dsh-plugin-wsl-env 0.11.0 → 0.13.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 (45) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/CONTRIBUTING.md +29 -7
  3. package/README.md +124 -138
  4. package/SECURITY.md +11 -1
  5. package/SUPPORT.md +29 -2
  6. package/cordis.patch.yml +17 -19
  7. package/docs/ARCHITECTURE.md +30 -29
  8. package/docs/CONFIGURATION.md +81 -21
  9. package/docs/LIMITATIONS.md +71 -6
  10. package/docs/MIGRATION.md +98 -0
  11. package/docs/PARITY.md +1 -1
  12. package/docs/PERFORMANCE.md +60 -0
  13. package/docs/QUICKSTART.md +125 -0
  14. package/docs/README.zh.md +117 -137
  15. package/docs/ROADMAP-1.0.md +780 -0
  16. package/docs/ROADMAP.md +4 -0
  17. package/docs/UPSTREAM-FILE-REFERENCE-SEAM.md +80 -0
  18. package/docs/UPSTREAM-LSP-SEAM.md +90 -0
  19. package/docs/UPSTREAM-SESSION-NOTICE.md +95 -0
  20. package/lib/agent-errors.d.ts +19 -0
  21. package/lib/agent-errors.js +22 -0
  22. package/lib/agent-exec.js +2 -2
  23. package/lib/agent.d.ts +68 -5
  24. package/lib/agent.js +129 -22
  25. package/lib/distro-route.d.ts +75 -0
  26. package/lib/distro-route.js +86 -0
  27. package/lib/fs-routing.d.ts +4 -1
  28. package/lib/index.d.ts +166 -37
  29. package/lib/index.js +353 -75
  30. package/lib/paths.d.ts +15 -0
  31. package/lib/paths.js +28 -0
  32. package/lib/preflight.d.ts +46 -3
  33. package/lib/preflight.js +83 -14
  34. package/lib/shell-env.d.ts +9 -3
  35. package/lib/shell-env.js +121 -43
  36. package/lib/subprocess.d.ts +12 -3
  37. package/lib/subprocess.js +21 -3
  38. package/package.json +23 -15
  39. package/scripts/diagnose.sh +9 -88
  40. package/scripts/doctor.mjs +548 -0
  41. package/scripts/profile-preset.mjs +254 -0
  42. package/lib/workspace-files-route.d.ts +0 -136
  43. package/lib/workspace-files-route.js +0 -225
  44. package/lib/workspace-files-wsl.d.ts +0 -257
  45. package/lib/workspace-files-wsl.js +0 -418
package/lib/paths.d.ts CHANGED
@@ -138,6 +138,21 @@ export declare function windowsToLinuxMount(winPath: string): string | undefined
138
138
  * @returns an absolute Linux path, or the input unchanged when it cannot be mapped.
139
139
  */
140
140
  export declare function toLinuxPath(value: string, options?: WorldPathOptions): string;
