vibe-forge 0.4.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.claude/commands/clear-attention.md +63 -63
  2. package/.claude/commands/compact-context.md +52 -0
  3. package/.claude/commands/configure-vcs.md +102 -102
  4. package/.claude/commands/forge.md +218 -171
  5. package/.claude/commands/need-help.md +77 -77
  6. package/.claude/commands/update-status.md +64 -64
  7. package/.claude/commands/worker-loop.md +106 -106
  8. package/.claude/hooks/worker-loop.js +217 -187
  9. package/.claude/scripts/setup-worker-loop.sh +45 -45
  10. package/.claude/settings.json +89 -0
  11. package/LICENSE +21 -21
  12. package/README.md +253 -232
  13. package/agents/aegis/personality.md +303 -269
  14. package/agents/anvil/personality.md +278 -240
  15. package/agents/architect/personality.md +260 -234
  16. package/agents/crucible/personality.md +362 -309
  17. package/agents/crucible-x/personality.md +210 -0
  18. package/agents/ember/personality.md +293 -265
  19. package/agents/flux/personality.md +248 -0
  20. package/agents/furnace/personality.md +342 -291
  21. package/agents/herald/personality.md +249 -247
  22. package/agents/loki/personality.md +108 -0
  23. package/agents/oracle/personality.md +284 -0
  24. package/agents/pixel/personality.md +140 -0
  25. package/agents/planning-hub/personality.md +473 -251
  26. package/agents/scribe/personality.md +253 -251
  27. package/agents/slag/personality.md +268 -0
  28. package/agents/temper/personality.md +270 -0
  29. package/bin/cli.js +372 -325
  30. package/bin/dashboard/api/agents.js +333 -0
  31. package/bin/dashboard/api/dispatch.js +507 -0
  32. package/bin/dashboard/api/tasks.js +416 -0
  33. package/bin/dashboard/public/assets/index-BpHfsx1r.js +2 -0
  34. package/bin/dashboard/public/assets/index-QODv4Zn9.css +1 -0
  35. package/bin/dashboard/public/index.html +14 -0
  36. package/bin/dashboard/server.js +645 -0
  37. package/bin/forge-daemon.sh +477 -851
  38. package/bin/forge-setup.sh +661 -645
  39. package/bin/forge-spawn.sh +164 -164
  40. package/bin/forge.cmd +83 -83
  41. package/bin/forge.sh +566 -387
  42. package/bin/lib/agents.sh +177 -177
  43. package/bin/lib/check-aliases.js +50 -0
  44. package/bin/lib/colors.sh +44 -44
  45. package/bin/lib/config.sh +347 -313
  46. package/bin/lib/constants.sh +241 -206
  47. package/bin/lib/daemon/budgets.sh +107 -0
  48. package/bin/lib/daemon/dependencies.sh +146 -0
  49. package/bin/lib/daemon/display.sh +128 -0
  50. package/bin/lib/daemon/notifications.sh +273 -0
  51. package/bin/lib/daemon/routing.sh +93 -0
  52. package/bin/lib/daemon/state.sh +163 -0
  53. package/bin/lib/daemon/sync.sh +103 -0
  54. package/bin/lib/database.sh +357 -305
  55. package/bin/lib/frontmatter.js +106 -0
  56. package/bin/lib/heimdall-setup.js +113 -0
  57. package/bin/lib/heimdall.js +265 -0
  58. package/bin/lib/json.sh +264 -258
  59. package/bin/lib/terminal.js +452 -446
  60. package/bin/lib/util.sh +126 -126
  61. package/bin/lib/vcs.js +349 -349
  62. package/config/agent-manifest.yaml +237 -243
  63. package/config/agents.json +207 -132
  64. package/config/task-template.md +159 -87
  65. package/config/task-types.yaml +111 -106
  66. package/config/templates/handoff-template.md +40 -0
  67. package/context/agent-overrides/README.md +41 -0
  68. package/context/architecture.md +42 -0
  69. package/context/modern-conventions.md +129 -129
  70. package/context/project-context-template.md +122 -122
  71. package/docs/agents.md +473 -409
  72. package/docs/architecture.md +194 -162
  73. package/docs/commands.md +451 -388
  74. package/docs/security.md +195 -144
  75. package/package.json +77 -50
  76. package/.claude/settings.local.json +0 -33
  77. package/agents/forge-master/capabilities.md +0 -144
  78. package/agents/forge-master/context-template.md +0 -128
  79. package/agents/forge-master/personality.md +0 -138
  80. package/agents/sentinel/personality.md +0 -194
  81. package/context/forge-state.yaml +0 -19
  82. package/docs/TODO.md +0 -150
  83. package/docs/getting-started.md +0 -243
  84. package/docs/npm-publishing.md +0 -95
  85. package/docs/workflows/README.md +0 -32
  86. package/docs/workflows/azure-devops.md +0 -108
  87. package/docs/workflows/bitbucket.md +0 -104
  88. package/docs/workflows/git-only.md +0 -130
  89. package/docs/workflows/gitea.md +0 -168
  90. package/docs/workflows/github.md +0 -103
  91. package/docs/workflows/gitlab.md +0 -105
  92. package/docs/workflows.md +0 -454
  93. package/tasks/completed/ARCH-001-duplicate-agent-config.md +0 -121
  94. package/tasks/completed/ARCH-002-mixed-bash-node-implementation.md +0 -88
  95. package/tasks/completed/ARCH-003-worker-loop-hook-duplication.md +0 -77
  96. package/tasks/completed/ARCH-009-test-organization.md +0 -78
  97. package/tasks/completed/ARCH-011-jq-vs-nodejs-json.md +0 -94
  98. package/tasks/completed/ARCH-012-tmp-files-in-root.md +0 -71
  99. package/tasks/completed/ARCH-013-exit-code-constants.md +0 -65
  100. package/tasks/completed/ARCH-014-sed-incompatibility.md +0 -96
  101. package/tasks/completed/ARCH-015-docs-todo-tracking.md +0 -83
  102. package/tasks/completed/CLEAN-001.md +0 -38
  103. package/tasks/completed/CLEAN-003.md +0 -47
  104. package/tasks/completed/CLEAN-004.md +0 -56
  105. package/tasks/completed/CLEAN-005.md +0 -75
  106. package/tasks/completed/CLEAN-006.md +0 -47
  107. package/tasks/completed/CLEAN-007.md +0 -34
  108. package/tasks/completed/CLEAN-008.md +0 -49
  109. package/tasks/completed/CLEAN-012.md +0 -58
  110. package/tasks/completed/CLEAN-013.md +0 -45
  111. package/tasks/completed/SEC-001-sql-injection-fix.md +0 -58
  112. package/tasks/completed/SEC-002-notification-injection-fix.md +0 -45
  113. package/tasks/completed/SEC-003-eval-injection-fix.md +0 -54
  114. package/tasks/completed/SEC-004-pid-race-condition-fix.md +0 -49
  115. package/tasks/completed/SEC-005-worker-loop-path-fix.md +0 -51
  116. package/tasks/completed/SEC-006-eval-agent-names.md +0 -55
  117. package/tasks/completed/SEC-007-spawn-escaping.md +0 -67
  118. package/tasks/pending/ARCH-004-git-bash-detection-duplication.md +0 -72
  119. package/tasks/pending/ARCH-005-missing-src-directory.md +0 -95
  120. package/tasks/pending/ARCH-006-task-template-location.md +0 -64
  121. package/tasks/pending/ARCH-007-daemon-monolith.md +0 -91
  122. package/tasks/pending/ARCH-008-forge-master-vs-hub.md +0 -81
  123. package/tasks/pending/ARCH-010-missing-index-files.md +0 -84
  124. package/tasks/pending/CLEAN-002.md +0 -29
  125. package/tasks/pending/CLEAN-009.md +0 -31
  126. package/tasks/pending/CLEAN-010.md +0 -30
  127. package/tasks/pending/CLEAN-011.md +0 -30
  128. package/tasks/pending/CLEAN-014.md +0 -32
  129. package/tasks/review/task-001.md +0 -78
