polan-prologue/dsh-experience-plus

dsh-experience-plus

DeepSeek Harness 综合体验插件:会话菜单 4 组重排 + 8 项会话管理、批量管理、桌面「重启」入口、插件升级卡片(版本检查 / 项目地址 / Star)

Uncategorizedcordisdeepseek-harnessdsh

体验增强++(dsh-experience-plus)

一个综合性体验插件,把 DSH 的会话管理补齐到「右键即用」,并给桌面端的托盘菜单与应用菜单补上「重启」入口。

  • 需求一:会话「…」菜单按 4 组重排(共 12 项,其中 8 项由本插件新建)。
  • 需求二:任务栏托盘图标右键菜单、应用菜单各增加「重启」。
  • 附带:「设置 → 体验优化 → 插件」里的插件升级卡片(版本状态、检查更新、项目地址、Star 入口)。

项目地址:https://github.com/polan-prologue/dsh-experience-plus · 许可证:MIT · DSH 兼容:见「三之三、跨 DSH 版本兼容契约」


快速开始

# 从 GitHub 安装(推荐:锁定 tag,升级由你决定)
dsh plugin --profile desktop add github:polan-prologue/dsh-experience-plus#v0.1.0

# 从本地目录安装(二次开发时用这个)
dsh plugin --profile desktop add link:/path/to/dsh-experience-plus

# 卸载
dsh plugin --profile desktop remove dsh-experience-plus

--profile 换成你自己的 profile 名(桌面版通常是 desktop,纯 Web 版是 web)。 装完重启 DSH;宿主半侧的改动(路由、配置字段)不会热重载。

这个插件会碰你的磁盘吗? 只有两处,且都默认关闭或显式触发:

动作默认说明
桌面「重启」补丁(改写 app.asar)off = 只检测、不写盘想启用要在设置页点「立即应用补丁」或跑 tools/应用桌面补丁.cmd
自动检查更新(出网请求)0 = 关闭开启后每次启动查一次、之后按间隔复查;只读仓库 tag,不上传任何本机信息

其余功能(会话菜单、批量管理、未读圆点、自动作答)都只动会话与插件自身配置。


一、会话菜单:4 组 12 项

排序靠 order 与内核自带的 4 行交错实现,分隔线落在本插件自己的行上(separatorBefore), 因此不需要遮蔽任何原生行——原生行为、快捷键、对话框全部保持原样。

组菜单项order来源图标
1置顶对话100内核IconPinOutlineRegular
重命名200内核IconEditOutlineRegular
复制会话 ID250本插件IconCopyOutlineRegular
标记为未读 / 已读260本插件IconQueueOutlineRegular
在新对话中继续300内核IconBranchOutlineRegular
2分隔条350本插件—(见下)
归档对话400内核IconArchiveOutlineRegular
批量管理450本插件IconChecklistOutlineRegular
3在文件资源管理器中显示500本插件IconFolderOpenOutlineRegular
复制工作目录510本插件IconFolderCloseRegular
导出对话记录520本插件IconDownloadOutlineRegular
4删除对话600本插件IconTrashOutlineRegular(danger 红字)

与需求原文的一处差异:需求第 1 组写作「重命名、置顶对话」、第 4 组写作「删除对话、归档对话」。 实际实现按内核原生顺序(置顶在前、重命名在后)与截图(归档对话只出现一次,位于第 2 组)落地。 理由:置顶/重命名是内核注册的行,调换它们需要遮蔽原生行并重新实现其行为(含重命名对话框), 收益只是顺序,风险却很高。如果你确实要「重命名在前」,告诉我,我再用遮蔽方案做。

右键打开菜单(本插件补齐的能力)

内核没有会话右键菜单——dsh-client-ui-workspace 里 onContextMenu 出现 0 次, 那张菜单只有行尾的「…」按钮能打开。本插件用纯插件侧的桥把它补上:

  • 在 document 上监听 contextmenu;
  • 右键落在带 data-row-key="session:<id>" 的行内时,替用户点一次该行的「…」按钮, 菜单就按原生方式锚定展开(preventDefault,不弹浏览器菜单);
  • 锚点用内核自己写下的 data-row-key(非哈希属性,比 CSS Module 的 class 稳定得多);
  • 文本输入区域内的右键不拦截,保留浏览器原生菜单。

所以现在「右键会话行」和「点行尾 …」两条路都能打开同一张菜单。

行级错误边界(防止一个插件拖垮整张菜单)

本插件的行与内核自带的行共用同一张菜单,任何一行渲染抛错都会让整张菜单打不开。 因此每一行都包了 RowBoundary:出错只让那一行消失并记一条 console,绝不连累原生行。 同理,所有 standard props(useSessions、useMenuOpenState)都做了存在性判断, 缺失时退化为「该项不可用」而不是「菜单打不开」。

8 项新建能力的实现