141
+ /**
142
+ * Join a requested path onto a workspace root in the distro's Linux
143
+ * coordinate system: UNC absolutes map to their Linux paths, POSIX absolutes
144
+ * win over the root, and relative paths join onto it.
145
+ *
146
+ * This is the path half of the shipped `workspace-files` service's `ctx.fs`
147
+ * calls: the routed backend receives the same path/`cwd` pairs a host
148
+ * workspace would, so the stock service can serve a distro workspace without a
149
+ * second implementation.
150
+ *
151
+ * @param linuxRoot - the workspace root in Linux form.
152
+ * @param path - the requested path, in any coordinate system.
153
+ * @returns the absolute Linux path.
154
+ */
155
+ export declare function linuxJoin(linuxRoot: string, path: string): string;
141
156
  /**
142
157
  * Resolve a caller-supplied path into the UNC coordinate system the host's fs
143
158
  * calls use. Linux paths from the model, UNC paths from the harness, and
package/lib/paths.js CHANGED
@@ -238,6 +238,34 @@ export function toLinuxPath(value, options) {
238
238
  const baseLinux = toLinuxPath(base, { distro: options?.distro });
239
239
  return `${baseLinux.replace(/\/+$/, "")}/${text.replace(/^\.\//, "")}`;
240
240
  }
241
+ /**
242
+ * Join a requested path onto a workspace root in the distro's Linux
243
+ * coordinate system: UNC absolutes map to their Linux paths, POSIX absolutes
244
+ * win over the root, and relative paths join onto it.
245
+ *
246
+ * This is the path half of the shipped `workspace-files` service's `ctx.fs`
247
+ * calls: the routed backend receives the same path/`cwd` pairs a host
248
+ * workspace would, so the stock service can serve a distro workspace without a
249
+ * second implementation.
250
+ *
251
+ * @param linuxRoot - the workspace root in Linux form.
252
+ * @param path - the requested path, in any coordinate system.
253
+ * @returns the absolute Linux path.
254
+ */
255
+ export function linuxJoin(linuxRoot, path) {
256
+ // The UNC check runs on the RAW spelling: the backslash-to-slash rewrite
257
+ // below would leave `//wsl.localhost/...`, which the identity regex cannot
258
+ // see.
259
+ const parsed = uncToPosix(path);
260
+ if (parsed !== undefined)
261
+ return parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
262
+ const text = String(path).replaceAll("\\", "/");
263
+ if (text.startsWith("/"))
264
+ return text.length > 1 ? text.replace(/\/+$/, "") : "/";
265
+ const base = linuxRoot === "/" ? "" : linuxRoot;
266
+ const joined = text.length === 0 ? base : `${base}/${text}`.replace(/\/\.(?=\/|$)/gu, "");
267
+ return joined.length === 0 ? "/" : joined;
268
+ }
241
269
  /**
242
270
  * Resolve a caller-supplied path into the UNC coordinate system the host's fs
243
271
  * calls use. Linux paths from the model, UNC paths from the harness, and
@@ -27,28 +27,52 @@ import z from "@deepseek-ai/schemastery";
27
27
  import type { Context } from "@deepseek-ai/cordis";
28
28
  export declare const name = "wsl-preflight";
29
29
  export declare const Config: z<Schemastery.ObjectS<NoInfer<{
30
- /** Distro to preflight; empty means this machine's default distro. */
30
+ /**
31
+ * Distro to preflight. Empty/`"auto"` follows the session workspace; an
32
+ * explicit name pins; `"default"` forces WSL's default.
33
+ */
31
34
  distro: z<string, string, "defined">;
32
35
  /** The `wsl.exe` path the probe spawns; keep in step with the providers' `wslPath`. */
33
36
  wslPath: z<string, string, "defined">;
34
37
  /** Whether the session-start sandbox preflight runs at all. */
35
38
  sandbox: z<boolean, boolean, "defined">;
39
+ /**
40
+ * Whether the preflight also checks the session-runtime prerequisites
41
+ * (systemd PID 1, the user manager, `systemd-run`, Linger). On by default,
42
+ * matching the shipped `sessionSandbox: true`; turn it off when the profile
43
+ * opts out of the session runtime, or the warning would name a prerequisite
44
+ * that profile does not use.
45
+ */
46
+ sessionSandbox: z<boolean, boolean, "defined">;
36
47
  }>>, Schemastery.ObjectT<NoInfer<{
37
- /** Distro to preflight; empty means this machine's default distro. */
48
+ /**
49
+ * Distro to preflight. Empty/`"auto"` follows the session workspace; an
50
+ * explicit name pins; `"default"` forces WSL's default.
51
+ */
38
52
  distro: z<string, string, "defined">;
39
53
  /** The `wsl.exe` path the probe spawns; keep in step with the providers' `wslPath`. */
40
54
  wslPath: z<string, string, "defined">;
41
55
  /** Whether the session-start sandbox preflight runs at all. */
42
56
  sandbox: z<boolean, boolean, "defined">;
57
+ /**
58
+ * Whether the preflight also checks the session-runtime prerequisites
59
+ * (systemd PID 1, the user manager, `systemd-run`, Linger). On by default,
60
+ * matching the shipped `sessionSandbox: true`; turn it off when the profile
61
+ * opts out of the session runtime, or the warning would name a prerequisite
62
+ * that profile does not use.
63
+ */
64
+ sessionSandbox: z<boolean, boolean, "defined">;
43
65
  }>>, "plain">;
44
66
  /** The config schema's validated output. */
45
67
  export interface PreflightConfig {
46
- /** Distro to preflight; empty means this machine's default distro. */
68
+ /** The distro mode: empty/`auto`, an explicit name, or `default`. */
47
69
  distro?: string;
48
70
  /** The `wsl.exe` path the probe spawns. */
49
71
  wslPath?: string;
50
72
  /** Whether the session-start sandbox preflight runs at all. */
51
73
  sandbox?: boolean;
74
+ /** Whether the session-runtime prerequisites are preflighted too. */
75
+ sessionSandbox?: boolean;
52
76
  }
