The rc.1 adapter first went out as 0.21.0-rc.1, but DSH 0.1.7-rc.1 is upstream's release batch, so the plugin that targets it must not sit on a prerelease channel. Re-cut it as v0.21.1: the version is now free of `-`, so release.yml publishes it under npm's `latest` instead of `alpha`. Version literals only — package.json, dsh.plugin.json and SIDEBAR_SERVICE_VERSION (tests/service.spec.ts still pins the last two together). No code change: the peer floor stays ^0.1.7-rc.1 and the support line stays "DSH 0.1.7-rc.1 and later". Docs follow the channel move: README/README_EN/guide drop the "alpha channel" framing, the install command becomes @latest, and every older DSH line now gets an explicitly pinned command in the version table (@0.19.1 and so on) because `latest` no longer covers them. The rc.1 design record keeps its history and gains a §8 for the re-cut.
110 lines
40 KiB
Markdown
110 lines
40 KiB
Markdown
# dsh-better-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 或插件自有路由(如 `jobs.output` 事件回放:读会话事件日志而非动注册表);做不到先向用户说明取舍,不改 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-better-sidebar]` 挂载、无错误条/pageerror/console 错误,展开 DSH 原生右侧栏后经其 guide 页逐个打开本插件贡献的 tab 类型**以及宿主自己的终端条目**(必须恰好 1 条——多于 1 条即意味着插件又在遮蔽宿主),再经插件文件树(原生 `files` kind 接管)打开 seed 文件强制加载 editor chunk(`client-editor.js`),并跑 mermaid / README 预览与 sidechat 宿主路由烟测。**`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.1.7-rc.1`——**不钉就会解析 `latest`,而 `latest` 仍是插件已不支持的 0.1.5 线**)。CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`(**pin 一律写精确版本号,不要写 `@alpha`**:rc.1 上线时 npm `alpha` 指的是 **0.1.7-alpha.2**,rc.1 走的是 `next`;`latest` 仍是 0.1.5-rc.3;peer 下限 `^0.1.7-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 0.1.7 适配要点(0.1.7-rc.1+ 基线)
|
||
|
||
**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-better-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-better-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` 是新值**);后台任务列表**没有替代字段**,改由插件新增的 `jobs.list` 路由读 `ctx.jobs.list(sessionId)`。同一版里 `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` 的同一道 workspace fence。
|
||
|
||
### 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-better-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` 通用兜底句。
|
||
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` 的「拒绝清单不得宽于宿主渲染器」双向边界)/ `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 工具门控)。
|