p4ni.

Build in Public

Astro 7 Agent Detection: What Triggers It, What It Breaks

· 12 min read

On this page

I found a dev server on my machine that had been running for 26 hours. I never started it — not knowingly. An AI coding agent ran astro dev for me; Astro 7 detected the agent, detached the process, and neither of us ever mentioned it again.

That’s the feature working exactly as designed. It’s also the part of Astro 7 nobody is writing about. Every release post covers the Rust compiler and the 15–61% faster builds. The agent detection gets a couple of lines: Astro can detect when it’s running inside an AI agent and enable background mode automatically, and JSON logging comes on with it.

That’s the whole description, and it raises more questions than it answers. What counts as an agent? What’s in the JSON? Is it genuinely machine-readable, or human text wrapped in braces? I read the source of Astro 7.1.3, then ran the detector against a dozen fabricated environments to find out. Some of what I found contradicts how the feature is usually summarized — including one place where Astro disagrees with its own dependency, and one where the lock file records something that isn’t true.

What triggers Astro 7’s agent detection

Astro doesn’t implement the detection itself. It delegates to am-i-vibing (v0.4.0 in this install), and the call site is this short:

// node_modules/astro/dist/cli/dev/index.js
function isRunByAgent() {
  try {
    return detectAgenticEnvironment().type === "agent";
  } catch {
    return false;
  }
}

That === "agent" comparison is the whole story, and it’s the detail worth internalizing, because am-i-vibing recognizes three kinds of environment — not two:

TypeMeaningExampleastro dev behavior
agentA program is running your commands non-interactivelyClaude Code, Codex CLI, Gemini CLIBackground + JSON
interactiveA human is typing in an AI-flavored editorCursor’s integrated terminalForeground, unchanged
hybridCould be eitherWarp TerminalForeground, unchanged

Only agent flips the switch. Running astro dev from the terminal inside an AI editor doesn’t background anything, because a human is watching that terminal and expects to see the server. That’s the right call, and it’s the distinction most summaries of this feature flatten out.

It also produces a genuine disagreement. am-i-vibing exports an isAgent() helper that returns true for agent and hybrid. Astro doesn’t use it — it compares the type itself. So in Warp Terminal, the library’s own convenience function says “this is an agent” while Astro says it isn’t:

TERM_PROGRAM=WarpTerminal  ->  type: "hybrid", isAgent(): true, Astro: foreground

I don’t think that’s a bug so much as Astro deliberately taking the conservative read. But if you’re building your own tooling on am-i-vibing, don’t assume isAgent() matches what Astro does.

The environment variable table has a catch

Detection is environment-variable matching — no TTY sniffing, no process-tree walking in the path Astro uses. But the string table you’d get by grepping the bundle is misleading, because many rules match on the value, not just the presence of a variable. I ran the detector under isolated environments to find out which is which:

CLAUDECODE=<anything>          -> agent        (any non-empty value)
CODEX_THREAD_ID=<anything>     -> agent        (any non-empty value)
GEMINI_CLI=1                   -> agent
GEMINI_CLI=true                -> not detected  ← value must be exactly "1"
AGENT=crush                    -> agent
AGENT=ci-runner                -> not detected  ← value must be "crush" or "amp"
AI_AGENT=crush                 -> agent
AI_AGENT=1                     -> not detected  ← also matched by value
CURSOR_TRACE_ID=<anything>     -> interactive   ← not an agent on its own
REPL_ID=<anything>             -> agent

Two consequences worth knowing. First, a generic-looking name like AGENT in your CI environment will not trigger detection unless it holds one of two specific values — so the obvious “my pipeline sets AGENT, am I at risk?” worry is unfounded. Second, REPL_ID classifies as agent unconditionally: am-i-vibing carries two more Replit rules — an interactive one, and a separate Replit Assistant gated on REPLIT_MODE=assistant — but both sit later in the same first-match-wins list and neither can ever be reached. Set REPLIT_MODE=assistant and you still come back as plain Replit. On Replit, a human typing astro dev by hand gets the agent treatment.

One more limit: the library can walk the process tree, but only when you opt in with checkProcesses: true. Astro calls it with no arguments. Agents that ship no environment variable of their own are therefore invisible to Astro no matter what they do.

What changes when Astro detects an agent

Detection forces exactly one flag, flags.json; backgrounding goes through a local variable:

const agentDetected = !process.env.ASTRO_DEV_BACKGROUND && isRunByAgent();
if (agentDetected) {
  flags.json = true;
}
const wantsBackground = !!flags.background || agentDetected;

