session-manager-dsh
Verified@gxx-labs/session-manager-dsh · v0.1.1 · MIT · Web UI
DSH 插件:设置 →「会话管理」页签,批量管理已归档会话(批量取消归档 / 批量归档 / 彻底删除会话及其磁盘文件)。UI 复用 DSH 设计令牌与 primitives。
Install
dsh plugin add @gxx-labs/session-manager-dsh Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Readme
@gxx-labs/session-manager-dsh
DSH 插件:在 设置 →「会话管理」 中批量管理已归档会话——批量取消归档、批量归档, 以及从磁盘彻底删除会话(含日志文件)。
⚠️ 删除是不可逆的。 DSH 本身不提供删除会话的能力,本插件以宿主文件操作实现, 属于超出官方保证范围的行为。使用前请读完本文件,尤其是「安全设计」与「风险」两节。
功能
设置页签 — 在 settings.section 槽位注册「会话管理」,与「通用」「插件」并列。
列表与筛选
- 两个视图:已归档(默认)/ 全部会话
- 搜索:标题或路径,大小写不敏感
- 排序:最近更新 / 标题 / 占用空间
- 分页:每页 20 条,超过一页时出现分页条并显示「第 a–b 项,共 N 项」
- 全选当前页(三态复选框)
分页与「全选」的关系(重要):全选只作用于当前页,不会选中看不见的会话。 若全选作用在未分页的全量上,用户在第 1 页点全选就会连带选中第 7 页的条目, 批量删除时会删掉从未看见过的会话——这正是分页引入后最危险的点,已由测试钉死。 批量操作作用于选中集合(已由
reconcile收敛),与翻页无关。页码一律收敛到合法范围:筛选后数据变少不会停在空白页;切换视图/搜索/排序会回到第 1 页。
批量操作
- 取消归档(恢复):从归档集移除,会话回到侧栏原位置
- 归档:对「全部会话」视图中的选中项归档;若会话正在运行,弹窗询问是否停止后归档
- 彻底删除:删除会话目录、投影缓存、workspace 记账
单条操作 — 每行提供「取消归档」图标按钮,无需先勾选。
结果回执 — 逐条结果反馈;成功汇总为提示,失败项在列表内标红并说明原因, 且失败项保持选中,便于重试。绝不静默吞掉失败。
安装
方式一:GUI(推荐)
设置 → 插件 → 添加插件,填入包名或本地路径。
本包声明了 dsh.bundle.patch(指向自带的 cordis.patch.yml),安装器能把它作为
profile layer 正确挂载——Loader 行由包自己提供,无需手工编辑 profile。
方式二:命令行
# 本地目录必须用 file: URL,不要用相对路径
dsh plugin --profile desktop add "file:C:/path/to/@gxx-labs/session-manager-dsh"
踩坑记录:不要传相对路径或裸
link:前缀。 一次实测中add "<本地目录>"被 pnpm 解析成link:/,它把源目录当成依赖去链接 并清空了源目录内容。用file:+ 绝对路径可避免。
方式三:手工挂载
# 1. 备份
cp ~/.dsh/profiles/desktop/{package.json,pnpm-lock.yaml,cordis.patch.yml} ~/.dsh/profiles/desktop/.backup/
# 2. 在 profile 的 package.json 加依赖
# 3. pnpm install
# 4. 若无插件管理器代劳,在 cordis.patch.yml 追加:
# - insert:
# - id: @gxx-labs/session-manager-dsh
# name: '@gxx-labs/session-manager-dsh'
行名必须是裸包名。
dsh-client-modules的locatePkgJson()只认精确包名; 写成@gxx-labs/session-manager-dsh/client会让它返回undefined,浏览器半不会被注册, 设置页签就不会出现。
装完刷新页面(client 侧改动无需重启宿主)。
数据与删除范围
会话在磁盘上的位置
| 项 | 路径 |
|---|---|
| 会话根目录 | <DSH_HOME>/sessions(默认 ~/.dsh/sessions) |
| 单个会话目录 | <root>/<projectKey(cwd)>/<encodeSegment(sessionId)>/ |
| 日志文件 | session.v4.jsonl.zstd(该目录内) |
| 写锁文件 | session.lock(该目录内) |
| 投影缓存 | <DSH_HOME>/storages/session_projcache/sessions/<sessionId>.json |
| 归档集与记账 | <DSH_HOME>/storages/workspace.json |
projectKey 与 encodeSegment 逐字复刻自 dsh-session-persistence-jsonl 的内部实现。
目录布局是本插件与 DSH 之间唯一的隐式契约——公式若不一致,删除就会指向错误路径,
因此 test/paths.test.mjs 用本机真实存在的会话目录名做回归断言,
scripts/dry-run.mjs 还能对真实数据做只读演练。
一次删除会清理三处
- 会话目录(日志 + 锁文件)—— 整个目录,不动
projectKey父目录(那里可能还有别的会话) - 投影缓存
<sessionId>.json - workspace 记账 —— 从
sessionIds与archivedSessionIds移除
缺少任何一处,会话都会残留或在侧栏"复活"。
安全设计
删除是本插件唯一不可逆的操作,因此设了硬执行闸门与五层交互防护。
执行顺序(本方案的核心,别调整)
一次删除按以下顺序执行,每一步的位置都有硬理由:
| 步 | 动作 | 为什么在这个位置 |
|---|---|---|
| 1 | 归档(stopActivity: true) |
侧栏立刻隐藏该会话,无需重启 DSH |
| 2 | evict(内存注册表移除) |
决定性一步:见下。必须在删文件之前 |
| 3 | 删除会话目录 + 投影缓存 | 磁盘彻底消失 |
| 4 | detachSession |
从 workspace 记账移除 |
| 5 | unarchive |
必须在文件 / 内存 / 记账都处理完之后 |
| 6 | sessions.refresh() |
让客户端目录同步 |
为什么必须有 evict(内存移除)
这是让条目真正消失的关键。DSH 的链路是:
ctx.sessions.list() ← 内存注册表
↓ 被无条件并入(records.set(id, { live: true }))
sessionQuery.listSessions()
↓
宿主 session.list() ← items.push(summaryFor(live))
↓
客户端 refresh() 拿到的基线 ← 里面仍有它
↓
侧栏继续显示
所以只删文件、只清记账都不够:只要会话还在内存注册表里,它必定出现在
list() 的返回中,侧栏就会继续显示它——refresh() 也救不了,因为它拿到的
基线本身就含内存会话。(这一条是本插件反复踩坑后才定位到的。)
evict 用 DSH 官方服务上的两个方法(不是 hack,是 DSH 自己 detach 时走的同一条路):
const entry = sessions.liveEntryFor(session) // session → entry
sessions.detachEntered(entry) // 从 store 移除 + emitDisposed
detachEntered 会发出 session/disposed。已核对全部监听者
(dsh-agent-loop / dsh-api-session-controller / dsh-client-file-upload /
dsh-experimental-agent-team)都只做内存记账,没有任何一个读会话文件——
所以即使它导致条目从客户端列表移除,也不会牵动磁盘。
为什么 evict 必须在 rm 之前:这是"先停用、再删除"。归档虽带
stopActivity(会取消在跑的 turn),但取消并不等到真正沉降;若先删文件、
后移除内存,那个仍在收尾的会话一旦追加事件,存储层会把刚删掉的目录
重新创建出来——那就白删了。先移除内存,存储层不再持有它,后续追加不落盘。
调用后立即自校验:evict 实现里会在 detachEntered 之后复查
sessions.get(id) === undefined。不这样做的话,一旦 this 绑定语义变化导致
静默失败,整个删除流程会"看起来成功、实际残留"。
拿不到这两个方法时抛错降级:文件照删,但保留归档标记(让侧栏仍然隐藏), 并如实提示"需重启 DSH"。绝不静默假装成功。
为什么 unarchive 必须最后
标记一清,sessionVisible() 就不再隐藏它。只有三项前置全部满足才安全:
canUnarchive = dirGone && detachOk && result.evicted === true
(文件已删) (记账已摘) (内存已移除)
任一未达成则保留标记:宁可留一个无害死 id(由启动清理兜底), 也不要把会话重新暴露到「未分组」。
硬执行闸门(任一不满足即拒绝,绝不"尽力而为"地删)
| 闸门 | 条件 | 失败码 |
|---|---|---|
| 1. 锁空闲 | 目标目录的 session.lock 可独占(否则说明正在被写入) |
locked |
| 2. 不越界 | 解析出的绝对路径必须位于会话根目录之内 | escaped-root |
为什么只保留这两道:早期版本曾用"会话是否在内存里"当闸门, 结果把大量正常会话误判为不可删(用户实测删不掉)。
读源码后确认:「在内存」≠「正在运行」。活跃的权威定义是
agent.status === 'running'(dsh-agent/archive-admission.js);
而归档只往 archivedSessionIds 里加一个 id(见 archiveSession),
它不停止 Agent、不卸载内存——归档是显示过滤标记,不是"让会话停止活动"。
既然归档已经解决了"删了还显示"的问题,内存状态就不再是拒绝理由。
归档集死 id 的启动清理
删除失败(或进程中断)时,归档标记会被故意保留——因为标记是侧栏隐藏的依据, 宁可留一个无害死 id,也不要把会话重新暴露出来。
这些残留由 lib/cleanup.js 在每次启动时收拾:
- 保守原则:只在确定会话已消失时才清
- 任何查询抛错(存储故障)一律
unknown并保留 —— 故障绝不能被当成"会话已删除" - 为什么必须做:
archivedSessionIds在 DSH 里没有任何过滤 (对比 workspace 实体的sessionIdsgetter 会按sessionPath过滤), 死 id 会永久留在workspace.json里,重启不会自己消失
交互防护
- 危险按钮语义独立 —— 不与「恢复」混排,带「永久删除,不可撤销」提示
- 路径预览 —— 确认弹窗列出将被删除的绝对路径(来自
/delete/preview,所见即所删) - 显式确认 —— 必须勾选「我已知晓此操作不可恢复」才能提交
- 逐条回执 —— 失败项保留在列表中并标红,附可操作原因
其他
- 写操作仅限本机 loopback,非本机返回 403
- 批量删除逐条串行(
workspaceRegistry内部用enqueueOperation串行化写入), 单条失败不中断整批 - 预览失败时关闭弹窗并提示错误,不会停在"按钮禁用且无路径"的死局
- 服务缺失(headless、非 Web profile)时优雅降级,不抛错
HTTP 接口
客户端半的读取走 DSH 客户端服务(ctx.get("workspaces") / ctx.get("sessions")),
与侧栏同源;删除与归档经宿主同源路由。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/session-manager/state |
会话根目录、归档集、固定集 |
| POST | /api/session-manager/archive |
{ ids, stopActivity? } |
| POST | /api/session-manager/unarchive |
{ ids } |
| POST | /api/session-manager/delete/preview |
{ ids } → 将删除的绝对路径清单(只读) |
| POST | /api/session-manager/delete |
{ ids } → 逐条结果 |
写操作限 loopback;失败一律以「逐条结果」返回,不因单条失败丢整批。
为什么需要宿主半
归档/取消归档有官方 Remote(workspaces.archiveSession/unarchiveSession),客户端可直呼;
但彻底删除需要 workspaceRegistry 的 detachSession(未暴露为任何 Remote 方法)
与文件系统操作。已安装的第三方插件拿不到 host.call,typert 的 @Remote 契约也由 DSH
构建流水线生成、第三方无法手写。因此走同源 webServer 路由——这也是
delivery-board-dsh 与 sapbuddy-dsh 实际工作的模式。
开发
pnpm install
pnpm run seed # 抽取宿主 primitives 种子(渲染测试需要)
pnpm run build # esbuild → dist/client.js(含 CSS 注入 + 类名对齐自检)
pnpm run verify # 产物契约与规范校验(13 项)
pnpm run test # 测试(94 个用例)
pnpm run dry-run # 对真实数据做只读演练
pnpm run check # build + verify + test
渲染测试需要宿主种子
test/render.test.mjs 用真实的 DSH primitives 渲染组件,而不是手写 stub——
stub 只能证明"替身能渲染",无法验证与宿主的属性契约。这些包只存在于 Electron 的
app.asar 内(profile 里那些 @deepseek-ai/* junction 实际指向不存在的路径),
所以 pnpm run seed 会把它们抽到 node_modules/.host-seed/ 并打包成单文件。
几个容易踩的坑(已在代码注释中标明)
- bundle 包装的
id必须等于package.json的name—— 客户端加载器按包名建 模块表键,写错会在浏览器里才炸。构建脚本从package.json读取,不硬编码。 - esbuild 必须用
--format=cjs,不能用iife—— 入口是 ESM,iife 输出不会 把导出挂到module.exports,包装层的return module.exports拿到空对象, 浏览器侧表现为"模块加载了但apply未定义"。 - CSS 必须由 JS
import——local-css会给类名加作用域前缀,JS 里读到的是映射后的 类名。若只在构建脚本里单独编 CSS 而 JS 不 import,两边类名不一致(CSS 是Sm_Sm_page、JS 是Sm_page),样式全部失效。构建脚本与verify-bundle.mjs双重断言。 - CSS 抽取要以 CSS 为入口 —— 入口是 JS 时 esbuild 会把 JS 文本写进
--outfile=x.css。 label必须是 thunk —— 导航每次投影时重读,切语言即跟随。- 声明
locale才能拿到t—— 渲染框架据此注入。 - jsdom 下 React 合成 onChange 不会由 DOM 事件触发 ——
_valueTracker、原生 setter、.click()三种都无效;渲染测试改为直接调用 fiber props 上的onChange。 - Modal 经 createPortal 挂到
document.body—— 弹窗断言必须查 body 而非容器。
结构
package.json 双面包声明(main = 宿主半,exports["./client"] = 浏览器半)
cordis.patch.yml 自带 Loader 行(行名必须为裸包名)
lib/paths.js 路径推导(复刻官方编码规则)+ 越界防护
lib/delete.js 删除执行器(三道闸门 + 三处清理)
lib/index.js 宿主半:webServer 路由 + 依赖装配
client/index.jsx 浏览器半:设置页签、列表、批量操作、确认弹窗
client/selection.js 选择/筛选纯逻辑(防"幽灵选中")
client/Sm.module.css CSS Modules:只用 --dsw-* 令牌
scripts/build-client.mjs esbuild 构建 + CSS 注入 + 类名对齐自检
scripts/build-host-seed.mjs 打包宿主 primitives 供渲染测试
scripts/fetch-host-seed.mjs 从 app.asar 抽取宿主种子
scripts/extract-css-from-bundle.mjs 从产物反向提取 CSS(源码丢失时的救急手段)
scripts/run-tests.mjs 测试运行器(同进程,规避沙箱 spawn 限制)
scripts/verify-bundle.mjs 产物契约校验
scripts/dry-run.mjs 真实数据只读演练
test/ paths / delete / routes / bundle / selection / render
client/selection.js 里的 reconcile 专门防一类危险缺陷:用户改搜索词或数据刷新后,
若选中集合仍含不可见项,点批量删除就会删掉看不见的会话。该逻辑是纯函数,单独可测。
风险与限制
- 误删不可恢复。 DSH 无回收站机制。防护见「安全设计」,但请先用无关紧要的会话验证。
- 依赖非公开内部契约。
encodeSegment/projectKey/ 目录布局 /session.lock/detachSession均为 DSH 内部实现,非公开插件 API。DSH 升级后可能变动, 届时删除会失败(设计为"失败即拒、不猜测"),需重新核对。settings.section亦属 内部契约(已在两个在装插件上验证可用)。 - 绕过官方契约。 DSH 明确不提供删除能力,本插件以宿主文件操作实现。
projectKey有损。 不同cwd理论上可能映射到同一 key。因此不通过扫描父目录 反推目标,而是严格由 header 的cwd+sessionId计算路径并做越界校验。- 搜索索引。 当前
session-query-sqlite配置为path: ':memory:'+openAt: never, 不落盘,无需清理。若改为非never,还需额外清理其索引。 - 只面向桌面/loopback Web profile(宿主持久化可用的场景)。
许可
MIT