dsh-wsl-workspace 0.7.2 → 0.7.3

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-wsl-workspace",
3
3
  "description": "WSL workspace support for DeepSeek Harness: add a WSL workspace from the web GUI and run the whole agent session (bash + file tools) inside the WSL distribution, VS Code Remote-WSL style. No toolchain install inside WSL required.",
4
- "version": "0.7.2",
4
+ "version": "0.7.3",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -45,7 +45,8 @@
45
45
  "0.1.2-rc.1": "compatible",
46
46
  "0.1.3-alpha.2": "compatible",
47
47
  "0.1.5-rc.1": "compatible",
48
- "0.1.5-rc.2": "compatible"
48
+ "0.1.5-rc.2": "compatible",
49
+ "0.1.7-rc.1": "compatible"
49
50
  }
50
51
  }
51
52
  },
@@ -68,6 +69,7 @@
68
69
  ],
69
70
  "peerDependencies": {
70
71
  "@deepseek-ai/cordis": "*",
72
+ "@deepseek-ai/cordis-plugin-include": "*",
71
73
  "@deepseek-ai/dsh-fs": "*",
72
74
  "@deepseek-ai/dsh-fs-local": "*",
73
75
  "@deepseek-ai/dsh-sandbox": "*",
@@ -76,10 +78,12 @@
76
78
  "@deepseek-ai/dsh-timeout": "*",
77
79
  "@deepseek-ai/dsh-tool-fs-search": "*",
78
80
  "@deepseek-ai/dsh-tools": "*",
79
- "@deepseek-ai/schemastery": "*"
81
+ "@deepseek-ai/schemastery": "*",
82
+ "js-yaml": "*"
80
83
  },
81
84
  "peerDependenciesMeta": {
82
85
  "@deepseek-ai/cordis": { "optional": true },
86
+ "@deepseek-ai/cordis-plugin-include": { "optional": true },
83
87
  "@deepseek-ai/dsh-fs": { "optional": true },
84
88
  "@deepseek-ai/dsh-fs-local": { "optional": true },
85
89
  "@deepseek-ai/dsh-sandbox": { "optional": true },
@@ -88,7 +92,8 @@
88
92
  "@deepseek-ai/dsh-timeout": { "optional": true },
89
93
  "@deepseek-ai/dsh-tool-fs-search": { "optional": true },
90
94
  "@deepseek-ai/dsh-tools": { "optional": true },
91
- "@deepseek-ai/schemastery": { "optional": true }
95
+ "@deepseek-ai/schemastery": { "optional": true },
96
+ "js-yaml": { "optional": true }
92
97
  },
