---
description: "给 DSH Web 侧栏补上真正的会话删除:会话行右键菜单删单条、「工作区」区头批量对话框删多条;两者都会先归档会话,再删掉它在磁盘上的日志目录与投影缓存。"
kind: "package-reference"
---
# dsh-delete-session
[English](README.md) | 中文
## 概述
`dsh-delete-session` 给 DSH Web 侧栏补上产品本身没有的破坏性操作:真正删除一条对话。会话行的右键菜单多出一条红色**「删除对话」**;「工作区」区头多出一枚红色垃圾桶按钮,点开带逐条勾选、标题过滤与全选的**批量**对话框。两个入口都先把会话归档——归档集合是产品自带的持久过滤器,也是唯一能让会话从所有分组视图里消失的东西——再删除 `$DSH_HOME/sessions/` 下它的日志目录与投影缓存里的对应条目,最后把该行从客户端会话列表里摘掉。最后这一步是必要的:任何被打开过的对话,其对象会一直留在 Host 进程里直到进程重启,而 Host 的会话列表会持续把它报回来。正在运行(agent 跑着)的对话会被拒绝,而不是照删。
## 目录
- [使用本包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [进一步探索](#further-exploration)
- [模型体验](#model-experience)
- [已知限制与延期工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
## 使用本包
作为 bundle 装进 DSH profile,然后重启提供 Web GUI 的那个进程:
```sh
dsh plugin add github:/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/<项目目录>//`,递归删除、重试三次,然后**复查**,因此被吞掉的失败不会被当成成功上报。
3. **删投影缓存** —— `$DSH_HOME/storages/session_projcache/sessions/.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` 以 `` 注入每份渲染出的 `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` 里带上目录;由于归档可能已经把行藏起来了,客户端会追加"该行已从侧栏隐藏,但日志文件未能删除",而不是假装删除成功。`/sessions` 根目录缺失会在碰任何东西之前以 `root-missing` 失败关闭。日志本就不存在的对话以 `ok` + `alreadyGone: true` 上报,这让重试保持幂等。
-----
## 进一步探索
- [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) —— 本插件要投稿的社区列表,也是本仓库遵循的 manifest 与描述规则的出处。
- [dsh-market](https://github.com/dsh-market/dsh-market) —— 可一键安装、切换、卸载列表中插件的插件市场,含主题 Tab。
- [npm 上的 @deepseek-ai/dsh](https://www.npmjs.com/package/@deepseek-ai/dsh) —— 这两半边挂载的 harness 本体。
## 模型体验
无。两半边都是界面与文件系统管道:插件不注册工具、不贡献提示词段落或上下文、不写入会话事件,因此它做的任何事都不会进入模型请求。被删的对话是通过侧栏自己的 DOM 按 id 定位的,插件从不读取它的内容。
#### KV Cache 影响
无。插件从不触碰请求前缀或事件流,因此不可能让提供方的缓存前缀失效。
## 已知限制与延期工作
- **两个入口都是 DOM 注入。** 侧栏在这两处都没有提供增量 slot,所以都得挂在产品自己的标记上。CSS module 后缀匹配(`_sectionHeader`、`_headerActions`)是刻意划出的稳定性边界——前缀 hash 每次上游构建都会变——但超出它的标记改动需要跟进更新插件。
- **尚在内存里的对话只是被藏起来。** 删除一个 Host 仍持有的对话会移除它的文件与那一行,归档集合也会让它不再出现在任何分组里,但进程会一直留着这个对象直到重启。
- **删掉正在查看的那条之后停在空状态。** 产品的归档观察者会清空选中项;插件不会替你挑一条替代的对话。
- **删除两个方向都是单向的。** 没有取消归档、也没有恢复:归档集合负责隐藏,日志目录已经没了。
- **只删两个路径。** 日志目录与投影缓存条目。附件二进制、Workspace 记录,以及部署为会话存放的其他东西都原样保留。
### 开发备注
维护者上下文——点击展开
两个零依赖的冒烟测试覆盖两半边,且不需要启动 DSH:
```sh
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`,回退到 `/.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 移除,因此卸载插件会让侧栏回到它来之前的样子。