dsh-plugin-wsl-env 0.12.0 → 0.13.1

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.
@@ -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,18 +161,29 @@ 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
108
177
  // before this service mounts and miss the event entirely (dsh-headless does
109
178
  // exactly that, and auto-preset sweeps for the same reason).
179
+ //
180
+ // `agents` is optional (this row injects nothing), so read it with `ctx.get`:
181
+ // a property read of an undeclared service throws in cordis 4, and the catch
182
+ // below would turn that into a silent no-op — exactly the sweep this block
183
+ // exists to perform.
110
184
  try {
111
- for (const agent of ctx.agents?.list() ?? [])
185
+ const agents = ctx.get("agents");
186
+ for (const agent of agents?.list() ?? [])
112
187
  void check(agent);
113
188
  }
114
189
  catch (error) {
@@ -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,155 @@ 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
+ // The live-agent sweep is a convenience, not a dependency: `inject` names
161
+ // only `shellEnv`, so reading `ctx.agents` as a property makes cordis 4
162
+ // throw "cannot get property \"agents\" without inject" and marks the whole
163
+ // component failed. `ctx.get` is the sanctioned read for a service this
164
+ // plugin did not inject, and it answers undefined when no agent loop is
165
+ // composed — the row still activates and resolves facts per shell call.
166
+ const live = ctx.get("agents");
167
+ for (const agent of live?.list?.() ?? [])
168
+ consider(agent);
91
169
  const dispose = ctx.shellEnv.register({
92
170
  name: "dsh-plugin-wsl-env",
93
171
  variables,
94
172
  resolve: (execution) => {
95
173
  const cwd = execution?.agent?.session?.header?.cwd;
96
- if (cwd === undefined || !isWslUnc(cwd))
174
+ const parsed = uncToPosix(String(cwd ?? ""));
175
+ if (parsed === undefined)
176
+ return {};
177
+ const named = parsed.distro;
178
+ if (pinned !== undefined && named.toLowerCase() !== pinned.toLowerCase())
97
179
  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))
180
+ if (isDefaultDistro(configured) && defaultName !== undefined && named.toLowerCase() !== defaultName.toLowerCase())
103
181
  return {};
104
- const facts = {
105
- DSH_WSL_DISTRO: distro,
106
- DSH_WSL_HOME: home,
107
- DSH_WSL_PORTS: ports.join(","),
182
+ const canonical = [...facts.keys()].find((distro) => distro.toLowerCase() === named.toLowerCase()) ?? named;
183
+ const known = facts.get(canonical);
184
+ ensurePorts(canonical);
185
+ const resolved = {
186
+ DSH_WSL_DISTRO: canonical,
187
+ DSH_WSL_PORTS: (ports.get(canonical) ?? []).join(","),
108
188
  };
109
- if (shell !== undefined)
110
- facts.DSH_WSL_SHELL = shell;
111
- return facts;
189
+ if (known?.home !== undefined)
190
+ resolved.DSH_WSL_HOME = known.home;
191
+ if (known?.shell !== undefined)
192
+ resolved.DSH_WSL_SHELL = known.shell;
193
+ return resolved;
112
194
  },
113
195
  });
114
196
  // Disposing matters: a config hot-reload re-runs apply(), and the registry
115
197
  // rejects a second contributor claiming the same keys.
116
198
  ctx.effect(() => () => {
117
- clearInterval(portsTimer);
199
+ off?.();
200
+ for (const timer of timers.values())
201
+ clearInterval(timer);
202
+ timers.clear();
118
203
  dispose();
119
204
  });
