dsh-wsl-tool 1.9.0 → 1.10.0

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/README.md CHANGED
@@ -134,13 +134,48 @@ A background job is owned by the calling session (`owner: exec.agent.id`), which
134
134
  is what lets the model read it back with `job_output`/`job_kill` and what keeps
135
135
  other sessions out; an execution with no agent starts the job unowned.
136
136
 
137
- ## A WSL terminal in the sidebar
137
+ ## The WSL panel (left sidebar)
138
138
 
139
- Installing this plugin also gives the desktop app's sidebar terminal a **WSL**
140
- shell. `cordis.patch.yml` overrides the composed `terminal-controller` row, whose
141
- configured shell is listed **first** and selected by default; the shells that row
142
- discovers (`powershell`, `cmd`, `bash`, …) stay selectable, so 新建终端 offers WSL
143
- next to them.
139
+ The plugin ships a small client half: a **WSL** entry in the desktop app's left
140
+ sidebar, whose panel carries one switch per feature, each with a one-line
141
+ explanation.
142
+
143
+ | Switch | What it controls |
144
+ |---|---|
145
+ | `wsl` 命令执行 | registers the `wsl` tool |
146
+ | `wsl-path` 路径转换 | registers the `wsl-path` tool |
147
+ | `wsl-env` 能力体检 | registers the `wsl-env` tool |
148
+ | 后台任务 | whether `wsl` accepts `runInBackground` |
149
+ | 自动转换路径 | the default for the per-call `translatePaths` |
150
+ | 默认跟随会话工作区 | start in the session's directory instead of `~` when `workdir` is omitted |
151
+ | 危险命令守卫 | whether a destructive command needs an explicit `allowDangerous` |
152
+
153
+ The panel edits the plugin's own configuration, so the same values can be written
154
+ by hand (`- id: tool-wsl` with `config:` in a profile patch) or by environment
155
+ variables. `lib/config.js` owns the precedence — **plugin configuration, then the
156
+ environment, then the built-in defaults** — and a switch left at its default lets
157
+ the layer below decide, which is why `DSH_WSL_WORKDIR=session` keeps working for
158
+ someone who never opened the panel.
159
+
160
+ **A change takes effect at the next DSH start**: the host reads this configuration
161
+ once per mount, and the panel says so. Distro and timeout are values rather than
162
+ features — set them in the patch or with `DSH_WSL_DISTRO` / `DSH_WSL_TIMEOUT_MS`,
163
+ and the panel shows what is currently in effect.
164
+
165
+ The settings surface needs `@deepseek-ai/schemastery`, which the plugin declares as
166
+ an optional peer dependency: without it the three tools still run on their defaults
167
+ and only the panel is missing.
168
+
169
+ ## Optional: a WSL terminal in the sidebar
170
+
171
+ The desktop app's sidebar terminal can open WSL instead of a Windows shell. It is
172
+ **opt-in** — installing this plugin does not change which shell your terminals
173
+ open — and the patch that enables it ships in the package as
174
+ [`extras/terminal-wsl.patch.yml`](extras/terminal-wsl.patch.yml).
175
+
176
+ To turn it on, copy that entry into your profile's own patch layer
177
+ (`$DSH_HOME/profiles/<profile>/cordis.patch.yml`); a CLI launch can instead pass
178
+ `--patch <path to the installed file>`. It applies at the next app start.
144
179
 
145
180
  ```yaml
146
181
  - id: terminal-controller
@@ -151,26 +186,19 @@ next to them.
151
186
  args: ['-e', 'bash', '-l']
152
187
  ```
153
188
 
189
+ - The sidebar's 新建终端 list is built from the composed `terminal-controller`
190
+ row: it lists the configured `shell` first and keeps the shells it discovers
191
+ (`powershell`, `cmd`, `bash`, …) selectable. The picker is core UI, so overriding
192
+ that row is the supported way in — which is also why this cannot be a plain
193
+ plugin entry.
154
194
  - No distribution is pinned, so `wsl.exe` follows the system default — the same
155
- rule the tools use when `DSH_WSL_DISTRO` is unset.
195
+ rule the tools use when `DSH_WSL_DISTRO` is unset. Add `-d <name>` to `args` to
196
+ pin one.
156
197
  - The session workspace is a Windows path that `wsl.exe` translates, so the
157
198
  terminal opens in `/mnt/<drive>/…` exactly like the Windows shells do; and it is
158
199
  a real PTY (`xterm-256color`), so full-screen programs work.
159
- - **Pinning a distribution, or choosing a different default, is a later patch
160
- layer** — a profile's own `cordis.patch.yml` wins over this bundle:
161
-
162
- ```yaml
163
- - id: terminal-controller
164
- config:
165
- shell:
166
- path: 'C:\Windows\System32\wsl.exe'
167
- name: WSL
168
- args: ['-d', 'Ubuntu-22.04', '-e', 'bash', '-l']
169
- ```
170
-
171
- An id-targeted patch replaces that row's whole config, so restate anything else
172
- you had set on it.
173
- - Applies at the next app start.
200
+ - An id-targeted patch replaces that row's whole config, so restate anything else
201
+ you had set on it (terminal limits, scrollback, a custom `shellCandidates` list).
174
202
 
175
203
  ## `wsl` parameters
176
204
 
package/README.zh-CN.md CHANGED
@@ -116,11 +116,39 @@ DSH_SUBPROCESS_LOCAL=/path/to/dsh/node_modules npm run test:real
116
116
  `job_output`/`job_kill` 读回它的依据,也是其他会话读不到它的围栏;exec 里没有 agent 时
117
117
  任务则是无主的。
118
118
 
119
- ## 在侧边栏开一个 WSL 终端
119
+ ## 左侧栏的 WSL 面板
120
120
 
121
- 装了这个插件,桌面版侧边栏终端就多出一个 **WSL** shell:插件的 `cordis.patch.yml` 覆盖组合里的
122
- `terminal-controller` 行,而该行配置的 shell 会排在**第一位**并成为默认;它由 `shellCandidates`
123
- 发现的 `powershell`/`cmd`/`bash` 等仍然可选,所以「新建终端」里 WSL 与它们并列。
121
+ 插件带了一个小的客户端半:桌面版左侧栏里多一个 **WSL** 入口,面板里每个功能一个开关,每个开关
122
+ 配一行说明。
123
+
124
+ | 开关 | 管什么 |
125
+ |---|---|
126
+ | `wsl` 命令执行 | 是否注册 `wsl` 工具 |
127
+ | `wsl-path` 路径转换 | 是否注册 `wsl-path` 工具 |
128
+ | `wsl-env` 能力体检 | 是否注册 `wsl-env` 工具 |
129
+ | 后台任务 | `wsl` 是否接受 `runInBackground` |
130
+ | 自动转换路径 | 每次调用的 `translatePaths` 默认值 |
131
+ | 默认跟随会话工作区 | 未传 `workdir` 时从会话目录开始,而不是 `~` |
132
+ | 危险命令守卫 | 危险命令是否必须显式 `allowDangerous` |
133
+
134
+ 面板改的是插件自己的配置,所以同样的值也可以手写进 profile patch(`- id: tool-wsl` 加 `config:`)
135
+ 或用环境变量设。优先级由 `lib/config.js` 定:**插件配置 > 环境变量 > 内置默认值**;开关停在默认值时
136
+ 下层说了算 —— 这正是"从没打开过面板的人,`DSH_WSL_WORKDIR=session` 依然生效"的原因。
137
+
138
+ **改动在下次启动 DSH 后生效**:宿主每次挂载只读一次该配置,面板里也写着这句。发行版与超时属于"值"
139
+ 而不是"功能":在 patch 里或用 `DSH_WSL_DISTRO` / `DSH_WSL_TIMEOUT_MS` 设置,面板只显示当前生效值。
140
+
141
+ 这个设置界面需要 `@deepseek-ai/schemastery`(插件把它声明为可选 peer 依赖):没有它三个工具照常按
142
+ 默认值工作,只是没有面板。
143
+
144
+ ## 可选:在侧边栏开一个 WSL 终端
145
+
146
+ 桌面版侧边栏终端可以开 WSL 而不是 Windows shell。这是**可选**的 —— 装插件**不会**改你终端默认
147
+ 开什么 —— 启用用的 patch 随包提供:[`extras/terminal-wsl.patch.yml`](extras/terminal-wsl.patch.yml)。
148
+
149
+ 启用方式:把里面的条目复制进你自己 profile 的 patch 层
150
+ (`$DSH_HOME/profiles/<profile>/cordis.patch.yml`);命令行启动也可以改成
151
+ `--patch <已安装文件路径>`。**下次启动应用时生效。**
124
152
 