53
77
  /**
54
78
  * The slice of a created agent this service inspects; the harness's agent
@@ -63,6 +87,25 @@ export interface PreflightAgent {
63
87
  cwd?: string | undefined;
64
88
  };
65
89
  } | undefined;
90
+ /**
91
+ * The agent's own context, when the harness exposes it. Used to publish the
92
+ * advisory into the session's runtime context; absent in the unit tests and
93
+ * on a harness without the `systemPrompt` seam, where the log line stands
94
+ * alone.
95
+ */
96
+ ctx?: {
97
+ inject?: (names: string[], install: (scope: PreflightPromptScope) => void) => unknown;
98
+ } | undefined;
99
+ }
100
+ /** The slice of a session context that can carry the advisory. */
101
+ export interface PreflightPromptScope {
102
+ systemPrompt: {
103
+ context: (entry: {
104
+ name: string;
105
+ order: number;
106
+ text: () => string;
107
+ }) => unknown;
108
+ };
66
109
  }
67
110
  /**
68
111
  * The cordis context extended with the slices this service consumes. `logger`
package/lib/preflight.js CHANGED
@@ -26,16 +26,28 @@
26
26
  import z from "@deepseek-ai/schemastery";
27
27
  import { isWslUnc } from "./paths.js";
28
28
  import { defaultDistro, runCapture } from "./wsl.js";
29
+ import { distroOfUnc, isDefaultDistro, pinnedDistro } from "./distro-route.js";
29
30
  import { bwrapFailure, bwrapUsable, missingBwrapRemedy } from "./sandbox-core.js";
30
- import { sessionScopeSweepScript } from "./session-scope.js";
31
+ import { sessionScopeProblem, sessionScopeProbeScript, sessionScopeSweepScript } from "./session-scope.js";
31
32
  export const name = "wsl-preflight";
32
33
  export const Config = z.object({
33
- /** Distro to preflight; empty means this machine's default distro. */
34
+ /**
35
+ * Distro to preflight. Empty/`"auto"` follows the session workspace; an
36
+ * explicit name pins; `"default"` forces WSL's default.
37
+ */
34
38
  distro: z.string().default(""),
35
39
  /** The `wsl.exe` path the probe spawns; keep in step with the providers' `wslPath`. */
36
40
  wslPath: z.string().default("wsl.exe"),
37
41
  /** Whether the session-start sandbox preflight runs at all. */
38
42
  sandbox: z.boolean().default(true),
43
+ /**
44
+ * Whether the preflight also checks the session-runtime prerequisites
45
+ * (systemd PID 1, the user manager, `systemd-run`, Linger). On by default,
46
+ * matching the shipped `sessionSandbox: true`; turn it off when the profile
47
+ * opts out of the session runtime, or the warning would name a prerequisite
48
+ * that profile does not use.
49
+ */
50
+ sessionSandbox: z.boolean().default(true),
39
51
  });
40
52
  /** Distros whose stale-scope sweep has already run in this process. */
41
53
  const sweptDistros = new Set();
