dsh-plugin-wsl-env 0.6.0 → 0.7.2

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.
package/lib/picker.js CHANGED
@@ -31,8 +31,11 @@
31
31
  * the entry point rather than something you must know a UNC path for;
32
32
  * - the breadcrumb "WSL" row jumps back to that distro list;
33
33
  * - the dialog's Home affordance targets the preferred distro's Linux `$HOME`;
34
- * - every other level is an ordinary directory listing, which the host
35
- * performs over the distro's UNC share.
34
+ * - every other level inside a distro is listed BY THE DISTRO — the resident
35
+ * agent's `ls` when it is up, a one-shot `wsl.exe --exec ls` when it is
36
+ * out — because the host's own listing used to walk the 9p share in its
37
+ * slow direction, one metadata round trip per entry; a Windows path keeps
38
+ * the host's own walk, which is native there.
36
39
  *
37
40
  * Because the client surface is unchanged, this row composes with the **shipped**
38
41
  * `@deepseek-ai/dsh-client-ui-directory-picker-browse` dialog: no client code,
@@ -46,9 +49,11 @@ import { homedir } from "node:os";
46
49
  import { join, resolve } from "node:path";
47
50
  import z from "@deepseek-ai/schemastery";
48
51
  import { DirectoryPicker, DirectoryPickerError } from "@deepseek-ai/dsh-host-directory-picker";
49
- import { UNC_PROVIDER_ROOT, distroRoot, isProviderRoot } from "./paths.js";
50
- import { breadcrumbs, boundedInsert, fullyQualified } from "./listing.js";
51
- import { listDistros, linuxHome } from "./wsl.js";
52
+ import { UNC_PROVIDER_ROOT, distroRoot, isProviderRoot, isWslUnc, uncToPosix } from "./paths.js";
53
+ import { breadcrumbs, boundedInsert, fullyQualified, lsListingArgv, parseLsListing } from "./listing.js";
54
+ import { listDistros, linuxHome, runCapture } from "./wsl.js";
55
+ import { sharedAgent } from "./agent-shared.js";
56
+ import { AgentUnavailableError } from "./agent-errors.js";
52
57
 
53
58
  /** How long a resolved distro list stays fresh before it is queried again. */
54
59
  const DEFAULT_DISTRO_CACHE_MS = 5_000;
@@ -269,6 +274,74 @@ export class WslDirectoryPicker extends DirectoryPicker {
269
274
  throw new DirectoryPickerError("directory-unreadable", path, `cannot list "${path}": not a fully qualified path`);
270
275
  }
271
276
  const target = resolve(path);
