0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude CodeのRemote Controlを任意のフォルダで開始する

0
Posted at

結論

Claude CodeのRemote Controlを任意のフォルダで開きたい場合、サーバモード(claude remote-control)は使わない。
tmuxのセッションを作り、その中で対話セッションを起こす。

tmux new-session -d -s myproject -c ~/workspaces/myproject \
  claude --remote-control myproject

これだけである。tmuxとClaude Codeが入っていて、このコマンドを叩ける場所であれば成立する。
Dockerもコンテナも必須ではない。

ただし、Remote Controlが利用できるアカウントであること(Team / Enterpriseでは管理者による有効化)が前提になる。
あわせて、対象フォルダで一度claudeを対話的に起動し、ログインとフォルダの信頼承認を済ませておく必要がある。
未処理のまま上のコマンドを叩くと、tmuxの中でダイアログが出たまま止まる(3章5節)。

  • フォルダは-cで指定するので任意に選べる
  • セッションを増やしたければ、名前とフォルダを変えて同じコマンドを叩くだけ
  • 起動したセッションはclaude.aiやスマホのClaudeアプリの一覧にmyprojectとして現れる

起こしたあとの操作もtmuxで足りる。

tmux ls                                             # 一覧
tmux send-keys -t =myproject -l -- "テストを流して"   # 入力を送る
tmux send-keys -t =myproject Enter
tmux capture-pane -p -t =myproject -S -40           # 画面を読む
tmux kill-session -t =myproject                     # 終了

本記事の構成

独立して読めるように書いてある。必要な範囲だけ拾ってほしい。

  • 1〜3章 … tmux方式の仕組み
    なぜサーバモードではなくtmuxなのか、という理由と、その実現方法。
    サーバモードで詰まった2点(1サーバ = 1ディレクトリ / GitHubのリポジトリでセッション生成に失敗した)を--debug-fileで原因を特定するところまで含めて書いた。
    そのうえで、セッションの起こし方・外からの操作・状態の取り方・事前に必要な条件を扱う。
    tmuxとClaude Codeがあれば、ここまでの内容で再現できる。

  • 4章 … 自作CLIによる自動化
    上のtmuxコマンドを毎回手で叩くのは現実的ではないので、CLIにまとめた。
    ツールの使い方ではなく、設計上の判断を書いている。
    状態をどこから取るか、待ち合わせをどう作るか、終了コードで何を返すか、ダイアログにテキストが届かない問題をどう扱うか、といった内容で自分でスクリプトを書く場合にも、そのまま同じ判断が必要になる。

  • 5章 … Docker利用者向けの補足
    コンテナ上で動かす場合に追加で必要になる話。
    永続化すべきファイル、bind mountの罠、MCP登録がイメージに焼けない理由など。
    コンテナ上でClaude Codeを動かしている場合に参照してほしい。

対象読者

  • Claude CodeのRemote Controlを複数プロジェクトで並行して使いたい人
  • Remote Controlが起動しない・セッションが立たない、で詰まっている人
  • コンテナ上でClaude Codeを動かしている人

検証環境

バージョン
Claude Code 2.1.251(ネイティブインストーラ版)
tmux 3.3a
Docker 29.7.2
Docker Compose v5.5.0
@infodb/booth 0.4.0

Claude Codeのネイティブ版はバックグラウンドで自動更新されるため、バージョンは固定していない。
上記は記事執筆時点(2026-08)に手元で確認した値である。

本記事は、--spawnのモード、claude agents --jsonの出力形、~/.claude.jsonに置かれる項目など、公開ドキュメントに載っていない内部の挙動に依拠している箇所がある。
これらはバージョンによって変わりうるため、記述どおりにならない場合はまず手元のバージョンを確認してほしい。

1. 前提: Remote Controlには2つの起動方法がある

名前が似ているが別物である。ここを分けておかないと以降の話が読めない。

起動方法 実体 セッションの数
claude remote-control (サブコマンド) サーバモード。常駐してclaude.aiからのセッション要求を待ち受け、要求のたびにセッションを生成(spawn)する サーバが生成する
claude --remote-control <名前> (フラグ) Remote Controlを有効にした対話セッション。普通にclaudeを起動するのと同じで、それがclaude.aiから見えるようになるだけ そのプロセスで1つ

サーバモードは--spawnsame-dir / worktree / session)や--capacityといったオプションを持つ。
same-dirworktreeでは1つのサーバが複数セッションを扱い、sessionは単一セッション専用である。

