開発の記録
Astro 7 のエージェント検出:何が引き金になり、何が壊れるか
· 読了まで約19分
目次
26時間動きっぱなしの開発サーバーが自分のマシンで見つかりました。起動した覚えはありません。少なくとも意識的には。AI コーディングエージェントが私の代わりに astro dev を叩き、Astro 7 がそのエージェントを検出してプロセスを切り離し、そのまま私もエージェントも二度とその話をしなかった、というだけのことです。
つまり機能は設計どおりに働いています。同時にこれは、Astro 7 について誰も書いていない部分でもあります。リリース記事はどれも Rust コンパイラと15〜61% 速くなったビルドを扱い、エージェント検出のほうは数行で片付きます。AI エージェントの中で動いていることを検出したらバックグラウンドモードを自動で有効にする、そしてそのときは JSON ログも自動で付く、と。
この説明でわかることより、わからなくなることのほうが多い。何をもってエージェントとするのか。JSON には何が入っているのか。本当に機械可読なのか、それとも人間向けの文字列を波括弧で包んだだけなのか。Astro 7.1.3 のソースを読み、12種類のでっちあげた環境に対して検出器を走らせて確かめました。出てきたもののいくつかは、この機能の一般的な要約と食い違います。Astro が自分の依存ライブラリと見解を違えている箇所も、ロックファイルが事実でないことを書き残す箇所もありました。
引き金になるのは agent だけ
検出そのものは Astro の実装ではありません。am-i-vibing(この環境では v0.4.0)に委譲していて、呼び出し側はこれだけです。
// node_modules/astro/dist/cli/dev/index.js
function isRunByAgent() {
try {
return detectAgenticEnvironment().type === "agent";
} catch {
return false;
}
}
この === "agent" の比較がすべてで、ここが頭に入れておく価値のある細部です。というのも am-i-vibing が区別する環境は3種類あるからです。2つのつもりで読むと取り違えます。
| type | 意味 | 例 | astro dev の挙動 |
|---|---|---|---|
agent | プログラムが非対話的にコマンドを走らせている | Claude Code、Codex CLI、Gemini CLI | バックグラウンド + JSON |
interactive | 人間が AI 寄りのエディタで打っている | Cursor の統合ターミナル | フォアグラウンド、変化なし |
hybrid | どちらとも取れる | Warp Terminal | フォアグラウンド、変化なし |
スイッチが入るのは agent だけです。 AI エディタの中のターミナルから astro dev を叩いてもバックグラウンドには回りません。そのターミナルは人間が見ていて、サーバーの出力が出ることを期待しているからです。妥当な判断ですし、この機能の要約でいちばん潰されやすい区別でもあります。
ここから本物の見解の相違も生まれています。am-i-vibing は isAgent() というヘルパーを公開していて、こちらは agent と hybrid の両方で true を返します。Astro はそれを使わず、type を自分で比較します。だから Warp Terminal では、ライブラリ自身の便利関数が「これはエージェントだ」と言い、Astro が「違う」と言う状態になります。
TERM_PROGRAM=WarpTerminal -> type: "hybrid", isAgent(): true, Astro: フォアグラウンド
バグというより、Astro が意図して保守的な側を取ったのだと思います。ただ am-i-vibing の上に自分のツールを作るなら、isAgent() が Astro と同じ判定をすると思い込まないほうがいい。
環境変数の表には落とし穴がある
検出は環境変数のマッチングです。Astro が通る経路には TTY の詮索もプロセスツリーの探索もありません。ただ、バンドルを grep して得られる文字列の表は誤解を招きます。多くのルールが変数の有無ではなく値でマッチするからです。隔離した環境で検出器を走らせて、どちらがどちらかを確かめました。
CLAUDECODE=<何でも> -> agent (空文字以外なら中身は問わない)
CODEX_THREAD_ID=<何でも> -> agent (空文字以外なら中身は問わない)
GEMINI_CLI=1 -> agent
GEMINI_CLI=true -> 検出されない ← 値がちょうど "1" である必要がある
AGENT=crush -> agent
AGENT=ci-runner -> 検出されない ← 値は "crush" か "amp" のみ
AI_AGENT=crush -> agent
AI_AGENT=1 -> 検出されない ← これも値で判定される
CURSOR_TRACE_ID=<何でも> -> interactive ← 単体ではエージェント扱いにならない
REPL_ID=<何でも> -> agent
ここから帰結が2つ。ひとつ、AGENT のようないかにも一般的な名前が CI 環境にあっても、値が特定の2つのどちらかでない限り検出は起きません。「うちのパイプラインは AGENT を立てているけど大丈夫か」という素朴な心配は杞憂でした。ふたつ、REPL_ID は無条件に agent に分類されます。am-i-vibing は Replit 向けにもう2つルールを持っていて、interactive のものと、REPLIT_MODE=assistant を条件にした Replit Assistant のものがあるのですが、どちらも先勝ちのリストの後方に置かれていて到達しません。REPLIT_MODE=assistant を立てても、返ってくるのは素の Replit です。Replit の上では、人間が手で astro dev と打ってもエージェント扱いになります。
もうひとつ限界を挙げると、このライブラリはプロセスツリーを辿ることもできますが、checkProcesses: true を明示したときだけです。Astro は引数なしで呼んでいます。自前の環境変数を持たないエージェントは、何をしていようと Astro からは見えません。
検出されると何が変わるか
検出が実際に書き換えるフラグは flags.json の1つだけで、バックグラウンド化のほうはローカル変数を経由します。
const agentDetected = !process.env.ASTRO_DEV_BACKGROUND && isRunByAgent();
if (agentDetected) {
flags.json = true;
}
const wantsBackground = !!flags.background || agentDetected;
検出は astro dev --background --json と打つのと等価だ、ということです。効いているのが組み合わせである点には注意がいります。--background 単体は JSON を意味しませんし、人間が起動したバックグラウンドサーバーは .astro/dev.log にタイムスタンプ付きの人間向けテキストを黙々と溜めます。このタイムスタンプは後でまた出てきます。
バックグラウンド経路は stdio をそのログファイルに向けた子プロセスを detach して起動します。ロックファイル .astro/dev.json を書くのはその子のほうで、親は 200ms おきにそれを読み直し、pid が自分の産んだ子と一致するまで待ちます。以下は例の26時間サーバーから取った実物です(タブをスペースに直してあります)。
{
"pid": 76478,
"port": 4324,
"url": "http://localhost:4324",
"urls": { "local": ["http://localhost:4324/"], "network": [] },
"background": true,
"startedAt": "2026-07-27T06:17:07.669Z"
}
開発サーバーに閉じない話も1つあります。dist/events/session.js が同じ検出器を呼び、CLI のテレメトリに agentId・agentName・agentType を付けています。こちらは isAgentic で発火するので、Cursor や Warp も対象です。開発サーバーの挙動が何ひとつ変わらない環境まで報告されます。気になるなら、これは通常の Astro テレメトリのチャンネルに乗っているので astro telemetry disable で他とまとめて止まります。
切り方と、逆に効かせ方
検出は環境チェックでしかないので、両方向の上書き手段はシェルです。
# エージェントのセッション内でもフォアグラウンドで動かす
env -u CLAUDECODE npx astro dev
# 人間がバックグラウンド + JSON ログを使う
astro dev --background --json
この挙動が実際に起きることは、1つ目のコマンドで確かめました。ドキュメントを読んだだけでは信じ切れなかったからです。同じシェル、同じプロジェクトで2回。変数を剥がしたほうは Astro 6 がずっとそうだったようにターミナルを掴んだまま止まり、ロックファイルには "background": false と書きました。手を加えないほうは 2.4 秒で制御を返し、"background": true と書きました。
3つ目のレバーもあります。そしてこれは扱いに注意が要るものです。さきほどのコードのガードを見てください。!process.env.ASTRO_DEV_BACKGROUND の部分です。Astro は起動した子プロセスがさらに自分自身をバックグラウンドに回そうとしないよう、この変数を子に渡しています。ただ、これを自分で立てるのを止めるものは何もなく、文書化されていないオプトアウトとして機能します。
ASTRO_DEV_BACKGROUND=1 astro dev # エージェントのセッション内でも、フォアグラウンド + 人間向けログ
そして、ここで不整合も表に出ます。同じファイルの下のほうで、ロックファイルの background フィールドもこの変数から導かれています(background: !!process.env.ASTRO_DEV_BACKGROUND)。だから明らかにバックグラウンドではない実行が、自分をバックグラウンドだと記録します。
{"pid": 41821, "port": 4402, "background": true, "startedAt": "2026-07-28T08:37:01.813Z"}
astro dev status もその主張をそのまま繰り返します。Dev server running at http://localhost:4402 (pid 41821, uptime 13s, background)。私がそれを読んでいるあいだ、当のサーバーは私のターミナルを掴んで離さないままです。オプトアウトには env -u CLAUDECODE を使ってください。ロックファイルに嘘を書かないのはこちらです。
JSON は「機械可読」から想像するより薄い
驚いたのはここです。JSON ロガーは31行しかなく、ペイロードのフィールドはちょうど3つです。
// node_modules/astro/dist/core/logger/impls/json.js
// 実物は pretty ? … : … の三項演算子で1行に詰まっている。折り返して片方だけ載せた
const payload = JSON.stringify({
message, // 人間向けの文字列。ANSI エスケープは除去済み
label: event.label, // "vite", "watch", "glob-loader", "content", "types", null…
level: event.level, // "info" | "warn" | "error" | ...
});
message に対して行われる加工は、正規表現で SGR の色コードを剥がすことだけ。残りは人間が読んでいたはずの文字列そのものです。だから実際のエラーはこう見えます。私のログから1行、読みやすいよう折り返してあります。
{
"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"
}
有用な情報がどこに置かれているかを見てください。ファイルパス、行番号、桁、落ちた関数。すべて改行区切りの人間向け文字列に埋め込まれています。file フィールドも line フィールドもエラーコードもありません。そしてタイムスタンプもない。 人間向けのロガーは各行の先頭に時刻を付けますが、JSON ロガーはそれを落とします。時系列に並べられない構造化ログというのは、なかなか奇妙な構造化です。
これがどれだけ問題になるかは、ログを読むのが誰かによります。
- 言語モデルは、そのエラー文字列を人間と同じように読めます。JSON の価値があるのは
levelのほうで、私のログは info が165行にエラーが2行でしたが、levelだけで残りを1文字も読まずにその2行が見つかります。 - スクリプトは、互換性の保証がないエラーの散文に正規表現をぶつける羽目になります。Astro はパッチリリースでどのメッセージを書き換えても、破壊的変更にはあたりません。
つまり「機械可読」は正しいけれど、読み過ぎやすい表現です。「LLM 可読」のほうが近い。この構造はログを絞り込めるようにするためのもので、エラーをプログラムから名指しできるようにするためのものではありません。トリアージであって診断ではない、ということです。
同じ壁に小さなひびがあと2つ。内部用のセンチネルが漏れます。Astro の人間向けロガーは label: "SKIP_FORMAT" を「この行は装飾するな」という指示として扱いますが、JSON ロガーはそれを見ないので "label":"SKIP_FORMAT" を実在のカテゴリであるかのように出力します。そして、そもそも JSON にならないものもあります。フラグの衝突エラーは throw されて汎用の CLI ハンドラが処理するため、JSON を有効にしているエージェントのセッションでも、スタックトレース付きのプレーンテキストとして stderr に届きます。
astro dev status / logs / stop
プロセスを黙ってバックグラウンドに回す以上、それを見つけ直す手段が要ります。Astro 7 はサブコマンドを3つ追加していて、どれも同じロックファイルを読みます。
astro dev status # 何か動いているか、どこで動いているか
astro dev logs # .astro/dev.log を出す(--follow で追尾)
astro dev stop # 止めてロックファイルを消す
エージェントとして呼ぶと、status は JSON で答えます。
{"message":"Dev server running at http://localhost:4324 (pid 76478, uptime 93615s, background)","label":"SKIP_FORMAT","level":"info"}
uptime 93615s が例の26時間です。ここですら数値フィールドは無く、message の中の散文です。
取り上げる価値があるのは logs です。フォアグラウンドのサーバーに対しては素っ気なく拒否し、起動したターミナルの側を見ろという扱いになります。--follow を付けると毎秒 pid を確認し直すので、サーバーが死ねば自分で終了します。永遠にぶら下がりません。エージェントに渡しても安全というわけで、おそらくそれが狙いです。
誰も言わない壊れ方
孤児サーバーはエッジケースではなく既定の結末です。 detach されたプロセスは、それを起動したエージェントのセッションより長く生きます。私のものは26時間、スリープを何度か挟み、別のプロジェクト2つをまたいで生き延びました。ロックファイルが防ぐのは重複だけです。2つ目の astro dev は既存のサーバーの居場所を教えてくれます(エージェント経路と --background なら穏当に報告して exit 0、素のフォアグラウンドならエラー扱いで exit 1)。長寿は誰も防ぎません。しかも重複チェックはポートを完全に無視します。4399 で何かが動いているときに --port 4401 を頼むと、4399 のことを教えられてサーバーは手に入りません。
ロックファイルが守るのは dev だけです。 プロセス一覧を確かめたら Astro の残骸が7つ出てきて、うち4つはポート 4321・4331・4400・4500 の astro preview でした。preview はロックファイルを書かず、stop サブコマンドを持たず、そもそも検出器を一度も呼びません。エージェントが本番ビルドをプレビューした瞬間、dev で得られていた保護は蒸発します。lsof -i :4321 は相変わらず自分の仕事です。
誤検出の経路は実在します。ただし想像するものとは違います。 犯人は TERM_PROGRAM=vscode と GIT_PAGER=cat の組み合わせでした。CI の一般的な変数のほうは無罪です。これは VS Code の GitHub Copilot と分類されて agent を返します。am-i-vibing の README 自身がまさにこの誤検出を警告しています。Copilot が開いたターミナルで人間がコマンドを打つ場面です。もし自分のパイプラインが開発サーバーを起動するなら、Astro 6 のフォアグラウンド挙動を当てにしないこと。明示的に起動し、待ち、自分で落とす。(Cloudflare Workers への静的デプロイに必要なのは astro build であって astro dev ではないので、そもそも起動しないのが正解というケースも多いはずです。)
エージェントのセッション内では --ignore-lock が使えなくなります。 ロックファイルに載らないバックグラウンドサーバーは stop からも status からも logs からも二度と見つけられないので、Astro は --background と --ignore-lock の併用を拒否します。そして検出はバックグラウンド化を意味するため、--background と打っていなくても拒否されます。
実際にどうするか
- セッションの入口と出口の両方で確認する。 始めるときに
astro dev status、終わるときにastro dev stop。バックグラウンドのサーバーは、それを起動したエージェントより長く生きます。 astro dev stopをエージェントが読む場所に置く。CLAUDE.md、AGENTS.md、使っているツールが読み込むもの。直し方は習慣であり、その習慣を必要としているのはエージェントのほうです。astro previewは自分で追う。 ロックファイルなし、stopなし、検出なし、保護なし。- オプトアウトは
ASTRO_DEV_BACKGROUND=1ではなくenv -u CLAUDECODEで。 ロックファイルに正しい値が残るのは後者だけです。 - スクリプトでログを読むなら
messageではなくlevelで判定する。messageの中の散文には互換性の保証がありません。タイムスタンプもありません。
ビルド時間よりこちらが効いてくる理由
ベンチマークの数字が見出しを取りますが、あれは連続的な話です。ビルドは何年も、リリースのたびに速くなってきました。エージェント検出のほうは不連続です。誰が、あるいは何が呼んだかによって、フレームワークが既定の実行時挙動を変えている。人間でない呼び出し元が、劣化した特殊ケースから一級の相手へ格上げされている。
実装は意図的に地味です。環境変数を読む依存ライブラリ、detach した spawn、ロックファイル、そして文字列を3つのフィールドで包むロガー。プロトコルもエージェント API も交渉もありません。それでも効くのは、エージェントと開発サーバーのあいだで最も苛立たしかった1点、つまりターミナルが塞がることを、数百行のプロセス管理で解いたからです。
足りていないのは残り半分です。構造化されているのは出力までで、エラーは構造化されていません。message が file と line と code に割れる日は、自動修復ループがモデルの読解力に頼らなくてよくなる日です。それまでのところ Astro 7 は、開発サーバーをエージェントが安全に起動できるものにしたうえで、結果の解釈はエージェントに預けています。
自分で形を確かめたいなら、コードは一度に読み切れる長さです。検出は dist/cli/dev/index.js、spawn は dist/cli/dev/background.js、ログの形式は dist/core/logger/impls/json.js。最後の1つは31行で、どのリリースノートより多くを説明してくれます。