feat: configurable terminal server ports for multi-dashboard support (#113)

* feat: make terminal server ports configurable to fix multi-dashboard EADDRINUSE

When multiple ao dashboards run simultaneously (e.g., ao on port 3000,
integrator on port 3002), both try to start terminal WebSocket servers
on hardcoded ports 3001/3003, causing EADDRINUSE. Add terminalPort and
directTerminalPort to config schema so each instance can use unique ports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use optional() instead of default() for terminal port schema

Zod .default() always fills in the value, making config.terminalPort
never undefined and the env var fallback in buildDashboardEnv dead code.
Switch to .optional() so the priority chain works correctly:
config value > TERMINAL_PORT env var > hardcoded default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: move terminal server defaults from 3001/3003 to 14800/14801

The 3000-3009 range is the most contested in dev tooling (Next.js
auto-increments, BrowserSync, Grafana, Rails, Express all default to
3000+). Port 14800-14899 has zero IANA registrations, zero known dev
tool conflicts, and is safely below OS ephemeral ranges.

Updated all hardcoded fallbacks, .env.local.example, docker-compose
port mappings, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: auto-detect available terminal ports for zero-config multi-dashboard

When no terminal ports are configured (no config, no env vars),
buildDashboardEnv now probes for an available port pair starting at
14800. The second `ao start` automatically gets 14802/14803 (or the
next free pair), eliminating EADDRINUSE without any user configuration.

Port detection scans in steps of 2 to keep the pair consecutive.
Explicit config/env values bypass auto-detection entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update onboarding test to use new default terminal port (14801)

The onboarding integration test had port 3003 hardcoded for the
WebSocket health check. Updated to read from DIRECT_TERMINAL_PORT
env var with 14801 as the default, matching the new port defaults.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-02-19 04:00:19 +05:30 committed by GitHub
parent 1a3cad900f
commit 520010d5a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 135 additions and 27 deletions

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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<ChildProcess> {
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"));
}

View File

@ -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<boolean> {
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<string, string> {
export async function buildDashboardEnv(
port: number,
configPath: string | null,
terminalPort?: number,
directTerminalPort?: number,
): Promise<Record<string, string>> {
const env: Record<string, string> = { ...process.env } as Record<string, string>;
// 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;
}

View File

@ -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),

View File

@ -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;

View File

@ -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

View File

@ -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}`);

View File

@ -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}`);

View File

@ -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);

View File

@ -18,7 +18,7 @@ export function Terminal({ sessionId }: TerminalProps) {
const [error, setError] = useState<string | null>(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;

View File

@ -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

View File

@ -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