277
+ const preferred = await this.preferredDistro(signal);
278
+ const level = isWslUnc(target) ? await this.listDistroLevel(target, signal) : await this.listHostLevel(target, signal);
279
+ return {
280
+ path: target,
281
+ home: preferred.length > 0 ? await this.homeOf(preferred, signal) : homedir(),
282
+ crumbs: this.crumbsFor(target),
283
+ entries: level.rows,
284
+ truncated: level.truncated,
285
+ };
286
+ }
287
+
288
+ /**
289
+ * List one level inside the distro, where the files live.
290
+ *
291
+ * The resident agent answers in one round trip; when it is out — distro
292
+ * stopped, or the resident's rebuild budget spent — the same `ls` runs
293
+ * through a one-shot `wsl.exe`. Neither path touches the 9p share, which is
294
+ * what the host walk it replaces used: one metadata round trip per entry,
295
+ * in the share's slow direction, plus one more per symlink.
296
+ *
297
+ * @param target - the absolute UNC directory to list.
298
+ * @param signal - caller lifetime.
299
+ * @returns the level's rows and truncation flag.
300
+ * @throws DirectoryPickerError with code `directory-unreadable`.
301
+ */
302
+ async listDistroLevel(target, signal) {
303
+ const parsed = uncToPosix(target);
304
+ const linuxDir = parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
305
+ let stdout;
306
+ try {
307
+ const result = await sharedAgent(parsed.distro).exec({
308
+ cwd: linuxDir,
309
+ argv: lsListingArgv(linuxDir),
310
+ timeoutMs: 15_000,
311
+ signal,
312
+ });
313
+ if (result.exitCode !== 0) {
314
+ const detail = result.stderr.toString("utf8").trim();
315
+ throw new DirectoryPickerError(
316
+ "directory-unreadable",
317
+ target,
318
+ `cannot list ${target}: ${detail.length > 0 ? detail : `ls exited ${result.exitCode}`}`,
319
+ );
320
+ }
321
+ stdout = result.stdout;
322
+ } catch (error) {
323
+ if (!(error instanceof AgentUnavailableError)) throw error;
324
+ try {
325
+ stdout = await runCapture(
326
+ [this.config.wslPath, "-d", parsed.distro, "--cd", linuxDir, "--exec", ...lsListingArgv(linuxDir)],
327
+ signal,
328
+ );
329
+ } catch (fallbackError) {
330
+ throw new DirectoryPickerError("directory-unreadable", target, `cannot list ${target}: ${messageOf(fallbackError)}`);
331
+ }
332
+ }
333
+ return parseLsListing(stdout, target, this.config.maxEntries);
334
+ }
335
+
336
+ /**
337
+ * List one Windows level with the host's own walk — native I/O on a native
338
+ * filesystem, unchanged from the shipped browse behaviour.
339
+ * @param target - the absolute Windows directory to list.
340
+ * @param signal - caller lifetime.
341
+ * @returns the level's rows and truncation flag.
342
+ * @throws DirectoryPickerError with code `directory-unreadable`.
343
+ */
344
+ async listHostLevel(target, signal) {
272
345
  const maxEntries = this.config.maxEntries;
273
346
  const keep = maxEntries + 1;
274
347
  const window = [];
@@ -295,27 +368,19 @@ export class WslDirectoryPicker extends DirectoryPicker {
295
368
  if (level !== undefined) await level.close().catch(() => {});
296
369
  }
297
370
 
298
- const entries = [];
371
+ const rows = [];
299
372
  let truncated = evicted;
300
373
  for (const candidate of window) {
301
374
  signal?.throwIfAborted();
302
375
  const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink, signal);
303
376
  if (row === null) continue;
304
- if (entries.length === maxEntries) {
377
+ if (rows.length === maxEntries) {
305
378
  truncated = true;
306
379
  break;
307
380
  }
308
- entries.push(row);
381
+ rows.push(row);
309
382
  }
310
-
311
- const preferred = await this.preferredDistro(signal);
312
- return {
313
- path: target,
314
- home: preferred.length > 0 ? await this.homeOf(preferred, signal) : homedir(),
315
- crumbs: this.crumbsFor(target),
316
- entries,
317
- truncated,
318
- };
383
+ return { rows, truncated };
319
384
  }
320
385
 
