dsh-rollback
Manifest validObservational file-mutation rollback for the fs tools: write/edit pre-images are checkpointed as git blobs or snapshots and restored through a model-facing rollback_files tool or a /rollback command.
dsh-rollback
English | 中文
File-mutation rollback plugin for DeepSeek Harness: it checkpoints the pre-image a write/edit mutation reports in its result, forward-snapshots the workspace around black-box bash/run_code calls so the files they change are diffable, retains every pre-image in the workspace git object database or a snapshot store, and exposes restore through a model-facing rollback_files tool and a /rollback human command. It registers no service and changes no loop code — capture rides the documented tools/* extension points (the tools/result observation event and the tools/execute around hook), and restore writes files directly (never through the fs policy seam or the sandbox, because undoing a mutation must not be gated by the policy the mutation passed).
Install
The package is an installable bundle (declares dsh.bundle), so it plugs into a profile without touching the harness. All you need is a dsh CLI; the runtime peer packages (@deepseek-ai/dsh-tools, @deepseek-ai/cordis, ...) resolve from the dsh installation itself, so nothing else is installed.
Prerequisites
- A
dshCLI on the machine (dsh pluginshells out topnpm, sopnpmmust be onPATH). - A profile to install into —
demobelow is initialized on first use; use any name.
Install from npm (recommended)
dsh plugin --profile demo add dsh-rollback
Other sources work the same way:
# straight from git (sources are built by the prepare script; pin a commit)
dsh plugin --profile demo add github:you/dsh-rollback#<sha>
# or from a local tarball
dsh plugin --profile demo add ./dsh-rollback-<version>.tgz
Verify the install
- The profile manifest under
$DSH_HOME/profiles/demo/($DSH_HOMEdefaults to~/.dsh) now listsdsh-rollbackindependenciesand indsh.profile.bundles— the reconciler adds it automatically because the package declaresdsh.bundle. The equivalent manual patch layer row:
- id: rollback
name: dsh-rollback
config:
mode: auto # auto | git | snapshot
storeDir: '' # '' = <harness home>/rollback
maxRecords: 200
gitPath: git
- Start a session. Any of these proves the plugin is live:
- the model sees a
rollback_filestool in its tool list, - typing
/rollbackanswersrollback: nothing to restore(instead of an unknown-command error) before any mutation happened.
- the model sees a
Usage
30-second quickstart
- Open a session whose working directory is a git repository.
writea filenotes.mdwith the contenthello.writeit again withgoodbye— the first content is silently checkpointed.- Tell the model "restore the file you just overwrote" (it calls
rollback_files), or type/rollbackyourself. - Read
notes.md: it sayshelloagain.
For humans — /rollback [count]
Type it in the chat input (base web and headless profiles mount the command registry the command needs):
/rollback— undo the single most recent captured mutation in this session's working directory,/rollback 3— undo the three most recent ones.
The output lists every restored file and its action:
rollback: restored 2 file mutation(s):
restored /ws/src/lib/parse.ts
deleted /ws/src/lib/generated.ts
A manual rollback also tells the model. A command's lifecycle (command/run/command/done) is log-only and never enters model context, so the plugin queues a model-facing notice for the next pre-step (Agent.inject), naming every path it rewrote or deleted and stating that the model's in-context copies are stale. Without it, a human rollback would rewrite files behind the model's back while its transcript kept describing the pre-rollback world — and it would keep working against paths that no longer exist. Set notifyModel: false to keep manual rollbacks silent.
For models — rollback_files
One model-facing tool, rollback_files {count} (no prompt section). It is meant for the model to undo its own write/edit mistakes instead of asking the user. Restores are scoped to the calling session's working directory and reported as a per-file summary — restored file contents are not echoed.
What is captured
Two capture paths feed the same store: successful write/edit tool results that carry a before pre-image, and file changes made by root bash/run_code calls (forward-snapshotted in git repositories, then diffed — see Behavior). Mutations through str_replace_editor, raw subprocesses, or a bash call outside a git repository carry no recoverable pre-image and are not captured. A session can only restore records at or under its own working directory.
Demo: before and after
One write overwrite, undone. Same file, four states:
| Step | Action | notes.md |
|---|---|---|
| 1 | original state | hello |
| 2 | model writes a broken edit — pre-image captured | goodbye |
| 3 | model calls rollback_files {"count": 1} | (transparent) |
| 4 | restored, byte-for-byte | hello |
The full transcript of that turn:
# 1. original
$ cat /ws/notes.md
hello
# 2. the model overwrites it; tools/result carries the pre-image "hello",
# and the capture listener checkpoints it into the git object database
> tool/call write {"path": "/ws/notes.md", "content": "goodbye"}
> tool/result {"path": "/ws/notes.md", "before": "hello", ...}
# 3. the model realizes the mistake and undoes it
> tool/call rollback_files {"count": 1}
> tool/result "rollback: restored 1 file mutation(s):
restored /ws/notes.md"
# 4. back to the pre-mutation content
$ cat /ws/notes.md
hello
Under the hood: git hash-object -w wrote the pre-image into git's object database (zero index/branch/working-tree pollution), and one line was appended to the durable manifest.jsonl — the same undo works after a restart.
How it works
flowchart TD
A["write/edit 工具结果"] --> B["tools/result 观察事件"]
B --> C{结果带 before 改前映像?}
C -- 否 --> X[忽略]
C -- 是 --> S[CheckpointStore 捕获]
A2["bash / run_code 调用"] --> B2["tools/execute 前向快照 + diff"]
B2 --> C2{工作区是 git 仓库?}
C2 -- 否 --> X2[跳过并告警]
C2 -- 是 --> S
S --> G{工作区是 git 仓库?}
G -- 是 --> BLOB["git hash-object -w 存 blob"]
G -- 否 --> P["写 storeDir/snapshots/ 快照"]
S --> MF["追加 manifest.jsonl"]
U["模型调 rollback_files / 用户 /rollback"] --> RS["restore 按 session.cwd 作用域"]
RS --> RR["git cat-file / 快照 / 删除文件"]
The diagram above is a full worked example with a before/after transcript — see Demo.
Plugin (namespace: rollback)
A function/namespace plugin (name / inject / Config / apply), not a service. It is a loop-hygiene guard in the same family as dsh-tool-call-timeout-policy: it layers a safety net over the documented tools/* extension points instead of touching the agent loop.
Config
| Key | Type | Default | Meaning |
|---|---|---|---|
mode | 'auto' | 'git' | 'snapshot' | 'auto' | git checkpoints every pre-image as a git blob (requires a repository; a non-repository path fails loud and captures nothing); snapshot always copies pre-images under storeDir/snapshots/; auto picks git per file when the workspace is a repository and snapshots otherwise. |
storeDir | string | '' | Root holding the durable manifest.jsonl and snapshots/. Empty resolves to rollback under the Harness home. |
maxRecords | number | 200 | Upper bound on in-memory records per store; the oldest are dropped beyond it (the durable manifest keeps everything). |
gitPath | string | 'git' | Git executable name or absolute path. |
mutationTools | string[] | ['bash', 'run_code'] | Tool names that get a forward snapshot — black-box mutation calls without a before pre-image. Only root dispatches are snapshotted (nested Code Mode sub-calls are covered by their run_code parent) and only inside a git repository. |
notifyModel | boolean | true | Inject a model-facing notice after a HUMAN /rollback, so the model learns that files changed on disk outside its control. The model's own rollback_files call needs no notice (its call and result are already in the transcript). |
Behavior
Capture. A tools/result listener converts a successful write/edit outcome into a checkpoint: the outcome's before field is the pre-mutation content (null records a file that did not exist). blob pre-images are written with git hash-object -w --stdin inside the file's repository (discovered by walking up to .git, cached per directory) — zero index/branch/working-tree pollution, content-addressed and deduplicated by git itself. Every record is appended as one JSONL line to manifest.jsonl; a store replay on plugin load restores the in-memory list, so restores survive restarts. Only absolute local display paths are captured; relative or remote display paths (non-local filesystem backends) are ignored.
Forward snapshot (bash / run_code). A tools/execute around hook snapshots the workspace before a root bash (or run_code) call runs. Git enumerates the candidate set in one invocation (git ls-files -co --exclude-standard): tracked files plus untracked-but-not-ignored ones, so the ignore rules decide what the model owns instead of a hand-rolled walk, and build outputs and vendored trees cost nothing. .git and node_modules are never snapshotted (and therefore never restored), and the plugin's own store directory is excluded. Every candidate is then retained as a raw git blob in one batched invocation (git hash-object -w --no-filters --stdin-paths): --no-filters keeps the pre-image byte-exact (without it git applies CRLF/attribute conversion, and a restore would rewrite line endings instead of reproducing the file), and the batch replaces one process per file with one process per snapshot. A mtime + size fast path skips files unchanged since the last snapshot. When the call settles the candidate set is re-enumerated and diffed, and every changed file is checkpointed — modified or deleted files keep the pre-mutation blob, created files an absent record. The snapshot is scoped to the session working directory and requires a git repository; otherwise it is skipped with a warning. Nested Code Mode sub-calls are covered by their run_code parent, so a mutation is never checkpointed twice.
Restore. restore(count, under) re-materializes the count most-recent records whose path lies at or under under (the calling agent's session working directory): blob via git cat-file blob <hash>, snapshot from storeDir/snapshots/<ref>, and absent by deleting the file. Writes are atomic (temp file + rename) and create parent directories. Restored records are removed from the in-memory list; the manifest stays append-only, so a restart replays the same records and a later restore re-applies the identical pre-image (idempotent, no double-undo).
Exposure.
rollback_filestool — model-facing restore, parametercount(integer, default 1). Registered onctx.tools; not concurrency-safe. It refuses when the calling execution has no session working directory./rollback [count]command — the same restore for the receiving agent's session; the command child activates only when a command registry is composed (basewebandheadlessprofiles mountdsh-commands). After a non-empty restore it injects one model-facing notice (see Model Experience); an agent without an inbox, or a rejected injection, leaves the command's own result untouched.
Why git, and why direct spawn
Git blobs are the same mechanism ccAgent uses: hash-object -w writes the pre-image without touching index, refs, or the working tree; cat-file restores bytes verbatim; unreferenced blobs are reclaimed by git's own gc. Git is spawned directly via node:child_process (never through ctx.shell or ctx.subprocess): a restore is a deliberate system-level undo, so it must not be confined by the sandbox or shell policy it is undoing.
Model Experience
Model-facing restore tool
What the model sees
This plugin adds one model-facing tool, rollback_files (integer parameter count, string output), and no prompt section. It changes no other tool's schema or system prompt.
The /rollback command line itself never reaches the model — a command's lifecycle is log-only — but a completed human rollback does: the plugin injects one plugin-sourced notice message naming every path it restored or deleted, plus the standing instruction that the model's copies of those paths are stale and must be re-read. Injected context is queued for the next pre-step without waking the driver, so an idle session sees it attached to whatever request comes next.
Token effect
Zero tokens on normal operation. A rollback_files call adds its small tool/result pair, and a human /rollback adds one short notice (a header line, one line per path, one instruction line; file contents are never echoed). Capture itself is invisible to the model.
KV Cache effect
Append-only; the added tool schema, results, and notices follow the reusable request prefix and do not invalidate existing KV-cache entries.
Known Limitations and Deferred Work
str_replace_editorand raw subprocesses are not checkpointed —bash/run_codefile changes are captured by forward snapshot, but only inside a git repository (a non-repository workspace skips the snapshot with a warning), and abashcall is snapshotted at the session working directory only, so files it changes elsewhere are not captured. A plan-scoped batch backup (capture every file a plan touches before execution) is the corresponding generalization, deferred.- Byte-exact for the forward snapshot; text-only for
write/edit— abash/run_codepre-image is retained as raw bytes (--no-filters), so binary files and CRLF files round-trip exactly. Thewrite/editpath still carries the tool's ownbeforestring, which is text by the fs tools' contract. - Ignored paths are never captured — the forward snapshot's candidate set comes from git's ignore rules, so a path that is both untracked and excluded (
dist/,build/,coverage/, ...) is not checkpointed by abash/run_codecall and cannot be rolled back. A file git already tracks is always captured, even when.gitignorematches it. - A snapshot of a large repository pays an O(files) first pass — the forward snapshot stores one git blob per candidate file, so the first snapshot of a repository in a session is dominated by git's loose-object writes. Batching removed the one-process-per-file cost (a fixed number of git invocations per snapshot), but the bytes are still read and stored once per file; later snapshots in the same session reuse the
mtime + sizecache. How expensive that first pass is varies with the filesystem and with antivirus scanning of.git/objects. - One path the batched hash cannot address disables the snapshot — paths reach git newline-delimited (
--stdin-paths), so a filename containing a newline (legal on POSIX; NTFS rejects it) makes the batch fail, and a failed batch aborts the snapshot instead of skipping that file: for as long as such a path exists in the workspace, nobash/run_codemutation in that repository is captured, and every call reports a warning. - Restores are workspace-scoped — records outside the calling agent's session working directory are never restored by that caller; there is no cross-directory or global restore entry point.
- A restore overwrites without a staleness check — the pre-image is written unconditionally. If the file changed again after the mutation was captured (a human edit, another agent, a later command), restoring silently discards those newer contents: there is no conflict detection, and the replaced state is not backed up.
- Sessions sharing a working directory can undo each other — scope is the directory, not the session. A record carries no session identity, so
/rollbackorrollback_filesin one session also selects mutations made by any other session with the same working directory (a subagent, a second tab). - The manual-rollback notice is advisory — it informs the model, it does not constrain it. What actually stops a model from writing over a rollback is the harness itself: the restore replaces the file (new inode/mtime/ctime), so
fs-observation-policy's version guard rejects the model's nextwrite/editof that path as stale and forces a re-read. Profiles without that policy get the notice only.notifyModel: falseremoves the notice too. - Append-only manifest, no pruning — restored records remain in
manifest.jsonland re-appear on replay (idempotent re-restore, never a double-undo), but a long-lived harness home grows without compaction. Replay also trims the in-memory list tomaxRecords, so after a restart only the newestmaxRecordsrecords are restorable even though the file keeps every one of them. - Concurrent stores collide on
seq— the sequence number is per-process (replayed to the manifest's tail, then incremented). Two harness processes sharing one store directory write duplicate sequence numbers, which breaks the strictly-increasing invariant the companion plugin asserts and blurs which records a bounded restore drops. - Capture durability is best-effort — the manifest append is neither fsynced nor awaited (capture rides the observation event fire-and-forget), so a crash between a mutation and the flush can lose the newest record(s), reported only as a warning.
- Git gc can prune long-lived blobs — default gc reclaims unreferenced objects after a retention window; checkpoints older than that window may fail to restore. A keep-alive ref namespace is deferred.
- No automatic restore on failure —
restoreOnFailureis deliberately not offered in v1; auto-restore would need to attribute failure to specific mutations first.
Development
pnpm install # peer packages resolve from npm releases
pnpm run build # tsdown -> lib/ (ESM + d.mts), self-contained
pnpm test # vitest, 23 store-level tests + 7 notice tests
License
MIT
Comments
Loading…
Similar plugins
by ruaibeite
Turn-level file undo for DeepSeek Harness: every write and edit is snapshotted before it lands, and the model can call the undo tool to roll back all files a conversation turn changed.
★ 0
MIT
JavaScript
Oct 23, 2026
dsh plugin --profile web add @ruaibeite/dsh-undoby zoyluoblue
dsh plugin: snapshot your workspace every turn and restore the files to any earlier point. A rewind/undo for DeepSeek Harness coding sessions — your own git repo is never touched.
★ 0
MIT
TypeScript
Aug 19, 2026
dsh plugin --profile web add @zoytown/dsh-rewindby caishengshuo
Every file, one version before it breaks. A DSH plugin: snapshots a file the instant before an agent rewrites it, with a per-session GUI timeline where any point can be restored in one click.
★ 0
MIT
JavaScript
Oct 24, 2026
dsh plugin --profile web add dsh-beforewriteby ruaibeite
Records the version of the edit target as an observation when the agent inspected the file with bash instead of the read tool, so the filesystem observation policy stops rejecting edits with FS_NOT_OB
★ 1
MIT
JavaScript
Oct 13, 2026
dsh plugin --profile web add @ruaibeite/dsh-auto-observeby caihua8669
Document review workbench: select text in a Markdown file, leave comments saved as MRSF sidecar files next to it, and let the agent read them with doc_review_* tools and propose edits you accept or re
★ 1
MIT
JavaScript
Oct 17, 2026
dsh plugin --profile web add @caihua/dsh-doc-reviewby roushanyyzz
让 AI 在对话中每修改一个文件,你就立刻看到它改了什么。 DSH(DeepSeek Harness)插件:拦截 write / edit 工具调用,在对话流的工具卡片位置直接渲染 git 风格文件差异——支持上下对比与左右对比,逐行高亮
★ 0
MIT
TypeScript
Aug 20, 2026
dsh plugin --profile web add dsh-diff-vis