結論
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つ |
サーバモードは--spawn(same-dir / worktree / session)や--capacityといったオプションを持つ。
same-dirとworktreeでは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-aやproject-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リポジトリでないディレクトリでも生成された。gitRepoUrlはnullになる。
[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のセッション名 |
myproject(claude --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は引数をキー名として解釈する。EnterやUpという文字列をそのまま送りたい場面と衝突するため、テキストを送るときは-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で突き合わせれば、どのセッションの記録かが確実に分かる -
statusはidle/busy/waiting/shell。waitingのときは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
- ログイン(
/login)… アカウント単位で一度だけでよい - ワークスペースの信頼の承認 … ディレクトリごとに必要
2つ目が見落としやすい。信頼はディレクトリ単位で記録されるため、
- 親ディレクトリの承認は子に引き継がれない
- 同じリポジトリの別worktreeにも引き継がれない
つまりプロジェクトを追加するたびに、そのディレクトリで一度承認する必要がある。
なお信頼ダイアログの既定はNo, exitである。何も考えずEnterを押すとセッションが終了する。
3-6. 設定してはいけない環境変数
Remote Controlは機能フラグの評価結果に依存している。以下を設定するとその評価が無効化され、利用できなくなる。
DISABLE_TELEMETRYDO_NOT_TRACKCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFICDISABLE_GROWTHBOOK
プライバシー配慮でこの手の変数をまとめて設定している環境は珍しくない。動かないときは真っ先に疑うとよい。
また、ANTHROPIC_BASE_URLをapi.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-sessionやlist-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}
commandもworking_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 |
組織情報(oauthAccount … organizationUuid / 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 installやnpm 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/
[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。
- npm: @infodb/booth
- ソース: tamuto/devenviron - tools/booth
pnpx @infodb/booth --help
devenviron
コンテナ側の環境はdevenvironとして整備している。
VSCodeのdevcontainerとRemote Control環境が同じベースイメージの上で動くようにしてあり、どちらから作業しても同じ開発環境になる。
本記事5章の構成はenvs/denv-cc-remote/に置いてある。
- リポジトリ: tamuto/devenviron