CLI 会话输出经 worker 观测 → IPC screen_update/screenshot_uploaded → daemon 决策 POST/PATCH → buildStreamingCard 组装飞书交互卡片。会话进程与 IPC 协议见 daemon-worker,卡片按钮回调入口(card.action.trigger)在路由层,见 im-routing。

Worker 侧:终端观测与上报

worker.ts 持续把 PTY/tmux 输出喂给 xterm-headless renderer,lastAnalyzerSnapshot 保存当前视口纯文本快照(worker.ts:6649 附近,2s 采样定时器 SCREEN_UPDATE_INTERVAL_MS = 2_000,供限流检测/CoCo picker 读取)。

screen_update IPC 消息在状态机关键边沿发射(非逐字节推送):publishScreenStatus(worker.ts:9094)推粗粒度 working/idle;检测到 CLI 限流时发 status: 'limited' + usageLimit(worker.ts:6015,含 Codex 结构化 transcript 的 429 终态);stuck 检测发 stuck_warning。status 取值 starting/working/analyzing/idle/limited,判定来源是屏幕模式匹配(classifyScreenUsageLimit)+ 结构化 transcript(Codex bridge 的 assistant_final → 直接 fireIdle,比屏幕启发更可靠)。

截图模式(displayMode: 'screenshot'):SCREENSHOT_INTERVAL_MS = 10_000 循环(worker.ts:7332)——snapshotToPng 让 tmux capture-pane 出新视口快照,经瞬态 xterm-headless 渲染成 PNG(utils/screenshot-renderer.ts,@napi-rs/canvas,Tokyo Night 主题,Latin 字体必须排在 CJK 前否则字宽错位);md5 哈希去重,屏幕没变化跳过;uploadImageBuffer 换 image_key 后发 screenshot_uploaded。按键动作后 +1s 补一帧(POST_ACTION_DELAY_MS)。历史方案曾把终端文本转 markdown 直接作卡片正文,现已弃用——同回合 PATCH 分支注释明言 “text is no longer a card body mode”(worker-pool.ts:7682)。

Daemon 侧:POST/PATCH 决策

screen_update 处理在 core/worker-pool.ts:7512:

  1. 更新 ds.lastScreenContent/lastScreenStatus;状态真正翻转才发 dashboard SSE patch + 状态转换 hook + usage 记账 + 回合 reaction(✋→✅)——screen 观测是上游去抖器,不逐 tick 刷屏
  2. 卡片抑制门:bot 级 opt-out(streamingCardDisabled,per-turn 精确)、重启恢复期 suppressRecoveryCard、托管静默回合
  3. 新回合(streamCardPending 或无 streamCardId):POST 新卡。CARD_POSTING_SENTINEL 防并发重复 POST;新回合换 card_nonce 使旧卡按钮失效;POST 成功后 recallFrozenCards 撤回上回合冻结卡(否则长会话泄漏一堆旧卡)
  4. 同回合:仅在状态变化时 PATCH;图片 PATCH 走 screenshot_uploaded 路径

PATCH 串行队列:scheduleCardPatch(worker-pool.ts:1787)每会话同时只有一个 PATCH in-flight,新请求写 ds.pendingCardJson latest-wins 覆盖——防止并发 PATCH 乱序、陈旧 screen_update 覆盖按钮 toggle 结果。这就是节流/批量机制:不做定时合帧,靠「状态边沿触发 + 单飞队列 + 最新覆盖」压制更新频率。

卡片组装(card-builder.ts)

buildStreamingCard(891):

  • 正文 pushStreamBody(833):limited 态显示限流重试提示;screenshot 态嵌 img 元素(无图占位「等待截图」);usage 行灰色小字单行渲染(context/token 指标 · runtime 身份连排,不做两栏列布局)
  • 控制按钮行:toggle_display(显隐输出)、export_text(导出屏幕文本)、refresh_screenshot、open_terminal(multi_url 区分桌面/移动端)、get_write_link(可写终端链接 opt-in)、close(danger);adopt 模式换成 takeover/disconnect;displayMode≠hidden 时加快捷按键(Esc/^C/Tab/Enter/方向键/半屏翻页)
  • 每个按钮 value 带 {root_id, session_id, cli_id, card_nonce},nonce 是回调时识别陈旧卡的关键
  • truncateContent(790)按字节上限截断,防超飞书消息体积限制

Markdown → 卡片(md-card.ts)

用于回复卡/botmux send 等正文卡(非流式卡正文):

  • prepareCardMarkdown(630):还原模型转义的 fence 反引号;本地路径链接归一化(~/... → filesystem/lexical/disabled 三模式)
  • buildCardBodyElements(652):按飞书卡片 v2 能力做结构适配——pipe 表格 → 原生 table widget(markdown widget 渲染不了网格);ATX 标题 → 加粗;代码 fence 两侧补空行(widget 识别需要);纯多图行 → column_set 并排;其余合并进单个 markdown 元素控制 element 数量

卡片按钮回调(card-handler.ts)

card.action.trigger 是 3s deadline 的同步回调且不重投:路由层 handleCardActionAckSafe(event-dispatcher.ts:670 起)用 2500ms race——超时先 ACK「后台处理中」toast,后台完成后 updateMessage 补 patch;去重 = event_id durable claim + in-flight Set(无 event_id 时回退按 action value 全字段哈希,含 nonce/页码/目标 id 防误碰撞)。

handleCardAction(card-handler.ts:947)分两段:

  1. 不绑 session 的动作先处理:机器过载清扫卡(owner 强闸门 + nonce 一次性核销)、grant 授权卡(grant_chat/grant_global/grant_deny + 时效/额度下拉)、relay/adopt/config 选择器
  2. 绑 session 的动作:getSessionByActionValue 按 root_id/session_id 定位 + validateCardCliBinding 校验 nonce/cli 一致性,然后按 action 分发:toggle_display、refresh_screenshot、restart、close、resume、仓库选择(commitRepoSelection)、get_write_link、open_local_terminal/cli 等。敏感动作查 ALLOWED_USERS/canOperate,操作者 open_id 按接收 bot 的 app 域解析

回调按钮标记(callback-button-marker.ts):所有 botmux 出站卡在 client.ts 四个出口统一给 callback 按钮盖 __bm_cb:1 戳(open_url 跳转按钮不盖)。其他 bot 读卡片历史做纯文本扁平化时,可证明性地剥掉 botmux 自家按钮,不靠手工维护按钮词表;marker 回传到 card-handler 被忽略。

相关页面

  • cards — 卡片产品形态与交互说明
  • web-terminal — 卡片上 open_terminal 指向的 Web 终端
  • dashboard — 会话状态 SSE 的消费方之一