@serkanalgur/opencode-nexus 2.6.0 → 2.7.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 +113 -40
  2. package/dist/index.js +3950 -174
  3. package/dist/tui.js +173 -47
  4. package/package.json +3 -4
package/dist/index.js CHANGED
@@ -8473,6 +8473,11 @@ var DEFAULT_CONFIG = {
8473
8473
  enabled: true,
8474
8474
  maxRetries: 3,
8475
8475
  contextTransfer: true
8476
+ },
8477
+ dashboard: {
8478
+ enabled: true,
8479
+ port: 4747,
8480
+ host: "127.0.0.1"
8476
8481
  }
8477
8482
  };
8478
8483
 
@@ -8481,9 +8486,11 @@ class NexusConfigManager {
8481
8486
  globalConfig = null;
8482
8487
  storageConfig = null;
8483
8488
  loadInfo = null;
8484
- constructor() {
8489
+ dashboardBase;
8490
+ constructor(dashboardBase) {
8485
8491
  this.projectConfig = null;
8486
8492
  this.globalConfig = null;
8493
+ this.dashboardBase = { ...DEFAULT_CONFIG.dashboard, ...dashboardBase };
8487
8494
  }
8488
8495
  loadConfigs(basePath) {
8489
8496
  if (this.loadInfo === null)
@@ -8545,6 +8552,11 @@ class NexusConfigManager {
8545
8552
  ...this.globalConfig?.selfHealing,
8546
8553
  ...this.projectConfig?.selfHealing,
8547
8554
  ...this.storageConfig?.selfHealing
8555
+ },
8556
+ dashboard: {
8557
+ enabled: this.storageConfig?.dashboard?.enabled ?? this.projectConfig?.dashboard?.enabled ?? this.globalConfig?.dashboard?.enabled ?? this.dashboardBase.enabled,
8558
+ port: this.storageConfig?.dashboard?.port ?? this.projectConfig?.dashboard?.port ?? this.globalConfig?.dashboard?.port ?? this.dashboardBase.port,
8559
+ host: this.storageConfig?.dashboard?.host ?? this.projectConfig?.dashboard?.host ?? this.globalConfig?.dashboard?.host ?? this.dashboardBase.host
8548
8560
  }
8549
8561
  };
8550
8562
  }
@@ -8665,6 +8677,7 @@ class NexusConfigManager {
8665
8677
  result.models = { ...current.models };
8666
8678
  result.budget = { ...current.budget };
8667
8679
  result.selfHealing = { ...current.selfHealing };
8680
+ result.dashboard = { ...current.dashboard };
8668
8681
  return result;
8669
8682
  }
8670
8683
  resetToDefaults() {
@@ -8809,94 +8822,3674 @@ var PRESETS = {
8809
8822
  }
8810
8823
  }
8811
8824
  }
