dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
在设置页管理已归档会话:恢复或彻底删除(直接物理删除,无备份层)。会话菜单新增红色彻底删除项,附三个 agent 工具。已打开的会话也能立即删除,残留内存副本以归档墓碑隐藏、重启后自动清理。
A DeepSeek Harness plugin that permanently deletes sessions, with a cross-workspace archived-session list to do it from and a red Delete permanently item in the session context menu.
DeepSeek Harness never deletes a session's files. Its own Delete workspace action says so outright: "This removes "{name}" from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped." A long-lived install therefore accumulates one session directory per conversation forever, with no way to reclaim the disk.
Archive/restore is no longer a gap. Since dsh 0.1.7 the official product ships archive and unarchive in the session ⋯ menu, a sidebar Show archived / Archived only view filter, an undo toast, and an admission gate that stops an archived session (and its subagent descendants) from running model steps. Use those — this plugin does not try to replace them.
| Capability | Official (dsh 0.1.7) | This plugin |
|---|---|---|
| Archive / unarchive | ✅ ⋯ menu, stop-work confirm, undo toast | ✅ |
| Find archived sessions | ✅ sidebar Show archived filter | ✅ one list across all workspaces |
| Delete session files | ❌ never | ✅ red Delete permanently (also its subagent sessions and their descendants, deepest first, by default) |
| Bulk operations | ❌ | ✅ select all + delete in one confirmed run |
| Reclaim orphan subagent sessions | ❌ | ✅ one-click cleanup block on the settings page |
Every delete is a direct physical deletion — there is no backup layer.
cascadeSubagents: false turns it
off). Subagent sessions have no surface anywhere in dsh, so the result line
says "including N subagent session(s)" — the only place you can learn they
existedorphanGraceHours (24 h) is
deliberately not offered, so a session still being written never looks like
residueHover a session row in the left sidebar → ⋯ menu: below the built-in Rename / Fork session / Archive session items, a red Delete permanently item appears (native danger styling, same confirm dialog).
The row is a normal entry in the official
sidebar.workspaces.session.menu.itemslot (idsession-manager-delete, order 500), rendered with the sameMenuItemButtonprimitive the shipped pin / rename / fork / archive rows use — so its danger colors, separator and keyboard behaviour come from the menu itself rather than from a copy of its styling.That slot arrived in dsh 0.1.7-alpha.1. Before it existed this row had to be injected by observing the DOM and resolving the session through the React fiber tree, which meant any change to the menu's markup could drop it silently — 0.1.7-rc.2 appended keyboard-shortcut hints to each row's text and did exactly that.
| Tool | Notes |
|---|---|
session_list_archived | List archived sessions as JSON |
session_restore_archived | Restore by id (reversible, no confirmation) |
session_delete_permanently | Delete an archived session by id; requires confirm: true; refuses unknown ids, ids that are not archived, and sessions with a running task |
Requires the dsh 0.1.7 line (release candidates included). Both of its hard
dependencies arrived there: the size-neutral product icons, and the
sidebar.workspaces.session.menu.item slot that the context-menu row registers
into. On anything older the context-menu row does not mount and the Settings
page is the only delete path.
git clone https://github.com/SunshineR04/dsh-session-manager.git
dsh plugin --profile <name> add "file:/path/to/dsh-session-manager"
dsh plugin add installs through pnpm and appends the package to
dsh.profile.bundles; this package's cordis.patch.yml bundle layer mounts
the plugin row automatically. If the plugin does not show up after a page
reload, restart the dsh desktop app (a live profile rebuilds most patches on
reload, but a newly added bundle is not guaranteed to be picked up).
⚠ Do not install this by the bare npm name. The npm package
dsh-session-manageris a different, unrelated project (hkkz9522/dsh-session-manager, published 0.5.x, another maintainer) which also registers a session menu row — so installing the wrong one looks like it worked. Install from this repository, by path as above.
Alternatively mount it manually in the profile's cordis.patch.yml (the
bundle channel above is easier):
- insert:
- id: session-manager
name: dsh-session-manager
⚠ One host per
DSH_HOME. The pending-deletion queue is a single read-modify-write file while the operation lock that serializes it is per-process, so two dsh instances sharing one home can each read the same snapshot and the last write wins — silently dropping the other's marker. That strands its tombstone with nothing left to sweep it while its files are already gone: a row that can neither be restored nor deleted again.scripts/twohost-race-probe.mjsreproduces it against the real manager (RESULT: LOST 1 marker(s): …). The faithful fix is a queue-format change (per-id atomic marker files) or a real cross-process lock — not a retry loop.
Deleting runs in this order:
cascadeSubagents: true):
the whole subagent subtree is deleted deepest first, and the session you
clicked is deleted last. The link comes from the persisted header
(parentSession plus origin === 'subagent' — upstream allows exactly that
one origin value, so a forked or independent session can never be swept up)
and it is recursive: subagents that spawned their own subagents go too.
Why a cascade at all: dsh's sidebar filters origin === 'subagent' at render
time and this plugin's settings page filters them as well, so a child left
behind has no surface at all — no row to click, no listing that carries
it, and the agent tool refuses a non-archived id. A field home had accumulated
68 such orphans (23 MB) that way.
Deepest-first is deliberate: after a crash, every remaining child still has
its parent on disk, so re-running the delete finds it again — deleting the
parent first would strand its children as exactly the orphan shape this
removes. A child with a RUNNING task refuses the whole delete
(allowDeleteRunning overrides it; one decision covers the subtree).sessionIds and — for a cold session — removes it from
the global archive set. (An open session deliberately keeps/adds the id
there instead: that is the tombstone, see below.)
The detach does not trust the workspace's filtered sessionIds view alone —
a stale registry header index can hide the id from that getter (which used
to let deleted sessions survive, resurfacing as ungrouped entries), so
the raw workspace record is checked as a fallback.~/.dsh/sessions/<encoded-project>/<session-id>/ (session.jsonl.zstd).
The directory is resolved through three seams in turn — registry header +
persistence locate, the persistence header listing, then a raw scan of
the sessions root for a directory named exactly the session id — so a
degenerated header seam can no longer silently skip the deletion.~/.dsh/storages/session_projcache/sessions/<id>.json (and .bak-*).api-session/removed event, so every connected
client drops the session from its list store immediately (once per cascaded
subagent too — the official sidebar filters them at RENDER time, not at store
time). (The host itself only emits this event when a live session is
disposed, which a cold delete never is.)The number of cascaded sessions is reported in the result
(subagents.sessionIds) and named in the menu toast, the settings-page summary
and the agent tool's reply ("including N subagent session(s)") — the only place
a user can learn they existed. cascadeSubagents: false restores "delete
exactly the id you asked for".
The SQLite search index reconciles itself once the source files are gone; attachments are content-addressed and intentionally kept.
allowDeleteRunning is on, and that switch
skips only the refusal — a forced delete is still tombstoned and queued like
any other open-session delete.
api-session/removed skips queued ids — checked both when the
pull is scheduled and when it fires, because the delete RESPONSE and the
event travel on different channels (the event usually wins). The row
therefore stays gone in every sidebar view right after the delete,
including 视图选项 → 全部对话(显示已归档).api-session/removed for each queued id that is still live, so every
connected client drops it again and the archived-rows view stays clean too.
The client debounces one repair per residue episode (never a poll loop); the
cost is a possible one-frame flicker after a list pull. It reads the queue
as soon as the host answers its ping and retries a failed read, so one
transient failure cannot disable the repair for the whole page. Every other
list pull (the Refresh button, the post-delete check, a cancel) re-checks
the residue right after pulling, so a row a pull re-learned is repaired too —
and a bulk run containing any open-session delete skips its whole-run pull,
since api-session/removed already dropped those ids from the store. Set
reannouncePendingRemovals: false to go back to the tombstone-only
behavior, where a reload re-shows the row while archived rows are displayed.session/not-pending (cancelling is
the one operation that puts a session BACK, so it must not restore something
nobody queued). Entries whose files are already gone (the normal open-session
delete) have nothing left to act on, so they collapse into a single
"Deleted · cleaned up automatically after restart" summary line with an optional
expander for their ids instead of occupying the banner — and canceling one
is refused by the host too (session/data-gone), not just hidden by the UI.session-manager/internal, log it, and leave the
file untouched. Repairing the file restores everything; a cold delete still
proceeds (it is the only delete left when the queue is corrupt) but now says in
its result that it is not crash-safe, because without a marker a crash during
it cannot be finished at the next start.EPERM/EBUSY, a search indexer, a foreign handle) the entry stays
tombstoned and queued instead of reporting a finished delete whose row the
sidebar would render again, and the next start's sweep retries it. Before this,
a crash between the archive-set write and the file removal left a session that
was neither archived nor deleted — listed again as an ungrouped row, and no
longer reachable from this plugin at all.sessionIds slot, so the session returns to its previous position.
A queued-for-deletion id is refused with session/pending (cancel it first
when its files are still on disk); restoring one would expose an
artifact-less husk.| Field | Default | Description |
|---|---|---|
sessionListLimit | 500 | Max entries per list call |
allowDeleteRunning | false | Force-delete sessions with a running task (skips only the refusal — a forced delete is still tombstoned and queued; dangerous) |
cascadeSubagents | true | A permanent delete also removes that session's subagent sessions and their descendants, deepest first (they have no UI surface at all — see "Delete semantics"). A running descendant refuses the whole delete unless allowDeleteRunning is on; set false to delete exactly the id you asked for |
orphanGraceHours | 24 | How old an orphan subagent session must be before the settings page offers it for cleanup (an orphan's parent is known to no source at all) |
toolDeleteRequiresConfirm | true | Agent delete tool requires confirm: true |
menuDeleteAvailable | true | Mount the red menu item |
reannouncePendingRemovals | true | On every pending-queue read, re-announce api-session/removed for queued ids whose session is still live, so even a "show archived" sidebar stops rendering the residue (set false for the tombstone-only behavior — see "Delete semantics") |
pnpm install
pnpm test # syntax check + host unit tests + client render smoke tests
Development needs Node ≥ 22.22.2 — the render suite mounts React inside
jsdom, and jsdom 30 declares ^22.22.2 || ^24.15.0 || >=26. The plugin itself
runs on Node ≥ 20 (engines.node), so a host on 20 is fine; only the test
suite needs the newer runtime.
The render tests mount the real client settings section with React inside
jsdom (test/client.render.test.mjs) — they catch UI crashes the host tests
cannot see.
Boots a throwaway dsh web instance against an isolated DSH_HOME (never your
real data) and drives the UI with puppeteer-core + local Chrome:
cp scripts/e2e-seed.local.example.json scripts/e2e-seed.local.json
# ^ fill in your own session/workspace data (gitignored, never committed)
node scripts/e2e-seed.mjs <e2e-home> ~/.dsh # 1. seed the isolated test HOME
# 2. create a profile in that HOME with this plugin, then start the test web instance.
# ⚠ The desktop `dsh` shim hardcodes DSH_HOME=<real home>, so prefixing the
# command with DSH_HOME=<e2e-home> does NOT isolate (verified: the profile
# lands in the real home). Point a Node CLI at that home instead — the one
# shipped inside the dsh npm package is a plain entry point, no Electron:
# ⚠ Do NOT use the `--expose-internals "<app.asar>/lib/desktop-cli.js"` form
# an earlier revision of this file recommended: on some app builds that asar
# path does not resolve and the CLI dies with MODULE_NOT_FOUND (verified
# 2026-09-30 against the installed desktop build).
# DSH=$(npm root -g)/@deepseek-ai/dsh/lib/bin.js
# DSH_HOME=<e2e-home> node "$DSH" --profile sm-test --from-default-profile web --dump-config
# ^ creates the profile from the shipped web template and exits (no boot)
# DSH_HOME=<e2e-home> node "$DSH" plugin --profile sm-test add <tarball>
# DSH_HOME=<e2e-home> node "$DSH" --profile sm-test --no-open --port 43123
# ^ boots; the printed line carries the token URL
node scripts/e2e-check.mjs <printed token URL> # 3. read-only checks: menu item / settings page
node scripts/e2e-mutations.mjs <URL> <e2e-home> # 4. closed loop: restore → archive → delete
node scripts/e2e-residue.mjs <URL> --home <e2e-home> # 5. residue acceptance: neither view shows the row
node scripts/e2e-contrast.mjs <URL> <e2e-home> # 6. WCAG AA contrast of the danger text, both themes
⚠ Step 6 needs a seeded home whose archive set is non-empty (the seed creates one archived session); it exits 2 with nothing to measure otherwise. It is read-only: it archives and deletes nothing.
⚠ Steps 4 and 5 really delete sessions, and they are guarded twice, because
each check proves something the other cannot (scripts/e2e-guard.mjs):
--home argument (or step 4's second positional) is required, and the
home must carry a marker scripts/e2e-seed.mjs wrote for that very path.
The real ~/.dsh is refused by name, by prefix (nothing inside it either),
and through a case variant, an 8.3 short name, a junction, a symlink or a
\\?\ path — the comparison resolves real paths, case-insensitively on
Windows. A directory that merely looks like a marker, a marker copied from
another home, and a marker from before 0.4.8 (it records no home) are refused
too: re-seed.session:… rows) and
refuses unless every one of them exists in the seeded home — failing closed
when the page answers nothing at all. Without this, pointing a script at your
own running dsh with a valid seeded --home passed every check and deleted
your real sessions.Both are unit-tested without a browser (test/e2e-guard.test.mjs), and the seed
itself validates its whole spec, refuses source == target, and writes the
marker before it deletes anything.
The remaining scripts are diagnostics, not acceptance tests:
e2e-bug2.mjs and e2e-live.mjs reproduce fixed field bugs (both delete, both
guarded), and e2e-probe.mjs is one-off DOM reconnaissance. Which scripts can
actually FAIL — an important distinction, because a transcript that always
exits 0 is not a test however reassuring it reads:
| script | can fail? |
|---|---|
e2e-residue.mjs | yes (throws → exit 1) |
e2e-dialog-style.mjs | yes (8 style checks, throws on any failure) |
e2e-realclick.mjs | yes — its bug-1 hover/click assertions are real exit 1 paths, not log lines |
e2e-contrast.mjs | yes (WCAG AA on the danger text, both themes) |
e2e-guard.mjs | yes (refuses an unseeded home) |
e2e-check.mjs, e2e-mutations.mjs, e2e-bug2.mjs, e2e-live.mjs, e2e-probe.mjs | no — they print a transcript. They still exit non-zero when they crash (a missing Chrome, a timeout), but a failed ASSERTION is invisible in the exit code, so read their output |
Set CHROME_PATH if your Chrome is not at the hard-coded default path.
If you hand-write a call to one of this plugin's routes (the scripts drive the
real UI, so none of them show this), they live under the shared /api prefix and
demand the connection plugin's envelope:
POST /api/session-manager/<endpoint> with content-type: application/json and
{ type: 'client-request', rpcId, method: 'session-manager/<endpoint>', payload }.
The method field is required and must name the endpoint; anything else answers
405 / 415 / 400 or bad-request: invalid client-request message.
Built after studying these projects (some locally inspectable under
~/.dsh/profiles/desktop/node_modules):
MIT