dsh-agent-clean
Manifest validRemove subagent entries from DeepSeek Harness session logs without breaking the log, delete orphaned projection caches, and purge whole sessions — always dry-run first, always with a backup.
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 thedshfield, 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 withformat v4 event N is not dense) - Only retypes the session's own
subagent/catalogrows tosubagent/catalog-dismissed+ignorable: true - Also rewrites the projection cache's
subagentCatalogto a valid empty state (DSH'sdsh-chat-managerdoes not remove that cache when a session directory is deleted, leaving orphans behind) - Full backup before every write;
restorebrings 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.jsonin 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 skips this window and tries the next one.
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/catalogevent intosubagent/catalog-dismissedand addignorable: true(unknown types must be ignorable or the loader throws). - Leave the header untouched: every
session-log-deepseek/delivery-acceptedrow'sdata.sessionIdmust equal the header'sid. Changing only the header id yieldsSessionFormatError: current-generation delivery marker names the wrong Session. - Valid empty cache state: set the projection's
subagentCatalogvalue to{"inheritedEventCount": <n>}(nothead.values: [], which fails schema validation withtoo_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
--applyevery touched file is first copied in full tobackups/<ISO>_<command>_<shortid>/together with amanifest.jsonrecording the original paths (used byrestore). - 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. orphansonly touches files understorages/session_projcache/sessions/.purgebacks up all generation logs (session.v3/v4…) plus the cache, then removes the session directory;restorerecreates it.- The auto-clean helper (2.2) is only ever armed, never in-process: it waits for the DSH pid to disappear, 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), and skips the window entirely if another DSH instance is still
running. "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 (leftover children get a 10-second grace). It
then goes through exactly the same backup → mirror self-check → real-loader verification path as
dismiss --all --apply. A switch turned off mid-session needs no kill: the waiting helper re-readsauto-arm.jsonand exits untouched, and a restarted host process will not arm a second helper while the previous one still waits for the same pid. - A silent no-show must be visible: every time the host half arms the helper it records what is waiting for what in
auto-armed.json, and while waiting the helper writes "last seen alive" toauto-heartbeat.jsonevery 60 seconds. On the next start, if that helper is gone and no report is newer than the arming record, the settings page says so — and it only shows that warning in red when there is actually something left to clean (a skipped window with nothing to do has no effect). The wording no longer claims it "disappeared after X" — that was the arming time, not the time it died; with a heartbeat the page can say "last heartbeat …, now gone". (Measured lesson: a helper armed by a host tool dies with that tool's process tree — nothing happens in the exit window and nothing is left on disk.) Every terminal path writes a report — success, timeout, another instance running, switch off, lock busy, plus 1.3.4's helper crash (helper-crash) and session-listing failure (list-failed) — and the helper shell installsuncaughtException/unhandledRejectionhandlers, so the next start can always tell "it crashed (with the error text)" from "it was killed along with something else (heartbeat stopped at …)". - Forked sessions: the first part of such a log is the parent's inherited region
(
identity.inheritedEventCount) and may contain the parent's ownsubagent/catalogrows. Those rows do not belong to this session: they are kept byte-for-byte, and the self-check must not count them as "remaining catalog" — otherwise any fork with inherited catalog rows fails the self-check forever and can never be cleaned. The projection cache is then written as{"inheritedEventCount":N}. When an auto-clean run does fail, the settings page now prints the per-session reason (report.results[].why) as well.
6. How correctness is proven
- Bundled regression suite (never touches real data):
node test/selftest.mjs→ 49/49 pass (fakeDSH_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, 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, and the rule that a heartbeat is only trusted when it belongs to that very helper); 52/52 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— fakeswindow.__ModuleLoader__and a minimal React to prove the settings slot is registered exactly once, that a lateslotsservice is still picked up throughinternal/service(idempotently), and that a host withoutslotsdoes not throw.npm run probe:host→VERDICT PASS— drives the host half's two routes with fake requests: fence 403/405, thescanpayload shape, and everyPOST /settingsbranch (bad body, unknown field, off, on, no double-arm). It backs up and restoresauto-arm.jsonand 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 autowaitprints开关关闭,未做任何改动。when the switch is off (exit 0) and writes{"ran":false,"why":"timeout",…}toauto-report.jsonwhen the waited pid never dies — both without touching a session log. - Byte-identical on a real log: with a real session log (11,443 lines / 12,622,844 B),
node test/selftest.mjs --fixture <log> --expect-sha256 B51E6D96F3B4CF922282ECA1BBD11BE175EB9D64EAC676F37F746DAB619C3EBA→ the produced sha256 is identical to the manually validated fix. - Full end-to-end (real desktop app + fake home): after dismiss the log goes 12,622,844 → 12,624,094 B (sha
B51E6D96…), the mirror and live real-loader checks both reportOK 投影条目=0, and the cache goes 117,693 → 111,544 B.restorereturns the log to 12,622,844 B (sha2140EFC1…) and the original cache, byte-for-byte. - Syntax check:
node --check clean.mjs.
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.
Comments
Loading…