@serkanalgur/opencode-nexus 2.3.5 → 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 +516 -140
  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));
8514
+ }
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;
8461
8525
  }
8462
- loadFromPath(basePath) {
8463
- this.loadConfigs(basePath);
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;
@@ -10085,6 +10149,21 @@ ${lines.join(`
10085
10149
  }
10086
10150
 
10087
10151
  // src/orchestrator.ts
10152
+ function assistantMessageText(message) {
10153
+ if (message.type !== "assistant")
10154
+ return "";
10155
+ return message.content.filter((part) => part.type === "text").map((part) => part.text).join("");
10156
+ }
10157
+ function lastAssistantText(messages) {
10158
+ for (let i = messages.length - 1;i >= 0; i--) {
10159
+ if (messages[i].type !== "assistant")
10160
+ continue;
10161
+ const text = assistantMessageText(messages[i]);
10162
+ if (text)
10163
+ return text;
10164
+ }
10165
+ return "";
10166
+ }
10088
10167
  var DEFAULT_ESCALATION = {
10089
10168
  maxRetries: 3,
10090
10169
  retryDelay: 1000,
@@ -10136,7 +10215,7 @@ class NexusOrchestrator {
10136
10215
  }
10137
10216
  notifications = null;
10138
10217
  modelCosts = new Map;
10139
- parentSessionID = null;
10218
+ lastDegradedSpawn = null;
10140
10219
  constructor(config, messageStoreConfig, memoryStoreConfig) {
10141
10220
  this.config = this.mergeConfig(config);
10142
10221
  this.budget = this.config.budget;
@@ -10161,7 +10240,7 @@ class NexusOrchestrator {
10161
10240
  async initialize(ctx, onStateChange) {
10162
10241
  this.ctx = ctx;
10163
10242
  this.onStateChange = onStateChange ?? null;
10164
- const projectDir = ctx.location?.directory || process.cwd();
10243
+ const projectDir = ctx.location.directory;
10165
10244
  this.configManager.loadFromPath(projectDir);
10166
10245
  await this.loadModelCosts();
10167
10246
  this.cleanupInterval = setInterval(() => this.cleanupStaleData(), 300000);
@@ -10181,41 +10260,22 @@ class NexusOrchestrator {
10181
10260
  }
10182
10261
  async loadModelCosts() {
10183
10262
  try {
10184
- if (!this.ctx)
10263
+ if (!this.ctx?.model)
10185
10264
  return;
10186
- if (this.ctx.client?.model?.list) {
10187
- const result = await this.ctx.client.model.list();
10188
- const models = result?.data?.data ?? result?.data ?? [];
10189
- if (Array.isArray(models)) {
10190
- for (const model of models) {
10191
- if (model.cost && Array.isArray(model.cost) && model.cost.length > 0) {
10192
- const baseCost = model.cost[0];
10193
- this.modelCosts.set(model.id, {
10194
- input: baseCost.input || 0,
10195
- output: baseCost.output || 0,
10196
- cacheRead: baseCost.cache?.read || 0,
10197
- cacheWrite: baseCost.cache?.write || 0
10198
- });
10199
- }
10200
- }
10201
- }
10265
+ const { data } = await this.ctx.model.list();
10266
+ if (!Array.isArray(data) || data.length === 0)
10202
10267
  return;
10203
- }
10204
- const location = this.ctx.location ?? this.ctx.data?.location?.default();
10205
- if (location && this.ctx.data?.location?.model) {
10206
- await this.ctx.data.location.model.sync(location);
10207
- const models = this.ctx.data.location.model.list(location) ?? [];
10208
- for (const model of models) {
10209
- if (model.cost && Array.isArray(model.cost) && model.cost.length > 0) {
10210
- const baseCost = model.cost[0];
10211
- this.modelCosts.set(model.id, {
10212
- input: baseCost.input || 0,
10213
- output: baseCost.output || 0,
10214
- cacheRead: baseCost.cache?.read || 0,
10215
- cacheWrite: baseCost.cache?.write || 0
10216
- });
10217
- }
10218
- }
10268
+ const per1k = (v) => (v || 0) / 1000;
10269
+ for (const model of data) {
10270
+ if (!model?.cost || !Array.isArray(model.cost) || model.cost.length === 0)
10271
+ continue;
10272
+ const baseCost = model.cost[0];
10273
+ 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)
10278
+ });
10219
10279
  }
10220
10280
  } catch {}
10221
10281
  }
@@ -10366,6 +10426,21 @@ class NexusOrchestrator {
10366
10426
  this.onStateChange?.();
10367
10427
  }, 100);
10368
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
+ }
10369
10444
  async execute(request) {
10370
10445
  if (this.running) {
10371
10446
  throw new Error("Orchestrator is already running");
@@ -10398,6 +10473,7 @@ class NexusOrchestrator {
10398
10473
  agentsUsed: this.agents.size
10399
10474
  };
10400
10475
  } catch (error) {
10476
+ console.error("[nexus] DAG execution failed:", error);
10401
10477
  return {
10402
10478
  success: false,
10403
10479
  tasks: [],
@@ -10493,7 +10569,21 @@ class NexusOrchestrator {
10493
10569
  const spawnPromises = [];
10494
10570
  for (const node of readyNodes) {
10495
10571
  if (this.agents.size < this.config.maxConcurrency) {
10496
- 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
+ }));
10497
10587
  }
10498
10588
  }
10499
10589
  await Promise.all(spawnPromises);
@@ -10503,10 +10593,15 @@ class NexusOrchestrator {
10503
10593
  async spawnAndExecute(node, transferContext) {
10504
10594
  const complexity = this.analyzeComplexity(node.task);
10505
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}`;
10506
10601
  const agent = await this.spawnAgent({
10507
10602
  role: node.task.requiredRole,
10508
10603
  task: node.task,
10509
- model: model.model
10604
+ model: qualifiedModel
10510
10605
  });
10511
10606
  node.spawnedAgent = agent;
10512
10607
  node.status = "running";
@@ -10564,15 +10659,14 @@ Please continue from where the previous agent left off.`;
10564
10659
  const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Task timed out")), timeout));
10565
10660
  await Promise.race([waitPromise, timeoutPromise]);
10566
10661
  const messages = await this.ctx.session.context({ sessionID: agent.sessionID });
10567
- const lastAssistantMsg = messages.filter((m) => m.role === "assistant").pop();
10568
- const output = lastAssistantMsg?.content || "Task completed";
10662
+ const output = lastAssistantText(messages) || "Task completed (no output captured)";
10569
10663
  const duration = Date.now() - startTime;
10570
10664
  const result = {
10571
10665
  success: true,
10572
- output: typeof output === "string" ? output : JSON.stringify(output),
10666
+ output,
10573
10667
  duration,
10574
10668
  tokensUsed: 0,
10575
- cost: this.estimateModelCost(agent.model.model)
10669
+ cost: this.estimateModelCost(agent.model.model, agent.model.provider)
10576
10670
  };
10577
10671
  this.dag.markComplete(node.id, result);
10578
10672
  this.todoEnforcer.completeTask(agent.id);
@@ -10608,7 +10702,7 @@ Please continue from where the previous agent left off.`;
10608
10702
  if (priorPattern.length > 0) {
10609
10703
  this.learning.recordSuccess(priorPattern[0].entry.id);
10610
10704
  }
10611
- if (typeof output === "string" && output.length > 0) {
10705
+ if (output.length > 0) {
10612
10706
  const securityIssues = this.securityScanner.scanContent(output, node.task.name);
10613
10707
  if (securityIssues.length > 0) {
10614
10708
  this.emit("security:issues-found", {
@@ -10769,7 +10863,57 @@ Please continue from where the previous agent left off.`;
10769
10863
  };
10770
10864
  this.notifyStateChange();
10771
10865
  }
10772
- async spawnAgent(config) {
10866
+ async createChildSession(params) {
10867
+ const { tool, agent, description, prompt, model, parent, callID } = params;
10868
+ if (!parent.sessionID) {
10869
+ throw new Error("Cannot spawn agent without a parent session ID");
10870
+ }
10871
+ if (!parent.agent) {
10872
+ throw new Error("Cannot spawn agent without the calling agent id (tool context has no 'agent')");
10873
+ }
10874
+ let childSessionID;
10875
+ let resolveChildSession = null;
10876
+ let watchdog;
10877
+ const childSessionReady = new Promise((resolve, reject) => {
10878
+ resolveChildSession = resolve;
10879
+ watchdog = setTimeout(() => reject(new Error(`subagent tool did not report a child session for ${agent}`)), 30000);
10880
+ });
10881
+ childSessionReady.catch(() => {});
10882
+ const reportProgress = (p) => {
10883
+ if (p?.sessionID && !childSessionID) {
10884
+ childSessionID = p.sessionID;
10885
+ resolveChildSession?.(p.sessionID);
10886
+ }
10887
+ return Promise.resolve();
10888
+ };
10889
+ const toolContext = {
10890
+ sessionID: parent.sessionID,
10891
+ agent: parent.agent,
10892
+ messageID: parent.messageID || `msg_${callID}`,
10893
+ id: `call_${callID}`,
10894
+ progress: reportProgress,
10895
+ signal: parent.signal ?? new AbortController().signal
10896
+ };
10897
+ const subagentCall = tool.execute({ agent, description, prompt, model, background: true }, toolContext);
10898
+ let childSessionIDResolved;
10899
+ try {
10900
+ childSessionIDResolved = await Promise.race([
10901
+ childSessionReady,
10902
+ subagentCall.then(() => {
10903
+ throw new Error(`subagent tool finished without reporting a child session for ${agent}`);
10904
+ }, (err) => {
10905
+ const message = err instanceof Error ? err.message : String(err);
10906
+ throw new Error(`subagent tool failed for ${agent}: ${message}`);
10907
+ })
10908
+ ]);
10909
+ } finally {
10910
+ if (watchdog)
10911
+ clearTimeout(watchdog);
10912
+ }
10913
+ subagentCall.catch(() => {});
10914
+ return childSessionIDResolved;
10915
+ }
10916
+ async spawnAgent(config, options) {
10773
10917
  if (!this.ctx) {
10774
10918
  throw new Error("Orchestrator not initialized");
10775
10919
  }
@@ -10784,7 +10928,8 @@ Please continue from where the previous agent left off.`;
10784
10928
  if (match) {
10785
10929
  modelConfig = match;
10786
10930
  } else {
10787
- 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")`);
10788
10933
  }
10789
10934
  }
10790
10935
  const slashIndex = modelConfig.indexOf("/");
@@ -10793,26 +10938,58 @@ Please continue from where the previous agent left off.`;
10793
10938
  const roleEmoji = this.configManager.getRoleEmoji(config.role);
10794
10939
  const title = `${roleEmoji} ${this.configManager.getRoleDisplayName(config.role)} — ${modelConfig}`;
10795
10940
  const agentTypeMap = {
10796
- architect: "architect",
10797
- coder: "build-orchestrator",
10798
- reviewer: "code-reviewer",
10799
- tester: "build-orchestrator",
10800
- explorer: "explore",
10801
- documenter: "doc-writer"
10941
+ architect: "nexus-architect",
10942
+ coder: "nexus-coder",
10943
+ reviewer: "nexus-reviewer",
10944
+ tester: "nexus-tester",
10945
+ explorer: "nexus-explorer",
10946
+ documenter: "nexus-documenter"
10802
10947
  };
10803
- const agentType = agentTypeMap[config.role] || "build";
10804
- const session = await this.ctx.session.create({
10805
- title,
10806
- agent: agentType,
10807
- model: modelName ? { providerID: provider, id: modelName } : undefined,
10808
- parentID: this.parentSessionID || undefined,
10809
- metadata: {
10810
- nexusRole: config.role,
10811
- nexusTask: config.task?.name || "direct-spawn",
10812
- nexusAgentId: agentId,
10813
- nexusModel: modelConfig
10814
- }
10815
- });
10948
+ const agentType = agentTypeMap[config.role] || "nexus-coder";
10949
+ const parent = options?.toolContext?.sessionID ? options.toolContext : undefined;
10950
+ const toolList = parent && typeof this.ctx.tool?.list === "function" ? await this.ctx.tool.list() : undefined;
10951
+ const subagentTool = Array.isArray(toolList) ? toolList.find((t) => t?.id === "subagent" && typeof t?.execute === "function") : undefined;
10952
+ const taskText = options?.task || config.task?.description || config.task?.name || "";
10953
+ let spawnPath = "session-create";
10954
+ let childSessionID;
10955
+ if (parent && subagentTool) {
10956
+ if (!taskText) {
10957
+ throw new Error("spawnAgent requires task text when using the subagent tool path");
10958
+ }
10959
+ spawnPath = "subagent-tool";
10960
+ childSessionID = await this.createChildSession({
10961
+ tool: subagentTool,
10962
+ agent: agentType,
10963
+ description: title,
10964
+ prompt: taskText,
10965
+ model: modelConfig,
10966
+ parent,
10967
+ callID: agentId
10968
+ });
10969
+ } else {
10970
+ const created = await this.ctx.session.create({
10971
+ title,
10972
+ agent: agentType,
10973
+ model: modelName ? { providerID: provider, id: modelName } : undefined,
10974
+ metadata: {
10975
+ nexusRole: config.role,
10976
+ nexusTask: config.task?.name || "direct-spawn",
10977
+ nexusAgentId: agentId,
10978
+ nexusModel: modelConfig
10979
+ }
10980
+ });
10981
+ childSessionID = created.id;
10982
+ }
10983
+ if (spawnPath === "session-create") {
10984
+ this.lastDegradedSpawn = {
10985
+ agentId,
10986
+ role: config.role,
10987
+ reason: parent ? "subagent-tool-unavailable" : "no-parent-context"
10988
+ };
10989
+ console.warn(`[nexus] spawn degraded for ${config.role}: child session ${childSessionID} was created via ` + `ctx.session.create (${this.lastDegradedSpawn.reason}) and is not linked to a parent session.`);
10990
+ } else {
10991
+ this.lastDegradedSpawn = null;
10992
+ }
10816
10993
  const agent = {
10817
10994
  id: agentId,
10818
10995
  name: title,
@@ -10835,7 +11012,8 @@ Please continue from where the previous agent left off.`;
10835
11012
  averageResponseTime: 0,
10836
11013
  errorRate: 0
10837
11014
  },
10838
- sessionID: session.id
11015
+ sessionID: childSessionID,
11016
+ spawnPath
10839
11017
  };
10840
11018
  this.agents.set(agentId, agent);
10841
11019
  const taskDesc = config.task?.name || `Agent ${config.role} task`;
@@ -10890,10 +11068,29 @@ Please continue from where the previous agent left off.`;
10890
11068
  selectModel(role, complexity) {
10891
11069
  return this.selectBestModel(role, complexity);
10892
11070
  }
10893
- estimateModelCost(model) {
10894
- const realCost = this.modelCosts.get(model);
11071
+ getModelCost(model, provider) {
11072
+ const exact = this.modelCosts.get(model);
11073
+ if (exact)
11074
+ return exact;
11075
+ if (provider) {
11076
+ const qualified = this.modelCosts.get(`${provider}/${model}`);
11077
+ if (qualified)
11078
+ return qualified;
11079
+ }
11080
+ const bare = model.slice(model.indexOf("/") + 1);
11081
+ let best;
11082
+ for (const [key, cost] of this.modelCosts) {
11083
+ if (key.slice(key.indexOf("/") + 1) !== bare)
11084
+ continue;
11085
+ if (!best || cost.input < best.input)
11086
+ best = cost;
11087
+ }
11088
+ return best;
11089
+ }
11090
+ estimateModelCost(model, provider) {
11091
+ const realCost = this.getModelCost(model, provider);
10895
11092
  if (realCost) {
10896
- return (realCost.input * 1000 + realCost.output * 1000) / 2;
11093
+ return (realCost.input + realCost.output) / 2;
10897
11094
  }
10898
11095
  const costs = {
10899
11096
  "claude-sonnet-4-6": 0.15,
@@ -10933,7 +11130,7 @@ Please continue from where the previous agent left off.`;
10933
11130
  scoreModel(modelId, role, complexity) {
10934
11131
  const [provider, ...parts] = modelId.split("/");
10935
11132
  const model = parts.join("/");
10936
- const cost = this.estimateModelCost(model);
11133
+ const cost = this.estimateModelCost(model, provider);
10937
11134
  const quality = this.estimateModelQuality(model);
10938
11135
  const maxCost = 15;
10939
11136
  const costScore = 1 - cost / maxCost;
@@ -10965,14 +11162,14 @@ Please continue from where the previous agent left off.`;
10965
11162
  const scored = unique.map((m) => this.scoreModel(m, role, complexity));
10966
11163
  const budgetRemaining = this.budget.maxTotalCost - this.totalSpent;
10967
11164
  const affordable = scored.filter((s) => {
10968
- const cost = this.estimateModelCost(s.model);
11165
+ const cost = this.estimateModelCost(s.model, s.provider);
10969
11166
  return cost <= budgetRemaining || cost === 0;
10970
11167
  });
10971
11168
  const best = (affordable.length > 0 ? affordable : scored).sort((a, b) => b.overallScore - a.overallScore)[0];
10972
11169
  return {
10973
11170
  provider: best.provider,
10974
11171
  model: best.model,
10975
- estimatedCost: this.estimateModelCost(best.model),
11172
+ estimatedCost: this.estimateModelCost(best.model, best.provider),
10976
11173
  estimatedQuality: best.qualityScore,
10977
11174
  reasoning: best.reasoning
10978
11175
  };
@@ -11041,8 +11238,20 @@ Please continue from where the previous agent left off.`;
11041
11238
  }
11042
11239
  getStatus(detailed) {
11043
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
+ };
11044
11253
  if (detailed)
11045
- return JSON.stringify({ ...state, budgetExceeded: this.budgetExceeded }, null, 2);
11254
+ return JSON.stringify({ ...state, budgetExceeded: this.budgetExceeded, config }, null, 2);
11046
11255
  return JSON.stringify({
11047
11256
  running: state.running,
11048
11257
  paused: state.paused,
@@ -11050,7 +11259,8 @@ Please continue from where the previous agent left off.`;
11050
11259
  agents: state.agents.length,
11051
11260
  tasks: state.tasks.length,
11052
11261
  totalCost: state.totalSpent,
11053
- budgetRemaining: state.budgetRemaining
11262
+ budgetRemaining: state.budgetRemaining,
11263
+ config
11054
11264
  }, null, 2);
11055
11265
  }
11056
11266
  listAgents(filter) {
@@ -11460,12 +11670,151 @@ class AstGrep {
11460
11670
  }
11461
11671
 
11462
11672
  // src/index.ts
11463
- import { writeFileSync as writeFileSync2, readFileSync as readFileSync3, mkdirSync as mkdirSync4, existsSync as existsSync3 } from "node:fs";
11464
- 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";
11465
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
+ }
11466
11793
  var NEXUS_AGENT_CONTENT = `---
11467
11794
  description: Nexus multi-agent orchestrator — decomposes tasks and delegates to specialized sub-agents
11468
11795
  mode: primary
11796
+ permissions:
11797
+ - action: subagent
11798
+ resource: "nexus-*"
11799
+ effect: allow
11800
+ - action: subagent
11801
+ resource: "nexus-architect"
11802
+ effect: allow
11803
+ - action: subagent
11804
+ resource: "nexus-coder"
11805
+ effect: allow
11806
+ - action: subagent
11807
+ resource: "nexus-reviewer"
11808
+ effect: allow
11809
+ - action: subagent
11810
+ resource: "nexus-tester"
11811
+ effect: allow
11812
+ - action: subagent
11813
+ resource: "nexus-explorer"
11814
+ effect: allow
11815
+ - action: subagent
11816
+ resource: "nexus-documenter"
11817
+ effect: allow
11469
11818
  ---
11470
11819
 
11471
11820
  # Nexus Orchestrator
@@ -11629,8 +11978,10 @@ mode: subagent
11629
11978
  permissions:
11630
11979
  - action: edit
11631
11980
  resource: "*"
11981
+ effect: allow
11632
11982
  - action: shell
11633
11983
  resource: "*"
11984
+ effect: allow
11634
11985
  ---
11635
11986
 
11636
11987
  # Nexus Architect Agent
@@ -11671,8 +12022,10 @@ mode: subagent
11671
12022
  permissions:
11672
12023
  - action: edit
11673
12024
  resource: "*"
12025
+ effect: allow
11674
12026
  - action: shell
11675
12027
  resource: "*"
12028
+ effect: allow
11676
12029
  ---
11677
12030
 
11678
12031
  # Nexus Coder Agent
@@ -11758,8 +12111,10 @@ mode: subagent
11758
12111
  permissions:
11759
12112
  - action: edit
11760
12113
  resource: "*"
12114
+ effect: allow
11761
12115
  - action: shell
11762
12116
  resource: "*"
12117
+ effect: allow
11763
12118
  ---
11764
12119
 
11765
12120
  # Nexus Tester Agent
@@ -11857,8 +12212,10 @@ mode: subagent
11857
12212
  permissions:
11858
12213
  - action: edit
11859
12214
  resource: "*"
12215
+ effect: allow
11860
12216
  - action: shell
11861
12217
  resource: "*"
12218
+ effect: allow
11862
12219
  ---
11863
12220
 
11864
12221
  # Nexus Documenter Agent
@@ -11936,6 +12293,25 @@ You are a technical writer who creates documentation that developers actually wa
11936
12293
  totalCost: initialState.totalSpent,
11937
12294
  budgetRemaining: initialState.budgetRemaining
11938
12295
  })));
12296
+ const spawnAndDeliver = async (opts, toolCtx) => {
12297
+ const agent = await orchestrator.spawnAgent({ role: opts.role, model: opts.model }, {
12298
+ toolContext: {
12299
+ sessionID: toolCtx?.sessionID || "",
12300
+ agent: toolCtx?.agent,
12301
+ messageID: toolCtx?.messageID,
12302
+ callID: toolCtx?.id,
12303
+ signal: toolCtx?.signal
12304
+ },
12305
+ task: opts.task
12306
+ });
12307
+ if (agent.spawnPath !== "subagent-tool") {
12308
+ await ctx.session.prompt({
12309
+ sessionID: agent.sessionID,
12310
+ text: opts.task
12311
+ });
12312
+ }
12313
+ return agent;
12314
+ };
11939
12315
  await ctx.tool.transform((editor) => {
11940
12316
  editor.namespace({
11941
12317
  name: "nexus",
@@ -11943,7 +12319,7 @@ You are a technical writer who creates documentation that developers actually wa
11943
12319
  });
11944
12320
  editor.add({
11945
12321
  name: "status",
11946
- 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)",
11947
12323
  input: {
11948
12324
  type: "object",
11949
12325
  properties: {
@@ -12082,7 +12458,10 @@ You are a technical writer who creates documentation that developers actually wa
12082
12458
  const { name } = input;
12083
12459
  try {
12084
12460
  orchestrator.configManager.applyPreset(name);
12085
- 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
+ };
12086
12465
  } catch (error) {
12087
12466
  return { content: `Error: ${error.message}` };
12088
12467
  }
@@ -12122,35 +12501,36 @@ You are a technical writer who creates documentation that developers actually wa
12122
12501
  });
12123
12502
  editor.add({
12124
12503
  name: "model.costs",
12125
- description: "Show real model pricing from OpenCode or set custom costs",
12504
+ description: "Show real model pricing from OpenCode, or set custom costs. All prices are USD per 1K tokens.",
12126
12505
  input: {
12127
12506
  type: "object",
12128
12507
  properties: {
12129
- model: { type: "string", description: "Model ID to show cost for (optional, shows all if omitted)" },
12130
- setInput: { type: "number", description: "Set input cost per token for a model" },
12131
- setOutput: { type: "number", description: "Set output cost per token for a model" }
12508
+ model: { type: "string", description: "Model to show cost for, as 'provider/id' or a bare 'id' (optional, shows all if omitted)" },
12509
+ setInput: { type: "number", description: "Set input cost in USD per 1K tokens for a model (e.g. 0.003 for $3 per million tokens)" },
12510
+ setOutput: { type: "number", description: "Set output cost in USD per 1K tokens for a model (e.g. 0.015 for $15 per million tokens)" }
12132
12511
  },
12133
12512
  additionalProperties: false
12134
12513
  },
12135
12514
  options: { codemode: true },
12136
12515
  execute: async (input) => {
12137
12516
  const { model, setInput, setOutput } = input;
12517
+ const per1k = (v) => `$${v}/1K tokens`;
12138
12518
  if (model && setInput !== undefined && setOutput !== undefined) {
12139
12519
  orchestrator.setModelCosts({ [model]: { input: setInput, output: setOutput } });
12140
- return { content: `Set ${model}: input=$${setInput}/token, output=$${setOutput}/token` };
12520
+ return { content: `Set ${model}: input=${per1k(setInput)}, output=${per1k(setOutput)}` };
12141
12521
  }
12142
12522
  if (model) {
12143
- const cost = orchestrator.modelCosts.get(model);
12523
+ const cost = orchestrator.getModelCost(model);
12144
12524
  if (cost) {
12145
- return { content: `${model}: input=$${cost.input}/token, output=$${cost.output}/token, cache_read=$${cost.cacheRead}/token, cache_write=$${cost.cacheWrite}/token` };
12525
+ return { content: `${model}: input=${per1k(cost.input)}, output=${per1k(cost.output)}, cache_read=${per1k(cost.cacheRead)}, cache_write=${per1k(cost.cacheWrite)}` };
12146
12526
  }
12147
12527
  const estimate = orchestrator["estimateModelCost"](model);
12148
12528
  return { content: `${model}: no real pricing data (estimated $${estimate}/1K tokens)` };
12149
12529
  }
12150
12530
  if (orchestrator.modelCosts.size > 0) {
12151
- const lines = ["\uD83D\uDCCA Model Pricing (from OpenCode):"];
12531
+ const lines = ["\uD83D\uDCCA Model Pricing (from OpenCode, per 1K tokens):"];
12152
12532
  for (const [id, cost] of orchestrator.modelCosts) {
12153
- lines.push(` ${id}: $${cost.input}/token in, $${cost.output}/token out`);
12533
+ lines.push(` ${id}: ${per1k(cost.input)} in, ${per1k(cost.output)} out`);
12154
12534
  }
12155
12535
  return { content: lines.join(`
12156
12536
  `) };
@@ -12174,27 +12554,15 @@ You are a technical writer who creates documentation that developers actually wa
12174
12554
  additionalProperties: false
12175
12555
  },
12176
12556
  options: { codemode: true },
12177
- execute: async (input) => {
12557
+ execute: async (input, toolCtx) => {
12178
12558
  const { role, task, model, wait, timeout } = input;
12179
12559
  try {
12180
- if (!orchestrator.parentSessionID) {
12181
- try {
12182
- const currentSession = ctx.session?.current?.();
12183
- if (currentSession?.id) {
12184
- orchestrator.parentSessionID = currentSession.id;
12185
- }
12186
- } catch {}
12187
- }
12188
- const agent = await orchestrator.spawnAgent({ role, model });
12189
- await orchestrator.ctx.session.prompt({
12190
- sessionID: agent.sessionID,
12191
- text: task
12192
- });
12560
+ const agent = await spawnAndDeliver({ role, task, model }, toolCtx);
12193
12561
  agent.status = "working";
12194
12562
  orchestrator.notifyStateChange();
12195
12563
  if (wait) {
12196
12564
  const waitTimeout = timeout || 120000;
12197
- const waitPromise = orchestrator.ctx.session.wait({ sessionID: agent.sessionID });
12565
+ const waitPromise = ctx.session.wait({ sessionID: agent.sessionID });
12198
12566
  const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out after ${waitTimeout}ms`)), waitTimeout));
12199
12567
  try {
12200
12568
  await Promise.race([waitPromise, timeoutPromise]);
@@ -12202,9 +12570,9 @@ You are a technical writer who creates documentation that developers actually wa
12202
12570
  agent.status = "working";
12203
12571
  orchestrator.notifyStateChange();
12204
12572
  try {
12205
- const messages = await orchestrator.ctx.session.context({ sessionID: agent.sessionID });
12206
- const lastMsg = messages.filter((m) => m.role === "assistant").pop();
12207
- if (lastMsg) {
12573
+ const messages = await ctx.session.context({ sessionID: agent.sessionID });
12574
+ const partialText = lastAssistantText(messages);
12575
+ if (partialText) {
12208
12576
  agent.status = "completed";
12209
12577
  await ctx.storage.set("orchestrator-state", JSON.parse(JSON.stringify(orchestrator.getState())));
12210
12578
  const taskPreview = task.length > 80 ? task.substring(0, 77) + "..." : task;
@@ -12216,7 +12584,7 @@ You are a technical writer who creates documentation that developers actually wa
12216
12584
  `\uD83D\uDCCE Session: ${agent.sessionID}`,
12217
12585
  `
12218
12586
  --- Partial Result ---`,
12219
- typeof lastMsg.content === "string" ? lastMsg.content : JSON.stringify(lastMsg.content)
12587
+ partialText
12220
12588
  ].join(`
12221
12589
  `)
12222
12590
  };
@@ -12234,9 +12602,8 @@ You are a technical writer who creates documentation that developers actually wa
12234
12602
  };
12235
12603
  }
12236
12604
  try {
12237
- const messages = await orchestrator.ctx.session.context({ sessionID: agent.sessionID });
12238
- const lastMsg = messages.filter((m) => m.role === "assistant").pop();
12239
- const result = lastMsg?.content || "Task completed (no output captured)";
12605
+ const messages = await ctx.session.context({ sessionID: agent.sessionID });
12606
+ const result = lastAssistantText(messages) || "Task completed (no output captured)";
12240
12607
  agent.status = "completed";
12241
12608
  await ctx.storage.set("orchestrator-state", JSON.parse(JSON.stringify(orchestrator.getState())));
12242
12609
  const taskPreview = task.length > 80 ? task.substring(0, 77) + "..." : task;
@@ -12311,24 +12678,19 @@ You are a technical writer who creates documentation that developers actually wa
12311
12678
  additionalProperties: false
12312
12679
  },
12313
12680
  options: { codemode: true },
12314
- execute: async (input) => {
12681
+ execute: async (input, toolCtx) => {
12315
12682
  const { role, task, model, timeout } = input;
12316
12683
  try {
12317
- const agent = await orchestrator.spawnAgent({ role, model });
12318
- await orchestrator.ctx.session.prompt({
12319
- sessionID: agent.sessionID,
12320
- text: task
12321
- });
12684
+ const agent = await spawnAndDeliver({ role, task, model }, toolCtx);
12322
12685
  agent.status = "working";
12323
12686
  orchestrator.notifyStateChange();
12324
12687
  const waitTimeout = timeout || 120000;
12325
- const waitPromise = orchestrator.ctx.session.wait({ sessionID: agent.sessionID });
12688
+ const waitPromise = ctx.session.wait({ sessionID: agent.sessionID });
12326
12689
  const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve("timeout"), waitTimeout));
12327
12690
  const outcome = await Promise.race([waitPromise.then(() => "completed"), timeoutPromise]);
12328
12691
  try {
12329
- const messages = await orchestrator.ctx.session.context({ sessionID: agent.sessionID });
12330
- const lastMsg = messages.filter((m) => m.role === "assistant").pop();
12331
- const resultContent = lastMsg?.content || (outcome === "timeout" ? "Timed out — agent may still be running" : "Completed with no output");
12692
+ const messages = await ctx.session.context({ sessionID: agent.sessionID });
12693
+ const resultContent = lastAssistantText(messages) || (outcome === "timeout" ? "Timed out — agent may still be running" : "Completed with no output");
12332
12694
  agent.status = outcome === "timeout" ? "working" : "completed";
12333
12695
  orchestrator.notifyStateChange();
12334
12696
  await ctx.storage.set("orchestrator-state", JSON.parse(JSON.stringify(orchestrator.getState())));
@@ -12630,12 +12992,20 @@ ${lines.join(`
12630
12992
  const agents = orchestrator.getState().agents.filter((a) => a.status === "working" || a.status === "idle");
12631
12993
  if (agents.length === 0)
12632
12994
  return { content: "No running agents to move to background." };
12995
+ const background = ctx.session.background;
12996
+ if (!background) {
12997
+ return { content: "This OpenCode version does not expose session.background on the plugin context — nothing was detached." };
12998
+ }
12999
+ let detached = 0;
12633
13000
  for (const agent of agents) {
13001
+ if (!agent.sessionID)
13002
+ continue;
12634
13003
  try {
12635
- await orchestrator.ctx.session.background({ sessionID: agent.sessionID });
13004
+ await background.call(ctx.session, { sessionID: agent.sessionID });
13005
+ detached++;
12636
13006
  } catch {}
12637
13007
  }
12638
- return { content: `${agents.length} agent(s) moved to background. You can continue working while they run.` };
13008
+ return { content: `${detached} agent(s) moved to background. You can continue working while they run.` };
12639
13009
  }
12640
13010
  });
12641
13011
  editor.add({
@@ -12652,11 +13022,11 @@ ${lines.join(`
12652
13022
  execute: async (input) => {
12653
13023
  const { sessionID } = input;
12654
13024
  try {
12655
- const messages = await orchestrator.ctx.session.context({ sessionID });
12656
- const lastMsg = messages.filter((m) => m.role === "assistant").pop();
12657
- if (lastMsg) {
13025
+ const messages = await ctx.session.context({ sessionID });
13026
+ const text = lastAssistantText(messages);
13027
+ if (text) {
12658
13028
  return { content: `Session ${sessionID} result:
12659
- ${lastMsg.content}` };
13029
+ ${text}` };
12660
13030
  }
12661
13031
  return { content: `Session ${sessionID} has no assistant messages yet.` };
12662
13032
  } catch (error) {
@@ -13064,19 +13434,24 @@ ${lines.join(`
13064
13434
  event.metadata = { ...event.metadata, nexusResult: result };
13065
13435
  }
13066
13436
  });
13437
+ const stopConfigWatch = watchConfigFiles(ctx, orchestrator);
13067
13438
  return () => {
13439
+ stopConfigWatch();
13068
13440
  orchestrator.shutdown();
13069
13441
  };
13070
13442
  }
13071
13443
  });
13072
13444
  export {
13073
13445
  AstGrep,
13446
+ CONFIG_POLL_INTERVAL_MS,
13447
+ CONFIG_RELOAD_DEBOUNCE_MS,
13074
13448
  CostForecaster,
13075
13449
  CustomRoleManager,
13076
13450
  DEFAULT_CONFIG,
13077
13451
  GoalManager,
13078
13452
  HealthMonitor,
13079
13453
  LearningModule,
13454
+ MAX_RELOAD_WAIT_MS,
13080
13455
  MessageRouter,
13081
13456
  MessageStore,
13082
13457
  ModuleRegistry,
@@ -13095,5 +13470,6 @@ export {
13095
13470
  detectCycles,
13096
13471
  getTemplate,
13097
13472
  instantiateTemplate,
13098
- listTemplates
13473
+ listTemplates,
13474
+ watchConfigFiles
13099
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.3.5",
3
+ "version": "2.4.1",
4
4
  "description": "Adaptive Multi-Agent Orchestration with Cost Intelligence for OpenCode V2",
5
5
  "keywords": [
6
6
  "opencode",