🤖 AIとつくる

作業環境を 1 クリックで開く — Chrome 2 窓と Claude Code の履歴一覧を 3 画面に (Windows + WSL)

約6,200字
3 画面に並んだ作業環境のイラスト。左にメインの Chrome (スプレッドシート)、中央のノート PC に Claude Code のセッション履歴一覧、右にブログ用の Chrome

Windows 11 のノート PC にディスプレイを 2 枚足した 3 画面で、WSL2 上の Claude Code を使って作業しています。PC を起動して作業を始めるとき、いつも同じことを手でやっていました。Chrome をメインのアカウントとブログ用のアカウントで 1 つずつ開き、メイン側でタスク管理のタブを開き、ターミナルから WSL に入って Claude Code を立ち上げる。この一式を 1 クリックにしたのがこの記事の内容です。

今はスタートメニューにピン留めしたショートカットを 1 つ押すと、左の画面にメインアカウントの Chrome、右の画面にブログ用の Chrome がそれぞれ最大化で開き、中央の画面には Claude Code のセッション履歴の一覧が出ます。日中も、用途別のショートカットから 1 クリックで Claude Code や WSL のターミナルを開いています。作ったのは Claude Code で、私がやったのは要望を伝えることと、動作確認と、判断です。

今はとても便利に使えています。ほかにメモリの増強や Claude Code の起動を速くする対応 もしていて、トータルでだいぶ感覚はよくなりました。

以降は 🤖 Claude Code の執筆です (設計と実装を担当。内容は 👤 ブログ主が確認・修正しています)。


起動すると開くもの

ピン留めした morning-routine を 1 回押すと、3 画面がこの状態になります。

画面開くもの
メインアカウントの Chrome (最大化)
- 作業管理のスプレッドシート
- issue 一覧
- タスクボード
- Claude の使用量ページ
中央Windows Terminal で Claude Code のセッション履歴一覧 (claude -r)
- その日に使うセッションをここから選ぶ
ブログ用アカウントの Chrome (最大化)

画面の役割は分かれています。左はタスクまわりで、タスクボードの確認と、Claude の使用量の残り (週の制限と 5 時間ごとの制限) のチェック。右はブログのドラフトやアクセス解析、アプリの収益などのデータや記事の確認。中央が Claude Code への指示出しとチャットで、文字を書くのはほぼ中央だけです。見るための左右と、書くための中央、という分担です。

左画面の Chrome。作業管理・issue 一覧・タスクボード・Claude の 4 タブが並び、Claude の使用量ページが開いている
左画面の Chrome。いつもの 4 タブが開いた状態で立ち上がる (Claude の使用量ページで制限の残りを確認)
中央画面。Windows のデスクトップの上に Windows Terminal の窓が開き、claude -r のセッション履歴一覧が表示されている
中央画面はセッション履歴の一覧。その日のセッションをここから選ぶ
右画面の Chrome。ブログ用アカウントの新しいタブページが開いた状態
右画面の Chrome (ブログ用アカウント)。起動直後は新しいタブページで、ここでドラフトやデータ類を開いていく

日中に使う単発のショートカットも同じ仕組みで作り、スタートメニューの「ピン留め済み」にまとめています。

ショートカット開くもの
morning-routine上の 3 画面を一括で開く
wsl-terminal新しい窓に WSL のシェル (Windows Terminal)
claude-new-window新しい窓に WSL のシェル+Claude Code (新規セッション)
claude-resume-window新しい窓に WSL のシェル+Claude Code (履歴一覧つき)
wsl-homeエクスプローラーで WSL のホームディレクトリ
wsl-notesエクスプローラーでブログの執筆メモのフォルダ (WSL 内)
blog-devブログのプレビューサーバを起動
Windows 11 のスタートメニューの「ピン留め済み」欄。morning-routine、wsl-terminal、wsl-home、claude-resume-window、claude-new-window、blog-dev などのショートカットが並んでいる
スタートメニューの「ピン留め済み」に並べたショートカット

タスクバーには、いちばんよく使う claude-resume-window だけをピン留めしています。.lnk のアイコンで違いを付けにくく、並べると見分けがつかないため 1 つにしています。

タスクバーの一部。エクスプローラーや Chrome のアイコンに並んで、ペンギンのアイコン (wsl.exe) の claude-resume-window がピン留めされている
タスクバーにはよく使う 1 つだけ (ペンギンのアイコンが claude-resume-window)

ワンクリック起動の仕組み

Pinned .lnk(signed exe)wsl.exelaunch.py+ config.jsonChrome x2→ move windowWindows Terminal→ claude -r

