dsh-wsl-workspace 0.7.1 → 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
@@ -434,9 +434,15 @@ const MAX_LINK_RESOLUTIONS = 32;
434
434
  /** How many parent levels above the session cwd are searched for a `.git` project marker. */
435
435
  const MAX_ANCESTOR_WALK = 64;
436
436
  /**
437
- * How long a completed lookup is served from cache before the next rescan.
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.
438
444
  */
439
- const CACHE_TTL_MS = 1e4;
445
+ const WALK_CONCURRENCY = 16;
440
446
  /** Maximum cached lookups (one entry per distinct scan root across sessions). */
441
447
  const CACHE_MAX_ENTRIES = 32;
442
448
  /**
@@ -516,7 +522,56 @@ async function nearestGitAncestor(distro, linuxDir, io) {
516
522
  return void 0;
517
523
  }
518
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
+ /**
519
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.
520
575
  * @param distro - the WSL distribution name.
521
576
  * @param linuxRoot - the workspace's absolute Linux path.
522
577
  * @param io - filesystem face.
@@ -528,41 +583,32 @@ async function discoverSkillRoots(distro, linuxRoot, io) {
528
583
  let linksResolved = 0;
529
584
  let frontier = [[linuxRoot, 0]];
530
585
  while (frontier.length > 0 && roots.length < MAX_SKILL_ROOTS) {
531
- const next = [];
532
- const links = [];
533
- for (const [dir, depth] of frontier) {
534
- if (visited.size >= MAX_VISITED_DIRECTORIES) return roots;
535
- if (visited.has(dir)) continue;
536
- visited.add(dir);
537
- if (roots.length < MAX_SKILL_ROOTS) {
538
- const directoryRoots = await skillRootsOfDirectory(distro, dir, io);
539
- roots.push(...directoryRoots.slice(0, MAX_SKILL_ROOTS - roots.length));
540
- }
541
- if (depth >= MAX_SCAN_DEPTH) continue;
542
- let entries;
543
- try {
544
- entries = await io.readdir(joinUnc(distro, dir), { withFileTypes: true });
545
- } catch {
546
- continue;
547
- }
548
- for (const entry of entries) {
549
- if (PRUNED_DIRECTORY_NAMES.has(entry.name)) continue;
550
- if (entry.name.startsWith(".") && entry.name !== ".dsh" && entry.name !== ".agents") continue;
551
- if (entry.name === ".dsh" || entry.name === ".agents") continue;
552
- const childPath = posix.join(dir, entry.name);
553
- if (entry.isDirectory()) {
554
- next.push([childPath, depth + 1]);
555
- continue;
556
- }
557
- if (!entry.isSymbolicLink()) continue;
558
- try {
559
- const target = await io.stat(joinUnc(distro, childPath));
560
- if (target.isDirectory()) next.push([childPath, depth + 1]);
561
- continue;
562
- } catch {}
563
- links.push([childPath, depth + 1]);
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);
564
601
  }
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);
565
610
  }
611
+ if (visited.size >= MAX_VISITED_DIRECTORIES) return roots;
566
612
  if (links.length > 0 && io.resolveLinks !== void 0 && linksResolved < MAX_LINK_RESOLUTIONS) {
567
613
  const batch = links.slice(0, MAX_LINK_RESOLUTIONS - linksResolved);
568
614
  linksResolved += batch.length;
@@ -572,33 +618,39 @@ async function discoverSkillRoots(distro, linuxRoot, io) {
572
618
  if (real === void 0) continue;
573
619
  try {
574
620
  const info = await io.stat(real);
575
- if (info.isDirectory()) next.push([uncToLinux(real), batch[index][1]]);
621
+ if (info.isDirectory()) nextLayer.push([uncToLinux(real), batch[index][1]]);
576
622
  } catch {}
577
623
  }
578
624
  }
579
- frontier = next;
625
+ frontier = nextLayer;
580
626
  }
581
627
  return roots;
582
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
+ ]];
583
639
  /**
584
640
  * Publish the skill roots of one scanned directory (its `.dsh/skills` and
585
641
  * `.agents/skills`, each with the host's project ranks).
586
642
  * @param distro - the WSL distribution name.
587
643
  * @param linuxDir - the scanned directory's Linux path.
588
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`.
589
648
  * @returns the directory's skill roots that exist.
590
649
  */
