三路飞书/CC 任务旁路观察站 — 把 Hermes 几十条 tool_call 中间态浓缩成「在干啥 / 进度 / 完工 / 等谁拍板」实时卡片墙。
爸爸跟 Hermes 在飞书聊天,Hermes 每接一句话要刷几十条 terminal: / write_file: / browser_*: 中间态。结论被噪音淹没,看不出真正在做什么、是否已完成、有没有在等回复。
起一只副驾驶守护进程:旁路读 Hermes 的所有消息,每 5 秒让 haiku 提炼一次每个话题在干啥;Web 驾驶舱呈现卡片墙 + 浏览器语音播报关键里程碑。
家里还跑着两套并行 agent:Pi(另一只飞书 bot,自己一套 jsonl)、CC(Claude Code 本机派出的子任务)。同一套提炼/通知/卡片对它们都开,一张墙看三路。
10 次主要 commit,大体三个阶段。每段都是"先能跑通 → 再深化模块边界 → 再拓宽数据源"。
Hermes 网关里加 HERMES_WINGMAN_FANOUT=1 钩子,旁路把所有消息写一份到 jsonl。Daemon 起来 tail 它,落进 SQLite。最小驾驶舱单文件 HTML 直接 fetch /api/state 渲染表格。这阶段就一个目标:把"hermes 现在在做什么"从命令行 grep 变成一张可看的网页。
单文件 vanilla JS 涨到 2163 行,加任何东西都怕。重写到 React + Vite + TypeScript,构建产物 web/static/dist/ 入版本控制(launchd 没有构建步)。新增三件套:双栏布局(左卡片墙、右滑出详情)、关键里程碑 TTS 语音播报(去重 + boot grace)、本地 CC 会话浏览/transcript 阅读。后端只加了一个 transcript 路由,其余 API 完整保留。
把硬塞在 daemon/worker.py 里的 fanout 读取 / haiku 调 / 飞书发卡 / 完工通知拆成 4 个抽象 — Source / Summarizer / Notifier / Pipeline — 在 4 个 commit 里逐步迁出。从此新加一路数据源只需要写一个 Source 子类。Pi 和 CC 就是用这套抽象接进来的;haiku 跟 lark-cli 的所有调用都收口到了独立模块。
| 抽象 | 谁实现 | 职责 (一句话) | 不变量 |
|---|---|---|---|
| Source | HermesSource · PiSource · CCSource | 从某个 chat 后端拉新事件 (poll()) + 列当前活跃话题 (threads()) | 无 I/O 时序逻辑、无 sleep、不持自己的线程 |
| Summarizer | HaikuSummarizer | 给一段对话上下文,返回 SummaryResult (summary/verdict/milestones/changes/choices/done) | 2 模型 fallback、永不抛、parse 边界 sanitize |
| Notifier | LarkTextNotifier · LarkCardNotifier · _ComposeNotifier | 把一份 SummaryResult 投递到某个出口 (文字 / 卡片 / 复合) | idempotent;失败返 False → 下一 tick 重试 |
| Pipeline | shared/pipeline.py | 循环 tick:poll → record → plan → summarize → notify → apply | per-source / per-thread 异常隔离;不依赖具体 source/notifier |
| ThreadStore | shared/store.py | SQLite thread_state 表的唯一入口 (WAL + 独占锁 + JSON 列在边界解码) | 写必须经此;读返 typed ThreadState |
| Lark adapter | shared/lark.py | 所有 lark-cli subprocess 调用唯一入口 | 5 种失败统一抛 LarkError(kind, detail) |
tick 决策被刻意拆成两个纯函数 + 一个薄编排,让"误判 done / 漏锁"这类 bug 可单测:
| 模块 | 行数 | 角色 |
|---|---|---|
| daemon/main.py | 367 | 入口 · 只组装 Source/Summarizer/Notifier/Pipeline |
| shared/pipeline.py | 451 | 5s tick 编排 + 纯决策 |
| shared/sources/cc.py | 1141 | mcp-bridge HTTP + TTL cache + Source 抽象 |
| shared/sources/pi.py | 1013 | ~/.pi/agent 文件读 + 节流缓存 + dashboard 投影 |
| shared/sources/hermes.py | 430 | fanout tail + 文本提取 + 噪音过滤 |
| shared/summarizers/haiku.py | ~470 | 2 模型 fallback HTTP + prompt + 解析 + sanitize |
| shared/store.py | 286 | SQLite 唯一入口 (WAL + 锁 + 边界解码) |
| shared/lark.py | 135 | lark-cli subprocess 唯一入口 |
| web/app.py | 559 | FastAPI 6 个只读 + 2 个回复路由 |
| web/frontend/src/**.tsx | ~740 | App + Topbar + Wall + ActiveCard + Recede + Drawer |
| tests/** | ~1.2k | 62 用例 (Source/Summarizer/Notifier/Pipeline/Lark/MCP/CC) |
实时探测结果(下表数据来自刚才直接打 :7421 的 6 个 API + launchd list + tail wingman.log):
| 功能 | 实现位置 | 状态 | 说明 |
|---|---|---|---|
| Hermes fanout 消费 | HermesSource.poll | 活 | tail -F + 增量 readline,启动时 seek-to-end 不重放历史 |
| Pi 文件夹观察 | PiSource | 活 | /api/pi_state 返 2 topics (idle),summary 都已填 |
| CC 任务桥接 | CCSource | 活 | /api/mcp_bridge reachable:true · 12 tasks · 19 sessions |
| Haiku 提炼 (5s tick) | HaikuSummarizer + Pipeline | 活 | 主模型 claude-haiku-4-5;最近一次 haiku_call_error 已是修复前的旧日志 |
| Haiku 2 模型 fallback | _request_haiku_text | 已修 | fallback 改 claude-haiku-4-5-20251001 (中转不支持 3.5 系列),timeout 15→25s |
| SQLite 持久化 (WAL) | ThreadStore | 活 | /api/state 返 4 threads;web 只读不撞 daemon 写 |
| 飞书 done 文字通知 | LarkTextNotifier | 活 | enabled=True,完工瞬时一行 ✅ + 驾驶舱链接 |
| 飞书交互卡片 | LarkCardNotifier | env 关 | WINGMAN_CARD_ENABLED=0;send 拿到的 msg_id 没法回写 store(已知短板) |
| FastAPI 驾驶舱后端 | web/app.py | 活 | :7421 listening · /healthz ok |
| ETag + gzip + 304 | _json_with_etag | 活 | 5s poll idle 时基本零字节传输 |
| 三段式卡片墙 | Wall.tsx | 活 | 现在在忙 / 以前在忙 (≥3h 折叠) / 出错 (折叠) |
| 按 source 分组 | groupBySource | 活 | CC / Pi / Hermes 三色徽章,各自独立铺卡 |
| 右栏滑出详情 Drawer | Drawer.tsx | 活 | thread / pi / cc task / cc session / sessions browser / transcript 5 路由 |
| 关键里程碑 TTS 播报 | useSound + lib/sound.ts | 活 | 21 关键词 + offset_s/30 分桶去重 + boot grace |
| 本地 CC 会话浏览 | SessionsBrowser + scanTranscript | 活 | bridge 侧尾读 ≤512KB,double realpath 防穿越 |
| 飞书话题快捷回复 (按钮) | /api/respond · /api/pi_respond | 活 | 用户身份 lark-cli,Hermes 走 reply_in_thread,Pi 走 routes 锚 |
| 主话题旁路 | TICK_SKIP_THREAD_IDS | 按设计 | 主话题专用于收发卡片,tick 它会形成自我喂养套娃 |
| fanout 日志轮转 | — | 未实现 | Hermes 网关换 jsonl 文件后 daemon 不会自动跟,需手动重启 |
| launchd 自启 (mac) | deploy/*.plist | 活 | com.leway.wingman · com.leway.cockpit 都 listed |
| 测试套件 | tests/ | 62/62 | .venv/bin/python -m pytest 一句话过 (0.66s) |
现在 Notifier 是 fire-and-forget,新发的卡片 msg_id 没办法回到 ThreadStore — 下次 tick 又重发一张新卡。Pipeline 加一个 on_notify_success(thread_id, msg_id) 回调,store 多一次 update,就能把 WINGMAN_CARD_ENABLED 打开,真用上飞书原生卡片(比 Web 驾驶舱更"在飞书里"的体验)。
现在 Hermes 网关重启会换 jsonl 文件,daemon 还指着已删除的 inode,得人工 launchctl kickstart。给 HermesSource 加 inode 探测,每 N 次 poll 比对一次,变了就 reopen — 复杂度极小,免去一种"为什么没更新"的排查路径。
现在主 + fallback 一起出错只会跳过这一 tick,下 5s 又重试。撞流量高峰时容易刷爆 jsonl。给 Summarizer 加指数退避(同一 thread 连续失败 N 次进入 60s/300s 冷却),失败信息进 ThreadView 让前端能显示"haiku 暂不可用"。
现在主话题在 TICK_SKIP_THREAD_IDS 里完全不进 tick,导致里面聊的内容没有 summary。其实可以允许它进 record (落 SQLite),但 skip summarize,这样至少看板能看到 recent_actions,做未来"如果主话题被换"做准备。
daemon/main.py 把 TICK_SKIP_THREAD_IDS 同时传 HermesConfig 和 Pipeline,语义重复 — 防御性的好,但有飘移风险。改成只在 Pipeline 一层过滤,Source 不再要这个字段,语义集中、配置变少。
"看 Hermes 在干嘛" 这件事,从单文件 vanilla 跑通,到 React 重构,再到三路统一架构 — 现阶段已经是一个测试齐、文档齐、launchd 长跑稳定的小系统。haiku 中转网关那个 fallback bug 修完之后,主要功能全部能用,主要遗憾只剩 fanout 不跟轮转、飞书原生卡片回写未打通这两条比较具体的工程债。