@cluesmith/codev 2.0.0-rc.47 → 2.0.0-rc.49

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 (34) hide show
  1. package/bin/af.js +2 -2
  2. package/bin/consult.js +1 -1
  3. package/dist/agent-farm/commands/start.d.ts +3 -0
  4. package/dist/agent-farm/commands/start.d.ts.map +1 -1
  5. package/dist/agent-farm/commands/start.js +65 -0
  6. package/dist/agent-farm/commands/start.js.map +1 -1
  7. package/dist/agent-farm/commands/status.d.ts +2 -0
  8. package/dist/agent-farm/commands/status.d.ts.map +1 -1
  9. package/dist/agent-farm/commands/status.js +56 -1
  10. package/dist/agent-farm/commands/status.js.map +1 -1
  11. package/dist/agent-farm/commands/stop.d.ts +6 -0
  12. package/dist/agent-farm/commands/stop.d.ts.map +1 -1
  13. package/dist/agent-farm/commands/stop.js +36 -1
  14. package/dist/agent-farm/commands/stop.js.map +1 -1
  15. package/dist/agent-farm/db/index.d.ts.map +1 -1
  16. package/dist/agent-farm/db/index.js +17 -0
  17. package/dist/agent-farm/db/index.js.map +1 -1
  18. package/dist/agent-farm/db/schema.d.ts +1 -1
  19. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  20. package/dist/agent-farm/db/schema.js +2 -0
  21. package/dist/agent-farm/db/schema.js.map +1 -1
  22. package/dist/agent-farm/lib/tower-client.d.ts +157 -0
  23. package/dist/agent-farm/lib/tower-client.d.ts.map +1 -0
  24. package/dist/agent-farm/lib/tower-client.js +223 -0
  25. package/dist/agent-farm/lib/tower-client.js.map +1 -0
  26. package/dist/agent-farm/servers/tower-server.js +836 -224
  27. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  28. package/dist/commands/adopt.js +1 -1
  29. package/package.json +1 -1
  30. package/templates/tower.html +2 -2
  31. package/dist/agent-farm/servers/dashboard-server.d.ts +0 -7
  32. package/dist/agent-farm/servers/dashboard-server.d.ts.map +0 -1
  33. package/dist/agent-farm/servers/dashboard-server.js +0 -2181
  34. package/dist/agent-farm/servers/dashboard-server.js.map +0 -1
