Skip to content
dsh.fish
Bundle

dsh-plugin-workspace-sorted

DeepSeek Harness host plugin: keep the durable Workspace registry order most-recently-used first

Source
karottc
stars
1 stars
License
MIT
Updated
Updated 6 days ago

Readme

# dsh-plugin-workspace-sorted

让 DeepSeek Harness 的 **工作区(Workspace)** 按「最近使用」排序的宿主(Host)插件。

侧边栏自带的「排序方式:手动排序 / 最近更新」只作用于**会话**;工作区分组的顺序永远是 Host 注册表的持久顺序。本插件让最近有活动的那个工作区排到最前面。

## 为什么需要它

三条来自 DSH 0.1.5-rc.2 源码的事实决定了实现方式:

1. `@deepseek-ai/dsh-client-ui-workspace` 的 `deriveGroups()` 接收
   `real workspaces in stable Host order`——工作区分组顺序就是
   `ctx.workspaceRegistry.list()` 的顺序,与会话排序选项无关。
2. Host 侧只有 `workspaceRegistry.insertBefore(id, beforeId?)` 能改变这个顺序
   (`ctx.workspaceController.insertBefore` 是它的 Remote 封装),而新建工作区只是
   prepend,会话活动从不重排工作区。
3. 浏览器端没有可用的排序扩展点:`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)。克隆到本机后,在本包所在
目录执行:

```sh
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
的构建白名单挡住):

```sh
dsh plugin --profile web add github:<owner>/<repo>
```

### 2) 激活

在 profile 自己的 `~/.dsh/profiles/web/cordis.patch.yml` 里声明这一行:

```yaml
- 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`**,所以要保留的键必须全部重写:

```yaml
- 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(...)`)时才会看到这些行。

## 验证

在本包所在目录下执行:

```sh
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`
  或卸载插件即可恢复纯手动排序。

## 卸载 / 回滚

1. 删掉 profile patch 里的那一行(热卸载,立即生效);
2. 可选:`dsh plugin --profile web remove dsh-plugin-workspace-sorted`。

两种方式都不会删除任何工作区、目录或会话;唯一被改动的持久数据是注册表里的
工作区**顺序**,而顺序本来就可以手动拖拽。

Install

dsh plugin --profile web add github:karottc/dsh-plugin-workspace-sorted

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source