@serkanalgur/opencode-nexus 2.4.0 → 2.5.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 +454 -65
  2. package/dist/tui.js +166 -75
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8366,7 +8366,7 @@ var Event16 = { Updated: Updated12, Resolved, Definitions: inventory(Updated12,
8366
8366
  // src/config.ts
8367
8367
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
8368
8368
  import { homedir } from "node:os";
8369
- import { join, dirname } from "node:path";
8369
+ import { join, dirname, resolve as resolve2 } from "node:path";
8370
8370
  function stripJsonComments(jsonc) {
8371
8371
  const result = [];
8372
8372
  let i = 0;
@@ -8414,14 +8414,46 @@ function readJsoncFile(filePath) {
8414
8414
  const raw = readFileSync(filePath, "utf-8");
8415
8415
  const stripped = stripJsonComments(raw);
8416
8416
  const parsed = JSON.parse(stripped);
8417
- return parsed;
8417
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
8418
+ console.warn(`[nexus] Ignoring config from ${filePath}: expected a JSON object, got ${Array.isArray(parsed) ? "array" : typeof parsed}`);
8419
+ return { config: null, existed: true };
8420
+ }
8421
+ return { config: parsed, existed: true };
8418
8422
  } catch (err) {
8419
- if (err.code !== "ENOENT") {
8420
- console.warn(`[nexus] Failed to load config from ${filePath}: ${err.message}`);
8423
+ const code = err.code;
8424
+ const message = err instanceof Error ? err.message : String(err);
8425
+ const absent = code === "ENOENT" || code === "ENOTDIR";
8426
+ if (!absent) {
8427
+ console.warn(`[nexus] Failed to load config from ${filePath}: ${message}`);
8421
8428
  }
8422
- return null;
8429
+ return { config: null, existed: !absent };
8423
8430
  }
8424
8431
  }
8432
+ function nexusProjectConfigPath(basePath) {
8433
+ return resolve2(join(basePath, ".opencode", "nexus.jsonc"));
8434
+ }
8435
+ function nexusGlobalConfigPath() {
8436
+ return resolve2(join(homedir(), ".config", "opencode", "nexus.jsonc"));
8437
+ }
8438
+ function redactHome(filePath) {
8439
+ const home = homedir();
8440
+ if (filePath === home)
8441
+ return "~";
8442
+ const prefix = home.endsWith("/") ? home : `${home}/`;
8443
+ return filePath.startsWith(prefix) ? `~${filePath.slice(home.length)}` : filePath;
8444
+ }
8445
+ function describeFile(file) {
8446
+ if (file.parsed)
8447
+ return "loaded";
8448
+ if (file.existed)
8449
+ return "unparseable";
8450
+ return "absent";
8451
+ }
8452
+ function formatConfigLoadLog(info) {
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)" : "";
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
+ }
8425
8457
  var DEFAULT_CONFIG = {
8426
8458
  models: {
8427
8459
  architect: "anthropic/claude-sonnet-4-6",
@@ -8448,19 +8480,51 @@ class NexusConfigManager {
8448
8480
  projectConfig = null;
8449
8481
  globalConfig = null;
8450
8482
  storageConfig = null;
8483
+ loadInfo = null;
8451
8484
  constructor() {
8452
8485
  this.projectConfig = null;
8453
8486
  this.globalConfig = null;
8454
8487
  }
8455
8488
  loadConfigs(basePath) {
8456
- this.storageConfig = null;
8457
- const projectPath = join(basePath, ".opencode", "nexus.jsonc");
8458
- this.projectConfig = readJsoncFile(projectPath);
8459
- const globalPath = join(homedir(), ".config", "opencode", "nexus.jsonc");
8460
- this.globalConfig = readJsoncFile(globalPath);
8489
+ if (this.loadInfo === null)
8490
+ this.storageConfig = null;
8491
+ const projectPath = nexusProjectConfigPath(basePath);
8492
+ const project = readJsoncFile(projectPath);
8493
+ this.projectConfig = project.config;
8494
+ const globalPath = nexusGlobalConfigPath();
8495
+ const global = readJsoncFile(globalPath);
8496
+ this.globalConfig = global.config;
8497
+ return {
8498
+ project: { path: redactHome(projectPath), existed: project.existed, parsed: project.config !== null },
8499
+ global: { path: redactHome(globalPath), existed: global.existed, parsed: global.config !== null }
8500
+ };
8501
+ }
8502
+ loadFromPath(basePath, trigger = "initial") {
8503
+ const sources = this.loadConfigs(basePath);
8504
+ this.loadInfo = {
8505
+ project: sources.project,
8506
+ global: sources.global,
8507
+ models: this.getResolvedModels(),
8508
+ sessionOverride: this.hasSessionOverride(),
8509
+ loadedAt: new Date().toISOString(),
8510
+ loadCount: (this.loadInfo?.loadCount ?? 0) + 1,
8511
+ trigger
8512
+ };
8513
+ console.log(formatConfigLoadLog(this.loadInfo));
8514
+ }
8515
+ getLoadInfo() {
8516
+ return this.loadInfo;
8461
8517
  }
8462
- loadFromPath(basePath) {
8463
- this.loadConfigs(basePath);
8518
+ getResolvedModels() {
8519
+ const models = {};
8520
+ for (const [role, model] of Object.entries(this.getConfig().models)) {
8521
+ if (model)
8522
+ models[role] = model;
8523
+ }
8524
+ return models;
8525
+ }
8526
+ hasSessionOverride() {
8527
+ return this.storageConfig !== null;
8464
8528
  }
8465
8529
  getConfig() {
8466
8530
  return {
@@ -8563,17 +8627,17 @@ class NexusConfigManager {
8563
8627
  `, "utf-8");
8564
8628
  }
8565
8629
  saveProjectConfig(basePath) {
8566
- const projectPath = join(basePath, ".opencode", "nexus.jsonc");
8630
+ const projectPath = nexusProjectConfigPath(basePath);
8567
8631
  const config = this.getSaveableConfig();
8568
8632
  this.writeJsoncFile(projectPath, config);
8569
8633
  }
8570
8634
  saveGlobalConfig() {
8571
- const globalPath = join(homedir(), ".config", "opencode", "nexus.jsonc");
8635
+ const globalPath = nexusGlobalConfigPath();
8572
8636
  const config = this.getSaveableConfig();
8573
8637
  this.writeJsoncFile(globalPath, config);
8574
8638
  }
8575
8639
  initProjectConfig(basePath) {
8576
- const projectPath = join(basePath, ".opencode", "nexus.jsonc");
8640
+ const projectPath = nexusProjectConfigPath(basePath);
8577
8641
  try {
8578
8642
  readFileSync(projectPath, "utf-8");
8579
8643
  return;
@@ -8581,7 +8645,7 @@ class NexusConfigManager {
8581
8645
  this.writeJsoncFile(projectPath, { ...DEFAULT_CONFIG });
8582
8646
  }
8583
8647
  initGlobalConfig() {
8584
- const globalPath = join(homedir(), ".config", "opencode", "nexus.jsonc");
8648
+ const globalPath = nexusGlobalConfigPath();
8585
8649
  try {
8586
8650
  readFileSync(globalPath, "utf-8");
8587
8651
  return;
@@ -9856,33 +9920,86 @@ class CustomRoleManager {
9856
9920
  }
9857
9921
 
9858
9922
  // src/forecast.ts
9923
+ var CACHE_READ_MULTIPLE = 0.1;
9924
+ var CACHE_WRITE_MULTIPLE = 1.25;
9925
+ function per1kPricing(input, output) {
9926
+ return {
9927
+ input,
9928
+ output,
9929
+ cacheRead: input * CACHE_READ_MULTIPLE,
9930
+ cacheWrite: input * CACHE_WRITE_MULTIPLE
9931
+ };
9932
+ }
9933
+ var FALLBACK_PRICING_PER_1K = {
9934
+ "claude-sonnet-4-6": per1kPricing(0.015, 0.075),
9935
+ "claude-opus-4-7": per1kPricing(0.075, 0.375),
9936
+ "claude-haiku-4-5": per1kPricing(0.001, 0.005),
9937
+ "gpt-5-mini": per1kPricing(0.00015, 0.0006),
9938
+ "gpt-5": per1kPricing(0.0025, 0.01),
9939
+ "gemini-2.5-flash": per1kPricing(0.000075, 0.0003),
9940
+ "minimax-m2.5-free": per1kPricing(0, 0)
9941
+ };
9942
+ var UNKNOWN_PRICING_PER_1K = per1kPricing(0.01, 0.05);
9943
+ var per1k = (rate, tokens) => rate * tokens / 1000;
9944
+ function bareModelId(ref) {
9945
+ return ref.slice(ref.indexOf("/") + 1);
9946
+ }
9947
+ function totalTokens(usage) {
9948
+ return usage.input + usage.output + usage.reasoning + usage.cache.read + usage.cache.write;
9949
+ }
9950
+ function priceTokens(usage, pricing) {
9951
+ const inputCost = per1k(pricing.input, usage.input);
9952
+ const outputCost = per1k(pricing.output, usage.output + usage.reasoning);
9953
+ const cacheCost = per1k(pricing.cacheRead, usage.cache.read) + per1k(pricing.cacheWrite, usage.cache.write);
9954
+ return { inputCost, outputCost, cacheCost, total: inputCost + outputCost + cacheCost };
9955
+ }
9956
+
9859
9957
  class CostForecaster {
9860
- modelPricing = new Map;
9861
- constructor() {
9862
- this.modelPricing.set("claude-sonnet-4-6", { input: 0.000015, output: 0.000075 });
9863
- this.modelPricing.set("claude-opus-4-7", { input: 0.000075, output: 0.000375 });
9864
- this.modelPricing.set("claude-haiku-4-5", { input: 0.000001, output: 0.000005 });
9865
- this.modelPricing.set("gpt-5-mini", { input: 0.00000015, output: 0.0000006 });
9866
- this.modelPricing.set("gpt-5", { input: 0.0000025, output: 0.00001 });
9867
- this.modelPricing.set("gemini-2.5-flash", { input: 0.000000075, output: 0.0000003 });
9958
+ resolvePricing;
9959
+ constructor(pricing) {
9960
+ this.resolvePricing = pricing;
9961
+ }
9962
+ priceFor(model, provider) {
9963
+ const real = this.resolvePricing?.(model, provider);
9964
+ if (real)
9965
+ return { pricing: real, source: "model-costs" };
9966
+ const bare = bareModelId(model);
9967
+ const fallback = FALLBACK_PRICING_PER_1K[model] ?? FALLBACK_PRICING_PER_1K[bare];
9968
+ if (fallback)
9969
+ return { pricing: fallback, source: "fallback-table" };
9970
+ return { pricing: UNKNOWN_PRICING_PER_1K, source: "unknown-model" };
9971
+ }
9972
+ measureCost(usage, model, provider) {
9973
+ const { pricing, source } = this.priceFor(model, provider);
9974
+ return {
9975
+ cost: priceTokens(usage, pricing).total,
9976
+ tokens: totalTokens(usage),
9977
+ pricingSource: source,
9978
+ ...source === "model-costs" ? { confidence: 1 } : {}
9979
+ };
9868
9980
  }
9869
- updatePricing(modelId, input, output) {
9870
- this.modelPricing.set(modelId, { input, output });
9981
+ costOf(usage, model, provider) {
9982
+ return priceTokens(usage, this.priceFor(model, provider).pricing).total;
9871
9983
  }
9872
- estimateTokens(task, complexity) {
9984
+ estimateTokensFor(complexity, fileCount) {
9873
9985
  const baseInput = 700;
9874
9986
  const complexityMultiplier = 1 + complexity.overall / 100 * 3;
9875
- const fileMultiplier = 1 + task.files.include.length * 0.2;
9987
+ const fileMultiplier = 1 + fileCount * 0.2;
9876
9988
  const input = Math.round(baseInput * complexityMultiplier * fileMultiplier);
9877
9989
  const output = Math.round(input * 0.5);
9878
9990
  return { input, output };
9879
9991
  }
9992
+ estimateTokens(task, complexity) {
9993
+ return this.estimateTokensFor(complexity, task.files.include.length);
9994
+ }
9995
+ estimateCost(complexity, model, provider) {
9996
+ const { input, output } = this.estimateTokensFor(complexity, 0);
9997
+ return this.costOf({ input, output, reasoning: 0, cache: { read: 0, write: 0 } }, model, provider);
9998
+ }
9880
9999
  forecastTask(task, role, modelId, complexity) {
9881
10000
  const { input, output } = this.estimateTokens(task, complexity);
9882
- const pricing = this.modelPricing.get(modelId) || { input: 0.00001, output: 0.00005 };
9883
- const inputCost = input * pricing.input;
9884
- const outputCost = output * pricing.output;
9885
- const overhead = 0.0001;
10001
+ const { pricing, source } = this.priceFor(modelId);
10002
+ const breakdown = priceTokens({ input, output, reasoning: 0, cache: { read: 0, write: 0 } }, pricing);
9886
10003
  return {
9887
10004
  taskId: task.id,
9888
10005
  taskName: task.name,
@@ -9890,9 +10007,15 @@ class CostForecaster {
9890
10007
  model: modelId,
9891
10008
  estimatedInputTokens: input,
9892
10009
  estimatedOutputTokens: output,
9893
- estimatedCost: inputCost + outputCost + overhead,
9894
- confidence: 0.6 + Math.random() * 0.3,
9895
- breakdown: { inputCost, outputCost, overhead }
10010
+ estimatedCost: breakdown.total,
10011
+ source: "estimated",
10012
+ pricingSource: source,
10013
+ confidence: undefined,
10014
+ breakdown: {
10015
+ inputCost: breakdown.inputCost,
10016
+ outputCost: breakdown.outputCost,
10017
+ cacheCost: breakdown.cacheCost
10018
+ }
9896
10019
  };
9897
10020
  }
9898
10021
  forecastAll(tasks, budgetRemaining) {
@@ -10100,6 +10223,12 @@ function lastAssistantText(messages) {
10100
10223
  }
10101
10224
  return "";
10102
10225
  }
10226
+ function sumBy(records, select) {
10227
+ let total = 0;
10228
+ for (const record of records.values())
10229
+ total += select(record);
10230
+ return total;
10231
+ }
10103
10232
  var DEFAULT_ESCALATION = {
10104
10233
  maxRetries: 3,
10105
10234
  retryDelay: 1000,
@@ -10145,6 +10274,8 @@ class NexusOrchestrator {
10145
10274
  onStateChange = null;
10146
10275
  stateChangeTimer = null;
10147
10276
  costHistory = [];
10277
+ tokensByModel = new Map;
10278
+ costProvenance = new Map;
10148
10279
  cleanupInterval = null;
10149
10280
  get healthMonitor() {
10150
10281
  return this._healthMonitor;
@@ -10162,6 +10293,7 @@ class NexusOrchestrator {
10162
10293
  this.messageRouter = new MessageRouter;
10163
10294
  this.escalationPolicy = {
10164
10295
  ...DEFAULT_ESCALATION,
10296
+ fallbackModels: [...DEFAULT_ESCALATION.fallbackModels],
10165
10297
  maxRetries: this.config.selfHealing.maxRetries,
10166
10298
  retryDelay: this.config.selfHealing.retryDelay,
10167
10299
  enableRespawn: this.config.selfHealing.contextTransfer
@@ -10171,7 +10303,7 @@ class NexusOrchestrator {
10171
10303
  this.performanceTracker = new PerformanceTracker;
10172
10304
  this.executionHistory = new ExecutionHistory;
10173
10305
  this.customRoles = new CustomRoleManager;
10174
- this.forecaster = new CostForecaster;
10306
+ this.forecaster = new CostForecaster((model, provider) => this.getModelCost(model, provider));
10175
10307
  }
10176
10308
  async initialize(ctx, onStateChange) {
10177
10309
  this.ctx = ctx;
@@ -10362,6 +10494,21 @@ class NexusOrchestrator {
10362
10494
  this.onStateChange?.();
10363
10495
  }, 100);
10364
10496
  }
10497
+ reloadConfigFromDisk(trigger = "event") {
10498
+ const projectDir = this.ctx?.location.directory;
10499
+ if (projectDir) {
10500
+ this.configManager.loadFromPath(projectDir, trigger);
10501
+ }
10502
+ const info = this.configManager.getLoadInfo();
10503
+ if (info) {
10504
+ this.notifyStateChange();
10505
+ this.emit("config:reloaded", info);
10506
+ }
10507
+ return info;
10508
+ }
10509
+ getConfigInfo() {
10510
+ return this.configManager.getLoadInfo();
10511
+ }
10365
10512
  async execute(request) {
10366
10513
  if (this.running) {
10367
10514
  throw new Error("Orchestrator is already running");
@@ -10394,6 +10541,7 @@ class NexusOrchestrator {
10394
10541
  agentsUsed: this.agents.size
10395
10542
  };
10396
10543
  } catch (error) {
10544
+ console.error("[nexus] DAG execution failed:", error);
10397
10545
  return {
10398
10546
  success: false,
10399
10547
  tasks: [],
@@ -10489,26 +10637,48 @@ class NexusOrchestrator {
10489
10637
  const spawnPromises = [];
10490
10638
  for (const node of readyNodes) {
10491
10639
  if (this.agents.size < this.config.maxConcurrency) {
10492
- spawnPromises.push(this.spawnAndExecute(node));
10640
+ spawnPromises.push(this.spawnAndExecute(node).catch((error) => {
10641
+ const err = error instanceof Error ? error : new Error(String(error));
10642
+ node.status = "failed";
10643
+ node.task.status = "failed";
10644
+ node.result = { success: false, error: err.message, duration: 0, tokensUsed: 0, cost: 0 };
10645
+ this.dag.markFailed(node.id, err);
10646
+ this.notifyStateChange();
10647
+ this.emit("task:failed", {
10648
+ taskId: node.id,
10649
+ taskName: node.task.name,
10650
+ role: node.task.requiredRole,
10651
+ error: err.message,
10652
+ duration: 0
10653
+ });
10654
+ }));
10493
10655
  }
10494
10656
  }
10495
10657
  await Promise.all(spawnPromises);
10496
10658
  await this.sleep(this.config.schedulerInterval);
10497
10659
  }
10498
10660
  }
10499
- async spawnAndExecute(node, transferContext) {
10661
+ selectQualifiedModel(node) {
10500
10662
  const complexity = this.analyzeComplexity(node.task);
10501
10663
  const model = this.selectModel(node.task.requiredRole, complexity);
10664
+ if (!model.provider || !model.model) {
10665
+ const missing = !model.provider && !model.model ? "provider and model" : !model.provider ? "provider" : "model";
10666
+ 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.`);
10667
+ }
10668
+ return `${model.provider}/${model.model}`;
10669
+ }
10670
+ async spawnAndExecute(node, options = {}) {
10671
+ const qualifiedModel = options.modelOverride ?? this.selectQualifiedModel(node);
10502
10672
  const agent = await this.spawnAgent({
10503
10673
  role: node.task.requiredRole,
10504
10674
  task: node.task,
10505
- model: model.model
10675
+ model: qualifiedModel
10506
10676
  });
10507
10677
  node.spawnedAgent = agent;
10508
10678
  node.status = "running";
10509
10679
  node.task.assignedAgent = agent.id;
10510
10680
  this.notifyStateChange();
10511
- await this.executeTask(agent, node, transferContext);
10681
+ await this.executeTask(agent, node, options.transferContext);
10512
10682
  }
10513
10683
  async executeTask(agent, node, transferContext) {
10514
10684
  if (!this.ctx || !agent.sessionID) {
@@ -10562,20 +10732,21 @@ Please continue from where the previous agent left off.`;
10562
10732
  const messages = await this.ctx.session.context({ sessionID: agent.sessionID });
10563
10733
  const output = lastAssistantText(messages) || "Task completed (no output captured)";
10564
10734
  const duration = Date.now() - startTime;
10735
+ const cost = await this.safeAccountTaskCost(agent, node.task);
10565
10736
  const result = {
10566
10737
  success: true,
10567
10738
  output,
10568
10739
  duration,
10569
- tokensUsed: 0,
10570
- cost: this.estimateModelCost(agent.model.model, agent.model.provider)
10740
+ tokensUsed: cost.tokensUsed,
10741
+ cost: cost.cost,
10742
+ costProvenance: cost.provenance
10571
10743
  };
10572
10744
  this.dag.markComplete(node.id, result);
10573
10745
  this.todoEnforcer.completeTask(agent.id);
10574
10746
  agent.metrics.tasksCompleted++;
10575
10747
  agent.metrics.totalCost += result.cost;
10576
- this.totalSpent += result.cost;
10577
- this.costByAgent.set(agent.id, (this.costByAgent.get(agent.id) || 0) + result.cost);
10578
- this.checkBudget();
10748
+ agent.metrics.totalTokens += result.tokensUsed;
10749
+ this.trackCost(agent.id, `${agent.model.provider}/${agent.model.model}`, result.cost, result.tokensUsed, result.costProvenance);
10579
10750
  this.performanceTracker.record({
10580
10751
  model: agent.model.model,
10581
10752
  role: node.task.requiredRole,
@@ -10617,14 +10788,20 @@ Please continue from where the previous agent left off.`;
10617
10788
  } catch (error) {
10618
10789
  const duration = Date.now() - startTime;
10619
10790
  const errorMessage = error.message || "Task failed";
10791
+ const cost = await this.safeAccountTaskCost(agent, node.task);
10620
10792
  const result = {
10621
10793
  success: false,
10622
10794
  error: errorMessage,
10623
10795
  duration,
10624
- tokensUsed: 0,
10625
- cost: 0
10796
+ tokensUsed: cost.tokensUsed,
10797
+ cost: cost.cost,
10798
+ costProvenance: cost.provenance
10626
10799
  };
10627
10800
  this.dag.markFailed(node.id, new Error(errorMessage));
10801
+ node.result = result;
10802
+ this.trackCost(agent.id, `${agent.model.provider}/${agent.model.model}`, result.cost, result.tokensUsed, result.costProvenance);
10803
+ agent.metrics.totalCost += result.cost;
10804
+ agent.metrics.totalTokens += result.tokensUsed;
10628
10805
  this.todoEnforcer.completeTask(agent.id);
10629
10806
  agent.metrics.tasksFailed++;
10630
10807
  agent.status = "failed";
@@ -10735,7 +10912,7 @@ Please continue from where the previous agent left off.`;
10735
10912
  await this.terminateAgent(agent.id);
10736
10913
  node.status = "pending";
10737
10914
  this.notifyStateChange();
10738
- await this.spawnAndExecute(node, context);
10915
+ await this.spawnAndExecute(node, { transferContext: context });
10739
10916
  return;
10740
10917
  }
10741
10918
  if (policy.fallbackModels.length > 0) {
@@ -10744,7 +10921,7 @@ Please continue from where the previous agent left off.`;
10744
10921
  await this.terminateAgent(agent.id);
10745
10922
  node.status = "pending";
10746
10923
  this.notifyStateChange();
10747
- await this.spawnAgent({ role: node.task.requiredRole, model: fallbackModel });
10924
+ await this.spawnAndExecute(node, { modelOverride: fallbackModel });
10748
10925
  return;
10749
10926
  }
10750
10927
  }
@@ -10829,7 +11006,8 @@ Please continue from where the previous agent left off.`;
10829
11006
  if (match) {
10830
11007
  modelConfig = match;
10831
11008
  } else {
10832
- throw new Error(`Invalid model "${config.model}". Use "providerID/modelID" format (e.g. "opencode-go/mimo-v2.5")`);
11009
+ const source = config.model ? "requested" : "configured for role";
11010
+ throw new Error(`Invalid model "${modelConfig}" (${source} "${config.role}"). Use "providerID/modelID" format (e.g. "opencode-go/mimo-v2.5")`);
10833
11011
  }
10834
11012
  }
10835
11013
  const slashIndex = modelConfig.indexOf("/");
@@ -10977,10 +11155,10 @@ Please continue from where the previous agent left off.`;
10977
11155
  if (qualified)
10978
11156
  return qualified;
10979
11157
  }
10980
- const bare = model.slice(model.indexOf("/") + 1);
11158
+ const bare = bareModelId(model);
10981
11159
  let best;
10982
11160
  for (const [key, cost] of this.modelCosts) {
10983
- if (key.slice(key.indexOf("/") + 1) !== bare)
11161
+ if (bareModelId(key) !== bare)
10984
11162
  continue;
10985
11163
  if (!best || cost.input < best.input)
10986
11164
  best = cost;
@@ -11062,28 +11240,96 @@ Please continue from where the previous agent left off.`;
11062
11240
  const scored = unique.map((m) => this.scoreModel(m, role, complexity));
11063
11241
  const budgetRemaining = this.budget.maxTotalCost - this.totalSpent;
11064
11242
  const affordable = scored.filter((s) => {
11065
- const cost = this.estimateModelCost(s.model, s.provider);
11066
- return cost <= budgetRemaining || cost === 0;
11243
+ const estimate = this.forecaster.estimateCost(complexity, s.model, s.provider);
11244
+ return estimate <= budgetRemaining || estimate === 0;
11067
11245
  });
11068
11246
  const best = (affordable.length > 0 ? affordable : scored).sort((a, b) => b.overallScore - a.overallScore)[0];
11069
11247
  return {
11070
11248
  provider: best.provider,
11071
11249
  model: best.model,
11072
- estimatedCost: this.estimateModelCost(best.model, best.provider),
11250
+ estimatedCost: this.forecaster.estimateCost(complexity, best.model, best.provider),
11073
11251
  estimatedQuality: best.qualityScore,
11074
11252
  reasoning: best.reasoning
11075
11253
  };
11076
11254
  }
11077
- trackCost(agentId, model, cost, tokens) {
11255
+ trackCost(agentId, model, cost, tokens, provenance) {
11078
11256
  this.totalSpent += cost;
11079
11257
  const agentCost = this.costByAgent.get(agentId) || 0;
11080
11258
  this.costByAgent.set(agentId, agentCost + cost);
11081
11259
  const modelCost = this.costByModel.get(model) || 0;
11082
11260
  this.costByModel.set(model, modelCost + cost);
11083
- this.costHistory.push({ timestamp: Date.now(), cost, agentId });
11261
+ this.tokensByModel.set(model, (this.tokensByModel.get(model) || 0) + tokens);
11262
+ this.recordProvenance(model, cost, provenance);
11263
+ this.costHistory.push({ timestamp: Date.now(), cost, agentId, model, tokens, provenance });
11084
11264
  this.checkBudget();
11085
11265
  this.notifyStateChange();
11086
11266
  }
11267
+ recordProvenance(model, cost, provenance) {
11268
+ const measured = provenance.usage === "measured";
11269
+ const prior = this.costProvenance.get(model) ?? {
11270
+ usage: provenance.usage,
11271
+ pricing: provenance.pricing,
11272
+ measuredEntries: 0,
11273
+ estimatedEntries: 0,
11274
+ measuredSpend: 0,
11275
+ estimatedSpend: 0
11276
+ };
11277
+ prior.usage = provenance.usage;
11278
+ prior.pricing = provenance.pricing;
11279
+ if (measured) {
11280
+ prior.measuredEntries++;
11281
+ prior.measuredSpend += cost;
11282
+ } else {
11283
+ prior.estimatedEntries++;
11284
+ prior.estimatedSpend += cost;
11285
+ }
11286
+ this.costProvenance.set(model, prior);
11287
+ }
11288
+ safeAccountTaskCost(agent, task) {
11289
+ return this.accountTaskCost(agent, task).catch(() => ({
11290
+ cost: 0,
11291
+ tokensUsed: 0,
11292
+ provenance: { usage: "estimated", pricing: "unknown-model" }
11293
+ }));
11294
+ }
11295
+ async accountTaskCost(agent, task) {
11296
+ const model = `${agent.model.provider}/${agent.model.model}`;
11297
+ const read = agent.sessionID ? await this.readSessionTokens(agent.sessionID) : { read: false };
11298
+ if (read.read) {
11299
+ const measured = this.forecaster.measureCost(read.usage, model, agent.model.provider);
11300
+ return {
11301
+ cost: measured.cost,
11302
+ tokensUsed: measured.tokens,
11303
+ provenance: { usage: "measured", pricing: measured.pricingSource }
11304
+ };
11305
+ }
11306
+ const predicted = this.forecaster.forecastTask(task, task.requiredRole, model, task.complexity);
11307
+ return {
11308
+ cost: predicted.estimatedCost,
11309
+ tokensUsed: predicted.estimatedInputTokens + predicted.estimatedOutputTokens,
11310
+ provenance: { usage: "estimated", pricing: predicted.pricingSource }
11311
+ };
11312
+ }
11313
+ async readSessionTokens(sessionID) {
11314
+ const finite = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
11315
+ try {
11316
+ const session = await this.ctx?.session.get({ sessionID });
11317
+ const tokens = session?.tokens;
11318
+ if (!tokens)
11319
+ return { read: false };
11320
+ return {
11321
+ read: true,
11322
+ usage: {
11323
+ input: finite(tokens.input),
11324
+ output: finite(tokens.output),
11325
+ reasoning: finite(tokens.reasoning),
11326
+ cache: { read: finite(tokens.cache?.read), write: finite(tokens.cache?.write) }
11327
+ }
11328
+ };
11329
+ } catch {
11330
+ return { read: false };
11331
+ }
11332
+ }
11087
11333
  checkBudget() {
11088
11334
  const remaining = this.budget.maxTotalCost - this.totalSpent;
11089
11335
  const remainingPercent = remaining / this.budget.maxTotalCost;
@@ -11138,8 +11384,20 @@ Please continue from where the previous agent left off.`;
11138
11384
  }
11139
11385
  getStatus(detailed) {
11140
11386
  const state = this.getState();
11387
+ const loadInfo = this.configManager.getLoadInfo();
11388
+ const config = {
11389
+ loaded: loadInfo !== null,
11390
+ loadedAt: loadInfo?.loadedAt ?? null,
11391
+ loadCount: loadInfo?.loadCount ?? 0,
11392
+ sessionOverride: this.configManager.hasSessionOverride(),
11393
+ diskModelsIgnored: this.configManager.hasSessionOverride(),
11394
+ trigger: loadInfo?.trigger ?? null,
11395
+ project: loadInfo?.project ?? null,
11396
+ global: loadInfo?.global ?? null,
11397
+ models: this.configManager.getResolvedModels()
11398
+ };
11141
11399
  if (detailed)
11142
- return JSON.stringify({ ...state, budgetExceeded: this.budgetExceeded }, null, 2);
11400
+ return JSON.stringify({ ...state, budgetExceeded: this.budgetExceeded, config }, null, 2);
11143
11401
  return JSON.stringify({
11144
11402
  running: state.running,
11145
11403
  paused: state.paused,
@@ -11147,7 +11405,8 @@ Please continue from where the previous agent left off.`;
11147
11405
  agents: state.agents.length,
11148
11406
  tasks: state.tasks.length,
11149
11407
  totalCost: state.totalSpent,
11150
- budgetRemaining: state.budgetRemaining
11408
+ budgetRemaining: state.budgetRemaining,
11409
+ config
11151
11410
  }, null, 2);
11152
11411
  }
11153
11412
  listAgents(filter) {
@@ -11161,7 +11420,11 @@ Please continue from where the previous agent left off.`;
11161
11420
  totalSpent: state.totalSpent,
11162
11421
  budgetRemaining: state.budgetRemaining,
11163
11422
  byAgent: Object.fromEntries(this.costByAgent),
11164
- byModel: Object.fromEntries(this.costByModel)
11423
+ byModel: Object.fromEntries(this.costByModel),
11424
+ tokensByModel: Object.fromEntries(this.tokensByModel),
11425
+ provenance: Object.fromEntries(this.costProvenance),
11426
+ measuredEntries: sumBy(this.costProvenance, (p) => p.measuredEntries),
11427
+ estimatedEntries: sumBy(this.costProvenance, (p) => p.estimatedEntries)
11165
11428
  }, null, 2);
11166
11429
  }
11167
11430
  pause() {
@@ -11557,9 +11820,126 @@ class AstGrep {
11557
11820
  }
11558
11821
 
11559
11822
  // src/index.ts
11560
- import { writeFileSync as writeFileSync2, readFileSync as readFileSync3, mkdirSync as mkdirSync4, existsSync as existsSync3 } from "node:fs";
11561
- import { join as join6 } from "node:path";
11823
+ import { writeFileSync as writeFileSync2, readFileSync as readFileSync3, mkdirSync as mkdirSync4, existsSync as existsSync3, statSync } from "node:fs";
11824
+ import { join as join6, resolve as resolve3, basename, dirname as dirname4 } from "node:path";
11562
11825
  import { homedir as homedir2 } from "node:os";
11826
+ var CONFIG_RELOAD_DEBOUNCE_MS = 150;
11827
+ var CONFIG_POLL_INTERVAL_MS = 2000;
11828
+ function isNexusConfigFile(file, eventDirectory, projectDirectory, projectPath, globalPath) {
11829
+ const resolved = resolve3(file);
11830
+ if (resolved === projectPath || resolved === globalPath)
11831
+ return true;
11832
+ if (eventDirectory !== undefined && resolve3(eventDirectory) !== projectDirectory)
11833
+ return false;
11834
+ return basename(resolved) === "nexus.jsonc" && basename(dirname4(resolved)) === ".opencode";
11835
+ }
11836
+ var watchedContexts = new WeakSet;
11837
+ var MAX_RELOAD_WAIT_MS = 1000;
11838
+ var MIN_INTERVAL_MS = 25;
11839
+ var clampInterval = (ms, fallback) => Number.isFinite(ms) && ms >= MIN_INTERVAL_MS ? ms : fallback;
11840
+ function watchConfigFiles(ctx, orchestrator, debounceMs = CONFIG_RELOAD_DEBOUNCE_MS, pollMs = CONFIG_POLL_INTERVAL_MS) {
11841
+ if (watchedContexts.has(ctx))
11842
+ return () => {};
11843
+ watchedContexts.add(ctx);
11844
+ const debounce = clampInterval(debounceMs, CONFIG_RELOAD_DEBOUNCE_MS);
11845
+ const pollEvery = clampInterval(pollMs, CONFIG_POLL_INTERVAL_MS);
11846
+ const projectDirectory = resolve3(ctx.location.directory);
11847
+ const projectPath = nexusProjectConfigPath(ctx.location.directory);
11848
+ const globalPath = nexusGlobalConfigPath();
11849
+ const watchedPaths = [projectPath, globalPath];
11850
+ const controller = new AbortController;
11851
+ let debounceTimer = null;
11852
+ let firstScheduledAt = 0;
11853
+ const clearPendingReload = () => {
11854
+ if (debounceTimer === null)
11855
+ return;
11856
+ clearTimeout(debounceTimer);
11857
+ debounceTimer = null;
11858
+ };
11859
+ const stampOf = (filePath) => {
11860
+ try {
11861
+ const stats = statSync(filePath);
11862
+ if (!stats.isFile())
11863
+ return "absent";
11864
+ return `${stats.mtimeMs}:${stats.size}`;
11865
+ } catch {
11866
+ return "absent";
11867
+ }
11868
+ };
11869
+ const lastSeen = new Map(watchedPaths.map((p) => [p, stampOf(p)]));
11870
+ const runReload = (trigger) => {
11871
+ for (const filePath of watchedPaths)
11872
+ lastSeen.set(filePath, stampOf(filePath));
11873
+ try {
11874
+ orchestrator.reloadConfigFromDisk(trigger);
11875
+ } catch (err) {
11876
+ console.warn(`[nexus] config reload failed (${trigger}): ${String(err)}`);
11877
+ }
11878
+ };
11879
+ const scheduleReload = (trigger) => {
11880
+ const pending = debounceTimer !== null;
11881
+ const waited = pending ? Date.now() - firstScheduledAt : 0;
11882
+ clearPendingReload();
11883
+ if (pending && waited >= MAX_RELOAD_WAIT_MS) {
11884
+ runReload(trigger);
11885
+ return;
11886
+ }
11887
+ if (!pending)
11888
+ firstScheduledAt = Date.now();
11889
+ debounceTimer = setTimeout(() => {
11890
+ debounceTimer = null;
11891
+ runReload(trigger);
11892
+ }, debounce);
11893
+ };
11894
+ const poll = () => {
11895
+ let changed = false;
11896
+ for (const filePath of watchedPaths) {
11897
+ const current = stampOf(filePath);
11898
+ if (lastSeen.get(filePath) === current)
11899
+ continue;
11900
+ lastSeen.set(filePath, current);
11901
+ changed = true;
11902
+ }
11903
+ if (changed)
11904
+ scheduleReload("poll");
11905
+ };
11906
+ const pollTimer = setInterval(poll, pollEvery);
11907
+ pollTimer.unref();
11908
+ const eventDomain = ctx.event;
11909
+ if (typeof eventDomain?.subscribe !== "function") {
11910
+ console.warn("[nexus] no config event stream on this context; polling is the only config reload trigger");
11911
+ return () => {
11912
+ clearInterval(pollTimer);
11913
+ clearPendingReload();
11914
+ controller.abort();
11915
+ };
11916
+ }
11917
+ const consume = async () => {
11918
+ try {
11919
+ for await (const event of eventDomain.subscribe({ signal: controller.signal })) {
11920
+ if (event.type !== "filesystem.changed")
11921
+ continue;
11922
+ const file = event.data?.file;
11923
+ if (typeof file !== "string")
11924
+ continue;
11925
+ if (!isNexusConfigFile(file, event.location?.directory, projectDirectory, projectPath, globalPath))
11926
+ continue;
11927
+ scheduleReload("event");
11928
+ }
11929
+ if (!controller.signal.aborted) {
11930
+ console.warn("[nexus] config event stream ended; polling remains the only config reload trigger");
11931
+ }
11932
+ } catch (err) {
11933
+ console.warn(`[nexus] config event stream failed; polling remains the only config reload trigger: ${String(err)}`);
11934
+ }
11935
+ };
11936
+ consume();
11937
+ return () => {
11938
+ clearInterval(pollTimer);
11939
+ clearPendingReload();
11940
+ controller.abort();
11941
+ };
11942
+ }
11563
11943
  var NEXUS_AGENT_CONTENT = `---
11564
11944
  description: Nexus multi-agent orchestrator — decomposes tasks and delegates to specialized sub-agents
11565
11945
  mode: primary
@@ -12089,7 +12469,7 @@ You are a technical writer who creates documentation that developers actually wa
12089
12469
  });
12090
12470
  editor.add({
12091
12471
  name: "status",
12092
- description: "Get orchestrator status and metrics",
12472
+ description: "Get orchestrator status, metrics, and the config files currently in effect (paths consulted, which existed, resolved role -> model map)",
12093
12473
  input: {
12094
12474
  type: "object",
12095
12475
  properties: {
@@ -12228,7 +12608,10 @@ You are a technical writer who creates documentation that developers actually wa
12228
12608
  const { name } = input;
12229
12609
  try {
12230
12610
  orchestrator.configManager.applyPreset(name);
12231
- return { content: `Applied preset: ${PRESETS[name]?.name || name}` };
12611
+ return {
12612
+ content: `Applied preset: ${PRESETS[name]?.name || name}
12613
+ ` + `⚠️ 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.`
12614
+ };
12232
12615
  } catch (error) {
12233
12616
  return { content: `Error: ${error.message}` };
12234
12617
  }
@@ -13201,19 +13584,24 @@ ${lines.join(`
13201
13584
  event.metadata = { ...event.metadata, nexusResult: result };
13202
13585
  }
13203
13586
  });
13587
+ const stopConfigWatch = watchConfigFiles(ctx, orchestrator);
13204
13588
  return () => {
13589
+ stopConfigWatch();
13205
13590
  orchestrator.shutdown();
13206
13591
  };
13207
13592
  }
13208
13593
  });