So detection is equivalent to typing astro dev --background --json. Note that it’s the pair that matters: --background on its own doesn’t imply JSON, and a background server started by a human quietly fills .astro/dev.log with timestamped human text instead — hold onto that timestamp, it comes back later.

The background path spawns a detached child with stdio redirected into that log file. The child is what writes the lock file at .astro/dev.json; the parent polls it every 200ms until the pid matches the process it spawned. Here’s the real lock file from the 26-hour server, reindented from tabs:

{
  "pid": 76478,
  "port": 4324,
  "url": "http://localhost:4324",
  "urls": { "local": ["http://localhost:4324/"], "network": [] },
  "background": true,
  "startedAt": "2026-07-27T06:17:07.669Z"
}

One thing that isn’t limited to the dev server: dist/events/session.js calls the same detector to decorate CLI telemetry with agentId, agentName, and agentType. That path fires on isAgentic, so it reports Cursor and Warp too — environments where nothing else about your dev server changes. If you care, it rides on the normal Astro telemetry channel and astro telemetry disable turns it off with everything else.

Turning it off — and forcing it on

Because detection is nothing but an environment check, the shell is your override in both directions:

# Foreground, even inside an agent session
env -u CLAUDECODE npx astro dev

# Background + JSON logs, as a human
astro dev --background --json

I used the first one to confirm the behavior is real rather than documentation folklore: same shell, same project, twice. The stripped run blocked the terminal exactly as Astro 6 always did and wrote "background": false into the lock file. The unmodified run returned control in 2.4 seconds and wrote "background": true.

There’s a third lever, and it’s the one to be careful with. Look at the guard in the snippet above: !process.env.ASTRO_DEV_BACKGROUND. Astro sets that variable on the child it spawns so the child doesn’t try to background itself — but nothing stops you setting it yourself, and it works as an undocumented opt-out:

ASTRO_DEV_BACKGROUND=1 astro dev   # foreground, human-readable logs, inside an agent session

It also exposes an inconsistency. Further down the same file, the lock file’s background field is derived from that variable too — background: !!process.env.ASTRO_DEV_BACKGROUND — so this run records itself as backgrounded when it plainly isn’t:

{"pid": 41821, "port": 4402, "background": true, "startedAt": "2026-07-28T08:37:01.813Z"}

astro dev status then repeats the claim — Dev server running at http://localhost:4402 (pid 41821, uptime 13s, background) — for a server that’s holding my terminal open as I read it. Prefer env -u CLAUDECODE for opting out; it doesn’t lie to the lock file.

The JSON is thinner than “machine-readable” suggests

This is the part that surprised me. The JSON logger is 31 lines, and the payload has exactly three fields:

// node_modules/astro/dist/core/logger/impls/json.js
// the real line is a pretty ? … : … ternary packed onto one line; wrapped, one branch shown
const payload = JSON.stringify({
  message,               // human string, ANSI escape codes stripped
  label: event.label,    // "vite", "watch", "glob-loader", "content", "types", null…
  level: event.level,    // "info" | "warn" | "error" | ...
});

The only transformation applied to message is stripping SGR color codes with a regex. Everything else is the same string a human would’ve read. So a real error looks like this — one line from my log, wrapped for readability:

{
  "message": "publishedOnly is not defined\n  Stack trace:\n    at Module.getStaticPaths (/…/src/pages/blog/[slug].astro:2:1)\n    [...] See full stack trace in the browser, or rerun with --verbose.",
  "label": null,
  "level": "error"
}

Look at where the useful information lives. The file path, the line number, the column, the failing function — all embedded in a newline-delimited human string. There’s no file field, no line field, no error code. And no timestamp: the human-facing logger prefixes every line with the time, the JSON logger drops it. Structured logs you can’t order in time are a strange kind of structured.

How much that matters depends on what’s reading the log:

  • A language model parses that error string as easily as you do. The valuable part of the JSON is level: my log had 165 info lines and two errors, and level alone finds the two without reading a word of the rest.
  • A script is stuck writing regexes against error prose that carries no compatibility guarantee. Astro can reword any message in a patch release without it being a breaking change.

So “machine-readable” is accurate but easy to read too much into. LLM-readable is closer. The structure exists to make logs filterable, not to make errors programmatically addressable — triage, not diagnosis.

