dsh-filesnap
dsh-filesnap — 把对话和它改过的文件一起回退到某一轮之前,不需要 git 仓库 · Rewind for DeepSeek Harness: the conversation and the files it changed, no repository required
- Stars
- 0
- Language
- TypeScript
- Created
- Aug 27, 2026
- Updated
- Aug 27, 2026
Introduction
dsh-filesnap
English | 中文
Rewind and redo for DeepSeek Harness.
Go back to the start of an earlier turn — the conversation and the files it
changed — in a project that has never seen git init, and in one that has.
Your commits, your stash, your worktree state: untouched. Nothing is stored
inside your repository.
> /rewind
Rewind points — the workspace as it stood before each turn:
turn when opened by
1 2026-08-27 09:12:04 add a rate limiter to the upload endpoint
2 2026-08-27 09:18:41 actually make it per-tenant
3 2026-08-27 09:31:10 now cache the tenant lookup
Rewind with /rewind <turn>. The conversation forks at that point and the files go back with it.
> /rewind 2
Rewound to turn 2 (actually make it per-tenant).
Files: 7 written, 1 deleted.
The conversation continues in session-9f3c1a04-….
Run /redo there to reverse this rewind.
The engine is filesnap
The load-bearing logic is not in this repository. Snapshotting and restoring is filesnap — a content-addressed store, written in Rust, that puts a directory back the way it was at an earlier moment. The bounded scan, the content addressing, the atomic restore and the store format all live there, and that is the repository to read if you want to know how the files actually move.
It ships as one 4 MB static binary with no runtime to install, and it runs once per turn in front of a model request that takes seconds:
| files captured | first capture | every capture after | |
|---|---|---|---|
| this repository | 84 | 20 ms | 8 ms |
| the harness monorepo | 7,995 of 70,918 on disk | 1.75 s | 268 ms |
The second column is the bounded scan: a snapshot covers what a turn can plausibly touch rather than everything under the root, which is why a 70,000-file checkout does not cost 70,000 files of work. The last column is content addressing — the second capture of this repository hashed nothing and reused all 84 files, so ten turns that change one file cost one file of storage, not ten copies.
(Measured on this machine with filesnap capture, warm page cache. Your
numbers will differ; the shape won't.)
This package is the harness half: it decides when to snapshot, what a rewind point means in a conversation, and how the two halves of a rewind are sequenced.
Using it for a different agent
The engine knows nothing about its host. Its own documentation is explicit
about it — it takes opaque string ids and absolute paths, never reads or writes
your git state, and treats a directory that has never seen git init as a
first-class workspace. Nothing in it is aware that dsh exists. That is a design
commitment, not a coincidence, and it is what makes it portable to an agent that
is not this one.
Two ways in:
- Rust —
cargo add filesnap.capture,restore,scan_reportand the store types are the public API. - Anything else — drive the binary. Every command writes versioned JSON Lines to stdout and keeps human text on stderr, so a subprocess and a line parser are the whole integration. The npm package ships the binary with no JS API, because this is the intended path rather than a fallback.
This repository is the worked example of the second one, and the cost is
measurable. src/cli.ts — 116 non-comment lines — is the entire
engine interface: spawn it, parse the JSONL, map the exit codes. There is
nothing dsh-specific in that file. Copy it.
What is not 116 lines is the other ~1,100 in this repository, and that is the part worth understanding before you start. Those lines decide when a turn is worth capturing, what a rewind point means once a conversation can fork, how the conversation and the files are sequenced so a crash between them is survivable, and what happens to a point inherited from a parent session. filesnap deliberately makes none of those decisions for you — they differ per agent, and a library that guessed would be wrong in a way you could not override.
So the honest pitch is narrow and, we think, better for it: filesnap does not give you a rewind feature. It makes the snapshot-and-restore half a solved problem in about a hundred lines, so your effort goes to the half that is actually specific to your agent.
crates.io · docs.rs · npm · github
Before you install: the plugin records its rewind points as session events, and the harness has no supported way for an out-of-repo plugin to declare an event type — so it declares them by mutating a harness constant at load. That works, and it has one user-visible consequence worth knowing up front: uninstalling the plugin leaves the conversations it captured in unopenable, because the reader refuses a log holding a type it does not know. The data is untouched on disk; reinstalling restores access. See Known limitations.
What it does
Captures once per turn. On agent/pre-step, before the model request and
before any tool runs, the workspace is snapshotted and the point is recorded in
the session log. The capture is awaited, so a snapshot is never half-taken when
the first edit lands.
Records pre-images before edits. fs/write-intent and fs/edit-intent run
immediately ahead of the provider's mutation — the last moment a file's
previous contents still exist. The plugin names the path and filesnap reads it,
so the stored pre-image rests on an observation rather than on a claim.
Both are single-slot decision waterfalls, and the deployment's own policy
takes that slot without delegating. These listeners are therefore registered
with prepend, which is safe precisely because they decide nothing: they
record and hand the decision on unchanged, so the policy still owns the
outcome. Appended instead, they never run at all — which is what happened
before tests/wiring.spec.ts grew a case that mounts a non-delegating decider
first, the order a profile patch layer actually produces.
These attachments are tool-agnostic. Coverage follows ctx.fs, not a list of
tool names, so a tool this plugin has never heard of is protected the moment it
writes through the seam.
Rewinds both halves, in the one order that works. A rewind forks the
conversation first, then restores the files into that fork, because filesnap
files an undo record in the session named by --undo-for and that has to be
the session the user ends up standing in. Get the order wrong and /redo
exists somewhere the user cannot reach.
The browser half
Optional, and a separate artifact. lib/client.js adds two things, and the
split is the design:
- A rewind control in each turn's own message row, beside copy and branch. The transcript already is the list of points — one per turn — so the control is one icon under the assistant message that closed that turn, and its tooltip says what the snapshot covered. There is no panel repeating the list.
- Two session-level controls in the header: undo the rewind that landed here (rendered only when one did), and ask the engine what it holds. The status answer lands in the transcript, where a long list belongs.
Picking a turn runs the three steps in the order the web needs them:
sessions.fork(atSeq) the deployment's own fork — composes the child's
preset and attaches it to the workspace
/rewind <point> --into <child> the host puts the files back and files the
undo record in that fork
sessions.open(child) the user lands where the files landed
The host plugin can fork by itself and does so for a headless run. In the web it must not: a second fork beside the deployment's correct one would leave the child out of the workspace it belongs to.
It loads off the same row. The web shell scans the host Loader's mounted
entries, resolves each one's package.json, and serves the ./client export
of any that declares dsh.client — so the - id: filesnap row that mounts the
host half is also what puts lib/client.js on
/plugins/dsh-filesnap/client.js. There is nothing to add to a web build and
no static module table to edit.
It does need npm run build:client to have run. Without it the shell says so
by name at launch:
client-modules: client bundle not found; run `pnpm run build` before launch:
package: dsh-filesnap
path: …/lib/client.js
A deployment that wants only the commands builds the host half and never runs
build:client; the row still works, with no browser entry.
Commands
/rewind | list the points this session can return to |
/rewind <turn> | fork the conversation there and put the files back with it |
/redo | reverse the rewind that landed in this session, and hand back to the conversation it forked from |
/rewind status | what the store holds here, and which files it does not protect |
/rewind status re-scans the tree rather than reading something a capture
stored, because the question is about the project as it stands now. That is why
nothing runs it per turn: it costs what a capture costs. The per-turn coverage
counts are free by comparison — the capture already reports them, so they ride
the log and show up in the rewind control's tooltip.
/rewind <turn> --into <session> files the undo record in a fork the caller
already made, which is what the browser half passes.
/rewind takes a turn number as listed, or a point id verbatim. There is no
"go back three" — the engine refuses relative addressing on purpose, because a
restore overwrites your files and an off-by-one in a relative index is easy to
make and easy to miss. Counting against a list you are looking at is not the
same as counting against an index you assumed.
Both dispatch without a model turn. Rewinding is something you do to the conversation, so it does not go through the thing being rewound.
Install
Nothing to install by hand. filesnap is a dependency of this package, so
installing the plugin brings the prebuilt binary for your platform with it:
$ dsh plugin --profile web add dsh-filesnap
The binary is found by resolution, not by PATH — the launcher's bin entry
lands in the profile's node_modules/.bin, which the subprocess provider's
scrubbed environment has no reason to include. Set the command config only to
point at a different build, or to a bare name for a subprocess provider whose
execution world is not this machine.
dsh plugin forwards its arguments to pnpm inside the profile directory, and
warns:
dsh: warning: dsh-filesnap declares no dsh.bundle — installed as a plain
dependency, not a profile layer
That warning is expected: this is a plugin, not a bundle, so it is mounted by a
row rather than by a layer. Add one to that profile's cordis.patch.yml
(~/.dsh/profiles/web/cordis.patch.yml):
# `insert` takes a list of rows.
- insert:
- id: filesnap
name: dsh-filesnap
dsh --profile web --dump-config prints the tree that actually boots, so you
can check the row landed:
$ dsh --profile web --dump-config | grep -A 1 filesnap
- id: filesnap
name: dsh-filesnap
Trying it locally
From a checkout, before publishing anything:
$ npm run build # lib/ is what the profile loads
$ dsh plugin --profile headless add /path/to/this/repo
Add the same insert row to ~/.dsh/profiles/headless/cordis.patch.yml,
confirm it composes with --dump-config, then run a task in a scratch
directory:
$ cd /tmp/scratch && echo hello > notes.txt
$ dsh --profile headless "change notes.txt to say goodbye"
The session's working directory is wherever you run it, so run it in the project you want snapshotted. Afterwards, ask the engine directly what it recorded — the session id is the one in the transcript:
$ filesnap log --session <session-id>
{"v":1,"type":"log.entry","turn":"<session-id>.t1","manifest":"a1b2…","at":…,"files":2,"absent":0}
$ filesnap status | jq -r 'select(.type=="status.unprotected") | "\(.reason)\t\(.path)"'
pnpm dsh --profile headless "…" runs the harness from source instead, if you
have its checkout. That launch resolves workspace packages through the
repository's own tsconfig, so it must run with the harness as the working
directory — which makes it the wrong way to snapshot some other directory.
Use an installed dsh for that.
Configuration
Every field has a default that is right on an ordinary machine; most deployments set none of them.
| field | default | |
|---|---|---|
command | (resolved) | normally unset — the binary installed with this package is found automatically. Set it to use a different build, or to a bare name that the subprocess provider resolves through its own PATH when its execution world is not this machine. |
dataDir | platform data directory | where the store lives — $XDG_DATA_HOME or ~/.local/share on Unix, %LOCALAPPDATA% on Windows. Never inside your project. |
timeoutMs | 120000 | wall-clock bound for one invocation. The expensive one is the per-turn scan. |
graceMs | 2000 | SIGTERM-to-SIGKILL grace when a deadline or a cancelled turn ends a run. |
maxOutputBytes | 1048576 | in-memory cap per collected stream. |
declareEdits | true | record pre-images before edits. Turning it off narrows coverage to whatever the per-turn scan sees. |
An unknown key or an unusable value fails at load, not at the first turn: a misconfiguration that surfaces as a missing snapshot an hour later is indistinguishable from a bug.
filesnap's scan limits are deliberately not exposed. A bound you have to
discover is not a bound, and filesnap status answers the question that
setting would have been reached for — which files in this project are not
protected, and why.
What it will not do
- Touch your version control. Git is read as one source of file names and never written.
- Delete a file it has never observed. A restore removes a path only when the capture it is restoring to looked for that path and did not find it.
- Snapshot what you excluded.
.filesnapignoreis symmetric — an ignored path is never stored, never restored, and never deleted by a restore. - Lose the rest of a rewind to one bad file. A file that cannot be written is named, the others still land, and the result says so.
- Rewind an agent that is mid-turn. Stop it first. A rewind would otherwise write over files the turn's own tools are still using.
- Hide the conversation you rewound out of. It is marked, not archived —
its title gains a
↩prefix, which/redoremoves again.archiveSessionexists onctx.workspaceRegistry; unarchive does not, and the harness's own comments call it deferred work. Archiving one half of a reversible pair would leave/redowith both conversations hidden, which is worse than the confusion it set out to fix.
What it records
Three log-only session events, merged into SessionEventMap. None is a
SurfaceEventType: a rewind changes which files are on disk and which
conversation you are standing in, and neither of those is a message the model
sees.
filesnap/point | a snapshot exists for this turn, and the id it is addressed by |
filesnap/rewound | this session was rewound; the conversation continues in child |
filesnap/redone | a rewind was reversed here |
They also drive a filesnap session projection, which is how the browser
reads the point list without re-deriving it from a transcript it renders for
other reasons. The projection registers through ctx.inject, so an assembly
with no projection registry is unaffected.
They live in the log rather than in a side table because a fork deep-clones the seed: a point recorded in the log travels into every child that inherits the turn it belongs to, so a freshly forked session can offer rewind points before it has run a turn of its own.
The plugin declares these three types to the persistence reader at load, and
must. That reader refuses a log holding a type it does not know unless the
event is marked ignorable, and both halves of that escape are closed to a
plugin outside the harness repository: KNOWN_SESSION_EVENT_TYPES is generated
from in-repo declarations — downstream events are outside it "by construction",
with a registration surface "deferred until such a consumer exists" — and
Session.append takes no options at all for a non-surface event, so the marker
cannot be set. Undeclared, every session this plugin captured in failed to open
with SessionFormatUnsupportedError.
The declaration deliberately does not unwind, because removing it would strand
those logs again. That is its cost, stated plainly: uninstalling this plugin
leaves sessions it captured in unreadable. The fix that removes the cost
belongs upstream — a registration surface, or an append that can mark an
event ignorable.
For other plugins
The service is ctx.filesnap.
const points = await ctx.filesnap.points(agent)
if (points.ok) {
const outcome = await ctx.filesnap.rewind(agent, String(points.value[0].turn))
}
rewind takes an optional destination. { kind: 'fork' } (the default) forks
the conversation itself; { kind: 'into', session } files the undo record in a
session you already forked — which is what a deployment with its own fork
should pass, so this plugin does not build a second one beside it.
Every operation returns { ok: true, value } or { ok: false, refusal }
rather than throwing. Every caller has to render the reason, and an exception
would make each of them re-derive it from a message string.
Why every @deepseek-ai peer is optional
They are declared so the requirement is visible, and marked optional so nothing
tries to satisfy it. A dsh plugin must not carry its own copy of the harness
packages: it runs inside a composed harness and uses the one already there. A
second copy is not a duplicate dependency, it is a second Cordis — different
Service classes, a different registry, and a plugin whose inject never
resolves, silently.
The profile install path already prevents this (autoInstallPeers: false in
the profile's pnpm settings, plus the launcher's symlink fallback into the
installation's own modules). Marking them optional is what makes a bare
npm install dsh-filesnap behave the same way instead of trying to materialize
a set that does not resolve — the harness's release trains are not in lockstep,
so npm's peer auto-install lands on a genuine conflict.
Development
The harness packages are peerDependencies — a deployment already has them,
and pinning a version here would fight whatever it runs. For a local
typecheck and test run, link a built sibling checkout:
$ git clone https://github.com/deepseek-ai/deepseek-harness ../deepseek-harness
$ ( cd ../deepseek-harness && pnpm install && pnpm run build )
$ npm install
$ npm run harness:link
$ npm run typecheck && npm test
harness:link symlinks the harness's built packages into node_modules
without writing them into package.json, so npm install stays reproducible
for anyone who has no checkout. The build matters: lib/types/*.d.ts is what a
consumer resolves, and checking against src would typecheck the harness's own
sources under this project's compiler settings rather than checking this
plugin.
harness:link also has to be re-run after any npm install: npm prunes what
package.json does not name, and these links are deliberately not named there.
The suite has four tiers. npm run test:standalone needs neither the harness
nor the binary. The engine, service and wiring suites drive the real filesnap
command, which npm install already brought in — so they just run.
FILESNAP_BIN points them at a different build, and a sibling filesnap
checkout's cargo output is the last resort.
Two build faces:
$ npm run build # the host half — plain tsc, no harness needed
$ npm run build:client # lib/client.js — needs the harness checkout
The browser artifact is the harness's own closure-factory format, produced by
its tsdown preset. That preset is a repository file rather than a published
entrypoint, and it resolves a package's externals by globbing the harness's
packages/ tree — so build:client stages this manifest there for the length
of the build and removes it afterwards. Sources, config and output all stay
here. It is not the imposition it looks like: the web application is itself
built from the harness repository, so anyone assembling a web build that
includes this plugin already has a checkout.
Known limitations
- A
/rewindtyped into the web composer reports the new session rather than opening it. The host command registry returns text, so that path names the fork and the user opens it. The header entry does not have this problem: it forks and navigates itself. - A self-performed fork inherits the parent's model route and preset, but not
per-agent model selection or workspace attachment. Those live in the
deployment's own fork path (
sessions.fork), which--intoexists to defer to — so the headless fork is the one that carries this gap. - Uninstalling the plugin strands the sessions it captured in. See "What it records" — the event-type declaration cannot unwind without re-breaking those logs, so removing the plugin re-introduces the refusal.
- The browser half is typecheck-verified and built, not browser-tested. The artifact is the shell's own format and the two faces compile against the harness's declarations, but no test drives the rendered menu.
- A plugin that reads an undeclared service as a property is torn down in
silence. Cordis refuses the read, the throw leaves the service constructor,
and the fiber is disposed with no log line — so the plugin is simply absent.
This one resolves
commands,fs,agentPresetsand the logger throughctx.get, andtests/wiring.spec.tsexists to keep it that way: it asserts the service is still reachable after boot and that a dispatched turn reaches the engine. The service-tier tests cannot see that failure, because calling a method directly proves nothing about whether a turn ever arrives. - A turn whose capture failed offers no rewind point. The failure is on stderr; the point is absent rather than listed and refused on use.
- Coverage of shell-written files follows the scan. A file a shell command creates outside the workspace, over the size limit, or beyond the recency budget is covered only if it also went through the filesystem seam.
Licence
Apache-2.0. See LICENSE. The engine it drives, filesnap, is under the same licence.