@@ -0,0 +1,645 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Vibe Forge Dashboard Server
4
+ *
5
+ * HTTP + WebSocket server for the dashboard web UI.
6
+ * Serves static files, REST API, and real-time WebSocket updates.
7
+ *
8
+ * Usage:
9
+ * node bin/dashboard/server.js [--port PORT] [--host HOST]
10
+ * DASHBOARD_PORT=5555 node bin/dashboard/server.js
11
+ */
12
+
13
+ const http = require('http');
14
+ const crypto = require('crypto');
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const url = require('url');
18
+
19
+ // Configuration
20
+ const DEFAULT_PORT = 2800; // Forge temperature in °F 🔥
21
+ // SECURITY: Do not change to '0.0.0.0' without reviewing all unauthenticated
22
+ // endpoints (/api/token, /api/health). The loopback gate on /api/token assumes
23
+ // the server is only reachable from localhost (RT-20260405-001).
24
+ const DEFAULT_HOST = 'localhost';
25
+ const MAX_TTS_TEXT_LENGTH = 2000;
26
+
27
+ // Resolve paths relative to project root
28
+ const PROJECT_ROOT = path.resolve(__dirname, '../..');
29
+ const PUBLIC_DIR = path.join(__dirname, 'public');
30
+
31
+ // Session token for API authentication (RT-20260405-001 Chain 1 fix)
32
+ const SESSION_TOKEN = crypto.randomBytes(32).toString('hex');
33
+ const TOKEN_FILE = path.join(PROJECT_ROOT, '.forge', 'dashboard.token');
34
+
35
+ // Import API handlers
36
+ const tasksApi = require('./api/tasks');
37
+ const agentsApi = require('./api/agents');
38
+ const dispatchApi = require('./api/dispatch');
39
+
40
+ // TTS - lazy-loaded so server still starts if msedge-tts is absent
41
+ let MsEdgeTTS, TTS_OUTPUT_FORMAT;
42
+ try {
43
+ ({ MsEdgeTTS, OUTPUT_FORMAT: TTS_OUTPUT_FORMAT } = require('msedge-tts'));
44
+ } catch (_) {
45
+ console.warn('[TTS] msedge-tts not installed — /api/tts will return 503');
46
+ }
47
+
48
+ // Agent → Edge TTS voice mapping
49
+ const AGENT_VOICES = {
50
+ 'planning-hub': 'en-US-GuyNeural',
51
+ 'planning-hub': 'en-US-GuyNeural',
52
+ 'oracle': 'en-US-AriaNeural',
53
+ 'architect': 'en-GB-RyanNeural',
54
+ 'aegis': 'en-US-JennyNeural',
55
+ 'pixel': 'en-US-MichelleNeural',
56
+ 'ember': 'en-US-ChristopherNeural',
57
+ 'anvil': 'en-US-EricNeural',
58
+ 'furnace': 'en-US-RogerNeural',
59
+ 'crucible': 'en-US-MonicaNeural',
60
+ 'temper': 'en-GB-ThomasNeural',
61
+ 'scribe': 'en-AU-NatashaNeural',
62
+ 'herald': 'en-US-SteffanNeural',
63
+ 'loki': 'en-IE-ConnorNeural',
64
+ 'crucible-x': 'en-US-DavisNeural',
65
+ 'system': 'en-US-AriaNeural',
66
+ };
67
+
68
+ // =============================================================================
69
+ // WebSocket Setup (lazy-loaded)
70
+ // =============================================================================
71
+
72
+ let WebSocketServer = null;
73
+ let wss = null;
74
+
75
+ /**
76
+ * Initialize WebSocket server
77
+ * @param {http.Server} server - HTTP server to attach to
78
+ */
79
+ function initWebSocket(server) {
80
+ try {
81
+ const { WebSocketServer: WSServer } = require('ws');
82
+ WebSocketServer = WSServer;
83
+
84
+ wss = new WebSocketServer({ server, path: '/ws' });
85
+
86
+ wss.on('connection', (ws, req) => {
87
+ const clientIp = req.socket.remoteAddress;
88
+
89
+ // Authenticate WebSocket connections via token query param
90
+ const wsUrl = new url.URL(req.url, `http://${req.headers.host}`);
91
+ if (wsUrl.searchParams.get('token') !== SESSION_TOKEN) {
92
+ ws.close(4001, 'Unauthorized');
93
+ console.log(`[WS] Rejected unauthenticated connection from ${clientIp}`);
94
+ return;
95
+ }
96
+
97
+ console.log(`[WS] Client connected: ${clientIp}`);
98
+
99
+ // Send initial connection confirmation
100
+ ws.send(JSON.stringify({
101
+ type: 'connected',
102
+ timestamp: new Date().toISOString(),
103
+ message: 'Connected to Vibe Forge Dashboard'
104
+ }));
105
+
106
+ ws.on('message', (message) => {
107
+ try {
108
+ const data = JSON.parse(message);
109
+ handleWebSocketMessage(ws, data);
110
+ } catch (err) {
111
+ ws.send(JSON.stringify({
112
+ type: 'error',
113
+ message: 'Invalid JSON message'
114
+ }));
115
+ }
116
+ });
117
+
118
+ ws.on('close', () => {
119
+ console.log(`[WS] Client disconnected: ${clientIp}`);
120
+ });
121
+
122
+ ws.on('error', (err) => {
123
+ console.error(`[WS] Error: ${err.message}`);
124
+ });
125
+ });
126
+
127
+ console.log('[WS] WebSocket server initialized at /ws');
128
+ return true;
129
+ } catch (err) {
130
+ console.warn('[WS] WebSocket disabled - ws package not installed');
131
+ console.warn('[WS] Run: npm install ws --save-dev');
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Handle incoming WebSocket messages
138
+ * @param {WebSocket} ws - WebSocket connection
139
+ * @param {Object} data - Parsed message data
140
+ */
141
+ function handleWebSocketMessage(ws, data) {
142
+ switch (data.type) {
143
+ case 'ping':
144
+ ws.send(JSON.stringify({ type: 'pong', timestamp: new Date().toISOString() }));
145
+ break;
146
+ case 'subscribe':
147
+ // Future: subscribe to specific events
148
+ ws.send(JSON.stringify({
149
+ type: 'subscribed',
150
+ channel: data.channel || 'all'
151
+ }));
152
+ break;
153
+ default:
154
+ ws.send(JSON.stringify({
155
+ type: 'unknown',
156
+ message: `Unknown message type: ${data.type}`
157
+ }));
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Broadcast message to all connected WebSocket clients
163
+ * @param {Object} data - Data to broadcast
164
+ */
165
+ function broadcast(data) {
166
+ if (!wss) return;
167
+
168
+ const message = JSON.stringify(data);
169
+ wss.clients.forEach((client) => {
170
+ if (client.readyState === 1) { // WebSocket.OPEN
171
+ client.send(message);
172
+ }
173
+ });
174
+ }
175
+
176
+ // Export broadcast for use by API handlers
177
+ module.exports = { broadcast };
178
+
179
+ // =============================================================================
180
+ // Static File Server
181
+ // =============================================================================
182
+
183
+ const MIME_TYPES = {
184
+ '.html': 'text/html',
185
+ '.css': 'text/css',
186
+ '.js': 'application/javascript',
187
+ '.json': 'application/json',
188
+ '.png': 'image/png',
189
+ '.jpg': 'image/jpeg',
190
+ '.jpeg': 'image/jpeg',
191
+ '.gif': 'image/gif',
192
+ '.svg': 'image/svg+xml',
193
+ '.ico': 'image/x-icon',
194
+ '.woff': 'font/woff',
195
+ '.woff2': 'font/woff2',
196
+ '.ttf': 'font/ttf'
197
+ };
198
+
199
+ /**
200
+ * Serve static file from public directory
201
+ * @param {string} reqPath - Requested path
202
+ * @param {http.ServerResponse} res - Response object
203
+ */
204
+ function serveStatic(reqPath, res) {
205
+ // Security: prevent directory traversal
206
+ const safePath = path.normalize(reqPath).replace(/^(\.\.[\/\\])+/, '');
207
+ let filePath = path.join(PUBLIC_DIR, safePath);
208
+
209
+ // Default to index.html for root
210
+ if (safePath === '/' || safePath === '') {
211
+ filePath = path.join(PUBLIC_DIR, 'index.html');
212
+ }
213
+
214
+ // Verify file is within PUBLIC_DIR
215
+ if (!filePath.startsWith(PUBLIC_DIR)) {
216
+ sendError(res, 403, 'Forbidden');
217
+ return;
218
+ }
219
+
220
+ fs.stat(filePath, (err, stats) => {
221
+ if (err || !stats.isFile()) {
222
+ // Try index.html for SPA routing
223
+ if (!filePath.endsWith('.html') && !path.extname(filePath)) {
224
+ serveStatic('/index.html', res);
225
+ return;
226
+ }
227
+ sendError(res, 404, 'Not Found');
228
+ return;
229
+ }
230
+
231
+ const ext = path.extname(filePath).toLowerCase();
232
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
233
+
234
+ res.writeHead(200, { 'Content-Type': contentType });
235
+ fs.createReadStream(filePath).pipe(res);
236
+ });
237
+ }
238
+
239
+ // =============================================================================
240
+ // API Router
241
+ // =============================================================================
242
+
243
+ /**
244
+ * Parse JSON body from request
245
+ * @param {http.IncomingMessage} req - Request object
246
+ * @returns {Promise<Object>} Parsed JSON body
247
+ */
248
+ function parseBody(req) {
249
+ return new Promise((resolve, reject) => {
250
+ let body = '';
251
+ req.on('data', chunk => {
252
+ body += chunk;
253
+ // Limit body size to 1MB
254
+ if (body.length > 1024 * 1024) {
255
+ reject(new Error('Request body too large'));
256
+ }
257
+ });
258
+ req.on('end', () => {
259
+ if (!body) {
260
+ resolve({});
261
+ return;
262
+ }
263
+ try {
264
+ resolve(JSON.parse(body));
265
+ } catch (err) {
266
+ reject(new Error('Invalid JSON'));
267
+ }
268
+ });
269
+ req.on('error', reject);
270
+ });
271
+ }
272
+
273
+ /**
274
+ * Send JSON response
275
+ * @param {http.ServerResponse} res - Response object
276
+ * @param {number} status - HTTP status code
277
+ * @param {Object} data - Response data
278
+ */
279
+ function sendJson(res, status, data) {
280
+ res.writeHead(status, {
281
+ 'Content-Type': 'application/json'
282
+ });
283
+ res.end(JSON.stringify(data));
284
+ }
285
+
286
+ /**
287
+ * Send error response
288
+ * @param {http.ServerResponse} res - Response object
289
+ * @param {number} status - HTTP status code
290
+ * @param {string} message - Error message
291
+ */
292
+ function sendError(res, status, message) {
293
+ sendJson(res, status, { error: message });
294
+ }
295
+
296
+ /**
297
+ * Route API requests
298
+ * @param {http.IncomingMessage} req - Request object
299
+ * @param {http.ServerResponse} res - Response object
300
+ * @param {string} pathname - Request pathname
301
+ */
302
+ async function routeApi(req, res, pathname) {
303
+ const method = req.method.toUpperCase();
304
+
305
+ // Handle preflight (same-origin only, no CORS)
306
+ if (method === 'OPTIONS') {
307
+ res.writeHead(204);
308
+ res.end();
309
+ return;
310
+ }
311
+
312
+ // Token bootstrap endpoint - same-origin only (no CORS headers = browsers
313
+ // block cross-origin reads). Dashboard UI fetches this on load.
314
+ // RT-20260405-001: Gate to loopback so changing DEFAULT_HOST won't expose it.
315
+ if (pathname === '/api/token' && method === 'GET') {
316
+ const remote = req.socket.remoteAddress;
317
+ if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') {
318
+ sendError(res, 403, 'Forbidden');
319
+ return;
320
+ }
321
+ sendJson(res, 200, { token: SESSION_TOKEN });
322
+ return;
323
+ }
324
+
325
+ // Authenticate all other API requests (RT-20260405-001 Chain 1 fix)
326
+ // Health check is exempt (monitoring/readiness probes)
327
+ if (pathname !== '/api/health') {
328
+ const token = req.headers['x-forge-token'];
329
+ if (token !== SESSION_TOKEN) {
330
+ sendError(res, 401, 'Unauthorized');
331
+ return;
332
+ }
333
+ }
334
+
335
+ try {
336
+ // Parse request body for POST/PUT
337
+ let body = {};
338
+ if (method === 'POST' || method === 'PUT') {
339
+ body = await parseBody(req);
340
+ }
341
+
342
+ // Tasks API
343
+ if (pathname === '/api/tasks') {
344
+ if (method === 'GET') {
345
+ const result = await tasksApi.listTasks(PROJECT_ROOT);
346
+ sendJson(res, 200, result);
347
+ return;
348
+ }
349
+ if (method === 'POST') {
350
+ const result = await tasksApi.createTask(PROJECT_ROOT, body);
351
+ broadcast({ type: 'task-created', task: result });
352
+ sendJson(res, 201, result);
353
+ return;
354
+ }
355
+ }
356
+
357
+ // Single task by ID
358
+ const taskMatch = pathname.match(/^\/api\/tasks\/([a-zA-Z0-9_-]+)$/);
359
+ if (taskMatch) {
360
+ const taskId = taskMatch[1];
361
+ if (method === 'GET') {
362
+ const result = await tasksApi.getTask(PROJECT_ROOT, taskId);
363
+ if (!result) {
364
+ sendError(res, 404, `Task not found: ${taskId}`);
365
+ return;
366
+ }
367
+ sendJson(res, 200, result);
368
+ return;
369
+ }
370
+ }
371
+
372
+ // Agents API
373
+ if (pathname === '/api/agents') {
374
+ if (method === 'GET') {
375
+ const result = await agentsApi.listAgents(PROJECT_ROOT);
376
+ sendJson(res, 200, result);
377
+ return;
378
+ }
379
+ }
380
+
381
+ // Dispatch API
382
+ if (pathname === '/api/dispatch') {
383
+ if (method === 'POST') {
384
+ try {
385
+ const result = await dispatchApi.dispatch(PROJECT_ROOT, body, broadcast);
386
+ sendJson(res, 201, result);
387
+ } catch (err) {
388
+ // Validation errors → 400; runtime errors bubble to outer catch → 500
389
+ // RT-20260405-001: Whitelist known validation messages to prevent
390
+ // leaking internal error details if dispatch.js adds unguarded throws.
391
+ if (err.statusCode === 400 && err.name === 'DispatchValidationError') {
392
+ sendError(res, 400, err.message);
393
+ } else if (err.statusCode === 400) {
394
+ sendError(res, 400, 'Invalid dispatch request');
395
+ } else {
396
+ throw err;
397
+ }
398
+ }
399
+ return;
400
+ }
401
+ }
402
+
403
+ // TTS API — synthesize speech via msedge-tts and stream MP3
404
+ if (pathname === '/api/tts') {
405
+ if (method === 'GET') {
406
+ if (!MsEdgeTTS) {
407
+ res.writeHead(503, { 'Content-Type': 'application/json' });
408
+ res.end(JSON.stringify({ error: 'TTS not available — install msedge-tts' }));
409
+ return;
410
+ }
411
+ const parsedQuery = new url.URL(req.url, `http://${req.headers.host}`).searchParams;
412
+ const text = parsedQuery.get('text') || '';
413
+ const agent = (parsedQuery.get('agent') || 'system').toLowerCase();
414
+ if (!text.trim()) {
415
+ res.writeHead(400, { 'Content-Type': 'application/json' });
416
+ res.end(JSON.stringify({ error: 'text is required' }));
417
+ return;
418
+ }
419
+ if (text.length > MAX_TTS_TEXT_LENGTH) {
420
+ res.writeHead(400, { 'Content-Type': 'application/json' });
421
+ res.end(JSON.stringify({ error: `text too long (max ${MAX_TTS_TEXT_LENGTH} chars)` }));
422
+ return;
423
+ }
424
+ const voiceName = AGENT_VOICES[agent] || AGENT_VOICES['system'];
425
+ try {
426
+ const tts = new MsEdgeTTS();
427
+ await tts.setMetadata(voiceName, TTS_OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3);
428
+ const { audioStream } = tts.toStream(text);
429
+ res.writeHead(200, {
430
+ 'Content-Type': 'audio/mpeg',
431
+ 'Cache-Control': 'no-store'
432
+ });
433
+ audioStream.pipe(res);
434
+ } catch (err) {
435
+ console.error('[TTS] Synthesis error:', err.message);
436
+ if (!res.headersSent) {
437
+ res.writeHead(500, { 'Content-Type': 'application/json' });
438
+ res.end(JSON.stringify({ error: 'TTS synthesis failed' }));
439
+ }
440
+ }
441
+ return;
442
+ }
443
+ }
444
+
445
+ // Issues API — placeholder, returns empty list (detection not yet implemented)
446
+ if (pathname === '/api/issues') {
447
+ if (method === 'GET') {
448
+ sendJson(res, 200, { issues: [], summary: { total: 0 } });
449
+ return;
450
+ }
451
+ }
452
+
453
+ // Config endpoint — exposes safe, UI-relevant config fields
454
+ if (pathname === '/api/config') {
455
+ if (method === 'GET') {
456
+ const configPath = path.join(PROJECT_ROOT, '.forge', 'config.json');
457
+ let cfg = {};
458
+ try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (_) {}
459
+ sendJson(res, 200, {
460
+ dashboard_enabled: cfg.dashboard_enabled ?? false,
461
+ dashboard_voice: cfg.dashboard_voice ?? false,
462
+ dashboard_port: cfg.dashboard_port ?? 2800,
463
+ });
464
+ return;
465
+ }
466
+ }
467
+
468
+ // Activity feed - chronological stream of agent events (T2-G2)
469
+ if (pathname === '/api/activity') {
470
+ if (method === 'GET') {
471
+ const dbPath = path.join(PROJECT_ROOT, '.forge', 'forge.db');
472
+ try {
473
+ const { execSync } = require('child_process');
474
+ const limit = Math.min(parseInt(new url.URL(req.url, `http://${req.headers.host}`).searchParams.get('limit')) || 50, 200);
475
+ const raw = execSync(
476
+ `sqlite3 "${dbPath}" "SELECT id, agent, status, task, recorded_at FROM status_history ORDER BY recorded_at DESC LIMIT ${limit};"`,
477
+ { encoding: 'utf8', timeout: 5000 }
478
+ ).trim();
479
+ const events = raw ? raw.split('\n').map(row => {
480
+ const [id, agent, status, task, recorded_at] = row.split('|');
481
+ return { id: +id, agent, status, task: task || null, recorded_at };
482
+ }) : [];
483
+ sendJson(res, 200, { events });
484
+ } catch (_) {
485
+ sendJson(res, 200, { events: [] });
486
+ }
487
+ return;
488
+ }
489
+ }
490
+
491
+ // Health check
492
+ if (pathname === '/api/health') {
493
+ sendJson(res, 200, {
494
+ status: 'ok',
495
+ timestamp: new Date().toISOString(),
496
+ version: require(path.join(PROJECT_ROOT, 'package.json')).version
497
+ });
498
+ return;
499
+ }
500
+
501
+ // Not found (RT-20260405-001: static message, don't reflect pathname)
502
+ sendError(res, 404, 'Not found');
503
+
504
+ } catch (err) {
505
+ console.error(`[API] Error: ${err.message}`);
506
+ sendError(res, 500, 'Internal server error');
507
+ }
508
+ }
509
+
510
+ // =============================================================================
511
+ // HTTP Server
512
+ // =============================================================================
513
+
514
+ /**
515
+ * Main request handler
516
+ * @param {http.IncomingMessage} req - Request object
517
+ * @param {http.ServerResponse} res - Response object
518
+ */
519
+ function requestHandler(req, res) {
520
+ const parsedUrl = url.parse(req.url, true);
521
+ const pathname = parsedUrl.pathname;
522
+
523
+ // Log request
524
+ console.log(`[HTTP] ${req.method} ${pathname}`);
525
+
526
+ // Route to API or static files
527
+ if (pathname.startsWith('/api/')) {
528
+ routeApi(req, res, pathname);
529
+ } else if (pathname === '/ws') {
530
+ // WebSocket handled by ws library, ignore here
531
+ res.writeHead(426, { 'Content-Type': 'text/plain' });
532
+ res.end('Upgrade Required');
533
+ } else {
534
+ serveStatic(pathname, res);
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Start the dashboard server
540
+ * @param {Object} options - Server options
541
+ * @param {number} options.port - Port to listen on
542
+ * @param {string} options.host - Host to bind to
543
+ */
544
+ function startServer(options = {}) {
545
+ const port = options.port || process.env.DASHBOARD_PORT || DEFAULT_PORT;
546
+ const host = options.host || process.env.DASHBOARD_HOST || DEFAULT_HOST;
547
+
548
+ const server = http.createServer(requestHandler);
549
+
550
+ // Write session token to file for dashboard UI to read
551
+ const forgeDir = path.join(PROJECT_ROOT, '.forge');
552
+ if (!fs.existsSync(forgeDir)) fs.mkdirSync(forgeDir, { recursive: true });
553
+ fs.writeFileSync(TOKEN_FILE, SESSION_TOKEN, { mode: 0o600 });
554
+
555
+ // Initialize WebSocket
556
+ initWebSocket(server);
557
+
558
+ server.listen(port, host, () => {
559
+ console.log('');
560
+ console.log('='.repeat(50));
561
+ console.log(' 🔥 VIBE FORGE DASHBOARD');
562
+ console.log('='.repeat(50));
563
+ console.log(` URL: http://${host}:${port}`);
564
+ console.log(` API: http://${host}:${port}/api/`);
565
+ console.log(` WebSocket: ws://${host}:${port}/ws`);
566
+ console.log('');
567
+ console.log(` Forge temperature: ${port}°F`);
568
+ console.log('='.repeat(50));
569
+ console.log('');
570
+ });
571
+
572
+ server.on('error', (err) => {
573
+ if (err.code === 'EADDRINUSE') {
574
+ console.error(`[ERROR] Port ${port} is already in use`);
575
+ console.error(`[ERROR] Try: DASHBOARD_PORT=${parseInt(port) + 1} node bin/dashboard/server.js`);
576
+ process.exit(1);
577
+ }
578
+ throw err;
579
+ });
580
+
581
+ // Graceful shutdown - clean up token file
582
+ function cleanup() {
583
+ try { fs.unlinkSync(TOKEN_FILE); } catch (_) {}
584
+ }
585
+
586
+ process.on('SIGTERM', () => {
587
+ console.log('[Server] Shutting down...');
588
+ cleanup();
589
+ server.close(() => {
590
+ console.log('[Server] Closed');
591
+ process.exit(0);
592
+ });
593
+ });
594
+
595
+ process.on('SIGINT', () => {
596
+ console.log('\n[Server] Interrupted, shutting down...');
597
+ cleanup();
598
+ server.close(() => {
599
+ console.log('[Server] Closed');
600
+ process.exit(0);
601
+ });
602
+ });
603
+
604
+ process.on('exit', cleanup);
605
+
606
+ return server;
607
+ }
608
+
609
+ // =============================================================================
610
+ // CLI
611
+ // =============================================================================
612
+
613
+ if (require.main === module) {
614
+ // Parse CLI arguments
615
+ const args = process.argv.slice(2);
616
+ const options = {};
617
+
618
+ for (let i = 0; i < args.length; i++) {
619
+ if (args[i] === '--port' && args[i + 1]) {
620
+ options.port = parseInt(args[i + 1], 10);
621
+ i++;
622
+ } else if (args[i] === '--host' && args[i + 1]) {
623
+ options.host = args[i + 1];
624
+ i++;
625
+ } else if (args[i] === '--help' || args[i] === '-h') {
626
+ console.log('Vibe Forge Dashboard Server');
627
+ console.log('');
628
+ console.log('Usage: node server.js [options]');
629
+ console.log('');
630
+ console.log('Options:');
631
+ console.log(' --port PORT Port to listen on (default: 2800)');
632
+ console.log(' --host HOST Host to bind to (default: localhost)');
633
+ console.log(' --help, -h Show this help message');
634
+ console.log('');
635
+ console.log('Environment:');
636
+ console.log(' DASHBOARD_PORT Port to listen on');
637
+ console.log(' DASHBOARD_HOST Host to bind to');
638
+ process.exit(0);
639
+ }
640
+ }
641
+
642
+ startServer(options);
643
+ }
644
+
645
+ module.exports = { startServer, broadcast };