dsh-plugin-workspace-sorted
让 DeepSeek Harness 的 工作区(Workspace) 按「最近使用」排序的宿主(Host)插件。
侧边栏自带的「排序方式:手动排序 / 最近更新」只作用于会话;工作区分组的顺序永远是 Host 注册表的持久顺序。本插件让最近有活动的那个工作区排到最前面。
为什么需要它
三条来自 DSH 0.1.5-rc.2 源码的事实决定了实现方式:
@deepseek-ai/dsh-client-ui-workspace 的 deriveGroups() 接收
real workspaces in stable Host order——工作区分组顺序就是
ctx.workspaceRegistry.list() 的顺序,与会话排序选项无关。
- Host 侧只有
workspaceRegistry.insertBefore(id, beforeId?) 能改变这个顺序
(ctx.workspaceController.insertBefore 是它的 Remote 封装),而新建工作区只是
prepend,会话活动从不重排工作区。
- 浏览器端没有可用的排序扩展点:
ui-workspace 的 /client 只导出
apply/inject 与类型,WorkspaceBrowser 组件是包内私有的,workspaces
这个 root hook 也已被它占用。要加 UI 选项只能整体替换
sidebar.workspaces 席位(约 2800 行 UI)。
因此唯一受支持的接缝是 Host 注册表本身,本插件就驱动它。
排序语义
与客户端为「新会话」挑选最近活跃工作区的逻辑(ui-workspace 的
recentWorkspace())完全一致:
- 工作区的活跃时间 = 其成员会话中最大的活跃时间;
- 没有任何成员会话时,回退到工作区自己的
createdAt;
- 排序为活跃时间降序,同分保持当前注册表位置(不会来回抖动)。
Host 侧一条会话的活跃时间是
max(header.createdAt, sessionListMetadata.lastPromptAt)——也就是侧边栏会话行上
显示的那个相对时间。所以「最近使用」看的是用户真正发过 prompt 的时间,
而不是任何一次后台事件。
数据来源:
| 来源 | 作用 |
|---|
ctx.sessionController.list()(可选服务,经 ctx.inject 等待就绪) | 启动时播种,保证刚装好的首次启动就是正确顺序 |
session/created | 在该工作区新建会话 = 使用了它 |
session/event 且 type === 'user/message' 且 source.kind === 'user' | 用户真正发 prompt |
事件只记录会话级时间戳,归属到哪个工作区是在 debounce 之后、
reconcile 时通过 workspace.sessionIds 惰性解析的——因为
attachSession() 是在会话发布之后才执行的。
安装与激活
插件包是两层结构,两者缺一不可,且激活行只能声明在其中一个层里。
1) 安装
本包以源码仓库形式分发(未发布到 npm registry)。克隆到本机后,在本包所在
目录执行:
git clone <repo-url> dsh-plugin-workspace-sorted
cd dsh-plugin-workspace-sorted
dsh plugin --profile web add link:.
dsh plugin 在转发给 pnpm 之前会把 link:. 解析成该目录的绝对路径,因此
README 与包本身都不需要、也不应该写死任何本机路径——这份文档可以直接给别人用。
也支持让 dsh 直接从 git 安装(本包没有 prepare 构建步骤,所以不会被 pnpm
的构建白名单挡住):
dsh plugin --profile web add github:<owner>/<repo>
2) 激活
在 profile 自己的 ~/.dsh/profiles/web/cordis.patch.yml 里声明这一行:
- insert:
- id: workspace-sorted
name: dsh-plugin-workspace-sorted
config:
enabled: true
debounceMs: 1500
countSessionCreation: true
includeSubagents: false
verbose: false
为什么激活行不放在包的 patch 里
loader 会拒绝一份 id 重复的组合结果:
TypeError: duplicate loader entry id: workspace-sorted
(cordis-plugin-loader, EntryTree.update)
这个 reject 发生在 Include 的初始化里,会直接让整个 profile 启动失败。
applyEntryPatches() 的 insert 只是 data.push(...insert),不做去重,
所以同一 id 在两个层各 insert 一次必炸。
于是形成一条硬约束:
| 激活行放在哪 | 下次重启 | 不重启热加载 |
|---|
包的 cordis.patch.yml | ✅ | ❌ bundle 列表是启动时读的 |
profile 的 cordis.patch.yml | ✅ | ✅ patchReload: live 会重放这一层 |
本插件选择了后者:包的 cordis.patch.yml 故意是 [],激活行由 profile 层声明。
这样改一行就能热加载/热卸载,且下次重启仍然只有一份 id。
(如果更想要「装完即生效、必须重启」的自包含形态,把那一行搬回包的
cordis.patch.yml 即可——但两层不能同时声明。)
配置
patch 会替换整份 config,所以要保留的键必须全部重写:
- id: workspace-sorted
config:
enabled: true # 总开关;false = 完全交回手动拖拽
debounceMs: 1500 # 活动合并窗口(50–60000)
countSessionCreation: true # 新建会话是否算「使用了该工作区」
includeSubagents: false # 侧边栏隐藏的 subagent 会话是否参与
verbose: false # 每次实际重排写一条 info 日志
日志走 ctx.logger(DSH 官方约定)。默认的 dsh web 组合只把日志写进
Cordis 内存缓冲区,终端不打印;只有在组合里注册了 logger exporter
(ctx.logger.exporter(...))时才会看到这些行。
验证
在本包所在目录下执行:
node --test # 20 个用例
node tools/preview.mjs # 只读 dry-run:当前顺序 vs 目标顺序 vs 计划移动数
dsh --profile web --dump-config | grep -c '^- id: workspace-sorted$' # 必须是 1
tools/preview.mjs 读取真实的 ~/.dsh/storages/workspace.json 与
session_projcache/,跑的是插件里同一份纯函数,不写任何东西。
已完成的实测记录
-
单元 / 框架层(20/20 通过):排序算法、插件生命周期,以及两个跑在真实
Cordis 上的集成用例——在真实 root context 上挂载插件、解析 inject、
通过真实事件总线 emit 触发重排,以及 ctx.inject(['sessionController'])
在服务后于插件提供时仍能触发启动播种。
-
真机端到端:在隔离的 DSH_HOME(建在用户目录下而非系统临时目录——
macOS 的临时目录是一条符号链接,会让 HMR 的路径匹配失效)里启动真实
dsh web,预置一份故意写反的注册表 ['older','newer'],然后
在宿主运行中把激活行追加进 profile patch。观察到的宿主日志:
[spy info] workspace-sorted: Workspace order will follow most-recent activity
[spy info] workspace-sorted: startup seed took 0 Workspace timestamp(s) from 0 Session(s)
[spy info] workspace-sorted: reordered 1 Workspace row(s) -> bbbb… > aaaa…
随后 workspace.json 落盘顺序变为 ['newer','older']。这同时证明了
热加载激活、真实宿主内加载、以及真实的持久重排三条链路。
-
重启安全:dsh --profile web --dump-config 中
^- id: workspace-sorted$ 计数为 1,即两个 patch 层没有重复 id。
设计约束
- 零运行时依赖:
lib/index.js 不 import 任何包,只与传入的 Cordis
context 交互,因此作为 profile bundle 层加载时不存在自身模块解析问题。
- 尽力而为、绝不伤主流程:两个事件监听器各自吞掉自己的异常——
session/created 是同步派发的,在那里抛异常会否决会话创建。
- 空闲零写入:推导顺序与当前注册表顺序一致时直接返回,不调用
insertBefore。所以「一直待在一个工作区里」不会反复写盘,安装瞬间
也不会产生任何写盘(本机首次 dry-run 就是「计划移动 0 次」)。
- 拖拽会被覆盖:这是「按最近使用排序」的必然代价。
enabled: false
或卸载插件即可恢复纯手动排序。
卸载 / 回滚
- 删掉 profile patch 里的那一行(热卸载,立即生效);
- 可选:
dsh plugin --profile web remove dsh-plugin-workspace-sorted。
两种方式都不会删除任何工作区、目录或会话;唯一被改动的持久数据是注册表里的
工作区顺序,而顺序本来就可以手动拖拽。