← Back to home@stone-brick

dsh-report-ledger

DSH 插件:把「汇报」做成代理之间的一等交互原语 —— 可追溯的传递路径账本、回执/结案/修正,以及一个会话时间线标签页。

Stars
0
Language
TypeScript
Created
Sep 24, 2026
Updated
Sep 28, 2026
GitHub repo

Introduction

dsh-report-ledger

一个 DSH 插件:把「汇报」做成代理之间的一等交互原语,并为长期协作留下可追溯的账本。

安装

dsh plugin --profile web add dsh-report-ledger

这条命令把包装进该 profile 的 node_modules;因为包声明了 dsh.bundle.patch, dsh plugin 会自动把 dsh-report-ledger 追加进 profile 的 dsh.profile.bundles, 不需要手改任何配置文件。装完重启 profile(dsh web)即可生效。

不启动也能验证装上了:

dsh --profile web --dump-config     # 配置树里应出现 report-ledger 这一行

卸载走同一条通道,依赖与该配置层会一起移除:

dsh plugin --profile web remove dsh-report-ledger

国内镜像(Gitee)

源码与发行版同步在 gitee.com/stone_zhan/dsh-report-ledger, 不经过 GitHub 也能装:

# 1) 下载发行版附件(预构建产物,不需要构建工具链)
#    https://gitee.com/stone_zhan/dsh-report-ledger/releases/download/v0.1.0/dsh-report-ledger-0.1.0.tgz
dsh plugin --profile web add ./dsh-report-ledger-0.1.0.tgz

# 2) 或直接从 Gitee 安装(安装时自动构建,约 15 秒)
dsh plugin --profile web add git+https://gitee.com/stone_zhan/dsh-report-ledger.git

GitHub 每次推送后由 .github/workflows/mirror-to-gitee.yml 自动镜像 main 与 tags。 注意:npm 安装走的是 npm 镜像站而不是 GitHub,所以 npm publish 之后上面这条一行命令 才是国内用户最省事的路;Gitee 镜像解决的是「拿不到 GitHub」时的源码与产物可达性。

装完你会得到什么

  • 宿主半(任何 profile):十个 report_* 工具 + peer_list / peer_start、 两段 systemPrompt 段,以及 web profile 下的两个只读 HTTP 端点;
  • 浏览器半(仅 web profile):会话头部多一个「汇报」标签页 —— 时间线、状态芯片、 搜索、线程跳转与就地展开的传递路径。

兼容性、权限与数据

  • DSH 0.1.5-rc.3 实测可用(构建产物与该版本的平台模块表对齐)。宿主半的运行时 外部依赖只有一个:@deepseek-ai/dsh-tools,由 profile 提供;浏览器半 require react、react/jsx-runtime 与 @deepseek-ai/dsh-client-ui-primitives(面板里的 按钮、芯片、悬停、状态点、输入框与正文渲染全部用它,与内置的 Chat / 轨迹视图同一套), 其余全部内联。宿主半在 headless profile 里同样可用(没有 web server 时只是少了那两个端点)。 ⚠️ 于是浏览器半多了一条硬依赖:shell 的冻结模块表里必须有 dsh-client-ui-primitives。它自带的对话视图就依赖它,所以缺了它意味着那个 shell 本身已经坏了;真缺的话失败模式是「汇报标签页不出现」而不是整个 GUI 崩。
  • 账本是本机文件:$DSH_HOME/report-ledger/(可用 DSH_REPORT_LEDGER_ROOT 覆盖),不上传任何地方。
  • 浏览器读数据走两个 GET 路由,注册在 DSH 的 web server 上,守卫仅放行 loopback 对端与 loopback Host;细节与信任假设见下文「守卫与信任假设」。
  • 同伴工具会新建根会话(在你自己的工作目录下),这是本插件唯一会新增活代理的能力, 每次创建都记进名册日志,并受 maxPeersPerAgent(默认 8)约束。

English quick start

dsh plugin --profile web add dsh-report-ledger   # install + register the bundle layer
dsh --profile web --dump-config                  # verify: a `report-ledger` row appears
dsh web                                          # restart the profile to load it

Tested against DSH 0.1.5-rc.3. The ledger is plain files under $DSH_HOME/report-ledger/ and nothing leaves the machine. The host half works in any profile; the web profile additionally gets a Reports tab in the GUI.

下面是用户安装。本仓库自身的开发装法(junction + hmr 热重载)见末尾 「开发与验证」。

为什么需要它

DSH 原本的代理间通信是相邻 Agent 的 steer 投递(ctx.subagents.sendMessage),它明确承认这些缺口:

  • 只能投给直接子代理或直接父代理,兄弟、祖辈、无关同伴一律 UNAUTHORIZED;
  • 没有持久 mailbox:父代理不在线时消息被拒绝,而不是"先接受、后送达";
  • 没有抄送、没有优先级、没有回执、没有线程、更没有传递路径记录(AgentMessageSource 只带一个 senderSessionId)。

本插件补上的是最后那一块地基。它不新造会话事件类型(那会让会话日志在重载时不可读,见下),而是复用 harness 已有的白名单词汇与公开 API。

核心设计

汇报是两层结构。

  • front matter 是缩略:主题、收发、抄送、共写者、跳数、最后跳。它是唯一进入模型上下文的部分,因此长期协作的上下文开销是有界的。
  • 正文留在账本里,由 report_read 按需打开(超长时只返回头部与文件定位,模型用 read 分页取余下部分)。

传递路径是权威的。 每次操作都追加一跳(append-only JSONL),因此:

  • to / cc / authors / hops / last 都是从跳流派生的缓存,任何下一条跳都会重新校正它——账本不可能显示历史里没有的收件人;
  • 待投递集合也由跳流派生:被投递过(sent/cc/forwarded/copied)但没有到达(delivered)的收件人即为待投递。审计轨迹本身就是那个缺失的持久 mailbox,所以它天然跨重启存活,且不可能与历史不一致。

投递走公开的 Agent 入口。 模型工具受"精确相邻"约束,但 Agent.steer/inject 只接收一条消息、不做授权检查,而任何活 Agent 都可由 ctx.agents.get(id) 取到。因此:

  • 主送(to)用 steer——空闲目标会因此开启一个回合;
  • 抄送(cc)用 inject——进入上下文但不唤醒任何人;
  • 非驻留收件人不会被拒绝,而是留在待投递集合里,等 agent/created 事件到来时自动送达。

账本布局

根目录 $DSH_HOME/report-ledger/(可用 DSH_REPORT_LEDGER_ROOT 显式覆盖,便于部署迁移与隔离测试):

reports/R-0001.md            front matter 缩略 + 正文
reports/R-0001.route.jsonl   append-only 传递路径,一行一跳

选择文件而非私有数据库,是因为账本是一段协作的长期记忆:它必须可被人直接检查、手工修正(写错主题、补一句 hop 说明都不需要迁移),而 front matter 让"只读缩略"不必打开正文。

工具

工具作用
report_author开一份汇报,可同时主送 to 与抄送 cc,可用 parent 关联上溯汇报
report_contribute以共同作者身份追加自己的一节(互不覆盖,各自记为独立跳)
report_send主送给更多代理(唤醒):向上回报、向下派发
report_cc抄送给更多代理(不唤醒):让需要知情者持有副本
report_forward转呈下去,或 mode:"copy" 作为参考副本分发
report_read读缩略 + 完整传递路径 + 正文(读取本身也记一跳)
report_list只列缩略,可按 session、status、task 过滤,用于纵览长期协作
report_ack回执,让发送方看到闭环
report_amend修正:改缩略字段或更正正文,仅发起者/共写者可改,改动会被记录
report_close结案:事情的终结,仅发起者/共写者可关

修正:记录,而不是抹掉

一个 agent 写错了主题时,原本只有两个坏选择:重开一份新汇报(丢掉原有传递路径与收件人),或者让错误留着。而人手改文件又能改——这个不对称是实现漏洞,不是设计取舍。

