我在 Mac 上跑一個叫 cc-memory-project 的個人 agent 環境(從 OpenClaw workspace 演化來的),有自製的 hybrid memory search、knowledge graph、cron → flag → SessionStart hook pipeline。一直缺一塊:人不在電腦前的時候,沒辦法用手機驅動它。

這篇記錄怎麼用 Claude Code 原生的 channels 功能把 Telegram 接回來、做成開機常駐,計價路徑怎麼選,以及部署過程踩到的四個坑。順帶講同一段時間長出來的兩個搭配能力:記憶的混合搜尋,跟抓 X/Twitter 走遠端瀏覽器。

為什麼是現在接回來

之前想過用 tmux 加一層腳本去駭出一個遠端輸入管道,一直沒做。結果 Claude Code 自己補上了 --channels:原生支援把外部聊天介面(Telegram、Discord 等)接成 agent 的輸入輸出端。官方做掉了我本來要自己駭的東西,那就不用駭了。

另一個推力是計價。Anthropic 五月公告,六月中之後 claude -p、Agent SDK、GitHub Actions、以及經 ACP 認證的第三方 app,會從訂閱池移出去、改吃獨立的月度 credit。但互動式 TUI(terminal 或 IDE)明確不受影響、繼續吃訂閱。所以如果我能讓 Telegram bridge 走的是「互動 TUI」這條路,就不會被新政策抽走額度。

計價路徑:先確認走的是哪條

動手之前先做了一個關鍵驗證:claude --channels(互動模式,不加 -p)開出來的 session,它的 entrypoint 是什麼。