.lnk が wsl.exe を起動し、WSL 側の launch.pyconfig.json を読んで Chrome 2 窓と Windows Terminal を開く、という流れです。Chrome の窓は開いたあとに PowerShell で目的の画面へ動かして最大化します (理由は注意点に書きます)。ファイルは WSL 側の 1 ディレクトリにまとめてあります。

~/tools/automation/morning-routine/
├── config.json            # 開くタブ・Chrome プロファイル・Claude Code の窓
├── launch.py              # config を読んで Chrome と Windows Terminal を起動
├── move-chrome-window.ps1 # Chrome の新しい窓を指定の画面へ動かす
├── make-shortcuts.ps1     # .lnk の生成
└── *.lnk                  # 生成物。スタートメニュー等にピン留めして使う

config.json はこんな形です (URL は例に置き換えています)。座標は 3 画面が横に並んだ環境のもので、中央が原点、左の画面が X=-1920〜-1、右が X=1920〜3839 です。

{
  "chrome_exe": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
  "chrome_user_data_dir": "/mnt/c/Users/<user>/AppData/Local/Google/Chrome/User Data",
  "chrome_windows": [
    { "name": "sub",  "profile": "Profile 1", "position": [1970, 100],  "maximized": true, "urls": [] },
    { "name": "main", "profile": "Default",   "position": [-1870, 100], "maximized": true,
      "urls": [
        "https://docs.google.com/spreadsheets/d/<id>/edit",
        "https://github.com/<user>/<repo>/issues",
        "https://github.com/users/<user>/projects/1",
        "https://claude.ai/new#settings/usage"
      ] }
  ],
  "claude_cmd": "claude",
  "claude_windows": [
    { "name": "Claude Code", "picker": true }
  ]
}

profile は Chrome のプロファイルディレクトリ名 (Default / Profile 1 など。どのアカウントがどれかは User Data\<profile>\Preferencesaccount_info で分かります)。claude_windowspicker: true で履歴一覧、resume: "<session-id>" で特定セッションの復帰になります。

プログラム

この形にした理由は、ロジックと設定を WSL 側に集めて git で管理し、Windows 側には署名済みの wsl.exe を呼ぶだけの .lnk を置きたかったからです。開くタブやセッションの変更が config.json の編集だけで済み、.lnk を作り直す手間も出ません (Windows 側にスクリプトファイルを置かない理由は注意点の Smart App Control の項)。

3 本のうち launch.py は説明用にコメントを短くした抜粋、PowerShell の 2 本はほぼそのままです。

launch.py

#!/usr/bin/env python3
"""launch.py morning | claude-resume | claude-new  [--dry-run]"""
import json, os, subprocess, sys, time

HERE = os.path.dirname(os.path.abspath(__file__))
CONFIG = os.path.join(HERE, "config.json")
DISTRO = "Ubuntu"
HOME = os.path.expanduser("~")
WIN_CWD = "/mnt/c"  # cwd が WSL パスだと UNC 警告が出るので Windows 側に寄せる
MOVER_WIN = "\\\\wsl.localhost\\" + DISTRO + HERE.replace("/", "\\") + "\\move-chrome-window.ps1"

def run(cmd, dry):
    if dry:
        print("DRY:", cmd); return
    subprocess.Popen(cmd, cwd=WIN_CWD, stdout=subprocess.DEVNULL,
                     stderr=subprocess.DEVNULL, start_new_session=True)

def set_window_placement(cfg, win, dry):
    """Chrome の Preferences に「前回の位置」を書いておく (最大化状態の初期値として効く)"""
    if not win.get("position") or dry:
        return
    x, y = win["position"]; w, h = win.get("size", [1600, 900])
    prefs_path = os.path.join(cfg["chrome_user_data_dir"], win["profile"], "Preferences")
    with open(prefs_path, encoding="utf-8") as f:
        prefs = json.load(f)
    prefs.setdefault("browser", {})["window_placement"] = {
        "left": x, "top": y, "right": x + w, "bottom": y + h,
        "maximized": bool(win.get("maximized"))}
    tmp = prefs_path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(prefs, f, ensure_ascii=False, separators=(",", ":"))
    os.replace(tmp, prefs_path)

