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

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 (35) hide show
  1. package/dashboard/dist/assets/{index-BIHeqvy0.css → index-BV7KQvFU.css} +1 -1
  2. package/dashboard/dist/assets/index-xOaDIZ0l.js +132 -0
  3. package/dashboard/dist/assets/index-xOaDIZ0l.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/spawn.d.ts.map +1 -1
  10. package/dist/agent-farm/commands/spawn.js +31 -20
  11. package/dist/agent-farm/commands/spawn.js.map +1 -1
  12. package/dist/agent-farm/commands/stop.js +1 -1
  13. package/dist/agent-farm/commands/stop.js.map +1 -1
  14. package/dist/agent-farm/servers/tower-server.js +561 -62
  15. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  16. package/dist/agent-farm/types.d.ts +0 -1
  17. package/dist/agent-farm/types.d.ts.map +1 -1
  18. package/dist/agent-farm/utils/config.d.ts.map +1 -1
  19. package/dist/agent-farm/utils/config.js +1 -3
  20. package/dist/agent-farm/utils/config.js.map +1 -1
  21. package/dist/agent-farm/utils/port-registry.d.ts +0 -1
  22. package/dist/agent-farm/utils/port-registry.d.ts.map +1 -1
  23. package/dist/agent-farm/utils/port-registry.js +1 -1
  24. package/dist/agent-farm/utils/port-registry.js.map +1 -1
  25. package/package.json +1 -1
  26. package/skeleton/protocols/bugfix/prompts/fix.md +77 -0
  27. package/skeleton/protocols/bugfix/prompts/investigate.md +77 -0
  28. package/skeleton/protocols/bugfix/prompts/pr.md +76 -0
  29. package/skeleton/protocols/bugfix/protocol.json +5 -0
  30. package/dashboard/dist/assets/index-VvUWRPNP.js +0 -120
  31. package/dashboard/dist/assets/index-VvUWRPNP.js.map +0 -1
  32. package/dist/agent-farm/servers/open-server.d.ts +0 -7
  33. package/dist/agent-farm/servers/open-server.d.ts.map +0 -1
  34. package/dist/agent-farm/servers/open-server.js +0 -315
  35. 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,51 @@ 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
+ return true;
246
+ }
247
+ catch (err) {
248
+ log('WARN', `Failed to create tmux session "${sessionName}": ${err.message}`);
249
+ return false;
250
+ }
251
+ }
177
252
  /**
178
253
  * Check if a tmux session exists
179
254
  */