8812
- };
8825
+ };
8826
+
8827
+ // src/broadcast.ts
8828
+ var BROADCAST_EVENTS = [
8829
+ "agent:spawned",
8830
+ "agent:terminated",
8831
+ "agent:escalation",
8832
+ "task:failed",
8833
+ "cost:delta",
8834
+ "security:issues-found",
8835
+ "memory:set",
8836
+ "budget:alert",
8837
+ "budget:exceeded",
8838
+ "config:reloaded",
8839
+ "orchestrator:paused",
8840
+ "orchestrator:resumed",
8841
+ "orchestrator:shutdown"
8842
+ ];
8843
+
8844
+ class StateBroadcaster {
8845
+ clients = new Set;
8846
+ orchestrator;
8847
+ broadcastTimer = null;
8848
+ throttleMs;
8849
+ unsubscribers = [];
8850
+ constructor(orchestrator, opts) {
8851
+ this.orchestrator = orchestrator;
8852
+ this.throttleMs = opts?.throttleMs ?? 1000;
8853
+ this.setupEventListeners();
8854
+ }
8855
+ addClient(ws) {
8856
+ this.clients.add(ws);
8857
+ this.snapshot("orchestrator:state", ws);
8858
+ }
8859
+ removeClient(ws) {
8860
+ this.clients.delete(ws);
8861
+ }
8862
+ setupEventListeners() {
8863
+ const orch = this.orchestrator;
8864
+ for (const eventName of BROADCAST_EVENTS) {
8865
+ const unsub = orch.on?.(eventName, (data) => {
8866
+ this.broadcast(eventName, data);
8867
+ });
8868
+ if (unsub)
8869
+ this.unsubscribers.push(unsub);
8870
+ }
8871
+ }
8872
+ snapshot(type, to) {
8873
+ let data;
8874
+ try {
8875
+ data = this.orchestrator.getState();
8876
+ } catch (error) {
8877
+ console.error(`[nexus] getState() failed; skipping the "${type}" push:`, error);
8878
+ return;
8879
+ }
8880
+ const frame = { type, data, timestamp: new Date().toISOString() };
8881
+ if (to)
8882
+ this.sendTo(to, frame);
8883
+ else
8884
+ this.broadcast(type, data);
8885
+ }
8886
+ broadcastState() {
8887
+ if (this.broadcastTimer)
8888
+ return;
8889
+ this.broadcastTimer = setTimeout(() => {
8890
+ this.broadcastTimer = null;
8891
+ this.snapshot("orchestrator:state");
8892
+ }, this.throttleMs);
8893
+ this.broadcastTimer.unref?.();
8894
+ }
8895
+ broadcast(event, data) {
8896
+ const message = JSON.stringify({
8897
+ type: event,
8898
+ data,
8899
+ timestamp: new Date().toISOString()
8900
+ });
8901
+ for (const client of this.clients) {
8902
+ try {
8903
+ client.send(message);
8904
+ } catch {
8905
+ this.clients.delete(client);
8906
+ }
8907
+ }
8908
+ }
8909
+ sendTo(ws, data) {
8910
+ try {
8911
+ ws.send(JSON.stringify(data));
8912
+ } catch {
8913
+ this.clients.delete(ws);
8914
+ }
8915
+ }
8916
+ getClientCount() {
8917
+ return this.clients.size;
8918
+ }
8919
+ destroy() {
8920
+ if (this.broadcastTimer) {
8921
+ clearTimeout(this.broadcastTimer);
8922
+ this.broadcastTimer = null;
8923
+ }
8924
+ for (const unsub of this.unsubscribers) {
8925
+ unsub();
8926
+ }
8927
+ this.unsubscribers = [];
8928
+ this.clients.clear();
8929
+ }
8930
+ }
8931
+
8932
+ // dashboard/index.html
8933
+ var dashboard_default = `<!DOCTYPE html>
8934
+ <html lang="en">
8935
+ <head>
8936
+ <meta charset="UTF-8">
8937
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
8938
+ <title>Nexus Dashboard</title>
8939
+ <style>
8940
+ :root {
8941
+ --bg: #0d1117;
8942
+ --surface: #161b22;
8943
+ --surface-hover: #1c2129;
8944
+ --border: #30363d;
8945
+ --text: #c9d1d9;
8946
+ --text-muted: #8b949e;
8947
+ --green: #3fb950;
8948
+ --yellow: #d29922;
8949
+ --red: #f85149;
8950
+ --blue: #58a6ff;
8951
+ --purple: #bc8cff;
8952
+ --cyan: #39d2c0;
8953
+ --orange: #f0883e;
8954
+ }
8955
+
8956
+ * { margin: 0; padding: 0; box-sizing: border-box; }
8957
+
8958
+ body {
8959
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
8960
+ background: var(--bg);
8961
+ color: var(--text);
8962
+ min-height: 100vh;
8963
+ overflow-x: hidden;
8964
+ }
8965
+
8966
+ /* ── Header ── */
8967
+ .header {
8968
+ display: flex;
8969
+ align-items: center;
8970
+ justify-content: space-between;
8971
+ padding: 16px 24px;
8972
+ border-bottom: 1px solid var(--border);
8973
+ background: var(--surface);
8974
+ }
8975
+ .header-left {
8976
+ display: flex;
8977
+ align-items: center;
8978
+ gap: 12px;
8979
+ }
8980
+ .header-logo {
8981
+ font-size: 22px;
8982
+ }
8983
+ .header-title {
8984
+ font-size: 18px;
8985
+ font-weight: 600;
8986
+ }
8987
+ .header-right {
8988
+ display: flex;
8989
+ align-items: center;
8990
+ gap: 16px;
8991
+ }
8992
+ .connection-badge {
8993
+ display: flex;
8994
+ align-items: center;
8995
+ gap: 8px;
8996
+ padding: 4px 12px;
8997
+ border-radius: 12px;
8998
+ font-size: 12px;
8999
+ font-weight: 500;
9000
+ background: rgba(63, 185, 80, 0.15);
9001
+ color: var(--green);
9002
+ transition: all 0.3s ease;
9003
+ }
9004
+ .connection-badge.disconnected {
9005
+ background: rgba(248, 81, 73, 0.15);
9006
+ color: var(--red);
9007
+ }
9008
+ .connection-badge.reconnecting {
9009
+ background: rgba(210, 153, 34, 0.15);
9010
+ color: var(--yellow);
9011
+ }
9012
+ .connection-dot {
9013
+ width: 8px;
9014
+ height: 8px;
9015
+ border-radius: 50%;
9016
+ background: var(--green);
9017
+ animation: pulse 2s infinite;
9018
+ }
9019
+ .disconnected .connection-dot { background: var(--red); animation: none; }
9020
+ .reconnecting .connection-dot { background: var(--yellow); animation: pulse 0.8s infinite; }
9021
+ @keyframes pulse {
9022
+ 0%, 100% { opacity: 1; }
9023
+ 50% { opacity: 0.4; }
9024
+ }
9025
+
9026
+ /* Orchestrator lifecycle pill. \`paused\` and \`running\` are distinct server
9027
+ fields; a paused orchestrator must not be indistinguishable from a running
9028
+ one, so this is a hard colour change, not a tint. */
9029
+ .orch-pill {
9030
+ display: flex;
9031
+ align-items: center;
9032
+ gap: 6px;
9033
+ padding: 4px 12px;
9034
+ border-radius: 12px;
9035
+ font-size: 12px;
9036
+ font-weight: 700;
9037
+ text-transform: uppercase;
9038
+ letter-spacing: 0.5px;
9039
+ border: 1px solid transparent;
9040
+ }
9041
+ .orch-pill.running { background: rgba(63, 185, 80, 0.15); color: var(--green); border-color: var(--green); }
9042
+ .orch-pill.paused { background: rgba(210, 153, 34, 0.2); color: var(--yellow); border-color: var(--yellow); }
9043
+ .orch-pill.idle { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); border-color: var(--border); }
9044
+ .orch-pill.unknown { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); border-color: var(--border); }
9045
+
9046
+ .uptime {
9047
+ font-size: 12px;
9048
+ color: var(--text-muted);
9049
+ }
9050
+ .auto-refresh-toggle {
9051
+ display: flex;
9052
+ align-items: center;
9053
+ gap: 6px;
9054
+ font-size: 12px;
9055
+ color: var(--text-muted);
9056
+ cursor: pointer;
9057
+ user-select: none;
9058
+ }
9059
+ .auto-refresh-toggle input[type="checkbox"] {
9060
+ accent-color: var(--blue);
9061
+ cursor: pointer;
9062
+ }
9063
+ .last-refresh {
9064
+ font-size: 11px;
9065
+ color: var(--text-muted);
9066
+ }
9067
+ .last-refresh.stale { color: var(--yellow); }
9068
+ .last-refresh.lost { color: var(--red); }
9069
+
9070
+ /* ── Main Layout ── */
9071
+ .main {
9072
+ padding: 24px;
9073
+ max-width: 1400px;
9074
+ margin: 0 auto;
9075
+ }
9076
+
9077
+ /* ── Overview Cards ── */
9078
+ .overview {
9079
+ display: grid;
9080
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
9081
+ gap: 16px;
9082
+ margin-bottom: 24px;
9083
+ }
9084
+ .stat-card {
9085
+ background: var(--surface);
9086
+ border: 1px solid var(--border);
9087
+ border-radius: 8px;
9088
+ padding: 20px;
9089
+ display: flex;
9090
+ flex-direction: column;
9091
+ gap: 8px;
9092
+ }
9093
+ .stat-card .label {
9094
+ font-size: 12px;
9095
+ color: var(--text-muted);
9096
+ text-transform: uppercase;
9097
+ letter-spacing: 0.5px;
9098
+ }
9099
+ .stat-card .value {
9100
+ font-size: 28px;
9101
+ font-weight: 700;
9102
+ font-variant-numeric: tabular-nums;
9103
+ }
9104
+ .stat-card .sub {
9105
+ font-size: 12px;
9106
+ color: var(--text-muted);
9107
+ }
9108
+ .stat-card.green .value { color: var(--green); }
9109
+ .stat-card.blue .value { color: var(--blue); }
9110
+ .stat-card.yellow .value { color: var(--yellow); }
9111
+ .stat-card.purple .value { color: var(--purple); }
9112
+ .stat-card.red .value { color: var(--red); }
9113
+ .stat-card.orange .value { color: var(--orange); }
9114
+ .stat-card.cyan .value { color: var(--cyan); }
9115
+
9116
+ /* ── Section Headers ── */
9117
+ .section-header {
9118
+ display: flex;
9119
+ align-items: center;
9120
+ gap: 8px;
9121
+ margin-bottom: 16px;
9122
+ }
9123
+ .section-header h2 {
9124
+ font-size: 16px;
9125
+ font-weight: 600;
9126
+ }
9127
+ .section-header .count {
9128
+ background: var(--border);
9129
+ color: var(--text-muted);
9130
+ padding: 2px 8px;
9131
+ border-radius: 10px;
9132
+ font-size: 12px;
9133
+ }
9134
+ /* A caveat that belongs next to the number it qualifies, so it cannot be
9135
+ read as part of a neighbouring card. */
9136
+ .section-note {
9137
+ font-size: 12px;
9138
+ color: var(--text-muted);
9139
+ margin: -10px 0 16px;
9140
+ line-height: 1.5;
9141
+ }
9142
+ .section-note strong { color: var(--text); font-weight: 600; }
9143
+ .section-note code, .config-explainer code, .cost-basis code, .accounting-note code {
9144
+ font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
9145
+ font-size: 11px;
9146
+ background: var(--bg);
9147
+ padding: 1px 4px;
9148
+ border-radius: 3px;
9149
+ color: var(--blue);
9150
+ }
9151
+
9152
+ /* ── Agent Grid ── */
9153
+ .agents-grid {
9154
+ display: grid;
9155
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
9156
+ gap: 16px;
9157
+ margin-bottom: 24px;
9158
+ }
9159
+ .agent-card {
9160
+ background: var(--surface);
9161
+ border: 1px solid var(--border);
9162
+ border-radius: 8px;
9163
+ padding: 16px;
9164
+ display: flex;
9165
+ flex-direction: column;
9166
+ gap: 12px;
9167
+ transition: border-color 0.2s;
9168
+ }
9169
+ .agent-card:hover {
9170
+ border-color: var(--blue);
9171
+ }
9172
+ .agent-header {
9173
+ display: flex;
9174
+ align-items: center;
9175
+ justify-content: space-between;
9176
+ }
9177
+ .agent-name {
9178
+ display: flex;
9179
+ align-items: center;
9180
+ gap: 8px;
9181
+ font-size: 14px;
9182
+ font-weight: 600;
9183
+ }
9184
+ .agent-role-badge {
9185
+ font-size: 11px;
9186
+ padding: 2px 8px;
9187
+ border-radius: 4px;
9188
+ font-weight: 500;
9189
+ text-transform: capitalize;
9190
+ }
9191
+ .agent-role-badge.architect { background: rgba(188, 140, 255, 0.15); color: var(--purple); }
9192
+ .agent-role-badge.coder { background: rgba(88, 166, 255, 0.15); color: var(--blue); }
9193
+ .agent-role-badge.reviewer { background: rgba(57, 210, 192, 0.15); color: var(--cyan); }
9194
+ .agent-role-badge.tester { background: rgba(63, 185, 80, 0.15); color: var(--green); }
9195
+ .agent-role-badge.explorer { background: rgba(210, 153, 34, 0.15); color: var(--yellow); }
9196
+ .agent-role-badge.documenter { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); }
9197
+ .status-dot {
9198
+ width: 8px;
9199
+ height: 8px;
9200
+ border-radius: 50%;
9201
+ flex-shrink: 0;
9202
+ }
9203
+ .status-dot.spawning { background: var(--yellow); animation: pulse 1s infinite; }
9204
+ .status-dot.idle { background: var(--yellow); }
9205
+ .status-dot.working { background: var(--green); animation: pulse 1.5s infinite; }
9206
+ .status-dot.blocked { background: var(--red); }
9207
+ .status-dot.completed { background: var(--blue); }
9208
+ .status-dot.failed { background: var(--red); }
9209
+ .status-dot.terminated { background: var(--text-muted); }
9210
+ .agent-meta {
9211
+ font-size: 12px;
9212
+ color: var(--text-muted);
9213
+ display: flex;
9214
+ align-items: center;
9215
+ gap: 6px;
9216
+ overflow: hidden;
9217
+ text-overflow: ellipsis;
9218
+ white-space: nowrap;
9219
+ }
9220
+ .agent-meta .mono {
9221
+ font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
9222
+ font-size: 11px;
9223
+ }
9224
+ .agent-metrics {
9225
+ display: grid;
9226
+ grid-template-columns: repeat(3, 1fr);
9227
+ gap: 8px;
9228
+ padding-top: 12px;
9229
+ border-top: 1px solid var(--border);
9230
+ }
9231
+ .metric {
9232
+ display: flex;
9233
+ flex-direction: column;
9234
+ gap: 2px;
9235
+ }
9236
+ .metric .metric-label {
9237
+ font-size: 10px;
9238
+ color: var(--text-muted);
9239
+ text-transform: uppercase;
9240
+ }
9241
+ .metric .metric-value {
9242
+ font-size: 14px;
9243
+ font-weight: 600;
9244
+ font-variant-numeric: tabular-nums;
9245
+ }
9246
+ .agent-cost-bar {
9247
+ height: 4px;
9248
+ background: var(--border);
9249
+ border-radius: 2px;
9250
+ overflow: hidden;
9251
+ margin-top: 4px;
9252
+ }
9253
+ .agent-cost-fill {
9254
+ height: 100%;
9255
+ border-radius: 2px;
9256
+ transition: width 0.4s ease;
9257
+ }
9258
+ .bar-caption {
9259
+ font-size: 10px;
9260
+ color: var(--text-muted);
9261
+ font-variant-numeric: tabular-nums;
9262
+ }
9263
+
9264
+ /* ── Sessions ── */
9265
+ .sessions-section { margin-bottom: 24px; }
9266
+ .sessions-card {
9267
+ background: var(--surface);
9268
+ border: 1px solid var(--border);
9269
+ border-radius: 8px;
9270
+ overflow: hidden;
9271
+ }
9272
+ .sessions-scroll { overflow-x: auto; }
9273
+ .session-table {
9274
+ width: 100%;
9275
+ border-collapse: collapse;
9276
+ font-size: 12px;
9277
+ min-width: 900px;
9278
+ }
9279
+ .session-table th {
9280
+ text-align: left;
9281
+ padding: 10px 12px;
9282
+ font-size: 10px;
9283
+ text-transform: uppercase;
9284
+ letter-spacing: 0.5px;
9285
+ color: var(--text-muted);
9286
+ border-bottom: 1px solid var(--border);
9287
+ font-weight: 600;
9288
+ white-space: nowrap;
9289
+ }
9290
+ .session-table td {
9291
+ padding: 10px 12px;
9292
+ border-bottom: 1px solid var(--border);
9293
+ vertical-align: top;
9294
+ }
9295
+ .session-table tr:last-child td { border-bottom: none; }
9296
+ .session-table .mono {
9297
+ font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
9298
+ font-size: 11px;
9299
+ word-break: break-all;
9300
+ }
9301
+ .session-table .num { font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
9302
+
9303
+ /* THE distinction this section exists for. An orphan (owned:false,
9304
+ agentId:null) and an abandoned row are marked with a hard left rule, a
9305
+ tinted ground, and a spelled-out badge — never a subtle shade. */
9306
+ tr.session-row.orphan {
9307
+ background: rgba(240, 136, 62, 0.10);
9308
+ box-shadow: inset 4px 0 0 var(--orange);
9309
+ }
9310
+ tr.session-row.abandoned {
9311
+ background: rgba(248, 81, 73, 0.10);
9312
+ box-shadow: inset 4px 0 0 var(--red);
9313
+ }
9314
+ tr.session-row.unread td:last-child { color: var(--text-muted); }
9315
+
9316
+ .flag {
9317
+ display: inline-block;
9318
+ font-size: 10px;
9319
+ font-weight: 700;
9320
+ letter-spacing: 0.5px;
9321
+ text-transform: uppercase;
9322
+ padding: 2px 6px;
9323
+ border-radius: 3px;
9324
+ white-space: nowrap;
9325
+ }
9326
+ .flag.orphan { background: var(--orange); color: #0d1117; }
9327
+ .flag.abandoned { background: var(--red); color: #ffffff; }
9328
+ .flag.unread { background: var(--border); color: var(--text-muted); }
9329
+
9330
+ .session-state {
9331
+ display: inline-block;
9332
+ font-size: 11px;
9333
+ font-weight: 600;
9334
+ padding: 2px 8px;
9335
+ border-radius: 4px;
9336
+ text-transform: capitalize;
9337
+ white-space: nowrap;
9338
+ }
9339
+ .session-state.running { background: rgba(63, 185, 80, 0.15); color: var(--green); }
9340
+ .session-state.idle { background: rgba(210, 153, 34, 0.15); color: var(--yellow); }
9341
+ .session-state.settled { background: rgba(88, 166, 255, 0.15); color: var(--blue); }
9342
+ .session-state.abandoned { background: rgba(248, 81, 73, 0.2); color: var(--red); }
9343
+
9344
+ /* The section-level alarm. Shown only when at least one session is running
9345
+ with no owning agent, because that is the leak. */
9346
+ .orphan-banner {
9347
+ display: flex;
9348
+ align-items: flex-start;
9349
+ gap: 10px;
9350
+ margin: 0 0 16px;
9351
+ padding: 12px 16px;
9352
+ border: 2px solid var(--orange);
9353
+ border-left-width: 6px;
9354
+ border-radius: 8px;
9355
+ background: rgba(240, 136, 62, 0.12);
9356
+ font-size: 13px;
9357
+ line-height: 1.5;
9358
+ }
9359
+ .orphan-banner .icon { font-size: 16px; }
9360
+ .orphan-banner .title { font-weight: 700; color: var(--orange); text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; }
9361
+ .orphan-banner .body { color: var(--text); }
9362
+ .orphan-banner .detail { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
9363
+
9364
+ .session-filter-bar { display: flex; gap: 4px; flex-wrap: wrap; }
9365
+ .session-filter-btn {
9366
+ background: var(--bg);
9367
+ border: 1px solid var(--border);
9368
+ border-radius: 4px;
9369
+ color: var(--text-muted);
9370
+ font-size: 11px;
9371
+ padding: 3px 10px;
9372
+ cursor: pointer;
9373
+ transition: all 0.2s;
9374
+ }
9375
+ .session-filter-btn:hover { border-color: var(--blue); color: var(--text); }
9376
+ .session-filter-btn.active {
9377
+ background: rgba(88, 166, 255, 0.15);
9378
+ border-color: var(--blue);
9379
+ color: var(--blue);
9380
+ }
9381
+
9382
+ /* ── Budget Gauge ── */
9383
+ .budget-section {
9384
+ display: grid;
9385
+ grid-template-columns: 1fr 1fr;
9386
+ gap: 16px;
9387
+ margin-bottom: 24px;
9388
+ }
9389
+ .budget-card {
9390
+ background: var(--surface);
9391
+ border: 1px solid var(--border);
9392
+ border-radius: 8px;
9393
+ padding: 20px;
9394
+ }
9395
+ .budget-card h3 {
9396
+ font-size: 14px;
9397
+ font-weight: 600;
9398
+ margin-bottom: 16px;
9399
+ }
9400
+ .gauge-container {
9401
+ display: flex;
9402
+ align-items: center;
9403
+ gap: 24px;
9404
+ }
9405
+ .gauge-ring {
9406
+ position: relative;
9407
+ width: 120px;
9408
+ height: 120px;
9409
+ flex-shrink: 0;
9410
+ }
9411
+ .gauge-ring svg {
9412
+ transform: rotate(-90deg);
9413
+ }
9414
+ .gauge-ring circle {
9415
+ fill: none;
9416
+ stroke-width: 8;
9417
+ stroke-linecap: round;
9418
+ }
9419
+ .gauge-ring .track { stroke: var(--border); }
9420
+ .gauge-ring .fill { stroke: var(--green); transition: stroke-dashoffset 0.6s ease, stroke 0.3s; }
9421
+ .gauge-ring .fill.warning { stroke: var(--yellow); }
9422
+ .gauge-ring .fill.danger { stroke: var(--red); }
9423
+ .gauge-center {
9424
+ position: absolute;
9425
+ inset: 0;
9426
+ display: flex;
9427
+ flex-direction: column;
9428
+ align-items: center;
9429
+ justify-content: center;
9430
+ }
9431
+ .gauge-pct {
9432
+ font-size: 24px;
9433
+ font-weight: 700;
9434
+ color: var(--text);
9435
+ }
9436
+ .gauge-pct-label {
9437
+ font-size: 10px;
9438
+ color: var(--text-muted);
9439
+ text-transform: uppercase;
9440
+ }
9441
+ .gauge-ring.unmeasurable { opacity: 0.35; }
9442
+ .budget-details {
9443
+ display: flex;
9444
+ flex-direction: column;
9445
+ gap: 12px;
9446
+ justify-content: center;
9447
+ }
9448
+ .budget-row {
9449
+ display: flex;
9450
+ justify-content: space-between;
9451
+ font-size: 13px;
9452
+ gap: 12px;
9453
+ }
9454
+ .budget-row .budget-label { color: var(--text-muted); }
9455
+ .budget-row .budget-val { font-weight: 600; font-variant-numeric: tabular-nums; }
9456
+
9457
+ /* ── Tasks List ── */
9458
+ .tasks-card {
9459
+ background: var(--surface);
9460
+ border: 1px solid var(--border);
9461
+ border-radius: 8px;
9462
+ padding: 20px;
9463
+ }
9464
+ .tasks-card h3 {
9465
+ font-size: 14px;
9466
+ font-weight: 600;
9467
+ margin-bottom: 16px;
9468
+ }
9469
+ .task-list {
9470
+ display: flex;
9471
+ flex-direction: column;
9472
+ gap: 8px;
9473
+ max-height: 420px;
9474
+ overflow-y: auto;
9475
+ scrollbar-width: thin;
9476
+ scrollbar-color: var(--border) transparent;
9477
+ }
9478
+ .task-item {
9479
+ padding: 10px 12px;
9480
+ background: var(--bg);
9481
+ border-radius: 6px;
9482
+ font-size: 13px;
9483
+ border-left: 3px solid transparent;
9484
+ }
9485
+ .task-item.failed { border-left-color: var(--red); }
9486
+ .task-item.completed { border-left-color: var(--blue); }
9487
+ .task-item.running { border-left-color: var(--green); }
9488
+ .task-item.queued { border-left-color: var(--yellow); }
9489
+ .task-line {
9490
+ display: flex;
9491
+ align-items: center;
9492
+ gap: 8px;
9493
+ }
9494
+ .task-info {
9495
+ display: flex;
9496
+ align-items: center;
9497
+ gap: 8px;
9498
+ overflow: hidden;
9499
+ flex: 1;
9500
+ min-width: 0;
9501
+ }
9502
+ .task-name {
9503
+ font-weight: 500;
9504
+ overflow: hidden;
9505
+ text-overflow: ellipsis;
9506
+ white-space: nowrap;
9507
+ }
9508
+ .task-role {
9509
+ color: var(--text-muted);
9510
+ font-size: 11px;
9511
+ flex-shrink: 0;
9512
+ }
9513
+ .task-cost {
9514
+ color: var(--text-muted);
9515
+ font-size: 11px;
9516
+ font-variant-numeric: tabular-nums;
9517
+ flex-shrink: 0;
9518
+ text-align: right;
9519
+ }
9520
+ .task-status {
9521
+ font-size: 11px;
9522
+ padding: 2px 8px;
9523
+ border-radius: 4px;
9524
+ font-weight: 500;
9525
+ text-transform: capitalize;
9526
+ flex-shrink: 0;
9527
+ }
9528
+ .task-status.pending { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); }
9529
+ .task-status.queued { background: rgba(210, 153, 34, 0.15); color: var(--yellow); }
9530
+ .task-status.running { background: rgba(63, 185, 80, 0.15); color: var(--green); }
9531
+ .task-status.completed { background: rgba(88, 166, 255, 0.15); color: var(--blue); }
9532
+ .task-status.failed { background: rgba(248, 81, 73, 0.15); color: var(--red); }
9533
+ .task-status.cancelled { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); }
9534
+
9535
+ .task-chip {
9536
+ font-size: 10px;
9537
+ padding: 1px 6px;
9538
+ border-radius: 3px;
9539
+ background: var(--border);
9540
+ color: var(--text-muted);
9541
+ white-space: nowrap;
9542
+ flex-shrink: 0;
9543
+ }
9544
+ .task-chip.priority-critical { background: rgba(248, 81, 73, 0.2); color: var(--red); }
9545
+ .task-chip.priority-high { background: rgba(240, 136, 62, 0.2); color: var(--orange); }
9546
+ .task-chip.priority-normal { background: rgba(88, 166, 255, 0.15); color: var(--blue); }
9547
+ .task-chip.priority-low { background: var(--border); color: var(--text-muted); }
9548
+ .task-meta {
9549
+ font-size: 10px;
9550
+ color: var(--text-muted);
9551
+ margin-top: 4px;
9552
+ display: flex;
9553
+ gap: 12px;
9554
+ flex-wrap: wrap;
9555
+ font-variant-numeric: tabular-nums;
9556
+ }
9557
+ .task-meta .mono {
9558
+ font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
9559
+ }
9560
+
9561
+ /* t.result: the only place a user can see WHY a task failed. */
9562
+ .task-result {
9563
+ margin-top: 6px;
9564
+ font-size: 11px;
9565
+ }
9566
+ .task-result summary {
9567
+ cursor: pointer;
9568
+ color: var(--blue);
9569
+ list-style: none;
9570
+ }
9571
+ .task-result summary::-webkit-details-marker { display: none; }
9572
+ .task-result summary::before { content: '▸ '; }
9573
+ .task-result[open] summary::before { content: '▾ '; }
9574
+ .task-result pre {
9575
+ margin-top: 6px;
9576
+ padding: 8px 10px;
9577
+ background: var(--bg);
9578
+ border: 1px solid var(--border);
9579
+ border-radius: 4px;
9580
+ font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
9581
+ font-size: 11px;
9582
+ line-height: 1.5;
9583
+ white-space: pre-wrap;
9584
+ word-break: break-word;
9585
+ max-height: 200px;
9586
+ overflow-y: auto;
9587
+ }
9588
+ .task-result pre.error { border-color: var(--red); color: var(--red); }
9589
+ .result-truncation {
9590
+ font-size: 10px;
9591
+ color: var(--text-muted);
9592
+ margin-top: 4px;
9593
+ }
9594
+
9595
+ .empty-message {
9596
+ text-align: center;
9597
+ padding: 24px;
9598
+ color: var(--text-muted);
9599
+ font-size: 13px;
9600
+ }
9601
+
9602
+ /* ── Activity Log ── */
9603
+ .activity-card {
9604
+ background: var(--surface);
9605
+ border: 1px solid var(--border);
9606
+ border-radius: 8px;
9607
+ padding: 20px;
9608
+ margin-bottom: 24px;
9609
+ }
9610
+ .activity-card h3 {
9611
+ font-size: 14px;
9612
+ font-weight: 600;
9613
+ margin-bottom: 16px;
9614
+ display: flex;
9615
+ align-items: center;
9616
+ justify-content: space-between;
9617
+ }
9618
+ .activity-filter-bar {
9619
+ display: flex;
9620
+ gap: 4px;
9621
+ }
9622
+ .activity-filter-btn {
9623
+ background: var(--bg);
9624
+ border: 1px solid var(--border);
9625
+ border-radius: 4px;
9626
+ color: var(--text-muted);
9627
+ font-size: 11px;
9628
+ padding: 3px 10px;
9629
+ cursor: pointer;
9630
+ transition: all 0.2s;
9631
+ }
9632
+ .activity-filter-btn:hover { border-color: var(--blue); color: var(--text); }
9633
+ .activity-filter-btn.active {
9634
+ background: rgba(88, 166, 255, 0.15);
9635
+ border-color: var(--blue);
9636
+ color: var(--blue);
9637
+ }
9638
+ .log-container {
9639
+ max-height: 260px;
9640
+ overflow-y: auto;
9641
+ display: flex;
9642
+ flex-direction: column;
9643
+ gap: 4px;
9644
+ scrollbar-width: thin;
9645
+ scrollbar-color: var(--border) transparent;
9646
+ }
9647
+ .log-container::-webkit-scrollbar { width: 6px; }
9648
+ .log-container::-webkit-scrollbar-track { background: transparent; }
9649
+ .log-container::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
9650
+ .log-entry {
9651
+ display: flex;
9652
+ gap: 10px;
9653
+ padding: 6px 8px;
9654
+ border-radius: 4px;
9655
+ font-size: 12px;
9656
+ font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
9657
+ line-height: 1.5;
9658
+ align-items: flex-start;
9659
+ }
9660
+ .log-entry:hover { background: var(--surface-hover); }
9661
+ .log-entry.hidden { display: none; }
9662
+ .log-time {
9663
+ color: var(--text-muted);
9664
+ flex-shrink: 0;
9665
+ font-variant-numeric: tabular-nums;
9666
+ }
9667
+ .log-icon {
9668
+ flex-shrink: 0;
9669
+ width: 16px;
9670
+ text-align: center;
9671
+ }
9672
+ .log-msg { color: var(--text); word-break: break-word; }
9673
+ .log-entry.event-spawn .log-icon { color: var(--green); }
9674
+ .log-entry.event-task .log-icon { color: var(--blue); }
9675
+ .log-entry.event-error .log-icon { color: var(--red); }
9676
+ .log-entry.event-budget .log-icon { color: var(--yellow); }
9677
+ .log-entry.event-info .log-icon { color: var(--text-muted); }
9678
+ .log-entry.event-system .log-icon { color: var(--purple); }
9679
+ .log-entry.event-success .log-icon { color: var(--green); }
9680
+ .log-entry.event-warning .log-icon { color: var(--yellow); }
9681
+
9682
+ /* ── No Agents placeholder ── */
9683
+ .no-agents {
9684
+ background: var(--surface);
9685
+ border: 1px dashed var(--border);
9686
+ border-radius: 8px;
9687
+ padding: 40px;
9688
+ text-align: center;
9689
+ color: var(--text-muted);
9690
+ margin-bottom: 24px;
9691
+ }
9692
+ .no-agents .icon { font-size: 32px; margin-bottom: 8px; }
9693
+ .no-agents .msg { font-size: 13px; }
9694
+
9695
+ /* ── Responsive ── */
9696
+ @media (max-width: 900px) {
9697
+ .budget-section { grid-template-columns: 1fr; }
9698
+ .agents-grid { grid-template-columns: 1fr; }
9699
+ }
9700
+ @media (max-width: 600px) {
9701
+ .main { padding: 16px; }
9702
+ .header { padding: 12px 16px; flex-wrap: wrap; gap: 8px; }
9703
+ .header-right { gap: 8px; flex-wrap: wrap; }
9704
+ .gauge-container { flex-direction: column; align-items: flex-start; }
9705
+ .agent-metrics { grid-template-columns: repeat(2, 1fr); }
9706
+ .activity-filter-bar { flex-wrap: wrap; }
9707
+ .task-line { flex-wrap: wrap; }
9708
+ .task-cost { text-align: left; }
9709
+ .activity-card h3 { flex-direction: column; align-items: flex-start; gap: 8px; }
9710
+ }
9711
+
9712
+ /* ── Fade-in animation ── */
9713
+ .fade-in {
9714
+ animation: fadeIn 0.3s ease;
9715
+ }
9716
+ @keyframes fadeIn {
9717
+ from { opacity: 0; transform: translateY(4px); }
9718
+ to { opacity: 1; transform: translateY(0); }
9719
+ }
9720
+
9721
+ /* ── Status pulse for working agents ── */
9722
+ .status-working {
9723
+ animation: statusPulse 1.5s infinite;
9724
+ }
9725
+ @keyframes statusPulse {
9726
+ 0%, 100% { opacity: 1; }
9727
+ 50% { opacity: 0.5; }
9728
+ }
9729
+
9730
+ /* ── Task Dependency Graph ──
9731
+ Columns are dependency LAYERS (longest-path depth), so an arrow always
9732
+ points right, and every arrow is drawn for a dependency edge the server
9733
+ actually reported. There is no "next node in the list" arrow. */
9734
+ .dag-section {
9735
+ margin-bottom: 24px;
9736
+ }
9737
+ .dag-section .section-header h2 {
9738
+ display: flex;
9739
+ align-items: center;
9740
+ gap: 8px;
9741
+ }
9742
+ #dag-container {
9743
+ background: var(--surface);
9744
+ border: 1px solid var(--border);
9745
+ border-radius: 8px;
9746
+ padding: 24px;
9747
+ min-height: 120px;
9748
+ overflow-x: auto;
9749
+ }
9750
+ .dag-empty {
9751
+ text-align: center;
9752
+ color: var(--text-muted);
9753
+ font-size: 13px;
9754
+ padding: 20px;
9755
+ }
9756
+ .dag-empty .icon { font-size: 28px; margin-bottom: 8px; }
9757
+ .dag-canvas {
9758
+ position: relative;
9759
+ display: inline-block;
9760
+ min-width: 100%;
9761
+ }
9762
+ .dag-edges {
9763
+ position: absolute;
9764
+ inset: 0;
9765
+ pointer-events: none;
9766
+ overflow: visible;
9767
+ }
9768
+ .dag-edges path { fill: none; stroke-width: 1.5; }
9769
+ .dag-edges path.edge-satisfied { stroke: var(--green); }
9770
+ .dag-edges path.edge-active { stroke: var(--blue); }
9771
+ .dag-edges path.edge-pending { stroke: var(--border); }
9772
+ .dag-edges polygon.arrowhead { fill: var(--border); }
9773
+ .dag-layers {
9774
+ display: flex;
9775
+ align-items: flex-start;
9776
+ gap: 44px;
9777
+ position: relative;
9778
+ z-index: 1;
9779
+ }
9780
+ .dag-layer {
9781
+ display: flex;
9782
+ flex-direction: column;
9783
+ gap: 28px;
9784
+ justify-content: center;
9785
+ min-height: 60px;
9786
+ }
9787
+ .dag-layer-index {
9788
+ font-size: 9px;
9789
+ color: var(--text-muted);
9790
+ text-transform: uppercase;
9791
+ letter-spacing: 0.5px;
9792
+ text-align: center;
9793
+ margin-bottom: 2px;
9794
+ }
9795
+ .dag-node {
9796
+ display: flex;
9797
+ flex-direction: column;
9798
+ align-items: center;
9799
+ gap: 4px;
9800
+ width: 120px;
9801
+ flex-shrink: 0;
9802
+ }
9803
+ .dag-node-box {
9804
+ width: 120px;
9805
+ padding: 10px 8px;
9806
+ border-radius: 8px;
9807
+ border: 2px solid var(--border);
9808
+ background: var(--surface);
9809
+ text-align: center;
9810
+ font-size: 11px;
9811
+ font-weight: 500;
9812
+ transition: border-color 0.3s, box-shadow 0.3s;
9813
+ position: relative;
9814
+ overflow: hidden;
9815
+ text-overflow: ellipsis;
9816
+ white-space: nowrap;
9817
+ }
9818
+ .dag-node-box.pending {
9819
+ border-color: var(--text-muted);
9820
+ color: var(--text-muted);
9821
+ }
9822
+ .dag-node-box.running {
9823
+ border-color: var(--blue);
9824
+ color: var(--blue);
9825
+ box-shadow: 0 0 12px rgba(88, 166, 255, 0.3);
9826
+ animation: dagPulse 1.5s infinite;
9827
+ }
9828
+ .dag-node-box.completed {
9829
+ border-color: var(--green);
9830
+ color: var(--green);
9831
+ background: rgba(63, 185, 80, 0.08);
9832
+ }
9833
+ .dag-node-box.failed {
9834
+ border-color: var(--red);
9835
+ color: var(--red);
9836
+ background: rgba(248, 81, 73, 0.08);
9837
+ }
9838
+ .dag-node-box.queued {
9839
+ border-color: var(--yellow);
9840
+ color: var(--yellow);
9841
+ }
9842
+ .dag-node-box.cancelled {
9843
+ border-color: var(--border);
9844
+ color: var(--text-muted);
9845
+ text-decoration: line-through;
9846
+ }
9847
+ @keyframes dagPulse {
9848
+ 0%, 100% { box-shadow: 0 0 12px rgba(88, 166, 255, 0.3); }
9849
+ 50% { box-shadow: 0 0 20px rgba(88, 166, 255, 0.6); }
9850
+ }
9851
+ .dag-node-label {
9852
+ font-size: 10px;
9853
+ color: var(--text-muted);
9854
+ max-width: 120px;
9855
+ overflow: hidden;
9856
+ text-overflow: ellipsis;
9857
+ white-space: nowrap;
9858
+ text-align: center;
9859
+ }
9860
+ .dag-node-role {
9861
+ font-size: 9px;
9862
+ color: var(--text-muted);
9863
+ opacity: 0.7;
9864
+ }
9865
+ .dag-node-deps {
9866
+ font-size: 9px;
9867
+ color: var(--text-muted);
9868
+ text-align: center;
9869
+ }
9870
+
9871
+ /* ── Cost ── */
9872
+ .cost-section {
9873
+ margin-bottom: 24px;
9874
+ }
9875
+ .cost-card {
9876
+ background: var(--surface);
9877
+ border: 1px solid var(--border);
9878
+ border-radius: 8px;
9879
+ padding: 20px;
9880
+ }
9881
+ .cost-card h3 {
9882
+ font-size: 14px;
9883
+ font-weight: 600;
9884
+ margin-bottom: 16px;
9885
+ display: flex;
9886
+ align-items: center;
9887
+ gap: 8px;
9888
+ }
9889
+ .cost-grid {
9890
+ display: grid;
9891
+ grid-template-columns: 1fr 1fr;
9892
+ gap: 16px;
9893
+ }
9894
+ .cost-chart {
9895
+ display: flex;
9896
+ flex-direction: column;
9897
+ gap: 10px;
9898
+ }
9899
+ .cost-chart-title {
9900
+ font-size: 12px;
9901
+ font-weight: 600;
9902
+ color: var(--text-muted);
9903
+ text-transform: uppercase;
9904
+ letter-spacing: 0.5px;
9905
+ margin-bottom: 4px;
9906
+ }
9907
+ .cost-bar-row {
9908
+ display: flex;
9909
+ align-items: center;
9910
+ gap: 10px;
9911
+ font-size: 12px;
9912
+ }
9913
+ .cost-bar-label {
9914
+ min-width: 120px;
9915
+ max-width: 160px;
9916
+ color: var(--text-muted);
9917
+ overflow: hidden;
9918
+ text-overflow: ellipsis;
9919
+ white-space: nowrap;
9920
+ }
9921
+ .cost-bar-track {
9922
+ flex: 1;
9923
+ height: 8px;
9924
+ background: var(--bg);
9925
+ border-radius: 4px;
9926
+ overflow: hidden;
9927
+ min-width: 40px;
9928
+ }
9929
+ .cost-bar-fill {
9930
+ height: 100%;
9931
+ border-radius: 4px;
9932
+ transition: width 0.5s ease;
9933
+ }
9934
+ .cost-bar-value {
9935
+ min-width: 60px;
9936
+ text-align: right;
9937
+ font-weight: 600;
9938
+ font-variant-numeric: tabular-nums;
9939
+ font-size: 12px;
9940
+ }
9941
+ .cost-total-row {
9942
+ display: flex;
9943
+ justify-content: space-between;
9944
+ padding-top: 10px;
9945
+ margin-top: 10px;
9946
+ border-top: 1px solid var(--border);
9947
+ font-size: 13px;
9948
+ }
9949
+ .cost-total-label { color: var(--text-muted); }
9950
+ .cost-total-value { font-weight: 700; color: var(--blue); }
9951
+ .cost-basis {
9952
+ font-size: 10px;
9953
+ color: var(--text-muted);
9954
+ margin-top: 6px;
9955
+ line-height: 1.5;
9956
+ }
9957
+ .cost-status {
9958
+ font-size: 12px;
9959
+ color: var(--yellow);
9960
+ margin-bottom: 12px;
9961
+ }
9962
+ .cost-status.hidden { display: none; }
9963
+ .accounting-row {
9964
+ display: flex;
9965
+ justify-content: space-between;
9966
+ font-size: 12px;
9967
+ padding: 3px 0;
9968
+ gap: 12px;
9969
+ }
9970
+ .accounting-row .k { color: var(--text-muted); }
9971
+ .accounting-row .v { font-variant-numeric: tabular-nums; font-weight: 600; }
9972
+ .accounting-row .v.unbilled { color: var(--orange); }
9973
+ .accounting-note {
9974
+ font-size: 10px;
9975
+ color: var(--text-muted);
9976
+ margin-top: 8px;
9977
+ line-height: 1.5;
9978
+ padding-top: 8px;
9979
+ border-top: 1px solid var(--border);
9980
+ }
9981
+
9982
+ /* ── Config Panel (read-only) ── */
9983
+ .config-section {
9984
+ margin-bottom: 24px;
9985
+ }
9986
+ .config-card {
9987
+ background: var(--surface);
9988
+ border: 1px solid var(--border);
9989
+ border-radius: 8px;
9990
+ padding: 20px;
9991
+ }
9992
+ .config-card h3 {
9993
+ font-size: 14px;
9994
+ font-weight: 600;
9995
+ margin-bottom: 16px;
9996
+ display: flex;
9997
+ align-items: center;
9998
+ gap: 8px;
9999
+ }
10000
+ .readonly-badge {
10001
+ font-size: 10px;
10002
+ font-weight: 700;
10003
+ letter-spacing: 0.5px;
10004
+ text-transform: uppercase;
10005
+ padding: 2px 8px;
10006
+ border-radius: 3px;
10007
+ background: var(--border);
10008
+ color: var(--text-muted);
10009
+ }
10010
+ .config-grid {
10011
+ display: grid;
10012
+ grid-template-columns: 1fr 1fr 1fr;
10013
+ gap: 16px;
10014
+ }
10015
+ .config-group {
10016
+ background: var(--bg);
10017
+ border-radius: 6px;
10018
+ padding: 14px;
10019
+ }
10020
+ .config-group-title {
10021
+ font-size: 12px;
10022
+ font-weight: 600;
10023
+ color: var(--text-muted);
10024
+ text-transform: uppercase;
10025
+ letter-spacing: 0.5px;
10026
+ margin-bottom: 10px;
10027
+ display: flex;
10028
+ align-items: center;
10029
+ gap: 6px;
10030
+ }
10031
+ .config-row {
10032
+ display: flex;
10033
+ justify-content: space-between;
10034
+ align-items: center;
10035
+ padding: 4px 0;
10036
+ font-size: 13px;
10037
+ gap: 12px;
10038
+ }
10039
+ .config-key {
10040
+ color: var(--text-muted);
10041
+ }
10042
+ .config-val {
10043
+ font-weight: 500;
10044
+ font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
10045
+ font-size: 12px;
10046
+ text-align: right;
10047
+ word-break: break-all;
10048
+ }
10049
+ .config-val.enabled { color: var(--green); }
10050
+ .config-val.disabled { color: var(--text-muted); }
10051
+ .config-val.warning { color: var(--yellow); }
10052
+ .config-val.model { color: var(--blue); }
10053
+ .config-val.unreported { color: var(--text-muted); font-style: italic; }
10054
+ .config-explainer {
10055
+ margin-top: 12px;
10056
+ font-size: 11px;
10057
+ color: var(--text-muted);
10058
+ line-height: 1.6;
10059
+ }
10060
+ .config-explainer code {
10061
+ font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
10062
+ color: var(--text);
10063
+ }
10064
+
10065
+ /* ── Resolved Config (read-only viewer) ── */
10066
+ .config-editor-section {
10067
+ margin-bottom: 24px;
10068
+ }
10069
+ .config-editor-card {
10070
+ background: var(--surface);
10071
+ border: 1px solid var(--border);
10072
+ border-radius: 8px;
10073
+ padding: 20px;
10074
+ }
10075
+ .config-editor-card h3 {
10076
+ font-size: 14px;
10077
+ font-weight: 600;
10078
+ margin-bottom: 12px;
10079
+ display: flex;
10080
+ align-items: center;
10081
+ gap: 8px;
10082
+ }
10083
+ .config-editor-header {
10084
+ display: flex;
10085
+ justify-content: space-between;
10086
+ align-items: center;
10087
+ margin-bottom: 12px;
10088
+ gap: 12px;
10089
+ flex-wrap: wrap;
10090
+ }
10091
+ .config-editor-actions {
10092
+ display: flex;
10093
+ gap: 8px;
10094
+ }
10095
+ .config-btn {
10096
+ padding: 6px 14px;
10097
+ border-radius: 6px;
10098
+ border: 1px solid var(--border);
10099
+ background: var(--bg);
10100
+ color: var(--text);
10101
+ font-size: 12px;
10102
+ font-weight: 500;
10103
+ cursor: pointer;
10104
+ transition: all 0.2s;
10105
+ }
10106
+ .config-btn:hover { border-color: var(--blue); color: var(--blue); }
10107
+ .config-textarea {
10108
+ width: 100%;
10109
+ min-height: 200px;
10110
+ background: var(--bg);
10111
+ border: 1px solid var(--border);
10112
+ border-radius: 6px;
10113
+ color: var(--text);
10114
+ font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
10115
+ font-size: 12px;
10116
+ line-height: 1.5;
10117
+ padding: 12px;
10118
+ resize: vertical;
10119
+ outline: none;
10120
+ }
10121
+ /* Not \`readOnly\` styling only — the field cannot be typed into, and says so. */
10122
+ .config-textarea[readonly] {
10123
+ cursor: default;
10124
+ border-style: dashed;
10125
+ }
10126
+ .config-status {
10127
+ font-size: 11px;
10128
+ color: var(--text-muted);
10129
+ margin-top: 8px;
10130
+ min-height: 18px;
10131
+ line-height: 1.5;
10132
+ }
10133
+ .config-status.error { color: var(--red); }
10134
+ .config-status.success { color: var(--green); }
10135
+
10136
+ /* ── Enhanced Log Stream ── */
10137
+ .log-stream-section {
10138
+ margin-bottom: 24px;
10139
+ }
10140
+ .log-stream-card {
10141
+ background: var(--surface);
10142
+ border: 1px solid var(--border);
10143
+ border-radius: 8px;
10144
+ padding: 20px;
10145
+ }
10146
+ .log-stream-card h3 {
10147
+ font-size: 14px;
10148
+ font-weight: 600;
10149
+ margin-bottom: 12px;
10150
+ display: flex;
10151
+ align-items: center;
10152
+ justify-content: space-between;
10153
+ }
10154
+ .log-filter-bar {
10155
+ display: flex;
10156
+ gap: 6px;
10157
+ margin-bottom: 12px;
10158
+ flex-wrap: wrap;
10159
+ }
10160
+ .log-filter-btn {
10161
+ background: var(--bg);
10162
+ border: 1px solid var(--border);
10163
+ border-radius: 4px;
10164
+ color: var(--text-muted);
10165
+ font-size: 11px;
10166
+ padding: 3px 10px;
10167
+ cursor: pointer;
10168
+ transition: all 0.2s;
10169
+ }
10170
+ .log-filter-btn:hover { border-color: var(--blue); color: var(--text); }
10171
+ .log-filter-btn.active {
10172
+ background: rgba(88, 166, 255, 0.15);
10173
+ border-color: var(--blue);
10174
+ color: var(--blue);
10175
+ }
10176
+ .log-stream-container {
10177
+ max-height: 300px;
10178
+ overflow-y: auto;
10179
+ display: flex;
10180
+ flex-direction: column;
10181
+ gap: 2px;
10182
+ scrollbar-width: thin;
10183
+ scrollbar-color: var(--border) transparent;
10184
+ }
10185
+ .log-stream-container::-webkit-scrollbar { width: 6px; }
10186
+ .log-stream-container::-webkit-scrollbar-track { background: transparent; }
10187
+ .log-stream-container::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
10188
+ .log-stream-entry {
10189
+ display: flex;
10190
+ gap: 8px;
10191
+ padding: 5px 8px;
10192
+ border-radius: 4px;
10193
+ font-size: 12px;
10194
+ font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
10195
+ line-height: 1.5;
10196
+ align-items: flex-start;
10197
+ }
10198
+ .log-stream-entry:hover { background: var(--surface-hover); }
10199
+ .log-stream-entry .ls-time {
10200
+ color: var(--text-muted);
10201
+ flex-shrink: 0;
10202
+ font-variant-numeric: tabular-nums;
10203
+ min-width: 65px;
10204
+ }
10205
+ .log-stream-entry .ls-badge {
10206
+ flex-shrink: 0;
10207
+ font-size: 10px;
10208
+ padding: 1px 6px;
10209
+ border-radius: 3px;
10210
+ font-weight: 500;
10211
+ text-transform: uppercase;
10212
+ min-width: 50px;
10213
+ text-align: center;
10214
+ }
10215
+ .log-stream-entry .ls-msg {
10216
+ color: var(--text);
10217
+ word-break: break-word;
10218
+ flex: 1;
10219
+ }
10220
+ .log-stream-entry.event-success .ls-badge { background: rgba(63, 185, 80, 0.15); color: var(--green); }
10221
+ .log-stream-entry.event-success .ls-msg { color: var(--green); }
10222
+ .log-stream-entry.event-warning .ls-badge { background: rgba(210, 153, 34, 0.15); color: var(--yellow); }
10223
+ .log-stream-entry.event-warning .ls-msg { color: var(--yellow); }
10224
+ .log-stream-entry.event-error .ls-badge { background: rgba(248, 81, 73, 0.15); color: var(--red); }
10225
+ .log-stream-entry.event-error .ls-msg { color: var(--red); }
10226
+ .log-stream-entry.event-info .ls-badge { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); }
10227
+ .log-stream-entry.event-task .ls-badge { background: rgba(88, 166, 255, 0.15); color: var(--blue); }
10228
+ .log-stream-entry.event-system .ls-badge { background: rgba(188, 140, 255, 0.15); color: var(--purple); }
10229
+ .log-stream-entry.event-budget .ls-badge { background: rgba(210, 153, 34, 0.15); color: var(--yellow); }
10230
+ .log-stream-entry.hidden { display: none; }
10231
+ .log-stream-count {
10232
+ font-size: 11px;
10233
+ color: var(--text-muted);
10234
+ text-align: right;
10235
+ margin-top: 6px;
10236
+ }
10237
+
10238
+ @media (max-width: 900px) {
10239
+ .config-grid { grid-template-columns: 1fr; }
10240
+ .cost-grid { grid-template-columns: 1fr; }
10241
+ }
10242
+ @media (max-width: 600px) {
10243
+ .config-editor-actions { flex-wrap: wrap; }
10244
+ .config-btn { flex: 1; text-align: center; }
10245
+ }
10246
+ </style>
10247
+ </head>
10248
+ <body>
10249
+
10250
+ <!-- Header -->
10251
+ <div class="header">
10252
+ <div class="header-left">
10253
+ <span class="header-logo">⚡</span>
10254
+ <span class="header-title">Nexus Dashboard</span>
10255
+ <!-- state.running / state.paused, read from the server. A paused
10256
+ orchestrator must not look like a running one. -->
10257
+ <span class="orch-pill unknown" id="orch-status" title="Orchestrator lifecycle, as reported by the server">No state</span>
10258
+ </div>
10259
+ <div class="header-right">
10260
+ <span class="uptime" id="uptime">Server uptime —</span>
10261
+ <label class="auto-refresh-toggle">
10262
+ <input type="checkbox" id="auto-refresh-check" checked> Auto-refresh
10263
+ </label>
10264
+ <span class="last-refresh" id="last-refresh">No state received</span>
10265
+ <div class="connection-badge disconnected" id="conn-badge">
10266
+ <span class="connection-dot"></span>
10267
+ <span id="conn-text">Disconnected</span>
10268
+ </div>
10269
+ </div>
10270
+ </div>
10271
+
10272
+ <!-- Main Content -->
10273
+ <div class="main">
10274
+
10275
+ <!-- Overview Stats -->
10276
+ <div class="overview">
10277
+ <div class="stat-card green">
10278
+ <span class="label">Agents</span>
10279
+ <span class="value" id="stat-agents">—</span>
10280
+ <span class="sub" id="stat-agents-sub">Live agents only</span>
10281
+ </div>
10282
+ <div class="stat-card blue">
10283
+ <span class="label">Tasks</span>
10284
+ <span class="value" id="stat-tasks">—</span>
10285
+ <span class="sub" id="stat-tasks-sub">No tasks reported</span>
10286
+ </div>
10287
+ <div class="stat-card cyan">
10288
+ <span class="label">Sessions</span>
10289
+ <span class="value" id="stat-sessions">—</span>
10290
+ <span class="sub" id="stat-sessions-sub">No sessions reported</span>
10291
+ </div>
10292
+ <div class="stat-card yellow">
10293
+ <span class="label">Spent</span>
10294
+ <span class="value" id="stat-spent">—</span>
10295
+ <span class="sub" id="stat-spent-sub">Budget ceiling not reported</span>
10296
+ </div>
10297
+ <div class="stat-card purple">
10298
+ <span class="label">Remaining</span>
10299
+ <span class="value" id="stat-remaining">—</span>
10300
+ <span class="sub" id="stat-remaining-sub">Awaiting state</span>
10301
+ </div>
10302
+ <div class="stat-card orange">
10303
+ <span class="label">Unbilled</span>
10304
+ <span class="value" id="stat-unbilled">—</span>
10305
+ <span class="sub" id="stat-unbilled-sub">Lower bound · not in Spend</span>
10306
+ </div>
10307
+ </div>
10308
+
10309
+ <!-- Sessions. The headline section: a session that is still running with no
10310
+ owning agent is the failure this page exists to make visible. -->
10311
+ <div class="sessions-section">
10312
+ <div class="section-header">
10313
+ <h2>🧵 Sessions</h2>
10314
+ <span class="count" id="session-count">0</span>
10315
+ <span class="session-filter-bar">
10316
+ <button class="session-filter-btn active" data-sfilter="all">All</button>
10317
+ <button class="session-filter-btn" data-sfilter="orphan">No owning agent</button>
10318
+ <button class="session-filter-btn" data-sfilter="running">Running</button>
10319
+ <button class="session-filter-btn" data-sfilter="abandoned">Abandoned</button>
10320
+ </span>
10321
+ </div>
10322
+ <p class="section-note" id="session-note">
10323
+ <strong>State is nexus's own bookkeeping, not a server session list.</strong>
10324
+ A row with <strong>no owning agent</strong> is a session that is still generating
10325
+ after the agent that owned it was terminated — it keeps spending, and nothing
10326
+ is collecting that spend. <strong>Abandoned</strong> means the plugin stopped
10327
+ watching; its <strong>unbilled</strong> figure is a <strong>lower bound</strong>
10328
+ on what went unbilled, not a total, and is deliberately not added to Spend.
10329
+ Token counts are the last figure actually read, not a live reading.
10330
+ </p>
10331
+ <div id="orphan-banner"></div>
10332
+ <div class="sessions-card">
10333
+ <div class="sessions-scroll">
10334
+ <div id="sessions-container">
10335
+ <div class="empty-message">No sessions reported</div>
10336
+ </div>
10337
+ </div>
10338
+ </div>
10339
+ </div>
10340
+
10341
+ <!-- Agent Grid -->
10342
+ <div class="section-header">
10343
+ <h2>🤖 Agents</h2>
10344
+ <span class="count" id="agent-count">0</span>
10345
+ </div>
10346
+ <p class="section-note">
10347
+ <strong>Live agents only.</strong> The orchestrator deletes an agent when it
10348
+ terminates, so a completed or terminated agent's spend leaves this list and
10349
+ survives only in <strong>Cost Breakdown</strong>, which reads the full history.
10350
+ </p>
10351
+ <div id="agents-container">
10352
+ <div class="no-agents">
10353
+ <div class="icon">🤖</div>
10354
+ <div class="msg">No agents spawned yet. Tasks will spawn agents automatically.</div>
10355
+ </div>
10356
+ </div>
10357
+
10358
+ <!-- Task Dependency Graph -->
10359
+ <div class="dag-section">
10360
+ <div class="section-header">
10361
+ <h2>📊 Task Dependencies</h2>
10362
+ <span class="count" id="dag-count">0</span>
10363
+ </div>
10364
+ <p class="section-note" id="dag-note"></p>
10365
+ <div id="dag-container">
10366
+ <div class="dag-empty">
10367
+ <div class="icon">📊</div>
10368
+ <div>No tasks in the graph yet.</div>
10369
+ </div>
10370
+ </div>
10371
+ </div>
10372
+
10373
+ <!-- Budget + Tasks side by side -->
10374
+ <div class="budget-section">
10375
+ <!-- Budget Gauge -->
10376
+ <div class="budget-card">
10377
+ <h3>💰 Budget</h3>
10378
+ <div class="gauge-container">
10379
+ <div class="gauge-ring" id="gauge-ring">
10380
+ <svg width="120" height="120" viewBox="0 0 120 120">
10381
+ <circle class="track" cx="60" cy="60" r="52"></circle>
10382
+ <circle class="fill" id="gauge-fill" cx="60" cy="60" r="52"
10383
+ stroke-dasharray="326.73" stroke-dashoffset="0"></circle>
10384
+ </svg>
10385
+ <div class="gauge-center">
10386
+ <span class="gauge-pct" id="gauge-pct">—</span>
10387
+ <span class="gauge-pct-label">Left</span>
10388
+ </div>
10389
+ </div>
10390
+ <div class="budget-details">
10391
+ <div class="budget-row">
10392
+ <span class="budget-label">Ceiling</span>
10393
+ <span class="budget-val" id="budget-total">—</span>
10394
+ </div>
10395
+ <div class="budget-row">
10396
+ <span class="budget-label">Spent</span>
10397
+ <span class="budget-val" id="budget-spent">—</span>
10398
+ </div>
10399
+ <div class="budget-row">
10400
+ <span class="budget-label">Remaining</span>
10401
+ <span class="budget-val" id="budget-remaining">—</span>
10402
+ </div>
10403
+ <div class="budget-row">
10404
+ <span class="budget-label">Alert below</span>
10405
+ <span class="budget-val" id="budget-alert">—</span>
10406
+ </div>
10407
+ <div class="budget-row">
10408
+ <span class="budget-label">Status</span>
10409
+ <span class="budget-val" id="budget-status" style="color: var(--text-muted)">Unknown</span>
10410
+ </div>
10411
+ </div>
10412
+ </div>
10413
+ <p class="config-explainer">
10414
+ <strong>Alert below</strong> is <code>config.budget.alertThreshold</code>:
10415
+ the fraction of the ceiling <em>still unspent</em> at or below which the
10416
+ orchestrator raises a budget alert. It is not a percentage of the budget
10417
+ consumed. <strong>Status</strong> is derived from remaining-vs-alert-threshold
10418
+ only, so it never claims a "critical" level the config has no threshold for.
10419
+ </p>
10420
+ </div>
10421
+
10422
+ <!-- Tasks -->
10423
+ <div class="tasks-card">
10424
+ <h3>📋 Tasks</h3>
10425
+ <div class="task-list" id="task-list">
10426
+ <div class="empty-message">No tasks yet</div>
10427
+ </div>
10428
+ </div>
10429
+ </div>
10430
+
10431
+ <!-- Cost Breakdown: reads /api/costs, which covers the FULL history rather
10432
+ than only the live agents in state. -->
10433
+ <div class="cost-section">
10434
+ <div class="cost-card">
10435
+ <h3>💸 Cost Breakdown</h3>
10436
+ <div class="cost-status hidden" id="cost-status"></div>
10437
+ <div class="cost-grid">
10438
+ <div class="cost-chart" id="cost-by-agent">
10439
+ <div class="cost-chart-title">By Agent</div>
10440
+ <div class="empty-message" style="padding:8px">No cost report yet</div>
10441
+ </div>
10442
+ <div class="cost-chart" id="cost-by-model">
10443
+ <div class="cost-chart-title">By Model</div>
10444
+ <div class="empty-message" style="padding:8px">No cost report yet</div>
10445
+ </div>
10446
+ </div>
10447
+ <div class="cost-chart" id="cost-accounting" style="margin-top:16px">
10448
+ <div class="cost-chart-title">Accounting</div>
10449
+ <div class="empty-message" style="padding:8px">No cost report yet</div>
10450
+ </div>
10451
+ <p class="cost-basis" id="cost-basis"></p>
10452
+ </div>
10453
+ </div>
10454
+
10455
+ <!-- Config Panel. Read-only, matching the server: it has no config write
10456
+ path, and a localhost socket with CORS:* is not the place to add one. -->
10457
+ <div class="config-section">
10458
+ <div class="config-card">
10459
+ <h3>⚙️ Configuration <span class="readonly-badge">Read-only</span></h3>
10460
+ <div class="config-grid">
10461
+ <div class="config-group">
10462
+ <div class="config-group-title">🤖 Models per Role</div>
10463
+ <div id="config-models">
10464
+ <div class="config-row"><span class="config-key">—</span><span class="config-val unreported">No state received</span></div>
10465
+ </div>
10466
+ </div>
10467
+ <div class="config-group">
10468
+ <div class="config-group-title">💰 Budget</div>
10469
+ <div id="config-budget">
10470
+ <div class="config-row"><span class="config-key">—</span><span class="config-val unreported">No state received</span></div>
10471
+ </div>
10472
+ </div>
10473
+ <div class="config-group">
10474
+ <div class="config-group-title">🔧 Self-Healing</div>
10475
+ <div id="config-healing">
10476
+ <div class="config-row"><span class="config-key">—</span><span class="config-val unreported">No state received</span></div>
10477
+ </div>
10478
+ </div>
10479
+ </div>
10480
+ <p class="config-explainer">
10481
+ Read from <code>state.config</code> under the real key names.
10482
+ <strong>Budget</strong> is the constraint in force right now, which
10483
+ <code>execute()</code> may have replaced for the duration of a run.
10484
+ <strong>Self-healing</strong> is <code>config.selfHealing</code>. There is no
10485
+ critical threshold, no auto-terminate and no escalation or deadlock-detection
10486
+ setting — none of those exist, so none are shown. Escalation to a
10487
+ notification is a code default after retries and model fallback are
10488
+ exhausted, not a configuration value.
10489
+ </p>
10490
+ </div>
10491
+ </div>
10492
+
10493
+ <!-- Resolved Config viewer. Replaces the editor: there is no write path to
10494
+ apply to, so there is nothing for an Apply button to do. -->
10495
+ <div class="config-editor-section">
10496
+ <div class="config-editor-card">
10497
+ <div class="config-editor-header">
10498
+ <h3>📝 Resolved Config <span class="readonly-badge">Read-only</span></h3>
10499
+ <div class="config-editor-actions">
10500
+ <button class="config-btn" id="config-format-btn">Format</button>
10501
+ <button class="config-btn" id="config-validate-btn">Check</button>
10502
+ </div>
10503
+ </div>
10504
+ <textarea class="config-textarea" id="config-editor" readonly wrap="off" spellcheck="false"
10505
+ placeholder="No configuration received from the server yet."></textarea>
10506
+ <div class="config-status" id="config-editor-status">Read-only: this server accepts no configuration writes.</div>
10507
+ </div>
10508
+ </div>
10509
+
10510
+ <!-- Activity Log -->
10511
+ <div class="activity-card">
10512
+ <h3>
10513
+ <span>📈 Activity Log</span>
10514
+ <span class="activity-filter-bar">
10515
+ <button class="activity-filter-btn active" data-afilter="all">All</button>
10516
+ <button class="activity-filter-btn" data-afilter="spawn">Spawn</button>
10517
+ <button class="activity-filter-btn" data-afilter="task">Task</button>
10518
+ <button class="activity-filter-btn" data-afilter="error">Error</button>
10519
+ <button class="activity-filter-btn" data-afilter="budget">Budget</button>
10520
+ <button class="activity-filter-btn" data-afilter="system">System</button>
10521
+ <button class="activity-filter-btn" data-afilter="info">Info</button>
10522
+ </span>
10523
+ </h3>
10524
+ <div class="log-container" id="log-container">
10525
+ <div class="log-entry event-info">
10526
+ <span class="log-time">--:--:--</span>
10527
+ <span class="log-icon">ℹ</span>
10528
+ <span class="log-msg">Waiting for connection to Nexus server...</span>
10529
+ </div>
10530
+ </div>
10531
+ </div>
10532
+
10533
+ <!-- Event Stream -->
10534
+ <div class="log-stream-section">
10535
+ <div class="log-stream-card">
10536
+ <h3>
10537
+ <span>📡 Event Stream</span>
10538
+ <span class="log-filter-bar">
10539
+ <button class="log-filter-btn active" data-filter="all">All</button>
10540
+ <button class="log-filter-btn" data-filter="success">Success</button>
10541
+ <button class="log-filter-btn" data-filter="warning">Warning</button>
10542
+ <button class="log-filter-btn" data-filter="error">Error</button>
10543
+ <button class="log-filter-btn" data-filter="task">Task</button>
10544
+ <button class="log-filter-btn" data-filter="system">System</button>
10545
+ <button class="log-filter-btn" data-filter="budget">Budget</button>
10546
+ </span>
10547
+ </h3>
10548
+ <div class="log-stream-container" id="log-stream-container">
10549
+ <div class="log-stream-entry event-info">
10550
+ <span class="ls-time">--:--:--</span>
10551
+ <span class="ls-badge">INFO</span>
10552
+ <span class="ls-msg">Waiting for events...</span>
10553
+ </div>
10554
+ </div>
10555
+ <div class="log-stream-count" id="log-stream-count">0 events</div>
10556
+ </div>
10557
+ </div>
10558
+
10559
+ </div>
10560
+
10561
+ <script>
10562
+ (function() {
10563
+ 'use strict';
10564
+
10565
+ // ── Constants ──
10566
+ var MAX_LOG_ENTRIES = 200;
10567
+ var CIRCUMFERENCE = 2 * Math.PI * 52; // ~326.73, the r=52 ring
10568
+ var AUTO_REFRESH_INTERVAL = 5000;
10569
+ var STALE_AFTER_MS = 15000; // a snapshot older than this is labelled stale
10570
+ // The server truncates \`result.output\` at this length. Labelled in the UI so a
10571
+ // clipped output is not read as the whole output.
10572
+ var SERVER_OUTPUT_TRUNCATION = 500;
10573
+
10574
+ var ws = null;
10575
+ var reconnectDelay = 1000;
10576
+ var reconnectTimer = null;
10577
+ var logEntries = [];
10578
+ var autoRefreshTimer = null;
10579
+ var lastState = null;
10580
+ /** ISO timestamp of the last snapshot we actually received. */
10581
+ var lastStateReceivedAt = null;
10582
+ /** ISO \`state.lastUpdated\`, as the server stamped it. */
10583
+ var lastStateUpdated = null;
10584
+ /** Parsed /api/costs, or null when it has never loaded. */
10585
+ var costReport = null;
10586
+ /** 'loading' | 'ready' | 'failed' — what to say about /api/costs. */
10587
+ var costReportStatus = 'loading';
10588
+ var costReportError = '';
10589
+
10590
+ // Log Stream
10591
+ var logStreamEntries = [];
10592
+ var MAX_LOG_STREAM = 500;
10593
+ var logFilter = 'all';
10594
+
10595
+ // Activity Log filter
10596
+ var activityFilter = 'all';
10597
+
10598
+ // Sessions filter
10599
+ var sessionFilter = 'all';
10600
+
10601
+ // Role emojis
10602
+ var ROLE_EMOJI = {
10603
+ architect: '🏗️', coder: '💻', reviewer: '🔍',
10604
+ tester: '🧪', explorer: '🔭', documenter: '📝'
10605
+ };
10606
+
10607
+ // Role colors for cost bars
10608
+ var ROLE_COLORS = {
10609
+ architect: '#bc8cff', coder: '#58a6ff', reviewer: '#39d2c0',
10610
+ tester: '#3fb950', explorer: '#d29922', documenter: '#8b949e'
10611
+ };
10612
+ var BAR_COLOR_PALETTE = ['#58a6ff', '#bc8cff', '#39d2c0', '#3fb950', '#d29922', '#f0883e', '#f85149'];
10613
+
10614
+ // ── DOM Refs ──
10615
+ var $connBadge = document.getElementById('conn-badge');
10616
+ var $connText = document.getElementById('conn-text');
10617
+ var $uptime = document.getElementById('uptime');
10618
+ var $autoRefreshCheck = document.getElementById('auto-refresh-check');
10619
+ var $lastRefresh = document.getElementById('last-refresh');
10620
+ var $orchStatus = document.getElementById('orch-status');
10621
+
10622
+ // Stats
10623
+ var $statAgents = document.getElementById('stat-agents');
10624
+ var $statAgentsSub = document.getElementById('stat-agents-sub');
10625
+ var $statTasks = document.getElementById('stat-tasks');
10626
+ var $statTasksSub = document.getElementById('stat-tasks-sub');
10627
+ var $statSessions = document.getElementById('stat-sessions');
10628
+ var $statSessionsSub = document.getElementById('stat-sessions-sub');
10629
+ var $statSpent = document.getElementById('stat-spent');
10630
+ var $statSpentSub = document.getElementById('stat-spent-sub');
10631
+ var $statRemaining = document.getElementById('stat-remaining');
10632
+ var $statRemainingSub = document.getElementById('stat-remaining-sub');
10633
+ var $statUnbilled = document.getElementById('stat-unbilled');
10634
+ var $statUnbilledSub = document.getElementById('stat-unbilled-sub');
10635
+
10636
+ // Sessions
10637
+ var $sessionsContainer = document.getElementById('sessions-container');
10638
+ var $sessionCount = document.getElementById('session-count');
10639
+ var $orphanBanner = document.getElementById('orphan-banner');
10640
+
10641
+ // Agents
10642
+ var $agentsContainer = document.getElementById('agents-container');
10643
+ var $agentCount = document.getElementById('agent-count');
10644
+
10645
+ // Budget
10646
+ var $gaugeRing = document.getElementById('gauge-ring');
10647
+ var $gaugeFill = document.getElementById('gauge-fill');
10648
+ var $gaugePct = document.getElementById('gauge-pct');
10649
+ var $budgetTotal = document.getElementById('budget-total');
10650
+ var $budgetSpent = document.getElementById('budget-spent');
10651
+ var $budgetRemaining = document.getElementById('budget-remaining');
10652
+ var $budgetAlert = document.getElementById('budget-alert');
10653
+ var $budgetStatus = document.getElementById('budget-status');
10654
+
10655
+ // Tasks
10656
+ var $taskList = document.getElementById('task-list');
10657
+
10658
+ // Log
10659
+ var $logContainer = document.getElementById('log-container');
10660
+
10661
+ // Task dependency graph
10662
+ var $dagContainer = document.getElementById('dag-container');
10663
+ var $dagCount = document.getElementById('dag-count');
10664
+ var $dagNote = document.getElementById('dag-note');
10665
+
10666
+ // Config
10667
+ var $configModels = document.getElementById('config-models');
10668
+ var $configBudget = document.getElementById('config-budget');
10669
+ var $configHealing = document.getElementById('config-healing');
10670
+
10671
+ // Resolved config viewer
10672
+ var $configEditor = document.getElementById('config-editor');
10673
+ var $configEditorStatus = document.getElementById('config-editor-status');
10674
+ var $configFormatBtn = document.getElementById('config-format-btn');
10675
+ var $configValidateBtn = document.getElementById('config-validate-btn');
10676
+
10677
+ // Log Stream
10678
+ var $logStreamContainer = document.getElementById('log-stream-container');
10679
+ var $logStreamCount = document.getElementById('log-stream-count');
10680
+
10681
+ // Cost
10682
+ var $costByAgent = document.getElementById('cost-by-agent');
10683
+ var $costByModel = document.getElementById('cost-by-model');
10684
+ var $costAccounting = document.getElementById('cost-accounting');
10685
+ var $costStatus = document.getElementById('cost-status');
10686
+ var $costBasis = document.getElementById('cost-basis');
10687
+
10688
+ // ── Value formatting ──
10689
+ //
10690
+ // One rule drives all of these: a value the server did not report renders as
10691
+ // an em dash, never as a plausible number. \`num()\` returning null for a
10692
+ // non-finite input is what makes that possible, so a caller cannot
10693
+ // accidentally format \`undefined\` as \`$0.00\`.
10694
+
10695
+ /** A finite number, or null. The gate every formatter goes through. */
10696
+ function num(v) {
10697
+ return (typeof v === 'number' && isFinite(v)) ? v : null;
10698
+ }
10699
+
10700
+ function fmt$(v) {
10701
+ var n = num(v);
10702
+ return n === null ? '—' : '$' + n.toFixed(2);
10703
+ }
10704
+
10705
+ function fmtInt(v) {
10706
+ var n = num(v);
10707
+ return n === null ? '—' : Math.round(n).toLocaleString('en-US');
10708
+ }
10709
+
10710
+ function fmtMs(v) {
10711
+ var n = num(v);
10712
+ if (n === null) return '—';
10713
+ if (n < 1000) return Math.round(n) + 'ms';
10714
+ return (n / 1000).toFixed(2) + 's';
10715
+ }
10716
+
10717
+ /** A 0..1 fraction as a percentage, or '—'. */
10718
+ function fmtPct(fraction) {
10719
+ var n = num(fraction);
10720
+ return n === null ? '—' : (n * 100).toFixed(1) + '%';
10721
+ }
10722
+
10723
+ function fmtTime(iso) {
10724
+ var d = iso ? new Date(iso) : null;
10725
+ if (!d || isNaN(d.getTime())) return '—';
10726
+ return d.toLocaleTimeString('en-US', { hour12: false });
10727
+ }
10728
+
10729
+ function fmtDateTime(iso) {
10730
+ var d = iso ? new Date(iso) : null;
10731
+ if (!d || isNaN(d.getTime())) return '—';
10732
+ return d.toLocaleString('en-US', { hour12: false });
10733
+ }
10734
+
10735
+ function fmtElapsed(ms) {
10736
+ var s = Math.floor(ms / 1000);
10737
+ var h = Math.floor(s / 3600);
10738
+ var m = Math.floor((s % 3600) / 60);
10739
+ var sec = s % 60;
10740
+ if (h > 0) return h + 'h ' + m + 'm';
10741
+ if (m > 0) return m + 'm ' + sec + 's';
10742
+ return sec + 's';
10743
+ }
10744
+
10745
+ function now() {
10746
+ return new Date().toLocaleTimeString('en-US', { hour12: false });
10747
+ }
10748
+
10749
+ /** 'enabled' / 'disabled' / 'not reported' for a boolean the server may omit. */
10750
+ function boolText(v) {
10751
+ if (typeof v !== 'boolean') return { text: 'not reported', cls: 'unreported' };
10752
+ return { text: v ? 'enabled' : 'disabled', cls: v ? 'enabled' : 'disabled' };
10753
+ }
10754
+
10755
+ /** A string, or the unreported marker. Never substitutes a placeholder. */
10756
+ function text(v) {
10757
+ return (typeof v === 'string' && v.length > 0) ? v : null;
10758
+ }
10759
+
10760
+ function escHtml(s) {
10761
+ var d = document.createElement('div');
10762
+ d.textContent = s === null || s === undefined ? '' : String(s);
10763
+ return d.innerHTML;
10764
+ }
10765
+
10766
+ // ── Header: lifecycle, uptime, snapshot age ──
10767
+
10768
+ /**
10769
+ * \`running\` and \`paused\` are separate server fields. \`paused\` wins when both
10770
+ * are true, because a paused orchestrator is mid-run and not executing — that
10771
+ * is the state the old page could not show at all.
10772
+ */
10773
+ function renderLifecycle(state) {
10774
+ var running = state.running;
10775
+ var paused = state.paused;
10776
+ if (typeof running !== 'boolean' && typeof paused !== 'boolean') {
10777
+ $orchStatus.className = 'orch-pill unknown';
10778
+ $orchStatus.textContent = 'No state';
10779
+ $orchStatus.title = 'The server has not reported running/paused yet.';
10780
+ return;
10781
+ }
10782
+ if (paused === true) {
10783
+ $orchStatus.className = 'orch-pill paused';
10784
+ $orchStatus.textContent = 'Paused';
10785
+ } else if (running === true) {
10786
+ $orchStatus.className = 'orch-pill running';
10787
+ $orchStatus.textContent = 'Running';
10788
+ } else {
10789
+ $orchStatus.className = 'orch-pill idle';
10790
+ $orchStatus.textContent = 'Idle';
10791
+ }
10792
+ $orchStatus.title = 'state.running=' + String(running) + ' · state.paused=' + String(paused);
10793
+ }
10794
+
10795
+ /** Narrow an arbitrary parsed JSON value to an object, or null. */
10796
+ function asObject(v) {
10797
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return null;
10798
+ return v;
10799
+ }
10800
+
10801
+ /** Narrow an arbitrary parsed JSON value to an array, or []. */
10802
+ function asArray(v) {
10803
+ return Array.isArray(v) ? v : [];
10804
+ }
10805
+
10806
+ /**
10807
+ * Age of the last snapshot, labelled. A page holding a stale snapshot is
10808
+ * presenting numbers that are no longer true, so the staleness is stated
10809
+ * rather than the timestamp simply left sitting there.
10810
+ */
10811
+ function renderSnapshotAge() {
10812
+ if (lastStateReceivedAt === null) {
10813
+ $lastRefresh.textContent = 'No state received';
10814
+ $lastRefresh.className = 'last-refresh lost';
10815
+ return;
10816
+ }
10817
+ var age = Date.now() - lastStateReceivedAt;
10818
+ var text = 'State ' + fmtTime(lastStateUpdated || new Date(lastStateReceivedAt).toISOString())
10819
+ + ' (' + fmtElapsed(age) + ' ago)';
10820
+ $lastRefresh.textContent = text;
10821
+ $lastRefresh.className = 'last-refresh' + (age > STALE_AFTER_MS ? ' stale' : '');
10822
+ $lastRefresh.title = 'Snapshot received at ' + fmtTime(new Date(lastStateReceivedAt).toISOString())
10823
+ + '. ' + (ws && ws.readyState === WebSocket.OPEN
10824
+ ? 'Auto-refresh asks the server every ' + (AUTO_REFRESH_INTERVAL / 1000) + 's.'
10825
+ : 'Not connected: this may be the last state the server sent.');
10826
+ }
10827
+
10828
+ // ── Auto-refresh ──
10829
+ //
10830
+ // The server answers \`{type:'getState'}\` and pushes a throttled snapshot on
10831
+ // every change, so the push is what keeps the page fresh and this poll is the
10832
+ // belt-and-braces refresh. Events are NOT deduped client-side: the server
10833
+ // delivers each one exactly once.
10834
+
10835
+ function startAutoRefresh() {
10836
+ stopAutoRefresh(true);
10837
+ autoRefreshTimer = setInterval(function() {
10838
+ if (ws && ws.readyState === WebSocket.OPEN) {
10839
+ try {
10840
+ ws.send(JSON.stringify({ type: 'getState' }));
10841
+ } catch (e) {
10842
+ addLog('error', '⚠️', 'Could not request state: ' + errorText(e));
10843
+ }
10844
+ }
10845
+ refreshSideChannels();
10846
+ }, AUTO_REFRESH_INTERVAL);
10847
+ }
10848
+
10849
+ function stopAutoRefresh(silent) {
10850
+ if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; }
10851
+ if (!silent) renderSnapshotAge();
10852
+ }
10853
+
10854
+ $autoRefreshCheck.addEventListener('change', function() {
10855
+ if (this.checked) {
10856
+ startAutoRefresh();
10857
+ refreshSideChannels();
10858
+ } else {
10859
+ stopAutoRefresh();
10860
+ }
10861
+ });
10862
+
10863
+ /**
10864
+ * \`/api/health\` and \`/api/costs\` are the two things the WebSocket does not
10865
+ * carry. Polled on the auto-refresh tick and once on connect.
10866
+ */
10867
+ function refreshSideChannels() {
10868
+ refreshHealth();
10869
+ refreshCostReport();
10870
+ }
10871
+
10872
+ function refreshHealth() {
10873
+ fetch('/api/health')
10874
+ .then(function(res) {
10875
+ if (res.status !== 200) throw new Error('HTTP ' + res.status);
10876
+ return res.json();
10877
+ })
10878
+ .then(function(body) {
10879
+ var data = asObject(body);
10880
+ if (data === null) throw new Error('not an object');
10881
+ var secs = num(data.uptime);
10882
+ if (secs === null) {
10883
+ $uptime.textContent = 'Server uptime —';
10884
+ $uptime.title = '/api/health did not report uptime.';
10885
+ return;
10886
+ }
10887
+ $uptime.textContent = 'Server uptime ' + fmtElapsed(secs * 1000);
10888
+ $uptime.title = 'The dashboard server process, from /api/health.';
10889
+ })
10890
+ .catch(function(err) {
10891
+ $uptime.textContent = 'Server uptime unavailable';
10892
+ $uptime.title = '/api/health failed: ' + errorText(err);
10893
+ });
10894
+ }
10895
+
10896
+ function refreshCostReport() {
10897
+ fetch('/api/costs')
10898
+ .then(function(res) {
10899
+ if (res.status !== 200) throw new Error('HTTP ' + res.status);
10900
+ return res.json();
10901
+ })
10902
+ .then(function(body) {
10903
+ var data = asObject(body);
10904
+ if (data === null) throw new Error('response was not an object');
10905
+ costReport = data;
10906
+ costReportStatus = 'ready';
10907
+ costReportError = '';
10908
+ renderCost();
10909
+ // The cost report is a separate channel from the socket, so the unbilled
10910
+ // card would otherwise wait for the next state push to notice it landed.
10911
+ if (lastState) renderUnbilledStat();
10912
+ })
10913
+ .catch(function(err) {
10914
+ costReportStatus = 'failed';
10915
+ costReportError = errorText(err);
10916
+ renderCost();
10917
+ if (lastState) renderUnbilledStat();
10918
+ });
10919
+ }
10920
+
10921
+ // ── Activity Log ──
10922
+ function addLog(type, icon, msg) {
10923
+ var entry = document.createElement('div');
10924
+ entry.className = 'log-entry event-' + type;
10925
+ entry.setAttribute('data-atype', type);
10926
+ entry.innerHTML = '<span class="log-time">' + now() + '</span>'
10927
+ + '<span class="log-icon">' + icon + '</span>'
10928
+ + '<span class="log-msg">' + escHtml(msg) + '</span>';
10929
+
10930
+ logEntries.push(entry);
10931
+ if (logEntries.length > MAX_LOG_ENTRIES) {
10932
+ var old = logEntries.shift();
10933
+ if (old.parentNode) old.parentNode.removeChild(old);
10934
+ }
10935
+
10936
+ if (activityFilter !== 'all' && type !== activityFilter) {
10937
+ entry.classList.add('hidden');
10938
+ }
10939
+
10940
+ if ($logContainer.firstChild) {
10941
+ $logContainer.insertBefore(entry, $logContainer.firstChild);
10942
+ } else {
10943
+ $logContainer.appendChild(entry);
10944
+ }
10945
+ }
10946
+
10947
+ function applyActivityFilter() {
10948
+ for (var i = 0; i < logEntries.length; i++) {
10949
+ var t = logEntries[i].getAttribute('data-atype');
10950
+ if (activityFilter === 'all' || t === activityFilter) {
10951
+ logEntries[i].classList.remove('hidden');
10952
+ } else {
10953
+ logEntries[i].classList.add('hidden');
10954
+ }
10955
+ }
10956
+ }
10957
+
10958
+ function setupActivityFilters() {
10959
+ var btns = document.querySelectorAll('.activity-filter-btn');
10960
+ for (var i = 0; i < btns.length; i++) {
10961
+ btns[i].addEventListener('click', function() {
10962
+ activityFilter = this.getAttribute('data-afilter');
10963
+ for (var j = 0; j < btns.length; j++) btns[j].classList.remove('active');
10964
+ this.classList.add('active');
10965
+ applyActivityFilter();
10966
+ });
10967
+ }
10968
+ }
10969
+
10970
+ // ── Log Stream ──
10971
+ function addLogStream(severity, label, msg) {
10972
+ var entry = document.createElement('div');
10973
+ entry.className = 'log-stream-entry event-' + severity;
10974
+ entry.setAttribute('data-severity', severity);
10975
+ entry.innerHTML = '<span class="ls-time">' + now() + '</span>'
10976
+ + '<span class="ls-badge">' + escHtml(label) + '</span>'
10977
+ + '<span class="ls-msg">' + escHtml(msg) + '</span>';
10978
+
10979
+ logStreamEntries.push({ el: entry, severity: severity });
10980
+ if (logStreamEntries.length > MAX_LOG_STREAM) {
10981
+ var old = logStreamEntries.shift();
10982
+ if (old.el.parentNode) old.el.parentNode.removeChild(old.el);
10983
+ }
10984
+
10985
+ if (logFilter !== 'all' && severity !== logFilter) {
10986
+ entry.classList.add('hidden');
10987
+ }
10988
+
10989
+ if ($logStreamContainer.firstChild) {
10990
+ $logStreamContainer.insertBefore(entry, $logStreamContainer.firstChild);
10991
+ } else {
10992
+ $logStreamContainer.appendChild(entry);
10993
+ }
10994
+ $logStreamCount.textContent = logStreamEntries.length + ' events';
10995
+ }
10996
+
10997
+ function setupLogFilters() {
10998
+ var btns = document.querySelectorAll('.log-filter-btn');
10999
+ for (var i = 0; i < btns.length; i++) {
11000
+ btns[i].addEventListener('click', function() {
11001
+ logFilter = this.getAttribute('data-filter');
11002
+ for (var j = 0; j < btns.length; j++) btns[j].classList.remove('active');
11003
+ this.classList.add('active');
11004
+ applyLogFilter();
11005
+ });
11006
+ }
11007
+ }
11008
+
11009
+ function applyLogFilter() {
11010
+ for (var i = 0; i < logStreamEntries.length; i++) {
11011
+ var e = logStreamEntries[i];
11012
+ if (logFilter === 'all' || e.severity === logFilter) {
11013
+ e.el.classList.remove('hidden');
11014
+ } else {
11015
+ e.el.classList.add('hidden');
11016
+ }
11017
+ }
11018
+ }
11019
+
11020
+ function errorText(err) {
11021
+ if (err instanceof Error) return err.message;
11022
+ if (typeof err === 'string') return err;
11023
+ return String(err);
11024
+ }
11025
+
11026
+ // ── Connection ──
11027
+ function setConnState(state) {
11028
+ $connBadge.className = 'connection-badge ' + state;
11029
+ if (state === 'connected') {
11030
+ $connText.textContent = 'Live';
11031
+ } else if (state === 'reconnecting') {
11032
+ $connText.textContent = 'Reconnecting...';
11033
+ } else {
11034
+ $connText.textContent = 'Disconnected';
11035
+ }
11036
+ }
11037
+
11038
+ function socketUrl() {
11039
+ // \`location.port\` is the dashboard's own HTTP port, which is also the
11040
+ // WebSocket port — same server, same origin.
11041
+ return 'ws://' + location.hostname + ':' + location.port + '/ws/events';
11042
+ }
11043
+
11044
+ function connect() {
11045
+ var url = socketUrl();
11046
+
11047
+ try {
11048
+ ws = new WebSocket(url);
11049
+ } catch (e) {
11050
+ addLog('error', '❌', 'WebSocket creation failed: ' + errorText(e));
11051
+ addLogStream('error', 'WS', 'WebSocket creation failed: ' + errorText(e));
11052
+ scheduleReconnect();
11053
+ return;
11054
+ }
11055
+
11056
+ setConnState('reconnecting');
11057
+ addLog('info', 'ℹ', 'Connecting to ' + url + '...');
11058
+ addLogStream('info', 'CONN', 'Connecting to ' + url + '...');
11059
+
11060
+ ws.onopen = function() {
11061
+ setConnState('connected');
11062
+ reconnectDelay = 1000;
11063
+ addLog('system', '🔌', 'Connected to Nexus server');
11064
+ addLogStream('success', 'CONN', 'Connected to Nexus server');
11065
+ if ($autoRefreshCheck.checked) {
11066
+ startAutoRefresh();
11067
+ refreshSideChannels();
11068
+ }
11069
+ };
11070
+
11071
+ ws.onmessage = function(evt) {
11072
+ try {
11073
+ handleMessage(JSON.parse(evt.data));
11074
+ } catch (e) {
11075
+ addLog('info', '📩', 'Non-JSON frame from server: ' + errorText(e));
11076
+ }
11077
+ };
11078
+
11079
+ ws.onclose = function(evt) {
11080
+ setConnState('disconnected');
11081
+ addLog('error', '🔌', 'Disconnected (code: ' + evt.code + ')');
11082
+ addLogStream('error', 'CONN', 'Disconnected (code: ' + evt.code + ')');
11083
+ stopAutoRefresh();
11084
+ renderSnapshotAge();
11085
+ scheduleReconnect();
11086
+ };
11087
+
11088
+ ws.onerror = function() {
11089
+ setConnState('disconnected');
11090
+ };
11091
+ }
11092
+
11093
+ function scheduleReconnect() {
11094
+ if (reconnectTimer) return;
11095
+ setConnState('reconnecting');
11096
+ addLog('info', '🔄', 'Reconnecting in ' + (reconnectDelay / 1000) + 's...');
11097
+ addLogStream('info', 'CONN', 'Reconnecting in ' + (reconnectDelay / 1000) + 's...');
11098
+ reconnectTimer = setTimeout(function() {
11099
+ reconnectTimer = null;
11100
+ reconnectDelay = Math.min(reconnectDelay * 1.5, 30000);
11101
+ connect();
11102
+ }, reconnectDelay);
11103
+ }
11104
+
11105
+ // ── Message Handler ──
11106
+ //
11107
+ // Every case below names an event the orchestrator actually emits and the
11108
+ // broadcaster actually forwards. The names handled here are exactly
11109
+ // BROADCAST_EVENTS plus \`orchestrator:state\` and \`pong\`; the eight dead
11110
+ // names the previous version carried (\`state\`, \`task:started\`,
11111
+ // \`task-assigned\`, \`task:completed\`, \`task-failed\`, \`task-completed\`,
11112
+ // \`review-completed\`, \`status-update\`) were removed rather than left as
11113
+ // branches that could never run.
11114
+
11115
+ function handleMessage(msg) {
11116
+ var type = msg.type || msg.event || '';
11117
+ var data = asObject(msg.data) || {};
11118
+
11119
+ switch (type) {
11120
+ case 'orchestrator:state':
11121
+ // The server sends \`{type, data, timestamp}\`. If \`data\` is somehow not
11122
+ // an object, fall back rather than storing a primitive and reading
11123
+ // \`.config\` off it.
11124
+ lastState = asObject(msg.data) || asObject(msg.state) || asObject(msg) || {};
11125
+ lastStateReceivedAt = Date.now();
11126
+ lastStateUpdated = typeof lastState.lastUpdated === 'string' ? lastState.lastUpdated : null;
11127
+ renderState(lastState);
11128
+ renderSnapshotAge();
11129
+ break;
11130
+
11131
+ case 'pong':
11132
+ // Request/reply liveness. Not a state change, so it is not logged.
11133
+ break;
11134
+
11135
+ case 'agent:spawned':
11136
+ addLog('spawn', '🤖', 'Agent spawned: ' + agentLabel(data));
11137
+ addLogStream('success', 'SPAWN', 'Agent spawned: ' + agentLabel(data));
11138
+ break;
11139
+
11140
+ case 'agent:terminated':
11141
+ addLog('info', '🛑', 'Agent terminated: ' + agentLabel(data));
11142
+ addLogStream('warning', 'TERM', 'Agent terminated: ' + agentLabel(data));
11143
+ break;
11144
+
11145
+ case 'agent:escalation':
11146
+ addLog('error', '📣', 'Escalation: ' + escalationText(data));
11147
+ addLogStream('error', 'ESCALATE', escalationText(data));
11148
+ break;
11149
+
11150
+ case 'task:failed':
11151
+ addLog('error', '❌', 'Task failed: ' + taskLabel(data) + ' — ' + errorOf(data));
11152
+ addLogStream('error', 'FAIL', 'Task failed: ' + taskLabel(data) + ' — ' + errorOf(data));
11153
+ break;
11154
+
11155
+ case 'cost:delta':
11156
+ addLog('budget', '💲', costDeltaText(data));
11157
+ addLogStream(costDeltaSeverity(data), 'COST', costDeltaText(data));
11158
+ break;
11159
+
11160
+ case 'security:issues-found':
11161
+ addLog('error', '🛡️', 'Security: ' + securityText(data));
11162
+ addLogStream('error', 'SECURITY', securityText(data));
11163
+ break;
11164
+
11165
+ case 'memory:set':
11166
+ addLog('info', '🧠', 'Memory set: ' + memoryText(data));
11167
+ addLogStream('info', 'MEMORY', memoryText(data));
11168
+ break;
11169
+
11170
+ case 'budget:alert':
11171
+ addLog('budget', '⚠️', 'Budget alert: ' + fmt$(data.remaining) + ' remaining (' + fmtPct(data.remainingPercent) + ')');
11172
+ addLogStream('warning', 'BUDGET', 'Budget alert: ' + fmt$(data.remaining) + ' remaining (' + fmtPct(data.remainingPercent) + ')');
11173
+ break;
11174
+
11175
+ case 'budget:exceeded':
11176
+ addLog('error', '🚨', 'Budget exceeded. Spent: ' + fmt$(data.totalSpent));
11177
+ addLogStream('error', 'BUDGET', 'Budget exceeded. Spent: ' + fmt$(data.totalSpent));
11178
+ break;
11179
+
11180
+ case 'orchestrator:paused':
11181
+ addLog('system', '⏸️', 'Orchestrator paused');
11182
+ addLogStream('system', 'SYS', 'Orchestrator paused');
11183
+ break;
11184
+
11185
+ case 'orchestrator:resumed':
11186
+ addLog('system', '▶️', 'Orchestrator resumed');
11187
+ addLogStream('system', 'SYS', 'Orchestrator resumed');
11188
+ break;
11189
+
11190
+ case 'orchestrator:shutdown':
11191
+ addLog('system', '🛑', 'Orchestrator shutting down');
11192
+ addLogStream('system', 'SYS', 'Orchestrator shutting down');
11193
+ break;
11194
+
11195
+ case 'config:reloaded':
11196
+ addLog('system', '⚙️', 'Config reloaded: ' + configReloadText(data));
11197
+ addLogStream('system', 'CONFIG', 'Config reloaded: ' + configReloadText(data));
11198
+ break;
11199
+
11200
+ default:
11201
+ // No known handler. Logged verbatim rather than dropped, so a new server
11202
+ // event is visible instead of silently invisible.
11203
+ if (type) {
11204
+ var blob = JSON.stringify(msg.data === undefined ? msg : msg.data);
11205
+ if (blob.length > 150) blob = blob.slice(0, 150) + '…';
11206
+ addLog('info', '📩', type + ': ' + blob);
11207
+ addLogStream('info', type.toUpperCase().slice(0, 8), blob);
11208
+ }
11209
+ }
11210
+ }
11211
+
11212
+ // ── Event payload formatting ──
11213
+ // Each returns a string containing a dash rather than a number when the field
11214
+ // the server did not send is missing.
11215
+
11216
+ function agentLabel(d) {
11217
+ if (d.name) return String(d.name);
11218
+ if (d.role) return String(d.role);
11219
+ return String(d.id || 'unknown agent');
11220
+ }
11221
+
11222
+ function taskLabel(d) {
11223
+ if (d.taskName) return String(d.taskName);
11224
+ if (d.name) return String(d.name);
11225
+ if (d.taskId) return String(d.taskId);
11226
+ if (d.id) return String(d.id);
11227
+ return 'unknown task';
11228
+ }
11229
+
11230
+ function errorOf(d) {
11231
+ var e = text(d.error);
11232
+ return e === null ? 'no error message reported' : e;
11233
+ }
11234
+
11235
+ function escalationText(d) {
11236
+ return taskLabel(d) + ' on agent ' + (text(d.agentId) || 'unreported') + ' — ' + errorOf(d)
11237
+ + ' (retries and model fallback are exhausted; notifying)';
11238
+ }
11239
+
11240
+ /** \`SecurityIssue\` is \`{id, severity, category, message, file?, line?}\`. */
11241
+ function securityText(d) {
11242
+ var total = num(d.totalIssues);
11243
+ var head = taskLabel(d) + ': ' + (total === null ? 'issue count unreported' : total + ' issue' + (total === 1 ? '' : 's'));
11244
+ var issues = asArray(d.issues);
11245
+ if (issues.length === 0) return head;
11246
+ return head + ' — ' + issues.slice(0, 3).map(function(i) {
11247
+ var obj = asObject(i);
11248
+ if (obj === null) return String(i);
11249
+ var where = text(obj.file);
11250
+ if (where !== null) {
11251
+ where += num(obj.line) === null ? '' : ':' + fmtInt(obj.line);
11252
+ }
11253
+ return (text(obj.message) || text(obj.id) || 'issue')
11254
+ + ' [' + (text(obj.severity) || 'no severity')
11255
+ + (text(obj.category) === null ? '' : '/' + text(obj.category))
11256
+ + (where === null ? '' : ' at ' + where) + ']';
11257
+ }).join('; ') + (issues.length > 3 ? ' …' : '');
11258
+ }
11259
+
11260
+ function memoryText(d) {
11261
+ var key = text(d.key);
11262
+ return (key === null ? 'key unreported' : key)
11263
+ + ' (scope ' + (text(d.scope) || 'unreported') + ', author ' + (text(d.author) || 'unreported') + ')';
11264
+ }
11265
+
11266
+ /**
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.
11270
+ */
11271
+ function configReloadText(d) {
11272
+ var parts = [];
11273
+ var count = num(d.loadCount);
11274
+ parts.push(count === null ? 'load count unreported' : 'load #' + fmtInt(count));
11275
+ parts.push('loaded ' + fmtDateTime(d.loadedAt));
11276
+ if (typeof d.sessionOverride === 'boolean') {
11277
+ parts.push(d.sessionOverride ? 'session override layered on top of disk' : 'no session override');
11278
+ }
11279
+ var project = asObject(d.project);
11280
+ if (project !== null) parts.push('project ' + configFileText(project));
11281
+ var global = asObject(d.global);
11282
+ if (global !== null) parts.push('global ' + configFileText(global));
11283
+ return parts.join(' · ');
11284
+ }
11285
+
11286
+ function configFileText(info) {
11287
+ var existence = info.existed === true ? 'found'
11288
+ : info.existed === false ? 'absent'
11289
+ : 'existence unreported';
11290
+ var parsed = typeof info.parsed === 'boolean' ? (info.parsed ? ', parsed' : ', unparsed') : '';
11291
+ return (text(info.path) || 'path unreported') + ' (' + existence + parsed + ')';
11292
+ }
11293
+
11294
+ /**
11295
+ * \`cost:delta\` is the most informative event for sessions: it carries the
11296
+ * session id, the reason it settled, and the amount. The \`reason\` decides how
11297
+ * alarming the line is — \`abandoned\` means unbilled spend.
11298
+ */
11299
+ function costDeltaSeverity(d) {
11300
+ if (d.reason === 'abandoned') return 'error';
11301
+ var dc = num(d.deltaCost);
11302
+ if (dc === null || dc === 0) return 'info';
11303
+ return 'success';
11304
+ }
11305
+
11306
+ function costDeltaText(d) {
11307
+ var session = text(d.sessionID);
11308
+ var reason = text(d.reason);
11309
+ var delta = num(d.deltaCost);
11310
+ var head = 'Session ' + (session === null ? 'unreported' : session)
11311
+ + ' (' + taskLabel(d) + ')';
11312
+ if (reason === 'abandoned') {
11313
+ var uncollected = asObject(d.uncollected);
11314
+ var lower = uncollected === null ? null : num(uncollected.observedUncollected);
11315
+ return head + ' ABANDONED — stopped collecting while still running; at least '
11316
+ + fmt$(lower) + ' unbilled (lower bound)';
11317
+ }
11318
+ if (reason === 'shutdown') {
11319
+ return head + ' charged at teardown, settlement unverified: ' + fmt$(delta);
11320
+ }
11321
+ if (reason === 'session-idle') {
11322
+ return head + ' went idle; settled ' + fmt$(delta) + ' (session total '
11323
+ + fmt$(d.sessionTotalCost) + ')';
11324
+ }
11325
+ return head + ' ' + (reason === null ? 'reason unreported' : reason) + ': '
11326
+ + fmt$(delta) + ' (session total ' + fmt$(d.sessionTotalCost) + ')';
11327
+ }
11328
+
11329
+ // ── Render State ──
11330
+ function renderState(state) {
11331
+ if (!state) return;
11332
+
11333
+ var agents = asArray(state.agents);
11334
+ var tasks = asArray(state.tasks);
11335
+ var sessions = asArray(state.sessions);
11336
+ var config = asObject(state.config);
11337
+ var configBudget = config === null ? null : asObject(config.budget);
11338
+ var maxTotalCost = configBudget === null ? null : num(configBudget.maxTotalCost);
11339
+ var totalSpent = num(state.totalSpent);
11340
+ var budgetRemaining = num(state.budgetRemaining);
11341
+
11342
+ renderLifecycle(state);
11343
+
11344
+ // ── Overview Stats ──
11345
+ // The headline count is \`liveAgents.length\` too, not \`agents.length\`: the
11346
+ // sub-line now says "live agents only" and points at the Agents section,
11347
+ // so a total that included entries the page cannot render would contradict
11348
+ // the caveat printed directly beneath it.
11349
+ $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
+ // Both figures from \`liveAgents\`, not one from \`agents\`: the count beside
11353
+ // the total has to be arithmetic the reader can do in their head, and
11354
+ // \`agents.length - activeCount\` mixing the two lists made the sub-line
11355
+ // disagree with the "Live agents only" section it is summarising whenever a
11356
+ // null entry was present.
11357
+ //
11358
+ // The caveat stays IN this line, not only on the Agents section further
11359
+ // down. The number it qualifies is HERE, so a reader who stops at the
11360
+ // overview card must be told it is not a census — "3 working · 0 not
11361
+ // working" reads as a complete count of the fleet, and this stat sits on a
11362
+ // card of four, above the fold, long before the section that carries the
11363
+ // qualifier is reached.
11364
+ $statAgentsSub.textContent = activeCount + ' working · '
11365
+ + (liveAgents.length - activeCount) + ' not working (live agents only)';
11366
+
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
+ $statTasks.textContent = String(tasks.length);
11374
+ $statTasksSub.textContent = runningTasks + ' running/queued · ' + completedTasks + ' done'
11375
+ + (failedTasks > 0 ? ' · ' + failedTasks + ' failed' : '');
11376
+
11377
+ $statSpent.textContent = fmt$(totalSpent);
11378
+ $statSpentSub.textContent = maxTotalCost === null
11379
+ ? 'Budget ceiling not reported'
11380
+ : 'of ' + fmt$(maxTotalCost) + ' ceiling'
11381
+ + (totalSpent === null ? '' : ' · ' + (maxTotalCost > 0 ? ((totalSpent / maxTotalCost) * 100).toFixed(1) + '% consumed' : 'ceiling is 0'));
11382
+
11383
+ $statRemaining.textContent = fmt$(budgetRemaining);
11384
+ $statRemainingSub.textContent = remainingSub(maxTotalCost, totalSpent, budgetRemaining);
11385
+
11386
+ renderUnbilledStat();
11387
+
11388
+ // ── Sections ──
11389
+ renderSessions(sessions);
11390
+ renderAgents(agents, configBudget);
11391
+ renderBudget(configBudget, totalSpent, budgetRemaining);
11392
+ renderTasks(tasks);
11393
+ renderDag(tasks);
11394
+ renderCost();
11395
+ renderConfig(state);
11396
+ updateConfigViewer(state);
11397
+ }
11398
+
11399
+ function remainingSub(maxTotalCost, totalSpent, budgetRemaining) {
11400
+ if (maxTotalCost === null) return 'Ceiling not reported · no percentage shown';
11401
+ if (maxTotalCost <= 0) return 'Ceiling is $0.00 · percentage undefined';
11402
+ var base = num(budgetRemaining);
11403
+ if (base === null) return 'Ceiling ' + fmt$(maxTotalCost) + ' · remaining unreported';
11404
+ return ((base / maxTotalCost) * 100).toFixed(1) + '% of ' + fmt$(maxTotalCost) + ' ceiling';
11405
+ }
11406
+
11407
+ /**
11408
+ * The unbilled figure comes from \`/api/costs\`, never from a running sum over
11409
+ * \`state.sessions\`, and it is always presented as a lower bound. It is not
11410
+ * added to \`state.totalSpent\` on either side of this line.
11411
+ */
11412
+ function renderUnbilledStat() {
11413
+ if (costReportStatus !== 'ready' || costReport === null) {
11414
+ $statUnbilled.textContent = '—';
11415
+ $statUnbilledSub.textContent = costReportStatus === 'failed'
11416
+ ? 'Cost report unavailable'
11417
+ : 'Loading cost report…';
11418
+ return;
11419
+ }
11420
+ var uncollected = asObject(costReport.uncollected);
11421
+ if (uncollected === null) {
11422
+ $statUnbilled.textContent = '—';
11423
+ $statUnbilledSub.textContent = 'Report carries no uncollected block';
11424
+ return;
11425
+ }
11426
+ var lower = num(uncollected.observedUncollected);
11427
+ var count = num(uncollected.sessions);
11428
+ var evicted = asObject(uncollected.evicted);
11429
+ var evictedCount = evicted === null ? null : num(evicted.sessions);
11430
+
11431
+ $statUnbilled.textContent = lower === null ? '—' : '≥ ' + fmt$(lower);
11432
+ $statUnbilledSub.textContent = (count === null ? 'session count unreported' : count + ' session' + (count === 1 ? '' : 's'))
11433
+ + ' · lower bound, NOT in Spend'
11434
+ + evictedSuffix(evicted, evictedCount);
11435
+ }
11436
+
11437
+ /**
11438
+ * How a report's \`uncollected.evicted\` block is described, in THREE states.
11439
+ *
11440
+ * \`uncollectedSummary()\` always emits \`evicted\`, at zero, and it does so
11441
+ * deliberately: the block is emitted unconditionally precisely so a consumer
11442
+ * can tell "nothing was dropped" from "this build does not report drops".
11443
+ * Collapsing the two — the obvious \`evictedCount > 0 ? ... : ''\` — throws
11444
+ * that distinction away, and this is the one surface where that costs
11445
+ * something concrete. The page is where a user decides whether the number in
11446
+ * front of them is COMPLETE. Rendering an absent block as if it were a
11447
+ * present-and-zero block asserts "nothing was evicted" from a report that
11448
+ * never said so, which is the same silent-shrinking-number failure the block
11449
+ * exists to prevent — arriving from the other direction.
11450
+ *
11451
+ * absent — the server does not report evictions at all. Say so, rather
11452
+ * than letting a silent omission read as a clean bill.
11453
+ * zero — reported, and nothing was dropped. Said explicitly, because
11454
+ * "reported nothing" and "did not report" are different claims
11455
+ * and only one of them is true here.
11456
+ * positive — reported, and this many abandoned sessions left the itemised
11457
+ * list. Their money is in \`evicted\`, NOT in the headline figure.
11458
+ */
11459
+ function evictedSuffix(evicted, evictedCount) {
11460
+ if (evicted === null || evictedCount === null) {
11461
+ return ' · evictions NOT reported by this server';
11462
+ }
11463
+ if (evictedCount === 0) return ' · 0 evicted, list is complete';
11464
+ return ' · plus ' + evictedCount + ' evicted from the itemised list';
11465
+ }
11466
+
11467
+ // ── Sessions ──
11468
+ /** Null-safe: a malformed entry must not take the whole section down. */
11469
+ function sessionIsOrphan(s) {
11470
+ if (s === null || s === undefined) return false;
11471
+ return s.owned !== true || s.agentId === null;
11472
+ }
11473
+
11474
+ function sessionPassesFilter(s) {
11475
+ if (s === null || s === undefined) return false;
11476
+ if (sessionFilter === 'all') return true;
11477
+ if (sessionFilter === 'orphan') return sessionIsOrphan(s);
11478
+ if (sessionFilter === 'running') return s.state === 'running';
11479
+ if (sessionFilter === 'abandoned') return s.state === 'abandoned';
11480
+ return true;
11481
+ }
11482
+
11483
+ function setupSessionFilters() {
11484
+ var btns = document.querySelectorAll('.session-filter-btn');
11485
+ for (var i = 0; i < btns.length; i++) {
11486
+ btns[i].addEventListener('click', function() {
11487
+ sessionFilter = this.getAttribute('data-sfilter');
11488
+ for (var j = 0; j < btns.length; j++) btns[j].classList.remove('active');
11489
+ this.classList.add('active');
11490
+ renderSessions(lastState ? asArray(lastState.sessions) : []);
11491
+ });
11492
+ }
11493
+ }
11494
+
11495
+ function renderSessions(sessions) {
11496
+ sessions = asArray(sessions);
11497
+ $sessionCount.textContent = String(sessions.length);
11498
+ renderOrphanBanner(sessions);
11499
+
11500
+ if (sessions.length === 0) {
11501
+ $sessionsContainer.innerHTML = '<div class="empty-message">'
11502
+ + 'No sessions reported. This list covers sessions nexus owns, is still collecting from, '
11503
+ + 'or has abandoned — it is not an enumeration of every open session on the server.'
11504
+ + '</div>';
11505
+ return;
11506
+ }
11507
+
11508
+ // Orphans first: the row that matters must not be below the fold of a long
11509
+ // list of healthy sessions.
11510
+ var rows = sessions.slice().sort(function(a, b) {
11511
+ return sessionRank(b) - sessionRank(a);
11512
+ });
11513
+
11514
+ var shown = rows.filter(sessionPassesFilter);
11515
+ if (shown.length === 0) {
11516
+ $sessionsContainer.innerHTML = '<div class="empty-message">No sessions match this filter.</div>';
11517
+ return;
11518
+ }
11519
+
11520
+ var html = '<table class="session-table"><thead><tr>'
11521
+ + '<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>'
11523
+ + '</tr></thead><tbody>';
11524
+ for (var i = 0; i < shown.length; i++) {
11525
+ html += sessionRow(asObject(shown[i]));
11526
+ }
11527
+ html += '</tbody></table>';
11528
+ $sessionsContainer.innerHTML = html;
11529
+ }
11530
+
11531
+ function sessionRank(s) {
11532
+ if (s === null || s === undefined) return 0;
11533
+ if (s.state === 'abandoned') return 3;
11534
+ if (sessionIsOrphan(s) && s.state === 'running') return 2;
11535
+ if (sessionIsOrphan(s)) return 1;
11536
+ return 0;
11537
+ }
11538
+
11539
+ function sessionRow(s) {
11540
+ if (s === null) return '';
11541
+ var id = text(s.id);
11542
+ var isOrphan = sessionIsOrphan(s);
11543
+ var abandoned = s.state === 'abandoned';
11544
+ var classes = 'session-row' + (abandoned ? ' abandoned' : isOrphan ? ' orphan' : '');
11545
+ // 0 tokens means "never read", not "spent nothing". Say which.
11546
+ var tokens = num(s.lastKnownTokens);
11547
+ var unread = tokens === null || tokens === 0;
11548
+ if (unread) classes += ' unread';
11549
+
11550
+ var owner;
11551
+ if (isOrphan) {
11552
+ owner = '<span class="flag orphan">No owning agent</span>';
11553
+ } else {
11554
+ owner = '<span class="mono">' + escHtml(text(s.agentId) || 'unreported') + '</span>';
11555
+ }
11556
+
11557
+ var flags = '';
11558
+ if (abandoned) flags += ' <span class="flag abandoned">Abandoned</span>';
11559
+ if (unread) flags += ' <span class="flag unread" title="The server reports 0 when it has no reading for this session">No reading</span>';
11560
+
11561
+ var unbilled = num(s.observedUncollected);
11562
+
11563
+ return '<tr class="' + classes + '">'
11564
+ + '<td class="mono">' + escHtml(id === null ? 'unreported' : id) + flags + '</td>'
11565
+ + '<td>' + owner + '</td>'
11566
+ + '<td class="mono">' + escHtml(text(s.taskId) || '—') + '</td>'
11567
+ + '<td>' + escHtml(text(s.role) || '—') + '</td>'
11568
+ + '<td class="mono">' + escHtml(text(s.model) || '—') + '</td>'
11569
+ + '<td><span class="session-state ' + escHtml(text(s.state) || 'unknown') + '">'
11570
+ + escHtml(text(s.state) || 'unreported') + '</span></td>'
11571
+ + '<td class="num" title="The last token count actually observed for this session. Not a live reading.">'
11572
+ + (unread ? '—' : fmtInt(tokens)) + '</td>'
11573
+ + '<td class="num" title="Lower bound on what this session spent unbilled. A lower bound, not a total; excluded from Spend.">'
11574
+ + (unbilled === null || unbilled === 0 ? '—' : '≥ ' + fmt$(unbilled)) + '</td>'
11575
+ + '</tr>';
11576
+ }
11577
+
11578
+ /**
11579
+ * The section-level alarm. It fires on the specific combination that is the
11580
+ * point of the whole view: a session that is still running, has no owning
11581
+ * agent, and is therefore spending into nothing.
11582
+ */
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
+ }
11592
+
11593
+ if (leaking.length === 0 && abandoned === 0) {
11594
+ $orphanBanner.innerHTML = '';
11595
+ return;
11596
+ }
11597
+
11598
+ var parts = [];
11599
+ if (leaking.length > 0) {
11600
+ var ids = leaking.map(function(s) { return text(s.id) || 'unreported'; });
11601
+ parts.push(
11602
+ '<div class="orphan-banner">'
11603
+ + '<span class="icon">🔓</span><div>'
11604
+ + '<div class="title">' + leaking.length + ' running session' + (leaking.length === 1 ? '' : 's')
11605
+ + ' with no owning agent</div>'
11606
+ + '<div class="body">These sessions are still generating after the agent that owned '
11607
+ + '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>'
11610
+ + '</div></div>');
11611
+ }
11612
+ if (abandoned > 0) {
11613
+ parts.push(
11614
+ '<div class="orphan-banner" style="border-color: var(--red); background: rgba(248,81,73,0.10)">'
11615
+ + '<span class="icon">⛔</span><div>'
11616
+ + '<div class="title" style="color: var(--red)">' + abandoned + ' abandoned session'
11617
+ + (abandoned === 1 ? '' : 's') + '</div>'
11618
+ + '<div class="body">The plugin stopped watching these while they were still running. '
11619
+ + 'Their unbilled spend is a <strong>lower bound</strong> and is excluded from Spend.</div>'
11620
+ + '</div></div>');
11621
+ }
11622
+ $orphanBanner.innerHTML = parts.join('');
11623
+ }
11624
+
11625
+ // ── Agents ──
11626
+ function renderAgents(agents, configBudget) {
11627
+ $agentCount.textContent = String(agents.length);
11628
+ if (agents.length === 0) {
11629
+ $agentsContainer.innerHTML = '<div class="no-agents"><div class="icon">🤖</div>'
11630
+ + '<div class="msg">No agents spawned yet. Tasks will spawn agents automatically.</div></div>';
11631
+ return;
11632
+ }
11633
+
11634
+ // Normalised against the per-agent ceiling, not against the most expensive
11635
+ // agent: normalising to the max makes the top bar 100% by construction, so
11636
+ // the bars encode rank rather than budget consumption.
11637
+ var perAgentCap = configBudget === null ? null : num(configBudget.maxCostPerAgent);
11638
+
11639
+ var html = '<div class="agents-grid">';
11640
+ for (var i = 0; i < agents.length; i++) {
11641
+ var a = asObject(agents[i]);
11642
+ if (a === null) continue;
11643
+ var role = text(a.role);
11644
+ var emoji = (role === null ? null : ROLE_EMOJI[role]) || '🤖';
11645
+ var status = text(a.status) || 'unknown';
11646
+ var cost = num(a.totalCost);
11647
+ var barPct = null;
11648
+ if (cost !== null && perAgentCap !== null && perAgentCap > 0) {
11649
+ barPct = Math.min(100, (cost / perAgentCap) * 100);
11650
+ }
11651
+ var overCap = cost !== null && perAgentCap !== null && cost > perAgentCap;
11652
+ var barColor = overCap ? '#f85149' : (role === null ? '#58a6ff' : (ROLE_COLORS[role] || '#58a6ff'));
11653
+
11654
+ html += '<div class="agent-card fade-in">';
11655
+ html += '<div class="agent-header">';
11656
+ html += '<div class="agent-name"><span class="status-dot ' + escHtml(status) + '"></span>'
11657
+ + emoji + ' ' + escHtml(text(a.name) || role || 'unnamed agent') + '</div>';
11658
+ html += '<span class="agent-role-badge ' + escHtml(role || 'documenter') + '">'
11659
+ + escHtml(role || 'role unreported') + '</span>';
11660
+ html += '</div>';
11661
+
11662
+ // \`sessionID\`, not \`sessionId\` — the previous casing matched nothing the
11663
+ // server sends, so the session link below was dead code.
11664
+ var sessionId = text(a.sessionID);
11665
+ html += '<div class="agent-meta" title="Resolved model">📦 ' + escHtml(text(a.model) || 'model unreported') + '</div>';
11666
+ html += '<div class="agent-meta"><span>🔗</span><span class="mono">'
11667
+ + (sessionId === null ? 'no session id reported' : escHtml(sessionId)) + '</span></div>';
11668
+ html += '<div class="agent-meta" title="state.agents[].spawnedAt">🕑 spawned '
11669
+ + escHtml(fmtDateTime(a.spawnedAt)) + '</div>';
11670
+
11671
+ html += '<div class="agent-metrics">';
11672
+ html += metric('Tasks', num(a.tasksCompleted) === null ? '—' : fmtInt(a.tasksCompleted) + ' done');
11673
+ html += metric('Failed', fmtInt(a.tasksFailed));
11674
+ html += metric('Cost', fmt$(a.totalCost));
11675
+ html += metric('Tokens', fmtInt(a.totalTokens));
11676
+ html += metric('Avg resp', fmtMs(a.averageResponseTime));
11677
+ html += metric('Error rate', fmtPct(a.errorRate));
11678
+ html += '</div>';
11679
+
11680
+ html += '<div class="agent-cost-bar"><div class="agent-cost-fill" style="width:'
11681
+ + (barPct === null ? 0 : barPct.toFixed(1)) + '%;background:' + barColor + '"></div></div>';
11682
+ html += '<div class="bar-caption">'
11683
+ + (perAgentCap === null || perAgentCap <= 0
11684
+ ? 'Per-agent ceiling not reported — no bar scale'
11685
+ : fmt$(a.totalCost) + ' of ' + fmt$(perAgentCap) + ' per-agent ceiling'
11686
+ + (overCap ? ' · OVER CEILING' : ''))
11687
+ + '</div>';
11688
+ html += '</div>';
11689
+ }
11690
+ html += '</div>';
11691
+ $agentsContainer.innerHTML = html;
11692
+ }
11693
+
11694
+ function metric(label, value) {
11695
+ return '<div class="metric"><span class="metric-label">' + escHtml(label) + '</span>'
11696
+ + '<span class="metric-value">' + escHtml(value) + '</span></div>';
11697
+ }
11698
+
11699
+ // ── Budget ──
11700
+ function renderBudget(configBudget, totalSpent, budgetRemaining) {
11701
+ var ceiling = configBudget === null ? null : num(configBudget.maxTotalCost);
11702
+ var alertThreshold = configBudget === null ? null : num(configBudget.alertThreshold);
11703
+ var hardLimit = configBudget === null ? null : configBudget.hardLimit;
11704
+
11705
+ // The gauge is a share of the REPORTED CEILING. With no ceiling there is no
11706
+ // fraction to draw, so the ring is dimmed and the centre says '—' rather
11707
+ // than falling back to an invented total.
11708
+ var remainingFraction = null;
11709
+ if (ceiling !== null && ceiling > 0 && budgetRemaining !== null) {
11710
+ remainingFraction = Math.max(0, Math.min(1, budgetRemaining / ceiling));
11711
+ }
11712
+
11713
+ if (remainingFraction === null) {
11714
+ $gaugeRing.className = 'gauge-ring unmeasurable';
11715
+ $gaugeFill.className = 'fill';
11716
+ $gaugeFill.style.strokeDashoffset = String(CIRCUMFERENCE);
11717
+ $gaugePct.textContent = '—';
11718
+ } else {
11719
+ $gaugeRing.className = 'gauge-ring';
11720
+ $gaugePct.textContent = (remainingFraction * 100).toFixed(0) + '%';
11721
+ $gaugeFill.style.strokeDashoffset = String(CIRCUMFERENCE * (1 - remainingFraction));
11722
+ // Thresholds come from \`alertThreshold\` only. The previous 20%/40% bands
11723
+ // were invented here and had nothing to do with any configuration.
11724
+ $gaugeFill.className = 'fill'
11725
+ + (alertThreshold !== null && remainingFraction <= alertThreshold ? ' danger' : '');
11726
+ }
11727
+
11728
+ $budgetTotal.textContent = fmt$(ceiling);
11729
+ $budgetSpent.textContent = fmt$(totalSpent);
11730
+ $budgetRemaining.textContent = fmt$(budgetRemaining);
11731
+
11732
+ if (alertThreshold === null) {
11733
+ $budgetAlert.textContent = 'not reported';
11734
+ $budgetAlert.style.color = 'var(--text-muted)';
11735
+ } else {
11736
+ // Stated as what it is: the REMAINING fraction at or below which we alert.
11737
+ $budgetAlert.textContent = fmtPct(alertThreshold) + ' remaining';
11738
+ $budgetAlert.style.color = 'var(--yellow)';
11739
+ }
11740
+
11741
+ if (remainingFraction === null) {
11742
+ $budgetStatus.textContent = 'Unknown';
11743
+ $budgetStatus.style.color = 'var(--text-muted)';
11744
+ } else if (alertThreshold !== null && remainingFraction <= alertThreshold) {
11745
+ $budgetStatus.textContent = 'At alert threshold';
11746
+ $budgetStatus.style.color = 'var(--red)';
11747
+ } else if (typeof hardLimit === 'boolean' && hardLimit && remainingFraction === 0) {
11748
+ $budgetStatus.textContent = 'At ceiling';
11749
+ $budgetStatus.style.color = 'var(--red)';
11750
+ } else {
11751
+ $budgetStatus.textContent = 'Above alert threshold';
11752
+ $budgetStatus.style.color = 'var(--green)';
11753
+ }
11754
+ }
11755
+
11756
+ // ── Tasks ──
11757
+ function renderTasks(tasks) {
11758
+ if (tasks.length === 0) {
11759
+ $taskList.innerHTML = '<div class="empty-message">No tasks yet</div>';
11760
+ return;
11761
+ }
11762
+ var html = '';
11763
+ for (var i = 0; i < tasks.length; i++) {
11764
+ var t = asObject(tasks[i]);
11765
+ if (t === null) continue;
11766
+ var status = text(t.status) || 'unknown';
11767
+ var priority = text(t.priority);
11768
+
11769
+ html += '<div class="task-item ' + escHtml(status) + '">';
11770
+ html += '<div class="task-line">';
11771
+ html += '<div class="task-info">';
11772
+ html += '<span class="task-name" title="' + escHtml(text(t.id) || '') + '">'
11773
+ + escHtml(text(t.name) || text(t.id) || 'unnamed task') + '</span>';
11774
+ if (text(t.role) !== null) html += '<span class="task-role">' + escHtml(text(t.role)) + '</span>';
11775
+ if (priority !== null) {
11776
+ html += '<span class="task-chip priority-' + escHtml(priority) + '">' + escHtml(priority) + '</span>';
11777
+ }
11778
+ html += '</div>';
11779
+ // cost/tokensUsed are \`TaskResult\` fields, absent until a task has a
11780
+ // result. Rendered only when present — no \`$0.00\` for "not run yet".
11781
+ if (num(t.cost) !== null) html += '<span class="task-cost">' + fmt$(t.cost) + '</span>';
11782
+ if (num(t.tokensUsed) !== null) html += '<span class="task-cost">' + fmtInt(t.tokensUsed) + ' tok</span>';
11783
+ html += '<span class="task-status ' + escHtml(status) + '">' + escHtml(status) + '</span>';
11784
+ html += '</div>';
11785
+
11786
+ // assignedAgent: whose it is, and dependencies: what it waits on.
11787
+ var meta = [];
11788
+ var agent = text(t.assignedAgent);
11789
+ meta.push('agent: ' + (agent === null ? 'unassigned' : agent));
11790
+ var deps = Array.isArray(t.dependencies) ? t.dependencies : [];
11791
+ meta.push('deps: ' + (deps.length === 0 ? 'none reported' : deps.join(', ')));
11792
+ if (num(t.tokensUsed) !== null) meta.push('tokens: ' + fmtInt(t.tokensUsed));
11793
+ html += '<div class="task-meta">';
11794
+ for (var m = 0; m < meta.length; m++) {
11795
+ var isMono = meta[m].indexOf('agent:') === 0 || meta[m].indexOf('deps:') === 0;
11796
+ html += '<span class="' + (isMono ? 'mono' : '') + '">' + escHtml(meta[m]) + '</span>';
11797
+ }
11798
+ html += '</div>';
11799
+
11800
+ html += taskResultBlock(t);
11801
+ html += '</div>';
11802
+ }
11803
+ $taskList.innerHTML = html;
11804
+ }
8813
11805
 
8814
- // src/broadcast.ts
8815
- class StateBroadcaster {
8816
- clients = new Set;
8817
- orchestrator;
8818
- broadcastTimer = null;
8819
- throttleMs;
8820
- unsubscribers = [];
8821
- constructor(orchestrator, opts) {
8822
- this.orchestrator = orchestrator;
8823
- this.throttleMs = opts?.throttleMs ?? 1000;
8824
- this.setupEventListeners();
11806
+ /**
11807
+ * \`t.result\` is the only place a user can see WHY a task failed. Truncated to
11808
+ * 500 characters by the server, which the caption says rather than letting a
11809
+ * clipped output read as a whole one.
11810
+ */
11811
+ function taskResultBlock(t) {
11812
+ var result = asObject(t.result);
11813
+ if (result === null) return '';
11814
+
11815
+ var success = typeof result.success === 'boolean' ? result.success : null;
11816
+ var output = text(result.output);
11817
+ var err = text(result.error);
11818
+ var duration = num(result.duration);
11819
+ if (output === null && err === null && duration === null) return '';
11820
+
11821
+ var summary = success === true ? 'Result: success'
11822
+ : success === false ? 'Result: failed'
11823
+ : 'Result (success flag not reported)';
11824
+
11825
+ var body = '';
11826
+ if (err !== null) body += '<pre class="error">' + escHtml(err) + '</pre>';
11827
+ if (output !== null) {
11828
+ body += '<pre>' + escHtml(output) + '</pre>';
11829
+ if (output.length >= SERVER_OUTPUT_TRUNCATION) {
11830
+ body += '<div class="result-truncation">Output truncated to '
11831
+ + SERVER_OUTPUT_TRUNCATION + ' characters by the server.</div>';
11832
+ }
11833
+ }
11834
+ if (duration !== null) {
11835
+ body += '<div class="result-truncation">Duration: ' + escHtml(fmtMs(duration)) + '</div>';
11836
+ }
11837
+
11838
+ return '<details class="task-result"><summary>' + escHtml(summary) + '</summary>'
11839
+ + '<div>' + body + '</div></details>';
8825
11840
  }
8826
- addClient(ws) {
8827
- this.clients.add(ws);
8828
- this.sendTo(ws, {
8829
- type: "orchestrator:state",
8830
- data: this.orchestrator.getState(),
8831
- timestamp: new Date().toISOString()
8832
- });
11841
+
11842
+ // ── Task dependency graph ──
11843
+ //
11844
+ // The previous version topologically sorted the tasks and drew an arrow
11845
+ // between each consecutive pair, which renders as a linear pipeline whatever
11846
+ // the real graph is. This lays the tasks out in dependency LAYERS (longest
11847
+ // path from a root) and draws one arrow per REPORTED edge, so the picture is
11848
+ // the graph the server described or there is no picture.
11849
+ //
11850
+ // It is a truthful layout, not an optimal one: layers are assigned by
11851
+ // longest-path depth and within a layer tasks keep the order \`state.tasks\`
11852
+ // gave them, so edges may cross. A crossing-minimised ordering (Sugiyama or
11853
+ // barycentre) is not attempted.
11854
+
11855
+ function renderDag(tasks) {
11856
+ var all = tasks.filter(function(t) { return asObject(t) !== null; });
11857
+ // A task with no id cannot be a node: an edge could never address it, so as
11858
+ // a box it would look unconnected and imply "no dependencies". Counted and
11859
+ // reported below rather than drawn.
11860
+ var list = all.filter(function(t) { return typeof t.id === 'string' && t.id.length > 0; });
11861
+ var idless = all.length - list.length;
11862
+
11863
+ if (all.length === 0) {
11864
+ $dagCount.textContent = '0';
11865
+ $dagNote.textContent = '';
11866
+ $dagContainer.innerHTML = '<div class="dag-empty"><div class="icon">📊</div>'
11867
+ + '<div>No tasks in the graph yet.</div></div>';
11868
+ return;
11869
+ }
11870
+
11871
+ var byId = {};
11872
+ for (var i = 0; i < list.length; i++) byId[list[i].id] = list[i];
11873
+
11874
+ // Only edges whose source is actually in this state are drawn. An edge to a
11875
+ // task that is not in the snapshot is counted and reported, because drawing
11876
+ // nothing and saying nothing would read as "no dependencies".
11877
+ var known = {};
11878
+ var edges = [];
11879
+ var dangling = 0;
11880
+ var selfDep = 0;
11881
+ for (var j = 0; j < list.length; j++) {
11882
+ var t = list[j];
11883
+ known[t.id] = [];
11884
+ var deps = Array.isArray(t.dependencies) ? t.dependencies : [];
11885
+ for (var d = 0; d < deps.length; d++) {
11886
+ var dep = deps[d];
11887
+ // A task listing ITSELF is dropped from the graph, and counted rather
11888
+ // than skipped in silence: a self-edge can never be satisfied — the
11889
+ // task waits for itself — so it is a data-integrity fault in the same
11890
+ // class as the dangling refs and the cycles this note already reports,
11891
+ // and drawing nothing while saying nothing would read as "no
11892
+ // dependencies". It is deliberately NOT folded into \`dangling\`: the
11893
+ // task IS in the snapshot, so counting it there would misdescribe the
11894
+ // cause, and a reader chasing a bad id would never find it.
11895
+ if (typeof dep !== 'string') continue;
11896
+ if (dep === t.id) { selfDep++; continue; }
11897
+ if (Object.prototype.hasOwnProperty.call(byId, dep)) {
11898
+ known[t.id].push(dep);
11899
+ edges.push({ from: dep, to: t.id });
11900
+ } else {
11901
+ dangling++;
11902
+ }
11903
+ }
11904
+ }
11905
+
11906
+ // Cycle detection (Kahn). A cycle makes the dependency depth undefined; it
11907
+ // is reported rather than silently laid out as if it were a DAG.
11908
+ var inDegree = {};
11909
+ for (var k = 0; k < list.length; k++) inDegree[list[k].id] = known[list[k].id].length;
11910
+ var queue = [];
11911
+ for (var q = 0; q < list.length; q++) if (inDegree[list[q].id] === 0) queue.push(list[q].id);
11912
+ var acyclic = 0;
11913
+ while (queue.length > 0) {
11914
+ var current = queue.shift();
11915
+ acyclic++;
11916
+ for (var t2 = 0; t2 < list.length; t2++) {
11917
+ var tid = list[t2].id;
11918
+ if (known[tid].indexOf(current) === -1) continue;
11919
+ inDegree[tid]--;
11920
+ if (inDegree[tid] === 0) queue.push(tid);
11921
+ }
11922
+ }
11923
+ var cycleCount = list.length - acyclic;
11924
+
11925
+ // Longest-path layering, iterated to a fixed point. In a cyclic graph the
11926
+ // value stops being a depth; those tasks land in deep columns and the cycle
11927
+ // is reported below rather than drawn as a dependency.
11928
+ var layer = {};
11929
+ for (var pass = 0; pass <= list.length; pass++) {
11930
+ var changed = false;
11931
+ for (var p = 0; p < list.length; p++) {
11932
+ var pid = list[p].id;
11933
+ var want = 0;
11934
+ for (var pd = 0; pd < known[pid].length; pd++) {
11935
+ var depLayer = layer[known[pid][pd]];
11936
+ if (typeof depLayer !== 'number') continue;
11937
+ if (depLayer + 1 > want) want = depLayer + 1;
11938
+ }
11939
+ if (layer[pid] !== want) { layer[pid] = want; changed = true; }
11940
+ }
11941
+ if (!changed) break;
11942
+ }
11943
+
11944
+ var maxLayer = 0;
11945
+ for (var m = 0; m < list.length; m++) {
11946
+ if (layer[list[m].id] > maxLayer) maxLayer = layer[list[m].id];
11947
+ }
11948
+ var columns = [];
11949
+ for (var c = 0; c <= maxLayer; c++) columns.push([]);
11950
+ for (var s = 0; s < list.length; s++) columns[layer[list[s].id]].push(list[s]);
11951
+
11952
+ $dagCount.textContent = list.length
11953
+ + (idless > 0 ? ' of ' + all.length : '')
11954
+ + (columns.length > 1 ? ' · ' + columns.length + ' layers' : '');
11955
+
11956
+ // ── Note under the header: what is and is not drawn. ──
11957
+ var notes = [];
11958
+ notes.push('Columns are <strong>dependency layers</strong> (longest path from a task with no dependencies). '
11959
+ + 'Every arrow is a dependency <code>state.tasks[].dependencies</code> edge the server reported. '
11960
+ + 'Layout is longest-path layering, not crossing-minimised, so arrows may cross.');
11961
+ if (edges.length === 0) {
11962
+ notes.push('<strong>No task reports any dependency</strong> on another task in this snapshot, '
11963
+ + 'so there is no graph to draw and the tasks below are shown by status only, in no implied order.');
11964
+ }
11965
+ if (cycleCount > 0) {
11966
+ notes.push('<strong style="color: var(--red)">' + cycleCount + ' task' + (cycleCount === 1 ? '' : 's')
11967
+ + ' sit in a dependency cycle.</strong> Layer depth is undefined for a cycle; those columns are a '
11968
+ + 'fallback placement, not a dependency.');
11969
+ }
11970
+ if (dangling > 0) {
11971
+ notes.push('<strong>' + dangling + ' dependency reference' + (dangling === 1 ? '' : 's')
11972
+ + ' points at a task id that is not in this snapshot</strong> and ' + (dangling === 1 ? 'is' : 'are')
11973
+ + ' not drawn. Either the task has left \`state.tasks\` or the id never matched.');
11974
+ }
11975
+ if (idless > 0) {
11976
+ notes.push('<strong>' + idless + ' task' + (idless === 1 ? '' : 's')
11977
+ + ' report no \`id\`</strong> and cannot be laid out, because an edge has nothing to address. '
11978
+ + 'They are excluded from the graph and from the count above.');
11979
+ }
11980
+ if (selfDep > 0) {
11981
+ notes.push('<strong style="color: var(--red)">' + selfDep + ' task' + (selfDep === 1 ? '' : 's')
11982
+ + ' list themselves as a dependency.</strong> A task cannot be its own '
11983
+ + 'prerequisite, so that edge is dropped and never drawn. It is a fault in the task data '
11984
+ + 'rather than a missing task, and it will stall the task rather than fail it.');
11985
+ }
11986
+ $dagNote.innerHTML = notes.join('<br>');
11987
+
11988
+ // ── Nodes, one column per layer. ──
11989
+ var html = '<div class="dag-canvas"><svg class="dag-edges" aria-hidden="true"></svg><div class="dag-layers">';
11990
+ for (var col = 0; col < columns.length; col++) {
11991
+ html += '<div class="dag-layer"><div class="dag-layer-index">L' + col + '</div>';
11992
+ for (var n = 0; n < columns[col].length; n++) {
11993
+ html += dagNode(columns[col][n], known);
11994
+ }
11995
+ html += '</div>';
11996
+ }
11997
+ html += '</div></div>';
11998
+ $dagContainer.innerHTML = html;
11999
+
12000
+ drawDagEdges(edges, byId, cycleCount);
8833
12001
  }
8834
- removeClient(ws) {
8835
- this.clients.delete(ws);
12002
+
12003
+ function dagNode(t, known) {
12004
+ var status = text(t.status) || 'unknown';
12005
+ var deps = known[t.id];
12006
+ return '<div class="dag-node" data-dag-id="' + escHtml(t.id) + '">'
12007
+ + '<div class="dag-node-box ' + escHtml(status) + '" title="' + escHtml(t.name || t.id) + '">'
12008
+ + escHtml(text(t.name) || text(t.id) || 'unnamed task') + '</div>'
12009
+ + '<div class="dag-node-label">' + escHtml(status) + '</div>'
12010
+ + '<div class="dag-node-role">' + escHtml(text(t.role) || 'role unreported') + '</div>'
12011
+ + '<div class="dag-node-deps">' + (deps.length === 0 ? 'no deps' : '← ' + deps.length + ' dep' + (deps.length === 1 ? '' : 's')) + '</div>'
12012
+ + '</div>';
8836
12013
  }
8837
- setupEventListeners() {
8838
- const orch = this.orchestrator;
8839
- const eventNames = [
8840
- "agent:spawned",
8841
- "agent:terminated",
8842
- "budget:alert",
8843
- "budget:exceeded"
8844
- ];
8845
- for (const eventName of eventNames) {
8846
- const unsub = orch.on?.(eventName, (data) => {
8847
- this.broadcast(eventName, data);
8848
- });
8849
- if (unsub)
8850
- this.unsubscribers.push(unsub);
12014
+
12015
+ /**
12016
+ * Draws the reported edges over the layer columns. Edges whose two endpoints
12017
+ * ended up in the same or inverted columns (only possible in a cycle) are
12018
+ * counted and skipped rather than drawn as a misleading backwards arrow.
12019
+ */
12020
+ function drawDagEdges(edges, byId, cycleCount) {
12021
+ var canvas = $dagContainer.querySelector('.dag-canvas');
12022
+ var svg = $dagContainer.querySelector('.dag-edges');
12023
+ if (canvas === null || svg === null) return;
12024
+
12025
+ var width = canvas.offsetWidth;
12026
+ var height = canvas.offsetHeight;
12027
+ if (width === 0 || height === 0) return;
12028
+ svg.setAttribute('width', String(width));
12029
+ svg.setAttribute('height', String(height));
12030
+ svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
12031
+
12032
+ var port = {};
12033
+ var boxes = canvas.querySelectorAll('.dag-node-box');
12034
+ for (var i = 0; i < boxes.length; i++) {
12035
+ var node = boxes[i].parentNode;
12036
+ var id = node.getAttribute('data-dag-id');
12037
+ port[id] = {
12038
+ x1: boxes[i].offsetLeft + boxes[i].offsetWidth,
12039
+ y1: boxes[i].offsetTop + boxes[i].offsetHeight / 2,
12040
+ x2: boxes[i].offsetLeft,
12041
+ y2: boxes[i].offsetTop + boxes[i].offsetHeight / 2
12042
+ };
12043
+ }
12044
+
12045
+ var paths = '';
12046
+ var skipped = 0;
12047
+ for (var e = 0; e < edges.length; e++) {
12048
+ // \`Object.prototype\` has keys like \`toString\`, so an id that collided with
12049
+ // one would otherwise resolve to a truthy inherited property and draw
12050
+ // from a number instead of a port.
12051
+ if (!Object.prototype.hasOwnProperty.call(port, edges[e].from)
12052
+ || !Object.prototype.hasOwnProperty.call(port, edges[e].to)) {
12053
+ skipped++;
12054
+ continue;
12055
+ }
12056
+ var from = port[edges[e].from];
12057
+ var to = port[edges[e].to];
12058
+ if (to.x2 <= from.x1) { skipped++; continue; }
12059
+ var source = asObject(byId[edges[e].from]);
12060
+ var sourceStatus = source === null ? null : source.status;
12061
+ var cls = sourceStatus === 'completed' ? 'edge-satisfied'
12062
+ : sourceStatus === 'running' ? 'edge-active'
12063
+ : 'edge-pending';
12064
+ var dx = Math.max(24, (to.x2 - from.x1) / 2);
12065
+ paths += '<path class="' + cls + '" d="M ' + from.x1 + ' ' + from.y1
12066
+ + ' C ' + (from.x1 + dx) + ' ' + from.y1
12067
+ + ', ' + (to.x2 - dx) + ' ' + to.y2
12068
+ + ', ' + to.x2 + ' ' + to.y2 + '"></path>';
12069
+ paths += '<polygon class="arrowhead" points="'
12070
+ + to.x2 + ',' + (to.y2 - 3) + ' ' + (to.x2 - 5) + ',' + to.y2 + ' ' + (to.x2) + ',' + (to.y2 + 3)
12071
+ + '" style="fill:' + (cls === 'edge-satisfied' ? 'var(--green)' : cls === 'edge-active' ? 'var(--blue)' : 'var(--border)') + '"></polygon>';
12072
+ }
12073
+ svg.innerHTML = paths;
12074
+
12075
+ if (skipped > 0) {
12076
+ $dagNote.innerHTML += '<br><strong>' + skipped + ' edge' + (skipped === 1 ? '' : 's')
12077
+ + ' could not be drawn</strong>'
12078
+ + (cycleCount > 0 ? ' because its two endpoints are not in strict dependency order (see the cycle warning above).'
12079
+ : ' because an endpoint is not laid out.');
8851
12080
  }
8852
12081
  }
8853
- broadcastState() {
8854
- if (this.broadcastTimer)
12082
+
12083
+ // ── Cost ──
12084
+ //
12085
+ // \`state.agents\` is LIVE ONLY — \`terminateAgent\` deletes the agent, so its
12086
+ // cost disappears from the agent list while the orchestrator still holds it.
12087
+ // Everything below therefore comes from /api/costs, which covers the whole
12088
+ // history, and the agent grid above says so rather than implying otherwise.
12089
+
12090
+ function renderCost() {
12091
+ if (costReportStatus !== 'ready' || costReport === null) {
12092
+ $costByAgent.innerHTML = '<div class="cost-chart-title">By Agent</div>'
12093
+ + '<div class="empty-message" style="padding:8px">No cost report yet</div>';
12094
+ $costByModel.innerHTML = '<div class="cost-chart-title">By Model</div>'
12095
+ + '<div class="empty-message" style="padding:8px">No cost report yet</div>';
12096
+ $costAccounting.innerHTML = '<div class="cost-chart-title">Accounting</div>'
12097
+ + '<div class="empty-message" style="padding:8px">No cost report yet</div>';
12098
+ $costBasis.textContent = '';
12099
+
12100
+ if (costReportStatus === 'failed') {
12101
+ $costStatus.className = 'cost-status';
12102
+ $costStatus.textContent = 'Cost report unavailable: /api/costs failed (' + costReportError
12103
+ + '). These bars are not shown rather than shown as zero.';
12104
+ } else {
12105
+ $costStatus.className = 'cost-status hidden';
12106
+ $costStatus.textContent = '';
12107
+ }
8855
12108
  return;
8856
- this.broadcastTimer = setTimeout(() => {
8857
- this.broadcastTimer = null;
8858
- this.broadcast("orchestrator:state", this.orchestrator.getState());
8859
- }, this.throttleMs);
12109
+ }
12110
+
12111
+ $costStatus.className = 'cost-status hidden';
12112
+ $costStatus.textContent = '';
12113
+
12114
+ var ceiling = null;
12115
+ if (lastState) {
12116
+ var cfg = asObject(lastState.config);
12117
+ var budget = cfg === null ? null : asObject(cfg.budget);
12118
+ if (budget !== null) ceiling = num(budget.maxTotalCost);
12119
+ }
12120
+
12121
+ // Bars are normalised against the BUDGET CEILING, so a bar's length is a
12122
+ // share of the money available, not a rank among the bars. Falling back to
12123
+ // share-of-total (labelled) only when there is no ceiling to divide by.
12124
+ var useCeiling = ceiling !== null && ceiling > 0;
12125
+ var basisText = useCeiling
12126
+ ? 'Bar length is each entry\\'s cost as a share of the ' + fmt$(ceiling)
12127
+ + ' budget ceiling — so a full bar means "this entry alone spent the '
12128
+ + 'whole budget". Source: <code>/api/costs</code>, which covers the full '
12129
+ + 'history, not only the live agents listed above.'
12130
+ : 'Budget ceiling not reported, so bar length is each entry\\'s share of the '
12131
+ + 'sum of the entries shown — relative size only, not budget consumption. '
12132
+ + 'Source: <code>/api/costs</code>, which covers the full history.';
12133
+ // \`byAgent\` and \`byModel\` are per-dimension sums, and neither is required to
12134
+ // equal \`Total spent\`: a timeout-delta settlement attributes its cost to an
12135
+ // agent id, while the pre-settlement charge already counted in \`Total spent\`.
12136
+ // The chart totals are therefore labelled as the sum of what is shown, not as
12137
+ // the run's total, so a discrepancy reads as what it is.
12138
+ basisText += ' Each chart\\'s total is the sum of the entries in that chart, which is not '
12139
+ + 'necessarily <code>Total spent</code> — the two are different accounting cuts of the same ledger.';
12140
+ // Static text with no interpolation, so innerHTML is safe here and keeps the
12141
+ // <code> markup readable.
12142
+ $costBasis.innerHTML = basisText;
12143
+
12144
+ // Agent names, for the ids the report is keyed by. An agent that has been
12145
+ // terminated is no longer in \`state.agents\`, so its id is shown on its own
12146
+ // rather than guessed at.
12147
+ var nameById = {};
12148
+ if (lastState) {
12149
+ var live = asArray(lastState.agents);
12150
+ for (var i = 0; i < live.length; i++) {
12151
+ var a = asObject(live[i]);
12152
+ if (a !== null && typeof a.id === 'string') nameById[a.id] = a;
12153
+ }
12154
+ }
12155
+
12156
+ var byAgent = asObject(costReport.byAgent) || {};
12157
+ var agentKeys = Object.keys(byAgent);
12158
+ agentKeys.sort(function(a, b) { return num(byAgent[b]) - num(byAgent[a]); });
12159
+ $costByAgent.innerHTML = costBars('By Agent', agentKeys, byAgent, useCeiling ? ceiling : null,
12160
+ function(key) {
12161
+ // \`nameById[key]\` is \`undefined\`, not \`null\`, for an agent the
12162
+ // orchestrator has since deleted — which is most of them, and exactly
12163
+ // the ones this chart exists to keep visible.
12164
+ var a = nameById[key];
12165
+ if (a === undefined || a === null) return key + ' (not a live agent)';
12166
+ var role = text(a.role);
12167
+ var emoji = role === null ? null : ROLE_EMOJI[role];
12168
+ return (emoji === null ? null : emoji + ' ') + key + ' · ' + (text(a.name) || key);
12169
+ });
12170
+
12171
+ var byModel = asObject(costReport.byModel) || {};
12172
+ var modelKeys = Object.keys(byModel);
12173
+ modelKeys.sort(function(a, b) { return num(byModel[b]) - num(byModel[a]); });
12174
+ var tokensByModel = asObject(costReport.tokensByModel) || {};
12175
+ $costByModel.innerHTML = costBars('By Model', modelKeys, byModel, useCeiling ? ceiling : null,
12176
+ function(key) { return key; },
12177
+ function(key) { return fmtInt(tokensByModel[key]) + ' tok'; });
12178
+
12179
+ renderCostAccounting();
8860
12180
  }
8861
- broadcast(event, data) {
8862
- const message = JSON.stringify({
8863
- type: event,
8864
- data,
8865
- timestamp: new Date().toISOString()
8866
- });
8867
- for (const client of this.clients) {
8868
- try {
8869
- client.send(message);
8870
- } catch {
8871
- this.clients.delete(client);
12181
+
12182
+ /**
12183
+ * One bar chart. \`divisor\` is the budget ceiling when there is one; the caller
12184
+ * says so in the caption, so the reader always knows what a bar length means.
12185
+ * The footer sums what is on screen and calls it that, because a per-dimension
12186
+ * sum is not the run's \`totalSpent\` and the two are allowed to disagree.
12187
+ */
12188
+ function costBars(title, keys, source, divisor, labelFor, valueSuffixFor) {
12189
+ var head = '<div class="cost-chart-title">' + escHtml(title) + '</div>';
12190
+ if (keys.length === 0) {
12191
+ return head + '<div class="empty-message" style="padding:8px">Nothing recorded yet</div>';
12192
+ }
12193
+
12194
+ var sum = 0;
12195
+ for (var i = 0; i < keys.length; i++) sum += num(source[keys[i]]) || 0;
12196
+
12197
+ var html = head;
12198
+ for (var k = 0; k < keys.length; k++) {
12199
+ var key = keys[k];
12200
+ var value = num(source[key]);
12201
+ var widthPct;
12202
+ if (value === null) {
12203
+ widthPct = 0;
12204
+ } else if (divisor !== null) {
12205
+ widthPct = Math.max(0, Math.min(100, (value / divisor) * 100));
12206
+ } else {
12207
+ widthPct = sum > 0 ? (value / sum) * 100 : 0;
12208
+ }
12209
+ html += '<div class="cost-bar-row">'
12210
+ + '<span class="cost-bar-label" title="' + escHtml(labelFor(key)) + '">' + escHtml(labelFor(key)) + '</span>'
12211
+ + '<div class="cost-bar-track"><div class="cost-bar-fill" style="width:'
12212
+ + widthPct.toFixed(1) + '%;background:' + BAR_COLOR_PALETTE[k % BAR_COLOR_PALETTE.length] + '"></div></div>'
12213
+ + '<span class="cost-bar-value">' + escHtml(fmt$(value))
12214
+ + (valueSuffixFor === undefined ? '' : '<span style="color:var(--text-muted);font-weight:400"> '
12215
+ + escHtml(valueSuffixFor(key)) + '</span>')
12216
+ + '</span>'
12217
+ + '</div>';
12218
+ }
12219
+ html += '<div class="cost-total-row"><span class="cost-total-label">Sum of these entries</span>'
12220
+ + '<span class="cost-total-value">' + escHtml(fmt$(sum)) + '</span></div>';
12221
+ return html;
12222
+ }
12223
+
12224
+ /**
12225
+ * The measured/estimated split and the uncollected bound, side by side,
12226
+ * because \`totalSpent\` on its own is not a fully billed figure and adding the
12227
+ * lower bound to it would produce a third thing that is neither.
12228
+ */
12229
+ function renderCostAccounting() {
12230
+ var report = asObject(costReport);
12231
+ if (report === null) return;
12232
+
12233
+ var measured = num(report.measuredSpend);
12234
+ var estimated = num(report.estimatedSpend);
12235
+ var measuredEntries = num(report.measuredEntries);
12236
+ var estimatedEntries = num(report.estimatedEntries);
12237
+ var uncollected = asObject(report.uncollected);
12238
+
12239
+ var html = '<div class="cost-chart-title">Accounting</div>';
12240
+ html += accountingRow('Measured', fmt$(measured));
12241
+ html += accountingRow('Estimated', fmt$(estimated));
12242
+ html += accountingRow('Total spent', fmt$(report.totalSpent));
12243
+ html += accountingRow('Budget remaining', fmt$(report.budgetRemaining));
12244
+ html += accountingRow('Priced entries', fmtInt(measuredEntries) + ' measured · ' + fmtInt(estimatedEntries) + ' estimated');
12245
+ html += accountingRow('Tokens by model', String(Object.keys(asObject(report.tokensByModel) || {}).length) + ' models');
12246
+
12247
+ if (uncollected === null) {
12248
+ html += accountingRow('Unbilled', 'no uncollected block in report', 'unbilled');
12249
+ } else {
12250
+ var count = num(uncollected.sessions);
12251
+ var lower = num(uncollected.observedUncollected);
12252
+ var evicted = asObject(uncollected.evicted);
12253
+ var evictedCount = evicted === null ? null : num(evicted.sessions);
12254
+ html += accountingRow('Unbilled (≥, excluded from Total spent)',
12255
+ (lower === null ? '—' : fmt$(lower)) + ' across '
12256
+ + (count === null ? 'an unreported number of' : String(count)) + ' session' + (count === 1 ? '' : 's'),
12257
+ 'unbilled');
12258
+ // The same three-way distinction the stat card makes, as its own row
12259
+ // rather than a suffix — see \`evictedSuffix\`. An absent block is stated,
12260
+ // not rendered as a clean zero.
12261
+ if (evicted === null || evictedCount === null) {
12262
+ html += accountingRow(' of which evicted from the itemised list',
12263
+ 'not reported by this server', 'unbilled');
12264
+ } else if (evictedCount === 0) {
12265
+ html += accountingRow(' of which evicted from the itemised list',
12266
+ '0 — the itemised list is complete', 'unbilled');
12267
+ } else {
12268
+ html += accountingRow(' of which evicted from the itemised list',
12269
+ fmt$(evicted.observedUncollected) + ' across ' + evictedCount + ' session' + (evictedCount === 1 ? '' : 's'),
12270
+ 'unbilled');
8872
12271
  }
8873
12272
  }
12273
+
12274
+ html += '<div class="accounting-note">'
12275
+ + 'Total spent is the orchestrator\\'s billed figure and is what the budget is checked against. '
12276
+ + 'The unbilled row is a <strong>lower bound</strong> on what abandoned sessions spent without '
12277
+ + 'being charged: it is the priced increment observed but not billed, and a session abandoned while '
12278
+ + 'still generating keeps spending past that reading, so the true under-count is larger by an unknown '
12279
+ + 'amount. It is deliberately not added to Total spent.'
12280
+ + '</div>';
12281
+
12282
+ $costAccounting.innerHTML = html;
8874
12283
  }
8875
- sendTo(ws, data) {
8876
- try {
8877
- ws.send(JSON.stringify(data));
8878
- } catch {
8879
- this.clients.delete(ws);
12284
+
12285
+ function accountingRow(key, value, valueClass) {
12286
+ return '<div class="accounting-row"><span class="k">' + escHtml(key) + '</span>'
12287
+ + '<span class="v ' + (valueClass || '') + '">' + escHtml(value) + '</span></div>';
12288
+ }
12289
+
12290
+ // ── Config panel (read-only, real key names) ──
12291
+ function renderConfig(state) {
12292
+ var config = asObject(state.config);
12293
+ if (config === null) {
12294
+ $configModels.innerHTML = '<div class="config-row"><span class="config-key">models</span>'
12295
+ + '<span class="config-val unreported">state.config not reported</span></div>';
12296
+ $configBudget.innerHTML = '<div class="config-row"><span class="config-key">budget</span>'
12297
+ + '<span class="config-val unreported">state.config not reported</span></div>';
12298
+ $configHealing.innerHTML = '<div class="config-row"><span class="config-key">selfHealing</span>'
12299
+ + '<span class="config-val unreported">state.config not reported</span></div>';
12300
+ return;
12301
+ }
12302
+
12303
+ // Models per role: the resolved map's own keys, not a hardcoded role list.
12304
+ // A configured role the list below did not think of still shows up.
12305
+ var models = asObject(config.models);
12306
+ if (models === null) {
12307
+ $configModels.innerHTML = '<div class="config-row"><span class="config-key">models</span>'
12308
+ + '<span class="config-val unreported">not reported</span></div>';
12309
+ } else {
12310
+ var roleKeys = Object.keys(models).sort();
12311
+ if (roleKeys.length === 0) {
12312
+ $configModels.innerHTML = '<div class="config-row"><span class="config-key">models</span>'
12313
+ + '<span class="config-val unreported">empty</span></div>';
12314
+ } else {
12315
+ var mhtml = '';
12316
+ for (var r = 0; r < roleKeys.length; r++) {
12317
+ var model = text(models[roleKeys[r]]);
12318
+ var emoji = ROLE_EMOJI[roleKeys[r]] || '🤖';
12319
+ mhtml += configRow(emoji + ' ' + roleKeys[r],
12320
+ model === null ? 'not reported' : model,
12321
+ model === null ? 'unreported' : 'model');
12322
+ }
12323
+ $configModels.innerHTML = mhtml;
12324
+ }
12325
+ }
12326
+
12327
+ // Budget: the real keys, none of the invented ones.
12328
+ var budget = asObject(config.budget);
12329
+ if (budget === null) {
12330
+ $configBudget.innerHTML = '<div class="config-row"><span class="config-key">budget</span>'
12331
+ + '<span class="config-val unreported">not reported</span></div>';
12332
+ } else {
12333
+ var bhtml = '';
12334
+ bhtml += configRow('maxTotalCost', fmt$(num(budget.maxTotalCost)));
12335
+ bhtml += configRow('maxCostPerTask', fmt$(num(budget.maxCostPerTask)));
12336
+ bhtml += configRow('maxCostPerAgent', fmt$(num(budget.maxCostPerAgent)));
12337
+ // Stated as the remaining fraction it is, not as "critical at X% spent".
12338
+ var alert = fmtPct(num(budget.alertThreshold));
12339
+ bhtml += configRow('alertThreshold', alert === '—' ? 'not reported' : alert + ' remaining', 'warning');
12340
+ // \`hardLimit\` is the real key and the opposite sense to \`autoTerminate\`:
12341
+ // true means STOP at the ceiling. Labelled that way.
12342
+ var hard = boolText(budget.hardLimit);
12343
+ bhtml += configRow('hardLimit (stop at ceiling)', hard.text, hard.cls);
12344
+ $configBudget.innerHTML = bhtml;
12345
+ }
12346
+
12347
+ // Self-healing: the real keys only.
12348
+ var healing = asObject(config.selfHealing);
12349
+ if (healing === null) {
12350
+ $configHealing.innerHTML = '<div class="config-row"><span class="config-key">selfHealing</span>'
12351
+ + '<span class="config-val unreported">not reported</span></div>';
12352
+ } else {
12353
+ var hhtml = '';
12354
+ var enabled = boolText(healing.enabled);
12355
+ hhtml += configRow('enabled', enabled.text, enabled.cls);
12356
+ hhtml += configRow('maxRetries', fmtInt(num(healing.maxRetries)));
12357
+ hhtml += configRow('retryDelay', fmtMs(num(healing.retryDelay)));
12358
+ hhtml += configRow('backoffMultiplier', num(healing.backoffMultiplier) === null
12359
+ ? 'not reported'
12360
+ : '× ' + num(healing.backoffMultiplier).toFixed(2));
12361
+ var transfer = boolText(healing.contextTransfer);
12362
+ hhtml += configRow('contextTransfer', transfer.text, transfer.cls);
12363
+ $configHealing.innerHTML = hhtml;
8880
12364
  }
8881
12365
  }
8882
- getClientCount() {
8883
- return this.clients.size;
12366
+
12367
+ function configRow(key, value, valueClass) {
12368
+ return '<div class="config-row"><span class="config-key">' + escHtml(key) + '</span>'
12369
+ + '<span class="config-val ' + (valueClass || '') + '">' + escHtml(value) + '</span></div>';
8884
12370
  }
8885
- destroy() {
8886
- if (this.broadcastTimer) {
8887
- clearTimeout(this.broadcastTimer);
8888
- this.broadcastTimer = null;
12371
+
12372
+ // ── Resolved config viewer ──
12373
+ //
12374
+ // Was an editor with an Apply button that sent \`{type:'config:update'}\`. The
12375
+ // server handles only \`ping\` and \`getState\`, so the button reported success
12376
+ // and nothing happened. There is no auth story for writes and the socket is
12377
+ // localhost with CORS \`*\`, so no write path was added here either: the field
12378
+ // is \`readonly\` and says so, and the Apply control is gone.
12379
+ function updateConfigViewer(state) {
12380
+ var config = asObject(state.config);
12381
+ if (config === null) {
12382
+ $configEditor.value = '';
12383
+ $configEditorStatus.textContent = 'Read-only: this server accepts no configuration writes. '
12384
+ + 'No configuration has been reported yet.';
12385
+ $configEditorStatus.className = 'config-status';
12386
+ return;
8889
12387
  }
8890
- for (const unsub of this.unsubscribers) {
8891
- unsub();
12388
+ var serialised;
12389
+ try {
12390
+ serialised = JSON.stringify(config, null, 2);
12391
+ } catch (e) {
12392
+ $configEditor.value = '';
12393
+ $configEditorStatus.textContent = 'Read-only: the reported config could not be serialised: ' + errorText(e);
12394
+ $configEditorStatus.className = 'config-status error';
12395
+ return;
8892
12396
  }
8893
- this.unsubscribers = [];
8894
- this.clients.clear();
12397
+ $configEditor.value = serialised;
12398
+ $configEditorStatus.textContent = 'Read-only: this is the config the orchestrator reports as in force. '
12399
+ + 'It is the resolved config (defaults → global → project → session override), not the file on disk. '
12400
+ + 'This server accepts no configuration writes, so there is nothing to apply.';
12401
+ $configEditorStatus.className = 'config-status';
8895
12402
  }
8896
- }
12403
+
12404
+ $configFormatBtn.addEventListener('click', function() {
12405
+ try {
12406
+ var parsed = JSON.parse($configEditor.value);
12407
+ $configEditor.value = JSON.stringify(parsed, null, 2);
12408
+ $configEditorStatus.textContent = 'Re-indented. Still read-only — nothing was sent to the server.';
12409
+ $configEditorStatus.className = 'config-status';
12410
+ } catch (e) {
12411
+ $configEditorStatus.textContent = 'Not valid JSON: ' + errorText(e);
12412
+ $configEditorStatus.className = 'config-status error';
12413
+ }
12414
+ });
12415
+
12416
+ /**
12417
+ * A shape check on the reported config, against the REAL key names. The old
12418
+ * check looked for \`budget.maxBudget\`, which does not exist, so it could not
12419
+ * have fired even when the editor had content.
12420
+ */
12421
+ $configValidateBtn.addEventListener('click', function() {
12422
+ var parsed;
12423
+ try {
12424
+ parsed = asObject(JSON.parse($configEditor.value));
12425
+ if (parsed === null) throw new Error('top level is not an object');
12426
+ } catch (e) {
12427
+ $configEditorStatus.textContent = 'Not valid JSON: ' + errorText(e);
12428
+ $configEditorStatus.className = 'config-status error';
12429
+ return;
12430
+ }
12431
+
12432
+ var notes = [];
12433
+ var budget = asObject(parsed.budget);
12434
+ if (budget === null) {
12435
+ notes.push('no \`budget\` block reported');
12436
+ } else {
12437
+ var ceiling = num(budget.maxTotalCost);
12438
+ if (ceiling === null) notes.push('\`budget.maxTotalCost\` is missing or not a number');
12439
+ else if (ceiling <= 0) notes.push('\`budget.maxTotalCost\` is ' + fmt$(ceiling) + ' — spend can never register against it');
12440
+ var threshold = num(budget.alertThreshold);
12441
+ if (threshold === null) notes.push('\`budget.alertThreshold\` is missing or not a number');
12442
+ else if (threshold < 0 || threshold > 1) {
12443
+ notes.push('\`budget.alertThreshold\` is ' + threshold + ', outside 0..1 — it is read as a fraction of budget REMAINING');
12444
+ }
12445
+ if (typeof budget.hardLimit !== 'boolean') notes.push('\`budget.hardLimit\` is missing or not a boolean');
12446
+ }
12447
+
12448
+ var healing = asObject(parsed.selfHealing);
12449
+ if (healing === null) {
12450
+ notes.push('no \`selfHealing\` block reported');
12451
+ } else {
12452
+ if (typeof healing.enabled !== 'boolean') notes.push('\`selfHealing.enabled\` is missing or not a boolean');
12453
+ var retries = num(healing.maxRetries);
12454
+ if (retries === null) notes.push('\`selfHealing.maxRetries\` is missing or not a number');
12455
+ else if (retries > 10) notes.push('\`selfHealing.maxRetries\` is ' + retries + ', which can mean many re-runs of a failing task');
12456
+ }
12457
+
12458
+ if (asObject(parsed.models) === null) notes.push('no \`models\` map reported');
12459
+
12460
+ if (notes.length === 0) {
12461
+ $configEditorStatus.textContent = 'Shape check passed: models, budget and selfHealing all carry the expected keys. '
12462
+ + 'Read-only — nothing was sent to the server.';
12463
+ $configEditorStatus.className = 'config-status success';
12464
+ } else {
12465
+ $configEditorStatus.textContent = notes.length + ' observation' + (notes.length === 1 ? '' : 's') + ': '
12466
+ + notes.join('; ') + '. Read-only — nothing was sent to the server.';
12467
+ $configEditorStatus.className = 'config-status';
12468
+ $configEditorStatus.style.color = 'var(--yellow)';
12469
+ }
12470
+ });
12471
+
12472
+ // ── Init ──
12473
+ setupLogFilters();
12474
+ setupActivityFilters();
12475
+ setupSessionFilters();
12476
+ refreshHealth();
12477
+ refreshCostReport();
12478
+ addLog('system', '⚡', 'Nexus Dashboard initialized');
12479
+ addLogStream('system', 'SYS', 'Nexus Dashboard initialized');
12480
+ // The snapshot age and the orchestrator pill both need a periodic tick: the
12481
+ // socket pushes on change, but "how old is what you are looking at" is only
12482
+ // knowable with a clock.
12483
+ setInterval(renderSnapshotAge, 1000);
12484
+ connect();
12485
+
12486
+ })();
12487
+ </script>
12488
+ </body>
12489
+ </html>
12490
+ `;
8897
12491
 
