@cluesmith/codev 2.0.0-rc.55 → 2.0.0-rc.57

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 (41) hide show
  1. package/dashboard/dist/assets/{index-BIHeqvy0.css → index-BV7KQvFU.css} +1 -1
  2. package/dashboard/dist/assets/index-bhDjF0Oa.js +131 -0
  3. package/dashboard/dist/assets/index-bhDjF0Oa.js.map +1 -0
  4. package/dashboard/dist/index.html +2 -2
  5. package/dist/agent-farm/commands/open.d.ts +4 -2
  6. package/dist/agent-farm/commands/open.d.ts.map +1 -1
  7. package/dist/agent-farm/commands/open.js +37 -70
  8. package/dist/agent-farm/commands/open.js.map +1 -1
  9. package/dist/agent-farm/commands/send.d.ts.map +1 -1
  10. package/dist/agent-farm/commands/send.js +55 -17
  11. package/dist/agent-farm/commands/send.js.map +1 -1
  12. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  13. package/dist/agent-farm/commands/spawn.js +31 -20
  14. package/dist/agent-farm/commands/spawn.js.map +1 -1
  15. package/dist/agent-farm/commands/stop.js +1 -1
  16. package/dist/agent-farm/commands/stop.js.map +1 -1
  17. package/dist/agent-farm/servers/tower-server.js +763 -62
  18. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  19. package/dist/agent-farm/types.d.ts +0 -1
  20. package/dist/agent-farm/types.d.ts.map +1 -1
  21. package/dist/agent-farm/utils/config.d.ts.map +1 -1
  22. package/dist/agent-farm/utils/config.js +1 -3
  23. package/dist/agent-farm/utils/config.js.map +1 -1
  24. package/dist/agent-farm/utils/port-registry.d.ts +0 -1
  25. package/dist/agent-farm/utils/port-registry.d.ts.map +1 -1
  26. package/dist/agent-farm/utils/port-registry.js +1 -1
  27. package/dist/agent-farm/utils/port-registry.js.map +1 -1
  28. package/package.json +1 -1
  29. package/skeleton/protocols/bugfix/prompts/fix.md +77 -0
  30. package/skeleton/protocols/bugfix/prompts/investigate.md +77 -0
  31. package/skeleton/protocols/bugfix/prompts/pr.md +61 -0
  32. package/skeleton/protocols/bugfix/protocol.json +5 -0
  33. package/skeleton/protocols/spider/prompts/plan.md +4 -68
  34. package/skeleton/protocols/spider/prompts/review.md +5 -23
  35. package/skeleton/protocols/spider/prompts/specify.md +4 -57
  36. package/dashboard/dist/assets/index-VvUWRPNP.js +0 -120
  37. package/dashboard/dist/assets/index-VvUWRPNP.js.map +0 -1
  38. package/dist/agent-farm/servers/open-server.d.ts +0 -7
  39. package/dist/agent-farm/servers/open-server.d.ts.map +0 -1
  40. package/dist/agent-farm/servers/open-server.js +0 -315
  41. package/dist/agent-farm/servers/open-server.js.map +0 -1
@@ -8,7 +8,7 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import net from 'node:net';
10
10
  import crypto from 'node:crypto';
11
- import { spawn, execSync } from 'node:child_process';
11
+ import { spawn, execSync, spawnSync } from 'node:child_process';
12
12
  import { homedir } from 'node:os';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { Command } from 'commander';
@@ -71,11 +71,40 @@ const projectTerminals = new Map();
71
71
  function getProjectTerminalsEntry(projectPath) {
72
72
  let entry = projectTerminals.get(projectPath);
73
73
  if (!entry) {
74
- entry = { builders: new Map(), shells: new Map() };
74
+ entry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
75
75
  projectTerminals.set(projectPath, entry);
76
76
  }
77
+ // Migration: ensure fileTabs exists for older entries
78
+ if (!entry.fileTabs) {
79
+ entry.fileTabs = new Map();
80
+ }
77
81
  return entry;
78
82
  }
83
+ /**
84
+ * Get language identifier for syntax highlighting
85
+ */
86
+ function getLanguageForExt(ext) {
87
+ const langMap = {
88
+ js: 'javascript', ts: 'typescript', jsx: 'javascript', tsx: 'typescript',
89
+ py: 'python', sh: 'bash', bash: 'bash', md: 'markdown',
90
+ html: 'markup', css: 'css', json: 'json', yaml: 'yaml', yml: 'yaml',
91
+ rs: 'rust', go: 'go', java: 'java', c: 'c', cpp: 'cpp', h: 'c',
92
+ };
93
+ return langMap[ext] || ext || 'plaintext';
94
+ }
95
+ /**
96
+ * Get MIME type for file
97
+ */
98
+ function getMimeTypeForFile(filePath) {
99
+ const ext = path.extname(filePath).slice(1).toLowerCase();
100
+ const mimeTypes = {
101
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
102
+ gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
103
+ mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
104
+ pdf: 'application/pdf', txt: 'text/plain',
105
+ };
106
+ return mimeTypes[ext] || 'application/octet-stream';
107
+ }
79
108
  /**
80
109
  * Generate next shell ID for a project
81
110
  */
