noni:让 agent 驱动交互式 CLI

· 2335字 · 5分钟

最近在 sandbox 里跑 agent 的时候,反复被一类问题卡住:gh auth loginssh-copy-idnpm publishnpm 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,每次调用就是一次 RPC
  • nonid 是 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。