321
386
  /**
@@ -334,6 +399,7 @@ export class WslDirectoryPicker extends DirectoryPicker {
334
399
  throw new DirectoryPickerError("directory-create-failed", join(parent, name), `"${name}" is not a single path segment`);
335
400
  }
336
401
  const target = join(parent, name);
402
+ if (isWslUnc(parent)) return this.createDistroDirectory(parent, name, target);
337
403
  try {
338
404
  await mkdir(target);
339
405
  return target;
@@ -344,6 +410,44 @@ export class WslDirectoryPicker extends DirectoryPicker {
344
410
  throw new DirectoryPickerError("directory-create-failed", target, `cannot create ${target}: ${messageOf(error)}`);
345
411
  }
346
412
  }
413
+
414
+ /**
415
+ * Create one child directory inside the distro — the resident agent's
416
+ * `mkdir` when it is up, a one-shot `wsl.exe --exec mkdir` when it is out;
417
+ * the host's `mkdir` over the 9p share is what this replaces.
418
+ *
419
+ * @param parent - the absolute UNC parent directory.
420
+ * @param name - the validated single path segment to create.
421
+ * @param target - the UNC path the caller receives on success.
422
+ * @returns the created directory's absolute path.
423
+ * @throws DirectoryPickerError with code `directory-exists` or `directory-create-failed`.
424
+ */
425
+ async createDistroDirectory(parent, name, target) {
426
+ const parsed = uncToPosix(parent);
427
+ const linuxParent = parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
428
+ try {
429
+ const result = await sharedAgent(parsed.distro).exec({ cwd: linuxParent, argv: ["mkdir", "--", name], timeoutMs: 10_000 });
430
+ if (result.exitCode === 0) return target;
431
+ const detail = result.stderr.toString("utf8");
432
+ if (/File exists/i.test(detail)) throw new DirectoryPickerError("directory-exists", target, `${target} already exists`);
433
+ throw new DirectoryPickerError(
434
+ "directory-create-failed",
435
+ target,
436
+ `cannot create ${target}: ${detail.trim().length > 0 ? detail.trim() : `mkdir exited ${result.exitCode}`}`,
437
+ );
438
+ } catch (error) {
439
+ if (error instanceof DirectoryPickerError) throw error;
440
+ if (!(error instanceof AgentUnavailableError)) throw error;
441
+ try {
442
+ await runCapture([this.config.wslPath, "-d", parsed.distro, "--cd", linuxParent, "--exec", "mkdir", "--", name]);
443
+ return target;
444
+ } catch (fallbackError) {
445
+ const message = messageOf(fallbackError);
446
+ if (/File exists/i.test(message)) throw new DirectoryPickerError("directory-exists", target, `${target} already exists`);
447
+ throw new DirectoryPickerError("directory-create-failed", target, `cannot create ${target}: ${message}`);
448
+ }
449
+ }
450
+ }
347
451
  }
348
452
 
349
453
  export default WslDirectoryPicker;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The agent-backed execution handle for the file-search spawn.