@@ -1,2181 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Dashboard server for Agent Farm.
4
- * Serves the split-pane dashboard UI and provides state/tab management APIs.
5
- */
6
- import http from 'node:http';
7
- import fs from 'node:fs';
8
- import path from 'node:path';
9
- import net from 'node:net';
10
- import httpProxy from 'http-proxy';
11
- import { spawn, execSync, exec } from 'node:child_process';
12
- import { promisify } from 'node:util';
13
- import { randomUUID, timingSafeEqual } from 'node:crypto';
14
- import { fileURLToPath } from 'node:url';
15
- const execAsync = promisify(exec);
16
- import { Command } from 'commander';
17
- import { getPortForTerminal } from '../utils/terminal-ports.js';
18
- import { escapeHtml, parseJsonBody, isRequestAllowed as isRequestAllowedBase, } from '../utils/server-utils.js';
19
- import { loadState, getAnnotations, addAnnotation, removeAnnotation, getUtils, addUtil, removeUtil, updateUtil, getBuilder, getBuilders, removeBuilder, upsertBuilder, clearState, getArchitect, setArchitect, } from '../state.js';
20
- import { TerminalManager } from '../../terminal/pty-manager.js';
21
- const __filename = fileURLToPath(import.meta.url);
22
- const __dirname = path.dirname(__filename);
23
- // Default dashboard port
24
- const DEFAULT_DASHBOARD_PORT = 4200;
25
- // Parse arguments with Commander for proper --help and validation
26
- const program = new Command()
27
- .name('dashboard-server')
28
- .description('Dashboard server for Agent Farm')
29
- .argument('[port]', 'Port to listen on', String(DEFAULT_DASHBOARD_PORT))
30
- .argument('[bindHost]', 'Host to bind to (default: localhost, use 0.0.0.0 for remote)')
31
- .option('-p, --port <port>', 'Port to listen on (overrides positional argument)')
32
- .option('-b, --bind <host>', 'Host to bind to (overrides positional argument)')
33
- .parse(process.argv);
34
- const opts = program.opts();
35
- const args = program.args;
36
- // Support both positional arg and --port flag (flag takes precedence)
37
- const portArg = opts.port || args[0] || String(DEFAULT_DASHBOARD_PORT);
38
- const port = parseInt(portArg, 10);
39
- // Bind host: flag > positional arg > default (undefined = localhost)
40
- const bindHost = opts.bind || args[1] || undefined;
41
- if (isNaN(port) || port < 1 || port > 65535) {
42
- console.error(`Error: Invalid port "${portArg}". Must be a number between 1 and 65535.`);
43
- process.exit(1);
44
- }
45
- // Configuration - ports are relative to the dashboard port
46
- // This ensures multi-project support (e.g., dashboard on 4300 uses 4350 for annotations)
47
- const CONFIG = {
48
- dashboardPort: port,
49
- architectPort: port + 1,
50
- builderPortStart: port + 10,
51
- utilPortStart: port + 30,
52
- openPortStart: port + 50,
53
- maxTabs: 20, // DoS protection: max concurrent tabs
54
- };
55
- // Find project root by looking for .agent-farm directory
56
- function findProjectRoot() {
57
- let dir = process.cwd();
58
- while (dir !== '/') {
59
- if (fs.existsSync(path.join(dir, '.agent-farm'))) {
60
- return dir;
61
- }
62
- if (fs.existsSync(path.join(dir, 'codev'))) {
63
- return dir;
64
- }
65
- dir = path.dirname(dir);
66
- }
67
- return process.cwd();
68
- }
69
- // Get project name from root path, with truncation for long names
70
- function getProjectName(projectRoot) {
71
- const baseName = path.basename(projectRoot);
72
- const maxLength = 30;
73
- if (baseName.length <= maxLength) {
74
- return baseName;
75
- }
76
- // Truncate with ellipsis for very long names
77
- return '...' + baseName.slice(-(maxLength - 3));
78
- }
79
- function findTemplatePath(filename, required = false) {
80
- // Templates are at package root: packages/codev/templates/
81
- // From compiled: dist/agent-farm/servers/ -> ../../../templates/
82
- // From source: src/agent-farm/servers/ -> ../../../templates/
83
- const pkgPath = path.resolve(__dirname, '../../../templates/', filename);
84
- if (fs.existsSync(pkgPath))
85
- return pkgPath;
86
- if (required) {
87
- throw new Error(`Template not found: ${filename}`);
88
- }
89
- return null;
90
- }
91
- const projectRoot = findProjectRoot();
92
- // Use modular dashboard template (Spec 0060)
93
- const templatePath = findTemplatePath('dashboard/index.html', true);
94
- // Terminal backend is always node-pty (Spec 0085)
95
- const terminalBackend = 'node-pty';
96
- // Load dashboard frontend preference from config (Spec 0085)
97
- function loadDashboardFrontend() {
98
- const configPath = path.resolve(projectRoot, 'af-config.json');
99
- if (fs.existsSync(configPath)) {
100
- try {
101
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
102
- return config?.dashboard?.frontend ?? 'react';
103
- }
104
- catch { /* ignore */ }
105
- }
106
- return 'react';
107
- }
108
- const dashboardFrontend = loadDashboardFrontend();
109
- // React dashboard dist path (built by Vite)
110
- const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
111
- const useReactDashboard = dashboardFrontend === 'react' && fs.existsSync(reactDashboardPath);
112
- if (useReactDashboard) {
113
- console.log('Dashboard frontend: React');
114
- }
115
- else if (dashboardFrontend === 'react') {
116
- console.log('Dashboard frontend: React (dist not found, falling back to legacy)');
117
- }
118
- else {
119
- console.log('Dashboard frontend: legacy');
120
- }
121
- const terminalManager = new TerminalManager({ projectRoot });
122
- console.log('Terminal backend: node-pty');
123
- // Clear stale terminalIds on startup — TerminalManager starts empty, so any
124
- // persisted terminalId from a previous run is no longer valid.
125
- {
126
- const arch = getArchitect();
127
- if (arch?.terminalId) {
128
- setArchitect({ ...arch, terminalId: undefined });
129
- }
130
- for (const builder of getBuilders()) {
131
- if (builder.terminalId) {
132
- upsertBuilder({ ...builder, terminalId: undefined });
133
- }
134
- }
135
- for (const util of getUtils()) {
136
- if (util.terminalId) {
137
- updateUtil(util.id, { terminalId: undefined });
138
- }
139
- }
140
- }
141
- // Auto-create architect PTY session if architect exists with a tmux session
142
- async function initArchitectTerminal() {
143
- const architect = getArchitect();
144
- if (!architect || !architect.tmuxSession || architect.terminalId)
145
- return;
146
- try {
147
- // Verify the tmux session actually exists before trying to attach.
148
- // If it doesn't exist, tmux attach exits immediately, leaving a dead terminalId.
149
- const { spawnSync } = await import('node:child_process');
150
- const probe = spawnSync('tmux', ['has-session', '-t', architect.tmuxSession], { stdio: 'ignore' });
151
- if (probe.status !== 0) {
152
- console.log(`initArchitectTerminal: tmux session '${architect.tmuxSession}' does not exist yet`);
153
- return;
154
- }
155
- // Use tmux directly (not via bash -c) to avoid DA response chaff.
156
- // bash -c creates a brief window where readline echoes DA responses as text.
157
- const info = await terminalManager.createSession({
158
- command: 'tmux',
159
- args: ['attach-session', '-t', architect.tmuxSession],
160
- cwd: projectRoot,
161
- cols: 200,
162
- rows: 50,
163
- label: 'architect',
164
- });
165
- // Wait to detect immediate exit (e.g., tmux session disappeared between check and attach)
166
- await new Promise((resolve) => setTimeout(resolve, 500));
167
- const session = terminalManager.getSession(info.id);
168
- if (!session || session.info.exitCode !== undefined) {
169
- console.error(`initArchitectTerminal: PTY exited immediately (exit=${session?.info.exitCode})`);
170
- terminalManager.killSession(info.id);
171
- return;
172
- }
173
- setArchitect({ ...architect, terminalId: info.id });
174
- console.log(`Architect terminal session created: ${info.id}`);
175
- // Listen for exit and auto-restart
176
- session.on('exit', (exitCode) => {
177
- console.log(`Architect terminal exited (code=${exitCode}), will attempt restart...`);
178
- // Clear the terminalId so we can recreate
179
- const arch = getArchitect();
180
- if (arch) {
181
- setArchitect({ ...arch, terminalId: undefined });
182
- }
183
- // Schedule restart after a brief delay
184
- setTimeout(() => {
185
- console.log('Attempting to restart architect terminal...');
186
- initArchitectTerminal().catch((err) => {
187
- console.error('Failed to restart architect terminal:', err.message);
188
- });
189
- }, 2000);
190
- });
191
- }
192
- catch (err) {
193
- console.error('Failed to create architect terminal session:', err.message);
194
- }
195
- }
196
- // Poll for architect state and create PTY session once available
197
- // start.ts writes architect to DB before spawning this server, but there can be a small delay
198
- (async function waitForArchitectAndInit() {
199
- for (let attempt = 0; attempt < 30; attempt++) {
200
- await new Promise((resolve) => setTimeout(resolve, 500));
201
- try {
202
- const arch = getArchitect();
203
- if (!arch)
204
- continue;
205
- if (arch.terminalId)
206
- return; // Already has terminal
207
- if (!arch.tmuxSession)
208
- continue; // No tmux session yet
209
- console.log(`initArchitectTerminal: attempt ${attempt + 1}, tmux=${arch.tmuxSession}`);
210
- await initArchitectTerminal();
211
- const updated = getArchitect();
212
- if (updated?.terminalId) {
213
- console.log(`initArchitectTerminal: success, terminalId=${updated.terminalId}`);
214
- return;
215
- }
216
- console.log(`initArchitectTerminal: attempt ${attempt + 1} failed, terminalId still unset`);
217
- }
218
- catch (err) {
219
- console.error(`initArchitectTerminal: attempt ${attempt + 1} error:`, err.message);
220
- }
221
- }
222
- console.warn('initArchitectTerminal: gave up after 30 attempts');
223
- })();
224
- // Log telemetry
225
- try {
226
- const metricsPath = path.join(projectRoot, '.agent-farm', 'metrics.log');
227
- fs.mkdirSync(path.dirname(metricsPath), { recursive: true });
228
- fs.appendFileSync(metricsPath, JSON.stringify({
229
- event: 'backend_selected',
230
- backend: 'node-pty',
231
- timestamp: new Date().toISOString(),
232
- }) + '\n');
233
- }
234
- catch { /* ignore */ }
235
- // Clean up dead processes from state (called on state load)
236
- function cleanupDeadProcesses() {
237
- // Clean up dead shell processes
238
- for (const util of getUtils()) {
239
- if (!isProcessRunning(util.pid)) {
240
- console.log(`Auto-closing shell tab ${util.name} (process ${util.pid} exited)`);
241
- if (util.tmuxSession) {
242
- killTmuxSession(util.tmuxSession);
243
- }
244
- removeUtil(util.id);
245
- }
246
- }
247
- // Clean up dead annotation processes
248
- for (const annotation of getAnnotations()) {
249
- if (!isProcessRunning(annotation.pid)) {
250
- console.log(`Auto-closing file tab ${annotation.file} (process ${annotation.pid} exited)`);
251
- removeAnnotation(annotation.id);
252
- }
253
- }
254
- }
255
- // Load state with cleanup
256
- function loadStateWithCleanup() {
257
- cleanupDeadProcesses();
258
- return loadState();
259
- }
260
- // Generate unique ID using crypto for collision resistance
261
- function generateId(prefix) {
262
- const uuid = randomUUID().replace(/-/g, '').substring(0, 8).toUpperCase();
263
- return `${prefix}${uuid}`;
264
- }
265
- // Get all ports currently used in state
266
- function getUsedPorts(state) {
267
- const ports = new Set();
268
- if (state.architect?.port)
269
- ports.add(state.architect.port);
270
- for (const builder of state.builders || []) {
271
- if (builder.port)
272
- ports.add(builder.port);
273
- }
274
- for (const util of state.utils || []) {
275
- if (util.port)
276
- ports.add(util.port);
277
- }
278
- for (const annotation of state.annotations || []) {
279
- if (annotation.port)
280
- ports.add(annotation.port);
281
- }
282
- return ports;
283
- }
284
- // Find available port in range (checks both state and actual availability)
285
- async function findAvailablePort(startPort, state) {
286
- // Get ports already allocated in state
287
- const usedPorts = state ? getUsedPorts(state) : new Set();
288
- // Skip ports already in state
289
- let port = startPort;
290
- while (usedPorts.has(port)) {
291
- port++;
292
- }
293
- // Then verify the port is actually available for binding
294
- return new Promise((resolve) => {
295
- const server = net.createServer();
296
- server.listen(port, () => {
297
- const { port: boundPort } = server.address();
298
- server.close(() => resolve(boundPort));
299
- });
300
- server.on('error', () => {
301
- resolve(findAvailablePort(port + 1, state));
302
- });
303
- });
304
- }
305
- // Wait for a port to be accepting connections (server ready)
306
- async function waitForPortReady(port, timeoutMs = 5000) {
307
- const startTime = Date.now();
308
- const pollInterval = 100; // Check every 100ms
309
- while (Date.now() - startTime < timeoutMs) {
310
- const isReady = await new Promise((resolve) => {
311
- const socket = new net.Socket();
312
- socket.setTimeout(pollInterval);
313
- socket.on('connect', () => {
314
- socket.destroy();
315
- resolve(true);
316
- });
317
- socket.on('error', () => {
318
- socket.destroy();
319
- resolve(false);
320
- });
321
- socket.on('timeout', () => {
322
- socket.destroy();
323
- resolve(false);
324
- });
325
- socket.connect(port, '127.0.0.1');
326
- });
327
- if (isReady) {
328
- return true;
329
- }
330
- // Wait before next poll
331
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
332
- }
333
- return false;
334
- }
335
- // Kill tmux session
336
- function killTmuxSession(sessionName) {
337
- try {
338
- execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
339
- }
340
- catch {
341
- // Session may not exist
342
- }
343
- }
344
- // Check if a process is running
345
- function isProcessRunning(pid) {
346
- try {
347
- // Signal 0 doesn't kill, just checks if process exists
348
- process.kill(pid, 0);
349
- return true;
350
- }
351
- catch {
352
- return false;
353
- }
354
- }
355
- // Graceful process termination with two-phase shutdown
356
- async function killProcessGracefully(pid, tmuxSession) {
357
- // First kill tmux session if provided
358
- if (tmuxSession) {
359
- killTmuxSession(tmuxSession);
360
- }
361
- // Guard: PID 0 sends signal to entire process group — never do that
362
- if (!pid || pid <= 0)
363
- return;
364
- try {
365
- // First try SIGTERM
366
- process.kill(pid, 'SIGTERM');
367
- // Wait up to 500ms for process to exit
368
- await new Promise((resolve) => {
369
- let attempts = 0;
370
- const checkInterval = setInterval(() => {
371
- attempts++;
372
- try {
373
- // Signal 0 checks if process exists
374
- process.kill(pid, 0);
375
- if (attempts >= 5) {
376
- // Process still alive after 500ms, use SIGKILL
377
- clearInterval(checkInterval);
378
- try {
379
- process.kill(pid, 'SIGKILL');
380
- }
381
- catch {
382
- // Already dead
383
- }
384
- resolve();
385
- }
386
- }
387
- catch {
388
- // Process is dead
389
- clearInterval(checkInterval);
390
- resolve();
391
- }
392
- }, 100);
393
- });
394
- }
395
- catch {
396
- // Process may already be dead
397
- }
398
- }
399
- // Spawn detached process with error handling
400
- function spawnDetached(command, args, cwd) {
401
- try {
402
- const child = spawn(command, args, {
403
- cwd,
404
- detached: true,
405
- stdio: 'ignore',
406
- });
407
- child.on('error', (err) => {
408
- console.error(`Failed to spawn ${command}:`, err.message);
409
- });
410
- child.unref();
411
- return child.pid || null;
412
- }
413
- catch (err) {
414
- console.error(`Failed to spawn ${command}:`, err.message);
415
- return null;
416
- }
417
- }
418
- // Check if tmux session exists
419
- function tmuxSessionExists(sessionName) {
420
- try {
421
- execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
422
- return true;
423
- }
424
- catch {
425
- return false;
426
- }
427
- }
428
- // Create a PTY terminal session via the TerminalManager.
429
- // Returns the terminal session ID, or null on failure.
430
- async function createTerminalSession(shellCommand, cwd, label) {
431
- if (!terminalManager)
432
- return null;
433
- try {
434
- const info = await terminalManager.createSession({
435
- command: '/bin/bash',
436
- args: ['-c', shellCommand],
437
- cwd,
438
- cols: 200,
439
- rows: 50,
440
- label,
441
- });
442
- return info.id;
443
- }
444
- catch (err) {
445
- console.error(`Failed to create terminal session:`, err.message);
446
- return null;
447
- }
448
- }
449
- /**
450
- * Generate a short 4-character base64-encoded ID for worktree names
451
- */
452
- function generateShortId() {
453
- const num = Math.floor(Math.random() * 0xFFFFFF);
454
- const bytes = new Uint8Array([num >> 16, (num >> 8) & 0xFF, num & 0xFF]);
455
- return btoa(String.fromCharCode(...bytes))
456
- .replace(/\+/g, '-')
457
- .replace(/\//g, '_')
458
- .replace(/=/g, '')
459
- .substring(0, 4);
460
- }
461
- /**
462
- * Spawn a worktree builder - creates git worktree and starts builder CLI
463
- * Similar to shell spawning but with git worktree isolation
464
- */
465
- async function spawnWorktreeBuilder(builderPort, state) {
466
- const shortId = generateShortId();
467
- const builderId = `worktree-${shortId}`;
468
- const branchName = `builder/worktree-${shortId}`;
469
- const worktreePath = path.resolve(projectRoot, '.builders', builderId);
470
- const sessionName = `builder-${builderId}`;
471
- try {
472
- // Ensure .builders directory exists
473
- const buildersDir = path.resolve(projectRoot, '.builders');
474
- if (!fs.existsSync(buildersDir)) {
475
- fs.mkdirSync(buildersDir, { recursive: true });
476
- }
477
- // Create git branch and worktree
478
- execSync(`git branch "${branchName}" HEAD`, { cwd: projectRoot, stdio: 'ignore' });
479
- execSync(`git worktree add "${worktreePath}" "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
480
- // Get builder command from af-config.json or use default shell
481
- const afConfigPath = path.resolve(projectRoot, 'af-config.json');
482
- const defaultShell = process.env.SHELL || 'bash';
483
- let builderCommand = defaultShell;
484
- if (fs.existsSync(afConfigPath)) {
485
- try {
486
- const config = JSON.parse(fs.readFileSync(afConfigPath, 'utf-8'));
487
- builderCommand = config?.shell?.builder || defaultShell;
488
- }
489
- catch {
490
- // Use default
491
- }
492
- }
493
- // Create PTY terminal session via node-pty
494
- const terminalId = await createTerminalSession(builderCommand, worktreePath, `builder-${builderId}`);
495
- if (!terminalId) {
496
- // Cleanup on failure
497
- try {
498
- execSync(`git worktree remove "${worktreePath}" --force`, { cwd: projectRoot, stdio: 'ignore' });
499
- execSync(`git branch -D "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
500
- }
501
- catch {
502
- // Best effort cleanup
503
- }
504
- return null;
505
- }
506
- const builder = {
507
- id: builderId,
508
- name: `Worktree ${shortId}`,
509
- port: 0,
510
- pid: 0,
511
- status: 'implementing',
512
- phase: 'interactive',
513
- worktree: worktreePath,
514
- branch: branchName,
515
- tmuxSession: sessionName,
516
- type: 'worktree',
517
- terminalId,
518
- };
519
- return { builder, pid: 0 };
520
- }
521
- catch (err) {
522
- console.error(`Failed to spawn worktree builder:`, err.message);
523
- // Cleanup any partial state
524
- killTmuxSession(sessionName);
525
- try {
526
- execSync(`git worktree remove "${worktreePath}" --force`, { cwd: projectRoot, stdio: 'ignore' });
527
- execSync(`git branch -D "${branchName}"`, { cwd: projectRoot, stdio: 'ignore' });
528
- }
529
- catch {
530
- // Best effort cleanup
531
- }
532
- return null;
533
- }
534
- }
535
- // parseJsonBody imported from ../utils/server-utils.js
536
- // Validate path is within project root (prevent path traversal)
537
- // Handles URL-encoded dots (%2e), symlinks, and other encodings
538
- function validatePathWithinProject(filePath) {
539
- // First decode any URL encoding to catch %2e%2e (encoded ..)
540
- let decodedPath;
541
- try {
542
- decodedPath = decodeURIComponent(filePath);
543
- }
544
- catch {
545
- // Invalid encoding
546
- return null;
547
- }
548
- // Resolve to absolute path
549
- const resolvedPath = decodedPath.startsWith('/')
550
- ? path.resolve(decodedPath)
551
- : path.resolve(projectRoot, decodedPath);
552
- // Normalize to remove any .. or . segments
553
- const normalizedPath = path.normalize(resolvedPath);
554
- // First check normalized path (for paths that don't exist yet)
555
- if (!normalizedPath.startsWith(projectRoot + path.sep) && normalizedPath !== projectRoot) {
556
- return null; // Path escapes project root
557
- }
558
- // If file exists, resolve symlinks to prevent symlink-based path traversal
559
- // An attacker could create a symlink within the repo pointing outside
560
- if (fs.existsSync(normalizedPath)) {
561
- try {
562
- const realPath = fs.realpathSync(normalizedPath);
563
- if (!realPath.startsWith(projectRoot + path.sep) && realPath !== projectRoot) {
564
- return null; // Symlink target escapes project root
565
- }
566
- return realPath;
567
- }
568
- catch {
569
- // realpathSync failed (broken symlink, permissions, etc.)
570
- return null;
571
- }
572
- }
573
- return normalizedPath;
574
- }
575
- // Count total tabs for DoS protection
576
- function countTotalTabs(state) {
577
- return state.builders.length + state.utils.length + state.annotations.length;
578
- }
579
- // Find open server script (prefer .ts for dev, .js for compiled)
580
- function getOpenServerPath() {
581
- const tsPath = path.join(__dirname, 'open-server.ts');
582
- const jsPath = path.join(__dirname, 'open-server.js');
583
- if (fs.existsSync(tsPath)) {
584
- return { script: tsPath, useTsx: true };
585
- }
586
- return { script: jsPath, useTsx: false };
587
- }
588
- /**
589
- * Escape a string for safe use in shell commands
590
- * Handles special characters that could cause command injection
591
- */
592
- function escapeShellArg(str) {
593
- // Single-quote the string and escape any single quotes within it
594
- return "'" + str.replace(/'/g, "'\\''") + "'";
595
- }
596
- /**
597
- * Get today's git commits from all branches for the current user
598
- */
599
- async function getGitCommits(projectRoot) {
600
- try {
601
- const { stdout: authorRaw } = await execAsync('git config user.name', { cwd: projectRoot });
602
- const author = authorRaw.trim();
603
- if (!author)
604
- return [];
605
- // Escape author name to prevent command injection
606
- const safeAuthor = escapeShellArg(author);
607
- // Get commits from all branches since midnight
608
- const { stdout: output } = await execAsync(`git log --all --since="midnight" --author=${safeAuthor} --format="%H|%s|%aI|%D"`, { cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 });
609
- if (!output.trim())
610
- return [];
611
- return output.trim().split('\n').filter(Boolean).map(line => {
612
- const parts = line.split('|');
613
- const hash = parts[0] || '';
614
- const message = parts[1] || '';
615
- const time = parts[2] || '';
616
- const refs = parts.slice(3).join('|'); // refs might contain |
617
- // Extract branch name from refs
618
- let branch = 'unknown';
619
- const headMatch = refs.match(/HEAD -> ([^,]+)/);
620
- const branchMatch = refs.match(/([^,\s]+)$/);
621
- if (headMatch) {
622
- branch = headMatch[1];
623
- }
624
- else if (branchMatch && branchMatch[1]) {
625
- branch = branchMatch[1];
626
- }
627
- return {
628
- hash: hash.slice(0, 7),
629
- message: message.slice(0, 100), // Truncate long messages
630
- time,
631
- branch,
632
- };
633
- });
634
- }
635
- catch (err) {
636
- console.error('Error getting git commits:', err.message);
637
- return [];
638
- }
639
- }
640
- /**
641
- * Get unique files modified today
642
- */
643
- async function getModifiedFiles(projectRoot) {
644
- try {
645
- const { stdout: authorRaw } = await execAsync('git config user.name', { cwd: projectRoot });
646
- const author = authorRaw.trim();
647
- if (!author)
648
- return [];
649
- // Escape author name to prevent command injection
650
- const safeAuthor = escapeShellArg(author);
651
- const { stdout: output } = await execAsync(`git log --all --since="midnight" --author=${safeAuthor} --name-only --format=""`, { cwd: projectRoot, maxBuffer: 10 * 1024 * 1024 });
652
- if (!output.trim())
653
- return [];
654
- const files = [...new Set(output.trim().split('\n').filter(Boolean))];
655
- return files.sort();
656
- }
657
- catch (err) {
658
- console.error('Error getting modified files:', err.message);
659
- return [];
660
- }
661
- }
662
- /**
663
- * Get GitHub PRs created or merged today via gh CLI
664
- * Combines PRs created today AND PRs merged today (which may have been created earlier)
665
- */
666
- async function getGitHubPRs(projectRoot) {
667
- try {
668
- // Use local time for the date (spec says "today" means local machine time)
669
- const now = new Date();
670
- const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
671
- // Fetch PRs created today AND PRs merged today in parallel
672
- const [createdResult, mergedResult] = await Promise.allSettled([
673
- execAsync(`gh pr list --author "@me" --state all --search "created:>=${today}" --json number,title,state,url`, { cwd: projectRoot, timeout: 15000 }),
674
- execAsync(`gh pr list --author "@me" --state merged --search "merged:>=${today}" --json number,title,state,url`, { cwd: projectRoot, timeout: 15000 }),
675
- ]);
676
- const prsMap = new Map();
677
- // Process PRs created today
678
- if (createdResult.status === 'fulfilled' && createdResult.value.stdout.trim()) {
679
- const prs = JSON.parse(createdResult.value.stdout);
680
- for (const pr of prs) {
681
- prsMap.set(pr.number, {
682
- number: pr.number,
683
- title: pr.title.slice(0, 100),
684
- state: pr.state,
685
- url: pr.url,
686
- });
687
- }
688
- }
689
- // Process PRs merged today (may overlap with created, deduped by Map)
690
- if (mergedResult.status === 'fulfilled' && mergedResult.value.stdout.trim()) {
691
- const prs = JSON.parse(mergedResult.value.stdout);
692
- for (const pr of prs) {
693
- prsMap.set(pr.number, {
694
- number: pr.number,
695
- title: pr.title.slice(0, 100),
696
- state: pr.state,
697
- url: pr.url,
698
- });
699
- }
700
- }
701
- return Array.from(prsMap.values());
702
- }
703
- catch (err) {
704
- // gh CLI might not be available or authenticated
705
- console.error('Error getting GitHub PRs:', err.message);
706
- return [];
707
- }
708
- }
709
- /**
710
- * Get builder activity from state.db for today
711
- * Note: state.json doesn't track timestamps, so we can only report current builders
712
- * without duration. They'll be counted as activity points, not time intervals.
713
- */
714
- function getBuilderActivity() {
715
- try {
716
- const builders = getBuilders();
717
- // Return current builders without time tracking (state.json lacks timestamps)
718
- // Time tracking will rely primarily on git commits
719
- return builders.map(b => ({
720
- id: b.id,
721
- status: b.status || 'unknown',
722
- startTime: '', // Unknown - not tracked in state.json
723
- endTime: undefined,
724
- }));
725
- }
726
- catch (err) {
727
- console.error('Error getting builder activity:', err.message);
728
- return [];
729
- }
730
- }
731
- /**
732
- * Detect project status changes in projectlist.md today
733
- * Handles YAML format inside Markdown fenced code blocks
734
- */
735
- async function getProjectChanges(projectRoot) {
736
- try {
737
- const projectlistPath = path.join(projectRoot, 'codev/projectlist.md');
738
- if (!fs.existsSync(projectlistPath))
739
- return [];
740
- // Get the first commit hash from today that touched projectlist.md
741
- const { stdout: firstCommitOutput } = await execAsync(`git log --since="midnight" --format=%H -- codev/projectlist.md | tail -1`, { cwd: projectRoot });
742
- if (!firstCommitOutput.trim())
743
- return [];
744
- // Get diff of projectlist.md from that commit's parent to HEAD
745
- let diff;
746
- try {
747
- const { stdout } = await execAsync(`git diff ${firstCommitOutput.trim()}^..HEAD -- codev/projectlist.md`, { cwd: projectRoot, maxBuffer: 1024 * 1024 });
748
- diff = stdout;
749
- }
750
- catch {
751
- return [];
752
- }
753
- if (!diff.trim())
754
- return [];
755
- // Parse status changes from diff
756
- // Format is YAML inside Markdown code blocks:
757
- // - id: "0058"
758
- // title: "File Search Autocomplete"
759
- // status: implementing
760
- const changes = [];
761
- const lines = diff.split('\n');
762
- let currentId = '';
763
- let currentTitle = '';
764
- let oldStatus = '';
765
- let newStatus = '';
766
- for (const line of lines) {
767
- // Track current project context from YAML id field
768
- // Match lines like: " - id: \"0058\"" or "+ - id: \"0058\""
769
- const idMatch = line.match(/^[+-]?\s*-\s*id:\s*["']?(\d{4})["']?/);
770
- if (idMatch) {
771
- // If we have a pending status change from previous project, emit it
772
- if (oldStatus && newStatus && currentId) {
773
- changes.push({
774
- id: currentId,
775
- title: currentTitle,
776
- oldStatus,
777
- newStatus,
778
- });
779
- oldStatus = '';
780
- newStatus = '';
781
- }
782
- currentId = idMatch[1];
783
- currentTitle = ''; // Will be filled by title line
784
- }
785
- // Track title (comes after id in YAML)
786
- // Match lines like: " title: \"File Search Autocomplete\""
787
- const titleMatch = line.match(/^[+-]?\s*title:\s*["']?([^"']+)["']?/);
788
- if (titleMatch && currentId) {
789
- currentTitle = titleMatch[1].trim();
790
- }
791
- // Track status changes
792
- // Match lines like: "- status: implementing" or "+ status: implemented"
793
- const statusMatch = line.match(/^([+-])\s*status:\s*(\w+)/);
794
- if (statusMatch) {
795
- const [, modifier, status] = statusMatch;
796
- if (modifier === '-') {
797
- oldStatus = status;
798
- }
799
- else if (modifier === '+') {
800
- newStatus = status;
801
- }
802
- }
803
- }
804
- // Emit final pending change if exists
805
- if (oldStatus && newStatus && currentId) {
806
- changes.push({
807
- id: currentId,
808
- title: currentTitle,
809
- oldStatus,
810
- newStatus,
811
- });
812
- }
813
- return changes;
814
- }
815
- catch (err) {
816
- console.error('Error getting project changes:', err.message);
817
- return [];
818
- }
819
- }
820
- /**
821
- * Merge overlapping time intervals
822
- */
823
- function mergeIntervals(intervals) {
824
- if (intervals.length === 0)
825
- return [];
826
- // Sort by start time
827
- const sorted = [...intervals].sort((a, b) => a.start.getTime() - b.start.getTime());
828
- const merged = [{ ...sorted[0] }];
829
- for (let i = 1; i < sorted.length; i++) {
830
- const last = merged[merged.length - 1];
831
- const current = sorted[i];
832
- // If overlapping or within 2 hours, merge
833
- const gapMs = current.start.getTime() - last.end.getTime();
834
- const twoHoursMs = 2 * 60 * 60 * 1000;
835
- if (gapMs <= twoHoursMs) {
836
- last.end = new Date(Math.max(last.end.getTime(), current.end.getTime()));
837
- }
838
- else {
839
- merged.push({ ...current });
840
- }
841
- }
842
- return merged;
843
- }
844
- /**
845
- * Calculate active time from commits and builder activity
846
- */
847
- function calculateTimeTracking(commits, builders) {
848
- const intervals = [];
849
- const fiveMinutesMs = 5 * 60 * 1000;
850
- // Add commit timestamps (treat each as 5-minute interval)
851
- for (const commit of commits) {
852
- if (commit.time) {
853
- const time = new Date(commit.time);
854
- if (!isNaN(time.getTime())) {
855
- intervals.push({
856
- start: time,
857
- end: new Date(time.getTime() + fiveMinutesMs),
858
- });
859
- }
860
- }
861
- }
862
- // Add builder sessions
863
- for (const builder of builders) {
864
- if (builder.startTime) {
865
- const start = new Date(builder.startTime);
866
- const end = builder.endTime ? new Date(builder.endTime) : new Date();
867
- if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
868
- intervals.push({ start, end });
869
- }
870
- }
871
- }
872
- if (intervals.length === 0) {
873
- return {
874
- activeMinutes: 0,
875
- firstActivity: '',
876
- lastActivity: '',
877
- };
878
- }
879
- const merged = mergeIntervals(intervals);
880
- const totalMinutes = merged.reduce((sum, interval) => sum + (interval.end.getTime() - interval.start.getTime()) / (1000 * 60), 0);
881
- return {
882
- activeMinutes: Math.round(totalMinutes),
883
- firstActivity: merged[0].start.toISOString(),
884
- lastActivity: merged[merged.length - 1].end.toISOString(),
885
- };
886
- }
887
- /**
888
- * Find the consult CLI path
889
- * Returns the path to the consult binary, checking multiple locations
890
- */
891
- function findConsultPath() {
892
- // When running from dist/, check relative paths
893
- // dist/agent-farm/servers/ -> ../../../bin/consult.js
894
- const distPath = path.join(__dirname, '../../../bin/consult.js');
895
- if (fs.existsSync(distPath)) {
896
- return distPath;
897
- }
898
- // When running from src/ with tsx, check src-relative paths
899
- // src/agent-farm/servers/ -> ../../../bin/consult.js (won't exist, it's .ts in src)
900
- // But bin/ is at packages/codev/bin/consult.js, so it should still work
901
- // Fall back to npx consult (works if @cluesmith/codev is installed)
902
- return 'npx consult';
903
- }
904
- /**
905
- * Generate AI summary via consult CLI
906
- */
907
- async function generateAISummary(data) {
908
- // Build prompt with commit messages and file names only (security: no full diffs)
909
- const hours = Math.floor(data.timeTracking.activeMinutes / 60);
910
- const mins = data.timeTracking.activeMinutes % 60;
911
- const prompt = `Summarize this developer's activity today for a standup report.
912
-
913
- Commits (${data.commits.length}):
914
- ${data.commits.slice(0, 20).map(c => `- ${c.message}`).join('\n') || '(none)'}
915
- ${data.commits.length > 20 ? `... and ${data.commits.length - 20} more` : ''}
916
-
917
- PRs: ${data.prs.map(p => `#${p.number} ${p.title} (${p.state})`).join(', ') || 'None'}
918
-
919
- Files modified: ${data.files.length} files
920
- ${data.files.slice(0, 10).join(', ')}${data.files.length > 10 ? ` ... and ${data.files.length - 10} more` : ''}
921
-
922
- Project status changes:
923
- ${data.projectChanges.map(p => `- ${p.id} ${p.title}: ${p.oldStatus} → ${p.newStatus}`).join('\n') || '(none)'}
924
-
925
- Active time: ~${hours}h ${mins}m
926
-
927
- Write a brief, professional summary (2-3 sentences) focusing on accomplishments. Be concise and suitable for a standup or status report.`;
928
- try {
929
- // Use consult CLI to generate summary
930
- const consultCmd = findConsultPath();
931
- const safePrompt = escapeShellArg(prompt);
932
- // Use async exec with timeout
933
- const { stdout } = await execAsync(`${consultCmd} --model gemini general ${safePrompt}`, { timeout: 60000, maxBuffer: 1024 * 1024 });
934
- return stdout.trim();
935
- }
936
- catch (err) {
937
- console.error('AI summary generation failed:', err.message);
938
- return '';
939
- }
940
- }
941
- /**
942
- * Collect all activity data for today
943
- */
944
- async function collectActivitySummary(projectRoot) {
945
- // Collect data from all sources in parallel - these are now truly async
946
- const [commits, files, prs, builders, projectChanges] = await Promise.all([
947
- getGitCommits(projectRoot),
948
- getModifiedFiles(projectRoot),
949
- getGitHubPRs(projectRoot),
950
- Promise.resolve(getBuilderActivity()), // This one is sync (reads from state)
951
- getProjectChanges(projectRoot),
952
- ]);
953
- const timeTracking = calculateTimeTracking(commits, builders);
954
- // Generate AI summary (skip if no activity)
955
- let aiSummary = '';
956
- if (commits.length > 0 || prs.length > 0) {
957
- aiSummary = await generateAISummary({
958
- commits,
959
- prs,
960
- files,
961
- timeTracking,
962
- projectChanges,
963
- });
964
- }
965
- return {
966
- commits,
967
- prs,
968
- builders,
969
- projectChanges,
970
- files,
971
- timeTracking,
972
- aiSummary: aiSummary || undefined,
973
- };
974
- }
975
- // Insecure remote mode - set when bindHost is 0.0.0.0
976
- const insecureRemoteMode = bindHost === '0.0.0.0';
977
- // ============================================================
978
- // Terminal Proxy (Spec 0062 - Secure Remote Access)
979
- // ============================================================
980
- // Create http-proxy instance for terminal proxying
981
- const terminalProxy = httpProxy.createProxyServer({ ws: true });
982
- // Handle proxy errors gracefully
983
- terminalProxy.on('error', (err, req, res) => {
984
- console.error('Terminal proxy error:', err.message);
985
- if (res && 'writeHead' in res && !res.headersSent) {
986
- res.writeHead(502, { 'Content-Type': 'application/json' });
987
- res.end(JSON.stringify({ error: 'Terminal unavailable' }));
988
- }
989
- });
990
- // getPortForTerminal is imported from utils/terminal-ports.ts (Spec 0062)
991
- // Security: Validate request origin (uses base from server-utils with insecureRemoteMode override)
992
- function isRequestAllowed(req) {
993
- // Skip all security checks in insecure remote mode
994
- if (insecureRemoteMode) {
995
- return true;
996
- }
997
- return isRequestAllowedBase(req);
998
- }
999
- /**
1000
- * Timing-safe token comparison to prevent timing attacks
1001
- */
1002
- function isValidToken(provided, expected) {
1003
- if (!provided)
1004
- return false;
1005
- // Ensure both strings are same length for timing-safe comparison
1006
- const providedBuf = Buffer.from(provided);
1007
- const expectedBuf = Buffer.from(expected);
1008
- if (providedBuf.length !== expectedBuf.length) {
1009
- // Still do a comparison to maintain constant time
1010
- timingSafeEqual(expectedBuf, expectedBuf);
1011
- return false;
1012
- }
1013
- return timingSafeEqual(providedBuf, expectedBuf);
1014
- }
1015
- /**
1016
- * Generate HTML for login page
1017
- */
1018
- function getLoginPageHtml() {
1019
- return `<!DOCTYPE html>
1020
- <html>
1021
- <head>
1022
- <title>Dashboard Login</title>
1023
- <meta name="viewport" content="width=device-width, initial-scale=1">
1024
- <style>
1025
- body { font-family: system-ui; background: #1a1a2e; color: #eee;
1026
- display: flex; justify-content: center; align-items: center;
1027
- min-height: 100vh; margin: 0; }
1028
- .login { background: #16213e; padding: 2rem; border-radius: 8px;
1029
- max-width: 400px; width: 90%; }
1030
- h1 { margin-top: 0; }
1031
- input { width: 100%; padding: 0.75rem; margin: 0.5rem 0;
1032
- border: 1px solid #444; border-radius: 4px;
1033
- background: #0f0f23; color: #eee; font-size: 1rem;
1034
- box-sizing: border-box; }
1035
- button { width: 100%; padding: 0.75rem; margin-top: 1rem;
1036
- background: #4a7c59; color: white; border: none;
1037
- border-radius: 4px; font-size: 1rem; cursor: pointer; }
1038
- button:hover { background: #5a9c69; }
1039
- .error { color: #ff6b6b; margin-top: 0.5rem; display: none; }
1040
- </style>
1041
- </head>
1042
- <body>
1043
- <div class="login">
1044
- <h1>Agent Farm Login</h1>
1045
- <p>Enter your API key to access the dashboard.</p>
1046
- <input type="password" id="key" placeholder="API Key" autofocus>
1047
- <div class="error" id="error">Invalid API key</div>
1048
- <button onclick="login()">Login</button>
1049
- </div>
1050
- <script>
1051
- // Check for key in URL (from QR code scan) or localStorage
1052
- (async function() {
1053
- const urlParams = new URLSearchParams(window.location.search);
1054
- const keyFromUrl = urlParams.get('key');
1055
- const keyFromStorage = localStorage.getItem('codev_web_key');
1056
- const key = keyFromUrl || keyFromStorage;
1057
-
1058
- if (key) {
1059
- if (keyFromUrl) {
1060
- localStorage.setItem('codev_web_key', keyFromUrl);
1061
- }
1062
- await verifyAndLoadDashboard(key);
1063
- }
1064
- })();
1065
-
1066
- async function verifyAndLoadDashboard(key) {
1067
- try {
1068
- // Fetch the actual dashboard with auth header
1069
- const res = await fetch(window.location.pathname, {
1070
- headers: {
1071
- 'Authorization': 'Bearer ' + key,
1072
- 'Accept': 'text/html'
1073
- }
1074
- });
1075
- if (res.ok) {
1076
- // Replace entire page with dashboard
1077
- const html = await res.text();
1078
- document.open();
1079
- document.write(html);
1080
- document.close();
1081
- // Clean URL without reload
1082
- history.replaceState({}, '', window.location.pathname);
1083
- } else {
1084
- // Key invalid
1085
- localStorage.removeItem('codev_web_key');
1086
- document.getElementById('error').style.display = 'block';
1087
- document.getElementById('error').textContent = 'Invalid API key';
1088
- }
1089
- } catch (e) {
1090
- document.getElementById('error').style.display = 'block';
1091
- document.getElementById('error').textContent = 'Connection error';
1092
- }
1093
- }
1094
-
1095
- async function login() {
1096
- const key = document.getElementById('key').value;
1097
- if (!key) return;
1098
- localStorage.setItem('codev_web_key', key);
1099
- await verifyAndLoadDashboard(key);
1100
- }
1101
- document.getElementById('key').addEventListener('keypress', (e) => {
1102
- if (e.key === 'Enter') login();
1103
- });
1104
- </script>
1105
- </body>
1106
- </html>`;
1107
- }
1108
- // Create server
1109
- const server = http.createServer(async (req, res) => {
1110
- // Security: Validate Host and Origin headers
1111
- if (!isRequestAllowed(req)) {
1112
- res.writeHead(403, { 'Content-Type': 'text/plain' });
1113
- res.end('Forbidden');
1114
- return;
1115
- }
1116
- // CRITICAL: When CODEV_WEB_KEY is set, ALL requests require auth
1117
- // NO localhost bypass - tunnel daemons (cloudflared) run locally and proxy
1118
- // to localhost, so checking remoteAddress would incorrectly trust remote traffic
1119
- const webKey = process.env.CODEV_WEB_KEY;
1120
- if (webKey) {
1121
- const authHeader = req.headers.authorization;
1122
- const token = authHeader?.replace('Bearer ', '');
1123
- if (!isValidToken(token, webKey)) {
1124
- // Return login page for HTML requests, 401 for API
1125
- if (req.headers.accept?.includes('text/html')) {
1126
- res.writeHead(200, { 'Content-Type': 'text/html' });
1127
- res.end(getLoginPageHtml());
1128
- return;
1129
- }
1130
- res.writeHead(401, { 'Content-Type': 'application/json' });
1131
- res.end(JSON.stringify({ error: 'Unauthorized' }));
1132
- return;
1133
- }
1134
- }
1135
- // When CODEV_WEB_KEY is NOT set: no auth required (local dev mode only)
1136
- // CORS headers
1137
- const origin = req.headers.origin;
1138
- if (insecureRemoteMode || webKey) {
1139
- // Allow any origin in insecure remote mode or when using auth (tunnel access)
1140
- res.setHeader('Access-Control-Allow-Origin', origin || '*');
1141
- }
1142
- else if (origin && (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:'))) {
1143
- res.setHeader('Access-Control-Allow-Origin', origin);
1144
- }
1145
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
1146
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
1147
- // Prevent caching of API responses
1148
- res.setHeader('Cache-Control', 'no-store');
1149
- if (req.method === 'OPTIONS') {
1150
- res.writeHead(200);
1151
- res.end();
1152
- return;
1153
- }
1154
- const url = new URL(req.url || '/', `http://localhost:${port}`);
1155
- try {
1156
- // Spec 0085: node-pty terminal manager REST API routes
1157
- if (terminalManager && url.pathname.startsWith('/api/terminals')) {
1158
- if (terminalManager.handleRequest(req, res)) {
1159
- return;
1160
- }
1161
- }
1162
- // API: Get state
1163
- if (req.method === 'GET' && url.pathname === '/api/state') {
1164
- const state = loadStateWithCleanup();
1165
- // Include project name for tab title
1166
- const stateWithProject = {
1167
- ...state,
1168
- projectName: getProjectName(projectRoot),
1169
- };
1170
- res.writeHead(200, { 'Content-Type': 'application/json' });
1171
- res.end(JSON.stringify(stateWithProject));
1172
- return;
1173
- }
1174
- // API: Create file tab (annotation)
1175
- if (req.method === 'POST' && url.pathname === '/api/tabs/file') {
1176
- const body = await parseJsonBody(req);
1177
- const filePath = body.path;
1178
- if (!filePath) {
1179
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1180
- res.end('Missing path');
1181
- return;
1182
- }
1183
- // Validate path is within project root (prevent path traversal)
1184
- const fullPath = validatePathWithinProject(filePath);
1185
- if (!fullPath) {
1186
- res.writeHead(403, { 'Content-Type': 'text/plain' });
1187
- res.end('Path must be within project directory');
1188
- return;
1189
- }
1190
- // Check file exists
1191
- if (!fs.existsSync(fullPath)) {
1192
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1193
- res.end(`File not found: ${filePath}`);
1194
- return;
1195
- }
1196
- // Check if already open
1197
- const annotations = getAnnotations();
1198
- const existing = annotations.find((a) => a.file === fullPath);
1199
- if (existing) {
1200
- // Verify the process is still running
1201
- if (isProcessRunning(existing.pid)) {
1202
- res.writeHead(200, { 'Content-Type': 'application/json' });
1203
- res.end(JSON.stringify({ id: existing.id, port: existing.port, existing: true }));
1204
- return;
1205
- }
1206
- // Process is dead - clean up stale entry and spawn new one
1207
- console.log(`Cleaning up stale annotation for ${fullPath} (pid ${existing.pid} dead)`);
1208
- removeAnnotation(existing.id);
1209
- }
1210
- // DoS protection: check tab limit
1211
- const state = loadState();
1212
- if (countTotalTabs(state) >= CONFIG.maxTabs) {
1213
- res.writeHead(429, { 'Content-Type': 'text/plain' });
1214
- res.end(`Tab limit reached (max ${CONFIG.maxTabs}). Close some tabs first.`);
1215
- return;
1216
- }
1217
- // Find available port (pass state to avoid already-allocated ports)
1218
- const openPort = await findAvailablePort(CONFIG.openPortStart, state);
1219
- // Start open server
1220
- const { script: serverScript, useTsx } = getOpenServerPath();
1221
- if (!fs.existsSync(serverScript)) {
1222
- res.writeHead(500, { 'Content-Type': 'text/plain' });
1223
- res.end('Open server not found');
1224
- return;
1225
- }
1226
- // Use tsx for TypeScript files, node for compiled JavaScript
1227
- const cmd = useTsx ? 'npx' : 'node';
1228
- const args = useTsx
1229
- ? ['tsx', serverScript, String(openPort), fullPath]
1230
- : [serverScript, String(openPort), fullPath];
1231
- const pid = spawnDetached(cmd, args, projectRoot);
1232
- if (!pid) {
1233
- res.writeHead(500, { 'Content-Type': 'text/plain' });
1234
- res.end('Failed to start open server');
1235
- return;
1236
- }
1237
- // Wait for open server to be ready (accepting connections)
1238
- const serverReady = await waitForPortReady(openPort, 5000);
1239
- if (!serverReady) {
1240
- // Server didn't start in time - kill it and report error
1241
- try {
1242
- process.kill(pid);
1243
- }
1244
- catch {
1245
- // Process may have already died
1246
- }
1247
- res.writeHead(500, { 'Content-Type': 'text/plain' });
1248
- res.end('Open server failed to start (timeout)');
1249
- return;
1250
- }
1251
- // Create annotation record
1252
- const annotation = {
1253
- id: generateId('A'),
1254
- file: fullPath,
1255
- port: openPort,
1256
- pid,
1257
- parent: { type: 'architect' },
1258
- };
1259
- addAnnotation(annotation);
1260
- res.writeHead(201, { 'Content-Type': 'application/json' });
1261
- res.end(JSON.stringify({ id: annotation.id, port: openPort }));
1262
- return;
1263
- }
1264
- // API: Create builder tab (spawns worktree builder with random ID)
1265
- if (req.method === 'POST' && url.pathname === '/api/tabs/builder') {
1266
- const builderState = loadState();
1267
- // DoS protection: check tab limit
1268
- if (countTotalTabs(builderState) >= CONFIG.maxTabs) {
1269
- res.writeHead(429, { 'Content-Type': 'text/plain' });
1270
- res.end(`Tab limit reached (max ${CONFIG.maxTabs}). Close some tabs first.`);
1271
- return;
1272
- }
1273
- // Find available port for builder
1274
- const builderPort = await findAvailablePort(CONFIG.builderPortStart, builderState);
1275
- // Spawn worktree builder
1276
- const result = await spawnWorktreeBuilder(builderPort, builderState);
1277
- if (!result) {
1278
- res.writeHead(500, { 'Content-Type': 'text/plain' });
1279
- res.end('Failed to spawn worktree builder');
1280
- return;
1281
- }
1282
- // Save builder to state
1283
- upsertBuilder(result.builder);
1284
- res.writeHead(201, { 'Content-Type': 'application/json' });
1285
- res.end(JSON.stringify({ id: result.builder.id, port: result.builder.port, name: result.builder.name }));
1286
- return;
1287
- }
1288
- // API: Create shell tab (supports worktree parameter for Spec 0057)
1289
- if (req.method === 'POST' && url.pathname === '/api/tabs/shell') {
1290
- const body = await parseJsonBody(req);
1291
- const name = body.name || undefined;
1292
- const command = body.command || undefined;
1293
- const worktree = body.worktree === true;
1294
- const branch = body.branch || undefined;
1295
- // Validate name if provided (prevent command injection)
1296
- if (name && !/^[a-zA-Z0-9_-]+$/.test(name)) {
1297
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1298
- res.end('Invalid name format');
1299
- return;
1300
- }
1301
- // Validate branch name if provided (prevent command injection)
1302
- // Allow: letters, numbers, underscores, hyphens, slashes, dots
1303
- // Reject: control chars, spaces, .., @{, trailing/leading slashes
1304
- if (branch) {
1305
- const invalidPatterns = [
1306
- /[\x00-\x1f\x7f]/, // Control characters
1307
- /\s/, // Whitespace
1308
- /\.\./, // Parent directory traversal
1309
- /@\{/, // Git reflog syntax
1310
- /^\//, // Leading slash
1311
- /\/$/, // Trailing slash
1312
- /\/\//, // Double slash
1313
- /^-/, // Leading hyphen (could be flag)
1314
- ];
1315
- const isInvalid = invalidPatterns.some(p => p.test(branch));
1316
- if (isInvalid) {
1317
- res.writeHead(200, { 'Content-Type': 'application/json' });
1318
- res.end(JSON.stringify({
1319
- success: false,
1320
- error: 'Invalid branch name. Avoid spaces, control characters, .., @{, and leading/trailing slashes.'
1321
- }));
1322
- return;
1323
- }
1324
- }
1325
- const shellState = loadState();
1326
- // DoS protection: check tab limit
1327
- if (countTotalTabs(shellState) >= CONFIG.maxTabs) {
1328
- res.writeHead(429, { 'Content-Type': 'text/plain' });
1329
- res.end(`Tab limit reached (max ${CONFIG.maxTabs}). Close some tabs first.`);
1330
- return;
1331
- }
1332
- // Determine working directory (project root or worktree)
1333
- let cwd = projectRoot;
1334
- let worktreePath;
1335
- if (worktree) {
1336
- // Create worktree for the shell
1337
- const worktreesDir = path.join(projectRoot, '.worktrees');
1338
- if (!fs.existsSync(worktreesDir)) {
1339
- fs.mkdirSync(worktreesDir, { recursive: true });
1340
- }
1341
- // Generate worktree name
1342
- const worktreeName = branch || `temp-${Date.now()}`;
1343
- worktreePath = path.join(worktreesDir, worktreeName);
1344
- // Check if worktree already exists
1345
- if (fs.existsSync(worktreePath)) {
1346
- res.writeHead(200, { 'Content-Type': 'application/json' });
1347
- res.end(JSON.stringify({
1348
- success: false,
1349
- error: `Worktree '${worktreeName}' already exists at ${worktreePath}`
1350
- }));
1351
- return;
1352
- }
1353
- // Create worktree
1354
- try {
1355
- let gitCmd;
1356
- if (branch) {
1357
- // Check if branch already exists
1358
- let branchExists = false;
1359
- try {
1360
- execSync(`git rev-parse --verify "${branch}"`, { cwd: projectRoot, stdio: 'pipe' });
1361
- branchExists = true;
1362
- }
1363
- catch {
1364
- // Branch doesn't exist
1365
- }
1366
- if (branchExists) {
1367
- // Checkout existing branch into worktree
1368
- gitCmd = `git worktree add "${worktreePath}" "${branch}"`;
1369
- }
1370
- else {
1371
- // Create new branch and worktree
1372
- gitCmd = `git worktree add "${worktreePath}" -b "${branch}"`;
1373
- }
1374
- }
1375
- else {
1376
- // Detached HEAD worktree
1377
- gitCmd = `git worktree add "${worktreePath}" --detach`;
1378
- }
1379
- execSync(gitCmd, { cwd: projectRoot, stdio: 'pipe' });
1380
- // Symlink .env from project root into worktree (if it exists)
1381
- const rootEnvPath = path.join(projectRoot, '.env');
1382
- const worktreeEnvPath = path.join(worktreePath, '.env');
1383
- if (fs.existsSync(rootEnvPath) && !fs.existsSync(worktreeEnvPath)) {
1384
- try {
1385
- fs.symlinkSync(rootEnvPath, worktreeEnvPath);
1386
- }
1387
- catch {
1388
- // Non-fatal: continue without .env symlink
1389
- }
1390
- }
1391
- cwd = worktreePath;
1392
- }
1393
- catch (gitError) {
1394
- const errorMsg = gitError instanceof Error
1395
- ? gitError.stderr?.toString() || gitError.message
1396
- : 'Unknown error';
1397
- res.writeHead(200, { 'Content-Type': 'application/json' });
1398
- res.end(JSON.stringify({
1399
- success: false,
1400
- error: `Git worktree creation failed: ${errorMsg}`
1401
- }));
1402
- return;
1403
- }
1404
- }
1405
- // Generate ID and name
1406
- const id = generateId('U');
1407
- const utilName = name || (worktree ? `worktree-${shellState.utils.length + 1}` : `shell-${shellState.utils.length + 1}`);
1408
- const sessionName = `af-shell-${id}`;
1409
- // Get shell command - if command provided, run it then keep shell open
1410
- const shell = process.env.SHELL || '/bin/bash';
1411
- const shellCommand = command
1412
- ? `${shell} -c '${command.replace(/'/g, "'\\''")}; exec ${shell}'`
1413
- : shell;
1414
- // Create PTY terminal session via node-pty
1415
- const terminalId = await createTerminalSession(shellCommand, cwd, `shell-${utilName}`);
1416
- if (!terminalId) {
1417
- res.writeHead(500, { 'Content-Type': 'text/plain' });
1418
- res.end('Failed to create terminal session');
1419
- return;
1420
- }
1421
- const util = {
1422
- id,
1423
- name: utilName,
1424
- port: 0,
1425
- pid: 0,
1426
- tmuxSession: sessionName,
1427
- worktreePath: worktreePath,
1428
- terminalId,
1429
- };
1430
- addUtil(util);
1431
- res.writeHead(201, { 'Content-Type': 'application/json' });
1432
- res.end(JSON.stringify({ success: true, id, port: 0, name: utilName, terminalId }));
1433
- return;
1434
- }
1435
- // API: Check if tab process is running (Bugfix #132)
1436
- if (req.method === 'GET' && url.pathname.match(/^\/api\/tabs\/[^/]+\/running$/)) {
1437
- const match = url.pathname.match(/^\/api\/tabs\/([^/]+)\/running$/);
1438
- if (!match) {
1439
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1440
- res.end('Invalid tab ID');
1441
- return;
1442
- }
1443
- const tabId = decodeURIComponent(match[1]);
1444
- let running = false;
1445
- let found = false;
1446
- // Check if it's a shell tab
1447
- if (tabId.startsWith('shell-')) {
1448
- const utilId = tabId.replace('shell-', '');
1449
- const tabUtils = getUtils();
1450
- const util = tabUtils.find((u) => u.id === utilId);
1451
- if (util) {
1452
- found = true;
1453
- // Check tmux session status (Spec 0076)
1454
- if (util.tmuxSession) {
1455
- running = tmuxSessionExists(util.tmuxSession);
1456
- }
1457
- else {
1458
- // Fallback for shells without tmux session (shouldn't happen in practice)
1459
- running = isProcessRunning(util.pid);
1460
- }
1461
- }
1462
- }
1463
- // Check if it's a builder tab
1464
- if (tabId.startsWith('builder-')) {
1465
- const builderId = tabId.replace('builder-', '');
1466
- const builder = getBuilder(builderId);
1467
- if (builder) {
1468
- found = true;
1469
- // Check tmux session status (Spec 0076)
1470
- if (builder.tmuxSession) {
1471
- running = tmuxSessionExists(builder.tmuxSession);
1472
- }
1473
- else {
1474
- // Fallback for builders without tmux session (shouldn't happen in practice)
1475
- running = isProcessRunning(builder.pid);
1476
- }
1477
- }
1478
- }
1479
- if (found) {
1480
- res.writeHead(200, { 'Content-Type': 'application/json' });
1481
- res.end(JSON.stringify({ running }));
1482
- }
1483
- else {
1484
- res.writeHead(404, { 'Content-Type': 'application/json' });
1485
- res.end(JSON.stringify({ running: false }));
1486
- }
1487
- return;
1488
- }
1489
- // API: Close tab
1490
- if (req.method === 'DELETE' && url.pathname.startsWith('/api/tabs/')) {
1491
- const tabId = decodeURIComponent(url.pathname.replace('/api/tabs/', ''));
1492
- let found = false;
1493
- // Check if it's a file tab
1494
- if (tabId.startsWith('file-')) {
1495
- const annotationId = tabId.replace('file-', '');
1496
- const tabAnnotations = getAnnotations();
1497
- const annotation = tabAnnotations.find((a) => a.id === annotationId);
1498
- if (annotation) {
1499
- await killProcessGracefully(annotation.pid);
1500
- removeAnnotation(annotationId);
1501
- found = true;
1502
- }
1503
- }
1504
- // Check if it's a builder tab
1505
- if (tabId.startsWith('builder-')) {
1506
- const builderId = tabId.replace('builder-', '');
1507
- const builder = getBuilder(builderId);
1508
- if (builder) {
1509
- await killProcessGracefully(builder.pid);
1510
- removeBuilder(builderId);
1511
- found = true;
1512
- }
1513
- }
1514
- // Check if it's a shell tab
1515
- if (tabId.startsWith('shell-')) {
1516
- const utilId = tabId.replace('shell-', '');
1517
- const tabUtils = getUtils();
1518
- const util = tabUtils.find((u) => u.id === utilId);
1519
- if (util) {
1520
- // Kill PTY session if present
1521
- if (util.terminalId && terminalManager) {
1522
- terminalManager.killSession(util.terminalId);
1523
- }
1524
- await killProcessGracefully(util.pid, util.tmuxSession);
1525
- // Note: worktrees are NOT cleaned up on tab close - they may contain useful context
1526
- // Users can manually clean up with `git worktree list` and `git worktree remove`
1527
- removeUtil(utilId);
1528
- found = true;
1529
- }
1530
- }
1531
- if (found) {
1532
- res.writeHead(200, { 'Content-Type': 'application/json' });
1533
- res.end(JSON.stringify({ success: true }));
1534
- }
1535
- else {
1536
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1537
- res.end('Tab not found');
1538
- }
1539
- return;
1540
- }
1541
- // API: Stop all
1542
- if (req.method === 'POST' && url.pathname === '/api/stop') {
1543
- const stopState = loadState();
1544
- // Kill all tmux sessions first
1545
- for (const util of stopState.utils) {
1546
- if (util.tmuxSession) {
1547
- killTmuxSession(util.tmuxSession);
1548
- }
1549
- }
1550
- if (stopState.architect?.tmuxSession) {
1551
- killTmuxSession(stopState.architect.tmuxSession);
1552
- }
1553
- // Kill all processes gracefully
1554
- const pids = [];
1555
- if (stopState.architect) {
1556
- pids.push(stopState.architect.pid);
1557
- }
1558
- for (const builder of stopState.builders) {
1559
- pids.push(builder.pid);
1560
- }
1561
- for (const util of stopState.utils) {
1562
- pids.push(util.pid);
1563
- }
1564
- for (const annotation of stopState.annotations) {
1565
- pids.push(annotation.pid);
1566
- }
1567
- // Kill all processes in parallel
1568
- await Promise.all(pids.map((pid) => killProcessGracefully(pid)));
1569
- // Clear state
1570
- clearState();
1571
- res.writeHead(200, { 'Content-Type': 'application/json' });
1572
- res.end(JSON.stringify({ success: true, killed: pids.length }));
1573
- // Exit after a short delay
1574
- setTimeout(() => process.exit(0), 500);
1575
- return;
1576
- }
1577
- // Open file route - handles file clicks from terminal
1578
- // Returns a small HTML page that messages the dashboard via BroadcastChannel
1579
- if (req.method === 'GET' && url.pathname === '/open-file') {
1580
- const filePath = url.searchParams.get('path');
1581
- const line = url.searchParams.get('line');
1582
- const sourcePort = url.searchParams.get('sourcePort');
1583
- if (!filePath) {
1584
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1585
- res.end('Missing path parameter');
1586
- return;
1587
- }
1588
- // Determine base path for relative path resolution
1589
- // If sourcePort is provided, look up the builder/util to get its worktree
1590
- let basePath = projectRoot;
1591
- if (sourcePort) {
1592
- const portNum = parseInt(sourcePort, 10);
1593
- const builders = getBuilders();
1594
- // Check if it's a builder terminal
1595
- const builder = builders.find((b) => b.port === portNum);
1596
- if (builder && builder.worktree) {
1597
- basePath = builder.worktree;
1598
- }
1599
- // Check if it's a utility terminal (they run in project root, so no change needed)
1600
- // Architect terminal also runs in project root
1601
- }
1602
- // Validate path is within project (or builder worktree)
1603
- // For relative paths, resolve against the determined base path
1604
- let fullPath;
1605
- if (filePath.startsWith('/')) {
1606
- // Absolute path - validate against project root
1607
- fullPath = validatePathWithinProject(filePath);
1608
- }
1609
- else {
1610
- // Relative path - resolve against base path, then validate
1611
- const resolvedPath = path.resolve(basePath, filePath);
1612
- // For builder worktrees, the path is within project root (worktrees are under .builders/)
1613
- fullPath = validatePathWithinProject(resolvedPath);
1614
- }
1615
- if (!fullPath) {
1616
- res.writeHead(403, { 'Content-Type': 'text/plain' });
1617
- res.end('Path must be within project directory');
1618
- return;
1619
- }
1620
- // Check file exists
1621
- if (!fs.existsSync(fullPath)) {
1622
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1623
- res.end(`File not found: ${filePath}`);
1624
- return;
1625
- }
1626
- // HTML-escape the file path for safe display (uses imported escapeHtml from server-utils.js)
1627
- const safeFilePath = escapeHtml(filePath);
1628
- const safeLineDisplay = line ? ':' + escapeHtml(line) : '';
1629
- // Serve a small HTML page that communicates back to dashboard
1630
- // Note: We only use BroadcastChannel, not API call (dashboard handles tab creation)
1631
- const html = `<!DOCTYPE html>
1632
- <html>
1633
- <head>
1634
- <title>Opening file...</title>
1635
- <style>
1636
- body {
1637
- font-family: system-ui;
1638
- background: #1a1a1a;
1639
- color: #ccc;
1640
- display: flex;
1641
- align-items: center;
1642
- justify-content: center;
1643
- height: 100vh;
1644
- margin: 0;
1645
- }
1646
- .message { text-align: center; }
1647
- .path { color: #3b82f6; font-family: monospace; margin: 8px 0; }
1648
- </style>
1649
- </head>
1650
- <body>
1651
- <div class="message">
1652
- <p>Opening file...</p>
1653
- <p class="path">${safeFilePath}${safeLineDisplay}</p>
1654
- </div>
1655
- <script>
1656
- (async function() {
1657
- const path = ${JSON.stringify(fullPath)};
1658
- const line = ${line ? parseInt(line, 10) : 'null'};
1659
-
1660
- // Use BroadcastChannel to message the dashboard
1661
- // Dashboard will handle opening the file tab
1662
- const channel = new BroadcastChannel('agent-farm');
1663
- channel.postMessage({
1664
- type: 'openFile',
1665
- path: path,
1666
- line: line
1667
- });
1668
-
1669
- // Close this window/tab after a short delay
1670
- setTimeout(() => {
1671
- window.close();
1672
- // If window.close() doesn't work (wasn't opened by script),
1673
- // show success message
1674
- document.body.innerHTML = '<div class="message"><p>File opened in dashboard</p><p class="path">You can close this tab</p></div>';
1675
- }, 500);
1676
- })();
1677
- </script>
1678
- </body>
1679
- </html>`;
1680
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1681
- res.end(html);
1682
- return;
1683
- }
1684
- // API: Check if projectlist.md exists (for starter page polling)
1685
- if (req.method === 'GET' && url.pathname === '/api/projectlist-exists') {
1686
- const projectlistPath = path.join(projectRoot, 'codev/projectlist.md');
1687
- const exists = fs.existsSync(projectlistPath);
1688
- res.writeHead(200, { 'Content-Type': 'application/json' });
1689
- res.end(JSON.stringify({ exists }));
1690
- return;
1691
- }
1692
- // Read file contents (for Projects tab to read projectlist.md)
1693
- if (req.method === 'GET' && url.pathname === '/file') {
1694
- const filePath = url.searchParams.get('path');
1695
- if (!filePath) {
1696
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1697
- res.end('Missing path parameter');
1698
- return;
1699
- }
1700
- // Validate path is within project root (prevent path traversal)
1701
- const fullPath = validatePathWithinProject(filePath);
1702
- if (!fullPath) {
1703
- res.writeHead(403, { 'Content-Type': 'text/plain' });
1704
- res.end('Path must be within project directory');
1705
- return;
1706
- }
1707
- // Check file exists
1708
- if (!fs.existsSync(fullPath)) {
1709
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1710
- res.end(`File not found: ${filePath}`);
1711
- return;
1712
- }
1713
- // Check if it's a directory
1714
- const stat = fs.statSync(fullPath);
1715
- if (stat.isDirectory()) {
1716
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1717
- res.end(`Cannot read directory as file: ${filePath}`);
1718
- return;
1719
- }
1720
- // Read and return file contents
1721
- try {
1722
- const content = fs.readFileSync(fullPath, 'utf-8');
1723
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
1724
- res.end(content);
1725
- }
1726
- catch (err) {
1727
- res.writeHead(500, { 'Content-Type': 'text/plain' });
1728
- res.end('Error reading file: ' + err.message);
1729
- }
1730
- return;
1731
- }
1732
- // API: Get directory tree for file browser (Spec 0055)
1733
- if (req.method === 'GET' && url.pathname === '/api/files') {
1734
- // Directories to exclude from the tree
1735
- const EXCLUDED_DIRS = new Set([
1736
- 'node_modules',
1737
- '.git',
1738
- 'dist',
1739
- '__pycache__',
1740
- '.next',
1741
- '.nuxt',
1742
- '.turbo',
1743
- 'coverage',
1744
- '.nyc_output',
1745
- '.cache',
1746
- '.parcel-cache',
1747
- 'build',
1748
- '.svelte-kit',
1749
- 'vendor',
1750
- '.venv',
1751
- 'venv',
1752
- 'env',
1753
- ]);
1754
- // Recursively build directory tree
1755
- function buildTree(dirPath, relativePath = '') {
1756
- const entries = [];
1757
- try {
1758
- const items = fs.readdirSync(dirPath, { withFileTypes: true });
1759
- for (const item of items) {
1760
- // Skip excluded directories only (allow dotfiles like .github, .eslintrc, etc.)
1761
- if (EXCLUDED_DIRS.has(item.name))
1762
- continue;
1763
- const itemRelPath = relativePath ? `${relativePath}/${item.name}` : item.name;
1764
- const itemFullPath = path.join(dirPath, item.name);
1765
- if (item.isDirectory()) {
1766
- const children = buildTree(itemFullPath, itemRelPath);
1767
- entries.push({
1768
- name: item.name,
1769
- path: itemRelPath,
1770
- type: 'dir',
1771
- children,
1772
- });
1773
- }
1774
- else if (item.isFile()) {
1775
- entries.push({
1776
- name: item.name,
1777
- path: itemRelPath,
1778
- type: 'file',
1779
- });
1780
- }
1781
- }
1782
- }
1783
- catch (err) {
1784
- // Ignore permission errors or inaccessible directories
1785
- console.error(`Error reading directory ${dirPath}:`, err.message);
1786
- }
1787
- // Sort: directories first, then files, alphabetically within each group
1788
- entries.sort((a, b) => {
1789
- if (a.type === 'dir' && b.type === 'file')
1790
- return -1;
1791
- if (a.type === 'file' && b.type === 'dir')
1792
- return 1;
1793
- return a.name.localeCompare(b.name);
1794
- });
1795
- return entries;
1796
- }
1797
- const tree = buildTree(projectRoot);
1798
- res.writeHead(200, { 'Content-Type': 'application/json' });
1799
- res.end(JSON.stringify(tree));
1800
- return;
1801
- }
1802
- // API: Get hash of file tree for change detection (auto-refresh)
1803
- if (req.method === 'GET' && url.pathname === '/api/files/hash') {
1804
- // Build a lightweight hash based on directory mtimes
1805
- // This is faster than building the full tree
1806
- function getTreeHash(dirPath) {
1807
- const EXCLUDED_DIRS = new Set([
1808
- 'node_modules', '.git', 'dist', '__pycache__', '.next',
1809
- '.nuxt', '.turbo', 'coverage', '.nyc_output', '.cache',
1810
- '.parcel-cache', 'build', '.svelte-kit', 'vendor', '.venv', 'venv', 'env',
1811
- ]);
1812
- let hash = '';
1813
- function walk(dir) {
1814
- try {
1815
- const stat = fs.statSync(dir);
1816
- hash += `${dir}:${stat.mtimeMs};`;
1817
- const items = fs.readdirSync(dir, { withFileTypes: true });
1818
- for (const item of items) {
1819
- if (EXCLUDED_DIRS.has(item.name))
1820
- continue;
1821
- if (item.isDirectory()) {
1822
- walk(path.join(dir, item.name));
1823
- }
1824
- else if (item.isFile()) {
1825
- // Include file mtime for change detection
1826
- const fileStat = fs.statSync(path.join(dir, item.name));
1827
- hash += `${item.name}:${fileStat.mtimeMs};`;
1828
- }
1829
- }
1830
- }
1831
- catch {
1832
- // Ignore errors
1833
- }
1834
- }
1835
- walk(dirPath);
1836
- // Simple hash: sum of char codes
1837
- let sum = 0;
1838
- for (let i = 0; i < hash.length; i++) {
1839
- sum = ((sum << 5) - sum + hash.charCodeAt(i)) | 0;
1840
- }
1841
- return sum.toString(16);
1842
- }
1843
- const hash = getTreeHash(projectRoot);
1844
- res.writeHead(200, { 'Content-Type': 'application/json' });
1845
- res.end(JSON.stringify({ hash }));
1846
- return;
1847
- }
1848
- // API: Create a new file (Bugfix #131)
1849
- if (req.method === 'POST' && url.pathname === '/api/files') {
1850
- const body = await parseJsonBody(req);
1851
- const filePath = body.path;
1852
- const content = body.content || '';
1853
- if (!filePath) {
1854
- res.writeHead(400, { 'Content-Type': 'application/json' });
1855
- res.end(JSON.stringify({ error: 'Missing path' }));
1856
- return;
1857
- }
1858
- // Validate path is within project root (prevent path traversal)
1859
- const fullPath = validatePathWithinProject(filePath);
1860
- if (!fullPath) {
1861
- res.writeHead(403, { 'Content-Type': 'application/json' });
1862
- res.end(JSON.stringify({ error: 'Path must be within project directory' }));
1863
- return;
1864
- }
1865
- // Check if file already exists
1866
- if (fs.existsSync(fullPath)) {
1867
- res.writeHead(409, { 'Content-Type': 'application/json' });
1868
- res.end(JSON.stringify({ error: 'File already exists' }));
1869
- return;
1870
- }
1871
- // Additional security: validate parent directories don't symlink outside project
1872
- // Find the deepest existing parent and ensure it's within project
1873
- let checkDir = path.dirname(fullPath);
1874
- while (checkDir !== projectRoot && !fs.existsSync(checkDir)) {
1875
- checkDir = path.dirname(checkDir);
1876
- }
1877
- if (fs.existsSync(checkDir) && checkDir !== projectRoot) {
1878
- try {
1879
- const realParent = fs.realpathSync(checkDir);
1880
- if (!realParent.startsWith(projectRoot + path.sep) && realParent !== projectRoot) {
1881
- res.writeHead(403, { 'Content-Type': 'application/json' });
1882
- res.end(JSON.stringify({ error: 'Path must be within project directory' }));
1883
- return;
1884
- }
1885
- }
1886
- catch {
1887
- res.writeHead(403, { 'Content-Type': 'application/json' });
1888
- res.end(JSON.stringify({ error: 'Cannot resolve path' }));
1889
- return;
1890
- }
1891
- }
1892
- try {
1893
- // Create parent directories if they don't exist
1894
- const parentDir = path.dirname(fullPath);
1895
- if (!fs.existsSync(parentDir)) {
1896
- fs.mkdirSync(parentDir, { recursive: true });
1897
- }
1898
- // Write the file
1899
- fs.writeFileSync(fullPath, content, 'utf-8');
1900
- res.writeHead(201, { 'Content-Type': 'application/json' });
1901
- res.end(JSON.stringify({ success: true, path: filePath }));
1902
- }
1903
- catch (err) {
1904
- console.error('Error creating file:', err.message);
1905
- res.writeHead(500, { 'Content-Type': 'application/json' });
1906
- res.end(JSON.stringify({ error: 'Failed to create file: ' + err.message }));
1907
- }
1908
- return;
1909
- }
1910
- // API: Hot reload check (Spec 0060)
1911
- // Returns modification times for all dashboard CSS/JS files
1912
- if (req.method === 'GET' && url.pathname === '/api/hot-reload') {
1913
- try {
1914
- const dashboardDir = path.join(__dirname, '../../../templates/dashboard');
1915
- const cssDir = path.join(dashboardDir, 'css');
1916
- const jsDir = path.join(dashboardDir, 'js');
1917
- const mtimes = {};
1918
- // Collect CSS file modification times
1919
- if (fs.existsSync(cssDir)) {
1920
- for (const file of fs.readdirSync(cssDir)) {
1921
- if (file.endsWith('.css')) {
1922
- const stat = fs.statSync(path.join(cssDir, file));
1923
- mtimes[`css/${file}`] = stat.mtimeMs;
1924
- }
1925
- }
1926
- }
1927
- // Collect JS file modification times
1928
- if (fs.existsSync(jsDir)) {
1929
- for (const file of fs.readdirSync(jsDir)) {
1930
- if (file.endsWith('.js')) {
1931
- const stat = fs.statSync(path.join(jsDir, file));
1932
- mtimes[`js/${file}`] = stat.mtimeMs;
1933
- }
1934
- }
1935
- }
1936
- res.writeHead(200, { 'Content-Type': 'application/json' });
1937
- res.end(JSON.stringify({ mtimes }));
1938
- }
1939
- catch (err) {
1940
- console.error('Hot reload check error:', err);
1941
- res.writeHead(500, { 'Content-Type': 'application/json' });
1942
- res.end(JSON.stringify({ error: err.message }));
1943
- }
1944
- return;
1945
- }
1946
- // Serve dashboard CSS files
1947
- if (req.method === 'GET' && url.pathname.startsWith('/dashboard/css/')) {
1948
- const filename = url.pathname.replace('/dashboard/css/', '');
1949
- // Validate filename to prevent path traversal
1950
- if (!filename || filename.includes('..') || filename.includes('/') || !filename.endsWith('.css')) {
1951
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1952
- res.end('Invalid filename');
1953
- return;
1954
- }
1955
- const cssPath = path.join(__dirname, '../../../templates/dashboard/css', filename);
1956
- if (fs.existsSync(cssPath)) {
1957
- const content = fs.readFileSync(cssPath, 'utf-8');
1958
- res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8' });
1959
- res.end(content);
1960
- return;
1961
- }
1962
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1963
- res.end('CSS file not found');
1964
- return;
1965
- }
1966
- // Serve dashboard JS files
1967
- if (req.method === 'GET' && url.pathname.startsWith('/dashboard/js/')) {
1968
- const filename = url.pathname.replace('/dashboard/js/', '');
1969
- // Validate filename to prevent path traversal
1970
- if (!filename || filename.includes('..') || filename.includes('/') || !filename.endsWith('.js')) {
1971
- res.writeHead(400, { 'Content-Type': 'text/plain' });
1972
- res.end('Invalid filename');
1973
- return;
1974
- }
1975
- const jsPath = path.join(__dirname, '../../../templates/dashboard/js', filename);
1976
- if (fs.existsSync(jsPath)) {
1977
- const content = fs.readFileSync(jsPath, 'utf-8');
1978
- res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
1979
- res.end(content);
1980
- return;
1981
- }
1982
- res.writeHead(404, { 'Content-Type': 'text/plain' });
1983
- res.end('JS file not found');
1984
- return;
1985
- }
1986
- // Terminal proxy route (Spec 0062 - Secure Remote Access)
1987
- // Routes /terminal/:id to the appropriate terminal instance
1988
- const terminalMatch = url.pathname.match(/^\/terminal\/([^/]+)(\/.*)?$/);
1989
- if (terminalMatch) {
1990
- const terminalId = terminalMatch[1];
1991
- const terminalPort = getPortForTerminal(terminalId, loadState());
1992
- if (!terminalPort) {
1993
- res.writeHead(404, { 'Content-Type': 'application/json' });
1994
- res.end(JSON.stringify({ error: `Terminal not found: ${terminalId}` }));
1995
- return;
1996
- }
1997
- // Rewrite the URL to strip the /terminal/:id prefix
1998
- req.url = terminalMatch[2] || '/';
1999
- terminalProxy.web(req, res, { target: `http://localhost:${terminalPort}` });
2000
- return;
2001
- }
2002
- // Annotation proxy route (Spec 0062 - Secure Remote Access)
2003
- // Routes /annotation/:id to the appropriate open-server instance
2004
- const annotationMatch = url.pathname.match(/^\/annotation\/([^/]+)(\/.*)?$/);
2005
- if (annotationMatch) {
2006
- const annotationId = annotationMatch[1];
2007
- const annotations = getAnnotations();
2008
- const annotation = annotations.find((a) => a.id === annotationId);
2009
- if (!annotation) {
2010
- res.writeHead(404, { 'Content-Type': 'application/json' });
2011
- res.end(JSON.stringify({ error: `Annotation not found: ${annotationId}` }));
2012
- return;
2013
- }
2014
- // Rewrite the URL to strip the /annotation/:id prefix, preserving query string
2015
- const remainingPath = annotationMatch[2] || '/';
2016
- req.url = url.search ? `${remainingPath}${url.search}` : remainingPath;
2017
- terminalProxy.web(req, res, { target: `http://localhost:${annotation.port}` });
2018
- return;
2019
- }
2020
- // Serve dashboard (Spec 0085: React or legacy based on config)
2021
- if (useReactDashboard && req.method === 'GET') {
2022
- // Serve React dashboard static files
2023
- const filePath = url.pathname === '/' || url.pathname === '/index.html'
2024
- ? path.join(reactDashboardPath, 'index.html')
2025
- : path.join(reactDashboardPath, url.pathname);
2026
- // Security: Prevent path traversal
2027
- const resolved = path.resolve(filePath);
2028
- if (!resolved.startsWith(reactDashboardPath)) {
2029
- res.writeHead(403, { 'Content-Type': 'text/plain' });
2030
- res.end('Forbidden');
2031
- return;
2032
- }
2033
- if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
2034
- const ext = path.extname(resolved);
2035
- const mimeTypes = {
2036
- '.html': 'text/html; charset=utf-8',
2037
- '.js': 'application/javascript',
2038
- '.css': 'text/css',
2039
- '.json': 'application/json',
2040
- '.svg': 'image/svg+xml',
2041
- '.png': 'image/png',
2042
- '.ico': 'image/x-icon',
2043
- '.map': 'application/json',
2044
- };
2045
- const contentType = mimeTypes[ext] ?? 'application/octet-stream';
2046
- // Cache static assets (hashed filenames) but not index.html
2047
- if (ext !== '.html') {
2048
- res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
2049
- }
2050
- res.writeHead(200, { 'Content-Type': contentType });
2051
- fs.createReadStream(resolved).pipe(res);
2052
- return;
2053
- }
2054
- // SPA fallback: serve index.html for client-side routing
2055
- if (!url.pathname.startsWith('/api/') && !url.pathname.startsWith('/ws/') && !url.pathname.startsWith('/terminal/') && !url.pathname.startsWith('/annotation/')) {
2056
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2057
- fs.createReadStream(path.join(reactDashboardPath, 'index.html')).pipe(res);
2058
- return;
2059
- }
2060
- }
2061
- if (!useReactDashboard && req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
2062
- // Legacy vanilla JS dashboard
2063
- try {
2064
- let template = fs.readFileSync(templatePath, 'utf-8');
2065
- const state = loadStateWithCleanup();
2066
- // Inject project name into template (HTML-escaped for security)
2067
- const projectName = escapeHtml(getProjectName(projectRoot));
2068
- template = template.replace(/\{\{PROJECT_NAME\}\}/g, projectName);
2069
- // Inject state into template
2070
- const stateJson = JSON.stringify(state);
2071
- template = template.replace('// STATE_INJECTION_POINT', `window.INITIAL_STATE = ${stateJson};`);
2072
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2073
- res.end(template);
2074
- }
2075
- catch (err) {
2076
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2077
- res.end('Error loading dashboard: ' + err.message);
2078
- }
2079
- return;
2080
- }
2081
- // 404 for everything else
2082
- res.writeHead(404, { 'Content-Type': 'text/plain' });
2083
- res.end('Not found');
2084
- }
2085
- catch (err) {
2086
- console.error('Request error:', err);
2087
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2088
- res.end('Internal server error: ' + err.message);
2089
- }
2090
- });
2091
- // Spec 0085: Attach node-pty WebSocket handler for /ws/terminal/:id routes
2092
- if (terminalManager) {
2093
- terminalManager.attachWebSocket(server);
2094
- }
2095
- // WebSocket upgrade handler for terminal proxy (Spec 0062)
2096
- // WebSocket for bidirectional terminal communication
2097
- server.on('upgrade', (req, socket, head) => {
2098
- // Security check for non-auth mode
2099
- const host = req.headers.host;
2100
- if (!insecureRemoteMode && !process.env.CODEV_WEB_KEY && host && !host.startsWith('localhost') && !host.startsWith('127.0.0.1')) {
2101
- socket.destroy();
2102
- return;
2103
- }
2104
- // CRITICAL: When CODEV_WEB_KEY is set, ALL WebSocket upgrades require auth
2105
- // NO localhost bypass - tunnel daemons run locally, so remoteAddress is unreliable
2106
- const webKey = process.env.CODEV_WEB_KEY;
2107
- if (webKey && !insecureRemoteMode) {
2108
- // Check Sec-WebSocket-Protocol for auth token
2109
- // Format: "auth-<token>, tty" or just "tty"
2110
- const protocols = req.headers['sec-websocket-protocol']?.split(',').map((p) => p.trim()) || [];
2111
- const authProtocol = protocols.find((p) => p.startsWith('auth-'));
2112
- const token = authProtocol?.substring(5); // Remove 'auth-' prefix
2113
- if (!isValidToken(token, webKey)) {
2114
- socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
2115
- socket.destroy();
2116
- return;
2117
- }
2118
- // Remove auth protocol from the list before forwarding
2119
- const cleanProtocols = protocols.filter((p) => !p.startsWith('auth-'));
2120
- req.headers['sec-websocket-protocol'] = cleanProtocols.join(', ') || 'tty';
2121
- }
2122
- const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
2123
- const terminalMatch = reqUrl.pathname.match(/^\/terminal\/([^/]+)(\/.*)?$/);
2124
- if (terminalMatch) {
2125
- const terminalId = terminalMatch[1];
2126
- const terminalPort = getPortForTerminal(terminalId, loadState());
2127
- if (terminalPort) {
2128
- // Rewrite URL to strip /terminal/:id prefix
2129
- req.url = terminalMatch[2] || '/';
2130
- terminalProxy.ws(req, socket, head, { target: `http://localhost:${terminalPort}` });
2131
- }
2132
- else {
2133
- // Terminal not found - close the socket
2134
- socket.destroy();
2135
- }
2136
- }
2137
- // Non-terminal WebSocket requests are ignored (socket will time out)
2138
- });
2139
- // Handle WebSocket proxy errors separately
2140
- terminalProxy.on('error', (err, req, socket) => {
2141
- console.error('WebSocket proxy error:', err.message);
2142
- if (socket && 'destroy' in socket && typeof socket.destroy === 'function' && !socket.destroyed) {
2143
- socket.destroy();
2144
- }
2145
- });
2146
- // Handle server errors (e.g., port already in use)
2147
- server.on('error', (err) => {
2148
- if (err.code === 'EADDRINUSE') {
2149
- console.error(`Error: Port ${port} is already in use.`);
2150
- console.error(`Run 'lsof -i :${port}' to find the process, or use 'af ports cleanup' to clean up orphans.`);
2151
- process.exit(1);
2152
- }
2153
- else {
2154
- console.error(`Server error: ${err.message}`);
2155
- process.exit(1);
2156
- }
2157
- });
2158
- if (bindHost) {
2159
- server.listen(port, bindHost, () => {
2160
- console.log(`Dashboard: http://${bindHost}:${port}`);
2161
- });
2162
- }
2163
- else {
2164
- server.listen(port, () => {
2165
- console.log(`Dashboard: http://localhost:${port}`);
2166
- });
2167
- }
2168
- // Spec 0085: Graceful shutdown for node-pty terminal manager
2169
- process.on('SIGTERM', () => {
2170
- if (terminalManager) {
2171
- terminalManager.shutdown();
2172
- }
2173
- process.exit(0);
2174
- });
2175
- process.on('SIGINT', () => {
2176
- if (terminalManager) {
2177
- terminalManager.shutdown();
2178
- }
2179
- process.exit(0);
2180
- });
2181
- //# sourceMappingURL=dashboard-server.js.map