125
153
  ```yaml
126
154
  - id: terminal-controller
@@ -131,23 +159,15 @@ DSH_SUBPROCESS_LOCAL=/path/to/dsh/node_modules npm run test:real
131
159
  args: ['-e', 'bash', '-l']
132
160
  ```
133
161
 
134
- - **不锁定发行版**:`wsl.exe` 跟随系统默认,与工具在未设 `DSH_WSL_DISTRO` 时的规则一致。
162
+ - 「新建终端」的列表来自组合里的 `terminal-controller` 行:它把配置的 `shell` 排在最前,同时保留
163
+ 它发现的 `powershell`/`cmd`/`bash` 等可选。那个选择列表属于核心 UI,所以**按 id 覆盖该行**是
164
+ 受支持的入口 —— 这也是它没法做成普通插件条目的原因。
165
+ - **不锁定发行版**:`wsl.exe` 跟随系统默认,与工具在未设 `DSH_WSL_DISTRO` 时的规则一致。要锁定就
166
+ 在 `args` 里加 `-d <名字>`。
135
167
  - 会话工作区是 Windows 路径,`wsl.exe` 会自动翻译,因此终端像 Windows shell 一样落在
136
168
  `/mnt/<盘>/…`;而且是**真 PTY**(`xterm-256color`),全屏程序可用。
137
- - **要锁定发行版或换默认 shell**,在更靠后的 patch 层里重述该行即可 —— profile 自己的
138
- `cordis.patch.yml` 优先于本组合包:
139
-
140
- ```yaml
141
- - id: terminal-controller
142
- config:
143
- shell:
144
- path: 'C:\Windows\System32\wsl.exe'
145
- name: WSL
146
- args: ['-d', 'Ubuntu-22.04', '-e', 'bash', '-l']
147
- ```
148
-
149
- 按 id 的 patch 会**整段替换**该行 config,其他设置要一并重述。
150
- - **下次启动应用时生效。**
169
+ - 按 id 的 patch 会**整段替换**该行 config,你之前在该行上设过的东西(终端上限、scrollback、
170
+ 自定义 `shellCandidates`)要一并重述。
151
171
 
152
172
  ## `wsl` 参数
153
173
 
package/cordis.patch.yml CHANGED
@@ -6,6 +6,10 @@
6
6
  # `tool "wsl" is already registered in this scope`. To keep the tools out of a
7
7
  # composition, override this row by id in a later patch layer (`- id: tool-wsl`
8
8
  # with `disabled: true`) instead of duplicating it.
9
+ #
10
+ # This file deliberately changes nothing else: installing a tool plugin must not
11
+ # reshape someone's session. Pointing the desktop app's sidebar terminal at WSL is
12
+ # offered as an opt-in instead — see `extras/terminal-wsl.patch.yml`.
9
13
 
10
14
  # The entry name is PATH-RELATIVE on purpose. DSH anchors a relative `name:` to
11
15
  # the directory of the patch file that declared it (`anchorInsertedPluginNames`),
@@ -19,35 +23,3 @@
19
23
  - insert:
20
24
  - id: tool-wsl
21
25
  name: './index.js'
22
-
23
- # ── Sidebar terminal: a WSL shell, listed first and selected by default ────────
24
- #
25
- # The desktop app's 新建终端 list is built from the composed `terminal-controller`
26
- # row: it discovers shells from `shellCandidates` and puts the configured `shell`
27
- # first. Overriding that row here is what makes installing this plugin also give
28
- # you a Linux terminal in the sidebar. (A candidate entry cannot do it: a shell
29
- # discovered by name gets `-i` appended, and `wsl.exe -i` is a hard error.)
30
- #
31
- # The path is absolute because the configured shell must resolve, and a shell that
32
- # cannot resolve makes every new terminal fail rather than just this one: wsl.exe
33
- # ships with Windows at a fixed location, so this resolves even where no
34
- # distribution is installed yet.
35
- #
36
- # No distro is pinned, so `wsl.exe` follows the system default — the same rule the
37
- # tools use when DSH_WSL_DISTRO is unset. Add `-d <name>` to `args` to pin one.
38
- #
39
- # This changes the default terminal shell, which is deliberate for a WSL plugin
40
- # and reversible: a later patch layer wins, so restating the row in a profile's own
41
- # cordis.patch.yml overrides it (and a profile that sets its own `shell` there is
42
- # unaffected by this row).
43
- #
44
- # An id-targeted patch replaces the whole config, so anything the layer below set
45
- # on this row (limits, scrollback, a custom shellCandidates list) falls back to its
46
- # schema default. Only `shell` is set here on purpose. Never add `disabled:` here:
47
- # that would switch off the terminal feature for the user instead of just this row.
48
- - id: terminal-controller
49
- config:
50
- shell:
51
- path: 'C:\Windows\System32\wsl.exe'
52
- name: WSL
53
- args: ['-e', 'bash', '-l']
@@ -0,0 +1,34 @@
1
+ # OPTIONAL — give the desktop app's sidebar terminal a WSL shell.
2
+ #
3
+ # This is deliberately NOT part of the bundle patch: installing a tool plugin must
4
+ # not change which shell someone's terminals open by default. To opt in, copy the
5
+ # entry below into the profile's own patch layer
6
+ # (`$DSH_HOME/profiles/<profile>/cordis.patch.yml`), or hand this file to a CLI
7
+ # launch with `--patch <path to this file>`. It applies at the next app start.
8
+ #
9
+ # Why an id-targeted override: the sidebar's 新建终端 list is built from the
10
+ # composed `terminal-controller` row, which discovers shells from
11
+ # `shellCandidates` and lists the configured `shell` first. A candidate entry
12
+ # cannot do it — a shell found by name gets `-i` appended, and `wsl.exe -i` is a
13
+ # hard error. The picker itself is core UI, so a plugin cannot add an entry to it;
14
+ # overriding the row is the supported way in.
15
+ #
16
+ # The path is absolute because a configured shell that fails to resolve makes every
17
+ # new terminal fail, not just this one, and wsl.exe ships with Windows at a fixed
18
+ # location whether or not a distribution is installed yet.
19
+ #
20
+ # No distribution is pinned, so `wsl.exe` follows the system default — the same
21
+ # rule the tools use when `DSH_WSL_DISTRO` is unset. Add `-d <name>` to `args` to
22
+ # pin one.
23
+ #
24
+ # An id-targeted patch replaces that row's whole config, so anything a layer below
25
+ # set on it (terminal limits, scrollback, a custom `shellCandidates` list) falls
26
+ # back to its schema default; only `shell` is set here on purpose. Never add
27
+ # `disabled:` — that would switch the whole terminal feature off instead of
28
+ # adjusting it.
29
+ - id: terminal-controller
30
+ config:
31
+ shell:
32
+ path: 'C:\Windows\System32\wsl.exe'
33
+ name: WSL
34
+ args: ['-e', 'bash', '-l']
package/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // dsh-wsl: model-facing WSL tools for DeepSeek Harness (DSH).
2
2
  //
3
- // Registers three tools:
3
+ // Registers up to three tools, each one switchable from the plugin's
4
+ // left-sidebar panel:
4
5
  // - `wsl` : run a Linux command through wsl.exe, returning stdout/stderr
5
6
  // with exit-code / signal / timeout / truncation markers.
6
7
  // - `wsl-path` : convert between Windows and WSL paths via `wslpath`.
@@ -11,10 +12,14 @@
11
12
  // plugin publishes nothing and only consumes the host-plane `subprocess` and
