dsh-session-sync mirrors the DSH session store into a dedicated git worktree and syncs it to a user-provided remote, giving cross-device session continuity with no cloud service. Session files are treated as opaque bytes: the plugin never parses JSONL/zstd — the physical encoding belongs to the harness, and the plugin's only job is to move those bytes, merge append-only histories, and report state.
- Consumes public services only:
sessions,commands,storageDomain,subprocess(hardinject);tools(registered viactx.inject(['tools'], …)when present);userQuestions/approval(optionalctx.get, fail closed when absent). lib/is zero-DSH-dependency: services are wired only at the boundary inindex.mjs;lib/depends only onnode:built-ins (the single sanctioned exception islib/domain.mjs, which importszodand@deepseek-ai/dsh-storage-domainbecause the domain record schema is a persistence-boundary validator).
| Module | Responsibility |
|---|---|
index.mjs |
The single host face: Config schema + resolveConfig, makeRunGit, tool factories, the /sync command handler, auto modes, apply() |
lib/constants.mjs |
Vocabulary + protocol constants (backends, confirm channels, merge kinds, error codes, defaults, bounds) — zero dependency |
lib/errors.mjs |
Structured domain errors (SyncError with stable code + details) |
lib/paths.mjs |
Path resolution, nesting checks, and fork-file naming — pure |
lib/sanitize.mjs |
Pure display/log redaction (remote-URL credentials, tokens, key=value secrets, path-containment display) |
lib/merge.mjs |
Append-only three-way merge classification — pure |
lib/mirror.mjs |
Byte mirror from the session store into the worktree (node:fs) |
lib/git.mjs |
Git command layer: verb whitelist + argument assertions, never force-push/reset/rebase/branch-switch |
lib/backend.mjs |
Transport backend seam contract + pure selectEncryptionMode (backend/mode/warnings selection) |
lib/age.mjs |
age binary probe (detectAge) + file-based ageEncrypt/ageDecrypt (optional external binary, graceful degrade) |
lib/encrypted.mjs |
EncryptedBackend: age-encrypted git transport (encrypt-then-push, decrypt-then-merge on plaintext, plaintext fallback) |
lib/engine.mjs |
Sync orchestration: push (mirror → commit → push, reconcile-and-retry once on rejection), pull (commit → fetch → three-way merge), status |
lib/status.mjs |
Read-only status collection (no fetch, no writes) |
lib/render.mjs |
Result text rendering (pure, JSON-safe inputs) |
lib/gate.mjs |
Confirmation gate + session-event adaptive gate |
lib/domain.mjs |
The session-sync storage-domain spec (state table, singleton key) |
$DSH_HOME/sessions ──byte mirror──▶ <repoDir>/sessions ──git──▶ custom remote
- Mirror copies each session file as opaque bytes into the worktree (symlinks refused, deletions only for files that vanished at the source and are neither fork files nor host-private artifacts). Host-private artifacts (
session.lock, the harness session lease, andsession.migration.*.tmpstaging files) are runtime state, not syncable content: the mirror neither copies them in nor deletes them from the worktree. - Commit records the mirror as a git commit (plugin identity, never the user's git identity).
- Push/pull move commits to/from the configured remote over
ctx.subprocess-driven git.
The three-way merge compares base (merge base), ours (local), and theirs (remote) per file:
| Verdict | Condition | Outcome |
|---|---|---|
identical |
ours == theirs |
keep ours |
ours-only |
theirs == base |
keep ours |
theirs-only |
ours == base |
adopt theirs (remote-only append) |
append-both |
both appended past base | keep ours + fork file for theirs |
diverged |
neither is a superset | keep ours + fork file for theirs |
Fork files are named <basename>.remote-fork-<UTC 14-digit yyyyMMddHHmmss>-<device8> (forkFileName rejects any non-14-digit stamp, so both backends must strip the ISO separators -, :, and T); mirror and cleanup never delete or overwrite files matching that pattern, so both lineages always survive. When a conflicting session is live and has no open turn, the plugin also forks the session at the harness level and injects a persistent user/message notice into the child. The session-level mapping follows the host's real session-store layout $DSH_HOME/sessions/<projectKey>/<sessionId>/<file> — projectKey is derived from the session cwd (_no-cwd when absent) and sessionId is the host's encodeSegment form, which lib/paths.mjs decodes back (~XXXX escapes). Fork paths that do not fit that shape (or the legacy flat <mirrorDir>/<sessionId>/<file> form) are reported with a warning and never silently dropped; the fork files themselves are always preserved.
pull/push (command and tools) cross confirmSync. confirmVia: auto prefers userQuestions then approval; an explicit userQuestions or approval forces one channel. With no answerer the operation fails closed. Read-only surfaces (status/diff/log) never ask. The approval channel requires an open turn (tools run inside a turn; /sync runs between turns and is directed to mount userQuestions).
sync/push, sync/pull, and sync/conflict are declared through SessionEventMap declaration merging in types.d.ts. At runtime the plugin appends one only when either (a) the host's KNOWN_SESSION_EVENT_TYPES already includes the type, or (b) the host Session.append exposes the ignorable envelope (probeIgnorableAppend). On 0.1.0-rc.6, 0.1.0-rc.8, and 0.1.1-rc.2 neither is true (rc.2's append takes no envelope for non-surface types; ignorable is a read-path marker only), so the gate stays closed and appends are skipped — sessions keep loading. Once a host records the types, appends turn on automatically; the gate is never removed by hand.
Sync metadata (deviceId, lastPullAt, lastPushAt, lastPushHead, lastError) lives in the session-sync storage domain (state table, singleton key). The device id is a random UUID also written to device.txt in the sync repository for cross-device fork attribution.
Every automatic behavior is a Cordis effect, so stop/hot-reload removes it:
autoPullOnStart—ctx.effectpulls once on mount.autoPushOnTurnEnd—ctx.on('session/event')pushes afterturn/end.pullIntervalMinutes—ctx.effectwraps asetIntervalperiodic pull (the disposer clears the timer).
Auto runs are covered by the config grant and never re-confirm.
- Verb whitelist in
lib/git.mjs;-f/--force/--force-with-lease/+refspec,reset,clean,rebase, and branch-switchcheckoutare refused. - Path containment before any join (
PATH_UNSAFEfails loud). - Symlinks never followed on either side of the mirror.
- Sanitize before display/log — remote-URL credentials, tokens, and
key=valuesecrets are redacted before reaching the model or the log. - Bounded subprocesses — git runs with
GIT_TERMINAL_PROMPT=0andGIT_OPTIONAL_LOCKS=0, deadline- and signal-bounded, with a per-stream output cap.
- Session-directory layout version marker. The mirror maps fork files back to sessions by path shape (
<projectKey>/<sessionId>/<file>plus the legacy flat form). A version marker in the session-directory layout would let a future plugin declare which layout it understands instead of sniffing segment shapes; recorded here per the 0.1.6 adaptation review (P2, no code change requested).
backend: git is the plaintext mirror transport. backend: encrypted wraps the same git transport with an age layer above the mirror: the plaintext mirror (sessions/) stays local-only (.gitignore), each session file is age-encrypted into encrypted/**/*.age, and only ciphertext is committed/pushed. Pulls decrypt the remote/base trees back to plaintext and run the same append-only three-way merge locally, then re-encrypt. When age is absent or ageRecipient/ageIdentity is empty, the encrypted backend degrades to plaintext git and warns explicitly (status/log) — it never silently claims encryption. object-storage is an interface placeholder and fails loud at load.