@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.
- package/package.json +2 -1
- package/servers/dashboard/README.md +87 -0
- package/servers/dashboard/bin/crewloop-dashboard.js +5 -0
- package/servers/dashboard/config-examples/codex-hooks.json +14 -0
- package/servers/dashboard/config-examples/kimi-code-config.toml +6 -0
- package/servers/dashboard/config-examples/opencode-plugin/crewloop-dashboard.js +64 -0
- package/servers/dashboard/package.json +46 -0
- package/servers/dashboard/public/app.js +447 -0
- package/servers/dashboard/public/index.html +96 -0
- package/servers/dashboard/public/styles.css +664 -0
- package/servers/dashboard/src/adapters/codex.ts +50 -0
- package/servers/dashboard/src/adapters/kimi.ts +40 -0
- package/servers/dashboard/src/adapters/opencode.ts +36 -0
- package/servers/dashboard/src/adapters/shim.test.ts +74 -0
- package/servers/dashboard/src/adapters/shim.ts +120 -0
- package/servers/dashboard/src/api/event.ts +70 -0
- package/servers/dashboard/src/api/skills.ts +11 -0
- package/servers/dashboard/src/config.ts +66 -0
- package/servers/dashboard/src/filters/sanitize.test.ts +94 -0
- package/servers/dashboard/src/filters/sanitize.ts +78 -0
- package/servers/dashboard/src/index.ts +24 -0
- package/servers/dashboard/src/presenter.test.ts +69 -0
- package/servers/dashboard/src/presenter.ts +56 -0
- package/servers/dashboard/src/server.test.ts +123 -0
- package/servers/dashboard/src/server.ts +191 -0
- package/servers/dashboard/src/skills/infer.test.ts +86 -0
- package/servers/dashboard/src/skills/infer.ts +53 -0
- package/servers/dashboard/src/skills/mapping.ts +26 -0
- package/servers/dashboard/src/skills/registry.ts +60 -0
- package/servers/dashboard/src/state.test.ts +88 -0
- package/servers/dashboard/src/state.ts +115 -0
- package/servers/dashboard/src/types.ts +110 -0
- package/servers/dashboard/tsconfig.json +19 -0
- 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
|
+
"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,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,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, '&')
|
|
299
|
+
.replace(/</g, '<')
|
|
300
|
+
.replace(/>/g, '>')
|
|
301
|
+
.replace(/"/g, '"')
|
|
302
|
+
.replace(/'/g, ''');
|
|
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
|
+
})();
|