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/CHANGELOG.md +176 -0
- package/README.md +6 -6
- package/cordis.patch.yml +48 -0
- package/docs/ARCHITECTURE.md +33 -20
- package/docs/CONFIGURATION.md +2 -2
- package/docs/LIMITATIONS.md +52 -54
- package/docs/PARITY.md +18 -4
- package/docs/README.zh.md +7 -7
- package/lib/fs-decisions.js +22 -25
- package/lib/fs-routing.js +176 -0
- package/lib/git-route.js +142 -0
- package/lib/index.js +77 -307
- package/lib/listing.js +47 -0
- package/lib/picker.js +121 -17
- package/lib/search-exec.js +114 -0
- package/lib/search-route.js +95 -0
- package/lib/subprocess.js +153 -16
- package/lib/terminal-activity.js +121 -0
- package/lib/workspace-files-route.js +205 -0
- package/lib/workspace-files-wsl.js +341 -0
- package/lib/wsl.js +0 -39
- package/package.json +22 -3
- package/lib/fs-publish.js +0 -42
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
|
|
35
|
-
*
|
|
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
|
|
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 (
|
|
377
|
+
if (rows.length === maxEntries) {
|
|
305
378
|
truncated = true;
|
|
306
379
|
break;
|
|
307
380
|
}
|
|
308
|
-
|
|
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
|
|
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
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
);
|
|
215
|
-
|
|
216
|
-
|
|
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
|
/**
|