93
98
  "files": [
94
99
  "lib",
@@ -41,8 +41,8 @@ export const zh: Record<string, string> = {
41
41
  'help.compat.versionLabel': '插件版本',
42
42
  'help.compat.unknown': '未能读取版本与兼容声明(宿主端没有响应)',
43
43
  'help.compat.body': '上方是这份构建声明兼容的 DSH 版本,每一条都在隔离实例上实测过(独立 DSH_HOME、依赖固定到该版本、跑满十项门禁)。\n插件在运行时自动识别 DSH 版本并选用对应的 API;两边都不支持时会明确报错,而不是留下一个空工作区。\n版本不在列表里通常仍然可用,但未经验证。',
44
- 'help.news.title': '本次更新(0.7.2)',
45
- 'help.news.body': '修掉了 WSL 技能目录在请求路径上重走的问题(issue #25):宿主会在请求期间重建技能目录并等待各 provider 的 list(),而插件只把答案保留 10 秒,于是每次重新收集都得重走整棵工作区,工作区一大就正好卡在走查的 4096 目录预算上。\n9P 共享上单次 readdir 实测 3~16 毫秒,所以命中预算的工作区那次走查要 20 秒;现在已发布的目录直接照原样返回,只有插件自己的探针确认变化时才丢弃缓存,重复查找降到 1~3 毫秒、且完全不碰文件系统。\n走查本身也变便宜:同一层并发探测(有上限)、按 frontier 顺序发布以保证目录顺序确定,且只在目录清单里出现 .dsh / .agents 时才探测对应 skills。同机实测命中预算的走查从 20.4 秒降到 4.8 秒。\n修掉了 npm 安装失败:npm 会自动安装缺失的同伴依赖,而 @deepseek-ai/dsh-tool-fs-search 自己又依赖并未公开发布的 @deepseek-ai/dsh-retention,于是 npm install dsh-wsl-workspace 直接 E404 失败(dsh plugin 走 pnpm、只警告,所以一直没暴露)。\n十个宿主同伴依赖现在都标为 optional:包仍声明宿主必须提供什么,但 npm 不再尝试下载它们。\nWSL 世界现在在会话能察觉到的每一处都与宿主一致:Linux 符号链接、会话的访问模式、发行版内的 grep / glob、实时技能目录、有状态的 shell,以及可跟踪的后台任务。\n新增 bash_background:持久 shell 的 schema 只有 command,没有生产者时 job_list 永远说"没有后台任务",而传给 bash 的 run_in_background 会被静默忽略;现在后台任务可跟踪,job_output 增量读取、job_kill 取消都照常工作。\nbash 的工具描述写明两件宿主没说的事:cd / export 跨调用保留,命令以 & 结尾会把整条命令后台化(请写成单独一行的 ( 长任务 > log 2>&1 ) &)。0.1.0-rc.7 的宿主缺少 Windows 进程检查器、持久 shell 起不来,插件会在启动时探测并回退到一次性 bash,不再让每次调用都报错。',
44
+ 'help.news.title': '本次更新(0.7.3)',
45
+ 'help.news.body': '插件在 DSH 0.1.7 上重新可加载、模式也回来了:那条线把预设平面改成了声明式的 @deepseek-ai/dsh-agent-preset 行,read() 变成 readDocument()、AgentPreset 不再有 path,于是变体生成器每次启动都抛 agentPresets.read is not a function,选择器里一个 wsl-* 模式都不会出现。现在按能力探测两代接口,两代都服务。\n在 0.1.7 上变体改为通过 ctx.agentPresets.register() 发布成一条声明;注销器由插件的 effect 持有,卸载或热重载会注销这些变体,而不是留下下一次 apply 无法替换的孤儿。更早的版本仍走原来的目录通道,行为不变。\n该通道下世界自己的 provider 行必须以 file: URL 命名:由声明挂载的预设由注册表自己的 entry tree 导入,它不会像启动期的 Include 那样把绝对路径翻译成 file: URL,否则那些行根本不会启动、整个变体被判为不可用而拒绝。relay 与解释器这类配置值仍保持原生路径。\n修掉了 PTC 变体的显示名:标签表里只登记了旧 id code,而 0.1.7 不发布显示名,于是模式显示为 WSL · ptc、描述也退化成 WSL execution world for ptc: …;现在两条通道都显示为 WSL · PTC mode(PTC 模式)。\n兼容性清单增加 0.1.7-rc.1;声明通道需要的两个模块(@deepseek-ai/cordis-plugin-include、js-yaml)在调用时解析,并声明为可选同伴依赖。',
46
46
  'help.usage.title': '用法与特性',
47
47
  'help.usage.body': '点击侧边栏底部的 W 按钮 → 选发行版 → 输入或浏览 Linux 路径 → 检查 → 创建并打开。\n创建出的会话里,bash 与文件工具都落在该发行版内,模型看到的路径全是 Linux 路径;Windows 盘可从会话内通过 /mnt/<盘符> 访问。\n四种模式(标准 / PTC / 极简 / 创造)各有 WSL 变体,在模式选择器里直接选即可,名字形如 WSL · Standard mode(标准模式)。\n用户名可选,等价于 wsl.exe -u <用户名>,只改变 bash 与 persistent-bash 的运行身份;文件工具走 Windows 侧共享,不受它影响。\n技能目录从会话 cwd 最近的 .git 祖先开始向下扫描 .dsh/skills 与 .agents/skills(含嵌套项目),上限 4 层目录 / 64 个技能目录 / 4096 个已访问目录,并按扫描根缓存 10 秒。9P 解不开的符号链接交给发行版 readlink 解析出真实路径(每次查找最多 32 条)后继续扫描,链接进来的项目与它下面的嵌套项目都能扫到。\n目录不再冻结:对 UNC 关闭文件监视之后,插件每 3 秒重查一次已发布的技能目录、并比对每个技能文件的修改时间与大小,所以新增、删除与改写都会在下一个回合生效;完整重新发现每 30 秒一次,用于找到此前不存在的技能目录(技能正文始终实时读取)。\n文件搜索由发行版内的 grep / glob 提供,源预设没有这两个工具的模式(极简)不会多出来:grep 用 GNU grep -E(\\d、\\w、(?i) 可用,环视与反向引用不支持),跳过隐藏项与 node_modules,不读 .gitignore;glob 用 GNU find 列文件、按 gitignore 风格匹配(* 不跨目录、** 跨、{a,b}、前导 ! 取反),按修改时间从旧到新排序。\n文件工具在链接处按真实路径工作,并按真实路径判策略:链接指向工作区外就等于工作区外。\nbash 由 PTY 承载的持久 shell 提供:登录环境、起始目录就是会话工作区,cd / export / venv / 后台任务跨调用保留(它取代了原先一次性 bash)。\n需要可跟踪的后台任务时用 bash_background:它立刻返回 job id,job_list / job_output(增量读取)/ job_kill 都作用于它;bash 本身没有 run_in_background 参数,传了会被忽略。job id 只在本次 DSH 进程内唯一,重启后会重新编号,引用前先用 job_list 确认。\nbash 与文件工具的 shell 都在发行版内运行、不受 DSH 文件策略约束;文件工具(read/write/edit)受策略约束,工作区内修改模式下只能写工作区内。',
48
48
  'help.known.title': '已知问题',
@@ -89,8 +89,8 @@ export const en: Record<string, string> = {
89
89
  'help.compat.versionLabel': 'Plugin version',
90
90
  'help.compat.unknown': 'Version and compatibility declaration unavailable (the host side did not answer)',
91
91
  'help.compat.body': 'The chips above are the DSH releases this build declares, each verified on an isolated instance (own DSH_HOME, dependencies pinned to that release, full check suite).\nThe plugin detects the DSH generation at runtime and picks the matching API; a release exposing neither fails loudly instead of leaving an empty workspace.\nA release outside the list usually still works, but is unverified.',
92
- 'help.news.title': "What's new in 0.7.2",
93
- 'help.news.body': 'Fixed the WSL skill catalog being re-walked on the request path (issue #25): the host rebuilds the catalog during a request and awaits each provider\'s list(), while this plugin kept its answer for only 10 s - so every re-collection re-walked the whole workspace.\nA readdir over the WSL 9P share costs 3-16 ms and the walk\'s budget is 4096 directories, so a large workspace sat 20 s on it. The published catalog is now served as-is until the provider\'s own watcher sees a change, which takes a repeat lookup to 1-3 ms with no filesystem traffic at all.\nThe walk is cheaper too: one BFS layer is probed concurrently and published in frontier order, and a directory\'s .dsh/skills is probed only when its own listing showed .dsh. A budget-sized walk measured 20.4 s before and 4.8 s after.\nFixed a failed npm install: npm auto-installs missing peers, and @deepseek-ai/dsh-tool-fs-search peers on @deepseek-ai/dsh-retention, which is not published - so npm install dsh-wsl-workspace died with E404.\nThe ten host peer dependencies are now marked optional, so npm leaves them to the host (dsh plugin add uses pnpm and only warned, which is why this stayed hidden).\nThe WSL world now matches the host everywhere a session can tell the difference: Linux symlinks, the session\'s access mode, in-distribution grep / glob, a live skill catalog, a stateful shell, and tracked background jobs.\nNew: bash_background. The persistent shell takes `command` only, so without a producer job_list always answered "no background jobs" and a run_in_background argument passed to bash was silently ignored; background work is now tracked, with incremental job_output reads and job_kill.\nThe bash tool description now states two things the host does not: cd / exports carry into the next call, and a command ending in & backgrounds the whole command - background the subshell instead, as ( long-job > log 2>&1 ) &.\n0.1.0-rc.7 lacks the Windows process inspector the persistent shell needs, so the plugin probes for it and falls back to a one-shot bash instead of failing every call.',
92
+ 'help.news.title': "What's new in 0.7.3",
93
+ 'help.news.body': 'The plugin loads on DSH 0.1.7 again, and its modes come back: that line turned the preset plane into declarative @deepseek-ai/dsh-agent-preset rows, renamed read() to readDocument() and dropped AgentPreset.path.\nThe variant generator therefore threw agentPresets.read is not a function on every boot and no wsl-* mode reached the picker; both roster generations are now probed by capability.\nOn 0.1.7 a variant is published as a declaration through ctx.agentPresets.register(); the effect installed by the plugin owns the returned disposers, so an unload or hot reload retires the variants instead of leaving orphans the next apply could not replace.\nEarlier releases keep the directory channel, unchanged.\nOn that channel the provider rows of the world must be named as file: URLs: a preset mounted from a declaration is imported by the entry tree of the registry, which does not translate an absolute path the way the boot-time Include does.\nWithout that rewrite those rows never started and the whole variant was refused as unusable. Config values such as the relay and the interpreter stay native paths.\nThe PTC variant is named again: the label table carried only the older id code, and 0.1.7 publishes no display name, so the mode read WSL · ptc with the generic description WSL execution world for ptc: …; both channels now show WSL · PTC mode(PTC 模式).\nThe compatibility manifest adds 0.1.7-rc.1, and the two modules the declaration channel needs (@deepseek-ai/cordis-plugin-include, js-yaml) are resolved at call time and declared as optional peers.',
94
94
  'help.usage.title': 'Usage and features',
95
95
  'help.usage.body': 'Click the W button at the sidebar foot, pick a distribution, type or browse to a Linux path, press Check, then Create & open.\nIn that session the bash tool and the file tools run inside the distribution, so every path the model sees is a Linux path; Windows drives stay reachable as /mnt/<drive>.\nEach mode (Standard / PTC / Minimal / Creator) has a WSL variant in the mode picker, named like WSL · Standard mode.\nThe optional username behaves like wsl.exe -u <user> for bash and persistent-bash; the file tools go through the Windows-side share and are unaffected.\nThe skill catalog is discovered from the nearest .git ancestor of the session cwd downwards (.dsh/skills and .agents/skills, nested projects included), bounded to 4 levels / 64 skill directories / 4096 visited directories, and cached per scan root for 10 seconds.\nA link the share cannot follow is resolved through the distribution (wsl.exe readlink, at most 32 per lookup) and the scan continues at the real path, so a linked-in project and its own nested projects are found too.\nThe catalog is not frozen: published skills directories are re-checked every 3 seconds (skill file mtime + size), so an add, remove or edit appears on the next turn; a 30-second walk finds a skills directory that did not exist before.\nFile search comes from grep / glob inside the distribution, and a mode that mounts no search suite (Minimal) gains none. grep is GNU grep -E (no lookaround or backreferences), skips hidden entries and node_modules, and does not read .gitignore; glob matches gitignore-style patterns here, oldest first.\nThe file tools work at the resolved real path of a link, and the policy is judged there too: a link out of the workspace is an outside write.\n`bash` is a PTY-backed stateful shell: login environment, starting directory the session workspace, and cd / exports / background jobs survive between calls.\nFor a tracked background job use bash_background: it returns a job id immediately, and job_list / job_output (incremental) / job_kill act on it.\n`bash` itself has no run_in_background parameter and ignores one; job ids are unique within this DSH process, so confirm with job_list before acting on one.\nBoth it and the file tools\' shell run inside the distribution, outside the DSH file policy; read/write/edit are inside it, and workspace-write only writes inside the workspace.',
96
96
  'help.known.title': 'Known issues',
package/src/index.ts CHANGED
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * Host half of dsh-wsl-workspace. Three responsibilities:
3
3
  *
4
- * 1. Materialize a `wsl-<mode>` variant for every healthy roster preset
5
- * under `<dshHome>/.agent-presets/` (the roster's auto-scanned user
6
- * root), so the WSL execution world — `shell-wsl` + `fs-wsl` behind one
7
- * entry-local realm, with `tool-bash`/`tool-fs` consumers — composes with
8
- * ANY mode instead of being a mode itself; the legacy standalone `wsl`
9
- * preset directory and stale variants are removed on boot. The preset
10
- * rows name THIS package's built lib files by absolute path, which the
11
- * preset mount resolves to `file:` URLs without relying on bare specifier
12
- * resolution from the preset's home directory.
4
+ * 1. Publish a `wsl-<mode>` variant for every healthy roster preset, so the
5
+ * WSL execution world — `shell-wsl` + `fs-wsl` behind one entry-local
6
+ * realm, with `tool-bash`/`tool-fs` consumers — composes with ANY mode
7
+ * instead of being a mode itself. The channel follows the release:
8
+ * `0.1.7-alpha.1+` stopped scanning any user preset root and builds its roster
9
+ * from declarative `@deepseek-ai/dsh-agent-preset` rows, so there a variant
10
+ * is a *declaration registered through `ctx.agentPresets`* — and only that
11
+ * path rewrites the absolute provider specifiers below into `file:` URLs,
12
+ * because a registered preset's entry tree does not translate them the way
13
+ * the boot-time Include does; earlier releases still get a variant directory
14
+ * under `<dshHome>/.agent-presets/`, whose legacy standalone `wsl` directory
15
+ * and stale variants are removed on boot.
13
16
  *
14
17
  * 2. Serve the browser dialog's data route (`/wsl-workspace/api`):
15
18
  * distribution discovery, one-level directory listing, path checks, and
@@ -26,8 +29,8 @@
26
29
  import { Context } from '@deepseek-ai/cordis'
27
30
  import z from '@deepseek-ai/schemastery'
28
31
  import { cpSync, existsSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
29
- import { fileURLToPath } from 'node:url'
30
- import { dirname, join } from 'node:path'
32
+ import { fileURLToPath, pathToFileURL } from 'node:url'
33
+ import { dirname, isAbsolute, join } from 'node:path'
31
34
  import type { IncomingMessage, ServerResponse } from 'node:http'
32
35
  import { homedir } from 'node:os'
33
36
  import { joinUnc, mntToWindowsPath, normalizeLinuxPath, isAbsoluteLinuxPath, isValidWslUsername, parseWslUnc } from './shared/paths.ts'
@@ -47,10 +50,18 @@ export const DEFAULT_ROUTE = '/wsl-workspace/api'
47
50
  * preset.yml text verbatim, so the plugin writes one bilingual string so
48
51
  * both locales can identify each variant. Custom presets keep their own
49
52
  * name.
53
+ *
54
+ * This table is consulted before anything the release publishes, so every id
55
+ * the mode has ever carried needs an entry. `code` was the id up to
56
+ * `0.1.0-rc.8` and `ptc` is the id from `0.1.1` on; without the `ptc` entry the
57
+ * lookup returned undefined and the variant fell through to the release's own
58
+ * display name — which `0.1.7` does not publish at all, leaving the mode named
59
+ * `WSL · ptc` with the generic description `WSL execution world for ptc: …`.
50
60
  */
51
61
  const MODE_DISPLAY_LABELS: Readonly<Record<string, { en: string; zh: string }>> = {
52
62
  standard: { en: 'Standard mode', zh: '标准模式' },
53
63
  code: { en: 'Code mode', zh: 'PTC 模式' },
64
+ ptc: { en: 'PTC mode', zh: 'PTC 模式' },
54
65
  minimal: { en: 'Minimal mode', zh: '极简模式' },
55
66
  cordis: { en: 'Creator mode', zh: '创造模式' },
56
67
  }
@@ -352,10 +363,149 @@ async function dispatch(method: string, params: Record<string, unknown>): Promis
352
363
  }
353
364
  }
354
365
 
355
- /** The `ctx.agentPresets` roster face this plugin consumes (optional service). */
366
+ /** One roster entry as the `ctx.agentPresets` face reports it. */
367
+ interface AgentPresetRosterEntry {
368
+ id: string
369
+ name?: string
370
+ description?: string
371
+ /** Roster position; `list()` sorts by it. */
372
+ order?: number
373
+ broken?: string
374
+ /** Legacy (≤ 0.1.6-alpha.2) only: the source preset's directory. */
375
+ path?: string
376
+ }
377
+
378
+ /** One preset's declared composition, as the declaration channel reports it. */
379
+ interface AgentPresetDocument {
380
+ agentPreset: string
381
+ /** The declared child plugin list as entry-list YAML, `!!js` expressions included. */
382
+ content: string
383
+ name?: string
384
+ description?: string
385
+ }
386
+
387
+ /** A declaration this plugin publishes; mirrors the Host's `PresetDefinition`. */
388
+ interface PresetDeclaration {
389
+ id: string
390
+ name?: string
391
+ description?: string
392
+ order?: number
393
+ plugins: readonly unknown[]
394
+ }
395
+
396
+ /**
397
+ * The `ctx.agentPresets` roster face this plugin consumes (optional service).
398
+ *
399
+ * Two generations sit behind this one shape, so both are probed by capability
400
+ * rather than by version:
401
+ *
402
+ * - `0.1.0-rc.7 … 0.1.6-alpha.2`: `list()` reports each preset's directory in
403
+ * `path`, and `read(id)` returns the composition text. A variant is a
404
+ * *directory* under the roster's scanned user root (`$DSH_HOME/.agent-presets/`).
405
+ * - `0.1.7-alpha.1+`: `read()` is renamed `readDocument()` and returns a document
406
+ * instead of a bare string, `AgentPreset.path` is gone, and **nothing scans
407
+ * the user root any more** — the roster is built from declarative
408
+ * `@deepseek-ai/dsh-agent-preset` rows carried by bundle patches. A variant
409
+ * is therefore a declaration row published through `register()`.
410
+ */
356
411
  interface AgentPresetsService {
357
- list(): Promise<{ id: string; broken?: string; path: string }[]>
358
- read(id: string): Promise<string>
412
+ list(): Promise<AgentPresetRosterEntry[]>
413
+ /** Legacy (≤ 0.1.6-alpha.2): the source composition text. */
414
+ read?(id: string): Promise<string>
415
+ /** 0.1.7-alpha.1+: the declared composition beside its published metadata. */
416
+ readDocument?(id: string): Promise<AgentPresetDocument>
417
+ /** 0.1.7-alpha.1+: publish a declaration; the returned disposer retires it. */
418
+ register?(definition: PresetDeclaration): Promise<() => Promise<void>>
419
+ }
420
+
421
+ /**
422
+ * Read one preset's declared composition across both roster generations.
423
+ * @param agentPresets - the roster face.
424
+ * @param preset - the roster entry to read.
425
+ * @returns the composition text, plus the display name when the face publishes it.
426
+ */
427
+ async function readPresetComposition(
428
+ agentPresets: AgentPresetsService,
429
+ preset: AgentPresetRosterEntry,
430
+ ): Promise<{ content: string; name?: string }> {
431
+ if (typeof agentPresets.readDocument === 'function') {
432
+ const document = await agentPresets.readDocument(preset.id)
433
+ return document.name === undefined
434
+ ? { content: document.content }
435
+ : { content: document.content, name: document.name }
436
+ }
437
+ if (typeof agentPresets.read === 'function') return { content: await agentPresets.read(preset.id) }
438
+ throw new Error(
439
+ `agentPresets: this DSH release exposes neither readDocument() nor read() (preset "${preset.id}")`,
440
+ )
441
+ }
442
+
443
+ /**
444
+ * Parse one transformed composition back into declaration rows.
445
+ *
446
+ * The composition is the entry-list YAML dialect, whose `!!js` scalars are
447
+ * expression nodes the Loader evaluates when it activates the row.
448
+ * `@deepseek-ai/cordis-plugin-include` exports `entryListSchema` precisely so
449
+ * config tooling can round-trip that dialect, and it is the same schema the
450
+ * harness parses preset patches with. Parsing the text back is what lets the
451
+ * (text-level) WSL transform keep working unchanged now that a variant is a
452
+ * declaration row instead of a directory of YAML.
453
+ *
454
+ * Both modules are resolved at call time rather than at module load: they are
455
+ * Host-provided (`dsh` depends on `cordis-plugin-include`, which depends on
456
+ * `js-yaml`), and a release that ever drops them must fail this one variant
457
+ * rather than refuse to load the whole plugin.
458
+ * @param content - the variant composition text.
459
+ * @returns the declaration's plugin rows.
460
+ */
461
+ async function parseVariantComposition(content: string): Promise<unknown[]> {
462
+ const includeSpecifier = '@deepseek-ai/cordis-plugin-include'
463
+ const yamlSpecifier = 'js-yaml'
464
+ const [include, yamlModule] = await Promise.all([
465
+ import(includeSpecifier) as Promise<{ entryListSchema: unknown }>,
466
+ import(yamlSpecifier) as Promise<{
467
+ load?(source: string, options: { schema: unknown }): unknown
468
+ default?: { load(source: string, options: { schema: unknown }): unknown }
469
+ }>,
470
+ ])
471
+ const load = yamlModule.load ?? yamlModule.default?.load
472
+ if (typeof load !== 'function') throw new Error('js-yaml: no load() export')
473
+ const rows = load(content, { schema: include.entryListSchema })
474
+ if (!Array.isArray(rows)) throw new Error('the transformed composition did not parse as an entry list')
475
+ return toImportableSpecifiers(rows)
476
+ }
477
+
478
+ /**
479
+ * Rewrite every absolute local module specifier as a `file:` URL.
480
+ *
481
+ * The generated world names THIS package's built providers (`shell.js`,
482
+ * `fs.js`, …) by absolute path. That is what the directory mechanism needed:
483
+ * those rows sat in an Include-backed tree, and the boot-time Include
484
+ * translates an absolute path into a `file:` URL before importing it. A preset
485
+ * mounted from a *declaration* is loaded by the registry's own entry tree,
486
+ * which has no such translation — handing it `C:/…/lib/shell.js` leaves those
487
+ * rows without a fiber, the audit reports them "never started", and the whole
488
+ * variant is refused. Rewriting the specifier is therefore part of adapting to
489
+ * the declaration mechanism, not a change to what the variant mounts.
490
+ *
491
+ * Only `name` is touched: config values (the relay path handed to the PTY
492
+ * backend, the interpreter path in `shellPath`) must stay native filesystem
493
+ * paths. Group rows carry their children in a `config` array, so those are
494
+ * walked too.
495
+ * @param rows - the parsed declaration rows.
496
+ * @returns the same rows with their module specifiers made importable.
497
+ */
498
+ function toImportableSpecifiers(rows: unknown[]): unknown[] {
499
+ const rewrite = (row: unknown): unknown => {
500
+ if (row === null || typeof row !== 'object') return row
501
+ const entry = row as { name?: unknown; config?: unknown }
502
+ if (typeof entry.name === 'string' && isAbsolute(entry.name)) {
503
+ entry.name = pathToFileURL(entry.name).href
504
+ }
505
+ if (Array.isArray(entry.config)) entry.config = entry.config.map(rewrite)
506
+ return row
507
+ }
508
+ return rows.map(rewrite)
359
509
  }
360
510
 
361
511
  /**
@@ -469,6 +619,7 @@ async function materializeVariants(
469
619
  dshHome: string,
470
620
  paths: { shell: string; fs: string; relay: string; node: string; sandbox: string; search: string; jobs: string },
471
621
  persistentShell: boolean,
622
+ track: (dispose: unknown) => void,
472
623
  ): Promise<void> {
473
624
  const presets = await agentPresets.list()
474
625
  const userRoot = join(dshHome, '.agent-presets')
@@ -477,12 +628,33 @@ async function materializeVariants(
477
628
  if (preset.broken !== undefined) continue
478
629
  if (isWslVariantId(preset.id)) continue
479
630
  const variantId = variantIdFor(preset.id)
480
- const source = await agentPresets.read(preset.id)
481
- const transformed = transformPresetForWsl(source, paths.shell, paths.fs, persistentShell ? {
631
+ const composition = await readPresetComposition(agentPresets, preset)
632
+ const transformed = transformPresetForWsl(composition.content, paths.shell, paths.fs, persistentShell ? {
482
633
  relayPath: paths.relay,
483
634
  nodePath: paths.node,
484
635
  sandboxPath: paths.sandbox,
485
636
  } : undefined, paths.search, paths.jobs)
637
+ // 0.1.7-alpha.1+ publishes a variant as a declaration row (the composition is
638
+ // already the exact entry-list dialect the declaration wants, so the only
639
+ // conversion is YAML back to rows); earlier releases still discover one as
640
+ // a directory under the roster's scanned user root.
641
+ if (typeof agentPresets.register === 'function') {
642
+ const plugins = await parseVariantComposition(transformed)
643
+ // A shipped mode keeps its bilingual label; a custom preset keeps the
644
+ // display name it published, falling back to its id.
645
+ const declaration: PresetDeclaration = {
646
+ id: variantId,
647
+ name: variantName(preset.id, composition.name ?? preset.name ?? preset.id),
648
+ description: variantDescription(preset.id),
649
+ ...(preset.order === undefined ? {} : { order: preset.order }),
650
+ plugins,
651
+ }
652
+ track(await agentPresets.register(declaration))
653
+ continue
654
+ }
655
+ if (preset.path === undefined) {
656
+ throw new Error(`agentPresets: roster entry "${preset.id}" carries no path on this release`)
657
+ }
486
658
  const dir = join(userRoot, variantId)
487
659
  const staging = `${dir}.staging`
488
660
  rmSync(staging, { recursive: true, force: true })
@@ -524,15 +696,20 @@ async function materializeVariants(
524
696
  publishVariant(staging, dir)
525
697
  generated.add(variantId)
526
698
  }
527
- for (const entry of readdirSync(userRoot, { withFileTypes: true })) {
528
- if (!entry.isDirectory()) continue
529
- if (entry.name === 'wsl') {
530
- // The legacy standalone WSL mode: folded into the variants above.
699
+ // Clean up the retired directory mechanism. `generated` holds the
700
+ // directories the directory path wrote on THIS boot; from 0.1.7-alpha.1 on it is
701
+ // always empty, because a variant is a declaration row and the roster no
702
+ // longer scans this root at all — so every `wsl*` entry left here (the legacy
703
+ // standalone `wsl` mode included) is inert leftover, and a `wsl-<mode>/`
704
+ // beside a registered `wsl-<mode>` declaration would only mislead. An absent
705
+ // root (a fresh install that never used the old mechanism) is not a failure.
706
+ if (existsSync(userRoot)) {
707
+ for (const entry of readdirSync(userRoot, { withFileTypes: true })) {
708
+ if (!entry.isDirectory()) continue
709
+ if (!/^wsl(-[a-z0-9-]+)?$/.test(entry.name)) continue
710
+ if (generated.has(entry.name)) continue
531
711
  rmSync(join(userRoot, entry.name), { recursive: true, force: true })
532
- continue
533
712
  }
534
- if (!/^wsl-[a-z0-9-]+$/.test(entry.name)) continue
535
- if (!generated.has(entry.name)) rmSync(join(userRoot, entry.name), { recursive: true, force: true })
536
713
  }
537
714
  }
538
715
 
@@ -576,6 +753,21 @@ export function apply(ctx: Context, config: Config): void {
576
753
  const agentPresets = ctx.get('agentPresets') as unknown as AgentPresetsService | undefined
577
754
  if (agentPresets !== undefined) {
578
755
  ctx.effect(() => {
756
+ // On 0.1.7-alpha.1+ the variants are declaration rows in Host state rather
757
+ // than files, so the effect owns their disposers: a plugin unload or hot
758
+ // reload retires them instead of leaving orphans the next apply could not
759
+ // replace (`Duplicate agent preset: wsl-<mode>`).
760
+ let stopped = false
761
+ const disposers: (() => Promise<void>)[] = []
762
+ const track = (dispose: unknown): void => {
763
+ if (typeof dispose !== 'function') return
764
+ const retire = dispose as () => Promise<void>
765
+ if (stopped) {
766
+ void Promise.resolve(retire()).catch(() => {})
767
+ return
768
+ }
769
+ disposers.push(retire)
770
+ }
579
771
  void (async () => {
580
772
  const persistentShell = await supportsPersistentShell(ctx)
581
773
  await materializeVariants(agentPresets, dshHome, {
@@ -586,14 +778,19 @@ export function apply(ctx: Context, config: Config): void {
586
778
  sandbox: sandboxPath,
587
779
  search: searchPath,
588
780
  jobs: jobsPath,
589
- }, persistentShell)
781
+ }, persistentShell, track)
590
782
  })().catch((error) => {
591
783
  // Variant generation is best-effort over a live roster: a missing or
592
784
  // unreadable source preset must not take the whole plugin down, but
593
785
  // the failure is surfaced loudly rather than hidden.
594
786
  console.error(`dsh-wsl-workspace: WSL preset-variant generation failed: ${messageOf(error)}`)
595
787
  })
596
- return () => {}
788
+ return () => {
789
+ stopped = true
790
+ for (const retire of disposers.splice(0, disposers.length)) {
791
+ void Promise.resolve(retire()).catch(() => {})
792
+ }
793
+ }
597
794
  }, 'dsh-wsl-workspace: WSL preset variants')
598
795
  }
599
796