用一盏交通灯显示 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) # 错
但 floor 和 level 在协议里是按感知亮度描述的,两者差一个 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