@inkobytes/nexus 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +455 -0
  3. package/bin/nexus.js +108 -0
  4. package/drills/nexus-agent-protocol/README.md +65 -0
  5. package/drills/nexus-agent-protocol/cases/blocked.yaml +20 -0
  6. package/drills/nexus-agent-protocol/cases/claim-before-edit.yaml +16 -0
  7. package/drills/nexus-agent-protocol/cases/current-file-state.yaml +15 -0
  8. package/drills/nexus-agent-protocol/cases/data-boundary-table-header.yaml +21 -0
  9. package/drills/nexus-agent-protocol/cases/data-mutation-delete-rows.yaml +20 -0
  10. package/drills/nexus-agent-protocol/cases/done-claim-adversarial.yaml +18 -0
  11. package/drills/nexus-agent-protocol/cases/ghost-file-claim-loop.yaml +16 -0
  12. package/drills/nexus-agent-protocol/cases/issue-found.yaml +21 -0
  13. package/drills/nexus-agent-protocol/cases/private-path-protection.yaml +23 -0
  14. package/drills/nexus-agent-protocol/cases/queue-is-thin-index.yaml +21 -0
  15. package/drills/nexus-agent-protocol/cases/removal-scope.yaml +26 -0
  16. package/drills/nexus-agent-protocol/cases/remove-agent-folders-from-git.yaml +24 -0
  17. package/drills/nexus-agent-protocol/cases/stale-lock-after-commit.yaml +26 -0
  18. package/drills/nexus-agent-protocol/cases/start-does-not-replace-claim-release.yaml +17 -0
  19. package/drills/nexus-agent-protocol/cases/task-contract.yaml +23 -0
  20. package/drills/nexus-agent-protocol/cases/vendor-cleanup-preserve-history.yaml +24 -0
  21. package/drills/nexus-agent-protocol/cases/wrong-repo-push.yaml +23 -0
  22. package/nexus-dashboard/docs/index.html +183 -0
  23. package/nexus-dashboard/index.html +678 -0
  24. package/nexus-dashboard/logo-nexus.svg +14 -0
  25. package/nexus-dashboard/style.css +1454 -0
  26. package/package.json +42 -0
  27. package/skills/nexus/SKILL.md +62 -0
  28. package/src/commands/checkin.js +19 -0
  29. package/src/commands/checkout.js +33 -0
  30. package/src/commands/chmod.js +93 -0
  31. package/src/commands/claim.js +122 -0
  32. package/src/commands/clean.js +76 -0
  33. package/src/commands/dashboard.js +387 -0
  34. package/src/commands/db.js +256 -0
  35. package/src/commands/doctor.js +958 -0
  36. package/src/commands/drill.js +507 -0
  37. package/src/commands/help.js +8 -0
  38. package/src/commands/init.js +576 -0
  39. package/src/commands/ledger.js +215 -0
  40. package/src/commands/metrics.js +178 -0
  41. package/src/commands/next.js +317 -0
  42. package/src/commands/release.js +107 -0
  43. package/src/commands/soul.js +156 -0
  44. package/src/commands/standup.js +59 -0
  45. package/src/commands/start.js +126 -0
  46. package/src/commands/status.js +109 -0
  47. package/src/hooks/pre-migration-backup.js +35 -0
  48. package/src/lib/agentScopes.js +61 -0
  49. package/src/lib/blackboard.js +90 -0
  50. package/src/lib/config.js +38 -0
  51. package/src/lib/dump.js +63 -0
  52. package/src/lib/git.js +111 -0
  53. package/src/lib/lockManager.js +302 -0
  54. package/src/lib/pathSafety.js +41 -0
  55. package/src/lib/permissions.js +74 -0