一方フラグ版は、ただの対話セッションである。/exitで終わるのも、ターミナルで普段使っているものと変わらない。

結論で挙げたコマンドはフラグ版を使っている。次章で、サーバモードを選ばなかった理由を書く。

2. サーバモードで詰まった2点

2-1. サーバは1つにつき1ディレクトリしか扱えない

--spawnの3モードは、いずれも起動したディレクトリが基準である。

モード 挙動
same-dir 起動したディレクトリでセッションを作る
worktree git worktreeを.claude/worktrees/<名前>/へ切り出してそこで作る
session 起動したディレクトリで独立したセッションを作る

その下のサブディレクトリを列挙してプロジェクトを選ばせる、という動きはしない。
つまり親ディレクトリでサーバを1つ起こしても、

~/workspaces
  ├── project-a     ← ここでセッションを開きたい
  ├── project-b     ← ここでも開きたい
  └── project-c

project-aproject-bを選ぶことはできず、全部~/workspaces直下で動く。

公式ドキュメントも「start Remote Control from a project directory」としており、親ディレクトリでの起動はそもそも想定されていない。
複数プロジェクトを扱うなら、プロジェクトの数だけサーバを常駐させることになる。

2-2. GitHubのリポジトリでセッション生成に失敗した

観測した事実と、その原因

サーバモードをGitHubのリポジトリで起動したところ、サーバ自体は正常に立ち上がり、TUIにはReadyと表示された。
しかしセッションは使えず、画面にはエラーも出ない。

デバッグログを取ったところ、セッション生成がHTTP 400で失敗していた。

[bridge] Session creation failed with status 400: GitHub repository access check failed.
         Either you don't have access to this repository, it isn't included in the Claude
         GitHub App's repository selection, or your GitHub connection has expired. ...

サーバはセッション生成の要求に、ローカルのgit remoteのURLをgit_repo_urlとして載せてapi.anthropic.comへ送る。
それがGitHubのURLだったため、サーバ側でClaude GitHub Appのリポジトリ選択に対する確認が走り、そこで通らなかった、という流れだった。
このアカウントはGitHubとの連携を行っていない状態である。

ローカルの実行環境をいくら調べても原因にたどり着かないのは、判定がローカルではなくサーバ側で行われているためだった。
また、この400が起きてもTUIの表示はReadyのままである。--debug-fileを付けない限り気づけない。

以下、この結論に至るまでに何を確かめたかを書く。

調査方法

claude remote-controlには--debug-fileがあり、[bridge]系のログがここに出る。TUIに出ない失敗はここで分かる。

claude remote-control --name probe --spawn session --verbose --debug-file ./rc.log

git remoteのホスティング先だけを変えた3つのディレクトリで起動し、ログを突き合わせた。
いずれも信頼承認済みで、同一マシン・同一アカウント・同一バージョンでの比較である。

対象 git remote
GitHubのリポジトリ https://github.com/<owner>/<repo>.git
セルフホストのGitLabのリポジトリ https://<自社のGitLab>/<group>/<repo>.git
gitリポジトリではない なし

以下のログは、リポジトリ名・ホスト名・各種IDを伏せ字にしてある。

GitHubのリポジトリでは、git_repo_urlが送られたうえで400が返っている。

[bridge:init] dir=/workspaces/<repo> branch=main gitRepoUrl=https://github.com/<owner>/<repo>.git machine=denv
[bridge:api] >>> {"machine_name":"denv","directory":"/workspaces/<repo>","branch":"main",
                  "git_repo_url":"https://github.com/<owner>/<repo>.git","max_sessions":1, ...}
[bridge] Session creation failed with status 400: GitHub repository access check failed.
         Either you don't have access to this repository, it isn't included in the Claude
         GitHub App's repository selection, or your GitHub connection has expired. ...

セルフホストのGitLabのリポジトリでは、同じ経路を通ってセッションが生成された。

[bridge:api] >>> {..., "git_repo_url":"https://<自社のGitLab>/<group>/<repo>.git", ...}
[bridge:init] Created initial session session_XXXXXXXX

gitリポジトリでないディレクトリでも生成された。gitRepoUrlnullになる。

[bridge:init] dir=/workspaces branch=HEAD gitRepoUrl=null machine=denv
[bridge:init] Registered, server environmentId=env_XXXXXXXX

ログ上、失敗理由として明示されているのは、送信されたGitHub URLに対するリポジトリアクセスチェックである。
3つのうちGitHubのものだけが失敗し、残る2つは同じ経路でセッションが生成された。