难点在于:账本的价值来自历史只增不改;如果 agent 能静默重写记录,账本就不再可信。所以 report_amend 的规则是修正必须被记录:

对象语义理由
缩略字段(subject / task / artifacts)替换,跳里逐字记录改前改后的值它们是当前状态,不是历史;而"从什么改成了什么"正是审计要的
正文默认追加一段 ### amendment正文记录的是人说过的话,改写别人的话就不是记录了
正文(replace_body: true)真替换,且跳里注明"body replaced"有时确实需要重写;那就让后人知道被重写过、被谁重写,而不是被误导

收件人与共写者永远不能在这里改——它们由跳流派生,唯一的修改途径就是再追加一跳。task 传空串可以清除标签(避免出现"空标签"与"无标签"两种含义)。

修正属于活动,所以给已结案的汇报做修正会自动重开它并记录 reopened。

实测效果:一个写了错别字的主题被改正后,落盘是

front matter:  subject: "the corrected title"          ← 当前真相
传递路径:      amended  # subject "teh wrong speling…" -> "the corrected title"   ← 历史

汇报的生命周期

三个状态:open → acked → closed。

动作谁能做效果
report_ack任何收件人记一跳;open → acked。回执 = "我收到了"
report_close仅发起者或共写者记一跳;→ closed。结案 = "这件事结束了"
贡献 / 主送 / 抄送 / 转呈任何有权限者自动重开:先记 reopened 跳,closed → open,随后才落下活动本身那一跳

两个刻意的设计决定:

1. 结案是拥有者的事,不是读者的事。 只有发起者或共写者能关闭;被拒绝时错误信息会报出该找谁关(列出作者),而不是只说一句"不行"——这样代理知道下一步该做什么。from 与 authors 都算拥有者,因为手改过的账本可能缺对应跳,而拒绝明显的主人会让汇报永远悬着。

2. closed 是状态,不是锁。 如果有人在关闭后又贡献/主送/抄送/转呈,那这件事显然又活了,于是自动重开并记录 reopened 跳。否则一份被过早关闭的汇报会静默吞掉后续工作——对一份以可追溯为全部意义的账本来说,这是最糟的失败模式。

所以不需要单独的"重开"工具:活动本身就是重开。回执与结案也是刻意不合并的两件事:回执表示"我收到了",事情仍在推进;结案表示"这件事结束了"。对已关闭的汇报回执只记跳,既不重开也不降级为 acked。

状态机每一次转换都落在 append-only 路径里:

authored → closed(note) → reopened → contributed

未来读者看到的是过程,而不只是当前终态。

提示词:协议与伙伴契约

插件注入两段 systemPrompt.section,这个拆分是有意的:

段名字 / 顺序求值方式理由
协议plugin:report-ledger / 200静态字符串对所有 agent 逐字节相同,因此是各 scope 里稳定的提示词前缀(KV cache 友好)
伙伴契约plugin:report-ledger:partnership / 201按 assembly 求值的函数契约要对两种读者说不同的话

协议段说明账本是什么、八个工具各做什么,以及两条会改变行为的约定:收件方不在线不是错误(投递会被持有);路径由账本自动记录,但每一步都必须经工具完成。

伙伴契约段分两种读者:

  • 对所有 agent:明确否定"工具"框架——其他代理不是「做完一项任务就可以终止的工具」,而是长期相处的同伴;一次任务的结束是这段关系的逗号而不是句号;交接用汇报而非口信;收到汇报要回执;兄弟代理之间也可以互相抄送。
  • 仅对被委派的子代理:你的会话是持久的、不是一次性函数调用;主动汇报,不要默默结束;你的父会话 id 是 X,可直接主送到它;权限范围启动即固定,需要越界时把限制写进汇报而不是反复重试。

角色判定是同步且精确的,来自 Session.header(origin === 'subagent' 或 delegationDepth > 0)——持久、首次组装时就在,无需查询、无需缓存、无异步竞态。子代理的父会话 id 也由此取得并直接内联进提示词,因为不给出这个地址,"向上汇报"就只是一句无法执行的口号。

时间线标签页

conversation.view 槽位里注册一个新 id 的视图(不覆盖已发布的 Chat / Trajectory),浏览器把 list 型槽位投影成会话头部的标签页。

页面渲染一条自上而下的时间线,把两类事件按时间合并:

  • 会话分叉:子树里每个会话一行,按深度缩进,标注「子代理 / 在线」;
  • 汇报出现:每份汇报一张缩略卡(状态、主题、收发、抄送、跳数、最后动作),点击就地展开完整传递路径 + 正文 + 仍在等待送达的收件人。鼠标点整行即可;键盘走左侧箭头——那是一个真按钮,带 aria-expanded 与 aria-controls,名字是「展开 R-0001 / 收起 R-0001」。

筛选与搜索

汇报多起来之后时间线需要收窄,所以工具栏提供:

  • 状态芯片:全部 / 进行中 / 已回执 / 已结案,标签里带数量。数量取自完整载荷而非筛选后的行——一个自己会变的标签会让你看不出到底排除了多少。
  • 搜索框:匹配汇报编号、主题、发起者 id 与名称、主送、抄送、共写者、任务标签、产物路径,以及会话标题与编号。
  • 清除按钮(仅在筛选生效时出现),状态选择记在 localStorage,搜索文字刻意不持久化。

两条刻意的规则:

状态筛选只作用于汇报;搜索作用于每一行。 会话没有生命周期状态,所以状态芯片不会隐藏会话——时间线的骨架始终在,缩进也就始终有意义(汇报的 depth 来自它的发起会话,与会话行是否被渲染无关)。

空状态分两种。 「这个会话树下还没有汇报」与「没有符合当前筛选的汇报」是不同的话,混用会让人以为账本坏了。判定下沉在 timeline-model.ts 的 emptyState() 里,由测试钉住:前者按「载荷里有没有汇报」判,后者按「筛选后还剩几行汇报」判——只数汇报行,因为会话行按设计不受状态芯片影响,把它们算进去会让一棵满是会话的树自称「你把结果筛没了」。账本为空时永远说第一种:此时筛选不可能是原因,归咎于它只会让人去找一个自己从没设过的筛选。

⚠️ 这里踩过一次:初版把「树下还没有汇报」挂在「一行都没有」上,而只要有会话就至少有那一行会话——于是这句话永远不出现,空账本渲染成一片没有任何解释的空白。空状态的判据必须落在载荷上,不能落在渲染出来的行上。

行构建与筛选逻辑都在 src/client/timeline-model.ts —— 一个不依赖 React 与 DOM 的纯模块。这样"哪些行出现、搜索匹配什么、芯片怎么计数"这些用户直接感受到的决定能用确定性测试钉住,而不必靠看浏览器;视图只负责渲染。

缩略卡上的身份与用词

id 缩短,全值在悬停里。 会话 id 是 36 字符,而一份汇报的收件人是一串 id,于是未缩写的缩略卡几乎全是 uuid,人能用的信息接近于零。所以密集行显示前 8 位(01edba72),悬停给出「会话标题 · 完整 id」;详情面板保留完整 id,因为那是有人要复制 id 的地方,而缩略卡不是。会话行也带上自己的短 id,于是卡片上的 from=/to= 能读回它属于哪棵树。实测副作用是好的:改短之后 7 张卡的 meta 行没有一行再触发省略号——overflow: hidden + text-overflow: ellipsis 从日常现象退回成安全网;而这层安全网仍然必要,因为收件人数量没有上限,宁可截断也不能让一行把整个标签页顶宽。

不拿会话标题当行标签,尽管它更「像人话」:fromName 取自会话标题,而标题是会话的第一句提示词——它没有长度上限,有时比它替换掉的 id 还长(本仓库里的子代理标题正是 You are doing READ-ONLY research,四个子代理一模一样)。把定长但不可读的 id 换成不定长且会重复的标题,只是把一种不可读换成另一种,所以名字放在悬停里、放在会话行上,不放在密集行里。

