@serkanalgur/opencode-nexus 2.7.0 → 2.8.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/README.md +63 -28
- package/dist/index.js +754 -120
- package/dist/tui.js +111 -23
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -8478,6 +8478,9 @@ var DEFAULT_CONFIG = {
|
|
|
8478
8478
|
enabled: true,
|
|
8479
8479
|
port: 4747,
|
|
8480
8480
|
host: "127.0.0.1"
|
|
8481
|
+
},
|
|
8482
|
+
notifications: {
|
|
8483
|
+
enabled: true
|
|
8481
8484
|
}
|
|
8482
8485
|
};
|
|
8483
8486
|
|
|
@@ -8487,10 +8490,12 @@ class NexusConfigManager {
|
|
|
8487
8490
|
storageConfig = null;
|
|
8488
8491
|
loadInfo = null;
|
|
8489
8492
|
dashboardBase;
|
|
8490
|
-
|
|
8493
|
+
notificationsBase;
|
|
8494
|
+
constructor(dashboardBase, notificationsBase) {
|
|
8491
8495
|
this.projectConfig = null;
|
|
8492
8496
|
this.globalConfig = null;
|
|
8493
8497
|
this.dashboardBase = { ...DEFAULT_CONFIG.dashboard, ...dashboardBase };
|
|
8498
|
+
this.notificationsBase = { ...DEFAULT_CONFIG.notifications, ...notificationsBase };
|
|
8494
8499
|
}
|
|
8495
8500
|
loadConfigs(basePath) {
|
|
8496
8501
|
if (this.loadInfo === null)
|
|
@@ -8557,6 +8562,9 @@ class NexusConfigManager {
|
|
|
8557
8562
|
enabled: this.storageConfig?.dashboard?.enabled ?? this.projectConfig?.dashboard?.enabled ?? this.globalConfig?.dashboard?.enabled ?? this.dashboardBase.enabled,
|
|
8558
8563
|
port: this.storageConfig?.dashboard?.port ?? this.projectConfig?.dashboard?.port ?? this.globalConfig?.dashboard?.port ?? this.dashboardBase.port,
|
|
8559
8564
|
host: this.storageConfig?.dashboard?.host ?? this.projectConfig?.dashboard?.host ?? this.globalConfig?.dashboard?.host ?? this.dashboardBase.host
|
|
8565
|
+
},
|
|
8566
|
+
notifications: {
|
|
8567
|
+
enabled: this.storageConfig?.notifications?.enabled ?? this.projectConfig?.notifications?.enabled ?? this.globalConfig?.notifications?.enabled ?? this.notificationsBase.enabled
|
|
8560
8568
|
}
|
|
8561
8569
|
};
|
|
8562
8570
|
}
|
|
@@ -8678,6 +8686,7 @@ class NexusConfigManager {
|
|
|
8678
8686
|
result.budget = { ...current.budget };
|
|
8679
8687
|
result.selfHealing = { ...current.selfHealing };
|
|
8680
8688
|
result.dashboard = { ...current.dashboard };
|
|
8689
|
+
result.notifications = { ...current.notifications };
|
|
8681
8690
|
return result;
|
|
8682
8691
|
}
|
|
8683
8692
|
resetToDefaults() {
|
|
@@ -10819,7 +10828,12 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
10819
10828
|
+ ' (' + fmtElapsed(age) + ' ago)';
|
|
10820
10829
|
$lastRefresh.textContent = text;
|
|
10821
10830
|
$lastRefresh.className = 'last-refresh' + (age > STALE_AFTER_MS ? ' stale' : '');
|
|
10822
|
-
|
|
10831
|
+
// The server's own stamp, verbatim, alongside the received-at time. The two
|
|
10832
|
+
// are different claims — one is when the snapshot was made, the other when
|
|
10833
|
+
// this page heard about it — and a \`title\` is where a raw timestamp belongs:
|
|
10834
|
+
// the visible line stays readable while the exact value stays checkable.
|
|
10835
|
+
$lastRefresh.title = 'state.lastUpdated ' + (lastStateUpdated || 'not reported')
|
|
10836
|
+
+ '; received at ' + fmtTime(new Date(lastStateReceivedAt).toISOString())
|
|
10823
10837
|
+ '. ' + (ws && ws.readyState === WebSocket.OPEN
|
|
10824
10838
|
? 'Auto-refresh asks the server every ' + (AUTO_REFRESH_INTERVAL / 1000) + 's.'
|
|
10825
10839
|
: 'Not connected: this may be the last state the server sent.');
|
|
@@ -11023,6 +11037,24 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11023
11037
|
return String(err);
|
|
11024
11038
|
}
|
|
11025
11039
|
|
|
11040
|
+
/**
|
|
11041
|
+
* A frame's own type, for an error message about that frame.
|
|
11042
|
+
*
|
|
11043
|
+
* Deliberately TOTAL: it is called from the render-failure path, where the
|
|
11044
|
+
* page has already thrown once, so it must not be the thing that throws
|
|
11045
|
+
* second. \`msg\` may be a non-object — a bare number, a string, \`null\` — so
|
|
11046
|
+
* every step is narrowed through the same \`asObject\`/\`text\` gates the rest of
|
|
11047
|
+
* the page uses rather than assumed. Mirrors \`handleMessage\`'s own
|
|
11048
|
+
* \`msg.type || msg.event\` so the two cannot disagree about a frame's name.
|
|
11049
|
+
*/
|
|
11050
|
+
function frameType(msg) {
|
|
11051
|
+
var obj = asObject(msg);
|
|
11052
|
+
if (obj === null) return 'untyped';
|
|
11053
|
+
var type = text(obj.type);
|
|
11054
|
+
if (type === null) type = text(obj.event);
|
|
11055
|
+
return type === null ? 'untyped' : type;
|
|
11056
|
+
}
|
|
11057
|
+
|
|
11026
11058
|
// ── Connection ──
|
|
11027
11059
|
function setConnState(state) {
|
|
11028
11060
|
$connBadge.className = 'connection-badge ' + state;
|
|
@@ -11068,11 +11100,34 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11068
11100
|
}
|
|
11069
11101
|
};
|
|
11070
11102
|
|
|
11103
|
+
// Parse and render are TWO failures with TWO owners, so they are caught
|
|
11104
|
+
// separately and reported separately.
|
|
11105
|
+
//
|
|
11106
|
+
// A single \`try\` around both meant a \`TypeError\` thrown by this page's own
|
|
11107
|
+
// renderer was reported as "Non-JSON frame from server" — which points a
|
|
11108
|
+
// reader at the server for a bug that is entirely in this file, and does so
|
|
11109
|
+
// with a message that is simply false about a frame that parsed perfectly
|
|
11110
|
+
// well. That misdiagnosis is what sent the investigation the wrong way when
|
|
11111
|
+
// \`renderState\` started throwing on every push.
|
|
11112
|
+
//
|
|
11113
|
+
// The render failure names the frame's own type, so a log line says which
|
|
11114
|
+
// message could not be drawn, and it is logged as an error rather than as
|
|
11115
|
+
// server-side information. The parse failure stays an \`info\` line: an
|
|
11116
|
+
// unparseable frame IS news about the server.
|
|
11071
11117
|
ws.onmessage = function(evt) {
|
|
11118
|
+
var msg;
|
|
11072
11119
|
try {
|
|
11073
|
-
|
|
11120
|
+
msg = JSON.parse(evt.data);
|
|
11074
11121
|
} catch (e) {
|
|
11075
11122
|
addLog('info', '📩', 'Non-JSON frame from server: ' + errorText(e));
|
|
11123
|
+
return;
|
|
11124
|
+
}
|
|
11125
|
+
try {
|
|
11126
|
+
handleMessage(msg);
|
|
11127
|
+
} catch (e) {
|
|
11128
|
+
addLog('error', '⚠️', 'Page failed to render a ' + frameType(msg)
|
|
11129
|
+
+ ' frame (this is a bug in the page, not a bad frame from the server): '
|
|
11130
|
+
+ errorText(e));
|
|
11076
11131
|
}
|
|
11077
11132
|
};
|
|
11078
11133
|
|
|
@@ -11148,8 +11203,8 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11148
11203
|
break;
|
|
11149
11204
|
|
|
11150
11205
|
case 'task:failed':
|
|
11151
|
-
addLog('error', '❌', 'Task failed: ' +
|
|
11152
|
-
addLogStream('error', 'FAIL', 'Task failed: ' +
|
|
11206
|
+
addLog('error', '❌', 'Task failed: ' + taskFailedText(data));
|
|
11207
|
+
addLogStream('error', 'FAIL', 'Task failed: ' + taskFailedText(data));
|
|
11153
11208
|
break;
|
|
11154
11209
|
|
|
11155
11210
|
case 'cost:delta':
|
|
@@ -11232,6 +11287,32 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11232
11287
|
return e === null ? 'no error message reported' : e;
|
|
11233
11288
|
}
|
|
11234
11289
|
|
|
11290
|
+
/**
|
|
11291
|
+
* A \`task:failed\` line: which task, why, and how long it took.
|
|
11292
|
+
*
|
|
11293
|
+
* \`duration\` was on the event and unread. It is the one field that separates
|
|
11294
|
+
* the two failures a reader has to tell apart — a task that failed
|
|
11295
|
+
* immediately and one that ran for minutes and then failed are the same line
|
|
11296
|
+
* of text without it, and only the second is a hang worth chasing. The
|
|
11297
|
+
* spawn-failure emit site reports \`duration: 0\` and omits the agent, model and
|
|
11298
|
+
* session entirely rather than fabricating them, so a zero here is a real
|
|
11299
|
+
* "the spawn itself failed", not a missing reading; it is printed as such
|
|
11300
|
+
* rather than as a dash that would read as unreported.
|
|
11301
|
+
*
|
|
11302
|
+
* The rest of the payload — \`role\`, \`agentId\`, \`model\`, \`sessionID\` — is
|
|
11303
|
+
* deliberately not read here. It is all in the throttled state push within a
|
|
11304
|
+
* second and rendered on the Tasks and Agents sections, so a log line
|
|
11305
|
+
* repeating it would be a second, staler copy of the same numbers. The line's
|
|
11306
|
+
* job is to name the task and the cause, which is what you act on.
|
|
11307
|
+
*/
|
|
11308
|
+
function taskFailedText(d) {
|
|
11309
|
+
var ms = num(d.duration);
|
|
11310
|
+
return taskLabel(d) + ' — ' + errorOf(d)
|
|
11311
|
+
+ (ms === null ? '' : ms === 0
|
|
11312
|
+
? ' (failed before any work — the spawn itself did not complete)'
|
|
11313
|
+
: ' (after ' + fmtMs(ms) + ')');
|
|
11314
|
+
}
|
|
11315
|
+
|
|
11235
11316
|
function escalationText(d) {
|
|
11236
11317
|
return taskLabel(d) + ' on agent ' + (text(d.agentId) || 'unreported') + ' — ' + errorOf(d)
|
|
11237
11318
|
+ ' (retries and model fallback are exhausted; notifying)';
|
|
@@ -11264,15 +11345,33 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11264
11345
|
}
|
|
11265
11346
|
|
|
11266
11347
|
/**
|
|
11267
|
-
* \`config:reloaded\` carries \`NexusConfigLoadInfo
|
|
11268
|
-
*
|
|
11269
|
-
*
|
|
11348
|
+
* \`config:reloaded\` carries \`NexusConfigLoadInfo\`. The useful part is not the
|
|
11349
|
+
* timestamps.
|
|
11350
|
+
*
|
|
11351
|
+
* The line already said how many times this has loaded, when, and which files
|
|
11352
|
+
* were consulted — and none of those answer the question a reader actually
|
|
11353
|
+
* has, which is WHY it reloaded. A reload you did not cause is exactly the one
|
|
11354
|
+
* worth explaining: \`initial\` is the process starting up, \`event\` is the
|
|
11355
|
+
* filesystem watch firing, \`poll\` is the interval check noticing the same
|
|
11356
|
+
* files changed. Without the trigger, a line appearing that nobody asked for
|
|
11357
|
+
* reads as the orchestrator having changed its own mind.
|
|
11358
|
+
*
|
|
11359
|
+
* So the trigger leads, and the count of resolved models comes with it: after
|
|
11360
|
+
* a reload the next question is always "did it pick up my change?", and a
|
|
11361
|
+
* number of roles is the one thing that can be compared against the Models
|
|
11362
|
+
* panel without reading the whole map back out of the config viewer.
|
|
11270
11363
|
*/
|
|
11271
11364
|
function configReloadText(d) {
|
|
11272
11365
|
var parts = [];
|
|
11366
|
+
var trigger = text(d.trigger);
|
|
11367
|
+
parts.push('trigger ' + (trigger === null ? 'unreported' : trigger));
|
|
11273
11368
|
var count = num(d.loadCount);
|
|
11274
11369
|
parts.push(count === null ? 'load count unreported' : 'load #' + fmtInt(count));
|
|
11275
|
-
|
|
11370
|
+
// The raw ISO, not a locale-formatted time. Every other line on this page
|
|
11371
|
+
// carries raw server values, and a log line whose one timestamp is the only
|
|
11372
|
+
// one a reader has to interpret by eye is the one they cannot grep.
|
|
11373
|
+
var loadedAt = text(d.loadedAt);
|
|
11374
|
+
parts.push('loaded ' + (loadedAt === null ? 'unreported' : loadedAt));
|
|
11276
11375
|
if (typeof d.sessionOverride === 'boolean') {
|
|
11277
11376
|
parts.push(d.sessionOverride ? 'session override layered on top of disk' : 'no session override');
|
|
11278
11377
|
}
|
|
@@ -11280,6 +11379,15 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11280
11379
|
if (project !== null) parts.push('project ' + configFileText(project));
|
|
11281
11380
|
var global = asObject(d.global);
|
|
11282
11381
|
if (global !== null) parts.push('global ' + configFileText(global));
|
|
11382
|
+
// The map itself is shown by the config viewer and the Models panel, so what
|
|
11383
|
+
// the log line carries is its SIZE — the checkable figure — rather than a
|
|
11384
|
+
// truncated copy of it.
|
|
11385
|
+
var models = asObject(d.models);
|
|
11386
|
+
if (models !== null) {
|
|
11387
|
+
var roles = Object.keys(models);
|
|
11388
|
+
parts.push(roles.length + ' role' + (roles.length === 1 ? '' : 's') + ' resolved'
|
|
11389
|
+
+ (roles.length === 0 ? ' (empty map)' : ''));
|
|
11390
|
+
}
|
|
11283
11391
|
return parts.join(' · ');
|
|
11284
11392
|
}
|
|
11285
11393
|
|
|
@@ -11303,27 +11411,129 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11303
11411
|
return 'success';
|
|
11304
11412
|
}
|
|
11305
11413
|
|
|
11414
|
+
/**
|
|
11415
|
+
* Which table priced this line, and which tier of it was selected.
|
|
11416
|
+
*
|
|
11417
|
+
* \`settledTier\` is on every \`cost:delta\`, and the page read none of it: each
|
|
11418
|
+
* line showed an amount with nothing at all about where the amount came from.
|
|
11419
|
+
* That matters because \`settledTier.pricing\` is a \`PricingSource\` —
|
|
11420
|
+
* \`model-costs\` (a published price list for this model), \`fallback-table\` (a
|
|
11421
|
+
* generic table, because this model is not in the list) or \`unknown-model\`.
|
|
11422
|
+
*
|
|
11423
|
+
* It is worth being precise about what this is and is not, because the page
|
|
11424
|
+
* already has a measured/estimated distinction and it is easy to conflate the
|
|
11425
|
+
* two. \`settledTier.pricing\` is about where the PRICE came from, not where
|
|
11426
|
+
* the TOKEN COUNTS came from. The orchestrator reads the counts off a real
|
|
11427
|
+
* session for this event, so the usage here is measured; what varies is
|
|
11428
|
+
* whether the number was multiplied by this model's real price or by a
|
|
11429
|
+
* stand-in. So the honest label is "priced by", never "estimated" — calling a
|
|
11430
|
+
* fallback-priced figure "estimated" would be a different and wrong claim,
|
|
11431
|
+
* and a \`$0.42\` off a generic table wearing a real price's clothes is exactly
|
|
11432
|
+
* the confusion the Accounting panel and the unbilled bound exist to prevent
|
|
11433
|
+
* on the other half of the page.
|
|
11434
|
+
*
|
|
11435
|
+
* \`threshold\` is the prompt size at which the tier table switches, and
|
|
11436
|
+
* \`promptSizeAtSettlement\` is the size that selected the tier. Both are
|
|
11437
|
+
* printed so a reader can see WHICH tier the amount was priced at rather than
|
|
11438
|
+
* trusting that it was a sensible one. A null \`threshold\` is a real value and
|
|
11439
|
+
* not a missing one: it is the base tier, which has no threshold to switch
|
|
11440
|
+
* at, so it is named rather than reported as unreported.
|
|
11441
|
+
*
|
|
11442
|
+
* When \`settledTier\` itself is absent the line says so. The field is on the
|
|
11443
|
+
* event, so its absence is a fact about this line, and omitting the clause
|
|
11444
|
+
* would let a reader assume every line carries a price source when some do
|
|
11445
|
+
* not.
|
|
11446
|
+
*/
|
|
11447
|
+
function settledTierText(d) {
|
|
11448
|
+
var tier = asObject(d.settledTier);
|
|
11449
|
+
if (tier === null) return ' · price source NOT reported';
|
|
11450
|
+
var threshold = num(tier.threshold);
|
|
11451
|
+
var size = num(tier.promptSizeAtSettlement);
|
|
11452
|
+
return ' · priced by ' + pricingSourceText(text(tier.pricing))
|
|
11453
|
+
+ ' at ' + (threshold === null ? 'the base tier' : 'the ' + fmtInt(threshold) + '-token tier')
|
|
11454
|
+
+ ' (' + (size === null ? 'prompt size at settlement unreported'
|
|
11455
|
+
: fmtInt(size) + ' prompt tokens at settlement') + ')';
|
|
11456
|
+
}
|
|
11457
|
+
|
|
11458
|
+
/**
|
|
11459
|
+
* Plain words for a \`PricingSource\`.
|
|
11460
|
+
*
|
|
11461
|
+
* The server's own token is kept inside the string for every branch, so a
|
|
11462
|
+
* source this build has never heard of is reported as unrecognised AND quoted
|
|
11463
|
+
* rather than being quietly described as something it is not. That is the
|
|
11464
|
+
* same rule the rest of the page follows for a value it does not recognise.
|
|
11465
|
+
*/
|
|
11466
|
+
function pricingSourceText(source) {
|
|
11467
|
+
if (source === 'model-costs') return "the model's published price list (" + source + ')';
|
|
11468
|
+
if (source === 'fallback-table') {
|
|
11469
|
+
return 'a FALLBACK price table, because this model is not in it (' + source + ')';
|
|
11470
|
+
}
|
|
11471
|
+
if (source === 'unknown-model') return 'an UNKNOWN-MODEL fallback price (' + source + ')';
|
|
11472
|
+
return 'an unrecognised price source (' + (source === null ? 'unreported' : source) + ')';
|
|
11473
|
+
}
|
|
11474
|
+
|
|
11306
11475
|
function costDeltaText(d) {
|
|
11307
11476
|
var session = text(d.sessionID);
|
|
11308
11477
|
var reason = text(d.reason);
|
|
11309
11478
|
var delta = num(d.deltaCost);
|
|
11479
|
+
var agent = text(d.agentId);
|
|
11480
|
+
var model = text(d.model);
|
|
11481
|
+
var tokens = num(d.deltaTokens);
|
|
11482
|
+
// \`nodeId\` is deliberately NOT read: every emit site sets it to the same
|
|
11483
|
+
// value as \`taskId\` (\`nodeId: ledger.taskId\`), so it is an alias rather
|
|
11484
|
+
// than a second fact, and printing both would put two ids for one task on
|
|
11485
|
+
// the line whenever they ever disagreed.
|
|
11310
11486
|
var head = 'Session ' + (session === null ? 'unreported' : session)
|
|
11311
|
-
+ ' ('
|
|
11487
|
+
+ (agent === null ? ' (agent unreported)' : ' on ' + agent)
|
|
11488
|
+
+ ' · ' + taskLabel(d)
|
|
11489
|
+
+ ' · ' + (model === null ? 'model unreported' : model);
|
|
11490
|
+
|
|
11491
|
+
// A dollar amount with no token count says nothing about magnitude — $0.25
|
|
11492
|
+
// over three tokens and $0.25 over three million are the same string. The
|
|
11493
|
+
// count is on the event and it is the only thing on this line that says how
|
|
11494
|
+
// big the increment was.
|
|
11495
|
+
var amount = ' · ' + (tokens === null ? 'token delta unreported' : fmtInt(tokens) + ' tok');
|
|
11496
|
+
|
|
11497
|
+
// A settlement can also have MOVED money after the fact. \`recordsAdjusted\`
|
|
11498
|
+
// names which of the per-agent, per-task, history and performance records
|
|
11499
|
+
// the increment was written into. That is worth saying on this page
|
|
11500
|
+
// specifically, because the agent cost bars and the Spend card are built
|
|
11501
|
+
// from exactly those records — so a line that rewrote them is the reason a
|
|
11502
|
+
// total can move without a bill appearing, and saying nothing about it
|
|
11503
|
+
// leaves the reader to conclude the numbers were wrong.
|
|
11504
|
+
var corrections = '';
|
|
11505
|
+
var adjusted = asObject(d.recordsAdjusted);
|
|
11506
|
+
if (adjusted !== null) {
|
|
11507
|
+
var moved = ['history', 'performance', 'node', 'agent'].filter(function(k) {
|
|
11508
|
+
return adjusted[k] === true;
|
|
11509
|
+
});
|
|
11510
|
+
corrections = moved.length === 0
|
|
11511
|
+
? ' · no records adjusted'
|
|
11512
|
+
: ' · corrected the ' + moved.join(', ') + ' record' + (moved.length === 1 ? '' : 's');
|
|
11513
|
+
}
|
|
11514
|
+
// A settlement that threw still bills. The line has to say the charge was
|
|
11515
|
+
// made and the bookkeeping failed, because a dollar figure printed with no
|
|
11516
|
+
// mention of the error beside it reads as a clean one.
|
|
11517
|
+
var fault = text(d.error);
|
|
11518
|
+
if (fault !== null) corrections += ' · SETTLEMENT ERROR: ' + fault;
|
|
11519
|
+
|
|
11312
11520
|
if (reason === 'abandoned') {
|
|
11313
11521
|
var uncollected = asObject(d.uncollected);
|
|
11314
11522
|
var lower = uncollected === null ? null : num(uncollected.observedUncollected);
|
|
11315
11523
|
return head + ' ABANDONED — stopped collecting while still running; at least '
|
|
11316
|
-
+ fmt$(lower) + ' unbilled (lower bound)';
|
|
11524
|
+
+ fmt$(lower) + ' unbilled (lower bound)' + amount + corrections + settledTierText(d);
|
|
11317
11525
|
}
|
|
11318
11526
|
if (reason === 'shutdown') {
|
|
11319
|
-
return head + ' charged at teardown, settlement unverified: ' + fmt$(delta)
|
|
11527
|
+
return head + ' charged at teardown, settlement unverified: ' + fmt$(delta)
|
|
11528
|
+
+ amount + corrections + settledTierText(d);
|
|
11320
11529
|
}
|
|
11321
11530
|
if (reason === 'session-idle') {
|
|
11322
11531
|
return head + ' went idle; settled ' + fmt$(delta) + ' (session total '
|
|
11323
|
-
+ fmt$(d.sessionTotalCost) + ')';
|
|
11532
|
+
+ fmt$(d.sessionTotalCost) + ')' + amount + corrections + settledTierText(d);
|
|
11324
11533
|
}
|
|
11325
11534
|
return head + ' ' + (reason === null ? 'reason unreported' : reason) + ': '
|
|
11326
|
-
+ fmt$(delta) + ' (session total ' + fmt$(d.sessionTotalCost) + ')'
|
|
11535
|
+
+ fmt$(delta) + ' (session total ' + fmt$(d.sessionTotalCost) + ')'
|
|
11536
|
+
+ amount + corrections + settledTierText(d);
|
|
11327
11537
|
}
|
|
11328
11538
|
|
|
11329
11539
|
// ── Render State ──
|
|
@@ -11339,6 +11549,28 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11339
11549
|
var totalSpent = num(state.totalSpent);
|
|
11340
11550
|
var budgetRemaining = num(state.budgetRemaining);
|
|
11341
11551
|
|
|
11552
|
+
// Every DERIVED list is declared here, with the other derived values, and
|
|
11553
|
+
// not beside the line that consumes it. \`var\` hoists the declaration but not
|
|
11554
|
+
// the assignment, so a derived list written just below its first use is
|
|
11555
|
+
// \`undefined\` at the moment it is read — which is a \`TypeError\` on the first
|
|
11556
|
+
// property access, thrown on every state push and every auto-refresh tick,
|
|
11557
|
+
// and therefore a page that renders nothing at all rather than a wrong
|
|
11558
|
+
// number. That is exactly how the \`liveAgents\` count below used to be
|
|
11559
|
+
// written, and it is invisible to a grep: nothing type-checks this file, so
|
|
11560
|
+
// \`test/dashboard-page-execution.test.ts\` runs the page instead of reading it.
|
|
11561
|
+
var liveAgents = agents.filter(function(a) { return asObject(a) !== null; });
|
|
11562
|
+
var activeCount = liveAgents.filter(function(a) { return a.status === 'working'; }).length;
|
|
11563
|
+
var liveTasks = tasks.filter(function(t) { return asObject(t) !== null; });
|
|
11564
|
+
var runningTasks = liveTasks.filter(function(t) {
|
|
11565
|
+
return t.status === 'running' || t.status === 'queued';
|
|
11566
|
+
}).length;
|
|
11567
|
+
var completedTasks = liveTasks.filter(function(t) { return t.status === 'completed'; }).length;
|
|
11568
|
+
var failedTasks = liveTasks.filter(function(t) { return t.status === 'failed'; }).length;
|
|
11569
|
+
// Same derivation the Sessions section uses for its header and its orphan
|
|
11570
|
+
// banner, so the overview card and the section cannot print two different
|
|
11571
|
+
// answers to "how many sessions, and is any of them leaking".
|
|
11572
|
+
var sessionTally = sessionCensus(sessions);
|
|
11573
|
+
|
|
11342
11574
|
renderLifecycle(state);
|
|
11343
11575
|
|
|
11344
11576
|
// ── Overview Stats ──
|
|
@@ -11347,8 +11579,6 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11347
11579
|
// so a total that included entries the page cannot render would contradict
|
|
11348
11580
|
// the caveat printed directly beneath it.
|
|
11349
11581
|
$statAgents.textContent = String(liveAgents.length);
|
|
11350
|
-
var liveAgents = agents.filter(function(a) { return asObject(a) !== null; });
|
|
11351
|
-
var activeCount = liveAgents.filter(function(a) { return a.status === 'working'; }).length;
|
|
11352
11582
|
// Both figures from \`liveAgents\`, not one from \`agents\`: the count beside
|
|
11353
11583
|
// the total has to be arithmetic the reader can do in their head, and
|
|
11354
11584
|
// \`agents.length - activeCount\` mixing the two lists made the sub-line
|
|
@@ -11364,16 +11594,31 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11364
11594
|
$statAgentsSub.textContent = activeCount + ' working · '
|
|
11365
11595
|
+ (liveAgents.length - activeCount) + ' not working (live agents only)';
|
|
11366
11596
|
|
|
11367
|
-
var liveTasks = tasks.filter(function(t) { return asObject(t) !== null; });
|
|
11368
|
-
var runningTasks = liveTasks.filter(function(t) {
|
|
11369
|
-
return t.status === 'running' || t.status === 'queued';
|
|
11370
|
-
}).length;
|
|
11371
|
-
var completedTasks = liveTasks.filter(function(t) { return t.status === 'completed'; }).length;
|
|
11372
|
-
var failedTasks = liveTasks.filter(function(t) { return t.status === 'failed'; }).length;
|
|
11373
11597
|
$statTasks.textContent = String(tasks.length);
|
|
11374
11598
|
$statTasksSub.textContent = runningTasks + ' running/queued · ' + completedTasks + ' done'
|
|
11375
11599
|
+ (failedTasks > 0 ? ' · ' + failedTasks + ' failed' : '');
|
|
11376
11600
|
|
|
11601
|
+
// The Sessions card. It sat at \`—\` with "No sessions reported" printed
|
|
11602
|
+
// beneath it for the whole life of the card, while the Sessions SECTION
|
|
11603
|
+
// further down the same page rendered the same list perfectly well —
|
|
11604
|
+
// \`$statSessions\` and \`$statSessionsSub\` were resolved by \`getElementById\`
|
|
11605
|
+
// at load and never assigned by any render path.
|
|
11606
|
+
//
|
|
11607
|
+
// The reason it shipped is worth recording, because it is the same reason
|
|
11608
|
+
// the \`liveAgents\` use-before-declaration shipped: a grep cannot tell "reads
|
|
11609
|
+
// the field somewhere" from "fills this element". The static contract test's
|
|
11610
|
+
// required-field list contains \`sessions\`, the page does read
|
|
11611
|
+
// \`state.sessions\` (in \`renderSessions\`), so every source-level assertion
|
|
11612
|
+
// passed over a card that never received a value. The assertion that was
|
|
11613
|
+
// missing is a rendered-output one, and it now lives in
|
|
11614
|
+
// \`dashboard-page-execution.test.ts\`: the card is checked for the VALUE the
|
|
11615
|
+
// section shows, not for the absence of an exception.
|
|
11616
|
+
//
|
|
11617
|
+
// The headline is the raw array length — the same figure the section header
|
|
11618
|
+
// prints — so the two can never disagree about how many sessions exist.
|
|
11619
|
+
$statSessions.textContent = String(sessionTally.total);
|
|
11620
|
+
$statSessionsSub.textContent = sessionsSub(sessionTally);
|
|
11621
|
+
|
|
11377
11622
|
$statSpent.textContent = fmt$(totalSpent);
|
|
11378
11623
|
$statSpentSub.textContent = maxTotalCost === null
|
|
11379
11624
|
? 'Budget ceiling not reported'
|
|
@@ -11471,6 +11716,94 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11471
11716
|
return s.owned !== true || s.agentId === null;
|
|
11472
11717
|
}
|
|
11473
11718
|
|
|
11719
|
+
/**
|
|
11720
|
+
* The session figures, derived ONCE per render and read by two surfaces.
|
|
11721
|
+
*
|
|
11722
|
+
* \`$statSessions\` (the overview card) and \`renderOrphanBanner\` (the section
|
|
11723
|
+
* alarm) answer the same question — is any session spending with nobody
|
|
11724
|
+
* watching it? — and they are printed on the same page. If each counted its
|
|
11725
|
+
* own, a future edit to one would put "0 unowned" on the card above "3
|
|
11726
|
+
* running with no owning agent" in the banner, and both halves would still
|
|
11727
|
+
* look correct on their own. So they share a derivation.
|
|
11728
|
+
*
|
|
11729
|
+
* \`total\` is the RAW array length, deliberately, because that is exactly what
|
|
11730
|
+
* the Sessions section header prints. The card and the section must not be
|
|
11731
|
+
* able to disagree about how many sessions exist, and the entries that are
|
|
11732
|
+
* not objects are therefore counted as \`unreported\` rather than quietly
|
|
11733
|
+
* dropped from a partition — see \`sessionsSub\`.
|
|
11734
|
+
*/
|
|
11735
|
+
function sessionCensus(sessions) {
|
|
11736
|
+
var all = asArray(sessions);
|
|
11737
|
+
var running = 0;
|
|
11738
|
+
var abandoned = 0;
|
|
11739
|
+
var unowned = 0;
|
|
11740
|
+
var unownedRunning = 0;
|
|
11741
|
+
var unownedRunningIds = [];
|
|
11742
|
+
var live = 0;
|
|
11743
|
+
for (var i = 0; i < all.length; i++) {
|
|
11744
|
+
var s = asObject(all[i]);
|
|
11745
|
+
if (s === null) continue;
|
|
11746
|
+
live++;
|
|
11747
|
+
if (s.state === 'running') running++;
|
|
11748
|
+
if (s.state === 'abandoned') abandoned++;
|
|
11749
|
+
if (sessionIsOrphan(s)) {
|
|
11750
|
+
unowned++;
|
|
11751
|
+
// The one combination the whole view exists for: still generating, and
|
|
11752
|
+
// the agent that owned it has been deleted, so its spend is collected
|
|
11753
|
+
// by nobody. Counted apart from \`unowned\` because "unowned" alone
|
|
11754
|
+
// reads as a bookkeeping state and this is a leak.
|
|
11755
|
+
if (s.state === 'running') {
|
|
11756
|
+
unownedRunning++;
|
|
11757
|
+
unownedRunningIds.push(text(s.id) || 'unreported');
|
|
11758
|
+
}
|
|
11759
|
+
}
|
|
11760
|
+
}
|
|
11761
|
+
return {
|
|
11762
|
+
total: all.length,
|
|
11763
|
+
live: live,
|
|
11764
|
+
unreported: all.length - live,
|
|
11765
|
+
running: running,
|
|
11766
|
+
abandoned: abandoned,
|
|
11767
|
+
unowned: unowned,
|
|
11768
|
+
unownedRunning: unownedRunning,
|
|
11769
|
+
unownedRunningIds: unownedRunningIds
|
|
11770
|
+
};
|
|
11771
|
+
}
|
|
11772
|
+
|
|
11773
|
+
/**
|
|
11774
|
+
* The overview card's sub-line for Sessions.
|
|
11775
|
+
*
|
|
11776
|
+
* Two constraints, and the second is the one that is easy to get wrong:
|
|
11777
|
+
*
|
|
11778
|
+
* 1. Every clause must be arithmetic the reader can check against the
|
|
11779
|
+
* headline above it. \`running\` and \`abandoned\` are disjoint slices of
|
|
11780
|
+
* the live sessions, and the entries that are not objects are stated
|
|
11781
|
+
* rather than dropped, so the figure above is always reachable from the
|
|
11782
|
+
* clauses beneath it. The \`unowned\` count is the ONE overlapping clause
|
|
11783
|
+
* and says so in its own wording, because an abandoned session is also
|
|
11784
|
+
* unowned.
|
|
11785
|
+
* 2. It must not imply a healthy fleet. \`owned: false\` on a RUNNING session
|
|
11786
|
+
* is not a session that finished — it is a session still generating with
|
|
11787
|
+
* the agent that owned it deleted. Rolling those into a bare "running"
|
|
11788
|
+
* total is exactly what would make a leaking fleet read as healthy, so
|
|
11789
|
+
* the still-running unowned count is a clause of its own and is placed
|
|
11790
|
+
* after the terminal-state counts rather than buried in a suffix.
|
|
11791
|
+
*/
|
|
11792
|
+
function sessionsSub(census) {
|
|
11793
|
+
if (census.total === 0) return 'No sessions reported';
|
|
11794
|
+
var parts = [census.running + ' running', census.abandoned + ' abandoned'];
|
|
11795
|
+
if (census.unowned > 0) {
|
|
11796
|
+
parts.push(census.unowned + ' with no owning agent'
|
|
11797
|
+
+ (census.unownedRunning > 0
|
|
11798
|
+
? ' (' + census.unownedRunning + ' still running)'
|
|
11799
|
+
: ' (all finished)'));
|
|
11800
|
+
}
|
|
11801
|
+
if (census.unreported > 0) {
|
|
11802
|
+
parts.push(census.unreported + ' unreported');
|
|
11803
|
+
}
|
|
11804
|
+
return parts.join(' · ');
|
|
11805
|
+
}
|
|
11806
|
+
|
|
11474
11807
|
function sessionPassesFilter(s) {
|
|
11475
11808
|
if (s === null || s === undefined) return false;
|
|
11476
11809
|
if (sessionFilter === 'all') return true;
|
|
@@ -11494,8 +11827,9 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11494
11827
|
|
|
11495
11828
|
function renderSessions(sessions) {
|
|
11496
11829
|
sessions = asArray(sessions);
|
|
11497
|
-
|
|
11498
|
-
|
|
11830
|
+
var census = sessionCensus(sessions);
|
|
11831
|
+
$sessionCount.textContent = String(census.total);
|
|
11832
|
+
renderOrphanBanner(census);
|
|
11499
11833
|
|
|
11500
11834
|
if (sessions.length === 0) {
|
|
11501
11835
|
$sessionsContainer.innerHTML = '<div class="empty-message">'
|
|
@@ -11519,7 +11853,8 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11519
11853
|
|
|
11520
11854
|
var html = '<table class="session-table"><thead><tr>'
|
|
11521
11855
|
+ '<th>Session</th><th>Owner</th><th>Task</th><th>Role</th><th>Model</th>'
|
|
11522
|
-
+ '<th>State</th><th class="num">
|
|
11856
|
+
+ '<th>State</th><th class="num">Age</th><th class="num">Last read tokens</th>'
|
|
11857
|
+
+ '<th class="num">Unbilled (≥)</th>'
|
|
11523
11858
|
+ '</tr></thead><tbody>';
|
|
11524
11859
|
for (var i = 0; i < shown.length; i++) {
|
|
11525
11860
|
html += sessionRow(asObject(shown[i]));
|
|
@@ -11568,6 +11903,7 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11568
11903
|
+ '<td class="mono">' + escHtml(text(s.model) || '—') + '</td>'
|
|
11569
11904
|
+ '<td><span class="session-state ' + escHtml(text(s.state) || 'unknown') + '">'
|
|
11570
11905
|
+ escHtml(text(s.state) || 'unreported') + '</span></td>'
|
|
11906
|
+
+ sessionAgeCell(s)
|
|
11571
11907
|
+ '<td class="num" title="The last token count actually observed for this session. Not a live reading.">'
|
|
11572
11908
|
+ (unread ? '—' : fmtInt(tokens)) + '</td>'
|
|
11573
11909
|
+ '<td class="num" title="Lower bound on what this session spent unbilled. A lower bound, not a total; excluded from Spend.">'
|
|
@@ -11575,38 +11911,82 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11575
11911
|
+ '</tr>';
|
|
11576
11912
|
}
|
|
11577
11913
|
|
|
11914
|
+
/**
|
|
11915
|
+
* How long a session has existed, as its own cell.
|
|
11916
|
+
*
|
|
11917
|
+
* \`SessionStateView.spawnedAt\` is documented as "from the owning agent. null
|
|
11918
|
+
* for an orphan" — and an unowned session is exactly the row this view exists
|
|
11919
|
+
* for. So the cell reads \`—\` on precisely the sessions a user most needs
|
|
11920
|
+
* dated. That is worth SHOWING rather than omitting: an undated leak is
|
|
11921
|
+
* visible, and the banner above the table says why it is undated, so the
|
|
11922
|
+
* absence cannot pass for "started a moment ago". Omitting the column would
|
|
11923
|
+
* have left that gap invisible, which is how it survived in the first place.
|
|
11924
|
+
*
|
|
11925
|
+
* The agent grid already prints its own \`spawnedAt\`, so leaving this one out
|
|
11926
|
+
* also made the two sections of the same page inconsistent about the same
|
|
11927
|
+
* field of the same object.
|
|
11928
|
+
*
|
|
11929
|
+
* The cell shows ELAPSED time rather than a wall clock because what makes a
|
|
11930
|
+
* session's age matter is how long it has outlived its agent — a five-minute
|
|
11931
|
+
* leak and a five-hour one are different incidents, and only the elapsed
|
|
11932
|
+
* figure answers that. The raw ISO is on the \`title\`, which is where an
|
|
11933
|
+
* unambiguous value belongs and which is also what the execution test reads,
|
|
11934
|
+
* since an elapsed duration is not a stable string to assert on.
|
|
11935
|
+
*/
|
|
11936
|
+
function sessionAgeCell(s) {
|
|
11937
|
+
var iso = text(s.spawnedAt);
|
|
11938
|
+
if (iso === null) {
|
|
11939
|
+
return '<td class="num" title="SessionStateView.spawnedAt was not reported for this session.'
|
|
11940
|
+
+ ' It comes from the owning agent, so it is null for an unowned session.">—</td>';
|
|
11941
|
+
}
|
|
11942
|
+
var at = new Date(iso);
|
|
11943
|
+
if (isNaN(at.getTime())) {
|
|
11944
|
+
return '<td class="num" title="SessionStateView.spawnedAt is not a readable timestamp: '
|
|
11945
|
+
+ escHtml(iso) + '">—</td>';
|
|
11946
|
+
}
|
|
11947
|
+
// A snapshot whose clock runs ahead of this one must not produce a negative
|
|
11948
|
+
// age, which would read as a session from the future.
|
|
11949
|
+
var age = Date.now() - at.getTime();
|
|
11950
|
+
if (age < 0) age = 0;
|
|
11951
|
+
return '<td class="num" title="state.sessions[].spawnedAt ' + escHtml(iso) + '">'
|
|
11952
|
+
+ escHtml(fmtElapsed(age)) + '</td>';
|
|
11953
|
+
}
|
|
11954
|
+
|
|
11578
11955
|
/**
|
|
11579
11956
|
* The section-level alarm. It fires on the specific combination that is the
|
|
11580
11957
|
* point of the whole view: a session that is still running, has no owning
|
|
11581
11958
|
* agent, and is therefore spending into nothing.
|
|
11959
|
+
*
|
|
11960
|
+
* Takes the shared \`sessionCensus\` rather than the raw list, so this banner
|
|
11961
|
+
* and the overview card are counting from one derivation and cannot drift
|
|
11962
|
+
* apart.
|
|
11582
11963
|
*/
|
|
11583
|
-
function renderOrphanBanner(
|
|
11584
|
-
var leaking =
|
|
11585
|
-
var abandoned =
|
|
11586
|
-
for (var i = 0; i < sessions.length; i++) {
|
|
11587
|
-
var s = asObject(sessions[i]);
|
|
11588
|
-
if (s === null) continue;
|
|
11589
|
-
if (s.state === 'abandoned') abandoned++;
|
|
11590
|
-
if (sessionIsOrphan(s) && s.state === 'running') leaking.push(s);
|
|
11591
|
-
}
|
|
11964
|
+
function renderOrphanBanner(census) {
|
|
11965
|
+
var leaking = census.unownedRunning;
|
|
11966
|
+
var abandoned = census.abandoned;
|
|
11592
11967
|
|
|
11593
|
-
if (leaking
|
|
11968
|
+
if (leaking === 0 && abandoned === 0) {
|
|
11594
11969
|
$orphanBanner.innerHTML = '';
|
|
11595
11970
|
return;
|
|
11596
11971
|
}
|
|
11597
11972
|
|
|
11598
11973
|
var parts = [];
|
|
11599
|
-
if (leaking
|
|
11600
|
-
var ids = leaking.map(function(s) { return text(s.id) || 'unreported'; });
|
|
11974
|
+
if (leaking > 0) {
|
|
11601
11975
|
parts.push(
|
|
11602
11976
|
'<div class="orphan-banner">'
|
|
11603
11977
|
+ '<span class="icon">🔓</span><div>'
|
|
11604
|
-
+ '<div class="title">' + leaking
|
|
11978
|
+
+ '<div class="title">' + leaking + ' running session' + (leaking === 1 ? '' : 's')
|
|
11605
11979
|
+ ' with no owning agent</div>'
|
|
11606
11980
|
+ '<div class="body">These sessions are still generating after the agent that owned '
|
|
11607
11981
|
+ 'them was terminated. Whatever they spend is not being collected, and the server '
|
|
11608
|
-
+ 'keeps them in view so you can terminate them
|
|
11609
|
-
+ '<
|
|
11982
|
+
+ 'keeps them in view so you can terminate them. '
|
|
11983
|
+
+ '<strong>The Age column reads — for these rows:</strong> a session with no owning '
|
|
11984
|
+
+ 'agent has no owning agent to carry a spawn time, so <code>SessionStateView.spawnedAt</code> '
|
|
11985
|
+
+ 'is null for exactly the sessions whose age you most want. How long these have been '
|
|
11986
|
+
+ 'leaking is not on this snapshot.'
|
|
11987
|
+
+ '</div>'
|
|
11988
|
+
+ '<div class="detail">Session ids: <span class="mono">'
|
|
11989
|
+
+ escHtml(census.unownedRunningIds.join(', ')) + '</span></div>'
|
|
11610
11990
|
+ '</div></div>');
|
|
11611
11991
|
}
|
|
11612
11992
|
if (abandoned > 0) {
|
|
@@ -11665,7 +12045,8 @@ var dashboard_default = `<!DOCTYPE html>
|
|
|
11665
12045
|
html += '<div class="agent-meta" title="Resolved model">📦 ' + escHtml(text(a.model) || 'model unreported') + '</div>';
|
|
11666
12046
|
html += '<div class="agent-meta"><span>🔗</span><span class="mono">'
|
|
11667
12047
|
+ (sessionId === null ? 'no session id reported' : escHtml(sessionId)) + '</span></div>';
|
|
11668
|
-
html += '<div class="agent-meta" title="state.agents[].spawnedAt
|
|
12048
|
+
html += '<div class="agent-meta" title="state.agents[].spawnedAt '
|
|
12049
|
+
+ escHtml(text(a.spawnedAt) || 'not reported') + '">🕑 spawned '
|
|
11669
12050
|
+ escHtml(fmtDateTime(a.spawnedAt)) + '</div>';
|
|
11670
12051
|
|
|
11671
12052
|
html += '<div class="agent-metrics">';
|
|
@@ -12556,6 +12937,7 @@ function stateMessage(orchestrator) {
|
|
|
12556
12937
|
|
|
12557
12938
|
class DashboardModule {
|
|
12558
12939
|
server = null;
|
|
12940
|
+
address = null;
|
|
12559
12941
|
orchestrator;
|
|
12560
12942
|
constructor(orchestrator) {
|
|
12561
12943
|
this.orchestrator = orchestrator;
|
|
@@ -12624,9 +13006,11 @@ class DashboardModule {
|
|
|
12624
13006
|
}
|
|
12625
13007
|
}
|
|
12626
13008
|
});
|
|
13009
|
+
this.address = { host, port, url: `http://${host}:${port}` };
|
|
12627
13010
|
console.log(`[nexus] Dashboard server running at http://${host}:${port}`);
|
|
12628
13011
|
} catch (err) {
|
|
12629
13012
|
this.server = null;
|
|
13013
|
+
this.address = null;
|
|
12630
13014
|
const detail = errorMessage(err);
|
|
12631
13015
|
console.error(`[nexus] Failed to start dashboard on ${host}:${port}: ${detail}`);
|
|
12632
13016
|
throw new Error(`Dashboard failed to start on port ${port}: ${detail}`);
|
|
@@ -12637,6 +13021,10 @@ class DashboardModule {
|
|
|
12637
13021
|
this.server.stop();
|
|
12638
13022
|
this.server = null;
|
|
12639
13023
|
}
|
|
13024
|
+
this.address = null;
|
|
13025
|
+
}
|
|
13026
|
+
getAddress() {
|
|
13027
|
+
return this.server ? this.address : null;
|
|
12640
13028
|
}
|
|
12641
13029
|
getClientCount() {
|
|
12642
13030
|
return this.orchestrator.broadcaster?.getClientCount() ?? 0;
|
|
@@ -12645,6 +13033,51 @@ class DashboardModule {
|
|
|
12645
13033
|
return this.server !== null;
|
|
12646
13034
|
}
|
|
12647
13035
|
}
|
|
13036
|
+
function parseDashboardTarget(input, fallback) {
|
|
13037
|
+
const parts = (input ?? "").trim().split(/\s+/).filter(Boolean);
|
|
13038
|
+
let port = fallback.port;
|
|
13039
|
+
let host = fallback.host;
|
|
13040
|
+
if (parts.length > 0) {
|
|
13041
|
+
const parsed = Number(parts[0]);
|
|
13042
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
|
13043
|
+
return { error: `"${parts[0]}" is not a port number. Give a port between 1 and 65535.` };
|
|
13044
|
+
}
|
|
13045
|
+
port = parsed;
|
|
13046
|
+
}
|
|
13047
|
+
if (parts.length > 1) {
|
|
13048
|
+
host = parts[1];
|
|
13049
|
+
}
|
|
13050
|
+
return { target: { port, host } };
|
|
13051
|
+
}
|
|
13052
|
+
function startDashboardServer(orchestrator, port, host) {
|
|
13053
|
+
const running = orchestrator.dashboard?.getAddress() ?? null;
|
|
13054
|
+
if (running) {
|
|
13055
|
+
return { kind: "already-running", ...running };
|
|
13056
|
+
}
|
|
13057
|
+
try {
|
|
13058
|
+
orchestrator.startDashboard(port, host);
|
|
13059
|
+
} catch (error) {
|
|
13060
|
+
return { kind: "refused", detail: error instanceof Error ? error.message : String(error) };
|
|
13061
|
+
}
|
|
13062
|
+
const address = orchestrator.dashboard?.getAddress() ?? null;
|
|
13063
|
+
if (!address) {
|
|
13064
|
+
return { kind: "refused", detail: "the dashboard reported a start but is not serving any address" };
|
|
13065
|
+
}
|
|
13066
|
+
return { kind: "started", ...address };
|
|
13067
|
+
}
|
|
13068
|
+
function describeDashboardStart(outcome) {
|
|
13069
|
+
if (outcome.kind === "refused") {
|
|
13070
|
+
return `Dashboard NOT started: ${outcome.detail}
|
|
13071
|
+
` + `No server is listening and no browser was opened. ` + `If the port is in use, either stop whatever holds it or pass a different \`port\`. ` + `Call again once the port is free — the dashboard does not retry on its own.`;
|
|
13072
|
+
}
|
|
13073
|
+
if (outcome.kind === "already-running") {
|
|
13074
|
+
return `A dashboard is already running at ${outcome.url} — nothing was started a second time.
|
|
13075
|
+
` + `That server keeps serving until it is stopped, so open that exact URL to use it. ` + `Call \`nexus.dashboard.stop\` first if you want it on a different port.`;
|
|
13076
|
+
}
|
|
13077
|
+
return `Dashboard started at ${outcome.url}
|
|
13078
|
+
` + `Open that exact URL in a browser. The page connects to the same host and port for its live ` + `state over WebSocket (ws://${outcome.host}:${outcome.port}/ws/events), so it works only while this ` + `server runs. The TUI's \`/nexus-dashboard\` command opens the browser once this tool has succeeded.
|
|
13079
|
+
` + `Call \`nexus.dashboard.stop\` to shut it down.`;
|
|
13080
|
+
}
|
|
12648
13081
|
|
|
12649
13082
|
// src/dag.ts
|
|
12650
13083
|
function detectCycles(nodes) {
|
|
@@ -13058,62 +13491,205 @@ class MessageRouter {
|
|
|
13058
13491
|
|
|
13059
13492
|
// src/notifications.ts
|
|
13060
13493
|
import { spawn } from "node:child_process";
|
|
13494
|
+
var MACOS_SCRIPT_WITH_SOUND = [
|
|
13495
|
+
"on run argv",
|
|
13496
|
+
'display notification (item 2 of argv) with title (item 1 of argv) sound name "Glass"',
|
|
13497
|
+
"end run"
|
|
13498
|
+
].join(`
|
|
13499
|
+
`);
|
|
13500
|
+
var MACOS_SCRIPT_SILENT = [
|
|
13501
|
+
"on run argv",
|
|
13502
|
+
"display notification (item 2 of argv) with title (item 1 of argv)",
|
|
13503
|
+
"end run"
|
|
13504
|
+
].join(`
|
|
13505
|
+
`);
|
|
13506
|
+
var WINDOWS_SCRIPT = [
|
|
13507
|
+
"$ErrorActionPreference = 'Stop'",
|
|
13508
|
+
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null",
|
|
13509
|
+
"$template = [Windows.UI.Notifications.ToastTemplateType]::ToastText02",
|
|
13510
|
+
"$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent($template)",
|
|
13511
|
+
"$texts = $xml.GetElementsByTagName('text')",
|
|
13512
|
+
"$texts.Item(0).AppendChild($xml.CreateTextNode($env:NEXUS_NOTIFY_TITLE)) | Out-Null",
|
|
13513
|
+
"$texts.Item(1).AppendChild($xml.CreateTextNode($env:NEXUS_NOTIFY_BODY)) | Out-Null",
|
|
13514
|
+
"$toast = New-Object Windows.UI.Notifications.ToastNotification $xml",
|
|
13515
|
+
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Nexus').Show($toast)"
|
|
13516
|
+
].join(`
|
|
13517
|
+
`);
|
|
13518
|
+
var WINDOWS_NOTIFY_ENV = {
|
|
13519
|
+
title: "NEXUS_NOTIFY_TITLE",
|
|
13520
|
+
body: "NEXUS_NOTIFY_BODY"
|
|
13521
|
+
};
|
|
13522
|
+
var NOTIFY_TIMEOUT_MS = 3000;
|
|
13523
|
+
function macScriptFor(options) {
|
|
13524
|
+
return options.silent || options.sound === false ? MACOS_SCRIPT_SILENT : MACOS_SCRIPT_WITH_SOUND;
|
|
13525
|
+
}
|
|
13526
|
+
function buildMacOSInvocation(options) {
|
|
13527
|
+
return {
|
|
13528
|
+
command: "osascript",
|
|
13529
|
+
args: ["-e", macScriptFor(options), "--", options.title, options.body]
|
|
13530
|
+
};
|
|
13531
|
+
}
|
|
13532
|
+
function buildLinuxInvocation(options) {
|
|
13533
|
+
return {
|
|
13534
|
+
command: "notify-send",
|
|
13535
|
+
args: [options.title, options.body]
|
|
13536
|
+
};
|
|
13537
|
+
}
|
|
13538
|
+
function buildWindowsInvocation(options) {
|
|
13539
|
+
return {
|
|
13540
|
+
command: "powershell",
|
|
13541
|
+
args: ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT],
|
|
13542
|
+
env: {
|
|
13543
|
+
...process.env,
|
|
13544
|
+
[WINDOWS_NOTIFY_ENV.title]: options.title,
|
|
13545
|
+
[WINDOWS_NOTIFY_ENV.body]: options.body
|
|
13546
|
+
}
|
|
13547
|
+
};
|
|
13548
|
+
}
|
|
13549
|
+
function buildInvocation(platform, options) {
|
|
13550
|
+
switch (platform) {
|
|
13551
|
+
case "darwin":
|
|
13552
|
+
return buildMacOSInvocation(options);
|
|
13553
|
+
case "linux":
|
|
13554
|
+
return buildLinuxInvocation(options);
|
|
13555
|
+
case "win32":
|
|
13556
|
+
return buildWindowsInvocation(options);
|
|
13557
|
+
default:
|
|
13558
|
+
return null;
|
|
13559
|
+
}
|
|
13560
|
+
}
|
|
13561
|
+
var boundSetTimeout = globalThis.setTimeout;
|
|
13562
|
+
var boundClearTimeout = globalThis.clearTimeout;
|
|
13563
|
+
var SYSTEM_TIMERS = {
|
|
13564
|
+
setTimeout: (handler, ms) => boundSetTimeout(handler, ms),
|
|
13565
|
+
clearTimeout: (handle) => boundClearTimeout(handle)
|
|
13566
|
+
};
|
|
13567
|
+
function runProcess(proc, label, timeoutMs = NOTIFY_TIMEOUT_MS, timers = SYSTEM_TIMERS) {
|
|
13568
|
+
return new Promise((resolve) => {
|
|
13569
|
+
let settled = false;
|
|
13570
|
+
let stderr = "";
|
|
13571
|
+
const finish = (outcome) => {
|
|
13572
|
+
if (settled)
|
|
13573
|
+
return;
|
|
13574
|
+
settled = true;
|
|
13575
|
+
timers.clearTimeout(timer);
|
|
13576
|
+
resolve(outcome);
|
|
13577
|
+
};
|
|
13578
|
+
const timer = timers.setTimeout(() => {
|
|
13579
|
+
if (!proc.killed) {
|
|
13580
|
+
try {
|
|
13581
|
+
proc.kill();
|
|
13582
|
+
} catch {}
|
|
13583
|
+
}
|
|
13584
|
+
finish({ ok: false, reason: `${label} timed out after ${timeoutMs}ms` });
|
|
13585
|
+
}, timeoutMs);
|
|
13586
|
+
proc.stderr?.on("data", (chunk) => {
|
|
13587
|
+
if (stderr.length < 500)
|
|
13588
|
+
stderr += String(chunk);
|
|
13589
|
+
});
|
|
13590
|
+
proc.on("error", (error) => {
|
|
13591
|
+
finish({ ok: false, reason: `${label} could not start: ${error.message}` });
|
|
13592
|
+
});
|
|
13593
|
+
proc.on("close", (code) => {
|
|
13594
|
+
if (code === 0) {
|
|
13595
|
+
finish({ ok: true, reason: null });
|
|
13596
|
+
return;
|
|
13597
|
+
}
|
|
13598
|
+
const detail = stderr.trim();
|
|
13599
|
+
finish({
|
|
13600
|
+
ok: false,
|
|
13601
|
+
reason: `${label} exited with code ${code}${detail ? `: ${detail}` : ""}`
|
|
13602
|
+
});
|
|
13603
|
+
});
|
|
13604
|
+
});
|
|
13605
|
+
}
|
|
13061
13606
|
|
|
13062
13607
|
class NotificationManager {
|
|
13063
13608
|
enabled;
|
|
13064
13609
|
platform;
|
|
13065
|
-
|
|
13610
|
+
spawnImpl;
|
|
13611
|
+
timeoutMs;
|
|
13612
|
+
timers;
|
|
13613
|
+
sent = 0;
|
|
13614
|
+
failed = 0;
|
|
13615
|
+
suppressed = 0;
|
|
13616
|
+
lastError = null;
|
|
13617
|
+
lastErrorAt = null;
|
|
13618
|
+
constructor(enabled = true, options = {}) {
|
|
13066
13619
|
this.enabled = enabled;
|
|
13067
13620
|
this.platform = process.platform;
|
|
13621
|
+
this.spawnImpl = options.spawnImpl ?? spawn;
|
|
13622
|
+
this.timeoutMs = options.timeoutMs ?? NOTIFY_TIMEOUT_MS;
|
|
13623
|
+
this.timers = options.timers ?? SYSTEM_TIMERS;
|
|
13068
13624
|
}
|
|
13069
13625
|
async notify(options) {
|
|
13070
|
-
if (!this.enabled)
|
|
13626
|
+
if (!this.enabled) {
|
|
13627
|
+
this.suppressed += 1;
|
|
13071
13628
|
return false;
|
|
13072
|
-
|
|
13073
|
-
|
|
13074
|
-
|
|
13075
|
-
|
|
13076
|
-
|
|
13077
|
-
|
|
13078
|
-
|
|
13079
|
-
|
|
13080
|
-
|
|
13629
|
+
}
|
|
13630
|
+
const outcome = await this.attempt(options);
|
|
13631
|
+
if (outcome.ok) {
|
|
13632
|
+
this.sent += 1;
|
|
13633
|
+
return true;
|
|
13634
|
+
}
|
|
13635
|
+
this.failed += 1;
|
|
13636
|
+
this.lastError = outcome.reason;
|
|
13637
|
+
this.lastErrorAt = new Date().toISOString();
|
|
13638
|
+
console.error(`[nexus] notification failed: ${outcome.reason}`);
|
|
13081
13639
|
return false;
|
|
13082
13640
|
}
|
|
13083
|
-
async
|
|
13084
|
-
|
|
13085
|
-
|
|
13086
|
-
|
|
13087
|
-
|
|
13088
|
-
|
|
13089
|
-
|
|
13090
|
-
|
|
13091
|
-
|
|
13092
|
-
|
|
13093
|
-
|
|
13094
|
-
|
|
13641
|
+
async test() {
|
|
13642
|
+
if (this.platform !== "darwin" && this.platform !== "linux" && this.platform !== "win32") {
|
|
13643
|
+
return {
|
|
13644
|
+
delivered: false,
|
|
13645
|
+
reason: `no notifier for platform "${this.platform}"`,
|
|
13646
|
+
enabled: this.enabled,
|
|
13647
|
+
platform: this.platform,
|
|
13648
|
+
stats: this.getStats()
|
|
13649
|
+
};
|
|
13650
|
+
}
|
|
13651
|
+
const probe = {
|
|
13652
|
+
title: "Nexus: notifications on",
|
|
13653
|
+
body: "If you can read this, OS notifications are working.",
|
|
13654
|
+
sound: true
|
|
13655
|
+
};
|
|
13656
|
+
const outcome = await this.attempt(probe);
|
|
13657
|
+
return {
|
|
13658
|
+
delivered: outcome.ok,
|
|
13659
|
+
reason: outcome.reason,
|
|
13660
|
+
enabled: this.enabled,
|
|
13661
|
+
platform: this.platform,
|
|
13662
|
+
stats: this.getStats()
|
|
13663
|
+
};
|
|
13095
13664
|
}
|
|
13096
|
-
async
|
|
13097
|
-
|
|
13098
|
-
|
|
13099
|
-
|
|
13100
|
-
|
|
13101
|
-
|
|
13102
|
-
|
|
13103
|
-
|
|
13104
|
-
|
|
13105
|
-
|
|
13665
|
+
async attempt(options) {
|
|
13666
|
+
const invocation = buildInvocation(this.platform, options);
|
|
13667
|
+
if (invocation === null) {
|
|
13668
|
+
return { ok: false, reason: `no notifier for platform "${this.platform}"` };
|
|
13669
|
+
}
|
|
13670
|
+
let proc;
|
|
13671
|
+
try {
|
|
13672
|
+
proc = this.spawnImpl(invocation.command, [...invocation.args], {
|
|
13673
|
+
env: invocation.env ? { ...invocation.env } : process.env
|
|
13674
|
+
});
|
|
13675
|
+
} catch (error) {
|
|
13676
|
+
return {
|
|
13677
|
+
ok: false,
|
|
13678
|
+
reason: `${invocation.command} could not start: ${error instanceof Error ? error.message : String(error)}`
|
|
13679
|
+
};
|
|
13680
|
+
}
|
|
13681
|
+
return runProcess(proc, invocation.command, this.timeoutMs, this.timers);
|
|
13106
13682
|
}
|
|
13107
|
-
|
|
13108
|
-
return
|
|
13109
|
-
|
|
13110
|
-
|
|
13111
|
-
|
|
13112
|
-
|
|
13113
|
-
|
|
13114
|
-
|
|
13115
|
-
|
|
13116
|
-
}
|
|
13683
|
+
getStats() {
|
|
13684
|
+
return {
|
|
13685
|
+
sent: this.sent,
|
|
13686
|
+
failed: this.failed,
|
|
13687
|
+
suppressed: this.suppressed,
|
|
13688
|
+
lastError: this.lastError,
|
|
13689
|
+
lastErrorAt: this.lastErrorAt,
|
|
13690
|
+
enabled: this.enabled,
|
|
13691
|
+
platform: this.platform
|
|
13692
|
+
};
|
|
13117
13693
|
}
|
|
13118
13694
|
setEnabled(enabled) {
|
|
13119
13695
|
this.enabled = enabled;
|
|
@@ -14013,12 +14589,19 @@ class NexusOrchestrator {
|
|
|
14013
14589
|
return this._healthMonitor;
|
|
14014
14590
|
}
|
|
14015
14591
|
notifications = null;
|
|
14592
|
+
sendNotification(options) {
|
|
14593
|
+
if (!this.notifications)
|
|
14594
|
+
return;
|
|
14595
|
+
this.notifications.notify(options).catch((error) => {
|
|
14596
|
+
console.error(`[nexus] notification rejected: ${error instanceof Error ? error.message : String(error)}`);
|
|
14597
|
+
});
|
|
14598
|
+
}
|
|
14016
14599
|
modelCosts = new Map;
|
|
14017
14600
|
lastDegradedSpawn = null;
|
|
14018
14601
|
constructor(config, messageStoreConfig, memoryStoreConfig) {
|
|
14019
14602
|
this.config = this.mergeConfig(config);
|
|
14020
14603
|
this.budget = this.config.budget;
|
|
14021
|
-
this.configManager = new NexusConfigManager(this.config.dashboard);
|
|
14604
|
+
this.configManager = new NexusConfigManager(this.config.dashboard, this.config.notifications);
|
|
14022
14605
|
this.moduleRegistry = new ModuleRegistry;
|
|
14023
14606
|
this.messageStore = new MessageStore(messageStoreConfig);
|
|
14024
14607
|
this.memoryStore = new PersistentMemoryStore(memoryStoreConfig);
|
|
@@ -14047,7 +14630,7 @@ class NexusOrchestrator {
|
|
|
14047
14630
|
this._healthMonitor = new HealthMonitor({
|
|
14048
14631
|
checkInterval: this.config.agents.healthCheckInterval
|
|
14049
14632
|
});
|
|
14050
|
-
this.notifications = new NotificationManager(
|
|
14633
|
+
this.notifications = new NotificationManager(this.configManager.getConfig().notifications.enabled);
|
|
14051
14634
|
const moduleCtx = {
|
|
14052
14635
|
orchestrator: this,
|
|
14053
14636
|
config: this.config,
|
|
@@ -14293,6 +14876,9 @@ class NexusOrchestrator {
|
|
|
14293
14876
|
port: 4747,
|
|
14294
14877
|
host: "127.0.0.1"
|
|
14295
14878
|
},
|
|
14879
|
+
notifications: {
|
|
14880
|
+
enabled: true
|
|
14881
|
+
},
|
|
14296
14882
|
security: {
|
|
14297
14883
|
sastEnabled: true,
|
|
14298
14884
|
secretsScanning: true,
|
|
@@ -14317,6 +14903,7 @@ class NexusOrchestrator {
|
|
|
14317
14903
|
communication: { ...defaults.communication, ...partial?.communication },
|
|
14318
14904
|
memory: { ...defaults.memory, ...partial?.memory },
|
|
14319
14905
|
dashboard: { ...defaults.dashboard, ...partial?.dashboard },
|
|
14906
|
+
notifications: { ...defaults.notifications, ...partial?.notifications },
|
|
14320
14907
|
security: { ...defaults.security, ...partial?.security },
|
|
14321
14908
|
learning: { ...defaults.learning, ...partial?.learning },
|
|
14322
14909
|
...partial?.cost ? { cost: { ...defaultCost, ...partial.cost } } : {}
|
|
@@ -14620,9 +15207,7 @@ Please continue from where the previous agent left off.`;
|
|
|
14620
15207
|
startedAt: new Date(startTime),
|
|
14621
15208
|
completedAt: new Date
|
|
14622
15209
|
});
|
|
14623
|
-
|
|
14624
|
-
this.notifications.notify({ title: "Nexus: Task Complete", body: `${node.task.name} completed successfully` });
|
|
14625
|
-
}
|
|
15210
|
+
this.sendNotification({ title: "Nexus: Task Complete", body: `${node.task.name} completed successfully` });
|
|
14626
15211
|
const priorPattern = this.learning.findSolutions(`task ${node.id} failed`);
|
|
14627
15212
|
if (priorPattern.length > 0) {
|
|
14628
15213
|
this.learning.recordSuccess(priorPattern[0].entry.id);
|
|
@@ -14703,9 +15288,7 @@ Please continue from where the previous agent left off.`;
|
|
|
14703
15288
|
timeout
|
|
14704
15289
|
});
|
|
14705
15290
|
}
|
|
14706
|
-
|
|
14707
|
-
this.notifications.notify({ title: "Nexus: Task Failed", body: `${node.task.name} failed: ${errorMessage}`, sound: true });
|
|
14708
|
-
}
|
|
15291
|
+
this.sendNotification({ title: "Nexus: Task Failed", body: `${node.task.name} failed: ${errorMessage}`, sound: true });
|
|
14709
15292
|
if (this.config.selfHealing.enabled) {
|
|
14710
15293
|
await this.handleFailure(agent, node, new Error(errorMessage));
|
|
14711
15294
|
}
|
|
@@ -15003,9 +15586,7 @@ Please continue from where the previous agent left off.`;
|
|
|
15003
15586
|
}
|
|
15004
15587
|
if (policy.alertOnFailure) {
|
|
15005
15588
|
this.emit("agent:escalation", { agentId: agent.id, taskId: node.id, error: error.message });
|
|
15006
|
-
|
|
15007
|
-
this.notifications.notify({ title: "Nexus: Task Failed", body: `${node.task.name} failed: ${error.message}`, sound: true });
|
|
15008
|
-
}
|
|
15589
|
+
this.sendNotification({ title: "Nexus: Task Failed", body: `${node.task.name} failed: ${error.message}`, sound: true });
|
|
15009
15590
|
}
|
|
15010
15591
|
node.status = "failed";
|
|
15011
15592
|
node.result = {
|
|
@@ -15425,16 +16006,20 @@ Please continue from where the previous agent left off.`;
|
|
|
15425
16006
|
checkBudget() {
|
|
15426
16007
|
const remaining = this.budget.maxTotalCost - this.totalSpent;
|
|
15427
16008
|
const remainingPercent = remaining / this.budget.maxTotalCost;
|
|
15428
|
-
if (remainingPercent <= this.config.budget.alertThreshold) {
|
|
15429
|
-
this.emit("budget:alert", { remaining, remainingPercent });
|
|
15430
|
-
if (this.notifications?.isEnabled()) {
|
|
15431
|
-
this.notifications.notify({ title: "Nexus: Budget Alert", body: `Budget low: $${remaining.toFixed(2)} remaining (${(remainingPercent * 100).toFixed(1)}%)`, sound: true });
|
|
15432
|
-
}
|
|
15433
|
-
}
|
|
15434
16009
|
if (this.budget.hardLimit && remaining <= 0 && !this.budgetExceeded) {
|
|
15435
16010
|
this.budgetExceeded = true;
|
|
15436
16011
|
this.emit("budget:exceeded", { totalSpent: this.totalSpent });
|
|
16012
|
+
this.sendNotification({
|
|
16013
|
+
title: "Nexus: Budget Limit Reached",
|
|
16014
|
+
body: `Hard limit hit at $${this.totalSpent.toFixed(2)}. Execution paused.`,
|
|
16015
|
+
sound: true
|
|
16016
|
+
});
|
|
15437
16017
|
this.pause();
|
|
16018
|
+
return;
|
|
16019
|
+
}
|
|
16020
|
+
if (!this.budgetExceeded && remainingPercent <= this.config.budget.alertThreshold) {
|
|
16021
|
+
this.emit("budget:alert", { remaining, remainingPercent });
|
|
16022
|
+
this.sendNotification({ title: "Nexus: Budget Alert", body: `Budget low: $${remaining.toFixed(2)} remaining (${(remainingPercent * 100).toFixed(1)}%)`, sound: true });
|
|
15438
16023
|
}
|
|
15439
16024
|
}
|
|
15440
16025
|
publish(topic, message) {
|
|
@@ -15489,8 +16074,9 @@ Please continue from where the previous agent left off.`;
|
|
|
15489
16074
|
models: this.configManager.getResolvedModels()
|
|
15490
16075
|
};
|
|
15491
16076
|
const spend = this.spendSplit();
|
|
16077
|
+
const notifications = this.notifications?.getStats() ?? null;
|
|
15492
16078
|
if (detailed)
|
|
15493
|
-
return JSON.stringify({ ...state, ...spend, budgetExceeded: this.budgetExceeded, config }, null, 2);
|
|
16079
|
+
return JSON.stringify({ ...state, ...spend, budgetExceeded: this.budgetExceeded, config, notifications }, null, 2);
|
|
15494
16080
|
return JSON.stringify({
|
|
15495
16081
|
running: state.running,
|
|
15496
16082
|
paused: state.paused,
|
|
@@ -15500,6 +16086,7 @@ Please continue from where the previous agent left off.`;
|
|
|
15500
16086
|
totalCost: state.totalSpent,
|
|
15501
16087
|
...spend,
|
|
15502
16088
|
budgetRemaining: state.budgetRemaining,
|
|
16089
|
+
notifications,
|
|
15503
16090
|
config
|
|
15504
16091
|
}, null, 2);
|
|
15505
16092
|
}
|
|
@@ -15675,11 +16262,29 @@ Please continue from where the previous agent left off.`;
|
|
|
15675
16262
|
this.resume();
|
|
15676
16263
|
return "Orchestrator resumed";
|
|
15677
16264
|
case "dashboard":
|
|
15678
|
-
return
|
|
16265
|
+
return this.handleDashboardCommand(parts.slice(2).join(" "));
|
|
15679
16266
|
default:
|
|
15680
|
-
return "Unknown command. Available: status, agents, costs, pause, resume, dashboard";
|
|
16267
|
+
return "Unknown command. Available: status, agents, costs, pause, resume, dashboard [port] [host], dashboard stop, dashboard state";
|
|
15681
16268
|
}
|
|
15682
16269
|
}
|
|
16270
|
+
handleDashboardCommand(argument) {
|
|
16271
|
+
const sub = argument.trim().split(/\s+/).filter(Boolean)[0]?.toLowerCase();
|
|
16272
|
+
if (sub === "state") {
|
|
16273
|
+
return JSON.stringify(this.getState(), null, 2);
|
|
16274
|
+
}
|
|
16275
|
+
if (sub === "stop") {
|
|
16276
|
+
const wasRunning = this.dashboard?.isRunning() ?? false;
|
|
16277
|
+
this.stopDashboard();
|
|
16278
|
+
return wasRunning ? "Dashboard stopped. The orchestrator, its agents and its sessions were not affected." : "No dashboard was running, so nothing was stopped. The orchestrator, its agents and its " + "sessions were not affected.";
|
|
16279
|
+
}
|
|
16280
|
+
const fallback = this.configManager.getConfig().dashboard;
|
|
16281
|
+
const parsed = parseDashboardTarget(argument, { port: fallback.port, host: fallback.host });
|
|
16282
|
+
if ("error" in parsed) {
|
|
16283
|
+
return `Dashboard NOT started: ${parsed.error}
|
|
16284
|
+
No server was started and no browser was opened.`;
|
|
16285
|
+
}
|
|
16286
|
+
return describeDashboardStart(startDashboardServer(this, parsed.target.port, parsed.target.host));
|
|
16287
|
+
}
|
|
15683
16288
|
}
|
|
15684
16289
|
|
|
15685
16290
|
// src/templates.ts
|
|
@@ -16101,20 +16706,19 @@ function watchConfigFiles(ctx, orchestrator, debounceMs = CONFIG_RELOAD_DEBOUNCE
|
|
|
16101
16706
|
}
|
|
16102
16707
|
var DASHBOARD_START_DESCRIPTION = "Start the Nexus web dashboard: an HTTP + WebSocket server that serves the dashboard page and the " + "orchestrator's live state, agents, tasks, sessions, costs and config. It is NOT started for you — " + "nothing in nexus listens until this tool is called, and it serves nothing but the dashboard. " + "The port must be FREE: the bind fails if another process holds it, and this tool reports that " + "failure rather than replacing the other listener. Host defaults to 127.0.0.1 and port to 4747, " + "or to the `dashboard` block in nexus.jsonc. If that block sets `enabled: false` the start is " + "refused and the refusal names the config key. On success the URL is printed — give the user that " + "exact URL; it is the only address the page is served on.";
|
|
16103
16708
|
var DASHBOARD_STOP_DESCRIPTION = "Stop the Nexus web dashboard started by `dashboard.start`, closing its HTTP and WebSocket " + "connections. Takes no arguments. A no-op if no dashboard is running — it says so rather than " + "reporting a stop that did not happen. Note that this only stops the dashboard server: the " + "orchestrator, its agents and its sessions are unaffected and keep running.";
|
|
16709
|
+
var NOTIFICATIONS_TEST_DESCRIPTION = "Send a test OS notification and report whether the OS notifier accepted it, with the reason if it did not. " + "Use this to verify notification setup when the user reports never seeing notifications: it " + "distinguishes notifications being switched off in config, this platform having no notifier, and " + "the notifier itself rejecting the notification. Reports the notifier's own result, never a canned " + "success. Note that `delivered: true` means the OS took the notification, not that a human will see " + "it: a muted app, Focus/Dnd, or missing notification permission all still exit 0.";
|
|
16710
|
+
async function runNotificationsTest(orchestrator) {
|
|
16711
|
+
if (!orchestrator.notifications) {
|
|
16712
|
+
return JSON.stringify({
|
|
16713
|
+
delivered: false,
|
|
16714
|
+
reason: "notification manager is not initialised yet",
|
|
16715
|
+
stats: null
|
|
16716
|
+
}, null, 2);
|
|
16717
|
+
}
|
|
16718
|
+
return JSON.stringify(await orchestrator.notifications.test(), null, 2);
|
|
16719
|
+
}
|
|
16104
16720
|
function runDashboardStart(orchestrator, port, host) {
|
|
16105
|
-
|
|
16106
|
-
orchestrator.startDashboard(port, host);
|
|
16107
|
-
} catch (error) {
|
|
16108
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
16109
|
-
return `Dashboard NOT started: ${detail}
|
|
16110
|
-
` + `No server is listening and no browser was opened. ` + `If the port is in use, either stop whatever holds it or pass a different \`port\`. ` + `Call this tool again once the port is free — the dashboard does not retry on its own.`;
|
|
16111
|
-
}
|
|
16112
|
-
const dashboardConfig = orchestrator.configManager.getConfig().dashboard;
|
|
16113
|
-
const boundHost = host || dashboardConfig.host;
|
|
16114
|
-
const boundPort = port || dashboardConfig.port;
|
|
16115
|
-
return `Dashboard started at http://${boundHost}:${boundPort}
|
|
16116
|
-
` + `Open that exact URL in a browser. The page connects to the same host and port for its live ` + `state over WebSocket (ws://${boundHost}:${boundPort}/ws/events), so it works only while this ` + `server runs. The TUI's \`/nexus-web\` command opens the browser once this tool has succeeded.
|
|
16117
|
-
` + `Call \`nexus.dashboard.stop\` to shut it down.`;
|
|
16721
|
+
return describeDashboardStart(startDashboardServer(orchestrator, port, host));
|
|
16118
16722
|
}
|
|
16119
16723
|
function runDashboardStop(orchestrator) {
|
|
16120
16724
|
const wasRunning = orchestrator.dashboard?.isRunning() ?? false;
|
|
@@ -16707,6 +17311,19 @@ You are a technical writer who creates documentation that developers actually wa
|
|
|
16707
17311
|
return { content: orchestrator.getCostReport() };
|
|
16708
17312
|
}
|
|
16709
17313
|
});
|
|
17314
|
+
editor.add({
|
|
17315
|
+
name: "notifications.test",
|
|
17316
|
+
description: NOTIFICATIONS_TEST_DESCRIPTION,
|
|
17317
|
+
input: {
|
|
17318
|
+
type: "object",
|
|
17319
|
+
properties: {},
|
|
17320
|
+
additionalProperties: false
|
|
17321
|
+
},
|
|
17322
|
+
options: { codemode: true },
|
|
17323
|
+
execute: async () => {
|
|
17324
|
+
return { content: await runNotificationsTest(orchestrator) };
|
|
17325
|
+
}
|
|
17326
|
+
});
|
|
16710
17327
|
editor.add({
|
|
16711
17328
|
name: "dashboard",
|
|
16712
17329
|
description: "Get full orchestrator state for dashboard display",
|
|
@@ -17787,10 +18404,25 @@ ${lines.join(`
|
|
|
17787
18404
|
});
|
|
17788
18405
|
});
|
|
17789
18406
|
await ctx.session.hook("prompt", (event) => {
|
|
17790
|
-
if (event.prompt.text.startsWith("/nexus"))
|
|
17791
|
-
|
|
17792
|
-
|
|
18407
|
+
if (!event.prompt.text.startsWith("/nexus"))
|
|
18408
|
+
return;
|
|
18409
|
+
let result;
|
|
18410
|
+
try {
|
|
18411
|
+
result = orchestrator.handleCommand(event.prompt.text);
|
|
18412
|
+
} catch (error) {
|
|
18413
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
18414
|
+
result = `The \`${event.prompt.text.trim()}\` command failed: ${detail}
|
|
18415
|
+
Nothing was changed.`;
|
|
17793
18416
|
}
|
|
18417
|
+
event.metadata = { ...event.metadata, nexusResult: result };
|
|
18418
|
+
event.prompt.text = [
|
|
18419
|
+
`The user ran \`${event.prompt.text.trim()}\`. The Nexus plugin already handled it and the `,
|
|
18420
|
+
`result is below. Do not run the command again and do not try to start, stop or open `,
|
|
18421
|
+
`anything yourself — report the result above in one or two sentences.`,
|
|
18422
|
+
"",
|
|
18423
|
+
result
|
|
18424
|
+
].join(`
|
|
18425
|
+
`);
|
|
17794
18426
|
});
|
|
17795
18427
|
const stopConfigWatch = watchConfigFiles(ctx, orchestrator);
|
|
17796
18428
|
return () => {
|
|
@@ -17815,6 +18447,7 @@ export {
|
|
|
17815
18447
|
MessageRouter,
|
|
17816
18448
|
MessageStore,
|
|
17817
18449
|
ModuleRegistry,
|
|
18450
|
+
NOTIFICATIONS_TEST_DESCRIPTION,
|
|
17818
18451
|
NexusConfigManager,
|
|
17819
18452
|
NexusOrchestrator,
|
|
17820
18453
|
PRESETS,
|
|
@@ -17833,5 +18466,6 @@ export {
|
|
|
17833
18466
|
listTemplates,
|
|
17834
18467
|
runDashboardStart,
|
|
17835
18468
|
runDashboardStop,
|
|
18469
|
+
runNotificationsTest,
|
|
17836
18470
|
watchConfigFiles
|
|
17837
18471
|
};
|