vibe-forge 0.4.0 → 0.8.2

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 +5 -5
  4. package/.claude/commands/forge.md +50 -3
  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 +37 -4
  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 +211 -232
  13. package/agents/aegis/personality.md +35 -1
  14. package/agents/anvil/personality.md +39 -1
  15. package/agents/architect/personality.md +26 -0
  16. package/agents/crucible/personality.md +54 -1
  17. package/agents/crucible-x/personality.md +210 -0
  18. package/agents/ember/personality.md +29 -1
  19. package/agents/flux/personality.md +248 -0
  20. package/agents/furnace/personality.md +52 -1
  21. package/agents/herald/personality.md +3 -1
  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 +222 -0
  26. package/agents/scribe/personality.md +3 -1
  27. package/agents/slag/personality.md +268 -0
  28. package/agents/{sentinel → temper}/personality.md +85 -9
  29. package/bin/cli.js +77 -30
  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 +176 -550
  38. package/bin/forge-setup.sh +28 -11
  39. package/bin/forge-spawn.sh +5 -5
  40. package/bin/forge.cmd +83 -83
  41. package/bin/forge.sh +210 -31
  42. package/config/agent-manifest.yaml +237 -243
  43. package/config/agents.json +207 -132
  44. package/config/task-types.yaml +111 -106
  45. package/context/agent-overrides/README.md +41 -0
  46. package/context/architecture.md +42 -0
  47. package/context/modern-conventions.md +129 -129
  48. package/docs/agents.md +473 -409
  49. package/docs/architecture.md +194 -162
  50. package/docs/commands.md +451 -388
  51. package/docs/security.md +195 -144
  52. package/package.json +38 -11
  53. package/src/lib/check-aliases.js +50 -0
  54. package/{bin → src}/lib/colors.sh +2 -1
  55. package/src/lib/config.sh +347 -0
  56. package/{bin → src}/lib/constants.sh +48 -13
  57. package/src/lib/daemon/budgets.sh +107 -0
  58. package/src/lib/daemon/dependencies.sh +146 -0
  59. package/src/lib/daemon/display.sh +128 -0
  60. package/src/lib/daemon/notifications.sh +273 -0
  61. package/src/lib/daemon/routing.sh +93 -0
  62. package/src/lib/daemon/state.sh +163 -0
  63. package/src/lib/daemon/sync.sh +103 -0
  64. package/{bin → src}/lib/database.sh +52 -0
  65. package/src/lib/frontmatter.js +106 -0
  66. package/src/lib/heimdall-setup.js +113 -0
  67. package/src/lib/heimdall.js +265 -0
  68. package/src/lib/index.sh +25 -0
  69. package/{bin → src}/lib/json.sh +7 -1
  70. package/{bin → src}/lib/terminal.js +7 -1
  71. package/.claude/settings.local.json +0 -33
  72. package/agents/forge-master/capabilities.md +0 -144
  73. package/agents/forge-master/context-template.md +0 -128
  74. package/agents/forge-master/personality.md +0 -138
  75. package/bin/lib/config.sh +0 -313
  76. package/config/task-template.md +0 -87
  77. package/context/forge-state.yaml +0 -19
  78. package/docs/TODO.md +0 -150
  79. package/docs/getting-started.md +0 -243
  80. package/docs/npm-publishing.md +0 -95
  81. package/docs/workflows/README.md +0 -32
  82. package/docs/workflows/azure-devops.md +0 -108
  83. package/docs/workflows/bitbucket.md +0 -104
  84. package/docs/workflows/git-only.md +0 -130
  85. package/docs/workflows/gitea.md +0 -168
  86. package/docs/workflows/github.md +0 -103
  87. package/docs/workflows/gitlab.md +0 -105
  88. package/docs/workflows.md +0 -454
  89. package/tasks/completed/ARCH-001-duplicate-agent-config.md +0 -121
  90. package/tasks/completed/ARCH-002-mixed-bash-node-implementation.md +0 -88
  91. package/tasks/completed/ARCH-003-worker-loop-hook-duplication.md +0 -77
  92. package/tasks/completed/ARCH-009-test-organization.md +0 -78
  93. package/tasks/completed/ARCH-011-jq-vs-nodejs-json.md +0 -94
  94. package/tasks/completed/ARCH-012-tmp-files-in-root.md +0 -71
  95. package/tasks/completed/ARCH-013-exit-code-constants.md +0 -65
  96. package/tasks/completed/ARCH-014-sed-incompatibility.md +0 -96
  97. package/tasks/completed/ARCH-015-docs-todo-tracking.md +0 -83
  98. package/tasks/completed/CLEAN-001.md +0 -38
  99. package/tasks/completed/CLEAN-003.md +0 -47
  100. package/tasks/completed/CLEAN-004.md +0 -56
  101. package/tasks/completed/CLEAN-005.md +0 -75
  102. package/tasks/completed/CLEAN-006.md +0 -47
  103. package/tasks/completed/CLEAN-007.md +0 -34
  104. package/tasks/completed/CLEAN-008.md +0 -49
  105. package/tasks/completed/CLEAN-012.md +0 -58
  106. package/tasks/completed/CLEAN-013.md +0 -45
  107. package/tasks/completed/SEC-001-sql-injection-fix.md +0 -58
  108. package/tasks/completed/SEC-002-notification-injection-fix.md +0 -45
  109. package/tasks/completed/SEC-003-eval-injection-fix.md +0 -54
  110. package/tasks/completed/SEC-004-pid-race-condition-fix.md +0 -49
  111. package/tasks/completed/SEC-005-worker-loop-path-fix.md +0 -51
  112. package/tasks/completed/SEC-006-eval-agent-names.md +0 -55
  113. package/tasks/completed/SEC-007-spawn-escaping.md +0 -67
  114. package/tasks/pending/ARCH-004-git-bash-detection-duplication.md +0 -72
  115. package/tasks/pending/ARCH-005-missing-src-directory.md +0 -95
  116. package/tasks/pending/ARCH-006-task-template-location.md +0 -64
  117. package/tasks/pending/ARCH-007-daemon-monolith.md +0 -91
  118. package/tasks/pending/ARCH-008-forge-master-vs-hub.md +0 -81
  119. package/tasks/pending/ARCH-010-missing-index-files.md +0 -84
  120. package/tasks/pending/CLEAN-002.md +0 -29
  121. package/tasks/pending/CLEAN-009.md +0 -31
  122. package/tasks/pending/CLEAN-010.md +0 -30
  123. package/tasks/pending/CLEAN-011.md +0 -30
  124. package/tasks/pending/CLEAN-014.md +0 -32
  125. package/tasks/review/task-001.md +0 -78
  126. /package/{bin → src}/lib/agents.sh +0 -0
  127. /package/{bin → src}/lib/util.sh +0 -0
  128. /package/{bin → src}/lib/vcs.js +0 -0
  129. /package/{context → templates}/project-context-template.md +0 -0
@@ -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 };