feat: permanently delete a conversation, sweep orphaned spill files

This commit is contained in:
pyh
2026-09-30 10:43:38 +08:00
commit 39a39adfec
10 changed files with 1408 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
*.log
.DS_Store
Thumbs.db
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 dsh-plugin
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.
+79
View File
@@ -0,0 +1,79 @@
# dsh-session-delete
DeepSeek Harness 插件:**彻底删除会话**,外加在 设置 → 通用设置 里**清理孤儿临时文件**。
DeepSeek Harness 原本只能归档会话,归档只是从列表里移开、日志仍然留在磁盘上;这个插件补齐了真正不可逆的删除。
## 功能
### 1. 删除会话
入口:
- 会话行 "..." 菜单 → **删除会话**(同一行的悬停快捷按钮也接管为删除,原"归档会话"按钮的位置)
- 两处入口都会弹出确认弹窗(复刻系统 Modal 的样式与键盘行为:Esc 关闭、Tab 焦点圈定、打开时焦点进入、关闭后焦点归位)
删除时一并处理:
| 对象 | 处理方式 |
| --- | --- |
| 会话日志目录 | 整个目录删除,只保留 `session.lock`(POSIX 语义下该锁文件随会话存活,删除它没有意义且可能影响锁语义) |
| 子智能体会话 | 按 `parentSession` 递归收集,连同各自的日志与溢出文件一起删除 |
| 工具输出溢出文件(spill) | 删除该会话(含子会话)对应的 spill 目录 |
| 工作区登记项 | 解除与该会话的绑定(detach)、取消置顶、取消归档 |
| 客户端列表 | 广播 `api-session/removed`,列表行立即消失 |
有意**不**处理的部分:
- **附件对象**(`$DSH_HOME/attachments/v1/objects`):按内容 sha256 寻址,同一份内容可能被多个会话共享,而 harness 里没有任何引用计数;跟着一个会话删除会破坏别的会话。要做只能实现"引用检查式清扫"(先扫剩余会话日志确认无人引用才删)。
- **投影缓存**(`$DSH_HOME/storages/session_projcache`):由日志派生的缓存,读取时带生命周期身份校验,永远不会串到别的会话;投影缓存服务本身也没有提供删除接口。
### 2. 清理临时文件(设置 → 通用设置)
只清理**孤儿**,判定规则偏保守:
1. 目录名必须精确等于 `session-<sha256(会话 id) 前 12 位十六进制>`——这是 spill 后端唯一会生成的形状;
2. 该名字不能属于任何已知会话(存量会话与内存中正在运行的会话都算);
3. 只在 spill 的 root 内操作(后端当前 root + `%TEMP%\dsh-spill*`);
4. 符号链接一律跳过不跟随,单项失败只记警告。
结果是幂等的,可以反复点击。
## 行为与边界
- **仍在运行的会话无法被插件从内存里移除**:它会先被归档并停止,日志与溢出文件在**下次启动 DeepSeek Harness 时**删除(界面会明确提示"已先停止并归档…");在此之前取消归档就会取消这次删除。
- 删除**不可恢复**,但**不是安全擦除**:按普通文件删除处理,底层介质上可能有残留。
- 工作区里的代码文件不受影响。
- 溢出文件被删除后,如果某个 fork 的日志里仍保留着指向它的路径文本,那些路径会失效——这与"父会话数据被删除"是同一件事。
## 安装
这是一个 DeepSeek Harness **bundle** 包(普通 Node 包 + `cordis.patch.yml`),由 Harness 的插件管理机制安装,而不是单独运行的程序:
- `package.json` 声明 `dsh.bundle.patch`(bundle 补丁)与 `dsh.client`(web 平台客户端半)
- `cordis.patch.yml` 把 `@local/dsh-session-delete` 插进 profile
- 安装后 profile 的依赖会是 `link:<本目录绝对路径>`,并在 `node_modules` 下建立链接
安装或升级后:**宿主半需要重启客户端**,只改浏览器半时刷新页面即可。
## 结构
```
index.js 宿主半:删除流程、溢出文件清理、两条已认证路由、启动补删
client.js 浏览器半:菜单行、行悬停按钮 + 气泡、确认弹窗、设置行
cordis.patch.yml bundle 补丁
locale/*.json 插件管理页显示的标题与描述
icon.svg 插件图标
```
## 兼容性
为 DeepSeek Harness **0.2.0-rc.2** 编写。
浏览器半**不 import 任何 Harness 客户端包**(上游规范要求如此:这些包随时可能变化,且渲染抛错会让整个插槽条目变空白),而是复刻 `dsh-client-ui-primitives` 的 markup、CSS 声明、图标 path 数据与键盘行为,只保留 `--dsw-*` 主题 token 引用。如果上游改动这些内部结构,界面细节可能失配(例如不再弹出气泡),但不会导致插件加载失败或崩溃。
删除逻辑只依赖稳定的服务契约:`sessionPersistence`、`sessionQuery`、`workspaceRegistry`、`sessions`、`agents`、`connection.fetch`,以及 spill 后端的目录规则(`<root>/session-<sha256(id)[:12]>/…`)。
## License
MIT
+662
View File
@@ -0,0 +1,662 @@
/**
* Browser half of the session-delete bundle.
*
* One row in a conversation's "..." menu opens 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.
*
* Harness Client packages may change without notice, so this bundle imports no
* Harness Client module: its menu row and dialog copy the shipped primitives'
* markup, stylesheet declarations, and focus/Escape behavior, keeping only
* `--dsw-*` theme-token references. React comes from the page's module table.
*/
window.__ModuleLoader__.load({
id: '@local/dsh-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'
const MENU_ORDER = 500
/** The shipped hover button's id; reusing its id takes that cell over. */
const ROW_ACTION_ID = 'archive'
const ROW_ACTION_ORDER = 100
/** Below the shipped entry's implicit 0: the lowest priority renders. */
const ROW_ACTION_PRIORITY = -1
/** General-settings position: after the log-upload row, before the version. */
const SETTINGS_ORDER = 95
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. 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-row{border-bottom:.5px solid var(--dsw-alias-border-l2);align-items:center;gap:8px;padding:16px 0;display:flex}
.dsd-row-text{flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px;display:flex}
.dsd-row-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}
.dsd-row-desc{color:var(--dsw-alias-label-tertiary);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. Redrawing a smaller glyph in a 24-unit box is what
* made an earlier version of this row look undersized.
*/
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
}
/**
* Withdraw the session row's hover preview while this button's own tooltip
* is showing.
*
* The shipped row buttons get this for free: `Tooltip` reports itself
* through the primitives' private `TooltipSuppression` context, and the
* enclosing `HoverCard` — whose anchor is the whole row, so a button inside
* it counts as hovering the row — hides its card while that bubble is up.
* That context object belongs to another module instance and cannot be
* reached from here, so this reproduces the same outcome through the DOM:
* a `pointerout` on the card's anchor wrapper makes React deliver
* `onPointerLeave` to that wrapper alone, which is exactly the cancel the
* hover card performs on a real leave. Nothing else in the tree is affected
* because the related target is the wrapper's own parent.
*
* @param button - the hovered row button.
*/
function withdrawRowPreview(button) {
try {
const wrapper = button.closest('[data-row-key]')?.parentElement
if (wrapper === null || wrapper === undefined) return
wrapper.dispatchEvent(new PointerEvent('pointerout', {
bubbles: true,
cancelable: true,
composed: true,
pointerType: 'mouse',
relatedTarget: wrapper.parentElement,
}))
} catch {
/* an unchanged host simply keeps showing its card */
}
}
/**
* The row's hover button, in the cell the shipped archive action occupied.
* Its tooltip copies the primitive's bubble: the same delay, the same
* bottom/end placement, and the same fade.
*/
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 = () => {
if (buttonRef.current !== null) withdrawRowPreview(buttonRef.current)
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))
}
/**
* The General-settings row: sweep spilled tool output whose conversation no
* longer exists. The owner supplies nothing, so copy, control, and result
* all belong to this entry.
*/
function TempCleanupRow(props) {
const t = translate(props.t)
const cleanOrphans = props.cleanOrphans
const [state, setState] = React.useState({ phase: 'idle', failed: false, message: '' })
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-row' },
h('div', { className: 'dsd-row-text' },
h('div', { className: 'dsd-row-title' }, t('cleanup.title')),
h('div', { className: 'dsd-row-desc' }, t('cleanup.description')),
state.phase === 'done' && h('div', {
className: `dsd-status${state.failed ? ' dsd-status-error' : ''}`,
role: state.failed ? 'alert' : 'status',
}, state.message)),
h('button', {
type: 'button',
className: 'dsd-button dsd-outline',
disabled: busy,
onClick: run,
}, busy ? t('cleanup.pending') : t('cleanup.action'))))
}
/** Render a byte count the way the settings row 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. 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) {
throw new Error(payload?.error?.message ?? `HTTP ${response.status}`)
}
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 reuses the shipped archive action's id, so the row keeps
// one hover button and archiving stays available from the "..." menu.
// Same id at the same priority is refused as a duplicate, and the
// lowest priority renders, so this row must sit below the shipped 0.
ctx.slots.inject('sidebar.workspaces.session.row.action', () => ctx.slots.register({
name: 'sidebar.workspaces.session.row.action',
id: ROW_ACTION_ID,
order: ROW_ACTION_ORDER,
priority: ROW_ACTION_PRIORITY,
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))
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: `${NS}-temp-cleanup`,
order: SETTINGS_ORDER,
locale: NS,
inject: () => ({ cleanOrphans }),
}, TempCleanupRow))
},
}
},
})
/** 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?.()
}
}
}
+5
View File
@@ -0,0 +1,5 @@
# Profile patch layer for the session-delete bundle: one Host row that owns the
# deletion operation and the authenticated route the browser half calls.
- insert:
- id: session-delete
name: '@local/dsh-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

+578
View File
@@ -0,0 +1,578 @@
/**
* 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.
*
* 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. Those files are otherwise reclaimed only by the backend's own
* age sweep, so deletion removes them here. Content-addressed attachments are
* deliberately left alone: one blob can be referenced by several sessions, and
* nothing in the harness counts those references.
*
* 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.
*
* The browser half reaches this operation through the authenticated exact
* Fetch route registered below; it is the only cross-half entry point this
* package needs.
*
* @module @local/dsh-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 = [
'agents',
'connection',
'sessionPersistence',
'sessionQuery',
'sessions',
'workspaceRegistry',
]
/**
* Install the deletion route and finish deletions deferred to this activation.
* @param ctx - Host context carrying the Session, Agent, storage, and Workspace services.
*/
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')
ctx.effect(() => {
let disposed = false
sweepPending(ctx, () => disposed).catch((error) => {
warn(ctx, `could not finish deferred deletions: ${message(error)}`)
})
return () => {
disposed = true
}
}, 'session-delete: deferred deletions')
}
/**
* 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 Response.json(value, { headers: { 'cache-control': 'no-store' } })
} 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 this run's end.
*/
async function deleteConversation(ctx, sessionId) {
const headers = await collectHeaders(ctx)
const target = headers.get(sessionId)
if (target === undefined) throw new Error(`conversation "${sessionId}" was not found`)
if (target.origin === 'subagent') {
throw new Error('a subagent conversation is removed together with the conversation that owns it')
}
const ids = [sessionId, ...subagentDescendants(headers, sessionId)]
const live = ids.filter((id) => isLive(ctx, id))
if (live.length > 0) {
for (const id of ids) await ctx.workspaceRegistry.archiveSession(id, { stopActivity: true })
await rememberPending(ctx, ids)
return { ok: true, status: 'scheduled', sessionIds: ids, live }
}
const removed = []
for (const id of ids) {
const header = 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,
}
}
/**
* 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 Response.json({ ok: true, ...report }, { headers: { 'cache-control': 'no-store' } })
} 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 — stored or live — hashes to it. 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 headers = await collectHeaders(ctx)
const known = new Set([...headers.keys()].map(spillSessionDirectory))
const report = { directories: 0, files: 0, bytes: 0, roots: 0, sessions: headers.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 }
}
/**
* Read every session header this process knows: stored ones plus live ones.
* @param ctx - Host context.
* @returns headers keyed by session id.
*/
async function collectHeaders(ctx) {
const headers = new Map()
for (const record of await ctx.sessionQuery.listSessions()) {
const header = record?.header
if (header !== undefined) headers.set(String(header.id), header)
}
for (const session of ctx.get('sessions')?.list() ?? []) {
headers.set(String(session.id), session.header)
}
return headers
}
/**
* Collect the subagent conversations stored underneath one conversation.
* @param headers - every known session header.
* @param rootId - the conversation being deleted.
* @returns descendant ids, nearest first.
*/
function subagentDescendants(headers, rootId) {
const children = new Map()
for (const header of headers.values()) {
if (header.origin !== 'subagent' || header.parentSession === undefined) continue
const parent = String(header.parentSession)
const rows = children.get(parent)
if (rows === undefined) children.set(parent, [String(header.id)])
else rows.push(String(header.id))
}
const found = []
const seen = new Set([rootId])
const queue = [rootId]
while (queue.length > 0) {
for (const child of children.get(queue.shift()) ?? []) {
if (seen.has(child)) continue
seen.add(child)
found.push(child)
queue.push(child)
}
}
return found
}
/**
* Whether this process currently holds the session or its agent in memory.
* @param ctx - Host context.
* @param sessionId - candidate identity.
* @returns true while the session is live.
*/
function isLive(ctx, sessionId) {
return ctx.agents.get(sessionId) !== undefined || ctx.get('sessions')?.get(sessionId) !== undefined
}
/**
* Remove every stored generation of one session, keeping its write lock file.
* @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.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.
* @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. Each id is deleted only while
* it is still archived, so restoring a conversation in the sidebar cancels its
* pending deletion.
* @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
const registry = ctx.workspaceRegistry
const headers = await collectHeaders(ctx)
const remaining = []
for (const sessionId of pending) {
if (isDisposed()) {
remaining.push(sessionId)
continue
}
if (isLive(ctx, sessionId)) {
remaining.push(sessionId)
continue
}
if (!registry.archivedSessionIds.includes(sessionId)) continue
try {
const header = headers.get(sessionId)
if (header !== undefined) await removeArtifacts(ctx, header)
await removeSpillArtifacts(ctx, [sessionId])
await forgetReferences(ctx, sessionId)
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 ids whose artifacts are removed on the next activation. */
function rememberPending(ctx, sessionIds) {
return readLedger(ctx).then((pending) => writeLedger(ctx, [...pending, ...sessionIds]))
}
/**
* 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.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)
}
/** Build one stable JSON failure the Client half surfaces verbatim. */
function failure(status, code, text) {
return Response.json(
{ ok: false, error: { code, message: text } },
{ status, headers: { 'cache-control': 'no-store' } },
)
}
/** 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. Settings → General can sweep orphaned temporary files left by conversations that are gone."
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"meta": {
"title": "会话彻底删除",
"description": "彻底删除一个会话:先停止它,再删除会话日志、子智能体日志与工具输出溢出文件,并清理相关注册表引用;设置 → 通用设置里可清理已删除会话遗留的孤立临时文件。"
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@local/dsh-session-delete",
"version": "1.0.0",
"private": true,
"description": "Permanently delete a conversation: stop it, remove its session log, subagent logs, and spilled tool output, and drop every registry reference. Settings → General can sweep orphaned temporary files left by conversations that are gone.",
"type": "module",
"license": "MIT",
"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",
"LICENSE"
],
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"immediately": true,
"inject": [
"@deepseek-ai/dsh-client-ui-workspace"
]
}
}
}