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