dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
DSH 会话历史插件:侧边栏历史会话面板,按工作区分组、搜索、归档/恢复,并把已归档会话从磁盘彻底删除 | DSH session-history panel with guarded permanent deletion of archived session logs
DSH 桌面端的会话历史面板:浏览历史会话、打开、归档(单个 / 批量)、恢复,以及把已归档的会话从磁盘彻底删除。
A session history panel for the DSH desktop app: browse, open, archive (singly or in bulk), restore, and permanently delete an archived session's log from disk.
| 归档(Archive) | 彻底删除(Purge) | |
|---|---|---|
| 谁提供 | DSH 自带:workspaceRegistry.archiveSession | 本插件:Host 半边直接删文件 |
| 效果 | 离开列表,JSONL 日志与工作区记账保留 | 从磁盘上删掉该会话的日志目录 |
| 可恢复 | 可以,unarchiveSession | 不可以 |
| 用在 | 列表里任何会话 | 只有已归档的会话 |
DSH 本身没有会话硬删除:workspaceRegistry 只有 archiveSession/unarchiveSession/pinSession/unpinSession(唯一的 delete(id) 删的是整个工作区注册,而且它明说 "retains the directory and every session log");sessionPersistence 只有 create/open/flush/stat/list;发行版自己的会话 ... 菜单也只注册了 pin/rename/fork/archive。
所以这一版按你的要求把彻底删除补上了 —— 走本插件自己的私有 RPC 通道 + Host 半边直接删文件,不假装 Host 有这个能力。
侧边栏新增一个面板图标(时钟表盘),点开是主列的面板:
┌─────────────────────────────────────────────────────────────────┐
│ 会话历史 [搜索…] [刷新] [全部归档] │
│ 共 12 条,已归档 3 条 │
├─────────────────────────────────────────────────────────────────┤
│ 默认工作区 C:\…\default-workspace 5 │
│ ● DeepSeek Harness 实时显示插件 2 分钟前 │
│ ○ DeepSeek 插件:时钟 3 天前 │
│ │
│ 其他目录 1 │
│ ○ 改名过的会话 1 周前 │
│ │
│ 已归档 3 [全部恢复]│
│ ○ 旧的实验 2 周前 ↺ 🗑 │
│ ↑ 点 🗑 后原地变成 [🗑 确认删除] [取消],8 秒无操作自动收起│
├─────────────────────────────────────────────────────────────────┤
│ 移除会话 = 归档(日志保留、可恢复);已归档的会话可以再「彻底删除」│
└─────────────────────────────────────────────────────────────────┘
cwd 匹配工作区路径分组,匹配不上进「其他目录」;组内按最近活动倒序。→ 打开、🗄 归档;已归档行是 ↺ 恢复 + 🗑 彻底删除。stopActivity 地问一次,只有 Host 用 workspace/session-active 拒绝时,面板顶部才出现确认条,把 Host 报的运行内容列出来,等你点「停止并归档」再带 stopActivity: true 重试。不拿 running 猜。runningSessionSummary.running 只是 ctx.remote.session.list() 的轮询快照,而"这个会话现在能不能归档"由 Host 侧的 Agent 注册表当场裁决,两者会不一致。按快照猜的写法一旦猜错(快照说没在跑、Host 说在跑),archiveSession 就会被 workspace/session-active 拒绝,而那时插件把任何拒绝都当"失败"上报 —— 结果就是正在使用的会话永远归档不掉,界面上没有任何出路。这正是修掉的那个 bug。
现在改成和发行版侧边栏完全相同的形状:
archiveSession(id) ← 先不带 stopActivity 问一次
├─ 成功 → 「已归档 N 条会话」
└─ workspace/session-active → 面板顶部确认条:
列出 Host 报的运行内容(turn / job / subagent / schedule)
[停止并归档] → archiveSession(id, { stopActivity: true })
[取消] → 收起(15 秒无操作自动收起)
判定拒绝用的是 RPC code(error.rpcError.code === "workspace/session-active")而不是 instanceof:workspaces 服务住在另一个 client bundle 里,插件 bundle 之间不共享 error class 身份。发行版自己的 activeSessionRefusal() 按 error.name 判,原因相同。
附带的好处:以前对 running === true 的行直接传 stopActivity,等于不问就取消对方正在跑的轮次、并丢弃排队中的输入;现在这一步必须先经过你确认。
其它错误(例如 session/not-found)仍然直接报错,并且从这一版起报错文案会带上 RPC code,例如 操作失败:session/not-found: …,不用再猜是哪种失败。
磁盘布局(在本机 0.2.0-rc.2 profile 上实测确认):
<DSH_HOME>/sessions/<projectKey>/<sessionId>/session.v4.jsonl.zstd
sessionId 实际取值都在 [A-Za-z0-9._-] 内(session-<uuid> 与裸 <uuid> 两种形式都出现过),所以 encodeSegment 不会改动它 —— 目录名就是 id 本身。
lib/index.js(Host 半边)因此不复刻后端的 projectKey 编码,而是直接按目录名精确匹配。所有守卫都朝"宁可拒绝,绝不删错"的方向排:
| # | 守卫 | 挡住的情况 |
|---|---|---|
| 1 | id 必须是单个普通路径段(/^[A-Za-z0-9._-]+$/,且不是 . / ..) | 路径穿越 |
| 2 | 调用方必须显式传 confirm: true | 误调用 |
| 3 | agents.get(id) 有值就拒绝 | 会话还活着 —— 删掉正在读写的日志会毁掉运行中的会话 |
| 4 | sessionPersistence.stat(id) 必须仍认得这个会话 | 拼错 / 过期的 id 撞上同名目录 |
| 5 | 只按目录名精确匹配,且必须恰好一个匹配 | 同名出现在多个 project 目录时拒绝猜 |
| 6 | 目录里必须真的有 .jsonl* 日志产物 | 删到不像会话目录的东西 |
| 7 | rm 带 maxRetries | Windows 上读者刚关句柄的 EBUSY 抖动 |
agents.get(id) 查的是活着的 Agent。而一个会话只要还被窗口打开着,它的 Agent 就还活着 —— 归档只是把它从列表里移走,不卸载它。所以在列表里"删掉"了某个会话、再去点 🗑,会遇到守卫 3,看起来像"我都删了怎么还说它开着"。
这不是 bug,是守卫在挡"从正在读写的日志底下把文件抽走"。两条出路:
面板现在按 RPC code(session-loaded)把这种拒绝本地化成上面那段可执行的说明,而不是原来那句英文 close it before deleting its log —— 后者根本没法照做,因为 DSH 没有"关闭会话"这个按钮。
删完返回 { sessionId, directory, project, files, bytes },面板把它汇总成「已彻底删除 N 条会话,释放 X MB」。多条时是串行的。
投影缓存不删:session-projection-cache 里按键为 session id 的条目在会话消失后是惰性的(几十 KB),为了它去动共享 storage domain 风险远大于收益。
| 用途 | 来源 |
|---|---|
| 会话列表(标题 / 时间 / cwd / running / blank) | ctx.remote.session.list({}) → SessionSummary[] |
| 标题文本 | summary.projections.values.title.title(title 投影单元发布的是对象) |
| 分组 / 归档集 / 置顶集 | useWorkspaces((s) => s.items / s.archivedSessionIds / s.pinnedSessionIds) |
| 归档 / 恢复 | ctx.workspaces.archiveSession(sessionId)(被拒时经确认再带 { stopActivity: true } 重试)/ unarchiveSession(sessionId) |
| 归档被拒的原因 | WorkspaceArchiveError.rpcError.code === "workspace/session-active",.details.activity 是 SessionActivity[](turn / job / subagent / schedule) |
| 打开会话 | ctx.uiWorkspace.openSession(sessionId) |
| 彻底删除 | 私有 RPC 通道 /session-history 的 delete 端点 → 本插件 Host 半边 |
sidebar.panellist 的契约原文:
Global panel icons. Each list id addresses the matching main panel; the sidebar owns the button and resolves its label from list metadata.
只要 sidebar.panellist 的 id 与 main 的 key 都是同一个 PANEL_ID("session-history"),侧边栏自己渲染按钮、管选中态、切面板 —— 插件里没有一行导航代码。main 的注册用 generator 形式,和发行版唯一的 main 注册(Plugins 页)一致。
私有路由契约(两端都由本插件拥有,因此没有套 Connection 的 RPC 信封 {type,rpcId,result},就是一个普通的 {ok, value|error} JSON):
// Host(webServer 的 prefix 路由,handler 是 node 风格)
ctx.inject(["webServer", "connection"], (scoped) => {
scoped.effect(() => scoped.webServer.register({ kind: "prefix", path: "/session-history", handler }), label);
});
// Browser(同源 POST,浏览器会话 cookie 随请求带上,正是 Host 准入判据所查的)
await fetch("session-history/delete", { method: "POST", headers: { "content-type": "application/json" }, body: … });
// → { ok: true, value } | { ok: false, error: { code, message, details } }
为什么不用 connection.rpc.handle:它的实现是 owner.effect(() => owner.webServer.register(route)),owner 取的是 connection 服务自己的 context,注册时机不由本插件掌握。发行版里 client-modules、client-file-upload、client-hmr 三个插件都是直接挂 webServer,本插件跟随这条更可控的路径。
| 文件 | 作用 |
|---|---|
lib/client.js | 浏览器半边:会话 store、面板 UI、行组件、内联 SVG 图标、两个槽位注册 |
lib/index.js | Host 半边:私有 RPC 通道 + 带守卫的磁盘删除 |
package.json | 双面包声明 exports["./client"] + dsh.client.platform = "web" + dsh.bundle.patch |
cordis.patch.yml | bundle 层:声明本包自带的挂载行,作为 bundle 安装时由插件管理器读取 |
本仓库通过 dsh.bundle.patch 声明了自带的 cordis.patch.yml,因此不需要手写补丁行:
dsh plugin --profile desktop add github:wangxiang0605qvq/dsh-session-history
要点:包必须放在 $DSH_HOME\profiles\node_modules\ 下 —— 那是运行时模块解析的查找根。放进 profiles\desktop\node_modules\ 不会被解析到,只写补丁行同样找不到模块。
$dest = "$env:USERPROFILE\.dsh\profiles\node_modules\@deepseek-ai\dsh-session-history"
New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null
Copy-Item -Recurse -Force . $dest
Remove-Item "$dest\.git" -Recurse -Force -ErrorAction SilentlyContinue
然后在 ~/.dsh/profiles/desktop/cordis.patch.yml 末尾追加:
- insert:
- id: session-history
name: "@deepseek-ai/dsh-session-history"
config: {}
重启 DSH,然后硬刷新(Ctrl+F5)页面。
不需要动 node_modules,把 lib/ 下的两个文件当作插件的两个半边即可:
~/.dsh/profiles/desktop/plugins/session-history/
├── package.json # 声明 exports["./client"] 与 dsh.client.platform = "web"
├── index.js # 即 lib/index.js
└── client.js # 即 lib/client.js
补丁行用相对路径,由 patch 文件所在目录解析:
- insert:
- id: session-history
name: ./plugins/session-history/index.js
注意:
window.__ModuleLoader__.load({ id })必须与 package.json 的name完全一致,否则 browser 模块表找不到对应的 factory,插件会静默不生效。本仓库两处都是@deepseek-ai/dsh-session-history;若你把name改成别的值,lib/client.js里的load({ id })要同步改。
⚠️ 改了
lib/index.js必须重启 DSHHost 半边不会热重载。 profile 的 HMR 只重新投递 client 半边(client-modules 按文件 mtime 生成带 revision 的 bundle URL,页面重新拉取),Host 半边是进程内 ESM,同一个 URL 在进程存活期间只求值一次 —— 插件管理器文档也写明 "Package replacements require restarting the process to load a fresh JavaScript module generation"。
症状就是第一次踩到的那个:改了
lib/index.js(哪怕改成完全空的apply),进程里跑的还是启动时那份,路由根本不注册,客户端 POST 穿透到静态 SPA 回退,报transport failure for /session-history/delete: HTTP 405。改lib/client.js不需要重启。判断当前进程加载的是哪一份:比对
lib/index.js的LastWriteTime与 DSH 进程的StartTime(Get-Process -Name "DeepSeek Harness")。前者晚于后者,就说明改动还没生效。
HTTP 405 / transport failure —— 路由没注册,请求落到了静态前端回退(dsh-host-frontend-static 的 fallback 座位只接受 GET/HEAD,其它方法一律 405)。按顺序检查:
lib/index.js 的 mtime 是否晚于 DSH 进程启动时间?是 → 重启 DSH。
重启后仍 405 → 用未认证探针区分两种情况:
Invoke-WebRequest "http://127.0.0.1:19387/session-history/delete" -Method POST `
-ContentType "application/json" -Body '{"sessionId":"x","confirm":true}'
ctx.inject(["webServer","connection"]) 没满足)彻底删除被拒 —— 路由通了,是守卫拒的。Host 现在给每种拒绝一个 code,面板按 code 本地化;没有 code 的走通用 purge-refused:
| code | 含义 | 怎么办 |
|---|---|---|
session-loaded | 会话还活着(Agent 没卸载) | 先离开这个会话,或重启 DSH 后不打开它 —— 见「守卫 3 有个反直觉的后果」 |
session-unknown | Host 已经不认识这个会话 | 刷新列表;它可能已被删掉或移走 |
ambiguous-session | 同名目录出现在多个 project 目录 | 插件拒绝猜,需要手动确认 |
no-log-artifact | 目录里没有 .jsonl 日志产物 | 不像会话目录,插件拒绝删 |
bad-session-id / confirm-required | 调用参数不合法 | 正常 UI 不会触发 |
| 想改什么 | 改哪里 |
|---|---|
| 侧边栏图标位置 | lib/client.js 注册处的 order: 50 |
| 面板 ID / RPC 通道名 | PANEL_ID、CHANNEL(两端必须同时改,client 与 index 各有一处) |
| 会话根目录 | lib/index.js 的 sessionsRoot()(默认 $DSH_HOME/sessions,回退 ~/.dsh/sessions) |
| 二次确认宽限时间 | 「全部归档」与「彻底删除」两处 setTimeout(..., 8000) |
| 归档确认条自动收起时间 | 面板里那个 setTimeout(() => actions.cancelStop(), 15000) |
| 归档要不要问(而不是直接停) | applyBatch 只在不带 stopActivity 时才把 workspace/session-active 转成确认条 |
$DSH_HOME/sessions 推导。推错不会删错:守卫 5/6 会让操作直接拒绝。ctx.remote.session.list() 是否包含已归档会话;若某版本把归档会话排除在外,「已归档」分组会少掉那些行(恢复本身仍通过 archivedSessionIds 生效)。origin === "subagent" 或有 parentSessionId)不列在历史里。MIT