12
13
  // `tools` registries, so it sits loose in an agent preset without a realm.
13
14
  //
14
- // The implementation lives in `lib/`: `config` (defaults and environment
15
- // overrides), `paths` (path translation and shell quoting), `guard` (the
16
- // destructive-command rules), `result` (markers and truncation), `runner` (the
17
- // one spawn path) and `tools/` (the three tool definitions).
15
+ // The implementation lives in `lib/`: `config` (defaults, environment overrides
16
+ // and the plugin's own switches), `paths` (path translation and shell quoting),
17
+ // `guard` (the destructive-command rules), `result` (markers and truncation),
18
+ // `runner` (the one spawn path), `tools/` (the three tool definitions) and
19
+ // `client.js` (the sidebar panel, a separate web-platform bundle).
20
+ //
21
+ // A configuration change is a mount, not something a running session re-reads:
22
+ // every switch below is consulted once, in `apply`.
18
23
 
19
24
  import { resolveConfig } from './lib/config.js'
20
25
  import { createRunner } from './lib/runner.js'
@@ -25,15 +30,54 @@ import { createWslEnvTool } from './lib/tools/wsl-env.js'
25
30
  export const name = 'tool-wsl'
26
31
  export const inject = ['tools', 'subprocess']
27
32
 
28
- export function apply(ctx) {
29
- // Read the environment once per mount: a configuration change is a mount
30
- // (i.e. a DSH restart), not something a running session re-reads.
31
- const config = resolveConfig()
33
+ // The schema is what gives this plugin a settings surface at all: the platform
34
+ // derives the namespace the sidebar panel writes to from the entry's id plus this
35
+ // schema. It needs `@deepseek-ai/schemastery`, which a real DSH profile supplies
36
+ // (the plugin declares it as an optional peer dependency) but which is absent when
37
+ // this module is imported standalone, as `test/smoke.mjs` does. A soft import
38
+ // keeps the tools runnable there: without a schema every switch keeps its default
39
+ // and only the panel is missing.
40
+ let Schema = null
41
+ try {
42
+ ({ Schema } = await import('@deepseek-ai/schemastery'))
43
+ } catch {
44
+ Schema = null
45
+ }
46
+
47
+ /**
48
+ * The plugin's own configuration.
49
+ *
50
+ * Every field is ALSO settable by hand in a profile patch
51
+ * (`- id: tool-wsl` / `config:`) and by the environment, and `lib/config.js`
52
+ * documents the precedence: this configuration wins, the environment is the
53
+ * deployment default, the built-in defaults are last.
54
+ *
55
+ * `distro` and `timeoutMs` are deliberately absent from the sidebar panel (they
56
+ * are values, not features) but stay here so a patch or a panel could grow a
57
+ * field for them without a schema change.
58
+ */
59
+ export const Config = Schema?.object({
60
+ tools: Schema.object({
61
+ wsl: Schema.boolean().default(true).description('注册 `wsl` 工具:在 WSL 里执行 Linux 命令。'),
62
+ path: Schema.boolean().default(true).description('注册 `wsl-path` 工具:Windows 路径与 /mnt/... 互转。'),
63
+ env: Schema.boolean().default(true).description('注册 `wsl-env` 工具:汇总 WSL 环境能力。'),
64
+ }).description('要注册哪几个工具。'),
65
+ backgroundJobs: Schema.boolean().default(true).description('允许 `runInBackground`,由内置 job 工具读回结果。'),
66
+ translatePaths: Schema.boolean().default(true).description('默认把命令里的 Windows 路径转成 /mnt/...。'),
67
+ startInSessionWorkspace: Schema.boolean().default(false).description('未传 `workdir` 时从会话工作区开始,而不是 Linux 家目录。'),
68
+ dangerGuard: Schema.boolean().default(true).description('危险命令必须显式 `allowDangerous` 才放行。关掉后模型可直接删除/分区。'),
69
+ distro: Schema.string().default('').description('要固定使用的发行版;留空则用系统默认(也可用 DSH_WSL_DISTRO)。'),
70
+ timeoutMs: Schema.number().default(0).description('默认命令超时毫秒数;0 表示用内置默认(也可用 DSH_WSL_TIMEOUT_MS)。'),
71
+ }).description('dsh-wsl 的功能开关与默认值。')
72
+
73
+ export function apply(ctx, settings = {}) {
74
+ // Resolved once per mount: a settings change is a restart, not a live edit.
75
+ const config = resolveConfig(process.env, settings)
32
76
  const runner = createRunner(ctx, config)
33
- // `ctx` is passed for the optional `jobs` service only (background commands);
34
- // it is read with ctx.get at call time, never injected, so a preset without
35
- // tool-jobs still mounts this plugin.
36
- ctx.tools.register(createWslTool({ ctx, config, runner }))
37
- ctx.tools.register(createWslPathTool({ config, runner }))
38
- ctx.tools.register(createWslEnvTool({ config, runner }))
77
+ // `ctx` is passed to the `wsl` tool for the optional `jobs` service only
78
+ // (background commands); it is read with ctx.get at call time, never injected,
79
+ // so a preset without tool-jobs still mounts this plugin.
80
+ if (config.tools.wsl) ctx.tools.register(createWslTool({ ctx, config, runner }))
81
+ if (config.tools.path) ctx.tools.register(createWslPathTool({ config, runner }))
82
+ if (config.tools.env) ctx.tools.register(createWslEnvTool({ config, runner }))
39
83
  }
package/lib/client.js ADDED
@@ -0,0 +1,691 @@
1
+ // dsh-wsl-tool, browser half.
2
+ //
3
+ // This file is a PLAIN hand-written bundle, exactly like the ones DSH ships: no
4
+ // build step, no `import`/`export` keywords, no JSX, no TypeScript. The page
5
+ // loads it as a script, the module loader below hands it a `require`, and the
6
+ // only two modules it may ask for are React and the client UI primitives.
7
+ //
8
+ // What it contributes:
9
+ // - one rail entry in the left sidebar (`sidebar.panellist`, a list seat), and
10
+ // - the panel behind that entry (`main`, a keyed seat under the same id),
11
+ // whose switches edit this plugin's own Host configuration row.
12
+ //
13
+ // The Host half reads its settings ONCE per mount (see `lib/config.js`), so a
14
+ // switch here changes the documented file/profile configuration; it does not
15
+ // reconfigure a running session. The panel says so in one line instead of
16
+ // pretending otherwise.
17
+ //
18
+ // Layout facts this file depends on (all verified against the shipped client):
19
+ // - `sidebar.panellist` entries carry `id`, `order` and `label`, and render
20
+ // their own icon (there is no icon field).
21
+ // - `main` is keyed by that same id, and its `inject` callback supplies extra
22
+ // props to the panel component.
23
+ // - `ctx.configForms.get(rowId)` returns a scope with
24
+ // `getSnapshot()` / `subscribe(listener)` / `mutate(ops, revision)`, where a
25
+ // snapshot is `{ status, value, base, user, writable, revision }` and one op
26
+ // is `{ op: 'set', path, value }` or `{ op: 'unset', path }`.
27
+
28
+ window.__ModuleLoader__.load({
29
+ id: 'dsh-wsl-tool',
30
+ factory: (require) => {
31
+ var module = { exports: {} }
32
+ var exports = module.exports
33
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
34
+
35
+ const React = require('react')
36
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
37
+
38
+ /**
39
+ * Panel identity. The layout pairs one `sidebar.panellist` entry with the
40
+ * `main` entry registered under the same key, so these two must agree; the
41
+ * rail entry's `label` is the visible text, the accessible name and the
42
+ * collapsed tooltip.
43
+ */
44
+ const PANEL_ID = 'dsh-wsl'
45
+
46
+ /**
47
+ * Loader row id of this bundle's Host half, read from our own
48
+ * `cordis.patch.yml`. `configForms.get` addresses a Host configuration entry
49
+ * by that row id — not by the package name, because one package may be
50
+ * composed as several rows under different ids.
51
+ */
52
+ const CONFIG_ROW_ID = 'tool-wsl'
53
+
54
+ // Theme tokens, read as CSS custom properties so the panel follows the
55
+ // active light/dark theme without importing any stylesheet. Each carries a
56
+ // neutral fallback: an unknown token must not make text invisible.
57
+ const LABEL_PRIMARY = 'var(--dsw-alias-label-primary, inherit)'
58
+ const LABEL_SECONDARY = 'var(--dsw-alias-label-secondary, inherit)'
59
+ const LABEL_TERTIARY = 'var(--dsw-alias-label-tertiary, inherit)'
60
+ const BORDER_SOFT = 'var(--dsw-alias-border-l2, rgba(127, 127, 127, 0.25))'
61
+ const BORDER_BADGE = 'var(--dsw-alias-border-l3, rgba(127, 127, 127, 0.35))'
62
+ const WARN_LABEL = 'var(--dsw-alias-state-warn-label, #b45309)'
63
+ const LINK_LABEL = 'var(--dsw-alias-link, inherit)'
64
+ const CODE_FILL = 'var(--dsw-alias-markdown-code-block, rgba(127, 127, 127, 0.12))'
65
+ const CODE_LABEL = 'var(--dsw-alias-markdown-inline-code, inherit)'
66
+ const RADIUS = 'var(--dsw-radius-xs, 4px)'
67
+
68
+ /**
69
+ * The switches, grouped into the two sections the panel renders.
70
+ *
71
+ * `path` - path inside this plugin's configuration object (settings ops are
72
+ * path-addressed, so a nested section is one array, not a string).
73
+ * `label` - visible row text; also the switch's accessible name.
74
+ * `hint` - the one-line explanation under the label.
75
+ * `when` - the composed default for the row, used ONLY when the resolved
76
+ * value does not carry the field at all (an older Host half, or a
77
+ * field the Host schema does not declare). Reading a missing field
78
+ * as `false` would silently mislabel a default-on feature.
79
+ * `danger` - marks a switch whose off state widens what the model may run.
80
+ */
81
+ const SECTIONS = [
82
+ {
83
+ id: 'tools',
84
+ title: '工具',
85
+ rows: [
86
+ {
87
+ path: ['tools', 'wsl'],
88
+ label: 'wsl 命令执行',
89
+ hint: '注册 `wsl` 工具:让模型在 WSL 发行版里执行 Linux 命令(一次一个全新 shell)',
90
+ when: true,
91
+ },
92
+ {
93
+ path: ['tools', 'path'],
94
+ label: 'wsl-path 路径转换',
95
+ hint: '注册 `wsl-path` 工具:在 Windows 路径与 `/mnt/...` 之间互转',
96
+ when: true,
97
+ },
98
+ {
99
+ path: ['tools', 'env'],
100
+ label: 'wsl-env 能力体检',
101
+ hint: '注册 `wsl-env` 工具:汇总发行版、内核、systemd、cgroup、GPU 直通、docker、挂载盘与两侧配置',
102
+ when: true,
103
+ },
104
+ ],
105
+ },
106
+ {
107
+ id: 'behavior',
108
+ title: '行为',
109
+ rows: [
110
+ {
111
+ path: ['backgroundJobs'],
112
+ label: '后台任务',
113
+ hint: '允许长任务用 `runInBackground` 后台执行,再由内置 job 工具读回结果',
114
+ when: true,
115
+ },
116
+ {
117
+ path: ['translatePaths'],
118
+ label: '自动转换路径',
119
+ hint: '命令里的 Windows 路径自动转成 `/mnt/...`(每次调用仍可用 `translatePaths` 覆盖)',
120
+ when: true,
121
+ },
122
+ {
123
+ path: ['startInSessionWorkspace'],
124
+ label: '默认跟随会话工作区',
125
+ hint: '未传 `workdir` 时从会话所在目录开始(关掉则从 Linux 家目录 `~` 开始)',
126
+ when: false,
127
+ },
128
+ {
129
+ path: ['dangerGuard'],
130
+ label: '危险命令守卫',
131
+ hint: '删除、分区、关机等命令必须显式 `allowDangerous` 才放行。关掉后模型可直接执行这类命令,请谨慎',
132
+ when: true,
133
+ danger: true,
134
+ },
135
+ ],
136
+ },
137
+ ]
138
+
139
+ /**
140
+ * The sidebar-terminal snippet, verbatim.
141
+ *
142
+ * It is a String.raw template AND its lines start at column 0 on purpose: it
143
+ * must come out byte-for-byte as the YAML a user pastes into a profile patch,
144
+ * backslashes in the Windows path included. Re-indenting it or writing it with
145
+ * ordinary escapes would quietly corrupt the path.
146
+ */
147
+ const TERMINAL_PATCH_YAML = String.raw`- id: terminal-controller
148
+ config:
149
+ shell:
150
+ path: 'C:\Windows\System32\wsl.exe'
151
+ name: WSL
152
+ args: ['-e', 'bash', '-l']`
153
+
154
+ /** A setting nobody can read yet; shaped like a scope snapshot so the panel
155
+ * can treat it uniformly. `status: 'unavailable'` is the platform's own word
156
+ * for "this Host does not serve that configuration entry". */
157
+ const UNAVAILABLE_SNAPSHOT = {
158
+ status: 'unavailable',
159
+ value: undefined,
160
+ base: undefined,
161
+ user: undefined,
162
+ writable: false,
163
+ revision: undefined,
164
+ }
165
+
166
+ // ---------------------------------------------------------------- helpers
167
+
168
+ /** Read one path out of a possibly absent object, never throwing. */
169
+ function readPath(target, path) {
170
+ let cursor = target
171
+ for (const step of path) {
172
+ if (cursor === null || typeof cursor !== 'object') return undefined
173
+ cursor = cursor[step]
174
+ }
175
+ return cursor
176
+ }
177
+
178
+ /**
179
+ * Whether the USER layer itself carries this path — i.e. whether the value is
180
+ * an override rather than inherited from the composition or the schema
181
+ * default. Presence is what marks an override, not a value comparison: an
182
+ * override that happens to equal the default is still an override.
183
+ */
184
+ function hasPath(target, path) {
185
+ let cursor = target
186
+ for (const step of path) {
187
+ if (cursor === null || typeof cursor !== 'object') return false
188
+ if (!Object.prototype.hasOwnProperty.call(cursor, step)) return false
189
+ cursor = cursor[step]
190
+ }
191
+ return true
192
+ }
193
+
194
+ /**
195
+ * Everything this panel knows about the Host configuration.
196
+ *
197
+ * Both the service and the row are optional, so every read is tolerant:
198
+ * a missing `configForms`, a row this Host does not serve, and a scope whose
199
+ * `getSnapshot` throws all end at `unavailable` instead of an exception in
200
+ * the middle of a render.
201
+ */
202
+ function createConfigModel() {
203
+ let served = false
204
+ let scope
205
+ let unsubscribe
206
+ const listeners = new Set()
207
+
208
+ const publish = () => {
209
+ // Copy first: a listener may unsubscribe itself while being notified.
210
+ for (const listener of Array.from(listeners)) {
211
+ try {
212
+ listener()
213
+ } catch (error) {
214
+ console.error('dsh-wsl-tool: a settings panel listener failed', error)
215
+ }
216
+ }
217
+ }
218
+
219
+ const read = () => {
220
+ if (scope === undefined || scope === null || typeof scope.getSnapshot !== 'function') {
221
+ return UNAVAILABLE_SNAPSHOT
222
+ }
223
+ try {
224
+ const snapshot = scope.getSnapshot()
225
+ return snapshot !== null && typeof snapshot === 'object' ? snapshot : UNAVAILABLE_SNAPSHOT
226
+ } catch (error) {
227
+ console.error('dsh-wsl-tool: reading the configuration scope failed', error)
228
+ return UNAVAILABLE_SNAPSHOT
229
+ }
230
+ }
231
+
232
+ return {
233
+ /** Whether the `configForms` service was there at all. A service this DSH
234
+ * does not ship reads differently from a row it does not serve. */
235
+ hasService: () => served,
236
+ /** Bind the scope once the service appears. */
237
+ attach: (next) => {
238
+ if (next === undefined || next === null) return
239
+ served = true
240
+ scope = next
241
+ if (typeof scope.subscribe === 'function') {
242
+ try {
243
+ unsubscribe = scope.subscribe(publish)
244
+ } catch (error) {
245
+ console.error('dsh-wsl-tool: subscribing to the configuration scope failed', error)
246
+ }
247
+ }
248
+ publish()
249
+ },
250
+ /** Release the scope subscription; the caller's effect owns the call. */
251
+ detach: () => {
252
+ if (typeof unsubscribe === 'function') {
253
+ try {
254
+ unsubscribe()
255
+ } catch (error) {
256
+ console.error('dsh-wsl-tool: releasing the configuration scope failed', error)
257
+ }
258
+ }
259
+ unsubscribe = undefined
260
+ },
261
+ read,
262
+ subscribe: (listener) => {
263
+ listeners.add(listener)
264
+ return () => {
265
+ listeners.delete(listener)
266
+ }
267
+ },
268
+ /**
269
+ * Submit exactly one path-addressed write against the revision read just
270
+ * now, and answer whether the Host accepted it.
271
+ *
272
+ * The revision matters: the settings service refuses a write that was
273
+ * composed against a value somebody else has already replaced, rather than
274
+ * silently overwriting it. Its own settings form treats any falsy answer as
275
+ * a refusal, so this panel does the same — `false` means "not saved", and
276
+ * the panel words it instead of pretending the switch moved.
277
+ */
278
+ write: async (op) => {
279
+ const snapshot = read()
280
+ if (scope === undefined || typeof scope.mutate !== 'function') return false
281
+ if (snapshot.writable !== true) return false
282
+ try {
283
+ const landed = await scope.mutate([op], snapshot.revision)
284
+ return Boolean(landed)
285
+ } catch (error) {
286
+ console.error('dsh-wsl-tool: writing the configuration failed', error)
287
+ return false
288
+ }
289
+ },
290
+ }
291
+ }
292
+
293
+ /** Stand-in model for a seat that somehow renders without injected props, so
294
+ * a missing prop shows the "not served" note instead of throwing. */
295
+ const DETACHED_MODEL = createConfigModel()
296
+
297
+ // ----------------------------------------------------------------- styles
298
+
299
+ const PANEL_STYLE = {
300
+ boxSizing: 'border-box',
301
+ display: 'flex',
302
+ flexDirection: 'column',
303
+ gap: '24px',
304
+ height: '100%',
305
+ maxWidth: '760px',
306
+ overflowY: 'auto',
307
+ padding: '24px',
308
+ color: LABEL_PRIMARY,
309
+ fontFamily: 'inherit',
310
+ fontSize: '13px',
311
+ lineHeight: '20px',
312
+ }
313
+ const HEADER_STYLE = { display: 'flex', flexDirection: 'column', gap: '4px' }
314
+ const TITLE_STYLE = { margin: 0, fontSize: '18px', fontWeight: 600, lineHeight: '26px' }
315
+ const DESCRIPTION_STYLE = { margin: 0, color: LABEL_SECONDARY }
316
+ const NOTE_STYLE = { margin: 0, color: LABEL_TERTIARY, fontSize: '12px', lineHeight: '18px' }
317
+ const STACK_STYLE = { display: 'flex', flexDirection: 'column', gap: '24px' }
318
+ const SECTION_STYLE = { display: 'flex', flexDirection: 'column', gap: '4px' }
319
+ const SECTION_TITLE_STYLE = { margin: 0, fontSize: '14px', fontWeight: 500, lineHeight: '20px' }
320
+ const ROWS_STYLE = { display: 'flex', flexDirection: 'column' }
321
+ const ROW_STYLE = {
322
+ display: 'flex',
323
+ alignItems: 'flex-start',
324
+ justifyContent: 'space-between',
325
+ gap: '16px',
326
+ padding: '14px 0',
327
+ borderBottom: '0.5px solid ' + BORDER_SOFT,
328
+ }
329
+ const ROW_COPY_STYLE = { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 }
330
+ const ROW_LABEL_STYLE = { display: 'flex', alignItems: 'center', gap: '6px', fontWeight: 500 }
331
+ const ROW_ACTIONS_STYLE = { display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }
332
+ const HINT_STYLE = { margin: 0, color: LABEL_TERTIARY, fontSize: '12px', lineHeight: '18px', maxWidth: '66ch' }
333
+ const BADGE_STYLE = {
334
+ border: '0.5px solid ' + BORDER_BADGE,
335
+ borderRadius: RADIUS,
336
+ color: LABEL_SECONDARY,
337
+ fontSize: '11px',
338
+ lineHeight: '16px',
339
+ padding: '1px 6px',
340
+ whiteSpace: 'nowrap',
341
+ }
342
+ const DANGER_STYLE = {
343
+ border: '0.5px solid currentColor',
344
+ borderRadius: RADIUS,
345
+ color: WARN_LABEL,
346
+ fontSize: '11px',
347
+ lineHeight: '16px',
348
+ padding: '0 4px',
349
+ whiteSpace: 'nowrap',
350
+ }
351
+ const RESET_STYLE = {
352
+ background: 'none',
353
+ border: 'none',
354
+ color: LINK_LABEL,
355
+ cursor: 'pointer',
356
+ fontFamily: 'inherit',
357
+ fontSize: '12px',
358
+ fontWeight: 'inherit',
359
+ padding: 0,
360
+ textDecoration: 'underline',
361
+ }
362
+ const DEFINITION_ROW_STYLE = { display: 'flex', alignItems: 'baseline', gap: '12px' }
363
+ const DEFINITION_LABEL_STYLE = { color: LABEL_SECONDARY, minWidth: '13em' }
364
+ const DEFINITION_VALUE_STYLE = { color: LABEL_PRIMARY }
365
+ const PRE_STYLE = {
366
+ margin: 0,
367
+ padding: '12px',
368
+ background: CODE_FILL,
369
+ borderRadius: RADIUS,
370
+ color: CODE_LABEL,
371
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
372
+ fontSize: '12px',
373
+ lineHeight: '18px',
374
+ overflowX: 'auto',
375
+ whiteSpace: 'pre',
376
+ }
377
+
378
+ // ------------------------------------------------------------ components
379
+
380
+ /**
381
+ * The rail icon. A `sidebar.panellist` entry draws its own glyph: an 18×18
382
+ * terminal prompt in `currentColor`, so the layout's selected/hover colours
383
+ * come through untouched.
384
+ */
385
+ function WslIcon() {
386
+ return React.createElement(
387
+ 'svg',
388
+ {
389
+ width: 18,
390
+ height: 18,
391
+ viewBox: '0 0 20 20',
392
+ fill: 'none',
393
+ stroke: 'currentColor',
394
+ strokeWidth: 1.5,
395
+ strokeLinecap: 'round',
396
+ strokeLinejoin: 'round',
397
+ 'aria-hidden': 'true',
398
+ focusable: 'false',
399
+ },
400
+ // The terminal frame, the prompt chevron, and the command line.
401
+ React.createElement('path', {
402
+ d: 'M3.5 4.5h13a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1Z',
403
+ }),
404
+ React.createElement('path', { d: 'M6.5 8.5 8.5 10.5 6.5 12.5' }),
405
+ React.createElement('path', { d: 'M10.5 13h3' }),
406
+ )
407
+ }
408
+
409
+ /** One read-only label/value line, used by the effective-defaults section. */
410
+ function definitionRow(label, value) {
411
+ return React.createElement(
412
+ 'div',
413
+ { style: DEFINITION_ROW_STYLE },
414
+ React.createElement('span', { style: DEFINITION_LABEL_STYLE }, label),
415
+ React.createElement('span', { style: DEFINITION_VALUE_STYLE }, value),
416
+ )
417
+ }
418
+
419
+ /** A section frame: a heading plus its body, which may be several nodes. */
420
+ function section(key, title, ...children) {
421
+ return React.createElement(
422
+ 'section',
423
+ { key, style: SECTION_STYLE },
424
+ React.createElement('h3', { style: SECTION_TITLE_STYLE }, title),
425
+ ...children,
426
+ )
427
+ }
428
+
429
+ /**
430
+ * The panel behind the rail entry.
431
+ *
432
+ * It renders whatever the scope currently says — `ready`, still `loading`, or
433
+ * `unavailable` — because a settings panel that throws or renders nothing on a
434
+ * stripped composition is worse than one that explains itself. Reading goes
435
+ * through the injected model so a service that appears later needs no remount.
436
+ */
437
+ function WslPanel(props) {
438
+ const model = props !== null && props !== undefined && props.model !== undefined ? props.model : DETACHED_MODEL
439
+ const [snapshot, setSnapshot] = React.useState(model.read())
440
+ // `busy` is the path key of the write in flight; `refused` remembers that the
441
+ // Host did not accept the last one, so the panel can say so.
442
+ const [busy, setBusy] = React.useState(undefined)
443
+ const [refused, setRefused] = React.useState(false)
444
+
445
+ React.useEffect(
446
+ () =>
447
+ model.subscribe(() => {
448
+ setSnapshot(model.read())
449
+ }),
450
+ [model],
451
+ )
452
+
453
+ /**
454
+ * Send one write and reflect its outcome. The promise is returned so a
455
+ * caller (or a test) can await the settled state; React ignores the return
456
+ * value of an event handler, and `model.write` never rejects.
457
+ */
458
+ const submit = (op, key) => {
459
+ setBusy(key)
460
+ setRefused(false)
461
+ return model.write(op).then((landed) => {
462
+ setBusy(undefined)
463
+ setRefused(!landed)
464
+ setSnapshot(model.read())
465
+ return landed
466
+ })
467
+ }
468
+
469
+ /** One switch row: switch, label and hint on the left, override controls and
470
+ * the switch itself on the right. */
471
+ const rowElement = (row) => {
472
+ const key = row.path.join('.')
473
+ const overridden = hasPath(snapshot.user, row.path)
474
+ const disabled = snapshot.writable !== true || busy !== undefined
475
+ const effective = readPath(snapshot.value, row.path)
476
+ const checked = effective === undefined ? row.when : effective === true
477
+ return React.createElement(
478
+ 'div',
479
+ { key, style: ROW_STYLE },
480
+ React.createElement(
481
+ 'div',
482
+ { style: ROW_COPY_STYLE },
483
+ React.createElement(
484
+ 'div',
485
+ { style: ROW_LABEL_STYLE },
486
+ row.label,
487
+ row.danger === true ? React.createElement('span', { style: DANGER_STYLE }, '有风险') : null,
488
+ ),
489
+ React.createElement('p', { style: HINT_STYLE }, row.hint),
490
+ ),
491
+ React.createElement(
492
+ 'div',
493
+ { style: ROW_ACTIONS_STYLE },
494
+ overridden ? React.createElement('span', { style: BADGE_STYLE }, '已覆盖') : null,
495
+ overridden
496
+ ? React.createElement(
497
+ 'button',
498
+ {
499
+ type: 'button',
500
+ style: RESET_STYLE,
501
+ disabled,
502
+ // Returned as well as performed: React ignores a handler's
503
+ // return value, but awaiting it is how a test sees the write
504
+ // settle.
505
+ onClick: () => submit({ op: 'unset', path: row.path }, key),
506
+ },
507
+ '恢复默认',
508
+ )
509
+ : null,
510
+ React.createElement(primitives.Switch, {
511
+ checked,
512
+ label: row.label,
513
+ disabled,
514
+ ...(row.danger === true
515
+ ? { title: '有风险:关掉后模型可以直接执行删除、分区、关机等命令' }
516
+ : {}),
517
+ onChange: (next) => submit({ op: 'set', path: row.path, value: next === true }, key),
518
+ }),
519
+ ),
520
+ )
521
+ }
522
+
523
+ const children = []
524
+
525
+ children.push(
526
+ React.createElement(
527
+ 'header',
528
+ { key: 'header', style: HEADER_STYLE },
529
+ React.createElement('h2', { style: TITLE_STYLE }, 'WSL'),
530
+ React.createElement('p', { style: DESCRIPTION_STYLE }, '通过 WSL 在 Windows 上执行 Linux 命令的开关与说明'),
531
+ React.createElement('p', { style: NOTE_STYLE }, '改动会在下次启动 DSH 后生效'),
532
+ ),
533
+ )
534
+
535
+ // The switches — or the one reason they cannot be shown right now.
536
+ if (model.hasService() !== true) {
537
+ children.push(
538
+ React.createElement(
539
+ 'p',
540
+ { key: 'note-service', style: NOTE_STYLE },
541
+ '此 DSH 未提供配置表单服务(configForms),无法在这里调整设置。',
542
+ ),
543
+ )
544
+ } else if (snapshot.status === 'loading') {
545
+ children.push(React.createElement('p', { key: 'note-loading', style: NOTE_STYLE }, '正在读取配置…'))
546
+ } else if (snapshot.status !== 'ready') {
547
+ children.push(
548
+ React.createElement('p', { key: 'note-unavailable', style: NOTE_STYLE }, '此 DSH 未提供该插件的配置作用域'),
549
+ )
550
+ } else {
551
+ const blocks = SECTIONS.map((group) =>
552
+ section(group.id, group.title, React.createElement('div', { style: ROWS_STYLE }, group.rows.map(rowElement))),
553
+ )
554
+ if (refused) {
555
+ blocks.unshift(
556
+ React.createElement('p', { key: 'note-refused', style: NOTE_STYLE }, '保存被拒绝:配置没有被写入,请重试'),
557
+ )
558
+ }
559
+ children.push(React.createElement('div', { key: 'switches', style: STACK_STYLE }, blocks))
560
+ }
561
+
562
+ // The effective values of the two knobs that have no switch, because they
563
+ // are a distro name and a duration rather than a boolean.
564
+ if (snapshot.status === 'ready') {
565
+ const distro = readPath(snapshot.value, ['distro'])
566
+ const timeoutMs = readPath(snapshot.value, ['timeoutMs'])
567
+ children.push(
568
+ section(
569
+ 'defaults',
570
+ '当前默认值',
571
+ React.createElement(
572
+ 'div',
573
+ null,
574
+ definitionRow(
575
+ '发行版 distro',
576
+ typeof distro === 'string' && distro.trim() !== '' ? distro : '系统默认发行版',
577
+ ),
578
+ definitionRow(
579
+ '命令超时 timeoutMs',
580
+ typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) ? timeoutMs + ' 毫秒' : '—',
581
+ ),
582
+ ),
583
+ React.createElement(
584
+ 'p',
585
+ { style: HINT_STYLE },
586
+ '这两个值来自插件配置,可以在 profile 的 cordis.patch.yml 里写,也可以用环境变量 DSH_WSL_DISTRO / DSH_WSL_TIMEOUT_MS 覆盖。',
587
+ ),
588
+ ),
589
+ )
590
+ }
591
+
592
+ // Opt-in, read-only: the snippet goes ABOVE its explanation, which refers to
593
+ // it as 「上面这段」.
594
+ children.push(
595
+ section(
596
+ 'terminal',
597
+ '侧边栏 WSL 终端(可选)',
598
+ React.createElement(
599
+ 'p',
600
+ { style: HINT_STYLE },
601
+ '插件还可以把桌面端的侧边栏终端指向 WSL;这是可选项,默认不开。',
602
+ ),
603
+ React.createElement('pre', { style: PRE_STYLE }, TERMINAL_PATCH_YAML),
604
+ React.createElement(
605
+ 'p',
606
+ { style: HINT_STYLE },
607
+ '把上面这段加进 `$DSH_HOME/profiles/<profile>/cordis.patch.yml` 后重启 DSH,新建终端里就会出现 WSL(详见插件 README)',
608
+ ),
609
+ ),
610
+ )
611
+
612
+ return React.createElement('div', { style: PANEL_STYLE }, children)
613
+ }
614
+
615
+ // ------------------------------------------------------------------ apply
616
+
617
+ /**
618
+ * Mount the rail entry and the panel.
619
+ *
620
+ * This function must never throw: a client half that fails to apply takes its
621
+ * whole bundle's surface down with it, and the WSL tools work fine without any
622
+ * of this. So a missing `slots` service, a missing `configForms`, an unserved
623
+ * configuration row and a scope that refuses to be read all degrade into a note
624
+ * in the panel instead of an exception.
625
+ */
626
+ function apply(ctx) {
627
+ const model = createConfigModel()
628
+
629
+ // `configForms` is reached through `ctx.inject`, never through this module's
630
+ // own `inject` array: the panel is a convenience, not a reason to refuse to
631
+ // mount on a composition that does not ship the settings service.
632
+ try {
633
+ if (typeof ctx.inject === 'function') {
634
+ ctx.inject(['configForms'], (inner) => {
635
+ let scope
636
+ try {
637
+ scope = inner.configForms.get(CONFIG_ROW_ID)
638
+ } catch (error) {
639
+ console.error('dsh-wsl-tool: this DSH serves no "' + CONFIG_ROW_ID + '" configuration row', error)
640
+ return
641
+ }
642
+ model.attach(scope)
643
+ // The subscription lives exactly as long as this fiber: `effect` runs
644
+ // the returned disposer on unload or recomposition.
645
+ if (typeof inner.effect === 'function') {
646
+ inner.effect(() => () => model.detach(), 'dsh-wsl-tool: configuration scope')
647
+ }
648
+ })
649
+ }
650
+ } catch (error) {
651
+ console.error('dsh-wsl-tool: asking for the configuration service failed', error)
652
+ }
653
+
654
+ try {
655
+ if (ctx.slots === undefined || ctx.slots === null) {
656
+ console.error('dsh-wsl-tool: this DSH has no slots service; the WSL panel stays unmounted')
657
+ return
658
+ }
659
+ // Both seats are registered from the innermost callback, exactly as the
660
+ // shipped panels do: the rail entry and the panel it opens appear together
661
+ // or not at all. The callback's return value is the disposer chain.
662
+ ctx.slots.inject('main', () =>
663
+ ctx.slots.inject('sidebar.panellist', () => {
664
+ const stopMain = ctx.slots.register(
665
+ { name: 'main', key: PANEL_ID, inject: () => ({ model }) },
666
+ WslPanel,
667
+ )
668
+ const stopIcon = ctx.slots.register(
669
+ { name: 'sidebar.panellist', id: PANEL_ID, order: 50, label: () => 'WSL' },
670
+ WslIcon,
671
+ )
672
+ return () => {
673
+ if (typeof stopIcon === 'function') stopIcon()
674
+ if (typeof stopMain === 'function') stopMain()
675
+ }
676
+ }),
677
+ )
678
+ } catch (error) {
679
+ console.error('dsh-wsl-tool: registering the WSL panel failed', error)
680
+ }
681
+ }
682
+
683
+ // `slots` is the one service this module needs before it can do anything.
684
+ // `configForms` is optional and is asked for above, at runtime.
685
+ const inject = ['slots']
686
+
687
+ exports.apply = apply
688
+ exports.inject = inject
689
+ return module.exports
690
+ },
691
+ })
package/lib/config.js CHANGED
@@ -1,9 +1,20 @@
1
1
  // Resolved settings for one mounted plugin instance.