能力落地方式
复制会话 ID浏览器侧写剪贴板(async clipboard API,失败退回 execCommand)
标记为未读插件自持状态(localStorage)+ 行首圆点 + 菜单项在「未读/已读」间切换
移动至项目已按用户要求移除。原因是内核根本无法把已有会话移到另一个项目(工作区成员关系 = 账目 ∧ 会话 header 里固化的 cwd,而 cwd 没有改写接口)。它占的 order 350 现在由一条纯分隔条接手——否则第 2 组的首项会变成内核自带的「归档对话」,而原生行不接受 separatorBefore,分组边界会消失。
批量管理进入批量模式:会话行首出现复选框,左栏下角(登录名上方)出现内联操作条——「全选 / 已选 N 项 / 取消」+「归档 / 删除」,形态对齐参考截图
在文件资源管理器中显示复用内核能力 sessionController.openWorkspacePath({ path })——官方带路径校验与桌面可用性判断。⚠️ 不能传 action: 'reveal':那是「在父目录里选中它」(explorer /select,<path>),会让用户看到上级目录;省略 action 才是打开目录本身
复制工作目录浏览器侧从会话摘要读 cwd 后写剪贴板
导出对话记录复用内核已有的 GET api/session.export?sessionId=…&includeDescendants=true 下载 ZIP
删除对话宿主侧文件操作,默认软删除到 ~/.dsh/session-trash/,见下面

二、需求二:两处「重启」

为什么必须打补丁

托盘菜单(new Tray() + setContextMenu)与应用菜单(Windows 上由主进程 Menu.buildFromTemplate(...).popup() 弹的原生菜单) 都由 Electron 主进程构建,而 DSH 插件跑在独立的宿主子进程(dsh-desktop-host,由主进程 fork)里:

  • 插件够不到 Electron 的 Tray / Menu;
  • 主进程也没有任何菜单扩展点(lib/main.js 里全是硬编码模板)。

所以两处「重启」只能注入 resources/app.asar 内的 lib/main.js。这是唯一的路径,没有替代方案。

怎么注入(不重打包 121 MB 的 asar)

关键事实(本机实测):

  1. asar 里每个 packed 文件都带 SHA256 integrity,读取时校验,不匹配直接 process.exit(1) ——所以绝不能只改内容不改哈希;
  2. 但 unpacked 条目走另一条读取路径(直接从 <asar>.unpacked/ 读盘)不校验 integrity;
  3. 本机 Electron 的 EnableEmbeddedAsarIntegrityValidation fuse 是 DISABLED(OnlyLoadAppFromAsar 也是)。

于是补丁做两件事:

  1. 把 patch 后的 main.js 写到 resources/app.asar.unpacked/lib/main.js;
  2. 把 asar header 里 lib/main.js 条目改成 { size, unpacked: true, integrity }, 并把 header JSON 用空格补齐到原字节长度后原地写回。

只动 header 中长度不变的一小段(3 392 048 字节),不动 121 MB 数据区,也不重算别的文件哈希。 脚本内置:锚点必须唯一命中、写盘前备份、语法自检、写盘后复检。

改了什么

位置改动
托盘菜单打开 DeepSeek Harness ─── 重启 DeepSeek Harness 退出 DeepSeek Harness
应用菜单… ─── 重启 退出
实现新增共用的 restartApplication()(app.relaunch() + 不询问退出),与内核崩溃恢复/开发态重启同一条语义
文案复用了内核已有的 restartApplication(重启/Restart),只新增 restartApplicationFull 以匹配托盘的 打开/退出 句式

改动是纯新增:与原始 main.js 比,净增 24 行、删除 0 行。


二之二、「插件升级」卡片(版本状态 / 检查更新 / 项目地址)

「设置 → 体验优化 → 插件」页里有一张卡片,把「我装的是哪一版、线上有没有新版、 仓库在哪」三件事放在一处:

元素含义
运行版本插件加载时的 package.json version(进程活着就不变)
磁盘版本此刻磁盘上 package.json 的 version(link: 安装下会被你改)
最新版本GitHub 上仓库的最新 tag(只显示已知值,查不到就不显示数字)
状态徽标🟢 已是最新版本 / 🟡 可升级到 x.y.z / ⚪ 无法确认最新版本
上次检查上次成功查到的时刻(相对时间),持久化在配置里
检查更新联网查一次(唯一由本卡片触发的出网动作)
去 GitHub 点 Star新窗口打开项目地址
打开项目地址同上;地址同时明文显示在下方一行
自动检查更新间隔下拉:关闭(默认)/ 每 6 / 12 / 24 小时

三条刻意的设计(改之前先读)

  1. 「无法确认」绝不写成「已是最新」。 查不到时徽标是灰点 + 无法确认, 并把三条候选路各自的失败原因逐条写出来(离线 / 仓库未公开 / GitHub 限流)。 把网络失败渲染成绿点,是本插件最不能犯的错。
  2. 打开设置页不联网。 GET .updateStatus 只读进程内缓存;出网只发生在 POST .updateCheck(你点了按钮)与自动检查定时器里。判据是「路由方法」, 见集成测试里那条「.updateStatus 必须只读」。
  3. 运行版本与磁盘版本不一致时会明确提示。 这在 link: / 目录复制安装下是常态: 你改了磁盘上的代码,浏览器里跑的还是加载时那一份。不提示的话, 用户会拿旧版本去比线上版本,得出错误结论。

