简体中文 | English
# dsh-workspace-gate · 工作区插件门控
> 插件照常加载,但**按工作区**把它不该占的提示词前缀摘掉——工具 schema 与提示词段都不再进模型上下文。
[](https://github.com/M1r0er/dsh-workspace-gate/tags)
[](https://www.npmjs.com/package/dsh-workspace-gate)
[](LICENSE)
[](https://github.com/topics/dsh-plugin)
一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)插件。
它让"要不要让模型看见某个插件"从**全局**下沉到**工作区**:插件依然全部加载、功能随时可用,
但在你指定的目录下,它的工具 JSON schema 和系统提示词段不再出现在每一次请求里。
省下的是每轮请求的输入 token,而不是插件的加载开销。
---
## 目录
- [它解决什么问题](#它解决什么问题)
- [它做什么 / 不做什么](#它做什么--不做什么)
- [主要特性](#主要特性)
- [界面](#界面)
- [安装](#安装)
- [使用](#使用)
- [归属是怎么判断的](#归属是怎么判断的)
- [数据存放](#数据存放)
- [为什么不是"真·停用插件"](#为什么不是真停用插件)
- [实测数据](#实测数据)
- [开发](#开发)
- [已知边界](#已知边界)
- [许可证](#许可证)
---
## 它解决什么问题
DSH 自带的插件管理只有 **全局** 开关:一个插件要么对所有工作区都开,要么都关。
但插件对 prompt 的成本是**每轮都付**的——只要它启用了,它的工具 JSON schema 和
系统提示词段就会出现在每一次请求里,哪怕这次对话完全用不到它。
典型场景:
- 装了「小说写作」插件,但你 90% 的时间在写代码;
- 装了「网页搜索」全家桶,但某个离线项目根本不该联网;
- 学生工作区只需要讲解/脑图,教师工作区只需要备课/教案。
**本插件把"要不要让模型看见"从全局下沉到工作区**,不动全局开关、不重启、不重载插件。
## 它做什么 / 不做什么
| 做 | 不做 |
| --- | --- |
| 按目录(含子目录继承)决定哪些工具/段不进 prompt | ❌ 不安装/卸载/升级插件 |
| 组级开关 + 展开到单个工具名/段名 | ❌ 不改变插件的全局启用状态 |
| 按来源分档:第三方 / 自定义 / 官方 / 官方可选包 | ❌ 不能把全局已停用的插件"变出来"(只减不加) |
| 自动归属推断 + 静态扫包 + 手工归属 | ❌ 不回溯已开会话(前缀已经发出去了) |
| fail-open:任何异常都当作"没配置" | ❌ 不碰 sandbox / approval 策略 |
## 主要特性
- **工作区级收窄**:配置单位是目录,子目录自动继承最近祖先(给 `D:\Projects` 配一次,
`D:\Projects\api` 就继承)。
- **只减不加**:用的是 DSH 最弱的收窄原语 `ctx.tools.restrict()`,它只能移除,不会新增能力。
- **两类可见面都管**:工具 schema + 系统提示词段(后者用"同名空文本段遮蔽"实现)。
- **按来源分档**:靠 bundle 的 `availability` 判断"随 dsh 安装"还是"用户自装",
第三方档默认展开,官方档默认收起。
- **归属可手工矫正**:每个工具/段右侧有归属下拉,可以把未归属的项并入已有分组或新建分组。
- **模型可调**:`workspace_gate` 工具支持 `status` / `list` / `set`。
- **可控的噪声**:不写 DOM、不加载宿主客户端包、颜色只用 `--dsw-alias-*` 主题 token。
- **可测试**:核心逻辑抽在 `lib/policy.mjs`,24 条单测,不启动 DSH 也能跑。
## 界面
插件管理页会多出一个 **「按工作区」** 页签:
```
插件照常加载;勾掉的插件,它的工具与提示词段不再进入这个目录的请求。
存储: ~/.dsh/data/workspace-gate.json [重新扫描]
工作区 (default-workspace) (my-project ●) (+ 添加目录)
C:\work\my-project · 跟随全局 · 已关 1 组 · 共 9 组
插件组 [筛选…]
› 第三方 2 ≈ 2.7k
[网] web-search-free [9 工具][1 段] ≈2.7k › ●
› 自定义分组 1
› 官方 7 ≈ 17.8k
› 官方可选包 1
› 未归属 27
```
- 行尾开关 = 这一组在这个目录下是否可见;
- `›` 展开后可以只勾单个工具/段,右侧下拉可以改它的归属;
- 顶部显示当前目录、是否已单独配置、关了几组、大概省多少 token/轮。
## 安装
本插件是标准 DSH bundle(`package.json` 里声明 `dsh.bundle.patch`)。
三种装法,**推荐顺序从上往下**;装好后都会在插件管理页出现「工作区插件门控」这一行。
> **已发布到 npm**:[`dsh-workspace-gate@0.1.1`](https://www.npmjs.com/package/dsh-workspace-gate)。
> 能访问 npm 的机器可以直接
> `dsh plugin --profile add dsh-workspace-gate@0.1.1`,不必走 GitHub。
> **版本要求**:`engines.dsh = ^0.2.1-rc.2`。比这更老的 DSH 会被插件管理器**直接拒绝安装**,
> 而不是装上以后在运行期报错。
### 方式 1 · 让 AI 帮你装(最省事,推荐新手)
新开一个会话,把这句话发给助手:
> 帮我安装这个 DSH 插件:`github:M1r0er/dsh-workspace-gate`
助手会调用 `plugin_manager` 完成「pnpm 安装 → 选中 bundle → 激活」整条链路,最后告诉你
`application: applied`。然后刷新页面即可。
- 前提:当前会话的预设启用了 `plugin_manager` 工具。DSH 默认只在 **Creator** 模式下启用它,
其它预设默认关闭;如果助手说没有这个工具,切到 Creator 再试,或改用方式 2。
- 安装 Host 代码属于高权限操作,会请求一次性授权。
### 方式 2 · Web 插件页安装(不碰命令行)
1. 打开 DSH 的 **插件** 页;
2. 点安装入口,在 spec 里填 `github:M1r0er/dsh-workspace-gate`
(也可以填你本地 clone 的**绝对路径**);
3. 若弹出 “Allow these scripts and retry”,确认后重试一次;
4. 列表里出现 `dsh-workspace-gate` 即装好,刷新页面;
5. 确认「工作区插件门控」这一行是**开启**状态。
### 方式 3 · 命令行(知道自己在做什么的人)
```bash
# A. 用 dsh CLI(profile 不是 Desktop 时)
dsh plugin --profile add github:M1r0er/dsh-workspace-gate
# B. 直接进 profile 目录用 pnpm
cd ~/.dsh/profiles/
pnpm add github:M1r0er/dsh-workspace-gate
# C. 本地目录(开发/调试)
git clone https://github.com/M1r0er/dsh-workspace-gate.git
pnpm add link:<克隆下来的绝对路径>
# D. 从 npm(0.1.1 起可用)
dsh plugin --profile add dsh-workspace-gate@0.1.1
```
> **`--profile ` 决定改哪个 profile 目录**:`--profile web` 改的是 `~/.dsh/profiles/web`,
> 而**桌面版 GUI 跑的是 `desktop` profile** —— 装进 `web`,桌面窗口里看不到,两个 profile 的
> 依赖和 `dsh.profile.bundles` 互相独立。要装进桌面版,请用它自己的插件页
> (npm 版 CLI 会拒绝管理 `desktop`)。
> ⚠️ **pnpm 只装依赖,不会把包写进 `dsh.profile.bundles`。**
> 少这一步插件不会加载。装完必须再做一件事:
>
> - 打开 Web 插件页,把 `dsh-workspace-gate` 这个 bundle **打开**;或
> - 手工在 `~/.dsh/profiles//package.json` 的 `dsh.profile.bundles`
> 数组末尾加 `"dsh-workspace-gate"`。
>
> 然后重启 DSH(没有 HMR 时)才会加载。
装好后:**关掉插件管理页里「工作区插件门控」这一行 = 门控整体不生效**,行为回到未安装状态(fail-open)。
### 常见坑
| 现象 | 原因 / 处理 |
| --- | --- |
| 装完插件页没有这一行 | bundle 没被选中:按方式 3 的提示把它加进 `dsh.profile.bundles` |
| 返回 `already-installed` | 同 spec 已装过;先在插件页 Remove 再装 |
| 桌面版提示 CLI 不接管 | Desktop profile 由 Electron 托管,npm CLI 会拒绝管理它;请用 Web 插件页 |
| 安装卡在 build scripts | pnpm 11 默认拦截安装脚本;在弹窗里批准后重试 |
| 想在插件市场里搜到 | 仓库已打 `dsh-plugin` topic,但要在市场出现还需被 awesome-dsh-plugin 索引收录(提交 PR) |
## 使用
### 给人
插件管理 → 「按工作区」→ 选目录 → 关掉不需要的组 → 保存。对**新会话**生效。
### 给模型
```text
workspace_gate(action:"status") # 最近哪些会话命中了哪个目录、摘掉了什么
workspace_gate(action:"list") # 可门控的插件 → 工具名/段名
workspace_gate(action:"set", dir:"D:\\Projects\\api",
hide:["plugin:r18-novel"]) # 需要显式给 dir
workspace_gate(action:"set", dir:"D:\\Projects",
show:["tool:read"]) # 从隐藏清单移除
```
> 这个工具没有用 `deferLoading`:实测打开后子会话里它**直接不可见**,模型想调也调不到。
两种写法等价(`dir` 必须是绝对路径,子目录会继承):
- 隐藏整组:`plugin:<分组 id>`
- 隐藏单项:`tool:<工具名>` / `section:<段名>`
## 归属是怎么判断的
DSH 运行时**不记录**某个工具是哪个插件注册的(`tools.register()` 只按名字存),
所以"按插件勾选"只能推断。本插件按强度依次尝试:
| 顺序 | 手段 | 说明 | 可靠度 |
| --- | --- | --- | --- |
| 1 | 段名命名空间 | `r18-novel:protocol` → `r18-novel`,与插件 id/包名归一后比较 | 高(约定) |
| 2 | 工具名前缀 | 工具名 token 与插件 id **或包名** token 的**前导全等**匹配 | 中高 |
| 3 | 静态扫包 | 读包入口(第三方递归扫 `.js`)里的 `name: 'x'` 定义位,与未归属工具名求交,**只在唯一命中时采纳** | 较高 |
| 4 | 手工归属 | 界面里自己指定,赢过以上全部 | 最高 |
静态扫包解决的是这类情况:`dsh-free-search` 的 profile 行 id 是 `web-search-free`,
而它注册的 `platform_search` / `video_search` / `weather` 等名字里根本不含包名——
只靠前缀只能认出 1/9,扫包之后 9 个全部归位。
**硬边界**:官方内置包在 `app.asar` 里,磁盘上没有目录,扫不到;运行时动态注册的工具
(没有对应文件插件)也没有包可扫。这些会留在「未归属」,用手工归属分类即可。
## 数据存放
`/data/workspace-gate.json`(本机示例:`~/.dsh/data/workspace-gate.json`)
```json
{
"version": 1,
"overrides": {
"d:/projects/api": ["plugin:r18-novel", "tool:free_search_news"]
},
"assignments": {
"tool:read": "文件工具"
}
}
```
- `overrides`:**按工作区**的隐藏清单,键是归一化目录(小写 + `/`),子目录继承最近祖先;
- `assignments`:**全局**的手工归属,赢过自动推断,只改分组、不影响"要不要隐藏";
- 文件损坏 / 读不到 → 退化为空配置(全放行),不会让门控把会话搞挂。
## 为什么不是"真·停用插件"
三条硬理由:
1. **全局状态**。profile 的 `disabled` 是每个 profile 一份,不是每个工作区一份;
两个工作区想一开一关只能来回改文件。
2. **来回改 = 反复重激活**。会触发 Loader 重载,而且同时开两个工作区会互相打架。
3. **那也不省 token**。真停用省的是内存与启动开销;而 token 取决于 prompt 里有什么。
本插件摘的正是"模型侧的东西",与工作区数量无关。
## 实测数据
本机 DSH 0.2.0-rc.2 / desktop profile:
| 事实 | 数值 |
| --- | --- |
| 全局可见工具(`tools.schemas(undefined)`) | 19 |
| 4 个 preset 作用域并集 | 53 |
| 某个具体会话可见工具 | 52–59 |
| 提示词段(按 preset 并集取) | 20 |
| 插件行总数 | 204 |
两个踩出来的坑:
1. **只查全局会漏掉 34+ 个工具**——多数标准工具由 agent preset 在自己的作用域注册,
清单必须按 preset 逐个 `agentPresets.acquireScope(id)` 取并集。
2. `systemPrompt.assemble()` 是 **async**,不 `await` 只拿到一个空对象。
验证记录(对某个目录隐藏 `tool:read` 与 `section:free-search:engines` 后新开子会话):
```text
bound = { cwd: "D:\\DSH Workspace", key: "d:/dsh workspace",
deny: ["weather"], sections: ["free-search:engines"], skipped: [] }
weather in agentTools : no
free-search:engines in agentSections : no
```
`skipped: []` 说明两个名字都被接受——包括 preset 作用域里的工具,即 `restrict` 对
"继承面"(全局 + 祖先层)有效,不必是全局注册。
## 开发
```bash
npm run check # node --check 三个入口
npm test # 24 条单测(纯逻辑,不需要 DSH)
```
```
index.js Host 半:绑定 agent、workspace_gate 工具、HTTP 路由
client.js Client 半:插件页「按工作区」页签
lib/policy.mjs 纯逻辑:目录匹配、归属推断、spec 展开、存储规整
tests/policy.test.mjs 上面那层的单测
cordis.patch.yml 组合包的行声明
locale/{zh,en}.json 插件管理页标题与描述
icon.svg 图标
```
Host 与 Client 之间的通道是本插件**自注册的 HTTP 路由**
(`/workspace-gate/state|apply|assign|refresh`):第三方插件的 Host 服务无法凭空长出
一个 remote namespace,而 `webServer.register()` 是宿主公开的接缝。
## 已知边界
1. **只对新会话生效**:已开会话的前缀已经发出去,不回溯。
2. **只减不加**:不会把全局已停用的插件变出来。
3. **归属靠推断**:`app.asar` 内与动态注册的工具无法自动归属,需手工分类。
4. **`restrict` 只作用于继承面**:极少见的、在该 agent 自己层注册的工具会落进 `skipped`。
5. **折叠状态不持久化**:刷新后回到默认展开策略。
## 许可证
[MIT](LICENSE) © 2026 M1r0er
---
本仓库打了 `dsh-plugin` topic,便于在 DSH 插件索引/市场里被检索到。