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