# dsh-session-titler:修复 0.2.1-alpha.x 挂载崩溃 + 版本自适应 > 本文件是 **2026-10-16 经子龙批准的方案正文原样存档**(批准即本文档 §1–§7)。 > 实施后的差异与实测结果见文末「事后补记」,该节是回填,不修改上面的批准正文。 ## 1. 目标与成功标准 **目标**:插件在 DSH `0.2.0-rc.2` 与 `0.2.1-alpha.2~alpha.4` 上都能装载并工作;被同一进程挂载两次时**不再**导致 entry 激活失败(不再进恢复页);并在运行时按宿主实际 API 自适应,同时把兼容范围显式声明出去。 **成功标准** 1. 同一进程里第二次 `apply(ctx)` 不再抛 `webserver: duplicate exact route "/session-titler/propose"`,日志里不再出现 `1 entry did not activate`。 2. 路由始终由**最新那次挂载**持有;旧 fiber 卸载不会误删新路由;HMR/profile patch 重载后路由仍可 POST。 3. 宿主只暴露 `llm.prepareCall` 而没有 `llm.stream` 时,走降级路径;两者都没有时报明确错误(`unsupported-host`),而不是崩栈。 4. 市场/插件页对当前 DSH 能给出**确定**的兼容结论(不再是 `undeclared/unknown`)。 5. `npm test` 全绿,新增用例覆盖上述 1~3。 ## 2. 根因(已在你的机器上核实) - 运行中的桌面版 = `2.0.18-next`,内置 DSH **0.2.1-alpha.2**;CLI `dsh` = **0.2.0-rc.2**。 - `~/.dsh/logs/desktop-next.log` 2026-10-16T06:40:46 起:`.dsh-market` 先 `hot-mount dsh-session-titler: live`,随后 06:40:48 出现 `webserver: duplicate exact route "/session-titler/propose"` → `1 entry did not activate` → `config reload … failed`;同秒还有 `client-modules: package dsh-session-titler resolves from multiple active Loader sources … remove one entry`。 - 即:同一 profile 里插件被**两个 loader 源同时挂载**(市场热挂载副本 + profile bundle 行 / HMR 重放 patch)。插件 `apply()` 不幂等:`ctx.webServer.register()` 在 4.0.4 与 4.0.5-alpha.1 两个 cordis 上**都**对重复 path 抛错(已对比 `dsh-host-webserver` 源码,两版 register 实现一致),第二次挂载直接把 entry 打挂。 - `~/.dsh/recovery/*` 在 14:18–14:41 的记录是 `before-plugin-disable` / `before-rollback` / `before-plugin-uninstall`(对象就是 `dsh-session-titler`),与上面同一条链。 - 插件用到的每个 API(`webServer.register`、`sessionQuery.readSurface/readSession`、`sessions.get/requestHeader/snapshotEvents`、`llm.stream/listProviders`、客户端 `slots/locale/sessions.using/binding.session.rename`、`__ModuleLoader__.load`)在 rc.2、alpha.2、alpha.4 之间**形状未变**(alpha.4 从 npm 取源码逐包 diff 过)——所以不是“API 被删”,是**挂载不幂等**。 - 兼容声明:`dsh-app-boot` 的 `evaluatePluginCompatibility()` 只读 `peerDependencies` 里的 `@deepseek-ai/dsh*`;市场(`dshmarket`)的 `manifestFacts()` 另读顶层 `engines.dsh`(或 `dsh.engines.dsh`)。本插件两者都没有 → 市场缓存事实 `{"version":"0.1.2","enginesDsh":null,"peerDependencies":{}}` → 结论 `unknown/undeclared`。 ## 3. 改动设计(按文件) ### 3.1 `index.js` —— 路由注册幂等化(核心修复) 新增模块内两个进程级标记,并在 `apply()` 里用它挂载路由: ```js const ROUTE_HANDLE = Symbol.for('dsh-session-titler/propose-route') const DUPLICATE_WARNED = Symbol.for('dsh-session-titler/duplicate-mount-warned') /** 是否本路由自身的“重复 path”拒绝。 */ function isOwnDuplicateRoute(error) { return error instanceof Error && /^webserver: duplicate (?:exact|prefix) route /u.test(error.message) && error.message.includes(`"${ROUTE_PATH}"`) } /** 挂载唯一的路由;同一进程内再次挂载时接管所有权,绝不抛给 entry。 */ function mountRoute(ctx, handler) { const prior = globalThis[ROUTE_HANDLE] if (typeof prior === 'function') { globalThis[ROUTE_HANDLE] = undefined try { prior() } catch { /* 已被释放 */ } if (globalThis[DUPLICATE_WARNED] !== true) { globalThis[DUPLICATE_WARNED] = true try { ctx.logger?.warn?.('session-titler: this plugin is mounted from more than one loader source; keeping one propose route. Remove one of the two installs (market copy / profile bundle row).') } catch { /* 日志失败不得影响挂载 */ } } } let dispose try { dispose = ctx.webServer.register({ kind: 'exact', path: ROUTE_PATH, handler }) } catch (error) { if (!isOwnDuplicateRoute(error)) throw error return () => {} // 别人的同名路由仍在服务;本次挂载不拥有它 } globalThis[ROUTE_HANDLE] = dispose return () => { // 只有仍归本次挂载所有时才删除;避免旧 fiber 的 disposer 把继任者的路由删掉。 if (globalThis[ROUTE_HANDLE] !== dispose) return globalThis[ROUTE_HANDLE] = undefined dispose() } } ``` `apply()` 只改一行:`ctx.effect(() => mountRoute(ctx, handler), 'session-titler: propose route')`。其余(`inject`、handler、fence、错误信封)不动。 要点/理由: - `globalThis[Symbol.for(...)]` 而不是模块级变量——两次挂载可能是**两个不同的模块实例**(file:// URL 不同),模块作用域共享不到。 - 必须“先释放旧的再注册”,因为 `register` 返回的 disposer 是 `() => table.delete(route.path)`(**按 path 删**,不是按身份),旧 fiber 的 disposer 稍后运行会误删新路由;所以返回的 disposer 加所有权判断。 - 兜底 `catch` 只为“本路由自身的重复”放行;其它错误照旧上抛,不掩盖真实问题。 - 只处理 host 路由;客户端那一半在双源下由 `dsh-client-modules` 拒绝(`duplicate graph entry` / `multiple active Loader sources`),插件侧无法救,见 §5 与 §7。 ### 3.2 `title.js` —— 能力探测与调用降级 把 `proposeTitle()` 里直接写死的 `ctx.llm.stream({...})` 换成一个小适配层: ```js /** * 按宿主实际暴露的入口发一次标题请求。 * 已验证 0.2.0-rc.2 / 0.2.1-alpha.2 / 0.2.1-alpha.4 都有 llm.stream, * 因此 stream 是主路径;prepareCall 仅为“将来 stream 被移除”兜底。 */ function streamTitle(ctx, options) { const llm = ctx.llm if (typeof llm?.stream === 'function') return llm.stream(options) if (typeof llm?.prepareCall === 'function') return preparedTitleStream(llm, options) throw new TitlerError('unsupported-host', 501, 'this DSH build exposes neither llm.stream nor llm.prepareCall, so no title can be requested') } /** 仅有 prepareCall 的宿主:按 0.2.1-alpha 的形态 (config, signal) 准备,再用它自己的 stream。 */ async function* preparedTitleStream(llm, options) { const { signal, ...rest } = options const call = await llm.prepareCall({ provider: rest.provider, model: rest.model, maxTokens: rest.maxTokens }, signal) for await (const chunk of call.stream({ ...rest, ...call.config })) yield chunk } ``` 同时把已存在的“探测式”写法补齐(这些现在都是 try/catch + 降级,保持): - `ctx.llm.listProviders()`:rc.2 与 alpha.2 均有且返回 `{id,…}[]`(已核对两版实现完全一致)→ 不改。 - `ctx.sessions?.get?.()` / `live.requestHeader?.()` / `live.snapshotEvents?.()` / `ctx.get('agentDefaultModel')?.currentSelection?.()` / `ctx.get('webRuntime')?.trustedHosts`:保持现有可选链 + try/catch。 - 失败映射保持:`NO_ADAPTER` → `no-adapter`;新增 `unsupported-host` 只有一种来源(上面那行)。 `proposeTitle()` 的循环改成 `for await (const chunk of streamTitle(ctx, {...}))`,其余(超时、`AbortSignal.any`、`finishFailure`、标题归一化)不动。 ### 3.3 `client.js` —— 缺失 API 的降级与文案 `confirm()` 里对 DSH 客户端 API 做存在性判断,避免抛进空处: ```js if (typeof sessions?.using !== 'function') { set({ phase: 'ready', error: t('unavailable') }); return } ... const result = await sessions.using(sessionId, { source: 'workspaceOperation' }, (reference) => { const rename = reference?.binding?.session?.rename if (typeof rename !== 'function') throw new Error(t('unavailable')) return rename(title) }) ``` `DICT.zh` / `DICT.en` 复用现有 `unavailable` 文案,不新增 key。其余 UI/槽位/样式不动。 ### 3.4 `package.json` —— 版本声明(用户已选 A:探测为主 + 补声明) - `version`: `0.1.2` → `0.2.0`。 - 顶层 `engines` 增加 `dsh`(市场读它给确定结论;DSH 自己的准入闸门**不读** `engines`,只读 peers): ```json "engines": { "node": ">=20.3", "dsh": ">=0.2.0-rc.1 <0.3.0-0" } ``` 该范围已用市场自己的 `satisfiesRange(..., {includePrerelease:true})` 实测:`0.2.0-rc.1/rc.2`、`0.2.1-alpha.2/3/4`、`0.2.1` → `true`;`0.3.0`、`0.1.5-alpha.1` → `false`。 - `dsh.compatibility` 按 cost-meter 的既有约定补上(页面/目录展示用): ```json "compatibility": { "dsh": ">=0.2.0-rc.1 <0.3.0-0", "dshReleases": { "0.2.0-rc.2": "compatible", "0.2.1-alpha.2": "compatible", "0.2.1-alpha.3": "unknown", "0.2.1-alpha.4": "unknown" } } ``` `0.2.1-alpha.3/alpha.4` 先用 `unknown`,本机实测通过后再改成 `compatible`(诚实标注,不靠猜)。 **明确不加 `peerDependencies`**:DSH 的 `evaluatePluginCompatibility()` 对 peer 不满足的处理是**deny 该 row / 跳过整个 bundle**(静默消失,比报错更糟),而 `engines.dsh` 只影响市场的“装前拒绝 + 展示”,语义正是我们要的。若将来需要 DSH 硬闸门再单独评估。 ## 4. 测试(`node --test`,不联网) 新增 `test/host.test.js`;`test/title.test.js` 增补 2 例。全部用现有 `context()` 风格的假 ctx。 1. **重复挂载不抛**:假 `webServer.register` 对同 path 二次注册抛 `webserver: duplicate exact route "/session-titler/propose"`;同一 ctx 连续 `apply()` 两次 → 不抛,`register` 调用 2 次,注册表里恰好 1 条路由,且 handler 是第二次那个。 2. **旧 fiber 不误删新路由**:mount A → mount B → 运行 A 的 disposer → 路由仍在且指向 B 的 handler。 3. **只管自己的重复**:`register` 抛无关错误(如 `fallback already registered`)时必须上抛。 4. **降级路径**:`llm` 只有 `prepareCall`(记录入参,返回 `{ config, stream }`)时,`proposeTitle` 仍产出标题,且 `prepareCall` 收到 `{provider, model, maxTokens}` 形态。 5. **都没有时报错**:`llm` 既无 `stream` 也无 `prepareCall` → `TitlerError`,`code === 'unsupported-host'`、`status === 501`。 6. 回归:现有 6 例(路由候选/adapter 校验/NO_ADAPTER/no-content)保持通过。 ## 5. 文档与发版 按你的存档规则(`docs/PLAN-<日期>.md` 原样存档 + 发版清单 + README 索引挂链): - 新增 `docs/PLAN-2026-10-16.md`:本计划正文**原样**存档;实施后差异另起一节并标明是事后补记。 - 新增 `docs/RELEASE-CHECKLIST-0.2.0.md`:打勾清单(改动文件、测试、两种 profile 的实测记录、声明范围复核、npm 发布步骤)。 - `README.md`: - 新增「支持的 DSH 版本与兼容声明」小节:`0.2.0-rc.2` / `0.2.1-alpha.2~alpha.4`,说明 `engines.dsh` + `dsh.compatibility` 的含义,以及市场里出现“不兼容/已停止安装”时用 `dsh plugin allow-version` 或插件页授权 exact-version 豁免。 - 「已知限制」补一条:**同一 profile 里不要同时保留两个安装源**(市场热挂载副本 + profile bundle 行)。双源时插件 Host 半已能存活,但 DSH 的 client loader 会拒绝该包(`client-modules: … multiple active Loader sources`),UI 入口不会出现——这一步必须靠清理安装源解决。 - 末尾「设计与发布文档」索引挂上两个新文档。 - `docs/01-plugin-design-and-implementation.md`:追加「事后补记:0.2.1-alpha.x 挂载幂等化」一节,记录根因证据链、幂等契约(所有权 + 释放顺序)、以及 §2 里的版本 diff 结论。 - 发版动作(`npm publish`、市场上架更新、`dsh plugin` 升级)**不在本次自动执行**,清单写好后再单独向你确认。 ## 6. 验收标准(怎么做) 1. `npm test` 全绿(含新增 6 类用例)。 2. **单源正常态**(`desktop` profile,`link:` 本仓库):重启 DSH → 日志无 `duplicate exact route`、无 `1 entry did not activate`;顶栏「✦ 生成标题」出弹窗、可编辑、确认后左侧标题更新且刷新仍在。 3. **故意制造双源**(复现 06:40 那条路径:`main` profile 里让市场把插件重新启用/热挂载,同时它在 `dsh.profile.bundles` 里):日志应只有一条 `mounted from more than one loader source` 警告,**没有** `duplicate exact route`、**没有** `1 entry did not activate`、不进入恢复页。 4. 路由存活:对该端口 `curl -sS -X POST -H 'Host: 127.0.0.1:' -H 'content-type: application/json' -d '{"sessionId":"<真实会话id>"}' http://127.0.0.1:/session-titler/propose` 返回 `{"ok":true,...}`;再改动 profile 的 `cordis.patch.yml` 触发一次 live patch reload,重复 curl 仍 200。 5. 兼容结论:重开插件页后,该插件显示为**确定**兼容(不再是“未声明”);`~/.dsh/profiles/

