@serkanalgur/opencode-nexus 2.4.1 → 2.6.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.
Files changed (3) hide show
  1. package/dist/index.js +696 -110
  2. package/dist/tui.js +89 -60
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8451,7 +8451,7 @@ function describeFile(file) {
8451
8451
  }
8452
8452
  function formatConfigLoadLog(info) {
8453
8453
  const models = Object.entries(info.models).map(([role, model]) => `${role}=${model}`).join(" ");
8454
- const override = info.sessionOverride ? " (+session override: storage; disk edits to models are IGNORED while a preset is set — clear the preset in the TUI to hand control back to disk)" : "";
8454
+ const override = info.sessionOverride ? ' (+session override: storage; disk edits to models are IGNORED while a preset is set — call the preset tool with mode "clear", or reset in the TUI, to hand control back to disk)' : "";
8455
8455
  return `[nexus] config loaded (#${info.loadCount} trigger=${info.trigger} at=${info.loadedAt}) ` + `project=${info.project.path} [${describeFile(info.project)}] ` + `global=${info.global.path} [${describeFile(info.global)}] ` + `models:${override} ${models}`;
8456
8456
  }
8457
8457
  var DEFAULT_CONFIG = {
@@ -8668,7 +8668,9 @@ class NexusConfigManager {
8668
8668
  return result;
8669
8669
  }
8670
8670
  resetToDefaults() {
8671
+ const hadOverride = this.storageConfig !== null;
8671
8672
  this.storageConfig = null;
8673
+ return hadOverride;
8672
8674
  }
8673
8675
  applyPreset(name) {
8674
8676
  const preset = PRESETS[name];
@@ -9797,10 +9799,20 @@ class PerformanceTracker {
9797
9799
  this.maxEntries = maxEntries;
9798
9800
  }
9799
9801
  record(entry) {
9800
- this.entries.push({ ...entry, timestamp: new Date });
9802
+ const id = `perf-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`;
9803
+ this.entries.push({ ...entry, id, timestamp: new Date });
9801
9804
  if (this.entries.length > this.maxEntries) {
9802
9805
  this.entries = this.entries.slice(-this.maxEntries);
9803
9806
  }
9807
+ return id;
9808
+ }
9809
+ adjust(id, { cost }) {
9810
+ const entry = this.entries.find((e) => e.id === id);
9811
+ if (!entry)
9812
+ return false;
9813
+ if (cost !== undefined)
9814
+ entry.cost += cost;
9815
+ return true;
9804
9816
  }
9805
9817
  getScores() {
9806
9818
  const groups = new Map;
@@ -9818,11 +9830,28 @@ class PerformanceTracker {
9818
9830
  const successRate = total > 0 ? successful / total : 0;
9819
9831
  const avgDuration = entries.reduce((s, e) => s + e.duration, 0) / total;
9820
9832
  const avgCost = entries.reduce((s, e) => s + e.cost, 0) / total;
9821
- const costEfficiency = avgCost > 0 ? successRate / avgCost : 0;
9833
+ const measured = entries.filter((e) => e.costProvenance.usage === "measured");
9834
+ const measuredTasks = measured.length;
9835
+ const estimatedTasks = total - measuredTasks;
9836
+ const measuredSuccessRate = measuredTasks > 0 ? measured.filter((e) => e.success).length / measuredTasks : 0;
9837
+ const measuredAvgCost = measuredTasks > 0 ? measured.reduce((s, e) => s + e.cost, 0) / measuredTasks : 0;
9838
+ const costEfficiency = measuredAvgCost > 0 ? measuredSuccessRate / measuredAvgCost : 0;
9822
9839
  const speedScore = Math.max(0, 1 - avgDuration / 30000);
9823
9840
  const costScore = Math.min(1, costEfficiency * 100);
9824
9841
  const overallScore = successRate * 40 + speedScore * 30 + costScore * 30;
9825
- scores.push({ model, role, totalTasks: total, successRate, avgDuration, avgCost, costEfficiency, overallScore });
9842
+ scores.push({
9843
+ model,
9844
+ role,
9845
+ totalTasks: total,
9846
+ successRate,
9847
+ avgDuration,
9848
+ avgCost,
9849
+ costEfficiency,
9850
+ overallScore,
9851
+ measuredTasks,
9852
+ estimatedTasks,
9853
+ costBasis: measuredTasks > 0 ? "measured" : "none"
9854
+ });
9826
9855
  }
9827
9856
  return scores.sort((a, b) => b.overallScore - a.overallScore);
9828
9857
  }
@@ -9858,6 +9887,16 @@ class ExecutionHistory {
9858
9887
  }
9859
9888
  return full;
9860
9889
  }
9890
+ adjust(id, { cost, tokensUsed }) {
9891
+ const record = this.records.find((r) => r.id === id);
9892
+ if (!record)
9893
+ return false;
9894
+ if (cost !== undefined)
9895
+ record.cost += cost;
9896
+ if (tokensUsed !== undefined)
9897
+ record.tokensUsed += tokensUsed;
9898
+ return true;
9899
+ }
9861
9900
  getAll() {
9862
9901
  return [...this.records];
9863
9902
  }
@@ -9879,7 +9918,17 @@ class ExecutionHistory {
9879
9918
  for (const r of this.records) {
9880
9919
  byRole[r.role] = (byRole[r.role] || 0) + 1;
9881
9920
  }
9882
- return { total, successRate: total > 0 ? successful / total : 0, totalCost, avgDuration, byRole };
9921
+ const costSplit = this.records.reduce((split, r) => {
9922
+ if (r.costProvenance.usage === "measured") {
9923
+ split.measuredCost += r.cost;
9924
+ split.measuredEntries++;
9925
+ } else {
9926
+ split.estimatedCost += r.cost;
9927
+ split.estimatedEntries++;
9928
+ }
9929
+ return split;
9930
+ }, { measuredCost: 0, estimatedCost: 0, measuredEntries: 0, estimatedEntries: 0 });
9931
+ return { total, successRate: total > 0 ? successful / total : 0, totalCost, costSplit, avgDuration, byRole };
9883
9932
  }
9884
9933
  clear() {
9885
9934
  this.records = [];
@@ -9920,33 +9969,122 @@ class CustomRoleManager {
9920
9969
  }
9921
9970
 
9922
9971
  // src/forecast.ts
9972
+ var CACHE_READ_MULTIPLE = 0.1;
9973
+ var CACHE_WRITE_MULTIPLE = 1.25;
9974
+ function per1kPricing(input, output) {
9975
+ return {
9976
+ input,
9977
+ output,
9978
+ cacheRead: input * CACHE_READ_MULTIPLE,
9979
+ cacheWrite: input * CACHE_WRITE_MULTIPLE
9980
+ };
9981
+ }
9982
+ function untiered(rates) {
9983
+ return { tiers: [{ rates }] };
9984
+ }
9985
+ var FALLBACK_PRICING_PER_1K = {
9986
+ "claude-sonnet-4-6": untiered(per1kPricing(0.015, 0.075)),
9987
+ "claude-opus-4-7": untiered(per1kPricing(0.075, 0.375)),
9988
+ "claude-haiku-4-5": untiered(per1kPricing(0.001, 0.005)),
9989
+ "gpt-5-mini": untiered(per1kPricing(0.00015, 0.0006)),
9990
+ "gpt-5": untiered(per1kPricing(0.0025, 0.01)),
9991
+ "gemini-2.5-flash": untiered(per1kPricing(0.000075, 0.0003)),
9992
+ "minimax-m2.5-free": untiered(per1kPricing(0, 0))
9993
+ };
9994
+ var UNKNOWN_PRICING_PER_1K = untiered(per1kPricing(0.01, 0.05));
9995
+ var per1k = (rate, tokens) => rate * tokens / 1000;
9996
+ function bareModelId(ref) {
9997
+ return ref.slice(ref.indexOf("/") + 1);
9998
+ }
9999
+ function totalTokens(usage) {
10000
+ return usage.input + usage.output + usage.reasoning + usage.cache.read + usage.cache.write;
10001
+ }
10002
+ function promptSizeOf(usage) {
10003
+ return usage.input + usage.cache.read + usage.cache.write;
10004
+ }
10005
+ var ZERO_RATES = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
10006
+ function selectTier(tiers, promptSize) {
10007
+ let best;
10008
+ let bestThreshold = Number.NEGATIVE_INFINITY;
10009
+ for (const tier of tiers) {
10010
+ const { threshold } = tier;
10011
+ if (threshold === undefined)
10012
+ continue;
10013
+ if (!(promptSize > threshold))
10014
+ continue;
10015
+ if (threshold > bestThreshold) {
10016
+ best = tier;
10017
+ bestThreshold = threshold;
10018
+ }
10019
+ }
10020
+ if (best)
10021
+ return best;
10022
+ const base = tiers.find((t) => t.threshold === undefined);
10023
+ return base ?? { rates: ZERO_RATES };
10024
+ }
10025
+ function priceUsage(usage, pricing) {
10026
+ return priceTokens(usage, selectTier(pricing.tiers, promptSizeOf(usage)).rates);
10027
+ }
10028
+ function priceUsageAtSettledTier(increment, pricing, settledUsage) {
10029
+ return priceTokens(increment, selectTier(pricing.tiers, promptSizeOf(settledUsage)).rates);
10030
+ }
10031
+ function priceTokens(usage, pricing) {
10032
+ const inputCost = per1k(pricing.input, usage.input);
10033
+ const outputCost = per1k(pricing.output, usage.output + usage.reasoning);
10034
+ const cacheCost = per1k(pricing.cacheRead, usage.cache.read) + per1k(pricing.cacheWrite, usage.cache.write);
10035
+ return { inputCost, outputCost, cacheCost, total: inputCost + outputCost + cacheCost };
10036
+ }
10037
+
9923
10038
  class CostForecaster {
9924
- modelPricing = new Map;
9925
- constructor() {
9926
- this.modelPricing.set("claude-sonnet-4-6", { input: 0.000015, output: 0.000075 });
9927
- this.modelPricing.set("claude-opus-4-7", { input: 0.000075, output: 0.000375 });
9928
- this.modelPricing.set("claude-haiku-4-5", { input: 0.000001, output: 0.000005 });
9929
- this.modelPricing.set("gpt-5-mini", { input: 0.00000015, output: 0.0000006 });
9930
- this.modelPricing.set("gpt-5", { input: 0.0000025, output: 0.00001 });
9931
- this.modelPricing.set("gemini-2.5-flash", { input: 0.000000075, output: 0.0000003 });
10039
+ resolvePricing;
10040
+ constructor(pricing) {
10041
+ this.resolvePricing = pricing;
10042
+ }
10043
+ priceFor(model, provider, promptSize) {
10044
+ const { pricing, source } = this.tiersFor(model, provider);
10045
+ return { pricing: selectTier(pricing.tiers, promptSize ?? 0).rates, source };
10046
+ }
10047
+ tiersFor(model, provider) {
10048
+ const real = this.resolvePricing?.(model, provider);
10049
+ if (real)
10050
+ return { pricing: real, source: "model-costs" };
10051
+ const bare = bareModelId(model);
10052
+ const fallback = FALLBACK_PRICING_PER_1K[model] ?? FALLBACK_PRICING_PER_1K[bare];
10053
+ if (fallback)
10054
+ return { pricing: fallback, source: "fallback-table" };
10055
+ return { pricing: UNKNOWN_PRICING_PER_1K, source: "unknown-model" };
10056
+ }
10057
+ measureCost(usage, model, provider) {
10058
+ const { pricing, source } = this.tiersFor(model, provider);
10059
+ return {
10060
+ cost: priceUsage(usage, pricing).total,
10061
+ tokens: totalTokens(usage),
10062
+ pricingSource: source,
10063
+ ...source === "model-costs" ? { confidence: 1 } : {}
10064
+ };
9932
10065
  }
9933
- updatePricing(modelId, input, output) {
9934
- this.modelPricing.set(modelId, { input, output });
10066
+ costOf(usage, model, provider) {
10067
+ return priceUsage(usage, this.tiersFor(model, provider).pricing).total;
9935
10068
  }
9936
- estimateTokens(task, complexity) {
10069
+ estimateTokensFor(complexity, fileCount) {
9937
10070
  const baseInput = 700;
9938
10071
  const complexityMultiplier = 1 + complexity.overall / 100 * 3;
9939
- const fileMultiplier = 1 + task.files.include.length * 0.2;
10072
+ const fileMultiplier = 1 + fileCount * 0.2;
9940
10073
  const input = Math.round(baseInput * complexityMultiplier * fileMultiplier);
9941
10074
  const output = Math.round(input * 0.5);
9942
10075
  return { input, output };
9943
10076
  }
10077
+ estimateTokens(task, complexity) {
10078
+ return this.estimateTokensFor(complexity, task.files.include.length);
10079
+ }
10080
+ estimateCost(complexity, model, provider) {
10081
+ const { input, output } = this.estimateTokensFor(complexity, 0);
10082
+ return this.costOf({ input, output, reasoning: 0, cache: { read: 0, write: 0 } }, model, provider);
10083
+ }
9944
10084
  forecastTask(task, role, modelId, complexity) {
9945
10085
  const { input, output } = this.estimateTokens(task, complexity);
9946
- const pricing = this.modelPricing.get(modelId) || { input: 0.00001, output: 0.00005 };
9947
- const inputCost = input * pricing.input;
9948
- const outputCost = output * pricing.output;
9949
- const overhead = 0.0001;
10086
+ const { pricing, source } = this.tiersFor(modelId);
10087
+ const breakdown = priceUsage({ input, output, reasoning: 0, cache: { read: 0, write: 0 } }, pricing);
9950
10088
  return {
9951
10089
  taskId: task.id,
9952
10090
  taskName: task.name,
@@ -9954,9 +10092,15 @@ class CostForecaster {
9954
10092
  model: modelId,
9955
10093
  estimatedInputTokens: input,
9956
10094
  estimatedOutputTokens: output,
9957
- estimatedCost: inputCost + outputCost + overhead,
9958
- confidence: 0.6 + Math.random() * 0.3,
9959
- breakdown: { inputCost, outputCost, overhead }
10095
+ estimatedCost: breakdown.total,
10096
+ source: "estimated",
10097
+ pricingSource: source,
10098
+ confidence: undefined,
10099
+ breakdown: {
10100
+ inputCost: breakdown.inputCost,
10101
+ outputCost: breakdown.outputCost,
10102
+ cacheCost: breakdown.cacheCost
10103
+ }
9960
10104
  };
9961
10105
  }
9962
10106
  forecastAll(tasks, budgetRemaining) {
@@ -10149,6 +10293,13 @@ ${lines.join(`
10149
10293
  }
10150
10294
 
10151
10295
  // src/orchestrator.ts
10296
+ class TaskTimeoutError extends Error {
10297
+ constructor(message = "Task timed out") {
10298
+ super(message);
10299
+ this.name = "TaskTimeoutError";
10300
+ }
10301
+ }
10302
+ var DELTA_READ_BACKOFF_MS = [1000, 2000, 4000];
10152
10303
  function assistantMessageText(message) {
10153
10304
  if (message.type !== "assistant")
10154
10305
  return "";
@@ -10164,6 +10315,15 @@ function lastAssistantText(messages) {
10164
10315
  }
10165
10316
  return "";
10166
10317
  }
10318
+ function sumBy(records, select) {
10319
+ let total = 0;
10320
+ for (const record of records.values())
10321
+ total += select(record);
10322
+ return total;
10323
+ }
10324
+ function baseInputRate(cost) {
10325
+ return selectTier(cost.tiers, 0).rates.input;
10326
+ }
10167
10327
  var DEFAULT_ESCALATION = {
10168
10328
  maxRetries: 3,
10169
10329
  retryDelay: 1000,
@@ -10209,7 +10369,14 @@ class NexusOrchestrator {
10209
10369
  onStateChange = null;
10210
10370
  stateChangeTimer = null;
10211
10371
  costHistory = [];
10372
+ tokensByModel = new Map;
10373
+ costProvenance = new Map;
10212
10374
  cleanupInterval = null;
10375
+ deltaLedgers = new Map;
10376
+ uncollected = new Map;
10377
+ deltaTimers = new Set;
10378
+ shuttingDown = false;
10379
+ deltaReadBackoffMs = DELTA_READ_BACKOFF_MS;
10213
10380
  get healthMonitor() {
10214
10381
  return this._healthMonitor;
10215
10382
  }
@@ -10226,6 +10393,7 @@ class NexusOrchestrator {
10226
10393
  this.messageRouter = new MessageRouter;
10227
10394
  this.escalationPolicy = {
10228
10395
  ...DEFAULT_ESCALATION,
10396
+ fallbackModels: [...DEFAULT_ESCALATION.fallbackModels],
10229
10397
  maxRetries: this.config.selfHealing.maxRetries,
10230
10398
  retryDelay: this.config.selfHealing.retryDelay,
10231
10399
  enableRespawn: this.config.selfHealing.contextTransfer
@@ -10235,7 +10403,7 @@ class NexusOrchestrator {
10235
10403
  this.performanceTracker = new PerformanceTracker;
10236
10404
  this.executionHistory = new ExecutionHistory;
10237
10405
  this.customRoles = new CustomRoleManager;
10238
- this.forecaster = new CostForecaster;
10406
+ this.forecaster = new CostForecaster((model, provider) => this.getModelCost(model, provider));
10239
10407
  }
10240
10408
  async initialize(ctx, onStateChange) {
10241
10409
  this.ctx = ctx;
@@ -10269,23 +10437,40 @@ class NexusOrchestrator {
10269
10437
  for (const model of data) {
10270
10438
  if (!model?.cost || !Array.isArray(model.cost) || model.cost.length === 0)
10271
10439
  continue;
10272
- const baseCost = model.cost[0];
10273
10440
  this.modelCosts.set(`${model.providerID}/${model.id}`, {
10274
- input: per1k(baseCost.input),
10275
- output: per1k(baseCost.output),
10276
- cacheRead: per1k(baseCost.cache?.read),
10277
- cacheWrite: per1k(baseCost.cache?.write)
10441
+ tiers: this.normaliseTiers(model.cost, per1k)
10278
10442
  });
10279
10443
  }
10280
10444
  } catch {}
10281
10445
  }
10446
+ normaliseTiers(cost, per1k) {
10447
+ const rates = (row) => ({
10448
+ input: per1k(row.input),
10449
+ output: per1k(row.output),
10450
+ cacheRead: per1k(row.cache?.read),
10451
+ cacheWrite: per1k(row.cache?.write)
10452
+ });
10453
+ const kept = cost.filter((row) => row?.tier === undefined || row.tier?.type === "context").map((row) => ({
10454
+ ...row.tier?.type === "context" && typeof row.tier.size === "number" ? { threshold: row.tier.size } : {},
10455
+ rates: rates(row)
10456
+ }));
10457
+ if (kept.length === 0)
10458
+ return [{ rates: rates(cost[0]) }];
10459
+ const base = kept.find((t) => t.threshold === undefined);
10460
+ const contextual = kept.filter((t) => t.threshold !== undefined).sort((a, b) => a.threshold - b.threshold);
10461
+ return base ? [{ rates: { ...base.rates } }, ...contextual] : [{ rates: { ...kept[0].rates } }, ...contextual];
10462
+ }
10282
10463
  setModelCosts(costs) {
10283
10464
  for (const [model, cost] of Object.entries(costs)) {
10284
10465
  this.modelCosts.set(model, {
10285
- input: cost.input,
10286
- output: cost.output,
10287
- cacheRead: cost.cacheRead || 0,
10288
- cacheWrite: cost.cacheWrite || 0
10466
+ tiers: [{
10467
+ rates: {
10468
+ input: cost.input,
10469
+ output: cost.output,
10470
+ cacheRead: cost.cacheRead || 0,
10471
+ cacheWrite: cost.cacheWrite || 0
10472
+ }
10473
+ }]
10289
10474
  });
10290
10475
  }
10291
10476
  }
@@ -10403,10 +10588,14 @@ class NexusOrchestrator {
10403
10588
  enabled: true,
10404
10589
  patternStorage: "memory",
10405
10590
  minConfidence: 0.7
10591
+ },
10592
+ cost: {
10593
+ timeoutDeltaGraceMs: 60000
10406
10594
  }
10407
10595
  };
10596
+ const { cost: defaultCost, ...restDefaults } = defaults;
10408
10597
  return {
10409
- ...defaults,
10598
+ ...restDefaults,
10410
10599
  ...partial,
10411
10600
  budget: { ...defaults.budget, ...partial?.budget },
10412
10601
  agents: { ...defaults.agents, ...partial?.agents },
@@ -10415,7 +10604,8 @@ class NexusOrchestrator {
10415
10604
  memory: { ...defaults.memory, ...partial?.memory },
10416
10605
  dashboard: { ...defaults.dashboard, ...partial?.dashboard },
10417
10606
  security: { ...defaults.security, ...partial?.security },
10418
- learning: { ...defaults.learning, ...partial?.learning }
10607
+ learning: { ...defaults.learning, ...partial?.learning },
10608
+ ...partial?.cost ? { cost: { ...defaultCost, ...partial.cost } } : {}
10419
10609
  };
10420
10610
  }
10421
10611
  notifyStateChange() {
@@ -10469,6 +10659,7 @@ class NexusOrchestrator {
10469
10659
  success: true,
10470
10660
  tasks: results,
10471
10661
  totalCost: this.totalSpent,
10662
+ ...this.spendSplit(),
10472
10663
  totalDuration,
10473
10664
  agentsUsed: this.agents.size
10474
10665
  };
@@ -10478,6 +10669,7 @@ class NexusOrchestrator {
10478
10669
  success: false,
10479
10670
  tasks: [],
10480
10671
  totalCost: this.totalSpent,
10672
+ ...this.spendSplit(),
10481
10673
  totalDuration: Date.now() - startTime,
10482
10674
  agentsUsed: this.agents.size
10483
10675
  };
@@ -10590,14 +10782,17 @@ class NexusOrchestrator {
10590
10782
  await this.sleep(this.config.schedulerInterval);
10591
10783
  }
10592
10784
  }
10593
- async spawnAndExecute(node, transferContext) {
10785
+ selectQualifiedModel(node) {
10594
10786
  const complexity = this.analyzeComplexity(node.task);
10595
10787
  const model = this.selectModel(node.task.requiredRole, complexity);
10596
10788
  if (!model.provider || !model.model) {
10597
10789
  const missing = !model.provider && !model.model ? "provider and model" : !model.provider ? "provider" : "model";
10598
10790
  throw new Error(`Model selection for role "${node.task.requiredRole}" is missing its ${missing}: ` + `got { provider: ${JSON.stringify(model.provider)}, model: ${JSON.stringify(model.model)} }. ` + `Cannot build a "providerID/modelID" reference.`);
10599
10791
  }
10600
- const qualifiedModel = `${model.provider}/${model.model}`;
10792
+ return `${model.provider}/${model.model}`;
10793
+ }
10794
+ async spawnAndExecute(node, options = {}) {
10795
+ const qualifiedModel = options.modelOverride ?? this.selectQualifiedModel(node);
10601
10796
  const agent = await this.spawnAgent({
10602
10797
  role: node.task.requiredRole,
10603
10798
  task: node.task,
@@ -10607,7 +10802,7 @@ class NexusOrchestrator {
10607
10802
  node.status = "running";
10608
10803
  node.task.assignedAgent = agent.id;
10609
10804
  this.notifyStateChange();
10610
- await this.executeTask(agent, node, transferContext);
10805
+ await this.executeTask(agent, node, options.transferContext);
10611
10806
  }
10612
10807
  async executeTask(agent, node, transferContext) {
10613
10808
  if (!this.ctx || !agent.sessionID) {
@@ -10624,6 +10819,9 @@ class NexusOrchestrator {
10624
10819
  }
10625
10820
  const startTime = Date.now();
10626
10821
  const timeout = node.task.timeout || this.config.defaultTimeout;
10822
+ let timeoutTimer = null;
10823
+ let waitGuard = null;
10824
+ let waitAbort = null;
10627
10825
  try {
10628
10826
  const rolePrompt = this.buildRolePrompt(node.task.requiredRole);
10629
10827
  let taskPrompt = `${rolePrompt}
@@ -10655,32 +10853,38 @@ Please continue from where the previous agent left off.`;
10655
10853
  sessionID: agent.sessionID,
10656
10854
  text: taskPrompt
10657
10855
  });
10658
- const waitPromise = this.ctx.session.wait({ sessionID: agent.sessionID });
10659
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Task timed out")), timeout));
10856
+ waitAbort = new AbortController;
10857
+ const waitPromise = this.ctx.session.wait({ sessionID: agent.sessionID }, { signal: waitAbort.signal });
10858
+ waitGuard = waitPromise.then(() => ({ outcome: "idle" }), () => ({ outcome: "poll-failed" }));
10859
+ const timeoutPromise = new Promise((_, reject) => {
10860
+ timeoutTimer = setTimeout(() => reject(new TaskTimeoutError), timeout);
10861
+ });
10660
10862
  await Promise.race([waitPromise, timeoutPromise]);
10661
10863
  const messages = await this.ctx.session.context({ sessionID: agent.sessionID });
10662
10864
  const output = lastAssistantText(messages) || "Task completed (no output captured)";
10663
10865
  const duration = Date.now() - startTime;
10866
+ const cost = await this.safeAccountTaskCost(agent, node.task);
10664
10867
  const result = {
10665
10868
  success: true,
10666
10869
  output,
10667
10870
  duration,
10668
- tokensUsed: 0,
10669
- cost: this.estimateModelCost(agent.model.model, agent.model.provider)
10871
+ tokensUsed: cost.tokensUsed,
10872
+ cost: cost.cost,
10873
+ costProvenance: cost.provenance
10670
10874
  };
10671
10875
  this.dag.markComplete(node.id, result);
10672
10876
  this.todoEnforcer.completeTask(agent.id);
10673
10877
  agent.metrics.tasksCompleted++;
10674
10878
  agent.metrics.totalCost += result.cost;
10675
- this.totalSpent += result.cost;
10676
- this.costByAgent.set(agent.id, (this.costByAgent.get(agent.id) || 0) + result.cost);
10677
- this.checkBudget();
10879
+ agent.metrics.totalTokens += result.tokensUsed;
10880
+ this.trackCost(agent.id, `${agent.model.provider}/${agent.model.model}`, result.cost, result.tokensUsed, result.costProvenance);
10678
10881
  this.performanceTracker.record({
10679
10882
  model: agent.model.model,
10680
10883
  role: node.task.requiredRole,
10681
10884
  success: result.success,
10682
10885
  duration: result.duration,
10683
10886
  cost: result.cost,
10887
+ costProvenance: result.costProvenance,
10684
10888
  tokensUsed: result.tokensUsed
10685
10889
  });
10686
10890
  this.executionHistory.record({
@@ -10690,6 +10894,7 @@ Please continue from where the previous agent left off.`;
10690
10894
  model: agent.model.model,
10691
10895
  status: "success",
10692
10896
  cost: result.cost,
10897
+ costProvenance: result.costProvenance,
10693
10898
  duration: result.duration,
10694
10899
  tokensUsed: result.tokensUsed,
10695
10900
  startedAt: new Date(startTime),
@@ -10716,14 +10921,20 @@ Please continue from where the previous agent left off.`;
10716
10921
  } catch (error) {
10717
10922
  const duration = Date.now() - startTime;
10718
10923
  const errorMessage = error.message || "Task failed";
10924
+ const cost = await this.safeAccountTaskCost(agent, node.task);
10719
10925
  const result = {
10720
10926
  success: false,
10721
10927
  error: errorMessage,
10722
10928
  duration,
10723
- tokensUsed: 0,
10724
- cost: 0
10929
+ tokensUsed: cost.tokensUsed,
10930
+ cost: cost.cost,
10931
+ costProvenance: cost.provenance
10725
10932
  };
10726
10933
  this.dag.markFailed(node.id, new Error(errorMessage));
10934
+ node.result = result;
10935
+ this.trackCost(agent.id, `${agent.model.provider}/${agent.model.model}`, result.cost, result.tokensUsed, result.costProvenance);
10936
+ agent.metrics.totalCost += result.cost;
10937
+ agent.metrics.totalTokens += result.tokensUsed;
10727
10938
  this.todoEnforcer.completeTask(agent.id);
10728
10939
  agent.metrics.tasksFailed++;
10729
10940
  agent.status = "failed";
@@ -10737,27 +10948,41 @@ Please continue from where the previous agent left off.`;
10737
10948
  duration,
10738
10949
  sessionID: agent.sessionID
10739
10950
  });
10740
- this.performanceTracker.record({
10951
+ const performanceId = this.performanceTracker.record({
10741
10952
  model: agent.model.model,
10742
10953
  role: node.task.requiredRole,
10743
10954
  success: result.success,
10744
10955
  duration: result.duration,
10745
10956
  cost: result.cost,
10957
+ costProvenance: result.costProvenance,
10746
10958
  tokensUsed: result.tokensUsed
10747
10959
  });
10748
- this.executionHistory.record({
10960
+ const historyId = this.executionHistory.record({
10749
10961
  taskId: node.id,
10750
10962
  taskName: node.task.name,
10751
10963
  role: node.task.requiredRole,
10752
10964
  model: agent.model.model,
10753
10965
  status: "failed",
10754
10966
  cost: result.cost,
10967
+ costProvenance: result.costProvenance,
10755
10968
  duration: result.duration,
10756
10969
  tokensUsed: result.tokensUsed,
10757
10970
  startedAt: new Date(startTime),
10758
10971
  completedAt: new Date,
10759
10972
  error: errorMessage
10760
- });
10973
+ }).id;
10974
+ if (error instanceof TaskTimeoutError && cost.usage && waitGuard && agent.sessionID) {
10975
+ this.armTimeoutDelta(agent, node, {
10976
+ usage: cost.usage,
10977
+ idleAt: cost.idleAt,
10978
+ result,
10979
+ waitGuard,
10980
+ abort: waitAbort,
10981
+ historyId,
10982
+ performanceId,
10983
+ timeout
10984
+ });
10985
+ }
10761
10986
  if (this.notifications?.isEnabled()) {
10762
10987
  this.notifications.notify({ title: "Nexus: Task Failed", body: `${node.task.name} failed: ${errorMessage}`, sound: true });
10763
10988
  }
@@ -10765,6 +10990,10 @@ Please continue from where the previous agent left off.`;
10765
10990
  await this.handleFailure(agent, node, new Error(errorMessage));
10766
10991
  }
10767
10992
  } finally {
10993
+ if (timeoutTimer) {
10994
+ clearTimeout(timeoutTimer);
10995
+ timeoutTimer = null;
10996
+ }
10768
10997
  if (agent.status !== "terminated" && agent.status !== "failed") {
10769
10998
  agent.status = "idle";
10770
10999
  }
@@ -10772,6 +11001,200 @@ Please continue from where the previous agent left off.`;
10772
11001
  this.notifyStateChange();
10773
11002
  }
10774
11003
  }
11004
+ armTimeoutDelta(agent, node, args) {
11005
+ const sessionID = agent.sessionID;
11006
+ if (!sessionID)
11007
+ return;
11008
+ const model = `${agent.model.provider}/${agent.model.model}`;
11009
+ if (this.shuttingDown)
11010
+ return;
11011
+ if (this.deltaLedgers.has(sessionID))
11012
+ return;
11013
+ const ledger = {
11014
+ sessionID,
11015
+ taskId: node.id,
11016
+ agentId: agent.id,
11017
+ agent,
11018
+ node,
11019
+ result: args.result,
11020
+ model,
11021
+ provider: agent.model.provider,
11022
+ charged: args.usage,
11023
+ idleAtTimeout: args.idleAt,
11024
+ pending: false,
11025
+ historyId: args.historyId,
11026
+ performanceId: args.performanceId,
11027
+ abort: args.abort ?? new AbortController
11028
+ };
11029
+ this.deltaLedgers.set(sessionID, ledger);
11030
+ const graceMs = this.config.cost?.timeoutDeltaGraceMs ?? Math.min(180000, Math.max(30000, args.timeout / 2));
11031
+ const timer = setTimeout(() => {
11032
+ this.deltaTimers.delete(timer);
11033
+ this.settleTimeoutDelta(ledger, "deadline").catch(() => {});
11034
+ }, graceMs);
11035
+ timer.unref?.();
11036
+ this.deltaTimers.add(timer);
11037
+ (async () => {
11038
+ try {
11039
+ const { outcome } = await args.waitGuard;
11040
+ if (outcome === "idle")
11041
+ await this.settleTimeoutDelta(ledger, "session-idle");
11042
+ else
11043
+ await this.settleTimeoutDelta(ledger, "poll-failed");
11044
+ } catch {}
11045
+ })();
11046
+ }
11047
+ async settleTimeoutDelta(ledger, trigger) {
11048
+ if (ledger.pending)
11049
+ return;
11050
+ if (this.deltaLedgers.get(ledger.sessionID) !== ledger)
11051
+ return;
11052
+ ledger.pending = true;
11053
+ try {
11054
+ let reason = trigger === "shutdown" ? "shutdown" : trigger === "session-idle" ? "session-idle" : "abandoned";
11055
+ let now;
11056
+ if (trigger === "deadline" || trigger === "poll-failed") {
11057
+ const probe = await this.readSessionTokens(ledger.sessionID);
11058
+ if (!probe.read || probe.idleAt <= ledger.idleAtTimeout) {
11059
+ this.abandonTimeoutDelta(ledger, probe.read ? probe.usage : undefined, reason);
11060
+ return;
11061
+ }
11062
+ now = probe.usage;
11063
+ reason = "session-idle";
11064
+ } else {
11065
+ const read = trigger === "shutdown" ? await this.readSessionTokens(ledger.sessionID) : await this.readSessionTokensWithRetry(ledger.sessionID);
11066
+ if (!read.read) {
11067
+ this.abandonTimeoutDelta(ledger, undefined, reason);
11068
+ return;
11069
+ }
11070
+ now = read.usage;
11071
+ }
11072
+ const delta = {
11073
+ input: Math.max(0, now.input - ledger.charged.input),
11074
+ output: Math.max(0, now.output - ledger.charged.output),
11075
+ reasoning: Math.max(0, now.reasoning - ledger.charged.reasoning),
11076
+ cache: {
11077
+ read: Math.max(0, now.cache.read - ledger.charged.cache.read),
11078
+ write: Math.max(0, now.cache.write - ledger.charged.cache.write)
11079
+ }
11080
+ };
11081
+ const deltaTokens = totalTokens(delta);
11082
+ if (deltaTokens === 0) {
11083
+ this.emit("cost:delta", {
11084
+ taskId: ledger.taskId,
11085
+ nodeId: ledger.taskId,
11086
+ agentId: ledger.agentId,
11087
+ sessionID: ledger.sessionID,
11088
+ model: ledger.model,
11089
+ deltaCost: 0,
11090
+ deltaTokens: 0,
11091
+ sessionTotalCost: priceUsage(ledger.charged, this.forecaster.tiersFor(ledger.model, ledger.provider).pricing).total,
11092
+ reason,
11093
+ settledTier: this.settledTierOf(ledger, ledger.charged)
11094
+ });
11095
+ return;
11096
+ }
11097
+ const { pricing, source } = this.forecaster.tiersFor(ledger.model, ledger.provider);
11098
+ const deltaCost = priceUsageAtSettledTier(delta, pricing, now).total;
11099
+ const provenance = { usage: "measured", pricing: source };
11100
+ ledger.charged = now;
11101
+ let historyAdjusted = false;
11102
+ let performanceAdjusted = false;
11103
+ let nodeAdjusted = false;
11104
+ let agentAdjusted = false;
11105
+ let error;
11106
+ try {
11107
+ this.trackCost(ledger.agentId, ledger.model, deltaCost, deltaTokens, provenance);
11108
+ historyAdjusted = this.executionHistory.adjust(ledger.historyId, {
11109
+ cost: deltaCost,
11110
+ tokensUsed: deltaTokens
11111
+ });
11112
+ performanceAdjusted = this.performanceTracker.adjust(ledger.performanceId, { cost: deltaCost });
11113
+ ledger.agent.metrics.totalCost += deltaCost;
11114
+ ledger.agent.metrics.totalTokens += deltaTokens;
11115
+ agentAdjusted = true;
11116
+ if (ledger.node.result === ledger.result) {
11117
+ ledger.result.cost += deltaCost;
11118
+ ledger.result.tokensUsed += deltaTokens;
11119
+ nodeAdjusted = true;
11120
+ }
11121
+ this.uncollected.delete(ledger.sessionID);
11122
+ } catch (thrown) {
11123
+ error = thrown instanceof Error ? thrown.message : String(thrown);
11124
+ }
11125
+ this.emit("cost:delta", {
11126
+ taskId: ledger.taskId,
11127
+ nodeId: ledger.taskId,
11128
+ agentId: ledger.agentId,
11129
+ sessionID: ledger.sessionID,
11130
+ model: ledger.model,
11131
+ deltaCost,
11132
+ deltaTokens,
11133
+ sessionTotalCost: priceUsage(now, pricing).total,
11134
+ reason,
11135
+ settledTier: this.settledTierOf(ledger, now),
11136
+ recordsAdjusted: { history: historyAdjusted, performance: performanceAdjusted, node: nodeAdjusted, agent: agentAdjusted },
11137
+ ...error === undefined ? {} : { error }
11138
+ });
11139
+ } finally {
11140
+ this.deltaLedgers.delete(ledger.sessionID);
11141
+ ledger.pending = false;
11142
+ ledger.abort.abort();
11143
+ }
11144
+ }
11145
+ abandonTimeoutDelta(ledger, probeUsage, reason) {
11146
+ const eventReason = reason === "shutdown" ? "shutdown" : "abandoned";
11147
+ const known = probeUsage ?? ledger.charged;
11148
+ const { pricing } = this.forecaster.tiersFor(ledger.model, ledger.provider);
11149
+ const observedIncrement = {
11150
+ input: Math.max(0, known.input - ledger.charged.input),
11151
+ output: Math.max(0, known.output - ledger.charged.output),
11152
+ reasoning: Math.max(0, known.reasoning - ledger.charged.reasoning),
11153
+ cache: {
11154
+ read: Math.max(0, known.cache.read - ledger.charged.cache.read),
11155
+ write: Math.max(0, known.cache.write - ledger.charged.cache.write)
11156
+ }
11157
+ };
11158
+ const observedUncollected = totalTokens(observedIncrement) > 0 ? priceUsageAtSettledTier(observedIncrement, pricing, known).total : 0;
11159
+ this.uncollected.set(ledger.sessionID, {
11160
+ sessionID: ledger.sessionID,
11161
+ taskId: ledger.taskId,
11162
+ agentId: ledger.agentId,
11163
+ model: ledger.model,
11164
+ lastKnownTokens: totalTokens(known),
11165
+ observedUncollected
11166
+ });
11167
+ this.emit("cost:delta", {
11168
+ taskId: ledger.taskId,
11169
+ nodeId: ledger.taskId,
11170
+ agentId: ledger.agentId,
11171
+ sessionID: ledger.sessionID,
11172
+ model: ledger.model,
11173
+ deltaCost: 0,
11174
+ deltaTokens: 0,
11175
+ sessionTotalCost: priceUsage(known, pricing).total,
11176
+ reason: eventReason,
11177
+ settledTier: this.settledTierOf(ledger, known),
11178
+ uncollected: { lastKnownTokens: totalTokens(known), observedUncollected }
11179
+ });
11180
+ }
11181
+ settledTierOf(ledger, usage) {
11182
+ const { pricing, source } = this.forecaster.tiersFor(ledger.model, ledger.provider);
11183
+ const promptSize = promptSizeOf(usage);
11184
+ return {
11185
+ pricing: source,
11186
+ promptSizeAtSettlement: promptSize,
11187
+ threshold: selectTier(pricing.tiers, promptSize).threshold ?? null
11188
+ };
11189
+ }
11190
+ async flushTimeoutDeltas() {
11191
+ for (const timer of this.deltaTimers)
11192
+ clearTimeout(timer);
11193
+ this.deltaTimers.clear();
11194
+ for (const ledger of [...this.deltaLedgers.values()]) {
11195
+ await this.settleTimeoutDelta(ledger, "shutdown").catch(() => {});
11196
+ }
11197
+ }
10775
11198
  buildRolePrompt(role) {
10776
11199
  if (this.customRoles.has(role)) {
10777
11200
  return this.customRoles.getPrompt(role) || `You are a ${role}. Complete the assigned task professionally.`;
@@ -10834,16 +11257,25 @@ Please continue from where the previous agent left off.`;
10834
11257
  await this.terminateAgent(agent.id);
10835
11258
  node.status = "pending";
10836
11259
  this.notifyStateChange();
10837
- await this.spawnAndExecute(node, context);
11260
+ await this.spawnAndExecute(node, { transferContext: context });
10838
11261
  return;
10839
11262
  }
10840
- if (policy.fallbackModels.length > 0) {
10841
- const fallbackModel = policy.fallbackModels.shift();
11263
+ const failedRef = `${agent.model.provider}/${agent.model.model}`;
11264
+ const hasUsableFallback = policy.fallbackModels.some((m) => m !== failedRef);
11265
+ if (policy.fallbackModels.length > 0 && hasUsableFallback) {
11266
+ let fallbackModel;
11267
+ while (policy.fallbackModels.length > 0) {
11268
+ const candidate = policy.fallbackModels.shift();
11269
+ if (candidate !== failedRef) {
11270
+ fallbackModel = candidate;
11271
+ break;
11272
+ }
11273
+ }
10842
11274
  if (fallbackModel) {
10843
11275
  await this.terminateAgent(agent.id);
10844
11276
  node.status = "pending";
10845
11277
  this.notifyStateChange();
10846
- await this.spawnAgent({ role: node.task.requiredRole, model: fallbackModel });
11278
+ await this.spawnAndExecute(node, { modelOverride: fallbackModel });
10847
11279
  return;
10848
11280
  }
10849
11281
  }
@@ -11077,32 +11509,16 @@ Please continue from where the previous agent left off.`;
11077
11509
  if (qualified)
11078
11510
  return qualified;
11079
11511
  }
11080
- const bare = model.slice(model.indexOf("/") + 1);
11512
+ const bare = bareModelId(model);
11081
11513
  let best;
11082
11514
  for (const [key, cost] of this.modelCosts) {
11083
- if (key.slice(key.indexOf("/") + 1) !== bare)
11515
+ if (bareModelId(key) !== bare)
11084
11516
  continue;
11085
- if (!best || cost.input < best.input)
11517
+ if (!best || baseInputRate(cost) < baseInputRate(best))
11086
11518
  best = cost;
11087
11519
  }
11088
11520
  return best;
11089
11521
  }
11090
- estimateModelCost(model, provider) {
11091
- const realCost = this.getModelCost(model, provider);
11092
- if (realCost) {
11093
- return (realCost.input + realCost.output) / 2;
11094
- }
11095
- const costs = {
11096
- "claude-sonnet-4-6": 0.15,
11097
- "claude-opus-4-7": 15,
11098
- "claude-haiku-4-5": 0.8,
11099
- "gpt-5-mini": 0.05,
11100
- "gpt-5": 2.5,
11101
- "gemini-2.5-flash": 0.075,
11102
- "minimax-m2.5-free": 0
11103
- };
11104
- return costs[model] || 0.1;
11105
- }
11106
11522
  estimateModelQuality(model) {
11107
11523
  const quality = {
11108
11524
  "claude-opus-4-7": 0.95,
@@ -11127,13 +11543,17 @@ Please continue from where the previous agent left off.`;
11127
11543
  };
11128
11544
  return speeds[model] || 0.5;
11129
11545
  }
11130
- scoreModel(modelId, role, complexity) {
11546
+ scoreModel(modelId, role, complexity, estimates) {
11131
11547
  const [provider, ...parts] = modelId.split("/");
11132
11548
  const model = parts.join("/");
11133
- const cost = this.estimateModelCost(model, provider);
11549
+ const estimate = estimates?.get(modelId) ?? this.forecaster.estimateCost(complexity, model, provider);
11550
+ let maxEstimate = 0;
11551
+ for (const value of estimates?.values() ?? []) {
11552
+ if (value > maxEstimate)
11553
+ maxEstimate = value;
11554
+ }
11555
+ const costScore = maxEstimate === 0 ? 1 : 1 - estimate / maxEstimate;
11134
11556
  const quality = this.estimateModelQuality(model);
11135
- const maxCost = 15;
11136
- const costScore = 1 - cost / maxCost;
11137
11557
  const speedScore = this.estimateModelSpeed(model);
11138
11558
  const qualityWeight = complexity.overall > 70 ? 0.6 : complexity.overall > 40 ? 0.4 : 0.2;
11139
11559
  const costWeight = 1 - qualityWeight;
@@ -11145,7 +11565,7 @@ Please continue from where the previous agent left off.`;
11145
11565
  qualityScore: quality,
11146
11566
  speedScore,
11147
11567
  overallScore,
11148
- reasoning: `Score: ${overallScore.toFixed(2)} (quality: ${quality.toFixed(2)}, cost: ${costScore.toFixed(2)}, speed: ${speedScore.toFixed(2)})`
11568
+ reasoning: `Score: ${overallScore.toFixed(2)} (quality: ${quality.toFixed(2)}, cost: ${costScore.toFixed(2)} [~$${estimate.toFixed(4)}/task], speed: ${speedScore.toFixed(2)})`
11149
11569
  };
11150
11570
  }
11151
11571
  selectBestModel(role, complexity) {
@@ -11159,31 +11579,127 @@ Please continue from where the previous agent left off.`;
11159
11579
  "opencode/minimax-m2.5-free"
11160
11580
  ];
11161
11581
  const unique = [...new Set(candidates)];
11162
- const scored = unique.map((m) => this.scoreModel(m, role, complexity));
11582
+ const estimates = new Map;
11583
+ for (const ref of unique) {
11584
+ const [provider, ...parts] = ref.split("/");
11585
+ estimates.set(ref, this.forecaster.estimateCost(complexity, parts.join("/"), provider));
11586
+ }
11587
+ const scored = unique.map((ref) => ({ ref, score: this.scoreModel(ref, role, complexity, estimates) }));
11163
11588
  const budgetRemaining = this.budget.maxTotalCost - this.totalSpent;
11164
- const affordable = scored.filter((s) => {
11165
- const cost = this.estimateModelCost(s.model, s.provider);
11166
- return cost <= budgetRemaining || cost === 0;
11589
+ const affordable = scored.filter(({ ref }) => {
11590
+ const estimate = estimates.get(ref) ?? 0;
11591
+ return estimate <= budgetRemaining || estimate === 0;
11167
11592
  });
11168
- const best = (affordable.length > 0 ? affordable : scored).sort((a, b) => b.overallScore - a.overallScore)[0];
11593
+ const best = (affordable.length > 0 ? affordable : scored).sort((a, b) => b.score.overallScore - a.score.overallScore)[0];
11169
11594
  return {
11170
- provider: best.provider,
11171
- model: best.model,
11172
- estimatedCost: this.estimateModelCost(best.model, best.provider),
11173
- estimatedQuality: best.qualityScore,
11174
- reasoning: best.reasoning
11595
+ provider: best.score.provider,
11596
+ model: best.score.model,
11597
+ estimatedCost: estimates.get(best.ref) ?? 0,
11598
+ estimatedQuality: best.score.qualityScore,
11599
+ reasoning: best.score.reasoning
11175
11600
  };
11176
11601
  }
11177
- trackCost(agentId, model, cost, tokens) {
11602
+ trackCost(agentId, model, cost, tokens, provenance) {
11178
11603
  this.totalSpent += cost;
11179
11604
  const agentCost = this.costByAgent.get(agentId) || 0;
11180
11605
  this.costByAgent.set(agentId, agentCost + cost);
11181
11606
  const modelCost = this.costByModel.get(model) || 0;
11182
11607
  this.costByModel.set(model, modelCost + cost);
11183
- this.costHistory.push({ timestamp: Date.now(), cost, agentId });
11608
+ this.tokensByModel.set(model, (this.tokensByModel.get(model) || 0) + tokens);
11609
+ this.recordProvenance(model, cost, provenance);
11610
+ this.costHistory.push({ timestamp: Date.now(), cost, agentId, model, tokens, provenance });
11184
11611
  this.checkBudget();
11185
11612
  this.notifyStateChange();
11186
11613
  }
11614
+ recordProvenance(model, cost, provenance) {
11615
+ const measured = provenance.usage === "measured";
11616
+ const prior = this.costProvenance.get(model) ?? {
11617
+ usage: provenance.usage,
11618
+ pricing: provenance.pricing,
11619
+ measuredEntries: 0,
11620
+ estimatedEntries: 0,
11621
+ measuredSpend: 0,
11622
+ estimatedSpend: 0
11623
+ };
11624
+ prior.usage = provenance.usage;
11625
+ prior.pricing = provenance.pricing;
11626
+ if (measured) {
11627
+ prior.measuredEntries++;
11628
+ prior.measuredSpend += cost;
11629
+ } else {
11630
+ prior.estimatedEntries++;
11631
+ prior.estimatedSpend += cost;
11632
+ }
11633
+ this.costProvenance.set(model, prior);
11634
+ }
11635
+ spendSplit() {
11636
+ return {
11637
+ measuredSpend: sumBy(this.costProvenance, (p) => p.measuredSpend),
11638
+ estimatedSpend: sumBy(this.costProvenance, (p) => p.estimatedSpend)
11639
+ };
11640
+ }
11641
+ safeAccountTaskCost(agent, task) {
11642
+ return this.accountTaskCost(agent, task).catch(() => ({
11643
+ cost: 0,
11644
+ tokensUsed: 0,
11645
+ provenance: { usage: "estimated", pricing: "unknown-model" },
11646
+ usage: null,
11647
+ idleAt: 0
11648
+ }));
11649
+ }
11650
+ async accountTaskCost(agent, task) {
11651
+ const model = `${agent.model.provider}/${agent.model.model}`;
11652
+ const read = agent.sessionID ? await this.readSessionTokens(agent.sessionID) : { read: false };
11653
+ if (read.read) {
11654
+ const measured = this.forecaster.measureCost(read.usage, model, agent.model.provider);
11655
+ return {
11656
+ cost: measured.cost,
11657
+ tokensUsed: measured.tokens,
11658
+ provenance: { usage: "measured", pricing: measured.pricingSource },
11659
+ usage: read.usage,
11660
+ idleAt: read.idleAt
11661
+ };
11662
+ }
11663
+ const predicted = this.forecaster.forecastTask(task, task.requiredRole, model, task.complexity);
11664
+ return {
11665
+ cost: predicted.estimatedCost,
11666
+ tokensUsed: predicted.estimatedInputTokens + predicted.estimatedOutputTokens,
11667
+ provenance: { usage: "estimated", pricing: predicted.pricingSource },
11668
+ usage: null,
11669
+ idleAt: 0
11670
+ };
11671
+ }
11672
+ async readSessionTokens(sessionID) {
11673
+ const finite = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
11674
+ try {
11675
+ const session = await this.ctx?.session.get({ sessionID });
11676
+ const tokens = session?.tokens;
11677
+ if (!tokens)
11678
+ return { read: false };
11679
+ return {
11680
+ read: true,
11681
+ idleAt: typeof session?.time?.idle === "number" && Number.isFinite(session.time.idle) ? session.time.idle : 0,
11682
+ usage: {
11683
+ input: finite(tokens.input),
11684
+ output: finite(tokens.output),
11685
+ reasoning: finite(tokens.reasoning),
11686
+ cache: { read: finite(tokens.cache?.read), write: finite(tokens.cache?.write) }
11687
+ }
11688
+ };
11689
+ } catch {
11690
+ return { read: false };
11691
+ }
11692
+ }
11693
+ async readSessionTokensWithRetry(sessionID) {
11694
+ for (let attempt = 0;attempt < this.deltaReadBackoffMs.length; attempt++) {
11695
+ if (attempt > 0)
11696
+ await this.sleep(this.deltaReadBackoffMs[attempt] ?? 0);
11697
+ const read = await this.readSessionTokens(sessionID);
11698
+ if (read.read)
11699
+ return read;
11700
+ }
11701
+ return { read: false };
11702
+ }
11187
11703
  checkBudget() {
11188
11704
  const remaining = this.budget.maxTotalCost - this.totalSpent;
11189
11705
  const remainingPercent = remaining / this.budget.maxTotalCost;
@@ -11250,8 +11766,9 @@ Please continue from where the previous agent left off.`;
11250
11766
  global: loadInfo?.global ?? null,
11251
11767
  models: this.configManager.getResolvedModels()
11252
11768
  };
11769
+ const spend = this.spendSplit();
11253
11770
  if (detailed)
11254
- return JSON.stringify({ ...state, budgetExceeded: this.budgetExceeded, config }, null, 2);
11771
+ return JSON.stringify({ ...state, ...spend, budgetExceeded: this.budgetExceeded, config }, null, 2);
11255
11772
  return JSON.stringify({
11256
11773
  running: state.running,
11257
11774
  paused: state.paused,
@@ -11259,6 +11776,7 @@ Please continue from where the previous agent left off.`;
11259
11776
  agents: state.agents.length,
11260
11777
  tasks: state.tasks.length,
11261
11778
  totalCost: state.totalSpent,
11779
+ ...spend,
11262
11780
  budgetRemaining: state.budgetRemaining,
11263
11781
  config
11264
11782
  }, null, 2);
@@ -11273,10 +11791,25 @@ Please continue from where the previous agent left off.`;
11273
11791
  return JSON.stringify({
11274
11792
  totalSpent: state.totalSpent,
11275
11793
  budgetRemaining: state.budgetRemaining,
11794
+ ...this.spendSplit(),
11276
11795
  byAgent: Object.fromEntries(this.costByAgent),
11277
- byModel: Object.fromEntries(this.costByModel)
11796
+ byModel: Object.fromEntries(this.costByModel),
11797
+ tokensByModel: Object.fromEntries(this.tokensByModel),
11798
+ provenance: Object.fromEntries(this.costProvenance),
11799
+ measuredEntries: sumBy(this.costProvenance, (p) => p.measuredEntries),
11800
+ estimatedEntries: sumBy(this.costProvenance, (p) => p.estimatedEntries),
11801
+ uncollected: this.uncollectedSummary()
11278
11802
  }, null, 2);
11279
11803
  }
11804
+ uncollectedSummary() {
11805
+ const all = [...this.uncollected.values()];
11806
+ return {
11807
+ sessions: all.length,
11808
+ lastKnownTokens: all.reduce((sum, u) => sum + u.lastKnownTokens, 0),
11809
+ observedUncollected: all.reduce((sum, u) => sum + u.observedUncollected, 0),
11810
+ taskIds: all.map((u) => u.taskId)
11811
+ };
11812
+ }
11280
11813
  pause() {
11281
11814
  this.paused = true;
11282
11815
  this.emit("orchestrator:paused", {});
@@ -11294,6 +11827,7 @@ Please continue from where the previous agent left off.`;
11294
11827
  this.budgetExceeded = false;
11295
11828
  }
11296
11829
  async shutdown() {
11830
+ this.shuttingDown = true;
11297
11831
  await this.moduleRegistry.teardownAll();
11298
11832
  this._healthMonitor?.stop();
11299
11833
  this.stopDashboard();
@@ -11306,6 +11840,7 @@ Please continue from where the previous agent left off.`;
11306
11840
  clearTimeout(this.stateChangeTimer);
11307
11841
  this.stateChangeTimer = null;
11308
11842
  }
11843
+ await this.flushTimeoutDeltas();
11309
11844
  this.agents.forEach((agent) => {
11310
11845
  agent.status = "terminated";
11311
11846
  });
@@ -11671,8 +12206,18 @@ class AstGrep {
11671
12206
 
11672
12207
  // src/index.ts
11673
12208
  import { writeFileSync as writeFileSync2, readFileSync as readFileSync3, mkdirSync as mkdirSync4, existsSync as existsSync3, statSync } from "node:fs";
12209
+ import { createHash } from "node:crypto";
11674
12210
  import { join as join6, resolve as resolve3, basename, dirname as dirname4 } from "node:path";
11675
12211
  import { homedir as homedir2 } from "node:os";
12212
+ function renderCost(cost, provenance) {
12213
+ return provenance.usage === "measured" ? `$${cost.toFixed(4)} measured` : `$${cost.toFixed(4)} estimated`;
12214
+ }
12215
+ function renderCostBasis(score) {
12216
+ if (score.costBasis === "none") {
12217
+ return "cost unscored: 0 measured tasks, 0% of score from cost";
12218
+ }
12219
+ return `avg=$${score.avgCost.toFixed(4)} (${score.measuredTasks}/${score.totalTasks} measured)`;
12220
+ }
11676
12221
  var CONFIG_RELOAD_DEBOUNCE_MS = 150;
11677
12222
  var CONFIG_POLL_INTERVAL_MS = 2000;
11678
12223
  function isNexusConfigFile(file, eventDirectory, projectDirectory, projectPath, globalPath) {
@@ -11687,6 +12232,8 @@ var watchedContexts = new WeakSet;
11687
12232
  var MAX_RELOAD_WAIT_MS = 1000;
11688
12233
  var MIN_INTERVAL_MS = 25;
11689
12234
  var clampInterval = (ms, fallback) => Number.isFinite(ms) && ms >= MIN_INTERVAL_MS ? ms : fallback;
12235
+ var ABSENT = "absent";
12236
+ var UNREADABLE = "unreadable";
11690
12237
  function watchConfigFiles(ctx, orchestrator, debounceMs = CONFIG_RELOAD_DEBOUNCE_MS, pollMs = CONFIG_POLL_INTERVAL_MS) {
11691
12238
  if (watchedContexts.has(ctx))
11692
12239
  return () => {};
@@ -11706,15 +12253,24 @@ function watchConfigFiles(ctx, orchestrator, debounceMs = CONFIG_RELOAD_DEBOUNCE
11706
12253
  clearTimeout(debounceTimer);
11707
12254
  debounceTimer = null;
11708
12255
  };
12256
+ const digestOf = (filePath) => {
12257
+ try {
12258
+ return createHash("sha1").update(readFileSync3(filePath)).digest("hex");
12259
+ } catch {
12260
+ return UNREADABLE;
12261
+ }
12262
+ };
11709
12263
  const stampOf = (filePath) => {
12264
+ let stat;
11710
12265
  try {
11711
12266
  const stats = statSync(filePath);
11712
12267
  if (!stats.isFile())
11713
- return "absent";
11714
- return `${stats.mtimeMs}:${stats.size}`;
12268
+ return ABSENT;
12269
+ stat = `${stats.mtimeMs}:${stats.size}`;
11715
12270
  } catch {
11716
- return "absent";
12271
+ return ABSENT;
11717
12272
  }
12273
+ return `${stat}|${digestOf(filePath)}`;
11718
12274
  };
11719
12275
  const lastSeen = new Map(watchedPaths.map((p) => [p, stampOf(p)]));
11720
12276
  const runReload = (trigger) => {
@@ -11963,6 +12519,19 @@ Before marking a task complete:
11963
12519
  4. Follows project conventions
11964
12520
  5. Has appropriate test coverage
11965
12521
  `;
12522
+ function clearPresetOverride(orchestrator) {
12523
+ const configManager = orchestrator.configManager;
12524
+ const hadOverride = configManager.resetToDefaults();
12525
+ const models = configManager.getResolvedModels();
12526
+ const resolved = Object.entries(models).map(([role, model]) => `${role}=${model}`).join(" ");
12527
+ return {
12528
+ content: hadOverride ? `Cleared the session preset override. nexus.jsonc is in control again — no config file was modified.
12529
+ ` + `Resolved models now: ${resolved}
12530
+ ` + `sessionOverride: false` : `No session preset override was set, so nothing was cleared — nexus.jsonc was already in control. No config file was modified.
12531
+ ` + `Resolved models now: ${resolved}
12532
+ ` + `sessionOverride: false`
12533
+ };
12534
+ }
11966
12535
  var src_default = define({
11967
12536
  id: "nexus",
11968
12537
  async setup(ctx) {
@@ -12444,23 +13013,35 @@ You are a technical writer who creates documentation that developers actually wa
12444
13013
  });
12445
13014
  editor.add({
12446
13015
  name: "preset",
12447
- description: "Apply a preset configuration",
13016
+ description: "Apply a session preset (model/budget selection), or clear it with mode 'clear' to hand control back to nexus.jsonc. A preset shadows the config file's models, so call mode 'clear' if the user edited nexus.jsonc but no model changed.",
12448
13017
  input: {
12449
13018
  type: "object",
12450
13019
  properties: {
12451
- name: { type: "string", description: "Preset name (minimal, balanced, enterprise, cost-optimized)" }
13020
+ mode: {
13021
+ type: "string",
13022
+ enum: ["apply", "clear"],
13023
+ description: "'apply' (default) applies the named preset for this session. 'clear' drops the session preset override so nexus.jsonc is in control again — use it whenever edits to nexus.jsonc appear to have no effect."
13024
+ },
13025
+ name: {
13026
+ type: "string",
13027
+ description: "Preset name (minimal, balanced, enterprise, cost-optimized). Required for mode 'apply'; ignored by 'clear'."
13028
+ }
12452
13029
  },
12453
- required: ["name"],
12454
13030
  additionalProperties: false
12455
13031
  },
12456
13032
  options: { codemode: true },
12457
13033
  execute: async (input) => {
12458
- const { name } = input;
13034
+ const { mode, name } = input;
13035
+ if (mode === "clear")
13036
+ return clearPresetOverride(orchestrator);
13037
+ if (!name) {
13038
+ return { content: `Error: mode 'apply' needs a \`name\` (${orchestrator.configManager.listPresets().join(", ")}). To hand control back to nexus.jsonc instead, call preset with mode: 'clear'.` };
13039
+ }
12459
13040
  try {
12460
13041
  orchestrator.configManager.applyPreset(name);
12461
13042
  return {
12462
13043
  content: `Applied preset: ${PRESETS[name]?.name || name}
12463
- ` + `⚠️ This preset now overrides the \`models\` section of nexus.jsonc. ` + `Edits to models in the config file will NOT take effect until the preset is cleared. ` + `Clear it from the TUI (config manager) to hand control back to disk. ` + `Budget and self-healing values from the file still apply.`
13044
+ ` + `⚠️ This preset now overrides the \`models\` section of nexus.jsonc. ` + `Edits to models in the config file will NOT take effect until the preset is cleared. ` + `Clear it with this tool (mode: 'clear'), or from the TUI (config manager), to hand control back to disk. ` + `Budget and self-healing values from the file still apply.`
12464
13045
  };
12465
13046
  } catch (error) {
12466
13047
  return { content: `Error: ${error.message}` };
@@ -12515,6 +13096,8 @@ You are a technical writer who creates documentation that developers actually wa
12515
13096
  execute: async (input) => {
12516
13097
  const { model, setInput, setOutput } = input;
12517
13098
  const per1k = (v) => `$${v}/1K tokens`;
13099
+ const renderTiers = (cost) => cost.tiers.map((t) => t.threshold === undefined ? `base: in=${per1k(t.rates.input)}, out=${per1k(t.rates.output)}, cache_read=${per1k(t.rates.cacheRead)}, cache_write=${per1k(t.rates.cacheWrite)}` : `over ${t.threshold} prompt tokens: in=${per1k(t.rates.input)}, out=${per1k(t.rates.output)}, cache_read=${per1k(t.rates.cacheRead)}, cache_write=${per1k(t.rates.cacheWrite)}`).join(`
13100
+ `);
12518
13101
  if (model && setInput !== undefined && setOutput !== undefined) {
12519
13102
  orchestrator.setModelCosts({ [model]: { input: setInput, output: setOutput } });
12520
13103
  return { content: `Set ${model}: input=${per1k(setInput)}, output=${per1k(setOutput)}` };
@@ -12522,20 +13105,23 @@ You are a technical writer who creates documentation that developers actually wa
12522
13105
  if (model) {
12523
13106
  const cost = orchestrator.getModelCost(model);
12524
13107
  if (cost) {
12525
- return { content: `${model}: input=${per1k(cost.input)}, output=${per1k(cost.output)}, cache_read=${per1k(cost.cacheRead)}, cache_write=${per1k(cost.cacheWrite)}` };
13108
+ return { content: `${model} (real pricing, from OpenCode):
13109
+ ${renderTiers(cost)}` };
12526
13110
  }
12527
- const estimate = orchestrator["estimateModelCost"](model);
12528
- return { content: `${model}: no real pricing data (estimated $${estimate}/1K tokens)` };
13111
+ const { pricing, source } = orchestrator.forecaster.priceFor(model);
13112
+ const label = source === "model-costs" ? "real pricing" : source === "fallback-table" ? "ESTIMATE (fallback table, not provider pricing)" : "ESTIMATE (unknown model — no table knows this rate)";
13113
+ return { content: `${model}: no real pricing data.
13114
+ ${label}: in=${per1k(pricing.input)}, out=${per1k(pricing.output)}, cache_read=${per1k(pricing.cacheRead)}, cache_write=${per1k(pricing.cacheWrite)}` };
12529
13115
  }
12530
13116
  if (orchestrator.modelCosts.size > 0) {
12531
13117
  const lines = ["\uD83D\uDCCA Model Pricing (from OpenCode, per 1K tokens):"];
12532
13118
  for (const [id, cost] of orchestrator.modelCosts) {
12533
- lines.push(` ${id}: ${per1k(cost.input)} in, ${per1k(cost.output)} out`);
13119
+ lines.push(` ${id}: ${renderTiers(cost)}`);
12534
13120
  }
12535
13121
  return { content: lines.join(`
12536
13122
  `) };
12537
13123
  }
12538
- return { content: "No real pricing data loaded. Using hardcoded estimates." };
13124
+ return { content: "No real pricing data loaded. Using labelled fallback estimates." };
12539
13125
  }
12540
13126
  });
12541
13127
  editor.add({
@@ -12765,7 +13351,7 @@ You are a technical writer who creates documentation that developers actually wa
12765
13351
  const scores = orchestrator.performanceTracker.getScores();
12766
13352
  if (scores.length === 0)
12767
13353
  return { content: "No performance data yet. Scores build up as tasks are executed." };
12768
- const lines = scores.map((s) => `${s.role}/${s.model}: score=${s.overallScore.toFixed(1)} success=${(s.successRate * 100).toFixed(0)}% avg=$${s.avgCost.toFixed(4)} (${s.totalTasks} tasks)`);
13354
+ const lines = scores.map((s) => `${s.role}/${s.model}: score=${s.overallScore.toFixed(1)} success=${(s.successRate * 100).toFixed(0)}% ${renderCostBasis(s)} (${s.totalTasks} tasks)`);
12769
13355
  return { content: lines.join(`
12770
13356
  `) };
12771
13357
  }
@@ -12787,7 +13373,7 @@ You are a technical writer who creates documentation that developers actually wa
12787
13373
  const best = orchestrator.performanceTracker.getBestModel(role);
12788
13374
  if (!best)
12789
13375
  return { content: `No performance data for role '${role}' yet.` };
12790
- return { content: `Best for ${role}: ${best.model} (score: ${best.overallScore.toFixed(1)}, success: ${(best.successRate * 100).toFixed(0)}%, avg cost: $${best.avgCost.toFixed(4)})` };
13376
+ return { content: `Best for ${role}: ${best.model} (score: ${best.overallScore.toFixed(1)}, success: ${(best.successRate * 100).toFixed(0)}%, ${renderCostBasis(best)})` };
12791
13377
  }
12792
13378
  });
12793
13379
  editor.add({
@@ -12826,7 +13412,7 @@ You are a technical writer who creates documentation that developers actually wa
12826
13412
  const records = count ? orchestrator.executionHistory.getRecent(count) : orchestrator.executionHistory.getAll();
12827
13413
  if (records.length === 0)
12828
13414
  return { content: "No execution history yet." };
12829
- const lines = records.map((r) => `${r.status === "success" ? "✅" : "❌"} ${r.taskName} (${r.role}) — $${r.cost.toFixed(4)} — ${r.duration}ms`);
13415
+ const lines = records.map((r) => `${r.status === "success" ? "✅" : "❌"} ${r.taskName} (${r.role}) — ${renderCost(r.cost, r.costProvenance)} — ${r.duration}ms`);
12830
13416
  return { content: lines.join(`
12831
13417
  `) };
12832
13418
  }
@@ -12842,7 +13428,7 @@ You are a technical writer who creates documentation that developers actually wa
12842
13428
  options: { codemode: true },
12843
13429
  execute: async () => {
12844
13430
  const stats = orchestrator.executionHistory.getStats();
12845
- return { content: `Total: ${stats.total} | Success: ${(stats.successRate * 100).toFixed(1)}% | Cost: $${stats.totalCost.toFixed(4)} | Avg: ${stats.avgDuration.toFixed(0)}ms
13431
+ return { content: `Total: ${stats.total} | Success: ${(stats.successRate * 100).toFixed(1)}% | Cost: $${stats.totalCost.toFixed(4)} (measured $${stats.costSplit.measuredCost.toFixed(4)} over ${stats.costSplit.measuredEntries} tasks, estimated $${stats.costSplit.estimatedCost.toFixed(4)} over ${stats.costSplit.estimatedEntries} tasks) | Avg: ${stats.avgDuration.toFixed(0)}ms
12846
13432
  By role: ${JSON.stringify(stats.byRole)}` };
12847
13433
  }
12848
13434
  });