13209
13594
  export {
13210
13595
  AstGrep,
13596
+ CONFIG_POLL_INTERVAL_MS,
13597
+ CONFIG_RELOAD_DEBOUNCE_MS,
13211
13598
  CostForecaster,
13212
13599
  CustomRoleManager,
13213
13600
  DEFAULT_CONFIG,
13214
13601
  GoalManager,
13215
13602
  HealthMonitor,
13216
13603
  LearningModule,
13604
+ MAX_RELOAD_WAIT_MS,
13217
13605
  MessageRouter,
13218
13606
  MessageStore,
13219
13607
  ModuleRegistry,
@@ -13232,5 +13620,6 @@ export {
13232
13620
  detectCycles,
13233
13621
  getTemplate,
13234
13622
  instantiateTemplate,
13235
- listTemplates
13623
+ listTemplates,
13624
+ watchConfigFiles
13236
13625
  };
package/dist/tui.js CHANGED
@@ -9,7 +9,7 @@ var PluginContext = createContext();
9
9
  // src/config.ts
10
10
  import { readFileSync, writeFileSync, mkdirSync } from "fs";
11
11
  import { homedir } from "os";
12
- import { join, dirname } from "path";
12
+ import { join, dirname, resolve } from "path";
13
13
  function stripJsonComments(jsonc) {
14
14
  const result = [];
15
15
  let i = 0;
@@ -57,14 +57,46 @@ function readJsoncFile(filePath) {
57
57
  const raw = readFileSync(filePath, "utf-8");
58
58
  const stripped = stripJsonComments(raw);
59
59
  const parsed = JSON.parse(stripped);
60
- return parsed;
60
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
61
+ console.warn(`[nexus] Ignoring config from ${filePath}: expected a JSON object, got ${Array.isArray(parsed) ? "array" : typeof parsed}`);
62
+ return { config: null, existed: true };
63
+ }
64
+ return { config: parsed, existed: true };
61
65
  } catch (err) {
62
- if (err.code !== "ENOENT") {
63
- console.warn(`[nexus] Failed to load config from ${filePath}: ${err.message}`);
66
+ const code = err.code;
67
+ const message = err instanceof Error ? err.message : String(err);
68
+ const absent = code === "ENOENT" || code === "ENOTDIR";
69
+ if (!absent) {
70
+ console.warn(`[nexus] Failed to load config from ${filePath}: ${message}`);
64
71
  }
65
- return null;
72
+ return { config: null, existed: !absent };
66
73
  }
67
74
  }
75
+ function nexusProjectConfigPath(basePath) {
76
+ return resolve(join(basePath, ".opencode", "nexus.jsonc"));
77
+ }
78
+ function nexusGlobalConfigPath() {
79
+ return resolve(join(homedir(), ".config", "opencode", "nexus.jsonc"));
80
+ }
81
+ function redactHome(filePath) {
82
+ const home = homedir();
83
+ if (filePath === home)
84
+ return "~";
85
+ const prefix = home.endsWith("/") ? home : `${home}/`;
86
+ return filePath.startsWith(prefix) ? `~${filePath.slice(home.length)}` : filePath;
87
+ }
88
+ function describeFile(file) {
89
+ if (file.parsed)
90
+ return "loaded";
91
+ if (file.existed)
92
+ return "unparseable";
93
+ return "absent";
94
+ }
95
+ function formatConfigLoadLog(info) {
96
+ const models = Object.entries(info.models).map(([role, model]) => `${role}=${model}`).join(" ");
97
+ const override = info.sessionOverride ? " (+session override: storage; disk edits to models are IGNORED while a preset is set \u2014 clear the preset in the TUI to hand control back to disk)" : "";
98
+ 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}`;
99
+ }
68
100
  var DEFAULT_CONFIG = {
69
101
  models: {
70
102
  architect: "anthropic/claude-sonnet-4-6",
@@ -91,19 +123,51 @@ class NexusConfigManager {
91
123
  projectConfig = null;
92
124
  globalConfig = null;
93
125
  storageConfig = null;
126
+ loadInfo = null;
94
127
  constructor() {
95
128
  this.projectConfig = null;
96
129
  this.globalConfig = null;
97
130
  }
98
131
  loadConfigs(basePath) {
99
- this.storageConfig = null;
100
- const projectPath = join(basePath, ".opencode", "nexus.jsonc");
101
- this.projectConfig = readJsoncFile(projectPath);
102
- const globalPath = join(homedir(), ".config", "opencode", "nexus.jsonc");
103
- this.globalConfig = readJsoncFile(globalPath);
132
+ if (this.loadInfo === null)
133
+ this.storageConfig = null;
134
+ const projectPath = nexusProjectConfigPath(basePath);
135
+ const project = readJsoncFile(projectPath);
136
+ this.projectConfig = project.config;
137
+ const globalPath = nexusGlobalConfigPath();
138
+ const global = readJsoncFile(globalPath);
139
+ this.globalConfig = global.config;
140
+ return {
141
+ project: { path: redactHome(projectPath), existed: project.existed, parsed: project.config !== null },
142
+ global: { path: redactHome(globalPath), existed: global.existed, parsed: global.config !== null }
143
+ };
104
144
  }
105
- loadFromPath(basePath) {
106
- this.loadConfigs(basePath);
145
+ loadFromPath(basePath, trigger = "initial") {
146
+ const sources = this.loadConfigs(basePath);
147
+ this.loadInfo = {
148
+ project: sources.project,
149
+ global: sources.global,
150
+ models: this.getResolvedModels(),
151
+ sessionOverride: this.hasSessionOverride(),
152
+ loadedAt: new Date().toISOString(),
153
+ loadCount: (this.loadInfo?.loadCount ?? 0) + 1,
154
+ trigger
155
+ };
156
+ console.log(formatConfigLoadLog(this.loadInfo));
157
+ }
158
+ getLoadInfo() {
159
+ return this.loadInfo;
160
+ }
161
+ getResolvedModels() {
162
+ const models = {};
163
+ for (const [role, model] of Object.entries(this.getConfig().models)) {
164
+ if (model)
165
+ models[role] = model;
166
+ }
167
+ return models;
168
+ }
169
+ hasSessionOverride() {
170
+ return this.storageConfig !== null;
107
171
  }
108
172
  getConfig() {
109
173
  return {
@@ -206,17 +270,17 @@ class NexusConfigManager {
206
270
  `, "utf-8");