def open_chrome(cfg, win, dry):
    set_window_placement(cfg, win, dry)
    args = [f"--profile-directory={win['profile']}", "--new-window"] + win.get("urls", [])
    # Start-Process は ArgumentList を引用符なしで連結するので、各引数を "..." で包む
    arglist = ",".join("'\"" + a.replace("'", "''") + "\"'" for a in args)
    ps = f"Start-Process -FilePath '{cfg['chrome_exe']}' -ArgumentList @({arglist})"
    run(["powershell.exe", "-NoProfile", "-Command", ps], dry)

def chrome_hwnds():
    r = subprocess.run(["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass",
                        "-File", MOVER_WIN, "-ListOnly"],
                       capture_output=True, text=True, cwd=WIN_CWD)
    return [l.strip() for l in r.stdout.splitlines() if l.strip().isdigit()]

def move_new_chrome(win, exclude, dry):
    """exclude にない新しい Chrome 窓を待って指定位置へ。戻り値はその窓の hwnd"""
    x, y = win["position"]; w, h = win.get("size", [1600, 900])
    cmd = ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", MOVER_WIN,
           "-X", str(x), "-Y", str(y), "-W", str(w), "-H", str(h), "-Exclude", ",".join(exclude)]
    if win.get("maximized"):
        cmd.append("-Maximized")
    if dry:
        print("DRY:", cmd); return None
    r = subprocess.run(cmd, capture_output=True, text=True, cwd=WIN_CWD)
    lines = [l.strip() for l in r.stdout.splitlines() if l.strip().isdigit()]
    return lines[-1] if lines else None

def open_claude(cfg, win, dry):
    claude = cfg["claude_cmd"]
    if win.get("resume"):
        claude += f" --resume {win['resume']}"
    elif win.get("picker"):
        claude += " -r"
    # bash -lic: 対話モードでないと .bashrc が途中で return し、nvm 配下の claude が PATH に入らない
    run(["wt.exe", "-w", "new", "nt", "--title", win["name"],
         "wsl.exe", "-d", DISTRO, "--cd", HOME, "--", "bash", "-lic", claude], dry)

def main():
    mode = sys.argv[1] if len(sys.argv) > 1 else "morning"
    dry = "--dry-run" in sys.argv
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    if mode == "claude-resume":
        open_claude(cfg, {"name": "Claude Code", "picker": True}, dry)
    elif mode == "claude-new":
        open_claude(cfg, {"name": "Claude Code"}, dry)
    else:
        known = [] if dry else chrome_hwnds()
        for win in cfg["chrome_windows"]:
            open_chrome(cfg, win, dry)
            if win.get("position"):
                hwnd = move_new_chrome(win, known, dry)
                if hwnd:
                    known.append(hwnd)
            time.sleep(0.5)
        for win in cfg["claude_windows"]:
            open_claude(cfg, win, dry)
            time.sleep(1.0)
    if not dry:
        time.sleep(1.5)  # デタッチが終わる前に終了すると起動が巻き添えになることがある

if __name__ == "__main__":
    main()

Chrome の起動は PowerShell の Start-Process 経由です。呼び出し元のコンソールが閉じても Chrome が巻き添えにならないように、Windows 側で完全に切り離しています。Chrome の窓を開いたあと move-chrome-window.ps1 で位置を確定し、その窓のハンドルを控えてから次の窓を開くので、窓のタイトル文字列には依存しません。Claude Code は Windows Terminal の新しい窓 (wt.exe -w new) で wsl.exebash -licclaude -r と起動します。

move-chrome-window.ps1

# 新しい Chrome の窓を待って、指定した位置へ動かす
#   -ListOnly                      現在の Chrome トップレベル窓の hwnd 一覧
#   -X -Y -W -H [-Maximized] -Exclude "h1,h2"   Exclude にない新しい窓を待ち、移動して hwnd を出力
param(
  [switch]$ListOnly,
  [int]$X, [int]$Y, [int]$W = 1600, [int]$H = 900,
  [switch]$Maximized,
  [string]$Exclude = '',
  [int]$TimeoutSec = 20
)
$ErrorActionPreference = 'Stop'
Add-Type @"
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
public class ChromeWin {
  [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc cb, IntPtr lp);
  delegate bool EnumProc(IntPtr h, IntPtr lp);
  [DllImport("user32.dll")] static extern int GetClassName(IntPtr h, StringBuilder sb, int max);
  [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h);
  [DllImport("user32.dll")] static extern int GetWindowTextLength(IntPtr h);
  [DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr h, int x, int y, int w, int hh, bool repaint);
  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
  public static List<long> List() {
    var found = new List<long>();
    EnumWindows((h, lp) => {
      if (!IsWindowVisible(h)) return true;
      var sb = new StringBuilder(64);
      GetClassName(h, sb, 64);
      if (sb.ToString() == "Chrome_WidgetWin_1" && GetWindowTextLength(h) > 0) found.Add(h.ToInt64());
      return true;
    }, IntPtr.Zero);
    return found;
  }
}
"@