词表只有一份。 卡片上的状态芯片复用工具栏筛选芯片同一批键(进行中 / 已回执 / 已结案):它们本来就在命名同一个状态,给它们两套词(卡上写 open、工具栏写 进行中)等于把建映射的活推给读者,而那个映射本身就是缺陷。跳动作同理,12 个动作各有词(发起 / 共写 / 主送 / 送达 / 抄送 / 转呈 / 副本 / 阅读 / 回执 / 修正 / 结案 / 重开),由 HOP_LABEL 对动作全集取 Record 保证「新增一个动作不可能没有词」。账本文件与模型看到的仍是原文 token——只有人的视图说人话。

用平台的组件库,而不是手搓

面板里每一个可见控件都来自 shell 的 @deepseek-ai/dsh-client-ui-primitives(它在 shell 的冻结模块表里,tsdown.config.ts 早已把它列为 external):Button(刷新、清除、时间基准、线程跳转)、Pill(状态筛选芯片、任务快捷筛选——它给 onClick 就是真按钮,不给就是纯标签)、Tag(状态与「子代理 / 在线」)、Tooltip(短 id 与各动作的悬停说明)、StateDot(在线会话是动画的 ongoing、待送达是 warning、错误是 error)、Input(搜索)、MarkdownText(正文)、以及图标(刷新、折叠箭头、复制、勾选)。手搓的颜色、hover、focus、暗色适配因此全部不再由本插件负责。

合法取值是读 CSS 读出来的,不是猜的。 组件的实现里只用到了部分枚举(Tag 的 tone 在代码里只出现 solid/neutral),但壳里真正渲染的是 CSS 里的 [data-tone=…] / [data-state=…] 规则——那里定义的是 8 个 tone(outline / neutral / quiet / solid / info / success / warning / danger)与 6 个 state(ongoing / idle / done / warning / error / failed)。所以状态映射是:进行中 → warning、已回执 → success、已结案 → neutral;会话在线 → ongoing。只按代码里的用法去猜,会以为只有三种语气色。

组件不吃 style,吃 className,而且落在它自己的 wrapper 上。 于是本插件唯一自己写的 CSS 是一行布局:把工具栏的搜索框撑满(client/styles.ts)。规则挂我们自己的类名,绝不碰 hash 类名——那是会随 shell 版本变的东西。

顺手换来的一个能力:账本路径旁边多了复制按钮,走 shell 的 writeClipboard(而不是 navigator.clipboard),这样它继承的是整个 GUI 都在用的那条降级链。

⚠️ 汇报行故意没有换成 DisclosureRow,尽管它正好是「一行摘要 + 展开正文」的形状,而且已发布的视图用了 8 次。原因写在 ReportsView 里:它的两种模式各和已经提交的东西冲突一处。expandOnRowClick: true 时整行是 role="button"——行内的任务芯片立刻变成嵌套交互内容,而且可访问名由整棵子树计算(读屏会把整行 id 列表念一遍)。expandOnRowClick: false 时前导按钮换成一个库内部的按钮,它的可访问名由那个图标决定,而展开后图标会被替换掉,名字随之消失;同时整行点击也没有了。所以行外壳保留自己的实现(普通容器承担鼠标便捷点击、具名展开按钮承担键盘路径、面板是兄弟节点所以点正文不会收起),里面的控件全部换成库组件。要用 DisclosureRow 的话,代价是把「点任务标签即分组」降级成展开后再点——这是个产品取舍,不是技术限制。

拓扑总览:竖向泳道

列表上方是一张竖向泳道图:一条泳道 = 一个会话(列),时间向下(行),每一份汇报画成发起者泳道上的一个节点,它的传递路径画成跨泳道的边。

(这一版已不在标签页里渲染:卡片画布接手了图表位,见下面「下一版拓扑」;TopologyView.tsx 与 topology-model.ts 保留在仓库里、不再被引入,因此不进 bundle,它的几何仍由 scripts/topology-check.ts 钉着。下面这段记的是它验证过的东西——结论全部沿用,换掉的只有"节点不承载内容"这一件事。)

泳道(会话, 树序) →   afcbce3a │ 01edba72 │ 05286f38 │ …  │ 树外
时间 ↓                        │          │          │    │
  19:00  ●R-0001 ─────▶──▶──▶┄┄▶  (主送实线、抄送点线)
  20:30            └╌╌●R-0002 ──▶  (线程竖向、回执向回)
  19:45                        ●R-0003 ┄┄┄┄┄┄┄┄┄┄┄┄▶

为什么是竖向、为什么不需要图引擎。 这个标签页本来就纵向滚动,时间向下与下面列表的阅读方向一致;而布局不需要任何图算法——泳道序就是子树 DFS 序(列表缩进用的同一个序),行序就是时间序(列表排序用的同一个序),两个 rank 都是现成的。通用引擎要解的是交叉最小化与端口路由,是这份数据没有的问题,却要为此付出:elkjs 是 EPL-2.0/GPL 且解包 8 MB,d3-dag 虽是 MIT 但为最优交叉最小化拖进一个线性规划求解器,@xyflow/react 1.2 MB 且自带一套样式表。所以这一版零新增依赖,几何由 src/client/topology-model.ts 这个纯模块给出。

它是什么、不是什么。 它是总览,不是第二个阅读面:节点点击后滚到列表里那张卡片并展开——正文、传递路径、待送达仍在同一个地方读。这样图不必处理 4000 字的正文,也不承担"键盘/读屏唯一入口"的责任:列表一直在,图只是加在它上面。

树外会话有一条共享泳道。 被 cc 进来的、或发起者在别的树里的汇报,如果没有这条泳道就无处落点;它们全部落在最后一列 树外。

这条横向滚动是刻意的。 视图自己有一个受控的滚动框(宽出即可横向滚动、高限 46vh),与之前那个"内容撑破整个标签页、滚动条只在最底部够得着"的意外滚动条不同:这里是包在一个框里的。默认几何(泳道 120px、沟槽 84px)是照"八条泳道刚好放进一个标签页"定的(实测 1108px,无需横向滚动)。

布局被 34 条断言钉住:泳道序等于子树序、节点落在发起者泳道、边落在收件人泳道且同一行、重复收件人只画一条、发给自己的不画、树外落点归到共享泳道、线程边只在线程两端都被画出时才存在、空输入不产生 NaN、同一输入给出逐字节相同的几何(见 scripts/topology-check.ts)。

图例必需,逐边文字不必需。 四条线型如果没人解释,读者只能猜——所以标题旁有图例,而且它的样例用的就是画线的同一批 class(edgeClass()),改了线的样子不可能留下一个说谎的图例。但每条边都挂文字是重复的:线型已经编码了主送/抄送/共写。所以文字只在悬停某一份汇报时出现,回答的是"我正在追的这条路径是什么关系"。这也正是 Mermaid 泳道文档里 "Label Cross-Lane Handoffs" 想说的那件事,只是它的载体是线型 + 悬停,不是常驻标签。

线程走泳道之间的缝隙。 父子连线跨行,直接画曲线会穿过中间的行与节点;所以模型为它算出 viaX——一条泳道边界,节点都居中在泳道里,边界离任何节点都有半个泳道远,连线因此完全不碰节点。这条规则也进了断言(边界必须严格落在两列中心之间、且从不落在任何一列的中心上)。

规模:布局是免费的,渲染才是成本

在一份 300 份汇报的账本上量过(隔离实例,真实浏览器):

量的是什么结果
纯布局(500 泳道 × 5000 汇报 × 7916 条边)3.4 ms(20 次中位数)——布局永远不是瓶颈
渲染元素(300 汇报,改之前)5469 个:838 条边各自一个 <path> + 838 个箭头 <polygon> + 300 条分隔线…
悬停一次(改之前)89 ms —— 每次 mouseenter 要改 831 个 <g> 的透明度