2
2
  //
3
- // Everything tunable lives here. The numeric knobs are environment-overridable
4
- // so a deployment can tune them without editing this package; an unparsable or
5
- // out-of-range value falls back to the default instead of failing the mount,
6
- // because one bad environment variable must not take all three tools down.
3
+ // Everything tunable lives here, and it is resolved from three layers, most
4
+ // specific first:
5
+ //
6
+ // 1. the plugin's own configuration — the switches in the left-sidebar panel
7
+ // (a host `Config` schema; see index.js), stored in the profile patch;
8
+ // 2. the environment, for a deployment that tunes a headless install with no
9
+ // UI at all;
10
+ // 3. the built-in defaults below.
11
+ //
12
+ // A layer only speaks when it actually says something: an unset switch, an empty
13
+ // distro or a zero timeout falls THROUGH to the layer beneath instead of pinning
14
+ // the default over it. That is what keeps `DSH_WSL_WORKDIR=session` working for a
15
+ // user who never opened the panel. An unparsable or out-of-range value falls back
16
+ // rather than failing the mount, because one bad environment variable must not
17
+ // take all three tools down.
7
18
 
8
19
  import { windowsPathToWsl } from './paths.js'
9
20
 
@@ -28,6 +39,16 @@ export const DEFAULTS = {
28
39
  maxCommandChars: 30_000,
29
40
  /** `setTimeout` stores its delay in a signed 32-bit int; larger fires at once. */
30
41
  maxTimerDelayMs: 2 ** 31 - 1,
42
+ /** Which tools this plugin registers at all. */
43
+ tools: { wsl: true, path: true, env: true },
44
+ /** Whether `wsl` accepts `runInBackground` (the built-in job tools read it back). */
45
+ backgroundJobs: true,
46
+ /** Whether a Windows path in `command` is rewritten to /mnt/... by default. */
47
+ translatePaths: true,
48
+ /** Whether the destructive-command guard may be bypassed only by the caller's
49
+ * explicit `allowDangerous`. Never set this from the environment: it is the
50
+ * one switch whose off position is a footgun, and the panel marks it as such. */
51
+ dangerGuard: true,
31
52
  }
