Skip to content
dsh.fish
Bundle

@deepseek-ai/dsh-session-history

DSH 会话历史面板:浏览、归档、恢复,并把已归档会话从磁盘彻底删除 | Session history panel for the DSH desktop app, with guarded permanent deletion of archived session logs

Source
wangxiang0605qvq
License
MIT
Updated
Updated 18 days ago

Readme

# @deepseek-ai/dsh-session-history

DSH 桌面端的**会话历史面板**:浏览历史会话、打开、归档(单个 / 批量)、恢复,以及把已归档的会话**从磁盘彻底删除**。

A session history panel for the DSH desktop app: browse, open, archive (singly or in bulk), restore, and permanently delete an archived session's log from disk.

## 两种"删除",含义完全不同

| | 归档(Archive) | 彻底删除(Purge) |
| --- | --- | --- |
| 谁提供 | **DSH 自带**:`workspaceRegistry.archiveSession` | **本插件**:Host 半边直接删文件 |
| 效果 | 离开列表,JSONL 日志与工作区记账**保留** | 从磁盘上删掉该会话的日志目录 |
| 可恢复 | 可以,`unarchiveSession` | **不可以** |
| 用在 | 列表里任何会话 | **只有已归档的会话** |

DSH 本身**没有**会话硬删除:`workspaceRegistry` 只有 `archiveSession`/`unarchiveSession`/`pinSession`/`unpinSession`(唯一的 `delete(id)` 删的是整个工作区注册,而且它明说 "retains the directory and every session log");`sessionPersistence` 只有 `create`/`open`/`flush`/`stat`/`list`;发行版自己的会话 `...` 菜单也只注册了 `pin`/`rename`/`fork`/`archive`。

所以这一版按你的要求把彻底删除补上了 —— 走本插件自己的私有 RPC 通道 + Host 半边直接删文件,**不假装 Host 有这个能力**。

## 长什么样

侧边栏新增一个面板图标(时钟表盘),点开是主列的面板:

```
┌─────────────────────────────────────────────────────────────────┐
│  会话历史                            [搜索…]  [刷新] [全部归档] │
│  共 12 条,已归档 3 条                                          │
├─────────────────────────────────────────────────────────────────┤
│  默认工作区   C:\…\default-workspace                        5   │
│   ● DeepSeek Harness 实时显示插件                       2 分钟前 │
│   ○ DeepSeek 插件:时钟                                  3 天前 │
│                                                                 │
│  其他目录                                                     1  │
│   ○ 改名过的会话                                         1 周前 │
│                                                                 │
│  已归档                                              3  [全部恢复]│
│   ○ 旧的实验                                   2 周前  ↺  🗑   │
│         ↑ 点 🗑 后原地变成 [🗑 确认删除] [取消],8 秒无操作自动收起│
├─────────────────────────────────────────────────────────────────┤
│  移除会话 = 归档(日志保留、可恢复);已归档的会话可以再「彻底删除」│
└─────────────────────────────────────────────────────────────────┘
```

- **分组**:按会话 `cwd` 匹配工作区路径分组,匹配不上进「其他目录」;组内按最近活动倒序。
- **绿点**:该会话正在运行。
- **搜索**:按标题或目录路径过滤。
- **行悬停**:`→` 打开、`🗄` 归档;已归档行是 `↺` 恢复 + `🗑` 彻底删除。
- **二次确认**:「全部归档」和「彻底删除」都要点两次,8 秒无操作自动取消。归档一个还在跑工作的会话也要确认一次(见下节)。
- **归档语义**:先**不带 `stopActivity` 地问一次**,只有 Host 用 `workspace/session-active` 拒绝时,面板顶部才出现确认条,把 Host 报的运行内容列出来,等你点「停止并归档」再带 `stopActivity: true` 重试。**不拿 `running` 猜。**

## 为什么归档要先问一次,而不是先看 `running`

`SessionSummary.running` 只是 `ctx.remote.session.list()` 的**轮询快照**,而"这个会话现在能不能归档"由 Host 侧的 Agent 注册表当场裁决,两者会不一致。按快照猜的写法一旦猜错(快照说没在跑、Host 说在跑),`archiveSession` 就会被 `workspace/session-active` 拒绝,而那时插件把任何拒绝都当"失败"上报 —— 结果就是**正在使用的会话永远归档不掉**,界面上没有任何出路。这正是修掉的那个 bug。

现在改成和发行版侧边栏完全相同的形状:

```text
archiveSession(id)                       ← 先不带 stopActivity 问一次
  ├─ 成功                    → 「已归档 N 条会话」
  └─ workspace/session-active → 面板顶部确认条:
        列出 Host 报的运行内容(turn / job / subagent / schedule)
        [停止并归档] → archiveSession(id, { stopActivity: true })
        [取消]       → 收起(15 秒无操作自动收起)
```

