LirafyブログWendo ↗
日本語

ブログ

バックエンドなしのモバイルアプリ: SSH とファイルで Claude Code の通知と承認を届ける

Wendo はバックエンドなしのモバイルアプリです。あなたのパソコンとスマホの間に、私たちのサーバーはありません。Claude Code が許可を求めたりタスクを終えたりすると、あなたのマシン上のシェルスクリプトがそのイベントをファイルに書き、アプリはターミナルを開くときと同じ鍵を使って SSH でそれを読みます。承認への返事も同じ道を通り、別のファイルとして戻っていきます。

設定方法が知りたい場合は、スマホで Claude Code の通知を受け取るガイドにまとめています。

なぜバックエンドなしのモバイルアプリなのか

Wendo は SSH クライアントです。動かすには、スマホがもともとパソコンまで届き、そこで認証できる必要があります。暗号化されたその経路がすでに開いているのに、通知のために間にサーバーを置くのは余計な部品です。

サーバーをなくしたことで、狙っていた結果が 3 つ得られました。

  • エージェントのしていること(実行したいコマンド、触ろうとしているファイル、最後のメッセージ)は、私たちのマシンを一切通りません。スマホが取りに来るまで、あなたのパソコンにとどまります。
  • アカウントがありません。保管すべきメールアドレスも、漏れるパスワードもありません。あなたを識別するのは SSH 鍵です。
  • ユーザーが増えるたびに費用がかかったり保守が必要になったりするインフラがありません。

その代わり、サーバーがやるはずのこと(履歴の保存、アプリが閉じているときの通知、端末のペアリング、購入の確認)は、両端にすでにあるものだけで片付ける必要があります。

契約: 1 イベントにつき JSON 1 行

すべての中心は ~/.wendo/events.jsonl です。インストーラーは wendo-hook.sh を Claude Code の 6 つのイベント(PermissionRequest、Notification、Stop、UserPromptSubmit、SessionStart、SessionEnd)に登録します。イベントが起きるたびに、フックは Claude Code が標準入力に渡す JSON を読み、ファイルに 1 行追記します。許可リクエストの行はおおよそ次のような形です(一部省略)。

{"id":"1793530000-4242-12345","time":1793530000,"host":"portatil","agent":"claude",
 "event":"PermissionRequest","category":"approval_required","kind":"permission",
 "title":"Claude Code — wendo","message":"Bash: flutter test","sessionId":"…",
 "toolName":"Bash","pendingActionId":"p1793530000-4242-6789",
 "expiresAt":1793530090000,"options":["Approve","Deny"],
 "mux":{"kind":"tmux","session":"dev","window":"1","pane":"%3","socket":"/tmp/tmux-501/default"}}

ファイルは末尾にしか伸びません。行を書き換える人はいません。解決した承認は、resolution 型の新しい行として記録します。おかげで並行処理がずっと単純になります。複数のフックが同時に書いても、短い行はアトミックに追記されるからです。ファイルが 1000 行を超えると、フックは最後の 500 行だけを残します(flock があればそれを使い、ローテーションするのが一度にひとつだけになるようにします)。フォルダは umask 077 で作られ、ファイルのパーミッションは 600 です。読めるのはあなたのユーザーだけです。

mux フィールドには、エージェントが動いている tmux、zellij、herdr のペインが入っていて、アプリはそこへあなたを連れていけます。

アプリの読み方: SSH でのポーリング

アプリは何も受け取りません。自分から聞きに行きます。読み込みのたびに、受信トレイを有効にしたホストそれぞれに SSH 接続を開き、ターミナルなしで次のような簡単なコマンドを実行します。

tail -n 1000 ~/.wendo/events.jsonl 2>/dev/null; true

頻度は受信トレイの状態で変わります。返事を待っている行があるうちは、フックがすぐ期限切れになるので 5 秒ごとに読み直します。エージェントが作業中なら 15 秒ごとです。動いているものが何もなければ、タブを開いたときにだけ読みます。あなたが返事をしたあとは、解決を確認するために 1.2 秒後と 3 秒後にもう一度読みます。

