@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.
- package/dist/index.js +516 -140
- package/dist/tui.js +80 -16
- 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
|
-
|
|
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
|
-
|
|
8420
|
-
|
|
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.
|
|
8457
|
-
|
|
8458
|
-
|
|
8459
|
-
const
|
|
8460
|
-
this.
|
|
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
|
-
|
|
8463
|
-
this.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
10187
|
-
|
|
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
|
|
10205
|
-
|
|
10206
|
-
|
|
10207
|
-
const
|
|
10208
|
-
|
|
10209
|
-
|
|
10210
|
-
|
|
10211
|
-
|
|
10212
|
-
|
|
10213
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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: "
|
|
10798
|
-
reviewer: "
|
|
10799
|
-
tester: "
|
|
10800
|
-
explorer: "
|
|
10801
|
-
documenter: "
|
|
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] || "
|
|
10804
|
-
const
|
|
10805
|
-
|
|
10806
|
-
|
|
10807
|
-
|
|
10808
|
-
|
|
10809
|
-
|
|
10810
|
-
|
|
10811
|
-
|
|
10812
|
-
|
|
10813
|
-
|
|
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:
|
|
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
|
-
|
|
10894
|
-
const
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
12130
|
-
setInput: { type: "number", description: "Set input cost per
|
|
12131
|
-
setOutput: { type: "number", description: "Set output cost per
|
|
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
|
|
12520
|
+
return { content: `Set ${model}: input=${per1k(setInput)}, output=${per1k(setOutput)}` };
|
|
12141
12521
|
}
|
|
12142
12522
|
if (model) {
|
|
12143
|
-
const cost = orchestrator.
|
|
12523
|
+
const cost = orchestrator.getModelCost(model);
|
|
12144
12524
|
if (cost) {
|
|
12145
|
-
return { content: `${model}: input
|
|
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}:
|
|
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
|
-
|
|
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 =
|
|
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
|
|
12206
|
-
const
|
|
12207
|
-
if (
|
|
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
|
-
|
|
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
|
|
12238
|
-
const
|
|
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
|
|
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 =
|
|
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
|
|
12330
|
-
const
|
|
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
|
|
13004
|
+
await background.call(ctx.session, { sessionID: agent.sessionID });
|
|
13005
|
+
detached++;
|
|
12636
13006
|
} catch {}
|
|
12637
13007
|
}
|
|
12638
|
-
return { content: `${
|
|
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
|
|
12656
|
-
const
|
|
12657
|
-
if (
|
|
13025
|
+
const messages = await ctx.session.context({ sessionID });
|
|
13026
|
+
const text = lastAssistantText(messages);
|
|
13027
|
+
if (text) {
|
|
12658
13028
|
return { content: `Session ${sessionID} result:
|
|
12659
|
-
${
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
this.
|
|
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
|
-
|
|
106
|
-
this.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
291
|
+
const globalPath = nexusGlobalConfigPath();
|
|
228
292
|
try {
|
|
229
293
|
readFileSync(globalPath, "utf-8");
|
|
230
294
|
return;
|