@cluesmith/codev 2.0.0-rc.72 → 2.0.0-rc.73

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 (50) hide show
  1. package/dashboard/dist/assets/{index-C7FtNK6Y.css → index-4n9zpWLY.css} +1 -1
  2. package/dashboard/dist/assets/{index-CDAINZKT.js → index-CH_utkcW.js} +32 -27
  3. package/dashboard/dist/assets/index-CH_utkcW.js.map +1 -0
  4. package/dashboard/dist/index.html +2 -2
  5. package/dist/agent-farm/commands/spawn-roles.d.ts +80 -0
  6. package/dist/agent-farm/commands/spawn-roles.d.ts.map +1 -0
  7. package/dist/agent-farm/commands/spawn-roles.js +278 -0
  8. package/dist/agent-farm/commands/spawn-roles.js.map +1 -0
  9. package/dist/agent-farm/commands/spawn-worktree.d.ts +96 -0
  10. package/dist/agent-farm/commands/spawn-worktree.d.ts.map +1 -0
  11. package/dist/agent-farm/commands/spawn-worktree.js +305 -0
  12. package/dist/agent-farm/commands/spawn-worktree.js.map +1 -0
  13. package/dist/agent-farm/commands/spawn.d.ts +5 -1
  14. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  15. package/dist/agent-farm/commands/spawn.js +65 -725
  16. package/dist/agent-farm/commands/spawn.js.map +1 -1
  17. package/dist/agent-farm/servers/tower-instances.d.ts +82 -0
  18. package/dist/agent-farm/servers/tower-instances.d.ts.map +1 -0
  19. package/dist/agent-farm/servers/tower-instances.js +441 -0
  20. package/dist/agent-farm/servers/tower-instances.js.map +1 -0
  21. package/dist/agent-farm/servers/tower-routes.d.ts +34 -0
  22. package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -0
  23. package/dist/agent-farm/servers/tower-routes.js +1445 -0
  24. package/dist/agent-farm/servers/tower-routes.js.map +1 -0
  25. package/dist/agent-farm/servers/tower-server.d.ts +5 -2
  26. package/dist/agent-farm/servers/tower-server.d.ts.map +1 -1
  27. package/dist/agent-farm/servers/tower-server.js +74 -2860
  28. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  29. package/dist/agent-farm/servers/tower-terminals.d.ts +119 -0
  30. package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -0
  31. package/dist/agent-farm/servers/tower-terminals.js +629 -0
  32. package/dist/agent-farm/servers/tower-terminals.js.map +1 -0
  33. package/dist/agent-farm/servers/tower-tunnel.d.ts +34 -0
  34. package/dist/agent-farm/servers/tower-tunnel.d.ts.map +1 -0
  35. package/dist/agent-farm/servers/tower-tunnel.js +299 -0
  36. package/dist/agent-farm/servers/tower-tunnel.js.map +1 -0
  37. package/dist/agent-farm/servers/tower-types.d.ts +85 -0
  38. package/dist/agent-farm/servers/tower-types.d.ts.map +1 -0
  39. package/dist/agent-farm/servers/tower-types.js +6 -0
  40. package/dist/agent-farm/servers/tower-types.js.map +1 -0
  41. package/dist/agent-farm/servers/tower-utils.d.ts +51 -0
  42. package/dist/agent-farm/servers/tower-utils.d.ts.map +1 -0
  43. package/dist/agent-farm/servers/tower-utils.js +161 -0
  44. package/dist/agent-farm/servers/tower-utils.js.map +1 -0
  45. package/dist/agent-farm/servers/tower-websocket.d.ts +25 -0
  46. package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -0
  47. package/dist/agent-farm/servers/tower-websocket.js +171 -0
  48. package/dist/agent-farm/servers/tower-websocket.js.map +1 -0
  49. package/package.json +1 -1
  50. package/dashboard/dist/assets/index-CDAINZKT.js.map +0 -1
@@ -1,712 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Tower server for Agent Farm.
4
- * Provides a centralized view of all agent-farm instances across projects.
3
+ * Tower server for Agent Farm — orchestrator module.
4
+ * Spec 0105: Tower Server Decomposition
5
+ *
6
+ * Creates HTTP/WS servers, initializes all subsystem modules, and
7
+ * delegates HTTP request handling to tower-routes.ts.
5
8
  */
6
9
  import http from 'node:http';
7
10
  import fs from 'node:fs';
8
11
  import path from 'node:path';
9
- import crypto from 'node:crypto';
10
- import { execSync } from 'node:child_process';
11
- import { homedir, tmpdir } from 'node:os';
12
+ import { homedir } from 'node:os';
12
13
  import { fileURLToPath } from 'node:url';
13
14
  import { Command } from 'commander';
14
- import { WebSocketServer, WebSocket } from 'ws';
15
- import { getGlobalDb } from '../db/index.js';
16
- import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js';
17
- import { getGateStatusForProject } from '../utils/gate-status.js';
18
- import { GateWatcher } from '../utils/gate-watcher.js';
19
- import { saveFileTab as saveFileTabToDb, deleteFileTab as deleteFileTabFromDb, loadFileTabsForProject as loadFileTabsFromDb, } from '../utils/file-tabs.js';
20
- import { TerminalManager } from '../../terminal/pty-manager.js';
21
- import { encodeData, encodeControl, decodeFrame } from '../../terminal/ws-protocol.js';
22
- import { TunnelClient } from '../lib/tunnel-client.js';
23
- import { readCloudConfig, getCloudConfigPath, maskApiKey } from '../lib/cloud-config.js';
15
+ import { WebSocketServer } from 'ws';
24
16
  import { SessionManager } from '../../terminal/session-manager.js';
17
+ import { startRateLimitCleanup } from './tower-utils.js';
18
+ import { initTunnel, shutdownTunnel, } from './tower-tunnel.js';
19
+ import { initInstances, shutdownInstances, registerKnownProject, getKnownProjectPaths, getInstances, } from './tower-instances.js';
20
+ import { initTerminals, shutdownTerminals, getProjectTerminals, getTerminalManager, getProjectTerminalsEntry, saveTerminalSession, deleteTerminalSession, deleteProjectTerminalSessions, getTerminalsForProject, reconcileTerminalSessions, startGateWatcher, } from './tower-terminals.js';
21
+ import { setupUpgradeHandler, } from './tower-websocket.js';
22
+ import { handleRequest } from './tower-routes.js';
25
23
  const __filename = fileURLToPath(import.meta.url);
26
24
  const __dirname = path.dirname(__filename);
27
25
  // Default port for tower dashboard
28
26
  const DEFAULT_PORT = 4100;
29
- // Rate limiting for activation requests (Spec 0090 Phase 1)
30
- // Simple in-memory rate limiter: 10 activations per minute per client
31
- const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
32
- const RATE_LIMIT_MAX = 10;
33
- const activationRateLimits = new Map();
34
- /**
35
- * Check if a client has exceeded the rate limit for activations
36
- * Returns true if rate limit exceeded, false if allowed
37
- */
38
- function isRateLimited(clientIp) {
39
- const now = Date.now();
40
- const entry = activationRateLimits.get(clientIp);
41
- if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
42
- // New window
43
- activationRateLimits.set(clientIp, { count: 1, windowStart: now });
44
- return false;
45
- }
46
- if (entry.count >= RATE_LIMIT_MAX) {
47
- return true;
48
- }
49
- entry.count++;
50
- return false;
51
- }
52
- /**
53
- * Clean up old rate limit entries periodically
54
- */
55
- function cleanupRateLimits() {
56
- const now = Date.now();
57
- for (const [ip, entry] of activationRateLimits.entries()) {
58
- if (now - entry.windowStart >= RATE_LIMIT_WINDOW_MS * 2) {
59
- activationRateLimits.delete(ip);
60
- }
61
- }
62
- }
63
- // Cleanup stale rate limit entries every 5 minutes
64
- setInterval(cleanupRateLimits, 5 * 60 * 1000);
65
- // ============================================================================
66
- // Cloud Tunnel Client (Spec 0097 Phase 4)
67
- // ============================================================================
68
- /** Tunnel client instance — created on startup or via POST /api/tunnel/connect */
69
- let tunnelClient = null;
70
- /** Config file watcher — watches cloud-config.json for changes */
71
- let configWatcher = null;
72
- /** Debounce timer for config file watcher events */
73
- let configWatchDebounce = null;
74
- /** Default tunnel port for codevos.ai */
75
- // TICK-001: tunnelPort is no longer needed — WebSocket connects on the same port
76
- /** Periodic metadata refresh interval (re-sends metadata to codevos.ai) */
77
- let metadataRefreshInterval = null;
78
- /** Metadata refresh period in milliseconds (30 seconds) */
79
- const METADATA_REFRESH_MS = 30_000;
80
- /**
81
- * Gather current tower metadata (projects + terminals) for codevos.ai.
82
- */
83
- async function gatherMetadata() {
84
- const instances = await getInstances();
85
- const projects = instances.map((i) => ({
86
- path: i.projectPath,
87
- name: i.projectName,
88
- }));
89
- // Build reverse mapping: terminal ID → project path
90
- const terminalToProject = new Map();
91
- for (const [projectPath, entry] of projectTerminals) {
92
- if (entry.architect)
93
- terminalToProject.set(entry.architect, projectPath);
94
- for (const termId of entry.builders.values())
95
- terminalToProject.set(termId, projectPath);
96
- for (const termId of entry.shells.values())
97
- terminalToProject.set(termId, projectPath);
98
- }
99
- const manager = terminalManager;
100
- const terminals = [];
101
- if (manager) {
102
- for (const session of manager.listSessions()) {
103
- terminals.push({
104
- id: session.id,
105
- projectPath: terminalToProject.get(session.id) ?? '',
106
- });
107
- }
108
- }
109
- return { projects, terminals };
110
- }
111
- /**
112
- * Start periodic metadata refresh — re-gathers metadata and pushes to codevos.ai
113
- * every METADATA_REFRESH_MS while the tunnel is connected.
114
- */
115
- function startMetadataRefresh() {
116
- stopMetadataRefresh();
117
- metadataRefreshInterval = setInterval(async () => {
118
- try {
119
- if (tunnelClient && tunnelClient.getState() === 'connected') {
120
- const metadata = await gatherMetadata();
121
- tunnelClient.sendMetadata(metadata);
122
- }
123
- }
124
- catch (err) {
125
- log('WARN', `Metadata refresh failed: ${err.message}`);
126
- }
127
- }, METADATA_REFRESH_MS);
128
- }
129
- /**
130
- * Stop the periodic metadata refresh.
131
- */
132
- function stopMetadataRefresh() {
133
- if (metadataRefreshInterval) {
134
- clearInterval(metadataRefreshInterval);
135
- metadataRefreshInterval = null;
136
- }
137
- }
138
- /**
139
- * Create or reconnect the tunnel client using the given config.
140
- * Sets up state change listeners and sends initial metadata.
141
- */
142
- async function connectTunnel(config) {
143
- // Disconnect existing client if any
144
- if (tunnelClient) {
145
- tunnelClient.disconnect();
146
- }
147
- const client = new TunnelClient({
148
- serverUrl: config.server_url,
149
- apiKey: config.api_key,
150
- towerId: config.tower_id,
151
- localPort: port,
152
- });
153
- client.onStateChange((state, prev) => {
154
- log('INFO', `Tunnel: ${prev} → ${state}`);
155
- if (state === 'connected') {
156
- startMetadataRefresh();
157
- }
158
- else if (prev === 'connected') {
159
- stopMetadataRefresh();
160
- }
161
- if (state === 'auth_failed') {
162
- log('ERROR', 'Cloud connection failed: API key is invalid or revoked. Run \'af tower register --reauth\' to update credentials.');
163
- }
164
- });
165
- // Gather and set initial metadata before connecting
166
- const metadata = await gatherMetadata();
167
- client.sendMetadata(metadata);
168
- tunnelClient = client;
169
- client.connect();
170
- // Ensure config watcher is running — the config directory now exists.
171
- // Handles the case where Tower booted before registration (directory didn't
172
- // exist, so startConfigWatcher() silently failed at boot time).
173
- startConfigWatcher();
174
- return client;
175
- }
176
- /**
177
- * Start watching cloud-config.json for changes.
178
- * On change: reconnect with new credentials.
179
- * On delete: disconnect tunnel.
180
- */
181
- function startConfigWatcher() {
182
- stopConfigWatcher();
183
- const configPath = getCloudConfigPath();
184
- const configDir = path.dirname(configPath);
185
- const configFile = path.basename(configPath);
186
- // Watch the directory (more reliable than watching the file directly)
187
- try {
188
- configWatcher = fs.watch(configDir, (eventType, filename) => {
189
- if (filename !== configFile)
190
- return;
191
- // Debounce: multiple events fire for a single write
192
- if (configWatchDebounce)
193
- clearTimeout(configWatchDebounce);
194
- configWatchDebounce = setTimeout(async () => {
195
- configWatchDebounce = null;
196
- try {
197
- const config = readCloudConfig();
198
- if (config) {
199
- log('INFO', `Cloud config changed, reconnecting tunnel (key: ${maskApiKey(config.api_key)})`);
200
- // Reset circuit breaker in case previous key was invalid
201
- if (tunnelClient)
202
- tunnelClient.resetCircuitBreaker();
203
- await connectTunnel(config);
204
- }
205
- else {
206
- // Config deleted or invalid
207
- log('INFO', 'Cloud config removed or invalid, disconnecting tunnel');
208
- if (tunnelClient) {
209
- tunnelClient.disconnect();
210
- tunnelClient = null;
211
- }
212
- }
213
- }
214
- catch (err) {
215
- log('WARN', `Error handling config change: ${err.message}`);
216
- }
217
- }, 500);
218
- });
219
- }
220
- catch {
221
- // Directory doesn't exist yet — that's fine, user hasn't registered
222
- }
223
- }
224
- /**
225
- * Stop watching cloud-config.json.
226
- */
227
- function stopConfigWatcher() {
228
- if (configWatcher) {
229
- configWatcher.close();
230
- configWatcher = null;
231
- }
232
- if (configWatchDebounce) {
233
- clearTimeout(configWatchDebounce);
234
- configWatchDebounce = null;
235
- }
236
- }
237
- // ============================================================================
238
- // PHASE 2 & 4: Terminal Management (Spec 0090)
239
- // ============================================================================
240
- // Global TerminalManager instance for tower-managed terminals
241
- // Uses a temporary directory as projectRoot since terminals can be for any project
242
- let terminalManager = null;
243
- const projectTerminals = new Map();
244
- /**
245
- * Get or create project terminal registry entry.
246
- * On first access for a project, hydrates file tabs from SQLite so
247
- * persisted tabs are available immediately (not just after /api/state).
248
- */
249
- function getProjectTerminalsEntry(projectPath) {
250
- let entry = projectTerminals.get(projectPath);
251
- if (!entry) {
252
- entry = { builders: new Map(), shells: new Map(), fileTabs: loadFileTabsForProject(projectPath) };
253
- projectTerminals.set(projectPath, entry);
254
- }
255
- // Migration: ensure fileTabs exists for older entries
256
- if (!entry.fileTabs) {
257
- entry.fileTabs = new Map();
258
- }
259
- return entry;
260
- }
261
- /**
262
- * Get language identifier for syntax highlighting
263
- */
264
- function getLanguageForExt(ext) {
265
- const langMap = {
266
- js: 'javascript', ts: 'typescript', jsx: 'javascript', tsx: 'typescript',
267
- py: 'python', sh: 'bash', bash: 'bash', md: 'markdown',
268
- html: 'markup', css: 'css', json: 'json', yaml: 'yaml', yml: 'yaml',
269
- rs: 'rust', go: 'go', java: 'java', c: 'c', cpp: 'cpp', h: 'c',
270
- };
271
- return langMap[ext] || ext || 'plaintext';
272
- }
273
- /**
274
- * Get MIME type for file
275
- */
276
- function getMimeTypeForFile(filePath) {
277
- const ext = path.extname(filePath).slice(1).toLowerCase();
278
- const mimeTypes = {
279
- png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
280
- gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
281
- mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
282
- pdf: 'application/pdf', txt: 'text/plain',
283
- };
284
- return mimeTypes[ext] || 'application/octet-stream';
285
- }
286
- /**
287
- * Generate next shell ID for a project
288
- */
289
- function getNextShellId(projectPath) {
290
- const entry = getProjectTerminalsEntry(projectPath);
291
- let maxId = 0;
292
- for (const id of entry.shells.keys()) {
293
- const num = parseInt(id.replace('shell-', ''), 10);
294
- if (!isNaN(num) && num > maxId)
295
- maxId = num;
296
- }
297
- return `shell-${maxId + 1}`;
298
- }
299
- /**
300
- * Get or create the global TerminalManager instance
301
- */
302
- function getTerminalManager() {
303
- if (!terminalManager) {
304
- // Use a neutral projectRoot - terminals specify their own cwd
305
- const projectRoot = process.env.HOME || '/tmp';
306
- terminalManager = new TerminalManager({
307
- projectRoot,
308
- logDir: path.join(homedir(), '.agent-farm', 'logs'),
309
- maxSessions: 100,
310
- ringBufferLines: 10000,
311
- diskLogEnabled: true,
312
- diskLogMaxBytes: 50 * 1024 * 1024,
313
- reconnectTimeoutMs: 300_000,
314
- });
315
- }
316
- return terminalManager;
317
- }
318
- /**
319
- * Normalize a project path to its canonical form for consistent SQLite storage.
320
- * Uses realpath to resolve symlinks and relative paths.
321
- */
322
- function normalizeProjectPath(projectPath) {
323
- try {
324
- return fs.realpathSync(projectPath);
325
- }
326
- catch {
327
- // Path doesn't exist yet, normalize without realpath
328
- return path.resolve(projectPath);
329
- }
330
- }
331
- /**
332
- * Save a terminal session to SQLite.
333
- * Guards against race conditions by checking if project is still active.
334
- */
335
- function saveTerminalSession(terminalId, projectPath, type, roleId, pid, shepherdSocket = null, shepherdPid = null, shepherdStartTime = null) {
336
- try {
337
- const normalizedPath = normalizeProjectPath(projectPath);
338
- // Race condition guard: only save if project is still in the active registry
339
- // This prevents zombie rows when stop races with session creation
340
- if (!projectTerminals.has(normalizedPath) && !projectTerminals.has(projectPath)) {
341
- log('INFO', `Skipping session save - project no longer active: ${projectPath}`);
342
- return;
343
- }
344
- const db = getGlobalDb();
345
- db.prepare(`
346
- INSERT OR REPLACE INTO terminal_sessions (id, project_path, type, role_id, pid, shepherd_socket, shepherd_pid, shepherd_start_time)
347
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
348
- `).run(terminalId, normalizedPath, type, roleId, pid, shepherdSocket, shepherdPid, shepherdStartTime);
349
- log('INFO', `Saved terminal session to SQLite: ${terminalId} (${type}) for ${path.basename(normalizedPath)}`);
350
- }
351
- catch (err) {
352
- log('WARN', `Failed to save terminal session: ${err.message}`);
353
- }
354
- }
355
- /**
356
- * Check if a terminal session is persistent (shepherd-backed).
357
- * A session is persistent if it can survive a Tower restart.
358
- */
359
- function isSessionPersistent(_terminalId, session) {
360
- return session.shepherdBacked;
361
- }
362
- /**
363
- * Delete a terminal session from SQLite
364
- */
365
- function deleteTerminalSession(terminalId) {
366
- try {
367
- const db = getGlobalDb();
368
- db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(terminalId);
369
- }
370
- catch (err) {
371
- log('WARN', `Failed to delete terminal session: ${err.message}`);
372
- }
373
- }
374
- /**
375
- * Delete all terminal sessions for a project from SQLite.
376
- * Normalizes path to ensure consistent cleanup regardless of how path was provided.
377
- */
378
- function deleteProjectTerminalSessions(projectPath) {
379
- try {
380
- const normalizedPath = normalizeProjectPath(projectPath);
381
- const db = getGlobalDb();
382
- // Delete both normalized and raw path to handle any inconsistencies
383
- db.prepare('DELETE FROM terminal_sessions WHERE project_path = ?').run(normalizedPath);
384
- if (normalizedPath !== projectPath) {
385
- db.prepare('DELETE FROM terminal_sessions WHERE project_path = ?').run(projectPath);
386
- }
387
- }
388
- catch (err) {
389
- log('WARN', `Failed to delete project terminal sessions: ${err.message}`);
390
- }
391
- }
392
- /**
393
- * Save a file tab to SQLite for persistence across Tower restarts.
394
- * Thin wrapper around utils/file-tabs.ts with error handling and path normalization.
395
- */
396
- function saveFileTab(id, projectPath, filePath, createdAt) {
397
- try {
398
- const normalizedPath = normalizeProjectPath(projectPath);
399
- saveFileTabToDb(getGlobalDb(), id, normalizedPath, filePath, createdAt);
400
- }
401
- catch (err) {
402
- log('WARN', `Failed to save file tab: ${err.message}`);
403
- }
404
- }
405
- /**
406
- * Delete a file tab from SQLite.
407
- * Thin wrapper around utils/file-tabs.ts with error handling.
408
- */
409
- function deleteFileTab(id) {
410
- try {
411
- deleteFileTabFromDb(getGlobalDb(), id);
412
- }
413
- catch (err) {
414
- log('WARN', `Failed to delete file tab: ${err.message}`);
415
- }
416
- }
417
- /**
418
- * Load file tabs for a project from SQLite.
419
- * Thin wrapper around utils/file-tabs.ts with error handling and path normalization.
420
- */
421
- function loadFileTabsForProject(projectPath) {
422
- try {
423
- const normalizedPath = normalizeProjectPath(projectPath);
424
- return loadFileTabsFromDb(getGlobalDb(), normalizedPath);
425
- }
426
- catch (err) {
427
- log('WARN', `Failed to load file tabs: ${err.message}`);
428
- }
429
- return new Map();
430
- }
27
+ // Rate limiting: cleanup interval for token bucket
28
+ const rateLimitCleanupInterval = startRateLimitCleanup();
431
29
  // Shepherd session manager (initialized at startup)
