CodeBuddy Telegram 通道实战:让无人值守的 AI 编程助手可见、可控、可恢复

AI

把 CodeBuddy CLI 部署在云服务器上、通过 Telegram 远程指挥它干活,是”随身 AI 编程助手”的经典玩法。但真实跑起来后你会发现:通道正常 ≠ 好用。本文记录一次完整的故障排查与改造过程——从”发消息石沉大海”到”每一步思考实时推送到手机”,期间踩过的坑(伪终端注入误区、硬编码审批弹窗、多层 SSH 引号)都是通用经验,适用于任何类似的 CLI-agent + 消息桥接架构。

一、架构:消息到底是怎么流动的

用 --channels server:telegram 启动 CodeBuddy 后,链路是这样的:

1
2
3
4
5
6
7
8
你在 Telegram 发消息
→ 桥接进程(MCP server,Bot API 长轮询)
→ MCP 通知 notifications/claude/channel
→ CLI 内部把文本注入会话(注意:不是写终端!)
→ TUI 输入框 → 模型开始执行
模型干完活
→ 主动调用 MCP 工具 reply(chat_id, text)
→ 桥接进程 sendMessage → 你收到回复

三个容易想当然的点:

  1. 桥接消息不走终端输入,走的是 MCP 通知管道。往 TUI 的 pty 写按键是另一条路,两者互不相通。
  2. 过程不可见是协议设计的:只有模型显式调用 reply 工具,内容才会到 Telegram。中间的思考、工具调用、卡住,用户一概看不见。
  3. Telegram Bot API 的 getUpdates 只允许一个消费者,但 sendMessage 不受限——这给后面”另开一路进度推送”留了口子。

二、事故复盘:三次”没反应”的真相

第 1 次:回车被界面重绘吞了

上一个任务刚结束的瞬间(TUI 正在做收尾重绘),我发出的消息其实已经送达、已经躺进了输入框——但提交它的回车在竞态中丢了。几秒后下一次重绘把输入框草稿清掉,消息彻底消失。

症状特征:日志里能看到停在输入框里的消息文本和 ↵ send 提示,之后零活动。

第 2 次:硬编码的高危审批弹窗(Telegram 永远看不到)

补发消息后,模型开始干活,但一条含 rm -rf 的命令触发了 CodeBuddy 内置的高危操作确认框:

  • 它是硬编码的:正则清单写死在二进制里,bypassPermissions 权限模式也压不住,没有任何配置项能关。
  • 它只在服务器终端的 TUI 里弹出。通道的 Telegram 权限按钮走的是另一套 MCP 通知,覆盖不到这种 TUI 弹窗。
  • 无人值守时,弹窗 = 整条通道死锁。你盯着 Telegram,它盯着终端,谁也等不到谁。

反编译确认后死心:配置层无解。治本思路是让模型的工具调用里根本不出现危险命令文本——见第四节。

第 3 次:按键注入的”安慰剂”

我尝试替用户按键:往进程的 /proc/<pid>/fd/3(一个指向 /dev/ptmx 的符号链接)写字节。写入”成功”了,界面上甚至能看到回显——但会话转录文件纹丝不动,弹窗岿然不动。

折腾了三轮 Esc、数字键、回车之后才意识到根本错误:

/dev/ptmx 每次被 open 都会分配一个全新的伪终端。 我写的所有字节都进了那个无人读取的幽灵终端。所谓”进程 stdin 卡死”是假象,进程活得好好的,只是我的输入从未到达它。

三、两个关键技术

1. 往别人的终端打字:TIOCSTI

正确姿势是打开 pty 的 slave 端(/dev/pts/N),用 ioctl(TIOCSTI) 逐字节塞进终端输入队列(需要 root):

1
2
3
4
5
6
7
8
9
10
11
import fcntl, os, time

pts = os.open('/dev/pts/0', os.O_RDWR)

def tiocsti(b: bytes):
fcntl.ioctl(pts, 0x5412, bytearray(b), True) # TIOCSTI

for ch in "要注入的文本".encode():
tiocsti(bytes([ch]))
time.sleep(1.5)
tiocsti(b'\r') # 回车提交

注入是否生效,不要看终端回显(它会骗你),要看会话转录文件的 mtime 是否前进。

2. 判断真相:转录文件 > TUI

CLI 会把每一次思考(reasoning)和工具调用实时写入会话转录(JSONL):

  • mtime 在动 = turn 活着;mtime 冻结 = 挂起
  • 最后一条事件的 reasoning 字段直接告诉你模型在想什么
  • TUI 每 60 秒定时重绘一遍界面,看起来”有输出”,但不代表有进展

排查通道问题时,永远先看转录,别信终端画面。

四、治本:rm → 回收站 shim

