@triflux/remote 10.48.1 → 10.50.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.
@@ -1,756 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>Triflux CTO Tray</title>
6
- <style>
7
- :root {
8
- --bg-color: rgba(30, 30, 30, 0.76);
9
- --border-color: rgba(255,255,255,0.15);
10
- --text-main: #ffffff;
11
- --text-muted: rgba(255,255,255,0.58);
12
- --accent: #0A84FF;
13
- --danger: #FF453A;
14
- --success: #32D74B;
15
- --warning: #FFD60A;
16
- --hover-bg: rgba(255,255,255,0.1);
17
- }
18
- * { box-sizing: border-box; }
19
- body { -webkit-font-smoothing: antialiased; margin: 0; background: transparent; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; color: var(--text-main); }
20
- ::-webkit-scrollbar { width: 6px; }
21
- ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 10px; }
22
- .mac-popover { width: 100%; height: 100vh; background: var(--bg-color); backdrop-filter: blur(22px) saturate(155%); -webkit-backdrop-filter: blur(22px) saturate(155%); border: 1px solid var(--border-color); border-radius: 14px; overflow: hidden; box-shadow: 0 20px 40px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.05) inset; display: flex; flex-direction: column; }
23
- .mac-header { padding: 15px 16px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(255,255,255,0.1); }
24
- .title-area { display: flex; align-items: center; gap: 10px; min-width: 0; }
25
- .mac-logo { width: 24px; height: 24px; background: linear-gradient(135deg, #0A84FF, #5E5CE6); border-radius: 6px; display: grid; place-items: center; font-weight: 700; font-size: 12px; flex: 0 0 auto; }
26
- .mac-title { font-size: 14px; font-weight: 600; letter-spacing: -0.2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
27
- .mac-status { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); }
28
- .status-dot, .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex: 0 0 auto; }
29
- .status-dot.on, .dot.on { background: var(--success); box-shadow: 0 0 8px var(--success); }
30
- .status-dot.off, .dot.off { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
31
- .dot.claude { background: #d19a66; box-shadow: 0 0 5px #d19a66; }
32
- .dot.codex { background: #ffffff; box-shadow: 0 0 5px rgba(255,255,255,0.5); }
33
- .dot.antigravity { background: #61afef; box-shadow: 0 0 5px #61afef; }
34
- .mac-tabs { display: flex; padding: 8px; gap: 4px; border-bottom: 1px solid rgba(255,255,255,0.05); }
35
- .mac-tab { flex: 1; background: transparent; border: none; color: var(--text-muted); font-size: 12px; font-weight: 600; padding: 6px 8px; cursor: pointer; border-radius: 7px; }
36
- .mac-tab.active { background: rgba(255,255,255,0.13); color: var(--text-main); }
37
- .mac-content-area { overflow-y: auto; flex: 1; }
38
- .tab-pane { display: none; }
39
- .tab-pane.active { display: block; }
40
- .section { padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,0.05); }
41
- .section-title { font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
42
- .stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
43
- .stat-box { background: rgba(0,0,0,0.22); border-radius: 8px; padding: 10px; border: 1px solid rgba(255,255,255,0.06); min-width: 0; }
44
- .stat-label { font-size: 10px; color: var(--text-muted); margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
45
- .stat-value { font-size: 18px; font-weight: 650; font-variant-numeric: tabular-nums; display: flex; align-items: baseline; gap: 4px; }
46
- .stat-unit { font-size: 10px; font-weight: 400; color: var(--text-muted); }
47
- .session-overview { display: grid; grid-template-columns: 1.25fr repeat(3, minmax(0, 0.78fr)); gap: 8px; }
48
- .summary-card { min-width: 0; border-radius: 10px; padding: 9px 10px; background: rgba(0,0,0,0.18); border: 1px solid rgba(255,255,255,0.07); }
49
- .summary-card.primary { background: rgba(10,132,255,0.14); border-color: rgba(10,132,255,0.24); }
50
- .summary-kpi { font-size: 18px; line-height: 1; font-weight: 760; font-variant-numeric: tabular-nums; letter-spacing: -0.3px; }
51
- .summary-label { margin-top: 4px; font-size: 10px; font-weight: 700; color: rgba(255,255,255,0.76); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
52
- .summary-sub { margin-top: 2px; font-size: 9px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
53
- .models-list, .server-list, .session-list { display: flex; flex-direction: column; gap: 8px; }
54
- .model-row { display: grid; grid-template-columns: minmax(76px, auto) 1fr auto; gap: 10px; align-items: center; font-size: 12px; min-width: 0; }
55
- .model-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
56
- .model-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
57
- .model-icon { width: 16px; height: 16px; border-radius: 4px; background: rgba(255,255,255,0.1); display: grid; place-items: center; font-size: 10px; font-weight: 700; flex: 0 0 auto; }
58
- .progress-bar-bg { height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; min-width: 30px; }
59
- .progress-bar-fill { height: 100%; border-radius: 2px; }
60
- .value-pill { font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; }
61
- .mini-card, .mcp-card { background: rgba(0,0,0,0.18); border: 1px solid rgba(255,255,255,0.06); border-radius: 9px; padding: 9px 10px; min-width: 0; }
62
- .mini-head, .mcp-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; min-width: 0; }
63
- .mini-title, .mcp-name { font-size: 12px; font-weight: 650; line-height: 1.25; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
64
- .path-text { font-size: 10px; color: var(--text-muted); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
65
- .copy-btn { background: transparent; border: 1px solid rgba(10,132,255,0.45); color: #64b5ff; font-size: 10px; line-height: 1; padding: 4px 7px; cursor: pointer; border-radius: 6px; flex: 0 0 auto; }
66
- .copy-btn:hover { background: var(--accent); color: #fff; }
67
- .agent-view { display: flex; flex-direction: column; gap: 8px; }
68
- .project-group { min-width: 0; border: 1px solid rgba(255,255,255,0.06); background: rgba(0,0,0,0.13); border-radius: 9px; overflow: hidden; }
69
- .project-group > summary, .session-card > summary, .bubble-wrap > summary { list-style: none; }
70
- .project-group > summary::-webkit-details-marker, .session-card > summary::-webkit-details-marker, .bubble-wrap > summary::-webkit-details-marker { display: none; }
71
- .project-summary { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; gap: 8px; align-items: center; padding: 9px 10px; cursor: pointer; }
72
- .project-chevron { color: rgba(255,255,255,0.44); font-size: 11px; transform: rotate(-90deg); transition: transform 120ms ease; }
73
- .project-group[open] .project-chevron { transform: rotate(0deg); }
74
- .project-name { color: #b6b1e8; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; font-weight: 750; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
75
- .project-count { display: inline-flex; align-items: center; justify-content: center; min-height: 20px; border-radius: 999px; padding: 2px 7px; color: rgba(255,255,255,0.72); background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.07); font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
76
- .project-path { color: var(--text-muted); font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 10px; padding: 0 9px 8px 32px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
77
- .project-body { padding: 0 8px 9px; display: flex; flex-direction: column; gap: 7px; }
78
- .session-card { border: 1px solid rgba(10,132,255,0.13); background: rgba(255,255,255,0.035); border-radius: 9px; overflow: hidden; }
79
- .session-summary { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 8px; align-items: center; padding: 8px 9px; cursor: pointer; }
80
- .session-summary-title { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 11px; font-weight: 740; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
81
- .session-summary-sub { color: var(--text-muted); font-size: 10px; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
82
- .session-detail { display: flex; flex-direction: column; gap: 9px; padding: 9px; border-top: 1px solid rgba(255,255,255,0.05); }
83
- .detail-section { min-width: 0; display: flex; flex-direction: column; gap: 7px; }
84
- .detail-label { color: var(--text-muted); font-size: 9px; font-weight: 800; letter-spacing: 0.5px; text-transform: uppercase; }
85
- .detail-empty { color: var(--text-muted); border: 1px dashed rgba(255,255,255,0.10); border-radius: 8px; padding: 8px; font-size: 10px; }
86
- .runtime-list { display: flex; flex-direction: column; gap: 7px; }
87
- .runtime-card { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 8px; align-items: center; padding: 8px; border-radius: 8px; background: rgba(255,255,255,0.045); border: 1px solid rgba(255,255,255,0.07); }
88
- .runtime-main { min-width: 0; }
89
- .runtime-title { display: flex; align-items: center; gap: 6px; min-width: 0; }
90
- .runtime-command { color: rgba(255,255,255,0.90); font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 11px; font-weight: 750; }
91
- .runtime-sub { color: var(--text-muted); font-size: 10px; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
92
- .runtime-meta { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 5px; }
93
- .chat-session-title { display: flex; align-items: center; justify-content: space-between; gap: 6px; min-width: 0; margin-bottom: 5px; }
94
- .chat-session-id { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 10px; color: rgba(255,255,255,0.78); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
95
- .bubble-card { min-width: 0; }
96
- .bubble-wrap { min-width: 0; }
97
- .bubble-preview { display: block; cursor: pointer; border-radius: 12px; padding: 8px 9px; font-size: 11px; line-height: 1.35; color: rgba(255,255,255,0.88); background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.08); max-height: 48px; overflow: hidden; }
98
- .bubble-card.worker .bubble-preview { border-top-left-radius: 4px; }
99
- .bubble-card.cto .bubble-preview { background: rgba(10,132,255,0.14); border-color: rgba(10,132,255,0.18); border-top-right-radius: 4px; }
100
- .bubble-full { margin: 6px 0 0; white-space: pre-wrap; word-break: break-word; max-height: 190px; overflow: auto; padding: 8px; border-radius: 8px; background: rgba(0,0,0,0.28); color: rgba(255,255,255,0.82); font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 10px; line-height: 1.35; }
101
- .agent-line { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 5px; align-items: start; min-width: 0; padding: 3px 0; }
102
- .agent-line.worker { padding-left: 10px; }
103
- .agent-marker { color: rgba(255,255,255,0.46); font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; line-height: 20px; text-align: center; user-select: none; }
104
- .agent-body { min-width: 0; border-radius: 7px; padding: 4px 5px; }
105
- .agent-body:hover { background: rgba(255,255,255,0.05); }
106
- .agent-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; align-items: center; min-width: 0; }
107
- .agent-title { color: rgba(255,255,255,0.90); font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
108
- .agent-sub { color: var(--text-muted); font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 10px; line-height: 1.35; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
109
- .agent-meta { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 5px; }
110
- .agent-state { color: var(--text-muted); font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 11px; white-space: nowrap; }
111
- .agent-state.active, .agent-state.on { color: var(--success); }
112
- .agent-state.idle { color: var(--warning); }
113
- .agent-state.stopped, .agent-state.stale, .agent-state.error { color: var(--danger); }
114
- .badge-row, .client-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
115
- .chip { display: inline-flex; align-items: center; gap: 5px; min-height: 20px; border-radius: 999px; padding: 3px 7px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.07); font-size: 10px; color: rgba(255,255,255,0.76); }
116
- .copy-chip { cursor: pointer; }
117
- .copy-chip:hover { border-color: rgba(10,132,255,0.42); color: #8ec9ff; }
118
- .copy-chip, .copy-btn { -webkit-user-drag: none; }
119
- .chat-session-id, .bubble-preview, .bubble-full, .runtime-sub, .path-text, .session-summary-title, .session-summary-sub, .project-path { -webkit-user-select: text; user-select: text; }
120
- .chip.on { color: var(--success); border-color: rgba(50,215,75,0.25); background: rgba(50,215,75,0.08); }
121
- .chip.partial { color: var(--warning); border-color: rgba(255,214,10,0.25); background: rgba(255,214,10,0.08); }
122
- .chip.off { color: var(--danger); border-color: rgba(255,69,58,0.25); background: rgba(255,69,58,0.08); }
123
- .client-chip-row { display: flex; gap: 7px; flex-wrap: wrap; }
124
- .client-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; border-radius: 999px; padding: 4px 8px; background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.08); color: rgba(255,255,255,0.82); font-size: 11px; }
125
- .client-chip strong { font-variant-numeric: tabular-nums; color: var(--text-main); }
126
- .client-chip.on { border-color: rgba(50,215,75,0.24); }
127
- .client-chip.partial { border-color: rgba(255,214,10,0.24); }
128
- .client-chip.off { border-color: rgba(255,69,58,0.24); }
129
- .agent-badge { width: 20px; height: 20px; display: inline-grid; place-items: center; flex: 0 0 auto; border-radius: 6px; font-size: 11px; font-weight: 850; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; border: 1px solid rgba(255,255,255,0.12); }
130
- .agent-tag { justify-content: center; }
131
- .agent-tag.claude { color: #d19a66; background: rgba(209,154,102,0.12); border-color: rgba(209,154,102,0.32); }
132
- .agent-tag.codex { color: #ffffff; background: rgba(255,255,255,0.10); border-color: rgba(255,255,255,0.32); }
133
- .agent-tag.antigravity { color: #61afef; background: rgba(97,175,239,0.12); border-color: rgba(97,175,239,0.32); }
134
- .muted { color: var(--text-muted); font-size: 11px; }
135
- .status-text.on { color: var(--success); }
136
- .status-text.partial { color: var(--warning); }
137
- .status-text.off, .status-text.error { color: var(--danger); }
138
- </style>
139
- </head>
140
- <body>
141
- <div class="mac-popover">
142
- <div class="mac-header">
143
- <div class="title-area"><div class="mac-logo">T</div><div class="mac-title">Triflux CTO</div></div>
144
- <div class="mac-status"><span class="status-dot on"></span><span>Connected</span></div>
145
- </div>
146
- <div class="mac-tabs">
147
- <button class="mac-tab active" onclick="switchTab('sessions', this)">Sessions</button>
148
- <button class="mac-tab" onclick="switchTab('gateway', this)">Gateway MCP</button>
149
- </div>
150
- <div class="mac-content-area">
151
- <div id="tab-sessions" class="tab-pane active"><div class="section muted">Loading sessions…</div></div>
152
- <div id="tab-gateway" class="tab-pane"><div class="section muted">Loading MCP…</div></div>
153
- </div>
154
- </div>
155
- <script>
156
- const CLIENT_LABEL = { claude: 'Claude', codex: 'Codex', antigravity: 'Antigravity' };
157
- const STATUS_LABEL = { on: 'ON', partial: 'PARTIAL', off: 'OFF' };
158
-
159
- function switchTab(id, btn) {
160
- document.querySelectorAll('.tab-pane').forEach(e => e.classList.remove('active'));
161
- document.querySelectorAll('.mac-tab').forEach(e => e.classList.remove('active'));
162
- document.getElementById('tab-' + id).classList.add('active');
163
- btn.classList.add('active');
164
- }
165
-
166
- async function fetchState() {
167
- try {
168
- const res = await fetch('/api/tray-state');
169
- const data = await res.json();
170
- renderSessions(data);
171
- renderGateway(data.mcp);
172
- document.querySelector('.mac-status').innerHTML = '<span class="status-dot on"></span><span>Connected</span>';
173
- } catch (e) {
174
- console.error(e);
175
- document.querySelector('.mac-status').innerHTML = '<span class="status-dot off"></span><span>Disconnected</span>';
176
- document.getElementById('tab-sessions').innerHTML = '<div class="section muted">Error loading sessions…</div>';
177
- }
178
- }
179
-
180
- function escapeHtml(value) {
181
- return String(value ?? '').replace(/[&<>"']/g, ch => ({
182
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
183
- }[ch]));
184
- }
185
- function shortId(value) { return String(value || '').substring(0, 8); }
186
- function pct(count, max) { return Math.max(4, Math.min(100, max ? Math.round((count / max) * 100) : 4)); }
187
- function dataAttr(value) { return escapeHtml(String(value ?? '')); }
188
- function renderAgentTag(agent) {
189
- if (!agent?.id) return '';
190
- const initial = (agent.icon || { claude: 'C', codex: 'X', antigravity: 'A' }[agent.id] || agent.id.slice(0, 1)).toUpperCase();
191
- return `<span class="agent-badge agent-tag ${escapeHtml(agent.id)}" title="${escapeHtml(agent.label || agent.id)}" aria-label="${escapeHtml(agent.label || agent.id)}">${escapeHtml(initial)}</span>`;
192
- }
193
- function renderCopyChip(label, value) {
194
- if (!value) return '';
195
- return `<span class="chip copy-chip" draggable="false" title="Copy ${escapeHtml(label)}" data-copy-text="${dataAttr(value)}">${escapeHtml(label)} ${escapeHtml(shortId(value))}</span>`;
196
- }
197
-
198
- function shellQuote(value) {
199
- const text = String(value ?? '').trim();
200
- return `'${text.replace(/'/g, `'\\''`)}'`;
201
- }
202
-
203
- function commandCwd(row) {
204
- const cwd = String(row?.cwd || row?.worktreePath || '').trim();
205
- if (!cwd || cwd === 'local' || cwd === 'local runtime') return '';
206
- if (/^https?:\/\//iu.test(cwd)) return '';
207
- return cwd;
208
- }
209
-
210
- function sessionAttachCommand(row) {
211
- const sid = String(row?.sessionId || '').trim();
212
- if (!sid) return '';
213
- const agentId = String(row?.agent_id || row?.agent?.id || '').trim().toLowerCase();
214
- const cwd = commandCwd(row);
215
- const prefix = cwd ? `cd ${shellQuote(cwd)} && ` : '';
216
- if (agentId === 'codex') return `${prefix}codex resume ${shellQuote(sid)}`;
217
- if (agentId === 'claude') return `${prefix}claude --resume ${shellQuote(sid)}`;
218
- if (agentId === 'antigravity') {
219
- const conversationId = String(row?.conversationId || '').trim();
220
- const token = conversationId || sid;
221
- const suffix = token === sid ? '' : ` # session ${sid}`;
222
- return `${prefix}agy --conversation ${shellQuote(token)}${suffix}`;
223
- }
224
- return `${prefix}tfx-live start --session ${shellQuote(sid)} --resume ${shellQuote(sid)}`;
225
- }
226
-
227
- function renderSessionCopyButton(row) {
228
- const sid = String(row?.sessionId || '').trim();
229
- const command = sessionAttachCommand(row);
230
- if (!sid || !command) return '';
231
- const pid = numericPid(row?.pid);
232
- const agentId = row?.agent_id || row?.agent?.id || '';
233
- return `<button class="copy-btn" draggable="false" data-copy-text="${dataAttr(command)}" data-session-id="${dataAttr(sid)}" data-pid="${dataAttr(pid)}" data-agent-id="${dataAttr(agentId)}" title="Copy launch command">Copy</button>`;
234
- }
235
-
236
- function collectOpenDetailKeys(container) {
237
- try {
238
- return new Set([...container.querySelectorAll('details[data-open-key][open]')].map(detail => detail.dataset.openKey).filter(Boolean));
239
- } catch (_err) {
240
- return new Set();
241
- }
242
- }
243
-
244
- function restoreOpenDetails(container, openKeys) {
245
- if (!openKeys || openKeys.size === 0) return;
246
- try {
247
- for (const detail of container.querySelectorAll('details[data-open-key]')) {
248
- if (openKeys.has(detail.dataset.openKey)) detail.open = true;
249
- }
250
- } catch (_err) {
251
- // Test and legacy WebView fallback: missing querySelectorAll should not break rendering.
252
- }
253
- }
254
-
255
- function renderRuntimeClients(runtime) {
256
- const clients = Array.isArray(runtime?.summary?.clients) ? runtime.summary.clients : [];
257
- if (clients.length === 0) return '<div class="mini-card muted">No runtime clients reported</div>';
258
- return `<div class="client-chip-row">${clients.map(client => `
259
- <span class="client-chip ${escapeHtml(client.status)}">
260
- <span class="dot ${escapeHtml(client.id)}" style="background:${escapeHtml(client.color)};box-shadow:0 0 6px ${escapeHtml(client.color)}"></span>
261
- <span>${escapeHtml(client.label)}</span>
262
- <strong>${escapeHtml(client.count || 0)}</strong>
263
- </span>
264
- `).join('')}</div>`;
265
- }
266
-
267
- function hygieneCount(hygiene, key) {
268
- const value = Number(hygiene?.[key] || 0);
269
- return Number.isFinite(value) && value > 0 ? value : 0;
270
- }
271
-
272
- function renderCtoHygiene(hygiene) {
273
- if (!hygiene || typeof hygiene !== 'object') return '';
274
- const actions = Array.isArray(hygiene.actions) ? hygiene.actions.slice(0, 2) : [];
275
- const actionCount = hygieneCount(hygiene, 'action_count') || actions.length;
276
- const actionHtml = actions.length > 0
277
- ? `<div class="models-list">${actions.map(action => `
278
- <div class="mini-card">
279
- <div class="mini-head">
280
- <div class="mini-title">${escapeHtml(action.id || action.kind || 'hygiene item')}</div>
281
- <div class="value-pill status-text partial">${escapeHtml(action.status || 'action')}</div>
282
- </div>
283
- <div class="path-text">${escapeHtml(action.action || action.kind || '')}</div>
284
- </div>
285
- `).join('')}</div>`
286
- : '<div class="mini-card muted">No hygiene actions reported</div>';
287
- return `
288
- <div class="section">
289
- <div class="section-title">CTO Hygiene</div>
290
- <div class="stats-grid">
291
- <div class="stat-box"><div class="stat-label">Active tasks</div><div class="stat-value">${hygieneCount(hygiene, 'active_tasks')}<span class="stat-unit">open</span></div></div>
292
- <div class="stat-box"><div class="stat-label">Stale sessions</div><div class="stat-value">${hygieneCount(hygiene, 'stale_sessions')}<span class="stat-unit">stale</span></div></div>
293
- <div class="stat-box"><div class="stat-label">Orphan worktrees</div><div class="stat-value">${hygieneCount(hygiene, 'orphan_worktrees')}<span class="stat-unit">orphan</span></div></div>
294
- <div class="stat-box"><div class="stat-label">Actions</div><div class="stat-value">${actionCount}<span class="stat-unit">dry-run</span></div></div>
295
- </div>
296
- <div class="path-text">${hygieneCount(hygiene, 'superseded_checkpoints')} superseded checkpoints · ${hygieneCount(hygiene, 'unknown_owner')} unknown owners</div>
297
- ${actionHtml}
298
- </div>
299
- `;
300
- }
301
-
302
- function liveFallbackRows(live) {
303
- return live.map(session => ({
304
- sessionId: session.sessionId,
305
- status: session.phase || 'active',
306
- agent_id: session.agent_id || session.agent?.id || '',
307
- agent: session.agent || null,
308
- role: session.role || '',
309
- roles: session.roles || [],
310
- metadata: session.metadata || {},
311
- promptText: session.promptText || '',
312
- prompt: session.prompt || '',
313
- userPrompt: session.userPrompt || '',
314
- handoffPrompt: session.handoffPrompt || '',
315
- command: session.command || '',
316
- source: session.source || '',
317
- conversationId: session.conversationId || '',
318
- focusable: session.focusable !== false,
319
- taskSummary: session.taskSummary || session.command || (session.pid ? `PID ${session.pid}` : ''),
320
- pid: session.pid || '',
321
- cwd: session.cwd || session.host || 'local runtime',
322
- worktreePath: session.worktreePath || '',
323
- elapsed: session.elapsed || '',
324
- started_at: session.started_at || null,
325
- }));
326
- }
327
-
328
- function compactText(value, fallback = '') {
329
- const text = String(value ?? '').replace(/\s+/g, ' ').trim();
330
- const safe = text || fallback;
331
- return safe.length > 84 ? safe.slice(0, 81) + '…' : safe;
332
- }
333
-
334
- function displayPath(value) {
335
- const path = String(value || '').trim();
336
- if (!path) return '';
337
- return path.replace(/^\/Users\/[^/]+/, '~');
338
- }
339
-
340
- function normalizeProjectPath(value) {
341
- const path = String(value || '').trim();
342
- if (!path) return '';
343
- if (path === '/' || /^\/+$/u.test(path) || /^[A-Za-z]:[\\/]$/u.test(path)) return path;
344
- return path.replace(/[\\/]+$/u, '') || path;
345
- }
346
-
347
- function worktreeFamilyRoot(path) {
348
- const normalized = normalizeProjectPath(path);
349
- const parts = normalized.split(/[\\/]/u);
350
- const index = parts.lastIndexOf('.worktrees');
351
- if (index <= 0) return '';
352
- return parts.slice(0, index).join(normalized.includes('\\') ? '\\' : '/') || '';
353
- }
354
-
355
- function projectName(value) {
356
- const path = normalizeProjectPath(value);
357
- if (!path) return 'local';
358
- return path.split(/[\\/]/u).filter(Boolean).pop() || path;
359
- }
360
-
361
- function projectPathFor(row, hub, fallback = '') {
362
- const cwd = normalizeProjectPath(String(row?.cwd || row?.worktreePath || '').trim());
363
- const rawRoot = normalizeProjectPath(String(hub?.projectRoot || fallback || '').trim());
364
- const familyRoot = worktreeFamilyRoot(rawRoot) || rawRoot;
365
- if (familyRoot && cwd && (cwd === familyRoot || cwd.startsWith(`${familyRoot}/.worktrees/`) || cwd.startsWith(`${familyRoot}/worktrees/`))) return familyRoot;
366
- if (cwd && cwd !== 'local' && cwd !== 'local runtime') return worktreeFamilyRoot(cwd) || cwd;
367
- return normalizeProjectPath(familyRoot || cwd || 'local');
368
- }
369
-
370
- function getProjectGroup(groups, projectPath) {
371
- const key = normalizeProjectPath(projectPath) || 'local';
372
- if (!groups.has(key)) groups.set(key, { projectPath: key, ctos: [], workers: [] });
373
- return groups.get(key);
374
- }
375
-
376
- function ctoRoleFromPayload(cto) {
377
- return cto?.roles?.cto || cto?.succession?.cto || null;
378
- }
379
-
380
- function normalizedRoleValue(value) {
381
- return String(value || '').trim().toLowerCase();
382
- }
383
-
384
- function rowHasCtoRole(row, ctoRole) {
385
- const sid = String(row?.sessionId || '').trim();
386
- const agentId = String(row?.agent_id || row?.agent?.id || '').trim();
387
- const leader = String(ctoRole?.leader_agent_id || '').trim();
388
- if (leader) return Boolean(agentId === leader || sid === leader || sid === `cto:${leader}`);
389
- if (ctoRole) return false;
390
- const role = normalizedRoleValue(row?.role || row?.metadata?.role || row?.sessionKind);
391
- const roles = Array.isArray(row?.roles || row?.metadata?.roles)
392
- ? (row.roles || row.metadata.roles).map(normalizedRoleValue)
393
- : String(row?.roles || row?.metadata?.roles || '').split(/[,\s]+/u).map(normalizedRoleValue);
394
- if (role === 'cto' || roles.includes('cto') || row?.metadata?.cto === true) return true;
395
- if (agentId === 'cto') return true;
396
- if (/^cto[:._-]/iu.test(sid)) return true;
397
- return false;
398
- }
399
-
400
- function ctoRoleRow(ctoRole, hub, fallbackProject) {
401
- if (!ctoRole) return null;
402
- const status = ctoRole.status === 'active' ? 'active' : 'offline';
403
- const leader = ctoRole.leader_agent_id || ctoRole.previous_leader_agent_id || 'unassigned';
404
- return {
405
- sessionId: ctoRole.leader_agent_id ? `cto:${ctoRole.leader_agent_id}` : `cto:${shortId(hub.id || fallbackProject || 'role')}`,
406
- status,
407
- cwd: hub.projectRoot || fallbackProject || 'local',
408
- taskSummary: status === 'active'
409
- ? `CTO leader ${leader} · epoch ${ctoRole.leader_epoch || 0} · pending ${ctoRole.pending_count || 0}`
410
- : `CTO offline · previous ${leader} · candidates ${ctoRole.live_candidate_count || 0}`,
411
- synthetic: true,
412
- roleStatus: ctoRole,
413
- };
414
- }
415
-
416
- function buildProjectGroups(activeSessions, liveRows, hub, ctoRole = null) {
417
- const groups = new Map();
418
- const fallbackProject = hub.projectRoot || activeSessions[0]?.cwd || activeSessions[0]?.worktreePath || 'local';
419
- for (const session of activeSessions) {
420
- const group = getProjectGroup(groups, projectPathFor(session, hub, fallbackProject));
421
- if (rowHasCtoRole(session, ctoRole)) group.ctos.push(session);
422
- else group.workers.push(session);
423
- }
424
-
425
- const activeSessionIds = new Set(
426
- activeSessions
427
- .map(session => session.sessionId)
428
- .filter(Boolean)
429
- );
430
- for (const worker of liveRows) {
431
- if (activeSessionIds.has(worker.sessionId)) continue;
432
- const group = getProjectGroup(groups, projectPathFor(worker, hub, fallbackProject));
433
- if (rowHasCtoRole(worker, ctoRole)) group.ctos.push(worker);
434
- else group.workers.push(worker);
435
- }
436
-
437
- if (groups.size === 0 && fallbackProject) getProjectGroup(groups, fallbackProject);
438
- const roleRow = ctoRoleRow(ctoRole, hub, fallbackProject);
439
- if (roleRow) {
440
- const project = projectPathFor(roleRow, hub, fallbackProject);
441
- const group = getProjectGroup(groups, project);
442
- const leader = String(ctoRole?.leader_agent_id || '').trim();
443
- const existing = group.ctos.find(row => {
444
- const sid = String(row?.sessionId || '').trim();
445
- const agentId = String(row?.agent_id || row?.agent?.id || '').trim();
446
- return sid === roleRow.sessionId || (leader && (sid === leader || agentId === leader));
447
- });
448
- if (existing) {
449
- existing.roleStatus = ctoRole;
450
- } else {
451
- group.ctos.unshift(roleRow);
452
- }
453
- }
454
- return [...groups.values()].sort((a, b) => a.projectPath.localeCompare(b.projectPath));
455
- }
456
-
457
- function explicitPromptText(row) {
458
- return String(row?.promptText || row?.prompt || row?.userPrompt || row?.handoffPrompt || '').trim();
459
- }
460
-
461
- function numericPid(value) {
462
- const text = String(value ?? '').trim();
463
- if (!text) return '';
464
- const pid = Number(text);
465
- return Number.isFinite(pid) && pid > 0 ? pid : '';
466
- }
467
-
468
- function isRuntimeRow(row) {
469
- return Boolean(numericPid(row?.pid)) || Boolean(row?.command);
470
- }
471
-
472
- function promptCandidateText(row) {
473
- const explicit = explicitPromptText(row);
474
- if (explicit) return explicit;
475
- if (isRuntimeRow(row)) return '';
476
- return String(row?.taskSummary || '').trim();
477
- }
478
-
479
- function equivalentText(left, right) {
480
- return String(left || '').replace(/\s+/g, ' ').trim() === String(right || '').replace(/\s+/g, ' ').trim();
481
- }
482
-
483
- function bubbleText(row, fallback) {
484
- return promptCandidateText(row) || String(row?.command || row?.sessionKind || row?.sessionId || fallback || '').trim() || fallback;
485
- }
486
-
487
- function isSyntheticCto(row) {
488
- return row?.synthetic === true || row?.sessionId?.startsWith('cto-') && row?.taskSummary === 'CTO runtime overlay';
489
- }
490
-
491
- function looksLikeRawCommand(text) {
492
- const value = String(text || '').trim();
493
- return /\/node_modules\/|\/bin\/|--dangerously-|model_instructions_file=|(?:^|\s)--(?:secret|token|api-key|password|pass|key)(?:[=\s]|$)|\bAuthorization\s*:|\bBearer\s+\S+|^(?:node|npm|pnpm|yarn|python(?:3)?|bash|zsh|sh|npx|bun|curl|wget)(?:\s|$)|^claude(?:\s+agents)?(?:\s|$)|^codex(?:\s|$)|^antigravity(?:\s|$)|^(?:\/|~\/|\.\/|\.\.\/|[A-Za-z]:[\\/])\S+/u.test(value);
494
- }
495
-
496
- function hasPromptText(row) {
497
- const text = promptCandidateText(row);
498
- const command = String(row?.command || '').trim();
499
- return Boolean(text) && !isSyntheticCto(row) && !looksLikeRawCommand(text) && (!command || !equivalentText(text, command));
500
- }
501
-
502
- function commandLabel(row) {
503
- const text = String(row?.command || row?.taskSummary || '').trim();
504
- const agentId = row?.agent_id || row?.agent?.id || '';
505
- if (/^claude\s+agents\b/u.test(text)) return 'claude agents';
506
- if (/^claude\b/u.test(text)) return 'claude';
507
- if (/codex(?:\s|$)|\/codex(?:\s|$)/u.test(text) || agentId === 'codex') return 'codex';
508
- if (/antigravity|\bagy\b/u.test(text) || agentId === 'antigravity') return 'antigravity';
509
- return agentId || 'runtime';
510
- }
511
-
512
- function renderChatBubble(row, { side = 'worker' } = {}) {
513
- const sid = row.sessionId || '';
514
- const text = bubbleText(row, side === 'cto' ? 'CTO prompt unavailable' : 'Worker prompt unavailable');
515
- const copyButton = row.synthetic ? '' : renderSessionCopyButton(row);
516
- const badge = side === 'cto'
517
- ? '<span class="agent-badge">T</span>'
518
- : renderAgentTag(row.agent) || '<span class="agent-badge">A</span>';
519
- return `
520
- <div class="bubble-card ${escapeHtml(side)}">
521
- <div class="chat-session-title">
522
- <span style="display:flex;align-items:center;gap:6px;min-width:0;">${badge}<span class="chat-session-id">${escapeHtml(sid || side)}</span></span>
523
- ${copyButton}
524
- </div>
525
- <details class="bubble-wrap" data-open-key="bubble:${escapeHtml(side)}:${dataAttr(sid || text.slice(0, 24))}">
526
- <summary class="bubble-preview">${escapeHtml(compactText(text, 'prompt'))}</summary>
527
- <pre class="bubble-full">${escapeHtml(text)}</pre>
528
- </details>
529
- </div>
530
- `;
531
- }
532
-
533
- function renderRuntimeCard(row) {
534
- const sid = row.sessionId || '';
535
- const status = row.status || row.phase || 'active';
536
- const copyButton = row.focusable === false ? '' : renderSessionCopyButton(row);
537
- return `
538
- <div class="runtime-card">
539
- ${renderAgentTag(row.agent)}
540
- <div class="runtime-main">
541
- <div class="runtime-title"><span class="runtime-command">${escapeHtml(commandLabel(row))}</span><span class="agent-state ${escapeHtml(status)}">${escapeHtml(status)}</span></div>
542
- <div class="runtime-sub">${escapeHtml(displayPath(row.cwd || row.host || 'local runtime'))}${row.elapsed ? ` · ${escapeHtml(row.elapsed)}` : ''}</div>
543
- </div>
544
- ${copyButton}
545
- </div>
546
- `;
547
- }
548
-
549
- function renderCtoChat(ctoRow, workers) {
550
- const status = ctoRow.status || ctoRow.phase || 'active';
551
- const title = compactText(ctoRow.sessionId || ctoRow.taskSummary || 'Session', 'Session');
552
- const subtitle = compactText(hasPromptText(ctoRow) ? ctoRow.taskSummary || ctoRow.sessionKind || '' : displayPath(ctoRow.cwd || ctoRow.worktreePath || ''), '');
553
- const role = ctoRow.roleStatus || null;
554
- const roleHtml = role
555
- ? `<div class="detail-section"><div class="detail-label">Succession</div><div class="path-text">leader ${escapeHtml(role.leader_agent_id || 'none')} · epoch ${escapeHtml(role.leader_epoch || 0)} · pending ${escapeHtml(role.pending_count || 0)} · candidates ${escapeHtml(role.live_candidate_count || 0)}/${escapeHtml(role.candidate_count || 0)}</div></div>`
556
- : '';
557
- const promptHtml = hasPromptText(ctoRow)
558
- ? `<div class="detail-section"><div class="detail-label">Prompt</div>${renderChatBubble(ctoRow, { side: 'cto' })}</div>`
559
- : '';
560
- const agentRows = workers.map(worker => hasPromptText(worker) ? renderChatBubble(worker, { side: 'worker' }) : renderRuntimeCard(worker)).join('');
561
- const agentsHtml = workers.length
562
- ? `<div class="detail-section"><div class="detail-label">Agents</div><div class="runtime-list">${agentRows}</div></div>`
563
- : '';
564
- const emptyHtml = roleHtml || promptHtml || agentsHtml ? '' : '<div class="detail-empty">No session details yet</div>';
565
- return `
566
- <details class="session-card" data-open-key="cto:${dataAttr(ctoRow.sessionId || title)}">
567
- <summary class="session-summary">
568
- <span class="agent-badge">T</span>
569
- <div style="min-width:0;">
570
- <div class="session-summary-title">${escapeHtml(title)}</div>
571
- ${subtitle ? `<div class="session-summary-sub">${escapeHtml(subtitle)}</div>` : ''}
572
- </div>
573
- <div class="agent-state ${escapeHtml(status)}">${escapeHtml(status)}</div>
574
- </summary>
575
- <div class="session-detail">
576
- ${roleHtml}
577
- ${promptHtml}
578
- ${agentsHtml}
579
- ${emptyHtml}
580
- </div>
581
- </details>
582
- `;
583
- }
584
-
585
- function pluralize(count, one, many = `${one}s`) {
586
- return `${count} ${count === 1 ? one : many}`;
587
- }
588
-
589
- function renderProjectGroup(group) {
590
- const projectDisplayPath = displayPath(group.projectPath);
591
- const projectLabel = projectName(group.projectPath);
592
- const sessionCount = group.ctos.filter(row => !isSyntheticCto(row)).length;
593
- const agentCount = group.workers.length;
594
- const ctoCount = group.ctos.length;
595
- const projectCount = [ctoCount ? pluralize(ctoCount, 'CTO') : '', sessionCount ? pluralize(sessionCount, 'session') : '', agentCount ? pluralize(agentCount, 'agent') : '']
596
- .filter(Boolean)
597
- .join(' · ') || 'empty';
598
- let html = `
599
- <details class="project-group" data-open-key="project:${dataAttr(group.projectPath)}">
600
- <summary class="project-summary">
601
- <span class="project-chevron">▾</span>
602
- <span class="project-name">${escapeHtml(projectLabel)}</span>
603
- <span class="project-count">${escapeHtml(projectCount)}</span>
604
- </summary>
605
- <div class="project-path">${escapeHtml(projectDisplayPath)}</div>
606
- <div class="project-body">
607
- `;
608
- const ctos = group.ctos.slice(0, 6);
609
- const workers = group.workers.slice(0, 10);
610
- for (const [index, ctoRow] of ctos.entries()) {
611
- html += renderCtoChat(ctoRow, index === 0 ? workers : []);
612
- }
613
- if (ctos.length === 0 && workers.length > 0) {
614
- const agentRows = workers.map(worker => hasPromptText(worker) ? renderChatBubble(worker, { side: 'worker' }) : renderRuntimeCard(worker)).join('');
615
- html += `<div class="detail-section"><div class="detail-label">Agents</div><div class="runtime-list">${agentRows}</div></div>`;
616
- }
617
- html += '</div></details>';
618
- return html;
619
- }
620
-
621
- function renderCtoRoleBanner(ctoRole) {
622
- if (!ctoRole) return '';
623
- const status = ctoRole.status || 'offline';
624
- const leader = ctoRole.leader_agent_id || ctoRole.previous_leader_agent_id || 'none';
625
- return `
626
- <div class="section">
627
- <div class="section-title">CTO Succession</div>
628
- <div class="mini-card">
629
- <div style="display:flex;align-items:center;gap:8px;justify-content:space-between;">
630
- <div class="mini-title"><span class="agent-badge">T</span> ${escapeHtml(leader)}</div>
631
- <div class="value-pill status-text ${escapeHtml(status)}">${escapeHtml(status)}</div>
632
- </div>
633
- <div class="path-text">epoch ${escapeHtml(ctoRole.leader_epoch || 0)} · pending ${escapeHtml(ctoRole.pending_count || 0)} · candidates ${escapeHtml(ctoRole.live_candidate_count || 0)}/${escapeHtml(ctoRole.candidate_count || 0)}${ctoRole.candidate_source ? ` · ${escapeHtml(ctoRole.candidate_source)}` : ''}</div>
634
- </div>
635
- </div>
636
- `;
637
- }
638
-
639
- function renderSessions(data) {
640
- const container = document.getElementById('tab-sessions');
641
- const openKeys = collectOpenDetailKeys(container);
642
- const hub = data?.hub || {};
643
- const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
644
- const cto = data?.cto || {};
645
- const ctoRole = ctoRoleFromPayload(cto);
646
- const live = Array.isArray(cto.live_sessions) ? cto.live_sessions : [];
647
- const runtime = data?.runtime || {};
648
- const activeSessions = sessions.filter(s => s.status !== 'stale');
649
- const liveRows = liveFallbackRows(live);
650
- const projectGroups = buildProjectGroups(activeSessions.slice(0, 10), liveRows, hub, ctoRole);
651
- const activeCount = activeSessions.filter(s => s.status === 'active').length;
652
- const idleCount = activeSessions.filter(s => s.status === 'idle').length;
653
- const staleCount = sessions.filter(s => s.status === 'stale').length;
654
- const runtimeTotal = runtime?.summary?.total || liveRows.length || 0;
655
- let html = `
656
- <div class="section">
657
- <div class="session-overview">
658
- <div class="summary-card primary"><div class="summary-kpi">${live.length}</div><div class="summary-label">Live agents</div><div class="summary-sub">${runtimeTotal} runtime CLIs</div></div>
659
- <div class="summary-card"><div class="summary-kpi">${activeCount}</div><div class="summary-label">Active</div></div>
660
- <div class="summary-card"><div class="summary-kpi">${idleCount}</div><div class="summary-label">Idle</div></div>
661
- <div class="summary-card"><div class="summary-kpi">${staleCount}</div><div class="summary-label">Stale</div></div>
662
- </div>
663
- <div class="path-text">${projectGroups.length} ${projectGroups.length === 1 ? 'workspace' : 'workspaces'} · ${cto.active_shards?.length || 0} shards · ${renderCopyChip('Hub', hub.id || String(hub.pid || ''))}</div>
664
- </div>
665
- <div class="section">
666
- <div class="section-title">Agent Mix</div>
667
- <div class="models-list">${renderRuntimeClients(runtime)}</div>
668
- </div>
669
- ${renderCtoRoleBanner(ctoRole)}
670
- ${renderCtoHygiene(cto.hygiene)}
671
- <div class="section">
672
- <div class="section-title">Workspaces</div>
673
- <div class="agent-view">
674
- `;
675
- for (const group of projectGroups) html += renderProjectGroup(group);
676
- if (projectGroups.length === 0) html += '<div class="mini-card muted">No active sessions detected</div>';
677
- html += '</div></div>';
678
- container.innerHTML = html;
679
- restoreOpenDetails(container, openKeys);
680
- }
681
-
682
- function renderGateway(mcp) {
683
- const container = document.getElementById('tab-gateway');
684
- const servers = Array.isArray(mcp?.servers) ? mcp.servers : [];
685
- const byStatus = mcp?.summary?.by_status || {};
686
- let html = `
687
- <div class="section">
688
- <div class="stats-grid">
689
- <div class="stat-box"><div class="stat-label">MCP Servers</div><div class="stat-value">${servers.length || mcp?.server_count || 0}<span class="stat-unit">cards</span></div></div>
690
- <div class="stat-box"><div class="stat-label">Registry Rows</div><div class="stat-value">${mcp?.summary?.total || 0}<span class="stat-unit">rows</span></div></div>
691
- <div class="stat-box"><div class="stat-label">Present</div><div class="stat-value">${byStatus.present || 0}<span class="stat-unit">ok</span></div></div>
692
- <div class="stat-box"><div class="stat-label">Registry</div><div class="stat-value">${mcp?.server_count || 0}<span class="stat-unit">defs</span></div></div>
693
- </div>
694
- <div class="path-text">${escapeHtml(mcp?.registry_path || '')}</div>
695
- </div>
696
- <div class="section">
697
- <div class="section-title">MCP Cards</div>
698
- <div class="server-list">
699
- `;
700
- for (const server of servers) {
701
- html += `
702
- <div class="mcp-card">
703
- <div class="mcp-head">
704
- <div class="mcp-name">${escapeHtml(server.name)}</div>
705
- <div class="value-pill status-text ${escapeHtml(server.status)}">${escapeHtml(STATUS_LABEL[server.status] || server.status)}</div>
706
- </div>
707
- <div class="path-text">${escapeHtml(server.target || '')}</div>
708
- <div class="client-chips">
709
- ${server.clients.map(client => `
710
- <span class="chip ${escapeHtml(client.status)}"><span class="dot ${escapeHtml(client.id)}"></span>${escapeHtml(client.label)} ${escapeHtml(STATUS_LABEL[client.status] || client.status)}${client.total > 1 ? ` · ${client.present}/${client.total}` : ''}</span>
711
- `).join('')}
712
- </div>
713
- </div>
714
- `;
715
- }
716
- if (servers.length === 0) html += '<div class="mcp-card muted">No MCP registry cards reported</div>';
717
- html += `</div>${mcp?.error ? `<div class="path-text status-text error">${escapeHtml(mcp.error)}</div>` : ''}</div>`;
718
- container.innerHTML = html;
719
- }
720
-
721
- async function copyText(txt, element) {
722
- if (!txt) return;
723
- try {
724
- await navigator.clipboard.writeText(txt);
725
- } catch (_err) {
726
- const input = document.createElement('textarea');
727
- input.value = txt;
728
- input.style.position = 'fixed';
729
- input.style.opacity = '0';
730
- document.body.appendChild(input);
731
- input.focus();
732
- input.select();
733
- document.execCommand('copy');
734
- document.body.removeChild(input);
735
- }
736
- if (element) {
737
- const previous = element.textContent;
738
- element.textContent = 'Copied';
739
- setTimeout(() => { element.textContent = previous; }, 700);
740
- }
741
- }
742
- document.addEventListener('click', event => {
743
- const copyTarget = event.target.closest('[data-copy-text]');
744
- if (copyTarget) {
745
- copyText(copyTarget.dataset.copyText, copyTarget);
746
- }
747
- });
748
- document.addEventListener('dragstart', event => {
749
- if (event.target.closest('[data-copy-text]')) event.preventDefault();
750
- });
751
-
752
- setInterval(fetchState, 3000);
753
- fetchState();
754
- </script>
755
- </body>
756
- </html>