Files
apes-sidebar/AGENTS.md
T
Menghuan1918 ab2246da47 chore(release): cut v0.21.1 as the stable adapter for DSH 0.1.7-rc.1
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.
2026-09-23 23:58:59 +08:00

40 KiB
Raw Blame History

dsh-better-sidebar 仓库规则(AGENTS)

本文只含项目全局开发规则(面向贡献者与 agent)。 消费插件接入 API 全参考(ctx.betterSidebar 服务、TabDescriptor / FileViewerDescriptor 全字段、声明式设置、原生右侧栏承载面、皮肤契约等)→ docs/external-plugin-guide.md;逐特性设计史(含实施偏差记录)→ 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 本版的能力让出与收敛

  1. 只读文件预览交还宿主: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' 兜底渲染下载面板,功能不回归。
  2. 宿主 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 决定正文链接进侧栏还是新标签页。
  3. 外链接管收敛:删掉 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 链接点了没反应。
  4. 文件树新增实时刷新:插件接管了 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 引入,但这次一并修)

  1. 幽灵字段 .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 接口里,需结构化探测)。
  2. package.json.files 漏了 lib/client-locale.js:chunk 名在 8 处镜像,files 里只有 client-editor.js / client-mermaid.js,于是 locale chunk 从来没进过 npm tarball(社区 PR #739 覆盖同一问题)。已补。
  3. 测试 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。
  4. 测试期依赖闭包又长了一层: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 的实际输出为准。
  5. 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,改动必须同步该节与 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(唯一权威接入文档,不再双份维护)。
  • 文件/文件夹图标:回退链是「具体 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(消费插件开发者向;§0 原生栏承载面 / §4 Tab API / §5 FileViewer API / §7 服务方法 / §10 平台陷阱 / §11 已移除的自由窗口 / §12 皮肤契约 / §15 真实案例)。
  • 设计文档: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 工具门控)。