它查什么、怎么查

只读公开的 tag,不发送任何本机信息。三条候选路按顺序试,第一条成功即止:

顺序端点为什么需要它
1api.github.com/repos/<owner>/<repo>/releases/latest有发 release 时最准
2api.github.com/repos/<owner>/<repo>/git/refs/tags/v只打 tag 没发 release 时唯一可用的一条
3HEAD github.com/<owner>/<repo>/archive/refs/tags/v<下一个版本>.tar.gz免 API、不占限额的兜底

凭据:若环境里有 GITHUB_TOKEN / GH_TOKEN,只用它提高 GitHub 限流额度。 它的值不进配置文件、不进日志、不进任何接口回包 —— 界面只显示 「正在使用环境变量里的 GitHub 凭据」这一句,凭据本身永远不出现在界面上。

项目地址来自插件 package.json 的 repository 字段(可用配置 repository 覆盖, 只认 github.com 的 owner/repo)。设置页里它是只读的: 写路由因此没有「把请求导向任意地址」的入口。


三、安装与验证

# 下面命令里的 <DSH 安装目录> 与 <插件目录> 是占位符,替换成你自己的位置。
# (本仓库刻意不写真实安装路径:README 会随 npm 包发布出去。)

# 安装(二选一)
#
# ① 从 GitHub 装(推荐:锁定 tag,何时升级由你决定)
dsh plugin --profile desktop add "github:polan-prologue/dsh-experience-plus#v0.3.0"
#
# ② 从本地目录装(改插件源码时用这个)
dsh plugin --profile desktop add "link:<插件目录>"
#
# 卸载
dsh plugin --profile desktop remove dsh-experience-plus
#
# ⚠️ 装完**重启 DSH**:宿主半侧的改动(路由、配置字段)不参与热重载。

# 跑集成测试
#
# ⚠️ 这里刻意**不写条数**:以前写「N 项」,改一次测试就漂移一次(已经漂过两次)。
#    最后一行会打印实际通过/失败数,以那个为准。
#
# ⚠️ **必须用 DSH 桌面版的 exe 来跑**(下面这条命令就是)。原因:
#    有几条用例断言的行为**本身依赖运行环境**——是不是桌面版布局
#    (`process.execPath` 指向桌面版 exe,因而 `resources/app.asar` 存在)。
#    用 plain `node tests/integration.test.mjs` 跑不算「失败」,但你会看到
#    另一些分支被走到(路由报「不是桌面版布局」、随包 preset 探测不到)。
#    那些分支**也有断言**(必须给出可读原因),所以两套环境都应当全绿;
#    只是要发布前验收时,请以本机这份桌面版命令的输出为准。
$env:ELECTRON_RUN_AS_NODE=1
& "<DSH 安装目录>\DeepSeek Harness.exe" tests/integration.test.mjs

# 跑会话日志瘦身算法测试
$env:ELECTRON_RUN_AS_NODE=1
& "<DSH 安装目录>\DeepSeek Harness.exe" tests/compaction.test.mjs

# 校验 preset 覆盖块(需要 DSH 桌面版:随包 preset 在 app.asar 里)
$env:ELECTRON_RUN_AS_NODE=1
& "<DSH 安装目录>\DeepSeek Harness.exe" tools/verify-preset-override.mjs

# 查看桌面补丁现状(只读)
& "<DSH 安装目录>\DeepSeek Harness.exe" --expose-internals tools/patch-desktop-main.mjs --check

# 手动应用 / 还原桌面补丁
tools\应用桌面补丁.cmd
tools\还原桌面补丁.cmd

桌面「重启」补丁:默认不写盘

desktopRestartPatch 的默认值是 off:

取值插件启动时做什么
off(默认)只检测 + 报告:把补丁现状打进日志,绝不写盘
auto由用户显式选择后才在启动时维护补丁(没打过就补上,已打过就不动)

为什么默认 off:这个补丁会改写 resources/app.asar 的 header——那是用户的应用本体。 契约 §二.4 / §六 要求「插件安装后不得静默改写用户的 profile / preset / asar」, 所以写盘必须由用户显式触发。想启用有四种方式(任选其一):

  1. 设置页点「体验优化 → 桌面重启补丁 → 立即应用补丁」(最省事的一条; 该行会直接显示补丁当前是否已生效);
  2. 设置页把「桌面重启补丁」取值改成 auto(写入本插件配置,属用户显式动作, 之后每次启动都会确保补丁在位);
  3. 跑 tools\应用桌面补丁.cmd;
  4. 跑 node tools/patch-desktop-main.mjs(可加 --app-root 指定安装目录)。

无论哪种取值,写盘失败只记一条带步骤名的警告,绝不阻断 DSH 启动。

重启 DeepSeek Harness 后生效。

「重启」入口与补丁是同一件事:菜单里那一项由 app.asar 内的主进程补丁提供, 补丁没打就没有入口。所以「默认不写盘」与「入口默认可见」天然互斥, 不存在中间态 —— 这就是为什么本插件提供一个一键的显式入口,而不是把默认值改成 auto。

