已上线 · launchd 62 / 62 测试通过 arch-refactor v0.4 · 2026-06-14

僚机 · Wingman

三路飞书/CC 任务旁路观察站 — 把 Hermes 几十条 tool_call 中间态浓缩成「在干啥 / 进度 / 完工 / 等谁拍板」实时卡片墙。

一、业务视角:解决什么问题

痛点
"我看不到 bot 在干嘛"

爸爸跟 Hermes 在飞书聊天,Hermes 每接一句话要刷几十条 terminal: / write_file: / browser_*: 中间态。结论被噪音淹没,看不出真正在做什么、是否已完成、有没有在等回复。

提案
"专门派个副驾驶看进度"

起一只副驾驶守护进程:旁路读 Hermes 的所有消息,每 5 秒让 haiku 提炼一次每个话题在干啥;Web 驾驶舱呈现卡片墙 + 浏览器语音播报关键里程碑。

三路统一
"不止 Hermes 一只 bot"

家里还跑着两套并行 agent:Pi(另一只飞书 bot,自己一套 jsonl)、CC(Claude Code 本机派出的子任务)。同一套提炼/通知/卡片对它们都开,一张墙看三路。

业务流程:一条消息从飞书到卡片

sequenceDiagram autonumber participant 爸 as 爸爸 (飞书) participant H as Hermes 网关 participant FN as fanout.jsonl participant D as Wingman daemon participant Hk as Haiku (中转) participant DB as SQLite participant W as 驾驶舱 :7421 participant L as 飞书 done 通知 爸->>H: "帮我整理这场会议纪要" H-->>FN: 旁路写入每条 in/out 消息 H-->>爸: 实时回 (tool_call + 文本) D->>FN: tail -F 读新行 D->>DB: upsert thread_state.recent_actions Note over D: 每 5s tick 一轮 D->>Hk: 提炼 (summary/verdict/milestones/changes/choices/done) Hk-->>D: JSON 结构化结果 D->>DB: apply_tick alt 完工瞬时 D->>L: ✅ 文字通知 + 驾驶舱链接 @dora end W->>DB: 浏览器 5s poll /api/state W-->>爸: 卡片墙 + 关键里程碑 TTS 语音

墙面长什么样

┌──────────────────────────────────────────────────────────────────────┐ │ 僚机 · running 2 · waiting 1 · ●online · 🔊 ──── 会话浏览 │ ├──────────────────────────────────────────────────────────────────────┤ │ │ │ 现在在忙 · 3 │ │ ┌────────┐ ┌────────┐ ┌────────────────────────────┐ │ │ │ Hermes │ │ Pi · 1 │ │ CC · 1 │ │ │ ├────────┤ ├────────┤ ├────────────────────────────┤ │ │ │ ● 在跑 │ │ ● 静默 │ │ ● 运行中 正在执行 Edit │ │ │ │ 会议..│ │ 关 4.. │ │ wingman · 派出的任务 │ │ │ │ summary│ │ summary│ │ 24 工具调用 · 18K tok · $0.42│ │ │ └────────┘ └────────┘ └────────────────────────────┘ │ │ │ │ 以前在忙 · 4 [展开 ▾] │ │ 出错 · 0 │ │ │ │ ──── 完工/归档 (Recede) ──── │ │ [✓ 已完成 · 11h 前] [✓ idle · 24h 前] ... │ └──────────────────────────────────────────────────────────────────────┘

二、是怎么一步步搭起来的

10 次主要 commit,大体三个阶段。每段都是"先能跑通 → 再深化模块边界 → 再拓宽数据源"。

阶段 1让一条消息走通 (v0.1 - v0.2)

Hermes 网关里加 HERMES_WINGMAN_FANOUT=1 钩子,旁路把所有消息写一份到 jsonl。Daemon 起来 tail 它,落进 SQLite。最小驾驶舱单文件 HTML 直接 fetch /api/state 渲染表格。这阶段就一个目标:把"hermes 现在在做什么"从命令行 grep 变成一张可看的网页

阶段 2驾驶舱重构成 React (v0.3)

单文件 vanilla JS 涨到 2163 行,加任何东西都怕。重写到 React + Vite + TypeScript,构建产物 web/static/dist/ 入版本控制(launchd 没有构建步)。新增三件套:双栏布局(左卡片墙、右滑出详情)、关键里程碑 TTS 语音播报(去重 + boot grace)、本地 CC 会话浏览/transcript 阅读。后端只加了一个 transcript 路由,其余 API 完整保留。