# 開一個 channels session(用一個假的 echo plugin 測)
claude --channels plugin:fakechat@claude-plugins-official
# 然後去翻這個 session 的 jsonl,抓 entrypoint
ls -t ~/.claude/projects/<project>/*.jsonl | head -1 \
  | xargs grep -o '"entrypoint":"[^"]*"' | head -1

結果是 entrypoint=cli,不是 sdk-cli。這就確認了:claude --channels 是互動 TUI 那條路,吃訂閱、不碰新的 credit pool。boot log 也看得到完整互動 TUI 起來、SessionStart hook 把記憶底圖載進去、訊息進來 agent 回應。

確認計價是對的之後才開始搭常駐,免得辛苦做完發現走錯計價路徑。

架構:launchd 常駐 + 每日硬重啟

整個 bridge 是兩個 launchd job:

  • 一個 always-on wrapper,把 claude --channels 用 PTY 包起來常駐(RunAtLoad + KeepAlive,crash 自動拉回)。
  • 一個每日凌晨三點的硬重啟 job,外加一則 liveness ping。

互動模式需要 TTY,但 launchd 底下沒有 controlling terminal,所以 wrapper 用 script -q /dev/null 配一個 PTY 給它。實測在 launchd(無 TTY)下能正常起來。

每天凌晨重啟一次,是為了同時解三件事:刷新 SessionStart 載入的記憶底圖、把累積膨脹的 context 重置掉(Telegram 端沒辦法打 /clear,只能靠程序重啟硬重置)、以及吃 binary 的 auto-update。

聽起來很直接。實際上四個坑一個接一個。

坑一:plugin 拿不到父程序的環境變數

第一個坑:怎麼設都讀不到 bot token。

Telegram channel plugin 是 Claude Code spawn 出來的子程序,它拿不到父程序的環境變數。plugin 的 server.ts 裡有一行註解寫得很白:「Plugin-spawned servers don’t get an env block」。所以不管在 shell 裡怎麼 export TELEGRAM_BOT_TOKEN,plugin 都看不到。

token 只認 plugin 自己的 state 檔 ~/.claude/channels/telegram/.env。解法是讓 wrapper 啟動時自動把 token 從設定檔寫進這個 .envchmod 600)。

# wrapper 啟動時做這件事,而不是靠 export
install -m 600 /dev/stdin ~/.claude/channels/telegram/.env <<EOF
TELEGRAM_BOT_TOKEN=${TG_BOT_TOKEN}
EOF

教訓:channel/plugin 這類被 spawn 的子程序,secret 一律寫進它自己的 state 檔,不要假設環境變數會傳下去。

坑二:一個 stale 的失敗快取,擋住所有重啟

token 修好之後,TUI 還是一直顯示「recent failure cached, retries automatically in 15 min」,plugin 死活連不上。重啟沒用,連全新啟動都擋。

這個最陰。Claude Code 會把 MCP server 的失敗狀態持久化到 ~/.claude/mcp-needs-auth-cache.json。我前面 token 還沒設好那次失敗,被寫進這個檔變成 plugin:telegram:telegram 一條 stale 紀錄。之後 Claude Code 看到這條紀錄就直接 skip、不再 spawn 這個 MCP server——連全新 launch 都被擋,因為它是 disk 持久化、不是 in-memory,重啟清不掉。

解法是備份後手動把那一條 key 移掉(保留其他 OAuth 條目,例如 Gmail、Calendar),再重啟 session。

cp ~/.claude/mcp-needs-auth-cache.json ~/.claude/mcp-needs-auth-cache.json.bak
# 用 jq 刪掉 plugin:telegram:telegram 這條,保留其他
jq 'del(."plugin:telegram:telegram")' \
  ~/.claude/mcp-needs-auth-cache.json.bak > ~/.claude/mcp-needs-auth-cache.json

為了確認 plugin 本身是好的、問題在 Claude Code 側,我直接對 plugin 的啟動指令餵一個 MCP initialize 的 JSON-RPC 握手。它回了完整的 capabilities,證明 plugin 讀得到 token、自己是健康的,問題出在那個 stale cache,不是 plugin 壞掉。這一步能把「plugin 壞」跟「Claude Code 擋住 plugin」分開判斷。

坑三:launchd 底下的 binary 是個假的

接著 launchd job 一起來就 exit 127、crash-loop。

127 是「command not found」。但我明明裝了 claude。問題出在 PATH:launchd 的 PATH 解析到的 claude,是另一個桌面 app(cmux.app)bundle 裡的一個 bash shim wrapper,那個 shim 自己又找不到真正的 claude binary,於是整串崩掉。

解法是在 plist 裡寫死指向真正的 binary——~/.local/bin/claude,那是一個指向真 Mach-O 執行檔的 symlink,而且會隨 auto-update 重新指向新版。不要相信 launchd 環境下 PATH 撈到的東西。

互動 shell 的 PATH      → claude = 真 binary(~/.local/bin/claude)
launchd 的 PATH         → claude = cmux.app 的 bash shim → exit 127

到這裡 bridge 上線了,能用了。然後第二天出了真正的事故。

坑四:訊息排隊五個小時——poller 被搶走了

某天晚上九點多我用手機發了一則訊息,沒回應。一直到隔天凌晨三點那個排程重啟,訊息才被消化——排了五個多小時。

根因是 Telegram 的 getUpdates long-poll 一個 bot token 只允許一個 consumer。我原本以為的運維鐵則是「平常開發用的 session 不要加 --channels 就安全」。這條是錯的。

真相是:telegram plugin 的 server.ts 是個 MCP server,只要 plugin 在那個 session 啟用,它就會無條件 spawn 並開始輪詢,跟有沒有加 --channels 無關。而且它啟動時會去讀 bot.pid,把前一個 poller 用 SIGTERM 接管掉。

所以任何一個開發 session 一開,plugin 就把常駐 bridge 的 poller 搶走了;等我把那個開發 session 關掉,被搶走的 poller 也跟著死——這下沒有任何人在輪詢,訊息就全卡在 Telegram 端排隊,直到下次排程重啟才有人來收。

而且這是健康檢查的盲區:bridge 的程序還活著,liveness ping 也正常,但訊息石沉大海。程序存活不等於資源持有。

正確的防護要設在「資源取得點」,而不是某個啟動旗標上,而且要雙保險:

// 全域 settings:開發 session 一律不載 telegram plugin
{ "enabledPlugins": { "telegram@claude-plugins-official": false } }
# 只有 bridge 的 wrapper 用 --settings 單獨為自己這個 session 啟用
claude --channels plugin:telegram@claude-plugins-official \
  --settings '{"enabledPlugins":{"telegram@claude-plugins-official":true}}'

全域關掉,只有 bridge 自己打開。這樣不管開幾個開發 session 都不會去搶 poller。卡住的時候用 launchctl kickstart -k 把 poller 搶回來。

這個坑的抽象教訓很通用:設計 always-on daemon 依賴的排他資源(API poller、port、lock)時,先列舉「還有哪些路徑會取得這個資源」,把防護放在取得點,不要放在某個你以為唯一的入口旗標上。

同期長出來的兩個搭配能力

接回 Telegram 之後,agent 用起來的場景變了——很多查詢是在手機上臨時起意問的,所以「它能不能自己找到東西」變得更重要。同一段時間補的兩個能力剛好搭得上。

記憶的混合搜尋

agent 的記憶是一堆 journal(時序日誌)跟 notes(主題知識庫)。以前要靠裸 grep,中文斷詞又不好處理。現在是一個混合檢索:BM25 打底、jieba 中文斷詞、再疊時間衰減權重跟「記憶廳分類」加權(事實、事件、發現、偏好、建議各有不同 boost)。

更關鍵的是把它接成一個 always-on 的 proactive recall hook:每次我發問題進來,SessionStart hook 先自動跑一輪搜尋,把最相關的幾條過去紀錄注入 context,再讓 agent 回答。所以從手機問「之前那個 X 怎麼處理的」,它不用我提醒就先去翻自己的記憶。

這裡有一個搭配的紀律:注入的 snippet 標記為「寫入當時為真」、non-authoritative。對會漂移的事實(版本號、IP、路徑、設定值),不能直接拿 snippet 的值來用,要先去讀 canonical 來源確認現值。不然 recall 自己會變成舊資料的散播源。

抓 X/Twitter 走遠端瀏覽器

本機的 WebFetch 是無狀態的 HTTP 抓取,沒有任何登入 session。X/Twitter 現在整站要登入才看得到推文,所以對 x.com 的 URL 一律回 HTTP 402(登入牆)。

解法是繞道我的 VPS:上面跑一個持久化的 Chrome,CDP 開在 9222 port,user-data-dir 裡存著手動登入過的 cookies。要抓推文就透過 SSH 叫遠端的瀏覽器導航過去、等它 render、再 eval 出文章內容。

ssh my-vps 'agent-browser --cdp 9222 navigate "<x-url>"'
sleep 9   # 等 render
ssh my-vps 'agent-browser --cdp 9222 eval "<取 article innerText 的 js>"'

用「一個真實已登入的瀏覽器」才讀得到,免費 API 那條路已經沒了。所以從手機丟一個 X 連結進來要我研究,背後是繞了一圈遠端瀏覽器。

安全姿態:唯一防線是 allowlist

這個 bridge 的權限模式我設成 bypassPermissions——手機訊息進來,agent 零 gate 執行任何工具跟 shell。無人值守時不卡是方便,但代價講白了就是遠端任意程式碼執行

這個姿態下,Telegram 的 allowlist 是唯一的安全邊界。pairing 加上把 policy 鎖成只有我自己的 chat id 能送,從「建議」升格成「強制前置」——沒做完不准上線。bot token 視為高敏 secret,絕不進 git。本機的全碟加密、不共享螢幕、信任網路,是這整套的物理防線。

剛好同一週我研究了一個金融 AI 助理的安全案例:攻擊者用一筆極小額轉帳,把 prompt injection 藏在轉帳備註欄,受害者一查交易,惡意指令就隨資料進了 LLM 的 context 被執行。它的教訓跟我這套防護是同一個哲學——信任邊界要設在資料進入點,不要指望 LLM 自己分辨善惡。我的 bridge 不靠 agent 判斷訊息善惡,而是在「誰能送訊息進來」這個取得點設死 allowlist。對照之下更確定方向是對的。

結尾

把 agent 接回 Telegram,模型那部分沒花什麼力氣——--channels 一個 flag 的事。真正耗時間的全是運維:環境變數傳不下去、一個 stale 快取擋住所有重啟、PATH 撈到假 binary、排他資源被默默搶走。

always-on agent 的瓶頸不在模型多聰明,在它跑的那台機器上、那些你以為理所當然會成立、結果不成立的前提。四個坑沒有一個跟 AI 有關,全是系統工程的老問題換了個場景重演。