auto 是契约 §二.4「不得静默修改用户环境」的显式例外: 例外成立的前提是「默认值是 off」+「auto 由用户主动选择」+「文档写明」。 三条缺一,这个选项就不该存在。契约文档 docs/settings-platform.md §二.4 记着这条例外。


三之二、「体验优化」设置平台

插件在「设置」里拥有一个分区「体验优化」(settings.section,id = experience-plus, order = 20,排在「内置插件」的 15 之后),并为每个功能提供一张自包含卡片 + 一个独立开关。

稳定子插槽 experience-plus.item(契约,永不改名)

分区本身只做一件事:renderSlot('experience-plus.item')。卡片内容完全自包含, 分区不感知任何卡片内部。任何插件/模块想加一张设置卡片,不改分区、不改宿主、不改已有卡片, 二选一即可(见下面的「两种接法」)。

接法一:registerFeatureCard —— 平台按字段渲染(推荐)

// 经全局键取平台 API(本插件与卫星插件的客户端 bundle 无法互相 import):
const platform = window.__DSH_EXPERIENCE_PLUS__
if (platform?.apiVersion >= 1) {
  platform.registerFeatureCard(ctx, {
    id: 'my-plugin.card',                        // 全局唯一;兜底注册也用它
    key: 'myFeature',                            // 本插件配置里的开关字段名(见下面「字段白名单」)
    order: 60,                                   // 卡片在体验优化分区里的位置
    kind: 'toggle',                              // 控件形状:toggle / select / info
    labelKey: 'settings.myFeature.title',        // 标题(本插件字典里的键)
    descriptionKey: 'settings.myFeature.description'
  })
}

函数签名:registerFeatureCard(ctx, card, options?)。

  • card:{ id, key?, order?, kind?, labelKey?, descriptionKey?, requiresKernel?, hostCapability?, extra? };
  • options:只有三个字段 —— slot、fallbackSlot、capabilities。 它们是给测试与特殊部署用的,日常调用请省略。

⚠️ 卡片由平台渲染,不支持传自定义组件。 第三个参数是 options,不是 React 组件;传组件进去会被明确抛错(不是静默忽略), 错误信息会指向下面的「接法二」。这是刻意的设计取舍,理由见下一节。

平台按 labelKey / descriptionKey / kind 渲染卡片,并自己完成三件事: 订阅配置快照、调 .config 路由写盘、按可用性置灰。所以卡片天然满足 「不可用就置灰 + 写明缺什么」,不需要每个接入方自己实现一遍。

接法二:直接注入插槽 —— 完全自定义外观

要自定义卡片长什么样(自带图表、按钮、整页详情……),不要走上面的助手, 按契约直接注入插槽即可:

ctx.effect(() => ctx.slots.inject('experience-plus.item', function* () {
  yield ctx.slots.register({ name: 'experience-plus.item', id: 'my-plugin.card', order: 60, locale: NS }, MyCard)
}), 'my-plugin: settings card')

「网页搜索++」「推理等级++」就是这么接的。代价是自己负责渲染、置灰与写盘; window.__DSH_EXPERIENCE_PLUS__.probeSlotDeclaration / availabilityOf 可以复用平台的判据。

为什么不让 registerFeatureCard 收组件

两个理由,都是「不给自己留后门」:

  1. 字段白名单。开关值最终要走本插件的 .config 路由写盘,而那条路由只接受 lib/config.js 里 patchableFields 白名单内的字段。 平台渲染时字段名来自 card.key,白名单是唯一的守门人; 一旦允许任意组件直接写配置,白名单就被绕过了, 「开关看起来能用、实际没效果」会重新变成可能。
  2. 可用性如实报告。平台渲染的卡片一定带「不可用就置灰 + 写明缺什么」; 自定义组件要自己保证,而这正是社区插件最容易被投诉的一点。

所以需要完全自由的场景走「接法二」——那是显式的自定义, 而不是在助手背后塞一个组件、效果却由平台决定。

开关字段必须落在本插件白名单内

card.key 必须是本插件 lib/config.js 的 patchableFields 里的字段 (当前是 autoAnswer / autoAnswerMinutes / archivedBatchManage / scrollLoadEarlier / instantDelete / desktopRestartPatch / enabled)。 写出白名单的字段会在写盘时被明确拒绝并在设置页显示原因——不会静默失败。

第三方插件想加自己的开关字段,需要同时改 patchableFields、schema 与归一化逻辑 (也就是改平台),而不是只加一张卡片。这是本版本的已知边界。

插槽选择与容错

registerFeatureCard 把插槽选择、兜底、容错全封在里面:

情况行为
experience-plus.item 已声明注册进主插槽,状态 available
主插槽未声明、plugins.item 已声明退回 plugins.item,状态 fallback
两个都还没声明挂等声明控制器,状态 probe-failed(「无法确认」≠「不可用」)
单个卡片注册抛错只记日志,其余卡片照常(一个功能挂掉不连累别的)

