# Changelog ## Unreleased ## 0.3.0 (2026-11-05) > 自 `0.2.0`(2026-09-08)以来累积的全部改动,**要求 DSH `0.2.x`**(peer `^0.2.0-rc.1`;在 `0.2.2-rc.1` 上构建、加载并完成飞书收发验证)。`0.2.0` 要求 DSH `0.1.3-alpha.x`,本版不再支持 0.1.x——这是本次唯一的破坏性变更。本机最后一次完整回归基线为 DSH `0.2.1-rc.2`,`0.2.2-rc.1` 的适配见下文「兼容」条。现行使用说明见 [项目首页](<./README.md>),文档入口见 [文档导航](<./docs/README.md>)。 > > The complete set of changes accumulated since `0.2.0` (2026-09-08). **Requires DSH `0.2.x`** (peer `^0.2.0-rc.1`; built, loaded and Feishu-verified on `0.2.2-rc.1`). `0.2.0` required DSH `0.1.3-alpha.x`, so 0.1.x is no longer supported — the only breaking change in this release. The last full regression baseline was DSH `0.2.1-rc.2`; the `0.2.2-rc.1` adaptation is the "兼容" entry below. ### 新增:权限卡接入 Auto review、智能体团队看板(`src/feishu-permission.ts` / `src/feishu-team.ts` / `src/index.ts` / `src/i18n.ts` / `docs/architecture.md`,2026-11-05) 两个能力都来自 DSH 官方实验插件,**是否启用由使用者决定**:插件只对接宿主已经广告出来的能力,没启用时不改变任何现有行为。 - **权限卡读宿主目录,而不是硬编码三个预设**:`/permission` 卡与 `permissionPresets.catalog()` 对齐(与 DSH Web UI 同一个读面)——配置预设按宿主顺序在前,`auto` 只在 auto-review 集成 live 时出现。**没装 auto-review 时卡片逐字不变**(三个按钮、无额外提示);装上后多一个「🧪 自动审查 EXP」按钮与一行实验说明。 - **Auto 先确认再写**:对齐 Web UI 的风险确认对话框。点 Auto 只把卡片就地重画成确认卡(说明无沙箱运行、逐次审查、拒绝的调用仍会走审批、仍属实验性、会多耗 token),点「我已了解风险」才真正 `permissionPresets.set(session, 'auto')`;取消不写任何东西。集成已被卸载时 DSH 会拒绝,拒绝理由照旧显示在卡上而不是抛出。 - **Auto 与「完全权限」同色**:Auto 的固定组合就是无沙箱(完全权限),所以按钮改用与「完全权限」相同的红色 danger 提示;仅可查看 / 工作区内修改仍是蓝色 primary。 - **`/permission` 文本路径同步**:`/permission`(无参卡不可用时的文本列表)、`/permission ` 的校验与回执都改为读目录,因此 `auto` 可用时 `auto` 也可直接命令切换;当前模式的读法改成优先 `permissionPresets.current()`(`auto` 与 `danger-full-access` 共享沙箱值,只读沙箱会把 Auto 会话显示成「完全权限」)。 - **智能体团队看板(新模块 `src/feishu-team.ts`)**:Agent Teams 只在宿主启用实验包并由 Agent 自动组队时产生 `team/member` / `team/task` 会话事件。模块把这些事件**只当触发**,在 flush 时读 lead 会话的 `agentTeam` 投影(版本化、派生视图 `ownerName`/`ready`/写入范围告警、坏记录 `failure` 都由宿主保证),因此不新增 peer 依赖、也不重写团队状态机。 - **更新逻辑与子代理看板一致**:复用 `feishu-panel` 的发送器——同名册变化**发新卡**(不就地改),同一突发合并、指纹去重、按聊天串行;看板内容 = 摘要 + 成员(Lead 只显示角色,队友显示运行/未运行/准备中/失败)+ 共享任务表(任务/状态/负责人/依赖,>12 行折叠成计数)+ 告警/坏记录行。成员活动读 live Agent registry,队友 `turn/start` / `turn/end` 走**非紧急**刷新(30s 地板),否则可续谈队友每轮都会把整张看板重发一遍;Lead 的回合是聊天自己的回合,永不触发看板。 - **没启用 / 读不到就不存在**:没有 Agent Teams 插件时根本不会有团队事件,看板不出现;投影服务缺失或读失败只记一次 info/warn 后静默降级(不阻塞 boot,关键坑 2)。 - **文档**:README 的能力列表、常用命令与配置说明补上这两项(并写明「实验插件未启用时行为不变」);[文档导航](<./docs/README.md>) 的「当前行为速查」与 [架构](<./docs/architecture.md>) 的模块表/卡片表同步权限卡与团队看板的口径(Auto 与完全权限同色、切换前先确认、看板只发新卡)。 - **测试**:新增权限 Auto 10 例、团队看板 23 例(投影解析、指纹去重、`replyTo` 话题坐标、未绑定会话不出卡、任务变化发新卡、队友/Lead 回合差异、投影异常降级、dispose 后不再发);全量 **1456 测试 / 73 文件**、`typecheck`、`build` 全绿。 **English**: Two optional DSH experimental plugins are now bridged without hard dependencies. The `/permission` card renders `permissionPresets.catalog()` instead of a hardcoded preset table, so the `auto` (Auto review) entry appears exactly when that integration is live — and stays byte-for-byte identical to the previous three-button card when it is not. Selecting Auto first repaints the card as the risk confirmation the Web UI shows, and only the confirm button writes the preset; a refusal (for example after the integration unloaded) is shown on the card instead of thrown. `/permission`'s text path lists and validates the same catalog, and the current mode now prefers `permissionPresets.current()` because `auto` and `danger-full-access` share the sandbox value. A new `src/feishu-team.ts` posts an Agent Teams board when the experimental team packages append `team/member` / `team/task` events to a Feishu-bound Lead session: the events are only triggers, the board reads the `agentTeam` projection at flush time, and updates reuse the send-only panel sender (coalescing, fingerprint de-duplication, in-order sends). Members, shared tasks, write-scope warnings and invalid-record failures are rendered from the projection; teammate activity comes from the live Agent registry and its turn edges refresh non-urgently, while the Lead's own turns never repost. Without the plugins nothing is posted and nothing changes. 1456 tests / 73 files, typecheck and build pass. ### 兼容:适配 DSH 0.2.2-rc.1(`src/harness.ts` / `src/index.ts` / `package.json`,2026-11-05) 上游 0.2.2-rc.1(0.2.1 → 0.2.2 跨 tuple,15638 commits、30 个新增包)改了两处插件用到的契约: - **`agent/request` 不再允许改路由**:0.2.2 在提示词组装与 pre-step 之前固定本步的 provider/model/推理等级,`agent/request` 里再改会直接抛错(新代码提示改用 `agent.model.select()`)。插件的主路径一直走宿主自己的 `sessionController.selectModel`,这条 hook 只是「宿主没有 `selectModel` 时」的兜底;兜底保留但不会再被触发。同时把它依赖的 `AgentLike.ctx.on` 放宽成 `(...args: any[])`——Cordis 4.1.0 把事件名收窄为 `keyof Events`,原来的 `(event: string)` 结构性签名不再接受真实 `Agent`(`tsc` 3 处报错即此,非 API 被删)。 - **Session projection 的 `stateOf(session, key)` 已废弃**且每次调用打一条 warning:会话列表改用新的按 key 读面 `snapshot(session, ['sessionListMetadata', 'title', 'agentPreset'])`(0.2.2 只计算传入的单元,语义等价且一次读齐)。 - **依赖**:`devDependencies` 对齐到 `^0.2.2-rc.1`、`@deepseek-ai/cordis ^4.1.0`(`package-lock.json` 重新解析);**`peerDependencies` 保持在 `^0.2.0-rc.1`**——运行时兼容闸按 `includePrerelease` 判定,这个范围同时覆盖 0.2.2-rc.1 与回滚目标 0.2.1-rc.5。 - **升级前彩排**:宿主 0.2.2-rc.1 在隔离 clone 里构建全绿(`--version` = 0.2.2-rc.1、residue 0、30 个新增包与改动包产物齐、102 个 client bundle);插件对 0.2.2 类型 `typecheck` 通过、全量 **1423 测试 / 72 文件**(1 例 `feishu-deliver` 并发用例首次超时、单跑通过,属既有的负载 flaky)、`build` 通过;真实 profile 在隔离 `DSH_HOME` 冷启动:`apply() called`、0 兼容闸拦截、0 条 `patch: entry … not found`、0 个未知配置键、0 条废弃告警。 - **部署侧**:0.2.2 默认把 `fs-observation-policy` 行下发为 `disabled: true`(先读后写不再强制)。本机在 profile patch 里显式写回 `disabled: false`,保持升级前的行为不变(升级指南给的正规做法);`apply_patch` 工具随 `standard` preset 默认关闭,本部署无新增工具。 **English**: DSH 0.2.2-rc.1 fixes the per-step model at prompt-assembly time, so `agent/request` can no longer change the route (plugins must call `agent.model.select()` before step preparation). The plugin already switches models through the host's `sessionController.selectModel`, so the only change needed was loosening the legacy fallback's structural `ctx.on` type (Cordis 4.1.0 narrowed event names to `keyof Events`). The deprecated per-key projection reader `stateOf(session, key)` was replaced with `snapshot(session, [...])` for the session list, dev dependencies moved to `^0.2.2-rc.1` / `cordis ^4.1.0` while peer ranges stay `^0.2.0-rc.1` (still covers both the new runtime and an rc.5 rollback), and the read-before-write policy row is re-enabled in the deployment's profile patch because 0.2.2 ships it disabled. Rehearsed against an isolated 0.2.2-rc.1 build (all gates, plugin typecheck, 1423 tests / 72 files, build, and a real-profile cold boot with zero compat denials) before switching. ### 新增:LaTeX 公式写成飞书原生可编辑公式(`src/doc-formula.ts` / `src/inline-markdown.ts` / `src/feishu-doc.ts`,2026-11-04) 起因:另一个 agent 在复习笔记里写 `$d = 0.61\lambda/NA$` 时踩到**静默吞内容**——飞书的 Markdown→块转换器不认公式:`$…$` 整段消失、`$$…$$` 变空文本块、`\[…\]` 反斜杠被吃掉。而飞书 docx API 其实**支持**公式(行内 `equation` 文本元素),只是转换器没实现。 - **写工具支持公式**:`feishu_doc_write` 在 `document.convert` 之前把公式换成分级占位符(行内 `DSHMFI…`、独占一行 `DSHMFB…`),转换后把占位符还原成 `equation` 文本元素。行内 `$…$` / `\(…\)` 留在原段落;独占一行的 `$$…$$` / `\[…\]` 变成居中、只含公式的文本段(读起来就是展示公式)。结果里的 `formulas` 报出写入了几个公式,`content_chars` 仍是源文件字符数。 - **不会误伤**:代码围栏与行内代码里的 `$` 原样保留;`$5` / `$10` 这类货币金额、`\$` 转义不当作公式;多行 `$$` 用 LaTeX `\\` 连接;某个占位符万一没被还原会打日志,不再静默丢内容。 - **块更新同样支持**:`feishu_doc_blocks update` 的 `text` 里 `$…$` 也解析成 `equation` 元素,与粗体/代码/链接并存。 - **读回**:`docs.v1.content.get` 把公式读成 `$…$`,所以读 → 改 → 写可以完整往返。 - **平台限制(已写进文档)**:docx **没有可创建的公式块**(`block_type` 选项列表不含 16),所以展示公式是「居中、只含 equation 的文本段」;上下标仍无行内样式,用公式代替。 - **真机验证(2026-11-04)**:创建含行内公式 + `\frac` 展示公式的文档(`formulas=2`)→ 读回 `$x^2 + y^2$` 与独占一行的 `$d = \frac{0.61\lambda}{n\sin\alpha}$`;行内代码里的 `$not a formula$` 保持代码;块更新写入 `前 $a+b$ 后 **粗体**` 后读回一致;探测文档已删除。 - 测试:新增公式提取/还原 10 例、行内解析公式 2 例、写工具端到端 1 例;全量 **1423 测试 / 72 文件**、`typecheck`、`build` 全绿。 **English**: Feishu's Markdown→blocks converter silently drops formulas (`$…$` vanishes, `$$…$$` becomes an empty paragraph, `\[…\]` loses its backslashes) even though the docx API supports formulas as an inline `equation` text element. `feishu_doc_write` now swaps formulas for typed placeholders before conversion and splices them back as equations: inline ones stay in their paragraph, a formula on its own line becomes a centred equation-only paragraph, code fences and inline code stay literal, currency amounts like `$5` are not mistaken for math, and an unresolved placeholder is logged instead of losing content. `feishu_doc_blocks update` understands `$…$` too, and reads come back as `$…$` so a read → edit → write round-trip works. Platform limits: docx has no creatable equation block type (so display formulas are centred text paragraphs) and no superscript/subscript style. Live-verified on the real tenant. 1423 tests / 72 files, typecheck and build pass. ### 新增:文档工具的发布登记、行内格式与大文档分批(`src/doc-registry.ts` / `src/inline-markdown.ts` / `src/feishu-doc.ts` / `src/feishu-doc-collab.ts`,2026-11-04) 起因:另一个 agent 用这批工具真实发布了两篇文档后给了实测评价(一次写入 313 块成功,但「改一段要重写整篇」「块更新把行内格式重置」「超长文档没底」)。逐条吸收后: - **`feishu_doc_write` 新增 `publish` 模式**:把 `.md` 文件的**绝对路径**当键,第一次发布 `create`,之后再发 `replace` **同一篇**——「Markdown 当源文件、改完重跑一次」不再堆出重复文档。登记持久化在 `$DSH_HOME/lark-doc-registry.json`(路径问宿主 `dshHomePath`、原子 tmp+rename、最多 300 条、坏文件当空表),记 `documentId` + 上次自己写入后的 `revision`;登记里的文档被删或失去权限时自动回到新建。**防误覆盖**:若文档被人在飞书改过(当前 revision ≠ 登记值),发布拒绝并提示先 `feishu_doc_read` 拿新 revision,或显式 `allow_foreign_edits: true`。`create` 遇到已登记的同一文件会报错并指向 `publish`,避免悄悄建第二篇。`publish` 第一次发布也可以传 `target` 收养一篇已有文档。 - **块更新的行内格式**:`feishu_doc_blocks update` 的新文本现在按行内 Markdown 解析成多个 `text_run`(`bold` / `italic` / `strikethrough` / `inline_code` / `link`),不再整块压成纯文本;`src/inline-markdown.ts` 处理转义、嵌套、链接标签内的格式与 `snake_case` 不误判。整块结构(标题/列表/表格)仍归写工具。 - **大文档分批插入**:`documentBlockDescendant.create` 的 ~1000 块上限改为自动分批——按**顶层块**切、子树不拆、每批 ≤800,批间 `index` 递增。 - **标题去重**:显式 `title` 与正文首个 H1 相同时剥掉那行 H1(此前只在省略 title 时剥),不再出现两个一级标题。 - **路径错误**:相对 `path` 报错会直说「本工具不解析相对路径,请传绝对路径」,不再是裸 ENOENT。 - **真机验证(2026-11-04,真实租户)**:同文件连发两次 → 同一 `document_id`、第二次 `created=false`、读回只有新版本;手动改文档后再发布 → 拒绝;`allow_foreign_edits` 后成功;900 段正文 → **2 次**插入请求;两篇探测文档用 `drive.v1.file.delete`(维护 scope)删净。 - **文档**:README / 能力参考 / 架构补上 `publish`、登记文件、分批、行内格式与**已确认的平台限制**(docx 无上下标样式、不抓远程图片、评论不能回复线程、工具不删文档)。 - 测试:新增 registry 6 例、行内解析 8 例,写工具新增 publish/标题/分批/路径 10 例;全量 **1410 测试 / 71 文件**、`typecheck`、`build` 全绿。 **English**: `feishu_doc_write` gained a `publish` mode keyed by a Markdown file's absolute path — the first run creates the document, later runs replace that same document (registry in `$DSH_HOME/lark-doc-registry.json`, host-provided home, atomic write, 300-entry cap) and refuse to overwrite edits made in Feishu unless a fresh revision or `allow_foreign_edits` is supplied. `feishu_doc_blocks update` now parses inline Markdown into styled `text_run` elements instead of flattening to plain text, oversized bodies insert in batches of at most 800 top-level blocks, a body H1 duplicating an explicit title is dropped, and a relative `path` fails with the absolute-path rule stated. Live-verified against a real tenant (same document on re-publish, foreign-edit refusal, 900 blocks in 2 insert requests, probe documents deleted). 1410 tests / 71 files, typecheck and build pass. ### 改进:全量校对用户可见文案与 DSH Web UI 的术语(`src/i18n.ts` / `src/commands-i18n.ts` / `src/index.ts` / `src/feishu-streaming.ts`,2026-11-04) 对照本机 DSH `0.2.1-rc.5` 的客户端语言包(`packages/client/*/src/client/locale*.ts`)逐项核对插件文案,凡 Web UI 已有同一概念的一律采用它的中英文命名: - **子智能体**(原「子代理」):面板标题/列名/未命名项/`/subagents` 说明/归档确认里的活动类型/注入提示;模式 `可继续`(原「可续谈」)、`模式未知`(原「未知」);文档同步。 - **推理等级**(原「思考强度」「推理强度」):模型选择卡当前行、档位选择器、`/reasoning` 卡片与切换回执、`/status` 的推理一行、新会话默认模型行。选项文案里的 `关闭思考` 保留——Web UI 自己的帮助文本就是这么说。 - **提供商**(原「Provider」):模型选择卡的 Provider 行/占位符/返回按钮/无模型提示;`provider 默认` → `提供商默认`。 - **权限**:卡片标题 `Permission` → `权限`;当前行改为 `访问模式:`(Web UI 的说法);说明改为「权限预设(沙箱模式与审批策略)」;`/permission` 与 `/help` 同步。三个预设名(仅可查看/工作区内修改/完全权限)本来就与 Web UI 逐字一致。 - **会话操作**:`改名` → `重命名`(含结果卡与失败提示),`Detach` → `释放`,`归档`/`置顶` 补成 Web UI 的 `归档会话`/`置顶会话`;归档确认里的活动类型改为「进行中的回合 / 运行中的子智能体 / 后台任务 / 定时提醒」。 - **后台任务**:`已停止` → `已取消`、`失败` → `已失败`(对齐 Web UI 任务状态)。 - **注入 / 压缩 / 交付 / 待办**:`注入上下文` → `上下文注入`(Web UI 原文),`已折叠 N 条历史记录` → `已压缩 N 条历史记录`,`交付物 / 文件投递` → `交付文件`(Web UI `row.title`),待办摘要改为 `任务清单 · 进行中 / 待处理 / 已完成`(Web UI 的任务状态用词)。 - **去掉面向用户的内部术语**:`sessionController.fork 缺失`、`这个宿主的 agent 不支持…`、`当前 turn`、`工作区 WebUI` 等改为「当前部署…」「本轮…」「Web UI」;`/status` 卡里原本中英混排的 `**Activity:** / **Tokens:** / **Context:**` 改为本地化标签(`活动` / `Token 用量` / `上下文`,数字行采用 Web UI 的 `N 轮 · N 步 · N 次工具调用`);`Agent 模板` → `Agent 预设`,`模板` → `预设`。 - 核对后确认**已经一致**的部分:权限预设名、交付文件标题、工作步骤四档(简洁/标准/详细/完全展开)、压缩卡三态、`本轮运行失败` / `API 密钥无效`、审批 `允许一次`/`拒绝`、自动回合触发原因、`深度求索中`、`上下文注入` 的行动词(已载入/已新增/已更新/已移除)、`第 N 轮` 等。 - 测试:更新受影响的断言(会话面板按钮、归档确认活动、权限卡当前行、注入行前缀、待办摘要、`/help` 停止说明、交付卡标题不复述);全量 **1384 测试 / 69 文件**、`typecheck`、`build` 全绿。 **English**: Audited every user-visible string against the local DSH `0.2.1-rc.5` client locales and adopted the Web UI's own wording wherever it already names the concept: subagents (子智能体), reasoning effort (推理等级), provider (提供商), permission title/current row (权限 / 访问模式), session actions (重命名 / 释放 / 归档会话 / 置顶会话), background-job states (已取消 / 已失败), context injection (上下文注入), compaction count (已压缩), deliverables (交付文件) and the todo status words (进行中 / 待处理 / 已完成). Developer jargon was removed from end-user copy (`sessionController.fork`, `this host's agent`, `current turn`, `workspace WebUI`), and the `/status` card no longer mixes English labels into Chinese output. 1384 tests / 69 files, typecheck and build pass. ### 改进:`/busy` 与状态卡改用 WebUI 的正式说法(`src/i18n.ts` / `src/feishu-busy.ts` / `src/index.ts` / `src/channel.ts`,2026-11-04) - **术语纠正**:`Enter while busy` / `忙碌时进入` 是对 WebUI 设置名的生硬直译,而飞书里根本没有 Enter 键。现在统一采用 DSH WebUI 自己的中英文命名:**繁忙时的发送行为**(`Send behavior while busy`),两个选项译为 **排队发送** / **插话发送**(English 仍为 `Queue` / `Steer`)。 - 覆盖三处用户可见位置:`/busy` 交互卡片(标题、当前模式、提示、按钮)、Turn Complete footer、`/status` 的同一行;`/busy` 的文本回退路径与 `/help` 里的命令说明同步改写。 - 卡片标题不再硬编码英文,改走 i18n(新增 `busyCardTitle`);顺带删掉五个从未被引用的旧 busy i18n 键(`busyTitle` / `busyIntro` / `busyQueueLabel` / `busySteerLabel` / `busyCurrentOption`),避免以后再被误用。 - 测试:更新 busy 卡片与 footer 的断言,新增英文 locale 卡片一例;全量 **1384 测试 / 69 文件**、`typecheck`、`build` 全绿。 **English**: `Enter while busy` / `忙碌时进入` was a literal translation of the WebUI setting name, and a Feishu chat has no Enter key at all. The busy picker card, the Turn Complete footer and `/status` now use the WebUI's own wording: **Send behavior while busy** (zh 繁忙时的发送行为) with the options **Queue** / **Steer** (zh 排队发送 / 插话发送). The card title is localised instead of hard-coded, five unused busy i18n keys were removed, and `/help`'s `/busy` description was reworded. 1384 tests / 69 files, typecheck and build pass. ### 新增:启动/重启后自动通知最后活跃会话(`src/feishu-startup-notice.ts` / `src/harness.ts` / `src/index.ts`,2026-11-04) - 插件在**进程启动后第一次连上飞书**时,向**最后活跃的会话**发一条文本(不调用模型):`DeepSeek Harness 已恢复在线,可以继续对话。` - 若那个会话的上一回合没有正常结束(DSH 停止/重启把它打断),追加:`上次对话在服务重启时中断,未完成的内容可能需要重新发起;发送任意消息即可继续。` - 文案以终端用户可读为准:使用产品全称 `DeepSeek Harness`,不出现 `turn/回合`、连接状态码等内部术语,也不重复说明「飞书已连接」(用户能收到消息本身就是证明);只说明「现在可用」与「怎么继续」;中英文一致。 - 实现:bridge 在 `turn/start` / `turn/end` 持久化「session 最后活动时间」与「回合未结束」标记(写进 `lark-session-map.json`,原子写);**入站消息也算活跃**(`noteSessionActivity`),即使进程在第一个回合完成前就重启,下一条通知仍找得到聊天。通知选最近活跃且能解析到飞书 chat 的 session,话题内回复带 `replyInThread`/`replyTo`,送达后才清标记;`dispose()` 不清这两张表,保证重启后提示不丢;`globalThis` 按 pid 去重,热重载不重复发;未发送时也打一行 `startup notice skipped: `,不静默。 - 测试:bridge 持久化(含「入站消息只算活跃、不算中断」)+ 通知模块 7 例;全量 **1383 测试 / 69 文件**、`typecheck`、`build` 全绿。 **English**: On the first channel connection after a process start the plugin posts one model-free text into the last active chat ("DSH started; Feishu is connected"), adding an "this turn may have been interrupted — send a message to continue" line when the bridge's persisted `turn/start` marker was never cleared by `turn/end`. The session activity and interrupted markers are persisted in the bridge state file (atomic write), `dispose()` deliberately keeps them, and a pid-keyed flag prevents repeats on hot reload. ### 改进:文档图片可下载到本地、写入对限流重试(2026-11-04) - **图片可真正查看**:`feishu_doc_read` 新增 `download_images`,通过 `drive.v1.media.download`(scope `docs:document.media:download`)把文档里的图片下载到临时目录,`images[].path` 可直接用文件工具打开/读图。此前只给临时 URL,agent 实际看不到图。 - **限流重试**:块插入、清空正文、图片上传、图片挂载四个写调用遇到飞书限流(业务码 `99991400` 或 HTTP 429)会自动退避重试两次(600ms / 1500ms);限流拒绝发生在写入生效前,重试安全。图片较多的写入不再一撞 3 次/秒就整体失败。 - **测试**:新增「图片保存到本地」「限流后重试成功」两例;全量 **1375 测试 / 68 文件**、`typecheck`、`build` 全绿。 **English**: `feishu_doc_read` can now save a document's images to local files (SDK media download, returned as `images[].path`) so the agent can actually open them, and the four write calls retry twice with backoff when Feishu answers with its rate-limit codes (`99991400` / HTTP 429). 1375 tests / 68 files, typecheck and build pass. ### 改进:文档工具可用性(`.md` 文件输入、格式守卫、评论作者、图片链接、版本强制,2026-11-04) 1. **`feishu_doc_write` 支持 `path`**:直接从工作区 `.md` 文件读正文(长文档不必再塞进工具参数),相对图片按该文件目录解析;`create` 的标题默认取首个 `# 标题` 或文件名。 2. **读取保真**:`docs.v1.content.get` 返回的 HTML 表格在读取层重写成 Markdown 表格;文档里的图片通过 `drive.v1.media.batchGetTmpDownloadUrl`(新增 scope `docs:document.media:download`)返回**临时下载链接**,读取结果里 `images[].url` 可直接取用(会过期)。 3. **`replace` 增加格式守卫**:写前扫块,发现 Markdown 表达不了的块(callout、grid、file、sheet 嵌入等)直接拒绝,并列出类型;确认要放弃这些结构时传 `allow_format_loss: true`。 4. **`append` 也强制 `revision`**:所有改已有文档的写入都必须带 `feishu_doc_read` 返回的 `revision_id`(`create` 除外),彻底去掉「不传就无保护」的路径。 5. **评论带作者**:`feishu_doc_comments` 的评论与回复现在返回 `user_id` + 解析后的 `user_name`(contact 查询,按调用上限 10 个、带缓存)。 **补丁 2**:`create` 从正文首个 H1 推导标题时,会把那一行 H1 从正文里去掉(否则标题在文档里出现两次)。 **补丁**:读取结果的渲染行现在会把图片的临时下载链接打印出来(此前 URL 只在结构化输出里,模型看不到)。 **平台限制(未做,已确认原因)**: - **自动把应用加成文档协作者不可能**:`permissionMember.create` 要求调用方已有该文档权限(鸡生蛋);用户必须先自己把应用加为协作者。 - **按标题搜索不可用**:`search.v2.docWiki.search` 语义是「当前用户可见的文档」,应用身份(tenant token)实测 0 结果(`search_cold`),即使开通 `search:docs:read` 也一样;要用需 user_access_token 或本地索引。 **验证**:`tests/feishu-doc.spec.ts` 23 例(新增:`.md` 文件输入+相对图片、append 缺 revision、replace 格式守卫、HTML 表格转 Markdown、图片链接)、collab 6 例(作者名);全量 **1372 测试 / 68 文件**、`typecheck`、`build` 全绿。 **English**: `feishu_doc_write` can take a Markdown file through `path` (relative images resolve against its directory, title defaults to the first H1 or the file name); reads rewrite Feishu's HTML tables back to Markdown and return temporary download URLs for embedded images (new `docs:document.media:download` scope); `replace` refuses documents containing blocks Markdown cannot reproduce unless `allow_format_loss: true`; `append` now also requires the revision id; comments include author ids and resolved names. Two gaps are platform-limited: an app cannot grant itself collaborator access, and tenant-token document search returns no results (it is user-visible search). 1372 tests / 68 files, typecheck and build pass. ### 修复:replace 强制版本守卫、发起人共享跨重启、维护权限独立成项(2026-11-04) **`replace` 现在必须带 `revision`**:它会删掉整篇正文,所以不接受「没有证据证明副本是最新的」调用;缺失时直接报错并提示先 `feishu_doc_read`(`append` 仍可选)。彻底消除「模型忘了传 revision 就静默覆盖」的路径。 **发起人身份落盘**:`chatToSender`(chat → 最后发言者 open_id)并入 bridge 的会话状态文件,加载时只接受 `ou_` 前缀、写入仅在值变化时触发;重启后由飞书发起的 `create` 仍能把文档共享给发起人,不再因为内存清空而 `shared=false`。 **维护权限独立成项**:`space:document:retrieve` / `space:document:delete` 单列为 `FEISHU_DOC_MAINTENANCE_SCOPES`,**不进扫码默认权限**(删除权不该默认授予,文档工具本身不删除任何文档);需要清理时用 `applicationConfig.patch` 的 `scope.add_scopes` 显式开通。本部署已开通(应用权限 42→44),并用它清掉了 5 篇探测文档,只保留 1 篇演示文档。 **验证**:新增 2 例(`replace` 缺 revision 被拒、发起人跨重启恢复);全量 **1368 测试 / 68 文件**、`typecheck`、`build` 全绿。 **English**: `replace` now requires the `revision_id` from `feishu_doc_read` (append keeps it optional), the chat→last-sender open id is persisted with the session map (loaded only for `ou_` values, saved on change) so a created document stays shareable after a restart, and the two maintenance scopes (`space:document:retrieve`, `space:document:delete`) live in their own exported constant and are deliberately absent from the scan defaults. The deployment granted them explicitly and used them to delete five probe documents. 1368 tests / 68 files, typecheck and build pass. ### 新增:文档协作工具——版本守卫、评论、块级编辑、图片读回(`src/feishu-doc.ts` / `src/feishu-doc-collab.ts`,2026-11-04) **版本守卫**:`feishu_doc_read` 现在返回 `revision_id`;`feishu_doc_write` 新增 `revision` 参数,写前比对当前版本,不一致直接失败并提示重新读取。**依据**:飞书的 `document_revision_id` 参数**不会拒绝过期版本**——用旧 revision 调 `documentBlockDescendant.create` 实测仍然 `code=0` 并写入,所以乐观锁只能放在应用层(剩余竞争窗口毫秒级)。 **评论**:新增 `feishu_doc_comments`。`list` 返回评论线程(id、是否全文评论、是否已解决、锚定引用、回复);`create` 发一条**全文评论**。飞书的创建接口只支持全文评论,且**回复全文评论会被拒**(`1069302 The comment section does not allow replies`,实测),因此不提供回复动作,工具描述里写明。 **块级编辑**:新增 `feishu_doc_blocks`。`list` 返回块 id / 类型 / 文本预览 / 图片素材 token;`update` 用 `documentBlock.patch` 的 `update_text_elements` 就地改一个块的文本——不动其他块,也不动 Markdown 转换覆盖不到的人工结构(callout、折叠、分栏等)。代价是该块的**行内样式重置为纯文本**。 **图片读回**:Markdown 读取永远不含图片(`docs.v1.content.get` 的口径),所以读取工具额外做一次块扫描,返回 `images: [{ block_id, token }]`,并在元数据行标注图片数量。 **权限**:不需要新 scope——评论用平台模板自带的 `docs:document.comment:*`,块级接口用已有的 docx 块权限。 **验证**:新增 `tests/feishu-doc-collab.spec.ts`(6 例:评论列取/创建/必填、块列取/更新/必填),`tests/feishu-doc.spec.ts` 增至 18 例(版本 + 图片读回 + 版本冲突拒绝);全量 **1366 测试 / 68 文件**、`typecheck`、`build` 全绿。真机侧已实测评论创建/列表、块 patch、revision 递增与「过期 revision 不被拒」。 **English**: Adds a collaboration layer on top of the document tools: `feishu_doc_read` now returns the revision id and the image blocks the Markdown body omits, `feishu_doc_write` accepts a `revision` and refuses to write when the document moved (the platform's `document_revision_id` does not reject stale revisions — verified), `feishu_doc_comments` lists/creates whole-document comments (replying to them is rejected with `1069302`, so no reply action), and `feishu_doc_blocks` lists blocks with ids/types/text/image tokens and rewrites one block in place via `update_text_elements` (inline formatting becomes plain text). No new scopes were needed. 1366 tests / 68 files, typecheck and build pass. ### 新增:文档图片自动上传 + 新建文档自动共享给对话用户(`src/feishu-doc.ts` / `src/harness.ts` / `src/channel.ts` / `src/provision.ts`,2026-11-04) **背景**:应用身份(tenant token)建出的 docx 归**机器人自己**所有(owner = bot 的 open_id,实测),用户拿到链接打不开;同时 Markdown 里的图片此前会被整块丢弃。 **图片**:解析 Markdown 里的 `![alt](target)`,只接受**绝对路径的本地文件**(png/jpeg/webp/gif、≤10 MB;远端 URL 不抓、相对路径无可靠基准),按出现顺序保留对应的 image 块。插入块后用 `drive.v1.media.uploadAll`(`parent_type: docx_image`、`parent_node` = image 块的 `block_id`)上传素材,再 `documentBlock.patch` 的 `replace_image: { token }` 挂上去。每张图独立失败、只记跳过,不中断整篇。 **共享**:bridge 在每条入站消息上记住该 chat 最后一个发言用户的 open_id(**仅内存**,重启丢失即跳过共享);`create` 成功后调 `drive.v1.permissionMember.create`(`member_type: openid`、`perm: edit`、`need_notification: true`)把文档共享给该用户。`append` / `replace` 不共享(文档已有归属)。 **权限**:扫码流程的 `FEISHU_DOC_SCOPES` 增加 `docs:document.media:upload`、`docs:permission.member:create`;当前应用已通过 `applicationConfig.patch` 补到 42 项(均 `grant_status=1`)。 **验证**:`tests/feishu-doc.spec.ts` 增至 16 例(新增:图片引用分类、上传+`replace_image` 调用形状、远端图片跳过、`create` 共享、`append` 不共享);全量 **1358 测试 / 67 文件**、`typecheck`、`build` 全绿。真机 API 侧已实测 `uploadAll` / `patch` / `permissionMember.create` 均 `code=0`;工具路径待重启后复验。 **已知边界**:`docs.v1.content.get` 读回的 Markdown **不含图片引用**(实测),所以读回不能用来判断图片是否嵌入;被跳过的图片会在文档里留下空图片占位。 **English**: Local images in the Markdown are now uploaded and embedded (absolute paths only, png/jpeg/webp/gif up to 10 MB; remote URLs are never fetched) via `media.uploadAll(parent_type: docx_image)` plus `documentBlock.patch` → `replace_image`. A newly created document is shared with the user who asked for it through `permissionMember.create` (the chat's last sender open id is kept in memory by the bridge; when it is unknown the share step is skipped and reported). The scan flow now also requests `docs:document.media:upload` and `docs:permission.member:create`; the existing app was topped up to 42 granted scopes. 16 document tests (5 new), 1358 tests / 67 files, typecheck and build pass; the live API calls were verified individually. Reading a document back as Markdown does not include image references. ### 变更:扫码建应用补齐在线文档权限;当前应用已在线补齐并完成端到端验证(`src/provision.ts` / `src/feishu-doc.ts`,2026-11-04) **权限**:平台模板本就带 `docx:document:readonly`、`docx:document:write_only`、`docx:document.block:convert`、`wiki:node:read`、`drive:drive.metadata:readonly`,缺的只有两项——`docx:document:create`(新建文档)与 `docs:document.content:read`(Markdown 读取)。扫码流程新增 `FEISHU_DOC_SCOPES` 把这两项预填给以后创建的应用。 **当前这个应用**:用自身凭证调 `application.v7.applicationConfig.patch` 的 `data.scope.add_scopes` 补齐(`code=0`,`application.scope.list` 从 38 项变 40 项、两项均 `grant_status=1`,免审权限立即生效),`document.create` 从 `99991672` 变为成功。 **端到端**:`feishu_doc_write`(create)→ `document.convert` 27 块 / 18 个一级块 → `documentBlockDescendant.create` → `feishu_doc_read` 读回,全部成功。回读与写入的差异如实记录在 [能力调研](<./docs/feishu-capabilities.md>) 第八节:标题是正文首个 H1 块(`docs.v1.content.get` 一并返回)、表格回读为 HTML ``、代码块语言被规范化(`js`→`JavaScript`)、已完成待办回读带删除线、行内样式与 H1–H6 完整保留。 **顺带修正**:`feishu_doc_read` 的渲染不再在正文前重复输出一次标题(文档正文首块就是标题),标题改放元数据行。 **仍需注意**:删除文档需要 `drive:drive` 或 `space:document:delete`(未申请),因此 API 建出的探测文档无法自行删除;应用自建文档对普通用户默认不可见,要分享需另加协作者。 **验证**:`tests/provision.spec.ts` 断言新权限已进 `addons.scopes.tenant`;全量测试 / `typecheck` / `build` 全绿;真机建文档、写块、读回均通过。 **English**: The scan flow now pre-fills `docx:document:create` and `docs:document.content:read` (the platform template already covers readonly/write_only/convert/wiki read). For the existing app both scopes were added through `applicationConfig.patch` → `scope.add_scopes` (`code=0`, 38→40 scopes, both `grant_status=1`, effective immediately), turning `document.create` from `99991672` into success. Verified end to end: create a document, convert 27 blocks, insert, then read it back as Markdown; the read-back differences (title as the first H1 block, tables as HTML, normalized code language, strikethrough on completed todos) are documented. The read tool no longer repeats the title above the body. ### 新增:飞书在线文档读写工具 `feishu_doc_read` / `feishu_doc_write`(`src/feishu-doc.ts`,2026-11-03) **动机**:在此之前,飞书通道只把「聊天」翻译成卡片;用户的真实工作却常常在飞书在线文档里,agent 拿不到也写不进。这两把工具让模型直接以 **Markdown** 读写 docx 文档,而不是把文档内容复制进聊天。 **读**(`feishu_doc_read`):接受 `…/docx/`、`…/wiki/` 链接或裸 token。wiki 链接先经 `wiki.v2.space.getNode` 解析成底层 docx;正文优先用 `docs.v1.content.get`(`content_type: markdown`,唯一返回 Markdown 的接口),该权限缺失时降级到 `docx.v1.document.rawContent` 纯文本并在结果里标 `format: text`。长文按 `max_chars`(默认 20,000,上限 60,000)截断并给出 `next_offset`,可以续读。 **写**(`feishu_doc_write`):三种模式——`create`(`docx.v1.document.create` + 插入块)、`append`(插到正文末尾)、`replace`(`documentBlockChildren.batchDelete` 清空后重写)。Markdown 先经 `docx.v1.document.convert` 转成原生块,再用 `documentBlockDescendant.create` 一次插入(`children_id` 取 `first_level_block_ids`)。写回前递归剥掉只读字段(表格 `merge_info`、`comment_ids`、`parent_id`);**图片块跳过**(转换产物没有素材 token,插入会让整次请求失败),结果里报 `skipped_images`。只支持新版文档(docx),旧版 `doc`/`sheets`/`bitable` 明确报错。 **权限(2026-11-03 真机探针,应用身份)**:本租户当前**缺两个 scope**,工具上线前需在开发者后台开通——`docx:document`(或仅新建时 `docx:document:create`)与 `docs:document.content:read`;两者未开通时分别返回 `99991672` 并给出申请链接。其余链路(文档/块读写、convert、wiki 解析、drive 元数据)已过权限闸门。另外应用身份只能操作**已把应用加为协作者**的文档。 **验证**:新增 `tests/feishu-doc.spec.ts`(11 例:目标解析、wiki 解析、截断续读、纯文本回退、图片剔除、append/replace/create 的调用形状、缺 target 报错);全量测试、`typecheck`、`build` 全绿。真机端到端待 scope 开通后补测。 **English**: Adds `feishu_doc_read` / `feishu_doc_write` model tools so the agent can read docx documents (and resolve wiki links) as Markdown and write them back as native blocks through `convert` + `documentBlockDescendant.create`, in `create` / `append` / `replace` modes. Reads fall back from the Markdown endpoint to `document.rawContent` when the Markdown scope is missing; long bodies truncate with a `next_offset`. Image blocks are skipped (converter output carries no media token), read-only fields are stripped before insert, and only docx is supported. Live probing on 2026-11-03 shows this tenant still needs `docx:document` (or `docx:document:create`) and `docs:document.content:read`; every other call in the chain passes the permission gate. 11 new tests; full suite, typecheck and build pass. ### 修复:同一轮出现两张一模一样的「✅ 完成」卡(`src/feishu-streaming.ts` / `src/channel.ts`,2026-10-30) **现象**:`compact` 档的一轮结束后,话题里出现两张标题与正文都相同的卡(`✅ 完成 · 2m39s`,正文逐字一致):一张是承载答案的紧凑过程卡收尾,另一张是 `channel.ts` 的兜底回答卡。 **取证**:日志顺序 `[send] … via card=…` → `[update] … seq=1` → `[reply] no step card carried the text → posting 1 reply card(s)`,且期间**没有**任何 `[update] failed`。也就是说收尾写入是成功的,但 `flushed()` 判定「本轮没有任何带正文的卡确认落地」(`delivered === 0` 或 `failed > delivered`),于是撤回「正文已上屏」标记、由 `channel.ts` 补发一张同样的卡。 **修法**(两层,都不放宽坑 45 的保护): 1. **收尾写不再误判**:`feishu-streaming` 记住最后一次**确认落地**的正文;之后若一次写入被拒、而它的正文与已落地的一致(说明只改了表头/指标),不再记失败——答案已经在屏幕上,缺的只是 chrome。 2. **兜底前比对内容**:`channel.ts` 准备补发回答卡前,先问流式侧「最新渲染的那张卡是否已经显示这段正文」;是则跳过补发(仍照常发交付卡与自动投递),从根本上避免同内容双卡。 3. 诊断:`flushed()` 现在打一行 `delivered=… failed=… keepNote=…`,判定依据不再隐身。 **验证**:新增 2 例(流式侧「只改表头的写入被拒不动标记」;`startChannel` 侧「卡已渲染同一答案时不补发」),全量 **1342 测试 / 66 文件**、`typecheck`、`build` 全绿。 ### 修复:通道停机不再「静默半关」,生命周期有可见日志(`src/channel.ts`,2026-10-29) **背景**:同日一次插件热重载后飞书**出站正常、入站全丢**,排查时发现两件事都不可见:① 连接建立/断开只走 Host logger 的 `info`,而本部署该级别被过滤 ⇒ 日志里从没有过这两行;② `stop()` 里 `withTimeout(channel.disconnect())` 超时后只是「不再等」,既不说明也不再尝试关闭。 **实现**:连接建立/断开同时写 stdout(`channel up (inbound live)` / `channel down (socket closed, pipeline drained)`);`channel.disconnect()` 内部分两步——先关 socket,再排空 SDK 发送管线——所以排空失败/超时时明确打印 `channel down with an un-drained pipeline (...)`,说明 socket 关闭已发出,并**照常 dispose bridge**,不再让卡住的排空把整个停机拖成 rejection,也不让它伪装成「什么都没发生」。 **验证**:`tests/plugin.spec.ts` 的「disconnect 失败」用例改为断言新契约(不 reject、如实上报、仍 dispose bridge);全量 1340 测试 / 66 文件、`typecheck`、`build` 全绿;重启后日志确认 `channel up (inbound live)`,入站恢复。 **English**: After a hot reload the bridge could keep sending while receiving nothing, and neither the connect/disconnect lines nor the abandoned pipeline drain were visible (the Host logger's `info` level is filtered in this deployment, and a timed-out `disconnect()` was swallowed). Connect/disconnect now also go to stdout, and a drain that does not settle is reported as `channel down with an un-drained pipeline (...)` — the socket close was already requested — while the bridge is still disposed. The disconnect-failure test now asserts that contract. 1340 tests / 66 files, typecheck and build pass; the restart log shows `channel up (inbound live)` and inbound works again. ### 修复:快捷切换模型「卡说成功、实际没换」(`src/harness.ts` / `src/feishu-model-select.ts` / `src/commands.ts`,2026-10-29) **现象**:在飞书里 `/model` 卡选过模型后,再用「🕘 最近使用 / ↺ 切回上次使用 / `/model last` / 推理档位」切换,卡片显示「✅ 已切换」,但下一轮请求仍用旧模型。真机取证(会话日志):17:55 用确认卡切到 `cpa-codex/gpt-6.1-terra`(生效,17:57 起所有 `request/header` 都是 terra);20:04 点「切回 `gpt-6.1-sol`」,卡读回是绿色「✅ 已切换 \`cpa-codex/gpt-6.1-sol\`」,而 20:16–21:16 的每一次请求**都还是 terra**,且没有任何新的 `model/selection` 事件。 **根因**:两条路线抢同一个 waterfall。宿主的 `sessionController.selectModel` → `agents.selectForNextRequest` 会给该 agent 装 `installModelSelection`(`system-prompt/assemble` + `agent/request` 两个监听器);插件的 `harness.switchRoute` 自己另装一对同样的监听器。Cordis waterfall 是「**先注册的在外层、返回值最终生效**」(`dispatch` 按注册顺序 `shift`,外层拿到内层结果再覆盖),所以 17:55 那次(宿主先装)永远把 20:04(插件后装)的覆盖改回去 —— 而 `switchRoute` 只看「agent 存不存在」,照样返回 `ok:true`,卡片就诚实地(但不正确地)报了成功。 **修法**:`switchRoute` 主路径改为调用宿主的 `sessionController.selectModel` —— 与 WebUI 选择器、飞书 `/model` 确认卡**完全同一个入口**(`agents.selectForNextRequest` + `model/selection` 事件),只有在宿主没有 `selectModel` 时才退回原来的本地钩子。新增失败原因 `rejected`(带宿主原始理由),卡片与命令都显示真实原因,不再伪装成功。 **语义变化(如实记录)**:所有切换现在都会同步**全局默认**(`selectModel` 的固有行为)。它只作为**以后新建会话**的种子;已有会话的路线按「本会话 pending → 自己日志里的 `request/header` → 默认」解析(`packages/api/session-controller/src/agent.ts:290`),所以**别的会话不会被改**。相关文案(模型卡两条成功脚注、`/model last`、`/reasoning`)已同步改为「只影响本会话;全局默认同步为它,只作用于以后新建的会话」,不再声称「没有改全局默认」。`README.md` 同步。 **验证**:`tests/harness.spec.ts` 新增 3 例(走 controller 且断言入参 sessionId/effort;controller 拒绝时返回 `rejected` + 原始理由;无 controller 时退回本地钩子并区分 no-agent / ok);`typecheck` / 全量测试 / build 全绿。**真机确认(2026-10-29 22:57)**:点「🕘 最近使用 → `cpa-codex/gpt-6.1-sol`」后会话日志新增 `model/selection = cpa-codex/gpt-6.1-sol`,下一轮 `request/header` 同步变为它 —— 卡面与真实路线终于一致。 ### 改进:可配置的 5 秒耗时刷新与附件等待文字(2026-10-29) - 运行中耗时刷新默认由 30 秒改为 **5 秒**,与步骤占位计时共用 `elapsedRefreshSeconds`(3/5/10/30 秒)。`/display` 卡片增加间隔按钮,文本命令支持 `/display tick [3|5|10|30]`;持久化后重绘同一张卡,不重建通道。已排定的定时器不取消,下次排程读取新值;慢写期间只保留最新一版刷新。 - 图片/文件/视频/语音没有用户文字时先存入 DSH 附件库,**不启动回合**。最后一个附件成功接收后静默 3 秒发累计数量提示;新成功附件重置倒计时,失败添加不重复提示。文字先到就取消未发提示、将暂存与本条消息一次提交;查询命令不消费暂存。 - 按聊天/话题隔离。禁用 SDK 的聊天级批次合并、保留串行队列,避免资源被归到最后一条消息的话题或吞掉后续斜杠命令。成功换绑清旧暂存,首次 onboarding 可认领初次绑定;在途上传核对绑定代际,避免跨会话。原生接受前的失败恢复附件、不重复提交文字;清理取消提示,不触发回合。暂存关联为内存态,重载不恢复,已落库对象不删除。 - 图片上限动态读取 `attachments.imageLimits`,累计**原始编码字节**、图片数与最大单图大小,提交前重新校验;本次新增超限不清掉此前暂存。图片保存改走原生 `saveImages()` 批次入口;通用文件无插件自设数量/大小配额。飞书传输约束与宿主准入政策分开。通道断开与 bridge 清理等待均有界。 **English:** Running elapsed clocks now default to five seconds, configurable through the same-message `/display` card or `/display tick` (3/5/10/30). Image/file/video/voice-only messages stage durable attachment references without starting a turn; after three quiet seconds a cumulative receipt asks for text. Further successful uploads debounce again, text consumes the draft, and commands leave it untouched. Chat/topic isolation, binding generations, native-admission rollback and original-image-byte accounting prevent cross-session delivery and silent loss. Image budgets follow the host's live policy; generic files gain no invented quota. Staging associations are memory-only and are not restored after reload. **验证 / Validation:** 66 个测试文件、1337 项测试通过;host/client typecheck 与插件 build 通过。新增防抖、限额、话题隔离、绑定变化、接受前回滚和跨代 reservation 回归。代码已构建,尚未重启部署;真实飞书点击/上传验收待部署后进行。 ### 改进:`/display` 卡片改成 `/busy` 那样**同一张卡就地更新**(`src/feishu-display.ts`,2026-10-28) **反馈**:点选 `display` 卡片的按钮后,旧卡「内容原样保留 + 表头转灰 + 底部一句『此卡片已失效』」,并要求「改成 Busy 卡片那样的」。 **取证(先比对了仓库里所有交互卡的旧卡处理)**:`/new` 是「整卡改写为绿色结算卡(`mode: 'replace'`)」;`/new` 的目录翻页是「收成灰色失效提示」;问题卡是「就地改写成结算卡」;交付确认卡是「发新卡 + 旧卡去按钮」;而 `/busy`、`/permission`、`/model`、`/session` 都是**同一张卡就地重绘**。`/display` 当时是唯一还在「保留内容 + 失效脚注」的决策卡。 **实现**:翻回设计文档的方案 C,与 `/busy` 统一——`open()` 用内嵌卡发送(`channel.send`),点击后用 `channel.updateCard(messageId, card)`(`im.v1.message.patch`)重绘**同一条消息**;`DisplayCardChannel` 收敛为 `send` / `updateCard` / `onCardAction`;去掉 `card-supersede` 接线与 messageId→chat 记忆表(点击按 payload 处理、重绘按来源 messageId 定点,**插件重启前发出的卡重启后仍可用**);已选中档位按钮加 `disabled: true`(状态而非动作,同 `/busy`);重绘失败只 `warn`(设置已落库,卡面保留上一版)。 **已知取舍**:就地更新在飞书侧「改 2–3 次后可能停止投递按钮回调」(关键坑 11)。接受而不绕开的理由:`/busy`、`/permission`、`/model` 已长期依赖就地更新;本卡每次写入都由人手点击触发;若真失效,重新 `/display` 即得新卡。没有做「N 次后换新卡」的自适应——那正是本次要消除的多卡/失效卡。 **验证**:`tests/feishu-display.spec.ts` 重写为就地更新契约(15 例:发一张卡、点击同一 messageId 重绘、档位点击同样就地重绘且选中项 disabled、异步写未落库前不重绘、首次发送落在原话题、重启前旧卡点击可用、非法 payload 不写不绘、`stop()` 后连在途点击也忽略、重绘失败只 warn)。`docs/display-card-design.md` 追加第十节记录改版理由与差异。`typecheck`(含 client)/ 全量测试 / build 全绿。**未重启**。 **English**: The operator asked for the `/display` card to behave like the `/busy` card. A survey of every interactive card showed three retirement shapes in the repo — `/new` rewrites the clicked card into a green settlement card, the folder-browser pages collapse to a grey stale notice, and `/busy` / `/permission` / `/model` / `/session` simply repaint the SAME message — while `/display` was the last decision card still doing "keep the content, append a stale footnote". It now follows `/busy`: `open()` sends one embedded card, and every tap writes the setting and repaints that same message via `im.v1.message.patch`. `DisplayCardChannel` shrinks to `send` / `updateCard` / `onCardAction`; the supersede wiring and the messageId→chat map are gone (a click is handled by its payload and repaints its own message, so a card posted before a plugin restart keeps working); the selected level button is `disabled` (state, not action); a failed repaint only warns. The known in-place risk (关键坑 11: callbacks may stop after ~2-3 edits) is accepted rather than designed around — `/busy`, `/permission` and `/model` already live on in-place updates, every write here is a human tap, and a fresh `/display` restores a working card. `tests/feishu-display.spec.ts` was rewritten to that contract (15 tests) and `docs/display-card-design.md` gained section 10. Typecheck (incl. client), full suite and build pass. Not restarted yet. ### 改进:PTC 模式下 compact 回合卡显示「这段程序真正调用了什么」(`src/feishu-progress.ts` / `src/feishu-streaming.ts`,2026-10-28) **反馈**:「PTC 模式的 run code 工具在紧凑显示模式没有任何摘要」。 **取证**:真机读回 PTC 话题里的回合卡,行就是 `✅ \`run_code\` · 506ms`。紧凑卡工具行的「是什么」由 `progressToolDetails()` 推导,它只认 read/write/edit/bash/pwsh,`run_code` 落到 undefined;而 `run_code` 的 schema 里 `description` 是**必填**、说明里写明「shown in the UI」,非紧凑的步骤卡本来就会显示它。更大的缺口是:PTC 模式下模型只调 `run_code`,程序内部真正执行的工具只出现在会话事件 `tool/ptc-dispatch-start` / `tool/ptc-dispatch`(Web UI 据此渲染嵌套子调用),插件此前完全没订阅——实测一轮真实 PTC 回合 = 12 次 `run_code` + 47 次子调用(`web_fetch` 17、`read` 10、`bash` 7、`glob` 3、`todo_write` 3、`grep` 2、`web_search` 2 …),卡片上全都看不见。 **实现**:① `progressToolDetails` 增加 `run_code` 分支,取其 `description`(压成一行、丢反引号、转义 markdown 活动字符、截断 80 字);② 新增 `progressSubtoolDetails`:文件/程序沿用原规则,网页取 **host**、搜索取 **query/pattern**(子行截断 40 字);③ 进度卡模型新增 `ProgressTool.children` 与 `subtoolStarted` / `subtoolFinished`,子行渲染为 `> ↳ ✅ \`read\` ×3 · AGENTS.md · 160ms`——箭头在状态图标**前**、工具名反引号**外**,同类按名合并、随父行一起结算;④ streaming 订阅两个 `tool/ptc-dispatch-*` 事件并转进回合卡(父行不在卡上就丢弃,避免凭空多出一行「模型没调过的工具」);⑤ 父调用结算 / `step/end` / `turn/end` 时把仍在跑的子行按实际耗时收尾,被中止的程序不会留一个永远转不完的行;失败子行标 ❌ 但**不计入标题的工具数**(标题数的是模型发起的调用)。 **否决的方案(有据)**:① 用多一级 markdown 引用(`> > `)表示缩进——飞书卡片渲染器**静默丢弃**嵌套引用(探针 ①⑤⑥⑦⑧ 六种写法全丢,两次样板卡真机可见,官方文档也只文档化单级 `>`),已记为关键坑 70;② 子行用全角空格缩进(能显示但不是真实布局缩进,读起来仍靠假空格)。 **验证**:新增 11 例(渲染 5:箭头位置/同名合并/运行中/失败不影响标题计数/仍只占一个元素;活动卡 6:挂载到父行、未知父丢弃、重放去重、父结算收尾、`turn/end` 收尾、只余子行时仍保持 tick;详情派生 6 例并入既有 describe),`typecheck`(含 client)/ **1298 测试 / 64 文件** / build 全绿;定稿样板卡由真实渲染器渲出、经使用者确认「效果不错」。**未重启**,等授权。 **English**: Reported as “PTC mode's run_code tool has no summary at all in compact display”. The compact row's “what” came from `progressToolDetails()`, which only knows read/write/edit/bash/pwsh, so `run_code` fell through to undefined — even though that tool's schema REQUIRES a `description` described as “shown in the UI”, and the non-compact step card already rendered it. The bigger gap: in PTC mode the model only calls `run_code`, and the tools its program actually runs reach the session stream as `tool/ptc-dispatch-start` / `tool/ptc-dispatch` (what the Web UI renders as nested sub-calls), which the plugin never subscribed to — one real turn was 12 `run_code` calls plus 47 sub-calls (web_fetch 17, read 10, bash 7 …), none visible. Now the parent row carries the description and each dispatched call renders as `> ↳ ✅ \`read\` ×3 · AGENTS.md · 160ms`: the arrow sits BEFORE the status icon and OUTSIDE the tool-name code span, same-named runs merge, and rows settle with their parent (a parent that settles, or a step/turn that ends, closes any sub-call still running; a failed sub-call shows ❌ without inflating the title's call count, which counts the model's own calls). Two alternatives were rejected with evidence: nested markdown quotes (`> > `) — the card renderer silently drops nested blockquotes (six probe shapes, two live sample cards, and the official docs only document single-level `>`; recorded as pitfall 70) — and full-width-space indentation (visible, but a fake indent). 11 new tests; typecheck (with client), 1298 tests / 64 files and build pass; the final sample card was rendered by the real renderer and approved by the operator. Not restarted yet. ### 恢复:compact 回合卡的标题回到原措辞 `🐳 深度求索中 · 12.3s`(`src/feishu-progress.ts`,2026-10-27) **背景**:同日为解决「工具调用期间步骤显示思考中」,先把卡片**标题**改成了相位文案(`🔧 工具调用中 · …` / `✍️ 回复中 · …`)。使用者指出方向错了:**要修的是步骤行反映的事实,不是卡片标题**(「步骤卡片的标题,你没有给我恢复」)。 **实现**:撤回标题改动——运行中始终是原来的 `🐳 深度求索中 · <已用时>`,纯文本兜底标题同样;**保留**步骤行小灯的相位修复(`tool-call-delta → 🔧 工具调用中` 等)。`runningTitle()` / `turnLivePhase()` 删除;`stepLivePhase()` 保留(步骤行与工具行的判据)。 **验证**:progress 测试改为钉住原措辞(工具运行 / tool / 回复 / 思考 / 等待五种相位下标题都必须是 `turnCardTitleRunning`,回合结束后仍是终态标题),`typecheck`(含 client)/ 全量测试 / build 全绿。**未重启**。 **English**: Same day, the running TITLE of the compact turn card had been made phase-aware while fixing 「工具调用期间显示思考中」. The operator corrected the direction: what matters is the STEP line's facts, not the header — “the step card's title, you did not restore it”. The title is back to the original constant `🐳 深度求索中 · ` (plain-text fallback too), and the step-light fix (a live `tool-call-delta` names the tool phase) stays. `runningTitle()`/`turnLivePhase()` are deleted; `stepLivePhase()` remains as the shared judge for the step light. Progress tests now pin the original wording across every phase. Not restarted yet. ### 修复:模型**正在生成工具调用**时,步骤仍显示「思考中」——现在有独立的 `工具调用中` 相位(`src/feishu-streaming.ts` / `src/feishu-progress.ts` / `src/i18n.ts`,2026-10-27) **反馈更正**:使用者澄清——不是标题的问题,是**步骤行反映的事实不准**:Agent 正在做工具调用时,步骤仍显示「思考中」,直到工具跑完才直接跳到「✅ + 耗时」。 **根因**:插件订阅的实时信号 `agent/assistant-stream` 只映射了两种 chunk——`reasoning-delta → thinking`、`text-delta → replying`;**`tool-call-delta` 被直接丢掉**(`wanted === undefined` 即 return)。而 tool-call 的流式增量正是「模型正在把工具调用写出来」这段窗口的唯一信号(DSH 侧两种路由 `llm-deepseek` / `llm-pi-ai` 都发 `tool-call-delta`)。于是这段窗口里卡片保留上一个相位 = `🧠 思考中`;等 `tool/call` 落地、工具真的开始执行时行里才有 `🔧`,而工具常常几百毫秒就结束(`read_image` 217ms、`sed` 150ms)⇒ 肉眼看到的就是「一直思考中 → 直接工具完成 + 耗时」。长参数的工具(例如写一大段 `bash` 脚本)这个窗口长达几十秒,问题最明显。 **实现**:① 新增相位 `tool`(`StepLivePhase` / `ProgressPhase` 都加),`tool-call-delta`(以及 `tool-call`)→ `tool`;② 同一 attempt 内相位**只能前进**(`awaiting < thinking < replying < tool`),迟到的 reasoning/text delta 不会把 `工具调用中` 打回旧相位;③ compact 回合卡:标题与步骤行小灯都显示 `🔧 工具调用中 · 12.3s`;④ 非 compact 档的占位步骤卡也认得这个相位(新 key `stepToolTitle` / `stepToolBody`,蓝底),不再退回「⏳ 正在分析请求」;⑤ `end(abandoned)` 仍回退到 `none`(DSH 要重试,重试会发自己的 `start`)。 **验证**:新增 3 例(compact:`reasoning-delta` → 思考中、随后 `tool-call-delta` → 工具调用中、再迟到 reasoning delta **不回退**;进度卡:`phase: 'tool'` 的标题与步骤行;步骤卡占位:`tool` 相位的标题与正文),`typecheck`(含 client)/ **1279 测试 / 63 文件** / build 全绿。**未重启**。 **English**: Correction from the operator: the complaint was not the title but the STEP line's facts — while the agent is doing a tool call the step still reads 「思考中」, and only when the tool finishes does it jump to `✅` + duration. Root cause: the plugin's live `agent/assistant-stream` handler mapped only `reasoning-delta → thinking` and `text-delta → replying`; **`tool-call-delta` was dropped** (`wanted === undefined → return`), yet those deltas are the only signal for the window in which the model is writing the tool call (both DSH routes, llm-deepseek and llm-pi-ai, emit them). So that window kept the previous phase (思考中); by the time `tool/call` lands the tool is often already finished (read_image 217 ms, sed 150 ms), which is exactly the reported "thinking → finished + duration" jump. New `tool` phase: `tool-call-delta`/`tool-call` map to it, phases inside one attempt may only move FORWARD (`awaiting < thinking < replying < tool`, so a late reasoning delta cannot undo 工具调用中), the compact card shows `🔧 工具调用中 · …` in both the title and the step light, and the placeholder step card at the other levels names it too (`stepToolTitle` / `stepToolBody`). 3 new tests; typecheck (with client), 1279 tests / 63 files and build pass. Not restarted yet. ### 改进(**已撤回**):compact 回合卡的运行标题改为跟随相位(`src/feishu-progress.ts`,2026-10-27;同日按使用者要求恢复原样,见下一条) **反馈**(使用者):「模型在执行工具调用的时候,步骤仍显示思考中,直到工具完成,直接显示工具完成和耗时,没有反应真正的状态。」 **取证**(真机读回服务端渲染态,2026-10-27 02:48–02:49):回合卡(`compact` 档,标题 `🐳 深度求索中 · N`)在一条 30 s `bash` 运行期间,**步骤行**是如实更新的: ``` ▸ 第 40 步 · 🔧 工具调用中 · 30.0s… > ⏳ `bash` · sleep / echo / python3 · 25.8s… ``` 但**卡片标题**从第一个 token 到工具结束一直是常量 `🐳 深度求索中 · N`(`turnCardTitleRunning`),工具跑几分钟它就"思考"几分钟——卡片上最大的一行字始终在说一件这件事没在做的事;工具一结束标题才变成终态(`⚙️ 本回合 …`),正好对上"直到工具完成,直接显示工具完成和耗时"。 **实现**:`runningTitle()` 取「最新一个仍打开的步骤」的实时相位(新增 `stepLivePhase()`,与步骤行的小灯共用一份判据):工具未结算 → `🔧 工具调用中 · 12.3s`;模型在出字 → `✍️ 回复中 · …`;等首 token → `📡 等待输出 · …`;**真在思考**(或还没收到相位)→ 保留 `🐳 深度求索中 · …`(丢失帧不臆造相位)。纯文本兜底标题同步。 **验证**:更新/新增 5 例(运行中工具 → 工具标题、回复中 → 回复标题、思考中保留原措辞、无相位不臆造、回合结束后仍是终态),并修正 4 个既有用例的标题断言(它们原本断言"工具运行期间标题恒为深度求索中"——正是被本次改掉的旧行为);`typecheck`(含 client)/ **1277 测试 / 63 文件** / build 全绿。**未重启**。 **English**: The operator reported that while a tool runs the card still says "thinking" and only flips to the finished tool + duration at the end. Live evidence (reading the server-side card render at 02:48–02:49) showed the step ROW is honest — `▸ 第 40 步 · 🔧 工具调用中 · 30.0s…` with `⏳ bash … 执行中…` — but the card's TITLE was the constant `🐳 深度求索中 · N` from the first token until the tool finished, i.e. the largest text on the card kept claiming the model was thinking while a 30 s tool ran, and only became the settled title at the end. The running title now follows the live phase of the newest open step (new `stepLivePhase()` shared with the step light): a running tool → `🔧 工具调用中 · …`, emitting text → `✍️ 回复中 · …`, awaiting the first token → `📡 等待输出 · …`, and the product wording `🐳 深度求索中 · …` is kept only for genuine thinking or when no phase was reported (a lost frame must not invent one). 5 new/updated tests plus 4 fixed title assertions (they encoded the old constant-title behaviour); typecheck (with client), 1277 tests / 63 files and build pass. Not restarted yet. ### 修复:长图/超大图被附件存储拒收(`IMAGE_DIMENSION_TOO_LARGE`)——入站图片先按上限缩放,原图另存为文件(`src/image-fit.ts` / `src/channel.ts`,2026-10-27) **现象**:上一条(批处理配对)修好后,同一批长图变成「图片处理出错:Image exceeds the configured per-side pixel limit. (code: IMAGE_DIMENSION_TOO_LARGE)」。真机图尺寸 **600×8745**,而附件存储的默认单边上限是 **8192px**(`maxImageDimension`,另 6400 万像素总量),超 6.7% 就被整条消息拒收。 **实现**:新增 `src/image-fit.ts`——纯 TS 读容器头拿尺寸(PNG/GIF/JPEG SOF/WebP 三种头;不引第三方解析器,猜错会误缩放)、按「单边 + 总量」算等比目标(边长取偶,给 mjpeg 用)、超限才调一次 `ffmpeg`(`scale=lanczos`,输出 JPEG,20s 超时,无 ffmpeg/失败一律回退原始字节)。`channel.ts` 的图片 admission 在 `saveImage` 前先 fit:缩放后的字节进 attachment store,**原图同时以文件形式落盘**(`saveFileStream` + `hostPath`)并作为 file block 交给会话,任务说明里追加一行 `[image adjusted: 600x8745 → 562x8192 …; full-resolution original: ]`——长图降到 8192 之后细节有限,真正要「截某格」时 agent 仍能从全分辨率原图裁。单张竖条/表情包等不超限的图完全不受影响(零额外开销,不 spawn 进程)。 **验证**:新增 11 例(容器头解析 4;等比/双边上限 3,含 8192² 撞 6400 万像素时退到像素预算;fit 行为 4,其中有**真实 ffmpeg** 一条:600×9000 → 546×8192 且产物能被读回同尺寸),并用**真机那张 600×8745 图**跑了一遍:输出 562×8192 / 1.55 MB(远低于 20 MB 单图上限)。`typecheck`(含 client)/ **1276 测试 / 63 文件** / build 全绿。**未重启**,等确认。 **English**: After the batched-pair fix landed, the same comic strips failed one step later with `IMAGE_DIMENSION_TOO_LARGE` — the real image is 600×8745 while the attachment store caps a side at 8192 px (and 64 M pixels total), so 6.7 % over was enough to reject the whole message. New `src/image-fit.ts` reads the intrinsic size from the container header (PNG/GIF/JPEG SOF/WebP VP8X-VP8-VP8L; no third-party parser, because a wrong guess would resize untouched images), computes an aspect-preserving target that satisfies both the per-side cap and the pixel budget (sides rounded to even for mjpeg), and only then runs one bounded ffmpeg pass (Lanczos, JPEG output, 20 s timeout; a missing binary falls back to the original bytes). The image admission path fits before `saveImage` and — because a 8192-px-tall preview cannot answer “crop panel 4” — also stores the untouched original as a FILE (`saveFileStream` + `hostPath`) and adds a `[image adjusted: …]` line to the turn. Images already within the limits take the old path with zero extra work. 11 new tests, including one against the real ffmpeg (600×9000 → 546×8192, read back at the same size), plus a live check of the actual 600×8745 strip (562×8192, 1.55 MB). Typecheck (with client), 1276 tests / 63 files and build pass. Not restarted yet. ### 修复:一次发多张图必「图片处理出错」——SDK 消息批处理让 (message_id, image_key) 配错对(`src/feishu-resource.ts` / `src/channel.ts` / `src/error-text.ts` / `src/log-redact.ts`,2026-10-27) **现象**(使用者报):agent 运行时发图片,聊天里回「图片处理出错:Request failed with status code 400 (code: ERR_BAD_REQUEST)」,图片进不了会话。 **根因**:SDK 1.74.0 有**按聊天的消息批处理**(600ms 防抖、最多 8 条/4000 字):同一聊天里连着发的多条消息会被合并成**一个** dispatch,其中 `messageId` 取**最后一条**、`resources` 取**并集**(`mergeBatch`:`Object.assign({}, last, { content, resources })`)。用户一次发两张图 = 两条消息 → 合并后插件拿「最后一条的 `message_id`」去下「第一条的 `image_key`」,飞书答 `400 {"code":234003,"msg":"File not in msg."}` ⇒ admission 失败。真机取证:02:08:45 两条 image 消息只产生一次 `[msg]` 日志;直接实验「正确配对 200 / 交叉配对两种都 400·234003」;02:10:44 那批同样复现。因此「单张图」「先文字后图片」通常没事,而**多图、或图片后紧跟文字**必中招。 **实现**:① `feishu-resource.ts` 新增 `downloadInboundResource()`:先按消息 id 试;命中 `234003`/400 时用**一次**有界列表(话题用 `thread`、主时间线用 `chat` 容器,最近 30 条)按 `body.content` 里的 file_key 找回**真正持有它的 message_id** 再下载;文件/视频/音频仍保留 `im.v1.file.get` 兜底,全部失败时抛**原始**错误。图片与文件两条 admission 路径都改走它(真实 API 上验证过列表能找回批内首条消息);② `error-text.ts` 改为**飞书业务码优先**于 axios 的 `ERR_BAD_REQUEST`,并解析被流式下载藏起来的 JSON 响应体(string/Buffer)⇒ 用户会看到「Request failed… — File not in msg. (code: 234003)」;③ `log-redact.ts` 的 SDK 日志改为**循环安全**渲染:数组逐项、axios 形错误只投影 `message`/`code`/`config.method+url`/`response.status`/`response.data.{code,msg}`,**永不打印 `config.data`/`request`**(app_secret 边界不变)——旧实现遇循环引用退化成 `[object Object],[object Object]`,正是这次查不到 234003 的原因。 **验证**:新增 14 例(资源归属解析 10:直连成功不查列表、按 thread/chat 容器找回 owner 并重试、找不到时抛原始错误、非 400 不查、file 兜底、列表失败不影响原始错误、`isResourceNotInMessage` 判定;错误文本 3:业务码优先、string/Buffer 响应体解析;日志 1:**带自引用的 axios 错误数组**渲染出 `234003`/`File not in msg.`/url 且不含 secret 与 `app_secret`),`typecheck`(含 client)/ 全量测试 / build 全绿。**重启后生效**(已授权 5s 重启)。 **English**: Sending several images at once always failed with 「图片处理出错: Request failed with status code 400」. The Feishu SDK batches inbound messages per chat (600 ms debounce, up to 8 messages): a batch is merged into ONE dispatch whose `messageId` is the LAST message's while `resources` is the union of all of them. Two images = two messages → the plugin asked for `(message_id_of_last, image_key_of_first)` → `400 {"code":234003,"msg":"File not in msg."}` → image admission failed. Evidence: one `[msg]` line for two image messages, and direct API experiments (correct pair 200, both cross pairs 400/234003). `downloadInboundResource()` now recovers the owning message: on a 400 it makes ONE bounded listing of the same thread (or chat) and finds the message whose body still contains the file key, then retries there; files/videos/audio keep the `im.v1.file.get` fallback and the original error is rethrown when nothing works. `errorText()` now prefers Feishu's business code over axios's `ERR_BAD_REQUEST` and parses the streamed JSON body (string/Buffer), so the chat says “File not in msg. (code: 234003)”. The SDK logger renders arrays element by element and projects axios errors to message/code/method+url/status/`response.data.{code,msg}` — never `config.data`/`request` — which is why the old line was `[object Object],[object Object]` and hid the business code. 14 new tests; typecheck (with client), the full suite and build pass. ### 修复:飞书通道启动失败不再静默——按退避自动重试(`src/runtime.ts`,2026-10-23) **现场**(2026-10-23):14:29 服务被停,15:03:32 又启动,但当时主机 **DNS 断了**——插件 `apply()` 正常,`channel.connect()` 却失败:`getaddrinfo ENOTFOUND open.feishu.cn`(取 tenant token 的请求连域名都解析不了)→ 拿不到 bot identity(`/open-apis/bot/v3/info`)→ `WebSocket connection failed` → `reconcile failed`。系统日志同期的 DNS 转发一直报 `server failure` / `network is unreachable`,最晚到 15:12:17 还在失败。设置页里那句「启动失败」就是它。 **根因不是网络本身,而是「失败后没有下一次」**:`LarkRuntime.reconcile()` 失败只把快照置成 `error`,**不重试**;再试只有三条路——插件重新 apply(重启)、凭据引用更新、任意设置写入。网络恢复不触发任何一条 ⇒ **15:04–17:58 插件日志零行**,通道静默离线 2 小时 55 分,直到人工重启(注意这与「长连接掉了 SDK 自己重连」是两件事:那次连通道都没建起来)。 **实现**:`reconcile()` 的失败分支改为**有界自动重试**——退避 `5s → 10s → 20s → 40s → 80s → 160s → 300s`(之后固定 300s,一直重试:这类故障靠时间自己好),单飞(同一时刻只有一个待触发 timer,不叠加),成功或 `dispose()` 立即取消并重置计数,每次安排重试 log 一行、恢复后 log 一次失败次数;**`unconfigured`(配置缺失/无效)不重试**,那种情况等不来结果、只会刷日志。timer 一律 `unref()`,不阻止进程(或测试 worker)退出。 **验证**:新增 4 例(按退避重试直到成功且成功后停止、退避封顶在 300s、配置无效不重试、dispose 取消待触发重试),并把三个既有的失败态用例补上 `dispose()`(不留真实 timer);`typecheck`(含 client)/ **1252 测试 / 62 文件** / build 全绿。**未重启**,下次重启生效。 **English**: A channel START failure no longer leaves the bot silently offline. On 2026-10-23 the host lost DNS: the 15:03:32 startup had a healthy plugin `apply()` but `channel.connect()` failed with `getaddrinfo ENOTFOUND open.feishu.cn` (the tenant-token request could not even resolve), so no bot identity, no WebSocket, `reconcile failed`. The real defect was that `LarkRuntime.reconcile()` only records the `error` snapshot and never tries again — the only triggers for another attempt were a plugin re-apply (restart), a credential-reference update or a settings write, so the network coming back did nothing: the plugin logged zero lines between 15:04 and 17:58 and the bot stayed offline for 2 h 55 m until a manual restart. (This is distinct from a dropped WebSocket, which the SDK reconnects by itself: here no channel was ever created.) The failure path now schedules a bounded retry with backoff 5s → 10s → 20s → 40s → 80s → 160s → 300s, then every 300s — retrying indefinitely is deliberate because this class of failure is fixed by time — single-flight (never more than one pending timer), cancelled and reset on success or `dispose()`, one log line per scheduled retry and one when it recovers. An `unconfigured` configuration is NOT retried (waiting cannot fix it, only log noise). All timers are `unref`ed so a pending retry never keeps the process alive. 4 new tests (backoff until success then stop, capped interval, no retry for an invalid configuration, dispose cancels) plus `dispose()` added to the three existing error-state cases; typecheck (with client), 1252 tests / 62 files and build pass. Not restarted yet. ### 修复:`/new` 模型卡上「默认模型」只是一行死文字,点不了;模型卡返回时还会丢流程标记(`src/feishu-model-select.ts` / `src/i18n.ts`,2026-10-23) **反馈**(使用者):「模型选择器还是没法点击选择默认模型」。真机取证(`~/.dsh/read-card.py` 读回 + journal):`/new` 的模型步骤上「最近使用」只有一条 `✅ 当前:deepseek-official/deepseek-flash`,是 markdown **死行**——而这张卡上「默认模型」恰恰是新会话要继承的那个路线,于是唯一想选的东西点不动,只能绕 provider/model 两级下拉。 同时发现一个连带 bug:**模型卡的「← 返回选择 Provider」按钮不带 `flow` 标记**。点它之后 provider 卡按「独立 `/model` 流程」重画(标题/措辞都变回 `/model`),再点上面的行会去**切当前会话**而不是选新会话的路线——真机日志里 `{"action":"select-provider","flow":"new-session"}` 之后紧跟着 `{"action":"go-back"}`(无 flow)就是这个。 **实现**:① `/new` 的卡片把「默认模型」从死行改成**可点行**(`⭐ 默认模型 \`p/m\` · 🧭 强度` + 「点这一行就直接用它(新会话将继承这个默认模型)」),动作走 `switch-recent`(带 `flow: 'new-session'`)⇒ 直接进最终确认卡,**不切当前会话、不写全局默认**;即使全局记忆和会话日志都为空也总是出现(这正是最容易卡住的首次状态);该路线不再在「最近使用」里重复出现;② 模型卡的返回按钮补上 `flow` 标记,返回后仍是「新建会话」卡片、行仍按新会话语义生效;③ `/model` 卡片行为不变(当前路线仍是 `✅ 当前` 标记,因为它已经在生效)。 **验证**:新增 3 例(无任何历史时默认行仍可点且带 flow 标记、点它只进确认卡且断言 `switchRoute`/`selectModel` 均未调用、返回按钮带 `flow` 且返回后卡片仍是新建会话样式),`typecheck`(含 client)/ **1248 测试 / 62 文件** / build 全绿。**未重启**。 **English**: Reported: “the model selector still cannot click to select the default model”. Evidence from the live card and journal: on the `/new` model step the "recently used" section held only `✅ 当前: deepseek-official/deepseek-flash` as a dead markdown line — yet on that card the default route is exactly what the new session would inherit, so the one route worth choosing could not be tapped, forcing the operator through the provider and model dropdowns. A second bug surfaced: the model card's "back to provider" button did not carry the `flow` marker, so going back re-rendered the provider card as the standalone `/model` one and its rows would switch the RUNNING session instead of choosing the new session's route (the journal shows `select-provider` with `flow: "new-session"` followed by a `go-back` without it). Fix: the `/new` card renders the default route as a TAPPABLE row (“⭐ default model … tap to use it for the new session”, action `switch-recent` + `flow: 'new-session'`) that goes straight to the final confirmation card without switching anything or touching the global default, and it is always present even when there is no history at all; the route is no longer repeated in the recent list; the back button now carries `flow`, so going back keeps the `/new` card and its rows keep the new-session semantics. `/model` is unchanged (its current route is already in effect). 3 new tests; typecheck (with client), 1248 tests / 62 files and build pass. Not restarted yet. ### 修复:`/new` 每步「选了没反应」——决策卡就地改写成结算卡;模型步骤与 `/model` 对齐(`src/card-supersede.ts` / `src/new-session-card.ts` / `src/feishu-onboarding.ts` / `src/feishu-model-select.ts` / `src/index.ts`,2026-10-23) **反馈**(使用者,两点):① 选完目录和模式后「卡片只是注释」,没有像问题卡那样直接变更、固化卡片内容,卡片变化不明显 ⇒ 以为没反馈;② 到模型选择环节,没有对齐 `/model`。 **根因**:① 流程每步发新卡,并把上一步的卡「保留原文 + 灰表头 + 底部追加一行 ✅ 已选择…」——工作区卡本身是一长串目录行,那行注释落在整张卡最底部,肉眼等于没变;② `/new` 的模型卡由 `index.ts` 自己渲染,而 `renderProviderSelectCard` 把「↺ 切回上次使用」与「🕘 最近使用(全局)」两段都按 `flow === undefined` 门控掉 ⇒ `/new` 只剩一个 provider 下拉。 **实现**:① `card-supersede` 新增 `mode: 'replace'`(带 `card` 载荷):决策卡**整卡**就地重画成结算卡,并递归剥掉 `behaviors`(被就地编辑过的卡不会再投递回调,结算卡也不该有点击);② `new-session-card` 新增三种结算卡——工作区 / 预设 / 模型:绿色表头「✅ …已选定」+ 决策值(工作区附「该目录会在创建会话时一并创建」)+ 脚注「这张卡片已锁定 · 继续请用下方最新的卡片」;工作区选择卡、目录浏览器页、「这个目录不存在,要创建吗」确认卡、预设卡、模型卡五处都在决定发生时改写;③ `/new` 模型步骤改由 model-select 的 `sendNewSessionProviderCard()` 发送 ⇒ 与 `/model` 同一套三段(当前路线 / 「↺ 用上次的模型」/ 🕘 最近使用最多 3 行)与同一份卡号登记;`/new` 用**不同文案**(「作为新会话的模型」),动作值带 `flow: 'new-session'`;④ 点最近行 / 快捷按钮在 `/new` 里的语义是**选定新会话的路线**:直接进最终确认卡(`onNewSessionConfirm`),**不调 `switchRoute`、不调 `selectModel`、不写全局默认**;provider 已下架时给红卡说明而不是静默;⑤ 「当前」口径分流:`/new` 读全局默认(新会话将继承的路线),`/model` 仍读本会话(`resolveCurrent(chatMessage, flow)`);⑥ 最终确认卡发出后模型卡也改写为「✅ 模型已选定」,不再是裸的「卡片已失效」。 **验证**:新增/改写 11 例(`replace` 模式整卡改写且剥回调;三种结算卡渲染且无回调;工作区卡与浏览器页就地改写成结算卡;缺口路径确认卡带「将创建」;`/new` 模型卡三段 + flow 标记;点最近行/上次模型只进确认卡且断言 `switchRoute` 与 `selectModel` 均未调用;下架 provider 红卡),`typecheck`(含 client)/ **1245 测试 / 62 文件** / build 全绿。**未重启**,线上要下次重启才生效。 **English**: Two `/new` complaints fixed. (1) “Choosing a directory and a preset only annotates the card” — every step posted a new card and retired the previous one by keeping its content, greying the header and appending one notice line at the very bottom, which on a long workspace list reads as nothing happening. `card-supersede` now has a `replace` mode: the card where the decision was made is rewritten IN PLACE into a settlement card (green “✅ … chosen” header, the chosen value, a “locked · continue with the newest card below” footnote) and painted inert — the workspace picker, the folder browser page, the “create this missing path?” card, the preset picker and the model card all settle this way. (2) The `/new` model step did not match `/model`: it was rendered by the plugin entry (not by the model-select module) and `renderProviderSelectCard` gated the “back to last used” action and the global recently-used rows on `flow === undefined`, so only the provider dropdown showed. The step is now sent by `model-select`'s `sendNewSessionProviderCard()` — same three sections and the same card bookkeeping as `/model`, with `/new`-specific wording and a `flow: 'new-session'` marker on every action. In `/new` a tap CHOOSES the route of the session about to be created: it goes straight to the final confirmation card and never calls `switchRoute`, `selectModel` or writes the global default; a row naming a provider that no longer exists paints a red explanation instead of doing nothing. “Current” is flow-scoped: the global default for `/new` (what the new session would inherit), this session's own route for `/model`. The model card now settles into “✅ model chosen” instead of the bare stale notice. 11 new/updated tests (including explicit assertions that a `/new` quick pick calls neither `switchRoute` nor `selectModel`); typecheck (with client), 1245 tests / 62 files and build pass. Not restarted yet. ### 新增:推理 / 工具预览长度可配置(O8,`src/config.ts` / `src/feishu-streaming.ts` / `src/client/*`,2026-10-22) **诉求**(TODO O8):详细档的推理是固定 200 字预览、verbose 才给全文;顺便把同族的「一次长块显示多少」一起做成可配置。 **实现**:新增三个同族设置(设置页可改、`.volatile()` 热生效、不动思考 token 估算): - `reasoningPreviewChars`(80–4000,默认 200):详细档折叠面板里推理保留的字符数;完全展开档仍显示整条推理链,不受此限; - `resultPreviewChars`(200–6000,默认 1500):单个工具结果预览的字符上限; - `argsPreviewChars`(200–6000,默认 2000):单个工具调用参数块的字符上限; - 后两者在完全展开档按 **4×** 展开,但**封顶 6000**:卡片 JSON 接近 30KB 会被飞书**整卡拒收**(关键坑 42),「不截断」在这里只能是「4 倍但仍留在预算内」; - 越界/不可读的值按各自默认值回落(不是最小值),与既有数字设置同一套 clamp 口径;三处上限集中在 `PREVIEW_CHARS`(Host 侧)与同构的客户端常量,避免设置页与运行值漂移。 **验证**:新增 3 例(默认值 + 越界 clamp + NaN 回落;渲染器按配置裁推理头;渲染器按配置裁结果/参数块),`typecheck`(含 client)/ **1235 测试 / 62 文件** / build 全绿。 **English**: Old O8 made configurable what was hardcoded: the detailed level's reasoning head (200 chars), plus the two sibling preview caps (tool result 1500, tool arguments 2000). Three live settings (`reasoningPreviewChars` 80-4000, `resultPreviewChars` 200-6000, `argsPreviewChars` 200-6000) are clamped with the same default-fallback rule as the other numeric settings and are read per render, so a save applies to the next card without a remount. The verbose level still expands them 4× but hard-caps at 6000, because a card near 30 KB is refused whole (关键坑 42) — "untruncated" can only mean "4×, still inside the budget". Three new tests; typecheck (including client), 1235 tests / 62 files and build pass. ### 改进:`/reasoning` 无参发「推理强度」一键切换卡;切档位改为只在当前会话生效(`src/feishu-model-select.ts` / `src/commands.ts` / `src/index.ts`,2026-10-21) **背景**(使用者问「有没有快速切换推理强度的开关」):原来只有 `/reasoning ` 文本命令(无参只回一段说明),且它读的是**全局默认**、切换时先 `agentDefaultModel.saveSelection()` 再 `sessionController.selectModel()`——和其他会话共享,属于「一次快捷切换变成全局改动」的老问题。 **实现**:① `/reasoning` 无参改为发一张卡:一行一个该模型**声明**的档位(`llm.resolveModelInfo` 动态取,不硬编码),当前档位标 `✅ 当前`,其余行点一下直接切;provider 默认的模型也能看到「provider 默认」提示;② 点行与 `/reasoning ` 都改走 `harness.switchRoute()`(只覆盖**本会话下一次请求**的 route,不写 session 事件、不写全局默认、不调 `selectModel`),与 `/model last`、最近使用一致;③ 当前路线优先读本会话(`sessionCurrentSelection`),全局默认仅作兜底;④ 失败有明确提示:会话未加载 / 宿主无钩子 / 档位不被该模型支持;`show on|off` 行为不变。 **验证**:新增 4 例(卡片按档位渲染并标当前、点行切档位且**断言未调 `selectModel`**;命令切档位同样**断言 `saveSelection` 与 `selectModel` 都没被调用**、未加载会话报错、未知档位报错),`typecheck` / **1232 测试 / 62 文件** / build 全绿。 **English**: `/reasoning` with no argument now opens a one-tap effort card (one row per effort the model actually declares, via `llm.resolveModelInfo`, current marked `✅ current`), and both the card and `/reasoning ` go through `harness.switchRoute()` — they override only THIS session's next request and never write the global default or call `selectModel` (the old path saved the effort globally and rebuilt other sessions from it). The current route is read from the session first, the global default is only a fallback; unloaded sessions, hosts without request hooks and unsupported efforts all get explicit messages. `show on|off` is unchanged. Four new tests (card rendering/current marker, local switch asserting no `selectModel`; command path asserting neither `saveSelection` nor `selectModel`, unloaded-session and unknown-level errors); typecheck, 1232 tests / 62 files and build pass. ### 新增:模型卡的「最近使用(全局)」三行(`src/recent-models.ts` / `src/harness.ts` / `src/feishu-model-select.ts`,2026-10-21) **背景**:`/model last` 与「↺ 切回上次使用」取的是**本会话自己的日志**(最后一条 `model/selection` / `request/header`),本来就是会话内的;这次按使用者要求再加一份**全局**(跨飞书会话)的最近使用列表。 **实现**:① 插件自维护全局列表(最多 12 条,卡片只用前 3):来源是插件每次真正应用的路线(`/model` 切换、卡片确认、`/new` 创建、快捷切回)+每个飞书会话日志里观察到的 `request/header`;持久化沿用 `lark-session-map.json`(同一状态文件、原子写、损坏隔离、disposed/空表拒写),重启后仍在。② 去重口径是 **`provider/model`**:effort 只是「最近一次使用带的参数」,随记录更新而不占名额 ⇒ 3 个名额 = 3 个不同模型(否则同一模型的 low/high 会把短名单占满)。③ 卡片在「↺ 切回上次使用」下方加 `🕘 最近使用(全局)` + 最多 3 行(`provider/model · 💡 effort`):**当前会话正在跑的路线也显示**(标 `✅ 当前`、不可点),其余行点一下直接切;短名单 = 全局记忆 ∪ 本会话日志里的最近路线(首次打开空栏的根因:全局记忆只有「插件自己应用过的路线」,重启后只剩当前这一条,再排除当前就全空了),打开时把仅存在于会话日志的路线回填进全局记忆。点行仍走 `switchRoute`(**只改本会话**,不写全局默认、不调 `selectModel`),成功卡明说这一点;`/new` 流程不显示。④ 边界:**「全局」= 跨飞书会话**;纯 WebUI、从未在飞书会话里跑过的模型不会进列表(不为此扫描全部会话日志);依旧**绝不读写 DSH 的全局默认**。 **验证**:新增 13 例(`recent-models` 9:置顶/按 provider+model 去重/上限、top3 与 merge 去重/limit/保留当前、持久化值校验;模型卡 3:三行渲染与 `/new` 不显示、**单条记忆时当前行仍显示为 `✅ 当前`**、点行本地切换且**断言未调 `selectModel`**;harness 2:重启后恢复全局列表并去重、短名单保留当前并回填),`typecheck` / **1228 测试 / 62 文件** 全绿。 **English**: The `/model last` route is read from THIS session's own log (last `model/selection` / `request/header`), so it was already session-scoped; this adds the GLOBAL (cross-Feishu-session) recent list the user asked for. The plugin keeps at most 12 entries (the card shows the top 3), fed by every route it really applies (`/model`, card confirm, `/new`, quick switch-back) plus the `request/header` observed in each Feishu session, persisted in the same atomically-written state file as the chat→session map. De-duplication is by **provider+model**: the effort is just the parameter of the most recent use, so three slots mean three different models. The picker card shows up to three rows under “↺ Back to last used”; the session's current route is shown as `✅ current` rather than dropped, and the shortlist merges the global memory with this session's own log so the first open after a restart is not empty (session-only routes are backfilled into the global memory). A tap still goes through `switchRoute` — this session only, never the global default, never `selectModel`; the `/new` flow does not show them. Scope: “global” means across Feishu sessions; a model used only in the Web UI and never in a Feishu session does not enter the list (scanning every session log is not worth it). 10 new tests; typecheck and 1224 tests / 62 files pass. Deployed by hot reload (build + HMR partialReload, no restart). ### 新增:`/model last` 一键切回本会话上次使用的模型(`src/route-history.ts` / `src/harness.ts` / `src/commands.ts`,2026-10-21) **诉求**(使用者):「快速选择上次使用的模型」;并明确警告不要重犯老毛病——一次快捷选择变成全局默认、导致所有 session 的模型都被改。 **为什么以前会全局改**:DSH 的 `sessionController.selectModel` 是「会话内选择 + `agentDefaultModel.saveSelection()` 持久化成全局默认」的原子接口。插件若用它做快捷切换,重启后按默认值重建会话就会把所有会话切过去(关键坑 59 的旧事故)。所以本功能**完全不调用 `selectModel`,也不写全局默认**。 **实现**:① `src/route-history.ts`(纯函数)从会话日志取最近的模型路线——`request/header` 证明「用过」、`model/selection` 表示「刚选还没跑」,连续重复折叠,最新在前;② `harness.recentRoutes()` 复用带窗口的 `snapshotEvents` 读取(冷会话退回持久化);③ `harness.switchRoute()` 在**活着的 agent** 上安装两个请求装配钩子(`system-prompt/assemble` + `agent/request`),只覆盖**下一次请求**的 provider/model/effort——不追加会话事件、不写全局默认;钩子由插件自己实现而不是 import 宿主 `installModelSelection`,避免关键坑 28 的模块二次求值;④ `/model last` 与**模型卡顶部的「↺ 切回上次使用」按钮**(无参 `/model` 的 provider 卡;仅当存在不同的历史路线时出现,`/new` 流程不显示;**按钮文字保持简短,目标路线写在卡片正文里**——飞书会截断按钮文字)共用同一条 `switchRoute` 路径;成功卡会明说「只改本会话」,失败(会话未加载/宿主无钩子/无历史路线)都有明确提示。 **验证**:新增 10 例(route-history 4:解析/折叠/limit+effort/忽略无效事件;`/model last` 3:成功切换且**断言 `saveSelection` 与 `selectModel` 都没被调用**、无可回退路线、未加载/不支持两种失败;模型卡 3:快捷按钮只在有不同历史时出现、点击走 `switchRoute` 且**不调 `selectModel`**、未加载时报错卡),`typecheck` / **1214 测试 / 61 文件** / build 全绿。**未重启**,线上要下次重启才生效。 **English**: New `/model last` quickly switches back to the previous model route of THIS session. The old failure mode it must never repeat is a quick pick becoming the global default and every rebuilt session inheriting it. DSH's `sessionController.selectModel` persists a global default, so this feature calls neither it nor `saveSelection`: `src/route-history.ts` reduces the session log to recent routes (`request/header` = used, `model/selection` = just picked; consecutive duplicates collapse), `harness.recentRoutes()` reads a bounded event window (persistence fallback for cold sessions), and `harness.switchRoute()` installs two request-assembly hooks on the live agent (`system-prompt/assemble` + `agent/request`) that override only the next request's provider/model/effort — no session event, no global write. The plugin spells those hooks out rather than importing the host's `installModelSelection`, avoiding a second evaluation of a host module (关键坑 28). `/model last` — and the same "↺ Back to last used" one-tap action on the picker card opened by `/model` (shown only when a different earlier route exists; not in the `/new` flow) — picks the newest route that differs from the current one and reports explicit errors for no history, an unloaded session, an unsupported host, or a removed provider. The success card states plainly that only this session changed. Ten new tests (4 route-history, 3 command, 3 card — including explicit assertions that neither `saveSelection` nor `selectModel` runs); typecheck, 1214 tests / 61 files and build pass. Not restarted yet. ### 优化:后台任务面板的「完整命令」只在新任务首次出现时给出(`src/feishu-jobs.ts` / `src/feishu-panel.ts`,2026-10-21) **判断**(使用者问「只显示新增任务的命令,这样是否合理」):**合理**。面板卡是**只发不更新**的快照日志,名册每次变化都会重发整张卡;之前每张卡都重复所有被截断命令的详情块,卡越滚越长,且信息在任务首次出现时已经给过。改成「只给本次新出现、且尚未给过详情的任务」后,卡片回到紧凑表格;完整命令仍留在**它第一次出现的那张卡**上,可回看。 **实现**:① `renderJobPanelCard(rows, t, now, { detailJobIds })`——`detailJobIds` 由调用方点名要带详情的行,省略时保持旧形状(全部截断行)供单测/独立使用;② `startFeishuJobs` 维护 per-chat「已交付详情的 job id 集合」,构建快照时只挑未交付过的新行;③ `createPanelSender` 新增 `onSent(key)` 钩子——**发送成功后才提交**这份记忆,发送失败会在下次快照重试,命令不会静默丢掉;④ 集合上限 256(job id 不复用,超限丢最旧 ⇒ 至多多显示一次),roster 清空/清理时一并忘记;⑤ 判定抽成 `jobNeedsDetail(view)`,渲染器与模块共用一份。 **验证**:新增 2 例(渲染器按 id 子集出详情;模块级:同一 job 的 progress/settle 快照不再重复命令、新 job 只带自己的命令),`npm run typecheck` / `npm test`(**1204 通过 / 60 文件**)/ `npm run build` 全绿。**未重启**,线上要下次重启才生效。 **English**: The background-task panel is a send-only snapshot log, so every roster change re-sends the whole card. Repeating every truncated row's full-command block on every card made the cards grow while adding nothing (the command was already delivered when the job first appeared). Now only jobs that are NEW in this snapshot AND have not had their command delivered yet get a detail block; later progress/settle snapshots keep just the table. `renderJobPanelCard` takes an optional `detailJobIds` set (omitted keeps the old render-everything shape for standalone use), and `createPanelSender` gained an `onSent(key)` hook so the per-chat memory of delivered job ids is committed only after the card really landed — a failed send retries on the next snapshot instead of silently dropping the command. The set is bounded (256 ids, oldest forgotten; a job id never returns, so the cost is at most one repeated block) and cleared with the roster. `jobNeedsDetail()` is shared by the renderer and the module. Two new tests; typecheck, 1204 tests / 60 files and build pass. Not restarted yet — ships with the next restart. ### 新增:`/fork [轮次]` 分叉命令 + 带预览的轮次选择卡片(`src/feishu-fork.ts` / `src/turn-history.ts` / `src/harness.ts`,2026-10-20) **诉求**(使用者):「实现个 `/fork ` 命令吧,也支持卡片,选择 fork 的 turn,可以看见 turn 的前一定数量内容的预览」。 **背景**:DSH 没有「撤回上一轮」——会话日志是 append-only;它支持的机制是 `sessionController.fork({sessionId, atSeq})`:把某个事件前缀复制到一个**新会话**,原会话完全不动。所以 `/fork` = 以某一轮**结束**为界分叉,用来「回到上一轮之前重来」。 **实现**: - `src/turn-history.ts`(纯函数):把一段事件窗口归约成**已完成轮次**——只认 `turn/start`…`turn/end` 成对的轮次(末尾进行中的轮次永不出现),每项带 `endSeq`(fork 切点,inclusive)和**预览**(该轮第一条用户文本;没有就用第一条 assistant 文本;压成一行、截到 80 字符)。 - `src/harness.ts` `listTurns(chat, limit)`:实时会话用 `snapshotEvents(from, to)` **带窗口**读取(从 2000 条起按需 ×4、上限 20 万;无参快照会缓存整份日志,关键坑 8);会话不在内存(重启后首条消息/冷绑定)时退回 `sessionPersistence` 冷读。 - `src/feishu-fork.ts`:`/fork`(无参)发一张选择卡——每行一个轮次(`第 N 轮` + 预览),只列最近 12 轮、更早的用一行汇总;点行即在该轮结束处分叉(一行一次,关键坑 55),随后发结果卡(新会话 id,绿/红)。`/fork ` 直接分叉并回文本。无 fork 能力、未绑定会话、轮次不存在都有明确提示。 - `src/index.ts`:`/fork` 在 `executeSlashCommand` 里拦截(与 `/session`、`/busy` 等既有卡片命令同型),并登记进 `FEISHU_INTERCEPTED_COMMANDS`,所以 `/help` 能看到。 **验证**:新增 14 例(turn 归约 6 例:完成轮/进行中排除/assistant 兜底/折叠截断/limit/错配 end;fork 模块 8 例:卡片行与顺序与省略汇总、点行分叉的 `atSeq`、直接 `/fork `、未知轮不 fork、无 fork 能力降级、无绑定会话提示、非本模块卡片点击忽略),`npm run typecheck` / `npm test`(**1202 通过 / 60 文件**)/ `npm run build` 全绿。**未重启**:线上要重启后才出现该命令(同一次重启也会让挂起的 MiniMax 512K/256K 限额生效——热载观测不到 `apply()`)。 **English**: New `/fork [turn]` command and a picker card. DSH has no "undo last turn" (the log is append-only); the supported mechanism is `sessionController.fork({sessionId, atSeq})`, which copies an event prefix into a NEW session and leaves the original untouched — so `/fork` forks at the END of a completed turn, which is the "go back to before that turn" workflow. `src/turn-history.ts` reduces an event window to completed turns only (paired `turn/start`/`turn/end`; the in-progress tail never appears), each with its inclusive `endSeq` and an 80-char single-line preview (first user text, else first assistant text). `HarnessConversationService.listTurns()` reads a live session through bounded `snapshotEvents(from, to)` windows (2k, ×4, capped at 200k — an unbounded snapshot caches the whole log) and falls back to persistence for cold sessions. The picker card lists the newest 12 completed turns (older ones summarised in one line); tapping a row forks at that turn's end and posts the new session id, and `/fork ` does the same directly. No fork capability, no bound session, or an unknown turn each get an explicit message. 14 new tests; typecheck, 1202 tests / 60 files and build pass. Not restarted yet — the command ships with the next restart, which is also what will apply the pending MiniMax 512K/256K limits (hot reload is currently not firing). ### 改进:后台任务面板在表格下方给出完整命令(`src/feishu-jobs.ts` / `src/i18n.ts`,2026-10-19) **诉求**(使用者):「后台任务表看不见完整命令,查一下看看能不能改」。 **根因**:面板的「任务」列是**卡片表格单元格**,`tableCell(..., { max: 48 })` 把命令截到 47 字符 + `…`;表格本身又只有一列宽,长命令/多行脚本在格子里必然读不全。**数据侧是完整的**:DSH 的 job label 就是 `args.command` 原文(`packages/shell/tool-bash/src/index.ts` 直接 `label: args.command`),插件这里只是在展示时截断。 **改法**:表格保持紧凑(仍 48 字符)作索引;对**被截断或含换行**的行,在表格下方新增 `📜 完整命令` 段:每个任务的完整命令放进**围栏代码块**(换行原样保留),块头是 `状态图标 + 编号 + 状态词`。围栏长度按命令里最长的反引号串动态加长(≥3),保证命令自己关不掉代码块(坑 68 的反向失败);单条超过 2000 字符时截断并在块后注明「完整命令共 N 字符,单卡放不下,已截断」(卡片 ~30KB 上限);短命令(表格已完整显示且无换行)**不重复**。多行命令即使折叠后短于 48 也算「表格显示不全」,同样进详情块。 **验证**:单测 +6(完整命令出现、多行原样、短命令不重复、含 ` ``` ` 的命令用 4 反引号围栏、超长截断注明),`npm run typecheck` / `npm test`(**1188 通过 / 58 文件**)/ `npm run build` 全绿。另把**真实渲染产物**(两个任务:长单行命令 + heredoc 多行脚本)发到一次性测试群做结构探针:`im.v1.message.create` **code=0** 接受、随即撤回(视觉观感——代码块里长行怎么换行——只能真机看)。**尚未重启**(使用者要求),线上仍是旧面板。 **English**: The background-jobs panel could not show a task's full command: its task cell is a card TABLE cell capped at 48 characters, so long commands and multi-line scripts were cut to `…`. The data was never truncated — DSH's job label is the raw `args.command`; only the rendering was. The table stays compact as an index and a `📜 Full commands` section is now appended for rows whose cell was cut or whose command has line breaks: each command verbatim in a fenced code block (line breaks preserved), headed by status icon + job id + status word. The fence is lengthened past the longest backtick run inside the command so the command cannot close its own block (the reverse of 关键坑 68); a command over 2000 chars is cut with an explicit "N characters, truncated to fit one card" note; short single-line commands are not repeated. Six new tests, typecheck / 1188 tests / build green, and the real rendered card (long single-line + heredoc) was accepted by `im.v1.message.create` with `code=0` in a throwaway group and then deleted — visual line-wrapping still needs a real screen. Not restarted yet, per the operator. ### 变更:紧凑过程卡的「分段」触发从「别的卡片」扩到「别的消息」(`src/channel.ts` / `src/index.ts` / `src/card-interleave.ts`,2026-10-19) **诉求**(使用者):「当 steer 插入一条消息后,紧凑模式的步骤卡片会在原卡片上更新,导致不方便看到新的进度」⇒ 以后「凡是出现了别的消息,就重新发新的步骤卡片」(旧规则是「凡是出现了别的卡片」)。 **根因**:分段广播(`src/card-interleave.ts`)只有在「别的模块发了一张卡」时触发。steer 的确认是一条**纯文本**通知(`channel.ts` 的 `sendOutbound`,不经 `cardChannel.send`),所以不触发——过程卡继续就地更新,新进度被写在 steer 通知的**上方**,看起来像没在动。 **改法**:① `startChannel` 新增可选参数 `noteMessagePosted(target)`,在所有经 `sendOutbound` 发出的消息成功后回调——回复卡/续卡/Turn Complete、busy(steer/queue)通知、命令结果、错误行都走这个唯一漏斗;② `index.ts` 的 `cardChannel.send` 改为**任何消息**(卡片或文本)都广播(原来只对卡片);③ 注释与文档统一成「任何别的消息」,模块名 `card-interleave` 作为历史名保留并在文档里说明。 **行为**:steer(以及 `/steer`、busy 文本、命令输出、错误行)之后,过程卡当前分段冻结;下一次真实进展(新 `tool/call`、新 step)在**下方新开一张卡**,只带这段新增内容。分段作用域是**会话**(chat + 话题根,见下一节),不是整个群。 **验证**:新增 2 例(busy steer 文本 ⇒ 回调触发;回复卡也触发),`npm run typecheck` / `npm test`(**1174 通过 / 58 文件**)/ `npm run build` 全绿。真机观感待使用者 steer 一次确认(改的是消息触发条件,单测能钉住回调,观感只有真机能看)。 **English**: The compact turn card stops growing at a *segment* boundary whenever another message lands in the conversation. That boundary used to be triggered by CARDS only, so a steer — whose acknowledgement is a plain TEXT notice (`sendOutbound`, not `cardChannel.send`) — did not end the segment: the running card kept updating in place, above the steer notice, which made new progress look stalled (user report, 2026-10-19). The rule is now "any other message": `startChannel` takes a `noteMessagePosted` callback fired by the single `sendOutbound` funnel (reply cards, overflow continuations, Turn Complete, busy notices, command results, error lines), and `cardChannel.send` announces text as well as cards. The next real progress row opens a NEW card below the interruption. The scope is the CONVERSATION (chat + topic root, see the next entry), not the whole group. Two new tests pin the callbacks; typecheck, 1174 tests and build pass, and the visual result awaits one real steer. ### 变更:分段作用域从「群」收紧到「会话」——一个话题不切另一个话题的卡(`src/card-interleave.ts` / `src/feishu-progress.ts` / `src/feishu-streaming.ts`,2026-10-19) **诉求**(使用者):「每个话题应该互不干扰才行,一个话题对应一个 session」。 **改法**:分段通知从 `chatId` 换成 `InterleaveTarget = { chatId, rootId? }`。话题内的发送本来就 reply 到该话题的**根消息**(`replyTo` = root id,关键坑 25),所以 root id 就是会话身份;没有 root 的消息属于主时间线。三个纯函数是唯一口径:`interleaveTargetOfSend()` 从发送 opts(`replyInThread`/`replyTo`)推目标、`interleaveTargetOfChat()` 从长寿命卡的会话推自身、`interleaveHits()` 只允许**同一会话**命中。话题 root id 未知时(连接后 `recoverTopicRoots()` 会补齐)不猜也不误伤:那张卡不参与分段,而「有 replyInThread 却没有 root」的 topic 发送本来会落到顶层,也不广播。 **行为**:A 话题里的 steer 通知/任务卡/问题卡不再切 B 话题的过程卡;主时间线的消息也不切任何话题卡。 **验证**:新增 8 例(target 透传、send/chat 推导、A 话题不动 B 话题、同一话题才切、root 未知不切),`npm run typecheck` / `npm test`(**1183 通过 / 58 文件**)/ `npm run build` 全绿。 **English**: Segmentation is now scoped to one conversation instead of the whole group chat. A send into a Feishu topic replies to that topic's root message, so the root id is used as the conversation identity; a message without one belongs to the main timeline. The three helpers `interleaveTargetOfSend` / `interleaveTargetOfChat` / `interleaveHits` are the single source of truth: a notice only ends a card's segment when both resolve to the same chat AND the same topic root. When a topic's root id is unknown (recovered on connect by `recoverTopicRoots`) nothing is guessed: that card is never segmented, and a topic send without a root id — which the channel routes to the top level anyway — is not announced. Eight new tests cover the derivation and both cross-topic directions; typecheck, 1183 tests and build pass. ### 兼容:DSH `0.2.1-rc.1` 把后台 job id 换成短 UUID,插件两种格式都认(`src/feishu-jobs.ts`,2026-10-17) **背景**(例行升级 DSH `0.2.0-rc.2 → 0.2.1-rc.1`):新版本把 job 注册表的 id 从 `-` 改成 `-`——`packages/jobs/jobs-local/src/index.ts` 现在是 `` `${kind}-${randomUUID().replaceAll('-', '').slice(0, 16)}` ``,上游自己的断言是 `^started background job bash-[0-9a-f]{16}$`。插件此前把「模型真的拿到了 job 句柄」这件事钉在工具结果的**字面句**上(`ANNOUNCEMENT_AT_TOP` / `PROMOTED_ANYWHERE` / `WORKFLOW_ANNOUNCEMENT`,每个都写着 `-\d+`),于是升级后**所有后台任务都不会再上「后台任务」面板**——句子其余部分没变,只有 id 形状变了,所以失败是静默的:没有报错、没有空行,只是卡片不再出现。 **修法**:把 id 形状抽成一个常量 `JOB_ID = [a-z][a-z0-9-]*(?:-[0-9a-f]{16}|-\d+)`,四条句子的正则都由它拼出。两种格式都接受是有意的:0.2.1 发短 UUID,0.2.0-rc.2(回滚目标)发数字后缀,回滚后同一份插件仍要能列任务。`tool-subagent` 的后台句在 0.2.1 已不再产生(后台子代理被 activation 取代),条目保留并注明原因——回滚时它仍然正确。 **验证**:新增 2 组断言(短 UUID 的 bash/terminal/workflow;短 UUID 的「超时转后台」括号行),原数字格式断言全部保留;`npm run typecheck` / `npm test`(**1172 通过 / 58 文件**)/ `npm run build` 全绿。升级前另在隔离环境对目标版本做了三轮彩排:A 轨 tag 构建 9/9 gates、`--version` = 0.2.1-rc.1;B 轨把插件 dev 依赖临时抬到 `^0.2.1-rc.1` 后 typecheck + 1171 例测试 + 构建全绿(插件自身范围**不升**,理由见下);B+ 轨真实 profile 冷启动 `apply() called`、无兼容闸拦截、无 `patch: entry not found`。 **为什么插件依赖范围仍是 `^0.2.0-rc.1`**:0.2.1 的兼容闸(`packages/boot/app-boot/src/plugin-compatibility.ts`)用 `semver.satisfies(..., { includePrerelease: true })` 判定 ⇒ 该范围覆盖 0.2.1-rc.1;同时保留它才能让「回滚到 0.2.0-rc.2」也过闸。升到 `^0.2.1-rc.1` 会把回滚路径关死。 **English**: DSH 0.2.1 replaced numeric job ids (`bash-7`) with short UUIDs (`bash-0123456789abcdef`). The background-task panel only lists jobs whose id it recovers from the producer's tool-result sentence, and those literals required `-\d+`, so after the upgrade every background job would have silently vanished from the panel. The id shape is now one constant that accepts both spellings — the old one on purpose, so a rollback to 0.2.0-rc.2 keeps working. Two new assertion groups pin the new format; all old ones stay. Typecheck, 1172 tests and build pass, and the target version was rehearsed three ways (tag build with 9/9 gates, plugin tests against 0.2.1-rc.1 packages, cold boot of the real profile in an isolated home). The plugin's peer ranges stay `^0.2.0-rc.1`: the new compatibility gate evaluates them with `includePrerelease`, which admits 0.2.1-rc.1 *and* keeps a rollback inside the gate. ### 修复:插件热重载把进行中的一轮拆给两个实例,同一张答复发了两遍(`src/feishu-streaming.ts`,2026-10-15) **现象**(使用者问「为什么刚刚你发了两条一样的完成卡片」):同一秒(01:19:50)聊天里出现两张**内容逐字相同**的收尾卡;journal 只记录了其中一张(流式路径的 `[send] message=`),另一张来自回复兜底路径。 **根因:热重载把「一轮」拆给了两个插件实例。** 01:19:16 我在**这一轮进行中**跑了 `npm run build`,HMR 立刻重挂插件(本批之前刚把模块热重载打开)。此后:**新实例**收到了这一轮的尾巴——剩余几个 `tool/call`/`tool/result`,以及最后的 `assistant/message`——于是照常渲染出一张带答案与完成 footer 的步骤卡;**旧实例**的回复 promise 仍在闭包里活着,走到 `.then()` 时它在**已被 dispose 的**实例上读送达记录:`flushed()` 没有统计、`intermediateSent` 为假,便判定「没有步骤卡承载正文」,又发了一张兜底回复卡。两张卡都自报完成。 **修复:一轮的渲染权归「看见它 `turn/start`」的那个实例。** 新增 `state.turnSeen`(`turn/start` 置真、`turn/end` 置假)与 `TURN_SCOPED_EVENTS` 名单(`assistant/*`、`tool/*`、`step/*`、`turn/end`、`deliverables/presented`、`user/message`):**没看见这一轮开头,就整轮不渲染**,交给仍在回答它的那个实例的回复路径。刻意不设闸的是 `request/header` / `request/context`(会话级路线,对后续回合仍有用)与 `compaction/*`(自有写入器,且压缩发生在轮与轮之间)。顺带改善:以前重挂后新实例只会渲染出**尾巴片段**(前半段它没看见),现在干脆交给兜底路径发完整答复。 **操作教训**:`npm run build` 现在自己就会热重载,**在有回合进行时构建等于把那一轮劈成两半**。有了这道闸不会再有重复卡,但重挂前已上屏的半张卡会停在那里(它的实例已经没了),兜底卡给出完整答复——所以「构建放到回复之后」仍是更好的习惯。 **验证**:新增 2 例(本轮无 `turn/start` ⇒ 步骤卡/续卡/过程卡一张都不发,且随后自己开的下一轮照常渲染;`request/header` 仍被采纳,随后的收尾 footer 带 `💡 xhigh`)。测试 harness 同时改成**忠实于宿主**——宿主必然在轮内事件前发 `turn/start`,harness 于是自动补一条,`noTurnStart` 可关掉以专门测这道闸;此前 17 个既有用例因为缺 `turn/start` 而变红,正说明它们过去在断言「模块容忍缺少开头」这一非宿主行为。`npm run typecheck` / `npm test`(**1171 通过 / 58 文件**)/ `npm run build` 全绿。 **English**: A plugin hot reload used to split ONE turn across two instances, so the same answer went out twice (the user asked why two identical completion cards appeared in the same second). The reload disposed the instance that opened the turn while its reply promise kept running: the freshly mounted instance rendered the turn's tail as a step card, and the old promise, reading its delivery record on a disposed instance, concluded no card had carried the text and posted a fallback reply card too. The module now records whether THIS instance saw the current turn's `turn/start` and ignores that turn's own events otherwise (`TURN_SCOPED_EVENTS`); `request/header` / `request/context` and `compaction/*` stay ungated on purpose. The test harness now mirrors the host by opening a turn for the first turn-scoped event (`noTurnStart` opts out), which is what the 17 previously-failing cases were really missing. Typecheck, 1171 tests and build pass. ### 修复:紧凑档 footer 的「模型 · 推理强度」行在插件热重载后会消失(`src/feishu-streaming.ts`,2026-10-15) **现象**(使用者报「footer 还是加上模型名称和推理强度,另起一行」):紧凑档收尾卡的 footer 只剩 `📦 N tokens · ♻️ x%` 与 `🚀 tok/s · 📊 上下文`,**本该自成一行的那对模型/推理强度不见了**。真机读回可复核:`~/.dsh/read-card.py `(本批新增的只读工具)读出 22:17 热重载之后 00:56 那张卡正是这样。 **根因:这行只有一个来源 `request/header`,而它在重载场景下不会再来。** `state.requestRoute` / `state.requestEffort` 只由 `request/header` 填充,而 DSH **只在 initial / resume / change / new series** 时追加该事件;插件是 HMR 重挂的,宿主 agent 并没有重启 ⇒ 本会话最后一次 `request/header` 停在重载之前,新实例永远等不到填充,这行便静默消失,直到下一次路线变化(本次本会话停在 19:41:48)。**「插件热重载」这个新能力恰好打穿了这条老假设**——重启路径不受影响,因为重启会把会话 resume 一次、补发 header。 **修复**:`turn/start` 本来就会调 `bridge.getSessionMeta()` 取上下文窗口,顺手把它的 `model` / `reasoningEffort` 记为 `state.sessionRoute`(该函数**优先读会话日志里最后一条 `request/header`**,正是这里需要的权威值);`turn/end` 组装摘要时**本回合自己的请求头优先、取不到才退回会话记录**。footer 的排布未动:模型与推理强度本来就在独立一行(`🧠 deepseek-flash · 💡 high`),这次只是让它不再缺席。 **验证**:新增 2 例流式——「没有 `request/header` 但会话记录有路线 ⇒ footer 仍带 `🧠 … · 💡 …` 且自成一行」与「两者都有时以本回合请求头为准(会话记录是 stale 值也不采用)」;两条断言在修复前都失败(先红后绿)。`npm run typecheck` / `npm test`(**1169 通过 / 58 文件**)/ `npm run build` 全绿。 **English**: At `compact`, the footer line naming the model and reasoning effort silently disappeared after a plugin hot reload. It was sourced only from the turn's `request/header`, an event DSH appends on initial / resume / change / new series — a reloaded plugin inside a live host agent never sees another one, so the fresh instance knew no route while the session's own record still did (found on a real card with the new read-back tool). `turn/start` now also captures `bridge.getSessionMeta()`'s `model` / `reasoningEffort` (which prefers the session log's latest `request/header`) as a session-scoped fallback, and the completion footer prefers the turn's own header and falls back to that record. The layout is unchanged — model and effort already had their own line; they just no longer go missing. Two streaming tests pin the fallback and the precedence. ### 变更:紧凑档收尾卡的标题、分隔线与 footer 简化(`src/turn-summary.ts` / `src/feishu-streaming.ts` / `src/i18n.ts`,2026-10-14) **诉求**(使用者):紧凑模式最后一张卡片的标题要一眼看出「完成」;footer 与正文之间加一道分隔线;footer 再简化,尤其是输入输出 token 那部分;**标题不要再显示第几轮第几步,只显示「完成」加时间**。 **改法**:① **标题自报完成**:承载答案的那张卡就是收尾卡,`turn/end` 写摘要时标题变成 `✅ 完成 · 总时长`(`compactCompletionTitle`)、表头转绿,**不再拼 `· 第 N 轮 · 第 M 步`**(其余卡片仍带坐标);总时长因此只在标题出现一次。② **正文与 footer 之间加 `hr`**:紧凑档从引入 footer 起就故意不加横线(省一次元素间距,关键坑 58 / 63),这次按使用者要求加回,且只在正文确实存在时加。③ **footer 只剩两个数**:`📦 443K tokens · ♻️ 78%`——去掉的正是 `📦 总数`(等于 `📥+📤`)与 `📥 in → 📤 out` 拆分。理由写进了代码注释:同一张卡上当时有**三种说法讲一件事**,而 footer 里的 `📥` 与下一行 `📥` 含义不同(一个含缓存读、一个只算新输入)。第二行(`🧠 模型 · 💡 推理强度 · 📦 新生成 N 个文件`)与卡片原有的 `🚀 tok/s · 📊 上下文` 行不变。④ **续卡标题顺带修正**:`renderOverflowCard` 的标题一直是硬编码英文 `Reply (continued n/m)`(中文聊天里也照印),现在走 i18n `📄 续 n/m`;**最后一张续卡**换成完成标题 `✅ 完成 · 续 n/n · 总时长`,且只有真的分成多片时才写 `n/n`(单片续卡只是答案的尾巴)。⑤ `appendCompletionFooter` 现在**自己**给卡片换标题、转绿、加 `hr` 再追加摘要行——「载着摘要的卡必须自报完成」这条规则因此不靠调用方记得做。 **边界**:摘要行在**没有统计**时返回空数组(原来是必然输出一行 `✅ 完成`)。此时收尾卡仍会被改标题(标题是唯一保证存活的那句话),footer 回退到该步自己的耗时行;最小收尾卡(镜像回合没有正文卡)在没有摘要又没有来源说明时,正文里补一句标题文本,避免发出空 body 的卡片。`standard` / `detailed` / `verbose` 三档仍是原来的 Turn Complete 卡与步骤卡。 **验证**:`tests/turn-summary.spec.ts` 重写并扩到 21 例(标题=完成+时间且**不含** `第 N 轮/第 N 步`、分片后缀、无统计时退化为裸标记、命中率行、入/出拆分确实不在、空摘要返回 `undefined`、`appendCompletionFooter` 的标题/绿头/`hr`/追加顺序与原卡不被修改、最小收尾卡非空 body);`tests/feishu-streaming.spec.ts` 新增或改写 3 例(收尾卡标题+绿头+`hr`+不重复时钟、两片续卡时 `📄 续 1/2` 与 `✅ 完成 · 续 2/2 · 时间`、单片续卡不写 `1/1`);`tests/plugin.spec.ts` / `tests/feishu-external-turns.spec.ts` 同步断言。另外把渲染结果**逐元素**打出来核对过(正文 / `hr` / 摘要行 / 原有速度-上下文行),用于核对的临时脚本未入库。`npm run typecheck` / `npm test`(**1167 通过 / 58 文件**)/ `npm run build` 全绿。 **English**: The `compact` card that ends a turn now announces it in its own header — `✅ Done · wall clock`, green, **with no turn/step position** — and the wall clock lives there only, so the footer no longer repeats it. A rule separates the answer body from the footer, and the footer is down to `📦 443K tokens · ♻️ 78%`: the redundant total (exactly `in + out`) and the whole `📥 in → 📤 out` split are gone, because one card used to say one thing three ways while printing `📥` with two different meanings one line apart. The second footer line (model, effort, produced-file count) and the card's existing tok/s + context line are unchanged. Continuation cards also lost their hardcoded English title (`Reply (continued n/m)`, shown even in a zh chat) in favour of `📄 续 n/m`, and the LAST fragment carries `✅ Done · continued n/n · wall clock` — with `n/n` only when the reply really was split. `appendCompletionFooter` now retitles, turns the header green, rules off the body and appends the summary itself, so "the card carrying the summary announces completion" cannot be forgotten by a caller. A turn whose stats never arrived gets the bare marker in the title and no footer lines (it falls back to the step's own timing line); the minimal fallback card keeps a non-empty body. Other levels are untouched — 21 rewritten/added summary tests, 3 streaming cases and the plugin/mirrored-turn assertions updated; typecheck, 1167 tests and build pass. ### 修复:紧凑档回合失败时,死在其中的那一步看不到工具情况(`src/feishu-streaming.ts` / `src/feishu-progress.ts` / `src/i18n.ts`,2026-10-14) **现象**(使用者报「如果某个步骤失败导致回合结束,步骤卡看不见那个步骤的工具情况」):紧凑档的回合过程卡在回合失败时,**死在其中的那一步只剩一个光秃秃的步头**——`▸ 第 6 步 · 22.3s`,下面什么都没有,而卡片标题是红色的「本回合失败」。真实会话日志(2026-10-14)里三个失败回合一模一样:模型已经在流式生成工具调用,然后连接断了,DSH 判定回合失败;那一步自然也没有任何工具行。 **根因:`assistant/attempt` 从未被消费**。DSH 把一次模型尝试的**未提交**结果追加成 `assistant/attempt`(提交成功才是 `assistant/message`),被杀掉的工具调用**只存在于这个事件的 `stream` 里**——因为尝试没有提交,loop 从没真正执行它,所以**永远不会有对应的 `tool/call`**。插件的 `session/event` 分发没有这个分支,于是那一步在卡上没有任何可渲染的事实。取证(会话日志,失败回合的 `assistant/attempt`):`step 6` 记录里是 `tool-call-chunks name="edit"`、`step 24` 是 `name="todo_write"`,而六次重试那种(`step 9`)全程没有工具名——所以标记与“在调什么”是两件事,都要能各自出现。 **修复**:(1) 新增 `assistant/attempt` 分支,用 `uncommittedToolNames(stream)` 从打包的 `tool-call-chunks`(并兼容旧的裸 `tool-call-delta`)取出工具名,去重、最多 3 个,交给过程卡的新方法 `stepAttempt()` 记为 `ProgressStep.unrun`;只改已经存在的步骤小节(与状态灯同一规则,不为一个没见过的步骤凭空造一节)。(2) `assistant/message` 提交或真出现 `tool/call` 时**清掉**这组名字——重试成功的一步不能继续宣称自己什么都没跑。(3) `finish(…, failedTurn)` 给**最新一步**打 `failed`:DSH 在 `turn/end` 之前就用 `step/end` 关掉了那一步,所以“还开着”不是判据;渲染成步头后缀 `❌ 失败`。(4) 未执行的调用渲染为 `⚠️ \`edit\` · 未执行`:它**不是工具行**——没有状态、没有耗时,也**不计入标题的工具数**(那里只数真正跑过的调用)。仅紧凑档(`turnCard`)新增这些行;`showToolCalls: false` 时一如既往没有过程卡。 **验证**:新增 10 例——`tests/feishu-progress.spec.ts` 4 例(失败步标记 + 未执行行 + 不计入工具数、死后重试清标记、卡片尚未创建时先记后渲染、给没见过的步骤不造节)、`tests/feishu-streaming.spec.ts` 6 例(`assistant/attempt` 的真实记录形状端到端、重试提交后不留残标记、`uncommittedToolNames` 的打包/裸分片/去重/上限/空集)。另外把**真实失败回合的完整事件流**(`step/start … assistant/attempt … step/end … turn/end error`)回放进流式模块逐张渲染核对,三个失败回合分别得到 `❌ 失败` + `⚠️ \`edit\`` / `⚠️ \`todo_write\`` / 仅标记;用于核对的临时回放脚本**未入库**。`npm run typecheck` / `npm test`(**1154 通过 / 58 文件**)/ `npm run build` 全绿。 **这一批明确没做**(记录在案):① 只覆盖紧凑档的过程卡——`standard` / `detailed` / `verbose` 的步骤卡**不投影** `assistant/attempt`,所以那三档里一次没提交的尝试仍然什么都不显示(那一步的残缺正文/推理同样丢弃,属既有行为,本次不动);② `unrun` 只取工具**名字**,不显示它的参数(紧凑档不带参数是这条档位的定义);③ 只看**最后一次**尝试的名字——若某次重试自己没流式出工具名,就不显示更早尝试的名字(真机上三种失败尾部各只对应一种情况,见上)。 **English**: At `compact`, a step that killed the turn used to render as a bare heading — `▸ Step 6 · 22.3s` with nothing under it beside a red title — so the reader could not see which tool that step was calling. Cause: DSH appends `assistant/attempt` (not `assistant/message`) exactly when a model attempt did **not** commit, and a call the attempt had started streaming only exists inside that event; because the loop never ran it, no `tool/call` ever follows, and the plugin never consumed the event at all. The plugin now reads those uncommitted call names (packed `tool-call-chunks`, plus the legacy raw `tool-call-delta`), keeps at most three as `ProgressStep.unrun`, clears them when the step commits or really calls a tool, marks the newest step `❌ failed` at `turn/end`, and renders each never-ran call as `⚠️ \`edit\` · not run` — deliberately not a tool row (no status, no clock) and not counted in the title's tool total. Compact only: the other levels do not project the event, so a step whose attempt never committed still shows nothing there, and only the LAST attempt's names are kept (never the arguments). Verified with 10 new tests plus a replay of three real failed turns taken from the session log; typecheck, 1154 tests and build pass. ### 变更:紧凑档不再发 Turn Complete 卡,摘要并入回复卡 footer + 独立「交付物」卡(`src/turn-summary.ts`(新)/ `src/feishu-streaming.ts` / `src/channel.ts` / `src/feishu-external-turns.ts`,2026-10-14) **诉求**(使用者):紧凑档不要 Turn Complete 卡片,把其中一部分内容融进最后那张回复卡的 footer,并且筛选显示的内容;交付物要有自己的卡片和标题;只动紧凑档。 **保留 vs 丢弃**(判据是「别处是否已经能看到」):保留 `✅ 完成 · ⏱ 总时长 · 📦 回合 tokens · 📥 in (♻️x%) → 📤 out`、`🧠 模型 · 💡 推理强度`、`📦 本回合新生成 N 个文件`(`produceCard` 打开且 >0 时);`📊 上下文占用`与 `🚀 tok/s` 沿用答案卡 footer 原有那一行,不重复。丢弃:`🧠 LLM / 🔧 Tools` 拆分与 `🔄 N 步`(过程卡就是逐步时间线)、`⚡ TTFT`、`📂 workspace`、`Enter-while-busy` 回显、外部回合的来源行(答案卡的 `⚡ 本回合由…自动触发` 已经在说同一件事)。完整取舍表写在 `src/turn-summary.ts` 的模块注释里。 **实现**:新模块 `src/turn-summary.ts` 负责这套筛选后的行、独立交付卡与回退收尾卡。`feishu-streaming.ts` 在 `turn/end` 把摘要写进承载答案的那张卡:首次发送在防抖窗口内就直接带上(`state.completionLines` 在 `flushPendingSend` 之前设好),已经在屏上则补一次写入;答案被切成续卡时挂在**最后一张续卡**上。这次写入 `hasText: false`,不会碰 `flushed()` 用来判定「正文是否上屏」的送达记录。`channel.ts` 在紧凑档不再发 footer:正文已上屏时只发交付卡;回退正文卡时把摘要追加到最后一张(于是「正文没上屏」的兜底也有回合结束标记)。`feishu-external-turns.ts` 同样不再发 Turn Complete 卡,交付卡走与回复路径相同的 hydrator(新 `hydrateCard`),并在**没有任何卡承载正文**时发一张最小收尾卡(标题 `✅ Turn 已完成`,只有来源、摘要、模型/上下文)。 **为什么交付物单独一张卡**:载着可点行的卡片在被就地更新 2–3 次后**不再投递回调**(关键坑 11 / 55),而答案卡整轮都在就地更新 ⇒ 交付卡只发一次、永不更新,点击发送/确认流程因此保持可靠;卡片标题为「📦 交付物」,正文不再重复标题行。 **验证**:新增 9 例 `tests/turn-summary.spec.ts`(保留/丢弃项、产出行、上下文行、无统计兜底、单元素 footer、本地化、交付卡、回退卡)、2 例流式(摘要写进答案卡且不新增卡片;超长答案时挂在最后一张续卡且只发一张)、2 例通道(紧凑档回退正文卡带摘要且无 Turn Complete;交付行进独立标题卡)、3 例镜像(紧凑档不发 Turn Complete、无正文卡时发最小收尾卡、交付卡先过 hydrator);`npm run typecheck` / `npm test`(**1144 通过 / 58 文件**)/ `npm run build` 全绿。**顺带修掉两处既有的时序脆弱断言**(同一文件:过程卡收尾渲染没等到绿色态就断言、步骤行耗时钉死成 `0ms` 而实际会读到 1ms)——它们与本批功能无关,但会让全量验证约 1/3 概率假红。`standard` / `detailed` / `verbose` 三档行为**未改动**。 **English**: At `compact` there is no Turn Complete card any more. A filtered summary is folded into the last answer card's footer (the streaming step card, or the fallback reply card; the last "continued" card when the answer overflows), keeping only completion + wall clock, turn tokens, model + effort, produced-file count and the existing context meter, and dropping the LLM/tool split, step count, TTFT, workspace and busy-mode echo that the process card or `/status` already shows. Delivered rows move to their own `📦 Deliverables` card, sent once so its tap-to-send rows keep delivering callbacks (key pitfalls 11 / 55). Mirrored (auto-triggered) turns do the same and fall back to a minimal completion card when no card carried the answer; that delivery card goes through the same media hydrator the reply path uses. Other levels are untouched. ### 修复:自动触发回合的 Turn Complete 卡片整块统计缺失(`src/index.ts` / `src/feishu-external-turns.ts`,2026-10-14) **现象**(使用者报「自动触发的轮 turn complete 卡片内容显示不完整」):定时提醒、后台任务唤醒、Web GUI 等**不是飞书消息触发的回合**,收尾卡只有首行来源说明和底部的会话元信息,**中间整块统计全没有**——没有 `⏱ 总时长 · 🧠 LLM · 🔧 Tools`,没有 `⚡ TTFT · 🚀 tok/s · 🔄 steps`,也没有 `📦 tokens · 📥 in · 📤 out`;同一回合里 `present` 声明的交付物也不进清单。飞书消息触发的回合一切正常,所以两张卡长得明显不一样。 **根因:接线顺序 + 按值捕获**。`apply()` 里 `startFeishuExternalTurns({ …, flushed, … })` 在**第 985 行**求值,而真正的 `flushed = streamingResult.flushed` 在**第 994 行**才赋值 ⇒ 外部回合模块拿到的是第 217 行的初始占位函数 `() => Promise.resolve(undefined)`,**永远是它**。于是每次收尾都是 `turnStats === undefined`:`renderFooterCard` 无统计可渲染;`deliver.plan({ files: turnStats?.deliverables ?? [] })` 也恒为空表。飞书消息触发的回合走的是 `runtime.start` 回调里的参数(连接时才求值),那时变量已是真函数,因此不受影响——这正是「只有自动触发的卡片不一样」的原因。附带影响:`flushed()` 同时是**收尾卡排序**的手段(等最后一张步骤卡的消息真正创建),拿到占位函数后这道等待也失效,收尾卡可能抢在步骤卡前面。 **修复**:(1) 把 `let flushed` 改成**延迟读取的 holder** `flushRef.current`——streaming 启动后写 `flushRef.current = streamingResult.flushed`,两处使用方(回复路径与外部回合模块)都在**调用时**读取,顺序不再影响正确性;(2) 外部回合接上与回复路径**同一套** `produceCard` 逻辑(`producedArtifacts` + `mergeProducedArtifacts` + `turnProducedLine` 计数行),不再丢掉「本回合新生成 N 个文件」。 **验证**:`tests/feishu-external-turns.spec.ts` 新增 3 例——自动触发回合的卡片必须带上三行统计、`flushed` 必须在**收尾时**读取(holder 后替换成真函数仍生效)、产出文件按 `delivery` 并入计划且只收 artifact 类;`npm run typecheck` / `npm test`(1128 通过)/ `npm run build` 全绿。 **English**: Auto-triggered turns (schedule reminders, background-job wake-ups, Web GUI) posted a Turn Complete card with **no statistics at all** and an empty deliverable list. `src/index.ts` passed streaming's `flushed()` **by value before it existed**: the deps object was built at line 985 while the real function was only assigned at line 994, so the module permanently held the initial no-op, `turnStats` was always `undefined`, and the ordering wait that keeps the footer below the final step card was gone too. Feishu-started turns capture the parameter inside `runtime.start` (evaluated after the assignment), which is why only auto-triggered cards looked wrong. Fixed by reading through a late-bound holder plus wiring `produceCard` into the mirrored completion path exactly like the reply path. Three regression tests added; typecheck, 1128 tests and build pass. ### 文档:区分当前指南、待办与历史设计(2026-10-13) - 新增文档总索引与媒体隐私说明;首页合并中英对照、命令与配置表,说明仓库主线不等于旧 npm 包。 - 架构从超长职责表改成模块导航 + 会话/事件/卡片/可靠性/交付章节;修正旧“一任务一卡”、占位标题、无限 verbose 与 200 字压缩摘要等现状误述。 - 待办保留 O/B/X/H/P6 编号,将当前缺口、条件性验证与完成索引分开;旧审计全文已保留为本地非发布快照,不把本机清理目录和部署配置混入公共功能手册。 - 能力参考移除第二份优先级表,更新问答终态、权限声明与组件证据;历史设计/迁移加明确状态,不再把旧计划指令当当前实施要求。 - 配置指南补入站/出站独立排查;隐私说明区分媒体自动内联、计划交付与显式文件工具的不同围栏,保留资源探针的验证范围,不承诺未测的跨租户/跨应用隔离。 - npm 文档清单补索引、隐私和能力参考;本批不改运行代码/依赖范围,不安装、不发布、不重启,也不修改网关或宿主配置。 - 验证:typecheck / build 通过,**57 个测试文件、1125 个用例全绿**;117 个本地链接与 80 个仓库链接目标/章节锚点核对通过(未请求远端页面);`npm pack --dry-run` 确认包内文档局部链接均可落地、内部笔记不入包;隐私扫描仅豁免声明字段/引用名。首次测试发现首页 `Settings` 加粗范围改变使字面断言失败,修正文档后全量重跑,未修改测试。 **English:** Reorganize current usage, architecture, platform evidence, the active backlog and historical decisions. Add a navigation index and media/privacy boundaries, align bilingual behavior, document the main-versus-published-package gap, clarify segmented compact cards, bounded verbose output, the 22,000-byte compaction budget and batch-confirmed questions. Keep stable backlog identifiers and preserve the old operational audit privately. Add inbound/outbound troubleshooting without turning network hypotheses into facts. Include the navigation/privacy/capability references in npm's document allowlist. No runtime code, dependency-range, deployment or network change; no installation, release or restart. ### 历史兼容演进摘要(版本均为当时快照) > 对齐 DSH `0.1.5-alpha.1`。插件**无需改代码**:typecheck / tests / build 全绿,全部 `@deepseek-ai/dsh-*` 依赖的公开 API 逐项 diff 无变化。 > > Aligned with DSH `0.1.5-alpha.1`. **No plugin code changes needed**: typecheck / tests / build all green, and the public API of every `@deepseek-ai/dsh-*` dependency is unchanged. > > 另已评估 **并已升级到** DSH `0.1.5-rc.1`(279 commits,见 `docs/migration-0.1.5-alpha.1-to-rc.1.md`):运行时代码与依赖范围**都无需修改**;下面新增的「交付物清单」正是 rc.1 新 `present` 工具带来的对齐项。 > > 也已评估 **并已升级到** DSH `0.1.5-rc.2`(距 rc.1 仅 4 commits):只改动 Web 客户端包(反馈弹窗、产物卡片排版、`CodeFileIcon` 重构),**17 个被插件 import 的宿主包零变化**,`SESSION_FORMAT_VERSION` 仍为 3——插件零改动、依赖范围不变,B1 测试替身继续有效。记录见 `TODO.md`。 > > 另已评估 **并已升级到** DSH **`0.1.6-alpha.2`**(887 commits / 2622 改动文件):插件运行时代码**零必改**(四个被消费的 `sessionController` 方法逐字节未变、client slot 契约零差异、依赖范围因同属 `0.1.6` tuple 也无需改)。部署侧前置项:默认模型目录删除两个 id(已改 settings 路由)、Creator preset 工具集由 `cordis_define/run` 换成 `plugin_manager`、解析模式默认值 `link`→`runtime`、web profile 默认开 HMR、`dsh.profile.patchReload` 字段被删除(已复核无动作)。完整评估、升级/回滚步骤与**事故记录**见 [`docs/migration-0.1.6-alpha.1-to-alpha.2.md`]()。 > > ⚠️ **升级当天触发上游 alpha.2 回归,会话坏过一次**:源码入口下 `@deepseek-ai/dsh-tools` 被求值两次 → 每次工具调用报 `Cannot read properties of undefined (reading 'prepare')`。已用**服务入口改走构建产物**规避,**未改动 DSH 源码**;上游修复前不改 DSH 源码(详见 migration 文档 §6)。 > > 同批次修掉:超阈值分卡把表格/代码块切成两半——切分改为块感知(见下条)。 > > 同批次修掉:**一次永不返回的出站请求会永久冻结整个聊天的卡片投递**(Agent 答完、WebUI 能看见、飞书什么都没有);同一次挂起还会让**卡片排序屏障静默失效**,于是「老问题重新出现」——问题卡片又抢在步骤卡片前面。两条见下。 > > 同批次修掉:**在群里新建话题建会话时,创建流程的卡片被发到另一个话题**——卡片所属话题不能按「群」推断(一个群只有一个 `chatId`、却有很多话题),改成按**被点击卡片的 message id** 精确回查。见下。 > > 已升级到 DSH **`0.1.7-alpha.1`**(1299 commits):这一版**换掉了设置服务**,插件侧有 4 处必改(见下节)。会话控制 / 流式 / 工具 / 附件 / 客户端 UI / 会话日志格式**均未破坏**。完整评估、升级/回滚步骤与**两次构建失败的根因**见 [`docs/migration-0.1.6-alpha.2-to-0.1.7-alpha.1.md`]()。 > > Upgraded to DSH **`0.1.7-alpha.1`** (1299 commits). This release **replaced the settings service**, so the plugin needed four changes (see the first entry below). Session control, streaming, tools, attachments, the client slot contract, and the session-log format are unaffected. > > 也已评估 **并已升级到** DSH **`0.1.7-alpha.2`**(162 commits):**插件零改动**——逐符号核过 `settings.section` / `sessionProjections` / `configForms` / `remote.credentials` / `sessionController.prompt`、`selectModel` / `archiveSession` / `whileServed` 全部 0 差异;18 个 peer 包里 16 个源码 0 变更,其余两个只有**纯新增可选字段**(`ToolDefinition.projectContent?`、`SessionPageRequest.turnWindow?`);持久化 schema 无变化。升级前后 `lib/index.js` 与 `client/client.js` **逐字节一致**,typecheck 0 error、458 tests 全绿。这一版有两条对本项目有用的上游修复:**后台任务完成唤醒默认不再有上限**(此前默认 3 次后降级为注入,用户离开时会话会静默卡住)、**Web 服务重启后原页面能恢复连接**。一处破坏性配置变更(`spill-policy` 的 `maxInlineBytes` → `maxInlineTokens`)只影响自定义过该策略的部署,本仓库未设置。 > > Also evaluated **and upgraded to** DSH **`0.1.7-alpha.2`** (162 commits): **no plugin changes**. Symbol-by-symbol diffs of `settings.section`, `sessionProjections`, `configForms`, `remote.credentials`, `sessionController.prompt`/`selectModel`, `archiveSession`, and `whileServed` are empty; 16 of the 18 peer packages have no source change, and the other two only add optional fields (`ToolDefinition.projectContent?`, `SessionPageRequest.turnWindow?`); the persistence schema is unchanged. `lib/index.js` and `client/client.js` are **byte-identical** before and after, with typecheck clean and 458 tests green. Two upstream fixes matter here: background-job completion wakes are now unbounded by default (a cap of three used to park notices invisibly while the user was away), and a Web client recovers its connection after a server restart. The one breaking config change (`spill-policy`'s `maxInlineBytes` → `maxInlineTokens`) affects only deployments that customised that policy; this one does not. > > 也已评估 **并已升级到** DSH **`0.1.7-rc.1`**(相对本仓库基线 **156 commits / 933 个文件**):**插件零代码改动**。这一版的发行说明是**从 `0.1.5-rc.3` 累计**写的,其中绝大多数内容此前已随 `0.1.7-alpha.1` / `alpha.2` 纳入;相对 alpha.2 的真实增量集中在 Web 展示层与插件安装链路。**唯一的新闸门**是插件 / 组合包 / preset 行的 **DSH peer 兼容性检查**(`includePrerelease: true`,豁免走 profile 的 `compatibility.json` 或 `dsh plugin allow-version`)——本插件声明的 18 个 peer 范围全部满足 `0.1.7-rc.1`,启动时 **0 条拒绝**。其余与本插件相关:**工作过程展示改为 4 档**(`compact`/`standard`/`detailed`/`verbose`,默认从 `compact` 变为 `standard`,旧值 `expanded` 读作 `detailed`);工具行新增 `preparing → start → result` 三阶段(**纯客户端**:不改模型请求、不改工具派发时机、不改 Session 事件、不改持久化格式);聊天内本地图片预览;pnpm 运行**有界**(进程退出即完成 + 静默超时 + 回收进程树),装上或卸下插件不再可能长期占住 profile 锁;新增 `agentPresetRegistry.readDocument()`(只读查看 preset 组成);Office 引擎改用随应用安装的 `libreoffice-kit` 0.1.0。会话日志仍是 **V4**(无迁移);跨重启实测「聊天 → 会话」绑定表**完好**、真实工具调用正常、`typecheck` 0 error、**650 tests / 41 files** 全绿。本次**未重建插件产物**(升级前后哈希一致),插件 dev 依赖有意保持在 `0.1.7-alpha.2`。 > > Also evaluated **and upgraded to** DSH **`0.1.7-rc.1`** (**156 commits / 933 files** against this repository's baseline): **no plugin changes needed**. This release's notes are written cumulatively from `0.1.5-rc.3`, and most of that had already been taken up with `0.1.7-alpha.1`/`alpha.2`; the real delta since alpha.2 concentrates on the Web presentation layer and the plugin install path. The one genuinely new gate is the **DSH peer compatibility check** for plugin, bundle, and agent-preset rows (`includePrerelease: true`; exemptions live in the profile's `compatibility.json` or `dsh plugin allow-version`). All 18 peers this plugin declares satisfy `0.1.7-rc.1`, and startup reported **zero refusals**. Other relevant changes: work details now have **four modes** (`compact`/`standard`/`detailed`/`verbose`, default moved from `compact` to `standard`, saved `expanded` read as `detailed`); tool rows gained `preparing → start → result` stages (**client-only**: no change to model requests, dispatch timing, Session events, or the persisted format); local images preview inside chat; pnpm runs are now **bounded** (process exit rather than pipe EOF, an idle timeout, and whole-tree reaping), so an install or removal can no longer hold the profile lock indefinitely; `agentPresetRegistry.readDocument()` was added for read-only preset composition viewing; and Office tasks now use the bundled `libreoffice-kit` 0.1.0. Session logs remain **V4** (no migration); across the restart the chat→session binding table stayed intact, real tool calls worked, `typecheck` was clean, and **650 tests across 41 files** passed. The plugin artifacts were **not rebuilt** (hashes unchanged before and after), and the plugin's dev dependencies were deliberately left at `0.1.7-alpha.2`. ### 修复:`compact` 过程卡**折叠区**的引用块从第 2 行一路连到最后一行(`src/feishu-progress.ts`,关键坑 65,2026-10-12) 使用者报「折叠起来的内容里,那个 quote 标志除了第一行以外,从第二行一直延伸到最后一行」。 **根因**:折叠区(「更早的 N 行」)把**所有**折叠行拼成了**一个** markdown 元素,而步头不带 `>`。飞书的卡片 markdown 是 CommonMark,**引用块会被随后一行未标记的普通段落文本延续**(惰性延续,关键坑 65)——于是一个元素里第 1 行是步头(块外),从第 2 行起是 `> ` 内容行,**框就一直画到元素的最后一行**,把中间每一个后续步头都框了进去。真机渲染的字符串长这样: ``` ▸ **第 1 步** · 2.0s ← 第 1 行:步头,无 > > 💬 第 1 步的叙述行 ← 框从这里开始 > ✅ `read` · file1.ts · 40ms ▸ **第 2 步** · 2.0s ← 无 >,但被惰性延续并进上面的框 > 💬 第 2 步的叙述行 ▸ **第 3 步** · 2.0s > 📥 注入上下文 · AGENTS.md(已更新) ``` **为什么上面的可见区没事**:可见部分是**每步一个元素**,元素边界正好是框的边界,跨不过去;折叠区图省事拼成了一个元素,边界就消失了。另外两个折叠面板(步骤卡的推理折叠、压缩卡摘要)里没有 `>` 行,**不受影响**。 **修法**:把「按步分组产出元素」抽成 `groupByStep()`,可见区与折叠区**共用同一份**——折叠区现在也是**每步一个 markdown 元素**(步头在框外,框到本步最后一行就结束),截断时的 `…` 仍挂在第一个元素开头,`PROGRESS_MAX_FOLDED` 上限不变。两边共用一份实现,是为了这个形状以后不会再各自漂移。 **观感决定按规矩先发样板**(关键坑 63/65):A 现状(对照)/ B 空行分隔 / C 每步一个元素 / D 每步一个元素 + 步间 `hr` 四张真实渲染卡(面板展开)发到聊天,使用者选 **C**;四张都被 `im.v1.message.create` 收下(`code=0`,顺带确认「面板里放多个元素」「面板里放 `hr`」都是合法结构)。 **验证**:`npm run typecheck` 0 error;**1125 tests / 57 files 全绿**(本批 +1:折叠区逐步产元素、且每个元素的第 1 行是步头、其余行全部带 `>`——即框不会跨过步边界);`npm run build` 已产出(`client/client.js` 未变,纯宿主侧)。 **English**: A user reported that inside the compact progress card's folded panel the quote frame started on the second line and ran to the very last line. The folded body was built as ONE markdown element holding every older row, and step headings carry no `>`: Feishu's card markdown is CommonMark, so a blockquote continues through a following unmarked paragraph line (lazy continuation, pitfall 65) — the frame opened on the first content row and swallowed every later step heading. The visible rows were unaffected because they are already one element per step, and an element boundary is the only thing that closes a blockquote. The fix extracts `groupByStep()` and shares it between the visible rows and the folded body, so the folded panel is now one markdown element per step as well; the `…` truncation marker still prefixes the first element and `PROGRESS_MAX_FOLDED` is unchanged. Per the visual-change rule the four candidate shapes (A current, B blank-line separated, C one element per step, D one element per step plus `hr`) were rendered with the real renderer and sent for a human pick — C was chosen, and all four were accepted by `im.v1.message.create` (`code=0`). typecheck clean, **1125 tests across 57 files** green (1 new: the folded panel holds one element per step, each element's first line is its heading and every other line is quoted), artifacts rebuilt (`client/client.js` unchanged, host-only). The other two folded panels (the step card's reasoning fold and the compaction summary) carry no `>` rows and were never affected. ### 新增:`compact` 过程卡上「正在跑的那一步」带状态灯(`src/feishu-progress.ts` / `src/feishu-streaming.ts` / `src/i18n.ts`,2026-10-12) `compact` 的回合过程卡此前只能告诉你「这一步跑了多久」,看不出**现在在干什么**——一步刚开始、还没调工具时,表头只有一个往上走的 `4.8s…`。现在**正在跑的那一步**在这条表头里带一个灯,插在**步号与耗时之间**: ``` ▸ **第 2 步** · 🧠 思考中 · 4.8s… > ⏳ `bash` · npm · 1.2s… ``` **为什么挂在表头**:这张卡上只有表头是「描述现在」的,它下面的行全是已经发生过的事。 **四种状态**(信号来自插件本来就在消费的**瞬时** `agent/assistant-stream` 帧——`compact` 的步骤卡不发出来,这是这条链路上唯一的实时信号): | 灯 | 判据 | |---|---| | 📡 等待输出 | 请求已发出、还没吐第一个 token | | 🧠 思考中 | 推理在流 | | ✍️ 回复中 | 正文在流 | | 🔧 工具调用中 | **该步还有工具行没结算**(优先于上面三个) | 前三个正是步骤卡占位标题用的那三个相位(同一个相位在同一个聊天里只有一个名字)。**「等待输出」单列**是因为 `step/start` 之后到第一个 token 之间说「思考中」没有证据(与步骤卡占位卡的判据一致)。**工具优先**是因为带着 tool-call 的那几帧报的是 `replying`——不这么判,整段工具调用期间表头都会在说一个这一步已经离开的相位。 **代价与边界**:帧按 token 到,但只有**相位变化**才写(`stepPhase()` 内部再去重一次),仍走既有的 900ms 合并窗口;30 秒一次的时钟重绘判据从「有工具在跑」扩成「这一步还活着(有工具没结算 **或** 有相位)」,所以思考期间表头时钟也是活的,最多 30 秒一次写。灯**只在步骤还没结束时渲染**(`endedAt === undefined`):`step/end` 一落地就消失,晚到的帧也被丢掉,已结算的表头不会被重新点亮。`compact` 之外的档位不受影响(那些档的过程卡不存在,相位仍只出现在步骤卡占位上)。 **形状是选出来的**:按坑 63/65 的老规矩,先用真实渲染器 `renderProgressCard` 渲染了 5 张样板(A 文字后缀 / A2 灯在时钟前 / B 工具态 / D 三灯条 / E 仅图标)发给人眼选,**使用者选定 A2**(灯在时钟前)。 **验证**:`npm run typecheck` 0 error;**1117 tests / 57 files 全绿**(本批新增 10 例:渲染侧 5 例——灯的位置与相位措辞、三相位逐个、步骤结束即消失、工具优先于相位、无帧时无灯;控制器侧 3 例——相位先于卡片到达仍进首次渲染、**相位变化才写**且 `step/end` 清灯、已结束的步与没见过的步号一律忽略;流式接线 2 例——`compact` 下相位落到过程卡、其他档位零影响;另有 3 条既有断言改为带灯的表头);`npm run build` 已产出(`client/client.js` 未变,纯宿主侧)。 **English**: The `compact` turn progress card used to say how long the running step had been going and nothing about what it was doing. The step that is still running now carries a light in its header, between the step number and the clock (`▸ **Step 2** · 🧠 thinking · 4.8s…`), because the header is the only line of that card that describes the present. Four states come from the transient `agent/assistant-stream` frames the plugin already consumes (the only live signal on this path, since `compact` posts no step card for tool work): 📡 awaiting output (request out, no token yet), 🧠 thinking, ✍️ replying, and 🔧 calling tools, which outranks the phase while one of that step's calls has not returned — the frames carrying a tool call report `replying`, so without that rule the header would name a phase the step has already left for the whole tool run. `awaiting output` is its own state because claiming "thinking" between `step/start` and the first token has no evidence (the same rule as the step card's placeholder). Frames arrive per token but only a phase CHANGE writes (deduplicated again inside `stepPhase`), still inside the existing 900ms coalescing window, and the 30-second clock repaint now treats a step as live while it has a phase, not only while a tool runs. The light renders only while the step is open (`endedAt === undefined`), so it is gone the moment `step/end` lands and a late frame cannot relight a settled header. The other levels are untouched. The shape was picked by eye from five real-renderer samples (pitfalls 63/65); the user chose A2 (the light before the clock). typecheck clean, **1117 tests across 57 files** green (10 new), artifacts rebuilt. ### 改动:`compact` 过程卡**运行中**的标题改为「深度求索中 · 耗时」(`src/i18n.ts` / `src/feishu-progress.ts`,2026-10-12) 运行中的标题从 `⚙️ 本回合进行中 · N 个工具 · 12.3s` 改成 `深度求索中 · 12.3s`(en:`DeepSeeking · 12.3s`,同一个双关——深度求索就是 DeepSeek):**这张卡是运行中扫一眼用的**,「现在在干什么」已经由**正在跑那一步的表头状态灯**承载(上一条),工具数在下面的行里数得出来,所以标题只需要回答两件事——**还在跑**、**跑了多久**。图标与计数一起去掉,是因为它们都不是这两个问题的一部分(`turnCardTitleRunning` 的签名随之从 `(calls, elapsed)` 收成 `(elapsed)`)。 **结束/失败的标题一个都没动**(`⚙️ 本回合 · N 个工具 · 耗时` / `⚙️ 本回合失败 · …`):那时不再有活动行在变,工具计数正是这张卡的意义所在。 **验证**:`npm run typecheck` 0 error;**1118 tests / 57 files 全绿**(新增 1 例钉住运行中标题的字面文案与「不含工具计数」;4 条既有断言随签名调整)。**顺带修掉 5 条会变成永真的断言**:流式用例一直用标题里有没有「本回合」来区分「这是过程卡还是步骤卡」,运行中标题不再含这三个字 ⇒ 不换判据的话,「其他档位没有过程卡」那几条会**恒为真**(测试还在跑,但已经什么都不测了)。现在统一走 `isTurnCardTitle()`(运行中前缀 **或** 结算后的「本回合」)。`npm run build` 已产出(`client/client.js` 未变,纯宿主侧)。 **English**: The running title changed from `⚙️ 本回合进行中 · N 个工具 · 12.3s` to `深度求索中 · 12.3s` (`DeepSeeking · 12.3s` in English — the same pun, since 深度求索 is DeepSeek's Chinese name). This card is scanned while the model works: "what is happening now" is already carried by the running step's header light (previous entry), the tool count is readable from the rows below, so the title only has to answer two questions — still running, and for how long. Icon and count went with it, and `turnCardTitleRunning` narrowed from `(calls, elapsed)` to `(elapsed)`. The settled titles are untouched (`⚙️ 本回合 · N 个工具 · 耗时` / `⚙️ 本回合失败 · …`): no row is moving any more by then, and the count is what that title is for. While updating the specs, five streaming assertions that told a turn card from a step card by looking for `本回合` in the title were rewritten to `isTurnCardTitle()` — the running title no longer contains that word, so left alone they would have asserted nothing ("no progress card at this level" would have been trivially true). typecheck clean, **1118 tests across 57 files** green, artifacts rebuilt. ### 调整:`compact` 过程卡的可见窗口从 3 步改回 5 步(`src/feishu-progress.ts`,2026-10-12) `PROGRESS_MAX_STEPS` 3 → 5。这个常量本身是 5 → 3 → 5:10-09 上线时是 5,10-12 白天按「一眼就够」收成 3,**同日晚些时候使用者要求改回 5**。窗口仍然**按步骤计数**、整步(连标题)折叠,`PROGRESS_MAX_ROWS = 40` 仍是外层上限,所以这次只动一个常量与它的说明,**卡片形状零变化**。 **为什么 3 太短**:这张卡是「一边干活一边看」的,3 步 = 运行中的那一步 + 「怎么走到这」的两步。回合稍长一点,想回头核对某一步做了什么时,它的标题已经折进面板里了。5 步留下「正在跑的 + 它之前四步」,代价只是长回合多几行(超过 40 行照样折)。 **顺带把两条测试改成常量驱动**(这次的直接教训):原来写死 `3` / `steps 6-8` / `hr ×2` 的地方,现在按 `PROGRESS_MAX_STEPS` 推导(`total = PROGRESS_MAX_STEPS + 3`、`dropped * 3`、`PROGRESS_MAX_STEPS - 1`)。其中「分隔线画在折叠面板之后」那条用的 4 步,在窗口变成 5 后**折叠面板整个不再出现**,于是直接失败——不是因为功能坏了,而是测试钉着旧窗口。以后再调这个常量不会再出现同一类假失败。 **验证**:`npm run typecheck` 0 error;**1118 tests / 57 files 全绿**(改 2 例、新增 0 例);`npm run build` 已产出(`client/client.js` 未变,纯宿主侧)。 **English**: `PROGRESS_MAX_STEPS` goes from 3 back to 5 (the constant has been 5 → 3 → 5: 5 when the card shipped on 10-09, narrowed to 3 on 10-12 for "one glance is enough", changed back to 5 later the same day at the user's request). The window still counts STEPS and folds whole steps, headers included, with `PROGRESS_MAX_ROWS = 40` as the outer bound — so this is one constant and its comment, and the card's shape is unchanged. Three steps means the running one plus the two that led to it, which is too short a memory for a turn that keeps working: by the time a step is worth re-reading, its header has folded away. Five keeps the running step and the four before it on screen, at the cost of a few more rows on long turns (past 40 rows the folding still applies). Two specs were made constant-driven in the same commit — they hardcoded `3`, `steps 6-8` and `hr ×2`, and the separator spec (four steps) failed outright once five fitted on screen, because it was pinning the old window rather than the behaviour. typecheck clean, **1118 tests across 57 files** green, artifacts rebuilt. ### 修复:模型把代码块的**收尾围栏粘在最后一行代码后面** → 后面整篇正文都变成代码(`src/card-markdown.ts`,关键坑 68,2026-10-12) 使用者报「某个会话第 518 轮第 2 步,代码块渲染错误」。查证如下。 **事实**:那条回复的正文(会话日志原文)里,代码块**只有开头一行是干净的**,收尾粘在最后一行代码末尾: ````text ``` --size {draft, draft-v, quality, quality-v} ← 只有这四个写死档``` ```` **为什么整篇都坏了**:飞书按**卡片元素**渲染 markdown,只有**独占一行**的围栏才算收尾(`…写死档``` ` 不算)。于是这个代码块**永远不闭合**,它后面所有的标题、表格、"请你选"全部被当成代码——就是使用者看到的"代码块渲染错了"。 **这不是插件改坏的**:把这条真实文本喂给真实的 `renderStepCard`,再走发送前那道 `neutralizeCard`,**修前两处输出逐字一致**(围栏仍然粘着)——插件是忠实透传,粘的是模型自己写的。 **量级**(2026-10-12 全量扫本机 26 个会话日志):**931 条带围栏的助手消息里,6 条围栏没有闭合**,其中 **4 条正是这种粘连**(分布在 3 个不同会话),另 1 条是"围栏开头粘在正文行尾",1 条是"真的忘了写收尾"。**是复发行为、不是一次性事故**,而模型改不了,只能在渲染层修。 **修法**(`repairGluedFenceClosers(text)`,排在 `neutralizeCardMarkdown()` 的**第一步**):在**围栏内部**,如果一行以「与开头同字符、长度不短于开头」的围栏串**结尾**,就把这个串挪到自己一行(于是它真的收尾了)。两条边界是这次的关键: 1. **只在元素本身围栏不闭合时才动手**——已闭合的元素**原样返回**。这挡住的是「在围栏里演示围栏」(代码示例本身有一行以 ``` 结尾):那种形状自带真正的收尾、走不到修复分支;不设这道闸,把合法的示例拆开会把它自己变成两个块。 2. **必须排在图片/链接/音频中和之前**:不先修,下面那半篇会被扫描器当成"代码里"而跳过中和——那里若有一个 `![x](本地路径)` 就会让整张卡被拒收。 **刻意不修**的两种:开头粘在正文行尾的(把散文切出一个代码块是猜测)、真的没写收尾的(无从判断模型想在哪一行结束)。**有证据、有安全读法的才动手。** **验证**:`npm run typecheck` 0 error;**1124 tests / 57 files 全绿**(本批 +6:真实原文的粘连被拆开且下面的散文留在块外、经 `neutralizeCardMarkdown` 的端到端行为、**已闭合元素不动**、无粘连候选的不动、开头是 4 个反引号时 3 个反引号的行不算收尾(嵌套写法)、波浪号 / 行尾空格 / 幂等);真实原文重放:修前 `neutralizeCard` 输出仍带 `…写死档``` `,修后是「代码行 + 独立 ` ``` ` 行」,后面的 `SIZES = …` 与标题回到块外;`npm run build` 已产出(`client/client.js` 未变,纯宿主侧)。 **⚠️ 已经发出去的那张卡不会变**:卡片只发不更新(关键坑 57),第 518 轮那张卡停在飞书里就是这个样子;这条修复只对**新**卡片生效。 **English**: A user reported a broken code block in one session's turn 518 step 2. The assistant text itself is malformed: the opening fence sits on its own line but the closing fence is glued to the end of the last code line (`--size {…} ← 只有这四个写死档``` `). Feishu renders each card element on its own and only a line that is NOTHING but the fence run closes a block, so the block never closed and every heading, table and sentence after it rendered as code. The plugin is not what broke it: feeding that exact text through the real `renderStepCard` and then through the pre-send `neutralizeCard` produced byte-identical fences before the fix — model text is passed through faithfully. This is recurring, not a one-off: across 26 session logs on this machine (2026-10-12), 931 assistant messages carry fences and 6 leave one unterminated, **4 of them exactly this glue** (in three different sessions); the other two are one opener glued to prose and one genuinely missing closer. `repairGluedFenceClosers()` runs FIRST inside `neutralizeCardMarkdown()` and moves a trailing fence run (same character, at least as long as the opener) onto its own line — but only for an element whose fences do not balance. That guard is the point: an element that closes later is returned untouched, so a code sample documenting fences inside a fence keeps its content instead of being split in two. Running it before the reference/audio passes matters as well, because those deliberately skip code: without the repair the second half of the answer counted as "inside code", and an image reference down there would have been left for Feishu to reject. Two shapes are deliberately NOT repaired — an opener glued to prose, and a fence with no closer at all — because neither has a reading we can defend. typecheck clean, **1124 tests across 57 files** green (6 new), a replay of the real text now yields "code line + its own ` ``` ` line" with the prose back outside the block, artifacts rebuilt. The card already sent cannot be fixed (cards are send-only, pitfall 57); this applies to new cards. ### 新增:`/subagents` —— 手动打开子代理面板(TODO O2,`src/feishu-subagents.ts` / `src/index.ts` / `src/commands.ts` / `src/i18n.ts`,2026-10-12) 面板此前**只能自动出现**(名册完全来自本进程的事件流:`subagent/catalog` + 宿主 `subagent/start`·`end`),于是**重启后看不见之前跑过的子代理**,也不能主动查一眼「现在有哪些、在跑什么」。WebUI 侧对应的是会话头部的**血缘视图**(`ui-subagent`,数据来自投影 ⇒ 打开即最新),所以这个手动入口也必须**打开即权威**,而不是只回放本进程见过的事件。 **命令**:`/subagents` = 打开**当前会话**的子代理面板;`/subagents tree` = 用 `listDescendants` 列**整棵子树**(复用既有的 `├─ ` / `└─ ` / `│ ` 树渲染)。不需要活的 agent,只需要 bridge 已经绑定的会话 ⇒ **重启后立刻可用**(与 `/status`、`/session` 同类)。 **数据 = 宿主目录 ∪ 本进程名册**(同一行「谁有谁填」): | 来源 | 给什么 | |---|---| | `ctx.get('subagents')` 的 `listChildren` / `listDescendants`(**可选依赖**,不进 `inject`,坑 2) | **权威目录**:id / label / mode(一次性·可续谈)/ 父会话 / 深度;以及 `kind: 'diagnostic'` + `reason` | | 本进程名册(既有) | 模型(`provider/model`)、每轮耗时、**结算原因**(completed / max-tokens / refused / aborted / error) | | `ctx.agents.get(id).status === 'running'` | 只有目录信息时的运行状态(与宿主自己的 `list_agents` 同口径) | **取舍与边界**:① **只有目录信息的行不编造耗时**,写 `—`;② 宿主那个 `list_agents` 工具**故意丢掉一次性子代理**(它为 `send_message` 服务),我们**两种 mode 都列**;③ 宿主读取**有界**(`withTimeout` 5s,坑 29):超时或抛错**退回名册 + warn**;④ **空态回一行纯文本**(`当前没有子代理`),不发空表卡(坑 40 的口径);⑤ 与**上一次手动打开**的快照签名相同 ⇒ 回「面板没有变化」,不重复发卡;⑥ 卡片**不加按钮**(面板是「只发不更新」,加按钮会踩坑 11 / 57,「刷新」就是再敲一次命令);⑦ **自动面板的既有行为完全不变**(有一条测试专门钉住)。 **验证**:`npm run typecheck` 0 error;**1107 tests / 57 files 全绿**(本批新增 17 例:目录行解析与容错(两种 mode / 位置与深度 / diagnostic / 丢弃不可用项)、目录∪名册合并(身份取目录、运行取名册、名册独有行保留、目录独有行不编造耗时)、`tree` 按 parent 画树、无子代理回文本而不发卡、本进程从未见过创建的子代理也能列出、服务缺失退回名册、读取超时退回名册并 warn、去重、**卡片无按钮**、**自动面板不受影响**);`npm run build` 已产出(`client/client.js` 未变,纯宿主侧)。 **English**: The subagent panel used to appear only on its own (its roster is built purely from this process's event stream), so after a restart earlier subagents were invisible and there was no way to ask "what is running right now". The WebUI counterpart is the session header's lineage view, whose data comes from a projection — i.e. opening it is authoritative — so this manual entry point had to be authoritative too. `/subagents` opens the current session's panel; `/subagents tree` reads `listDescendants` and draws the whole subtree with the existing tree renderer. No live agent is required, only the session the bridge already binds, so it works immediately after a restart like `/status` and `/session`. The data is the Host catalog (`subagents.listChildren`/`listDescendants`, an OPTIONAL dependency read through `ctx.get`, never injected — pitfall 2) unioned with this process's roster: the catalog supplies id / label / mode / parent / depth (and `kind: 'diagnostic'` + `reason`), the roster supplies the model (`provider/model`), per-run duration and the outcome, and `agents.get(id).status === 'running'` states a catalog-only row. Deliberate choices: a catalog-only row claims NO duration (`—`); unlike the Host's own `list_agents` tool we keep BOTH modes (that tool drops one-shot children because it serves `send_message`); the host read is bounded at 5s (`withTimeout`, pitfall 29) and falls back to the roster with a warning; an empty panel answers in one line of text instead of an empty table card; an identical snapshot to the last manual open answers "unchanged" rather than reposting; the card carries no buttons (the panel is send-only — pitfalls 11/57 — so "refresh" means running the command again); and the automatic panel's behaviour is untouched (one test pins exactly that). typecheck clean, **1107 tests across 57 files** green (17 new), artifacts rebuilt. ### 修复:子代理面板的「模型」列对**被恢复**的子代理永远是 `—`(`src/feishu-subagents.ts` / `src/index.ts`,2026-10-12) 使用者报「另外一个会话运行完的卡片仍然是 `-`」。查下来是**真 bug,而且上游代码就是证据**:DSH **只在路线发生变化时**才追加 `request/context`—— ```ts // packages/core/agent-loop/src/agent.ts const previousContext = session.requestContext() if (previousContext?.provider !== requestContext.provider || previousContext.model !== requestContext.model || …) { session.append('request/context', requestContext) // 只有变了才记 } ``` 而面板**只靠这个事件**填模型列 ⇒ **被恢复的 continuable 子代理**(路线与上次相同)**一个事件都不会发**,那一格永远停在 `—`。 **取证**(那个话题 `omt_19fdba…` 的子会话 `72d42fb3`):子会话日志里最后一条 `request/context` 是**当天 10:11:02**(`cpa-codex/gpt-6.1-sol`),而它 **10:28 被恢复**跑了一次(2m34s)⇒ 路线没变 ⇒ 那段窗口里没有任何模型事件;两张面板卡(10:28:34 运行中、10:31:06 已完成)都只能是 `—`。对照:同一小时内新起的子代理(全新会话,第一次请求必然与「无」不同)模型正常显示 ⇒ 机制本身没坏,缺的是**事件覆盖不到的场景**。 **改法**:不要只等事件 —— 行的模型有第二个来源,**直接问宿主该子会话当前的路线**(`sessions.get(childId).requestContext()`,**纯内存、同步**;那个 fold 会**重放整份日志**,所以恢复的会话也有值)。三处落地:① 行创建时(`subagent/catalog` 到达)就填;② 快照构建时,对**仍为空**的行再问一次(覆盖「catalog 先到、会话随后才驻留」);③ 实时 `request/context` 到达时照旧覆盖。**顺带**:列现在显示 **`provider/model`**(如 `cpa-codex/gpt-6.1-sol`),因为事件与缓存两边都能拿到 provider,两个不同 provider 上的同名模型因此可区分;单元格上限 24 → 32 字符。宿主给不出路线时(子会话已不在内存)仍然是 `—`。 **这是坑 66 的同类错误**:坑 66 是「把**宿主后端缺一个方法**当成**这件事做不到**」,这条是「把**没有信号**当成**没有事实**」——两条都是**用错误的层去推断**。记为坑 67。 **验证**:`npm run typecheck` 0 error;**1090 tests / 56 files 全绿**(新增 2 例:**「被恢复的子代理一个 `request/context` 都不发,第一张卡就已经带路线」**、**「catalog 先到时先显示 `—`,会话随后驻留则在下一张卡补上路线」**;原「事件到达才显示模型」那条改成断言 `provider/model`);`npm run build` 已产出。 **English**: Reported as "another session's finished card still shows `-`", and it was a real bug with the upstream code as evidence: DSH appends `request/context` ONLY when the route CHANGES (`packages/core/agent-loop/src/agent.ts` compares against the session's previous context), while the panel waited for exactly that event — so a RESUMED continuable subagent (same route as before) never emits one and its row sat at `—` forever. Evidence from the affected thread: the child's own log's last `request/context` was 10:11:02 (`cpa-codex/gpt-6.1-sol`) and the child was resumed at 10:28 for 2m34s, so no model event existed in that window and both panel cards (running, then completed) could only show `—`; a brand-new child started in the same hour displayed its model normally, which proves the mechanism works and only the event's coverage was missing. The row's model now has a SECOND source: ask the host for that session's current route (`sessions.get(childId).requestContext()` — pure memory, synchronous; the fold replays the whole log, so resumed sessions answer too). It is applied in three places: when the row is created, when a snapshot is built for a row that is still empty (covering "catalog arrived before the session was resident"), and by the live event as before. Along the way the column now reads `provider/model` (both sources carry the provider, and two same-named models on different providers are now tellable apart) with the cell cap raised from 24 to 32 characters; a child the host cannot answer for (no longer resident) still shows `—`. typecheck clean, **1090 tests across 56 files** green (2 new), artifacts rebuilt. ### 修复:「新建文件夹」按钮被自己的门控藏掉了(O17 补丁,`src/feishu-onboarding.ts` / `tests/onboarding.spec.ts`,2026-10-12) 使用者报「**没看见新建目录按钮**」。查下来是上一批我加的门控**把功能藏没了**,而不是没发布(自检里 `browse-mkdir` 明明在产物里):本机 `dsh` 进程带着 `DISPLAY=:0` / `XDG_SESSION_TYPE=x11`,`directory-picker-auto` 于是选了 **`native` 后端**(`resolve.ts`:linux 上有 `DISPLAY`/`WAYLAND_DISPLAY` 且非远程绑定就 `native`),而 `native` 没有 `createDirectory` ⇒ 按「后端不是 `browse` 就藏按钮」的判据,按钮永不出现。 **根因是判据跨层推断了**:`native` 说明的是**宿主的选择框**不可用(它要弹系统对话框),**不等于文件系统不能建目录**。这个插件在没有 `browse` 后端时本来就在**自己列目录**(`listDirectoryLocally`),O16 那批也**已经在自己 `mkdir`** —— 没有理由在这个按钮上突然要求宿主必须能建。 **改法**:`resolveDirectoryCreator()` **不再返回 `undefined`** —— 宿主挂载了 `browse.createDirectory` 就用它(保留它的围栏:单段、非递归、`directory-exists`),否则返回一个**插件自己的 `mkdir`** 实现(同样非递归 `mkdir`,名字在调用前按同一口径校验:非空、单段、不含 `/` 或 `\`、不是 `.` / `..`),**按钮无条件显示**;非法名字在**碰磁盘之前**就被拒(一行中文提示挂在浏览器上),`EEXIST` 仍翻成「已经有一个叫 … 的文件夹了」。 **验证**:`npm run typecheck` 0 error;**1088 tests / 57 files 全绿**(`tests/onboarding.spec.ts`:把「后端不能建 ⇒ 藏按钮」那条**反转为**「`native` 后端下按钮照在,并且真的用插件自己的 `mkdir` 在临时目录里建出文件夹、随后进入它」,另加「名字含 `/` 在碰盘前被拒」;「浏览器卡上只有一个 form(新建文件夹)」那条断言同步);`npm run build` 已产出。 **English**: The user reported they could not see the new-folder button. It was my own gate from the previous batch hiding a feature that had shipped (the self-check showed `browse-mkdir` present in the artifact): this host's `dsh` runs with `DISPLAY=:0`/X11, so `directory-picker-auto` selects the **`native` backend**, which has no `createDirectory` — and my criterion ("hide the button unless the backend is `browse`") therefore hid it forever. The criterion crossed a layer: `native` is a statement about the host's CHOOSER (it opens an OS dialog), not about the filesystem. This plugin already lists levels itself when `browse` is absent and already does its own `mkdir` for the `/new` flow, so there was no reason to demand a host capability here. `resolveDirectoryCreator()` no longer returns `undefined`: it uses `browse.createDirectory` when mounted (keeping that backend's fences — one segment, non-recursive, `directory-exists`) and otherwise falls back to the plugin's own non-recursive `mkdir`, with the name validated to the same contract (non-blank, one segment, no `/` or `\`, not `.`/`..`) BEFORE anything touches disk. The button is unconditional; an invalid name is refused with one Chinese line on the browser, and `EEXIST` still reads "a folder named … is already there". typecheck clean, **1088 tests across 57 files** green (the "backend cannot create ⇒ hide it" case was inverted into "under a `native` backend the button is there and the plugin's own `mkdir` really creates the folder, then enters it", plus a case for the separator being refused before disk I/O), artifacts rebuilt. ### 修复:`/new` 的路径不存在时先问再建、目录浏览器能新建文件夹、注册推迟到最后一步(O16 / O17 / O19,`src/feishu-onboarding.ts` / `src/index.ts` / `src/new-session-card.ts` / `src/i18n.ts`,2026-10-12) 三件事同批结案(O16 与 O19 共用 `commitWorkspace`,一次重构)。 **O16(手输一个不存在的路径 → 流程走死)**:`commitWorkspace` 原来是**直接** `workspaceRegistry.create(path)`,而上游 `Workspace.create` 先 `realpathNormalize` 再 `stat` ⇒ 目录不存在就在第一步抛 `ENOENT`,红卡上印着原始英文错误;失败分支**连日志都没有**(只有成功路径打 `created workspace`)。现在:**先用 `stat` 判存在性**,不存在就发一张确认卡「这个目录还不存在」——卡上除了「✅ 就建这个目录 / ← 返回重输」,还列出**最深已存在父目录的同级目录**(可点行,点一下直接改用那个):这是**路径打错的一键修正**,也是卡片输入框**做不了补全**(没有候选字段、且飞书不回传按键事件,见 `docs/feishu-capabilities.md` §2.2.2)之后能做的最便宜的替代。**确认本身也不落盘**,见 O19。同一处顺带修掉两条难懂/静默的分支:**相对路径**现在给中文说明(原来抛上游的 `TypeError: not fully qualified`)、**路径存在但是个文件**单独报;失败一律补 `logger.warn`。 **O17(目录浏览器不能在当前层新建文件夹)**:`DirectoryPickerLike` 里早就声明了 `createDirectory?` 却**0 个调用点**。现在浏览器上多一个「📁+ 新建文件夹」(`form` + 单段名输入 + 提交,与工作区选择卡同一种已验证过的形状)→ 新动作 `browse-mkdir` → `directoryPicker.createDirectory(当前层, 名字)` → 成功后**直接进入新目录**(顺手把它的内容列出来);`directory-exists`(上游是 `DirectoryPickerError('directory-exists')`)与非法名字翻成中文,作为**一行提示挂在原来那张浏览器上**,层级不丢。**门控**:只有挂载的后端真有 `createDirectory`(`browse`)才显示按钮;`native` 后端或插件自己的只读 listing 不显示 —— 一个只能失败的按钮比没有按钮更糟。台账原来写的判据是「非 browse 后端就藏浏览入口」,**这条既太宽也不成立**(浏览入口本来就是无条件显示的),按推荐只藏这一个按钮。 **O19(`/new` 的副作用不全在最后一步)**:`commitWorkspace` 过去会**当场** `workspaceRegistry.create`,于是「工作区在第二步就注册并落盘,中途取消也不撤销」。现在 `commitWorkspace` 只**校验并记录**(`flowState.workspace` / `workspaceMissing`),真正的 `mkdir -p`(仅当操作者在确认卡上点了「就建这个目录」)与 `workspaceRegistry.create` 一起挪到流程**最后一步**:`commitNewSession` 新增 `prepareWorkspace` 钩子,在 `startNewSession` **之前**执行,失败则**不创建会话**并把原因写在操作者刚按的那张确认卡上(这是整个 `/new` 流程唯一触碰世界的地方,「取消则什么都不留」因此成立)。最终确认卡多一行 `🆕 将新建目录:…`,说清楚目录是按下按钮才出现的。**文本命令路径**(`/new --workspace …`)不受影响:它只接受已注册的工作区(找不到就报错),本来无需注册。 **验证**:`npm run typecheck` 0 error;**1088 tests / 57 files 全绿**(新增 9 例:`tests/onboarding.spec.ts` 6 例 —— 已存在路径不注册就进预设步 / 不存在的路径只确认、不注册、不落盘(断言目录仍不存在)/ 同级候选行可一键改用并清掉 missing 标记 / 相对路径与「是个文件」各自的中文报错 + 有 warn / 后端不能建目录时按钮隐藏 / 新建文件夹成功后进入新目录、重名翻成一行提示 / 无后端时忽略陈旧表单;`tests/new-session-commit.spec.ts`(新文件)3 例 —— **prepareWorkspace 在 startNewSession 之前执行**、准备失败则不建会话且卡上写明原因、无钩子时照常工作);`npm run build` 已产出(`client/client.js` 未变,本批纯宿主侧)。 **English**: Three ledger items closed together (O16 and O19 share `commitWorkspace`, so they were one refactor). **O16**: `commitWorkspace` used to call `workspaceRegistry.create(path)` directly, and upstream `Workspace.create` runs `realpathNormalize` then `stat`, so a path that does not exist threw `ENOENT` at the first step and the red card carried the raw English error — with no log line at all on the failure branch (only the success path logged "created workspace"). It now stats the path first, and a missing one gets a confirmation card ("that folder does not exist yet") whose rows list the **neighbours of the deepest existing ancestor**: one tap fixes a typo, which is the cheapest substitute for path completion after the card input turned out to be unable to complete (no candidate field, and Feishu sends no keystroke events — capabilities 2.2.2). Confirming writes NOTHING yet (O19). Two other branches were fixed in the same place: a **relative path** now explains itself in Chinese (it used to surface upstream's `TypeError: not fully qualified`), and "the path exists but is a file" says so; every failure now logs a warning. **O17**: `DirectoryPickerLike` had declared `createDirectory?` all along with **zero call sites**; the browser now offers "📁+ New folder" (a `form` + one-segment input + submit, the shape the workspace picker already proved) → new `browse-mkdir` action → `directoryPicker.createDirectory(currentLevel, name)` → on success it **enters the new folder**; `directory-exists` and invalid names become one Chinese line on the browser the operator is already looking at, so the level is not lost. It is gated on the mounted backend actually having `createDirectory` (the `browse` backend): the `native` chooser and the plugin's own read-only listing get no button, because a button that can only fail is worse than none — the ledger's original criterion ("hide the whole browser entry") was both broader and untrue, since that entry was unconditional. **O19**: registration used to happen inside the picker step, so cancelling `/new` left a workspace behind. `commitWorkspace` now only validates and records (`workspace` / `workspaceMissing`), and the `mkdir -p` (only after the operator confirmed) plus `workspaceRegistry.create` moved to the flow's final step, via a new `prepareWorkspace` hook on `commitNewSession` that runs BEFORE `startNewSession`; a failure creates no session and explains itself on the card that was just pressed. The final confirmation card gained a `🆕 Will create the folder` line. The text-command path (`/new --workspace …`) is unaffected — it only accepts already-registered workspaces. typecheck clean, **1088 tests across 57 files** green (9 new: 6 in `tests/onboarding.spec.ts`, 3 in the new `tests/new-session-commit.spec.ts` pinning that preparation runs before session creation and that a failed preparation creates no session), artifacts rebuilt (`client/client.js` unchanged: host-only). ### 文档:`input` 不能预填、也不能补全——「路径补全」这条路按能力事实封死(`docs/feishu-capabilities.md` §2.2.2,2026-10-12) 使用者问「`/new` 的输入框能不能预填 home,能不能像 Linux 的 tab 那样一边输入一边补全」。逐个字段名真发(对照组 `bogus_xyz` 同样被拒 ⇒ 「被拒」是可信的否定证据): - `value` 是 `input` 上**唯一**被接受的初值字段,但**发出去输入框仍是空的**(发了 A/B 对照样板,使用者目视确认)——服务端容忍它,客户端不用它渲染; - `default_value` / `initial_value` / `default` / `preset_text` / `prefill` / `text`、form 级 `value`/`initial_value`、以及 `options` / `autocomplete` / `suggestions` / `datasource`(元素级与 form 级)**全部 `unknown property` 拒收整卡**。 ⇒ **预填做不到;补全也做不到,而且不只是缺字段**:飞书不回传按键事件(卡片是静态 JSON,输入只在客户端,**只有「提交」产生一次回调**),没有事件源就没有实时补全。能做的最接近的是**「点着补全」**(提交前缀 → 候选可点行 → 点一行把路径补全后回填输入框),或在「路径不存在」的确认卡里列出**最深已存在父目录**的同级候选(更便宜)。两条都记进台账,供 O16/O17 落地时选。 **English**: The user asked whether the `/new` path input can be prefilled with home, and whether it can autocomplete as the user types (like a shell's tab completion). Every candidate field was tried with a real send, with the `bogus_xyz` control refused the same way, so a refusal is trustworthy negative evidence: `value` is the ONLY accepted initial-value field on `input`, and a real A/B sample card showed the box stays EMPTY after sending (the server tolerates the field, the client does not render it); `default_value`, `initial_value`, `default`, `preset_text`, `prefill`, `text`, form-level `value`/`initial_value`, and `options` / `autocomplete` / `suggestions` / `datasource` (element- and form-level) are all refused whole-card as `unknown property`. So prefill is impossible, and completion is impossible for a second reason: Feishu does not send keystroke events back to the bot — the card is static JSON, typing happens client-side, and only a SUBMIT produces a callback, so there is no event source to complete from. The closest thing that CAN be built is click-to-complete (submit a prefix → candidate rows → tap one to extend the path back into the input), or the cheaper variant of listing siblings of the deepest existing parent on the "path does not exist" confirm card. Both are recorded in the ledger for O16/O17. ### 文档:把「飞书能不能只折叠一部分内容」的实测结论记进能力台账(`docs/feishu-capabilities.md` §2.3.3,2026-10-12) 使用者问「除了折叠卡片,feishu 是否还支持折叠部分内容、点击展示全部内容」。官方文档在本机仍不可达(`open.feishu.cn` / `open.larksuite.com` / `raw.githubusercontent.com` 全被解析成非公网地址),所以按老规矩实测:把 `lines` / `max_lines` / `collapsed` / `fold` / `tooltip` 五个猜测的属性名塞进 markdown 元素**真发一次**,全部被 `230099 · 200621 unknown property` **整卡拒收**;对照组 `bogus_xyz` 同样被拒、而「已知名字配非法值」(`text_size: 'gigantic'`)被接受 ⇒ 服务端校验的是**属性名是否存在**,因此这些「被拒」是可信的否定证据。 结论:**折叠机制只有 `collapsible_panel` 一种**,markdown 没有「显示前 N 行 + 展开全文」;但**「折叠部分内容」本身支持** —— 面板只包住选中的那几个元素,前后元素照常显示(当天发过真机样板:`markdown` + 面板 + `markdown`,只有中间那段折起来)。折叠粒度是**整个元素**(块级容器,折不进一段文本中间);`overflow` 元素确实存在但它是**动作菜单**,不能就地揭示内容。要「点击看到全部」只有两条路:面板展开(原生)或自己发按钮 + **重发一张展开的卡**(坑 11)。 **English**: The user asked whether Feishu can fold PART of a card's content with a click-to-reveal, beyond the fold panel. The official docs remain unreachable from this machine, so the question was settled by real sends: five guessed property names on the markdown element (`lines`, `max_lines`, `collapsed`, `fold`, `tooltip`) are all refused whole-card with `230099 · 200621 unknown property`, while the control `bogus_xyz` is refused the same way and a KNOWN name with an illegal value (`text_size: 'gigantic'`) is accepted — so the validator checks property NAMES, which makes those refusals trustworthy negative evidence. Conclusion: `collapsible_panel` is the only folding mechanism and markdown has no "show N lines + expand"; but folding a PART of the content is supported — the panel simply wraps the chosen elements and its siblings stay visible (a real sample card with `markdown` + panel + `markdown` was sent). Folding granularity is one whole element (a block container cannot fold half a paragraph), and although an `overflow` element exists it is an action menu, not a content reveal. Click-to-see-everything therefore has two routes: expand the panel (native), or send a button whose action re-posts an expanded card (pitfall 11). ### 改进:过程卡的文件行只写裸文件名(`src/feishu-streaming.ts`,2026-10-12) 使用者:「读写文件的文件名显示就不显示上一级目录了,只显示裸文件名」。 **改法**:`progressToolDetails()` 里文件类工具(read / write / edit)的 `shortPath()`(末两段,`…/src/feishu-progress.ts`)换成 `bareFileName()`(**只留最后一段**,`feishu-progress.ts`)。两张卡的分工保持不变:**详细档的步骤卡**照旧带模型自己的描述与整条路径,**简洁档的过程行**是一行扫读用的索引 —— 目录不是「改了什么」,而它每行都在占用宽度。 **两条边界**:① Windows 反斜杠同样只取最后一段;② **空路径、或以分隔符结尾的路径不给名字**(那一行干脆不写),因为「写一个目录名」比「不写」更糟 —— 与坑 64「读不出的程序名直接丢掉」同一条口径。**已知且接受的代价**:不同目录下的同名文件在这张卡上读起来一样(`src/client/client.ts` 与 `src/client.ts` 都是 `client.ts`)——这一行是指向那次调用,不是文件在磁盘上的身份(写进了函数注释与 `docs/architecture.md`)。 **顺带取证**:用**真实渲染器**(`renderProgressCard`,且 detail 由**真实的 `progressToolDetails()`** 算出、不是手写名字)渲染「步头在框外 + 步内每行在框里 + 文件只给裸名」的合成效果,真发一张样板到飞书核对。 **验证**:`npm run typecheck` 0 error;**1079 tests / 56 files 全绿**(`tests/feishu-streaming.spec.ts` 那组改为钉住裸名、补 Windows 路径一例、补「不指向任何文件则这一行不写」一例;`tests/feishu-progress.spec.ts` 的行断言同步);`npm run build` 已产出。 **English**: The user asked for the file rows to stop showing the parent directory and carry the bare file name only. `progressToolDetails()` now reduces a read/write/edit path to its last segment (`bareFileName()`), replacing the two-segment form (`…/src/feishu-progress.ts`). The division of labour is unchanged: the DETAILED step card still shows the model's own description and the whole path, while this one-line compact row is an index for scanning — the directory is not what changed, and it spends width on every row. Two boundaries: a Windows backslash path is cut the same way, and a path that names no file (empty, or ending in a separator) contributes NO name at all, because printing a directory name would be worse than printing nothing — the same rule as "a program name we cannot read is dropped" (pitfall 64). The accepted cost is that same-named files in different directories read alike here (`src/client/client.ts` and `src/client.ts` both read `client.ts`), which is written into the function's comment and `docs/architecture.md`: this row points at a call, it does not identify a file on disk. A sample card of both changes combined — heading outside the quote, content inside it, bare file names — was rendered by the REAL renderer (with the details produced by the real `progressToolDetails()`) and sent to Feishu for a look. typecheck clean, **1079 tests across 56 files** green, artifacts rebuilt. ### 改进:回合过程卡——引用块框住「步内内容」,步骤标题留在块外(`src/feishu-progress.ts` / `tests/feishu-progress.spec.ts`,2026-10-12) 使用者先追问「工具调用的 quote 逻辑是什么,**为什么上下文注入的显示不会出现那个符号**」,随后自己给出规则:**竖线只框步骤内的内容,不要把步骤标题也框住**;再补一条判断标准:「已经有那个 emoji 图标了,再加个竖线不是多余吗,**要不全有 要么全没有**」。 **问题**:上一版只有**叙述行**带 `> `,于是竖线靠飞书的**惰性延续**顺带盖住它下面的工具行,而**它上面的注入行在框外** —— 同一张卡上三种待遇(框外 / 框内 / 看起来被框住),读起来就像「漏了一个符号」,而且竖线的含义(「模型说的话」)和它实际盖住的范围(整步)对不上。 **改法**:**注入行、叙述行、工具行统一带 `PROGRESS_QUOTE_MARKER`**,步头 `▸ **第 N 步** · 4.8s` 保持平铺 ⇒ 步头是这一节的标题,它下面的每一行都属于「这一步做了什么」的那一个引用块。**逐行显式加,不靠惰性延续**:叙述行行内标记不成对时会单独成一个元素(坑 60),延续会在那里断掉、把该步的工具行留在框外——同一张卡就会有的步骤框住、有的没框住(这条写进了常量注释)。 **先发样板再改**(关键坑 63 的做法):用**真实渲染器**(`renderProgressCard`,不是手写 JSON)渲染同一份 2 步内容,发到飞书共 **7 张**——A 现状 / B 叙述单独成元素 / C 注入也加线 / D 全无竖线 / E 连标题一起框 / F 框内容不框标题(逐行显式)/ F2 同上但只标第一行(靠延续)。使用者确认 **F 与 F2 看不出差别** ⇒ 取 **F**(显式,理由见上)。 **成本**:元素数不变(步头与内容仍在同一个元素里,元素内换行不付间距),样板卡 699 → 731 字节。 **验证**:`npm run typecheck` 0 error;**1077 tests / 56 files 全绿**(`tests/feishu-progress.spec.ts` 49 → 50:新增一条钉住「**只有步头不带 `> `,其余每一行都带**」,另把 3 处行断言改走新的 `row()` 助手,形状改动集中在一处);`npm run build` 已产出。 **English**: The user first asked what the tool rows' quote logic was and **why context-injection rows did not carry that symbol**, then stated the rule: the bar frames a step's CONTENT, never its heading — with the tie-breaker "the emoji is already there, so a bar on top of it is redundant: **either all rows or none**". The previous shape quoted only the narration, so the renderer's lazy continuation dragged the bar over the tool rows below it while the injection row ABOVE it sat outside — three different treatments on one card, reading like a missing marker, and a bar whose meaning ("the model speaking") no longer matched what it covered. Now every content row — injection notices, narration and tool rows — carries `PROGRESS_QUOTE_MARKER`, while the `▸ **Step N**` heading stays plain: the heading titles the section and everything under it is what the step did. The marker is emitted **per line instead of leaning on lazy continuation**, because a narration with unbalanced inline markup keeps an element of its own (pitfall 60) and the continuation would break exactly there, leaving that step's tool rows unframed — some steps framed, others not. Seven sample cards rendered by the REAL renderer (`renderProgressCard`) went to Feishu before any code changed (A current / B narration alone / C injections quoted too / D no bar / E heading quoted too / F content framed with the heading outside / F2 same but relying on continuation); the user confirmed F and F2 look identical, so F (explicit) was taken. Element count is unchanged (heading and content still share one element, so in-element newlines cost nothing) and the sample card went from 699 to 731 bytes. typecheck clean, **1077 tests across 55 files** green (one new case pins "only the heading is unquoted, every other row is quoted"), artifacts rebuilt. ### 改进:压缩摘要从「200 字」提到「单卡能装的上限」(`src/feishu-compaction.ts` / `src/i18n.ts`,2026-10-12) 使用者要求「增多压缩摘要可显示的字数」。**先量真实数据再定数**:65 个会话日志里 **182 条真实压缩摘要** —— 最短 **1,338**、中位 **11,303**、p90 15,478、最长 21,127 字 ⇒ 原来的 200 字上限**只显示了不到 2%**。 **量着量着发现单位就错了**:卡片真正的约束是 **JSON 体积**(~30KB),而**中文在 JSON 里 3 字节/字**。用真实卡片外壳实测: | 摘要 | 卡片 JSON | 占 30KB | |---|---|---| | 200 字(旧上限) | 1.0KB | 3% | | 5,000 字 | 13.3KB | 43% | | 8,000 字 | 21.0KB | 68% | | 11,303 字(真实中位) | 29.4KB | 96% 贴边 | | 15,478 字(p90) | 40.1KB | **130% ⇒ 整卡会被拒收** | | 21,127 字(最长) | 54.5KB | 177% | | 英文 20,000 字 | 20.5KB | 67% ✅ | **改法**:上限从「200 个字符」改成**字节预算** `COMPACTION_SUMMARY_BYTES = 22_000`(`fitBytes()` 按 UTF-8 逐码点累计,不会把一个码点切成半个)⇒ **≈7,300 中文 / ≈22,000 英文**,是单卡能装的最多内容(**比原来多 36 倍**),并留出其余卡片内容与 JSON 转义的余量。截断仍走 `truncateInline`(切口落在 `**` 里会留字面星号,坑 60)。**「按字节切会不会乱码」使用者追问后实测**(把混合文本——中日韩 + 星光面 emoji + ZWJ 家族 + 组合音标——**每一个字节预算**都扫一遍,53 档):**孤立代理项 0、UTF-8 往返失败 0、超预算 0** —— 因为切点按**码点**走(`for…of` + `codePointAt`),不可能切进多字节序列或代理对(朴素的 `slice(0, 字节数)` 才会乱码)。但发现**字形簇**会被切开(53 档里 16 档):`👨‍👩‍👧` 这种 ZWJ 序列会只剩 `👨`。⇒ 切点再**回退到最近的字形簇边界**(`Intl.Segmenter`,无 ICU 时退回码点切,仍是合法文本);两条都加了永久测试。**装不下时卡片自己说清楚**:新增 i18n 键在摘要下面补一行「⚠️ 单张卡片装不下整份摘要:上面是前约 N 字,全文 M 字」,不再只留一个省略号。 **没做的**:把完整摘要(中位 11.3K、最长 21K 字)送进飞书 —— **一张卡物理上装不下**,要完整只能多条续卡或文件消息;设计文档 §2.4 当初明确否掉了「另发文件」的按钮,本轮不动,已在 `docs/transcript-parity-design.md` 记下这条路仍空着。 **验证**:`npm run typecheck` 0 error;**1076 tests / 56 files 全绿**(新增 6 例:5,000 字摘要**完整**带上卡、最长真实摘要(21,127 字)下**卡片 JSON 仍 <30KB**、英文按字节装得更多、**每个字节预算都不乱码**(扫 53 档)、**不切进字形簇**、无 ICU 时退回码点切仍合法);`npm run build` 已产出。**未重启**(要生效需重启)。 ### 修复:`bash` 行里的「程序名」认错——引号内的 `\|`、heredoc 正文、循环关键字都被当成程序(O27 补丁,`src/shell-programs.ts`(新)/ `src/feishu-streaming.ts`,2026-10-12) 使用者看着真机卡片提出「bash 命令获取不准确」。**没有猜,直接把真实输入捞出来复现**:从会话日志里取出最近 **12 条 `bash` 调用**的原始命令,逐条跑当时的提取函数 —— **6 条是错的**: | 真实命令(节选) | 当时提取出 | 为什么错 | |---|---|---| | `grep -n "first.frame\|first_frame" scripts/x.py \| head -6` | `echo / grep / **first_frame"**` | 引号内的 `\|` 是 grep 的「或」,被当成了管道(带引号的名字就是这么漏出来的) | | `cat > out/prompt.txt <<'EOF' … EOF` | `cat / **For** / **integrated_multimodal_description:**` | **heredoc 正文**(提示词散文)被逐行当成命令 | | `python3 -c "…\nimport sys…"` | `echo / python3 / **import**` | 多行引号里的 Python 被当成命令 | | `for i in $(seq 1 90); do … done` | `run_h3.sh / **for** / **do**` | shell 关键字 | | 含 `# 注释` / `⚠️` / `2026-10-12` 的命令 | `python3 / **#** / **Qwen-Image-2.1(2026-10-12` | 注释、emoji、日期 | **根因**:当时的实现是「按 `\| ; && 换行` 硬切 + 取第一个词」——不认引号、不认注释、不认 heredoc、不认关键字。 **改法**:**不再切分,改成解析**(新模块 `src/shell-programs.ts`):引号/注释/heredoc 正文整体跳过、分隔符只在它们之外生效、`\` 续行留在同一段(否则下一行的 `--flag` 会被当成命令)、候选词必须**长得像个程序**(裸名字)才被采纳,`do`/`then`/`{` 这类**前缀关键字跳过它自己**(`do curl …` 要认的是 `curl`),`for`/`done`/`$(…)`/`cd` 这类**整段跳过**。读不出来的词直接丢掉——**写错一个程序名比不写更糟**(这条写进了模块注释)。 **验证**:12 条真实命令**全部给出合理结果**(`python3` / `git / echo / grep` / `echo / grep / head` / `cat / python3` / `run_h3.sh / curl / echo` …),并把这 6 条**逐字**写进 `tests/shell-programs.spec.ts` 当回归(16 例,含 `splitShellSegments` 的引号/注释/heredoc/续行四条);`npm run typecheck` 0 error、**1070 tests / 56 files 全绿**、`npm run build` 已产出。**按要求未重启**。 ### 改进:compact 回合过程卡——一步一个元素,工具行标出「碰了什么」(O27,`src/feishu-progress.ts` / `src/feishu-streaming.ts`,2026-10-11) 使用者反馈「compact 的步骤卡片不够 compact」。**先用真实渲染器量了一遍**(10 步 / 42 次调用 / 3 失败):卡片只有 **1578 字节**(上限 30KB ⇒ 字节完全不是瓶颈),却渲染成 **17 个元素、16 个都是单行**,可见文本仅 **327 字** —— 平均每个元素装 20 字。每个元素都是一个独立块、两侧各付一次 `vertical_spacing` 4px(坑 58),于是 **64px 纯间距**花在 327 字上。构成是:**5 行纯步标题** + 8 行工具行 + 2 行叙述 + 1 行注入。 **做了一条(②)**:**一步的行装进同一个 markdown 元素**。元素内换行是普通换行,只有**步与步之间**付间距 ⇒ 同一张卡 **17 元素 → 6-10 元素**(取决于该步有没有叙述行),步骤边界照样看得出来。**没做**的三条(行数/注入聚合/笔记截断/回合结束收起)使用者明确说不改:行数没减,卡的"高度"主要来自行数,这一点在建议里已如实说明。 **叙述行有一条安全边界**:它是模型文本,**只有行内标记成对**(`**`/反引号计数为偶)才并入本步元素;奇数时单独占一个元素 —— 多行同元素会让未闭合的 `**` 与下一行配对、渲染成错的加粗(坑 60)。这是"多塞一行"与"渲染出错"之间唯一诚实的取舍。 **顺带**(使用者要求)**给工具行标出它碰了什么**:读/写/编辑给文件名(末两段路径,`…/src/feishu-progress.ts`),`bash`/`pwsh` 给**程序名**(按 `| && ; 换行` 切开、丢掉 `sudo`/`time`/`env`/`cd` 这类包装及其参数、取 basename、去重),其余工具不给(名字已经说明一切)。派生发生在流式层(`progressToolDetails()`,参数只在那里),卡片层只负责渲染。**合并行的名字跨调用累计、上限 3 个** —— 第一版把上限加在"调用数"上,一张样板卡当场印出 5 个(`npm / tail / git / node / tail`),已改为按名字累计(`bash ×3 · npm / tail / git`,测试钉住)。**`$(…)` 这类命令替换不解析**:猜一个名字比留空更糟,测试里写明这条口径。 **验证**:`npm run typecheck` 0 error;**1055 tests / 55 files 全绿**(本批新增 20 例:`tests/feishu-progress.spec.ts` 钉住"一步一元素 / 步边界仍分开 / 叙述行成对才并入 / 不成对单独成元素 / 行数不变 / 上限 3 个名字",`tests/feishu-streaming.spec.ts` 钉住文件名与程序名派生、包装与 `cd` 参数不被误认、basename 与去重、替换不解析);`npm run build` 已产出。**另发了两版真机样板卡到飞书**(用真实渲染器渲染后直接发送,第一版暴露了上面那个 5 名字的 bug,修完重发)。**按要求未重启 dsh**:线上仍是旧构建。 **同日跟进(使用者看完样板后的第二条反馈)**:「一个步骤内的工具调用挤在一起,步骤不是很明显」,并且**可见窗口要按步骤数量算、显示最近 3 个**。两处已改: - **步骤标题的中括号试过又撤了**:`**【第 3 步】**`(en `**[Step 3]**`)实测被使用者否掉——「不要中括号 看起来很丑」,改回 `**第 3 步** · 1.2s`。**这条记下来**:让标题更显眼不等于更好看,标题的样式最终由人眼定,所以先发样板再改代码。 - **`PROGRESS_MAX_STEPS` 5 → 3**:窗口**按步骤计数**(不是按行数)—— 最新一步是"在干什么"、前两步是"怎么走到这的",更早的整步进折叠面板。`PROGRESS_MAX_ROWS = 40` 仍留作外层兜底(单步可能有几十个调用)。 - **行的样式已按使用者选定落地**:**步骤行加图标**(`▸ **第 N 步** · 1.2s`,en `▸ **Step N**`;先发 `🔹` / `▸` / `📍` 三版样板,使用者选了小三角)、**工具行不带列表符**(先按样板选了 `- ` 无序列表,隔天使用者看过带分隔线的效果后取消:「感觉有了分割线以后效果不错,取消无序列表」—— 有了 `hr` 之后列表符只是多余的标点)、**步骤中间夹的 reply(叙述行)用 quote**(`> 💬 改完两处…`)、**步骤之间加分隔线**。分隔线用的是**原生 `hr` 元素**(不是 `---` 文本:卡片 markdown 不保证把它画成线,而画一排制表符只是假线、还照样占一行);规则只落在**步与步之间**——第一组之前不加、被叙述行拆成两个元素的同一步内部也不加(有测试钉住这三条)。标记集中在 `PROGRESS_TOOL_MARKER` / `PROGRESS_QUOTE_MARKER` 两个常量,图标在 i18n 的 `turnCardStep` 模板里。 **为什么坚持先发样板**:`- ` 会不会被飞书画成真圆点、`>` 会不会画成引用条、行首空格会不会被 markdown 吃掉 —— 这些**只有人眼能判**(关键坑 54 的同一条道理:卡片语法真伪只能靠真发一次)。中括号那版就是被这样否掉的。 ### 修复:连接状态指示在出站全断时仍显示「已连接」(O26,`src/outbound-health.ts`(新)/ `src/runtime.ts` / `src/index.ts` / `src/client/LarkPluginDetail.tsx` / `src/client/LarkChannelStatusController.ts` / `src/client/index.ts`,2026-10-11) **事故**(2026-10-11 16:48:29 → 16:56:50):**79 行 `write EPROTO … tlsv1 unrecognized name (SSL alert 112)`**,**每一个新建的出站 TLS 握手都被拒** —— 回复、卡片创建与更新、`feishu_send_file`、reaction 全部发不出去;而那 8 分半里**插件详情页的徽章一直是「已连接」**。真正发生的是**两个方向命运不同**:已建立的长连接(入站)活着(16:49/16:50/16:51 的 `[msg]` 照收),新建的出站握手全挂。当时观测到 `open.feishu.cn` 走网关 fake-IP,故障排查指向新建出站的网关/代理路径;**fake-IP 本身不能证明 DNS 被劫持,也未取得实际命中规则与出站日志来确认精确根因**。插件无法修复外部路由,但能改的是**不要在出站无响应时仍报告正常**(2026-10-13 文档整理收窄了原记录过强的归因)。 **问题**:`LarkRuntime` 的 `connected` 是**建连那一刻的快照** —— `start()` 返回即置位,之后永不复查(`src/runtime.ts` 的 `snapshot`),UI 只读这一个字段。于是「WebSocket 连上了」被当成「链路健康」,而链路真正能不能发东西,这个快照从来不知道。 **改法:两个方向都按事实报,不按建连结果报。** 1. **出站 = 请求自己的历史(`src/outbound-health.ts`(新))**:SDK 的每一个 HTTP 请求都走 `Client`/`WSClient` 的 `httpInstance`(实测只调用 `.request`,`lib/index.js:100718` 以 `params.httpInstance || defaultHttpInstance` 兜底)⇒ 把 SDK 自己的 `defaultHttpInstance` 包一层 **Proxy**(不是逐方法转发:接口以后新增方法也自动被观测;非函数属性原样透传;调用一律以**原实例**作 receiver,所以 axios 内部 `get → this.request` 的自调用不会重复计数)后经 `createLarkChannel({ httpInstance })` 交给通道。**判定只有一条**:rejection 里**有 `response`** = 对端**答了**(400/429/500 都算答了,链路是好的);**没有 `response`** = 链路失败;`ERR_CANCELED`(我们自己取消的)**两者都不算**。`verdict` 从**第一次无人应答**起就是 `failing`,直到下一次收到应答才回到 `ok`(失败计数作为历史保留)。 2. **入站 = 长连接的真实生命周期**:`runtime.status()` 每次都读 `channel.getConnectionStatus()`,随状态一起报 `inbound: { state, reconnectAttempts, … }`(可选:通道没这个方法就只报建连结论,绝不因此让 `/status` 挂掉)。 3. **UI 不再二选一**:`connected` 之上若出站 `failing` → 徽章「**已连接 · 出站异常**」+ 红点 + 明细行(连续失败次数 + 对端原话);长连接 `reconnecting`/`failed`/`idle` → 「**已连接 · 长连接断开**」+ 重连次数。**只有 `connected` 会被二次判读**(`connecting`/`error` 本来就在描述自己,旧失败不该改它们的画)。另:**`observed === 0` 时不再暗示健康**,明细行写「尚未观测到出站请求」——「没测过」和「测过是好的」必须区分。 4. **指示本身必须是活的**:这条路由原来**每个页面只读一次**(进入详情页那一刻),所以「页面已经开着、之后才断」照样看不出来 —— 正是这次事故的形态。现在只要有条目挂在页上就持续复读:配置流程在跑时 2s、其余 **10s**(本机 loopback 的 JSON GET,页面关掉即停)。顺带把「一次配置跑完回调一次」的保证从「读完就停表」改成**观察到 active → terminal 那次跳变**,否则慢速轮询会把「重读凭据徽章」重复触发。 **验证**:`npm run typecheck` 0 error;**1024 tests / 55 files 全绿**(本批新增 32 例:`tests/outbound-health.spec.ts` 18 例钉住分类口径/计数/复发与恢复/Proxy 透传与「一次调用只记一次」,`tests/runtime.spec.ts` 5 例钉住入站随取随读、可选字段与「通道没有 getter 也不崩」,`tests/client.spec.ts` 9 例钉住两种异常徽章、明细行、健康时不多嘴、未观测提示、脏状态不改 `connecting`,以及轮询节拍/页面关掉即停/配置完成只回调一次);`npm run build` 产出 `lib/index.js` + `client/client.js`。**另跑了一次一次性真机探针**(真 SDK + 真凭据,跑完即删):真实 `chat.get` 走通且 `observed` 累加到 2(token 请求也在这条线上),把域名指到一个没人监听的本地端口后 `verdict` 立刻变 `failing`、`lastError = connect ECONNREFUSED`,再答一次即回到 `ok` —— 证明插件确实截到了 SDK 的真实流量,而不是只在单测里自洽。**生效需要重启 dsh**。 ### 改进:出站「静默挂起」也看得出来——放弃等待时上报(O26 补完,`src/outbound-timeout.ts` / `src/outbound-health.ts` / `src/index.ts` / 5 个发送模块,2026-10-11) 接上一节留下的空洞:**永不 settle 的请求**。半开连接(对端已经不在了、却没有 FIN/RST)会让一个 HTTP 请求**既不成功也不失败**,而 `outbound-health.ts` 只看「有结局」的请求 ⇒ 表上什么都不留、徽章照样绿,用户那边**一条消息都收不到**。这正是坑 29 实测过的形态(2026-09-21,SDK 的 HTTP client 是 `timeout: 0`)。 **为什么不在健康表里加看门狗**:那必须在模块内拍一个「多久没回应算故障」的阈值 —— 定 20s,30MB 文件上传、带媒体的卡片写入**正常也会超过**;定 120s,更短的挂起漏掉、更慢的上传照样误报。那只是把「谎报正常」换成「谎报故障」。 **改成在已经知道自己在等多久的地方上报**: - **`src/outbound-timeout.ts`**:新增 `withLinkTimeout`(= `link` 版 `withTimeout`)与 `observeOutboundWaits` 观察者。**预算由调用点自己给**(普通发送 20s、发文件 120s、卡内媒体按整卡预算),所以「本来就是慢」的大文件不会被误判。上报是 **best-effort**:观察者抛错也必须让等待结束 —— 写这条时被自己的测试抓到过一次,原实现里 observer 抛错会让 timer 回调中断、promise **永远不 settle**,正是本模块要防的事。 - **哪些等待算链路事实**:全部 **18 个真的发飞书请求**的调用点改成 `withLinkTimeout`(`index.ts` 8、`feishu-send-file.ts` 3、`media-upload.ts` 3、`card-images.ts` 3、`channel.ts` 1)。**等在自家管线上的(卡片链 flush、上一张卡 settle)不算**,仍走普通 `withTimeout`/`settleWithin` —— 它们超时说明我们自己的链路卡了,不是飞书。这条边界有测试钉住(发送模块里不许再出现普通 `withTimeout(`,`card-send-barrier.ts` / `feishu-streaming.ts` 里不许出现 `withLinkTimeout(`)。 - **`src/outbound-health.ts`**:新增 `noteAbandoned(what, ms)`,写入 `no answer within <预算>ms: <调用点>` 并计入 `failing`;**超时不是取消**(请求可能稍后才回来),所以任何一次收到应答都会像别的失败一样把它清掉。 - **`src/index.ts`**:安装观察者,把 `noteAbandoned` 接到同一个 `OutboundHealth`。 **效果**:网络断得干净时看 reject(上一节),断得不干净时看这里 —— 20 秒(或该调用点自己的预算)之后徽章就变红、明细行写明「按 20000ms 预算放弃了等待 send message」,而不是让你对着一个绿点猜为什么没人回话。 **验证**:`npm run typecheck` 0 error;**1035 tests / 55 files 全绿**(本批新增 11 例:`tests/outbound-safety.spec.ts` 9 例钉住上报内容与预算、及时应答不上报、自家管线不上报、摘掉观察者后不再上报、观察者抛错仍按原消息 reject,以及「哪些等待算链路等待」的三条源码边界;`tests/outbound-health.spec.ts` 2 例钉住 `noteAbandoned` 的计数与迟到应答可清除);`npm run build` 已产出(**按要求未重启 dsh**)。 ### 新增:本轮「产出物」——一行计数 + 只投产出物形状的那一份(O3 结案,`src/produced-files.ts`(新)/ `src/feishu-streaming.ts` / `src/channel.ts` / `src/config.ts` / `src/i18n.ts` / `src/client/`,2026-10-11) 承接 O3(「飞书这边没有 produced 产物行」)。**先决定不做什么**:WebUI 的 produced 是**成功 `write`/`edit` 改过的文件**,而飞书是聊天不是工作区浏览器 —— 本会话实测 **69 个 produced 路径 vs 3 个 presented**,逐条看全是 `src/`、`tests/`、`docs/` 与 `/tmp` 脚本,**没有一个是用户要拿走的东西**;每轮贴 4–6 行路径只会把答案挤出屏幕。所以**不做 produced 全表**(也因此没去接 `workspace/changes` / `workspaceChanges.summary()` 那条 git 口径的主机服务:全表都不做,它就没有必须接的理由)。 **做的是两件**: 1. **Turn Complete 卡加一行计数**:`📦 本回合新生成 N 个文件`(notation 档、**不列路径、不带按钮**),只在真有产出物时出现。 2. **只投「产出物形状」的那一份**:媒体(图/视频/音频)与 pdf/office 文档,**复用既有 `present` 投递管线** —— 媒体卡内内联、文档 ≤ `deliverDocMaxMb` 自动发原生文件消息、其余给可点行。源码、测试、临时脚本**永不投递**(`producedArtifacts()` 按 `deliver-files.ts` 的分类过滤)。这条正是几次真机场景:模型生成了图/报告却忘了 `present`,以前聊天里毫无迹象。 **实现**: - **`src/produced-files.ts`(新,纯函数)**:`mutationPathOf()` 按 WebUI `ui-deliverables/src/client/turn-deliverables.ts` 的同一套规则从工具参数取路径(`write` 需 string `content`;`edit` 需非空且真的不同的 `old_string`/`new_string`;`str_replace_editor` 只认 `create`/`str_replace`/`insert`);`settleProducedCall()` 与**成功**的 `tool/result` 配对(失败的结果不产出任何东西);`producedArtifacts()` 只留媒体与文档;`mergeProducedArtifacts()` 按**解析后路径**去重(模型写 `out.png`、`present` 写 `/abs/ws/out.png` 时不能投两次)后并入 `present` 的清单。 - **采集(`feishu-streaming.ts`)**:`tool/call` 时把「这个调用会写哪个文件」记在 `StepToolCall.produced`(**参数只在 `tool/call` 上**),`tool/result` 时按 `isError` 结算进 `turnStats.produced`(逐回合清空);另有**会话级** `everPresentedPaths` —— `present` 声明过的路径记一辈子,避免「更早的轮次 `present` 交付过、这一轮又写了一次」被再投一遍。 - **合并与显示(`channel.ts`)**:只有 `produceCard === 'delivery'` 才把这些产出物并进 `deliver.plan()` 的 `files`(于是走 `stat`/`realpath`/大小/白名单那一整套既有校验);计数行由 `renderFooterCard(..., producedCount)` 渲染,`off` 时调用方传 `undefined`。 - **开关(`produceCard`,默认 `delivery`)**:`off` 完全静默(= 以前的行为)/ `summary` 只报个数、不投任何文件 / `delivery` 两者都做。WebUI 设置页「卡片显示」里的「本轮产出物」也是它,`.volatile()` 热生效。默认选 `delivery` 是因为缺口是实测的(21 个回合写过文件,只有 1 个回合调过 `present`);不同意就选 `off`。 **验证**:`npm run typecheck` 0 error;**993 tests / 54 files 全绿**(新增 27 例:`tests/produced-files.spec.ts` 18 例钉住参数规则/成败配对/分类过滤/去重口径,`feishu-streaming.spec.ts` 4 例钉住采集与逐回合清空,`channel-footer.spec.ts` 2 例钉住计数行**只报数不列路径**与 `off`/0 的静默,`plugin.spec.ts` 2 例钉住 `delivery` 真的把产出物并进 plan、`summary` 一个都不投,`config.spec.ts` 1 例钉住默认与未知值回退);`npm run build` 产出 `lib/index.js` + `client/client.js`。**生效需要重启 dsh**(本次按要求未重启)。 ### 修复:提问卡的「确认提交」点完卡片原样不动,看不出是否已确认(`src/feishu-questions.ts` / `src/i18n.ts`,2026-10-11) 操作者反馈:提问卡的最后一张「📝 确认全部回答」卡上点「✅ 确认提交」后**卡片样式没有任何变化**,因此无法判断答案是否已经提交给模型。 **取证(只查、未重启)**: - 事件日志(2026-10-11 09:08,`ask_user_question` 单题批次):09:08:21 发问题卡 `om_…ef7ad` → 09:08:42 `answer-open`(点选项行)→ 09:08:48 `[card] type=batch-confirm` → 09:08:48 `[q] batch card tap type=batch-confirm` → 09:08:48 起**再无任何卡片写入**(无 patch、无 `230020`、无拒收警告)。 - 只读读回飞书上的两张卡(`im.v1.message.get` + `card_msg_content_type=user_card_content`):问题卡已是 `turquoise` 表头、选中项 `✅ … — *已选择*`、无按钮(说明**选项行那一步确实重绘过**);确认卡仍是 `blue` 表头、两行答案 + `✅ 确认提交` / `↩️ 返回上一题` 两个按钮**原样**,其最后写入时间就是它**发出**的时刻。 - 提交本身是成功的:该会话日志 `turn 417 step 1` 的 `tool/result` = `{"answers":[{"id":"which_two","selected":["三个都跑(5 个模型)"]}]}`。 - 代码侧对得上:`batch-confirm` 分支原来只 `dispatch({kind:'confirm'})` 就放行回合,`askBatchConfirm` 里提交后也没有任何重绘 ⇒ **不是写入失败,是提交后从来没写入过**。单题批次的回执卡按设计被跳过(最后一个答案由最终卡记录),所以用户连 `✅ 已提交` 那张回执也看不到,观感被放大。 **改了什么**:`batch-confirm` 被认下的那次点击,在把答案交给模型**之前**把确认卡就地重绘为结算态——表头 `blue 📝 确认全部回答` → `green ✅ 已提交`,保留逐题答案行,底部提示换成「这组回答已经交给模型,这张卡片不再接受修改。要改请在下面直接回复。」,**去掉两个按钮**。写入走 `updateCardQuiet`(失败只记 warn),提交本身绝不依赖卡片写入(坑 29);重绘只在**真正被认下**的点击上发生,歧义卡(`fromCard` 忽略掉的陈旧点击)不会留下假回执。行渲染抽成 `batchAnswerRows()`,确认卡与结算卡共用。 **验证**:`npm run typecheck` 0 error;**966 tests / 53 files 全绿**(`tests/feishu-questions.spec.ts` 23 → 24 例,新增「确认后确认卡被重绘成绿色回执、答案仍在、按钮为 0」;测试替身顺手补上**真实 message id**:此前 `commitBatch` 在确认卡还没发出时就点「最后一张卡」,走的是 `evt.messageId === undefined` 兜底路径,根本测不到重绘);`npm run build` 产出 `lib/index.js`。**生效需要重启 dsh**(本次按操作者要求**未重启**)。 ### 修复:标点被包进加粗(`**加粗。**后面`)时飞书按字面渲染星号(`src/card-emphasis.ts`(新)/ `src/card-markdown.ts` / `tests/card-emphasis.spec.ts`(新),2026-10-10) 承接上一条排查时发现的第二类成因。真机探针(两张编号卡:A 每行一个 `markdown` 元素,B 全部行放一个元素 + 一个表格单元格)逐条确认: | 源文本 | 渲染 | |---|---| | `**加粗。**后面` / `**加粗:**后面` / `**带括号(说明)**后面` / `**加粗…**后面` | **字面 `**`**(编号 2 / 4 / 9 / 11,表格单元格同) | | `**加粗**。后面` / `**加粗**:后面` / `**加粗**,后面` | 粗体正常(3 / 5 / 6) | | `**加粗** 前后有空格` / `前缀**加粗**后缀` / `**整行只有加粗**` / `**两处**和**第二处**` / 行内代码混排 | 粗体正常(1 / 7 / 8 / 10 / 12 / 14) | 即:**关闭 `**` 夹在"末尾标点"与"紧跟的字"之间时不是合法的关闭分隔符**,飞书照字面输出;把标点移到加粗外面即可(可见文字完全不变)。这正是 CommonMark 的分隔符规则,第三方实现([loopx PR #5352](https://github.com/loopx-project/loopx/pull/5352))独立得出了同一条结论。 - 新增 `src/card-emphasis.ts` 的 `normalizeCardEmphasis(text)`:只处理成对的 `**`(`***` 长串不碰),按规则把加粗内部的末尾标点移到外面;围栏代码块、行内代码 span、转义字符、链接目标一律不透明,未闭合的 marker 不动(那是上一条截断问题,不是这里的事);**幂等**。 - 接入 `neutralizeCardMarkdown` 的**最后一道**:判的是飞书真正收到的 markdown(包括前面回退逻辑刚插入的代码 span),于是每张出站卡片自动经过。 - **全量实测**:9019 条真实助手文本中 **67 条(0.74%)、87 行**被改写;**可见文字 0 变化**(只移动 `**` 的位置);对全部真实文本幂等。 - 测试:`tests/card-emphasis.spec.ts` 17 例(探针 14 条逐条钉住、不透明性、幂等、与 `neutralizeCardMarkdown` 的集成)。 - **上线与确认**:两条修复(本条 + 上一条截断)于 **2026-10-10 12:39:54** 插件重载后同时生效(重载后仍正常收发卡片、无 error),使用者当日确认「没问题了」。 ### 修复:卡片里的加粗被固定字数硬切,星号按字面渲染(`src/text-chunk.ts` / `src/feishu-progress.ts` / `src/feishu-streaming.ts` / `src/feishu-compaction.ts` / `src/channel.ts` / `src/feishu-deliver.ts` / `src/error-text.ts`,2026-10-10) 使用者反馈「卡片消息加粗符号近期经常渲染失败」。逐条排查后确认,问题在**我们自己把文本切短的方式**,不在飞书: - **根因**:所有一行式预览都用 `slice(0, N) + '…'` 硬切。当切口落在 `**…**` 的中间,卡上就剩下一个**未闭合的 `**`**,飞书按字面渲染出星号;如果切口落在行内代码 span 里,同样会留下一个孤零零的反引号。全量取证:8998 条助手文本里,简洁档解说行(上限 80 字)有 **218 条(3.1%)** 会被切出悬空 `**`,近三天为 6/63、2/272、5/281——与「近期经常」吻合(简洁档的解说行是 2026-10-09 那一批改动引入的)。 - **修法**:新增 `truncateInline(text, maxLen)`(`src/text-chunk.ts`):切之前先定位切口处仍未闭合的 `**` 或行内代码 span,把该 opener **丢掉**再补省略号。刻意**不自动补闭合**——补了会把模型没写完的句子整段加粗;丢掉 marker 只是少一处强调,读者看到的文字不变。转义字符(`\*`)与三连星(`***`)不参与判定,反引号只有"与开 span 等长"才算闭合。 - **接入点**:简洁档解说行 `condenseStepNote`(80)、推理预览(80)、压缩摘要(200)、回合交付物说明(60)、交付清单行说明(60)、错误原因(1000)。推理全文与摘要正文渲染在代码围栏里,不受影响。 - **测试**:`tests/truncation.spec.ts` 新增 8 例(含"切口正好落在 `**` 两个星号之间"、"闭合的粗体对保持不动"、"未闭合行内代码被丢掉"),`tests/feishu-progress.spec.ts` 新增 1 例;**948 tests / 52 files** 全绿,typecheck 0 error,`npm run build` 通过(`client/client.js` 未变动)。**上线**:与下一条(强调边界规范化)同批于 2026-10-10 12:39:54 插件重载后生效。 - **同批发现的第二条**(真机探针确认,修法见下一条):第三方实现 [loopx PR #5352](https://github.com/loopx-project/loopx/pull/5352) 报告的「关闭 `**` 夹在标点与后字之间时 Lark 不认」确实作用于**卡片**元素——已发两张编号探针卡实测确认。 ### 新增:简洁档把整轮的工具活动合并到一张卡片(O22 第二轮,`src/feishu-progress.ts`(新)/ `src/display-level.ts` / `src/feishu-streaming.ts` / `src/i18n.ts`,2026-10-09) O22 落地时留了一条「仍待拍板」:**`compact` 档要不要真的合并卡片**。当时只把步骤卡缩成状态行(`🔧 N 个工具调用 · M 个失败`),理由是要合并就得新造一套跨 step 的卡片生命周期。本轮按使用者的要求补上,形态先拍板(卡片 vs 普通消息 → **卡片**)。 - **一张回合级过程卡**:从 `turn/start` 起算,**首个工具调用时懒创建**(没有工具调用的回合根本没有这张卡),之后整轮就地更新,`turn/end` 定格。标题 `⚙️ 本回合进行中 · N 个工具 · 12.3s`(运行中灰头 → 正常结束绿头 → 回合失败红头;失败原因仍由那张失败卡承载,不重复,坑 7/40);正文按 **step 小节**(`**第 2 步** · 3.4s`,运行中带 `…`)+ **每行一个工具**(`✅ \`read\` · 0.4s` / `⏳ \`bash\` · 1.2s…` / `❌ \`edit\` · 50ms`)。**只显示工具名与耗时**,没有参数、结果与推理——那正是 `compact` 的定义。 - **连续同名调用合并成一行**(`✅ \`read\` ×2 · 1.2s`,耗时累加;运行中的按首个开始时刻走表)。**超过 40 行按整步折叠**进 `collapsible_panel`(复用 `card-fold.ts` 那套「看得出来已折叠」的外观),面板正文另有 60 行上限——折叠只少占屏,**JSON 仍然随卡发出去**,所以两处都必须有界(坑 42 的 30KB 整卡拒收)。 - **写入纪律**(坑 45 的 `230020` 真机链条):单卡同时只允许**一次写入在飞**、只写**最新一版**(被顶掉的渲染直接丢弃、不重放),并加 900ms 合并窗口;CardKit 实例优先(无编辑上限),退化走 `im.v1.message.patch`;整张卡彻底发不出去时,**回合结束时补一条纯文本汇总**(文本而不是第二张卡,坑 40)。 - **三条寿命红线,都来自实测**:① 卡片实体**只能发送一次、有效期 14 天**(`cardkit.card.create` 接口说明,见 `docs/feishu-capabilities.md` §七)⇒ **每回合新建实体、不跨回合复用、不落盘**,重启后这张卡停在最后一次写入的形态(沿用既有「陈旧卡」文本兜底,不做跨重启恢复);② 带按钮的卡就地更新 2–3 次后**停止投递回调**(坑 11),`interactive_container` 行被点过一次后该卡不再投递行回调(坑 55)⇒ 这张卡**永远没有按钮、没有可点行**(有测试钉住);③ 本插件**未启用** `streaming_mode` ⇒ 不受「流式卡片 10 分钟自动关闭」约束,长回合不会被强制关闭。 - **档位门控**:新增策略轴 `turnCard`,**只有 `compact` 为真**(`showToolCalls=false` 时同样关闭——要求完全不显示工具的人不该收到一张列出每个工具的卡);`standard` / `detailed` / `verbose` 行为零变化。**步骤卡与过程卡的分工(2026-10-09 真机复核后按使用者拍板定稿)**:`compact` 的步骤卡**只承载答案正文**——`tools` 降为 `off`,工具段整段不渲染,**只有工具调用的步骤因此不再发卡**(它的活动全在那张回合过程卡上),`step/start` 的占位卡也只在**该步带着注入提示行**时才发(注入行只渲染在步骤卡上,丢了就真丢了,O21①;`step/end` 的终态 `done` 形态同样保留注入行)。⇒ 一轮的实际形态是「`⚙️ 本回合…`(工具时间线)+ 有正文的那几张卡」,而不是「每步一张计数卡 + 一张过程卡」。答案正文**不并进**过程卡(30KB 上限与溢出续卡会失控)。 - **明确没做**:卡上不带参数/结果(`verbose` 才显示);不做跨回合/跨天复用的常驻卡(实体 14 天且只能发送一次);不把答案正文合并进来。 **验证**:`npm run typecheck` 0 error;**908 tests / 51 files 全绿**(新增 25 例:`tests/feishu-progress.spec.ts` 18 例——耗时格式化、运行与终态头色、同名合并、运行/失败行、整步折叠与面板上限、无按钮无行、懒开卡、每回合新实体、无工具回合零卡片、突发合并成一次写入、一次在飞且渲染最新态、终态绿色且 `turn/end` 后不再动作、发卡失败只补一条文本、话题坐标、`stop()`;`tests/feishu-streaming.spec.ts` 新增 5 例接线——简洁档只有工具的一步**一张卡都不发**、正文卡不含任何工具痕迹、简洁档由**正文**开卡(占位不再无条件发)、详细档不发过程卡、`showToolCalls=false` 不发、无工具回合不发、简洁档的正文/隐藏推理渲染;`tests/display-level.spec.ts` 新增 2 例——档位映射与「简洁用过程卡取代步骤卡的工具段」);`npm run build`(`lib/index.js` 656,855 → 672,376 B;`client/client.js` 未变,本批纯宿主侧);**生效需要重启 dsh**。 **English**: The one open question O22 left behind — should `compact` really merge cards — is now implemented, with the shape picked first (card vs plain message → **card**). A **turn-level progress card** opens lazily on the first tool call (a turn that calls no tool never sees it), is updated in place for the whole turn and freezes at `turn/end`: grey running header → green on success / red on failure (the failure facts still ride the dedicated failure card), a section per step (`**Step 2** · 3.4s`, `…` while running) and one line per tool (`✅ \`read\` · 0.4s`, `⏳ \`bash\` · 1.2s…`, `❌ \`edit\` · 50ms`) — names and durations only, no arguments, no results, no reasoning. Consecutive same-named calls merge into one row with a summed duration; past 40 rows the older **whole steps** fold into a `collapsible_panel` (same "you can see it is folded" chrome as the reasoning panel) whose body is itself capped at 60 lines, because folding hides pixels but the JSON still ships (坑 42's 30KB rejection). Writes follow the `230020` discipline measured in 坑 45: at most one write in flight per card, only the newest render, a 900ms coalescing window, the CardKit instance first and `im.v1.message.patch` as the fallback, plus one plain-text summary at `turn/end` when no card could be created at all. Three lifetime rules, all measured: a card entity may be **sent once only** and is operable for **14 days**, so every turn gets a new entity and nothing is persisted (after a restart the card simply stops updating); a card carrying buttons stops delivering callbacks after 2-3 in-place updates (坑 11) and a tapped `interactive_container` row stops delivering row callbacks (坑 55), so this card **never carries a button or a tappable row** (pinned by a test); and this plugin does not enable `streaming_mode`, so the 10-minute auto-close of native streaming cards does not apply. The feature is gated by a new `turnCard` policy axis that is true only at `compact` (and false when tool display is off entirely); the other three levels are unchanged. The split between the two cards was finalised after seeing it live: under `compact` the step cards carry the ANSWER only (their tool section is off entirely), so a step that only ran tools posts no card at all, and the `step/start` placeholder is now sent only for a step that claimed injection notices (those render on a step card and nowhere else); the answer text is deliberately NOT merged into the turn card. Verified with typecheck clean, **906 tests across 51 files green** (23 new), and a rebuilt `lib/index.js` (`client/client.js` untouched, host-only batch). ### 修复:续接(重建)会话被强行切到「全局默认模型」(`src/harness.ts` / `tests/harness.spec.ts`,2026-10-09) 现象:重启 dsh(或某个会话的 agent 已被回收)之后,很多飞书会话的模型被换成了「别的地方最近挑过的那个模型」。 根因(两层,缺一不可): - **上游:一个 loop 的路线只认「创建者传的 `agentOptions`」**——`packages/core/agent-loop/src/agent.ts:556` 是 `const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' }`,会话自己日志里的 `request/header` **只在 provider+model 相同时**用来补 `reasoningEffort`。会话控制器(`session-controller`)本来会给它建的 agent 装一个 selection 监听器,让「会话自己的路线」优先(`selectionFor`:pending → 日志里的 header → 全局默认);但本插件是**直接**走 `agents.create` / `agents.resume` 建 agent 的(为了自己挂 preset),**没装那个监听器**,于是 `agentOptions` 说了算。 - **插件:新建和续接共用同一段 `initial`**——`creation?.provider ?? config.provider ?? deps.selection()`,而 `deps.selection()` 就是 `agentDefaultModel.currentSelection()`,即**全局默认**;`config.provider/model` 本部署没配,续接时 `creation` 也不存在(`creation` 只由 `/new` 与新会话卡片写入)⇒ **每次重建都拿全局默认当路线**。而 DSH 的 `selectModel` 每次都会 `agentDefaultModel.saveSelection()` 把这个选择持久化成默认 ⇒ 任何一个入口(Web UI 选择器 / 插件 `/model`、`/effort`、模型卡、`/new`)挑一次模型,之后**每次重启都会把所有被重建的飞书会话拽到那个模型**。 实测(真机日志):本会话 20:47:10 打出 `request/header reason=resume cfg=cpa-codex/gpt-6.1-sol/medium startsSeries=true`,而它自己的路线是 `deepseek-official/deepseek-flash`,且全日志**没有任何 cpa-codex 的 `model/selection`**;同一时段另一个飞书会话 20:50:20 也是「minimax → cpa-codex,无 selection 事件」——两个都是被默认值顶掉的。 修法:新增 `recordedRoute(sessionId)`,续接前读**该会话自己的日志**(`sessionPersistence.open(id,'read')` 分块读、`readFrom` 兜底、内存有界;不缓存,所以 agent 存活期间改过的路线照样生效),优先级 **最后一条 `model/selection`(明确的用户/插件选择)> 最后一条非 `resume` 的 `request/header`(它自己在自己进程里跑过的路线)> 第一条 header**;`initial` 改成 **`creation` > `recorded` > `config` > 全局默认**(新建会话行为不变)。日志读不出来时静默回退到配置——绝不让一条消息因为读日志失败而卡住。 已发生的会话修复(一次性,**不需要重启**):扫全部 53 个会话,只有 4 个「自身记录 ≠ 最后一次请求路线」,其中 2 个有明确自选记录被 resume 顶掉(`lark-v2-2b18e9cf…`:10-02 选了 MiniMax-M3.1-Flash-Preview,10-08 被顶成 deepseek-flash;`lark-v2-6185f515…`:09-28 选了 MiniMax-M3.1-Flash-Preview,09-30 被顶掉),用运行中宿主自己的 RPC(`POST /api/session/selectModel`,走 `dsh web` 的 cookie 认证)写回各自路线,并把 `selectModel` 顺带改写的全局默认**还原**;另外 2 个未动——`session-2be5cb53…` 其实还带着 pending 选择(UI 显示本来就对),`lark-v2-e4a580b1…` 从来没有 `model/selection`(63 条 header 全是 `resume`,无从判断意图,强改等于猜)。验证:`session/list` 的 `modelSelection.next` 已是各自路线。 **English**: Re-created (resumed) sessions were silently moved onto the deployment default model. Upstream takes a loop's route from the `agentOptions` its creator passed — a session's own logged `request/header` only donates a reasoning effort, and only when provider and model already match (`agent-loop`). The session controller installs a selection listener that would prefer the session's own route, but this bridge creates and resumes its agents through `agents` directly (to mount its own preset), so no listener exists and `agentOptions` decides; the bridge built those options as `creation ?? config ?? deps.selection()` — and `deps.selection()` is `agentDefaultModel.currentSelection()`, the global default. Since DSH's `selectModel` persists every pick as that default, any pick anywhere plus any restart dragged every rebuilt chat onto it. The fix reads the session's own route from its log before resuming (`recordedRoute`: last `model/selection` > last non-`resume` `request/header` > first header, chunked and uncached) and prefers it over the default; creation routes still win. The four already-mismatched sessions were inspected, the two with a recorded choice were restored through the running host's own `session/selectModel` RPC (no restart) and the default that call rewrote was put back. ### 改进:提问卡的确认流程重做——点选即作答、回执卡带「返回」、最后一张卡统一提交(`src/feishu-questions.ts` / `src/i18n.ts`,2026-10-09) 使用者的三条要求(原话):① 点击后还是会发确认卡,但**默认就是确认**,不再需要用户点击确认;② 确认卡上增加一个「返回」按钮,用来**反悔这一次作答**;③ 当用户回答完所有的卡片后,有一个**最终的确认卡**用来提交回答,此时需要用户选择「确认」或「返回上一题」。 - **点选即作答**:`interactive_container` 行 / `checker` 提交 / 自定义回答 / 跳过,一次点击就把这一题记下来(旧流程是「先弹确认卡、再点一次确认」,两次点击)。问题卡**就地写成本题的结算卡**(选中项打勾、无交互),**不再有第二次确认**。 - **每答一题发一张回执卡(`✅ 已提交`)**:写明「已选:X」,带一个 **`↩️ 返回重答`** 按钮——这就是「反悔这一次作答」的入口;点它会把这道题**重开成一张全新的问题卡**(被点过行的卡飞书不再投递行回调,坑 55),回执卡就地变成灰色「已撤销」提示,已记下的答案被丢弃。 - **最后一张卡统一提交**:所有题都有答案后发一张 `📝 确认全部回答`,逐题列出「问题 → 作答」,两个按钮 **`✅ 确认提交`(把整组答案交给模型)/ `↩️ 返回上一题`(丢掉最后一题、重开那张卡)**。**只有点「确认提交」才真正 resolve 这次 `ask_user_question`**——所以一张卡的答案也能反悔,代价是单问题也要多点一次确认(这是「最终确认卡」语义的直接结果;想改回「单问题一票即交」只需去掉这条分支)。 - **卡片数**:N 题 = **N 张问题卡 + (N-1) 张回执卡 + 1 张最终卡**(最后一题的答案由最终卡记录,不再多发一张回执);单问题 = 2 张。相比旧流程的「问题卡 + 确认卡 + 结果卡」三连,每题的卡片数没增加,但**每一步都能反悔**,且不再有「已作废·结果见下方新卡的卡片」这种指针卡。 - **实现**:`PendingCard`(每问一个)换成 **`PendingBatch`**(整批一个状态:答案表 / 当前是第几题 / 问题卡与回执卡的 message id / 最终卡 / 等待中的 settle + 队列),`presentQuestions` 变成一个状态机循环:跳过已作答的题 → 问下一题 → 记答案、发回执 → 全答完发最终卡 → `确认` 结束 / `返回上一题` 或任一回执的 `返回` 走 `{kind:'undo'}` 分支回到那一题。回调一律**按 `evt.messageId` 认当前卡**(问题卡 / 回执卡 / 最终卡各只认自己那张),旧卡与已作废卡的点击一律忽略。被 Web UI 抢先或回合中止时,**整批的三类卡片一起退休**(`cancelCard`)。 **验证**:`npm run typecheck` 0 error;**936 tests / 52 files 全绿**(`tests/feishu-questions.spec.ts` 23 例按新流程重写/新增:两问批次「问题卡 → 回执 → 问题卡 → 最终卡 → 确认」的顺序与答案、`返回上一题` 重开最后一题后重新作答再确认、回执「返回重答」撤销后再作答、点选即作答(无第二次确认)、回执卡与最终卡的按钮类型、已作答的卡再点不产生第二张卡/第二个答案、Web UI 抢先时整批退休);**未重启**(使用者上一轮要求先不重启,本次同样等确认)。 **English**: The operator's three requirements for the question cards, implemented literally: tapping a row IS the answer (no second confirmation tap), every answer is followed by a receipt card carrying 返回 so that one answer can be redone, and once every question is answered a single final card commits the whole set with 确认提交 / 返回上一题. Internals: the per-question `PendingCard` became one `PendingBatch` (answers, current index, the message ids of the question/receipt/final cards, a parked settle plus a one-slot queue), and `presentQuestions` is now a small state machine — skip answered questions, ask the next one, record the answer and post a receipt, then post the final card; 确认 resolves the `ask_user_question` call, while 返回上一题 or any receipt's 返回 dispatches an undo that re-asks exactly that question on a fresh card. Card count per batch: N question cards + (N-1) receipts + one final card. Every callback is validated against the message id of the card that role owns, and an abort or a Web UI win retires the whole batch. ### 修复:提问卡不再为每个答案多发一张「结果卡」+「已作废」指针(`src/feishu-questions.ts` / `src/i18n.ts`,2026-10-09) 使用者复看时指出「**在 Agent 发出问题卡片的时候还是会产生大量的碎卡片**」,并怀疑是**废卡机制**的问题。取真机证据后确认:**两边都对**——碎卡片里既有一半是我上一批引入的(见下一条),另一半正是提问卡的**废卡指针**机制。 **证据**(话题 `dsh-sshtk`,一轮 5 个问题的问答,19:41–19:47,用 `im/v1/messages` 逐条读回 `create_time` / `update_time` / 当前卡片内容): - **每个被作答的问题都产生三张新卡片**:`问题卡`(`[q] sending card`)→ 点选后**新发的确认卡** → 提交后**又新发一张「结果卡」**,同时原来的问题卡被就地改写成灰色 `已作废 · 本题已提交,结果见下方新发的卡片` 指着它。⇒ 一轮 5 问 ≈ 15 张卡起步(实测那 4 分钟里 22 条消息)。 - **「废卡」本身是就地改写、不是新发**(`create_time` 早于 `update_time`,如 `19:41:09 → 19:41:55`,同一条消息)⇒ 机制没坏,**坏的是「结果另发一张、再让旧卡指向它」这个形状**:多一张卡,还要多一张灰卡去解释它。 **改法**:`submitAnswer` 把结算卡**写回问题卡本卡**(`updateCard`,与多选 / 跳过 / 自定义回答三条分支本来就是同一个形状),**不再新发结果卡**,`renderSubmittedQuestionCard` 与 `questionCardSubmitted` 随之删除(没有指针要解释了)。只有「问题卡的 message id 从来没拿到」这一种情况才兜底发一张。⇒ 一次作答从 **3 张实体卡降到 2 张**(问题卡 + 确认卡),且不再出现 `已作废`。 **验证**:`npm run typecheck` 0 error;**935 tests / 52 files 全绿**(`tests/feishu-questions.spec.ts` 里钉住旧形状的那一例改成新形状:提交后 `sentCards` 从 3 降到 2,问题卡被就地写成带勾选的结算卡);**未重启**(使用者要求先不重启)。 **English**: The operator pushed back on my first diagnosis — "I think the retired-card mechanism is the problem" — and the evidence backs them. Reading every message of one five-question batch back from the API (`create_time`, `update_time`, current card JSON) shows **three new cards per answered question**: the question card, a new confirmation card on the first tap, and **another new "result" card** on submit — while the spent question card was repainted in place as a grey `已作废 · 本题已提交,结果见下方新发的卡片` pointer at it. The retirement itself is an in-place rewrite (create time older than update time), so the mechanism was fine; the **shape** was wrong. `submitAnswer` now writes the settled card **into the question card itself** — the same shape the multi-select, skip and custom branches always used — so no result card is posted, no pointer is needed, and `renderSubmittedQuestionCard` / `questionCardSubmitted` are gone. One answered question now costs two cards instead of three, and no `已作废` card appears at all. (The other half of the fragments was mine and is fixed in the entry below.) ### 修复 / 改进:简洁档的「单卡」收口——注入与中段正文并入过程卡、外部卡片后另起新卡、只显示最新 5 步、运行中的工具不再显示 0ms(`src/card-interleave.ts`(新)/ `src/feishu-progress.ts` / `src/feishu-streaming.ts` / `src/feishu-compaction.ts` / `src/index.ts` / `src/i18n.ts`,2026-10-09) 上一轮上线后使用者复看,报了五条。逐条都取到了真机证据(会话日志 + 从聊天读回卡片): - **「reply 出现在第一步时会溢出成为单卡」与「上下文注入消息也是单独卡片」是同一个根因**。真机证据:每个回合的 **step 1** 都必然认领一条 `user/message`(`source.kind = 'time-context'`,即「Time sampled while preparing turn N, step 1: …」)。旧逻辑里「带注入的 step 必须发卡」(O21① 的例外)于是**每个回合的第一步都被强制开一张步骤卡**,而它同时载着这一步的正文 ⇒ 看起来就是「第一步冒出一张 reply 卡」。**改法:`compact` 下注入提示行改为回合过程卡上该步小节里的一行**(`📥 注入上下文 · …`,渲染在叙述行与工具行之前),**不再为注入开任何步骤卡**;注入先于 `step/start` 到达时照旧缓冲、由那一步认领。**O21① 的意图(注入不能丢)保留**:回合过程卡存在时注入在它上面;整轮没有任何工具时,步骤卡(即答案卡)仍会带上注入行——所以一条提示**只会出现一次**,也**不会**为了渲染它而多出一张卡。中段正文(同一条消息既写话又调用工具)因此回到上一轮定下的行为,第一步不再有例外。 - **外部卡片之后另起新卡(`src/card-interleave.ts` 新)**:就地更新的卡会把新内容画在**它原来的位置**——一旦别的模块在它下面发了一张卡(📋 Todo List、任务卡、子代理名册、提问卡、压缩卡),继续就地更新就会让「最新的一行」出现在那张卡的**上面**,读起来是乱的。**只在真的有新内容时才开新卡**(同日真机教训):提问卡一次是**三张连着**(问题卡 → 确认卡 → 作废卡),第一版实现「收到通知立刻开新卡」于是每次问答都刷出一张内容完全相同、只有耗时在变的 `⚙️ 本回合进行中 · 1 个工具 · …`,一轮 5 个问题的问答里 17 条消息有 9 条是这种碎片。现在收到通知只是**关掉当前分段**(旧卡冻结、不再增长),**下一件真事**(新的 `tool/call` 或新 step)才开新卡;被关掉的那张卡仍保留**时钟**(仍在跑的调用每 30 秒照旧刷新已耗时间,但不会多出一行),并在 `turn/end` 时**只改表头**写成终态(绿/红 + 工具计数),正文保持冻结——一张永远写着「进行中」的卡是假话。新增一个极小的注册表:`cardChannel.send`(所有**别的**模块发卡的路径,且只针对卡片、不含纯文本)在发送成功后 `note(chatId)`,压缩卡自己 `note`。回合过程卡收到通知就**结束当前分段**:屏幕上那张停更,下一次事件**新开一张卡**,只渲染从那之后发生的步骤/工具(标题里的计数仍是整轮总数;分段切在一个还没结束的 step 上时,那一步留在新卡里,否则它的后续工具行会落进一张永远不显示它们的卡)。`compact` 的答案卡同理:收到通知后本步这张卡**退休**,之后新到的正文**发新卡、只带新增的那部分**(`supersededFrom` 记录切点)。 - **过程卡只显示最新 5 步**(原来按 40 行预算,长回合约 20 步):新增 `PROGRESS_MAX_STEPS = 5`,可见窗口是**最新的 5 个整步**,更早的整步(连同小节标题)折叠进 `collapsible_panel`;40 行仍作为外层上限保留(单步就可能几十行)。 - **运行中的工具不再显示 `0ms`**:没跑满 1 秒的调用显示 **`执行中`**(`turnCardRunningLabel`),超过 1 秒显示已消耗时间,并新增 **30 秒一次的重绘**(`PROGRESS_RUNNING_TICK_MS`)让这个时间真的在走——以前卡片是在调用刚开始时写的,那一行于是永远停在 `0ms`。**快的工具不付这笔开销**:计时器只在有调用仍在跑时存在,最后一个跑完就撤掉,没到 30 秒的调用一次都不会触发。 **验证**:`npm run typecheck` 0 error;**935 tests / 52 files 全绿**(本批新增 13 例:`tests/feishu-progress.spec.ts` 9 例——注入行顺序、只留最新 5 步且折叠计数正确、分段只渲染本段行而计数保持整轮、`执行中`→30s 重绘、快调用不排 tick、外部卡片后另起新卡且旧卡零更新、跨中断的「仍在跑的 step」留在新段;`tests/feishu-streaming.spec.ts` 3 例——注入 + 工具 + 叙述**只发一张回合卡**(注入与 `💬` 都在上面)、其他档位仍把注入留在步骤卡、外部卡片后简洁答案卡另起新卡且只带新增正文;`tests/card-interleave.spec.ts` 3 例——广播、退订、监听器抛错不打断发送;2 例钉住碎片回归——**连续三次中断通知只关分段、不新开卡**,以及被关掉的分段**只走时钟、不加行**);`npm run build`(`lib/index.js` 676,881 → 686,794 B;`client/client.js` 未变,本批纯宿主侧);**生效需要重启 dsh**。 **English**: Five follow-ups from the user's second look, each grounded in live evidence. First, the two card complaints share one root cause: EVERY turn's step 1 claims a `time-context` injection, and the old "an injection-bearing step must have a card" rule (O21①) therefore forced a step card on it — a card that also carried that step's narration, which is what read as "a stray reply at step 1". Injection notices are now one row of the turn card's step section, no card is opened for them, and O21① survives as content rather than as a card: the notice shows on the turn card when it exists and on the answer card when the turn ran no tool — exactly once, never a card of its own. Second, a new tiny `card-interleave` registry (`src/card-interleave.ts`): the channel's general `send` path (what every OTHER module posts cards through — todos, jobs, subagent panels, questions; plus the compaction card explicitly) announces that a card landed in a chat, and the long-lived compact cards end their segment there. The turn card stops updating and the next event that has something genuinely new — a tool call, a step — opens a NEW card below the interruption carrying only what happened since (the first implementation opened one immediately on every notice, so a question card, its confirmation card and its retirement notice each spawned an identical `本回合进行中` fragment: 9 of the 17 messages in one five-question exchange; a closed card now keeps only its elapsed clock alive and gets a header-only terminal render at `turn/end`) (counters stay turn-wide; a step that is still running stays in the new segment so its remaining rows stay readable), and `compact`'s answer card retires so further text arrives as a new card with only the new part. Third, the turn card keeps only the NEWEST FIVE steps visible (`PROGRESS_MAX_STEPS`); older whole steps fold away with their headers, and the 40-row budget stays as the outer bound. Fourth, a running tool no longer prints a frozen `0ms`: under a second it says `执行中`, after that it shows the elapsed time, and a 30-second tick repaints the card so that time actually moves — dropped as soon as the last call settles, so a fast tool pays nothing. ### 改进:中间回复不再单独发卡 + 简洁档卡片收紧(O22 第三轮,`src/feishu-progress.ts` / `src/feishu-streaming.ts` / `src/i18n.ts`,2026-10-09) 第二轮上线后使用者报了两条:**「模型中间的 reply 还是单独发,应该压到步骤卡片里面,或者不显示」**、**「compact 的步骤卡片行距太大,可以小一些」**。两条都按「压进去 + 收紧」处理,没有选「不显示」。 - **中间回复改判为「过程」,不再单独发卡**。判据取 WebUI 自己的定义:只有**不含 tool-call 块的正文消息**才算一轮的最终答案(`ui-chat/src/client/conversation-nodes/turn-process.ts` 的 `latestAnswer` 明确拒绝任何带 tool-call 的 assistant 消息),所以在**同一条消息里既写了话又调用了工具**的正文属于**过程叙述**。`compact` 下它不再开步骤卡(以前会被当成一次「回复」,一轮里出现好几张),而是作为**该步小节下的一行 `💬 …`** 落到那张回合过程卡上——只取首个非空行、空白折叠、80 字截断(过程卡是索引,正文归答案卡)。**判定发生在 `assistant/message`**:tool-call 块就在这条已组装消息里(`tool/call` 事件是之后才来的),所以在「本来要发卡」的那一刻就能判。**保留的例外**:该步带着注入提示行时步骤卡照发(注入行只渲染在步骤卡上,O21①),正文跟它一起留在卡上、不再往过程卡上重复一份;另外三档行为不变(中段正文仍各自成卡)。 - **简洁档答案卡收紧**:去掉 `📝 **消息**` 小节标题(这一档只有这一节,标题只是多一个空段落)、去掉正文与脚注之间的 `hr`、**脚注两行(`⏱/📥/📤` 与 `🚀/📊`)合并成一个元素**(每个元素都要各自付一次元素间距)、并给整张卡设 `body.vertical_spacing: 4px`。回合过程卡同样设为 4px(它整张卡就是一行行短文本,40+ 行时默认间距读起来像每行之间空一行)。**字段是实测的,不是猜的**:`cardkit.card.create` 静默容忍未知字段(坑 48),所以按坑 54 的办法在**一次性测试群**里逐案真发一次、随即撤回(`~/.dsh/probe-card-spacing.py`)——未知字段与非法取值分别被 `230099`(`2006 unknown property` / `11310` 取值)拒收,而 markdown 的 `margin`(`0px`、`4px 4px`、`0px 0px 8px 0px`、裸 `0` 均可)、`body.vertical_spacing`(`small|medium|large|extra_large` 或任意 px)**都被接受**;`body.vertical_spacing: 'none'`、markdown `padding`、`line_height` 则被拒。⇒ 新的关键坑 58。 **验证**:`npm run typecheck` 0 error;**921 tests / 51 files 全绿**(本批新增 13 例:`tests/feishu-progress.spec.ts` 6 例——`condenseStepNote` 取首行/折叠空白/截断/纯空白、小节下的 `💬` 行顺序与「无叙述则无该行」、`vertical_spacing`、懒开卡时叙述进入**首版**渲染、重复叙述不产生写入且只保留最新;`tests/feishu-streaming.spec.ts` 5 例——简洁档「带工具的消息」正文只落过程卡且**只发一张卡**、带注入的同类步骤仍发步骤卡且不重复、详细档行为不变、简洁档答案卡无小节标题/无 `hr`/脚注只有一个 notation 元素/间距 4px、详细档小节标题与 `hr` 仍在;另含既有 2 例回归);`npm run build`(`lib/index.js` 672,376 → 676,881 B;`client/client.js` 未变,本批纯宿主侧);**生效需要重启 dsh**。上一节记录的 908 tests 是本批开始前的数字,以本节为准。 **English**: Two things the user reported right after the second round: the model's mid-turn reply still arrived as a card of its own, and `compact`'s step card had too much spacing. Both are handled by folding and tightening — not by hiding. **A reply-bearing assistant message that also calls tools is process, not the answer**: the Web UI draws exactly that line (`ui-chat/src/client/conversation-nodes/turn-process.ts` → `latestAnswer` refuses any assistant message whose blocks contain a tool call), and the tool-call blocks are already inside the assembled `assistant/message`, so `compact` can decide at the moment it would have sent the card. Such text no longer opens a step card; it becomes one `💬 …` row under that step's section on the turn progress card (first non-empty line, whitespace squeezed, 80 characters — the card is an index, the prose belongs to the answer). A step that carries injection notices keeps its step card (notices render there and nowhere else), and the other three levels are unchanged. The `compact` answer card also loses chrome that only made it taller: no `📝 Message` section label, no `hr` before the footer, both footer lines in ONE element, and `body.vertical_spacing: 4px` on the card (the progress card too, whose every row is a short line). The spacing fields were **measured, not guessed**: `cardkit.card.create` silently tolerates unknown fields, so each candidate was really sent once to a throwaway group and deleted immediately — unknown fields and bad values come back as `230099` (`2006` / `11310`), while markdown `margin` and `body.vertical_spacing` are accepted (`none`, markdown `padding` and `line_height` are not). See key pitfall 58. ### 改进:所有「自动启动的回合」都有标记——与 Web UI 同一张触发来源表(`src/feishu-turn-origin.ts` / `src/feishu-streaming.ts` / `src/feishu-external-turns.ts` / `src/channel.ts` / `src/i18n.ts`,2026-10-08) 紧跟上一批:外部入口的回合已经能回显、收尾、交付,但**步骤卡**上只有后台任务那一种标记(`tool-jobs` → 「本回合由后台任务完成通知触发」),定时提醒 / 子代理 / 团队消息等自动启动的回合在飞书侧看不出「谁把它叫醒的」。 - **用上游自己的触发器表**:DSH Web UI 的 `ui-chat/src/client/chat/turn-trigger.ts` 就是这件事的权威清单,逐项对齐——`tool-jobs`(后台任务状态更新)· `schedule`(自动化任务)· `subagent-settled`(子任务状态更新)· `agent-message`(收到任务消息)· `team-message`(收到团队消息)· `goal`(继续执行目标)· `webhook`(收到外部事件,`provider: github` 时是 GitHub 事件)· `cordis-host-runner`(插件状态更新)· 其余非人类来源兜底「收到执行请求」。**文案照抄 Web UI 的中英用词**,同一个事件在聊天与 GUI 里叫同一个名字。 - **宿主侧唤醒点逐个核对过**(`followup()` 调用点 = 能在空闲 owner 上开新回合的地方):`jobs/tool-jobs`、`schedule/schedule/src/runtime.ts`、`subagent/subagent`(结算与 `agent-message`)、`webhook/webhook/src/session.ts`、`goal/goal-round-driver` —— 全部落在上表里;`api/session-controller` / `acp` / `sdk` / `headless` 的 `user` 来源是人类入口,不算自动启动。自定义/未知来源走兜底家族,**只报家族名、绝不贴 `source` 里的自由文本**(坑 50)。 - **两条判定规则(都对齐 Web UI)**:① **人类消息拥有这一回合**——人类先说话,则本回合里后来被认领的通知不再算触发器(既有「忙回合里被注入的通知不算触发」测试语义不变,也避免 Web GUI 回合被后台通知抢标);② **具名家族优先于兜底**——新会话的第一个回合先认领指令注入(`agent-instructions` → 兜底「收到执行请求」),随后真正的唤醒者(如 `schedule`)把它升级成具名家族;反过来,兜底项遇到人类消息会被**撤下**。 - **两处标记**:步骤卡首行 `⚡ 本回合由**<家族名>**自动触发`(回合级属性,每个 step 都带);收尾卡首行改成**具名来源**——`💬 本回合的来源是**Web GUI**` / `**自动化任务**` / `**后台任务状态更新**`…,不再是笼统的「Web GUI / 后台任务」。 **验证**:`npm run typecheck` 0 error;**883 tests / 50 files 全绿**(新增 13 例:触发表 3、回合归因 5、步骤卡具名/升级/撤销 3、收尾卡具名与兜底 2);`npm run build`。**生效需要重启 dsh。** **English**: Follow-up to the previous entry. External turns could already be mirrored and completed, but only ONE automatic way to start a turn was marked on the step cards (a settled background job). The marker is now driven by the DSH Web UI's own turn-trigger table (`ui-chat/src/client/chat/turn-trigger.ts`), with the same names in both languages: `tool-jobs`, `schedule`, `subagent-settled`, `agent-message`, `team-message`, `goal`, `webhook` (GitHub flavour when `provider: github`), `cordis-host-runner`, and a generic "Execution requested" family for any other non-human source (named, never quoted). Every host-side `followup()` call site that can open a turn on an idle owner was checked and lands in that table: `jobs/tool-jobs`, `schedule/schedule`, `subagent/subagent`, `webhook/webhook`, `goal/goal-round-driver`; the `user` sources of `session-controller` / `acp` / `sdk` / `headless` are human entrances. Two rules, both mirroring the Web UI: a human message owns its turn (a notice claimed later in it is not a trigger), and a named family beats the generic fallback (a fresh session claims its instruction injection first, then the real waker upgrades it; the generic guess is dropped once a human message arrives). Step cards now lead with `⚡ This turn was started automatically: ****`, and the completion card names the source (`Web GUI`, `Automation task`, `Background task updated`, …). typecheck clean, 883 tests across 50 files green (13 new), artifacts rebuilt. ### 新增:外部入口的回合也完整镜像到飞书(Web GUI / 后台任务 / 定时任务)+ 飞书界面提示词(`src/feishu-turn-origin.ts`(新)/ `src/feishu-surface.ts`(新)/ `src/feishu-external-turns.ts`(新)/ `src/harness.ts` / `src/channel.ts` / `src/config.ts` / `src/i18n.ts` / `src/index.ts`,2026-10-08) 操作者的问题:「绑定飞书会话、但我在 Web GUI 操作会发生什么」——答案是**飞书只看到过程、看不到收尾**。`channel.ts` 只处理它自己分发的回合(收到飞书消息 → prompt → 回复卡 + Turn Complete 卡 + 交付),所以 Web GUI 发起或后台任务唤醒的回合在飞书侧只有步骤卡:**没有「谁问的」,没有收尾卡,也没有 `present` 的交付面**(`renderFooterCard` 全仓只有 `channel.ts` 那两处),而模型那一轮看到的提示词恰好是「你在 Web GUI 里 + present 有 native-open」,于是它既不会调 `feishu_send_file`,飞书也不会收到任何交付物。这就是 TODO O25 记的「仍未做:WebUI 触发的轮次没有交付卡」。 **这一批用原生机制补齐三件事**(不改 DSH 源码、不改会话记录格式、不新增 `MessageSourceMap` kind、不遮蔽上游任何 section): - **回合来源判别**(`feishu-turn-origin.ts`):DSH 的 `SessionPromptRequest` **没有客户端身份字段**(只有 `requestId` / `mode` / `content` / 可选 `clientTimeZone`),所以来源从原生 `user/message.source` 读——插件 mint 的 `requestId` 被 session-controller **原样**写进 `source.rpcId`(`commands.ts`),因此「这条用户消息是我替飞书发的」可以精确匹配;走 `agent.steer/followup` 直连的图片/文件消息(无 `rpcId`)记一次性标记,且**只被「带附件的下一条消息」消费**(别的 surface 发一条纯文本插不进来);其余 `kind:'user'` 即其它入口(`clientTimeZone` 由浏览器客户端带、我们从不带,**只用来区分「Web GUI」与「其它入口」的文案**,不用来判定来源)。判定是**逐回合**的:`turn/start` 重置、本回合第一条用户消息定调 ⇒ Web GUI 的插话不会把正在跑的飞书回合改判成外部回合。 - **回显用户消息**:外部入口的用户消息以**纯文本**(150KB、不经 markdown ⇒ 不会被图片/链接语法整卡拒收)发进该会话的**话题**(硬规则 3),前缀 `💬 Web GUI:` / `💬 其它入口:`;**一期不上传附件**,只写「(附件:N 张图片、N 个文件,未镜像到飞书)」。实现上是渲染 `user/message` 事件——Web GUI 显示飞书消息用的就是同一份会话事件流,方向反过来而已。 - **收尾 + 交付**:`turn/end` 时对**绑定会话中非飞书发起**的回合(含后台任务唤醒、定时任务这种根本没有用户消息的回合)复用:streaming 的 `flushed()`(这正是「收尾卡必须排在最后一张步骤卡之后」的保证)、`renderFooterCard()`(新增可选的 `externalTurn` 标记,卡首行写「💬 本回合不是从飞书消息发起的(Web GUI / 后台任务)」)、以及**同一个 deliver 控制器**(`plan` + `autoSend`,可点行 token、话题登记、文档自动投递与飞书发起的回合完全同一条路径)。**飞书发起的回合仍由 `channel.ts` 独占** ⇒ 一个回合不可能出现两张收尾卡。 - **飞书界面提示词**(`feishu-surface.ts`):用 `systemPrompt.section`(`inject` 动态注入,**与 Web bundle 的 `app:web-surface` 完全同形**、同一个 `WEB_SURFACE` 顺序槽,排在 `ui:deliverable-file-references` 之后,正好就地纠正那句 native-open)补一句只有本通道知道的事实:用户在飞书聊天里、打不开本地路径、`present` 落在回合末交付卡上(媒体内联 / 白名单文档自动发 / 其余点一下)、要「能保存或转发的原件」或「回合没结束就要给」就调 `feishu_send_file`。`text` 是 provider:**非绑定飞书的会话返回空串**(别人的提示词一个字节不变);文案**每会话稳定、不随回合变**——因为交付面补上之后,这句话对任何回合都成立 ⇒ 组装出的提示词在绑定期间永不变化(变化的系统提示词会让上游**追加**一条 `system/message` 节点并使缓存前缀失效)。两个开关 `surfaceContext` / `mirrorExternalTurns`(都默认开,形状与上游 `surfaceContext` 一致)。 **明确没做**:外部回合若「最后一张步骤卡写入失败」不会回退发正文卡(发起方通常正看着 Web GUI;飞书发起回合的那条回退仍在 `channel.ts`);附件不上传;O3 的 `produced` 口径(write/edit 产出的文件)不在本条。 **验证**:`npm run typecheck` 0 error;**870 tests / 50 files 全绿**(新增 30 例 / 3 文件:`tests/feishu-turn-origin.spec.ts` 11 例——minted `rpcId` 命中 / 外来 `rpcId` 按 `clientTimeZone` 分 Web GUI 与其它入口 / 宿主注入 kind 不算外部 / 跨会话不误配 / TTL 过期 / 逐回合定调且插话不改判 / 直连标记只被附件消息消费 / 直连标记过期;`tests/feishu-surface.spec.ts` 7 例——注册名与 `WEB_SURFACE` 槽、绑定会话出文案、非绑定与诊断组装为空、开关关掉为空、开关**实时读取**、文案里不含 native-open、disposer 幂等;`tests/feishu-external-turns.spec.ts` 12 例——文本/附件/非浏览器三类回显与话题坐标、自己分发的消息不回显、宿主注入不回显、开关关掉与未绑定不动作、外部回开发收尾卡(含标记行 + `plan`/`autoSend`/话题登记)、飞书发起的回合不发第二张、无用户消息的后台回合照样收尾、`describeExternalMessage` 的空消息与超长截断);`npm run build` 产出 `lib/index.js` + `client/client.js`;**生效需要重启 dsh**。 **English**: A session bound to one Feishu chat can also be driven from the Web GUI, and turns started there (or woken by a background job or a schedule) reached the chat as step cards only: no message above them, no Turn Complete card, no `present` delivery — while that turn's prompt said "you are in the Web GUI" and "present gives preview and native-open", so the model never called `feishu_send_file` and nothing was delivered. The fix uses only native mechanisms (no DSH patch, no new session event, no new `MessageSourceMap` kind, no shadowing of upstream sections): a **turn-origin** module reads the native `user/message.source` (the session controller writes this plugin's `requestId` verbatim into `source.rpcId`, so a message this plugin dispatched matches exactly; a direct `steer`/`followup` carries no id and is covered by a one-shot marker consumed only by the next message that has an attachment; `source.clientTimeZone` merely labels the echo as "Web GUI" vs "another surface"). The origin is decided **per turn** from its first user message, so a Web GUI steering message cannot relabel a running Feishu turn. Other surfaces' user messages are echoed into the same topic as plain text (attachments are named, not uploaded in phase one), and `turn/end` for a non-Feishu turn posts the **same Turn Complete card** — with a first line saying so — and runs the **same delivery pipeline**, reusing streaming's `flushed()` so the footer can never overtake the final step card; a Feishu-started turn stays owned by `channel.ts`, so no turn can produce two footers. Finally, a **Feishu surface prompt section** — the exact shape of the Web bundle's `app:web-surface`, in the same `WEB_SURFACE` slot and after `ui:deliverable-file-references`, so it reads as the correction of the native-open promise — tells a bound session where the user is; its provider returns `''` for any other session, and the text is stable per session (after this change no part of it depends on where the turn came from, so the assembled prompt never changes and no extra `system/message` node or cache invalidation is produced). Two settings, both default on: `surfaceContext` and `mirrorExternalTurns`. Verified with typecheck clean and **870 tests across 50 files green** (30 new across three specs). Known gaps: no text fallback when the final step card failed on an external turn, attachments are not uploaded, and O3's `produced` files are untouched. ### 修复:问题卡片——选项描述终于显示、单选改成「整行可点 + 确认卡」、选项不再被截断、多选题可以作答(O24,`src/feishu-questions.ts` / `src/i18n.ts`,2026-10-07) 操作者的原话是「在 WebUI 中,每个选项下面都有一个具体的描述,但在我们这里只有选项,没有选项的描述」,本轮他判定这是**最影响使用的缺口**。根因全在渲染层,上游一路都给足了数据(`AskUserQuestionOption.description`、`AskUserQuestionItem.multiSelect` 都是随 `user-questions/request` 一起到的持久字段): - **描述从未被读取**:卡片正文只拼 `header / question / detail`,选项**只**做成按钮、按钮文字就是 `option.label`。现在**单选题的每个选项本身就是整行可点的内容**(但**点一下不会直接作答**:先弹一张确认卡,见下)(`interactive_container`,与工作区选择面板、交付清单同一形状:`width:fill` + `has_border:false` + `padding:4px 12px`),行内是 `1. **标签**` + 一行 `description`(`text_size: notation`)——**点一下选中的就是这一项**(但要过确认卡才结算),不再有「选项 → 序号按钮 → 自己对照」这一步,**也没有任何选项按钮**;结算卡同样带描述,并用 `✅`/`⬜` 标出选中项。 - **第 7 个选项起凭空消失**:`options.slice(0, 6)` 是插件自己造的上限、不是飞书边界,已删除(不另设上限;卡片真正的硬边界仍是关键坑 42 的 200 元素 / ~30KB)。 - **多选题不再把选项写两遍**(操作者 2026-10-07 反馈「主干会显示选项,选项也会显示,太重复了」):正文只留一行提示,**复选框自己带标签 + 描述**(`checker.text` 用 `lark_md`:`1. **标签**` 加粗、描述换行在下;该 tag 已用**发送校验器**验过会被接受)。 - **多选题在飞书侧原本答不出来**:`multiSelect` 过去只改按钮配色(`default` vs `primary`),点一下仍然只回一个 label。多选题**不用可点行**(每次勾选都要重绘卡片 → 撞坑 11),而是渲染成 `form` + **每个选项一个 `checker` 复选框**(正文保留带描述的选项列表)+ 一个「✅ 提交」按钮,**一次提交带回全部勾选项**(`form_value = {"opt_0":true,"opt_1":false,…}`)——过程中没有任何就地重绘,天然避开「带按钮的卡改 2–3 次后回调失效」(关键坑 11)。 - **回退安全**:若租户/客户端拒收复选框卡,发送失败后**自动重试一次「只放序号」的单选卡**,问题不会因为控件不支持而丢失(新用例覆盖)。 - **点选即冻结 + 两个出口都以「旧卡变灰 + 发新卡」收尾**(操作者 2026-10-07 第二轮要求):点一行后**立刻**把问题卡改成无交互的灰卡(`⏳ 已选择 **标签**,请在下方确认卡上确认或返回。`,行与按钮全部移除);然后 ① 按「✅ 确认提交」→ **结果作为一张新卡发出**(已选态 ✅/⬜),原问题卡变灰「本题已提交,结果见下方新发的卡片」;② 按「↩️ 返回」→ 原问题卡变灰「已作废」,**新发一张可交互的同一张问题卡**。两条出口的终态一致:**问题卡永远不再复用**。结果卡发不出去时回退为「直接把结果写进问题卡」(否则结果无处可见);跳过 / 自定义回答 / 多选题提交仍是单次交互,保持原地结算。 - **可点行必须防误触(操作者 2026-10-07 要求)**:整行点击的命中面积大,点错了答案就错了。现在点选一行只**弹确认卡**(`📋 确认提交`:显示将要提交的选项 + 描述,按钮「✅ 确认提交」/「↩️ 返回」),只有「确认提交」才结算;「返回」把确认卡与那张问题卡**一并改成灰卡**,并**另发一张可交互的问题卡**(首版写的是「返回后原问题卡还能继续选」,真机验收证否:被点过的可点行不再投递回调,见下方 15:37 那段与关键坑 55)。确认卡带按钮 ⇒ **最多只写一次**:换选另一个选项时**发新卡并退休旧卡**(不就地改),因此不碰坑 11。取消/被 WebUI 抢先/Turn 中断时,确认卡也会被一并退休(问题卡与确认卡各写一次)。**确认卡发送失败不能变成死路**:此时直接按原选择提交并留 warning(问题不会卡住)。跳过与自定义回答不受影响(它们本来就不是「一下即生效」的误触路径)。 - 顺带修掉:卡片里的「自定义回答」提示 / 占位 / 按钮 / 跳过按钮此前**硬编码中文**,`i18n.ts` 里的键早就存在却没接上,现按当前语言渲染。 - **确认卡文案与实现对齐**(2026-10-07 复审文档时发现):确认卡上写的还是首版口径「点错了就按『返回』——问题卡片仍然可以继续选别的」/「已返回。问题卡片仍然可以作答」,而「返回」的实际行为是**作废这张卡 + 另发一张新的问题卡**(下面 15:37 那段就是它被迫改的原因)⇒ 改成「这张卡会作废,并另发一张新的问题卡给你重选」/「已返回。这张确认卡已作废,请在下方新发的问题卡上继续作答」,英文同步(`questionConfirmNote` / `questionConfirmBacked`)。 **探针发现(已固化为关键坑 54)**:`cardkit.card.create` **容忍未知字段**(只有伪造 tag 才会被拒),所以它证明不了字段名;而 `im.v1.message.create` **严格校验**——给 `checker` 塞 `options` 直接被拒(`200621 unknown property`),且**被拒的卡不会发出去**,于是「真发一次」成了**免费的字段探针**。实测结论:`checker` 是**单个复选框**(`text` + `checked`,**没有 `options` 字段**),多选 = 每个选项一个 checker(`name` 各异),回调形状 `form_value = {"c1":true,…}`。 **验证**:`npm run typecheck` 0 error;**840 tests / 47 files 全绿**(`tests/feishu-questions.spec.ts` 由 11 例增到 22 例:选项整行可点且无按钮、结算卡带描述、8 个选项全渲染(旧上限外)、多选 = 每项一个 `checker` + 一次提交、未勾选不结算 + 列表形状容错、复选框卡被拒时回退单选、返回时**重发新问题卡**、重复点同一项忽略、确认卡发不出去时直接提交、别处答完后退休确认卡、自定义回答 i18n 接线);`npm run build` 产出 `lib/index.js`。**多选控件真机验证(2026-10-07 14:14)**:先经 `cardkit` 探针确认 tag 存在(伪造 tag 对照被拒),再向操作者发一张三复选框试验卡,勾选三项提交后 journal 读回 `[q] probe=checker form_value={"c1":true,"c2":true,"c3":true}` ⇒ 复选框**渲染成功**且**每项只带回一个布尔**,与实现的解析口径一致。**真机端到端验证(2026-10-07 14:29–14:30,重启后)**:在飞书里真发一张 **8 个长选项**(每项都带描述,标签 20–30 字)的单选卡 + 一张 **5 选项多选卡** —— ① 正文 8 条「序号 + 标签 · 描述」与 **8 个序号按钮**全部渲染(那一轮还是「序号按钮」;15:37 的确认卡验收才把它换成整行可点),操作者点第 3 项,答案按 label 正确结算(`O3 补 produced 交付物`),即第 7、8 个选项**不再被截断**;② 多选卡勾 3 项后**一次提交**,journal 读回 `[q] multi-submit form_value={"opt_0":true,"opt_1":true,"opt_2":false,"opt_3":true,"opt_4":false}`,工具侧收到 3 个 label ⇒ 复选框卡走的是**主路径**、没有触发单选回退;③ 操作者确认「描述都显示了 / 序号按钮点下去是那一项 / 多选提交顺手」三项到位。**确认卡真机验收(2026-10-07 15:37–15:38,重启后)踩到一个真 bug,已修**:操作者点第一行 → journal 出现 `value={"selected":["照搬 WebUI 的 produced 口径:…"],"type":"answer-open"}`(行可点、长标签原样带回)→ **另发了一张确认卡**(回调来自新的 message id)→ 按「↩️ 返回」→ **再点选项行就没有任何回调了**(`[card]` 那行日志是无条件打的,`src/index.ts:557`:这段时间一条都没有),**而同一张卡上的「⏭️ 跳过本题」按钮照样能回调**。⇒ 判定为**飞书客户端侧的行为**:**一张卡上的 `interactive_container` 行一旦被点过一次,这张卡后续的行点击不再投递回调**(普通 `button` 不受影响)。修法:①「↩️ 返回」不再只是清状态,而是**重发一张全新的问题卡**,并把用过的那张改成灰卡(「已作废…请使用下方新发的同一张问题卡」);② 所有回调**按 `evt.messageId` 认卡**——确认卡的回调只认当前确认卡、其余只认当前问题卡,**旧卡上的点击一律忽略**(否则旧卡能再开一张确认卡)。⇒ 修复后二轮真机验收通过(2026-10-07 15:44–15:46):4 次回调分别来自 **4 个不同的 message id** —— ① `answer-open`(原问题卡)→ ② `answer-back`(确认卡)→ ③ **`answer-open`(「返回」后新发的那张问题卡)**←这一步就是原先丢回调的地方,现在通了 → ④ `answer-confirm`(新的确认卡,**「✅ 确认提交」此前从未真机点过,这次也通了**),工具侧收到所选 label。三条路径(行可点 / 返回重发新卡后仍可点 / 确认提交)全部真机通过。注意:卡片正文**读不回来**(`im.v1.message.get` 只回「请升级至最新版本客户端」),这类结论只能靠回调日志 + 使用者目视(关键坑 26)。**生效需要重启 dsh。** **明确没做**:`plan-review` 意图不渲染(上游只有接收端、没有发送端,模型造不出来,等上游);选项数照旧不设自造上限,真撞到卡片元素预算时按步骤卡相位那套处理。 **English**: The question card had three rendering defects, all fixable without waiting for upstream, which already ships `AskUserQuestionOption.description` and `AskUserQuestionItem.multiSelect` on the request event: the option descriptions were never read (the body only carried header/question/detail and every option became a button labelled with its own text), options past the sixth were dropped by a self-invented `slice(0, 6)`, and a multi-select question could only ever return one label (`multiSelect` merely changed the button colour). Each single-select option is now a **full-width tappable row** (the same `interactive_container` as the workspace picker and the deliverable list: the label and its description live in the row and there are **no option buttons** at all), and one tap does **not** settle the answer — it freezes that card into a button-less grey card and posts a **confirmation card** (`✅ Submit` / `↩️ Back`). Submit posts the **result as a new card** and greys the old one; Back greys the spent card and **posts a fresh question card**, because the Feishu client consumes a tapped `interactive_container` row and that card then never delivers further row taps (key pitfall 55) — so callbacks are matched by `evt.messageId` and taps on a superseded card are ignored. Settled cards keep the descriptions and mark the picked rows with `✅`/`⬜`, the six-option cap is gone, and a multi-select question renders one `checker` checkbox per option in a form plus a single Submit — **one submission carries every check** (`form_value = {"opt_0":true,…}`) with no card re-render at all, sidestepping the callback death after 2–3 in-place updates (key pitfall 11). If a tenant or client rejects the checkbox card, the send retries once as a single-pick index card so the question is never lost, and the custom-answer controls are finally translated instead of hardcoded Chinese. The probe finding behind the shape is now key pitfall 54: `cardkit.card.create` **tolerates unknown fields** (it only rejects made-up tags), while `im.v1.message.create` **validates them** — a `checker` with an `options` field is refused with `200621 unknown property`, and a refused card **is never posted**, which makes a real send a free field-name oracle. `checker` is a single checkbox (`text` + `checked`, no `options`), so multi-select means one checker per option, and the live tap returned `form_value = {"c1":true,"c2":true,"c3":true}`. Verified with typecheck clean, 840 tests across 47 files green (11 new in that spec), and a rebuilt `lib/index.js`; a restart is required. ### 新增:`present` 交付物按类型投递——文档自动发、其余确认后发、媒体卡内内联(O25,`src/deliver-files.ts`(新)/ `src/feishu-deliver.ts`(新)/ `src/feishu-send-file.ts` / `src/channel.ts` / `src/config.ts` / `src/client/` / `src/i18n.ts`,2026-10-07) 原来 `present` 只做了一件事:把路径列成 Turn Complete 卡上的一行。清单本身没错,但**清单就是终点**——真机扫过的 13 次 `present` 里只有 1 次真正送达(那一次还是模型自己额外调了 `feishu_send_file`),而卡片根本没有文件组件,`.md` / `.csv` / `.zip` 这类文件在卡上**无处可放**(关键坑 42),于是那行永远是「点不动的路径」。本次按操作者 2026-10-07 拍板的四条口径补齐投递: - **媒体(图片 / 视频 / 音频)**:**卡内内联**(复用既有 `card-images.ts` 水合器,不额外发消息),但行上**写出文件名**——内联图片的 alt 只是悬停提示,不写名字就看不出收到的是哪个文件;引用仍写成 `[预览](路径)` 交给水合器上传。 - **文档白名单**(`pdf` / `doc` / `docx` / `ppt` / `pptx` / `xls` / `xlsx`):不超过新设置项 **`deliverDocMaxMb`**(1–30,默认 30 = 飞书文件接口硬顶)就**自动发成原生文件消息**,行尾标 `自动投递`——**故意不写「已发送」**,因为这一行在推送之前就已渲染好,失败另有文字提示。**单轮最多自动发 8 个**(`DELIVER_AUTO_CAP`)——聊天消息撤不回,超出的文档与其余文件一样变成可点行,由操作者点。 - **其余文件**(源码、`.md`、`.csv`、压缩包……)与**超限文档**:交付清单里变成**可点行**(`interactive_container`,即工作区选择面板那种整行可点样式),点一下弹**确认卡**,只有按下「📤 发送」才真正投递。这是操作者的要求(聊天消息撤不回),也是本次唯一新增的交互。 - **超过飞书 30MB / 文件已不存在 / 落在会话工作区外**:不给按钮,行内直接写原因——一个点了没反应的按钮比没有按钮更糟。 实现要点(安全与有界是硬要求): - **回调里绝不放路径**。[`src/feishu-deliver.ts`]() 为每个可点行发一个**不透明 token**(`randomUUID`),登记「会话 / 聊天 / 工作区 real root / real 绝对路径 / 大小 / 描述 / 时间戳」;token **30 分钟过期、最多 200 个**,每次点击都**重新校验**(文件仍在、是普通文件、非空、≤30MB、`realpath` 仍在工作区内)——卡片发出后把文件换成指向工作区外的符号链接,点击一样会被拒。 - **自动发送天然有界且去重**:走的是从 `feishu_send_file` 抽出来的同一个 `sendWorkspaceFile`(`src/feishu-send-file.ts`),**每次发送都套 120s 上限**(SDK 的 HTTP client `timeout: 0`,此前工具路径其实没有上限——顺手补上关键坑 29 的这处缺口);`src/deliver-files.ts` 记「本会话 10 分钟内已发过这个路径」,所以模型已经显式 `feishu_send_file` 过的文件不会因为 `present` 再发一遍。失败只发一条文字说明,不抛错(确认卡回调抛错会杀进程,见 `card-dispatch.ts`)。 - **清单与投递不会被「没有步骤卡」那条路径吃掉**:`channel.ts` 现在在发卡**前**规划交付物,把行的渲染交给新模块;没有步骤卡承载正文的回退分支**只在真的有交付物时**补发 Turn Complete 卡(顺带补上 O3 里「回退分支不带 `turnStats`」的缺口)。 - **确认卡只被改一次**(点「发送」/「取消」后结算),Turn Complete 卡本身**只发不改**,因此不碰「带按钮的卡就地更新 2–3 次后回调失效」(关键坑 11);确认卡与随后的文件消息一律用**这张卡自己的话题**(硬规则 3 / 关键坑 31)——两层保证:规划时就把话题存进 token,同时 `channel.ts` 把交付卡的 message id **登记进 `card-topics`**(新增 `noteCardTopic` 接线),所以点击**不再依赖**「按群最近使用」的兜底(评审发现的缺口:这张卡此前从没登记过,多话题群里会把确认卡与文件发进另一个话题)。同一轮评审还顺手补了两个会在自动路径上咬人的旧隐患:**封面帧上传**(`im.v1.image.create`)此前没有超时上限;内联失败回退成文件消息时,若失败原因正是**我们自己的 120s 超时**则不再回退(超时不是拒绝,回退会把同一个文件发两次)。 - 新设置项落在 WebUI 插件设置页(`deliverDocMaxMb`,中英双语说明),`.volatile()` 热生效不重启。 **明确没做的**:① WebUI 触发的轮次**仍然没有任何交付卡**(完整卡只在飞书入站那条链路上发,O3 的剩余部分),本次只覆盖飞书触发的轮次;② **提示词层未动**——`ui-deliverables` 那条全局 system prompt 仍会让模型以为「已经交付了」(关键坑 52 / O25 的 C 方案),文档类现在至少会真的送达,但模型的行为没有被纠正;③ 清单仍最多显示 10 行(超出只计数),第 11 行起的文件不会自动投递也不会给按钮;④ 交付清单**有意不去重**(同一文件可能既内联又作为文件消息出现一次)。 **验证**:`npm run typecheck` 0 error;**829 tests / 47 files 全绿**(新增 42 例:`tests/feishu-deliver.spec.ts` 31 例覆盖分类 / 规划 / 自动投递上限 / 渲染 / 自动投递 / 去重 / 失败通知 / 点击确认 / 取消 / 串聊拒绝 / 文件消失 / 符号链接逃逸 / 过期与淘汰 token / 计划期话题优先 / 双击只发一次 / dispose;另加 `channel.ts` 接线 4 例、footer 渲染与媒体水合 2 例、配置与设置页钳制/一致性 3 例、共享发送器超时与工具记账 2 例);`npm run build` 产出 `lib/index.js` + `client/client.js`;两张新卡形态另经**真机 `cardkit.card.create` 探针**验证(`code=0`,只建实体不发消息)。**生效需要重启 dsh**。 **真机验证(2026-10-07 13:08–13:23,重启后)**:一轮真实对话里用 `present` 声明三份探针文件(PDF / PNG / Markdown),再用 `im.v1.message.list` **回读聊天**核对:① 13:09:02 的 Turn Complete 卡**第一个元素就是 `img`** ⇒ `still.png` **卡内内联**(媒体分支,且行上写了文件名);② 13:09:03 收到 **`file` 消息 `report.pdf`** ⇒ 文档分支**自动送达、无需任何点击**;③ 13:22:58 点击 `notes.md` 那一行(回调载荷实测只有 `{"p":"deliver-open","token":"…uuid…"}`,**不含路径**)→ 13:22:59 弹出 **「📤 文件投递」确认卡** → 13:23:03 按「📤 发送」→ 13:23:04 收到 **`file` 消息 `notes.md`**。三条分支(媒体内联 / 文档自动 / 其他确认)**全部真机通过**;客户端侧新设置项需刷新页面后可见(client bundle 已重建)。 **English**: `present` used to end at a line on the Turn Complete card, and that line cannot be tapped — a card has no file component (key pitfall 42), so 12 of 13 real `present` calls delivered nothing. Per the operator's 2026-10-07 decisions, delivery now splits three ways: **media** stays inline in the card via the existing hydrator (with the FILE NAME written out, since an inlined picture's alt text is only a tooltip); **office documents** (`pdf`/`doc`/`docx`/`ppt`/`pptx`/`xls`/`xlsx`) under the new `deliverDocMaxMb` setting (1–30 MB, default 30 = Feishu's file ceiling) are pushed as native file messages by themselves (at most **8 per turn**, `DELIVER_AUTO_CAP`) and labelled `auto-delivery` — deliberately not "sent", because the row is rendered before the push and a failure reports itself separately; **everything else** — and any document over the setting — becomes a tappable row (`interactive_container`, the workspace picker's whole-row shape) that opens a confirmation card, where only its Send button delivers. Files over 30 MB, missing files and paths outside the session workspace get an explanatory row instead of a dead button. A callback never carries a path: each row carries an opaque token (30-minute TTL, 200-entry cap) whose entry is re-validated on every tap — still present, a regular non-empty file, ≤30 MB, and still inside the workspace on its REAL path, so a symlink swapped in after the card was built is refused. Auto-delivery reuses the same `sendWorkspaceFile` the tool now uses, every send is bounded at 120 s (the old tool path was unbounded — a latent pitfall 29 hole), and `deliver-files.ts` suppresses a file the model already sent with `feishu_send_file` in the same session. The fallback reply path (no step card carried the text) now also posts the footer, but only when there really are deliverables. Confirmation cards settle with one update; the Turn Complete card is never edited, so pitfall 11's callback death does not apply, and both the confirmation and the file message resolve their topic from that card's own topic (pitfall 31) — the plan stores it in each token AND `channel.ts` now registers the delivery card's message id in `card-topics` via a new `noteCardTopic` hook, so the chat-level "most recent topic" guess is no longer relied on (this card had never been registered, which would have sent the confirmation and the file into another topic of a multi-topic group). The same review closed two older hazards on the automatic path: the cover-frame upload (`im.v1.image.create`) had no timeout, and an inline-failure fallback no longer runs when the failure was our own 120 s timeout (a timeout is not a rejection — retrying as a file would deliver the same file twice). Not done: WebUI-triggered turns still get no delivery card at all, and the global `ui-deliverables` prompt that tells the model `present` already delivered is untouched (pitfall 52). Verified with a clean typecheck, **829 tests in 47 files** (31 deliver-module cases plus 11 elsewhere), a production build, and a live `cardkit.card.create` probe of both new card shapes (`code=0`, entities only — no messages sent); a dsh restart is required for it to take effect. **Live verification (2026-10-07 13:08–13:23, after the restart)**: one real turn declared three probe files (PDF / PNG / Markdown) with `present`, and reading the chat back through `im.v1.message.list` confirmed all three branches — the Turn Complete card at 13:09:02 leads with an `img` element (the PNG inlined, with its file name written on the row), `report.pdf` arrived as a `file` message at 13:09:03 **with no tap at all**, and tapping the `notes.md` row (whose callback payload carried only `{"p":"deliver-open","token":""}` — no path) opened the confirmation card at 13:22:59, whose Send button delivered `notes.md` at 13:23:04. The new setting needs a page refresh to show up, since the client bundle was rebuilt. ### 文档:README / 设计文档按 rc.2 与已上线能力刷新(2026-10-02,零代码改动) 升级之后做的一次**文档体检**,找出并修掉三处已经过时的说法(都不涉及代码): - 🔴 **安装要求写错了整整一个大版本线**:README 仍写「要求 DSH `0.1.5-alpha.1` 或更高(同一 `0.1.5` 预发布线)」,而插件实际的 peer 范围早已是 **`^0.2.0-rc.1`**。更糟的是 DSH 0.1.7 起有**版本闸门**(peer 不满足就静默跳过加载),照着旧说明装会直接装不上。已改成「要求 DSH `0.2.0-rc.1` 或更高(同一 `0.2.0` 预发布线,已在 `0.2.0-rc.2` 实测)」,并说明同一 `0.2.0` 线内升 `rc.2` 无需改动。 - 🔴 **已上线的能力在 README 里查不到**:4 档工作展示(`transcriptView`,含「开关只能减不能加」的合成规则)、可折叠的思考面板与压缩摘要、**上下文注入提示行**、**压缩卡**(自动压缩与 `/compact` 都会发)——README 的能力表、`/display` 命令行、配置项说明都还是这批能力之前的样子(`/display` 只写了四个布尔开关,压缩那段还写着「本插件不订阅 compaction」)。中英双语表都已补齐,并新增 `transcriptView` 配置项说明。 - ⚠️ **一处会让人以为卡上有数的说法**:README 两处写「reasoning 标题带思考耗时与**思考 token**」。思考 token 取不到(见上一条结论),已改成只写耗时并指向原因。 - ℹ️ `docs/transcript-parity-design.md` 的基线从 `0.2.0-rc.1`(`4878cdabd8`)刷新到 `0.2.0-rc.2`(`639ed01539`):核实 `packages/llm/llm/src/message.ts` 在两版之间**零改动**(`MessageSourceMap` 仍在第 110 行),状态行从「§3 待拍板、尚未动代码」改为「三件事都已实现」,并在「已实现」表里补上折叠可见性那一项。**顺带查出一件新事**:rc.2 给 `MessageSourceMap` 加了第 29 个声明者 —— `user-questions` 的 `user-question-reply`(timed 问答的迟到答案),已记为坑 50 与 TODO;本部署 `Config.mode` 仍是 `legacy`,所以它还从未在真机上出现。 **English**: documentation-only pass after the rc.2 upgrade. The README's install requirement still named DSH `0.1.5-alpha.1` while the plugin's peer range has been `^0.2.0-rc.1` for a while — and since DSH 0.1.7 a mismatched peer range is silently skipped at load, so following the old line could not have worked. It now states DSH `0.2.0-rc.1`+ on the `0.2.0` prerelease line (verified on `0.2.0-rc.2`). The feature tables, the `/display` row and the configuration table now document what actually shipped: the four work-detail levels (with the rule that the old switches can only subtract), the folded reasoning panel and compaction summary, the context-injection notice line, and the compaction card. Two claims that reasoning tokens are shown were corrected — they are not obtainable. The parity design doc's baseline moved to `0.2.0-rc.2` and its status line now says the three gaps are closed; that review also surfaced DSH rc.2 adding a 29th `MessageSourceMap` producer (`user-question-reply`, the late-reply injection of timed questions), recorded as key pitfall 50 and a TODO — unreachable on this deployment because `Config.mode` is still `legacy`. ### 文档:跨平台兼容性审计入档(2026-10-05,零代码改动) 有人问「我们这个插件是否跨平台兼容」,于是做了一次**只读的跨平台审计**,结论与三处文档更新(**没有改任何 `src/` 或 `client/` 代码,因此不重建、不重启**): - ✅ **构造上跨平台,证据是硬的**:`src/` 里 `process.platform` 出现 **0 次**;没有 `/proc`、`/etc`、systemctl、`chmod`、`process.kill`、信号等 POSIX-only 调用;**没有硬编码的平台绝对路径**;只用了 `node:os`(`homedir`/`tmpdir`)、`node:path`、`node:fs/promises`、`node:child_process`(**仅**用于按裸名调 ffmpeg / ffprobe)。4 个运行时依赖全是纯 JS,无原生模块、无 install script、无 `os`/`cpu`/`engines` 约束;本包 `package.json` **也没有** `os`/`cpu`/`engines` ⇒ 任何平台都装得上。依赖树里 76 个平台限定包**全部是 `optional`**(esbuild / rolldown / rollup / node-addon),由 npm 按平台自选,且都在构建期。 - ✅ **Windows 意识早就在代码里**,不是这次推断的:`pwsh` 与 `bash` 归一到同一个摘要变体([`src/feishu-streaming.ts`]() 与 [`src/feishu-jobs.ts`]() 各有一处)、`~` 展开同时接受 `~/` 与 `~\`、vitest 配置用 `fileURLToPath(new URL(...))` 而非 `__dirname`。 - ⚠️ **但「构造兼容」不等于「验证过」**:完整链路(真机卡片渲染、媒体转码、宿主联调)**只在 Linux x64 跑过一次**。新增的 README「平台支持 / Platform support」一节把这句话写在了最显眼处,并按中英双语说明 **实测只有 Linux**、ffmpeg 是**可选**外部依赖(缺失只影响封面与音频转码,媒体引用退化为路径文字)。 - 🔴 **记一条待修(TODO O23)**:「聊天 → 会话」绑定表的路径是自己手拼的 `` `${homedir()}/.dsh` ``([`src/index.ts`]()),**没问宿主**。当前口径与上游 `resolveDshHome` 一致,**所以现在不是 bug**;但宿主已经把 `dshHomePath` 服务 provide 出来了,上游一旦改口径就会**静默写到另一个目录** ⇒ 重启后聊天退回默认会话,症状与 O13 一样却查不到原因。**未动代码**,等拍板。 - ⚪ **一个 Windows 上的已知退化**(失败方向安全,不会漏发工作区外文件):工作区包含判断是路径前缀比较,Windows 的大小写不敏感与 8.3 短名可能让工作区**内**的媒体判成区外、只留路径文字。 - 📋 **验证状态本身进 TODO(H5)**:别的平台跑一遍 `typecheck` + `test` 只能证明「装得上、测得过」,证不了卡片渲染;要真的算「支持」得在 macOS / Windows 上各起一次冷启动 + 一轮真实对话。 **English**: a read-only cross-platform audit, prompted by the question of whether this plugin is cross-platform. `src/` contains **zero** `process.platform` branches, no POSIX-only calls, no hardcoded platform paths, and all four runtime dependencies are pure JavaScript with no `os`/`cpu`/`engines` constraints (the 76 platform-constrained packages in the tree are all `optional` build-time entries npm picks per platform). Windows awareness was already in the code rather than inferred: `pwsh` and `bash` normalize to the same summary variant, `~` expansion accepts both separators, and the vitest config resolves paths with `fileURLToPath(new URL(...))`. But "compatible by construction" is not "verified": the full path has only ever run on Linux x64, and the new README **Platform support** section now says so in both languages, also recording that ffmpeg is an *optional* external dependency. Two findings became TODOs — the chat→session binding path is hand-assembled instead of asking the host's `dshHomePath` service (not a bug today, since the resolution currently matches upstream, but a silent-divergence risk), and the fact that no non-Linux platform has been verified at all. No `src/` or `client/` code changed, so nothing was rebuilt and dsh was not restarted. ### 文档:`present` 交付缺口入档(2026-10-06,零代码改动) 有人问「present 工具,是不是我们插件没实现兼容」,于是把这条从头核了一遍。结论是**部分兼容,而且缺口恰好落在 `present` 真正被用的那一类文件上**(记入 TODO O25 + 关键坑 52,**没有改任何 `src/`,不需要 build / 重启**): - ✅ **事件层是接了的**:订阅 `deliverables/presented`,按 path 去重收集(单轮上限 16),渲染成 Turn Complete 卡第一个元素 `📦 交付文件(N)`(≤6 条 + 「另 N 个」)。 - ✅ **媒体类交付真能到用户手里**:`png` / `mp4` / `ogg` 的条目走既有 hydrator 真上传、在卡内内联。 - ❌ **文档类交付只落到一行点不动的纯文本**:`docx` / `xlsx` / `pdf` / `zip` / `md` / `csv` / `ts` 会被 `linkFallback()` 降级成 `描述 · \`path\``。根因是卡片能力边界(卡片没有文件/附件组件,只能走原生文件消息),**不是渲染 bug**。 - ❌ **提示词层我们零干预,模型被误导**:WebUI 的 `ui-deliverables` 在 host 半注册了一条**全局** system prompt(「Each presented file adds a card below the reply, with preview and native-open actions」),**不区分客户端**,飞书会话同样收到;而飞书既没有 `native-open`(那是 WebUI 的宿主桌面路由)也没有文档预览 ⇒ 模型以为已经交付了。 - 📊 **真机数据(3 个飞书会话 / 444 轮)**:`present` 调用 **13 次**,其中**只有 1 次**真正送达(那一轮模型自己额外调了 `feishu_send_file`);13 次全是非媒体(`.md`/`.sh`/`.csv`/`.tsx`/`.ts`),媒体 **0 次**。 - 🧭 **三个候选修法(当时建议「卡片加『发送文件』按钮 + 给飞书会话注入一段说明」,两者都没做)**:卡片按钮(路径须校验「本会话本轮声明过 + 在工作区内」)、收到事件后自动推送(有重复/刷屏与排序风险)、或只注入一段提示让模型改用 `feishu_send_file`。 **English**: a documentation-only record of the `present` (deliverables) compatibility review, prompted by the question of whether the plugin is compatible with DSH's `present` tool. The event is consumed and rendered on the Turn Complete card, and media deliverables (image/video/audio) are genuinely uploaded and inlined; but every non-media deliverable — the exact file types `present` is actually used for — degrades to an inert `description · \`path\`` text line, because Feishu cards have no attachment component. Worse, the WebUI's `ui-deliverables` host half registers a **global** system-prompt section promising "a card below the reply, with preview and native-open actions", which reaches Feishu sessions too and is not corrected anywhere in this plugin. A scan of three Feishu sessions (444 turns) found 13 `present` calls, exactly one of which reached the user (that turn also called `feishu_send_file` explicitly), and all 13 were non-media. Three candidate fixes were identified; the operator chose to record the finding for now. Recorded as TODO O25 and pitfall 52; no `src/` or `client/` code changed. ### DSH 适配:0.2.0-rc.1 → 0.2.0-rc.2(2026-10-02,插件零必改) > 本跳 **187 commits / 1022 文件、同一 `0.2.0` tuple、零包增删**,是 rc.1 以来以**桌面端/客户端**为主的小版本。完整逐符号/逐事件核查、三轨彩排、切换与实测见本机 `notes/upgrade-0.2.0-rc.2.md`。 - ✅ **插件零必改**:被 import 的 18 个宿主包 + 客户端 5 个包导出**零删除**,插件用到的 **30 个事件名逐个仍在**。 - ✅ **依赖范围有意保持在 `^0.2.0-rc.1`**(同 tuple 覆盖 rc.2,semver 与运行时 peer 闸门都实测通过);升到 `^0.2.0-rc.2` 反而会让**回滚到 rc.1 被闸门拒**。只把 `package-lock.json` 刷新到 rc.2(提交 `3d009d5`,零源码改动、产物逐字节一致),使本地 typecheck / **787 用例 / 46 文件**跑在真正运行的那版上。 - ⚠️ 两个有源码改动的相关包:`user-questions` 新增**实验性异步问答**(`tool-ask-user` 行 `Config.mode` 默认 `'legacy'` ⇒ 本部署行为不变);`llm-pi-ai` 升到 **pi-ai 0.87.1**(compat 字段有增删,本部署用到的 `forceAdaptiveThinking`/`reasoningEfforts` 均不受影响,冷启动彩排已验证补丁整体解析通过)。 - ✅ **先彩排后切换、三轨全绿**(宿主构建 / 插件全新装 787 用例 / 真实补丁隔离 `DSH_HOME` 冷启动且 0 peer denial);**正式切换一次跑通**:`TARGET_OK=1 PLUGIN_OK=1 ROLLED_BACK=0`,`NRestarts=0`,session map 与停服前逐项一致,journal 无 error、**peer denial 0**。 - ⚠️ 如实记录一次**与升级无关的瞬时网络抖动**(`20:11` 两次到飞书的 `ECONNRESET`,插件重试后送达,此后 0 条 `sdk error`)——非 rc.2 回归。 ### 结论(无代码改动):推理面板的 `· N tokens` 为什么永远不出现(2026-09-29) 使用者问「思考块还是获取不了 token 数目吗」。答案是**拿不到,而且不是插件的 bug**,所以这一条**不改代码**(`renderStepCard` 里「有 `usage.reasoningTokens` 就显示」的分支保留,作为等上游的出口)。三环取证: 1. **最上游就不给**:本部署飞书会话跑 MiniMax-M3.1-Flash-Preview(pi-ai 的 anthropic-messages 协议,`https://api.minimaxi.com/anthropic`)。直连原始接口(返回体里确实有 `type:"thinking"` 块),`usage` 只有 `input_tokens` / `output_tokens` / `cache_creation_input_tokens` / `cache_read_input_tokens` —— **没有思考明细**,思考被算进 `output_tokens`。 2. **pi-ai 只给部分协议填 `usage.reasoning`**:`@earendil-works/pi-ai@0.85.1` 的 `Usage.reasoning` 只在 `openai-completions` / `openai-responses` / `google-vertex` / `google-generative-ai` 里赋值,**anthropic-messages 不填**。 3. **DSH 的适配层也不映射**:`packages/llm/llm-pi-ai/src/stream.ts` 的 `mapUsage()` 与 `packages/llm/llm-deepseek/src/translate.ts` 的 `updateUsage()` 都只带 input/output/cache/total —— 全仓库 `grep -rn "reasoningTokens:" packages apps`(排除测试)**没有任何生产者**;最近 7 天 12 个会话日志里 `"reasoningTokens"` 命中 **0** 次。 ⇒ 卡片上那个 `· N tokens` 是**永不执行**的「等上游」代码;真机读回同一张卡也确认 header 只有耗时(`💬 **推理** · 1.3s(▸ 已折叠 · 点击展开)`)。 **曾评估、未采用**:显示估算值 `≈ N tokens`。实测标定(直连 MiniMax,比较 `output_tokens` 与 thinking 文本长度)显示字符/token 在 **2.5–4.3** 之间浮动(短链路固定开销占比大),误差约 ±40%;且**中文提问时 thinking 文本常常是英文**,「中文 1 字≈1 token」的直觉不成立。使用者最终选择**保持现状**,所以既不做估算、也不把 `output_tokens` 当思考量(那里面还含正文与工具调用)。**排查口诀**:要显示某个用量字段前,先 `grep -rn "<字段>:" packages apps` 确认**真有人生产**它。 **English**: no code change — the reasoning header's `· N tokens` can never appear, and it is not a plugin bug. (1) The provider does not report it: a raw call to the MiniMax Anthropic endpoint this deployment uses (the response really does carry `type:"thinking"` blocks) returns `usage` with only `input_tokens` / `output_tokens` / `cache_creation_input_tokens` / `cache_read_input_tokens`. (2) pi-ai fills `Usage.reasoning` only for its `openai-completions`, `openai-responses`, `google-vertex` and `google-generative-ai` APIs, not for `anthropic-messages`. (3) DSH's adapters do not map it either (`mapUsage()` in the pi-ai adapter, `updateUsage()` in the DeepSeek one), and a repository-wide grep finds **no producer** of `reasoningTokens` outside tests; 12 session logs from the last week contain zero occurrences. The branch therefore stays as a dormant "waiting on upstream" path. An estimated `≈ N tokens` was evaluated and rejected: calibration against the real API put the ratio at 2.5–4.3 chars/token (±40%), and MiniMax's thinking text is frequently English even for Chinese prompts. Rule of thumb recorded for next time: before displaying a usage field, grep for its producer. ### 修复:折叠面板现在一眼能看出「已折叠」(`src/card-fold.ts`(新)/ `src/feishu-streaming.ts` / `src/feishu-compaction.ts` / `src/i18n.ts`,2026-09-29) **问题**(使用者原话):「思考块看不出来是已经折叠的样子。不了解的人不知道这个东西已经折叠了」——压缩卡的摘要面板同理。这是收起态的**结构性**事实:面板收起时只渲染 `header`,而光秃秃的 header 就是一个「小标题」的样子;WebUI 的同类行有 chevron,飞书这张卡什么都没有。 **修法**(两条一起上:一条靠我们说,一条靠客户端画): 1. **文案**:面板 header 标题结尾统一加 `(▸ 已折叠 · 点击展开)`(英文 ` (▸ Collapsed · click to expand)`)——写成 i18n 函数 `cardFoldTitle(title)`,因为中文不要空格、英文要。这是**唯一客户端丢不掉**的信号。 2. **外观**:面板加 `border: {color:'grey-300', corner_radius:'4px'}`,header 加 `background_color: 'grey-100'`——有边框、有底色的 header 才像「一块可点的东西」,而不是「一行标题」。 推理面板与压缩摘要面板改走同一个 `foldedPanel()`(`src/card-fold.ts`),值只存在一处,不会再各自漂移。 **取证口径(重要,已写进关键坑 48)**:`cardkit.card.create` **静默容忍未知字段**——往 `collapsible_panel.header` 里塞 `bogus_xyz` 照样 `code=0`。所以「建卡成功」**不能**证明字段存在,**只有「某个值被拒」才算**: | 字段 | 通过 | 被拒 | |---|---|---| | `border.color` | 命名色 token(`grey` / `grey-300` / `blue` / `neutral`) | `#DEE0E3`、`rgba(...)` → `11310 collapsible panel border color is invalid` | | `header.background_color` | 同一套命名色 token(`grey-50`…`grey-300`、`blue`、`yellow`…) | hex / rgba → `11310 … header background color is invalid`;**对象** `{light,dark}` → `10002 expected string for background_color` | | `border.corner_radius` | `4px` / `8px` / `0px` | `4`、`50%`、`4pt` → `10002 invalid corner radius` | | `header.padding` | `4px`,或四个值 `4px 8px 4px 8px` | `4px 8px`、`4`、对象 → `10002 invalid panel header padding` | | `expanded` | `true` / `false` | 字符串 → `10002 bad variable formmat` | | `header.icon` | `{tag:'standard_icon',…}`(**token 不校验**,乱写也过 ⇒ 不可用) | `{tag:'custom_icon', img_key:'img_v3_…伪造'}` → `200570 card contains invalid image keys` | 端到端再校一遍:把**插件真实渲染出来的**产物——zh/en 各一张步骤卡(折叠推理 + 工具参数/结果 + 页脚)、压缩卡的 summary 与 done 两态——直接打给 `cardkit.card.create`,四张全部 `code=0`(脚本 `~/.dsh/probe-real-fold-cards.py`,渲染走插件的 `vite-node`)——防止「手写探针能过、产物不能过」。完整表与脚本清单见 [`docs/feishu-capabilities.md`](./docs/feishu-capabilities.md) §2.3.2。 **验证**:`npm run typecheck` 0 error;**787 tests / 46 files 全绿**(新增 `tests/card-fold.spec.ts` 5 例,并把「色值只能是命名 token / 半径只能是 px」写进断言,防止以后有人手滑改成 hex);`npm run build` 通过,`lib/index.js` 已重建(本批不动客户端,`client/client.js` 无 diff)。**真机验证(2026-09-29 02:21:33 重启后)**:读回线上卡片(`im.v1.messages.get?card_msg_content_type=user_card_content`)确认三个字段都到了服务端渲染态——`collapsible_panel expanded=false border={grey-300, 4px} header.background_color=grey-100 title=💬 **推理** · 1.3s(▸ 已折叠 · 点击展开)`;journal 0 条 error / `230020`。 **English**: a folded panel must look folded. A collapsed `collapsible_panel` renders only its header, and a bare header reads as a section title — nothing suggests there is more behind it (the Web UI's equivalent row draws a chevron; this card had nothing). The fix is two-layered: the header title now always ends with `(▸ 已折叠 · 点击展开)` / ` (▸ Collapsed · click to expand)` via an i18n function (`cardFoldTitle`, because CJK takes no space and Latin does), and the panel is drawn as a bordered, tinted block (`border {grey-300, 4px}` + `header.background_color: grey-100`) so the header reads as a control. Both folds (reasoning and compaction summary) now share one `foldedPanel()` builder. The evidence rule matters here and is recorded as key pitfall 48: `cardkit.card.create` **silently tolerates unknown fields** (a `bogus_xyz` inside `header` still returns `code=0`), so a successful create proves nothing — only a *rejected value* does (named colour tokens only: hex/rgba are refused with `11310` and an object-valued `background_color` with `10002`; `corner_radius` is px-only; `header.padding` takes one or four px values). The plugin's real render output for four cards (zh/en step cards, compaction summary and done) was then fed straight to `cardkit.card.create` — all `code=0`. `npm run typecheck` clean, **787 tests across 46 files** green (5 new in `tests/card-fold.spec.ts`, which also pins the probed value constraints), `npm run build` rebuilt `lib/index.js` with no client diff. Live check after the 02:21:33 restart: reading a real card back (`im.v1.messages.get?card_msg_content_type=user_card_content`) shows `collapsible_panel expanded=false border={grey-300, 4px} header.background_color=grey-100 title=💬 **推理** · 1.3s(▸ 已折叠 · 点击展开)`, with zero error / `230020` lines in the journal. **上线与实测(2026-09-23,两次重启)**: - **上线**:`49df1ba`(L1+L2)+ `0b82880`(L3)于 18:36:14 重启生效(`MainPID` 4193368 → 384812);`e914bc9`(`cardImages` 设置)于 20:10:45 重启生效(→ 529495)。两次都 `systemctl` active、journal 里**卡片相关的 error / `invalid image keys` / `message handling failed` 均为 0**;产物核对 `lib/index.js` 与 `client/client.js` 都含 `cardImages`。 - **L3 真机端到端(使用者要求"不走 `feishu_send_file`、直接内联")**:在工作区内造了一张 120×120 蓝白棋盘格 PNG(`notes/inline-probe.png`,297 字节,放在 gitignore 的 `notes/` 下,工作区保持干净),然后**只在回答正文里**写 `![内联探针 · 蓝白棋盘格](notes/inline-probe.png)`: - journal:`21:26:01 dsh-feishu: [img] uploaded notes/inline-probe.png → img_v3_0215q_b271840d-…` —— **整个回合只上传一次**(步骤卡期间被 update 多次,命中的是 `path+mtime+size` 缓存),且**没有**任何 `card image … left as text`; - **服务端回读**(`im.v1.messages` + `card_msg_content_type=user_card_content`):最近 20 条卡片里有 **2 张**的正文已经是 `![内联探针 · 蓝白棋盘格](img_v3_0215q_b271840d-…)`,即卡片确实被改写成了真 key,而不是只上传成功; - **使用者目视确认**:卡片里直接显示了那张棋盘格图片。⇒ L3 从"相对路径 → 工作区解析 → 上传 → 内联渲染"整条链路成立,全程未调用 `feishu_send_file`。 - **仍未真机触发过的路径**(如实记录):L2 的"飞书仍拒收 → 剥净重试 → 回退 patch"——L1 已经覆盖了实测到的全部非法形态,重启后 0 次拒收,所以这三步目前只有单测(`tests/card-write.spec.ts` 的 12 例)覆盖,还没有活体样本。 ### 新增:四档「工作步骤展示」+ 推理可折叠 + 压缩卡 + 注入提示行(O22 / O5 / O21,`src/display-level.ts`(新)/ `src/injection-notice.ts`(新)/ `src/feishu-compaction.ts`(新)/ `src/feishu-streaming.ts` / `src/config.ts` / `src/commands.ts` / `src/feishu-display.ts` / `src/i18n.ts` / `src/client/`,2026-09-29) 一批四件事,全部围绕「飞书里能看到多少、以及看不到的那两件事」。调研与逐条实测见 [`docs/transcript-parity-design.md`]()。 **① 四档显示级别(对齐 WebUI 的 `ui-chat.transcriptView`,O22)** 新增设置项 `transcriptView`:`compact|standard|detailed|verbose`,**默认 `detailed`**,文案与 WebUI 逐字一致(简洁 / 标准 / 详细 / 完全展开);`/display <档位>` 与 `/display` 卡片顶部四个按钮都能切换,WebUI 设置页也新增同名下拉框。历史写法 `normal` / `expanded` 一律**按 `detailed` 读**(0.2.0 自己的读法),非法值也退回默认。 - 档位**只改渲染轴**,不比对枚举:`compact` = 推理不显示 + 工具只报计数;`standard` = 推理首行 + 每个工具一行摘要(无参数、无结果);`detailed` = 推理 200 字折叠 + 参数 + 结果(**与加档位之前完全相同**);`verbose` = 推理全文 + 参数/结果放宽到 4 倍上限。 - **档位是基线,四个老开关只能「减」不能「加」**(用 AND 合成):任何档位下 `showReasoning=false` 都等于「推理 off」,`detailed` 下 `showToolArgs=false` 仍生效。这样升级不会把用户关掉的东西又打开,也不需要再造一个 `custom` 档。 - **飞书侧不做「合并卡片」**:WebUI 的 `foldCompletedTurns` / `stepGrouping` 两轴在飞书没有视觉等价物(一个 step 只能一张卡,关键坑 12),所以 `compact` 只是把卡片缩成状态行,**不合并一轮里的多张卡**——合并会让交付正文归属变得含混。 - 一处诚实说明:`verbose` 的「不截断」是**把上限提到 4 倍**(结果 1500→6000、参数 2000→6000 字符),不是真的无限——卡片 JSON 过 ~30KB 会被整张拒收(关键坑 42),而**被拒收的卡什么都不显示**,所以上界必须留。 **② 推理可折叠(O5,`collapsible_panel` 首次真机使用)** 推理段从「明文代码块 + 开头 200 字」改成**默认收起、点开看全文**的折叠面板:头部就是原来的 `💬 **推理** · 耗时 · tokens`(外加推理时长与思考 token 数),正文在面板里。 - 组件名是 **`collapsible_panel`**(2.0 的名字);1.0 时代的 `collapsible` 会被服务端以 `10002 not support tag` 拒收——本机文档不可达,这条是用**只建实体、不发送**的 `cardkit.v1.card.create` 探针 + **伪造 tag 对照**实测出来的(见 `docs/feishu-capabilities.md` §2.3.1)。 - **顺带解决了信息损失**:以前砍到 200 字是因为「不折叠会占满屏」;收起来之后 `detailed` 仍给 200 字(与旧观感一致)、`verbose` 给全文。 - 已知小限制:卡片**就地更新会重写整张卡的 JSON**,客户端本地的展开状态可能被重置(一步通常只剩 1–2 次写入);压缩卡同理。 **③ 压缩卡(O21 第②件)** 以前自动压缩与 `/compact` 在聊天里**完全没有迹象**(插件根本没订阅 `compaction/*`),使用者不知道模型刚丢掉了几百条历史。现在一次压缩是一张可更新卡: ``` 🗜️ 正在压缩上下文… ← compaction/start,grey,写明触发方式(自动 / /compact) 🗜️ 上下文已压缩 ← compaction/summary,green 已折叠 609 条历史记录 · 约 302K tokens · provider/model ← 详情行在折叠区【之上】 摘要(▸ 已折叠 · 点击展开) ← collapsible_panel,expanded:false,正文上限 200 字;灰底+边框(2026-09-29 追加) ⚠️ 上下文压缩失败 + 原因 + 「本次没有产生摘要」 ← compaction/end 带 error 时,red ``` - **不假设「start 之后必有 summary」**:失败路径(`compaction/end.error`)没有摘要;`compaction/prune`(无模型的纯裁剪)整场都没有 `summary` 事件——两者都按实际有的事实渲染,没有正文时明说「这次压缩没有摘要内容」。 - **没有「把完整摘要另发文件」按钮**(使用者已否掉);摘要超 200 字即截断。 - 写入走同一套 CardKit 实例更新(一次一张、序列递增);**卡片完全发不出去时退回纯文本**一行(关键坑 40:报告写入失败的东西不能又是卡片)。 **④ 上下文注入提示行(O21 第①件)** DSH 的上下文注入是持久事件(统一落在 `user/message`,`data.source.kind !== 'user'`),来源是**开放集合**;以前插件只认 `tool-jobs`,其余**全部静默丢弃**,所以「这轮被喂了什么」在飞书完全看不到。现在每条注入在**它所归属的那张 step 卡的正文最前面**加**一行提示**: - 只显示**元数据**,**绝不显示注入内容**(`content` 是给模型看的,实测 `AGENTS.md` 基线一条 6.2k 字); - 例如 `📥 注入上下文 · AGENTS.md(已更新)`、`📥 注入上下文 · 技能目录(12 条)`、`📥 注入上下文 · 插件 @deepseek-ai/dsh-system-prompt`、`🔗 跨会话召回 · …`、`✉️ 会话间消息 · 来自会话 …`、`🤖 子代理已结算 · …`;未知 kind 只报种类,**不 dump 原始字段**(将来的生产者不能借这个通道往卡片上贴任意文本); - **`compact-checkpoint` 与 `tool-jobs` 静默**:前者由压缩卡负责,后者已有「本回合由后台任务完成通知触发」标记(关键坑 37),不能重复; - **位置规则**:注入总是先于它所归属的那次请求落盘(实测 93.0% 落在 `step/start` 之后、`request/header` 之前),所以「插到卡的最前面」既是语义正确、也是「这一次请求确实读到了它」的可视证据;剩下 6.8% 落在 `step/start` **之前**(全是压缩事务里的插件重注入)→ **先进会话级缓冲**,等那个 step 建卡时并入首版(放在 step 状态里会被 `resetStep` 清掉,这正是要缓冲的原因);提案里「追加到卡片末尾」那一档在持久事件里**实测 0 条**,故不实现。 - 卡片总数**不增加一张**;有注入的 step 一定有卡(`step/start` 的建卡是无条件的,已就地注释)。 **同时改动**:`/display` 文本命令新增档位形式(`/display compact`),`/display` 后不带参数会先报当前档位;`src/commands.ts` 的 `DisplayControl` 增加 `getLevel` / `setLevel`(档位与四个开关写的是同一批 `lark-channel` 配置字段,三个入口永远同源);WebUI 设置页新增「工作步骤展示」下拉框(`transcriptView`)。 **验证**:`npm run typecheck` 0 error(含 client);**782 tests / 45 files 全绿**(新增 **51 例**:三个新模块自带 35 例——档位→策略映射与 legacy 读法、注入提示行(**用真机会话日志里的真实 `source` 载荷**逐个核对字段名)、压缩卡渲染与控制器(顺序、序列、话题、纯文本兜底、`stop()` 后不再开卡);`feishu-streaming.spec.ts` 新增 13 例折叠/档位/注入/压缩接线;`config.spec.ts` 1 例、`client.spec.ts` 2 例);`npm run build` 通过,`lib/index.js` 与受跟踪的 `client/client.js` 均已重建。另跑了一次**只建实体、不发送**的整卡探针(`~/.dsh/probe-step-card-panel.py`,用例与结果见 [`docs/feishu-capabilities.md`](./docs/feishu-capabilities.md) §2.3.1):本批产出的**步骤卡**(注入提示行 + 折叠推理面板 + 正文 + 工具参数/结果 + 页脚)与**压缩卡**都被 CardKit 接受(`code=0`),把面板换回 1.0 的 `collapsible` 则被拒(`10002`)——对照组有效。**真机验证(2026-09-29 01:55 重启后)**:按 09-23 那次内联媒体验证用过的同一招读回线上卡片(`im.v1.messages` + **`card_msg_content_type=user_card_content`**;不加这个参数只会拿到降级内容、只有 title 是新的),**一张真实步骤卡同时确认三件事**——`📥 注入上下文 · AGENTS.md(已更新)` 在**正文最前面**且卡内没有注入正文(由一次真实的 `agent-instructions/replace` 注入触发)、推理面板 `collapsible_panel` + **`expanded:false`(默认收起真的生效)**、默认「详细」档的工具段完整;元素顺序与页脚正确,写入链 seq 1→4 完好、无 `230020`。WebUI 半边也已核对:启动页拼出的插件包里含 `transcriptView*` 全部键与「工作步骤展示」文案。**仍未真机验**:压缩卡三态(需一次真压缩)、`/display compact|verbose` 的观感切换。 **English**: four things in one batch. (1) A new `transcriptView` setting mirroring the WebUI's `ui-chat.transcriptView` (`compact`/`standard`/`detailed`/`verbose`, default `detailed`, same wording), switchable from `/display `, from four buttons on the `/display` card, and from the WebUI settings page; the two WebUI legacy spellings (`normal`, `expanded`) both read as `detailed` and anything unreadable falls back to the default. A level only projects onto render axes (compact = no reasoning text + tool counts; standard = reasoning first line + one summary line per call; detailed = the exact pre-level behavior; verbose = whole reasoning + 4x payload caps), and it is a BASELINE: the four existing switches are ANDed in and can only ever remove detail, so an upgrade cannot re-enable something a deployment had turned off. Feishu deliberately does NOT merge cards across a turn — the WebUI's folding axes have no visual equivalent when one step is one card. "Untruncated" at `verbose` honestly means four times the cap, because a card over ~30 KB is refused whole and would show nothing. (2) Reasoning is now a collapsed `collapsible_panel` (the 1.0-era `collapsible` tag is refused with `10002 not support tag` — verified with a send-free `cardkit.card.create` probe plus a bogus-tag control), which also lifts the information loss: the 200-character cut only ever existed because an unfolded chain filled the screen. (3) A compaction card finally reports what used to be invisible: `compaction/start` opens it, `compaction/summary` adds a details line above a folded, 200-character-capped summary, and `compaction/end` closes it green or red with the failure reason and an explicit "no summary was produced" — never assuming a summary exists, since neither a failed attempt nor a `compaction/prune` emits one. (4) Every injected context message now adds ONE metadata-only notice line at the top of the step card whose request reads it (`compact-checkpoint` and `tool-jobs` stay silent, unknown kinds are named but never dumped), buffered session-side when it arrives before `step/start` (6.8%, all of them compaction re-injections) so `resetStep` cannot lose it; the proposed "append before the footer" position has zero occurrences in the durable log and is not implemented. Typecheck clean, 782 tests across 45 files pass (51 new, using real `source` payloads copied from this machine's session logs), artifacts rebuilt. ### 变更:步骤卡区分「请求还没发出 / 已发出等待 / 思考中 / 回复中」(O20 方案 2,`src/feishu-streaming.ts` / `src/i18n.ts`,2026-09-29) **问题**(使用者原话):「卡片就是在模型输出首字之前的那个卡片,和模型开始思考的卡片要区分开来」。占位卡在 `step/start` 就发出,而那一刻 DSH **还没组装、更没发出**请求,标题却写着「🧠 深度思考中…」——该说「等待」的时候说「思考」;等模型真的开始思考时,插件手里**没有任何实时信号**(0.1.3 起 token 级增量不再是持久事件,`assistant/chunk` 已死,代码里那段是只累加不发卡的历史遗留),只能等整条消息落地后一次性写进同一张卡,标题此刻已经是「回复」——该说「思考」的时候又说了「回复」。 **修法(方案 2:接实时流,只用来切阶段;内容仍等 `assistant/message`)**: 1. **订阅瞬时发布 `agent/assistant-stream`**:注册 `{ global: true }`(它是 agent-scoped 的 `emit`,与子代理名册同一套写法),按 `payload.agent.session.id` 关联会话,并沿用既有的 `bridge.resolveChat` 门控——只服务已绑定飞书聊天的会话(关键坑 31)。帧序 `start → chunk… → end`,按 attempt 发布。 2. **相位机(一次尝试内只前进)**:`start` → **`awaiting`**(请求已发出、还没拿到 token);首个 `reasoning-delta` → **`thinking`**;首个 `text-delta` → **`replying`**;`end` 且 `outcome.kind === 'abandoned'`(重试 / 中断)→ 退回占位,等重试自己的 `start`。迟到的 reasoning delta **不会**把「回复中」改回「思考中」。 3. **文案**:占位改成 `⏳ 正在分析请求 · 第 N 轮 · 第 M 步` + 「请求还没发出,模型尚未收到这一轮的内容」,把「深度思考」从这个阶段彻底拿掉;新增 `📡 已发出请求,等待输出…` / `🧠 思考中…` / `✍️ 回复中`(zh + en)。 4. **等待阶段带 route**:`awaiting` 时在卡上补一行 `📡 provider / model`,取自持久的 `request/context`(路由变化时才有该事件,所以按会话记住、不按步清空),回答「这次请求发给了谁」。 5. **只重绘没有内容的卡**:相位只在卡的「无内容」形态上生效——内容一旦落地,标题仍由既有规则(回复 / 工具进行中 / 工具完成 / 工具出错 / 本步无输出)决定,迟到的帧影响不到已结算的卡。 6. **生命周期**:退订挂在 `stop()`;`step/end` 之后不再理会该步的帧;`step/start` / `turn/end` 复位相位。 **写入纪律不变**:相位切换走既有的 `updateStepCard`(按卡防抖、一卡同时只允许一次写入在飞、只保留最新一版),每步最多多 2–3 次写入;卡仍是**一个 step 一张**(关键坑 12),相位切换**只更新不重发**。 **验证**:typecheck 0 error;**729 tests / 42 files 全绿**,其中新增 **9 例**——4 例单元(四相位的标题与正文、route 只在等待阶段出现、有内容时相位被忽略)+ 5 例接线(一张卡走完 占位→等待→思考→回复、迟到推理不倒退、`abandoned` 退回后再 `start`、非绑定会话的帧被忽略、`step/end` 后与 `stop()` 不再响应)。测试替身同步改成**按事件名**登记监听器,避免把瞬时帧喂给持久事件的处理器。产物已重建(`lib/index.js`;`client/client.js` 逐字节未变)。 **English**: the step card used to say "Thinking…" the moment `step/start` arrived — before DSH had even built the request — and then say "Reply" while the model was actually thinking, because no token delta is a durable session event any more (`assistant/chunk` was removed in 0.1.3). The card now carries a live phase fed by the transient `agent/assistant-stream` publication, subscribed `{ global: true }` and attributed through `payload.agent.session.id`, gated on a bound Feishu chat. Phase machine: the attempt's `start` frame means "request sent, awaiting output", the first reasoning delta means thinking, the first text delta means replying, and an `abandoned` end returns to the placeholder for the retry that follows; within one attempt the phase only moves forward. The pre-request placeholder is now "Analyzing request" plus "the request has not been sent", the awaiting card names the route from the durable `request/context`, and the phase only ever repaints a card that still has no content, so settled steps are untouched. Phases ride the existing debounced per-card update path with latest-wins coalescing, keep one card per step, and are unsubscribed on `stop()`. Typecheck clean, 729 tests across 42 files pass (9 new), artifacts rebuilt. ### 修复:最后一张步骤卡停在「深度思考中」——卡片发送自己等自己 60 秒(`src/feishu-streaming.ts` / `src/index.ts` / `src/card-write.ts`,2026-09-24) **现象**(使用者报告):「刚才有个会话的最后卡片又卡在思考了,turn complete 卡片出来了以后那个卡片都没有最终更新」。journal 里这次留下了完整链条(19:30:27–19:31:28): 1. `[send] card instance create failed: Client network socket disconnected before secure TLS connection was established` —— CardKit 建卡掉线,走 `channel.send` 兜底; 2. 之后 30 秒每 5 秒一条 `[update] no card instance, falling back to patch`(占位卡的计时 tick),全部悬在 `ref.messageId` 上; 3. `pending card flush gave up: … step-card flush before send timed out after 60000ms` **紧跟** `[send] message=om_… via send` —— 卡片比它本该出现的时间晚了整整 **60.000s**(= `CARD_BARRIER_TIMEOUT_MS`);随后 **7 次** `[update] patch` 在同一秒齐发,飞书回 `230020 This operation triggers the frequency limit, ext=Update the single messages too frequently`,载着这一版答案的那次写入被拒(400);turn 随即结束,`[reply] a step card carried the text → footer only` —— 插件以为正文已上屏,于是只发了 footer,那张卡永远停在「🧠 深度思考中」。 **根因(自锁死)**:`actuallySendStepCard` 在**发起 send 之前**就把这条 send 的 promise 登记进 `lastCardSendByChat`(这是"后一张卡必须等前一张卡的消息建好"的顺序保证),而 `cardChannel.send` 走 `cardBarrier.run(...)`,它的 flush 正是「flush 本聊天还没发的防抖卡 + `await lastCardSendByChat.get(chatId)`」。于是**步骤卡自己的兜底 send 在等自己**:只有「由 flush 内部触发的 send」(`flushPendingSend → actuallySendStepCard`)被屏障的 `flushing` 集合豁免,而由防抖定时器直接触发的那条路径不在其中 → 一直卡到 barrier 超时。首选的 `sendCardByReference` 不走屏障,所以这条路径平时看不见。 **修法(三处,另加一处韧性)**: 1. **步骤卡自己的 send 不再过屏障**:新增 `cardChannel.sendStepCard`(`index.ts`,只保留 HTTP 外层的有界超时),流式侧的兜底分支用 `channel.sendStepCard ?? channel.send`。这与首选的 `sendCardByReference` 同一档位;「与前一张卡的顺序」本来就由调用方的 `previousSettled` 保证(`resetStep` 会先 `flushPendingSend`,所以前一张卡一定已登记进链)。 2. **一张卡同时只允许一次写入在飞,且只保留最新一张**:`writeSlots`(按 `StepCardRef`)。在飞时到达的渲染只记住最新那张,被顶掉的旧渲染直接以 `false` 结束——它的内容更旧,重放只会制造 `230020`。慢写入 + 5 秒 tick 因此不再堆积成突发。 3. **送达判据从「有文本落地过」改成「最新一版文本落地了」**:`turnTextOrder`(`{next, delivered, failed}`)按**构建顺序**(内容新旧)盖戳,而不是完成顺序;被顶掉的写入不盖戳。`flushed()` 只在 `delivered > 0 && failed <= delivered` 时保留乐观标记,否则撤回 → `channel.ts` 照发回复卡。旧的布尔标记会把「第一步落地、最后一步被拒」判成"已送达",于是最后一版答案静默消失。 4. **建卡传输失败重试一次**:`createCardInstanceWithRetry`(`card-write.ts`)。内容类拒收不重试(card writer 已经剥图重试过)。一次掉线不再把这张卡**永久**降到 `patch` 路径——而 `patch` 正是有 `230020` 频率限制的那条路。 **验证**:typecheck 0 error、**720 tests / 42 files** 全绿,产物已重建(`lib/index.js` 含 `sendStepCard`/`writeSlots`/`turnTextOrder`;`client/client.js` 逐字节未变)。新增 4 例回归(`tests/feishu-streaming.spec.ts`「the step card must not wait on the ordering barrier it belongs to」)+ 3 例重试(`tests/card-write.spec.ts`)。**反向验证**:三例在修前全红——真实 barrier 装配下 raw send **调用 0 次**(自锁死)、并发渲染产生 **4 次**写入而不是 1 次、被拒后 `clearIntermediateSent` **0 次**。 **English**: the last step card of a turn could stay on "🧠 深度思考中" after the Turn Complete card had already been posted. The journal shows the whole chain: the CardKit instance create dropped its socket, the fallback send wedged for exactly 60.000s (`CARD_BARRIER_TIMEOUT_MS`) because it was waiting on the chat's own step-card send chain — which held the very promise that send was inside — then the seven updates scheduled in between were released in one burst, Feishu answered `230020` (per-message edit frequency) and the write carrying the answer was refused. Three changes: the step card's own send now goes through `cardChannel.sendStepCard`, which is deliberately not barrier-wrapped (ordering against the previous card is already guaranteed by the caller's `previousSettled`, and the preferred `sendCardByReference` path never used the barrier either); card writes are serialized per card with latest-wins coalescing (`writeSlots`), so a slow write plus the 5s placeholder tick can no longer pile up into a burst; and the delivery record became `turnTextOrder`, a content-order stamp of the newest text-carrying write that landed versus the newest one refused, because a plain "some text landed" boolean judged "first step landed, last step refused" as delivered and silently lost the final answer. A dropped socket during the instance create is also retried once (`createCardInstanceWithRetry`) instead of permanently downgrading that card to the rate-limited patch path. All 720 tests pass, and each of the three regressions fails before the fix. ### 新增:三个媒体**大小**上限分开设置;修 `feishu_send_file` 的图片兜底(`src/config.ts` / `src/card-images.ts` / `src/feishu-send-file.ts` / `src/client/`,2026-09-24) **动机**(使用者的要求):「增加设置自动内联的媒体大小,默认 15mb。三个媒体分开设置,注意输入范围检查」,以及「顺便把刚才那个发图片的修了」(TODO O15)。 **做法**: - 新增三个配置字段,各自带范围(`.volatile()`,保存即生效):`cardImageMaxMb` **1–10,默认 10**、`cardVideoMaxMb` **1–30,默认 15**、`cardAudioMaxMb` **1–30,默认 15**。**图片的默认值是 10 而不是 15**:飞书 `im.v1.image.create` 的硬上限就是 10MB,给 15 只会是个永远碰不到的数字(图片转成卡片图片元素必须走这个接口);视频/音频走 `im.v1.file.create`,30MB。所以三个范围的上界都是**平台边界**,不是我们的口味;视频/音频默认 15 是刻意的——超过它的代价是**等**(上传发生在写卡路径上),不是被拒收。 - 判定改成**按分类后的类型**逐类比上限:以前是"图片一个上限、其余一切共享一个上限",而且 ISO-BMFF 的检查发生在分类之前。现在 ISO-BMFF 先分类(video/audio)再各比各的上限;`inspect()` 的返回值也分出第三种结果 `{ refused: 'size' }`,与"根本不是媒体"区分开。 - **顺带堵住一个洞**:Opus/WAV/MP3/FLAC 这些非 ISO-BMFF 音频**从来没有字节检查**(旧的 `maxFileBytes` 只作用在 ISO-BMFF 分支),一个 100MB 的 WAV 会被转码并上传。现在同样受 `cardAudioMaxMb` 约束。 - **超限文件不再写「检过但不是媒体」的负缓存**(加设置时发现的交互 bug):那张负缓存按 `path+mtime+size` 命中就直接跳过,于是**把上限改大不会生效**,要等缓存淘汰或文件变动。现在只有"真的不是媒体"才记负缓存;超限的每次重新判(代价是一次 64 字节头读,不是上传)。用例直接锁死这条:先用 1KB 上限跑一遍、再抬到 10KB 跑第二张卡,必须内联出来。 - **`feishu_send_file` 的图片补上 `{file}` 兜底**(O15):工具里视频/音频本来就有 `sendWithFileFallback`,图片却是直接 `ch.send({ image })`——一张 ≤10MB 但超过分辨率上限的图(GIF > 2000×2000、其他 > 12000×12000,`234039`)会让整次调用抛错、文件一份都不落地,而官方文档对这类图的口径正是「请用上传文件接口以文件形式发送」。现在图片也走同一条兜底,`sent_as` 如实报 `file`;工具描述同步写明超分辨率也算。 **验证**:typecheck 0 error、**713 tests / 42 files** 全绿。新增:`tests/config.spec.ts` 2 例(三个默认值 + 逐类钳制:999→上界、0→下界、小数取整、`NaN`/空→该类默认)、`tests/card-images.spec.ts` 3 例(各类各比各的上限、超限的**非 ISO 音频**也被拦、上限**实时重读**所以改大下一张卡就生效)、`tests/client.spec.ts` 2 例(三个输入框各自的 min/max 与默认值、保存写出**数字**且越界/清空分别落到上界/默认)、`tests/feishu-send-file.spec.ts` 1 例(图片被拒 → 仍以 `file` 送达、`sent_as: 'file'`)。**反向验证**:把图片分支还原成旧的直接 `ch.send({image})`,用例立刻转红(`Failed to send "tall.png" via Feishu: … 234039 …`),恢复后转绿。 **English**:The auto-inline SIZE limit became three settings, one per media kind, each with its own range: `cardImageMaxMb` (1-10, default 10), `cardVideoMaxMb` and `cardAudioMaxMb` (1-30, default 15). The image default is 10 rather than 15 because that is Feishu's own ceiling — a card image must come from `im.v1.image.create`, which refuses more than 10 MB, so a larger field value could never be reached; video/audio go through `im.v1.file.create` (30 MB). The hydrator now measures each file against the ceiling of its CLASSIFIED kind (ISO-BMFF is classified before the check), which also closed a hole: Opus/WAV/MP3/FLAC had no byte check at all, so an oversized WAV was transcoded and uploaded regardless. Over-ceiling files are no longer written to the "inspected, not media" negative cache — that entry is keyed on path+mtime+size and short-circuited the next card, so raising a limit would not have taken effect until the entry was evicted. And `feishu_send_file` gained the file fallback its image branch was missing: a picture under 10 MB but over Feishu's resolution ceiling (GIF > 2000x2000, anything else > 12000x12000, `234039`) used to fail the whole call and deliver nothing, where the API's own advice is to send it as a file; it now arrives as an attachment with `sent_as: 'file'`, and reverting the branch turns the new test red. ### 新增:单卡媒体上限做成设置项(`src/config.ts` / `src/card-images.ts` / `src/index.ts` / `src/client/`,2026-09-24) **动机**(使用者的要求):「这个媒体文件的数量限制也在设置页面做一个」——上一批把视频/音频上限从 2 提到 12,但那个数字是代码里的常量,改它要发版。 **做法**:新增配置字段 `cardMediaLimit`(`1–30`,默认 `12`,`.volatile()` ⇒ 保存即生效、不重启、不打断正在跑的 turn)。语义与设置页文案一致:**图片算一处、视频与音频合起来算一处(各按这个数)**——这样一份图片很多的回答不会把同一条消息里的视频挤掉。hydrator 通过 `mediaLimit: () => currentSettings().cardMediaLimit` **每次判定时实时读**,所以下一次卡片写入就用新值;`claim()` 里两个计数各自对着这个数判。客户端是自包含的(不 import host 模块,否则会把 zod 和 config schema 打进浏览器包),所以 `CARD_MEDIA_LIMIT` 的边界在 `src/client/LarkSettingsCardController.ts` 里镜像了一份,真正校验的是 host schema;客户端 `writeValue` 再把暂存的文本转成**数字**(`999 → 30`、空 → 回到默认 12 而不是写最小值)。上限取 30 不是随意的:30 个视频 = 90 个计名额 + 它们拆出的文本段,仍在飞书 200 元素预算内,不变量测试就按**设置的上限**跑。 **验证**:typecheck 0 error、**704 tests / 42 files** 全绿(新增 `tests/config.spec.ts` 2 例:默认值/取值 + 越界钳制;`tests/client.spec.ts` 2 例:输入框渲染与 min/max、保存写出**数字**、越界与清空分别落到 30 / 默认;`tests/card-images.spec.ts` 2 例:上限**实时**生效、按组各算一份而不是一个共享池;最坏情况不变量测试改用设置的上限)。 **English**:The per-card media limit is now a setting, `cardMediaLimit` (1-30, default 12, `.volatile()` so a save applies without a restart and without killing a running turn). It counts per group — images on the one hand, video and audio together on the other — so an image-heavy reply cannot starve its own clips. The hydrator reads it live on every card write, and the worst-case card-budget test now runs at the setting's maximum, which is why 30 is the ceiling (30 videos spend 90 counted elements plus the text segments around them, still inside Feishu's 200). The client bundle mirrors the bounds because it deliberately imports no host module. ### 修复:卡片内联媒体的三个缺口——单卡只放 2 个、交付清单走不到内联、溢出续卡不过 hydrator(`src/card-images.ts` / `src/channel.ts` / `src/feishu-streaming.ts`,2026-09-24) **现象**(使用者报告):① 一张卡里的视频超过两个就不再内联,只剩路径文字;② 末尾那张 Turn Complete(交付文件)卡完全不渲染媒体。 **先查清"这是插件的限制还是飞书的限制"**(使用者的第一个要求):真机打 `cardkit.v1.card.create`(只建卡片实体、不发消息),并带**负对照**证明探针能测出拒绝: | 卡片内容 | 元素数 | 卡片字节 | 结果 | |---|---|---|---| | 2 / 8 / 16 / 32 / 64 个视频(带封面+fallback) | 3–65 | 0.7–19.3 KB | ✅ 全部接受 | | **66 个视频** | 67 | 19.8 KB | ✅ 接受 | | **67 个视频** | 68 | 20.2 KB | ❌ `300305 element exceeds the limit` | | 198 个视频(不带封面/fallback) | 199 | 25.5 KB | ✅ 接受 | | 199 个内联 `