8898
12492
  // src/dashboard.ts
8899
- import { join as join2 } from "node:path";
8900
12493
  var CORS_HEADERS = {
8901
12494
  "Access-Control-Allow-Origin": "*",
8902
12495
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
@@ -8911,10 +12504,58 @@ function jsonResponse(data, status = 200) {
8911
12504
  }
8912
12505
  });
8913
12506
  }
12507
+ function htmlResponse(html) {
12508
+ return new Response(html, {
12509
+ status: 200,
12510
+ headers: {
12511
+ "Content-Type": "text/html; charset=utf-8",
12512
+ ...CORS_HEADERS
12513
+ }
12514
+ });
12515
+ }
12516
+ function errorMessage(err) {
12517
+ return err instanceof Error ? err.message : String(err);
12518
+ }
12519
+ var cachedSpaHtml = null;
12520
+ function getSpaHtml() {
12521
+ if (cachedSpaHtml !== null)
12522
+ return cachedSpaHtml;
12523
+ const inlined = dashboard_default;
12524
+ if (typeof inlined !== "string" || inlined.length === 0) {
12525
+ throw new Error("Dashboard HTML was not inlined into the bundle — the text loader for dashboard/index.html did not run");
12526
+ }
12527
+ cachedSpaHtml = inlined;
12528
+ return cachedSpaHtml;
12529
+ }
12530
+ var API_ROUTES = new Map([
12531
+ ["/api/state", (o) => o.getState()],
12532
+ ["/api/config", (o) => o.configManager.exportConfig()],
12533
+ ["/api/agents", (o) => o.getState().agents],
12534
+ [
12535
+ "/api/costs",
12536
+ (o) => {
12537
+ const parsed = JSON.parse(o.getCostReport());
12538
+ if (parsed === null || typeof parsed !== "object") {
12539
+ throw new Error("Cost report did not parse to an object");
12540
+ }
12541
+ return parsed;
12542
+ }
12543
+ ],
12544
+ ["/api/health", () => ({ ok: true, uptime: process.uptime() })]
12545
+ ]);
12546
+ function isApiPath(pathname) {
12547
+ return pathname === "/api" || pathname.startsWith("/api/");
12548
+ }
12549
+ function stateMessage(orchestrator) {
12550
+ return JSON.stringify({
12551
+ type: "orchestrator:state",
12552
+ data: orchestrator.getState(),
12553
+ timestamp: new Date().toISOString()
12554
+ });
12555
+ }
8914
12556
 