確かめていないこと

観測したのは上記3つの条件だけであり、そこから言えることには限りがある。

  • GitHubとのアカウント連携を済ませた場合にこの400が解消するかどうかは確認していない。
    エラーメッセージは連携済みを前提に「リポジトリ選択に含まれていない」「連携が切れている」を挙げているため、連携すれば通る可能性はある
  • セルフホストのGitLabで通ったのは、そのホストに対する連携の仕組みが無いためと考えられるが、確認していない。
    gitlab.comのようなホスティングサービスや、連携の対象になりうる他のサービスで同じ結果になるとは限らない
  • 未連携のGitHubリポジトリを一般に拒否する仕様なのか、それとも特定の条件で起きるものなのかも切り分けていない

エラーメッセージ自体は対処方法を示しており、GitHub → Org settings → GitHub Apps → Claude → Repository access で対象リポジトリを追加する、という導線になっている。
将来的に仕様が変わることも含め、現在どうなっているかは手元で--debug-fileを取って確認してほしい。

それでもtmuxを選ぶ理由

claude --remote-control <名前>は、ローカルで起動済みのセッションを登録する形であり、サーバモードのようにセッション生成を要求しない。観測した範囲では、git remoteのURLに影響を受けていない。
実際この記事は、GitHubのリポジトリ上で動かしたclaude --remote-controlのセッションで書いている。

そもそもClaude GitHub Appに業務リポジトリへのアクセスを与えたくない、あるいは与える権限がない場合、連携という選択肢自体が取れない。その場合サーバモードは候補から外れるが、tmux方式にはこの依存がない。

2-3. 共通する原因は「セッション生成をサーバに任せていること」

2つを並べると、原因はどちらもサーバモードの構造にある。

  • ディレクトリが固定されるのは、サーバが自分の起動ディレクトリを基準にセッションを作るから
  • セッションが生成されないのは、生成要求がサーバ側を経由し、そこでGitHub連携チェックに落ちるから

したがって、サーバにセッション生成を任せなければ両方とも回避できる。
そして1章のとおり、claude --remote-control <名前>はサーバモードを経由しない、ただの対話セッションである。

残る問題は「対話セッションなので起動したターミナルを占有する」という一点だけになる。
これはtmuxで解ける。

3. tmuxでセッションを起こす

3-1. 構造

セッションを起こす役目を、サーバモードのspawnからtmuxへ移す。

[サーバモード]
  claude remote-control (常駐プロセス)
    └ spawn → セッション        ← ディレクトリ固定・spawnで失敗

[tmux]
  tmux サーバ
    ├ session "project-a" → claude --remote-control project-a  (cwd: ~/workspaces/project-a)
    ├ session "project-b" → claude --remote-control project-b  (cwd: ~/workspaces/project-b)
    └ session "sandbox"   → claude --remote-control sandbox    (cwd: ~/workspaces/sandbox)

new-session -dでデタッチ状態で作るため、ターミナルは占有されない。
-cで作業ディレクトリを、-sでセッション名を指定する。

tmux new-session -d -s project-a -c ~/workspaces/project-a \
  claude --remote-control project-a

セッションを増やすのはコマンドをもう一度叩くだけで、常駐プロセスの構成をいじる必要はない。
落とすのもkill-sessionでセッション単位に行える。

3-2. 名前の設計

booth(後述のCLI)では、セッション名を~/workspaces直下のフォルダ名と決めている。
この1つの名前が3つを同時に決める。

決まるもの
tmuxのセッション名 myproject
作業ディレクトリ ~/workspaces/myproject
Remote Controlのセッション名 myprojectclaude --remote-control myproject

フォルダ名はワークスペース内で一意なので、セッション名の衝突と作業ディレクトリの指定が同時に片付く。
名前を2つ考える必要がない。

3-3. 外から操作する

tmuxのコマンドがそのまま操作系になる。

tmux ls                                            # 一覧
tmux has-session -t =myproject                     # 存在確認(無ければ exit 1)
tmux send-keys -t =myproject: -l -- "テストを流して"  # 入力を送る
tmux send-keys -t =myproject: Enter
tmux capture-pane -p -t =myproject: -S -40         # 画面を読む
tmux attach -t =myproject                          # 座る
tmux kill-session -t =myproject                    # 終了

実装上、ここには2つ注意点がある。

-l(literal)で送る

send-keysは引数をキー名として解釈する。EnterUpという文字列をそのまま送りたい場面と衝突するため、テキストを送るときは-lを付けてリテラル扱いにする。

