zfdx123/dsh-atelier--packages-dsh-session-cleaner ↗★ 0
@zfdx123/dsh-session-cleaner
会话清理:在运行中的 web 运行时里彻底删除 DSH 会话——实时 store 条目、工作区记录、磁盘产物与投影缓存行一并清掉,并在会话行菜单里加一个删除入口。 适合需要彻底清理DSH会话残留、释放磁盘空间的用户。
Install
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:zfdx123/dsh-atelier#8a3566cc5762e3e6d26b23c75c6ee3c4a336304f&path:packages/dsh-session-cleanerREADME
Read the full README ↗@zfdx123/dsh-session-cleaner
给 DSH(DeepSeek Harness)补上删除会话的能力——从运行中的 web 运行时里删,不需要重启。DSH 只有「归档」:workspace.archiveSession 把会话 id 加进一个注册表集合,文件仍留在磁盘上;不存在 session.delete。本插件补上这个缺口:一次删除会清掉四个表面——live store 条目、工作区记账(归档集合与各 workspace 的会话槽)、磁盘产物目录、投影缓存行——并从三个入口暴露出来(侧边栏会话行的 ⋮ 菜单、设置页「会话清理」、一个 HTTP 路由),删除确认框复用 DSH 自己的 UI 原语,与「删除工作区」那个框同款。当前版本 1.0.0,面向 DSH ^0.1.6-alpha.1。
安装
# 从 npm 安装单个包
dsh plugin --profile web add @zfdx123/dsh-session-cleaner
# 一次装齐整套(MCP 管理器、技能管理器、记忆、CodeGraph、钩子排序、会话清理、Superpowers)
dsh plugin --profile web add @zfdx123/dsh-atelier
# 然后重启 dsh web(bundle 不做热加载)
安装时 dsh plugin 会把带 dsh.bundle.patch 的依赖自动补进 dsh.profile.bundles。卸载是可逆的:dsh plugin --profile web remove @zfdx123/dsh-session-cleaner + 重启。
宿主那一半改完要重启:dsh web 在启动时装配 bundle,客户端那一半不做热加载,所以改完代码后重启进程 + 刷新页面才会生效。
本地开发有两种装法,区别只在落盘形态:
# 软链(源码改完 profile 立刻就是新的,只需重启 dsh)
dsh plugin --profile web add link:/absolute/path/to/dsh-atelier/packages/dsh-session-cleaner
# 拷贝(Windows 上 file: 落成的是拷贝,改完要重新 add)
dsh plugin --profile web add file:/absolute/path/to/dsh-atelier/packages/dsh-session-cleaner
快速上手
侧边栏 ⋮ 菜单
会话行右侧 ⋮ → 「删除会话」,位置紧跟在**「归档会话」下面**。点击 → 确认框 → 该行立即消失。会话正在运行时该项置灰;已打开(有 agent 附着)的会话会被服务端拒绝,请先关闭它。
设置页「会话清理」
列出全部会话:workspace 成员、未分组的游离会话、已归档会话、以及旧格式(裸 UUID)会话。
按 workspace 分组显示,不再混在一条时间线里:
- 组顺序与侧边栏一致(宿主给的 workspace 顺序),「未分组」放最后;组内按最近活动倒序,空组不渲染;
- 组头是
名称 · N 个会话,点一下折叠/展开(默认展开); - 已归档 / 旧格式只是行内徽章,会话仍留在它所属的 workspace 组里——「归档」是行的属性,不是一个分组;
- 每行可删除;已归档的行额外提供取消归档。
搜索框:命中组名则整组保留,否则按行标题过滤;搜索时命中的组自动展开(命中藏在折叠的组里等于没搜到),组头计数改为 命中/总数。
HTTP
POST /api-ext/session.delete
Content-Type: application/json
{ "sessionId": "session-1bb8d361-ea6b-4b92-bab2-c858c92e8822" }
同源页面里可直接调用:
await fetch('/api-ext/session.delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: 'session-…' }),
}).then(r => r.json());
响应沿用 host 的 JSON 信封:
{ "ok": true, "value": { "liveBefore": true, "liveDetached": true,
"accounting": { "unarchived": true, "detached": ["…"] },
"files": { "root": "…", "removed": ["…"], "failed": [] },
"projection": { "ok": true, "deleted": true } } }
{ "ok": false, "error": { "code": "refused", "message": "…" } }
状态码:200 成功;400 bad-request(id 非法 / JSON 坏);405 method-not-allowed(方法不是 POST);415 unsupported-media-type(content-type 不是 application/json——两个路由都只收 JSON);409 refused(会话被打开);500 internal。
排查:诊断路由
菜单项是 DOM 增强(见「已知限制」),所以客户端会把每一步观测 POST 到 POST /api-ext/session.cleaner.diag({ "report": { "event": "…", "detail": … } }),同一个路由把这份内存环形缓冲(上限 100 条,不落盘)读回来;带一个 inspect 字段时还返回该会话的只读删除面报告——id 是否合法、sessions 根、会被删的产物目录、有没有 live 条目、agent 状态、是否已归档、属于哪些 workspace:
await fetch('/api-ext/session.cleaner.diag', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ inspect: 'session-…' }),
}).then(r => r.json());
它不删任何东西,是「插件到底看见了什么」的安全查看方式。
它做什么
-
删除四个表面。 一次
deleteSession依次做四件事:- live store:如果该会话有内存条目就 detach 掉(
SessionStore.enter()返回的那个 disposer),并把该会话从前端的会话列表里摘掉。踩过的坑:光 detach 不会让前端掉行。 前端唯一的「会话没了」通知是
session/disposed,而SessionStore.detachEntered只在entry.announced === true时才发它——只有 agent 真正打开过的会话才会置位(sessions.enter只由 agent loop 调用;session.list只是读 live 条目、其余从持久化summarizeCold,不会把冷会话 prepare/enter 进 store)。而本路由拒绝一切有 agent 附着的会话,所以它删掉的会话根本没有内存条目、任何事件都不会发。detach 那一步因此是防御性保留的:万一有「agent 已消失、条目还在」的残留,也不能让它变成一个指向已删文件的幽灵行。 真正让界面掉行的是客户端:删完由客户端自己调ctx.sessions.handleSessionRemoved(id)(正是api-session/removed中继调用的那个方法),再refresh()跟 host 基线对账——host 基线来自磁盘上的会话日志,已经不含它了。 - 记账:从全局归档集合移除,并从每个记账了它的 workspace 移除(host 会据此推
archived帧给前端,两个设置页的归档集合因此同步)。 - 磁盘:删除
/ /(遍历全部 project 目录,只删名字恰好等于 id 的目录)。 - 投影缓存:删除
session_projcache里该会话的行(文件搜索索引是派生的,会自行收敛);域没打开时该行保留,只写一条 warn 日志,删除本身仍然成功。
- live store:如果该会话有内存条目就 detach 掉(
-
三个入口共用同一套语义。 菜单、设置页、HTTP 都走同一个
deleteSession;两个界面入口还共用同一个确认框:用 DSH 自己的 UI 原语(@deepseek-ai/dsh-client-ui-primitives的Modal+Button)搭的标题 + 一句说明 +[取消]/[删除],删除按钮是原生那种红字描边(--dsw-alias-state-error-primary)。- 不使用
window.confirm/window.alert——浏览器原生弹窗在这里既不统一也很丑; - 删除过程中框内显示「正在删除…」;被拒绝(会话打开中)时框不关,错误就显示在框里;
- 只有
Esc、点遮罩、点[取消]或删除成功才会关闭。
- 不使用
-
⋮ 菜单与图标。 菜单是命令式 DOM、没有自己的 React 树,所以这一侧的确认框挂在一个独立的 React root 上(
react-dom/client的createRoot),用完即卸载;确认框本身与设置页是同一个组件。三个图标同样用 DSH 自己的图标组件,不自绘:搜索框IconSearchOutline16、组头折叠箭头IconChevronDownOutline14、⋮ 菜单里的IconTrashOutline16。(图标组件只收{size, className},转发不了style/aria-hidden,所以定位、旋转、颜色挂在图标外面那层 box 上,两条路径共用同一个 box。)⋮ 菜单里那个图标由 React 渲染进一个临时容器、把 SVG 取出来后再把临时 root 卸掉——菜单项背后不留挂着的 root。 -
组件库缺席就整体降级。 组件库不存在或缺少其中任一成员时,插件整体退回自绘 SVG(确认框同时退回
window.confirm):loadPrimitives的形态检查是全有或全无的,所以「只到了一半」的组件库不会让界面变成一半原生一半自绘。 -
安全边界。 id 必须匹配
^(session-)?$,否则直接bad-request,不会进入任何路径拼接;有 agent 附着的会话拒绝删除(running 或 idle 都算「已打开」)——不会把别人正在用的会话从底下抽走;只删「位于 sessions 根之下、且目录名恰好等于该 id」的目录;两个路由都校验 HTTP 方法(POST)和 JSON content-type(不符返回 415)。 -
sessions 根与宿主同源。
sessionsRoot()复刻宿主的DSH_HOME处理:$DSH_HOME为空或纯空白视为未设置 →~/.dsh/sessions;~、~/、~\先展开再resolve;相对路径按工作目录解析——所以插件和持久化后端永远指向同一棵树。 -
形态:bundle 而不是动态插件。 同样的功能先用动态 Cordis 插件做过一版(未随本仓库发布)。bundle 形态在三点上更好:
维度 动态插件 bundle(本包) 持久性 进程级,DSH 重启即失效 装进 profile,重启仍在 跨平台 沙箱不给 Node API,只能 shell 出去(要分平台写引擎 + 处理引号) 直接 node:fs,无 shell、无平台分支入口 只能挂设置页 设置页 + 侧边栏 ⋮ 菜单 + HTTP 路由
前置要求
- DeepSeek Harness
^0.1.6-alpha.1(engines.dsh),且是 web profile:宿主侧声明webServer、workspaceRegistry、sessions、agents、storageDomain,客户端侧要slots/locale/sessions/uiWorkspace - Node
^22.19.0 || >=24.0.0 - peer
@deepseek-ai/cordis ^4.0.2;@deepseek-ai/dsh-agent、@deepseek-ai/dsh-host-webserver、@deepseek-ai/dsh-session、@deepseek-ai/dsh-workspace为可选 peer(都声明为^0.1.6-alpha.1) - 会话树的位置由
DSH_HOME决定;插件的sessionsRoot()与宿主解析规则一致
已知限制
- ⋮ 菜单靠 DOM 增强。 DSH 没有给行菜单公开 Slot,所以只能在菜单打开时往里插一项。识别方式是语义的:同一处新增(或从它向上 6 层)同时含「归档会话」与「分叉会话/重命名」两个文案才认定为会话行菜单,插入点是包含两者的最小元素;行优先取刚点击的 ⋮ 触发器(
aria-label前缀或rowActions容器,3 秒新鲜度窗口),窗口外才退回closest('[role="treeitem"]')——不靠矩形距离猜测。若 DSH 改了这些文案或结构,该项会静默不出现(设置页仍然可用),诊断路由会记录跳过原因。 - 会话 id 从 React fiber 里读。 行的 DOM 上没有任何携带 id 的属性,所以从行元素的
__reactFiber$*/__reactInternalInstance$*向上找 10 层,取memoizedProps.node.id(或props.sessionId/props.node.sessionId)。拿不到才退回标题反查(走/api/session.list的目录,标题重复时跳过注入,避免删错);目录取不到时只损失「运行中」标记,行仍可用。 - 会话打开中(running 或 idle agent)时拒绝删除,不代用户关会话。
- 删除不可恢复:文件、记账、live 条目、投影缓存行都会消失。
- 不广播
session/disposed。 被删的冷会话本来就没有 live 条目,所以宿主不会发任何「会话没了」的事件——本页面由客户端自己把行摘掉;其他已打开的页面/标签页要刷新才会同步。 - 投影缓存域没打开时那一行会保留(只写 warn 日志,删除仍返回成功):缓存是折叠快捷方式而不是权威,下次冷读会自行收敛。
- 宿主半改动要重启进程;客户端半刷新页面即可,但 bundle 整体不做热加载。
开发
node --test test/host.test.js # node:test 版(host)
node test/run.js # host + client 全部用例,平铺断言(受限沙箱里也能跑)
npm test # 上面两条依次跑
test/cases.js 是 host 用例,只操作 mkdtemp 出来的 scratch 目录:deleteSession 的第三个参数 { root } 就是为此留的测试缝,不会碰到真实会话。
test/client-cases.js 是客户端用例:把 client.js 挂在一个极简的 window.__ModuleLoader__ 垫片上、配一个照抄真 React 语义的替身(props.children、函数组件即时展开、hook 槽位跨渲染存活并在 setState 时重渲染),再给 Modal/Button 提供保持契约的替身(Modal 关闭时渲染 null),于是设置页可以无浏览器、无 DOM、无框架地被完整驱动——点删除、在确认框里点取消/删除、点组头折叠、往搜索框里打字——并断言它渲染了什么、对注入的服务做了什么。⋮ 菜单那一路另配一个迷你 DOM(节点、属性、子节点,以及安装器真正查询的那几个选择器),于是菜单项也能被真正构建出来、点进去删一次。图标用例的组件库替身分三档(完整 / 缺图标 / 模块表缺失)并记录「哪个图标组件被调用」,同时区分「React 渲染出来的 svg」与「自绘的 svg」——所以「用原生图标」和「退回自绘」两条路径都有断言。
许可
MIT