207
271
  }
208
272
  saveProjectConfig(basePath) {
209
- const projectPath = join(basePath, ".opencode", "nexus.jsonc");
273
+ const projectPath = nexusProjectConfigPath(basePath);
210
274
  const config = this.getSaveableConfig();
211
275
  this.writeJsoncFile(projectPath, config);
212
276
  }
213
277
  saveGlobalConfig() {
214
- const globalPath = join(homedir(), ".config", "opencode", "nexus.jsonc");
278
+ const globalPath = nexusGlobalConfigPath();
215
279
  const config = this.getSaveableConfig();
216
280
  this.writeJsoncFile(globalPath, config);
217
281
  }
218
282
  initProjectConfig(basePath) {
219
- const projectPath = join(basePath, ".opencode", "nexus.jsonc");
283
+ const projectPath = nexusProjectConfigPath(basePath);
220
284
  try {
221
285
  readFileSync(projectPath, "utf-8");
222
286
  return;
@@ -224,7 +288,7 @@ class NexusConfigManager {
224
288
  this.writeJsoncFile(projectPath, { ...DEFAULT_CONFIG });
225
289
  }
226
290
  initGlobalConfig() {
227
- const globalPath = join(homedir(), ".config", "opencode", "nexus.jsonc");
291
+ const globalPath = nexusGlobalConfigPath();
228
292
  try {
229
293
  readFileSync(globalPath, "utf-8");
230
294
  return;
@@ -390,6 +454,78 @@ var PRESETS = {
390
454
 
391
455
  // src/tui.tsx
392
456
  import { jsxDEV, Fragment } from "@opentui/solid/jsx-dev-runtime";
457
+ var NEXUS_AGENT_PREFIX = "nexus-";
458
+ function metadataString(metadata, key) {
459
+ const value = metadata?.[key];
460
+ return typeof value === "string" && value.length > 0 ? value : undefined;
461
+ }
462
+ function roleFromAgent(agent) {
463
+ if (!agent)
464
+ return;
465
+ const role = agent.startsWith(NEXUS_AGENT_PREFIX) ? agent.slice(NEXUS_AGENT_PREFIX.length) : agent;
466
+ return role.length > 0 ? role : undefined;
467
+ }
468
+ function modelLabel(model) {
469
+ if (!model?.providerID || !model.id)
470
+ return;
471
+ return `${model.providerID}/${model.id}`;
472
+ }
473
+ function titleCase(value) {
474
+ return value.charAt(0).toUpperCase() + value.slice(1);
475
+ }
476
+ function sidebarAgentFor(session, status, now) {
477
+ const role = metadataString(session.metadata, "nexusRole") ?? roleFromAgent(session.agent);
478
+ return {
479
+ id: session.id,
480
+ name: role ? titleCase(role) : session.title || session.id.slice(0, 12),
481
+ role: role ?? "agent",
482
+ status,
483
+ model: metadataString(session.metadata, "nexusModel") ?? modelLabel(session.model) ?? "",
484
+ sessionID: session.id,
485
+ spawnedAt: now,
486
+ tasksCompleted: 0,
487
+ tasksFailed: 0
488
+ };
489
+ }
490
+ function sidebarChildSessionIDs(family, currentSessionID) {
491
+ if (!Array.isArray(family))
492
+ return [];
493
+ return family.filter((id) => id !== currentSessionID);
494
+ }
495
+ function collectSidebarAgents(source, currentSessionID, statusOf, now) {
496
+ const childIDs = sidebarChildSessionIDs(source.family(currentSessionID), currentSessionID);
497
+ if (childIDs.length === 0)
498
+ return [];
499
+ const agents = [];
500
+ for (const id of childIDs) {
501
+ try {
502
+ const session = source.get(id);
503
+ if (!session)
504
+ continue;
505
+ let status = "completed";
506
+ try {
507
+ status = statusOf(id);
508
+ } catch {}
509
+ agents.push(sidebarAgentFor(session, status, now));
510
+ } catch {}
511
+ }
512
+ return agents;
513
+ }
514
+ function mergeSidebarAgents(agents, polled, childIDs) {
515
+ const next = agents.map((agent) => ({ ...agent }));
516
+ for (const agent of polled) {
517
+ const existing = next.find((a) => a.sessionID === agent.sessionID);
518
+ if (existing) {
519
+ existing.status = agent.status;
520
+ existing.name = agent.name;
521
+ existing.role = agent.role;
522
+ existing.model = agent.model;
523
+ } else {
524
+ next.push(agent);
525
+ }
526
+ }
527
+ return next.filter((a) => a.sessionID && childIDs.includes(a.sessionID) || a.status === "completed" || a.status === "failed");
528
+ }
393
529
  var tui_default = define({
394
530
  id: "nexus.cli",
395
531
  setup(context) {
@@ -784,69 +920,20 @@ var tui_default = define({
784
920
  const currentSessionID = currentRoute.sessionID;
785
921
  if (!currentSessionID)
786
922
  return;
787
- const family = context.data.session.family(currentSessionID);
788
- if (!family || !Array.isArray(family))
789
- return;
790
- const childIDs = family.filter((id) => id !== currentSessionID);
791
- try {
792
- const allSessions = context.data.session.list();
793
- if (allSessions && Array.isArray(allSessions)) {
794
- for (const s of allSessions) {
795
- if (!s || !s.id)
796
- continue;
797
- const meta = s.metadata;
798
- if (meta?.nexusRole && !childIDs.includes(s.id) && s.id !== currentSessionID) {
799
- childIDs.push(s.id);
800
- }
801
- }
802
- }
803
- } catch {}
923
+ const source = context.data.session;
924
+ const childIDs = sidebarChildSessionIDs(source.family(currentSessionID), currentSessionID);
804
925
  if (childIDs.length === 0) {
805
926
  setSidebarState((draft) => {
806
- draft.agents = draft.agents.filter((a) => a.status === "completed" || a.status === "failed");
927
+ draft.agents = mergeSidebarAgents(draft.agents, [], []);
807
928
  });
808
929
  return;
809
930
  }
810
- const newAgents = childIDs.map((id) => {
811
- try {
812
- const session = context.data.session.get(id);
813
- if (!session)
814
- return null;
815
- const meta = session.metadata || {};
816
- let status = "completed";
817
- try {
818
- status = context.data.session.status(id) === "running" ? "working" : "completed";
819
- } catch {}
820
- return {
821
- id,
822
- name: meta.nexusRole ? `${meta.nexusRole.charAt(0).toUpperCase() + meta.nexusRole.slice(1)}` : session?.title || id.slice(0, 12),
823
- role: meta.nexusRole || "agent",
824
- status,
825
- model: meta.nexusModel || "",
826
- sessionID: id,
827
- spawnedAt: new Date().toISOString(),
828
- tasksCompleted: 0,
829
- tasksFailed: 0
830
- };
831
- } catch {
832
- return null;
833
- }
834
- }).filter(Boolean);
835
- if (newAgents.length > 0) {
836
- setSidebarState((draft) => {
837
- for (const agent of newAgents) {
838
- const existing = draft.agents.find((a) => a.sessionID === agent.sessionID);
839
- if (existing) {
840
- existing.status = agent.status;
841
- existing.name = agent.name;
842
- existing.model = agent.model;
843
- } else {
844
- draft.agents.push(agent);
845
- }
846
- }
847
- draft.agents = draft.agents.filter((a) => a.sessionID && childIDs.includes(a.sessionID) || a.status === "completed" || a.status === "failed");
848
- });
849
- }
931
+ const newAgents = collectSidebarAgents(source, currentSessionID, (id) => context.data.session.status(id) === "running" ? "working" : "completed", new Date().toISOString());
932
+ if (newAgents.length === 0)
933
+ return;
934
+ setSidebarState((draft) => {
935
+ draft.agents = mergeSidebarAgents(draft.agents, newAgents, childIDs);
936
+ });
850
937
  } catch {}
851
938
  };
852
939
  const unsubSessionSucceeded = context.data.on("session.execution.succeeded", (event) => {
@@ -975,5 +1062,9 @@ var tui_default = define({
975
1062
  }
976
1063
  });
977
1064
  export {
978
- tui_default as default
1065
+ collectSidebarAgents,
1066
+ tui_default as default,
1067
+ mergeSidebarAgents,
1068
+ sidebarAgentFor,
1069
+ sidebarChildSessionIDs
979
1070
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serkanalgur/opencode-nexus",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Adaptive Multi-Agent Orchestration with Cost Intelligence for OpenCode V2",
5
5
  "keywords": [
6
6
  "opencode",