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
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install deepseek-ai-dsh-session-history from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.