commit 39a39adfec46e5e3f1db80cf49f54a2a56873874 Author: pyh Date: Wed Sep 30 10:36:39 2026 +0800 feat: permanently delete a conversation, sweep orphaned spill files diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc5c6be --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.log +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a42829 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..114f987 --- /dev/null +++ b/README.md @@ -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-`——这是 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 后端的目录规则(`/session-/…`)。 + +## License + +MIT diff --git a/client.js b/client.js new file mode 100644 index 0000000..bd79b97 --- /dev/null +++ b/client.js @@ -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?.() + } + } +} diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..b12bfd2 --- /dev/null +++ b/cordis.patch.yml @@ -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' diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..136c1a2 --- /dev/null +++ b/icon.svg @@ -0,0 +1,7 @@ + diff --git a/index.js b/index.js new file mode 100644 index 0000000..f312c57 --- /dev/null +++ b/index.js @@ -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 `/session-/…`, 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-` 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) +} diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..28b8b27 --- /dev/null +++ b/locale/en.json @@ -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." + } +} diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..c437191 --- /dev/null +++ b/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "会话彻底删除", + "description": "彻底删除一个会话:先停止它,再删除会话日志、子智能体日志与工具输出溢出文件,并清理相关注册表引用;设置 → 通用设置里可清理已删除会话遗留的孤立临时文件。" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4037e9b --- /dev/null +++ b/package.json @@ -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" + ] + } + } +}