NIGHT576/dsh-delete-session0

dsh-delete-session

Adds real conversation deletion to the DSH Web sidebar: a session-menu action for one conversation and a Workspaces-header batch dialog for several, each archiving the conversation and then removing its log directory and projection cache from disk.

AI 分析

该插件暂无 AI 分析内容。

包名
dsh-delete-session
版本
1.2.0
许可证
MIT
最近更新
2026年9月12日

安装

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:NIGHT576/dsh-delete-session

description: "Adds real conversation deletion to the DSH Web sidebar: a session-menu action for one conversation and a Workspaces-header batch dialog for several, each archiving the conversation and then removing its log directory and projection cache from disk." kind: "package-reference"

dsh-delete-session

English | 中文

Summary

dsh-delete-session gives the DSH Web sidebar a destructive action the product itself does not ship: deleting a conversation for real. A session row's context menu gains a red Delete conversation item, and the Workspaces section header gains a red trash button that opens a batch dialog with per-conversation checkboxes, a title filter, and select-all. Both entry points archive the conversation first — the product's own durable filter, which is the only thing that hides a session from every grouping — then remove its log directory under $DSH_HOME/sessions/ and its entry in the projection cache, and finally drop its row from the client session list. That last step matters because a conversation that has ever been opened stays alive inside the host process until the process restarts, and the host's session list keeps reporting it. A conversation whose agent is currently running is refused rather than deleted.

Table of Contents


Use this package

Install it into a DSH profile as a bundle and restart the process that serves the Web GUI:

dsh plugin --profile web add github:NIGHT576/dsh-delete-session

The profile patch that makes it load lives in this repository (cordis.patch.yml, pointed at by dsh.bundle.patch), so dsh plugin add is the whole installation: it installs the package as a profile dependency, appends the package to dsh.profile.bundles, and applies the patch as the outermost composition layer. The host half is imported at startup, so it needs a process restart; the browser half is fetched by the page and applies on the next reload.

If you installed an earlier copy by hand, remove the hand-written insert row from the profile's own cordis.patch.yml before adding the bundle — the same plugin id inserted twice would compose the plugin twice.

When to choose it

Choose it when a deployment accumulates conversations that must actually go away — disk-usage pressure, a shared machine, or a demo that has to start clean. It is not an undoable archive: the archive set is only the mechanism that hides the row, and the log directory is removed for good. If you need the conversation to remain recoverable, archive it through the product's own command instead and leave this plugin out.

The two entry points

Entry pointWhereWhat it does
Delete conversationA session row's context menuConfirms once, then deletes that one conversation
Batch deleteThe trash button in the Workspaces section header, beside the search / view-options / add-workspace iconsOpens a dialog listing every ordinary conversation, newest first, with checkboxes

Both open the same kind of dialog: title, ×, a description naming the conversation, and a footer with Cancel (focused by default) and a red confirm button. Escape closes, the backdrop dismisses, and while a delete is in flight every control is disabled so the dialog cannot be re-entered.

What a delete actually does

  1. Archive first. The conversation is added to the workspace registry's archive set (workspaceRegistry.archiveSession on the host, uiWorkspace.archiveSession on the client, which also echoes the complete set into the client model). This must happen while the log still exists: the registry rejects an id that is neither live, header-indexed, nor present in a fresh persistence listing.
  2. Remove the log directory$DSH_HOME/sessions/ //, recursively, with three retries, then re-checked so a swallowed failure cannot be reported as success.
  3. Remove the projection cache$DSH_HOME/storages/session_projcache/sessions/.json. Derived state, so a leftover entry would be harmless, but it is removed anyway.
  4. Drop the client rowsessions.handleSessionRemoved(id), the exact relay the host's own api-session/removed event drives, plus a sessions.clear() when the deleted conversation was the one on screen.

Nothing else is touched: attachments, other sessions, workspace records, and the workspace accounting slot the conversation occupied are all left alone.

