最近在 sandbox 里跑 agent 的时候,反复被一类问题卡住:gh auth login、ssh-copy-id、npm publish、npm login 这种命令,几乎全都需要人坐在终端前面按几下键。Agent 跑到这一步就僵住了。我做了个小工具叫 noni 来解决这件事,开源在 GitHub 。
一开始踩的坑 🔗
最早我的想法很朴素:让 agent 自己去驱动 PTY 不就行了?拿 script / expect / 直接 fork 个伪终端读写。
试过几轮就发现这条路对 agent 不友好:
- ANSI 转义序列、光标移动、清屏指令混杂在输出里,agent 要先 strip 一遍才能看懂"现在屏幕上是什么"
- TUI 程序(
gh,fzf,k9s)会查询终端能力(OSC 11 背景色、DA1 设备属性),不应答它就死等 read -s这种密码输入是把 termios 的 ECHO 关掉,agent 光看屏幕看不出区别- 每个 CLI 写一个 expect 脚本,没法复用
更本质的问题:agent 的 context 是稀缺资源。让它边读 ANSI 字节流边做状态机,token 烧得心疼,且不稳。
换了个思路 🔗
不让 agent 看原始字节流,给它结构化的 prompt 信号:
agent: noni run "gh auth login"
noni: {id: abc, status: waiting_input, prompt: {type: select, options: [...]}}
agent: noni key abc enter
noni: {id: abc, status: waiting_input, prompt: {type: yesno, default: "y"}}
agent: noni input abc Y
noni: {id: abc, status: waiting_input, prompt: {type: password, echo: false}}
agent: noni secret abc --env GH_TOKEN
noni: {id: abc, status: exited, exit_code: 0}
Agent 看到的是 prompt.type —— 是 select 还是 yesno 还是 password —— 而不是一屏花花绿绿的 ANSI。它直接根据类型决定下一步发什么键。
整个工具我想做的事就两件:让 agent 看清楚 + 让它安全输入。决策权完全在 agent 这边,noni 不替它做选择。
怎么"看清楚" 🔗
prompt 类型按几个不同强度的信号来判断:
| 类型 | 信号 | 置信度 |
|---|---|---|
password |
termios ECHO 关掉了 | 0.99 |
yesno |
匹配 (y/n) [Y/n] (yes/no) 模式 |
0.9 |
select |
找到 > ❯ * 标记 + 缩进选项块 |
0.85 |
input |
行尾 : ? > |
0.7 |
unknown |
1s idle 还匹配不上就 fallback | 0.0 |
最准的是 password —— termios 状态是协议级别的真信号,不会被 false positive。中间几档是文本模式匹配,会有边界 case,但加上 confidence 字段交给 agent 自己判断就行。
匹配到 unknown 也不是失败,只是 detector 没把握,agent 拿到 screen 字段自己看屏幕就好。这个 fallback 很关键:与其让 detector 瞎猜,不如承认不知道,让上层 LLM 看一眼。
我一开始 detector 直接看原始 PTY 字节,结果死得很惨 —— 光标乱跳、TUI 重绘、ANSI escape 全混在一起。后来换成 hinshun/vt10x 维护一个虚拟终端,detector 看的是"屏幕渲染稳定后的样子",立刻干净多了。这是中间最有用的一个重构。
怎么"安全输入" 🔗
最早的设计是 noni input <id> <text> 直接发文本。问题来了:token 怎么传?写到命令行参数里 → agent 的 RPC log 就有了 → context 里就有了。
后来加了 noni secret <id> --env VAR —— 它读的不是 agent 那边的环境变量,而是 daemon 进程的环境变量。token 你用 GH_TOKEN=ghp_xxx nonid & 启动 daemon 时塞进去,整个 RPC wire 上从来不会出现这个 token,agent 的 context 也看不到。
这个设计是协议级别的硬保障。写在文档里说"请不要把 token 放参数里"那种约定型方案,agent 一旦聪明过头就会绕过去。把它做成 daemon 才能解开的密封信封,就稳了。
架构 🔗
noni (CLI, 一次一调) ──JSON-RPC over Unix socket──▶ nonid (常驻 daemon)
│ PTY
▼
child process
noni是 stateless CLI,每次调用就是一次 RPCnonid是 daemon,持有所有 PTY session- socket 在
$XDG_RUNTIME_DIR/noni/sock,权限 0600,没有 TCP listener - Daemon 由 CLI 首次调用时自动 spawn,agent 不用关心
为什么这么分?因为 PTY session 必须有人持续读(缓冲会满),而 agent 是回合制的 —— 它发一条命令,等几秒,再发一条。中间这段时间得有人替它收着 child 的输出,这个角色就是 daemon。
CLI 之所以 stateless,是为了让任何 agent / 任何脚本都能驱动它,不依赖 SDK,不依赖长连接。一个 bash 就能驱动整个流程。
实测 🔗
我让一个 agent(Gemini 在 web 容器里跑)从零做了这件事:clone 仓库 → make build → 装 gh cli → noni run -- gh auth login → 一路按键完成 device flow 登录。
整个过程它发了 14 个 RPC:
- 1 次
run启动 - 1 次
wait --until prompt阻塞到首屏稳定 - 3 次
key enter+wait --until prompt(穿过 host 选择 / 协议选择 / yesno 三屏) - 看到 device code
BDBE-34E8,把链接和码丢给我去浏览器授权 - 等我说 done 之后再
key enter+wait --until exit
完整跑通。有意思的是这个 agent 完全没用 noni status 读结构化的 prompt 字段,它一直在 noni read --raw 看屏幕文本,自己做视觉分类。也能跑通 —— 这给了我一个反向信号:结构化 prompt 信号是优化项不是必需品,现代 LLM 在屏幕文本上做分类完全 hold 得住。这反而让我对工具的鲁棒性更有信心了。
但 noni secret 这条路就是 agent 替代不了的 —— 不管它多聪明,token 不在它的 context 里就是不在。这是协议层硬保障。
一些边界 case 🔗
不是所有 CLI 都能完美驱动。gh / survey 库依赖 OSC 查询应答,目前 vt10x 不会回这些 query,会卡住一些屏。这次实测我自己手动一步步跑碰到了,但 agent 节奏快反而绕过去了。这块计划在 M3-M4 加一个 OSC/CSI 应答层,把常见 query (DA1/DA2/cursor position/OSC 11) 做掉。
macOS 早期 termios 已经接了,但只在 Linux 上跑过 CI。这次实测发现一个 doctor 自检的小 bug —— 硬编码了 /bin/true,在 mac 上是 /usr/bin/true,已经修了。
Windows 不在 v0.1 计划里,PTY 那一套差太远。
安装 🔗
brew install williamwa/tap/noni
# 或
go install github.com/williamwa/noni/cmd/noni@latest
go install github.com/williamwa/noni/cmd/nonid@latest
Go 单文件,无运行时依赖。还在 v0.1.0,Linux 主测,macOS 能跑。
一点感想 🔗
做这个工具的过程里我反复在调整一个边界:多少东西交给 agent,多少东西工具替它做。
最早我倾向于把"判断这是哪种 prompt、该按什么键"也做进 noni —— 让它给出一个 “next_action” 建议。后来想清楚了:那是越权。agent 看到的上下文比 noni 多得多(用户意图、历史交互、其他工具状态),让 noni 替它决定按什么键,反而让 agent 失去主动权。
现在的边界很清楚:noni 给"事实"(屏幕上是什么 + 这是什么类型的 prompt),agent 做"决策"(要发什么)。这个边界在用 LLM 做 agent 的场景下特别重要 —— 你的工具应该让 agent 更聪明,而不是替它思考。
仓库地址:https://github.com/williamwa/noni ,欢迎拍砖、issue、PR。