Claude Code × tmux × n8nで、iPhoneからClaude Codeを遠隔操作できるようにしたよ!というお話

自宅で動かしているセルフホストサービスと自動化の仕組みを全部棚卸ししてみたよ!というお話の中で少し触れましたが、今回はその中のひとつ、「Claude Codeをtmux上に常駐させて、iPhoneから遠隔で起動・一覧・終了できるようにした」話を掘り下げます。外出先でふと「あのタスク、Claude Codeに投げておきたいな」と思ったときに、iPhoneからワンタップでセッションを起動できる、という環境です。

自宅で動かしているセルフホストサービスと自動化の仕組みを全部棚卸ししてみたよ!というお話

SSH接続が画面ロックのたびに切れるのが面倒だった

自宅のMiniPC(Proxmoxサーバー)はTailscaleに常時繋がっており、iPhone側も同じTailscaleネットワークに参加させているので、iPhoneの「SSH Term X」というターミナルアプリからSSHして直接Claude Codeを使うこと自体は、実はこの仕組みを作る前からできていました。ただ、これには一つ大きな不満がありました。家事などでほんの少し操作から離れるだけで、iPhoneが画面ロックされて5分ほど経つと、SSH接続がバックグラウンドで切られてしまうのです。

そうなると、再度SSHで繋ぎ直した上で、/resumeで直前のセッションを復帰させる、という一手間が毎回発生します。ちょっとした調べ物や指示のたびに「繋ぐ→ロックで切れる→/resumeで戻す」を繰り返すのは、地味にストレスでした。

そこで発想を変えて、Claude Code自体をtmux上に常駐させ、--remote-controlオプションでClaudeの公式iPhoneアプリから直接操作できるようにするという構成に切り替えました。SSH接続の生死に依存しない形にしてしまえば、ロックで切れる問題自体を回避できます。さらに、そこから一歩進めてセッションの起動・確認・終了そのものもiPhoneから完結させたいと思い、n8n経由のWebhook化まで踏み込みました。

tmuxでセッションを常駐させる

Claude Codeのセッションを「常駐」させるにあたって、素直にSSHで繋いでclaudeコマンドを叩くだけだと、SSH接続が切れた瞬間にプロセスも道連れで終了してしまうという問題があります。外出先からの操作は基本的に単発のWebhookなので、コネクションを張りっぱなしにするわけにはいきません。

そこで使っているのがtmuxです。tmux上でセッションを起動しておけば、

  • SSH接続そのものは一瞬で切れても、tmux上のプロセスは生き続ける
  • 後から別のSSH接続で同じtmuxセッションにアタッチすれば、続きの状態がそのまま見られる
  • セッションごとに名前を付けて、並行していくつも走らせておける

という特性が得られます。「起動だけさせて後で確認する」「複数のタスクを並行で走らせる」といった運用に、tmuxはかなり相性が良いです。

n8nでWebhook化した3つの操作

iPhoneのショートカットから直接SSHを叩くのは現実的ではないので、間にn8nを挟んでWebhook化しています。3つとも作りはほぼ同じで、Webhook → 加工用のCodeノード → SSHノードでコマンド実行 → レスポンスという4ノードの一本道構成です。用意したのは次の3本です。

1. セッション起動

Webhookを叩くと、指定した名前(省略時は自動生成)でtmuxセッションを新規作成し、その中でClaude Codeを起動します。

セッション起動ワークフロー
セッション起動ワークフロー

ノード構成は次の4つです。

Webhook:iPhoneのショートカットからのPOSTリクエストを受け取ります。

セッション名生成(Codeノード):bodyにnameがあればそれを使い、なければタイムスタンプなどから自動でセッション名を組み立てます。

JavaScript
const body = $input.first().json.body || {};
const now = new Date();
const jst = new Date(now.getTime() + 9 * 60 * 60 * 1000);
const ts = jst.toISOString().replace('T', '-').replace(/:/g, '').slice(0, 15);
const name = (body.name && String(body.name).replace(/[^\p{L}\p{N}_-]/gu, '')) || `claude-${ts}`;
const command = `tmux new-session -d -s ${name} -c /root "/root/.local/bin/claude --remote-control '${name}'" && tmux set-option -t ${name} @claude_managed 1`;
return [{ json: { name, command } }];

名前が空ならJSTのタイムスタンプからclaude-20260824-1310のような名前を自動生成し、指定があればそれを使う、というだけの処理です。あわせてtmux set-option@claude_managedという独自のフラグを立てているのがポイントで、これが後述の一覧取得ワークフローでの絞り込みに使われています。

SSHでセッション起動(SSHノード / Execute Command):直前のノードで組み立てたcommandを、SSH経由でそのまま実行します。この1コマンドが叩ければ「外出先からワンタップでタスクを投げられる」体験のほぼ全部が成立します。

レスポンス:起動結果をJSONで返します。

2. セッション一覧取得

今どんなセッションが動いているかを確認するためのWebhookです。

セッション一覧取得ワークフロー
セッション一覧取得ワークフロー

