dsh-plugin-wsl-env 0.9.0 → 0.11.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +456 -0
  2. package/CONTRIBUTING.md +2 -2
  3. package/README.md +85 -68
  4. package/SUPPORT.md +3 -3
  5. package/agent/native/linux-aarch64/dsh-wsl-agent +0 -0
  6. package/agent/native/linux-x86_64/dsh-wsl-agent +0 -0
  7. package/agent/wsl-agent.sh +83 -16
  8. package/cordis.patch.yml +41 -35
  9. package/docs/ARCHITECTURE.md +9 -1
  10. package/docs/CONFIGURATION.md +7 -4
  11. package/docs/LIMITATIONS.md +59 -18
  12. package/docs/PARITY.md +3 -1
  13. package/docs/PEER-PARITY.md +18 -11
  14. package/docs/README.zh.md +98 -74
  15. package/docs/ROADMAP.md +79 -0
  16. package/docs/UPSTREAM-FSIO-EXPORT.md +3 -3
  17. package/docs/UPSTREAM-TERMINAL-TITLE.md +9 -2
  18. package/examples/preset-wsl-session-sandbox.cordis.patch.yml +178 -0
  19. package/lib/agent-protocol.d.ts +10 -4
  20. package/lib/agent-protocol.js +10 -3
  21. package/lib/agent-shared.js +33 -5
  22. package/lib/agent.d.ts +187 -0
  23. package/lib/agent.js +311 -23
  24. package/lib/bwrap.d.ts +8 -0
  25. package/lib/bwrap.js +4 -1
  26. package/lib/fs-routing.d.ts +40 -4
  27. package/lib/fs-routing.js +80 -12
  28. package/lib/fsio-agent.js +1 -1
  29. package/lib/index.d.ts +134 -0
  30. package/lib/index.js +191 -3
  31. package/lib/native-agent.d.ts +124 -0
  32. package/lib/native-agent.js +198 -0
  33. package/lib/ports.d.ts +13 -2
  34. package/lib/ports.js +18 -5
  35. package/lib/preflight.js +24 -1
  36. package/lib/session-runtime.d.ts +161 -0
  37. package/lib/session-runtime.js +220 -0
  38. package/lib/session-scope.d.ts +124 -0
  39. package/lib/session-scope.js +191 -0
  40. package/lib/shell-env.js +10 -3
  41. package/lib/watcher.d.ts +25 -12
  42. package/lib/watcher.js +120 -32
  43. package/lib/workspace-files-route.d.ts +8 -4
  44. package/lib/workspace-files-route.js +42 -14
  45. package/lib/workspace-files-wsl.js +25 -8
  46. package/package.json +35 -25
