NIGHT576/dsh-delete-session

dsh-delete-session

Real deletion for DSH Web conversations: a session-row menu action and a Workspaces-header batch dialog; removes each session's log directory and projection cache. 给 DSH Web 侧栏补上真正的会话删除:右键菜单删单条、区头批量删多条,删掉日志目录与投影缓存。

待分类

description: "给 DSH Web 侧栏补上真正的会话删除:会话行右键菜单删单条、「工作区」区头批量对话框删多条;两者都会先归档会话,再删掉它在磁盘上的日志目录与投影缓存。" kind: "package-reference"

dsh-delete-session

English | 中文

概述

dsh-delete-session 给 DSH Web 侧栏补上产品本身没有的破坏性操作:真正删除一条对话。会话行的右键菜单多出一条红色**「删除对话」;「工作区」区头多出一枚红色垃圾桶按钮,点开带逐条勾选、标题过滤与全选的批量**对话框。两个入口都先把会话归档——归档集合是产品自带的持久过滤器,也是唯一能让会话从所有分组视图里消失的东西——再删除 $DSH_HOME/sessions/ 下它的日志目录与投影缓存里的对应条目,最后把该行从客户端会话列表里摘掉。最后这一步是必要的:任何被打开过的对话,其对象会一直留在 Host 进程里直到进程重启,而 Host 的会话列表会持续把它报回来。正在运行(agent 跑着)的对话会被拒绝,而不是照删。

目录


使用本包

作为 bundle 装进 DSH profile,然后重启提供 Web GUI 的那个进程:

dsh plugin --profile web add github:NIGHT576/dsh-delete-session

让它加载的 profile patch 就在本仓库里(cordis.patch.yml,由 dsh.bundle.patch 指向),所以 dsh plugin add 就是全部安装步骤:它把包装成 profile 依赖、把包追加进 dsh.profile.bundles,并在最外层应用这份 patch。Host 半边在启动时导入,所以需要重启进程;浏览器半边由页面拉取,下次刷新页面即生效。

如果你早先手工装过一份,先把 profile 里手写的那条 insert 行删掉再添加 bundle——同一个插件 id 被插入两次会让插件被组合两遍。

什么时候该用它

当部署里堆积的对话必须真正消失时用它:磁盘压力、共用机器、或者必须干净开场的演示。它不是可撤销的归档——归档集合只是把行藏起来的机制,日志目录是彻底删掉的。如果对话需要可恢复,请用产品自带的归档能力,别装这个插件。

两个入口

入口位置行为
删除对话会话行的 … 右键菜单二次确认后删除这一条
批量删除「工作区」区头里,搜索 / 视图选项 / 添加工作区 那一排中的垃圾桶按钮打开对话框,按最近更新倒序列出所有普通对话,逐条勾选

两者打开的是同一类对话框:标题、×、写明是哪个对话的说明,以及页脚的取消(默认获得焦点)与红色确认按钮。Escape 关闭,点遮罩关闭;删除进行中所有控件禁用,对话框无法被重复进入。

一次删除到底做了什么

  1. 先归档。 把会话加入 Workspace 注册表的归档集合(Host 侧 workspaceRegistry.archiveSession,客户端 uiWorkspace.archiveSession,后者还会把完整集合回显进客户端模型)。这一步必须在日志还在时完成:注册表会拒绝既非 live、又不在头部索引、也不在最新持久化列表里的 id。
  2. 删日志目录 —— $DSH_HOME/sessions/<项目目录>/<sessionId>/,递归删除、重试三次,然后复查,因此被吞掉的失败不会被当成成功上报。
  3. 删投影缓存 —— $DSH_HOME/storages/session_projcache/sessions/<sessionId>.json。它只是派生状态,留着也无害,但仍然删掉。
  4. 摘掉客户端那一行 —— sessions.handleSessionRemoved(id),这正是 Host 自己的 api-session/removed 事件在客户端的中继实现;被删的对话恰好是当前查看的那条时,再补一次 sessions.clear()。

其他东西一律不动:附件、别的会话、Workspace 记录,以及该会话在 Workspace 记账里占的那个 slot 都原样保留。

会被拒绝的情况

  • 运行中的对话会被拒,两边都拦——Host 检查 agents.get(id).status === 'running',客户端则把该行置灰并跳过。删掉一个正在被追加写入的日志,会让它带着残缺历史被重新创建出来。
  • 正在查看的那条允许删,对话框会多一行说明。删它会清空选中项(归档集合的观察者自己就会做),插件再补一次显式 sessions.clear() 兜底,于是你落到「没有会话」的空状态。
  • 失败是按条计的。 批量删除逐条汇报:删掉的从列表消失,没删掉的保持勾选并打印原因,重试只需一次点击。

