Back to home@extracurricular-ai

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 capturedfirst captureevery capture after
this repository8420 ms8 ms
the harness monorepo7,995 of 70,918 on disk1.75 s268 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:

  • Rustcargo add filesnap. capture, restore, scan_report and 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

/rewindlist the points this session can return to
/rewind <turn>fork the conversation there and put the files back with it
/redoreverse the rewind that landed in this session, and hand back to the conversation it forked from
/rewind statuswhat 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.

fielddefault
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.
dataDirplatform data directorywhere the store lives — $XDG_DATA_HOME or ~/.local/share on Unix, %LOCALAPPDATA% on Windows. Never inside your project.
timeoutMs120000wall-clock bound for one invocation. The expensive one is the per-turn scan.
graceMs2000SIGTERM-to-SIGKILL grace when a deadline or a cancelled turn ends a run.
maxOutputBytes1048576in-memory cap per collected stream.
declareEditstruerecord 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. .filesnapignore is 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 /redo removes again. archiveSession exists on ctx.workspaceRegistry; unarchive does not, and the harness's own comments call it deferred work. Archiving one half of a reversible pair would leave /redo with 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/pointa snapshot exists for this turn, and the id it is addressed by
filesnap/rewoundthis session was rewound; the conversation continues in child
filesnap/redonea 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 /rewind typed 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 --into exists 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, agentPresets and the logger through ctx.get, and tests/wiring.spec.ts exists 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.