@@ -67,6 +79,52 @@ export async function apply(ctx, config = {}) {
67
79
  return;
68
80
  const wslPath = config.wslPath ?? "wsl.exe";
69
81
  const warn = (message) => ctx.logger?.warn?.(message);
82
+ /**
83
+ * Publish one advisory: the log line always, plus — when the harness exposes
84
+ * the agent's `systemPrompt` — a session-scoped runtime-context entry, so the
85
+ * warning is visible to the session instead of only in the app log. DSH
86
+ * 0.2.1 has no toast/notice seam; docs/UPSTREAM-SESSION-NOTICE.md records the
87
+ * ask, and the runtime context is the available visible channel today.
88
+ *
89
+ * @param agent - the session that just opened.
90
+ * @param message - the advisory, remedy included.
91
+ */
92
+ const announce = (agent, message) => {
93
+ warn(message);
94
+ const agentCtx = agent.ctx;
95
+ if (agentCtx === undefined || typeof agentCtx.inject !== "function")
96
+ return;
97
+ try {
98
+ agentCtx.inject(["systemPrompt"], (scope) => {
99
+ scope.systemPrompt.context({ name: "wsl-preflight", order: 200, text: () => message });
100
+ });
101
+ }
102
+ catch (error) {
103
+ ctx.logger?.debug?.(`wsl-preflight: could not publish the notice to the session (${error?.message ?? error})`);
104
+ }
105
+ };
106
+ /**
107
+ * Ask the distro's session-runtime probe. Only a recognized refusal is a
108
+ * verdict: an empty or unfamiliar answer means "cannot tell" (a fixture, an
109
+ * old image, a wedged shell) and stays at debug level — the preflight is
110
+ * advisory and must not invent a prerequisite from silence.
111
+ *
112
+ * @param distro - the resolved distro.
113
+ * @returns the remedy, or undefined when the session runtime is usable or unknown.
114
+ */
115
+ const probeSession = async (distro) => {
116
+ try {
117
+ const output = await runCapture([wslPath, "-d", distro, "--exec", "sh", "-c", sessionScopeProbeScript()]);
118
+ const word = output.trim().split(/\r?\n/u)[0]?.trim() ?? "";
119
+ if (!["no-systemd", "no-user-manager", "no-systemd-run", "no-linger"].includes(word))
120
+ return undefined;
121
+ return sessionScopeProblem({ output: { stdout: word }, distro }) ?? undefined;
122
+ }
123
+ catch (error) {
124
+ ctx.logger?.debug?.(`wsl-preflight: the session probe could not run (${error?.message ?? error})`);
125
+ return undefined;
126
+ }
127
+ };
70
128
  /**
71
129
  * Probe the distro once and surface an unusable `bwrap` as a warning.
72
130
  *
@@ -76,18 +134,24 @@ export async function apply(ctx, config = {}) {
76
134
  // Only a distro workspace has commands that confine: a host session would
77
135
  // warn about a sandbox it never uses.
78
136
  const cwd = agent?.session?.header?.cwd;
79
- if (cwd === undefined || !isWslUnc(cwd))
137
+ if (agent === undefined || cwd === undefined || !isWslUnc(cwd))
80
138
  return;
81
- let distro = config.distro ?? "";
139
+ const pinned = pinnedDistro(config.distro);
140
+ let distro = pinned ?? "";
82
141
  if (distro.length === 0) {
83
- try {
84
- distro = await defaultDistro({ wslPath });
142
+ if (!isDefaultDistro(config.distro)) {
143
+ distro = distroOfUnc(cwd) ?? "";
85
144
  }
86
- catch (error) {
87
- // No WSL at all is the missing-wsl probe's territory; the first
88
- // command's own error is the precise one. Stay quiet at warn level.
89
- ctx.logger?.debug?.(`wsl-preflight: no default distro to preflight (${error?.message ?? error})`);
90
- return;
145
+ if (distro.length === 0) {
146
+ try {
147
+ distro = await defaultDistro({ wslPath });
148
+ }
149
+ catch (error) {
150
+ // No WSL at all is the missing-wsl probe's territory; the first
151
+ // command's own error is the precise one. Stay quiet at warn level.
152
+ ctx.logger?.debug?.(`wsl-preflight: no default distro to preflight (${error?.message ?? error})`);
153
+ return;
154
+ }
91
155
  }
92
156
  }
93
157
  // bwrapUsable answers false on a failed probe — it never rejects — so
@@ -97,11 +161,16 @@ export async function apply(ctx, config = {}) {
97
161
  // runtime; a live agent in another session's cgroup makes its scope safe.
98
162
  sweepStaleScopes(wslPath, distro);
99
163
  const usable = await bwrapUsable({ wslPath, distro });
100
- if (usable) {
101
- ctx.logger?.debug?.(`wsl-preflight: bwrap is usable inside distro "${distro}"`);
164
+ if (!usable) {
165
+ announce(agent, `wsl-preflight: ${bwrapFailure(wslPath, distro) ?? missingBwrapRemedy(distro)}`);
102
166
  return;
103
167
  }
104
- warn(`wsl-preflight: ${bwrapFailure(wslPath, distro) ?? missingBwrapRemedy(distro)}`);
168
+ ctx.logger?.debug?.(`wsl-preflight: bwrap is usable inside distro "${distro}"`);
169
+ if (!(config.sessionSandbox ?? true))
170
+ return;
171
+ const sessionProblem = await probeSession(distro);
172
+ if (sessionProblem !== undefined)
173
+ announce(agent, `wsl-preflight: ${sessionProblem}`);
105
174
  };
106
175
  ctx.on("agent/created", (payload) => void check(payload?.agent));
107
176
  // Mount order is not a guarantee: a session created during startup can finish
@@ -31,19 +31,25 @@ import type { Context } from "@deepseek-ai/cordis";
31
31
  export declare const name = "wsl-shell-env";
32
32
  export declare const inject: string[];
33
33
  export declare const Config: z<Schemastery.ObjectS<NoInfer<{
34
- /** Distro these facts describe; empty means this machine's default distro. */
34
+ /**
35
+ * Distro these facts describe. Empty/`"auto"` follows each session's
36
+ * workspace; an explicit name pins; `"default"` forces WSL's default.
37
+ */
35
38
  distro: z<string, string, "defined">;
36
39
  /** How often the listening-port snapshot refreshes, in milliseconds. */
37
40
  portsRefreshMs: z<number, number, "defined">;
38
41
  }>>, Schemastery.ObjectT<NoInfer<{
39
- /** Distro these facts describe; empty means this machine's default distro. */
42
+ /**
43
+ * Distro these facts describe. Empty/`"auto"` follows each session's
44
+ * workspace; an explicit name pins; `"default"` forces WSL's default.
45
+ */
40
46
  distro: z<string, string, "defined">;
41
47
  /** How often the listening-port snapshot refreshes, in milliseconds. */
42
48
  portsRefreshMs: z<number, number, "defined">;
43
49
  }>>, "plain">;
