feat: adapt to DSH 0.1.7-alpha.1 and yield the read-only previews

DSH 0.1.7-alpha.1 is 1299 commits past 0.1.6-alpha.2 and rewrites four
contracts this plugin was built on. The peer floor moves to
^0.1.7-alpha.1 (semver's prerelease rule means ^0.1.6-alpha.2 can never
match a 0.1.7 prerelease); 0.1.6-alpha.2 and earlier stay on v0.19.1.
Released as 0.21.0-alpha.1 (dist-tag alpha).

Hard breaks:
- dsh-settings was rewritten: register(ns, schema) is gone, a form is
  addressed by the plugin row's Loader entry id and read from the
  module's exported Config. The preferences are merged into Config and
  marked volatile — which requires DSH's own schemastery, because only
  that build wraps a volatile field in the cosmokit reference the
  loader's volatile commit walks. With the public package a settings
  write reports success and revision 1 while the effective value never
  changes.
- ui-primitives renamed its whole icon family (IconXxx14/16 ->
  IconXxxRegular/Medium): 26 imported symbols, 17 files. The failing
  mode is render-time, not load-time. Five call sites that relied on the
  old 14px default now pass size={14} explicitly.
- Session format v4 rejects source.kind === 'plugin'; the side-chat
  boundary injection now writes plugin:dsh-better-sidebar, the same kind
  DSH's own v3->v4 migration produces, so historical threads survive.
  Tool results moved to role 'tool' with top-level content/isError; all
  four parsers accept both shapes.
- The client session snapshot dropped subagentsByParent/jobsBySession
  and the on-demand subagent-catalog observation API. The Tasks page
  reads projectionsBySession and a new jobs.list route.

Yields and convergence:
- The read-only previews go to DSH's documentpreview (Office->PDF, a
  spreadsheet worker, zoom, per-directory refresh); editor.canOpen
  refuses those 26 extensions. Markdown, HTML (with its sandbox
  switches) and the editable code buffer stay.
- The host browser kind is desktop-profile-only now, so the web e2e
  lanes expect no browser entry and assert its absence.
- Link takeover claims only urlTarget links and passes the rest to the
  host, fixing a regression from the 0.1.6 round: the fallback pointed
  at a 'browser' type the plugin no longer registers, so http links in
  its own markdown did nothing.

Also fixed, found along the way:
- The file tree refreshes live (new directory watch over the plugin's
  own socket), since the plugin owns the `files` kind.
- ctx.sessions.list has no `current` field at either tag; the three
  readers were dead. They now use ctx.sidebarRight.mounted.
- package.json.files was missing lib/client-locale.js.
- Two genuinely dead call sites: sessions.openSubagent/open/
  refreshSubagents do not exist on the current baseline, so the
  topology click, the jump-back and the retry were all no-ops.
- The test harness window shim lacked addEventListener, which the 0.1.7
  primitives call at module-evaluation time; and the e2e token exchange
  needs Playwright's request stack, not Node's fetch.
