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.
- package/.claude/commands/clear-attention.md +63 -63
- package/.claude/commands/compact-context.md +52 -0
- package/.claude/commands/configure-vcs.md +5 -5
- package/.claude/commands/forge.md +50 -3
- package/.claude/commands/need-help.md +77 -77
- package/.claude/commands/update-status.md +64 -64
- package/.claude/commands/worker-loop.md +106 -106
- package/.claude/hooks/worker-loop.js +37 -4
- package/.claude/scripts/setup-worker-loop.sh +45 -45
- package/.claude/settings.json +89 -0
- package/LICENSE +21 -21
- package/README.md +211 -232
- package/agents/aegis/personality.md +35 -1
- package/agents/anvil/personality.md +39 -1
- package/agents/architect/personality.md +26 -0
- package/agents/crucible/personality.md +54 -1
- package/agents/crucible-x/personality.md +210 -0
- package/agents/ember/personality.md +29 -1
- package/agents/flux/personality.md +248 -0
- package/agents/furnace/personality.md +52 -1
- package/agents/herald/personality.md +3 -1
- package/agents/loki/personality.md +108 -0
- package/agents/oracle/personality.md +284 -0
- package/agents/pixel/personality.md +140 -0
- package/agents/planning-hub/personality.md +222 -0
- package/agents/scribe/personality.md +3 -1
- package/agents/slag/personality.md +268 -0
- package/agents/{sentinel → temper}/personality.md +85 -9
- package/bin/cli.js +77 -30
- package/bin/dashboard/api/agents.js +333 -0
- package/bin/dashboard/api/dispatch.js +507 -0
- package/bin/dashboard/api/tasks.js +416 -0
- package/bin/dashboard/public/assets/index-BpHfsx1r.js +2 -0
- package/bin/dashboard/public/assets/index-QODv4Zn9.css +1 -0
- package/bin/dashboard/public/index.html +14 -0
- package/bin/dashboard/server.js +645 -0
- package/bin/forge-daemon.sh +176 -550
- package/bin/forge-setup.sh +28 -11
- package/bin/forge-spawn.sh +5 -5
- package/bin/forge.cmd +83 -83
- package/bin/forge.sh +210 -31
- package/config/agent-manifest.yaml +237 -243
- package/config/agents.json +207 -132
- package/config/task-types.yaml +111 -106
- package/context/agent-overrides/README.md +41 -0
- package/context/architecture.md +42 -0
- package/context/modern-conventions.md +129 -129
- package/docs/agents.md +473 -409
- package/docs/architecture.md +194 -162
- package/docs/commands.md +451 -388
- package/docs/security.md +195 -144
- package/package.json +38 -11
- package/src/lib/check-aliases.js +50 -0
- package/{bin → src}/lib/colors.sh +2 -1
- package/src/lib/config.sh +347 -0
- package/{bin → src}/lib/constants.sh +48 -13
- package/src/lib/daemon/budgets.sh +107 -0
- package/src/lib/daemon/dependencies.sh +146 -0
- package/src/lib/daemon/display.sh +128 -0
- package/src/lib/daemon/notifications.sh +273 -0
- package/src/lib/daemon/routing.sh +93 -0
- package/src/lib/daemon/state.sh +163 -0
- package/src/lib/daemon/sync.sh +103 -0
- package/{bin → src}/lib/database.sh +52 -0
- package/src/lib/frontmatter.js +106 -0
- package/src/lib/heimdall-setup.js +113 -0
- package/src/lib/heimdall.js +265 -0
- package/src/lib/index.sh +25 -0
- package/{bin → src}/lib/json.sh +7 -1
- package/{bin → src}/lib/terminal.js +7 -1
- package/.claude/settings.local.json +0 -33
- package/agents/forge-master/capabilities.md +0 -144
- package/agents/forge-master/context-template.md +0 -128
- package/agents/forge-master/personality.md +0 -138
- package/bin/lib/config.sh +0 -313
- package/config/task-template.md +0 -87
- package/context/forge-state.yaml +0 -19
- package/docs/TODO.md +0 -150
- package/docs/getting-started.md +0 -243
- package/docs/npm-publishing.md +0 -95
- package/docs/workflows/README.md +0 -32
- package/docs/workflows/azure-devops.md +0 -108
- package/docs/workflows/bitbucket.md +0 -104
- package/docs/workflows/git-only.md +0 -130
- package/docs/workflows/gitea.md +0 -168
- package/docs/workflows/github.md +0 -103
- package/docs/workflows/gitlab.md +0 -105
- package/docs/workflows.md +0 -454
- package/tasks/completed/ARCH-001-duplicate-agent-config.md +0 -121
- package/tasks/completed/ARCH-002-mixed-bash-node-implementation.md +0 -88
- package/tasks/completed/ARCH-003-worker-loop-hook-duplication.md +0 -77
- package/tasks/completed/ARCH-009-test-organization.md +0 -78
- package/tasks/completed/ARCH-011-jq-vs-nodejs-json.md +0 -94
- package/tasks/completed/ARCH-012-tmp-files-in-root.md +0 -71
- package/tasks/completed/ARCH-013-exit-code-constants.md +0 -65
- package/tasks/completed/ARCH-014-sed-incompatibility.md +0 -96
- package/tasks/completed/ARCH-015-docs-todo-tracking.md +0 -83
- package/tasks/completed/CLEAN-001.md +0 -38
- package/tasks/completed/CLEAN-003.md +0 -47
- package/tasks/completed/CLEAN-004.md +0 -56
- package/tasks/completed/CLEAN-005.md +0 -75
- package/tasks/completed/CLEAN-006.md +0 -47
- package/tasks/completed/CLEAN-007.md +0 -34
- package/tasks/completed/CLEAN-008.md +0 -49
- package/tasks/completed/CLEAN-012.md +0 -58
- package/tasks/completed/CLEAN-013.md +0 -45
- package/tasks/completed/SEC-001-sql-injection-fix.md +0 -58
- package/tasks/completed/SEC-002-notification-injection-fix.md +0 -45
- package/tasks/completed/SEC-003-eval-injection-fix.md +0 -54
- package/tasks/completed/SEC-004-pid-race-condition-fix.md +0 -49
- package/tasks/completed/SEC-005-worker-loop-path-fix.md +0 -51
- package/tasks/completed/SEC-006-eval-agent-names.md +0 -55
- package/tasks/completed/SEC-007-spawn-escaping.md +0 -67
- package/tasks/pending/ARCH-004-git-bash-detection-duplication.md +0 -72
- package/tasks/pending/ARCH-005-missing-src-directory.md +0 -95
- package/tasks/pending/ARCH-006-task-template-location.md +0 -64
- package/tasks/pending/ARCH-007-daemon-monolith.md +0 -91
- package/tasks/pending/ARCH-008-forge-master-vs-hub.md +0 -81
- package/tasks/pending/ARCH-010-missing-index-files.md +0 -84
- package/tasks/pending/CLEAN-002.md +0 -29
- package/tasks/pending/CLEAN-009.md +0 -31
- package/tasks/pending/CLEAN-010.md +0 -30
- package/tasks/pending/CLEAN-011.md +0 -30
- package/tasks/pending/CLEAN-014.md +0 -32
- package/tasks/review/task-001.md +0 -78
- /package/{bin → src}/lib/agents.sh +0 -0
- /package/{bin → src}/lib/util.sh +0 -0
- /package/{bin → src}/lib/vcs.js +0 -0
- /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 };
|