Files
apes-sidebar/AGENTS.md
T

127 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# dsh-apes-sidebar 仓库规则(AGENTS)
> 本文只含**项目全局开发规则**(面向贡献者与 agent)。
> 消费插件接入 API 全参考(`ctx.betterSidebar` 服务、TabDescriptor / FileViewerDescriptor 全字段、声明式设置、原生右侧栏承载面、皮肤契约等)→ [docs/external-plugin-guide.md](docs/external-plugin-guide.md);逐特性设计史(含实施偏差记录)→ [docs/plans/](docs/plans/)。
---
## 1. 仓库硬约束(必须遵守)
- **禁止修改 DSH 源码**:对官方 checkout(`~/.dsh/source/current`)零写入。
- **代码改动必须走 PR**:非文档改动在 `feat/*` / `fix/*` 分支开发,`gh pr create` 发起,review 合并后进 main;**仅纯文档改动**(README / AGENTS.md / docs/)允许直推 main。
- **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles/<profile>/`),插件作为独立包被 profile 引用,不反向侵入 DSH。
- **市场受管安装约束**:`dependencies` / `peerDependencies` / `optionalDependencies` **一律不得出现 `cordis`**(按名硬拒,optional 无效),`scripts` 不得含 `preinstall` / `install` / `postinstall` / `prepare`。由 `tests/market-manifest.spec.ts` 守护。
- 缺能力时用 DSH 现成只读/公开 API 或插件自有路由(会话事件日志回放是既有手段之一,但后台任务已有宿主客户端 `ctx.jobs`,别再自建回放路由);做不到先向用户说明取舍,不改 DSH。
---
## 2. CI 挂载冒烟(`plugin-mount` job / `pnpm test:mount`)
「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add <tarball>`)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-apes-sidebar]` 挂载、无错误条/pageerror/console 错误,展开 DSH 原生右侧栏后经其 guide 页逐个打开本插件贡献的 tab 类型**以及宿主自己的终端条目**(必须恰好 1 条——多于 1 条即意味着插件又在遮蔽宿主),再经插件文件树(原生 `files` kind 接管)打开 seed 文件强制加载 editor chunk(`client-editor.js`),并跑 mermaid / README 预览与 sidechat 宿主路由烟测。**`tests/e2e/native-reload.e2e.ts` 是同一 lane 里的第二条门**:它 `utimesSync` 已安装的 `lib/client.js`(**在钉住的宿主 0.1.7-rc.1 上**:HMR 每 500ms stat-poll,rev = `mtime/ctime/size` 的 sha1;DSH 源码树的新版本改成只比 `mtime+size` 并对**内容**取 sha1——pin 上调时这个触发器必须同步复核,否则该用例会红在替换断言上而不是静默失效)触发**页面内条目替换**,断言替换前后都无 `native register … error`、无诊断条、且 `files` 接管重新可用——这是唯一能在部署层复现「teardown 期间注册把 id 孤儿化」(见 §3.4 第 9 条)的用例,v0.22.0 上连续 3 次运行全红、修复后连续 3 次全绿。**`browser` 不在这个清单里,而是显式断言缺席**:DSH 0.1.7 的 `ui-sidebar-browser` 只在 desktop profile 挂载(`web-app` 的 patch 里 `disabled: ctx.get('profileContext')?.name !== 'desktop'`),lane 全走 web profile,钉住「没有」比默默放过更能暴露宿主立场的变化。
本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(`DSH_CMD` 未设时 `scripts/e2e-common.sh` 回退的 npx 钉版是 `DSH_NPX_SPEC`,默认 `@deepseek-ai/dsh@0.2.0-rc.1`——**不钉就会解析 `latest`,而 `latest` 仍是 0.1.7-rc.2**;本机 pnpm 的 registry 是 npmmirror,新预发布常滞后,装不到时用 `pnpm install --registry https://registry.npmjs.org` 一次性覆盖)。CI 钉 `@deepseek-ai/dsh@0.2.0-rc.1`(**pin 一律写精确版本号,不要写 dist-tag**:0.1.7-rc.1 上线时 `alpha` 指的是 **0.1.7-alpha.2**,而 0.2.0-rc.1 只挂在 `next` 上;peer 下限 `^0.2.0-rc.1`)。该步骤用 `NODE_OPTIONS=--max-old-space-size=4096`:钉版自身的传递依赖是浮动 `^` 范围,上游分阶段发布预发布版时(rc.2 于 2026-09-10 的 14:43–14:57 逐包上线)npm 会组出混合 peer 图,3062 条 ERESOLVE 后 OOM(exit 134);另一个失败模式是对未发布兄弟包 ETARGET,靠「钉一个已完整发布的版本」修掉。**不要加 `--legacy-peer-deps`**:它跳过的正是全局安装必须提供的 peer,`@deepseek-ai/dsh-app-boot` 在 boot 时 require 的 `@deepseek-ai/cordis-plugin-group` 是 peer 而非 dependency,加了它 CLI 直接 `ERR_MODULE_NOT_FOUND`(实测过一次,见 rc.2 计划 C 节)。`ci-windows` 的 `Test` 跑 `pnpm test:windows`(`--maxWorkers=1`)而非 `pnpm test`——多个 spec 真起进程(`smoke` / `install-powershell` / `git`),2 核 runner 上并行起 ConPTY / 冷启 `powershell.exe` 是超时与 worker 静默死亡的放大器;`vitest.config.ts` 的全局 `testTimeout: 15_000`(默认 5000 在 Windows 上对真起进程的用例太低,三个不同文件先后翻车)是配套的一半。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。
---
## 3. DSH 版本适配要点
### 3.0 当前基线:DSH 0.2.0-rc.1(v0.24.0 起)
**v0.24.0 起仅支持 DSH 0.2.0-rc.1+**(peer 下限 `^0.2.0-rc.1`,CI 与挂载 lane 钉 `@deepseek-ai/dsh@0.2.0-rc.1`)。**0.1.7 线(含 npm `latest` 的 0.1.7-rc.2)停留在 v0.23.0**。
**教训(本次最贵的一条)**:**caret 范围永远跨不过 minor**。`^0.1.7-rc.1` 展开为 `>=0.1.7-rc.1 <0.2.0-0`,对 `0.2.0-rc.1` 判定为 **false**(实测,`includePrerelease: true` 也救不了)→ 宿主启动预检(§3.4 第 12 条)把整行 `row.disabled = true`,只在 stderr 留一行,插件**静默不加载**。§3.4 第 12 条里「`includePrerelease` 让判定更宽松(`^0.1.6-alpha.2` 也能满足 `0.1.7-rc.1`)」只在**同一 minor 内**成立:那里的上界 `<0.2.0-0` 没被跨过。显式比较器不受此限(`>=0.1.1-rc.2 <0.2.0` 反而容纳 `0.2.0-rc.1`),本仓库仍只用 caret + 当前基线 tuple,并由 `tests/market-manifest.spec.ts` 钉住。
**0.2.0-rc.1 复查结论(适配前实测,故本版无任何运行时兼容分支)**:
- 本插件消费的 **19 个宿主包零值导出删除**(脚本对比每包的 `export {}` / `exports.x=`);唯一新增是 `ui-primitives` 的 `pointerModality`。
- 类型面变化只有三处且全为增量:`ui-primitives`(`DisclosureRow` / `TextShimmer` / `Tooltip` 新增可选 prop、overlay 顶部内距改用 frame overlay inset)、`dsh-session`(新增 `ToolCallRecovery` 与 `openTurnClosers` 的新措辞;`interruptedTurnClosers` 仍在)、`dsh-api-remotes`(新增 product-analytics remote 行)。
- **未变**:会话格式仍 v4(无 `session-format-v4-to-v5`)、`SUBAGENT_DESCRIPTOR_VERSION` 仍 3、`dsh/lib/bin.js` 与 `dsh-client-modules` 运行时**逐字节相同**、web profile 客户端 inject 列表相同、`ui-primitives` 裸 import 集合相同(无需新增测试期依赖)、agent-team 四个包类型逐字相同。
- **宿主 web profile 组成**:新增 desktop-only `product-analytics` / `desktop-product-telemetry`(web 上 `disabled`);`time-context` / `schedule` / `ui-schedule`(本就 `disabled`)移入可选 `dsh-experimental-schedule-bundle`。挂载 lane 的「宿主 tab 恰好 1 条(terminal)、browser 缺席」断言不受影响。插件**不依赖**宿主 schedule 服务。
- **生态连带**:可选集成 `@huanlin/dsh-plugin-better-locale` 的 peer 钉 `^0.1.x`(最新 0.4.3 也钉 `^0.1.5-rc.2`),在 0.2.0 上必被预检禁用 → `pnpm peers check` 会报它的 5 条 unmet peer,属上游未适配,与本插件自身 14 条 peer 无关。
- **本机 dev 树**:先 `pnpm install --registry https://registry.npmjs.org`(npmmirror 对新预发布滞后,直接 `pnpm install` 会 `ERR_PNPM_NO_MATCHING_VERSION`)。
- **用户 profile 自己的 pin 也要同批抬**(2026-09-28 实测踩到):profile 的 `pnpm-workspace.yaml` 里若有 `overrides: '@deepseek-ai/dsh-*'`(例如历史上为修「旧宿主包遮蔽 bundle」而加的 `dsh-settings` pin),**升级宿主时必须与宿主版本同批更新**。忘了抬的后果:profile 顶层留住旧版 `dsh-settings`,它用精确版本拉来旧版 `dsh-config-editor` 并遮蔽 bundle 自带的 0.2.0 版本 → 预检禁用 `config-editor` 行 → `settings` 行永远 `pending (waiting for service: configEditor)` → 界面报「加载提供商目录失败: settings service is absent」。症状与原因隔着好几层,先看宿主日志里的 `disabling profile plugin row` 与 `pending (waiting for service: …)` 两行。
### 3.1~3.4 历史:DSH 0.1.7 适配要点(0.1.7-rc.1+ 基线,v0.21.1–v0.23.0)
**v0.21.1 起仅支持 DSH 0.1.7-rc.1+**(peer 下限 `^0.1.7-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`)。**下限必须动**:semver 的预发布规则要求比较子与候选版本同 `[major,minor,patch]` 元组,`^0.1.6-alpha.2` 在数学上**永远容纳不下任何 `0.1.7-*` 预发布版**(实测 `0.1.7-alpha.1` 对 `^0.1.6-alpha.2` / `>=0.1.6-alpha.2` 全是 false),而 `^0.1.7-rc.1` 天然容纳 0.1.7 的 rc/正式全线。**下限从 alpha.1 收到 rc.1 是刻意的**:`0.21.0-alpha.1` 从未发布到 npm(`latest` 仍是 v0.19.1,npm 上没有 alpha.1 的下载量),而修掉 rc.1 的 `DiffBlockLabels` 之后,同一份代码在 alpha.1 上会**崩**——alpha.1 的 `DiffBlock` 仍读上游已删除的 `labels.files`。声明与「真机验证过的宿主」一致,比声明一条跑不通的支持线更安全;rc.1 起 peer 还是**启动期硬约束**(见 §3.4 第 12 条),声明过宽的代价从「可能报错」变成「静默不加载」。**支持线取舍**:0.1.6-alpha.2 及更早的用户停留在 **v0.19.1**(0.20.0 这一版从未发布到 npm)——本版破坏面足够大(`dsh-settings` 整体重写、25 个图标具名导出消失、会话格式 v3→v4),按本仓库先例不写运行时兼容层。发版:release.yml 按版本号是否含 `-` 自动选 `alpha`/`latest` dist-tag(`0.21.1` 不含 `-` → `latest`,npm `latest` 由此从 0.19.1 前移到 0.21.1;中间的 `0.21.0-rc.1` 含 `-`,只进过 `alpha` 通道)。
### 3.1 本版四类硬破坏(都是「不修就崩或静默失效」)
1. **`dsh-settings` 整体重写**(`packages/settings/settings`):`SettingsProvider.register(ns, schema)` 被删除,只剩 `SettingsForms` 的 `describe/update/replace/mutate/configure`;**命名空间就是插件 Loader 行的 `entry.options.id`**(不再是插件自选的字符串),schema 来自 `entry.fiber.runtime.Config`,且 `describe()` 只列出「有 `Config` 且 fiber 处于 ACTIVE」的条目。本插件的适配:
- `src/config.ts` 把用户偏好并进导出的 `Config`(原先只是 4 个部署限额),并把每个偏好字段收集为 volatile schema。**这一个标记就是「改设置不重挂插件」的全部机制**:Loader 的 `equalExceptVolatile` 跳过 volatile 字段,纯 volatile 变更走 `_commitVolatile` 提交进运行中的引用并发 `loader/volatile-update`,而普通字段(4 个限额)保持原来的重挂语义。**必须用 DSH 自己的 schemastery(`@deepseek-ai/schemastery`),这是本版最容易踩空的一处**。`schemastery` 的 `meta.volatile` 只是标记;DSH 的构建额外在**解析期把 volatile 字段包成 cosmokit `Volatile` 引用**(`createVolatile`),而 Loader 的 `_commitVolatile` 正是走 `volatileEntries`/`updateVolatile` 去提交那些引用。用公开的 `schemastery` 包:写入**报告成功、revision 也涨**,但有效值永远不变——静默丢弃每一次改设置(真机实测:`settings.update` 200 + `revision: 1`,而 `describe().value` 仍是旧值)。另一个同等致命的坑:`.volatile()` 走 `extra()`,**返回的是副本而不是原地修改**,`for (const f of ...) f.volatile()` 会把标记丢掉,随后宿主报 `Plugin entry "better-sidebar" has no volatile fields`;必须收集返回值。
- `src/index.ts` 的 `ownEntryId(ctx)` **运行时自发现**自己那一行的 id(按 `options.name === 'dsh-apes-sidebar'` + `entry.fiber === ctx.fiber` 匹配,回退到第一个启用的同名行),**不允许硬编码 `better-sidebar`**:聚合包会用 `web-ui-better-sidebar` 之类的 id 挂同一个包。
- `viewOf()` = `describe({redactSecrets:true}).find(d => d.ns === ns)`;写入 = `update(ns, patch, expectedRevision)`(`SettingsConflictError` 仍导出、构造签名未变,409 分支保留)。
- `ctx.effect(() => settings.configure({ auto: false }, ctx.fiber))` —— 官方 README 给「自带设置页的插件」的写法;不这么做 Settings 会把同一批字段渲染两遍。
- **`settings/document-updated` 监听不到,不要写**:它在 settings 服务自己的 context 上 `emit`,而 cordis 的事件只向上冒泡到该 ctx 的祖先,插件的 fiber 是兄弟。本插件改用「客户端每次重新读取表单时重新求值」覆盖同样的触发点(`settingsFace.get()` 里跑门控同步)。
2. **`ui-primitives` 图标整族改名**:`Icon<Name><14|16>` → `Icon<Name>Regular`(1px 描边)/ `Icon<Name>Medium`(1.3px)。0.1.6 的 81 个 `export const Icon*` 在 0.1.7 变成 184 个,**旧的尺寸后缀名一个不剩**(`git grep -c Outline16 <tag> -- packages/client/ui-primitives/src` = 0)。插件 25 个 icon 具名导入消失、17 个文件受影响。**失败模式是 render 期而不是 load 期**:插件 client bundle 是 CJS + external,产物是裸属性读,缺导出 = `undefined` → React 抛 `Element type must be invalid`,不是 ESM link error。**改名必须同时钉回尺寸**:0.1.6 的 `IconXxx14` 默认 `size = 14`,0.1.7 的 `XxxRegular` 一律默认 16,所以原先依赖 14px 默认值的 5 处(`TabBar.tsx` / `Sidebar.tsx` / `FileTree.tsx` 的 `IconCloseFillRegular`、`SubagentView.tsx` 两处 `IconRefreshOutlineRegular`)必须显式传 `size={14}`,否则**静默变大 2px**。另有 `StateDot` 的 ongoing 态被整图重画(10px 像素矩阵 → 14px 圆环 spinner,默认尺寸 `10 → 14`),签名兼容故无编译错误;插件选择跟随宿主观感。**rc.1 复查:`icons/index.tsx` 只新增了 `IconUsersOutline{Artwork,Regular,Medium}`,无删除、无改名,也没有任何既有 icon 的默认尺寸变化**(`export const` 187 → 189;`icons/props.ts` 与 `shared-artwork.tsx` 逐字未变)——上面这轮改名是 alpha.1 的历史账,不是 rc.1 的。**计数更正**:插件实际是 **25 个 icon 具名导入 + 1 个 type-only `IconProps`**(`tests/primitives-exports.spec.ts` 会实时提取导入面并与装好的包对照,数字写错它会先红)。
3. **会话格式 v3 → v4**(`packages/session/session-format-v3-to-v4`):两处变化。
- **`source.kind === 'plugin'` 被硬拒**(`message-sources.ts` 抛 `format v4 message requires a producer-owned source kind`),而插件的 sidechat 边界注入正是那个形状。改用 **`plugin:dsh-apes-sidebar`**:与 DSH 自己的 v3→v4 迁移对历史行产出的 kind **逐字一致**(`producerKind()` 对未知插件名返回 `` `plugin:${plugin}` ``),所以新旧数据形状统一,**历史 sidechat 会话不会丢**。类型层面还需一条 `declare module '@deepseek-ai/dsh-llm'` 的 `MessageSourceMap` 增强(0.1.7 把它改成合并可扩展的封闭联合,没有共享 catch-all)。
- **tool 结果消息重写**:`role: 'user'` + 嵌套 `type: 'tool-result'` 包装块 → `role: 'tool'` + **顶层** `content` / `isError` / `toolCallId`。插件的 4 个解析器原先靠 `candidate.type === 'tool-result'` 命中,在新形状下恒返回空串(**静默空白**)。现在四处都**同时接受新旧形状**(旧分支必须保留:历史日志里就是旧形状)。
4. **客户端会话快照换字段**:`SessionListState.subagentsByParent` / `jobsBySession` 被 `projectionsBySession` 取代,且 **`setSubagentCatalogOpen` 这个按需观察 API 被整体删除**(0.1.7 在连接建立时一次性加载所有 Session 的 projection,所以那套 observe/unobserve 机制应当删除而不是改名)。子代理目录改读 `projectionsBySession[sid]?.values.subagentCatalog`(`{id, createdAt, mode: 'one-shot'|'continuable'|'unknown', label?}`,**注意 `unknown` 是新值**);后台任务列表**没有替代字段**,但 0.1.7 的 web profile 会挂载**客户端** `ctx.jobs` 服务(`@deepseek-ai/dsh-api-job-controller/client`,`web-app/cordis.patch.yml`),任务页直接读它——`state.getSnapshot()/subscribe()` 是整份快照(`{rows: 按被观察会话的 roster, observed: 按任务 id 的保留输出}`),`watchRows(sessionId)` 是按会话引用计数的推送 roster(空集合即该会话无任务),`observe(sessionId|undefined, jobId)` 是**非消费**的输出流(返回释放函数),`kill(sessionId, jobId)` 是注册表准入。插件因此**删掉了自建的 `jobs.list` / `jobs.output` / `jobs.kill` 三条路由**(`src/jobs-routes.ts` 已不存在),也不再用事件回放读模型已读的输出:那个 pane 从诞生起就受「不能碰模型 `job_output` 游标」的约束,宿主服务把这条约束变成了能力。同一版里 `ctx.jobs.list/get/read/kill` 的 caller 从 `Agent` 变成 `SessionId`(插件因此可以彻底不碰 `ctx.agents` 来调注册表)。
### 3.2 本版的能力让出与收敛
5. **只读文件预览交还宿主**:0.1.7 的 `ui-sidebar-documentpreview` 长齐了 code / excel(xlsx,xls,csv,tsv) / office(宿主侧转 PDF) / pdf / image / html / markdown / text,并带缩放与**按目录自动刷新**。插件删掉自己的 `image` / `pdf` / `binary-download` 三个 viewer,并在 `src/client/native/index.ts` 的 `editor.canOpen` 里**拒绝认领**这些扩展名(`canOpen` 返回 false 就把地址让给内置 `text` 的 `fallback` 档):
`xlsx xls csv tsv fods pdf png jpg jpeg gif webp svg bmp ico doc docx ppt pptx`
**rc.1 修掉了一处过度让出**:最初让出的 27 个里,有 9 个宿主**根本没有渲染器**(`xlsb xlt xltx xltm ots dot dotx avif`——`avif` / `ods` 在 `ui-sidebar-documentpreview/src/client/document/unviewable.ts` 的「已知二进制、无渲染器」表里直接走空状态,其余 7 个落回 text 兜底后按二进制判定失败,同样是空状态),点开只有一句「暂不支持预览」;而这些文件在让出**之前**是走插件兜底显示下载面板的——也就是说这是一处真实的用户可见回归。现已收回这 9 个,`fods` 继续让出(它是扁平 XML 文本,宿主 text 兜底会用纯文本显示,比下载面板有用)。`tests/native-surface.spec.ts` 现在**双向**钉住这条边界:宿主渲染的必须拒绝、宿主没有渲染器的必须认领。
**保留在插件侧的三件**:`md/markdown`(自带渲染器,用户认为优于内置)、`html/htm`(自带沙箱预览 + `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe`,宿主没有这两个开关)、以及 `code` catch-all(**它是可编辑的 CodeMirror + 保存**,内置那几个是只读预览)。未知二进制(`.zip`/`.wasm`)仍走 `code` 认领 → 宿主 fsRead 判 binary → head 重匹配无 sniffer → `EditorHost.tsx` 的 `load.status === 'binary'` 兜底渲染下载面板,**功能不回归**。
6. **宿主 `browser` kind 在 Web profile 不再挂载**:`packages/bundle/web-app/cordis.patch.yml` 给 `ui-sidebar-browser` 加了 `disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'"`。因此 e2e 的 `HOST_OWNED_TABS` 只剩 `['terminal']`,并且**显式断言 browser 条目缺席**(钉住新真值,而不是默默放宽)。DSH 同时新增用户设置 `linkOpening` 决定正文链接进侧栏还是新标签页。
7. **外链接管收敛**:删掉 `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps` 三个偏好键(含 20 份词典的 key)。新行为是**只认领声明了 `urlTarget` 的链接**,其余一律不 `preventDefault`、放行给宿主;认领成功但目标类型此刻不可用时 `window.open(url, '_blank', 'noopener,noreferrer')` 兜底。**修掉了一个真实回归**:0.1.6 那版把浏览器交还宿主时,`openInSidebar` 仍兜底到 `'browser'`,而插件注册表里已无该类型、`service.openTab` 对未注册类型静默 `return`——默认配置下插件自绘 markdown(sidechat 转录 / 编辑器预览 / diff)里的 http 链接**点了没反应**。
8. **文件树新增实时刷新**:插件接管了 `files` kind,所以宿主自己的按目录 watch 覆盖不到它。新增 `src/fs-watch.ts`(宿主侧,每条连接一组 `fs.watch`,150ms 去抖,上限 64 个句柄)+ `src/client/use-dir-watch.ts`(一条 `/sidebar/ws/fs-watch` socket 携带展开目录集,目录折叠即退订),客户端收到通知后只丢弃并重列那一层缓存。路径解析沿用 `fs.tree` 的同一套词法解析(v0.23.0 起无工作区包含检查)。
### 3.3 既有缺陷(不是 0.1.7 引入,但这次一并修)
9. **幽灵字段 `.current`**:插件读 `ctx.sessions.list.getSnapshot().current`,而 `SessionListState` **在两个 tag 里都没有这个字段**(插件 `context-types.ts` 自造了它,所以 tsc 一直放行)。后果:`src/client/native/surface.ts` 的 `onScreen` 判定恒 false(永远走 `*In` 分支),`src/client/Sidebar.tsx` 的 `store.setSession(current)` 恒收到 `undefined`(按会话持久化实际没绑上),`use-host-feeds.ts` 的窄视口 park 门控恒假。**改用 0.1.7 新增的 `ctx.sidebarRight.mounted`**(`ObservableSnapshot<SessionId | undefined>`,只在会话真变化时 set)。`Pending` 队列与 `openTabIn/openResourceIn/closeIn` **必须保留**:它们仍是唯一能写「未上屏会话 store」的路径(这三个方法在具体 class 上,不在 `ISidebarRight` 接口里,需结构化探测)。
10. **`package.json.files` 漏了 `lib/client-locale.js`**:chunk 名在 8 处镜像,`files` 里只有 `client-editor.js` / `client-mermaid.js`,于是 locale chunk 从来没进过 npm tarball(社区 PR #739 覆盖同一问题)。已补。
11. **测试 harness 的 `window` shim 缺 `addEventListener`**:0.1.7 的 `ui-primitives` 在**模块求值期**就 `window.addEventListener('scroll'|'resize'|'blur')`,而 `tests/browser-globals.ts` 的假 window 只有 `clearTimeout/setTimeout/innerWidth/innerHeight/getComputedStyle/location` → `TypeError: window.addEventListener is not a function`,**任何 import `service.ts` 的 spec 一个用例都跑不起来**。已补 `addEventListener` / `removeEventListener` / `dispatchEvent` / `matchMedia`。
12. **测试期依赖闭包又长了一层**:`ui-primitives@0.1.7` 的产物新裸 import `@deepseek-ai/dsh-client-store` 与 `@deepseek-ai/dsh-util-workspace-path`,**而 `dsh-client-store` 自己的产物又裸 import `zustand`(×3)与 `immer`**,且这几个包都不声明 `dependencies`。所以 devDependencies 要补的是 **4 个**(两个 `@deepseek-ai` 包 + `zustand@~4.4.7` + `immer@^10.1.1`),不是 2 个——只补前两个,vitest 一加载就 `Cannot find package 'zustand'`。**反过来不要预加** `dsh-workspace` / `dsh-agent-preset-registry`:它们只是 `dsh-subagent` 的 optional peer,而该包 28 个 `lib/*.js` 里从不 import 它们(实测解包核对)。一律以 `pnpm peers check` 的实际输出为准。
13. **e2e 的 token 交换不能走 Node 全局 `fetch`**:宿主静态 fallback 对纯 Node 的 GET 回 **空体 405 且无 `Set-Cookie`**(在 0.1.5-rc.2 与 0.1.7-alpha.1 上都复现,属 harness 怪癖而非宿主回归),于是 `cookieHeader` 永远拿不到、后续 `/api` 种子请求全部未认证、挂载 lane 卡在拿不到 tab bar。改用 Playwright 自己的 `request.newContext()`(`exchangeLaunchCookieViaPlaywright`,`maxRedirects: 0` 以免跟随 303 丢掉 `Set-Cookie`)。
### 3.4 宿主契约(0.1.5 线起沿用,均经真机挂载冒烟验证)
1. **一次性 token 鉴权**:就绪行 `dsh web: http://127.0.0.1:<port>/?token=<43字符>`(导航换签名 cookie,干净 URL 401)。`e2e-mount.sh` 的 URL grep 必须延伸到空白(`[^ ]*`,在 `/` 截断丢 token);e2e 统一走 `tests/e2e/host.ts`(token 必选:`parseLaunchUrl` 对裸 origin 直接抛错),带 stamps 导航走 `gotoPage()`(先 addCookies 再直达——token 换 cookie 的 303 会丢弃同 URL 其它 query 参数)。插件 `/sidebar/*` 路由不受影响,同源 fetch 照旧。
2. **Remote gateway 斜杠 RPC(唯一方言)**:`POST /api/workspace/create`,payload 恰为 `{args: {...}}`,**args 按控制器 TS 参数名包装**(`workspace/create`、`session/create` → `{args:{request:{...}}}`;`session/list` 参数名 `_request` **不可省略**——`{}` 也被拒 `args fields do not match the descriptor`);envelope `method` 与路径一致,点分路径 404。请求由 `tests/e2e/host-protocol.ts` 的 `rpcAttempt` 构造、`tests/e2e-host-protocol.spec.ts` 锁定。
3. **`MarkdownText` labels 嵌套契约**:必填 `labels: { code: { copyLabel, copiedLabel, toolbarLabels? }, footnotes }`(漏传回退硬编码中文)。四个渲染点(mermaid.tsx / MarkdownHtml.tsx / TextEditor.tsx / SideChatView.tsx)统一走 `src/client/markdown-labels.tsx` 的 `markdownTextProps()`。**alpha.1 逐字未变;rc.1 新增可选的 `labels.code.toolbarLabels`**(`{codeLabel, wrapLabel, unwrapLabel}` 三个必填串)——传了它 `CodeBlock` 才渲染宿主新的 code card(语言名 + 换行开关 + 复制),不传就保留旧的 `data-code-block-banner` info banner。插件选择**全面采用**(同时用宿主同一套措辞 `codeBlock.title|wrap|unwrap`),所以 `MarkdownCopyLabels` 现在是 5 个字段,每个构造点都要给全(`tests/mermaid-markdown.spec.tsx` / `tests/markdown-html-render.spec.tsx` 两个 fixture 一并同步)。**同时修掉它带出来的坑**:新 card 对没有语法高亮的语言(mermaid 正是)显示 `codeLabel`,**info string 不再进 DOM**,于是 `src/client/mermaid.tsx` 原来那条「读 banner 里的 `mermaid` 字样」的探测恒假、围栏不再被换成图(三个 mermaid 用例当场红);现改为按**围栏正文**匹配——`splitMermaidBlocks(text)` 得到的 mermaid 正文集合 vs `block.querySelector('code')` 的文本,两侧 `trimEnd()`。`DiffBlockLabels` / `ReadBlockLabels` 在 rc.1 里改为继承 `CodeToolbarLabels` 且**删除了 `DiffBlockLabels.files`**,所以 `src/client/SideChatView.tsx` 的 diff / read 两个字典要给三个新键、并删掉 `files`(`sideChatBlockFiles` 词条随之从 20 份词典中删除)。
4. **侧边对话转录走自有路由,不碰客户端宿主 RPC**:`ctx.connection.api`(含 `sessions.history`)在 alpha.1 整体移除,继任 `session/follow|page` 又对 `origin:'subagent'` 会话强制 subagent 地址(普通 `{kind:'session'}` 被 `agent-busy` 拒)且分页 `throughSeq` 不得超当前游标。转录因此由 **`sidechat.events`** 插件路由供给(`src/sidechat-routes.ts`:live 读 `agent.session.snapshotEvents()`、冷读走 `sessionPersistence` 的**句柄 API** `open(id,'read')` → `read()` → `close()`;`inspect` 早在 0.1.5 就被删除,注释里再写它属于历史残留,服务端 `session/end-seed` 切割 + `afterSeq` 增量)。**0.1.5 起实时增量不在日志里**:`assistant/chunk` 事件被删除,进行中的模型增量改由 `agent/assistant-stream` 瞬时帧发布(`start`/`chunk`/`end`,不入会话日志),结算时才落 `assistant/message` 或 `assistant/attempt`。插件在宿主侧 `src/assistant-live.ts` 折叠这些帧成有界缓冲。**0.1.7 复查:`packages/core/agent/src/runtime-types.ts` 逐字未变,无需调整。**
5. **`dsh-subagent` 的 `SUBAGENT_DESCRIPTOR_VERSION` 仍是 3**(0.1.7 复查:`descriptor.ts` 两 tag 同值)。sidechat 种子的 `subagent/descriptor` 版本由宿主包盖章,插件不硬编码;测试断言跟随常量(`tests/sidechat-routes.spec.ts`),勿钉字面量。
6. **`@deepseek-ai/dsh-client-runtime` 包已消亡**(继任 seed 是裸名 `dsh-client-store`,无 `/client` 子路径):peerDependencies、devDependencies、`dsh.client.inject`、chunk externals 白名单(`src/client/chunk-loader.ts` / `tsdown.config.ts` / `tests/chunk-loader.spec.ts` / `tests/manifest-consistency.spec.ts` 四处同步)均已无该条目。
7. **e2e scratch profile 的 `minimumReleaseAgeExclude` 含 `'@deepseek-ai/*'`**(`scripts/e2e-mount.sh` / `e2e-aggregate-mount.sh`,与仓库根 `pnpm-workspace.yaml` 同策):alpha 版本常在发布后 24h 内跑 lane,pnpm 11 的 `minimumReleaseAge` 默认会拒装新鲜包。
8. **插件开发树的 dsh-* 传递 peer 需提升为 devDependencies**(alpha.3 首见):`dsh-subagent` 等 npm 包把 `dsh-attachment` 等 dsh-* 姊妹包声明为 peerDependencies(由宿主 bundle 树统一提供,宿主侧无此问题),插件仓库若只直接依赖其中一部分,其余 peer 在 pnpm 下会解析到树上残留的旧版——如 `dsh-attachment@0.1.1-rc.1` 缺 `admitPromptContent` 导出、`dsh-subagent` 产物 import 它时测试加载即崩。因此 devDependencies 需涵盖 dev 树实际触达的全部 peer(attachment / scope / session-projection / system-prompt / user-approval / util-time 等,与直接依赖同款精确钉版);**`@deepseek-ai/cordis` peer 自 alpha.3 起要求 `^4.0.2`,0.1.7-alpha.1 要求 `^4.0.3`,rc.1 起要求 `~4.0.4`**(rc.1 顺手把 vendor 一起抬了:cordis 4.0.4 / schemastery 3.18.4 / cordis-plugin-group 1.0.4 / cordis-plugin-include 1.0.9;loader 保持 1.0.5)。**rc.1 实测的教训**:只改 `dependencies` 里的 schemastery 和 peer 里的 cordis **不够**——`cordis-plugin-group` / `-include` 是 `dsh-app-boot` / `dsh-config-editor` 的**传递 peer**,pnpm 会继续沿用 lockfile 里的旧副本,`pnpm peers check` 一次报出四类 unmet peer;按本条把四个 vendor 包**显式钉进 devDependencies** 才收敛(这也是 rc.1 那条「DSH 内部引用一律精确版本」的 workspace-release-ranges 政策的直接后果)。适配新版本时先跑 `pnpm peers check`,把新失配的传递 peer 一并提升进 devDependencies。**0.1.7 新增的同类问题**见 §3.3 第 12 条(`ui-primitives` 的第二层裸 import)。例外:**`dsh-client-locale` 的 peer/devDep 允许落后于基线**的历史已消除,rc.1 起上游恢复发版,0.1.7 起与 DSH 同 tuple。
9. **右侧栏是 DSH 原生栏,插件只提供 tab 类型**:聊天里一切文件打开(工具行 / 产物行 / 正文提及 / 行内代码路径)统一走 `ctx.sidebarRight.openResource(fileAddressFor(sessionId, cwd, path))`。插件的每个 `TabDescriptor` 注册成原生 tab 类型(`kind = descriptor.id`,`extension` 带)+ 原生 tab 体(`sidebar.right.pane.tab` keyed 槽,key = `dsh-apes-sidebar:<id>`);`editor` 类型同时认领 `dsh-resource://file/**`(**但按 §3.2 第 5 条的清单让出一部分**),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件文件树,注销即复位)。`ctx.sidebarRight.openTab/openResource/close` 只对**在屏会话**写入;跨会话用具体类上的 `openTabIn/openResourceIn/closeIn`。原生 tab 的插件侧状态在 `src/client/native/tab-adapter.tsx`。**注册必须等服务、不能等槽声明**:原生栏先声明 `sidebar.right.pane.tab` 再 `provide('sidebarRightTabs')`,真机 profile 上槽声明回调里 `ctx.get('sidebarRightTabs')` 仍是 undefined(实测 3 秒后才出现),按槽触发注册会静默什么都不注册且永不重试——用 `ctx.inject(['sidebarRightTabs'], cb)` 驱动类型注册(`tests/native-surface.spec.ts` 以「槽先触发、服务后到达」的顺序守护)。**0.1.6-alpha.2 起**:`SidebarRightGuideEntry.id` 必填且同 provider 内唯一(缺它会静默清空整个承载面);同 provider 的 guide 条目是**追加**关系,`chain` 那种「替换宿主条目」的语义只存在于 `conversation.chat.turnTail` 且已被上游改成 `list`。**0.1.7 起**:`sidebar-right` 本体的契约**零破坏**——槽名无改名、`SidebarRightTabDefinition` 没有新的必填字段、只有新增的可选 `keepMounted` 与 `ISidebarRight.mounted`,`openTabIn/openResourceIn/closeIn` 在具体 class 上字节不变;`tab.visible` 语义收紧为 `active && (...)`(全局面板/后台会话时 body 不挂载);持久化校验收紧(非根 / 列轴 / 嵌套 / 3+ docked pane 的 split 一律判非法**整份丢弃**,0.1.6 的 sidebar 自己产不出这种布局);`RightbarRoot` 现在为每个保留会话渲染一个隐藏 seat(`SidebarSessionViews`,未导出、插件拿不到)。原生 tab 体宿主 `.paneBody` 是**有确定高度的块级滚动容器**(非 flex 容器),native 适配层因此给每个 tab 体包一层 `height:100%` 的列 flex 宿主(`sidebar.module.css` 的 `.nativeTabHost`),tab 组件根继续用 `flex:1`/`height:100%`。全局面板(根级 `main` keyed 槽 + `sidebar.panellist` + `ctx.layout.selectPanel/beginNavigation`、`rightbar` 改根级并新增 `rightbar.session`)插件**不接入**,只做兼容。底部工作台的中心列锚点认 `[data-slot="main.conversation"]`(并跳过 `display: contents` 祖先,同时保留 alpha.1 的 `[data-slot="conversation"]`);文件地址语法跟随 alpha.2(`fileAddressFor` 一律 session 作用域、绝对路径保留前导 `/`、`parseFileAddress` 前缀解析并去 `?`/`#`)。**原生指南说明的 4 条上限(归属更正:这是 0.1.7-alpha.1 起就存在的既成事实,不是 rc.1 的变更)**:`ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx` 的 `MAX_DESCRIBED_ENTRIES = 4` 及其判定行在 alpha.1 / alpha.2 / rc.1 三个 tag 上**逐字相同**,宿主终端的 guide 条目(`ui-sidebar-terminal/src/client/TerminalGuide.tsx`)也是 alpha.1 就有了,`ui-sidebar-terminal/src` 在 rc.1 **零改动**。含义不变:条目 ≤ 4 条才渲染说明,更长的列表是整列丢弃而不是截断——插件默认贡献 4 个 guide 条目(文件 / 文件变动 / 任务管理 / 侧边对话),与宿主的终端条目合计后可能超过 4 条,此时说明不渲染;插件**不恢复**旧的 `nativeGuideDesc` 通用兜底句。**原生注册账本的两条不变量(v0.22.1 起,由 `tests/native-registration.spec.ts` 守护)**:① `live` 里同时装着描述符类型(键 = descriptor id)与 `files` 接管(键 = `FILES_KIND`,**永远不在 `wanted` 里**),清理循环必须**跳过 `FILES_KIND`**——它只由编辑器类型的 `wantsFiles/hasFiles` 开关分支与 seat disposer 管理;否则每次通知都会把它拆掉再重建,而 teardown 期间的那次重建会把 id 孤儿化:`tabs.register` 建在**宿主** ctx 上(宿主 ctx 此时仍活着)所以照样成功,紧随的 `ctx.slots.inject` 建在**插件** ctx 上却抛 `cannot create effect on inactive context`,`live.set` 永不执行 → 该 id 在整页生命周期内不可再注册(真实表现:`native register files error: … already registered` 刷屏 + 文件树落到宿主空态,只有刷新页面才恢复;官方桌面壳日志实证见 [docs/plans/2026-09-28-native-files-takeover-reload-leak.md](docs/plans/2026-09-28-native-files-takeover-reload-leak.md))。② 因此「注册成功一半」是**可达状态**:任何在宿主取走 id **之后**失败的注册(槽位注册抛错等)必须**回滚释放该 type**、并释放已经建好的槽位——插件侧只留一个下次通知可以重试的失败,绝不留下无处释放的 id。相关断言必须写在注册表的**事件日志**上而不是「活着的 id 集合」上:孤儿 id 会让键集合看起来完全正常,键集合断言在未修复代码上同样通过。
10. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`)**:dsh-session 契约「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话的 inbox 折重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**。宿主 `session.fork`(api-session-controller)的调用即规范形态;回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + 对 `ownEvents()` 跑 inbox 折)守护。**0.1.7 复查:`Session.create` 的五个参数与函数体逐字相同**;上游新增了 `buildForkSeed(events, boundary)` 导出,与本插件手工的 `buildSidechatInheritance` 职责重叠但**收益不明确**(插件还有 dangling-tool-call 退回结构化快照的自有需求),暂不替换。
11. **`dsh.plugin.json` 从来不是 DSH 契约**:0.1.7 全树 grep 仍 **0 命中**。DSH 真正读的是 `package.json` 的 `dsh.bundle.patch` + `dsh.client`(`platform`/`inject`/`immediately`/`external`)+ `exports["./client"]`。`dsh.plugin.json` 只服务第三方 registry(社区市场)通道,`lib/client-registry.js` 同理;`tests/manifest-consistency.spec.ts` 守护的是这条通道的形状,不是 DSH 契约。**`dsh.manifestVersion` 也仍是「只声明、零读」**(0.1.7 的 `package-manifest` README 自己写明「Current installers and loaders do not enforce `dsh.manifestVersion` or `engines.dsh`」)。0.1.7 的官方管控面新增:`plugin-manager` 的**多 registry 回退**(`registryPlan()`:配置源 → `fallbackRegistries`(默认含 `registry.npmmirror.com`),`network`/`timeout`/`not-found`/`no-matching-version` 会自动问下一个源;`registry` 入参只存在于 Web 对话框与 `plugin_manager` 工具,**CLI 没有 registry flag**)、`waitForInstall`、以及**插件展示元数据**:`package.json#icon`(36×36、纯填充无描边、**双色渐变**——与 DSH 自带的 `PluginArtwork*` 同风格:它们「carry their own brand colors instead of riding currentColor」,且每个都是多色,单色版在 30px 行内尺寸下会读成一块纯色板)+ `locale/<lang>.json` 的 `meta.title`/`description`。本插件**已采纳**(v0.21.1):`icon.svg` + `locale/en.json` + `locale/zh.json`,并由 `tests/plugin-meta.spec.ts` 守护「声明了就必须发布出去」。**rc.1 复查:`packages/boot/app-boot/src/package-meta.ts` 与 `scripts/verify-package-meta.ts` 在两个 tag 上逐字相同**(`ui-plugin-manager/src/client/presentation.ts` 只多了一段 `incompatible-version` 文案),所以上面三条硬规则与「**没有任何尺寸/渐变校验**」都仍然成立。三条硬规则:icon 必须是**包内相对路径**且写进 `files`;`locale/en.json` 是发现基线(缺它整个目录都不读);`exports` 必须暴露 `./locale/*.json` 与 `./package.json`。**所有失败都是静默的**——元数据有问题只会退化成一行纯文字,没有任何报错。`dsh.bundle.patch` 现在多了形状校验(非法值会 `throw`,引导期跳过该 bundle),本插件是合法的单字符串。
12. **peer 声明是启动期硬约束(rc.1 起;本版最容易静默失效的一处)**:`packages/boot/app-boot/src/plugin-compatibility.ts`(rc.1 **新文件**)只做一件事——遍历 profile 行的 `peerDependencies`,对**名字为 `@deepseek-ai/dsh` 或以 `@deepseek-ai/dsh-` 开头**的条目跑 `semver.satisfies(宿主版本, range, { includePrerelease: true })`,不满足就把整行 `row.disabled = true`(group 行连 `group` 一起关掉),只在 stderr 留一行 `disabling profile plugin <label>: <reason>`。调用点:`compatibility-preflight.ts` 的 `prepareProfileEntries`(`dsh web --profile <p>` 的必经路径)、`profile.ts`(bundle 行,异常被吞成 skip,该 bundle 不再贡献 patch 层)、`agent-preset-registry/src/mount.ts`(预置行),以及 plugin-manager 的安装前/安装后校验。**四个要点**:(a)它**只读 peer,不读 `engines.dsh` / `dsh.manifestVersion`**(那两个字段仍然零读者,见第 11 条);(b)`peerDependenciesMeta.optional` **没有任何豁免**——我们那条 optional 的 `@deepseek-ai/dsh-client-ui-sidebar-right` 照样参与判定;(c)`includePrerelease` 被强制打开,判定比 npm 宽松(`^0.1.6-alpha.2` 也能满足 `0.1.7-rc.1`),但**精确版本号、或没有预发布子句的范围(如 `^0.1.7`)一定不过**;(d)豁免只能写 profile 自己的 `compatibility.json`(`dsh plugin allow-version <pkg@ver> --dsh-version <exact> --accept-risk`),没有全局开关也没有环境变量。因此本仓库的 DSH peer **一律写 `^<基线>` 浮动范围、绝不钉精确版本**(devDependencies 才用精确钉版);`tests/market-manifest.spec.ts` 新增一条守护把这条规则钉住。**`pnpm peers check` 看不到这个失败模式**:它校验的是解析出来的 dev 树,不是宿主会读的范围形状。
---
## 4. npm 发版(GitHub Release → npm publish)
`.github/workflows/release.yml` 在 GitHub Release(tag `vX.Y.Z`)发布时自动发 npm:
1. **前置**:`package.json` 版本 bump 到 `X.Y.Z`,CI 全绿后打 tag;tag 与版本不匹配直接失败。
2. **流程**:`pnpm build` / `typecheck` / `test` → 校验 tag → `pnpm publish --provenance --access public`。
3. **认证**:npm **Trusted Publishing(OIDC)**,不配 `NPM_TOKEN`。一次性配置(npmjs.com package → Settings → Trusted Publishers):Provider `GitHub Actions`、Org `omdsh-dev`、Repo `DSH-better-sidebar`、Workflow filename `release.yml`、Environment 留空。
4. **调试**:`workflow_dispatch` + `dry_run=true` 只打包不发版。
---
## 5. 开发规则速查
- **构建纯度门**:client bundle 禁止 value-import `@dsh-external/*` 或非白名单 `@deepseek-ai/*`(`tsdown.config.ts` 拦截);`import type {}` 被擦除不触发——类型可共享,运行时符号不行;跨插件交互走 `ctx.betterSidebar` 方法调用。
- **懒加载 chunk**:重依赖(CodeMirror / mermaid / 19 份第三语言词典)在独立 bundle(`lib/client-<name>.js`,0.20.0 起只剩 `editor` / `mermaid` / `locale`),经 `/sidebar/bundle` 按需下发、`globalThis.__dshChunks__` 物化(`src/client/chunk-loader.ts`),**核心 bundle 禁止静态 import `src/client/chunks/*`**。chunk 名在 8 处镜像(`tsdown.config.ts` / `src/bundle-route.ts` / `src/client/chunk-loader.ts` / `package.json.files` / `scripts/package-registry.mjs` / `tests/{bundle-route,chunk-loader,chunk-artifact,manifest-consistency}.spec.ts`)——删一个 chunk 必须同步全部。
- **i18n**:词典在 `betterSidebar` 命名空间,跟随 DSH `ctx.locale`;**新增 zh key 必须同步 `src/client/locales-ja.ts` 的 ja 翻译**(否则 ja 下回退 en)。渲染 `MarkdownText` 必须经 `markdownTextProps()`(§3 第 3 条)。
- **皮肤契约**:视觉值只消费 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌,无硬编码颜色,**插件没有任何豁免面**——文件/文件夹图标是 DSH 官方 `FileTypeIcon` 的图形(宿主自己的调色板),插件画的每个 glyph(含内置 tab 彩色图标)颜色都来自令牌;`tests/theme.spec.ts` 同时守护「图标模块零颜色字面量」「样式表每条 `color` 解析到令牌」「没有图标数据被做成 chunk」。契约全文与 titleBar 四方案模型见[指南 §12](docs/external-plugin-guide.md),改动必须同步该节与 `tests/theme.spec.ts`。
- **契约反向引用**:皮肤契约被 `src/client/shell-presets.ts`、`tests/e2e/mount.e2e.ts` 的注释以「指南 §12」引用——调整指南章节结构时同步检查这两处。
- **接入 API 即文档**:`src/client/service.ts` 与 `src/client/builtins/` 的任何行为变更,必须同步 [docs/external-plugin-guide.md](docs/external-plugin-guide.md)(唯一权威接入文档,不再双份维护)。
- **文件/文件夹图标**:回退链是「具体 `names`/`exts` 注册 → catch-all(`exts: []`)→ 宿主 `FileTypeIcon`」。插件**不持有**扩展名表、图标数据或图标 chunk(#429 的 563 条数据与 `fileIconTheme` 开关在 rc.2 适配时删除,因为宿主已导出官方图形)。**语义雷区**:宿主分类器覆盖任意路径,所以注册 `exts: []` 会接管所有未具体命中的行——只想补几个类型的插件必须用 `exts`/`names`。
- **内置 tab 图标**:五个内置类型(文件 / 文件变动 / 任务管理 / 侧边对话 / diff)的 glyph 集中在 `src/client/builtins/tab-icons.tsx`(颜色在 `tab-icons.module.css`,全部 `--dsw-alias-*`),三处消费面自动同步:底部工作台 tab 条、原生指南胶囊、原生 tab 芯片。**原生芯片的图标是插件自己画的**——宿主的 `SidebarRightTabDefinition` 没有 icon 字段,但 `sidebar.right.pane.tab.title` 槽就是芯片内容(`NativeTabTitle` 渲染 `[glyph][title]`,glyph `aria-hidden` 以免改可访问名)。
---
## 6. 文档与测试地图
- **插件展示元数据**:`package.json#icon` + `locale/<lang>.json`(DSH 0.1.7+ 的 Plugins 页与 `plugin_manager` 列表),由 `tests/plugin-meta.spec.ts` 守护。
- **接入 API 全参考**:[docs/external-plugin-guide.md](docs/external-plugin-guide.md)(消费插件开发者向;§0 原生栏承载面 / §4 Tab API / §5 FileViewer API / §7 服务方法 / §10 平台陷阱 / §11 已移除的自由窗口 / §12 皮肤契约 / §15 真实案例)。
- **设计文档**:[docs/plans/](docs/plans/)(30+ 份逐特性设计,含实施偏差记录)。
- **测试不得依赖非普适二进制**:CI 跑 ubuntu + windows 两个 runner,`rg` / `fd` / `jq` 这类「开发机上有」的工具在 runner 上没有,代价是整个 spec 在**收集阶段**失败(`spawnSync rg ENOENT`)而本地永远绿——上一版的 `tests/primitives-exports.spec.ts` 就这么让分支红了两轮。取文件清单用 `readdirSync` 自己走,或只用 `git` / `tar` / `pnpm` / node 内置能力;确需外部工具时先探测存在性并跳过。
- **关键测试守护**:`tests/service.spec.ts` / `builtins.spec.ts`(注册表与内置清单:**5 tab + 3 viewer**——终端与浏览器已交还宿主,只读预览也按 §3.2 第 5 条让出,只剩 markdown / html / code)/ `market-manifest.spec.ts`(市场约束,含 `pnpm pack` 后的产物面)/ `manifest-consistency.spec.ts`(registry 通道形状)/ `e2e-host-protocol.spec.ts`(RPC 双协议)/ `native-surface.spec.ts`(原生右侧栏承载面:guide 条目 id 唯一性、以及 `canOpen` 的「拒绝清单不得宽于宿主渲染器」双向边界)/ `native-registration.spec.ts`(原生 tab 注册生命周期:`files` 接管不得被通知拆建、注册失败必须回滚已占用的 id 与已建槽位;断言写在注册表的事件日志上)/ `theme.spec.ts`(皮肤契约,含空白面板胶囊几何)/ `plugin-list.spec.ts`(推荐插件目录)/ `fs-search.spec.ts`(host 文件名搜索)/ `fs-watch.spec.ts`(文件树目录 watch 的去抖、句柄上限与错误路径)/ `smoke.spec.ts` 的 settings 段(按 entry id 读写表单、**一次性 legacy 迁移**、open 工具门控)。