This commit is contained in:
Menghuan1918
2026-09-22 17:09:08 +08:00
parent de99fd4cfb
commit 6586e3e2d3
96 changed files with 4567 additions and 2080 deletions
@@ -0,0 +1,129 @@
# DSH 0.1.7-alpha.1 适配记录
> 适配对象:`dsh-better-sidebar` v0.20.0 开发线(支持 DSH 0.1.6-alpha.2)→ **v0.21.0-alpha.1(仅支持 DSH 0.1.7-alpha.1+)**。
> 基线标签:`dsh-v0.1.6-alpha.2`(`ddefc45fbc`)→ `dsh-v0.1.7-alpha.1`(`c36a83ff6b`),相隔 **1299 个 commit / 4754 个文件变更**。
> 本文记录**为什么这么改**与**改完的证据**;规则性结论沉淀在仓库根 `AGENTS.md` §3。
## 1. 怎么做的(方法与边界)
先用一次 8 路 workflow 侦察把 1299 个 commit 收敛到「与本插件相交的 surface」:sidebar-right 契约、ui-chat 槽、ui-primitives 导出面、boot/plugin-manager、宿主内置侧栏能力盘点、peer 依赖图、会话格式与 sidechat 依赖面、插件触点全量清单;随后一个独立的交叉验证子代理逐条复核,**证伪了 8 条**(例如「ui-primitives 只需补 2 个 devDep」被推翻为 4 个,「聊天正文里的 http 链接什么都不开」被推翻为会回落 `window.open`)。
三条纪律贯穿全程:
1. **结论必须带证据**:每条破坏都给出 `git show <tag>:<path>` / 实测命令与行号,不采信记忆。
2. **区分「硬报错」与「静默失效」**:后者的代价高得多——本版一共 6 处静默失效(见 §3)。
3. **不采信子代理报告**:所有关键结论由主代理独立复跑命令核对后才进入实现。
**没有做的事**:不改 DSH 源码;不写 0.1.6/0.1.7 双基线兼容层;不接入 `ctx.documentPreviews`;不恢复终端。
## 2. 硬破坏与适配(不修就崩)
### 2.1 `dsh-settings` 整体重写 —— 本版最大的一处
0.1.6 的 `SettingsProvider.register(ns, schema)` 给出 `{get, watch, update, replace}`;0.1.7 只剩 `SettingsForms` 的 `describe/update/replace/mutate/configure`,且**表单按插件 Loader 行的 `entry.options.id` 寻址**,schema 取自 `entry.fiber.runtime.Config`。
后果链条(全部实测):`register` 不存在 → 插件 `ctx.inject(['settings'])` 回调体内 `TypeError` → **被 cordis 吞掉** → `settingsFace` 恒 `undefined` → 侧边卡片设置页回落到 schema 默认值、`agentOpenTools` 永远打不开、`aionui-panel` 互斥失效。
适配:
- `src/config.ts` 把用户偏好**并入导出的 `Config`**(原先只是 4 个部署限额),每个偏好字段标为 volatile,并且 **schemastery 必须换成 DSH 自己的 `@deepseek-ai/schemastery`**。这一点是计划里标记为「未验证」的风险,真机上被证实为**必须**:`meta.volatile` 只是标记,DSH 的构建额外在解析期把 volatile 字段包成 cosmokit `Volatile` 引用,而 Loader 的 `_commitVolatile` 正是提交那些引用。用公开包时 `settings.update` 返回 200、`revision` 也递增,**但有效值永远不变**——设置写入被静默丢弃(`--dsh-e2e-marker` 为空、title-bar 方案不生效就是它的症状)。另有第二个坑:`.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` 之类)下静默失配。
- `viewOf()` = `describe({redactSecrets:true}).find(d => d.ns === ns)`;写入 `update(ns, patch, expectedRevision)`(`SettingsConflictError` 仍导出、构造签名未变,409 分支保留)。
- `configure({ auto: false }, ctx.fiber)` 关掉 DSH 的自动表单,避免同一批字段渲染两遍。
- **一次性 legacy 导入**(见 §2.2)。
- **放弃 `settings/document-updated` 监听**:cordis 的事件只向上冒泡到该 ctx 的祖先,而 settings 服务是在它自己的 context 上 `emit`,插件的 fiber 是兄弟,监听必然不触发。改用「客户端每次重新读取表单时重新求值」覆盖同样的触发点。
### 2.2 用户设置会静默丢失 —— 已定位并修复
0.1.7 删除了 file-backed settings provider。迁移时它把 `$DSH_HOME/settings.yaml` **先改名成 `.imported`**,再逐段导入,导入键是「同名 section → 同名 entry id」。插件的 section 键是**包名** `dsh-better-sidebar`,而它的行 id 是 `better-sidebar`,`LEGACY_SECTION_ENTRIES` 里也没有这个例外——**导入必然 warn 失败,section 只留在被改名的文件里**。
这不是理论问题:本机 `~/.dsh/settings.yaml`(63 KB)里就有一份真实的 `dsh-better-sidebar` 段,含 `tabsEnabled`(关掉了三个第三方 tab)、`viewersEnabled`、`titleBarScheme`、`titleBarStripPx`、`editorExplorer` 等。
修复:插件自己在 `ctx.inject(['settings','profileContext'])` 里读 `join(profileContext.home, 'settings.yaml.imported')`(退化再读 `settings.yaml`),取出该段,**按当前 `Config` 声明的字段过滤**(段里还留着 `terminalFontSize` / `browserInterceptHttp` / `defaultWidthPercent` 等已删字段,原样转发会让整个 patch 校验失败),再 `update(entryId, section)`。**只在行的用户层为空时执行**,所以幂等,且永不覆盖升级后的新值。
### 2.3 `ui-primitives` 图标整族改名
`Icon<Name><14|16>` → `Icon<Name>Regular`(1px)/ `Icon<Name>Medium`(1.3px)。具名导出 81 → 184,**旧尺寸后缀名一个不剩**。插件 26 个具名导入消失、17 个文件受影响、107 处替换。
两处容易踩空的地方:
1. **不能全局替换**:`src/client/icons.tsx` 里有 13 个**插件自绘**的同风格 glyph(`IconImageOutline16` / `IconVscode16` …)。改名集合是从「`import ... from '@deepseek-ai/dsh-client-ui-primitives'` 的具名列表 ∩ 已不存在于该包」算出来的,机械替换 26 个符号,13 个自绘 glyph 原样保留。
2. **失败模式是 render 期**:插件 client bundle 是 CJS + external,产物是裸属性读,缺导出 = `undefined` → React 抛 `Element type is invalid`,不是 ESM link error;而 `pnpm typecheck` 会直接报 `no exported member`,所以**类型检查是这一层唯一的守护**(此前测试对图标名零断言,本次补了 `tests/primitives-exports.spec.ts`)。
**静默尺寸回归**:0.1.6 的 `IconXxx14` 默认 `size = 14`,0.1.7 的 `XxxRegular` 一律默认 16。5 处原先依赖默认值的调用点(`TabBar.tsx` / `Sidebar.tsx` / `FileTree.tsx` 的 `IconCloseFillRegular`、`SubagentView.tsx` 两处 `IconRefreshOutlineRegular`)已显式传 `size={14}`,否则会静默变大 2px。另有 `StateDot` 的 ongoing 态被整图重画(10px 像素矩阵 → 14px 圆环 spinner,默认尺寸 10 → 14),插件选择跟随宿主观感。
### 2.4 会话格式 v3 → v4
**`source.kind === 'plugin'` 被硬拒**(`format v4 message requires a producer-owned source kind`),而插件的 sidechat 边界注入正是那个形状。改用 **`plugin:dsh-better-sidebar`**——这不是随手取的字符串:DSH 自己的 v3→v4 迁移对未知插件名产出的正是 `` `plugin:${plugin}` ``,用同一个值意味着**新写的数据与迁移后的历史数据形状统一**,`assertV4MessageSources` 通过,历史 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'` 命中,在新形状下会 continue 到底、恒返回空串——**工具输出、jobs 回放、转录、文件变动四处一起静默空白**。现在四处都同时接受新旧形状(旧分支必须保留:历史日志里就是旧形状)。
### 2.5 客户端会话快照换字段
`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?}`)。旧 catalog 条目带的 `activity` / `hasChildren` 不在新投影里:`hasChildren` 由「该子会话自己的投影是否有非空 catalog」推导,`activity` 由已有的 live 通道(`subagents.live`)提供。`mode: 'unknown'` 是新值,凡穷举 mode 处都要容纳。
- 后台任务列表**没有替代字段**,新增插件路由 `jobs.list`(读 `ctx.jobs.list(sessionId)`)。这比旧的推送镜像更权威:镜像是 last-wins 的帧折叠,注册表是状态本身。
- 同一版里 `ctx.jobs.list/get/read/kill` 的 caller 从 `Agent` 变成 `SessionId`,插件因此可以**彻底不碰 `ctx.agents`** 来调注册表(`src/jobs-routes.ts` 删掉了整个 `agents` 查找)。
### 2.6 依赖与测试 harness
- peer 下限 `^0.1.6-alpha.2` → `^0.1.7-alpha.1`(semver 预发布规则:前者数学上不含任何 `0.1.7-*`);`@deepseek-ai/cordis` peer `^4.0.2` → `^4.0.3`。
- devDependencies 补 **4 个**:`@deepseek-ai/dsh-client-store`、`@deepseek-ai/dsh-util-workspace-path`(`ui-primitives@0.1.7` 产物新裸 import 的两个包)、以及**这两个包自己**裸 import 的 `zustand@~4.4.7` 与 `immer@^10.1.1`(它们同样不声明 `dependencies`)。只补前两个,vitest 一加载就 `Cannot find package 'zustand'`。反过来**没有**预加 `dsh-workspace` / `dsh-agent-preset-registry`:实测 `dsh-subagent@0.1.7` 的 28 个 `lib/*.js` 从不 import 它们。
- `tests/browser-globals.ts` 的假 `window` 补 `addEventListener` / `removeEventListener` / `dispatchEvent` / `matchMedia`。0.1.7 的 `ui-primitives` 在**模块求值期**就 `window.addEventListener('scroll'|'resize'|'blur')`,缺它会让**任何 import `service.ts` 的 spec 一个用例都跑不起来**(`TypeError: window.addEventListener is not a function`)——用 HEAD 干净 worktree 复现过,确认不是本次删除造成的。
- `package.json.files` 补 `lib/client-locale.js`(chunk 名在 8 处镜像,`files` 一直漏这一个,locale chunk 从来没进过 npm tarball)。
## 3. 静默失效清单(本版的真实风险面)
| # | 症状 | 根因 | 守护 |
|---|---|---|---|
| 1 | 侧边卡片设置页只剩默认值、`agentOpenTools` 打不开 | `settings.register` 抛错被 cordis 吞 | `smoke.spec.ts` 的 settings 段 + `configure({auto:false})` |
| 2 | 用户既有设置全部丢失 | legacy 段键名与行 id 不一致,导入静默 warn 失败 | 新增两条 legacy 迁移用例(含「已有用户值时不动」) |
| 3 | 工具输出 / jobs 回放 / 转录 / 文件变动四处空白 | `type === 'tool-result'` 在新形状下不再命中 | 4 个解析器各有真实生产者 round-trip 用例 |
| 4 | 任务管理页空列表 | `subagentsByParent` / `jobsBySession` 被删且读处全带 `?.` | 新 spec 断言从投影推导拓扑 + jobs 基线 |
| 5 | 5 处图标静默变大 2px | 图标默认尺寸 14 → 16 | 显式 `size={14}` |
| 6 | 插件自绘 markdown 里 http 链接点了没反应 | 上一版交还浏览器时兜底仍指向已不存在的 `'browser'` 类型 | `link-intercept.spec.ts` 新增 12 例 |
| 7 | 文件树一直陈旧 | 插件接管 `files` kind,宿主自己的 watch 覆盖不到 | `fs-watch.spec.ts` 5 例 |
第 6 条是**上一轮自己引入的回归**,本次一并修掉:`openInSidebar` 只认领声明了 `urlTarget` 的链接,其余一律放行给宿主(DSH 0.1.7 起目的地由用户设置 `linkOpening` + 宿主决定),认领成功但目标不可用时 `window.open(..., 'noopener,noreferrer')` 兜底。
## 4. 取舍(用户拍板,逐条记录)
| 议题 | 选择 | 代价 |
|---|---|---|
| 支持线 | 只支持 `^0.1.7-alpha.1`,不写兼容层 | 0.1.6 及更早用户停在 v0.19.1 |
| 发版 | `0.21.0-alpha.1`,dist-tag `alpha` | npm `latest` 仍是 0.19.1,新用户默认装不到 |
| 设置架构 | 迁到 entry-config 模型 **+** 自己做 legacy 导入 | 约 60 行新增,换回「用户设置不丢」 |
| 只读预览 | 让出 image / pdf / 电子表格 / Office 给内置 | 插件设置页少 3 行 viewer 开关;换来宿主的 Office→PDF、表格 worker、缩放 |
| 文件树 | 继续接管 **+** 本次实现目录 watch | 新增宿主 WS 路由 + 客户端订阅(约 300 行) |
| 外链 | 收敛为「只认领 urlTarget」 | 删掉 3 个偏好键(含 20 份词典 key) |
| 视觉 | 尺寸钉回 14,`StateDot` 跟随宿主新观感 | 与宿主其他进行中状态一致,少一处硬编码 |
**保留在插件侧的三件**(内置做不了的):Markdown 自带渲染器、HTML 沙箱预览(`htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` 宿主没有)、以及 `code` catch-all 的**可编辑 + 保存**能力。未知二进制(`.zip` / `.wasm`)仍走 `code` 认领 → 宿主 fsRead 判 binary → head 重匹配无 sniffer → `EditorHost.tsx` 的 `binary` 兜底渲染下载面板,**功能不回归**(`builtins.spec.ts` 用 `planFsReadOutcome` 把这条链钉住)。
## 5. 落地偏差(与计划的差异,逐条说明)
1. **`MessageSourceMap` 模块增强是计划外的必需品**。计划只写了「改 kind 值」,实测仅改值会留下 `TS2322`:0.1.7 把消息 source 改成合并可扩展的封闭联合,没有共享 catch-all。按上游 `dsh-time-context` / `dsh-tmux-context` 的同款接缝在 `src/sidechat-routes.ts` 里加了 `declare module '@deepseek-ai/dsh-llm'`。
2. **图标改名范围比侦察报告小**:报告说 39 个值符号里 26 个消失,实际要做改名的是 26 个(`IconProps` 是类型,13 个是插件自绘 glyph)。
3. **`ui-primitives` 的裸 import 闭包比报告深一层**:报告给的是 2 个 `@deepseek-ai` 包,实际还要补 `zustand` / `immer`(第二层)。这是交叉验证阶段证伪的 8 条之一。
4. **`setSubagentCatalogOpen` 是「删除」而不是「替代」**:计划预期有替代 API,实际 0.1.7 改成全量加载投影,那套机制整体不需要了。
5. **`jobs.list` 是计划外的新路由**:计划里只写了「改数据源」,实际旧的 jobs 镜像在 0.1.7 没有任何替代,只能自己开路由。
6. **`exchangeLaunchCookie` 换成 Playwright request context**:计划里列为「已知 e2e 阻塞」,落地时确认宿主静态 fallback 对纯 Node 的 GET 回 **空体 405 且无 `Set-Cookie`**(0.1.5-rc.2 与 0.1.7-alpha.1 都复现),改用 `request.newContext()` + `maxRedirects: 0`。
7. **schemastery 必须换成 DSH 的构建(计划里标为「未验证」的风险,真机证实为必须)**:原计划认为「宿主只鸭子判定 `meta.volatile`,不必换依赖」。真机上 `settings.update` 返回 200、`revision` 递增,但 `describe().value` 仍是旧值——写入被静默丢弃。根因在 DSH 的 schemastery 构建:它在解析期把 volatile 字段包成 cosmokit `Volatile` 引用,Loader 的 `_commitVolatile` 提交的正是那些引用;公开的 `schemastery` 包只设标记、产普通值,于是「提交」无事可做。附带第二个坑:`.volatile()` 经 `extra()` 返回副本,写成 `for (const f of ...) f.volatile()` 会丢掉标记,宿主随即报 `Plugin entry "better-sidebar" has no volatile fields`。
8. **`tests/browser-globals.ts` 的修复不在原计划里**:这是「跑起来才发现」的阻塞,由执行子代理用 HEAD 干净 worktree 复现归因后修的。
9. **README/AGENTS 里「v0.20.0 已是 npm latest」的表述是错的**:实测 npm `latest` 是 `0.19.1`,0.20.0 从未发布。文档已按事实改写。
## 6. 验证证据
**门禁**(见本仓库 CI:`plugin-mount` 用真实 0.1.7-alpha.1 scratch profile 跑 Playwright 无头渲染):
- `pnpm peers check` 干净;`pnpm typecheck` / `pnpm lint` / `pnpm check:consumer-types` 通过。
- `pnpm test` 全绿(含本次新增的 `fs-watch.spec.ts`、`primitives-exports.spec.ts`,以及 `smoke.spec.ts` 的两条 legacy 迁移用例)。
- `pnpm build` 只产出 `client-editor.js` / `client-mermaid.js` / `client-locale.js`。
- `pnpm test:mount` 在真实 `@deepseek-ai/dsh@0.1.7-alpha.1` 上通过。
**子代理的三处反证**(比正向通过更有说服力):
1. tool 结果解析:把新形状分支改回旧行为 → **16 failed / 88 passed**,4 条真实生产者 round-trip 全挂、jobs 回放 5 条挂;恢复后 104 全绿。
2. 让出的预览格式:`builtins.spec.ts` 用 26 个扩展名逐条断言 `editor` **不**认领,同时断言 `md` / `html` / 代码扩展名**仍然**认领。
3. 链接接管:新增用例断言「未命中 `urlTarget` 时 `defaultPrevented === false` 且不调用任何打开函数」,以及「命中但被禁用时走 `window.open` 兜底」。