if ($ListOnly) {
  [ChromeWin]::List() | ForEach-Object { Write-Output $_ }
  exit 0
}

[int64[]]$excludeList = @($Exclude -split ',' | Where-Object { $_.Trim() -match '^\d+$' } | ForEach-Object { [int64]$_.Trim() })

$deadline = (Get-Date).AddSeconds($TimeoutSec)
$target = [int64]0
while ((Get-Date) -lt $deadline) {
  foreach ($h in [ChromeWin]::List()) {
    if ($excludeList -notcontains [int64]$h) { $target = [int64]$h; break }
  }
  if ($target -ne 0) { break }
  Start-Sleep -Milliseconds 300
}
if ($target -eq 0) { Write-Error "no new chrome window within ${TimeoutSec}s"; exit 1 }

$hwnd = [IntPtr]$target
[ChromeWin]::ShowWindow($hwnd, 9) | Out-Null                       # SW_RESTORE
[ChromeWin]::MoveWindow($hwnd, $X, $Y, $W, $H, $true) | Out-Null
if ($Maximized) { [ChromeWin]::ShowWindow($hwnd, 3) | Out-Null }   # SW_MAXIMIZE (移動先の画面で最大化)
Write-Output $target

EnumWindows でクラス名 Chrome_WidgetWin_1 の可視ウィンドウを列挙し、既知のハンドルにないものを新しい窓とみなして、復元 → 移動 → 最大化の順に動かします。ファイルは UTF-8 BOM 付きで保存します (理由は注意点)。

make-shortcuts.ps1

# .lnk を生成する。ターゲットは署名済みの wsl.exe / wt.exe / explorer.exe
$ErrorActionPreference = 'Stop'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$wsl = "$env:SystemRoot\System32\wsl.exe"
$explorer = "$env:SystemRoot\explorer.exe"
$wt = "$env:LOCALAPPDATA\Microsoft\WindowsApps\wt.exe"
$defs = @(
  @{ Name = 'morning-routine';      Target = $wsl; Args = '-d Ubuntu -- bash -lic "python3 ~/tools/automation/morning-routine/launch.py morning"' },
  # 1 窓だけ開くものは wt.exe を直接呼ぶ (launch.py を経由するとシェルの初期化が 2 回になる)
  @{ Name = 'claude-resume-window'; Target = $wt; Args = 'wsl.exe -d Ubuntu --cd /home/<user> -- bash -lic "claude -r"' },
  @{ Name = 'claude-new-window';    Target = $wt; Args = 'wsl.exe -d Ubuntu --cd /home/<user> -- bash -lic "claude"' },
  @{ Name = 'wsl-terminal';         Target = $wt; Args = 'wsl.exe -d Ubuntu --cd /home/<user>' },
  @{ Name = 'wsl-home';             Target = $explorer; Args = '\\wsl.localhost\Ubuntu\home\<user>'; Icon = "$env:SystemRoot\System32\imageres.dll,3" }
)
$shell = New-Object -ComObject WScript.Shell
foreach ($d in $defs) {
  $tmp = Join-Path $env:TEMP ($d.Name + '.lnk')   # COM の Save は UNC に直接書けないことがあるので TEMP 経由
  $sc = $shell.CreateShortcut($tmp)
  $sc.TargetPath = $d.Target
  $sc.Arguments = $d.Args
  $sc.WorkingDirectory = $env:USERPROFILE
  if ($d.Icon) { $sc.IconLocation = $d.Icon } else { $sc.IconLocation = "$env:SystemRoot\System32\wsl.exe,0" }
  $sc.Save()
  Move-Item -Force $tmp (Join-Path $here ($d.Name + '.lnk'))
  Write-Host ("created: " + $d.Name + ".lnk")
}

WSL から powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\wsl.localhost\Ubuntu\home\<user>\tools\automation\morning-routine\make-shortcuts.ps1" で実行すると、同じディレクトリに .lnk ができます。これを Windows 側にコピーしてピン留めします。

注意点

作る途中で分かった Windows 側の挙動です。個々の環境に依存するものが多いので、同じ構成を組むときの参考程度に。

Claude Code をシェルから起動するときは bash -licclaude は nvm 配下にあり、nvm の初期化は .bashrc に書かれています。.bashrc は非対話シェルでは冒頭で return するので、bash -lc '...' では claude: command not found になります。-i を付けて対話モードにすると、手で打つときと同じ経路になります。