判定拒绝用的是 **RPC code**(`error.rpcError.code === "workspace/session-active"`)而不是 `instanceof`:`workspaces` 服务住在另一个 client bundle 里,**插件 bundle 之间不共享 error class 身份**。发行版自己的 `activeSessionRefusal()` 按 `error.name` 判,原因相同。

附带的好处:以前对 `running === true` 的行直接传 `stopActivity`,等于**不问就取消对方正在跑的轮次、并丢弃排队中的输入**;现在这一步必须先经过你确认。

其它错误(例如 `session/not-found`)仍然直接报错,并且从这一版起报错文案会带上 RPC code,例如 `操作失败:session/not-found: …`,不用再猜是哪种失败。

## 彻底删除是怎么做的(以及为什么这样写)

磁盘布局(在本机 0.2.0-rc.2 profile 上实测确认):

```text
<DSH_HOME>/sessions/<projectKey>/<sessionId>/session.v4.jsonl.zstd
```

`sessionId` 实际取值都在 `[A-Za-z0-9._-]` 内(`session-<uuid>` 与裸 `<uuid>` 两种形式都出现过),所以 `encodeSegment` 不会改动它 —— **目录名就是 id 本身**。

`lib/index.js`(Host 半边)因此**不复刻后端的 `projectKey` 编码**,而是直接按目录名精确匹配。所有守卫都朝"宁可拒绝,绝不删错"的方向排:

| # | 守卫 | 挡住的情况 |
| --- | --- | --- |
| 1 | id 必须是单个普通路径段(`/^[A-Za-z0-9._-]+$/`,且不是 `.` / `..`) | 路径穿越 |
| 2 | 调用方必须显式传 `confirm: true` | 误调用 |
| 3 | `agents.get(id)` 有值就拒绝 | **会话还活着** —— 删掉正在读写的日志会毁掉运行中的会话 |
| 4 | `sessionPersistence.stat(id)` 必须仍认得这个会话 | 拼错 / 过期的 id 撞上同名目录 |
| 5 | 只按目录名精确匹配,且必须**恰好一个**匹配 | 同名出现在多个 project 目录时拒绝猜 |
| 6 | 目录里必须真的有 `.jsonl*` 日志产物 | 删到不像会话目录的东西 |
| 7 | `rm` 带 `maxRetries` | Windows 上读者刚关句柄的 EBUSY 抖动 |

### 守卫 3 有个反直觉的后果:归档 ≠ 卸载

`agents.get(id)` 查的是**活着的 Agent**。而一个会话只要还被窗口打开着,它的 Agent 就还活着 —— **归档只是把它从列表里移走,不卸载它**。所以在列表里"删掉"了某个会话、再去点 🗑,会遇到守卫 3,看起来像"我都删了怎么还说它开着"。

这不是 bug,是守卫在挡"从正在读写的日志底下把文件抽走"。两条出路:

1. **先离开这个会话** —— 切到别的会话,或新建一个;等它的 Agent 卸载后再回来删。
2. **重启 DSH,然后不要打开它** —— 直接从本面板的「已归档」分组里删。重启后只有 DSH 自己打开的会话是活的,归档过的那个一定是死的,这条最稳。

面板现在按 RPC code(`session-loaded`)把这种拒绝本地化成上面那段可执行的说明,而不是原来那句英文 `close it before deleting its log` —— 后者根本没法照做,因为 DSH 没有"关闭会话"这个按钮。

删完返回 `{ sessionId, directory, project, files, bytes }`,面板把它汇总成「已彻底删除 N 条会话,释放 X MB」。多条时是**串行**的。

**投影缓存不删**:`session-projection-cache` 里按键为 session id 的条目在会话消失后是惰性的(几十 KB),为了它去动共享 storage domain 风险远大于收益。

## 数据来源

| 用途 | 来源 |
| --- | --- |
| 会话列表(标题 / 时间 / cwd / running / blank) | `ctx.remote.session.list({})` → `SessionSummary[]` |
| 标题文本 | `summary.projections.values.title.title`(`title` 投影单元发布的是对象) |
| 分组 / 归档集 / 置顶集 | `useWorkspaces((s) => s.items / s.archivedSessionIds / s.pinnedSessionIds)` |
| 归档 / 恢复 | `ctx.workspaces.archiveSession(sessionId)`(被拒时经确认再带 `{ stopActivity: true }` 重试)/ `unarchiveSession(sessionId)` |
| 归档被拒的原因 | `WorkspaceArchiveError.rpcError.code === "workspace/session-active"`,`.details.activity` 是 `SessionActivity[]`(`turn` / `job` / `subagent` / `schedule`) |
| 打开会话 | `ctx.uiWorkspace.openSession(sessionId)` |
| **彻底删除** | 私有 RPC 通道 `/session-history` 的 `delete` 端点 → 本插件 Host 半边 |