截图

插件目前没有声明截图;在 package.json 旁放 screenshots.json(1–8 个相对图片路径)即可添加,市场在未声明时会退回到从本 README 抽取图片。


理解实现

实现细节——点击展开

两半边都是无需构建的朴素 JavaScript:Host 半边是导出 apply/inject 的 Node ESM,浏览器半边是 window.__ModuleLoader__.load({ id, factory }) 形态的模块,React 由外壳注入。本包在运行时不 import 任何 harness 的东西——Host 半边只用 Node 内置模块,浏览器半边只用页面已经发布的服务。

那条私有路由

浏览器碰不到文件系统,所以客户端通过一条私有 HTTP 路由调用 Host 半边:POST /dsh-delete-session。路由由每进程随机生成的 48 位十六进制 token 守卫,该 token 经 webServer.tapIndex 以 <meta name="dsh-delete-session-token"> 注入每份渲染出的 index.html,客户端再用 x-dsh-delete-session-token 头回传。跨源页面读不到这个 meta,自定义头又会强制一次外部源无法满足的预检。方法、token、JSON 结构都在碰任何东西之前校验,请求体上限 256 KiB。

刻意保留两套请求契约:

请求体响应
{ sessionId, currentSessionId, allowCurrent? }一个扁平结果对象
{ sessionIds: [...], currentSessionId, allowCurrent }{ ok, deleted, failed, results },每个 id 一条结果,最多 200 条

扁平契约出现得更早,至今仍然提供,所以客户端遇到批量升级之前的 Host 半边时会退化成逐条请求(识别方法是它回 400/404/405)。每条结果都带稳定的 code——ok、invalid-id、current、running、root-missing、log-locked、empty、bad-body、forbidden、method、internal——本地化文案由客户端负责,Host 不需要知道页面语言。

客户端挂在哪里

侧栏在这两个位置都没有提供增量 slot,所以两个入口都由注册在 shell.overlay 里的同一个座位组件注入到产品自己的 DOM 上。

  • 菜单项。 捕获阶段的 click 监听记录下被点 … 按钮所在行的 session id,它是从该行的 React fiber 里读出来的(memoizedProps.node.id,以 session- 开头——这个前缀同时也把 Workspace 行排除掉)。随后 MutationObserver 通过「同时含 Fork 与 Archive 两个来自实时 workspace 词典的标签」来确认新弹出的菜单,收敛到仍同时含有这两个标签的最小子树,再把红色条目追加进去。
  • 区头按钮。 它作为区头的直系子节点插在 *_headerActions 那组图标之前。那组容器的上限是 60px——正好两个 28px 图标按钮——并且 overflow: hidden,塞第三个会被裁掉。定位靠 CSS module 类名的后缀(_sectionHeader、_headerActions),因为前缀 hash 每次上游构建都会重生成;主路径以任意渲染出的 role="treeitem" 行为锚点,不对侧栏之外的文档结构做任何假设,页面上还没有会话行时则退化成按区头自己的文案(两种语言拼写都带上)匹配。

React 用自己持有的 DOM 引用做增删,不会移除自己不认识、也不属于它的兄弟节点,所以插在两个 React 子节点之间的节点是安全的;万一某次重渲染把它带走,观察者的下一批变更就会补回来。座位被卸载时,它拥有的每个节点、监听器与样式表都会被移除。

两个值得知道的设计决定

  • 先归档再删文件,且绝不 detachSession。 Workspace 注册表的归档集合设计上保留会话的记账 slot——"归档的会话保留它的 sessionIds slot,取消归档时能回到原位"——而且它本来就会在会话头部不再可解析时把 slot 过滤掉。早期版本在删完日志后又调了 detachSession,结果会话从它的 Workspace 里掉了出去,用户刚删掉的那一行以「未分组」的形式重新出现。现在什么都不摘。
  • 客户端自己摘掉那一行。 Host 没有任何公开途径去 dispose 一个由别的所有者创建的对话,而让客户端删行的事件(api-session/removed)只由 session/disposed 触发,插件够不到。调用 sessions.handleSessionRemoved(id) 用的就是同一条中继,这正是"Host 仍把对话留在内存里、行却立刻消失"的原因;归档集合则仍是列表刷新后用来对账的持久过滤器。