8915
12557
  class DashboardModule {
8916
12558
  server = null;
8917
- clients = new Set;
8918
12559
  orchestrator;
8919
12560
  constructor(orchestrator) {
8920
12561
  this.orchestrator = orchestrator;
@@ -8925,7 +12566,7 @@ class DashboardModule {
8925
12566
  this.server = Bun.serve({
8926
12567
  port,
8927
12568
  hostname: host,
8928
- async fetch(req, server) {
12569
+ fetch(req, server) {
8929
12570
  const url = new URL(req.url);
8930
12571
  if (req.method === "OPTIONS") {
8931
12572
  return new Response(null, { headers: CORS_HEADERS });
@@ -8935,53 +12576,35 @@ class DashboardModule {
8935
12576
  return new Response(null);
8936
12577
  return new Response("WebSocket upgrade failed", { status: 500 });
8937
12578
  }
8938
- if (url.pathname === "/api/state") {
8939
- return jsonResponse(self.orchestrator.getState());
8940
- }
8941
- if (url.pathname === "/api/config") {
8942
- return jsonResponse(self.orchestrator.configManager.exportConfig());
8943
- }
8944
- if (url.pathname === "/api/agents") {
8945
- const state = self.orchestrator.getState();
8946
- return jsonResponse(state.agents);
8947
- }
8948
- if (url.pathname === "/api/costs") {
8949
- return jsonResponse(self.orchestrator.getCostReport());
8950
- }
8951
- if (url.pathname === "/api/health") {
8952
- return jsonResponse({ ok: true, uptime: process.uptime() });
12579
+ if (isApiPath(url.pathname)) {
12580
+ const handler = API_ROUTES.get(url.pathname);
12581
+ if (!handler) {
12582
+ return jsonResponse({ error: "Not found", path: url.pathname }, 404);
12583
+ }
12584
+ try {
12585
+ return jsonResponse(handler(self.orchestrator));
12586
+ } catch (err) {
12587
+ return jsonResponse({
12588
+ error: `Failed to build response for ${url.pathname}`,
12589
+ detail: errorMessage(err)
12590
+ }, 500);
12591
+ }
8953
12592
  }
8954
12593
  try {
8955
- const spaPath = join2(process.cwd(), "dashboard", "index.html");
8956
- const spaContent = await Bun.file(spaPath).text();
8957
- if (spaContent.length > 100) {
8958
- return new Response(spaContent, {
8959
- headers: { "Content-Type": "text/html; charset=utf-8", ...CORS_HEADERS }
8960
- });
8961
- }
8962
- return new Response(`SPA not found at ${spaPath} (content: ${spaContent.length} bytes)`, {
8963
- headers: { "Content-Type": "text/plain", ...CORS_HEADERS }
8964
- });
8965
- } catch (e) {
8966
- return new Response(`SPA error: ${e}`, {
8967
- headers: { "Content-Type": "text/plain", ...CORS_HEADERS }
8968
- });
12594
+ return htmlResponse(getSpaHtml());
12595
+ } catch (err) {
12596
+ return jsonResponse({
12597
+ error: "Dashboard bundle is broken",
12598
+ detail: errorMessage(err)
12599
+ }, 500);
8969
12600
  }
8970
- return new Response("Nexus Dashboard API", {
8971
- headers: {
8972
- "Content-Type": "text/plain",
8973
- ...CORS_HEADERS
8974
- }
8975
- });
8976
12601
  },
8977
12602
  websocket: {
8978
12603
  open(ws) {
8979
- self.clients.add(ws);
8980
- ws.send(JSON.stringify({
8981
- type: "orchestrator:state",
8982
- data: self.orchestrator.getState(),
8983
- timestamp: new Date().toISOString()
8984
- }));
12604
+ const broadcaster = self.orchestrator.broadcaster;
12605
+ broadcaster?.addClient(ws);
12606
+ if (!broadcaster)
12607
+ ws.send(stateMessage(self.orchestrator));
8985
12608
  },
8986
12609
  message(ws, message) {
8987
12610
  try {
@@ -8991,33 +12614,22 @@ class DashboardModule {
8991
12614
  type: "pong",
8992
12615
  timestamp: new Date().toISOString()
8993
12616
  }));
12617
+ } else if (msg.type === "getState") {
12618
+ ws.send(stateMessage(self.orchestrator));
8994
12619
  }
8995
12620
  } catch {}
8996
12621
  },
8997
12622
  close(ws) {
8998
- self.clients.delete(ws);
12623
+ self.orchestrator.broadcaster?.removeClient(ws);
8999
12624
  }
9000
12625
  }
9001
12626
  });
9002
12627
  console.log(`[nexus] Dashboard server running at http://${host}:${port}`);
9003
12628
  } catch (err) {
9004
12629
  this.server = null;
9005
- console.error(`[nexus] Failed to start dashboard on ${host}:${port}: ${err.message}`);
9006
- throw new Error(`Dashboard failed to start on port ${port}: ${err.message}`);
9007
- }
9008
- }
9009
- broadcast(event, data) {
9010
- const message = JSON.stringify({
9011
- type: event,
9012
- data,
9013
- timestamp: new Date().toISOString()
9014
- });
9015
- for (const client of this.clients) {
9016
- try {
9017
- client.send(message);
9018
- } catch {
9019
- this.clients.delete(client);
9020
- }
12630
+ const detail = errorMessage(err);
12631
+ console.error(`[nexus] Failed to start dashboard on ${host}:${port}: ${detail}`);
12632
+ throw new Error(`Dashboard failed to start on port ${port}: ${detail}`);
9021
12633
  }
9022
12634
  }
9023
12635
  stop() {
@@ -9025,10 +12637,9 @@ class DashboardModule {
9025
12637
  this.server.stop();
9026
12638
  this.server = null;
9027
12639
  }
9028
- this.clients.clear();
9029
12640
  }
9030
12641
  getClientCount() {
9031
- return this.clients.size;
12642
+ return this.orchestrator.broadcaster?.getClientCount() ?? 0;
9032
12643
  }
9033
12644
  isRunning() {
9034
12645
  return this.server !== null;
@@ -9069,9 +12680,9 @@ function detectCycles(nodes) {
9069
12680
 
9070
12681
  // src/message-store.ts
9071
12682
  import { appendFileSync, readFileSync as readFileSync2, existsSync, mkdirSync as mkdirSync2 } from "node:fs";
9072
- import { join as join3, dirname as dirname2 } from "node:path";
12683
+ import { join as join2, dirname as dirname2 } from "node:path";
9073
12684
  var DEFAULT_CONFIG2 = {
9074
- storagePath: join3(process.env.HOME || "~", ".local", "share", "opencode-nexus", "messages.jsonl"),
12685
+ storagePath: join2(process.env.HOME || "~", ".local", "share", "opencode-nexus", "messages.jsonl"),
9075
12686
  maxMessages: 1e4,
9076
12687
  rotationSize: 1000
9077
12688
  };
@@ -9159,10 +12770,10 @@ class MessageStore {
9159
12770
 
9160
12771
  // src/memory-store.ts
9161
12772
  import { Database } from "bun:sqlite";
9162
- import { join as join4, dirname as dirname3 } from "node:path";
12773
+ import { join as join3, dirname as dirname3 } from "node:path";
9163
12774
  import { mkdirSync as mkdirSync3 } from "node:fs";
9164
12775
  var DEFAULT_CONFIG3 = {
9165
- dbPath: join4(process.env.HOME || "~", ".local", "share", "opencode-nexus", "memory.db"),
12776
+ dbPath: join3(process.env.HOME || "~", ".local", "share", "opencode-nexus", "memory.db"),
9166
12777
  defaultTTL: 0,
9167
12778
  maxEntries: 1e4
9168
12779
  };
@@ -10118,7 +13729,7 @@ class CostForecaster {
10118
13729
  // src/worktree.ts
10119
13730
  import { execSync } from "node:child_process";
10120
13731
  import { existsSync as existsSync2 } from "node:fs";
10121
- import { join as join5 } from "node:path";
13732
+ import { join as join4 } from "node:path";
10122
13733
 
10123
13734
  class WorktreeManager {
10124
13735
  worktrees = new Map;
@@ -10126,11 +13737,11 @@ class WorktreeManager {
10126
13737
  repoRoot;
10127
13738
  constructor(repoRoot, baseDir) {
10128
13739
  this.repoRoot = repoRoot;
10129
- this.baseDir = baseDir || join5(repoRoot, ".worktrees", "nexus-agents");
13740
+ this.baseDir = baseDir || join4(repoRoot, ".worktrees", "nexus-agents");
10130
13741
  }
10131
13742
  create(agentId) {
10132
13743
  const branch = `nexus-agent-${agentId}`;
10133
- const path = join5(this.baseDir, agentId);
13744
+ const path = join4(this.baseDir, agentId);
10134
13745
  try {
10135
13746
  if (!existsSync2(this.baseDir)) {
10136
13747
  execSync(`mkdir -p "${this.baseDir}"`, { cwd: this.repoRoot });
@@ -10300,6 +13911,7 @@ class TaskTimeoutError extends Error {
10300
13911
  }
10301
13912
  }
10302
13913
  var DELTA_READ_BACKOFF_MS = [1000, 2000, 4000];
13914
+ var MAX_UNCOLLECTED_SESSIONS = 200;
10303
13915
  function assistantMessageText(message) {
10304
13916
  if (message.type !== "assistant")
10305
13917
  return "";
@@ -10324,6 +13936,20 @@ function sumBy(records, select) {
10324
13936
  function baseInputRate(cost) {
10325
13937
  return selectTier(cost.tiers, 0).rates.input;
10326
13938
  }
13939
+ function sessionStateOfAgent(status) {
13940
+ switch (status) {
13941
+ case "spawning":
13942
+ case "working":
13943
+ return "running";
13944
+ case "idle":
13945
+ case "blocked":
13946
+ return "idle";
13947
+ case "completed":
13948
+ case "failed":
13949
+ case "terminated":
13950
+ return "settled";
13951
+ }
13952
+ }
10327
13953
  var DEFAULT_ESCALATION = {
10328
13954
  maxRetries: 3,
10329
13955
  retryDelay: 1000,
@@ -10366,7 +13992,7 @@ class NexusOrchestrator {
10366
13992
  forecaster;
10367
13993
  worktreeManager = null;
10368
13994
  todoEnforcer = new TodoEnforcer;
10369
- onStateChange = null;
13995
+ stateChangeListener = null;
10370
13996
  stateChangeTimer = null;
10371
13997
  costHistory = [];
10372
13998
  tokensByModel = new Map;
@@ -10374,6 +14000,12 @@ class NexusOrchestrator {
10374
14000
  cleanupInterval = null;
10375
14001
  deltaLedgers = new Map;
10376
14002
  uncollected = new Map;
14003
+ uncollectedEvicted = {
14004
+ sessions: 0,
14005
+ lastKnownTokens: 0,
14006
+ observedUncollected: 0,
14007
+ cap: MAX_UNCOLLECTED_SESSIONS
14008
+ };
10377
14009
  deltaTimers = new Set;
10378
14010
  shuttingDown = false;
10379
14011
  deltaReadBackoffMs = DELTA_READ_BACKOFF_MS;
@@ -10386,7 +14018,7 @@ class NexusOrchestrator {
10386
14018
  constructor(config, messageStoreConfig, memoryStoreConfig) {
10387
14019
  this.config = this.mergeConfig(config);
10388
14020
  this.budget = this.config.budget;
10389
- this.configManager = new NexusConfigManager;
14021
+ this.configManager = new NexusConfigManager(this.config.dashboard);
10390
14022
  this.moduleRegistry = new ModuleRegistry;
10391
14023
  this.messageStore = new MessageStore(messageStoreConfig);
10392
14024
  this.memoryStore = new PersistentMemoryStore(memoryStoreConfig);
@@ -10407,7 +14039,7 @@ class NexusOrchestrator {
10407
14039
  }
10408
14040
  async initialize(ctx, onStateChange) {
10409
14041
  this.ctx = ctx;
10410
- this.onStateChange = onStateChange ?? null;
14042
+ this.stateChangeListener = onStateChange ?? null;
10411
14043
  const projectDir = ctx.location.directory;
10412
14044
  this.configManager.loadFromPath(projectDir);
10413
14045
  await this.loadModelCosts();
@@ -10425,6 +14057,7 @@ class NexusOrchestrator {
10425
14057
  }
10426
14058
  };
10427
14059
  await this.moduleRegistry.setupAll(moduleCtx);
14060
+ this.initBroadcaster();
10428
14061
  }
10429
14062
  async loadModelCosts() {
10430
14063
  try {
@@ -10475,22 +14108,19 @@ class NexusOrchestrator {
10475
14108
  }
10476
14109
  }
10477
14110
  initBroadcaster(opts) {
14111
+ this.broadcaster?.destroy();
10478
14112
  this.broadcaster = new StateBroadcaster(this, opts);
10479
- const previousOnStateChange = this.onStateChange;
10480
- this.onStateChange = () => {
10481
- previousOnStateChange?.();
10482
- this.broadcaster?.broadcastState();
10483
- };
10484
14113
  }
10485
14114
  startDashboard(port, host) {
10486
- const dashPort = port || this.config.dashboard.port;
10487
- const dashHost = host || this.config.dashboard.host;
10488
- this.dashboard = new DashboardModule(this);
10489
- this.dashboard.start(dashPort, dashHost);
10490
- this.on("agent:spawned", (agent) => this.dashboard?.broadcast("agent:spawned", agent));
10491
- this.on("agent:terminated", (agent) => this.dashboard?.broadcast("agent:terminated", agent));
10492
- this.on("budget:alert", (data) => this.dashboard?.broadcast("budget:alert", data));
10493
- this.on("budget:exceeded", (data) => this.dashboard?.broadcast("budget:exceeded", data));
14115
+ const dashboardConfig = this.configManager.getConfig().dashboard;
14116
+ if (!dashboardConfig.enabled) {
14117
+ throw new Error("Dashboard is disabled by configuration (`dashboard.enabled: false` in .opencode/nexus.jsonc or " + "~/.config/opencode/nexus.jsonc). Set it to true — or remove the block, which defaults to enabled — " + "to start the server.");
14118
+ }
14119
+ const dashPort = port || dashboardConfig.port;
14120
+ const dashHost = host || dashboardConfig.host;
14121
+ const dashboard = new DashboardModule(this);
14122
+ dashboard.start(dashPort, dashHost);
14123
+ this.dashboard = dashboard;
10494
14124
  }
10495
14125
  stopDashboard() {
10496
14126
  if (this.dashboard) {
@@ -10512,6 +14142,9 @@ class NexusOrchestrator {
10512
14142
  spawnedAt: a.spawnedAt.toISOString(),
10513
14143
  tasksCompleted: a.metrics.tasksCompleted,
10514
14144
  tasksFailed: a.metrics.tasksFailed,
14145
+ totalTokens: a.metrics.totalTokens,
14146
+ averageResponseTime: a.metrics.averageResponseTime,
14147
+ errorRate: a.metrics.errorRate,
10515
14148
  totalCost: a.metrics.totalCost
10516
14149
  }));
10517
14150
  const tasks = Array.from(this.tasks.values()).map((t) => ({
@@ -10520,7 +14153,10 @@ class NexusOrchestrator {
10520
14153
  role: t.requiredRole,
10521
14154
  priority: t.priority || "normal",
10522
14155
  status: t.status,
14156
+ dependencies: t.dependencies,
10523
14157
  assignedAgent: t.assignedAgent,
14158
+ cost: t.result?.cost,
14159
+ tokensUsed: t.result?.tokensUsed,
10524
14160
  result: t.result ? {
10525
14161
  success: t.result.success,
10526
14162
  output: t.result.output?.slice(0, 500),
@@ -10533,11 +14169,89 @@ class NexusOrchestrator {
10533
14169
  paused: this.paused,
10534
14170
  agents,
10535
14171
  tasks,
14172
+ config: {
14173
+ models: this.configManager.getResolvedModels(),
14174
+ budget: this.budget,
14175
+ selfHealing: this.config.selfHealing
14176
+ },
14177
+ sessions: this.sessionViews(),
10536
14178
  totalSpent: this.totalSpent,
10537
14179
  budgetRemaining: this.budget.maxTotalCost - this.totalSpent,
10538
14180
  lastUpdated: new Date().toISOString()
10539
14181
  };
10540
14182
  }
14183
+ sessionViews() {
14184
+ const views = new Map;
14185
+ for (const agent of this.agents.values()) {
14186
+ if (!agent.sessionID)
14187
+ continue;
14188
+ views.set(agent.sessionID, {
14189
+ id: agent.sessionID,
14190
+ owned: true,
14191
+ agentId: agent.id,
14192
+ taskId: this.taskIdForAgent(agent.id),
14193
+ role: agent.role,
14194
+ model: `${agent.model.provider}/${agent.model.model}`,
14195
+ state: sessionStateOfAgent(agent.status),
14196
+ spawnedAt: agent.spawnedAt.toISOString(),
14197
+ lastKnownTokens: agent.metrics.totalTokens,
14198
+ observedUncollected: 0
14199
+ });
14200
+ }
14201
+ for (const ledger of this.deltaLedgers.values()) {
14202
+ this.applyCollectionRecord(views, {
14203
+ sessionID: ledger.sessionID,
14204
+ taskId: ledger.taskId,
14205
+ agentId: ledger.agentId,
14206
+ model: ledger.model,
14207
+ state: "running",
14208
+ lastKnownTokens: totalTokens(ledger.charged),
14209
+ observedUncollected: 0
14210
+ });
14211
+ }
14212
+ for (const spend of this.uncollected.values()) {
14213
+ this.applyCollectionRecord(views, {
14214
+ sessionID: spend.sessionID,
14215
+ taskId: spend.taskId,
14216
+ model: spend.model,
14217
+ state: "abandoned",
14218
+ lastKnownTokens: spend.lastKnownTokens,
14219
+ observedUncollected: spend.observedUncollected
14220
+ });
14221
+ }
14222
+ return [...views.values()];
14223
+ }
14224
+ applyCollectionRecord(views, record) {
14225
+ const existing = views.get(record.sessionID);
14226
+ if (existing) {
14227
+ existing.taskId = existing.taskId ?? record.taskId;
14228
+ existing.model = existing.model ?? record.model;
14229
+ existing.state = record.state;
14230
+ existing.lastKnownTokens = record.lastKnownTokens;
14231
+ existing.observedUncollected = record.observedUncollected;
14232
+ return;
14233
+ }
14234
+ const owner = record.agentId !== undefined ? this.agents.get(record.agentId) : undefined;
14235
+ views.set(record.sessionID, {
14236
+ id: record.sessionID,
14237
+ owned: owner !== undefined,
14238
+ agentId: owner?.id ?? null,
14239
+ taskId: record.taskId,
14240
+ role: owner?.role ?? null,
14241
+ model: record.model,
14242
+ state: record.state,
14243
+ spawnedAt: owner ? owner.spawnedAt.toISOString() : null,
14244
+ lastKnownTokens: record.lastKnownTokens,
14245
+ observedUncollected: record.observedUncollected
14246
+ });
14247
+ }
14248
+ taskIdForAgent(agentId) {
14249
+ for (const task of this.tasks.values()) {
14250
+ if (task.assignedAgent === agentId)
14251
+ return task.id;
14252
+ }
14253
+ return null;
14254
+ }
10541
14255
  mergeConfig(partial) {
10542
14256
  const defaults = {
10543
14257
  maxConcurrency: 5,
@@ -10613,9 +14327,13 @@ class NexusOrchestrator {
10613
14327
  return;
10614
14328
  this.stateChangeTimer = setTimeout(() => {
10615
14329
  this.stateChangeTimer = null;
10616
- this.onStateChange?.();
14330
+ this.notifyStateListeners();
10617
14331
  }, 100);
10618
14332
  }
14333
+ notifyStateListeners() {
14334
+ this.stateChangeListener?.();
14335
+ this.broadcaster?.broadcastState();
14336
+ }
10619
14337
  reloadConfigFromDisk(trigger = "event") {
10620
14338
  const projectDir = this.ctx?.location.directory;
10621
14339
  if (projectDir) {
@@ -10849,6 +14567,8 @@ Errors encountered: ${transferContext.errorLog.join(", ") || "None"}`;
10849
14567
 
10850
14568
  Please continue from where the previous agent left off.`;
10851
14569
  }
14570
+ agent.status = "working";
14571
+ this.notifyStateChange();
10852
14572
  await this.ctx.session.prompt({
10853
14573
  sessionID: agent.sessionID,
10854
14574
  text: taskPrompt
@@ -11156,6 +14876,7 @@ Please continue from where the previous agent left off.`;
11156
14876
  }
11157
14877
  };
11158
14878
  const observedUncollected = totalTokens(observedIncrement) > 0 ? priceUsageAtSettledTier(observedIncrement, pricing, known).total : 0;
14879
+ this.uncollected.delete(ledger.sessionID);
11159
14880
  this.uncollected.set(ledger.sessionID, {
11160
14881
  sessionID: ledger.sessionID,
11161
14882
  taskId: ledger.taskId,
@@ -11164,6 +14885,7 @@ Please continue from where the previous agent left off.`;
11164
14885
  lastKnownTokens: totalTokens(known),
11165
14886
  observedUncollected
11166
14887
  });
14888
+ this.trimUncollected();
11167
14889
  this.emit("cost:delta", {
11168
14890
  taskId: ledger.taskId,
11169
14891
  nodeId: ledger.taskId,
@@ -11801,13 +15523,36 @@ Please continue from where the previous agent left off.`;
11801
15523
  uncollected: this.uncollectedSummary()
11802
15524
  }, null, 2);
11803
15525
  }
15526
+ trimUncollected() {
15527
+ while (this.uncollected.size > MAX_UNCOLLECTED_SESSIONS) {
15528
+ const oldest = this.uncollected.keys().next();
15529
+ if (oldest.done)
15530
+ return;
15531
+ const dropped = this.uncollected.get(oldest.value);
15532
+ if (dropped) {
15533
+ this.uncollectedEvicted.sessions += 1;
15534
+ this.uncollectedEvicted.lastKnownTokens += dropped.lastKnownTokens;
15535
+ this.uncollectedEvicted.observedUncollected += dropped.observedUncollected;
15536
+ }
15537
+ this.uncollected.delete(oldest.value);
15538
+ }
15539
+ }
11804
15540
  uncollectedSummary() {
11805
15541
  const all = [...this.uncollected.values()];
11806
15542
  return {
11807
15543
  sessions: all.length,
11808
15544
  lastKnownTokens: all.reduce((sum, u) => sum + u.lastKnownTokens, 0),
11809
15545
  observedUncollected: all.reduce((sum, u) => sum + u.observedUncollected, 0),
11810
- taskIds: all.map((u) => u.taskId)
15546
+ taskIds: all.map((u) => u.taskId),
15547
+ entries: all.map((u) => ({
15548
+ sessionID: u.sessionID,
15549
+ taskId: u.taskId,
15550
+ agentId: u.agentId,
15551
+ model: u.model,
15552
+ lastKnownTokens: u.lastKnownTokens,
15553
+ observedUncollected: u.observedUncollected
15554
+ })),
15555
+ evicted: { ...this.uncollectedEvicted }
11811
15556
  };
11812
15557
  }
11813
15558
  pause() {
@@ -11847,7 +15592,9 @@ Please continue from where the previous agent left off.`;
11847
15592
  this.agents.clear();
11848
15593
  this.running = false;
11849
15594
  this.emit("orchestrator:shutdown", {});
11850
- this.onStateChange?.();
15595
+ this.stateChangeListener?.();
15596
+ this.broadcaster?.destroy();
15597
+ this.broadcaster = null;
11851
15598
  }
11852
15599
  cleanupStaleData() {
11853
15600
  const now = Date.now();
@@ -11903,7 +15650,13 @@ Please continue from where the previous agent left off.`;
11903
15650
  }
11904
15651
  emit(event, data) {
11905
15652
  const handlers = this.eventHandlers.get(event) || [];
11906
- handlers.forEach((handler) => handler(data));
15653
+ handlers.forEach((handler) => {
15654
+ try {
15655
+ handler(data);
15656
+ } catch (error) {
15657
+ console.error(`[nexus] event handler for "${event}" threw; other listeners were still notified:`, error);
15658
+ }
15659
+ });
11907
15660
  }
11908
15661
  handleCommand(text) {
11909
15662
  const parts = text.split(" ");
@@ -12207,7 +15960,7 @@ class AstGrep {
12207
15960
  // src/index.ts
12208
15961
  import { writeFileSync as writeFileSync2, readFileSync as readFileSync3, mkdirSync as mkdirSync4, existsSync as existsSync3, statSync } from "node:fs";
12209
15962
  import { createHash } from "node:crypto";
12210
- import { join as join6, resolve as resolve3, basename, dirname as dirname4 } from "node:path";
15963
+ import { join as join5, resolve as resolve3, basename, dirname as dirname4 } from "node:path";
12211
15964
  import { homedir as homedir2 } from "node:os";
12212
15965
  function renderCost(cost, provenance) {
12213
15966
  return provenance.usage === "measured" ? `$${cost.toFixed(4)} measured` : `$${cost.toFixed(4)} estimated`;
@@ -12346,8 +16099,29 @@ function watchConfigFiles(ctx, orchestrator, debounceMs = CONFIG_RELOAD_DEBOUNCE
12346
16099
  controller.abort();
12347
16100
  };
12348
16101
  }
12349
- var NEXUS_AGENT_CONTENT = `---
12350
- description: Nexus multi-agent orchestrator — decomposes tasks and delegates to specialized sub-agents
16102
+ 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
+ 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.";
16104
+ 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.`;
16118
+ }
16119
+ function runDashboardStop(orchestrator) {
16120
+ const wasRunning = orchestrator.dashboard?.isRunning() ?? false;
16121
+ orchestrator.stopDashboard();
16122
+ 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.";
16123
+ }
16124
+ var NEXUS_AGENT_CONTENT = `---description: Nexus multi-agent orchestrator — decomposes tasks and delegates to specialized sub-agents
12351
16125
  mode: primary
12352
16126
  permissions:
12353
16127
  - action: subagent
@@ -12536,9 +16310,9 @@ var src_default = define({
12536
16310
  id: "nexus",
12537
16311
  async setup(ctx) {
12538
16312
  try {
12539
- const agentDir = join6(homedir2(), ".config", "opencode", "agents");
16313
+ const agentDir = join5(homedir2(), ".config", "opencode", "agents");
12540
16314
  mkdirSync4(agentDir, { recursive: true });
12541
- const orchestratorFile = join6(agentDir, "nexus-orchestrator.md");
16315
+ const orchestratorFile = join5(agentDir, "nexus-orchestrator.md");
12542
16316
  writeFileSync2(orchestratorFile, NEXUS_AGENT_CONTENT, "utf-8");
12543
16317
  const subagents = {
12544
16318
  "nexus-architect.md": `---
@@ -12827,12 +16601,12 @@ You are a technical writer who creates documentation that developers actually wa
12827
16601
  - Changelog follows semantic versioning with clear descriptions`
12828
16602
  };
12829
16603
  for (const [filename, content] of Object.entries(subagents)) {
12830
- const filepath = join6(agentDir, filename);
16604
+ const filepath = join5(agentDir, filename);
12831
16605
  writeFileSync2(filepath, content, "utf-8");
12832
16606
  }
12833
16607
  } catch {}
12834
16608
  try {
12835
- const configPath = join6(homedir2(), ".config", "opencode", "opencode.jsonc");
16609
+ const configPath = join5(homedir2(), ".config", "opencode", "opencode.jsonc");
12836
16610
  if (existsSync3(configPath)) {
12837
16611
  const configContent = readFileSync3(configPath, "utf-8");
12838
16612
  if (!configContent.includes('"lsp"')) {
@@ -13050,25 +16824,24 @@ You are a technical writer who creates documentation that developers actually wa
13050
16824
  });
13051
16825
  editor.add({
13052
16826
  name: "dashboard.start",
13053
- description: "Start the web dashboard server",
16827
+ description: DASHBOARD_START_DESCRIPTION,
13054
16828
  input: {
13055
16829
  type: "object",
13056
16830
  properties: {
13057
- port: { type: "number", description: "Port (default: 4747)" },
13058
- host: { type: "string", description: "Host (default: 127.0.0.1)" }
16831
+ port: { type: "number", description: "Port to listen on (default: 4747, or `dashboard.port` from nexus.jsonc). Must be free." },
16832
+ host: { type: "string", description: "Bind address (default: 127.0.0.1, or `dashboard.host` from nexus.jsonc)" }
13059
16833
  },
13060
16834
  additionalProperties: false
13061
16835
  },
13062
16836
  options: { codemode: true },
13063
16837
  execute: async (input) => {
13064
16838
  const { port, host } = input;
13065
- orchestrator.startDashboard(port, host);
13066
- return { content: `Dashboard started at http://${host || "127.0.0.1"}:${port || 4747}` };
16839
+ return { content: runDashboardStart(orchestrator, port, host) };
13067
16840
  }
13068
16841
  });
13069
16842
  editor.add({
13070
16843
  name: "dashboard.stop",
13071
- description: "Stop the web dashboard server",
16844
+ description: DASHBOARD_STOP_DESCRIPTION,
13072
16845
  input: {
13073
16846
  type: "object",
13074
16847
  properties: {},
@@ -13076,8 +16849,7 @@ You are a technical writer who creates documentation that developers actually wa
13076
16849
  },
13077
16850
  options: { codemode: true },
13078
16851
  execute: async () => {
13079
- orchestrator.stopDashboard();
13080
- return { content: "Dashboard stopped" };
16852
+ return { content: runDashboardStop(orchestrator) };
13081
16853
  }
13082
16854
  });
13083
16855
  editor.add({
@@ -14033,6 +17805,8 @@ export {
14033
17805
  CONFIG_RELOAD_DEBOUNCE_MS,
14034
17806
  CostForecaster,
14035
17807
  CustomRoleManager,
17808
+ DASHBOARD_START_DESCRIPTION,
17809
+ DASHBOARD_STOP_DESCRIPTION,
14036
17810
  DEFAULT_CONFIG,
14037
17811
  GoalManager,
14038
17812
  HealthMonitor,
@@ -14057,5 +17831,7 @@ export {
14057
17831
  getTemplate,
14058
17832
  instantiateTemplate,
14059
17833
  listTemplates,
17834
+ runDashboardStart,
17835
+ runDashboardStop,
14060
17836
  watchConfigFiles
14061
17837
  };