From 823e55e71b2d0a566011f9df5f0737d74fcd5b7c Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 25 Jun 2026 06:14:02 +0000 Subject: [PATCH 1/4] fix(dev): honor explicit --port literally instead of silently offsetting by runtime index When running multiple `agentcore dev` runtimes, an explicit -p/--port was silently offset by the runtime's index in agentcore.json (basePort + index), so `agentcore dev -r AgentB -p 8788` bound 8789 with no explanation. Clients targeting http://localhost:<-p>/invocations silently hit the wrong port. Use Commander's getOptionValueSource to distinguish explicit vs default -p: honor an explicit --port literally across the --logs, web-ui, and TUI paths; when -p is implicit (default), keep the index offset but log it (mirroring the existing findAvailablePort shift message) and document it in --help. invoke continues to target whatever port the server bound, made consistent with the chosen server semantics. Scope: Surgical, unit-test-verified. tsc --noEmit clean, eslint clean (no new warnings), 1002/1002 unit tests pass across src/cli/operations/dev and src/cli/tui including the updated and new getAgentPort tests. The only failing tests (4 in dev.test.ts) are pre-existing CLI-spawn failures that fail identically on the clean baseline (require a built dist; environmental, unrelated to this change). The web-ui path is a structurally multi-agent server, so for the explicit -p case the SELECTED runtime binds exactly -p while concurrently-served runtimes are offset relative to it -- the minimal way to honor explicit -p there without redesigning the multi-agent port model. --- src/cli/commands/dev/browser-mode.ts | 5 ++- src/cli/commands/dev/command.tsx | 20 +++++++-- .../operations/dev/__tests__/config.test.ts | 44 +++++++++++++++++++ src/cli/operations/dev/config.ts | 19 ++++++-- .../operations/dev/web-ui/handlers/start.ts | 13 +++++- src/cli/operations/dev/web-ui/web-server.ts | 6 +++ src/cli/tui/hooks/useDevServer.ts | 9 +++- src/cli/tui/screens/dev/DevScreen.tsx | 3 ++ 8 files changed, 108 insertions(+), 11 deletions(-) diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index b40600e21..33997c9e7 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -104,6 +104,8 @@ export interface BrowserModeOptions { workingDir: string; project: AgentCoreProjectSpec; port: number; + /** Whether `port` was set explicitly via -p/--port (used as the agent base port instead of uiPort+1) */ + portExplicit?: boolean; agentName?: string; harnessName?: string; /** OTEL env vars to pass to dev servers (set by the dev command when collector is active) */ @@ -152,7 +154,7 @@ export async function launchBrowserDev(): Promise { } export async function runBrowserMode(opts: BrowserModeOptions): Promise { - const { workingDir, project, agentName, harnessName, otelEnvVars = {}, collector } = opts; + const { workingDir, project, port, portExplicit, agentName, harnessName, otelEnvVars = {}, collector } = opts; const configRoot = findConfigRoot(workingDir); // Browser mode serves multiple agents; we don't know which agent will be @@ -236,6 +238,7 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { harnesses: harnessInfoList, selectedAgent: agentName, selectedHarness: harnessName, + agentBasePort: portExplicit ? port : undefined, envVars: mergedEnvVars, getEnvVars: async () => { const { envVars: freshEnvVars } = await loadDevEnv(workingDir); diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index e680899d7..2ac7f046d 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -171,7 +171,11 @@ export const registerDev = (program: Command) => { .alias('d') .description(COMMAND_DESCRIPTIONS.dev) .argument('[prompt]', 'Send a prompt to a running dev server [non-interactive]') - .option('-p, --port ', 'Port for development server', '8080') + .option( + '-p, --port ', + 'Port for development server. Used as-is when set explicitly; the default is offset by the runtime index in multi-runtime projects.', + '8080' + ) .option('-r, --runtime ', 'Runtime to run or invoke (required if multiple runtimes)') .option('-s, --stream', 'Stream response when invoking [non-interactive]') .option('-l, --logs', 'Run dev server with logs to stdout [non-interactive]') @@ -188,9 +192,11 @@ export const registerDev = (program: Command) => { .option('-b, --no-browser', 'Use terminal TUI instead of web-based chat UI') .option('--no-traces', 'Disable local OTEL trace collection') - .action(async (positionalPrompt: string | undefined, opts) => { + .action(async (positionalPrompt: string | undefined, opts, command) => { try { const port = parseInt(opts.port, 10); + const portSource = command.getOptionValueSource('port'); + const portExplicit = portSource === 'cli' || portSource === 'env'; // Parse custom headers let headers: Record | undefined; @@ -259,7 +265,7 @@ export const registerDev = (program: Command) => { let invokePort = port; let targetAgent = invokeProject?.runtimes[0]; if (opts.runtime && invokeProject) { - invokePort = getAgentPort(invokeProject, opts.runtime, port); + invokePort = getAgentPort(invokeProject, opts.runtime, port, portExplicit); targetAgent = invokeProject.runtimes.find(a => a.name === opts.runtime); } else if (invokeProject && invokeProject.runtimes.length > 1 && !opts.runtime) { const names = invokeProject.runtimes.map(a => a.name).join(', '); @@ -399,7 +405,10 @@ export const registerDev = (program: Command) => { const isA2A = config.protocol === 'A2A'; const isMcp = config.protocol === 'MCP'; - const fixedPort = isA2A ? 9000 : isMcp ? 8000 : getAgentPort(project, config.agentName, port); + const fixedPort = isA2A ? 9000 : isMcp ? 8000 : getAgentPort(project, config.agentName, port, portExplicit); + if (!isA2A && !isMcp && !portExplicit && fixedPort !== port) { + console.log(`Port ${port} in use as base, using ${fixedPort} for ${config.agentName}`); + } const actualPort = await findAvailablePort(fixedPort); if ((isA2A || isMcp) && actualPort !== fixedPort) { throw new ValidationError( @@ -488,6 +497,7 @@ export const registerDev = (program: Command) => { }} workingDir={workingDir} port={port} + portExplicit={portExplicit} agentName={opts.runtime} headers={headers} skipDeploy={opts.skipDeploy} @@ -533,6 +543,7 @@ export const registerDev = (program: Command) => { workingDir, project, port, + portExplicit, agentName: pickerResult.agentName, harnessName: pickerResult.harnessName, otelEnvVars, @@ -551,6 +562,7 @@ export const registerDev = (program: Command) => { workingDir, project, port, + portExplicit, agentName: opts.runtime, otelEnvVars, collector, diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index a8e2289b3..6dbc25885 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -528,10 +528,54 @@ describe('getAgentPort', () => { payments: [], }; + // Default (implicit) port: offset by runtime index so parallel runtimes differ. expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080); expect(getAgentPort(project, 'Agent2', 8080)).toBe(8081); }); + it('honors an explicit port literally with no index offset', () => { + const project: AgentCoreProjectSpec = { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [ + { + name: 'AgentA', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entrypoint: filePath('main.py'), + codeLocation: dirPath('./agents/a'), + protocol: 'HTTP', + }, + { + name: 'AgentB', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entrypoint: filePath('main.py'), + codeLocation: dirPath('./agents/b'), + protocol: 'HTTP', + }, + ], + memories: [], + knowledgeBases: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + abTests: [], + harnesses: [], + datasets: [], + payments: [], + }; + + // Explicit -p: 2nd runtime resolves to the literal value (8788), not 8789. + expect(getAgentPort(project, 'AgentB', 8788, true)).toBe(8788); + // Default -p: 2nd runtime still resolves to base + index (8789). + expect(getAgentPort(project, 'AgentB', 8788, false)).toBe(8789); + }); + it('returns basePort when agent not found', () => { const project: AgentCoreProjectSpec = { name: 'TestProject', diff --git a/src/cli/operations/dev/config.ts b/src/cli/operations/dev/config.ts index 95b855124..37cd4c48d 100644 --- a/src/cli/operations/dev/config.ts +++ b/src/cli/operations/dev/config.ts @@ -66,11 +66,22 @@ export function getDevSupportedAgents(project: AgentCoreProjectSpec | null): Age } /** - * Get the port for a specific agent based on its index in the project. - * Base port + agent index = actual port + * Resolve the port for a specific agent. + * + * - When the user supplied `-p`/`--port` explicitly (`explicit === true`), the + * port is honored literally with NO index offset, so `dev -r AgentB -p 8788` + * binds exactly 8788 regardless of AgentB's position in the project. + * - When the port is the default (`explicit === false`), the agent's index in + * the project is added so parallel runtimes bind distinct ports + * (basePort, basePort + 1, ...). */ -export function getAgentPort(project: AgentCoreProjectSpec | null, agentName: string, basePort: number): number { - if (!project) return basePort; +export function getAgentPort( + project: AgentCoreProjectSpec | null, + agentName: string, + basePort: number, + explicit = false +): number { + if (explicit || !project) return basePort; const index = project.runtimes.findIndex(a => a.name === agentName); return index >= 0 ? basePort + index : basePort; } diff --git a/src/cli/operations/dev/web-ui/handlers/start.ts b/src/cli/operations/dev/web-ui/handlers/start.ts index 52c9c129e..dcb95fab4 100644 --- a/src/cli/operations/dev/web-ui/handlers/start.ts +++ b/src/cli/operations/dev/web-ui/handlers/start.ts @@ -100,7 +100,18 @@ async function doStartAgent( const isMCP = config.protocol === 'MCP'; const fixedPort = isA2A ? 9000 : isMCP ? 8000 : undefined; const isTsHttp = !config.isPython && config.protocol === 'HTTP'; - const targetPort = fixedPort ?? ctx.options.uiPort + 1 + (agentIndex >= 0 ? agentIndex : 0); + // When -p is set explicitly, honor it for the selected runtime (no offset) so the + // web UI matches the --logs and TUI paths; other concurrently-served runtimes are + // offset relative to it. Otherwise derive HTTP ports from uiPort + 1 + index. + const selectedIndex = ctx.options.selectedAgent + ? ctx.options.agents.findIndex(a => a.name === ctx.options.selectedAgent) + : -1; + const safeAgentIndex = agentIndex >= 0 ? agentIndex : 0; + const targetPort = + fixedPort ?? + (ctx.options.agentBasePort !== undefined + ? ctx.options.agentBasePort + (safeAgentIndex - (selectedIndex >= 0 ? selectedIndex : 0)) + : ctx.options.uiPort + 1 + safeAgentIndex); const agentPort = await findAvailablePort(targetPort); if (fixedPort && agentPort !== fixedPort) { const reason = isA2A ? 'A2A agents require port 9000.' : 'MCP agents require port 8000 (FastMCP default).'; diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts index dc59be4a6..e8f2b1a61 100644 --- a/src/cli/operations/dev/web-ui/web-server.ts +++ b/src/cli/operations/dev/web-ui/web-server.ts @@ -146,6 +146,12 @@ export interface WebUIOptions { uiPort: number; /** Available agents (metadata only — servers are started on demand) */ agents: AgentInfo[]; + /** + * Explicit agent base port from -p/--port. When set, HTTP agents bind off this + * value (base + index) instead of uiPort + 1 + index, so an explicit `-p` is + * honored in the web UI consistently with the --logs and TUI paths. + */ + agentBasePort?: number; /** Deployed harnesses available for invocation (metadata only — no local server needed) */ harnesses?: HarnessInfo[]; /** Dev config factory — called when an agent needs to be started. Required for dev mode, unused when onStart is provided. */ diff --git a/src/cli/tui/hooks/useDevServer.ts b/src/cli/tui/hooks/useDevServer.ts index 7e5d6964b..5444a7d91 100644 --- a/src/cli/tui/hooks/useDevServer.ts +++ b/src/cli/tui/hooks/useDevServer.ts @@ -13,6 +13,7 @@ import { createDevServer, fetchA2AAgentCard, findAvailablePort, + getAgentPort, getDevConfig, getEndpointUrl, invokeA2AStreaming, @@ -47,6 +48,7 @@ const MAX_LOG_ENTRIES = 50; export function useDevServer(options: { workingDir: string; port: number; + portExplicit?: boolean; agentName?: string; onReady?: () => void; headers?: Record; @@ -154,7 +156,10 @@ export function useDevServer(options: { // A2A servers always use port 9000, MCP servers use port 8000 (framework defaults, not configurable via env) const isA2A = config.protocol === 'A2A'; const isMcp = config.protocol === 'MCP'; - const fixedPort = isA2A ? 9000 : isMcp ? 8000 : targetPort; + // HTTP: honor an explicit -p literally; otherwise offset by the runtime index + // so parallel runtimes bind distinct ports (consistent with the --logs path). + const httpPort = getAgentPort(project, config.agentName, targetPort, options.portExplicit); + const fixedPort = isA2A ? 9000 : isMcp ? 8000 : httpPort; // On restart, reuse the same port. On initial start, find an available port. // If restart times out waiting for port, fall back to finding a new one. @@ -246,7 +251,9 @@ export function useDevServer(options: { config?.module, config?.directory, config?.isPython, + project, options.workingDir, + options.portExplicit, targetPort, restartTrigger, envVars, diff --git a/src/cli/tui/screens/dev/DevScreen.tsx b/src/cli/tui/screens/dev/DevScreen.tsx index 8dbc66c75..bf51899cf 100644 --- a/src/cli/tui/screens/dev/DevScreen.tsx +++ b/src/cli/tui/screens/dev/DevScreen.tsx @@ -23,6 +23,8 @@ interface DevScreenProps { onBack: () => void; workingDir?: string; port?: number; + /** Whether `port` was set explicitly via -p/--port (no index offset applied when true) */ + portExplicit?: boolean; /** Pre-selected agent name (from CLI --agent flag) */ agentName?: string; /** Custom headers to forward to the agent on every invocation */ @@ -249,6 +251,7 @@ export function DevScreen(props: DevScreenProps) { } = useDevServer({ workingDir, port: props.port ?? 8080, + portExplicit: props.portExplicit, agentName: selectedAgentName, onReady: onServerReady, headers: props.headers, From e42c7d9f0111160d697307ee933a77ce769914b1 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 29 Jun 2026 14:36:24 +0000 Subject: [PATCH 2/4] fix(dev): honor explicit --port literally instead of silently offsetting by runtime index (#1079) From 1ecc5a2c9aa172cff1f0ac110983b9ebe3d5c33e Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 29 Jun 2026 14:42:33 +0000 Subject: [PATCH 3/4] style(dev): format command.tsx fixedPort ternary with prettier --- src/cli/commands/dev/command.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index 2ac7f046d..ab6686d64 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -405,7 +405,11 @@ export const registerDev = (program: Command) => { const isA2A = config.protocol === 'A2A'; const isMcp = config.protocol === 'MCP'; - const fixedPort = isA2A ? 9000 : isMcp ? 8000 : getAgentPort(project, config.agentName, port, portExplicit); + const fixedPort = isA2A + ? 9000 + : isMcp + ? 8000 + : getAgentPort(project, config.agentName, port, portExplicit); if (!isA2A && !isMcp && !portExplicit && fixedPort !== port) { console.log(`Port ${port} in use as base, using ${fixedPort} for ${config.agentName}`); } From 8c56f8f072e5a9e7e59062834a66d5ab70c648a2 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 29 Jun 2026 14:52:16 +0000 Subject: [PATCH 4/4] fix(dev): fail fast on explicit --port conflict, clarify offset log, instrument port_explicit - throw/error instead of silently rebinding when an explicit -p is taken across the --logs, TUI, and web-ui start paths (the silent-shift behavior #1079 removes) - reword the implicit-offset log to name the runtime index and how to override (no longer implies a port conflict) - web-ui: honor explicit -p literally only for the selected runtime; other runtimes keep the default uiPort+1+index allocation (no ports below the base) - extract resolveAgentTargetPort and unit-test it - add optional port_explicit telemetry attr to DevAttrs --- src/cli/commands/dev/browser-mode.ts | 2 +- src/cli/commands/dev/command.tsx | 17 ++++- .../dev/web-ui/__tests__/start-port.test.ts | 76 +++++++++++++++++++ .../operations/dev/web-ui/handlers/start.ts | 60 ++++++++++++--- src/cli/operations/dev/web-ui/web-server.ts | 8 +- src/cli/telemetry/schemas/command-run.ts | 3 + src/cli/tui/hooks/useDevServer.ts | 10 +++ 7 files changed, 158 insertions(+), 18 deletions(-) create mode 100644 src/cli/operations/dev/web-ui/__tests__/start-port.test.ts diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index 33997c9e7..a3c8102bb 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -104,7 +104,7 @@ export interface BrowserModeOptions { workingDir: string; project: AgentCoreProjectSpec; port: number; - /** Whether `port` was set explicitly via -p/--port (used as the agent base port instead of uiPort+1) */ + /** Whether `port` was set explicitly via -p/--port (the selected runtime then binds it literally) */ portExplicit?: boolean; agentName?: string; harnessName?: string; diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index ab6686d64..41176398b 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -322,6 +322,8 @@ export const registerDev = (program: Command) => { invoke_count: 0, }, async recorder => { + recorder.set({ port_explicit: portExplicit }); + const project = await loadProjectConfig(workingDir); if (!project) { throw new NoProjectError(); @@ -405,13 +407,17 @@ export const registerDev = (program: Command) => { const isA2A = config.protocol === 'A2A'; const isMcp = config.protocol === 'MCP'; + const isHttp = !isA2A && !isMcp; const fixedPort = isA2A ? 9000 : isMcp ? 8000 : getAgentPort(project, config.agentName, port, portExplicit); - if (!isA2A && !isMcp && !portExplicit && fixedPort !== port) { - console.log(`Port ${port} in use as base, using ${fixedPort} for ${config.agentName}`); + if (isHttp && !portExplicit && fixedPort !== port) { + const idx = project.runtimes.findIndex(a => a.name === config.agentName); + console.log( + `Runtime "${config.agentName}" is at index ${idx}; using port ${fixedPort} (pass --port ${fixedPort} to override).` + ); } const actualPort = await findAvailablePort(fixedPort); if ((isA2A || isMcp) && actualPort !== fixedPort) { @@ -419,6 +425,13 @@ export const registerDev = (program: Command) => { `Port ${fixedPort} is in use. ${config.protocol} agents require port ${fixedPort}.` ); } + // An explicit -p must be honored literally; if it's taken, fail fast instead of + // silently rebinding to a different port (the silent-shift behavior #1079 removes). + if (isHttp && portExplicit && actualPort !== fixedPort) { + throw new ValidationError( + `Port ${fixedPort} is in use. Free it or pass a different --port (no port is chosen automatically when --port is set explicitly).` + ); + } // Deploy resources before starting dev server (harness projects) if (!opts.skipDeploy && hasHarnesses) { diff --git a/src/cli/operations/dev/web-ui/__tests__/start-port.test.ts b/src/cli/operations/dev/web-ui/__tests__/start-port.test.ts new file mode 100644 index 000000000..34e1e6cc1 --- /dev/null +++ b/src/cli/operations/dev/web-ui/__tests__/start-port.test.ts @@ -0,0 +1,76 @@ +import { resolveAgentTargetPort } from '../handlers/start.js'; +import { describe, expect, it } from 'vitest'; + +describe('resolveAgentTargetPort', () => { + const base = { uiPort: 7777 }; + + it('uses uiPort + 1 + index for HTTP runtimes when no explicit -p is set', () => { + expect(resolveAgentTargetPort({ ...base, protocol: 'HTTP', agentName: 'A', agentIndex: 0 })).toBe(7778); + expect(resolveAgentTargetPort({ ...base, protocol: 'HTTP', agentName: 'B', agentIndex: 1 })).toBe(7779); + }); + + it('falls back to index 0 when the agent is not found', () => { + expect(resolveAgentTargetPort({ ...base, protocol: 'HTTP', agentName: 'missing', agentIndex: -1 })).toBe(7778); + }); + + it('uses framework-fixed ports for A2A and MCP regardless of -p', () => { + expect( + resolveAgentTargetPort({ + ...base, + protocol: 'A2A', + agentName: 'A', + agentIndex: 3, + agentBasePort: 8788, + selectedAgent: 'A', + }) + ).toBe(9000); + expect( + resolveAgentTargetPort({ + ...base, + protocol: 'MCP', + agentName: 'A', + agentIndex: 3, + agentBasePort: 8788, + selectedAgent: 'A', + }) + ).toBe(8000); + }); + + it('honors an explicit -p literally for the selected runtime (no offset)', () => { + expect( + resolveAgentTargetPort({ + ...base, + protocol: 'HTTP', + agentName: 'AgentB', + agentIndex: 1, + agentBasePort: 8788, + selectedAgent: 'AgentB', + }) + ).toBe(8788); + }); + + it('keeps the default allocation for non-selected runtimes even when -p is explicit', () => { + // AgentA (index 0) is not selected, so it never binds below the requested -p. + expect( + resolveAgentTargetPort({ + ...base, + protocol: 'HTTP', + agentName: 'AgentA', + agentIndex: 0, + agentBasePort: 8788, + selectedAgent: 'AgentB', + }) + ).toBe(7778); + // AgentC (index 2) likewise uses the default base, not a port derived from -p. + expect( + resolveAgentTargetPort({ + ...base, + protocol: 'HTTP', + agentName: 'AgentC', + agentIndex: 2, + agentBasePort: 8788, + selectedAgent: 'AgentB', + }) + ).toBe(7780); + }); +}); diff --git a/src/cli/operations/dev/web-ui/handlers/start.ts b/src/cli/operations/dev/web-ui/handlers/start.ts index dcb95fab4..64afcabf5 100644 --- a/src/cli/operations/dev/web-ui/handlers/start.ts +++ b/src/cli/operations/dev/web-ui/handlers/start.ts @@ -70,6 +70,35 @@ export async function handleStart( } } +/** + * Resolve the target port a web-UI-served agent should bind to. + * + * - A2A/MCP agents use their framework-fixed ports (9000 / 8000). + * - When `-p`/`--port` was set explicitly (`agentBasePort !== undefined`), the + * *selected* runtime is honored literally (binds exactly `agentBasePort`, no + * offset). All other runtimes fall back to the default `uiPort + 1 + index` + * allocation, so an explicit `-p` never produces ports below the requested + * value or for runtimes the user didn't ask for. + * - Otherwise every HTTP runtime is allocated `uiPort + 1 + index`. + */ +export function resolveAgentTargetPort(args: { + protocol: string; + agentName: string; + agentIndex: number; + uiPort: number; + agentBasePort?: number; + selectedAgent?: string; +}): number { + const { protocol, agentName, agentIndex, uiPort, agentBasePort, selectedAgent } = args; + if (protocol === 'A2A') return 9000; + if (protocol === 'MCP') return 8000; + const safeIndex = agentIndex >= 0 ? agentIndex : 0; + if (agentBasePort !== undefined && agentName === selectedAgent) { + return agentBasePort; + } + return uiPort + 1 + safeIndex; +} + /** * Actually start an agent server. Extracted so the result * can be shared across concurrent requests via startingAgents. @@ -100,18 +129,17 @@ async function doStartAgent( const isMCP = config.protocol === 'MCP'; const fixedPort = isA2A ? 9000 : isMCP ? 8000 : undefined; const isTsHttp = !config.isPython && config.protocol === 'HTTP'; - // When -p is set explicitly, honor it for the selected runtime (no offset) so the - // web UI matches the --logs and TUI paths; other concurrently-served runtimes are - // offset relative to it. Otherwise derive HTTP ports from uiPort + 1 + index. - const selectedIndex = ctx.options.selectedAgent - ? ctx.options.agents.findIndex(a => a.name === ctx.options.selectedAgent) - : -1; - const safeAgentIndex = agentIndex >= 0 ? agentIndex : 0; - const targetPort = - fixedPort ?? - (ctx.options.agentBasePort !== undefined - ? ctx.options.agentBasePort + (safeAgentIndex - (selectedIndex >= 0 ? selectedIndex : 0)) - : ctx.options.uiPort + 1 + safeAgentIndex); + const targetPort = resolveAgentTargetPort({ + protocol: config.protocol, + agentName, + agentIndex, + uiPort: ctx.options.uiPort, + agentBasePort: ctx.options.agentBasePort, + selectedAgent: ctx.options.selectedAgent, + }); + // An explicit -p must be honored literally for the selected runtime; if it's taken, + // fail fast instead of silently rebinding (the silent-shift behavior #1079 removes). + const portIsExplicit = ctx.options.agentBasePort !== undefined && agentName === ctx.options.selectedAgent; const agentPort = await findAvailablePort(targetPort); if (fixedPort && agentPort !== fixedPort) { const reason = isA2A ? 'A2A agents require port 9000.' : 'MCP agents require port 8000 (FastMCP default).'; @@ -122,6 +150,14 @@ async function doStartAgent( error: `Port ${fixedPort} is in use. ${reason}`, }; } + if (portIsExplicit && agentPort !== targetPort) { + return { + success: false, + name: agentName, + port: 0, + error: `Port ${targetPort} is in use. Free it or pass a different --port (no port is chosen automatically when --port is set explicitly).`, + }; + } if (agentPort !== targetPort) { onLog?.('info', `[${agentName}] Port ${targetPort} in use, using ${agentPort}`); } diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts index e8f2b1a61..c6826de90 100644 --- a/src/cli/operations/dev/web-ui/web-server.ts +++ b/src/cli/operations/dev/web-ui/web-server.ts @@ -147,9 +147,11 @@ export interface WebUIOptions { /** Available agents (metadata only — servers are started on demand) */ agents: AgentInfo[]; /** - * Explicit agent base port from -p/--port. When set, HTTP agents bind off this - * value (base + index) instead of uiPort + 1 + index, so an explicit `-p` is - * honored in the web UI consistently with the --logs and TUI paths. + * Explicit agent base port from -p/--port. When set, the selected runtime + * ({@link selectedAgent}) is honored literally (binds exactly this value, no + * offset); other HTTP runtimes keep the default uiPort + 1 + index allocation. + * This keeps an explicit `-p` consistent with the --logs and TUI paths without + * remapping runtimes the user didn't ask for. */ agentBasePort?: number; /** Deployed harnesses available for invocation (metadata only — no local server needed) */ diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index 564709f2d..3df3888c5 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -129,6 +129,9 @@ const DevAttrs = safeSchema({ has_stream: z.boolean(), agent_protocol: AgentProtocol.optional(), invoke_count: Count, + // Whether -p/--port was set explicitly (vs the default). Lets us track adoption + // of the explicit-port-honored behavior introduced for #1079. + port_explicit: z.boolean().optional(), }); const InvokeAttrs = safeSchema({ diff --git a/src/cli/tui/hooks/useDevServer.ts b/src/cli/tui/hooks/useDevServer.ts index 5444a7d91..6f3cd3722 100644 --- a/src/cli/tui/hooks/useDevServer.ts +++ b/src/cli/tui/hooks/useDevServer.ts @@ -185,6 +185,16 @@ export function useDevServer(options: { } else { port = isRestart && portFree ? actualPortRef.current : await findAvailablePort(fixedPort); if (!isRestart && port !== fixedPort) { + // An explicit -p must be honored literally; if it's taken, surface an error + // instead of silently rebinding (the silent-shift behavior #1079 removes). + if (options.portExplicit) { + addLog( + 'error', + `Port ${fixedPort} is in use. Free it or pass a different --port (no port is chosen automatically when --port is set explicitly).` + ); + setStatus('error'); + return; + } addLog('warn', `Port ${fixedPort} in use, using ${port}`); } }