こちらは先ほどと少し順番が違っていて、「SSHで生の結果を取ってきてから、Codeノードで整形する」という並びになっています。

Webhook:POSTリクエストを受け取ります。

SSHで一覧取得(SSHノード / Execute Command):SSH経由で次のコマンドを実行します。

Shell
tmux list-sessions -F '#{session_name}|#{session_created}|#{@claude_managed}' 2>/dev/null || true

整形(Codeノード):SSHノードの出力をパースし、Claude Code用に作られたセッションだけを抜き出してJSON配列に整えます。

JavaScript
const stdout = ($input.first().json.stdout || '').trim();
const names = stdout
  ? stdout.split('\n').filter(Boolean)
      .map(line => line.split('|'))
      .filter(parts => parts[2] === '1')
      .map(parts => parts[0])
  : [];
return [{ json: { names, count: names.length } }];

ここでparts[2] === '1'、つまり@claude_managedフラグが立っているセッションだけを抽出しているのが、前述の「起動時に立てておいたフラグ」が効いてくる部分です。名前のパターンで判定するのではなく、このフラグの有無だけで判定しているので、どんな名前を付けても正しく一覧化できます。

レスポンス:セッション名の一覧を返します。ここで取得した一覧は、次の「セッション終了」でそのまま選択肢として使えるようにしています。おかげで「一覧を見る → 終了したいものを選ぶ」という流れが、iPhone側で完結します。

3. セッション終了

不要になったセッションを終了させるWebhookです。

セッション終了ワークフロー
セッション終了ワークフロー

構成は起動用のワークフローとほぼ同じ並びです。

Webhook:POSTリクエストを受け取ります(2の一覧取得で得たセッション名を渡します)。

コマンド生成(Codeノード):受け取ったセッション名(1件でも複数件でも)からtmux kill-sessionのコマンド列を組み立てます。

JavaScript
const body = $input.first().json.body || {};
const raw = body.name;
let list;
if (Array.isArray(raw)) {
  list = raw;
} else if (raw !== undefined && raw !== null && raw !== '') {
  // Shortcutsの複数選択はテキスト結合時に改行区切りの1文字列になるため分割する
  list = String(raw).split(/\r?\n/);
} else {
  list = [];
}
const names = list.map(n => String(n).replace(/[^\p{L}\p{N}_-]/gu, '')).filter(Boolean);
const parts = names.map(name => `(tmux kill-session -t '${name}' 2>&1 && echo "KILLED:${name}" || echo "NOTFOUND:${name}")`);
const command = names.length ? parts.join(' ; ') : 'echo NO_NAME';
return [{ json: { names, command } }];

配列でも改行区切りの1文字列でもどちらでも受け取れるようにした上で、各セッション名ごとにkill-sessionを試み、成功したらKILLED:、見つからなければNOTFOUND:という目印を標準出力に残す、という組み立てです。

SSHでセッション終了(SSHノード / Execute Command):組み立てたcommandをSSH経由でまとめて実行します。

レスポンス:標準出力に残ったKILLED:NOTFOUND:の目印を拾って、セッションごとの結果(stoppednot_founderror)に変換して返します。複数選択にも対応していて、一覧から選んだ複数のセッションをまとめて終了できるようにしているので、使い終わったセッションの片付けもiPhoneだけで完結します。

iPhoneのShortcutsを紹介

これらのWebhookを実際に叩いているのが、iPhoneのショートカットです。使っているアクションを順番に紹介します。

ショートカット前半
ショートカット前半
ショートカット後半
ショートカット後半

まず「メニューから選択」アクションで、「新規セッション開始」と「既存セッション終了」の2つに分岐させています。

新規セッション開始を選んだ場合は、

  1. 「テキストを要求」:「セッション名を入力してください。未入力でも実行できます。」というプロンプトを出す(複数行を許可はON、空欄のまま実行してもOK)
  2. 「URLの内容を取得」:URLは起動用のWebhook(start-claude-session)、方法はPOST、「本文を要求」をJSONにした上で、nameフィールドに直前の「テキストを要求」の結果(入力を要求)を割り当てる

既存セッション終了を選んだ場合は、

  1. 「URLの内容を取得」:一覧取得用のWebhook(list-claude-sessions)にPOSTで、本文は空のJSONのまま叩く
  2. 「辞書の値を取得」:直前の「URLの内容」の中からnamesキーの値(セッション名の配列)を取り出す
  3. 「リストから選択」:取り出したnamesを選択肢として表示する。「複数を選択」はON、「最初にすべてを選択」はOFFにしていて、必要なものだけチェックして選べるようにしている
  4. 「URLの内容を取得」:URLは終了用のWebhook(stop-claude-session)、方法はPOST、「本文を要求」をJSONにした上で、nameフィールドに直前の「リストから選択」の結果(選択した項目)を割り当てる

最後に「メニューの終了」アクションで締める、という構成です。特別なプラグインなどは使わず、標準アクションの「メニューから選択」「テキストを要求」「URLの内容を取得」「辞書の値を取得」「リストから選択」だけで組んであります。

