dsh-agent-clean
Cleanup tool for DSH (DeepSeek Harness) subagent entries, sessions and orphaned projection caches.
Zero dependencies: Node.js built-ins only (node:zlib zstd, node:fs, node:child_process).
Born out of a real incident: a session's subagent panel got stuck/desynced and the entries could not be removed.
After reverse-engineering DSH's session log format (v4) and its loader, the non-destructive fix was found,
wrapped into this CLI, and covered by a full regression suite.
Two halves — use either one. The CLI is standalone: it works on DSH's files on disk, you run it yourself
from a terminal, outside DSH, and nothing has to be installed — get it with git clone (section 2) or
Code → Download ZIP on the GitHub page. The same package also ships an optional DSH plugin half: a host
route plus a page under Settings → Session cleanup that shows what needs cleaning, hands you the exact
commands, and carries a switch (on by default) that runs the non-destructive cleanup for you on the next
quit (section 2.2). Installing it is optional; the CLI is unaffected. Plugin marketplaces list npm
packages that declare the dsh field, so this package can appear there once it is published to npm.
- Never deletes a log line, never renumbers
seq (v4 requires dense seq; deleting a line makes the whole session fail with format v4 event N is not dense)
- Only retypes the session's own
subagent/catalog rows to subagent/catalog-dismissed + ignorable: true
- Also rewrites the projection cache's
subagentCatalog to a valid empty state (DSH's dsh-chat-manager does not remove that cache when a session directory is deleted, leaving orphans behind)
- Full backup before every write;
restore brings files back byte-for-byte
中文说明见 README.zh.md。
1. Requirements
| Item | Requirement |
|---|
| Node.js | >= 22 (needs built-in zlib zstd; the CLI checks this on startup and tells you) |
| OS | Windows / macOS / Linux |
| DSH | Desktop app (optional but recommended: used for the "real loader" self-check); otherwise point --modules at a node_modules containing @deepseek-ai/* |
The DSH home defaults to ~/.dsh (on Windows C:\Users\<you>\.dsh) and can be overridden with DSH_HOME:
$DSH_HOME/sessions/<escaped-workspace-name>/<session-id>/session.v4.jsonl.zstd ← session log
$DSH_HOME/storages/session_projcache/sessions/<session-id>.json ← projection cache (subagent entries live here)
2. Get the code, install & run
git clone https://github.com/PolitaryMonicy/dsh-agent-clean.git
cd dsh-agent-clean
(or click Code → Download ZIP on the GitHub page and unpack it.) No npm install needed — there are no
third-party modules. Pick one:
:: Windows (double-click or run from a terminal)
clean.cmd list
# macOS / Linux
chmod +x clean.sh
./clean.sh list
# any platform
node clean.mjs list
The wrappers locate Node themselves: %DSH_NODE% / $DSH_NODE → node on PATH → common install paths.
2.1 Optional: the DSH plugin half
The same package is a DSH plugin (it declares dsh.bundle.patch and dsh.client), so the Desktop app's plugin
manager can install it:
dsh plugin --profile desktop add dsh-agent-clean # from npm
dsh plugin --profile desktop add github:PolitaryMonicy/dsh-agent-clean # straight from GitHub
dsh plugin --profile desktop add /path/to/this/repo # local checkout
Then quit DSH completely and start it again. A page appears at Settings → 会话清理诊断 /
Session cleanup (the floating 🧹 button older builds had is gone).
The page scans $DSH_HOME (the same code as list + orphans), shows per-session subagent-entry counts, log
sizes, cache titles and orphaned caches, and gives you copy-ready dismiss / purge / orphans commands —
including one that clears the subagent entries of every session at once. If the page reports
宿主返回 HTTP 404, the host half did not load — check dsh --profile desktop --dump-config for the entry and
%TEMP%\dsh-agent-clean-host.log for the reason.
The page also carries a switch (see 2.2). Neither the page nor the switch ever touches session storage while DSH
is running: DSH's persistence contract forbids rewriting committed events (seq must stay dense, single writer)
and the projection cache has no invalidation API, so even a successful in-process write would leave the old value
in memory. Everything that changes a session happens after DSH has exited, through the same clean.mjs
pipeline you would run by hand (per-session full backup → structural self-check → real-loader verification).
To remove it: dsh plugin --profile desktop remove dsh-agent-clean.
2.2 Auto-clean on quit (on by default)
Because you have to restart DSH anyway for a cleaned session to look clean, the plugin can do the cleanup in that
restart window for you. With the switch on (the default), each DSH boot:
- writes the switch to
auto-arm.json in the package's state directory, and
- spawns a detached helper (
clean.mjs autowait --pid <the DSH process>), which survives DSH's exit and
waits for that pid to disappear.
Once DSH is really gone, the helper runs the same pipeline as dismiss --all --apply — full backup per session,
structural self-check, verification with the real loader — over every session that has subagent entries, and
writes auto-report.json (auto.log keeps a short history). You see the outcome in the page the next time you
start DSH. Turning the switch off needs no restart: a helper that is still waiting re-reads the switch, sees
enabled: false, and exits without touching anything. If a second DSH instance is still running at that moment,
the helper does not skip: it keeps waiting until no DSH instance is left at all (only the 72 h cap ends it
with a "skipped" note).
To make it actually run: fully quit DSH (closing the window is not enough — it may ask whether to keep
running in the background), wait a minute or two for the helper to finish (the page's "last auto-clean" line
refreshes with this run's result), and only then reopen. Cleaning one session takes tens of seconds, while
"close and immediately reopen" leaves a one-to-two-second gap between boots: the helper confirms over several
rounds before acting, and stops on the spot as soon as a new instance is up, leaving the rest for next time.
You can also fully quit DSH and run the commands from section 3 by hand.
This is why the switch lives in the package's own state file rather than in DSH's settings service: a detached
helper cannot read another process's in-memory settings, and this plugin must stay importable with no dependencies
(no @deepseek-ai/schemastery) so a local link: install keeps working.
Prefer to do it yourself? Turn the switch off and use the commands in the page — they are identical.
3. Commands
| Command | What it does |
|---|
list [--workspace <substr>] | List sessions, titles, log sizes and each session's own subagent entry count |
dismiss --session <id|prefix> [--apply] | Remove subagent entries (non-destructive: keeps every line and seq) |
dismiss --all [--workspace <substr>] [--apply] | Same, for several sessions |
purge --session <id|prefix> [--apply] | Delete the session directory + all generation logs + projection cache (full backup first) |
orphans [--apply] | List/delete projection caches whose session directory is gone |
restore --backup <dir> | Restore from a backup (works for dismiss / purge / orphans backups) |
autowait --pid <DSH pid> | Wait for that DSH process to exit, then dismiss every session that has subagent entries (what the plugin's switch arms at boot; obeys auto-arm.json) |
version / help | Version / help |
Common flags
| Flag | Meaning |
|---|
--apply | Without it you get a dry run (default). With it, files are written |
--force | Required when the log was written to in the last 5 minutes (guard against weakening a live session) |
--workspace <substr> | Only touch sessions whose workspace name contains the substring |
--no-verify | Skip only the "no way to self-check" gate. A failing real loader still refuses to write |
--no-app | Do not use the desktop app; use plain Node + --modules |
--app <exe> / --asar <app.asar> | Point at the DSH app explicitly |
--modules <node_modules> | Point at a directory containing @deepseek-ai/* |
Exit codes: 0 success (after dismiss the projected entry count should be 0); 1 error (including a failed real-loader self-check or bad arguments); 2 self-check passed but entries are still non-zero.
Suggested flow: list → dismiss --session <first 8 chars> (dry run) → add --apply → quit DSH completely and restart.
4. Why it has to be done this way
A v4 session log is a multi-frame zstd JSONL: line 0 is the header (no seq), then one event per line with a dense seq (0,1,2,…).
The DSH client renders the subagent panel from the projection cache (projectionsBySession[].values.subagentCatalog) with no summary fallback — so changing only the log leaves the entries visible.
Four conditions must hold at the same time (all established empirically):
- Retype: turn that entry's
subagent/catalog event into subagent/catalog-dismissed and add ignorable: true (unknown types must be ignorable or the loader throws).
- Leave the header untouched: every
session-log-deepseek/delivery-accepted row's data.sessionId must equal the header's id. Changing only the header id yields
SessionFormatError: current-generation delivery marker names the wrong Session.
- Valid empty cache state: set the projection's
subagentCatalog value to {"inheritedEventCount": <n>} (not head.values: [], which fails schema validation with too_small).
- Restart DSH completely — a running process still holds the old values in memory.
Two more constraints (handled by the tool):
- Never delete lines: that breaks seq density →
format v4 event N is not dense (the reason the original "strip lines" approach failed).
- The loader also verifies that header.id matches the identity derived from the log path, so a "mirror" self-check must live under the same escaped workspace directory name; otherwise even a correct transform fails with
header id "…" and cwd identify "…".
5. Safety design
- Dry run by default; with
--apply every touched file is first copied in full to backups/<ISO>_<command>_<shortid>/ together with a manifest.json recording the original paths (used by restore).
- Before writing, the transform is verified on a mirror by the real loader (the desktop Electron binary running
verify_loader.mjs); only then are the real files touched. After writing, both the on-disk log and the real loader are re-checked.
- Recompressed frames use
ZSTD_c_checksumFlag = 1, matching the original format.
- Writes are atomic: session logs and caches go to a sibling
dsac-write.tmp first and are then renamed over the target. If the helper is killed mid-write (shutdown, the cascade from the app menu's Restart, Task Manager), the disk holds either the complete pre-write file or the complete post-write file — never half a log. The temp name deliberately avoids session.v, so the session scanner (which only matches session.v<digits>.jsonl(.zstd)) cannot mistake it for a generation log.
orphans only touches files under storages/session_projcache/sessions/.
purge backs up all generation logs (session.v3/v4…) plus the cache, then removes the session directory; restore recreates it.
- The auto-clean helper (2.2) is only ever armed, never in-process: it waits for the DSH pid to disappear and takes a
pid-and-timestamp lock so two helpers cannot run at once (if a previous helper is still finishing, this one waits up
to 3 minutes instead of burning the whole window). If another DSH instance is still running it does not give up: it
keeps waiting until no DSH instance is left at all (bounded by
maxWaitMs, 72 h by default) and only then writes a
"skipped this window" report. "Another instance" must be decided by command line, not by image name: one DSH
instance has five or six same-named processes (Electron main, GPU, network, renderer, host), and the tool runner plus
the helper itself run the same exe — counting by image name turned one instance into five in a real report. So only
the main process and the host process (owner of the session store, which is also the pid being waited for) count as
an instance; the helper itself, the waited pid, Electron children and runners never do. A "zero instances" moment is
confirmed over three consecutive rounds (≈4.5 s) before anything is touched: a clean run takes tens of seconds, and a
new instance's host opening the session store mid-write is exactly what must not happen. It then goes through exactly
the same backup → mirror self-check → real-loader verification path as . A switch turned off
mid-session needs no kill: the waiting helper re-reads and exits untouched, and a restarted host
process will not arm a second helper while the previous one still waits for the same pid. When a has
taken over ( names a different waited pid and its helper is alive), the older helper yields and exits
— the newer one acts in the real exit window and cleans the same sessions, so no work is lost.
(Measured lesson: the "Restart" item in DSH's app menu is — the new
instance starts first and the old one exits afterwards, so the two overlap for a second or two; a 10-second grace
followed by a skip therefore skipped and lost the whole exit window. Such a restart chain has
no "zero instances" moment at all, so the real cleanup happens when you DSH.)
6. How correctness is proven
- Bundled regression suite (never touches real data):
node test/selftest.mjs → 60/60 pass (fake DSH_HOME; covers list, orphans, purge+restore, the dismiss safety gate, offline transform, restore rollback, the auto-clean "is another DSH instance running?" check that must not match the helper itself nor the GPU/renderer children and tool runners and must not call the current instance's own main process "another instance", a forked session whose inherited catalog rows must survive byte-for-byte while its own rows are dismissed and the cache is rewritten with the fence, the "a helper that never finished must not stay silent" check, the rule that a heartbeat is only trusted when it belongs to that very helper, the restart-chain hand-over rule: an older helper yields only while the newer helper is alive, and stays as a fallback once it is gone, write atomicity: bytes land verbatim, no temp file survives, and a dismiss leaves no leftovers in the session directory, and lock release: your own lock is deleted while someone else's is never touched); 63/63 pass with a real-log fixture (section below).
- Offline probes for the two plugin halves (no DSH, no writes to session storage):
npm run probe:client → VERDICT A=PASS B=PASS C=PASS — fakes window.__ModuleLoader__ and a minimal React
to prove the settings slot is registered exactly once, that a late slots service is still picked up through
internal/service (idempotently), and that a host without slots does not throw.
npm run probe:host → VERDICT PASS — drives the host half's two routes with fake requests: fence 403/405,
the scan payload shape, and every POST /settings branch (bad body, unknown field, off, on, no double-arm).
It backs up and restores auto-arm.json and arms the helper in dry-run mode (DSAC_ARM_DRYRUN=1), so no real
process is spawned and no cleanup runs.
- Auto-clean helper exercised for real:
clean.mjs autowait prints 开关关闭,未做任何改动。 when the switch is
off (exit 0) and writes {"ran":false,"why":"timeout",…} to auto-report.json when the waited pid never dies —
both without touching a session log. 1.3.7 end-to-end run (fake DSH_HOME + fake state dir, real log as material,
--no-app to skip the real loader): the helper rewrote all 22 catalog rows of an 11,443-line / 12,622,844 B session
(catalog 0 / dismissed 22, line count unchanged), left in , reported , exited 0, and left no
behind.
7. Caveats
- You must quit and restart DSH after a change, or the UI will not reflect it.
- Do not put backup directories inside
$DSH_HOME/sessions/ (they would be treated as sessions).
- To avoid the problem in the first place: spawn fewer long-lived continuable subagents; prefer workflows for batch work.
- A future DSH release may change the session format — re-check section 4 if it does.
8. License
MIT, see LICENSE.