How it works
dsh-filesnap provides DSH browser controls, commands and conversation/file recovery. The filesnap engine captures and restores files; this plugin decides when to capture, what a point means in a forkable conversation, and how the transcript and filesystem operations are sequenced.
The two layers#
| Layer | Owns | Does not own |
|---|---|---|
filesnap engine |
bounded scan, content-addressed blobs, manifests, restore, undo records, garbage collection | conversations, dsh sessions, browser navigation |
dsh-filesnap plugin |
turn lifecycle, pre-edit observations, session events, fork/restore order, commands, browser projection | the store format and file-moving implementation |
The engine is a static Rust binary with versioned JSON Lines on stdout, human
diagnostics on stderr and meaningful exit codes. The plugin's complete process
adapter is src/cli.ts; it contains no rewind policy.
Capture lifecycle#
Three host attachments cover different mutation paths:
agent/pre-stepwaits for the deployment's decision. If the step enters, the workspace is captured before the model request and before any tool runs.fs/write-intentrecords the target's pre-image immediately before a write.fs/edit-intentdoes the same immediately before an edit.
The filesystem listeners use { prepend: true }. Those hooks are single-slot
decision waterfalls, and a deployment policy may take the slot without calling
the next listener. dsh-filesnap observes the target and delegates unchanged, so
the deployment still owns the decision while the pre-image is read before it
disappears.
Coverage follows the ctx.fs seam rather than a list of tool names. A new tool
is covered as soon as it writes through that seam. Shell writes do not pass
through it, so they rely on the bounded scan at the next turn boundary.
The tracked set#
Each capture uses a bounded union rather than a full recursive copy:
- file names already known to the workspace, including Git-tracked files;
- paths declared by observed writes and edits, wherever those paths are;
- a bounded recent-change scan for shell writes and other mutations outside the filesystem seam.
Unchanged bytes are stored once and referenced by multiple manifests. An ignored path is excluded symmetrically: no capture, no restore and no deletion.
Rewind sequence#
The order is fixed:
1. select an explicit rewind point
2. fork the conversation at that point
3. capture a rescue point before changing files
4. restore the target manifest into the fork
5. record the rewind and its undo information in that fork
6. open the fork (browser) or return its id (headless command)
The fork must exist before restore. The engine files its undo record under the
session named by --undo-for; that session must be the one in which the user
lands, otherwise /redo would exist somewhere inaccessible.
The browser already has a deployment-aware fork path that composes the child's
preset and workspace attachment. It therefore creates the child and calls
/rewind <point> --into <child>. Headless use asks the host plugin to create
the fork itself.
Restore safety invariants#
- A restore captures a rescue point before its first write.
- A path is deleted only when the target manifest contains a positive tombstone saying the path was absent.
- Capture errors do not become tombstones. An unreadable path is skipped rather than later interpreted as permission to delete it.
- Per-file restore failures are reported individually; the remaining files are still attempted.
- Rewind is refused while the agent is in an active turn.
- A capture that failed does not create a selectable rewind point.
Session events and projection#
The plugin records three log-only events:
| Event | Meaning |
|---|---|
filesnap/point |
a snapshot exists before this turn |
filesnap/rewound |
this session was rewound and continues in a child |
filesnap/redone |
the rewind that landed here was reversed |
Keeping these records in the session log matters because a fork deep-copies its seed. A child therefore inherits the points belonging to the turns it keeps, even before it runs a new turn of its own.
The browser does not parse the transcript. A filesnap session projection folds
committed events into a client-safe value containing points and the most recent
rewind record. The projection is optional, so a headless assembly without a
projection registry still captures and exposes commands.
Browser/host boundary#
The host and browser code use different Cordis Context declarations. Values
cross the boundary through plain types in src/wire.ts,
preventing host-only context merges from changing the browser API's types.
The same profile row mounts the host and makes the ./client export available.
The web shell serves the built lib/client.js; no static module table is
modified.
Service API#
Other plugins can use 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 accepts { kind: 'fork' } or { kind: 'into', session }. Operations
return { ok: true, value } or { ok: false, refusal }; callers receive a
structured reason instead of having to parse an exception message.
Why the harness peers are optional#
The @deepseek-ai/* packages are peer dependencies because the plugin runs
inside an already-composed harness. Bundling another Cordis or session package
would create incompatible service classes and registries.
They are marked optional so a plain npm install dsh-filesnap does not try to
materialize a second, potentially conflicting harness release line. The dsh
profile installer already supplies the packages from the active deployment.
The upstream event-registration gap#
dsh's persistence reader refuses a log holding a non-surface event type it does
not know, unless the event's envelope carries ignorable: true
(session-persistence/src/coordinator.ts, assertEventsSupported). The harness
reserved that marker for exactly this case — an out-of-repository plugin's
informational events — and every representation preserves it. But a plugin
cannot set it: Session.append builds the envelope with deepFreeze and copies
only the surface fields into it, on 0.1.2-rc.1 and on master alike. There is
no option, no hook, and no write-side seam.
dsh-filesnap therefore adds its three event types to the reader's known set at
load. That declaration reaches one thing: the @deepseek-ai/dsh-session module
instance the plugin imported. It fails to reach the reader in two situations:
- The plugin is not loaded. Uninstalling it leaves sessions it captured unopenable until it is installed again. The data is intact on disk.
- The reader holds a different instance of the same package. A source
launch (
pnpm dsh, via tsx) resolves the harness's own packages tosrc/, while the plugin's import resolves tolib/. Two files, two Sets. The session is refused with the plugin installed and running. The built CLI (node apps/cli/lib/bin.js web) and an npm-installed dsh resolve everything tolib/and are unaffected.
tests/persistence.spec.ts pins the mechanism: one log written through the
real store and JSONL backend, read back in this process (declaration present:
opens) and in a child process that never loaded the plugin (refused, by name,
at the point's seq).
The durable fix belongs upstream and is small: let append accept
{ ignorable: true } for a non-surface event and spread it into the frozen
envelope. Reader, codec and seed validation already honour the field. When that
lands, the child-process assertion is the one that flips, and the load-time
declaration becomes legacy-only, for logs written before it.
Storage lifecycle#
The store lives in the platform data directory by default, never inside the project. Content remains reachable while a point references it; nothing is deleted merely for being old.
The engine already provides delete, gc and doctor, but the plugin does not
yet expose them as /rewind subcommands. Until it does, use the binary installed
inside the profile:
~/.dsh/profiles/<profile>/node_modules/.bin/filesnap gc
~/.dsh/profiles/<profile>/node_modules/.bin/filesnap doctor --workdir .
~/.dsh/profiles/<profile>/node_modules/.bin/filesnap delete --session <id>
Run /rewind status first to inspect the workspace records, shared blob usage
and unprotected paths.
Browser implementation#
The optional ./client export adds:
- a rewind action beside the existing actions on each completed assistant turn;
- header actions for redo and store status.
The transcript is already the list of turns, so the plugin does not add a second checkpoint panel. In the browser, the deployment creates the correctly composed child session, the host restores files into it, and the client opens that child. Headless use performs the fork in the host plugin.
The browser bundle is typechecked and built during release. It does not yet have an automated in-browser test; that remains a tracked limitation.
Headless continuation#
A host-performed headless fork inherits the model route and preset, but not the deployment’s per-agent model selection or workspace attachment.
Using the engine separately#
The snapshot engine is also usable outside dsh through Rust
(cargo add filesnap) or its versioned JSON Lines CLI. The complete subprocess
adapter in this repository is src/cli.ts.