Repository navigation
Background dev server for AI coding agents #16610
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
335b2a8
Add experimental background dev server management
matthewp 8e2ea34
Skip lock file in test/production environments
matthewp 4be0626
Move lock file logic from JS API to CLI layer
matthewp 1f7c645
Ensure .astro directory exists before opening log file
matthewp e8ffef4
Fix infinite spawn recursion when agent env vars are inherited
matthewp e21d368
Use logger instead of raw JSON output, add command hints
matthewp 206ddb4
Fix background flag in lock file, remove BUG-REPORT.md
matthewp af8277f
Replace --experimental-* flags with subcommands, upgrade am-i-vibing …
matthewp 6a7995c
Add --follow (-f) flag to astro dev logs
matthewp aae0717
SIGKILL fallback after SIGTERM timeout in force-kill and stop
matthewp 665cbae
Report AI agent info in CLI session telemetry
matthewp b3fdbd6
Update changeset to major with expanded description
matthewp 2d1cbe5
Bust turbo cache for CI build
matthewp 0ca22c2
Merge remote-tracking branch 'origin/next' into background-dev
matthewp b61f2c0
Merge remote-tracking branch 'origin/next' into background-dev
matthewp a6af8ea
Auto-enable JSON logger when AI agent is detected
matthewp d677b43
Update .changeset/experimental-background-dev.md
matthewp 103299f
Improve lockfile error handling and clarify isProcessAlive
matthewp f24f670
Deduplicate resolveRootURL into lockfile module
matthewp 9646f4e
Use SKIP_FORMAT logger label instead of null
matthewp bfee51d
Handle SIGTERM in logs --follow cleanup
matthewp d6a37f7
Use process.exit() consistently for error exits
matthewp 1e413e8
Use Vite resolvedUrls for lock file URL instead of hardcoded localhost
matthewp b0180b2
Error on unknown dev subcommand instead of falling through
matthewp 8cc9665
Change `astro dev background` subcommand to `astro dev --background` …
matthewp da92a8e
Update .changeset/experimental-background-dev.md
matthewp 377ee6f
Merge branch 'next' into background-dev
matthewp 105a113
Remove resolveRootURL, reuse resolveRoot from config
matthewp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| --- | ||
| 'astro': major | ||
| --- | ||
|
|
||
| Adds background dev server management for AI coding agents. | ||
|
|
||
| When an AI coding agent is detected, `astro dev` now automatically starts the dev server as a detached background process. This prevents the dev server from blocking the agent's terminal and allows it to continue working while the server runs. | ||
|
|
||
| A lock file (`.astro/dev.json`) is written when the dev server starts, recording the server's URL, port, and PID. This prevents duplicate servers from being started for the same project. | ||
|
|
||
| #### New flag and subcommands | ||
|
|
||
| - `astro dev --background` — Start the dev server as a background process (this is what runs automatically when an agent is detected). | ||
| - `astro dev stop` — Stop a running background dev server. | ||
| - `astro dev status` — Check if a dev server is running and display its URL, PID, and uptime. | ||
| - `astro dev logs` — View logs from a background dev server. Use `--follow` (`-f`) to stream new output as it's written. | ||
|
|
||
| These allow you to start and manage dev servers programmatically and were designed with AI coding agents in mind. | ||
|
|
||
| #### What should I do? | ||
|
|
||
| No action is required. If you are not using an AI coding agent, `astro dev` behaves exactly as before. If you are using an agent, background mode is enabled automatically — the agent will receive the server URL and PID, and can use `astro dev stop` to shut it down. | ||
|
|
||
| To opt out of automatic background mode when an agent is detected, set the environment variable `ASTRO_DEV_BACKGROUND=0` before running `astro dev`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import { spawn } from 'node:child_process'; | ||
| import { existsSync, mkdirSync, openSync } from 'node:fs'; | ||
| import { resolve } from 'node:path'; | ||
| import { fileURLToPath, pathToFileURL } from 'node:url'; | ||
| import type { AstroLogger } from '../../core/logger/core.js'; | ||
| import type { Flags } from '../flags.js'; | ||
| import { | ||
| checkExistingServer, | ||
| getLogFileURL, | ||
| readLockFile, | ||
| removeLockFile, | ||
| isProcessAlive, | ||
| GRACEFUL_SHUTDOWN_TIMEOUT, | ||
| } from '../../core/dev/lockfile.js'; | ||
| import { resolveRoot } from '../../core/config/config.js'; | ||
|
|
||
| export interface BackgroundResult { | ||
| pid: number; | ||
| url: string; | ||
| existing?: boolean; | ||
| } | ||
|
|
||
| export interface BackgroundErrorResult { | ||
| error: string; | ||
| message: string; | ||
| } | ||
|
|
||
| export function formatBackgroundOutput(result: BackgroundResult | BackgroundErrorResult): string { | ||
| return JSON.stringify(result); | ||
| } | ||
|
|
||
| export async function background({ | ||
| flags, | ||
| logger, | ||
| }: { flags: Flags; logger: AstroLogger }): Promise<void> { | ||
| const root = pathToFileURL(resolveRoot(flags.root) + '/'); | ||
|
|
||
| // Check for existing server | ||
| const existing = checkExistingServer(root); | ||
| if (existing && !flags.force) { | ||
| logger.info('SKIP_FORMAT', `Dev server already running at ${existing.url} (pid ${existing.pid})\n` + | ||
| ' Stop: astro dev stop\n' + | ||
| ' Status: astro dev status\n' + | ||
| ' Logs: astro dev logs'); | ||
| return; | ||
| } | ||
|
|
||
| // If --force, kill the existing server first | ||
| if (existing && flags.force) { | ||
| try { | ||
| process.kill(existing.pid, 'SIGTERM'); | ||
| } catch { | ||
| // Already dead | ||
| } | ||
| // Wait for graceful shutdown before escalating to SIGKILL | ||
| const deadline = Date.now() + GRACEFUL_SHUTDOWN_TIMEOUT; | ||
| while (Date.now() < deadline) { | ||
| if (!isProcessAlive(existing.pid)) break; | ||
| await new Promise((r) => setTimeout(r, 100)); | ||
| } | ||
| // If still alive after timeout, force kill | ||
| if (isProcessAlive(existing.pid)) { | ||
| try { | ||
| process.kill(existing.pid, 'SIGKILL'); | ||
| } catch { | ||
| // Already dead | ||
| } | ||
| } | ||
| removeLockFile(root); | ||
| } | ||
|
|
||
| // Build the args for the child process: plain `astro dev` (no --background) | ||
| const args: string[] = ['dev']; | ||
| if (flags.port) args.push('--port', String(flags.port)); | ||
| if (flags.host != null) { | ||
| if (typeof flags.host === 'string') { | ||
| args.push('--host', flags.host); | ||
| } else { | ||
| args.push('--host'); | ||
| } | ||
| } | ||
| if (flags.config) args.push('--config', String(flags.config)); | ||
| if (flags.root) args.push('--root', String(flags.root)); | ||
| if (flags.allowedHosts) args.push('--allowed-hosts', String(flags.allowedHosts)); | ||
| if (flags.experimentalJson) args.push('--experimental-json'); | ||
|
|
||
| // Open the log file for writing, ensuring the .astro directory exists | ||
| const logFileURL = getLogFileURL(root); | ||
| const logFilePath = fileURLToPath(logFileURL); | ||
| const dotAstroDir = fileURLToPath(new URL('.astro/', root)); | ||
| if (!existsSync(dotAstroDir)) { | ||
| mkdirSync(dotAstroDir, { recursive: true }); | ||
| } | ||
| const logFd = openSync(logFilePath, 'w'); | ||
|
|
||
| // Find the astro binary | ||
| const rootPath = fileURLToPath(root); | ||
| const astroBin = resolve(rootPath, 'node_modules', '.bin', 'astro'); | ||
|
|
||
| // Spawn the dev server as a detached child process | ||
| const child = spawn(astroBin, args, { | ||
| detached: true, | ||
| stdio: ['ignore', logFd, logFd], | ||
| cwd: rootPath, | ||
| env: { ...process.env, ASTRO_DEV_BACKGROUND: '1' }, | ||
| }); | ||
|
|
||
| child.unref(); | ||
|
|
||
| const childPid = child.pid; | ||
| if (!childPid) { | ||
| logger.error('SKIP_FORMAT', 'Failed to spawn background dev server process.'); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| // Poll the lock file to detect when the server is ready | ||
| const timeout = 30000; | ||
| const deadline = Date.now() + timeout; | ||
|
|
||
| while (Date.now() < deadline) { | ||
| // Check if child is still alive | ||
| if (!isProcessAlive(childPid)) { | ||
| logger.error('SKIP_FORMAT', 'Dev server process exited before becoming ready.'); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| // Check for the lock file (written by the child's dev server) | ||
| const lockData = readLockFile(root); | ||
| if (lockData && lockData.pid === childPid) { | ||
| logger.info('SKIP_FORMAT', `Dev server running at ${lockData.url} (pid ${lockData.pid})\n` + | ||
| ' Stop: astro dev stop\n' + | ||
| ' Status: astro dev status\n' + | ||
| ' Logs: astro dev logs'); | ||
| return; | ||
| } | ||
|
|
||
| await new Promise((r) => setTimeout(r, 200)); | ||
| } | ||
|
|
||
| // Timeout: kill the child and report failure | ||
| try { | ||
| process.kill(childPid, 'SIGTERM'); | ||
| } catch { | ||
| // Already dead | ||
| } | ||
| removeLockFile(root); | ||
|
|
||
| logger.error('SKIP_FORMAT', `Dev server failed to start within ${timeout / 1000}s.`); | ||
| process.exit(1); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,31 +1,49 @@ | ||
| import { isAgent } from 'am-i-vibing'; | ||
| import colors from 'piccolore'; | ||
| import devServer from '../../core/dev/index.js'; | ||
| import { pathToFileURL } from 'node:url'; | ||
| import { checkExistingServer, removeLockFile, writeLockFile } from '../../core/dev/lockfile.js'; | ||
| import { resolveRoot } from '../../core/config/config.js'; | ||
| import { printHelp } from '../../core/messages/runtime.js'; | ||
| import { type Flags, flagsToAstroInlineConfig } from '../flags.js'; | ||
| import { type Flags, createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js'; | ||
|
|
||
| interface DevOptions { | ||
| flags: Flags; | ||
| } | ||
|
|
||
| function isRunByAgent(): boolean { | ||
| try { | ||
| return isAgent(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This info (and the specific agent) might be a useful addition to telemetry
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Very easy! Good library we have 😉 06744ad |
||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| export async function dev({ flags }: DevOptions) { | ||
| if (flags.help || flags.h) { | ||
| printHelp({ | ||
| commandName: 'astro dev', | ||
| usage: '[...flags]', | ||
| usage: '[command] [...flags]', | ||
| tables: { | ||
| Flags: [ | ||
| ['--mode', `Specify the mode of the project. Defaults to "development".`], | ||
| ['--port', `Specify which port to run on. Defaults to 4321.`], | ||
| ['--host', `Listen on all addresses, including LAN and public addresses.`], | ||
| ['--host <custom-address>', `Expose on a network IP address at <custom-address>`], | ||
| ['--open', 'Automatically open the app in the browser on server start'], | ||
| ['--force', 'Clear the content layer cache, forcing a full rebuild.'], | ||
| [ | ||
| '--allowed-hosts', | ||
| 'Specify a comma-separated list of allowed hosts or allow any hostname.', | ||
| ], | ||
| ['--help (-h)', 'See all available flags.'], | ||
| Commands: [ | ||
| ['stop', 'Stop a running background dev server.'], | ||
| ['status', 'Check if a dev server is running.'], | ||
| ['logs [--follow]', 'View logs from a background dev server.'], | ||
| ], | ||
| Flags: [ | ||
| ['--background', 'Start the dev server as a background process.'], | ||
| ['--mode', `Specify the mode of the project. Defaults to "development".`], | ||
| ['--port', `Specify which port to run on. Defaults to 4321.`], | ||
| ['--host', `Listen on all addresses, including LAN and public addresses.`], | ||
| ['--host <custom-address>', `Expose on a network IP address at <custom-address>`], | ||
| ['--open', 'Automatically open the app in the browser on server start'], | ||
| ['--force', 'Clear the content layer cache, forcing a full rebuild.'], | ||
| [ | ||
| '--allowed-hosts', | ||
| 'Specify a comma-separated list of allowed hosts or allow any hostname.', | ||
| ], | ||
| ['--help (-h)', 'See all available flags.'], | ||
| ], | ||
| }, | ||
| description: `Check ${colors.cyan( | ||
| 'https://docs.astro.build/en/reference/cli-reference/#astro-dev', | ||
|
|
@@ -34,7 +52,85 @@ export async function dev({ flags }: DevOptions) { | |
| return; | ||
| } | ||
|
|
||
| // When an AI coding agent is detected, enable background mode and JSON logging automatically. | ||
| const agentDetected = !process.env.ASTRO_DEV_BACKGROUND && isRunByAgent(); | ||
| if (agentDetected) { | ||
| flags.experimentalJson = true; | ||
| } | ||
|
|
||
| const logger = createLoggerFromFlags(flags); | ||
| const subcommand = flags._[3]?.toString(); | ||
|
|
||
|
ematipico marked this conversation as resolved.
|
||
| // Handle `astro dev stop` | ||
| if (subcommand === 'stop') { | ||
| const { stop } = await import('./stop.js'); | ||
| await stop({ flags, logger }); | ||
| return; | ||
| } | ||
|
|
||
| // Handle `astro dev status` | ||
| if (subcommand === 'status') { | ||
| const { status } = await import('./status.js'); | ||
| await status({ flags, logger }); | ||
| return; | ||
| } | ||
|
|
||
| // Handle `astro dev logs` | ||
| if (subcommand === 'logs') { | ||
| const { logs } = await import('./logs.js'); | ||
| await logs({ flags, logger }); | ||
| return; | ||
| } | ||
|
|
||
| // Handle `astro dev --background` or auto-enable when an AI coding agent is detected. | ||
| // Skip if ASTRO_DEV_BACKGROUND is set — this means we're the spawned child process | ||
| // and should run the foreground dev server, not recurse into background mode. | ||
| if (flags.background || agentDetected) { | ||
| const { background } = await import('./background.js'); | ||
| await background({ flags, logger }); | ||
| return; | ||
| } | ||
|
|
||
| // Unknown subcommand — exit with an error before starting the server. | ||
| if (subcommand) { | ||
| logger.error('SKIP_FORMAT', `Unknown command: astro dev ${subcommand}\n\nRun \`astro dev --help\` to see available commands.`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| // Foreground dev server: check lock file, start server, write lock file | ||
| const root = pathToFileURL(resolveRoot(flags.root) + '/'); | ||
| const existingServer = checkExistingServer(root); | ||
| if (existingServer) { | ||
| const message = [ | ||
| 'Another astro dev server is already running.', | ||
| '', | ||
| ` URL: ${existingServer.url}`, | ||
| ` PID: ${existingServer.pid}`, | ||
| '', | ||
| `Run \`astro dev stop\` to stop it, or use \`astro dev --force\` to replace it.`, | ||
| ].join('\n'); | ||
| throw new Error(message); | ||
| } | ||
|
|
||
| const inlineConfig = flagsToAstroInlineConfig(flags); | ||
| const server = await devServer(inlineConfig); | ||
|
|
||
| // Use Vite's resolved local URL which accounts for host and protocol (http/https). | ||
| const serverUrl = new URL(server.resolvedUrls.local[0]).origin; | ||
| writeLockFile(root, { | ||
| pid: process.pid, | ||
| port: server.address.port, | ||
| url: serverUrl, | ||
| background: !!process.env.ASTRO_DEV_BACKGROUND, | ||
| startedAt: new Date().toISOString(), | ||
| }); | ||
|
|
||
| // Wrap the original stop to also clean up the lock file | ||
| const originalStop = server.stop.bind(server); | ||
| server.stop = async () => { | ||
| removeLockFile(root); | ||
| await originalStop(); | ||
| }; | ||
|
|
||
| return await devServer(inlineConfig); | ||
| return server; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reminder that I will rename this to
json, so it's best to rebase this PR after I merge my custom logger PR. Better to track it in linear so we don't stomp on each other