追踪代理间汇报路径,留回执与结案记录
30/100
综合分
0
GitHub 星标
730
npm 周下载
—/5
用户评分
数据截至 2026/9/28 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add dsh-report-ledgernpm 源 · dsh-report-ledger 的发布者账号已校验与本仓库一致
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:已验证
是什么
dsh 原生插件 · chat
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
更新放缓:最近一次提交在 37 天前
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
社区实装还没有人打卡 · 装过的第一个就是你
安装兼容性检查✓ 自动检查通过展开查看检查明细 ↓
以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。
✓npm 包dsh-report-ledger @ 0.1.0
✓Node 引擎未声明 engines.node
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明
验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/10/8 08:45:52
安全扫描自动 · 静态分析源码v2.7.0🔵 扫描:含敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。
可执行外部命令读写/删除本地文件发起外部网络请求
查看扫描证据(共 33 处,展示前 17 条)
- 写入本地文件lib/index.js:860
await mkdir(reportsDir(), { recursive: true }); - 读取本地文件lib/index.js:874
for (const name of await readdir(reportsDir())) { - 读取本地文件lib/index.js:883
if (existsSync(routePath(candidate))) continue; - 写入本地文件lib/index.js:885
await writeFile(path, "", { flag: "wx" }); - 写入本地文件lib/index.js:914
await rename(from, to); - 写入本地文件lib/index.js:949
await writeFile(temp, serializeReport({ - 读取本地文件lib/index.js:956
sig: signature(await stat(target)), - 删除本地文件lib/index.js:963
await unlink(temp).catch(() => void 0); - 读取本地文件lib/index.js:1029
return parseReport(await readFile(path, "utf8"))?.front; - 读取本地文件lib/index.js:1044
info = await stat(path); - 读取本地文件lib/index.js:1074
const parsed = parseReport(await readFile(reportPath(id), "utf8")); - 写入本地文件lib/index.js:1149
await appendFile(path, `${needsNewline ? "\n" : ""}${JSON.stringify(hop)}\n`, "utf8"); - 写入本地文件lib/index.js:1170
await appendFile(reportPath(id), text, "utf8"); - 删除本地文件lib/index.js:1291
await unlink(path); - 删除本地文件lib/index.js:1293
if (await readFile(routePath(id), "utf8") === "") await unlink(routePath(id)); - 执行外部命令固定命令lib/index.js:1383
execFile("git", [...args], { - 发起网络请求lib/client.js:1631
const response = await fetch(url, {
扫描于 2026/11/3(1 天前) · 范围 入口闭包 + 全包扫描 · 来源 npm 发布包
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
功能亮点
- 追踪汇报传递路径
- 回执与结案记录
- 会话时间线标签页
DSH 插件:把「汇报」做成代理之间的一等交互原语 —— 可追溯的传递路径账本、回执/结案/修正,以及一个会话时间线标签页。
最近更新
在 GitHub 查看全部 →v0.2.52026-10-28
发布 0.2.5:同伴可唤醒、读取独立计数、结案要交代,开发者模式搬出发布产物
代码内容(0.2.4 之后,24 笔):
· 同伴:新增 peer_wake —— 只有开启者能唤、不占名额、只在账本还欠它投递时才有意义;
经 ctx.agents.resume 恢复会话并补投,失败原样报原因而不是静默挂账。
peer_start 支持 report(直接交一份已有汇报,不新建作业单,并在那份报告的检出里开工)
与 parent(让新作业单挂进树里)。
· 账本:hops 拆出 reads(读取是审计事实、不是因果动作),last 不再落在读取上;
report_read 的路径开头加按动作计数行;report_list 的行给出 authors=N;
新增 pnpm repair 重算摘要(幂等、不动 updated、跳流缺失只报不改)。
· 结案:从未有过贡献、又不带 note 的结案被直接拒绝。
· 开发者模式:整体搬出发布产物,改为检出附加包 dev/bundle(不发布、在 files 之外的 dev/ 下);
主包只导出 mountHost;两行同时选中时单实例闸排队互斥;release-boundary-check 进 CI。
· 指导与文档:PROTOCOL 补四条硬事实(非驻留不能自回、contribution 粒度、结案交代、
amend 会重开);人类文档同步;本机 DSH 升级步骤写进 docs/dev-build.md。
· 兼容性:0.2.1 线 rc.2 / rc.3 / rc.4 的宿主半与浏览器半实测通过,
dshReleases 三条都记为 compatible。
· 发布:GitHub release 现在由 publish.yml 在 tag 推送时创建并附 tarball(此前一直是手工,
因此 v0.2.3 / v0.2.4 只有 tag 没有 release 页——那两页已补齐)。
验证(本机 0.2.5 源码):typecheck 0 错;pnpm test 十六套 1151 项全绿;
pnpm test:tools 三套 240 项全绿;pnpm build 后 lib 与源码一致。
CI 另跑 release-boundary-check(产物不含 dev/、bundle patch 只有 release 一行)。
兼容性:dsh.compatibility.dshReleases 声明 0.2.0-rc.2 与 0.2.1-rc.2 / rc.3 / rc.4。
v0.2.42026-10-28
· dsh-report-ledger 0.2.4
· 画布
· 帧内按父子关系分列:链保持一列、分叉向右;帧内列间距加宽,给线与箭头留横向空间
· 跨列线程走列间空隙并分道;同沟槽车道合并、按端口排序;帧边按端口数加长
· 固定一个版式(缩放不再换档);帧排布先量线道再定间隙;箭头、线宽、车道间距整体放大一档
· 下钻:范围根帧 + 面包屑 + 变换记忆(真实浏览器实测)
· 悬停流光:行进点恒定线速、密度按线长分配;图的图例可静音一类线;四类线各有色相并保留虚实,卡片状态加形状标记
· 时间线:从传入会话向上解析到树根,画布显示整棵树;被汇报提到的已知会话补进帧,树外只留无记录的 id
· 汇报页
· 传递路径改成 GitHub 式时间线:中性连线、实心/空心点、底色标签、对称引用
· 正文默认折叠,收起时只显示 front matter 缩略
· 账本文件改成交付卡:类型图标 + 文件名 + 路径 + 紧凑原生控件,用宿主桌面的默认应用打开(含预取与选择器)
· 画布与汇报列表共选:卡片直达汇报页,悬停互相点亮
· 账本读取
· 标题走零 I/O 的投影缓存;昂贵的标题回退有界且被记忆
· 列表只读文件头;同一份文件一次变更只读一次
· 修复与发布
· 修复 0.2.4 审计发现的 2 个 S1 与 10 个 S2;处理 S3 剩余技术债
· 同伴继承开启者的权限状态(沙箱 / 审批 / 预设)
· README 瘦身到使用面,设计与开发记录拆进 docs/
· 发布:0.2.4 版本号与 Gitee 资产 URL
· 验证
· ci、publish to npm、mirror to gitee 三个 workflow 均 success
· npm 上 0.2.4 为 latest
v0.2.32026-10-28
发布 0.2.3:读路径与开发者模式
代码内容(本次 tag 包含的 5 笔):
· 读路径:前缀读 + 签名缓存 + 单趟投递。列表只读文件头、同一份文件一次变更只读一次;
补投从「每会话扫一遍全库」改为一趟账本服务所有等待会话(20×200 场景 21–31s → 冷 309ms / 热 32ms)。
磁盘格式与语义均未变:待投仍由跳流派生,每份仍在锁内重读跳流后才投。
· 开发者模式:预设代理改为插件自有状态(不向 DSH 注册预设)、结论归档让 fork 不必重踩、
退役时的声明式交接(report_transfer / report_handover)。
· 文档订正:工具数量改为十三个(开工作区十四个);门禁计数改为十四套 1034 项 + 工具面三套 162 项。
验证(本机 0.2.3 源码):tsc 0 错;pnpm test 14 套 1034 项全绿;pnpm test:tools 3 套 162 项全绿;
pnpm build 后 lib 与源码一致(CI 的「Committed lib/ matches a fresh build」会核同一件事)。
兼容性:dsh.compatibility.dshReleases 保持只声明 0.2.0-rc.2。对 0.2.1-rc.2 已核 shell 冻结
模块表(9 条逐条相同)与所用 primitives 导出,但未在其上跑完整套件,不写进已验证目录。
未做:隔离环境安装验证(0.2.0 起即缺)。
数据来自仓库的 GitHub Release,可能与插件实际版本不一致。
README
由 DeepSeek 最新模型翻译生成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 也能装:
展开完整文档(README 剩余 1 节)
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 进来的、或发起者在别的树里的汇报,如果没有这条泳道就无处落点;它们全部落在最后一列 树外。
This horizontal scroll is intentional. The view has its own controlled scroll box (wide enough to scroll horizontally, height capped at 46vh), unlike the previous accidental scrollbar where "content burst the entire tab and the scrollbar was only reachable at the very bottom": here it is wrapped in a box. The default geometry (lane 120px, gutter 84px) is set for "eight lanes just fit in one tab" (measured at 1108px, no horizontal scroll needed).
The layout is pinned down by 34 assertions: lane order equals subtree order, nodes land in the initiator's lane, edges land in the recipient's lane and on the same row, duplicate recipients draw only one line, self-sends draw nothing, out-of-tree landings go to the shared lane, thread edges exist only when both ends of the thread are drawn, empty input produces no NaN, and the same input yields byte-identical geometry (see scripts/topology-check.ts).
A legend is required; per-edge text is not. If no one explains the four line styles, readers can only guess—so there is a legend next to the title, and its samples use the exact same classes as the drawn lines (edgeClass()); changing how lines look cannot leave behind a lying legend. But attaching text to every edge is redundant: the line style already encodes primary/cc/co-write. So text appears only when hovering over a report, answering "what is the relationship of the path I'm currently tracing". This is exactly what Mermaid's swimlane docs mean by "Label Cross-Lane Handoffs", except its carrier is line style + hover, not persistent labels.
Threads travel through the gaps between lanes. Parent-child links cross rows, and drawing a curve directly would pass through the rows and nodes in between; so the model computes viaX for it—a lane boundary. Nodes are all centered in lanes, and the boundary is half a lane away from any node, so the link never touches a node. This rule is also in the assertions (the boundary must fall strictly between the centers of two columns, and never on the center of any column).
Scale: layout is free, rendering is the cost
Measured on a ledger of 300 reports (isolated instance, real browser):
| What was measured | Result |
| Pure layout (500 lanes × 5000 reports × 7916 edges) | 3.4 ms (median of 20) — layout is never the bottleneck |
| Rendered elements (300 reports, before the change) | 5469: 838 edges each with a + 838 arrow s + 300 separator lines… |
| One hover (before the change) | 89 ms — every mouseenter had to change the opacity of 831 s |
Both fixes paid off in measurements:
1. Merge same-kind edges into one , switch arrows to SVG marker—edges were never clickable anyway, and one element per edge only bought "one shape per redraw". Arrows are decorations of the path, not nodes; handing them to marker means zero extra elements.
2. Fade only applies to "the edge layer", never through descendant selectors. This is the most valuable one: separately rewriting a property matched by a descendant selector ([data-hot] … :not(…)) itself costs 35 ms, because the browser has to recompute styles for the entire subtree, even if nothing visibly changes in the end (I overrode the result with injected CSS using !important and still couldn't bring it down—overriding only changes the result, not the recomputation). So nodes simply don't fade: when tracing a path, what should recede is the relationship, not the participants.
Result: elements 5469 → 2689, edge paths 838 → 10, arrow polygons 834 → 0, hover 89 → 44 ms (best case 16–19 ms, i.e. one frame).
3. Windowing: render only the rows actually visible in the scroll box (4 rows of overscan above and below). With 300 reports, only 14–20 rows are rendered at any moment, elements 2689 → 265, hover 44 → 32 ms—and 32 ms is the floor of the measurement method (each sample waits two frames, about 33 ms at 60 Hz), meaning there is no measurable overhead left. All four scroll positions were verified to have the window covering the viewport (top/middle/bottom/near the end), the sticky header is in place, and there are no blank bands.
⚠️ Every derived list introduced by windowing must depend on the window. I switched the node list's data source from layout.nodes to shownNodes, but forgot to add it to the useMemo dependency array—the result was that edges and the timeline scrolled along, but only the nodes stayed put, looking like "it scrolled but the content didn't change". This kind of bug is obvious in the browser (caught immediately by testing four scroll positions), yet all seven sets of assertions fail to catch it: the pure model is not wrong; what's wrong is "whether React recomputed". So the windowing assertions only cover "which rows should be in the window", not this layer, and that needs to be stated clearly.
Next version topology: card canvas (reference and measurements)
The swimlane version proved that "relationships and belonging can be drawn", but it has a fatal weakness: nodes are chips and carry no content—a report is reduced to just an R-0001 in the diagram. The next version switches to a card diagram, and two decisions are already made:
- Session = box (like the Comment in Blueprint: with a title, background color, collapsible as a whole), with belonging expressed by containment, not position;
- Fully canvas-rendered (not SVG, not DOM).
Card diagram design: within a frame, report cards are stacked by time; lines have direction and semantics—primary as solid line (card out → recipient frame's inbox port), cc as dotted line, co-write as dashed line (co-writer frame → card inbox), thread as blue line (parent card bottom edge → child card top edge). Three LOD levels: full card (≥0.8) → compact card (0.4–0.8) → color bar (,之后所有操作累积、最后一次 setAttribute('d', …) 提交——正是我这边实测出来的“合并路径”(838 条边 → 10 条)。④ 坐标统一走 (x + dx) scale 并取整,奇数描边宽补 translate(0.5, 0.5) 求清晰。⑤ 文字不跟几何一起画:foreignObject + 真 交给浏览器排版,并留住节点引用让 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 后浏览器自己换掉了插件(页内不刷新),这就是改完立刻能看到的原因 |
三处是看了截图才改的(不是想出来的):
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= | 该会话的递归子树(DFS 前序、兄弟按创建时间)+ 子树相关的汇报缩略 |
| GET /api/report-ledger/report?id= | 单份汇报的缩略 + 完整路径 + 待送达集合 + 正文 |
子树由 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" 的行。 行里还有一个任务芯片——一个真的 ,于是构成嵌套交互内容;更糟的是 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 ""
该 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 --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#(或 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//attach_files?access_token=" \
-F "file=@dsh-report-ledger-0.1.0.tgz"
已知限制
- 单进程假设。 每个汇报的写操作由进程内互斥锁串行化。跨进程共享同一账本需要租约协议——这与 harness 自身延期的工作相同。
- 正文并发覆盖。 跳流 append-only 永不丢跳,但两个人同时改写同一份正文是后写者胜(report_contribute 用追加,规避了常见路径)。
- fromName 取自会话标题,是"会话名"而非"代理名"。预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
stone-brick/dsh-report-ledger
追踪代理间汇报路径,留回执与结案记录
相关插件 · 对话 / 记忆
上下文中枢oceanbase/powercontext
上下文跟着工作走 —— 跨会话、跨智能体、跨人。
★ 1,489已汉化DSH
73.1
智能体记忆库plastic-labs/honcho
Honcho 是记忆基础设施,用来构建有状态的智能体。这些智能体能理解不断变化的人、智能体、群组、项目和想法。
★ 7,918已汉化DSH
71.7
长程智能体控制面loopx-project/loopx
给 Agent 一个目标,让工作持续向前。
★ 6,358已汉化DSH
71.3
智能体上下文数据库volcengine/OpenViking
给 AI 智能体用的上下文数据库
★ 41,453已汉化DSH
71.3
长效机器人团队Fei2411/DS-Bot
Download DS Bot for Windows 10/11 64-bit / 下载 Windows…
★ 5,293已汉化DSH
71.2
可携带记忆层EverMind-AI/EverOS
为什么选择 EverOS
★ 13,797已汉化DSH
71.1
这是你的仓库?认领条目