diff --git a/src/cli/run/runner.ts b/src/cli/run/runner.ts index a9bc7b12..c9c2527c 100644 --- a/src/cli/run/runner.ts +++ b/src/cli/run/runner.ts @@ -5,6 +5,7 @@ import { checkCompletionConditions } from "./completion" import { createEventState, processEvents, serializeError } from "./events" import type { OhMyOpenCodeConfig } from "../../config" import { loadPluginConfig } from "../../plugin-config" +import { getAvailableServerPort, DEFAULT_SERVER_PORT } from "../../shared/port-utils" const POLL_INTERVAL_MS = 500 const DEFAULT_TIMEOUT_MS = 0 @@ -89,7 +90,7 @@ export async function run(options: RunOptions): Promise { const pluginConfig = loadPluginConfig(directory, { command: "run" }) const resolvedAgent = resolveRunAgent(options, pluginConfig) - console.log(pc.cyan("Starting opencode server...")) + console.log(pc.cyan("Starting opencode server (auto port selection enabled)...")) const abortController = new AbortController() let timeoutId: ReturnType | null = null @@ -103,18 +104,24 @@ export async function run(options: RunOptions): Promise { } try { - // Support custom OpenCode server port via environment variable - // This allows Open Agent and other orchestrators to run multiple - // concurrent missions without port conflicts - const serverPort = process.env.OPENCODE_SERVER_PORT + const envPort = process.env.OPENCODE_SERVER_PORT ? parseInt(process.env.OPENCODE_SERVER_PORT, 10) : undefined - const serverHostname = process.env.OPENCODE_SERVER_HOSTNAME || undefined + const serverHostname = process.env.OPENCODE_SERVER_HOSTNAME || "127.0.0.1" + const preferredPort = envPort && !isNaN(envPort) ? envPort : DEFAULT_SERVER_PORT + + const { port: serverPort, wasAutoSelected } = await getAvailableServerPort(preferredPort, serverHostname) + + if (wasAutoSelected) { + console.log(pc.yellow(`Port ${preferredPort} is busy, using port ${serverPort} instead`)) + } else { + console.log(pc.dim(`Using port ${serverPort}`)) + } const { client, server } = await createOpencode({ signal: abortController.signal, - ...(serverPort && !isNaN(serverPort) ? { port: serverPort } : {}), - ...(serverHostname ? { hostname: serverHostname } : {}), + port: serverPort, + hostname: serverHostname, }) const cleanup = () => { diff --git a/src/shared/index.ts b/src/shared/index.ts index 99b43262..93163dcc 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -40,3 +40,4 @@ export * from "./session-utils" export * from "./tmux" export * from "./model-suggestion-retry" export * from "./opencode-server-auth" +export * from "./port-utils" diff --git a/src/shared/port-utils.test.ts b/src/shared/port-utils.test.ts new file mode 100644 index 00000000..34ef34b9 --- /dev/null +++ b/src/shared/port-utils.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test" +import { + isPortAvailable, + findAvailablePort, + getAvailableServerPort, + DEFAULT_SERVER_PORT, +} from "./port-utils" + +describe("port-utils", () => { + describe("isPortAvailable", () => { + it("#given unused port #when checking availability #then returns true", async () => { + const port = 59999 + const result = await isPortAvailable(port) + expect(result).toBe(true) + }) + + it("#given port in use #when checking availability #then returns false", async () => { + const port = 59998 + const blocker = Bun.serve({ + port, + hostname: "127.0.0.1", + fetch: () => new Response("blocked"), + }) + + try { + const result = await isPortAvailable(port) + expect(result).toBe(false) + } finally { + blocker.stop(true) + } + }) + }) + + describe("findAvailablePort", () => { + it("#given start port available #when finding port #then returns start port", async () => { + const startPort = 59997 + const result = await findAvailablePort(startPort) + expect(result).toBe(startPort) + }) + + it("#given start port blocked #when finding port #then returns next available", async () => { + const startPort = 59996 + const blocker = Bun.serve({ + port: startPort, + hostname: "127.0.0.1", + fetch: () => new Response("blocked"), + }) + + try { + const result = await findAvailablePort(startPort) + expect(result).toBe(startPort + 1) + } finally { + blocker.stop(true) + } + }) + + it("#given multiple ports blocked #when finding port #then skips all blocked", async () => { + const startPort = 59993 + const blockers = [ + Bun.serve({ port: startPort, hostname: "127.0.0.1", fetch: () => new Response() }), + Bun.serve({ port: startPort + 1, hostname: "127.0.0.1", fetch: () => new Response() }), + Bun.serve({ port: startPort + 2, hostname: "127.0.0.1", fetch: () => new Response() }), + ] + + try { + const result = await findAvailablePort(startPort) + expect(result).toBe(startPort + 3) + } finally { + blockers.forEach((b) => b.stop(true)) + } + }) + }) + + describe("getAvailableServerPort", () => { + it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => { + const preferredPort = 59990 + const result = await getAvailableServerPort(preferredPort) + expect(result.port).toBe(preferredPort) + expect(result.wasAutoSelected).toBe(false) + }) + + it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => { + const preferredPort = 59989 + const blocker = Bun.serve({ + port: preferredPort, + hostname: "127.0.0.1", + fetch: () => new Response("blocked"), + }) + + try { + const result = await getAvailableServerPort(preferredPort) + expect(result.port).toBeGreaterThan(preferredPort) + expect(result.wasAutoSelected).toBe(true) + } finally { + blocker.stop(true) + } + }) + }) + + describe("DEFAULT_SERVER_PORT", () => { + it("#given constant #when accessed #then returns 4096", () => { + expect(DEFAULT_SERVER_PORT).toBe(4096) + }) + }) +}) diff --git a/src/shared/port-utils.ts b/src/shared/port-utils.ts new file mode 100644 index 00000000..978a2658 --- /dev/null +++ b/src/shared/port-utils.ts @@ -0,0 +1,48 @@ +const DEFAULT_SERVER_PORT = 4096 +const MAX_PORT_ATTEMPTS = 20 + +export async function isPortAvailable(port: number, hostname: string = "127.0.0.1"): Promise { + try { + const server = Bun.serve({ + port, + hostname, + fetch: () => new Response(), + }) + server.stop(true) + return true + } catch { + return false + } +} + +export async function findAvailablePort( + startPort: number = DEFAULT_SERVER_PORT, + hostname: string = "127.0.0.1" +): Promise { + for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) { + const port = startPort + attempt + if (await isPortAvailable(port, hostname)) { + return port + } + } + throw new Error(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`) +} + +export interface AutoPortResult { + port: number + wasAutoSelected: boolean +} + +export async function getAvailableServerPort( + preferredPort: number = DEFAULT_SERVER_PORT, + hostname: string = "127.0.0.1" +): Promise { + if (await isPortAvailable(preferredPort, hostname)) { + return { port: preferredPort, wasAutoSelected: false } + } + + const port = await findAvailablePort(preferredPort + 1, hostname) + return { port, wasAutoSelected: true } +} + +export { DEFAULT_SERVER_PORT }