@@ -0,0 +1,678 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Nexus Dashboard</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet">
11
+ </head>
12
+ <body>
13
+ <div class="layout">
14
+ <nav class="sidebar">
15
+ <div class="brand">
16
+ <div class="brand-icon">
17
+ <svg viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
18
+ <path d="M106.53 120C104.676 120 102.933 119.642 101.3 118.925C99.6762 118.208 98.2434 117.216 97.0018 115.948C95.7688 114.672 94.7994 113.199 94.0936 111.53C93.3963 109.86 93.0519 108.068 93.0604 106.154C93.0689 104.24 93.4218 102.448 94.1191 100.778C94.8249 99.1084 95.7943 97.6399 97.0274 96.3724C98.2604 95.0962 99.689 94.0996 101.313 93.3829C102.937 92.6661 104.676 92.3077 106.53 92.3077C108.392 92.3077 110.136 92.6661 111.76 93.3829C113.393 94.0996 114.821 95.0962 116.046 96.3724C117.279 97.6399 118.244 99.1084 118.941 100.778C119.638 102.448 119.991 104.24 120 106.154C120.008 108.068 119.664 109.86 118.967 111.53C118.269 113.199 117.304 114.672 116.071 115.948C114.838 117.216 113.405 118.208 111.773 118.925C110.14 119.642 108.392 120 106.53 120Z" fill="white"/>
19
+ <path d="M60.0048 108.245V106.399H90.1051V108.245H60.0048Z" fill="white"/>
20
+ <path d="M29.9084 108.245V106.399H60.0087V108.245H29.9084Z" fill="white"/>
21
+ <path d="M13.4699 120C11.6161 120 9.87284 119.642 8.24015 118.925C6.61596 118.208 5.1831 117.216 3.94158 115.948C2.70855 114.672 1.73914 113.199 1.03334 111.53C0.336046 109.86 -0.00835 108.068 0.000153682 106.154C0.00865719 104.24 0.361557 102.448 1.05885 100.778C1.76465 99.1084 2.73406 97.6399 3.96709 96.3724C5.20011 95.0962 6.62872 94.0996 8.2529 93.3829C9.87709 92.6661 11.6161 92.3077 13.4699 92.3077C15.3322 92.3077 17.0754 92.6661 18.6996 93.3829C20.3323 94.0996 21.7609 95.0962 22.9854 96.3724C24.2184 97.6399 25.1836 99.1084 25.8809 100.778C26.5782 102.448 26.9311 104.24 26.9396 106.154C26.9481 108.068 26.6037 109.86 25.9064 111.53C25.2091 113.199 24.2439 114.672 23.0109 115.948C21.7779 117.216 20.345 118.208 18.7123 118.925C17.0796 119.642 15.3322 120 13.4699 120Z" fill="white"/>
22
+ <path d="M80.96 74.3496V72.5035L102.512 46.1404H104.307V47.9866L82.7559 74.3496H80.96Z" fill="white"/>
23
+ <path d="M80.974 74.3496L59.4225 47.9866V46.1404H61.2184L82.77 72.5035V74.3496H80.974Z" fill="white"/>
24
+ <path d="M37.885 74.3496V72.5035L59.4365 46.1404H61.2325V47.9866L39.6809 74.3496H37.885Z" fill="white"/>
25
+ <path d="M37.899 74.3496L16.3475 47.9866V46.1404H18.1434L39.695 72.5035V74.3496H37.899Z" fill="white"/>
26
+ <path d="M106.53 27.6923C104.676 27.6923 102.933 27.3339 101.3 26.6171C99.6762 25.9003 98.2434 24.9082 97.0018 23.6407C95.7688 22.3645 94.7994 20.8916 94.0936 19.222C93.3963 17.5524 93.0519 15.7605 93.0604 13.8462C93.0689 11.9318 93.4218 10.1399 94.1191 8.47028C94.8249 6.8007 95.7943 5.33217 97.0274 4.06469C98.2604 2.78846 99.689 1.79196 101.313 1.07517C102.937 0.358392 104.676 0 106.53 0C108.392 0 110.136 0.358392 111.76 1.07517C113.393 1.79196 114.821 2.78846 116.046 4.06469C117.279 5.33217 118.244 6.8007 118.941 8.47028C119.638 10.1399 119.991 11.9318 120 13.8462C120.008 15.7605 119.664 17.5524 118.967 19.222C118.269 20.8916 117.304 22.3645 116.071 23.6407C114.838 24.9082 113.405 25.9003 111.773 26.6171C110.14 27.3339 108.392 27.6923 106.53 27.6923Z" fill="white"/>
27
+ <path d="M60.0048 15.9373V14.0912H90.1051V15.9373H60.0048Z" fill="white"/>
28
+ <path d="M29.9084 15.9373V14.0912H60.0087V15.9373H29.9084Z" fill="white"/>
29
+ <path d="M13.4699 27.6923C11.6161 27.6923 9.87284 27.3339 8.24015 26.6171C6.61596 25.9003 5.1831 24.9082 3.94158 23.6407C2.70855 22.3645 1.73914 20.8916 1.03334 19.222C0.336046 17.5524 -0.00835 15.7605 0.000153682 13.8462C0.00865719 11.9318 0.361557 10.1399 1.05885 8.47028C1.76465 6.8007 2.73406 5.33217 3.96709 4.06469C5.20011 2.78846 6.62872 1.79196 8.2529 1.07517C9.87709 0.358392 11.6161 0 13.4699 0C15.3322 0 17.0754 0.358392 18.6996 1.07517C20.3323 1.79196 21.7609 2.78846 22.9854 4.06469C24.2184 5.33217 25.1836 6.8007 25.8809 8.47028C26.5782 10.1399 26.9311 11.9318 26.9396 13.8462C26.9481 15.7605 26.6037 17.5524 25.9064 19.222C25.2091 20.8916 24.2439 22.3645 23.0109 23.6407C21.7779 24.9082 20.345 25.9003 18.7123 26.6171C17.0796 27.3339 15.3322 27.6923 13.4699 27.6923Z" fill="white"/>
30
+ </svg>
31
+ </div>
32
+ <div class="brand-wordmark">
33
+ <h1>Nexus</h1>
34
+ </div>
35
+ </div>
36
+ <nav class="nav-links">
37
+ <a href="#active" class="nav-link"><span data-icon="lock"></span>Claims &amp; Locks</a>
38
+ <a href="#metrics" class="nav-link"><span data-icon="chart-line"></span>Metrics</a>
39
+ <a href="#progress" class="nav-link nav-link-sub"><span data-icon="activity"></span>Progress</a>
40
+ <a href="#metrics" class="nav-link nav-link-sub"><span data-icon="tag"></span>By Epic</a>
41
+ <a href="#cost-split" class="nav-link nav-link-sub"><span data-icon="bar-chart-2"></span>Cost Split</a>
42
+ <a href="#by-agent" class="nav-link nav-link-sub"><span data-icon="users"></span>By Agent</a>
43
+ <a href="#queue" class="nav-link"><span data-icon="list-checks"></span>Queue</a>
44
+ <a href="#next-codex" class="nav-link nav-link-sub"><span data-icon="bot"></span>Next: Codex</a>
45
+ <a href="#next-gemini" class="nav-link nav-link-sub"><span data-icon="bot"></span>Next: Gemini</a>
46
+ <a href="#next-claude" class="nav-link nav-link-sub"><span data-icon="bot"></span>Next: Claude</a>
47
+ <a href="#next-agy" class="nav-link nav-link-sub"><span data-icon="bot"></span>Next: Agy</a>
48
+ <a href="#ledger" class="nav-link nav-link-sub"><span data-icon="file-text"></span>Ledger</a>
49
+ <a href="#standup-section" class="nav-link"><span data-icon="messages-square"></span>Standup</a>
50
+ <a href="#git-status" class="nav-link"><span data-icon="git-branch"></span>Git Status</a>
51
+ <a href="#recent-releases" class="nav-link"><span data-icon="ship"></span>Recent Releases</a>
52
+ <a href="#nexus-report" class="nav-link"><span data-icon="file-text"></span>Nexus Report</a>
53
+ <div class="nav-divider"></div>
54
+ <a href="/docs/index.html" class="nav-link"><span data-icon="file-text"></span>CLI Docs</a>
55
+ </nav>
56
+ </nav>
57
+ <main class="content">
58
+ <header>
59
+ <div class="brand">
60
+ <div class="brand-wordmark">
61
+ <h1>Nexus</h1>
62
+ <div class="brand-repo muted" id="repo">Loading repo...</div>
63
+ </div>
64
+ </div>
65
+ <div class="header-meta">
66
+ <div id="updated"></div>
67
+ <div class="status-wrap">
68
+ <span data-icon="circle-check"></span>
69
+ <div id="health" class="status">Loading</div>
70
+ </div>
71
+ </div>
72
+ </header>
73
+ <div id="health-alert" class="health-alert" hidden>
74
+ <div class="health-alert-inner">
75
+ <span class="health-alert-icon">🔴</span>
76
+ <div class="health-alert-body">
77
+ <div class="health-alert-title">Health issues detected</div>
78
+ <ul id="health-alert-list"></ul>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ <div class="dashboard-grid">
83
+ <section class="now-section" id="active">
84
+ <h2><span data-icon="lock"></span>Claims &amp; Locks</h2>
85
+ <div id="locks"></div>
86
+ </section>
87
+ <section class="ring-section" id="progress">
88
+ <h2><span data-icon="activity"></span>Progress</h2>
89
+ <div id="ring-chart"></div>
90
+ </section>
91
+ <section class="chart-sm" id="metrics">
92
+ <h2><span data-icon="tag"></span>By Epic</h2>
93
+ <div id="chart-epic"></div>
94
+ </section>
95
+ <section class="chart-sm" id="cost-split">
96
+ <h2><span data-icon="bar-chart-2"></span>Cost Split</h2>
97
+ <div id="chart-cost"></div>
98
+ </section>
99
+ <section class="chart-sm" id="by-agent">
100
+ <h2><span data-icon="users"></span>By Agent</h2>
101
+ <div id="chart-agent"></div>
102
+ </section>
103
+ <section class="wide queue-section" id="queue">
104
+ <div class="queue-tabs">
105
+ <a href="#queue" class="tab-button queue-tab active" data-queue-mode="queue"><span
106
+ data-icon="list-checks"></span>Queue</a>
107
+ <span class="next-label">Next for:</span>
108
+ <a href="#next-codex" class="tab-button agent-tab" data-next-agent="@codex">codex <span class="presence-dot"></span></a>
109
+ <a href="#next-gemini" class="tab-button agent-tab" data-next-agent="@gemini">gemini <span class="presence-dot"></span></a>
110
+ <a href="#next-claude" class="tab-button agent-tab" data-next-agent="@claude">claude <span class="presence-dot"></span></a>
111
+ <a href="#next-agy" class="tab-button agent-tab" data-next-agent="@agy">agy <span class="presence-dot"></span></a>
112
+ <a href="#ledger" class="tab-button queue-tab ledger-tab" data-queue-mode="ledger"><svg
113
+ xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
114
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
115
+ <path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />
116
+ <path d="M8 11h8" />
117
+ <path d="M8 7h6" />
118
+ </svg>Nexus Ledger</a>
119
+ </div>
120
+ <div id="queue-panel" class="queue-panel"></div>
121
+ </section>
122
+ <section id="standup-section">
123
+ <h2><span data-icon="messages-square"></span>Standup</h2>
124
+ <dl id="standup"></dl>
125
+ </section>
126
+ <section id="recent-releases">
127
+ <h2><span data-icon="ship"></span>Recent Releases</h2>
128
+ <dl id="releases"></dl>
129
+ </section>
130
+ <section class="wide terminal-section" id="git-status">
131
+ <h2><span data-icon="git-branch"></span>Git Status</h2>
132
+ <div id="git"></div>
133
+ </section>
134
+ <section class="wide report-section" id="nexus-report">
135
+ <h2><span data-icon="file-text"></span>Nexus Report</h2>
136
+ <pre id="report"></pre>
137
+ </section>
138
+ </div>
139
+ </main>
140
+ </div>
141
+ <script>
142
+ let currentSnapshot = null;
143
+ let queueView = { mode: 'queue', agent: null };
144
+ let repoRoot = '';
145
+ let lastQueueSig = '';
146
+ let lastLocksSig = '';
147
+
148
+ function queueViewFromHash(hash) {
149
+ const value = String(hash || '').replace(/^#/, '').toLowerCase();
150
+ if (value === 'ledger') return { mode: 'ledger', agent: null };
151
+ if (value.startsWith('next-')) return { mode: 'next', agent: '@' + value.replace(/^next-/, '') };
152
+ if (value === 'queue') return { mode: 'queue', agent: null };
153
+ return null;
154
+ }
155
+
156
+ function syncQueueViewFromHash() {
157
+ const nextView = queueViewFromHash(window.location.hash);
158
+ if (!nextView) return;
159
+ queueView = nextView;
160
+ renderQueuePanel(currentSnapshot);
161
+ document.getElementById('queue')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
162
+ }
163
+
164
+ async function load() {
165
+ const data = await fetch('/api/snapshot').then(r => r.json());
166
+ currentSnapshot = data;
167
+ repoRoot = data.repo;
168
+
169
+ // Always update the timestamp — it's benign
170
+ document.getElementById('updated').textContent = 'Updated ' + new Date(data.generatedAt).toLocaleTimeString();
171
+
172
+ // Don't re-render DOM while user has a popover open or is working in DevTools
173
+ if (document.querySelector('.task-popover:popover-open')) return;
174
+ document.getElementById('repo').textContent = data.repo + ' . ' + data.branch;
175
+ document.getElementById('health').textContent = data.health.ok ? 'Healthy' : 'Needs attention';
176
+ document.getElementById('health').className = data.health.ok ? 'status' : 'status warn';
177
+ document.querySelector('.status-wrap [data-icon]').dataset.icon = data.health.ok ? 'circle-check' : 'triangle-alert';
178
+ renderIcons();
179
+ renderHealthAlert(data.health);
180
+ const newLocksSig = JSON.stringify(data.locks);
181
+ const locksChanged = newLocksSig !== lastLocksSig;
182
+ if (locksChanged) lastLocksSig = newLocksSig;
183
+
184
+ if (locksChanged && !data.locks.length) {
185
+ document.getElementById('locks').innerHTML = '<p class="muted">No active locks.</p>';
186
+ } else {
187
+ const groups = {};
188
+ for (const lock of data.locks) {
189
+ const key = lock.agent || 'unknown';
190
+ if (!groups[key]) groups[key] = { locks: [], subagents: 0, modelLabels: new Set(), trustSource: lock.trustSource || 'unverified', verified: lock.verified ?? true };
191
+ groups[key].locks.push(lock);
192
+ if ((lock.subagents || 0) > groups[key].subagents) groups[key].subagents = lock.subagents;
193
+ const modelLabel = [lock.model, lock.thinking].filter(Boolean).join(' · ');
194
+ if (modelLabel) groups[key].modelLabels.add(modelLabel);
195
+ if (!lock.verified) { groups[key].verified = false; groups[key].trustSource = lock.trustSource || 'unverified'; }
196
+ }
197
+ const chevron = '<svg class="lock-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"></polyline></svg>';
198
+ locksChanged && (document.getElementById('locks').innerHTML = Object.entries(groups).map(([agent, group]) => {
199
+ const subagentsTag = group.subagents > 0 ? '<span class="lock-group-subagents">+subagents (' + group.subagents + ')</span>' : '';
200
+ const modelText = Array.from(group.modelLabels).join(', ');
201
+ const modelTag = modelText ? '<span class="lock-group-model">' + escapeHtml(modelText) + '</span>' : '';
202
+ const files = group.locks.map(lock => {
203
+ const href = 'vscode://file' + escapeHtml(repoRoot + '/' + lock.target);
204
+ const intent = lock.intent ? '<div class="lock-meta"><span class="lock-intent">' + escapeHtml(lock.intent) + '</span></div>' : '';
205
+ const staleTag = lock.stale ? '<span class="lock-stale">stale</span>' : '';
206
+ return '<div class="lock">'
207
+ + '<a class="lock-link" href="' + href + '">' + escapeHtml(lock.target) + '</a>'
208
+ + intent
209
+ + '<div class="lock-age">Age: ' + formatAge(lock.age) + staleTag + '</div>'
210
+ + '</div>';
211
+ }).join('');
212
+ const trustSource = group.trustSource || 'unverified';
213
+ const trustTip = trustSource === 'harness' ? 'Verified (Claude Code harness)' : trustSource === 'operator' ? 'Operator-declared (NEXUS_AGENT)' : 'Unverified — no harness fingerprint detected';
214
+ const trustDot = '<span class="trust-dot" data-trust="' + trustSource + '" title="' + trustTip + '"></span>';
215
+ return '<details class="lock-group" open>'
216
+ + '<summary class="lock-group-header">'
217
+ + trustDot
218
+ + '<span class="lock-group-agent">' + escapeHtml(agent) + '</span>'
219
+ + modelTag
220
+ + subagentsTag
221
+ + '<span class="lock-group-count">' + group.locks.length + ' file' + (group.locks.length !== 1 ? 's' : '') + '</span>'
222
+ + chevron
223
+ + '</summary>'
224
+ + '<div class="lock-group-body">' + files + '</div>'
225
+ + '</details>';
226
+ }).join(''));
227
+ }
228
+
229
+ const newQueueSig = JSON.stringify({ queue: data.queue, ledger: data.ledger });
230
+ if (newQueueSig !== lastQueueSig) {
231
+ lastQueueSig = newQueueSig;
232
+ renderQueuePanel(data);
233
+ }
234
+ updateAgentStatus(data);
235
+ const chartTasks = getChartTasks(data);
236
+ renderProgressRing(chartTasks);
237
+ renderEpicBars(chartTasks);
238
+ renderCostBars(chartTasks);
239
+ renderAgentBars(chartTasks);
240
+ fillFeed('standup', data.standup);
241
+ fillFeed('releases', data.releases);
242
+ document.getElementById('report').textContent = data.report && data.report.trim()
243
+ ? data.report
244
+ : 'No Nexus report entries yet.';
245
+ document.getElementById('git').innerHTML = data.dirtyFiles.length
246
+ ? '<ul>' + data.dirtyFiles.map(line => {
247
+ const xy = line.slice(0, 2);
248
+ const path = line.slice(3);
249
+ const hasDiff = xy !== '??';
250
+ const status = '<span class="git-status-code">' + escapeHtml(xy.trim() || '?') + '</span>';
251
+ const label = status + '<code>' + escapeHtml(path) + '</code>';
252
+ return '<li class="git-file">' + (hasDiff
253
+ ? '<a class="git-link" href="vscode://file' + escapeHtml(repoRoot + '/' + path) + '">' + label + '</a>'
254
+ : label) + '</li>';
255
+ }).join('') + '</ul>'
256
+ : '<p class="muted">Working tree clean.</p>';
257
+ }
258
+
259
+ document.querySelectorAll('[data-queue-mode], [data-next-agent]').forEach((button) => {
260
+ button.addEventListener('click', () => {
261
+ queueView = button.dataset.nextAgent
262
+ ? { mode: 'next', agent: button.dataset.nextAgent }
263
+ : button.dataset.queueMode === 'ledger'
264
+ ? { mode: 'ledger', agent: null }
265
+ : { mode: 'queue', agent: null };
266
+ renderQueuePanel(currentSnapshot);
267
+ });
268
+ });
269
+
270
+ window.addEventListener('hashchange', syncQueueViewFromHash);
271
+
272
+ function renderQueuePanel(data) {
273
+ if (!data) return;
274
+ document.querySelectorAll('.queue-tabs .tab-button').forEach((button) => {
275
+ let active = false;
276
+ if (queueView.mode === 'queue') active = button.dataset.queueMode === 'queue';
277
+ else if (queueView.mode === 'ledger') active = button.dataset.queueMode === 'ledger';
278
+ else active = button.dataset.nextAgent === queueView.agent;
279
+ button.classList.toggle('active', active);
280
+ });
281
+
282
+ if (queueView.mode === 'queue') {
283
+ const openTasks = (data.queue || []).filter(task => !task.checked);
284
+ document.getElementById('queue-panel').innerHTML = openTasks.length
285
+ ? openTasks.map(renderTask).join('')
286
+ : '<p class="muted">No open queue tasks. Completed work is in Nexus Ledger.</p>';
287
+ return;
288
+ }
289
+
290
+ if (queueView.mode === 'ledger') {
291
+ document.getElementById('queue-panel').innerHTML = data.ledger && data.ledger.length
292
+ ? '<div class="ledger-list">' + data.ledger.map(renderLedgerEntry).join('') + '</div>'
293
+ : '<p class="muted">No ledger entries yet. Completed tasks will appear here after matching releases write <code>_NEXUS_LEDGER.md</code>.</p>';
294
+ return;
295
+ }
296
+
297
+ const agentTasks = data.queue.filter(task =>
298
+ !task.checked && normalizeAgent(task.agent) === normalizeAgent(queueView.agent)
299
+ );
300
+ const suggested = pickNextTask(agentTasks, queueView.agent);
301
+ document.getElementById('queue-panel').innerHTML = suggested
302
+ ? renderNextSuggestion(suggested, queueView.agent)
303
+ : '<p class="muted">📋 No safe auto-flow tasks available for ' + escapeHtml(queueView.agent) + '. Standby.</p>';
304
+ }
305
+
306
+ function pickNextTask(tasks, agent) {
307
+ return tasks.find(task =>
308
+ normalizeAgent(task.agent) === normalizeAgent(agent) &&
309
+ task.status === 'Ready' &&
310
+ task.autoFlow === 'yes' &&
311
+ String(task.review || '').toLowerCase() === 'approved'
312
+ );
313
+ }
314
+
315
+ const NEXT_DRILL_HINTS = [
316
+ { id: 'data-mutation-delete-rows', keywords: ['db', 'database', 'migration', 'persisted'] },
317
+ { id: 'task-contract', keywords: ['all traces', 'completely', 'broad', 'cleanup', 'migration'] },
318
+ ];
319
+
320
+ function relatedDrillsForTask(task) {
321
+ const pinned = String(task.drills || '')
322
+ .split(',')
323
+ .map((item) => item.trim())
324
+ .filter(Boolean);
325
+ if (pinned.length) return pinned;
326
+
327
+ const haystack = [
328
+ task.title,
329
+ task.id,
330
+ task.epic,
331
+ task.depends,
332
+ task.files,
333
+ task.notes,
334
+ ].join(' ').toLowerCase();
335
+ const matches = [];
336
+ for (const hint of NEXT_DRILL_HINTS) {
337
+ if (hint.keywords.some(keyword => haystack.includes(keyword))) matches.push(hint.id);
338
+ }
339
+ return [...new Set(matches)];
340
+ }
341
+
342
+ function renderNextSuggestion(task, agent) {
343
+ const lines = [
344
+ `NEXUS SUGGESTS for ${agent}:`,
345
+ ` Task: ${task.id || task.title || 'unknown'}`,
346
+ ` Epic: ${task.epic || 'unknown'}`,
347
+ ` Files: ${task.files || 'no files'}`,
348
+ ` Cost: ${task.cost || 'unknown'}`,
349
+ ` Auto-flow: ${task.autoFlow || 'no'}`,
350
+ ];
351
+ const drills = relatedDrillsForTask(task);
352
+ if (drills.length) {
353
+ lines.push('');
354
+ lines.push(' Related known failure-mode guidance from drills:');
355
+ for (const id of drills) lines.push(` - ${id}`);
356
+ lines.push(' Run `nexus drill show <id>` if the task matches that risk.');
357
+ }
358
+ return `<article class="task"><pre>${escapeHtml(lines.join('\n'))}</pre></article>`;
359
+ }
360
+
361
+ function formatNotes(text) {
362
+ return escapeHtml(text).replace(/`([^`]+)`/g, '<code>$1</code>');
363
+ }
364
+
365
+ function metaChip(tip, svgPath, val) {
366
+ return '<span class="pop-meta-item" data-tip="' + tip + '">'
367
+ + '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + svgPath + '</svg>'
368
+ + escapeHtml(val)
369
+ + '</span>';
370
+ }
371
+
372
+ const ICONS = {
373
+ epic: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r="1" fill="currentColor" stroke="none"/>',
374
+ cost: '<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
375
+ auto: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>',
376
+ depends: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
377
+ files: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
378
+ notes: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
379
+ };
380
+
381
+ function renderTask(task) {
382
+ const done = task.checked ? ' is-done' : '';
383
+ const popId = 'tp-' + (task.id || Math.random().toString(36).slice(2, 9));
384
+
385
+ const chips = [
386
+ task.epic && metaChip('Epic', ICONS.epic, task.epic),
387
+ task.cost && metaChip('Cost', ICONS.cost, task.cost),
388
+ task.autoFlow && metaChip('Auto-flow', ICONS.auto, task.autoFlow),
389
+ task.depends && task.depends !== 'none' && metaChip('Depends on', ICONS.depends, task.depends),
390
+ ].filter(Boolean).join('');
391
+
392
+ const filesRow = task.files
393
+ ? '<div class="pop-files"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + ICONS.files + '</svg><span>' + escapeHtml(task.files) + '</span></div>'
394
+ : '';
395
+
396
+ const notesRow = task.notes
397
+ ? '<div class="pop-notes"><div class="pop-notes-label"><svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + ICONS.notes + '</svg>Notes</div><div class="pop-notes-body">' + formatNotes(task.notes) + '</div></div>'
398
+ : '';
399
+
400
+ const popover = '<div id="' + popId + '" popover class="task-popover">'
401
+ + '<div class="task-pop-title">' + escapeHtml(task.title) + '</div>'
402
+ + (chips ? '<div class="pop-meta">' + chips + '</div>' : '')
403
+ + filesRow + notesRow
404
+ + '</div>';
405
+
406
+ return '<div class="task' + done + '">'
407
+ + '<div class="task-header"><div class="task-title"><strong>' + escapeHtml(task.title) + '</strong></div>'
408
+ + '<button class="task-peek" popovertarget="' + popId + '" title="Task details">ⓘ</button></div>'
409
+ + '<span class="pill">' + escapeHtml(task.agent) + '</span>'
410
+ + '<span class="pill">' + escapeHtml(task.status || 'unknown') + '</span>'
411
+ + '<span class="pill">auto ' + escapeHtml(task.autoFlow || 'no') + '</span>'
412
+ + '<br><code>' + escapeHtml(task.files || 'no files') + '</code>'
413
+ + popover
414
+ + '</div>';
415
+ }
416
+
417
+ function renderLedgerEntry(entry) {
418
+ const completedAt = entry.completedAt ? new Date(entry.completedAt) : null;
419
+ const dateText = completedAt && !Number.isNaN(completedAt.getTime())
420
+ ? completedAt.toLocaleString()
421
+ : 'unknown time';
422
+ const files = Array.isArray(entry.files) ? entry.files : [];
423
+ const fileText = files.length ? files.join(', ') : 'no files';
424
+ const sha = entry.sha && entry.sha !== 'unknown' ? entry.sha.slice(0, 7) : 'unknown';
425
+ const commitLine = entry.commit
426
+ ? '<div class="ledger-commit">' + escapeHtml(entry.commit) + '</div>'
427
+ : '';
428
+
429
+ return '<article class="ledger-entry">'
430
+ + '<div class="ledger-main">'
431
+ + '<div class="ledger-title-row">'
432
+ + '<strong class="ledger-title">' + escapeHtml(entry.title || entry.id || 'Completed task') + '</strong>'
433
+ + '<code class="ledger-id">' + escapeHtml(entry.id || 'unknown') + '</code>'
434
+ + '</div>'
435
+ + '<div class="ledger-meta">'
436
+ + '<span>' + escapeHtml(dateText) + '</span>'
437
+ + '<span>' + escapeHtml(entry.agent || 'unknown') + '</span>'
438
+ + '<span>' + escapeHtml(entry.epic || 'unknown epic') + '</span>'
439
+ + '<span>' + escapeHtml(entry.cost || 'unknown cost') + '</span>'
440
+ + '<span>' + escapeHtml(sha) + '</span>'
441
+ + '</div>'
442
+ + commitLine
443
+ + '</div>'
444
+ + '<div class="ledger-files"><span>Files</span><code>' + escapeHtml(fileText) + '</code></div>'
445
+ + '</article>';
446
+ }
447
+
448
+ function getChartTasks(data) {
449
+ const seen = new Set();
450
+ const tasks = [];
451
+
452
+ for (const entry of data.ledger || []) {
453
+ if (!entry.id || seen.has(entry.id)) continue;
454
+ seen.add(entry.id);
455
+ tasks.push({
456
+ id: entry.id,
457
+ agent: entry.agent,
458
+ epic: entry.epic,
459
+ cost: entry.cost,
460
+ checked: true,
461
+ status: 'Done',
462
+ });
463
+ }
464
+
465
+ for (const task of data.queue || []) {
466
+ if (task.id && seen.has(task.id)) continue;
467
+ if (task.id) seen.add(task.id);
468
+ tasks.push(task);
469
+ }
470
+
471
+ return tasks;
472
+ }
473
+
474
+ function normalizeAgent(agent) {
475
+ return String(agent || '').replace(/^@/, '').toLowerCase();
476
+ }
477
+
478
+ function updateAgentStatus(data) {
479
+ const now = Math.floor(Date.now() / 1000);
480
+ const activeLockAgents = new Set((data.locks || [])
481
+ .filter(lock => !lock.stale)
482
+ .map(lock => normalizeAgent(lock.agent)));
483
+ document.querySelectorAll('[data-next-agent]').forEach(btn => {
484
+ const agent = normalizeAgent(btn.dataset.nextAgent);
485
+ const presence = data.presence && data.presence[agent];
486
+
487
+ let online = activeLockAgents.has(agent);
488
+ let idle = false;
489
+
490
+ if (presence) {
491
+ const age = now - presence;
492
+ if (age < 60) online = true;
493
+ else if (age < 300) idle = true;
494
+ }
495
+
496
+ btn.dataset.online = online.toString();
497
+ btn.dataset.idle = idle.toString();
498
+ });
499
+ }
500
+ function renderHealthAlert(health) {
501
+ const el = document.getElementById('health-alert');
502
+ if (health.ok) { el.hidden = true; return; }
503
+ el.hidden = false;
504
+ document.getElementById('health-alert-list').innerHTML = health.issues.map(item => {
505
+ const lethal = /tracked private|exposed|secret|credential/i.test(item);
506
+ return '<li' + (lethal ? ' class="lethal"' : '') + '>' + escapeHtml(item) + '</li>';
507
+ }).join('');
508
+ }
509
+
510
+ function renderProgressRing(queue) {
511
+ const total = queue.length;
512
+ const done = queue.filter(t => t.checked).length;
513
+ const R = 44, circ = 2 * Math.PI * R;
514
+ const filled = total > 0 ? (done / total) * circ : 0;
515
+ const allDone = total > 0 && done === total;
516
+ document.getElementById('ring-chart').innerHTML =
517
+ '<div class="ring-wrap">'
518
+ + '<svg class="ring-svg" viewBox="0 0 100 100">'
519
+ + '<circle class="ring-track" cx="50" cy="50" r="' + R + '"/>'
520
+ + '<circle class="ring-fill' + (allDone ? ' all-done' : '') + '" cx="50" cy="50" r="' + R + '"'
521
+ + ' stroke-dasharray="' + filled.toFixed(2) + ' ' + circ.toFixed(2) + '"'
522
+ + ' transform="rotate(-90 50 50)"/>'
523
+ + '<text class="ring-label" x="50" y="46">' + done + '/' + total + '</text>'
524
+ + '<text class="ring-sub" x="50" y="61">tasks done</text>'
525
+ + '</svg>'
526
+ + '<div class="ring-caption"><strong>Queue Progress</strong>' + (allDone ? 'All clear ✓' : (total - done) + ' remaining') + '</div>'
527
+ + '</div>';
528
+ }
529
+
530
+ function renderEpicBars(queue) {
531
+ const el = document.getElementById('chart-epic');
532
+ if (!queue.length) { el.innerHTML = '<p class="muted">No tasks yet.</p>'; return; }
533
+ const epics = {};
534
+ for (const task of queue) {
535
+ const epic = task.epic || 'Uncategorized';
536
+ if (!epics[epic]) epics[epic] = { done: 0, total: 0 };
537
+ epics[epic].total++;
538
+ if (task.checked) epics[epic].done++;
539
+ }
540
+ el.innerHTML = '<div class="chart-list">' + Object.entries(epics).map(([epic, d]) => {
541
+ const pct = d.total > 0 ? (d.done / d.total) * 100 : 0;
542
+ return '<div class="chart-row">'
543
+ + '<div class="chart-row-header"><span class="chart-row-label">' + escapeHtml(epic) + '</span>'
544
+ + '<span class="chart-row-val">' + d.done + '/' + d.total + '</span></div>'
545
+ + '<div class="chart-bar-track"><div class="chart-bar-fill clr-done" style="width:' + pct.toFixed(1) + '%"></div></div>'
546
+ + '</div>';
547
+ }).join('') + '</div>';
548
+ }
549
+
550
+ function renderCostBars(queue) {
551
+ const el = document.getElementById('chart-cost');
552
+ if (!queue.length) { el.innerHTML = '<p class="muted">No tasks yet.</p>'; return; }
553
+ const costs = { small: 0, medium: 0, large: 0 };
554
+ for (const task of queue) { const c = (task.cost || '').toLowerCase(); if (c in costs) costs[c]++; }
555
+ const max = Math.max(...Object.values(costs), 1);
556
+ const cols = Object.entries(costs).map(([cost, count]) => {
557
+ const barFlex = Math.max((count / max) * 100, 4).toFixed(0);
558
+ const spacerFlex = (100 - Number(barFlex)).toFixed(0);
559
+ return '<div class="vchart-col">'
560
+ + '<div class="vchart-spacer" style="flex:' + spacerFlex + '"></div>'
561
+ + '<span class="vchart-count">' + count + '</span>'
562
+ + '<div class="vchart-bar clr-' + cost + '" style="flex:' + barFlex + '"></div>'
563
+ + '<span class="vchart-label">' + cost.charAt(0).toUpperCase() + cost.slice(1) + '</span>'
564
+ + '</div>';
565
+ }).join('');
566
+ el.innerHTML = '<div class="vchart-wrap"><div class="vchart-bars">' + cols + '</div></div>';
567
+ }
568
+
569
+ const AGENT_COLORS = { claude: '#F4845F', codex: '#2dd4bf', gemini: '#0EA5E9', agy: '#d97706' };
570
+ function renderAgentBars(queue) {
571
+ const el = document.getElementById('chart-agent');
572
+ if (!queue.length) { el.innerHTML = '<p class="muted">No tasks yet.</p>'; return; }
573
+ const agents = {};
574
+ for (const task of queue) {
575
+ const a = normalizeAgent(task.agent) || 'unknown';
576
+ agents[a] = (agents[a] || 0) + 1;
577
+ }
578
+ const total = Object.values(agents).reduce((a, b) => a + b, 0) || 1;
579
+ const entries = Object.entries(agents).sort((a, b) => b[1] - a[1]);
580
+
581
+ // SVG pie chart
582
+ const cx = 50, cy = 50, r = 40;
583
+ let angle = -Math.PI / 2;
584
+ const slices = entries.length === 1
585
+ ? (() => {
586
+ const [a, n] = entries[0];
587
+ const color = AGENT_COLORS[a] || '#6b7f74';
588
+ return '<circle cx="' + cx + '" cy="' + cy + '" r="' + r + '" fill="' + color + '"><title>@' + escapeHtml(a) + ': ' + n + ' tasks</title></circle>';
589
+ })()
590
+ : entries.map(([a, n]) => {
591
+ const sweep = (n / total) * 2 * Math.PI;
592
+ const x1 = cx + r * Math.cos(angle);
593
+ const y1 = cy + r * Math.sin(angle);
594
+ angle += sweep;
595
+ const x2 = cx + r * Math.cos(angle);
596
+ const y2 = cy + r * Math.sin(angle);
597
+ const large = sweep > Math.PI ? 1 : 0;
598
+ const color = AGENT_COLORS[a] || '#6b7f74';
599
+ return '<path d="M' + cx + ',' + cy + ' L' + x1.toFixed(2) + ',' + y1.toFixed(2)
600
+ + ' A' + r + ',' + r + ' 0 ' + large + ',1 ' + x2.toFixed(2) + ',' + y2.toFixed(2)
601
+ + ' Z" fill="' + color + '" stroke="var(--panel)" stroke-width="1.5"><title>@' + escapeHtml(a) + ': ' + n + ' tasks</title></path>';
602
+ }).join('');
603
+
604
+ const legend = entries.map(([a, n]) => {
605
+ const color = AGENT_COLORS[a] || '#6b7f74';
606
+ return '<div class="agent-legend-item"><div class="agent-legend-dot" style="background:' + color + '"></div>@' + escapeHtml(a) + '<span class="chart-row-val">' + n + '</span></div>';
607
+ }).join('');
608
+
609
+ const agentCount = entries.length;
610
+ el.innerHTML = '<div class="pie-wrap">'
611
+ + '<svg class="pie-svg" viewBox="0 0 100 100"><g>' + slices + '</g></svg>'
612
+ + '<div class="agent-legend">' + legend + '</div>'
613
+ + '</div>'
614
+ + '<div class="pie-caption">' + total + ' task' + (total !== 1 ? 's' : '') + ' across ' + agentCount + ' agent' + (agentCount !== 1 ? 's' : '') + '</div>';
615
+ }
616
+
617
+ function renderIcons() {
618
+ const icons = {
619
+ activity: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>',
620
+ 'bar-chart-2': '<line x1="18" x2="18" y1="20" y2="10"></line><line x1="12" x2="12" y1="20" y2="4"></line><line x1="6" x2="6" y1="20" y2="14"></line>',
621
+ 'chart-line': '<path d="M3 3v16a2 2 0 0 0 2 2h16"></path><path d="m19 9-5 5-4-4-3 3"></path>',
622
+ bot: '<path d="M12 8V4H8"></path><rect width="16" height="12" x="4" y="8" rx="2"></rect><path d="M2 14h2"></path><path d="M20 14h2"></path><path d="M15 13v2"></path><path d="M9 13v2"></path>',
623
+ tag: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"></path><circle cx="7.5" cy="7.5" r="1" fill="currentColor" stroke="none"></circle>',
624
+ users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>',
625
+ 'circle-check': '<circle cx="12" cy="12" r="10"></circle><path d="m9 12 2 2 4-4"></path>',
626
+ 'git-branch': '<line x1="6" x2="6" y1="3" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path>',
627
+ 'git-commit-horizontal': '<circle cx="12" cy="12" r="3"></circle><line x1="3" x2="9" y1="12" y2="12"></line><line x1="15" x2="21" y1="12" y2="12"></line>',
628
+ 'file-text': '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path><path d="M10 9H8"></path><path d="M16 13H8"></path><path d="M16 17H8"></path>',
629
+ 'heart-pulse': '<path d="M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7Z"></path><path d="M3.2 12h3l1.5-3 3 6 1.5-3h3.6"></path>',
630
+ 'list-checks': '<path d="m3 17 2 2 4-4"></path><path d="m3 7 2 2 4-4"></path><path d="M13 6h8"></path><path d="M13 12h8"></path><path d="M13 18h8"></path>',
631
+ lock: '<rect width="18" height="11" x="3" y="11" rx="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path>',
632
+ 'messages-square': '<path d="M14 9a2 2 0 0 1-2 2H6l-4 4V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z"></path><path d="M18 9h2a2 2 0 0 1 2 2v10l-4-4h-6a2 2 0 0 1-2-2v-1"></path>',
633
+ ship: '<path d="M12 10.189V14"></path><path d="M12 2v3"></path><path d="M19 13V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6"></path><path d="M19.38 20A11.6 11.6 0 0 0 21 14l-8.188-3.639a2 2 0 0 0-1.624 0L3 14a11.6 11.6 0 0 0 2.81 7.76"></path><path d="M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1s1.2 1 2.5 1c2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"></path>',
634
+ 'triangle-alert': '<path d="m21.7 18-8-14a2 2 0 0 0-3.4 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path>',
635
+ };
636
+ document.querySelectorAll('[data-icon]').forEach((node) => {
637
+ const name = node.dataset.icon;
638
+ node.innerHTML = '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">' + (icons[name] || '') + '</svg>';
639
+ });
640
+ }
641
+ function fillFeed(id, entries) {
642
+ document.getElementById(id).innerHTML = entries.length
643
+ ? entries.map(renderFeedEntry).join('')
644
+ : '<div class="muted">No entries.</div>';
645
+ }
646
+ function renderFeedEntry(entry) {
647
+ if (entry.type === 'Commit') {
648
+ return '<div class="feed-row"><dd><div class="feed-title"><span class="feed-icon"><svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><line x1="3" x2="9" y1="12" y2="12"></line><line x1="15" x2="21" y1="12" y2="12"></line></svg></span>' + escapeHtml(entry.title) + '</div></dd></div>';
649
+ }
650
+ return '<div class="feed-row feed-row-labeled">'
651
+ + '<dd>'
652
+ + '<div class="feed-agent"><span class="feed-agent-icon"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 8V4H8"></path><rect width="16" height="12" x="4" y="8" rx="2"></rect><path d="M2 14h2"></path><path d="M20 14h2"></path><path d="M15 13v2"></path><path d="M9 13v2"></path></svg></span><span>' + escapeHtml(formatAgentName(entry.type)) + '</span></div>'
653
+ + (entry.meta ? '<div class="feed-meta">' + escapeHtml(entry.meta) + '</div>' : '')
654
+ + (entry.warning ? '<div class="feed-warning">' + escapeHtml(entry.warning) + '</div>' : '')
655
+ + '<div class="feed-title">' + escapeHtml(entry.title) + '</div>'
656
+ + '</dd>'
657
+ + '</div>';
658
+ }
659
+ function formatAge(seconds) {
660
+ if (seconds === null || seconds === undefined) return 'unknown';
661
+ if (seconds < 60) return seconds + 's';
662
+ if (seconds < 3600) return Math.floor(seconds / 60) + 'm';
663
+ return Math.floor(seconds / 3600) + 'h';
664
+ }
665
+ function formatAgentName(value) {
666
+ return String(value || '').replace(/^@/, '');
667
+ }
668
+ function escapeHtml(value) {
669
+ return String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
670
+ }
671
+ renderIcons();
672
+ syncQueueViewFromHash();
673
+ load();
674
+ new EventSource('/events').addEventListener('update', load);
675
+ </script>
676
+ </body>
677
+
678
+ </html>