Skip to content

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)按这个顺序执行,顺序是有意的:

  1. workspaceRegistry.archiveSession(id, { stopActivity: true }) —— 先写持久化归档集。它是"被停掉的工作再次唤醒"时的准入闸门,也让内置的停止事件把在跑的工作收掉。这一步写的标记在第 5 步会被摘掉,所以它只是过程中的护栏,不会留在最终状态里。若会话因某种原因不在注册表里,这一步只记 warning 并继续(真正的授权是"持久化里有它的 header")。
  2. 把活着的 Session 从宿主内存里摘掉(detachLive)—— 这是让"删除"名副其实的一步。见下节。
  3. 第一遍文件系统清理:删会话日志目录(不是单个文件):<DSH_HOME>/sessions/--<mangle(cwd)>--/<encode(id)>/,里面有 session.lock 和历史各代 session.vN.jsonl.zstd;同时删投影缓存 <DSH_HOME>/storages/session_projcache/sessions/<sessionId>.json。
  4. 反复扫尾:摘掉内存会触发 session/disposed,而后端要"最后排空并关闭写句柄"、投影缓存要"live→cold 落一次盘",两者都是不可 await 的异步写,都可能把我们刚删掉的文件重新写出来。所以扫描一直做到连续两轮什么都没找到为止(上限 12 轮 × 80ms)。
  5. 摘掉所有持久引用:unarchiveSession(归档标记)、unpinSession(置顶),以及每个 Workspace 实体上的 detachSession(工作区登记)。这些各是一次自己的持久化写入,都走注册表的同一条 mutation 链。
  6. 确认遍:再清一次,然后逐个核对日志目录与缓存是否真的都不在了;留下的会作为 warnings 返回,而不是假装成功。
  7. 为每个真正清干净的 id 触发 api-session/removed(摘内存时内置的 controller 已经发过一次,这一步是兜底)。
  8. 顺手删掉本插件早期版本留下的 ~/.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,确认不需要回滚后可以自行删除。