dsh-session-menu-delete
给 DeepSeek Harness 侧栏「会话行 ⋯ 菜单」加一项 删除会话 —— 位置紧跟在「归档会话」下方,带垃圾桶图标,红色破坏性样式。
DSH 原生只有「归档」,没有删除;这个插件把它补上,并且连带清理子代理会话(不留孤儿),同时保护 fork 出来的会话。
它加在哪
那个菜单就是插槽 sidebar.workspaces.session.menu.item,内置四项的 order 是:
| 条目 | 来源 | order |
|---|---|---|
| 置顶会话 | 内置 | 100 |
| 重命名 | 内置 | 200 |
| 分叉会话 | 内置 | 300 |
| 归档会话 | 内置 | 400 |
| 删除会话 | 本插件 | 500 |
图标用 primitives 的 IconTrashOutlineRegular(与内置菜单项同一套),并以 danger: true 渲染成破坏性操作的红字。
点了会删什么
点击即删,没有二次确认。 client 半发 POST /session-menu-delete/api/delete,host 半依次处理:
- 会话工件目录
<DSH_HOME>/sessions/<slug>/<sessionId>/ - 投影缓存记录
<DSH_HOME>/storages/session_projcache/sessions/<id>.json - 工作区账本归属 —— 走
workspaceRegistry服务的entity.detachSession(),而不是直接改workspace.json(否则 DSH 的内存态会在下次写盘时把手改的内容覆盖回去) - 归档 / 置顶集合里的该 id
- 它派生出的子代理会话(级联,见下)
- 它独享的附件对象(顺手清理,见下 —— 被别的会话共享的一律不动)
会话仍在运行时,先按 DSH 自己的 retire 顺序摘除(agent.cancel({kind:'disposed'}) → scope.dispose() → agents.store.delete() → sessions 条目 detach()),再删文件,避免留下"文件没了但注册表还在"的僵尸。
⚠️ 全部不可恢复:会话工件没有回收站,附件也没有 —— 删掉就是删掉(早期版本给附件配过回收站,后来去掉了,理由见「附件孤儿清理」一节)。
删掉「正在看的那个会话」不会把界面留在死状态
主视图(右侧对话面板)由 ui-workspace 的 uiWorkspace 服务持有,钉在一个 mainReference 上。DSH 只在会话被归档时让它自动让位(clearArchivedCurrent)—— 会话被彻底删除时不会,引用还指着已经不存在的 id,输入框就退化成「会话不可用」。
所以 client 半在删除前先取两个快照,删完按下述规则处理:
| 删的是 | 行为 |
|---|---|
| 当前正在看的会话 | 立刻开一个新会话(与侧栏「新会话」按钮同一条路:uiWorkspace.startSession(workspaceId),复用该工作区的空白会话,没有就新建) |
| 其它会话 | 什么都不做,你正在看的不受影响 |
三个细节:
- 工作区跟着走:新会话开在被删会话所属的那个工作区里。
- 工作区没了就不硬塞:若该工作区已随会话一起消失,改传
undefined,交给内置的"就近工作区"兜底,而不是让内置流程弹一个错误提示。 - 兜底不留死界面:若 4 秒后主视图仍钉在已删 id 上(新会话没开起来),直接
clearMain()退到空态 —— 宁可空白,也不给你一个「会话不可用」的输入框。
uiWorkspace 是软依赖:走 ctx.get("uiWorkspace")(cordis 语义下不受 inject 约束、缺失返回 undefined),所以拿不到它只会让跳转静默失效,不会拖垮删除本身。
级联删除:只链子代理,绝不碰分叉
子代理会话是独立落盘的会话工件(与父会话平级放在同一个 slug 目录里),header 长这样:
{ "id": "<child-id>", "parentSession": "<parent-id>", "origin": "subagent", "delegationDepth": 1 }
不做级联就会留下孤儿:父会话没了、子代理却还在磁盘上,甚至继续作为独立会话行出现在侧栏。所以删除时会:
- 扫全部会话工件的首行 header —— zstd 只解压到第一个换行就停,不展开整份日志;
- 只把
origin === 'subagent'的会话放进血缘图,顺着parentSession递归收集后代(子代理还能再起子代理,深度 2、3…); - 先删后代、再删自己。
主会话 ─┬─ 子代理 → 一起删 ✓
└─ fork 出来的副本 → 保留 🔒
└─ 它的子代理 → 也保留 🔒(级联不穿过 fork)
🔒 分叉会话受保护:用户主动 fork 出来的会话同样带
parentSession,但没有origin标记 —— 它们永远不进血缘图,删父会话时一律保留。这条边界有专门的测试用例守着。
fork 出来的会话是自包含的:分叉那一刻,历史以数据形式(seed)写进新工件,seedLength 只是水位计数、不是指向父文件的引用。所以删掉父会话,副本的内容完整、可以继续对话;唯一的残留是它 header 里的 parentSession 变成悬空血缘(仅元数据,不影响使用)。
想只删目标会话、不动它的子代理,请求体加 {"cascade": false}。
想先看看会连带删掉谁,GET /session-menu-delete/api/lineage?sessionId=<id> 会列出全部后代。
附件孤儿清理:看一眼就等于引用一次
附件不塞在会话日志里,而是内容寻址的独立对象:
<DSH_HOME>/attachments/v1/objects/<前2位>/<内容的 sha256>
日志里只留一句引用。好处是同一张图只存一份(去重);代价是删会话只摘引用、不动物件,于是攒下没人引用的孤儿。
图片在磁盘上有两个位置,别搞混(只有第一个是本插件管的):
| 位置 | 是什么 | 能删吗 |
|---|---|---|
attachments/v1/objects/<前2位>/<sha256> |
持久附件,一份原始图,会话历史按哈希引用它 | 删了会话就裂图,只能按下面的规则清 |
cache/attachments/request-images/<变体哈希> |
DSH 自己给模型请求生成的图片版本(按路由缩放/重新编码后缓存,跨轮复用)——不是本插件写的 | 随便删,下次读取自动重建 |
objects/<前2位>/这层分片目录不会自己消失:早期版本只搬走文件、留下空壳,在文件管理器里看着就是"一堆空文件夹"。现在删完对象会回头检查动过的分片、空了就删(有测试守着)。
⚠️ 删除不可撤销 —— 本插件没有回收站。(早期版本有一个,后来去掉了:见下方"为什么不要回收站"。) 所以
--clean/POST /attachments/clean是真删,删之前先用只读预览看清楚。
清理有两个入口,共用同一套判据 —— 唯一区别是新鲜度窗口只作用于后者:
| 入口 | 触发方式 | 清理范围 | 新鲜度窗口 |
|---|---|---|---|
| 随会话删除顺手清(默认开启) | 点菜单里的「删除会话」 | 只清引用者全部落在本次被删集合内的附件(含级联的子代理);被 fork 副本或其它会话共享的一律不动 | 不适用(见下) |
| 独立清理 | POST /attachments/clean |
全库范围内所有孤儿 | 生效 |
两者都是直接删除。顺手清理可用请求体 {"attachments": false} 关掉。
为什么顺手清理不套新鲜度窗口? 窗口防的是"引用还没落盘",而能进这个集合的前提恰恰相反:引用已经在盘上、且引用者全都要被删掉 —— 主人没了,它不可能再被谁需要。 早期版本在这里也套了窗口,代价是:删掉一个刚读过图的会话,那些图被静默跳过,既不删、也不留任何痕迹,变成永远没人管的孤儿(实测踩过一次,一次 11 张、1.49 MB,因为它们只有 1–5 分钟大)。 落盘实时性由 DSH 保证(实测每个会话日志的 mtime 就等于它最新事件的时间,没有可观测延迟)。
为什么不要回收站? 它救不了真正想救的东西:删错会话时,会话日志本身不可恢复,把附件还原出来也没有任何会话引用它们,等于一堆没主的图片躺在磁盘上。而它能救的唯一场景(误判"没人引用"→ 活会话裂图)代价换不来一个看不见、不会过期、只有 CLI 才能操作的目录。去掉之后判据反而更硬:进不了"确定没人再用"这一步,就一个字节都不删。
为什么删会话不干脆"把它引用过的图全清掉"?因为附件是共享的:fork 会复制历史,历史里的图片引用也跟着复制。若照着"这个会话引用过哪些图"去删,副本里的图会集体变裂图。判据必须是引用者是否全部落在此次被删的集合内,而不是"这个会话引用过它"。
清理这件事有个反直觉的坑:"看一眼"就等于"引用一次"。用 read_image 读一张图,DSH 会把它登记成该会话的图片附件并写进事件流 —— 若删除依据是一次陈旧的扫描,就会删掉一个其实已被引用的对象,之后组装模型请求时报 Attachment object is missing.(真踩过,当时靠一个临时探针文件才手工救回来)。同理,别用文本搜哈希来判断"有没有人引用":探针自己打印的哈希被写进日志后,会造成 100% 的假命中。所以清理走三条规则:
- 删除瞬间重算引用,绝不复用上一次的扫描结果;
- 结构化解析
attachmentId(递归遍历事件 JSON,含tool/result里的图片),不做文本匹配 —— 文本搜 64 位 hex 会把日志里任何偶然的长十六进制串都当成引用(实测:探针自己打印的哈希写进日志后,11/11 全部假命中); - 新鲜度保护:独立清理时,mtime 在窗口内(默认 15 分钟)的孤儿一律跳过 —— 刚上传、刚被看一眼的图,其引用可能还没落盘;级联删除不适用这条(主人已经没了)。
没有回收站之后,这三条规则就是全部的防线,所以一条都不能省。
# 1. 先看会删谁(只读,不动文件)
Invoke-RestMethod "http://127.0.0.1:19387/session-menu-delete/api/attachments/orphans" | ConvertTo-Json -Depth 5
# 2. 确认后删除(不带 confirm 同样只预览)
Invoke-RestMethod -Method Post "http://127.0.0.1:19387/session-menu-delete/api/attachments/clean" `
-ContentType "application/json" -Body '{"confirm":true}' | ConvertTo-Json -Depth 5
安装
把本仓库放到本地任意目录,然后用它的绝对路径安装:
dsh plugin --profile desktop add "file:<本仓库所在目录的绝对路径>"
dsh plugin --profile <name> <args> 会把参数转发给 pnpm,所以包由 pnpm 管理(nodeLinker: hoisted)。
装完还必须让 DSH 知道要加载它,二选一:
- 在 DSH 的 「插件」页面里启用(会写
dsh.profile.bundles); - 或手工把
"dsh-session-menu-delete"加进~/.dsh/profiles/desktop/package.json的dsh.profile.bundles数组。
改动需要重启 DSH 生效(bundle 与 client 半都在启动时加载)。
file:依赖是「拷贝」安装,而且 pnpm 不会替你刷新这份拷贝。(nodeLinker: hoisted,实测 pnpm 11.7.0) 改完源码后add/install/install --force都会回一句Already up to date并留下旧副本; 把node_modules/dsh-session-menu-delete删掉再install也不会重建(随后 DSH 启动会报cannot resolve profile bundle)。可靠的做法是删掉旧目录后手动同步一次:$src = "<本仓库绝对路径>"; $dst = "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-session-menu-delete" Remove-Item -Recurse -Force $dst Copy-Item -Recurse -Force $src $dst Remove-Item -Recurse -Force "$dst\.git"校验是否同步到位(两边哈希应一致):
(Get-FileHash "$src\lib\client.js").Hash; (Get-FileHash "$dst\lib\client.js").Hash
卸载
dsh plugin --profile desktop remove dsh-session-menu-delete
再从 dsh.profile.bundles 里删掉 "dsh-session-menu-delete" 这一行,重启 DSH 即完全还原。
HTTP 端点
| 方法与路径 | 作用 |
|---|---|
POST /session-menu-delete/api/delete |
删除会话(默认级联子代理);body { sessionId, cascade? } |
GET /session-menu-delete/api/lineage?sessionId=<id> |
预演级联:列出该会话全部子代理后代 |
GET /session-menu-delete/api/attachments/orphans |
预览附件孤儿(只读,不动文件) |
POST /session-menu-delete/api/attachments/clean |
删除孤儿;body { confirm: true, freshMinutes? } |
GET /session-menu-delete/api/health |
存活检查,回显各根目录与能力标记 |
attachmentsRecoverable: false是/health里刻意回显的能力标记 —— 提醒任何调用方:删掉的附件捡不回来。
运维工具(tools/)
两个小脚本,不依赖正在运行的 DSH:它们用 mock 的 cordis ctx 把插件真实的 host 半加载起来,
直接调它自己的 HTTP 端点 —— 跑的是与线上完全相同的那份代码,所以改完插件不必等重启就能验证。
DSH_HOME 环境变量优先,缺省 ~/.dsh。
node tools/check-attachments.mjs # 体检(只读):对象 / 引用 / 孤儿,一项不改
node tools/check-attachments.mjs --clean # 删掉孤儿(真删,不可恢复)
node tools/delete-session.mjs --dry <会话 id> # 只列血缘,不删任何东西
node tools/delete-session.mjs <会话 id> [更多 id...] # 走菜单项同一条代码路径删除
check-attachments.mjs 默认永远只读,只有显式 --clean 才动手(端点那侧还要求 confirm);
它也会顺带提示旧版回收站目录的残留(本版已不认识那个目录)。
delete-session.mjs 什么时候用得上:会话已经没有工作区归属、侧栏里点不到(工作区先被删了),
或者 DSH 没重启而你想清掉磁盘上的残留。两点限制写在它自己的文件头里:离线跑动不了工作区账本
(拿不到 DSH 的内存态服务,侧栏那行可能还在),以及别删此刻正被 DSH 打开的会话。
结构
| 文件 | 作用 |
|---|---|
lib/index.js |
host 半:cordis 插件,会话删除 + 血缘扫描 + 附件孤儿清理 + HTTP 端点 |
lib/client.js |
client 半:Module Loader 包(factory(require),无构建步骤),注册菜单项 |
cordis.patch.yml |
bundle 层 patch:往插件树里 insert 一行 |
tools/harness.mjs |
离线脚手架:mock ctx 加载 host 半,把 HTTP 端点暴露成 call(method, path, body) |
tools/check-attachments.mjs |
附件体检 / 删除孤儿 |
tools/delete-session.mjs |
离线删除会话(含 --dry 预演血缘) |
test-host.mjs |
host 半离线冒烟测试(71 项:级联 / fork 保护 / 附件清理 / 顺手清理 / 新鲜度边界 / 分片收尾 / 端点下线) |
test-client.mjs |
client 半离线冒烟测试(34 项:菜单注册 / 点击链路 / 删当前会话后开新会话 / 各种兜底) |
测试用临时 DSH_HOME + mock 服务跑,工件用真实的多帧 zstd 写(header 帧 + 正文帧),
级联血缘走的是与线上完全相同的解压路径;全程不碰真实会话数据:
node test-host.mjs
node test-client.mjs
已知边界
- 点击即删:没有二次确认,误点无法撤回(仅在删除失败时
alert报错)。 - 附件也是直删:没有回收站、没有 undo。误判的代价不可恢复 —— 所以先跑只读预览(
GET /attachments/orphans/tools/check-attachments.mjs)再动手,永远不会错。 - 删掉当前会话时会自动开一个新会话;
uiWorkspace服务缺失或新会话开不起来时退到空态,不会出现「会话不可用」的死界面(详见上文)。 - 级联只认
origin === 'subagent';fork 出来的会话(有parentSession、无origin)一律不删。 - 附件清理只动
attachments/v1/objects下的对象,判据是会话日志里的结构化引用;"界面上正在显示、但引用尚未落盘"的图由新鲜度窗口兜底(窗口内一律不碰)—— 该窗口只作用于独立清理,级联删除不看它(见上文)。给出「孤儿」结论时,别只用文本搜哈希:探针自己打印的哈希写进日志会 100% 假命中,必须解析 JSON 里的attachmentId。 - 运行时若
node:zlib没有 zstd 支持,血缘扫描读不到 header,级联会退化为只删目标会话;附件清理同样读不到引用,此时会一个都不删(保守方向,不会误删)。 - 对内部服务的访问一律窄化取值:拿不到
workspaceRegistry就跳过"账本摘除"这一步,而不是让整个删除失败(离线工具正跑在这条路径上)。代价是这种情形下账本不会更新 —— 实时运行时有inject保证服务存在,不会走到这里。 - 删除回报的
filesRemoved/cacheRemoved是如实的:rm(..., {force: true})对不存在的路径不报错,所以这两项都不能拿"没抛异常"当"删掉了"(投影缓存那条先stat确认;工件那条本来就走locateSessionDir定位)。 - ⚠️ 已知未修:插件端点不校验来源。
/session-menu-delete/api/*是插件自己的前缀路由,不经 DSH 的鉴权网关(实测:DSH 自身路由GET /→ 401,插件路由GET /…/api/health→ 200),且 handler 不检查Content-Type与Origin。后果:本机上的任何程序、以及你浏览器里打开的任意网页都能调用删除类端点 —— 网页用Content-Type: text/plain发 POST 属于"简单请求",不触发 CORS 预检,浏览器拦不住发送(实测该请求返回 200 并进入删除流程)。当前状态:有意保留未修。 最小修法是要求一个自定义请求头(如x-dsh-plugin-call: 1):自定义头必然触发预检,服务端不返回 CORS 头 → 预检失败 → 网页发不出来;本地工具补上这个头即可,零成本。 - client 半优先用
@deepseek-ai/dsh-client-ui-primitives的MenuItemButton;该组件缺失时退回自绘role="menuitem"按钮,不会让侧栏渲染崩掉。 file:依赖指向本目录。移动或删除这个源目录后,需要重新dsh plugin add才能再安装/更新;平时改源码则按上文「安装」一节的同步步骤手动刷新副本。
License
MIT
No comments yet. Be the first to write one.