阶段 3三路统一架构 (v0.4 · 当前)

把硬塞在 daemon/worker.py 里的 fanout 读取 / haiku 调 / 飞书发卡 / 完工通知拆成 4 个抽象 — Source / Summarizer / Notifier / Pipeline — 在 4 个 commit 里逐步迁出。从此新加一路数据源只需要写一个 Source 子类。Pi 和 CC 就是用这套抽象接进来的;haiku 跟 lark-cli 的所有调用都收口到了独立模块。

# 4 commit 推进顺序 (commit 1/4 → 4/4) c399bf0 commit 1/4 Source/Summarizer/Notifier 骨架 + Pipeline 空壳 f8eea98 commit 2/4 HermesSource + HaikuSummarizer + Lark 通知器 + 新 daemon entry 23db6eb commit 3/4 PiSource + 删 web/pi_summarizer.py + 合并 shared/pi.py 6dda98b commit 4/4 CCSource + 删 daemon/worker.py + shared/bridge.py + shared/haiku.py 572eb74 arch fix Pi/CC 事件不再写 Hermes 的 SQLite — 看板不再撞车 03f5577 feat+fix 卡片墙三段式分组 + haiku 中转网关 fallback

三、技术视角:现在的形状

3.1 进程拓扑

flowchart LR subgraph 外部 L[飞书云] Hg[Hermes 网关
~/.hermes/hermes-agent] Pi[Pi 飞书 bot
~/.pi/agent] Br[mcp-bridge
:8787] An[Anthropic 中转
api.eagle.openclaws.co.uk] end subgraph "本机 launchd" D[com.leway.wingman
daemon/main.py
纯 stdlib] W[com.leway.cockpit
web/app.py
uvicorn :7421] end DB[(SQLite WAL
~/.hermes/wingman.db)] FE[React + Vite
web/static/dist/] Browser[浏览器驾驶舱] L --> Hg Hg -->|fanout jsonl| D Pi -->|读 jsonl/json| D Br -->|HTTP /api/state| D D -->|提炼 haiku| An D -->|done 通知| L D --> DB Pi -->|读 jsonl/json| W Br -->|HTTP| W DB -->|只读| W W -->|静态文件| FE FE --> Browser Browser -->|5s poll| W Browser -->|按钮回复| W W -->|lark-cli 用户身份| L classDef daemon fill:#f6efd9,stroke:#c96442,color:#2a2620; classDef store fill:#fff,stroke:#5b6e3a,color:#2a2620; classDef ext fill:#ede6d0,stroke:#857a5a,color:#4d463a; class D,W daemon class DB,FE store class L,Hg,Pi,Br,An,Browser ext

3.2 关键抽象 — 一张表读完

抽象谁实现职责 (一句话)不变量
SourceHermesSource · PiSource · CCSource从某个 chat 后端拉新事件 (poll()) + 列当前活跃话题 (threads())无 I/O 时序逻辑、无 sleep、不持自己的线程
SummarizerHaikuSummarizer给一段对话上下文,返回 SummaryResult (summary/verdict/milestones/changes/choices/done)2 模型 fallback、永不抛、parse 边界 sanitize
NotifierLarkTextNotifier · LarkCardNotifier · _ComposeNotifier把一份 SummaryResult 投递到某个出口 (文字 / 卡片 / 复合)idempotent;失败返 False → 下一 tick 重试
Pipelineshared/pipeline.py循环 tick:poll → record → plan → summarize → notify → applyper-source / per-thread 异常隔离;不依赖具体 source/notifier
ThreadStoreshared/store.pySQLite thread_state 表的唯一入口 (WAL + 独占锁 + JSON 列在边界解码)写必须经此;读返 typed ThreadState
Lark adaptershared/lark.py所有 lark-cli subprocess 调用唯一入口5 种失败统一抛 LarkError(kind, detail)

3.3 Pipeline 一次 tick 干了什么

flowchart TD Start([tick - 每 5s]) --> A[per source: poll] A --> B{persists_to_store?} B -- Hermes --> B1[record_event 落 SQLite] B -- Pi/CC --> B2[丢弃事件 - 只前进游标] B1 --> C B2 --> C C[per thread: plan_tick] --> D{action} D -- skip --> Skip([下一 thread]) D -- skip_log --> SkipLog([log + 下一 thread]) D -- force_done --> F[合成 完工 15min 兜底] D -- haiku --> H[Summarizer.summarize] H --> H1{得到结果?} H1 -- 否 --> Hf[空 SummaryResult fallback 摘要] H1 -- 是 --> G[build_decision] F --> G Hf --> G G --> N{done 首次?} N -- 是 --> N1[notify done] N -- 否 --> N2[notify card_update] N1 --> S[apply_tick 落库] N2 --> S S --> ST[Source.on_tick_complete 同步节流计数] ST --> Skip