高危弹窗的死结在于:模型删除文件天然要用 rm,而命令文本含 rm 就触发弹窗。破局方式是改变语义——在 PATH 最前面放一个 shim,让 rm 变成”挪进回收站”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/usr/bin/env bash
# ~/.local/bin/rm —— 删除即回收(硬删走 /bin/rm)
set -u
TRASH_DIR="${RM_TRASH_DIR:-$HOME/.trash}"
JOURNAL="$TRASH_DIR/journal.log"
mkdir -p "$TRASH_DIR"

force=0; end_of_opts=0; targets=()
for arg in "$@"; do
[ "$end_of_opts" = 1 ] && { targets+=("$arg"); continue; }
case "$arg" in
--) end_of_opts=1 ;;
-) targets+=("$arg") ;;
-*) case "$arg" in *f*) force=1 ;; esac ;;
*) targets+=("$arg") ;;
esac
done
[ ${#targets[@]} -eq 0 ] && { echo "rm: missing operand" >&2; exit 1; }

ts=$(date +%Y%m%d-%H%M%S); status=0
for t in "${targets[@]}"; do
if [ ! -e "$t" ] && [ ! -L "$t" ]; then
[ "$force" = 0 ] && { echo "rm: cannot remove '$t': No such file or directory" >&2; status=1; }
continue
fi
dest="$TRASH_DIR/$(basename -- "$t").${ts}.$$"
n=0
while [ -e "$dest" ]; do n=$((n+1)); dest="$TRASH_DIR/$(basename -- "$t").${ts}.$$.${n}"; done
mv -- "$t" "$dest" && echo "$(date -Iseconds) $t $dest" >> "$JOURNAL"
done
exit $status

配套两件事:

  1. 给 agent 写持久记忆(CodeBuddy 的 memory 机制):”所有删除一律走 shim/mv 到 ~/.trash,绝不使用 /bin/rm;验证删除结果用 ls/cat 读 journal——命令文本里含 rm 也会触发弹窗”。
  2. 误删可恢复(回收站里有时间戳和原路径),真要硬删永远保留 /bin/rm 这条路。

这个模式值得推广:凡是”agent 发出的命令会触发人肉审批”的场景,都可以用语义替换(shim)+ 持久记忆来消除死锁,而不是想办法远程替人按按钮。

五、体感升级:过程流式推送到 Telegram

“没反应”最磨人的地方是不知道它是在干活还是死了。既然每次思考/工具调用都会落进转录文件,那就 tail 它:

1
2
3
4
5
独立守护进程(systemd 常驻)
→ 每 2.5s 检查最新转录文件是否增长
→ 新事件格式化成一行:🤔 思考 / ⚙️ 工具调用 / ✅ 结果
→ 每 15s 打包 sendMessage 推到配对 chat
→ turn 中途 180s 无新事件 → 推送 ⏸ 卡住告警(含"可能卡在终端审批"提示)

要点:

  • 发送直接走 Bot API 的 sendMessage,与通道的 getUpdates 轮询互不冲突(API 只限制后者单消费者)
  • token 从桥接的 MCP 配置里读,白名单取 access.json 的 allowFrom——不引入新凭据
  • 关键细节:attach 时要从文件尾部回读一次、初始化”最后事件时间”,否则服务在事故中途上线时,卡住告警永远不会触发(我们的第一版就栽在这)
  • 每条消息间隔 ≥3 秒,规避 Telegram 频控

上线后的体感:📩 收到消息 → 🤔 思考中 → ⚙️ 正在执行 xx → ✅ 完成 →(最终回复)。终于”看得见它在干活”,卡住也会在 3 分钟内收到 ⏸ 告警。

六、通用避坑清单

  1. 多层 SSH 引号必剥变量:ssh host 'sudo -i bash -c "… $VAR …"' 里 $VAR 会在本地被剥成空串。正确做法是本地写好脚本,ssh host 'sudo -i bash -s' < script.sh 管道进去。
  2. 写 /proc/*/fd/* 里的 ptmx 符号链接 = 开新终端,不是往原终端写。要注入用 TIOCSTI 打 slave 端。
  3. 无人值守 + 人肉审批 = 死局。用 shim 语义替换 + agent 持久记忆消灭危险命令文本,或换成可远程应答的审批通道(如 MCP permission 通知 + inline 按钮)。
  4. turn 收尾瞬间是消息丢失高发期。重要指令避开”刚干完活”的时点,或靠进度推送器的 ⏸ 告警兜底。
  5. 给桥接加 ack 反馈:收到消息立刻回一个 emoji reaction / “typing…” 状态,用户至少知道消息进来了。
  6. 回执、进度、告警三路分离:最终答案走 reply(触发通知),过程流走推送器,卡死告警独立触发——任何一路静默都还有另外两路。

七、结语

这次改造后,远程 AI 编程助手的工作流变成了:Telegram 下指令 → 手机上看实时过程流 → 干完收到正式回复 → 卡住 3 分钟内收到告警 → 误删可从回收站捞回。核心经验一句话:先弄清消息真实流动的管道,再谈可靠性;能用语义替换消灭的审批死锁,就不要建造更精巧的”远程按键”机器。