3
+ *
4
+ * `dsh-tool-fs-search` consumes a deliberately small slice of the subprocess
5
+ * handle: it awaits `handle.done` for `{exitCode, signal}`, then reads
6
+ * `handle.collected.stdout/stderr.readFrom(0)` — nothing else. This module
7
+ * builds exactly that surface around one resident-agent `exec`, so a distro
8
+ * search pays one round trip on the warm agent (~10 ms) instead of a one-shot
9
+ * `wsl.exe` spawn (~400 ms of process startup for a few ms of rg).
10
+ *
11
+ * The agent being out is not an error condition here — it is the fallback
12
+ * cue. `spawnFallback` (the shipped one-shot handle the caller builds) is
13
+ * consulted lazily inside `done`; from that point the facade proxies the
14
+ * delegate's `collected` and `done` verbatim. Neither transport ever touches
15
+ * the 9p share.
16
+ *
17
+ * Cancellation shares one channel: the caller's `signal` and `kill()` abort
18
+ * the agent exec, which settles `done` as killed (`{exitCode: null, signal:
19
+ * "SIGTERM"}`) — the same shape the one-shot path reports for a killed search.
20
+ * A genuine agent failure (anything but unavailability) propagates through
21
+ * `done` as a rejection, which the tool classifies as a search failure.
22
+ *
23
+ * Pure shaping over injected collaborators, so the handle semantics are
24
+ * unit-testable without a distro.
25
+ *
26
+ * @module dsh-plugin-wsl/search-exec
27
+ */
28
+
29
+ import { AgentUnavailableError } from "./agent-errors.js";
30
+
31
+ /**
32
+ * The output-stream reader the search tool consumes: `handle.collected.stdout`
33
+ * / `stderr` expose `readFrom(offset)` with the seam's standard shape.
34
+ *
35
+ * @typedef {Object} SearchCollectedReader
36
+ * @property {(from: number) => {text: string, lossy: boolean, nextOffset: number}} readFrom
37
+ */
38
+
39
+ /**
40
+ * The slice of the subprocess handle the file-search tool consumes
41
+ * (`dsh-tool-fs-search` `runRipgrep`): settle on `done`, then read the
42
+ * collected streams. Nothing else of the handle is contract.
43
+ *
44
+ * @typedef {Object} SearchExecutionHandle
45
+ * @property {Promise<{exitCode: number|null, signal: string|null}>} done
46
+ * @property {{stdout: SearchCollectedReader, stderr: SearchCollectedReader}} collected
47
+ * @property {() => boolean|void} [kill] - optional cooperative kill; the tool
48
+ * relies on its `signal` for cancellation instead.
49
+ */
50
+
51
+ /**
52
+ * Run one search command through the agent and present it as a search handle.
53
+ *
54
+ * @param object - the handle's collaborators.
55
+ * @param {object} object.agent - the {@link WslAgent}-like runner: `exec`
56
+ * accepting `{cwd, argv, maxOutputBytes, signal}` and resolving
57
+ * `{exitCode, stdout, stderr}`; throws `AgentUnavailableError` when out.
58
+ * @param {string} object.cwd - the Linux working directory to search from.
59
+ * @param {string[]} object.argv - the distro-side argv (`rg` and its arguments).
60
+ * @param {number} [object.maxOutputBytes] - per-stream capture ceiling the
61
+ * agent enforces; the tool's own raw-output budget rides here.
62
+ * @param {AbortSignal} [object.signal] - the caller's cancellation.
63
+ * @param {() => object} object.spawnFallback - builds the one-shot handle the
64
+ * facade delegates to when the agent is out; called at most once, lazily.
65
+ * @returns {object} the search handle (`done`, `collected`, `kill`).
66
+ */
67
+ export function searchExecutionHandle({ agent, cwd, argv, maxOutputBytes = 0, signal, spawnFallback }) {
68
+ const state = { delegate: undefined, stdout: undefined, stderr: undefined };
69
+ const controller = new AbortController();
70
+ if (signal?.aborted) controller.abort(signal.reason);
71
+ else signal?.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
72
+
73
+ const reader = (slot) => ({
74
+ readFrom(from) {
75
+ const buffer = state[slot] ?? Buffer.alloc(0);
76
+ const start = Math.min(from, buffer.length);
77
+ return { text: buffer.subarray(start).toString("utf8"), lossy: false, nextOffset: buffer.length };
78
+ },
79
+ });
80
+
81
+ /** @type {object} */
82
+ const handle = {
83
+ get collected() {
84
+ if (state.delegate !== undefined) return state.delegate.collected;
85
+ return { stdout: reader("stdout"), stderr: reader("stderr") };
86
+ },
87
+ done: (async () => {
88
+ let result;
89
+ try {
90
+ result = await agent.exec({ cwd, argv, maxOutputBytes, signal: controller.signal });
91
+ state.stdout = result.stdout;
92
+ state.stderr = result.stderr;
93
+ } catch (error) {
94
+ if (!(error instanceof AgentUnavailableError)) {
95
+ if (controller.signal.aborted) {
96
+ // Our kill() or the caller's cancellation: the killed outcome the
97
+ // one-shot path reports, not a failure.
98
+ return { exitCode: null, signal: "SIGTERM" };
99
+ }
100
+ throw error;
101
+ }
102
+ const delegate = spawnFallback();
103
+ state.delegate = delegate;
104
+ return delegate.done;
105
+ }
106
+ return { exitCode: result.exitCode, signal: null };
107
+ })(),
108
+ kill() {
109
+ controller.abort(new Error("killed"));
110
+ state.delegate?.kill?.();
111
+ },
112
+ };
113
+ return handle;
114
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Which execution world a file-search spawn belongs to, and the `wsl.exe`
3
+ * argv it becomes there.
4
+ *
5
+ * `dsh-tool-fs-search` spawns the packaged ripgrep binary through
6
+ * `ctx.subprocess` with the session workspace as the working directory. Left
7
+ * alone, that spawn runs the Windows binary over the distro's 9p share — the
8
+ * one model-facing I/O path that still crossed the share, and in the slow
9
+ * direction: measured at ~17 ms per file of metadata round trips where the
10
+ * distro's own ext4 answers in microseconds. The interception point is the
11
+ * subprocess provider's `spawn`, and the decision lives here — pure,
12
+ * peer-free, and unit-testable, mirroring `terminal-route.js`.
13
+ *
14
+ * The rewrite is deliberately narrow. A spawn is intercepted only when the
15
+ * working directory NAMES the distro outright — a `\\wsl.localhost\<distro>`
16
+ * UNC — because that is the form a distro session's search carries and the
17
+ * only one that resolves without an async distro query: `spawn` hands back
18
+ * its handle synchronously, so the override must stay synchronous. A Windows
19
+ * drive directory keeps the host binary, which is native there and touches
20
+ * no share; anything else passes through untouched, exactly as before.
21
+ *
22
+ * The rewritten argv forwards every rg argument verbatim, so the tool reads
23
+ * the output format and exit codes of the distro's own rg. One addition is
24
+ * made: when the tool named no search path — its pattern rides `--regexp=`,
25
+ * and a bare `--` separator is exactly how it spells an explicit path — the
26
+ * rewrite appends `-- .`. Reason: `wsl.exe --exec` hands the distro-side rg a
27
+ * relay FIFO for stdin, and rg's readable-stdin heuristic then searches stdin
28
+ * (instantly empty) instead of walking the working directory; the packaged
29
+ * binary under the old path saw a non-readable NUL stdin and walked it. An
30
+ * explicit path makes the search deterministic on either transport.
31
+ *
32
+ * The distro-side binary must therefore exist — `scripts/bootstrap.sh` reports
33
+ * it — and a distro without one surfaces rg's own "command not found" instead
34
+ * of a silent fall-back to the share.
35
+ *
36
+ * @module dsh-plugin-wsl/search-route
37
+ */
38
+
39
+ import { uncToPosix } from "./paths.js";
40
+
41
+ /** The one program this rewrite passes through to the distro. */
42
+ const SEARCH_PROGRAM = "rg";
43
+
44
+ /**
45
+ * Whether a spawn's program is the ripgrep binary.
46
+ *
47
+ * A bare `argv[0]` is judged by its own name, so the packaged binary's
48
+ * absolute path and any other spelling of `rg` match alike.
49
+ *
50
+ * @param unknown - the spawn's `argv[0]`.
51
+ * @returns true when the spawn is a ripgrep launch.
52
+ */
53
+ export function isSearchProgram(program) {
54
+ if (typeof program !== "string" || program.length === 0) return false;
55
+ const base = program.slice(Math.max(program.lastIndexOf("/"), program.lastIndexOf("\\")) + 1);
56
+ return base.toLowerCase().replace(/\.exe$/u, "") === SEARCH_PROGRAM;
57
+ }
58
+
59
+ /**
60
+ * The distro-side rewrite of one search spawn, when it belongs in the distro.
61
+ *
62
+ * Two argv forms come back for one decision. `rgArgv` is the distro command
63
+ * itself (`rg` and its arguments) — what the resident agent execs. `wslArgv`
64
+ * is the same command wrapped for a one-shot `wsl.exe --exec` launch — the
65
+ * fallback transport for when the agent is out. The working directory becomes
66
+ * the `--cd` the distro starts in; `wsl.exe` itself must start in a Windows
67
+ * directory, so the caller supplies its own host cwd when applying `wslArgv`.
68
+ *
69
+ * @param object - the spawn's inputs.
70
+ * @param {unknown[]} object.argv - the spawn's argv; `argv[0]` is the program.
71
+ * @param {unknown} object.cwd - the spawn's working directory, any coordinate system.
72
+ * @param {string} object.wslPath - path to `wsl.exe`.
73
+ * @returns {{distro: string, linuxCwd: string, rgArgv: string[], wslArgv: string[]}|undefined} the
74
+ * rewrite, or undefined when the spawn is not a distro search and must run
75
+ * exactly as the shipped implementation would run it.
76
+ */
77
+ export function searchSpawnRewrite({ argv, cwd, wslPath }) {
78
+ if (!Array.isArray(argv) || !isSearchProgram(argv[0])) return undefined;
79
+ const named = uncToPosix(cwd);
80
+ if (named === undefined) return undefined;
81
+ const linuxCwd = named.linuxPath.length > 0 ? named.linuxPath : "/";
82
+ // No bare `--` means the tool named no search path; without one, distro-side
83
+ // rg reads the relay fifo on stdin (rg's readable-stdin heuristic) instead of
84
+ // walking the directory. `.` is the working directory — the same directory
85
+ // the packaged binary walked when the host spawned it directly.
86
+ const forwarded = argv.slice(1);
87
+ const tool = forwarded.includes("--") ? forwarded : [...forwarded, "--", "."];
88
+ const rgArgv = [SEARCH_PROGRAM, ...tool];
89
+ return {
90
+ distro: named.distro,
91
+ linuxCwd,
92
+ rgArgv,
93
+ wslArgv: [wslPath, "-d", named.distro, "--cd", linuxCwd, "--exec", ...rgArgv],
94
+ };
95
+ }
package/lib/subprocess.js CHANGED
@@ -42,16 +42,21 @@
42
42
  * ## What this provider therefore changes
43
43
  *
44
44
  * It replaces the shipped `subprocess` row at the composition level and
45
- * overrides one method: `spawnTerminal`. A `wsl.exe` launch is rewritten into a
45
+ * overrides two methods. `spawnTerminal` rewrites a `wsl.exe` launch into a
46
46
  * launch that actually names the distro and the Session workspace's Linux
47
- * directory; every other request — including every ordinary `spawn()`, which the
48
- * command path uses for `wsl.exe -lc <command>` — reaches the shipped
49
- * implementation untouched.
47
+ * directory. `spawn` rewrites one further launch — the file-search tool's
48
+ * packaged ripgrep binary, which a distro workspace's UNC working directory
49
+ * names outright — into the distro's own rg: agent-first, one round trip on
50
+ * the warm resident, with the one-shot `wsl.exe --exec` handle as the
51
+ * documented fallback when the agent is out (`lib/search-route.js` holds the
52
+ * decision, `lib/search-exec.js` the handle). Every other request —
53
+ * including every other `spawn()`, which the command path uses for
54
+ * `wsl.exe -lc <command>` — reaches the shipped implementation untouched.
50
55
  *
51
56
  * The interpreter, the PTY, output handling and process-tree teardown stay the
52
57
  * shipped implementation's, so the blast radius of replacing a root service
53
- * stays at the one thing that was wrong: the directory and distro a terminal
54
- * starts in.
58
+ * stays at the two things that were wrong: the directory and distro a terminal
59
+ * starts in, and the filesystem a search walks.
55
60
  *
56
61
  * ## What the operator must pair it with
57
62
  *
@@ -70,10 +75,16 @@
70
75
  * @module dsh-plugin-wsl/subprocess
71
76
  */
72
77
 
78
+ import { randomUUID } from "node:crypto";
73
79
  import z from "@deepseek-ai/schemastery";
74
80
  import { LocalSubprocessRuntime } from "@deepseek-ai/dsh-subprocess-local";
75
81
  import { isPosixAbsolute, toLinuxPath, uncToPosix, windowsToLinuxMount } from "./paths.js";
76
82
  import { DEFAULT_WSL_PATH, defaultDistro, wslEnvValue, wslTerminalArgv } from "./wsl.js";
83
+ import { searchSpawnRewrite } from "./search-route.js";
84
+ import { searchExecutionHandle } from "./search-exec.js";
85
+ import { sharedAgent } from "./agent-shared.js";
86
+ import { parseTerminalActivity, TERMINAL_ID_ENV, terminalActivityProbe, wrapTerminalHandle } from "./terminal-activity.js";
87
+ import { AgentUnavailableError } from "./agent-errors.js";
77
88
  import { terminalRoute } from "./terminal-route.js";
78
89
 
79
90
  /**
@@ -128,6 +139,19 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
128
139
  * the composition-owned terminal for every session.
129
140
  */
130
141
  hostSessions: z.boolean().default(true),
142
+ /**
143
+ * Let the controller reclaim an idle distro terminal. The terminal is
144
+ * marked at launch (`DSH_TERMINAL_ID` through the existing `WSLENV`
145
+ * forwarding) and its handle's `inspectActivity` is replaced with a
146
+ * distro-side observation: the resident agent scans `/proc` for the
147
+ * marker — a shell with live children is busy, a shell without children
148
+ * is idle. The shipped observation can only answer `unknown` for a distro
149
+ * terminal, and `unknown` never accumulates idle, which is why these
150
+ * terminals had to be closed by hand. When the agent is out the
151
+ * observation answers `unknown` (no reclaim) — a probe that cannot answer
152
+ * must not authorize a close. `false` restores that posture.
153
+ */
154
+ terminalIdleReclaim: z.boolean().default(true),
131
155
  });
