深夜提醒

现在是深夜,建议您注意休息,不要熬夜哦~

🏮 🏮 🏮

新年快乐

祝君万事如意心想事成!

share-image
ESC

把 ConnectBot 改造成"远程 Agent 聊天室":一个移动端操控 Codex 的实战记录

一台穿透内网的 SSH 客户端,一套跑在服务器上的 AI 编程助手,一个像微信一样的聊天界面——把它们拼在一起,就是这篇博客的主角。

背景:为什么要把 ConnectBot 改造成聊天工具

我的开发机在家庭内网,没有公网 IP。之前用 FRP 把 SSH 穿透出去,手机上装 ConnectBot 就能随时连回来敲命令。但”敲命令”这件事,在手机上体验很一般:终端键盘小、输出要手动翻、长任务挂着心里没底。

而 Codex、OpenCode、Claude Code 这些 AI 编程助手,本质都是”给一句话,它在服务器上干活,然后流式输出结果”。这不就是一个天然的聊天场景吗?

坦白说,做这个项目最直接的动机有点懒:不管在外面还是在家,我都越来越嫌弃打字累。现在限制 AI 发挥的根本不是模型能力,而是人类的输入速度——一句话要想半天、敲半天,等 agent 跑完再琢磨下一句,一来一回效率全耗在打字上了。所以我想要的不是”更好的终端”,而是把”跟 AI 交流”这件事变得像发微信一样轻:想到什么说什么,怎么随意怎么来。

于是想法很直接:在 ConnectBot 里加一个像微信一样的对话框,连上 SSH 后默认进入聊天界面,选择用哪个 agent,发一句话,agent 在远程服务器上执行并实时把结果推回来。 终端保留,一键切换。

把手机变成 Agent 的远程遥控器

架构:一条 SSH 连接,两个通道

聊天功能不能影响原来的终端会话,所以方案是在已认证的 SSH 连接上再开一个独立的 exec 通道,专门跑 agent。FRP 穿透、主机密钥校验、认证全部天然继承,不用重复造轮子。

手机 App ──SSH exec 通道──▶ 远程 helper 脚本 ──▶ codex / opencode / claude
▲ │
└──────── 行协议(实时流) ───────┘

远程端不需要预装任何东西:App 启动时把一段 helper 脚本用 base64 内嵌进 bash -c 命令,SSH 连上后写盘再执行。helper 脚本维护一个简单的行协议:

请求: SEND|<agent>|<session>|<cwd>|<msg>
应答: READY | STATUS | OUT | ERR | DONE|<exit>|<session-id>

每个字段用 base64 编码,避免中文和特殊字符破坏协议。agent 的输出被实时解析成 OUT 事件,App 端用 Flow 流式更新聊天气泡,效果和微信里对方正在输入一样。

会话续聊:让 agent”记住”上次的对话

聊天工具的灵魂是上下文。每次执行完,helper 会把 agent 返回的 session id 带回 App,存进 SharedPreferences(按”主机 + agent”分 key)。下次发消息时带上 session id,codex 用 resume <id>、opencode 用 --continue、claude 用 --resume <id> 续聊。

这里踩了一个大坑,后面细说。

踩坑记录(每一坑都值得写)

坑 1:微信输入法弹出来是”透明的”

手机默认输入法是微信输入法,但在 App 里一点输入框,键盘”弹了”却什么都看不见——系统报告键盘已显示,屏幕上只有一片空白。

排查过程:

  • dumpsys input_method 显示 IME 已绑定、mIsInputViewShown=true,但 IME 窗口是 fmt=TRANSPARENT,微信键盘的视图根本没画出来;
  • 切到系统 AOSP 键盘一切正常,说明是微信输入法和这个 App 的窗口环境不兼容;
  • 对比窗口属性发现,App 的窗口 soft input mode 变成了 adjust=pan(edge-to-edge 下删掉 adjustResize 后的系统默认),而其他正常的 App 是 adjust=resize
  • 恢复 windowSoftInputMode="adjustResize" 后,微信键盘正常渲染。