判据不是「回调未触发」:ctx.slots.inject 的回调不触发,只说明分区还没打开, 绝不能据此判定插槽不存在。声明状态一律正向查询——有 ctx.slots.spec() 就直接问它, 旧版内核没有 spec 就退化成「真的注册一次、看是否抛错,随即注销」。

「扩展能力」页:支持的扩展插件清单

这一页有两层内容,分开看才不会误解:

层内容来源
静态清单「支持的扩展插件」:名称 / 一句话作用 / 项目链接 / 安装态 / 一键安装或卸载dsh-extensions.json(本仓库的声明文件)
动态卡片已安装扩展自己注册的设置卡片(如「网页搜索++」的优先级设置)运行期插槽注册

为什么必须有静态清单:动态卡片那一层「谁装进来谁才有」,所以只装体验增强++ 时 这一页原本是空的 —— 用户看不出这页是干什么的,也不知道有配套插件可装。 而客户端看不到「没装的插件」(它只能枚举已加载的),这份清单只能由平台静态声明。

安装态分三态,刻意不合并:

状态含义界面动作
已安装profile 的 dependencies 与 bundles 都有卸载
已装未启用依赖里有、bundles 里没有 → 不会加载重新安装(并点名提示)
未安装依赖里没有一键安装

「已装未启用」单独成一态,是因为把它显示成「已安装」最坑人: 用户在插件列表里找不到它,只会以为插件坏了。

一键安装 / 卸载执行的就是 dsh plugin --profile <你的 profile> add|remove <规格>, 与你在终端手敲的那条完全一样(不自己实现 pnpm 逻辑,避免内核升级后悄悄偏离)。 请求体只接受声明文件里的 id,不接受任意包名 —— 否则这就成了一个 「谁能让浏览器发一条 POST,谁就能往你 profile 里装任意包」的入口。

⚠️ 生效条件(如实说明,不粉饰):安装/卸载改的是 profile 的依赖与 bundle 层, 新增插件行需要重启 DSH 才会被加载 —— 这是宿主在启动时读取决定的, 插件侧做不到免重启。界面在这一页常驻写明这一点。

功能开关

功能配置字段默认说明
归档批量管理archivedBatchManagetrue关闭后归档行不再出现复选框,右键入口会如实说明
滑动到顶部自动加载更早scrollLoadEarliertrue关闭后回到手点「加载更早」
删除即点即删instantDeletetrue关闭后改为等宿主删完再关闭确认框(更慢,但每步都有回报)
无人作答时自动选答案autoAnswerfalse写入会替用户做决定,必须显式开启
桌面重启补丁desktopRestartPatchoff默认只检测不写盘;要写盘可点该行的「立即应用补丁」,或把取值显式改成 auto(见第三节「桌面「重启」补丁」)

三个新开关的默认值都等于「加上开关之前的实际行为」,所以老用户升级后不写任何配置, 行为与升级前完全一致。只有主动去设置页关掉,行为才变。

「删除即点即删」是双向分派:开着走乐观路径(立刻收尾 + 后台请求,失败会回滚并提示), 关着走 confirmWaited(等宿主真正删完再关弹窗)。两条路都在,不存在「关掉就什么也不做」。


三之三、跨 DSH 版本兼容契约(发布到社区的前提)

使用者会跑在不同版本的 DSH 上,而本插件依赖不少内核内部约定。 完整规格见 docs/settings-platform.md,这里是要点。

铁律

  1. 任何内核依赖都必须先探测、后使用。宿主侧统一走 detectCapabilities(ctx), 客户端侧统一走 detectClientCapabilities(ctx) —— 升级后只需复查这两个函数。 宿主侧的依赖清单集中在 HOST_REQUIREMENTS 一张表里(新增功能只加一行), 客户端侧的依赖在 detectClientCapabilities 内逐个探测。 客户端侧的探测必须在 apply() 的最开头:早先它排在几处注册之后, 于是那份结论对它们毫无作用——旧内核没有 ctx.slots 时直接抛穿 apply(), 插件变成 FAILED。现在每一段注册都先查能力,缺哪项就跳过哪一项并记日志。
  2. 一个功能挂掉不能影响其它功能。每个功能注册独立 try/catch,失败只打日志 (说明是哪个功能)与落诊断,绝不抛穿宿主。 客户端侧分两层:guardedEffect(能力前置检查 + ctx.effect 容错) 与 safeRegister(插槽注册容错)。两层缺一不可—— ctx.slots.inject 的回调在真实内核里是插槽声明之后才被调用的, 那时 apply() 早已返回,apply 的 try/catch 兜不住回调里的异常。
  3. 降级优先于报错。内核依赖缺失时停用该功能,而不是让插件 FAILED。 用户宁可少一个功能,也不要整个插件红掉。
  4. 设置卡片如实报告状态。禁止「开关看起来能开、实际什么也没做」—— 这是社区插件最容易被投诉的行为。卡片有三种状态:可用 / 本版本不可用(置灰 + 写明缺哪个服务)/ 探测失败(置灰 + 提示「无法确认可用性」)。
  5. 安装后不自动改写用户 profile / preset / asar。需要这类前提的功能一律 「检测 → 提示 → 由用户显式执行」。 本插件有两处正好落在这一条上,处置方式必须一致:
    • tool-ask-user 的 timed 模式(要覆盖 preset-standard):只提供生成/校验工具, 安装后不动你的 profile(见第七节);
    • 桌面「重启」补丁(要改写 app.asar):默认 desktopRestartPatch: off, 只检测不写盘;写盘的两条显式路径是「设置页点『立即应用补丁』」与 「显式把取值改成 auto」(见第三节「桌面「重启」补丁:默认不写盘」, 契约侧记在 docs/settings-platform.md §二.4)。 两处的共同点是默认不写盘,区别是第二处额外提供了一个一键的显式入口 (因为「重启」入口本身依赖补丁,不提供入口就等于功能不可达)。

