Skip to content
Merged
Show file tree
Hide file tree
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 May 5, 2026
8e2ea34
Skip lock file in test/production environments
matthewp May 5, 2026
4be0626
Move lock file logic from JS API to CLI layer
matthewp May 5, 2026
1f7c645
Ensure .astro directory exists before opening log file
matthewp May 5, 2026
e8ffef4
Fix infinite spawn recursion when agent env vars are inherited
matthewp May 5, 2026
e21d368
Use logger instead of raw JSON output, add command hints
matthewp May 5, 2026
206ddb4
Fix background flag in lock file, remove BUG-REPORT.md
matthewp May 5, 2026
af8277f
Replace --experimental-* flags with subcommands, upgrade am-i-vibing …
matthewp May 11, 2026
6a7995c
Add --follow (-f) flag to astro dev logs
matthewp May 11, 2026
aae0717
SIGKILL fallback after SIGTERM timeout in force-kill and stop
matthewp May 11, 2026
665cbae
Report AI agent info in CLI session telemetry
matthewp May 11, 2026
b3fdbd6
Update changeset to major with expanded description
matthewp May 11, 2026
2d1cbe5
Bust turbo cache for CI build
matthewp May 11, 2026
0ca22c2
Merge remote-tracking branch 'origin/next' into background-dev
matthewp May 12, 2026
b61f2c0
Merge remote-tracking branch 'origin/next' into background-dev
matthewp May 27, 2026
a6af8ea
Auto-enable JSON logger when AI agent is detected
matthewp May 27, 2026
d677b43
Update .changeset/experimental-background-dev.md
matthewp May 27, 2026
103299f
Improve lockfile error handling and clarify isProcessAlive
matthewp May 27, 2026
f24f670
Deduplicate resolveRootURL into lockfile module
matthewp May 27, 2026
9646f4e
Use SKIP_FORMAT logger label instead of null
matthewp May 27, 2026
bfee51d
Handle SIGTERM in logs --follow cleanup
matthewp May 27, 2026
d6a37f7
Use process.exit() consistently for error exits
matthewp May 27, 2026
1e413e8
Use Vite resolvedUrls for lock file URL instead of hardcoded localhost
matthewp May 27, 2026
b0180b2
Error on unknown dev subcommand instead of falling through
matthewp May 27, 2026
8cc9665
Change `astro dev background` subcommand to `astro dev --background` …
matthewp May 27, 2026
da92a8e
Update .changeset/experimental-background-dev.md
matthewp May 28, 2026
377ee6f
Merge branch 'next' into background-dev
matthewp May 28, 2026
105a113
Remove resolveRootURL, reuse resolveRoot from config
matthewp May 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/experimental-background-dev.md
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`.
1 change: 1 addition & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
"@clack/prompts": "^1.1.0",
"@oslojs/encoding": "^1.1.0",
"@rollup/pluginutils": "^5.3.0",
"am-i-vibing": "^0.3.0",
"aria-query": "^5.3.2",
"axobject-query": "^4.1.0",
"ci-info": "^4.4.0",
Expand Down
150 changes: 150 additions & 0 deletions packages/astro/src/cli/dev/background.ts
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');

Copy link
Copy Markdown
Member

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


// 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);
}
126 changes: 111 additions & 15 deletions packages/astro/src/cli/dev/index.ts
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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',
Expand All @@ -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();

Comment thread
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;
}
Loading
Loading