@cluesmith/codev 2.0.0-rc.72 → 2.0.0-rc.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dashboard/dist/assets/{index-C7FtNK6Y.css → index-4n9zpWLY.css} +1 -1
  2. package/dashboard/dist/assets/{index-CDAINZKT.js → index-b38SaXk5.js} +33 -28
  3. package/dashboard/dist/assets/index-b38SaXk5.js.map +1 -0
  4. package/dashboard/dist/index.html +2 -2
  5. package/dist/agent-farm/commands/spawn-roles.d.ts +80 -0
  6. package/dist/agent-farm/commands/spawn-roles.d.ts.map +1 -0
  7. package/dist/agent-farm/commands/spawn-roles.js +278 -0
  8. package/dist/agent-farm/commands/spawn-roles.js.map +1 -0
  9. package/dist/agent-farm/commands/spawn-worktree.d.ts +96 -0
  10. package/dist/agent-farm/commands/spawn-worktree.d.ts.map +1 -0
  11. package/dist/agent-farm/commands/spawn-worktree.js +305 -0
  12. package/dist/agent-farm/commands/spawn-worktree.js.map +1 -0
  13. package/dist/agent-farm/commands/spawn.d.ts +5 -1
  14. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  15. package/dist/agent-farm/commands/spawn.js +65 -725
  16. package/dist/agent-farm/commands/spawn.js.map +1 -1
  17. package/dist/agent-farm/db/index.d.ts.map +1 -1
  18. package/dist/agent-farm/db/index.js +56 -2
  19. package/dist/agent-farm/db/index.js.map +1 -1
  20. package/dist/agent-farm/db/schema.d.ts +1 -1
  21. package/dist/agent-farm/db/schema.js +3 -3
  22. package/dist/agent-farm/servers/tower-instances.d.ts +82 -0
  23. package/dist/agent-farm/servers/tower-instances.d.ts.map +1 -0
  24. package/dist/agent-farm/servers/tower-instances.js +454 -0
  25. package/dist/agent-farm/servers/tower-instances.js.map +1 -0
  26. package/dist/agent-farm/servers/tower-routes.d.ts +34 -0
  27. package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -0
  28. package/dist/agent-farm/servers/tower-routes.js +1445 -0
  29. package/dist/agent-farm/servers/tower-routes.js.map +1 -0
  30. package/dist/agent-farm/servers/tower-server.d.ts +5 -2
  31. package/dist/agent-farm/servers/tower-server.d.ts.map +1 -1
  32. package/dist/agent-farm/servers/tower-server.js +89 -2875
  33. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  34. package/dist/agent-farm/servers/tower-terminals.d.ts +119 -0
  35. package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -0
  36. package/dist/agent-farm/servers/tower-terminals.js +629 -0
  37. package/dist/agent-farm/servers/tower-terminals.js.map +1 -0
  38. package/dist/agent-farm/servers/tower-tunnel.d.ts +34 -0
  39. package/dist/agent-farm/servers/tower-tunnel.d.ts.map +1 -0
  40. package/dist/agent-farm/servers/tower-tunnel.js +299 -0
  41. package/dist/agent-farm/servers/tower-tunnel.js.map +1 -0
  42. package/dist/agent-farm/servers/tower-types.d.ts +86 -0
  43. package/dist/agent-farm/servers/tower-types.d.ts.map +1 -0
  44. package/dist/agent-farm/servers/tower-types.js +6 -0
  45. package/dist/agent-farm/servers/tower-types.js.map +1 -0
  46. package/dist/agent-farm/servers/tower-utils.d.ts +58 -0
  47. package/dist/agent-farm/servers/tower-utils.d.ts.map +1 -0
  48. package/dist/agent-farm/servers/tower-utils.js +182 -0
  49. package/dist/agent-farm/servers/tower-utils.js.map +1 -0
  50. package/dist/agent-farm/servers/tower-websocket.d.ts +25 -0
  51. package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -0
  52. package/dist/agent-farm/servers/tower-websocket.js +171 -0
  53. package/dist/agent-farm/servers/tower-websocket.js.map +1 -0
  54. package/dist/agent-farm/utils/shell.d.ts +1 -1
  55. package/dist/agent-farm/utils/shell.js +1 -1
  56. package/dist/commands/porch/next.js +4 -4
  57. package/dist/commands/porch/next.js.map +1 -1
  58. package/dist/terminal/pty-manager.d.ts +1 -1
  59. package/dist/terminal/pty-manager.js +5 -5
  60. package/dist/terminal/pty-session.d.ts +20 -20
  61. package/dist/terminal/pty-session.js +55 -55
  62. package/dist/terminal/session-manager.d.ts +15 -15
  63. package/dist/terminal/session-manager.js +34 -34
  64. package/dist/terminal/{shepherd-client.d.ts → shellper-client.d.ts} +10 -10
  65. package/dist/terminal/{shepherd-client.d.ts.map → shellper-client.d.ts.map} +1 -1
  66. package/dist/terminal/{shepherd-client.js → shellper-client.js} +20 -20
  67. package/dist/terminal/{shepherd-client.js.map → shellper-client.js.map} +1 -1
  68. package/dist/terminal/{shepherd-main.d.ts → shellper-main.d.ts} +3 -3
  69. package/dist/terminal/shellper-main.d.ts.map +1 -0
  70. package/dist/terminal/{shepherd-main.js → shellper-main.js} +17 -17
  71. package/dist/terminal/{shepherd-main.js.map → shellper-main.js.map} +1 -1
  72. package/dist/terminal/{shepherd-process.d.ts → shellper-process.d.ts} +8 -8
  73. package/dist/terminal/{shepherd-process.d.ts.map → shellper-process.d.ts.map} +1 -1
  74. package/dist/terminal/{shepherd-process.js → shellper-process.js} +11 -11
  75. package/dist/terminal/{shepherd-process.js.map → shellper-process.js.map} +1 -1
  76. package/dist/terminal/{shepherd-protocol.d.ts → shellper-protocol.d.ts} +5 -5
  77. package/dist/terminal/{shepherd-protocol.d.ts.map → shellper-protocol.d.ts.map} +1 -1
  78. package/dist/terminal/{shepherd-protocol.js → shellper-protocol.js} +5 -5
  79. package/dist/terminal/{shepherd-protocol.js.map → shellper-protocol.js.map} +1 -1
  80. package/dist/terminal/{shepherd-replay-buffer.d.ts → shellper-replay-buffer.d.ts} +4 -4
  81. package/dist/terminal/{shepherd-replay-buffer.d.ts.map → shellper-replay-buffer.d.ts.map} +1 -1
  82. package/dist/terminal/{shepherd-replay-buffer.js → shellper-replay-buffer.js} +4 -4
  83. package/dist/terminal/{shepherd-replay-buffer.js.map → shellper-replay-buffer.js.map} +1 -1
  84. package/package.json +1 -1
  85. package/skeleton/protocols/bugfix/builder-prompt.md +7 -1
  86. package/skeleton/protocols/maintain/protocol.md +3 -3
  87. package/skeleton/protocols/spir/builder-prompt.md +7 -0
  88. package/skeleton/resources/commands/agent-farm.md +2 -2
  89. package/skeleton/roles/builder.md +15 -1
  90. package/dashboard/dist/assets/index-CDAINZKT.js.map +0 -1
  91. package/dist/terminal/shepherd-main.d.ts.map +0 -1
