spidu-lee/dsh-session-delete ↗★ 0

dsh-session-delete

Adds a real "Delete session" row to the sidebar session "..." menu: after a confirmation prompt it removes the session's log directory from disk, cleans the projection cache, detaches it from every workspace and broadcasts the removal. 适合需要彻底清除磁盘会话日志、投影缓存及工作区关联的隐私敏感用户。

パッケージ
dsh-session-delete
互換性
未検証
バージョン
0.1.0
ライセンス
MIT
最終更新
2026/10/01

同名パッケージの別リポジトリ

インストール

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:spidu-lee/dsh-session-delete

ドキュメント

README 全文を読む ↗

Session Delete for DSH

Permanently delete a session — the one thing DeepSeek Harness cannot do.

English · 中文


English

Adds a Delete session permanently row to the sidebar session “⋯” menu. After a confirmation dialog it erases the session for good: its log directory on disk, its projection cache, and its links to every workspace — and every open window drops the row immediately.

Why this plugin exists

DSH ships archiveSession and nothing else. Two behaviours leave sessions that can never be removed from the interface:

  • Deleting a workspace only ungroups its sessions. The confirmation says it verbatim: "This removes “…” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped." So the sessions fall into Ungrouped, and deleting the folder afterwards changes nothing — the logs still live under $DSH_HOME/sessions.
  • The persistence seam has no deletion API. Its own documentation states: "Nothing deletes session files — logs accumulate under root until removed externally; the seam has no deletion API."

This plugin closes that gap. It is the difference from the other session-menu plugins: it does not stop at a UI action, it removes the on-disk session record and cleans up everything that pointed at it.

What it does

StepAction
1Validates sessionId (one path segment, no ..)
2Scans `/
/` (the project key is a lossy encoding of the cwd, so it is scanned, never inverted)
3404 when there is nothing to delete
4Asks the workspace/session-activity waterfall what still runs — the same question archive asks — and, if anything does, dispatches workspace/session-stop (the fan-out archive uses for stopActivity) and re-polls until it settles
5409 only when that work refuses to settle; the message names what is still running. The waterfall reports it entry by entry, which is the only honest busy signal: a live Agent exists for any session a window has open, working or not
6Dispatches workspace/session-stop for a session that is merely loaded in memory, so it cannot flush its header back to disk after the removal
7fs.rm(dir, { recursive: true, force: true }) — the real deletion
8Stats the directory again, up to 4 rounds 350 ms apart, and answers delete-resurrected instead of success if a writer puts it back
9Detaches the session from every workspace through the registry entity API and unpins it — only after the files are gone, so a failure cannot orphan a workspace link
10Removes storages/session_projcache/sessions/.json (best effort, reported as a warning)
11Asks whether the session is still listed — live in the host's store, or held by the persistence layer as created-but-unmaterialized — and says so as a warning rather than claiming a clean success
12Emits api-session/removed, so every open client drops the row

The client then applies that removal to its own list locally, which is not the same thing as refreshing: see below.

Why steps 4–6 and 8 exist. A session that is still resident in the host can flush its header back to disk a fraction of a second after the removal — which is how a deleted conversation used to reappear in the sidebar after a restart. The browser half now leaves the session first (see below) and the host verifies that nothing came back.

Why step 11 exists. Deleting the files does not make the host forget the session. The list the sidebar renders is live-preferred: it merges the in-memory store with the persisted corpus, and the persistence layer also lists sessions this process created but never materialized. The row is removed in the window that asked for the deletion (see below), but the two holders stay, and they do not carry the same risk:

  • a resident Session in the host's store can flush its header back to disk, so that log may reappear and the conversation may be listed again after a restart;
  • a pending entry in the persistence layer only lives inside this process and disappears with it.

Neither can be cleared by a plugin — there is no removal API on sessions, and the tracker's pending map is internal (only hasPendingSession(id) is public) — so the deletion reports which one it found instead of claiming the operation was completely clean.

Why the liveness question is a waterfall and not agents.get. An earlier revision refused the deletion whenever the Agent registry held the session. That is not a busy signal: DSH keeps an Agent alive for any session a window has open, so a perfectly idle conversation was refused with "it is still running". workspace/session-activity is what archiveSession itself consults, and its providers are the ones that actually know — the Agent registry reports the running turn, alongside owned jobs, subagent descendants and active schedules.

Install

From the market (once listed):

dsh plugin --profile web add github:spidu-lee/dsh-session-delete

Or add it to a profile patch by hand:

# cordis.patch.yml
- insert:
    - id: session-delete
      name: 'dsh-session-delete'

The package declares both dsh.bundle.patch and dsh.client (platform: web), so it installs as a normal bundle. Restart DSH after installing: the host half mounts its route family at startup.

"private": true is intentional. An unrelated npm package already owns the name dsh-session-delete, so this plugin is distributed from GitHub only.

The menu row and the dialog

