@serkanalgur/opencode-nexus 2.4.0 → 2.4.1

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 +265 -26
  2. package/dist/tui.js +80 -16
  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));
8461
8514
  }
8462
- loadFromPath(basePath) {
8463
- this.loadConfigs(basePath);
8515
+ getLoadInfo() {
8516
+ return this.loadInfo;
8517
+ }
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;
@@ -10362,6 +10426,21 @@ class NexusOrchestrator {
10362
10426
  this.onStateChange?.();
10363
10427
  }, 100);
10364
10428
  }
10429
+ reloadConfigFromDisk(trigger = "event") {
10430
+ const projectDir = this.ctx?.location.directory;
10431
+ if (projectDir) {
10432
+ this.configManager.loadFromPath(projectDir, trigger);
10433
+ }
10434
+ const info = this.configManager.getLoadInfo();
10435
+ if (info) {
10436
+ this.notifyStateChange();
10437
+ this.emit("config:reloaded", info);
10438
+ }
10439
+ return info;
10440
+ }
10441
+ getConfigInfo() {
10442
+ return this.configManager.getLoadInfo();
10443
+ }
10365
10444
  async execute(request) {
10366
10445
  if (this.running) {
10367
10446
  throw new Error("Orchestrator is already running");
@@ -10394,6 +10473,7 @@ class NexusOrchestrator {
10394
10473
  agentsUsed: this.agents.size
10395
10474
  };
10396
10475
  } catch (error) {
10476
+ console.error("[nexus] DAG execution failed:", error);
10397
10477
  return {
10398
10478
  success: false,
10399
10479
  tasks: [],
@@ -10489,7 +10569,21 @@ class NexusOrchestrator {
10489
10569
  const spawnPromises = [];
10490
10570
  for (const node of readyNodes) {
10491
10571
  if (this.agents.size < this.config.maxConcurrency) {
10492
- spawnPromises.push(this.spawnAndExecute(node));
10572
+ spawnPromises.push(this.spawnAndExecute(node).catch((error) => {
10573
+ const err = error instanceof Error ? error : new Error(String(error));
10574
+ node.status = "failed";
10575
+ node.task.status = "failed";
10576
+ node.result = { success: false, error: err.message, duration: 0, tokensUsed: 0, cost: 0 };
10577
+ this.dag.markFailed(node.id, err);
10578
+ this.notifyStateChange();
10579
+ this.emit("task:failed", {
10580
+ taskId: node.id,
10581
+ taskName: node.task.name,
10582
+ role: node.task.requiredRole,
10583
+ error: err.message,
10584
+ duration: 0
10585
+ });
10586
+ }));
10493
10587
  }
10494
10588
  }
10495
10589
  await Promise.all(spawnPromises);
@@ -10499,10 +10593,15 @@ class NexusOrchestrator {
10499
10593
  async spawnAndExecute(node, transferContext) {
10500
10594
  const complexity = this.analyzeComplexity(node.task);
10501
10595
  const model = this.selectModel(node.task.requiredRole, complexity);
10596
+ if (!model.provider || !model.model) {
10597
+ const missing = !model.provider && !model.model ? "provider and model" : !model.provider ? "provider" : "model";
10598
+ 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
+ }
10600
+ const qualifiedModel = `${model.provider}/${model.model}`;
10502
10601
  const agent = await this.spawnAgent({
10503
10602
  role: node.task.requiredRole,
10504
10603
  task: node.task,
10505
- model: model.model
10604
+ model: qualifiedModel
10506
10605
  });
10507
10606
  node.spawnedAgent = agent;
10508
10607
  node.status = "running";
@@ -10829,7 +10928,8 @@ Please continue from where the previous agent left off.`;
10829
10928
  if (match) {
10830
10929
  modelConfig = match;
10831
10930
  } else {
10832
- throw new Error(`Invalid model "${config.model}". Use "providerID/modelID" format (e.g. "opencode-go/mimo-v2.5")`);
10931
+ const source = config.model ? "requested" : "configured for role";
10932
+ throw new Error(`Invalid model "${modelConfig}" (${source} "${config.role}"). Use "providerID/modelID" format (e.g. "opencode-go/mimo-v2.5")`);
10833
10933
  }
10834
10934
  }
10835
10935
  const slashIndex = modelConfig.indexOf("/");
@@ -11138,8 +11238,20 @@ Please continue from where the previous agent left off.`;
11138
11238
  }
11139
11239
  getStatus(detailed) {
11140
11240
  const state = this.getState();
11241
+ const loadInfo = this.configManager.getLoadInfo();
11242
+ const config = {
11243
+ loaded: loadInfo !== null,
11244
+ loadedAt: loadInfo?.loadedAt ?? null,
11245
+ loadCount: loadInfo?.loadCount ?? 0,
11246
+ sessionOverride: this.configManager.hasSessionOverride(),
11247
+ diskModelsIgnored: this.configManager.hasSessionOverride(),
11248
+ trigger: loadInfo?.trigger ?? null,
11249
+ project: loadInfo?.project ?? null,
11250
+ global: loadInfo?.global ?? null,
11251
+ models: this.configManager.getResolvedModels()
11252
+ };
11141
11253
  if (detailed)
11142
- return JSON.stringify({ ...state, budgetExceeded: this.budgetExceeded }, null, 2);
11254
+ return JSON.stringify({ ...state, budgetExceeded: this.budgetExceeded, config }, null, 2);
11143
11255
  return JSON.stringify({
11144
11256
  running: state.running,
11145
11257
  paused: state.paused,
@@ -11147,7 +11259,8 @@ Please continue from where the previous agent left off.`;
11147
11259
  agents: state.agents.length,
11148
11260
  tasks: state.tasks.length,
11149
11261
  totalCost: state.totalSpent,
11150
- budgetRemaining: state.budgetRemaining
11262
+ budgetRemaining: state.budgetRemaining,
11263
+ config
11151
11264
  }, null, 2);
11152
11265
  }
11153
11266
  listAgents(filter) {
@@ -11557,9 +11670,126 @@ class AstGrep {
11557
11670
  }
11558
11671
 
11559
11672
  // 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";
11673
+ import { writeFileSync as writeFileSync2, readFileSync as readFileSync3, mkdirSync as mkdirSync4, existsSync as existsSync3, statSync } from "node:fs";
11674
+ import { join as join6, resolve as resolve3, basename, dirname as dirname4 } from "node:path";
11562
11675
  import { homedir as homedir2 } from "node:os";
11676
+ var CONFIG_RELOAD_DEBOUNCE_MS = 150;
11677
+ var CONFIG_POLL_INTERVAL_MS = 2000;
11678
+ function isNexusConfigFile(file, eventDirectory, projectDirectory, projectPath, globalPath) {
11679
+ const resolved = resolve3(file);
11680
+ if (resolved === projectPath || resolved === globalPath)
11681
+ return true;
11682
+ if (eventDirectory !== undefined && resolve3(eventDirectory) !== projectDirectory)
11683
+ return false;
11684
+ return basename(resolved) === "nexus.jsonc" && basename(dirname4(resolved)) === ".opencode";
11685
+ }
11686
+ var watchedContexts = new WeakSet;
11687
+ var MAX_RELOAD_WAIT_MS = 1000;
11688
+ var MIN_INTERVAL_MS = 25;
11689
+ var clampInterval = (ms, fallback) => Number.isFinite(ms) && ms >= MIN_INTERVAL_MS ? ms : fallback;
11690
+ function watchConfigFiles(ctx, orchestrator, debounceMs = CONFIG_RELOAD_DEBOUNCE_MS, pollMs = CONFIG_POLL_INTERVAL_MS) {
11691
+ if (watchedContexts.has(ctx))
11692
+ return () => {};
11693
+ watchedContexts.add(ctx);
11694
+ const debounce = clampInterval(debounceMs, CONFIG_RELOAD_DEBOUNCE_MS);
11695
+ const pollEvery = clampInterval(pollMs, CONFIG_POLL_INTERVAL_MS);
11696
+ const projectDirectory = resolve3(ctx.location.directory);
11697
+ const projectPath = nexusProjectConfigPath(ctx.location.directory);
11698
+ const globalPath = nexusGlobalConfigPath();
11699
+ const watchedPaths = [projectPath, globalPath];
11700
+ const controller = new AbortController;
11701
+ let debounceTimer = null;
11702
+ let firstScheduledAt = 0;
11703
+ const clearPendingReload = () => {
11704
+ if (debounceTimer === null)
11705
+ return;
11706
+ clearTimeout(debounceTimer);
11707
+ debounceTimer = null;
11708
+ };
11709
+ const stampOf = (filePath) => {
11710
+ try {
11711
+ const stats = statSync(filePath);
11712
+ if (!stats.isFile())
11713
+ return "absent";
11714
+ return `${stats.mtimeMs}:${stats.size}`;
11715
+ } catch {
11716
+ return "absent";
11717
+ }
11718
+ };
11719
+ const lastSeen = new Map(watchedPaths.map((p) => [p, stampOf(p)]));
11720
+ const runReload = (trigger) => {
11721
+ for (const filePath of watchedPaths)
11722
+ lastSeen.set(filePath, stampOf(filePath));
11723
+ try {
11724
+ orchestrator.reloadConfigFromDisk(trigger);
11725
+ } catch (err) {
11726
+ console.warn(`[nexus] config reload failed (${trigger}): ${String(err)}`);
11727
+ }
11728
+ };
11729
+ const scheduleReload = (trigger) => {
11730
+ const pending = debounceTimer !== null;
11731
+ const waited = pending ? Date.now() - firstScheduledAt : 0;
11732
+ clearPendingReload();
11733
+ if (pending && waited >= MAX_RELOAD_WAIT_MS) {
11734
+ runReload(trigger);
11735
+ return;
11736
+ }
11737
+ if (!pending)
11738
+ firstScheduledAt = Date.now();
11739
+ debounceTimer = setTimeout(() => {
11740
+ debounceTimer = null;
11741
+ runReload(trigger);
11742
+ }, debounce);
11743
+ };
11744
+ const poll = () => {
11745
+ let changed = false;
11746
+ for (const filePath of watchedPaths) {
11747
+ const current = stampOf(filePath);
11748
+ if (lastSeen.get(filePath) === current)
11749
+ continue;
11750
+ lastSeen.set(filePath, current);
11751
+ changed = true;
11752
+ }
11753
+ if (changed)
11754
+ scheduleReload("poll");
11755
+ };
11756
+ const pollTimer = setInterval(poll, pollEvery);
11757
+ pollTimer.unref();
11758
+ const eventDomain = ctx.event;
11759
+ if (typeof eventDomain?.subscribe !== "function") {
11760
+ console.warn("[nexus] no config event stream on this context; polling is the only config reload trigger");
11761
+ return () => {
11762
+ clearInterval(pollTimer);
11763
+ clearPendingReload();
11764
+ controller.abort();
11765
+ };
11766
+ }
11767
+ const consume = async () => {
11768
+ try {
11769
+ for await (const event of eventDomain.subscribe({ signal: controller.signal })) {
11770
+ if (event.type !== "filesystem.changed")
11771
+ continue;
11772
+ const file = event.data?.file;
11773
+ if (typeof file !== "string")
11774
+ continue;
11775
+ if (!isNexusConfigFile(file, event.location?.directory, projectDirectory, projectPath, globalPath))
11776
+ continue;
11777
+ scheduleReload("event");
11778
+ }
11779
+ if (!controller.signal.aborted) {
11780
+ console.warn("[nexus] config event stream ended; polling remains the only config reload trigger");
11781
+ }
11782
+ } catch (err) {
11783
+ console.warn(`[nexus] config event stream failed; polling remains the only config reload trigger: ${String(err)}`);
11784
+ }
11785
+ };
11786
+ consume();
11787
+ return () => {
11788
+ clearInterval(pollTimer);
11789
+ clearPendingReload();
11790
+ controller.abort();
11791
+ };
11792
+ }
11563
11793
  var NEXUS_AGENT_CONTENT = `---
11564
11794
  description: Nexus multi-agent orchestrator — decomposes tasks and delegates to specialized sub-agents
11565
11795
  mode: primary
@@ -12089,7 +12319,7 @@ You are a technical writer who creates documentation that developers actually wa
12089
12319
  });
12090
12320
  editor.add({
12091
12321
  name: "status",
12092
- description: "Get orchestrator status and metrics",
12322
+ description: "Get orchestrator status, metrics, and the config files currently in effect (paths consulted, which existed, resolved role -> model map)",
12093
12323
  input: {
12094
12324
  type: "object",
12095
12325
  properties: {
@@ -12228,7 +12458,10 @@ You are a technical writer who creates documentation that developers actually wa
12228
12458
  const { name } = input;
12229
12459
  try {
12230
12460
  orchestrator.configManager.applyPreset(name);
12231
- return { content: `Applied preset: ${PRESETS[name]?.name || name}` };
12461
+ return {
12462
+ 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.`
12464
+ };
12232
12465
  } catch (error) {
12233
12466
  return { content: `Error: ${error.message}` };
12234
12467
  }
@@ -13201,19 +13434,24 @@ ${lines.join(`
13201
13434
  event.metadata = { ...event.metadata, nexusResult: result };
13202
13435
  }
13203
13436
  });
13437
+ const stopConfigWatch = watchConfigFiles(ctx, orchestrator);
13204
13438
  return () => {
13439
+ stopConfigWatch();
13205
13440
  orchestrator.shutdown();
13206
13441
  };
13207
13442
  }
13208
13443
  });
13209
13444
  export {
13210
13445
  AstGrep,
13446
+ CONFIG_POLL_INTERVAL_MS,
13447
+ CONFIG_RELOAD_DEBOUNCE_MS,
13211
13448
  CostForecaster,
13212
13449
  CustomRoleManager,
13213
13450
  DEFAULT_CONFIG,
13214
13451
  GoalManager,
13215
13452
  HealthMonitor,
13216
13453
  LearningModule,
13454
+ MAX_RELOAD_WAIT_MS,
13217
13455
  MessageRouter,
13218
13456
  MessageStore,
13219
13457
  ModuleRegistry,
@@ -13232,5 +13470,6 @@ export {
13232
13470
  detectCycles,
13233
13471
  getTemplate,
13234
13472
  instantiateTemplate,
13235
- listTemplates
13473
+ listTemplates,
13474
+ watchConfigFiles
13236
13475
  };
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
+ };
144
+ }
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;
104
168
  }
105
- loadFromPath(basePath) {
106
- this.loadConfigs(basePath);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serkanalgur/opencode-nexus",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "Adaptive Multi-Agent Orchestration with Cost Intelligence for OpenCode V2",
5
5
  "keywords": [
6
6
  "opencode",