diff --git a/agent-orchestrator.yaml.example b/agent-orchestrator.yaml.example index 3e55d1aaa..99daf79e6 100644 --- a/agent-orchestrator.yaml.example +++ b/agent-orchestrator.yaml.example @@ -10,6 +10,11 @@ worktreeDir: ~/.worktrees # Web dashboard port port: 3000 +# Terminal server ports (defaults: 14800/14801 — chosen to avoid conflicts with dev tools) +# Override when running multiple dashboards to avoid EADDRINUSE +# terminalPort: 14800 +# directTerminalPort: 14801 + # Default plugins (these are the defaults — you can omit this section) defaults: runtime: tmux # tmux | process | docker | kubernetes | ssh | e2b diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2f8667de9..9c0150310 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -328,12 +328,12 @@ curl http://localhost:3000/api/sessions/my-app-3 ```bash # Terminal server ports (for web dashboard) -TERMINAL_PORT=3001 -DIRECT_TERMINAL_PORT=3003 +TERMINAL_PORT=14800 +DIRECT_TERMINAL_PORT=14801 # Next.js -NEXT_PUBLIC_TERMINAL_PORT=3001 -NEXT_PUBLIC_DIRECT_TERMINAL_PORT=3003 +NEXT_PUBLIC_TERMINAL_PORT=14800 +NEXT_PUBLIC_DIRECT_TERMINAL_PORT=14801 ``` ### User Secrets diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index dc603abf5..b0eb2d669 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -62,7 +62,12 @@ export function registerDashboard(program: Command): void { console.log(chalk.bold(`Starting dashboard on http://localhost:${port}\n`)); - const env = buildDashboardEnv(port, config.configPath); + const env = await buildDashboardEnv( + port, + config.configPath, + config.terminalPort, + config.directTerminalPort, + ); const child = spawn("npx", ["next", "dev", "-p", String(port)], { cwd: webDir, diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 9d4d7ef6a..3e7edc1c9 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -65,8 +65,14 @@ function resolveProject( * Start dashboard server in the background. * Returns the child process handle for cleanup. */ -function startDashboard(port: number, webDir: string, configPath: string | null): ChildProcess { - const env = buildDashboardEnv(port, configPath); +async function startDashboard( + port: number, + webDir: string, + configPath: string | null, + terminalPort?: number, + directTerminalPort?: number, +): Promise { + const env = await buildDashboardEnv(port, configPath, terminalPort, directTerminalPort); const child = spawn("pnpm", ["run", "dev"], { cwd: webDir, @@ -150,7 +156,13 @@ export function registerStart(program: Command): void { } spinner.start("Starting dashboard"); - dashboardProcess = startDashboard(port, webDir, config.configPath); + dashboardProcess = await startDashboard( + port, + webDir, + config.configPath, + config.terminalPort, + config.directTerminalPort, + ); spinner.succeed(`Dashboard starting on http://localhost:${port}`); console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n")); } diff --git a/packages/cli/src/lib/web-dir.ts b/packages/cli/src/lib/web-dir.ts index d378593e6..7ee48c311 100644 --- a/packages/cli/src/lib/web-dir.ts +++ b/packages/cli/src/lib/web-dir.ts @@ -3,6 +3,7 @@ * Shared utility to avoid duplication between dashboard.ts and start.ts. */ +import { createServer } from "node:net"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; @@ -12,11 +13,59 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const require = createRequire(import.meta.url); +/** Default terminal server base port (14800 range: zero IANA registrations, no dev tool conflicts) */ +const DEFAULT_TERMINAL_PORT = 14800; + +/** + * Check if a TCP port is available by attempting to bind to it. + * Returns true if the port is free, false if in use. + */ +function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.once("error", () => { + resolve(false); + }); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); + }); +} + +/** + * Find a pair of consecutive available ports starting from `base`. + * Scans upward in steps of 2 (keeping ports paired) until both are free. + * Returns [terminalPort, directTerminalPort]. + */ +async function findAvailablePortPair(base: number): Promise<[number, number]> { + const MAX_ATTEMPTS = 50; + for (let i = 0; i < MAX_ATTEMPTS; i++) { + const p1 = base + i * 2; + const p2 = p1 + 1; + const [free1, free2] = await Promise.all([isPortAvailable(p1), isPortAvailable(p2)]); + if (free1 && free2) { + return [p1, p2]; + } + } + // If all 50 pairs exhausted, fall back to the base (will fail at bind time with clear error) + return [base, base + 1]; +} + /** * Build environment variables for spawning the dashboard process. * Shared between `ao start` and `ao dashboard` to avoid duplication. + * + * Terminal server ports default to 14800/14801 but can be overridden via config. + * When no explicit port is set, auto-detects available ports to allow multiple + * dashboard instances to run simultaneously without EADDRINUSE conflicts. */ -export function buildDashboardEnv(port: number, configPath: string | null): Record { +export async function buildDashboardEnv( + port: number, + configPath: string | null, + terminalPort?: number, + directTerminalPort?: number, +): Promise> { const env: Record = { ...process.env } as Record; // Pass config path so dashboard uses the same config as the CLI @@ -24,10 +73,37 @@ export function buildDashboardEnv(port: number, configPath: string | null): Reco env["AO_CONFIG_PATH"] = configPath; } - // Set ports for client-side access (Next.js requires NEXT_PUBLIC_ prefix) env["PORT"] = String(port); - env["NEXT_PUBLIC_TERMINAL_PORT"] = env["TERMINAL_PORT"] ?? "3001"; - env["NEXT_PUBLIC_DIRECT_TERMINAL_PORT"] = env["DIRECT_TERMINAL_PORT"] ?? "3003"; + + // If explicit ports provided (config or env var), use them directly. + // Otherwise, auto-detect an available pair starting from the default. + const explicitTerminal = terminalPort ?? (env["TERMINAL_PORT"] ? parseInt(env["TERMINAL_PORT"], 10) : undefined); + const explicitDirect = directTerminalPort ?? (env["DIRECT_TERMINAL_PORT"] ? parseInt(env["DIRECT_TERMINAL_PORT"], 10) : undefined); + + let resolvedTerminal: number; + let resolvedDirect: number; + + if (explicitTerminal !== undefined && explicitDirect !== undefined) { + // Both explicitly set — use as-is + resolvedTerminal = explicitTerminal; + resolvedDirect = explicitDirect; + } else if (explicitTerminal !== undefined) { + // Terminal port set, derive direct from it + resolvedTerminal = explicitTerminal; + resolvedDirect = explicitTerminal + 1; + } else if (explicitDirect !== undefined) { + // Direct port set, derive terminal from it + resolvedTerminal = explicitDirect - 1; + resolvedDirect = explicitDirect; + } else { + // Neither set — auto-detect available pair + [resolvedTerminal, resolvedDirect] = await findAvailablePortPair(DEFAULT_TERMINAL_PORT); + } + + env["TERMINAL_PORT"] = String(resolvedTerminal); + env["DIRECT_TERMINAL_PORT"] = String(resolvedDirect); + env["NEXT_PUBLIC_TERMINAL_PORT"] = String(resolvedTerminal); + env["NEXT_PUBLIC_DIRECT_TERMINAL_PORT"] = String(resolvedDirect); return env; } diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 713b29b33..4a4c34070 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -90,6 +90,8 @@ const DefaultPluginsSchema = z.object({ const OrchestratorConfigSchema = z.object({ port: z.number().default(3000), + terminalPort: z.number().optional(), + directTerminalPort: z.number().optional(), readyThresholdMs: z.number().nonnegative().default(300_000), defaults: DefaultPluginsSchema.default({}), projects: z.record(ProjectConfigSchema), diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 25bcbe221..ae0346506 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -795,6 +795,12 @@ export interface OrchestratorConfig { /** Web dashboard port (defaults to 3000) */ port?: number; + /** Terminal WebSocket server port (defaults to 3001) */ + terminalPort?: number; + + /** Direct terminal WebSocket server port (defaults to 3003) */ + directTerminalPort?: number; + /** Milliseconds before a "ready" session becomes "idle" (default: 300000 = 5 min) */ readyThresholdMs: number; diff --git a/packages/web/.env.local.example b/packages/web/.env.local.example index d0260f17a..79d39233a 100644 --- a/packages/web/.env.local.example +++ b/packages/web/.env.local.example @@ -1,7 +1,7 @@ -# Terminal server ports -TERMINAL_PORT=3001 # ttyd (iframe) terminal server -DIRECT_TERMINAL_PORT=3003 # Direct WebSocket terminal server with XDA support +# Terminal server ports (14800 range chosen to avoid conflicts with dev tools) +TERMINAL_PORT=14800 # ttyd (iframe) terminal server +DIRECT_TERMINAL_PORT=14801 # Direct WebSocket terminal server with XDA support # Public environment variables (exposed to browser) -NEXT_PUBLIC_TERMINAL_PORT=3001 -NEXT_PUBLIC_DIRECT_TERMINAL_PORT=3003 +NEXT_PUBLIC_TERMINAL_PORT=14800 +NEXT_PUBLIC_DIRECT_TERMINAL_PORT=14801 diff --git a/packages/web/server/direct-terminal-ws.ts b/packages/web/server/direct-terminal-ws.ts index 05badae16..74ad78998 100644 --- a/packages/web/server/direct-terminal-ws.ts +++ b/packages/web/server/direct-terminal-ws.ts @@ -210,7 +210,7 @@ if (isMainModule) { console.log(`[DirectTerminal] Using tmux: ${TMUX}`); const { server, shutdown } = createDirectTerminalServer(TMUX); - const PORT = parseInt(process.env.DIRECT_TERMINAL_PORT ?? "3003", 10); + const PORT = parseInt(process.env.DIRECT_TERMINAL_PORT ?? "14801", 10); server.listen(PORT, () => { console.log(`[DirectTerminal] WebSocket server listening on port ${PORT}`); diff --git a/packages/web/server/terminal-websocket.ts b/packages/web/server/terminal-websocket.ts index b8840b7a9..8116b5221 100644 --- a/packages/web/server/terminal-websocket.ts +++ b/packages/web/server/terminal-websocket.ts @@ -307,7 +307,7 @@ const server = createServer(async (req, res) => { res.end("Not found"); }); -const PORT = parseInt(process.env.TERMINAL_PORT ?? "3001", 10); +const PORT = parseInt(process.env.TERMINAL_PORT ?? "14800", 10); server.listen(PORT, () => { console.log(`[Terminal] Server listening on port ${PORT}`); diff --git a/packages/web/src/components/DirectTerminal.tsx b/packages/web/src/components/DirectTerminal.tsx index bda6803b9..7fda2273b 100644 --- a/packages/web/src/components/DirectTerminal.tsx +++ b/packages/web/src/components/DirectTerminal.tsx @@ -124,7 +124,7 @@ export function DirectTerminal({ sessionId, startFullscreen = false }: DirectTer // Connect WebSocket const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const hostname = window.location.hostname; - const port = process.env.NEXT_PUBLIC_DIRECT_TERMINAL_PORT ?? "3003"; + const port = process.env.NEXT_PUBLIC_DIRECT_TERMINAL_PORT ?? "14801"; const wsUrl = `${protocol}//${hostname}:${port}/ws?session=${encodeURIComponent(sessionId)}`; console.log("[DirectTerminal] Connecting to:", wsUrl); diff --git a/packages/web/src/components/Terminal.tsx b/packages/web/src/components/Terminal.tsx index ce9ac8a18..4d169d8ab 100644 --- a/packages/web/src/components/Terminal.tsx +++ b/packages/web/src/components/Terminal.tsx @@ -18,7 +18,7 @@ export function Terminal({ sessionId }: TerminalProps) { const [error, setError] = useState(null); useEffect(() => { - const port = process.env.NEXT_PUBLIC_TERMINAL_PORT ?? "3001"; + const port = process.env.NEXT_PUBLIC_TERMINAL_PORT ?? "14800"; // Use current hostname instead of hardcoded localhost const protocol = window.location.protocol; const hostname = window.location.hostname; diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml index bc12cb2db..a9a075428 100644 --- a/tests/integration/docker-compose.yml +++ b/tests/integration/docker-compose.yml @@ -15,10 +15,10 @@ services: - ../..:/workspace/agent-orchestrator # Expose ports for debugging (optional) # Use 9000/9001/9003 on host to avoid conflicts with orchestrator - # Container still uses 9000/3001/3003 internally + # Container still uses 9000/14800/14801 internally ports: - - "9000:9000" # Dashboard (host:container) - - "9001:3001" # Terminal WebSocket (host:container) - - "9003:3003" # DirectTerminal WebSocket (host:container) + - "9000:9000" # Dashboard (host:container) + - "9001:14800" # Terminal WebSocket (host:container) + - "9003:14801" # DirectTerminal WebSocket (host:container) # Increase shared memory for browser-based tests (future) shm_size: 2gb diff --git a/tests/integration/onboarding-test.sh b/tests/integration/onboarding-test.sh index bb44359a7..1516e8bc7 100755 --- a/tests/integration/onboarding-test.sh +++ b/tests/integration/onboarding-test.sh @@ -131,10 +131,12 @@ end_step "Step 7: Dashboard API responding" start_step "Step 8: Verify WebSocket servers" # Check if direct terminal WebSocket server is running (required for terminal feature) -echo " Checking WebSocket server on port 3003..." +# Default port is 14801 (14800 range chosen to avoid conflicts with dev tools) +DIRECT_TERMINAL_PORT="${DIRECT_TERMINAL_PORT:-14801}" +echo " Checking WebSocket server on port $DIRECT_TERMINAL_PORT..." max_retries=10 for i in $(seq 1 $max_retries); do - if curl -sf http://localhost:3003/health > /dev/null 2>&1; then + if curl -sf "http://localhost:$DIRECT_TERMINAL_PORT/health" > /dev/null 2>&1; then echo " ✓ WebSocket server responding" break fi