3.4 一次决策的纯/不纯分离

tick 决策被刻意拆成两个纯函数 + 一个薄编排,让"误判 done / 漏锁"这类 bug 可单测:

# 纯 1: plan_tick — 闸门 def _plan_tick(thread, now): # 状态分类: active/stalled/done_fallback # stalled 频率节流: 30s 一次 # no_new 检测: count + last_ts 都没动 return (action, is_stalled, last_ts)
# 纯 2: build_decision — 映射 def _build_decision(thread, result, is_stalled, ts): # 空 summary → 倒序找最后 bot 动作兜底 # status: done / stalled / active # 透传 carry-over: card_msg_id / done_notified return (Decision, done)
# 薄编排: _tick_one_thread = 纯决策 夹在 (haiku call → notifier → store) 中间 # notifier 一次 tick 只发一次 — done 或 card_update 二选一,防双发竞态

四、做得怎么样 — 技术评估

做得比较干净的
  • 层次清楚 — Source / Summarizer / Notifier / Pipeline 各管一段,daemon 入口 367 行只做组装,看一遍就懂
  • 深模块到位 — ThreadStore 独占 SQLite + WAL,Lark adapter 把 5 处重复的 subprocess 调用收成一个类的 5 个动词
  • 错误边界 — Pipeline per-source / per-thread try/except,一路坏不连带,haiku 永不抛只返 None
  • 状态隔离 — Pi/CC 不写 Hermes 的 SQLite(persists_to_store 标志),看板字段 + tick 跳过两套独立集合 (TICK_SKIP_THREAD_IDS vs DASHBOARD_HIDDEN_THREAD_IDS)
  • 测试覆盖 — 62 用例全过 (.66s),pipeline / source / summarizer / notifier 都各有 wired + skeleton 两层
  • 前端实时性 — ETag 304 + gzip + Vite 构建产物入版本控制,launchd 部署零构建步
  • 纯函数决策 — plan_tick / build_decision 可单测,I/O 全留在薄编排里
还有这些遗憾
  • haiku 偶尔 timeout — 中转网关 (api.eagle) 平时 ~1.5s,偶发被网络抖穿;已把 timeout 放宽到 25s + fallback 模型修成 4.5-pinned (中转不支持 3.5 系列)
  • 主话题硬编码不 tickWINGMAN_MAIN_THREAD_IDTICK_SKIP_THREAD_IDS 是设计 (防"卡片→fanout→再总结"套娃),但在主话题里聊的内容就拿不到 summary,需要换个普通话题验
  • fanout 文件不支持轮转 — Hermes 网关重启后日志被换名字,daemon 还指着旧 fd,得手动重启 daemon
  • WINGMAN_CARD_ENABLED 默认关 — 飞书交互卡片有发送/编辑回路问题(send 拿到的 msg_id 没法回写 store),已知短板,目前只用文字通知
  • thread_name 解析偶发 230001 — lark-cli 拉群名失败,只影响显示名字(用 thread_id 兜底)
  • 声音去重靠 module-scope — useRef 而非 useState,可工作但跨标签页不同步
  • HermesConfig.skip_thread_ids 是参数 — daemon 同时传给 Source 和 Pipeline 各一份,语义重复,有飘移风险

代码体量(行数为静态计数)

