@cluesmith/codev 2.0.0-rc.5 → 2.0.0-rc.50
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/bin/af.js +2 -2
- package/bin/consult.js +1 -1
- package/bin/porch.js +6 -35
- package/dashboard/dist/assets/index-BIHeqvy0.css +32 -0
- package/dashboard/dist/assets/index-VvUWRPNP.js +120 -0
- package/dashboard/dist/assets/index-VvUWRPNP.js.map +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/agent-farm/cli.d.ts.map +1 -1
- package/dist/agent-farm/cli.js +93 -64
- package/dist/agent-farm/cli.js.map +1 -1
- package/dist/agent-farm/commands/architect.d.ts.map +1 -1
- package/dist/agent-farm/commands/architect.js +13 -6
- package/dist/agent-farm/commands/architect.js.map +1 -1
- package/dist/agent-farm/commands/attach.d.ts +13 -0
- package/dist/agent-farm/commands/attach.d.ts.map +1 -0
- package/dist/agent-farm/commands/attach.js +179 -0
- package/dist/agent-farm/commands/attach.js.map +1 -0
- package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
- package/dist/agent-farm/commands/cleanup.js +30 -3
- package/dist/agent-farm/commands/cleanup.js.map +1 -1
- package/dist/agent-farm/commands/consult.js +1 -1
- package/dist/agent-farm/commands/consult.js.map +1 -1
- package/dist/agent-farm/commands/index.d.ts +2 -2
- package/dist/agent-farm/commands/index.d.ts.map +1 -1
- package/dist/agent-farm/commands/index.js +2 -2
- package/dist/agent-farm/commands/index.js.map +1 -1
- package/dist/agent-farm/commands/{util.d.ts → shell.d.ts} +5 -5
- package/dist/agent-farm/commands/shell.d.ts.map +1 -0
- package/dist/agent-farm/commands/{util.js → shell.js} +23 -36
- package/dist/agent-farm/commands/shell.js.map +1 -0
- package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
- package/dist/agent-farm/commands/spawn.js +455 -217
- package/dist/agent-farm/commands/spawn.js.map +1 -1
- package/dist/agent-farm/commands/start.d.ts +3 -0
- package/dist/agent-farm/commands/start.d.ts.map +1 -1
- package/dist/agent-farm/commands/start.js +92 -79
- package/dist/agent-farm/commands/start.js.map +1 -1
- package/dist/agent-farm/commands/status.d.ts +2 -0
- package/dist/agent-farm/commands/status.d.ts.map +1 -1
- package/dist/agent-farm/commands/status.js +56 -1
- package/dist/agent-farm/commands/status.js.map +1 -1
- package/dist/agent-farm/commands/stop.d.ts +6 -0
- package/dist/agent-farm/commands/stop.d.ts.map +1 -1
- package/dist/agent-farm/commands/stop.js +115 -11
- package/dist/agent-farm/commands/stop.js.map +1 -1
- package/dist/agent-farm/commands/tower.d.ts +9 -0
- package/dist/agent-farm/commands/tower.d.ts.map +1 -1
- package/dist/agent-farm/commands/tower.js +59 -19
- package/dist/agent-farm/commands/tower.js.map +1 -1
- package/dist/agent-farm/db/index.d.ts.map +1 -1
- package/dist/agent-farm/db/index.js +59 -0
- package/dist/agent-farm/db/index.js.map +1 -1
- package/dist/agent-farm/db/schema.d.ts +2 -2
- package/dist/agent-farm/db/schema.d.ts.map +1 -1
- package/dist/agent-farm/db/schema.js +8 -3
- package/dist/agent-farm/db/schema.js.map +1 -1
- package/dist/agent-farm/db/types.d.ts +3 -0
- package/dist/agent-farm/db/types.d.ts.map +1 -1
- package/dist/agent-farm/db/types.js +3 -0
- package/dist/agent-farm/db/types.js.map +1 -1
- package/dist/agent-farm/hq-connector.d.ts +2 -2
- package/dist/agent-farm/hq-connector.js +2 -2
- package/dist/agent-farm/lib/tower-client.d.ts +157 -0
- package/dist/agent-farm/lib/tower-client.d.ts.map +1 -0
- package/dist/agent-farm/lib/tower-client.js +223 -0
- package/dist/agent-farm/lib/tower-client.js.map +1 -0
- package/dist/agent-farm/servers/tower-server.js +1152 -95
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/state.d.ts +4 -10
- package/dist/agent-farm/state.d.ts.map +1 -1
- package/dist/agent-farm/state.js +30 -31
- package/dist/agent-farm/state.js.map +1 -1
- package/dist/agent-farm/types.d.ts +48 -0
- package/dist/agent-farm/types.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.js +12 -11
- package/dist/agent-farm/utils/config.js.map +1 -1
- package/dist/agent-farm/utils/deps.d.ts.map +1 -1
- package/dist/agent-farm/utils/deps.js +0 -16
- package/dist/agent-farm/utils/deps.js.map +1 -1
- package/dist/agent-farm/utils/notifications.d.ts +30 -0
- package/dist/agent-farm/utils/notifications.d.ts.map +1 -0
- package/dist/agent-farm/utils/notifications.js +121 -0
- package/dist/agent-farm/utils/notifications.js.map +1 -0
- package/dist/agent-farm/utils/server-utils.d.ts +2 -1
- package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
- package/dist/agent-farm/utils/server-utils.js +11 -1
- package/dist/agent-farm/utils/server-utils.js.map +1 -1
- package/dist/agent-farm/utils/shell.d.ts +9 -22
- package/dist/agent-farm/utils/shell.d.ts.map +1 -1
- package/dist/agent-farm/utils/shell.js +34 -34
- package/dist/agent-farm/utils/shell.js.map +1 -1
- package/dist/agent-farm/utils/terminal-ports.d.ts +1 -1
- package/dist/agent-farm/utils/terminal-ports.js +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +5 -54
- package/dist/cli.js.map +1 -1
- package/dist/commands/adopt.d.ts.map +1 -1
- package/dist/commands/adopt.js +39 -4
- package/dist/commands/adopt.js.map +1 -1
- package/dist/commands/consult/index.d.ts.map +1 -1
- package/dist/commands/consult/index.js +63 -3
- package/dist/commands/consult/index.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +0 -15
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +31 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/porch/build-counter.d.ts +5 -0
- package/dist/commands/porch/build-counter.d.ts.map +1 -0
- package/dist/commands/porch/build-counter.js +5 -0
- package/dist/commands/porch/build-counter.js.map +1 -0
- package/dist/commands/porch/checks.d.ts +16 -29
- package/dist/commands/porch/checks.d.ts.map +1 -1
- package/dist/commands/porch/checks.js +90 -144
- package/dist/commands/porch/checks.js.map +1 -1
- package/dist/commands/porch/claude.d.ts +27 -0
- package/dist/commands/porch/claude.d.ts.map +1 -0
- package/dist/commands/porch/claude.js +107 -0
- package/dist/commands/porch/claude.js.map +1 -0
- package/dist/commands/porch/index.d.ts +21 -43
- package/dist/commands/porch/index.d.ts.map +1 -1
- package/dist/commands/porch/index.js +456 -1015
- package/dist/commands/porch/index.js.map +1 -1
- package/dist/commands/porch/plan.d.ts +70 -0
- package/dist/commands/porch/plan.d.ts.map +1 -0
- package/dist/commands/porch/plan.js +190 -0
- package/dist/commands/porch/plan.js.map +1 -0
- package/dist/commands/porch/prompts.d.ts +19 -0
- package/dist/commands/porch/prompts.d.ts.map +1 -0
- package/dist/commands/porch/prompts.js +250 -0
- package/dist/commands/porch/prompts.js.map +1 -0
- package/dist/commands/porch/protocol.d.ts +59 -0
- package/dist/commands/porch/protocol.d.ts.map +1 -0
- package/dist/commands/porch/protocol.js +260 -0
- package/dist/commands/porch/protocol.js.map +1 -0
- package/dist/commands/porch/run.d.ts +40 -0
- package/dist/commands/porch/run.d.ts.map +1 -0
- package/dist/commands/porch/run.js +893 -0
- package/dist/commands/porch/run.js.map +1 -0
- package/dist/commands/porch/state.d.ts +23 -112
- package/dist/commands/porch/state.d.ts.map +1 -1
- package/dist/commands/porch/state.js +81 -699
- package/dist/commands/porch/state.js.map +1 -1
- package/dist/commands/porch/types.d.ts +72 -173
- package/dist/commands/porch/types.d.ts.map +1 -1
- package/dist/commands/porch/types.js +2 -1
- package/dist/commands/porch/types.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +22 -0
- package/dist/commands/update.js.map +1 -1
- package/dist/lib/scaffold.d.ts +24 -0
- package/dist/lib/scaffold.d.ts.map +1 -1
- package/dist/lib/scaffold.js +78 -0
- package/dist/lib/scaffold.js.map +1 -1
- package/dist/terminal/index.d.ts +8 -0
- package/dist/terminal/index.d.ts.map +1 -0
- package/dist/terminal/index.js +5 -0
- package/dist/terminal/index.js.map +1 -0
- package/dist/terminal/pty-manager.d.ts +60 -0
- package/dist/terminal/pty-manager.d.ts.map +1 -0
- package/dist/terminal/pty-manager.js +334 -0
- package/dist/terminal/pty-manager.js.map +1 -0
- package/dist/terminal/pty-session.d.ts +79 -0
- package/dist/terminal/pty-session.d.ts.map +1 -0
- package/dist/terminal/pty-session.js +215 -0
- package/dist/terminal/pty-session.js.map +1 -0
- package/dist/terminal/ring-buffer.d.ts +27 -0
- package/dist/terminal/ring-buffer.d.ts.map +1 -0
- package/dist/terminal/ring-buffer.js +74 -0
- package/dist/terminal/ring-buffer.js.map +1 -0
- package/dist/terminal/ws-protocol.d.ts +27 -0
- package/dist/terminal/ws-protocol.d.ts.map +1 -0
- package/dist/terminal/ws-protocol.js +44 -0
- package/dist/terminal/ws-protocol.js.map +1 -0
- package/package.json +18 -3
- package/skeleton/DEPENDENCIES.md +3 -29
- package/skeleton/builders.md +1 -1
- package/skeleton/protocol-schema.json +282 -0
- package/skeleton/protocols/bugfix/builder-prompt.md +49 -0
- package/skeleton/protocols/bugfix/protocol.json +14 -2
- package/skeleton/protocols/experiment/builder-prompt.md +47 -0
- package/skeleton/protocols/experiment/protocol.json +101 -0
- package/skeleton/protocols/maintain/builder-prompt.md +41 -0
- package/skeleton/protocols/maintain/prompts/audit.md +111 -0
- package/skeleton/protocols/maintain/prompts/clean.md +91 -0
- package/skeleton/protocols/maintain/prompts/sync.md +113 -0
- package/skeleton/protocols/maintain/prompts/verify.md +110 -0
- package/skeleton/protocols/maintain/protocol.json +141 -0
- package/skeleton/protocols/maintain/protocol.md +13 -7
- package/skeleton/protocols/protocol-schema.json +53 -0
- package/skeleton/protocols/spider/builder-prompt.md +53 -0
- package/skeleton/protocols/spider/prompts/implement.md +109 -50
- package/skeleton/protocols/spider/prompts/specify.md +29 -4
- package/skeleton/protocols/spider/protocol.json +96 -154
- package/skeleton/protocols/spider/protocol.md +26 -16
- package/skeleton/protocols/spider/templates/plan.md +14 -0
- package/skeleton/protocols/tick/builder-prompt.md +51 -0
- package/skeleton/protocols/tick/protocol.json +7 -2
- package/skeleton/resources/commands/agent-farm.md +25 -43
- package/skeleton/resources/commands/overview.md +6 -16
- package/skeleton/resources/workflow-reference.md +2 -2
- package/skeleton/roles/architect.md +152 -315
- package/skeleton/roles/builder.md +109 -218
- package/skeleton/templates/AGENTS.md +1 -1
- package/skeleton/templates/CLAUDE.md +1 -1
- package/skeleton/templates/cheatsheet.md +4 -2
- package/templates/dashboard/index.html +17 -43
- package/templates/dashboard/js/dialogs.js +7 -7
- package/templates/dashboard/js/files.js +2 -2
- package/templates/dashboard/js/main.js +3 -3
- package/templates/dashboard/js/projects.js +3 -3
- package/templates/dashboard/js/tabs.js +1 -1
- package/templates/dashboard/js/utils.js +22 -87
- package/templates/tower.html +542 -27
- package/dist/agent-farm/commands/kickoff.d.ts +0 -19
- package/dist/agent-farm/commands/kickoff.d.ts.map +0 -1
- package/dist/agent-farm/commands/kickoff.js +0 -331
- package/dist/agent-farm/commands/kickoff.js.map +0 -1
- package/dist/agent-farm/commands/rename.d.ts +0 -13
- package/dist/agent-farm/commands/rename.d.ts.map +0 -1
- package/dist/agent-farm/commands/rename.js +0 -33
- package/dist/agent-farm/commands/rename.js.map +0 -1
- package/dist/agent-farm/commands/tutorial.d.ts +0 -10
- package/dist/agent-farm/commands/tutorial.d.ts.map +0 -1
- package/dist/agent-farm/commands/tutorial.js +0 -49
- package/dist/agent-farm/commands/tutorial.js.map +0 -1
- package/dist/agent-farm/commands/util.d.ts.map +0 -1
- package/dist/agent-farm/commands/util.js.map +0 -1
- package/dist/agent-farm/servers/dashboard-server.d.ts +0 -7
- package/dist/agent-farm/servers/dashboard-server.d.ts.map +0 -1
- package/dist/agent-farm/servers/dashboard-server.js +0 -1872
- package/dist/agent-farm/servers/dashboard-server.js.map +0 -1
- package/dist/agent-farm/tutorial/index.d.ts +0 -8
- package/dist/agent-farm/tutorial/index.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/index.js +0 -8
- package/dist/agent-farm/tutorial/index.js.map +0 -1
- package/dist/agent-farm/tutorial/prompts.d.ts +0 -57
- package/dist/agent-farm/tutorial/prompts.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/prompts.js +0 -147
- package/dist/agent-farm/tutorial/prompts.js.map +0 -1
- package/dist/agent-farm/tutorial/runner.d.ts +0 -52
- package/dist/agent-farm/tutorial/runner.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/runner.js +0 -204
- package/dist/agent-farm/tutorial/runner.js.map +0 -1
- package/dist/agent-farm/tutorial/state.d.ts +0 -26
- package/dist/agent-farm/tutorial/state.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/state.js +0 -89
- package/dist/agent-farm/tutorial/state.js.map +0 -1
- package/dist/agent-farm/tutorial/steps/first-spec.d.ts +0 -7
- package/dist/agent-farm/tutorial/steps/first-spec.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/steps/first-spec.js +0 -136
- package/dist/agent-farm/tutorial/steps/first-spec.js.map +0 -1
- package/dist/agent-farm/tutorial/steps/implementation.d.ts +0 -7
- package/dist/agent-farm/tutorial/steps/implementation.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/steps/implementation.js +0 -76
- package/dist/agent-farm/tutorial/steps/implementation.js.map +0 -1
- package/dist/agent-farm/tutorial/steps/index.d.ts +0 -10
- package/dist/agent-farm/tutorial/steps/index.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/steps/index.js +0 -10
- package/dist/agent-farm/tutorial/steps/index.js.map +0 -1
- package/dist/agent-farm/tutorial/steps/planning.d.ts +0 -7
- package/dist/agent-farm/tutorial/steps/planning.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/steps/planning.js +0 -143
- package/dist/agent-farm/tutorial/steps/planning.js.map +0 -1
- package/dist/agent-farm/tutorial/steps/review.d.ts +0 -7
- package/dist/agent-farm/tutorial/steps/review.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/steps/review.js +0 -78
- package/dist/agent-farm/tutorial/steps/review.js.map +0 -1
- package/dist/agent-farm/tutorial/steps/setup.d.ts +0 -7
- package/dist/agent-farm/tutorial/steps/setup.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/steps/setup.js +0 -126
- package/dist/agent-farm/tutorial/steps/setup.js.map +0 -1
- package/dist/agent-farm/tutorial/steps/welcome.d.ts +0 -7
- package/dist/agent-farm/tutorial/steps/welcome.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/steps/welcome.js +0 -50
- package/dist/agent-farm/tutorial/steps/welcome.js.map +0 -1
- package/dist/commands/pcheck/cache.d.ts +0 -48
- package/dist/commands/pcheck/cache.d.ts.map +0 -1
- package/dist/commands/pcheck/cache.js +0 -170
- package/dist/commands/pcheck/cache.js.map +0 -1
- package/dist/commands/pcheck/evaluator.d.ts +0 -15
- package/dist/commands/pcheck/evaluator.d.ts.map +0 -1
- package/dist/commands/pcheck/evaluator.js +0 -246
- package/dist/commands/pcheck/evaluator.js.map +0 -1
- package/dist/commands/pcheck/index.d.ts +0 -12
- package/dist/commands/pcheck/index.d.ts.map +0 -1
- package/dist/commands/pcheck/index.js +0 -249
- package/dist/commands/pcheck/index.js.map +0 -1
- package/dist/commands/pcheck/parser.d.ts +0 -39
- package/dist/commands/pcheck/parser.d.ts.map +0 -1
- package/dist/commands/pcheck/parser.js +0 -155
- package/dist/commands/pcheck/parser.js.map +0 -1
- package/dist/commands/pcheck/types.d.ts +0 -82
- package/dist/commands/pcheck/types.d.ts.map +0 -1
- package/dist/commands/pcheck/types.js +0 -5
- package/dist/commands/pcheck/types.js.map +0 -1
- package/dist/commands/porch/consultation.d.ts +0 -56
- package/dist/commands/porch/consultation.d.ts.map +0 -1
- package/dist/commands/porch/consultation.js +0 -330
- package/dist/commands/porch/consultation.js.map +0 -1
- package/dist/commands/porch/notifications.d.ts +0 -99
- package/dist/commands/porch/notifications.d.ts.map +0 -1
- package/dist/commands/porch/notifications.js +0 -223
- package/dist/commands/porch/notifications.js.map +0 -1
- package/dist/commands/porch/plan-parser.d.ts +0 -38
- package/dist/commands/porch/plan-parser.d.ts.map +0 -1
- package/dist/commands/porch/plan-parser.js +0 -166
- package/dist/commands/porch/plan-parser.js.map +0 -1
- package/dist/commands/porch/protocol-loader.d.ts +0 -46
- package/dist/commands/porch/protocol-loader.d.ts.map +0 -1
- package/dist/commands/porch/protocol-loader.js +0 -253
- package/dist/commands/porch/protocol-loader.js.map +0 -1
- package/dist/commands/porch/signal-parser.d.ts +0 -88
- package/dist/commands/porch/signal-parser.d.ts.map +0 -1
- package/dist/commands/porch/signal-parser.js +0 -148
- package/dist/commands/porch/signal-parser.js.map +0 -1
- package/dist/commands/tower.d.ts +0 -16
- package/dist/commands/tower.d.ts.map +0 -1
- package/dist/commands/tower.js +0 -21
- package/dist/commands/tower.js.map +0 -1
- package/skeleton/config.json +0 -7
- package/skeleton/porch/protocols/bugfix.json +0 -85
- package/skeleton/porch/protocols/spider.json +0 -135
- package/skeleton/porch/protocols/tick.json +0 -76
- package/skeleton/protocols/spider/prompts/defend.md +0 -215
- package/skeleton/protocols/spider/prompts/evaluate.md +0 -241
- package/templates/dashboard/css/activity.css +0 -151
- package/templates/dashboard/js/activity.js +0 -112
|
@@ -7,17 +7,182 @@ import http from 'node:http';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import net from 'node:net';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
10
11
|
import { spawn, execSync } from 'node:child_process';
|
|
11
12
|
import { homedir } from 'node:os';
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
13
14
|
import { Command } from 'commander';
|
|
15
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
14
16
|
import { getGlobalDb } from '../db/index.js';
|
|
15
17
|
import { cleanupStaleEntries } from '../utils/port-registry.js';
|
|
16
18
|
import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js';
|
|
19
|
+
import { TerminalManager } from '../../terminal/pty-manager.js';
|
|
20
|
+
import { encodeData, encodeControl, decodeFrame } from '../../terminal/ws-protocol.js';
|
|
17
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
18
22
|
const __dirname = path.dirname(__filename);
|
|
19
23
|
// Default port for tower dashboard
|
|
20
24
|
const DEFAULT_PORT = 4100;
|
|
25
|
+
// Rate limiting for activation requests (Spec 0090 Phase 1)
|
|
26
|
+
// Simple in-memory rate limiter: 10 activations per minute per client
|
|
27
|
+
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
|
28
|
+
const RATE_LIMIT_MAX = 10;
|
|
29
|
+
const activationRateLimits = new Map();
|
|
30
|
+
/**
|
|
31
|
+
* Check if a client has exceeded the rate limit for activations
|
|
32
|
+
* Returns true if rate limit exceeded, false if allowed
|
|
33
|
+
*/
|
|
34
|
+
function isRateLimited(clientIp) {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
const entry = activationRateLimits.get(clientIp);
|
|
37
|
+
if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
|
|
38
|
+
// New window
|
|
39
|
+
activationRateLimits.set(clientIp, { count: 1, windowStart: now });
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (entry.count >= RATE_LIMIT_MAX) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
entry.count++;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Clean up old rate limit entries periodically
|
|
50
|
+
*/
|
|
51
|
+
function cleanupRateLimits() {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
for (const [ip, entry] of activationRateLimits.entries()) {
|
|
54
|
+
if (now - entry.windowStart >= RATE_LIMIT_WINDOW_MS * 2) {
|
|
55
|
+
activationRateLimits.delete(ip);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Cleanup stale rate limit entries every 5 minutes
|
|
60
|
+
setInterval(cleanupRateLimits, 5 * 60 * 1000);
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// PHASE 2 & 4: Terminal Management (Spec 0090)
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// Global TerminalManager instance for tower-managed terminals
|
|
65
|
+
// Uses a temporary directory as projectRoot since terminals can be for any project
|
|
66
|
+
let terminalManager = null;
|
|
67
|
+
const projectTerminals = new Map();
|
|
68
|
+
/**
|
|
69
|
+
* Get or create project terminal registry entry
|
|
70
|
+
*/
|
|
71
|
+
function getProjectTerminalsEntry(projectPath) {
|
|
72
|
+
let entry = projectTerminals.get(projectPath);
|
|
73
|
+
if (!entry) {
|
|
74
|
+
entry = { builders: new Map(), shells: new Map() };
|
|
75
|
+
projectTerminals.set(projectPath, entry);
|
|
76
|
+
}
|
|
77
|
+
return entry;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Generate next shell ID for a project
|
|
81
|
+
*/
|
|
82
|
+
function getNextShellId(projectPath) {
|
|
83
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
84
|
+
let maxId = 0;
|
|
85
|
+
for (const id of entry.shells.keys()) {
|
|
86
|
+
const num = parseInt(id.replace('shell-', ''), 10);
|
|
87
|
+
if (!isNaN(num) && num > maxId)
|
|
88
|
+
maxId = num;
|
|
89
|
+
}
|
|
90
|
+
return `shell-${maxId + 1}`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Get or create the global TerminalManager instance
|
|
94
|
+
*/
|
|
95
|
+
function getTerminalManager() {
|
|
96
|
+
if (!terminalManager) {
|
|
97
|
+
// Use a neutral projectRoot - terminals specify their own cwd
|
|
98
|
+
const projectRoot = process.env.HOME || '/tmp';
|
|
99
|
+
terminalManager = new TerminalManager({
|
|
100
|
+
projectRoot,
|
|
101
|
+
logDir: path.join(homedir(), '.agent-farm', 'logs'),
|
|
102
|
+
maxSessions: 100,
|
|
103
|
+
ringBufferLines: 1000,
|
|
104
|
+
diskLogEnabled: true,
|
|
105
|
+
diskLogMaxBytes: 50 * 1024 * 1024,
|
|
106
|
+
reconnectTimeoutMs: 300_000,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return terminalManager;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Handle WebSocket connection to a terminal session
|
|
113
|
+
* Uses hybrid binary protocol (Spec 0085):
|
|
114
|
+
* - 0x00 prefix: Control frame (JSON)
|
|
115
|
+
* - 0x01 prefix: Data frame (raw PTY bytes)
|
|
116
|
+
*/
|
|
117
|
+
function handleTerminalWebSocket(ws, session, req) {
|
|
118
|
+
const resumeSeq = req.headers['x-session-resume'];
|
|
119
|
+
// Create a client adapter for the PTY session
|
|
120
|
+
// Uses binary protocol for data frames
|
|
121
|
+
const client = {
|
|
122
|
+
send: (data) => {
|
|
123
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
124
|
+
// Encode as binary data frame (0x01 prefix)
|
|
125
|
+
ws.send(encodeData(data));
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
// Attach client to session and get replay data
|
|
130
|
+
let replayLines;
|
|
131
|
+
if (resumeSeq && typeof resumeSeq === 'string') {
|
|
132
|
+
replayLines = session.attachResume(client, parseInt(resumeSeq, 10));
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
replayLines = session.attach(client);
|
|
136
|
+
}
|
|
137
|
+
// Send replay data as binary data frame
|
|
138
|
+
if (replayLines.length > 0) {
|
|
139
|
+
const replayData = replayLines.join('\n');
|
|
140
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
141
|
+
ws.send(encodeData(replayData));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Handle incoming messages from client (binary protocol)
|
|
145
|
+
ws.on('message', (rawData) => {
|
|
146
|
+
try {
|
|
147
|
+
const frame = decodeFrame(Buffer.from(rawData));
|
|
148
|
+
if (frame.type === 'data') {
|
|
149
|
+
// Write raw input to terminal
|
|
150
|
+
session.write(frame.data.toString('utf-8'));
|
|
151
|
+
}
|
|
152
|
+
else if (frame.type === 'control') {
|
|
153
|
+
// Handle control messages
|
|
154
|
+
const msg = frame.message;
|
|
155
|
+
if (msg.type === 'resize') {
|
|
156
|
+
const cols = msg.payload.cols;
|
|
157
|
+
const rows = msg.payload.rows;
|
|
158
|
+
if (typeof cols === 'number' && typeof rows === 'number') {
|
|
159
|
+
session.resize(cols, rows);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else if (msg.type === 'ping') {
|
|
163
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
164
|
+
ws.send(encodeControl({ type: 'pong', payload: {} }));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// If decode fails, try treating as raw UTF-8 input (for simpler clients)
|
|
171
|
+
try {
|
|
172
|
+
session.write(rawData.toString('utf-8'));
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Ignore malformed input
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
ws.on('close', () => {
|
|
180
|
+
session.detach(client);
|
|
181
|
+
});
|
|
182
|
+
ws.on('error', () => {
|
|
183
|
+
session.detach(client);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
21
186
|
// Parse arguments with Commander
|
|
22
187
|
const program = new Command()
|
|
23
188
|
.name('tower-server')
|
|
@@ -52,6 +217,41 @@ function log(level, message) {
|
|
|
52
217
|
}
|
|
53
218
|
}
|
|
54
219
|
}
|
|
220
|
+
// Global exception handlers to catch uncaught errors
|
|
221
|
+
process.on('uncaughtException', (err) => {
|
|
222
|
+
log('ERROR', `Uncaught exception: ${err.message}\n${err.stack}`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
});
|
|
225
|
+
process.on('unhandledRejection', (reason) => {
|
|
226
|
+
const message = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason);
|
|
227
|
+
log('ERROR', `Unhandled rejection: ${message}`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
});
|
|
230
|
+
// Graceful shutdown handler (Phase 2 - Spec 0090)
|
|
231
|
+
async function gracefulShutdown(signal) {
|
|
232
|
+
log('INFO', `Received ${signal}, starting graceful shutdown...`);
|
|
233
|
+
// 1. Stop accepting new connections
|
|
234
|
+
server?.close();
|
|
235
|
+
// 2. Close all WebSocket connections
|
|
236
|
+
if (terminalWss) {
|
|
237
|
+
for (const client of terminalWss.clients) {
|
|
238
|
+
client.close(1001, 'Server shutting down');
|
|
239
|
+
}
|
|
240
|
+
terminalWss.close();
|
|
241
|
+
}
|
|
242
|
+
// 3. Kill all PTY sessions
|
|
243
|
+
if (terminalManager) {
|
|
244
|
+
log('INFO', 'Shutting down terminal manager...');
|
|
245
|
+
terminalManager.shutdown();
|
|
246
|
+
}
|
|
247
|
+
// 4. Stop cloudflared tunnel if running
|
|
248
|
+
stopTunnel();
|
|
249
|
+
log('INFO', 'Graceful shutdown complete');
|
|
250
|
+
process.exit(0);
|
|
251
|
+
}
|
|
252
|
+
// Catch signals for clean shutdown
|
|
253
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
254
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
55
255
|
if (isNaN(port) || port < 1 || port > 65535) {
|
|
56
256
|
log('ERROR', `Invalid port "${portArg}". Must be a number between 1 and 65535.`);
|
|
57
257
|
process.exit(1);
|
|
@@ -97,6 +297,200 @@ async function isPortListening(port) {
|
|
|
97
297
|
function getProjectName(projectPath) {
|
|
98
298
|
return path.basename(projectPath);
|
|
99
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Get the base port for a project from global.db
|
|
302
|
+
* Returns null if project not found or not running
|
|
303
|
+
*/
|
|
304
|
+
async function getBasePortForProject(projectPath) {
|
|
305
|
+
try {
|
|
306
|
+
const db = getGlobalDb();
|
|
307
|
+
const row = db.prepare('SELECT base_port FROM port_allocations WHERE project_path = ?').get(projectPath);
|
|
308
|
+
if (!row)
|
|
309
|
+
return null;
|
|
310
|
+
// Check if actually running
|
|
311
|
+
const isRunning = await isPortListening(row.base_port);
|
|
312
|
+
return isRunning ? row.base_port : null;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Cloudflared tunnel management
|
|
319
|
+
let tunnelProcess = null;
|
|
320
|
+
let tunnelUrl = null;
|
|
321
|
+
function isCloudflaredInstalled() {
|
|
322
|
+
try {
|
|
323
|
+
execSync('which cloudflared', { stdio: 'ignore' });
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function getTunnelStatus() {
|
|
331
|
+
return {
|
|
332
|
+
available: isCloudflaredInstalled(),
|
|
333
|
+
running: tunnelProcess !== null && tunnelUrl !== null,
|
|
334
|
+
url: tunnelUrl,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
async function startTunnel(port) {
|
|
338
|
+
if (!isCloudflaredInstalled()) {
|
|
339
|
+
return { success: false, error: 'cloudflared not installed. Install with: brew install cloudflared' };
|
|
340
|
+
}
|
|
341
|
+
if (tunnelProcess) {
|
|
342
|
+
return { success: true, url: tunnelUrl || undefined };
|
|
343
|
+
}
|
|
344
|
+
return new Promise((resolve) => {
|
|
345
|
+
tunnelProcess = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
|
|
346
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
347
|
+
});
|
|
348
|
+
const handleOutput = (data) => {
|
|
349
|
+
const text = data.toString();
|
|
350
|
+
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
351
|
+
if (match && !tunnelUrl) {
|
|
352
|
+
tunnelUrl = match[0];
|
|
353
|
+
log('INFO', `Cloudflared tunnel started: ${tunnelUrl}`);
|
|
354
|
+
resolve({ success: true, url: tunnelUrl });
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
tunnelProcess.stdout?.on('data', handleOutput);
|
|
358
|
+
tunnelProcess.stderr?.on('data', handleOutput);
|
|
359
|
+
tunnelProcess.on('close', (code) => {
|
|
360
|
+
log('INFO', `Cloudflared tunnel closed with code ${code}`);
|
|
361
|
+
tunnelProcess = null;
|
|
362
|
+
tunnelUrl = null;
|
|
363
|
+
});
|
|
364
|
+
// Timeout after 30 seconds
|
|
365
|
+
setTimeout(() => {
|
|
366
|
+
if (!tunnelUrl) {
|
|
367
|
+
tunnelProcess?.kill();
|
|
368
|
+
tunnelProcess = null;
|
|
369
|
+
resolve({ success: false, error: 'Tunnel startup timed out' });
|
|
370
|
+
}
|
|
371
|
+
}, 30000);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
function stopTunnel() {
|
|
375
|
+
if (tunnelProcess) {
|
|
376
|
+
tunnelProcess.kill();
|
|
377
|
+
tunnelProcess = null;
|
|
378
|
+
tunnelUrl = null;
|
|
379
|
+
log('INFO', 'Cloudflared tunnel stopped');
|
|
380
|
+
}
|
|
381
|
+
return { success: true };
|
|
382
|
+
}
|
|
383
|
+
const sseClients = [];
|
|
384
|
+
let notificationIdCounter = 0;
|
|
385
|
+
/**
|
|
386
|
+
* Broadcast a notification to all connected SSE clients
|
|
387
|
+
*/
|
|
388
|
+
function broadcastNotification(notification) {
|
|
389
|
+
const id = ++notificationIdCounter;
|
|
390
|
+
const data = JSON.stringify({ ...notification, id });
|
|
391
|
+
const message = `id: ${id}\ndata: ${data}\n\n`;
|
|
392
|
+
for (const client of sseClients) {
|
|
393
|
+
try {
|
|
394
|
+
client.res.write(message);
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// Client disconnected, will be cleaned up on next iteration
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Get gate status for a project by querying its dashboard API.
|
|
403
|
+
* Uses timeout to prevent hung projects from stalling tower status.
|
|
404
|
+
*/
|
|
405
|
+
async function getGateStatusForProject(basePort) {
|
|
406
|
+
const controller = new AbortController();
|
|
407
|
+
const timeout = setTimeout(() => controller.abort(), 2000); // 2-second timeout
|
|
408
|
+
try {
|
|
409
|
+
const response = await fetch(`http://localhost:${basePort}/api/status`, {
|
|
410
|
+
signal: controller.signal,
|
|
411
|
+
});
|
|
412
|
+
clearTimeout(timeout);
|
|
413
|
+
if (!response.ok)
|
|
414
|
+
return { hasGate: false };
|
|
415
|
+
const projectStatus = await response.json();
|
|
416
|
+
// Check if any builder has a pending gate
|
|
417
|
+
const builderWithGate = projectStatus.builders?.find((b) => b.gateStatus?.waiting || b.status === 'gate-pending');
|
|
418
|
+
if (builderWithGate) {
|
|
419
|
+
return {
|
|
420
|
+
hasGate: true,
|
|
421
|
+
gateName: builderWithGate.gateStatus?.gateName || builderWithGate.currentGate,
|
|
422
|
+
builderId: builderWithGate.id,
|
|
423
|
+
timestamp: builderWithGate.gateStatus?.timestamp || Date.now(),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// Project dashboard not responding or timeout
|
|
429
|
+
}
|
|
430
|
+
return { hasGate: false };
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Get terminal list for a project from tower's registry.
|
|
434
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server fetch.
|
|
435
|
+
* Returns architect, builders, and shells with their URLs.
|
|
436
|
+
*/
|
|
437
|
+
function getTerminalsForProject(projectPath, proxyUrl) {
|
|
438
|
+
const entry = projectTerminals.get(projectPath);
|
|
439
|
+
const manager = getTerminalManager();
|
|
440
|
+
const terminals = [];
|
|
441
|
+
if (!entry) {
|
|
442
|
+
return { terminals: [], gateStatus: { hasGate: false } };
|
|
443
|
+
}
|
|
444
|
+
// Add architect terminal
|
|
445
|
+
if (entry.architect) {
|
|
446
|
+
const session = manager.getSession(entry.architect);
|
|
447
|
+
if (session) {
|
|
448
|
+
terminals.push({
|
|
449
|
+
type: 'architect',
|
|
450
|
+
id: 'architect',
|
|
451
|
+
label: 'Architect',
|
|
452
|
+
url: `${proxyUrl}?tab=architect`,
|
|
453
|
+
active: true,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// Add builder terminals
|
|
458
|
+
for (const [builderId] of entry.builders) {
|
|
459
|
+
const terminalId = entry.builders.get(builderId);
|
|
460
|
+
if (terminalId) {
|
|
461
|
+
const session = manager.getSession(terminalId);
|
|
462
|
+
if (session) {
|
|
463
|
+
terminals.push({
|
|
464
|
+
type: 'builder',
|
|
465
|
+
id: builderId,
|
|
466
|
+
label: `Builder ${builderId}`,
|
|
467
|
+
url: `${proxyUrl}?tab=builder-${builderId}`,
|
|
468
|
+
active: true,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// Add shell terminals
|
|
474
|
+
for (const [shellId] of entry.shells) {
|
|
475
|
+
const terminalId = entry.shells.get(shellId);
|
|
476
|
+
if (terminalId) {
|
|
477
|
+
const session = manager.getSession(terminalId);
|
|
478
|
+
if (session) {
|
|
479
|
+
terminals.push({
|
|
480
|
+
type: 'shell',
|
|
481
|
+
id: shellId,
|
|
482
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
483
|
+
url: `${proxyUrl}?tab=shell-${shellId}`,
|
|
484
|
+
active: true,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Gate status - builders don't have gate tracking yet in tower
|
|
490
|
+
// TODO: Add gate status tracking when porch integration is updated
|
|
491
|
+
const gateStatus = { hasGate: false };
|
|
492
|
+
return { terminals, gateStatus };
|
|
493
|
+
}
|
|
100
494
|
/**
|
|
101
495
|
* Get all instances with their status
|
|
102
496
|
*/
|
|
@@ -110,35 +504,37 @@ async function getInstances() {
|
|
|
110
504
|
}
|
|
111
505
|
const basePort = allocation.base_port;
|
|
112
506
|
const dashboardPort = basePort;
|
|
113
|
-
const architectPort = basePort + 1;
|
|
114
507
|
// Check if dashboard is running (main indicator of running instance)
|
|
508
|
+
// All terminals are multiplexed on dashboardPort via WebSocket (Spec 0085)
|
|
115
509
|
const dashboardActive = await isPortListening(dashboardPort);
|
|
116
|
-
//
|
|
117
|
-
const
|
|
510
|
+
// Encode project path for proxy URL
|
|
511
|
+
const encodedPath = Buffer.from(allocation.project_path).toString('base64url');
|
|
512
|
+
const proxyUrl = `/project/${encodedPath}/`;
|
|
513
|
+
// Get terminals and gate status from tower's registry
|
|
514
|
+
// Phase 4 (Spec 0090): Tower manages terminals directly
|
|
515
|
+
const { terminals, gateStatus } = getTerminalsForProject(allocation.project_path, proxyUrl);
|
|
118
516
|
const ports = [
|
|
119
517
|
{
|
|
120
518
|
type: 'Dashboard',
|
|
121
519
|
port: dashboardPort,
|
|
122
|
-
url:
|
|
520
|
+
url: proxyUrl, // Use tower proxy URL, not raw localhost
|
|
123
521
|
active: dashboardActive,
|
|
124
522
|
},
|
|
125
|
-
{
|
|
126
|
-
type: 'Architect',
|
|
127
|
-
port: architectPort,
|
|
128
|
-
url: `http://localhost:${architectPort}`,
|
|
129
|
-
active: architectActive,
|
|
130
|
-
},
|
|
131
523
|
];
|
|
132
524
|
instances.push({
|
|
133
525
|
projectPath: allocation.project_path,
|
|
134
526
|
projectName: getProjectName(allocation.project_path),
|
|
135
527
|
basePort,
|
|
136
528
|
dashboardPort,
|
|
137
|
-
architectPort,
|
|
529
|
+
architectPort: basePort + 1, // Legacy field for backward compat
|
|
138
530
|
registered: allocation.registered_at,
|
|
139
531
|
lastUsed: allocation.last_used_at,
|
|
140
532
|
running: dashboardActive,
|
|
533
|
+
proxyUrl, // Tower proxy URL for dashboard
|
|
534
|
+
architectUrl: `${proxyUrl}?tab=architect`, // Direct URL to architect terminal
|
|
535
|
+
terminals, // All available terminals
|
|
141
536
|
ports,
|
|
537
|
+
gateStatus,
|
|
142
538
|
});
|
|
143
539
|
}
|
|
144
540
|
// Sort: running first, then by last used (most recent first)
|
|
@@ -213,8 +609,8 @@ async function getDirectorySuggestions(inputPath) {
|
|
|
213
609
|
}
|
|
214
610
|
/**
|
|
215
611
|
* Launch a new agent-farm instance
|
|
216
|
-
*
|
|
217
|
-
* Auto-adopts non-codev directories
|
|
612
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server
|
|
613
|
+
* Auto-adopts non-codev directories and creates architect terminal
|
|
218
614
|
*/
|
|
219
615
|
async function launchInstance(projectPath) {
|
|
220
616
|
// Clean up stale port allocations before launching (handles machine restarts)
|
|
@@ -246,74 +642,76 @@ async function launchInstance(projectPath) {
|
|
|
246
642
|
return { success: false, error: `Failed to adopt codev: ${err.message}` };
|
|
247
643
|
}
|
|
248
644
|
}
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
// SECURITY: Use spawn with cwd option to avoid command injection
|
|
252
|
-
// Do NOT use bash -c with string concatenation
|
|
645
|
+
// Phase 4 (Spec 0090): Tower manages terminals directly
|
|
646
|
+
// No dashboard-server spawning - tower handles everything
|
|
253
647
|
try {
|
|
254
|
-
//
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
});
|
|
266
|
-
// Small delay to ensure cleanup
|
|
267
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
268
|
-
// Now start using codev af (avoids npx caching issues)
|
|
269
|
-
// Capture output to detect errors
|
|
270
|
-
const child = spawn('codev', ['af', 'start'], {
|
|
271
|
-
detached: true,
|
|
272
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
273
|
-
cwd: projectPath,
|
|
274
|
-
});
|
|
275
|
-
let stdout = '';
|
|
276
|
-
let stderr = '';
|
|
277
|
-
child.stdout?.on('data', (data) => {
|
|
278
|
-
stdout += data.toString();
|
|
279
|
-
});
|
|
280
|
-
child.stderr?.on('data', (data) => {
|
|
281
|
-
stderr += data.toString();
|
|
282
|
-
});
|
|
283
|
-
// Wait a moment for the process to start (or fail)
|
|
284
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
285
|
-
// Check if the dashboard port is listening
|
|
286
|
-
// Resolve symlinks (macOS /tmp -> /private/tmp)
|
|
648
|
+
// Clear any stale state file
|
|
649
|
+
const stateFile = path.join(projectPath, '.agent-farm', 'state.json');
|
|
650
|
+
if (fs.existsSync(stateFile)) {
|
|
651
|
+
try {
|
|
652
|
+
fs.unlinkSync(stateFile);
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
// Ignore - file might not exist or be locked
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// Ensure project has port allocation
|
|
287
659
|
const resolvedPath = fs.realpathSync(projectPath);
|
|
288
660
|
const db = getGlobalDb();
|
|
289
|
-
|
|
661
|
+
let allocation = db
|
|
290
662
|
.prepare('SELECT base_port FROM port_allocations WHERE project_path = ? OR project_path = ?')
|
|
291
663
|
.get(projectPath, resolvedPath);
|
|
292
|
-
if (allocation) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
664
|
+
if (!allocation) {
|
|
665
|
+
// Allocate a new port for this project
|
|
666
|
+
// Find the next available port block (starting at 4200, incrementing by 100)
|
|
667
|
+
const existingPorts = db
|
|
668
|
+
.prepare('SELECT base_port FROM port_allocations ORDER BY base_port')
|
|
669
|
+
.all();
|
|
670
|
+
let nextPort = 4200;
|
|
671
|
+
for (const { base_port } of existingPorts) {
|
|
672
|
+
if (base_port >= nextPort) {
|
|
673
|
+
nextPort = base_port + 100;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
db.prepare("INSERT INTO port_allocations (project_path, project_name, base_port, created_at) VALUES (?, ?, ?, datetime('now'))").run(resolvedPath, path.basename(projectPath), nextPort);
|
|
677
|
+
allocation = { base_port: nextPort };
|
|
678
|
+
log('INFO', `Allocated port ${nextPort} for project: ${projectPath}`);
|
|
679
|
+
}
|
|
680
|
+
// Initialize project terminal entry
|
|
681
|
+
const entry = getProjectTerminalsEntry(resolvedPath);
|
|
682
|
+
// Create architect terminal if not already present
|
|
683
|
+
if (!entry.architect) {
|
|
684
|
+
const manager = getTerminalManager();
|
|
685
|
+
// Read af-config.json to get the architect command
|
|
686
|
+
let architectCmd = 'claude';
|
|
687
|
+
const configPath = path.join(projectPath, 'af-config.json');
|
|
688
|
+
if (fs.existsSync(configPath)) {
|
|
689
|
+
try {
|
|
690
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
691
|
+
if (config.shell?.architect) {
|
|
692
|
+
architectCmd = config.shell.architect;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
// Ignore config read errors, use default
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
try {
|
|
700
|
+
const session = await manager.createSession({
|
|
701
|
+
command: architectCmd,
|
|
702
|
+
args: [],
|
|
703
|
+
cwd: projectPath,
|
|
704
|
+
label: 'Architect',
|
|
705
|
+
env: process.env,
|
|
706
|
+
});
|
|
707
|
+
entry.architect = session.id;
|
|
708
|
+
log('INFO', `Created architect terminal for project: ${projectPath}`);
|
|
709
|
+
}
|
|
710
|
+
catch (err) {
|
|
711
|
+
log('WARN', `Failed to create architect terminal: ${err.message}`);
|
|
712
|
+
// Don't fail the launch - project is still active, just without architect
|
|
713
|
+
}
|
|
714
|
+
}
|
|
317
715
|
return { success: true, adopted };
|
|
318
716
|
}
|
|
319
717
|
catch (err) {
|
|
@@ -334,27 +732,55 @@ function getProcessOnPort(targetPort) {
|
|
|
334
732
|
}
|
|
335
733
|
}
|
|
336
734
|
/**
|
|
337
|
-
* Stop an agent-farm instance by killing
|
|
735
|
+
* Stop an agent-farm instance by killing all its terminals
|
|
736
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly
|
|
338
737
|
*/
|
|
339
|
-
async function stopInstance(
|
|
738
|
+
async function stopInstance(projectPath) {
|
|
340
739
|
const stopped = [];
|
|
341
|
-
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
740
|
+
const manager = getTerminalManager();
|
|
741
|
+
// Resolve symlinks for consistent lookup
|
|
742
|
+
let resolvedPath = projectPath;
|
|
743
|
+
try {
|
|
744
|
+
if (fs.existsSync(projectPath)) {
|
|
745
|
+
resolvedPath = fs.realpathSync(projectPath);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
// Ignore - use original path
|
|
750
|
+
}
|
|
751
|
+
// Get project terminals
|
|
752
|
+
const entry = projectTerminals.get(resolvedPath) || projectTerminals.get(projectPath);
|
|
753
|
+
if (entry) {
|
|
754
|
+
// Kill architect
|
|
755
|
+
if (entry.architect) {
|
|
756
|
+
const session = manager.getSession(entry.architect);
|
|
757
|
+
if (session) {
|
|
758
|
+
manager.killSession(entry.architect);
|
|
759
|
+
stopped.push(session.pid);
|
|
350
760
|
}
|
|
351
|
-
|
|
352
|
-
|
|
761
|
+
}
|
|
762
|
+
// Kill all shells
|
|
763
|
+
for (const terminalId of entry.shells.values()) {
|
|
764
|
+
const session = manager.getSession(terminalId);
|
|
765
|
+
if (session) {
|
|
766
|
+
manager.killSession(terminalId);
|
|
767
|
+
stopped.push(session.pid);
|
|
353
768
|
}
|
|
354
769
|
}
|
|
770
|
+
// Kill all builders
|
|
771
|
+
for (const terminalId of entry.builders.values()) {
|
|
772
|
+
const session = manager.getSession(terminalId);
|
|
773
|
+
if (session) {
|
|
774
|
+
manager.killSession(terminalId);
|
|
775
|
+
stopped.push(session.pid);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// Clear project from registry
|
|
779
|
+
projectTerminals.delete(resolvedPath);
|
|
780
|
+
projectTerminals.delete(projectPath);
|
|
355
781
|
}
|
|
356
782
|
if (stopped.length === 0) {
|
|
357
|
-
return { success: true, error: 'No
|
|
783
|
+
return { success: true, error: 'No terminals found to stop', stopped };
|
|
358
784
|
}
|
|
359
785
|
return { success: true, stopped };
|
|
360
786
|
}
|
|
@@ -375,6 +801,54 @@ function findTemplatePath() {
|
|
|
375
801
|
// escapeHtml, parseJsonBody, isRequestAllowed imported from ../utils/server-utils.js
|
|
376
802
|
// Find template path
|
|
377
803
|
const templatePath = findTemplatePath();
|
|
804
|
+
// WebSocket server for terminal connections (Phase 2 - Spec 0090)
|
|
805
|
+
let terminalWss = null;
|
|
806
|
+
// React dashboard dist path (for serving directly from tower)
|
|
807
|
+
// React dashboard dist path (for serving directly from tower)
|
|
808
|
+
// Phase 4 (Spec 0090): Tower serves everything directly, no dashboard-server
|
|
809
|
+
const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
|
|
810
|
+
const hasReactDashboard = fs.existsSync(reactDashboardPath);
|
|
811
|
+
if (hasReactDashboard) {
|
|
812
|
+
log('INFO', `React dashboard found at: ${reactDashboardPath}`);
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
log('WARN', 'React dashboard not found - project dashboards will not work');
|
|
816
|
+
}
|
|
817
|
+
// MIME types for static file serving
|
|
818
|
+
const MIME_TYPES = {
|
|
819
|
+
'.html': 'text/html',
|
|
820
|
+
'.js': 'application/javascript',
|
|
821
|
+
'.css': 'text/css',
|
|
822
|
+
'.json': 'application/json',
|
|
823
|
+
'.png': 'image/png',
|
|
824
|
+
'.jpg': 'image/jpeg',
|
|
825
|
+
'.gif': 'image/gif',
|
|
826
|
+
'.svg': 'image/svg+xml',
|
|
827
|
+
'.ico': 'image/x-icon',
|
|
828
|
+
'.woff': 'font/woff',
|
|
829
|
+
'.woff2': 'font/woff2',
|
|
830
|
+
'.ttf': 'font/ttf',
|
|
831
|
+
'.map': 'application/json',
|
|
832
|
+
};
|
|
833
|
+
/**
|
|
834
|
+
* Serve a static file from the React dashboard dist
|
|
835
|
+
*/
|
|
836
|
+
function serveStaticFile(filePath, res) {
|
|
837
|
+
if (!fs.existsSync(filePath)) {
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
const ext = path.extname(filePath);
|
|
841
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
842
|
+
try {
|
|
843
|
+
const content = fs.readFileSync(filePath);
|
|
844
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
845
|
+
res.end(content);
|
|
846
|
+
return true;
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
378
852
|
// Create server
|
|
379
853
|
const server = http.createServer(async (req, res) => {
|
|
380
854
|
// Security: Validate Host and Origin headers
|
|
@@ -398,13 +872,275 @@ const server = http.createServer(async (req, res) => {
|
|
|
398
872
|
}
|
|
399
873
|
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
400
874
|
try {
|
|
401
|
-
//
|
|
875
|
+
// =========================================================================
|
|
876
|
+
// NEW API ENDPOINTS (Spec 0090 - Tower as Single Daemon)
|
|
877
|
+
// =========================================================================
|
|
878
|
+
// Health check endpoint (Spec 0090 Phase 1)
|
|
879
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
880
|
+
const instances = await getInstances();
|
|
881
|
+
const activeCount = instances.filter((i) => i.running).length;
|
|
882
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
883
|
+
res.end(JSON.stringify({
|
|
884
|
+
status: 'healthy',
|
|
885
|
+
uptime: process.uptime(),
|
|
886
|
+
activeProjects: activeCount,
|
|
887
|
+
totalProjects: instances.length,
|
|
888
|
+
memoryUsage: process.memoryUsage().heapUsed,
|
|
889
|
+
timestamp: new Date().toISOString(),
|
|
890
|
+
}));
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
// API: List all projects (Spec 0090 Phase 1)
|
|
894
|
+
if (req.method === 'GET' && url.pathname === '/api/projects') {
|
|
895
|
+
const instances = await getInstances();
|
|
896
|
+
const projects = instances.map((i) => ({
|
|
897
|
+
path: i.projectPath,
|
|
898
|
+
name: i.projectName,
|
|
899
|
+
basePort: i.basePort,
|
|
900
|
+
active: i.running,
|
|
901
|
+
proxyUrl: i.proxyUrl,
|
|
902
|
+
terminals: i.terminals.length,
|
|
903
|
+
lastUsed: i.lastUsed,
|
|
904
|
+
}));
|
|
905
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
906
|
+
res.end(JSON.stringify({ projects }));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
// API: Project-specific endpoints (Spec 0090 Phase 1)
|
|
910
|
+
// Routes: /api/projects/:encodedPath/activate, /deactivate, /status
|
|
911
|
+
const projectApiMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/(activate|deactivate|status)$/);
|
|
912
|
+
if (projectApiMatch) {
|
|
913
|
+
const [, encodedPath, action] = projectApiMatch;
|
|
914
|
+
let projectPath;
|
|
915
|
+
try {
|
|
916
|
+
projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
|
|
917
|
+
if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
|
|
918
|
+
throw new Error('Invalid path');
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
923
|
+
res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
// GET /api/projects/:path/status
|
|
927
|
+
if (req.method === 'GET' && action === 'status') {
|
|
928
|
+
const instances = await getInstances();
|
|
929
|
+
const instance = instances.find((i) => i.projectPath === projectPath);
|
|
930
|
+
if (!instance) {
|
|
931
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
932
|
+
res.end(JSON.stringify({ error: 'Project not found' }));
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
936
|
+
res.end(JSON.stringify({
|
|
937
|
+
path: instance.projectPath,
|
|
938
|
+
name: instance.projectName,
|
|
939
|
+
active: instance.running,
|
|
940
|
+
basePort: instance.basePort,
|
|
941
|
+
terminals: instance.terminals,
|
|
942
|
+
gateStatus: instance.gateStatus,
|
|
943
|
+
}));
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
// POST /api/projects/:path/activate
|
|
947
|
+
if (req.method === 'POST' && action === 'activate') {
|
|
948
|
+
// Rate limiting: 10 activations per minute per client
|
|
949
|
+
const clientIp = req.socket.remoteAddress || '127.0.0.1';
|
|
950
|
+
if (isRateLimited(clientIp)) {
|
|
951
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
952
|
+
res.end(JSON.stringify({ error: 'Too many activations, try again later' }));
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const result = await launchInstance(projectPath);
|
|
956
|
+
if (result.success) {
|
|
957
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
958
|
+
res.end(JSON.stringify({ success: true, adopted: result.adopted }));
|
|
959
|
+
}
|
|
960
|
+
else {
|
|
961
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
962
|
+
res.end(JSON.stringify({ success: false, error: result.error }));
|
|
963
|
+
}
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
// POST /api/projects/:path/deactivate
|
|
967
|
+
if (req.method === 'POST' && action === 'deactivate') {
|
|
968
|
+
// Check if project exists in port allocations
|
|
969
|
+
const allocations = loadPortAllocations();
|
|
970
|
+
const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
|
|
971
|
+
const allocation = allocations.find((a) => a.project_path === projectPath || a.project_path === resolvedPath);
|
|
972
|
+
if (!allocation) {
|
|
973
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
974
|
+
res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
// Phase 4: Stop terminals directly via tower
|
|
978
|
+
const result = await stopInstance(projectPath);
|
|
979
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
980
|
+
res.end(JSON.stringify(result));
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
// =========================================================================
|
|
985
|
+
// TERMINAL API (Phase 2 - Spec 0090)
|
|
986
|
+
// =========================================================================
|
|
987
|
+
// POST /api/terminals - Create a new terminal
|
|
988
|
+
if (req.method === 'POST' && url.pathname === '/api/terminals') {
|
|
989
|
+
try {
|
|
990
|
+
const body = await parseJsonBody(req);
|
|
991
|
+
const manager = getTerminalManager();
|
|
992
|
+
const info = await manager.createSession({
|
|
993
|
+
command: typeof body.command === 'string' ? body.command : undefined,
|
|
994
|
+
args: Array.isArray(body.args) ? body.args : undefined,
|
|
995
|
+
cols: typeof body.cols === 'number' ? body.cols : undefined,
|
|
996
|
+
rows: typeof body.rows === 'number' ? body.rows : undefined,
|
|
997
|
+
cwd: typeof body.cwd === 'string' ? body.cwd : undefined,
|
|
998
|
+
env: typeof body.env === 'object' && body.env !== null ? body.env : undefined,
|
|
999
|
+
label: typeof body.label === 'string' ? body.label : undefined,
|
|
1000
|
+
});
|
|
1001
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
1002
|
+
res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}` }));
|
|
1003
|
+
}
|
|
1004
|
+
catch (err) {
|
|
1005
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
1006
|
+
log('ERROR', `Failed to create terminal: ${message}`);
|
|
1007
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1008
|
+
res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message }));
|
|
1009
|
+
}
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
// GET /api/terminals - List all terminals
|
|
1013
|
+
if (req.method === 'GET' && url.pathname === '/api/terminals') {
|
|
1014
|
+
const manager = getTerminalManager();
|
|
1015
|
+
const terminals = manager.listSessions();
|
|
1016
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1017
|
+
res.end(JSON.stringify({ terminals }));
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
// Terminal-specific routes: /api/terminals/:id/*
|
|
1021
|
+
const terminalRouteMatch = url.pathname.match(/^\/api\/terminals\/([^/]+)(\/.*)?$/);
|
|
1022
|
+
if (terminalRouteMatch) {
|
|
1023
|
+
const [, terminalId, subpath] = terminalRouteMatch;
|
|
1024
|
+
const manager = getTerminalManager();
|
|
1025
|
+
// GET /api/terminals/:id - Get terminal info
|
|
1026
|
+
if (req.method === 'GET' && (!subpath || subpath === '')) {
|
|
1027
|
+
const session = manager.getSession(terminalId);
|
|
1028
|
+
if (!session) {
|
|
1029
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1030
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1034
|
+
res.end(JSON.stringify(session.info));
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
// DELETE /api/terminals/:id - Kill terminal
|
|
1038
|
+
if (req.method === 'DELETE' && (!subpath || subpath === '')) {
|
|
1039
|
+
if (!manager.killSession(terminalId)) {
|
|
1040
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1041
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
res.writeHead(204);
|
|
1045
|
+
res.end();
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
// POST /api/terminals/:id/resize - Resize terminal
|
|
1049
|
+
if (req.method === 'POST' && subpath === '/resize') {
|
|
1050
|
+
try {
|
|
1051
|
+
const body = await parseJsonBody(req);
|
|
1052
|
+
if (typeof body.cols !== 'number' || typeof body.rows !== 'number') {
|
|
1053
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1054
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'cols and rows must be numbers' }));
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
const info = manager.resizeSession(terminalId, body.cols, body.rows);
|
|
1058
|
+
if (!info) {
|
|
1059
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1060
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1064
|
+
res.end(JSON.stringify(info));
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1068
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
|
|
1069
|
+
}
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
// GET /api/terminals/:id/output - Get terminal output
|
|
1073
|
+
if (req.method === 'GET' && subpath === '/output') {
|
|
1074
|
+
const lines = parseInt(url.searchParams.get('lines') ?? '100', 10);
|
|
1075
|
+
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
|
|
1076
|
+
const output = manager.getOutput(terminalId, lines, offset);
|
|
1077
|
+
if (!output) {
|
|
1078
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1079
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1083
|
+
res.end(JSON.stringify(output));
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
// =========================================================================
|
|
1088
|
+
// EXISTING API ENDPOINTS
|
|
1089
|
+
// =========================================================================
|
|
1090
|
+
// API: Get status of all instances (legacy - kept for backward compat)
|
|
402
1091
|
if (req.method === 'GET' && url.pathname === '/api/status') {
|
|
403
1092
|
const instances = await getInstances();
|
|
404
1093
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
405
1094
|
res.end(JSON.stringify({ instances }));
|
|
406
1095
|
return;
|
|
407
1096
|
}
|
|
1097
|
+
// API: Server-Sent Events for push notifications
|
|
1098
|
+
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
1099
|
+
const clientId = crypto.randomBytes(8).toString('hex');
|
|
1100
|
+
res.writeHead(200, {
|
|
1101
|
+
'Content-Type': 'text/event-stream',
|
|
1102
|
+
'Cache-Control': 'no-cache',
|
|
1103
|
+
Connection: 'keep-alive',
|
|
1104
|
+
});
|
|
1105
|
+
// Send initial connection event
|
|
1106
|
+
res.write(`data: ${JSON.stringify({ type: 'connected', id: clientId })}\n\n`);
|
|
1107
|
+
const client = { res, id: clientId };
|
|
1108
|
+
sseClients.push(client);
|
|
1109
|
+
log('INFO', `SSE client connected: ${clientId} (total: ${sseClients.length})`);
|
|
1110
|
+
// Clean up on disconnect
|
|
1111
|
+
req.on('close', () => {
|
|
1112
|
+
const index = sseClients.findIndex((c) => c.id === clientId);
|
|
1113
|
+
if (index !== -1) {
|
|
1114
|
+
sseClients.splice(index, 1);
|
|
1115
|
+
}
|
|
1116
|
+
log('INFO', `SSE client disconnected: ${clientId} (total: ${sseClients.length})`);
|
|
1117
|
+
});
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
// API: Receive notification from builder
|
|
1121
|
+
if (req.method === 'POST' && url.pathname === '/api/notify') {
|
|
1122
|
+
const body = await parseJsonBody(req);
|
|
1123
|
+
const type = typeof body.type === 'string' ? body.type : 'info';
|
|
1124
|
+
const title = typeof body.title === 'string' ? body.title : '';
|
|
1125
|
+
const messageBody = typeof body.body === 'string' ? body.body : '';
|
|
1126
|
+
const project = typeof body.project === 'string' ? body.project : undefined;
|
|
1127
|
+
if (!title || !messageBody) {
|
|
1128
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1129
|
+
res.end(JSON.stringify({ success: false, error: 'Missing title or body' }));
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
// Broadcast to all connected SSE clients
|
|
1133
|
+
broadcastNotification({
|
|
1134
|
+
type,
|
|
1135
|
+
title,
|
|
1136
|
+
body: messageBody,
|
|
1137
|
+
project,
|
|
1138
|
+
});
|
|
1139
|
+
log('INFO', `Notification broadcast: ${title}`);
|
|
1140
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1141
|
+
res.end(JSON.stringify({ success: true }));
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
408
1144
|
// API: Browse directories for autocomplete
|
|
409
1145
|
if (req.method === 'GET' && url.pathname === '/api/browse') {
|
|
410
1146
|
const inputPath = url.searchParams.get('path') || '';
|
|
@@ -499,16 +1235,44 @@ const server = http.createServer(async (req, res) => {
|
|
|
499
1235
|
res.end(JSON.stringify(result));
|
|
500
1236
|
return;
|
|
501
1237
|
}
|
|
1238
|
+
// API: Get tunnel status (cloudflared availability and running tunnel)
|
|
1239
|
+
if (req.method === 'GET' && url.pathname === '/api/tunnel/status') {
|
|
1240
|
+
const status = getTunnelStatus();
|
|
1241
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1242
|
+
res.end(JSON.stringify(status));
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
// API: Start cloudflared tunnel
|
|
1246
|
+
if (req.method === 'POST' && url.pathname === '/api/tunnel/start') {
|
|
1247
|
+
const result = await startTunnel(port);
|
|
1248
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1249
|
+
res.end(JSON.stringify(result));
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
// API: Stop cloudflared tunnel
|
|
1253
|
+
if (req.method === 'POST' && url.pathname === '/api/tunnel/stop') {
|
|
1254
|
+
const result = stopTunnel();
|
|
1255
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1256
|
+
res.end(JSON.stringify(result));
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
502
1259
|
// API: Stop an instance
|
|
1260
|
+
// Phase 4 (Spec 0090): Accept projectPath or basePort for backwards compat
|
|
503
1261
|
if (req.method === 'POST' && url.pathname === '/api/stop') {
|
|
504
1262
|
const body = await parseJsonBody(req);
|
|
505
|
-
|
|
506
|
-
if
|
|
1263
|
+
let targetPath = body.projectPath;
|
|
1264
|
+
// Backwards compat: if basePort provided, find the project path
|
|
1265
|
+
if (!targetPath && body.basePort) {
|
|
1266
|
+
const allocations = loadPortAllocations();
|
|
1267
|
+
const allocation = allocations.find((a) => a.base_port === body.basePort);
|
|
1268
|
+
targetPath = allocation?.project_path || '';
|
|
1269
|
+
}
|
|
1270
|
+
if (!targetPath) {
|
|
507
1271
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
508
|
-
res.end(JSON.stringify({ success: false, error: 'Missing basePort' }));
|
|
1272
|
+
res.end(JSON.stringify({ success: false, error: 'Missing projectPath or basePort' }));
|
|
509
1273
|
return;
|
|
510
1274
|
}
|
|
511
|
-
const result = await stopInstance(
|
|
1275
|
+
const result = await stopInstance(targetPath);
|
|
512
1276
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
513
1277
|
res.end(JSON.stringify(result));
|
|
514
1278
|
return;
|
|
@@ -531,6 +1295,228 @@ const server = http.createServer(async (req, res) => {
|
|
|
531
1295
|
}
|
|
532
1296
|
return;
|
|
533
1297
|
}
|
|
1298
|
+
// Project routes: /project/:base64urlPath/*
|
|
1299
|
+
// Phase 4 (Spec 0090): Tower serves React dashboard and handles APIs directly
|
|
1300
|
+
// Uses Base64URL (RFC 4648) encoding to avoid issues with slashes in paths
|
|
1301
|
+
if (url.pathname.startsWith('/project/')) {
|
|
1302
|
+
const pathParts = url.pathname.split('/');
|
|
1303
|
+
// ['', 'project', base64urlPath, ...rest]
|
|
1304
|
+
const encodedPath = pathParts[2];
|
|
1305
|
+
const subPath = pathParts.slice(3).join('/');
|
|
1306
|
+
if (!encodedPath) {
|
|
1307
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1308
|
+
res.end('Missing project path');
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
// Decode Base64URL (RFC 4648)
|
|
1312
|
+
let projectPath;
|
|
1313
|
+
try {
|
|
1314
|
+
projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
|
|
1315
|
+
// Support both POSIX (/) and Windows (C:\) paths
|
|
1316
|
+
if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
|
|
1317
|
+
throw new Error('Invalid project path');
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
catch {
|
|
1321
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1322
|
+
res.end('Invalid project path encoding');
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
const basePort = await getBasePortForProject(projectPath);
|
|
1326
|
+
// Phase 4 (Spec 0090): Tower handles everything directly
|
|
1327
|
+
const isApiCall = subPath.startsWith('api/') || subPath === 'api';
|
|
1328
|
+
const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
|
|
1329
|
+
// Serve React dashboard static files directly if:
|
|
1330
|
+
// 1. Not an API call
|
|
1331
|
+
// 2. Not a WebSocket path
|
|
1332
|
+
// 3. React dashboard is available
|
|
1333
|
+
// 4. Project doesn't need to be running for static files
|
|
1334
|
+
if (!isApiCall && !isWsPath && hasReactDashboard) {
|
|
1335
|
+
// Determine which static file to serve
|
|
1336
|
+
let staticPath;
|
|
1337
|
+
if (!subPath || subPath === '' || subPath === 'index.html') {
|
|
1338
|
+
staticPath = path.join(reactDashboardPath, 'index.html');
|
|
1339
|
+
}
|
|
1340
|
+
else {
|
|
1341
|
+
// Check if it's a static asset
|
|
1342
|
+
staticPath = path.join(reactDashboardPath, subPath);
|
|
1343
|
+
}
|
|
1344
|
+
// Try to serve the static file
|
|
1345
|
+
if (serveStaticFile(staticPath, res)) {
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
// SPA fallback: serve index.html for client-side routing
|
|
1349
|
+
const indexPath = path.join(reactDashboardPath, 'index.html');
|
|
1350
|
+
if (serveStaticFile(indexPath, res)) {
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
// Phase 4 (Spec 0090): Handle project APIs directly instead of proxying to dashboard-server
|
|
1355
|
+
if (isApiCall) {
|
|
1356
|
+
const apiPath = subPath.replace(/^api\/?/, '');
|
|
1357
|
+
// GET /api/state - Return project state (architect, builders, shells)
|
|
1358
|
+
if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
|
|
1359
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
1360
|
+
const manager = getTerminalManager();
|
|
1361
|
+
// Build state response compatible with React dashboard
|
|
1362
|
+
const state = {
|
|
1363
|
+
architect: null,
|
|
1364
|
+
builders: [],
|
|
1365
|
+
utils: [],
|
|
1366
|
+
annotations: [],
|
|
1367
|
+
projectName: path.basename(projectPath),
|
|
1368
|
+
};
|
|
1369
|
+
// Add architect if exists
|
|
1370
|
+
if (entry.architect) {
|
|
1371
|
+
const session = manager.getSession(entry.architect);
|
|
1372
|
+
state.architect = {
|
|
1373
|
+
port: basePort || 0,
|
|
1374
|
+
pid: session?.pid || 0,
|
|
1375
|
+
terminalId: entry.architect,
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
// Add shells
|
|
1379
|
+
for (const [shellId, terminalId] of entry.shells) {
|
|
1380
|
+
const session = manager.getSession(terminalId);
|
|
1381
|
+
state.utils.push({
|
|
1382
|
+
id: shellId,
|
|
1383
|
+
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
1384
|
+
port: basePort || 0,
|
|
1385
|
+
pid: session?.pid || 0,
|
|
1386
|
+
terminalId,
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
// Add builders
|
|
1390
|
+
for (const [builderId, terminalId] of entry.builders) {
|
|
1391
|
+
const session = manager.getSession(terminalId);
|
|
1392
|
+
state.builders.push({
|
|
1393
|
+
id: builderId,
|
|
1394
|
+
name: `Builder ${builderId}`,
|
|
1395
|
+
port: basePort || 0,
|
|
1396
|
+
pid: session?.pid || 0,
|
|
1397
|
+
status: 'running',
|
|
1398
|
+
phase: '',
|
|
1399
|
+
worktree: '',
|
|
1400
|
+
branch: '',
|
|
1401
|
+
type: 'spec',
|
|
1402
|
+
terminalId,
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1406
|
+
res.end(JSON.stringify(state));
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
// POST /api/tabs/shell - Create a new shell terminal
|
|
1410
|
+
if (req.method === 'POST' && apiPath === 'tabs/shell') {
|
|
1411
|
+
try {
|
|
1412
|
+
const manager = getTerminalManager();
|
|
1413
|
+
const shellId = getNextShellId(projectPath);
|
|
1414
|
+
// Create terminal session
|
|
1415
|
+
const session = await manager.createSession({
|
|
1416
|
+
command: process.env.SHELL || '/bin/bash',
|
|
1417
|
+
args: [],
|
|
1418
|
+
cwd: projectPath,
|
|
1419
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
1420
|
+
env: process.env,
|
|
1421
|
+
});
|
|
1422
|
+
// Register terminal with project
|
|
1423
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
1424
|
+
entry.shells.set(shellId, session.id);
|
|
1425
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1426
|
+
res.end(JSON.stringify({
|
|
1427
|
+
id: shellId,
|
|
1428
|
+
port: basePort || 0,
|
|
1429
|
+
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
1430
|
+
terminalId: session.id,
|
|
1431
|
+
}));
|
|
1432
|
+
}
|
|
1433
|
+
catch (err) {
|
|
1434
|
+
log('ERROR', `Failed to create shell: ${err.message}`);
|
|
1435
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1436
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1437
|
+
}
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
// DELETE /api/tabs/:id - Delete a terminal tab
|
|
1441
|
+
const deleteMatch = apiPath.match(/^tabs\/(.+)$/);
|
|
1442
|
+
if (req.method === 'DELETE' && deleteMatch) {
|
|
1443
|
+
const tabId = deleteMatch[1];
|
|
1444
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
1445
|
+
const manager = getTerminalManager();
|
|
1446
|
+
// Find and delete the terminal
|
|
1447
|
+
let terminalId;
|
|
1448
|
+
if (tabId.startsWith('shell-')) {
|
|
1449
|
+
terminalId = entry.shells.get(tabId);
|
|
1450
|
+
if (terminalId) {
|
|
1451
|
+
entry.shells.delete(tabId);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
else if (tabId.startsWith('builder-')) {
|
|
1455
|
+
terminalId = entry.builders.get(tabId);
|
|
1456
|
+
if (terminalId) {
|
|
1457
|
+
entry.builders.delete(tabId);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
else if (tabId === 'architect') {
|
|
1461
|
+
terminalId = entry.architect;
|
|
1462
|
+
if (terminalId) {
|
|
1463
|
+
entry.architect = undefined;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
if (terminalId) {
|
|
1467
|
+
manager.killSession(terminalId);
|
|
1468
|
+
res.writeHead(204);
|
|
1469
|
+
res.end();
|
|
1470
|
+
}
|
|
1471
|
+
else {
|
|
1472
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1473
|
+
res.end(JSON.stringify({ error: 'Tab not found' }));
|
|
1474
|
+
}
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
// POST /api/stop - Stop all terminals for project
|
|
1478
|
+
if (req.method === 'POST' && apiPath === 'stop') {
|
|
1479
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
1480
|
+
const manager = getTerminalManager();
|
|
1481
|
+
// Kill all terminals
|
|
1482
|
+
if (entry.architect) {
|
|
1483
|
+
manager.killSession(entry.architect);
|
|
1484
|
+
}
|
|
1485
|
+
for (const terminalId of entry.shells.values()) {
|
|
1486
|
+
manager.killSession(terminalId);
|
|
1487
|
+
}
|
|
1488
|
+
for (const terminalId of entry.builders.values()) {
|
|
1489
|
+
manager.killSession(terminalId);
|
|
1490
|
+
}
|
|
1491
|
+
// Clear registry
|
|
1492
|
+
projectTerminals.delete(projectPath);
|
|
1493
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1494
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
// Unhandled API route
|
|
1498
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1499
|
+
res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
// For WebSocket paths, let the upgrade handler deal with it
|
|
1503
|
+
if (isWsPath) {
|
|
1504
|
+
// WebSocket paths are handled by the upgrade handler
|
|
1505
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1506
|
+
res.end('WebSocket connections should use ws:// protocol');
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
// If we get here for non-API, non-WS paths and React dashboard is not available
|
|
1510
|
+
if (!hasReactDashboard) {
|
|
1511
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1512
|
+
res.end('Dashboard not available');
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
// Fallback for unmatched paths
|
|
1516
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1517
|
+
res.end('Not found');
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
534
1520
|
// 404 for everything else
|
|
535
1521
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
536
1522
|
res.end('Not found');
|
|
@@ -545,6 +1531,77 @@ const server = http.createServer(async (req, res) => {
|
|
|
545
1531
|
server.listen(port, '127.0.0.1', () => {
|
|
546
1532
|
log('INFO', `Tower server listening at http://localhost:${port}`);
|
|
547
1533
|
});
|
|
1534
|
+
// Initialize terminal WebSocket server (Phase 2 - Spec 0090)
|
|
1535
|
+
terminalWss = new WebSocketServer({ noServer: true });
|
|
1536
|
+
// WebSocket upgrade handler for terminal connections and proxying
|
|
1537
|
+
server.on('upgrade', async (req, socket, head) => {
|
|
1538
|
+
const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
|
|
1539
|
+
// Phase 2: Handle /ws/terminal/:id routes directly
|
|
1540
|
+
const terminalMatch = reqUrl.pathname.match(/^\/ws\/terminal\/([^/]+)$/);
|
|
1541
|
+
if (terminalMatch) {
|
|
1542
|
+
const terminalId = terminalMatch[1];
|
|
1543
|
+
const manager = getTerminalManager();
|
|
1544
|
+
const session = manager.getSession(terminalId);
|
|
1545
|
+
if (!session) {
|
|
1546
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
1547
|
+
socket.destroy();
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
1551
|
+
handleTerminalWebSocket(ws, session, req);
|
|
1552
|
+
});
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
// Phase 4 (Spec 0090): Handle project WebSocket routes directly
|
|
1556
|
+
// Route: /project/:encodedPath/ws/terminal/:terminalId
|
|
1557
|
+
if (!reqUrl.pathname.startsWith('/project/')) {
|
|
1558
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
1559
|
+
socket.destroy();
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
const pathParts = reqUrl.pathname.split('/');
|
|
1563
|
+
// ['', 'project', base64urlPath, 'ws', 'terminal', terminalId]
|
|
1564
|
+
const encodedPath = pathParts[2];
|
|
1565
|
+
if (!encodedPath) {
|
|
1566
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
1567
|
+
socket.destroy();
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
// Decode Base64URL (RFC 4648) - NOT URL encoding
|
|
1571
|
+
// Wrap in try/catch to handle malformed Base64 input gracefully
|
|
1572
|
+
let projectPath;
|
|
1573
|
+
try {
|
|
1574
|
+
projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
|
|
1575
|
+
// Support both POSIX (/) and Windows (C:\) paths
|
|
1576
|
+
if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
|
|
1577
|
+
throw new Error('Invalid project path');
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
catch {
|
|
1581
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
1582
|
+
socket.destroy();
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
// Check for terminal WebSocket route: /project/:path/ws/terminal/:id
|
|
1586
|
+
const wsMatch = reqUrl.pathname.match(/^\/project\/[^/]+\/ws\/terminal\/([^/]+)$/);
|
|
1587
|
+
if (wsMatch) {
|
|
1588
|
+
const terminalId = wsMatch[1];
|
|
1589
|
+
const manager = getTerminalManager();
|
|
1590
|
+
const session = manager.getSession(terminalId);
|
|
1591
|
+
if (!session) {
|
|
1592
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
1593
|
+
socket.destroy();
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
1597
|
+
handleTerminalWebSocket(ws, session, req);
|
|
1598
|
+
});
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
// Unhandled WebSocket route
|
|
1602
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
1603
|
+
socket.destroy();
|
|
1604
|
+
});
|
|
548
1605
|
// Handle uncaught errors
|
|
549
1606
|
process.on('uncaughtException', (err) => {
|
|
550
1607
|
log('ERROR', `Uncaught exception: ${err.message}\n${err.stack}`);
|