@@ -0,0 +1,1445 @@
1
+ /**
2
+ * HTTP route handlers for tower server.
3
+ * Spec 0105: Tower Server Decomposition — Phase 6
4
+ *
5
+ * Contains all HTTP request routing and response logic.
6
+ * The orchestrator (tower-server.ts) creates the HTTP server and
7
+ * delegates to handleRequest() for all HTTP requests.
8
+ *
9
+ * NOTE: This file exceeds the 900-line guideline because it contains
10
+ * all HTTP route handlers (~30 routes) which share a single responsibility
11
+ * (HTTP request handling). Splitting would create arbitrary boundaries
12
+ * without improving cohesion. See spec: "cohesion trumps arbitrary ceilings."
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import crypto from 'node:crypto';
17
+ import { execSync } from 'node:child_process';
18
+ import { homedir, tmpdir } from 'node:os';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js';
21
+ import { isRateLimited, normalizeProjectPath, getLanguageForExt, getMimeTypeForFile, serveStaticFile, } from './tower-utils.js';
22
+ import { handleTunnelEndpoint } from './tower-tunnel.js';
23
+ import { getKnownProjectPaths, getInstances, getDirectorySuggestions, launchInstance, killTerminalWithShellper, stopInstance, } from './tower-instances.js';
24
+ import { getProjectTerminals, getTerminalManager, getProjectTerminalsEntry, getNextShellId, saveTerminalSession, isSessionPersistent, deleteTerminalSession, deleteProjectTerminalSessions, saveFileTab, deleteFileTab, getTerminalsForProject, } from './tower-terminals.js';
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = path.dirname(__filename);
27
+ // ============================================================================
28
+ // Helper: read raw request body
29
+ // ============================================================================
30
+ async function readBody(req) {
31
+ return new Promise((resolve) => {
32
+ let data = '';
33
+ req.on('data', (chunk) => data += chunk.toString());
34
+ req.on('end', () => resolve(data));
35
+ });
36
+ }
37
+ const ROUTES = {
38
+ 'GET /health': (_req, res) => handleHealthCheck(res),
39
+ 'GET /api/projects': (_req, res) => handleListProjects(res),
40
+ 'POST /api/terminals': (req, res, _url, ctx) => handleTerminalCreate(req, res, ctx),
41
+ 'GET /api/terminals': (_req, res) => handleTerminalList(res),
42
+ 'GET /api/status': (_req, res) => handleStatus(res),
43
+ 'GET /api/events': (req, res, _url, ctx) => handleSSEEvents(req, res, ctx),
44
+ 'POST /api/notify': (req, res, _url, ctx) => handleNotify(req, res, ctx),
45
+ 'GET /api/browse': (_req, res, url) => handleBrowse(res, url),
46
+ 'POST /api/create': (req, res, _url, ctx) => handleCreateProject(req, res, ctx),
47
+ 'POST /api/launch': (req, res) => handleLaunchInstance(req, res),
48
+ 'POST /api/stop': (req, res) => handleStopInstance(req, res),
49
+ 'GET /': (_req, res, _url, ctx) => handleDashboard(res, ctx),
50
+ 'GET /index.html': (_req, res, _url, ctx) => handleDashboard(res, ctx),
51
+ };
52
+ // ============================================================================
53
+ // Main request handler
54
+ // ============================================================================
55
+ export async function handleRequest(req, res, ctx) {
56
+ // Security: Validate Host and Origin headers
57
+ if (!isRequestAllowed(req)) {
58
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
59
+ res.end('Forbidden');
60
+ return;
61
+ }
62
+ // CORS headers — allow localhost and tunnel proxy origins
63
+ const origin = req.headers.origin;
64
+ if (origin && (origin.startsWith('http://localhost:') ||
65
+ origin.startsWith('http://127.0.0.1:') ||
66
+ origin.startsWith('https://'))) {
67
+ res.setHeader('Access-Control-Allow-Origin', origin);
68
+ }
69
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
70
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
71
+ res.setHeader('Cache-Control', 'no-store');
72
+ if (req.method === 'OPTIONS') {
73
+ res.writeHead(200);
74
+ res.end();
75
+ return;
76
+ }
77
+ const url = new URL(req.url || '/', `http://localhost:${ctx.port}`);
78
+ try {
79
+ // Exact-match route dispatch (O(1) lookup)
80
+ const routeKey = `${req.method} ${url.pathname}`;
81
+ const handler = ROUTES[routeKey];
82
+ if (handler) {
83
+ return await handler(req, res, url, ctx);
84
+ }
85
+ // Pattern-based routes (require regex or prefix matching)
86
+ // Tunnel endpoints: /api/tunnel/* (Spec 0097 Phase 4)
87
+ if (url.pathname.startsWith('/api/tunnel/')) {
88
+ const tunnelSub = url.pathname.slice('/api/tunnel/'.length);
89
+ await handleTunnelEndpoint(req, res, tunnelSub);
90
+ return;
91
+ }
92
+ // Project API: /api/projects/:encodedPath/activate|deactivate|status (Spec 0090 Phase 1)
93
+ const projectApiMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/(activate|deactivate|status)$/);
94
+ if (projectApiMatch) {
95
+ return await handleProjectAction(req, res, ctx, projectApiMatch);
96
+ }
97
+ // Terminal-specific routes: /api/terminals/:id/* (Spec 0090 Phase 2)
98
+ const terminalRouteMatch = url.pathname.match(/^\/api\/terminals\/([^/]+)(\/.*)?$/);
99
+ if (terminalRouteMatch) {
100
+ return await handleTerminalRoutes(req, res, url, terminalRouteMatch);
101
+ }
102
+ // Project routes: /project/:base64urlPath/* (Spec 0090 Phase 4)
103
+ if (url.pathname.startsWith('/project/')) {
104
+ return await handleProjectRoutes(req, res, ctx, url);
105
+ }
106
+ // 404 for everything else
107
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
108
+ res.end('Not found');
109
+ }
110
+ catch (err) {
111
+ ctx.log('ERROR', `Request error: ${err.message}`);
112
+ res.writeHead(500, { 'Content-Type': 'application/json' });
113
+ res.end(JSON.stringify({ error: err.message }));
114
+ }
115
+ }
116
+ // ============================================================================
117
+ // Global route handlers
118
+ // ============================================================================
119
+ async function handleHealthCheck(res) {
120
+ const instances = await getInstances();
121
+ const activeCount = instances.filter((i) => i.running).length;
122
+ res.writeHead(200, { 'Content-Type': 'application/json' });
123
+ res.end(JSON.stringify({
124
+ status: 'healthy',
125
+ uptime: process.uptime(),
126
+ activeProjects: activeCount,
127
+ totalProjects: instances.length,
128
+ memoryUsage: process.memoryUsage().heapUsed,
129
+ timestamp: new Date().toISOString(),
130
+ }));
131
+ }
132
+ async function handleListProjects(res) {
133
+ const instances = await getInstances();
134
+ const projects = instances.map((i) => ({
135
+ path: i.projectPath,
136
+ name: i.projectName,
137
+ active: i.running,
138
+ proxyUrl: i.proxyUrl,
139
+ terminals: i.terminals.length,
140
+ }));
141
+ res.writeHead(200, { 'Content-Type': 'application/json' });
142
+ res.end(JSON.stringify({ projects }));
143
+ }
144
+ async function handleProjectAction(req, res, ctx, match) {
145
+ const [, encodedPath, action] = match;
146
+ let projectPath;
147
+ try {
148
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
149
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
150
+ throw new Error('Invalid path');
151
+ }
152
+ projectPath = normalizeProjectPath(projectPath);
153
+ }
154
+ catch {
155
+ res.writeHead(400, { 'Content-Type': 'application/json' });
156
+ res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
157
+ return;
158
+ }
159
+ // GET /api/projects/:path/status
160
+ if (req.method === 'GET' && action === 'status') {
161
+ const instances = await getInstances();
162
+ const instance = instances.find((i) => i.projectPath === projectPath);
163
+ if (!instance) {
164
+ res.writeHead(404, { 'Content-Type': 'application/json' });
165
+ res.end(JSON.stringify({ error: 'Project not found' }));
166
+ return;
167
+ }
168
+ res.writeHead(200, { 'Content-Type': 'application/json' });
169
+ res.end(JSON.stringify({
170
+ path: instance.projectPath,
171
+ name: instance.projectName,
172
+ active: instance.running,
173
+ terminals: instance.terminals,
174
+ gateStatus: instance.gateStatus,
175
+ }));
176
+ return;
177
+ }
178
+ // POST /api/projects/:path/activate
179
+ if (req.method === 'POST' && action === 'activate') {
180
+ // Rate limiting: 10 activations per minute per client
181
+ const clientIp = req.socket.remoteAddress || '127.0.0.1';
182
+ if (isRateLimited(clientIp)) {
183
+ res.writeHead(429, { 'Content-Type': 'application/json' });
184
+ res.end(JSON.stringify({ error: 'Too many activations, try again later' }));
185
+ return;
186
+ }
187
+ const result = await launchInstance(projectPath);
188
+ if (result.success) {
189
+ res.writeHead(200, { 'Content-Type': 'application/json' });
190
+ res.end(JSON.stringify({ success: true, adopted: result.adopted }));
191
+ }
192
+ else {
193
+ res.writeHead(400, { 'Content-Type': 'application/json' });
194
+ res.end(JSON.stringify({ success: false, error: result.error }));
195
+ }
196
+ return;
197
+ }
198
+ // POST /api/projects/:path/deactivate
199
+ if (req.method === 'POST' && action === 'deactivate') {
200
+ const knownPaths = getKnownProjectPaths();
201
+ const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
202
+ const isKnown = knownPaths.some((p) => p === projectPath || p === resolvedPath);
203
+ if (!isKnown) {
204
+ res.writeHead(404, { 'Content-Type': 'application/json' });
205
+ res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
206
+ return;
207
+ }
208
+ const result = await stopInstance(projectPath);
209
+ res.writeHead(200, { 'Content-Type': 'application/json' });
210
+ res.end(JSON.stringify(result));
211
+ return;
212
+ }
213
+ }
214
+ async function handleTerminalCreate(req, res, ctx) {
215
+ try {
216
+ const body = await parseJsonBody(req);
217
+ const manager = getTerminalManager();
218
+ // Parse request fields
219
+ const command = typeof body.command === 'string' ? body.command : undefined;
220
+ const args = Array.isArray(body.args) ? body.args : undefined;
221
+ const cols = typeof body.cols === 'number' ? body.cols : undefined;
222
+ const rows = typeof body.rows === 'number' ? body.rows : undefined;
223
+ const cwd = typeof body.cwd === 'string' ? body.cwd : undefined;
224
+ const env = typeof body.env === 'object' && body.env !== null ? body.env : undefined;
225
+ const label = typeof body.label === 'string' ? body.label : undefined;
226
+ // Optional session persistence via shellper
227
+ const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
228
+ const termType = typeof body.type === 'string' && ['builder', 'shell'].includes(body.type) ? body.type : null;
229
+ const roleId = typeof body.roleId === 'string' ? body.roleId : null;
230
+ const requestPersistence = body.persistent === true;
231
+ let info;
232
+ let persistent = false;
233
+ // Try shellper if persistence was requested
234
+ const shellperManager = ctx.getShellperManager();
235
+ if (requestPersistence && shellperManager && command && cwd) {
236
+ try {
237
+ const sessionId = crypto.randomUUID();
238
+ // Strip CLAUDECODE so spawned Claude processes don't detect nesting
239
+ const sessionEnv = { ...(env || process.env) };
240
+ delete sessionEnv['CLAUDECODE'];
241
+ const client = await shellperManager.createSession({
242
+ sessionId,
243
+ command,
244
+ args: args || [],
245
+ cwd,
246
+ env: sessionEnv,
247
+ cols: cols || 200,
248
+ rows: 50,
249
+ restartOnExit: false,
250
+ });
251
+ const replayData = client.getReplayData() ?? Buffer.alloc(0);
252
+ const shellperInfo = shellperManager.getSessionInfo(sessionId);
253
+ const session = manager.createSessionRaw({
254
+ label: label || `terminal-${sessionId.slice(0, 8)}`,
255
+ cwd,
256
+ });
257
+ const ptySession = manager.getSession(session.id);
258
+ if (ptySession) {
259
+ ptySession.attachShellper(client, replayData, shellperInfo.pid, sessionId);
260
+ }
261
+ info = session;
262
+ persistent = true;
263
+ if (projectPath && termType && roleId) {
264
+ const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
265
+ if (termType === 'builder') {
266
+ entry.builders.set(roleId, session.id);
267
+ }
268
+ else {
269
+ entry.shells.set(roleId, session.id);
270
+ }
271
+ saveTerminalSession(session.id, projectPath, termType, roleId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime);
272
+ ctx.log('INFO', `Registered shellper terminal ${session.id} as ${termType} "${roleId}" for project ${projectPath}`);
273
+ }
274
+ }
275
+ catch (shellperErr) {
276
+ ctx.log('WARN', `Shellper creation failed for terminal, falling back: ${shellperErr.message}`);
277
+ }
278
+ }
279
+ // Fallback: non-persistent session (graceful degradation per plan)
280
+ // Shellper is the only persistence backend for new sessions.
281
+ if (!info) {
282
+ info = await manager.createSession({ command, args, cols, rows, cwd, env, label });
283
+ persistent = false;
284
+ if (projectPath && termType && roleId) {
285
+ const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
286
+ if (termType === 'builder') {
287
+ entry.builders.set(roleId, info.id);
288
+ }
289
+ else {
290
+ entry.shells.set(roleId, info.id);
291
+ }
292
+ saveTerminalSession(info.id, projectPath, termType, roleId, info.pid);
293
+ ctx.log('WARN', `Terminal ${info.id} for ${projectPath} is non-persistent (shellper unavailable)`);
294
+ }
295
+ }
296
+ res.writeHead(201, { 'Content-Type': 'application/json' });
297
+ res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}`, persistent }));
298
+ }
299
+ catch (err) {
300
+ const message = err instanceof Error ? err.message : 'Unknown error';
301
+ ctx.log('ERROR', `Failed to create terminal: ${message}`);
302
+ res.writeHead(500, { 'Content-Type': 'application/json' });
303
+ res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message }));
304
+ }
305
+ }
306
+ function handleTerminalList(res) {
307
+ const manager = getTerminalManager();
308
+ const terminals = manager.listSessions();
309
+ res.writeHead(200, { 'Content-Type': 'application/json' });
310
+ res.end(JSON.stringify({ terminals }));
311
+ }
312
+ async function handleTerminalRoutes(req, res, url, match) {
313
+ const [, terminalId, subpath] = match;
314
+ const manager = getTerminalManager();
315
+ // GET /api/terminals/:id - Get terminal info
316
+ if (req.method === 'GET' && (!subpath || subpath === '')) {
317
+ const session = manager.getSession(terminalId);
318
+ if (!session) {
319
+ res.writeHead(404, { 'Content-Type': 'application/json' });
320
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
321
+ return;
322
+ }
323
+ res.writeHead(200, { 'Content-Type': 'application/json' });
324
+ res.end(JSON.stringify(session.info));
325
+ return;
326
+ }
327
+ // DELETE /api/terminals/:id - Kill terminal (disable shellper auto-restart if applicable)
328
+ if (req.method === 'DELETE' && (!subpath || subpath === '')) {
329
+ if (!(await killTerminalWithShellper(manager, terminalId))) {
330
+ res.writeHead(404, { 'Content-Type': 'application/json' });
331
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
332
+ return;
333
+ }
334
+ // TICK-001: Delete from SQLite
335
+ deleteTerminalSession(terminalId);
336
+ res.writeHead(204);
337
+ res.end();
338
+ return;
339
+ }
340
+ // POST /api/terminals/:id/write - Write data to terminal (Spec 0104)
341
+ if (req.method === 'POST' && subpath === '/write') {
342
+ try {
343
+ const body = await parseJsonBody(req);
344
+ if (typeof body.data !== 'string') {
345
+ res.writeHead(400, { 'Content-Type': 'application/json' });
346
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'data must be a string' }));
347
+ return;
348
+ }
349
+ const session = manager.getSession(terminalId);
350
+ if (!session) {
351
+ res.writeHead(404, { 'Content-Type': 'application/json' });
352
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
353
+ return;
354
+ }
355
+ session.write(body.data);
356
+ res.writeHead(200, { 'Content-Type': 'application/json' });
357
+ res.end(JSON.stringify({ ok: true }));
358
+ }
359
+ catch {
360
+ res.writeHead(400, { 'Content-Type': 'application/json' });
361
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
362
+ }
363
+ return;
364
+ }
365
+ // POST /api/terminals/:id/resize - Resize terminal
366
+ if (req.method === 'POST' && subpath === '/resize') {
367
+ try {
368
+ const body = await parseJsonBody(req);
369
+ if (typeof body.cols !== 'number' || typeof body.rows !== 'number') {
370
+ res.writeHead(400, { 'Content-Type': 'application/json' });
371
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'cols and rows must be numbers' }));
372
+ return;
373
+ }
374
+ const info = manager.resizeSession(terminalId, body.cols, body.rows);
375
+ if (!info) {
376
+ res.writeHead(404, { 'Content-Type': 'application/json' });
377
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
378
+ return;
379
+ }
380
+ res.writeHead(200, { 'Content-Type': 'application/json' });
381
+ res.end(JSON.stringify(info));
382
+ }
383
+ catch {
384
+ res.writeHead(400, { 'Content-Type': 'application/json' });
385
+ res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
386
+ }
387
+ return;
388
+ }
389
+ // GET /api/terminals/:id/output - Get terminal output
390
+ if (req.method === 'GET' && subpath === '/output') {
391
+ const lines = parseInt(url.searchParams.get('lines') ?? '100', 10);
392
+ const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
393
+ const output = manager.getOutput(terminalId, lines, offset);
394
+ if (!output) {
395
+ res.writeHead(404, { 'Content-Type': 'application/json' });
396
+ res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
397
+ return;
398
+ }
399
+ res.writeHead(200, { 'Content-Type': 'application/json' });
400
+ res.end(JSON.stringify(output));
401
+ return;
402
+ }
403
+ }
404
+ async function handleStatus(res) {
405
+ const instances = await getInstances();
406
+ res.writeHead(200, { 'Content-Type': 'application/json' });
407
+ res.end(JSON.stringify({ instances }));
408
+ }
409
+ function handleSSEEvents(req, res, ctx) {
410
+ const clientId = crypto.randomBytes(8).toString('hex');
411
+ res.writeHead(200, {
412
+ 'Content-Type': 'text/event-stream',
413
+ 'Cache-Control': 'no-cache',
414
+ Connection: 'keep-alive',
415
+ });
416
+ // Send initial connection event
417
+ res.write(`data: ${JSON.stringify({ type: 'connected', id: clientId })}\n\n`);
418
+ const client = { res, id: clientId };
419
+ ctx.addSseClient(client);
420
+ ctx.log('INFO', `SSE client connected: ${clientId}`);
421
+ // Clean up on disconnect
422
+ req.on('close', () => {
423
+ ctx.removeSseClient(clientId);
424
+ ctx.log('INFO', `SSE client disconnected: ${clientId}`);
425
+ });
426
+ }
427
+ async function handleNotify(req, res, ctx) {
428
+ const body = await parseJsonBody(req);
429
+ const type = typeof body.type === 'string' ? body.type : 'info';
430
+ const title = typeof body.title === 'string' ? body.title : '';
431
+ const messageBody = typeof body.body === 'string' ? body.body : '';
432
+ const project = typeof body.project === 'string' ? body.project : undefined;
433
+ if (!title || !messageBody) {
434
+ res.writeHead(400, { 'Content-Type': 'application/json' });
435
+ res.end(JSON.stringify({ success: false, error: 'Missing title or body' }));
436
+ return;
437
+ }
438
+ // Broadcast to all connected SSE clients
439
+ ctx.broadcastNotification({
440
+ type,
441
+ title,
442
+ body: messageBody,
443
+ project,
444
+ });
445
+ ctx.log('INFO', `Notification broadcast: ${title}`);
446
+ res.writeHead(200, { 'Content-Type': 'application/json' });
447
+ res.end(JSON.stringify({ success: true }));
448
+ }
449
+ async function handleBrowse(res, url) {
450
+ const inputPath = url.searchParams.get('path') || '';
451
+ try {
452
+ const suggestions = await getDirectorySuggestions(inputPath);
453
+ res.writeHead(200, { 'Content-Type': 'application/json' });
454
+ res.end(JSON.stringify({ suggestions }));
455
+ }
456
+ catch (err) {
457
+ res.writeHead(200, { 'Content-Type': 'application/json' });
458
+ res.end(JSON.stringify({ suggestions: [], error: err.message }));
459
+ }
460
+ }
461
+ async function handleCreateProject(req, res, ctx) {
462
+ const body = await parseJsonBody(req);
463
+ const parentPath = body.parent;
464
+ const projectName = body.name;
465
+ if (!parentPath || !projectName) {
466
+ res.writeHead(400, { 'Content-Type': 'application/json' });
467
+ res.end(JSON.stringify({ success: false, error: 'Missing parent or name' }));
468
+ return;
469
+ }
470
+ // Validate project name
471
+ if (!/^[a-zA-Z0-9_-]+$/.test(projectName)) {
472
+ res.writeHead(400, { 'Content-Type': 'application/json' });
473
+ res.end(JSON.stringify({ success: false, error: 'Invalid project name' }));
474
+ return;
475
+ }
476
+ // Expand ~ to home directory
477
+ let expandedParent = parentPath;
478
+ if (expandedParent.startsWith('~')) {
479
+ expandedParent = expandedParent.replace('~', homedir());
480
+ }
481
+ // Validate parent exists
482
+ if (!fs.existsSync(expandedParent)) {
483
+ res.writeHead(400, { 'Content-Type': 'application/json' });
484
+ res.end(JSON.stringify({ success: false, error: `Parent directory does not exist: ${parentPath}` }));
485
+ return;
486
+ }
487
+ const projectPath = path.join(expandedParent, projectName);
488
+ // Check if project already exists
489
+ if (fs.existsSync(projectPath)) {
490
+ res.writeHead(400, { 'Content-Type': 'application/json' });
491
+ res.end(JSON.stringify({ success: false, error: `Directory already exists: ${projectPath}` }));
492
+ return;
493
+ }
494
+ try {
495
+ // Run codev init (it creates the directory)
496
+ execSync(`codev init --yes "${projectName}"`, {
497
+ cwd: expandedParent,
498
+ stdio: 'pipe',
499
+ timeout: 60000,
500
+ });
501
+ // Launch the instance
502
+ const launchResult = await launchInstance(projectPath);
503
+ if (!launchResult.success) {
504
+ res.writeHead(500, { 'Content-Type': 'application/json' });
505
+ res.end(JSON.stringify({ success: false, error: launchResult.error }));
506
+ return;
507
+ }
508
+ res.writeHead(200, { 'Content-Type': 'application/json' });
509
+ res.end(JSON.stringify({ success: true, projectPath }));
510
+ }
511
+ catch (err) {
512
+ // Clean up on failure
513
+ try {
514
+ if (fs.existsSync(projectPath)) {
515
+ fs.rmSync(projectPath, { recursive: true });
516
+ }
517
+ }
518
+ catch {
519
+ // Ignore cleanup errors
520
+ }
521
+ res.writeHead(500, { 'Content-Type': 'application/json' });
522
+ res.end(JSON.stringify({ success: false, error: `Failed to create project: ${err.message}` }));
523
+ }
524
+ }
525
+ async function handleLaunchInstance(req, res) {
526
+ const body = await parseJsonBody(req);
527
+ let projectPath = body.projectPath;
528
+ if (!projectPath) {
529
+ res.writeHead(400, { 'Content-Type': 'application/json' });
530
+ res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
531
+ return;
532
+ }
533
+ // Expand ~ to home directory
534
+ if (projectPath.startsWith('~')) {
535
+ projectPath = projectPath.replace('~', homedir());
536
+ }
537
+ // Reject relative paths — tower daemon CWD is unpredictable
538
+ if (!path.isAbsolute(projectPath)) {
539
+ res.writeHead(400, { 'Content-Type': 'application/json' });
540
+ res.end(JSON.stringify({
541
+ success: false,
542
+ error: `Relative paths are not supported. Use an absolute path (e.g., /Users/.../project or ~/Development/project).`,
543
+ }));
544
+ return;
545
+ }
546
+ // Normalize path (resolve .. segments, trailing slashes)
547
+ projectPath = path.resolve(projectPath);
548
+ const result = await launchInstance(projectPath);
549
+ res.writeHead(result.success ? 200 : 400, { 'Content-Type': 'application/json' });
550
+ res.end(JSON.stringify(result));
551
+ }
552
+ async function handleStopInstance(req, res) {
553
+ const body = await parseJsonBody(req);
554
+ const targetPath = body.projectPath;
555
+ if (!targetPath) {
556
+ res.writeHead(400, { 'Content-Type': 'application/json' });
557
+ res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
558
+ return;
559
+ }
560
+ const result = await stopInstance(targetPath);
561
+ res.writeHead(200, { 'Content-Type': 'application/json' });
562
+ res.end(JSON.stringify(result));
563
+ }
564
+ function handleDashboard(res, ctx) {
565
+ if (!ctx.templatePath) {
566
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
567
+ res.end('Template not found. Make sure tower.html exists in agent-farm/templates/');
568
+ return;
569
+ }
570
+ try {
571
+ const template = fs.readFileSync(ctx.templatePath, 'utf-8');
572
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
573
+ res.end(template);
574
+ }
575
+ catch (err) {
576
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
577
+ res.end('Error loading template: ' + err.message);
578
+ }
579
+ }
580
+ // ============================================================================
581
+ // Project-scoped route handler
582
+ // ============================================================================
583
+ async function handleProjectRoutes(req, res, ctx, url) {
584
+ const pathParts = url.pathname.split('/');
585
+ // ['', 'project', base64urlPath, ...rest]
586
+ const encodedPath = pathParts[2];
587
+ const subPath = pathParts.slice(3).join('/');
588
+ if (!encodedPath) {
589
+ res.writeHead(400, { 'Content-Type': 'application/json' });
590
+ res.end(JSON.stringify({ error: 'Missing project path' }));
591
+ return;
592
+ }
593
+ // Decode Base64URL (RFC 4648)
594
+ let projectPath;
595
+ try {
596
+ projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
597
+ // Support both POSIX (/) and Windows (C:\) paths
598
+ if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
599
+ throw new Error('Invalid project path');
600
+ }
601
+ // Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
602
+ projectPath = normalizeProjectPath(projectPath);
603
+ }
604
+ catch {
605
+ res.writeHead(400, { 'Content-Type': 'application/json' });
606
+ res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
607
+ return;
608
+ }
609
+ // Phase 4 (Spec 0090): Tower handles everything directly
610
+ const isApiCall = subPath.startsWith('api/') || subPath === 'api';
611
+ const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
612
+ // Tunnel endpoints are tower-level, not project-scoped, but the React
613
+ // dashboard uses relative paths (./api/tunnel/...) which resolve to
614
+ // /project/<encoded>/api/tunnel/... in project context. Handle here by
615
+ // extracting the tunnel sub-path and dispatching to handleTunnelEndpoint().
616
+ if (subPath.startsWith('api/tunnel/')) {
617
+ const tunnelSub = subPath.slice('api/tunnel/'.length); // e.g. "status", "connect", "disconnect"
618
+ await handleTunnelEndpoint(req, res, tunnelSub);
619
+ return;
620
+ }
621
+ // GET /file?path=<relative-path> — Read project file by path (for StatusPanel project list)
622
+ if (req.method === 'GET' && subPath === 'file' && url.searchParams.has('path')) {
623
+ const relPath = url.searchParams.get('path');
624
+ const fullPath = path.resolve(projectPath, relPath);
625
+ // Security: ensure resolved path stays within project directory
626
+ if (!fullPath.startsWith(projectPath + path.sep) && fullPath !== projectPath) {
627
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
628
+ res.end('Forbidden');
629
+ return;
630
+ }
631
+ try {
632
+ const content = fs.readFileSync(fullPath, 'utf-8');
633
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
634
+ res.end(content);
635
+ }
636
+ catch {
637
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
638
+ res.end('Not found');
639
+ }
640
+ return;
641
+ }
642
+ // Serve React dashboard static files directly if:
643
+ // 1. Not an API call
644
+ // 2. Not a WebSocket path
645
+ // 3. React dashboard is available
646
+ // 4. Project doesn't need to be running for static files
647
+ if (!isApiCall && !isWsPath && ctx.hasReactDashboard) {
648
+ // Determine which static file to serve
649
+ let staticPath;
650
+ if (!subPath || subPath === '' || subPath === 'index.html') {
651
+ staticPath = path.join(ctx.reactDashboardPath, 'index.html');
652
+ }
653
+ else {
654
+ // Check if it's a static asset
655
+ staticPath = path.join(ctx.reactDashboardPath, subPath);
656
+ }
657
+ // Try to serve the static file
658
+ if (serveStaticFile(staticPath, res)) {
659
+ return;
660
+ }
661
+ // SPA fallback: serve index.html for client-side routing
662
+ const indexPath = path.join(ctx.reactDashboardPath, 'index.html');
663
+ if (serveStaticFile(indexPath, res)) {
664
+ return;
665
+ }
666
+ }
667
+ // Phase 4 (Spec 0090): Handle project APIs directly instead of proxying to dashboard-server
668
+ if (isApiCall) {
669
+ const apiPath = subPath.replace(/^api\/?/, '');
670
+ // GET /api/state - Return project state (architect, builders, shells)
671
+ if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
672
+ return handleProjectState(res, projectPath);
673
+ }
674
+ // POST /api/tabs/shell - Create a new shell terminal
675
+ if (req.method === 'POST' && apiPath === 'tabs/shell') {
676
+ return handleProjectShellCreate(res, ctx, projectPath);
677
+ }
678
+ // POST /api/tabs/file - Create a file tab (Spec 0092)
679
+ if (req.method === 'POST' && apiPath === 'tabs/file') {
680
+ return handleProjectFileTabCreate(req, res, ctx, projectPath);
681
+ }
682
+ // GET /api/file/:id - Get file content as JSON (Spec 0092)
683
+ const fileGetMatch = apiPath.match(/^file\/([^/]+)$/);
684
+ if (req.method === 'GET' && fileGetMatch) {
685
+ return handleProjectFileGet(res, ctx, projectPath, fileGetMatch[1]);
686
+ }
687
+ // GET /api/file/:id/raw - Get raw file content (for images/video) (Spec 0092)
688
+ const fileRawMatch = apiPath.match(/^file\/([^/]+)\/raw$/);
689
+ if (req.method === 'GET' && fileRawMatch) {
690
+ return handleProjectFileRaw(res, ctx, projectPath, fileRawMatch[1]);
691
+ }
692
+ // POST /api/file/:id/save - Save file content (Spec 0092)
693
+ const fileSaveMatch = apiPath.match(/^file\/([^/]+)\/save$/);
694
+ if (req.method === 'POST' && fileSaveMatch) {
695
+ return handleProjectFileSave(req, res, ctx, projectPath, fileSaveMatch[1]);
696
+ }
697
+ // DELETE /api/tabs/:id - Delete a terminal or file tab
698
+ const deleteMatch = apiPath.match(/^tabs\/(.+)$/);
699
+ if (req.method === 'DELETE' && deleteMatch) {
700
+ return handleProjectTabDelete(res, ctx, projectPath, deleteMatch[1]);
701
+ }
702
+ // POST /api/stop - Stop all terminals for project
703
+ if (req.method === 'POST' && apiPath === 'stop') {
704
+ return handleProjectStopAll(res, projectPath);
705
+ }
706
+ // GET /api/files - Return project directory tree for file browser (Spec 0092)
707
+ if (req.method === 'GET' && apiPath === 'files') {
708
+ return handleProjectFiles(res, url, projectPath);
709
+ }
710
+ // GET /api/git/status - Return git status for file browser (Spec 0092)
711
+ if (req.method === 'GET' && apiPath === 'git/status') {
712
+ return handleProjectGitStatus(res, ctx, projectPath);
713
+ }
714
+ // GET /api/files/recent - Return recently opened file tabs (Spec 0092)
715
+ if (req.method === 'GET' && apiPath === 'files/recent') {
716
+ return handleProjectRecentFiles(res, projectPath);
717
+ }
718
+ // GET /api/annotate/:tabId/* — Serve rich annotator template and sub-APIs
719
+ const annotateMatch = apiPath.match(/^annotate\/([^/]+)(\/(.*))?$/);
720
+ if (annotateMatch) {
721
+ return handleProjectAnnotate(req, res, ctx, url, projectPath, annotateMatch);
722
+ }
723
+ // POST /api/paste-image - Upload pasted image to temp file (Issue #252)
724
+ if (req.method === 'POST' && apiPath === 'paste-image') {
725
+ const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
726
+ let size = 0;
727
+ const chunks = [];
728
+ let aborted = false;
729
+ req.on('data', (chunk) => {
730
+ size += chunk.length;
731
+ if (size > MAX_IMAGE_SIZE) {
732
+ aborted = true;
733
+ res.writeHead(413, { 'Content-Type': 'application/json' });
734
+ res.end(JSON.stringify({ error: 'Image too large (max 10 MB)' }));
735
+ req.destroy();
736
+ return;
737
+ }
738
+ chunks.push(chunk);
739
+ });
740
+ req.on('end', () => {
741
+ if (aborted)
742
+ return;
743
+ try {
744
+ const buffer = Buffer.concat(chunks);
745
+ const contentType = req.headers['content-type'] || 'image/png';
746
+ const ext = contentType.includes('jpeg') || contentType.includes('jpg') ? '.jpg'
747
+ : contentType.includes('gif') ? '.gif'
748
+ : contentType.includes('webp') ? '.webp'
749
+ : '.png';
750
+ const filename = `paste-${crypto.randomUUID()}${ext}`;
751
+ const pasteDir = path.join(tmpdir(), 'codev-paste');
752
+ fs.mkdirSync(pasteDir, { recursive: true });
753
+ const filePath = path.join(pasteDir, filename);
754
+ fs.writeFileSync(filePath, buffer);
755
+ res.writeHead(200, { 'Content-Type': 'application/json' });
756
+ res.end(JSON.stringify({ path: filePath }));
757
+ }
758
+ catch (err) {
759
+ if (!res.headersSent) {
760
+ const status = err.message.includes('too large') ? 413 : 500;
761
+ res.writeHead(status, { 'Content-Type': 'application/json' });
762
+ res.end(JSON.stringify({ error: err.message }));
763
+ }
764
+ }
765
+ });
766
+ req.on('error', (err) => {
767
+ if (!res.headersSent) {
768
+ res.writeHead(500, { 'Content-Type': 'application/json' });
769
+ res.end(JSON.stringify({ error: err.message }));
770
+ }
771
+ });
772
+ return;
773
+ }
774
+ // Unhandled API route
775
+ res.writeHead(404, { 'Content-Type': 'application/json' });
776
+ res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));
777
+ return;
778
+ }
779
+ // For WebSocket paths, let the upgrade handler deal with it
780
+ if (isWsPath) {
781
+ // WebSocket paths are handled by the upgrade handler
782
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
783
+ res.end('WebSocket connections should use ws:// protocol');
784
+ return;
785
+ }
786
+ // If we get here for non-API, non-WS paths and React dashboard is not available
787
+ if (!ctx.hasReactDashboard) {
788
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
789
+ res.end('Dashboard not available');
790
+ return;
791
+ }
792
+ // Fallback for unmatched paths
793
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
794
+ res.end('Not found');
795
+ }
796
+ // ============================================================================
797
+ // Project API sub-handlers
798
+ // ============================================================================
799
+ async function handleProjectState(res, projectPath) {
800
+ // Refresh cache via getTerminalsForProject (handles SQLite sync
801
+ // and shellper reconnection in one place)
802
+ const encodedPath = Buffer.from(projectPath).toString('base64url');
803
+ const proxyUrl = `/project/${encodedPath}/`;
804
+ const { gateStatus } = await getTerminalsForProject(projectPath, proxyUrl);
805
+ // Now read from the refreshed cache
806
+ const entry = getProjectTerminalsEntry(projectPath);
807
+ const manager = getTerminalManager();
808
+ const state = {
809
+ architect: null,
810
+ builders: [],
811
+ utils: [],
812
+ annotations: [],
813
+ projectName: path.basename(projectPath),
814
+ gateStatus,
815
+ };
816
+ // Add architect if exists
817
+ if (entry.architect) {
818
+ const session = manager.getSession(entry.architect);
819
+ if (session) {
820
+ state.architect = {
821
+ port: 0,
822
+ pid: session.pid || 0,
823
+ terminalId: entry.architect,
824
+ persistent: isSessionPersistent(entry.architect, session),
825
+ };
826
+ }
827
+ }
828
+ // Add shells from refreshed cache
829
+ for (const [shellId, terminalId] of entry.shells) {
830
+ const session = manager.getSession(terminalId);
831
+ if (session) {
832
+ state.utils.push({
833
+ id: shellId,
834
+ name: `Shell ${shellId.replace('shell-', '')}`,
835
+ port: 0,
836
+ pid: session.pid || 0,
837
+ terminalId,
838
+ persistent: isSessionPersistent(terminalId, session),
839
+ });
840
+ }
841
+ }
842
+ // Add builders from refreshed cache
843
+ for (const [builderId, terminalId] of entry.builders) {
844
+ const session = manager.getSession(terminalId);
845
+ if (session) {
846
+ state.builders.push({
847
+ id: builderId,
848
+ name: `Builder ${builderId}`,
849
+ port: 0,
850
+ pid: session.pid || 0,
851
+ status: 'running',
852
+ phase: '',
853
+ worktree: '',
854
+ branch: '',
855
+ type: 'spec',
856
+ terminalId,
857
+ persistent: isSessionPersistent(terminalId, session),
858
+ });
859
+ }
860
+ }
861
+ // Add file tabs (Spec 0092 - served through Tower, no separate ports)
862
+ for (const [tabId, tab] of entry.fileTabs) {
863
+ state.annotations.push({
864
+ id: tabId,
865
+ file: tab.path,
866
+ port: 0, // No separate port - served through Tower
867
+ pid: 0, // No separate process
868
+ });
869
+ }
870
+ res.writeHead(200, { 'Content-Type': 'application/json' });
871
+ res.end(JSON.stringify(state));
872
+ }
873
+ async function handleProjectShellCreate(res, ctx, projectPath) {
874
+ try {
875
+ const manager = getTerminalManager();
876
+ const shellId = getNextShellId(projectPath);
877
+ const shellCmd = process.env.SHELL || '/bin/bash';
878
+ const shellArgs = [];
879
+ let shellCreated = false;
880
+ // Try shellper first for persistent shell session
881
+ const shellperManager = ctx.getShellperManager();
882
+ if (shellperManager) {
883
+ try {
884
+ const sessionId = crypto.randomUUID();
885
+ // Strip CLAUDECODE so spawned Claude processes don't detect nesting
886
+ const shellEnv = { ...process.env };
887
+ delete shellEnv['CLAUDECODE'];
888
+ const client = await shellperManager.createSession({
889
+ sessionId,
890
+ command: shellCmd,
891
+ args: shellArgs,
892
+ cwd: projectPath,
893
+ env: shellEnv,
894
+ cols: 200,
895
+ rows: 50,
896
+ restartOnExit: false,
897
+ });
898
+ const replayData = client.getReplayData() ?? Buffer.alloc(0);
899
+ const shellperInfo = shellperManager.getSessionInfo(sessionId);
900
+ const session = manager.createSessionRaw({
901
+ label: `Shell ${shellId.replace('shell-', '')}`,
902
+ cwd: projectPath,
903
+ });
904
+ const ptySession = manager.getSession(session.id);
905
+ if (ptySession) {
906
+ ptySession.attachShellper(client, replayData, shellperInfo.pid, sessionId);
907
+ }
908
+ const entry = getProjectTerminalsEntry(projectPath);
909
+ entry.shells.set(shellId, session.id);
910
+ saveTerminalSession(session.id, projectPath, 'shell', shellId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime);
911
+ shellCreated = true;
912
+ res.writeHead(200, { 'Content-Type': 'application/json' });
913
+ res.end(JSON.stringify({
914
+ id: shellId,
915
+ port: 0,
916
+ name: `Shell ${shellId.replace('shell-', '')}`,
917
+ terminalId: session.id,
918
+ persistent: true,
919
+ }));
920
+ }
921
+ catch (shellperErr) {
922
+ ctx.log('WARN', `Shellper creation failed for shell, falling back: ${shellperErr.message}`);
923
+ }
924
+ }
925
+ // Fallback: non-persistent session (graceful degradation per plan)
926
+ // Shellper is the only persistence backend for new sessions.
927
+ if (!shellCreated) {
928
+ const session = await manager.createSession({
929
+ command: shellCmd,
930
+ args: shellArgs,
931
+ cwd: projectPath,
932
+ label: `Shell ${shellId.replace('shell-', '')}`,
933
+ env: process.env,
934
+ });
935
+ const entry = getProjectTerminalsEntry(projectPath);
936
+ entry.shells.set(shellId, session.id);
937
+ saveTerminalSession(session.id, projectPath, 'shell', shellId, session.pid);
938
+ ctx.log('WARN', `Shell ${shellId} for ${projectPath} is non-persistent (shellper unavailable)`);
939
+ res.writeHead(200, { 'Content-Type': 'application/json' });
940
+ res.end(JSON.stringify({
941
+ id: shellId,
942
+ port: 0,
943
+ name: `Shell ${shellId.replace('shell-', '')}`,
944
+ terminalId: session.id,
945
+ persistent: false,
946
+ }));
947
+ }
948
+ }
949
+ catch (err) {
950
+ ctx.log('ERROR', `Failed to create shell: ${err.message}`);
951
+ res.writeHead(500, { 'Content-Type': 'application/json' });
952
+ res.end(JSON.stringify({ error: err.message }));
953
+ }
954
+ }
955
+ async function handleProjectFileTabCreate(req, res, ctx, projectPath) {
956
+ try {
957
+ const body = await readBody(req);
958
+ const { path: filePath, line, terminalId } = JSON.parse(body || '{}');
959
+ if (!filePath || typeof filePath !== 'string') {
960
+ res.writeHead(400, { 'Content-Type': 'application/json' });
961
+ res.end(JSON.stringify({ error: 'Missing path parameter' }));
962
+ return;
963
+ }
964
+ // Resolve path: use terminal's cwd for relative paths when terminalId is provided
965
+ let fullPath;
966
+ if (path.isAbsolute(filePath)) {
967
+ fullPath = filePath;
968
+ }
969
+ else if (terminalId) {
970
+ const manager = getTerminalManager();
971
+ const session = manager.getSession(terminalId);
972
+ if (session) {
973
+ fullPath = path.join(session.cwd, filePath);
974
+ }
975
+ else {
976
+ ctx.log('WARN', `Terminal session ${terminalId} not found, falling back to project root`);
977
+ fullPath = path.join(projectPath, filePath);
978
+ }
979
+ }
980
+ else {
981
+ fullPath = path.join(projectPath, filePath);
982
+ }
983
+ // Security: symlink-aware containment check
984
+ // For non-existent files, resolve the parent directory to handle
985
+ // intermediate symlinks (e.g., /tmp -> /private/tmp on macOS).
986
+ let resolvedPath;
987
+ try {
988
+ resolvedPath = fs.realpathSync(fullPath);
989
+ }
990
+ catch {
991
+ try {
992
+ resolvedPath = path.join(fs.realpathSync(path.dirname(fullPath)), path.basename(fullPath));
993
+ }
994
+ catch {
995
+ resolvedPath = path.resolve(fullPath);
996
+ }
997
+ }
998
+ let normalizedProject;
999
+ try {
1000
+ normalizedProject = fs.realpathSync(projectPath);
1001
+ }
1002
+ catch {
1003
+ normalizedProject = path.resolve(projectPath);
1004
+ }
1005
+ const isWithinProject = resolvedPath.startsWith(normalizedProject + path.sep)
1006
+ || resolvedPath === normalizedProject;
1007
+ if (!isWithinProject) {
1008
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1009
+ res.end(JSON.stringify({ error: 'Path outside project' }));
1010
+ return;
1011
+ }
1012
+ // Non-existent files still create a tab (spec 0101: file viewer shows "File not found")
1013
+ const fileExists = fs.existsSync(fullPath);
1014
+ const entry = getProjectTerminalsEntry(projectPath);
1015
+ // Check if already open
1016
+ for (const [id, tab] of entry.fileTabs) {
1017
+ if (tab.path === fullPath) {
1018
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1019
+ res.end(JSON.stringify({ id, existing: true, line, notFound: !fileExists }));
1020
+ return;
1021
+ }
1022
+ }
1023
+ // Create new file tab (write-through: in-memory + SQLite)
1024
+ const id = `file-${crypto.randomUUID()}`;
1025
+ const createdAt = Date.now();
1026
+ entry.fileTabs.set(id, { id, path: fullPath, createdAt });
1027
+ saveFileTab(id, projectPath, fullPath, createdAt);
1028
+ ctx.log('INFO', `Created file tab: ${id} for ${path.basename(fullPath)}`);
1029
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1030
+ res.end(JSON.stringify({ id, existing: false, line, notFound: !fileExists }));
1031
+ }
1032
+ catch (err) {
1033
+ ctx.log('ERROR', `Failed to create file tab: ${err.message}`);
1034
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1035
+ res.end(JSON.stringify({ error: err.message }));
1036
+ }
1037
+ }
1038
+ function handleProjectFileGet(res, ctx, projectPath, tabId) {
1039
+ const entry = getProjectTerminalsEntry(projectPath);
1040
+ const tab = entry.fileTabs.get(tabId);
1041
+ if (!tab) {
1042
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1043
+ res.end(JSON.stringify({ error: 'File tab not found' }));
1044
+ return;
1045
+ }
1046
+ try {
1047
+ const ext = path.extname(tab.path).slice(1).toLowerCase();
1048
+ const isText = !['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'webm', 'mov', 'pdf'].includes(ext);
1049
+ if (isText) {
1050
+ const content = fs.readFileSync(tab.path, 'utf-8');
1051
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1052
+ res.end(JSON.stringify({
1053
+ path: tab.path,
1054
+ name: path.basename(tab.path),
1055
+ content,
1056
+ language: getLanguageForExt(ext),
1057
+ isMarkdown: ext === 'md',
1058
+ isImage: false,
1059
+ isVideo: false,
1060
+ }));
1061
+ }
1062
+ else {
1063
+ // For binary files, just return metadata
1064
+ const stat = fs.statSync(tab.path);
1065
+ const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
1066
+ const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
1067
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1068
+ res.end(JSON.stringify({
1069
+ path: tab.path,
1070
+ name: path.basename(tab.path),
1071
+ content: null,
1072
+ language: ext,
1073
+ isMarkdown: false,
1074
+ isImage,
1075
+ isVideo,
1076
+ size: stat.size,
1077
+ }));
1078
+ }
1079
+ }
1080
+ catch (err) {
1081
+ ctx.log('ERROR', `GET /api/file/:id failed: ${err.message}`);
1082
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1083
+ res.end(JSON.stringify({ error: err.message }));
1084
+ }
1085
+ }
1086
+ function handleProjectFileRaw(res, ctx, projectPath, tabId) {
1087
+ const entry = getProjectTerminalsEntry(projectPath);
1088
+ const tab = entry.fileTabs.get(tabId);
1089
+ if (!tab) {
1090
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1091
+ res.end(JSON.stringify({ error: 'File tab not found' }));
1092
+ return;
1093
+ }
1094
+ try {
1095
+ const data = fs.readFileSync(tab.path);
1096
+ const mimeType = getMimeTypeForFile(tab.path);
1097
+ res.writeHead(200, {
1098
+ 'Content-Type': mimeType,
1099
+ 'Content-Length': data.length,
1100
+ 'Cache-Control': 'no-cache',
1101
+ });
1102
+ res.end(data);
1103
+ }
1104
+ catch (err) {
1105
+ ctx.log('ERROR', `GET /api/file/:id/raw failed: ${err.message}`);
1106
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1107
+ res.end(JSON.stringify({ error: err.message }));
1108
+ }
1109
+ }
1110
+ async function handleProjectFileSave(req, res, ctx, projectPath, tabId) {
1111
+ const entry = getProjectTerminalsEntry(projectPath);
1112
+ const tab = entry.fileTabs.get(tabId);
1113
+ if (!tab) {
1114
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1115
+ res.end(JSON.stringify({ error: 'File tab not found' }));
1116
+ return;
1117
+ }
1118
+ try {
1119
+ const body = await readBody(req);
1120
+ const { content } = JSON.parse(body || '{}');
1121
+ if (typeof content !== 'string') {
1122
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1123
+ res.end(JSON.stringify({ error: 'Missing content parameter' }));
1124
+ return;
1125
+ }
1126
+ fs.writeFileSync(tab.path, content, 'utf-8');
1127
+ ctx.log('INFO', `Saved file: ${tab.path}`);
1128
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1129
+ res.end(JSON.stringify({ success: true }));
1130
+ }
1131
+ catch (err) {
1132
+ ctx.log('ERROR', `POST /api/file/:id/save failed: ${err.message}`);
1133
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1134
+ res.end(JSON.stringify({ error: err.message }));
1135
+ }
1136
+ }
1137
+ async function handleProjectTabDelete(res, ctx, projectPath, tabId) {
1138
+ const entry = getProjectTerminalsEntry(projectPath);
1139
+ const manager = getTerminalManager();
1140
+ // Check if it's a file tab first (Spec 0092, write-through: in-memory + SQLite)
1141
+ if (tabId.startsWith('file-')) {
1142
+ if (entry.fileTabs.has(tabId)) {
1143
+ entry.fileTabs.delete(tabId);
1144
+ deleteFileTab(tabId);
1145
+ ctx.log('INFO', `Deleted file tab: ${tabId}`);
1146
+ res.writeHead(204);
1147
+ res.end();
1148
+ }
1149
+ else {
1150
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1151
+ res.end(JSON.stringify({ error: 'File tab not found' }));
1152
+ }
1153
+ return;
1154
+ }
1155
+ // Find and delete the terminal
1156
+ let terminalId;
1157
+ if (tabId.startsWith('shell-')) {
1158
+ terminalId = entry.shells.get(tabId);
1159
+ if (terminalId) {
1160
+ entry.shells.delete(tabId);
1161
+ }
1162
+ }
1163
+ else if (tabId.startsWith('builder-')) {
1164
+ terminalId = entry.builders.get(tabId);
1165
+ if (terminalId) {
1166
+ entry.builders.delete(tabId);
1167
+ }
1168
+ }
1169
+ else if (tabId === 'architect') {
1170
+ terminalId = entry.architect;
1171
+ if (terminalId) {
1172
+ entry.architect = undefined;
1173
+ }
1174
+ }
1175
+ if (terminalId) {
1176
+ // Disable shellper auto-restart if applicable, then kill the PtySession
1177
+ await killTerminalWithShellper(manager, terminalId);
1178
+ // TICK-001: Delete from SQLite
1179
+ deleteTerminalSession(terminalId);
1180
+ res.writeHead(204);
1181
+ res.end();
1182
+ }
1183
+ else {
1184
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1185
+ res.end(JSON.stringify({ error: 'Tab not found' }));
1186
+ }
1187
+ }
1188
+ async function handleProjectStopAll(res, projectPath) {
1189
+ const entry = getProjectTerminalsEntry(projectPath);
1190
+ const manager = getTerminalManager();
1191
+ // Kill all terminals (disable shellper auto-restart if applicable)
1192
+ if (entry.architect) {
1193
+ await killTerminalWithShellper(manager, entry.architect);
1194
+ }
1195
+ for (const terminalId of entry.shells.values()) {
1196
+ await killTerminalWithShellper(manager, terminalId);
1197
+ }
1198
+ for (const terminalId of entry.builders.values()) {
1199
+ await killTerminalWithShellper(manager, terminalId);
1200
+ }
1201
+ // Clear registry
1202
+ getProjectTerminals().delete(projectPath);
1203
+ // TICK-001: Delete all terminal sessions from SQLite
1204
+ deleteProjectTerminalSessions(projectPath);
1205
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1206
+ res.end(JSON.stringify({ ok: true }));
1207
+ }
1208
+ function handleProjectFiles(res, url, projectPath) {
1209
+ const maxDepth = parseInt(url.searchParams.get('depth') || '3', 10);
1210
+ const ignore = new Set(['.git', 'node_modules', '.builders', 'dist', '.agent-farm', '.next', '.cache', '__pycache__']);
1211
+ function readTree(dir, depth) {
1212
+ if (depth <= 0)
1213
+ return [];
1214
+ try {
1215
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1216
+ return entries
1217
+ .filter(e => !e.name.startsWith('.') || e.name === '.env.example')
1218
+ .filter(e => !ignore.has(e.name))
1219
+ .sort((a, b) => {
1220
+ // Directories first, then alphabetical
1221
+ if (a.isDirectory() && !b.isDirectory())
1222
+ return -1;
1223
+ if (!a.isDirectory() && b.isDirectory())
1224
+ return 1;
1225
+ return a.name.localeCompare(b.name);
1226
+ })
1227
+ .map(e => {
1228
+ const fullPath = path.join(dir, e.name);
1229
+ const relativePath = path.relative(projectPath, fullPath);
1230
+ if (e.isDirectory()) {
1231
+ return { name: e.name, path: relativePath, type: 'directory', children: readTree(fullPath, depth - 1) };
1232
+ }
1233
+ return { name: e.name, path: relativePath, type: 'file' };
1234
+ });
1235
+ }
1236
+ catch {
1237
+ return [];
1238
+ }
1239
+ }
1240
+ const tree = readTree(projectPath, maxDepth);
1241
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1242
+ res.end(JSON.stringify(tree));
1243
+ }
1244
+ function handleProjectGitStatus(res, ctx, projectPath) {
1245
+ try {
1246
+ // Get git status in porcelain format for parsing
1247
+ const result = execSync('git status --porcelain', {
1248
+ cwd: projectPath,
1249
+ encoding: 'utf-8',
1250
+ timeout: 5000,
1251
+ });
1252
+ // Parse porcelain output: XY filename
1253
+ // X = staging area status, Y = working tree status
1254
+ const modified = [];
1255
+ const staged = [];
1256
+ const untracked = [];
1257
+ for (const line of result.split('\n')) {
1258
+ if (!line)
1259
+ continue;
1260
+ const x = line[0]; // staging area
1261
+ const y = line[1]; // working tree
1262
+ const filepath = line.slice(3);
1263
+ if (x === '?' && y === '?') {
1264
+ untracked.push(filepath);
1265
+ }
1266
+ else {
1267
+ if (x !== ' ' && x !== '?') {
1268
+ staged.push(filepath);
1269
+ }
1270
+ if (y !== ' ' && y !== '?') {
1271
+ modified.push(filepath);
1272
+ }
1273
+ }
1274
+ }
1275
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1276
+ res.end(JSON.stringify({ modified, staged, untracked }));
1277
+ }
1278
+ catch (err) {
1279
+ // Not a git repo or git command failed — return graceful degradation with error field
1280
+ ctx.log('WARN', `GET /api/git/status failed: ${err.message}`);
1281
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1282
+ res.end(JSON.stringify({ modified: [], staged: [], untracked: [], error: err.message }));
1283
+ }
1284
+ }
1285
+ function handleProjectRecentFiles(res, projectPath) {
1286
+ const entry = getProjectTerminalsEntry(projectPath);
1287
+ // Get all file tabs sorted by creation time (most recent first)
1288
+ const recentFiles = Array.from(entry.fileTabs.values())
1289
+ .sort((a, b) => b.createdAt - a.createdAt)
1290
+ .slice(0, 10) // Limit to 10 most recent
1291
+ .map(tab => ({
1292
+ id: tab.id,
1293
+ path: tab.path,
1294
+ name: path.basename(tab.path),
1295
+ relativePath: path.relative(projectPath, tab.path),
1296
+ }));
1297
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1298
+ res.end(JSON.stringify(recentFiles));
1299
+ }
1300
+ function handleProjectAnnotate(req, res, ctx, url, projectPath, annotateMatch) {
1301
+ const tabId = annotateMatch[1];
1302
+ const subRoute = annotateMatch[3] || '';
1303
+ const entry = getProjectTerminalsEntry(projectPath);
1304
+ const tab = entry.fileTabs.get(tabId);
1305
+ if (!tab) {
1306
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1307
+ res.end(JSON.stringify({ error: 'File tab not found' }));
1308
+ return;
1309
+ }
1310
+ const filePath = tab.path;
1311
+ const ext = path.extname(filePath).slice(1).toLowerCase();
1312
+ const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
1313
+ const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
1314
+ const is3D = ['stl', '3mf'].includes(ext);
1315
+ const isPdf = ext === 'pdf';
1316
+ const isMarkdown = ext === 'md';
1317
+ // Sub-route: GET /file — re-read file content from disk
1318
+ if (req.method === 'GET' && subRoute === 'file') {
1319
+ try {
1320
+ const content = fs.readFileSync(filePath, 'utf-8');
1321
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
1322
+ res.end(content);
1323
+ }
1324
+ catch (err) {
1325
+ ctx.log('ERROR', `GET /api/annotate/:id/file failed: ${err.message}`);
1326
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1327
+ res.end(JSON.stringify({ error: err.message }));
1328
+ }
1329
+ return;
1330
+ }
1331
+ // Sub-route: POST /save — save file content
1332
+ if (req.method === 'POST' && subRoute === 'save') {
1333
+ // Note: async body reading handled via callback pattern since this function is sync
1334
+ let data = '';
1335
+ req.on('data', (chunk) => data += chunk.toString());
1336
+ req.on('end', () => {
1337
+ try {
1338
+ const parsed = JSON.parse(data || '{}');
1339
+ const fileContent = parsed.content;
1340
+ if (typeof fileContent !== 'string') {
1341
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
1342
+ res.end('Missing content');
1343
+ return;
1344
+ }
1345
+ fs.writeFileSync(filePath, fileContent, 'utf-8');
1346
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1347
+ res.end(JSON.stringify({ ok: true }));
1348
+ }
1349
+ catch (err) {
1350
+ ctx.log('ERROR', `POST /api/annotate/:id/save failed: ${err.message}`);
1351
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1352
+ res.end(JSON.stringify({ error: err.message }));
1353
+ }
1354
+ });
1355
+ return;
1356
+ }
1357
+ // Sub-route: GET /api/mtime — file modification time
1358
+ if (req.method === 'GET' && subRoute === 'api/mtime') {
1359
+ try {
1360
+ const stat = fs.statSync(filePath);
1361
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1362
+ res.end(JSON.stringify({ mtime: stat.mtimeMs }));
1363
+ }
1364
+ catch (err) {
1365
+ ctx.log('ERROR', `GET /api/annotate/:id/api/mtime failed: ${err.message}`);
1366
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1367
+ res.end(JSON.stringify({ error: err.message }));
1368
+ }
1369
+ return;
1370
+ }
1371
+ // Sub-route: GET /api/image, /api/video, /api/model, /api/pdf — raw binary content
1372
+ if (req.method === 'GET' && (subRoute === 'api/image' || subRoute === 'api/video' || subRoute === 'api/model' || subRoute === 'api/pdf')) {
1373
+ try {
1374
+ const data = fs.readFileSync(filePath);
1375
+ const mimeType = getMimeTypeForFile(filePath);
1376
+ res.writeHead(200, {
1377
+ 'Content-Type': mimeType,
1378
+ 'Content-Length': data.length,
1379
+ 'Cache-Control': 'no-cache',
1380
+ });
1381
+ res.end(data);
1382
+ }
1383
+ catch (err) {
1384
+ ctx.log('ERROR', `GET /api/annotate/:id/${subRoute} failed: ${err.message}`);
1385
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1386
+ res.end(JSON.stringify({ error: err.message }));
1387
+ }
1388
+ return;
1389
+ }
1390
+ // Default: serve the annotator HTML template
1391
+ if (req.method === 'GET' && (subRoute === '' || subRoute === undefined)) {
1392
+ try {
1393
+ const templateFile = is3D ? '3d-viewer.html' : 'open.html';
1394
+ const tplPath = path.resolve(__dirname, `../../../templates/${templateFile}`);
1395
+ let html = fs.readFileSync(tplPath, 'utf-8');
1396
+ const fileName = path.basename(filePath);
1397
+ const fileSize = fs.statSync(filePath).size;
1398
+ if (is3D) {
1399
+ html = html.replace(/\{\{FILE\}\}/g, fileName);
1400
+ html = html.replace(/\{\{FILE_PATH_JSON\}\}/g, JSON.stringify(filePath));
1401
+ html = html.replace(/\{\{FORMAT\}\}/g, ext);
1402
+ }
1403
+ else {
1404
+ html = html.replace(/\{\{FILE\}\}/g, fileName);
1405
+ html = html.replace(/\{\{FILE_PATH\}\}/g, filePath);
1406
+ html = html.replace(/\{\{BUILDER_ID\}\}/g, '');
1407
+ html = html.replace(/\{\{LANG\}\}/g, getLanguageForExt(ext));
1408
+ html = html.replace(/\{\{IS_MARKDOWN\}\}/g, String(isMarkdown));
1409
+ html = html.replace(/\{\{IS_IMAGE\}\}/g, String(isImage));
1410
+ html = html.replace(/\{\{IS_VIDEO\}\}/g, String(isVideo));
1411
+ html = html.replace(/\{\{IS_PDF\}\}/g, String(isPdf));
1412
+ html = html.replace(/\{\{FILE_SIZE\}\}/g, String(fileSize));
1413
+ // Inject initialization script (template loads content via fetch)
1414
+ let initScript;
1415
+ if (isImage) {
1416
+ initScript = `initImage(${fileSize});`;
1417
+ }
1418
+ else if (isVideo) {
1419
+ initScript = `initVideo(${fileSize});`;
1420
+ }
1421
+ else if (isPdf) {
1422
+ initScript = `initPdf(${fileSize});`;
1423
+ }
1424
+ else {
1425
+ initScript = `fetch('file').then(r=>r.text()).then(init);`;
1426
+ }
1427
+ html = html.replace('// FILE_CONTENT will be injected by the server', initScript);
1428
+ }
1429
+ // Handle ?line= query param for scroll-to-line
1430
+ const lineParam = url.searchParams.get('line');
1431
+ if (lineParam) {
1432
+ 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>`;
1433
+ html = html.replace('</body>', `${scrollScript}</body>`);
1434
+ }
1435
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1436
+ res.end(html);
1437
+ }
1438
+ catch (err) {
1439
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
1440
+ res.end(`Failed to serve annotator: ${err.message}`);
1441
+ }
1442
+ return;
1443
+ }
1444
+ }
1445
+ //# sourceMappingURL=tower-routes.js.map