132
156
 
133
157
  constructor(ctx, config) {
@@ -208,21 +232,134 @@ export class WslSubprocessRuntime extends LocalSubprocessRuntime {
208
232
  linuxCwd: target.linuxCwd,
209
233
  login: this.config.loginShell,
210
234
  });
211
- const forward = new Set([...SAFE_FORWARD, ...this.config.forwardEnv]);
212
- const imported = Object.keys(spec.env ?? {}).filter(
213
- (name) => forward.has(name) || name.startsWith(DSH_ENV_PREFIX),
214
- );
215
- return super.spawnTerminal({
216
- ...spec,
235
+ // The idle-reclaim mark: one env name per terminal, riding the WSLENV
236
+ // forwarding this launch already does. The distro shell and everything it
237
+ // spawns carry it in /proc/<pid>/environ — what the activity probe scans.
238
+ const terminalId = this.config.terminalIdleReclaim ? randomUUID() : undefined;
239
+ const specWithMark = terminalId === undefined ? spec : { ...spec, env: { ...spec.env, [TERMINAL_ID_ENV]: terminalId } };
240
+ const handle = await super.spawnTerminal({
241
+ ...specWithMark,
217
242
  argv,
218
243
  // A Windows CreateProcess cannot use a Linux path as its working
219
244
  // directory; the Linux directory rides on `--cd` instead.
220
245
  cwd: this.hostCwd(),
221
- env: {
222
- ...spec.env,
223
- ...(imported.length > 0 ? { WSLENV: wslEnvValue(imported) } : {}),
224
- },
246
+ env: this.forwardedEnv(specWithMark),
225
247
  });
248
+ if (terminalId === undefined) return handle;
249
+ return wrapTerminalHandle(handle, this.distroActivity(target.distro, terminalId));
250
+ }
251
+
252
+ /**
253
+ * The replacement `inspectActivity` for one distro terminal: ask the
254
+ * resident agent whether the marked shell is at its prompt.
255
+ *
256
+ * The revision increments only when the reported state changes, so the
257
+ * controller's retention accumulates idle across consecutive `idle`
258
+ * observations and restarts the clock on every transition. Any probe
259
+ * failure — the agent out, the distro stopped, a script error — answers
260
+ * `unknown`, which the retention reads as "not idle": the probe that cannot
261
+ * answer never authorizes a close. The first unexpected (non-availability)
262
+ * failure is logged once, so a broken probe is diagnosable without
263
+ * spamming the log every poll.
264
+ *
265
+ * @param distro - the distro the terminal lives in.
266
+ * @param terminalId - the launch's `DSH_TERMINAL_ID` mark.
267
+ * @returns the seam's activity observation.
268
+ */
269
+ distroActivity(distro, terminalId) {
270
+ let revision = 0;
271
+ let lastState = "";
272
+ let warned = false;
273
+ return async () => {
274
+ try {
275
+ const probe = terminalActivityProbe(terminalId);
276
+ const result = await sharedAgent(distro).exec({
277
+ cwd: probe.cwd,
278
+ argv: probe.argv,
279
+ maxOutputBytes: 256,
280
+ timeoutMs: 10_000,
281
+ });
282
+ const state = parseTerminalActivity(result.stdout) ?? "unknown";
283
+ if (state !== lastState) {
284
+ revision += 1;
285
+ lastState = state;
286
+ }
287
+ return { state, revision };
288
+ } catch (error) {
289
+ if (!(error instanceof AgentUnavailableError) && !warned) {
290
+ warned = true;
291
+ this.ctx.logger?.warn(`wsl-subprocess: the terminal activity probe failed and idle reclamation is paused (${String(error?.message ?? error)})`);
292
+ }
293
+ return { state: "unknown", revision };
294
+ }
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Open one host-side process, rewriting two kinds of launches into the
300
+ * distro: the file-search tool's ripgrep (agent-first, one-shot fallback)
301
+ * and git snapshot commands (one-shot only — snapshot operations are not
302
+ * latency-sensitive, and their stdin relay and collected streams are the
303
+ * shipped handle's). Both decisions are pure coordinate functions
304
+ * (`search-route.js`, `git-route.js`); a spawn neither names (any other
305
+ * program, any drive or relative directory) reaches the shipped
306
+ * implementation verbatim.
307
+ *
308
+ * @param spec - the spawn request.
309
+ * @returns the live search handle.
310
+ */
311
+ spawn(spec) {
312
+ const gitRewrite = gitSpawnRewrite({ argv: spec.argv, cwd: spec.cwd, wslPath: this.config.wslPath });
313
+ if (gitRewrite !== undefined) {
314
+ // The snapshot environment (GIT_INDEX_FILE / GIT_OBJECT_DIRECTORY /
315
+ // GIT_ALTERNATE_OBJECT_DIRECTORIES and the git hygiene flags) is
316
+ // path-shaped in Windows form; the values are translated into distro
317
+ // paths and every forwarded name rides WSLENV. The user's real index,
318
+ // object store, work tree, and refs stay untouched by design — the
319
+ // snapshot runs in the tool's private scratch.
320
+ const { env, wslenv } = translateGitEnv(spec.env);
321
+ return super.spawn({
322
+ ...spec,
323
+ argv: gitRewrite.wslArgv,
324
+ cwd: this.hostCwd(),
325
+ env: wslenv.length > 0 ? { ...env, WSLENV: wslEnvValue(wslenv) } : env,
326
+ });
327
+ }
328
+ const rewrite = searchSpawnRewrite({ argv: spec.argv, cwd: spec.cwd, wslPath: this.config.wslPath });
329
+ if (rewrite === undefined) return super.spawn(spec);
330
+ return searchExecutionHandle({
331
+ agent: sharedAgent(rewrite.distro),
332
+ cwd: rewrite.linuxCwd,
333
+ argv: rewrite.rgArgv,
334
+ // One budget serves both streams; the stdout budget is the binding one,
335
+ // and a pathological stderr is still bounded by it.
336
+ maxOutputBytes: spec.stdio?.stdout?.maxBytes ?? 0,
337
+ signal: spec.signal,
338
+ spawnFallback: () =>
339
+ super.spawn({
340
+ ...spec,
341
+ argv: rewrite.wslArgv,
342
+ // `wsl.exe --cd` carries the Linux directory; the host process itself
343
+ // must start in a Windows directory, as with a terminal launch.
344
+ cwd: this.hostCwd(),
345
+ env: this.forwardedEnv(spec),
346
+ }),
347
+ });
348
+ }
349
+
350
+ /**
351
+ * The environment one `wsl.exe` launch forwards: the safe list plus every
352
+ * managed `DSH_*` name, admitted through `WSLENV` and never `PATH` — see the
353
+ * WSLENV note in the recipes.
354
+ * @param spec - the launch request whose env is forwarded.
355
+ * @returns the environment the host launch carries.
356
+ */
357
+ forwardedEnv(spec) {
358
+ const forward = new Set([...SAFE_FORWARD, ...this.config.forwardEnv]);
359
+ const imported = Object.keys(spec.env ?? {}).filter(
360
+ (name) => forward.has(name) || name.startsWith(DSH_ENV_PREFIX),
361
+ );
362
+ return imported.length > 0 ? { ...spec.env, WSLENV: wslEnvValue(imported) } : spec.env;
226
363
  }
227
364
 
228
365
  /**