@archznn/crewloop-skills 0.3.0 → 0.4.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 (34) hide show
  1. package/package.json +2 -1
  2. package/servers/dashboard/README.md +87 -0
  3. package/servers/dashboard/bin/crewloop-dashboard.js +5 -0
  4. package/servers/dashboard/config-examples/codex-hooks.json +14 -0
  5. package/servers/dashboard/config-examples/kimi-code-config.toml +6 -0
  6. package/servers/dashboard/config-examples/opencode-plugin/crewloop-dashboard.js +64 -0
  7. package/servers/dashboard/package.json +46 -0
  8. package/servers/dashboard/public/app.js +447 -0
  9. package/servers/dashboard/public/index.html +96 -0
  10. package/servers/dashboard/public/styles.css +664 -0
  11. package/servers/dashboard/src/adapters/codex.ts +50 -0
  12. package/servers/dashboard/src/adapters/kimi.ts +40 -0
  13. package/servers/dashboard/src/adapters/opencode.ts +36 -0
  14. package/servers/dashboard/src/adapters/shim.test.ts +74 -0
  15. package/servers/dashboard/src/adapters/shim.ts +120 -0
  16. package/servers/dashboard/src/api/event.ts +70 -0
  17. package/servers/dashboard/src/api/skills.ts +11 -0
  18. package/servers/dashboard/src/config.ts +66 -0
  19. package/servers/dashboard/src/filters/sanitize.test.ts +94 -0
  20. package/servers/dashboard/src/filters/sanitize.ts +78 -0
  21. package/servers/dashboard/src/index.ts +24 -0
  22. package/servers/dashboard/src/presenter.test.ts +69 -0
  23. package/servers/dashboard/src/presenter.ts +56 -0
  24. package/servers/dashboard/src/server.test.ts +123 -0
  25. package/servers/dashboard/src/server.ts +191 -0
  26. package/servers/dashboard/src/skills/infer.test.ts +86 -0
  27. package/servers/dashboard/src/skills/infer.ts +53 -0
  28. package/servers/dashboard/src/skills/mapping.ts +26 -0
  29. package/servers/dashboard/src/skills/registry.ts +60 -0
  30. package/servers/dashboard/src/state.test.ts +88 -0
  31. package/servers/dashboard/src/state.ts +115 -0
  32. package/servers/dashboard/src/types.ts +110 -0
  33. package/servers/dashboard/tsconfig.json +19 -0
  34. package/skills/orchestrator/SKILL.md +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archznn/crewloop-skills",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "CrewLoop AI agent skills bundle",
5
5
  "license": "MIT",
6
6
  "author": "leorsousa05",
@@ -14,6 +14,7 @@
14
14
  "references/",
15
15
  "assets/",
16
16
  "servers/obsidian-mcp/",
17
+ "servers/dashboard/",
17
18
  "README.md",
18
19
  "LICENSE.md"
19
20
  ],