44
50
  /** The config schema's validated output. */
45
51
  export interface ShellEnvConfig {
46
- /** Distro these facts describe; empty means this machine's default distro. */
52
+ /** The distro mode: empty/`auto`, an explicit name, or `default`. */
47
53
  distro?: string;
48
54
  /** How often the listening-port snapshot refreshes, in milliseconds. */
49
55
  portsRefreshMs?: number;
package/lib/shell-env.js CHANGED
@@ -27,14 +27,18 @@
27
27
  * @module dsh-plugin-wsl/shell-env
28
28
  */
29
29
  import z from "@deepseek-ai/schemastery";
30
- import { isAnotherDistrosUnc, isWslUnc } from "./paths.js";
31
- import { defaultDistro, defaultShell, linuxHomePath } from "./wsl.js";
30
+ import { uncToPosix } from "./paths.js";
31
+ import { defaultDistro, defaultShell, linuxHomePath, listDistros } from "./wsl.js";
32
+ import { isDefaultDistro, pinnedDistro } from "./distro-route.js";
32
33
  import { sharedAgent } from "./agent-shared.js";
33
34
  import { scanListeningPorts } from "./ports.js";
34
35
  export const name = "wsl-shell-env";
35
36
  export const inject = ["shellEnv"];
36
37
  export const Config = z.object({
37
- /** Distro these facts describe; empty means this machine's default distro. */
38
+ /**
39
+ * Distro these facts describe. Empty/`"auto"` follows each session's
40
+ * workspace; an explicit name pins; `"default"` forces WSL's default.
41
+ */
38
42
  distro: z.string().default(""),
39
43
  /** How often the listening-port snapshot refreshes, in milliseconds. */
40
44
  portsRefreshMs: z.number().default(10 * 1000),
@@ -47,74 +51,148 @@ export const Config = z.object({
47
51
  */
48
52
  export async function apply(ctx, config = {}) {
49
53
  // `resolve` is called synchronously by the registry (`Object.entries` of its
50
- // result), so every fact has to be resolved up front, not per call.
54
+ // result), so a fact that needs `wsl.exe` cannot be fetched there. The
55
+ // default/pinned distro is probed once at mount; every other distro is
56
+ // probed when a session created in it appears, so a machine with several
57
+ // stopped distros does not boot them all at application start.
51
58
  const configured = config.distro ?? "";
52
- const distro = configured.length > 0 ? configured : await defaultDistro();
53
- // undefined when both probes failed (a cold distro): the fact is then
54
- // simply absent, which beats presenting the "bash" placeholder as the
55
- // distro's login shell for this process's lifetime.
56
- const shell = await defaultShell(distro);
57
- // The consumer is a shell running INSIDE the distro, so the fact is the POSIX
58
- // path, not the host-side world path.
59
- const home = await linuxHomePath(distro);
59
+ const portsRefreshMs = config.portsRefreshMs ?? 10_000;
60
+ const pinned = pinnedDistro(configured);
61
+ let distros;
62
+ if (pinned !== undefined) {
63
+ distros = [pinned];
64
+ }
65
+ else {
66
+ try {
67
+ distros = await listDistros();
68
+ }
69
+ catch {
70
+ distros = [];
71
+ }
72
+ if (distros.length === 0) {
73
+ try {
74
+ distros = [await defaultDistro()];
75
+ }
76
+ catch {
77
+ distros = [];
78
+ }
79
+ }
80
+ }
81
+ const defaultName = distros[0];
82
+ /** The facts one distro can answer; a failed probe leaves its key absent. */
83
+ const facts = new Map();
84
+ const inflight = new Set();
85
+ const ensureFacts = async (distro) => {
86
+ if (facts.has(distro) || inflight.has(distro))
87
+ return;
88
+ inflight.add(distro);
89
+ try {
90
+ const [shell, home] = await Promise.all([
91
+ defaultShell(distro),
92
+ linuxHomePath(distro).catch(() => undefined),
93
+ ]);
94
+ facts.set(distro, { shell, home });
95
+ }
96
+ finally {
97
+ inflight.delete(distro);
98
+ }
99
+ };
100
+ const initial = pinned ?? defaultName;
101
+ if (initial !== undefined)
102
+ await ensureFacts(initial);
60
103
  const variables = {
61
104
  DSH_WSL_DISTRO: {
62
- description: `Name of the WSL distro this workspace lives in (${distro}).`,
105
+ description: "Name of the WSL distro this session's workspace lives in.",
63
106
  },
64
107
  DSH_WSL_SHELL: {
65
- description: `Absolute path of the login shell that model shell calls run in inside ${distro}. Commands are executed by this shell, not by bash.`,
108
+ description: "Absolute path of the login shell that model shell calls run in inside that distro. Commands are executed by this shell, not by bash.",
66
109
  },
67
110
  DSH_WSL_HOME: {
68
- description: `Home directory of the user model shell calls run as inside ${distro}.`,
111
+ description: "Home directory of the user model shell calls run as inside that distro.",
69
112
  },
70
113
  DSH_WSL_PORTS: {
71
- description: `Ports with a listener inside ${distro} (comma-separated, ascending), as of the last snapshot — refreshed every ${Math.round(config.portsRefreshMs / 1000)}s, so a dev server started moments ago may not be listed yet. Empty means none were listening at the last successful scan (or no scan has succeeded yet); a failed scan keeps the last successful list.`,
114
+ description: `Ports with a listener inside that distro (comma-separated, ascending), as of the last snapshot — refreshed every ${Math.round(portsRefreshMs / 1000)}s, so a dev server started moments ago may not be listed yet. Empty means none were listening at the last successful scan (or no scan has succeeded yet); a failed scan keeps the last successful list.`,
72
115
  },
73
116
  };
74
117
  // The port snapshot refreshes on a timer and is served synchronously: the
75
118
  // registry's resolve is sync by contract, so live reads are impossible —
76
- // the timer bounds staleness instead (see lib/ports.js).
77
- let ports = [];
78
- const refreshPorts = async () => {
79
- const next = await scanListeningPorts(sharedAgent(distro), 8000);
80
- if (next === undefined) {
81
- // A transient agent outage must not read as "no ports": keep the last
82
- // successful snapshot and leave a debug breadcrumb.
83
- ctx.logger?.debug?.("wsl-shell-env: port snapshot failed; keeping the last known list");
84
- return;
119
+ // the timer bounds staleness instead (see lib/ports.js). A timer starts only
120
+ // for a distro a session actually uses, so multi-distro users do not keep
121
+ // every distro's resident agent warm.
122
+ const ports = new Map();
123
+ const timers = new Map();
124
+ const refreshPorts = async (distro) => {
125
+ try {
126
+ const next = await scanListeningPorts(sharedAgent(distro), 8000);
127
+ if (next === undefined) {
128
+ // A transient agent outage must not read as "no ports": keep the last
129
+ // successful snapshot and leave a debug breadcrumb.
130
+ ctx.logger?.debug?.("wsl-shell-env: port snapshot failed; keeping the last known list");
131
+ return;
132
+ }
133
+ ports.set(distro, next);
85
134
  }
86
- ports = next;
135
+ catch {
136
+ // Same posture as a failed scan: keep the last successful list.
137
+ }
138
+ };
139
+ const ensurePorts = (distro) => {
140
+ if (timers.has(distro))
141
+ return;
142
+ void refreshPorts(distro);
143
+ const timer = setInterval(() => void refreshPorts(distro), portsRefreshMs);
144
+ timer.unref?.();
145
+ timers.set(distro, timer);
146
+ };
147
+ const lifecycle = ctx;
148
+ const consider = (agent) => {
149
+ const parsed = uncToPosix(String(agent?.session?.header?.cwd ?? ""));
150
+ if (parsed === undefined)
151
+ return;
152
+ if (pinned !== undefined && parsed.distro.toLowerCase() !== pinned.toLowerCase())
153
+ return;
154
+ if (isDefaultDistro(configured) && defaultName !== undefined && parsed.distro.toLowerCase() !== defaultName.toLowerCase())
155
+ return;
156
+ void ensureFacts(parsed.distro);
157
+ ensurePorts(parsed.distro);
87
158
  };
88
- void refreshPorts();
89
- const portsTimer = setInterval(() => void refreshPorts(), config.portsRefreshMs);
90
- portsTimer.unref?.();
159
+ const off = lifecycle.on?.("agent/created", (payload) => consider(payload?.agent));
160
+ for (const agent of lifecycle.agents?.list?.() ?? [])
161
+ consider(agent);
91
162
  const dispose = ctx.shellEnv.register({
92
163
  name: "dsh-plugin-wsl-env",
93
164
  variables,
94
165
  resolve: (execution) => {
95
166
  const cwd = execution?.agent?.session?.header?.cwd;
96
- if (cwd === undefined || !isWslUnc(cwd))
167
+ const parsed = uncToPosix(String(cwd ?? ""));
168
+ if (parsed === undefined)
169
+ return {};
170
+ const named = parsed.distro;
171
+ if (pinned !== undefined && named.toLowerCase() !== pinned.toLowerCase())
97
172
  return {};
98
- // The facts describe ONE distro. A workspace under another distro's UNC
99
- // is not that distro — the shell refuses its commands outright (the
100
- // cross-distro guard) — so the honest resolution is no facts at all,
101
- // not another distro's home and shell presented as this session's.
102
- if (isAnotherDistrosUnc(cwd, distro))
173
+ if (isDefaultDistro(configured) && defaultName !== undefined && named.toLowerCase() !== defaultName.toLowerCase())
103
174
  return {};
104
- const facts = {
105
- DSH_WSL_DISTRO: distro,
106
- DSH_WSL_HOME: home,
107
- DSH_WSL_PORTS: ports.join(","),
175
+ const canonical = [...facts.keys()].find((distro) => distro.toLowerCase() === named.toLowerCase()) ?? named;
176
+ const known = facts.get(canonical);
177
+ ensurePorts(canonical);
178
+ const resolved = {
179
+ DSH_WSL_DISTRO: canonical,
180
+ DSH_WSL_PORTS: (ports.get(canonical) ?? []).join(","),
108
181
  };
109
- if (shell !== undefined)
110
- facts.DSH_WSL_SHELL = shell;
111
- return facts;
182
+ if (known?.home !== undefined)
183
+ resolved.DSH_WSL_HOME = known.home;
184
+ if (known?.shell !== undefined)
185
+ resolved.DSH_WSL_SHELL = known.shell;
186
+ return resolved;
112
187
  },
113
188
  });
114
189
  // Disposing matters: a config hot-reload re-runs apply(), and the registry
115
190
  // rejects a second contributor claiming the same keys.
116
191
  ctx.effect(() => () => {
117
- clearInterval(portsTimer);
192
+ off?.();
193
+ for (const timer of timers.values())
194
+ clearInterval(timer);
195
+ timers.clear();
118
196
  dispose();
119
197
  });
120
198
  }
@@ -89,7 +89,10 @@ import type { SubprocessTerminalActivity } from "./terminal-activity.js";
89
89
  * builds.
90
90
  */
91
91
  export interface WslSubprocessConfig {
92
- /** Distro name; empty means WSL's own default distro. */
92
+ /**
93
+ * Distro mode: empty/`"auto"` follows the launch directory, `"default"`
94
+ * forces WSL's default, and an explicit name pins.
95
+ */
93
96
  distro: string;
94
97
  /** Path to `wsl.exe`. */
95
98
  wslPath: string;
@@ -121,7 +124,10 @@ export interface WslSubprocessConfig {
121
124
  */
122
125
  export declare class WslSubprocessRuntime extends LocalSubprocessRuntime {
123
126
  static Config: z<Schemastery.ObjectS<NoInfer<{
124
- /** Distro name; empty means WSL's own default distro. */
127
+ /**
128
+ * Distro mode: empty/`"auto"` follows the launch directory, `"default"`
129
+ * forces WSL's default, and an explicit name pins.
130
+ */
125
131
  distro: z<string, string, "defined">;
126
132
  /** Path to `wsl.exe`; overridable for a non-standard install. */
127
133
  wslPath: z<string, string, "defined">;
@@ -163,7 +169,10 @@ export declare class WslSubprocessRuntime extends LocalSubprocessRuntime {
163
169
  */
164
170
  terminalIdleReclaim: z<boolean, boolean, "defined">;
165
171
  }>>, Schemastery.ObjectT<NoInfer<{
166
- /** Distro name; empty means WSL's own default distro. */
172
+ /**
173
+ * Distro mode: empty/`"auto"` follows the launch directory, `"default"`
174
+ * forces WSL's default, and an explicit name pins.
175
+ */
167
176
  distro: z<string, string, "defined">;
168
177
  /** Path to `wsl.exe`; overridable for a non-standard install. */
169
178
  wslPath: z<string, string, "defined">;
package/lib/subprocess.js CHANGED
@@ -90,6 +90,7 @@ import { sharedAgent } from "./agent-shared.js";
90
90
  import { parseTerminalActivity, TERMINAL_ID_ENV, terminalActivityProbe, wrapTerminalHandle } from "./terminal-activity.js";
91
91
  import { AgentUnavailableError } from "./agent-errors.js";
92
92
  import { terminalRoute } from "./terminal-route.js";
93
+ import { pinnedDistro, isDefaultDistro } from "./distro-route.js";
93
94
  /**
94
95
  * Env names a terminal launch keeps for the distro. `TERM` rides along because
95
96
  * the PTY consumer sets it for the host process, and WSL imports no name that
@@ -111,7 +112,10 @@ const WSL_PROGRAM = "wsl";
111
112
  */
112
113
  export class WslSubprocessRuntime extends LocalSubprocessRuntime {
113
114
  static Config = z.object({
114
- /** Distro name; empty means WSL's own default distro. */
115
+ /**
116
+ * Distro mode: empty/`"auto"` follows the launch directory, `"default"`
117
+ * forces WSL's default, and an explicit name pins.
118
+ */
115
119
  distro: z.string().default(""),
116
120
  /** Path to `wsl.exe`; overridable for a non-standard install. */
117
121
  wslPath: z.string().default(DEFAULT_WSL_PATH),
@@ -160,7 +164,7 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
160
164
  constructor(ctx, config) {
161
165
  super(ctx);
162
166
  this.config = config;
163
- this.resolvedDistro = config.distro;
167
+ this.resolvedDistro = pinnedDistro(config.distro) ?? "";
164
168
  }
165
169
  /**
166
170
  * The distro a launch uses when the request's directory does not name one,
@@ -192,8 +196,22 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
192
196
  async terminalTarget(cwd) {
193
197
  const text = String(cwd ?? "").trim();
194
198
  const named = uncToPosix(text);
195
- if (named !== undefined)
199
+ if (named !== undefined) {
200
+ const pinned = pinnedDistro(this.config.distro);
201
+ if (pinned !== undefined && named.distro.toLowerCase() !== pinned.toLowerCase()) {
202
+ throw new Error(`"${text}" names a workspace in another WSL distro, and this profile pins its terminal to "${pinned}". ` +
203
+ `Open a session in that distro, or set this row's distro to ''/auto.`);
204
+ }
205
+ if (isDefaultDistro(this.config.distro)) {
206
+ const fallback = await this.distro();
207
+ if (named.distro.toLowerCase() !== fallback.toLowerCase()) {
208
+ throw new Error(`"${text}" names a workspace in another WSL distro, and this profile forces WSL's default ("${fallback}"). ` +
209
+ `Open a session in that distro, or set this row's distro to ''/auto.`);
210
+ }
211
+ return { distro: fallback, linuxCwd: named.linuxPath };
212
+ }
196
213
  return { distro: named.distro, linuxCwd: named.linuxPath };
214
+ }
197
215
  const distro = await this.distro();
198
216
  if (isPosixAbsolute(text))
199
217
  return { distro, linuxCwd: text };