dsh-wsl-workspace 0.7.5 → 0.7.7

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +761 -0
  2. package/CHANGELOG.zh.md +279 -0
  3. package/README.de.md +18 -4
  4. package/README.es.md +18 -4
  5. package/README.fr.md +18 -4
  6. package/README.ja.md +18 -4
  7. package/README.ko.md +18 -4
  8. package/README.md +8 -330
  9. package/README.pt.md +18 -4
  10. package/README.ru.md +18 -4
  11. package/README.zh.md +8 -123
  12. package/TESTING.md +139 -16
  13. package/lib/client.js +1422 -1048
  14. package/lib/client.js.map +1 -1
  15. package/lib/fs.js +12 -16
  16. package/lib/fs.js.map +1 -1
  17. package/lib/index.js +700 -147
  18. package/lib/index.js.map +1 -1
  19. package/lib/{links-DQ8GgpMj.js → links-CHu17hHQ.js} +8 -11
  20. package/lib/links-CHu17hHQ.js.map +1 -0
  21. package/lib/paths-CkIGMcuV.js +133 -0
  22. package/lib/paths-CkIGMcuV.js.map +1 -0
  23. package/lib/shell.js +149 -142
  24. package/lib/shell.js.map +1 -1
  25. package/lib/wsl-DN2-D6tr.js +231 -0
  26. package/lib/wsl-DN2-D6tr.js.map +1 -0
  27. package/lib/wsl-bash-tool-DAjyus4L.js +1923 -0
  28. package/lib/wsl-bash-tool-DAjyus4L.js.map +1 -0
  29. package/lib/wsl-bash-tool.js +2 -0
  30. package/lib/{wsl-credentials-gFHTqaiN.js → wsl-credentials-DzKgEzy7.js} +30 -5
  31. package/lib/wsl-credentials-DzKgEzy7.js.map +1 -0
  32. package/lib/wsl-env-sWOHjH2G.js +118 -0
  33. package/lib/wsl-env-sWOHjH2G.js.map +1 -0
  34. package/lib/wsl-jobs.js +73 -38
  35. package/lib/wsl-jobs.js.map +1 -1
  36. package/lib/wsl-relay.js +99 -15
  37. package/lib/wsl-relay.js.map +1 -1
  38. package/lib/wsl-sandbox.js +22 -4
  39. package/lib/wsl-sandbox.js.map +1 -1
  40. package/lib/wsl-search.js +56 -20
  41. package/lib/wsl-search.js.map +1 -1
  42. package/lib/wsl-terminal-tool.js +260 -0
  43. package/lib/wsl-terminal-tool.js.map +1 -0
  44. package/package.json +63 -23
  45. package/image-2.png +0 -0
  46. package/image-3.png +0 -0
  47. package/lib/links-DQ8GgpMj.js.map +0 -1
  48. package/lib/wsl-BTA7XT8X.js +0 -308
  49. package/lib/wsl-BTA7XT8X.js.map +0 -1
  50. package/lib/wsl-credentials-gFHTqaiN.js.map +0 -1
  51. package/src/client/AddWslWorkspace.tsx +0 -375
  52. package/src/client/api.ts +0 -145
  53. package/src/client/help.tsx +0 -99
  54. package/src/client/index.ts +0 -361
  55. package/src/client/locales.ts +0 -100
  56. package/src/client/styles.ts +0 -356
  57. package/src/fs.ts +0 -451
  58. package/src/host/variants.ts +0 -479
  59. package/src/host/wsl-jobs.ts +0 -270
  60. package/src/host/wsl-relay.ts +0 -89
  61. package/src/host/wsl-sandbox.ts +0 -47
  62. package/src/host/wsl-search.ts +0 -1166
  63. package/src/host/wsl-skills.ts +0 -821
  64. package/src/index.ts +0 -897
  65. package/src/shared/links.ts +0 -99
  66. package/src/shared/paths.ts +0 -175
  67. package/src/shared/relay-node.ts +0 -292
  68. package/src/shared/wsl-credentials.ts +0 -135
  69. package/src/shared/wsl.ts +0 -189
  70. package/src/shell.ts +0 -451