Enterは間を空けて別に送る

テキストとEnterを一度に送ると、TUIが入力を取り込む前にEnterが届いて取りこぼすことがある。
テキストを送ったあと少し待ってからEnterを送る。boothは既定で150ms空けている。

ダイアログにテキストは届かない

許可プロンプトや信頼ダイアログが開いている間は入力欄が無いため、send-keys -lでテキストを送っても何も起きない。
選択肢の操作が要るので、キー名で送る。

tmux send-keys -t =myproject: Escape        # 閉じる
tmux send-keys -t =myproject: Down Enter    # 選択して確定

3-4. 状態は画面ではなくclaude agents --jsonから取る

自動化するなら、そのセッションが入力を受け付けられる状態かどうかを知る必要がある。
capture-paneで画面を取って文字列を解析する方法は採らなかった。TUIの見た目に依存するため、表示が変わった瞬間に壊れる。

Claude Codeには、稼働中のセッションを列挙するコマンドがある。

claude agents --json
[
  {
    "pid": 1234,
    "cwd": "/home/user/workspaces/myproject",
    "sessionId": "...",
    "startedAt": 1756512000000,
    "status": "idle"
  }
]
  • cwdで突き合わせれば、どのセッションの記録かが確実に分かる
  • statusidle / busy / waiting / shellwaitingのときはwaitingForに理由が入る
  • 値は将来増えうるので、文字列のまま扱って未知の値で落ちないようにしておくとよい

~/.claude/sessions/を直接読む方法もあるが、後述のとおりこのディレクトリを複数コンテナで共有していると他コンテナの記録まで混ざる。claude agents --jsonは実行したホスト(コンテナ)のぶんだけを返すので、こちらが確実である。

3-5. 事前に満たしておく条件

セッションを非対話で起こす前に、満たしておく必要があるものが3つある。

条件 単位
Remote Controlが利用できるアカウントであること アカウント / 組織
ログインが済んでいること アカウント
ワークスペースの信頼が承認されていること ディレクトリ

下2つが未処理だと、tmuxの中でダイアログが出たまま止まる。

アカウント側の条件

  • Claude Codeが利用できるプランのアカウントであること。無料プランでは利用できない
  • Team / Enterpriseの場合、管理者がRemote Controlを有効化していること

後者は利用者側では変更できないため、動かない場合は管理者に確認する必要がある。

利用可能なプランやRemote Control自体の仕様は変わりうるので、最新の条件はClaude Codeの公式ドキュメントを確認してほしい。本記事では扱わない。

初回の対話操作

残る2つは、対象フォルダで一度claudeを対話的に起動して済ませる。

cd ~/workspaces/myproject && claude
  1. ログイン(/login)… アカウント単位で一度だけでよい
  2. ワークスペースの信頼の承認 … ディレクトリごとに必要

2つ目が見落としやすい。信頼はディレクトリ単位で記録されるため、

  • 親ディレクトリの承認は子に引き継がれない
  • 同じリポジトリの別worktreeにも引き継がれない

つまりプロジェクトを追加するたびに、そのディレクトリで一度承認する必要がある。

なお信頼ダイアログの既定はNo, exitである。何も考えずEnterを押すとセッションが終了する。

3-6. 設定してはいけない環境変数

Remote Controlは機能フラグの評価結果に依存している。以下を設定するとその評価が無効化され、利用できなくなる。

  • DISABLE_TELEMETRY
  • DO_NOT_TRACK
  • CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
  • DISABLE_GROWTHBOOK

プライバシー配慮でこの手の変数をまとめて設定している環境は珍しくない。動かないときは真っ先に疑うとよい。

また、ANTHROPIC_BASE_URLapi.anthropic.com以外へ向けている場合も利用できない。
Amazon Bedrock / Google CloudのAgent Platform / Microsoft Foundry経由でも利用できない。

3-7. ポートは開かない

Remote Controlはclaude.aiへの外向き接続で成立する。待ち受けるポートは存在しないため、ファイアウォールの穴あけもポートフォワードも不要である。

4. 操作をCLIにまとめる(booth)

3章までで仕組みは完成しているが、tmux new-session -d -s ... -c ... claude --remote-control ...を毎回打つのは現実的ではない。また、送ったあとの待ち合わせや、止まっている理由の判定までは素のtmuxでは面倒を見てくれない。

そこで、この操作をまとめたCLIを@infodb/boothとして用意した。
以下は単なるラッパーではない部分、つまり設計上の判断である。