## 槽位与「不用写导航」

`sidebar.panellist` 的契约原文:

> Global panel icons. **Each list id addresses the matching main panel; the sidebar owns the button and resolves its label from list metadata.**

只要 `sidebar.panellist` 的 `id` 与 `main` 的 `key` 都是同一个 `PANEL_ID`(`"session-history"`),侧边栏自己渲染按钮、管选中态、切面板 —— 插件里**没有一行导航代码**。`main` 的注册用 generator 形式,和发行版唯一的 `main` 注册(Plugins 页)一致。

私有路由契约(两端都由本插件拥有,因此**没有**套 Connection 的 RPC 信封 `{type,rpcId,result}`,就是一个普通的 `{ok, value|error}` JSON):

```js
// Host(webServer 的 prefix 路由,handler 是 node 风格)
ctx.inject(["webServer", "connection"], (scoped) => {
  scoped.effect(() => scoped.webServer.register({ kind: "prefix", path: "/session-history", handler }), label);
});
// Browser(同源 POST,浏览器会话 cookie 随请求带上,正是 Host 准入判据所查的)
await fetch("session-history/delete", { method: "POST", headers: { "content-type": "application/json" }, body: … });
//      → { ok: true, value } | { ok: false, error: { code, message, details } }
```

为什么不用 `connection.rpc.handle`:它的实现是 `owner.effect(() => owner.webServer.register(route))`,`owner` 取的是 connection 服务自己的 context,注册时机不由本插件掌握。发行版里 `client-modules`、`client-file-upload`、`client-hmr` 三个插件都是**直接挂 `webServer`**,本插件跟随这条更可控的路径。

## 文件

| 文件 | 作用 |
| --- | --- |
| `lib/client.js` | 浏览器半边:会话 store、面板 UI、行组件、内联 SVG 图标、两个槽位注册 |
| `lib/index.js` | **Host 半边:私有 RPC 通道 + 带守卫的磁盘删除** |
| `package.json` | 双面包声明 `exports["./client"]` + `dsh.client.platform = "web"` + `dsh.bundle.patch` |
| `cordis.patch.yml` | bundle 层:声明本包自带的挂载行,作为 bundle 安装时由插件管理器读取 |

## 安装

### 方式一:插件管理器(推荐)

本仓库通过 `dsh.bundle.patch` 声明了自带的 `cordis.patch.yml`,因此不需要手写补丁行:

```powershell
dsh plugin --profile desktop add github:wangxiang0605qvq/dsh-session-history
```

### 方式二:手动放进解析根(离线可用)

要点:**包必须放在 `$DSH_HOME\profiles\node_modules\` 下** —— 那是运行时模块解析的查找根。放进 `profiles\desktop\node_modules\` 不会被解析到,只写补丁行同样找不到模块。

```powershell
$dest = "$env:USERPROFILE\.dsh\profiles\node_modules\@deepseek-ai\dsh-session-history"
New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null
Copy-Item -Recurse -Force . $dest
Remove-Item "$dest\.git" -Recurse -Force -ErrorAction SilentlyContinue
```

然后在 `~/.dsh/profiles/desktop/cordis.patch.yml` 末尾追加:

```yaml
- insert:
    - id: session-history
      name: "@deepseek-ai/dsh-session-history"
      config: {}
```

重启 DSH,然后硬刷新(Ctrl+F5)页面。

### 方式三:作为 profile 本地插件(本机实测通过的路径)

不需要动 `node_modules`,把 `lib/` 下的两个文件当作插件的两个半边即可:

```
~/.dsh/profiles/desktop/plugins/session-history/
├── package.json   # 声明 exports["./client"] 与 dsh.client.platform = "web"
├── index.js       # 即 lib/index.js
└── client.js      # 即 lib/client.js
```

补丁行用**相对路径**,由 patch 文件所在目录解析:

```yaml
- insert:
    - id: session-history
      name: ./plugins/session-history/index.js
