@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.
- package/dashboard/dist/assets/{index-BIHeqvy0.css → index-BV7KQvFU.css} +1 -1
- package/dashboard/dist/assets/index-xOaDIZ0l.js +132 -0
- package/dashboard/dist/assets/index-xOaDIZ0l.js.map +1 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/agent-farm/commands/open.d.ts +4 -2
- package/dist/agent-farm/commands/open.d.ts.map +1 -1
- package/dist/agent-farm/commands/open.js +37 -70
- package/dist/agent-farm/commands/open.js.map +1 -1
- package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
- package/dist/agent-farm/commands/spawn.js +31 -20
- package/dist/agent-farm/commands/spawn.js.map +1 -1
- package/dist/agent-farm/commands/stop.js +1 -1
- package/dist/agent-farm/commands/stop.js.map +1 -1
- package/dist/agent-farm/servers/tower-server.js +561 -62
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/types.d.ts +0 -1
- package/dist/agent-farm/types.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.js +1 -3
- package/dist/agent-farm/utils/config.js.map +1 -1
- package/dist/agent-farm/utils/port-registry.d.ts +0 -1
- package/dist/agent-farm/utils/port-registry.d.ts.map +1 -1
- package/dist/agent-farm/utils/port-registry.js +1 -1
- package/dist/agent-farm/utils/port-registry.js.map +1 -1
- package/package.json +1 -1
- package/skeleton/protocols/bugfix/prompts/fix.md +77 -0
- package/skeleton/protocols/bugfix/prompts/investigate.md +77 -0
- package/skeleton/protocols/bugfix/prompts/pr.md +76 -0
- package/skeleton/protocols/bugfix/protocol.json +5 -0
- package/dashboard/dist/assets/index-VvUWRPNP.js +0 -120
- package/dashboard/dist/assets/index-VvUWRPNP.js.map +0 -1
- package/dist/agent-farm/servers/open-server.d.ts +0 -7
- package/dist/agent-farm/servers/open-server.d.ts.map +0 -1
- package/dist/agent-farm/servers/open-server.js +0 -315
- 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
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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
|
-
//
|
|
239
|
-
if (session.tmux_session && tmuxSessionExists(session.tmux_session)) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
if (
|
|
627
|
-
const session = manager.getSession(
|
|
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: '
|
|
631
|
-
id:
|
|
632
|
-
label:
|
|
633
|
-
url: `${proxyUrl}?tab=
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
868
|
-
|
|
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,
|
|
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
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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:
|
|
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,
|
|
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
|
-
//
|
|
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');
|