# dsh-session-isolation [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![tests: 25 passing](https://img.shields.io/badge/tests-25%20passing-brightgreen.svg)](#开发) [![DSH plugin](https://img.shields.io/badge/DSH-plugin-4c6ef5.svg)](#安装) **DSH 的「每个会话一个独立工作目录」插件**——即 Codex 那种体验,按项目选择开启, 可以在 Web 界面里点开关,也可以在对话里让 agent 打开,或者直接放一个标记文件。 [English](README.md) | 中文 --- ## 要解决的问题 DSH 会把所选工作区目录交给每个会话,于是同一项目里的两个会话写的是同一批文件。 三个 agent 跑在同一个目录上,后一个会把前一个刚写完的文件"顺手修掉"。 ## 它做什么 某个目录开启隔离后,在那里**新建**的每个会话都在自己的 `session-` 子目录里工作。 这个子目录(而不是项目目录)成为该会话 `workspace-write` 沙箱的写入根,因此并行会话 互相写不到对方的文件;而项目本身的文件对所有会话仍然可读。 ``` project/ ← 你选择的工作区(每个会话都能读) ├── .dsh-isolate ← 开关(一个空文件;删掉即关闭隔离) ├── src/ … ← 项目自己的文件(对会话只读) ├── session-1a2b3c4d/ ← 会话 A 的沙箱根,只有它能写 └── session-9f8e7d6c/ ← 会话 B 的沙箱根,只有它能写 ``` * 目录在会话**创建之前**就建好,名字取自会话 id 的前八个字符(极小概率重名时加 `-2`、`-3`)。 * 会话在侧边栏里仍归属该项目;`AGENTS.md` 发现与项目技能查找照常工作——它们都从会话 cwd 向上走。 * **已存在的会话不会被搬动**:隔离改的是「下一个会话在哪里工作」,不是正在跑的会话。 * 关掉开关后不再新建私有目录;此前创建的会话保留自己的目录,并继续归属于该项目。 没有改动 DSH 任何源码:插件走标准 Cordis 组合挂载,加载时包装两个接缝,卸载时复原。 ## 安装 插件无需构建,除 DSH 宿主本身外没有运行依赖。仓库包含 Cordis 入口、六个 `lib/` 模块、 浏览器半边、bundle patch 和完整测试。已在 DSH `0.1.6-alpha.2` 上验证,要求 Node.js 22+。 ### 从 GitHub 安装 ```bash dsh plugin --profile add \ https://github.com/sweet-boby/dsh-session-isolation.git ``` 安装后重启一次宿主。插件管理器会安装包、选中 bundle 并应用 [`cordis.patch.yml`](cordis.patch.yml);重启是为了让浏览器模块表发现新的 `dsh.client` bundle。 ### 从 Web 界面安装 打开 **插件 → 添加插件**,粘贴 GitHub 地址: ```text https://github.com/sweet-boby/dsh-session-isolation ``` 如果当前 DSH 版本的安装器只接受本地 bundle 路径,就先克隆仓库,再粘贴本地目录的绝对路径。 ### 从本地检出安装 ```bash git clone https://github.com/sweet-boby/dsh-session-isolation.git dsh plugin --profile add \ /绝对路径/dsh-session-isolation ``` ### 手工写入 profile 在 `~/.dsh/profiles//package.json` 里加依赖并选中 bundle: ```json { "dependencies": { "dsh-session-isolation": "github:sweet-boby/dsh-session-isolation" }, "dsh": { "profile": { "bundles": ["…", "dsh-session-isolation"] } } } ``` 本地开发时,把依赖改成 `file:/绝对路径/dsh-session-isolation`,然后在 profile 目录安装: ```bash cd ~/.dsh/profiles/ pnpm install --ignore-scripts ``` 无论用哪种方式安装,最后都要重启宿主。 ### 运行要求 * Node.js 22 或更新版本。 * DSH 组合里需要有 `sessionController`、`workspaceRegistry`、`tools` 三个服务——所有 base 系 profile 都有。缺任何一个时插件保持 pending,而不是半加载状态。 * Web 服务(`ctx.webServer`)是可选依赖:没有它时插件保留策略与工具,只是不提供 GUI 页面。 ## 开启隔离 隔离是**目录**的属性,持久形态就是目录里的 `.dsh-isolate` 标记文件——设置跟着文件夹走, 用文件管理器也能改。 1. **Web 界面**:**设置 → 内置插件 → 工作区隔离**。每个工作区一行,显示路径、会话数和一个开关。 列表每五秒刷新一次,所以在别处做的改动也会同步过来。 2. **对话里**:让 agent 打开隔离。它会调用 `session_isolation` 工具,动作有 `status`、`list`、 `enable`、`disable`,可选绝对路径 `path`(默认取当前会话的工作区)。 3. **宿主代码**:`await ctx.isolatedSessions.setIsolated(workspaceId, true)` 或 `setIsolatedPath(path, true)`;`list()` / `isIsolated(id)` 读取当前状态。 4. **手工**:在项目目录里建一个空的 `.dsh-isolate` 文件。 ### 怎么确认生效了 在该工作区里**新开**一个会话,问它的工作目录(`pwd`,或让它写一个文件)。答案会是一个 `session-` 子目录;项目根目录在它的文件策略里是只读位置。开两个这样的会话,会得到 两个不同的目录。 ## GUI 页面 浏览器半边是手写的客户端 bundle([`lib/client.js`](lib/client.js)),通过 `dsh.client` 与 `exports["./client"]` 声明,因此 Web 客户端模块表会像对待打包插件一样提供它。它用 `ctx.slots.inject` 注册一个 `settings.plugins.tab` 条目,也就是**先等 Settings 侧声明该 slot** 再注册——插件激活顺序并不稳定,往未声明的 slot 里注册会让整个浏览器启动失败。 外部插件的浏览器半边无法声明 Remote 命名空间,所以两半通过 Web 服务上的两个具名路由通信: | 路由 | 方法 | 请求体 | 响应 | |---|---|---|---| | `/session-isolation/workspaces` | `GET` | — | `{ workspaces: [{ workspaceId, title, path, isolates, sessionCount }], isolatedCount }` | | `/session-isolation/toggle` | `POST` | `{ workspaceId, enabled }` | 刷新后的列表,或 `{ error }` | 路由本身不持有状态——所有答案都是从标记文件现场投影出来的;它们和其他插件资源路由一样, 是绑定在回环地址上的免认证路由。没有 Web 服务的宿主不会注册它们。 > **页面出现需要重启一次宿主。** 客户端模块表在激活时扫描 `dsh.client` 声明,并按包名缓存 > 「这不是客户端包」的判定,所以宿主启动之后才出现的 bundle 要等下次启动才会被拾取。 > 宿主侧改动可以通过配置重载生效,**新的**客户端 bundle 不行。在此之前,其他开关 > (工具、标记文件、宿主服务)都已经是可用的。 ## 实现原理 两个接缝,加载时包装、卸载时复原;未接管的调用一律委托原实现;接缝若被上游挪动会**显式报错** (`… this DSH build moved it`),而不是静默地什么都不隔离。 1. **`ctx.sessionController.create`**——GUI 调用的 Remote 入口。DSH 解析新会话 cwd 的规则是 `workspace.path ?? request.cwd ?? default`,而控制器会拒绝同时携带 `workspaceId` 与 `cwd` 的请求,所以插件在**这一次调用期间**把目标工作区实体指向新分配的私有目录。持久记录不动, 重定向在 `finally` 里移除,创建失败会删掉刚分配的目录。重定向期间实体的自身写入落在一个 可变槽位里,因此本次调用刚记账的会话不会在移除重定向时被回滚。 2. **工作区注册表的会话路径索引**——每个 `WorkspaceEntity` 实际读取的那个 host 对象 (注册表只构造一份并共享)。位于隔离工作区内的会话目录会解析回该工作区根路径,于是原有的 挂载校验、持久化成员裁剪、一次性历史 bootstrap、以及「一个会话只属于一个工作区」不变量 全都继续成立,无需改动源码。 隔离状态本身不由插件持久化:每次都从标记文件重算,因此重启、外部编辑、其他工具最终都会收敛到 同一个答案。扫描是串行的;若某次扫描落在"按调用重定向"进行期间,它会合并而不是回退一个当时 看不见的决定。 ## 限制与实话 * **项目根目录对会话不可写。** 这正是隔离本身。如果某个会话必须改项目自己的文件,就别给那个 项目开隔离,或者把共享材料放进会话目录。 * **它不是 git worktree 的替代品。** 会话目录在仓库内部,`git` 能对上父仓库,但**分支**仍是共享的。 要做到每个 agent 一条分支需要 worktree,本插件不创建 worktree。正常的代码仓库建议**保持关闭**: 开了之后 agent 改不到仓库本身的文件。 * **读权限不变。** 隔离约束的是写入,与 DSH 自己的 `workspace-write` 语义一致;会话仍能读项目。 用户也可以为某一轮批准 `danger-full-access` 绕过边界——那是 DSH 的审批策略,不是本插件的行为。 * **卸载插件会让隔离过的会话变成未分组。** 注册表的成员规则是"规范化 cwd 全等",没有插件时私有 目录不再与项目匹配。重新启用插件即可恢复分组;无论哪种情况,会话日志与目录都不会被改动。 * **嵌套遵循最近的已标记祖先。** 把一个工作区注册在隔离项目的路径本身上时,该路径以那个工作区为准; 其内部的会话目录归属于最近的已标记祖先。 * **本地 `file:` 安装是拷贝。** pnpm 对 `file:` 依赖做拷贝而不是软链,所以改完插件要在下次启动宿主前 重新 `pnpm install`(或删掉 `node_modules/dsh-session-isolation` 再装)。 ## 常见问题 | 现象 | 原因 | 处理 | |---|---|---| | 设置里没有「工作区隔离」标签页 | 宿主启动时客户端 bundle 还不存在 | 重启一次宿主(见上文重启说明) | | `/session-isolation/workspaces` 返回 404 | 插件没加载,或宿主没有 Web 服务 | 确认 bundle 已选中且已安装;查看宿主启动日志里 `session-isolation` fiber 是否失败 | | 新会话仍在共用项目目录 | 标记不在该工作区自己的目录里,或会话早于标记 | 确认 `.dsh-isolate` 就在页面显示的工作区路径下;只有之后新建的会话才隔离 | | 隔离过的会话显示为未分组 | 该宿主上插件没生效 | 重新启用插件;目录与日志都完好 | | agent 写不了项目根目录的文件 | 设计如此 | 关掉该项目的隔离,或让会话写进自己的目录 | ## 卸载 1. 逐个项目关闭隔离(各自删掉 `.dsh-isolate`),也可以直接留着——没有插件时它们不起作用。 2. 从 profile 的 `dsh.profile.bundles` 与 `dependencies` 中移除 `dsh-session-isolation`, 在 profile 目录执行 `pnpm install --ignore-scripts`。 3. 重启宿主。 卸载不会触碰任何会话目录与日志。 ## 开发 ```bash node --test "tests/*.test.js" ``` | 测试文件 | 覆盖内容 | |---|---| | [`tests/isolation.test.js`](tests/isolation.test.js) | 标记策略、目录分配、两个接缝包装、状态缓存、服务 API,以及用桩件驱动的 HTTP 路由 | | [`tests/integration.test.js`](tests/integration.test.js) | **真实**的 `dsh-storage` → `dsh-storage-domain` → `dsh-workspace` 栈加真实 `dsh-tools`:会话创建、并发会话、成员归属、同一介质上的重启、开关切换 | | [`tests/tool.test.js`](tests/tool.test.js) | `session_isolation` 工具对外暴露的 JSON Schema | | [`tests/client.test.js`](tests/client.test.js) | 浏览器半边的模块表交接与 slot 声明等待 | `tests/integration.test.js` 用绝对路径导入某个 DSH 检出里的构建产物(文件顶部的 `CHECKOUT` 常量), 检出位置变化时改它即可。 ### 目录结构 ``` index.js Cordis 插件:接缝包装、服务、工具、可选 Web 路由 lib/isolate.js 标记策略与会话目录分配 lib/state.js 隔离状态缓存:标记扫描、重定向守卫、串行化 lib/patch.js 两个接缝包装 + ctx.isolatedSessions 服务 lib/tool.js session_isolation 模型工具 lib/web.js GUI 页面背后的两个 HTTP 路由 lib/client.js 浏览器半边(设置 → 内置插件 页面) cordis.patch.yml 插入插件行的 bundle patch ``` ## 许可证 [MIT](LICENSE)