dsh-session-delete
Verified@szhl/dsh-session-delete · v1.0.1 · MIT · Web UI
DeepSeek Harness plugin: delete a conversation for good — the log, the projection cache, the archive marker, the workspace registration and the live Session object, subagent sessions included. DSH ships archive only.
Install
dsh plugin add @szhl/dsh-session-delete Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Readme
@szhl/dsh-session-delete
在侧栏会话的「⋯」菜单里加一项删除会话,二次确认后把这次对话从磁盘上彻底抹掉,不留痕迹:日志、投影缓存、归档标记、置顶、工作区登记、浏览器里那份草稿,全部清掉,并且把它从宿主内存里摘掉,所以侧栏那一行不会再回来。
DSH 本身没有这个功能,而且是刻意没有:
@deepseek-ai/dsh-session-persistence文档:「无删除或保留接口——剪枝已存储会话属于带外后端维护。」@deepseek-ai/dsh-workspace只有archiveSession,没有deleteSession;文档明确写着「会话删除与文件夹移除是彼此独立且尚未提供的功能」。- 侧栏文档明确写着:「没有 Session 删除:会话可以归档但绝不会被删除。」
归档只把会话从列表里藏起来,文件还占着磁盘。这个插件补的是真正的删除。它按 DSH 的插件架构实现(dsh.bundle.patch + dsh.client),不改任何 App 文件,也不碰任何生成的 RPC 接口。
安装
dsh plugin --profile desktop add @szhl/dsh-session-delete
--profile 换成你自己的 profile 名(桌面版是 desktop);dsh plugin 会把后面的参数转交给 profile 里的 pnpm。装完重启 DSH 生效。
(本地开发时也可以按文末「安装位置」用 link: 挂进 profile。)
用法
侧栏任意会话行悬停 → 点行尾「⋯」→ 菜单最下方(分隔线之后、红色)的删除会话…。
确认框会先列清楚要删掉什么:占用磁盘、子代理会话数量、工作目录、存储位置;如果该会话还有正在跑的回合/子代理/后台任务,会一并列出,主按钮变成停止并删除。
确认是两段式的,和内置的「停止并归档」一致:第一次提交不带 stopActivity,宿主若发现还有工作在跑就回 409 active 并附上清单,对话框把它列出来、主按钮才变成「停止并删除」,再点一次才真的动手。这样「掐掉正在跑的回合」永远是一次显式确认,而不是默认行为。
- 当前正在主视图里打开的会话可以删。菜单项不再置灰;确认之后会先把主区域切到一个可见的普通会话,再执行删除 —— 否则主区域会停在一个刚被删掉的目录上。
- 候选会话是严格筛过的:排除子代理会话、有父会话的会话、已归档的会话。
这一步不能省:
session/list返回的ids里含子代理和归档(实测本机 37 条里 29 条是子代理), 而且按updatedAt倒序排、子代理没有用户 prompt 所以永远排在最前 —— 随手写ids.find(id => id !== target)会挑中目标自己的子代理, 而它就在同一次删除的 descendants 里,等于"切到马上就要被删的那一个"。 归档会话更糟:切过去会被clearArchivedCurrent()把主视图清空。 - 除目标外一个候选都没有时(例如全新 profile 的首次会话),会在同一工作区里新建一个空会话再删; 连工作区都定位不到才会报「切不走」。
- 候选会话是严格筛过的:排除子代理会话、有父会话的会话、已归档的会话。
这一步不能省:
- 删掉的那一行会立即消失,不用刷新页面,刷新了也不会回来(见下)。
- 运行中的 DSH 宿主进程自己的会话(
DSH_SESSION_ID,只有宿主环境里设了才会生效,桌面宿主实测没有设)会被拒绝。 - 刚创建、还没落盘的会话会被拒绝(
409 unpersisted):它只存在于宿主内存里,磁盘上还没有日志目录,没有东西可删。给它发一条消息落盘后再删。 - 上一次删除留下的残影可以再点一次删(对话框会换成「清理干净」)。那种行的日志已经没了,但归档标记还在 —— 它会把这行、内存里的对象、归档集和工作区登记一起清掉。
删除都做了什么
宿主半边(index.js)按这个顺序执行,顺序是有意的:
workspaceRegistry.archiveSession(id, { stopActivity: true })—— 先写持久化归档集。它是"被停掉的工作再次唤醒"时的准入闸门,也让内置的停止事件把在跑的工作收掉。这一步写的标记在第 5 步会被摘掉,所以它只是过程中的护栏,不会留在最终状态里。若会话因某种原因不在注册表里,这一步只记 warning 并继续(真正的授权是"持久化里有它的 header")。- 把活着的
Session从宿主内存里摘掉(detachLive)—— 这是让"删除"名副其实的一步。见下节。 - 第一遍文件系统清理:删会话日志目录(不是单个文件):
<DSH_HOME>/sessions/--<mangle(cwd)>--/<encode(id)>/,里面有session.lock和历史各代session.vN.jsonl.zstd;同时删投影缓存<DSH_HOME>/storages/session_projcache/sessions/<sessionId>.json。 - 反复扫尾:摘掉内存会触发
session/disposed,而后端要"最后排空并关闭写句柄"、投影缓存要"live→cold 落一次盘",两者都是不可 await 的异步写,都可能把我们刚删掉的文件重新写出来。所以扫描一直做到连续两轮什么都没找到为止(上限 12 轮 × 80ms)。 - 摘掉所有持久引用:
unarchiveSession(归档标记)、unpinSession(置顶),以及每个 Workspace 实体上的detachSession(工作区登记)。这些各是一次自己的持久化写入,都走注册表的同一条 mutation 链。 - 确认遍:再清一次,然后逐个核对日志目录与缓存是否真的都不在了;留下的会作为
warnings返回,而不是假装成功。 - 为每个真正清干净的 id 触发
api-session/removed(摘内存时内置的 controller 已经发过一次,这一步是兜底)。 - 顺手删掉本插件早期版本留下的
~/.dsh/session-delete-diag.json(那份取证文件里只列了会话 id,早就不写了)。
为什么必须把内存里的对象也摘掉
浏览器这边的侧栏画的是宿主给的列表,而宿主给的列表是"live 优先"的:
async listSessions(signal) {
const persisted = ... // 磁盘上的
for (const session of this._ctx.sessions.list()) records.set(session.id, {...}) // 内存里的,覆盖前者
...
}
只要宿主还持有那个 Session 对象,它就一直在 session/list 里 —— 只删文件的话,刷新一次页面那一行就回来了,点开还能从内存里读到整段对话。而"能把它藏起来"的唯一现成手段就是归档标记,那正是"删了却只是归档了"的由来。
所以删除走的是 SessionStore 自己的卸载能力:enter() 给每个条目装了 detach,会话的持有方(fiber)在卸载时调的就是它;插件从 ctx.sessions.store 里取出同一个条目调 entry.detach(),效果与持有方自己卸载一致 —— 而且会发出 session/disposed,由 DSH 自己的消费者完成收尾:后端排空并关闭写句柄、投影缓存落盘、API-session controller 把那一行摘掉。实测(同一个宿主进程里):sessionQuery.listSessions() 摘之前包含它、摘之后不包含;session/disposed 确实派发;重复 detach 是幂等的。
ctx.sessions 没有公开的"按 id 释放"入口,所以这里是能力探测:store 不是 Map、条目没有 detach、或正在派发(announcing/appending)时会如实上报(stillLive: true / deferred),文件照样删干净;对话框在这种降级情况下会改用另一句提示,告诉操作者"页面刷新后这一行可能回来,DSH 重启后彻底消失",而不是假装一切正常。
为什么最后还要发一次 api-session/removed
文件在宿主上没了,并不等于浏览器列表里的那一行会消失:侧栏画的是客户端自己那份会话目录。摘内存会经由内置 controller 转发这个事件,冷会话则没有这条边;两种情况插件都自己补一次。客户端半边在删除成功后也会直接调用同一个入口(handleSessionRemoved)兜底,所以即使事件没送到,本地这一行也会立即消失。
子代理会话一并删除:靠 SessionHeader.parentSession 递归,但要求 origin === 'subagent'。fork 也带 parentSession 却没有 origin —— fork 是独立对话,绝不能被连带删除。
会清掉哪些痕迹
| 痕迹 | 位置 | 处理 |
|---|---|---|
| 会话日志目录(各代日志 + 锁) | <DSH_HOME>/sessions/--<mangle(cwd)>--/<id>/ |
删除 |
| 投影缓存 | <DSH_HOME>/storages/session_projcache/sessions/<id>.json |
删除,并扫掉卸载时重写的那份 |
| 归档标记 | workspace.json → global.archivedSessionIds |
摘除 |
| 置顶 | workspace.json → global.pinnedSessionIds |
摘除 |
| 工作区登记 | workspace.json → tables.workspaces[*].sessionIds |
摘除 |
宿主内存里的 Session |
宿主进程 | 摘除(行不会再回来,对话不再可读) |
| 浏览器里的会话分片 | localStorage 里键名含该 id 的条目(dsh.conversation.<id> 存着未发送的草稿) |
删除 |
| 本插件早期的取证文件 | <DSH_HOME>/session-delete-diag.json |
删除 |
不删的东西:~/.dsh/attachments/v1/objects/ 是按内容寻址、跨会话共享的,删除会话不会回收其中的对象(也就不会误删其他会话还在用的附件)。子代理会话的日志与缓存会一并删除;它们的附件对象同理保留。
安全边界
- 要真正删文件,会话 id 必须能在
sessionPersistence.list()里查到才会动手。这是首要的目录穿越防线。(唯一的例外是"归档集里的残影":它的日志目录必须已经不存在,此时不删任何文件,只清理内存与登记。) - 日志目录优先用后端的
locate({ id, cwd })求值;该方法未在 seam 文档中公开,因此另有等价回退实现(复刻projectKey/encodeSegment,不依赖会话格式版本号)。 - 计算出的目录必须严格位于
<DSH_HOME>/sessions之内,否则拒绝并记入warnings(此时该 id 也不会出现在deleted里)。 - 投影缓存文件名用的是原始会话 id,只在 id 满足存储域的
^[A-Za-z0-9_-]+$记录键字符集时才删除。 - 请求体上限 4 KiB;路由经
ctx.connection.requestRejection只应答本机操作者自己的浏览器。 - 删除不会在失败时假装成功:
deleted只列真正清干净的 id,其余进warnings,客户端用红色横幅如实显示(不再只console.warn)。
限制
- 内存里的 agent 对象会留到 DSH 退出。
ctx.agents没有按 id 释放的入口,agent 的生命周期绑在注册表自己的 fiber 上(AgentRegistry.create/resume用的是this.ctx),所以删不掉的只有这一个对象;但它已经没有对应的Session(列表、打开、读取都查不到它),而且它的写句柄已经被session/disposed关掉 ——session/event找不到 writer,不会再写盘。所以它既不占 UI,也不会重新长出文件。 - 有条加载时的收尾:
apply扫一遍归档集,凡"持久化里已经没有、日志目录也不在"的 id 都按残影处理 —— 摘内存、取消归档、退出工作区登记、补发一次api-session/removed。这是给本插件早期版本或被打断的删除擦屁股的修补路径;本版本跑完的删除不会留下这种标记。 sessionPersistence没有删除接口,所以这里直接操作文件系统。DSH 未来版本若改变存储布局,日志目录的回退实现需要跟着改(locate()存在时不受影响);若改变ctx.sessions的内部结构,摘内存会降级为"只删磁盘 + 如实告警",删除本身不会失败。
安装位置
已注册进 desktop profile:
~/.dsh/profiles/desktop/package.json——dependencies加link:,dsh.profile.bundles加入本包- 本包的
cordis.patch.yml(经dsh.bundle.patch生效)—— 插入本插件行,并把本插件目录(dsh-session-delete)加进hmr的root,与原有的 tencent-docs 两个 root 并列
卸载:从 profile 的 package.json 里删掉这两处,再 pnpm install,重启 DSH。
验证状态
index.js与client.js语法、package.json与两份 locale JSON、以及客户端字典的中英键位/占位符一致性均已校验。- 宿主侧行为测试:
.tools/test-session-delete.mjs,84/84 通过(合成目录树 + 假 ctx,包含一个带detach能力的真Map形状 store 和一个会重写投影缓存的session/disposed副作用)。覆盖:只扫 subagent 血缘不碰 fork、子代理与孙代理一并删除、无 cwd 会话、仓库外 cwd、投影缓存精确删除、卸载时重写的缓存被扫尾清掉、活动工作拒绝(409/active)、未知会话 404、注册表不认识该 id 也不阻断删除(只记 warning)、内存中未落盘会话 409/unpersisted、宿主自身会话 409、越界 id 不逃逸、locate()指到仓库外时拒绝删除并如实告警、优先采用locate()的路径、归档标记/置顶/工作区登记全都被摘掉、活着的会话被摘出内存且stillLive: false、ctx.sessions.store结构不符时降级但仍删文件并如实报告、Workspace 实体没有detachSession也不报错、残影的 inspect/delete 返回 200 且清理登记、加载时清掉不可达标记并同时摘掉那一行、仍被内存持有的残影在加载时也被摘掉、正常的已归档会话在加载时不受影响、早期版本的调试转储文件在删除时被一并清掉。 - 在真实宿主进程里确认过摘内存这条关键路径(临时探针,已移除):
ctx.sessions.store是Map、条目带detach;对一个临时建的活会话调用它之后,ctx.sessions.get()变undefined、sessionQuery.listSessions()不再包含它、session/disposed确实派发、重复调用是幂等的。 - 两条宿主路由已在运行中的 App 里确认注册并受信任围栏保护:
GET /dsh-session-delete.state不带 cookie →401、POST /dsh-session-delete.delete不带 cookie →401,对照不存在路径 →404。 - 宿主半边确实会热重载:实测改
index.js后约 8–15 秒内在同一个宿主进程里再次apply(HMR 只换模块,不换进程)。 - 客户端模块热重载不需要刷新页面:
client-hmr每 500ms stat 轮询一次各 bundle,而本插件的client.js是源码直供(exports["./client"]直接指向它,无构建步骤),所以改文件即时生效。 - 仍需人工点一次:删一个会话,确认 ①那一行立刻消失 ②刷新页面后不再出现 ③
~/.dsh/sessions与session_projcache里都没有它 ④workspace.json的archivedSessionIds/sessionIds里都没有它。
排查用的历史遗留文件
插件早期版本在 ~/.dsh/session-delete-diag.json 写过一次取证快照(只含会话 id 与 header 键名),本版本已不再写,并会在下一次删除时顺手删掉它。同类的历史遗留还有:
~/.dsh/storages/workspace.json.bak—— 早期手工 shell 脚本留下的备份<workspace>/delete-session-357295a6.sh—— 同一批手工脚本
这两个都含旧会话 id,确认不需要回滚后可以自行删除。