Slotsidebar.workspaces.session.menu.item
Row iddsh-session-delete.session.delete
Order500 (after the shipped pin 100 / rename 200 / fork 300 / archive 400)
Styleui-primitives MenuItemButton with danger + separatorBefore, icon IconTrashOutlineRegular

The confirmation is not window.confirm — that is a native Electron dialog whose chrome is the window title, and it can be neither themed nor localized. This plugin reuses the ui-primitives Modal that the shipped archive confirmation uses, so the mask, card, radius, elevation and focus trap are the host's own.

Slotshell.overlay
Entry iddsh-session-delete.session-delete-confirm
  • The row and the dialog are decoupled by a module-scoped pending-request store (useSyncExternalStore): the row only files a request, shell.overlay renders exactly one dialog for the current request, so in-flight and error state cannot leak.
  • The dialog reads the session's local retention through ctx.get("sessions").retainInfo(id) and says up front which case it is looking at: dialog.noteOpen ("opened in this window"), dialog.noteHeld ("held here"), or dialog.note.
  • Deleting the conversation you are looking at is handled, not refused. If the row you clicked is the one in the Main view, the dialog first navigates to another session (uiWorkspace.openSession, or startSession() when the workspace has no other row), waits for the mainView reference count to drop, lets the release reach the host, and only then deletes. If that release does not happen within 5 s the deletion is cancelled with dialog.stillOpen — a clear refusal beats a resurrection.
  • The danger button is themed: text and border --dsw-alias-state-error-primary, hover background --dsw-alias-interactive-bg-hover-danger. The stylesheet is injected once (``) and removed when the plugin unloads.
  • On failure the dialog stays open and shows the host's own error text, with the known refusals mapped to their own messages (dialog.blockedRunning when work would not settle, dialog.blockedResurrected when the log came back). A warning that accompanies a refusal is listed too — that is where the instruction for what to do next lives.
  • After a successful delete the row is removed by calling sessions.handleSessionRemoved(id) — the same entry point the api-session/removed relay uses — rather than by refreshing the baseline. The two are not equivalent: a refresh merges against the host's answer with "identities absent from the baseline are removed", so a session the host still lists is inserted back, leaving the row stranded under Ungrouped. A refresh is only the fallback for a client that does not expose the removal entry point.
  • If the host reports non-fatal warnings (a projection-cache miss, a workspace that could not be unpinned), the dialog switches to a read-only done state that lists them instead of closing on a false success.

Localization

  • UI strings use ctx.locale.register(ns, locale, dict) in the namespace dsh-session-delete; the slot registration carries locale: NS to receive an injected t, so switching the interface language takes effect immediately. Keys: menu.deleteSession, dialog.title, dialog.desc, dialog.descUntitled, dialog.note, dialog.noteOpen, dialog.noteHeld, dialog.sessionId, dialog.confirm, dialog.switching, dialog.pending, dialog.cancel, dialog.close, dialog.failed, dialog.stillOpen, dialog.noTarget, dialog.blockedRunning, dialog.blockedResurrected, dialog.notFound, dialog.badRequest, dialog.forbidden, dialog.deleteFailed, dialog.warnings, plus one warning.* key per warning code (zh + en).
  • The host never sends prose. It has no idea which language the requesting window is in, so a warning crosses the wire as { code, params, message } and an error as { code, message, params }. The dialog renders warning. / dialog. through the injected t, interpolating params; message is the host's English sentence, used only as the fallback when the browser half does not know the code (an older client against a newer host must not render a blank line). The codes today are corpus-held-live, corpus-held-pending, activity-stop-failed, activity-unavailable, workspace-registry-missing, workspace-list-failed, workspace-detach-failed, unpin-failed, projection-cache-failed, directory-rewritten and emit-failed.
  • Package metadata (the name and description shown on the plugin card) follows DSH's official convention: locale/en.json and locale/zh.json next to package.json, each shaped {"meta": {"title": …, "description": …}}, with "./locale/*.json" exported. The host's readPluginMeta() picks the language up on its own.

Security

  • The route family sits behind the shared loopback trust fence: the peer socket address must be 127/8 or ::1, Host must be a loopback authority, sec-fetch-site: cross-site is rejected outright, and a present Origin must match Host. X-Forwarded-For is never trusted.
  • Only / / is ever removed, and the target is re-asserted inside the root with path.relative immediately before removal.
  • Request bodies are capped at 64 KiB and only POST is accepted.
  • Deletion is irreversible, so the confirmation is mandatory. There is deliberately no trash can.
  • POST api/dsh-session-delete/info reports the active session roots, whether each service actually resolved (services.sessions / .workspaceRegistry / .sessionPersistence) and whether the workspace/session-activity waterfall can be dispatched at all (activityWaterfall), plus the ids currently loaded. A false anywhere is the signature of a liveness check that has silently degraded to "nothing is running", which is exactly the bug that let a deleted session be written back — so check it first when a deletion is refused.
  • Add ?sessionId= to that same request and it also answers the liveness question for that one session — {"asked":true,"summary":"turn×1","entries":[…]} — and a corpus block saying which source would still list that session now (live, pending, listed, persisted). "Why was my deletion refused, or why is the row still there?" is answered directly by it.