見てのとおり、URLは192.168.50.202という自宅LAN内のプライベートIPをそのまま指定しています。これで動くのは、前述のとおりiPhoneが常時Tailscaleに接続していて、外出先でも実質的に自宅ネットワークの中にいる状態になっているためです。わざわざグローバルIPやドメインを用意してリバースプロキシで外部公開する必要がなく、Webhookは自宅LANの中だけに閉じたままになっています。外部から直接叩かれる経路がそもそも存在しないので、認証やアクセス制限を別途考えなくても、この構成自体が侵入経路を減らす形になっているのがありがたいところです。

これをホーム画面に登録しておけば、思いついた瞬間にタップひとつでセッションを起動できます。特にセッション名を省略できるのがポイントで、名前を考える一手間がなくなるだけで、実際に使う頻度がかなり変わりました。

Claudeアプリからそのまま操作できる快適さ

このWebhookでセッションを作成すると、Claudeの公式iPhoneアプリの「Code」欄に、そのセッションがそのまま立ち上がってきます。あとはiPhoneアプリのUI上から普通にClaudeとやり取りするだけで、裏側ではサーバー上のClaude Codeが実際に動いている、という状態です。

ここで表示させるには前提として、Claude Code側でリモートコントロール機能をONにしておく必要があります。OFFのままだと、tmux上でセッション自体は起動していても、iPhoneアプリの「Code」欄には表示されないので注意してください。

これが非常に快適で、単に「外出先からiPhoneのUIでClaude Codeを使える」というだけでなく、スクリーンショットや画像をそのまま投げられるのがターミナルアプリ経由よりも圧倒的に楽です。

ターミナルアプリでSSH越しに使っていた頃は、画像をClaude Codeに渡すにはサーバー側のファイルシステムに何らかの方法で転送してからパスを指定するしかありませんでした。SSHはあくまでテキストの入出力をやり取りするだけの通信路なので、iPhone側のクリップボードやカメラロールの中身を直接渡す手段がなく、SCPで送るなり共有フォルダ経由で同期するなり、ひと手間かけて「サーバー側に画像を運ぶ」作業が必要だったわけです。

Claudeアプリ経由だと、この「サーバー側への転送」をアプリが裏側で自動的にやってくれます。カメラロールから選んで送るだけで、画像はアプリ経由でサーバー上の作業ディレクトリに保存され、Claude Codeがそのままローカルファイルとして参照できる状態になります。ユーザー側の体感としては「チャットに画像を貼っただけ」なのに、裏では確かにサーバーへのアップロードが完了している、というのがこの構成のありがたいところです。

iPhoneから実際にタスクを投げてみた

実際にショートカットからセッションを作成し、iPhoneアプリ上からタスクを依頼してみました。Yahooのトップページのスクリーンショットを用意してセッションに添付し、その内容のまとめをObsidianのノートとして出力させてみます。

下の画像は、そのとき実際にObsidianへ出力された結果です。iPhoneのアプリ自体がエージェントとして動いているわけではなく、サーバー側のClaude Codeがタスクを処理し、その結果をObsidianのノートに書き出しているだけなので、iPhone側で見えているのは、あくまでサーバーで実行された処理結果です。

iPhoneから投げたタスクの結果
iPhoneから投げたタスクの結果。サーバー側のClaude CodeがYahoo!トップページのスクショを読み取り、Obsidianに「まとめ」ノートとして書き出している

まとめ

そもそもの発端は、iPhoneの「SSH Term X」でMiniPC上のClaude Codeコンテナに接続して使っていたときの不便さでした。家事などでほんの少し操作から離れるだけでセッションが切れてしまい、そのたびに繋ぎ直して/resumeで復帰させる、という手間が地味にストレスだったのです。

そこでClaude Codeをtmux上に常駐させ、--remote-controlでClaudeの公式iPhoneアプリから直接操作できるようにしました。これによってSSH接続の生死に左右されない環境が整い、外出先や家事の合間からでも開発環境やタスク依頼にアクセスできるようになりました。しかもTailscaleが必要なのはセッションを新規作成する瞬間(Webhookを叩くとき)だけで、そのあとのやり取りはClaudeアプリを経由するため、ネットさえ繋がっていればどこからでも続きを進められます。さらにn8nで起動/一覧/終了の3本のWebhookを用意し、iPhoneのショートカットから叩けるようにしたことで、セッションの作成から片付けまでがiPhoneだけで完結します。

作成したセッションはClaudeアプリの「Code」欄にそのまま表示され、iPhoneのUIで普通に操作できます。スクリーンショットや画像を渡すのもターミナルアプリ経由よりずっと楽になりました。加えて、MiniPCにはSynology NASも接続されているため、この仕組みを起点にClaude Codeへ委任できるタスクの幅は、今後さらに広げていけそうです。

「思いついた瞬間にタスクを投げられる」という体験は、実際に運用してみるとかなり便利です。本来ならPCの前に座らないと始められなかったはずの作業が、手元のiPhoneひとつでいつでも始められるようになっているというのは、あらためて考えるとなかなか凄いことだと感じています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です