@@ -213,9 +288,9 @@ function killTmuxSession(sessionName) {
213
288
  /**
214
289
  * Reconcile terminal sessions from SQLite against reality on startup.
215
290
  *
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.
291
+ * For sessions with surviving tmux sessions: re-attach via new node-pty,
292
+ * register in projectTerminals, and update SQLite with new terminal ID.
293
+ * For dead sessions: clean up SQLite rows and kill orphaned processes.
219
294
  */
220
295
  async function reconcileTerminalSessions() {
221
296
  const db = getGlobalDb();
@@ -232,18 +307,54 @@ async function reconcileTerminalSessions() {
232
307
  return;
233
308
  }
234
309
  log('INFO', `Reconciling ${sessions.length} terminal sessions from previous run...`);
310
+ const manager = getTerminalManager();
311
+ let reconnected = 0;
235
312
  let killed = 0;
236
313
  let cleaned = 0;
237
314
  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)})`);
315
+ // Can we reconnect to a surviving tmux session?
316
+ if (session.tmux_session && tmuxAvailable && tmuxSessionExists(session.tmux_session)) {
317
+ try {
318
+ // Create new node-pty that attaches to the surviving tmux session
319
+ const newSession = await manager.createSession({
320
+ command: 'tmux',
321
+ args: ['attach-session', '-t', session.tmux_session],
322
+ cwd: session.project_path,
323
+ label: session.type === 'architect' ? 'Architect' : `${session.type} ${session.role_id || session.id}`,
324
+ });
325
+ // Register in projectTerminals Map
326
+ const entry = getProjectTerminalsEntry(session.project_path);
327
+ if (session.type === 'architect') {
328
+ entry.architect = newSession.id;
329
+ }
330
+ else if (session.type === 'builder') {
331
+ const builderId = session.role_id || session.id;
332
+ entry.builders.set(builderId, newSession.id);
333
+ }
334
+ else if (session.type === 'shell') {
335
+ const shellId = session.role_id || session.id;
336
+ entry.shells.set(shellId, newSession.id);
337
+ }
338
+ // Update SQLite: delete old row, insert new with new terminal ID
339
+ db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
340
+ saveTerminalSession(newSession.id, session.project_path, session.type, session.role_id, newSession.pid, session.tmux_session);
341
+ log('INFO', `Reconnected to tmux session "${session.tmux_session}" → terminal ${newSession.id} (${session.type} for ${path.basename(session.project_path)})`);
342
+ reconnected++;
343
+ continue;
344
+ }
345
+ catch (err) {
346
+ log('WARN', `Failed to reconnect to tmux session "${session.tmux_session}": ${err.message}`);
347
+ // Fall through to cleanup
348
+ killTmuxSession(session.tmux_session);
349
+ killed++;
350
+ }
351
+ }
352
+ // No tmux or tmux session dead — check for orphaned processes
353
+ else if (session.tmux_session && tmuxSessionExists(session.tmux_session)) {
354
+ // tmux exists but tmuxAvailable is false (shouldn't happen, but be safe)
243
355
  killTmuxSession(session.tmux_session);
244
356
  killed++;
245
357
  }
246
- // Check if process still running (kill if we can)
247
358
  else if (session.pid && processExists(session.pid)) {
248
359
  log('INFO', `Found orphaned process: PID ${session.pid} (${session.type} for ${path.basename(session.project_path)})`);
249
360
  try {
@@ -254,11 +365,11 @@ async function reconcileTerminalSessions() {
254
365
  // Process may not be killable (different user, etc)
255
366
  }
256
367
  }
257
- // Always clean up the DB row - we start fresh after restart
368
+ // Clean up the DB row for sessions we couldn't reconnect
258
369
  db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
259
370
  cleaned++;
260
371
  }
261
- log('INFO', `Reconciliation complete: ${killed} orphaned processes killed, ${cleaned} DB rows cleaned`);
372
+ log('INFO', `Reconciliation complete: ${reconnected} reconnected, ${killed} orphaned killed, ${cleaned} cleaned up`);
262
373
  }
263
374
  /**
264
375
  * Get terminal sessions from SQLite for a project.
@@ -601,16 +712,32 @@ async function getGateStatusForProject(basePort) {
601
712
  * Returns architect, builders, and shells with their URLs.
602
713
  */
603
714
  function getTerminalsForProject(projectPath, proxyUrl) {
604
- const entry = projectTerminals.get(projectPath);
605
715
  const manager = getTerminalManager();
606
716
  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) {
717
+ // SQLite is authoritative - query it first (Spec 0090 requirement)
718
+ const dbSessions = getTerminalSessionsForProject(projectPath);
719
+ // Use normalized path for cache consistency
720
+ const normalizedPath = normalizeProjectPath(projectPath);
721
+ // Build a fresh entry from SQLite, then replace atomically to avoid
722
+ // destroying in-memory state that was registered via POST /api/terminals.
723
+ // Previous approach cleared the cache then rebuilt, which lost terminals
724
+ // if their SQLite rows were deleted by external interference (e.g., tests).
725
+ const freshEntry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
726
+ // Preserve file tabs from existing entry (not stored in SQLite)
727
+ const existingEntry = projectTerminals.get(normalizedPath);
728
+ if (existingEntry) {
729
+ freshEntry.fileTabs = existingEntry.fileTabs;
730
+ }
731
+ for (const dbSession of dbSessions) {
732
+ // Verify session still exists in TerminalManager (runtime state)
733
+ const session = manager.getSession(dbSession.id);
734
+ if (!session) {
735
+ // Stale row in SQLite - clean it up
736
+ deleteTerminalSession(dbSession.id);
737
+ continue;
738
+ }
739
+ if (dbSession.type === 'architect') {
740
+ freshEntry.architect = dbSession.id;
614
741
  terminals.push({
615
742
  type: 'architect',
616
743
  id: 'architect',
@@ -619,39 +746,78 @@ function getTerminalsForProject(projectPath, proxyUrl) {
619
746
  active: true,
620
747
  });
621
748
  }
749
+ else if (dbSession.type === 'builder') {
750
+ const builderId = dbSession.role_id || dbSession.id;
751
+ freshEntry.builders.set(builderId, dbSession.id);
752
+ terminals.push({
753
+ type: 'builder',
754
+ id: builderId,
755
+ label: `Builder ${builderId}`,
756
+ url: `${proxyUrl}?tab=builder-${builderId}`,
757
+ active: true,
758
+ });
759
+ }
760
+ else if (dbSession.type === 'shell') {
761
+ const shellId = dbSession.role_id || dbSession.id;
762
+ freshEntry.shells.set(shellId, dbSession.id);
763
+ terminals.push({
764
+ type: 'shell',
765
+ id: shellId,
766
+ label: `Shell ${shellId.replace('shell-', '')}`,
767
+ url: `${proxyUrl}?tab=shell-${shellId}`,
768
+ active: true,
769
+ });
770
+ }
622
771
  }
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);
772
+ // Also merge in-memory entries that may not be in SQLite yet
773
+ // (e.g., registered via POST /api/terminals but SQLite row was lost)
774
+ if (existingEntry) {
775
+ if (existingEntry.architect && !freshEntry.architect) {
776
+ const session = manager.getSession(existingEntry.architect);
628
777
  if (session) {
778
+ freshEntry.architect = existingEntry.architect;
629
779
  terminals.push({
630
- type: 'builder',
631
- id: builderId,
632
- label: `Builder ${builderId}`,
633
- url: `${proxyUrl}?tab=builder-${builderId}`,
780
+ type: 'architect',
781
+ id: 'architect',
782
+ label: 'Architect',
783
+ url: `${proxyUrl}?tab=architect`,
634
784
  active: true,
635
785
  });
636
786
  }
637
787
  }
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
- });
788
+ for (const [builderId, terminalId] of existingEntry.builders) {
789
+ if (!freshEntry.builders.has(builderId)) {
790
+ const session = manager.getSession(terminalId);
791
+ if (session) {
792
+ freshEntry.builders.set(builderId, terminalId);
793
+ terminals.push({
794
+ type: 'builder',
795
+ id: builderId,
796
+ label: `Builder ${builderId}`,
797
+ url: `${proxyUrl}?tab=builder-${builderId}`,
798
+ active: true,
799
+ });
800
+ }
801
+ }
802
+ }
803
+ for (const [shellId, terminalId] of existingEntry.shells) {
804
+ if (!freshEntry.shells.has(shellId)) {
805
+ const session = manager.getSession(terminalId);
806
+ if (session) {
807
+ freshEntry.shells.set(shellId, terminalId);
808
+ terminals.push({
809
+ type: 'shell',
810
+ id: shellId,
811
+ label: `Shell ${shellId.replace('shell-', '')}`,
812
+ url: `${proxyUrl}?tab=shell-${shellId}`,
813
+ active: true,
814
+ });
815
+ }
652
816
  }
653
817
  }
654
818
  }
819
+ // Atomically replace the cache entry
820
+ projectTerminals.set(normalizedPath, freshEntry);
655
821
  // Gate status - builders don't have gate tracking yet in tower
656
822
  // TODO: Add gate status tracking when porch integration is updated
657
823
  const gateStatus = { hasGate: false };
@@ -864,8 +1030,20 @@ async function launchInstance(projectPath) {
864
1030
  try {
865
1031
  // Parse command string to separate command and args
866
1032
  const cmdParts = architectCmd.split(/\s+/);
867
- const cmd = cmdParts[0];
868
- const cmdArgs = cmdParts.slice(1);
1033
+ let cmd = cmdParts[0];
1034
+ let cmdArgs = cmdParts.slice(1);
1035
+ // Wrap in tmux for session persistence across Tower restarts
1036
+ const tmuxName = `architect-${path.basename(projectPath)}`;
1037
+ let activeTmuxSession = null;
1038
+ if (tmuxAvailable) {
1039
+ const tmuxCreated = createTmuxSession(tmuxName, cmd, cmdArgs, projectPath, 200, 50);
1040
+ if (tmuxCreated) {
1041
+ cmd = 'tmux';
1042
+ cmdArgs = ['attach-session', '-t', tmuxName];
1043
+ activeTmuxSession = tmuxName;
1044
+ log('INFO', `Created tmux session "${tmuxName}" for architect`);
1045
+ }
1046
+ }
869
1047
  const session = await manager.createSession({
870
1048
  command: cmd,
871
1049
  args: cmdArgs,
@@ -874,8 +1052,8 @@ async function launchInstance(projectPath) {
874
1052
  env: process.env,
875
1053
  });
876
1054
  entry.architect = session.id;
877
- // TICK-001: Save to SQLite for persistence
878
- saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid, null);
1055
+ // TICK-001: Save to SQLite for persistence (with tmux session name)
1056
+ saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid, activeTmuxSession);
879
1057
  log('INFO', `Created architect terminal for project: ${projectPath}`);
880
1058
  }
881
1059
  catch (err) {
@@ -922,6 +1100,11 @@ async function stopInstance(projectPath) {
922
1100
  // Get project terminals
923
1101
  const entry = projectTerminals.get(resolvedPath) || projectTerminals.get(projectPath);
924
1102
  if (entry) {
1103
+ // Query SQLite for tmux session names BEFORE deleting rows
1104
+ const dbSessions = getTerminalSessionsForProject(resolvedPath);
1105
+ const tmuxSessions = dbSessions
1106
+ .filter(s => s.tmux_session)
1107
+ .map(s => s.tmux_session);
925
1108
  // Kill architect
926
1109
  if (entry.architect) {
927
1110
  const session = manager.getSession(entry.architect);
@@ -946,6 +1129,10 @@ async function stopInstance(projectPath) {
946
1129
  stopped.push(session.pid);
947
1130
  }
948
1131
  }
1132
+ // Kill tmux sessions (node-pty kill only detaches, tmux keeps running)
1133
+ for (const tmuxName of tmuxSessions) {
1134
+ killTmuxSession(tmuxName);
1135
+ }
949
1136
  // Clear project from registry
950
1137
  projectTerminals.delete(resolvedPath);
951
1138
  projectTerminals.delete(projectPath);
@@ -1093,6 +1280,8 @@ const server = http.createServer(async (req, res) => {
1093
1280
  if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
1094
1281
  throw new Error('Invalid path');
1095
1282
  }
1283
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
1284
+ projectPath = normalizeProjectPath(projectPath);
1096
1285
  }
1097
1286
  catch {
1098
1287
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -1165,17 +1354,58 @@ const server = http.createServer(async (req, res) => {
1165
1354
  try {
1166
1355
  const body = await parseJsonBody(req);
1167
1356
  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
- });
1357
+ // Parse request fields
1358
+ let command = typeof body.command === 'string' ? body.command : undefined;
1359
+ let args = Array.isArray(body.args) ? body.args : undefined;
1360
+ const cols = typeof body.cols === 'number' ? body.cols : undefined;
1361
+ const rows = typeof body.rows === 'number' ? body.rows : undefined;
1362
+ const cwd = typeof body.cwd === 'string' ? body.cwd : undefined;
1363
+ const env = typeof body.env === 'object' && body.env !== null ? body.env : undefined;
1364
+ const label = typeof body.label === 'string' ? body.label : undefined;
1365
+ // Optional tmux wrapping: create tmux session, then node-pty attaches to it
1366
+ const tmuxSession = typeof body.tmuxSession === 'string' ? body.tmuxSession : null;
1367
+ let activeTmuxSession = null;
1368
+ if (tmuxSession && tmuxAvailable && command && cwd) {
1369
+ const tmuxCreated = createTmuxSession(tmuxSession, command, args || [], cwd, cols || 200, rows || 50);
1370
+ if (tmuxCreated) {
1371
+ // Override: node-pty attaches to the tmux session
1372
+ command = 'tmux';
1373
+ args = ['attach-session', '-t', tmuxSession];
1374
+ activeTmuxSession = tmuxSession;
1375
+ log('INFO', `Created tmux session "${tmuxSession}" for terminal`);
1376
+ }
1377
+ // If tmux creation failed, fall through to bare node-pty
1378
+ }
1379
+ let info;
1380
+ try {
1381
+ info = await manager.createSession({ command, args, cols, rows, cwd, env, label });
1382
+ }
1383
+ catch (createErr) {
1384
+ // Clean up orphaned tmux session if node-pty creation failed
1385
+ if (activeTmuxSession) {
1386
+ killTmuxSession(activeTmuxSession);
1387
+ log('WARN', `Cleaned up orphaned tmux session "${activeTmuxSession}" after node-pty failure`);
1388
+ }
1389
+ throw createErr;
1390
+ }
1391
+ // Optional project association: register terminal with project state
1392
+ const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
1393
+ const termType = typeof body.type === 'string' && ['builder', 'shell'].includes(body.type) ? body.type : null;
1394
+ const roleId = typeof body.roleId === 'string' ? body.roleId : null;
1395
+ if (projectPath && termType && roleId) {
1396
+ const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
1397
+ if (termType === 'builder') {
1398
+ entry.builders.set(roleId, info.id);
1399
+ }
1400
+ else {
1401
+ entry.shells.set(roleId, info.id);
1402
+ }
1403
+ saveTerminalSession(info.id, projectPath, termType, roleId, info.pid, activeTmuxSession);
1404
+ log('INFO', `Registered terminal ${info.id} as ${termType} "${roleId}" for project ${projectPath}${activeTmuxSession ? ` (tmux: ${activeTmuxSession})` : ''}`);
1405
+ }
1406
+ // Return tmuxSession so caller knows whether tmux is backing this terminal
1177
1407
  res.writeHead(201, { 'Content-Type': 'application/json' });
1178
- res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}` }));
1408
+ res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}`, tmuxSession: activeTmuxSession }));
1179
1409
  }
1180
1410
  catch (err) {
1181
1411
  const message = err instanceof Error ? err.message : 'Unknown error';
@@ -1494,6 +1724,8 @@ const server = http.createServer(async (req, res) => {
1494
1724
  if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
1495
1725
  throw new Error('Invalid project path');
1496
1726
  }
1727
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
1728
+ projectPath = normalizeProjectPath(projectPath);
1497
1729
  }
1498
1730
  catch {
1499
1731
  res.writeHead(400, { 'Content-Type': 'text/plain' });
@@ -1580,6 +1812,15 @@ const server = http.createServer(async (req, res) => {
1580
1812
  terminalId,
1581
1813
  });
1582
1814
  }
1815
+ // Add file tabs (Spec 0092 - served through Tower, no separate ports)
1816
+ for (const [tabId, tab] of entry.fileTabs) {
1817
+ state.annotations.push({
1818
+ id: tabId,
1819
+ file: tab.path,
1820
+ port: 0, // No separate port - served through Tower
1821
+ pid: 0, // No separate process
1822
+ });
1823
+ }
1583
1824
  res.writeHead(200, { 'Content-Type': 'application/json' });
1584
1825
  res.end(JSON.stringify(state));
1585
1826
  return;
@@ -1589,10 +1830,23 @@ const server = http.createServer(async (req, res) => {
1589
1830
  try {
1590
1831
  const manager = getTerminalManager();
1591
1832
  const shellId = getNextShellId(projectPath);
1833
+ // Wrap in tmux for session persistence
1834
+ let shellCmd = process.env.SHELL || '/bin/bash';
1835
+ let shellArgs = [];
1836
+ const tmuxName = `shell-${path.basename(projectPath)}-${shellId}`;
1837
+ let activeTmuxSession = null;
1838
+ if (tmuxAvailable) {
1839
+ const tmuxCreated = createTmuxSession(tmuxName, shellCmd, shellArgs, projectPath, 200, 50);
1840
+ if (tmuxCreated) {
1841
+ shellCmd = 'tmux';
1842
+ shellArgs = ['attach-session', '-t', tmuxName];
1843
+ activeTmuxSession = tmuxName;
1844
+ }
1845
+ }
1592
1846
  // Create terminal session
1593
1847
  const session = await manager.createSession({
1594
- command: process.env.SHELL || '/bin/bash',
1595
- args: [],
1848
+ command: shellCmd,
1849
+ args: shellArgs,
1596
1850
  cwd: projectPath,
1597
1851
  label: `Shell ${shellId.replace('shell-', '')}`,
1598
1852
  env: process.env,
@@ -1601,7 +1855,7 @@ const server = http.createServer(async (req, res) => {
1601
1855
  const entry = getProjectTerminalsEntry(projectPath);
1602
1856
  entry.shells.set(shellId, session.id);
1603
1857
  // TICK-001: Save to SQLite for persistence
1604
- saveTerminalSession(session.id, projectPath, 'shell', shellId, session.pid, null);
1858
+ saveTerminalSession(session.id, projectPath, 'shell', shellId, session.pid, activeTmuxSession);
1605
1859
  res.writeHead(200, { 'Content-Type': 'application/json' });
1606
1860
  res.end(JSON.stringify({
1607
1861
  id: shellId,
@@ -1617,12 +1871,193 @@ const server = http.createServer(async (req, res) => {
1617
1871
  }
1618
1872
  return;
1619
1873
  }
1620
- // DELETE /api/tabs/:id - Delete a terminal tab
1874
+ // POST /api/tabs/file - Create a file tab (Spec 0092)
1875
+ if (req.method === 'POST' && apiPath === 'tabs/file') {
1876
+ try {
1877
+ const body = await new Promise((resolve) => {
1878
+ let data = '';
1879
+ req.on('data', (chunk) => data += chunk.toString());
1880
+ req.on('end', () => resolve(data));
1881
+ });
1882
+ const { path: filePath, line } = JSON.parse(body || '{}');
1883
+ if (!filePath || typeof filePath !== 'string') {
1884
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1885
+ res.end(JSON.stringify({ error: 'Missing path parameter' }));
1886
+ return;
1887
+ }
1888
+ // Resolve path relative to project
1889
+ const fullPath = path.isAbsolute(filePath)
1890
+ ? filePath
1891
+ : path.join(projectPath, filePath);
1892
+ // Security: ensure path is within project or is absolute path user provided
1893
+ const normalizedFull = path.normalize(fullPath);
1894
+ const normalizedProject = path.normalize(projectPath);
1895
+ if (!normalizedFull.startsWith(normalizedProject) && !path.isAbsolute(filePath)) {
1896
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1897
+ res.end(JSON.stringify({ error: 'Path outside project' }));
1898
+ return;
1899
+ }
1900
+ // Check file exists
1901
+ if (!fs.existsSync(fullPath)) {
1902
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1903
+ res.end(JSON.stringify({ error: 'File not found' }));
1904
+ return;
1905
+ }
1906
+ const entry = getProjectTerminalsEntry(projectPath);
1907
+ // Check if already open
1908
+ for (const [id, tab] of entry.fileTabs) {
1909
+ if (tab.path === fullPath) {
1910
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1911
+ res.end(JSON.stringify({ id, existing: true, line }));
1912
+ return;
1913
+ }
1914
+ }
1915
+ // Create new file tab
1916
+ const id = `file-${Date.now().toString(36)}`;
1917
+ entry.fileTabs.set(id, { id, path: fullPath, createdAt: Date.now() });
1918
+ log('INFO', `Created file tab: ${id} for ${path.basename(fullPath)}`);
1919
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1920
+ res.end(JSON.stringify({ id, existing: false, line }));
1921
+ }
1922
+ catch (err) {
1923
+ log('ERROR', `Failed to create file tab: ${err.message}`);
1924
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1925
+ res.end(JSON.stringify({ error: err.message }));
1926
+ }
1927
+ return;
1928
+ }
1929
+ // GET /api/file/:id - Get file content as JSON (Spec 0092)
1930
+ const fileGetMatch = apiPath.match(/^file\/([^/]+)$/);
1931
+ if (req.method === 'GET' && fileGetMatch) {
1932
+ const tabId = fileGetMatch[1];
1933
+ const entry = getProjectTerminalsEntry(projectPath);
1934
+ const tab = entry.fileTabs.get(tabId);
1935
+ if (!tab) {
1936
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1937
+ res.end(JSON.stringify({ error: 'File tab not found' }));
1938
+ return;
1939
+ }
1940
+ try {
1941
+ const ext = path.extname(tab.path).slice(1).toLowerCase();
1942
+ const isText = !['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'webm', 'mov', 'pdf'].includes(ext);
1943
+ if (isText) {
1944
+ const content = fs.readFileSync(tab.path, 'utf-8');
1945
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1946
+ res.end(JSON.stringify({
1947
+ path: tab.path,
1948
+ name: path.basename(tab.path),
1949
+ content,
1950
+ language: getLanguageForExt(ext),
1951
+ isMarkdown: ext === 'md',
1952
+ isImage: false,
1953
+ isVideo: false,
1954
+ }));
1955
+ }
1956
+ else {
1957
+ // For binary files, just return metadata
1958
+ const stat = fs.statSync(tab.path);
1959
+ const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
1960
+ const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
1961
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1962
+ res.end(JSON.stringify({
1963
+ path: tab.path,
1964
+ name: path.basename(tab.path),
1965
+ content: null,
1966
+ language: ext,
1967
+ isMarkdown: false,
1968
+ isImage,
1969
+ isVideo,
1970
+ size: stat.size,
1971
+ }));
1972
+ }
1973
+ }
1974
+ catch (err) {
1975
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1976
+ res.end(JSON.stringify({ error: err.message }));
1977
+ }
1978
+ return;
1979
+ }
1980
+ // GET /api/file/:id/raw - Get raw file content (for images/video) (Spec 0092)
1981
+ const fileRawMatch = apiPath.match(/^file\/([^/]+)\/raw$/);
1982
+ if (req.method === 'GET' && fileRawMatch) {
1983
+ const tabId = fileRawMatch[1];
1984
+ const entry = getProjectTerminalsEntry(projectPath);
1985
+ const tab = entry.fileTabs.get(tabId);
1986
+ if (!tab) {
1987
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1988
+ res.end('File tab not found');
1989
+ return;
1990
+ }
1991
+ try {
1992
+ const data = fs.readFileSync(tab.path);
1993
+ const mimeType = getMimeTypeForFile(tab.path);
1994
+ res.writeHead(200, {
1995
+ 'Content-Type': mimeType,
1996
+ 'Content-Length': data.length,
1997
+ 'Cache-Control': 'no-cache',
1998
+ });
1999
+ res.end(data);
2000
+ }
2001
+ catch (err) {
2002
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
2003
+ res.end(err.message);
2004
+ }
2005
+ return;
2006
+ }
2007
+ // POST /api/file/:id/save - Save file content (Spec 0092)
2008
+ const fileSaveMatch = apiPath.match(/^file\/([^/]+)\/save$/);
2009
+ if (req.method === 'POST' && fileSaveMatch) {
2010
+ const tabId = fileSaveMatch[1];
2011
+ const entry = getProjectTerminalsEntry(projectPath);
2012
+ const tab = entry.fileTabs.get(tabId);
2013
+ if (!tab) {
2014
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2015
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2016
+ return;
2017
+ }
2018
+ try {
2019
+ const body = await new Promise((resolve) => {
2020
+ let data = '';
2021
+ req.on('data', (chunk) => data += chunk.toString());
2022
+ req.on('end', () => resolve(data));
2023
+ });
2024
+ const { content } = JSON.parse(body || '{}');
2025
+ if (typeof content !== 'string') {
2026
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2027
+ res.end(JSON.stringify({ error: 'Missing content parameter' }));
2028
+ return;
2029
+ }
2030
+ fs.writeFileSync(tab.path, content, 'utf-8');
2031
+ log('INFO', `Saved file: ${tab.path}`);
2032
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2033
+ res.end(JSON.stringify({ success: true }));
2034
+ }
2035
+ catch (err) {
2036
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2037
+ res.end(JSON.stringify({ error: err.message }));
2038
+ }
2039
+ return;
2040
+ }
2041
+ // DELETE /api/tabs/:id - Delete a terminal or file tab
1621
2042
  const deleteMatch = apiPath.match(/^tabs\/(.+)$/);
1622
2043
  if (req.method === 'DELETE' && deleteMatch) {
1623
2044
  const tabId = deleteMatch[1];
1624
2045
  const entry = getProjectTerminalsEntry(projectPath);
1625
2046
  const manager = getTerminalManager();
2047
+ // Check if it's a file tab first (Spec 0092)
2048
+ if (tabId.startsWith('file-')) {
2049
+ if (entry.fileTabs.has(tabId)) {
2050
+ entry.fileTabs.delete(tabId);
2051
+ log('INFO', `Deleted file tab: ${tabId}`);
2052
+ res.writeHead(204);
2053
+ res.end();
2054
+ }
2055
+ else {
2056
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2057
+ res.end(JSON.stringify({ error: 'File tab not found' }));
2058
+ }
2059
+ return;
2060
+ }
1626
2061
  // Find and delete the terminal
1627
2062
  let terminalId;
1628
2063
  if (tabId.startsWith('shell-')) {
@@ -1678,6 +2113,65 @@ const server = http.createServer(async (req, res) => {
1678
2113
  res.end(JSON.stringify({ ok: true }));
1679
2114
  return;
1680
2115
  }
2116
+ // GET /api/git/status - Return git status for file browser (Spec 0092)
2117
+ if (req.method === 'GET' && apiPath === 'git/status') {
2118
+ try {
2119
+ // Get git status in porcelain format for parsing
2120
+ const result = execSync('git status --porcelain', {
2121
+ cwd: projectPath,
2122
+ encoding: 'utf-8',
2123
+ timeout: 5000,
2124
+ });
2125
+ // Parse porcelain output: XY filename
2126
+ // X = staging area status, Y = working tree status
2127
+ const modified = [];
2128
+ const staged = [];
2129
+ const untracked = [];
2130
+ for (const line of result.split('\n')) {
2131
+ if (!line)
2132
+ continue;
2133
+ const x = line[0]; // staging area
2134
+ const y = line[1]; // working tree
2135
+ const filepath = line.slice(3);
2136
+ if (x === '?' && y === '?') {
2137
+ untracked.push(filepath);
2138
+ }
2139
+ else {
2140
+ if (x !== ' ' && x !== '?') {
2141
+ staged.push(filepath);
2142
+ }
2143
+ if (y !== ' ' && y !== '?') {
2144
+ modified.push(filepath);
2145
+ }
2146
+ }
2147
+ }
2148
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2149
+ res.end(JSON.stringify({ modified, staged, untracked }));
2150
+ }
2151
+ catch (err) {
2152
+ // Not a git repo or git command failed
2153
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2154
+ res.end(JSON.stringify({ modified: [], staged: [], untracked: [], error: err.message }));
2155
+ }
2156
+ return;
2157
+ }
2158
+ // GET /api/files/recent - Return recently opened file tabs (Spec 0092)
2159
+ if (req.method === 'GET' && apiPath === 'files/recent') {
2160
+ const entry = getProjectTerminalsEntry(projectPath);
2161
+ // Get all file tabs sorted by creation time (most recent first)
2162
+ const recentFiles = Array.from(entry.fileTabs.values())
2163
+ .sort((a, b) => b.createdAt - a.createdAt)
2164
+ .slice(0, 10) // Limit to 10 most recent
2165
+ .map(tab => ({
2166
+ id: tab.id,
2167
+ path: tab.path,
2168
+ name: path.basename(tab.path),
2169
+ relativePath: path.relative(projectPath, tab.path),
2170
+ }));
2171
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2172
+ res.end(JSON.stringify(recentFiles));
2173
+ return;
2174
+ }
1681
2175
  // Unhandled API route
1682
2176
  res.writeHead(404, { 'Content-Type': 'application/json' });
1683
2177
  res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));
@@ -1714,6 +2208,9 @@ const server = http.createServer(async (req, res) => {
1714
2208
  // SECURITY: Bind to localhost only to prevent network exposure
1715
2209
  server.listen(port, '127.0.0.1', async () => {
1716
2210
  log('INFO', `Tower server listening at http://localhost:${port}`);
2211
+ // Check tmux availability once at startup
2212
+ tmuxAvailable = checkTmux();
2213
+ log('INFO', `tmux available: ${tmuxAvailable}${tmuxAvailable ? '' : ' (terminals will not persist across restarts)'}`);
1717
2214
  // TICK-001: Reconcile terminal sessions from previous run
1718
2215
  await reconcileTerminalSessions();
1719
2216
  });
@@ -1762,6 +2259,8 @@ server.on('upgrade', async (req, socket, head) => {
1762
2259
  if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
1763
2260
  throw new Error('Invalid project path');
1764
2261
  }
2262
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
2263
+ projectPath = normalizeProjectPath(projectPath);
1765
2264
  }
1766
2265
  catch {
1767
2266
  socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');