dsh-report-ledger
DSH 插件:把「汇报」做成代理之间的一等交互原语 —— 可追溯的传递路径账本、回执/结案/修正,以及一个会话时间线标签页。
- Stars
- 0
- Language
- TypeScript
- Created
- Sep 24, 2026
- Updated
- Sep 28, 2026
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 提供;浏览器半 requirereact、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> 的透明度 |
两处修法都有实测回报:
- 同类边合并成一条
<path>,箭头改用 SVGmarker——边本来就不可点击,一条边一个元素只买到"每次重绘一个形状"。箭头是路径的装饰而不是节点,交给marker后额外元素为零。 - 淡出只作用于"边的那一层",绝不走后代选择器。 这是最有价值的一条:单独翻写一个被后代选择器匹配的属性(
[data-hot] … :not(…))本身就要 35 ms,因为浏览器要为整棵子树重算样式,哪怕最终没有任何可见变化(我用注入 CSS 把结果改成!important覆盖也降不下来——覆盖只改结果,不减重算)。所以节点干脆不淡出:追踪一条路径时该退到后面的是关系,不是参与者。
结果:元素 5469 → 2689、边路径 838 → 10、箭头多边形 834 → 0、悬停 89 → 44 ms(最好的一次 16–19 ms,即一帧)。
- 窗口化:只渲染滚动框里真正看得见的那几行(上下各留 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 | 双画布:静态层与交互层分开。(这条来自一篇二次分析而非其源码,标注待确认;但理由与我们的实测一致——悬停曾是我们最大的热点) |
为什么一个图库都不引(实测与查证,不是好恶)
| 候选 | 事实 | 判定 |
|---|---|---|
| konva | MIT、零运行时依赖、canvas,且自带文字 wrap/ellipsis 与命中检测 | 用我们的真实用法建了探针:+491 KB raw / +115 KB gzip,而当前客户端半只有 89 KB raw——5.6 倍。换来的实际只有换行与命中两件小事(约 65 行),而保留式场景图对我们是负资产:架构是"纯模型 → 绘制一遍",引入场景图等于维护第二份可变几何,正是本仓库已经踩过的那类 bug(边跟着滚、节点没动)。不引。 |
| @antv/g6 | MIT,概念上最省(combo 就是"框",自带布局与小地图) | 11 个运行时依赖/解包 7.6 MB/自定门槛 400 KB gzip;且 @antv/layout 有 WASM 动态分块路径——单文件插件 bundle 里动态分块会 404,与当初否掉 Mermaid、elkjs 是同一个坑。不引。 |
| cytoscape | MIT、零依赖、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:画出来的矩形与线段逐字节相同,而文字确实变了 |
| 主题走 token | 15 个颜色槽 + 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 后浏览器自己换掉了插件(页内不刷新),这就是改完立刻能看到的原因 |
三处是看了截图才改的(不是想出来的):
- 首屏不是"适应窗口"。 一开始沿用 fit,结果是 57%——落在紧凑档,读者第一眼看到的是一堵单行卡片墙,而卡片画布的卖点恰是被省略掉的主题。改成按宽度适配、且不低于卡片档(
clamp(…, 0.8, 1)):暗像素 1,743 → 8,976,帧、列与可读卡片同时在场。"适应窗口"按钮仍然保留,用来看全貌。 - 遮罩从 0.18 改到 0.55。 第一版照搬泳道版的淡出值,截图一看:其它帧、标题、卡片全都读不出来了——追一条路径的代价是把整个上下文赔进去。泳道版自己早就得出过同一条结论的另一半(该退到后面的是关系,不是参与者),而画布遮罩比逐个形状淡出更粗暴,所以必须更轻。现在聚焦路径在顶上全强度重绘,其余保持可读。
- 计数说的是"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-aijunction。 Node 按 realpath 解析模块:插件包经~/.dsh/profiles/node_modules/dsh-report-ledgerjunction 指向本仓库后,真实路径仍在工作区,因此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:
该 profile 的补丁里同样把$env:DSH_REPORT_LEDGER_ROOT = "$env:TEMP\report-ledger-it" dsh --profile reports-dev "<task>"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取自会话标题,是"会话名"而非"代理名"。