1
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?

Galaxy Z Fold から自宅 PC の Claude Code を操作するアプリを自作した(Agent SDK × Jetpack Compose)

1
Posted at

はじめに

ふだんは Windows の VS Code で Claude Code 拡張を使って開発しています。外出先でもスマホから同じように指示を出したい。そう思って、Galaxy Z Fold8 から自宅 PC の Claude Code を操作するアプリを作りました。

Claude Code には公式の Remote Control もあります。ただ、Fold を開いたときは一覧と会話を横に並べたいし、VS Code みたいにファイルツリーも見たい。自分の使い方に合わせたかったので、Android アプリから自作しました。

この記事では、作ったものの全体像、PC 側の中継サーバー(bridge)とアプリの作り、途中でハマったところを紹介します。ソースコードは公開していないので、要所だけ抜粋して載せます。

完成したもの

Fold を閉じたカバー画面では 1 画面ずつ切り替え、開くと左に一覧、右に会話を並べます。

01-session-list.png

02-chat.png

開いた状態の 2 画面
05-expanded-chat.png

できることは次のとおりです。

  • 新しいセッションを始める。過去のセッションを分岐して、またはそのまま続ける
  • 指示を送ると、返答が少しずつ流れてくる。ツールの実行は 1 行ずつ並ぶ
  • ツールの承認(許可・拒否・常に許可)、実行中のターンの中断
  • 権限モード、モデル、effort の切り替え
  • VS Code のようなファイルツリーと、読み取り専用のファイル表示(ピンチで拡大)
  • アプリを閉じても、作業は PC 側で続く

モデル、effort の切り替え
04-model-effort.png

※ 画面に出ている会話とファイルは、撮影用に作ったダミーのプロジェクト(todo-app)のものです。

全体の構成

インターネットには一切公開していません。外出先からは Tailscale で自宅 LAN に入り、PC のローカルアドレスへつなぎます。

部品 中身
bridge Node.js 24 + TypeScript。fastify で REST と WebSocket を受け、@anthropic-ai/claude-agent-sdk でセッションを動かす
アプリ Kotlin + Jetpack Compose。OkHttp の WebSocket、kotlinx.serialization、DataStore
経路 Tailscale のサブネットルーター(自宅の Proxmox 上の LXC)経由。アプリから見ると、いつも PC のローカル IP

アプリは Claude Code を直接動かしません。bridge が Claude Code を動かし、アプリは bridge に指示を送って結果を受け取るだけです。作業フォルダは VS Code で開いているのと同じフォルダなので、CLAUDE.md、.claude/settings.json、hooks、MCP の設定がそのまま効きます。

bridge: Agent SDK で Claude Code を動かし続ける

1 セッション = query() 1 本

Agent SDK の query() は、prompt に AsyncIterable を渡すと「ストリーミング入力」になり、1 本の query() に何度でも指示を流し込めます。そこで、アプリから届いた指示を入れておくキューを作り、セッションごとに query() を 1 本ずつ動かし続けます。

// ユーザー入力を query() に流し込むためのキュー
class InputQueue implements AsyncIterable<SDKUserMessage> {
  private items: SDKUserMessage[] = [];
  private waiter: (() => void) | null = null;

  push(item: SDKUserMessage) {
    this.items.push(item);
    this.waiter?.();
  }

  async *[Symbol.asyncIterator]() {
    while (true) {
      const item = this.items.shift();
      if (item) { yield item; continue; }
      await new Promise<void>((r) => (this.waiter = r));
    }
  }
}

this.q = query({
  prompt: this.input,          // InputQueue
  options: {
    cwd: projectPath,
    resume: resumeId,          // 過去のセッションを開くとき
    forkSession: fork,         // 分岐して続けるとき
    includePartialMessages: true, // 返答を少しずつ受け取る
    canUseTool: this.canUseTool,  // 承認をスマホへ回す(次の節)
  },
});

ストリーミング入力にしておくと、interrupt()(中断)、setPermissionMode()、setModel() も実行中に呼べます。effort は applyFlagSettings({ effortLevel }) で変えています。

承認をスマホへ回す

ツールを使う前に承認が要るとき、SDK は canUseTool を呼びます。ここで Promise を作って保留にし、アプリが答えたら resolve します。

private canUseTool: CanUseTool = (toolName, input, options) =>
  new Promise<PermissionResult>((resolve) => {
    const requestId = options.requestId ?? randomUUID();
    this.pending.set(requestId, { toolName, suggestions: options.suggestions, resolve });
    this.emit({ kind: "permission_request", requestId, toolName, input,
      canAlwaysAllow: !!options.suggestions?.length });
    this.setState("waiting_permission");
  });

// アプリから permission.reply が届いたら
replyPermission(requestId: string, allow: boolean, message?: string, alwaysAllow = false) {
  const p = this.pending.get(requestId);
  if (!p) return false;
  this.pending.delete(requestId);
  p.resolve(allow
    ? { behavior: "allow", updatedPermissions: alwaysAllow ? p.suggestions : undefined }
    : { behavior: "deny", message: message || "スマホから拒否されました" });
  return true;
}

