無後端的手機 App:用 SSH 和檔案傳遞 Claude Code 的通知與核准

Wendo 是一個無後端的手機 App:你的電腦和手機之間沒有我們的伺服器。Claude Code 要求權限或完成任務時,你電腦上的一個 shell 指令碼會把事件記到一個檔案裡,App 再透過 SSH 讀取它,用的就是開啟終端機時那把金鑰。核准的回覆也走同一條路回去,同樣是一個檔案。
如果你要找的是設定方式,請看這篇指南:在手機上收到 Claude Code 通知。
為什麼做一個沒有後端的手機 App
Wendo 是 SSH 用戶端:要讓它運作,你的手機本來就得連得到你的電腦並通過驗證。既然這條加密的通道已經開著,再在中間放一台伺服器專門處理通知,就是多出來的元件。
拿掉它帶來了三個我們想要的結果:
- 你的代理在做的事(它想執行的指令、要動的檔案、最後一則訊息)不會經過我們的任何機器。它一直留在你的電腦上,直到你的手機來要。
- 沒有帳號。沒有要保存的電子郵件,也沒有會外洩的密碼。身分就是你的 SSH 金鑰。
- 不必為每個新使用者付費或維護基礎設施。
代價是,伺服器原本會做的事(保存歷史、在 App 關閉時通知、配對裝置、驗證購買)全都得用兩端現有的東西解決。
契約:每個事件一行 JSON
一切的核心是 ~/.wendo/events.jsonl。安裝程式把 wendo-hook.sh 註冊到 Claude Code 的六個事件(PermissionRequest、Notification、Stop、UserPromptSubmit、SessionStart 和 SessionEnd),每次觸發時,hook 會從標準輸入讀取 Claude Code 傳來的 JSON,並在檔案末尾加上一行。一筆權限請求大概長這樣(經過刪減):
{"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。這讓並行簡單很多,因為多個 hook 可以同時寫入,而短行的附加是不可分割的。檔案超過 1000 行時,hook 只留下最後 500 行(有 flock 就用它,確保一次只有一個在輪替)。資料夾以 umask 077 建立,檔案權限是 600:只有你的使用者讀得到。
mux 欄位記錄代理在 tmux、zellij 或 herdr 的哪個窗格裡執行,App 才能直接帶你過去。
App 怎麼讀:透過 SSH 輪詢
App 不接收任何東西:它主動去問。每次讀取時,它會對每台開啟收件匣的主機建立一條 SSH 連線,在不配置終端機的情況下執行一個很簡單的指令:
tail -n 1000 ~/.wendo/events.jsonl 2>/dev/null; true
讀取頻率取決於收件匣的狀態。只要有一列在等你回覆,就每 5 秒讀一次,因為 hook 很快就會逾時。有代理在工作時,每 15 秒一次。如果沒有任何活動,只在進入這個分頁時讀取。你回覆之後,它會在 1.2 秒和 3 秒時各再讀一次,確認處理結果。
拿到這些行之後,inbox.dart 依時間排序,再收合成一列列項目:每台主機、每個工作階段一列。換成別的 App 會放在伺服器上的邏輯,幾乎都在這裡。例如,如果一個請求之後出現 Stop 或 UserPromptSubmit,中間卻沒有處理結果,它就判定這個請求已經在終端機裡回答了;又例如,因為壓縮內容而產生的 SessionStart 不會改變任何東西。
由於檔案只能附加,在 App 裡刪除就等於記住刪了什麼。Wendo 在手機上保存「墓碑」,以列或以主機為單位。如果代理又在那個工作階段寫了東西,那一列會再次出現。如果某台主機沒有回應,App 會保留上次從它讀到的內容,而不是把清單清空。
Android 上 App 關閉時
Android 上有一個 WorkManager 工作,最多每 15 分鐘喚醒一次(系統不允許設得更短,還可能為了省電而延後),它會像開著的 App 一樣讀取收件匣,並為新的內容發出本機通知。這次讀取永遠不會要求生物辨識:如果你的 SSH 金鑰有保護且處於鎖定狀態,那台主機就讀取失敗,就這樣。已經通知過的內容會存在手機的儲存空間裡,讓開著的 App 和背景工作不會重複通知同一件事。iOS 上沒有這個工作。
一次核准的來回
PermissionRequest 是 hook 唯一會停下來等待的事件。完整流程如下:
- hook 產生一個
pendingActionId(字母、數字、_和-;App 在把它放進路徑之前也用同樣的規則驗證),算出expiresAt,寫入帶有options: ["Approve","Deny"]的那一行。 - App 看到一列帶有
pendingActionId、沒有處理結果也還沒過期的項目,就顯示按鈕。 - 你按下其中一個時,App 會把
{"decision":"approve"}寫到~/.wendo/answers/<id>.tmp,再重新命名為~/.wendo/answers/<id>。重新命名能避免 hook 讀到寫到一半的回覆。 - hook 每半秒檢查一次這個檔案。找到之後,它以 Claude Code 預期的格式輸出決定,並加上它的處理結果行:
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}
棘手的是你沒回覆時會怎樣。等待時間預設為 90 秒(WENDO_APPROVAL_TIMEOUT)。時間到了 hook 什麼都不輸出,Claude Code 收不到 hook 的回覆,就在終端機裡照常發問。hook 記下一筆 timeout 處理結果後結束。如果你先在終端機裡回答,Claude Code 會取消 hook,由一個 trap 把處理結果記為 local。
設計原則是:手機出任何狀況,都不能讓代理卡住。沒電、沒訊號或 App 關著,最壞的情況也只是一如往常:在終端機裡發問。
還剩一個縫隙:hook 剛放棄的那一刻,你剛好在手機上回覆。為了補上它,留下回覆之後,同一個 SSH 指令會最多再等 3 秒,看有沒有這個 id 的處理結果行。如果結果是 timeout,或沒有人取走檔案,它就把檔案刪掉,App 會告訴你「已過期 — 主機將回退到本地」,而不是當作已送出。
沒有自己的伺服器也能發推播通知:ntfy,而且可以不用
要在 iOS 上 App 關閉時通知你,或是比 Android 的 15 分鐘更早通知你,就需要有人把通知推過來,這就少不了推播服務。我們選了 ntfy,因為一個 HTTP 請求就能發布,也能自行架設。
如果你設定了 NTFY_TOPIC,hook 會發布權限、通知和任務完成這幾類提醒(不包括工作階段的開始與結束,也不包括每次送出的提示)。提醒裡帶有一個 wendo://attach?event=…&host=…&sid=… 連結,指向多工器的那個窗格,點下去 App 就會開到確切的位置。
App 也會讀取這個 topic(/json?poll=1&since=24h),當作收件匣的另一個來源,但以 SSH 讀到的為準。ntfy 只知道有事情需要你,不知道它是否已經處理,所以如果某台主機的 SSH 讀取失敗,來自 ntfy 的該主機權限提醒就不算數。隱私的代價也在這裡:在公用伺服器上,topic 就等於密碼,而提醒裡包含專案名稱和指令的片段。所以 topic 一定要又長又隨機,也因此才有 NTFY_URL,讓你使用自己的伺服器。
不用伺服器,用 QR Code 把手機和電腦配對
沒有帳號,手機要怎麼加入一台電腦?靠 wendo-pair.sh 產生的 QR Code,裡面是一個網址:
wendo://pair?h=<host>&p=<puerto>&u=<usuario>&n=<nombre>&hk=<huella>
hk 是 ed25519 主機金鑰的 SHA256 指紋,用 ssh-keyscan 對那個連接埠上的 sshd 讀出來。App 用嚴格的格式驗證它(SHA256: 加上 43 個 base64 字元),並把它固定在這台主機上。第一次連線會經過驗證,而不是盲目接受;如果哪天伺服器出示了另一把金鑰,連線就會被拒絕。沒有指紋的 QR Code 仍然可以用,採首次連線信任;指紋格式錯誤的則不行。最後一步是用 App 顯示的指令,把手機的公鑰安裝到 authorized_keys。這些都不會離開你的網路。
沒有授權伺服器的 Pro
沒有後端卻要賣訂閱,是最不順手的部分。Wendo Pro 透過 Google Play 和 App Store 的帳單系統收費,驗證放在原生模組裡,而不是 Dart。在 Android 上(ProLicense.kt),每筆購買都附有它的 JSON 和一個以 App 授權金鑰產生的 RSA 簽章,用公鑰驗證。它也會檢查 App 有沒有被重新簽署,狀態則以 Keystore 中的一把金鑰封存保存,所以改一個檔案拿不到 Pro。每次和商店完整同步都會延長效期;沒有同步時,訂閱的 Pro 撐 7 天,終身購買撐 30 天,把時鐘調回去也延長不了。在 iOS 上,原生層用 StoreKit 2 驗證。免費方案的 5 次試用核准也是由原生層計算。
程式碼本身就寫明了它做得到哪裡:有 root 和 Frida,或替 App 打補丁,就能繞過全部驗證,因為沒有一台伺服器去問 Google,就沒有辦法阻止。目標要保守得多:不能讓一鍵工具就破解。
無後端 App 有哪些實際限制
- 手機必須連得到你的電腦。在同一個 Wi-Fi 下,區域 IP 就夠了;在外面,你需要 Tailscale 之類的工具。電腦關機或休眠時,App 就沒有對象可以問。
- 通知的延遲取決於輪詢:App 開著時是幾秒(有東西等回覆時 5 秒,只有代理在工作時 15 秒),Android 背景最多 15 分鐘甚至更久,iOS 上 App 關閉時只有透過 ntfy 才收得到。
- 每次讀取都是一條 SSH 連線。主機一多,在有東西等待的期間,每隔幾秒就是好幾條連線。
- 在一支手機上刪除的內容,只有那支手機記得。
- hook 需要電腦上有
jq,目前也只註冊到 Claude Code。 - 公用的 ntfy 看得到你發布到 topic 的內容。如果你在意,就自己架一個。
想看這些元件在 App 裡怎麼搭配,Wendo 的頁面上有其他部分(終端機、tmux 工作區、聊天檢視),hook 的變數說明則在通知與核准文件。這一切所依靠的 SSH,我們在在 Flutter 裡實作 SSH、Mosh 和 Eternal Terminal裡說明過;另一個不靠伺服器的部分是語音輸入,請見在手機上離線跑 Whisper。Wendo 目前還沒有上架商店。