日志前缀

探测失败统一记一条可读日志,前缀是常量 CAPABILITY_LOG_PREFIX (dsh-experience-plus: 能力缺失 -> <名字>)。用户反馈问题时搜这一个字符串就能定位。 宿主侧(lib/index.js)与客户端侧(lib/client.js)各有一份同名常量, 字面量逐字一致(客户端 bundle 无法 import 宿主模块), 并由测试「客户端降级」里的一条断言钉死两者不漂移。

各功能依赖的 DSH 前提(宿主侧)

功能依赖缺失时
管理路由connection.fetch.register全部 HTTP 入口不注册,记日志
归档批量管理workspaceController.archiveSession停用,卡片标注不可用
删除会话sessionController.list(退路 sessionPersistence.list)停用
在资源管理器中显示sessionController.openWorkspacePath停用
在项目里新建会话sessionController.create停用
恢复已归档workspaceRegistry.unarchiveSession停用
无人作答时自动选答案userQuestions.answer + agents.roots + sessionProjections.stateOf停用
设置卡片数据源settings.describe / settings.update只读展示,写开关时给出可读原因
设置分区settings.section 的 children 声明能力退回 plugins.item(fallback 状态)

各功能依赖的 DSH 前提(客户端侧)

客户端侧逐段注册,每段各自查能力 + 独立容错(见铁律 1、2)。

这一段注册依赖缺失时
样式注入无(只依赖 document)跳过并记日志
字典与取词器ctx.locale.bind + ctx.locale.register退回本插件中文字典直查
会话菜单行ctx.slots + slots.inject + ctx.effect菜单行整段跳过
行首单元格(复选框/未读圆点)同上跳过
归档行复选框(行尾动作区)同上跳过
浮层(提示/确认框/批量条/门控观察者)同上跳过
设置分区「体验优化」同上,且注册可能被内核拒绝(旧版不接受 children)跳过分区并记日志,其余功能照常
滑动到顶部自动加载更早ctx.effect跳过
批量模式标记清理 / 行批量勾选ctx.effect跳过
会话行右键打开菜单 / 全局错误上报ctx.effect跳过
配置拉取ctx.effect跳过(功能退回默认值)

升级 DSH 后需要复查什么

优先复查(最脆的地方):

  • detectCapabilities(宿主,lib/index.js)与 detectClientCapabilities(客户端,lib/client.js);
  • settings.section 的 children 声明形状与 experience-plus.item 子插槽是否仍被支持;
  • 客户端插槽名:sidebar.workspaces.session.menu.item、sidebar.session.row.leading (仅当 !row.archived && !row.blank 时渲染)、sidebar.workspaces.session.row.action、shell.overlay;
  • CSS Module 类名带哈希,只有语义后缀稳定 —— 选择器一律写 [class*="rowActions"], 绝不用完整哈希类名(如 hIlkoa_rowActions);
  • 会话日志 session.v4.jsonl.zstd 的版本号与帧格式:zstdDecompressSync 只解第一帧, 对整文件调用会「成功」但只拿到一行 header,静默丢掉全部内容。 必须按 magic 28 B5 2F FD 扫描帧边界后逐帧解压。日志升到 v5 就必须重新适配 (tools/compact-session-logs.mjs 会探测文件名与格式,不匹配就停用并提示);
  • tool-ask-user 的 preset 子插件行写法(顶层补丁会静默失效)。

推荐的复查方式:跑两套测试 + 依赖缺失注入。 测试里已经内置了三组针对性关卡,内核契约一变就会直接变红:

  1. 「能力探测:逐个抽掉内核依赖」——宿主侧:抽掉某个服务后该功能停用、 插件仍加载、其余降级都能由共享依赖解释;
  2. 「客户端降级:逐个抽掉 slots / inject / locale / effect」——客户端侧: apply() 不抛错、返回 undefined、日志写明缺什么; 其中「设置分区注册被拒」那一段刻意把 inject 回调推迟到 apply() 返回之后再跑, 因为真实内核就是这个时序——同步桩会让 apply 的 try/catch 顺手兜住, 于是 safeRegister 有没有都一样(这是本仓库实测到的假覆盖)。
  3. 「卡片注册逐张隔离」「设置卡片如实报告可用性」——一张卡片挂掉不连累其余; 宿主说不可用时界面真的置灰并写明原因。

