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.
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
- import { defaultDistro, isAbsoluteLinuxPath, isValidWslUsername, joinUnc, listDistros, mntToWindowsPath, normalizeLinuxPath, parseWslUnc } from "./wsl-6VcgOvF9.js";
2
- import { canonicalWslUnc, getWindowsWorkspace, getWorkspaceUsername, listWorkspaceKeys, registerWindowsWorkspace, setWorkspaceUsername } from "./wsl-credentials-B6wIXsKt.js";
1
+ import { defaultDistro, isAbsoluteLinuxPath, isValidWslUsername, joinUnc, listDistros, mntToWindowsPath, normalizeLinuxPath, parseWslUnc, uncToLinux } from "./wsl-CECXiGZH.js";
2
+ import { canonicalWslUnc, getWindowsWorkspace, getWorkspaceUsername, listWorkspaceKeys, registerWindowsWorkspace, setWorkspaceUsername } from "./wsl-credentials-BJXc9tfi.js";
3
+ import { resolveLinuxSymlinks } from "./links-BQxdJOMy.js";
3
4
  import z from "@deepseek-ai/schemastery";
4
5
  import { cpSync, existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
6
  import { fileURLToPath } from "node:url";
@@ -28,41 +29,151 @@ const WORLD_ROWS = new Set([
28
29
  "tool-fs",
29
30
  "tool-fs-search",
30
31
  "str-replace-editor",
32
+ "tool-str-replace-editor",
33
+ "wsl-world",
31
34
  "filesystem",
32
35
  "persistent-shell",
36
+ "persistent-bash",
37
+ "persistent-pwsh",
38
+ "terminal-bash",
39
+ "terminal-pwsh",
33
40
  "custom-bash",
34
41
  "bootstrap-filesystem"
35
42
  ]);
43
+ /** Execution-world rows that register the model-facing editor tool. */
44
+ const EDITOR_ROWS = new Set(["str-replace-editor", "tool-str-replace-editor"]);
45
+ /** The row ids this generator mounts inside its own world group. */
46
+ const WSL_WORLD_PROVIDER_IDS = ["shell-wsl", "fs-wsl"];
47
+ /**
48
+ * Whether a top-level row is this generator's own WSL world group.
49
+ *
50
+ * A preset copied from a generated variant (a user renaming `wsl-standard` to
51
+ * their own mode, say) carries the group under whatever id the copy kept, so
52
+ * the mounted provider ids identify it even when the row id changed. Without
53
+ * that check the variant would carry two world groups and the loader refuses
54
+ * the whole preset with `duplicate loader entry id: wsl-world`.
55
+ * @param block - the row's lines.
56
+ * @returns true when the row mounts this generator's WSL providers.
57
+ */
58
+ function isWslWorldGroup(block) {
59
+ const text = block.join("\n");
60
+ return WSL_WORLD_PROVIDER_IDS.some((id) => new RegExp(`^\\s+- id: ${id}$`, "m").test(text));
61
+ }
62
+ /**
63
+ * The persistent-shell rows: the host's own PTY registry and its config-driven
64
+ * backend, told to run this plugin's relay (so the shell is a WSL one), plus
65
+ * the persistent tool that consumes them.
66
+ *
67
+ * Two shapes matter here. The registry is an agent-owned service — the shipped
68
+ * `persistent-shell` group keeps it in its own `terminals` realm for that
69
+ * reason — so this is a nested group of the world rather than three flat rows.
70
+ * And the persistent tool registers the tool name **`bash`**, exactly like the
71
+ * one-shot `dsh-tool-bash` row it replaces: both cannot be mounted (the tools
72
+ * registry rejects the duplicate and the whole preset fails to load), which is
73
+ * also why DSH's own Minimal mode describes itself as "a single-tool agent with
74
+ * a persistent shell". So a world with the relay paths swaps the shell tool
75
+ * instead of adding one, and a world without them keeps the one-shot row.
76
+ */
77
+ function persistentShellRows(relayPath, nodePath) {
78
+ return [
79
+ " # Persistent shell: the host PTY registry and its backend, running",
80
+ " # this plugin's relay, which hands the PTY to `wsl.exe … bash`",
81
+ " # (distribution and user come from the session). It registers the",
82
+ " # `bash` tool, so it takes the place of the one-shot row above.",
83
+ " - id: persistent-shell",
84
+ " name: cordis:group",
85
+ " group: true",
86
+ " isolate:",
87
+ " terminals: true",
88
+ " config:",
89
+ " - id: pty",
90
+ " name: '@deepseek-ai/dsh-terminal'",
91
+ " - id: terminal-wsl",
92
+ " name: '@deepseek-ai/dsh-terminal-bash'",
93
+ " config:",
94
+ " backendType: wsl",
95
+ " shellDialect: bash",
96
+ ` shellPath: '${nodePath.replace(/'/g, "''")}'`,
97
+ " shellArgs:",
98
+ ` - '${relayPath.replace(/'/g, "''")}'`,
99
+ " - id: persistent-bash",
100
+ " name: '@deepseek-ai/dsh-tool-bash-persistent'",
101
+ " config:",
102
+ " backendType: wsl",
103
+ ...SHELL_DESCRIPTION_ROWS
104
+ ];
105
+ }
106
+ /**
107
+ * The persistent tool's model-facing description, replacing the host default.
108
+ *
109
+ * The host tool's default says only that state persists, and its own Minimal
110
+ * preset goes further in the wrong direction by suggesting `sleep 10 &`. Both
111
+ * facts a WSL session needs are missing, and both were learned the hard way:
112
+ *
113
+ * - The shell is one process for the whole Agent, so a `cd` in one call decides
114
+ * where the *next* call starts. A model that read the one-shot tool's contract
115
+ * ("each call runs in a fresh shell — pass `workdir` instead of using `cd`")
116
+ * will be surprised, and a probe left in `/tmp` makes every later relative path
117
+ * resolve somewhere it never named.
118
+ * - The host wraps each command as `eval -- $'…'`. A trailing `&` therefore
119
+ * backgrounds the *whole* wrapped command: the tool's completion marker is
120
+ * printed before the work runs, so the call reports no output and exit code 0
121
+ * while the real output arrives later and can land inside the next call's
122
+ * output window. `( … ) &` on its own line keeps the `&` on the subshell and
123
+ * leaves the wrapper's sequencing intact.
124
+ *
125
+ * `description` is a supported key on every declared release (its `Config` schema
126
+ * carries it from 0.1.0-rc.7 on), so the override is version-safe.
127
+ */
128
+ const SHELL_DESCRIPTION_ROWS = [
129
+ " description: |-",
130
+ " Run commands in a persistent bash shell inside this WSL distribution. State, including",
131
+ " the current directory and exported environment variables, persists across calls: use",
132
+ " absolute paths or an explicit `cd` at the start of a command instead of relying on where",
133
+ " the previous call left the shell. The shell runs as the workspace's Linux user; Windows",
134
+ " files are reachable as /mnt/<drive>, and no toolchain install is required.",
135
+ " * This tool takes `command` only. It has no `run_in_background` parameter, and passing",
136
+ " one is ignored - use the `bash_background` tool when the mode provides it, or background",
137
+ " a subshell as below.",
138
+ " * To leave work running without a tracked job, put it in a subshell with the `&` on its",
139
+ " own line: `( long-job > log 2>&1 ) &`. Then poll the log file in a later call.",
140
+ " * Never end a `&&` chain with `&`. That backgrounds the whole command, so the call",
141
+ " returns immediately with no output and exit code 0, and the real output arrives later,",
142
+ " possibly inside the next call's output.",
143
+ " * Avoid commands that wait for stdin: an interactive foreground child can run until the",
144
+ " command timeout, and a timeout resets the shell and discards its state."
145
+ ];
36
146
  /** The injected WSL world group: providers + the bash/fs consumers, entry-local. */
37
- function wslWorldGroup(shellPath, fsPath, includeEditor) {
147
+ function wslWorldGroup(shellPath, fsPath, includeEditor, persistent, searchPath, jobsPath, sawJobs = false) {
38
148
  return [
39
149
  "# ── WSL execution world (dsh-wsl-workspace variant) ─────────────────────",
40
150
  "# The shell and fs services are provided entry-locally (the isolate",
41
151
  "# realm); host services (tools registry, shell-env, jobs) fall through.",
42
- "# tool-fs-search is intentionally absent: the packaged ripgrep runs on",
43
- "# the Windows host and cannot open Linux paths; WSL sessions search with",
44
- "# shell tools instead.",
45
152
  "- id: wsl-world",
46
153
  " name: cordis:group",
47
154
  " group: true",
48
155
  " isolate:",
49
156
  " shell: true",
50
157
  " fs: true",
158
+ ...persistent === void 0 ? [] : [" sandbox: true"],
51
159
  " config:",
52
160
  ` - id: shell-wsl`,
53
161
  ` name: '${shellPath.replace(/'/g, "''")}'`,
54
162
  " - id: fs-wsl",
55
163
  ` name: '${fsPath.replace(/'/g, "''")}'`,
56
- " - id: tool-bash",
57
- " name: '@deepseek-ai/dsh-tool-bash'",
164
+ ...persistent === void 0 ? [] : [" - id: sandbox-wsl", ` name: '${persistent.sandboxPath.replace(/'/g, "''")}'`],
165
+ ...persistent === void 0 ? [" - id: tool-bash", " name: '@deepseek-ai/dsh-tool-bash'"] : [],
58
166
  " - id: tool-fs",
59
167
  " name: '@deepseek-ai/dsh-tool-fs'",
168
+ ...searchPath === void 0 ? [] : [" - id: search-wsl", ` name: '${searchPath.replace(/'/g, "''")}'`],
169
+ ...jobsPath === void 0 || persistent === void 0 || !sawJobs ? [] : [" - id: jobs-wsl", ` name: '${jobsPath.replace(/'/g, "''")}'`],
60
170
  ...includeEditor ? [
61
171
  " - id: str-replace-editor",
62
172
  " name: '@deepseek-ai/dsh-tool-str-replace-editor'",
63
173
  " config:",
64
174
  " maxOutputChars: 16000"
65
175
  ] : [],
176
+ ...persistent === void 0 ? [] : persistentShellRows(persistent.relayPath, persistent.nodePath),
66
177
  ""
67
178
  ].join("\n");
68
179
  }
@@ -228,24 +339,38 @@ function appendPersona(lines, span) {
228
339
  /**
229
340
  * Transform one source preset composition into its WSL variant: drop the
230
341
  * execution-world rows, keep everything else verbatim, and append the WSL
231
- * world group. The persistent-shell group is NOT re-added: it registers the
232
- * same `bash` tool name as the WSL world's `dsh-tool-bash`, and the tools
233
- * registry rejects duplicates within one preset layer — the whole variant
234
- * fails to mount and the session falls back to another preset. Its PTY
235
- * backend additionally cannot run on this plugin's Windows host
236
- * (`dsh-subprocess-local`: "terminal inspection is unsupported on platform
237
- * win32"), so the group could never spawn a shell here anyway. The WSL
238
- * world's ordinary `bash` tool covers command execution for every variant.
342
+ * world group. A row id that appears twice in the source is kept once, and a
343
+ * world group the source already carries (a preset copied from a generated
344
+ * variant) is replaced rather than duplicated, so the variant always mounts
345
+ * exactly one world pointing at this installation's providers.
346
+ *
347
+ * The source's own persistent-shell group is not re-added as such: it registers
348
+ * the same `bash` tool name as the WSL world's `dsh-tool-bash`, and the tools
349
+ * registry rejects duplicates within one preset layer — the whole variant fails
350
+ * to mount and the session falls back to another preset. Its PTY *backend*, on
351
+ * the other hand, is exactly what a stateful WSL shell needs, and it is
352
+ * config-driven: given the relay in {@link persistentShellRows} it runs
353
+ * `wsl.exe … bash` under the host's PTY, and its tool registers the separate
354
+ * `persistent-bash` name, so the one-shot `bash` above stays as it is.
239
355
  * @param source - the source composition text.
240
356
  * @param shellPath - absolute path of the plugin's built WSL shell provider.
241
357
  * @param fsPath - absolute path of the plugin's built WSL fs provider.
358
+ * @param persistent - the relay, interpreter and sandbox provider a stateful
359
+ * shell needs; omit to generate a world without the persistent-shell rows.
360
+ * @param searchPath - absolute path of the plugin's built in-distribution
361
+ * `grep`/`glob` tools; mounted only for a source that had `tool-fs-search`.
362
+ * @param jobsPath - absolute path of the plugin's built background-job producer
363
+ * for the persistent shell; mounted only alongside that shell.
242
364
  * @returns the variant composition text.
243
365
  */
244
- function transformPresetForWsl(source, shellPath, fsPath) {
366
+ function transformPresetForWsl(source, shellPath, fsPath, persistent, searchPath, jobsPath) {
245
367
  const lines = source.split("\n");
246
368
  const spans = topLevelSpans(lines);
247
369
  const kept = [];
370
+ const seen = /* @__PURE__ */ new Set();
248
371
  let sawEditor = false;
372
+ let sawSearch = false;
373
+ let sawJobs = false;
249
374
  let personaAppended = false;
250
375
  for (const span of spans) {
251
376
  const id = spanId(lines, span);
@@ -253,9 +378,16 @@ function transformPresetForWsl(source, shellPath, fsPath) {
253
378
  kept.push(...lines.slice(span.start, span.end));
254
379
  continue;
255
380
  }
256
- if (WORLD_ROWS.has(id)) continue;
381
+ if (seen.has(id)) continue;
382
+ seen.add(id);
383
+ const block = lines.slice(span.start, span.end);
384
+ if (WORLD_ROWS.has(id) || isWslWorldGroup(block)) {
385
+ if (EDITOR_ROWS.has(id)) sawEditor = true;
386
+ if (id === "tool-fs-search") sawSearch = true;
387
+ continue;
388
+ }
257
389
  if (id === SKILL_FILESYSTEM_ROW) {
258
- kept.push(...disableSkillWatch(lines.slice(span.start, span.end)));
390
+ kept.push(...disableSkillWatch(block));
259
391
  continue;
260
392
  }
261
393
  if (id === "persona" && !personaAppended && appendablePersona(lines, span)) {
@@ -263,13 +395,14 @@ function transformPresetForWsl(source, shellPath, fsPath) {
263
395
  personaAppended = true;
264
396
  continue;
265
397
  }
266
- kept.push(...lines.slice(span.start, span.end));
267
- if (id === "str-replace-editor") sawEditor = true;
398
+ kept.push(...block);
268
399
  }
269
400
  if (source.includes("str-replace-editor")) sawEditor = true;
401
+ if (source.includes("tool-fs-search")) sawSearch = true;
402
+ if (source.includes("tool-jobs")) sawJobs = true;
270
403
  const result = [...kept];
271
404
  if (result.length > 0 && result[result.length - 1] !== "") result.push("");
272
- result.push(wslWorldGroup(shellPath, fsPath, sawEditor));
405
+ result.push(wslWorldGroup(shellPath, fsPath, sawEditor, persistent, sawSearch ? searchPath : void 0, jobsPath, sawJobs));
273
406
  return result.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
274
407
  }
275
408
  /** Whether an id is one of this plugin's own preset directories. */
@@ -292,12 +425,41 @@ const MAX_SCAN_DEPTH = 4;
292
425
  const MAX_SKILL_ROOTS = 64;
293
426
  /** Maximum directories visited per lookup (an absolute blast-radius cap). */
294
427
  const MAX_VISITED_DIRECTORIES = 4096;
428
+ /**
429
+ * How many Linux symlinks one lookup may hand to the distribution (a second
430
+ * blast-radius cap, and a latency cap: each resolution is a short `wsl.exe`
431
+ * call, so a tree with hundreds of links cannot stall the catalog).
432
+ */
433
+ const MAX_LINK_RESOLUTIONS = 32;
295
434
  /** How many parent levels above the session cwd are searched for a `.git` project marker. */
296
435
  const MAX_ANCESTOR_WALK = 64;
297
- /** How long a completed lookup is served from cache before the next rescan. */
298
- const CACHE_TTL_MS = 1e4;
436
+ /**
437
+ * How many directories one discovery layer may probe at once.
438
+ *
439
+ * A `readdir` over the `\\wsl.localhost\…` share measured 15.6 ms against 1.6 ms
440
+ * for a `stat`, and a budget-sized walk is 4096 directories: probing one
441
+ * directory at a time costs 20-30 s, which is what a turn in a large workspace
442
+ * used to wait for. The layer stays bounded so the share is never flooded; node's
443
+ * own filesystem thread pool is what ultimately caps the real parallelism.
444
+ */
445
+ const WALK_CONCURRENCY = 16;
299
446
  /** Maximum cached lookups (one entry per distinct scan root across sessions). */
300
447
  const CACHE_MAX_ENTRIES = 32;
448
+ /**
449
+ * How often a served scan root is re-checked for catalog changes: the skills
450
+ * directories it published, plus one modification stamp per skill file. This is
451
+ * the pass that makes a skill added — or a description edited — mid-session
452
+ * visible on the model's next request.
453
+ */
454
+ const REFRESH_POLL_MS = 3e3;
455
+ /**
456
+ * How often the full re-discovery walk runs for a served scan root. Only a walk
457
+ * can find a skills directory that did not exist before (a new nested project,
458
+ * say), and it costs one `readdir` per visited directory, so it runs on its own
459
+ * slower cadence instead of on every poll. It used to be the only pass, at
460
+ * {@link REFRESH_POLL_MS}'s old value of 10 s.
461
+ */
462
+ const DISCOVERY_POLL_MS = 3e4;
301
463
  /** Kebab-case skill names, matching the host grammar. */
302
464
  const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
303
465
  /** Directory names that never contain project skill roots (safe to prune while walking). */
@@ -333,7 +495,8 @@ const PRUNED_DIRECTORY_NAMES = new Set([
333
495
  const nodeSkillIo = {
334
496
  readdir: async (path, options) => readdir(path, options),
335
497
  readFile: async (path, options) => readFile(path, options),
336
- stat: async (path) => stat(path)
498
+ stat: async (path) => stat(path),
499
+ resolveLinks: async (uncPaths) => resolveLinuxSymlinks(uncPaths)
337
500
  };
338
501
  /**
339
502
  * Locate the nearest ancestor of `linuxDir` (the directory itself included)
@@ -359,7 +522,56 @@ async function nearestGitAncestor(distro, linuxDir, io) {
359
522
  return void 0;
360
523
  }
361
524
  /**
525
+ * Everything the walk learns from one directory, in the order the round trips
526
+ * are worth paying for: the listing first — it also says which skill markers can
527
+ * exist there at all — then the markers it showed.
528
+ * @param distro - the WSL distribution name.
529
+ * @param dir - the directory's Linux path.
530
+ * @param depth - its depth below the scan root.
531
+ * @param io - filesystem face.
532
+ * @returns the directory's roots, child directories and unresolved links.
533
+ */
534
+ async function probeDirectory(distro, dir, depth, io) {
535
+ let entries;
536
+ if (depth < MAX_SCAN_DEPTH) try {
537
+ entries = await io.readdir(joinUnc(distro, dir), { withFileTypes: true });
538
+ } catch {}
539
+ const listed = entries ?? [];
540
+ const allMarkers = PROJECT_SKILL_MARKERS.map(([marker]) => marker);
541
+ const markers = entries === void 0 ? allMarkers : allMarkers.filter((marker) => listed.some((entry) => entry.name === marker));
542
+ const roots = await skillRootsOfDirectory(distro, dir, io, markers);
543
+ const children = [];
544
+ const links = [];
545
+ for (const entry of listed) {
546
+ if (PRUNED_DIRECTORY_NAMES.has(entry.name)) continue;
547
+ if (entry.name.startsWith(".") && entry.name !== ".dsh" && entry.name !== ".agents") continue;
548
+ if (entry.name === ".dsh" || entry.name === ".agents") continue;
549
+ const childPath = posix.join(dir, entry.name);
550
+ if (entry.isDirectory()) {
551
+ children.push([childPath, depth + 1]);
552
+ continue;
553
+ }
554
+ if (!entry.isSymbolicLink()) continue;
555
+ try {
556
+ const target = await io.stat(joinUnc(distro, childPath));
557
+ if (target.isDirectory()) children.push([childPath, depth + 1]);
558
+ continue;
559
+ } catch {}
560
+ links.push([childPath, depth + 1]);
561
+ }
562
+ return {
563
+ roots,
564
+ children,
565
+ links
566
+ };
567
+ }
568
+ /**
362
569
  * Scan a WSL workspace root for nested skill directories.
570
+ *
571
+ * A layer is probed concurrently but published in frontier order, so the
572
+ * catalog never depends on which probe happened to finish first, and the budget
573
+ * is claimed before any probe starts: concurrency must not change what is
574
+ * visited, only how long it takes.
363
575
  * @param distro - the WSL distribution name.
364
576
  * @param linuxRoot - the workspace's absolute Linux path.
365
577
  * @param io - filesystem face.
@@ -368,62 +580,77 @@ async function nearestGitAncestor(distro, linuxDir, io) {
368
580
  async function discoverSkillRoots(distro, linuxRoot, io) {
369
581
  const roots = [];
370
582
  const visited = /* @__PURE__ */ new Set();
583
+ let linksResolved = 0;
371
584
  let frontier = [[linuxRoot, 0]];
372
585
  while (frontier.length > 0 && roots.length < MAX_SKILL_ROOTS) {
373
- const next = [];
374
- for (const [dir, depth] of frontier) {
375
- if (visited.size >= MAX_VISITED_DIRECTORIES) return roots;
376
- if (visited.has(dir)) continue;
377
- visited.add(dir);
378
- if (roots.length < MAX_SKILL_ROOTS) {
379
- const directoryRoots = await skillRootsOfDirectory(distro, dir, io);
380
- roots.push(...directoryRoots.slice(0, MAX_SKILL_ROOTS - roots.length));
381
- }
382
- if (depth >= MAX_SCAN_DEPTH) continue;
383
- let entries;
384
- try {
385
- entries = await io.readdir(joinUnc(distro, dir), { withFileTypes: true });
386
- } catch {
387
- continue;
586
+ const layer = [];
587
+ for (const item of frontier) {
588
+ if (visited.size >= MAX_VISITED_DIRECTORIES) break;
589
+ if (visited.has(item[0])) continue;
590
+ visited.add(item[0]);
591
+ layer.push(item);
592
+ }
593
+ if (layer.length === 0) return roots;
594
+ const probes = new Array(layer.length);
595
+ let next = 0;
596
+ await Promise.all(Array.from({ length: Math.min(WALK_CONCURRENCY, layer.length) }, async () => {
597
+ for (let index = next; index < layer.length; index = next) {
598
+ next += 1;
599
+ const [dir, depth] = layer[index];
600
+ probes[index] = await probeDirectory(distro, dir, depth, io);
388
601
  }
389
- for (const entry of entries) {
390
- if (PRUNED_DIRECTORY_NAMES.has(entry.name)) continue;
391
- if (entry.name.startsWith(".") && entry.name !== ".dsh" && entry.name !== ".agents") continue;
392
- if (entry.name === ".dsh" || entry.name === ".agents") continue;
393
- const childPath = posix.join(dir, entry.name);
394
- if (entry.isDirectory()) {
395
- next.push([childPath, depth + 1]);
396
- continue;
397
- }
398
- if (entry.isSymbolicLink()) try {
399
- const target = await io.stat(joinUnc(distro, childPath));
400
- if (target.isDirectory()) next.push([childPath, depth + 1]);
602
+ }));
603
+ const nextLayer = [];
604
+ const links = [];
605
+ for (const probe of probes) {
606
+ if (probe === void 0) continue;
607
+ if (roots.length < MAX_SKILL_ROOTS) roots.push(...probe.roots.slice(0, MAX_SKILL_ROOTS - roots.length));
608
+ nextLayer.push(...probe.children);
609
+ links.push(...probe.links);
610
+ }
611
+ if (visited.size >= MAX_VISITED_DIRECTORIES) return roots;
612
+ if (links.length > 0 && io.resolveLinks !== void 0 && linksResolved < MAX_LINK_RESOLUTIONS) {
613
+ const batch = links.slice(0, MAX_LINK_RESOLUTIONS - linksResolved);
614
+ linksResolved += batch.length;
615
+ const resolved = await io.resolveLinks(batch.map(([path]) => joinUnc(distro, path)));
616
+ for (let index = 0; index < batch.length; index += 1) {
617
+ const real = resolved[index];
618
+ if (real === void 0) continue;
619
+ try {
620
+ const info = await io.stat(real);
621
+ if (info.isDirectory()) nextLayer.push([uncToLinux(real), batch[index][1]]);
401
622
  } catch {}
402
623
  }
403
624
  }
404
- frontier = next;
625
+ frontier = nextLayer;
405
626
  }
406
627
  return roots;
407
628
  }
629
+ /** The two project skill markers, with the source and rank each publishes. */
630
+ const PROJECT_SKILL_MARKERS = [[
631
+ ".dsh",
632
+ "project-dsh",
633
+ PROJECT_DSH_RANK
634
+ ], [
635
+ ".agents",
636
+ "project-agents",
637
+ PROJECT_AGENTS_RANK
638
+ ]];
408
639
  /**
409
640
  * Publish the skill roots of one scanned directory (its `.dsh/skills` and
410
641
  * `.agents/skills`, each with the host's project ranks).
411
642
  * @param distro - the WSL distribution name.
412
643
  * @param linuxDir - the scanned directory's Linux path.
413
644
  * @param io - filesystem face.
645
+ * @param markers - the markers worth probing. The walk narrows this to the ones
646
+ * its listing actually showed, because each probe is a round trip and a
647
+ * directory without a `.dsh` entry cannot hold `.dsh/skills`.
414
648
  * @returns the directory's skill roots that exist.
415
649
  */
416
- async function skillRootsOfDirectory(distro, linuxDir, io) {
650
+ async function skillRootsOfDirectory(distro, linuxDir, io, markers = PROJECT_SKILL_MARKERS.map(([marker]) => marker)) {
417
651
  const result = [];
418
- for (const [marker, source, rank] of [[
419
- ".dsh",
420
- "project-dsh",
421
- PROJECT_DSH_RANK
422
- ], [
423
- ".agents",
424
- "project-agents",
425
- PROJECT_AGENTS_RANK
426
- ]]) {
652
+ for (const [marker, source, rank] of PROJECT_SKILL_MARKERS) {
653
+ if (!markers.includes(marker)) continue;
427
654
  const path = joinUnc(distro, posix.join(linuxDir, marker, "skills"));
428
655
  try {
429
656
  const info = await io.stat(path);
@@ -457,6 +684,32 @@ async function listSkillEntries(root, io) {
457
684
  });
458
685
  return entries.sort((a, b) => a.name.localeCompare(b.name));
459
686
  }
687
+ /**
688
+ * The published shape of one skills directory: its path, then each skill's name,
689
+ * kind and modification stamp.
690
+ *
691
+ * The stamp is what makes an edit to an *existing* skill visible: the catalog the
692
+ * model sees is rebuilt only when the registry's revision moves, and a directory
693
+ * listing alone cannot tell a rewritten `SKILL.md` from an untouched one. One
694
+ * `stat` per skill file (never a read) is the whole cost, and a file that cannot
695
+ * be stamped reports the same `gone` marker on every pass, so a substrate that
696
+ * does not expose modification times simply never triggers on content.
697
+ * @param root - the skills directory.
698
+ * @param entries - its entries, as just listed.
699
+ * @param io - filesystem face.
700
+ * @returns the deterministic shape string for this directory.
701
+ */
702
+ async function shapeOfRoot(root, entries, io) {
703
+ const stamps = await Promise.all(entries.map(async (entry) => {
704
+ try {
705
+ const info = await io.stat(entry.path);
706
+ return `${entry.name}:${entry.kind}:${info.mtimeMs ?? 0}:${info.size ?? 0}`;
707
+ } catch {
708
+ return `${entry.name}:${entry.kind}:gone`;
709
+ }
710
+ }));
711
+ return `${root.path}\u0001${stamps.sort().join(",")}`;
712
+ }
460
713
  /** Read and parse one skill file; `undefined` when missing or unparsable. */
461
714
  async function readSkill(path, io, signal) {
462
715
  signal?.throwIfAborted();
@@ -589,22 +842,37 @@ function frontmatterBoolean(fields, key, dflt = false) {
589
842
  * The WSL workspace skill provider. Registered on the host's `ctx.skills`
590
843
  * registry; serves only lookups whose cwd is a WSL UNC workspace path.
591
844
  *
592
- * Completed `list()` lookups are cached per scan root for CACHE_TTL_MS so
593
- * repeated catalog builds over the slow 9P share do not rescan the tree;
594
- * `get()` always re-reads the skill file so body edits are picked up
595
- * immediately. `control.invalidate` remains reserved for host-driven
596
- * invalidation; the provider self-invalidates through the TTL.
845
+ * Completed `list()` lookups are cached per scan root and served as-is: a lookup
846
+ * is on the session's request path, and re-walking the tree there is what made
847
+ * every turn in a large workspace wait for a 4096-directory scan of the 9P
848
+ * share. `get()` always re-reads the skill file so body edits are picked up
849
+ * immediately.
850
+ *
851
+ * A scan root that has been served is re-checked every REFRESH_POLL_MS: the
852
+ * host cannot watch a `\\wsl.localhost\…` path (which is why the generated
853
+ * preset pins `watch: false`), so this provider watches for it instead. A
854
+ * changed directory listing clears the cache and calls `control.invalidate()`,
855
+ * which bumps the registry's revision; the catalog middleware re-collects on
856
+ * the session's next request, so a skill added mid-session reaches the model
857
+ * without starting a new session, and the cache is only ever refilled by a
858
+ * lookup the detector has already invalidated.
597
859
  */
598
860
  var WslSkillsProvider = class {
599
861
  name = "wsl-workspace";
600
862
  control;
601
863
  io;
602
- now;
864
+ refreshMs;
865
+ /** Cheap polls between two full discovery walks (at least one). */
866
+ walkEveryPolls;
867
+ /** The published catalog per scan root, served until its detector drops it. */
603
868
  cache = /* @__PURE__ */ new Map();
604
- constructor(control, io = nodeSkillIo, now = Date.now) {
869
+ /** One change detector per served scan root, keyed like {@link cache}. */
870
+ detectors = /* @__PURE__ */ new Map();
871
+ constructor(control, io = nodeSkillIo, refreshMs = REFRESH_POLL_MS, discoveryMs = DISCOVERY_POLL_MS) {
605
872
  this.control = control;
606
873
  this.io = io;
607
- this.now = now;
874
+ this.refreshMs = refreshMs;
875
+ this.walkEveryPolls = Math.max(1, Math.round(discoveryMs / refreshMs));
608
876
  }
609
877
  /**
610
878
  * Discover nested project skills for a WSL UNC session workspace.
@@ -621,16 +889,18 @@ var WslSkillsProvider = class {
621
889
  const scanRoot = await nearestGitAncestor(unc.distro, unc.linuxPath, this.io) ?? unc.linuxPath;
622
890
  const cacheKey = `${unc.distro}\u0000${scanRoot}`;
623
891
  const cached = this.cache.get(cacheKey);
624
- if (cached !== void 0 && cached.expiresAt > this.now()) {
892
+ if (cached !== void 0) {
625
893
  this.cache.delete(cacheKey);
626
894
  this.cache.set(cacheKey, cached);
627
- return [...cached.candidates];
895
+ return [...cached];
628
896
  }
629
897
  const roots = await discoverSkillRoots(unc.distro, scanRoot, this.io);
630
898
  const candidates = [];
631
899
  const seenSkills = /* @__PURE__ */ new Set();
900
+ const signature = [];
632
901
  for (const root of roots) {
633
902
  const entries = await listSkillEntries(root, this.io);
903
+ signature.push(await shapeOfRoot(root, entries, this.io));
634
904
  for (const entry of entries) {
635
905
  options.signal?.throwIfAborted();
636
906
  const parsed = await readSkill(entry.path, this.io, options.signal);
@@ -654,16 +924,83 @@ var WslSkillsProvider = class {
654
924
  });
655
925
  }
656
926
  }
657
- this.cache.set(cacheKey, {
658
- expiresAt: this.now() + CACHE_TTL_MS,
659
- candidates
660
- });
927
+ this.cache.set(cacheKey, candidates);
661
928
  while (this.cache.size > CACHE_MAX_ENTRIES) {
662
929
  const oldest = this.cache.keys().next().value;
663
930
  if (oldest === void 0) break;
664
931
  this.cache.delete(oldest);
665
932
  }
666
- return candidates;
933
+ this.watch(cacheKey, unc.distro, scanRoot, signature.join(""), roots);
934
+ return [...candidates];
935
+ }
936
+ /**
937
+ * Keep one scan root's catalog honest for as long as this provider is
938
+ * registered: the host cannot watch a UNC workspace, so the provider polls the
939
+ * shape it just published and, on any difference, drops its own cache and asks
940
+ * the registry to re-collect for the session's next request.
941
+ *
942
+ * Two passes keep that affordable: the cheap one (every
943
+ * {@link REFRESH_POLL_MS}) re-reads the skills directories already published
944
+ * and re-stats their skill files, so an added, removed or edited skill is
945
+ * noticed on its own; the full re-discovery walk (every
946
+ * {@link DISCOVERY_POLL_MS}) is what can find a skills directory that did not
947
+ * exist before. Neither pass reads a skill file: the description the catalog
948
+ * shows changes only through the registry's revision.
949
+ * @param cacheKey - this provider's key for the scan root.
950
+ * @param distro - the WSL distribution.
951
+ * @param scanRoot - the Linux path the lookup scanned.
952
+ * @param signature - the shape the lookup just published.
953
+ * @param roots - the skills directories that lookup found.
954
+ */
955
+ watch(cacheKey, distro, scanRoot, signature, roots) {
956
+ const existing = this.detectors.get(cacheKey);
957
+ if (existing !== void 0) {
958
+ existing.signature = signature;
959
+ existing.roots = roots;
960
+ return;
961
+ }
962
+ const detector = {
963
+ timer: setInterval(() => void this.detect(cacheKey, distro, scanRoot), this.refreshMs),
964
+ signature,
965
+ roots,
966
+ polls: 0,
967
+ busy: false
968
+ };
969
+ if (typeof detector.timer.unref === "function") detector.timer.unref();
970
+ this.detectors.set(cacheKey, detector);
971
+ this.control.signal.addEventListener("abort", () => {
972
+ clearInterval(detector.timer);
973
+ this.detectors.delete(cacheKey);
974
+ }, { once: true });
975
+ }
976
+ /** One poll: the cheap pass always, the discovery walk on its own cadence. */
977
+ async detect(cacheKey, distro, scanRoot) {
978
+ const detector = this.detectors.get(cacheKey);
979
+ if (detector === void 0 || this.control.signal.aborted) return;
980
+ if (detector.busy) return;
981
+ detector.busy = true;
982
+ try {
983
+ detector.polls += 1;
984
+ const walk = detector.polls % this.walkEveryPolls === 0;
985
+ let signature;
986
+ try {
987
+ signature = await this.shape(distro, walk ? await discoverSkillRoots(distro, scanRoot, this.io) : detector.roots);
988
+ } catch {
989
+ return;
990
+ }
991
+ if (signature === detector.signature) return;
992
+ detector.signature = signature;
993
+ this.cache.delete(cacheKey);
994
+ this.control.invalidate();
995
+ } finally {
996
+ detector.busy = false;
997
+ }
998
+ }
999
+ /** The shape of one root set: paths, entry names, kinds and file stamps. */
1000
+ async shape(distro, roots) {
1001
+ const parts = [];
1002
+ for (const root of roots) parts.push(await shapeOfRoot(root, await listSkillEntries(root, this.io), this.io));
1003
+ return parts.join("");
667
1004
  }
668
1005
  /**
669
1006
  * Load a complete skill body for a previously listed candidate.
@@ -972,8 +1309,76 @@ function publishVariant(staging, dest) {
972
1309
  force: true
973
1310
  });
974
1311
  }
1312
+ /**
1313
+ * Whether a host's terminal stack can allocate a PTY process *on this platform*.
1314
+ *
1315
+ * The world's `bash` is the host's persistent-shell stack, and on Windows that
1316
+ * stack needs a platform process inspector. `@deepseek-ai/dsh-subprocess-local`
1317
+ * only grew one in `0.1.0-rc.8`: in `0.1.0-rc.7` `spawnTerminal` throws
1318
+ * `subprocess-local: terminal inspection is unsupported on platform win32`
1319
+ * before the process is started, so *every* persistent shell fails there — a
1320
+ * real session shows the model getting that error for each `bash` call while
1321
+ * grep/glob (which never touch the PTY) keep working. The host itself ships the
1322
+ * same gap: that release's Minimal preset mounts `persistent-bash` with no
1323
+ * `disabled:` guard for Windows.
1324
+ *
1325
+ * The probe asks the substrate the question directly instead of pattern-matching
1326
+ * a version: `spawnTerminal` builds its inspector before `node-pty` starts the
1327
+ * program, so handing it a program that cannot exist reaches that check and
1328
+ * nothing else — no process is created either way, and the failure message says
1329
+ * which half failed. A release that can build the inspector reports an ordinary
1330
+ * spawn failure instead, which is the "supported" answer.
1331
+ * @param ctx - plugin context; the `subprocess` service is looked up with `get`
1332
+ * and waited for briefly, because the world is generated during profile boot.
1333
+ * @returns true when the persistent shell may be mounted.
1334
+ */
1335
+ async function supportsPersistentShell(ctx) {
1336
+ if (process.platform !== "win32") return true;
1337
+ const subprocess = await waitForSubprocess(ctx);
1338
+ if (subprocess?.spawnTerminal === void 0) return true;
1339
+ try {
1340
+ const handle = await subprocess.spawnTerminal({
1341
+ argv: ["dsh-wsl-workspace-pty-probe-does-not-exist"],
1342
+ cwd: process.cwd(),
1343
+ rows: 24,
1344
+ cols: 80,
1345
+ graceMs: 1e3
1346
+ });
1347
+ await handle?.terminate?.();
1348
+ return true;
1349
+ } catch (error) {
1350
+ return !isTerminalInspectionUnsupported(error);
1351
+ }
1352
+ }
1353
+ /**
1354
+ * Whether one spawn failure is the missing-platform-inspector error.
1355
+ * @param error - the rejection from `spawnTerminal`.
1356
+ * @returns true when the host cannot inspect terminal processes here.
1357
+ */
1358
+ function isTerminalInspectionUnsupported(error) {
1359
+ return /terminal inspection is unsupported on platform/i.test(messageOf(error));
1360
+ }
1361
+ /**
1362
+ * Look up the `subprocess` service, giving profile boot a moment to publish it.
1363
+ *
1364
+ * Bounded: the world is generated in a fire-and-forget effect, so this wait never
1365
+ * blocks profile boot, and the service is normally already published by the base
1366
+ * bundles the web app mounts before this plugin's own injection resolves. An
1367
+ * absent service is answered as "supported" by the caller, which is the
1368
+ * behaviour this plugin shipped before the probe existed.
1369
+ * @param ctx - plugin context.
1370
+ * @returns the service, or undefined when this deployment has none yet.
1371
+ */
1372
+ async function waitForSubprocess(ctx) {
1373
+ for (let attempt = 0; attempt < 20; attempt += 1) {
1374
+ const service = ctx.get("subprocess");
1375
+ if (service !== void 0) return service;
1376
+ await new Promise((resolve) => setTimeout(resolve, 100));
1377
+ }
1378
+ return void 0;
1379
+ }
975
1380
  /** Materialize one WSL variant per healthy source preset. */
976
- async function materializeVariants(agentPresets, dshHome, shellPath, fsPath) {
1381
+ async function materializeVariants(agentPresets, dshHome, paths, persistentShell) {
977
1382
  const presets = await agentPresets.list();
978
1383
  const userRoot = join(dshHome, ".agent-presets");
979
1384
  const generated = /* @__PURE__ */ new Set();
@@ -982,7 +1387,11 @@ async function materializeVariants(agentPresets, dshHome, shellPath, fsPath) {
982
1387
  if (isWslVariantId(preset.id)) continue;
983
1388
  const variantId = variantIdFor(preset.id);
984
1389
  const source = await agentPresets.read(preset.id);
985
- const transformed = transformPresetForWsl(source, shellPath, fsPath);
1390
+ const transformed = transformPresetForWsl(source, paths.shell, paths.fs, persistentShell ? {
1391
+ relayPath: paths.relay,
1392
+ nodePath: paths.node,
1393
+ sandboxPath: paths.sandbox
1394
+ } : void 0, paths.search, paths.jobs);
986
1395
  const dir = join(userRoot, variantId);
987
1396
  const staging = `${dir}.staging`;
988
1397
  rmSync(staging, {
@@ -1001,7 +1410,7 @@ async function materializeVariants(agentPresets, dshHome, shellPath, fsPath) {
1001
1410
  const meta = readFileSync(join(dirname(preset.path), "preset.yml"), "utf8");
1002
1411
  if (labels === void 0) {
1003
1412
  const match = /^name:\s*(.+)$/m.exec(meta);
1004
- if (match?.[1] !== void 0 && match[1].trim() !== "") name$1 = variantName(preset.id, match[1].trim());
1413
+ if (match?.[1] !== void 0 && match[1].trim() !== "") name$1 = variantName(preset.id, unquoteScalar(match[1].trim()));
1005
1414
  }
1006
1415
  const orderMatch = /^order:\s*(\d+)\s*$/m.exec(meta);
1007
1416
  if (orderMatch?.[1] !== void 0) orderLine = `order: ${orderMatch[1]}\n`;
@@ -1047,9 +1456,25 @@ function apply(ctx, config) {
1047
1456
  const packageRoot = fileURLToPath(new URL("..", import.meta.url));
1048
1457
  const shellPath = join(packageRoot, "lib", "shell.js").replace(/\\/g, "/");
1049
1458
  const fsPath = join(packageRoot, "lib", "fs.js").replace(/\\/g, "/");
1459
+ const relayPath = join(packageRoot, "lib", "wsl-relay.js").replace(/\\/g, "/");
1460
+ const sandboxPath = join(packageRoot, "lib", "wsl-sandbox.js").replace(/\\/g, "/");
1461
+ const searchPath = join(packageRoot, "lib", "wsl-search.js").replace(/\\/g, "/");
1462
+ const jobsPath = join(packageRoot, "lib", "wsl-jobs.js").replace(/\\/g, "/");
1463
+ const nodePath = process.execPath.replace(/\\/g, "/");
1050
1464
  const agentPresets = ctx.get("agentPresets");
1051
1465
  if (agentPresets !== void 0) ctx.effect(() => {
1052
- materializeVariants(agentPresets, dshHome, shellPath, fsPath).catch((error) => {
1466
+ (async () => {
1467
+ const persistentShell = await supportsPersistentShell(ctx);
1468
+ await materializeVariants(agentPresets, dshHome, {
1469
+ shell: shellPath,
1470
+ fs: fsPath,
1471
+ relay: relayPath,
1472
+ node: nodePath,
1473
+ sandbox: sandboxPath,
1474
+ search: searchPath,
1475
+ jobs: jobsPath
1476
+ }, persistentShell);
1477
+ })().catch((error) => {
1053
1478
  console.error(`dsh-wsl-workspace: WSL preset-variant generation failed: ${messageOf(error)}`);
1054
1479
  });
1055
1480
  return () => {};
@@ -1138,5 +1563,5 @@ function apply(ctx, config) {
1138
1563
  }
1139
1564
 
1140
1565
  //#endregion
1141
- export { Config, DEFAULT_ROUTE, apply, inject, name };
1566
+ export { Config, DEFAULT_ROUTE, apply, inject, isTerminalInspectionUnsupported, name };
1142
1567
  //# sourceMappingURL=index.js.map