# 贡献与内部实现说明 面向**改代码的人**(包括 AI 助手)。用户向说明见 [README.md](./README.md)。 > ⚠️ 改代码前先读「与官方源码的一致性核验结论」与「界面文案原则」—— 那两节是踩坑总结。 --- ## 与官方源码的一致性核验结论 改代码前请先读这一节 —— 每一条都有源码位置或实测支撑。 | # | 维度 | 结论 | |---|---|---| | 1 | 路由接口 | ✅ `{ kind: 'exact', path, handler }` 对齐 `WebRoute`(`host/webserver/src/index.ts:42-48`);`path` 是**不含尾斜杠的绝对 pathname**;`register()` 返回 disposer;官方 dispatch 先取 `new URL(req.url).pathname`,故**查询串自动剥离** | | 2 | 路由去重 | ⚠️ 官方按 `(kind, path)` 去重,**重复注册会 throw** ⇒ 注册必须包在 `ctx.effect` 里,保证 HMR 重载时旧 disposer 先执行 | | 3 | Cordis 约定 | ✅ `ctx.effect(callback, label)` / `ctx.logger` / `name`+`inject`+`apply` 三导出,与官方用法同形 | | 4 | client bundle | ✅ `exports.apply` + `exports.inject` + `return module.exports` 与官方产物**逐字一致**;`package.json` 声明了 `dsh.client` 就必须导出 `"./client"`(否则官方报错) | | 5 | **鉴权** | ❌ **命名路由天然绕过 dsh 的令牌栅栏**(`match()` 中 exact 命中即返回,而鉴权由 fallback 的 SPA 承担)。实测:`GET /` → 401,而 `GET /api/dsh-agent/state` 无令牌 → **200**。⇒ 本插件自带栅栏(见下) | | 6 | **指令发现链** | ❌ 官方是「`.git` 项目根 + 逐层目录 + 4 个候选名 + 同目录 trimmed 去重」,只数两个文件会**严重低报**。实测某仓库:真实 **52,892** 字节 vs 只数两份的 35,701 字节 | | 7 | **配置平面** | ❌ `agent-instructions` 的**生效实例在 preset 平面**(`presets//agent.cordis.yml`);web-app bundle 把 host 平面那份设为 `disabled: true`,**两平面不合并 config** ⇒ 在 host 平面写 config 覆盖**无效** | | 8 | 上限来源 | ✅ 上限值可只读获得:`settings.yaml` 的 `agent-presets.default` → 该 preset 的 `maxBytes`。**`minimal` preset 未声明该行 ⇒ AGENTS.md 完全不加载** | | 9 | **可写范围** | ✅ 官方候选名是**闭集**(`config.ts:11-13` 共 4 个)⇒ 给文件名加 `.disabled` 后缀即可让发现链「探测不到它」,这就是「暂停」的**全部**机制,不用改任何源码。反过来,可写入口按「候选闭集 × 祖先链 × {原名, `.disabled`}」**全量枚举做白名单**,链外路径一律拒绝 | | 10 | 同目录多候选 | ❌ 同目录里 `AGENTS.md` 与 `CLAUDE.md` **都会加载**(只有 trimmed 内容相同的才去重)。实测某仓库两文件并存 ⇒ 发现链 3 项、合计 52,892 字节;任何"一层只显示一个文件"的折叠都会低报并让第二份失去编辑入口 | --- ## 文件结构 ``` dsh-agent-instructions/ ├─ package.json ← 插件清单:dsh.bundle.patch + dsh.client.platform=web ├─ cordis.patch.yml ← 挂载声明(insert 一行,注入 webServer) ├─ lib/ │ ├─ index.js ← Host 侧:4 条回环路由 + 安全栅栏 + 官方语义复刻 + 链内白名单 │ └─ client.js ← 浏览器侧:React + 官方 ui-primitives + 设置页独立页 └─ tests/ ├─ official-semantics.test.mjs ← 语义单测(100 项):官方语义复刻 + 改名往返 + 工作区==DSH_HOME 回归 ├─ contract.test.mjs ← 接口契约:真实调用 4 条路由,逐字段核对「谁提供、谁消费」 ├─ audit-bundle.mjs ← 静态审计:死代码 / 冗余文案键 / 孤立 CSS / 安全不变量 └─ fuzz-local.mjs ← 随机化测试(固定种子):18 组性质,含白名单拒绝面 ``` --- ## `lib/index.js` — Host 侧 | 路由 | 方法 | 作用 | |---|---|---| | `/api/dsh-agent/state` | GET | 完整层清单(**含已暂停的层**)+ 当前编辑目标 + 当前生效范围 + 三种范围的改动预告 + **指令预算** | | `/api/dsh-agent/file` | PUT/POST | 写入链内**任意一层**(`target` = 链内绝对路径,链外拒绝),支持乐观并发 | | `/api/dsh-agent/layers` | PUT/POST | 批量改名开关:`{mode:'both'\|'project'\|'global'}`(三种生效范围)或 `{changes:[{base,enabled}]}`(单层开关) | | `/api/dsh-agent/activation` | PUT/POST | **兼容入口**:只切全局层(`/layers` 是它的超集,界面已改用后者);响应里的 `activation` 对象就是它的公开契约 | > 四条路由**都要求** `x-dsh-agent: 1` 请求头(见下)。 **响应字段里没有一个是"发出去但没人读"的** —— `tests/contract.test.mjs` 会真实调用每条路由, 把响应按对象分组(顶层 / `file` / `budget` / `layersView[i]`)与 client 源码里实际读取的字段做**双向差分**: client 读了、响应里没有 ⇒ 必然的 bug(会读到 `undefined`);响应里有、没人读 ⇒ 冗余。 当前结果是**零缺失、零冗余**。`layersView[i].creatable`/`mtime`、`budget.approximated`/`limitSource`/ `pruningNotice`、以及顶层那批 v0.3.x 遗留字段,都是被这个测试逼出来的。 ### 安全栅栏(`guard()`)—— 改代码时务必保留 因为命名路由**绕过了 dsh 的令牌鉴权**(核验结论 #5),这道栅栏是**唯一**的跨站防线: 1. **回环来源**:`remoteAddress` 必须是 `127.0.0.1` / `::1` / `::ffff:127.0.0.1` 2. **同源**:`Origin` 缺失放行(curl 等);`null` 或非本机 host **拒绝**;`Sec-Fetch-Site: cross-site` **拒绝** 3. **强制 `x-dsh-agent: 1` 请求头** —— 跨站的 `fetch(..., { mode: 'no-cors' })` **无法设置自定义头**(会触发预检且必然失败),这是最直接的一道拦阻 4. **写操作额外强制 `content-type: application/json`** —— 再挡一层无预检的简单请求 > 实测:缺护栏头 → **403**;恶意 `Origin` → **403**;`Sec-Fetch-Site: cross-site` → **403**; > `text/plain` 夹 JSON 的 PUT → **415**。 ### 写入:原子 + 乐观并发(防数据丢失) - **原子写入**(`writeAtomic`):先写同目录隐藏临时文件再 `rename` 覆盖,中途崩溃不会把 `AGENTS.md` 截成半截。手法与官方 `packages/util/atomic-write` 一致,但不引依赖。 临时文件名不匹配任何官方候选名,不会被指令加载器发现。 - **乐观并发**:调用方带上读取时的 `expectedMtime`;与磁盘不一致时返回 **409** 并回传磁盘现状, 客户端保留用户草稿并提示「再点一次保存将覆盖磁盘版本」。 ⇒ 两个标签页 / 外部编辑器同时改,不会静默覆盖。 > 实测:`expectedMtime` 不匹配 → **409**,且磁盘内容**未被写坏**;正确 mtime → **200**;写完**无临时文件残留**。 ### 版本身份(`PLUGIN_VERSION`) Host 代码**只在 `dsh web` 启动时装载** —— 改了不重启就是旧代码在跑,而两者从界面上看不出来。 因此界面副标题会显示 `插件 vX.Y.Z`。**改 Host 侧(`lib/index.js`)后必须提升该常量,并同步 `package.json`。** 看不到版本号、或版本号比源码旧,就说明该重启了。 ### 指令发现链(复刻官方 `discoverInstructionFiles`) 1. **用户全局** = `/AGENTS.md` —— 官方**硬编码**该名字,**不走候选列表** 2. **项目侧** = 项目根(靠 `.git` 向上识别,找不到回落 cwd)→ cwd **逐层** 3. 每层按 `AGENTS.md`/`CLAUDE.md` → `AGENTS.local.md`/`CLAUDE.local.md` 顺序取存在的文件 4. **同目录内 trimmed 内容重复者只保留最早的**(不同目录不合并) 返回顺序即官方渲染顺序「宽泛 → 具体」。 > **只有一个发现器**(`buildLayerView`)。它同时产出两样东西:界面上那份层清单(**保留已暂停的层**, > 否则开关一关就再也开不回来),以及预算统计要用的「官方此刻真正会加载的那些份」 > (`includedLayersOf` 从同一份清单筛出来)。 > 曾经是两套独立发现 —— 不仅每次请求把整条链读两遍,还存在真实的自相矛盾窗口: > 两次读取之间文件被改动,界面就会同时说「列表 3 层」和「合计 2 层」。 ### 预算与裁剪模拟 官方 `renderInstructionContext`:超预算时**从最宽泛的一层开始逐个丢弃**,只保留最具体的;连最具体那份也放不下才截断它 ⇒ **全局最先出局**。 本插件按同一顺序模拟,并把「会被省略的层」报给界面。输入是**已经发现好的层清单**(同一份数据,见上), 统计函数自己不再去读磁盘 —— 数字与列表同源,不可能打架。 **单文件上限**:官方 `readBounded` 对单文件是 `if (file.size > maxSourceBytes) return undefined` —— 默认 `maxSourceBytes = 1048576`, 即**单文件超过 1 MB 会被完全忽略**(既不加载也不报错)。因此: - 本插件的保存上限是 **1 MB**(与官方一致),不再是渲染预算 65536 - 发现链里超过 1 MB 的层会被标为 `ignored`,**不计入合计、也不占条宽**,并在界面红字列出 - 编辑区上方的尺寸标签会区分两种超限:超渲染预算 = 会被截断/整层省略;超单文件上限 = 会被完全忽略 > ⚠️ 口径说明:这里是**文件字节合计**,不含官方渲染时的分段标题/标记行,故为近似值(真实占用会略高几十字节/文件)。 ### preset 平面(只读,绝不写入) - 从 `$DSH_HOME/settings.yaml` 逐行解析 `agent-presets.default`(不依赖 YAML 库) - 用户 preset(`$DSH_HOME/.agent-presets//`)**优先于**官方 shipped preset - 从 `agent.cordis.yml` 中提取 `agent-instructions` 行的 `maxBytes`;**行不存在或 `disabled: true` ⇒ 判定该 preset 不加载指令**,预算不适用 - harness 根由 `process.argv[1]` 上溯探测;探测失败一律回落官方默认 65536 ### 层开关与三种生效范围(`/api/dsh-agent/layers`) 「暂停」= 在 `<层>/<候选名>` ↔ `<层>/<候选名>.disabled` 之间**改名**: - 官方候选名是闭集(`config.ts:11-13`)⇒ 改名后**发现链探测不到它**,等价「不导入对话」 - **内容一个字节不丢**,改回原名即完全恢复 - 与 preset 平面无关,**对所有平面同时生效**(这正是选改名而非改配置的原因) 界面上是**三种生效范围**(一次改多层)+ 每一行自己的开关(同一套机制的细粒度): | 生效范围 | 全局层 | 项目各层 | |---|---|---| | **工作区 + 全局** | 启用 | 启用 | | **仅工作区** | **暂停** | 启用 | | **仅全局** | 启用 | **暂停** | 状态由**磁盘现状反推**(不存任何配置):全局启用 ∧ 项目各层一致 = 三种之一,否则显示「自定义」。 ⚠️ 项目各层必须**整体一致**才算「仅全局/仅工作区」—— 否则「项目根那份被暂停、只有子目录那份还在生效」会被误报成「仅全局」,用户会以为项目层全停了。 「切到某个范围会改动哪些层」也由 **host 统一算**(`modePlans` 随 `/state` 回传),界面只负责显示。 client 曾经复制了一份同样的规则来渲染确认框 —— 两份规则一定会漂移,而 「确认框说改 2 个、实际改了 3 个」是最难被发现的那类不一致。 ### ⚠️ 特例:工作区就是 DSH_HOME 用户完全可能把工作区填成 `~/.dsh`(他是想编辑全局那份指令)。这时「全局层」与「项目层」 **是同一个文件**,会引出一串连锁问题。现在的处理是三条: 1. **层清单只列一行**(`buildLayerView` 跳过与全局层同路径的项目候选,含"可创建"占位那一行)。 否则同一份文件在列表里出现两次,用户会以为是两个不同的东西。 2. **三个生效范围 Pill 照旧渲染**,只在下面加一句说明(「工作区 + 全局」与「仅全局」在这里是同一个结果)。 曾经把这一整行替换成说明文字 —— 用户的直接反应是"三个模式的选项怎么没了",那是**过度反应**: "选项消失"比"两个结果相同"更让人困惑。 配套地,高亮必须**跟随用户的选择**:host 只能从磁盘反推出 `both`(两者在这里确实等价), 所以特例下 client 用「用户最近一次点的那个」来点亮 Pill(且只在它与当前物理状态相容时才采信), 否则点「仅全局」会跳到「工作区 + 全局」上 —— 看着就是"点了没动"。 还有一个连带坑:`switchMode` 的**早退条件也要把 `chosenMode` 算进去**。 碰撞时物理 `mode` 恒为 `both`,若只比 `mode`,用户点「工作区 + 全局」会被当成"已经是这个状态" 而静默返回,高亮就**再也回不去**(实测确认过这个 bug)。 3. **改名计划里同一个路径不得出现两次**。这是曾经的**真事故**:`planModeChanges` 会把同一个文件 既"暂停"又"启用",而**执行顺序决定最终状态** ⇒ 点「仅全局」(全局启用 + 项目暂停) 实际把它**暂停**了,界面则显示成「自定义」——按钮怎么点都不会亮。用户实测反馈的就是这一条。 配套地,`targetsCollide`("工作区是不是 dsh 配置目录")必须比 `global.activePath` 而不是 `global.path`:全局被暂停时后者是 `.disabled`,与工作区目标不同名,判定就会失效、提示消失。 ### 链内白名单 —— 可写范围的唯一入口 `/file` 与 `/layers` 都只接受**白名单内**的路径。白名单**按规则重算**,不依赖磁盘现状: ``` 候选闭集(4) × 祖先链(项目根 → cwd 的每一层) × {官方名, 官方名 + .disabled} + /AGENTS.md 及其 .disabled ``` - 用「重算」而不是「扫出来的清单」的原因:暂停态的层不在发现结果里,拿清单做校验就**再也恢复不回来**了 - `changes[].base` 必须是**官方名位置**(不含 `.disabled`)——`.disabled` 由 base 推导,不接受外部直接指定 - Windows 大小写不敏感 ⇒ 命中判定按小写比较(`pathKey`) > 实测拒绝:家目录 `~/.ssh/id_rsa`、系统目录 `C:/Windows/...`、`../../` 目录穿越、 > 同仓库的 `package.json`、`/settings.yaml`、候选名加别的后缀(`AGENTS.md.disabled.bak`)——**链外 0 泄漏**。 ### 编辑目标归一化(同一层的两种形态) 一层的**身份**是 `base`(官方名所在路径),而 `X.md` 与 `X.md.disabled` 只是它此刻的两种形态。 界面会把上次的编辑目标记在 `localStorage` 里,而那个路径可能是**改名前的形态** —— 不归一化的话,一打开面板就会看到「正在编辑 …/AGENTS.md.disabled」+「还没有这个文件 · 保存后创建」, 可那一层明明有文件(只是叫官方名),用户会被误导去"重新创建一个"。 所以 `resolveTargetPath` 命中白名单后还会再做一步:**请求的形态不存在、而同一层的另一种形态存在时, 落到存在的那一份上**。反向边界也守住了:该层一份都没有(可创建的新层)时**不归一化**, 否则会把"新建"入口也一起改掉。 --- ## `lib/client.js` — 浏览器侧 - 由 dsh 的 `__ModuleLoader__` 加载(closure-factory 形态) - **React + 官方 slot 体系**(`settings.section` 独立设置页),但**仍不需要 tsdown 构建链** —— 手写 bundle 直接用 `React.createElement`,`require('react')` / `require('@deepseek-ai/dsh-client-ui-primitives')` 可被模块系统解析(走共享静态表,**无需在 package.json 声明**) - **双入口,共用同一份组件树**(`AgentApp`,React + 官方 `ui-primitives` 控件,`React.createElement` 写法 ⇒ 零构建链): 1. **设置页独立页**(官方 `settings.section` slot)—— 设置导航里有独立一项「Agent 身份与指令」。 2. **右下角浮动按钮** —— 打开模态外壳(`ReactDOM.createRoot`),内嵌同一个组件。 形态是 **34×34 圆形图标 + 50% 透明**(hover / 键盘聚焦时恢复实心),原因见下面「浮动按钮不该挡住输入区」。 - ⚠️ **不再挂侧边栏条目**(早期版本用启发式探测侧边栏,已移除)。 - **回退保险**:注册后用 `ctx.slots.entries('settings.section')` 自省 ledger(这正是 `ui-settings-general` 投影导航所用的 API);若条目没进去,自动补注册 `settings.general.item` 行 —— **绝不让入口静默消失**。 - **i18n**:`ctx.locale.register(NS, { zh, en })`;界面语言每次现读 ``(dsh 是启动后才写的), 并用 `MutationObserver` 监听变化后**用新文案重新注册**(官方 slot 契约要求注册方这样做)。 ⚠️ 语言**故意不 `inject: ['locale']`** —— 免得某个环境没有该服务就整个插件不加载。 - **官方控件**(实测 class 为 CSS Modules 哈希 ⇒ 确认为官方件):`Button` / `Pill` / `Switch` / `Input` / `StateDot` / `DisclosureRow` / `Tooltip` / `Toast`,以及 `fileSizeText` / `relativeTime` / `writeClipboard` 工具函数(均带回落)。 - **外部改动自动刷新**:10s 轮询 + 仅页面可见时执行 + 有未保存草稿时不打扰,只提示「磁盘已变更」。 - **列表就是编辑器**(核心交互):全局与项目**会一起被读取**(官方说法是更具体的优先,但不会覆盖系统与用户指令), 所以界面按读取顺序列出每一层,每层带**作用范围注解**、字节数、**状态点**(生效 / 已暂停 / 会被丢弃 / 被截断 / 被忽略 / 内容重复), **点哪一行就在下面编辑哪一份**(选中行有蓝边 + 「正在编辑」徽章,编辑器上方常显完整路径),每行右侧自带暂停/启用开关。 ⇒ 曾经的做法是「先选一个『编辑哪个文件』的二元开关,再在另一个列表里看会读哪些」——两个概念各说各话, 用户想改项目文件却"被全局覆盖了"(实测反馈)。合并成一个列表后,那个痛点消失。 - **三种生效范围**在列表上方,一键改多层。显示「自定义」时**只写三个字、不加猜测性说明** —— 曾在「自定义」后面追加过「两份都暂停了,指令不会生效」,结果在"3 层里只暂停了 1 层"时也说这句话,直接是错的。 - **同目录多候选逐个列出、不折叠**:实测某仓库 `AGENTS.md`(17,182B) 与 `CLAUDE.md`(9B) 并存,官方两份都读。 折叠成一行会让第二份没有编辑入口,还会让「3 个文件都会生效」与 2 行列表同屏自相矛盾。 - **「会生效的文件数」直接数列表里的行**(`row.included`),不另算一套 —— 数字与行数必须同源。 - **行里「路径」才是主标题**,「全局 / 项目」分类降到第二行的注解里。 同一屏常常有两三行都叫「工作区文件」,把分类放在主标题上等于让用户逐行去第二行找差异; 换成「路径在第一行、分类 + 作用范围在第二行」之后,扫读顺序变成"先认清是哪个文件"。 - **点一行就把焦点交给编辑器**(`focus({ preventScroll: true })`)—— 「点一行」的语义就是"我要改它", 省掉一次 Tab;同时不抢滚动位置,所以想连续点几行浏览的人不会被打断。 - **占比极小时显示「<1%」而不是四舍五入成「0%」** —— 实测见过「合计 72 / 65,536 字节 · 0%」, 读起来像"根本没有内容",与事实相反。 - **超限提示是可见文字,不是 hover 才有的 `title`**:粘贴超过 1 MB 的内容时,页脚直接把 「超过官方单文件上限,官方会完全忽略该文件」写出来(红字)。只挂 `title` 时, 用户点保存只会拿到一个 413,看不出原因。 - **切语言不会丢草稿**:官方 slot 契约要求语言变化时「用新文案重新注册」,而重新注册会**卸载重建**组件 —— 草稿只活在内存里,本会随之消失。现在未保存内容会存进模块级 `draftKeep`, 组件首次 load 时若目标仍是同一份文件就放回编辑器(用户只是换了个语言,没做错任何事)。 - **换工作区会重置编辑目标**到新工作区自己的文件(明确的上下文切换);想改别的层,点那一行即可(一行一个入口)。 - **行数是显式记账的 state**,不在渲染时读 textarea:textarea 是非受控的,值由 `useEffect` 在渲染**之后**才推进, 切换工作区那一帧读到的是上一个文件的内容 ⇒ 实测出现过「打开 2 行文件,页脚写着 180 行」。 - **「需要注意」区**:只在**确实会出问题**时出现,把技术数据翻译成人话 —— 超预算(**官方从最宽泛开始丢 ⇒ 全局最先出局**)、preset 未声明指令机制、单文件超 1 MB 被静默忽略、 全局已暂停、**当前工作区就是 DSH_HOME**(此时全局那层和工作区那份是同一个文件)。 - **「它是怎么生效的?」**:可折叠的 5 行说明 —— 「改哪份和用哪份是同一件事」、上下叠加的关系、文件要放在项目目录里、 官方只认的 4 个文件名(以及「暂停」就是给它加 `.disabled`)、读取时机。 - **编辑器软换行**:`white-space: pre-wrap` + `overflow-wrap: anywhere` + `overflow-x: hidden` ⇒ 长行自动折行,**不产生横向滚动条**(实测 612 字符长行下 `scrollWidth === clientWidth`)。 改前是 `white-space: pre`,长行会撑出横向滚动条。 - **盒模型统一为 `border-box`**:我们自己带 padding/border 的类全部显式声明 `box-sizing: border-box` (**不碰官方 ui-primitives 组件**)。原因:`.dsh-agent-editor` 是 `width:100%` + `padding:12px 14px`, 默认 `content-box` 下净增约 29px,会把设置面板的内容列撑出**横向滚动条** —— 实测修前 585 > 556(父容器)、设置面板 609 > 604;修后 564 == 564、612 == 612,子树内溢出元素 0。 - ⚠️ **导航项图标无法自定义**:`ui-settings-general` 的 `navIcon(id)` 是**硬编码 if-else 表** (只认 `models` / `agent-presets` / `plugins` / `archived-sessions`,其余兜底为齿轮), 且 `settings.section` 的注册选项里**没有 icon 字段** ⇒ 我们的机器人图标只能出现在 **浮动按钮**与**页面标题**这两个自己能控制的表面。 - 失败策略:DOM 出问题**只降级本面板**,绝不影响 dsh 界面 ### 浮动按钮:平常显示,会挡到输入区时才隐藏 按钮固定在右下角,而 dsh 的聊天输入区**在有对话时一直延伸到页面底部**(含发送按钮与底部工具栏), 所以无论按钮做得多小,右下角都会被压 —— 这是用户连续两轮反馈的问题。 三轮演进(每一步都由几何实测驱动,不凭感觉): | | 形态 | 结果 | |---|---|---| | 初版 | 图标 + 文字胶囊 141×34 · opacity 1 | 明确压住输入区右下角 | | 缩小后 | 34×34 圆点 · opacity .5 | 占用面积 −76%,但**仍压在底部工具栏上** | | **现版** | 34×34 圆点 · **条件隐藏** | 平常正常显示;一旦会挡到输入区就淡出 | **判定为什么用垂直方向**:实测发现"与输入框矩形做重叠判定"是错的 —— 按钮在 `x 1350~1384`、输入框元素在 `x 461~1205`,两者水平上**根本不重叠**; 真正被压住的是输入区的**容器**(含右侧那个发送按钮)。 而 dsh 的输入区只有两种布局,用垂直方向就能可靠区分: | 状态 | 主输入框 bottom | 视口高 | 判定 | |---|---|---|---| | 新会话(hero,输入框居中) | 503 | 900 | 不冲突 ⇒ **正常显示**(实测 opacity 0.5) | | 有对话(输入区贴底) | 816 | 900 | 冲突 ⇒ **隐藏**(实测 opacity 0) | | 窄屏 + 有对话 | 736 | 820 | 冲突 ⇒ **隐藏** | 判据:主输入框(宽 ≥ 视口一半的可编辑元素)的 `bottom` 是否伸进「视口底部 200px」这条带子。 实现见 `watchFabCollision()`(每 500ms 检查一次,且只在状态真正翻转时才写 DOM,避免无谓的样式重算)。 配套约束: - 隐藏时同时设 `pointer-events: none` —— 否则它**看不见却仍会吃掉发送按钮的点击** - 名称不丢:`title`(悬浮)与 `aria-label`(读屏)都带完整文案,随语言切换一起更新 - 键盘可达:Tab 聚焦时仍会显现(`:focus-visible`) - 面板入口不受影响:设置里的「Agent 身份与指令」一直都在 ### 面板交互 | 行 | 说明 | |---|---| | **生效范围** | 三选一:`工作区 + 全局` / `仅工作区` / `仅全局`;不成三者之一时显示「自定义」。点一下先弹确认框说清「会改哪些文件」再执行。**点已经激活的那一个**则给一句状态回执("当前已经是「X」了")—— 静默无反馈会让人以为"点了没反应" | | **工作区** | 绝对路径输入框,回车生效(并重置编辑目标);留空 = dsh 默认工作区;旁边是官方系统目录选择器 | | **会读取哪些文件** | 按读取顺序列出每一层;**点一行即载入编辑器**(选中行蓝边 + 「正在编辑」徽章);每行右侧的开关单独暂停/启用该层。工作区那层会额外列一行 **「个性化规则」**(= 官方 `AGENTS.local.md`)。注解分**两行、互不覆盖**:① **层语义行**(**恒常显示**,内容只由"这一层是什么"决定 —— 如 `工作区文件 · 只在这个工作区里读它`、`个性化规则 · 可叠加(仅本工作区,且仅全局不可用)`)② **状态行**(仅在**非正常**时追加:`未创建` / `已暂停,不会生效` / 超长会被省略 / 单文件超 1 MB)。⚠️ 早先这两者是 `health.note \|\| rowScopeNote(row)` 的**二选一**,后果是一旦出现状态提示,层语义就**整条被顶掉**(用户实测反馈过两次)—— 现在语义归语义、状态归状态,**各占一行**,正常态不出现状态行(行高不变)。
**「同目录内容重复」故意不提示**:官方只在两份**逐字节相同**(忽略首尾空白)时才折叠它们,而那种情况下"读一份"与"读两份"结果**毫无差别**;提示它反而会让人误以为"**个性化不能叠加**",可叠加恰恰是这层的卖点。⚠️ 但 `row.duplicate` **字段必须保留** —— `lib/index.js` 的 `included` 与 `summarizeBudget` 都在用它。
**未创建的行开关仍可点**:点了会载入编辑器("启用"一个还不存在的文件无从谈起);该行开关**只在「仅全局」模式下禁用**(该模式会把整个项目层暂停) | | **编辑器** | 上方常显当前层的完整路径;**预设文案分两套** —— 普通层(全局 / 工作区)给「身份 + 工作规范」模板,**「个性化规则」给「我的偏好 + 补充约定」模板**(它是额外的补充,套用主文件模板会诱导用户把 `AGENTS.md` 抄一遍,反而稀释了"叠加"的意义)。预设里只显示**用户认知的文件名** —— 暂停用的 `.disabled` 后缀不会漏进文案(曾出现「在此撰写 AGENTS.local.md.disabled …」,已修) | | **需要注意** | 只在真出问题时出现(内容太长 / preset 不读这些文件 / 单文件超 1 MB / 某一层已暂停 / 工作区就是 dsh 配置目录),每条给「原因 + 后果 + 应对」 | | **它是怎么生效的?** | 可折叠的 6 行说明:改哪份=用哪份、叠加生效、文件要放在项目目录里、官方只认的 4 个文件名(暂停=加 `.disabled`)、读取时机、**「个性化规则」是什么**(对应 `AGENTS.local.md`,只给自己的工作规则) | | **正在编辑** | 编辑器上方常显当前层的完整绝对路径;该层不存在时提示「保存后创建」,已暂停时提示「启用后才会生效」 | | **指令预算** | 分段占用条(一段一层)+ 分项字节 + preset 归属 + 超预算时列出会被省略的层 | | 保存 / 放弃 / 重新读取 / 关闭 | 支持 **Ctrl/Cmd + S** 保存;**Esc** 关闭(模态下)。**凡会丢弃用户已写内容的动作都要二次确认** —— 关闭、重新读取、切换生效范围、**放弃改动**四条路径行为必须一致(曾经只有"放弃改动"不确认,误点即丢;已修) | | **窄屏** | ≤480px 时条目自动**纵向堆叠**、长文本单行省略(完整值由 `title` 兜住)。标题 / 路径 / 注解一律 `nowrap` —— 早期版本会把文字**逐字竖排**成一列(`overflow-wrap:anywhere` + 无响应式所致) | **确认对话框是自绘的**(`askConfirm`),不用 `window.confirm`。 原生 `confirm` 是**浏览器级 UI**(标题会写「127.0.0.1:3080 显示」),不受 dsh 主题控制、与界面风格割裂。 自绘版是覆盖在面板之上的一张小卡片:同一套设计 token、等宽字体显示路径、 **Esc 取消 / Enter 确认 / 点遮罩取消**、危险操作按钮用红色,默认聚焦确认按钮。 ### 设计 token(改 UI 时改这些) 实测**存在**:`--dsw-alias-bg-layer-1/2`、`--dsw-alias-label-primary/caption/dimmed`、 `--dsw-alias-border-l1/l2`、`--dsw-alias-button-primary-fill/hover`、 `--dsw-alias-interactive-bg-hover`、`--dsw-alias-brand-primary`、 `--dsw-alias-state-success/warn/error-primary`、`--dsh-content-font-size`。 ⚠️ **不存在**(写了会静默走 fallback):`--dsw-alias-label-success` / `label-warning` / `label-danger`。 > 另注:本主题下 `--dsw-alias-brand-primary` 与 `--dsw-alias-button-primary-fill` 实测均为 `rgb(15,17,21)` **近黑墨色** —— dsh 的主按钮本来就是白字黑底,所以预算条用墨色是**符合原生风格**的,不是 bug。 > 新增 token 前请用探针实测(见下面单测的用法),不要凭记忆写。 --- ## 单测与静态审计 ```sh node tests/official-semantics.test.mjs # 语义 + 改名往返单测(100 项) node tests/lang-resolution.test.mjs # 界面语言来源优先级 + 接线不变量(27 项) node tests/contract.test.mjs # 接口契约(真实调用 + 字段双向差分 + 语言头跟随,54 项) node tests/audit-bundle.mjs # 死代码 / 文案键双向 / Host 文案表 / CSS 模板串静态审计 node tests/fuzz-local.mjs # 随机化测试(固定种子,18 组性质,2873 断言) ``` **语义单测**拿**真实的官方文件**(四个 preset 的 `agent.cordis.yml`、你的 `settings.yaml`、真实仓库的 `.git`) 验证:settings 解析、preset 行解析(含 `minimal` 不加载分支)、项目根识别与目录链顺序、 预算裁剪模拟、超单文件上限的忽略处理、同源栅栏判定、入口护栏(含护栏头与 content-type)。 其中 **4 组(`【7】`–`【10】`)在临时目录里跑真实的 `rename`** —— 这条路径会真的动磁盘上的文件, 端到端测它就得在用户真实仓库里动手,所以用 `mkdtemp` 造同构 fixture 来验证: 链内白名单(含 6 类链外路径必须拒绝)、层清单与模式推导、暂停/恢复往返(内容逐字节一致 + 幂等)、 同目录多候选与去重。当前 **100 项全通过**。 **接口契约测试**(`contract.test.mjs`)解决的是另一类问题:手写 bundle 没有类型检查, host 改了响应字段、client 忘了跟进,运行时只会得到一个 `undefined` —— 而 `undefined` 在 JSX 里 往往只是"什么都不显示",不报错也不留痕。这个测试 mock 一个最小 `ctx` 真实加载 Host 插件、 在临时 fixture 上**真的调用**四条路由(含 409 冲突、413 超限、403 链外路径、缺护栏头等分支), 再按响应对象分组与 client 源码做**双向差分**。当前 **54 项全通过** —— 其中【6】那一组是**界面语言跟随**:带 `x-dsh-lang: zh/en` 各调一次 `/state`,断言 `planeNote` 真的换了语言;不带该头时断言**回落中文**(老调用方不退化);错误文案同样验一遍。 > ⚠️ 它必须在调用任何路由**之前**把 `process.env.DSH_HOME` 指向临时目录 —— > Host 的「全局层」路径由它决定,而 `/activation` 那条路由会**真的改名**那个文件。 > 不隔离的话,跑一次测试就会把开发者自己的 `~/.dsh/AGENTS.md` 改成 `.disabled` >(即使测试末尾有恢复语句,中途断言失败或进程被打断就会把它留在暂停态)。 > 这条已经踩过:测试偶发失败过一次,根因正是它读到了**本机真实的全局文件状态**。 **界面语言来源测试**(`lang-resolution.test.mjs`)盯的是一个「看不见但很致命」的问题: 插件判定界面语言的**权威源**必须是 dsh 的 `locale` 服务,而不是 ``。 根因:dsh 服务端出的 HTML **写死** `lang="en"`(`apps/web/index.html`),要等 locale 插件 激活后才异步改写成 `zh-CN`;只认这个属性,读在写入之前就拿到 `en`,界面便**永久停在英文** —— 而 dsh 自己明明是中文(官方自己的 e2e 都注明 markup already ships en, so this alone cannot prove the sync ran)。这个测试**从 `lib/client.js` 原文抽出** `normalizeLang` / `currentLang` 求值(不是抄一份逻辑,所以盯的是真源码),覆盖六级优先级与五种坏服务 (返回 null / 抛异常 / 缺方法 / 空串 / 区域变体),并静态核验接线不变量: `registerLocale` 必须早于首次取文案、两个语言来源都要订阅、只有一个判定漏斗。当前 **27 项全通过**。 **静态审计**(因为手写 bundle 没有打包器的摇树与类型检查兜底)会检查: 文案键 zh/en 是否对齐、有没有定义了却从未调用的文案键、**调用的文案键是否都有定义**(正向 + 反向双向闭环 —— 拼错键名时 `t()` 只会静默返回 `undefined`、界面渲染成空白,既不报错也不留痕)、 顶层函数/常量是否有零引用(死代码)、**注入的 CSS 里有没有从未被引用的类**、 **CSS 模板字符串里的反引号数是否恰好为 2**(注释里混入反引号会提前结束字符串,报错行却指向注释, 极易看偏 —— 实测踩过两次,故固化为检查)、host 侧定义自查,以及 5 项安全不变量 (`window.confirm` 清零、`fetch` 只在 `apiFetch` 内、`exports.inject` 含 `slots`、导出 `apply`、`CLIENT_BUILD` 存在)。 **Host 文案表(`TEXT`)的三项闭环**:zh/en 键集合必须一致、调用的键必须有定义、**定义了的键必须被调用**; 外加一条硬约束 —— **`TEXT` 块之外不得再出现面向用户的中文**(服务端日志 `ctx.logger` 放行)。 这条是实测踩出来的:`planeNote` 曾写死中文,导致 dsh 切成英文时界面里冒出一行中文; 而「再多加一份词典」这种修法本身会再次悄悄漏掉新文案,所以必须由机器盯着。 发现问题时退出码为 1,可直接接进 CI。 > 动态选键(`t(cond ? 'a' : 'b')`)是按「带引号的键名出现在源码里」识别的 —— > 字典里的键名不带引号,所以这个判据不会把"字典定义"误当成"调用"。 > 改这条判据后务必做**双向自检**:真键全部命中、再塞一个不存在的假键确认仍判为未调用, > 否则等于把检查悄悄废掉。 --- ## 界面文案原则(改文案前先读) 这六条是踩过坑总结的 —— 前四条因文案写成行话被连续两次反馈"看不懂",后两条是语言来源踩出来的: 1. **不出现环境变量名** —— 写「dsh 的配置目录」,不要写 `DSH_HOME` 2. **不出现机制术语** —— 「去重」「发现链」「项目根」「优先级」「宽泛/具体」「打底」这类词一律换成人话 3. **说文件,不说"层"** —— 用户看到的是一个个文件;「层」是我们的内部叫法 4. **每句都要回答"所以会怎样 / 我该做什么"** —— 只描述机制而不说后果,用户就是一头雾水 5. **语言只能有一个权威源,而且不能靠猜** —— 客户端取语言的顺序固定为 「官方 `locale` 服务 → `` → `navigator.language`」。前两者都可能**滞后** (HTML 属性是启动后才写的),所以必须**服务优先**。别把这条改回去,也别只留 `MutationObserver` —— 观察器晚于写入挂上就永远收不到那次变化。 6. **Host 侧不许拼面向用户的自然语言** —— 这些字符串是当**数据**发给客户端原样渲染的,不经过翻译层。 统一走 `T(lang, '键', …)`,语言由客户端在每个请求上带的 `x-dsh-lang` 头决定。 写死中文(或写死英文)都会在另一种语言的界面里露出来 —— 这正是本项目踩过的那次。 --- ## 上架就绪度(对照 `awesome-dsh-plugin` 的收录标准) dsh 生态的"上架"= 往收录列表 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) 提一个 PR(在 `data/plugins/__.yml` 加一条 YAML),商店与市场随每日 registry 刷新自动收录。 它的 `contributing.md` 定义了硬门槛,逐条对照: | 官方要求 | 本插件 | |---|---| | `package.json` 声明 **`dsh.bundle`** manifest
(⚠️ 官方写明:**被拒最常见的原因**就是只声明 `dsh.client` —— 那样根本无法安装) | ✅ 已声明 `dsh.bundle.patch` | | 配 `cordis.patch.yml`(`insert` 一行,声明 `id` / `name`) | ✅ 已有 | | 仓库含**真实可用的代码**(占位、抢名、纯 README 仓库不收) | ✅ 源码 4 千余行 + 4 套离线测试 | | 仓库**创建满 1 天**(CI 自动检查,用于过滤"提 PR 前几分钟才建好"的仓库) | ⏳ 取决于建仓时间 | | 项目**活跃维护**(定期扫描会移除已归档 / 长期停更的条目) | ✅ 持续迭代 | | 仓库添加 **`dsh-plugin`** topic | ⏳ 待建仓后添加 | | 描述**只说功能、不带营销词**,且**必须与代码相符**(官方:夸大是"本来不错的插件被打回"的主因) | ✅ 无营销词 | | `category` 选贴合实际功能的(本插件 → `identity`) | ✅ `identity` | | 一个 PR **最多 3 条**条目 | ✅ 只有这 1 个插件 | | 截图(**可选**、推荐 1-8 张):在**自己仓库**的 `package.json` 旁放 `screenshots.json`,图片须 GitHub 托管 | ⏳ 待建仓后添加 | | 发布到 npm(**可选**,与收录无关,只影响商店的下载量排序) | ⏳ 可选 | ### 上架进度 | 项 | 状态 | |---|---| | 真实可用代码 / `dsh.bundle` manifest / `cordis.patch.yml` | ✅ 达标(收录指南点名:**只声明 `dsh.client` 是最常见被拒原因**,我们两者都有) | | 活跃维护 / 描述无营销词 | ✅ 达标 | | 本地 git 仓库 | ✅ 已建(`main` 分支) | | `package.json` 的 `repository` | ✅ 已指向真实地址(曾为占位符 `github.com/local/...`) | | 推送到 GitHub | ⏳ 待推(`Bay-Zeddie/dsh-agent-instructions`) | | `dsh-plugin` topic | ⏳ 待加(推送后到仓库 Settings → Topics 添加) | | 收录 PR | ⏳ 待提(`beancookie/awesome-dsh-plugin`:在其 `README.md` 与 `README.en.md` **各加一行**,分类内按 owner/repo 字母序,CI 会校验顺序) | | 发布到 npm(**可选**) | ⏳ 可选(与收录无关,只影响商店的下载量排序) | > 提醒:`peerDependencies` 若要声明官方 `@deepseek-ai/*` 包,范围必须**显式带上预发布分支**, > 否则会静默排除 harness 的所有 `x.y.z-alpha.*` 构建(例:`">=0.1.0-rc.1 <0.2.0-0"`)。 > 本插件目前不依赖任何官方包(纯手写 bundle),故不适用。