结论:edge-to-edge 下 adjustResize 会被忽略,删掉 manifest 里的声明反而会让系统退化成 adjust=pan,某些 IME(比如微信)在这种窗口下会渲染空白。

坑 2:输入框被键盘”顶上去”再也回不来

这是最折磨人的一个 bug:键盘收起后,输入框停在屏幕中间,下面一大片空白。

先怀疑 IME insets 残留——确实,系统在键盘收起后 visible=false 但 insets hint 还是 1219px 不清零(LineageOS 上微信输入法的问题)。于是轮询 insets、按 isVisible 判断,padding 确实归零了,但输入框还是卡在中间

最后用日志定位到 Compose 布局本身:

SelectionContainer {
LazyColumn(Modifier.weight(1f)) { ... } // weight 在这里失效!
}

weight(1f)ColumnScope 的修饰符,但 LazyColumn 外面包了一层 SelectionContainer,weight 根本不生效,列表按内容高度而不是撑满剩余空间,输入框自然被顶在中间。把 weight 移到 SelectionContainer 上就正常了。

坑 3:codex 一直”执行失败”,而且每次都是新会话

症状:消息发出去,codex 显示”开始执行”后马上失败;就算成功了,下次又是全新会话,之前聊的全不记得。

日志揭示了完整链条:

  1. App 保存的会话 ID 01a03ad0 恰好是终端里另一个 codex 进程正在写的会话,resume 时被 thread-store conflict: active writer 锁住;
  2. helper 想检测这个失败并回退新会话,但管道是 printf | codex | python | streamPIPESTATUS[0] 取到的是 printf 的退出码(永远 0),codex 失败被当成成功;
  3. 修好退出码后,回退逻辑依赖 grep stderr 里的 active writer,但旧版 codex 的错误在 stdout 的 JSON 流里,还是抓不到;
  4. 最终改为”resume 失败就无条件开新会话”,终于通了;
  5. 但新会话的 ID 又取错了——extract_codex_session_id 从”最新修改的 rollout 文件”提取,而终端里那个活跃进程一直在写旧会话文件,永远提取到旧 ID,下次又撞锁,死循环。

最终方案:从 codex 新会话的 thread.started 事件的 thread_id 直接取 ID,彻底绕开”猜最新文件”。

会话续聊失败自动回退

坑 4:系统默认输入法”自己变回英文”

用户明明在系统设置里选的是微信输入法,打开 App 却是英文键盘。查代码,App 里没有任何指定输入法的逻辑——Android 上 App 无权选输入法。

真相:ColorOS/LineageOS 在默认输入法进程被强停后,会把默认重置回系统英文键盘。之前调试键盘空白时反复强停微信输入法进程,触发了这个重置。

最终效果

连上 SSH 后默认进入聊天界面,顶部是 agent 选择(Codex / OpenCode / Claude Code)和执行状态,中间是对话流,底部是输入框:

聊天主界面

  • 支持 codex / opencode / claude 三种 agent,自动探测版本兼容性;
  • 实时流式输出,agent 的思考和工具调用都能看到;
  • 会话按主机 + agent 持久化,下次自动续聊;
  • 一键切回经典终端;
  • 新会话 ID 从 thread.started 事件精确提取,不会被其他进程干扰。

写在最后

这个项目最有价值的部分不是功能本身,而是踩坑的过程:IME insets 在不同 ROM 上的行为差异、Compose 作用域修饰符的隐蔽失效、SSH exec 环境与本地 shell 的 PATH 差异、多进程会话锁竞争……每一个问题都逼着你去读系统状态、加日志、验证假设。

工具链:Kotlin + Jetpack Compose + trilead SSH + FRP,helper 脚本是纯 bash + Python 过滤器。如果你也想在手机上远程操控自己的 AI 助手,希望这篇记录能帮你少踩几个坑。

文章作者:阿文
文章链接: https://www.awen.me/post/connectbot-agent-chat.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿文的博客

评论

0 条评论
😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️
加载中...

留言反馈

😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️