Install
Inside DeepSeek Harness, with dsh-market
dsh plugin --profile web add dshmarket
Or from the command line
dsh plugin --profile web add @domitor-syh/dsh-rollback
Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.
README
dsh-rollback · TRAE-style rollback plugin
🌐 语言 / Language: 中文 · English
A TRAE-style "roll back to before this turn" plugin for the DeepSeek Harness (DSH) Web client: it captures per-turn checkpoints and, with one click, rolls back both workspace files and model context to before a given turn while keeping the same session id.
What it is
dsh-rollback faithfully implements the core idea behind TRAE's rollback — rolling back conversation state and file state in lockstep:
- Model behavior is driven by both the conversation history and the workspace files, so a rollback must roll back both at once; otherwise you get hallucination continuation or state conflicts.
- Rollback = restore the files touched in this turn and later (modified → write back the prior content, created → delete) + truncate the conversation history in place (same session id, so the model no longer sees the truncated content).
Supported DSH versions
How the latest release (0.4.0) fares on every DSH build published so far; the list comes from @deepseek-ai/dsh on the npm registry and covers every build published as of this release:
| DSH version | Status | Notes |
|---|---|---|
| 0.2.0-rc.2 | Tested | The build the official desktop app currently bundles, and the target this release was adapted to. Both core changes were fixed and re-verified on it: conversation.chat.turnTail moved from chain to list (otherwise the interrupted-turn rollback button silently disappears), and session format v4 refuses source.kind: 'plugin' (otherwise every later turn in a rolled-back session fails) |
| 0.1.5-rc.2 | Tested | The build the previous adaptation was verified against end to end: rollback, truncation, file restore, hero, hiding, and image re-attach. 0.4.0 still behaves as verified there — both fixes keep recognizing and falling back to the old spelling (the chain seat, the {kind:'plugin'} marker), so one artifact keeps serving that generation |
| 0.1.1-rc.2 | Supported by design, not tested | The plugin keeps feature-detected fallbacks for the contracts this generation used: the retired conversationEvents service, session.chat on the snapshot, the legacy start/end surface-op spelling, and addImages/pruneImages. No 0.1.1 build was available, so this path was not exercised on a machine running 0.1.1 — it is a designed fallback, not a tested one |
| 0.1.1-rc.1, 0.1.2-alpha.2–alpha.5, 0.1.2-rc.1, 0.1.3-alpha.2, 0.1.5-alpha.1/alpha.2/rc.1/rc.3, 0.1.6-alpha.1/alpha.2, 0.1.7-alpha.1/alpha.2/rc.1 | Not verified | The plugin was never run on these builds, including 0.1.5-rc.3 — the same 0.1.5 line, published after 0.1.5-rc.2. If such a build changed a contract, the plugin does not fail silently: it names the problem in the console (see below), and its hiding is fail-closed, so the worst case is the cosmetic "no hiding". If you want to try one, use a conversation you can throw away |
| 0.0.1-rc.1/rc.2/rc.5, 0.1.0-rc.2/rc.3/rc.6/rc.7/rc.8 (below 0.1.1) | Not supported | Below 0.1.1 does not have the contracts those fallbacks cover, and falls below the lower bound of the compatibility range (dsh.engines.dsh and peerDependencies are both >=0.1.5-rc.2 …) |
| 0.2.0-rc.1 and earlier 0.2.0 prereleases, and any 0.2.0-or-later build not yet published | Not verified | Only 0.2.0-rc.2 has been tested. The 0.2.0 line is still in prerelease, so its contracts may change again; the same self-protection applies (it names the problem in the console, hiding is fail-closed, so the worst case is the cosmetic "no hiding"). If you want to try one, use a conversation you can throw away |
- On DSH 0.1.5-rc.2 install 0.3.x. Do not put 0.1.0–0.2.2 on 0.1.5: that generation breaks session loading there — its client half listed the
conversationEventsservice among its required injects, and 0.1.5 no longer provides it, so the half stays pending forever (the install looks like it did nothing); its host half threw from inside thesession/createdobserver while the framework resumed a session (0.1.5 no longer hands outsession.events), so sessions fail to open and the transcript renders empty. - On DSH 0.1.1-rc.2, 0.1.0–0.2.2 is the generation that was built and verified against it, so 0.2.x is the best-tested choice; 0.3.0 is compatible by design through the fallbacks listed above, but was not exercised on a 0.1.1 build.
When the framework contract is not the one the plugin expects, it reports loudly instead of failing silently: the browser console prints [rollback] framework contract mismatch: … or [rollback] hiding disabled for this pass: …. Its hiding logic is fail-closed — if the chat shape cannot be read it hides nothing and hands back anything it had hidden, so a future framework change can at worst degrade to "no hiding" (a cosmetic effect only) and can never blank the transcript. The host half keeps every observer it registers inside its own try/catch, so a plugin failure never breaks the host's own session loading.
dsh.engines.dsh in package.json is >=0.1.5-rc.2 <0.2.0-0 || >=0.2.0-rc.2 <0.3.0-0. DSH does not read that field — the compatibility gate that actually runs lives in peerDependencies (below) — so this is metadata for humans and tooling (the marketplace badge), kept as the same expression as the gate.
The compatibility gate (inside dsh-app-boot) checks only peerDependencies entries named @deepseek-ai/dsh or @deepseek-ai/dsh-*, comparing semver.satisfies(runtimeVersion, range, { includePrerelease: true }) against the running runtime's version. Declare none and it checks nothing at all. This plugin therefore declares @deepseek-ai/dsh, @deepseek-ai/dsh-session and @deepseek-ai/dsh-invariants (the last two mainly to name the host interfaces we are actually coupled to; the comparison is still against the runtime version), all with that same range.
Both halves of the range are load-bearing:
- Two clauses, not one open-ended lower bound. The single clause
>=0.1.5-rc.2does not include0.2.0-rc.2under default semver rules (no prerelease comparator for the samemajor.minor.patch) — which is exactly why same-category plugins in the ecosystem write OR chains. This plugin supports both the 0.1.5 and the 0.2.0 lines (the old-contract fallback branches exist for that), so each line gets a clause. - The ceiling is
<0.2.0-0/<0.3.0-0, not<0.2.0/<0.3.0. The gate enablesincludePrerelease, and a prerelease sorts below its own release — so<0.3.0would let0.3.0-rc.1through.<0.3.0-0(a numeric identifier sorts below any alphanumeric one) is the correct way to exclude the whole next line. The point of this gate is to prevent "running on a format it has never seen, causing crashes or data loss", so the next line has to be stopped and re-validated rather than waved through.
That makes the matrix above the authoritative statement: the range decides what the gate admits, the matrix records what has actually been run.
Features
| Capability | Description |
|---|---|
| Per-turn checkpoints | A checkpoint is captured before each turn, recording only the files actually touched (Copy-before-Write prior content), not a full snapshot |
| 10-turn sliding window | Mirrors TRAE's "last 10 turns only"; checkpoints beyond the window are dropped |
| File rollback | Modified files are written back to their pre-turn content; files deleted since are brought back; files created this turn are deleted; unrestorable files are reported as skipped |
| In-place truncation | Rewrites the model context with a user/message surface replace — the same primitive the built-in /compact uses — taking effect the moment the rollback runs, keeping the same session id |
| Two entry points | The /rollback human command, and a Web rollback button after every turn (on the reply's action strip for a normal turn, in the turn footer for an interrupted one) |
| Affected-file list | The Web button opens a dialog listing the files affected by this and later turns and their actions (restore/recover/delete/skip). It is a read-out, not a control: the rows are not clickable (earlier versions opened the editor from here, which nobody used) — open a file from the sidebar's file tree instead |
| No rollback while running | Any open turn refuses a rollback outright; wait for the turn to end or pause it (a running turn has no button to press anyway) |
Getting started
Install
dsh plugin --profile web add @domitor-syh/dsh-rollback
Then restart dsh web. When running DSH from source:
pnpm dsh plugin --profile web add @domitor-syh/dsh-rollback
Usage
- Web button: a ↩ rollback button appears in the action strip under each finalized assistant reply, alongside the feedback buttons → opens the affected-files list → confirm to roll back.
- Human command: type
/in the composer and pick 回退 (orrollbackin an English interface) → the framework's own turn picker opens:Roll back the previous turnplus one numeric entry per rollback-able turn. Choosing runs it; nothing is inserted into the composer.- Two entry points:
/rollbackwith no argument opens the turn picker → the confirmation dialog → rollback;/rollback <turn>rolls back immediately, with no confirmation dialog.
- Two entry points:
- The difference comes from the framework: the dialog lives in the client, and an argument-bearing command line never reaches the client — contributions and decorations are consulted only for a bare token, and a host command declaring input hands its arguments straight to the host. Pick the argument-free form when you want to confirm.
- Read-only helpers:
/rollback listlists the turns, and/rollback preview <n>previews the files a rollback to before turn n would touch (without executing).
- No model tool: a model-invoked rollback would always happen while a turn is running, and a running turn refuses every rollback — so the tool cannot exist. A rollback is always started by a human.
Interface preview
Rollback button: appears after every turn — in the reply's action strip for a normal turn (next to the feedback buttons), and in the turn footer for an interrupted one (that turn has no closing reply, so the strip cannot carry a button). While a turn is running the button stays and greys out.

Rollback dialog & file-change notice: clicking ↩ opens a confirmation dialog listing each affected file and its action — modified files are written back (
restore), deleted files are brought back (recover), files created this turn are deleted (delete), and unrestorable ones are flagged "skip".
Rolled-back messages hidden: after confirming, the rolled-back messages are hidden immediately (no divider is rendered in the UI); the rolled-back turn's text/images return to the composer for further editing.
Rolling back the first message: when rolling back to before the first message, the chat shows a "rolled back to the start of the conversation" welcome page.

Architecture
| File | Responsibility |
|---|---|
src/core/ |
Pure logic (no DSH dependencies): checkpoint model, capture/merge, rollback planning, truncation planning, sliding window, session folding — all unit-test-covered |
src/service.ts |
Host-side execution: captures pre-write content via tools/result + folds turns via session/event; performs restore/delete/truncate |
src/index.ts |
Plugin body (host half): registers the rollback tool and the /rollback command |
src/client/index.ts |
Browser half: the rollback button on the official assistant-actions slot + affected-files dialog + localization, reaching the host through the shipped commands Remote |
Key implementation points:
- Pre-content capture:
write/editresults already carrybefore/after, read throughctx.on('tools/result');str_replace_editorreturns only rendered text, so its target is read ahead of the call intools/pre-execute. - Drive-root fallback: on Windows the file tools cannot touch a file directly under a drive root — the filesystem layer pre-creates the parent directory,
dirname('E:\\file.txt')isE:\**with its trailing separator**, and Windows answers a mkdir on a volume root with EPERM. The plugin wrapsctx.fs.writeTextandctx.fs.editText: **only when the original path throws exactly that error shape** does it land the bytes as a sibling temp file plus arename, with no mkdir preflight. The edit branch also reproduces the provider's literal-match semantics word for word (theFS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDITdecisions and messages) and keeps the original file's line-ending style and permission bits; only methods the mounted backend actually implements are wrapped. Every other error, and any target the policy does not permit (fail closed), is rethrown untouched. If the filesystem layer stops pre-creating the directory, this branch becomes unreachable and retires itself. - Empty-directory cleanup: after a rollback deletes the files it created, the ancestor directories that are now empty AND whose creation time falls inside the rolled-back span are removed too (deepest first; children this same pass is about to remove count as already gone, so a whole new directory chain goes together). The creation time is what separates two cases: with a directory made in turn 3 and a file made in turn 5, rolling back to before turn 5 deletes the file and keeps the directory, while rolling back to before turn 3 removes both. An unknown creation time, an unreadable directory, or any remaining content keeps the directory (fail closed).
- Boundary re-scan with last known content: capture only ever sees the file tools, so the plugin re-checks the paths it has watched when each turn ends (and again when a user message arrives) — a stat-only fast path while the fingerprint is unchanged — and records what a shell command rewrote or removed against that turn. A deletion is therefore already recorded the moment the turn that made it ends, with no further message needed, and rolling back to before that turn restores the file (a rollback waits for a scan still in flight, so asking the instant the turn ends cannot miss it). The decisions fail closed: a file that vanished before the plugin ever read it is not recorded (recording it would surface only as an unrestorable path, and any unrestorable file aborts the whole rollback) but warned about once; a file above 8 MiB stops being watched instead of pretending to be restorable; and after a rollback the registry drops only what it learned about the contents, keeping the paths watched so coverage is not quietly lost.
- Write-preference hint: one always-on runtime context line tells the model that only files touched by
write/editare tracked for rollback, so file contents should be changed with those tools rather than a shell command. - In-place truncation: for the consecutive nodes in
session.surface.nodesfrom turn n onward, auser/messagesurfacereplace(surfaceOp: { op:'replace', start, end }+sourceEventSeqscovering every shadowed node) is appended, replacing that span of history in place; the session id is unchanged.- The marker enters the log the moment
/rollbackruns, so the rolled-back range leaves the model's history immediately. - Its content is an automatically generated checkpoint notice that tells the model not to acknowledge it; rolling back to the same point again lets the newer marker's range cover the older one, leaving a single marker.
- The marker enters the log the moment
- UI hiding: the client hides the chat seats inside the rolled-back range (
display: none), driven by the durable marker in the log, so the hiding survives a refresh or restart. - Four rollback tags:
restore(the file is still there; old content goes back · green),recover(the file was deleted; it is brought back · blue),delete(undo a file this span created · red), andskip(cannot be restored). The first three are distinguished by the recorded kind (updated/removed/created), so writing content back and bringing a file back are two different things in the data itself. An empty list no longer claims there were no file changes: the plugin cannot see files a shell command wrote directly, so it does not make that promise. - Two placements, one button per turn: a normal turn keeps its button on the assistant action strip, where it has always been; only an interrupted turn (no closing reply, so the strip cannot carry a button) gets one in the turn footer. The footer entry is a contribution to the official
conversation.chat.turnTailslot, which is a chain slot: an entry MUST supplyselect, and returning null means "do not render" — which is exactly how "only where the strip cannot" is implemented. The registration is guarded and reports itself in the console, so a silently missing button cannot happen again. - No rollback while a turn runs: an open turn refuses a rollback entirely (
src/core/rollback-guard.ts), whatever the target turn. The agent loop holds a position in the model-visible surface and keeps appending, so truncating underneath it would shadow history the turn is still writing while its later output stays; a command does not interrupt the run either, so the refusal itself is what keeps the two from interleaving. A running turn simply has NO button (the footer node only exists once its turn ends, and the action strip only once a reply finalizes), so there is one rule here and no second disabling mechanism. - Welcome hero: once a rollback has emptied the whole conversation, the driver injects a host element into the transcript and portals the hero into it.
- Client transport: reuses the shipped
ctx.remote.commands.executeto call/rollback …. - Regression tests: 14 files / 206 cases —
tests/core.test.ts(35),tests/truncation-plan.test.ts(29),tests/log-replay.test.ts(18),tests/dir-cleanup.test.ts(15),tests/root-write-fallback.test.ts(15),tests/rollback-guard.test.ts(14),tests/root-write.test.ts(14),tests/boundary-scan.test.ts(13),tests/boundary-rescan.test.ts(13),tests/literal-edit.test.ts(12),tests/turn-entry.test.ts(11),tests/sidecar-replay.test.ts(7),tests/boundary-pipeline.test.ts(5), andtests/empty-dirs.test.ts(5).
Known limitations
- An unrestorable file is SKIPPED, not a reason to abort the whole rollback: when a file cannot be put back (its pre-turn content was never recorded, or the filesystem refused — locked, no permission, a sandbox that disallows restoring outside the workspace), the plugin does what it can, truncates the conversation anyway, and lists what it skipped. That is a deliberate trade: aborting everything left files partly restored, the conversation untruncated, and the blocking cause (often permanent) failing identically on every retry — stranding the user. The skipped entries are marked 跳过 in the preview BEFORE confirming, so the cost is known, and their records are KEPT so a later rollback tries again (a released file lock then just works).
- A target beyond the retained range is refused: checkpoints are a 10-turn sliding window and older state cannot be rebuilt. In the UI those entries are greyed out (the greying IS the explanation — no extra tooltip), and the command reports the available range — previously such a target silently restored from the oldest retained records (the wrong state, with nothing said).
- The chat trail still keeps rolled-back messages: DSH renders that trail from append-origin events, and the log itself is append-only, so a surface
replaceonly affects the model context. The plugin hides the rolled-back range in the UI, driven by the durable marker in the log (preserved across refresh/restart). - The model reads one line of checkpoint text: a marker the model cannot see cannot be written between turns (that needs an open step), so the model reads the checkpoint notice — roughly 34 tokens (136 characters, down from 331 / ~83). The notice stays in the model's context for the rest of the session (only a later rollback replaces it), so it is paid on every subsequent request — which is why the wording is as terse as it can be while still carrying four facts: it is machine-generated rather than something the user said; the messages after it were removed; the workspace files were reverted with them; and the model should continue from what remains without minding or mentioning the checkpoint. A character-budget assertion in
tests/truncation-plan.test.tskeeps it from growing back. - Checkpoints are process-in-memory plus a 20-turn sidecar: the fold state lives with the session object in memory (
WeakMap); after a restart it is rebuilt from the sidecar (storages/dsh-rollback/checkpoints-v2/), which keeps the most recent 20 turns (KEEP_TURNS) and prunes older records at load. - Created-file deletion and empty-directory cleanup go through the local filesystem: the filesystem abstraction has no delete primitive; deletion uses
processPath+ Nodeunlink, and the directory cleanup uses Nodermdir, both reliable only for the local backend. - Path-bearing attachments (reference chips) do not come back to the input box after a rollback: text and images are restored, a reference chip is not — the framework models the input box as
{ draft, occurrences, attachmentIds }but exposes only the registration actions for the last two to plugins, while a chip can only arrive through the reference an@mention protocol returns; there is no plugin interface that inserts one. The most mature plugin of this kind,dsh-recall-plugin, cannot do it either (all of its reference-related hits are 0). So only text and images are restored here, and the path is deliberately not stuffed into the input box as plain text (that would change what you are about to send). - A file attached with
@pathcomes back as that text alone: the measured form of the limitation above. An@pathmention is onetextblock in the message (measured:blocks: {text: 1}, carrying something like@C:\dir\file.dll what is this), and the framework never turns it into an uploaded attachment — so the plugin can neither see an attachment nor rebuild a chip, and what returns is the original@pathtext. It looks like "the attachment did not come back", but the cause is different: there never was an attachment. To tell them apart, the console prints[rollback] rolled-back turn message: { turn, blocks, restorable, beforeRead }after a rollback — nofile/imageinblocksmeans that turn's message carried no attachment. - Stated plainly: there is NO official interface that puts a FILE back into the input box. The only thing this plugin can rebuild is an image (read the bytes with
uiConversation.imageUrl, register a draft withconversation.createDrafts, attach it withaddAttachments— a path measured working). For a file, the official surface offers the samecreateDrafts(which kicks off an upload); the plugin already does exactly that and now retries the append — but whenever the framework did not keep that file as a durable attachment (e.g. the composer "chip" was really an@pathreference), nothing can turn it back into a chip. That is not something missing from this plugin: the route does not exist in the interface a plugin is given. The peer plugindsh-recall-pluginlikewise attempts it and then falls back to a notice (its copy says the official API "only reads images back"), and has no other trick either. - Rollback is irreversible: it replaces history in place and offers no redo.
- Files a shell command creates, and files no file tool ever touched, are out of scope: the plugin captures changes only from
write/editresults and re-checks those paths at message boundaries — so a shell command that rewrites or deletes a tracked file (anywhere on disk, including outside the workspace) can be rolled back, while a brand-new file written by a shell command, or a change to a file no tool ever touched, cannot (the plugin does not even know the latter existed). The plugin injects a hint steering content changes to the file tools, but it cannot enforce that.
Development
pnpm install # install deps (prepare also builds once)
pnpm build # emit lib/index.js, lib/invariant.js, lib/client.js from src/
pnpm test # run the dependency-free core unit tests
pnpm typecheck # tsc over core + tests, then scripts/typecheck-host.mjs over the
# five DSH-facing host and client files (src/dsh-types.ts,
# src/service.ts, src/index.ts, src/invariant.ts,
# src/client/index.ts) — nothing silenced
pnpm check:readmes # the two READMEs must agree on structure and on every
# language-neutral fact
pnpm deploy:profile # pack and deploy into the DSH profile (default "web");
# restarting DSH afterwards is still up to you
️ Editing code requires
pnpm deploy:profile: DSH does not load this plugin from this repository — it loads the copy a package manager extracted into the profile'snode_modulesfrom thefile:tarball (see the header ofscripts/deploy-profile.mjs). Runningpnpm buildalone only changes this repository'slib/, so the runtime is unchanged. The script packs, refreshes both the referenced tarball and the extracted copy, verifies they match the build, and then reminds you of the one step it cannot take: restarting DSH, because the host half is loaded at startup and a running process keeps the code it booted with.
License
Comments
Comments live in GitHub Discussions. Sign in with GitHub to post or react.