用一盏交通灯显示 Agent 状态

起因

Agent 干活的时候人是闲的,于是注意力必然切走——去看文档、回消息、开会。等想起来切回终端,常常发现它在 5 分钟前就停下来等审批了。这 5 分钟是纯浪费,而且随着同时跑的 agent 变多,浪费是乘上去的。

问题的本质是:状态在屏幕上,而注意力不在屏幕上。软件通知(弹窗、声音、Bar 图标)都要求你先看向屏幕,解决不了这个问题。真正需要的是一个在视野边缘、不用聚焦也能感知的信号源。

所以:ESP32-C3 + 一个红黄绿交通灯模块,放在工位上。

状态源(本机 socket)→ daemon(Mac 常驻)→ BLE → ESP32-C3 → GPIO → 交通灯

板子用充电头独立供电,和 Mac 之间没有任何物理连接。带电脑离开工位,BLE 断开、灯自动熄灭;回来自动重连恢复。不用数据线,也不用连 WiFi。

关于状态源:我用的 agent 客户端在本机暴露了一个订阅接口,一次订阅就能拿到所有 agent 会话的聚合状态,不需要逐个 agent 挂 hook。这部分不是本文重点,换成 Claude Code 的 hook 往一个文件里写状态、daemon 去读,链路后面所有内容一字不用改。

状态与灯效

状态 含义 交通灯 板载 D5(链路灯)
waitingForApproval 等你审批工具调用 🔴 红灯快闪(300ms) 弱常亮
waitingForAnswer 等你回答问题 🔴 红灯呼吸(1.5s,全幅) 弱常亮
running Agent 正在干活 🟡 黄灯浅呼吸(4s,40%~100%) 弱常亮
completed 任务完成 🟢 绿灯呼吸(3s),5 分钟后自动熄灭 弱常亮
无活跃会话 ⚫ 全灭 弱常亮
Mac 不在 / daemon 没跑 ⚫ 全灭 慢呼吸(4s)
板子没电 ⚫ 全灭 ⚫ 全灭

最后三行是故障可辨识性的设计:交通灯全灭有三种可能的原因,靠一颗和交通灯走完全独立引脚的板载 LED 把它们区分开。后面「排查顺序」一节会讲这颗灯的价值。

设计一:视觉强度必须匹配紧迫程度

这是整个项目里唯一称得上「设计」的部分。

周边视觉(余光)对运动的敏感度远高于对亮度的敏感度。所以视觉强度的排序是:

快闪 > 全幅呼吸 > 浅呼吸 > 常亮 > 灭

不是按亮度排。 据此分配:

  • 两个 waiting 是 agent 卡住了在等你,强度必须压过 running。它们之间用快慢区分:快闪 = 去点确认,呼吸 = 去打字
  • running 什么都不用你做,却是持续时间最长的状态(可能几小时)。它需要满足两个矛盾的要求:能证明「系统还活着」,又不能抢注意力。

第一版 running 用了全幅呼吸,结果是最不需要关注的状态拿到了第二强的视觉效果,而需要你起身打字的 waitingForAnswer 反而是静态常亮——强度和优先级完全倒置。

修法不是把 running 改回常亮:常亮区分不了「正在跑」和「卡死了」,会丢掉活性证明。正解是给波形补一个振幅下限 floor,让呼吸在 [floor, level] 之间摆动而不是从 0 开始:

{"lights": {"yellow": {"level": 100, "floor": 40, "wave": "breath", "period": 4000}}}

floor=40 是实机对比 0 / 40 / 60 / 75 之后选定的:远看近乎常亮,凑近看得出在动,又不会像全幅呼吸那样暗到疑似熄灭。

顺带一提,level 一直是振幅上限,floor 补的是这个模型本来就缺的下限——属于模型的自然补全,不是为某个状态加的特例功能。加完之后「浅呼吸」「半亮闪烁」这些效果全部落回配置侧表达。

踩坑:floor 的 gamma 空间错位

人眼对亮度是近似对数感知,所以呼吸必须做 gamma≈2 校正,否则线性渐变看起来会「冲上去太快」。第一版的实现是:gamma 用在波形上,然后在 PWM 空间里插值。

duty = floor + (level - floor) * gamma(x)   # 错

floorlevel 在协议里是按感知亮度描述的,两者差一个 gamma。PWM 60% 看起来已经有全亮的约 80% 了,于是 floor=60 实际只压掉 20% 的可见摆动——表现就是「看着就是常亮」,参数完全不起作用。

正解是先把 floor 换算到 PWM 空间,再插值:

f = floor ** 2 / 100
duty = f + (level - f) * gamma(x)   # 对
floor(感知) 0 40 60 75
PWM 下限 0% 16% 36% 56%
可见摆动幅度 100% 84% 64% 44%