Two smaller cracks in the same wall. Internal sentinels leak through: Astro’s human logger treats label: "SKIP_FORMAT" as an instruction meaning “don’t decorate this line,” but the JSON logger doesn’t check for it and emits "label":"SKIP_FORMAT" as if it were a real category. And not everything becomes JSON at all — a flag-conflict error is thrown and handled by the generic CLI handler, so it arrives on stderr as plain text with a stack trace, in an agent session that has JSON enabled.

astro dev status, logs, and stop

Backgrounding a process silently means you need a way to find it again. Astro 7 adds three subcommands, all reading the same lock file:

astro dev status   # is anything running, and where?
astro dev logs     # dump .astro/dev.log (--follow to tail it)
astro dev stop     # kill it and remove the lock file

status answers in JSON when you’re an agent:

{"message":"Dev server running at http://localhost:4324 (pid 76478, uptime 93615s, background)","label":"SKIP_FORMAT","level":"info"}

That uptime 93615s is the 26 hours. Note that even here it’s prose inside message rather than a numeric field.

logs is the one worth calling out: it refuses outright on a foreground server, pointing you at the terminal where it was started, and with --follow it re-checks the pid every second so it exits on its own when the server dies instead of hanging forever. That makes it safe to hand to an agent, which is presumably the point.

The failure modes nobody mentions

Orphaned servers are the default outcome, not an edge case. A detached process outlives the agent session that spawned it. Mine survived 26 hours, several sleep cycles, and two other projects. The lock file prevents duplicates — a second astro dev tells you where the existing server is, exiting 0 on the agent and --background paths and 1 as a plain foreground error — but nothing prevents longevity. And the duplicate check ignores ports entirely: ask for --port 4401 while something’s running on 4399 and you’ll be told about 4399 and get no server.

The lock file only covers dev. Checking my process list turned up seven stray Astro processes, four of them astro preview on ports 4321, 4331, 4400, and 4500. preview writes no lock file, has no stop subcommand, and never calls the detector at all — so the protection you get in dev evaporates the moment an agent previews a production build. lsof -i :4321 is still your job.

The false-positive path is real, just not the one you’d guess. It isn’t generic CI variables; it’s TERM_PROGRAM=vscode plus GIT_PAGER=cat, which classifies as GitHub Copilot in VS Code and returns agent. am-i-vibing’s own README warns about exactly this — a human running a command in a Copilot-opened terminal. If a pipeline of yours starts the dev server, don’t rely on Astro 6’s foreground behavior: start it explicitly, wait for it, and shut it down yourself. (A static deploy to Cloudflare Workers needs astro build, not astro dev, so often the fix is not running it at all.)

--ignore-lock stops working inside an agent session. A background server missing from the lock file could never be found by stop, status, or logs, so Astro rejects the combination — and since detection implies backgrounding, it rejects it even when you never typed --background.

What to do about it

  • Check at both ends of a session. astro dev status when you start, astro dev stop when you finish. A backgrounded server outlives the agent that spawned it.
  • Put astro dev stop where your agent will read itCLAUDE.md, AGENTS.md, whatever your tool loads. The fix is a habit, and the agent is the one that needs it.
  • Track astro preview yourself. No lock file, no stop, no detection, no protection.
  • Opt out with env -u CLAUDECODE, not ASTRO_DEV_BACKGROUND=1. Only env -u leaves an honest lock file.
  • If a script reads the log, match on level, not message. The prose inside message carries no compatibility guarantee — and no timestamp.

Why this matters more than the build times

The benchmark numbers are the headline, but they’re a continuation; builds have been getting faster every release for years. Agent detection is a discontinuity. It’s a framework changing its default runtime behavior based on who — or what — invoked it, and treating a non-human caller as a first-class case rather than a degraded one.

The implementation is deliberately unglamorous: a dependency that reads environment variables, a detached spawn, a lock file, and a logger that wraps strings in three fields. No protocol, no agent API, no negotiation. It works because it fixed the single most annoying thing about agents and dev servers — the blocked terminal — with a few hundred lines of process management.

What’s missing is the other half. Structured output exists; structured errors don’t. The day message splits into file, line, and code is the day an auto-repair loop stops depending on a model’s reading comprehension. Until then, Astro 7 has made the dev server safe for agents to start, and left interpreting the results to the agent.

If you want to see the shape of it yourself, the code is short enough to read in one sitting: dist/cli/dev/index.js for the detection, dist/cli/dev/background.js for the spawn, and dist/core/logger/impls/json.js for the log format. That last one is 31 lines and explains more than any release note.