はじめに
自宅PCのWSL2では、Claude Codeをtmuxの中で常駐させています(作り方はPCを再起動してもClaude Codeの続きに戻るに書きました)。出先からSSHで入っても、家でWindows Terminalから入っても、同じ作業に戻れるのが利点です。
ある日、家のPCの前でClaude Codeに「このフォルダからVS Codeを起動できますか?」と頼みました。返ってきたのは「code . で起動しました」。ところが、画面には何も出てきません。
調べていくと、起動はしていたが、自分の画面ではない場所に出ていたことが分かりました。最終的に、次のことができるようになりました。
- 「VS Codeを開いて」と頼むと、自分の画面にVS Codeが開く
- 「このページをブラウザで見せて」と頼むと、自分の画面にChromeが開く
- 「自動で操作して見せて」と頼むと、Claude Codeが操作するChromeの様子を、自分の画面で見ていられる
先に答えを書いておきます。
- 原因: Windowsのプログラムが、画面の無い「セッション0」で起動していた
- 見分け方: WSLからWindowsへの窓口ごとに
cmd.exe /c echo %SESSIONNAME%を実行し、Consoleを返すものが自分の画面につながっている - 直し方: その窓口を環境変数
WSL_INTEROPで指定して起動する。これを毎回自動でやるスクリプトwinrunを作った
ここまでの作業はすべて、Claude Codeとの会話の中で進めました。人間がやったのは、許可を出すことと、WSLを1回再起動することだけです。
環境は Windows 11 / WSL 2.6.3.0 / Ubuntu 24.04 です。
「起動しました」と言われたのに、何も出ない
最初のやりとりはこうでした。
(code . >/dev/null 2>&1 &) ; sleep 3; echo launched
# → launched
Claude Codeは launched を見て「起動しました」と報告しました。ですが、出力を全部捨ててバックグラウンドに回しているので、launched は「命令を投げた」ことしか意味しません。成功したかどうかは、これでは分かりません。
「開かないですね」と返すと、Claude Codeは出力を捨てずに実行し直しました。
timeout: failed to run command ‘code’: No such file or directory
code というコマンド自体が見つかっていませんでした。この環境では、Windows側のフォルダ(/mnt/c/...)がPATHに入っていないためです。最初の「起動しました」は、黙って失敗していたわけです。
フルパスで起動すると、プロセスは動くが画面に出ない
そこでVS Codeをフルパスで起動し、Windowsのプロセス一覧(tasklist.exe)で確かめました。
イメージ名 PID セッション名 セッション# メモリ使用量
========================= ======== ================ =========== ============
Code.exe 10376 Services 0 141,132 K
Code.exe 13984 Services 0 33,524 K
Code.exe は動いています。問題は「セッション名」の列です。Services、セッション番号0。
Windowsは、ログインしている人の画面(デスクトップ)とは別に、裏で動くサービス専用の領域を持っています。これがセッション0です。セッション0には、人が見る画面がありません。そこで起動したプログラムは、ウィンドウを作っても誰の目にも触れません。自分の画面は、セッション番号1の Console のほうです。
見えないChromeは、見えないだけでは済まない
「画面に出ないだけなら害はない」と思いがちですが、Chromeの場合はそうでもありません。
実は1か月前にも、Claude Codeとは関係のない経路で同じ穴に落ちていました。Cloudflareのログインでブラウザを開くコマンドが、セッション0にChromeを起動していたのです。そのChromeは見えないまま、普段使っているChromeの設定やログイン情報を使い続けて居座りました。その結果、デスクトップのアイコンからChromeを起動しようとしても、起動しなくなりました。
見えない場所に何かを起動してしまったら、tasklist.exe で Services のプロセスが残っていないか確認し、残っていたら終了させておくのが安全です。今回も、セッション0で起動してしまった Code.exe は、Claude Codeがすぐに片付けました。
なぜセッション0で起動するのか
WSLの中からWindowsのプログラムを起動すると、その依頼はWSLとWindowsをつなぐ窓口を通って、Windows側で実行されます。窓口は /run/WSL/ にあるファイルとして見えます。
$ ls -la /run/WSL/
lrwxrwxrwx 1_interop -> /run/WSL/2_interop
srwxrwxrwx 2_interop
srwxrwxrwx 3462_interop
srwxrwxrwx 124309_interop
窓口は1つではありません。Windows側でWSLを起動した経路(wsl.exe を呼んだもの)ごとに1つずつできます。そして、窓口を通って起動したプログラムは、その窓口を作った wsl.exe と同じセッションに生まれます。
どの窓口を使うかは、環境変数 WSL_INTEROP で決まります。未設定なら 1_interop です。
うちでは、WSLをタスクスケジューラからログオン前に起動しています。ログオン前に動くタスクはセッション0で走るので、そのタスクが作った 2_interop(1_interop の指す先)もセッション0につながります。さらに、Claude Codeが動いているtmuxは、WSLの起動と一緒にsystemdが立ち上げたものです。systemdから始まったプロセスには WSL_INTEROP が設定されていないので、既定の窓口を使ってしまいます。
つまり、「WSLがセッション0から起動されている」と「起動する側が WSL_INTEROP を持っていない」の2つがそろったときに起きます。Windows Terminalで開いたシェルから直接 code . と打つ分には、このシェルは自分の画面の窓口を持っているので問題になりません。
どの窓口が自分の画面につながっているかを聞く
窓口の番号を見ても、どれがどのセッションのものかは分かりません。そこで、それぞれの窓口経由でWindowsの cmd.exe を起動し、自分がどのセッションにいるかを答えさせました。
2_interop: %SESSIONNAME%
3462_interop: %SESSIONNAME%
124309_interop: Console
%SESSIONNAME% はWindowsの環境変数で、自分の画面のセッションでは Console になります。セッション0ではこの変数自体が定義されていないので、%SESSIONNAME% という文字がそのまま返ってきます。
124309_interop だけが Console でした。これは、家でtmuxにつなぐためにWindows Terminalで開いていたWSLのターミナルが作った窓口です。
この窓口を指定して起動し直すと、今度は自分の画面に出ました(初回だけ、WSL拡張の導入などの確認画面がいくつか出ます)。
Code.exe 6840 Console 1 110,632 K
Code.exe 9100 Console 1 34,944 K
毎回探すスクリプトにする
最初に考えるのは、「tmuxやsystemdに WSL_INTEROP を渡しておけばよいのでは」という直し方です。ですが、これはうまくいきません。
- 窓口の番号は、WSLを起動し直すたびに変わります。固定の値は書けません
- 自分の画面の窓口は、Windows TerminalでWSLのターミナルを開いている間しかありません。systemdがtmuxを起動する時点では、まだ存在しないことがほとんどです
- 環境変数は、プロセスが起動したときに決まります。あとから設定を足しても、すでに動いているClaude Codeには届きません
なので、起動する直前に、今ある窓口から Console のものを探すことにしました。
実は、この「探す」部分は1か月前に書いてありました。先ほどの見えないChromeの件のときに、ブラウザを開くコマンドを正しい窓口へ向ける仕組みとして作った wsl-interop-console です。窓口のファイルを1つ出力するだけのスクリプトで、要点は次のとおりです。
# ~/.local/bin/wsl-interop-console(要点)
# 窓口 $1 が自分の画面(Console)につながっているか
probe() {
[ -S "${1:-}" ] || return 1
case "$(cd /mnt/c && WSL_INTEROP="$1" timeout 2 \
/mnt/c/Windows/System32/cmd.exe /c 'echo %SESSIONNAME%' 2>/dev/null)" in
Console*) return 0 ;;
*) return 1 ;;
esac
}
# ① 今の WSL_INTEROP → ② 前回当たった窓口(キャッシュ)→ ③ 新しい順に全部、の順に試す。
# 当たったらキャッシュに書き戻して出力する
probe "${WSL_INTEROP:-}" && emit "$WSL_INTEROP"
probe "$cached" && emit "$cached"
for sock in $(ls -1t /run/WSL/*_interop 2>/dev/null); do
probe "$sock" && emit "$sock"
done
exit 1
- 前回当たった窓口を先に試すので、ふだんは
cmd.exeを1回呼ぶだけで済みます(手元では全体で0.15秒ほどでした) - 全部を探すときは新しい順に見ます。自分の画面の窓口は、ターミナルを開いたときにあとからできるからです
cd /mnt/cは、WSL側のフォルダからcmd.exeを呼んだときに出る警告を避けるためです
winrun は、これを呼んで結果を WSL_INTEROP に入れるだけです。最初は winrun の中にも同じ探し方を書いていましたが、判定が2か所に分かれると、片方だけ直して食い違うことになります。なので、探す処理は1か所にまとめました。
#!/usr/bin/env bash
# ~/.local/bin/winrun
# Windows のプログラムを、自分の画面(Console セッション)で起動する。
if ! sock=$("${HOME}/.local/bin/wsl-interop-console"); then
echo "winrun: デスクトップにつながる interop が見つかりません(Windows 側で WSL のターミナルを1つ開いてください)" >&2
exit 1
fi
WSL_INTEROP=$sock exec "$@"
これを使って、VS CodeとChromeを開く短いスクリプトも作りました。
# ~/.local/bin/code(VS Code を開く)
exec winrun "/mnt/c/Users/<user>/AppData/Local/Programs/Microsoft VS Code/bin/code" "$@"
# ~/.local/bin/winbrowse(Chrome で URL を開く)
exec winrun "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" "$@"
.bashrcの関数ではなく、スクリプトにしました。 関数だと、それを読み込んだシェルの中でしか使えず、Claude Codeが実行するコマンドからは呼べないことがあるからです- 自分の画面につながる窓口が無いときは、見えない場所で起動せずにエラーで止めます。 前提は、Windows側でWSLのターミナルが1つ開いていることです
どのプロジェクトのClaude Codeにも覚えてもらう
スクリプトを置いただけでは、Claude Codeは存在を知りません。別のプロジェクトで「ブラウザで見せて」と頼むと、また一から調べ始めてしまいます。
そこで、ユーザー全体の指示ファイル ~/.claude/CLAUDE.md に書きました。このファイルは、どのフォルダでClaude Codeを始めても読み込まれます。中身は、この記事でここまで書いた理由を短くまとめた段落と、次の表です。
| コマンド | 用途 |
|---|---|
| `winbrowse <URL>` | Windows の Chrome で URL をユーザーの画面に開く |
| `code <path>` | VS Code をユーザーの画面に開く |
| `winrun <exe> [args]` | ほかの Windows アプリをユーザーの画面に開く |
「なぜそうするのか」も一緒に書いておくと、想定外の場面でもClaude Codeが判断しやすくなります。
自動操作の様子も見たい
winbrowse で開くのは、自分が普段使っているChromeです。見るのはこちらで、操作するのも自分です。Claude Codeはこのウィンドウを操作できません。
Claude Codeがブラウザを自動操作するときは、これまで画面の無いブラウザで動かし、スクリーンショットを見せてもらっていました。それはそれで便利なのですが、「どう動いているのか」を見ていたくなります。そこで聞いてみました。「winbrowseで操作してもらうことはできないですか? 自動操作のさまも見たいのですが」。
最終的には、次の形になりました。
Chromeを「外から操作できるモード」で起動する
Chromeは、起動オプション --remote-debugging-port を付けると、そのポートで外部からの操作を受け付けます(Chrome DevTools Protocol、略してCDPと呼ばれる仕組みです)。自動操作ツールのPlaywrightは、ここにつないでブラウザを操作できます。
普段のChromeとは別の、専用のプロファイルで起動します。普段のログイン状態を自動操作に触らせないためです。また、最近のChromeは、既定のプロファイルではこのオプションを受け付けなくなっています。
こうして起動したChromeは自分の画面に開き、Windows側からは 127.0.0.1:9222 で応答しました。ところが、WSLからは届きません。
WSL2は標準では、Windowsとは別のネットワークの中にいます。WSLから見た 127.0.0.1 はWSL自身のことで、Windowsの 127.0.0.1 ではありません。そしてChromeの操作用ポートは、Windowsの 127.0.0.1 でしか待ち受けていません。
中継を置こうとして、止められた
Claude Codeが最初に試したのは、Windows側に中継のプログラムを置く方法でした。PowerShellで 0.0.0.0:9223 を待ち受け、届いた通信を 127.0.0.1:9222 に流すものです。
これは、途中でClaude Codeの自動モードの安全チェックに止められました。理由は「ローカルのサービスを外に公開する操作」です。
止められて当然でした。Chromeの操作用ポートは、ブラウザを丸ごと操作できる入口です。ページを開くのも、入力するのも、Cookieを読むのもできます。0.0.0.0 で待ち受けると、同じLANにいる別の機械からも、その入口に届いてしまいます。Claude Codeも中継プログラムを止めて片付け、別の方法を提案してきました。
WSLとWindowsで localhost を共有する
提案されたのは、WSLのネットワークをミラーモード(networkingMode=mirrored)に切り替える方法です。ミラーモードでは、WSLとWindowsが同じ localhost を共有します。WSLから 127.0.0.1:9222 にアクセスすると、そのままWindowsのChromeに届きます。ポートを外に開く必要がありません。
C:\Users\<user>\.wslconfig の [wsl2] に1行足しました。実物には、あとで読み返して困らないように、理由と戻し方をもう少し詳しくコメントで書いてあります。
[wsl2]
# (既存の設定は省略)
# WSL と Windows で localhost を共有する(2026-09-26)。WSL から Windows の Chrome を自動操作するため。
# 戻すときはこの行を消して wsl --shutdown
networkingMode=mirrored
反映するには wsl --shutdown でWSLを止める必要があります。Claude Code自身もWSLの中で動いているので、これを実行すると会話ごと止まります。なので、ここだけは人間がWindows側で実行しました。WSLが立ち上がり直したら、いつもの方法でClaude Codeに戻り、「起動し直しました」と伝えるだけです。
切り替えの副作用も書いておきます。
- WSLで動かしている開発サーバー(
localhost:5173など)が、Windowsのブラウザからそのまま見えるようになります。便利ですが、Windows側と同じポート番号を使うとぶつかります - WSLのネットワーク構成が変わり、LANのIPアドレスがWSLに直接見えるようになりました。うちではdockerもCloudflare Tunnelもそのまま動いています
起動スクリプトとPlaywrightでつなぐ
Chromeの起動もスクリプトにしました。すでに起動していれば何もせず、起動したらWSLから届くまで待ちます。
Chromeの起動は裏に回して出力も捨てているので、そのままだと窓口が見つからなかったときの失敗が見えません。その場合、約30秒待ったあとに「mirroredを確認」という見当違いの案内を出していました。冒頭の「起動しました」と同じ落とし穴です。なので、窓口があるかどうかだけを先に確かめています。
#!/usr/bin/env bash
# ~/.local/bin/winchrome-cdp
# 自動操作できる Windows の Chrome をデスクトップに起動する(DevTools: 127.0.0.1:9222)。
# 普段の Chrome とは別の専用プロファイル(ログイン状態は共有しない)。
set -eu
PORT=9222
if curl -s -m 2 "http://127.0.0.1:$PORT/json/version" >/dev/null; then
echo "winchrome-cdp: すでに 127.0.0.1:$PORT で起動しています" >&2
exit 0
fi
# 窓口が無いときは、ここで止める(下の起動は裏に回すので、失敗が見えなくなる)
winrun true || exit 1
winrun "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" \
--remote-debugging-port=$PORT \
"--user-data-dir=C:\\Users\\<user>\\AppData\\Local\\claude-cdp-profile" \
--no-first-run --no-default-browser-check "${@:-about:blank}" >/dev/null 2>&1 &
for _ in $(seq 20); do
curl -s -m 1 "http://127.0.0.1:$PORT/json/version" >/dev/null && { echo "winchrome-cdp: 127.0.0.1:$PORT で待ち受け中"; exit 0; }
sleep 0.5
done
echo "winchrome-cdp: Chrome は起動したが WSL から 127.0.0.1:$PORT に届かない(.wslconfig の networkingMode=mirrored を確認)" >&2
exit 1
WSL側からは、Playwrightの connectOverCDP でつなぎます。人間が目で追えるように、文字は1文字ずつ間を置いて打たせます。
const { chromium } = require("playwright")
;(async () => {
const browser = await chromium.connectOverCDP("http://127.0.0.1:9222")
const ctx = browser.contexts()[0]
const page = ctx.pages()[0] || (await ctx.newPage())
await page.bringToFront()
await page.goto("https://app.example.com", { waitUntil: "domcontentloaded" })
// 見ている人が追えるように、1文字ずつゆっくり打つ(送信はしない)
await page
.locator("input[type=email]")
.pressSequentially("demo@example.com", { delay: 120 })
// 接続を切るだけ。Chrome は閉じない
await browser.close()
})()
ここでも小さな罠が2つありました。
- PlaywrightはNode.js 20以上が必要です。 うちのWSLの
/usr/bin/nodeは18だったので、「Playwright requires Node.js 20 or higher.」で止まりました。nvmで入れてあった新しいNodeで動かしています browser.close()は接続を切るだけで、Chromeは閉じません。 画面に残るので続けて使えますが、片付けるときは自分で閉じます
実行すると、自分の画面のChromeで、ページが開き、メールアドレス欄に1文字ずつ文字が入っていきました。誰も触っていないのにカーソルが動いて文字が打たれていくのは、分かっていても少し不思議な光景です。
応用: ログインまで見せる — ただしパスワードには触れさせない
こうなると、ログインから先も自動で見せてほしくなります。自作のWebアプリには、動作確認用のテストアカウントを作ってあり、そのパスワードは開発フォルダの .env.local に書いてあります。
ここはClaude Codeのほうから、パスワードの扱いを分けて考えたいと言ってきました。パスワードの値をClaude Codeが読んだり打ち込んだりすると、会話の記録に残ってしまうからです。そこで次の形にしました。
- ログイン用のスクリプト
qa-loginを用意する。パスワードはスクリプトが.env.localから自分で読み、Chromeの入力欄に直接入れる - スクリプトはパスワードを画面にもログにも出さず、「ログインできた/できなかった」だけを返す。入力に失敗したときも、値を含むかもしれない元のエラーは出さない
- 受け付けるのはテストアカウントだけ。それ以外のアドレスを渡すと、入力する前に拒否する
このスクリプトはClaude Codeが自分で実行してよい、と決めました。パスワードはスクリプトの外に出ず、対象もテストアカウントだけだからです。ログイン後に本番のデータを書き換える操作は、これまでどおり事前に確認してもらいます。この取り決めもメモに残し、次の会話に引き継いでいます。
qa-login を実行すると、ログインから画面の移動まで、すべて自分の画面のChromeで目の前で動きました。
まとめ
- Windowsのプログラムが「動いているのに画面に出ない」ときは、
tasklist.exeのセッション名を見ます。Servicesなら、画面の無いセッション0に出ています。WSLがセッション0から起動されていて、起動する側がWSL_INTEROPを持っていない(systemdが起動したtmuxの中など)と起きます - 窓口ごとに
cmd.exe /c echo %SESSIONNAME%を聞き、Consoleを返したものをWSL_INTEROPに指定します。 番号は変わるので、起動する直前に探すスクリプトにします - スクリプトを作ったら、
~/.claude/CLAUDE.mdに理由と一緒に書いておきます。 どのプロジェクトのClaude Codeも、一から調べずに使えます - ブラウザの操作用ポートは、
0.0.0.0で開かずにミラーモードで届かせます。 操作用ポートは、ブラウザを丸ごと操作できる入口だからです
一通り終わったあと、最初と同じように「VSCodeを開いて」と頼むと、今度は自分の画面にVS Codeが開きました。最初に「起動しました」と言われて何も出なかったときから、約40分後のことです。
今回いちばん効いたのは、その最初の「起動しました」を疑ったことかもしれません。バックグラウンドで実行して出力を捨てると、失敗しても成功したように見えます。「開かないですね」と一言返したところから、全部が始まりました。