120
205
  }
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-wsl-env",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "\u8ba9 DeepSeek Harness \u4ee5 WSL \u5b50\u7cfb\u7edf\u4f5c\u4e3a\u4f1a\u8bdd\u7684\u6267\u884c\u73af\u5883\uff1ashell\u3001\u6587\u4ef6\u8bfb\u5199\u4e0e\u547d\u4ee4\u6267\u884c\u5747\u5728\u5b50\u7cfb\u7edf\u5185\u5b8c\u6210\uff0c\u76ee\u5f55\u9009\u62e9\u5668\u4e0e GUI \u7ec8\u7aef\u540c\u6837\u5b9a\u4f4d\u5230\u5b50\u7cfb\u7edf\u8def\u5f84\uff0c\u5e76\u901a\u8fc7\u5b50\u7cfb\u7edf\u5185\u7684 bubblewrap \u5bf9\u547d\u4ee4\u8fdb\u884c\u6c99\u7bb1\u9694\u79bb",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -95,31 +95,41 @@
95
95
  "examples/**/*.yml",
96
96
  "README.md",
97
97
  "docs/README.zh.md",
98
+ "docs/QUICKSTART.md",
99
+ "docs/PERFORMANCE.md",
98
100
  "docs/CONFIGURATION.md",
99
101
  "docs/LIMITATIONS.md",
100
102
  "docs/PARITY.md",
101
103
  "docs/PEER-PARITY.md",
102
104
  "docs/UPSTREAM-FSIO-EXPORT.md",
105
+ "docs/UPSTREAM-LSP-SEAM.md",
106
+ "docs/UPSTREAM-FILE-REFERENCE-SEAM.md",
107
+ "docs/MIGRATION.md",
108
+ "docs/UPSTREAM-SESSION-NOTICE.md",
103
109
  "docs/UPSTREAM-TERMINAL-TITLE.md",
104
110
  "docs/ARCHITECTURE.md",
105
111
  "docs/ROADMAP.md",
112
+ "docs/ROADMAP-1.0.md",
106
113
  "CHANGELOG.md",
107
114
  "CONTRIBUTING.md",
108
115
  "SECURITY.md",
109
116
  "SUPPORT.md",
110
117
  "LICENSE",
111
118
  "scripts/bootstrap.sh",
112
- "scripts/diagnose.sh"
119
+ "scripts/diagnose.sh",
120
+ "scripts/doctor.mjs",
121
+ "scripts/profile-preset.mjs"
113
122
  ],
