erne-universal 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -26
- package/agents/feature-builder.md +88 -0
- package/agents/senior-developer.md +77 -0
- package/bin/cli.js +4 -2
- package/dashboard/package.json +10 -0
- package/dashboard/public/agents.js +329 -0
- package/dashboard/public/canvas.js +275 -0
- package/dashboard/public/index.html +113 -0
- package/dashboard/public/sidebar.js +107 -0
- package/dashboard/public/ws-client.js +69 -0
- package/dashboard/server.js +191 -0
- package/docs/assets/dashboard-preview.png +0 -0
- package/docs/superpowers/plans/2026-03-11-agent-dashboard.md +1537 -0
- package/docs/superpowers/specs/2026-03-11-agent-dashboard-design.md +275 -0
- package/hooks/hooks.json +14 -0
- package/lib/dashboard.js +156 -0
- package/lib/init.js +294 -0
- package/lib/start.js +26 -0
- package/lib/update.js +60 -0
- package/package.json +3 -1
- package/scripts/daily-news/scan-ai-agents.js +222 -0
- package/scripts/daily-news/scan-rn-expo.js +233 -0
- package/scripts/hooks/dashboard-event.js +89 -0
- package/scripts/sync/issue-to-clickup.js +108 -0
- package/scripts/validate-all.js +1 -1
|
@@ -0,0 +1,1537 @@
|
|
|
1
|
+
# ERNE Agent Dashboard Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build a pixel-art visual dashboard that shows ERNE agents working in real-time via Claude Code hooks, WebSocket, and HTML5 Canvas.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Standalone Node.js server receives events from Claude Code hooks (PreToolUse/PostToolUse on Agent tool) via HTTP POST, maintains in-memory agent state, and pushes updates to the browser over WebSocket. The browser renders a pixel-art office with 4 rooms and 8 agent sprites using HTML5 Canvas.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js (built-in `http`, `fs`, `path`, `net`, `child_process`), `ws` npm package, HTML5 Canvas API, vanilla JavaScript.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-03-11-agent-dashboard-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Chunk 1: Dashboard Server + Event API
|
|
16
|
+
|
|
17
|
+
### Task 1: Initialize dashboard package
|
|
18
|
+
|
|
19
|
+
**Files:**
|
|
20
|
+
- Create: `dashboard/package.json`
|
|
21
|
+
- Create: `dashboard/server.js`
|
|
22
|
+
|
|
23
|
+
- [ ] **Step 1: Create dashboard/package.json**
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"name": "erne-dashboard",
|
|
28
|
+
"version": "1.0.0",
|
|
29
|
+
"private": true,
|
|
30
|
+
"description": "ERNE Agent Visual Dashboard",
|
|
31
|
+
"main": "server.js",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"ws": "^8.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- [ ] **Step 2: Install dependencies**
|
|
39
|
+
|
|
40
|
+
Note: `ws` is scoped to `dashboard/package.json` only (not root `package.json`) to keep the dashboard self-contained. The spec's mention of updating root `package.json` is intentionally overridden — the dashboard is an independent sub-package.
|
|
41
|
+
|
|
42
|
+
Run: `cd dashboard && npm install`
|
|
43
|
+
Expected: `node_modules/` created with `ws` package
|
|
44
|
+
|
|
45
|
+
- [ ] **Step 3: Create dashboard/server.js with HTTP + WebSocket**
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
'use strict';
|
|
49
|
+
|
|
50
|
+
const http = require('http');
|
|
51
|
+
const fs = require('fs');
|
|
52
|
+
const path = require('path');
|
|
53
|
+
const { WebSocketServer } = require('ws');
|
|
54
|
+
|
|
55
|
+
const PORT = parseInt(process.env.ERNE_DASHBOARD_PORT || '3333', 10);
|
|
56
|
+
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
57
|
+
|
|
58
|
+
// In-memory agent state
|
|
59
|
+
const AGENTS = {
|
|
60
|
+
'architect': { status: 'idle', task: '', room: 'development', startedAt: 0, lastEvent: 0 },
|
|
61
|
+
'native-bridge-builder': { status: 'idle', task: '', room: 'development', startedAt: 0, lastEvent: 0 },
|
|
62
|
+
'expo-config-resolver': { status: 'idle', task: '', room: 'development', startedAt: 0, lastEvent: 0 },
|
|
63
|
+
'ui-designer': { status: 'idle', task: '', room: 'development', startedAt: 0, lastEvent: 0 },
|
|
64
|
+
'code-reviewer': { status: 'idle', task: '', room: 'review', startedAt: 0, lastEvent: 0 },
|
|
65
|
+
'upgrade-assistant': { status: 'idle', task: '', room: 'review', startedAt: 0, lastEvent: 0 },
|
|
66
|
+
'tdd-guide': { status: 'idle', task: '', room: 'testing', startedAt: 0, lastEvent: 0 },
|
|
67
|
+
'performance-profiler': { status: 'idle', task: '', room: 'testing', startedAt: 0, lastEvent: 0 },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const MIME_TYPES = {
|
|
71
|
+
'.html': 'text/html',
|
|
72
|
+
'.js': 'application/javascript',
|
|
73
|
+
'.css': 'text/css',
|
|
74
|
+
'.png': 'image/png',
|
|
75
|
+
'.json': 'application/json',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Auto-timeout: reset agents idle after 5 minutes of no events
|
|
79
|
+
const TIMEOUT_MS = 5 * 60 * 1000;
|
|
80
|
+
|
|
81
|
+
setInterval(() => {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
for (const [name, agent] of Object.entries(AGENTS)) {
|
|
84
|
+
if (agent.status === 'working' && agent.lastEvent > 0 && (now - agent.lastEvent) > TIMEOUT_MS) {
|
|
85
|
+
agent.status = 'idle';
|
|
86
|
+
agent.task = '';
|
|
87
|
+
broadcast({ type: 'state', agents: AGENTS });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}, 30000);
|
|
91
|
+
|
|
92
|
+
// WebSocket clients
|
|
93
|
+
const wsClients = new Set();
|
|
94
|
+
|
|
95
|
+
function broadcast(data) {
|
|
96
|
+
const msg = JSON.stringify(data);
|
|
97
|
+
for (const client of wsClients) {
|
|
98
|
+
if (client.readyState === 1) {
|
|
99
|
+
client.send(msg);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function handleEvent(event) {
|
|
105
|
+
const { type, agent, task } = event;
|
|
106
|
+
if (!agent || !AGENTS[agent]) return;
|
|
107
|
+
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const agentState = AGENTS[agent];
|
|
110
|
+
agentState.lastEvent = now;
|
|
111
|
+
|
|
112
|
+
switch (type) {
|
|
113
|
+
case 'agent:start':
|
|
114
|
+
agentState.status = 'working';
|
|
115
|
+
agentState.task = task || '';
|
|
116
|
+
agentState.startedAt = now;
|
|
117
|
+
break;
|
|
118
|
+
case 'agent:complete':
|
|
119
|
+
agentState.status = 'done';
|
|
120
|
+
agentState.task = '';
|
|
121
|
+
// Transition to idle after 3 seconds
|
|
122
|
+
setTimeout(() => {
|
|
123
|
+
agentState.status = 'idle';
|
|
124
|
+
broadcast({ type: 'state', agents: AGENTS });
|
|
125
|
+
}, 3000);
|
|
126
|
+
break;
|
|
127
|
+
default:
|
|
128
|
+
// tool:call or unknown — just update lastEvent
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
broadcast({ type: 'state', agents: AGENTS });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const server = http.createServer((req, res) => {
|
|
136
|
+
// API endpoint
|
|
137
|
+
if (req.method === 'POST' && req.url === '/api/events') {
|
|
138
|
+
let body = '';
|
|
139
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
140
|
+
req.on('end', () => {
|
|
141
|
+
try {
|
|
142
|
+
const event = JSON.parse(body);
|
|
143
|
+
event.timestamp = Date.now();
|
|
144
|
+
handleEvent(event);
|
|
145
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
146
|
+
res.end('{"ok":true}');
|
|
147
|
+
} catch {
|
|
148
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
149
|
+
res.end('{"error":"invalid json"}');
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// GET /api/state — current state snapshot
|
|
156
|
+
if (req.method === 'GET' && req.url === '/api/state') {
|
|
157
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
158
|
+
res.end(JSON.stringify({ agents: AGENTS }));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Static file serving
|
|
163
|
+
let filePath = req.url === '/' ? '/index.html' : req.url;
|
|
164
|
+
filePath = path.join(PUBLIC_DIR, filePath);
|
|
165
|
+
|
|
166
|
+
// Prevent directory traversal
|
|
167
|
+
if (!filePath.startsWith(PUBLIC_DIR)) {
|
|
168
|
+
res.writeHead(403);
|
|
169
|
+
res.end('Forbidden');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const ext = path.extname(filePath);
|
|
174
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
175
|
+
|
|
176
|
+
fs.readFile(filePath, (err, data) => {
|
|
177
|
+
if (err) {
|
|
178
|
+
res.writeHead(404);
|
|
179
|
+
res.end('Not Found');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
183
|
+
res.end(data);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// WebSocket server
|
|
188
|
+
const wss = new WebSocketServer({ server });
|
|
189
|
+
|
|
190
|
+
wss.on('connection', (ws) => {
|
|
191
|
+
wsClients.add(ws);
|
|
192
|
+
// Send current state on connect
|
|
193
|
+
ws.send(JSON.stringify({ type: 'state', agents: AGENTS }));
|
|
194
|
+
ws.on('close', () => wsClients.delete(ws));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
server.listen(PORT, () => {
|
|
198
|
+
console.log(`ERNE Dashboard running at http://localhost:${PORT}`);
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
- [ ] **Step 4: Create minimal dashboard/public/index.html for testing**
|
|
203
|
+
|
|
204
|
+
```html
|
|
205
|
+
<!DOCTYPE html>
|
|
206
|
+
<html><head><title>ERNE Dashboard</title></head>
|
|
207
|
+
<body>
|
|
208
|
+
<h1>ERNE Dashboard</h1>
|
|
209
|
+
<pre id="state"></pre>
|
|
210
|
+
<script>
|
|
211
|
+
const ws = new WebSocket(`ws://${location.host}`);
|
|
212
|
+
ws.onmessage = (e) => {
|
|
213
|
+
document.getElementById('state').textContent = JSON.stringify(JSON.parse(e.data), null, 2);
|
|
214
|
+
};
|
|
215
|
+
</script>
|
|
216
|
+
</body>
|
|
217
|
+
</html>
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
- [ ] **Step 5: Test server manually**
|
|
221
|
+
|
|
222
|
+
Run: `cd dashboard && node server.js &`
|
|
223
|
+
Run: `curl -s http://localhost:3333/api/state`
|
|
224
|
+
Expected: JSON with all 8 agents in `idle` status
|
|
225
|
+
|
|
226
|
+
Run: `curl -s -X POST http://localhost:3333/api/events -H 'Content-Type: application/json' -d '{"type":"agent:start","agent":"architect","task":"Planning auth"}'`
|
|
227
|
+
Expected: `{"ok":true}`
|
|
228
|
+
|
|
229
|
+
Run: `curl -s http://localhost:3333/api/state | node -e "process.stdin.on('data',d=>{const s=JSON.parse(d);console.log(s.agents.architect.status)})"`
|
|
230
|
+
Expected: `working`
|
|
231
|
+
|
|
232
|
+
Run: `kill %1` (stop server)
|
|
233
|
+
|
|
234
|
+
- [ ] **Step 6: Commit**
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
git add dashboard/package.json dashboard/package-lock.json dashboard/server.js dashboard/public/index.html
|
|
238
|
+
git commit -m "feat(dashboard): add HTTP + WebSocket server with agent state management"
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
### Task 2: Hook script for Claude Code integration
|
|
244
|
+
|
|
245
|
+
**Files:**
|
|
246
|
+
- Create: `scripts/hooks/dashboard-event.js`
|
|
247
|
+
- Modify: `hooks/hooks.json`
|
|
248
|
+
|
|
249
|
+
- [ ] **Step 1: Create scripts/hooks/dashboard-event.js**
|
|
250
|
+
|
|
251
|
+
```js
|
|
252
|
+
#!/usr/bin/env node
|
|
253
|
+
'use strict';
|
|
254
|
+
|
|
255
|
+
const http = require('http');
|
|
256
|
+
const fs = require('fs');
|
|
257
|
+
|
|
258
|
+
// Read stdin (hook context from Claude Code)
|
|
259
|
+
let stdinData = '';
|
|
260
|
+
try {
|
|
261
|
+
stdinData = fs.readFileSync(0, 'utf8');
|
|
262
|
+
} catch {
|
|
263
|
+
process.exit(0);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let hookContext;
|
|
267
|
+
try {
|
|
268
|
+
hookContext = JSON.parse(stdinData);
|
|
269
|
+
} catch {
|
|
270
|
+
process.exit(0);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const DASHBOARD_PORT = parseInt(process.env.ERNE_DASHBOARD_PORT || '3333', 10);
|
|
274
|
+
|
|
275
|
+
// Known ERNE agent keywords
|
|
276
|
+
const AGENT_KEYWORDS = [
|
|
277
|
+
'architect', 'code-reviewer', 'tdd-guide', 'performance-profiler',
|
|
278
|
+
'native-bridge-builder', 'expo-config-resolver', 'ui-designer', 'upgrade-assistant',
|
|
279
|
+
];
|
|
280
|
+
|
|
281
|
+
function detectAgent(text) {
|
|
282
|
+
if (!text) return null;
|
|
283
|
+
const lower = text.toLowerCase();
|
|
284
|
+
for (const keyword of AGENT_KEYWORDS) {
|
|
285
|
+
if (lower.includes(keyword)) return keyword;
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function extractTaskDescription(text) {
|
|
291
|
+
if (!text) return '';
|
|
292
|
+
// Take first 100 chars as task description
|
|
293
|
+
return text.slice(0, 100).split('\n')[0];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Determine event type from hook context
|
|
297
|
+
// PreToolUse with Agent tool → agent:start
|
|
298
|
+
// PostToolUse with Agent tool → agent:complete
|
|
299
|
+
const event = hookContext.event || '';
|
|
300
|
+
const toolName = hookContext.tool_name || hookContext.toolName || '';
|
|
301
|
+
const toolInput = hookContext.tool_input || hookContext.toolInput || {};
|
|
302
|
+
|
|
303
|
+
if (toolName !== 'Agent' && toolName !== 'agent') {
|
|
304
|
+
process.exit(0);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const prompt = toolInput.prompt || toolInput.description || '';
|
|
308
|
+
const agentName = detectAgent(prompt);
|
|
309
|
+
|
|
310
|
+
if (!agentName) {
|
|
311
|
+
process.exit(0);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let eventType;
|
|
315
|
+
if (event.toLowerCase().includes('pre')) {
|
|
316
|
+
eventType = 'agent:start';
|
|
317
|
+
} else if (event.toLowerCase().includes('post')) {
|
|
318
|
+
eventType = 'agent:complete';
|
|
319
|
+
} else {
|
|
320
|
+
process.exit(0);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const payload = JSON.stringify({
|
|
324
|
+
type: eventType,
|
|
325
|
+
agent: agentName,
|
|
326
|
+
task: extractTaskDescription(prompt),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// POST to dashboard server (fire and forget, silent on error)
|
|
330
|
+
const req = http.request(
|
|
331
|
+
{
|
|
332
|
+
hostname: '127.0.0.1',
|
|
333
|
+
port: DASHBOARD_PORT,
|
|
334
|
+
path: '/api/events',
|
|
335
|
+
method: 'POST',
|
|
336
|
+
headers: { 'Content-Type': 'application/json' },
|
|
337
|
+
timeout: 2000,
|
|
338
|
+
},
|
|
339
|
+
() => { process.exit(0); }
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
req.on('error', () => { process.exit(0); });
|
|
343
|
+
req.on('timeout', () => { req.destroy(); process.exit(0); });
|
|
344
|
+
req.write(payload);
|
|
345
|
+
req.end();
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
- [ ] **Step 2: Add hook entries to hooks/hooks.json**
|
|
349
|
+
|
|
350
|
+
Add these two entries to the `hooks` array in `hooks/hooks.json`:
|
|
351
|
+
|
|
352
|
+
```json
|
|
353
|
+
{
|
|
354
|
+
"event": "PreToolUse",
|
|
355
|
+
"pattern": "Agent",
|
|
356
|
+
"script": "dashboard-event.js",
|
|
357
|
+
"command": "node scripts/hooks/run-with-flags.js dashboard-event.js",
|
|
358
|
+
"profiles": ["minimal", "standard", "strict"]
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
"event": "PostToolUse",
|
|
362
|
+
"pattern": "Agent",
|
|
363
|
+
"script": "dashboard-event.js",
|
|
364
|
+
"command": "node scripts/hooks/run-with-flags.js dashboard-event.js",
|
|
365
|
+
"profiles": ["minimal", "standard", "strict"]
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
- [ ] **Step 3: Test hook script manually**
|
|
370
|
+
|
|
371
|
+
Run dashboard server first: `cd dashboard && node server.js &`
|
|
372
|
+
|
|
373
|
+
Then simulate a PreToolUse hook:
|
|
374
|
+
```bash
|
|
375
|
+
echo '{"event":"PreToolUse","tool_name":"Agent","tool_input":{"prompt":"Use architect to plan the auth module"}}' | node scripts/hooks/dashboard-event.js
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Check state:
|
|
379
|
+
```bash
|
|
380
|
+
curl -s http://localhost:3333/api/state | node -e "process.stdin.on('data',d=>{const s=JSON.parse(d);console.log(s.agents.architect.status)})"
|
|
381
|
+
```
|
|
382
|
+
Expected: `working`
|
|
383
|
+
|
|
384
|
+
Simulate PostToolUse:
|
|
385
|
+
```bash
|
|
386
|
+
echo '{"event":"PostToolUse","tool_name":"Agent","tool_input":{"prompt":"Use architect to plan the auth module"}}' | node scripts/hooks/dashboard-event.js
|
|
387
|
+
```
|
|
388
|
+
Expected: status becomes `done`, then `idle` after 3 seconds.
|
|
389
|
+
|
|
390
|
+
Run: `kill %1`
|
|
391
|
+
|
|
392
|
+
- [ ] **Step 4: Commit**
|
|
393
|
+
|
|
394
|
+
```bash
|
|
395
|
+
git add scripts/hooks/dashboard-event.js hooks/hooks.json
|
|
396
|
+
git commit -m "feat(dashboard): add Claude Code hook script for agent event tracking"
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## Chunk 2: Pixel Art Canvas Frontend
|
|
402
|
+
|
|
403
|
+
### Task 3: Canvas office renderer
|
|
404
|
+
|
|
405
|
+
**Files:**
|
|
406
|
+
- Create: `dashboard/public/canvas.js`
|
|
407
|
+
|
|
408
|
+
- [ ] **Step 1: Create dashboard/public/canvas.js**
|
|
409
|
+
|
|
410
|
+
This file renders the office background: 4 rooms with walls, floors, doors, desks, and computers.
|
|
411
|
+
|
|
412
|
+
```js
|
|
413
|
+
'use strict';
|
|
414
|
+
|
|
415
|
+
// Office layout constants
|
|
416
|
+
const TILE_SIZE = 16;
|
|
417
|
+
const OFFICE_COLS = 52;
|
|
418
|
+
const OFFICE_ROWS = 36;
|
|
419
|
+
const CANVAS_W = OFFICE_COLS * TILE_SIZE; // 832
|
|
420
|
+
const CANVAS_H = OFFICE_ROWS * TILE_SIZE; // 576
|
|
421
|
+
|
|
422
|
+
// Color palette
|
|
423
|
+
const COLORS = {
|
|
424
|
+
wall: '#2C2137',
|
|
425
|
+
wallLight: '#4A3F5C',
|
|
426
|
+
floor: '#8B7355',
|
|
427
|
+
floorAlt: '#7A6548',
|
|
428
|
+
desk: '#5C4033',
|
|
429
|
+
deskTop: '#8B6914',
|
|
430
|
+
computer: '#1a1a2e',
|
|
431
|
+
computerScreen: '#16213e',
|
|
432
|
+
computerScreenOn: '#4CAF50',
|
|
433
|
+
door: '#6B4226',
|
|
434
|
+
doorFrame: '#8B5A2B',
|
|
435
|
+
whiteboard: '#E8E8E8',
|
|
436
|
+
whiteboardFrame: '#666666',
|
|
437
|
+
coffeeMachine: '#333333',
|
|
438
|
+
chair: '#4A3F5C',
|
|
439
|
+
roomLabel: '#FFFFFF',
|
|
440
|
+
headerBg: '#1a1a2e',
|
|
441
|
+
headerText: '#E0E0E0',
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// Room definitions (in tile coordinates)
|
|
445
|
+
const ROOMS = {
|
|
446
|
+
development: { x: 1, y: 3, w: 24, h: 14, label: 'DEVELOPMENT' },
|
|
447
|
+
review: { x: 27, y: 3, w: 24, h: 14, label: 'REVIEW' },
|
|
448
|
+
testing: { x: 1, y: 19, w: 24, h: 14, label: 'TESTING' },
|
|
449
|
+
conference: { x: 27, y: 19, w: 24, h: 14, label: 'CONFERENCE' },
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// Desk positions per room (tile coordinates, relative to room)
|
|
453
|
+
const DESK_POSITIONS = {
|
|
454
|
+
development: [
|
|
455
|
+
{ x: 4, y: 4, agent: 'architect' },
|
|
456
|
+
{ x: 12, y: 4, agent: 'native-bridge-builder' },
|
|
457
|
+
{ x: 4, y: 9, agent: 'expo-config-resolver' },
|
|
458
|
+
{ x: 12, y: 9, agent: 'ui-designer' },
|
|
459
|
+
],
|
|
460
|
+
review: [
|
|
461
|
+
{ x: 4, y: 4, agent: 'code-reviewer' },
|
|
462
|
+
{ x: 12, y: 4, agent: 'upgrade-assistant' },
|
|
463
|
+
],
|
|
464
|
+
testing: [
|
|
465
|
+
{ x: 4, y: 4, agent: 'tdd-guide' },
|
|
466
|
+
{ x: 12, y: 4, agent: 'performance-profiler' },
|
|
467
|
+
],
|
|
468
|
+
conference: [],
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
function drawOffice(ctx) {
|
|
472
|
+
// Background
|
|
473
|
+
ctx.fillStyle = COLORS.wall;
|
|
474
|
+
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
|
475
|
+
|
|
476
|
+
// Header
|
|
477
|
+
ctx.fillStyle = COLORS.headerBg;
|
|
478
|
+
ctx.fillRect(0, 0, CANVAS_W, 3 * TILE_SIZE);
|
|
479
|
+
ctx.fillStyle = COLORS.headerText;
|
|
480
|
+
ctx.font = 'bold 20px monospace';
|
|
481
|
+
ctx.textAlign = 'center';
|
|
482
|
+
ctx.fillText('ERNE HQ', CANVAS_W / 2, 2 * TILE_SIZE);
|
|
483
|
+
|
|
484
|
+
// Draw each room
|
|
485
|
+
for (const [name, room] of Object.entries(ROOMS)) {
|
|
486
|
+
drawRoom(ctx, room, name);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function drawRoom(ctx, room, name) {
|
|
491
|
+
const px = room.x * TILE_SIZE;
|
|
492
|
+
const py = room.y * TILE_SIZE;
|
|
493
|
+
const pw = room.w * TILE_SIZE;
|
|
494
|
+
const ph = room.h * TILE_SIZE;
|
|
495
|
+
|
|
496
|
+
// Floor (checkerboard)
|
|
497
|
+
for (let row = 0; row < room.h; row++) {
|
|
498
|
+
for (let col = 0; col < room.w; col++) {
|
|
499
|
+
ctx.fillStyle = (row + col) % 2 === 0 ? COLORS.floor : COLORS.floorAlt;
|
|
500
|
+
ctx.fillRect(px + col * TILE_SIZE, py + row * TILE_SIZE, TILE_SIZE, TILE_SIZE);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Walls (top and sides, 2px thick appearance)
|
|
505
|
+
ctx.strokeStyle = COLORS.wallLight;
|
|
506
|
+
ctx.lineWidth = 3;
|
|
507
|
+
ctx.strokeRect(px, py, pw, ph);
|
|
508
|
+
|
|
509
|
+
// Room label
|
|
510
|
+
ctx.fillStyle = COLORS.roomLabel;
|
|
511
|
+
ctx.font = 'bold 11px monospace';
|
|
512
|
+
ctx.textAlign = 'center';
|
|
513
|
+
ctx.fillText(room.label, px + pw / 2, py + 14);
|
|
514
|
+
|
|
515
|
+
// Door (bottom center of room)
|
|
516
|
+
const doorX = px + pw / 2 - TILE_SIZE;
|
|
517
|
+
const doorY = py + ph - 2;
|
|
518
|
+
ctx.fillStyle = COLORS.door;
|
|
519
|
+
ctx.fillRect(doorX, doorY, TILE_SIZE * 2, 4);
|
|
520
|
+
ctx.fillStyle = COLORS.doorFrame;
|
|
521
|
+
ctx.fillRect(doorX - 2, doorY, 2, 4);
|
|
522
|
+
ctx.fillRect(doorX + TILE_SIZE * 2, doorY, 2, 4);
|
|
523
|
+
|
|
524
|
+
// Desks
|
|
525
|
+
const desks = DESK_POSITIONS[name] || [];
|
|
526
|
+
for (const desk of desks) {
|
|
527
|
+
drawDesk(ctx, px + desk.x * TILE_SIZE, py + desk.y * TILE_SIZE);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Room-specific furniture
|
|
531
|
+
if (name === 'conference') {
|
|
532
|
+
drawConferenceTable(ctx, px, py, pw, ph);
|
|
533
|
+
}
|
|
534
|
+
if (name === 'development') {
|
|
535
|
+
drawWhiteboard(ctx, px + 20 * TILE_SIZE, py + 2 * TILE_SIZE);
|
|
536
|
+
}
|
|
537
|
+
if (name === 'testing') {
|
|
538
|
+
drawWhiteboard(ctx, px + 20 * TILE_SIZE, py + 2 * TILE_SIZE);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function drawDesk(ctx, x, y) {
|
|
543
|
+
// Desk surface
|
|
544
|
+
ctx.fillStyle = COLORS.desk;
|
|
545
|
+
ctx.fillRect(x, y, TILE_SIZE * 3, TILE_SIZE * 2);
|
|
546
|
+
ctx.fillStyle = COLORS.deskTop;
|
|
547
|
+
ctx.fillRect(x + 2, y + 2, TILE_SIZE * 3 - 4, 4);
|
|
548
|
+
|
|
549
|
+
// Computer monitor
|
|
550
|
+
ctx.fillStyle = COLORS.computer;
|
|
551
|
+
ctx.fillRect(x + TILE_SIZE, y - TILE_SIZE + 4, TILE_SIZE, TILE_SIZE - 4);
|
|
552
|
+
ctx.fillStyle = COLORS.computerScreen;
|
|
553
|
+
ctx.fillRect(x + TILE_SIZE + 2, y - TILE_SIZE + 6, TILE_SIZE - 4, TILE_SIZE - 10);
|
|
554
|
+
|
|
555
|
+
// Chair (below desk)
|
|
556
|
+
ctx.fillStyle = COLORS.chair;
|
|
557
|
+
ctx.fillRect(x + TILE_SIZE - 2, y + TILE_SIZE * 2 + 2, TILE_SIZE + 4, TILE_SIZE - 4);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function drawConferenceTable(ctx, rx, ry, rw, rh) {
|
|
561
|
+
const cx = rx + rw / 2;
|
|
562
|
+
const cy = ry + rh / 2;
|
|
563
|
+
ctx.fillStyle = COLORS.desk;
|
|
564
|
+
ctx.fillRect(cx - TILE_SIZE * 4, cy - TILE_SIZE * 1.5, TILE_SIZE * 8, TILE_SIZE * 3);
|
|
565
|
+
ctx.fillStyle = COLORS.deskTop;
|
|
566
|
+
ctx.fillRect(cx - TILE_SIZE * 4 + 3, cy - TILE_SIZE * 1.5 + 3, TILE_SIZE * 8 - 6, TILE_SIZE * 3 - 6);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function drawWhiteboard(ctx, x, y) {
|
|
570
|
+
ctx.fillStyle = COLORS.whiteboardFrame;
|
|
571
|
+
ctx.fillRect(x, y, TILE_SIZE * 3, TILE_SIZE * 2);
|
|
572
|
+
ctx.fillStyle = COLORS.whiteboard;
|
|
573
|
+
ctx.fillRect(x + 2, y + 2, TILE_SIZE * 3 - 4, TILE_SIZE * 2 - 4);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Get absolute desk position for an agent
|
|
577
|
+
function getAgentDeskPosition(agentName) {
|
|
578
|
+
for (const [roomName, desks] of Object.entries(DESK_POSITIONS)) {
|
|
579
|
+
for (const desk of desks) {
|
|
580
|
+
if (desk.agent === agentName) {
|
|
581
|
+
const room = ROOMS[roomName];
|
|
582
|
+
return {
|
|
583
|
+
x: (room.x + desk.x) * TILE_SIZE + TILE_SIZE - 2,
|
|
584
|
+
y: (room.y + desk.y) * TILE_SIZE + TILE_SIZE * 2 + 2,
|
|
585
|
+
room: roomName,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Export for use in other modules
|
|
594
|
+
window.OfficeCanvas = {
|
|
595
|
+
TILE_SIZE, CANVAS_W, CANVAS_H, COLORS, ROOMS, DESK_POSITIONS,
|
|
596
|
+
drawOffice, getAgentDeskPosition,
|
|
597
|
+
};
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
- [ ] **Step 2: Verify canvas renders**
|
|
601
|
+
|
|
602
|
+
Update `dashboard/public/index.html` to load canvas.js and draw the office:
|
|
603
|
+
|
|
604
|
+
```html
|
|
605
|
+
<!DOCTYPE html>
|
|
606
|
+
<html>
|
|
607
|
+
<head>
|
|
608
|
+
<title>ERNE Dashboard</title>
|
|
609
|
+
<style>
|
|
610
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
611
|
+
body { background: #1a1a2e; display: flex; }
|
|
612
|
+
#office-canvas { image-rendering: pixelated; }
|
|
613
|
+
</style>
|
|
614
|
+
</head>
|
|
615
|
+
<body>
|
|
616
|
+
<canvas id="office-canvas"></canvas>
|
|
617
|
+
<script src="canvas.js"></script>
|
|
618
|
+
<script>
|
|
619
|
+
const canvas = document.getElementById('office-canvas');
|
|
620
|
+
canvas.width = OfficeCanvas.CANVAS_W;
|
|
621
|
+
canvas.height = OfficeCanvas.CANVAS_H;
|
|
622
|
+
const ctx = canvas.getContext('2d');
|
|
623
|
+
OfficeCanvas.drawOffice(ctx);
|
|
624
|
+
</script>
|
|
625
|
+
</body>
|
|
626
|
+
</html>
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
Run: `cd dashboard && node server.js &`
|
|
630
|
+
Open: `http://localhost:3333` in browser
|
|
631
|
+
Expected: Pixel art office with 4 rooms, desks, computers, doors visible
|
|
632
|
+
Run: `kill %1`
|
|
633
|
+
|
|
634
|
+
- [ ] **Step 3: Commit**
|
|
635
|
+
|
|
636
|
+
```bash
|
|
637
|
+
git add dashboard/public/canvas.js dashboard/public/index.html
|
|
638
|
+
git commit -m "feat(dashboard): add pixel-art office canvas renderer with 4 rooms"
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
---
|
|
642
|
+
|
|
643
|
+
### Task 4: Procedural agent sprites
|
|
644
|
+
|
|
645
|
+
**Files:**
|
|
646
|
+
- Create: `dashboard/public/agents.js`
|
|
647
|
+
|
|
648
|
+
- [ ] **Step 1: Create dashboard/public/agents.js**
|
|
649
|
+
|
|
650
|
+
Draws 32x32 pixel art characters procedurally with unique traits per agent.
|
|
651
|
+
|
|
652
|
+
```js
|
|
653
|
+
'use strict';
|
|
654
|
+
|
|
655
|
+
// Agent visual definitions
|
|
656
|
+
const AGENT_DEFS = {
|
|
657
|
+
'architect': { bodyColor: '#3498db', traitColor: '#2980b9', trait: 'hardhat' },
|
|
658
|
+
'native-bridge-builder': { bodyColor: '#e74c3c', traitColor: '#c0392b', trait: 'wrench' },
|
|
659
|
+
'expo-config-resolver': { bodyColor: '#9b59b6', traitColor: '#8e44ad', trait: 'gear' },
|
|
660
|
+
'ui-designer': { bodyColor: '#e91e63', traitColor: '#c2185b', trait: 'paintbrush' },
|
|
661
|
+
'code-reviewer': { bodyColor: '#2ecc71', traitColor: '#27ae60', trait: 'glasses' },
|
|
662
|
+
'upgrade-assistant': { bodyColor: '#f39c12', traitColor: '#e67e22', trait: 'arrow' },
|
|
663
|
+
'tdd-guide': { bodyColor: '#1abc9c', traitColor: '#16a085', trait: 'testtube' },
|
|
664
|
+
'performance-profiler': { bodyColor: '#e67e22', traitColor: '#d35400', trait: 'stopwatch' },
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
const SKIN_COLOR = '#FDBCB4';
|
|
668
|
+
const HAIR_COLOR = '#4A3728';
|
|
669
|
+
|
|
670
|
+
// Generate sprite frames for an agent onto an offscreen canvas
|
|
671
|
+
// Returns a canvas with 4 cols x 4 rows of 32x32 frames
|
|
672
|
+
function generateSpriteSheet(agentName) {
|
|
673
|
+
const def = AGENT_DEFS[agentName];
|
|
674
|
+
if (!def) return null;
|
|
675
|
+
|
|
676
|
+
const sheet = document.createElement('canvas');
|
|
677
|
+
sheet.width = 128;
|
|
678
|
+
sheet.height = 128;
|
|
679
|
+
const ctx = sheet.getContext('2d');
|
|
680
|
+
|
|
681
|
+
// Row 0: IDLE (4 frames, subtle head bob)
|
|
682
|
+
for (let f = 0; f < 4; f++) {
|
|
683
|
+
const ox = f * 32;
|
|
684
|
+
const oy = 0;
|
|
685
|
+
const headBob = f === 1 || f === 2 ? -1 : 0;
|
|
686
|
+
drawCharacter(ctx, ox, oy, def, headBob, false);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Row 1: WORKING (4 frames, typing animation)
|
|
690
|
+
for (let f = 0; f < 4; f++) {
|
|
691
|
+
const ox = f * 32;
|
|
692
|
+
const oy = 32;
|
|
693
|
+
const armOffset = f % 2 === 0 ? -1 : 1;
|
|
694
|
+
drawCharacter(ctx, ox, oy, def, 0, true, armOffset);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Row 2: MOVING (4 frames, walk cycle)
|
|
698
|
+
for (let f = 0; f < 4; f++) {
|
|
699
|
+
const ox = f * 32;
|
|
700
|
+
const oy = 64;
|
|
701
|
+
const legOffset = f < 2 ? 1 : -1;
|
|
702
|
+
drawWalkingCharacter(ctx, ox, oy, def, legOffset);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Row 3: DONE (4 frames, checkmark)
|
|
706
|
+
for (let f = 0; f < 4; f++) {
|
|
707
|
+
const ox = f * 32;
|
|
708
|
+
const oy = 96;
|
|
709
|
+
drawCharacter(ctx, ox, oy, def, 0, false);
|
|
710
|
+
if (f < 3) {
|
|
711
|
+
drawCheckmark(ctx, ox, oy, f);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return sheet;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function drawCharacter(ctx, ox, oy, def, headBob, typing, armOffset) {
|
|
719
|
+
// Body (shirt)
|
|
720
|
+
ctx.fillStyle = def.bodyColor;
|
|
721
|
+
ctx.fillRect(ox + 10, oy + 14, 12, 10);
|
|
722
|
+
|
|
723
|
+
// Head
|
|
724
|
+
ctx.fillStyle = SKIN_COLOR;
|
|
725
|
+
ctx.fillRect(ox + 11, oy + 4 + headBob, 10, 10);
|
|
726
|
+
|
|
727
|
+
// Hair
|
|
728
|
+
ctx.fillStyle = HAIR_COLOR;
|
|
729
|
+
ctx.fillRect(ox + 11, oy + 3 + headBob, 10, 3);
|
|
730
|
+
|
|
731
|
+
// Eyes
|
|
732
|
+
ctx.fillStyle = '#333';
|
|
733
|
+
ctx.fillRect(ox + 13, oy + 8 + headBob, 2, 2);
|
|
734
|
+
ctx.fillRect(ox + 18, oy + 8 + headBob, 2, 2);
|
|
735
|
+
|
|
736
|
+
// Arms
|
|
737
|
+
ctx.fillStyle = def.bodyColor;
|
|
738
|
+
if (typing && armOffset) {
|
|
739
|
+
ctx.fillRect(ox + 7, oy + 15 + armOffset, 3, 6);
|
|
740
|
+
ctx.fillRect(ox + 22, oy + 15 - armOffset, 3, 6);
|
|
741
|
+
} else {
|
|
742
|
+
ctx.fillRect(ox + 7, oy + 15, 3, 6);
|
|
743
|
+
ctx.fillRect(ox + 22, oy + 15, 3, 6);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Hands
|
|
747
|
+
ctx.fillStyle = SKIN_COLOR;
|
|
748
|
+
ctx.fillRect(ox + 7, oy + 21, 3, 2);
|
|
749
|
+
ctx.fillRect(ox + 22, oy + 21, 3, 2);
|
|
750
|
+
|
|
751
|
+
// Legs
|
|
752
|
+
ctx.fillStyle = '#2c3e50';
|
|
753
|
+
ctx.fillRect(ox + 11, oy + 24, 4, 6);
|
|
754
|
+
ctx.fillRect(ox + 17, oy + 24, 4, 6);
|
|
755
|
+
|
|
756
|
+
// Trait
|
|
757
|
+
drawTrait(ctx, ox, oy + headBob, def);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function drawWalkingCharacter(ctx, ox, oy, def, legOffset) {
|
|
761
|
+
// Body
|
|
762
|
+
ctx.fillStyle = def.bodyColor;
|
|
763
|
+
ctx.fillRect(ox + 10, oy + 14, 12, 10);
|
|
764
|
+
|
|
765
|
+
// Head
|
|
766
|
+
ctx.fillStyle = SKIN_COLOR;
|
|
767
|
+
ctx.fillRect(ox + 11, oy + 4, 10, 10);
|
|
768
|
+
|
|
769
|
+
// Hair
|
|
770
|
+
ctx.fillStyle = HAIR_COLOR;
|
|
771
|
+
ctx.fillRect(ox + 11, oy + 3, 10, 3);
|
|
772
|
+
|
|
773
|
+
// Eyes
|
|
774
|
+
ctx.fillStyle = '#333';
|
|
775
|
+
ctx.fillRect(ox + 13, oy + 8, 2, 2);
|
|
776
|
+
ctx.fillRect(ox + 18, oy + 8, 2, 2);
|
|
777
|
+
|
|
778
|
+
// Arms (swinging)
|
|
779
|
+
ctx.fillStyle = def.bodyColor;
|
|
780
|
+
ctx.fillRect(ox + 7, oy + 15 + legOffset, 3, 6);
|
|
781
|
+
ctx.fillRect(ox + 22, oy + 15 - legOffset, 3, 6);
|
|
782
|
+
|
|
783
|
+
// Legs (walking)
|
|
784
|
+
ctx.fillStyle = '#2c3e50';
|
|
785
|
+
ctx.fillRect(ox + 11, oy + 24 + legOffset, 4, 6);
|
|
786
|
+
ctx.fillRect(ox + 17, oy + 24 - legOffset, 4, 6);
|
|
787
|
+
|
|
788
|
+
drawTrait(ctx, ox, oy, def);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function drawTrait(ctx, ox, oy, def) {
|
|
792
|
+
ctx.fillStyle = def.traitColor;
|
|
793
|
+
switch (def.trait) {
|
|
794
|
+
case 'hardhat':
|
|
795
|
+
ctx.fillRect(ox + 9, oy + 1, 14, 3);
|
|
796
|
+
ctx.fillRect(ox + 11, oy + 0, 10, 2);
|
|
797
|
+
break;
|
|
798
|
+
case 'wrench':
|
|
799
|
+
ctx.fillRect(ox + 24, oy + 6, 6, 2);
|
|
800
|
+
ctx.fillRect(ox + 28, oy + 4, 2, 6);
|
|
801
|
+
break;
|
|
802
|
+
case 'gear':
|
|
803
|
+
ctx.fillRect(ox + 25, oy + 5, 5, 5);
|
|
804
|
+
ctx.fillStyle = def.bodyColor;
|
|
805
|
+
ctx.fillRect(ox + 26, oy + 6, 3, 3);
|
|
806
|
+
break;
|
|
807
|
+
case 'paintbrush':
|
|
808
|
+
ctx.fillRect(ox + 25, oy + 4, 2, 8);
|
|
809
|
+
ctx.fillStyle = '#ff6b6b';
|
|
810
|
+
ctx.fillRect(ox + 24, oy + 3, 4, 2);
|
|
811
|
+
break;
|
|
812
|
+
case 'glasses':
|
|
813
|
+
ctx.fillStyle = '#666';
|
|
814
|
+
ctx.fillRect(ox + 12, oy + 7, 8, 1);
|
|
815
|
+
ctx.fillStyle = '#88CCFF';
|
|
816
|
+
ctx.fillRect(ox + 12, oy + 7, 3, 3);
|
|
817
|
+
ctx.fillRect(ox + 17, oy + 7, 3, 3);
|
|
818
|
+
break;
|
|
819
|
+
case 'arrow':
|
|
820
|
+
ctx.fillRect(ox + 26, oy + 3, 2, 8);
|
|
821
|
+
ctx.fillRect(ox + 24, oy + 3, 6, 2);
|
|
822
|
+
ctx.fillRect(ox + 25, oy + 2, 4, 1);
|
|
823
|
+
break;
|
|
824
|
+
case 'testtube':
|
|
825
|
+
ctx.fillStyle = '#88DDCC';
|
|
826
|
+
ctx.fillRect(ox + 26, oy + 4, 3, 7);
|
|
827
|
+
ctx.fillStyle = '#55BBAA';
|
|
828
|
+
ctx.fillRect(ox + 26, oy + 8, 3, 3);
|
|
829
|
+
break;
|
|
830
|
+
case 'stopwatch':
|
|
831
|
+
ctx.fillRect(ox + 25, oy + 5, 5, 5);
|
|
832
|
+
ctx.fillStyle = '#FFF';
|
|
833
|
+
ctx.fillRect(ox + 26, oy + 6, 3, 3);
|
|
834
|
+
ctx.fillStyle = def.traitColor;
|
|
835
|
+
ctx.fillRect(ox + 27, oy + 3, 1, 2);
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function drawCheckmark(ctx, ox, oy, frame) {
|
|
841
|
+
const scale = (frame + 1) / 3;
|
|
842
|
+
ctx.fillStyle = '#4CAF50';
|
|
843
|
+
const cx = ox + 16;
|
|
844
|
+
const cy = oy - 2;
|
|
845
|
+
const s = Math.floor(4 * scale);
|
|
846
|
+
ctx.fillRect(cx - s, cy - s, s * 2, s * 2);
|
|
847
|
+
ctx.fillStyle = '#FFF';
|
|
848
|
+
// Simple checkmark pixels
|
|
849
|
+
if (frame >= 1) {
|
|
850
|
+
ctx.fillRect(cx - 2, cy, 1, 1);
|
|
851
|
+
ctx.fillRect(cx - 1, cy + 1, 1, 1);
|
|
852
|
+
ctx.fillRect(cx, cy, 1, 1);
|
|
853
|
+
ctx.fillRect(cx + 1, cy - 1, 1, 1);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Sprite animation state manager
|
|
858
|
+
const agentSprites = {};
|
|
859
|
+
|
|
860
|
+
function initAgentSprites() {
|
|
861
|
+
for (const name of Object.keys(AGENT_DEFS)) {
|
|
862
|
+
const sheet = generateSpriteSheet(name);
|
|
863
|
+
const pos = window.OfficeCanvas.getAgentDeskPosition(name);
|
|
864
|
+
agentSprites[name] = {
|
|
865
|
+
sheet,
|
|
866
|
+
x: pos ? pos.x : 0,
|
|
867
|
+
y: pos ? pos.y : 0,
|
|
868
|
+
targetX: pos ? pos.x : 0,
|
|
869
|
+
targetY: pos ? pos.y : 0,
|
|
870
|
+
room: pos ? pos.room : 'development',
|
|
871
|
+
status: 'idle',
|
|
872
|
+
frame: 0,
|
|
873
|
+
frameTimer: 0,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function updateAgentState(agentName, status) {
|
|
879
|
+
const sprite = agentSprites[agentName];
|
|
880
|
+
if (!sprite) return;
|
|
881
|
+
sprite.status = status;
|
|
882
|
+
sprite.frame = 0;
|
|
883
|
+
sprite.frameTimer = 0;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function updateAgentSprites(dt) {
|
|
887
|
+
for (const sprite of Object.values(agentSprites)) {
|
|
888
|
+
sprite.frameTimer += dt;
|
|
889
|
+
if (sprite.frameTimer > 1000 / 12) { // 12 FPS sprite animation
|
|
890
|
+
sprite.frameTimer = 0;
|
|
891
|
+
sprite.frame = (sprite.frame + 1) % 4;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function drawAgentSprites(ctx) {
|
|
897
|
+
for (const [name, sprite] of Object.entries(agentSprites)) {
|
|
898
|
+
if (!sprite.sheet) continue;
|
|
899
|
+
|
|
900
|
+
const row = sprite.status === 'idle' ? 0
|
|
901
|
+
: sprite.status === 'working' ? 1
|
|
902
|
+
: sprite.status === 'moving' ? 2
|
|
903
|
+
: 3; // done
|
|
904
|
+
|
|
905
|
+
const sx = sprite.frame * 32;
|
|
906
|
+
const sy = row * 32;
|
|
907
|
+
|
|
908
|
+
ctx.drawImage(sprite.sheet, sx, sy, 32, 32, sprite.x, sprite.y, 32, 32);
|
|
909
|
+
|
|
910
|
+
// Status indicator dot above head
|
|
911
|
+
const dotColor = sprite.status === 'working' ? '#4CAF50'
|
|
912
|
+
: sprite.status === 'done' ? '#2196F3'
|
|
913
|
+
: '#9E9E9E';
|
|
914
|
+
ctx.fillStyle = dotColor;
|
|
915
|
+
ctx.fillRect(sprite.x + 14, sprite.y - 4, 4, 4);
|
|
916
|
+
|
|
917
|
+
// Name label
|
|
918
|
+
ctx.fillStyle = '#FFF';
|
|
919
|
+
ctx.font = '8px monospace';
|
|
920
|
+
ctx.textAlign = 'center';
|
|
921
|
+
ctx.fillText(name.split('-')[0], sprite.x + 16, sprite.y + 38);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
window.AgentSprites = {
|
|
926
|
+
AGENT_DEFS, agentSprites,
|
|
927
|
+
initAgentSprites, updateAgentState, updateAgentSprites, drawAgentSprites,
|
|
928
|
+
};
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
- [ ] **Step 2: Commit**
|
|
932
|
+
|
|
933
|
+
```bash
|
|
934
|
+
git add dashboard/public/agents.js
|
|
935
|
+
git commit -m "feat(dashboard): add procedural pixel-art agent sprites with animations"
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
---
|
|
939
|
+
|
|
940
|
+
### Task 5: Sidebar status panel
|
|
941
|
+
|
|
942
|
+
**Files:**
|
|
943
|
+
- Create: `dashboard/public/sidebar.js`
|
|
944
|
+
|
|
945
|
+
- [ ] **Step 1: Create dashboard/public/sidebar.js**
|
|
946
|
+
|
|
947
|
+
```js
|
|
948
|
+
'use strict';
|
|
949
|
+
|
|
950
|
+
const SIDEBAR_W = 220;
|
|
951
|
+
const SIDEBAR_PAD = 12;
|
|
952
|
+
const ROW_H = 52;
|
|
953
|
+
|
|
954
|
+
const STATUS_COLORS = {
|
|
955
|
+
idle: '#9E9E9E',
|
|
956
|
+
working: '#4CAF50',
|
|
957
|
+
done: '#2196F3',
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
const STATUS_LABELS = {
|
|
961
|
+
idle: 'IDLE',
|
|
962
|
+
working: 'WORKING',
|
|
963
|
+
done: 'DONE',
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
function drawSidebar(ctx, agents, canvasW, canvasH) {
|
|
967
|
+
const sx = canvasW;
|
|
968
|
+
const sy = 0;
|
|
969
|
+
|
|
970
|
+
// Background
|
|
971
|
+
ctx.fillStyle = '#16213e';
|
|
972
|
+
ctx.fillRect(sx, sy, SIDEBAR_W, canvasH);
|
|
973
|
+
|
|
974
|
+
// Header
|
|
975
|
+
ctx.fillStyle = '#1a1a2e';
|
|
976
|
+
ctx.fillRect(sx, sy, SIDEBAR_W, 40);
|
|
977
|
+
ctx.fillStyle = '#E0E0E0';
|
|
978
|
+
ctx.font = 'bold 14px monospace';
|
|
979
|
+
ctx.textAlign = 'center';
|
|
980
|
+
ctx.fillText('AGENTS', sx + SIDEBAR_W / 2, 26);
|
|
981
|
+
|
|
982
|
+
// Separator
|
|
983
|
+
ctx.fillStyle = '#2C2137';
|
|
984
|
+
ctx.fillRect(sx, 40, SIDEBAR_W, 2);
|
|
985
|
+
|
|
986
|
+
// Agent rows
|
|
987
|
+
const agentNames = Object.keys(agents);
|
|
988
|
+
let y = 46;
|
|
989
|
+
|
|
990
|
+
for (const name of agentNames) {
|
|
991
|
+
const agent = agents[name];
|
|
992
|
+
const status = agent.status || 'idle';
|
|
993
|
+
|
|
994
|
+
// Row background (alternating)
|
|
995
|
+
ctx.fillStyle = agentNames.indexOf(name) % 2 === 0 ? '#1a1a3e' : '#16213e';
|
|
996
|
+
ctx.fillRect(sx, y, SIDEBAR_W, ROW_H);
|
|
997
|
+
|
|
998
|
+
// Status dot
|
|
999
|
+
ctx.fillStyle = STATUS_COLORS[status] || STATUS_COLORS.idle;
|
|
1000
|
+
ctx.beginPath();
|
|
1001
|
+
ctx.arc(sx + SIDEBAR_PAD + 6, y + 16, 5, 0, Math.PI * 2);
|
|
1002
|
+
ctx.fill();
|
|
1003
|
+
|
|
1004
|
+
// Agent name
|
|
1005
|
+
ctx.fillStyle = '#E0E0E0';
|
|
1006
|
+
ctx.font = 'bold 11px monospace';
|
|
1007
|
+
ctx.textAlign = 'left';
|
|
1008
|
+
ctx.fillText(name, sx + SIDEBAR_PAD + 18, y + 19);
|
|
1009
|
+
|
|
1010
|
+
// Status label
|
|
1011
|
+
ctx.fillStyle = STATUS_COLORS[status] || STATUS_COLORS.idle;
|
|
1012
|
+
ctx.font = '9px monospace';
|
|
1013
|
+
ctx.fillText(STATUS_LABELS[status] || 'IDLE', sx + SIDEBAR_PAD + 18, y + 32);
|
|
1014
|
+
|
|
1015
|
+
// Task (truncated)
|
|
1016
|
+
if (agent.task) {
|
|
1017
|
+
ctx.fillStyle = '#888';
|
|
1018
|
+
ctx.font = '8px monospace';
|
|
1019
|
+
const taskText = agent.task.length > 24 ? agent.task.slice(0, 24) + '...' : agent.task;
|
|
1020
|
+
ctx.fillText(taskText, sx + SIDEBAR_PAD + 18, y + 44);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
y += ROW_H;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Connection indicator (drawn by main loop)
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function drawConnectionIndicator(ctx, canvasW, canvasH, connected) {
|
|
1030
|
+
const sx = canvasW + SIDEBAR_W - 16;
|
|
1031
|
+
const sy = canvasH - 16;
|
|
1032
|
+
ctx.fillStyle = connected ? '#4CAF50' : '#F44336';
|
|
1033
|
+
ctx.beginPath();
|
|
1034
|
+
ctx.arc(sx, sy, 5, 0, Math.PI * 2);
|
|
1035
|
+
ctx.fill();
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
window.Sidebar = {
|
|
1039
|
+
SIDEBAR_W,
|
|
1040
|
+
drawSidebar,
|
|
1041
|
+
drawConnectionIndicator,
|
|
1042
|
+
};
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
- [ ] **Step 2: Commit**
|
|
1046
|
+
|
|
1047
|
+
```bash
|
|
1048
|
+
git add dashboard/public/sidebar.js
|
|
1049
|
+
git commit -m "feat(dashboard): add sidebar status panel with agent list and status badges"
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
---
|
|
1053
|
+
|
|
1054
|
+
### Task 6: WebSocket client with auto-reconnect
|
|
1055
|
+
|
|
1056
|
+
**Files:**
|
|
1057
|
+
- Create: `dashboard/public/ws-client.js`
|
|
1058
|
+
|
|
1059
|
+
- [ ] **Step 1: Create dashboard/public/ws-client.js**
|
|
1060
|
+
|
|
1061
|
+
```js
|
|
1062
|
+
'use strict';
|
|
1063
|
+
|
|
1064
|
+
function createWSClient(onStateUpdate, onConnectionChange) {
|
|
1065
|
+
let ws = null;
|
|
1066
|
+
let reconnectDelay = 1000;
|
|
1067
|
+
const MAX_DELAY = 30000;
|
|
1068
|
+
let connected = false;
|
|
1069
|
+
|
|
1070
|
+
function connect() {
|
|
1071
|
+
ws = new WebSocket(`ws://${location.host}`);
|
|
1072
|
+
|
|
1073
|
+
ws.onopen = () => {
|
|
1074
|
+
connected = true;
|
|
1075
|
+
reconnectDelay = 1000;
|
|
1076
|
+
onConnectionChange(true);
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
ws.onmessage = (e) => {
|
|
1080
|
+
try {
|
|
1081
|
+
const data = JSON.parse(e.data);
|
|
1082
|
+
if (data.type === 'state' && data.agents) {
|
|
1083
|
+
onStateUpdate(data.agents);
|
|
1084
|
+
}
|
|
1085
|
+
} catch {}
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
ws.onclose = () => {
|
|
1089
|
+
connected = false;
|
|
1090
|
+
onConnectionChange(false);
|
|
1091
|
+
setTimeout(connect, reconnectDelay);
|
|
1092
|
+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_DELAY);
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
ws.onerror = () => {
|
|
1096
|
+
ws.close();
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
connect();
|
|
1101
|
+
|
|
1102
|
+
return {
|
|
1103
|
+
isConnected: () => connected,
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
window.WSClient = { createWSClient };
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
- [ ] **Step 2: Commit**
|
|
1111
|
+
|
|
1112
|
+
```bash
|
|
1113
|
+
git add dashboard/public/ws-client.js
|
|
1114
|
+
git commit -m "feat(dashboard): add WebSocket client with exponential backoff reconnect"
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
---
|
|
1118
|
+
|
|
1119
|
+
### Task 7: Wire everything together in index.html
|
|
1120
|
+
|
|
1121
|
+
**Files:**
|
|
1122
|
+
- Modify: `dashboard/public/index.html`
|
|
1123
|
+
|
|
1124
|
+
- [ ] **Step 1: Rewrite dashboard/public/index.html**
|
|
1125
|
+
|
|
1126
|
+
```html
|
|
1127
|
+
<!DOCTYPE html>
|
|
1128
|
+
<html lang="en">
|
|
1129
|
+
<head>
|
|
1130
|
+
<meta charset="UTF-8">
|
|
1131
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1132
|
+
<title>ERNE Dashboard</title>
|
|
1133
|
+
<style>
|
|
1134
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1135
|
+
body {
|
|
1136
|
+
background: #0a0a1a;
|
|
1137
|
+
display: flex;
|
|
1138
|
+
justify-content: center;
|
|
1139
|
+
align-items: center;
|
|
1140
|
+
min-height: 100vh;
|
|
1141
|
+
overflow: hidden;
|
|
1142
|
+
}
|
|
1143
|
+
canvas {
|
|
1144
|
+
image-rendering: pixelated;
|
|
1145
|
+
image-rendering: crisp-edges;
|
|
1146
|
+
}
|
|
1147
|
+
</style>
|
|
1148
|
+
</head>
|
|
1149
|
+
<body>
|
|
1150
|
+
<canvas id="dashboard"></canvas>
|
|
1151
|
+
|
|
1152
|
+
<script src="canvas.js"></script>
|
|
1153
|
+
<script src="agents.js"></script>
|
|
1154
|
+
<script src="sidebar.js"></script>
|
|
1155
|
+
<script src="ws-client.js"></script>
|
|
1156
|
+
<script>
|
|
1157
|
+
const canvas = document.getElementById('dashboard');
|
|
1158
|
+
const totalW = OfficeCanvas.CANVAS_W + Sidebar.SIDEBAR_W;
|
|
1159
|
+
const totalH = OfficeCanvas.CANVAS_H;
|
|
1160
|
+
canvas.width = totalW;
|
|
1161
|
+
canvas.height = totalH;
|
|
1162
|
+
const ctx = canvas.getContext('2d');
|
|
1163
|
+
|
|
1164
|
+
// Scale canvas to fit viewport
|
|
1165
|
+
function scaleCanvas() {
|
|
1166
|
+
const scaleX = window.innerWidth / totalW;
|
|
1167
|
+
const scaleY = window.innerHeight / totalH;
|
|
1168
|
+
const scale = Math.min(scaleX, scaleY, 2);
|
|
1169
|
+
canvas.style.width = Math.floor(totalW * scale) + 'px';
|
|
1170
|
+
canvas.style.height = Math.floor(totalH * scale) + 'px';
|
|
1171
|
+
}
|
|
1172
|
+
scaleCanvas();
|
|
1173
|
+
window.addEventListener('resize', scaleCanvas);
|
|
1174
|
+
|
|
1175
|
+
// Initialize sprites
|
|
1176
|
+
AgentSprites.initAgentSprites();
|
|
1177
|
+
|
|
1178
|
+
// State
|
|
1179
|
+
let agentServerState = {};
|
|
1180
|
+
let wsConnected = false;
|
|
1181
|
+
|
|
1182
|
+
// WebSocket
|
|
1183
|
+
WSClient.createWSClient(
|
|
1184
|
+
(agents) => {
|
|
1185
|
+
agentServerState = agents;
|
|
1186
|
+
// Update sprite states
|
|
1187
|
+
for (const [name, state] of Object.entries(agents)) {
|
|
1188
|
+
AgentSprites.updateAgentState(name, state.status);
|
|
1189
|
+
if (AgentSprites.agentSprites[name]) {
|
|
1190
|
+
AgentSprites.agentSprites[name].task = state.task || '';
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
},
|
|
1194
|
+
(connected) => {
|
|
1195
|
+
wsConnected = connected;
|
|
1196
|
+
}
|
|
1197
|
+
);
|
|
1198
|
+
|
|
1199
|
+
// Main render loop
|
|
1200
|
+
let lastTime = 0;
|
|
1201
|
+
function render(time) {
|
|
1202
|
+
const dt = time - lastTime;
|
|
1203
|
+
lastTime = time;
|
|
1204
|
+
|
|
1205
|
+
// Update animations
|
|
1206
|
+
AgentSprites.updateAgentSprites(dt);
|
|
1207
|
+
|
|
1208
|
+
// Clear
|
|
1209
|
+
ctx.clearRect(0, 0, totalW, totalH);
|
|
1210
|
+
|
|
1211
|
+
// Draw office
|
|
1212
|
+
OfficeCanvas.drawOffice(ctx);
|
|
1213
|
+
|
|
1214
|
+
// Draw agents
|
|
1215
|
+
AgentSprites.drawAgentSprites(ctx);
|
|
1216
|
+
|
|
1217
|
+
// Draw sidebar
|
|
1218
|
+
Sidebar.drawSidebar(ctx, agentServerState, OfficeCanvas.CANVAS_W, totalH);
|
|
1219
|
+
Sidebar.drawConnectionIndicator(ctx, OfficeCanvas.CANVAS_W, totalH, wsConnected);
|
|
1220
|
+
|
|
1221
|
+
requestAnimationFrame(render);
|
|
1222
|
+
}
|
|
1223
|
+
requestAnimationFrame(render);
|
|
1224
|
+
</script>
|
|
1225
|
+
</body>
|
|
1226
|
+
</html>
|
|
1227
|
+
```
|
|
1228
|
+
|
|
1229
|
+
- [ ] **Step 2: Test full integration**
|
|
1230
|
+
|
|
1231
|
+
Run: `cd dashboard && node server.js &`
|
|
1232
|
+
Open: `http://localhost:3333`
|
|
1233
|
+
Expected: Pixel art office with all 8 agents at their desks, sidebar showing all agents as IDLE, green connection dot.
|
|
1234
|
+
|
|
1235
|
+
Test agent status change:
|
|
1236
|
+
```bash
|
|
1237
|
+
curl -s -X POST http://localhost:3333/api/events -H 'Content-Type: application/json' -d '{"type":"agent:start","agent":"architect","task":"Planning navigation"}'
|
|
1238
|
+
```
|
|
1239
|
+
Expected: Architect sprite switches to typing animation, sidebar shows WORKING with green dot, task text visible.
|
|
1240
|
+
|
|
1241
|
+
```bash
|
|
1242
|
+
curl -s -X POST http://localhost:3333/api/events -H 'Content-Type: application/json' -d '{"type":"agent:complete","agent":"architect"}'
|
|
1243
|
+
```
|
|
1244
|
+
Expected: Architect shows DONE with checkmark, then after 3s returns to IDLE.
|
|
1245
|
+
|
|
1246
|
+
Run: `kill %1`
|
|
1247
|
+
|
|
1248
|
+
- [ ] **Step 3: Commit**
|
|
1249
|
+
|
|
1250
|
+
```bash
|
|
1251
|
+
git add dashboard/public/index.html
|
|
1252
|
+
git commit -m "feat(dashboard): wire canvas, sprites, sidebar, and WebSocket into main render loop"
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
---
|
|
1256
|
+
|
|
1257
|
+
## Chunk 3: CLI Integration
|
|
1258
|
+
|
|
1259
|
+
### Task 8: lib/dashboard.js command
|
|
1260
|
+
|
|
1261
|
+
**Files:**
|
|
1262
|
+
- Create: `lib/dashboard.js`
|
|
1263
|
+
|
|
1264
|
+
- [ ] **Step 1: Create lib/dashboard.js**
|
|
1265
|
+
|
|
1266
|
+
```js
|
|
1267
|
+
'use strict';
|
|
1268
|
+
|
|
1269
|
+
const { fork } = require('child_process');
|
|
1270
|
+
const { createServer } = require('net');
|
|
1271
|
+
const { resolve } = require('path');
|
|
1272
|
+
const { exec } = require('child_process');
|
|
1273
|
+
|
|
1274
|
+
module.exports = async function dashboard() {
|
|
1275
|
+
const args = process.argv.slice(3);
|
|
1276
|
+
let port = 3333;
|
|
1277
|
+
let noOpen = false;
|
|
1278
|
+
|
|
1279
|
+
for (let i = 0; i < args.length; i++) {
|
|
1280
|
+
if (args[i] === '--port' && args[i + 1]) {
|
|
1281
|
+
port = parseInt(args[i + 1], 10);
|
|
1282
|
+
i++;
|
|
1283
|
+
}
|
|
1284
|
+
if (args[i] === '--no-open') {
|
|
1285
|
+
noOpen = true;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Validate port
|
|
1290
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
1291
|
+
console.error(`Invalid port: ${process.argv[process.argv.indexOf('--port') + 1]}. Must be 1-65535.`);
|
|
1292
|
+
process.exit(1);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Check port availability
|
|
1296
|
+
const available = await checkPort(port);
|
|
1297
|
+
if (!available) {
|
|
1298
|
+
console.error(`Port ${port} is already in use. Try: erne dashboard --port ${port + 1}`);
|
|
1299
|
+
process.exit(1);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// Auto-configure hooks if needed
|
|
1303
|
+
await ensureHooksConfigured();
|
|
1304
|
+
|
|
1305
|
+
// Start dashboard server
|
|
1306
|
+
const serverPath = resolve(__dirname, '../dashboard/server.js');
|
|
1307
|
+
const child = fork(serverPath, [], {
|
|
1308
|
+
env: { ...process.env, ERNE_DASHBOARD_PORT: String(port) },
|
|
1309
|
+
stdio: 'pipe',
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
child.stdout.on('data', (data) => process.stdout.write(data));
|
|
1313
|
+
child.stderr.on('data', (data) => process.stderr.write(data));
|
|
1314
|
+
|
|
1315
|
+
// Wait for server to be ready
|
|
1316
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1317
|
+
|
|
1318
|
+
const url = `http://localhost:${port}`;
|
|
1319
|
+
console.log(`ERNE Dashboard running at ${url}`);
|
|
1320
|
+
|
|
1321
|
+
// Open browser
|
|
1322
|
+
if (!noOpen) {
|
|
1323
|
+
const openCmd = process.platform === 'darwin' ? 'open'
|
|
1324
|
+
: process.platform === 'win32' ? 'start'
|
|
1325
|
+
: 'xdg-open';
|
|
1326
|
+
exec(`${openCmd} ${url}`);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Clean shutdown
|
|
1330
|
+
process.on('SIGINT', () => {
|
|
1331
|
+
child.kill();
|
|
1332
|
+
process.exit(0);
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
process.on('SIGTERM', () => {
|
|
1336
|
+
child.kill();
|
|
1337
|
+
process.exit(0);
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
// Keep alive
|
|
1341
|
+
child.on('exit', (code) => {
|
|
1342
|
+
console.log(`Dashboard server exited with code ${code}`);
|
|
1343
|
+
process.exit(code || 0);
|
|
1344
|
+
});
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1347
|
+
function checkPort(port) {
|
|
1348
|
+
return new Promise((resolve) => {
|
|
1349
|
+
const server = createServer();
|
|
1350
|
+
server.once('error', () => resolve(false));
|
|
1351
|
+
server.once('listening', () => {
|
|
1352
|
+
server.close();
|
|
1353
|
+
resolve(true);
|
|
1354
|
+
});
|
|
1355
|
+
server.listen(port);
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
async function ensureHooksConfigured() {
|
|
1360
|
+
const fs = require('fs');
|
|
1361
|
+
const path = require('path');
|
|
1362
|
+
const readline = require('readline/promises');
|
|
1363
|
+
|
|
1364
|
+
const hooksPath = resolve(__dirname, '../hooks/hooks.json');
|
|
1365
|
+
let config;
|
|
1366
|
+
try {
|
|
1367
|
+
config = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
|
|
1368
|
+
} catch {
|
|
1369
|
+
return; // No hooks file, skip
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const hasDashboardHook = config.hooks.some(h => h.script === 'dashboard-event.js');
|
|
1373
|
+
if (hasDashboardHook) return;
|
|
1374
|
+
|
|
1375
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1376
|
+
const answer = await rl.question('Dashboard hooks not configured. Add them now? (Y/n) ');
|
|
1377
|
+
rl.close();
|
|
1378
|
+
|
|
1379
|
+
if (answer.toLowerCase() === 'n') return;
|
|
1380
|
+
|
|
1381
|
+
config.hooks.push(
|
|
1382
|
+
{
|
|
1383
|
+
event: 'PreToolUse',
|
|
1384
|
+
pattern: 'Agent',
|
|
1385
|
+
script: 'dashboard-event.js',
|
|
1386
|
+
command: 'node scripts/hooks/run-with-flags.js dashboard-event.js',
|
|
1387
|
+
profiles: ['minimal', 'standard', 'strict'],
|
|
1388
|
+
},
|
|
1389
|
+
{
|
|
1390
|
+
event: 'PostToolUse',
|
|
1391
|
+
pattern: 'Agent',
|
|
1392
|
+
script: 'dashboard-event.js',
|
|
1393
|
+
command: 'node scripts/hooks/run-with-flags.js dashboard-event.js',
|
|
1394
|
+
profiles: ['minimal', 'standard', 'strict'],
|
|
1395
|
+
}
|
|
1396
|
+
);
|
|
1397
|
+
|
|
1398
|
+
fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + '\n');
|
|
1399
|
+
console.log('Dashboard hooks added to hooks/hooks.json');
|
|
1400
|
+
}
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
- [ ] **Step 2: Create lib/start.js**
|
|
1404
|
+
|
|
1405
|
+
**Files:**
|
|
1406
|
+
- Create: `lib/start.js`
|
|
1407
|
+
|
|
1408
|
+
```js
|
|
1409
|
+
'use strict';
|
|
1410
|
+
|
|
1411
|
+
const { spawn } = require('child_process');
|
|
1412
|
+
const { resolve } = require('path');
|
|
1413
|
+
|
|
1414
|
+
module.exports = async function start() {
|
|
1415
|
+
// Run init — lib/init.js exports async function init() directly
|
|
1416
|
+
const init = require('./init');
|
|
1417
|
+
await init();
|
|
1418
|
+
|
|
1419
|
+
// Start dashboard in background
|
|
1420
|
+
const port = 3333;
|
|
1421
|
+
const serverPath = resolve(__dirname, '../dashboard/server.js');
|
|
1422
|
+
|
|
1423
|
+
const child = spawn('node', [serverPath], {
|
|
1424
|
+
env: { ...process.env, ERNE_DASHBOARD_PORT: String(port) },
|
|
1425
|
+
detached: true,
|
|
1426
|
+
stdio: 'ignore',
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
child.unref();
|
|
1430
|
+
console.log(`ERNE Dashboard started in background at http://localhost:${port}`);
|
|
1431
|
+
};
|
|
1432
|
+
```
|
|
1433
|
+
|
|
1434
|
+
- [ ] **Step 3: Update bin/cli.js with new commands**
|
|
1435
|
+
|
|
1436
|
+
Add `dashboard` and `start` to the `COMMANDS` object in `bin/cli.js`:
|
|
1437
|
+
|
|
1438
|
+
```js
|
|
1439
|
+
dashboard: () => require('../lib/dashboard'),
|
|
1440
|
+
start: () => require('../lib/start'),
|
|
1441
|
+
```
|
|
1442
|
+
|
|
1443
|
+
Update the help text to include:
|
|
1444
|
+
```
|
|
1445
|
+
dashboard Launch the visual agent dashboard
|
|
1446
|
+
start Initialize project + launch dashboard
|
|
1447
|
+
```
|
|
1448
|
+
|
|
1449
|
+
- [ ] **Step 4: Test CLI commands**
|
|
1450
|
+
|
|
1451
|
+
Run: `node bin/cli.js dashboard --no-open &`
|
|
1452
|
+
Expected: `ERNE Dashboard running at http://localhost:3333`
|
|
1453
|
+
|
|
1454
|
+
Run: `curl -s http://localhost:3333/api/state | head -c 50`
|
|
1455
|
+
Expected: JSON starting with `{"agents":{`
|
|
1456
|
+
|
|
1457
|
+
Run: `kill %1`
|
|
1458
|
+
|
|
1459
|
+
Run: `node bin/cli.js help`
|
|
1460
|
+
Expected: Help text includes `dashboard` and `start` commands
|
|
1461
|
+
|
|
1462
|
+
- [ ] **Step 5: Commit**
|
|
1463
|
+
|
|
1464
|
+
```bash
|
|
1465
|
+
git add lib/dashboard.js lib/start.js bin/cli.js
|
|
1466
|
+
git commit -m "feat(cli): add 'erne dashboard' and 'erne start' commands"
|
|
1467
|
+
```
|
|
1468
|
+
|
|
1469
|
+
---
|
|
1470
|
+
|
|
1471
|
+
### Task 9: End-to-end integration test
|
|
1472
|
+
|
|
1473
|
+
- [ ] **Step 1: Full flow test**
|
|
1474
|
+
|
|
1475
|
+
Start the dashboard:
|
|
1476
|
+
```bash
|
|
1477
|
+
node bin/cli.js dashboard --no-open &
|
|
1478
|
+
```
|
|
1479
|
+
|
|
1480
|
+
Simulate a full agent lifecycle via hook script:
|
|
1481
|
+
```bash
|
|
1482
|
+
# Agent starts
|
|
1483
|
+
echo '{"event":"PreToolUse","tool_name":"Agent","tool_input":{"prompt":"Use architect to plan the auth module"}}' | node scripts/hooks/dashboard-event.js
|
|
1484
|
+
|
|
1485
|
+
# Wait and check
|
|
1486
|
+
sleep 1
|
|
1487
|
+
curl -s http://localhost:3333/api/state | node -e "process.stdin.on('data',d=>{const a=JSON.parse(d).agents;console.log('architect:',a.architect.status,'task:',a.architect.task)})"
|
|
1488
|
+
```
|
|
1489
|
+
Expected: `architect: working task: Use architect to plan the auth module`
|
|
1490
|
+
|
|
1491
|
+
```bash
|
|
1492
|
+
# Agent completes
|
|
1493
|
+
echo '{"event":"PostToolUse","tool_name":"Agent","tool_input":{"prompt":"Use architect to plan the auth module"}}' | node scripts/hooks/dashboard-event.js
|
|
1494
|
+
|
|
1495
|
+
sleep 1
|
|
1496
|
+
curl -s http://localhost:3333/api/state | node -e "process.stdin.on('data',d=>{const a=JSON.parse(d).agents;console.log('architect:',a.architect.status)})"
|
|
1497
|
+
```
|
|
1498
|
+
Expected: `architect: done` (then `idle` after 3 more seconds)
|
|
1499
|
+
|
|
1500
|
+
```bash
|
|
1501
|
+
# Test multiple agents
|
|
1502
|
+
echo '{"event":"PreToolUse","tool_name":"Agent","tool_input":{"prompt":"Run code-reviewer on PR #42"}}' | node scripts/hooks/dashboard-event.js
|
|
1503
|
+
echo '{"event":"PreToolUse","tool_name":"Agent","tool_input":{"prompt":"Use tdd-guide for auth tests"}}' | node scripts/hooks/dashboard-event.js
|
|
1504
|
+
|
|
1505
|
+
sleep 1
|
|
1506
|
+
curl -s http://localhost:3333/api/state | node -e "process.stdin.on('data',d=>{const a=JSON.parse(d).agents;console.log('code-reviewer:',a['code-reviewer'].status);console.log('tdd-guide:',a['tdd-guide'].status)})"
|
|
1507
|
+
```
|
|
1508
|
+
Expected:
|
|
1509
|
+
```
|
|
1510
|
+
code-reviewer: working
|
|
1511
|
+
tdd-guide: working
|
|
1512
|
+
```
|
|
1513
|
+
|
|
1514
|
+
Run: `kill %1`
|
|
1515
|
+
|
|
1516
|
+
- [ ] **Step 2: Commit final integration**
|
|
1517
|
+
|
|
1518
|
+
```bash
|
|
1519
|
+
git add -A
|
|
1520
|
+
git commit -m "feat(dashboard): complete ERNE Agent Dashboard v1 with pixel-art office"
|
|
1521
|
+
```
|
|
1522
|
+
|
|
1523
|
+
---
|
|
1524
|
+
|
|
1525
|
+
## Summary
|
|
1526
|
+
|
|
1527
|
+
| Task | Description | Files |
|
|
1528
|
+
|------|-------------|-------|
|
|
1529
|
+
| 1 | Dashboard server + event API | `dashboard/server.js`, `dashboard/package.json` |
|
|
1530
|
+
| 2 | Hook script for Claude Code | `scripts/hooks/dashboard-event.js`, `hooks/hooks.json` |
|
|
1531
|
+
| 3 | Canvas office renderer | `dashboard/public/canvas.js` |
|
|
1532
|
+
| 4 | Procedural agent sprites | `dashboard/public/agents.js` |
|
|
1533
|
+
| 5 | Sidebar status panel | `dashboard/public/sidebar.js` |
|
|
1534
|
+
| 6 | WebSocket client | `dashboard/public/ws-client.js` |
|
|
1535
|
+
| 7 | Wire everything in index.html | `dashboard/public/index.html` |
|
|
1536
|
+
| 8 | CLI commands (dashboard, start) | `lib/dashboard.js`, `lib/start.js`, `bin/cli.js` |
|
|
1537
|
+
| 9 | End-to-end integration test | Manual test script |
|