起動のたびに「このフォルダを信頼しますか」が出るとき~/.claude.jsonprojects 配下、そのディレクトリの hasTrustDialogAcceptedfalse のままになっていることがあります (上流の不具合報告: anthropics/claude-code#58013)。手で true にすると出なくなり、この環境では再起動をまたいでも戻っていません。

Smart App Control と Mark of the Web。WSL で作った .bat や .lnk をエクスプローラーで \\wsl.localhost\... から Windows 側へコピーすると、Zone.Identifier (ZoneId=3、いわゆる Mark of the Web) が付き、Smart App Control が実行を止めます (スマート アプリ コントロールの公式 FAQ (Microsoft Support))。付いたものは、ファイルのプロパティ→全般タブ最下部の「許可する」か、PowerShell の Unblock-File (公式ドキュメント — Microsoft Learn) で外せます。WSL 側から cp/mnt/c/Users/<user>/... へ書けば最初から付きません。.lnk のターゲットを署名済みの wsl.exewt.exe にしているのも、スクリプトファイルそのものを Windows 側に置かないためです。

ピン留めは .lnk のコピー。タスクバーへのピン留めは元の .lnk を参照せず、%APPDATA%\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\ にコピーを作ります。.lnk を作り直したら、ピンを外して付け直します。config.json を変えるだけなら .lnk は変わらないので、この手間は要りません。

Start-Process の引数はスペース入りを引用符で-ArgumentList に配列を渡すと、要素はスペース区切りで連結され、引用符は補われません (Start-Process の公式ドキュメント (Microsoft Learn))。--profile-directory=Profile 1 はそのままだと Profile1 に分かれ、Chrome は 1 を URL として http://0.0.0.1/ を開きます。launch.py で各要素を "..." で包んでいるのはこのためです。

Chrome の 2 窓目の位置は、開いたあとに動かす。Chromium のソースコード (window_sizer.cc) では、新しい窓の位置は「明示的な指定 → 直前のアクティブな窓の隣 → 保存された位置 → 既定」の順で決まります。プロファイルが違っても Chrome は 1 つのプロセスなので、1 窓目が開いている状態で開いた 2 窓目は、起動スイッチや Preferences の値に関わらず 1 窓目の隣に出ます (2 回目の chrome.exe 起動に付けたスイッチが既存プロセスで無視される件は chromium-discuss の報告 と同じ症状)。move-chrome-window.ps1 で開いたあとに動かしているのはこのためです。アカウントごとに --user-data-dir を分けて別プロセスにする方法もありますが、ログイン状態や拡張機能が 2 系統に分かれるので採っていません。

PowerShell スクリプト (.ps1) は UTF-8 BOM 付きで保存。Windows PowerShell 5.1 は BOM のない .ps1 を既定のコードページ (日本語環境では Shift-JIS) として読むため、UTF-8 の日本語コメントが化けて構文エラーになります (文字コードの公式ドキュメント: about_Character_Encoding (Microsoft Learn))。

一度に開く Claude Code の窓の数。当初は用途別のセッションを 5 つ同時に復帰させていましたが、当時は MCP サーバーの子プロセスがセッションごとに起動する構成だったため、まとめて開くと MCP の起動が重なり、Claude Code の立ち上がりに時間がかかっていました。それをきっかけに、自動で開くのは履歴一覧の 1 窓だけにしています。MCP の起動のしかたは、その後 PC で 1 つの常駐ハブに共有する構成へ変えたので (Claude Code の resume 起動を 7秒→2秒に)、今なら複数窓を同時に開いても問題ないかもしれません。1 窓+履歴から選ぶ運用でスムーズに作業できているので、こちらは戻していません。

まとめ

  • 起動後の環境づくりは、ピン留めした .lnk 1 つ。左右に Chrome 2 アカウント、中央に Claude Code の履歴一覧
  • ロジックと設定は WSL 側で git 管理、Windows 側は署名済み exe を呼ぶ .lnk だけ。変更は config.json の編集で済む
  • Chrome の 2 窓目の位置だけは起動後に動かす必要があり、そこは PowerShell で補っている

「ブラウザだけ」「履歴一覧だけ」のような小さい範囲から始めて、日中用のショートカットを後から足していく形が、この環境では使い続けやすい形でした。同じような起動後の手作業がある方の参考になればうれしいです。

参考リンク

一次情報 (公式ドキュメント・ソース・上流の報告):