「常に許可」は、SDK が提案してくる許可ルール(suggestions)をそのまま返すだけで実現できました。何を承認なしで通すかは Claude Code 側の設定が決めるので、bridge では判断しません。

03-permission.png

許可ボタンは朱色の判子の形にしました。画面の中でいちばん大事な操作なので、ここだけ目立たせています。

電波が切れても取りこぼさない

スマホは電波が切れます。そこで bridge は、アプリへ送るイベントすべてにセッションごとの連番(seq)を付け、直近 2,000 件をリングバッファに覚えておきます。

private emit(event: AppEvent) {
  const seq = ++this.seq;
  this.buffer.push({ seq, event });
  if (this.buffer.length > this.opts.bufferSize) this.buffer.shift();
  for (const l of this.listeners) l(seq, event);
}

// つなぎ直したアプリが「lastSeq まで受け取った」と言ってきたら、その続きを流し直す
attach(listener: Listener, lastSeq: number) {
  const oldest = this.buffer[0]?.seq ?? this.seq + 1;
  const truncated = lastSeq + 1 < oldest && lastSeq < this.seq;
  for (const e of this.buffer) if (e.seq > lastSeq) listener(e.seq, e.event);
  this.listeners.add(listener);
  return { truncated, lastSeq: this.seq };
}

アプリは最後に受け取った seq を覚えておき、つなぎ直すたびに session.attach を送ります。重複して届いた分は seq で捨てます。承認待ちの要求も保留されたままなので、圏外から戻ってきても承認カードがもう一度出ます。

つなぎ直しの間隔は 1、2、4、8、16、30 秒と延ばし、OkHttp の pingInterval(20 秒)で死んだ接続に早く気づくようにしました。

アプリ: Fold の 2 画面と「青焼き」

画面の幅だけで組み方を変える

カバー画面か、開いた画面か、ではなく、アプリの窓の幅で決めています。600dp 以上なら 2 画面、それより狭ければ 1 画面です。こうしておくと、分割画面やポップアップで窓の大きさが変わっても同じ仕組みで切り替わります。

BoxWithConstraints {
    val expanded = maxWidth >= 600.dp
    if (expanded) ExpandedLayout(vm, connection) else CompactLayout(vm, connection)
}

AndroidManifest.xml の configChanges に screenSize|smallestScreenSize|screenLayout などを入れ、Fold の開け閉めで画面を作り直さないようにしています。書きかけの指示や開いている会話は ViewModel に持たせているので、開け閉めしても消えません。

ファイルツリーと拡大

左の一覧は「セッション」と「ファイル」を切り替えられます。Claude が書き換えたファイルには朱色の「M」が付きます。ファイルを長押しすると、入力欄に @パス が入ります。

06-expanded-files.png

長い行が切れて読めないときは、右の見出しの左端のボタンで一覧を閉じれば、画面いっぱいに広がります。文字は 2 本指のピンチか −・+ で 60〜300% に変えられます。

07-pane-closed.png

デザイン「青焼き」

Material 3 の標準部品のままでは、どうにも味気なかったので作り直しました。テーマは設計図(青焼き)です。紺青の地に薄い方眼を引き、角を立て、線は細くしています。書体は IBM Plex Sans JP と IBM Plex Mono をアプリに同梱しました(SIL Open Font License)。ライトとダークは端末の設定に合わせます。

アイコンも同じテーマで、方眼の上にコマンドの印「>_」と判子を描いています。

09-app-icon.png

非公式の小技: アプリで始めた会話を VS Code で続ける

アプリで始めたセッションは、PC の ~/.claude/projects/ に VS Code のセッションと同じ形式で残ります。ところが、VS Code 拡張の「過去の会話」の一覧には出てきませんでした。

調べると、セッションファイル(JSON Lines)の各行にある "entrypoint" の値で仕分けているようでした。Agent SDK 経由のセッションは "entrypoint":"sdk-ts" になり、これが一覧から外れます(拡張 2.1.310 で確認)。

そこで bridge は、セッションが閉じたあと(Claude Code のプロセスが終わり、もう書き込まれない状態)に、そのファイルの sdk-ts を cli に書き換えています。

const FROM = '"entrypoint":"sdk-ts"';
const TO = '"entrypoint":"cli"';

async function rewrite(file: string): Promise<number> {
  const text = await readFile(file, "utf8");
  const count = text.split(FROM).length - 1;
  if (count === 0) return 0;
  // 途中で落ちても壊れたファイルが残らないよう、別名で書いてから置き換える
  const tmp = `${file}.rcc-tmp`;
  await writeFile(tmp, text.replaceAll(FROM, TO), "utf8");
  await rename(tmp, file);
  return count;
}

これで、外でアプリから始めた作業を、帰宅後に VS Code の「過去の会話」から開いて続けられるようになりました。

