@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.
- package/dashboard/dist/assets/{index-BIHeqvy0.css → index-BV7KQvFU.css} +1 -1
- package/dashboard/dist/assets/index-bhDjF0Oa.js +131 -0
- package/dashboard/dist/assets/index-bhDjF0Oa.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/send.d.ts.map +1 -1
- package/dist/agent-farm/commands/send.js +55 -17
- package/dist/agent-farm/commands/send.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 +763 -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 +61 -0
- package/skeleton/protocols/bugfix/protocol.json +5 -0
- package/skeleton/protocols/spider/prompts/plan.md +4 -68
- package/skeleton/protocols/spider/prompts/review.md +5 -23
- package/skeleton/protocols/spider/prompts/specify.md +4 -57
- 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,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
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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
|
-
//
|
|
239
|
-
if (session.tmux_session && tmuxSessionExists(session.tmux_session)) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
if (
|
|
627
|
-
const session = manager.getSession(
|
|
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: '
|
|
631
|
-
id:
|
|
632
|
-
label:
|
|
633
|
-
url: `${proxyUrl}?tab=
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
868
|
-
|
|
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,
|
|
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
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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:
|
|
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,
|
|
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
|
-
//
|
|
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');
|