@serkanalgur/opencode-nexus 2.3.4 → 2.4.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 +276 -118
  2. package/dist/tui.js +19 -1
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8486,7 +8486,11 @@ class NexusConfigManager {
8486
8486
  }
8487
8487
  getModelForRole(role) {
8488
8488
  const config = this.getConfig();
8489
- return config.models[role] || config.models.coder || DEFAULT_CONFIG.models.coder;
8489
+ const model = config.models[role] || config.models.coder || DEFAULT_CONFIG.models.coder;
8490
+ if (!model.includes("/")) {
8491
+ console.warn(`[nexus] Model "${model}" for role "${role}" is missing provider prefix. Expected "providerID/modelID" format.`);
8492
+ }
8493
+ return model;
8490
8494
  }
8491
8495
  updateStorageConfig(update) {
8492
8496
  this.storageConfig = {
@@ -8570,10 +8574,18 @@ class NexusConfigManager {
8570
8574
  }
8571
8575
  initProjectConfig(basePath) {
8572
8576
  const projectPath = join(basePath, ".opencode", "nexus.jsonc");
8577
+ try {
8578
+ readFileSync(projectPath, "utf-8");
8579
+ return;
8580
+ } catch {}
8573
8581
  this.writeJsoncFile(projectPath, { ...DEFAULT_CONFIG });
8574
8582
  }
8575
8583
  initGlobalConfig() {
8576
8584
  const globalPath = join(homedir(), ".config", "opencode", "nexus.jsonc");
8585
+ try {
8586
+ readFileSync(globalPath, "utf-8");
8587
+ return;
8588
+ } catch {}
8577
8589
  this.writeJsoncFile(globalPath, { ...DEFAULT_CONFIG });
8578
8590
  }
8579
8591
  saveConfig(level, basePath) {
@@ -10073,6 +10085,21 @@ ${lines.join(`
10073
10085
  }
10074
10086
 
10075
10087
  // src/orchestrator.ts
10088
+ function assistantMessageText(message) {
10089
+ if (message.type !== "assistant")
10090
+ return "";
10091
+ return message.content.filter((part) => part.type === "text").map((part) => part.text).join("");
10092
+ }
10093
+ function lastAssistantText(messages) {
10094
+ for (let i = messages.length - 1;i >= 0; i--) {
10095
+ if (messages[i].type !== "assistant")
10096
+ continue;
10097
+ const text = assistantMessageText(messages[i]);
10098
+ if (text)
10099
+ return text;
10100
+ }
10101
+ return "";
10102
+ }
10076
10103
  var DEFAULT_ESCALATION = {
10077
10104
  maxRetries: 3,
10078
10105
  retryDelay: 1000,
@@ -10124,7 +10151,7 @@ class NexusOrchestrator {
10124
10151
  }
10125
10152
  notifications = null;
10126
10153
  modelCosts = new Map;
10127
- parentSessionID = null;
10154
+ lastDegradedSpawn = null;
10128
10155
  constructor(config, messageStoreConfig, memoryStoreConfig) {
10129
10156
  this.config = this.mergeConfig(config);
10130
10157
  this.budget = this.config.budget;
@@ -10149,7 +10176,7 @@ class NexusOrchestrator {
10149
10176
  async initialize(ctx, onStateChange) {
10150
10177
  this.ctx = ctx;
10151
10178
  this.onStateChange = onStateChange ?? null;
10152
- const projectDir = ctx.location?.directory || process.cwd();
10179
+ const projectDir = ctx.location.directory;
10153
10180
  this.configManager.loadFromPath(projectDir);
10154
10181
  await this.loadModelCosts();
10155
10182
  this.cleanupInterval = setInterval(() => this.cleanupStaleData(), 300000);
@@ -10169,41 +10196,22 @@ class NexusOrchestrator {
10169
10196
  }
10170
10197
  async loadModelCosts() {
10171
10198
  try {
10172
- if (!this.ctx)
10199
+ if (!this.ctx?.model)
10173
10200
  return;
10174
- if (this.ctx.client?.model?.list) {
10175
- const result = await this.ctx.client.model.list();
10176
- const models = result?.data?.data ?? result?.data ?? [];
10177
- if (Array.isArray(models)) {
10178
- for (const model of models) {
10179
- if (model.cost && Array.isArray(model.cost) && model.cost.length > 0) {
10180
- const baseCost = model.cost[0];
10181
- this.modelCosts.set(model.id, {
10182
- input: baseCost.input || 0,
10183
- output: baseCost.output || 0,
10184
- cacheRead: baseCost.cache?.read || 0,
10185
- cacheWrite: baseCost.cache?.write || 0
10186
- });
10187
- }
10188
- }
10189
- }
10201
+ const { data } = await this.ctx.model.list();
10202
+ if (!Array.isArray(data) || data.length === 0)
10190
10203
  return;
10191
- }
10192
- const location = this.ctx.location ?? this.ctx.data?.location?.default();
10193
- if (location && this.ctx.data?.location?.model) {
10194
- await this.ctx.data.location.model.sync(location);
10195
- const models = this.ctx.data.location.model.list(location) ?? [];
10196
- for (const model of models) {
10197
- if (model.cost && Array.isArray(model.cost) && model.cost.length > 0) {
10198
- const baseCost = model.cost[0];
10199
- this.modelCosts.set(model.id, {
10200
- input: baseCost.input || 0,
10201
- output: baseCost.output || 0,
10202
- cacheRead: baseCost.cache?.read || 0,
10203
- cacheWrite: baseCost.cache?.write || 0
10204
- });
10205
- }
10206
- }
10204
+ const per1k = (v) => (v || 0) / 1000;
10205
+ for (const model of data) {
10206
+ if (!model?.cost || !Array.isArray(model.cost) || model.cost.length === 0)
10207
+ continue;
10208
+ const baseCost = model.cost[0];
10209
+ this.modelCosts.set(`${model.providerID}/${model.id}`, {
10210
+ input: per1k(baseCost.input),
10211
+ output: per1k(baseCost.output),
10212
+ cacheRead: per1k(baseCost.cache?.read),
10213
+ cacheWrite: per1k(baseCost.cache?.write)
10214
+ });
10207
10215
  }
10208
10216
  } catch {}
10209
10217
  }
@@ -10552,15 +10560,14 @@ Please continue from where the previous agent left off.`;
10552
10560
  const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Task timed out")), timeout));
10553
10561
  await Promise.race([waitPromise, timeoutPromise]);
10554
10562
  const messages = await this.ctx.session.context({ sessionID: agent.sessionID });
10555
- const lastAssistantMsg = messages.filter((m) => m.role === "assistant").pop();
10556
- const output = lastAssistantMsg?.content || "Task completed";
10563
+ const output = lastAssistantText(messages) || "Task completed (no output captured)";
10557
10564
  const duration = Date.now() - startTime;
10558
10565
  const result = {
10559
10566
  success: true,
10560
- output: typeof output === "string" ? output : JSON.stringify(output),
10567
+ output,
10561
10568
  duration,
10562
10569
  tokensUsed: 0,
10563
- cost: this.estimateModelCost(agent.model.model)
10570
+ cost: this.estimateModelCost(agent.model.model, agent.model.provider)
10564
10571
  };
10565
10572
  this.dag.markComplete(node.id, result);
10566
10573
  this.todoEnforcer.completeTask(agent.id);
@@ -10596,7 +10603,7 @@ Please continue from where the previous agent left off.`;
10596
10603
  if (priorPattern.length > 0) {
10597
10604
  this.learning.recordSuccess(priorPattern[0].entry.id);
10598
10605
  }
10599
- if (typeof output === "string" && output.length > 0) {
10606
+ if (output.length > 0) {
10600
10607
  const securityIssues = this.securityScanner.scanContent(output, node.task.name);
10601
10608
  if (securityIssues.length > 0) {
10602
10609
  this.emit("security:issues-found", {
@@ -10757,7 +10764,57 @@ Please continue from where the previous agent left off.`;
10757
10764
  };
10758
10765
  this.notifyStateChange();
10759
10766
  }
10760
- async spawnAgent(config) {
10767
+ async createChildSession(params) {
10768
+ const { tool, agent, description, prompt, model, parent, callID } = params;
10769
+ if (!parent.sessionID) {
10770
+ throw new Error("Cannot spawn agent without a parent session ID");
10771
+ }
10772
+ if (!parent.agent) {
10773
+ throw new Error("Cannot spawn agent without the calling agent id (tool context has no 'agent')");
10774
+ }
10775
+ let childSessionID;
10776
+ let resolveChildSession = null;
10777
+ let watchdog;
10778
+ const childSessionReady = new Promise((resolve, reject) => {
10779
+ resolveChildSession = resolve;
10780
+ watchdog = setTimeout(() => reject(new Error(`subagent tool did not report a child session for ${agent}`)), 30000);
10781
+ });
10782
+ childSessionReady.catch(() => {});
10783
+ const reportProgress = (p) => {
10784
+ if (p?.sessionID && !childSessionID) {
10785
+ childSessionID = p.sessionID;
10786
+ resolveChildSession?.(p.sessionID);
10787
+ }
10788
+ return Promise.resolve();
10789
+ };
10790
+ const toolContext = {
10791
+ sessionID: parent.sessionID,
10792
+ agent: parent.agent,
10793
+ messageID: parent.messageID || `msg_${callID}`,
10794
+ id: `call_${callID}`,
10795
+ progress: reportProgress,
10796
+ signal: parent.signal ?? new AbortController().signal
10797
+ };
10798
+ const subagentCall = tool.execute({ agent, description, prompt, model, background: true }, toolContext);
10799
+ let childSessionIDResolved;
10800
+ try {
10801
+ childSessionIDResolved = await Promise.race([
10802
+ childSessionReady,
10803
+ subagentCall.then(() => {
10804
+ throw new Error(`subagent tool finished without reporting a child session for ${agent}`);
10805
+ }, (err) => {
10806
+ const message = err instanceof Error ? err.message : String(err);
10807
+ throw new Error(`subagent tool failed for ${agent}: ${message}`);
10808
+ })
10809
+ ]);
10810
+ } finally {
10811
+ if (watchdog)
10812
+ clearTimeout(watchdog);
10813
+ }
10814
+ subagentCall.catch(() => {});
10815
+ return childSessionIDResolved;
10816
+ }
10817
+ async spawnAgent(config, options) {
10761
10818
  if (!this.ctx) {
10762
10819
  throw new Error("Orchestrator not initialized");
10763
10820
  }
@@ -10765,33 +10822,74 @@ Please continue from where the previous agent left off.`;
10765
10822
  throw new Error("Budget exceeded — cannot spawn new agents");
10766
10823
  }
10767
10824
  const agentId = `agent-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
10768
- const modelConfig = config.model || this.configManager.getModelForRole(config.role);
10825
+ let modelConfig = config.model || this.configManager.getModelForRole(config.role);
10826
+ if (!modelConfig.includes("/")) {
10827
+ const allModels = this.configManager.getConfig().models;
10828
+ const match = Object.values(allModels).find((m) => m?.split("/")[1] === modelConfig);
10829
+ if (match) {
10830
+ modelConfig = match;
10831
+ } else {
10832
+ throw new Error(`Invalid model "${config.model}". Use "providerID/modelID" format (e.g. "opencode-go/mimo-v2.5")`);
10833
+ }
10834
+ }
10769
10835
  const slashIndex = modelConfig.indexOf("/");
10770
- const provider = slashIndex > -1 ? modelConfig.slice(0, slashIndex) : modelConfig;
10771
- const modelName = slashIndex > -1 ? modelConfig.slice(slashIndex + 1) : modelConfig;
10836
+ const provider = modelConfig.slice(0, slashIndex);
10837
+ const modelName = modelConfig.slice(slashIndex + 1);
10772
10838
  const roleEmoji = this.configManager.getRoleEmoji(config.role);
10773
10839
  const title = `${roleEmoji} ${this.configManager.getRoleDisplayName(config.role)} — ${modelConfig}`;
10774
10840
  const agentTypeMap = {
10775
- architect: "architect",
10776
- coder: "build-orchestrator",
10777
- reviewer: "code-reviewer",
10778
- tester: "build-orchestrator",
10779
- explorer: "explore",
10780
- documenter: "doc-writer"
10841
+ architect: "nexus-architect",
10842
+ coder: "nexus-coder",
10843
+ reviewer: "nexus-reviewer",
10844
+ tester: "nexus-tester",
10845
+ explorer: "nexus-explorer",
10846
+ documenter: "nexus-documenter"
10781
10847
  };
10782
- const agentType = agentTypeMap[config.role] || "build";
10783
- const session = await this.ctx.session.create({
10784
- title,
10785
- agent: agentType,
10786
- model: modelName ? { providerID: provider, id: modelName } : undefined,
10787
- parentID: this.parentSessionID || undefined,
10788
- metadata: {
10789
- nexusRole: config.role,
10790
- nexusTask: config.task?.name || "direct-spawn",
10791
- nexusAgentId: agentId,
10792
- nexusModel: modelConfig
10793
- }
10794
- });
10848
+ const agentType = agentTypeMap[config.role] || "nexus-coder";
10849
+ const parent = options?.toolContext?.sessionID ? options.toolContext : undefined;
10850
+ const toolList = parent && typeof this.ctx.tool?.list === "function" ? await this.ctx.tool.list() : undefined;
10851
+ const subagentTool = Array.isArray(toolList) ? toolList.find((t) => t?.id === "subagent" && typeof t?.execute === "function") : undefined;
10852
+ const taskText = options?.task || config.task?.description || config.task?.name || "";
10853
+ let spawnPath = "session-create";
10854
+ let childSessionID;
10855
+ if (parent && subagentTool) {
10856
+ if (!taskText) {
10857
+ throw new Error("spawnAgent requires task text when using the subagent tool path");
10858
+ }
10859
+ spawnPath = "subagent-tool";
10860
+ childSessionID = await this.createChildSession({
10861
+ tool: subagentTool,
10862
+ agent: agentType,
10863
+ description: title,
10864
+ prompt: taskText,
10865
+ model: modelConfig,
10866
+ parent,
10867
+ callID: agentId
10868
+ });
10869
+ } else {
10870
+ const created = await this.ctx.session.create({
10871
+ title,
10872
+ agent: agentType,
10873
+ model: modelName ? { providerID: provider, id: modelName } : undefined,
10874
+ metadata: {
10875
+ nexusRole: config.role,
10876
+ nexusTask: config.task?.name || "direct-spawn",
10877
+ nexusAgentId: agentId,
10878
+ nexusModel: modelConfig
10879
+ }
10880
+ });
10881
+ childSessionID = created.id;
10882
+ }
10883
+ if (spawnPath === "session-create") {
10884
+ this.lastDegradedSpawn = {
10885
+ agentId,
10886
+ role: config.role,
10887
+ reason: parent ? "subagent-tool-unavailable" : "no-parent-context"
10888
+ };
10889
+ 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.`);
10890
+ } else {
10891
+ this.lastDegradedSpawn = null;
10892
+ }
10795
10893
  const agent = {
10796
10894
  id: agentId,
10797
10895
  name: title,
@@ -10814,7 +10912,8 @@ Please continue from where the previous agent left off.`;
10814
10912
  averageResponseTime: 0,
10815
10913
  errorRate: 0
10816
10914
  },
10817
- sessionID: session.id
10915
+ sessionID: childSessionID,
10916
+ spawnPath
10818
10917
  };
10819
10918
  this.agents.set(agentId, agent);
10820
10919
  const taskDesc = config.task?.name || `Agent ${config.role} task`;
@@ -10869,10 +10968,29 @@ Please continue from where the previous agent left off.`;
10869
10968
  selectModel(role, complexity) {
10870
10969
  return this.selectBestModel(role, complexity);
10871
10970
  }
10872
- estimateModelCost(model) {
10873
- const realCost = this.modelCosts.get(model);
10971
+ getModelCost(model, provider) {
10972
+ const exact = this.modelCosts.get(model);
10973
+ if (exact)
10974
+ return exact;
10975
+ if (provider) {
10976
+ const qualified = this.modelCosts.get(`${provider}/${model}`);
10977
+ if (qualified)
10978
+ return qualified;
10979
+ }
10980
+ const bare = model.slice(model.indexOf("/") + 1);
10981
+ let best;
10982
+ for (const [key, cost] of this.modelCosts) {
10983
+ if (key.slice(key.indexOf("/") + 1) !== bare)
10984
+ continue;
10985
+ if (!best || cost.input < best.input)
10986
+ best = cost;
10987
+ }
10988
+ return best;
10989
+ }
10990
+ estimateModelCost(model, provider) {
10991
+ const realCost = this.getModelCost(model, provider);
10874
10992
  if (realCost) {
10875
- return (realCost.input * 1000 + realCost.output * 1000) / 2;
10993
+ return (realCost.input + realCost.output) / 2;
10876
10994
  }
10877
10995
  const costs = {
10878
10996
  "claude-sonnet-4-6": 0.15,
@@ -10912,7 +11030,7 @@ Please continue from where the previous agent left off.`;
10912
11030
  scoreModel(modelId, role, complexity) {
10913
11031
  const [provider, ...parts] = modelId.split("/");
10914
11032
  const model = parts.join("/");
10915
- const cost = this.estimateModelCost(model);
11033
+ const cost = this.estimateModelCost(model, provider);
10916
11034
  const quality = this.estimateModelQuality(model);
10917
11035
  const maxCost = 15;
10918
11036
  const costScore = 1 - cost / maxCost;
@@ -10944,14 +11062,14 @@ Please continue from where the previous agent left off.`;
10944
11062
  const scored = unique.map((m) => this.scoreModel(m, role, complexity));
10945
11063
  const budgetRemaining = this.budget.maxTotalCost - this.totalSpent;
10946
11064
  const affordable = scored.filter((s) => {
10947
- const cost = this.estimateModelCost(s.model);
11065
+ const cost = this.estimateModelCost(s.model, s.provider);
10948
11066
  return cost <= budgetRemaining || cost === 0;
10949
11067
  });
10950
11068
  const best = (affordable.length > 0 ? affordable : scored).sort((a, b) => b.overallScore - a.overallScore)[0];
10951
11069
  return {
10952
11070
  provider: best.provider,
10953
11071
  model: best.model,
10954
- estimatedCost: this.estimateModelCost(best.model),
11072
+ estimatedCost: this.estimateModelCost(best.model, best.provider),
10955
11073
  estimatedQuality: best.qualityScore,
10956
11074
  reasoning: best.reasoning
10957
11075
  };
@@ -11445,6 +11563,28 @@ import { homedir as homedir2 } from "node:os";
11445
11563
  var NEXUS_AGENT_CONTENT = `---
11446
11564
  description: Nexus multi-agent orchestrator — decomposes tasks and delegates to specialized sub-agents
11447
11565
  mode: primary
11566
+ permissions:
11567
+ - action: subagent
11568
+ resource: "nexus-*"
11569
+ effect: allow
11570
+ - action: subagent
11571
+ resource: "nexus-architect"
11572
+ effect: allow
11573
+ - action: subagent
11574
+ resource: "nexus-coder"
11575
+ effect: allow
11576
+ - action: subagent
11577
+ resource: "nexus-reviewer"
11578
+ effect: allow
11579
+ - action: subagent
11580
+ resource: "nexus-tester"
11581
+ effect: allow
11582
+ - action: subagent
11583
+ resource: "nexus-explorer"
11584
+ effect: allow
11585
+ - action: subagent
11586
+ resource: "nexus-documenter"
11587
+ effect: allow
11448
11588
  ---
11449
11589
 
11450
11590
  # Nexus Orchestrator
@@ -11608,8 +11748,10 @@ mode: subagent
11608
11748
  permissions:
11609
11749
  - action: edit
11610
11750
  resource: "*"
11751
+ effect: allow
11611
11752
  - action: shell
11612
11753
  resource: "*"
11754
+ effect: allow
11613
11755
  ---
11614
11756
 
11615
11757
  # Nexus Architect Agent
@@ -11650,8 +11792,10 @@ mode: subagent
11650
11792
  permissions:
11651
11793
  - action: edit
11652
11794
  resource: "*"
11795
+ effect: allow
11653
11796
  - action: shell
11654
11797
  resource: "*"
11798
+ effect: allow
11655
11799
  ---
11656
11800
 
11657
11801
  # Nexus Coder Agent
@@ -11737,8 +11881,10 @@ mode: subagent
11737
11881
  permissions:
11738
11882
  - action: edit
11739
11883
  resource: "*"
11884
+ effect: allow
11740
11885
  - action: shell
11741
11886
  resource: "*"
11887
+ effect: allow
11742
11888
  ---
11743
11889
 
11744
11890
  # Nexus Tester Agent
@@ -11836,8 +11982,10 @@ mode: subagent
11836
11982
  permissions:
11837
11983
  - action: edit
11838
11984
  resource: "*"
11985
+ effect: allow
11839
11986
  - action: shell
11840
11987
  resource: "*"
11988
+ effect: allow
11841
11989
  ---
11842
11990
 
11843
11991
  # Nexus Documenter Agent
@@ -11915,6 +12063,25 @@ You are a technical writer who creates documentation that developers actually wa
11915
12063
  totalCost: initialState.totalSpent,
11916
12064
  budgetRemaining: initialState.budgetRemaining
11917
12065
  })));
12066
+ const spawnAndDeliver = async (opts, toolCtx) => {
12067
+ const agent = await orchestrator.spawnAgent({ role: opts.role, model: opts.model }, {
12068
+ toolContext: {
12069
+ sessionID: toolCtx?.sessionID || "",
12070
+ agent: toolCtx?.agent,
12071
+ messageID: toolCtx?.messageID,
12072
+ callID: toolCtx?.id,
12073
+ signal: toolCtx?.signal
12074
+ },
12075
+ task: opts.task
12076
+ });
12077
+ if (agent.spawnPath !== "subagent-tool") {
12078
+ await ctx.session.prompt({
12079
+ sessionID: agent.sessionID,
12080
+ text: opts.task
12081
+ });
12082
+ }
12083
+ return agent;
12084
+ };
11918
12085
  await ctx.tool.transform((editor) => {
11919
12086
  editor.namespace({
11920
12087
  name: "nexus",
@@ -12101,35 +12268,36 @@ You are a technical writer who creates documentation that developers actually wa
12101
12268
  });
12102
12269
  editor.add({
12103
12270
  name: "model.costs",
12104
- description: "Show real model pricing from OpenCode or set custom costs",
12271
+ description: "Show real model pricing from OpenCode, or set custom costs. All prices are USD per 1K tokens.",
12105
12272
  input: {
12106
12273
  type: "object",
12107
12274
  properties: {
12108
- model: { type: "string", description: "Model ID to show cost for (optional, shows all if omitted)" },
12109
- setInput: { type: "number", description: "Set input cost per token for a model" },
12110
- setOutput: { type: "number", description: "Set output cost per token for a model" }
12275
+ model: { type: "string", description: "Model to show cost for, as 'provider/id' or a bare 'id' (optional, shows all if omitted)" },
12276
+ setInput: { type: "number", description: "Set input cost in USD per 1K tokens for a model (e.g. 0.003 for $3 per million tokens)" },
12277
+ setOutput: { type: "number", description: "Set output cost in USD per 1K tokens for a model (e.g. 0.015 for $15 per million tokens)" }
12111
12278
  },
12112
12279
  additionalProperties: false
12113
12280
  },
12114
12281
  options: { codemode: true },
12115
12282
  execute: async (input) => {
12116
12283
  const { model, setInput, setOutput } = input;
12284
+ const per1k = (v) => `$${v}/1K tokens`;
12117
12285
  if (model && setInput !== undefined && setOutput !== undefined) {
12118
12286
  orchestrator.setModelCosts({ [model]: { input: setInput, output: setOutput } });
12119
- return { content: `Set ${model}: input=$${setInput}/token, output=$${setOutput}/token` };
12287
+ return { content: `Set ${model}: input=${per1k(setInput)}, output=${per1k(setOutput)}` };
12120
12288
  }
12121
12289
  if (model) {
12122
- const cost = orchestrator.modelCosts.get(model);
12290
+ const cost = orchestrator.getModelCost(model);
12123
12291
  if (cost) {
12124
- return { content: `${model}: input=$${cost.input}/token, output=$${cost.output}/token, cache_read=$${cost.cacheRead}/token, cache_write=$${cost.cacheWrite}/token` };
12292
+ return { content: `${model}: input=${per1k(cost.input)}, output=${per1k(cost.output)}, cache_read=${per1k(cost.cacheRead)}, cache_write=${per1k(cost.cacheWrite)}` };
12125
12293
  }
12126
12294
  const estimate = orchestrator["estimateModelCost"](model);
12127
12295
  return { content: `${model}: no real pricing data (estimated $${estimate}/1K tokens)` };
12128
12296
  }
12129
12297
  if (orchestrator.modelCosts.size > 0) {
12130
- const lines = ["\uD83D\uDCCA Model Pricing (from OpenCode):"];
12298
+ const lines = ["\uD83D\uDCCA Model Pricing (from OpenCode, per 1K tokens):"];
12131
12299
  for (const [id, cost] of orchestrator.modelCosts) {
12132
- lines.push(` ${id}: $${cost.input}/token in, $${cost.output}/token out`);
12300
+ lines.push(` ${id}: ${per1k(cost.input)} in, ${per1k(cost.output)} out`);
12133
12301
  }
12134
12302
  return { content: lines.join(`
12135
12303
  `) };
@@ -12153,27 +12321,15 @@ You are a technical writer who creates documentation that developers actually wa
12153
12321
  additionalProperties: false
12154
12322
  },
12155
12323
  options: { codemode: true },
12156
- execute: async (input) => {
12324
+ execute: async (input, toolCtx) => {
12157
12325
  const { role, task, model, wait, timeout } = input;
12158
12326
  try {
12159
- if (!orchestrator.parentSessionID) {
12160
- try {
12161
- const currentSession = ctx.session?.current?.();
12162
- if (currentSession?.id) {
12163
- orchestrator.parentSessionID = currentSession.id;
12164
- }
12165
- } catch {}
12166
- }
12167
- const agent = await orchestrator.spawnAgent({ role, model });
12168
- await orchestrator.ctx.session.prompt({
12169
- sessionID: agent.sessionID,
12170
- text: task
12171
- });
12327
+ const agent = await spawnAndDeliver({ role, task, model }, toolCtx);
12172
12328
  agent.status = "working";
12173
12329
  orchestrator.notifyStateChange();
12174
12330
  if (wait) {
12175
12331
  const waitTimeout = timeout || 120000;
12176
- const waitPromise = orchestrator.ctx.session.wait({ sessionID: agent.sessionID });
12332
+ const waitPromise = ctx.session.wait({ sessionID: agent.sessionID });
12177
12333
  const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out after ${waitTimeout}ms`)), waitTimeout));
12178
12334
  try {
12179
12335
  await Promise.race([waitPromise, timeoutPromise]);
@@ -12181,9 +12337,9 @@ You are a technical writer who creates documentation that developers actually wa
12181
12337
  agent.status = "working";
12182
12338
  orchestrator.notifyStateChange();
12183
12339
  try {
12184
- const messages = await orchestrator.ctx.session.context({ sessionID: agent.sessionID });
12185
- const lastMsg = messages.filter((m) => m.role === "assistant").pop();
12186
- if (lastMsg) {
12340
+ const messages = await ctx.session.context({ sessionID: agent.sessionID });
12341
+ const partialText = lastAssistantText(messages);
12342
+ if (partialText) {
12187
12343
  agent.status = "completed";
12188
12344
  await ctx.storage.set("orchestrator-state", JSON.parse(JSON.stringify(orchestrator.getState())));
12189
12345
  const taskPreview = task.length > 80 ? task.substring(0, 77) + "..." : task;
@@ -12195,7 +12351,7 @@ You are a technical writer who creates documentation that developers actually wa
12195
12351
  `\uD83D\uDCCE Session: ${agent.sessionID}`,
12196
12352
  `
12197
12353
  --- Partial Result ---`,
12198
- typeof lastMsg.content === "string" ? lastMsg.content : JSON.stringify(lastMsg.content)
12354
+ partialText
12199
12355
  ].join(`
12200
12356
  `)
12201
12357
  };
@@ -12213,9 +12369,8 @@ You are a technical writer who creates documentation that developers actually wa
12213
12369
  };
12214
12370
  }
12215
12371
  try {
12216
- const messages = await orchestrator.ctx.session.context({ sessionID: agent.sessionID });
12217
- const lastMsg = messages.filter((m) => m.role === "assistant").pop();
12218
- const result = lastMsg?.content || "Task completed (no output captured)";
12372
+ const messages = await ctx.session.context({ sessionID: agent.sessionID });
12373
+ const result = lastAssistantText(messages) || "Task completed (no output captured)";
12219
12374
  agent.status = "completed";
12220
12375
  await ctx.storage.set("orchestrator-state", JSON.parse(JSON.stringify(orchestrator.getState())));
12221
12376
  const taskPreview = task.length > 80 ? task.substring(0, 77) + "..." : task;
@@ -12290,24 +12445,19 @@ You are a technical writer who creates documentation that developers actually wa
12290
12445
  additionalProperties: false
12291
12446
  },
12292
12447
  options: { codemode: true },
12293
- execute: async (input) => {
12448
+ execute: async (input, toolCtx) => {
12294
12449
  const { role, task, model, timeout } = input;
12295
12450
  try {
12296
- const agent = await orchestrator.spawnAgent({ role, model });
12297
- await orchestrator.ctx.session.prompt({
12298
- sessionID: agent.sessionID,
12299
- text: task
12300
- });
12451
+ const agent = await spawnAndDeliver({ role, task, model }, toolCtx);
12301
12452
  agent.status = "working";
12302
12453
  orchestrator.notifyStateChange();
12303
12454
  const waitTimeout = timeout || 120000;
12304
- const waitPromise = orchestrator.ctx.session.wait({ sessionID: agent.sessionID });
12455
+ const waitPromise = ctx.session.wait({ sessionID: agent.sessionID });
12305
12456
  const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve("timeout"), waitTimeout));
12306
12457
  const outcome = await Promise.race([waitPromise.then(() => "completed"), timeoutPromise]);
12307
12458
  try {
12308
- const messages = await orchestrator.ctx.session.context({ sessionID: agent.sessionID });
12309
- const lastMsg = messages.filter((m) => m.role === "assistant").pop();
12310
- const resultContent = lastMsg?.content || (outcome === "timeout" ? "Timed out — agent may still be running" : "Completed with no output");
12459
+ const messages = await ctx.session.context({ sessionID: agent.sessionID });
12460
+ const resultContent = lastAssistantText(messages) || (outcome === "timeout" ? "Timed out — agent may still be running" : "Completed with no output");
12311
12461
  agent.status = outcome === "timeout" ? "working" : "completed";
12312
12462
  orchestrator.notifyStateChange();
12313
12463
  await ctx.storage.set("orchestrator-state", JSON.parse(JSON.stringify(orchestrator.getState())));
@@ -12609,12 +12759,20 @@ ${lines.join(`
12609
12759
  const agents = orchestrator.getState().agents.filter((a) => a.status === "working" || a.status === "idle");
12610
12760
  if (agents.length === 0)
12611
12761
  return { content: "No running agents to move to background." };
12762
+ const background = ctx.session.background;
12763
+ if (!background) {
12764
+ return { content: "This OpenCode version does not expose session.background on the plugin context — nothing was detached." };
12765
+ }
12766
+ let detached = 0;
12612
12767
  for (const agent of agents) {
12768
+ if (!agent.sessionID)
12769
+ continue;
12613
12770
  try {
12614
- await orchestrator.ctx.session.background({ sessionID: agent.sessionID });
12771
+ await background.call(ctx.session, { sessionID: agent.sessionID });
12772
+ detached++;
12615
12773
  } catch {}
12616
12774
  }
12617
- return { content: `${agents.length} agent(s) moved to background. You can continue working while they run.` };
12775
+ return { content: `${detached} agent(s) moved to background. You can continue working while they run.` };
12618
12776
  }
12619
12777
  });
12620
12778
  editor.add({
@@ -12631,11 +12789,11 @@ ${lines.join(`
12631
12789
  execute: async (input) => {
12632
12790
  const { sessionID } = input;
12633
12791
  try {
12634
- const messages = await orchestrator.ctx.session.context({ sessionID });
12635
- const lastMsg = messages.filter((m) => m.role === "assistant").pop();
12636
- if (lastMsg) {
12792
+ const messages = await ctx.session.context({ sessionID });
12793
+ const text = lastAssistantText(messages);
12794
+ if (text) {
12637
12795
  return { content: `Session ${sessionID} result:
12638
- ${lastMsg.content}` };
12796
+ ${text}` };
12639
12797
  }
12640
12798
  return { content: `Session ${sessionID} has no assistant messages yet.` };
12641
12799
  } catch (error) {
package/dist/tui.js CHANGED
@@ -129,7 +129,11 @@ class NexusConfigManager {
129
129
  }
130
130
  getModelForRole(role) {
131
131
  const config = this.getConfig();
132
- return config.models[role] || config.models.coder || DEFAULT_CONFIG.models.coder;
132
+ const model = config.models[role] || config.models.coder || DEFAULT_CONFIG.models.coder;
133
+ if (!model.includes("/")) {
134
+ console.warn(`[nexus] Model "${model}" for role "${role}" is missing provider prefix. Expected "providerID/modelID" format.`);
135
+ }
136
+ return model;
133
137
  }
134
138
  updateStorageConfig(update) {
135
139
  this.storageConfig = {
@@ -213,10 +217,18 @@ class NexusConfigManager {
213
217
  }
214
218
  initProjectConfig(basePath) {
215
219
  const projectPath = join(basePath, ".opencode", "nexus.jsonc");
220
+ try {
221
+ readFileSync(projectPath, "utf-8");
222
+ return;
223
+ } catch {}
216
224
  this.writeJsoncFile(projectPath, { ...DEFAULT_CONFIG });
217
225
  }
218
226
  initGlobalConfig() {
219
227
  const globalPath = join(homedir(), ".config", "opencode", "nexus.jsonc");
228
+ try {
229
+ readFileSync(globalPath, "utf-8");
230
+ return;
231
+ } catch {}
220
232
  this.writeJsoncFile(globalPath, { ...DEFAULT_CONFIG });
221
233
  }
222
234
  saveConfig(level, basePath) {
@@ -382,6 +394,12 @@ var tui_default = define({
382
394
  id: "nexus.cli",
383
395
  setup(context) {
384
396
  const configManager = new NexusConfigManager;
397
+ try {
398
+ const loc = context.location ?? context.data.location.default();
399
+ if (loc?.directory) {
400
+ configManager.loadFromPath(loc.directory);
401
+ }
402
+ } catch {}
385
403
  let configSaveScope = "global";
386
404
  const handleModelSelect = async (role) => {
387
405
  if (!configManager.getRoles().includes(role)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serkanalgur/opencode-nexus",
3
- "version": "2.3.4",
3
+ "version": "2.4.0",
4
4
  "description": "Adaptive Multi-Agent Orchestration with Cost Intelligence for OpenCode V2",
5
5
  "keywords": [
6
6
  "opencode",