dsh-rollback

September 11, 2026 · View on GitHub

npm version License: MIT

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 dsh CLI on the machine (dsh plugin shells out to pnpm, so pnpm must be on PATH).
  • A profile to install into — demo below is initialized on first use; use any name.
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

  1. The profile manifest under $DSH_HOME/profiles/demo/ ($DSH_HOME defaults to ~/.dsh) now lists dsh-rollback in dependencies and in dsh.profile.bundles — the reconciler adds it automatically because the package declares dsh.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
  1. Start a session. Any of these proves the plugin is live:
    • the model sees a rollback_files tool in its tool list,
    • typing /rollback answers rollback: nothing to restore (instead of an unknown-command error) before any mutation happened.

Usage

30-second quickstart

  1. Open a session whose working directory is a git repository.
  2. write a file notes.md with the content hello.
  3. write it again with goodbye — the first content is silently checkpointed.
  4. Tell the model "restore the file you just overwrote" (it calls rollback_files), or type /rollback yourself.
  5. Read notes.md: it says hello again.

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:

StepActionnotes.md
1original statehello
2model writes a broken edit — pre-image capturedgoodbye
3model calls rollback_files {"count": 1}(transparent)
4restored, byte-for-bytehello

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

KeyTypeDefaultMeaning
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.
storeDirstring''Root holding the durable manifest.jsonl and snapshots/. Empty resolves to rollback under the Harness home.
maxRecordsnumber200Upper bound on in-memory records per store; the oldest are dropped beyond it (the durable manifest keeps everything).
gitPathstring'git'Git executable name or absolute path.
mutationToolsstring[]['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.
notifyModelbooleantrueInject 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_files tool — model-facing restore, parameter count (integer, default 1). Registered on ctx.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 (base web and headless profiles mount dsh-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_editor and raw subprocesses are not checkpointedbash/run_code file changes are captured by forward snapshot, but only inside a git repository (a non-repository workspace skips the snapshot with a warning), and a bash call 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 — a bash/run_code pre-image is retained as raw bytes (--no-filters), so binary files and CRLF files round-trip exactly. The write/edit path still carries the tool's own before string, 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 a bash/run_code call and cannot be rolled back. A file git already tracks is always captured, even when .gitignore matches 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 + size cache. 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, no bash/run_code mutation 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 /rollback or rollback_files in 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 next write/edit of that path as stale and forces a re-read. Profiles without that policy get the notice only. notifyModel: false removes the notice too.
  • Append-only manifest, no pruning — restored records remain in manifest.jsonl and 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 to maxRecords, so after a restart only the newest maxRecords records 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 failurerestoreOnFailure is 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