@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
@@ -12,13 +12,177 @@ import { spawn, execSync } from 'node:child_process';
12
12
  import { homedir } from 'node:os';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { Command } from 'commander';
15
+ import { WebSocketServer, WebSocket } from 'ws';
15
16
  import { getGlobalDb } from '../db/index.js';
16
17
  import { cleanupStaleEntries } from '../utils/port-registry.js';
17
18
  import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js';
19
+ import { TerminalManager } from '../../terminal/pty-manager.js';
20
+ import { encodeData, encodeControl, decodeFrame } from '../../terminal/ws-protocol.js';
18
21
  const __filename = fileURLToPath(import.meta.url);
19
22
  const __dirname = path.dirname(__filename);
20
23
  // Default port for tower dashboard
21
24
  const DEFAULT_PORT = 4100;
25
+ // Rate limiting for activation requests (Spec 0090 Phase 1)
26
+ // Simple in-memory rate limiter: 10 activations per minute per client
27
+ const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
28
+ const RATE_LIMIT_MAX = 10;
29
+ const activationRateLimits = new Map();
30
+ /**
31
+ * Check if a client has exceeded the rate limit for activations
32
+ * Returns true if rate limit exceeded, false if allowed
33
+ */
34
+ function isRateLimited(clientIp) {
35
+ const now = Date.now();
36
+ const entry = activationRateLimits.get(clientIp);
37
+ if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
38
+ // New window
39
+ activationRateLimits.set(clientIp, { count: 1, windowStart: now });
40
+ return false;
41
+ }
42
+ if (entry.count >= RATE_LIMIT_MAX) {
43
+ return true;
44
+ }
45
+ entry.count++;
46
+ return false;
47
+ }
48
+ /**
49
+ * Clean up old rate limit entries periodically
50
+ */
51
+ function cleanupRateLimits() {
52
+ const now = Date.now();
53
+ for (const [ip, entry] of activationRateLimits.entries()) {
54
+ if (now - entry.windowStart >= RATE_LIMIT_WINDOW_MS * 2) {
55
+ activationRateLimits.delete(ip);
56
+ }
57
+ }
58
+ }
59
+ // Cleanup stale rate limit entries every 5 minutes
60
+ setInterval(cleanupRateLimits, 5 * 60 * 1000);
61
+ // ============================================================================
62
+ // PHASE 2 & 4: Terminal Management (Spec 0090)
63
+ // ============================================================================
64
+ // Global TerminalManager instance for tower-managed terminals
65
+ // Uses a temporary directory as projectRoot since terminals can be for any project
66
+ let terminalManager = null;
67
+ const projectTerminals = new Map();
68
+ /**
69
+ * Get or create project terminal registry entry
70
+ */
71
+ function getProjectTerminalsEntry(projectPath) {
72
+ let entry = projectTerminals.get(projectPath);
73
+ if (!entry) {
74
+ entry = { builders: new Map(), shells: new Map() };
75
+ projectTerminals.set(projectPath, entry);
76
+ }
77
+ return entry;
78
+ }
79
+ /**
80
+ * Generate next shell ID for a project
81
+ */
82
+ function getNextShellId(projectPath) {
83
+ const entry = getProjectTerminalsEntry(projectPath);
84
+ let maxId = 0;
85
+ for (const id of entry.shells.keys()) {
86
+ const num = parseInt(id.replace('shell-', ''), 10);
87
+ if (!isNaN(num) && num > maxId)
88
+ maxId = num;
89
+ }
90
+ return `shell-${maxId + 1}`;
91
+ }
92
+ /**
93
+ * Get or create the global TerminalManager instance
94
+ */
95
+ function getTerminalManager() {
96
+ if (!terminalManager) {
97
+ // Use a neutral projectRoot - terminals specify their own cwd
98
+ const projectRoot = process.env.HOME || '/tmp';
99
+ terminalManager = new TerminalManager({
100
+ projectRoot,
101
+ logDir: path.join(homedir(), '.agent-farm', 'logs'),
102
+ maxSessions: 100,
103
+ ringBufferLines: 1000,
104
+ diskLogEnabled: true,
105
+ diskLogMaxBytes: 50 * 1024 * 1024,
106
+ reconnectTimeoutMs: 300_000,
107
+ });
108
+ }
109
+ return terminalManager;
110
+ }
111
+ /**
112
+ * Handle WebSocket connection to a terminal session
113
+ * Uses hybrid binary protocol (Spec 0085):
114
+ * - 0x00 prefix: Control frame (JSON)
115
+ * - 0x01 prefix: Data frame (raw PTY bytes)
116
+ */
117
+ function handleTerminalWebSocket(ws, session, req) {
118
+ const resumeSeq = req.headers['x-session-resume'];
119
+ // Create a client adapter for the PTY session
120
+ // Uses binary protocol for data frames
121
+ const client = {
122
+ send: (data) => {
123
+ if (ws.readyState === WebSocket.OPEN) {
124
+ // Encode as binary data frame (0x01 prefix)
125
+ ws.send(encodeData(data));
126
+ }
127
+ },
128
+ };
129
+ // Attach client to session and get replay data
130
+ let replayLines;
131
+ if (resumeSeq && typeof resumeSeq === 'string') {
132
+ replayLines = session.attachResume(client, parseInt(resumeSeq, 10));
133
+ }
134
+ else {
135
+ replayLines = session.attach(client);
136
+ }
137
+ // Send replay data as binary data frame
138
+ if (replayLines.length > 0) {
139
+ const replayData = replayLines.join('\n');
140
+ if (ws.readyState === WebSocket.OPEN) {
141
+ ws.send(encodeData(replayData));
142
+ }
143
+ }
144
+ // Handle incoming messages from client (binary protocol)
145
+ ws.on('message', (rawData) => {
146
+ try {
147
+ const frame = decodeFrame(Buffer.from(rawData));
148
+ if (frame.type === 'data') {
149
+ // Write raw input to terminal
150
+ session.write(frame.data.toString('utf-8'));
151
+ }
152
+ else if (frame.type === 'control') {
153
+ // Handle control messages
154
+ const msg = frame.message;
155
+ if (msg.type === 'resize') {
156
+ const cols = msg.payload.cols;
157
+ const rows = msg.payload.rows;
158
+ if (typeof cols === 'number' && typeof rows === 'number') {
159
+ session.resize(cols, rows);
160
+ }
161
+ }
162
+ else if (msg.type === 'ping') {
163
+ if (ws.readyState === WebSocket.OPEN) {
164
+ ws.send(encodeControl({ type: 'pong', payload: {} }));
165
+ }
166
+ }
167
+ }
168
+ }
169
+ catch {
170
+ // If decode fails, try treating as raw UTF-8 input (for simpler clients)
171
+ try {
172
+ session.write(rawData.toString('utf-8'));
173
+ }
174
+ catch {
175
+ // Ignore malformed input
176
+ }
177
+ }
178
+ });
179
+ ws.on('close', () => {
180
+ session.detach(client);
181
+ });
182
+ ws.on('error', () => {
183
+ session.detach(client);
184
+ });
185
+ }
22
186
  // Parse arguments with Commander
