✨ feat: 会话彻底删除插件,支持删除会话与清理孤儿临时文件

This commit is contained in:
pyh
2026-09-30 17:03:07 +08:00
commit b977f57fa4
13 changed files with 1717 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
*.log
.DS_Store
Thumbs.db
+10
View File
@@ -0,0 +1,10 @@
# <emoji> <type>[(scope)][!]: <中文描述,一句话,不加句号>
# 一条提交信息只有这一行,不写正文,也不写脚注。
# 破坏性变更只用 ! 标记,例如:♻️ refactor!: 改掉了某某行为
# 需要交代背景时,把说明写进 README.md、CONTRIBUTING.md 或代码注释。
#
# 类型与 emoji:feat ✨ / fix 🐛 / docs 📝 / refactor ♻️ / perf ⚡️ /
# test ✅ / build 📦️ / ci 💚 / chore 🔧 / revert ⏪️
# 例:✨ feat(client): 会话行悬停区加删除按钮
# 例:🐛 fix(host): 延迟删除台账只记录根会话
# 例:📝 docs(ci): 安装说明只保留 Git 标签与本地 tgz
+94
View File
@@ -0,0 +1,94 @@
# 提交信息规范
本仓库的提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/) 的**首行**结构,并在行首加一个 emoji 做视觉标记。**描述用中文**,类型关键字、作用域保持英文。
## 格式
**一条提交信息只有一行**,没有正文,也没有脚注:
```text
<emoji> <type>[(scope)][!]: <中文描述>
```
- `<emoji>`:下面表格里该类型对应的 emoji,后面跟**一个空格**。emoji 是必需的,且必须与本行类型一致——`📝 docs:` 对,`✨ docs:` 不对。
- `<type>`:小写英文关键字,见下表。
- `(scope)`:可选,改动范围。用 `()` 包住一个简短英文标识,例如 `docs(ci)`、`fix(client)`、`feat(host)`。
- `!`:可选,紧跟在 type 或 scope 之后、冒号之前,表示破坏性变更。
- `<描述>`:中文,一句话,讲清这次改动做了什么;不换行、不用列表、不加句号。宽度不超过 72(中文字符按 2 个宽度算)。
**不要写正文,也不要写脚注。** 连 `Refs:` / `Closes:` / `BREAKING CHANGE:` 这类尾注也不写——需要交代的"为什么""影响面""迁移注意事项",写进 [README.md](README.md) 或本文件,或写进代码注释,让说明跟着代码走,而不是埋在 `git log` 里。
**破坏性变更只靠 `!` 标记**(例如 `♻️ refactor!: …`)。这一条是对 Conventional Commits 的有意偏离:它要求破坏性变更必须有 `BREAKING CHANGE:` 脚注,本仓库不写脚注,所以判断破坏性变更以 `!` 为准。
如果确实需要多行说明,说明这次改动不该只用一个提交标题交代——先补文档,再提交。
## 类型与 emoji
| 类型 | emoji | 码位 | 用途 |
| --- | --- | --- | --- |
| `feat` | ✨ | `U+2728` | 新增功能 |
| `fix` | 🐛 | `U+1F41B` | 修复缺陷 |
| `docs` | 📝 | `U+1F4DD` | 只改文档 |
| `refactor` | ♻️ | `U+267B U+FE0F` | 重构,行为不变 |
| `perf` | ⚡️ | `U+26A1 U+FE0F` | 性能优化 |
| `test` | ✅ | `U+2705` | 变更测试 |
| `build` | 📦️ | `U+1F4E6 U+FE0F` | 构建系统或依赖 |
| `ci` | 💚 | `U+1F49A` | CI 配置 |
| `chore` | 🔧 | `U+1F527` | 杂项,以上都不属于时用 |
| `revert` | ⏪️ | `U+23EA U+FE0F` | 回滚某次提交,正文写 `Refs: <被回滚的提交>` |
**复制上表里的 emoji,不要手敲。** 带 `U+FE0F` 的那五个是「基础字符 + 变体选择符」两个码位,手敲时很容易只打出前一个字符;对工具来说 `♻`(`U+267B`)和 `♻️`(`U+267B U+FE0F`)是两个不同的字符串,比对会失败。写规范或写脚本时,比较 emoji 前先做一次 NFC 归一化,或把两边都按 `\uFE0F?` 处理。
## 示例
每条都是一整条提交信息,就这一行:
```text
✨ feat(client): 会话行悬停区加删除按钮
```
```text
🐛 fix(host): 延迟删除台账只记录根会话
```
带作用域与破坏性标记(破坏性变更**没有**脚注,只看 `!`):
```text
♻️ refactor(client)!: 行内按钮改用自己的槽位 id
```
```text
📝 docs: 安装说明改为只走 Git 标签与本地 tgz
```
## 工具链提示
emoji 在行首是本仓库的有意选择,视觉上整齐。代价是:以类型前缀开头的解析工具(`commitlint`、`semantic-release`、`release-please` 等)会读不到类型。如果以后要接这类工具,有两条路:
1. 把 emoji 挪到冒号之后(`feat: ✨ 描述`),类型回到行首,规范其余部分不变;
2. 保留行首 emoji,给工具写一个剥掉开头 emoji 与空格的预处理。可用的正则(`\p{Extended_Pictographic}` 已覆盖带与不带变体选择符两种写法):
```text
^(?:\p{Extended_Pictographic}\uFE0F?\s+)?(?<type>[a-z]+)(?:\((?<scope>[^)]*)\))?(?<breaking>!)?:\s(?<desc>.+)$
```
## 自查
`scripts/check-commit-log.mjs` 会按本规范检查现有提交,只报告、不拦提交。除了首行格式,它还会报出**任何带正文或脚注的提交**:
```bash
node scripts/check-commit-log.mjs # 最近 10 条
node scripts/check-commit-log.mjs all # 全部
```
它从本文件的类型表里读 emoji 对照表,所以改了表不用改脚本。规范之前的提交不符合是正常的。
## 启用提交模板(可选)
`.gitmessage` 是提交信息模板。启用后每次 `git commit` 会带上前缀提示:
```bash
git config commit.template .gitmessage
```
模板整份都是 `#` 开头的注释,git 会自动丢弃,所以直接 `git commit -m` 也不受影响。只对当前仓库生效;换机器要重新执行一次。
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 pyh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+99
View File
@@ -0,0 +1,99 @@
# 会话彻底删除
DeepSeek Harness(DSH)插件:给会话加上**真正不可逆的删除**,并清理已删除会话遗留的临时文件。
DSH 原本只能归档会话——归档只是把会话从列表里移开,日志仍然留在磁盘上(DSH 官方的 JSONL 会话存储后端明确写着「没有任何东西会删除会话文件,日志会一直堆在 root 下直到被外部移除」,会话存储契约里也没有删除 API)。这个插件补上的正是这一块:两处删除入口,以及插件页里的一次性孤儿文件清理。
## 功能
### 1. 删除会话
两个入口,行为一致,都会先弹出确认弹窗(复刻系统 Modal 的样式与键盘行为:Esc 关闭、Tab 焦点圈定、打开时焦点进入、关闭后焦点归位):
- 会话行右侧 **⋯** 菜单 → **删除会话**
- 会话行悬停时的 🗑 按钮(在官方的「归档」「置顶」之前,是悬停区的第一个按钮)
确认后一次性处理:
| 对象 | 处理方式 |
| --- | --- |
| 会话日志目录 | 整个删除,仅保留 `session.lock` |
| 子智能体会话 | 按血缘递归收集,连同日志与溢出文件一并删除 |
| 工具输出溢出文件(spill) | 删除该会话(含子会话)对应的 spill 目录 |
| 工作区登记项 | 解除绑定、取消置顶、取消归档 |
| 客户端列表 | 广播 `api-session/removed`,列表行立即消失 |
有意**不**处理:
- **附件对象**(`$DSH_HOME/attachments/v1/objects`):按内容 sha256 寻址,同一份内容可能被多个会话共享,而 DSH 没有引用计数,跟着一个会话删除会破坏别的会话;
- **投影缓存**(`$DSH_HOME/storages/session_projcache`):由日志派生的缓存,读取时带生命周期身份校验,永远不会串到别的会话。
### 2. 清理临时文件
**设置 → 插件 → 会话彻底删除**(点会话彻底删除那一行右侧的配置按钮),清理 spill 后端留下的**孤儿**目录。判定偏保守:
1. 目录名必须精确等于 `session-<sha256(会话 id) 前 12 位>`——这是 spill 后端唯一会生成的形状;
2. 该名字不属于任何已知会话(`sessionQuery.listSessions()` 已经把存量会话与内存中正在运行的会话合并成同一份语料,所以「已知」只需要一个来源);
3. 只在 spill 的 root 内操作(后端当前 root + 系统临时目录下的 `dsh-spill*`);
4. 符号链接一律跳过、不跟随;单项失败只记警告。
结果直接显示在该页(清理了几个目录、几个文件、释放多少空间),可反复点击。
这个页面占的是 `plugins.row.config` 槽位——和官方插件「终端」的配置页是同一个位置。注册了这个槽位的 key(`<包名>#<行 id>`)之后,插件行就会长出配置入口,所以本插件不声明 `Config` 也能有自己的页面。
## 行为与边界
- **仍在运行的会话**无法被插件从内存里移除:它会先被归档并停止,日志与溢出文件在**下次启动 DSH 时**删除(界面会明确提示);在此之前取消归档就会取消这次删除。
- 删除**不可恢复**,但**不是安全擦除**:按普通文件删除处理,底层介质上可能有残留。
- 工作区里的代码文件不受影响。
- 溢出文件被删除后,fork 出来的会话日志里若仍保留指向它的路径文本,那些路径会失效。
- 行内按钮的悬停气泡由按钮自身定位。指针停在会话行上时,行自己的悬停预览卡可能和这个气泡同时出现——插件不会去动宿主的行 DOM 来避免这一点(那是插件不该依赖的自有渲染结构)。若观感不能接受,可把气泡改为只在键盘 focus 时显示:删掉 `DeleteSessionRowButton` 里的 `onMouseEnter: show` / `onMouseLeave: hide` 两行即可。
## 安装
DSH 的 **插件 → 添加插件** 里有两个输入,含义不同:
- **上面那个输入框**:装什么(包名 / Git 地址 / 本地 tgz 的绝对路径);
- **下面的「安装源」**:只决定**按包名安装时**去哪个注册表查。填 Git 地址或 tgz 时它不参与拉取,保持默认(官方源)即可,不必切到「自定义地址」。
### 1. Git 仓库地址(推荐)
在上面的输入框里填:
```text
https://gitea.iwake.top/dsh-plugin/session-delete.git#v2.0.0
```
`#` 后面可以跟标签或提交,用来锁定版本;不写则取默认分支。仓库是公开的,不需要凭据,也不需要改安装源。
想跟随 2.x 的最新版本,可以把后缀写成 `#semver:^2.0.0`——pnpm 会按仓库里的标签挑最高的 2.x,以后发了 v2.0.1,移除旧版本再装一次就能拿到。
### 2. 本地 tgz
把仓库打包成 tgz(例如在仓库目录执行 `npm pack`,得到 `dsh-plugin-session-delete-2.0.0.tgz`),把它在运行 DSH 那台机器上的绝对路径填进上面的输入框。
### 升级
DSH 暂不支持插件自动更新,升级就是用新标签重装一次(建议先移除旧版本)。安装或升级后:**宿主半变更需要重启 DSH**(完全退出再打开,仅关闭窗口不一定退出进程);只改浏览器半时刷新页面即可。
如果你是在本地开发这个插件(profile 里装的是 `link:` 到工作目录),**光刷新页面看不到改动**:profile 的 `node_modules` 里可能是一份与源码同 inode 的硬链接副本,需要移除再重装一次插件,让 profile 重新取一份。
## 兼容性
基于 **DeepSeek Harness 0.2.0-rc.2** 编写。DSH 仍在演进,其它版本与平台组合未逐一验证。
- **浏览器半**不 import 任何 Harness 客户端包(上游规范如此要求:这些包随时可能变化,渲染抛错会让整个插槽条目变空白),而是复刻 `dsh-client-ui-primitives` 的 markup、CSS 声明、图标 path 数据与键盘行为,只保留 `--dsw-*` 主题 token 引用。它**不读写宿主的 DOM**,也不占用官方槽位:行内按钮用自己的 id 与官方「归档」「置顶」并列,不是顶替。上游若改动这些内部结构,界面细节可能失配(例如悬停气泡不再出现),但不会导致插件加载失败。
- **宿主半**只依赖稳定的服务契约:`sessionQuery`(`traceSession` 取血缘、`listSessions` 取语料)、`sessionPersistence`(含其 JSONL 后端的 `locate()` 诊断钩子,用于定位会话目录)、`workspaceRegistry`、`connection.fetch`(注册受认证的 exact 路由),以及 spill 后端的目录规则。
- **跨半边通信**走 `connection.fetch` 注册的 `POST /api/plugin/session-delete/delete` 与 `.../orphans` 两条路由,浏览器半用文档相对路径请求(支持挂载前缀)。这是静态客户端包与宿主半通信的正规通道;`host.call` 属于动态客户端运行器,不适用于包形式的插件。
## 贡献
提交信息用「emoji + Conventional Commits + 中文描述」,规则、类型表和示例见 [CONTRIBUTING.md](CONTRIBUTING.md)。想省事可以让 git 带上模板:
```bash
git config commit.template .gitmessage
```
## License
[MIT](LICENSE)
+677
View File
@@ -0,0 +1,677 @@
/**
* Browser half of the session-delete bundle.
*
* One row in a conversation's "..." menu and one hover button on the
* conversation's sidebar row open an irreversible-deletion dialog. Confirming
* posts to the Host half's authenticated route; the Host owns every policy
* decision and the Client only renders its answer.
*
* The temporary-file sweep is the plugin's other operation, and it lives on
* this plugin's own page under Settings → Plugins: the entry registers into
* `plugins.row.config` under the `<package name>#<row id>` key the manager
* dispatches, which also gives the row its configure control. That is the same
* seat a bundled plugin's configuration form occupies, so the plugin's only
* control sits beside everything else the manager says about it instead of
* taking a row in General settings.
*
* Two client-authoring rules shape this file:
*
* - No Harness Client module is imported. Those packages change without notice,
* a plain-JavaScript bundle has no type check, and a throwing component blanks
* the whole slot entry, so the menu row, the row button, the dialog, and the
* plugin page's section re-implement the shipped markup, stylesheet
* declarations, and focus/Escape behavior themselves. Class names are renamed
* under `dsd-` and every color, radius, elevation, and motion value stays a
* `--dsw-*` theme-token reference.
* - Nothing reads another package's DOM. An earlier version nudged the row's
* hover preview out of the way by dispatching a synthetic pointer event at a
* `[data-row-key]` ancestor; that is exactly the kind of dependency on a
* self-owned rendering surface a plugin must not take. The tooltip below is
* positioned from its own button alone.
*
* @module @dsh-plugin/session-delete/client
*/
window.__ModuleLoader__.load({
id: '@dsh-plugin/session-delete',
factory(require) {
const React = require('react')
const h = React.createElement
const NS = 'session-delete'
const ROUTE = 'api/plugin/session-delete/delete'
const ORPHAN_ROUTE = 'api/plugin/session-delete/orphans'
/** The shipped menu rows occupy 100–400; this one follows them. */
const MENU_ORDER = 500
/** Between the shipped `archive` (100) and `pin` (200) hover buttons. */
const ROW_ACTION_ORDER = 150
/**
* The key `plugins.row.config` is dispatched under: the manager composes it
* as `<package name>#<row id>`, with the row id read from this bundle's own
* patch. Both halves are literals from `package.json` and
* `cordis.patch.yml`; a typo costs the configure control silently, so they
* are the only two places renaming has to reach.
*/
const PACKAGE = '@dsh-plugin/session-delete'
const ROW_ID = 'session-delete'
const CONFIG_KEY = `${PACKAGE}#${ROW_ID}`
const TOOLTIP_DELAY_MS = 500
const TOOLTIP_GAP = 8
/** Document base captured at bundle registration, before any routing. */
const BASE = typeof document === 'undefined' ? 'http://127.0.0.1/' : document.baseURI
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
const zh = {
'menu.item': '删除会话',
'actions.delete': '删除会话',
'dialog.title': '永久删除此会话?',
'dialog.body': '“{title}”的会话日志将被彻底删除,无法恢复',
'dialog.note': '工作区中的文件不受影响;该会话的对话内容不会保留',
'dialog.cancel': '取消',
'dialog.confirm': '永久删除',
'dialog.pending': '正在删除…',
'dialog.close': '关闭',
'toast.deleted': '会话及其子智能体会话已彻底删除。',
'toast.deletedSpill': '会话及其子智能体会话已彻底删除,并清理了 {n} 个溢出临时文件',
'toast.scheduled': '该会话仍在本进程中运行,已先停止并归档;它的会话日志会在下次启动 DeepSeek Harness 时删除',
'toast.failed': '删除失败:{message}',
'error.unknown': '未知错误',
'cleanup.title': '清理临时文件',
'cleanup.description': '删除已不存在会话遗留的工具输出溢出文件,不影响现有会话',
'cleanup.action': '清理',
'cleanup.pending': '正在清理…',
'cleanup.empty': '没有发现孤立的临时文件',
'cleanup.done': '已清理 {directories} 个目录、{n} 个文件,释放 {size}',
'cleanup.failed': '清理失败:{message}',
}
const en = {
'menu.item': 'Delete conversation',
'actions.delete': 'Delete conversation',
'dialog.title': 'Delete this conversation permanently?',
'dialog.body': 'The stored log of “{title}” will be permanently deleted. This cannot be undone.',
'dialog.note': 'Files in the workspace are not affected; nothing of this conversation is kept.',
'dialog.cancel': 'Cancel',
'dialog.confirm': 'Delete permanently',
'dialog.pending': 'Deleting…',
'dialog.close': 'Close',
'toast.deleted': 'The conversation and its subagent conversations were permanently deleted.',
'toast.deletedSpill': 'The conversation and its subagent conversations were permanently deleted, and {n} spilled tool-output files were removed.',
'toast.scheduled': 'This conversation is still live in this process. It was stopped and archived; its stored log will be deleted the next time DeepSeek Harness starts.',
'toast.failed': 'Deletion failed: {message}',
'error.unknown': 'unknown error',
'cleanup.title': 'Clean up temporary files',
'cleanup.description': 'Deletes spilled tool-output files left behind by conversations that no longer exist; existing conversations are untouched.',
'cleanup.action': 'Clean up',
'cleanup.pending': 'Cleaning…',
'cleanup.empty': 'No orphaned temporary files were found.',
'cleanup.done': 'Removed {n} files from {directories} directories, freeing {size}.',
'cleanup.failed': 'Cleanup failed: {message}',
}
/**
* Menu-row declarations mirror `dsh-client-ui-primitives`' Menu.module.css
* `.item/.itemIcon/.itemLabel`; dialog declarations mirror its
* Modal.module.css and Button.module.css plus the workspace dialog's
* destructive accent and secondary status lines; the settings row copies
* the General-section row pattern. Class names are renamed under `dsd-`,
* and every color, radius, elevation, and transition stays a
* `--dsw-*`/`--ds-*` token reference.
*/
const CSS = `
.dsd-item{display:flex;align-items:center;gap:6px;width:100%;min-height:34px;padding:6px 8px;border:none;border-radius:var(--dsw-radius-md);background:transparent;cursor:pointer;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);text-align:left}
.dsd-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
.dsd-item:focus-visible:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);outline:none}
.dsd-item:disabled{opacity:.4;cursor:not-allowed}
.dsd-item-icon{display:inline-flex;flex:none;width:14px;height:14px;align-items:center;justify-content:center;color:var(--dsw-alias-menu-icon)}
.dsd-item-icon svg{width:14px;height:14px}
.dsd-item-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dsd-row-button{border-radius:var(--dsw-radius-xs);cursor:pointer;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;flex:none;justify-content:center;align-items:center;padding:0;display:inline-flex}
.dsd-row-button:hover{color:var(--dsw-alias-label-primary)}
.dsd-row-button:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
.dsd-stack{flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px;display:flex}
.dsd-section{flex-direction:column;gap:12px;display:flex}
.dsd-section-head{align-items:center;gap:16px;display:flex}
.dsd-section-title{color:var(--dsw-alias-label-primary);margin:0;font-size:14px;font-weight:500;line-height:22px}
.dsd-section-desc{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;font-weight:400;line-height:18px}
.dsd-tooltip{display:inline-flex;align-items:center;gap:8px;position:fixed;z-index:100;width:max-content;max-width:50vw;padding:3px 7px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-tooltip-bg);color:var(--dsw-static-neutral-bluish-00);font-size:13px;line-height:20px;white-space:pre-line;overflow-wrap:break-word;pointer-events:none;transform:translateX(-100%);animation:dsd-tooltip-in 150ms var(--ds-ease-in-out)}
@keyframes dsd-tooltip-in{from{opacity:0}}
@media (prefers-reduced-motion: reduce){.dsd-tooltip{animation:none}}
.dsd-modal-root{pointer-events:auto;position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:max(24px,var(--dsh-frame-overlay-top,24px)) 24px}
.dsd-mask{position:absolute;inset:var(--dsh-frame-chrome-top,0px) 0 0;backdrop-filter:var(--dsw-mask-blur)}
.dsd-mask::after{content:'';position:absolute;inset:0;background:var(--dsw-alias-bg-mask-1);animation:dsd-enter var(--ds-transition-duration) var(--ds-ease-in-out)}
.dsd-dialog{box-sizing:border-box;position:relative;z-index:1;display:flex;flex-direction:column;gap:20px;width:min(380px,100%);padding:0 0 24px;overflow:hidden;border:0;border-radius:var(--dsw-radius-panel);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent);animation:dsd-enter var(--ds-transition-duration) var(--ds-ease-in-out);color:var(--dsw-alias-label-primary)}
.dsd-dialog:focus{outline:none}
@keyframes dsd-enter{from{opacity:0}to{opacity:1}}
@media (prefers-reduced-motion: reduce){.dsd-mask::after,.dsd-dialog{animation:none}}
.dsd-content{display:flex;flex-direction:column;width:100%}
.dsd-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:22px 14px 12px 24px}
.dsd-title{margin:0;font-size:16px;line-height:24px;font-weight:500;color:var(--dsw-alias-label-primary)}
.dsd-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;cursor:pointer;color:var(--dsw-alias-label-secondary)}
.dsd-close:hover{background:var(--dsw-alias-interactive-bg-hover)}
.dsd-description{margin:0;padding:0 24px;font-size:14px;line-height:22px;font-weight:400;color:var(--dsw-alias-label-primary)}
.dsd-body{display:flex;flex-direction:column;min-width:0;margin-top:20px;padding:0 24px}
.dsd-note{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
.dsd-status{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
.dsd-status + .dsd-status,.dsd-note + .dsd-status{margin-top:8px}
.dsd-status-error{color:var(--dsw-alias-state-error-primary)}
.dsd-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:0 24px}
.dsd-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;height:36px;padding:0 14px;border:none;border-radius:var(--dsw-radius-md);cursor:pointer;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);background:transparent}
.dsd-button:disabled{cursor:not-allowed;opacity:.4}
.dsd-outline{border:.5px solid var(--dsw-alias-border-l3);background:transparent}
.dsd-outline:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
.dsd-destructive:not(:disabled){color:var(--dsw-alias-state-error-primary)}
`
/** Minimal observable store; the shape the Client slot framework injects as a hook. */
function createStore(initial) {
let value = initial
const listeners = new Set()
return {
getSnapshot: () => value,
subscribe: (listener) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
set: (next) => {
if (next === value) return
value = next
for (const listener of [...listeners]) listener()
},
}
}
/**
* Use the locale seat the slot owner hands this entry; fall back to this
* package's own dictionaries so a missing seat degrades copy, never render.
*/
function translate(seat) {
if (typeof seat === 'function') return seat
const isChinese = typeof document === 'undefined'
|| !/^en/i.test(document.documentElement?.getAttribute?.('lang') ?? 'zh')
const dictionary = isChinese ? zh : en
return (key, params) => {
const template = dictionary[key] ?? key
if (params === undefined) return template
return template.replace(/\{(\w+)\}/g, (match, name) => (
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
))
}
}
/**
* Icon paths are the shipped `IconTrashOutline` and `IconCloseOutline`
* artwork verbatim: a 16-unit viewBox the glyph fills, stroked at the
* regular weight of 1.
*/
function TrashIcon({ size = 16 }) {
return h('svg', {
width: size, height: size, viewBox: '0 0 16 16', fill: 'none',
xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, strokeWidth: 1,
},
h('path', { d: 'M1.28149 3.88831H14.7187', stroke: 'currentColor' }),
h('path', {
d: 'M5.41602 3.88833V2.47962C5.41602 2.29282 5.52492 2.11366 5.71876 1.98157C5.9126 1.84948 6.17551 1.77527 6.44964 1.77527H9.55053C9.82466 1.77527 10.0876 1.84948 10.2814 1.98157C10.4753 2.11366 10.5842 2.29282 10.5842 2.47962V3.88833',
stroke: 'currentColor',
}),
h('path', {
d: 'M2.57349 3.88831L3.19366 13.2943C3.21937 13.5502 3.33952 13.7872 3.53065 13.9593C3.72178 14.1313 3.97016 14.2259 4.22729 14.2246H11.7728C12.0299 14.2259 12.2783 14.1313 12.4694 13.9593C12.6605 13.7872 12.7807 13.5502 12.8064 13.2943L13.4266 3.88831',
stroke: 'currentColor',
}),
h('path', { d: 'M6.44946 6.98926V11.1238', stroke: 'currentColor' }),
h('path', { d: 'M9.55054 6.98926V11.1238', stroke: 'currentColor' }))
}
/** Close glyph of the dialog header, matching the primitive close button. */
function CloseIcon({ size = 16 }) {
return h('svg', {
width: size, height: size, viewBox: '0 0 16 16', fill: 'none',
xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, strokeWidth: 1,
},
h('path', { d: 'M2.5 2.5L13.5 13.5', stroke: 'currentColor' }),
h('path', { d: 'M13.5 2.5L2.5 13.5', stroke: 'currentColor' }))
}
/**
* The menu row: raise one deletion request for the conversation it belongs
* to. The menu's own open-state hook is declared by the slot owner, so the
* row closes it before the dialog appears.
*/
function DeleteSessionMenuItem(props) {
const sessionId = props.sessionId
const displayTitle = props.displayTitle
const t = translate(props.t)
const requestSessionDelete = props.requestSessionDelete
const closeMenu = useMenuCloser(props.useMenuOpenState)
return h(React.Fragment, null,
h('style', null, CSS),
h('button', {
type: 'button',
role: 'menuitem',
className: 'dsd-item',
'aria-label': t('menu.item'),
onClick: () => {
closeMenu()
requestSessionDelete(sessionId, displayTitle ?? '')
},
},
h('span', { className: 'dsd-item-icon' }, h(TrashIcon, { size: 14 })),
h('span', { className: 'dsd-item-label' }, t('menu.item'))))
}
/** Close the owning menu through the slot's declared hook, when it has one. */
function useMenuCloser(useMenuOpenState) {
const closer = React.useRef(() => {})
if (typeof useMenuOpenState === 'function') {
/** The hook belongs to the entry for its whole life, so the order is stable. */
const openState = useMenuOpenState()
if (Array.isArray(openState) && typeof openState[1] === 'function') {
closer.current = () => openState[1](false)
}
}
return closer.current
}
/**
* The row's hover button. It carries its own id in the row-action list, so
* the shipped `archive` and `pin` buttons keep their cells instead of being
* shadowed.
*
* Its tooltip copies the primitive's bubble — the same delay, the same
* bottom/end placement, the same fade — and is placed from this button's own
* rect alone. While the pointer rests on the row, the row's own hover
* preview may appear alongside it; that is accepted rather than suppressed
* by reaching into the row's DOM.
*/
function DeleteSessionRowButton(props) {
const t = translate(props.t)
const requestSessionDelete = props.requestSessionDelete
const buttonRef = React.useRef(null)
const timerRef = React.useRef(null)
const [anchor, setAnchor] = React.useState(null)
const show = () => {
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
const rect = buttonRef.current?.getBoundingClientRect()
if (rect === undefined) return
setAnchor({ left: rect.right, top: rect.bottom + TOOLTIP_GAP })
}, TOOLTIP_DELAY_MS)
}
const hide = () => {
clearTimeout(timerRef.current)
setAnchor(null)
}
React.useEffect(() => () => clearTimeout(timerRef.current), [])
const label = t('actions.delete')
return h(React.Fragment, null,
h('style', null, CSS),
h('button', {
ref: buttonRef,
type: 'button',
className: 'dsd-row-button',
'aria-label': label,
onMouseEnter: show,
onMouseLeave: hide,
onFocus: show,
onBlur: hide,
onClick: () => {
hide()
requestSessionDelete(props.sessionId, props.displayTitle ?? '')
},
}, h(TrashIcon, { size: 14 })),
anchor !== null && h('span', {
className: 'dsd-tooltip',
role: 'tooltip',
style: { left: anchor.left, top: anchor.top },
}, label))
}
/**
* This plugin's page under Settings → Plugins: sweep spilled tool output
* whose conversation no longer exists.
*
* The owner asks every `plugins.row.config` entry for two views. `summary`
* is the one-liner on the row, and this plugin's manifest description
* already fills that seat, so `summary` renders nothing and the page keeps
* the text the manager read from `locale/*.json`. `page` is the section the
* manager renders with its own heading, breadcrumb, and title; copy,
* control, and result belong to this entry.
*/
function TempCleanupPanel(props) {
const t = translate(props.t)
const cleanOrphans = props.cleanOrphans
// Every hook sits above the view guard: React must see the same hook
// order in both views, so the guard can only skip the output.
const [state, setState] = React.useState({ phase: 'idle', failed: false, message: '' })
if (props.view !== 'page') return null
const busy = state.phase === 'busy'
const run = async () => {
setState({ phase: 'busy', failed: false, message: '' })
try {
const value = await cleanOrphans()
const files = Number(value.files) || 0
const directories = Number(value.directories) || 0
setState({
phase: 'done',
failed: false,
message: files === 0
? t('cleanup.empty')
: t('cleanup.done', { n: files, directories, size: formatBytes(Number(value.bytes) || 0) }),
})
} catch (error) {
setState({
phase: 'done',
failed: true,
message: t('cleanup.failed', { message: error instanceof Error ? error.message : t('error.unknown') }),
})
}
}
return h(React.Fragment, null,
h('style', null, CSS),
h('div', { className: 'dsd-section' },
h('div', { className: 'dsd-section-head' },
h('div', { className: 'dsd-stack' },
h('h4', { className: 'dsd-section-title' }, t('cleanup.title')),
h('p', { className: 'dsd-section-desc' }, t('cleanup.description'))),
h('button', {
type: 'button',
className: 'dsd-button dsd-outline',
disabled: busy,
onClick: run,
}, busy ? t('cleanup.pending') : t('cleanup.action'))),
state.phase === 'done' && h('p', {
className: `dsd-status${state.failed ? ' dsd-status-error' : ''}`,
role: state.failed ? 'alert' : 'status',
}, state.message)))
}
/** Render a byte count the way the cleanup result reports reclaimed space. */
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`
}
/** The overlay entry: the dialog, or nothing while no request is pending. */
function DeleteSessionDialog(props) {
const request = props.useDeleteRequest((pending) => pending)
if (request === null) return null
return h(DeleteSessionForm, {
request,
deleteConversation: props.deleteConversation,
onSettle: props.settleSessionDelete,
t: props.t,
}, request.sessionId)
}
/**
* One request's dialog; its in-flight and result state die with the request.
* Focus, Escape, and Tab behave as the shipped modal does: focus moves into
* the card on open, Tab stays inside it, Escape closes it, and focus returns
* to the element that opened it.
*/
function DeleteSessionForm({ request, deleteConversation, onSettle, t: seat }) {
const t = translate(seat)
const [phase, setPhase] = React.useState('idle')
const [result, setResult] = React.useState(null)
const dialogRef = React.useRef(null)
const busy = phase === 'deleting'
// The layer effect mounts once per dialog, exactly as the shipped modal
// does: the current close command is read through a ref so a state change
// never re-runs focus setup or steals focus mid-request.
const busyRef = React.useRef(busy)
busyRef.current = busy
const closeRef = React.useRef(null)
closeRef.current = () => {
if (busyRef.current) return
onSettle()
}
const close = () => {
closeRef.current()
}
React.useEffect(() => {
const dialog = dialogRef.current
if (dialog === null) return undefined
const owner = dialog.ownerDocument
const previous = owner.activeElement
const initial = dialog.querySelector('[data-dsd-autofocus]')
?? dialog.querySelector(FOCUSABLE)
?? dialog
initial.focus?.({ preventScroll: true })
const onKeyDown = (event) => {
if (event.defaultPrevented || event.ctrlKey || event.altKey || event.metaKey) return
if (event.key === 'Escape' && !event.shiftKey) {
event.preventDefault()
if (!event.repeat) closeRef.current()
return
}
if (event.key !== 'Tab') return
if (owner.activeElement?.closest('[role="menu"]') != null) return
const items = [...dialog.querySelectorAll(FOCUSABLE)].filter((item) => item.closest('[inert], [hidden]') === null)
const first = items[0] ?? dialog
const last = items.at(-1) ?? dialog
const atEdge = event.shiftKey ? owner.activeElement === first : owner.activeElement === last
if (owner.activeElement === dialog || !dialog.contains(owner.activeElement) || atEdge) {
event.preventDefault()
;(event.shiftKey ? last : first).focus()
}
}
owner.addEventListener('keydown', onKeyDown)
return () => {
owner.removeEventListener('keydown', onKeyDown)
if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true })
}
}, [])
React.useEffect(() => {
if (phase !== 'done' || result === null || result.kind !== 'deleted') return undefined
const timer = setTimeout(() => closeRef.current(), 2500)
return () => clearTimeout(timer)
}, [phase, result])
const confirm = async () => {
setPhase('deleting')
setResult(null)
try {
const value = await deleteConversation(request.sessionId)
if (value.status === 'scheduled') {
setResult({ kind: 'scheduled', message: t('toast.scheduled') })
} else {
const spilled = Number(value.spilledFiles) || 0
setResult({
kind: 'deleted',
message: spilled > 0 ? t('toast.deletedSpill', { n: spilled }) : t('toast.deleted'),
})
}
setPhase('done')
} catch (error) {
setResult({
kind: 'failed',
message: t('toast.failed', { message: error instanceof Error ? error.message : t('error.unknown') }),
})
setPhase('error')
}
}
const title = request.displayTitle !== undefined && request.displayTitle !== ''
? request.displayTitle
: request.sessionId
return h(React.Fragment, null,
h('style', null, CSS),
h('div', { className: 'dsd-modal-root', role: 'presentation' },
h('div', { className: 'dsd-mask', 'aria-hidden': 'true', onClick: close }),
h('div', {
ref: dialogRef,
tabIndex: -1,
className: 'dsd-dialog',
role: 'dialog',
'aria-modal': 'true',
'aria-label': t('dialog.title'),
},
h('div', { className: 'dsd-content' },
h('div', { className: 'dsd-header' },
h('h2', { className: 'dsd-title' }, t('dialog.title')),
h('button', {
type: 'button',
className: 'dsd-close',
'aria-label': t('dialog.close'),
onClick: close,
}, h(CloseIcon, { size: 14 }))),
h('p', { className: 'dsd-description' }, t('dialog.body', { title })),
h('div', { className: 'dsd-body' },
h('p', { className: 'dsd-note' }, t('dialog.note')),
busy && h('div', { className: 'dsd-status', role: 'status' }, t('dialog.pending')),
result !== null && h('div', {
className: `dsd-status${result.kind === 'failed' ? ' dsd-status-error' : ''}`,
role: result.kind === 'failed' ? 'alert' : 'status',
}, result.message))),
h('div', { className: 'dsd-footer' },
phase === 'done'
? h('button', { type: 'button', className: 'dsd-button dsd-outline', onClick: close }, t('dialog.close'))
: [
h('button', {
key: 'cancel',
type: 'button',
className: 'dsd-button dsd-outline',
disabled: busy,
'data-dsd-autofocus': true,
onClick: close,
}, t('dialog.cancel')),
h('button', {
key: 'confirm',
type: 'button',
className: 'dsd-button dsd-outline dsd-destructive',
disabled: busy,
onClick: confirm,
}, t('dialog.confirm')),
]))))
}
/**
* Post to one of this package's Host routes. The registration path on the
* Host is absolute while this one is document-relative, so the request is
* built against the base captured at bundle registration and works under a
* mounted prefix. Routes sit behind the connection's trust fence, so the
* page's own credentials apply.
* @param route - document-relative route path.
* @param body - JSON body to send.
* @returns the Host's result value.
*/
async function post(route, body) {
const response = await fetch(new URL(route, BASE).href, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
let payload = null
try {
payload = await response.json()
} catch {
payload = null
}
if (!response.ok || payload?.ok === false) {
const code = payload?.error?.code
const detail = payload?.error?.message ?? `HTTP ${response.status}`
throw new Error(code === undefined ? detail : `${detail} (${code})`)
}
return payload
}
/**
* Delete one conversation through the Host half.
* @param sessionId - conversation to delete.
* @returns the Host's result value.
*/
function deleteConversation(sessionId) {
return post(ROUTE, { sessionId })
}
/**
* Sweep spilled tool output whose conversation no longer exists.
* @returns the Host's cleanup report.
*/
function cleanOrphans() {
return post(ORPHAN_ROUTE, {})
}
return {
inject: ['slots', 'locale'],
apply(ctx) {
const request = createStore(null)
const requestSessionDelete = (sessionId, displayTitle) => {
request.set({ sessionId, displayTitle })
}
const locale = ctx.get('locale')
if (locale !== undefined) {
ctx.effect(() => registerDictionaries(locale, NS, { zh, en }), 'session-delete: dictionaries')
}
ctx.slots.inject('sidebar.workspaces.session.menu.item', () => ctx.slots.register({
name: 'sidebar.workspaces.session.menu.item',
id: NS,
order: MENU_ORDER,
locale: NS,
inject: () => ({ requestSessionDelete }),
}, DeleteSessionMenuItem))
// This entry carries its own id, so the shipped `archive` and `pin`
// hover buttons keep their cells and are not shadowed by a lower
// priority. Archiving therefore stays exactly where the harness put it.
ctx.slots.inject('sidebar.workspaces.session.row.action', () => ctx.slots.register({
name: 'sidebar.workspaces.session.row.action',
id: NS,
order: ROW_ACTION_ORDER,
locale: NS,
inject: () => ({ requestSessionDelete }),
}, DeleteSessionRowButton))
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay',
id: `${NS}-confirm`,
locale: NS,
inject: () => ({
hooks: { deleteRequest: request },
deleteConversation,
settleSessionDelete: () => {
request.set(null)
},
}),
}, DeleteSessionDialog))
// The cleanup control belongs on this plugin's own page: registering
// this cell key is what makes the row grow a configure control, and the
// manager renders the entry for both its summary and page views.
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
name: 'plugins.row.config',
key: CONFIG_KEY,
locale: NS,
inject: () => ({ cleanOrphans }),
}, TempCleanupPanel))
},
}
},
})
/** Register this package's dictionaries, tolerating either registration form. */
function registerDictionaries(locale, namespace, dictionaries) {
try {
return locale.register(namespace, dictionaries)
} catch {
const disposers = Object.entries(dictionaries).map(([id, dict]) => locale.register(namespace, id, dict))
return () => {
for (const dispose of disposers) dispose?.()
}
}
}
+13
View File
@@ -0,0 +1,13 @@
# Bundle patch layer for @dsh-plugin/session-delete.
#
# One Host row is enough: the Host half owns the two authenticated routes the
# browser half posts to, and the browser half is picked up automatically from
# this package's `dsh.client` declaration (dsh-client-modules scans enabled
# Loader entries, serves `/plugins/@dsh-plugin/session-delete/client.js`, and
# boots it with the page), so the client never needs a row of its own.
#
# The row id, this package name, and the client module id are the same string
# in three places; renaming the package means changing all three.
- insert:
- id: session-delete
name: '@dsh-plugin/session-delete'
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"
stroke="#6B7280" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16" />
<path d="M10 4h4a1 1 0 0 1 1 1v2H9V5a1 1 0 0 1 1-1Z" />
<path d="M6.5 7l.8 11.2A2 2 0 0 0 9.3 20h5.4a2 2 0 0 0 2-1.8L17.5 7" />
<path d="M10.5 11v5.5M13.5 11v5.5" />
</svg>

After

Width:  |  Height:  |  Size: 405 B

+639
View File
@@ -0,0 +1,639 @@
/**
* Host half of the session-delete bundle.
*
* A conversation is durable in exactly two places: its session log (one
* directory per session under the persistence root) and the Workspace registry
* that references it. Permanent deletion therefore means: stop the session and
* gate it against new work, remove its stored artifacts together with its
* subagent descendants' logs and their spilled tool output, and drop the
* registry references to it.
*
* The harness deliberately has no deletion API of its own — the shipped JSONL
* backend's own documentation says session files accumulate under `root` until
* something removes them externally — so removing them is this bundle's whole
* job. Two artifacts are deliberately left alone:
*
* - content-addressed attachments, because one blob can be referenced by
* several sessions and nothing in the harness counts those references;
* - the projection cache, which is derived and carries a lifecycle identity
* check on read, so it can never resolve to a different conversation.
*
* Spilled tool output is the session's one other session-scoped artifact: the
* spill backend stores oversized results at `<root>/session-<hash>/…`, where
* the directory is `sha256(session id)` truncated to 12 hex characters and the
* root is either the configured one or a `dsh-spill-*` directory under the OS
* temp directory.
*
* A session that is still live in this process cannot be removed from the
* in-memory Session and Agent stores by another plugin, and deleting a log
* under a live writer would let the next append recreate a header-less
* artifact. Such a session is archived (durably gated and stopped) now and its
* artifacts are removed on the next activation, when nothing is live yet.
*
* Cross-half contract: the browser half reaches both operations through the
* authenticated exact Fetch routes registered below (`ctx.connection.fetch`),
* which is how a static client bundle talks to its Host half — the `host.call`
* channel belongs to the dynamic client runner, not to a package bundle.
*
* @module @dsh-plugin/session-delete
*/
import { createHash } from 'node:crypto'
import { lstat, open, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { homedir, tmpdir } from 'node:os'
/** Authenticated exact route the Client half posts one deletion to. */
const ROUTE_PATH = '/api/plugin/session-delete/delete'
/** Authenticated exact route the General-settings row posts its sweep to. */
const ORPHAN_ROUTE_PATH = '/api/plugin/session-delete/orphans'
/** Durable record of sessions whose artifacts still have to be removed. */
const LEDGER_FILENAME = 'session-delete-pending.json'
/** The backend's per-session write lock; it survives its session on POSIX. */
const LEASE_FILENAME = 'session.lock'
/** Exactly the shape the local spill backend derives from a session id. */
const SPILL_SESSION_DIRECTORY = /^session-[0-9a-f]{12}$/
/** Services this Host half needs before it may run at all. */
export const inject = [
'connection',
'sessionPersistence',
'sessionQuery',
'workspaceRegistry',
]
/**
* Install the deletion routes and finish deletions deferred to this activation.
*
* @param ctx - Host context carrying the persistence, query, and Workspace services.
* @returns the activation's deferred-deletion promise. The runtime ignores it
* (the effect owns the work and catches its own failures); awaiting it is how
* a test observes that the startup sweep has settled.
*/
export function apply(ctx) {
const inFlight = new Set()
ctx.effect(() => ctx.connection.fetch.register({
path: ROUTE_PATH,
methods: ['POST'],
requestBody: 'buffered',
fetch: (request) => handleRequest(ctx, request, inFlight),
}), 'session-delete: delete route')
ctx.effect(() => ctx.connection.fetch.register({
path: ORPHAN_ROUTE_PATH,
methods: ['POST'],
requestBody: 'buffered',
fetch: () => handleOrphanSweep(ctx),
}), 'session-delete: orphan sweep route')
let swept
ctx.effect(() => {
let disposed = false
swept = sweepPending(ctx, () => disposed).catch((error) => {
warn(ctx, `could not finish deferred deletions: ${message(error)}`)
})
return () => {
disposed = true
}
}, 'session-delete: deferred deletions')
return swept
}
/**
* Answer one authenticated deletion request.
* @param ctx - Host context.
* @param request - buffered Fetch request carrying `{ sessionId }`.
* @param inFlight - identities already being deleted in this process.
* @returns the JSON result or a stable failure the Client half reports.
*/
async function handleRequest(ctx, request, inFlight) {
let body
try {
body = await request.json()
} catch {
return failure(400, 'bad-request', 'the request body must be JSON')
}
const sessionId = body?.sessionId
if (typeof sessionId !== 'string' || sessionId.trim() === '') {
return failure(400, 'bad-request', 'sessionId must be a non-empty string')
}
if (inFlight.has(sessionId)) {
return failure(409, 'busy', `conversation "${sessionId}" is already being deleted`)
}
inFlight.add(sessionId)
try {
const value = await deleteConversation(ctx, sessionId)
return json(value)
} catch (error) {
warn(ctx, `deleting "${sessionId}" failed: ${message(error)}`)
return failure(500, 'delete-failed', message(error))
} finally {
inFlight.delete(sessionId)
}
}
/**
* Delete one conversation and every subagent conversation under it.
* @param ctx - Host context.
* @param sessionId - conversation the user asked to delete.
* @returns `deleted` when the artifacts are gone, `scheduled` when a live
* session forced the removal to the next activation.
*/
async function deleteConversation(ctx, sessionId) {
const lineage = await resolveLineage(ctx, sessionId)
const ids = lineage.ids
if (lineage.liveIds.length > 0) {
for (const id of lineage.ids) await ctx.workspaceRegistry.archiveSession(id, { stopActivity: true })
await rememberPending(ctx, sessionId)
return { ok: true, status: 'scheduled', sessionIds: ids, live: lineage.liveIds }
}
const removed = []
for (const id of ids) {
const header = lineage.headers.get(id)
if (header === undefined) continue
if (await removeArtifacts(ctx, header)) removed.push(id)
}
const spilled = await removeSpillArtifacts(ctx, ids)
for (const id of ids) await forgetReferences(ctx, id)
for (const id of removed) announceRemoval(ctx, id)
return {
ok: true,
status: 'deleted',
sessionIds: ids,
removed,
spilledDirectories: spilled.directories,
spilledFiles: spilled.files,
}
}
/**
* Resolve one conversation and its subagent descendants from the query
* service's lineage trace.
*
* `traceSession` is the harness's own corpus observation: one call answers the
* whole parent/child structure, so this bundle never has to list every stored
* header and re-derive `parentSession` edges itself. Each copied
* `SessionRecord` also carries the header the deletion needs and a `live` flag,
* which is what keeps the `agents` and `sessions` services out of `inject`.
*
* @param ctx - Host context.
* @param sessionId - conversation the user asked to delete.
* @returns the target and descendant ids (parents first), their headers, and
* which of them this process currently holds live.
*/
async function resolveLineage(ctx, sessionId) {
let trace
try {
trace = await ctx.sessionQuery.traceSession(sessionId)
} catch (error) {
throw new Error(`conversation "${sessionId}" was not found: ${message(error)}`)
}
const target = trace?.target
const header = target?.header
if (target === undefined || header === undefined) {
throw new Error(`conversation "${sessionId}" was not found`)
}
if (header.origin === 'subagent') {
throw new Error('a subagent conversation is removed together with the conversation that owns it')
}
const headers = new Map([[String(header.id), header]])
const ids = [String(header.id)]
const liveIds = target.live === true ? [String(header.id)] : []
collectDescendants(trace.descendants, headers, ids, liveIds)
return { headers, ids, liveIds }
}
/**
* Walk one already-traced descendant tree into flat id order.
* @param nodes - lineage nodes, nearest generation first.
* @param headers - accumulator keyed by session id.
* @param ids - accumulator in parents-first order.
* @param liveIds - accumulator of ids this process holds live.
*/
function collectDescendants(nodes, headers, ids, liveIds) {
for (const node of Array.isArray(nodes) ? nodes : []) {
const header = node?.session?.header
if (header === undefined) continue
const id = String(header.id)
if (headers.has(id)) continue
headers.set(id, header)
ids.push(id)
if (node.session.live === true) liveIds.push(id)
collectDescendants(node.descendants, headers, ids, liveIds)
}
}
/**
* Derive one session's spill directory name exactly as the local spill backend
* does: `sha256(session id)` truncated to 12 hex characters.
* @param sessionId - owning session identity.
* @returns the directory name shared by every root.
*/
function spillSessionDirectory(sessionId) {
return `session-${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 12)}`
}
/**
* Every spill root this process may have written under: the backend's active
* root plus the `dsh-spill-*` default roots the backend itself sweeps.
* @param ctx - Host context.
* @returns absolute candidate roots.
*/
async function spillRoots(ctx) {
const roots = new Set()
const configured = ctx.get('spillStore')?.root
if (typeof configured === 'string' && configured !== '') roots.add(configured)
const base = tmpdir()
let entries = []
try {
entries = await readdir(base, { withFileTypes: true })
} catch (error) {
warn(ctx, `spill roots under ${base} could not be listed: ${message(error)}`)
}
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('dsh-spill')) roots.add(join(base, entry.name))
}
return [...roots]
}
/**
* Remove the spilled tool output of the given sessions.
*
* Only a real, session-named directory under a spill root is removed; a symlink,
* a file, or any other entry is left untouched, and a failure is reported
* without failing the deletion that already happened.
*
* @param ctx - Host context.
* @param sessionIds - identities whose spilled output goes away.
* @returns how many directories and files were removed.
*/
async function removeSpillArtifacts(ctx, sessionIds) {
const names = sessionIds.map(spillSessionDirectory)
let directories = 0
let files = 0
for (const root of await spillRoots(ctx)) {
for (const name of names) {
const directory = join(root, name)
let info
try {
info = await lstat(directory)
} catch {
continue
}
if (info.isSymbolicLink() || !info.isDirectory()) continue
try {
files += (await readdir(directory)).length
} catch {
/* a listing failure never blocks the removal */
}
try {
await rm(directory, { recursive: true, force: true })
directories += 1
} catch (error) {
warn(ctx, `spilled output at ${directory} was kept: ${message(error)}`)
}
}
}
return { directories, files }
}
/**
* Answer the General-settings cleanup request.
* @param ctx - Host context.
* @returns the JSON report the settings row shows.
*/
async function handleOrphanSweep(ctx) {
try {
const report = await sweepOrphanSpill(ctx)
return json({ ok: true, ...report })
} catch (error) {
warn(ctx, `orphan sweep failed: ${message(error)}`)
return failure(500, 'sweep-failed', message(error))
}
}
/**
* Remove spilled tool output that no session known to this home directory can
* own.
*
* A spill directory is orphaned only when its name has exactly the
* `session-<sha256(session id)[:12]>` shape the spill backend derives AND no
* session known to this process hashes to it. `listSessions()` already merges
* live and persisted sessions into one logical corpus, so "known" needs no
* second source. Anything else, including a symlink or an entry the backend
* would never create, is left alone.
*
* @param ctx - Host context.
* @returns counts for the settings row.
*/
async function sweepOrphanSpill(ctx) {
const records = await ctx.sessionQuery.listSessions()
const known = new Set()
for (const record of records) {
const id = record?.header?.id
if (id !== undefined) known.add(spillSessionDirectory(id))
}
const report = { directories: 0, files: 0, bytes: 0, roots: 0, sessions: known.size }
for (const root of await spillRoots(ctx)) {
report.roots += 1
let entries
try {
entries = await readdir(root, { withFileTypes: true })
} catch (error) {
warn(ctx, `spill root ${root} could not be listed: ${message(error)}`)
continue
}
for (const entry of entries) {
if (!entry.isDirectory() || !SPILL_SESSION_DIRECTORY.test(entry.name)) continue
if (known.has(entry.name)) continue
const directory = join(root, entry.name)
try {
const info = await lstat(directory)
if (info.isSymbolicLink() || !info.isDirectory()) continue
const measured = await measureDirectory(directory)
await rm(directory, { recursive: true, force: true })
report.directories += 1
report.files += measured.files
report.bytes += measured.bytes
} catch (error) {
warn(ctx, `orphaned spill directory ${directory} was kept: ${message(error)}`)
}
}
}
return report
}
/**
* Measure one spill session directory. The backend writes every artifact
* directly into it, so leaf files are the whole content.
* @param directory - spill session directory.
* @returns file count and total bytes.
*/
async function measureDirectory(directory) {
let files = 0
let bytes = 0
let entries
try {
entries = await readdir(directory, { withFileTypes: true })
} catch {
return { files, bytes }
}
for (const entry of entries) {
if (!entry.isFile()) continue
try {
bytes += (await stat(join(directory, entry.name))).size
files += 1
} catch {
/* a file that vanished mid-measure simply does not count */
}
}
return { files, bytes }
}
/**
* Remove every stored generation of one session, keeping its write lock file.
*
* The absolute artifact path comes from the backend's `locate()` hook. That
* hook is not part of the abstract `sessionPersistence` contract — the seam
* itself declares only `create`, `open`, `flush`, `stat`, and `list` — so it is
* probed rather than assumed, and a backend without it fails this one
* conversation instead of silently deleting nothing. The returned path names
* the highest canonical generation *file*, so its parent directory is the
* session-owned directory whose contents go away.
*
* @param ctx - Host context.
* @param header - the session's stored header, which names its artifact.
* @returns whether anything was removed.
*/
async function removeArtifacts(ctx, header) {
const persistence = ctx.get('sessionPersistence')
if (typeof persistence?.locate !== 'function') {
throw new Error('this session storage backend cannot locate stored artifacts, so nothing was deleted')
}
const located = persistence.locate(header)
const artifactPath = located?.path
if (typeof artifactPath !== 'string' || artifactPath === '') {
throw new Error(`conversation "${String(header.id)}" has no stored artifact to delete`)
}
const directory = dirname(artifactPath)
let entries
try {
entries = await readdir(directory, { withFileTypes: true })
} catch (error) {
if (error?.code === 'ENOENT') return false
throw error
}
let removed = false
for (const entry of entries) {
if (entry.name === LEASE_FILENAME) continue
await rm(join(directory, entry.name), { recursive: true, force: true })
removed = true
}
if (removed && process.platform !== 'win32') await syncDirectory(directory)
return removed
}
/**
* Flush one directory entry set on POSIX, where removal is not durable until
* the parent directory is synced.
* @param directory - directory to sync.
*/
async function syncDirectory(directory) {
const handle = await open(directory, 'r')
try {
await handle.sync()
} finally {
await handle.close()
}
}
/**
* Drop every durable reference to a session whose artifacts are gone:
* Workspace accounting, the archive set, and the pin set. Each call is
* idempotent for an id it does not hold, so a failure is reported and the
* remaining references are still cleared.
* @param ctx - Host context.
* @param sessionId - removed identity.
*/
async function forgetReferences(ctx, sessionId) {
const registry = ctx.workspaceRegistry
for (const workspace of registry.list()) {
try {
await workspace.detachSession(sessionId)
} catch (error) {
warn(ctx, `workspace "${workspace.id}" kept a reference to "${sessionId}": ${message(error)}`)
}
}
for (const [operation, run] of [
['unpin', () => registry.unpinSession(sessionId)],
['unarchive', () => registry.unarchiveSession(sessionId)],
]) {
try {
await run()
} catch (error) {
warn(ctx, `${operation} of "${sessionId}" failed: ${message(error)}`)
}
}
}
/** Announce one removed conversation so connected pages drop its row. */
function announceRemoval(ctx, sessionId) {
try {
ctx.emit('api-session/removed', sessionId)
} catch (error) {
warn(ctx, `could not announce removal of "${sessionId}": ${message(error)}`)
}
}
/**
* Finish deletions deferred by a live session.
*
* Every ledger entry is a root conversation, so each iteration resolves its own
* subtree and removes it whole; a child is never recorded separately, because
* the sweep that removes a root may not re-run for an entry a sibling root
* already took with it. An entry is deleted only while it is still archived, so
* restoring a conversation in the sidebar cancels its pending deletion: the
* durable archive set is what the sidebar's own unarchive drops.
*
* @param ctx - Host context.
* @param isDisposed - whether this plugin already unloaded.
*/
async function sweepPending(ctx, isDisposed) {
const pending = await readLedger(ctx)
if (pending.length === 0) return
// The registry-global archive set; membership is the user's own "still
// archived" decision and changes only through the sidebar or this plugin.
const registry = ctx.workspaceRegistry
const archivedSessionIds = registry.archivedSessionIds
const remaining = []
for (const sessionId of pending) {
if (isDisposed()) {
remaining.push(sessionId)
continue
}
let lineage
try {
lineage = await resolveLineage(ctx, sessionId)
} catch (error) {
// The conversation is not in the corpus any more: what is left to do is
// drop the registry references a previous run did not reach, and stop
// retrying it on every activation.
warn(ctx, `deferred deletion of "${sessionId}" resolved to nothing: ${message(error)}`)
await forgetReferences(ctx, sessionId)
continue
}
if (lineage.liveIds.length > 0) {
remaining.push(sessionId)
continue
}
if (!archivedSessionIds.includes(sessionId)) continue
try {
for (const id of lineage.ids) {
const header = lineage.headers.get(id)
if (header !== undefined) await removeArtifacts(ctx, header)
}
await removeSpillArtifacts(ctx, lineage.ids)
for (const id of lineage.ids) await forgetReferences(ctx, id)
announceRemoval(ctx, sessionId)
} catch (error) {
warn(ctx, `could not finish deleting "${sessionId}": ${message(error)}`)
remaining.push(sessionId)
}
}
await writeLedger(ctx, remaining)
}
/**
* Read the deferred-deletion record.
* @param ctx - Host context.
* @returns session ids still waiting for removal.
*/
async function readLedger(ctx) {
try {
const parsed = JSON.parse(await readFile(ledgerPath(ctx), 'utf8'))
return Array.isArray(parsed?.sessions)
? parsed.sessions.filter((id) => typeof id === 'string' && id !== '')
: []
} catch {
return []
}
}
/**
* Replace the deferred-deletion record.
* @param ctx - Host context.
* @param sessionIds - ids still waiting for removal.
*/
async function writeLedger(ctx, sessionIds) {
const unique = [...new Set(sessionIds)]
const path = ledgerPath(ctx)
if (unique.length === 0) {
await rm(path, { force: true })
return
}
await writeFile(path, `${JSON.stringify({ version: 1, sessions: unique }, null, 2)}\n`, 'utf8')
}
/**
* Record the root conversation whose artifacts are removed on the next
* activation.
*
* Only a tree root is recorded: the activation sweep removes a root together
* with everything under it, so a separately recorded child would either be
* deleted twice or, once its root had taken it, need its entry dropped on a
* pass that never runs.
*
* @param ctx - Host context.
* @param sessionId - the root conversation of one deferred deletion.
*/
async function rememberPending(ctx, sessionId) {
const pending = await readLedger(ctx)
await writeLedger(ctx, [...pending, sessionId])
}
/**
* The deferred-deletion record lives beside the session store it describes, so
* it follows `$DSH_HOME` without this plugin reading harness configuration.
* @param ctx - Host context.
* @returns absolute ledger path.
*/
function ledgerPath(ctx) {
const root = ctx.get('sessionPersistence')?.root
if (typeof root === 'string' && root !== '') return join(dirname(root), LEDGER_FILENAME)
const home = process.env.DSH_HOME
const base = typeof home === 'string' && home !== '' ? home : join(homedir(), '.dsh')
return join(base, LEDGER_FILENAME)
}
/** Answer one JSON value the Client half reads, never cached. */
function json(value, status = 200) {
return Response.json(value, { status, headers: { 'cache-control': 'no-store' } })
}
/** Build one stable JSON failure the Client half surfaces verbatim. */
function failure(status, code, text) {
return json({ ok: false, error: { code, message: text } }, status)
}
/** Log one contained diagnostic. */
function warn(ctx, text) {
try {
ctx.logger?.warn?.(`session-delete: ${text}`)
} catch {
/* a diagnostic never fails the operation it describes */
}
}
/** Render one unknown failure as text. */
function message(error) {
return error instanceof Error ? error.message : String(error)
}
+6
View File
@@ -0,0 +1,6 @@
{
"meta": {
"title": "Session delete",
"description": "Permanently delete a conversation: stop it, remove its session log, subagent logs, and spilled tool output, then drop every registry reference. This plugin's own page sweeps what conversations that are gone left behind."
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"meta": {
"title": "会话彻底删除",
"description": "彻底删除一个会话:先停止它,再删除会话日志、子智能体日志与工具输出溢出文件,并清理相关注册表引用;这个插件的页面里可清理已删除会话遗留的孤立临时文件。"
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"name": "@dsh-plugin/session-delete",
"version": "2.0.1",
"private": true,
"type": "module",
"description": "Permanently delete a conversation: stop it, remove its session log, subagent logs, and spilled tool output, and drop every registry reference. The plugin's own page under Settings → Plugins can sweep orphaned temporary files.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://gitea.iwake.top/dsh-plugin/session-delete.git"
},
"homepage": "https://gitea.iwake.top/dsh-plugin/session-delete",
"exports": {
".": "./index.js",
"./client": "./client.js",
"./package.json": "./package.json",
"./locale/*.json": "./locale/*.json"
},
"icon": "./icon.svg",
"meta": {
"title": "Session delete",
"description": "Permanently delete a conversation and its stored log, and sweep orphaned temporary files."
},
"files": [
"index.js",
"client.js",
"cordis.patch.yml",
"icon.svg",
"locale/*.json",
"README.md",
"CONTRIBUTING.md",
".gitmessage",
"scripts/**/*.mjs",
"LICENSE"
],
"engines": {
"node": ">=22",
"dsh": "0.2.0-rc.2"
},
"dsh": {
"manifestVersion": 1,
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"immediately": true,
"inject": [
"@deepseek-ai/dsh-client-ui-workspace"
]
}
}
}
+88
View File
@@ -0,0 +1,88 @@
// Checks existing commits against the CONTRIBUTING.md spec. The spec allows one
// line only, so a subject must both parse (emoji + type + Chinese description)
// and be the whole message: any body or footer is reported too.
//
// This is a reporting tool, not a hook: it never blocks a commit.
//
// Usage:
// node scripts/check-commit-log.mjs # newest 10 commits
// node scripts/check-commit-log.mjs 50 # newest 50 commits
// node scripts/check-commit-log.mjs all # every commit
// DSD_SPEC=path/to/CONTRIBUTING.md node scripts/check-commit-log.mjs
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const specPath = process.env.DSD_SPEC ?? resolve(repoRoot, 'CONTRIBUTING.md')
const doc = readFileSync(specPath, 'utf8')
const count = process.argv[2] ?? '10'
/** The regex the spec documents for a subject line. */
const SUBJECT = new RegExp(
'^(?:\\p{Extended_Pictographic}\\uFE0F?\\s+)?' +
'(?<type>[a-z]+)' +
'(?:\\((?<scope>[^)]*)\\))?' +
'(?<breaking>!)?' +
':\\s(?<desc>.+)$',
'u',
)
// The spec's table is the single source of truth for the type → emoji mapping,
// so a table edit cannot drift from what this script checks.
const emojiOfType = new Map()
for (const row of doc.split('\n').filter((line) => /^\| `[a-z]+` \| /.test(line))) {
const cells = row.split('|').map((cell) => cell.trim())
emojiOfType.set(cells[1].replaceAll('`', ''), cells[2])
}
if (emojiOfType.size === 0) throw new Error(`${specPath} has no type table to read`)
/** Strip the variation selector, so '♻' and '♻️' compare equal. */
const base = (text) => text.replace(/\uFE0F/g, '')
/** Display width, counting anything above U+2000 as double. */
const widthOf = (text) => [...text].reduce((n, c) => n + (c.codePointAt(0) > 0x2000 ? 2 : 1), 0)
// %B is the raw message body, so a record can hold several lines: a record ends
// at the next hash that starts a line, which is what this splits on.
const range = count === 'all' ? [] : [`-${Number(count)}`]
const raw = execFileSync('git', ['log', ...range, '--format=%h%x09%B%x00'], {
encoding: 'utf8',
cwd: repoRoot,
})
const commits = raw.split('\0').map((chunk) => chunk.replace(/^\n+/, '')).filter((chunk) => chunk.trim() !== '')
let problems = 0
for (const chunk of commits) {
const tab = chunk.indexOf('\t')
const hash = chunk.slice(0, tab)
const message = chunk.slice(tab + 1).replace(/\n+$/, '')
const lines = message.split('\n')
const subject = lines[0]
const extra = lines.slice(1).filter((line) => line.trim() !== '')
const notes = []
const match = SUBJECT.exec(subject)
if (match === null) {
notes.push('首行缺少类型前缀,或整体格式不是 <emoji> <type>[(scope)][!]: <描述>')
} else {
const expected = emojiOfType.get(match.groups.type)
const lead = [...subject][0]
if (expected === undefined) notes.push(`类型不在规范表里:${match.groups.type}`)
else if (base(lead) !== base(expected)) notes.push(`类型 ${match.groups.type} 的 emoji 应为 ${expected},实际是 ${lead}`)
const width = widthOf(subject)
if (width > 72) notes.push(`首行宽度 ${width},超过 72`)
if (/[。.]$/.test(subject)) notes.push('首行结尾不应有句号')
}
if (extra.length > 0) notes.push(`带了 ${extra.length} 行正文或脚注,规范要求只有一行`)
if (notes.length > 0) problems += 1
const mark = notes.length === 0 ? 'ok ' : 'FAIL'
console.log(` ${mark} ${hash} ${notes.length === 0 ? '' : notes.join(';') + ' — '}${subject}`)
}
console.log(problems === 0
? `\n检查了 ${commits.length} 条提交,全部符合规范`
: `\n检查了 ${commits.length} 条提交,其中 ${problems} 条不符合规范(规范文件:${specPath})`)
process.exit(problems === 0 ? 0 : 1)