4-1. 状態を持たない

boothはローカルにPIDファイルもロックファイルも置かない。
真実はtmuxサーバだけにあり、has-sessionlist-sessionsが答える。

このため、boothを実行するマシンを変えても、途中で再インストールしても、セッションの見え方は変わらない。
インストール不要でpnpxから実行できるのは、この設計と相性がよい。

pnpx @infodb/booth ls
# TARGET  SERVICE  NAME       STATE   UPTIME  ATTACHED
# denv    denv     project-a  idle    12m     no
# denv    denv     project-b  busy    3m      no
# denv    denv     sandbox    idle    1m      no

4-2. 4つのフェーズと、止まっている理由の診断

セッションの状態を4つのフェーズに分けている。

フェーズ 意味
absent tmuxセッションが無い
starting tmuxはあるが、claude agentsにまだ現れない。起動途中か、何かで止まっている
ready 起動完了。statusが付く
unknown 状態を報告できない(claude以外を起こしている場合など)

厄介なのはstartingである。単に起動が遅いだけなのか止まっているのか、外からは同じに見える。
そこでboothは、止まっているなら何で止まっているのかを~/.claude.jsonから判定する。

starting のまま進まない
  ├ oauthAccount が無く .credentials.json も無い       → ログイン画面で止まっている
  └ projects[<workdir>].hasTrustDialogAccepted != true → 信頼ダイアログで止まっている

3章5節で挙げた初回の対話操作が、そのままstartingで止まる2大要因になっている。
判定できるので、理由を名指しで報告する。

◌ myproject: starting · blocked on the trust dialog

4-3. 終了コードで分岐できるようにする

全コマンドが、セッションのどの状態で終わったかを終了コードで返す。

コード 状態
0 idle — プロンプト待ち。入力を受け付ける
10 busy — ターンを処理中
11 waiting — 人が答えるべきもので停止(ダイアログ・許可プロンプト)
12 starting — まだ使えない。ログインか信頼ダイアログで停止
13 absent — 開いていない
1 設定またはdockerのエラー

これにより、シェルスクリプトからも、別のAIエージェントからも機械的に分岐できる。

booth send myproject "テストを流して"
case $? in
  0)  echo "完了" ;;
  11) echo "判断待ち。人間に回す" ;;
  10) echo "まだ処理中" ;;
esac

booth status <name> --jsonも用意してあり、監視プロセス向けにはこちらを使う。

4-4. 投げっぱなしにしない

待つ系のコマンドは、人の判断が必要になった時点で止まる。booth sendの内部はこうなっている。

テキストを送る
  ↓
[settle待ち] 5秒だけ「idleから離れるか」を見る
  ↓  ← 離れなければ「入力が効いていない疑い」として警告する
[settled待ち] idle か waiting になるまでポーリング
  ↓
  ├ idle    → ターン完了 (exit 0)
  ├ waiting → 判断待ち。ペインの内容を表示して停止 (exit 11)
  └ timeout → まだ busy。ペインを表示して停止 (exit 10)

送った直後はまだidleのままなので、いきなり完了と判定しないよう猶予(settle)を置いている。
猶予の間に一度もidleから離れなければ、入力が届いていない可能性が高い。boothはそれを警告として出す。黙って成功扱いにはしない。

3章3節で書いたとおりダイアログにテキストは届かないため、waiting中のsendは既定で拒否する(--forceで強行は可能)。
代わりにkeyコマンドでtmuxのキー名を送る。

booth key myproject Escape
booth key myproject Down Enter

closeも同様に、idleになるのを待ってから/exitを送る。処理中に送った/exitは取りこぼされるためである。
既定10秒待って消えなければkill-sessionする。

4-5. 止まったら次に叩くコマンドを出す

止まったとき、その状態に対応する次の一手をCLI自身が出力する。

◌ myproject: starting · blocked on the trust dialog
  │ Do you trust the files in this folder?
  │ ❯ No, exit
  │   Yes, proceed
  Next:
    booth attach myproject   approve the folder yourself — the dialog's default is
                             'No, exit', so Enter would end the session

これは知識の置き場所の問題である。
「信頼ダイアログの既定はNo, exit」といった罠は、ドキュメントに書いても古くなるし読まれない。
観測した状態から生成する形でツール側に持たせておけば、人が使ってもAIエージェントが使っても同じ導線に乗る。

同梱しているClaude Code用のskill(SKILL.md)も、この方針に合わせてライフサイクルと終了コードの取り決めだけに絞ってある。