Refusals

  • Running conversations are refused in both halves — the host checks agents.get(id).status === 'running', and the client greys the row out and skips it. Removing a log that a live turn is still appending to would recreate the file with a partial history.
  • The conversation you are viewing is allowed, with an extra line in the dialog saying so. Deleting it clears the selection (the archive-set watcher does that on its own) and the plugin adds an explicit sessions.clear() as a backstop, so you land on the no-session view state.
  • Every failure is per-item. A batch reports each conversation separately: the ones that went away are removed from the list, and the ones that did not stay checked with their reason printed, so a retry is one click.

Screenshots

The plugin declares no screenshots yet; screenshots.json beside package.json (1–8 relative image paths) is the way to add them, and storefronts otherwise fall back to images found in this README.


Understand the implementation

Implementation internals — click to expand

Both halves are plain JavaScript with no build step: the host half is Node ESM exporting apply/inject, and the browser half is a window.__ModuleLoader__.load({ id, factory }) module that receives React from the shell. The package imports nothing from the harness at runtime — the host half uses only Node builtins, and the browser half only the services the page already publishes.

The private route

The browser cannot reach the filesystem, so the client half calls the host half over one private HTTP route, POST /dsh-delete-session. The route is guarded by a 48-hex-character token minted per process and injected into every rendered index.html by webServer.tapIndex as ``; the client sends it back in x-dsh-delete-session-token. A cross-origin page cannot read that meta tag, and the custom header forces a preflight a foreign origin cannot satisfy. Method, token, and JSON shape are all checked before anything is touched, and the request body is capped at 256 KiB.

Two body contracts exist, on purpose:

BodyAnswer
{ sessionId, currentSessionId, allowCurrent? }One flat result object
{ sessionIds: [...], currentSessionId, allowCurrent }{ ok, deleted, failed, results }, one result per id, capped at 200 ids

The flat contract came first and is still served, so the client half falls back to one request per conversation when it meets a host half from before the batch upgrade (recognisable by a 400/404/405 answer). Every result carries a stable codeok, invalid-id, current, running, root-missing, log-locked, empty, bad-body, forbidden, method, internal — and the client half owns the localized wording, so the host never has to know the page's language.

Where the client half attaches

The sidebar exposes no additive slot at either location, so both entry points are injected into product-owned DOM from one seat component registered in the shell.overlay slot.

  • The context-menu item. A capture-phase click listener records the session id of the row whose button was clicked, read out of that row's React fiber (memoizedProps.node.id, which starts with session- — that prefix is also what keeps workspace rows out). A MutationObserver then recognises the freshly opened menu by requiring both the Fork and the Archive labels from the live workspace dictionary, descends to the smallest subtree holding them, and appends the red item.
  • The header button. It is inserted as a direct child of the section header, immediately before the *_headerActions cluster. That cluster is capped at 60px — exactly two 28px icon buttons — with overflow: hidden, so a third child inside it would be clipped. Location is done by CSS-module class suffix (_sectionHeader, _headerActions) because the prefix hash is regenerated on every upstream build, and the primary path anchors on any rendered role="treeitem" row rather than assuming anything about the document outside the sidebar; a label match against both shipped spellings is the fallback for a sidebar with no rows yet.

React keeps direct references to its own DOM nodes and never removes an unrelated sibling, so an injected node between two React children is safe; if a re-render ever does remove it, the next observer batch puts it back. Removing the seat removes every node, listener, and stylesheet it owns.

Two design decisions worth knowing

  • Archive before delete, and never detachSession. The workspace registry's archive set is designed to keep a session's accounting slot — "an archived session keeps its sessionIds slot so unarchiving restores its position" — and it already filters a slot out once the session's header stops resolving. An earlier version additionally called detachSession after removing the log; that dropped the conversation out of its workspace, so the row the user had just deleted reappeared under Ungrouped instead of disappearing. Nothing is detached now.
  • The client drops the row itself. The host has no public way to dispose a conversation another owner created, and the event that removes a client row (api-session/removed) is driven only by session/disposed, which the plugin cannot reach. Calling sessions.handleSessionRemoved(id) is that same relay, and it is what makes the row vanish immediately for a conversation the host still holds in memory; the archive set remains the durable filter that a later list refresh is reconciled against.