模块行数角色
daemon/main.py367入口 · 只组装 Source/Summarizer/Notifier/Pipeline
shared/pipeline.py4515s tick 编排 + 纯决策
shared/sources/cc.py1141mcp-bridge HTTP + TTL cache + Source 抽象
shared/sources/pi.py1013~/.pi/agent 文件读 + 节流缓存 + dashboard 投影
shared/sources/hermes.py430fanout tail + 文本提取 + 噪音过滤
shared/summarizers/haiku.py~4702 模型 fallback HTTP + prompt + 解析 + sanitize
shared/store.py286SQLite 唯一入口 (WAL + 锁 + 边界解码)
shared/lark.py135lark-cli subprocess 唯一入口
web/app.py559FastAPI 6 个只读 + 2 个回复路由
web/frontend/src/**.tsx~740App + Topbar + Wall + ActiveCard + Recede + Drawer
tests/**~1.2k62 用例 (Source/Summarizer/Notifier/Pipeline/Lark/MCP/CC)

分维评分

架构清晰度A
测试覆盖A-
错误处理A-
可观测性 (jsonl + ETag + boot log)B+
外部依赖鲁棒性 (haiku/网关)B
前端可维护性 (React + 拆组件)B+
部署 (launchd + dist 入库)A-
文档 (CLAUDE/CONTEXT/README + 内联 docstring)A

五、功能现状逐项盘

实时探测结果(下表数据来自刚才直接打 :7421 的 6 个 API + launchd list + tail wingman.log):

功能实现位置状态说明
Hermes fanout 消费HermesSource.polltail -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 文字通知LarkTextNotifierenabled=True,完工瞬时一行 ✅ + 驾驶舱链接
飞书交互卡片LarkCardNotifierenv 关WINGMAN_CARD_ENABLED=0;send 拿到的 msg_id 没法回写 store(已知短板)
FastAPI 驾驶舱后端web/app.py:7421 listening · /healthz ok
ETag + gzip + 304_json_with_etag5s poll idle 时基本零字节传输
三段式卡片墙Wall.tsx现在在忙 / 以前在忙 (≥3h 折叠) / 出错 (折叠)
按 source 分组groupBySourceCC / Pi / Hermes 三色徽章,各自独立铺卡
右栏滑出详情 DrawerDrawer.tsxthread / pi / cc task / cc session / sessions browser / transcript 5 路由
关键里程碑 TTS 播报useSound + lib/sound.ts21 关键词 + offset_s/30 分桶去重 + boot grace
本地 CC 会话浏览SessionsBrowser + scanTranscriptbridge 侧尾读 ≤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/*.plistcom.leway.wingman · com.leway.cockpit 都 listed
测试套件tests/62/62.venv/bin/python -m pytest 一句话过 (0.66s)

六、接下来值得做的几件事

建议 1把 Card 回写打通

现在 Notifier 是 fire-and-forget,新发的卡片 msg_id 没办法回到 ThreadStore — 下次 tick 又重发一张新卡。Pipeline 加一个 on_notify_success(thread_id, msg_id) 回调,store 多一次 update,就能把 WINGMAN_CARD_ENABLED 打开,真用上飞书原生卡片(比 Web 驾驶舱更"在飞书里"的体验)。

建议 2fanout 文件轮转感知

现在 Hermes 网关重启会换 jsonl 文件,daemon 还指着已删除的 inode,得人工 launchctl kickstart。给 HermesSource 加 inode 探测,每 N 次 poll 比对一次,变了就 reopen — 复杂度极小,免去一种"为什么没更新"的排查路径。

建议 3haiku 退避更稳一点

现在主 + fallback 一起出错只会跳过这一 tick,下 5s 又重试。撞流量高峰时容易刷爆 jsonl。给 Summarizer 加指数退避(同一 thread 连续失败 N 次进入 60s/300s 冷却),失败信息进 ThreadView 让前端能显示"haiku 暂不可用"。

建议 4把"主话题"做成可观察但不 tick

现在主话题在 TICK_SKIP_THREAD_IDS 里完全不进 tick,导致里面聊的内容没有 summary。其实可以允许它进 record (落 SQLite),但 skip summarize,这样至少看板能看到 recent_actions,做未来"如果主话题被换"做准备。

建议 5收口 skip_thread_ids 的来源

daemon/main.py 把 TICK_SKIP_THREAD_IDS 同时传 HermesConfig 和 Pipeline,语义重复 — 防御性的好,但有飘移风险。改成只在 Pipeline 一层过滤,Source 不再要这个字段,语义集中、配置变少。

七、一句话总结

"看 Hermes 在干嘛" 这件事,从单文件 vanilla 跑通,到 React 重构,再到三路统一架构 — 现阶段已经是一个测试齐、文档齐、launchd 长跑稳定的小系统。haiku 中转网关那个 fallback bug 修完之后,主要功能全部能用,主要遗憾只剩 fanout 不跟轮转、飞书原生卡片回写未打通这两条比较具体的工程债。

3 路数据源 (Hermes/Pi/CC) 2 个 launchd 服务 62/62 测试通过 10 个核心 commit 3 个已知短板 5 条建议