package/CHANGELOG.md CHANGED
@@ -16,6 +16,462 @@ were part of the record.
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [0.11.0] - 2026-10-18
20
+
21
+ The shared read agent is the native binary (A7): unconfined reads now use the
22
+ packaged static-musl agent with the shell as the fallback, cutting 16
23
+ concurrent reads from 342-614 ms to 2.4-8.2 ms. The A6 FS measurement and
24
+ metrics ship with it.
25
+
26
+ ### Added
27
+
28
+ - **The shared read agent is native (A7).** `WslAgent` gained an async
29
+ `prepare()` hook; `sharedAgent` uses it to select the packaged native binary
30
+ through `agentBackend` (`auto`/`native`), with the shell as the fallback
31
+ (`shell`, a failed preparation, or a native HELLO/runtime failure; the reads
32
+ never fail closed). The A6 probe measures 16 concurrent shared reads at
33
+ 2.4-8.2 ms, down from 342-614 ms on the shell. The native shared agent also
34
+ serves the picker, the subprocess activity probes and the non-session
35
+ command path, so those inherit EXEC concurrency.
36
+
37
+ - **`WslAgent.metrics()`.** Per-kind queue wait (sent -> ACK), execution time
38
+ (ACK -> settle), in-flight peak, rejected and cancelled counters; the
39
+ 47-check lifecycle suite covers the counters and the reset.
40
+
41
+ - **`test/probe/fs-queue.mjs`.** A live measurement of both FS agents. The A6
42
+ run showed the shared shell reads were the batch bottleneck (16 concurrent
43
+ `stat`/`read`/`list` at 342-614 ms, queue up to 583 ms) while native FS ops
44
+ execute in 0.2-0.3 ms; the A7 run measures the native shared reads at
45
+ 2.4-8.2 ms. v6's lock removal only takes 16 native writes from 7.7 ms to
46
+ ~5 ms, so it stays deferred.
47
+
48
+ ### Changed
49
+
50
+ - **The plan after the A6 measurement: A7 (native shared reads) landed, v6
51
+ deferred.** [`docs/ROADMAP.md`](docs/ROADMAP.md) records the done, next and
52
+ deferred steps; ADR 0002, LIMITATIONS and the READMEs describe the native
53
+ shared reads and the v6 deferral.
54
+
55
+ - `test/probe/fs-queue.mjs` and the README/LIMITATIONS/ROADMAP describe the
56
+ native shared reads; `test/agent-shared.test.mjs` covers the shell selection.
57
+
58
+ ## [0.10.0] - 2026-10-18
59
+
60
+ The native in-distro agent is the default session-runtime backend: `agentBackend: auto`
61
+ selects the packaged static-musl binary for the distro's architecture, the shipped
62
+ shell agent stays as the fallback, and `native`/`shell` keep the strict and forced
63
+ modes. The peer line moves to DeepSeek Harness `0.2.1-rc.2`, which is the desktop
64
+ app's own version line. No module under `src/` changed, and that is the
65
+ finding, not an omission: every peer artifact this plugin subclasses or
66
+ replicates is byte-identical to the one shipped for `0.2.0-rc.2`, except one
67
+ added field in the `dsh-api-workspace-files` class that `WorkspaceFilesWsl`
68
+ extends (and that package's regenerated typert artifacts), so the seams the
69
+ code is built on did not move. What did move is the shipped composition — the
70
+ standard agent preset — and the plugin's embedded `preset-wsl` is regenerated
71
+ from it.
72
+
73
+ ### Added
74
+
75
+ - **The watcher uses inotify when it can.** With `inotifywait` installed and an
76
+ unbounded directory target, the in-distro watcher now uses recursive inotify
77
+ watches and coalesces events to one callback per 0.5s instead of polling
78
+ `find`. A file target, a positive `watchMaxDepth`, or a missing
79
+ `inotify-tools` keeps the polling fallback, so the backend is optional. The
80
+ polling mtime-old blind spot does not apply to the inotify path.
81
+
82
+ - **`sessionSandbox` runs one session's commands in one scope.** With the key
83
+ on, the shell executor acquires the runtime for `(distro, workspace,
84
+ policy)` on the first command, reuses it for the session, runs the command
85
+ WITHOUT the per-command bwrap wrap (the runtime's own mounts confine it),
86
+ and releases it from the preset scope's disposer — closing the agent and
87
+ stopping the unit, whose cgroup reaps the session's leftovers. On in the
88
+ shipped preset; requires `agent: true`, systemd as PID 1, a reachable user
89
+ manager, `systemd-run` and `Linger`. The preflight refuses with the remedy
90
+ when one is missing (only a success is cached, so following the remedy
91
+ needs no app restart); set `sessionSandbox: false` for the per-command
92
+ shape. Validated on a live session: `ps -p 1` is systemd, the command's
93
+ cgroup is the `dsh-wsl-*.scope`, a background process and `/tmp` survive
94
+ across commands, and the unit disappears after the session ends.
95
+
96
+ - **The session-runtime registry.** `src/session-runtime.ts` refcounts one
97
+ agent per `(distro, workspace, policy)`, composes the systemd user scope
98
+ prefix with a bwrap mount profile that avoids a PID namespace (and binds the
99
+ real `/tmp` under `workspace-write`), and tears a runtime down by closing the
100
+ agent and then stopping the scope. `--unshare-pid` is now a profile option
101
+ (default true), and the agent accepts an explicit lease independent of the
102
+ idle close. Tests cover the refcount, the mode-specific mounts, the mask in
103
+ the key, the explicit lease, and the close-then-stop order. It is wired into
104
+ the shell executor behind `sessionSandbox`, with a preflight that refuses
105
+ with a remedy; the filesystem provider's unification is next.
106
+
107
+ - **The session-scoped sandbox's launcher primitives.** `src/session-scope.ts`
108
+ builds the systemd user unit name, the `systemd-run --user --scope` prefix,
109
+ the stop argv and the preflight probe argv; unit tests pin the shapes and the
110
+ sanitization. ADR 0001 records the runtime-ownership decision the wiring
111
+ still has to make (the agent is a per-distro singleton today). No behavior
112
+ change yet.
113
+
114
+ - **`docs/VALUE.zh.md` states what the plugin is worth, in Chinese**, for a
115
+ reader deciding whether to install it: the three misalignments it removes,
116
+ six values each with its mechanism and the evidence that backs it, the
117
+ comparison against no integration / desktop remoting / putting the CLI in
118
+ WSL, the costs and limits stated before the sale, how the claims are
119
+ proven, and a rule for when not to use it. It is linked from both readme
120
+ indexes and sits beside the reference docs, never in place of them.
121
+
122
+ ### Changed
123
+
124
+ - **The peer pins move to the `0.2.1-rc.2` line**: `@deepseek-ai/dsh-*` at
125
+ `0.2.1-rc.2`, `@deepseek-ai/cordis` at `~4.0.5`, `@deepseek-ai/schemastery`
126
+ at `~3.18.5`, with `pnpm-lock.yaml` refreshed. pnpm's release-age policy
127
+ records the exact closure it had to exempt in `pnpm-workspace.yaml`, the way
128
+ the harness repository does. The three seam packages were compared byte for
129
+ byte against the previous pin: `dsh-fs-local`'s `lib/index.js` (the fsio
130
+ dialect this plugin replicates), `dsh-fs`'s, and `dsh-bash-local`'s (the
131
+ spill machinery and the `spawnSpec` seam) are identical files, so every line
132
+ reference in `docs/PEER-PARITY.md` and `docs/UPSTREAM-SPAWN-SEAM.md` still
133
+ names the same code — only the tarballs' digests changed, and
134
+ `docs/PEER-PARITY.md` now carries the `0.2.1-rc.2` ones. `cordis@4.0.5` and
135
+ `schemastery@3.18.5` likewise publish the same bytes as `4.0.4` and
136
+ `3.18.4` except for their manifests. Elsewhere: `dsh-fs@0.2.1-rc.2` drops its
137
+ unpublished `./invariant` export subpath (nothing here imported it),
138
+ `dsh-subprocess-local`'s new code is Linux systemd scope selection (this
139
+ plugin spawns `wsl.exe` from a Windows host), `dsh-api-workspace-files` adds
140
+ one `resource` field to its own lookup registration, and the typert protocol
141
+ gained modules.
142
+ - **`preset-wsl` is regenerated from the shipped standard preset.** The rows
143
+ that arrived are the clock-reading `time-context` row, `tool-schedule`, and
144
+ the disabled `tool-plugin-market/tools` row; the rows that left are
145
+ `tool-ralph` and the disabled `subagent_codex` / `subagent_claude_code`
146
+ entries, whose providers the preset no longer declares anywhere. The two
147
+ `tool-subagent` rows drop `backgroundMode`, the plan-mode section's first
148
+ sentence now says the plan is approved *through* `exit_plan_mode`, and
149
+ `persona.suffix` is gone: the host plane's new `working-directory` row
150
+ reports the Session's cwd, so the "Your working directory is …" suffix is no
151
+ longer the mechanism. The three deliberate deviations from the shipped copy
152
+ — the `wsl-env` realm wrapping every row, the fixed shell-tool choice, and
153
+ the preset's identity — are unchanged, and the transformation is now
154
+ [scripts/build-preset-wsl.mjs](scripts/build-preset-wsl.mjs) rather than the
155
+ machine-local generator the block used to name: it takes the shipped file and
156
+ prints the row, and its output is the committed block byte for byte.
157
+ - `docs/UPSTREAM-TERMINAL-FONT.md` closes: the `0.2.1-rc.2` line made the
158
+ terminal font a user setting (`--dsh-font-family-terminal` /
159
+ `--dsh-terminal-font-size`), so the hardcoded stack it argued against is now
160
+ only the fallback appended after the user's list.
161
+ `docs/UPSTREAM-TERMINAL-TITLE.md` was re-checked against the new tag and is
162
+ unchanged, including after the terminal controller's 0.2.1 rework: the tab
163
+ title is still built from `shell.name` alone.
164
+
165
+ ### Fixed
166
+
167
+ - **The GUI file service loads again from a linked install** (`workspace-files-wsl`
168
+ failed to import, and the Desktop host then died on a `write EOF`). The module
169
+ imports `@deepseek-ai/dsh-api-workspace-files` and
170
+ `@deepseek-ai/dsh-typert-protocol` at runtime, but both were declared only as
171
+ dev dependencies. The runtime's linked-plugin resolver routes a bare import
172
+ to the installation package only when the plugin's own manifest lists that
173
+ name under `peerDependencies` (`dsh-app-boot`'s `readPeerNames` /
174
+ `routeLinked`), so the import missed, the entry never activated, and Desktop
175
+ exited 1; the fatal `write EOF` is the shell writing to that already-exited
176
+ host. Both names are peers now, and `test/peers.test.mjs` scans every
177
+ `lib/*.js` import so an undeclared runtime package fails the check chain
178
+ instead of the app.
179
+
180
+ - **A warm native-agent cache no longer kills the Host on startup.** The
181
+ in-distro copy script prints the cache path and exits *without reading stdin*
182
+ when the digest already matches. `copyBinary` still streamed the packaged
183
+ binary into that closed pipe and left stdin's `error` event unhandled; on
184
+ Windows the write surfaced as `write EOF` and took the Host down before the
185
+ UI could load, so the first run (cold cache) worked and every later start
186
+ crashed. The write error is absorbed now — the exit code and stdout already
187
+ decide the result — and a POSIX regression check runs an early-exit script
188
+ against a payload larger than any pipe buffer.
189
+
190
+ - **The root-plane router survives its first distro target, and that path now
191
+ has a regression test.** Three defects sat behind the same runtime failure:
192
+ - `WslRoutingFileSystem` kept its state in ES `#private` fields. Cordis
193
+ invokes a service method with a shadow receiver (the traceable proxy
194
+ `ReflectService` builds), and private members do not cross it, so the first
195
+ routed call threw `Cannot read private member #distro from an object whose
196
+ class did not declare it`. The fields are plain now, exactly like the host
197
+ backend's own `config` and `locks`.
198
+ - Constructing the distro backend with the router's context re-registered
199
+ `fs` in the same scope (`service "fs" has been registered`). The backend
200
+ now receives an `fs`-isolated child scope, while `sandboxPolicy` still
201
+ resolves from the parent. The router captures its construction context,
202
+ because `this.ctx` inside a method is the *caller's* context, not its own.
203
+ - The row dropped the `static inject = ["sandboxPolicy"]` declaration the
204
+ shipped `fs-sandbox` row carries, although the backend it constructs reads
205
+ `ctx.sandboxPolicy.defaultMode`. That service is provided by a sibling row,
206
+ not an ancestor, so the fiber could not resolve it and construction failed
207
+ with `cannot get property "sandboxPolicy" without inject`. The declaration
208
+ is restored.
209
+
210
+ The `0.2.1-rc.2` line's new `working-directory` row resolves the session cwd
211
+ through the root `ctx.fs`, which is what first exercised the distro branch.
212
+ `test/composition.test.mjs` now boots the takeover row through the Loader
213
+ and drives the whole routing table through the service proxy — distro side
214
+ stubbed at the backend surface, host side over a temp workspace, with the
215
+ policy supplied from a sibling fiber as in the app — so all three defects
216
+ fail the unit suite instead of a session.
217
+
218
+ - **The GUI file service confines every read to the workspace, not just
219
+ listings.** `stat`, `read` and `readBytes` passed the joined Linux path
220
+ straight to `stat`/`sed`/`tail`, so an absolute path outside the workspace,
221
+ a `..` traversal, a symlink leaving it, or another distro's UNC name were
222
+ served — while `list` already resolved the real path and refused escapes
223
+ with `workspace-file/outside-workspace`. All four ops now share one guard:
224
+ `realpath -L` on the target and the root, then the same root/under-root
225
+ test, so an escape is the same refusal whichever op asks. The new checks
226
+ drive the real `sh` guard against a temp root (absolute sibling, `..`,
227
+ symlink out, symlink in, the root itself) and assert the refusal mapping in
228
+ all three ops.
229
+
230
+ - **The resident transport owns its generation and its pipes.** Three
231
+ lifecycle leaks shared one cause — state keyed by "the current process"
232
+ instead of by the process that actually emitted the event:
233
+ - Every spawn now owns a generation; its `error`, `exit`, `stdout` and
234
+ `stderr` handlers return unless `this.process` is still that child, so a
235
+ stale exit can no longer settle a newer start, null a newer transport, or
236
+ let a stale watchdog kill it. `handshakeTimer`, the ACK-armed watchdog and
237
+ the frame-cap kill all bind to their own child.
238
+ - `close()` settles the requests it interrupts, fails a start it interrupts,
239
+ and destroys the host's ends of `stdin`/`stdout`/`stderr` after the exit.
240
+ A descendant that inherited a pipe's write end can no longer keep the
241
+ read side — and the fd — open until it exits.
242
+ - The agent's lease and timeout watchdogs now spawn with `/dev/null` stdio,
243
+ so their `sleep` children do not hold the protocol pipes after the agent
244
+ exits. Without this, killing the watchdog subshell left the `sleep` as an
245
+ orphan holding the write ends for up to the full lease window (12 minutes
246
+ at the shipped 720 s), which is what kept a test process alive after every
247
+ check had already passed.
248
+
249
+ The fake transport covers the stale-exit and stdio-teardown checks; the real
250
+ `sh` agent is spawned with a 720 s lease and asserted to close its pipes
251
+ immediately after `SHUTDOWN`.
252
+
253
+ - **Cancellation reaches the command the agent is blocked on.** The stdin loop
254
+ is serial: a `KILL` frame written while a command ran sat in the pipe until
255
+ that command finished, and by then the id no longer matched, so the frame
256
+ was dropped. A request aborted mid-flight kept running to its own timeout; a
257
+ request aborted while queued ran in full later, with its answer discarded as
258
+ noise. Protocol 5 adds the agent shell's PID to `HELLO`; on abort the host
259
+ delivers `SIGUSR1` from a second `wsl.exe`, and the agent's trap kills the
260
+ request's process group — TERM, then KILL after the same 3 s grace the
261
+ timeout watcher uses. A request cancelled while it was still queued stays in
262
+ the pending map and is signalled on its `ACK`, so it is killed the moment it
263
+ becomes the running one; its late `RES` only cleans up. A PID-namespaced
264
+ confined resident is never signalled by its HELLO pid (that PID does not
265
+ exist in the distro and could name an unrelated process) — there the KILL
266
+ frame remains the fallback, and filesystem ops are short by construction.
267
+ The `KILL` frame stays as the fallback everywhere.
268
+
269
+ The fake transport covers the started, queued and confined cases; the real
270
+ `sh` agent is asked to run `sleep 30`, receives `USR1` after its ACK, and is
271
+ asserted to answer with a non-zero exit well before the sleep ends.
272
+
273
+ - **The agent's ACK is emitted only once the request's child exists.** It used
274
+ to be printed just before `run_capture`, i.e. before `CURRENT_PID` was set.
275
+ The host arms its execution watchdog and delivers `USR1` on the ACK, so both
276
+ could race the spawn: a cancellation right after the ACK found an empty
277
+ `CURRENT_PID`, dropped the signal, and the command ran to its own timeout.
278
+ `run_capture` now prints the ACK after `CURRENT_PID=$!`, which is what the
279
+ host's comment always claimed it meant.
280
+
281
+ - **The GUI page contract no longer returns its EOF probe line.** `pageArgv`
282
+ reads one line past the requested page so EOF is decidable from the yield;
283
+ `distroRead` returned every line it got and derived `lines`/`eof` from the
284
+ probe, so an N-line page carried N+1 lines, a page ending exactly at the file
285
+ reported `eof: false`, and `bytes` added the line count on top of the UTF-8
286
+ length (over-charging CRLF files and refusing pages that fit). The probe is
287
+ split off before counting, `eof` means "no line past the page", and `bytes`
288
+ is the page's own UTF-8 length. Tests cover truncation, exact EOF, and the
289
+ byte cap.
290
+
291
+ - **The resident's pending queue is bounded.** One resident refuses a request
292
+ past `maxPending` (64 by default) with a clear "requests in flight" error
293
+ instead of growing its map — and the retained frame lines — without bound.
294
+ A single frame's size is still the caller's.
295
+
296
+ - **The output budget now bounds the capture while the command runs.** The
297
+ agent wrote stdout and stderr to two unbounded files and applied
298
+ `maxOutputBytes` only while encoding the `RES`, so a chatty command could
299
+ fill the distro's `/tmp` — and a huge answer could exceed the 256 MiB frame
300
+ cap and kill the shared agent. With a budget, both streams are captured
301
+ through FIFOs drained by a bounded `dd` reader; the rest is discarded, so
302
+ the files stay within a block or two of the budget. The readers are given a
303
+ one-second grace once the request's own process is gone and then stopped, so
304
+ a background descendant holding the FIFO cannot delay the answer — and the
305
+ grace lets a just-exited command's output reach the file before the reader
306
+ is stopped. The block slack is what distinguishes "exactly at the cap" from
307
+ "cut". The unbounded path (`FS` frames) still writes files directly. Tests
308
+ cover `yes` capped at 1 KiB (`truncated: true`) and `sleep & echo` answered
309
+ without waiting for the background child.
310
+
311
+ - **Confined file mutations share the session runtime.** With `wsl-fs`
312
+ `sessionSandbox: true` (the shipped preset), a confined `writeText`/`editText`
313
+ acquires the same `(distro, workspace, policy)` runtime as the shell
314
+ executor: one agent, one mount profile, one real `/tmp`, and the same scope
315
+ lifecycle. Reads stay on the plain shared agent; the app-level `fs-routing`
316
+ backend keeps the mode off because it is not session-owned. A failed
317
+ preflight refuses the write with the systemd/linger remedy.
318
+
319
+ - **A closed watcher signals its loop by PID.** The heartbeat alone cannot
320
+ rely on the pipe closing when the host kills `wsl.exe`, so the loop now
321
+ announces its shell PID (`P <pid>`) before READY and `close()` sends it
322
+ `TERM` through a second `wsl.exe`; the TERM trap removes the stamp. The
323
+ real-machine watch probe now asserts the loop is gone, matching the script's
324
+ own argv rather than the `pgrep` wrapper. This is the fix the probe found
325
+ when the heartbeat-only version was tried on the real transport.
326
+
327
+ - **A failed port scan keeps the last successful snapshot.** The ports
328
+ environment fact was set to an empty list on any scan failure, which the
329
+ model reads as "nothing is listening". A failed scan (agent out, non-zero
330
+ exit) now leaves the previous list in place and logs a debug line; only a
331
+ successful empty scan produces an empty list.
332
+
333
+ - **The stale-scope sweep recognizes the native agent.** It looked only for
334
+ `wsl-agent.sh` in the scope's cgroup, so a live native session scope (whose
335
+ agent is `dsh-wsl-agent`) could be stopped at the next session preflight.
336
+ The liveness check now accepts either marker; a scope with only leftover
337
+ background processes is still reaped.
338
+
339
+ - **The native EXEC timeout is seconds.** The spike read the v5 EXEC frame's
340
+ timeout field as milliseconds, so a 120 s budget killed every command after
341
+ 120 ms; longer commands exited 143 on the native session. It now converts
342
+ seconds (`Duration::from_secs`), and the spike's check runs a 300 ms command
343
+ under a 2 s budget so the unit cannot regress silently.
344
+
345
+ - **The inotify watcher is reaped promptly on close and on a vanished
346
+ directory.** The blocking FIFO read is woken by a one-second marker, so the
347
+ TERM trap runs and the target check reports `E` within a second of the
348
+ directory disappearing. The real-machine probe's close and vanished-target
349
+ checks now pass with `inotify-tools` installed; the pre-watch creation check
350
+ was also corrected (a file created before the watch is not the polling
351
+ mtime blind spot).
352
+
353
+ - **A closed watcher no longer leaks its distro-side loop.** `close()` kills
354
+ `wsl.exe` but cannot signal the loop inside the distro, and a loop that only
355
+ writes on changes would sleep and poll forever. Each tick now writes an
356
+ ignored `H` heartbeat; a `PIPE` trap turns the closed pipe's SIGPIPE into a
357
+ clean exit through the stamp-removing EXIT trap. The host ignores `H`; the
358
+ real-`sh` check closes the pipe and asserts the loop exits and the stamp is
359
+ gone.
360
+
361
+ - **A distro target contains only targets in the same distro.** `contains()`
362
+ compared Linux path strings, so another distro's share (or a `..`-bearing
363
+ key) could test as contained when the spelling lined up. It now compares the
364
+ distro coordinate and lexically normalizes the paths; the resolver remains
365
+ the canonicalizer for symlinks.
366
+
367
+ - **A `notdir` failure reports `FS_NOT_DIRECTORY`.** The wire reason was
368
+ mapped to `FS_NOT_FOUND`, so a path whose parent is a regular file looked
369
+ like a missing path. It now carries the specific code the tool layer already
370
+ knows, so the model gets the right remedy.
371
+
372
+ - **Stale session scopes are swept.** A hard kill of the app can leave a
373
+ `dsh-wsl-*.scope` and its background processes behind. The session preflight
374
+ now lists those units and stops one only when its cgroup has no
375
+ `wsl-agent.sh`; a live agent means another session still owns it. The sweep
376
+ runs once per distro per process (a failed spawn re-arms it) and is a no-op
377
+ without a user manager. Validated on a live user manager: two
378
+ `dsh-wsl-test-sweep*` scopes with only a `sleep` were stopped while the
379
+ session's own scope (with its live agent) stayed active.
380
+
381
+ - **`watch()` accepts a file target.** The poll loop treated every
382
+ non-directory as vanished, so `ctx.fs.watch` on a regular file failed
383
+ immediately with "no longer exists". A file is now watched by its
384
+ `mtime:size` fingerprint — a rename-over or an in-place write fires — and a
385
+ removal reports the target as gone. Directory watching is unchanged. A
386
+ real-`sh` check runs the loop against a temp file (READY, a size change, a
387
+ removal).
388
+
389
+ ### Notes
390
+
391
+ - **The native-session probe.** `test/probe/native-session.mjs` (also in the
392
+ real-machine suite and the self-hosted probes job) exercises the packaged
393
+ native binary end to end: the cache/digest, the session scope, EXEC, FS
394
+ write/read, a KILL that reaps the request group while the agent survives, a
395
+ wrong HELLO digest failing the handshake, and the scope/cache teardown. It
396
+ is 8/8 on the live machine. The probe found two integration bugs, both
397
+ fixed here: a valid cache is now reused instead of rewritten (rewriting a
398
+ binary a live session is running fails with ETXTBSY), and the agent's stdin
399
+ now has an error listener so `close()` after a failed handshake (EPIPE) does
400
+ not crash the host.
401
+
402
+ - **Static musl packages for x86_64 and aarch64.** The package now ships
403
+ `agent/native/linux-x86_64/dsh-wsl-agent` (static-pie musl, 635,752 bytes,
404
+ sha256 `5920bdd5…`) and `agent/native/linux-aarch64/dsh-wsl-agent` (static
405
+ musl, 535,136 bytes, sha256 `cf7dd150…`), both stripped. One static binary
406
+ runs on glibc and musl distros, so Alpine no longer depends on the shell
407
+ fallback. A CI job builds both (x86_64 via musl-gcc, aarch64 via
408
+ cargo-zigbuild), tests the x86_64 build and uploads the `native-package`
409
+ artifact; the committed binaries are the reviewed result.
410
+
411
+ - **The native HELLO binds the process to the binary.** The native agent
412
+ hashes `/proc/self/exe` and puts the sha256 in its HELLO digest; the host
413
+ passes the digest it copied into the cache as `expectedDigest`, so a binary
414
+ swapped after the copy check fails the handshake before accepting a request.
415
+ The spike is 18/18 (the new check compares the handshake to the file's
416
+ sha256). The shell agent's script digest is unchanged.
417
+
418
+ - **The packaged native binary.** `agent/native/linux-x86_64/dsh-wsl-agent`
419
+ ships in the npm `files`; `agentBackend: auto` resolves it for a distro whose
420
+ `uname -m` is x86_64/amd64 (aarch64 is mapped but has no binary yet), with
421
+ `DSH_WSL_AGENT_NATIVE` still the development override. After the sha256 copy
422
+ the host executes the binary once with `</dev/null` and only then selects it,
423
+ so a glibc/musl mismatch falls back to the shell agent. The CI builds the
424
+ spike from source and runs its suite against both that build and the
425
+ committed package binary. The musl and aarch64 builds are the next step.
426
+
427
+ - Added **`wsl-shell.agentBackend`**: `auto` (the default) uses the native
428
+ in-distro agent when a verified source is available — the development
429
+ override `DSH_WSL_AGENT_NATIVE`, and later the packaged per-arch binary —
430
+ else the shipped shell agent; `native` fails the first command instead of
431
+ falling back; `shell` forces the shell agent. Commands and confined writes
432
+ share the session runtime, so both follow the selection. With no source and
433
+ the default, behavior is unchanged (shell).
434
+
435
+ - Added the opt-in **native agent host wiring**: `DSH_WSL_AGENT_NATIVE=<host
436
+ path>` makes the session runtime sha256 the binary into
437
+ `~/.cache/dsh-wsl/agent/<sha>/dsh-wsl-agent` (never from `/mnt/c`), verify
438
+ the distro-side digest, and spawn it under the session scope/bwrap with USR1
439
+ off (the native reads KILL frames). The shared FS-read agent, the one-shot
440
+ fallback and the default remain the shell agent; only x86_64 is accepted.
441
+ `scripts/native-wiring-check.mjs` verifies the copy + real runtime + EXEC/FS
442
+ end to end. Packaging/musl/default rollout is still pending.
443
+
444
+ - Added the **native agent spike** (ADR 0002 stage 2) under
445
+ `agent/native/spike`: a std-only Rust event loop for HELLO/PING/SHUTDOWN/
446
+ SETENV/EXEC/KILL with per-request process groups, plus the FS
447
+ `stat`/`lstat`/`read`/`write`/`list`/`realpath` surface (mode, no-replace,
448
+ expected version, staging + fsync + atomic publication; the `dsh-fs` side
449
+ channel in the RES stderr). Its manual check passes 16/16 (concurrent EXECs,
450
+ KILL without a second `wsl.exe`, timeout group kill, output cut with the pipe
451
+ still drained, FS stat/lstat/read/list/realpath matching the shell,
452
+ write/refusal, side channel, dot-dot refusal, expected-version stale replace,
453
+ setuid mode, EXEC differential). The host wiring remains; the shell agent
454
+ stays the shipped peer.
455
+
456
+ - Added **ADR 0002 — native in-distro agent (design)**: goals and non-goals,
457
+ language/distribution options (Rust static binaries, copied to the user's
458
+ cache), the v5-compatible spike and v6 concurrency protocol, the event-loop
459
+ process model, the host integration/copy/verify path, the security and test
460
+ matrix, the migration stages, and the triggers that would justify building
461
+ it. Implementation is deferred; the shell agent remains the shipped peer.
462
+
463
+ - Documented the one-shot fallback's timeout boundary: the host deadline kills
464
+ `wsl.exe`, not the distro-side tree; the session runtime's scope is the
465
+ cleanup path, and a systemd-scoped one-shot is not shipped.
466
+
467
+
468
+ - Gates on the new pins: style 9/9, syntax 7/7 members and 3/3 chain members
469
+ against `schemastery@3.18.5`, build freshness 74 artifacts, `tsc --noEmit`
470
+ clean, unit suite 30/30 files, coverage 98.42% lines / 84.20% branches /
471
+ 96.01% functions. The real-machine probes were not re-run: they need
472
+ Windows and WSL, and their peer-verify legs are covered by the byte
473
+ comparison above.
474
+
19
475
  ## [0.9.0] - 2026-10-06
20
476
 
21
477
  A usability round over the sandbox failure path: the failure stops being a
package/CONTRIBUTING.md CHANGED
@@ -24,8 +24,8 @@ algorithm, and the boot test starts a row through the real Loader — so a fresh
24
24
  checkout needs one `pnpm install` before the gates pass.
25
25
 
26
26
  Those pins are deliberate, and they move with the harness rather than on their
27
- own: the `@deepseek-ai/dsh-*` peers sit at `0.2.0-rc.2`, `@deepseek-ai/cordis`
28
- at `~4.0.4` and `@deepseek-ai/schemastery` at `~3.18.4`. The plugin subclasses
27
+ own: the `@deepseek-ai/dsh-*` peers sit at `0.2.1-rc.2`, `@deepseek-ai/cordis`
28
+ at `~4.0.5` and `@deepseek-ai/schemastery` at `~3.18.5`. The plugin subclasses
29
29
  peer classes and mirrors peer protocol, so raising one pin alone is how the
30
30
  seams drift: bump them in the commit that matches a harness release, re-run the
31
31
  comparison `docs/PEER-PARITY.md` describes, and let the gate chain and the