tgcc 是我写的一个工具:用手机在 Telegram 里给 Claude Code 发消息,它在我的电脑上执行,结果发回 Telegram。通勤路上想到一件事,发过去,到家看结果。
从 2026 年 2 月写到 6 月,四个月,707 个提交。现在它停了,我不再维护,也不再自己用。
这篇讲这四个月里技术上发生了什么:为什么选短进程而不是长驻终端、tmux 抓屏那条路怎么一步步走到死胡同、最后为什么该换的不是解析器而是数据源,一个专门做日志脱敏的模块怎么把密钥泄漏在了自己的测试里,以及我在什么时候决定停下来。
一、最开始想解决什么
最早的提交是 2026-02-07 14:24:
02-07 14:24 | feat: claude-tg package skeleton with config
02-07 21:33 | feat: formatter and runner modules with full test coverage
02-07 21:35 | feat: stream and media modules with tests
02-07 21:38 | feat: bot handlers and CLI entry point
02-07 21:41 | chore: add README and design docs, remove old monolith files
02-07 22:02 | ci: add PyPI publish workflow on tag push
最后一行的 remove old monolith files 说明 02-07 之前还有一个单文件版本,那个连 git 都没进。
需求很简单:我不在电脑前的时候,也想让 Claude Code 干活。手机上没有终端,但有 Telegram。所以要的就是一根管子——Telegram 消息进来,claude 跑起来,输出回去。
第一天晚上的三个提交能说明这活儿的真实质地:
22:08 | fix: increase subprocess stdout buffer to 50MB
22:09 | fix: increase stream buffer limit to 2GB
22:11 | fix: set stream buffer limit to 100MB
三分钟内改了三次缓冲区大小,50MB 不够,跳到 2GB,冷静下来收回 100MB。Claude Code 的输出量能撑爆 asyncio 默认的 64KB 流缓冲,这是文档里不会写的东西。
二、核心架构决策:短进程,不是长驻终端
这个项目最重要的一个决策,是每次请求都起一个新的 claude -p 短进程,而不是维护一个长驻的交互式 Claude 终端。
claude -p --input-format text \
--session-id <uuid> | --resume <session_id> \
--output-format stream-json --verbose
docs/architecture.md 里把理由写清楚了:长驻终端意味着你要自己处理 TTY 状态、prompt-ready 检测、ANSI 解析、崩溃恢复,以及为每个用户维护一个交互进程池。这四样每一样都是坑。
配套的三个决策:
| 决策 | 理由 |
|---|---|
用 --resume 维持上下文 |
会话状态归 Claude Code 自己管,tgcc 只存「哪个 chat 对应哪个 session id」 |
stream-json 而不是只要最终 JSON |
能在 Telegram 上实时显示正在调用什么工具、跑了多久、Stop 按钮 |
| 每个 chat 串行、chat 之间并行 | 一个对话一个活动 runner 加一个 FIFO 队列,避免上下文错乱 |
| 状态卡与最终答案分离 | 状态卡回答「它现在在干嘛」,最终答案保持可读、分页 |
这套设计撑住了整个项目,最后发布的版本用的还是它。但它有一个代价,两个月后才暴露。
三、撞墙:headless 模式拒绝交互式命令
claude -p 是无头模式。你在 Telegram 里发 /model,Claude 会回你一句 isn't available in this environment。
这不是 bug,是 -p 的定义——它不假设有终端,所以所有需要 TTY、选择器、账号流程的命令全部不可用。而 /model、/effort、/permissions 恰好是我最想在手机上用的三个。
第二节那个「短进程」决策,换来了简单和稳定,代价就是这个。
于是有了两条路:要么放弃这些命令,要么想办法搞一个真的终端出来。
我先走了第二条。
四、tmux 那条路:五个坑和一条死胡同
先排除了一个选项:Anthropic 官方的 --remote-control 是托管中继,不支持第三方桥接。
剩下的方案是 tmux:在一个 tmux 伪终端里跑真正的交互式 claude,用 send-keys 喂输入,用 capture-pane -p 抓渲染完的纯文本帧——这样就不用自己解析 ANSI。这条路在独立分支 tmux-interactive 上做,2026-06-02 完成。
然后开始踩坑。
坑一:TUI 跑在 alt-screen 里,没有 scrollback。 capture-pane -S - 想抓历史,抓不到,只能拿到当前这一帧。解决办法很笨——把 TMUX_PANE_HEIGHT 默认设成 200,让单轮输出尽量落在一帧里。
坑二:spinner 是随机词。 Claude Code 运行时显示的是 · Flambéing… 这类东西,词是随机的,没法靠匹配固定字符串判断「还在跑」。更麻烦的是 ✻ Crunched for 3s 看着像运行状态,其实是完成后的持久摘要,拿它当运行标志会永远等下去。最后的判据是「连续两帧完全相同」。
坑三:正文里的 --- 被当成了输入框边界。 解析器要从帧里切出「Claude 的回复」,它用横线定位输入框。结果 Markdown 的分隔线和表格边框也是横线,回复被从中间截断。改成以孤立的空 ❯ 作锚点才修好。
这三个都修了。第四个没修成。
box-drawing 表格的数据行会消失——表头在、分隔线在、底边在,中间的数据行是空的。我先怀疑解析器,写测试证明解析器对 box 表格处理正常。那根因只剩一个:capture-pane 抓到了 TUI 的渲染中间态。Claude Code 先把表格框架画出来,再往里填数据;wait_idle 判定「稳定了」的那一刻,数据还没填完。
memory 里我当时写的结论是:
抓屏方案的固有脆弱性,打补丁只能堵个例。
这句话是这个项目里我最认可的一个判断。前三个坑都是「解析得不够聪明」,可以修;第四个坑是「数据源本身就是错的」——你抓的是给人眼看的渲染结果,它天然有中间态,你在下游怎么解析都救不回来。
所以换数据源。
Claude Code 自己会把完整对话写进 JSONL transcript(~/.claude/projects/<encoded>/<uuid>.jsonl),那是结构化数据,没有 ANSI、没有 chrome、没有渲染中间态。开源项目 ccbot 就是这么做的:不抓屏,直接读 transcript,tmux 只用来送键盘输入。
而我们比 ccbot 简单一大截。ccbot 不控制 claude 怎么启动,得靠 SessionStart hook 写一张 window → session_id 的映射表,再扫 sessions-index.json 按 cwd 匹配文件。我们是自己用 tmux 把 claude 启动起来的,只要在命令里加一个 --session-id <uuid>,路径就完全确定:chat_id → session_id(自己生成的)→ project_dir(自己知道的)→ 直接拼出 transcript 路径。整个 hook / 映射 / 扫描层全部不需要。
增量读那部分有个细节值得记:只在一行成功 JSON 解析之后才推进 byte offset。因为你随时可能读到一行只写了一半,解析失败就 break,下一轮从同一个 offset 重来。另外 offset > size 说明文件被截断了,归零重读。
效果验证得很硬:让 claude 输出一个 Markdown 表格(正是旧方案会截断的场景),transcript 读出来是完整无损的原始 markdown,不是渲染后的 box-drawing。
最后保留了混合架构:普通对话读 transcript,交互式 modal(/model 的选择器这类不产生 transcript 输出的东西)仍然走抓屏。
坑五,收尾时才发现的:tmux 是 daemon,不在 bot 的进程树里。 tgcc stop 只 SIGTERM bot 那棵树,所以 tgcc restart 之后 claude 进程还活着;但旧代码重启后会生成一个新 uuid 去建 session,reader 于是指向一个不存在的文件,然后静默退回抓屏——功能没坏,只是悄悄降级回了那个有 bug 的路径。修法是把 chat_id → session_id 持久化进 status.json:tmux 还活着就复用旧 id 重连,tmux 死了才用新 uuid。这里特意没用 --resume,因为 claude resume 会 fork 出新 id,把「路径确定」这个前提破坏掉。
这条分支的最后一个提交是 2026-06-03 13:27 feat: persist interactive session id across bot restarts。
然后它就停在那里了,从来没有合并进 main。
同一天下午 14:03,我在 main 分支上问了一句「现在这个分支有没有 INTERACTIVE 相关的」。答案是:功能代码一点没有,但 README 里还留着 /screen、/key 和一整章「交互模式(tmux)」。也就是说,主分支的文档在描述一个主分支没有的功能。14:05 我让它清掉了。
最终发布的 0.8.x 是纯 headless 的 main 分支。/model、/effort、/permissions 最后是用 tgcc 自己的 per-chat 设置实现的——绕过问题,而不是解决问题。那五个坑、那次数据源迁移、135 行的 transcript_reader.py,一行都没进产品。
我不认为那两周白费。真正的收获是那句「打补丁只能堵个例」,以及知道了在什么条件下抓屏是可行的、在什么条件下必须换数据源。但从产品角度,它就是一条死胡同。
五、autopilot 那四天:480 个提交
2026-05-29 到 06-01,四天,480 个提交。
按天分:05-29 有 66 个,05-30 有 199 个,05-31 有 174 个,06-01 有 41 个。平均下来每 12 分钟一个提交,昼夜不停。
同期我的人类输入是多少?Codex 日志里 05-30 和 05-31 两天一条都没有。
这四天在干什么,看改动最多的文件就知道:
297 次 CHANGELOG.md
281 次 docs/releases/v0.1.0.md
219 次 tests/release_readiness_fixture.py
201 次 scripts/release_readiness_config.py
145 次 scripts/check_release_readiness.py
在做「开源发布就绪度检查」——写一套脚本,检查这个项目够不够格公开发布:密钥有没有泄漏、文件权限对不对、文档和代码一致不一致、release 流程能不能自动化。然后 AI 自己跑这套检查、自己修、自己提交,循环了四天。
提交信息的质量并没有因为自动化而下降。随便抽一个:
Harden permission repair path tightening
Constraint: open-source Alpha trust depends on local secret and runtime file
permission repair being safe to recommend publicly.
Rejected: keeping doctor repair on direct path chmod | leaves a separate
permission-tightening path outside the fd identity checks.
Confidence: high
Scope-risk: narrow
Tested: uv run python scripts/validate_local.py
Not-tested: live Telegram smoke and GitHub repository setting gates remain
external release-owner actions.
约束、否决了什么方案、信心、影响范围、测了什么、没测什么,全都在。480 个提交基本都是这个规格。
这是我在这个项目里见过的最像「工程」的一段,也是我参与最少的一段。
这四天的产物——那套 release readiness 脚本、docs/releases/v0.1.0.md——一个字都没进最终发布的仓库。原因见下一节。
顺便,这也解释了版本号的疑点:现在 GitHub 上第一个提交叫 Initial release: tgcc v0.8.0。0.1 到 0.7 不是不存在,是都在那 653 个被抹掉的提交里。
六、脱敏模块把密钥泄漏在了自己的测试里
2026-06-02 傍晚,我收到一封邮件。发信人 Robin,德国的安全工程师,业余扫 GitHub 上泄漏的密钥然后通知仓库主人。
Type: TelegramBotToken
Link: .../blob/2b55b5d5.../tests/test_sanitizer.py#L111
Committed on: 2026-05-25
This secret is still valid.
泄漏的位置是 tests/test_sanitizer.py 第 111 行。
sanitizer.py 是这个项目的日志脱敏模块,负责在把 Claude 的输出发回 Telegram 之前,把 token、API key、私钥替换成 ***。README 里「安全可见」那条卖点说的就是它。
test_sanitizer.py 是它的测试。为了测「Telegram token 会被正确打码」,需要一个 token 形态的字符串。当时用的是真的那一个。
一个专门防止密钥泄漏到日志里的模块,它的测试用例泄漏了密钥。
提交它的那个 commit,标题是 Prevent tgcc instance collisions and secret leakage,附带 Constraint: / Rejected: / Tested: 245 passed, 86% coverage 一整套纪律字段。
This secret is still valid 不是猜的——Robin 的报告里带着 bot 名称、能否加入群组、能否读取所有消息这些字段,那是拿 token 调 getMe 才拿得到的。从 05-25 提交到 06-02 报告,泄漏了八天。
我把邮件原样贴给 AI。它当天做完了代码侧修复:新增 tests/token_fixtures.py 在运行时构造一个语法合法但不可用的假 token,替换掉两个测试文件里的样例,再加 tests/test_repository_secrets.py 扫描所有已跟踪文件防止同类字面量再进来。543 个测试通过。这两个文件今天还在仓库里。
然后它说了一句我认为是整个项目里最重要的一句话:
代码修复不能让已经泄露的旧 token 失效。
接着列了三件必须我自己做的事:去 BotFather 轮换 token、更新部署环境、以及如果要清掉已经进入公开 git 历史的那一份,需要用 git filter-repo 或 BFG 重写历史并 force-push。
三天后,也就是 06-05,现在这个仓库的历史从 Initial release: tgcc v0.8.0 开始了。
而 GitHub 上这个仓库的 created_at 是 2026-06-05T09:48:55Z。 Robin 在 06-02 发来的链接指向的就是这个仓库路径,说明它当时已经存在。一个仓库不可能在自己被创建之前就有内容。所以这不是 force-push,是删库重建:旧仓库删掉,同名建一个新的,把清理过的代码作为 v0.8.0 推上去。
代价是 2 月到 6 月那 653 个提交跟着一起没了。GitHub 上现在只剩最后四天的 54 个,2 月 7 日那个 claude-tg package skeleton、5 月 19 日凌晨那 12 个提交、tmux 那条分支的全部工作,一个都不在了。
清掉泄漏的密钥历史这件事本身是对的,没有别的选择。但它顺手也把这个项目的成长过程删掉了 92%,而且是在我完全没意识到的情况下——我当时想的是「把 token 清干净」,不是「这会删掉四个月的开发史」。
删库重建的动机是我从证据反推的:泄漏的提交在远端已不可达、新仓库的创建时间晚于 Robin 的报告、AI 在报告当天就建议了重写历史。链条完整,但它不是记录——当时的会话日志里没有讨论过这个决定,我也不记得还有没有别的考虑。
七、最后两天:重构,以及决定不再重构
06-07 和 06-08 是会话日志缺失的那两天,内容靠 docs/dev/ 的 20 份文档还原。
安全加固(Phase 1):群聊改成默认拒绝——之前任何授权用户都能在任意群里触发运行,把 bypassPermissions 的输出流给全群;从 status.json 恢复的 session id 加了 UUID 校验才能进 claude --resume 的 argv;脱敏器补上 ANSI/OSC 转义序列、URL 里的凭据、HTTP Basic auth 头;发给 Telegram 的异常文本也要脱敏,防止泄漏路径。
Executor 重构(Phase 2):Executor.run 从 368 行拆到 91 行,减少 75%,提取出 6 个辅助方法,75 个测试全程通过。
这几个数字我回仓库核过一遍,因为它们的出处是 AI 自己写的完成报告,属于二手材料。git show 7be64b0^:src/claude_code_tg/executor.py 里 run 确实是 368 行(报告写的是 367),重构后 91 行分毫不差,6 个辅助方法(_build_claude_command、_handle_system_event、_handle_assistant_event、_handle_user_event、_process_stream_events、_build_execution_result)也对得上。
但同一次核对也翻出了报告没写的一件事:executor.py 这个文件从 911 行涨到了 1034 行。 方法短了 75%,文件长了 13.5%。
这才是拆分的真实账目。把一个 368 行的方法拆开,代码总量不会减少,反而要多付函数签名、参数传递和边界处理的开销;买到的是圈复杂度和可读性,代价是文件更长、跳转更多。报告里那句「圈复杂度(估计)~45 → ~8」是 AI 自己的估算,不是工具实测,我没有独立验证。
依赖注入(Phase 3.1):3 个 Protocol 接口(Executor / SessionStore / ConfigProvider)加一个 ServiceContainer,TGBot 支持容器注入且向后兼容。
然后停了。 docs/dev/phase3-task3.2-completion-report.md 里有一节标题就叫「为什么停止进一步拆分」:
现有 mixin 模式(TGBot + BotCommandHandlers + BotMessageProcessor)已经是良好的组合。 BotCommandHandlers 依赖 20+ 个 TGBot 属性,强行提取会降低可读性。 参考阶段 2 经验:「认识何时停止很重要」。 边际收益递减。
原计划里还有 3.3 统一配置管理、3.4 抽象状态持久化层、3.5 命令注册框架,全部标成「优先级:低」,没做。
这是整个项目里我觉得最成熟的一个决定。前面有 tmux 那条做到尽头才发现是死路的分支,有 480 个提交最后一并作废的 autopilot,到这里终于是在收益还是正的时候主动停下。
八、做出来的东西,和它的结局
到 06-08 停下来时:
| 源码 | 11016 行 / 39 个文件 |
| 测试 | 15029 行 / 50 个文件 / 775 个测试函数 |
| 文档 | 32 篇 / 7887 行 |
| 发布 | GitHub 5 个 tag,PyPI 2 个版本 |
测试代码比源码多 36%。
分发这边,数字是 2026-09-03 当天抓的:
GitHub:0 star,0 fork,0 watcher。一共两个 PR,都是 dependabot 的依赖升级。零个真人 issue,零个真人 PR。
PyPI:累计 403 次下载(不含镜像),含镜像 1378 次。403 看着比 0 好看,拆开就没了:06-05 发布当天 144 次,06-07 发 0.8.4 当天 107 次,两天占 62%;剩下是每天 5 到 9 次的平铺长尾,一直延续到今天——项目 6 月就停了,下载量几个月不变,这种平滑本身就是机器的签名。含镜像是不含镜像的 3.4 倍,指向同一个结论。
近 14 天 GitHub traffic 是 0 次页面访问。但这个窗口是 8 月 20 日到 9 月 3 日,离活跃期已经三个月,它说明「现在没人看」,不说明「发布时没人看」——GitHub traffic 只留 14 天,发布当时的数据我没抓,现在拿不到了。
整个项目周期里,唯一一个主动联系我的真人是 Robin。为了告诉我我泄漏了密钥。
日志里最后两条人类输入,06-09 凌晨:
00:53 深度调研 marvis 这个产品,能否实现和当前项目里的类似功能
01:14 能否做一个工具,在 Marvis 里运行,这个工具支持和 Claude code 对话
打完 v0.8.4 的第二天,我已经在看能不能换个平台重做一遍。之后 git 上只剩 06-18 一次 dependabot 的自动提交。这个项目我不再维护,也不再自己用。
这两条消息本身就是答案的一部分:我要的从来不是 Telegram,是离开电脑之后还能继续推进工作。壳可以换,需求不会变。tgcc 只是这个需求当时能找到的最快实现。
九、四个月学到的
一、抓屏的坑在数据源,不在解析器。 前三个截断 bug 我都修好了,第四个修不好,因为渲染中间态是抓屏方案的固有属性。判断标准很简单:如果你消费的是「给人眼看的最终呈现」,那所有中间态、样式、截断都会变成你的问题;能拿到结构化的上游数据就一定去拿。换掉数据源之后,那一整类 bug 直接消失了。
二、认识何时停止。 这个项目有三次「做到尽头」:tmux 分支做到能用才发现不该合并,autopilot 提交 480 次最后全部作废,Phase 3 拆到一半主动收手。只有第三次是划算的。前两次的共同点是,我在过程中从来没问过「继续做下去,边际收益还是正的吗」。
三、AI 能改代码,改不了世界的状态。 密钥泄漏那次,AI 在几分钟内完成了代码修复、加了防回归测试、跑完 543 个测试。但它不能替我去 BotFather 轮换 token——那一步必须我自己做。凡是需要在真实世界里改变状态的事(轮换凭据、删仓库、点授权按钮),都还是人的责任,而这些往往才是止损的关键动作。
四、AI 写的完成报告,每句都真,但只说好看的一半。 那 20 份 docs/dev/ 文档是 AI 给自己写的成绩单。我核过其中一份:run 方法 368→91 行属实、6 个辅助方法属实,但它没提文件整体从 911 行涨到了 1034 行,圈复杂度那个数字它自己标了「估计」。没有一句假话,被省掉的是不利的那一半。这类文档拿来还原「当时在想什么」很好用,当数据源就得自己回去验。
十、查不到的
这个项目花了多少 AI 费用。 没有按项目记账,Codex 和 Claude Code 的用量混在订阅里,拆不出来。
为什么 03-15 之后停了两个月,05-19 又重新开始。 git 上是干净的空档,日志里也没有。
除我之外有没有人真的用过。 0 star、0 issue、0 PR,403 次下载里绝大部分是机器。但「没有证据有用户」和「确定没有用户」不是一回事,PyPI 不告诉我下载的是谁。