32
53
 
33
54
  /**
@@ -69,8 +90,18 @@ function envInt(env, name, fallback, min, max) {
69
90
  return value >= min && value <= max ? value : fallback
70
91
  }
71
92
 
72
- /** @returns a distribution name to pin, or null to use the system default. */
73
- function envDistro(env) {
93
+ /** A configured boolean, or the fallback when the layer did not set one. */
94
+ function setting(value, fallback) {
95
+ return typeof value === 'boolean' ? value : fallback
96
+ }
97
+
98
+ /**
99
+ * @param env - the environment layer.
100
+ * @param configured - the plugin's own configuration layer (may be undefined).
101
+ * @returns a distribution name to pin, or null to use the system default.
102
+ */
103
+ function resolveDistro(env, configured) {
104
+ if (typeof configured === 'string' && configured.trim() !== '') return configured.trim()
74
105
  const raw = env.DSH_WSL_DISTRO
75
106
  return typeof raw === 'string' && raw.trim() !== '' ? raw.trim() : null
76
107
  }
@@ -78,15 +109,19 @@ function envDistro(env) {
78
109
  /**
79
110
  * Where a call starts when the caller passes no `workdir`.
80
111
  *
81
- * `home` (the default) keeps the documented `~`. `session` starts in the
82
- * session's working directory — the plugin's own process cwd, the same source
83
- * `dsh-pwsh-local` uses by default — which is what an agent working on a
84
- * Windows checkout usually wants, since its files live at `/mnt/<drive>/...`
85
- * rather than in the Linux home. Anything else is an explicit default path.
112
+ * `home` keeps the documented `~`. `session` starts in the session's working
113
+ * directory — the plugin's own process cwd, the same source `dsh-pwsh-local` uses
114
+ * by default — which is what an agent working on a Windows checkout usually
115
+ * wants, since its files live at `/mnt/<drive>/...` rather than in the Linux home.
116
+ * Anything else is an explicit default path.
86
117
  *
87
118
  * @returns a path, or null meaning "the process working directory".
88
119
  */
89
- function envWorkdir(env) {
120
+ function resolveWorkdir(env, settings) {
121
+ // Only the ON position is authoritative: off means "whatever the layers below
122
+ // say", so `DSH_WSL_WORKDIR=session` keeps working for a user who never opened
123
+ // the panel, and a fresh install still starts in `~`.
124
+ if (settings.startInSessionWorkspace === true) return null
90
125
  const raw = env.DSH_WSL_WORKDIR
91
126
  if (typeof raw !== 'string' || raw.trim() === '') return DEFAULTS.workdir
92
127
  const value = raw.trim()
@@ -103,27 +138,45 @@ function envWorkdir(env) {
103
138
  * stream and would be discarded exactly when it is most useful.
104
139
  *
105
140
  * @param env - environment to read (injectable for tests).
141
+ * @param settings - the plugin's own configuration, as the composition resolved
142
+ * it against the Config schema (injectable for tests; may be undefined).
106
143
  */
107
- export function resolveConfig(env = process.env) {
144
+ export function resolveConfig(env = process.env, settings = {}) {
108
145
  const maxOutputBytes = envInt(
109
146
  env, 'DSH_WSL_MAX_OUTPUT_BYTES', DEFAULTS.maxOutputBytes, OUTPUT_BYTES_MIN, OUTPUT_BYTES_MAX,
110
147
  )
111
148
  const maxCommandTimeoutMs = envInt(
112
149
  env, 'DSH_WSL_MAX_TIMEOUT_MS', DEFAULTS.maxCommandTimeoutMs, TIMEOUT_MS_MIN, DEFAULTS.maxTimerDelayMs,
113
150
  )
151
+ const tools = settings.tools ?? {}
152
+ // 0 means "not configured", so the environment and the built-in default still
153
+ // decide; a positive value is the user's own deadline.
154
+ const configuredTimeoutMs = Number.isInteger(settings.timeoutMs) && settings.timeoutMs > 0
155
+ ? settings.timeoutMs
156
+ : undefined
114
157
  return {
115
158
  ...DEFAULTS,
159
+ tools: {
160
+ wsl: setting(tools.wsl, DEFAULTS.tools.wsl),
161
+ path: setting(tools.path, DEFAULTS.tools.path),
162
+ env: setting(tools.env, DEFAULTS.tools.env),
163
+ },
164
+ backgroundJobs: setting(settings.backgroundJobs, DEFAULTS.backgroundJobs),
165
+ translatePaths: setting(settings.translatePaths, DEFAULTS.translatePaths),
166
+ dangerGuard: setting(settings.dangerGuard, DEFAULTS.dangerGuard),
167
+ startInSessionWorkspace: settings.startInSessionWorkspace === true,
116
168
  // null means "whatever wsl.exe uses by default", which is what makes the
117
169
  // package portable: `Ubuntu-22.04` exists on the author's machine, not
118
170
  // necessarily on a storefront user's.
119
- distro: envDistro(env),
120
- defaultWorkdir: envWorkdir(env),
171
+ distro: resolveDistro(env, settings.distro),
172
+ defaultWorkdir: resolveWorkdir(env, settings),
121
173
  maxOutputBytes,
122
174
  maxSpillBytes: Math.max(DEFAULTS.maxSpillBytes, maxOutputBytes),
123
175
  maxCommandTimeoutMs,
124
176
  // The default deadline obeys the cap too, so the two knobs cannot disagree.
125
177
  commandTimeoutMs: Math.min(
126
- envInt(env, 'DSH_WSL_TIMEOUT_MS', DEFAULTS.commandTimeoutMs, TIMEOUT_MS_MIN, DEFAULTS.maxTimerDelayMs),
178
+ configuredTimeoutMs
179
+ ?? envInt(env, 'DSH_WSL_TIMEOUT_MS', DEFAULTS.commandTimeoutMs, TIMEOUT_MS_MIN, DEFAULTS.maxTimerDelayMs),
127
180
  maxCommandTimeoutMs,
128
181
  ),
129
182
  }
package/lib/tools/wsl.js CHANGED
@@ -195,7 +195,7 @@ export function createWslTool({ ctx, config, runner }) {
195
195
  },
196
196
  translatePaths: {
197
197
  type: 'boolean',
198
- description: 'Default true: rewrite Windows paths in `command` to /mnt/... . Set false to pass `command` verbatim, e.g. a native path for a Windows program launched through interop. `workdir` is always translated.',
198
+ description: `Default ${config.translatePaths}: rewrite Windows paths in \`command\` to /mnt/... . Set false to pass \`command\` verbatim, e.g. a native path for a Windows program launched through interop. \`workdir\` is always translated.`,
199
199
  },
200
200
  },
201
201
  required: ['command', 'description'],
@@ -263,9 +263,11 @@ export function createWslTool({ ctx, config, runner }) {
263
263
  }
264
264
  }
265
265
 
266
- const command = args.translatePaths === false ? args.command : windowsPathToWsl(args.command)
266
+ // The switch is the default, the parameter is the per-call override.
267
+ const translatePaths = args.translatePaths ?? config.translatePaths
268
+ const command = translatePaths === false ? args.command : windowsPathToWsl(args.command)
267
269
  const reason = destructiveReason(command)
268
- if (reason !== null && args.allowDangerous !== true) {
270
+ if (reason !== null && args.allowDangerous !== true && config.dangerGuard) {
269
271
  throw new Error(
270
272
  `wsl: refused a destructive command (${reason}). ` +
271
273
  'If this is intended, re-issue it with `allowDangerous: true`.',
@@ -283,6 +285,16 @@ export function createWslTool({ ctx, config, runner }) {
283
285
  }
284
286
 
285
287
  if (args.runInBackground === true) {
288
+ // The switch is checked here rather than hidden from the schema: a model
289
+ // that was told about `runInBackground` (by a cached prompt, or a
290
+ // transcript resumed from before the switch moved) must be told why it is
291
+ // refused instead of silently running in the foreground.
292
+ if (!config.backgroundJobs) {
293
+ throw new Error(
294
+ 'wsl: background jobs are switched off in this plugin\'s settings ' +
295
+ '(left sidebar, WSL panel -> 后台任务). Run it in the foreground, or turn the switch back on.',
296
+ )
297
+ }
286
298
  // A background job is meant to outlive the foreground deadline, so the
287
299
  // configured default does NOT apply; an explicit timeoutMs still does.
288
300
  return startInBackground(command, { ...opts, timeoutMs: args.timeoutMs }, exec)
package/package.json CHANGED
@@ -1,9 +1,23 @@
1
1
  {
2
2
  "name": "dsh-wsl-tool",
3
- "version": "1.9.0",
4
- "description": "Run Linux commands from Windows through WSL, essentially matching a native Linux DSH for command execution: background jobs, stdin, path translation, a destructive-command guard and a WSL capability report. WSL calls run below the DSH sandbox, and a project on a Windows drive keeps Windows filesystem semantics.",
3
+ "version": "1.10.0",
4
+ "description": "Run Linux commands from Windows through WSL, essentially matching a native Linux DSH for command execution: background jobs, stdin, path translation, a destructive-command guard and a WSL capability report. A left-sidebar panel switches each capability on or off, each with a one-line explanation, and an opt-in patch gives the desktop app's sidebar terminal a WSL shell. WSL calls run below the DSH sandbox, and a project on a Windows drive keeps Windows filesystem semantics.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./lib/client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "peerDependencies": {
14
+ "@deepseek-ai/schemastery": "^3.18.1"
15
+ },
16
+ "peerDependenciesMeta": {
17
+ "@deepseek-ai/schemastery": {
18
+ "optional": true
19
+ }
20
+ },
7
21
  "license": "MIT",
8
22
  "repository": {
9
23
  "type": "git",
@@ -30,12 +44,13 @@
30
44
  "README.md",
31
45
  "README.zh-CN.md",
32
46
  "PUBLISHING.md",
47
+ "extras",
33
48
  "screenshots.json",
34
49
  "assets",
35
50
  "LICENSE"
36
51
  ],
37
52
  "scripts": {
38
- "test": "node test/smoke.mjs",
53
+ "test": "node test/smoke.mjs && node test/client.mjs",
39
54
  "test:real": "node test/smoke.mjs --real && node test/real-seam.mjs",
40
55
  "sync": "node scripts/sync-profile.mjs"
41
56
  },
@@ -46,6 +61,13 @@
46
61
  "dsh": {
47
62
  "bundle": {
48
63
  "patch": "./cordis.patch.yml"
64
+ },
65
+ "client": {
66
+ "platform": "web",
67
+ "immediately": true,
68
+ "inject": [
69
+ "@deepseek-ai/dsh-client-ui-primitives"
70
+ ]
49
71
  }
50
72
  }
51
73
  }