Skip to content
dsh-market Browse plugins GitHub 中文

SunshineR04/dsh-session-manager

Settings-page manager for archived DeepSeek Harness sessions: restore an archived session to its previous workspace position or permanently delete it (direct physical deletion, no backup layer). Adds a red permanently-delete item to the session context menu and three agent tools. Deleting an open session works immediately; the leftover in-memory copy is hidden behind an archive tombstone until the next restart.

Stars ★ 1 Category Sessions & Messages Listed 2026-09-05

Install

Inside DeepSeek Harness, with dsh-market

dsh plugin --profile web add dshmarket

Or from the command line

dsh plugin --profile web add "https://github.com/SunshineR04/dsh-session-manager/releases/download/v0.3.0/dsh-session-manager-0.3.0.tgz"

Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.

README

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.

中文文档

What dsh already does, and what this plugin is for

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
Bulk operations ❌ ✅ select all + delete in one confirmed run

Every delete is a direct physical deletion — there is no backup layer.

Features

1. Settings page: Settings → Session Manager

  • Lists every archived session: title, owning workspace, project directory, update time, running state
  • Restore (returns to its pre-archive position) and red Delete permanently per session
  • Select all + bulk delete: tick rows (or the header checkbox) and delete every selected session in one confirmed action. The run is serial and tolerant: a failing session is reported in full while the rest still go through, sessions with a running task are skipped (the dialog says how many before you commit), and the per-run result is one summary — clean, partially failed, or all-running refused
  • Destructive confirm dialog — every delete is a direct physical delete, there is no backup layer
  • Backed by the official session / workspace client stores — fully reactive

2. Session context menu: red "Delete permanently"

Hover 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.item slot (id session-manager-delete, order 500), rendered with the same MenuItemButton primitive 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.

3. Agent tools

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

Install

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-manager is 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

Delete semantics

⚠ 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.mjs reproduces 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:

  1. Open sessions only: a persistent pending-deletion marker is written before any mutation (crash safety — a crash mid-delete always leaves the next boot a marker to sweep), right after the read-only existence check; malformed ids read back from that queue file are pattern-validated before any filesystem use.
  2. Registry bookkeeping (durable + broadcast): detaches the id from its workspace's 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.
  3. Removes the session artifact directory ~/.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.
  4. Removes the metadata checkpoint ~/.dsh/storages/session_projcache/sessions/<id>.json (and .bak-*).
  5. Broadcasts the official api-session/removed event, so every connected client drops the session from its list store immediately. (The host itself only emits this event when a live session is disposed, which a cold delete never is.)

The SQLite search index reconciles itself once the source files are gone; attachments are content-addressed and intentionally kept.

  • Every delete is a direct physical deletion — there is no backup layer, so double-check the confirm dialog.
  • Deleting an open session works immediately: its registry accounting, files and metadata are removed at once (post-delete flushes cannot recreate anything — appends open the log by path and never recreate a deleted directory). Because dsh has no public "close session" API, the in-memory copy lingers until its process ends; the id is kept in the archive set as a tombstone and the next dsh restart finishes the cleanup. Sessions with a running task are refused unless allowDeleteRunning is on, and that switch skips only the refusal — a forced delete is still tombstoned and queued like any other open-session delete.
    • At delete time the client never pulls that id back: a successful open delete marks it in this client's pending set, and the debounced list pull that follows 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 视图选项 → 全部对话(显示已归档).
    • The one residual shape: a page reload (or another client) re-learns the id from the host's own session list, which still reports the in-memory copy. It is archived, so the default view keeps hiding it; only a sidebar set to show archived rows renders it in the ungrouped bucket until dsh restarts.
    • Repair hook (on by default since 0.4.1): every read of the pending-delete queue makes the host re-announce the official 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.
  • Pending banner: the settings page lists only the sessions you can still act on. Entries with files on disk (e.g. a mid-delete crash leftover) get a row with Cancel deletion, which reads the queue, drops the pending marker FIRST and only then clears the tombstone — the reverse order could strand "tombstone cleared, marker still set", which is data loss: the next start reads that surviving marker and deletes the very session you just cancelled. If the registry refuses the un-archive the marker is written back, so the cancel stays retryable; an unreadable queue touches nothing; and an id that is not queued at all is refused with 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.
  • An unreadable queue refuses instead of guessing: the pending-delete queue is the only record a queued deletion can be finished from, and every writer persists the snapshot it just read — so treating a failed read as "the queue is empty" would erase every marker on the next write, leaving their tombstones in the archive set forever (files gone, un-restorable, un-clearable). Hence a missing file is the normal empty queue, while a read failure or corrupt JSON (including a torn file from the non-atomic fallback write) makes every queue-dependent operation — listing, restore, cancel, open-session delete and the boot sweep — refuse with 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.
  • A cold delete finishes in the same call, without losing its crash safety: it writes the same pending marker and keeps the same archive tombstone while it works, then clears both once the files are gone. If the file phase fails (Windows 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.
  • Restore only removes the id from the archive set — archiving keeps the workspace 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.

Config

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)
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")

Develop

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.

Browser E2E (optional)

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):

  1. the --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.
  2. the INSTANCE behind the URL must be serving that home. A URL cannot prove it, so before the first click the script asks the page which sessions it can see (the plugin's own list endpoint plus the rendered 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.

References

Built after studying these projects (some locally inspectable under ~/.dsh/profiles/desktop/node_modules):

License

MIT

Content from the project README on GitHub ↗

Comments

Comments live in GitHub Discussions. Sign in with GitHub to post or react.