失败处理

归档先尝试,失败不算致命:删不掉的日志返回 log-locked 并在 detail 里带上目录;由于归档可能已经把行藏起来了,客户端会追加"该行已从侧栏隐藏,但日志文件未能删除",而不是假装删除成功。<DSH_HOME>/sessions 根目录缺失会在碰任何东西之前以 root-missing 失败关闭。日志本就不存在的对话以 ok + alreadyGone: true 上报,这让重试保持幂等。


进一步探索

  • awesome-dsh-plugin —— 本插件要投稿的社区列表,也是本仓库遵循的 manifest 与描述规则的出处。
  • dsh-market —— 可一键安装、切换、卸载列表中插件的插件市场,含主题 Tab。
  • npm 上的 @deepseek-ai/dsh —— 这两半边挂载的 harness 本体。

模型体验

无。两半边都是界面与文件系统管道:插件不注册工具、不贡献提示词段落或上下文、不写入会话事件,因此它做的任何事都不会进入模型请求。被删的对话是通过侧栏自己的 DOM 按 id 定位的,插件从不读取它的内容。

KV Cache 影响

无。插件从不触碰请求前缀或事件流,因此不可能让提供方的缓存前缀失效。

已知限制与延期工作

  • 两个入口都是 DOM 注入。 侧栏在这两处都没有提供增量 slot,所以都得挂在产品自己的标记上。CSS module 后缀匹配(_sectionHeader、_headerActions)是刻意划出的稳定性边界——前缀 hash 每次上游构建都会变——但超出它的标记改动需要跟进更新插件。
  • 尚在内存里的对话只是被藏起来。 删除一个 Host 仍持有的对话会移除它的文件与那一行,归档集合也会让它不再出现在任何分组里,但进程会一直留着这个对象直到重启。
  • 删掉正在查看的那条之后停在空状态。 产品的归档观察者会清空选中项;插件不会替你挑一条替代的对话。
  • 删除两个方向都是单向的。 没有取消归档、也没有恢复:归档集合负责隐藏,日志目录已经没了。
  • 只删两个路径。 日志目录与投影缓存条目。附件二进制、Workspace 记录,以及部署为会话存放的其他东西都原样保留。

开发备注

维护者上下文——点击展开

两个零依赖的冒烟测试覆盖两半边,且不需要启动 DSH:

npm test
# 也可单独跑
node test/host-smoke.mjs      # 在临时 $DSH_HOME 上驱动真实路由
node test/client-render.mjs   # 用最小 React 与 DOM 桩渲染真实座位组件

Host 测试同时是上面那两条规则的回归守卫:它断言归档发生在日志被删之前,并断言 detachSession 从未被调用。客户端测试用一个手写的小 DOM 驱动挂载副作用,因此区头注入、点击开框的接线、幂等重注入、文案兜底都被检查到。

改动这里之前值得记住的坑:

  1. 删完不要动 Workspace 记账。 那就是「未分组」bug:被删的行会以游离项的身份回来。见上面的设计决定。
  2. 不要把区头按钮塞进 *_headerActions。 它上限 60px 且 overflow: hidden,第三个子节点会被裁掉。
  3. 不要在座位的挂载 effect 里订阅任何东西、并把订阅 disposer 当作 cleanup 返回。 曾经这样做让整个座位在挂载时被卸载:apply 正常完成、slots.register 也返回了 disposer,但组件永远不出现。深浅色因此改成"打开对话框时采样一次",而不是实时跟随。
  4. 子进程不展开 %VAR%。 cmd.exe 里 echo %DSH_HOME% 会原样输出字符串;请像 Host 半边那样直接读 process.env.DSH_HOME,回退到 <home>/.dsh。
  5. Host Guard 禁止碰别的 Cordis Context。 service.ctx.emit(...) 会被拒绝;插件只能用自己 apply(ctx) 的那个 ctx。
  6. 运行中的对话在两边都必须保持不可删。 客户端置灰是为了说明原因;即使客户端没拦,Host 也会拒绝。

Profile 机制:patch 层能热更,模块代码不能。改 cordis.patch.yml 立即生效,改 lib/index.js 需要重启进程,改 lib/client.js 在下次刷新页面时生效。

运行时不变式: 所有副作用都属于那一个 shell.overlay 座位:注入的菜单项、区头按钮、样式表、对话框与文档监听器都在它的挂载 effect 里创建、由它的 cleanup 移除,因此卸载插件会让侧栏回到它来之前的样子。