行がそろうと、inbox.dart が時刻順に並べ、ホストとセッションごとに 1 つの項目へまとめます。ほかのアプリならサーバーにあるはずのロジックの大半がここにあります。たとえば、あとから解決の行を挟まずに Stop や UserPromptSubmit が届けばリクエストはターミナルで答えられたと判断し、コンパクションによる SessionStart は何も変えないと判断します。

ファイルは追記専用なので、アプリで削除するということは、何を削除したかを覚えておくことです。Wendo は項目ごと、またはホストごとに「墓標」をスマホ本体に保存します。エージェントがそのセッションにまた書き込めば、項目は再び現れます。ホストが応答しないときは、一覧を空にせず、最後に読んだ内容を残しておきます。

Android でアプリを閉じているとき

Android には、最短でも 15 分ごとに起きる WorkManager のタスクがあります(システムはそれより短い間隔を許さず、バッテリー節約のために遅らせることもあります)。開いているアプリと同じように受信トレイを読み、新しいものをローカル通知で知らせます。この読み込みは生体認証を求めません。SSH 鍵を保護してロックしているなら、そのホストは失敗して終わりです。すでに通知したものはスマホのストレージに記録して、開いているアプリとバックグラウンドのタスクが同じ通知を繰り返さないようにしています。iOS にはこのタスクはありません。

承認の往復

フックが待ち続けるイベントは PermissionRequest だけです。流れは次のとおりです。

  1. フックが pendingActionId(英字、数字、_、-。アプリもパスに入れる前に同じ規則で検証します)を生成し、expiresAt を計算して、options: ["Approve","Deny"] 付きの行を書きます。
  2. アプリは、pendingActionId があり、解決されておらず、期限も切れていない項目を見つけてボタンを表示します。
  3. ボタンを押すと、アプリは {"decision":"approve"} を ~/.wendo/answers/<id>.tmp に書き、~/.wendo/answers/<id> にリネームします。リネームすることで、フックが書きかけの返事を読むことを防ぎます。
  4. フックは 0.5 秒ごとにそのファイルを確認します。見つけたら、Claude Code が期待する形式で決定を出力し、解決の行を追記します。
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}

難しいのは、返事をしなかった場合です。待ち時間は既定で 90 秒(WENDO_APPROVAL_TIMEOUT)です。時間切れになるとフックは何も出力せず、フックから返事のない Claude Code は、いつもどおりターミナルで質問します。フックは timeout の解決を記録して終了します。先にターミナルで答えた場合は Claude Code がフックをキャンセルし、trap が解決を local として記録します。

設計のルールは、スマホ側の不具合でエージェントを止めないことでした。バッテリー切れでも、圏外でも、アプリが閉じていても、最悪の場合はいつもと同じ、ターミナルでの質問です。

ひとつ隙間が残ります。フックがあきらめたちょうどそのときに、スマホから返事をした場合です。これに対処するため、返事を置いたあと、同じ SSH コマンドがその id の解決の行が現れるのを最大 3 秒待ちます。解決が timeout だった場合や、誰もファイルを拾わなかった場合はファイルを消し、アプリは送信済みとみなさずに「期限切れ — ホストがローカルにフォールバックします」と表示します。

Wendo の受信トレイ。Claude Code の承認待ち(my-app の npm test)と、拒否・承認ボタン 承認の流れ: Claude Code が許可を求め、フックが events.jsonl に記録し、Wendo が SSH で読み取り、スマホで答えると、フックが allow か deny を Claude Code に返す

自前のサーバーなしでプッシュ通知: ntfy、しかも任意

iOS でアプリを閉じているとき、あるいは Android の 15 分を待たずに知らせたいときは、通知を押し出してくれる誰かが必要で、それには push サービスが要ります。私たちは ntfy を選びました。HTTP リクエスト 1 回で公開でき、自分でホストもできるからです。

NTFY_TOPIC を設定すると、フックは許可リクエスト、通知、タスク完了を公開します(セッションの開始と終了、プロンプトごとのイベントは公開しません)。通知にはマルチプレクサのペインを含む wendo://attach?event=…&host=…&sid=… のリンクが付いていて、タップするとアプリがちょうどその場所を開きます。