两处修法都有实测回报:

  1. 同类边合并成一条 <path>,箭头改用 SVG marker——边本来就不可点击,一条边一个元素只买到"每次重绘一个形状"。箭头是路径的装饰而不是节点,交给 marker 后额外元素为零。
  2. 淡出只作用于"边的那一层",绝不走后代选择器。 这是最有价值的一条:单独翻写一个被后代选择器匹配的属性([data-hot] … :not(…))本身就要 35 ms,因为浏览器要为整棵子树重算样式,哪怕最终没有任何可见变化(我用注入 CSS 把结果改成 !important 覆盖也降不下来——覆盖只改结果,不减重算)。所以节点干脆不淡出:追踪一条路径时该退到后面的是关系,不是参与者。

结果:元素 5469 → 2689、边路径 838 → 10、箭头多边形 834 → 0、悬停 89 → 44 ms(最好的一次 16–19 ms,即一帧)。

  1. 窗口化:只渲染滚动框里真正看得见的那几行(上下各留 4 行 overscan)。300 份汇报时任意时刻只渲染 14–20 行,元素 2689 → 265、悬停 44 → 32 ms——而 32 ms 就是测法的地板(每次采样等两帧,60 Hz 下约 33 ms),也就是已经没有可测的开销了。四个滚动位置都验过窗口覆盖视口(顶部/中部/底部/偏后),粘性表头在位,没有空白带。

⚠️ 窗口化引入的每个派生列表都必须依赖窗口。 我把节点列表的数据源从 layout.nodes 换成 shownNodes,却忘了把它加进 useMemo 的依赖数组——结果是边与时间轴跟着滚动,只有节点不动,看起来像"滚了但内容没换"。这类 bug 在浏览器里一眼可见(四种滚动位置一测就抓到),而七套断言全都发现不了:纯模型没有错,错的是"React 有没有重算"。所以窗口化的断言只覆盖"哪些行该在窗口里",覆盖不到这一层,这一点要说清楚。

下一版拓扑:卡片画布(参考与实测)

泳道版证明了"关系和归属可以画出来",但它有个致命弱点:节点是芯片,不承载内容——一份汇报在图里只剩一个 R-0001。下一版改成卡片图,两个决定已经定了:

  • 会话 = 框(蓝图里 Comment 那种:带标题、底色,可整体折叠),归属靠包含表达,不靠位置;
  • 全 canvas 渲染(不是 SVG、不是 DOM)。

卡片图的设计:帧内按时间堆叠汇报卡片;线有方向与语义——主送实线(卡片出 → 对方帧的收件口)、抄送点线、共写虚线(共写者帧 → 卡片入)、线程蓝线(父卡下缘 → 子卡上缘)。三档 LOD:完整卡(≥0.8)→ 紧凑卡(0.4–0.8)→ 色条(<0.4),配矩形剔除。

⚠️ 一条硬规矩:卡片尺寸是 LOD 常量,绝不由文字度量决定。 否则布局就依赖 canvas 度量、变成不可测的东西,而"缩放到全局时有多少个绘制对象"恰恰是这套设计唯一站得住的标准。文字在绘制时裁剪/省略。

参考里真正可用的部分

参考学到什么
Unreal 蓝图卡片节点的解剖(标题栏 + 引脚 + 有类型的线)与 Comment 框做分组。(⚠️ Epic 的文档页是 JS 渲染的,抓不到正文——这部分是通行认知,不是引用)
draw.io / maxGraph① 它用的是 SVG 不是 canvas:packages/core/src/view/canvas/ 下只有 AbstractCanvas2D(13.6 KB)、SvgCanvas2D(47.6 KB)、XmlCanvas2D(28.2 KB,是导出不是绘制)——所以"照 draw.io 做"不支持"改用 canvas"。② 真正值钱的是架构:画家抽象(state + save/restore + 变换 + rect/roundrect/text/begin/moveTo/quadTo/curveTo/fill/stroke),形状只管画模型坐标。③ begin() 建一个 <path>,之后所有操作累积、最后一次 setAttribute('d', …) 提交——正是我这边实测出来的"合并路径"(838 条边 → 10 条)。④ 坐标统一走 (x + dx) * scale 并取整,奇数描边宽补 translate(0.5, 0.5) 求清晰。⑤ 文字不跟几何一起画:foreignObject + 真 <div> 交给浏览器排版,并留住节点引用让 updateText 只挪位置。⑥ 命中的容差靠克隆一个更粗的透明描边。
React Flow 的性能文档node-graph 在规模上的共识:只渲染可见元素、memo 化自定义节点、别让视口变化触发全量重渲染。(reactflow.dev/learn/advanced-use/performance)
Excalidraw双画布:静态层与交互层分开。(这条来自一篇二次分析而非其源码,标注待确认;但理由与我们的实测一致——悬停曾是我们最大的热点)

为什么一个图库都不引(实测与查证,不是好恶)

候选事实判定
konvaMIT、零运行时依赖、canvas,且自带文字 wrap/ellipsis 与命中检测用我们的真实用法建了探针:+491 KB raw / +115 KB gzip,而当前客户端半只有 89 KB raw——5.6 倍。换来的实际只有换行与命中两件小事(约 65 行),而保留式场景图对我们是负资产:架构是"纯模型 → 绘制一遍",引入场景图等于维护第二份可变几何,正是本仓库已经踩过的那类 bug(边跟着滚、节点没动)。不引。
@antv/g6MIT,概念上最省(combo 就是"框",自带布局与小地图)11 个运行时依赖/解包 7.6 MB/自定门槛 400 KB gzip;且 @antv/layout 有 WASM 动态分块路径——单文件插件 bundle 里动态分块会 404,与当初否掉 Mermaid、elkjs 是同一个坑。不引。
cytoscapeMIT、零依赖、canvas节点模型是"形状 + 标签",做不了多行卡片,正是新设计最在意的部分。不引。
maxGraph(draw.io)Apache-2.0,路由/端口/泳道形状齐全渲染是 SVG,与"全 canvas"冲突;只取几何/路由又得半个大库。不引,但抄它的画家抽象。
Excalidraw / tldraw—前者是编辑器应用(渲染器不对外复用),后者生产使用需要 license key。不引。

Step 0 已落地:src/client/cardgraph-model.ts(96 条断言)

先做模型、再写画布,因为这份设计要证明的全是数字:哪只帧装哪张卡、线从哪条边走、两条线落在同一条帧边上时各自落在哪、一个视口能看见什么。模型里已经钉死的规则:

规则是什么为什么
列 = 子树深度同深度的帧在同一列里自上而下堆,列序即 DFS 序父亲永远在孩子的左边,主送/线程天然是短横线;不需要 rank 计算
帧高 = 标题栏 + 内边距 + 卡片堆空帧也保底一个最小高度空会话仍然读得出一只框,而不是消失
卡片刻度 = LOD 常量文字只影响绘制,不影响几何见上面的硬规矩;实测断言:400 字主题与 3 字主题拿到同一个矩形
输入顺序无关卡片按 created 排,平局用 report id;连边也按卡片序走"同一本账,同一张图",打乱输入重建 byte 相同
收件口均分同一帧同一边的线,按远端 y 排序后在卡片带内等分两条线永不落在同一像素,且不会在口子上互相交叉
同列走沟槽同列两只帧之间的线,从卡片侧边出去、沿栏间沟槽、再进对方同侧直线会横穿它自己落地的那只框(这是实测 dump 里发现的,不是设想),读起来像"这条线属于那只框"
跨列是直线跨列的线直接连,可能穿过中间的帧这是这一版接受的代价:细线 + 箭头叠在框上,和 draw.io 一样。真正的绕障要图算法,正是我们不要的东西
矩形剔除取代窗口化一切带 bounds 的东西(帧/卡片/线)用同一个 cullByBounds自由画布没有"行"可数;且两端都在屏幕外、中段穿过视口的线必须留下——泳道版为这条踩过坑,断言里复现了它
视口反变换worldViewport 把 pan/zoom 换成绘制坐标里的可见矩形坏掉的 scale(0/NaN)退化成 1,绝不产生无限或反向矩形

现在没有任何视图引入这个模块,所以它对运行时是零成本:重建前后 lib/client.js 都是 89,360 B(实测,未变)。它由 scripts/cardgraph-check.ts(96 条断言,与另外六套一起进 pnpm test)钉住。

