dsh-dialogue-cleanup
English · 简体中文
Delete DeepSeek Harness sessions from the running Web GUI — without restarting, without leaving the window.
One row in the session ... menu for a single delete, plus a bulk management page inside Settings.
Install · Gallery · How it works · Releasing · Releases
Why it exists
One-line positioning: deleting a session should actually delete it. DeepSeek Harness keeps a session in four places at once — the live store, the workspace records, the registry leftovers (pin / archive), and the files on disk. Removing one of them leaves a ghost row, a stale workspace, or a directory that silently keeps occupying disk. Everything below is that positioning made concrete:
- One irreversible operation, implemented once.
deleteSessionlives in the Host (lib/service.js) and is reached through two thin adapters — the HTTP contract and the slot UI — so the single-delete path and the bulk path cannot drift apart. - A running session is refused, not guessed at. The Host decides
deletableand the UI simply does not offer what the Host would reject. The panel never decides by itself whether something is safe to delete. - Archived sessions are still shown by default. Archiving is not deleting: an archived session
still occupies disk, so hiding it by default would hide exactly the files you came to clean up.
(The official sidebar hides them; here it is a tri-state filter —
hide archived/all/archived only— and archived are shown by default.) - Subagent rows are labelled. The official sidebar hides
origin: 'subagent'rows entirely, so whenever those rows do appear (kind = subagent / all) each one is explicitly badged — a subagent session must never look like an ordinary conversation. - It says what it actually did. The result line reports ok / failed / degraded / directories
removed, and each failure keeps its own reason verbatim. When the live store entry cannot be
detached, the plugin reports
detached: falserather than claiming success. - Errors are semantically truthful. Unlike the upstream package (which always answered
200), this one uses real status codes: 400 / 403 / 404 / 405 / 409 / 429 / 500.
UI at a glance
The confirmation overlay: irreversible, and it says so. Two closing paths only — an explicit
Cancel button, or Esc. Clicking the scrim deliberately does not dismiss it, because a
mis-click on an irreversible delete should not cost you your chance to confirm:
The overlay is a native <dialog> driven by showModal(), so it enters the browser's top
layer and therefore stacks above the Settings panel it was opened from (a plain positioned element
could not). That also brings a real focus trap and correct Esc semantics for free — and it needs no
portal outside the component tree.
The bulk panel (inside Settings) — search, project / state / session-kind / archive filters,
sort, paging, and per-row status badges. The badges in the screenshot above come from real render
output: 空闲 (idle) / 运行中 (running, not selectable) / 子代理 (subagent) / 已归档 (archived).
The two screenshots are generated from the actual component tree and the actual CSS by
scripts/render-shot.mjs(the same React stand-in the render tests use), rasterised with headless Edge. They are not hand-drawn mockups; they are also not a photograph of a live GUI — see Honest limitations.
Quick Start
dsh plugin --profile web add dsh-dialogue-cleanup
- Single delete — hover a session row in the sidebar, open the
...menu and pick Delete session (registered atorder: 500, right after the shipped Archive entry at 400). A themed confirmation appears; confirming removes the live entry, the workspace records, the registry leftovers and the on-disk artifacts. - Bulk delete — open Settings → Dialogue Cleanup. Tick rows (or Select all deletable), then delete them in one request.
- Host-side changes need a restart of the DSH process; client-side changes do not (see the table in docs/RELEASING.md).
Compatible with DSH 0.2.0-rc.2. The npm package and the GitHub Release are published together; versioning rules and the release chain live in docs/RELEASING.md.
How it works
One npm package carries both halves, installed into the host per the official plugin spec:
- Host half (repo process) —
lib/index.jsis a Cordis function plugin exporting onlyname/inject/apply. It registers a singlectx.webServerprefix route and keeps the entire deletion implementation inlib/service.js. Filesystem removal and path containment live inlib/artifacts.js; the audit trail inlib/audit.js. - Client half (browser) —
client.js, a single-file bundle registered throughwindow.__ModuleLoader__.load({ id, factory }), with zero dependencies beyondrequire("react"). - Both entry points are official slots; neither touches the DOM:
sidebar.workspaces.session.menu.itematorder: 500→ the single-delete row.settings.section(id: dialogue-cleanup,order: 25, after the shippedaccount(-10) / general(0) / models(10) / plugins(15) / agent-presets(20)) → entry and panel. One registration is one Settings page, whose content renders in the panel's content column.
- Because the panel lives in Settings it needs no
layoutservice and does not compete for space inmainor the sidebar — one fewer optional dependency and one fewer degradation branch. - The stylesheet travels with the component. The menu item renders its own
<style>beside itself (finding H1 of the project's own audit: the row was rendering unstyled because the stylesheet was only injected on the settings path).
The deletion sequence
- Refuse a running session — 409
refused. - Detach the live store entry — otherwise the deleted row survives as a ghost.
- Drop workspace records — counting only workspaces that actually detached.
- Clear registry leftovers — pin / archive records, via the public idempotent API.
- Remove on-disk artifacts —
<DSH_HOME>/sessions/<project>/<sessionId>, plus the derived projection cache. - Write an audit line —
<DSH_HOME>/logs/dialogue-cleanup.jsonl.
HTTP contract
| Method | Path | Purpose |
|---|---|---|
POST |
/api-ext/dialogue-cleanup/delete |
{ "sessionId": "..." } → delete one; { "sessionIds": [...] } → delete many (the Host runs the same deleteSession per id and returns a per-id result) |
POST/GET |
/api-ext/dialogue-cleanup/list |
session rows with deletable / blockedReason |
GET |
/api-ext/dialogue-cleanup/status |
capabilities, config, runtime paths |
Envelope: { ok: true, value } / { ok: false, error: { code, message, hint? } }.
Configuration
All tunables live in the plugin Config (lib/config.js), so a user's patch layer survives
upgrades. A matching override replaces the whole config — write every field you care about.
| Key | Default | Meaning |
|---|---|---|
maxBodyBytes |
65536 |
request-body ceiling (upstream: 1 MiB) |
readTimeoutMs |
10000 |
body-read timeout (upstream: none) |
audit / auditPath |
true / null |
audit log; null → <DSH_HOME>/logs/... |
rateLimitPerMinute |
60 |
per-minute delete request budget (a batch counts once); 0 disables |
requireIdleSession |
true |
safety valve for the running-session refusal |
stopIdleAgent |
false |
see the honest-degradation note below |
matchAlternateIdSpelling |
true |
probe the other session- spelling, guarded |
removeProjectionCache |
true |
also drop the derived projection cache |
clearRegistryRecords |
true |
unpin / unarchive leftovers |
confirmInUi |
true |
UI confirmation (the Host does not trust it) |
titleCacheTtlMs |
300000 |
title-fold cache TTL; 0 disables |
titleFetchConcurrency |
8 |
shard concurrency when folding titles |
titleLimit |
100 |
max rows per page to fold titles for; 0 skips title reading |
runningProbeTtlMs |
1000 |
how long a running-state snapshot is reused. Turns batch delete from O(N) full-corpus list calls into O(1); 0 re-probes every item |
Honest limitations
These are measured against the live runtime and differ from what the upstream package assumes. They are reported truthfully rather than papered over:
- No public stop for an idle agent. The public
Agenttype is{ readonly id: SessionId }— nostatus, noctx. Upstream'sagent.status === "running"andagent.ctx.fiber._unload()therefore cannot be relied on (_unloadis a Cordis private method and is deliberately never called). "Is it running?" comes from the publicsessionController.list()→SessionSummary.running. A session that still has an attached agent and cannot be stopped safely is refused, and the UI shows why. - Detaching the live entry is best-effort. The public
sessionsservice exposes create/prepare/enter/announce/flush/get/list/fork — no publicdetach. The plugin tries the public method first, then the internalstoreentry, and reportsdetached: falsewhen neither is available instead of claiming success. - Two on-disk id spellings coexist. Observed in one
<DSH_HOME>:...\<uuid>\session.v4.jsonl.zstdand...\session-<uuid>\session.v4.jsonl.zstd. An exact-id join can never delete the prefixed half. This plugin probes the alternate spelling only when it can confirm the candidate is not a different live session; with no such evidence it deletes nothing rather than risk a neighbour. - The screenshots are real render output, not a live-GUI photograph. They come from the actual component tree + actual CSS, rasterised headless; the theme tokens are pinned to a light theme and the row data is a demo fixture. What the panel looks like inside a running GUI with the real theme has not been captured here.
Development and acceptance
# Full gate: node --check + migration/contract/render/release regression suites
node --test "test/**/*.test.mjs"
# Release gate only (no version change, no packing, no publishing)
pwsh -File scripts/release.ps1 -LocalOnly
# Regenerate the two documentation screenshots from the real component tree
node scripts/render-shot.mjs
The suites are currently 161 tests across 9 files:
| Suite | Covers |
|---|---|
contract.test.mjs |
the host ↔ client contract and slot declarations (largest suite) |
service.test.mjs |
deleteSession end-to-end behaviour and refusals |
client.test.mjs |
registration surface, ids, no-cross-package requires |
render.test.mjs |
components actually rendered through a React stand-in |
artifacts.test.mjs |
path containment, symlink refusal, alternate-spelling guards |
session-id.test.mjs |
id validation (no separators or dots) |
eol.test.mjs |
line endings, and that every tracked file is git-declared eol=lf |
release-workflow.test.mjs |
the OIDC publishing structure (reverse assertion: pack-release.yml must not contain its own publish steps) |
ps-script.test.mjs |
PowerShell 5.1 BOM / encoding pitfalls |
Two of these exist because the corresponding bug actually shipped and was caught afterwards:
eol.test.mjs— the first release produced different bytes from CI (pnpm pack) and from local (npm pack):icon.svgandLICENSEwere CRLF in CI, because.gitattributesdeclared only* text=autoand the GitHub Windows runner defaults tocore.autocrlf=true. The first version of this test missed it (it scanned a hardcoded extension list, so*.svgand the extension-lessLICENSEwere invisible); it now sweeps every git-tracked file and reads the effective attribute viagit check-attr. Fixing it also caught.gitignore.release-workflow.test.mjs— the OIDC trust is bound to a workflow filename, and writing the publish steps a second time is exactly the bug that broke that other repository's releases twice. See docs/RELEASING.md.
Repository layout
| Path | Contents |
|---|---|
lib/index.js |
apply(ctx, config) — assembles and registers the host routes |
lib/service.js |
deleteSession — the single deletion implementation |
lib/artifacts.js |
disk removal + path containment |
lib/http.js |
same-origin checks, body limits, status codes |
lib/config.js |
Config schema + defaults |
lib/audit.js |
JSONL audit trail |
client.js |
the web client half: slot registrations (menu row + confirm overlay + settings section) |
cordis.patch.yml |
inserts the host half, inject: [webServer] |
.github/workflows/ |
pack-release.yml (tag → gate → pack → GitHub Release) and publish-npm.yml (the only npm-publish implementation, reused via workflow_call) |
docs/RELEASING.md |
version rules and the release chain |
scripts/release.ps1 |
version bump + gate (+ CI pack/release) |
scripts/render-shot.mjs |
regenerates the documentation screenshots |
test/ |
the 161-test regression suite |
License
MIT
No comments yet. Be the first to write one.