```

> 注意:`window.__ModuleLoader__.load({ id })` 必须与 package.json 的 `name` 完全一致,否则 browser 模块表找不到对应的 factory,插件会**静默不生效**。本仓库两处都是 `@deepseek-ai/dsh-session-history`;若你把 `name` 改成别的值,`lib/client.js` 里的 `load({ id })` 要同步改。

> ### ⚠️ 改了 `lib/index.js` 必须重启 DSH
>
> **Host 半边不会热重载。** profile 的 HMR 只重新投递 **client 半边**(client-modules 按文件 mtime 生成带 revision 的 bundle URL,页面重新拉取),Host 半边是进程内 ESM,同一个 URL 在进程存活期间只求值一次 —— 插件管理器文档也写明 "Package replacements require restarting the process to load a fresh JavaScript module generation"。
>
> 症状就是第一次踩到的那个:**改了 `lib/index.js`(哪怕改成完全空的 `apply`),进程里跑的还是启动时那份**,路由根本不注册,客户端 POST 穿透到静态 SPA 回退,报 `transport failure for /session-history/delete: HTTP 405`。改 `lib/client.js` 不需要重启。
>
> 判断当前进程加载的是哪一份:比对 `lib/index.js` 的 `LastWriteTime` 与 DSH 进程的 `StartTime`(`Get-Process -Name "DeepSeek Harness"`)。前者晚于后者,就说明改动还没生效。

## 排查

**`HTTP 405` / `transport failure`** —— 路由没注册,请求落到了静态前端回退(`dsh-host-frontend-static` 的 fallback 座位只接受 GET/HEAD,其它方法一律 405)。按顺序检查:

1. `lib/index.js` 的 mtime 是否晚于 DSH 进程启动时间?是 → 重启 DSH。
2. 重启后仍 405 → 用未认证探针区分两种情况:

   ```powershell
   Invoke-WebRequest "http://127.0.0.1:19387/session-history/delete" -Method POST `
     -ContentType "application/json" -Body '{"sessionId":"x","confirm":true}'
   ```

   - **405** = 路由仍未注册(`ctx.inject(["webServer","connection"])` 没满足)
   - **401 / 403** = 路由已注册,被 auth 判据挡住了(浏览器里带 cookie 就不会)

**彻底删除被拒** —— 路由通了,是守卫拒的。Host 现在给每种拒绝一个 code,面板按 code 本地化;没有 code 的走通用 `purge-refused`:

| code | 含义 | 怎么办 |
| --- | --- | --- |
| `session-loaded` | 会话还活着(Agent 没卸载) | 先离开这个会话,或重启 DSH 后不打开它 —— 见「守卫 3 有个反直觉的后果」 |
| `session-unknown` | Host 已经不认识这个会话 | 刷新列表;它可能已被删掉或移走 |
| `ambiguous-session` | 同名目录出现在多个 project 目录 | 插件拒绝猜,需要手动确认 |
| `no-log-artifact` | 目录里没有 `.jsonl` 日志产物 | 不像会话目录,插件拒绝删 |
| `bad-session-id` / `confirm-required` | 调用参数不合法 | 正常 UI 不会触发 |

## 可调项

| 想改什么 | 改哪里 |
| --- | --- |
| 侧边栏图标位置 | `lib/client.js` 注册处的 `order: 50` |
| 面板 ID / RPC 通道名 | `PANEL_ID`、`CHANNEL`(**两端必须同时改**,client 与 index 各有一处) |
| 会话根目录 | `lib/index.js` 的 `sessionsRoot()`(默认 `$DSH_HOME/sessions`,回退 `~/.dsh/sessions`) |
| 二次确认宽限时间 | 「全部归档」与「彻底删除」两处 `setTimeout(..., 8000)` |
| 归档确认条自动收起时间 | 面板里那个 `setTimeout(() => actions.cancelStop(), 15000)` |
| 归档要不要问(而不是直接停) | `applyBatch` 只在不带 `stopActivity` 时才把 `workspace/session-active` 转成确认条 |

## 已知边界

- **彻底删除不可恢复**;面板只对已归档的行提供它,且需要二次确认。
- **正在使用的会话也能归档**,但要先确认停止它正在跑的工作:那一停会取消当前轮次并丢弃排队中的输入,所以插件不会替你做这个决定。
- **已归档但还开着的会话删不掉日志**(守卫 3):归档不卸载 Agent。先离开它,或重启 DSH 后不打开它再删。
- 日志根目录按 `$DSH_HOME/sessions` 推导。**推错不会删错**:守卫 5/6 会让操作直接拒绝。
- 投影缓存里的对应条目会留下(惰性,不影响功能)。
- 面板依赖 `ctx.remote.session.list()` 是否包含已归档会话;若某版本把归档会话排除在外,「已归档」分组会少掉那些行(恢复本身仍通过 `archivedSessionIds` 生效)。
- 子会话(`origin === "subagent"` 或有 `parentSessionId`)不列在历史里。
- 无头 / TUI 环境没有侧边栏与主列,面板不显示。

## License

MIT

Install

dsh plugin --profile web add github:wangxiang0605qvq/dsh-session-history

Profile: web

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