@@ -139,6 +168,7 @@ function saveTerminalSession(terminalId, projectPath, type, roleId, pid, tmuxSes
139
168
  INSERT OR REPLACE INTO terminal_sessions (id, project_path, type, role_id, pid, tmux_session)
140
169
  VALUES (?, ?, ?, ?, ?, ?)
141
170
  `).run(terminalId, normalizedPath, type, roleId, pid, tmuxSession);
171
+ log('INFO', `Saved terminal session to SQLite: ${terminalId} (${type}) for ${path.basename(normalizedPath)}`);
142
172
  }
143
173
  catch (err) {
144
174
  log('WARN', `Failed to save terminal session: ${err.message}`);
@@ -174,6 +204,56 @@ function deleteProjectTerminalSessions(projectPath) {
174
204
  log('WARN', `Failed to delete project terminal sessions: ${err.message}`);
175
205
  }
176
206
  }
207
+ // Whether tmux is available on this system (checked once at startup)
208
+ let tmuxAvailable = false;
209
+ /**
210
+ * Check if tmux is installed and available
211
+ */
212
+ function checkTmux() {
213
+ try {
214
+ execSync('tmux -V', { stdio: 'ignore' });
215
+ return true;
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ }
221
+ /**
222
+ * Create a tmux session with the given command.
223
+ * Returns true if created successfully, false on failure.
224
+ */
225
+ function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
226
+ // Kill any stale session with this name
227
+ if (tmuxSessionExists(sessionName)) {
228
+ killTmuxSession(sessionName);
229
+ }
230
+ try {
231
+ // Use spawnSync with array args to avoid shell injection via project paths
232
+ const tmuxArgs = [
233
+ 'new-session', '-d',
234
+ '-s', sessionName,
235
+ '-c', cwd,
236
+ '-x', String(cols),
237
+ '-y', String(rows),
238
+ command, ...args,
239
+ ];
240
+ const result = spawnSync('tmux', tmuxArgs, { stdio: 'ignore' });
241
+ if (result.status !== 0) {
242
+ log('WARN', `tmux new-session exited with code ${result.status} for "${sessionName}"`);
243
+ return false;
244
+ }
245
+ // Hide tmux status bar (dashboard has its own tabs), enable mouse, and
246
+ // use aggressive-resize so tmux sizes to the largest client (not smallest)
247
+ spawnSync('tmux', ['set-option', '-t', sessionName, 'status', 'off'], { stdio: 'ignore' });
248
+ spawnSync('tmux', ['set-option', '-t', sessionName, 'mouse', 'on'], { stdio: 'ignore' });
249
+ spawnSync('tmux', ['set-option', '-t', sessionName, 'aggressive-resize', 'on'], { stdio: 'ignore' });
250
+ return true;
251
+ }
252
+ catch (err) {
253
+ log('WARN', `Failed to create tmux session "${sessionName}": ${err.message}`);
254
+ return false;
255
+ }
256
+ }
177
257
  /**
178
258
  * Check if a tmux session exists
179
259
  */
@@ -213,9 +293,9 @@ function killTmuxSession(sessionName) {
213
293
  /**
214
294
  * Reconcile terminal sessions from SQLite against reality on startup.
215
295
  *
216
- * DESTRUCTIVE: Since we can't re-attach to PTY sessions after restart,
217
- * any surviving tmux sessions are orphaned and must be killed.
218
- * This ensures clean state and prevents zombie terminals.
296
+ * For sessions with surviving tmux sessions: re-attach via new node-pty,
297
+ * register in projectTerminals, and update SQLite with new terminal ID.
298
+ * For dead sessions: clean up SQLite rows and kill orphaned processes.
219
299
  */
220
300
  async function reconcileTerminalSessions() {
221
301
  const db = getGlobalDb();
@@ -232,18 +312,54 @@ async function reconcileTerminalSessions() {
232
312
  return;
233
313
  }
234
314
  log('INFO', `Reconciling ${sessions.length} terminal sessions from previous run...`);
315
+ const manager = getTerminalManager();
316
+ let reconnected = 0;
235
317
  let killed = 0;
236
318
  let cleaned = 0;
237
319
  for (const session of sessions) {
238
- // Check if tmux session exists (can survive Tower restart)
239
- if (session.tmux_session && tmuxSessionExists(session.tmux_session)) {
240
- // DESTRUCTIVE: Kill the orphaned tmux session since we can't re-attach
241
- // Without the PTY object, we have no way to control this session
242
- log('INFO', `Found orphaned tmux session: ${session.tmux_session} (${session.type} for ${path.basename(session.project_path)})`);
320
+ // Can we reconnect to a surviving tmux session?
321
+ if (session.tmux_session && tmuxAvailable && tmuxSessionExists(session.tmux_session)) {
322
+ try {
323
+ // Create new node-pty that attaches to the surviving tmux session
324
+ const newSession = await manager.createSession({
325
+ command: 'tmux',
326
+ args: ['attach-session', '-t', session.tmux_session],
327
+ cwd: session.project_path,
328
+ label: session.type === 'architect' ? 'Architect' : `${session.type} ${session.role_id || session.id}`,
329
+ });
330
+ // Register in projectTerminals Map
331
+ const entry = getProjectTerminalsEntry(session.project_path);
332
+ if (session.type === 'architect') {
333
+ entry.architect = newSession.id;
334
+ }
335
+ else if (session.type === 'builder') {
336
+ const builderId = session.role_id || session.id;
337
+ entry.builders.set(builderId, newSession.id);
338
+ }
339
+ else if (session.type === 'shell') {
340
+ const shellId = session.role_id || session.id;
341
+ entry.shells.set(shellId, newSession.id);
342
+ }
343
+ // Update SQLite: delete old row, insert new with new terminal ID
344
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
345
+ saveTerminalSession(newSession.id, session.project_path, session.type, session.role_id, newSession.pid, session.tmux_session);
346
+ log('INFO', `Reconnected to tmux session "${session.tmux_session}" → terminal ${newSession.id} (${session.type} for ${path.basename(session.project_path)})`);
347
+ reconnected++;
348
+ continue;
349
+ }
350
+ catch (err) {
351
+ log('WARN', `Failed to reconnect to tmux session "${session.tmux_session}": ${err.message}`);
352
+ // Fall through to cleanup
353
+ killTmuxSession(session.tmux_session);
354
+ killed++;
355
+ }
356
+ }
357
+ // No tmux or tmux session dead — check for orphaned processes
358
+ else if (session.tmux_session && tmuxSessionExists(session.tmux_session)) {
359
+ // tmux exists but tmuxAvailable is false (shouldn't happen, but be safe)
243
360
  killTmuxSession(session.tmux_session);
244
361
  killed++;
245
362
  }
246
- // Check if process still running (kill if we can)
247
363
  else if (session.pid && processExists(session.pid)) {
248
364
  log('INFO', `Found orphaned process: PID ${session.pid} (${session.type} for ${path.basename(session.project_path)})`);
249
365
  try {
@@ -254,11 +370,11 @@ async function reconcileTerminalSessions() {
254
370
  // Process may not be killable (different user, etc)
255
371
  }
256
372
  }
257
- // Always clean up the DB row - we start fresh after restart
373
+ // Clean up the DB row for sessions we couldn't reconnect
258
374
  db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
259
375
  cleaned++;
260
376
  }
261
- log('INFO', `Reconciliation complete: ${killed} orphaned processes killed, ${cleaned} DB rows cleaned`);
377
+ log('INFO', `Reconciliation complete: ${reconnected} reconnected, ${killed} orphaned killed, ${cleaned} cleaned up`);
262
378
  }
263
379
  /**
264
380
  * Get terminal sessions from SQLite for a project.
@@ -601,16 +717,32 @@ async function getGateStatusForProject(basePort) {
601
717
  * Returns architect, builders, and shells with their URLs.
602
718
  */
603
719
  function getTerminalsForProject(projectPath, proxyUrl) {
604
- const entry = projectTerminals.get(projectPath);
605
720
  const manager = getTerminalManager();
606
721
  const terminals = [];
607
- if (!entry) {
608
- return { terminals: [], gateStatus: { hasGate: false } };
609
- }
610
- // Add architect terminal
611
- if (entry.architect) {
612
- const session = manager.getSession(entry.architect);
613
- if (session) {
722
+ // SQLite is authoritative - query it first (Spec 0090 requirement)
723
+ const dbSessions = getTerminalSessionsForProject(projectPath);
724
+ // Use normalized path for cache consistency
725
+ const normalizedPath = normalizeProjectPath(projectPath);
726
+ // Build a fresh entry from SQLite, then replace atomically to avoid
727
+ // destroying in-memory state that was registered via POST /api/terminals.
728
+ // Previous approach cleared the cache then rebuilt, which lost terminals
729
+ // if their SQLite rows were deleted by external interference (e.g., tests).
730
+ const freshEntry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
731
+ // Preserve file tabs from existing entry (not stored in SQLite)
732
+ const existingEntry = projectTerminals.get(normalizedPath);
733
+ if (existingEntry) {
734
+ freshEntry.fileTabs = existingEntry.fileTabs;
735
+ }
736
+ for (const dbSession of dbSessions) {
737
+ // Verify session still exists in TerminalManager (runtime state)
738
+ const session = manager.getSession(dbSession.id);
739
+ if (!session) {
740
+ // Stale row in SQLite - clean it up
741
+ deleteTerminalSession(dbSession.id);
742
+ continue;
743
+ }
744
+ if (dbSession.type === 'architect') {
745
+ freshEntry.architect = dbSession.id;
614
746
  terminals.push({
615
747
  type: 'architect',
616
748
  id: 'architect',
@@ -619,39 +751,78 @@ function getTerminalsForProject(projectPath, proxyUrl) {
619
751
  active: true,
620
752
  });
621
753
  }
754
+ else if (dbSession.type === 'builder') {
755
+ const builderId = dbSession.role_id || dbSession.id;
756
+ freshEntry.builders.set(builderId, dbSession.id);
757
+ terminals.push({
758
+ type: 'builder',
759
+ id: builderId,
760
+ label: `Builder ${builderId}`,
761
+ url: `${proxyUrl}?tab=builder-${builderId}`,
762
+ active: true,
763
+ });
764
+ }
765
+ else if (dbSession.type === 'shell') {
766
+ const shellId = dbSession.role_id || dbSession.id;
767
+ freshEntry.shells.set(shellId, dbSession.id);
768
+ terminals.push({
769
+ type: 'shell',
770
+ id: shellId,
771
+ label: `Shell ${shellId.replace('shell-', '')}`,
772
+ url: `${proxyUrl}?tab=shell-${shellId}`,
773
+ active: true,
774
+ });
775
+ }
622
776
  }
623
- // Add builder terminals
624
- for (const [builderId] of entry.builders) {
625
- const terminalId = entry.builders.get(builderId);
626
- if (terminalId) {
627
- const session = manager.getSession(terminalId);
777
+ // Also merge in-memory entries that may not be in SQLite yet
778
+ // (e.g., registered via POST /api/terminals but SQLite row was lost)
779
+ if (existingEntry) {
780
+ if (existingEntry.architect && !freshEntry.architect) {
781
+ const session = manager.getSession(existingEntry.architect);
628
782
  if (session) {
783
+ freshEntry.architect = existingEntry.architect;
629
784
  terminals.push({
630
- type: 'builder',
631
- id: builderId,
632
- label: `Builder ${builderId}`,
633
- url: `${proxyUrl}?tab=builder-${builderId}`,
785
+ type: 'architect',
786
+ id: 'architect',
787
+ label: 'Architect',
788
+ url: `${proxyUrl}?tab=architect`,
634
789
  active: true,
635
790
  });
636
791
  }
637
792
  }
638
- }
639
- // Add shell terminals
640
- for (const [shellId] of entry.shells) {
641
- const terminalId = entry.shells.get(shellId);
642
- if (terminalId) {
643
- const session = manager.getSession(terminalId);
644
- if (session) {
645
- terminals.push({
646
- type: 'shell',
647
- id: shellId,
648
- label: `Shell ${shellId.replace('shell-', '')}`,
649
- url: `${proxyUrl}?tab=shell-${shellId}`,
650
- active: true,
651
- });
793
+ for (const [builderId, terminalId] of existingEntry.builders) {
794
+ if (!freshEntry.builders.has(builderId)) {
795
+ const session = manager.getSession(terminalId);
796
+ if (session) {
797
+ freshEntry.builders.set(builderId, terminalId);
798
+ terminals.push({
799
+ type: 'builder',
800
+ id: builderId,
801
+ label: `Builder ${builderId}`,
802
+ url: `${proxyUrl}?tab=builder-${builderId}`,
803
+ active: true,
804
+ });
805
+ }
806
+ }
807
+ }
808
+ for (const [shellId, terminalId] of existingEntry.shells) {
809
+ if (!freshEntry.shells.has(shellId)) {
810
+ const session = manager.getSession(terminalId);
811
+ if (session) {
812
+ freshEntry.shells.set(shellId, terminalId);
813
+ terminals.push({
814
+ type: 'shell',
815
+ id: shellId,
816
+ label: `Shell ${shellId.replace('shell-', '')}`,
817
+ url: `${proxyUrl}?tab=shell-${shellId}`,
818
+ active: true,
819
+ });
820
+ }
652
821
  }
653
822
  }
654
823
  }
824
+ // Atomically replace the cache entry
825
+ projectTerminals.set(normalizedPath, freshEntry);
655
826
  // Gate status - builders don't have gate tracking yet in tower
656
827
  // TODO: Add gate status tracking when porch integration is updated
657
828
  const gateStatus = { hasGate: false };
@@ -864,8 +1035,20 @@ async function launchInstance(projectPath) {
864
1035
  try {
865
1036
  // Parse command string to separate command and args
866
1037
  const cmdParts = architectCmd.split(/\s+/);
867
- const cmd = cmdParts[0];
868
- const cmdArgs = cmdParts.slice(1);
1038
+ let cmd = cmdParts[0];
1039
+ let cmdArgs = cmdParts.slice(1);
1040
+ // Wrap in tmux for session persistence across Tower restarts
1041
+ const tmuxName = `architect-${path.basename(projectPath)}`;
1042
+ let activeTmuxSession = null;
1043
+ if (tmuxAvailable) {
1044
+ const tmuxCreated = createTmuxSession(tmuxName, cmd, cmdArgs, projectPath, 200, 50);
1045
+ if (tmuxCreated) {
1046
+ cmd = 'tmux';
1047
+ cmdArgs = ['attach-session', '-t', tmuxName];
1048
+ activeTmuxSession = tmuxName;
1049
+ log('INFO', `Created tmux session "${tmuxName}" for architect`);
1050
+ }
1051
+ }
869
1052
  const session = await manager.createSession({
870
1053
  command: cmd,
871
1054
  args: cmdArgs,
@@ -874,8 +1057,8 @@ async function launchInstance(projectPath) {
874
1057
  env: process.env,
875
1058
  });
876
1059
  entry.architect = session.id;
877
- // TICK-001: Save to SQLite for persistence
878
- saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid, null);
1060
+ // TICK-001: Save to SQLite for persistence (with tmux session name)
1061
+ saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid, activeTmuxSession);
879
1062
  log('INFO', `Created architect terminal for project: ${projectPath}`);
880
1063
  }
881
1064
  catch (err) {
@@ -922,6 +1105,11 @@ async function stopInstance(projectPath) {
922
1105
  // Get project terminals
923
1106
  const entry = projectTerminals.get(resolvedPath) || projectTerminals.get(projectPath);
924
1107
  if (entry) {
1108
+ // Query SQLite for tmux session names BEFORE deleting rows
1109
+ const dbSessions = getTerminalSessionsForProject(resolvedPath);
1110
+ const tmuxSessions = dbSessions
1111
+ .filter(s => s.tmux_session)
1112
+ .map(s => s.tmux_session);
925
1113
  // Kill architect
926
1114
  if (entry.architect) {
927
1115
  const session = manager.getSession(entry.architect);
@@ -946,6 +1134,10 @@ async function stopInstance(projectPath) {
946
1134
  stopped.push(session.pid);
947
1135
  }
948
1136
  }
1137
+ // Kill tmux sessions (node-pty kill only detaches, tmux keeps running)
1138
+ for (const tmuxName of tmuxSessions) {
1139
+ killTmuxSession(tmuxName);
1140
+ }
949
1141
  // Clear project from registry
950
1142
  projectTerminals.delete(resolvedPath);
951
1143
  projectTerminals.delete(projectPath);
@@ -1093,6 +1285,8 @@ const server = http.createServer(async (req, res) => {
1093
1285
  if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
1094
1286
  throw new Error('Invalid path');
1095
1287
  }
1288
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
1289
+ projectPath = normalizeProjectPath(projectPath);
1096
1290
  }
1097
1291
  catch {
1098
1292
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -1165,17 +1359,58 @@ const server = http.createServer(async (req, res) => {
1165
1359
  try {
1166
1360
  const body = await parseJsonBody(req);
1167
1361
  const manager = getTerminalManager();
1168
- const info = await manager.createSession({
1169
- command: typeof body.command === 'string' ? body.command : undefined,
1170
- args: Array.isArray(body.args) ? body.args : undefined,
1171
- cols: typeof body.cols === 'number' ? body.cols : undefined,
1172
- rows: typeof body.rows === 'number' ? body.rows : undefined,
1173
- cwd: typeof body.cwd === 'string' ? body.cwd : undefined,
1174
- env: typeof body.env === 'object' && body.env !== null ? body.env : undefined,
1175
- label: typeof body.label === 'string' ? body.label : undefined,
1176
- });
1362
+ // Parse request fields
1363
+ let command = typeof body.command === 'string' ? body.command : undefined;
1364
+ let args = Array.isArray(body.args) ? body.args : undefined;
1365
+ const cols = typeof body.cols === 'number' ? body.cols : undefined;
1366
+ const rows = typeof body.rows === 'number' ? body.rows : undefined;
1367
+ const cwd = typeof body.cwd === 'string' ? body.cwd : undefined;
1368
+ const env = typeof body.env === 'object' && body.env !== null ? body.env : undefined;
1369
+ const label = typeof body.label === 'string' ? body.label : undefined;
1370
+ // Optional tmux wrapping: create tmux session, then node-pty attaches to it
1371
+ const tmuxSession = typeof body.tmuxSession === 'string' ? body.tmuxSession : null;
1372
+ let activeTmuxSession = null;
1373
+ if (tmuxSession && tmuxAvailable && command && cwd) {
1374
+ const tmuxCreated = createTmuxSession(tmuxSession, command, args || [], cwd, cols || 200, rows || 50);
1375
+ if (tmuxCreated) {
1376
+ // Override: node-pty attaches to the tmux session
1377
+ command = 'tmux';
1378
+ args = ['attach-session', '-t', tmuxSession];
1379
+ activeTmuxSession = tmuxSession;
1380
+ log('INFO', `Created tmux session "${tmuxSession}" for terminal`);
1381
+ }
1382
+ // If tmux creation failed, fall through to bare node-pty
1383
+ }
1384
+ let info;
1385
+ try {
1386
+ info = await manager.createSession({ command, args, cols, rows, cwd, env, label });
1387
+ }
1388
+ catch (createErr) {
1389
+ // Clean up orphaned tmux session if node-pty creation failed
1390
+ if (activeTmuxSession) {
1391
+ killTmuxSession(activeTmuxSession);
1392
+ log('WARN', `Cleaned up orphaned tmux session "${activeTmuxSession}" after node-pty failure`);
1393
+ }
1394
+ throw createErr;
1395
+ }
1396
+ // Optional project association: register terminal with project state
1397
+ const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
1398
+ const termType = typeof body.type === 'string' && ['builder', 'shell'].includes(body.type) ? body.type : null;
1399
+ const roleId = typeof body.roleId === 'string' ? body.roleId : null;
1400
+ if (projectPath && termType && roleId) {
1401
+ const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
1402
+ if (termType === 'builder') {
1403
+ entry.builders.set(roleId, info.id);
1404
+ }
1405
+ else {
1406
+ entry.shells.set(roleId, info.id);
1407
+ }
1408
+ saveTerminalSession(info.id, projectPath, termType, roleId, info.pid, activeTmuxSession);
1409
+ log('INFO', `Registered terminal ${info.id} as ${termType} "${roleId}" for project ${projectPath}${activeTmuxSession ? ` (tmux: ${activeTmuxSession})` : ''}`);
1410
+ }
1411
+ // Return tmuxSession so caller knows whether tmux is backing this terminal
1177
1412
  res.writeHead(201, { 'Content-Type': 'application/json' });
1178
- res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}` }));
1413
+ res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}`, tmuxSession: activeTmuxSession }));
1179
1414
  }
1180
1415
  catch (err) {
1181
1416
  const message = err instanceof Error ? err.message : 'Unknown error';
@@ -1494,6 +1729,8 @@ const server = http.createServer(async (req, res) => {
1494
1729
  if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
1495
1730
  throw new Error('Invalid project path');
1496
1731
  }
1732
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
1733
+ projectPath = normalizeProjectPath(projectPath);
1497
1734
  }
1498
1735
  catch {
1499
1736
  res.writeHead(400, { 'Content-Type': 'text/plain' });
@@ -1504,6 +1741,27 @@ const server = http.createServer(async (req, res) => {
1504
1741
  // Phase 4 (Spec 0090): Tower handles everything directly
1505
1742
  const isApiCall = subPath.startsWith('api/') || subPath === 'api';
1506
1743
  const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
1744
+ // GET /file?path=<relative-path> — Read project file by path (for StatusPanel project list)
1745
+ if (req.method === 'GET' && subPath === 'file' && url.searchParams.has('path')) {
1746
+ const relPath = url.searchParams.get('path');
1747
+ const fullPath = path.resolve(projectPath, relPath);
1748
+ // Security: ensure resolved path stays within project directory
1749
+ if (!fullPath.startsWith(projectPath + path.sep) && fullPath !== projectPath) {
1750
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
1751
+ res.end('Forbidden');
1752
+ return;
1753
+ }
1754
+ try {
1755
+ const content = fs.readFileSync(fullPath, 'utf-8');
1756
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
1757
+ res.end(content);
1758
+ }
1759
+ catch {
1760
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1761
+ res.end('Not found');
1762
+ }
1763
+ return;
1764
+ }
1507
1765
  // Serve React dashboard static files directly if:
1508
1766
  // 1. Not an API call
1509
1767
  // 2. Not a WebSocket path
@@ -1580,6 +1838,15 @@ const server = http.createServer(async (req, res) => {
1580
1838
  terminalId,
1581
1839
  });
1582
1840
  }
1841
+ // Add file tabs (Spec 0092 - served through Tower, no separate ports)
1842
+ for (const [tabId, tab] of entry.fileTabs) {
1843
+ state.annotations.push({
1844
+ id: tabId,
1845
+ file: tab.path,
1846
+ port: 0, // No separate port - served through Tower
1847
+ pid: 0, // No separate process
1848
+ });
1849
+ }
1583
1850
  res.writeHead(200, { 'Content-Type': 'application/json' });
1584
1851
  res.end(JSON.stringify(state));
1585
1852
  return;
@@ -1589,10 +1856,23 @@ const server = http.createServer(async (req, res) => {
1589
1856
  try {
1590
1857
  const manager = getTerminalManager();
1591
1858
  const shellId = getNextShellId(projectPath);
1859
+ // Wrap in tmux for session persistence
1860
+ let shellCmd = process.env.SHELL || '/bin/bash';
1861
+ let shellArgs = [];
1862
+ const tmuxName = `shell-${path.basename(projectPath)}-${shellId}`;
1863
+ let activeTmuxSession = null;
1864
+ if (tmuxAvailable) {
1865
+ const tmuxCreated = createTmuxSession(tmuxName, shellCmd, shellArgs, projectPath, 200, 50);
1866
+ if (tmuxCreated) {
1867
+ shellCmd = 'tmux';
1868
+ shellArgs = ['attach-session', '-t', tmuxName];
1869
+ activeTmuxSession = tmuxName;
1870
+ }
1871
+ }
1592
1872
  // Create terminal session
1593
1873
  const session = await manager.createSession({
1594
- command: process.env.SHELL || '/bin/bash',
1595
- args: [],
1874
+ command: shellCmd,
1875
+ args: shellArgs,
1596
1876
  cwd: projectPath,
1597
1877
  label: `Shell ${shellId.replace('shell-', '')}`,
1598
1878
  env: process.env,
@@ -1601,7 +1881,7 @@ const server = http.createServer(async (req, res) => {
1601
1881
  const entry = getProjectTerminalsEntry(projectPath);
1602
1882
  entry.shells.set(shellId, session.id);
1603
1883
  // TICK-001: Save to SQLite for persistence
1604
- saveTerminalSession(session.id, projectPath, 'shell', shellId, session.pid, null);
1884
+ saveTerminalSession(session.id, projectPath, 'shell', shellId, session.pid, activeTmuxSession);
1605
1885
  res.writeHead(200, { 'Content-Type': 'application/json' });
1606
1886
  res.end(JSON.stringify({
1607
1887
  id: shellId,
@@ -1617,12 +1897,193 @@ const server = http.createServer(async (req, res) => {
1617
1897
  }
1618
1898
  return;
1619
1899
  }
1620
- // DELETE /api/tabs/:id - Delete a terminal tab
1900
+ // POST /api/tabs/file - Create a file tab (Spec 0092)
1901
+ if (req.method === 'POST' && apiPath === 'tabs/file') {
1902
+ try {
1903
+ const body = await new Promise((resolve) => {
1904
+ let data = '';
1905
+ req.on('data', (chunk) => data += chunk.toString());
1906
+ req.on('end', () => resolve(data));
1907
+ });
1908
+ const { path: filePath, line } = JSON.parse(body || '{}');
1909
+ if (!filePath || typeof filePath !== 'string') {
1910
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1911
+ res.end(JSON.stringify({ error: 'Missing path parameter' }));
1912
+ return;
1913
+ }
1914
+ // Resolve path relative to project
1915
+ const fullPath = path.isAbsolute(filePath)
1916
+ ? filePath
1917
+ : path.join(projectPath, filePath);
1918
+ // Security: ensure path is within project or is absolute path user provided
1919
+ const normalizedFull = path.normalize(fullPath);
1920
+ const normalizedProject = path.normalize(projectPath);
1921
+ if (!normalizedFull.startsWith(normalizedProject) && !path.isAbsolute(filePath)) {
1922
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1923
+ res.end(JSON.stringify({ error: 'Path outside project' }));
1924
+ return;
1925
+ }
1926
+ // Check file exists
1927
+ if (!fs.existsSync(fullPath)) {
1928
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1929
+ res.end(JSON.stringify({ error: 'File not found' }));
1930
+ return;
1931
+ }
1932
+ const entry = getProjectTerminalsEntry(projectPath);
1933
+ // Check if already open
1934
+ for (const [id, tab] of entry.fileTabs) {
1935
+ if (tab.path === fullPath) {
1936
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1937
+ res.end(JSON.stringify({ id, existing: true, line }));
1938
+ return;
1939
+ }
1940
+ }
1941
+ // Create new file tab
1942
+ const id = `file-${Date.now().toString(36)}`;
1943
+ entry.fileTabs.set(id, { id, path: fullPath, createdAt: Date.now() });
1944
+ log('INFO', `Created file tab: ${id} for ${path.basename(fullPath)}`);
1945
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1946
+ res.end(JSON.stringify({ id, existing: false, line }));
1947
+ }
1948
+ catch (err) {
1949
+ log('ERROR', `Failed to create file tab: ${err.message}`);
1950
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1951
+ res.end(JSON.stringify({ error: err.message }));
1952
+ }
1953
+ return;
1954
+ }
1955
+ // GET /api/file/:id - Get file content as JSON (Spec 0092)
1956
+ const fileGetMatch = apiPath.match(/^file\/([^/]+)$/);
1957
+ if (req.method === 'GET' && fileGetMatch) {
1958
+ const tabId = fileGetMatch[1];
1959
+ const entry = getProjectTerminalsEntry(projectPath);
1960
+ const tab = entry.fileTabs.get(tabId);
1961
+ if (!tab) {
1962
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1963
+ res.end(JSON.stringify({ error: 'File tab not found' }));
1964
+ return;
1965
+ }
1966
+ try {
1967
+ const ext = path.extname(tab.path).slice(1).toLowerCase();
1968
+ const isText = !['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'webm', 'mov', 'pdf'].includes(ext);
1969
+ if (isText) {
1970
+ const content = fs.readFileSync(tab.path, 'utf-8');
1971
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1972
+ res.end(JSON.stringify({
1973
+ path: tab.path,
1974
+ name: path.basename(tab.path),
1975
+ content,
1976
+ language: getLanguageForExt(ext),
1977
+ isMarkdown: ext === 'md',
1978
+ isImage: false,
1979
+ isVideo: false,
1980
+ }));
1981
+ }
1982
+ else {
1983
+ // For binary files, just return metadata
1984
+ const stat = fs.statSync(tab.path);
1985
+ const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
1986
+ const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
1987
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1988
+ res.end(JSON.stringify({
1989
+ path: tab.path,
1990
+ name: path.basename(tab.path),
1991
+ content: null,
1992
+ language: ext,
1993
+ isMarkdown: false,
1994
+ isImage,
1995
+ isVideo,
1996
+ size: stat.size,
1997
+ }));
1998
+ }
1999
+ }
2000
+ catch (err) {
2001
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2002
+ res.end(JSON.stringify({ error: err.message }));
2003
+ }
2004
+ return;
2005
+ }
2006
+ // GET /api/file/:id/raw - Get raw file content (for images/video) (Spec 0092)
2007
+ const fileRawMatch = apiPath.match(/^file\/([^/]+)\/raw$/);
2008
+ if (req.method === 'GET' && fileRawMatch) {
2009
+ const tabId = fileRawMatch[1];
2010
+ const entry = getProjectTerminalsEntry(projectPath);
2011
+ const tab = entry.fileTabs.get(tabId);
2012
+ if (!tab) {
2013
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2014
+ res.end('File tab not found');
2015
+ return;
2016
+ }
2017
+ try {
2018
+ const data = fs.readFileSync(tab.path);
2019
+ const mimeType = getMimeTypeForFile(tab.path);
2020
+ res.writeHead(200, {
2021
+ 'Content-Type': mimeType,
2022
+ 'Content-Length': data.length,
2023
+ 'Cache-Control': 'no-cache',
2024
+ });
2025
+ res.end(data);
2026
+ }
2027
+ catch (err) {
2028
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2029
+ res.end(err.message);
2030
+ }
2031
+ return;
2032
+ }
2033
+ // POST /api/file/:id/save - Save file content (Spec 0092)
2034
+ const fileSaveMatch = apiPath.match(/^file\/([^/]+)\/save$/);
2035
+ if (req.method === 'POST' && fileSaveMatch) {
2036
+ const tabId = fileSaveMatch[1];
2037
+ const entry = getProjectTerminalsEntry(projectPath);
2038
+ const tab = entry.fileTabs.get(tabId);
2039
+ if (!tab) {
2040
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2041
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2042
+ return;
2043
+ }
2044
+ try {
2045
+ const body = await new Promise((resolve) => {
2046
+ let data = '';
2047
+ req.on('data', (chunk) => data += chunk.toString());
2048
+ req.on('end', () => resolve(data));
2049
+ });
2050
+ const { content } = JSON.parse(body || '{}');
2051
+ if (typeof content !== 'string') {
2052
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2053
+ res.end(JSON.stringify({ error: 'Missing content parameter' }));
2054
+ return;
2055
+ }
2056
+ fs.writeFileSync(tab.path, content, 'utf-8');
2057
+ log('INFO', `Saved file: ${tab.path}`);
2058
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2059
+ res.end(JSON.stringify({ success: true }));
2060
+ }
2061
+ catch (err) {
2062
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2063
+ res.end(JSON.stringify({ error: err.message }));
2064
+ }
2065
+ return;
2066
+ }
2067
+ // DELETE /api/tabs/:id - Delete a terminal or file tab
1621
2068
  const deleteMatch = apiPath.match(/^tabs\/(.+)$/);
1622
2069
  if (req.method === 'DELETE' && deleteMatch) {
1623
2070
  const tabId = deleteMatch[1];
1624
2071
  const entry = getProjectTerminalsEntry(projectPath);
1625
2072
  const manager = getTerminalManager();
2073
+ // Check if it's a file tab first (Spec 0092)
2074
+ if (tabId.startsWith('file-')) {
2075
+ if (entry.fileTabs.has(tabId)) {
2076
+ entry.fileTabs.delete(tabId);
2077
+ log('INFO', `Deleted file tab: ${tabId}`);
2078
+ res.writeHead(204);
2079
+ res.end();
2080
+ }
2081
+ else {
2082
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2083
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2084
+ }
2085
+ return;
2086
+ }
1626
2087
  // Find and delete the terminal
1627
2088
  let terminalId;
1628
2089
  if (tabId.startsWith('shell-')) {
@@ -1678,6 +2139,241 @@ const server = http.createServer(async (req, res) => {
1678
2139
  res.end(JSON.stringify({ ok: true }));
1679
2140
  return;
1680
2141
  }
2142
+ // GET /api/files - Return project directory tree for file browser (Spec 0092)
2143
+ if (req.method === 'GET' && apiPath === 'files') {
2144
+ const maxDepth = parseInt(url.searchParams.get('depth') || '3', 10);
2145
+ const ignore = new Set(['.git', 'node_modules', '.builders', 'dist', '.agent-farm', '.next', '.cache', '__pycache__']);
2146
+ function readTree(dir, depth) {
2147
+ if (depth <= 0)
2148
+ return [];
2149
+ try {
2150
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
2151
+ return entries
2152
+ .filter(e => !e.name.startsWith('.') || e.name === '.env.example')
2153
+ .filter(e => !ignore.has(e.name))
2154
+ .sort((a, b) => {
2155
+ // Directories first, then alphabetical
2156
+ if (a.isDirectory() && !b.isDirectory())
2157
+ return -1;
2158
+ if (!a.isDirectory() && b.isDirectory())
2159
+ return 1;
2160
+ return a.name.localeCompare(b.name);
2161
+ })
2162
+ .map(e => {
2163
+ const fullPath = path.join(dir, e.name);
2164
+ const relativePath = path.relative(projectPath, fullPath);
2165
+ if (e.isDirectory()) {
2166
+ return { name: e.name, path: relativePath, type: 'directory', children: readTree(fullPath, depth - 1) };
2167
+ }
2168
+ return { name: e.name, path: relativePath, type: 'file' };
2169
+ });
2170
+ }
2171
+ catch {
2172
+ return [];
2173
+ }
2174
+ }
2175
+ const tree = readTree(projectPath, maxDepth);
2176
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2177
+ res.end(JSON.stringify(tree));
2178
+ return;
2179
+ }
2180
+ // GET /api/git/status - Return git status for file browser (Spec 0092)
2181
+ if (req.method === 'GET' && apiPath === 'git/status') {
2182
+ try {
2183
+ // Get git status in porcelain format for parsing
2184
+ const result = execSync('git status --porcelain', {
2185
+ cwd: projectPath,
2186
+ encoding: 'utf-8',
2187
+ timeout: 5000,
2188
+ });
2189
+ // Parse porcelain output: XY filename
2190
+ // X = staging area status, Y = working tree status
2191
+ const modified = [];
2192
+ const staged = [];
2193
+ const untracked = [];
2194
+ for (const line of result.split('\n')) {
2195
+ if (!line)
2196
+ continue;
2197
+ const x = line[0]; // staging area
2198
+ const y = line[1]; // working tree
2199
+ const filepath = line.slice(3);
2200
+ if (x === '?' && y === '?') {
2201
+ untracked.push(filepath);
2202
+ }
2203
+ else {
2204
+ if (x !== ' ' && x !== '?') {
2205
+ staged.push(filepath);
2206
+ }
2207
+ if (y !== ' ' && y !== '?') {
2208
+ modified.push(filepath);
2209
+ }
2210
+ }
2211
+ }
2212
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2213
+ res.end(JSON.stringify({ modified, staged, untracked }));
2214
+ }
2215
+ catch (err) {
2216
+ // Not a git repo or git command failed
2217
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2218
+ res.end(JSON.stringify({ modified: [], staged: [], untracked: [], error: err.message }));
2219
+ }
2220
+ return;
2221
+ }
2222
+ // GET /api/files/recent - Return recently opened file tabs (Spec 0092)
2223
+ if (req.method === 'GET' && apiPath === 'files/recent') {
2224
+ const entry = getProjectTerminalsEntry(projectPath);
2225
+ // Get all file tabs sorted by creation time (most recent first)
2226
+ const recentFiles = Array.from(entry.fileTabs.values())
2227
+ .sort((a, b) => b.createdAt - a.createdAt)
2228
+ .slice(0, 10) // Limit to 10 most recent
2229
+ .map(tab => ({
2230
+ id: tab.id,
2231
+ path: tab.path,
2232
+ name: path.basename(tab.path),
2233
+ relativePath: path.relative(projectPath, tab.path),
2234
+ }));
2235
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2236
+ res.end(JSON.stringify(recentFiles));
2237
+ return;
2238
+ }
2239
+ // GET /api/annotate/:tabId/* — Serve rich annotator template and sub-APIs
2240
+ const annotateMatch = apiPath.match(/^annotate\/([^/]+)(\/(.*))?$/);
2241
+ if (annotateMatch) {
2242
+ const tabId = annotateMatch[1];
2243
+ const subRoute = annotateMatch[3] || '';
2244
+ const entry = getProjectTerminalsEntry(projectPath);
2245
+ const tab = entry.fileTabs.get(tabId);
2246
+ if (!tab) {
2247
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
2248
+ res.end('File tab not found');
2249
+ return;
2250
+ }
2251
+ const filePath = tab.path;
2252
+ const ext = path.extname(filePath).slice(1).toLowerCase();
2253
+ const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
2254
+ const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
2255
+ const is3D = ['stl', '3mf'].includes(ext);
2256
+ const isMarkdown = ext === 'md';
2257
+ // Sub-route: GET /file — re-read file content from disk
2258
+ if (req.method === 'GET' && subRoute === 'file') {
2259
+ try {
2260
+ const content = fs.readFileSync(filePath, 'utf-8');
2261
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
2262
+ res.end(content);
2263
+ }
2264
+ catch (err) {
2265
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2266
+ res.end(err.message);
2267
+ }
2268
+ return;
2269
+ }
2270
+ // Sub-route: POST /save — save file content
2271
+ if (req.method === 'POST' && subRoute === 'save') {
2272
+ try {
2273
+ const body = await new Promise((resolve) => {
2274
+ let data = '';
2275
+ req.on('data', (chunk) => data += chunk.toString());
2276
+ req.on('end', () => resolve(data));
2277
+ });
2278
+ const parsed = JSON.parse(body || '{}');
2279
+ const fileContent = parsed.content;
2280
+ if (typeof fileContent !== 'string') {
2281
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
2282
+ res.end('Missing content');
2283
+ return;
2284
+ }
2285
+ fs.writeFileSync(filePath, fileContent, 'utf-8');
2286
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2287
+ res.end(JSON.stringify({ ok: true }));
2288
+ }
2289
+ catch (err) {
2290
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2291
+ res.end(err.message);
2292
+ }
2293
+ return;
2294
+ }
2295
+ // Sub-route: GET /api/mtime — file modification time
2296
+ if (req.method === 'GET' && subRoute === 'api/mtime') {
2297
+ try {
2298
+ const stat = fs.statSync(filePath);
2299
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2300
+ res.end(JSON.stringify({ mtime: stat.mtimeMs }));
2301
+ }
2302
+ catch (err) {
2303
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2304
+ res.end(err.message);
2305
+ }
2306
+ return;
2307
+ }
2308
+ // Sub-route: GET /api/image, /api/video, /api/model — raw binary content
2309
+ if (req.method === 'GET' && (subRoute === 'api/image' || subRoute === 'api/video' || subRoute === 'api/model')) {
2310
+ try {
2311
+ const data = fs.readFileSync(filePath);
2312
+ const mimeType = getMimeTypeForFile(filePath);
2313
+ res.writeHead(200, {
2314
+ 'Content-Type': mimeType,
2315
+ 'Content-Length': data.length,
2316
+ 'Cache-Control': 'no-cache',
2317
+ });
2318
+ res.end(data);
2319
+ }
2320
+ catch (err) {
2321
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2322
+ res.end(err.message);
2323
+ }
2324
+ return;
2325
+ }
2326
+ // Default: serve the annotator HTML template
2327
+ if (req.method === 'GET' && (subRoute === '' || subRoute === undefined)) {
2328
+ try {
2329
+ const templateFile = is3D ? '3d-viewer.html' : 'open.html';
2330
+ const tplPath = path.resolve(__dirname, `../../../templates/${templateFile}`);
2331
+ let html = fs.readFileSync(tplPath, 'utf-8');
2332
+ const fileName = path.basename(filePath);
2333
+ const fileSize = fs.statSync(filePath).size;
2334
+ if (is3D) {
2335
+ html = html.replace(/\{\{FILE\}\}/g, fileName);
2336
+ html = html.replace(/\{\{FILE_PATH_JSON\}\}/g, JSON.stringify(filePath));
2337
+ html = html.replace(/\{\{FORMAT\}\}/g, ext);
2338
+ }
2339
+ else {
2340
+ html = html.replace(/\{\{FILE\}\}/g, fileName);
2341
+ html = html.replace(/\{\{FILE_PATH\}\}/g, filePath);
2342
+ html = html.replace(/\{\{BUILDER_ID\}\}/g, '');
2343
+ html = html.replace(/\{\{LANG\}\}/g, getLanguageForExt(ext));
2344
+ html = html.replace(/\{\{IS_MARKDOWN\}\}/g, String(isMarkdown));
2345
+ html = html.replace(/\{\{IS_IMAGE\}\}/g, String(isImage));
2346
+ html = html.replace(/\{\{IS_VIDEO\}\}/g, String(isVideo));
2347
+ html = html.replace(/\{\{FILE_SIZE\}\}/g, String(fileSize));
2348
+ // Inject initialization script (template loads content via fetch)
2349
+ let initScript;
2350
+ if (isImage) {
2351
+ initScript = `initImage(${fileSize});`;
2352
+ }
2353
+ else if (isVideo) {
2354
+ initScript = `initVideo(${fileSize});`;
2355
+ }
2356
+ else {
2357
+ initScript = `fetch('file').then(r=>r.text()).then(init);`;
2358
+ }
2359
+ html = html.replace('// FILE_CONTENT will be injected by the server', initScript);
2360
+ }
2361
+ // Handle ?line= query param for scroll-to-line
2362
+ const lineParam = url.searchParams.get('line');
2363
+ if (lineParam) {
2364
+ 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>`;
2365
+ html = html.replace('</body>', `${scrollScript}</body>`);
2366
+ }
2367
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2368
+ res.end(html);
2369
+ }
2370
+ catch (err) {
2371
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2372
+ res.end(`Failed to serve annotator: ${err.message}`);
2373
+ }
2374
+ return;
2375
+ }
2376
+ }
1681
2377
  // Unhandled API route
1682
2378
  res.writeHead(404, { 'Content-Type': 'application/json' });
1683
2379
  res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));
@@ -1714,6 +2410,9 @@ const server = http.createServer(async (req, res) => {
1714
2410
  // SECURITY: Bind to localhost only to prevent network exposure
1715
2411
  server.listen(port, '127.0.0.1', async () => {
1716
2412
  log('INFO', `Tower server listening at http://localhost:${port}`);
2413
+ // Check tmux availability once at startup
2414
+ tmuxAvailable = checkTmux();
2415
+ log('INFO', `tmux available: ${tmuxAvailable}${tmuxAvailable ? '' : ' (terminals will not persist across restarts)'}`);
1717
2416
  // TICK-001: Reconcile terminal sessions from previous run
1718
2417
  await reconcileTerminalSessions();
1719
2418
  });
@@ -1762,6 +2461,8 @@ server.on('upgrade', async (req, socket, head) => {
1762
2461
  if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
1763
2462
  throw new Error('Invalid project path');
1764
2463
  }
2464
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
2465
+ projectPath = normalizeProjectPath(projectPath);
1765
2466
  }
1766
2467
  catch {
1767
2468
  socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');