dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
侧边栏会话操作:右键打开行菜单、双击重命名、复制会话 ID,以及二次确认后连同存储一起删除会话。
English | 中文
Extra actions for the Conversation list in the DeepSeek Harness (DSH) Web UI.
The built-in row menu offers only Rename / Fork / Archive, and only from the
⋯ button at the end of the row. Without patching DSH, this plugin adds:
| Action | Gesture |
|---|---|
| Menu on right-click | Right-click anywhere on a Conversation row; the Harness's own menu opens (Rename / Fork / Archive plus this plugin's two rows) |
| Rename by double-click | Double-click a Conversation row (outside its buttons); the Harness's own rename dialog opens |
| Copy session ID | Row menu → Copy session ID; the menu dismisses itself once the id is on the clipboard (a refused write leaves it open to retry) |
| Delete session | Row menu → Delete session (red, below Archive); permanent, behind a confirmation, and the row leaves the list with the storage |
Delete is not Archive. Archive hides a Conversation and keeps both its log and its accounting slot; Delete erases it from storage and cannot be undone, so it always asks twice.
Once the storage is gone the browser half calls the Session list's own removal
entry point (sessions.handleSessionRemoved, the very method the Host's
api-session/removed frame drives), so the row goes immediately. It
deliberately does not re-pull the list: that pull merges the Host's current
baseline back in, which would return any row the Host still reports. The Host
half also emits the official api-session/removed, as the same signal on the
wire. Deleting the Conversation that is currently open also clears the
selection back to the empty state, so the conversation pane cannot keep showing
a session the list no longer has.
This package is a DSH bundle: its manifest declares dsh.bundle.patch,
pointing at the cordis.patch.yml it ships. There are two ways to install it —
pick one, never both:
Recommended: install it as a bundle (the community market's Install button, or by hand)
dsh plugin --profile desktop add dsh-session-actions
The package then appears in the profile's dsh.profile.bundles, and its patch
layer inserts the Loader row at startup, so no profile file needs editing. The
market verifies the dsh.bundle.patch declaration against npm before it
installs and refuses a package without one.
Or: add the insert row by hand (for a profile that does not use bundles)
In the DSH profile's cordis.patch.yml (for example
$DSH_HOME/profiles/desktop/cordis.patch.yml):
- insert:
- id: session-actions
name: 'dsh-session-actions'
⚠️ If the profile already lists this package in dsh.profile.bundles, do
not also add that row: the plugin would load twice (the Host half fails on
a duplicate route registration, and the menu rows appear twice).
The package declares dsh.client.platform: web and ships both halves
(index.js for the Host, client.js for the browser) with no build step.
Both halves load at application start: the browser half's bytes are
snapshotted during the startup scan and the Host half is evaluated then, while
HMR is unavailable in the current DSH Desktop build (the log reports
[hmr] Error: --expose-internals is required for HMR service). Upgrading the
plugin therefore needs a DSH restart; a page refresh is not enough.
Deletion follows the layout DSH really produces, per Conversation:
$DSH_HOME/sessions/<projectKey(cwd)>/<sessionId>/session.v<N>.jsonl.zstd the log (and its lock and siblings)
$DSH_HOME/storages/session_projcache/sessions/<sessionId>.json the projection cache
$DSH_HOME/storages/workspace.json the id, from archivedSessionIds[]
and every workspaces[*].sessionIds[]
projectKey is a lossy encoding (separators collapse, long paths truncate), so
the owning directory is found by scanning the sessions root rather than
recomputed — which also means a Conversation whose header cannot be read is
still deletable.
Attachments are not touched. $DSH_HOME/attachments/v1/objects/ holds
content-addressed (sha256-sharded) objects that several Conversations can
share, so removing them per Conversation would damage the others. The
confirmation dialog says so.
agent/status: while an Agent is executing a turn for the Conversation
(ctx.agents.get(id).status === 'running') the plugin refuses, because that
turn is still appending to the log this surface would unlink — deleting it
would discard the question and the answer together. A Conversation that is
merely open, and idle, deletes normally: the files go, and the row goes with
them.
AgentHandle.dispose() goes to the creator
only). So after deleting an open-but-idle Conversation, the process keeps a
copy of it until you restart DSH. Storage and list are already clean, and
no new log content is produced in the meantime.workspace.json directly, while the Workspace registry keeps its own
in-memory copy and rewrites the whole document on its next mutation (archive,
reorder). The residue is an id naming a Conversation that no longer exists,
which no grouping surface renders; the Host's session index reconciles against
storage on every read, so no ghost row survives in the list.SessionNodeItem builds its Menu items inline), so the browser half
appends two rows after the menu renders; the right-click gesture likewise
presses the ⋯ trigger the row itself renders instead of building a
second menu. Having made the card taller, it then re-places that card from its
real size — flipping it above the trigger when the space below cannot hold
it, keeping the Harness's own 12px viewport margin, and repeating the
correction after any scroll or resize. Without that, the two added rows fall
below the fold whenever the menu opens near the bottom of a short window. The
Session id is never guessed from DOM text: it is read back
from the row's own React fiber props (node / onRename / onArchive),
whose names are not mangled in the shipped bundle. If a future DSH renames
those props, this half degrades to adding nothing rather than to acting on the
wrong Conversation./api2/dsh-session-actions/* is a plugin-owned JSON surface, not part of the
official /api channel (that is a generated Remote assembly an out-of-tree
plugin has no seat in). Because it is destructive it carries three fences,
matching the other plugin-owned /api2 surface: POST only, application/json
only (a cross-site page cannot send that content type without a CORS preflight,
which this route never answers), and a loopback Host (extend with the
comma-separated DSH_SESSION_ACTIONS_TRUSTED_HOSTS).
A Session id is validated against ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ before it
is joined to a path, encoded the way the persistence backend encodes it, and the
result is re-checked to still sit under the sessions root.
node --test 'test/*.test.mjs'
Zero dependencies, node:test. Three layers: test/session-files.test.mjs
builds the real storage layout in a temp directory and asserts what survives;
test/plugin.test.mjs drives the trust fence, request validation, the
running-Session refusal, and the api-session/removed announcement through a
A deletion must leave no trace, including the trace another plugin keeps. So the Host half emits conversation/deleted (payload: the removed session id) after the storage is gone and before it announces api-session/removed, letting any plugin that remembers the conversation drop its record — dsh-conversation-link, for one, forgets the handle, the nickname, and the edge. The order is deliberate: plugins forget first, the list collapses after. A throwing listener is logged and cannot turn a completed deletion into a failure.
A Conversation whose storage was already removed outside this surface is still cleared. inspect then answers exists: false, the confirmation explains that only the list row is left, and the primary button becomes Remove from list while staying actionable. Confirming runs the same delete route: the missing directory is a no-op, while the projection cache and the workspace-registry references are still pruned and api-session/removed is still broadcast, so the row disappears. There is no longer a delete dialog that looks clickable and does nothing.
fake Cordis context; test/client.test.mjs runs the browser half against a
small fake DOM and a stateful React stand-in: fiber id lookup, double-click
rename, the right-click menu, menu decoration and re-placement, the dismiss
after a copy, the confirmation flow, and the list removal after a delete.
MIT