5. Dockerコンテナ上で動かす場合

ここまではtmuxとClaude Codeが入っていれば成立する話で、Dockerは出てこない。
ここからは、これをコンテナ上でやる場合に追加で必要になることを書く。
コンテナを使っていない場合はこの章を飛ばしてよい。

筆者は開発環境をコンテナに寄せている(devenviron)ため、Claude Codeもその中で動かしている。
以下はその過程で踏んだものである。ベースイメージのOSはDebian GNU/Linux 12 (bookworm)で、パスの記述はこれに従っている。

5-1. transportはdocker compose exec

3章のtmuxコマンドを、コンテナの外から叩けるようにするだけでよい。

docker compose exec -T denv tmux new-session -d -s project-a -c /workspaces/project-a \
  claude --remote-control project-a

boothもこの形で動いている。-TはTTYを割り当てない指定で、出力を受け取るコマンドに使う。
attachのようにTTYが要るものだけ-Tを外す。

5-2. コンテナは待機するだけにする

セッションはtmuxが持つので、コンテナ自身は何もしない。

FROM <ベースイメージ>

RUN curl -fsSL https://claude.ai/install.sh | bash

ENV PATH="/root/.local/bin:${PATH}"

WORKDIR /workspaces

CMD ["sleep", "infinity"]
  • tmuxが入っていることが追加要件になる(ベースイメージに無ければapt install tmux
  • ENV PATHは必須。インストーラはclaudeを/root/.local/binへ置き、PATHは~/.bashrcへ追記する。docker compose execは非対話実行でこれを読まないため、Dockerfile側で通しておかないとexec: claude: executable file not found in $PATHになる
  • インストーラで入るのは自動更新されるバイナリなので、バージョンは固定していない
services:
  denv:
    image: my-claude-image:local
    build:
      context: .
      dockerfile: Dockerfile
    stdin_open: true
    tty: true
    hostname: denv
    volumes:
      - ${WORKSPACES_ROOT:-/root/workspaces}:/workspaces
      - ${WORKSPACES_ROOT:-/root/workspaces}/.denv/.claude:/root/.claude
      - ${WORKSPACES_ROOT:-/root/workspaces}/.denv/.claude.json:/root/.claude.json
    environment:
      - TZ=${TZ:-Asia/Tokyo}

commandworking_dirも書かない。待機はイメージのCMDが担い、作業ディレクトリはセッションごとにtmuxが指定する。
portsも不要である(3章7節)。

このファイルは、プロジェクトが何個に増えても編集しない。

5-3. working_dirを切り替える案は成立しない

サーバモードのままコンテナで複数プロジェクトを扱おうとすると、まずこれを考える。結論として成立しない。

docker composeはコンテナを「composeプロジェクト名 + サービス名」で識別する。
同じサービスに対する2回目のupは、新しいコンテナの追加ではなく既存コンテナの作り直しになる。

WORKDIR=/workspaces/project-a docker compose up -d   # project-a のセッション
WORKDIR=/workspaces/project-b docker compose up -d   # ← project-a が落ちて置き換わるだけ

複数を同時に動かすには、プロジェクトごとにサービスを定義して並べるしかない。

services:
  project-a:
    working_dir: /workspaces/project-a
    command: ["claude", "remote-control", "--name", "project-a", "--spawn", "session"]
  project-b:
    working_dir: /workspaces/project-b
    command: ["claude", "remote-control", "--name", "project-b", "--spawn", "session"]
  # プロジェクトが増えるたびにここへ足す…

プロジェクトを1つ増やすたびにcomposeを編集してupし直すことになり、コンテナもその数だけ常駐する。
tmux方式にすると、この問題自体が消える。

5-4. 永続化するファイルは2箇所ある

コンテナを作り直しても認証を保つには永続化が要る。ここが最大のハマりどころだった。

Claude Codeは認証まわりの情報を2箇所に分けて保存する。

保存先 内容
~/.claude/.credentials.json 認証トークン
~/.claude.json 組織情報(oauthAccountorganizationUuid / organizationName / organizationRole / organizationType)と、Remote Controlの可否判定に使う機能フラグのキャッシュ(cachedGrowthBookFeatures / cachedStatsigGates

当初~/.claudeだけを名前付きボリュームで永続化していたところ、トークンだけが残って組織情報が消える状態になり、次のエラーでRemote Controlが起動できなくなった。

Unable to determine your organization for Remote Control eligibility

ログインは通っているのにRemote Controlだけ起動しない、という分かりにくい壊れ方をする。
~/.claude(ディレクトリ)と~/.claude.json(単一ファイル)の両方を永続化すること。
単一ファイルは名前付きボリュームで扱えないため、bind mountになる。

5-5. bind mountのソースは事前に正しい型で作る

bind mountのソースが存在しないと、Dockerはそれをディレクトリとして作る。
イメージ側の/root/.claude.jsonはファイルなので、そこへディレクトリを被せることになり起動に失敗する。

error mounting ".../.claude.json" to rootfs at "/root/.claude.json":
create mountpoint for /root/.claude.json mount: cannot create subdirectories in
".../merged/root/.claude.json": not a directory

先に実体を作っておけばよい。.claude.jsonは空ファイルだとJSONとして不正になるため{}で初期化する。

# 既定値は compose 側の ${WORKSPACES_ROOT:-/root/workspaces} と揃える
denv="${WORKSPACES_ROOT:-/root/workspaces}/.denv"

# すでにディレクトリとして作られている場合は先に外す
[ -d "$denv/.claude.json" ] && rmdir "$denv/.claude.json"

mkdir -p "$denv/.claude"
[ -s "$denv/.claude.json" ] || echo '{}' > "$denv/.claude.json"

.gitconfig.npmrcのようにイメージ側に実体が無いファイルを同じくbind mountしている場合は注意が要る。
マウント先が存在しないとディレクトリのままでもマウントが通ってしまい、起動時には何も起きず、gitが認証情報を読めないという形で後から表面化する。

5-6. /root/.local/share/claudeは永続化してはいけない

こちらは逆に、永続化すると壊れるパターンである。

  • 名前付きボリュームの初期化はボリュームを作った1回だけで、2回目以降はイメージ側の内容を無視して既存の中身が優先される
  • claudeの実体はversions/<バージョン>にあり、/root/.local/bin/claudeはそこへのシンボリックリンクである
  • そのため、イメージを作り直すとリンク先がボリューム内に存在しなくなり、exec: claude: executable file not found in $PATHで起動できなくなる

自動更新で取得したバイナリは失われるが、イメージ側のバージョンから再度更新されるだけなので実害はない。

5-7. MCPサーバの登録はイメージに焼けない

これも~/.claude.json絡みである。

ビルド時にclaude mcp add --scope userで登録を焼き込む方式を採ったところ、MCPとして認識されなかった。

  • ユーザスコープのMCP登録先は~/.claude.jsonである
  • そのファイルは5章4節の理由でホスト側からbind mountしている
  • 結果、イメージに焼いた登録がbind mountで丸ごと覆い隠されていた

登録先と永続化先が同一ファイルなので、この2つは原理的に両立しない。
登録はログインと同じく初回のみの手作業にするのが素直である。
~/.claude.jsonをホスト側へ永続化した以上、一度登録すればコンテナを作り直しても残る。

docker compose run --rm denv \
  claude mcp add --scope user <name> -- npx -y <package>

--scope userで登録すれば、.claude.jsonを共有している全コンテナ・全プロジェクトで有効になる。

ただし永続化されるのは登録内容であってツール本体ではない。
コンテナ内でuv tool installnpm install -gしても/root/.localは永続化していないので作り直すと消える。
npx -y <pkg>uvx <pkg>のように起動のたびに解決される形なら問題ない。常設したいものはDockerfileへ追加する。

5-8. hostnameを指定しないとデバイス名が16進の羅列になる

claude.aiやスマホアプリに表示されるデバイス名は、コンテナのhostnameである。
Dockerは既定でコンテナIDをhostnameにするため、指定しないと判別できない。

composeにはサービス名を参照する変数がない(${...}は環境変数と.envしか読まない)ので、素直に書く。

表示名は2種類あるので整理しておく。

表示 決まり方 指定方法
デバイス名 コンテナのhostname composeのhostname:
セッション名 claude --remote-control <名前>に渡した名前 セッション起動時

1つのコンテナに複数セッションを同居させると、デバイス名は全セッションで共通になる。区別はセッション名で行う。
デバイス名でも分けたい場合は、プロジェクトごとにサービスを立て、booth側でservice = "{name}"と書く。

[targets.perproject]
compose_file = "/root/workspaces/.denv/docker-compose.yaml"
service = "{name}"     # booth 名と同じ名前のサービスへ exec する

この場合booth lsは、起動中のサービスをcomposeに列挙させて順に問い合わせる(tmuxが無いサービスは読み飛ばされる)。
コンテナが増えるぶんメモリを食うので、必要なものだけにするとよい。

5-9. 初回の対話操作はコンテナ内で行う

3章5節の手順は、コンテナの場合こうなる。信頼はディレクトリ単位なので-wで対象を指定する。

docker compose run --rm -w /workspaces/myproject denv claude

5-10. コンテナでの利用手順まとめ

# コンテナを起こす(1回だけ。プロジェクトが増えても触らない)
docker compose up -d

# booth の設定を書く(1回だけ)
pnpx @infodb/booth init
mkdir -p ~/.config/booth && mv booth.toml ~/.config/booth/
~/.config/booth/booth.toml
[defaults]
target = "denv"
workspaces_root = "/workspaces"
command = "claude --remote-control {name}"

[targets.denv]
compose_file = "/root/workspaces/.denv/docker-compose.yaml"
service = "denv"
pnpx @infodb/booth open project-a
pnpx @infodb/booth open project-b
pnpx @infodb/booth ls

claude.aiまたはスマホのClaudeアプリのセッション一覧に並ぶので、そこから開いて指示を出せる。
ターミナルからも操作できる。

pnpx @infodb/booth send project-a "テストを流して結果を教えて"
pnpx @infodb/booth logs project-a -n 40
pnpx @infodb/booth close project-a

1プロジェクト内で並行作業したい場合

先にgit worktreeでフォルダを分け、それぞれにboothを開く。

git -C /workspaces/myproject worktree add /workspaces/myproject-feature feature-branch
booth open myproject
booth open myproject-feature

「1フォルダ = 1セッション」という約束が単純なぶん、こういう応用がそのまま効く。
worktreeは信頼の承認が引き継がれないので、追加したフォルダで一度承認が要る(5章9節)。

6. transportを差し替える

本記事の方式を抽象化すると「リモートのtmuxにセッションを作り、外から叩く」だけである。
docker compose execはそこへ到達するためのtransportでしかない。

セッションの実体はtmuxが持ち、状態はclaude agents --jsonが答える。boothはその間を取り持っているだけである。
ということは、transportを差し替えれば同じことが成立する。

[現在]  booth ──> docker compose exec <service> tmux ...  ──> コンテナ内の tmux

[将来]  booth ──> ssh <host> tmux ...                     ──> リモートホストの tmux

sshで到達できるマシンにtmuxとClaude Codeが入っていれば、原理的にはそのまま動く。

  • 自宅の作業マシンをsshで叩いて、外出先からスマホでセッションを開く
  • 重いタスクだけ別のマシンで回す
  • 複数のリモートをtargetsとして並べ、booth open <名前> --target <ホスト>で使い分ける

boothの設定は既に[targets.*]という形で複数の実行先を持てる構造になっており、open以外のコマンドも全部そこを経由している。transportの抽象化は自然に入る場所にある。

現時点のboothはdocker compose止まりだが、設計としてはそこまで見据えてある。

まとめ

  • Remote Controlにはサーバモード(claude remote-control)と対話セッション(claude --remote-control <名前>)の2つがあり、別物である
  • サーバモードは1サーバ = 1ディレクトリに固定される。またGitHubのリポジトリでは、git_repo_urlを使ったサーバ側のGitHub連携チェックにより、セッション生成がHTTP 400で失敗することがある(TUIには出ないので--debug-fileで確認する)
  • spawnを使わず、tmuxでセッションを起こせば両方を回避できる。tmux new-session -d -s <名前> -c <フォルダ> claude --remote-control <名前>だけで成立し、Dockerは必須ではない
  • 状態はclaude agents --jsonから取れる。画面を読む必要はない
  • 事前に必要なのはログイン(アカウント単位)と信頼の承認(ディレクトリ単位)。後者はプロジェクトごとに要る
  • コンテナで動かす場合は、~/.claude~/.claude.jsonの両方を永続化する。逆に/root/.local/share/claudeは永続化してはいけない
  • transportを差し替えれば、同じ仕組みがssh先でも成立する

ツールと環境

@infodb/booth

tmuxセッション上のClaude Codeを外から操作するCLI。現時点のtransportはdocker compose exec。MIT。

pnpx @infodb/booth --help

devenviron

コンテナ側の環境はdevenvironとして整備している。
VSCodeのdevcontainerとRemote Control環境が同じベースイメージの上で動くようにしてあり、どちらから作業しても同じ開発環境になる。
本記事5章の構成はenvs/denv-cc-remote/に置いてある。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?