From daf5bb2e19039e66adc39c4c19d0ef04c73a3793 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 27 Mar 2026 14:45:03 -0400 Subject: [PATCH 1/7] feat(dev): add positional prompt arg, remove --invoke flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `agentcore dev --invoke "Hello"` with `agentcore dev "Hello"`. The dev command now accepts an optional positional [prompt] argument that invokes a running dev server directly. The --invoke/-i flag is removed entirely — this is a breaking change for scripts using it. Rejected: keep --invoke as deprecated alias | adds UX confusion with two ways to do the same thing Confidence: high Scope-risk: narrow --- docs/commands.md | 25 +++++++------- docs/local-development.md | 11 +++---- src/cli/commands/dev/__tests__/dev.test.ts | 38 ++++++++++++++++++++-- src/cli/commands/dev/command.tsx | 29 +++++++++-------- src/cli/tui/copy.ts | 2 +- 5 files changed, 69 insertions(+), 36 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index fbfd5c7f..49fc1354 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -475,22 +475,23 @@ Start local development server with hot-reload. agentcore dev agentcore dev --agent MyAgent --port 3000 agentcore dev --logs # Non-interactive -agentcore dev --invoke "Hello" --stream # Direct invoke +agentcore dev "Hello" --stream # Invoke running server +agentcore dev "Hello" --agent MyAgent # Invoke specific agent # MCP protocol dev commands -agentcore dev --invoke list-tools -agentcore dev --invoke call-tool --tool myTool --input '{"arg": "value"}' +agentcore dev list-tools +agentcore dev call-tool --tool myTool --input '{"arg": "value"}' ``` -| Flag | Description | -| ----------------------- | ------------------------------------------------------ | -| `-p, --port ` | Port (default: 8080; MCP uses 8000, A2A uses 9000) | -| `-a, --agent ` | Agent to run (required if multiple agents) | -| `-i, --invoke ` | Invoke running server | -| `-s, --stream` | Stream response (with --invoke) | -| `-l, --logs` | Non-interactive stdout logging | -| `--tool ` | MCP tool name (with `--invoke call-tool`) | -| `--input ` | MCP tool arguments as JSON (with `--invoke call-tool`) | +| Flag / Argument | Description | +| -------------------- | ---------------------------------------------------- | +| `[prompt]` | Invoke running dev server with this prompt | +| `-p, --port ` | Port (default: 8080; MCP uses 8000, A2A uses 9000) | +| `-a, --agent ` | Agent to run or invoke (required if multiple agents) | +| `-s, --stream` | Stream response when invoking | +| `-l, --logs` | Non-interactive stdout logging | +| `--tool ` | MCP tool name (with `call-tool` prompt) | +| `--input ` | MCP tool arguments as JSON (with `--tool`) | ### invoke diff --git a/docs/local-development.md b/docs/local-development.md index 284bfd6a..e81252e3 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -23,17 +23,14 @@ agentcore dev --logs With the dev server running, open another terminal: ```bash -# Interactive chat -agentcore invoke - # Single prompt -agentcore invoke "What can you do?" +agentcore dev "What can you do?" # With streaming -agentcore invoke "Tell me a story" --stream +agentcore dev "Tell me a story" --stream -# Direct invoke to running server -agentcore dev --invoke "Hello" --stream +# Specify agent (if multiple) +agentcore dev "Hello" --agent MyAgent ``` ## Environment Setup diff --git a/src/cli/commands/dev/__tests__/dev.test.ts b/src/cli/commands/dev/__tests__/dev.test.ts index 61c3d867..7e5ef84d 100644 --- a/src/cli/commands/dev/__tests__/dev.test.ts +++ b/src/cli/commands/dev/__tests__/dev.test.ts @@ -7,13 +7,20 @@ describe('dev command', () => { const result = await runCLI(['dev', '--help'], process.cwd()); expect(result.exitCode).toBe(0); + expect(result.stdout.includes('[prompt]'), 'Should show [prompt] positional argument').toBeTruthy(); expect(result.stdout.includes('--port'), 'Should show --port option').toBeTruthy(); expect(result.stdout.includes('--agent'), 'Should show --agent option').toBeTruthy(); - expect(result.stdout.includes('--invoke'), 'Should show --invoke option').toBeTruthy(); expect(result.stdout.includes('--stream'), 'Should show --stream option').toBeTruthy(); expect(result.stdout.includes('--logs'), 'Should show --logs option').toBeTruthy(); expect(result.stdout.includes('8080'), 'Should show default port').toBeTruthy(); }); + + it('does not show --invoke flag', async () => { + const result = await runCLI(['dev', '--help'], process.cwd()); + + expect(result.exitCode).toBe(0); + expect(result.stdout.includes('--invoke'), 'Should not show removed --invoke option').toBeFalsy(); + }); }); describe('requires project context', () => { @@ -28,6 +35,34 @@ describe('dev command', () => { }); }); + describe('positional prompt invoke', () => { + it('attempts invoke when positional prompt is provided', async () => { + // With no dev server running, the invoke path triggers a connection error + const result = await runCLI(['dev', 'Hello agent'], process.cwd()); + + expect(result.exitCode).toBe(1); + // Should attempt to connect to dev server and fail — not show a project error + const output = result.stderr.toLowerCase(); + expect( + output.includes('fetch failed') || output.includes('econnrefused') || output.includes('dev server not running'), + `Should attempt invoke and fail with connection error, got: ${result.stderr}` + ).toBeTruthy(); + }); + + it('does not require project context when invoking', async () => { + // Invoke path loads config but does not call requireProject() + // So the error should be about connection, not missing project + const result = await runCLI(['dev', 'test prompt'], process.cwd()); + + expect(result.exitCode).toBe(1); + const output = result.stderr.toLowerCase(); + expect( + !output.includes('no agentcore project found'), + `Should not fail with project error when prompt is provided, got: ${result.stderr}` + ).toBeTruthy(); + }); + }); + describe('flag validation', () => { it('rejects invalid port number', async () => { const result = await runCLI(['dev', '--port', 'abc'], process.cwd()); @@ -46,7 +81,6 @@ describe('dev command', () => { expect(result.exitCode).toBe(0); expect(result.stdout.includes('--stream'), 'Should show --stream option').toBeTruthy(); - expect(result.stdout.includes('--invoke'), 'Should show --invoke option').toBeTruthy(); }); }); }); diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index afd352ca..7f6de640 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -96,8 +96,8 @@ async function handleMcpInvoke( } } else if (invokeValue === 'call-tool') { if (!toolName) { - console.error('Error: --tool is required with --invoke call-tool'); - console.error('Usage: agentcore dev --invoke call-tool --tool --input \'{"arg": "value"}\''); + console.error('Error: --tool is required with call-tool'); + console.error('Usage: agentcore dev call-tool --tool --input \'{"arg": "value"}\''); process.exit(1); } // Initialize session first, then call tool with the session ID @@ -117,8 +117,8 @@ async function handleMcpInvoke( } else { console.error(`Error: Unknown MCP invoke command "${invokeValue}"`); console.error('Usage:'); - console.error(' agentcore dev --invoke list-tools'); - console.error(' agentcore dev --invoke call-tool --tool --input \'{"arg": "value"}\''); + console.error(' agentcore dev list-tools'); + console.error(' agentcore dev call-tool --tool --input \'{"arg": "value"}\''); process.exit(1); } } catch (err) { @@ -137,20 +137,20 @@ export const registerDev = (program: Command) => { .command('dev') .alias('d') .description(COMMAND_DESCRIPTIONS.dev) + .argument('[prompt]', 'Invoke running dev server with this prompt [non-interactive]') .option('-p, --port ', 'Port for development server', '8080') .option('-a, --agent ', 'Agent to run or invoke (required if multiple agents)') - .option('-i, --invoke ', 'Invoke running dev server (use --agent if multiple) [non-interactive]') - .option('-s, --stream', 'Stream response when using --invoke [non-interactive]') + .option('-s, --stream', 'Stream response when invoking [non-interactive]') .option('-l, --logs', 'Run dev server with logs to stdout [non-interactive]') - .option('--tool ', 'MCP tool name (used with --invoke call-tool) [non-interactive]') - .option('--input ', 'MCP tool arguments as JSON (used with --invoke call-tool) [non-interactive]') + .option('--tool ', 'MCP tool name (used with "call-tool" prompt) [non-interactive]') + .option('--input ', 'MCP tool arguments as JSON (used with --tool) [non-interactive]') .option( '-H, --header
', 'Custom header to forward to the agent (format: "Name: Value", repeatable) [non-interactive]', (val: string, prev: string[]) => [...prev, val], [] as string[] ) - .action(async opts => { + .action(async (positionalPrompt: string | undefined, opts) => { try { const port = parseInt(opts.port, 10); @@ -160,8 +160,9 @@ export const registerDev = (program: Command) => { headers = parseHeaderFlags(opts.header); } - // If --invoke provided, call the dev server and exit - if (opts.invoke) { + // If a prompt is provided, call the dev server and exit + const invokePrompt = positionalPrompt; + if (invokePrompt) { const invokeProject = await loadProjectConfig(getWorkingDirectory()); // Determine which agent/port to invoke @@ -190,11 +191,11 @@ export const registerDev = (program: Command) => { // Protocol-aware dispatch if (protocol === 'MCP') { - await handleMcpInvoke(invokePort, opts.invoke, opts.tool, opts.input, headers); + await handleMcpInvoke(invokePort, invokePrompt, opts.tool, opts.input, headers); } else if (protocol === 'A2A') { - await invokeA2ADevServer(invokePort, opts.invoke, headers); + await invokeA2ADevServer(invokePort, invokePrompt, headers); } else { - await invokeDevServer(invokePort, opts.invoke, opts.stream ?? false, headers); + await invokeDevServer(invokePort, invokePrompt, opts.stream ?? false, headers); } return; } diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index 7e0ddf78..1dae2fd4 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -32,7 +32,7 @@ export const COMMAND_DESCRIPTIONS = { add: 'Add resources (agent, evaluator, online-eval, memory, credential, target)', create: 'Create a new AgentCore project', deploy: 'Deploy project infrastructure to AWS via CDK.', - dev: 'Launch local development server with hot-reload.', + dev: 'Launch local dev server, or invoke a running one.', invoke: 'Invoke a deployed agent endpoint.', logs: 'Stream or search agent runtime logs.', package: 'Package agent artifacts without deploying.', From 814a1d0352ad59d6006e2ca045416a9054981963 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 27 Mar 2026 16:12:45 -0400 Subject: [PATCH 2/7] feat(dev): auto-start dev server on invoke When running `agentcore dev "Hello"`, the CLI now automatically starts a dev server if none is running, invokes it, and shuts it down after. If a server is already running (via --logs or TUI), it reuses it. - Export waitForServerReady from dev operations barrel - Probe target port before invoking; auto-start if nothing listening - Use Promise.race to bail early if server crashes during startup - Silent startup (no "Starting dev server..." output) - Helpful error when outside project with no server running - Update tests, docs, and command descriptions Constraint: Must not add latency when server is already running Rejected: Always require separate terminal for dev server | poor UX parity with agentcore invoke Confidence: high Scope-risk: narrow --- docs/commands.md | 4 +- docs/local-development.md | 7 +- src/cli/commands/dev/__tests__/dev.test.ts | 20 ++--- src/cli/commands/dev/command.tsx | 94 +++++++++++++++++++--- src/cli/operations/dev/index.ts | 1 + src/cli/operations/dev/server.ts | 2 +- src/cli/tui/copy.ts | 2 +- 7 files changed, 101 insertions(+), 29 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 49fc1354..51d32962 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -475,7 +475,7 @@ Start local development server with hot-reload. agentcore dev agentcore dev --agent MyAgent --port 3000 agentcore dev --logs # Non-interactive -agentcore dev "Hello" --stream # Invoke running server +agentcore dev "Hello" --stream # Invoke agent (auto-starts server) agentcore dev "Hello" --agent MyAgent # Invoke specific agent # MCP protocol dev commands @@ -485,7 +485,7 @@ agentcore dev call-tool --tool myTool --input '{"arg": "value"}' | Flag / Argument | Description | | -------------------- | ---------------------------------------------------- | -| `[prompt]` | Invoke running dev server with this prompt | +| `[prompt]` | Invoke local agent (auto-starts server if needed) | | `-p, --port ` | Port (default: 8080; MCP uses 8000, A2A uses 9000) | | `-a, --agent ` | Agent to run or invoke (required if multiple agents) | | `-s, --stream` | Stream response when invoking | diff --git a/docs/local-development.md b/docs/local-development.md index e81252e3..7648cff1 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -20,10 +20,10 @@ agentcore dev --logs ## Invoking Local Agents -With the dev server running, open another terminal: +Send a prompt to your agent — the dev server starts automatically if it isn't already running: ```bash -# Single prompt +# Single prompt (auto-starts server, invokes, shuts down) agentcore dev "What can you do?" # With streaming @@ -33,6 +33,9 @@ agentcore dev "Tell me a story" --stream agentcore dev "Hello" --agent MyAgent ``` +If you already have `agentcore dev` or `agentcore dev --logs` running in another terminal, the invoke reuses that +server. + ## Environment Setup ### Python Virtual Environment diff --git a/src/cli/commands/dev/__tests__/dev.test.ts b/src/cli/commands/dev/__tests__/dev.test.ts index 7e5ef84d..4b1df3da 100644 --- a/src/cli/commands/dev/__tests__/dev.test.ts +++ b/src/cli/commands/dev/__tests__/dev.test.ts @@ -36,29 +36,29 @@ describe('dev command', () => { }); describe('positional prompt invoke', () => { - it('attempts invoke when positional prompt is provided', async () => { - // With no dev server running, the invoke path triggers a connection error + it('exits with helpful error when no server running and no project found', async () => { + // With no dev server running and no project, the auto-start path + // detects both conditions and shows a helpful error const result = await runCLI(['dev', 'Hello agent'], process.cwd()); expect(result.exitCode).toBe(1); - // Should attempt to connect to dev server and fail — not show a project error const output = result.stderr.toLowerCase(); expect( - output.includes('fetch failed') || output.includes('econnrefused') || output.includes('dev server not running'), - `Should attempt invoke and fail with connection error, got: ${result.stderr}` + output.includes('no dev server running'), + `Should mention no dev server running, got: ${result.stderr}` ).toBeTruthy(); }); - it('does not require project context when invoking', async () => { - // Invoke path loads config but does not call requireProject() - // So the error should be about connection, not missing project + it('does not go through requireProject guard when invoking', async () => { + // Invoke path uses loadProjectConfig (soft check), not requireProject() + // The error should mention the dev server, not the generic project guard message const result = await runCLI(['dev', 'test prompt'], process.cwd()); expect(result.exitCode).toBe(1); const output = result.stderr.toLowerCase(); expect( - !output.includes('no agentcore project found'), - `Should not fail with project error when prompt is provided, got: ${result.stderr}` + !output.includes('run agentcore create'), + `Should not show the requireProject guard message, got: ${result.stderr}` ).toBeTruthy(); }); }); diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index 7f6de640..4012918f 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -14,6 +14,7 @@ import { invokeForProtocol, listMcpTools, loadProjectConfig, + waitForServerReady, } from '../../operations/dev'; import { getGatewayEnvVars } from '../../operations/dev/gateway-env.js'; import { FatalError } from '../../tui/components'; @@ -137,7 +138,7 @@ export const registerDev = (program: Command) => { .command('dev') .alias('d') .description(COMMAND_DESCRIPTIONS.dev) - .argument('[prompt]', 'Invoke running dev server with this prompt [non-interactive]') + .argument('[prompt]', 'Invoke local agent with this prompt (auto-starts server if needed) [non-interactive]') .option('-p, --port ', 'Port for development server', '8080') .option('-a, --agent ', 'Agent to run or invoke (required if multiple agents)') .option('-s, --stream', 'Stream response when invoking [non-interactive]') @@ -160,10 +161,11 @@ export const registerDev = (program: Command) => { headers = parseHeaderFlags(opts.header); } - // If a prompt is provided, call the dev server and exit + // If a prompt is provided, invoke the dev server (auto-starting if needed) const invokePrompt = positionalPrompt; if (invokePrompt) { - const invokeProject = await loadProjectConfig(getWorkingDirectory()); + const workingDir = getWorkingDirectory(); + const invokeProject = await loadProjectConfig(workingDir); // Determine which agent/port to invoke let invokePort = port; @@ -184,18 +186,84 @@ export const registerDev = (program: Command) => { if (protocol === 'A2A') invokePort = 9000; else if (protocol === 'MCP') invokePort = 8000; - // Show model info if available (not applicable to MCP) - if (protocol !== 'MCP' && targetAgent?.modelProvider) { - console.log(`Provider: ${targetAgent.modelProvider}`); + // Check if a dev server is already running on the target port + const serverRunning = await waitForServerReady(invokePort, 500); + + // Auto-start a dev server if none is running + let autoStartedServer: ReturnType | undefined; + if (!serverRunning) { + if (!invokeProject) { + console.error('Error: No dev server running and no agentcore project found.'); + console.error('Start a dev server first: agentcore dev'); + process.exit(1); + } + + const configRoot = findConfigRoot(workingDir); + const envVars = configRoot ? await readEnvFile(configRoot) : {}; + const gatewayEnvVars = await getGatewayEnvVars(); + const mergedEnvVars = { ...gatewayEnvVars, ...envVars }; + const agentName = opts.agent ?? invokeProject.agents[0]?.name; + const config = getDevConfig(workingDir, invokeProject, configRoot ?? undefined, agentName); + + if (!config) { + console.error('Error: No dev-supported agents found.'); + process.exit(1); + } + + const serverErrors: string[] = []; + let resolveServerExit: () => void; + const serverExitPromise = new Promise(resolve => { + resolveServerExit = resolve; + }); + + const devCallbacks = { + onLog: (level: string, msg: string) => { + if (level === 'error') serverErrors.push(msg); + }, + onExit: () => { + resolveServerExit(); + }, + }; + + const server = createDevServer(config, { + port: invokePort, + envVars: mergedEnvVars, + callbacks: devCallbacks, + }); + await server.start(); + + // Wait for server to accept connections, bail early if process crashes + const ready = await Promise.race([waitForServerReady(invokePort), serverExitPromise.then(() => false)]); + + if (!ready) { + if (serverErrors.length > 0) { + console.error(serverErrors.slice(-5).join('\n')); + } + console.error('Error: Dev server failed to start. Run "agentcore dev --logs" for details.'); + server.kill(); + process.exit(1); + } + autoStartedServer = server; } - // Protocol-aware dispatch - if (protocol === 'MCP') { - await handleMcpInvoke(invokePort, invokePrompt, opts.tool, opts.input, headers); - } else if (protocol === 'A2A') { - await invokeA2ADevServer(invokePort, invokePrompt, headers); - } else { - await invokeDevServer(invokePort, invokePrompt, opts.stream ?? false, headers); + try { + // Show model info if available (not applicable to MCP) + if (protocol !== 'MCP' && targetAgent?.modelProvider) { + console.log(`Provider: ${targetAgent.modelProvider}`); + } + + // Protocol-aware dispatch + if (protocol === 'MCP') { + await handleMcpInvoke(invokePort, invokePrompt, opts.tool, opts.input, headers); + } else if (protocol === 'A2A') { + await invokeA2ADevServer(invokePort, invokePrompt, headers); + } else { + await invokeDevServer(invokePort, invokePrompt, opts.stream ?? false, headers); + } + } finally { + if (autoStartedServer) { + autoStartedServer.kill(); + } } return; } diff --git a/src/cli/operations/dev/index.ts b/src/cli/operations/dev/index.ts index ae3c8edd..5c11b692 100644 --- a/src/cli/operations/dev/index.ts +++ b/src/cli/operations/dev/index.ts @@ -1,6 +1,7 @@ export { findAvailablePort, waitForPort, + waitForServerReady, createDevServer, DevServer, type LogLevel, diff --git a/src/cli/operations/dev/server.ts b/src/cli/operations/dev/server.ts index 1f3b3751..050e1bbb 100644 --- a/src/cli/operations/dev/server.ts +++ b/src/cli/operations/dev/server.ts @@ -7,7 +7,7 @@ import type { DevServer, DevServerOptions } from './dev-server'; * Dev server barrel module. * Re-exports types, utilities, and the factory function. */ -export { findAvailablePort, waitForPort } from './utils'; +export { findAvailablePort, waitForPort, waitForServerReady } from './utils'; export { DevServer, type LogLevel, type DevServerCallbacks, type DevServerOptions } from './dev-server'; export { CodeZipDevServer } from './codezip-dev-server'; export { ContainerDevServer } from './container-dev-server'; diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index 1dae2fd4..f1cce21a 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -32,7 +32,7 @@ export const COMMAND_DESCRIPTIONS = { add: 'Add resources (agent, evaluator, online-eval, memory, credential, target)', create: 'Create a new AgentCore project', deploy: 'Deploy project infrastructure to AWS via CDK.', - dev: 'Launch local dev server, or invoke a running one.', + dev: 'Launch local dev server, or invoke an agent locally.', invoke: 'Invoke a deployed agent endpoint.', logs: 'Stream or search agent runtime logs.', package: 'Package agent artifacts without deploying.', From 6f92f56acd039bc834d0e04d9930bfe271efc80f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 27 Mar 2026 16:24:01 -0400 Subject: [PATCH 3/7] fix(dev): prevent orphaned servers and handle empty prompt Fixes found during bugbash of auto-start feature: - Register process.on('exit') handler to kill auto-started server, ensuring cleanup even when invoke helpers call process.exit(1) - Initialize resolveServerExit with safe default to prevent potential undefined call if Promise executor changes - Add unref() to SIGKILL timer in DevServer.kill() so Node.js exits promptly instead of hanging 2 seconds - Use !== undefined check for prompt so empty string "" enters invoke path instead of falling through to interactive TUI Constraint: process.on('exit') handlers are synchronous; cannot await server shutdown Rejected: Modify invoke helpers to throw instead of process.exit | too many callers to change Confidence: high Scope-risk: narrow Not-tested: rapid consecutive invocations (port TIME_WAIT race) --- src/cli/commands/dev/command.tsx | 10 +++++++--- src/cli/operations/dev/dev-server.ts | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index 4012918f..410df94b 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -163,7 +163,7 @@ export const registerDev = (program: Command) => { // If a prompt is provided, invoke the dev server (auto-starting if needed) const invokePrompt = positionalPrompt; - if (invokePrompt) { + if (invokePrompt !== undefined) { const workingDir = getWorkingDirectory(); const invokeProject = await loadProjectConfig(workingDir); @@ -211,7 +211,8 @@ export const registerDev = (program: Command) => { } const serverErrors: string[] = []; - let resolveServerExit: () => void; + // eslint-disable-next-line @typescript-eslint/no-empty-function + let resolveServerExit: () => void = () => {}; const serverExitPromise = new Promise(resolve => { resolveServerExit = resolve; }); @@ -232,6 +233,10 @@ export const registerDev = (program: Command) => { }); await server.start(); + // Ensure server cleanup on any exit path (process.exit in invoke helpers, SIGINT, etc.) + const cleanupServer = () => server.kill(); + process.on('exit', cleanupServer); + // Wait for server to accept connections, bail early if process crashes const ready = await Promise.race([waitForServerReady(invokePort), serverExitPromise.then(() => false)]); @@ -240,7 +245,6 @@ export const registerDev = (program: Command) => { console.error(serverErrors.slice(-5).join('\n')); } console.error('Error: Dev server failed to start. Run "agentcore dev --logs" for details.'); - server.kill(); process.exit(1); } autoStartedServer = server; diff --git a/src/cli/operations/dev/dev-server.ts b/src/cli/operations/dev/dev-server.ts index a0d3c19d..10424c0d 100644 --- a/src/cli/operations/dev/dev-server.ts +++ b/src/cli/operations/dev/dev-server.ts @@ -82,9 +82,10 @@ export abstract class DevServer { kill(): void { if (!this.child || this.child.killed) return; this.child.kill('SIGTERM'); - setTimeout(() => { + const killTimer = setTimeout(() => { if (this.child && !this.child.killed) this.child.kill('SIGKILL'); }, 2000); + killTimer.unref(); } /** Mode-specific setup (e.g., venv creation, container image build). Returns false to abort. */ From 70eaac8383ab92ffceb731c48e76720c7040ff2e Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 27 Mar 2026 16:29:19 -0400 Subject: [PATCH 4/7] fix(dev): detect non-agentcore servers on target port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the TCP port probe finds something listening, do a lightweight HTTP GET to the endpoint. If the response is HTML (text/html), the server is not an agentcore dev server — show a clear error instead of sending a POST and dumping raw HTML as the error message. Constraint: Cannot add a /health endpoint without modifying all Python agent templates Rejected: HEAD request probe | some servers return different headers for HEAD vs GET Confidence: high Scope-risk: narrow --- src/cli/commands/dev/command.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index 410df94b..f82b5ace 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -189,6 +189,22 @@ export const registerDev = (program: Command) => { // Check if a dev server is already running on the target port const serverRunning = await waitForServerReady(invokePort, 500); + // If something is listening, verify it's an agentcore dev server (not a random process) + if (serverRunning) { + try { + const probeUrl = getEndpointUrl(invokePort, protocol); + const probe = await fetch(probeUrl, { method: 'GET', signal: AbortSignal.timeout(2000) }); + const contentType = probe.headers.get('content-type') ?? ''; + if (contentType.includes('text/html')) { + console.error(`Error: Port ${invokePort} is in use by another process (not an agentcore dev server).`); + console.error(`Stop the other process or use --port to specify a different port.`); + process.exit(1); + } + } catch { + // Probe failed — let the invoke dispatch handle the error + } + } + // Auto-start a dev server if none is running let autoStartedServer: ReturnType | undefined; if (!serverRunning) { From 52c833ae99abbfe411e3599a8f89396d5ae9dca0 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 27 Mar 2026 17:05:17 -0400 Subject: [PATCH 5/7] fix(dev): reliable server cleanup on SIGINT and non-HTTP port detection - Spawn child processes with detached:true and kill the entire process group (-pid) to clean up uvicorn workers and other grandchild processes on shutdown - Fix SIGKILL fallback that never fired: child.killed is set when the signal is sent, not when the process exits. Use child.exitCode to check actual termination. - Add explicit SIGINT handler in auto-start invoke path so Ctrl+C kills the server before Node exits - Improve port identity probe: detect non-HTTP processes (e.g., raw TCP listeners) by treating probe failures as port conflicts instead of silently falling through - Guard kill() in finally block with try-catch and remove exit listener to prevent leaks Constraint: process.kill(-pid) requires detached:true so child is its own process group leader Rejected: child.killed for exit check | set on signal send, not on actual termination Rejected: process.exit(130) in SIGINT handler | kills Node before child cleanup completes Confidence: high Scope-risk: moderate Not-tested: SIGINT during waitForServerReady phase (before invoke starts) --- src/cli/commands/dev/command.tsx | 31 ++++++++++-- .../__tests__/container-dev-server.test.ts | 8 +++- .../dev/__tests__/dev-server.test.ts | 47 ++++++++++++++----- src/cli/operations/dev/dev-server.ts | 27 +++++++++-- 4 files changed, 91 insertions(+), 22 deletions(-) diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index f82b5ace..17762454 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -200,13 +200,22 @@ export const registerDev = (program: Command) => { console.error(`Stop the other process or use --port to specify a different port.`); process.exit(1); } - } catch { - // Probe failed — let the invoke dispatch handle the error + } catch (probeErr) { + // Probe connected (serverRunning=true) but failed to get an HTTP response. + // This means a non-HTTP process (e.g., raw TCP listener) occupies the port. + const msg = probeErr instanceof Error ? probeErr.message : ''; + if (!msg.includes('ECONNREFUSED')) { + console.error(`Error: Port ${invokePort} is in use by another process (not an agentcore dev server).`); + console.error(`Stop the other process or use --port to specify a different port.`); + process.exit(1); + } } } // Auto-start a dev server if none is running let autoStartedServer: ReturnType | undefined; + // eslint-disable-next-line @typescript-eslint/no-empty-function + let cleanupServer: () => void = () => {}; if (!serverRunning) { if (!invokeProject) { console.error('Error: No dev server running and no agentcore project found.'); @@ -250,8 +259,17 @@ export const registerDev = (program: Command) => { await server.start(); // Ensure server cleanup on any exit path (process.exit in invoke helpers, SIGINT, etc.) - const cleanupServer = () => server.kill(); + cleanupServer = () => { + try { + server.kill(); + } catch { + // Ignore cleanup errors + } + }; process.on('exit', cleanupServer); + process.on('SIGINT', () => { + cleanupServer(); + }); // Wait for server to accept connections, bail early if process crashes const ready = await Promise.race([waitForServerReady(invokePort), serverExitPromise.then(() => false)]); @@ -282,7 +300,12 @@ export const registerDev = (program: Command) => { } } finally { if (autoStartedServer) { - autoStartedServer.kill(); + try { + autoStartedServer.kill(); + } catch { + // Ignore cleanup errors + } + process.removeListener('exit', cleanupServer); } } return; diff --git a/src/cli/operations/dev/__tests__/container-dev-server.test.ts b/src/cli/operations/dev/__tests__/container-dev-server.test.ts index 30dbe43c..855d84c0 100644 --- a/src/cli/operations/dev/__tests__/container-dev-server.test.ts +++ b/src/cli/operations/dev/__tests__/container-dev-server.test.ts @@ -45,6 +45,8 @@ function createMockChildProcess() { proc.stdout = new EventEmitter(); proc.stderr = new EventEmitter(); proc.killed = false; + proc.exitCode = null; + proc.pid = 12345; proc.kill = vi.fn(); return proc; } @@ -472,10 +474,11 @@ describe('ContainerDevServer', () => { describe('kill()', () => { it('stops container using docker stop before calling super.kill()', async () => { + const processKillSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); mockSuccessfulPrepare(); const server = new ContainerDevServer(defaultConfig, defaultOptions); - const child = await server.start(); + await server.start(); // Clear mocks to isolate the kill call mockSpawn.mockClear(); @@ -484,7 +487,8 @@ describe('ContainerDevServer', () => { // Container stop is async (spawn not spawnSync) so UI can render "Stopping..." message expect(mockSpawn).toHaveBeenCalledWith('docker', ['stop', 'agentcore-dev-testagent'], { stdio: 'ignore' }); - expect(child!.kill).toHaveBeenCalledWith('SIGTERM'); // eslint-disable-line @typescript-eslint/unbound-method + expect(processKillSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); + processKillSpy.mockRestore(); }); it('does not call container stop when runtimeBinary is empty (prepare not called)', () => { diff --git a/src/cli/operations/dev/__tests__/dev-server.test.ts b/src/cli/operations/dev/__tests__/dev-server.test.ts index 06827e9c..29576b90 100644 --- a/src/cli/operations/dev/__tests__/dev-server.test.ts +++ b/src/cli/operations/dev/__tests__/dev-server.test.ts @@ -13,6 +13,8 @@ function createMockChildProcess() { proc.stdout = new EventEmitter(); proc.stderr = new EventEmitter(); proc.killed = false; + proc.exitCode = null; + proc.pid = 12345; proc.kill = vi.fn(); return proc; } @@ -75,6 +77,7 @@ describe('DevServer', () => { cwd: '/test', env: { PATH: '/usr/bin' }, stdio: ['ignore', 'pipe', 'pipe'], + detached: true, }); }); @@ -101,22 +104,42 @@ describe('DevServer', () => { }); describe('kill()', () => { + let processKillSpy: ReturnType; + + beforeEach(() => { + processKillSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + }); + + afterEach(() => { + processKillSpy.mockRestore(); + }); + it('does nothing when no child process (no start called)', () => { // Should not throw server.kill(); }); - it('does nothing when child already killed', async () => { + it('does nothing when child already exited', async () => { await server.start(); - mockChild.killed = true; + mockChild.exitCode = 0; server.kill(); - expect(mockChild.kill).not.toHaveBeenCalled(); + expect(processKillSpy).not.toHaveBeenCalled(); }); - it('sends SIGTERM first', async () => { + it('sends SIGTERM to process group first', async () => { await server.start(); + server.kill(); + expect(processKillSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); + }); + + it('falls back to child.kill if process group kill fails', async () => { + processKillSpy.mockImplementation(() => { + throw new Error('ESRCH'); + }); + + await server.start(); server.kill(); expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM'); }); @@ -127,13 +150,13 @@ describe('DevServer', () => { await server.start(); server.kill(); - expect(mockChild.kill).toHaveBeenCalledTimes(1); - expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM'); + expect(processKillSpy).toHaveBeenCalledTimes(1); + expect(processKillSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); vi.advanceTimersByTime(2000); - expect(mockChild.kill).toHaveBeenCalledTimes(2); - expect(mockChild.kill).toHaveBeenCalledWith('SIGKILL'); + expect(processKillSpy).toHaveBeenCalledTimes(2); + expect(processKillSpy).toHaveBeenCalledWith(-12345, 'SIGKILL'); vi.useRealTimers(); }); @@ -144,13 +167,13 @@ describe('DevServer', () => { await server.start(); server.kill(); - // Simulate process dying after SIGTERM - mockChild.killed = true; + // Simulate process exiting after SIGTERM + mockChild.exitCode = 0; vi.advanceTimersByTime(2000); - expect(mockChild.kill).toHaveBeenCalledTimes(1); - expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM'); + expect(processKillSpy).toHaveBeenCalledTimes(1); + expect(processKillSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); vi.useRealTimers(); }); diff --git a/src/cli/operations/dev/dev-server.ts b/src/cli/operations/dev/dev-server.ts index 10424c0d..47ea0b9c 100644 --- a/src/cli/operations/dev/dev-server.ts +++ b/src/cli/operations/dev/dev-server.ts @@ -72,18 +72,37 @@ export abstract class DevServer { cwd: spawnConfig.cwd, env: spawnConfig.env, stdio: ['ignore', 'pipe', 'pipe'], + detached: true, }); this.attachHandlers(); return this.child; } - /** Kill the dev server process. Sends SIGTERM, then SIGKILL after 2 seconds. */ + /** Kill the dev server process group. Sends SIGTERM, then SIGKILL after 2 seconds. */ kill(): void { - if (!this.child || this.child.killed) return; - this.child.kill('SIGTERM'); + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain + if (!this.child || this.child.exitCode !== null) return; + const pid = this.child.pid!; + // Kill the entire process group (negative PID) to clean up child processes (e.g., uvicorn workers) + try { + process.kill(-pid, 'SIGTERM'); + } catch { + this.child.kill('SIGTERM'); + } + const child = this.child; const killTimer = setTimeout(() => { - if (this.child && !this.child.killed) this.child.kill('SIGKILL'); + if (child.exitCode === null) { + try { + process.kill(-pid, 'SIGKILL'); + } catch { + try { + child.kill('SIGKILL'); + } catch { + // Process already dead + } + } + } }, 2000); killTimer.unref(); } From 8c7ea32e175a3bdfbd64e98de92c761c72cd1a8d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 27 Mar 2026 17:33:47 -0400 Subject: [PATCH 6/7] revert(dev): remove dev-server.ts spawn/kill changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the detached:true, process group kill, and exitCode changes from dev-server.ts. These affected all dev server modes (TUI, --logs, auto-start) but were only needed for an edge case in auto-start SIGINT cleanup. Too much blast radius for the benefit. The command.tsx fixes (port probe, SIGINT handler, exit listener cleanup) remain — they are scoped to the invoke path only. --- .../__tests__/container-dev-server.test.ts | 8 +--- .../dev/__tests__/dev-server.test.ts | 47 +++++-------------- src/cli/operations/dev/dev-server.ts | 27 ++--------- 3 files changed, 18 insertions(+), 64 deletions(-) diff --git a/src/cli/operations/dev/__tests__/container-dev-server.test.ts b/src/cli/operations/dev/__tests__/container-dev-server.test.ts index 855d84c0..30dbe43c 100644 --- a/src/cli/operations/dev/__tests__/container-dev-server.test.ts +++ b/src/cli/operations/dev/__tests__/container-dev-server.test.ts @@ -45,8 +45,6 @@ function createMockChildProcess() { proc.stdout = new EventEmitter(); proc.stderr = new EventEmitter(); proc.killed = false; - proc.exitCode = null; - proc.pid = 12345; proc.kill = vi.fn(); return proc; } @@ -474,11 +472,10 @@ describe('ContainerDevServer', () => { describe('kill()', () => { it('stops container using docker stop before calling super.kill()', async () => { - const processKillSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); mockSuccessfulPrepare(); const server = new ContainerDevServer(defaultConfig, defaultOptions); - await server.start(); + const child = await server.start(); // Clear mocks to isolate the kill call mockSpawn.mockClear(); @@ -487,8 +484,7 @@ describe('ContainerDevServer', () => { // Container stop is async (spawn not spawnSync) so UI can render "Stopping..." message expect(mockSpawn).toHaveBeenCalledWith('docker', ['stop', 'agentcore-dev-testagent'], { stdio: 'ignore' }); - expect(processKillSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); - processKillSpy.mockRestore(); + expect(child!.kill).toHaveBeenCalledWith('SIGTERM'); // eslint-disable-line @typescript-eslint/unbound-method }); it('does not call container stop when runtimeBinary is empty (prepare not called)', () => { diff --git a/src/cli/operations/dev/__tests__/dev-server.test.ts b/src/cli/operations/dev/__tests__/dev-server.test.ts index 29576b90..06827e9c 100644 --- a/src/cli/operations/dev/__tests__/dev-server.test.ts +++ b/src/cli/operations/dev/__tests__/dev-server.test.ts @@ -13,8 +13,6 @@ function createMockChildProcess() { proc.stdout = new EventEmitter(); proc.stderr = new EventEmitter(); proc.killed = false; - proc.exitCode = null; - proc.pid = 12345; proc.kill = vi.fn(); return proc; } @@ -77,7 +75,6 @@ describe('DevServer', () => { cwd: '/test', env: { PATH: '/usr/bin' }, stdio: ['ignore', 'pipe', 'pipe'], - detached: true, }); }); @@ -104,42 +101,22 @@ describe('DevServer', () => { }); describe('kill()', () => { - let processKillSpy: ReturnType; - - beforeEach(() => { - processKillSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); - }); - - afterEach(() => { - processKillSpy.mockRestore(); - }); - it('does nothing when no child process (no start called)', () => { // Should not throw server.kill(); }); - it('does nothing when child already exited', async () => { + it('does nothing when child already killed', async () => { await server.start(); - mockChild.exitCode = 0; + mockChild.killed = true; server.kill(); - expect(processKillSpy).not.toHaveBeenCalled(); + expect(mockChild.kill).not.toHaveBeenCalled(); }); - it('sends SIGTERM to process group first', async () => { + it('sends SIGTERM first', async () => { await server.start(); - server.kill(); - expect(processKillSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); - }); - - it('falls back to child.kill if process group kill fails', async () => { - processKillSpy.mockImplementation(() => { - throw new Error('ESRCH'); - }); - - await server.start(); server.kill(); expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM'); }); @@ -150,13 +127,13 @@ describe('DevServer', () => { await server.start(); server.kill(); - expect(processKillSpy).toHaveBeenCalledTimes(1); - expect(processKillSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); + expect(mockChild.kill).toHaveBeenCalledTimes(1); + expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM'); vi.advanceTimersByTime(2000); - expect(processKillSpy).toHaveBeenCalledTimes(2); - expect(processKillSpy).toHaveBeenCalledWith(-12345, 'SIGKILL'); + expect(mockChild.kill).toHaveBeenCalledTimes(2); + expect(mockChild.kill).toHaveBeenCalledWith('SIGKILL'); vi.useRealTimers(); }); @@ -167,13 +144,13 @@ describe('DevServer', () => { await server.start(); server.kill(); - // Simulate process exiting after SIGTERM - mockChild.exitCode = 0; + // Simulate process dying after SIGTERM + mockChild.killed = true; vi.advanceTimersByTime(2000); - expect(processKillSpy).toHaveBeenCalledTimes(1); - expect(processKillSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); + expect(mockChild.kill).toHaveBeenCalledTimes(1); + expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM'); vi.useRealTimers(); }); diff --git a/src/cli/operations/dev/dev-server.ts b/src/cli/operations/dev/dev-server.ts index 47ea0b9c..10424c0d 100644 --- a/src/cli/operations/dev/dev-server.ts +++ b/src/cli/operations/dev/dev-server.ts @@ -72,37 +72,18 @@ export abstract class DevServer { cwd: spawnConfig.cwd, env: spawnConfig.env, stdio: ['ignore', 'pipe', 'pipe'], - detached: true, }); this.attachHandlers(); return this.child; } - /** Kill the dev server process group. Sends SIGTERM, then SIGKILL after 2 seconds. */ + /** Kill the dev server process. Sends SIGTERM, then SIGKILL after 2 seconds. */ kill(): void { - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - if (!this.child || this.child.exitCode !== null) return; - const pid = this.child.pid!; - // Kill the entire process group (negative PID) to clean up child processes (e.g., uvicorn workers) - try { - process.kill(-pid, 'SIGTERM'); - } catch { - this.child.kill('SIGTERM'); - } - const child = this.child; + if (!this.child || this.child.killed) return; + this.child.kill('SIGTERM'); const killTimer = setTimeout(() => { - if (child.exitCode === null) { - try { - process.kill(-pid, 'SIGKILL'); - } catch { - try { - child.kill('SIGKILL'); - } catch { - // Process already dead - } - } - } + if (this.child && !this.child.killed) this.child.kill('SIGKILL'); }, 2000); killTimer.unref(); } From dfec04bef4bdf7c261d964da1c8359f56774cb46 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 27 Mar 2026 18:08:36 -0400 Subject: [PATCH 7/7] fix(dev): simplify invoke to require running server, improve error messages Remove auto-start logic from the invoke path. Users must now start the dev server in a separate terminal with `agentcore dev --logs` before invoking with `agentcore dev "prompt"`. Improve connection error detection by adding isConnectionRefused() helper that checks ConnectionError name, cause chain, and common fetch failure messages instead of only checking err.message for ECONNREFUSED. Constraint: Node fetch wraps ECONNREFUSED in TypeError cause chain Rejected: Auto-start server on invoke | orphaned processes, complexity Confidence: high Scope-risk: narrow --- docs/commands.md | 4 +- docs/local-development.md | 14 +- src/cli/commands/dev/__tests__/dev.test.ts | 7 +- src/cli/commands/dev/command.tsx | 154 ++++----------------- 4 files changed, 36 insertions(+), 143 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 51d32962..4cfb64ea 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -475,7 +475,7 @@ Start local development server with hot-reload. agentcore dev agentcore dev --agent MyAgent --port 3000 agentcore dev --logs # Non-interactive -agentcore dev "Hello" --stream # Invoke agent (auto-starts server) +agentcore dev "Hello" --stream # Invoke running dev server agentcore dev "Hello" --agent MyAgent # Invoke specific agent # MCP protocol dev commands @@ -485,7 +485,7 @@ agentcore dev call-tool --tool myTool --input '{"arg": "value"}' | Flag / Argument | Description | | -------------------- | ---------------------------------------------------- | -| `[prompt]` | Invoke local agent (auto-starts server if needed) | +| `[prompt]` | Send a prompt to a running dev server | | `-p, --port ` | Port (default: 8080; MCP uses 8000, A2A uses 9000) | | `-a, --agent ` | Agent to run or invoke (required if multiple agents) | | `-s, --stream` | Stream response when invoking | diff --git a/docs/local-development.md b/docs/local-development.md index 7648cff1..1c2400ad 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -20,22 +20,18 @@ agentcore dev --logs ## Invoking Local Agents -Send a prompt to your agent — the dev server starts automatically if it isn't already running: +Start the dev server in one terminal, then send prompts from another: ```bash -# Single prompt (auto-starts server, invokes, shuts down) -agentcore dev "What can you do?" +# Terminal 1: start the dev server +agentcore dev --logs -# With streaming +# Terminal 2: send prompts +agentcore dev "What can you do?" agentcore dev "Tell me a story" --stream - -# Specify agent (if multiple) agentcore dev "Hello" --agent MyAgent ``` -If you already have `agentcore dev` or `agentcore dev --logs` running in another terminal, the invoke reuses that -server. - ## Environment Setup ### Python Virtual Environment diff --git a/src/cli/commands/dev/__tests__/dev.test.ts b/src/cli/commands/dev/__tests__/dev.test.ts index 4b1df3da..d370bcb3 100644 --- a/src/cli/commands/dev/__tests__/dev.test.ts +++ b/src/cli/commands/dev/__tests__/dev.test.ts @@ -37,15 +37,14 @@ describe('dev command', () => { describe('positional prompt invoke', () => { it('exits with helpful error when no server running and no project found', async () => { - // With no dev server running and no project, the auto-start path - // detects both conditions and shows a helpful error + // With no dev server running, invoke path shows connection error const result = await runCLI(['dev', 'Hello agent'], process.cwd()); expect(result.exitCode).toBe(1); const output = result.stderr.toLowerCase(); expect( - output.includes('no dev server running'), - `Should mention no dev server running, got: ${result.stderr}` + output.includes('dev server not running'), + `Should mention dev server not running, got: ${result.stderr}` ).toBeTruthy(); }); diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index 17762454..20b8acd2 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -14,7 +14,6 @@ import { invokeForProtocol, listMcpTools, loadProjectConfig, - waitForServerReady, } from '../../operations/dev'; import { getGatewayEnvVars } from '../../operations/dev/gateway-env.js'; import { FatalError } from '../../tui/components'; @@ -49,9 +48,9 @@ async function invokeDevServer( console.log(response); } } catch (err) { - if (err instanceof Error && err.message.includes('ECONNREFUSED')) { + if (isConnectionRefused(err)) { console.error(`Error: Dev server not running on port ${port}`); - console.error('Start it with: agentcore dev'); + console.error('Start it with: agentcore dev --logs'); } else { console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); } @@ -66,9 +65,9 @@ async function invokeA2ADevServer(port: number, prompt: string, headers?: Record } process.stdout.write('\n'); } catch (err) { - if (err instanceof Error && err.message.includes('ECONNREFUSED')) { + if (isConnectionRefused(err)) { console.error(`Error: Dev server not running on port ${port}`); - console.error('Start it with: agentcore dev'); + console.error('Start it with: agentcore dev --logs'); } else { console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); } @@ -76,6 +75,14 @@ async function invokeA2ADevServer(port: number, prompt: string, headers?: Record } } +function isConnectionRefused(err: unknown): boolean { + if (!(err instanceof Error)) return false; + // ConnectionError from invoke.ts wraps fetch failures after retries + if (err.name === 'ConnectionError') return true; + const msg = err.message + (err.cause instanceof Error ? err.cause.message : ''); + return msg.includes('ECONNREFUSED') || msg.includes('fetch failed'); +} + async function handleMcpInvoke( port: number, invokeValue: string, @@ -123,9 +130,9 @@ async function handleMcpInvoke( process.exit(1); } } catch (err) { - if (err instanceof Error && err.message.includes('ECONNREFUSED')) { + if (isConnectionRefused(err)) { console.error(`Error: Dev server not running on port ${port}`); - console.error('Start it with: agentcore dev'); + console.error('Start it with: agentcore dev --logs'); } else { console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); } @@ -138,7 +145,7 @@ export const registerDev = (program: Command) => { .command('dev') .alias('d') .description(COMMAND_DESCRIPTIONS.dev) - .argument('[prompt]', 'Invoke local agent with this prompt (auto-starts server if needed) [non-interactive]') + .argument('[prompt]', 'Send a prompt to a running dev server [non-interactive]') .option('-p, --port ', 'Port for development server', '8080') .option('-a, --agent ', 'Agent to run or invoke (required if multiple agents)') .option('-s, --stream', 'Stream response when invoking [non-interactive]') @@ -161,7 +168,7 @@ export const registerDev = (program: Command) => { headers = parseHeaderFlags(opts.header); } - // If a prompt is provided, invoke the dev server (auto-starting if needed) + // If a prompt is provided, invoke a running dev server const invokePrompt = positionalPrompt; if (invokePrompt !== undefined) { const workingDir = getWorkingDirectory(); @@ -186,127 +193,18 @@ export const registerDev = (program: Command) => { if (protocol === 'A2A') invokePort = 9000; else if (protocol === 'MCP') invokePort = 8000; - // Check if a dev server is already running on the target port - const serverRunning = await waitForServerReady(invokePort, 500); - - // If something is listening, verify it's an agentcore dev server (not a random process) - if (serverRunning) { - try { - const probeUrl = getEndpointUrl(invokePort, protocol); - const probe = await fetch(probeUrl, { method: 'GET', signal: AbortSignal.timeout(2000) }); - const contentType = probe.headers.get('content-type') ?? ''; - if (contentType.includes('text/html')) { - console.error(`Error: Port ${invokePort} is in use by another process (not an agentcore dev server).`); - console.error(`Stop the other process or use --port to specify a different port.`); - process.exit(1); - } - } catch (probeErr) { - // Probe connected (serverRunning=true) but failed to get an HTTP response. - // This means a non-HTTP process (e.g., raw TCP listener) occupies the port. - const msg = probeErr instanceof Error ? probeErr.message : ''; - if (!msg.includes('ECONNREFUSED')) { - console.error(`Error: Port ${invokePort} is in use by another process (not an agentcore dev server).`); - console.error(`Stop the other process or use --port to specify a different port.`); - process.exit(1); - } - } - } - - // Auto-start a dev server if none is running - let autoStartedServer: ReturnType | undefined; - // eslint-disable-next-line @typescript-eslint/no-empty-function - let cleanupServer: () => void = () => {}; - if (!serverRunning) { - if (!invokeProject) { - console.error('Error: No dev server running and no agentcore project found.'); - console.error('Start a dev server first: agentcore dev'); - process.exit(1); - } - - const configRoot = findConfigRoot(workingDir); - const envVars = configRoot ? await readEnvFile(configRoot) : {}; - const gatewayEnvVars = await getGatewayEnvVars(); - const mergedEnvVars = { ...gatewayEnvVars, ...envVars }; - const agentName = opts.agent ?? invokeProject.agents[0]?.name; - const config = getDevConfig(workingDir, invokeProject, configRoot ?? undefined, agentName); - - if (!config) { - console.error('Error: No dev-supported agents found.'); - process.exit(1); - } - - const serverErrors: string[] = []; - // eslint-disable-next-line @typescript-eslint/no-empty-function - let resolveServerExit: () => void = () => {}; - const serverExitPromise = new Promise(resolve => { - resolveServerExit = resolve; - }); - - const devCallbacks = { - onLog: (level: string, msg: string) => { - if (level === 'error') serverErrors.push(msg); - }, - onExit: () => { - resolveServerExit(); - }, - }; - - const server = createDevServer(config, { - port: invokePort, - envVars: mergedEnvVars, - callbacks: devCallbacks, - }); - await server.start(); - - // Ensure server cleanup on any exit path (process.exit in invoke helpers, SIGINT, etc.) - cleanupServer = () => { - try { - server.kill(); - } catch { - // Ignore cleanup errors - } - }; - process.on('exit', cleanupServer); - process.on('SIGINT', () => { - cleanupServer(); - }); - - // Wait for server to accept connections, bail early if process crashes - const ready = await Promise.race([waitForServerReady(invokePort), serverExitPromise.then(() => false)]); - - if (!ready) { - if (serverErrors.length > 0) { - console.error(serverErrors.slice(-5).join('\n')); - } - console.error('Error: Dev server failed to start. Run "agentcore dev --logs" for details.'); - process.exit(1); - } - autoStartedServer = server; + // Show model info if available (not applicable to MCP) + if (protocol !== 'MCP' && targetAgent?.modelProvider) { + console.log(`Provider: ${targetAgent.modelProvider}`); } - try { - // Show model info if available (not applicable to MCP) - if (protocol !== 'MCP' && targetAgent?.modelProvider) { - console.log(`Provider: ${targetAgent.modelProvider}`); - } - - // Protocol-aware dispatch - if (protocol === 'MCP') { - await handleMcpInvoke(invokePort, invokePrompt, opts.tool, opts.input, headers); - } else if (protocol === 'A2A') { - await invokeA2ADevServer(invokePort, invokePrompt, headers); - } else { - await invokeDevServer(invokePort, invokePrompt, opts.stream ?? false, headers); - } - } finally { - if (autoStartedServer) { - try { - autoStartedServer.kill(); - } catch { - // Ignore cleanup errors - } - process.removeListener('exit', cleanupServer); - } + // Protocol-aware dispatch + if (protocol === 'MCP') { + await handleMcpInvoke(invokePort, invokePrompt, opts.tool, opts.input, headers); + } else if (protocol === 'A2A') { + await invokeA2ADevServer(invokePort, invokePrompt, headers); + } else { + await invokeDevServer(invokePort, invokePrompt, opts.stream ?? false, headers); } return; }