这三组关卡都是行为验证,不是文本匹配。本仓库在这上面栽过两次: 用「源码里出现过某个词」当判据的关卡,改坏实现照样全绿。 所以二选一形状的分派(如「删除即点即删」的 confirmInstant / confirmWaited) 是把源码里切出来的 if/else 真的执行一遍,看实际被调用的是哪一个分支。


四、已知限制(重要,请先读)

1. 「移动至项目」在内核能力下无法真正实现 🔴

DSH 的会话与项目(工作区)是这样绑定的:

  • 工作区成员关系 = 注册表里的 sessionIds 账目 ∧ 会话 header 里的 cwd 经 realpath 后与工作区 path 相等;
  • 而 cwd 是创建时固化的只读字段,sessionPersistence 没有改写入口,sessionController 也没有 setCwd;
  • workspaceController.insertSessionBefore 只重排已在账目内的会话,跨工作区会抛 workspace/move-invalid。

所以本插件的这一项如实降级为:选一个目标项目 → 在那里新建一个会话(并自动打开)。 点开时对话框会明说这一点,不会假装移动成功。

不要尝试去重写 zstd 日志首行 header 的 cwd——那会破坏「仅追加、连续 seq」不变量, 并让投影缓存的身份比对失败。

2. 「标记为未读」是插件自持状态 🟠

内核的未读状态叫 completionUnread,由 api-session/status 单向推导,没有任何写入口。 因此未读标记由本插件自持(localStorage),并且:

  • 行首圆点注册在 sidebar.session.row.leading,而该槽位只在行的主状态为 idle 时挂载 —— 正在运行的会话、有待处理交互的会话、已归档的会话看不到这个点;
  • 标记是手动开关:菜单项在「标记为未读 / 标记为已读」之间切换,不会因为你打开会话自动清除。

3. 「删除对话」默认是软删除 🟠

内核没有任何删除入口(官方 README 明确写「无删除或保留接口」,且这是功能边界而非待办), 只能删文件。经评估后默认采用软删除:

  • sessionDeleteMode: 'trash'(默认)→ 会话目录移入 ~/.dsh/session-trash/<批次>/,可手动还原;
  • sessionDeleteMode: 'purge' → 直接物理删除,不可恢复。

