cascade-ai 0.68.0 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -1810,7 +1810,7 @@ var CASCADE_VERSION, CASCADE_CONFIG_FILE, CASCADE_DB_FILE, CASCADE_DASHBOARD_SEC
1810
1810
  var init_constants = __esm({
1811
1811
  "src/constants.ts"() {
1812
1812
  init_pricing();
1813
- CASCADE_VERSION = "0.68.0";
1813
+ CASCADE_VERSION = "0.69.0";
1814
1814
  CASCADE_CONFIG_FILE = ".cascade/config.json";
1815
1815
  CASCADE_DB_FILE = ".cascade/memory.db";
1816
1816
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -8535,6 +8535,21 @@ var BaseTier = class extends EventEmitter__default.default {
8535
8535
  * which node (Cockpit node panel / Why panel).
8536
8536
  */
8537
8537
  servingModel;
8538
+ /**
8539
+ * This tier's identity IN THE TASK GRAPH — the section or subtask id the
8540
+ * planner assigned — plus where it sat in the dependency order.
8541
+ *
8542
+ * `id` and `graphNodeId` are deliberately different id spaces, and both are
8543
+ * needed. `id` identifies this tier INSTANCE ("T2_a1b2c3d4", minted per
8544
+ * construction); `graphNodeId` identifies the WORK ("s1"), which is what
8545
+ * dependency edges point at. Without the distinction a graph view cannot be
8546
+ * drawn at all: `dependsOn` lists section ids, while every running tier
8547
+ * reports a generated tier id, so every edge would name a node that never
8548
+ * appears in the stream.
8549
+ */
8550
+ graphNodeId;
8551
+ graphDependsOn;
8552
+ graphWave;
8538
8553
  constructor(role, id, parentId) {
8539
8554
  super();
8540
8555
  this.role = role;
@@ -8546,6 +8561,33 @@ var BaseTier = class extends EventEmitter__default.default {
8546
8561
  setPresenter(on = true) {
8547
8562
  this.isPresenter = on;
8548
8563
  }
8564
+ /**
8565
+ * Record where this tier sits in the compiled task graph.
8566
+ *
8567
+ * Called by whichever tier scheduled this one: the id and edges are known at
8568
+ * construction, the wave only once the scheduler reaches it, so the fields
8569
+ * are set independently rather than all at once.
8570
+ */
8571
+ setGraphPosition(position) {
8572
+ if (position.nodeId !== void 0) this.graphNodeId = position.nodeId;
8573
+ if (position.dependsOn !== void 0) this.graphDependsOn = [...position.dependsOn];
8574
+ if (position.wave !== void 0) this.graphWave = position.wave;
8575
+ }
8576
+ /**
8577
+ * The graph fields, for spreading into a status payload.
8578
+ *
8579
+ * One helper rather than two literals because `tier:status` is emitted from
8580
+ * two different call sites with two different payload shapes; adding a field
8581
+ * to only one of them makes it arrive intermittently, which is worse for a
8582
+ * consumer than never arriving at all.
8583
+ */
8584
+ graphFields() {
8585
+ return {
8586
+ nodeId: this.graphNodeId,
8587
+ dependsOn: this.graphDependsOn ? [...this.graphDependsOn] : void 0,
8588
+ waveId: this.graphWave
8589
+ };
8590
+ }
8549
8591
  getStatus() {
8550
8592
  return this.status;
8551
8593
  }
@@ -8560,7 +8602,8 @@ var BaseTier = class extends EventEmitter__default.default {
8560
8602
  status,
8561
8603
  timestamp,
8562
8604
  output,
8563
- model: this.servingModel
8605
+ model: this.servingModel,
8606
+ ...this.graphFields()
8564
8607
  };
8565
8608
  this.emit("status", event);
8566
8609
  this.emit("tier:status", event);
@@ -8600,7 +8643,8 @@ var BaseTier = class extends EventEmitter__default.default {
8600
8643
  progressPct: update.progressPct,
8601
8644
  timestamp,
8602
8645
  output: update.output,
8603
- model: this.servingModel
8646
+ model: this.servingModel,
8647
+ ...this.graphFields()
8604
8648
  });
8605
8649
  }
8606
8650
  buildMessage(type, to, payload) {
@@ -9503,7 +9547,8 @@ Now execute your subtask using this context where relevant.`
9503
9547
  if (result2.verdict === "passed") passed.push(result2.criterion);
9504
9548
  }
9505
9549
  this.sendStatusUpdate({ progressPct: 70, currentAction: "Self-testing output", status: "IN_PROGRESS" });
9506
- const testResult = await this.selfTest(assignment, output, undecided(acceptanceResults));
9550
+ const pendingAcceptance = undecided(acceptanceResults);
9551
+ const testResult = await this.selfTest(assignment, output, pendingAcceptance);
9507
9552
  checksRun.push(...testResult.checksRun);
9508
9553
  passed.push(...testResult.passed);
9509
9554
  failed.push(...testResult.failed);
@@ -9512,7 +9557,7 @@ Now execute your subtask using this context where relevant.`
9512
9557
  issues.push(`Initial check failed: ${testResult.failed.join(", ")}`);
9513
9558
  const corrected = await this.correctOutput(output, testResult.failed);
9514
9559
  output = corrected;
9515
- const retest = await this.selfTest(assignment, output);
9560
+ const retest = await this.selfTest(assignment, output, pendingAcceptance);
9516
9561
  passed.push(...retest.passed);
9517
9562
  if (retest.failed.length > 0) {
9518
9563
  failed.push(...retest.failed);
@@ -11437,6 +11482,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
11437
11482
  for (const a of assignments) {
11438
11483
  const worker = new T3Worker(this.router, this.toolRegistry, this.id);
11439
11484
  if (this.store) worker.setStore(this.store, taskId);
11485
+ worker.setGraphPosition({ nodeId: a.subtaskId, dependsOn: a.dependsOn ?? [] });
11440
11486
  worker.setPeerBus(this.t3PeerBus);
11441
11487
  if (this.permissionEscalator) worker.setPermissionEscalator(this.permissionEscalator);
11442
11488
  if (this.toolCreator) worker.setToolCreator(this.toolCreator);
@@ -11557,6 +11603,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
11557
11603
  runnableIds.push(fallbackId);
11558
11604
  }
11559
11605
  wave++;
11606
+ for (const id of runnableIds) workerMap.get(id)?.setGraphPosition({ wave });
11560
11607
  this.log(`Wave ${wave}: running ${runnableIds.length} subtask(s) in parallel`);
11561
11608
  this.sendStatusUpdate({
11562
11609
  progressPct: 20 + Math.min(wave * 10, 60),
@@ -11887,23 +11934,63 @@ var DependencyScheduler = class {
11887
11934
  dependents.get(dependencyId)?.push(node.id);
11888
11935
  }
11889
11936
  }
11890
- const completed = /* @__PURE__ */ new Set();
11937
+ const policy = this.options.onUpstreamFailure ?? "continue";
11938
+ const settled = /* @__PURE__ */ new Set();
11891
11939
  const results = /* @__PURE__ */ new Map();
11940
+ const blocked = /* @__PURE__ */ new Map();
11892
11941
  const waves = [];
11893
11942
  let wave = 0;
11894
- while (completed.size < nodes.length) {
11895
- const ready = nodes.filter((node) => !completed.has(node.id) && (inDegree.get(node.id) ?? 0) === 0);
11943
+ const blockDescendants = async (failedIds) => {
11944
+ const rootsFor = /* @__PURE__ */ new Map();
11945
+ const queue = [];
11946
+ for (const rootId of failedIds) {
11947
+ for (const childId of dependents.get(rootId) ?? []) queue.push({ id: childId, root: rootId });
11948
+ }
11949
+ while (queue.length > 0) {
11950
+ const { id, root } = queue.shift();
11951
+ if (settled.has(id)) continue;
11952
+ const known = rootsFor.get(id);
11953
+ if (known) {
11954
+ if (!known.has(root)) known.add(root);
11955
+ continue;
11956
+ }
11957
+ rootsFor.set(id, /* @__PURE__ */ new Set([root]));
11958
+ for (const childId of dependents.get(id) ?? []) queue.push({ id: childId, root });
11959
+ }
11960
+ for (const node of nodes) {
11961
+ const roots = rootsFor.get(node.id);
11962
+ if (!roots || settled.has(node.id)) continue;
11963
+ const causes = nodes.filter((n) => roots.has(n.id));
11964
+ const blockedBy = causes.map((n) => n.id);
11965
+ const blockedByTitles = causes.map((n) => n.title || n.id);
11966
+ const record = {
11967
+ blockedBy,
11968
+ blockedByTitles,
11969
+ reason: blockedByTitles.length === 1 ? `Required upstream work "${blockedByTitles[0]}" failed` : `Required upstream work ${blockedByTitles.map((t) => `"${t}"`).join(", ")} failed`
11970
+ };
11971
+ blocked.set(node.id, record);
11972
+ settled.add(node.id);
11973
+ await this.hooks.onNodeBlocked?.(node, record);
11974
+ }
11975
+ };
11976
+ while (settled.size < nodes.length) {
11977
+ const ready = nodes.filter((node) => !settled.has(node.id) && (inDegree.get(node.id) ?? 0) === 0);
11896
11978
  if (ready.length === 0) {
11897
11979
  throw new Error("Dependency scheduler received a cyclic or incomplete task graph");
11898
11980
  }
11899
11981
  waves.push(ready.map((node) => node.id));
11900
11982
  await this.hooks.onWaveStart?.(ready, wave);
11983
+ const outcomes = /* @__PURE__ */ new Map();
11901
11984
  const runOne = async (node) => {
11902
11985
  const result = await this.hooks.execute(node);
11903
11986
  results.set(node.id, result);
11904
- completed.add(node.id);
11905
- for (const dependentId of dependents.get(node.id) ?? []) {
11906
- inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
11987
+ settled.add(node.id);
11988
+ const outcome = this.hooks.classify?.(node, result) ?? "succeeded";
11989
+ outcomes.set(node.id, outcome);
11990
+ if (outcome === "failed" && policy === "block") ; else {
11991
+ for (const dependentId of dependents.get(node.id) ?? []) {
11992
+ inDegree.set(dependentId, Math.max(0, (inDegree.get(dependentId) ?? 1) - 1));
11993
+ }
11907
11994
  }
11908
11995
  await this.hooks.onNodeComplete?.(node, result);
11909
11996
  };
@@ -11912,9 +11999,13 @@ var DependencyScheduler = class {
11912
11999
  } else {
11913
12000
  await Promise.all(ready.map(runOne));
11914
12001
  }
12002
+ const failedThisWave = ready.filter((node) => outcomes.get(node.id) === "failed").map((node) => node.id);
12003
+ if (policy === "block" && failedThisWave.length > 0) {
12004
+ await blockDescendants(failedThisWave);
12005
+ }
11915
12006
  wave++;
11916
12007
  }
11917
- return { results, waves };
12008
+ return { results, waves, blocked };
11918
12009
  }
11919
12010
  };
11920
12011
 
@@ -12015,6 +12106,9 @@ QUALITY RULES:
12015
12106
 
12016
12107
  ${generation}` : ""}`;
12017
12108
  }
12109
+ function producedOutput(result) {
12110
+ return result.status === "COMPLETED" || result.status === "PARTIAL";
12111
+ }
12018
12112
  var T1Administrator = class extends BaseTier {
12019
12113
  router;
12020
12114
  toolRegistry;
@@ -12149,7 +12243,7 @@ Board guidance (must be followed in the plan): ${decision.note}`,
12149
12243
  let allT2Results = await this.dispatchT2Managers(plan.sections);
12150
12244
  let pass = 1;
12151
12245
  const maxReplanPasses = this.config.maxReplanPasses ?? 2;
12152
- const okCount = (rs) => rs.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL").length;
12246
+ const okCount = (rs) => rs.filter(producedOutput).length;
12153
12247
  while (pass <= maxReplanPasses) {
12154
12248
  const reviewResult = await this.reviewT2Outputs(enrichedPrompt, plan, allT2Results);
12155
12249
  if (reviewResult.approved) {
@@ -12280,7 +12374,7 @@ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomp
12280
12374
  /** Structured, grounded summary of what's already done — used to keep
12281
12375
  * corrective replan passes from re-emitting completed sections. */
12282
12376
  summarizeCompletedSections(results) {
12283
- const done = results.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL");
12377
+ const done = results.filter(producedOutput);
12284
12378
  if (done.length === 0) return "";
12285
12379
  const lines = done.map((r) => `- "${r.sectionTitle}" [${r.status}]: ${(r.sectionSummary || "(no summary)").slice(0, 300)}`);
12286
12380
  return `ALREADY COMPLETED SECTIONS (do not redo these \u2014 plan only what's still missing):
@@ -12425,6 +12519,7 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
12425
12519
  if (this.runBreaker) manager.setRunBreaker(this.runBreaker);
12426
12520
  if (this.escalationCallback) manager.setEscalationCallback(this.escalationCallback);
12427
12521
  manager.setHierarchyContext(`You are a T2 Manager for the section "${section.sectionTitle}". You are part of a COMPLEX task overseen by T1 Administrator.`);
12522
+ manager.setGraphPosition({ nodeId: section.sectionId, dependsOn: section.dependsOn ?? [] });
12428
12523
  if (this.store) {
12429
12524
  manager.setStore(this.store);
12430
12525
  }
@@ -12551,6 +12646,13 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
12551
12646
  const scheduler = new DependencyScheduler(
12552
12647
  compiled.graph,
12553
12648
  {
12649
+ // The wave is the scheduler's own counter, and it is the only place the
12650
+ // parallelism is visible: nodes sharing a wave ran at the same time.
12651
+ // Stamped before the wave executes so the first status event a node
12652
+ // emits already carries it.
12653
+ onWaveStart: (nodes, wave) => {
12654
+ for (const node of nodes) managers[node.ordinal]?.setGraphPosition({ wave });
12655
+ },
12554
12656
  execute: async (node) => {
12555
12657
  const section = node.payload;
12556
12658
  const manager = managers[node.ordinal];
@@ -12585,6 +12687,62 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
12585
12687
  };
12586
12688
  }
12587
12689
  },
12690
+ // PARTIAL is deliberately not a failure: a degraded-but-real section
12691
+ // can still feed the one after it, and cancelling that work would cost
12692
+ // the user more than letting it try.
12693
+ // BLOCKED cannot reach here today — the scheduler settles blocked nodes
12694
+ // through onNodeBlocked and never executes them, so classify only ever
12695
+ // sees results that ran. It is listed anyway because the failure mode if
12696
+ // that ever changes is silent and expensive: a blocked section would
12697
+ // classify as 'succeeded' and release its own dependents, so work that
12698
+ // should have been skipped would run and be billed.
12699
+ classify: (_node, result) => result.status === "FAILED" || result.status === "BLOCKED" ? "failed" : "succeeded",
12700
+ onNodeBlocked: (node, blockedInfo) => {
12701
+ const section = node.payload;
12702
+ this.log(`\u292B Skipped "${section.sectionTitle}" \u2014 ${blockedInfo.reason}`);
12703
+ resultMap.set(node.id, {
12704
+ sectionId: section.sectionId,
12705
+ sectionTitle: section.sectionTitle,
12706
+ // BLOCKED, not FAILED. This section was never attempted — saying it
12707
+ // failed reports a breakage that did not happen, and hides the one
12708
+ // that did (the upstream section named below).
12709
+ status: "BLOCKED",
12710
+ t3Results: [],
12711
+ sectionSummary: "",
12712
+ issues: [
12713
+ `${blockedInfo.reason}. This section was not attempted, so no tokens were spent on it.`
12714
+ ],
12715
+ blockedBy: { ids: [...blockedInfo.blockedBy], titles: [...blockedInfo.blockedByTitles] }
12716
+ });
12717
+ completedSections++;
12718
+ this.sendStatusUpdate({
12719
+ progressPct: 10 + Math.floor(completedSections / totalSections * 85),
12720
+ currentAction: `Skipped: ${section.sectionTitle} (blocked by an upstream failure)`,
12721
+ status: "IN_PROGRESS"
12722
+ });
12723
+ this.emit("tier:status", {
12724
+ tierId: section.sectionId,
12725
+ // A blocked section has no tier instance — it was never
12726
+ // constructed — so its runtime id and its graph id are the same
12727
+ // thing here. Emitting nodeId anyway keeps every node in the
12728
+ // stream addressable in the graph's id space, which is what
12729
+ // dependsOn edges point at.
12730
+ nodeId: section.sectionId,
12731
+ dependsOn: [...section.dependsOn ?? []],
12732
+ role: "T2",
12733
+ label: section.sectionTitle,
12734
+ status: "BLOCKED",
12735
+ blockedBy: [...blockedInfo.blockedByTitles],
12736
+ output: blockedInfo.reason
12737
+ });
12738
+ this.emit("section:complete", {
12739
+ id: node.id,
12740
+ title: section.sectionTitle,
12741
+ status: "BLOCKED",
12742
+ blockedBy: [...blockedInfo.blockedByTitles],
12743
+ output: ""
12744
+ });
12745
+ },
12588
12746
  onNodeComplete: (node, result) => {
12589
12747
  resultMap.set(node.id, result);
12590
12748
  completedSections++;
@@ -12601,14 +12759,18 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
12601
12759
  // intra-section T3 wave (t2-manager.ts) — 'sequential' otherwise only
12602
12760
  // serialized T3 workers within a single section while every section's
12603
12761
  // T2Manager (and its T3 workers) still ran in parallel here.
12604
- mode: this.router.getT3ExecutionMode?.() === "sequential" ? "sequential" : "parallel"
12762
+ mode: this.router.getT3ExecutionMode?.() === "sequential" ? "sequential" : "parallel",
12763
+ // A section that declared `dependsOn` said it needs that section's
12764
+ // output. Running it after the dependency failed produces a second
12765
+ // failure, billed, whose cause is the first one — so don't.
12766
+ onUpstreamFailure: "block"
12605
12767
  }
12606
12768
  );
12607
12769
  await scheduler.run();
12608
12770
  return sections.map((s) => resultMap.get(s.sectionId)).filter(Boolean);
12609
12771
  }
12610
12772
  async compileFinalOutput(originalPrompt, plan, t2Results) {
12611
- const completedSections = t2Results.filter((r) => r.status !== "FAILED");
12773
+ const completedSections = t2Results.filter(producedOutput);
12612
12774
  if (!completedSections.length) {
12613
12775
  const allIssues = [
12614
12776
  ...t2Results.flatMap((r) => r.issues ?? []),
@@ -12628,7 +12790,6 @@ ${r.sectionSummary}
12628
12790
  Outputs:
12629
12791
  ${r.t3Results.filter((t) => t.status === "COMPLETED").map((t) => `\u2022 ${typeof t.output === "string" ? t.output : JSON.stringify(t.output)}`).join("\n")}`).join("\n\n---\n\n");
12630
12792
  const openIssues = t2Results.flatMap((r) => r.issues).filter(Boolean);
12631
- t2Results.filter((r) => r.status === "FAILED" || r.status === "PARTIAL");
12632
12793
  const compilePrompt = `Compile a final, coherent response to the user's original request.
12633
12794
 
12634
12795
  Original request: ${originalPrompt}
@@ -13123,6 +13284,7 @@ Model: ${asset.modelId} \u2014 ${r.selected.reason}`;
13123
13284
  };
13124
13285
  var DEFAULT_VIDEO_SECONDS = 5;
13125
13286
  var MAX_VIDEO_SECONDS = 8;
13287
+ var MIN_VIDEO_SECONDS = 4;
13126
13288
  var GenerateVideoTool = class extends BaseTool {
13127
13289
  constructor(deps) {
13128
13290
  super();
@@ -13139,7 +13301,7 @@ var GenerateVideoTool = class extends BaseTool {
13139
13301
  type: "object",
13140
13302
  properties: {
13141
13303
  prompt: { type: "string", description: "What the video should show: subject, action, camera movement, style." },
13142
- seconds: { type: "number", description: `Clip length, 1-${MAX_VIDEO_SECONDS} seconds (default ${DEFAULT_VIDEO_SECONDS}). Billed per second \u2014 keep it short.` },
13304
+ seconds: { type: "number", description: `Clip length, ${MIN_VIDEO_SECONDS}-${MAX_VIDEO_SECONDS} seconds (default ${DEFAULT_VIDEO_SECONDS}). Billed per second \u2014 keep it short.` },
13143
13305
  aspectRatio: { type: "string", description: 'Frame shape: "16:9" (default) or "9:16" for vertical.' },
13144
13306
  model: { type: "string", description: "Optional: force a specific video model." }
13145
13307
  },
@@ -13151,7 +13313,7 @@ var GenerateVideoTool = class extends BaseTool {
13151
13313
  const r = resolve(this.deps, "video", input["model"] ? String(input["model"]) : void 0);
13152
13314
  if (!r.ok) return r.error;
13153
13315
  const asked = typeof input["seconds"] === "number" ? input["seconds"] : DEFAULT_VIDEO_SECONDS;
13154
- const seconds = Math.max(1, Math.min(MAX_VIDEO_SECONDS, Math.round(asked)));
13316
+ const seconds = Math.max(MIN_VIDEO_SECONDS, Math.min(MAX_VIDEO_SECONDS, Math.round(asked)));
13155
13317
  const asset = await generateVideo(
13156
13318
  r.selected.capability,
13157
13319
  r.cfg,
@@ -13168,7 +13330,7 @@ var GenerateVideoTool = class extends BaseTool {
13168
13330
  const location = await this.deps.sink(asset);
13169
13331
  const unit = r.selected.cost;
13170
13332
  const estimate = unit ? ` Estimated cost: $${(unit.amount * seconds).toFixed(2)} (${seconds}s x ${unit.label}).` : "";
13171
- const clamped = seconds !== Math.round(asked) ? ` Duration was clamped to ${seconds}s (max ${MAX_VIDEO_SECONDS}).` : "";
13333
+ const clamped = seconds !== Math.round(asked) ? ` Duration was clamped to ${seconds}s (allowed ${MIN_VIDEO_SECONDS}-${MAX_VIDEO_SECONDS}).` : "";
13172
13334
  return `Video generated and saved to ${location}.
13173
13335
  Model: ${asset.modelId}.${estimate}${clamped}`;
13174
13336
  }
@@ -16309,7 +16471,24 @@ var TaskAnalyzer = class {
16309
16471
  feedback;
16310
16472
  bias;
16311
16473
  lastProfile = null;
16312
- lastSelectedModels = /* @__PURE__ */ new Map();
16474
+ /** Models chosen by the run currently in flight. Cleared when it completes. */
16475
+ currentRunSelections = /* @__PURE__ */ new Map();
16476
+ /**
16477
+ * Immutable snapshot of the last COMPLETED run: its selections AND the task
16478
+ * type they were chosen for.
16479
+ *
16480
+ * The task type has to travel WITH the selections. `lastProfile` is overwritten
16481
+ * by the next analyze(), so if run B started before the user rated run A, A's
16482
+ * models were credited under B's task type — teaching the router that a coding
16483
+ * model is good at creative writing, from a rating that never said so.
16484
+ *
16485
+ * Explicit ratings arrive after the run finishes — rateLastRun() is by nature
16486
+ * a reaction to a finished answer — but the run finalizer calls
16487
+ * recordRunOutcome() first, and that used to clear the only map there was. So
16488
+ * every explicit rating iterated an empty map, recorded nothing, and returned
16489
+ * false: the 3x-weighted user signal never reached the tracker at all.
16490
+ */
16491
+ lastCompletedRun;
16313
16492
  constructor(tracker, bias = "balanced") {
16314
16493
  this.tracker = tracker;
16315
16494
  this.bias = bias;
@@ -16356,11 +16535,15 @@ var TaskAnalyzer = class {
16356
16535
  */
16357
16536
  async selectModel(prompt, tier, selector, opts) {
16358
16537
  const profile = await this.analyze(prompt);
16538
+ const recordSelection = (model) => {
16539
+ if (model) this.currentRunSelections.set(tier, model);
16540
+ return model;
16541
+ };
16359
16542
  if (profile.requiresVision) {
16360
- return selector.selectVisionModel();
16543
+ return recordSelection(selector.selectVisionModel());
16361
16544
  }
16362
16545
  let candidates2 = selector.getCandidatesForTier(tier);
16363
- if (candidates2.length === 0) return selector.selectForTier(tier);
16546
+ if (candidates2.length === 0) return recordSelection(selector.selectForTier(tier));
16364
16547
  if (opts?.requiresToolUse) {
16365
16548
  const toolCapable = candidates2.filter((m) => m.supportsToolUse !== false);
16366
16549
  if (toolCapable.length > 0) candidates2 = toolCapable;
@@ -16370,9 +16553,7 @@ var TaskAnalyzer = class {
16370
16553
  score: this.scoreModel(m, profile)
16371
16554
  }));
16372
16555
  scored.sort((a, b) => b.score - a.score);
16373
- const best = scored[0]?.model ?? selector.selectForTier(tier);
16374
- if (best) this.lastSelectedModels.set(tier, best);
16375
- return best;
16556
+ return recordSelection(scored[0]?.model ?? selector.selectForTier(tier));
16376
16557
  }
16377
16558
  /**
16378
16559
  * Record the outcome of a completed run across all tiers that were selected
@@ -16381,25 +16562,38 @@ var TaskAnalyzer = class {
16381
16562
  recordRunOutcome(outcome, costByTier, contextTokens = 0) {
16382
16563
  if (!this.tracker || !this.lastProfile) return;
16383
16564
  const taskType = this.lastProfile.type;
16384
- for (const [tier, model] of this.lastSelectedModels) {
16565
+ for (const [tier, model] of this.currentRunSelections) {
16385
16566
  const cost = costByTier[tier] ?? 0;
16386
16567
  this.tracker.record(model.id, taskType, outcome, 0, cost, contextTokens);
16387
16568
  }
16388
- this.lastSelectedModels.clear();
16569
+ this.lastCompletedRun = { selections: new Map(this.currentRunSelections), taskType };
16570
+ this.currentRunSelections.clear();
16389
16571
  void this.tracker.save();
16390
16572
  }
16391
16573
  /**
16392
- * Record an explicit user rating (good/bad) for the last run's selected models.
16574
+ * Record an explicit user rating (good/bad) for the last COMPLETED run's models.
16393
16575
  * Explicit ratings carry 3× the weight of auto-detected outcomes.
16394
- * Does NOT clear lastSelectedModels — the auto record already did that.
16576
+ *
16577
+ * Reads the completed-run snapshot, not the in-flight map: by the time a user
16578
+ * can rate an answer, the finalizer has already run and the in-flight map is
16579
+ * empty.
16580
+ *
16581
+ * The snapshot is CONSUMED. recordExplicit() weights a rating 3x by recording
16582
+ * three samples, so a double submit would inject six — a stutter on the
16583
+ * button would count as two opinions. Consuming makes a second call a no-op
16584
+ * that reports false, which is what "rate this run" should mean.
16395
16585
  */
16396
16586
  recordExplicitRating(rating) {
16397
- if (!this.tracker || !this.lastProfile) return false;
16398
- const taskType = this.lastProfile.type;
16399
- for (const [, model] of this.lastSelectedModels) {
16400
- this.tracker.recordExplicit(model.id, taskType, rating, 0);
16401
- }
16402
- return this.lastSelectedModels.size > 0;
16587
+ const snapshot = this.lastCompletedRun;
16588
+ if (!this.tracker || !snapshot || snapshot.selections.size === 0) return false;
16589
+ const ratedModels = /* @__PURE__ */ new Set();
16590
+ for (const [, model] of snapshot.selections) {
16591
+ if (ratedModels.has(model.id)) continue;
16592
+ ratedModels.add(model.id);
16593
+ this.tracker.recordExplicit(model.id, snapshot.taskType, rating, 0);
16594
+ }
16595
+ this.lastCompletedRun = void 0;
16596
+ return true;
16403
16597
  }
16404
16598
  scoreModel(model, profile) {
16405
16599
  const perf = this.tracker?.performanceScore(model.id, profile.type) ?? 0.5;
@@ -17440,6 +17634,8 @@ var RESUME_SCHEMA_VERSION = 1;
17440
17634
  var DEFAULT_MAX_CHECKPOINTS = 5;
17441
17635
  var DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
17442
17636
  var FILE_PATTERN = /^(\d+)-(.+)\.json$/;
17637
+ var CLAIM_SUFFIX = ".claimed";
17638
+ var DEFAULT_CLAIM_TIMEOUT_MS = 10 * 60 * 1e3;
17443
17639
  function safeId(taskId) {
17444
17640
  const cleaned = taskId.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.{2,}/g, "_");
17445
17641
  return cleaned.slice(0, 100) || "run";
@@ -17454,16 +17650,24 @@ var ResumeStore = class {
17454
17650
  maxCheckpoints;
17455
17651
  maxAgeMs;
17456
17652
  now;
17653
+ claimTimeoutMs;
17457
17654
  constructor(options) {
17458
17655
  this.dir = options.dir;
17459
17656
  this.maxCheckpoints = options.maxCheckpoints ?? DEFAULT_MAX_CHECKPOINTS;
17460
17657
  this.maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
17461
17658
  this.now = options.now ?? Date.now;
17659
+ this.claimTimeoutMs = options.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;
17462
17660
  }
17463
17661
  /**
17464
- * Persist a checkpoint and prune. Never throws: failing to save a resume file
17465
- * must not turn a recoverable stop into a crash, which would be a strictly
17466
- * worse outcome than the one this store exists to improve.
17662
+ * Persist a checkpoint and prune.
17663
+ *
17664
+ * Never throws — failing to save a resume file must not turn a recoverable
17665
+ * stop into a crash — but DOES report whether the atomic rename landed.
17666
+ * Callers decide whether an original claim is safe to discard based on that
17667
+ * answer, so "I swallowed the error" and "the checkpoint is on disk" must not
17668
+ * look alike to them.
17669
+ *
17670
+ * @returns true only when the checkpoint is durably on disk.
17467
17671
  */
17468
17672
  async save(checkpoint) {
17469
17673
  try {
@@ -17479,7 +17683,9 @@ var ResumeStore = class {
17479
17683
  await fs31.promises.writeFile(tempPath, JSON.stringify(record, null, 2), "utf-8");
17480
17684
  await fs31.promises.rename(tempPath, finalPath);
17481
17685
  await this.prune();
17686
+ return true;
17482
17687
  } catch {
17688
+ return false;
17483
17689
  }
17484
17690
  }
17485
17691
  /** Valid, unexpired checkpoints, newest first. */
@@ -17509,15 +17715,87 @@ var ResumeStore = class {
17509
17715
  return (await this.list())[0] ?? null;
17510
17716
  }
17511
17717
  /**
17512
- * Load a checkpoint and delete it in the same step. Resuming twice from one
17513
- * checkpoint would re-run restored work, so consumption is part of reading.
17718
+ * Claim a checkpoint for a resume attempt.
17719
+ *
17720
+ * This used to DELETE the file and return it, which opened a window with no
17721
+ * recovery record at all: between preparing the continuation and the resumed
17722
+ * run writing its own checkpoint, a crash lost the only copy — and a crash is
17723
+ * precisely what this store exists to survive. Deleting on read optimised for
17724
+ * "never resume twice" at the cost of "never lose the work", which is the
17725
+ * wrong trade for a recovery mechanism.
17726
+ *
17727
+ * Instead the file is RENAMED to `.claimed`, which still hides it from
17728
+ * `list()` (so a concurrent resume cannot take the same work) but keeps it on
17729
+ * disk. The caller then either `release`s it — restoring it for another
17730
+ * attempt — or `settle`s it once the resumed run is safely under way. A claim
17731
+ * abandoned by a dead process is reclaimed after `claimTimeoutMs`.
17514
17732
  */
17515
- async consume(taskId) {
17733
+ async claim(taskId) {
17516
17734
  const all = await this.list();
17517
17735
  const target = taskId ? all.find((c) => c.taskId === taskId) : all[0];
17518
17736
  if (!target) return null;
17519
- await this.delete(target.taskId);
17520
- return target;
17737
+ try {
17738
+ const entries = await fs31.promises.readdir(this.dir);
17739
+ const wanted = safeId(target.taskId);
17740
+ const source = entries.find((name) => FILE_PATTERN.exec(name)?.[2] === wanted);
17741
+ if (!source) return null;
17742
+ const claimId = `${this.now()}-${safeId(target.taskId)}`;
17743
+ await fs31.promises.rename(path34__default.default.join(this.dir, source), path34__default.default.join(this.dir, `${claimId}${CLAIM_SUFFIX}`));
17744
+ return { checkpoint: target, claimId };
17745
+ } catch {
17746
+ return null;
17747
+ }
17748
+ }
17749
+ /** Give a claim back, so the work stays recoverable after a failed start. */
17750
+ async release(claimId) {
17751
+ try {
17752
+ await fs31.promises.rename(
17753
+ path34__default.default.join(this.dir, `${claimId}${CLAIM_SUFFIX}`),
17754
+ path34__default.default.join(this.dir, `${claimId}.json`)
17755
+ );
17756
+ } catch {
17757
+ }
17758
+ }
17759
+ /** Discard a claim for good — the resumed run has taken ownership. */
17760
+ async settle(claimId) {
17761
+ try {
17762
+ await fs31.promises.rm(path34__default.default.join(this.dir, `${claimId}${CLAIM_SUFFIX}`), { force: true });
17763
+ } catch {
17764
+ }
17765
+ }
17766
+ /**
17767
+ * Claim-and-discard in one step. Kept for callers that genuinely want the old
17768
+ * semantics; prefer claim/settle so a crash mid-resume stays recoverable.
17769
+ */
17770
+ async consume(taskId) {
17771
+ const claimed = await this.claim(taskId);
17772
+ if (!claimed) return null;
17773
+ await this.settle(claimed.claimId);
17774
+ return claimed.checkpoint;
17775
+ }
17776
+ /**
17777
+ * Return claims abandoned by a process that died mid-resume. Without this a
17778
+ * crash between claim and settle would strand the checkpoint invisibly —
17779
+ * trading one loss window for another.
17780
+ */
17781
+ async reclaimStale() {
17782
+ let reclaimed = 0;
17783
+ try {
17784
+ const entries = await fs31.promises.readdir(this.dir);
17785
+ const cutoff = this.now() - this.claimTimeoutMs;
17786
+ for (const name of entries) {
17787
+ if (!name.endsWith(CLAIM_SUFFIX)) continue;
17788
+ const stamp2 = Number(/^(\d+)-/.exec(name)?.[1]);
17789
+ if (!Number.isFinite(stamp2) || stamp2 >= cutoff) continue;
17790
+ await fs31.promises.rename(
17791
+ path34__default.default.join(this.dir, name),
17792
+ path34__default.default.join(this.dir, `${name.slice(0, -CLAIM_SUFFIX.length)}.json`)
17793
+ );
17794
+ reclaimed++;
17795
+ }
17796
+ } catch {
17797
+ }
17798
+ return reclaimed;
17521
17799
  }
17522
17800
  /** Remove every checkpoint for a task id. */
17523
17801
  async delete(taskId) {
@@ -18534,6 +18812,25 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
18534
18812
  lastInterruptedRun;
18535
18813
  /** Durable resume checkpoints. See core/orchestration/resume-store.ts. */
18536
18814
  resumeStore;
18815
+ /** Claim held by an in-flight resume, settled once the run owns the work. */
18816
+ claimedResumeId;
18817
+ /**
18818
+ * Facts inherited from the checkpoint this run is resuming, so its own
18819
+ * checkpoint is cumulative rather than only covering this attempt.
18820
+ */
18821
+ resumedFrom;
18822
+ /** Whether THIS run persisted a checkpoint of its own. Drives settle-vs-release. */
18823
+ wroteCheckpointThisRun = false;
18824
+ /**
18825
+ * Whether the run reached the end of its normal path.
18826
+ *
18827
+ * NOT the same as `runError == null`: the cancellation and budget handlers
18828
+ * both null `runError` deliberately (an intentional stop is not a telemetry
18829
+ * error) and the breaker path never sets it at all. Treating that as success
18830
+ * meant an immediate cancel — no output, no replacement checkpoint — deleted
18831
+ * the original claim and the finished sections with it.
18832
+ */
18833
+ completedNormally = false;
18537
18834
  /** Section results of the CURRENT run, so an interruption can checkpoint them. */
18538
18835
  currentRunCompleted = [];
18539
18836
  initPromise;
@@ -19051,17 +19348,31 @@ ${input}` }],
19051
19348
  * worse. Nothing here is allowed to throw.
19052
19349
  */
19053
19350
  async checkpointRun(taskId, prompt, partialOutput, reason, detail) {
19054
- if (!this.currentRunCompleted.length && !partialOutput) return;
19351
+ const inherited = this.resumedFrom?.completed ?? [];
19352
+ const merged = /* @__PURE__ */ new Map();
19353
+ for (const node of inherited) merged.set(node.id, node);
19354
+ for (const node of this.currentRunCompleted) {
19355
+ const existing = merged.get(node.id);
19356
+ if (existing?.status === "COMPLETED" && node.status !== "COMPLETED") continue;
19357
+ merged.set(node.id, node);
19358
+ }
19359
+ const completed = [...merged.values()];
19360
+ const canonicalPrompt = this.resumedFrom?.prompt ?? prompt;
19361
+ const carriedPartial = partialOutput || (this.resumedFrom?.partialOutput ?? "");
19362
+ if (!completed.length && !carriedPartial) return false;
19055
19363
  try {
19056
- await this.resumeStore?.save({
19364
+ const saved = await this.resumeStore?.save({
19057
19365
  taskId,
19058
- prompt,
19366
+ prompt: canonicalPrompt,
19059
19367
  reason,
19060
19368
  ...detail ? { detail } : {},
19061
- partialOutput,
19062
- completed: [...this.currentRunCompleted]
19369
+ partialOutput: carriedPartial,
19370
+ completed
19063
19371
  });
19372
+ this.wroteCheckpointThisRun = saved === true;
19373
+ return saved === true;
19064
19374
  } catch {
19375
+ return false;
19065
19376
  }
19066
19377
  }
19067
19378
  /** True when a task can be resumed via /continue (this process or a previous one). */
@@ -19099,11 +19410,55 @@ ${input}` }],
19099
19410
  * checkpoint, so the same finished work is never restored into two runs.
19100
19411
  */
19101
19412
  async prepareDurableResume(opts = {}) {
19102
- const checkpoint = await this.resumeStore?.consume(opts.taskId);
19103
- if (!checkpoint) return null;
19104
- this.lastInterruptedRun = void 0;
19105
- this.raiseBudgetForResume(opts.maxTokens);
19106
- return this.buildResumePrompt(checkpoint.prompt, checkpoint.partialOutput, checkpoint.completed);
19413
+ await this.resumeStore?.reclaimStale();
19414
+ const claimed = await this.resumeStore?.claim(opts.taskId);
19415
+ if (!claimed) return null;
19416
+ try {
19417
+ this.lastInterruptedRun = void 0;
19418
+ this.raiseBudgetForResume(opts.maxTokens);
19419
+ const prompt = this.buildResumePrompt(
19420
+ claimed.checkpoint.prompt,
19421
+ claimed.checkpoint.partialOutput,
19422
+ claimed.checkpoint.completed
19423
+ );
19424
+ this.resumedFrom = {
19425
+ prompt: claimed.checkpoint.prompt,
19426
+ completed: claimed.checkpoint.completed,
19427
+ partialOutput: claimed.checkpoint.partialOutput
19428
+ };
19429
+ this.claimedResumeId = claimed.claimId;
19430
+ return prompt;
19431
+ } catch (err) {
19432
+ await this.resumeStore?.release(claimed.claimId);
19433
+ throw err;
19434
+ }
19435
+ }
19436
+ /**
19437
+ * Discard the claimed checkpoint once the resumed run has taken ownership of
19438
+ * the work (it has produced its own checkpoint, or finished). Called by the
19439
+ * run loop; safe to call when nothing is claimed.
19440
+ */
19441
+ async settleClaimedResume(succeeded) {
19442
+ const claimId = this.claimedResumeId;
19443
+ if (!claimId) return;
19444
+ this.claimedResumeId = void 0;
19445
+ if (succeeded || this.wroteCheckpointThisRun) {
19446
+ await this.resumeStore?.settle(claimId);
19447
+ } else {
19448
+ await this.resumeStore?.release(claimId);
19449
+ }
19450
+ }
19451
+ /**
19452
+ * Hand back a claim taken by prepareDurableResume() when the caller never
19453
+ * gets as far as run(). The REPL prepares the continuation and submits it
19454
+ * separately, so a failure in between would otherwise strand the checkpoint
19455
+ * for the whole lease timeout before reclaimStale() could recover it.
19456
+ */
19457
+ async releaseClaimedResume() {
19458
+ const claimId = this.claimedResumeId;
19459
+ if (!claimId) return;
19460
+ this.claimedResumeId = void 0;
19461
+ await this.resumeStore?.release(claimId);
19107
19462
  }
19108
19463
  /** A resume gets a bigger budget — the previous attempt exhausted the old one. */
19109
19464
  raiseBudgetForResume(maxTokens) {
@@ -19139,7 +19494,12 @@ ${partialOutput}` : ""
19139
19494
  async resumeRun(opts = {}) {
19140
19495
  const prompt = await this.prepareDurableResume(opts) ?? this.prepareResume(opts);
19141
19496
  if (!prompt) return null;
19142
- return this.run({ prompt });
19497
+ try {
19498
+ return await this.run({ prompt });
19499
+ } catch (err) {
19500
+ await this.releaseClaimedResume();
19501
+ throw err;
19502
+ }
19143
19503
  }
19144
19504
  getWorkspacePath() {
19145
19505
  return this.workspacePath;
@@ -19509,6 +19869,9 @@ ${prompt}` : prompt;
19509
19869
  const taskId = crypto.randomUUID();
19510
19870
  this.decisionLog = [];
19511
19871
  this.currentRunCompleted = [];
19872
+ this.wroteCheckpointThisRun = false;
19873
+ this.completedNormally = false;
19874
+ if (!this.claimedResumeId) this.resumedFrom = void 0;
19512
19875
  if (options.fastAnswer) {
19513
19876
  return this.runFastAnswer(options, startMs, taskId);
19514
19877
  }
@@ -19787,6 +20150,7 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
19787
20150
  ${finalOutput}` : ""
19788
20151
  ].join("\n").trimEnd();
19789
20152
  }
20153
+ this.completedNormally = trip == null;
19790
20154
  } catch (err) {
19791
20155
  if (err instanceof CascadeCancelledError || err instanceof Error && err.name === "AbortError" || options.signal?.aborted) {
19792
20156
  this.emit("run:cancelled", {
@@ -19828,6 +20192,10 @@ ${finalOutput}` : ""
19828
20192
  escalator.cancelAllPending();
19829
20193
  } catch {
19830
20194
  }
20195
+ try {
20196
+ await this.settleClaimedResume(this.completedNormally);
20197
+ } catch {
20198
+ }
19831
20199
  try {
19832
20200
  this.releasePendingEscalations();
19833
20201
  } catch {
@@ -21018,7 +21386,8 @@ function AgentTreeInternal({
21018
21386
  ] }),
21019
21387
  node.status === "COMPLETED" && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: theme.colors.success, children: " \u2714" }),
21020
21388
  node.status === "FAILED" && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: theme.colors.error, children: " \u2718" }),
21021
- node.status === "ESCALATED" && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: theme.colors.warning, children: " \u25B2" })
21389
+ node.status === "ESCALATED" && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: theme.colors.warning, children: " \u25B2" }),
21390
+ node.status === "BLOCKED" && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: theme.colors.muted, children: " \u292B skipped" })
21022
21391
  ] }, `t2-${node.id}-${i}`);
21023
21392
  }
21024
21393
  if (row.type === "t3") {
@@ -21043,7 +21412,8 @@ function AgentTreeInternal({
21043
21412
  /* @__PURE__ */ jsxRuntime.jsx(Spinner__default.default, { type: "dots" })
21044
21413
  ] }),
21045
21414
  node.status === "COMPLETED" && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: theme.colors.success, children: " \u2714" }),
21046
- node.status === "FAILED" && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: theme.colors.error, children: " \u2718" })
21415
+ node.status === "FAILED" && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: theme.colors.error, children: " \u2718" }),
21416
+ node.status === "BLOCKED" && /* @__PURE__ */ jsxRuntime.jsx(ink.Text, { color: theme.colors.muted, children: " \u292B skipped" })
21047
21417
  ] }, `t3-${node.id}-${i}`);
21048
21418
  }
21049
21419
  return null;
@@ -22042,7 +22412,7 @@ function Repl({ config, workspacePath, themeName, initialPrompt, identityName, a
22042
22412
  }, []);
22043
22413
  const recordNodeEvent = React2.useCallback((event) => {
22044
22414
  const existing = treeNodesRef.current.get(event.tierId);
22045
- const node = { id: event.tierId, role: event.role ?? existing?.role ?? "T3", label: event.label ?? existing?.label ?? event.tierId, status: event.status ?? existing?.status ?? "IDLE", currentAction: event.currentAction ?? existing?.currentAction, progressPct: event.progressPct ?? existing?.progressPct, parentId: event.parentId ?? existing?.parentId, children: [] };
22415
+ const node = { id: event.tierId, role: event.role ?? existing?.role ?? "T3", label: event.label ?? existing?.label ?? event.tierId, status: event.status ?? existing?.status ?? "IDLE", currentAction: event.currentAction ?? existing?.currentAction, progressPct: event.progressPct ?? existing?.progressPct, parentId: event.parentId ?? existing?.parentId, nodeId: event.nodeId ?? existing?.nodeId, dependsOn: event.dependsOn ?? existing?.dependsOn, waveId: event.waveId ?? existing?.waveId, blockedBy: event.blockedBy ?? existing?.blockedBy, children: [] };
22046
22416
  treeNodesRef.current.set(event.tierId, node);
22047
22417
  const store = storeRef.current;
22048
22418
  if (store) {
@@ -22379,13 +22749,21 @@ ${lastUser.content}`;
22379
22749
  onContinue: async (args) => {
22380
22750
  const cascade = cascadeRef.current;
22381
22751
  if (!cascade) return "Not ready yet.";
22752
+ if (state.isExecuting) {
22753
+ return "A task is already running \u2014 wait for it to finish (or press Esc to stop it) before using /continue.";
22754
+ }
22382
22755
  const n = args[0] ? parseInt(args[0].replace(/[^0-9]/g, ""), 10) : NaN;
22383
22756
  const opts = Number.isFinite(n) && n > 0 ? { maxTokens: n } : {};
22384
22757
  const prompt = await cascade.prepareDurableResume(opts) ?? cascade.prepareResume(opts);
22385
22758
  if (!prompt) {
22386
22759
  return "Nothing to continue \u2014 no interrupted task was found for this workspace.";
22387
22760
  }
22388
- await handleSubmit(prompt);
22761
+ try {
22762
+ await handleSubmit(prompt);
22763
+ } catch (err) {
22764
+ await cascade.releaseClaimedResume();
22765
+ throw err;
22766
+ }
22389
22767
  return "";
22390
22768
  }
22391
22769
  });
@@ -24878,8 +25256,14 @@ var DashboardSocket = class {
24878
25256
  emitCascadeEvent(ev) {
24879
25257
  this.io.emit("cascade:event", ev);
24880
25258
  }
24881
- emitTierStatus(tierId, role, status, sessionId, action) {
24882
- const payload = { tierId, role, status, action, timestamp: (/* @__PURE__ */ new Date()).toISOString(), sessionId };
25259
+ /**
25260
+ * `extra` exists because this signature is positional: every field the tier
25261
+ * emits that is not named here is silently dropped on this path, while the
25262
+ * other two forwarders spread the whole event through. That asymmetry is how
25263
+ * a field can reach the web dashboard from one code path and not another.
25264
+ */
25265
+ emitTierStatus(tierId, role, status, sessionId, action, extra) {
25266
+ const payload = { tierId, role, status, action, timestamp: (/* @__PURE__ */ new Date()).toISOString(), sessionId, ...extra };
24883
25267
  this.io.emit("tier:status", payload);
24884
25268
  this.io.to(`session:${sessionId}`).emit("tier:status", payload);
24885
25269
  }