23
187
  const program = new Command()
24
188
  .name('tower-server')
@@ -63,9 +227,31 @@ process.on('unhandledRejection', (reason) => {
63
227
  log('ERROR', `Unhandled rejection: ${message}`);
64
228
  process.exit(1);
65
229
  });
230
+ // Graceful shutdown handler (Phase 2 - Spec 0090)
231
+ async function gracefulShutdown(signal) {
232
+ log('INFO', `Received ${signal}, starting graceful shutdown...`);
233
+ // 1. Stop accepting new connections
234
+ server?.close();
235
+ // 2. Close all WebSocket connections
236
+ if (terminalWss) {
237
+ for (const client of terminalWss.clients) {
238
+ client.close(1001, 'Server shutting down');
239
+ }
240
+ terminalWss.close();
241
+ }
242
+ // 3. Kill all PTY sessions
243
+ if (terminalManager) {
244
+ log('INFO', 'Shutting down terminal manager...');
245
+ terminalManager.shutdown();
246
+ }
247
+ // 4. Stop cloudflared tunnel if running
248
+ stopTunnel();
249
+ log('INFO', 'Graceful shutdown complete');
250
+ process.exit(0);
251
+ }
66
252
  // Catch signals for clean shutdown
67
- process.on('SIGINT', () => process.exit(0));
68
- process.on('SIGTERM', () => process.exit(0));
253
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
254
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
69
255
  if (isNaN(port) || port < 1 || port > 65535) {
70
256
  log('ERROR', `Invalid port "${portArg}". Must be a number between 1 and 65535.`);
71
257
  process.exit(1);
@@ -244,23 +430,21 @@ async function getGateStatusForProject(basePort) {
244
430
  return { hasGate: false };
245
431
  }
246
432
  /**
247
- * Fetch terminal list from a project's dashboard.
433
+ * Get terminal list for a project from tower's registry.
434
+ * Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server fetch.
248
435
  * Returns architect, builders, and shells with their URLs.
249
436
  */
250
- async function getTerminalsForProject(basePort, proxyUrl) {
251
- const controller = new AbortController();
252
- const timeout = setTimeout(() => controller.abort(), 2000);
253
- try {
254
- const response = await fetch(`http://localhost:${basePort}/api/state`, {
255
- signal: controller.signal,
256
- });
257
- clearTimeout(timeout);
258
- if (!response.ok)
259
- return { terminals: [], gateStatus: { hasGate: false } };
260
- const state = await response.json();
261
- const terminals = [];
262
- // Add architect terminal
263
- if (state.architect?.terminalId) {
437
+ function getTerminalsForProject(projectPath, proxyUrl) {
438
+ const entry = projectTerminals.get(projectPath);
439
+ const manager = getTerminalManager();
440
+ const terminals = [];
441
+ if (!entry) {
442
+ return { terminals: [], gateStatus: { hasGate: false } };
443
+ }
444
+ // Add architect terminal
445
+ if (entry.architect) {
446
+ const session = manager.getSession(entry.architect);
447
+ if (session) {
264
448
  terminals.push({
265
449
  type: 'architect',
266
450
  id: 'architect',
@@ -269,47 +453,43 @@ async function getTerminalsForProject(basePort, proxyUrl) {
269
453
  active: true,
270
454
  });
271
455
  }
272
- // Add builder terminals
273
- for (const builder of state.builders || []) {
274
- if (builder.terminalId) {
275
- const label = builder.projectId ? `Builder ${builder.projectId}` : `Builder ${builder.id}`;
456
+ }
457
+ // Add builder terminals
458
+ for (const [builderId] of entry.builders) {
459
+ const terminalId = entry.builders.get(builderId);
460
+ if (terminalId) {
461
+ const session = manager.getSession(terminalId);
462
+ if (session) {
276
463
  terminals.push({
277
464
  type: 'builder',
278
- id: builder.id,
279
- label,
280
- url: `${proxyUrl}?tab=builder-${builder.id}`,
465
+ id: builderId,
466
+ label: `Builder ${builderId}`,
467
+ url: `${proxyUrl}?tab=builder-${builderId}`,
281
468
  active: true,
282
469
  });
283
470
  }
284
471
  }
285
- // Add shell terminals
286
- for (const util of state.utils || []) {
287
- if (util.terminalId) {
472
+ }
473
+ // Add shell terminals
474
+ for (const [shellId] of entry.shells) {
475
+ const terminalId = entry.shells.get(shellId);
476
+ if (terminalId) {
477
+ const session = manager.getSession(terminalId);
478
+ if (session) {
288
479
  terminals.push({
289
480
  type: 'shell',
290
- id: util.id,
291
- label: util.name || `Shell ${util.id}`,
292
- url: `${proxyUrl}?tab=shell-${util.id}`,
481
+ id: shellId,
482
+ label: `Shell ${shellId.replace('shell-', '')}`,
483
+ url: `${proxyUrl}?tab=shell-${shellId}`,
293
484
  active: true,
294
485
  });
295
486
  }
296
487
  }
297
- // Check for pending gates
298
- const builderWithGate = state.builders?.find((b) => b.gateStatus?.waiting || b.status === 'gate-pending');
299
- const gateStatus = builderWithGate
300
- ? {
301
- hasGate: true,
302
- gateName: builderWithGate.gateStatus?.gateName || builderWithGate.currentGate,
303
- builderId: builderWithGate.id,
304
- timestamp: builderWithGate.gateStatus?.timestamp || Date.now(),
305
- }
306
- : { hasGate: false };
307
- return { terminals, gateStatus };
308
- }
309
- catch {
310
- // Project dashboard not responding or timeout
311
488
  }
312
- return { terminals: [], gateStatus: { hasGate: false } };
489
+ // Gate status - builders don't have gate tracking yet in tower
490
+ // TODO: Add gate status tracking when porch integration is updated
491
+ const gateStatus = { hasGate: false };
492
+ return { terminals, gateStatus };
313
493
  }
314
494
  /**
315
495
  * Get all instances with their status
@@ -330,10 +510,9 @@ async function getInstances() {
330
510
  // Encode project path for proxy URL
331
511
  const encodedPath = Buffer.from(allocation.project_path).toString('base64url');
332
512
  const proxyUrl = `/project/${encodedPath}/`;
333
- // Get terminals and gate status if running
334
- const { terminals, gateStatus } = dashboardActive
335
- ? await getTerminalsForProject(basePort, proxyUrl)
336
- : { terminals: [], gateStatus: { hasGate: false } };
513
+ // Get terminals and gate status from tower's registry
514
+ // Phase 4 (Spec 0090): Tower manages terminals directly
515
+ const { terminals, gateStatus } = getTerminalsForProject(allocation.project_path, proxyUrl);
337
516
  const ports = [
338
517
  {
339
518
  type: 'Dashboard',
@@ -430,8 +609,8 @@ async function getDirectorySuggestions(inputPath) {
430
609
  }
431
610
  /**
432
611
  * Launch a new agent-farm instance
433
- * First stops any stale state, then starts fresh
434
- * Auto-adopts non-codev directories
612
+ * Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server
613
+ * Auto-adopts non-codev directories and creates architect terminal
435
614
  */
436
615
  async function launchInstance(projectPath) {
437
616
  // Clean up stale port allocations before launching (handles machine restarts)
@@ -463,22 +642,10 @@ async function launchInstance(projectPath) {
463
642
  return { success: false, error: `Failed to adopt codev: ${err.message}` };
464
643
  }
465
644
  }
466
- // Use af command (the Agent Farm CLI)
467
- // Resolve absolute path to af to handle daemonized process without PATH
468
- // SECURITY: Use spawn with cwd option to avoid command injection
469
- // Do NOT use bash -c with string concatenation
645
+ // Phase 4 (Spec 0090): Tower manages terminals directly
646
+ // No dashboard-server spawning - tower handles everything
470
647
  try {
471
- // Resolve af path - needed when tower runs as daemon without full PATH
472
- let afPath = 'af';
473
- try {
474
- afPath = execSync('which af', { encoding: 'utf-8' }).trim();
475
- }
476
- catch {
477
- // Fall back to 'af' and hope it's in PATH
478
- log('WARN', 'Could not resolve af path, using "af"');
479
- }
480
- // Don't call "af dash stop" from the tower - it can accidentally kill the tower
481
- // due to tree-kill or orphan detection. Instead, just clear any stale state file.
648
+ // Clear any stale state file
482
649
  const stateFile = path.join(projectPath, '.agent-farm', 'state.json');
483
650
  if (fs.existsSync(stateFile)) {
484
651
  try {
@@ -488,68 +655,63 @@ async function launchInstance(projectPath) {
488
655
  // Ignore - file might not exist or be locked
489
656
  }
490
657
  }
491
- // Start using af dash start
492
- // Capture output to detect errors
493
- const child = spawn(afPath, ['dash', 'start'], {
494
- detached: true,
495
- stdio: ['ignore', 'pipe', 'pipe'],
496
- cwd: projectPath,
497
- env: process.env,
498
- });
499
- // Handle spawn errors (e.g., ENOENT if af not found)
500
- // Use object wrapper to avoid TypeScript narrowing issues
501
- const state = { spawnError: null, stdout: '', stderr: '' };
502
- child.on('error', (err) => {
503
- state.spawnError = err.message;
504
- log('ERROR', `Spawn error: ${err.message}`);
505
- });
506
- child.stdout?.on('data', (data) => {
507
- state.stdout += data.toString();
508
- });
509
- child.stderr?.on('data', (data) => {
510
- state.stderr += data.toString();
511
- });
512
- // Wait a moment for the process to start (or fail)
513
- await new Promise((resolve) => setTimeout(resolve, 2000));
514
- // Check for spawn error first (e.g., codev not found)
515
- if (state.spawnError) {
516
- return {
517
- success: false,
518
- error: `Failed to spawn codev: ${state.spawnError}`,
519
- };
520
- }
521
- // Check if the dashboard port is listening
522
- // Resolve symlinks (macOS /tmp -> /private/tmp)
658
+ // Ensure project has port allocation
523
659
  const resolvedPath = fs.realpathSync(projectPath);
524
660
  const db = getGlobalDb();
525
- const allocation = db
661
+ let allocation = db
526
662
  .prepare('SELECT base_port FROM port_allocations WHERE project_path = ? OR project_path = ?')
527
663
  .get(projectPath, resolvedPath);
528
- if (allocation) {
529
- const dashboardPort = allocation.base_port;
530
- const isRunning = await isPortListening(dashboardPort);
531
- if (!isRunning) {
532
- // Process failed to start - try to get error info
533
- const errorInfo = state.stderr || state.stdout || 'Unknown error - check codev installation';
534
- child.unref();
535
- return {
536
- success: false,
537
- error: `Failed to start: ${errorInfo.trim().split('\n')[0]}`,
538
- };
539
- }
540
- }
541
- else {
542
- // No allocation found - process might have failed before registering
543
- if (state.stderr || state.stdout) {
544
- const errorInfo = state.stderr || state.stdout;
545
- child.unref();
546
- return {
547
- success: false,
548
- error: `Failed to start: ${errorInfo.trim().split('\n')[0]}`,
549
- };
550
- }
551
- }
552
- child.unref();
664
+ if (!allocation) {
665
+ // Allocate a new port for this project
666
+ // Find the next available port block (starting at 4200, incrementing by 100)
667
+ const existingPorts = db
668
+ .prepare('SELECT base_port FROM port_allocations ORDER BY base_port')
669
+ .all();
670
+ let nextPort = 4200;
671
+ for (const { base_port } of existingPorts) {
672
+ if (base_port >= nextPort) {
673
+ nextPort = base_port + 100;
674
+ }
675
+ }
676
+ db.prepare('INSERT INTO port_allocations (project_path, project_name, base_port, created_at) VALUES (?, ?, ?, datetime("now"))').run(resolvedPath, path.basename(projectPath), nextPort);
677
+ allocation = { base_port: nextPort };
678
+ log('INFO', `Allocated port ${nextPort} for project: ${projectPath}`);
679
+ }
680
+ // Initialize project terminal entry
681
+ const entry = getProjectTerminalsEntry(resolvedPath);
682
+ // Create architect terminal if not already present
683
+ if (!entry.architect) {
684
+ const manager = getTerminalManager();
685
+ // Read af-config.json to get the architect command
686
+ let architectCmd = 'claude';
687
+ const configPath = path.join(projectPath, 'af-config.json');
688
+ if (fs.existsSync(configPath)) {
689
+ try {
690
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
691
+ if (config.shell?.architect) {
692
+ architectCmd = config.shell.architect;
693
+ }
694
+ }
695
+ catch {
696
+ // Ignore config read errors, use default
697
+ }
698
+ }
699
+ try {
700
+ const session = await manager.createSession({
701
+ command: architectCmd,
702
+ args: [],
703
+ cwd: projectPath,
704
+ label: 'Architect',
705
+ env: process.env,
706
+ });
707
+ entry.architect = session.id;
708
+ log('INFO', `Created architect terminal for project: ${projectPath}`);
709
+ }
710
+ catch (err) {
711
+ log('WARN', `Failed to create architect terminal: ${err.message}`);
712
+ // Don't fail the launch - project is still active, just without architect
713
+ }
714
+ }
553
715
  return { success: true, adopted };
554
716
  }
555
717
  catch (err) {
@@ -570,26 +732,55 @@ function getProcessOnPort(targetPort) {
570
732
  }
571
733
  }
572
734
  /**
573
- * Stop an agent-farm instance by killing processes on its ports
735
+ * Stop an agent-farm instance by killing all its terminals
736
+ * Phase 4 (Spec 0090): Tower manages terminals directly
574
737
  */
575
- async function stopInstance(basePort) {
738
+ async function stopInstance(projectPath) {
576
739
  const stopped = [];
577
- // Kill the dashboard process (all terminals multiplexed on basePort via Spec 0085)
578
- const portsToCheck = [basePort];
579
- for (const p of portsToCheck) {
580
- const pid = getProcessOnPort(p);
581
- if (pid) {
582
- try {
583
- process.kill(pid, 'SIGTERM');
584
- stopped.push(p);
740
+ const manager = getTerminalManager();
741
+ // Resolve symlinks for consistent lookup
742
+ let resolvedPath = projectPath;
743
+ try {
744
+ if (fs.existsSync(projectPath)) {
745
+ resolvedPath = fs.realpathSync(projectPath);
746
+ }
747
+ }
748
+ catch {
749
+ // Ignore - use original path
750
+ }
751
+ // Get project terminals
752
+ const entry = projectTerminals.get(resolvedPath) || projectTerminals.get(projectPath);
753
+ if (entry) {
754
+ // Kill architect
755
+ if (entry.architect) {
756
+ const session = manager.getSession(entry.architect);
757
+ if (session) {
758
+ manager.killSession(entry.architect);
759
+ stopped.push(session.pid);
585
760
  }
586
- catch {
587
- // Process may have already exited
761
+ }
762
+ // Kill all shells
763
+ for (const terminalId of entry.shells.values()) {
764
+ const session = manager.getSession(terminalId);
765
+ if (session) {
766
+ manager.killSession(terminalId);
767
+ stopped.push(session.pid);
768
+ }
769
+ }
770
+ // Kill all builders
771
+ for (const terminalId of entry.builders.values()) {
772
+ const session = manager.getSession(terminalId);
773
+ if (session) {
774
+ manager.killSession(terminalId);
775
+ stopped.push(session.pid);
588
776
  }
589
777
  }
778
+ // Clear project from registry
779
+ projectTerminals.delete(resolvedPath);
780
+ projectTerminals.delete(projectPath);
590
781
  }
591
782
  if (stopped.length === 0) {
592
- return { success: true, error: 'No processes found to stop', stopped };
783
+ return { success: true, error: 'No terminals found to stop', stopped };
593
784
  }
594
785
  return { success: true, stopped };
595
786
  }
@@ -610,6 +801,54 @@ function findTemplatePath() {
610
801
  // escapeHtml, parseJsonBody, isRequestAllowed imported from ../utils/server-utils.js
611
802
  // Find template path
612
803
  const templatePath = findTemplatePath();
804
+ // WebSocket server for terminal connections (Phase 2 - Spec 0090)
805
+ let terminalWss = null;
806
+ // React dashboard dist path (for serving directly from tower)
807
+ // React dashboard dist path (for serving directly from tower)
808
+ // Phase 4 (Spec 0090): Tower serves everything directly, no dashboard-server
809
+ const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
810
+ const hasReactDashboard = fs.existsSync(reactDashboardPath);
811
+ if (hasReactDashboard) {
812
+ log('INFO', `React dashboard found at: ${reactDashboardPath}`);
813
+ }
814
+ else {
815
+ log('WARN', 'React dashboard not found - project dashboards will not work');
816
+ }
817
+ // MIME types for static file serving
818
+ const MIME_TYPES = {
819
+ '.html': 'text/html',
820
+ '.js': 'application/javascript',
821
+ '.css': 'text/css',
822
+ '.json': 'application/json',
823
+ '.png': 'image/png',
824
+ '.jpg': 'image/jpeg',
825
+ '.gif': 'image/gif',
826
+ '.svg': 'image/svg+xml',
827
+ '.ico': 'image/x-icon',
828
+ '.woff': 'font/woff',
829
+ '.woff2': 'font/woff2',
830
+ '.ttf': 'font/ttf',
831
+ '.map': 'application/json',
832
+ };
833
+ /**
834
+ * Serve a static file from the React dashboard dist
835
+ */
836
+ function serveStaticFile(filePath, res) {
837
+ if (!fs.existsSync(filePath)) {
838
+ return false;
839
+ }
840
+ const ext = path.extname(filePath);
841
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
842
+ try {
843
+ const content = fs.readFileSync(filePath);
844
+ res.writeHead(200, { 'Content-Type': contentType });
845
+ res.end(content);
846
+ return true;
847
+ }
848
+ catch {
849
+ return false;
850
+ }
851
+ }
613
852
  // Create server
614
853
  const server = http.createServer(async (req, res) => {
615
854
  // Security: Validate Host and Origin headers
@@ -633,7 +872,222 @@ const server = http.createServer(async (req, res) => {
633
872
  }
634
873
  const url = new URL(req.url || '/', `http://localhost:${port}`);
635
874
  try {
636
- // API: Get status of all instances
875
+ // =========================================================================
876
+ // NEW API ENDPOINTS (Spec 0090 - Tower as Single Daemon)
877
+ // =========================================================================
878
+ // Health check endpoint (Spec 0090 Phase 1)
879
+ if (req.method === 'GET' && url.pathname === '/health') {
880
+ const instances = await getInstances();
881
+ const activeCount = instances.filter((i) => i.running).length;
882
+ res.writeHead(200, { 'Content-Type': 'application/json' });
883
+ res.end(JSON.stringify({
884
+ status: 'healthy',
885
+ uptime: process.uptime(),
886
+ activeProjects: activeCount,
887
+ totalProjects: instances.length,
888
+ memoryUsage: process.memoryUsage().heapUsed,
889
+ timestamp: new Date().toISOString(),
890
+ }));
891
+ return;
892
+ }
893
+ // API: List all projects (Spec 0090 Phase 1)
894
+ if (req.method === 'GET' && url.pathname === '/api/projects') {
895
+ const instances = await getInstances();
896
+ const projects = instances.map((i) => ({
897
+ path: i.projectPath,
898
+ name: i.projectName,
899
+ basePort: i.basePort,
900
+ active: i.running,
901
+ proxyUrl: i.proxyUrl,
902
+ terminals: i.terminals.length,
903
+ lastUsed: i.lastUsed,
904
+ }));
905
+ res.writeHead(200, { 'Content-Type': 'application/json' });
906
+ res.end(JSON.stringify({ projects }));
907
+ return;
908
+ }
909
+ // API: Project-specific endpoints (Spec 0090 Phase 1)
910
+ // Routes: /api/projects/:encodedPath/activate, /deactivate, /status
911
+ const projectApiMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/(activate|deactivate|status)$/);
912
+ if (projectApiMatch) {
913
+ const [, encodedPath, action] = projectApiMatch;
914
+ let projectPath;
915
+ try {
916
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
917
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
918
+ throw new Error('Invalid path');
919
+ }
920
+ }
921
+ catch {
922
+ res.writeHead(400, { 'Content-Type': 'application/json' });
923
+ res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
924
+ return;
925
+ }
926
+ // GET /api/projects/:path/status
927
+ if (req.method === 'GET' && action === 'status') {
928
+ const instances = await getInstances();
929
+ const instance = instances.find((i) => i.projectPath === projectPath);
930
+ if (!instance) {
931
+ res.writeHead(404, { 'Content-Type': 'application/json' });
932
+ res.end(JSON.stringify({ error: 'Project not found' }));
933
+ return;
934
+ }
935
+ res.writeHead(200, { 'Content-Type': 'application/json' });
936
+ res.end(JSON.stringify({
937
+ path: instance.projectPath,
938
+ name: instance.projectName,
939
+ active: instance.running,
940
+ basePort: instance.basePort,
941
+ terminals: instance.terminals,
942
+ gateStatus: instance.gateStatus,
943
+ }));
944
+ return;
945
+ }
946
+ // POST /api/projects/:path/activate
947
+ if (req.method === 'POST' && action === 'activate') {
948
+ // Rate limiting: 10 activations per minute per client
949
+ const clientIp = req.socket.remoteAddress || '127.0.0.1';
950
+ if (isRateLimited(clientIp)) {
951
+ res.writeHead(429, { 'Content-Type': 'application/json' });
952
+ res.end(JSON.stringify({ error: 'Too many activations, try again later' }));
953
+ return;
954
+ }
955
+ const result = await launchInstance(projectPath);
956
+ if (result.success) {
957
+ res.writeHead(200, { 'Content-Type': 'application/json' });
958
+ res.end(JSON.stringify({ success: true, adopted: result.adopted }));
959
+ }
960
+ else {
961
+ res.writeHead(400, { 'Content-Type': 'application/json' });
962
+ res.end(JSON.stringify({ success: false, error: result.error }));
963
+ }
964
+ return;
965
+ }
966
+ // POST /api/projects/:path/deactivate
967
+ if (req.method === 'POST' && action === 'deactivate') {
968
+ // Check if project exists in port allocations
969
+ const allocations = loadPortAllocations();
970
+ const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
971
+ const allocation = allocations.find((a) => a.project_path === projectPath || a.project_path === resolvedPath);
972
+ if (!allocation) {
973
+ res.writeHead(404, { 'Content-Type': 'application/json' });
974
+ res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
975
+ return;
976
+ }
977
+ // Phase 4: Stop terminals directly via tower
978
+ const result = await stopInstance(projectPath);
979
+ res.writeHead(200, { 'Content-Type': 'application/json' });
980
+ res.end(JSON.stringify(result));
981
+ return;
982
+ }
983
+ }
984
+ // =========================================================================
985
+ // TERMINAL API (Phase 2 - Spec 0090)
986
+ // =========================================================================
987
+ // POST /api/terminals - Create a new terminal
988
+ if (req.method === 'POST' && url.pathname === '/api/terminals') {
989
+ try {
990
+ const body = await parseJsonBody(req);
991
+ const manager = getTerminalManager();
992
+ const info = await manager.createSession({
993
+ command: typeof body.command === 'string' ? body.command : undefined,
994
+ args: Array.isArray(body.args) ? body.args : undefined,
995
+ cols: typeof body.cols === 'number' ? body.cols : undefined,
996
+ rows: typeof body.rows === 'number' ? body.rows : undefined,
997
+ cwd: typeof body.cwd === 'string' ? body.cwd : undefined,
998
+ env: typeof body.env === 'object' && body.env !== null ? body.env : undefined,
999
+ label: typeof body.label === 'string' ? body.label : undefined,
1000
+ });
1001
+ res.writeHead(201, { 'Content-Type': 'application/json' });
1002
+ res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}` }));
1003
+ }
1004
+ catch (err) {
1005
+ const message = err instanceof Error ? err.message : 'Unknown error';
1006
+ log('ERROR', `Failed to create terminal: ${message}`);
1007
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1008
+ res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message }));
1009
+ }
1010
+ return;
1011
+ }
1012
+ // GET /api/terminals - List all terminals
1013
+ if (req.method === 'GET' && url.pathname === '/api/terminals') {
1014
+ const manager = getTerminalManager();
1015
+ const terminals = manager.listSessions();
1016
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1017
+ res.end(JSON.stringify({ terminals }));
1018
+ return;
1019
+ }
1020
+ // Terminal-specific routes: /api/terminals/:id/*
1021
+ const terminalRouteMatch = url.pathname.match(/^\/api\/terminals\/([^/]+)(\/.*)?$/);
1022
+ if (terminalRouteMatch) {
1023
+ const [, terminalId, subpath] = terminalRouteMatch;
1024
+ const manager = getTerminalManager();
1025
+ // GET /api/terminals/:id - Get terminal info
1026
+ if (req.method === 'GET' && (!subpath || subpath === '')) {
1027
+ const session = manager.getSession(terminalId);
1028
+ if (!session) {
1029
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1030
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1031
+ return;
1032
+ }
1033
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1034
+ res.end(JSON.stringify(session.info));
1035
+ return;
1036
+ }
1037
+ // DELETE /api/terminals/:id - Kill terminal
1038
+ if (req.method === 'DELETE' && (!subpath || subpath === '')) {
1039
+ if (!manager.killSession(terminalId)) {
1040
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1041
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1042
+ return;
1043
+ }
1044
+ res.writeHead(204);
1045
+ res.end();
1046
+ return;
1047
+ }
1048
+ // POST /api/terminals/:id/resize - Resize terminal
1049
+ if (req.method === 'POST' && subpath === '/resize') {
1050
+ try {
1051
+ const body = await parseJsonBody(req);
1052
+ if (typeof body.cols !== 'number' || typeof body.rows !== 'number') {
1053
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1054
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'cols and rows must be numbers' }));
1055
+ return;
1056
+ }
1057
+ const info = manager.resizeSession(terminalId, body.cols, body.rows);
1058
+ if (!info) {
1059
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1060
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1061
+ return;
1062
+ }
1063
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1064
+ res.end(JSON.stringify(info));
1065
+ }
1066
+ catch {
1067
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1068
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
1069
+ }
1070
+ return;
1071
+ }
1072
+ // GET /api/terminals/:id/output - Get terminal output
1073
+ if (req.method === 'GET' && subpath === '/output') {
1074
+ const lines = parseInt(url.searchParams.get('lines') ?? '100', 10);
1075
+ const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
1076
+ const output = manager.getOutput(terminalId, lines, offset);
1077
+ if (!output) {
1078
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1079
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1080
+ return;
1081
+ }
1082
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1083
+ res.end(JSON.stringify(output));
1084
+ return;
1085
+ }
1086
+ }
1087
+ // =========================================================================
1088
+ // EXISTING API ENDPOINTS
1089
+ // =========================================================================
1090
+ // API: Get status of all instances (legacy - kept for backward compat)
637
1091
  if (req.method === 'GET' && url.pathname === '/api/status') {
638
1092
  const instances = await getInstances();
639
1093
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -803,15 +1257,22 @@ const server = http.createServer(async (req, res) => {
803
1257
  return;
804
1258
  }
805
1259
  // API: Stop an instance
1260
+ // Phase 4 (Spec 0090): Accept projectPath or basePort for backwards compat
806
1261
  if (req.method === 'POST' && url.pathname === '/api/stop') {
807
1262
  const body = await parseJsonBody(req);
808
- const basePort = body.basePort;
809
- if (!basePort) {
1263
+ let targetPath = body.projectPath;
1264
+ // Backwards compat: if basePort provided, find the project path
1265
+ if (!targetPath && body.basePort) {
1266
+ const allocations = loadPortAllocations();
1267
+ const allocation = allocations.find((a) => a.base_port === body.basePort);
1268
+ targetPath = allocation?.project_path || '';
1269
+ }
1270
+ if (!targetPath) {
810
1271
  res.writeHead(400, { 'Content-Type': 'application/json' });
811
- res.end(JSON.stringify({ success: false, error: 'Missing basePort' }));
1272
+ res.end(JSON.stringify({ success: false, error: 'Missing projectPath or basePort' }));
812
1273
  return;
813
1274
  }
814
- const result = await stopInstance(basePort);
1275
+ const result = await stopInstance(targetPath);
815
1276
  res.writeHead(200, { 'Content-Type': 'application/json' });
816
1277
  res.end(JSON.stringify(result));
817
1278
  return;
@@ -834,26 +1295,23 @@ const server = http.createServer(async (req, res) => {
834
1295
  }
835
1296
  return;
836
1297
  }
837
- // Reverse proxy: /project/:base64urlPath/* → localhost:basePort/*
1298
+ // Project routes: /project/:base64urlPath/*
1299
+ // Phase 4 (Spec 0090): Tower serves React dashboard and handles APIs directly
838
1300
  // Uses Base64URL (RFC 4648) encoding to avoid issues with slashes in paths
839
- // All terminals multiplexed on basePort via WebSocket (Spec 0085)
840
1301
  if (url.pathname.startsWith('/project/')) {
841
1302
  const pathParts = url.pathname.split('/');
842
- // ['', 'project', base64urlPath, terminalType, ...rest]
1303
+ // ['', 'project', base64urlPath, ...rest]
843
1304
  const encodedPath = pathParts[2];
844
- const terminalType = pathParts[3];
845
- const rest = pathParts.slice(4);
1305
+ const subPath = pathParts.slice(3).join('/');
846
1306
  if (!encodedPath) {
847
1307
  res.writeHead(400, { 'Content-Type': 'text/plain' });
848
1308
  res.end('Missing project path');
849
1309
  return;
850
1310
  }
851
- // Decode Base64URL (RFC 4648) - NOT URL encoding
852
- // Wrap in try/catch to handle malformed Base64 input gracefully
1311
+ // Decode Base64URL (RFC 4648)
853
1312
  let projectPath;
854
1313
  try {
855
1314
  projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
856
- // Validate decoded path is reasonable (non-empty, looks like absolute path)
857
1315
  // Support both POSIX (/) and Windows (C:\) paths
858
1316
  if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
859
1317
  throw new Error('Invalid project path');
@@ -865,35 +1323,198 @@ const server = http.createServer(async (req, res) => {
865
1323
  return;
866
1324
  }
867
1325
  const basePort = await getBasePortForProject(projectPath);
868
- if (!basePort) {
1326
+ // Phase 4 (Spec 0090): Tower handles everything directly
1327
+ const isApiCall = subPath.startsWith('api/') || subPath === 'api';
1328
+ const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
1329
+ // Serve React dashboard static files directly if:
1330
+ // 1. Not an API call
1331
+ // 2. Not a WebSocket path
1332
+ // 3. React dashboard is available
1333
+ // 4. Project doesn't need to be running for static files
1334
+ if (!isApiCall && !isWsPath && hasReactDashboard) {
1335
+ // Determine which static file to serve
1336
+ let staticPath;
1337
+ if (!subPath || subPath === '' || subPath === 'index.html') {
1338
+ staticPath = path.join(reactDashboardPath, 'index.html');
1339
+ }
1340
+ else {
1341
+ // Check if it's a static asset
1342
+ staticPath = path.join(reactDashboardPath, subPath);
1343
+ }
1344
+ // Try to serve the static file
1345
+ if (serveStaticFile(staticPath, res)) {
1346
+ return;
1347
+ }
1348
+ // SPA fallback: serve index.html for client-side routing
1349
+ const indexPath = path.join(reactDashboardPath, 'index.html');
1350
+ if (serveStaticFile(indexPath, res)) {
1351
+ return;
1352
+ }
1353
+ }
1354
+ // Phase 4 (Spec 0090): Handle project APIs directly instead of proxying to dashboard-server
1355
+ if (isApiCall) {
1356
+ const apiPath = subPath.replace(/^api\/?/, '');
1357
+ // GET /api/state - Return project state (architect, builders, shells)
1358
+ if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
1359
+ const entry = getProjectTerminalsEntry(projectPath);
1360
+ const manager = getTerminalManager();
1361
+ // Build state response compatible with React dashboard
1362
+ const state = {
1363
+ architect: null,
1364
+ builders: [],
1365
+ utils: [],
1366
+ annotations: [],
1367
+ projectName: path.basename(projectPath),
1368
+ };
1369
+ // Add architect if exists
1370
+ if (entry.architect) {
1371
+ const session = manager.getSession(entry.architect);
1372
+ state.architect = {
1373
+ port: basePort || 0,
1374
+ pid: session?.pid || 0,
1375
+ terminalId: entry.architect,
1376
+ };
1377
+ }
1378
+ // Add shells
1379
+ for (const [shellId, terminalId] of entry.shells) {
1380
+ const session = manager.getSession(terminalId);
1381
+ state.utils.push({
1382
+ id: shellId,
1383
+ name: `Shell ${shellId.replace('shell-', '')}`,
1384
+ port: basePort || 0,
1385
+ pid: session?.pid || 0,
1386
+ terminalId,
1387
+ });
1388
+ }
1389
+ // Add builders
1390
+ for (const [builderId, terminalId] of entry.builders) {
1391
+ const session = manager.getSession(terminalId);
1392
+ state.builders.push({
1393
+ id: builderId,
1394
+ name: `Builder ${builderId}`,
1395
+ port: basePort || 0,
1396
+ pid: session?.pid || 0,
1397
+ status: 'running',
1398
+ phase: '',
1399
+ worktree: '',
1400
+ branch: '',
1401
+ type: 'spec',
1402
+ terminalId,
1403
+ });
1404
+ }
1405
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1406
+ res.end(JSON.stringify(state));
1407
+ return;
1408
+ }
1409
+ // POST /api/tabs/shell - Create a new shell terminal
1410
+ if (req.method === 'POST' && apiPath === 'tabs/shell') {
1411
+ try {
1412
+ const manager = getTerminalManager();
1413
+ const shellId = getNextShellId(projectPath);
1414
+ // Create terminal session
1415
+ const session = await manager.createSession({
1416
+ command: process.env.SHELL || '/bin/bash',
1417
+ args: [],
1418
+ cwd: projectPath,
1419
+ label: `Shell ${shellId.replace('shell-', '')}`,
1420
+ env: process.env,
1421
+ });
1422
+ // Register terminal with project
1423
+ const entry = getProjectTerminalsEntry(projectPath);
1424
+ entry.shells.set(shellId, session.id);
1425
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1426
+ res.end(JSON.stringify({
1427
+ id: shellId,
1428
+ port: basePort || 0,
1429
+ name: `Shell ${shellId.replace('shell-', '')}`,
1430
+ terminalId: session.id,
1431
+ }));
1432
+ }
1433
+ catch (err) {
1434
+ log('ERROR', `Failed to create shell: ${err.message}`);
1435
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1436
+ res.end(JSON.stringify({ error: err.message }));
1437
+ }
1438
+ return;
1439
+ }
1440
+ // DELETE /api/tabs/:id - Delete a terminal tab
1441
+ const deleteMatch = apiPath.match(/^tabs\/(.+)$/);
1442
+ if (req.method === 'DELETE' && deleteMatch) {
1443
+ const tabId = deleteMatch[1];
1444
+ const entry = getProjectTerminalsEntry(projectPath);
1445
+ const manager = getTerminalManager();
1446
+ // Find and delete the terminal
1447
+ let terminalId;
1448
+ if (tabId.startsWith('shell-')) {
1449
+ terminalId = entry.shells.get(tabId);
1450
+ if (terminalId) {
1451
+ entry.shells.delete(tabId);
1452
+ }
1453
+ }
1454
+ else if (tabId.startsWith('builder-')) {
1455
+ terminalId = entry.builders.get(tabId);
1456
+ if (terminalId) {
1457
+ entry.builders.delete(tabId);
1458
+ }
1459
+ }
1460
+ else if (tabId === 'architect') {
1461
+ terminalId = entry.architect;
1462
+ if (terminalId) {
1463
+ entry.architect = undefined;
1464
+ }
1465
+ }
1466
+ if (terminalId) {
1467
+ manager.killSession(terminalId);
1468
+ res.writeHead(204);
1469
+ res.end();
1470
+ }
1471
+ else {
1472
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1473
+ res.end(JSON.stringify({ error: 'Tab not found' }));
1474
+ }
1475
+ return;
1476
+ }
1477
+ // POST /api/stop - Stop all terminals for project
1478
+ if (req.method === 'POST' && apiPath === 'stop') {
1479
+ const entry = getProjectTerminalsEntry(projectPath);
1480
+ const manager = getTerminalManager();
1481
+ // Kill all terminals
1482
+ if (entry.architect) {
1483
+ manager.killSession(entry.architect);
1484
+ }
1485
+ for (const terminalId of entry.shells.values()) {
1486
+ manager.killSession(terminalId);
1487
+ }
1488
+ for (const terminalId of entry.builders.values()) {
1489
+ manager.killSession(terminalId);
1490
+ }
1491
+ // Clear registry
1492
+ projectTerminals.delete(projectPath);
1493
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1494
+ res.end(JSON.stringify({ ok: true }));
1495
+ return;
1496
+ }
1497
+ // Unhandled API route
1498
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1499
+ res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));
1500
+ return;
1501
+ }
1502
+ // For WebSocket paths, let the upgrade handler deal with it
1503
+ if (isWsPath) {
1504
+ // WebSocket paths are handled by the upgrade handler
1505
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
1506
+ res.end('WebSocket connections should use ws:// protocol');
1507
+ return;
1508
+ }
1509
+ // If we get here for non-API, non-WS paths and React dashboard is not available
1510
+ if (!hasReactDashboard) {
869
1511
  res.writeHead(404, { 'Content-Type': 'text/plain' });
870
- res.end('Project not found or not running');
1512
+ res.end('Dashboard not available');
871
1513
  return;
872
1514
  }
873
- // All terminals now multiplexed on basePort via WebSocket (Spec 0085)
874
- // Just pass the path through — the React dashboard handles routing
875
- let targetPort = basePort;
876
- let proxyPath = terminalType ? [terminalType, ...rest].join('/') : rest.join('/');
877
- // Proxy the request
878
- const proxyReq = http.request({
879
- hostname: '127.0.0.1',
880
- port: targetPort,
881
- path: '/' + proxyPath + (url.search || ''),
882
- method: req.method,
883
- headers: {
884
- ...req.headers,
885
- host: `localhost:${targetPort}`,
886
- },
887
- }, (proxyRes) => {
888
- res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
889
- proxyRes.pipe(res);
890
- });
891
- proxyReq.on('error', (err) => {
892
- log('ERROR', `Proxy error: ${err.message}`);
893
- res.writeHead(502, { 'Content-Type': 'text/plain' });
894
- res.end('Proxy error: ' + err.message);
895
- });
896
- req.pipe(proxyReq);
1515
+ // Fallback for unmatched paths
1516
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1517
+ res.end('Not found');
897
1518
  return;
898
1519
  }
899
1520
  // 404 for everything else
@@ -910,20 +1531,37 @@ const server = http.createServer(async (req, res) => {
910
1531
  server.listen(port, '127.0.0.1', () => {
911
1532
  log('INFO', `Tower server listening at http://localhost:${port}`);
912
1533
  });
913
- // WebSocket upgrade handler for proxying terminal connections
914
- // Same terminal port routing as HTTP proxy
1534
+ // Initialize terminal WebSocket server (Phase 2 - Spec 0090)
1535
+ terminalWss = new WebSocketServer({ noServer: true });
1536
+ // WebSocket upgrade handler for terminal connections and proxying
915
1537
  server.on('upgrade', async (req, socket, head) => {
916
1538
  const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
1539
+ // Phase 2: Handle /ws/terminal/:id routes directly
1540
+ const terminalMatch = reqUrl.pathname.match(/^\/ws\/terminal\/([^/]+)$/);
1541
+ if (terminalMatch) {
1542
+ const terminalId = terminalMatch[1];
1543
+ const manager = getTerminalManager();
1544
+ const session = manager.getSession(terminalId);
1545
+ if (!session) {
1546
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
1547
+ socket.destroy();
1548
+ return;
1549
+ }
1550
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
1551
+ handleTerminalWebSocket(ws, session, req);
1552
+ });
1553
+ return;
1554
+ }
1555
+ // Phase 4 (Spec 0090): Handle project WebSocket routes directly
1556
+ // Route: /project/:encodedPath/ws/terminal/:terminalId
917
1557
  if (!reqUrl.pathname.startsWith('/project/')) {
918
1558
  socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
919
1559
  socket.destroy();
920
1560
  return;
921
1561
  }
922
1562
  const pathParts = reqUrl.pathname.split('/');
923
- // ['', 'project', base64urlPath, terminalType, ...rest]
1563
+ // ['', 'project', base64urlPath, 'ws', 'terminal', terminalId]
924
1564
  const encodedPath = pathParts[2];
925
- const terminalType = pathParts[3];
926
- const rest = pathParts.slice(4);
927
1565
  if (!encodedPath) {
928
1566
  socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
929
1567
  socket.destroy();
@@ -944,51 +1582,25 @@ server.on('upgrade', async (req, socket, head) => {
944
1582
  socket.destroy();
945
1583
  return;
946
1584
  }
947
- const basePort = await getBasePortForProject(projectPath);
948
- if (!basePort) {
949
- socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
950
- socket.destroy();
1585
+ // Check for terminal WebSocket route: /project/:path/ws/terminal/:id
1586
+ const wsMatch = reqUrl.pathname.match(/^\/project\/[^/]+\/ws\/terminal\/([^/]+)$/);
1587
+ if (wsMatch) {
1588
+ const terminalId = wsMatch[1];
1589
+ const manager = getTerminalManager();
1590
+ const session = manager.getSession(terminalId);
1591
+ if (!session) {
1592
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
1593
+ socket.destroy();
1594
+ return;
1595
+ }
1596
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
1597
+ handleTerminalWebSocket(ws, session, req);
1598
+ });
951
1599
  return;
952
1600
  }
953
- // All terminals now multiplexed on basePort via WebSocket (Spec 0085)
954
- // Just pass the path through — the React dashboard handles routing
955
- let targetPort = basePort;
956
- let proxyPath = terminalType ? [terminalType, ...rest].join('/') : rest.join('/');
957
- // Connect to target
958
- const proxySocket = net.connect(targetPort, '127.0.0.1', () => {
959
- // Rewrite Origin header for WebSocket compatibility
960
- const headers = { ...req.headers };
961
- headers.origin = 'http://localhost';
962
- headers.host = `localhost:${targetPort}`;
963
- // Forward the upgrade request
964
- let headerStr = `${req.method} /${proxyPath}${reqUrl.search || ''} HTTP/1.1\r\n`;
965
- for (const [key, value] of Object.entries(headers)) {
966
- if (value) {
967
- if (Array.isArray(value)) {
968
- for (const v of value) {
969
- headerStr += `${key}: ${v}\r\n`;
970
- }
971
- }
972
- else {
973
- headerStr += `${key}: ${value}\r\n`;
974
- }
975
- }
976
- }
977
- headerStr += '\r\n';
978
- proxySocket.write(headerStr);
979
- if (head.length > 0)
980
- proxySocket.write(head);
981
- // Pipe bidirectionally
982
- socket.pipe(proxySocket);
983
- proxySocket.pipe(socket);
984
- });
985
- proxySocket.on('error', (err) => {
986
- log('ERROR', `WebSocket proxy error: ${err.message}`);
987
- socket.destroy();
988
- });
989
- socket.on('error', () => {
990
- proxySocket.destroy();
991
- });
1601
+ // Unhandled WebSocket route
1602
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
1603
+ socket.destroy();
992
1604
  });
993
1605
  // Handle uncaught errors
994
1606
  process.on('uncaughtException', (err) => {