Step 1 已落地:src/client/cardgraph-painter.ts(78 条断言)

画家只认一个结构化上下文 PaintContext——十来个方法(填充、路径、文字、一个变换),真 canvas 天然满足它,测试里换成一只记录器。于是"这条线被淡出了、那只框没有""画的是虚线还是实线""箭头落在哪个点"全都成了断言,不用开浏览器。这也是 draw.io 的画家抽象真正值钱的地方:换成 SVG 只是再实现一遍这个接口。

决定是什么
绘制顺序:帧 → 线 → 卡片线永远压在它穿过的框上(不会消失),而卡片的文字永远不会被线穿过
文字只在这里量foldText/elide 按 measure 折行:Latin 能在空格处断就断,中文按字断;按 (字体, 文本) 缓存,因为画布上每次测量都是一次同步排版
几何一点不看文字断言把测量宽度从 1 改到 40:画出来的矩形与线段逐字节相同,而文字确实变了
主题走 token15 个颜色槽 + 4 个字体 token(每个自带 size/weight/line-height/family),全部有兜底;重读靠一只金丝雀 token(页面底色)——它没动就不重读,于是每次重绘只多一次属性读取
状态与线型同源卡片左缘色条、状态文字、四种线的虚实都取自同一张表,和图例、列表的 Tag 语气一致

⚠️ 已知未做:坐标不做像素对齐。 draw.io 是在变换之后取整的,因为它的画布是 1:1 屏幕空间;我们的画布是缩放过的,要取整就得逐点换算到屏幕空间。先按抗锯齿走,等实测说糊了再补——这条留在 README 而不是留在代码注释里,是因为它需要一个浏览器里的判断。

Step 2 已落地:src/client/CardgraphView.tsx——在真实浏览器里逐条量过

标签页现在渲染卡片画布。两只画布(静态 + 交互)、平移缩放、命中、LOD 跟随、点开详情全部做完,并且在隔离实例 + 真实浏览器里逐条验证(不是推理):

验的是什么结果
真的画出来了吗底图 946×420、100% 不透明;像素里数到文字(8,976 个暗像素)、橙/绿状态色、蓝色线程——四种线型都落了墨
悬停不重绘底图悬停前后底图墨量完全相同(397,320);交互层从 0 变成有内容。这就是两层的意义
悬停的代价中位 32.6 ms,而每次采样等两帧的地板是 ~33 ms(60 Hz)——也就是测不到开销,与泳道版当初撞到的同一条地板
点击卡片打开下面列表里的详情(手工核对几何 dump 出现在 DOM 里)——图表不另开阅读面
拖拽平移了(按钮的 left 变了),且没有触发打开——拖动与点击靠 4px 阈值分开
滚轮以光标为中心缩放(80% → 100% → 80%),window.scrollY 始终为 0——页面不会在缩放下滚动
LOD 真的跟着缩放卡片档 8,976 暗像素 → 芯片档(26%)301(只剩帧标题)→ 125% 时 13,200
无障碍7 张可见卡片各有一个真按钮(透明、带 aria-label),键盘/读屏可达;列表仍是完整路径
热重载重建 lib/client.js 后浏览器自己换掉了插件(页内不刷新),这就是改完立刻能看到的原因

三处是看了截图才改的(不是想出来的):

  1. 首屏不是"适应窗口"。 一开始沿用 fit,结果是 57%——落在紧凑档,读者第一眼看到的是一堵单行卡片墙,而卡片画布的卖点恰是被省略掉的主题。改成按宽度适配、且不低于卡片档(clamp(…, 0.8, 1)):暗像素 1,743 → 8,976,帧、列与可读卡片同时在场。"适应窗口"按钮仍然保留,用来看全貌。
  2. 遮罩从 0.18 改到 0.55。 第一版照搬泳道版的淡出值,截图一看:其它帧、标题、卡片全都读不出来了——追一条路径的代价是把整个上下文赔进去。泳道版自己早就得出过同一条结论的另一半(该退到后面的是关系,不是参与者),而画布遮罩比逐个形状淡出更粗暴,所以必须更轻。现在聚焦路径在顶上全强度重绘,其余保持可读。
  3. 计数说的是"8 个框"而不是"8 个会话"。 共享的"树外"帧也是一个框,把它算成会话就是在说假话。

⚠️ 顺带修掉一个自己在代码里埋的雷:滚轮处理原先在 setZoom 的 updater 里调 setOffset——那是"在 updater 里做副作用",React 有权把 updater 调用两次,于是每一格滚轮位移会被应用两遍、缩放会从光标下漂走。改成用 ref 读当前变换、在 updater 外面算。

代价:整个卡片画布(模型 + 画家 + 视图)让客户端半从 89,360 B → 114,955 B(gzip 24.86 → 32.17 kB),+7.3 kB gzip——而当初被否掉的 konva 光是库自己就要 +115 kB gzip。泳道版的 DOM 视图随之下线,这部分是被它自己腾出来的地方抵掉的。

留下什么、重写什么:泳道序(成为帧的排列序)、树外泳道(成为"树外"帧)、四种边的语义、窗口化(升级为矩形剔除)、单层淡出、合并路径、marker 箭头、图例、悬停文字——全部保留;泳道的"列 + 行 + 芯片"重写成"帧 + 卡片 + 线"。列表、详情面板、过滤、轮询完全不动。

线程、时间基准与阅读上限

  • 线程跳转:详情面板显示该汇报的上溯(parent)与下递(children),点任意编号即跳到那一份。链接可以指向当前树之外的汇报(它属于另一条线),此时详情仍会打开——report 端点按账本范围而非子树范围查询——并明确提示"不在当前会话树的列表里"。
  • 时间基准:一键在「按创建 / 按最近活动」之间切换。会话始终按创建时间;切换只影响汇报在时间轴上的落点。
  • 任务标签即分组:卡片上的 task 标签可点击——点它就把搜索框设为该标签,于是同一次协作(可能横跨多条会话树)被拉到一起。这是刻意的实现选择:复用已有的搜索,不引入第二套筛选状态;report_list({task}) 在工具侧提供同一维度的分组,并把该任务的 open/acked/closed 统计一并返回。点它还把状态芯片清回「全部」:它的提示语承诺的是「只看任务 X」,而只要还有一个状态芯片在收窄,这句话就是假的——实测先筛「已结案」再点任务芯片,原本会得到一句「显示 0/7」,让人以为这个任务没有汇报。
  • 正文明限:面板只显示正文开头(>4000 字时截断并提示),与 report_read 给模型的上限保持一致——让人类视图与模型视图被同样地约束,双方都不会对对方看到的范围感到意外。范围一致,排版不同:正文用 shell 自己的 MarkdownText 渲染,所以标题、列表、表格、代码块在这里与在对话标签页里长得一样(代码块还带「复制」按钮),而模型拿到的仍是同一段纯文本。面板里没有第二个滚动条:正文已经被这个上限约束住了,再套一个 320px 的内层滚动只会在时间线自己的滚动之上抢滚轮;面板随内容变高,滚动统一交给外层。
  • 截断 Markdown 不等于截断文本:切点落在代码块中间会留下一个没有闭合的围栏,等于把「代码块到哪里结束」交给渲染器去猜。实测这个渲染器猜得对(切在围栏中间会渲染成一个正常闭合的代码块,没有尾随痕迹),所以 closeOpenFence() 是便宜的保险而不是修一个看得见的坏——渲染器是插件不拥有的平台模块,它的宽容不是契约,补一个围栏让输出在任何渲染器下都是合法 Markdown。它共 5 条断言,且只在正文真被截断时才跑。
  • 两种"看不见"分得很清:汇报不在树里 vs 汇报被当前筛选隐藏——两组措辞不同。把后者说成"可能属于另一条线"是错的,所以两种情形各有各的话。

数据通路

浏览器一个请求取全部数据:

