@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.
Files changed (4) hide show
  1. package/README.md +63 -28
  2. package/dist/index.js +754 -120
  3. package/dist/tui.js +111 -23
  4. 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
- constructor(dashboardBase) {
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
- $lastRefresh.title = 'Snapshot received at ' + fmtTime(new Date(lastStateReceivedAt).toISOString())
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
- handleMessage(JSON.parse(evt.data));
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: ' + taskLabel(data) + ' — ' + errorOf(data));
11152
- addLogStream('error', 'FAIL', 'Task failed: ' + taskLabel(data) + ' — ' + errorOf(data));
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\`, so the useful part is which
11268
- * files were consulted, whether a session override is layered on top of them,
11269
- * and how many times this has loaded.
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
- parts.push('loaded ' + fmtDateTime(d.loadedAt));
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
- + ' (' + taskLabel(d) + ')';
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
- $sessionCount.textContent = String(sessions.length);
11498
- renderOrphanBanner(sessions);
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">Last read tokens</th><th class="num">Unbilled (≥)</th>'
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(sessions) {
11584
- var leaking = [];
11585
- var abandoned = 0;
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.length === 0 && abandoned === 0) {
11968
+ if (leaking === 0 && abandoned === 0) {
11594
11969
  $orphanBanner.innerHTML = '';
11595
11970
  return;
11596
11971
  }
11597
11972
 
11598
11973
  var parts = [];
11599
- if (leaking.length > 0) {
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.length + ' running session' + (leaking.length === 1 ? '' : 's')
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.</div>'
11609
- + '<div class="detail">Session ids: <span class="mono">' + escHtml(ids.join(', ')) + '</span></div>'
11982
+ + 'keeps them in view so you can terminate them. '
11983
+ + '<strong>The Age column reads &mdash; 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">🕑 spawned '
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
- constructor(enabled = true) {
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
- try {
13073
- if (this.platform === "darwin") {
13074
- return await this.notifyMacOS(options);
13075
- } else if (this.platform === "linux") {
13076
- return await this.notifyLinux(options);
13077
- } else if (this.platform === "win32") {
13078
- return await this.notifyWindows(options);
13079
- }
13080
- } catch {}
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 notifyMacOS(options) {
13084
- const soundFlag = options.silent ? "" : options.sound !== false ? 'sound name "Glass"' : "";
13085
- const script = `display notification "${options.body}" with title "${options.title}" ${soundFlag}`;
13086
- return new Promise((resolve) => {
13087
- const proc = spawn("osascript", ["-e", script]);
13088
- proc.on("close", (code) => resolve(code === 0));
13089
- proc.on("error", () => resolve(false));
13090
- setTimeout(() => {
13091
- proc.kill();
13092
- resolve(false);
13093
- }, 3000);
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 notifyLinux(options) {
13097
- return new Promise((resolve) => {
13098
- const proc = spawn("notify-send", [options.title, options.body]);
13099
- proc.on("close", (code) => resolve(code === 0));
13100
- proc.on("error", () => resolve(false));
13101
- setTimeout(() => {
13102
- proc.kill();
13103
- resolve(false);
13104
- }, 3000);
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
- async notifyWindows(options) {
13108
- return new Promise((resolve) => {
13109
- const proc = spawn("powershell", ["-Command", `New-BurntToastNotification -Text '${options.title}','${options.body}'`]);
13110
- proc.on("close", (code) => resolve(code === 0));
13111
- proc.on("error", () => resolve(false));
13112
- setTimeout(() => {
13113
- proc.kill();
13114
- resolve(false);
13115
- }, 3000);
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(true);
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
- if (this.notifications?.isEnabled()) {
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
- if (this.notifications?.isEnabled()) {
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
- if (this.notifications?.isEnabled()) {
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 JSON.stringify(this.getState(), null, 2);
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
- try {
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
- const result = orchestrator.handleCommand(event.prompt.text);
17792
- event.metadata = { ...event.metadata, nexusResult: result };
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
  };