これは公開された仕様ではなく、拡張の内部の動きに合わせた小技です。拡張の更新で一覧の判定が変われば、効かなくなるおそれがあります。書き換えるのは PC 上の記録ファイルだけで、Claude Code の通信の中身には手を入れていません(環境変数で最初から名乗りを変える方法もありますが、通信まで変わるので採りませんでした)。

ハマったところ

Android 17 でローカル IP につながらない

いちばん首をかしげたのがこれです。アプリから PC の LAN の IP に接続すると、10 秒でタイムアウトしました。adb shell から curl すると届くので、ネットワークの問題ではなさそうで、しばらく原因が分かりませんでした。

Android 17(API 37)を対象にしたアプリは、LAN のアドレスへの通信に実行時の許可 ACCESS_LOCAL_NETWORK(「付近のデバイス」のグループ)が要ります。マニフェストに書くだけでなく、実行時に求める必要があります。

<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
private fun hasLocalNetworkPermission(): Boolean =
    Build.VERSION.SDK_INT < 37 ||
        checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED

許可がないあいだは許可を求める画面だけを出し、設定アプリで変えて戻ってきたら(onResume)つなぎ直すようにしました。平文の HTTP も、network_security_config.xml で PC の IP にだけ許可しています。

返答が 2 回表示される

thinking のあとの返答が、同じ文章で 2 回並ぶことがありました。

SDK は、返答を少しずつ流すイベント(text_delta)では、本来のブロックの位置(index)を付けてきます。ところが確定版の assistant メッセージは、ブロックごとに分けて送ってくるので、content の中の位置がいつも 0 になります。位置で突き合わせていたため、流れてきた文章と確定版が別物とみなされていました。

bridge では、確定版の文章を、流れてきた文章と中身で突き合わせて正しい位置を付け直しています。

const texts = this.streamed.get(msg.message.id); // 位置 → 流れてきた文章
msg.message.content.forEach((b, pos) => {
  if (b.type === "text") {
    const match = texts && [...texts].find(([, t]) => t === b.text || t.trim() === b.text.trim());
    blocks.push({ type: "text", index: match ? match[0] : pos, text: b.text });
  }
});

タスク スケジューラで止めても bridge が止まらない

bridge はログオン時にタスク スケジューラから起動しています(conhost --headless で黒い窓を出さず、落ちたら 10 秒後に起動し直すループの PowerShell スクリプト)。ところが Stop-ScheduledTask で止めても、子の pwsh と node が残って動き続けました。

止めるときは、起動スクリプトの pwsh をプロセスツリーごと止める(taskkill /T)専用のコマンドを用意しました。先に node を止めると、ループが起動し直してしまうので、pwsh から止めるのがポイントです。

横向きでキーボードを出すと入力欄がつぶれる

カバー画面を横にしてキーボードを出すと、見出しとキーボードに挟まれて入力欄の高さが 0 になり、文字を打てなくなりました。分割画面でも同じでした。

キーボードを除いて残る高さが 360dp 未満のときは、会話の見出しを畳み、入力欄を 2 行までにするようにしました。

val imeHeight = with(LocalDensity.current) { WindowInsets.ime.getBottom(this).toDp() }
val cramped = maxHeight - imeHeight < 360.dp

エミュレーターで wm size と settings put system font_scale 2.0 を変えながら描画して確かめると、文字サイズ最大のときの崩れも一緒に見つかりました。

セキュリティで気をつけたこと

自分しか使わないとはいえ、PC で Claude Code を動かせる入口なので、次のようにしています。

  • bridge は LAN の IP にだけ bind する(0.0.0.0 は起動時に断る)。インターネットには出さない
  • すべての要求に Bearer トークンが要る(WebSocket の接続要求も)。照合は SHA-256 にしてから timingSafeEqual で比べる
  • トークンは PC ではユーザー環境変数に置き、ファイルに書かない。アプリでは Android Keystore の鍵で AES-GCM 暗号化して保存する
  • アプリの平文 HTTP は、自宅 PC の IP にだけ許可する
  • ファイル表示は、作業フォルダの中だけ。..、絶対パス、シンボリックリンクで外に出られないよう、実体パスにしてから確かめる。.env や鍵ファイルなどは中身を返さない

運用

  • bridge はログオンの 30 秒後に、タスク スケジューラから自動で起動する
  • bridge とアプリのログは、どちらも 1 行 1 件の JSON で 1 か所に集める。アプリのログは端末にためておき、bridge につながるたびに PC へ送る。外でつながらなかった件も、帰宅後に PC だけで追える
  • プロンプトや返答の本文はログに書かない。トークンらしき文字列は伏せ字にする

まとめ

Agent SDK のストリーミング入力と canUseTool のおかげで、「Claude Code をスマホから操作する」部分は思ったより素直に作れました。時間を食ったのはむしろスマホ側の事情で、電波が切れる前提の作りと、Android 17 の新しい制限でした。

外で思いついたことをその場で頼んでおき、帰ってから VS Code で続きをやる。この流れができたのがいちばんうれしいところです。いまの弱点は、承認待ちになってもアプリを開くまで気づけないこと。通知はしばらく使ってみてから考えます。

1
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
1
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?