这个 bug 逃过了全部回归测试——测试比对的是 daemon 发出的 JSON,而 bug 在固件把 JSON 变成光的那一段。最后是靠人眼发现的。凡是最终输出是物理量(光、声、力)的项目,测试的覆盖边界都停在数字信号这一侧,剩下的只能靠人。

设计二:固件是灯效播放器,不是灯效实现

第一版固件的协议是 {"light": "red", "mode": "blink"}mode 是个枚举。想加一种效果就得改固件、拔线、重烧、重新装回工位。板子一旦挂上充电头放到工位上,重烧的成本就是整个项目里最高的一项操作

所以把固件降级成播放器:协议描述每盏灯在时间轴上怎么变化,固件只负责按描述驱动 PWM

{"lights": {"red":   {"level": 100, "wave": "blink",  "period": 300}}}
{"lights": {"green": {"level": 100, "wave": "breath", "period": 3000}}}
{"lights": {}}

每盏灯四个参数:

参数 取值 说明
level 0–100 亮度上限(PWM,16 位分辨率)
floor 0–level 亮度下限,默认 0。控制视觉强度的主旋钮
wave null / blink / breath 省略即常亮
period 毫秒 波形周期
phase 毫秒 相位偏移

这个模型的好处是新效果靠参数组合表达。跑马灯不需要固件支持——它只是三盏灯同波形同周期、phase 错开:

{"lights": {"red":    {"level":100,"wave":"blink","period":900,"phase":0},
            "yellow": {"level":100,"wave":"blink","period":900,"phase":300},
            "green":  {"level":100,"wave":"blink","period":900,"phase":600}}}

灯效是全量快照,未列出的灯自动熄灭。这一点后面讲心跳时还会用到。

链路指示灯是个例外:它必须在没有连接的时候工作,所以行为不能靠实时下发。做法是 daemon 连上时推一次配置、固件写进 flash:

{"config": {"link_connected":    {"level": 35},
            "link_disconnected": {"level": 35, "wave": "breath", "period": 4000}}}

之后断电重启也按这套行为走。改它只需要改 daemon 里的常量并重启 daemon,不用插线重烧固件

结果是:后续所有可预见的调整——改映射、调亮度、换波形、改链路灯行为——全部落在 Mac 这一侧。只有改引脚或加新波形类型才需要动板子。到现在为止,板子确实一直挂在充电头上没动过。

设计三:链路必须自愈

跨设备链路的失败模式比想象中多。三条机制配合:

机制 位置 作用
心跳(5s) daemon 每 5 秒无条件重发当前状态
看门狗(20s) 固件 超过 20 秒没收到写入就主动断开重新广播
断开即熄灯 固件 连接断开立刻全灭,避免灯停在过期状态

心跳能无条件重发,前提正是上面那条「灯效是幂等的全量快照」——重发没有任何副作用。这条设计一开始只是为了协议简洁,后来发现它是心跳和自愈能成立的基础。

踩坑:半开连接

实测中出现过 BLE 断开后 daemon 连续 20 分钟扫不到设备

直接诱因是当时跑了个 CPU 忙等把核心打满,daemon 的 asyncio 卡住,macOS 侧单方面判定链路超时断开。但真正的问题在板子那侧:它收不到 DISCONNECT 事件,BLE 栈就卡在「已连接」状态,永远不再广播。daemon 再怎么扫也扫不到。

只靠一侧重连无法恢复半开连接,这是必修项而不是优化项——Mac 睡眠、突然断电、高负载都会重现。修复必须两侧配合:daemon 定期重发喂看门狗,固件超时就主动 gap_disconnect 触发重新广播。实测 20.3 秒触发,广播正常恢复。

心跳还附带一个好处:板子意外重启后不需要任何握手,5 秒内自动回到正确状态。

踩坑:bleak 的 connect / write 没有默认超时

macOS 的 CoreBluetooth 在链路异常时可能永远不返回。实测踩过一次:某次写入挂起后,负责 BLE 的协程彻底静默——既不重连也不报错,进程还活着、状态订阅照常,但灯不再更新。而固件那侧的看门狗已经正确断开并重新广播了。

结论:跨设备的 IO 必须自带超时,且两端的保护要对称。一端有看门狗另一端没有超时,等于没有。

日志上的判据是这两行必须成对出现:

[14:41:34] dominant=running sessions=2 -> {"lights": {...}}   ← 算出了新状态
[14:41:35] BLE ← {"lights": {...}}                            ← 真的发出去了

只有前者没有后者,就是 BLE 协程卡死了。

关于状态源的两条实测结论