/.dsh-market/discovery-compatibility-v1.json` 里 `dsh-session-titler` 的 `enginesDsh` 应为 `">=0.2.0-rc.1 <0.3.0-0"`。 6. 0.2.0-rc.2 回归:用 CLI(`dsh --profile web` 之类)跑一次同样的生成标题,确认没退化。 ## 7. 假设、边界与明确不做 - **假设**:崩溃就是 §2 的重复挂载(你已确认现象 A,且日志/恢复快照一致)。 - **alpha.3 / alpha.4 无法在本机实跑**(本机只有 alpha.2 内置 + rc.2 CLI);alpha.4 只做了 npm 源码 diff(`dsh-llm`、`dsh-host-webserver`、`dsh-session-query`、`dsh-session` 对插件可见的 API 未变)。因此声明里先写 `unknown`,你实测后再改。 - **客户端半在双源下救不了**:`dsh-client-modules` 按包名拒绝重复源,这是环境问题(清理掉多余安装源即可),插件侧只做降级文案,不试图 hack loader。 - **不做**:不加 `peerDependencies`(会开启 DSH 硬闸门,超出范围时整个 bundle 被静默跳过);不改 `fence.js` 的信任规则;不改槽位/样式;不引入对新 DSH 内部包的 import(保持插件零 harness 依赖);不做自动标题。 - **风险与回滚**:改动集中在 `index.js` 的 `mountRoute` + `title.js` 的 `streamTitle`,若出问题,删掉这两处即可退回“单源可用、双源崩溃”的原状;`engines.dsh` 是纯声明,删除即回退为“未声明”。 --- ## 事后补记(实施后回填,2026-10-16) > 本节写于实施完成之后,用于记录与批准正文的差异、以及实测结果;上面正文不修改。 ### B1 与批准的差异 | 项 | 批准正文 | 实际实施 | 原因 | |---|---|---|---| | `proposeTitle` 的 catch 分支 | 未提 | 增加 `if (error instanceof TitlerError) throw error` | 否则 `streamTitle` 抛出的 `unsupported-host` 会被下面的 `llm-error` 兜底吞掉并改写,验收标准 3 无法满足 | | `preparedTitleStream` 形态 | 写 `(config, signal)` | 同(只做 0.2.1-alpha 的对象形态) | 旧的三参形态 `(provider, model, signal)` 只存在于 0.2.0-rc.2,而该版本有 `llm.stream`,该分支不可达;将来真需要时跟随当时最新形态 | | `dsh.compatibility.dshReleases` | 只列 rc.2 / alpha.2~4 | 额外列 `0.2.0-rc.1: "unknown"` | 让 0.2.0 线内的版本都有明确标注,避免“未知版本被默认当成兼容” | | `test/host.test.js` 用例数 | 计划列 6 类 | 实际 6 条,另含 1 条“双挂载后路由仍能返回 200 提案”的集成用例 | 幂等性必须证明**服务仍然可用**,只证明“不抛”不够 | ### B2 实施结果(本机) - 改动文件:`index.js`(`mountRoute` + 所有权交接)、`title.js`(`streamTitle` / `preparedTitleStream` + `TitlerError` 透传)、`client.js`(`sessions.using` / `binding.session.rename` 存在性判断)、`package.json`(`0.2.0` + `engines.dsh` + `dsh.compatibility`)、`test/host.test.js`(新增)、`test/title.test.js`(+2 例)。 - `npm test`:**19 条全绿**(原有 11 条 + `title.test.js` 新增 2 条能力探测 + `test/host.test.js` 6 条挂载幂等)。 - `node --check`:`index.js` / `title.js` / `fence.js` / `client.js` / 两个测试文件全部通过。 - 声明范围复核:用市场自带 `satisfiesRange`(`includePrerelease: true`)实测 `>=0.2.0-rc.1 <0.3.0-0`,`0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-alpha.2/3/4`、`0.2.1` → `true`;`0.3.0`、`0.1.5-alpha.1` → `false`。 - 声明生效复核:用市场自带的 `manifestFacts` + `deriveHostCompatibility` 直接跑新旧 `package.json`——旧 `0.1.2` 在 `0.2.1-alpha.2` 上是 `unknown / undeclared`,新 `0.2.0` 是 `compatible / manifest`(`requirement = ">=0.2.0-rc.1 <0.3.0-0"`),`0.3.0` 上 `incompatible`。即“市场说未声明”在清单层面已消除。 ### B3 待办 / 未验证项 1. **0.2.1-alpha.2 实机验收(§6 的 2/3/4/5)尚未执行**:本机运行的桌面版进程用 `main` profile,且该 profile 里插件已被卸载;实机验证需要把插件重新装/启用并**重启 DSH**,属于会影响当前会话的重要操作,已单独向子龙请示。 2. `0.2.1-alpha.3` / `alpha.4` 本机无法运行(只有 alpha.2 内置 + rc.2 CLI),声明里先保持 `unknown`,待实测后回填为 `compatible` 或 `incompatible`。 3. 市场页显示为“确定兼容”需要在重启后等 `discovery-compatibility-v1.json` 的 24h TTL 刷新(或删除该缓存文件强制重取)——属于验收步骤 5,未执行。