端点返回
GET /api/report-ledger/timeline?root=<sessionId>该会话的递归子树(DFS 前序、兄弟按创建时间)+ 子树相关的汇报缩略
GET /api/report-ledger/report?id=<R-0001>单份汇报的缩略 + 完整路径 + 待送达集合 + 正文

子树由 ctx.sessionQuery.listSessions() 的 parentSession 链走出,标题只对子树内存活的会话查询(长寿命部署里语料远大于一次协作,而标题是装饰、树不是)。汇报按"子树任一成员参与过它的路径"过滤(发起、主送、抄送、共写),因此这是协作账本而不是全库倾倒。

为什么不用 typert 生成的 Remote:那需要构建期代码生成。第三方插件的通行做法是自建同源、仅限 loopback 的 JSON 通道,本插件照此实现。两半共用 src/shared/wire.ts 的类型——该文件只有 export type,会被完全擦除,所以浏览器 bundle 不可能内联宿主代码(构建后已核验:外部依赖仅 react、react/jsx-runtime 与 @deepseek-ai/dsh-client-ui-primitives,node 内置模块与 yaml 均为 0 命中)。

那个平台模块的类型是声明出来的,不是导入的(src/client/platform.d.ts):它只存在于 shell 自己的 bundle 里,不在 profile 的 node_modules 层——而 tsconfig 的 @deepseek-ai/* 正映射到那一层。这与 client/index.ts 给 slots / locale 写结构化接口是同一个做法,理由也一样:断言我们实际调用的形状,不多声明一个字段(多声明的字段在运行时会被静默忽略)。

视图会自己跟上。 账本是别的 agent 在后台写的,所以只取一次的快照会在最需要它的那一刻过期:打开期间每 10 秒重取一次时间线,页面不可见时跳过(后台标签页零成本),切回该浏览器标签页时立刻补一次。「刷新」按钮保留,并且只有它会连已打开的详情面板一起重取——轮询刻意不碰详情,否则正文每 10 秒闪回一次「正在读取传递路径」。两个计数器(timelineNonce / detailNonce)就是为这个区分存在的。

守卫与信任假设

两个端点都是 GET、只读、无写入面(所有变更仍只走模型工具,那是唯一会记录跳的路径)。守卫照实复刻部署中第三方插件的做法:TCP 对端必须是 loopback + Host 必须解析为自身且是 loopback 主机名 + 浏览器同源标记一致。第二项挡掉 DNS rebinding 拼法(localhost.attacker.tld)与非规范权威(默认端口 127.0.0.1:80 解析后会消失,故不相等)。

⚠️ 信任假设要说清楚:实测发现已注册的 exact 路由先于鉴权匹配——未注册路径返回 401,而注册过的路由直接 200,不要求会话 cookie。所以守卫是这些端点唯一的防护,其信任边界是"本机进程",与账本文件本身可被本机读取是同一信任级。反向代理部署需要守卫的共享令牌变体;只服务直接 loopback 是安全的默认,失败模式是"读被拒绝"而非"读被泄露"。

同伴:代理自主开启会话

两个工具:

工具作用
peer_list列出你能对话的代理及其与你的关系(上级 / 下属 / 兄弟 / 你开启的同伴 / 账本里有往来的联系人),并标注谁此刻在线
peer_start开启一个独立同伴会话,并把任务作为一份汇报交给它

同伴是独立根会话,不是下属

这是本阶段最重要的设计判断。peer_start 创建的会话没有 parentSession、没有 origin、delegationDepth 为 0——它是一个根会话。这样它才配得上"同伴":拥有自己的生命周期与预设、不占用任何委派深度预算、出现在工作区会话列表里、并且比开启它的那一轮活得更久。

代价是血缘无法表达这段关系(parentSession 是空的),所以名册日志($DSH_HOME/report-ledger/peers.jsonl,append-only)记录它:谁开启了谁、何时、什么名字、继承的工作目录。这条记录同时是授权凭证。

授权规则

DSH 的委派层拒绝非相邻通信,源码原话是"其他代理、祖先、teams、workflows、hosts 保持拒绝,直到有一个显式的授权协议有生产消费者"。本插件就是那个消费者,它实现的规则故意收得很窄:

血缘授予通道,开启过的会话授予通道——而"仅仅在账本里有往来"不单独授予通道。

最后一条是刻意的:正因为"通过账本联系对方"是建立接触的方式,把接触本身当作授权就形成了循环——先有鸡还是先有蛋。所以主动伸手(report_send/report_cc)永远允许(它会产生那条记录),而直接通道只来自血缘或"我开启了它"。

创建会话是本插件唯一会新增活代理的能力(其余都只是记录),所以它的授权故事是叠加的:工具可见性(DSH 自己认定的唯一真实闸门)+ maxPeersPerAgent 预算(默认 8,超限是明确的工具错误而非静默拒绝)+ 继承调用者自己的工作目录(无法被指向无关目录树)+ 每次创建都在名册里留痕。

创建与对话是分开的

peer_start 只负责创建与记录;交任务由调用者用一份汇报完成(工具层组合两者)。于是:任务天然进了账本、同伴被投递唤醒、路径被记录,而同伴之后对同一份汇报的 report_contribute 就让它成为双向线程。这正是"用汇报做交互的关键"——S4 没有引入第二条轻量消息通道,因为那会产生一条不受审计的旁路,正好抵消 S1 的全部价值。

一个必须记住的 API 陷阱

AgentHandle.dispose() 会"停止循环、注销代理、并从 store 里移除该会话"。所以对"必须比这一轮活得更久的同伴"绝不能持有或自动释放 handle——本插件创建后即丢弃 handle,同伴通过 ctx.agents 保持可寻址。自动 dispose 会删掉同伴的会话。

一处已知限制

在一次性 headless 运行里,开启的同伴不会真的执行任务:它不是子代理,因此不在运行器 drain 的范围内,父任务一结算进程就退出了。任务本身不丢——投递已作为 agent/inbox/spliced 进入同伴的会话日志,会话恢复时它就在历史里。在长期运行的 web profile 中同伴会正常处理收件箱。

工程约定(踩过的坑)

  • 绝不新增自定义会话事件类型。 SessionEventMap 看起来可扩展,但持久化读取路径 assertEventsSupported 只在 KNOWN_SESSION_EVENT_TYPES 命中或事件带 ignorable: true 时放行,而 Session.append 从不设置 ignorable;白名单是从仓库内成员生成的字面量。追加新类型会让该会话日志在重载时不可读。因此账本走文件,模型可见的摘要走 source: {kind:'plugin', plugin:'report-ledger', form:'relay'}(既有已知形状)。
  • 官方包必须保持 external。 内联 dsh-tools 会复制服务注册表、破坏实例同一性。构建只内联真正的第三方依赖(yaml)。
  • 运行时解析需要 node_modules/@deepseek-ai junction。 Node 按 realpath 解析模块:插件包经 ~/.dsh/profiles/node_modules/dsh-report-ledger junction 指向本仓库后,真实路径仍在工作区,因此 import '@deepseek-ai/dsh-tools' 只会从本仓库向上查找。工作区里的 node_modules/@deepseek-ai → profile 官方包层的 junction 正是为此,与生态里 link-profile.mjs 的做法一致。pnpm install 可能清掉它,重装后需重建。
  • 工具参数规范中不能写 required: false。 defineTool 的 ParameterSchemaSpec 只接受 required: true 或整个省略,写 false 会在加载时报 required must be true when present。可选参数就是不带 required 的字段。
  • 投递与账本分层。 deliver() 只做传输、绝不碰账本;到达跳由 ReportService 统一写入,保证审计的写者唯一。
  • 可选服务用 ctx.inject,不要用 ctx.get——两者对"可能后到的服务"并不等价。 ctx.get 读的是此刻的注册表,provider 还没激活就返回 undefined;服务注入回调则在服务真正出现时运行。本项目在 web profile 上因此真实踩坑:webserver 行 inject 了 webStartup,会晚于我们的行激活,于是路由被静默跳过,所有请求落到 /api 鉴权栅栏上得到 401,而我们的 handler 从未被执行。之所以不用声明式 inject: ['webServer'](那样必然排在后面):headless profile 根本没有 web server,硬依赖会让整个插件在那里永远等待、什么也不贡献。ctx.inject(['webServer'], (scoped) => …) 同时满足两者——可选,且与到达顺序解耦。
  • ctx.get('webServer') 的失败是静默的,所以凡是通过 ctx.get 拿可选服务再"可用则注册"的地方,都必须有一个能在真实 profile 里被观测到的验证手段,否则这类 bug 只会在浏览器里表现为一个空标签页。本项目靠独立 web profile 的 HTTP 断言抓到它。
  • 每个副作用都必须在 apply 的 ctx.effect 里注册。 在工具执行体内部直接调用 ctx.webServer.register(...) 并把 disposer 存进闭包,是一个真实的陷阱:该副作用不归插件 fiber 所有,cordis_stop 与 cordis_undefine 都无法回收它,只能靠重启进程清除(本项目在诊断探针上踩到过一次,正式插件的路由因此写在 ctx.effect 内)。
  • 手改账本不能让记录消失。 YAML 的严格默认会把重复键判为错误,而重复键正是手改时最容易出现的情况(追加一个已存在的字段)。那会让整份文档解析失败、汇报从账本里静默消失。所以读取路径用 uniqueKeys: false(后者胜),而"无 front matter""未闭合块"这类真正无法解释的输入仍然拒绝。丢失记录远比一个歧义键被可预测地解决严重。
  • 锚在列表项上的面板,必须保证那一项存在。 详情面板原本渲染在汇报行内部,于是当目标不在(筛选后的)列表里时,面板无处渲染、连同里面的提示一起消失。修法不是把面板抽出来,而是为被打开的汇报补一行合成行——这同时修掉了另一个我还没发现的同类缺陷:在详情打开时改变筛选,面板原本也会消失。
  • 补出来的那一行要插进它自己的时间位置,不能追加在末尾。 追加会让 11:12 的汇报显示在 14:30 的汇报下面——偏偏用户正处在「为什么少了一条」的语境里,一个看着像排序坏了的列表比一片空白更误导。落地做法是把排序的比较函数抽出来给插入复用:排序与插入用同一个比较器,两份实现一定会漂移。这一条同样钉在测试里(插入后列表仍有序、且新行不是最后一个)。
  • 不要把「整行可点」实现成 role="button" 的行。 行里还有一个任务芯片——一个真的 <button>,于是构成嵌套交互内容;更糟的是 role="button" 的可访问名由整棵子树计算,读屏会把一整行 uuid 念一遍、再把任务标签念第二遍。改法是让行回归普通容器(点击保留为鼠标便捷路径),把展开动作交给左侧箭头:真按钮、名字说清它做什么(展开 R-0001)、aria-expanded + aria-controls 指向它打开的面板(面板带 id)。这样每行两个 Tab 停靠点是两个真实动作(展开、按任务筛选),而不是同一个动作的两次。⚠️ 箭头必须 stopPropagation:它和行处理的是同一个动作,不拦住就会切换两次,表现成「点了没反应」——这一类 bug 在自动化里显示为 aria-expanded 从 false 变回 false,肉眼则完全看不出区别。
  • 平台组件的枚举要去 CSS 里确认,不能只按已发布视图的用法推断。 Tag 的 tone 在平台代码里只出现了 solid 与 neutral 两种,照此推断就会以为状态只能用灰阶——而 CSS 里其实定义了 8 个。能渲染的取值集合由 [data-tone=…] / [data-state=…] 的样式规则决定,代码里没用到只是没用到。
  • 这些组件不接受 style,只接受 className,而且落在它们自己的 wrapper 上。 于是「让搜索框占满工具栏」这件事只能靠一条 CSS 解决,而那条规则的类名必须是我们自己的(report-ledger-search),不能去写 _wrap_1g6ru_1 这类 hash 类名——同一条规则挂在 hash 类名上,就是给自己埋一个随 shell 版本爆的雷。
  • 接在早退块里的东西可能要不到。 上面那个"不在当前树里"的提示原本嵌在线程块的 IIFE 内,而该块在汇报没有上溯/下递时会提前 return null——偏偏"树外汇报没有线程链接"正是常见情形。条件渲染里的早退会静默吞掉同一块里其它独立的内容。
  • whiteSpace: 'nowrap' 的样式对象不能复用到「长度由数据决定」的单元格上。 time 这个样式对象本来只描述时间戳,却被顺手复用到了传递路径的收件人/备注列上;而 1fr 网格轨道的自动最小尺寸就是 min-content——对一整行不可断行的文本来说,那就是整行宽度。结果网格宽过自己的面板、整个标签页多出 426px 横向滚动,备注被切在屏幕外,而那个滚动条只在标签页的最底部才够得着(容器被外层撑到 1314px,横向滚动条在它自己底部)。同一类问题在 flex 项目上表现为「不写 min-width: 0 就永远缩不下去」,于是汇报行的 meta 行、账本路径、待送达列表是同一个毛病的三处实例。修法是分成两个样式:时间戳保持 nowrap,内容一律 whiteSpace: 'normal' + minWidth: 0;需要保持单行密度的地方用 overflow: hidden + textOverflow: 'ellipsis',而不是让它去撑破容器。
  • 在浏览器里量,而不是在浏览器里看。 上面那条缺陷在截图里只是"文字好像被切了",getBoundingClientRect 与 scrollWidth/clientWidth 一量就是精确的一句话:网格 1052px、单元格右边缘 1478px、shell 溢出 426px。凡是"布局被内容撑破"这一类,肉眼只能给出怀疑,测量才给出结论。

开发与验证

pnpm build        # 产出 lib/index.js(host 半)与 lib/client.js(client 半)
pnpm test         # 八套确定性检查共 536 项断言:账本内核、生命周期与任务分组 78 + 提示词角色分流 47
                  # + 时间线装配与路由守卫 59 + 同伴名册与创建 42
                  # + 时间线模型(筛选/线程/空状态/身份/正文/围栏)81
                  # + 拓扑布局(泳道/节点/边/树外/线程路由/确定性)51
                  # + 卡片画布模型(分档尺寸/帧装箱/收件口/沟槽/剔除/视口/拾取)96
                  # + 卡片画布画家(绘制顺序/裁剪/虚线/箭头/遮罩/主题/折行/度量缓存)82
                  #(不需要 DSH,不触碰真实账本,不启动服务器)
pnpm typecheck    # 对部署中的 harness 类型做全量类型检查

安装到 profile(本仓库已这么装好):包经 junction 出现在 ~/.dsh/profiles/node_modules/dsh-report-ledger,并在 profile 的 cordis.patch.yml 中有一行:

- insert:
    - id: report-ledger
      name: 'dsh-report-ledger'
      config:
        announceToAgent: true

开发回路:

  • 宿主半:保存即生效。 web profile 的 cordis.patch.yml 里把 dsh-base 默认禁用的 hmr 行打开,并把 root 扩到本仓库的 lib/(因为插件经 junction 挂载、真实路径在 profile 之外)。配合 pnpm watch,回路是:保存 → tsdown 重建(约 0.2s)→ HMR 就地重载该插件条目。进程不重启、端口不断、正在进行的会话不中断。
    • 已实测确认:改 lib/index.js 后新代码即刻生效,且宿主进程 PID 不变。本插件被判定为"直接变更"走局部重载,不会触发 loader.exit()(那是 CLI 入口静态依赖树里文件改动才会走的路径;插件由 Loader 动态 import() 加载,不属于那棵树)。
    • 重载是安全的:插件的持久状态全在磁盘账本上,内存里只有一个互斥锁表,重载不丢数据。
    • ⚠️ 启用 hmr 需要一次重启才生效。 通过 patchReload: live 在运行中启用只会"启用行"而不应用 config——实测服务自己报 root: [](空监视)与 schema 默认 debounce: 100。组合树本身是对的(dsh --profile web --dump-config 可见完整 config),只是生效时机问题。
  • 客户端半:保存即生效,同样不需要刷新页面(已实测)。 原先这里写的是「重建 + 页面刷新」,并注明"未验证"——那条是错的。dsh-web-app 的 client-hmr 行是常驻的(dsh-web-app/cordis.patch.yml:always mounted: it is idle until a rebuild watcher actually rewrites client bundles),其 node 半侧每 pollIntervalMs(默认 500ms)stat 轮询每个图 bundle,变化时经 /plugins/events 的 SSE 通道广播 rebuilt 帧;浏览器半侧据此 invalidate → prefetch 新 factory → 拆旧 fiber → entry.refresh() 重新挂载。插件经 junction 挂在 profile 之外不影响这条链路:轮询的是解析后的真实路径。
    • 实测方式与结果:直接改写插件 lib/client.js 里的 "view.tab" 字面量(不重建源码、不刷新、不点击),标签在约 1 秒内变成新值;改回去又自动回退。两次 performance.getEntriesByType('navigation').length 始终为 1,页面没有重新导航。也就是说 pnpm watch(或任何写 lib/client.js 的构建)对客户端半就是完整回路:保存 → 重建 → 页面自己换掉这个插件。
    • 代价与边界:换掉的是插件,插件内的 React 状态会丢(展开的详情会收起),而会话、工作区与连接状态不受影响;重载失败不回滚,该 entry 停在 FAILED 视图并在下一次 rebuilt 帧从头重试。
    • 仍然需要刷新页面的只有一种情况:启动图本身变了。 装/卸插件、启用/禁用某一行(即 dsh.client 名单变化)只在页面加载时组合——每个 rebuilt 帧只携带单个插件产物的 revision,不替换启动图。
  • pnpm watch 的生命周期:它是个前台常驻进程。由代理会话启动的那种只在该会话存活期间有效;要长期常驻请在自己的终端里跑。
  • 离线/批量集成验证仍可走独立的 headless profile(~/.dsh/profiles/reports-dev/),它一次性跑任务、不干扰正在服务的 GUI:
    $env:DSH_REPORT_LEDGER_ROOT = "$env:TEMP\report-ledger-it"
    dsh --profile reports-dev "<task>"
    
    该 profile 的补丁里同样把 hmr 打开并把 root 扩到 lib/。
  • 验证浏览器侧能力时,在隔离的 DSH_HOME 里另起一个 web profile,不要动正在服务的那个:DSH 明确声明两个 harness 进程不协调共享同一持久化 store,共用会威胁正在运行的实例。
    # 只把包解析层 junction 进去,会话/账本留在临时 home 里
    $iso = "$env:TEMP\dsh-web-test"
    mkdir "$iso\profiles"
    cmd /c mklink /J "$iso\profiles\node_modules" "$env:USERPROFILE\.dsh\profiles\node_modules"
    # 在该 home 内建一个 bundles = [dsh-base, dsh-web-app] + 本插件行的 profile
    $env:DSH_HOME = $iso
    dsh --profile <你的-web-profile> --port 3099 --no-open
    
    启动会打印一个带 ?token= 的 URL —— web profile 用 URL token 鉴权,带上它就能让自动化浏览器登录这个隔离实例,从而验证真实渲染。用完先删 junction 再递归删除临时 home,否则删除会顺着 junction 冲进真实 profile 层。 另外:已注册的 exact 路由先于 /api 鉴权栅栏匹配,所以自查端点时可以不带 token 直接 curl。
  • 补丁语法:插新行用 - insert:;按 id 修改已有行必须写成顶层 - id:,把已有行放进 insert 会新建一条同 id 的行并报 duplicate loader entry id。

发布(维护者)

分发形态是预构建的 bundle:lib/ 在发布前构建好,用户安装时不跑任何构建脚本,因此不需要 allowBuilds 授权。

pnpm check          # 类型检查 + 五套确定性检查
pnpm pack           # 先出 tarball 核对产物(prepare 会顺带构建)
npm publish         # ⚠ 本机 registry 若是镜像站,必须显式 --registry=https://registry.npmjs.org

pnpm pack 的产物清单缺一项的表现都是「装上了不生效」而不是报错,逐条核对:

检查项本包取值
main / exports 指向构建产物而非 src/lib/index.js / lib/client.js
files 含入口与 cordis.patch.yml["lib", "cordis.patch.yml", "THIRD-PARTY-NOTICES.md"]
dsh.bundle.patch 指向该 patch./cordis.patch.yml
version 已递增npm 不允许覆盖已发布版本

发布后在干净环境里验证(本仓库已按此验证过 0.1.0 的 tarball):

dsh plugin --profile demo add dsh-report-ledger   # 空 DSH_HOME 里
dsh --profile demo --dump-config                  # 应出现 `# == dsh-report-ledger` 这一层

dsh plugin add 会因包声明了 dsh.bundle 而自动把包名追加进 dsh.profile.bundles,用户不需要手改配置。

CI 发布(可信发布 OIDC): .github/workflows/publish.yml 负责推 tag 后自动发布——不需要任何 npm 令牌, 也不需要手输一次性验证码,并自动附带 provenance。首次启用前要在 npm 侧建立一次信任关系(需交互式 2FA):

npm trust github dsh-report-ledger --file publish.yml \
  --repo stone-brick/dsh-report-ledger --allow-publish

--file 必须与工作流文件名完全一致。之后发版就是 pnpm version patch && git push origin main --follow-tags。 注意 CI 只跑 pnpm test + 构建,不跑 typecheck:类型来自 profile 的官方包层(见下文 junction 一节), CI 里没有这一层,把官方包装成 devDependency 反而会在工作区复制服务注册表、破坏实例同一性。

首次发布(新包名)只能手工来一次:npm 的可信发布与暂存发布都要求包已存在,新包名两者都会 404。 手动发一次时如果是安全密钥账号,npm publish 会打印一个 https://www.npmjs.com/auth/cli/… 链接, 在浏览器里完成认证即可(放行凭据用 --//registry.npmjs.org/:_authToken=… 传,别写进 .npmrc)。

git 安装与 npm 安装不是一回事:add github:<你>/dsh-report-ledger#<sha>(或 Gitee 地址)拉到的是源码, 靠仓库里的 prepare 构建出 lib/ 才能跑——本仓库实测在 pnpm 10.14 上直接通过、未要求 allowBuilds, 但部分 pnpm 版本会拦截依赖的构建脚本,届时 dsh 会打印出要写进 profile 的 pnpm-workspace.yaml 的包键。 对外仍推荐 npm 安装:预构建产物、不触发任何构建脚本、不需要授权。

Gitee 镜像

Gitee 自带的「仓库镜像管理」在本账号不可用(GET /api/v5/repos/{owner}/{repo}/mirror 返回 404 Not Found Project),所以同步方向反过来:GitHub 主动推。

  • 工作流 .github/workflows/mirror-to-gitee.yml,在 main 与 v* tag 的 push 后镜像 main + tags;
  • 凭据是 GitHub 仓库 Secret GITEE_TOKEN(Gitee 私人令牌,只需 projects 权限)。 令牌有有效期,过期后要重新生成并 gh secret set GITEE_TOKEN,否则工作流会认证失败;
  • 令牌只经 credential.helper 按需交给 git,不写进 remote URL,所以不会落进 .git/config 或命令输出。

发行版附件不在自动同步范围内,需要单独上传;注意附件接口要求令牌放在 query 上, 放 form 里会得到 401 登录失效(实测):

curl -X POST "https://gitee.com/api/v5/repos/stone_zhan/dsh-report-ledger/releases/<release_id>/attach_files?access_token=<token>" \
     -F "file=@dsh-report-ledger-0.1.0.tgz"

已知限制

  • 单进程假设。 每个汇报的写操作由进程内互斥锁串行化。跨进程共享同一账本需要租约协议——这与 harness 自身延期的工作相同。
  • 正文并发覆盖。 跳流 append-only 永不丢跳,但两个人同时改写同一份正文是后写者胜(report_contribute 用追加,规避了常见路径)。
  • fromName 取自会话标题,是"会话名"而非"代理名"。