591
- async function skillRootsOfDirectory(distro, linuxDir, io) {
650
+ async function skillRootsOfDirectory(distro, linuxDir, io, markers = PROJECT_SKILL_MARKERS.map(([marker]) => marker)) {
592
651
  const result = [];
593
- for (const [marker, source, rank] of [[
594
- ".dsh",
595
- "project-dsh",
596
- PROJECT_DSH_RANK
597
- ], [
598
- ".agents",
599
- "project-agents",
600
- PROJECT_AGENTS_RANK
601
- ]]) {
652
+ for (const [marker, source, rank] of PROJECT_SKILL_MARKERS) {
653
+ if (!markers.includes(marker)) continue;
602
654
  const path = joinUnc(distro, posix.join(linuxDir, marker, "skills"));
603
655
  try {
604
656
  const info = await io.stat(path);
@@ -790,9 +842,10 @@ function frontmatterBoolean(fields, key, dflt = false) {
790
842
  * The WSL workspace skill provider. Registered on the host's `ctx.skills`
791
843
  * registry; serves only lookups whose cwd is a WSL UNC workspace path.
792
844
  *
793
- * Completed `list()` lookups are cached per scan root for CACHE_TTL_MS so
794
- * repeated catalog builds over the slow 9P share do not rescan the tree;
795
- * `get()` always re-reads the skill file so body edits are picked up
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
796
849
  * immediately.
797
850
  *
798
851
  * A scan root that has been served is re-checked every REFRESH_POLL_MS: the
@@ -801,23 +854,23 @@ function frontmatterBoolean(fields, key, dflt = false) {
801
854
  * changed directory listing clears the cache and calls `control.invalidate()`,
802
855
  * which bumps the registry's revision; the catalog middleware re-collects on
803
856
  * the session's next request, so a skill added mid-session reaches the model
804
- * without starting a new session.
857
+ * without starting a new session, and the cache is only ever refilled by a
858
+ * lookup the detector has already invalidated.
805
859
  */
806
860
  var WslSkillsProvider = class {
807
861
  name = "wsl-workspace";
808
862
  control;
809
863
  io;
810
- now;
811
864
  refreshMs;
812
865
  /** Cheap polls between two full discovery walks (at least one). */
813
866
  walkEveryPolls;
867
+ /** The published catalog per scan root, served until its detector drops it. */
814
868
  cache = /* @__PURE__ */ new Map();
815
869
  /** One change detector per served scan root, keyed like {@link cache}. */
816
870
  detectors = /* @__PURE__ */ new Map();
817
- constructor(control, io = nodeSkillIo, now = Date.now, refreshMs = REFRESH_POLL_MS, discoveryMs = DISCOVERY_POLL_MS) {
871
+ constructor(control, io = nodeSkillIo, refreshMs = REFRESH_POLL_MS, discoveryMs = DISCOVERY_POLL_MS) {
818
872
  this.control = control;
819
873
  this.io = io;
820
- this.now = now;
821
874
  this.refreshMs = refreshMs;
822
875
  this.walkEveryPolls = Math.max(1, Math.round(discoveryMs / refreshMs));
823
876
  }
@@ -836,10 +889,10 @@ var WslSkillsProvider = class {
836
889
  const scanRoot = await nearestGitAncestor(unc.distro, unc.linuxPath, this.io) ?? unc.linuxPath;
837
890
  const cacheKey = `${unc.distro}\u0000${scanRoot}`;
838
891
  const cached = this.cache.get(cacheKey);
839
- if (cached !== void 0 && cached.expiresAt > this.now()) {
892
+ if (cached !== void 0) {
840
893
  this.cache.delete(cacheKey);
841
894
  this.cache.set(cacheKey, cached);
842
- return [...cached.candidates];
895
+ return [...cached];
843
896
  }
844
897
  const roots = await discoverSkillRoots(unc.distro, scanRoot, this.io);
845
898
  const candidates = [];
@@ -871,17 +924,14 @@ var WslSkillsProvider = class {
871
924
  });
872
925
  }
873
926
  }
874
- this.cache.set(cacheKey, {
875
- expiresAt: this.now() + CACHE_TTL_MS,
876
- candidates
877
- });
927
+ this.cache.set(cacheKey, candidates);
878
928
  while (this.cache.size > CACHE_MAX_ENTRIES) {
879
929
  const oldest = this.cache.keys().next().value;
880
930
  if (oldest === void 0) break;
881
931
  this.cache.delete(oldest);
882
932
  }
883
933
  this.watch(cacheKey, unc.distro, scanRoot, signature.join(""), roots);
884
- return candidates;
934
+ return [...candidates];
885
935
  }
886
936
  /**
887
937
  * Keep one scan root's catalog honest for as long as this provider is