@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.
- package/README.md +113 -40
- package/dist/index.js +3950 -174
- package/dist/tui.js +173 -47
- 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
|
-
|
|
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
|
-
|
|
8815
|
-
|
|
8816
|
-
|
|
8817
|
-
|
|
8818
|
-
|
|
8819
|
-
|
|
8820
|
-
|
|
8821
|
-
|
|
8822
|
-
|
|
8823
|
-
|
|
8824
|
-
|
|
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
|
-
|
|
8827
|
-
|
|
8828
|
-
|
|
8829
|
-
|
|
8830
|
-
|
|
8831
|
-
|
|
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
|
-
|
|
8835
|
-
|
|
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
|
-
|
|
8838
|
-
|
|
8839
|
-
|
|
8840
|
-
|
|
8841
|
-
|
|
8842
|
-
|
|
8843
|
-
|
|
8844
|
-
|
|
8845
|
-
|
|
8846
|
-
|
|
8847
|
-
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
|
|
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
|
-
|
|
8854
|
-
|
|
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
|
-
|
|
8857
|
-
|
|
8858
|
-
|
|
8859
|
-
|
|
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
|
-
|
|
8862
|
-
|
|
8863
|
-
|
|
8864
|
-
|
|
8865
|
-
|
|
8866
|
-
|
|
8867
|
-
|
|
8868
|
-
|
|
8869
|
-
|
|
8870
|
-
|
|
8871
|
-
|
|
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
|
-
|
|
8876
|
-
|
|
8877
|
-
|
|
8878
|
-
|
|
8879
|
-
|
|
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
|
-
|
|
8883
|
-
|
|
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
|
-
|
|
8886
|
-
|
|
8887
|
-
|
|
8888
|
-
|
|
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
|
-
|
|
8891
|
-
|
|
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
|
-
|
|
8894
|
-
this.
|
|
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
|
-
|
|
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
|
|
8939
|
-
|
|
8940
|
-
|
|
8941
|
-
|
|
8942
|
-
|
|
8943
|
-
|
|
8944
|
-
|
|
8945
|
-
|
|
8946
|
-
|
|
8947
|
-
|
|
8948
|
-
|
|
8949
|
-
|
|
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
|
-
|
|
8956
|
-
|
|
8957
|
-
|
|
8958
|
-
|
|
8959
|
-
|
|
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.
|
|
8980
|
-
ws
|
|
8981
|
-
|
|
8982
|
-
|
|
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.
|
|
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
|
-
|
|
9006
|
-
|
|
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.
|
|
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
|
|
12683
|
+
import { join as join2, dirname as dirname2 } from "node:path";
|
|
9073
12684
|
var DEFAULT_CONFIG2 = {
|
|
9074
|
-
storagePath:
|
|
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
|
|
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:
|
|
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
|
|
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 ||
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
|
10487
|
-
|
|
10488
|
-
|
|
10489
|
-
|
|
10490
|
-
|
|
10491
|
-
|
|
10492
|
-
|
|
10493
|
-
|
|
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.
|
|
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.
|
|
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) =>
|
|
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
|
|
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
|
|
12350
|
-
|
|
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 =
|
|
16313
|
+
const agentDir = join5(homedir2(), ".config", "opencode", "agents");
|
|
12540
16314
|
mkdirSync4(agentDir, { recursive: true });
|
|
12541
|
-
const orchestratorFile =
|
|
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 =
|
|
16604
|
+
const filepath = join5(agentDir, filename);
|
|
12831
16605
|
writeFileSync2(filepath, content, "utf-8");
|
|
12832
16606
|
}
|
|
12833
16607
|
} catch {}
|
|
12834
16608
|
try {
|
|
12835
|
-
const configPath =
|
|
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:
|
|
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: "
|
|
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
|
|
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:
|
|
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
|
|
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
|
};
|