这两条是拿真实环境探出来的,直接改变了设计:

1. 订阅之后不推历史状态。 订阅成功后要等下一次状态变更才有事件,空闲 25 秒的探针收到 0 个 event。所以 daemon 启动时会主动写一次熄灯来确立已知初态,否则灯会停在固件上电自检的收尾状态上。

2. completed 之后不再推任何事件。 实测完成后空闲 3 分 47 秒零 event,状态一直停在 completed。不加定时器的话,绿灯会通宵长亮。解法是 daemon 侧加一个超时定时器,默认 300 秒后主动熄灯,与固件无关。

顺带一条:事件频率比预期高,一次工具调用会推 2~3 个事件。daemon 按最终灯效 payload 去重,实测 13 个事件只产生 1 次下发。去重要按最终输出而不是按输入事件做,才能吃掉所有语义等价的抖动。

硬件部分的坑

板子是 CORE-ESP32-C3 的 USB 直连款(Type-C 走 ESP32-C3 原生 USB-Serial-JTAG,免驱动,串口设备名形如 /dev/cu.usbmodem*)。代价是 GPIO18/19 被 USB 占用

交通灯模块是四针一体排线 GND R Y G(共阴,板载限流电阻,高电平点亮),所以要挑连续四孔。最终用管脚 01–04:

管脚 丝印
01 GND GND
02 IO00 R 红
03 IO01 Y 黄
04 IO12 G 绿

绝对不要接 IO18 / IO19——那是 USB 的 D-/D+。实测踩过:接上之后板子直接从 Mac 消失,/dev/cu.usbmodem* 不见了,烧录和 REPL 全断。拔掉即恢复,不会永久损坏。同样要避开 IO14~17(SPI Flash,接了会真变砖)和 IO02 / IO08 / IO09(strapping,影响启动)。

讽刺的是,板子上看着「连续又顺手」的那组 IO12 / IO18 / IO19 / GND,中间两个恰好就是 USB。顺手的排列和安全的排列是两回事,接线前必须查 pinout。

排查顺序:先物理层,再怀疑代码

这个项目一共栽过两次「灯不亮」,两次都是物理原因,两次都先去查了代码。

第二次的现象是:daemon 日志完全正常(BLE 已连接、写入成功、状态推送无误),板子也不广播(说明它认为自己已连接),但灯就是不亮。我顺着软件路径推理了很久——「什么情况能让 BLE 活着但灯不动」,猜到主循环停摆。

真实根因是一根杜邦线断芯了。 所有观测信号都健康,恰恰是因为软件侧确实一切正常。

这就是板载 D5 那颗链路灯的价值——它和交通灯走完全独立的引脚,于是能一眼分开三种「全灭」:

交通灯 板载 D5 结论
不亮 行为正常 固件和链路都活着 → 查交通灯这一路的接线
不亮 慢呼吸 板子活着但没连上 → 看 daemon 是否在跑、Mac 是否在旁边
不亮 全灭 板子没通电 → 查充电头、线、插座
亮但颜色错 正常 接线插错孔

(充电头这条也踩过:PD 快充可能因为负载太低直接断输出。)

硬件项目的排查顺序应当是:物理连接 → 供电 → 固件 → 上位机。 软件层的深挖必须放在物理层排除之后——不是因为物理故障更常见,而是因为排除它的成本低得多:拔插一根线 10 秒钟,而读代码猜测半小时起步。

推论是:设计阶段就该留出能区分故障层的独立观测点。这颗成本几乎为零的板载 LED,比后来所有的日志都更快地定位了问题。

常驻化

launchd 一份 plist,登录即启动、崩溃自动拉起:

cp launchd/com.kyson.agent-light.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.kyson.agent-light.plist

launchctl list | grep agent-light   # 第一列 PID,第二列上次退出码
tail -f /tmp/agent-light.log

一个容易卡住的点:launchd 拉起的后台进程首次访问蓝牙时,授权弹窗可能弹不出来。如果日志里一直是「未发现设备」但手动跑同一个脚本却正常,去「系统设置 → 隐私与安全性 → 蓝牙」里检查并放行。

回头看

代码量不大(daemon 240 行,固件 300 行,测试 140 行),硬件成本几十块。真正花时间的是两件事:把「紧迫程度」翻译成「视觉强度」的那套映射,和让链路在各种异常下自愈

前者是这个项目唯一不能靠抄解决的部分——因为它的评价标准在人眼里,不在代码里。后者则完全是常识:任何跨设备链路都必须假设对端会以最难看的方式消失。

至于效果:现在余光扫到黄灯在慢慢呼吸,就知道不用管;红灯一闪,起身两步就过去了。

关键词: 技术 esp32 micropython agent