114
123
  "scripts": {
115
- "test": "pnpm run lint:style && pnpm run test:syntax && pnpm run lint:build && pnpm run lint:types && pnpm run test:unit",
124
+ "test": "pnpm run lint:style && pnpm run lint:links && pnpm run test:syntax && pnpm run lint:build && pnpm run lint:types && pnpm run test:unit",
116
125
  "lint:types": "tsc --noEmit",
117
126
  "build": "node scripts/build.mjs",
118
127
  "lint:build": "node test/build-freshness.mjs",
119
- "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=84 --test-coverage-functions=70 test/agent-exec.test.mjs test/agent-fs-script.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/fs-substrate.test.mjs test/fs.boot.test.mjs test/composition.test.mjs test/provider.test.mjs test/file-reference-wsl.test.mjs test/fsio-agent.test.mjs test/fsio-text.test.mjs test/git-route.test.mjs test/listing.test.mjs test/paths.test.mjs test/ports.test.mjs test/preset-choice.test.mjs test/probe-cache.test.mjs test/preflight.test.mjs test/sandbox.test.mjs test/search-exec.test.mjs test/search-route.test.mjs test/shell.test.mjs test/terminal-activity.test.mjs test/terminal.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs test/session-scope.test.mjs test/session-runtime.test.mjs test/native-agent.test.mjs test/peers.test.mjs test/agent-shared.test.mjs",
128
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=84 --test-coverage-functions=70 --test-coverage-exclude='scripts/**' test/agent-exec.test.mjs test/agent-fs-script.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/fs-substrate.test.mjs test/fs.boot.test.mjs test/composition.test.mjs test/provider.test.mjs test/file-reference-wsl.test.mjs test/fsio-agent.test.mjs test/fsio-text.test.mjs test/git-route.test.mjs test/listing.test.mjs test/paths.test.mjs test/ports.test.mjs test/preset-choice.test.mjs test/probe-cache.test.mjs test/preflight.test.mjs test/sandbox.test.mjs test/search-exec.test.mjs test/search-route.test.mjs test/shell.test.mjs test/shell-env.test.mjs test/terminal-activity.test.mjs test/terminal.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs test/session-scope.test.mjs test/session-runtime.test.mjs test/native-agent.test.mjs test/peers.test.mjs test/agent-shared.test.mjs test/distro-route.test.mjs test/fs-routing.test.mjs test/doctor.test.mjs test/profile-preset.test.mjs test/packaging.test.mjs test/peer-canary.test.mjs",
120
129
  "test:syntax": "node test/syntax.mjs",
121
- "test:unit": "node --test test/fs.boot.test.mjs test/fsio-text.test.mjs test/composition.test.mjs test/provider.test.mjs test/file-reference-wsl.test.mjs test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preflight.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/agent-fs-script.test.mjs test/fsio-agent.test.mjs test/fs-substrate.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs test/git-route.test.mjs test/fsio-text.test.mjs test/search-route.test.mjs test/search-exec.test.mjs test/terminal-activity.test.mjs test/session-scope.test.mjs test/session-runtime.test.mjs test/native-agent.test.mjs test/peers.test.mjs test/agent-shared.test.mjs",
130
+ "test:unit": "node --test test/fs.boot.test.mjs test/fsio-text.test.mjs test/composition.test.mjs test/provider.test.mjs test/file-reference-wsl.test.mjs test/paths.test.mjs test/listing.test.mjs test/shell.test.mjs test/shell-env.test.mjs test/terminal.test.mjs test/probe-cache.test.mjs test/preflight.test.mjs test/preset-choice.test.mjs test/sandbox.test.mjs test/agent-protocol.test.mjs test/agent.test.mjs test/agent-fs-script.test.mjs test/fsio-agent.test.mjs test/fs-substrate.test.mjs test/agent-exec.test.mjs test/ports.test.mjs test/watcher.test.mjs test/wsl.test.mjs test/fs-decisions.test.mjs test/git-route.test.mjs test/search-route.test.mjs test/search-exec.test.mjs test/terminal-activity.test.mjs test/session-scope.test.mjs test/session-runtime.test.mjs test/native-agent.test.mjs test/peers.test.mjs test/agent-shared.test.mjs test/distro-route.test.mjs test/fs-routing.test.mjs test/doctor.test.mjs test/profile-preset.test.mjs test/packaging.test.mjs test/peer-canary.test.mjs",
122
131
  "lint:style": "node test/style.mjs",
132
+ "lint:links": "node scripts/check-links.mjs",
123
133
  "prepublishOnly": "pnpm test",
124
134
  "probe": "bash test/probe/run.sh",
125
135
  "probe:missing-wsl": "bash test/probe/missing-wsl.sh",
@@ -134,8 +144,15 @@
134
144
  "probe:agent": "bash test/probe/agent.sh",
135
145
  "probe:substrate": "bash test/probe/substrate.sh",
136
146
  "probe:exec": "bash test/probe/exec.sh",
147
+ "probe:fs-routing": "bash test/probe/fs-routing.sh",
148
+ "probe:gui-preview": "bash test/probe/gui-preview.sh",
149
+ "probe:file-reference": "bash test/probe/file-reference-9p.sh",
137
150
  "bootstrap": "bash scripts/bootstrap.sh",
138
- "diagnose": "bash scripts/diagnose.sh"
151
+ "diagnose": "bash scripts/diagnose.sh",
152
+ "doctor": "node scripts/doctor.mjs",
153
+ "preset": "node scripts/profile-preset.mjs",
154
+ "e2e:install": "bash test/e2e/install.sh",
155
+ "canary:peers": "node scripts/peer-canary.mjs"
139
156
  },
140
157
  "packageManager": "pnpm@11.7.0",
141
158
  "engines": {