432
30
  let shepherdManager = null;
433
- /**
434
- * Check if a process is running
435
- */
436
- function processExists(pid) {
437
- try {
438
- process.kill(pid, 0);
439
- return true;
440
- }
441
- catch {
442
- return false;
443
- }
444
- }
445
- /**
446
- * Reconcile terminal sessions on startup.
447
- *
448
- * DUAL-SOURCE STRATEGY (shepherd + SQLite):
449
- *
450
- * Phase 1 — Shepherd reconnection:
451
- * For SQLite rows with shepherd_socket IS NOT NULL, attempt to reconnect
452
- * via SessionManager.reconnectSession(). Shepherd processes survive Tower
453
- * restarts as detached OS processes.
454
- *
455
- * Phase 2 — SQLite sweep:
456
- * Any rows not matched in Phase 1 are stale → clean up.
457
- *
458
- * File tabs are the exception: they have no backing process, so SQLite is
459
- * the sole source of truth for their persistence (see file_tabs table).
460
- */
461
- async function reconcileTerminalSessions() {
462
- const manager = getTerminalManager();
463
- const db = getGlobalDb();
464
- let shepherdReconnected = 0;
465
- let orphanReconnected = 0;
466
- let killed = 0;
467
- let cleaned = 0;
468
- // Track matched session IDs across all phases
469
- const matchedSessionIds = new Set();
470
- // ---- Phase 1: Shepherd reconnection ----
471
- let allDbSessions;
472
- try {
473
- allDbSessions = db.prepare('SELECT * FROM terminal_sessions').all();
474
- }
475
- catch (err) {
476
- log('WARN', `Failed to read terminal sessions: ${err.message}`);
477
- allDbSessions = [];
478
- }
479
- const shepherdSessions = allDbSessions.filter(s => s.shepherd_socket !== null);
480
- if (shepherdSessions.length > 0) {
481
- log('INFO', `Found ${shepherdSessions.length} shepherd session(s) in SQLite — reconnecting...`);
482
- }
483
- for (const dbSession of shepherdSessions) {
484
- const projectPath = dbSession.project_path;
485
- // Skip sessions whose project path doesn't exist or is in temp directory
486
- if (!fs.existsSync(projectPath)) {
487
- log('INFO', `Skipping shepherd session ${dbSession.id} — project path no longer exists: ${projectPath}`);
488
- // Kill orphaned shepherd process before removing row
489
- if (dbSession.shepherd_pid && processExists(dbSession.shepherd_pid)) {
490
- try {
491
- process.kill(dbSession.shepherd_pid, 'SIGTERM');
492
- killed++;
493
- }
494
- catch { /* not killable */ }
495
- }
496
- db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbSession.id);
497
- cleaned++;
498
- continue;
499
- }
500
- const tmpDirs = ['/tmp', '/private/tmp', '/var/folders', '/private/var/folders'];
501
- if (tmpDirs.some(d => projectPath === d || projectPath.startsWith(d + '/'))) {
502
- log('INFO', `Skipping shepherd session ${dbSession.id} — project is in temp directory: ${projectPath}`);
503
- // Kill orphaned shepherd process before removing row
504
- if (dbSession.shepherd_pid && processExists(dbSession.shepherd_pid)) {
505
- try {
506
- process.kill(dbSession.shepherd_pid, 'SIGTERM');
507
- killed++;
508
- }
509
- catch { /* not killable */ }
510
- }
511
- db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbSession.id);
512
- cleaned++;
513
- continue;
514
- }
515
- if (!shepherdManager) {
516
- log('WARN', `Shepherd manager not initialized — cannot reconnect ${dbSession.id}`);
517
- continue;
518
- }
519
- try {
520
- // For architect sessions, restore auto-restart behavior after reconnection
521
- let restartOptions;
522
- if (dbSession.type === 'architect') {
523
- let architectCmd = 'claude';
524
- const configPath = path.join(projectPath, 'af-config.json');
525
- if (fs.existsSync(configPath)) {
526
- try {
527
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
528
- if (config.shell?.architect) {
529
- architectCmd = config.shell.architect;
530
- }
531
- }
532
- catch { /* use default */ }
533
- }
534
- const cmdParts = architectCmd.split(/\s+/);
535
- const cleanEnv = { ...process.env };
536
- delete cleanEnv['CLAUDECODE'];
537
- restartOptions = {
538
- command: cmdParts[0],
539
- args: cmdParts.slice(1),
540
- cwd: projectPath,
541
- env: cleanEnv,
542
- restartDelay: 2000,
543
- maxRestarts: 50,
544
- };
545
- }
546
- const client = await shepherdManager.reconnectSession(dbSession.id, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time, restartOptions);
547
- if (!client) {
548
- log('INFO', `Shepherd session ${dbSession.id} is stale (PID/socket dead) — will clean up`);
549
- continue; // Will be cleaned up in Phase 3
550
- }
551
- // Wait for REPLAY frame — the shepherd sends it right after WELCOME,
552
- // but it may arrive in a separate read from the Unix socket.
553
- const replayData = await client.waitForReplay();
554
- const label = dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || 'unknown'}`;
555
- // Create a PtySession backed by the reconnected shepherd client
556
- const session = manager.createSessionRaw({ label, cwd: projectPath });
557
- const ptySession = manager.getSession(session.id);
558
- if (ptySession) {
559
- ptySession.attachShepherd(client, replayData, dbSession.shepherd_pid, dbSession.id);
560
- }
561
- // Register in projectTerminals Map
562
- const entry = getProjectTerminalsEntry(projectPath);
563
- if (dbSession.type === 'architect') {
564
- entry.architect = session.id;
565
- }
566
- else if (dbSession.type === 'builder') {
567
- entry.builders.set(dbSession.role_id || dbSession.id, session.id);
568
- }
569
- else if (dbSession.type === 'shell') {
570
- entry.shells.set(dbSession.role_id || dbSession.id, session.id);
571
- }
572
- // Update SQLite with new terminal ID
573
- db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbSession.id);
574
- saveTerminalSession(session.id, projectPath, dbSession.type, dbSession.role_id, dbSession.shepherd_pid, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time);
575
- registerKnownProject(projectPath);
576
- // Clean up on exit
577
- if (ptySession) {
578
- ptySession.on('exit', () => {
579
- const currentEntry = getProjectTerminalsEntry(projectPath);
580
- if (dbSession.type === 'architect' && currentEntry.architect === session.id) {
581
- currentEntry.architect = undefined;
582
- }
583
- deleteTerminalSession(session.id);
584
- });
585
- }
586
- matchedSessionIds.add(dbSession.id);
587
- shepherdReconnected++;
588
- log('INFO', `Reconnected shepherd session → ${session.id} (${dbSession.type} for ${path.basename(projectPath)})`);
589
- }
590
- catch (err) {
591
- log('WARN', `Failed to reconnect shepherd session ${dbSession.id}: ${err.message}`);
592
- }
593
- }
594
- // ---- Phase 2: Sweep stale SQLite rows ----
595
- for (const session of allDbSessions) {
596
- if (matchedSessionIds.has(session.id))
597
- continue;
598
- const existing = manager.getSession(session.id);
599
- if (existing && existing.status !== 'exited')
600
- continue;
601
- // Stale row — kill orphaned process if any, then delete
602
- if (session.pid && processExists(session.pid)) {
603
- log('INFO', `Killing orphaned process: PID ${session.pid} (${session.type} for ${path.basename(session.project_path)})`);
604
- try {
605
- process.kill(session.pid, 'SIGTERM');
606
- killed++;
607
- }
608
- catch { /* process not killable */ }
609
- }
610
- db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
611
- cleaned++;
612
- }
613
- const total = shepherdReconnected + orphanReconnected;
614
- if (total > 0 || killed > 0 || cleaned > 0) {
615
- log('INFO', `Reconciliation complete: ${shepherdReconnected} shepherd, ${orphanReconnected} orphan, ${killed} killed, ${cleaned} stale rows cleaned`);
616
- }
617
- else {
618
- log('INFO', 'No terminal sessions to reconcile');
619
- }
620
- }
621
- /**
622
- * Get terminal sessions from SQLite for a project.
623
- * Normalizes path for consistent lookup.
624
- */
625
- function getTerminalSessionsForProject(projectPath) {
626
- try {
627
- const normalizedPath = normalizeProjectPath(projectPath);
628
- const db = getGlobalDb();
629
- return db.prepare('SELECT * FROM terminal_sessions WHERE project_path = ?').all(normalizedPath);
630
- }
631
- catch {
632
- return [];
633
- }
634
- }
635
- /**
636
- * Handle WebSocket connection to a terminal session
637
- * Uses hybrid binary protocol (Spec 0085):
638
- * - 0x00 prefix: Control frame (JSON)
639
- * - 0x01 prefix: Data frame (raw PTY bytes)
640
- */
641
- function handleTerminalWebSocket(ws, session, req) {
642
- const resumeSeq = req.headers['x-session-resume'];
643
- // Create a client adapter for the PTY session
644
- // Uses binary protocol for data frames
645
- const client = {
646
- send: (data) => {
647
- if (ws.readyState === WebSocket.OPEN) {
648
- // Encode as binary data frame (0x01 prefix)
649
- ws.send(encodeData(data));
650
- }
651
- },
652
- };
653
- // Attach client to session and get replay data
654
- let replayLines;
655
- if (resumeSeq && typeof resumeSeq === 'string') {
656
- replayLines = session.attachResume(client, parseInt(resumeSeq, 10));
657
- }
658
- else {
659
- replayLines = session.attach(client);
660
- }
661
- // Send replay data as binary data frame
662
- if (replayLines.length > 0) {
663
- const replayData = replayLines.join('\n');
664
- if (ws.readyState === WebSocket.OPEN) {
665
- ws.send(encodeData(replayData));
666
- }
667
- }
668
- // Handle incoming messages from client (binary protocol)
669
- ws.on('message', (rawData) => {
670
- try {
671
- const frame = decodeFrame(Buffer.from(rawData));
672
- if (frame.type === 'data') {
673
- // Write raw input to terminal
674
- session.write(frame.data.toString('utf-8'));
675
- }
676
- else if (frame.type === 'control') {
677
- // Handle control messages
678
- const msg = frame.message;
679
- if (msg.type === 'resize') {
680
- const cols = msg.payload.cols;
681
- const rows = msg.payload.rows;
682
- if (typeof cols === 'number' && typeof rows === 'number') {
683
- session.resize(cols, rows);
684
- }
685
- }
686
- else if (msg.type === 'ping') {
687
- if (ws.readyState === WebSocket.OPEN) {
688
- ws.send(encodeControl({ type: 'pong', payload: {} }));
689
- }
690
- }
691
- }
692
- }
693
- catch {
694
- // If decode fails, try treating as raw UTF-8 input (for simpler clients)
695
- try {
696
- session.write(rawData.toString('utf-8'));
697
- }
698
- catch {
699
- // Ignore malformed input
700
- }
701
- }
702
- });
703
- ws.on('close', () => {
704
- session.detach(client);
705
- });
706
- ws.on('error', () => {
707
- session.detach(client);
708
- });
709
- }
710
31
  // Parse arguments with Commander
711
32
  const program = new Command()
712
33
  .name('tower-server')
@@ -763,12 +84,7 @@ async function gracefulShutdown(signal) {
763
84
  }
764
85
  terminalWss.close();
765
86
  }
766
- // 3. Kill all PTY sessions
767
- if (terminalManager) {
768
- log('INFO', 'Shutting down terminal manager...');
769
- terminalManager.shutdown();
770
- }
771
- // 3b. Shepherd clients: do NOT call shepherdManager.shutdown() here.
87
+ // 3. Shepherd clients: do NOT call shepherdManager.shutdown() here.
772
88
  // SessionManager.shutdown() disconnects sockets, which triggers ShepherdClient
773
89
  // 'close' events → PtySession exit(-1) → SQLite row deletion. This would erase
774
90
  // the rows that reconcileTerminalSessions() needs on restart.
@@ -777,19 +93,14 @@ async function gracefulShutdown(signal) {
777
93
  if (shepherdManager) {
778
94
  log('INFO', 'Shepherd sessions will continue running (sockets close on process exit)');
779
95
  }
780
- // 4. Stop gate watcher
781
- if (gateWatcherInterval) {
782
- clearInterval(gateWatcherInterval);
783
- gateWatcherInterval = null;
784
- }
785
- // 5. Disconnect tunnel (Spec 0097 Phase 4)
786
- stopMetadataRefresh();
787
- stopConfigWatcher();
788
- if (tunnelClient) {
789
- log('INFO', 'Disconnecting tunnel...');
790
- tunnelClient.disconnect();
791
- tunnelClient = null;
792
- }
96
+ // 4. Stop rate limit cleanup
97
+ clearInterval(rateLimitCleanupInterval);
98
+ // 5. Disconnect tunnel (Spec 0097 Phase 4 / Spec 0105 Phase 2)
99
+ shutdownTunnel();
100
+ // 6. Tear down instance module (Spec 0105 Phase 3)
101
+ shutdownInstances();
102
+ // 7. Tear down terminal module (Spec 0105 Phase 4) — stops gate watcher, shuts down terminal manager
103
+ shutdownTerminals();
793
104
  log('INFO', 'Graceful shutdown complete');
794
105
  process.exit(0);
795
106
  }
@@ -801,79 +112,7 @@ if (isNaN(port) || port < 1 || port > 65535) {
801
112
  process.exit(1);
802
113
  }
803
114
  log('INFO', `Tower server starting on port ${port}`);
804
- /**
805
- * Register a project in the known_projects table so it persists across restarts
806
- * even when all terminal sessions are gone.
807
- */
808
- function registerKnownProject(projectPath) {
809
- try {
810
- const db = getGlobalDb();
811
- db.prepare(`
812
- INSERT INTO known_projects (project_path, name, last_launched_at)
813
- VALUES (?, ?, datetime('now'))
814
- ON CONFLICT(project_path) DO UPDATE SET last_launched_at = datetime('now')
815
- `).run(projectPath, path.basename(projectPath));
816
- }
817
- catch {
818
- // Table may not exist yet (pre-migration)
819
- }
820
- }
821
- /**
822
- * Get all known project paths from known_projects, terminal_sessions, and in-memory cache
823
- */
824
- function getKnownProjectPaths() {
825
- const projectPaths = new Set();
826
- // From known_projects table (persists even after all terminals are killed)
827
- try {
828
- const db = getGlobalDb();
829
- const projects = db.prepare('SELECT project_path FROM known_projects').all();
830
- for (const p of projects) {
831
- projectPaths.add(p.project_path);
832
- }
833
- }
834
- catch {
835
- // Table may not exist yet
836
- }
837
- // From terminal_sessions table (catches any missed by known_projects)
838
- try {
839
- const db = getGlobalDb();
840
- const sessions = db.prepare('SELECT DISTINCT project_path FROM terminal_sessions').all();
841
- for (const s of sessions) {
842
- projectPaths.add(s.project_path);
843
- }
844
- }
845
- catch {
846
- // Table may not exist yet
847
- }
848
- // From in-memory cache (includes projects activated this session)
849
- for (const [projectPath] of projectTerminals) {
850
- projectPaths.add(projectPath);
851
- }
852
- return Array.from(projectPaths);
853
- }
854
- /**
855
- * Get project name from path
856
- */
857
- function getProjectName(projectPath) {
858
- return path.basename(projectPath);
859
- }
860
- // Spec 0100: Gate watcher for af send notifications
861
- const gateWatcher = new GateWatcher(log);
862
- let gateWatcherInterval = null;
863
- function startGateWatcher() {
864
- gateWatcherInterval = setInterval(async () => {
865
- const projectPaths = getKnownProjectPaths();
866
- for (const projectPath of projectPaths) {
867
- try {
868
- const gateStatus = getGateStatusForProject(projectPath);
869
- await gateWatcher.checkAndNotify(gateStatus, projectPath);
870
- }
871
- catch (err) {
872
- log('WARN', `Gate watcher error for ${projectPath}: ${err instanceof Error ? err.message : String(err)}`);
873
- }
874
- }
875
- }, 10_000);
876
- }
115
+ // SSE (Server-Sent Events) infrastructure for push notifications
877
116
  const sseClients = [];
878
117
  let notificationIdCounter = 0;
879
118
  /**
@@ -892,539 +131,6 @@ function broadcastNotification(notification) {
892
131
  }
893
132
  }
894
133
  }
895
- /**
896
- * Get terminal list for a project from tower's registry.
897
- * Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server fetch.
898
- * Returns architect, builders, and shells with their URLs.
899
- */
900
- async function getTerminalsForProject(projectPath, proxyUrl) {
901
- const manager = getTerminalManager();
902
- const terminals = [];
903
- // Query SQLite first, then augment with shepherd reconnection
904
- const dbSessions = getTerminalSessionsForProject(projectPath);
905
- // Use normalized path for cache consistency
906
- const normalizedPath = normalizeProjectPath(projectPath);
907
- // Build a fresh entry from SQLite, then replace atomically to avoid
908
- // destroying in-memory state that was registered via POST /api/terminals.
909
- // Previous approach cleared the cache then rebuilt, which lost terminals
910
- // if their SQLite rows were deleted by external interference (e.g., tests).
911
- const freshEntry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
912
- // Load file tabs from SQLite (persisted across restarts)
913
- const existingEntry = projectTerminals.get(normalizedPath);
914
- if (existingEntry && existingEntry.fileTabs.size > 0) {
915
- // Use in-memory state if already populated (avoids redundant DB reads)
916
- freshEntry.fileTabs = existingEntry.fileTabs;
917
- }
918
- else {
919
- freshEntry.fileTabs = loadFileTabsForProject(projectPath);
920
- }
921
- for (const dbSession of dbSessions) {
922
- // Verify session still exists in TerminalManager (runtime state)
923
- let session = manager.getSession(dbSession.id);
924
- if (!session && dbSession.shepherd_socket && shepherdManager) {
925
- // PTY session gone but shepherd may still be alive — reconnect on-the-fly
926
- try {
927
- // Restore auto-restart for architect sessions (same as startup reconciliation)
928
- let restartOptions;
929
- if (dbSession.type === 'architect') {
930
- let architectCmd = 'claude';
931
- const configPath = path.join(dbSession.project_path, 'af-config.json');
932
- if (fs.existsSync(configPath)) {
933
- try {
934
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
935
- if (config.shell?.architect) {
936
- architectCmd = config.shell.architect;
937
- }
938
- }
939
- catch { /* use default */ }
940
- }
941
- const cmdParts = architectCmd.split(/\s+/);
942
- const cleanEnv = { ...process.env };
943
- delete cleanEnv['CLAUDECODE'];
944
- restartOptions = {
945
- command: cmdParts[0],
946
- args: cmdParts.slice(1),
947
- cwd: dbSession.project_path,
948
- env: cleanEnv,
949
- restartDelay: 2000,
950
- maxRestarts: 50,
951
- };
952
- }
953
- const client = await shepherdManager.reconnectSession(dbSession.id, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time, restartOptions);
954
- if (client) {
955
- // Wait for REPLAY frame — same race as startup reconciliation path
956
- const replayData = await client.waitForReplay();
957
- const label = dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || dbSession.id}`;
958
- const newSession = manager.createSessionRaw({ label, cwd: dbSession.project_path });
959
- const ptySession = manager.getSession(newSession.id);
960
- if (ptySession) {
961
- ptySession.attachShepherd(client, replayData, dbSession.shepherd_pid, dbSession.id);
962
- // Clean up on exit (same as startup reconciliation path)
963
- ptySession.on('exit', () => {
964
- const currentEntry = getProjectTerminalsEntry(dbSession.project_path);
965
- if (dbSession.type === 'architect' && currentEntry.architect === newSession.id) {
966
- currentEntry.architect = undefined;
967
- }
968
- deleteTerminalSession(newSession.id);
969
- });
970
- }
971
- deleteTerminalSession(dbSession.id);
972
- saveTerminalSession(newSession.id, dbSession.project_path, dbSession.type, dbSession.role_id, dbSession.shepherd_pid, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time);
973
- dbSession.id = newSession.id;
974
- session = manager.getSession(newSession.id);
975
- log('INFO', `Reconnected to shepherd on-the-fly → ${newSession.id}`);
976
- }
977
- }
978
- catch (err) {
979
- log('WARN', `Failed shepherd on-the-fly reconnect for ${dbSession.id}: ${err.message}`);
980
- }
981
- }
982
- if (!session) {
983
- // Stale row, nothing to reconnect — clean up
984
- deleteTerminalSession(dbSession.id);
985
- continue;
986
- }
987
- if (dbSession.type === 'architect') {
988
- freshEntry.architect = dbSession.id;
989
- terminals.push({
990
- type: 'architect',
991
- id: 'architect',
992
- label: 'Architect',
993
- url: `${proxyUrl}?tab=architect`,
994
- active: true,
995
- });
996
- }
997
- else if (dbSession.type === 'builder') {
998
- const builderId = dbSession.role_id || dbSession.id;
999
- freshEntry.builders.set(builderId, dbSession.id);
1000
- terminals.push({
1001
- type: 'builder',
1002
- id: builderId,
1003
- label: `Builder ${builderId}`,
1004
- url: `${proxyUrl}?tab=builder-${builderId}`,
1005
- active: true,
1006
- });
1007
- }
1008
- else if (dbSession.type === 'shell') {
1009
- const shellId = dbSession.role_id || dbSession.id;
1010
- freshEntry.shells.set(shellId, dbSession.id);
1011
- terminals.push({
1012
- type: 'shell',
1013
- id: shellId,
1014
- label: `Shell ${shellId.replace('shell-', '')}`,
1015
- url: `${proxyUrl}?tab=shell-${shellId}`,
1016
- active: true,
1017
- });
1018
- }
1019
- }
1020
- // Also merge in-memory entries that may not be in SQLite yet
1021
- // (e.g., registered via POST /api/terminals but SQLite row was lost)
1022
- if (existingEntry) {
1023
- if (existingEntry.architect && !freshEntry.architect) {
1024
- const session = manager.getSession(existingEntry.architect);
1025
- if (session && session.status === 'running') {
1026
- freshEntry.architect = existingEntry.architect;
1027
- terminals.push({
1028
- type: 'architect',
1029
- id: 'architect',
1030
- label: 'Architect',
1031
- url: `${proxyUrl}?tab=architect`,
1032
- active: true,
1033
- });
1034
- }
1035
- }
1036
- for (const [builderId, terminalId] of existingEntry.builders) {
1037
- if (!freshEntry.builders.has(builderId)) {
1038
- const session = manager.getSession(terminalId);
1039
- if (session && session.status === 'running') {
1040
- freshEntry.builders.set(builderId, terminalId);
1041
- terminals.push({
1042
- type: 'builder',
1043
- id: builderId,
1044
- label: `Builder ${builderId}`,
1045
- url: `${proxyUrl}?tab=builder-${builderId}`,
1046
- active: true,
1047
- });
1048
- }
1049
- }
1050
- }
1051
- for (const [shellId, terminalId] of existingEntry.shells) {
1052
- if (!freshEntry.shells.has(shellId)) {
1053
- const session = manager.getSession(terminalId);
1054
- if (session && session.status === 'running') {
1055
- freshEntry.shells.set(shellId, terminalId);
1056
- terminals.push({
1057
- type: 'shell',
1058
- id: shellId,
1059
- label: `Shell ${shellId.replace('shell-', '')}`,
1060
- url: `${proxyUrl}?tab=shell-${shellId}`,
1061
- active: true,
1062
- });
1063
- }
1064
- }
1065
- }
1066
- }
1067
- // Atomically replace the cache entry
1068
- projectTerminals.set(normalizedPath, freshEntry);
1069
- // Read gate status from porch YAML files
1070
- const gateStatus = getGateStatusForProject(projectPath);
1071
- return { terminals, gateStatus };
1072
- }
1073
- // Resolve once at module load: both symlinked and real temp dir paths
1074
- const _tmpDir = tmpdir();
1075
- const _tmpDirResolved = (() => {
1076
- try {
1077
- return fs.realpathSync(_tmpDir);
1078
- }
1079
- catch {
1080
- return _tmpDir;
1081
- }
1082
- })();
1083
- function isTempDirectory(projectPath) {
1084
- return (projectPath.startsWith(_tmpDir + '/') ||
1085
- projectPath.startsWith(_tmpDirResolved + '/') ||
1086
- projectPath.startsWith('/tmp/') ||
1087
- projectPath.startsWith('/private/tmp/'));
1088
- }
1089
- /**
1090
- * Get all instances with their status
1091
- */
1092
- async function getInstances() {
1093
- const knownPaths = getKnownProjectPaths();
1094
- const instances = [];
1095
- for (const projectPath of knownPaths) {
1096
- // Skip builder worktrees - they're managed by their parent project
1097
- if (projectPath.includes('/.builders/')) {
1098
- continue;
1099
- }
1100
- // Skip projects in temp directories (e.g. test artifacts) or whose directories no longer exist
1101
- if (!projectPath.startsWith('remote:')) {
1102
- if (!fs.existsSync(projectPath)) {
1103
- continue;
1104
- }
1105
- if (isTempDirectory(projectPath)) {
1106
- continue;
1107
- }
1108
- }
1109
- // Encode project path for proxy URL
1110
- const encodedPath = Buffer.from(projectPath).toString('base64url');
1111
- const proxyUrl = `/project/${encodedPath}/`;
1112
- // Get terminals and gate status from tower's registry
1113
- // Phase 4 (Spec 0090): Tower manages terminals directly - no separate dashboard server
1114
- const { terminals, gateStatus } = await getTerminalsForProject(projectPath, proxyUrl);
1115
- // Project is active if it has any terminals (Phase 4: no port check needed)
1116
- const isActive = terminals.length > 0;
1117
- instances.push({
1118
- projectPath,
1119
- projectName: getProjectName(projectPath),
1120
- running: isActive,
1121
- proxyUrl,
1122
- architectUrl: `${proxyUrl}?tab=architect`,
1123
- terminals,
1124
- gateStatus,
1125
- });
1126
- }
1127
- // Sort: running first, then by project name
1128
- instances.sort((a, b) => {
1129
- if (a.running !== b.running) {
1130
- return a.running ? -1 : 1;
1131
- }
1132
- return a.projectName.localeCompare(b.projectName);
1133
- });
1134
- return instances;
1135
- }
1136
- /**
1137
- * Get directory suggestions for autocomplete
1138
- */
1139
- async function getDirectorySuggestions(inputPath) {
1140
- // Default to home directory if empty
1141
- if (!inputPath) {
1142
- inputPath = homedir();
1143
- }
1144
- // Expand ~ to home directory
1145
- if (inputPath.startsWith('~')) {
1146
- inputPath = inputPath.replace('~', homedir());
1147
- }
1148
- // Relative paths are meaningless for the tower daemon — only absolute paths
1149
- if (!path.isAbsolute(inputPath)) {
1150
- return [];
1151
- }
1152
- // Determine the directory to list and the prefix to filter by
1153
- let dirToList;
1154
- let prefix;
1155
- if (inputPath.endsWith('/')) {
1156
- // User typed a complete directory path, list its contents
1157
- dirToList = inputPath;
1158
- prefix = '';
1159
- }
1160
- else {
1161
- // User is typing a partial name, list parent and filter
1162
- dirToList = path.dirname(inputPath);
1163
- prefix = path.basename(inputPath).toLowerCase();
1164
- }
1165
- // Check if directory exists
1166
- if (!fs.existsSync(dirToList)) {
1167
- return [];
1168
- }
1169
- const stat = fs.statSync(dirToList);
1170
- if (!stat.isDirectory()) {
1171
- return [];
1172
- }
1173
- // Read directory contents
1174
- const entries = fs.readdirSync(dirToList, { withFileTypes: true });
1175
- // Filter to directories only, apply prefix filter, and check for codev/
1176
- const suggestions = [];
1177
- for (const entry of entries) {
1178
- if (!entry.isDirectory())
1179
- continue;
1180
- if (entry.name.startsWith('.'))
1181
- continue; // Skip hidden directories
1182
- const name = entry.name.toLowerCase();
1183
- if (prefix && !name.startsWith(prefix))
1184
- continue;
1185
- const fullPath = path.join(dirToList, entry.name);
1186
- const isProject = fs.existsSync(path.join(fullPath, 'codev'));
1187
- suggestions.push({ path: fullPath, isProject });
1188
- }
1189
- // Sort: projects first, then alphabetically
1190
- suggestions.sort((a, b) => {
1191
- if (a.isProject !== b.isProject) {
1192
- return a.isProject ? -1 : 1;
1193
- }
1194
- return a.path.localeCompare(b.path);
1195
- });
1196
- // Limit to 20 suggestions
1197
- return suggestions.slice(0, 20);
1198
- }
1199
- /**
1200
- * Launch a new agent-farm instance
1201
- * Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server
1202
- * Auto-adopts non-codev directories and creates architect terminal
1203
- */
1204
- async function launchInstance(projectPath) {
1205
- // Validate path exists
1206
- if (!fs.existsSync(projectPath)) {
1207
- return { success: false, error: `Path does not exist: ${projectPath}` };
1208
- }
1209
- // Validate it's a directory
1210
- const stat = fs.statSync(projectPath);
1211
- if (!stat.isDirectory()) {
1212
- return { success: false, error: `Not a directory: ${projectPath}` };
1213
- }
1214
- // Auto-adopt non-codev directories
1215
- const codevDir = path.join(projectPath, 'codev');
1216
- let adopted = false;
1217
- if (!fs.existsSync(codevDir)) {
1218
- try {
1219
- // Run codev adopt --yes to set up the project
1220
- execSync('npx codev adopt --yes', {
1221
- cwd: projectPath,
1222
- stdio: 'pipe',
1223
- timeout: 30000,
1224
- });
1225
- adopted = true;
1226
- log('INFO', `Auto-adopted codev in: ${projectPath}`);
1227
- }
1228
- catch (err) {
1229
- return { success: false, error: `Failed to adopt codev: ${err.message}` };
1230
- }
1231
- }
1232
- // Phase 4 (Spec 0090): Tower manages terminals directly
1233
- // No dashboard-server spawning - tower handles everything
1234
- try {
1235
- // Ensure project has port allocation
1236
- const resolvedPath = fs.realpathSync(projectPath);
1237
- // Persist in known_projects so the project survives terminal cleanup
1238
- registerKnownProject(resolvedPath);
1239
- // Initialize project terminal entry
1240
- const entry = getProjectTerminalsEntry(resolvedPath);
1241
- // Create architect terminal if not already present
1242
- if (!entry.architect) {
1243
- const manager = getTerminalManager();
1244
- // Read af-config.json to get the architect command
1245
- let architectCmd = 'claude';
1246
- const configPath = path.join(projectPath, 'af-config.json');
1247
- if (fs.existsSync(configPath)) {
1248
- try {
1249
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1250
- if (config.shell?.architect) {
1251
- architectCmd = config.shell.architect;
1252
- }
1253
- }
1254
- catch {
1255
- // Ignore config read errors, use default
1256
- }
1257
- }
1258
- try {
1259
- // Parse command string to separate command and args
1260
- const cmdParts = architectCmd.split(/\s+/);
1261
- const cmd = cmdParts[0];
1262
- const cmdArgs = cmdParts.slice(1);
1263
- // Build env with CLAUDECODE removed so spawned Claude processes
1264
- // don't detect a nested session
1265
- const cleanEnv = { ...process.env };
1266
- delete cleanEnv['CLAUDECODE'];
1267
- // Try shepherd first for persistent session with auto-restart
1268
- let shepherdCreated = false;
1269
- if (shepherdManager) {
1270
- try {
1271
- const sessionId = crypto.randomUUID();
1272
- const client = await shepherdManager.createSession({
1273
- sessionId,
1274
- command: cmd,
1275
- args: cmdArgs,
1276
- cwd: projectPath,
1277
- env: cleanEnv,
1278
- cols: 200,
1279
- rows: 50,
1280
- restartOnExit: true,
1281
- restartDelay: 2000,
1282
- maxRestarts: 50,
1283
- });
1284
- // Get replay data and shepherd info
1285
- const replayData = client.getReplayData() ?? Buffer.alloc(0);
1286
- const shepherdInfo = shepherdManager.getSessionInfo(sessionId);
1287
- // Create a PtySession backed by the shepherd client
1288
- const session = manager.createSessionRaw({
1289
- label: 'Architect',
1290
- cwd: projectPath,
1291
- });
1292
- const ptySession = manager.getSession(session.id);
1293
- if (ptySession) {
1294
- ptySession.attachShepherd(client, replayData, shepherdInfo.pid, sessionId);
1295
- }
1296
- entry.architect = session.id;
1297
- saveTerminalSession(session.id, resolvedPath, 'architect', null, shepherdInfo.pid, shepherdInfo.socketPath, shepherdInfo.pid, shepherdInfo.startTime);
1298
- // Clean up cache/SQLite when the shepherd session exits
1299
- if (ptySession) {
1300
- ptySession.on('exit', () => {
1301
- const currentEntry = getProjectTerminalsEntry(resolvedPath);
1302
- if (currentEntry.architect === session.id) {
1303
- currentEntry.architect = undefined;
1304
- }
1305
- deleteTerminalSession(session.id);
1306
- log('INFO', `Architect shepherd session exited for ${projectPath}`);
1307
- });
1308
- }
1309
- shepherdCreated = true;
1310
- log('INFO', `Created shepherd-backed architect session for project: ${projectPath}`);
1311
- }
1312
- catch (shepherdErr) {
1313
- log('WARN', `Shepherd creation failed for architect, falling back: ${shepherdErr.message}`);
1314
- }
1315
- }
1316
- // Fallback: non-persistent session (graceful degradation per plan)
1317
- // Shepherd is the only persistence backend for new sessions.
1318
- if (!shepherdCreated) {
1319
- const session = await manager.createSession({
1320
- command: cmd,
1321
- args: cmdArgs,
1322
- cwd: projectPath,
1323
- label: 'Architect',
1324
- env: cleanEnv,
1325
- });
1326
- entry.architect = session.id;
1327
- saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid);
1328
- const ptySession = manager.getSession(session.id);
1329
- if (ptySession) {
1330
- ptySession.on('exit', () => {
1331
- const currentEntry = getProjectTerminalsEntry(resolvedPath);
1332
- if (currentEntry.architect === session.id) {
1333
- currentEntry.architect = undefined;
1334
- }
1335
- deleteTerminalSession(session.id);
1336
- log('INFO', `Architect pty exited for ${projectPath}`);
1337
- });
1338
- }
1339
- log('WARN', `Architect terminal for ${projectPath} is non-persistent (shepherd unavailable)`);
1340
- }
1341
- log('INFO', `Created architect terminal for project: ${projectPath}`);
1342
- }
1343
- catch (err) {
1344
- log('WARN', `Failed to create architect terminal: ${err.message}`);
1345
- // Don't fail the launch - project is still active, just without architect
1346
- }
1347
- }
1348
- return { success: true, adopted };
1349
- }
1350
- catch (err) {
1351
- return { success: false, error: `Failed to launch: ${err.message}` };
1352
- }
1353
- }
1354
- /**
1355
- * Kill a terminal session, including its shepherd auto-restart if applicable.
1356
- * For shepherd-backed sessions, calls SessionManager.killSession() which clears
1357
- * the restart timer and removes the session before sending SIGTERM, preventing
1358
- * the shepherd from auto-restarting the process.
1359
- */
1360
- async function killTerminalWithShepherd(manager, terminalId) {
1361
- const session = manager.getSession(terminalId);
1362
- if (!session)
1363
- return false;
1364
- // If shepherd-backed, disable auto-restart via SessionManager before killing the PtySession
1365
- if (session.shepherdBacked && session.shepherdSessionId && shepherdManager) {
1366
- await shepherdManager.killSession(session.shepherdSessionId);
1367
- }
1368
- return manager.killSession(terminalId);
1369
- }
1370
- /**
1371
- * Stop an agent-farm instance by killing all its terminals
1372
- * Phase 4 (Spec 0090): Tower manages terminals directly
1373
- */
1374
- async function stopInstance(projectPath) {
1375
- const stopped = [];
1376
- const manager = getTerminalManager();
1377
- // Resolve symlinks for consistent lookup
1378
- let resolvedPath = projectPath;
1379
- try {
1380
- if (fs.existsSync(projectPath)) {
1381
- resolvedPath = fs.realpathSync(projectPath);
1382
- }
1383
- }
1384
- catch {
1385
- // Ignore - use original path
1386
- }
1387
- // Get project terminals
1388
- const entry = projectTerminals.get(resolvedPath) || projectTerminals.get(projectPath);
1389
- if (entry) {
1390
- // Kill architect (disable shepherd auto-restart if applicable)
1391
- if (entry.architect) {
1392
- const session = manager.getSession(entry.architect);
1393
- if (session) {
1394
- await killTerminalWithShepherd(manager, entry.architect);
1395
- stopped.push(session.pid);
1396
- }
1397
- }
1398
- // Kill all shells (disable shepherd auto-restart if applicable)
1399
- for (const terminalId of entry.shells.values()) {
1400
- const session = manager.getSession(terminalId);
1401
- if (session) {
1402
- await killTerminalWithShepherd(manager, terminalId);
1403
- stopped.push(session.pid);
1404
- }
1405
- }
1406
- // Kill all builders (disable shepherd auto-restart if applicable)
1407
- for (const terminalId of entry.builders.values()) {
1408
- const session = manager.getSession(terminalId);
1409
- if (session) {
1410
- await killTerminalWithShepherd(manager, terminalId);
1411
- stopped.push(session.pid);
1412
- }
1413
- }
1414
- // Clear project from registry
1415
- projectTerminals.delete(resolvedPath);
1416
- projectTerminals.delete(projectPath);
1417
- // TICK-001: Delete all terminal sessions from SQLite
1418
- deleteProjectTerminalSessions(resolvedPath);
1419
- if (resolvedPath !== projectPath) {
1420
- deleteProjectTerminalSessions(projectPath);
1421
- }
1422
- }
1423
- if (stopped.length === 0) {
1424
- return { success: true, error: 'No terminals found to stop', stopped };
1425
- }
1426
- return { success: true, stopped };
1427
- }
1428
134
  /**
1429
135
  * Find the tower template
1430
136
  * Template is bundled with agent-farm package in templates/ directory
@@ -1439,7 +145,6 @@ function findTemplatePath() {
1439
145
  }
1440
146
  return null;
1441
147
  }
1442
- // escapeHtml, parseJsonBody, isRequestAllowed imported from ../utils/server-utils.js
1443
148
  // Find template path
1444
149
  const templatePath = findTemplatePath();
1445
150
  // WebSocket server for terminal connections (Phase 2 - Spec 0090)
@@ -1454,1453 +159,32 @@ if (hasReactDashboard) {
1454
159
  else {
1455
160
  log('WARN', 'React dashboard not found - project dashboards will not work');
1456
161
  }
1457
- // MIME types for static file serving
1458
- const MIME_TYPES = {
1459
- '.html': 'text/html',
1460
- '.js': 'application/javascript',
1461
- '.css': 'text/css',
1462
- '.json': 'application/json',
1463
- '.png': 'image/png',
1464
- '.jpg': 'image/jpeg',
1465
- '.gif': 'image/gif',
1466
- '.svg': 'image/svg+xml',
1467
- '.ico': 'image/x-icon',
1468
- '.woff': 'font/woff',
1469
- '.woff2': 'font/woff2',
1470
- '.ttf': 'font/ttf',
1471
- '.map': 'application/json',
162
+ // ============================================================================
163
+ // Route context — wires orchestrator state into route handlers
164
+ // ============================================================================
165
+ const routeCtx = {
166
+ log,
167
+ port,
168
+ templatePath,
169
+ reactDashboardPath,
170
+ hasReactDashboard,
171
+ getShepherdManager: () => shepherdManager,
172
+ broadcastNotification,
173
+ addSseClient: (client) => {
174
+ sseClients.push(client);
175
+ },
176
+ removeSseClient: (id) => {
177
+ const index = sseClients.findIndex(c => c.id === id);
178
+ if (index !== -1) {
179
+ sseClients.splice(index, 1);
180
+ }
181
+ },
1472
182
  };
1473
- /**
1474
- * Serve a static file from the React dashboard dist
1475
- */
1476
- function serveStaticFile(filePath, res) {
1477
- if (!fs.existsSync(filePath)) {
1478
- return false;
1479
- }
1480
- const ext = path.extname(filePath);
1481
- const contentType = MIME_TYPES[ext] || 'application/octet-stream';
1482
- try {
1483
- const content = fs.readFileSync(filePath);
1484
- res.writeHead(200, { 'Content-Type': contentType });
1485
- res.end(content);
1486
- return true;
1487
- }
1488
- catch {
1489
- return false;
1490
- }
1491
- }
1492
- /**
1493
- * Handle tunnel management endpoints (Spec 0097 Phase 4).
1494
- * Extracted so both /api/tunnel/* and /project/<encoded>/api/tunnel/* can use it.
1495
- */
1496
- async function handleTunnelEndpoint(req, res, tunnelSub) {
1497
- // POST connect
1498
- if (req.method === 'POST' && tunnelSub === 'connect') {
1499
- try {
1500
- const config = readCloudConfig();
1501
- if (!config) {
1502
- res.writeHead(400, { 'Content-Type': 'application/json' });
1503
- res.end(JSON.stringify({ success: false, error: 'Not registered. Run \'af tower register\' first.' }));
1504
- return;
1505
- }
1506
- if (tunnelClient)
1507
- tunnelClient.resetCircuitBreaker();
1508
- const client = await connectTunnel(config);
1509
- res.writeHead(200, { 'Content-Type': 'application/json' });
1510
- res.end(JSON.stringify({ success: true, state: client.getState() }));
1511
- }
1512
- catch (err) {
1513
- log('ERROR', `Tunnel connect failed: ${err.message}`);
1514
- res.writeHead(500, { 'Content-Type': 'application/json' });
1515
- res.end(JSON.stringify({ success: false, error: err.message }));
1516
- }
1517
- return;
1518
- }
1519
- // POST disconnect
1520
- if (req.method === 'POST' && tunnelSub === 'disconnect') {
1521
- if (tunnelClient) {
1522
- tunnelClient.disconnect();
1523
- tunnelClient = null;
1524
- }
1525
- res.writeHead(200, { 'Content-Type': 'application/json' });
1526
- res.end(JSON.stringify({ success: true }));
1527
- return;
1528
- }
1529
- // GET status
1530
- if (req.method === 'GET' && tunnelSub === 'status') {
1531
- let config = null;
1532
- try {
1533
- config = readCloudConfig();
1534
- }
1535
- catch {
1536
- // Config file may be corrupted — treat as unregistered
1537
- }
1538
- const state = tunnelClient?.getState() ?? 'disconnected';
1539
- const uptime = tunnelClient?.getUptime() ?? null;
1540
- const response = {
1541
- registered: config !== null,
1542
- state,
1543
- uptime,
1544
- };
1545
- if (config) {
1546
- response.towerId = config.tower_id;
1547
- response.towerName = config.tower_name;
1548
- response.serverUrl = config.server_url;
1549
- response.accessUrl = `${config.server_url}/t/${config.tower_name}/`;
1550
- }
1551
- res.writeHead(200, { 'Content-Type': 'application/json' });
1552
- res.end(JSON.stringify(response));
1553
- return;
1554
- }
1555
- // Unknown tunnel endpoint
1556
- res.writeHead(404, { 'Content-Type': 'application/json' });
1557
- res.end(JSON.stringify({ error: 'Not found' }));
1558
- }
1559
- // Create server
183
+ // ============================================================================
184
+ // Create server delegates all HTTP handling to tower-routes.ts
185
+ // ============================================================================
1560
186
  const server = http.createServer(async (req, res) => {
1561
- // Security: Validate Host and Origin headers
1562
- if (!isRequestAllowed(req)) {
1563
- res.writeHead(403, { 'Content-Type': 'text/plain' });
1564
- res.end('Forbidden');
1565
- return;
1566
- }
1567
- // CORS headers — allow localhost and tunnel proxy origins
1568
- const origin = req.headers.origin;
1569
- if (origin && (origin.startsWith('http://localhost:') ||
1570
- origin.startsWith('http://127.0.0.1:') ||
1571
- origin.startsWith('https://'))) {
1572
- res.setHeader('Access-Control-Allow-Origin', origin);
1573
- }
1574
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
1575
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
1576
- res.setHeader('Cache-Control', 'no-store');
1577
- if (req.method === 'OPTIONS') {
1578
- res.writeHead(200);
1579
- res.end();
1580
- return;
1581
- }
1582
- const url = new URL(req.url || '/', `http://localhost:${port}`);
1583
- try {
1584
- // =========================================================================
1585
- // NEW API ENDPOINTS (Spec 0090 - Tower as Single Daemon)
1586
- // =========================================================================
1587
- // Health check endpoint (Spec 0090 Phase 1)
1588
- if (req.method === 'GET' && url.pathname === '/health') {
1589
- const instances = await getInstances();
1590
- const activeCount = instances.filter((i) => i.running).length;
1591
- res.writeHead(200, { 'Content-Type': 'application/json' });
1592
- res.end(JSON.stringify({
1593
- status: 'healthy',
1594
- uptime: process.uptime(),
1595
- activeProjects: activeCount,
1596
- totalProjects: instances.length,
1597
- memoryUsage: process.memoryUsage().heapUsed,
1598
- timestamp: new Date().toISOString(),
1599
- }));
1600
- return;
1601
- }
1602
- // =========================================================================
1603
- // Tunnel Management Endpoints (Spec 0097 Phase 4)
1604
- // Also reachable from /project/<encoded>/api/tunnel/* (see project router)
1605
- // =========================================================================
1606
- if (url.pathname.startsWith('/api/tunnel/')) {
1607
- const tunnelSub = url.pathname.slice('/api/tunnel/'.length);
1608
- await handleTunnelEndpoint(req, res, tunnelSub);
1609
- return;
1610
- }
1611
- // API: List all projects (Spec 0090 Phase 1)
1612
- if (req.method === 'GET' && url.pathname === '/api/projects') {
1613
- const instances = await getInstances();
1614
- const projects = instances.map((i) => ({
1615
- path: i.projectPath,
1616
- name: i.projectName,
1617
- active: i.running,
1618
- proxyUrl: i.proxyUrl,
1619
- terminals: i.terminals.length,
1620
- }));
1621
- res.writeHead(200, { 'Content-Type': 'application/json' });
1622
- res.end(JSON.stringify({ projects }));
1623
- return;
1624
- }
1625
- // API: Project-specific endpoints (Spec 0090 Phase 1)
1626
- // Routes: /api/projects/:encodedPath/activate, /deactivate, /status
1627
- const projectApiMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/(activate|deactivate|status)$/);
1628
- if (projectApiMatch) {
1629
- const [, encodedPath, action] = projectApiMatch;
1630
- let projectPath;
1631
- try {
1632
- projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
1633
- if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
1634
- throw new Error('Invalid path');
1635
- }
1636
- // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
1637
- projectPath = normalizeProjectPath(projectPath);
1638
- }
1639
- catch {
1640
- res.writeHead(400, { 'Content-Type': 'application/json' });
1641
- res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
1642
- return;
1643
- }
1644
- // GET /api/projects/:path/status
1645
- if (req.method === 'GET' && action === 'status') {
1646
- const instances = await getInstances();
1647
- const instance = instances.find((i) => i.projectPath === projectPath);
1648
- if (!instance) {
1649
- res.writeHead(404, { 'Content-Type': 'application/json' });
1650
- res.end(JSON.stringify({ error: 'Project not found' }));
1651
- return;
1652
- }
1653
- res.writeHead(200, { 'Content-Type': 'application/json' });
1654
- res.end(JSON.stringify({
1655
- path: instance.projectPath,
1656
- name: instance.projectName,
1657
- active: instance.running,
1658
- terminals: instance.terminals,
1659
- gateStatus: instance.gateStatus,
1660
- }));
1661
- return;
1662
- }
1663
- // POST /api/projects/:path/activate
1664
- if (req.method === 'POST' && action === 'activate') {
1665
- // Rate limiting: 10 activations per minute per client
1666
- const clientIp = req.socket.remoteAddress || '127.0.0.1';
1667
- if (isRateLimited(clientIp)) {
1668
- res.writeHead(429, { 'Content-Type': 'application/json' });
1669
- res.end(JSON.stringify({ error: 'Too many activations, try again later' }));
1670
- return;
1671
- }
1672
- const result = await launchInstance(projectPath);
1673
- if (result.success) {
1674
- res.writeHead(200, { 'Content-Type': 'application/json' });
1675
- res.end(JSON.stringify({ success: true, adopted: result.adopted }));
1676
- }
1677
- else {
1678
- res.writeHead(400, { 'Content-Type': 'application/json' });
1679
- res.end(JSON.stringify({ success: false, error: result.error }));
1680
- }
1681
- return;
1682
- }
1683
- // POST /api/projects/:path/deactivate
1684
- if (req.method === 'POST' && action === 'deactivate') {
1685
- // Check if project is known (has terminals or sessions)
1686
- const knownPaths = getKnownProjectPaths();
1687
- const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
1688
- const isKnown = knownPaths.some((p) => p === projectPath || p === resolvedPath);
1689
- if (!isKnown) {
1690
- res.writeHead(404, { 'Content-Type': 'application/json' });
1691
- res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
1692
- return;
1693
- }
1694
- // Phase 4: Stop terminals directly via tower
1695
- const result = await stopInstance(projectPath);
1696
- res.writeHead(200, { 'Content-Type': 'application/json' });
1697
- res.end(JSON.stringify(result));
1698
- return;
1699
- }
1700
- }
1701
- // =========================================================================
1702
- // TERMINAL API (Phase 2 - Spec 0090)
1703
- // =========================================================================
1704
- // POST /api/terminals - Create a new terminal
1705
- if (req.method === 'POST' && url.pathname === '/api/terminals') {
1706
- try {
1707
- const body = await parseJsonBody(req);
1708
- const manager = getTerminalManager();
1709
- // Parse request fields
1710
- let command = typeof body.command === 'string' ? body.command : undefined;
1711
- let args = Array.isArray(body.args) ? body.args : undefined;
1712
- const cols = typeof body.cols === 'number' ? body.cols : undefined;
1713
- const rows = typeof body.rows === 'number' ? body.rows : undefined;
1714
- const cwd = typeof body.cwd === 'string' ? body.cwd : undefined;
1715
- const env = typeof body.env === 'object' && body.env !== null ? body.env : undefined;
1716
- const label = typeof body.label === 'string' ? body.label : undefined;
1717
- // Optional session persistence via shepherd
1718
- const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
1719
- const termType = typeof body.type === 'string' && ['builder', 'shell'].includes(body.type) ? body.type : null;
1720
- const roleId = typeof body.roleId === 'string' ? body.roleId : null;
1721
- const requestPersistence = body.persistent === true;
1722
- let info;
1723
- let persistent = false;
1724
- // Try shepherd if persistence was requested
1725
- if (requestPersistence && shepherdManager && command && cwd) {
1726
- try {
1727
- const sessionId = crypto.randomUUID();
1728
- // Strip CLAUDECODE so spawned Claude processes don't detect nesting
1729
- const sessionEnv = { ...(env || process.env) };
1730
- delete sessionEnv['CLAUDECODE'];
1731
- const client = await shepherdManager.createSession({
1732
- sessionId,
1733
- command,
1734
- args: args || [],
1735
- cwd,
1736
- env: sessionEnv,
1737
- cols: cols || 200,
1738
- rows: 50,
1739
- restartOnExit: false,
1740
- });
1741
- const replayData = client.getReplayData() ?? Buffer.alloc(0);
1742
- const shepherdInfo = shepherdManager.getSessionInfo(sessionId);
1743
- const session = manager.createSessionRaw({
1744
- label: label || `terminal-${sessionId.slice(0, 8)}`,
1745
- cwd,
1746
- });
1747
- const ptySession = manager.getSession(session.id);
1748
- if (ptySession) {
1749
- ptySession.attachShepherd(client, replayData, shepherdInfo.pid, sessionId);
1750
- }
1751
- info = session;
1752
- persistent = true;
1753
- if (projectPath && termType && roleId) {
1754
- const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
1755
- if (termType === 'builder') {
1756
- entry.builders.set(roleId, session.id);
1757
- }
1758
- else {
1759
- entry.shells.set(roleId, session.id);
1760
- }
1761
- saveTerminalSession(session.id, projectPath, termType, roleId, shepherdInfo.pid, shepherdInfo.socketPath, shepherdInfo.pid, shepherdInfo.startTime);
1762
- log('INFO', `Registered shepherd terminal ${session.id} as ${termType} "${roleId}" for project ${projectPath}`);
1763
- }
1764
- }
1765
- catch (shepherdErr) {
1766
- log('WARN', `Shepherd creation failed for terminal, falling back: ${shepherdErr.message}`);
1767
- }
1768
- }
1769
- // Fallback: non-persistent session (graceful degradation per plan)
1770
- // Shepherd is the only persistence backend for new sessions.
1771
- if (!info) {
1772
- info = await manager.createSession({ command, args, cols, rows, cwd, env, label });
1773
- persistent = false;
1774
- if (projectPath && termType && roleId) {
1775
- const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
1776
- if (termType === 'builder') {
1777
- entry.builders.set(roleId, info.id);
1778
- }
1779
- else {
1780
- entry.shells.set(roleId, info.id);
1781
- }
1782
- saveTerminalSession(info.id, projectPath, termType, roleId, info.pid);
1783
- log('WARN', `Terminal ${info.id} for ${projectPath} is non-persistent (shepherd unavailable)`);
1784
- }
1785
- }
1786
- res.writeHead(201, { 'Content-Type': 'application/json' });
1787
- res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}`, persistent }));
1788
- }
1789
- catch (err) {
1790
- const message = err instanceof Error ? err.message : 'Unknown error';
1791
- log('ERROR', `Failed to create terminal: ${message}`);
1792
- res.writeHead(500, { 'Content-Type': 'application/json' });
1793
- res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message }));
1794
- }
1795
- return;
1796
- }
1797
- // GET /api/terminals - List all terminals
1798
- if (req.method === 'GET' && url.pathname === '/api/terminals') {
1799
- const manager = getTerminalManager();
1800
- const terminals = manager.listSessions();
1801
- res.writeHead(200, { 'Content-Type': 'application/json' });
1802
- res.end(JSON.stringify({ terminals }));
1803
- return;
1804
- }
1805
- // Terminal-specific routes: /api/terminals/:id/*
1806
- const terminalRouteMatch = url.pathname.match(/^\/api\/terminals\/([^/]+)(\/.*)?$/);
1807
- if (terminalRouteMatch) {
1808
- const [, terminalId, subpath] = terminalRouteMatch;
1809
- const manager = getTerminalManager();
1810
- // GET /api/terminals/:id - Get terminal info
1811
- if (req.method === 'GET' && (!subpath || subpath === '')) {
1812
- const session = manager.getSession(terminalId);
1813
- if (!session) {
1814
- res.writeHead(404, { 'Content-Type': 'application/json' });
1815
- res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1816
- return;
1817
- }
1818
- res.writeHead(200, { 'Content-Type': 'application/json' });
1819
- res.end(JSON.stringify(session.info));
1820
- return;
1821
- }
1822
- // DELETE /api/terminals/:id - Kill terminal (disable shepherd auto-restart if applicable)
1823
- if (req.method === 'DELETE' && (!subpath || subpath === '')) {
1824
- if (!(await killTerminalWithShepherd(manager, terminalId))) {
1825
- res.writeHead(404, { 'Content-Type': 'application/json' });
1826
- res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1827
- return;
1828
- }
1829
- // TICK-001: Delete from SQLite
1830
- deleteTerminalSession(terminalId);
1831
- res.writeHead(204);
1832
- res.end();
1833
- return;
1834
- }
1835
- // POST /api/terminals/:id/write - Write data to terminal (Spec 0104)
1836
- if (req.method === 'POST' && subpath === '/write') {
1837
- try {
1838
- const body = await parseJsonBody(req);
1839
- if (typeof body.data !== 'string') {
1840
- res.writeHead(400, { 'Content-Type': 'application/json' });
1841
- res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'data must be a string' }));
1842
- return;
1843
- }
1844
- const session = manager.getSession(terminalId);
1845
- if (!session) {
1846
- res.writeHead(404, { 'Content-Type': 'application/json' });
1847
- res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1848
- return;
1849
- }
1850
- session.write(body.data);
1851
- res.writeHead(200, { 'Content-Type': 'application/json' });
1852
- res.end(JSON.stringify({ ok: true }));
1853
- }
1854
- catch {
1855
- res.writeHead(400, { 'Content-Type': 'application/json' });
1856
- res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
1857
- }
1858
- return;
1859
- }
1860
- // POST /api/terminals/:id/resize - Resize terminal
1861
- if (req.method === 'POST' && subpath === '/resize') {
1862
- try {
1863
- const body = await parseJsonBody(req);
1864
- if (typeof body.cols !== 'number' || typeof body.rows !== 'number') {
1865
- res.writeHead(400, { 'Content-Type': 'application/json' });
1866
- res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'cols and rows must be numbers' }));
1867
- return;
1868
- }
1869
- const info = manager.resizeSession(terminalId, body.cols, body.rows);
1870
- if (!info) {
1871
- res.writeHead(404, { 'Content-Type': 'application/json' });
1872
- res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1873
- return;
1874
- }
1875
- res.writeHead(200, { 'Content-Type': 'application/json' });
1876
- res.end(JSON.stringify(info));
1877
- }
1878
- catch {
1879
- res.writeHead(400, { 'Content-Type': 'application/json' });
1880
- res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
1881
- }
1882
- return;
1883
- }
1884
- // GET /api/terminals/:id/output - Get terminal output
1885
- if (req.method === 'GET' && subpath === '/output') {
1886
- const lines = parseInt(url.searchParams.get('lines') ?? '100', 10);
1887
- const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
1888
- const output = manager.getOutput(terminalId, lines, offset);
1889
- if (!output) {
1890
- res.writeHead(404, { 'Content-Type': 'application/json' });
1891
- res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
1892
- return;
1893
- }
1894
- res.writeHead(200, { 'Content-Type': 'application/json' });
1895
- res.end(JSON.stringify(output));
1896
- return;
1897
- }
1898
- }
1899
- // =========================================================================
1900
- // EXISTING API ENDPOINTS
1901
- // =========================================================================
1902
- // API: Get status of all instances (legacy - kept for backward compat)
1903
- if (req.method === 'GET' && url.pathname === '/api/status') {
1904
- const instances = await getInstances();
1905
- res.writeHead(200, { 'Content-Type': 'application/json' });
1906
- res.end(JSON.stringify({ instances }));
1907
- return;
1908
- }
1909
- // API: Server-Sent Events for push notifications
1910
- if (req.method === 'GET' && url.pathname === '/api/events') {
1911
- const clientId = crypto.randomBytes(8).toString('hex');
1912
- res.writeHead(200, {
1913
- 'Content-Type': 'text/event-stream',
1914
- 'Cache-Control': 'no-cache',
1915
- Connection: 'keep-alive',
1916
- });
1917
- // Send initial connection event
1918
- res.write(`data: ${JSON.stringify({ type: 'connected', id: clientId })}\n\n`);
1919
- const client = { res, id: clientId };
1920
- sseClients.push(client);
1921
- log('INFO', `SSE client connected: ${clientId} (total: ${sseClients.length})`);
1922
- // Clean up on disconnect
1923
- req.on('close', () => {
1924
- const index = sseClients.findIndex((c) => c.id === clientId);
1925
- if (index !== -1) {
1926
- sseClients.splice(index, 1);
1927
- }
1928
- log('INFO', `SSE client disconnected: ${clientId} (total: ${sseClients.length})`);
1929
- });
1930
- return;
1931
- }
1932
- // API: Receive notification from builder
1933
- if (req.method === 'POST' && url.pathname === '/api/notify') {
1934
- const body = await parseJsonBody(req);
1935
- const type = typeof body.type === 'string' ? body.type : 'info';
1936
- const title = typeof body.title === 'string' ? body.title : '';
1937
- const messageBody = typeof body.body === 'string' ? body.body : '';
1938
- const project = typeof body.project === 'string' ? body.project : undefined;
1939
- if (!title || !messageBody) {
1940
- res.writeHead(400, { 'Content-Type': 'application/json' });
1941
- res.end(JSON.stringify({ success: false, error: 'Missing title or body' }));
1942
- return;
1943
- }
1944
- // Broadcast to all connected SSE clients
1945
- broadcastNotification({
1946
- type,
1947
- title,
1948
- body: messageBody,
1949
- project,
1950
- });
1951
- log('INFO', `Notification broadcast: ${title}`);
1952
- res.writeHead(200, { 'Content-Type': 'application/json' });
1953
- res.end(JSON.stringify({ success: true }));
1954
- return;
1955
- }
1956
- // API: Browse directories for autocomplete
1957
- if (req.method === 'GET' && url.pathname === '/api/browse') {
1958
- const inputPath = url.searchParams.get('path') || '';
1959
- try {
1960
- const suggestions = await getDirectorySuggestions(inputPath);
1961
- res.writeHead(200, { 'Content-Type': 'application/json' });
1962
- res.end(JSON.stringify({ suggestions }));
1963
- }
1964
- catch (err) {
1965
- res.writeHead(200, { 'Content-Type': 'application/json' });
1966
- res.end(JSON.stringify({ suggestions: [], error: err.message }));
1967
- }
1968
- return;
1969
- }
1970
- // API: Create new project
1971
- if (req.method === 'POST' && url.pathname === '/api/create') {
1972
- const body = await parseJsonBody(req);
1973
- const parentPath = body.parent;
1974
- const projectName = body.name;
1975
- if (!parentPath || !projectName) {
1976
- res.writeHead(400, { 'Content-Type': 'application/json' });
1977
- res.end(JSON.stringify({ success: false, error: 'Missing parent or name' }));
1978
- return;
1979
- }
1980
- // Validate project name
1981
- if (!/^[a-zA-Z0-9_-]+$/.test(projectName)) {
1982
- res.writeHead(400, { 'Content-Type': 'application/json' });
1983
- res.end(JSON.stringify({ success: false, error: 'Invalid project name' }));
1984
- return;
1985
- }
1986
- // Expand ~ to home directory
1987
- let expandedParent = parentPath;
1988
- if (expandedParent.startsWith('~')) {
1989
- expandedParent = expandedParent.replace('~', homedir());
1990
- }
1991
- // Validate parent exists
1992
- if (!fs.existsSync(expandedParent)) {
1993
- res.writeHead(400, { 'Content-Type': 'application/json' });
1994
- res.end(JSON.stringify({ success: false, error: `Parent directory does not exist: ${parentPath}` }));
1995
- return;
1996
- }
1997
- const projectPath = path.join(expandedParent, projectName);
1998
- // Check if project already exists
1999
- if (fs.existsSync(projectPath)) {
2000
- res.writeHead(400, { 'Content-Type': 'application/json' });
2001
- res.end(JSON.stringify({ success: false, error: `Directory already exists: ${projectPath}` }));
2002
- return;
2003
- }
2004
- try {
2005
- // Run codev init (it creates the directory)
2006
- execSync(`codev init --yes "${projectName}"`, {
2007
- cwd: expandedParent,
2008
- stdio: 'pipe',
2009
- timeout: 60000,
2010
- });
2011
- // Launch the instance
2012
- const launchResult = await launchInstance(projectPath);
2013
- if (!launchResult.success) {
2014
- res.writeHead(500, { 'Content-Type': 'application/json' });
2015
- res.end(JSON.stringify({ success: false, error: launchResult.error }));
2016
- return;
2017
- }
2018
- res.writeHead(200, { 'Content-Type': 'application/json' });
2019
- res.end(JSON.stringify({ success: true, projectPath }));
2020
- }
2021
- catch (err) {
2022
- // Clean up on failure
2023
- try {
2024
- if (fs.existsSync(projectPath)) {
2025
- fs.rmSync(projectPath, { recursive: true });
2026
- }
2027
- }
2028
- catch {
2029
- // Ignore cleanup errors
2030
- }
2031
- res.writeHead(500, { 'Content-Type': 'application/json' });
2032
- res.end(JSON.stringify({ success: false, error: `Failed to create project: ${err.message}` }));
2033
- }
2034
- return;
2035
- }
2036
- // API: Launch new instance
2037
- if (req.method === 'POST' && url.pathname === '/api/launch') {
2038
- const body = await parseJsonBody(req);
2039
- let projectPath = body.projectPath;
2040
- if (!projectPath) {
2041
- res.writeHead(400, { 'Content-Type': 'application/json' });
2042
- res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
2043
- return;
2044
- }
2045
- // Expand ~ to home directory
2046
- if (projectPath.startsWith('~')) {
2047
- projectPath = projectPath.replace('~', homedir());
2048
- }
2049
- // Reject relative paths — tower daemon CWD is unpredictable
2050
- if (!path.isAbsolute(projectPath)) {
2051
- res.writeHead(400, { 'Content-Type': 'application/json' });
2052
- res.end(JSON.stringify({
2053
- success: false,
2054
- error: `Relative paths are not supported. Use an absolute path (e.g., /Users/.../project or ~/Development/project).`,
2055
- }));
2056
- return;
2057
- }
2058
- // Normalize path (resolve .. segments, trailing slashes)
2059
- projectPath = path.resolve(projectPath);
2060
- const result = await launchInstance(projectPath);
2061
- res.writeHead(result.success ? 200 : 400, { 'Content-Type': 'application/json' });
2062
- res.end(JSON.stringify(result));
2063
- return;
2064
- }
2065
- // API: Stop an instance
2066
- if (req.method === 'POST' && url.pathname === '/api/stop') {
2067
- const body = await parseJsonBody(req);
2068
- const targetPath = body.projectPath;
2069
- if (!targetPath) {
2070
- res.writeHead(400, { 'Content-Type': 'application/json' });
2071
- res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
2072
- return;
2073
- }
2074
- const result = await stopInstance(targetPath);
2075
- res.writeHead(200, { 'Content-Type': 'application/json' });
2076
- res.end(JSON.stringify(result));
2077
- return;
2078
- }
2079
- // Serve dashboard
2080
- if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
2081
- if (!templatePath) {
2082
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2083
- res.end('Template not found. Make sure tower.html exists in agent-farm/templates/');
2084
- return;
2085
- }
2086
- try {
2087
- const template = fs.readFileSync(templatePath, 'utf-8');
2088
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2089
- res.end(template);
2090
- }
2091
- catch (err) {
2092
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2093
- res.end('Error loading template: ' + err.message);
2094
- }
2095
- return;
2096
- }
2097
- // Project routes: /project/:base64urlPath/*
2098
- // Phase 4 (Spec 0090): Tower serves React dashboard and handles APIs directly
2099
- // Uses Base64URL (RFC 4648) encoding to avoid issues with slashes in paths
2100
- if (url.pathname.startsWith('/project/')) {
2101
- const pathParts = url.pathname.split('/');
2102
- // ['', 'project', base64urlPath, ...rest]
2103
- const encodedPath = pathParts[2];
2104
- const subPath = pathParts.slice(3).join('/');
2105
- if (!encodedPath) {
2106
- res.writeHead(400, { 'Content-Type': 'application/json' });
2107
- res.end(JSON.stringify({ error: 'Missing project path' }));
2108
- return;
2109
- }
2110
- // Decode Base64URL (RFC 4648)
2111
- let projectPath;
2112
- try {
2113
- projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
2114
- // Support both POSIX (/) and Windows (C:\) paths
2115
- if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
2116
- throw new Error('Invalid project path');
2117
- }
2118
- // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
2119
- projectPath = normalizeProjectPath(projectPath);
2120
- }
2121
- catch {
2122
- res.writeHead(400, { 'Content-Type': 'application/json' });
2123
- res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
2124
- return;
2125
- }
2126
- // Phase 4 (Spec 0090): Tower handles everything directly
2127
- const isApiCall = subPath.startsWith('api/') || subPath === 'api';
2128
- const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
2129
- // Tunnel endpoints are tower-level, not project-scoped, but the React
2130
- // dashboard uses relative paths (./api/tunnel/...) which resolve to
2131
- // /project/<encoded>/api/tunnel/... in project context. Handle here by
2132
- // extracting the tunnel sub-path and dispatching to handleTunnelEndpoint().
2133
- if (subPath.startsWith('api/tunnel/')) {
2134
- const tunnelSub = subPath.slice('api/tunnel/'.length); // e.g. "status", "connect", "disconnect"
2135
- await handleTunnelEndpoint(req, res, tunnelSub);
2136
- return;
2137
- }
2138
- // GET /file?path=<relative-path> — Read project file by path (for StatusPanel project list)
2139
- if (req.method === 'GET' && subPath === 'file' && url.searchParams.has('path')) {
2140
- const relPath = url.searchParams.get('path');
2141
- const fullPath = path.resolve(projectPath, relPath);
2142
- // Security: ensure resolved path stays within project directory
2143
- if (!fullPath.startsWith(projectPath + path.sep) && fullPath !== projectPath) {
2144
- res.writeHead(403, { 'Content-Type': 'text/plain' });
2145
- res.end('Forbidden');
2146
- return;
2147
- }
2148
- try {
2149
- const content = fs.readFileSync(fullPath, 'utf-8');
2150
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
2151
- res.end(content);
2152
- }
2153
- catch {
2154
- res.writeHead(404, { 'Content-Type': 'text/plain' });
2155
- res.end('Not found');
2156
- }
2157
- return;
2158
- }
2159
- // Serve React dashboard static files directly if:
2160
- // 1. Not an API call
2161
- // 2. Not a WebSocket path
2162
- // 3. React dashboard is available
2163
- // 4. Project doesn't need to be running for static files
2164
- if (!isApiCall && !isWsPath && hasReactDashboard) {
2165
- // Determine which static file to serve
2166
- let staticPath;
2167
- if (!subPath || subPath === '' || subPath === 'index.html') {
2168
- staticPath = path.join(reactDashboardPath, 'index.html');
2169
- }
2170
- else {
2171
- // Check if it's a static asset
2172
- staticPath = path.join(reactDashboardPath, subPath);
2173
- }
2174
- // Try to serve the static file
2175
- if (serveStaticFile(staticPath, res)) {
2176
- return;
2177
- }
2178
- // SPA fallback: serve index.html for client-side routing
2179
- const indexPath = path.join(reactDashboardPath, 'index.html');
2180
- if (serveStaticFile(indexPath, res)) {
2181
- return;
2182
- }
2183
- }
2184
- // Phase 4 (Spec 0090): Handle project APIs directly instead of proxying to dashboard-server
2185
- if (isApiCall) {
2186
- const apiPath = subPath.replace(/^api\/?/, '');
2187
- // GET /api/state - Return project state (architect, builders, shells)
2188
- if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
2189
- // Refresh cache via getTerminalsForProject (handles SQLite sync
2190
- // and shepherd reconnection in one place)
2191
- const encodedPath = Buffer.from(projectPath).toString('base64url');
2192
- const proxyUrl = `/project/${encodedPath}/`;
2193
- const { gateStatus } = await getTerminalsForProject(projectPath, proxyUrl);
2194
- // Now read from the refreshed cache
2195
- const entry = getProjectTerminalsEntry(projectPath);
2196
- const manager = getTerminalManager();
2197
- const state = {
2198
- architect: null,
2199
- builders: [],
2200
- utils: [],
2201
- annotations: [],
2202
- projectName: path.basename(projectPath),
2203
- gateStatus,
2204
- };
2205
- // Add architect if exists
2206
- if (entry.architect) {
2207
- const session = manager.getSession(entry.architect);
2208
- if (session) {
2209
- state.architect = {
2210
- port: 0,
2211
- pid: session.pid || 0,
2212
- terminalId: entry.architect,
2213
- persistent: isSessionPersistent(entry.architect, session),
2214
- };
2215
- }
2216
- }
2217
- // Add shells from refreshed cache
2218
- for (const [shellId, terminalId] of entry.shells) {
2219
- const session = manager.getSession(terminalId);
2220
- if (session) {
2221
- state.utils.push({
2222
- id: shellId,
2223
- name: `Shell ${shellId.replace('shell-', '')}`,
2224
- port: 0,
2225
- pid: session.pid || 0,
2226
- terminalId,
2227
- persistent: isSessionPersistent(terminalId, session),
2228
- });
2229
- }
2230
- }
2231
- // Add builders from refreshed cache
2232
- for (const [builderId, terminalId] of entry.builders) {
2233
- const session = manager.getSession(terminalId);
2234
- if (session) {
2235
- state.builders.push({
2236
- id: builderId,
2237
- name: `Builder ${builderId}`,
2238
- port: 0,
2239
- pid: session.pid || 0,
2240
- status: 'running',
2241
- phase: '',
2242
- worktree: '',
2243
- branch: '',
2244
- type: 'spec',
2245
- terminalId,
2246
- persistent: isSessionPersistent(terminalId, session),
2247
- });
2248
- }
2249
- }
2250
- // Add file tabs (Spec 0092 - served through Tower, no separate ports)
2251
- for (const [tabId, tab] of entry.fileTabs) {
2252
- state.annotations.push({
2253
- id: tabId,
2254
- file: tab.path,
2255
- port: 0, // No separate port - served through Tower
2256
- pid: 0, // No separate process
2257
- });
2258
- }
2259
- res.writeHead(200, { 'Content-Type': 'application/json' });
2260
- res.end(JSON.stringify(state));
2261
- return;
2262
- }
2263
- // POST /api/tabs/shell - Create a new shell terminal
2264
- if (req.method === 'POST' && apiPath === 'tabs/shell') {
2265
- try {
2266
- const manager = getTerminalManager();
2267
- const shellId = getNextShellId(projectPath);
2268
- const shellCmd = process.env.SHELL || '/bin/bash';
2269
- const shellArgs = [];
2270
- let shellCreated = false;
2271
- // Try shepherd first for persistent shell session
2272
- if (shepherdManager) {
2273
- try {
2274
- const sessionId = crypto.randomUUID();
2275
- // Strip CLAUDECODE so spawned Claude processes don't detect nesting
2276
- const shellEnv = { ...process.env };
2277
- delete shellEnv['CLAUDECODE'];
2278
- const client = await shepherdManager.createSession({
2279
- sessionId,
2280
- command: shellCmd,
2281
- args: shellArgs,
2282
- cwd: projectPath,
2283
- env: shellEnv,
2284
- cols: 200,
2285
- rows: 50,
2286
- restartOnExit: false,
2287
- });
2288
- const replayData = client.getReplayData() ?? Buffer.alloc(0);
2289
- const shepherdInfo = shepherdManager.getSessionInfo(sessionId);
2290
- const session = manager.createSessionRaw({
2291
- label: `Shell ${shellId.replace('shell-', '')}`,
2292
- cwd: projectPath,
2293
- });
2294
- const ptySession = manager.getSession(session.id);
2295
- if (ptySession) {
2296
- ptySession.attachShepherd(client, replayData, shepherdInfo.pid, sessionId);
2297
- }
2298
- const entry = getProjectTerminalsEntry(projectPath);
2299
- entry.shells.set(shellId, session.id);
2300
- saveTerminalSession(session.id, projectPath, 'shell', shellId, shepherdInfo.pid, shepherdInfo.socketPath, shepherdInfo.pid, shepherdInfo.startTime);
2301
- shellCreated = true;
2302
- res.writeHead(200, { 'Content-Type': 'application/json' });
2303
- res.end(JSON.stringify({
2304
- id: shellId,
2305
- port: 0,
2306
- name: `Shell ${shellId.replace('shell-', '')}`,
2307
- terminalId: session.id,
2308
- persistent: true,
2309
- }));
2310
- }
2311
- catch (shepherdErr) {
2312
- log('WARN', `Shepherd creation failed for shell, falling back: ${shepherdErr.message}`);
2313
- }
2314
- }
2315
- // Fallback: non-persistent session (graceful degradation per plan)
2316
- // Shepherd is the only persistence backend for new sessions.
2317
- if (!shellCreated) {
2318
- const session = await manager.createSession({
2319
- command: shellCmd,
2320
- args: shellArgs,
2321
- cwd: projectPath,
2322
- label: `Shell ${shellId.replace('shell-', '')}`,
2323
- env: process.env,
2324
- });
2325
- const entry = getProjectTerminalsEntry(projectPath);
2326
- entry.shells.set(shellId, session.id);
2327
- saveTerminalSession(session.id, projectPath, 'shell', shellId, session.pid);
2328
- log('WARN', `Shell ${shellId} for ${projectPath} is non-persistent (shepherd unavailable)`);
2329
- res.writeHead(200, { 'Content-Type': 'application/json' });
2330
- res.end(JSON.stringify({
2331
- id: shellId,
2332
- port: 0,
2333
- name: `Shell ${shellId.replace('shell-', '')}`,
2334
- terminalId: session.id,
2335
- persistent: false,
2336
- }));
2337
- }
2338
- }
2339
- catch (err) {
2340
- log('ERROR', `Failed to create shell: ${err.message}`);
2341
- res.writeHead(500, { 'Content-Type': 'application/json' });
2342
- res.end(JSON.stringify({ error: err.message }));
2343
- }
2344
- return;
2345
- }
2346
- // POST /api/tabs/file - Create a file tab (Spec 0092)
2347
- if (req.method === 'POST' && apiPath === 'tabs/file') {
2348
- try {
2349
- const body = await new Promise((resolve) => {
2350
- let data = '';
2351
- req.on('data', (chunk) => data += chunk.toString());
2352
- req.on('end', () => resolve(data));
2353
- });
2354
- const { path: filePath, line, terminalId } = JSON.parse(body || '{}');
2355
- if (!filePath || typeof filePath !== 'string') {
2356
- res.writeHead(400, { 'Content-Type': 'application/json' });
2357
- res.end(JSON.stringify({ error: 'Missing path parameter' }));
2358
- return;
2359
- }
2360
- // Resolve path: use terminal's cwd for relative paths when terminalId is provided
2361
- let fullPath;
2362
- if (path.isAbsolute(filePath)) {
2363
- fullPath = filePath;
2364
- }
2365
- else if (terminalId) {
2366
- const manager = getTerminalManager();
2367
- const session = manager.getSession(terminalId);
2368
- if (session) {
2369
- fullPath = path.join(session.cwd, filePath);
2370
- }
2371
- else {
2372
- log('WARN', `Terminal session ${terminalId} not found, falling back to project root`);
2373
- fullPath = path.join(projectPath, filePath);
2374
- }
2375
- }
2376
- else {
2377
- fullPath = path.join(projectPath, filePath);
2378
- }
2379
- // Security: symlink-aware containment check
2380
- // For non-existent files, resolve the parent directory to handle
2381
- // intermediate symlinks (e.g., /tmp -> /private/tmp on macOS).
2382
- let resolvedPath;
2383
- try {
2384
- resolvedPath = fs.realpathSync(fullPath);
2385
- }
2386
- catch {
2387
- try {
2388
- resolvedPath = path.join(fs.realpathSync(path.dirname(fullPath)), path.basename(fullPath));
2389
- }
2390
- catch {
2391
- resolvedPath = path.resolve(fullPath);
2392
- }
2393
- }
2394
- let normalizedProject;
2395
- try {
2396
- normalizedProject = fs.realpathSync(projectPath);
2397
- }
2398
- catch {
2399
- normalizedProject = path.resolve(projectPath);
2400
- }
2401
- const isWithinProject = resolvedPath.startsWith(normalizedProject + path.sep)
2402
- || resolvedPath === normalizedProject;
2403
- if (!isWithinProject) {
2404
- res.writeHead(403, { 'Content-Type': 'application/json' });
2405
- res.end(JSON.stringify({ error: 'Path outside project' }));
2406
- return;
2407
- }
2408
- // Non-existent files still create a tab (spec 0101: file viewer shows "File not found")
2409
- const fileExists = fs.existsSync(fullPath);
2410
- const entry = getProjectTerminalsEntry(projectPath);
2411
- // Check if already open
2412
- for (const [id, tab] of entry.fileTabs) {
2413
- if (tab.path === fullPath) {
2414
- res.writeHead(200, { 'Content-Type': 'application/json' });
2415
- res.end(JSON.stringify({ id, existing: true, line, notFound: !fileExists }));
2416
- return;
2417
- }
2418
- }
2419
- // Create new file tab (write-through: in-memory + SQLite)
2420
- const id = `file-${crypto.randomUUID()}`;
2421
- const createdAt = Date.now();
2422
- entry.fileTabs.set(id, { id, path: fullPath, createdAt });
2423
- saveFileTab(id, projectPath, fullPath, createdAt);
2424
- log('INFO', `Created file tab: ${id} for ${path.basename(fullPath)}`);
2425
- res.writeHead(200, { 'Content-Type': 'application/json' });
2426
- res.end(JSON.stringify({ id, existing: false, line, notFound: !fileExists }));
2427
- }
2428
- catch (err) {
2429
- log('ERROR', `Failed to create file tab: ${err.message}`);
2430
- res.writeHead(500, { 'Content-Type': 'application/json' });
2431
- res.end(JSON.stringify({ error: err.message }));
2432
- }
2433
- return;
2434
- }
2435
- // GET /api/file/:id - Get file content as JSON (Spec 0092)
2436
- const fileGetMatch = apiPath.match(/^file\/([^/]+)$/);
2437
- if (req.method === 'GET' && fileGetMatch) {
2438
- const tabId = fileGetMatch[1];
2439
- const entry = getProjectTerminalsEntry(projectPath);
2440
- const tab = entry.fileTabs.get(tabId);
2441
- if (!tab) {
2442
- res.writeHead(404, { 'Content-Type': 'application/json' });
2443
- res.end(JSON.stringify({ error: 'File tab not found' }));
2444
- return;
2445
- }
2446
- try {
2447
- const ext = path.extname(tab.path).slice(1).toLowerCase();
2448
- const isText = !['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'webm', 'mov', 'pdf'].includes(ext);
2449
- if (isText) {
2450
- const content = fs.readFileSync(tab.path, 'utf-8');
2451
- res.writeHead(200, { 'Content-Type': 'application/json' });
2452
- res.end(JSON.stringify({
2453
- path: tab.path,
2454
- name: path.basename(tab.path),
2455
- content,
2456
- language: getLanguageForExt(ext),
2457
- isMarkdown: ext === 'md',
2458
- isImage: false,
2459
- isVideo: false,
2460
- }));
2461
- }
2462
- else {
2463
- // For binary files, just return metadata
2464
- const stat = fs.statSync(tab.path);
2465
- const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
2466
- const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
2467
- res.writeHead(200, { 'Content-Type': 'application/json' });
2468
- res.end(JSON.stringify({
2469
- path: tab.path,
2470
- name: path.basename(tab.path),
2471
- content: null,
2472
- language: ext,
2473
- isMarkdown: false,
2474
- isImage,
2475
- isVideo,
2476
- size: stat.size,
2477
- }));
2478
- }
2479
- }
2480
- catch (err) {
2481
- log('ERROR', `GET /api/file/:id failed: ${err.message}`);
2482
- res.writeHead(500, { 'Content-Type': 'application/json' });
2483
- res.end(JSON.stringify({ error: err.message }));
2484
- }
2485
- return;
2486
- }
2487
- // GET /api/file/:id/raw - Get raw file content (for images/video) (Spec 0092)
2488
- const fileRawMatch = apiPath.match(/^file\/([^/]+)\/raw$/);
2489
- if (req.method === 'GET' && fileRawMatch) {
2490
- const tabId = fileRawMatch[1];
2491
- const entry = getProjectTerminalsEntry(projectPath);
2492
- const tab = entry.fileTabs.get(tabId);
2493
- if (!tab) {
2494
- res.writeHead(404, { 'Content-Type': 'application/json' });
2495
- res.end(JSON.stringify({ error: 'File tab not found' }));
2496
- return;
2497
- }
2498
- try {
2499
- const data = fs.readFileSync(tab.path);
2500
- const mimeType = getMimeTypeForFile(tab.path);
2501
- res.writeHead(200, {
2502
- 'Content-Type': mimeType,
2503
- 'Content-Length': data.length,
2504
- 'Cache-Control': 'no-cache',
2505
- });
2506
- res.end(data);
2507
- }
2508
- catch (err) {
2509
- log('ERROR', `GET /api/file/:id/raw failed: ${err.message}`);
2510
- res.writeHead(500, { 'Content-Type': 'application/json' });
2511
- res.end(JSON.stringify({ error: err.message }));
2512
- }
2513
- return;
2514
- }
2515
- // POST /api/file/:id/save - Save file content (Spec 0092)
2516
- const fileSaveMatch = apiPath.match(/^file\/([^/]+)\/save$/);
2517
- if (req.method === 'POST' && fileSaveMatch) {
2518
- const tabId = fileSaveMatch[1];
2519
- const entry = getProjectTerminalsEntry(projectPath);
2520
- const tab = entry.fileTabs.get(tabId);
2521
- if (!tab) {
2522
- res.writeHead(404, { 'Content-Type': 'application/json' });
2523
- res.end(JSON.stringify({ error: 'File tab not found' }));
2524
- return;
2525
- }
2526
- try {
2527
- const body = await new Promise((resolve) => {
2528
- let data = '';
2529
- req.on('data', (chunk) => data += chunk.toString());
2530
- req.on('end', () => resolve(data));
2531
- });
2532
- const { content } = JSON.parse(body || '{}');
2533
- if (typeof content !== 'string') {
2534
- res.writeHead(400, { 'Content-Type': 'application/json' });
2535
- res.end(JSON.stringify({ error: 'Missing content parameter' }));
2536
- return;
2537
- }
2538
- fs.writeFileSync(tab.path, content, 'utf-8');
2539
- log('INFO', `Saved file: ${tab.path}`);
2540
- res.writeHead(200, { 'Content-Type': 'application/json' });
2541
- res.end(JSON.stringify({ success: true }));
2542
- }
2543
- catch (err) {
2544
- log('ERROR', `POST /api/file/:id/save failed: ${err.message}`);
2545
- res.writeHead(500, { 'Content-Type': 'application/json' });
2546
- res.end(JSON.stringify({ error: err.message }));
2547
- }
2548
- return;
2549
- }
2550
- // DELETE /api/tabs/:id - Delete a terminal or file tab
2551
- const deleteMatch = apiPath.match(/^tabs\/(.+)$/);
2552
- if (req.method === 'DELETE' && deleteMatch) {
2553
- const tabId = deleteMatch[1];
2554
- const entry = getProjectTerminalsEntry(projectPath);
2555
- const manager = getTerminalManager();
2556
- // Check if it's a file tab first (Spec 0092, write-through: in-memory + SQLite)
2557
- if (tabId.startsWith('file-')) {
2558
- if (entry.fileTabs.has(tabId)) {
2559
- entry.fileTabs.delete(tabId);
2560
- deleteFileTab(tabId);
2561
- log('INFO', `Deleted file tab: ${tabId}`);
2562
- res.writeHead(204);
2563
- res.end();
2564
- }
2565
- else {
2566
- res.writeHead(404, { 'Content-Type': 'application/json' });
2567
- res.end(JSON.stringify({ error: 'File tab not found' }));
2568
- }
2569
- return;
2570
- }
2571
- // Find and delete the terminal
2572
- let terminalId;
2573
- if (tabId.startsWith('shell-')) {
2574
- terminalId = entry.shells.get(tabId);
2575
- if (terminalId) {
2576
- entry.shells.delete(tabId);
2577
- }
2578
- }
2579
- else if (tabId.startsWith('builder-')) {
2580
- terminalId = entry.builders.get(tabId);
2581
- if (terminalId) {
2582
- entry.builders.delete(tabId);
2583
- }
2584
- }
2585
- else if (tabId === 'architect') {
2586
- terminalId = entry.architect;
2587
- if (terminalId) {
2588
- entry.architect = undefined;
2589
- }
2590
- }
2591
- if (terminalId) {
2592
- // Disable shepherd auto-restart if applicable, then kill the PtySession
2593
- await killTerminalWithShepherd(manager, terminalId);
2594
- // TICK-001: Delete from SQLite
2595
- deleteTerminalSession(terminalId);
2596
- res.writeHead(204);
2597
- res.end();
2598
- }
2599
- else {
2600
- res.writeHead(404, { 'Content-Type': 'application/json' });
2601
- res.end(JSON.stringify({ error: 'Tab not found' }));
2602
- }
2603
- return;
2604
- }
2605
- // POST /api/stop - Stop all terminals for project
2606
- if (req.method === 'POST' && apiPath === 'stop') {
2607
- const entry = getProjectTerminalsEntry(projectPath);
2608
- const manager = getTerminalManager();
2609
- // Kill all terminals (disable shepherd auto-restart if applicable)
2610
- if (entry.architect) {
2611
- await killTerminalWithShepherd(manager, entry.architect);
2612
- }
2613
- for (const terminalId of entry.shells.values()) {
2614
- await killTerminalWithShepherd(manager, terminalId);
2615
- }
2616
- for (const terminalId of entry.builders.values()) {
2617
- await killTerminalWithShepherd(manager, terminalId);
2618
- }
2619
- // Clear registry
2620
- projectTerminals.delete(projectPath);
2621
- // TICK-001: Delete all terminal sessions from SQLite
2622
- deleteProjectTerminalSessions(projectPath);
2623
- res.writeHead(200, { 'Content-Type': 'application/json' });
2624
- res.end(JSON.stringify({ ok: true }));
2625
- return;
2626
- }
2627
- // GET /api/files - Return project directory tree for file browser (Spec 0092)
2628
- if (req.method === 'GET' && apiPath === 'files') {
2629
- const maxDepth = parseInt(url.searchParams.get('depth') || '3', 10);
2630
- const ignore = new Set(['.git', 'node_modules', '.builders', 'dist', '.agent-farm', '.next', '.cache', '__pycache__']);
2631
- function readTree(dir, depth) {
2632
- if (depth <= 0)
2633
- return [];
2634
- try {
2635
- const entries = fs.readdirSync(dir, { withFileTypes: true });
2636
- return entries
2637
- .filter(e => !e.name.startsWith('.') || e.name === '.env.example')
2638
- .filter(e => !ignore.has(e.name))
2639
- .sort((a, b) => {
2640
- // Directories first, then alphabetical
2641
- if (a.isDirectory() && !b.isDirectory())
2642
- return -1;
2643
- if (!a.isDirectory() && b.isDirectory())
2644
- return 1;
2645
- return a.name.localeCompare(b.name);
2646
- })
2647
- .map(e => {
2648
- const fullPath = path.join(dir, e.name);
2649
- const relativePath = path.relative(projectPath, fullPath);
2650
- if (e.isDirectory()) {
2651
- return { name: e.name, path: relativePath, type: 'directory', children: readTree(fullPath, depth - 1) };
2652
- }
2653
- return { name: e.name, path: relativePath, type: 'file' };
2654
- });
2655
- }
2656
- catch {
2657
- return [];
2658
- }
2659
- }
2660
- const tree = readTree(projectPath, maxDepth);
2661
- res.writeHead(200, { 'Content-Type': 'application/json' });
2662
- res.end(JSON.stringify(tree));
2663
- return;
2664
- }
2665
- // GET /api/git/status - Return git status for file browser (Spec 0092)
2666
- if (req.method === 'GET' && apiPath === 'git/status') {
2667
- try {
2668
- // Get git status in porcelain format for parsing
2669
- const result = execSync('git status --porcelain', {
2670
- cwd: projectPath,
2671
- encoding: 'utf-8',
2672
- timeout: 5000,
2673
- });
2674
- // Parse porcelain output: XY filename
2675
- // X = staging area status, Y = working tree status
2676
- const modified = [];
2677
- const staged = [];
2678
- const untracked = [];
2679
- for (const line of result.split('\n')) {
2680
- if (!line)
2681
- continue;
2682
- const x = line[0]; // staging area
2683
- const y = line[1]; // working tree
2684
- const filepath = line.slice(3);
2685
- if (x === '?' && y === '?') {
2686
- untracked.push(filepath);
2687
- }
2688
- else {
2689
- if (x !== ' ' && x !== '?') {
2690
- staged.push(filepath);
2691
- }
2692
- if (y !== ' ' && y !== '?') {
2693
- modified.push(filepath);
2694
- }
2695
- }
2696
- }
2697
- res.writeHead(200, { 'Content-Type': 'application/json' });
2698
- res.end(JSON.stringify({ modified, staged, untracked }));
2699
- }
2700
- catch (err) {
2701
- // Not a git repo or git command failed — return graceful degradation with error field
2702
- log('WARN', `GET /api/git/status failed: ${err.message}`);
2703
- res.writeHead(200, { 'Content-Type': 'application/json' });
2704
- res.end(JSON.stringify({ modified: [], staged: [], untracked: [], error: err.message }));
2705
- }
2706
- return;
2707
- }
2708
- // GET /api/files/recent - Return recently opened file tabs (Spec 0092)
2709
- if (req.method === 'GET' && apiPath === 'files/recent') {
2710
- const entry = getProjectTerminalsEntry(projectPath);
2711
- // Get all file tabs sorted by creation time (most recent first)
2712
- const recentFiles = Array.from(entry.fileTabs.values())
2713
- .sort((a, b) => b.createdAt - a.createdAt)
2714
- .slice(0, 10) // Limit to 10 most recent
2715
- .map(tab => ({
2716
- id: tab.id,
2717
- path: tab.path,
2718
- name: path.basename(tab.path),
2719
- relativePath: path.relative(projectPath, tab.path),
2720
- }));
2721
- res.writeHead(200, { 'Content-Type': 'application/json' });
2722
- res.end(JSON.stringify(recentFiles));
2723
- return;
2724
- }
2725
- // GET /api/annotate/:tabId/* — Serve rich annotator template and sub-APIs
2726
- const annotateMatch = apiPath.match(/^annotate\/([^/]+)(\/(.*))?$/);
2727
- if (annotateMatch) {
2728
- const tabId = annotateMatch[1];
2729
- const subRoute = annotateMatch[3] || '';
2730
- const entry = getProjectTerminalsEntry(projectPath);
2731
- const tab = entry.fileTabs.get(tabId);
2732
- if (!tab) {
2733
- res.writeHead(404, { 'Content-Type': 'application/json' });
2734
- res.end(JSON.stringify({ error: 'File tab not found' }));
2735
- return;
2736
- }
2737
- const filePath = tab.path;
2738
- const ext = path.extname(filePath).slice(1).toLowerCase();
2739
- const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
2740
- const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
2741
- const is3D = ['stl', '3mf'].includes(ext);
2742
- const isPdf = ext === 'pdf';
2743
- const isMarkdown = ext === 'md';
2744
- // Sub-route: GET /file — re-read file content from disk
2745
- if (req.method === 'GET' && subRoute === 'file') {
2746
- try {
2747
- const content = fs.readFileSync(filePath, 'utf-8');
2748
- res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
2749
- res.end(content);
2750
- }
2751
- catch (err) {
2752
- log('ERROR', `GET /api/annotate/:id/file failed: ${err.message}`);
2753
- res.writeHead(500, { 'Content-Type': 'application/json' });
2754
- res.end(JSON.stringify({ error: err.message }));
2755
- }
2756
- return;
2757
- }
2758
- // Sub-route: POST /save — save file content
2759
- if (req.method === 'POST' && subRoute === 'save') {
2760
- try {
2761
- const body = await new Promise((resolve) => {
2762
- let data = '';
2763
- req.on('data', (chunk) => data += chunk.toString());
2764
- req.on('end', () => resolve(data));
2765
- });
2766
- const parsed = JSON.parse(body || '{}');
2767
- const fileContent = parsed.content;
2768
- if (typeof fileContent !== 'string') {
2769
- res.writeHead(400, { 'Content-Type': 'text/plain' });
2770
- res.end('Missing content');
2771
- return;
2772
- }
2773
- fs.writeFileSync(filePath, fileContent, 'utf-8');
2774
- res.writeHead(200, { 'Content-Type': 'application/json' });
2775
- res.end(JSON.stringify({ ok: true }));
2776
- }
2777
- catch (err) {
2778
- log('ERROR', `POST /api/annotate/:id/save failed: ${err.message}`);
2779
- res.writeHead(500, { 'Content-Type': 'application/json' });
2780
- res.end(JSON.stringify({ error: err.message }));
2781
- }
2782
- return;
2783
- }
2784
- // Sub-route: GET /api/mtime — file modification time
2785
- if (req.method === 'GET' && subRoute === 'api/mtime') {
2786
- try {
2787
- const stat = fs.statSync(filePath);
2788
- res.writeHead(200, { 'Content-Type': 'application/json' });
2789
- res.end(JSON.stringify({ mtime: stat.mtimeMs }));
2790
- }
2791
- catch (err) {
2792
- log('ERROR', `GET /api/annotate/:id/api/mtime failed: ${err.message}`);
2793
- res.writeHead(500, { 'Content-Type': 'application/json' });
2794
- res.end(JSON.stringify({ error: err.message }));
2795
- }
2796
- return;
2797
- }
2798
- // Sub-route: GET /api/image, /api/video, /api/model, /api/pdf — raw binary content
2799
- if (req.method === 'GET' && (subRoute === 'api/image' || subRoute === 'api/video' || subRoute === 'api/model' || subRoute === 'api/pdf')) {
2800
- try {
2801
- const data = fs.readFileSync(filePath);
2802
- const mimeType = getMimeTypeForFile(filePath);
2803
- res.writeHead(200, {
2804
- 'Content-Type': mimeType,
2805
- 'Content-Length': data.length,
2806
- 'Cache-Control': 'no-cache',
2807
- });
2808
- res.end(data);
2809
- }
2810
- catch (err) {
2811
- log('ERROR', `GET /api/annotate/:id/${subRoute} failed: ${err.message}`);
2812
- res.writeHead(500, { 'Content-Type': 'application/json' });
2813
- res.end(JSON.stringify({ error: err.message }));
2814
- }
2815
- return;
2816
- }
2817
- // Default: serve the annotator HTML template
2818
- if (req.method === 'GET' && (subRoute === '' || subRoute === undefined)) {
2819
- try {
2820
- const templateFile = is3D ? '3d-viewer.html' : 'open.html';
2821
- const tplPath = path.resolve(__dirname, `../../../templates/${templateFile}`);
2822
- let html = fs.readFileSync(tplPath, 'utf-8');
2823
- const fileName = path.basename(filePath);
2824
- const fileSize = fs.statSync(filePath).size;
2825
- if (is3D) {
2826
- html = html.replace(/\{\{FILE\}\}/g, fileName);
2827
- html = html.replace(/\{\{FILE_PATH_JSON\}\}/g, JSON.stringify(filePath));
2828
- html = html.replace(/\{\{FORMAT\}\}/g, ext);
2829
- }
2830
- else {
2831
- html = html.replace(/\{\{FILE\}\}/g, fileName);
2832
- html = html.replace(/\{\{FILE_PATH\}\}/g, filePath);
2833
- html = html.replace(/\{\{BUILDER_ID\}\}/g, '');
2834
- html = html.replace(/\{\{LANG\}\}/g, getLanguageForExt(ext));
2835
- html = html.replace(/\{\{IS_MARKDOWN\}\}/g, String(isMarkdown));
2836
- html = html.replace(/\{\{IS_IMAGE\}\}/g, String(isImage));
2837
- html = html.replace(/\{\{IS_VIDEO\}\}/g, String(isVideo));
2838
- html = html.replace(/\{\{IS_PDF\}\}/g, String(isPdf));
2839
- html = html.replace(/\{\{FILE_SIZE\}\}/g, String(fileSize));
2840
- // Inject initialization script (template loads content via fetch)
2841
- let initScript;
2842
- if (isImage) {
2843
- initScript = `initImage(${fileSize});`;
2844
- }
2845
- else if (isVideo) {
2846
- initScript = `initVideo(${fileSize});`;
2847
- }
2848
- else if (isPdf) {
2849
- initScript = `initPdf(${fileSize});`;
2850
- }
2851
- else {
2852
- initScript = `fetch('file').then(r=>r.text()).then(init);`;
2853
- }
2854
- html = html.replace('// FILE_CONTENT will be injected by the server', initScript);
2855
- }
2856
- // Handle ?line= query param for scroll-to-line
2857
- const lineParam = url.searchParams.get('line');
2858
- if (lineParam) {
2859
- const scrollScript = `<script>window.addEventListener('load',()=>{setTimeout(()=>{const el=document.querySelector('[data-line="${lineParam}"]');if(el){el.scrollIntoView({block:'center'});el.classList.add('highlighted-line');}},200);})</script>`;
2860
- html = html.replace('</body>', `${scrollScript}</body>`);
2861
- }
2862
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2863
- res.end(html);
2864
- }
2865
- catch (err) {
2866
- res.writeHead(500, { 'Content-Type': 'text/plain' });
2867
- res.end(`Failed to serve annotator: ${err.message}`);
2868
- }
2869
- return;
2870
- }
2871
- }
2872
- // Unhandled API route
2873
- res.writeHead(404, { 'Content-Type': 'application/json' });
2874
- res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));
2875
- return;
2876
- }
2877
- // For WebSocket paths, let the upgrade handler deal with it
2878
- if (isWsPath) {
2879
- // WebSocket paths are handled by the upgrade handler
2880
- res.writeHead(400, { 'Content-Type': 'text/plain' });
2881
- res.end('WebSocket connections should use ws:// protocol');
2882
- return;
2883
- }
2884
- // If we get here for non-API, non-WS paths and React dashboard is not available
2885
- if (!hasReactDashboard) {
2886
- res.writeHead(404, { 'Content-Type': 'text/plain' });
2887
- res.end('Dashboard not available');
2888
- return;
2889
- }
2890
- // Fallback for unmatched paths
2891
- res.writeHead(404, { 'Content-Type': 'text/plain' });
2892
- res.end('Not found');
2893
- return;
2894
- }
2895
- // 404 for everything else
2896
- res.writeHead(404, { 'Content-Type': 'text/plain' });
2897
- res.end('Not found');
2898
- }
2899
- catch (err) {
2900
- log('ERROR', `Request error: ${err.message}`);
2901
- res.writeHead(500, { 'Content-Type': 'application/json' });
2902
- res.end(JSON.stringify({ error: err.message }));
2903
- }
187
+ await handleRequest(req, res, routeCtx);
2904
188
  });
2905
189
  // SECURITY: Bind to localhost only to prevent network exposure
2906
190
  server.listen(port, '127.0.0.1', async () => {
@@ -2918,107 +202,37 @@ server.listen(port, '127.0.0.1', async () => {
2918
202
  log('INFO', `Cleaned up ${staleCleaned} stale shepherd socket(s)`);
2919
203
  }
2920
204
  log('INFO', 'Shepherd session manager initialized');
205
+ // Spec 0105 Phase 4: Initialize terminal management module
206
+ initTerminals({
207
+ log,
208
+ shepherdManager,
209
+ registerKnownProject,
210
+ getKnownProjectPaths,
211
+ });
212
+ // Spec 0105 Phase 3: Initialize instance lifecycle module
213
+ // Must be before reconcileTerminalSessions() so instance APIs are available
214
+ // as soon as the server starts accepting requests.
215
+ initInstances({
216
+ log,
217
+ projectTerminals: getProjectTerminals(),
218
+ getTerminalManager,
219
+ shepherdManager,
220
+ getProjectTerminalsEntry,
221
+ saveTerminalSession,
222
+ deleteTerminalSession,
223
+ deleteProjectTerminalSessions,
224
+ getTerminalsForProject,
225
+ });
2921
226
  // TICK-001: Reconcile terminal sessions from previous run
2922
227
  await reconcileTerminalSessions();
2923
228
  // Spec 0100: Start background gate watcher for af send notifications
2924
229
  startGateWatcher();
2925
230
  log('INFO', 'Gate watcher started (10s poll interval)');
2926
- // Spec 0097 Phase 4: Auto-connect tunnel if registered
2927
- try {
2928
- const config = readCloudConfig();
2929
- if (config) {
2930
- log('INFO', `Cloud config found, connecting tunnel (tower: ${config.tower_name}, key: ${maskApiKey(config.api_key)})`);
2931
- await connectTunnel(config);
2932
- }
2933
- else {
2934
- log('INFO', 'No cloud config found, operating in local-only mode');
2935
- }
2936
- }
2937
- catch (err) {
2938
- log('WARN', `Failed to read cloud config: ${err.message}. Operating in local-only mode.`);
2939
- }
2940
- // Start watching cloud-config.json for changes
2941
- startConfigWatcher();
231
+ // Spec 0097 Phase 4 / Spec 0105 Phase 2: Initialize cloud tunnel
232
+ await initTunnel({ port, log, projectTerminals: getProjectTerminals(), terminalManager: getTerminalManager() }, { getInstances });
2942
233
  });
2943
234
  // Initialize terminal WebSocket server (Phase 2 - Spec 0090)
2944
235
  terminalWss = new WebSocketServer({ noServer: true });
2945
- // WebSocket upgrade handler for terminal connections and proxying
2946
- server.on('upgrade', async (req, socket, head) => {
2947
- const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
2948
- // Phase 2: Handle /ws/terminal/:id routes directly
2949
- const terminalMatch = reqUrl.pathname.match(/^\/ws\/terminal\/([^/]+)$/);
2950
- if (terminalMatch) {
2951
- const terminalId = terminalMatch[1];
2952
- const manager = getTerminalManager();
2953
- const session = manager.getSession(terminalId);
2954
- if (!session) {
2955
- socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
2956
- socket.destroy();
2957
- return;
2958
- }
2959
- terminalWss.handleUpgrade(req, socket, head, (ws) => {
2960
- handleTerminalWebSocket(ws, session, req);
2961
- });
2962
- return;
2963
- }
2964
- // Phase 4 (Spec 0090): Handle project WebSocket routes directly
2965
- // Route: /project/:encodedPath/ws/terminal/:terminalId
2966
- if (!reqUrl.pathname.startsWith('/project/')) {
2967
- socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
2968
- socket.destroy();
2969
- return;
2970
- }
2971
- const pathParts = reqUrl.pathname.split('/');
2972
- // ['', 'project', base64urlPath, 'ws', 'terminal', terminalId]
2973
- const encodedPath = pathParts[2];
2974
- if (!encodedPath) {
2975
- socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
2976
- socket.destroy();
2977
- return;
2978
- }
2979
- // Decode Base64URL (RFC 4648) - NOT URL encoding
2980
- // Wrap in try/catch to handle malformed Base64 input gracefully
2981
- let projectPath;
2982
- try {
2983
- projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
2984
- // Support both POSIX (/) and Windows (C:\) paths
2985
- if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
2986
- throw new Error('Invalid project path');
2987
- }
2988
- // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
2989
- projectPath = normalizeProjectPath(projectPath);
2990
- }
2991
- catch {
2992
- socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
2993
- socket.destroy();
2994
- return;
2995
- }
2996
- // Check for terminal WebSocket route: /project/:path/ws/terminal/:id
2997
- const wsMatch = reqUrl.pathname.match(/^\/project\/[^/]+\/ws\/terminal\/([^/]+)$/);
2998
- if (wsMatch) {
2999
- const terminalId = wsMatch[1];
3000
- const manager = getTerminalManager();
3001
- const session = manager.getSession(terminalId);
3002
- if (!session) {
3003
- socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
3004
- socket.destroy();
3005
- return;
3006
- }
3007
- terminalWss.handleUpgrade(req, socket, head, (ws) => {
3008
- handleTerminalWebSocket(ws, session, req);
3009
- });
3010
- return;
3011
- }
3012
- // Unhandled WebSocket route
3013
- socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
3014
- socket.destroy();
3015
- });
3016
- // Handle uncaught errors
3017
- process.on('uncaughtException', (err) => {
3018
- log('ERROR', `Uncaught exception: ${err.message}\n${err.stack}`);
3019
- process.exit(1);
3020
- });
3021
- process.on('unhandledRejection', (reason) => {
3022
- log('ERROR', `Unhandled rejection: ${reason}`);
3023
- });
236
+ // Spec 0105 Phase 5: WebSocket upgrade handler extracted to tower-websocket.ts
237
+ setupUpgradeHandler(server, terminalWss, port);
3024
238
  //# sourceMappingURL=tower-server.js.map