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.
@@ -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 |