dsh-wsl-workspace 0.4.3 → 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.
@@ -0,0 +1,957 @@
1
+ import { defaultDistroSync, isAbsoluteLinuxPath, parseWslUnc, windowsToMntPath } from "./wsl-CECXiGZH.js";
2
+ import { getWorkspaceUsername } from "./wsl-credentials-BJXc9tfi.js";
3
+ import z from "@deepseek-ai/schemastery";
4
+ import { posix } from "node:path";
5
+ import { execFile } from "node:child_process";
6
+ import { defineTool } from "@deepseek-ai/dsh-tools";
7
+ import { GLOB_MAX_RESULTS, GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, RAW_OUTPUT_MAX_BYTES, SEARCH_META_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, formatGlobOutput, formatGrepMatches, formatGrepOutput, parseGlobArgs, parseGrepArgs, presentGlobCall, presentGlobResult, presentGrepCall, presentGrepResult, previewLine, sampleAcrossTopLevel, trySaveFormattedResult } from "@deepseek-ai/dsh-tool-fs-search";
8
+
9
+ //#region src/host/wsl-search.ts
10
+ /**
11
+ * The fixed `grep` script the distribution runs. Model-controlled values arrive
12
+ * as positional parameters, so no value is ever parsed by a shell; `$0` is the
13
+ * literal {@link SCRIPT_ARGV0} marker (not a path).
14
+ *
15
+ * `-r` recurses, `-n` numbers lines, `-I` skips binary files, `-E` is POSIX ERE,
16
+ * `-H` forces the file name onto every record (grep omits it when handed a single
17
+ * file, which would break the framing below), and `-Z` terminates each file name
18
+ * with NUL so the Node side can frame `path\0line:text` records unambiguously.
19
+ *
20
+ * The exclusions stand in for ripgrep's default hidden-entry skipping, which GNU
21
+ * grep cannot express (it has no `.gitignore` support either). Three GNU quirks
22
+ * shape how they are written, each measured against grep 3.12 on Ubuntu:
23
+ * - A file `--exclude` pattern silently cancels `--include` entirely, so the
24
+ * hidden-file guard rides `--include='[!.]*'` instead — and only when the
25
+ * caller passed no filter of its own (repeated includes are OR-ed) and only
26
+ * for a *directory* target, because a file the caller named explicitly is
27
+ * exactly what it asked for.
28
+ * - `--exclude-dir` excludes the search root itself when its base name starts
29
+ * with a dot, so that flag is skipped for a dot-rooted target.
30
+ * - `head -c` bounds the transfer in-distro, `PIPESTATUS[0]` keeps grep's own
31
+ * status rather than head's, and the search root is resolved physically first
32
+ * so a linked-in directory is searched where the file tools would read it.
33
+ *
34
+ * The search root is resolved physically first, so the printed paths are
35
+ * absolute and a linked-in directory is searched at its real path — the same
36
+ * place the file tools read and write.
37
+ *
38
+ * A distribution whose `grep` is not GNU's exits 3 with a diagnostic instead of
39
+ * framing records the parser cannot read. Every mainstream WSL distribution
40
+ * (Debian, Ubuntu, Fedora, Arch, openSUSE) ships GNU grep; Alpine's busybox grep
41
+ * would report that clearly.
42
+ */
43
+ const GREP_SCRIPT = [
44
+ "set -u",
45
+ "pattern=$1; include=$2; target=$3; cap=$4",
46
+ "[ -n \"$target\" ] || target=.",
47
+ "if [ -d \"$target\" ]; then",
48
+ " target=$(cd -- \"$target\" && pwd -P)",
49
+ " dir=1",
50
+ "else",
51
+ " case $target in /*) ;; *) target=$PWD/$target ;; esac",
52
+ " dir=0",
53
+ "fi",
54
+ "if ! grep --version 2>/dev/null | head -n 1 | grep -q GNU; then",
55
+ " printf \"grep is not GNU grep: %s\\n\" \"$(grep --version 2>/dev/null | head -n 1)\" >&2",
56
+ " exit 3",
57
+ "fi",
58
+ "opts=(-rnIEH -Z)",
59
+ "if [ \"$dir\" = 1 ]; then",
60
+ " opts+=(--exclude-dir=node_modules)",
61
+ " case ${target##*/} in .*) ;; *) opts+=(--exclude-dir=\".*\") ;; esac",
62
+ " [ -n \"$include\" ] || opts+=(--include=\"[!.]*\")",
63
+ "fi",
64
+ "if [ -n \"$include\" ]; then",
65
+ " while IFS= read -r one; do [ -n \"$one\" ] && opts+=(--include=\"$one\"); done <<<\"$include\"",
66
+ "fi",
67
+ "grep \"${opts[@]}\" --regexp=\"$pattern\" -- \"$target\" | head -c \"$cap\"",
68
+ "exit \"${PIPESTATUS[0]}\""
69
+ ].join("\n");
70
+ /**
71
+ * The fixed `glob` script: an absolute file listing with modification times.
72
+ *
73
+ * `%T@` is the mtime in seconds (GNU find only), so the caller can order the
74
+ * result the way ripgrep's `--sort=modified` does — oldest first — without a
75
+ * stat per file and without a 9P round trip per file. VCS metadata directories
76
+ * are pruned; the pattern itself is matched in Node.
77
+ *
78
+ * The header is `G`/`P` (GNU `-printf`, else the plain `-print0` fallback)
79
+ * immediately followed by the physically resolved root, **NUL terminated** so a
80
+ * path containing a newline cannot split it; every record after it is NUL framed
81
+ * too. `PIPESTATUS[0]` is what the script exits with, because a `find` that
82
+ * cannot read the target must not look like an empty directory.
83
+ */
84
+ const GLOB_SCRIPT = [
85
+ "set -u",
86
+ "target=$1; cap=$2",
87
+ "[ -n \"$target\" ] || target=.",
88
+ "if [ -d \"$target\" ]; then target=$(cd -- \"$target\" && pwd -P); else",
89
+ " case $target in /*) ;; *) target=$PWD/$target ;; esac",
90
+ "fi",
91
+ "prune=(-name .git -o -name .hg -o -name .svn -o -name .bzr -o -name .jj -o -name .sl)",
92
+ "if find --version 2>/dev/null | head -n 1 | grep -q GNU; then",
93
+ " { printf 'G%s\\0' \"$target\"; find \"$target\" \\( \"${prune[@]}\" \\) -prune -o -type f -printf '%T@\\t%p\\0'; } | head -c \"$cap\"",
94
+ "else",
95
+ " { printf 'P%s\\0' \"$target\"; find \"$target\" \\( \"${prune[@]}\" \\) -prune -o -type f -print0; } | head -c \"$cap\"",
96
+ "fi",
97
+ "exit \"${PIPESTATUS[0]}\""
98
+ ].join("\n");
99
+ /** The marker `$0` of both scripts; a fixed string, never a path from the model. */
100
+ const SCRIPT_ARGV0 = "dsh";
101
+ /** GNU grep stderr shapes that mean "the pattern is not a valid regex". */
102
+ const INVALID_PATTERN = /Unmatched|Invalid (?:regular expression|range end|character class|back reference|preceding regular expression)|unrecognized|trailing backslash|parentheses not balanced|Invalid collating element/i;
103
+ /**
104
+ * The defaults, kept as data as well as schema fields. A world row that mounts
105
+ * this plugin without a `config:` block hands `apply` an *undefined* config —
106
+ * schemastery's defaults are not applied on that path — and a live session
107
+ * caught exactly that as `Cannot read properties of undefined (reading
108
+ * 'grepMaxMatches')`, which failed the whole world. Reading the defaults from one
109
+ * place keeps the schema and the fallback from drifting apart.
110
+ */
111
+ const DEFAULTS = {
112
+ grepMaxMatches: GREP_MAX_MATCHES,
113
+ grepMaxLineBytes: GREP_MAX_LINE_BYTES,
114
+ globMaxResults: GLOB_MAX_RESULTS,
115
+ sampleOverCapGlobResults: false,
116
+ searchMetaMaxBytes: SEARCH_META_MAX_BYTES,
117
+ rawOutputMaxBytes: RAW_OUTPUT_MAX_BYTES,
118
+ timeoutMs: SEARCH_TIMEOUT_MS,
119
+ wslPath: "wsl.exe"
120
+ };
121
+ /** Validated plugin config. */
122
+ const Config = z.object({
123
+ grepMaxMatches: z.number().default(DEFAULTS.grepMaxMatches),
124
+ grepMaxLineBytes: z.number().default(DEFAULTS.grepMaxLineBytes),
125
+ globMaxResults: z.number().default(DEFAULTS.globMaxResults),
126
+ sampleOverCapGlobResults: z.boolean().default(DEFAULTS.sampleOverCapGlobResults),
127
+ searchMetaMaxBytes: z.number().default(DEFAULTS.searchMetaMaxBytes),
128
+ rawOutputMaxBytes: z.number().default(DEFAULTS.rawOutputMaxBytes),
129
+ timeoutMs: z.number().default(DEFAULTS.timeoutMs),
130
+ wslPath: z.string().default(DEFAULTS.wslPath),
131
+ distro: z.string()
132
+ });
133
+ /** Services these tools register into. */
134
+ const inject = ["tools"];
135
+ /**
136
+ * Frame a raw `grep -rnIE -Z` stdout buffer into matches.
137
+ *
138
+ * GNU grep prints `<path>\0<line>:<text>\n` per match, so splitting on NUL and
139
+ * then on the first newline of each following segment recovers all three fields
140
+ * even when a path contains spaces, colons or newlines (the text cannot, because
141
+ * grep reports one line at a time and `-I` keeps NUL bytes out of the stream).
142
+ * @param stdout - the complete raw stdout bytes.
143
+ * @returns the matches in output order.
144
+ */
145
+ function parseGrepRecords(stdout) {
146
+ const records = [];
147
+ const segments = stdout.toString("utf8").split("\0");
148
+ let path = segments[0] ?? "";
149
+ for (let index = 1; index < segments.length; index += 1) {
150
+ const segment = segments[index] ?? "";
151
+ if (segment === "") continue;
152
+ const newline = segment.indexOf("\n");
153
+ const head = newline < 0 ? segment : segment.slice(0, newline);
154
+ const colon = head.indexOf(":");
155
+ const lineNumber = Number.parseInt(colon < 0 ? head : head.slice(0, colon), 10);
156
+ if (Number.isFinite(lineNumber)) records.push({
157
+ path,
158
+ lineNumber,
159
+ line: colon < 0 ? "" : head.slice(colon + 1)
160
+ });
161
+ path = newline < 0 ? "" : segment.slice(newline + 1);
162
+ }
163
+ return records;
164
+ }
165
+ /**
166
+ * Frame a raw `glob` stdout buffer into a listing mode, a resolved root and
167
+ * entries.
168
+ *
169
+ * The script prefixes its output with a NUL-terminated `<mode><root>` header
170
+ * (`G` = GNU `-printf`: `mtime\tpath` records; `P` = plain `-print0`: paths only,
171
+ * no modification times). NUL framing keeps a root whose own path contains a
172
+ * newline in one piece.
173
+ * @param stdout - the complete raw stdout bytes.
174
+ * @returns the listing; `plain` entries carry `mtimeMs: 0`.
175
+ */
176
+ function parseGlobRecords(stdout) {
177
+ const headerEnd = stdout.indexOf(0);
178
+ const header = (headerEnd < 0 ? stdout : stdout.subarray(0, headerEnd)).toString("utf8");
179
+ const mode = header.startsWith("G") ? "gnu" : "plain";
180
+ const root = header.length > 0 ? header.slice(1) : "";
181
+ const files = [];
182
+ const body = headerEnd < 0 ? Buffer.alloc(0) : stdout.subarray(headerEnd + 1);
183
+ for (const entry of body.toString("utf8").split("\0")) {
184
+ if (entry === "") continue;
185
+ if (mode === "plain") {
186
+ files.push({
187
+ path: entry,
188
+ mtimeMs: 0
189
+ });
190
+ continue;
191
+ }
192
+ const tab = entry.indexOf(" ");
193
+ if (tab < 0) continue;
194
+ const seconds = Number.parseFloat(entry.slice(0, tab));
195
+ files.push({
196
+ path: entry.slice(tab + 1),
197
+ mtimeMs: Number.isFinite(seconds) ? Math.round(seconds * 1e3) : 0
198
+ });
199
+ }
200
+ return {
201
+ mode,
202
+ root,
203
+ files
204
+ };
205
+ }
206
+ /**
207
+ * Expand one glob's `{a,b,c}` alternations into the equivalent list of plain
208
+ * globs. ripgrep's globset understands braces; GNU grep's `--include` does not,
209
+ * so the tool passes one `--include` per expansion (repeated includes are OR-ed).
210
+ * @param glob - the glob to expand.
211
+ * @returns every brace-free alternative, in source order.
212
+ */
213
+ function expandBraces(glob) {
214
+ const open = glob.indexOf("{");
215
+ if (open < 0) return [glob];
216
+ const close = glob.indexOf("}", open + 1);
217
+ if (close < 0) return [glob];
218
+ const head = glob.slice(0, open);
219
+ const tail = glob.slice(close + 1);
220
+ return glob.slice(open + 1, close).split(",").flatMap((part) => expandBraces(`${head}${part}${tail}`));
221
+ }
222
+ /**
223
+ * Translate one gitignore-style glob into a regular-expression source (no
224
+ * anchors). `*` and `?` never cross a separator, `**` does (a `**` followed by a
225
+ * separator also matches zero segments), `[...]` is a character class (negated
226
+ * by a leading `!` or `^`), and `{a,b}` is an alternation.
227
+ * @param pattern - the glob source.
228
+ * @returns the unanchored regex source.
229
+ */
230
+ function globSource(pattern) {
231
+ let source = "";
232
+ let index = 0;
233
+ while (index < pattern.length) {
234
+ const char = pattern[index] ?? "";
235
+ if (char === "*") {
236
+ if (pattern[index + 1] === "*") {
237
+ if (pattern[index + 2] === "/") {
238
+ source += "(?:[^/]*/)*";
239
+ index += 3;
240
+ continue;
241
+ }
242
+ source += ".*";
243
+ index += 2;
244
+ continue;
245
+ }
246
+ source += "[^/]*";
247
+ index += 1;
248
+ continue;
249
+ }
250
+ if (char === "?") {
251
+ source += "[^/]";
252
+ index += 1;
253
+ continue;
254
+ }
255
+ if (char === "[") {
256
+ const close = pattern.indexOf("]", index + 2);
257
+ if (close > index + 1) {
258
+ const body = pattern.slice(index + 1, close);
259
+ const negated = body.startsWith("!") || body.startsWith("^");
260
+ const chars = (negated ? body.slice(1) : body).replace(/[\\^\]]/g, "\\$&");
261
+ source += `[${negated ? "^" : ""}${chars}]`;
262
+ index = close + 1;
263
+ continue;
264
+ }
265
+ source += "\\[";
266
+ index += 1;
267
+ continue;
268
+ }
269
+ if (char === "{") {
270
+ const close = pattern.indexOf("}", index + 1);
271
+ if (close > index + 1) {
272
+ const alternatives = pattern.slice(index + 1, close).split(",").map((part) => globSource(part));
273
+ source += `(?:${alternatives.join("|")})`;
274
+ index = close + 1;
275
+ continue;
276
+ }
277
+ source += "\\{";
278
+ index += 1;
279
+ continue;
280
+ }
281
+ source += /[.+^$()|\\]/.test(char) ? `\\${char}` : char;
282
+ index += 1;
283
+ }
284
+ return source;
285
+ }
286
+ /**
287
+ * Compile one glob into a whole-string matcher.
288
+ * @param pattern - the glob source.
289
+ * @returns the anchored regular expression.
290
+ */
291
+ function globToRegExp(pattern) {
292
+ return /* @__PURE__ */ new RegExp(`^${globSource(pattern)}$`);
293
+ }
294
+ /**
295
+ * Whether one file passes a glob filter. A pattern containing `/` matches the
296
+ * path relative to the search root; one without matches the basename at any
297
+ * depth. A leading `!` negates the match, so `!*.log` keeps everything that is
298
+ * not a log file.
299
+ * @param pattern - the glob filter.
300
+ * @param relativePath - the candidate's path relative to the search root.
301
+ * @returns true when the file is selected.
302
+ */
303
+ function matchGlob(pattern, relativePath) {
304
+ let body = pattern;
305
+ let negated = false;
306
+ if (body.startsWith("!")) {
307
+ negated = true;
308
+ body = body.slice(1);
309
+ }
310
+ const candidate = body.includes("/") ? relativePath : posix.basename(relativePath);
311
+ const matched = globToRegExp(body).test(candidate);
312
+ return negated ? !matched : matched;
313
+ }
314
+ /**
315
+ * Map one absolute Linux path to what the model should see: relative to the
316
+ * session workdir when it is inside it, absolute otherwise. The POSIX twin of
317
+ * the host suite's `toWorkdirRelative`, which is Windows-flavored
318
+ * (`path.relative` prints `src\a.ts`).
319
+ * @param absolutePath - the search's absolute Linux path.
320
+ * @param workdir - the session workdir in Linux coordinates.
321
+ * @returns the display path.
322
+ */
323
+ function displayPath(absolutePath, workdir) {
324
+ const relative = posix.relative(workdir, absolutePath);
325
+ if (relative === "") return ".";
326
+ return relative.startsWith("..") || posix.isAbsolute(relative) ? absolutePath : relative;
327
+ }
328
+ /**
329
+ * Apply DSH's inline caps to a match list: preview each line and keep the first
330
+ * `maxMatches`. Byte-for-byte the outcome of the host suite's private
331
+ * `retainGrepMatches` (`ItemRetainer` head + `previewLine`), which that package
332
+ * does not export.
333
+ * @param matches - every match the search parsed.
334
+ * @param maxMatches - the inline match cap.
335
+ * @param maxLineBytes - the per-line preview budget.
336
+ * @returns the retention outcome, with exact omission metadata.
337
+ */
338
+ function retainMatches(matches, maxMatches, maxLineBytes) {
339
+ const items = matches.slice(0, Math.max(0, maxMatches)).map((match) => ({
340
+ ...match,
341
+ line: previewLine(match.line, maxLineBytes)
342
+ }));
343
+ const omitted = Math.max(0, matches.length - items.length);
344
+ return {
345
+ items,
346
+ truncated: omitted > 0,
347
+ seen: matches.length,
348
+ kept: items.length,
349
+ omitted: omitted > 0 ? {
350
+ kind: "exact",
351
+ count: omitted
352
+ } : { kind: "none" }
353
+ };
354
+ }
355
+ /**
356
+ * Apply DSH's inline cap to a path list (the host suite's private
357
+ * `retainGlobPaths`).
358
+ * @param paths - every discovered path.
359
+ * @param maxResults - the inline path cap.
360
+ * @returns the retention outcome, with exact omission metadata.
361
+ */
362
+ function retainPaths(paths, maxResults) {
363
+ const items = paths.slice(0, Math.max(0, maxResults));
364
+ const omitted = Math.max(0, paths.length - items.length);
365
+ return {
366
+ items,
367
+ truncated: omitted > 0,
368
+ seen: paths.length,
369
+ kept: items.length,
370
+ omitted: omitted > 0 ? {
371
+ kind: "exact",
372
+ count: omitted
373
+ } : { kind: "none" }
374
+ };
375
+ }
376
+ /** The serialized UTF-8 byte size of one card payload (the size persisted and re-sent). */
377
+ function metaBytes(meta) {
378
+ return Buffer.byteLength(JSON.stringify(meta), "utf8");
379
+ }
380
+ /**
381
+ * Drop trailing file groups (or paths) until the serialized card metadata fits
382
+ * `maxMetaBytes`, marking it truncated. Mirrors the host suite's private
383
+ * `capMetaBytes`: `total` keeps counting what the search found, and a single
384
+ * oversized item survives rather than producing an empty card.
385
+ * @param meta - the projected metadata, already capped to the inline item count.
386
+ * @param maxMetaBytes - the serialized byte budget.
387
+ * @returns the same metadata when it fits, else a byte-bounded copy.
388
+ */
389
+ function capMetaBytes(meta, maxMetaBytes) {
390
+ if (metaBytes(meta) <= maxMetaBytes) return meta;
391
+ if (meta.shape === "matches") {
392
+ const files = [...meta.files];
393
+ while (files.length > 1 && metaBytes({
394
+ ...meta,
395
+ files,
396
+ truncated: true
397
+ }) > maxMetaBytes) files.pop();
398
+ return {
399
+ ...meta,
400
+ files,
401
+ truncated: true
402
+ };
403
+ }
404
+ const paths = [...meta.paths];
405
+ while (paths.length > 1 && metaBytes({
406
+ ...meta,
407
+ paths,
408
+ truncated: true
409
+ }) > maxMetaBytes) paths.pop();
410
+ return {
411
+ ...meta,
412
+ paths,
413
+ truncated: true
414
+ };
415
+ }
416
+ /**
417
+ * Project a retained match page into the `matches`-shaped search card: the host
418
+ * suite's private `grepSearchMeta` + `groupMatchesByFile`, reproduced verbatim
419
+ * (first-seen file order, then the byte cap).
420
+ * @param page - the retained page (previewed, capped) or any equivalent subset.
421
+ * @param maxMetaBytes - the serialized metadata budget.
422
+ * @returns the card metadata.
423
+ */
424
+ function grepSearchMeta(page, maxMetaBytes) {
425
+ const byFile = /* @__PURE__ */ new Map();
426
+ for (const match of page.items) {
427
+ const entry = {
428
+ lineNumber: match.lineNumber,
429
+ line: match.line
430
+ };
431
+ const group = byFile.get(match.path);
432
+ if (group === void 0) byFile.set(match.path, [entry]);
433
+ else group.push(entry);
434
+ }
435
+ return capMetaBytes({
436
+ shape: "matches",
437
+ files: Array.from(byFile, ([path, matches]) => ({
438
+ path,
439
+ matches
440
+ })),
441
+ truncated: page.truncated,
442
+ total: page.seen
443
+ }, maxMetaBytes);
444
+ }
445
+ /**
446
+ * Project a retained path page into the `paths`-shaped search card (the host
447
+ * suite's private `globSearchMeta`).
448
+ * @param page - the retained page (capped) or any equivalent subset.
449
+ * @param maxMetaBytes - the serialized metadata budget.
450
+ * @returns the card metadata.
451
+ */
452
+ function globSearchMeta(page, maxMetaBytes) {
453
+ return capMetaBytes({
454
+ shape: "paths",
455
+ paths: [...page.items],
456
+ truncated: page.truncated,
457
+ total: page.seen
458
+ }, maxMetaBytes);
459
+ }
460
+ /**
461
+ * Format one capped `glob` page in the flat (head) style, mirroring the host
462
+ * suite's private `formatGlobPage`.
463
+ * @param items - the page shown inline.
464
+ * @param seen - how many paths the complete result holds.
465
+ * @param spillRef - the saved complete-result reference, or undefined when unsaved.
466
+ * @returns the model-facing text.
467
+ */
468
+ function formatGlobPage(items, seen, spillRef) {
469
+ const recovery = spillRef !== void 0 ? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}` : "The complete result could not be saved; narrow pattern or path to see more.";
470
+ return `${items.join("\n")}\n\n(Showing ${items.length} of ${seen} paths. ${recovery})`;
471
+ }
472
+ /**
473
+ * Normalize a spill reference for rendering.
474
+ *
475
+ * The canonical value is validated against the tool's output schema, and a
476
+ * backend's `SpillRef` carries fields of its own (`@deepseek-ai/dsh-spill`'s
477
+ * `SpillRef` is `{locator, bytes, retrievalHint}`), so the schema cannot close
478
+ * the object. This keeps the renderer's contract to the two fields it reads and
479
+ * survives a backend that omits the hint.
480
+ * @param spill - the backend's reference, as returned into the canonical value.
481
+ * @returns a render-ready reference, or undefined when there is nothing to name.
482
+ */
483
+ function normalizeSpill(spill) {
484
+ if (spill === void 0 || spill === null) return void 0;
485
+ const locator = spill.locator;
486
+ if (typeof locator !== "string" || locator === "") return void 0;
487
+ const retrievalHint = spill.retrievalHint;
488
+ return {
489
+ ...spill,
490
+ locator,
491
+ retrievalHint: typeof retrievalHint === "string" ? retrievalHint : ""
492
+ };
493
+ }
494
+ /**
495
+ * The spill reference's schema: an object whose extra fields are the backend's
496
+ * business. Requiring only what the footer prints keeps a version's `SpillRef`
497
+ * (which carries `bytes` too, and is branded) from failing the tool's own output
498
+ * validation — which is why `additionalProperties` is `true` here.
499
+ */
500
+ const SPILL_SCHEMA = {
501
+ type: "object",
502
+ additionalProperties: true,
503
+ properties: {
504
+ locator: { type: "string" },
505
+ retrievalHint: { type: "string" }
506
+ }
507
+ };
508
+ /**
509
+ * Format one `glob` result exactly as the host suite's private `renderGlobPaths`
510
+ * does: whole when within the cap, else the modification-time head or the
511
+ * top-level sample, with the same footer.
512
+ * @param paths - the complete display-path list, in modification-time order.
513
+ * @param caps - the inline cap and the sampling switch.
514
+ * @param root - the search root in the same display-path space as `paths`.
515
+ * @param spillRef - the saved complete-result reference, or undefined when unsaved.
516
+ * @returns the model-facing text.
517
+ */
518
+ function renderGlobText(paths, caps, root, spillRef) {
519
+ if (paths.length === 0) return "No files found";
520
+ if (paths.length <= caps.maxResults) return paths.join("\n");
521
+ if (!caps.sampleOverCapGlobResults) return formatGlobPage(paths.slice(0, caps.maxResults), paths.length, spillRef);
522
+ return formatGlobOutput(sampleAcrossTopLevel([...paths], caps.maxResults, root), paths.length, spillRef);
523
+ }
524
+ /**
525
+ * The inline page a `glob` card shows, computed the same way {@link renderGlobText}
526
+ * computes its model page so text and card never disagree (the host suite's
527
+ * private `globCardPage`).
528
+ * @param paths - the complete display-path list.
529
+ * @param caps - the inline cap and the sampling switch.
530
+ * @param root - the search root in the same display-path space as `paths`.
531
+ * @returns the page and whether the complete result was capped.
532
+ */
533
+ function globCardPage(paths, caps, root) {
534
+ if (paths.length <= caps.maxResults) return {
535
+ items: [...paths],
536
+ truncated: false
537
+ };
538
+ if (!caps.sampleOverCapGlobResults) return {
539
+ items: paths.slice(0, caps.maxResults),
540
+ truncated: true
541
+ };
542
+ return {
543
+ items: sampleAcrossTopLevel([...paths], caps.maxResults, root).items,
544
+ truncated: true
545
+ };
546
+ }
547
+ /**
548
+ * Format one `grep` result exactly as the host suite's private
549
+ * `formatRetainedGrep` does, including its zero-match wording.
550
+ * @param matches - every match the search returned, with display paths.
551
+ * @param caps - the inline caps.
552
+ * @param spillRef - the saved complete-result reference, or undefined when unsaved.
553
+ * @returns the model-facing text.
554
+ */
555
+ function renderGrepText(matches, caps, spillRef) {
556
+ const retained = retainMatches(matches, caps.maxMatches, caps.maxLineBytes);
557
+ if (retained.seen === 0) return "No matches found";
558
+ return formatGrepOutput(retained, spillRef);
559
+ }
560
+ /**
561
+ * Resolve the distribution and Linux workdir one search runs in, mirroring the
562
+ * shell and filesystem providers' chain: a WSL UNC cwd carries both, an absolute
563
+ * Linux cwd uses the configured distribution (then the host default).
564
+ * @param cwd - the calling session's cwd.
565
+ * @param configured - the world's configured distribution, when any.
566
+ * @returns the target, or undefined when the cwd is not in a WSL world.
567
+ */
568
+ function resolveTarget(cwd, configured) {
569
+ if (cwd === void 0 || cwd === "") return void 0;
570
+ const unc = parseWslUnc(cwd);
571
+ if (unc !== null) {
572
+ const linuxCwd = unc.linuxPath === "" ? "/" : unc.linuxPath;
573
+ const username = getWorkspaceUsername(cwd);
574
+ return {
575
+ distro: unc.distro,
576
+ linuxCwd,
577
+ ...username === void 0 ? {} : { username }
578
+ };
579
+ }
580
+ if (!isAbsoluteLinuxPath(cwd)) return void 0;
581
+ const distro = configured !== void 0 && configured.trim() !== "" ? configured.trim() : defaultDistroSync();
582
+ if (distro === void 0 || distro === "") return void 0;
583
+ return {
584
+ distro,
585
+ linuxCwd: cwd
586
+ };
587
+ }
588
+ /**
589
+ * Express a model-supplied search target in Linux coordinates: a WSL UNC path
590
+ * becomes its Linux form, a Windows drive path becomes its `/mnt/<drive>` form
591
+ * (the same translation the file tools apply, so `grep path='D:\proj'` searches
592
+ * what `read D:\proj\a.ts` reads), and everything else — absolute, relative,
593
+ * empty — is used as typed and resolved by the distribution against the session
594
+ * workdir.
595
+ * @param path - the tool call's `path`, when given.
596
+ * @returns the script's `target` parameter.
597
+ */
598
+ function linuxTarget(path) {
599
+ if (path === void 0) return "";
600
+ const unc = parseWslUnc(path);
601
+ if (unc !== null) return unc.linuxPath === "" ? "/" : unc.linuxPath;
602
+ return windowsToMntPath(path) ?? path;
603
+ }
604
+ /**
605
+ * Build the `wsl.exe` argv for one search. Values ride as positional parameters
606
+ * after the fixed script, so nothing the model typed is ever parsed by a shell.
607
+ * @param target - where to run.
608
+ * @param script - the fixed script.
609
+ * @param values - the script's positional parameters.
610
+ * @returns the complete argv vector.
611
+ */
612
+ function buildWslArgv(target, script, values) {
613
+ return [
614
+ "-d",
615
+ target.distro,
616
+ ...target.username === void 0 ? [] : ["-u", target.username],
617
+ "--cd",
618
+ target.linuxCwd,
619
+ "-e",
620
+ "bash",
621
+ "-c",
622
+ script,
623
+ SCRIPT_ARGV0,
624
+ ...values
625
+ ];
626
+ }
627
+ /**
628
+ * Run one fixed script inside the distribution.
629
+ *
630
+ * Errors stay in the host suite's two domains: a spawn failure (a missing
631
+ * `wsl.exe`) rejects, while every completed or killed run comes back for the
632
+ * caller to classify.
633
+ * @param argv - the complete `wsl.exe` argv.
634
+ * @param wslPath - the executable to run.
635
+ * @param timeoutMs - the hard kill deadline.
636
+ * @param rawOutputMaxBytes - the stdout budget the script already bounded.
637
+ * @param signal - caller cancellation.
638
+ * @returns the run outcome.
639
+ */
640
+ function runInDistro(argv, wslPath, timeoutMs, rawOutputMaxBytes, signal) {
641
+ return new Promise((settle, fail) => {
642
+ execFile(wslPath, [...argv], {
643
+ maxBuffer: rawOutputMaxBytes + 1048576,
644
+ encoding: "buffer",
645
+ timeout: timeoutMs,
646
+ killSignal: "SIGKILL",
647
+ windowsHide: true,
648
+ ...signal === void 0 ? {} : { signal }
649
+ }, (error, stdout, stderr) => {
650
+ const out = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout ?? "");
651
+ const err = Buffer.isBuffer(stderr) ? stderr.toString("utf8") : String(stderr ?? "");
652
+ if (error === null || error === void 0) {
653
+ settle({
654
+ code: 0,
655
+ stdout: out,
656
+ stderr: err,
657
+ aborted: false,
658
+ signal: null
659
+ });
660
+ return;
661
+ }
662
+ const aborted = error.name === "AbortError";
663
+ const code = typeof error.code === "number" ? error.code : null;
664
+ const killSignal = error.signal ?? null;
665
+ if (code === null && !aborted && killSignal === null) {
666
+ fail(error);
667
+ return;
668
+ }
669
+ settle({
670
+ code,
671
+ stdout: out,
672
+ stderr: err,
673
+ aborted,
674
+ signal: killSignal
675
+ });
676
+ });
677
+ });
678
+ }
679
+ /**
680
+ * Classify one completed run into raw stdout or a `SEARCH_*` failure, using the
681
+ * host suite's error vocabulary so retry/permission layers branch identically.
682
+ *
683
+ * The two engines disagree on exit 1: it is grep's "searched, found nothing",
684
+ * while `find` returns non-zero when it could not read the target at all — which
685
+ * must not be reported as an empty directory.
686
+ * @param run - the completed run.
687
+ * @param toolName - `grep` or `glob`, for the message.
688
+ * @param rawOutputMaxBytes - the budget the script bounded stdout to.
689
+ * @returns the raw stdout bytes.
690
+ */
691
+ function acceptRun(run, toolName, rawOutputMaxBytes) {
692
+ if (run.aborted || run.signal !== null) throw new SearchError(`${toolName} was cancelled or timed out before it finished`, "SEARCH_ABORTED");
693
+ if (run.stdout.length > rawOutputMaxBytes) throw new SearchError(`${toolName} produced more than ${rawOutputMaxBytes} bytes of raw output; narrow pattern or path`, "SEARCH_RAW_OUTPUT_OVERFLOW");
694
+ const code = run.code ?? -1;
695
+ if (code === 0 || code === 1 && toolName === "grep") return run.stdout;
696
+ const detail = (run.stderr.trim().split("\n")[0] ?? "").slice(0, 300);
697
+ if (code === 2 && toolName === "grep" && INVALID_PATTERN.test(run.stderr)) throw new SearchError(`${toolName} pattern rejected by the distribution's grep${detail === "" ? "" : `: ${detail}`}`, "SEARCH_INVALID_PATTERN");
698
+ if (code === 3) throw new SearchError(`${toolName} needs a GNU grep inside the distribution${detail === "" ? "" : ` (${detail})`}`, "SEARCH_FAILED");
699
+ if (code === 127) throw new SearchError(`${toolName} could not start its search command inside the distribution${detail === "" ? "" : `: ${detail}`}`, "SEARCH_FAILED");
700
+ throw new SearchError(`${toolName} search failed inside the distribution (exit ${code})${detail === "" ? "" : `: ${detail}`}`, "SEARCH_FAILED");
701
+ }
702
+ /**
703
+ * Register the `grep` and `glob` tools plus their system-prompt guidance.
704
+ *
705
+ * Both keep the host suite's split of responsibilities: `execute` returns the
706
+ * complete canonical value (display paths included) and may spill it, `render`
707
+ * applies the inline caps and formats, and `presentationMeta` projects the search
708
+ * card from the same retained page so text and card agree.
709
+ * @param ctx - plugin context; registrations are effects scoped to it.
710
+ * @param config - plugin configuration; a row without a `config:` block mounts
711
+ * this plugin with none, and {@link DEFAULTS} then supplies every knob.
712
+ */
713
+ function apply(ctx, config) {
714
+ const resolved = {
715
+ ...DEFAULTS,
716
+ ...config === void 0 ? {} : config
717
+ };
718
+ const tools = ctx.get("tools");
719
+ if (tools === void 0) return;
720
+ registerGrep(ctx, tools, resolved);
721
+ registerGlob(ctx, tools, resolved);
722
+ registerGuidance(ctx);
723
+ }
724
+ /**
725
+ * Register the model-facing `grep` tool.
726
+ * @param ctx - plugin context.
727
+ * @param tools - the tool registry.
728
+ * @param config - resolved configuration.
729
+ */
730
+ function registerGrep(ctx, tools, config) {
731
+ const caps = {
732
+ maxMatches: config.grepMaxMatches,
733
+ maxLineBytes: config.grepMaxLineBytes,
734
+ maxMetaBytes: config.searchMetaMaxBytes
735
+ };
736
+ const tool = defineTool({
737
+ name: "grep",
738
+ description: `Search file contents inside this WSL distribution with a GNU grep extended regular expression. Returns matching lines with line numbers, grouped by file. Returns the first ${caps.maxMatches} matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.`,
739
+ parameters: {
740
+ pattern: {
741
+ type: "string",
742
+ required: true,
743
+ description: "Regular expression to search for (GNU grep -E syntax: \\d, \\w, \\b and (?i) work; lookaround and backreferences do not)."
744
+ },
745
+ path: {
746
+ type: "string",
747
+ description: "File or Linux path to search. Defaults to the session workspace; a relative path resolves against it."
748
+ },
749
+ include: {
750
+ type: "string",
751
+ description: "One glob filter on file names for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). A filter containing \"/\" is matched against the path relative to the search root. Not a list; negation is not supported."
752
+ }
753
+ },
754
+ timeoutMs: config.timeoutMs,
755
+ output: {
756
+ schema: {
757
+ type: "object",
758
+ additionalProperties: false,
759
+ properties: {
760
+ matches: {
761
+ type: "array",
762
+ required: true,
763
+ items: {
764
+ type: "object",
765
+ additionalProperties: false,
766
+ properties: {
767
+ path: {
768
+ type: "string",
769
+ required: true
770
+ },
771
+ lineNumber: {
772
+ type: "integer",
773
+ required: true
774
+ },
775
+ line: {
776
+ type: "string",
777
+ required: true
778
+ }
779
+ }
780
+ }
781
+ },
782
+ spill: SPILL_SCHEMA
783
+ }
784
+ },
785
+ render: (_args, value) => [{
786
+ type: "text",
787
+ text: renderGrepText(value.matches, caps, normalizeSpill(value.spill))
788
+ }],
789
+ presentationMeta: (_args, value) => grepSearchMeta(retainMatches(value.matches, caps.maxMatches, caps.maxLineBytes), caps.maxMetaBytes)
790
+ },
791
+ async execute(args, exec) {
792
+ const input = parseGrepArgs(args);
793
+ const target = resolveTarget(exec.agent?.session.header.cwd, config.distro);
794
+ if (target === void 0) throw new SearchError("grep is only available in a WSL workspace session", "SEARCH_FAILED");
795
+ const globs = expandBraces(input.include ?? "");
796
+ const pathShaped = globs.filter((glob) => glob.includes("/"));
797
+ const argv = buildWslArgv(target, GREP_SCRIPT, [
798
+ input.pattern,
799
+ pathShaped.length > 0 ? "" : globs.join("\n"),
800
+ linuxTarget(input.path),
801
+ String(config.rawOutputMaxBytes + 1)
802
+ ]);
803
+ const run = await runInDistro(argv, config.wslPath, config.timeoutMs, config.rawOutputMaxBytes, exec.signal);
804
+ const stdout = acceptRun(run, "grep", config.rawOutputMaxBytes);
805
+ const matches = parseGrepRecords(stdout).filter((record) => {
806
+ if (pathShaped.length === 0) return true;
807
+ const relative = posix.relative(target.linuxCwd, record.path);
808
+ const candidate = relative.startsWith("..") ? record.path : relative;
809
+ return pathShaped.some((glob) => matchGlob(glob, candidate));
810
+ }).map((record) => ({
811
+ path: displayPath(record.path, target.linuxCwd),
812
+ lineNumber: record.lineNumber,
813
+ line: record.line
814
+ }));
815
+ if (matches.length <= caps.maxMatches) return { matches };
816
+ const previewed = matches.map((match) => ({
817
+ ...match,
818
+ line: previewLine(match.line, caps.maxLineBytes)
819
+ }));
820
+ const spill = await trySaveFormattedResult(ctx, exec, "grep-results.txt", `Found ${matches.length} matches\n\n${formatGrepMatches(previewed)}`);
821
+ return spill === void 0 ? { matches } : {
822
+ matches,
823
+ spill
824
+ };
825
+ },
826
+ presentCall: presentGrepCall,
827
+ presentResult: presentGrepResult
828
+ });
829
+ tools.register(tool);
830
+ }
831
+ /**
832
+ * Register the model-facing `glob` tool.
833
+ * @param ctx - plugin context.
834
+ * @param tools - the tool registry.
835
+ * @param config - resolved configuration.
836
+ */
837
+ function registerGlob(ctx, tools, config) {
838
+ const caps = {
839
+ maxResults: config.globMaxResults,
840
+ sampleOverCapGlobResults: config.sampleOverCapGlobResults
841
+ };
842
+ const overCap = caps.sampleOverCapGlobResults ? `a larger result instead returns ${caps.maxResults} paths sampled across top-level entries` : `a larger result returns the first ${caps.maxResults} paths in modification-time order`;
843
+ const tool = defineTool({
844
+ name: "glob",
845
+ description: `Find files inside this WSL distribution whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to ${caps.maxResults} paths come back in modification-time order (oldest first); ${overCap}, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.`,
846
+ parameters: {
847
+ pattern: {
848
+ type: "string",
849
+ required: true,
850
+ description: "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth. A leading \"!\" negates the pattern."
851
+ },
852
+ path: {
853
+ type: "string",
854
+ description: "Directory to search in. Defaults to the session workspace; a relative path resolves against it."
855
+ }
856
+ },
857
+ timeoutMs: config.timeoutMs,
858
+ output: {
859
+ schema: {
860
+ type: "object",
861
+ additionalProperties: false,
862
+ properties: {
863
+ root: {
864
+ type: "string",
865
+ required: true
866
+ },
867
+ paths: {
868
+ type: "array",
869
+ required: true,
870
+ items: { type: "string" }
871
+ },
872
+ spill: SPILL_SCHEMA
873
+ }
874
+ },
875
+ render: (_args, value) => [{
876
+ type: "text",
877
+ text: renderGlobText(value.paths, caps, value.root, normalizeSpill(value.spill))
878
+ }],
879
+ presentationMeta: (_args, value) => {
880
+ const page = globCardPage(value.paths, caps, value.root);
881
+ return globSearchMeta({
882
+ items: page.items,
883
+ truncated: page.truncated,
884
+ seen: value.paths.length
885
+ }, config.searchMetaMaxBytes);
886
+ }
887
+ },
888
+ async execute(args, exec) {
889
+ const input = parseGlobArgs(args);
890
+ const target = resolveTarget(exec.agent?.session.header.cwd, config.distro);
891
+ if (target === void 0) throw new SearchError("glob is only available in a WSL workspace session", "SEARCH_FAILED");
892
+ const argv = buildWslArgv(target, GLOB_SCRIPT, [linuxTarget(input.path), String(config.rawOutputMaxBytes + 1)]);
893
+ const run = await runInDistro(argv, config.wslPath, config.timeoutMs, config.rawOutputMaxBytes, exec.signal);
894
+ const stdout = acceptRun(run, "glob", config.rawOutputMaxBytes);
895
+ const listing = parseGlobRecords(stdout);
896
+ const rootAbsolute = listing.root === "" ? target.linuxCwd : listing.root;
897
+ listing.files.sort(listing.mode === "gnu" ? (left, right) => left.mtimeMs - right.mtimeMs || left.path.localeCompare(right.path) : (left, right) => left.path.localeCompare(right.path));
898
+ const root = input.path === void 0 ? "." : displayPath(rootAbsolute, target.linuxCwd);
899
+ const paths = [];
900
+ for (const file of listing.files) {
901
+ const relative = posix.relative(rootAbsolute, file.path);
902
+ if (!matchGlob(input.pattern, relative === "" ? posix.basename(file.path) : relative)) continue;
903
+ paths.push(displayPath(file.path, target.linuxCwd));
904
+ }
905
+ if (paths.length <= caps.maxResults) return {
906
+ root,
907
+ paths
908
+ };
909
+ const spill = await trySaveFormattedResult(ctx, exec, "glob-results.txt", paths.join("\n"));
910
+ return spill === void 0 ? {
911
+ root,
912
+ paths
913
+ } : {
914
+ root,
915
+ paths,
916
+ spill
917
+ };
918
+ },
919
+ presentCall: presentGlobCall,
920
+ presentResult: presentGlobResult
921
+ });
922
+ tools.register(tool);
923
+ }
924
+ /**
925
+ * Register the tools' scope-aware system-prompt guidance, mirroring the host
926
+ * suite's sections. Best-effort: a release whose `systemPrompt` surface differs
927
+ * loses the hint, never the tools.
928
+ * @param ctx - plugin context.
929
+ */
930
+ function registerGuidance(ctx) {
931
+ const systemPrompt = ctx.get("systemPrompt");
932
+ if (systemPrompt === void 0 || typeof systemPrompt.section !== "function") return;
933
+ const order = (name) => {
934
+ try {
935
+ return systemPrompt.getSectionOrder?.(name);
936
+ } catch {
937
+ return void 0;
938
+ }
939
+ };
940
+ try {
941
+ systemPrompt.section({
942
+ name: "tool:grep",
943
+ order: order("TOOL_GREP"),
944
+ text: () => "Use the grep tool — not shell grep or rg — to search file contents inside this WSL workspace. Use read on a matched file when you need surrounding context."
945
+ });
946
+ systemPrompt.section({
947
+ name: "tool:glob",
948
+ order: order("TOOL_GLOB"),
949
+ text: () => "Use the glob tool — not shell find — to discover files by path pattern inside this WSL workspace. A pattern with no \"/\" matches basenames at any depth, so \"*\" matches every file in the tree rather than its top level. Results are files only, never directories."
950
+ });
951
+ } catch {}
952
+ }
953
+ var wsl_search_default = apply;
954
+
955
+ //#endregion
956
+ export { Config, apply, buildWslArgv, capMetaBytes, wsl_search_default as default, displayPath, expandBraces, globCardPage, globSearchMeta, globToRegExp, grepSearchMeta, inject, linuxTarget, matchGlob, normalizeSpill, parseGlobRecords, parseGrepRecords, registerGuidance, renderGlobText, renderGrepText, resolveTarget, retainMatches, retainPaths };
957
+ //# sourceMappingURL=wsl-search.js.map