package.json 双声明:dsh.bundle(宿主层)+ dsh.client(浏览器层)
src/org.ts 纯投影:会话事件 → 事务所 + 时间线(零 import、零 IO)
src/index.ts Cordis 宿主:订阅事件、观察策略、挂 /abyss 端点
client/client.js 嵌入式面板(手写,无构建步骤)
lib/index.js 宿主层的编译产物
tests/ 113 个用例
为什么宿主层要编译:dsh 用普通 Node 跑插件,而 Node 拒绝对 node_modules 里的 TypeScript 做类型剥离。一个直接发 .ts 入口的插件在作者机器上(pnpm dsh 跑在 tsx 下)能用,用户装上就炸。所以宿主层用 tsdown 编到 lib/index.js;浏览器层本来就是纯 JavaScript。
零运行时依赖:@deepseek-ai/cordis 是纯类型导入,构建时擦除;运行时只用 node: 内置模块。
src/org.ts 是一个纯函数集合,把会话事件流归约成一间事务所。它不认识网络、不认识文件、不认识 cordis。
用到的事件(dsh 一共声明 47 种,这里读其中 20 种):
| 事件 | 投影成什么 |
|---|---|
subagent/descriptor |
一位新同事入职:岗位、厂商、正式/临时 |
session/title |
案子的名字(和产品侧栏同一个名字) |
request/header / request/context |
模型路由、上下文窗口 |
turn/start / turn/end |
上下班(考勤泳道的一段班次) |
assistant/chunk |
思考中 / 发言中 |
assistant/message |
台词 + 令牌 + 工资 + 上下文占用 |
tool/call |
活动状态;subagent → 派活,send_message → 私聊,report → 汇报 |
tool/result |
失败计数(error 或 结果块的 isError) |
| `tool-workflow/agent-start | end` |
approval/asked / approval/decided |
等批准 / 已批复 + 阻塞时长 |
llm/retry |
重拨 |
compaction/start / compaction/summary |
去档案室 + 归档掉的令牌数 |
todo/write |
员工档案里的待办清单 |
设计原则:
- 每一句台词都是日志原文,没有模板句。取不到文本时宁可只改状态、不编一句话。
- 返回相同引用表示无变化,宿主据此决定要不要广播。
- 不猜:路由没广播上下文窗口就不画水位;shell 退出码非零不计入失败(dsh 把它当普通输出,去嗅探那段文字会让每个没匹配到的
grep都变成事故)。
dsh 的真实层级是:
进程(一个 dsh 实例) 事务所
└─ 工区(cwd) 项目
└─ 会话树 案子:主会话 + 它下面的全部子代理
└─ agent 会话 一位成员
一个项目里开 N 个会话就是 N 个并行的案子,不是一张扁平花名册。面板因此有三档范围:本会话 / 本项目 / 全部,页签和底栏都跟着当前范围走。
超过 maxTeams 时,宿主只淘汰已经全部结束的案子,进行中的永不淘汰。
全部挂在产品自己的 origin 下 /abyss,没有 CORS 头——一个宽松的跨域头会让你访问的任意网站都能读到你的会话标题、工具名和花费。
| 端点 | 用途 |
|---|---|
GET /abyss/state |
当前快照(成员、场景、组织树、主题) |
GET /abyss/events |
SSE 实时流:snapshot / staff / scene / gone |
GET /abyss/replay?team=<id> |
从磁盘日志重建一个案子(面板用它回放,也用它重建办公室) |
GET /abyss/report?team=<id> |
同一个重建结果渲染成 Markdown 复盘 |
GET /abyss/cases |
档案库:磁盘上的案子列表(只读 header,不打开日志) |
无 web 服务器的组合(headless / ACP)可以显式配 port 单独开一个数据端口;办公室视图本身是 Web UI 特性。
- 实时:
ctx.on('session/event')。 - 历史:优先
ctx.sessionPersistence(durable 目录),回退到ctx.sessions(只有本进程还开着的会话)。这一步是「重启后办公室还在」的全部原因——只读实时 store 的话,昨天的案子一律 404。 - 读不到就上报:某个成员的日志损坏(比如 seq 断档)时,重建结果会带上
unreadable,复盘报告顶部和回放条上都会写明。一份安静地少算了的报告,比没有报告更糟。
- 手写 lazy-CJS bundle,经
window.__ModuleLoader__注册,由产品的/plugins下发。 - 所有插值都转义:会话标题、工具名、场景台词都是模型产物,一个没转义的引号就是注入产品自己的页面。
- 渲染按帧节流且有上限:一屏最多 12 张卡片、浏览器侧最多 300 条场景。
- 瞬时状态放在模型里,不放在 DOM 里:每次渲染都会替换楼面的 markup,只存在 DOM 里的气泡会被下一次重绘抹掉。
- 组织树由面板自己推导,不依赖线路上的那份——否则从磁盘重建的办公室会出现「楼面 7 个人、组织树 0 个节点」。
- 主题跟随
body[data-ds-dark-theme],语言跟随html[lang],动效尊重prefers-reduced-motion。
npm test # 113 个:投影 46 + 宿主装配 15 + 浏览器 52- 浏览器测试驱动的是发布的那个 bundle,不是逻辑副本。本项目踩过的每一个客户端缺陷——全局被遮蔽、引号未转义、状态只存在 DOM 里、折叠开关反转、动画 class 落到文字上——对"重新实现一遍逻辑"的测试都是不可见的。
- 宿主测试用真实事件形状驱动
apply(),覆盖端点、SSE、配置校验、持久层回退、损坏日志。 - 真机联调:真实跑任务并在运行中观察面板、并发三个会话、真实触发审批升级与工具失败;并把插件报出的数字和原始日志逐项对账(排除损坏成员后逐项吻合)。
- 实时楼面在内存里,重启后靠磁盘重建;
compaction与llm/retry两条路径有单测但缺真机证据(真实日志里它们分别出现 0 次和 1 次)。 - 便签动画需要收发双方的卡片都在屏幕上,否则只有发送方说话。
- 回放按压缩后的真实间隔走,长时间空档会被跳过而不是等过去。