package/CHANGELOG.md ADDED
@@ -0,0 +1,761 @@
1
+ # Changelog
2
+
3
+ All notable changes to `dsh-wsl-workspace`, newest first. Back to the [README](README.md); the Chinese record is [CHANGELOG.zh.md](CHANGELOG.zh.md).
4
+
5
+ ## 0.7.7 — 2026-10-07
6
+ - **What a user installs is now the plugin, not a copy of the repository.** `package.json#files` shipped
7
+ `src/` and both README screenshots: a fresh `npm pack` of the previous tree is **2,757,545 B across 79
8
+ members**, of which 2,265,091 B (82.1%) was the 30 source files and the 2 images — neither of which
9
+ anything at runtime reads (the plugin loads `lib/`; the source-reading gates read the repository). The
10
+ same pack is now **492,454 B across 47 members**, and the only difference between the two member lists
11
+ is the 32 removed entries. The nine READMEs point their screenshots at absolute
12
+ `raw.githubusercontent.com/…/HEAD/…` URLs in the same commit, so the npm page still renders without the
13
+ bytes in the tarball — chosen deliberately over relying on the registry rewriting a relative path.
14
+
15
+ - **The deliberate-red ledger is empty, and the ten debts it held are paid.** Every entry was a
16
+ reproduction with a stated repair; all ten are now fixed, and the mechanism outlived them — a NEW red
17
+ nobody declared still fails the gate.
18
+
19
+ - **`check` folded every `stat` failure into `{exists:false}`** (issue #44 §6). A directory nobody can
20
+ read, or an `EIO` over 9P, was reported as *not existing* — the one thing a caller cannot tell apart
21
+ from the truth. Only `ENOENT` answers `exists:false` now; everything else is thrown, and the envelope
22
+ carries the OS's own sentence.
23
+ - **The decode policy was "does this buffer contain a NUL, then it is UTF-16LE"**, so a NUL-delimited
24
+ UTF-8 stream (`find -print0`, `grep -Z`, `git ls-files -z` — which this plugin's own search path
25
+ speaks) came back as mojibake at double length, silently. The sniff is now structural: a BOM, or NULs
26
+ at a regular parity and a real density. A caller that knows its encoding passes it and skips the
27
+ heuristic entirely.
28
+ - **`reg.exe`'s answer was decoded with a hard-coded UTF-8 conversion** while the same module owned that
29
+ decoder, so a UTF-16LE answer yielded `undefined` — an empty picker, no throw, no log. One decode
30
+ policy now serves every captured stream.
31
+ - **The search path decoded stderr at the capture point**, which put a NUL between every character of a
32
+ failure detail, counted the 300-character budget in bytes, and made the invalid-pattern classifier
33
+ unable to ever match. `WslRun.stderr` is bytes; the decode happens where the text is used, the budget
34
+ cuts characters, and the classifier reads decoded text.
35
+ - **A launch failure dropped the reason `wsl.exe` gave** (which distribution is missing) because the
36
+ detail came only from stderr. It now falls back to the first decoded line of stdout.
37
+ - **Three call sites handed an argument list to a command interpreter.** `verify-install.mjs`,
38
+ `verify-artifact-identity.mjs` and `ci/install-pinned.mjs` all fell back to a shell on Windows when
39
+ `npm_execpath` was absent — the shape Node now refuses (DEP0190) and that re-tokenises a spaced path
40
+ and passes `& | > < ^ %` to the interpreter. npm's own `bin/npm-cli.js` is now looked for in three places —
41
+ `npm_execpath`, beside `node.exe`, and through the `npm` on `PATH` — and a missing one is named
42
+ rather than guessed. The third was added because the second is not true on a hosted tool cache,
43
+ where a job that was never broken died with "npm's entry script was not found".
44
+ - **`install-pinned.mjs` created junctions through a shell with its output discarded**, which threw away
45
+ the linker's sentence and left the operator a bare exit number. `fs.symlinkSync` is the same junction
46
+ and arrives as an Error carrying `EEXIST`/`EPERM`.
47
+
48
+ Hazards A, B and E were **measurements of Node and the OS**, not of this repository: each test
49
+ reproduced a shape and watched the information disappear, so fixing the product left them red. They are
50
+ now assertions about this repository's own call sites and fail if a site returns; the measurements stay
51
+ above them as the reason those rules read the way they do. `tests/deliberate-reds.test.mjs` now
52
+ exercises the ledger mechanism against its own synthetic entries, because a control that reads the real
53
+ array stops covering the gate the moment the last debt is closed.
54
+
55
+ - **`bash_background`'s `job_output` was always empty on DSH 0.2.x (issue #56).** `bash_background` (and `bash`'s `run_in_background: true`, which shares the same producer) started the job and `job_list` tracked it, but `job_output` returned `(no new output)` on every read — during the run, after completion, and the completion notice carried no body. The command *did* run and its stdout landed on disk, so the read channel was dead, not the task.
56
+
57
+ The root cause is a registry contract change between release lines. `@deepseek-ai/dsh-jobs-local@0.2.x` fills the job's output ring **only** from the `spec.output` pull-source array (`start()` drains each source's `read(from)` on later ticks); it never inspects the `readOutput` that `run()` returns. This plugin's producer, however, only set `readOutput` (the 0.1.x contract), so `spec.output` was empty, the pump was never armed, and the ring stayed empty. The fake registry the unit tests used implemented the old `run()`-`readOutput` semantics, so the suite stayed green while a real 0.2.0-rc.2 host went red.
58
+
59
+ The producer now feeds **both** channels, and each release line reads the one it understands: a `spec.output` array of two pull-sources — `stdout` and a `stderr`-tagged source — each reading from the shell process's own per-stream `observed` readers (the same independent cursors `readOutput` already drained, so the two never steal bytes from each other), plus the `run()`-returned `readOutput` kept for the 0.1.x registry. `spillPath` flows through unchanged, so an overflowed stream still reports its spill file, and `stderr` stays on its own lane via the channel tag.
60
+
61
+ A unit gate now mirrors the 0.2.x drain loop against the produced `output` sources and would have failed on the old producer (no sources, or none yielding the bytes), so this regression cannot silently return.
62
+
63
+
64
+ - **Every `bash` call in a WSL workspace failed on DSH Desktop 0.2.x (issue #51), and the fix is
65
+ three separate things.** (1) *The shell seam*: 0.2.x calls `ctx.shell.execute(spec)` and awaits
66
+ `result()`, while this plugin implemented the 0.1.x `resolve`/`run`/`start` — so **both** the
67
+ persistent path and the one-shot fallback were dead, not one of them. One spawn primitive now
68
+ serves all three faces, with `onExpiry` and `observed` filled in; the CI gate that hid it was the
69
+ typecheck **error-count** budget (the `TS2515` line was in the output the whole time), which now
70
+ reddens on banned codes at any count, self-tests itself, and may only ever be lowered
71
+ (212 → 209). (2) *The readiness contract*: the host injects `PS1`/`PROMPT_COMMAND` as Windows
72
+ variables, which WSL drops unless `WSLENV` names them, so the persistent shell came up with the
73
+ distribution's own prompt and the host could not recognise it — one bridge in the relay, proven on
74
+ a real ConPTY (before: default prompt; after: `prompt=[dsh> ]` with the `133;D;` marker), and the
75
+ contract values now have a parity gate against the installed host instead of a hand copy.
76
+ (3) *The persistent shell itself*: the host's PTY tool decides "finished" by matching a sentinel
77
+ line in terminal text and requiring the exit code to be followed immediately by a newline, and an
78
+ interactive Linux shell repaints with `ESC[<n>X`, filling the cells after the sentinel with
79
+ spaces — measured in a real Desktop session as **three calls hanging 303.8 s each** before the
80
+ host wiped the shell. The world now mounts this plugin's own `bash`: one long-lived
81
+ `bash --norc -i` over **pipes**, completing on a NUL-delimited record carrying a per-command
82
+ nonce, with the login environment sourced silently, history expansion off (which also closes the
83
+ `!` class of failures for this path), a pseudo-terminal of its own for a command caught waiting for
84
+ input (`script -qec`, decided by reading the process rather than its name — see the bullet on the
85
+ reading; `tty: true` asks for one up front), and a session rebuild that replays `cd` and exported
86
+ variables when a call wedges
87
+ *without* executing a merely-slow command a second time.
88
+ Measured on this machine, both build planes: first call ~0.5 s including boot, then a **median of 8 ms**
89
+ (measured 2026-10-06, once the answer stopped waiting for the state record — the same command through
90
+ one `wsl.exe` per call is 222-236 ms, and a person typing into an open terminal sees 16 ms), with
91
+ `cd`/`export`/exit codes/CJK/`!`/`sed -i`/`tar`/`git commit`/`sudo` all verified, while the same
92
+ command through the PTY tier still times out — that control is the last cell of the new
93
+ `bash-session-real` gate, so the replaced behaviour cannot silently come back. Verified end to end
94
+ through **real host tool dispatch** (the host's own `bash` tool calling our executor, in
95
+ `tool-bash-real`), and `DSH_WSL_PTY_SHELL=1` keeps the old tier reachable. **Clicked inside DSH
96
+ Desktop with this build installed**, and the clicks found two defects the offline gates could not
97
+ see — the host's plugin loader unwraps `exports.default`, so a module-level `inject` never reaches
98
+ the fiber and every call failed `cannot get property "subprocess" without inject`; and the shell's
99
+ echo of a frame reaches stderr as only its *tail*, so a fragment of our own framing was in the
100
+ model's body on every call. Both are fixed and both are now gated by cells that enter through the
101
+ channel the product uses.
102
+
103
+ ## 0.7.6 — 2026-10-01
104
+ - **A second pass made the session `bash` answer like the host's `bash`, field by field.** A matrix of
105
+ 34 everyday commands found four behaviours that were not everyday-good, and all four are fixed:
106
+ `run_in_background: true` was an argument silently ignored (it now delegates to the same jobs
107
+ producer `bash_background` uses); a relative `workdir` resolved to nothing and ran wherever the last
108
+ call had left the shell (it now joins onto the session directory and lets bash's own `cd` error
109
+ speak, as the host's `resolveWorkdir` does); a stream larger than the cap lost its head with only a
110
+ boolean to say so (it now spills to a file and says `[output truncated; full output: …]`, the same
111
+ sentence and the same directory as the one-shot path, one file per command); and a rebuild left
112
+ detached children running (it now reaps exactly the processes carrying its own session token, with a
113
+ control process that must survive). The journal replays shell options, `shopt`s, aliases and
114
+ functions as well as `cd` and exports — chunked into separate frames, because `eval` parses its
115
+ whole string before running any of it, so a `shopt` on line 50 cannot rescue a completion function
116
+ on line 1623 that needs extglob to be *parsed*. What differs is now a table two gates read:
117
+ `tests/wsl-bash-parity.test.ts` against the installed host package, and
118
+ `scripts/compatibility/bash-parity-real.mjs` running one probe script through both tools on a real
119
+ distribution — an undeclared difference, or a declared one that stopped being true, fails the build
120
+ ([docs/bash-parity.md](docs/bash-parity.md)). Footprint measured: ~9.1 MB of Windows working set per
121
+ session across two `wsl.exe` processes, 3.4 MB inside the distribution, `vmmem` unchanged, no runtime
122
+ files until a stream overflows.
123
+
124
+ - **An agent's shell now ends with the agent.** The session `bash` registered its cleanup on the
125
+ plugin's scope only, so every agent that had ever called `bash` left a shell behind — two `wsl.exe`
126
+ and about 9 MB of Windows working set — until the whole world was disposed. The cell that found it
127
+ drives a second agent through the same registered tool and asks what happens when one of them stops.
128
+ The other isolation boundaries it now pins were already true and had never been tested: a second
129
+ agent does not inherit the first one's directory, exports or aliases; two calls issued at once each
130
+ settle with their own answer; and a rebuild's process sweep stops only the processes carrying its
131
+ own session token, so one agent's wedged shell cannot reap another's work. `sudo` also says what it
132
+ needs: where the distribution asks for a password, the reply now adds that this shell has nobody to
133
+ type it and names the two ways out, instead of leaving sudo's own line for the model to retry. And an
134
+ alias used on the line that defines it answers 127 here exactly as it does in `bash -ic` on the same
135
+ distribution — measured, and recorded as bash's rule rather than ours.
136
+
137
+ - **Three seams in the pseudo-terminal tier, found by driving it rather than reasoning about it.** An
138
+ emphasising program on a terminal it cannot colour writes overstrike, and a measured `man` page came
139
+ back as `N\bNA\bAM\bME\bE` — unreadable where a person at a real terminal reads `NAME`; the fold now
140
+ resolves the two shapes this distribution was measured emitting (`X\bX`, `_\bX`) the way a terminal
141
+ does. The escalation decision read only the command's first word, so `bash -c 'sudo true'` — a shape
142
+ models write constantly — sat to its deadline returning `(no output)` plus a session rebuild where
143
+ bare `sudo true` answers in 46 ms; one layer of a `bash`/`sh`/`zsh`/`dash` `-c` wrapper is now read,
144
+ and a wrapper whose contents cannot be read is left alone rather than escalated on a guess. And
145
+ `tty: false` did nothing: with only `true` honoured, `man ls` came back through the pty with no way
146
+ back to the plain pipe, so `false` is a veto now. What is *not* fixed, because it is what a terminal
147
+ is: an escalated call has one stream, so the `[stderr]` section the plain path writes cannot appear
148
+ there (the host's own PTY tier is the same — it is a row in [docs/bash-parity.md](docs/bash-parity.md)
149
+ with a probe behind it), and a program on a terminal that nothing can type into used to cost its
150
+ deadline plus a rebuild — measured 5.7-6.1 s with a screen dump — because the frame gives a pty
151
+ `/dev/null` for input rather than letting the program eat the next command's bytes; that case is what
152
+ the reading below now ends early.
153
+
154
+ - **A command waiting for a keyboard is diagnosed by reading the distribution, not by matching its
155
+ name — and it gets an answer instead of a deadline.** Nothing in this release ships a list of command
156
+ names. The path here was: one whitelist of first words → three "classes" (credential / keyboard /
157
+ pager) with an 8-second ceiling on one of them → all of it deleted in favour of a second look taken
158
+ from outside the shell while the call is in flight. Each step is kept in the record because each was
159
+ paid for by a measurement: the whitelist stopped at a hyphen so `ssh-copy-id`/`ssh-keygen`/`redis-cli`
160
+ could never match it; reading only the first word cost **121 703 ms** in a real session, where the
161
+ model sent `{"command":"printf x; vim note.txt","timeoutMs":15000}` and got the screen's raw escapes
162
+ after the two-minute default had run out; honouring a named deadline bought nothing on a wait that can
163
+ never be satisfied, since an agent reads a 16 889 ms silence as "this environment is slow" and
164
+ reprices every later call — while `tar -cf /dev/null /usr` legitimately takes 15 s and `vim -es` over
165
+ **1 000 000 lines** takes 1 s, so any rule that stops a quiet command must be able to tell those apart.
166
+ What ships reads, per sample (200–280 ms; first look at 600 ms of silence, then every 400 ms for the
167
+ first few looks and every 2 s after that): the process state,
168
+ whether the job owns the terminal's foreground group, `/proc/<pid>/wchan`, whether a terminal is among
169
+ the process's descriptors, and whether its CPU is advancing (`/proc/<pid>/schedstat`). `sleep`, a
170
+ network wait and a build are each excluded by a different one of those columns; a program asleep in a
171
+ terminal read is not. It then stops the wait — `SIGCONT` first, because a stopped process ignores
172
+ `SIGTERM` — and runs the command once more on a pseudo-terminal inside the same call, where the
173
+ keyboard read meets end-of-file and the program prints its own complaint. Measured on the real
174
+ session (`D:\Temp\issue51-s0\v5-run.txt`): `sh -c 'read x < /dev/tty'` — a program called `sh`, in no
175
+ list that ever existed — stopped at 628 ms and answered at 1 760 ms; `sleep 4` was untouched;
176
+ `sudo` asking for a password, which hides its `/proc` entries by clearing its dumpable flag, is
177
+ read through the distribution's root rights — a second pass of the same probe, run as root, sees the
178
+ wait its own user cannot; it reads `/proc` and nothing else, runs at most once per call, and a
179
+ distribution where root cannot be used falls back to the unconfirmable reading and says so — and came
180
+ back with sudo's own words at 2 412 ms; a 60 000 ms deadline asked
181
+ of a keyboard wait returned in 1 733 ms. Two things are said out
182
+ loud rather than done quietly: **the command has now run twice** ("anything it had already done before
183
+ that prompt has now been done twice"), because an unannounced second execution is the defect this
184
+ ticket already caught once; and if the `/proc` walk never answers, the body says the check could not
185
+ run instead of leaving a silent deadline — a premise that has gone away is not allowed to look like a
186
+ verdict. And when the walk does answer but this kernel reports no sleep location — which is what the
187
+ WSL1 runner does, `wchan` and `/proc/<pid>/syscall` empty for every process, no foreground job named —
188
+ the deadline body prints the rows it read and says so (`w=not-reported`, beside `w=running` for a
189
+ process on the CPU and `w=0` for one it may not look inside), instead of leaving "timed out" to be read
190
+ as "examined and found ordinary". `tty: true` asks for a terminal up front and `tty: false` vetoes the second attempt; the
191
+ pager and live-display behaviours that the class lists existed to encode are simply what the pipe does
192
+ (`man` prints the page, `top: failed tty get` refuses in 687 ms), and the sheet of which symptom
193
+ belongs to which layer is [docs/tty-triage.md](docs/tty-triage.md).
194
+
195
+ - **A repeated failure is named, and still executed.** The same command bytes failing twice in the same
196
+ shell adds `[this exact command has failed 2 times in this shell with nothing succeeding in it since: …]`,
197
+ and any success clears every streak — the claim is about a shell where nothing has worked since, so an
198
+ `npm install` succeeding between two failing `npm test` calls must not be talked about as a stuck loop.
199
+ Deliberately not a refusal: a tool declining to run
200
+ what it was asked is the shape this ticket is about, and the ordinary case — `npm test` passing on the
201
+ third try after an install — is exactly what a refusal would break.
202
+
203
+ - **`sudo`'s note offers the human first.** The product has interactive terminal tabs in the right sidebar
204
+ (`dsh-client-ui-sidebar-terminal`; the host's own PTY, which this plugin does not touch) where a person
205
+ can type the password. That is now the first way out, with NOPASSWD / `DSH_WSL_USER=root` second as the
206
+ way to make the agent able to run it alone.
207
+
208
+ - **The keyboard hint is given by the reading, not by whether we applied our own bound.** The first live
209
+ session of this on the installed desktop did not send `vim note.txt`; the model sent
210
+ `{"command":"printf x; vim note.txt","timeoutMs":15000}` — naming its own deadline, which under the
211
+ older rule bought it a generic sentence instead of an explanation of the wait. The body now says what
212
+ was seen whatever the call asked for. A second cell makes the timeout wording check itself: it may
213
+ claim a restart only when the session reported one, and the measurement there is that an escalated
214
+ `sleep` really does rebuild the session.
215
+
216
+ - **The ordinary-commands checklist was driven through a real dsh session, and that is what found the
217
+ 121 703 ms.** A real DSH 0.2.0-rc.2 instance (its own `DSH_HOME`, its own port, a scripted local
218
+ provider so no inference was bought): 27 rows of everyday commands, read back from the session's own
219
+ durable log rather than from a transcript someone has to relay. Most rows answered as designed —
220
+ `sudo`'s own verdict with the password note, the pager class in 73 ms, `man`/`less` printing whole
221
+ documents — and two rows did not: the compound `printf`-then-editor line above, and a cell whose
222
+ expectation was written for the wrong medium. The pass also caught a false promise in the tool
223
+ description: a live display on a pipe does not print a document, it refuses — `top: failed tty get`,
224
+ exit 1, 687 ms — so the description says that and points at `top -bn1`.
225
+
226
+ - **A failure this tool did not predict now names its own layer.** An escalated call that ends badly
227
+ without matching one of the known shapes (a password, a waiting keyboard) appends
228
+ `[this call ran on a pseudo-terminal (script -qec, one stream): re-run the same command with "tty": false to rule this layer out before looking anywhere else]`,
229
+ and every terminal decision is logged at debug level — `wsl-bash: stopped a command waiting for input
230
+ (terminal at 601ms) and re-running it on a pseudo-terminal` — so a `dsh web`/`headless` log answers
231
+ the question too (the installed desktop keeps its child's stdout in memory only, so there the
232
+ transcript lines are the readable half). The sheet of which symptom belongs to which layer — including
233
+ the host's PTY tier's own sentinel, the one issue #51 was filed against — is
234
+ [docs/tty-triage.md](docs/tty-triage.md), and every line it names is asserted by a cell in
235
+ `bash-session-real` (55 cells now, on both planes).
236
+
237
+ - **A live model found the one shape the cells could not: a builtin that reads the terminal blocks the
238
+ *shell itself*.** The build was driven end to end by a real model on a real token plan — four calls in a
239
+ WSL workspace, every command chosen by the model — and it picked \`read -r line < /dev/tty\` where every
240
+ cell in this repository used \`sh -c …\`. A child that blocks is found by walking the shell's
241
+ descendants; a *builtin* has no child, so nothing was classified and the call died on its 30 s deadline
242
+ with no note (the model's own summary noticed, and said so). Measured while it happens, the shell's own
243
+ row is \`Ss+ pgid==tpgid wchan=wait_woken fd0=/dev/tty\` with CPU flat — the same signature, one level up —
244
+ so the shell is now part of the walk, marked, because between commands it waits on a pipe and must not
245
+ be read as a wait. Freeing it took a measurement rather than a hope: \`kill -INT\` does **not** work (bash
246
+ catches it and the read syscall restarts — the call ran to its deadline with the signal delivered), so
247
+ the shell is stopped like any other wedged process, the session rebuilds from its journal, and the
248
+ command is re-run on a terminal by the same retry path. Same prompt, second run: that call came back in
249
+ **3 104 ms** with \`read-exit=1 x=''\` and the note naming the restart, and the model's closing summary
250
+ quoted it. Three further defects surfaced while landing it, all announced by a cell: the stop set named
251
+ every row in the sample (which now always contains the shell), the privileged path took its stop set
252
+ from the unreadable user-plane rows instead of the witness's, and a watchdog-stopped call was reported
253
+ in the caller-cancel shape — which made the live gate abort its own run.
254
+
255
+ - **The terminal is given by a reading, not by a list, and the second attempt is announced.** Three sets
256
+ of command names — credential, keyboard, pager — decided whether a call got a pseudo-terminal, and how
257
+ long it was allowed to wait. They are gone: they were the maintenance cost of this layer and the source
258
+ of two of its defects, every distribution could disagree with them, and the thing they were standing in
259
+ for is directly observable. What replaces them is one function (`src/host/wsl-bash-starve.ts`) that
260
+ reads the distribution from outside the shell while the call is in flight, and a stop-plus-retry that
261
+ acts on what it finds. The full shape, the measurements behind each threshold and the doors a caller
262
+ can use are in the bullet above and in [docs/tty-triage.md](docs/tty-triage.md).
263
+
264
+ - **A DSH Desktop profile generated no WSL variant at all (issue #47).** The variant
265
+ generator asked the host for two modules at call time — the entry-list dialect and the
266
+ YAML engine under it — on the assumption that host and plugin share a `node_modules`.
267
+ Desktop ships the host inside an archive, and Node finds a bare specifier by walking the
268
+ filesystem upward, so that walk never reaches it. The first healthy source preset threw,
269
+ the throw left the generation loop, and everything after the loop — including the sweep
270
+ of the retired mechanism's leftovers — was skipped. The dialog's only answer was that no
271
+ healthy `wsl` preset exists, and the cause sat in one swallowed host-console line.
272
+ Reproduced on this machine with real packages rather than inferred: a dialect built
273
+ against the release the host umbrella hoists, loaded through the release a sibling plugin
274
+ hoists, fails inside the loader, on wording that names neither package, version nor path.
275
+ - **The dialect is built in the plugin now** (`src/index.ts`), from the same engine
276
+ namespace that parses with it, so schema and engine cannot come from different majors.
277
+ Only the read half of the published schema was ever used here; its write half serves a
278
+ path this plugin does not take. Six lines, no new dependency.
279
+ - **The engine is a real dependency** (`js-yaml: ^4.1.0`, resolving to 4.3.2 — the release
280
+ the host umbrella itself hoists). Declaring it an optional peer with a `*` range had
281
+ handed the choice of major to whichever release the profile happened to carry, and with
282
+ `autoInstallPeers` off an optional peer is never installed at all. The sibling plugin that
283
+ keeps working in the same profile keeps working precisely because it declares a real
284
+ dependency; this now matches. The two peer entries nothing imports anymore are deleted,
285
+ so the manifest stops describing an import that does not exist.
286
+ - **One failing source is one variant's failure.** Generation is fault-tolerant per source,
287
+ the retired-directory sweep runs regardless, and the outcome is a count line —
288
+ `WSL preset variants: n/m registered` — because proving that routes answer had never
289
+ proved that anything was generated. On `dsh web` that line is in the boot log and the
290
+ compatibility matrix now asserts it; **on DSH Desktop it is not persisted anywhere**
291
+ (measured on a real install: its log directory holds only crash bundles, which embed a
292
+ child's stderr solely when that child exits non-zero, and capture only *renderer* console
293
+ output — so a main-process `console.error` has no sink). Making the outcome visible there is
294
+ issue #47's own recommendation #3 and is now filed as a separate ticket, on measured
295
+ grounds rather than as polish. Before this change, a source that vanished
296
+ mid-update preserved the previous complete variant only by accident: the abort skipped
297
+ the sweep that would otherwise delete it. The accident is gone, the contract stays, and
298
+ `tests/host-materialize.mjs` caught the difference on the first run.
299
+ - **A dialect failure names what it stood on** — package, version and path. That act is
300
+ decided on a user's machine by an installer; it has since been measured through a real pnpm
301
+ layout rather than assumed (see the last bullet), and the line still names the copy so a
302
+ frame on some other installer carries its own conclusion.
303
+ - **New gate** `tests/host-profile-isolation.mjs` (`npm run test:profile`): profile-shaped
304
+ trees under the temp dir, each booting the plugin's own copy — the loaned schema absent,
305
+ the hoisted engine on the wrong major, both at once, and one unreadable source among
306
+ healthy ones. Its control arm, its dialect-equivalence check against the pinned host
307
+ schema, and its probe that every provider path a declaration names really imports were
308
+ green on the *same* frame as the eight reds it was written to produce; three separate
309
+ mutations of the fix each reddened a different subset, which is what keeps the two
310
+ defects from being reported as one. `ci/install-pinned.mjs` materialises the hostile
311
+ engine release in a second tree that is never linked into the repo root.
312
+ - **Two older gates were asking the wrong question.** `scripts/verify-install.mjs` now
313
+ asserts the installed tree's runtime surface too: every declared dependency must be
314
+ reachable from the installed `lib/` and on the declared version line, and an empty
315
+ `dependencies` is itself a refusal — its earlier green was *produced* by the engine being
316
+ absent, which is the state the issue reported. `scripts/verify-dsh-compat.sh` asserts the
317
+ outcome count per matrix release: generating less than it was offered is `VARIANTS_FAIL`,
318
+ and a line that never appears is `VARIANTS_NOT_VERIFIED`, never a pass.
319
+ - **What this machine could not decide, and what it did.** Measured for real, with pnpm's
320
+ hoisted linker and the reporter's own `autoInstallPeers: false`: the published 0.7.5 in a
321
+ profile tree whose sibling package hoists `js-yaml` 5.x generates nothing, and this build
322
+ in that same tree gets its engine nested under itself and registers every source it was
323
+ offered. The same pair was then run in this machine's own Desktop profile shape — and it
324
+ turned out the real thing is installed here: `.dsh\profiles\desktop` carries 0.7.5 under
325
+ exactly those settings with no engine and no include package on its walk-up, which is the
326
+ report's shortage sitting on a maintainer machine the whole time. Still unmeasured: the
327
+ packaged host **process** — reading that profile is free, launching a GUI plus its
328
+ self-updater on a machine its owner is using is not, and it waits for a nod. The
329
+ reporter's host-console line, which his report nominated as decisive, turns out not to be
330
+ reachable by him at all: a healthy Desktop boot persists no host output (above), so that
331
+ check is asked of a maintainer with a shell, and the reply drafted for #47 asks him instead
332
+ for the observable — whether the dialog lists the variants.
333
+ Both limits, and the frames that back the rest, are in `docs/compatibility-evidence.md`.
334
+ - **A WSL session's file references preview again (issue #49).** The conversation's file
335
+ links, a tool row's line reference and the turn tail's changed files all open through the
336
+ right Sidebar's navigation controller, and the address they carry holds the path exactly as
337
+ the model wrote it — a Linux path. The host decodes that address and resolves it with
338
+ `node:path.resolve(cwd, path)`, where a POSIX absolute path is root-relative: `/mnt/d/x`
339
+ landed on the workspace drive (`D:\mnt\d\x`), an in-distribution path under the cwd share's
340
+ root, and a `/mnt/<drive>` reference under a UNC cwd became
341
+ `\\wsl.localhost\<distro>\mnt\<drive>\…`, which 9P refuses with `EPERM`. Reproduced on the
342
+ real DSH Desktop both ways before the fix: `文件不存在,可能已被移动或删除` for a file the
343
+ `write` tool had just created at that very path, and `EPERM` for the same reference from a
344
+ UNC workspace.
345
+ - **The repair is client-side, because the host plane offers this plugin no hook.** `fs` and
346
+ the `workspaceFiles` endpoint belong to other plugins, and cordis refuses a second
347
+ `provide` for a name another fiber owns, while neither publishes a path-resolution seam.
348
+ The client half now translates the address before the Sidebar sees it — `/mnt/<drive>/…`
349
+ back to `X:\…`, and every other Linux path through the distribution's UNC share — at
350
+ `openResource` / `openResourceIn`, the one entry every reference surface goes through, so
351
+ the tab's content and the metadata read under the same address are repaired together. A
352
+ translated path inside the workspace becomes the same relative address the Files panel
353
+ builds, so the Sidebar reveals an open tab instead of duplicating it.
354
+ - `listWorkspaceRecords` is a new host route, cached by the client: an in-distribution path
355
+ needs the distribution, which a UNC workspace carries but a `/mnt/<drive>` workspace only
356
+ has in `wsl-workspaces.json`. `host-api.mjs` covers it and its probe floor moved 12 → 13.
357
+ - The address grammar is **mirrored** rather than imported — six of the eleven declared
358
+ releases ship no `@deepseek-ai/dsh-util-workspace-path`, or ship it without the grammar —
359
+ and a unit case cross-checks the mirror against the owning package byte for byte wherever
360
+ it is resolvable. Anything the hook cannot rebuild byte-for-byte, or whose distribution is
361
+ unknown, or a session it does not treat as WSL-bound, is passed through untouched rather
362
+ than guessed at.
363
+ - Only 0.1.5-rc.1 and later ship a right Sidebar, a document preview and the resource model;
364
+ the six earlier declared releases have no reference surface to fix, and the hook simply
365
+ does not install there.
366
+ - Measured for this entry: the eleven declared releases at 14/16 on the harness sweep (the
367
+ same two known baselines as before), `host-api` 13/13 against a live 0.2.0-rc.2 frontend,
368
+ and the reference click driven end-to-end in a real browser on 0.1.5-rc.1, 0.1.5-rc.2,
369
+ 0.1.7-rc.1, 0.1.7-rc.2 and 0.2.0-rc.2, plus four configurations on the real DSH Desktop
370
+ (drive-spelling and UNC workspaces × `/mnt/<drive>` and in-distribution paths).
371
+ - **The agent can now type into a terminal of its own** (`wsl_terminal`, issue #51's last gap). A
372
+ pipe cannot be typed into, so every program that asks a question — `sudo` wanting a password,
373
+ `ssh` a first-connection fingerprint, a REPL, an editor, a TUI — was diagnosed, stopped and
374
+ re-run on a one-shot pseudo-terminal only so the model could read the complaint, and a person had
375
+ to take over; the ticket's contract is that *everything a person can run is reachable by the
376
+ agent*, so the unanswered class gets a door. The tool is the model-facing shape over the host's
377
+ own PTY registry and `dsh-terminal-bash` backend pointed at this plugin's relay — no new terminal
378
+ machinery, the same stack behind the right sidebar's terminal tab — with `open`/`send`/`read`/
379
+ `signal`/`close`/`list`, mounted beside the pipe `bash` (which stays the default: it carries every
380
+ byte, a terminal screen is a 160-column rendering with bounded scrollback). Two facts it took
381
+ reading host code to get right: the shell runs as the **workspace's configured user** because the
382
+ host builds a PTY child's environment itself and drops every `DSH_*` variable, so the relay reads
383
+ the workspace store back (the same fix the session `bash` needed — it had been resolving the user
384
+ from the environment alone); and the backend's quiet window is lowered to 1200 ms from its 3000 ms
385
+ default (source note in `src/host/variants.ts`) so a send the host does not recognise as a prompt
386
+ returns in ~1.2–1.8 s instead of ~3.0–3.6 s — the send says which of the two happened rather than
387
+ presenting a quiet screen as a prompt. Verified on a real distribution, both build planes, as
388
+ `root` and as `ruler`: `bash-session-real` now carries the door's own cells (66/66), including a
389
+ keystroke typed into a program blocked on `/dev/tty` reaching it (`GOT=…`), `submit: false` typing
390
+ without running and the next Enter running it, `SIGINT` ending a `sleep 30` with the shell
391
+ surviving, and `close` leaving no `bash -i` behind — `open` measured 477–490 ms. Found on the way:
392
+ `tests/host-profile-isolation.mjs` was red on Windows only (it counted a boot *diagnostic* as a
393
+ failure, so its two healthy-frame cells passed in CI and failed on the platform this plugin exists
394
+ for); those cells now separate diagnostics from failures and say why.
395
+ - **A command can be fed its input now** (`stdin`). The session shell's own stdin *is* the protocol
396
+ channel, so a command's stdin was `/dev/null` by construction — right for most calls, and fatal for
397
+ the class that reads a program's input from a pipe (`cat`, a build tool asking its question on stdin,
398
+ a script that reads data). The call's `stdin` text now travels inside the same frame and is decoded
399
+ into a temporary file the command's stdin is redirected from, removed after the exit status has been
400
+ read; the escalated (`tty: true`) path gets it too, because `script` forwards its stdin to the pty it
401
+ creates, and a background job receives it through the one-shot executor's own `stdin` spec field.
402
+ Over 32 KiB the call is refused by name and nothing runs — the frame's cost is measured (64 kB
403
+ answers in ~3.8 s, 256 kB in ~59 s), and a program fed half its input fails in ways that look like the
404
+ program's fault. Live cells: `cat` printing both fed lines **and the call still settling afterwards**
405
+ (the record that ends a call must not be eaten by the command's own read — that is the failure this
406
+ channel is shaped around), the same on a real pseudo-terminal (`read x; echo GOT=$x`), and the
407
+ refusal's own sentence.
408
+
409
+ - **An outside adversarial run found three things this machine could not see, and each is now closed.**
410
+ (1) *The readiness contract depended on the distribution's startup files leaving it alone.* On a
411
+ distribution shipping `/etc/profile.d/80-systemd-osc-context.sh`, that file runs
412
+ `PROMPT_COMMAND+=(…)`, bash refuses to export an array, and the interactive shell the relay `exec`s
413
+ inherits no `PROMPT_COMMAND` at all — measured here in a simulated profile: `env | grep -c
414
+ '^PROMPT_COMMAND='` goes from `1` to `0` and the `133;D;` marker never reaches the wire, so
415
+ `wsl_terminal` pays its quiet window on every keystroke and the `DSH_WSL_PTY_SHELL=1` tier never
416
+ settles. The relay now bridges the contract a second time under a name no startup file knows
417
+ (`__DSH_READINESS_PROMPT_COMMAND`) and re-asserts it between the login pass and the `exec`, `unset`
418
+ first — assigning to an array only writes element `[0]` and still exports nothing. Four arms measured:
419
+ old command + hostile profile `0`/no marker (the report's shape), old + benign `1`/marker (why this
420
+ machine was green), **new + hostile `1`/marker `prompt=[dsh> ]`**, new + no contract bridged
421
+ `0`/no marker (a host that injects none still gets its own shell). (2) *The function snapshot's cap
422
+ was all-or-nothing*, so on a distribution whose own startup functions are 86,954 bytes the whole
423
+ section — the function the model had just defined included — was dropped, and the journal's own
424
+ `alias, function, shopt and set options survive a restart` cell went red for the same reason. The cap
425
+ is now a per-function budget over only the functions the distribution's own files did not define
426
+ (a rebuilt shell re-sources those), and what does not fit is named. (3) *A rebuild that had something to
427
+ report was not believed by the cell that checks it*: the note renders as `was restarted and its
428
+ directory …` when nothing was left out and `was restarted; not restored: …` when something was, and
429
+ the check matched only the first, so on a reporting machine the claim and the fact were both true and
430
+ the cell still read them as disagreeing. Both wordings count now, and a starve-triggered rebuild
431
+ carries what it could not restore into the note of the call that asked for the terminal, instead of
432
+ losing it with the first attempt.
433
+
434
+ ## 0.7.5 — 2026-09-30
435
+
436
+ - **The persistent shell works on DSH Desktop again (issue #40).** The Desktop
437
+ host process *is* the packaged Electron executable running in node mode
438
+ (`ELECTRON_RUN_AS_NODE=1`; the Desktop's own code is
439
+ `new DesktopHostProcess(resources.node, …)` with
440
+ `resources.node = process.execPath`), and the plugin pointed the PTY backend's
441
+ `shellPath` at `process.execPath` with `shellArgs[0] = lib/wsl-relay.js`. An
442
+ Electron binary writes **nothing at all** under a ConPTY: the relay's
443
+ `wsl.exe` child inherits a dead stream, the relay exits 0 with zero bytes, the
444
+ backend's readiness probe never sees a prompt, and **every** `bash` call fails
445
+ with `PTY shell exited during startup`. The `0.1.0-rc.7` one-shot fallback is
446
+ unaffected, which is exactly what hid the failure behind a mode that works.
447
+ Measured with the host's own node-pty: same relay, same `\\wsl.localhost\…`
448
+ cwd, same environment — a real node gives a bash prompt, the Electron binary
449
+ gives 0 bytes and a clean exit.
450
+ - **The relay's interpreter is now resolved deliberately**
451
+ (`src/shared/relay-node.ts`): first the runtime payload the Desktop passes to
452
+ its host process (the `…/resources/runtime/primary-runtime` path in
453
+ `process.argv`, which carries a real node), then the same payload beside the
454
+ running executable (inside `Contents/Resources/…` on macOS), then
455
+ `DSH_DESKTOP_NODE_EXECUTABLE`, then a `node` on `PATH`, and only then
456
+ `process.execPath`. Every candidate is **asked what it is** (`-e` printing
457
+ `process.versions.electron`), because `--version` cannot tell them apart: with
458
+ `ELECTRON_RUN_AS_NODE` inherited the Electron executable answers with the
459
+ *node* version (measured: `v24.18.1`), so any `^v\d+\.\d+\.\d+` test selects
460
+ the broken one. A host that is not Electron (`dsh web`) keeps
461
+ `process.execPath` and spawns no probe at all, so its behaviour is identical
462
+ to 0.7.4; on the Desktop the chosen interpreter and every rejected candidate
463
+ go to the boot log.
464
+ - PR #39 diagnosed this correctly and pointed the same way, but its first
465
+ candidate, `DSH_DESKTOP_NODE_EXECUTABLE`, **is the Electron executable** — the
466
+ Desktop's own `resources/runtime/bin/node.cmd` is `set
467
+ ELECTRON_RUN_AS_NODE=1` followed by that variable — and its `--version` check
468
+ cannot tell the two apart. This fix takes the same direction with the payload
469
+ path first and a discriminator that actually rejects Electron.
470
+ - `tests/relay-node.test.mjs`: candidate derivation (argv / beside the
471
+ executable / the environment variable, including de-duplication and order) and
472
+ the Electron discriminator, including the version-shaped output that must be
473
+ refused.
474
+ - `scripts/compatibility/conpty-relay.mjs`: **a new standing gate** — it drives
475
+ the relay through a real ConPTY with the host's own node-pty and requires the
476
+ resolved interpreter to produce a live bash prompt. That is the invariant
477
+ issue #40 broke, and nothing checked it before. Registered in
478
+ `Run-Checks.ps1` and the harness, so it runs on every declared release.
479
+ - **The second error in issue #40 is fixed too**: `bash_background` handed the jobs
480
+ registry an `owner` of the wrong shape, which failed with
481
+ `session "[object Object]" has no live agent` on `0.1.7` and later. That contract
482
+ **changed at `0.1.7-rc.1`**: `start()` took the agent object before (the registry
483
+ reads `owner.id` and `owner.ctx`) and takes the session id after (resolving it with
484
+ `agents.get(id)`) — the host's own producers moved the same way, from
485
+ `owner: parent` to `owner: parent.id`. The plugin now picks the shape from whether
486
+ the registry offers `resolveOwner`; `tests/wsl-jobs.test.ts`, which had pinned the
487
+ wrong half as correct behaviour, now pins both contracts, and all eleven declared
488
+ releases were exercised one by one.
489
+ - `dsh.compatibility.dshReleases` declares `0.2.0-rc.2` (the DSH release DSH
490
+ Desktop 0.2.0-rc.2 ships), so this build claims eleven releases instead of ten.
491
+ - **The second error in issue #40 is fixed too**: `bash_background` handed the
492
+ jobs registry the agent object as the job owner, but the registry resolves that
493
+ owner with `ctx.agents.get(owner)` and wants the **session id**, so every call
494
+ failed with `session "[object Object]" has no live agent`. The host's own
495
+ producers pass `agent.id`. `tests/wsl-jobs.test.ts` had asserted the broken
496
+ contract as if it were correct; it now asserts the session id.
497
+ - **Verification**: sixteen harness checks on each of the eleven declared
498
+ releases (14/16 everywhere — the two failures are the documented baseline:
499
+ `typecheck`, and `host-api`, which needs a *running* frontend and passes 12/12
500
+ when pointed at one), plus the runbook's six-item frontend pass on all eleven
501
+ (dialog distro list, create & open, write, read, one-shot/persistent bash,
502
+ skills, reopen from the UI) with `web.err` at 0 bytes and every file re-read
503
+ independently on the Linux side.
504
+ - **And a full pass inside the real DSH Desktop.** The official installer was
505
+ unpacked and the Electron application itself was started (isolated `DSH_HOME`,
506
+ window driven over CDP, plugin installed and enabled through the Desktop's own
507
+ plugin panel). With **0.7.4** installed, every `bash` call returned
508
+ `PTY shell exited during startup` and `bash_background` returned
509
+ `session "[object Object]" has no live agent`, while the dialog, the
510
+ distribution list and the skills all worked. With **0.7.5**, bash returned
511
+ `6.18.33.2-microsoft-standard-WSL2` / `/home/mille/fx-3381` / `mille`, a second
512
+ independent `pwd` still said `/tmp`, `bash_background` returned `bash-1` with
513
+ `job_list` reporting `bash-1 [bash] running` and `bg.txt` really containing
514
+ `BG_3381_OK`, and the workspace still opened after a window reload. The boot log
515
+ names the interpreter it chose. The whole run is written up in
516
+ `docs/compatibility-evidence.md`.
517
+
518
+ ## 0.7.4 — 2026-09-28
519
+
520
+ - **The dialog works in DSH Desktop again (issues #35, #36).** The Desktop host
521
+ wraps `child_process` before the plugin is loaded: it installs plain
522
+ `exec`/`execFile` wrappers and re-exports them with
523
+ `syncBuiltinESMExports()`. That copy of `execFile` carries no
524
+ `util.promisify.custom`, so `promisify(execFile)` fell back to the generic
525
+ implementation, which resolves with the **first** callback argument only —
526
+ the stdout string — and `result.stdout` was `undefined` on every call. The
527
+ distro lookup read `.stdout` and threw
528
+ `Cannot read properties of undefined (reading 'includes')`, the frontend
529
+ caught it and rendered an empty picker: the dialog opened, the distribution
530
+ list stayed empty, and "Create & open" could not be completed, while
531
+ `wsl.exe -l -q` in a terminal listed the distribution normally. All three
532
+ call sites now use the **callback** form of `execFile`, which no wrapper can
533
+ reshape.
534
+ - **The `wsl.exe` lookup is a candidate list.** `wsl.exe` is tried on `PATH`
535
+ first, then the absolute `%SystemRoot%\System32\wsl.exe`, and a lookup that
536
+ fails now names every candidate it tried and the error each one produced,
537
+ instead of a type error from inside the decoder.
538
+ - `tests/exec-shape.mjs` reproduces the Desktop wrapper (plain wrappers plus
539
+ `syncBuiltinESMExports()`), spawns a probe with and without `--import`,
540
+ asserts the wrapped shape is broken and the new helpers are right, and runs
541
+ the real `listDistros`/`defaultDistro`/`resolveLinuxSymlink` through both
542
+ shapes. It is registered as the `exec-shape` check in
543
+ `scripts/compatibility/Run-Checks.ps1` so a future refactor cannot
544
+ reintroduce the promisified call.
545
+ - `dsh.compatibility.dshReleases` declares `0.1.7-rc.2` as well, so this build
546
+ claims ten releases instead of nine.
547
+ - **Verification**: fifteen harness checks on each of the ten declared releases
548
+ (13/15 everywhere — the two failures are the documented baseline: `typecheck`
549
+ and `host-api`, which needs a *running* frontend and passes 12/12 when pointed
550
+ at one), plus the runbook's six-item frontend pass on all ten (dialog distro
551
+ list, create & open, write, read, one-shot bash, persistent bash, skills) with
552
+ `web.err` at 0 bytes and the file re-read independently on the Linux side.
553
+ The Desktop-wrapper reproduction is in `docs/compatibility-evidence.md`,
554
+ together with the pack-identity hash and the plain-npm install gate.
555
+
556
+ ## 0.7.3 — 2026-09-23
557
+
558
+ - **The plugin loads on DSH `0.1.7-rc.1` again, and its modes come back.** The
559
+ `0.1.7` line renamed the host preset face — `read()` became `readDocument()`, which
560
+ returns a document (`{agentPreset, content, name, description}`) instead of the
561
+ composition text, and `AgentPreset` lost its `path` — so the variant generator
562
+ threw `agentPresets.read is not a function` on every boot and no `wsl-*` mode
563
+ ever reached the picker. The roster face is now probed by capability and both
564
+ generations are served: `readDocument()` where it exists, `read()` behind it.
565
+ - **A variant is a declaration row on that line.** `0.1.7` stopped scanning
566
+ `$DSH_HOME/.agent-presets/` — a preset there is a declarative
567
+ `@deepseek-ai/dsh-agent-preset` row, and the directory a variant used to be
568
+ written to is read by nothing at all. The generator now expands the composed
569
+ variant back into an entry list and publishes it through
570
+ `ctx.agentPresets.register()`; the plugin's effect owns the returned disposers, so
571
+ an unload or hot reload retires the variants instead of leaving orphans the next
572
+ apply could not replace (`Duplicate agent preset: wsl-<mode>`). Earlier releases
573
+ keep the directory channel, unchanged.
574
+ - **The world's own providers are named as `file:` URLs on that channel.** A preset
575
+ mounted from a declaration is imported by the registry's entry tree, which —
576
+ unlike the boot-time Include — does not turn an absolute path into a `file:` URL.
577
+ Without the rewrite the provider rows never started, the audit reported each as
578
+ `never started`, and the whole variant was refused as unusable.
579
+ - **The PTC variant is named again on `0.1.7`.** The label table that gives the
580
+ shipped modes their bilingual `WSL · …` names carried the mode's older id
581
+ (`code`) but not `ptc`, the id it has used since `0.1.1`. That stayed invisible
582
+ as long as the release published its own display name, because the lookup fell
583
+ through to it — but `0.1.7` publishes none, so the mode reached the picker as
584
+ `WSL · ptc` with the generic `WSL execution world for ptc: …` description. Both
585
+ ids are covered now, and the variant reads `WSL · PTC mode(PTC 模式)` on every
586
+ channel, like the other three shipped modes.
587
+ - `dsh.compatibility.dshReleases` declares `0.1.7-rc.1`. The two modules the
588
+ declaration channel needs (`@deepseek-ai/cordis-plugin-include`, `js-yaml`) are
589
+ resolved at call time and declared as **optional** peers, so a release that lacks
590
+ them fails one variant rather than refusing to load the plugin.
591
+
592
+ ## 0.7.2 — 2026-09-21
593
+
594
+ - **The WSL skill catalog is no longer re-walked on the request path (issue #25).**
595
+ The host rebuilds the catalog during a request and awaits each provider's
596
+ `list()`, and this provider kept its own answer for only 10 s — so every time the
597
+ catalog was re-collected (a new session or scope, or simply a lookup more than
598
+ 10 s after the last one) that request paid a full walk of the workspace, one
599
+ directory at a time: two `stat`s and one `readdir` each. A `readdir` over the
600
+ `\\wsl.localhost\…` 9P share measures 3-16 ms here and the walk's budget is 4096
601
+ directories, which is why a large workspace cost 20.4 s, on the request path. A
602
+ published catalog is now served as-is, and only the provider's own change detector
603
+ can drop it: a repeat lookup costs 1-3 ms and no filesystem traffic. The freshness
604
+ contract is unchanged — a new nested skills directory still appears within 30 s,
605
+ and an added, removed or edited skill within 3 s.
606
+ - The walk itself is cheaper: one BFS layer is probed concurrently (bounded) and
607
+ published in frontier order, so the catalog stays deterministic, and a
608
+ directory's `.dsh/skills` / `.agents/skills` are probed only when its own listing
609
+ showed that marker. The budget-sized walk went from 20.4 s to 4.8 s on the same
610
+ machine; node's filesystem thread pool caps the real parallelism.
611
+
612
+ ## 0.7.1 — 2026-09-20
613
+
614
+ - **`npm install dsh-wsl-workspace` no longer fails.** Verifying the published
615
+ artifact turned up a regression this release introduced: npm auto-installs
616
+ missing peer dependencies, and the `@deepseek-ai/dsh-tool-fs-search` peer added in
617
+ 0.6.0 itself peers on `@deepseek-ai/dsh-retention`, which is **not published** — so
618
+ a plain `npm install` died with `E404 … @deepseek-ai/dsh-retention` (0.4.3 installs
619
+ fine, so it was ours). `dsh plugin add` uses pnpm, which only *warns* about unmet
620
+ peers, which is why every harness run and real install passed. All ten host peers
621
+ are now marked optional in `peerDependenciesMeta`: the package still declares what
622
+ the host must provide, but npm no longer tries to fetch it.
623
+
624
+ ## 0.7.0 — 2026-09-20
625
+
626
+ The WSL world now matches the host everywhere a session can tell the difference,
627
+ and the last two known issues are closed. Everything below ships together: a WSL
628
+ variant gets Linux symlinks, the session's access mode, in-distribution search, a
629
+ live skill catalog, a stateful shell, and tracked background jobs.
630
+
631
+ - **The host's `bash` contract is stated in the tool description.** The persistent
632
+ tool wraps each command as `eval -- $'…'`, so a command ending in `&` backgrounds
633
+ the *whole* wrapped command — the call returns immediately with exit code 0 and
634
+ no output while the real output arrives later, possibly inside the next call's.
635
+ And the shell is one process for the whole Agent, so a `cd` carries into the next
636
+ call. The host default says neither, and DSH's own Minimal preset recommends the
637
+ hazardous form (`sleep 10 &`). The world now overrides `description` (a supported
638
+ key on every declared release) with both facts and the safe forms.
639
+ - **`0.1.0-rc.7` falls back to a working one-shot shell.** That release's
640
+ `dsh-subprocess-local` has no Windows process inspector, so the host's PTY-backed
641
+ persistent shell cannot start on Windows at all — every `bash` call failed with
642
+ `subprocess-local: terminal inspection is unsupported on platform win32` (the
643
+ host ships the same gap: its Minimal preset mounts `persistent-bash` there with
644
+ no Windows guard). The plugin now *probes* the substrate instead of assuming —
645
+ it hands `spawnTerminal` a program that cannot exist, which reaches the inspector
646
+ check and nothing else — and when the answer is no, the world keeps the one-shot
647
+ `dsh-tool-bash` row: a working, stateless shell rather than an error per call.
648
+ - **Tracked background jobs, restored.** Replacing the one-shot bash tool with the
649
+ persistent one also removed the only thing that started a registry job, so
650
+ `job_list` always answered "no background jobs" and a `run_in_background: true`
651
+ argument handed to `bash` was silently ignored (the parameter schema allows extra
652
+ properties, so nothing complained). The world now mounts `bash_background`
653
+ (`src/host/wsl-jobs.ts`), a thin producer over the host's own `ctx.jobs.start`
654
+ plus this plugin's `ctx.shell.start`: it returns a job id, and
655
+ `job_list`/`job_output`/`job_kill` work on it as usual. It is mounted only where
656
+ the source mode also mounts the `job_*` tools, and only alongside the persistent
657
+ shell.
658
+ - **Six defects found by hunting the new code with worst-case input**: a hidden-file
659
+ guard that also applied to an explicitly named file (`grep path=.env` returned
660
+ nothing), a discarded `find` exit status (`glob path=/nope-missing` looked like an
661
+ empty directory), a line-terminated glob header (a root whose name contains a
662
+ newline came back truncated), untranslated Windows paths (`grep path='D:\proj'`
663
+ failed where `read` worked), a closed spill schema (which would have failed the
664
+ tool's own output validation on every capped search), and a catalog detector that
665
+ could stack polls on a slow share. Plus two in the new producer: it was mounted in
666
+ a mode with no `job_*` tools to read its ids, and it defaulted a job's working
667
+ directory to the host process's rather than the session workspace.
668
+ - **Verification**: thirteen checks on each of the eight declared releases
669
+ (`0.1.0-rc.7` … `0.1.5-rc.2`) — `search-real` drives the real tools against a real
670
+ distribution fixture — leaving only the two pre-existing baseline failures
671
+ (`typecheck`, and `host-api` which needs a live server). 152 unit tests, including
672
+ a parity check of every renderer against the host suite's own formatters. Real
673
+ browser sessions on five releases for the tool behaviour, and a **frontend pass on
674
+ all eight** (entry button, dialog, path check, create & open, mode picker, help
675
+ panel with v0.7.0 and 8 release chips), with the session log as evidence for the
676
+ tool set, the search results, the catalog replacement, the shell fallback and the
677
+ background-job lifecycle.
678
+
679
+ ## 0.6.0 — 2026-09-19
680
+
681
+ - **WSL sessions get `grep` and `glob`**: the host suite spawns the packaged Windows ripgrep and every path the model hands it is a Linux one, so the generated world dropped `tool-fs-search` and left the model to grep through the shell — the last bullet of the panel's known issues. The world now mounts an in-distribution twin that keeps the host suite's contract: the same tool names, parameter schemas, inline caps (250 matches / 100 paths), output schema, `Line N:` grouping, found-count header, capped-result footer, search card and formatted-result spill — the rendering comes from `@deepseek-ai/dsh-tool-fs-search`'s own exported formatters, and the two projections that package keeps private (the card metadata and the glob page) are reproduced and compared against it in unit tests. `grep` runs GNU grep inside the distribution (`-rnIEH -Z`, POSIX ERE, hidden entries and `node_modules` skipped like ripgrep's defaults, no `.gitignore` support), `glob` uses GNU `find` with in-process gitignore-style matching and ripgrep's oldest-first modification order. Model-controlled values travel as separate argv elements after a fixed script, so nothing the model types is ever parsed by a shell.
682
+ - **The skill catalog notices an edited skill, not just an added one**: the catalog message is rebuilt only when the registry's revision moves, and the old detector compared directory listings — so rewriting an existing `SKILL.md` (a description, say) changed nothing it could see and the model kept the old text until a new session. The cheap pass now also stamps every skill file with its modification time and size, and runs every 3 seconds instead of 10. The full re-discovery walk — the only pass that can find a skills directory that did not exist before — moved to its own 30-second cadence, so the change detection is both faster and cheaper than the single 10-second poll it replaces.
683
+ - **`lib/` is rebuilt deterministically**: `tsdown` writes into a committed `lib/`, and stale code-split chunks from an earlier build survived every rebuild (`clean: false` plus two configurations sharing one output directory). Local build tooling now clears the directory first, and the three new runtime peers (`@deepseek-ai/dsh-tool-fs-search`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/schemastery`) are declared, which is also what keeps them external instead of bundling a second copy of DSH's tool stack into this plugin.
684
+ - **Four defects found by hunting the new code with worst-case input**: a hidden-file guard that also applied to an explicitly named file (`grep path=.env` returned nothing), a discarded `find` exit status (`glob path=/nope-missing` looked like an empty directory), a line-terminated glob header (a root whose name contains a newline came back truncated) and untranslated Windows paths (`grep path='D:\proj'` failed where `read` worked). The spill schema was also closed, which would have failed the tool's own output validation on every capped search, and the catalog detector gained an in-flight guard so a slow poll cannot stack `wsl.exe` calls.
685
+ - **The host's `bash` wrapper is documented in the tool description**: the persistent tool wraps each command as `eval -- $'…'`, so a trailing `&` backgrounds the whole wrapped command and the call reports exit code 0 with no output; and the shell is one process, so a `cd` carries into the next call. The host default says neither, and DSH's own Minimal preset recommends `sleep 10 &` — the world now overrides the description with both facts and the safe forms.
686
+ - **`0.1.0-rc.7` no longer gets a broken shell**: that release's `dsh-subprocess-local` has no Windows process inspector, so the host's PTY-backed persistent shell cannot start on Windows at all (the host ships the same gap: its Minimal preset mounts `persistent-bash` there with no Windows guard). The world probes the substrate at startup and, when the answer is no, keeps the one-shot `bash` row — a working stateless shell — instead of failing every call. Verified by a real session on that release.
687
+ - **Tracked background jobs are back in WSL sessions**: replacing the one-shot bash tool with the persistent one also removed the only thing that started a registry job, so `job_list` always said "no background jobs" and `run_in_background: true` handed to `bash` was silently ignored (the parameter schema allows extra properties, so nothing complained) — the exact defect an operator's session surfaced. The world now mounts `bash_background` (`src/host/wsl-jobs.ts`), a thin producer over the host's own `ctx.jobs.start` + this plugin's `ctx.shell.start`: the tool returns a job id, and `job_list`/`job_output`/`job_kill` work on it as usual. Verified in a real session: `started background job bash-1` → `job_list` shows `running` → incremental `job_output` reads (`tick 1`, `tick 2`, then `tick 3`) → `[status: completed, exit code: 0]` plus the runtime's completion notice.
688
+ - **Verification**: thirteen checks on each of the eight declared releases (`0.1.0-rc.7` … `0.1.5-rc.2`) — `search-real` joins the suite, driving the real tools against a real distribution fixture (framing, includes and braces, caps and footers, spill, cards, error codes, argv-safety, explicit dot-files, unreadable roots, odd root names, `/mnt` paths, abort and overflow, glob ordering and pruning) — leaving only the two pre-existing baseline failures (`typecheck`, and `host-api` which needs a live server). Unit tests add `tests/wsl-search.test.ts` (33 cases) and the refresh, cadence, stacking and description cases; `skills-real` proves an edited skill file invalidates the catalog through the share's own modification times. Real browser sessions on five releases confirm the behaviour end to end, including the `0.1.0-rc.7` fallback.
689
+
690
+ ## 0.5.0 — 2026-09-19
691
+
692
+ - **The file tools now follow Linux symlinks**: the `\\wsl.localhost` share lists a link entry but cannot describe it — `lstat`, `stat` and `readFile` on the link all fail and `resolve()` hands back a lexical identity for it — so a link path behaved like a missing file and a linked-in project's files could not be read or written at all. `resolve`/`lstat` now ask the distribution (`wsl.exe … readlink -f`, the same resolver the skill scan uses) whenever this share cannot already describe the path, and continue at the real path. A link is never replaced by a regular file, and writing through a dangling link creates its target while keeping the link.
693
+ - **The access mode constrains a WSL session again**: a variant mounts its own `fs` provider in the preset's isolate realm, so the host's `fs-sandbox` wrapper was not in the call path and `workspace-write` did not stop a write outside the workspace (measured before the fix: a Linux path and a `D:\...` path both went through). `writeText`/`editText` now fence the mutation exactly as `@deepseek-ai/dsh-fs-sandbox` does: `ctx.sandboxPolicy` (the tool layer's per-call value, else the service), the same `writableRoots` allow-list plus the distribution's `/tmp`, the same `FS_SANDBOX_DENIED`, and the `sandboxMode` getter the tool reads to advertise escalation. Because the fence runs after link resolution it judges the real path, so a link out of the workspace is an outside write.
694
+ - **Live skill catalog**: with `watch: false` pinned on the UNC-hostile watcher, a skill added while a session ran only appeared in the next session. The provider now keeps a change detector per scan root it has served, re-checking the published directory shape every 10 s (roots plus entry names and kinds, never re-reading skill files) and calling `control.invalidate()` when it changed, which makes the catalog middleware re-collect on the session's next turn.
695
+ - **`bash` is now a stateful WSL shell** — the capability the per-mode matrix kept showing was missing (every `bash` call used to be a fresh process). DSH's PTY registry takes replaceable backends and `@deepseek-ai/dsh-terminal-bash` is a config-driven one, so the world mounts it (inside its own `persistent-shell` group, because the registry is an agent-owned service) with `backendType: wsl` and points it at this plugin's relay (`src/host/wsl-relay.ts` → `lib/wsl-relay.js`) run by the host's own node. The relay resolves the distribution (session UNC cwd → `DSH_WSL_DISTRO` → host default) and the optional `DSH_WSL_USER`, then hands its stdio — the PTY — to `wsl.exe -d … --cd … -e bash -lc 'cd … && exec bash -i'`: login environment, interactive, and the session directory preserved. `@deepseek-ai/dsh-tool-bash-persistent` registers the **`bash`** name, so it takes the place of the one-shot `dsh-tool-bash` row (mounting both fails the whole preset — the same collision DSH's Minimal mode sidesteps by being a persistent-shell-only agent). The world also isolates and provides its own no-op `sandbox` capability: the PTY backend confines through `ctx.sandbox` before spawning, and the host's Windows runner cannot read the security descriptor of a `\\wsl.localhost\…` workspace root (`GetNamedSecurityInfoW failed (Win32 1)`), so a WSL session declares `enforcement: 'partial'` and keeps the policy where it is meaningful — in the file tools.
696
+ - **Verification**: the eight declared releases (`0.1.0-rc.7` … `0.1.5-rc.2`) run twelve harness checks — `fs-real` (link resolution through the real backend, reads through links and chains, dangling-link creation, link preservation, the fence on an outside link target, the distro `/tmp` allowance) and `relay-real` (stateful shell against real WSL, distribution and user resolution, clean exit) are new — with the same two documented baseline failures (`typecheck`, and `host-api` needing a live server). Unit: `tests/fs-policy.test.ts` (7 fence cases) and the skill-provider refresh cases, on top of the existing suite.
697
+
698
+ ## 0.4.5 — 2026-09-19
699
+
700
+ - **A project linked into a WSL workspace is discoverable now**: the `\\wsl.localhost` 9P share lists a Linux symlink but cannot resolve its target, so the skill scan — which already followed directory links on substrates that resolve them — skipped every linked-in project, and with it every nested project below it (the layout issue [#10](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/10) describes). When the share reports a link it cannot follow, the provider now asks the distribution itself (`wsl.exe -d <distro> -- readlink -f <linux path>`) and continues the walk at the real path. The fallback is bounded on purpose: at most 32 links per lookup, four calls in flight, a 10 s timeout each, and the existing depth / visited-directory / skill-directory budgets are untouched. Because the walk continues at the resolved path, a project reachable both directly and through a link is visited once, and a link that points back at the workspace root is absorbed by the visited set instead of looping.
701
+ - **What the fallback does not cover**: `read/write/edit` still resolve their paths through `WslFileSystem`, which does not follow Linux links, so reading or writing a link path reports it missing — use the real path. The help panel's known-issues list now states that instead of promising a fallback "not implemented yet".
702
+ - **Why one `wsl.exe` per link** (measured, and worth recording): `wsl.exe` silently drops the arguments that follow a command (`sh -c 'echo $#' sh a b c` answers 0), and its command-line parser truncates an argument containing a double quote, so a batched `sh` loop cannot be made reliable through it. A bare `readlink -f a b c` is no better: GNU `readlink` stops at the first path it cannot resolve and still exits non-zero, which would silently starve the rest of the batch. Passing each path as a process argument to one short call avoids quoting entirely — paths with spaces, quotes and backslashes all resolve — at the cost of one process per link (about 35 ms warm; six links cost 179 ms end to end on this machine, and a workspace with no links never starts a distribution process at all).
703
+ - **Verification**: the eight declared releases (`0.1.0-rc.7` … `0.1.5-rc.2`) pass the same 8/10 harness checks as 0.4.4 — only the documented `typecheck` baseline and the check that needs a live server fail. The real-9P check now builds a fixture whose only path in is a symlink and asserts the linked project, its nested project and its service through `get()`; the same walk with the fallback face removed finds neither, which is the pre-fix behaviour reproduced in the same run. On a live WSL fixture (`/home/mille/symprobe/ws`: a link out of the workspace, a link chain, a file link, a dangling link and a loop back to the root) the catalog went from 2 skills to 5, and `get()` read every body through the resolved locator.
704
+
705
+ ## 0.4.4 — 2026-09-19
706
+
707
+ - **A preset built on top of a WSL variant could not be used at all**: this generator recognises its own output by id prefix (`wsl-`), so a user preset that started life as a copy of `wsl-standard` or `wsl-cordis` — a "data mode" that carries its own world, say — was treated as a plain source preset and had a *second* world group appended to it. DSH refuses a composition carrying two `wsl-world` rows, so choosing that mode failed outright with `无法切换到「WSL · <name>」:duplicate loader entry id: wsl-world`; on a release that mounts the group before validating row ids the same duplication surfaces one step later as `tool "str replace editor" is already registered in this scope` (the report in [#24](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/24)). The generator now replaces the world group it finds — identified by the mounted `shell-wsl`/`fs-wsl` provider ids, so a copy whose group was renamed is caught too — and every variant ends up with exactly one world pointing at this installation's providers. A top-level row id that appears twice in a source is reduced to its first occurrence as well, because DSH rejects the whole preset on a duplicate id rather than the offending row.
708
+ - **`tool-str-replace-editor` rows are replaced like the older `str-replace-editor` row** ([#24](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/24)): newer rosters name the editor row that way, and it registers the same `str_replace_editor` tool as the world group's own editor row, so the source row is dropped just like its predecessor and the WSL-aware editor the variant injects stays.
709
+ - **Variant display names are no longer double-quoted**: the variant's `preset.yml` copied the source's `name:` scalar verbatim, so a quoted `name: 'Data mode'` reached the mode picker as `WSL · ''Data mode''`. The scalar is unquoted before it is re-emitted.
710
+ - **Not adopted from [#24](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/pull/24)**: disabling the `tool-cordis` row to avoid a duplicate inspect-provider registration. A `disabled` row never applies, so the WSL variant of Creator mode silently lost `cordis_inspect_list` / `cordis_inspect_query` (checked against 0.4.3, where both are present and answer with the host and the client providers); the PR's own description that the model "can still see the tools in the catalog" is not what happens. The registration their report shows needs that row applied twice, which the row-id reduction above now prevents where a copied preset caused it.
711
+ - **Help panel tidied up**: the panel now opens with a greeting line and the repository link, carries a "What's new" section for this build, and lists only the limitations that still apply — the historical "fixed in 0.4.3" note and the per-generation API walkthrough are gone. The compatibility chips are untouched: they are the manifest this build declares, not history.
712
+ - **Verification**: the eight declared releases (`0.1.0-rc.7` … `0.1.5-rc.2`) pass the same 8/10 harness checks as 0.4.3 — only the documented `typecheck` baseline and the check that needs a live server fail; 136 transforms over every shipped preset of the 17 installed runtimes are unchanged apart from the repair, and all 68 "copied variant" cases resolve to a single fresh world group.
713
+ - **Per-mode matrix with a real model** (every WSL variant, not just the default one): on `0.1.0-rc.7`, `0.1.1-rc.2`, `0.1.3-alpha.2` and `0.1.5-rc.2` each of the four variants — Standard, PTC, Minimal, Creator — was driven through the browser and asked to write a file with its file tool, run `uname -r; pwd; whoami` in bash and land that output in the workspace, then read the file back. Every mode produced `MODE-<mode>-OK` and a WSL2 kernel line in `/home/mille/<workspace>/notes/` with no loader error; the follow-up bash call lands in the workspace again, which is the documented per-call shell (the PTY group stays dropped). `0.1.2-rc.1` and `0.1.5-rc.1` were driven through all four modes without the file/bash assertions.
714
+ - Browser + real-model spot checks of the copied-variant mode (mounts and answers), Creator mode (inspect tools intact) and the `0.1.0-rc.7` standard flow complete the pass.
715
+
716
+ ## 0.4.3 — 2026-09-11
717
+
718
+ - **The persona text moved in `0.1.3-alpha.2`** ([#22](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/22)): DSH renamed the persona's model-facing scalar from `text` to an inline `suffix` plus a folded `prefix`, and the variant generator only recognised `text: >-`. On that line the WSL environment sentence was never appended - the session still ran inside the distribution, but the model was never told that its working directory is a Linux path reachable from Windows as `/mnt/<drive>`. The generator now amends `suffix`, `text` or `prefix` (folding an inline scalar into a block scalar when needed, so the sentence joins the working-directory line exactly where the legacy `text` block put it), and a persona carrying `complete: true` is still left alone. Verified on seven releases: the five older ones keep their persona block byte-identical, and the two newer ones now carry the sentence into the model's system message.
719
+ - **Help panel**: the dialog gained a "?" button that opens an in-place panel - the DSH releases this build declares (read from `package.json` through the host route, so the list can never drift from the manifest), how the plugin is used, its features, and the limitations it cannot fix.
720
+ - **The skill catalog now reaches UNC workspaces**: the host skill provider watches a workspace through `chokidar`, and watching a `\\wsl.localhost\...` path fails; the failed watcher makes the skill snapshot report `complete: false`, and `dsh-tool-skill` withholds the *entire* catalog message while a snapshot is incomplete — so a WSL session's model saw no skills at all, not even the ones the plugin had discovered. The variant generator now pins `watch: false` on the `skill-filesystem` row (merged into an existing `config:` block when there is one, and left alone when the source declares `watch` itself), which makes the host collect the catalog once at session start instead. Verified end to end on `0.1.5-rc.2`: the model's context carries the `<available_skills>` list. Trade-off: a skill added mid-session appears in the next session rather than the running one; skill bodies are still read live.
721
+ - **`verify-lib` hardening**: its comment/string stripper could pair a lone apostrophe inside a comment with a later one and swallow the rest of the bundle, which made every `node:*` import look tree-shaken. The quote rules now stop at a newline, exactly as a JavaScript string does.
722
+
723
+ ## 0.4.2 — 2026-09-10
724
+
725
+ - **Create & open in a `0.1.2-rc.1` workspace**: the session starter is now resolved when the dialog writes, not when the plugin applies. This plugin applies *before* the UI domain that publishes `uiWorkspace` registers its service, so the lookup cached at apply time stayed `undefined` for the whole page life: `Create & open` created the workspace and then silently opened no session, leaving `sessionIds` empty while the dialog reported success. A release exposing neither `uiWorkspace.startSession` nor `workspaces.startSession` now fails *before* anything is written, instead of leaving an orphaned workspace behind.
726
+ - **Skill body integrity**: skill bodies no longer lose their first character. `findFrontmatterEnd` already returns the index of the body's first character (the closing delimiter's newline plus one), so the slice must start there; the previous offset dropped that character and made the one after the delimiter look like the body. The existing fixtures always put a blank line after the delimiter, which is exactly what hid it.
727
+ - **UTF-8 BOM skills are no longer dropped**: a `SKILL.md` saved with a leading BOM (Notepad, VS Code's "UTF-8 with BOM", PowerShell redirection) did not match the opening `---` and disappeared from the catalog entirely. The parser strips the BOM before the fence check.
728
+ - **Binding converges on late inputs**: the agent-preset roster and the registered `/mnt/<drive>` workspace set are both inputs to binding, and both land asynchronously after the plugin's first pass. Each now re-runs the pass when it arrives instead of waiting for a session-store event that may never come.
729
+ - **Compatibility manifest corrected**: `0.1.3-alpha.1` is not published (`npm view @deepseek-ai/dsh@0.1.3-alpha.1` is a 404), so the declaration could never be verified; it is replaced by the published `0.1.3-alpha.2`.
730
+ - **Reproducible publishes**: a new `.gitattributes` (`* text=auto eol=lf`, `lib/** -text`) pins line endings. `core.autocrlf=true` used to rewrite text files to CRLF on checkout, and since `lib/` is committed and published verbatim the same commit produced different npm tarballs depending on the machine; the repository already stored LF, so no renormalisation was needed.
731
+ - **Closed-loop tests**: `tests/client-lifecycle.test.mjs` drives the browser half through the shipped `lib/client.js` for both service shapes — legacy (`connection.api.agentPresets` + `workspaces.startSession`) and current (`remote.agentPresets` + `uiWorkspace`) — and asserts `Create & open` for the normal, late-registration and no-starter cases. The skill tests now cover a body that starts on the delimiter's next line, for LF and CRLF files.
732
+
733
+ ## 0.4.1 — 2026-09-03
734
+
735
+ - **DSH v0.1.2-rc.1 compatibility**: Added backward compatibility support for DSH v0.1.2-rc.1 and later versions through feature detection and compatibility wrappers. The plugin now automatically detects the DSH version at runtime and uses the appropriate API:
736
+ - `uiWorkspace.startSession()` for v0.1.2-rc.1+
737
+ - `workspaces.startSession()` for v0.1.1-rc.2 and earlier
738
+ - `summary.projectionValues?.agentPreset` for v0.1.2-rc.1+
739
+ - `summary.agentPreset` for v0.1.1-rc.2 and earlier
740
+ - Projection-based auto-sync for v0.1.2-rc.1+
741
+ - `sessions.noteAgentPreset()` for v0.1.1-rc.2 and earlier
742
+ - **Updated compatibility manifest**: Added v0.1.2-rc.1 to the `dsh.compatibility.dshReleases` declaration.
743
+ - **Fixed `without inject` crash on v0.1.2-rc.1+**: the agent-preset roster is read through the `remote.agentPresets` namespace service via `ctx.get('remote.agentPresets')` (topology-free store lookup) instead of the `remote` aggregate's `agentPresets` property, which Cordis' associate proxy rejects when the dotted property is not declared in `inject`. `inject` stays limited to the services both DSH generations share (`slots`, `locale`, `sessions`, `workspaces`).
744
+ - **Compatibility manifest**: declared v0.1.3-alpha.1 compatible (its plugin-facing API surface matches v0.1.2-rc.1). Final adaptation notes consolidated in `docs/archive/compatibility-summary-0.4.1.zh.md` (supersedes the root-level draft plans).
745
+
746
+ ## 0.4.0 — 2026-08-29
747
+
748
+ Follow-ups from the [#12](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/12) limitation list and the [#13](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/13) compatibility work:
749
+
750
+ - **Lookup cache**: completed skill-catalog lookups are cached per scan root for 10 seconds, so repeated catalog builds no longer rescan the workspace over the slow 9P share; `get()` keeps reading skill bodies live, and freshly added skills appear within the TTL window.
751
+ - **Symlinked projects — investigated in 0.4.0, resolved in 0.4.5**: the discovery walk now recognizes directory symlinks explicitly and prunes them safely (no crashes, no loops), and the probe showed that following them is impossible over the `\\wsl.localhost` share itself (the Windows side cannot resolve Linux symlink targets: `readlink` → `EISDIR`, `stat`/`readdir` → `ENOENT`); 0.4.5 resolves them through the distribution instead, so linked-in projects are discoverable (see that changelog entry). A name+body fingerprint dedupe also guarantees aliased skill files can never publish twice on substrates that do resolve links.
752
+ - **Block-scalar frontmatter**: `description:` / `whenToUse:` written as YAML block scalars (`|` literal, `>` folded) now parse — such skills were silently dropped before.
753
+ - **Compatibility manifest**: `dsh.compatibility.dshReleases` declares per-release compatibility with the official DSH versions, backed by reproducible disposable-Profile install/start/uninstall evidence (`scripts/verify-dsh-compat.sh`), and `engines` declares the Node.js floor.
754
+ - **Guard scripts**: `scripts/check-rank-parity.mjs` fails the release when the copied project-rank constants drift from the host's `dsh-skill-filesystem`.
755
+
756
+ ## 0.3.2 — 2026-08-29
757
+
758
+ - **WSL workspace sessions now inject nested-project skill catalogs** ([#10](https://github.com/dsh-wsl-workspace-maintainers/dsh-wsl-workspace/issues/10)): `.dsh/skills` and `.agents/skills` directories of projects nested below the registered workspace root are discovered and published with the host's project ranks and sources, so the model sees the same skill catalog it would see when the session cwd is the project folder itself. Discovery is depth- and budget-bounded, prunes `node_modules`/dot-directories, and leaves non-WSL sessions untouched.
759
+ - **Host-parity scan root**: lookups from inside a project subtree resolve the nearest `.git` ancestor first, so the enclosing project's skills stay visible from deeper cwds; skills above that ancestor do not leak.
760
+ - **Hardening**: the skill-root budget is enforced per push, and the `skills.registerProvider` call is guarded so a host whose `skills` service has a different shape can no longer break plugin load.
761
+ - **Housekeeping**: removed stale prebuilt `lib/` chunks that shipped dead vendor code (including an inlined schemastery copy that triggered dsh.so's `new Function` static rule); added `scripts/repro-setup.sh` plus a nested skill-catalog regression suite, and a matching TESTING.md section.