Offline self-test

test/dry-run.mjs drives both route handlers with a temporary directory and a fake cordis context, covering invalid ids, unknown ids, a non-loopback Host, work that stops when asked, work that refuses to settle, a merely-loaded session, an idle session, the delayed verification pass, a concurrent second delete, a session still resident in the host store, a session the persistence layer still holds as pending, and the service/waterfall/corpus probes:

$env:ELECTRON_RUN_AS_NODE='1'
& 'D:\Program Files\DeepSeek Harness\DeepSeek Harness.exe' 'D:\DSHWorkSpace\dsh-session-delete\test\dry-run.mjs'

A fake context cannot prove that the real host resolves ctx.sessions or dispatches workspace/session-activity. Always follow this up against a restarted DSH with POST …/info and read its services and activityWaterfall fields; the original silent-guard bug passed every offline check.

Compatibility and known limits

  • Built against DSH 0.2.0-rc.2 (desktop runtime). The host half imports nothing but node:os, node:path and node:fs; the browser half requires react, react/jsx-runtime and @deepseek-ai/dsh-client-ui-primitives through the harness module loader.
  • The host half declares inject = ["webServer", "sessions"]. Declaring the session store is deliberate: an unresolved service must not look like "no session is loaded", because that is what silently disables the removal-verification policy. agents is deliberately not declared — see "Why the liveness question is a waterfall" above.
  • A session open in another window keeps a live generation there. The host stops its activity and re-checks the directory, but a window that reopens the session afterwards can still rewrite the header; the plugin reports that as delete-resurrected rather than claiming success.
  • Removing the log directory is the deletion; there is no recycle bin and no undo.

License

MIT


中文

在左侧会话行的 「⋯」菜单里加一行 「彻底删除会话」。确认之后它会把会话真正抹掉: 磁盘上的日志目录、投影缓存、以及它在每个工作区里的关联,并且所有已打开的窗口立刻移除该行。

为什么需要它

DSH 内置只有 archiveSession,没有删除。有两种行为会留下永远删不掉的会话:

  • 删除工作区只是把会话「解组」。 确认框原文写着:「这会把『…』从工作区列表里移除。 文件夹和会话日志会保留。它的会话将出现在未分组下。」 于是会话掉进**「未分组」**, 之后再去删文件夹也没用——日志仍然躺在 $DSH_HOME/sessions 里。
  • 持久化层没有删除 API。 它自己的文档写得很直白:「没有任何东西会删除会话文件—— 日志在 root 下不断堆积,直到被外部删除;这个接缝没有删除 API。」

本插件补上这个缺口。这也是它和其他会话菜单插件的区别:它不停留在界面动作, 而是真正删掉磁盘上的会话记录,并清理所有指向它的东西。

它做了什么

步骤动作
1校验 sessionId(单段路径名,禁止 ..)
2扫描 `/
/`(projectKey 是 cwd 的有损编码,只能扫,不能反推)
3没找到 → 404
4用 workspace/session-activity waterfall 询问该会话还有什么在跑——与内置归档 archiveSession 问的是同一个问题;若有,就派发 workspace/session-stop(归档 stopActivity 用的同一条扇出)并轮询直到它真的停下来
5只有在那份工作停不下来时才 409,且消息里点名还在跑的是什么。这个 waterfall 逐项报告,是唯一诚实的「忙」信号:Agent 注册表对任何被窗口打开过的会话都有一条记录,不管它在不在干活
6会话只是加载在内存里 → 也派发 workspace/session-stop,让它没法在删除之后把 header 再写回磁盘
7fs.rm(dir, { recursive: true, force: true }) —— 真正的删除
8再次 stat 该目录,最多 4 轮、每轮间隔 350 ms;若被重新写回,返回 delete-resurrected 而不是假装成功
9通过 registry 实体 API 把会话从所有工作区解绑,并取消置顶——只在文件确实删掉之后才做,失败时不会留下「工作区里没了、磁盘上还在」的孤儿状态
10删除 storages/session_projcache/sessions/.json(尽力而为,失败记入告警)
11追问该会话是否仍被列为存在——在宿主内存里活着,或被持久化层记作「已创建但尚未落盘」——并把这些如实写成告警,而不是谎报干净成功
12广播 api-session/removed,所有已打开的客户端据此移除该行

随后客户端会把这个移除直接应用到自己的列表上——这和「刷新基线」不是一回事,见下文。

为什么需要第 11 步。 删掉文件并不会让宿主忘记这个会话。侧边栏渲染的列表是内存优先的: 它把内存中的会话存储与磁盘语料合并,而持久化层还会额外列出「本进程已创建但尚未落盘」的会话。 行本身会在发起删除的那个窗口里被移除(见下文),但这两个来源会留下,而且它们的风险并不相同:

  • 宿主存储里活着的 Session 能把 header 再次落盘,所以那份日志可能回来,重启后