两种模式都会清理 storages/session_projcache/sessions/<id>.json 与 workspace.json 里的登记。 绝不触碰 ~/.dsh/attachments/**——那是内容寻址、跨会话共享的。

删除前会拒绝三类会话:正在运行、仍有派生的子会话、找不到日志目录; 拿不到会话清单时一律拒绝(绝不盲删)。

因此确认框的文案与需求截图的「此操作不可恢复」有意不同——默认模式下它其实是可恢复的。 如果你要的就是物理删除,把配置改成 purge 即可。


五、技术备注(二次开发前必读)

  1. Config schema 必须是真的 schemastery。本机有过一次血泪事故:链接安装下解析不到 @deepseek-ai/schemastery,于是写了个手搓替身,结果 DSH 每次重启都要重置才能进—— 因为宿主启动时会跑 settings.describe() → volatileForm() → plainSchema() → new z(schema.toJSON())。 lib/schema.js 里的多路径解析就是为这件事准备的,绝不要回退到自制 schema。

  2. apply() 必须返回 undefined。cordis 会把返回值当 effect,返回对象会被判 Invalid effect 并让插件状态变成 FAILED。异步副作用一律 fire-and-forget。

  3. 路由用 ctx.connection.fetch.register,只认精确 pathname,所以客户端用点号分段 (/api/dsh-experience-plus.reveal)。用斜杠(…/reveal)会静默 404。

  4. shell.overlay 的容器是 position:absolute; inset:0; pointer-events:none, 所以自己的根节点必须 position:fixed; inset:0 且显式 pointer-events:auto, 否则浮层会被点击穿透,表现为「弹不出来」。

  5. 读 asar 原始字节时 offset 是相对数据区起点的,必须加 dataOffset(漏加会读到无关字节且不报错)。 lib/desktop-patch.js 里还为此临时切换 process.noAsar——不能永久打开,宿主自己还要用 asar 加载模块。

  6. 批量替换必须复核目标函数自身(本插件最贵的一次事故)。 给 8 个行组件统一加「防崩护栏」时,一次 replace_all 把 const [, setMenuOpen] = useMenuOpenState() 换成了 const setMenuOpen = useMenuToggle(useMenuOpenState)—— 连 useMenuToggle 自己的函数体也一起换了,于是它自己调自己:

    function useMenuToggle(useMenuOpenState) {
      if (typeof useMenuOpenState !== 'function') return () => {}
      const setMenuOpen = useMenuToggle(useMenuOpenState)  // ← 无限递归
      return setMenuOpen
    }
    

    渲染期抛 RangeError: Maximum call stack size exceeded → 被行级错误边界静默吞掉 → 8 行一起消失、菜单只剩原生 4 项。而当时 21 项测试全绿,因为它们只检查「注册了没有」, 从不渲染组件。 现在的关卡:tests/integration.test.mjs 里有个迷你渲染器,会把每个已注册的行组件真的递归渲染一遍 (带深度上限,把无限递归变成可读失败)。这条关卡已用「注入旧 bug」验证过确实会红。

  7. 浏览器里的异常要主动送出浏览器。模型看不到浏览器 Console,所以本插件把启动自查、 行渲染异常、全局未捕获错误都 POST 给宿主,落盘到 <插件目录>/.runtime/client-diagnostics.jsonl。 上面那次事故就是靠这份诊断在 10 秒内定位的——没有它只能靠猜。

  8. 别猜宿主服务的字段名,能读到权威投影就别问宿主。 曾经照着直觉写 sessionController.list() 返回数组、SessionSummary.id, 真实契约却是 list(request, signal) → { items: [{ sessionId, … }] } (而且 SessionSummary 根本没有 title)。后果是会话清单恒为空 → 批量管理空白、删除对话全部被误判成「找不到该会话」,而单测全绿(因为单测照着同样的错假设写的)。 现在的做法:列表一律用客户端权威投影(useSessions / useWorkspaces,它们带 displayTitle、cwd、sessionIds),宿主只保留「必须由宿主做的写操作」; 并且 .delete 的测试改成喂真实契约形状,作为这条回归的关卡。

  9. 删文件不会让宿主刷新它的会话清单——这是个容易踩的坑: 直接删日志目录,宿主不会重扫磁盘,那一行会一直留在工作区列表里, 看起来像「删除失败」,而诊断里 deleted=1, refused=[] 明明是成功的。 正确顺序是先归档、再删文件:归档是内核支持的写操作,会推送状态更新让列表即时刷新; 随后删磁盘上的日志,这条会话才彻底消失。另外 workspace.json 里的 archivedSessionIds 刻意不清——归档标记正是「别再显示它」的那个标志,清掉反而可能让它冒回来。

  10. 浮层要自己给被压住的滚动容器留位置。批量操作条是 fixed 元素,浮在会话列表之上; 不预留空间就会盖住最后几行(用户截图实测)。做法是从会话行向上找最近的可滚动祖先, 批量模式期间加 padding-bottom,退出时还原——比猜 CSS 类名稳,也不依赖具体内核版本。

  11. 桌面版没有「刷新页面」这个选项(网页版才有)。所以客户端改动靠 client-hmr 自动热重载, 宿主改动用插件管理器热重载;不要把「请刷新」写进交付说明。

  12. 排除 input 时要分清「录入」与「勾选」。整行点选处理器里我曾用 closest('input, …') 一律放行,结果点行首复选框时处理器直接 return, 事件继续冒泡去打开了会话——既没勾上、又跳走。正确写法是 input:not([type="checkbox"])。


六、目录

lib/
  index.js           宿主半侧:9 条管理路由 + 能力探测 + 启动时**检测**桌面补丁(默认只报告)
  client.js          浏览器半侧:菜单行、浮层、设置分区、功能卡片、字典
  config.js          配置字段的唯一事实来源(宿主与客户端共用)
  schema.js          Config schema(真 schemastery 多路径解析)
  desktop-patch.js   桌面主进程补丁核心(CLI 与宿主共用同一份实现)
docs/
  settings-platform.md         设置平台与跨版本兼容契约(改设计先改这里)
tools/
  patch-desktop-main.mjs       桌面「重启」补丁 CLI:--check / --restore / --app-root
  preset-paths.mjs             定位 profile 与随包 preset(**不硬编码任何本机路径**)
  gen-preset-override.mjs      生成 preset 覆盖块:--apply / --profile / --source
  verify-preset-override.mjs   与随包 preset 逐项对账:--profile / --source
  compact-session-logs.mjs     会话日志瘦身(多帧 → 单帧):--apply / --restore
  应用桌面补丁.cmd / 还原桌面补丁.cmd
tests/
  integration.test.mjs     集成测试(不 mock 本插件自己的模块;条数以运行输出为准)
  compaction.test.mjs      会话日志瘦身算法测试

preset-paths.mjs 的自动探测顺序:--profile-file / DSH_PROFILE_FILE → --profile(在 $DSH_HOME/profiles/ 下按名字找)→ 优先 desktop。 随包 preset 依次尝试 profile 的 node_modules、$DSH_HOME/node_modules, 最后直接从 resources/app.asar 里按条目读出(与包内路径无关,任何 DSH 桌面版都能用)。 三级都不通时列出已尝试的路径并报错退出——绝不写半成品配置。

路由清单(全部只认精确 pathname,自带内核的 Host/Origin 栅栏与 cookie 鉴权): .diag / .reveal / .createInProject / .archive / .delete / .restore / .desktopPatch / .capabilities / .config。

附:会话日志瘦身(解决「加载更早」慢)

为什么慢

会话日志是多帧拼接的:每次增量写入都追加一个新的 zstd 帧,长期会