dsh-workspace-mover 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,214 +1,224 @@
1
- # Architecture
2
-
3
- Technical reference for contributors and advanced users. Product-facing install/usage docs live in [README.md](../README.md) / [README_EN.md](../README_EN.md). This file is the **single source of truth** for implementation details; do not duplicate it into READMEs.
4
-
5
- ## Plugin mount (DSH 0.1.5)
6
-
7
- - Package is pure JavaScript source-as-product (no TypeScript, no build step).
8
- - Host half mounts through a standard `insert` row in [`cordis.patch.yml`](../cordis.patch.yml).
9
- - Client half is injected via `package.json` → `dsh.client` (`@deepseek-ai/dsh-client-connection`, `@deepseek-ai/dsh-client-ui-slots`, platform `web`).
10
- - Official bundles are never rewritten; only event listeners and official slots are used.
11
- - From GitHub, pnpm runs no install scripts, so `allowBuilds` is not required.
12
-
13
- ## Host / Client split
14
-
15
- | Half | Entry | Role |
16
- | --- | --- | --- |
17
- | Host | `lib/index.js` | RPC, disk archives, workspace registry, backups, trash, history, locks |
18
- | Client | `client/client.js` | Sidebar drag intercept, multi-select, confirmation UI, rescue panel (settings slot) |
19
-
20
- Client locates rows by ARIA semantics only:
21
-
22
- - session rows: `[aria-selected]`
23
- - workspace title rows: `[aria-expanded]`
24
-
25
- It never depends on CSS-module hash class names. UI uses official design tokens (`--dsw-alias-*`) for theme adaptation.
26
-
27
- Only cross-group drops are intercepted; official same-group reorder stays untouched. After a successful move the client actively refreshes and reconciles the official workspace projection; a page reload remains the user-facing fallback.
28
-
29
- ## RPC channel
30
-
31
- Single logical channel `/workspace-mover` via `ctx.connection.rpc.handle('/workspace-mover', …)`. No independent REST routes.
32
-
33
- | Endpoint | Purpose |
34
- | --- | --- |
35
- | `mover.status` | Capability report (detected services / degraded items) plus `storageLayout`, `degradedFeatures`, and the `unarchiveApi` / `setTitleApi` flags |
36
- | `mover.workspaces` | List workspaces |
37
- | `mover.move` | Single-session true move |
38
- | `mover.preview` | Read-only migration preview: source/target, collision, running state, adapter and estimated new snapshot bytes; `backup.reused` identifies an existing matching snapshot |
39
- | `mover.moveMany` | Bulk move (≤ `batchLimit`, default 50) |
40
- | `mover.scan` | Classified rescue scan (parses the newest `scanLimit`, default 400; reports `scanned` vs `scannedParsed`) |
41
- | `mover.repair` | Attach / relink / home one item |
42
- | `mover.repairAll` | Batch repair (per-item isolation) |
43
- | `mover.history` | Recent moves (≤ `historyLimit`, default 100) |
44
- | `mover.undo` | Undo one history entry |
45
- | `mover.ws.audit` | Workspace path health |
46
- | `mover.repoint` | Move-home wizard |
47
- | `mover.archived` / `mover.unarchive` | List / restore archived sessions (`mover.archived` also reports `truncated` / `scannedParsed` / `scannedTotal` / `archivedTotal`) |
48
- | `mover.openFolder` | Open group directory in OS file manager |
49
- | `mover.session.delete` | Session → recycle bin; optional `purgeBackups: true` also deletes that session's migration backups |
50
- | `mover.trash.list` / `mover.trash.restore` / `mover.trash.purge` | Recycle bin; `purge` accepts optional `purgeBackups: true` |
51
- | `mover.backups.list` / `mover.backups.restore` / `mover.backups.deleteOne` | Recovery-snapshot management; `list` reports `orphan` per group, physical `count`, plus `orphanGroups` / `orphanBytes` |
52
- | `mover.tasks.list` / `mover.tasks.retry` / `mover.tasks.forget` | Migration task center |
53
- | `mover.migrations.enqueue` / `mover.migrations.list` / `mover.migrations.retry` / `mover.migrations.cancel` | Persistent busy-session queue; `retry` accepts one `taskId` or a `taskIds` batch, with per-item failure isolation |
54
- | `mover.bundle.export` / `mover.bundle.inspect` / `mover.bundle.import` | Portable Bundle export, dry-run inspection, and import through the adapter/backup pipeline |
55
- | `mover.data.cleanup` | Dry-run and clean aged plugin data (default age = `cleanupDays`) |
56
-
57
- ### Tunable policies
58
-
59
- `mover.data.cleanup`'s age and the caps above come from `resolvePolicy()` in `lib/index.js`, read **at the point of use** (never frozen at module load):
60
-
61
- | Policy | Default | Environment override |
62
- | --- | --- | --- |
63
- | physical snapshot retention per session | 20 | `DSH_WORKSPACE_MOVER_BACKUP_KEEP` |
64
- | move history length | 100 | `DSH_WORKSPACE_MOVER_HISTORY_LIMIT` |
65
- | batch cap (move / repair) | 50 | `DSH_WORKSPACE_MOVER_BATCH_LIMIT` |
66
- | repoint cap | 200 | `DSH_WORKSPACE_MOVER_REPOINT_LIMIT` |
67
- | rescue scan cap | 400 | `DSH_WORKSPACE_MOVER_SCAN_LIMIT` |
68
- | cleanup age (days) | 30 | `DSH_WORKSPACE_MOVER_CLEANUP_DAYS` |
69
-
70
- An unparseable or out-of-range value falls back to the default, so a bad configuration can never disable a feature.
71
-
72
- ### Dependency declaration, and why every peer is `*`
73
-
74
- `package.json` declares the eight host packages this plugin integrates with as `peerDependencies`, all `*` and all `optional` in `peerDependenciesMeta`. They are **declarative metadata for storefronts**, not a compatibility gate: the plugin fail-softs at runtime and reports any real degradation through `mover.status` capabilities and `mover_doctor`. The real floor is `engines.dsh` (`>=0.1.5-rc.1`) plus `engines.node`.
75
-
76
- This was not the first attempt. The ranges originally used the ecosystem's "explicit prerelease branch" idiom, e.g. `>=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0`, and the host then reported the plugin as **incompatible with `0.2.0-rc.2`** — its own tested host line. The mechanism, confirmed by running dsh-market's own range evaluator:
77
-
78
- - npm's prerelease rule is evaluated at the **comparator-set level**: one `||` alternative is one set, and a prerelease version satisfies that set only when at least one comparator in it shares the version's exact `[major, minor, patch]` tuple *and* carries a prerelease tag; only then are all comparators checked normally.
79
- - `<0.2.0-0` does share the `0.2.0` tuple and does carry a prerelease, so the set is admitted — but `0.2.0-rc.2 > 0.2.0-0` (the `-0` sentinel sorts below every real prerelease), so the bound then excludes **every** 0.2.0 prerelease.
80
-
81
- Enumerating release lines reproduces the fault on each new line: a `<0.3.0-0` bound breaks the moment a 0.3 prerelease ships. Hence `*`. dsh-market filters peer declarations to `/^@deepseek-ai\/dsh(?:-|$)/`, which is also why `@deepseek-ai/cordis` and `@deepseek-ai/schemastery` were never named in the warning — they are only evaluated when `engines.dsh` mentions them, and it does not.
82
-
83
- `test/policy.test.mjs` asserts that the declared ranges plus `engines.dsh` derive `compatible` — never `incompatible` — for DSH versions from `0.1.5-rc.1` through `1.0.0`.
84
-
85
- **Official Config is deliberately not registered.** The schema would need `@deepseek-ai/schemastery`, which the host loader provides but which is not resolvable from this plugin's directory (verified by `createRequire` from `lib/index.js`); the npm `schemastery` has no `.volatile()` either. Because a bundle-layer static import failure breaks **boot** (a documented ecosystem failure class), `lib/config.js` never imports it — it exposes `buildConfig(z)` / `attachConfig(moduleOrBuilder)` so a host that hands over a schema builder can attach one, and exports `Config === undefined` otherwise. `buildConfig` contains all its own failures and returns `undefined` rather than throwing.
86
-
87
- ### Official-API preference is observable
88
-
89
- v2.2 prefers two documented host APIs and keeps an equivalent fallback for each: `registry.unarchiveSession()` (fallback: `enqueueOperation` + `setState`) and `entity.setTitle()` (formerly folded into `entity.mutate`). Both fallbacks are silent by design — the feature still works — so `mover.status.capabilities` reports `unarchiveApi` / `setTitleApi` and lists `unarchive-official-api` / `set-title-official-api` in `degradedFeatures` when the host lacks them. That is what makes "which path did it take?" answerable on a real machine instead of a guess.
90
-
91
- Errors carry stable codes (`busy`, `conflict`, `not-found`, `rollback-failed`, …). Host logs write failures under `MOVE FAILED`.
92
-
93
- ## Agent tools (DSH ≥ 0.1.5 with `dsh-tools`)
94
-
95
- When the host provides the `tools` service, `apply()` registers four plain (zero-dependency) tool definitions; each tool's execute re-enters the internal RPC dispatch, so agent calls share the panel's locks, error codes, and transaction semantics:
96
-
97
- | Tool | Gate | Purpose |
98
- | --- | --- | --- |
99
- | `mover_list_sessions` | none (read-only) | Compose scan + workspace list (sessions capped at 40, counts, ghosts, recoveryCount) for id resolution |
100
- | `mover_move_session` | user approval | Move one session (`sessionId`, `targetWorkspaceId`, optional `sessionTitle`) |
101
- | `mover_repair_sessions` | user approval | One `mover.repairAll` pass (accounting-only) |
102
- | `mover_doctor` | none (read-only) | One `mover.doctor` self-check pass (host services, data dirs, recovery records, workspace paths) |
103
-
104
- - Mutating tools call `ctx.approval.request({ agent, toolName, reason, signal })` before acting; `allowed-once` proceeds, anything else (`denied` / `cancelled` / `unavailable`) or a throwing seam fails closed. Hosts without the approval seam (no UI) rely on conversational confirmation and proceed.
105
- - `exec.signal` propagates into the RPC layer; cancellation lets the in-flight step finish rolling back to a consistent state.
106
- - Hosts without `dsh-tools` skip registration silently; `mover.status` reports the `agentTools` flag.
107
-
108
- ## Session archives and storage adapter
109
-
110
- - The fallback filesystem adapter recognizes names `session.jsonl[.zstd]` and `session.vN.jsonl[.zstd]`; it selects the highest generation without rewriting older files to v3.
111
- - A mixed plain/Zstd directory and a file whose compression differs from the host's configured mode are rejected explicitly. Mixed compression elsewhere under the same root is diagnosed per session, not by a root-wide scan.
112
- - v3/v4 filenames are recognized, but a v4 runtime fixture has not yet been verified. Filename/header mismatch is rejected only when the header contains one of the generation fields currently recognized by the adapter.
113
- - True move keeps the session id and all frames; only the header `cwd` (first frame) is rewritten; other frames stay byte-identical. Publish via temp file + atomic rename.
114
-
115
- ### The storage-layout assumption, and its self-check
116
-
117
- The adapter first reports the public `list` / `stat` / `open` surface. When a provider also exposes a physical root, the plugin uses the legacy directory adapter as a visible `filesystem-fallback`; when only official handles are available it stays read/diagnostic capable and marks physical moves as `unsupported` instead of guessing a path.
118
-
119
- `verifyStorageLayout(ctx)` closes that gap: it takes up to five real headers from `persistence.list()`, reverse-derives each session's directory with this plugin's own encoding, and confirms an archive is actually there. Result states are `ok` / `degraded` / `empty` (no sessions to verify) / `unavailable`. It is exposed as `mover.status.capabilities.storageLayout` plus a `data-layout` check in `mover_doctor`, is strictly read-only, and never throws (any error degrades to a warning).
120
-
121
- `mover.status.capabilities.storageAdapter` is `filesystem-fallback`, `official-handle`, or `unavailable`; `storageApi`, `physicalPath`, and `migrationQueueState` expose the exact degradation and queue state. `mover_doctor` reports the same adapter under `storage-adapter` and checks the durable migration records under `data-migrations`.
122
-
123
- Portable Bundles contain `manifest.json`, `sessions/`, `attachments/`, `checksums.json`, and `workspace-metadata.json`. Import defaults to dry-run and reports id conflicts, missing attachments, compression incompatibilities, required/free space, and recoverable files. Execution refuses a blocked plan, backs up each artifact, verifies the rewritten header, and isolates failures per session. Attachment files are preserved and copied into the imported session directory; the bundle report calls out any missing file.
124
-
125
- ## Move pipeline
126
-
127
- Order of operations for a cross-workspace move:
128
-
129
- 1. **Running check** — reject only mid-turn sessions (`agents.get(id)?.status === 'running'`); idle resident sessions may move.
130
- 2. **Read authority header** from disk; verify target ≠ source.
131
- 3. **Recovery snapshot** to `$DSH_HOME/workspace-mover/backups/` (up to `backupKeep` distinct physical files per session, default 20) **before any side effect**. The content fingerprint ignores the migration-only header `cwd`, so a move back and forth reuses the existing snapshot. A backup failure aborts with `backup-failed` and nothing has been touched.
132
- 4. **Preflight** — dual-accounting detect, target writability probe, disk-space hint.
133
- 5. **Rewrite first frame** (header cwd) only.
134
- 6. **Move directory** (Windows: exponential-backoff retry on EPERM; fallback copy+delete).
135
- 7. **In-memory closeout** — invalidate three registry indexes; for resident sessions clear stale persistence-coordinator write state; retarget live header `cwd` before official `attachSession`.
136
- 8. **Accounting** — source `detachSession` then target `attachSession`.
137
- 9. **Verify** — read archive back; id + cwd must match or the move fails.
138
- 10. **Rollback on any failure** — restore live header + index snapshot → original bytes back to source dir → reattach source workspace.
139
-
140
- Concurrency: same session / same target workspace try-acquire a lock. A running session returns `busy` to the transaction layer and is persisted in `migrations.json` as `waiting-for-idle`; the queue wakes every few seconds and on the next plugin start. Bulk moves de-duplicate first and isolate each result.
141
-
142
- If “rollback also failed”, a durable recovery record is written and shown as a red manual-recovery row. Session files and backups stay in place — never auto-deleted.
143
-
144
- ## Workspace repoint (move-home)
145
-
146
- Official workspace entity writes prune members using in-memory session cwd indexes. The wizard:
147
-
148
- 1. Audits path validity (`mover.ws.audit`).
149
- 2. Pre-seeds all affected sessions’ three indexes to the new path.
150
- 3. Rewrites the entity path through the unified `mutate` channel so members are not pruned.
151
- 4. Batch-migrates member sessions and strays from the old path through the same move pipeline.
152
- 5. Running sessions skip; an interrupted run resumes with only the remainder.
153
-
154
- If `mutate` is unavailable after a host upgrade, the wizard aborts before the first file change.
155
-
156
- ## Plugin-owned data
157
-
158
- All plugin metadata lives under `$DSH_HOME/workspace-mover/` and never overwrites official session archives in place except through the move pipeline.
159
-
160
- | Path | Use |
161
- | --- | --- |
162
- | `backups/` | Byte-level recovery snapshots; new files include a SHA-256 content fingerprint, while legacy timestamp-only names remain readable |
163
- | `history.json` | Last 100 cross-workspace moves |
164
- | trash / task / recovery files | Recycle bin manifest, task center, recovery ledger |
165
-
166
- Official session files and workspace registry are only mutated via host APIs: registry `mutate` / `attachSession` / `detachSession` and durable domain state.
167
-
168
- ## Compatibility boundary
169
-
170
- Verified (each entry dated; the newest verification supersedes the badge claims in the READMEs):
171
-
172
- - DeepSeek Harness `0.2.0-rc.2` (desktop, 2026-10-04) — see `docs/compatibility/dsh-0.2.0-rc.2-2026-10-04.md`
173
- - DeepSeek Harness `0.1.5-rc.1` / `0.1.5-rc.2` (earlier release line)
174
- - dsh-market `1.45.1`
175
- - Node.js `≥ 22`
176
- - `package.json` → `engines.dsh`: `>= 0.1.5-rc.1` (a lower bound only; it is deliberately not an upper bound, and dsh-market reads it for its host-requirement card)
177
-
178
- GitHub-only packages may show “host requirement unknown” in dsh-market when no npm manifest is published; that is metadata, not runtime incompatibility.
179
-
180
- Version-sensitive host behaviors degrade with explicit UI messages (not silent failure):
181
-
182
- - Unarchive needs the registry durable state channel.
183
- - Projection-cache titles parse defensively against v3; missing file falls back to archive header title.
184
- - Host upgrades that rename registry cache fields hit the degrade path (feature still usable; ownership refresh may need a restart).
185
-
186
- ## Coexistence notes (implementation)
187
-
188
- - CSS classes `wsm-*` and DOM attributes `data-wsm-*` are private namespaces.
189
- - Settings panel uses the official `settings.section` slot.
190
- - Sidebar redraw plugins that replace workspace DOM can break ARIA row lookup — features stop triggering; data is not damaged.
191
- - Do not install a second document-level drag interceptor for cross-workspace moves.
192
-
193
- ## Client-half invariants
194
-
195
- - **Every document-level listener and the menu `MutationObserver` is registered through the `listen()` helper and released in one `ctx.effect` teardown**, and `apply` is guarded by a module-level `applied` flag. The desktop client disposes and recomposes the plugin fiber on hot reload; without both, a second copy of every listener stacks up (duplicate confirm dialogs, duplicate RPC, two closures fighting over the same selection highlight).
196
- - **A row is only ever mapped to a session id by a verifiable credential.** The authoritative path walks the React fiber to `memoizedProps.node.id`; the fallback aligns DOM rows to `workspace.sessionIds` by exact/longest title match. A row that cannot be confidently mapped is reported, never substituted — the previous "take the next id in order" behavior could move a different, valid session that the host cannot detect.
197
- - **A workspace header is only mapped to a registry entity when the header text contains that entity's title or path** (or when no entity carries a display identity at all). `registry.list()` includes workspaces whose folder is gone while the sidebar hides them, so positional mapping is unsafe.
198
- - `rowSessionId`'s fiber guard is intentionally permissive; if the official node shape changes, the fallback engages and the user is told, rather than the feature silently doing nothing.
199
- - Dialogs are built by `injectOverlay()` and handed to `openDialog()`, which appends **before** focusing (an element not in the document cannot take focus in a browser), focuses the primary button, and traps Tab within the card.
200
-
201
- ## Tests & release
202
-
203
- - `npm test` runs `test/core.test.mjs`, `test/e2e-sandbox.test.mjs`, `test/policy.test.mjs`, `test/client-dom.test.mjs`, `test/traffic-badge.test.mjs` and `test/release-downloads.test.mjs`. `client-dom.test.mjs` drives the **client half** against a hand-rolled DOM stub (no devDependencies, so CI keeps running plain `node --test`); `policy.test.mjs` covers the tunable-policy layer and the guarded Config builder. Exact case count is reported by the test run — do not hard-code it here beyond the release it was written for.
204
- - Coverage includes rollback, rescue scan/repair, history undo, workspace repoint, post-move verification, recycle bin / backup restore, task center, data-protection cleanup, concurrency locks, error codes, storage-layout self-check, backup orphan GC, tunable policies, and the client half's row/workspace resolution, drag payload, listener teardown and dialog dismissal.
205
- - Adding a test file requires listing it in `package.json` → `scripts.test`; the script enumerates files explicitly, so a new file that is not listed never runs in CI.
206
- - CI: `.github/workflows/test.yml`.
207
- - Release assets may ship a `.tgz` install package (`dsh-workspace-mover-*.tgz`).
208
- - Traffic/clones and release-download badges are refreshed by `.github/workflows/traffic-badge.yml` via Gist JSON endpoints (not part of the runtime plugin).
209
-
210
- ## Related docs
211
-
212
- - User install/usage: [README.md](../README.md), [README_EN.md](../README_EN.md)
213
- - Full version history: [CHANGELOG.md](../CHANGELOG.md)
214
- - Security policy: [SECURITY.md](../SECURITY.md)
1
+ # Architecture
2
+
3
+ Technical reference for contributors and advanced users. Product-facing install/usage docs live in [README.md](../README.md) / [README_EN.md](../README_EN.md). This file is the **single source of truth** for implementation details; do not duplicate it into READMEs.
4
+
5
+ ## Plugin mount (DSH 0.1.5)
6
+
7
+ - Package is pure JavaScript source-as-product (no TypeScript, no build step).
8
+ - Host half mounts through a standard `insert` row in [`cordis.patch.yml`](../cordis.patch.yml).
9
+ - Client half is injected via `package.json` → `dsh.client` (`@deepseek-ai/dsh-client-connection`, `@deepseek-ai/dsh-client-ui-slots`, platform `web`).
10
+ - Official bundles are never rewritten; only event listeners and official slots are used.
11
+ - From GitHub, pnpm runs no install scripts, so `allowBuilds` is not required.
12
+
13
+ ## Host / Client split
14
+
15
+ | Half | Entry | Role |
16
+ | --- | --- | --- |
17
+ | Host | `lib/index.js` | RPC, disk archives, workspace registry, backups, trash, history, locks |
18
+ | Client | `client/client.js` | Sidebar drag intercept, multi-select, confirmation UI, rescue panel (settings slot) |
19
+
20
+ Client locates rows by ARIA semantics only:
21
+
22
+ - session rows: `[aria-selected]`
23
+ - workspace title rows: `[aria-expanded]`
24
+
25
+ It never depends on CSS-module hash class names. UI uses official design tokens (`--dsw-alias-*`) for theme adaptation.
26
+
27
+ Only cross-group drops are intercepted; official same-group reorder stays untouched. After a successful move the client actively refreshes and reconciles the official workspace projection; a page reload remains the user-facing fallback.
28
+
29
+ ## RPC channel
30
+
31
+ Single logical channel `/workspace-mover` via `ctx.connection.rpc.handle('/workspace-mover', …)`. No independent REST routes.
32
+
33
+ | Endpoint | Purpose |
34
+ | --- | --- |
35
+ | `mover.status` | Capability report (detected services / degraded items) plus `storageLayout`, `degradedFeatures`, and the `unarchiveApi` / `setTitleApi` flags |
36
+ | `mover.workspaces` | List workspaces |
37
+ | `mover.move` | Single-session true move |
38
+ | `mover.preview` | Read-only migration preview: source/target, collision, running state, adapter and estimated new snapshot bytes; `backup.reused` identifies an existing matching snapshot |
39
+ | `mover.moveMany` | Bulk move (≤ `batchLimit`, default 50) |
40
+ | `mover.scan` | Classified rescue scan (parses the newest `scanLimit`, default 400; reports `scanned` vs `scannedParsed`) |
41
+ | `mover.repair` | Attach / relink / home one item |
42
+ | `mover.repairAll` | Batch repair (per-item isolation) |
43
+ | `mover.history` | Recent moves (≤ `historyLimit`, default 100) |
44
+ | `mover.undo` | Undo one history entry |
45
+ | `mover.ws.audit` | Workspace path health |
46
+ | `mover.ws.create` | Register a new workspace (`name`, `directory`) over an existing folder; `conflict` when the name or the folder is already in use |
47
+ | `mover.ws.rename` | Rename one workspace (`workspaceId`, `name`, optional `dryRun`); display title only, `conflict` on a name another workspace holds |
48
+ | `mover.repoint` | Move-home wizard |
49
+ | `mover.archived` / `mover.unarchive` | List / restore archived sessions (`mover.archived` also reports `truncated` / `scannedParsed` / `scannedTotal` / `archivedTotal`) |
50
+ | `mover.openFolder` | Open group directory in OS file manager |
51
+ | `mover.session.delete` | Session → recycle bin; optional `purgeBackups: true` also deletes that session's migration backups |
52
+ | `mover.trash.list` / `mover.trash.restore` / `mover.trash.purge` | Recycle bin; `purge` accepts optional `purgeBackups: true` |
53
+ | `mover.backups.list` / `mover.backups.restore` / `mover.backups.deleteOne` | Recovery-snapshot management; `list` reports `orphan` per group, physical `count`, plus `orphanGroups` / `orphanBytes` |
54
+ | `mover.tasks.list` / `mover.tasks.retry` / `mover.tasks.forget` | Migration task center |
55
+ | `mover.migrations.enqueue` / `mover.migrations.list` / `mover.migrations.retry` / `mover.migrations.cancel` | Persistent busy-session queue; `retry` accepts one `taskId` or a `taskIds` batch, with per-item failure isolation |
56
+ | `mover.bundle.export` / `mover.bundle.inspect` / `mover.bundle.import` | Portable Bundle export, dry-run inspection, and import through the adapter/backup pipeline |
57
+ | `mover.data.cleanup` | Dry-run and clean aged plugin data (default age = `cleanupDays`) |
58
+
59
+ ### Tunable policies
60
+
61
+ `mover.data.cleanup`'s age and the caps above come from `resolvePolicy()` in `lib/index.js`, read **at the point of use** (never frozen at module load):
62
+
63
+ | Policy | Default | Environment override |
64
+ | --- | --- | --- |
65
+ | physical snapshot retention per session | 20 | `DSH_WORKSPACE_MOVER_BACKUP_KEEP` |
66
+ | move history length | 100 | `DSH_WORKSPACE_MOVER_HISTORY_LIMIT` |
67
+ | batch cap (move / repair) | 50 | `DSH_WORKSPACE_MOVER_BATCH_LIMIT` |
68
+ | repoint cap | 200 | `DSH_WORKSPACE_MOVER_REPOINT_LIMIT` |
69
+ | rescue scan cap | 400 | `DSH_WORKSPACE_MOVER_SCAN_LIMIT` |
70
+ | cleanup age (days) | 30 | `DSH_WORKSPACE_MOVER_CLEANUP_DAYS` |
71
+
72
+ An unparseable or out-of-range value falls back to the default, so a bad configuration can never disable a feature.
73
+
74
+ ### Dependency declaration, and why every peer is `*`
75
+
76
+ `package.json` declares the eight host packages this plugin integrates with as `peerDependencies`, all `*` and all `optional` in `peerDependenciesMeta`. They are **declarative metadata for storefronts**, not a compatibility gate: the plugin fail-softs at runtime and reports any real degradation through `mover.status` capabilities and `mover_doctor`. The real floor is `engines.dsh` (`>=0.1.5-rc.1`) plus `engines.node`.
77
+
78
+ This was not the first attempt. The ranges originally used the ecosystem's "explicit prerelease branch" idiom, e.g. `>=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0`, and the host then reported the plugin as **incompatible with `0.2.0-rc.2`** — its own tested host line. The mechanism, confirmed by running dsh-market's own range evaluator:
79
+
80
+ - npm's prerelease rule is evaluated at the **comparator-set level**: one `||` alternative is one set, and a prerelease version satisfies that set only when at least one comparator in it shares the version's exact `[major, minor, patch]` tuple *and* carries a prerelease tag; only then are all comparators checked normally.
81
+ - `<0.2.0-0` does share the `0.2.0` tuple and does carry a prerelease, so the set is admitted — but `0.2.0-rc.2 > 0.2.0-0` (the `-0` sentinel sorts below every real prerelease), so the bound then excludes **every** 0.2.0 prerelease.
82
+
83
+ Enumerating release lines reproduces the fault on each new line: a `<0.3.0-0` bound breaks the moment a 0.3 prerelease ships. Hence `*`. dsh-market filters peer declarations to `/^@deepseek-ai\/dsh(?:-|$)/`, which is also why `@deepseek-ai/cordis` and `@deepseek-ai/schemastery` were never named in the warning — they are only evaluated when `engines.dsh` mentions them, and it does not.
84
+
85
+ `test/policy.test.mjs` asserts that the declared ranges plus `engines.dsh` derive `compatible` — never `incompatible` — for DSH versions from `0.1.5-rc.1` through `1.0.0`.
86
+
87
+ **Official Config is deliberately not registered.** The schema would need `@deepseek-ai/schemastery`, which the host loader provides but which is not resolvable from this plugin's directory (verified by `createRequire` from `lib/index.js`); the npm `schemastery` has no `.volatile()` either. Because a bundle-layer static import failure breaks **boot** (a documented ecosystem failure class), `lib/config.js` never imports it — it exposes `buildConfig(z)` / `attachConfig(moduleOrBuilder)` so a host that hands over a schema builder can attach one, and exports `Config === undefined` otherwise. `buildConfig` contains all its own failures and returns `undefined` rather than throwing.
88
+
89
+ ### Official-API preference is observable
90
+
91
+ v2.2 prefers two documented host APIs and keeps an equivalent fallback for each: `registry.unarchiveSession()` (fallback: `enqueueOperation` + `setState`) and `entity.setTitle()` (formerly folded into `entity.mutate`). Both fallbacks are silent by design — the feature still works — so `mover.status.capabilities` reports `unarchiveApi` / `setTitleApi` and lists `unarchive-official-api` / `set-title-official-api` in `degradedFeatures` when the host lacks them. That is what makes "which path did it take?" answerable on a real machine instead of a guess.
92
+
93
+ Errors carry stable codes (`busy`, `conflict`, `not-found`, `rollback-failed`, …). Host logs write failures under `MOVE FAILED`.
94
+
95
+ ## Agent tools (DSH ≥ 0.1.5 with `dsh-tools`)
96
+
97
+ When the host provides the `tools` service, `apply()` registers six plain (zero-dependency) tool definitions; each tool's execute re-enters the internal RPC dispatch, so agent calls share the panel's locks, error codes, and transaction semantics:
98
+
99
+ | Tool | Gate | Purpose |
100
+ | --- | --- | --- |
101
+ | `mover_list_sessions` | none (read-only) | Compose scan + workspace list (sessions capped at 40, counts, ghosts, recoveryCount) for id resolution |
102
+ | `mover_move_session` | user approval | Move one session (`sessionId`, `targetWorkspaceId`, optional `sessionTitle`) |
103
+ | `mover_repair_sessions` | user approval | One `mover.repairAll` pass (accounting-only) |
104
+ | `mover_doctor` | none (read-only) | One `mover.doctor` self-check pass (host services, data dirs, recovery records, workspace paths) |
105
+ | `mover_create_workspace` | none | Register a workspace (`name`, `directory`) through `mover.ws.create` |
106
+ | `mover_rename_workspace` | user approval unless full access | Rename a workspace (`workspaceId`, `name`) through `mover.ws.rename` |
107
+
108
+ - Mutating tools call `approval.request({ agent, toolName, callId, reason, signal })` before acting; `allowed-once` proceeds, anything else (`denied` / `cancelled` / `unavailable`) or a throwing seam fails closed. Hosts without an approval seam refuse operations requiring approval and direct the user to the rescue panel. `callId` is the tool call's own id. The ACP answerer delegates any request without one, so ACP sessions could never approve before it was passed, and the Web prompt uses it to attach to the tool call it already shows.
109
+ - `mover_rename_workspace` skips the prompt when the calling session's effective sandbox mode, read from `ctx.sandboxPolicy.resolve({ session })`, is `danger-full-access` — the same test the official `plugin_manager` tool uses. The bypass is required, not only convenient: the shipped `danger-full-access` preset pairs that mode with approval policy `never`, under which every request resolves `rejected`. A missing policy service or a throwing `resolve` counts as *not* full access, so the tool asks. The tool validates through `dryRun` first, so an unknown id, a blank name, or a name conflict fails before the user is asked; renaming to the current title is a no-op that never prompts. The commit re-validates inside the serialized naming queue, so a conflict created while the prompt was open is still rejected. `mover_doctor` reports whether full-access detection is available under `host-sandbox-policy`.
110
+ - `mover_create_workspace` is ungated because it only adds a registry entry for an existing folder: it never creates folders or files, and a directory already used by a workspace is rejected. "Same folder" uses the host's own identity. The official `registry.create()` canonicalizes with `realpath` and returns the *existing* entity for an owned path, so a trailing slash, `..`, or a symlink alias counts as the same folder. The plugin treats "returned an entity that existed before the call" as `conflict` instead of re-implementing path comparison.
111
+ - Workspace names must be unique (exact match after trimming) for both create and rename. The host registry allows duplicate titles, but the official rename dialog refuses them; the plugin applies that rule to both tools. Check and write run in one serialized queue so two calls cannot claim the same name.
112
+ - `exec.signal` propagates into the RPC layer; cancellation lets the in-flight step finish rolling back to a consistent state.
113
+ - Hosts without `dsh-tools` skip registration silently; `mover.status` reports the `agentTools` flag.
114
+ - **Only `workspaceRegistry` and `sessionPersistence` are in `inject`.** Cordis treats every `inject` entry as required: a missing one leaves the whole plugin fiber inactive, which removes the rescue panel's RPC channel along with every tool. `tools` and `approval` were listed until 2.3.x, so a host without `dsh-user-approval` lost the plugin entirely, and the old missing-approval branch could never run. Now the tools register inside a nested `ctx.inject(['tools'], …)`, so they appear and disappear with `dsh-tools` and the `agentTools` flag follows. Missing approval now blocks operations that require confirmation. The approval service is read with `ctx.get('approval')` on each call, so installing or removing `dsh-user-approval` takes effect without re-mounting the plugin.
115
+
116
+ ## Session archives and storage adapter
117
+
118
+ - The fallback filesystem adapter recognizes names `session.jsonl[.zstd]` and `session.vN.jsonl[.zstd]`; it selects the highest generation without rewriting older files to v3.
119
+ - A mixed plain/Zstd directory and a file whose compression differs from the host's configured mode are rejected explicitly. Mixed compression elsewhere under the same root is diagnosed per session, not by a root-wide scan.
120
+ - v3/v4 filenames are recognized, but a v4 runtime fixture has not yet been verified. Filename/header mismatch is rejected only when the header contains one of the generation fields currently recognized by the adapter.
121
+ - True move keeps the session id and all frames; only the header `cwd` (first frame) is rewritten; other frames stay byte-identical. Publish via temp file + atomic rename.
122
+
123
+ ### The storage-layout assumption, and its self-check
124
+
125
+ The adapter first reports the public `list` / `stat` / `open` surface. When a provider also exposes a physical root, the plugin uses the legacy directory adapter as a visible `filesystem-fallback`; when only official handles are available it stays read/diagnostic capable and marks physical moves as `unsupported` instead of guessing a path.
126
+
127
+ `verifyStorageLayout(ctx)` closes that gap: it takes up to five real headers from `persistence.list()`, reverse-derives each session's directory with this plugin's own encoding, and confirms an archive is actually there. Result states are `ok` / `degraded` / `empty` (no sessions to verify) / `unavailable`. It is exposed as `mover.status.capabilities.storageLayout` plus a `data-layout` check in `mover_doctor`, is strictly read-only, and never throws (any error degrades to a warning).
128
+
129
+ `mover.status.capabilities.storageAdapter` is `filesystem-fallback`, `official-handle`, or `unavailable`; `storageApi`, `physicalPath`, and `migrationQueueState` expose the exact degradation and queue state. `mover_doctor` reports the same adapter under `storage-adapter` and checks the durable migration records under `data-migrations`.
130
+
131
+ Portable Bundles contain `manifest.json`, `sessions/`, `attachments/`, `checksums.json`, and `workspace-metadata.json`. Import defaults to dry-run and reports id conflicts, missing attachments, compression incompatibilities, required/free space, and recoverable files. Execution refuses a blocked plan, backs up each artifact, verifies the rewritten header, and isolates failures per session. Attachment files are preserved and copied into the imported session directory; the bundle report calls out any missing file.
132
+
133
+ ## Move pipeline
134
+
135
+ Order of operations for a cross-workspace move:
136
+
137
+ 1. **Running check** — queue running or resident sessions; a resident backend writer cannot be safely rebound through the current public API. Await backend `flush()` when available, then recheck residency before reading the archive.
138
+ 2. **Read authority header** from disk; verify target ≠ source.
139
+ 3. **Recovery snapshot** to `$DSH_HOME/workspace-mover/backups/` (up to `backupKeep` distinct physical files per session, default 20) **before any side effect**. The content fingerprint ignores the migration-only header `cwd`, so a move back and forth reuses the existing snapshot. A backup failure aborts with `backup-failed` and nothing has been touched.
140
+ 4. **Preflight** — dual-accounting detect, target writability probe, disk-space hint.
141
+ 5. **Rewrite header cwd**; if an existing `working-directory/change` would override it, append a new event with the next contiguous sequence number. Preserve original events. Reject unsupported generations or invalid sequences before detach.
142
+ 6. **Move directory** (Windows: exponential-backoff retry on EPERM; fallback copy+delete).
143
+ 7. **In-memory closeout** — invalidate three registry indexes before official `attachSession`; never change private writer state or live session headers. Clear the cached `workingDirectory` row so it rebuilds from the migrated log.
144
+ 8. **Accounting** — source `detachSession` then target `attachSession`.
145
+ 9. **Verify** — read archive back; id + cwd must match or the move fails.
146
+ 10. **Rollback on any failure** — restore original bytes back to source dir and reattach the source workspace.
147
+
148
+ Concurrency: same session / same target workspace try-acquire a lock. A running or resident session returns `busy` to the transaction layer and is persisted in `migrations.json` as `waiting-for-idle`; the queue wakes every few seconds and on the next plugin start. Idle alone does not release a resident writer: wait for unloading or restart. Workspace repoint checks residency before changing the workspace path. Bulk moves de-duplicate first and isolate each result.
149
+
150
+ If “rollback also failed”, a durable recovery record is written and shown as a red manual-recovery row. Session files and backups stay in place — never auto-deleted.
151
+
152
+ ## Workspace repoint (move-home)
153
+
154
+ Official workspace entity writes prune members using in-memory session cwd indexes. The wizard:
155
+
156
+ 1. Audits path validity (`mover.ws.audit`).
157
+ 2. Pre-seeds all affected sessions’ three indexes to the new path.
158
+ 3. Rewrites the entity path through the unified `mutate` channel so members are not pruned.
159
+ 4. Batch-migrates member sessions and strays from the old path through the same move pipeline.
160
+ 5. Running sessions skip; an interrupted run resumes with only the remainder.
161
+
162
+ If `mutate` is unavailable after a host upgrade, the wizard aborts before the first file change.
163
+
164
+ ## Plugin-owned data
165
+
166
+ All plugin metadata lives under `$DSH_HOME/workspace-mover/` and never overwrites official session archives in place except through the move pipeline.
167
+
168
+ | Path | Use |
169
+ | --- | --- |
170
+ | `backups/` | Byte-level recovery snapshots; new files include a SHA-256 content fingerprint, while legacy timestamp-only names remain readable |
171
+ | `history.json` | Last 100 cross-workspace moves |
172
+ | trash / task / recovery files | Recycle bin manifest, task center, recovery ledger |
173
+
174
+ Official session files and workspace registry are only mutated via host APIs: registry `mutate` / `attachSession` / `detachSession` and durable domain state.
175
+
176
+ ## Compatibility boundary
177
+
178
+ Verified (each entry dated; the newest verification supersedes the badge claims in the READMEs):
179
+
180
+ - DeepSeek Harness `0.2.0-rc.2` (desktop, 2026-10-04) — see `docs/compatibility/dsh-0.2.0-rc.2-2026-10-04.md`
181
+ - DeepSeek Harness `0.1.5-rc.1` / `0.1.5-rc.2` (earlier release line)
182
+ - dsh-market `1.45.1`
183
+ - Node.js `≥ 22`
184
+ - `package.json` → `engines.dsh`: `>= 0.1.5-rc.1` (a lower bound only; it is deliberately not an upper bound, and dsh-market reads it for its host-requirement card)
185
+
186
+ GitHub-only packages may show “host requirement unknown” in dsh-market when no npm manifest is published; that is metadata, not runtime incompatibility.
187
+
188
+ Version-sensitive host behaviors degrade with explicit UI messages (not silent failure):
189
+
190
+ - Unarchive needs the registry durable state channel.
191
+ - Projection-cache titles parse defensively against v3; missing file falls back to archive header title.
192
+ - Host upgrades that rename registry cache fields hit the degrade path (feature still usable; ownership refresh may need a restart).
193
+
194
+ ## Coexistence notes (implementation)
195
+
196
+ - CSS classes `wsm-*` and DOM attributes `data-wsm-*` are private namespaces.
197
+ - Settings panel uses the official `settings.section` slot.
198
+ - Sidebar redraw plugins that replace workspace DOM can break ARIA row lookup — features stop triggering; data is not damaged.
199
+ - Do not install a second document-level drag interceptor for cross-workspace moves.
200
+
201
+ ## Client-half invariants
202
+
203
+ - **Every document-level listener and the menu `MutationObserver` is registered through the `listen()` helper and released in one `ctx.effect` teardown**, and `apply` is guarded by a module-level `applied` flag. The desktop client disposes and recomposes the plugin fiber on hot reload; without both, a second copy of every listener stacks up (duplicate confirm dialogs, duplicate RPC, two closures fighting over the same selection highlight).
204
+ - **A row is only ever mapped to a session id by a verifiable credential.** The authoritative path walks the React fiber to `memoizedProps.node.id`; the fallback aligns DOM rows to `workspace.sessionIds` by exact/longest title match. A row that cannot be confidently mapped is reported, never substituted — the previous "take the next id in order" behavior could move a different, valid session that the host cannot detect.
205
+ - **A workspace header is only mapped to a registry entity when the header text contains that entity's title or path** (or when no entity carries a display identity at all). `registry.list()` includes workspaces whose folder is gone while the sidebar hides them, so positional mapping is unsafe.
206
+ - `rowSessionId`'s fiber guard is intentionally permissive; if the official node shape changes, the fallback engages and the user is told, rather than the feature silently doing nothing.
207
+ - Dialogs are built by `injectOverlay()` and handed to `openDialog()`, which appends **before** focusing (an element not in the document cannot take focus in a browser), focuses the primary button, and traps Tab within the card.
208
+
209
+ ## Tests & release
210
+
211
+ - `npm test` runs `test/core.test.mjs`, `test/e2e-sandbox.test.mjs`, `test/policy.test.mjs`, `test/client-dom.test.mjs`, `test/traffic-badge.test.mjs` and `test/release-downloads.test.mjs`. `client-dom.test.mjs` drives the **client half** against a hand-rolled DOM stub (no devDependencies, so CI keeps running plain `node --test`); `policy.test.mjs` covers the tunable-policy layer and the guarded Config builder. Exact case count is reported by the test run — do not hard-code it here beyond the release it was written for.
212
+ - Coverage includes rollback, rescue scan/repair, history undo, workspace repoint, post-move verification, recycle bin / backup restore, task center, data-protection cleanup, concurrency locks, error codes, storage-layout self-check, backup orphan GC, tunable policies, and the client half's row/workspace resolution, drag payload, listener teardown and dialog dismissal.
213
+ - Adding a test file requires listing it in `package.json` → `scripts.test`; the script enumerates files explicitly, so a new file that is not listed never runs in CI.
214
+ - CI: `.github/workflows/test.yml`.
215
+ - Release assets may ship a `.tgz` install package (`dsh-workspace-mover-*.tgz`).
216
+ - Traffic/clones and release-download badges are refreshed by `.github/workflows/traffic-badge.yml` via Gist JSON endpoints (not part of the runtime plugin).
217
+
218
+ ## Related docs
219
+
220
+ - User install/usage: [README.md](../README.md), [README_EN.md](../README_EN.md)
221
+ - Full version history: [CHANGELOG.md](../CHANGELOG.md)
222
+ - Security policy: [SECURITY.md](../SECURITY.md)
223
+
224
+ Backup restore derives the filename generation from the official header `version` (legacy headers without a generation use `session.jsonl`); it never defaults v4 content to a v3 filename. Portable Bundle manifests read the plugin version from `package.json`.
@@ -0,0 +1,37 @@
1
+ # 2026-10-18 compatibility fixes (unreleased)
2
+
3
+ - Plugin baseline: 2.3.0; package version unchanged. No GitHub push or npm publication.
4
+ - Source review: official DSH `dsh-v0.2.1-rc.2`; compared JSONL writer lifecycle with `dsh-v0.2.0-rc.2`.
5
+ - Environment: Windows, Node v22.23.2 local sandbox. Desktop/Web runtime acceptance is pending.
6
+ - Fixtures: constructed JSONL records using the official v4 header `version` and event envelope (`type`, `seq`, `time`, `data`). These are sandbox fixtures, not a captured official runtime session.
7
+
8
+ ## Changed behavior
9
+
10
+ 1. Resident sessions, including idle sessions, wait for unloading before physical migration. Current DSH exposes no public writer rebind API. The plugin no longer replaces live headers or clears private coordinator state. Workspace repoint refuses resident sessions before changing the registry path.
11
+ 2. Cold migration awaits backend `flush()` when available, then checks residency and running state again. A flush failure aborts before backup/detach.
12
+ 3. Migration sets the effective working directory to the target workspace. Original working-directory events remain intact; a final `working-directory/change` is appended when needed. Its sequence must follow a contiguous v3/v4 log. Unrecognized formats or invalid sequences abort before detach. The working-directory checkpoint is invalidated so replay sees the new directory.
13
+ 4. Backup restore uses the official header version for v3/v4 filenames. Headers without generation metadata restore as unversioned legacy JSONL. Plain and Zstd are both covered.
14
+ 5. Bundle manifests take `pluginVersion` from package.json.
15
+
16
+ Working-directory events are actual log changes, so sessions with such changes can need new recovery snapshots on subsequent moves. Existing snapshot retention limits still apply. Sessions whose content only changes in header cwd continue to reuse a snapshot.
17
+
18
+ ## Local verification
19
+
20
+ The regression suite covers resident queueing and retry after unloading, a session loaded during flush, flush failure, snapshots including flushed data, repoint refusal without registry mutation, working-directory replay and byte-exact attach-failure rollback, plain/Zstd backup restore for legacy/v3/v4, and the exported plugin version. Full suite: 180 tests passed, 0 failed (Node v22.23.2). Syntax checks passed for lib/index.js and client/client.js. npm pack --dry-run --json passed (29 packaged files); no archive was published. git diff --check passed.
21
+
22
+ ## Desktop acceptance checklist
23
+
24
+ Use disposable sessions and two test workspaces A and B. Test after loading this local source; the published npm version does not contain these changes.
25
+
26
+ 1. Open a session in A, finish its current turn, then request migration to B. Expect a waiting task, no directory move, and a message about its resident writer. Merely becoming idle must not bypass the guard.
27
+ 2. Close/unload that session, or restart DSH without reopening it. The durable migration task should finish. Reopen it in B and send a message. Confirm new events appear under B, with no new archive in A. Restart once more and confirm the session still loads.
28
+ 3. On DSH 0.2.1-rc.2, change a disposable session's working directory before migration. Unload it, migrate A to B, then reopen it. The effective working directory should be B; original directory-change history should remain, followed by a change to B. Test the return move as well.
29
+ 4. Create a recovery snapshot for a disposable v4 session, remove that test session through the rescue UI, then restore its backup. Expect `session.v4.jsonl[.zstd]`, successful reopening, and no generation mismatch in doctor.
30
+ 5. Try workspace repoint while an affected session is resident. Expect refusal and the original registry path to remain. Unload the session and retry.
31
+
32
+ ## Remaining validation
33
+
34
+ - Actual DSH Desktop and Web runtime: not run in this audit.
35
+ - Official runtime v4 session replay and continued writing: pending user acceptance.
36
+ - Node 22/24 matrix: local runtime only; CI has not run for these uncommitted changes.
37
+ - Host session loading concurrent with migration is not coordinated by a public host lifecycle lock. Plugin locks serialize plugin operations; residency is checked again before file replacement. Keep the test session closed while its queued move completes.