アプリはトピック(/json?poll=1&since=24h)も受信トレイの情報源のひとつとして読みますが、SSH で読んだ内容が優先です。ntfy が知っているのは何かがあなたを必要としていたことだけで、それがもう解決したかどうかは知りません。そのため、あるホストの SSH での読み込みが失敗した場合、ntfy から来たそのホストの許可リクエストは数に入れません。そしてここにプライバシー上のコストがあります。公開サーバーではトピックがパスワードの役割を果たし、通知にはプロジェクト名とコマンドの抜粋が含まれます。だからトピックは長くランダムにする必要があり、自分のサーバーを使うための NTFY_URL も用意しています。

サーバーなしでスマホとパソコンを QR でペアリングする

アカウントがないのに、スマホはどうやってパソコンを登録するのでしょうか。wendo-pair.sh が生成する QR コードで、中身は URL です。

wendo://pair?h=<host>&p=<puerto>&u=<usuario>&n=<nombre>&hk=<huella>

hk は ed25519 ホスト鍵の SHA256 フィンガープリントで、そのポートの sshd に対して ssh-keyscan で読み取ったものです。アプリは厳密なパターン(SHA256: と base64 の 43 文字)で検証し、そのホストに固定します。最初の接続は無条件に受け入れるのではなく検証され、後日サーバーが別の鍵を出してきたら接続を拒否します。フィンガープリントのない QR も使えますが、その場合は初回接続を信頼する方式になります。フィンガープリントの形式が壊れている QR は使えません。最後の手順は、アプリが表示するコマンドでスマホの公開鍵を authorized_keys に追加することです。どれもあなたのネットワークの外には出ません。

ライセンスサーバーなしの Pro

バックエンドなしでサブスクリプションを売るのは、いちばん居心地の悪い部分です。Wendo Pro は Google Play と App Store の課金で支払われ、確認は Dart ではなくネイティブモジュールで行います。Android(ProLicense.kt)では、購入ごとに JSON と、アプリのライセンス鍵による RSA 署名が届き、公開鍵で検証します。アプリが再署名されていないことも確認し、状態は Keystore の鍵で封印して保存するので、ファイルを書き換えても Pro にはなりません。ストアとの完全な同期が成功するたびに状態は更新されます。同期がなければ、Pro はサブスクリプションで 7 日、買い切りで 30 日もち、時計を戻しても延びません。iOS ではネイティブ側が StoreKit 2 で検証します。無料プランの 5 回の試用承認を数えるのもネイティブ側です。

コード自身が限界を書いています。root と Frida を使うか、アプリにパッチを当てれば全部回避できます。Google に問い合わせるサーバーがなければ防ぐ方法はないからです。目標はもっと控えめで、ワンクリックのツールでは足りないようにすることです。

バックエンドなしのアプリの本当の限界

  • スマホからパソコンに届く必要があります。同じ Wi-Fi ならローカル IP で十分ですが、外からなら Tailscale のようなものが要ります。パソコンが電源オフかスリープなら、アプリには問い合わせる相手がいません。
  • 通知はポーリングの間隔だけ遅れます。アプリを開いていれば数秒(返事待ちがあれば 5 秒、エージェントが作業中なだけなら 15 秒)、Android のバックグラウンドでは 15 分以上、iOS でアプリを閉じているときは ntfy 経由でしか届きません。
  • 読み込みのたびに SSH 接続が 1 本開きます。ホストが複数あれば、何かが返事を待っている間は数秒ごとに複数の接続です。
  • あるスマホで削除したものは、そのスマホしか覚えていません。
  • フックにはパソコン側の jq が必要で、今のところ登録先は Claude Code だけです。
  • 公開の ntfy は、トピックに公開した内容を見られます。気になるなら自分でホストしてください。

これらの部品がアプリの中でどうつながっているかは Wendo のページで残り(ターミナル、tmux のスペース、チャットビュー)と一緒に見られます。フックの変数のリファレンスは通知と承認のドキュメントにあります。そのすべてを支える SSH についてはFlutter アプリに SSH・Mosh・Eternal Terminal を組み込んだ方法に、サーバーなしで動くもうひとつの部品である音声入力についてはスマホで Whisper をネットなしで使うに書きました。Wendo はまだストアに出ていません。