@@ -0,0 +1,87 @@
1
+ # CrewLoop Dashboard
2
+
3
+ Real-time skill dashboard for CrewLoop agents. It shows which skill an agent is currently running, the tools it invokes, and a live event timeline — all in a browser.
4
+
5
+ ## Features
6
+
7
+ - Live skill inference from agent tool usage.
8
+ - WebSocket updates with in-memory session state.
9
+ - Sanitized event stream: no commands, secrets, or file contents reach the UI.
10
+ - Dark/light mode and reduced-motion support.
11
+ - Session selector for multi-agent monitoring.
12
+ - `crewloop dashboard` CLI command.
13
+
14
+ ## Quick start
15
+
16
+ ### From the CrewLoop repository
17
+
18
+ ```bash
19
+ cd servers/dashboard
20
+ npm install
21
+ npm run build
22
+ npm start
23
+ ```
24
+
25
+ Open `http://127.0.0.1:7890`.
26
+
27
+ ### From the CrewLoop CLI
28
+
29
+ ```bash
30
+ crewloop dashboard
31
+ # or
32
+ crewloop dashboard --port 8080 --host 0.0.0.0
33
+ ```
34
+
35
+ The CLI looks for the dashboard server inside the `@archznn/crewloop-skills` package under `servers/dashboard`. Build it first if it is not present.
36
+
37
+ ## Environment variables
38
+
39
+ | Variable | Default | Description |
40
+ |----------|---------|-------------|
41
+ | `CREWLOOP_DASHBOARD_PORT` | `7890` | HTTP/WebSocket port |
42
+ | `CREWLOOP_DASHBOARD_HOST` | `127.0.0.1` | Bind address |
43
+
44
+ ## Agent integration
45
+
46
+ Agents send JSON events to `POST http://127.0.0.1:7890/event`. The included shim (`dist/adapters/shim.js`) normalizes Kimi and Codex hook payloads and forwards them.
47
+
48
+ See `config-examples/` for:
49
+ - `kimi-code-config.toml` — Kimi Code hook configuration.
50
+ - `codex-hooks.json` — Codex CLI hook configuration.
51
+ - `opencode-plugin/crewloop-dashboard.js` — OpenCode plugin example.
52
+
53
+ ### Event schema
54
+
55
+ ```typescript
56
+ interface DashboardEvent {
57
+ id: string;
58
+ timestamp: number;
59
+ source: 'kimi' | 'codex' | 'opencode' | 'log-watcher';
60
+ session_id: string;
61
+ event_type: 'session_start' | 'session_end' | 'tool_start' | 'tool_end' | 'skill_change';
62
+ skill?: string;
63
+ tool?: string;
64
+ detail?: string;
65
+ status?: 'running' | 'success' | 'error';
66
+ duration_ms?: number;
67
+ }
68
+ ```
69
+
70
+ ## Security
71
+
72
+ - The server binds to `127.0.0.1` by default.
73
+ - Dangerous keys (`command`, `content`, `token`, `api_key`, etc.) are stripped before storage and broadcast.
74
+ - Events containing dangerous top-level keys are rejected.
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ npm run typecheck
80
+ npm run build
81
+ npm test
82
+ ```
83
+
84
+ ## Known limitations
85
+
86
+ - Codex file-edit hooks do not always expose the tool name; in those cases skill inference falls back to the session's previous active skill.
87
+ - The log watcher adapter is a deferred fallback and not yet implemented.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ const path = require('node:path');
3
+
4
+ const distIndex = path.join(__dirname, '..', 'dist', 'index.js');
5
+ require(distIndex);
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://example.com/codex-hooks-schema.json",
3
+ "description": "Codex CLI hook configuration for CrewLoop dashboard.",
4
+ "hooks": {
5
+ "before_tool_use": {
6
+ "command": "crewloop-shim",
7
+ "args": ["codex"]
8
+ },
9
+ "after_tool_use": {
10
+ "command": "crewloop-shim",
11
+ "args": ["codex"]
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,6 @@
1
+ # Kimi Code configuration snippet for CrewLoop dashboard integration.
2
+ # Place in your Kimi Code config file and ensure crewloop-shim is on PATH.
3
+
4
+ [hooks]
5
+ before_tool_use = "crewloop-shim kimi"
6
+ after_tool_use = "crewloop-shim kimi"
@@ -0,0 +1,64 @@
1
+ // OpenCode plugin example for CrewLoop dashboard.
2
+ // Loads agent context and posts tool events to the local dashboard server.
3
+
4
+ const http = require('node:http');
5
+ const { env } = require('node:process');
6
+
7
+ const DEFAULT_URL = 'http://127.0.0.1:7890';
8
+
9
+ function postEvent(event) {
10
+ const serverUrl = env.CREWLOOP_DASHBOARD_URL || DEFAULT_URL;
11
+ const body = JSON.stringify(event);
12
+ const url = new URL('/event', serverUrl);
13
+
14
+ const req = http.request(
15
+ {
16
+ hostname: url.hostname,
17
+ port: url.port,
18
+ path: url.pathname,
19
+ method: 'POST',
20
+ headers: {
21
+ 'Content-Type': 'application/json',
22
+ 'Content-Length': Buffer.byteLength(body),
23
+ },
24
+ timeout: 300,
25
+ },
26
+ () => {}
27
+ );
28
+
29
+ req.on('error', () => {});
30
+ req.on('timeout', () => req.destroy());
31
+ req.write(body);
32
+ req.end();
33
+ }
34
+
35
+ module.exports = function crewloopDashboardPlugin(context) {
36
+ const sessionId = context.sessionId || `opencode-${Date.now()}`;
37
+
38
+ return {
39
+ name: 'crewloop-dashboard',
40
+ beforeToolUse(tool) {
41
+ postEvent({
42
+ id: `${sessionId}-${Date.now()}`,
43
+ timestamp: Date.now(),
44
+ source: 'opencode',
45
+ session_id: sessionId,
46
+ event_type: 'tool_start',
47
+ tool: tool.name,
48
+ detail: tool.args?.path || tool.args?.skill,
49
+ });
50
+ },
51
+ afterToolUse(tool, result) {
52
+ postEvent({
53
+ id: `${sessionId}-${Date.now()}`,
54
+ timestamp: Date.now(),
55
+ source: 'opencode',
56
+ session_id: sessionId,
57
+ event_type: 'tool_end',
58
+ tool: tool.name,
59
+ status: result?.error ? 'error' : 'success',
60
+ duration_ms: result?.duration_ms,
61
+ });
62
+ },
63
+ };
64
+ };
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@archznn/crewloop-dashboard",
3
+ "version": "0.2.0",
4
+ "description": "Real-time CrewLoop skill dashboard server and UI",
5
+ "license": "MIT",
6
+ "author": "leorsousa05",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/leorsousa05/CrewLoop.git"
10
+ },
11
+ "homepage": "https://leorsousa05.github.io/CrewLoop/",
12
+ "bin": {
13
+ "crewloop-dashboard": "bin/crewloop-dashboard.js"
14
+ },
15
+ "files": [
16
+ "bin/",
17
+ "dist/",
18
+ "public/",
19
+ "config-examples/",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "start": "node dist/index.js",
24
+ "build": "tsc",
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "node --test dist/**/*.test.js",
27
+ "prepublishOnly": "npm run build"
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "dependencies": {
33
+ "ws": "^8.18.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^20.0.0",
37
+ "@types/ws": "^8.5.0",
38
+ "typescript": "^5.4.0"
39
+ },
40
+ "keywords": [
41
+ "ai-agent",
42
+ "crewloop",
43
+ "dashboard",
44
+ "observability"
45
+ ]
46
+ }
@@ -0,0 +1,447 @@
1
+ (() => {
2
+ 'use strict';
3
+
4
+ // ---- Config ----
5
+ const WS_URL = `ws://${location.host}/ws`;
6
+ const MAX_EVENTS = 100;
7
+ const SKILL_ICONS = {
8
+ orchestrator: 'ph-target',
9
+ architect: 'ph-blueprint',
10
+ designer: 'ph-palette',
11
+ engineer: 'ph-wrench',
12
+ reviewer: 'ph-magnifying-glass',
13
+ shipper: 'ph-rocket-launch',
14
+ 'docs-writer': 'ph-article',
15
+ tester: 'ph-flask',
16
+ 'product-manager': 'ph-chart-bar',
17
+ maintainer: 'ph-toolbox',
18
+ researcher: 'ph-microscope',
19
+ 'security-guard': 'ph-shield',
20
+ 'accessibility-auditor': 'ph-person',
21
+ 'obsidian-second-brain': 'ph-brain',
22
+ 'project-mapper': 'ph-tree-structure',
23
+ default: 'ph-circle',
24
+ };
25
+
26
+ // ---- State ----
27
+ const state = {
28
+ sessions: new Map(),
29
+ activeSessionId: null,
30
+ selectedSessionId: null,
31
+ theme: localStorage.getItem('crewloop-theme') || 'system',
32
+ connection: 'connecting',
33
+ lastPong: 0,
34
+ eventCountWindow: [],
35
+ ws: null,
36
+ reconnectTimer: null,
37
+ pingTimer: null,
38
+ };
39
+
40
+ // ---- DOM refs ----
41
+ const $ = (id) => document.getElementById(id);
42
+ const themeToggle = $('theme-toggle');
43
+ const connectionDot = $('connection-dot');
44
+ const sessionTrigger = $('session-trigger');
45
+ const sessionLabel = $('session-label');
46
+ const sessionList = $('session-list');
47
+ const activeStrip = $('active-strip');
48
+ const activeSkillIcon = $('active-skill-icon');
49
+ const activeSkillName = $('active-skill-name');
50
+ const statusBadge = $('status-badge');
51
+ const statusDot = $('status-dot');
52
+ const statusText = $('status-text');
53
+ const confidenceBadge = $('confidence-badge');
54
+ const activeSkillSource = $('active-skill-source');
55
+ const toolCount = $('tool-count');
56
+ const durationEl = $('duration');
57
+ const eventRate = $('event-rate');
58
+ const timeline = $('timeline');
59
+ const activityGraph = $('activity-graph');
60
+
61
+ // ---- Utils ----
62
+ function formatDuration(ms) {
63
+ const totalSeconds = Math.floor(ms / 1000);
64
+ const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
65
+ const seconds = (totalSeconds % 60).toString().padStart(2, '0');
66
+ if (totalSeconds < 3600) return `${minutes}:${seconds}`;
67
+ const hours = Math.floor(totalSeconds / 3600);
68
+ return `${hours}:${minutes}:${seconds}`;
69
+ }
70
+
71
+ function formatTime(ts) {
72
+ const d = new Date(ts);
73
+ return d.toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
74
+ }
75
+
76
+ function updateEventRate() {
77
+ const now = Date.now();
78
+ const windowStart = now - 60000;
79
+ state.eventCountWindow = state.eventCountWindow.filter((t) => t > windowStart);
80
+ eventRate.textContent = state.eventCountWindow.length.toString();
81
+ }
82
+
83
+ function skillIcon(skillName) {
84
+ const key = skillName?.toLowerCase().replace(/\s+/g, '-');
85
+ return SKILL_ICONS[key] || SKILL_ICONS.default;
86
+ }
87
+
88
+ function prefersReducedMotion() {
89
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
90
+ }
91
+
92
+ // ---- Theme ----
93
+ function applyTheme(theme) {
94
+ const root = document.documentElement;
95
+ let resolved = theme;
96
+ if (theme === 'system') {
97
+ resolved = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
98
+ }
99
+ root.setAttribute('data-theme', resolved);
100
+ const icon = themeToggle.querySelector('i');
101
+ if (resolved === 'light') {
102
+ icon.className = 'ph ph-sun';
103
+ } else {
104
+ icon.className = 'ph ph-moon';
105
+ }
106
+ }
107
+
108
+ function cycleTheme() {
109
+ const order = ['system', 'light', 'dark'];
110
+ const idx = order.indexOf(state.theme);
111
+ state.theme = order[(idx + 1) % order.length];
112
+ localStorage.setItem('crewloop-theme', state.theme);
113
+ applyTheme(state.theme);
114
+ }
115
+
116
+ // ---- WebSocket ----
117
+ function setConnection(status) {
118
+ state.connection = status;
119
+ connectionDot.className = 'connection-dot ' + status;
120
+ }
121
+
122
+ function connect() {
123
+ setConnection('connecting');
124
+ const ws = new WebSocket(WS_URL);
125
+ state.ws = ws;
126
+
127
+ ws.addEventListener('open', () => {
128
+ setConnection('connected');
129
+ state.lastPong = Date.now();
130
+ startPing();
131
+ });
132
+
133
+ ws.addEventListener('message', (event) => {
134
+ let msg;
135
+ try {
136
+ msg = JSON.parse(event.data);
137
+ } catch {
138
+ return;
139
+ }
140
+ handleMessage(msg);
141
+ });
142
+
143
+ ws.addEventListener('close', () => {
144
+ setConnection('disconnected');
145
+ stopPing();
146
+ scheduleReconnect();
147
+ });
148
+
149
+ ws.addEventListener('error', () => {
150
+ ws.close();
151
+ });
152
+ }
153
+
154
+ function scheduleReconnect() {
155
+ if (state.reconnectTimer) return;
156
+ state.reconnectTimer = setTimeout(() => {
157
+ state.reconnectTimer = null;
158
+ connect();
159
+ }, 3000);
160
+ }
161
+
162
+ function startPing() {
163
+ stopPing();
164
+ state.pingTimer = setInterval(() => {
165
+ if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
166
+ state.ws.send(JSON.stringify({ type: 'ping' }));
167
+ if (Date.now() - state.lastPong > 35000) {
168
+ state.ws.close();
169
+ }
170
+ }, 15000);
171
+ }
172
+
173
+ function stopPing() {
174
+ if (state.pingTimer) {
175
+ clearInterval(state.pingTimer);
176
+ state.pingTimer = null;
177
+ }
178
+ }
179
+
180
+ function handleMessage(msg) {
181
+ if (msg.type === 'pong') {
182
+ state.lastPong = Date.now();
183
+ return;
184
+ }
185
+ if (msg.type === 'snapshot') {
186
+ state.sessions.clear();
187
+ (msg.sessions || []).forEach((s) => state.sessions.set(s.id, s));
188
+ if (!state.selectedSessionId) {
189
+ state.selectedSessionId = state.activeSessionId || state.sessions.keys().next().value || null;
190
+ }
191
+ renderSessionSelector();
192
+ renderAll();
193
+ return;
194
+ }
195
+ if (msg.type === 'update') {
196
+ const session = msg.session;
197
+ if (!session) return;
198
+ state.sessions.set(session.id, session);
199
+ if (msg.isActive) {
200
+ state.activeSessionId = session.id;
201
+ if (!state.selectedSessionId) state.selectedSessionId = session.id;
202
+ }
203
+ state.eventCountWindow.push(Date.now());
204
+ renderSessionSelector();
205
+ renderAll();
206
+ }
207
+ }
208
+
209
+ // ---- Rendering ----
210
+ function getSession() {
211
+ return state.sessions.get(state.selectedSessionId) || null;
212
+ }
213
+
214
+ function renderAll() {
215
+ const session = getSession();
216
+ renderActiveSkill(session);
217
+ renderTelemetry(session);
218
+ renderTimeline(session);
219
+ renderActivityGraph(session);
220
+ }
221
+
222
+ function renderActiveSkill(session) {
223
+ if (!session) {
224
+ activeSkillName.textContent = 'IDLE';
225
+ activeSkillIcon.className = 'ph ph-circle';
226
+ activeStrip.className = 'panel-accent-strip';
227
+ statusDot.className = 'status-dot';
228
+ statusText.textContent = 'IDLE';
229
+ confidenceBadge.textContent = 'unknown';
230
+ activeSkillSource.innerHTML = '<i class="ph ph-monitor"></i><span>waiting for events</span>';
231
+ return;
232
+ }
233
+
234
+ const skill = session.activeSkill || { name: 'UNKNOWN', confidence: 'low' };
235
+ const iconClass = skillIcon(skill.name);
236
+ activeSkillName.textContent = skill.name.toUpperCase();
237
+ activeSkillIcon.className = 'ph ' + iconClass;
238
+
239
+ const running = session.status === 'running';
240
+ activeStrip.className = 'panel-accent-strip' + (running ? ' running' : '');
241
+ statusDot.className = 'status-dot ' + (running ? 'running' : session.status || '');
242
+ statusText.textContent = running ? 'RUNNING' : (session.status ? session.status.toUpperCase() : 'IDLE');
243
+ confidenceBadge.textContent = skill.confidence || 'low';
244
+
245
+ activeSkillSource.innerHTML = `<i class="ph ph-${sourceIcon(session.source)}"></i><span>${session.source || 'unknown'}</span>`;
246
+ }
247
+
248
+ function sourceIcon(source) {
249
+ switch (source) {
250
+ case 'kimi': return 'chat-teardrop-text';
251
+ case 'codex': return 'terminal';
252
+ case 'opencode': return 'code-block';
253
+ case 'log-watcher': return 'file-text';
254
+ default: return 'monitor';
255
+ }
256
+ }
257
+
258
+ function renderTelemetry(session) {
259
+ if (!session) {
260
+ toolCount.textContent = '0';
261
+ durationEl.textContent = '00:00';
262
+ updateEventRate();
263
+ return;
264
+ }
265
+ const events = session.events || [];
266
+ const toolEvents = events.filter((e) => e.event_type === 'tool_start' || e.event_type === 'tool_end');
267
+ toolCount.textContent = String(Math.ceil(toolEvents.length / 2));
268
+ const dur = session.lastActivity && session.startTime ? session.lastActivity - session.startTime : 0;
269
+ durationEl.textContent = formatDuration(dur);
270
+ updateEventRate();
271
+ }
272
+
273
+ function renderTimeline(session) {
274
+ timeline.innerHTML = '';
275
+ if (!session) return;
276
+ const events = (session.events || []).slice(-MAX_EVENTS).reverse();
277
+ const fragment = document.createDocumentFragment();
278
+ events.forEach((ev) => {
279
+ const li = document.createElement('li');
280
+ li.className = 'timeline-item';
281
+ const outcome = ev.status || (ev.event_type.endsWith('_end') ? 'success' : '');
282
+ li.innerHTML = `
283
+ <span class="timeline-time">${formatTime(ev.timestamp)}</span>
284
+ <span class="timeline-dot ${outcome}"></span>
285
+ <div class="timeline-main">
286
+ <span class="timeline-tool">${ev.tool || ev.event_type}</span>
287
+ <span class="timeline-detail">${escapeHtml(ev.detail || ev.skill || '')}</span>
288
+ </div>
289
+ <span class="timeline-outcome">${outcome}</span>
290
+ `;
291
+ fragment.appendChild(li);
292
+ });
293
+ timeline.appendChild(fragment);
294
+ }
295
+
296
+ function escapeHtml(str) {
297
+ return String(str)
298
+ .replace(/&/g, '&amp;')
299
+ .replace(/</g, '&lt;')
300
+ .replace(/>/g, '&gt;')
301
+ .replace(/"/g, '&quot;')
302
+ .replace(/'/g, '&#039;');
303
+ }
304
+
305
+ function renderActivityGraph(session) {
306
+ activityGraph.innerHTML = '';
307
+ if (!session || !session.events || session.events.length === 0) {
308
+ activityGraph.innerHTML = '<div class="activity-empty">Waiting for agent activity...</div>';
309
+ return;
310
+ }
311
+
312
+ const events = session.events;
313
+ const now = Date.now();
314
+ const span = Math.max(60000, now - Math.min(...events.map((e) => e.timestamp)));
315
+ const buckets = 40;
316
+ const bucketMs = span / buckets;
317
+ const counts = new Array(buckets).fill(0);
318
+
319
+ events.forEach((e) => {
320
+ const idx = Math.min(buckets - 1, Math.floor((now - e.timestamp) / bucketMs));
321
+ const safeIdx = buckets - 1 - idx;
322
+ counts[safeIdx]++;
323
+ });
324
+
325
+ const max = Math.max(1, ...counts);
326
+ const canvas = document.createElement('canvas');
327
+ canvas.className = 'activity-canvas';
328
+ const rect = activityGraph.getBoundingClientRect();
329
+ const dpr = window.devicePixelRatio || 1;
330
+ canvas.width = Math.max(1, Math.floor(rect.width * dpr));
331
+ canvas.height = Math.max(1, Math.floor(rect.height * dpr));
332
+ const ctx = canvas.getContext('2d');
333
+ ctx.scale(dpr, dpr);
334
+ const width = rect.width;
335
+ const height = rect.height;
336
+ const pad = 4;
337
+ const barW = (width - pad * 2) / buckets;
338
+
339
+ const style = getComputedStyle(document.documentElement);
340
+ const accent = style.getPropertyValue('--accent').trim() || '#22d3ee';
341
+ const muted = style.getPropertyValue('--bg-inset').trim() || '#1c1c1f';
342
+
343
+ // background grid
344
+ ctx.fillStyle = muted;
345
+ ctx.fillRect(0, 0, width, height);
346
+
347
+ counts.forEach((count, i) => {
348
+ const barH = (count / max) * (height - pad * 2);
349
+ const x = pad + i * barW;
350
+ const y = height - pad - barH;
351
+ ctx.fillStyle = accent;
352
+ ctx.fillRect(x + 1, y, Math.max(1, barW - 2), Math.max(1, barH));
353
+ });
354
+
355
+ activityGraph.appendChild(canvas);
356
+ }
357
+
358
+ function renderSessionSelector() {
359
+ sessionList.innerHTML = '';
360
+ if (state.sessions.size === 0) {
361
+ sessionLabel.textContent = 'No session';
362
+ return;
363
+ }
364
+
365
+ const sessions = Array.from(state.sessions.values()).sort((a, b) => (b.lastActivity || 0) - (a.lastActivity || 0));
366
+ if (!state.selectedSessionId || !state.sessions.has(state.selectedSessionId)) {
367
+ state.selectedSessionId = sessions[0].id;
368
+ }
369
+
370
+ const activeSession = state.sessions.get(state.activeSessionId);
371
+ let label = 'No session';
372
+ const current = state.sessions.get(state.selectedSessionId);
373
+ if (current) {
374
+ label = current.skill ? current.skill.toUpperCase() : truncate(current.id, 12);
375
+ if (current.id === state.activeSessionId && activeSession) {
376
+ label = '● ' + label;
377
+ }
378
+ }
379
+ sessionLabel.textContent = label;
380
+
381
+ sessions.forEach((s) => {
382
+ const li = document.createElement('li');
383
+ li.className = 'session-item' + (s.id === state.selectedSessionId ? ' active' : '');
384
+ li.setAttribute('role', 'option');
385
+ li.setAttribute('aria-selected', String(s.id === state.selectedSessionId));
386
+ li.innerHTML = `
387
+ <span class="session-item-id">${truncate(s.id, 16)}</span>
388
+ <span class="session-item-source">${s.source || 'unknown'}</span>
389
+ `;
390
+ li.addEventListener('click', () => {
391
+ state.selectedSessionId = s.id;
392
+ closeSessionList();
393
+ renderAll();
394
+ });
395
+ sessionList.appendChild(li);
396
+ });
397
+ }
398
+
399
+ function truncate(str, n) {
400
+ if (!str) return '';
401
+ return str.length > n ? str.slice(0, n - 1) + '…' : str;
402
+ }
403
+
404
+ function toggleSessionList() {
405
+ const open = sessionList.classList.toggle('open');
406
+ sessionTrigger.setAttribute('aria-expanded', String(open));
407
+ }
408
+
409
+ function closeSessionList() {
410
+ sessionList.classList.remove('open');
411
+ sessionTrigger.setAttribute('aria-expanded', 'false');
412
+ }
413
+
414
+ // ---- Event listeners ----
415
+ themeToggle.addEventListener('click', cycleTheme);
416
+
417
+ sessionTrigger.addEventListener('click', (e) => {
418
+ e.stopPropagation();
419
+ toggleSessionList();
420
+ });
421
+
422
+ document.addEventListener('click', (e) => {
423
+ if (!sessionTrigger.contains(e.target) && !sessionList.contains(e.target)) {
424
+ closeSessionList();
425
+ }
426
+ });
427
+
428
+ window.addEventListener('resize', () => {
429
+ if (prefersReducedMotion()) {
430
+ renderActivityGraph(getSession());
431
+ } else {
432
+ window.requestAnimationFrame(() => renderActivityGraph(getSession()));
433
+ }
434
+ });
435
+
436
+ // Keep duration ticking
437
+ setInterval(() => {
438
+ const session = getSession();
439
+ if (session && session.status === 'running') {
440
+ renderTelemetry(session);
441
+ }
442
+ }, 1000);
443
+
444
+ // Init
445
+ applyTheme(state.theme);
446
+ connect();
447
+ })();