Failure handling

The archive is attempted first and its failure is not fatal: a log that cannot be removed returns log-locked with the directory in detail, and because the archive may already have hidden the row, the client appends "the row is hidden, but its log file could not be deleted" instead of pretending the delete succeeded. A missing /sessions root fails closed as root-missing before anything is touched. A conversation whose log is already gone is reported as ok with alreadyGone: true, which keeps a retry idempotent.


Further Exploration

  • awesome-dsh-plugin — the community list this plugin targets, and the source of the manifest and description rules followed here.
  • dsh-market — a plugin storefront that installs, switches, and uninstalls listed plugins, including the themes tab.
  • @deepseek-ai/dsh on npm — the harness these halves attach to.

Model Experience

None. Both halves are UI and file-system plumbing: the plugin registers no tools, contributes no prompt section or context, and writes no session events, so nothing it does reaches a model request. The conversation being deleted is addressed by id through the sidebar's own DOM; its content is never read.

KV Cache Impact

None. The plugin never touches the request prefix or the event stream, so it cannot invalidate a provider's cached prefix.

Known Limitations and Deferred Work

  • The entry points are DOM-injected. The sidebar exposes no additive slot in either location, so both attach to product-owned markup. The CSS-module suffix match (_sectionHeader, _headerActions) is the deliberate stability boundary — the prefix hash changes every upstream build — but a markup change beyond it needs a plugin update.
  • A live conversation is only hidden. Deleting a conversation the host still holds in memory removes its files and its row, and the archive set keeps it out of every grouping, but the process keeps the object until it restarts.
  • Deleting the conversation you are viewing leaves you on the empty state. The product's archive watcher clears the selection; the plugin does not pick a replacement conversation for you.
  • Deletion is one-way in both directions. There is no unarchive or restore: the archive set hides, the log directory is gone.
  • Only two paths are removed. The log directory and the projection-cache entry. Attachment blobs, workspace records, and anything else a deployment stores for a session are left in place.

Dev Note

Maintainer context — click to expand

Two dependency-free smoke tests cover both halves and run without DSH:

npm test
# or individually
node test/host-smoke.mjs      # drives the real route against a throwaway $DSH_HOME
node test/client-render.mjs   # renders the real seat with a minimal React and DOM stub

The host test is also the regression guard for the two rules above: it asserts that the archive lands before the log is removed, and that detachSession is never called. The client test drives the mount effect through a small hand-built DOM so header injection, the click-to-dialog wiring, idempotent re-injection, and the label fallback are all checked.

Pitfalls worth remembering before changing anything here:

  1. Do not detach the conversation from its workspace after deleting it. That is the Ungrouped bug: the deleted row comes back as an ungrouped stray. See the design decisions above.
  2. Do not put the header button inside *_headerActions. It is capped at 60px with overflow: hidden; the third child is clipped.
  3. Do not subscribe to anything inside the seat's mount effect and return the subscription disposer as cleanup. Doing so once unmounted the whole seat on mount: apply finished, slots.register returned its disposer, and the component never appeared. The colour scheme is therefore sampled when a dialog opens rather than followed live.
  4. A child process does not expand %VAR%. echo %DSH_HOME% inside cmd.exe prints the literal string; read process.env.DSH_HOME instead, as the host half does, and fall back to /.dsh.
  5. The host guard forbids reaching into another Cordis context. service.ctx.emit(...) is rejected; a plugin may only use the context it was applied with.
  6. A running conversation must stay undeletable, on both halves. The client greys the row out to say why; the host refuses even if the client does not.

Profile mechanics: the patch layer is live-reloadable, module code is not. Editing cordis.patch.yml takes effect immediately, editing lib/index.js needs a process restart, and editing lib/client.js takes effect on the next page reload.

Runtime invariant: every side effect belongs to the one shell.overlay seat: the injected menu rows, the header button, the stylesheet, the dialogs, and the document listeners are all created in its mount effect and removed by its cleanup, so unloading the plugin leaves the sidebar exactly as it found it.