dsh-session-isolation
DeepSeek Harness 插件:让同一个工作区里的多个会话互不覆盖对方的产出。
这个插件是干什么用的
一句话:给同一个工作区里的每个会话划一条写入边界。
它要解决的问题
DeepSeek Harness 的会话,在"对话"这一层本来就是隔离的——每个会话有自己独立的事件日志、独立的 agent、独立的上下文窗口。唯一被共享的东西是工作目录。
于是在同一个工作区里开多个会话时,会出这种事(下面是一个真实案例):
在同一个工作区里开了两个会话,分别做两个不相干的项目(下面叫 A 项目和 B 项目);
会话 A 建了一份"目录级"文档
README-文件索引.md,用来索引它自己那批产出;会话 B 看到这个工作区,按"这个目录是我的项目"的假设去改这份索引,把它自己项目的内容也塞了进去;
结果一份文档里混着两个项目的内容。甚至出现了这样的标题:
## 五之三、B 项目(独立于 A 项目)—— 一边声明"独立于 A 项目",一边被写进同一个索引。
并行跑的时候更直接:两个会话写同一个路径,后写的把先写的整文件覆盖。
这不是"会话隔离坏了",而是多个写入方共享了同一批文件。
装上之后是什么样
- 每个会话(以及它派生的子代理)自动获得自己的产出目录:
<工作区>/.sessions/<会话短id>/ - 会话的第一步会收到一条说明,告诉它产出目录在哪、哪些东西不能碰;
- 当一个会话想覆盖另一个仍然存活的会话的文件时,写入被拒绝,并被告知改写到自己的产出目录;
- 归属会话结束、或应用重启之后自动放行,不会永久锁死文件。
它不做什么
- 它不改变会话的 cwd。会话的工作目录仍然由 DSH 创建时决定,所以这是"约定 + 拒绝"式的隔离,不是物理隔离;
- 它不拦 bash。
bash里的>、tee、python -c "open(...,'w')"写文件不会被守卫拦住(详见"已知边界"); - 要做到物理隔离,仍然是"一个话题一个工作区"或 git worktree。
怎么工作
两条机制,都挂在 DSH 自己的扩展接缝上,没有一行 hack:
机制 1 · 产出目录约定(挂 agent/pre-step)
每个会话在第一步收到一条注入说明,内容是:
- 本工作区目录;
- 本会话的产出目录绝对路径;
- 四条写入边界(新文件写自己的目录 / 不动别人的文件 / 目录级索引文件默认不碰 / 拿不准就问用户)。
每个会话只注入一次。
机制 2 · 跨会话写入守卫(挂 ctx.tools.guard)
write / edit / str_replace_editor / apply_patch 在落地之前检查目标路径:
- 如果这个文件已被同一工作区里另一个仍然存活的会话占用 → 直接拒绝,并给出替代路径;
- 归属在
tools/result里按"文件确实落到磁盘上"记账,失败或被拒绝的调用不会产生归属。
归属按血缘计算(关键设计)
- 子代理(
header.origin === 'subagent')沿parentSession上溯到顶层会话, 所以整棵委派树算同一个归属:父会话和它的子代理可以自由互相修改产出; - 两个无关的顶层会话之间才互相隔离;
- 用户手动 fork 出来的会话不是子代理,因此是独立归属。
归属只在该顶层会话存活期间生效。会话结束或应用重启后自动放行——避免"文件被永久锁死"这种反向麻烦。
安装
命令行 profile
dsh plugin --profile web add /绝对路径/dsh-session-isolation
本插件零第三方依赖,只用 node: 内置模块,所以不需要构建步骤、也不需要联网。
DSH Desktop
⚠️ 桌面版真正的 DSH_HOME 是
~/Library/Application Support/dsh-desktop/harness
不是 ~/.dsh。 直接在终端跑 dsh plugin 会装进 ~/.dsh,对桌面版不生效。
要么用桌面版内置的插件市场安装,要么显式指定 home:
DSH_HOME="$HOME/Library/Application Support/dsh-desktop/harness" \
dsh plugin --profile web add /绝对路径/dsh-session-isolation
装完重启 DSH(CLI 安装不会热加载)。
验证装上了
dsh --profile web --dump-config | grep -A2 session-isolation
应能看到:
# == dsh-session-isolation
- id: session-isolation
name: dsh-session-isolation
验证真的生效
重启后,在任意工作区开一个会话并让它做事,然后:
ls "<工作区>/.sessions"
应该出现以会话短 id 命名的目录(短 id 就是 $DSH_SESSION_ID 去掉 session- 前缀后的第一段)。
配置
全部通过环境变量:
| 变量 | 默认 | 作用 |
|---|---|---|
DSH_SESSION_ISOLATION |
guard |
guard = 约定 + 守卫;advise = 只注入约定、不拒绝;off = 完全关闭 |
DSH_SESSION_ISOLATION_DIR |
.sessions |
产出目录名 |
DSH_SESSION_ISOLATION_MKDIR |
1 |
设 0 则不主动创建产出目录,只告知路径 |
用 advise 或 off 可以随时把插件软化/静默掉,不用卸载。
已知边界(重要)
只拦文件工具,不拦 bash。
bash里的>/tee/python -c "open(...,'w')"这类写文件不会被守卫拦下—— 可靠地解析 shell 命令很困难,硬拦会误伤正常命令。这条路径只由机制 1 的约定覆盖,没有强制。归属记录是进程内内存,DSH 重启后清空。 重启后已存在的文件重新变成"无主",第一个写入它的会话会取得归属。
不做物理隔离。 见上文"它不做什么"。
只对顶层会话之间生效。 子代理按血缘并入其父会话,不独立成归属。
测试
node test/logic.test.mjs
21 项离线测试,用一个最小 fake ctx 复现 ctx.agents.get / ctx.tools.guard / ctx.on
三个接缝,覆盖:
- 两个会话争同一文件的拦截
- 归属会话自身放行
- 血缘归属(父会话的子代理放行 / 别的会话的子代理照样拦)
- 其他工作区不受影响
- 归属会话离线后放行
- 只读工具与非文件工具放行
edit/str_replace_editor同样受守卫pre-step只注入一次,reject决策不被污染
卸载
dsh plugin --profile web remove dsh-session-isolation
实现要点(给维护者)
| 依赖的 DSH 接缝 | 参考位置 |
|---|---|
ctx.on('agent/pre-step', handler, { prepend: true }) |
packages/context/time-context/src/index.ts |
ctx.tools.guard(fn),返回字符串即拒绝 |
packages/core/tools/src/index.ts |
ctx.on('tools/result', (exec) => …) |
packages/context/agent-instructions/src/index.ts |
agent.session.header 的 cwd / id / origin / parentSession |
packages/core/session/src/types.ts |
子代理写 parentSession |
packages/subagent/subagent/src/child-agent.ts |
消息来源 { kind: 'plugin', form: 'snapshot', sections: [...] } |
packages/llm/llm/src/message.ts |
本插件刻意不 import 任何 @deepseek-ai/* 包:以本地路径安装时 peer 依赖不一定解析得到,
而运行期的消息 id 只是普通字符串(brand 只存在于类型层),所以手工构造消息与工厂函数等价。
License
MIT
No comments yet. Be the first to write one.