dsh-win-pwsh7 0.2.0 → 0.2.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.4 — plugin page aligned with the dsh-ops card language
4
+
5
+ - Restyled both contributions after the installed dsh-ops 0.5.0 branch: a 0.5px hairline card on
6
+ `radius-lg` with a shaded card body, 13px/600 group titles, max-content fact labels, and the
7
+ same input, button and focus-token metrics.
8
+ - Split the configure surface into two cards: the executor path sits in a shaded body, the
9
+ dsh-ops coexistence switch sits in the card head, and save/reset follow the cards.
10
+ - The status section is one card now: title, refresh metric and action in the head, the facts in
11
+ a shaded body, and the profile patch as a sunken preview block.
12
+ - Updated the client gate to assert the card language instead of the retired flat section.
13
+
14
+ ## 0.2.3 — removable dsh-ops coexistence bridge
15
+
16
+ - Moved the bridge, profile-patch ownership, and pure name planning into `lib/dsh-ops-bridge.js`.
17
+ `index.js` now mounts it in one place; `package.json` ships the module.
18
+ - Enabled coexistence declaratively from `cordis.patch.yml`; the plugin no longer writes an
19
+ enable override into the profile. Turning the switch off writes only a marked disable override.
20
+ - Removal contract: delete the module, the mount line, the config field, the client switch, and its
21
+ gate; the bundle patch disappears with the package. A switch-off override is the only possible
22
+ profile residue and is removed by the plugin's cleanup when it runs.
23
+ - The bundle patch must come after `dsh-ops` in `dsh.profile.bundles`; otherwise the loader warns
24
+ and skips it. The profile keeps `dsh-ops` before `dsh-win-pwsh7`.
25
+
26
+ ## 0.2.2 — fix the dsh-ops bridge freeze
27
+
28
+ - Fixed the 0.2.1 hide/unhide oscillation that could pin the Host main thread when many sessions
29
+ were restored. The bridge now plans from the host's **pre-restriction** `knownNames` /
30
+ `restrictableNames` instead of the post-restriction `visible` set, and filters the deny list to
31
+ names `tools.restrict()` actually accepts. The fallback path latches names it already hid.
32
+ - Added regression checks for the stable plan, the older-host fallback, and the scope-local
33
+ `ops_bash` case that used to throw an unknown-global-tool warning.
34
+ - Restored `dshOpsCompat` to default-on after multi-session validation of the fixed bridge.
35
+ - The configuration page now uses the native `Switch` primitive and native setting-row layout for
36
+ the coexistence toggle; labels and hints no longer expose raw config keys.
37
+
38
+ ## 0.2.1 — dsh-ops coexistence bridge
39
+
40
+ - Added a default-on `dshOpsCompat` configuration row. When dsh-ops is installed, pwsh7 writes
41
+ a marked `dsh-ops-compat` override into the active profile patch and enables it, so the native
42
+ `pwsh` tool is visible again alongside `ops_bash`.
43
+ - The agent bridge re-hides the official `read` / `glob` / `grep` tools when their OPS
44
+ counterparts are visible, plus the duplicate official `bash` / `pwsh_persistent` shell. It
45
+ keeps `pwsh` and `ops_bash` and does nothing when dsh-ops is absent.
46
+ - The compatibility patch needs a DSH restart; turning the switch off removes only the block this
47
+ plugin wrote. Uninstall cleanup is best-effort and leaves at most a harmless compatibility row.
48
+ - Added the plugin-page toggle with its own localized row and hint.
49
+
3
50
  ## 0.2.0 — 优化 Store 版 pwsh7 安装方式
4
51
 
5
52
  优化store版pwsh7安装方式,重构项目前端设计和工程设计,使其符合原生DSH工程习惯和界面风格。
package/README.en.md CHANGED
@@ -25,14 +25,26 @@ default executor then chooses the runtime itself, and the plugin still never wri
25
25
  ## What it does not do
26
26
 
27
27
  - It does not install anything at startup.
28
- - It does not write the executor's `pwshPath` or edit the profile patch.
28
+ - It does not write the executor's `pwshPath`; it only writes a marked dsh-ops compatibility override when coexistence is on.
29
29
  - It does not uninstall a Store-installed PowerShell 7.
30
30
  - It does not auto-detect VPNs or preflight metered connections.
31
31
 
32
+ ## Coexistence with dsh-ops
33
+
34
+ On by default. The `dsh-ops-compat` override in `cordis.patch.yml` owns the enabled state: while
35
+ the dsh-win-pwsh7 bundle is installed and comes after dsh-ops, a DSH restart restores the native
36
+ `pwsh` tool and keeps `ops_bash`. Turning the switch off writes only a marked disable override;
37
+ removing the bundle removes the enable override, and cleanup best-effort removes the disable one.
38
+
39
+ The 0.2.1 bridge planned from the post-restriction visible set, which made it hide and restore the
40
+ same tools in a loop and starved the Host main thread. 0.2.2 plans from the pre-restriction
41
+ `knownNames` / `restrictableNames` and filters names `tools.restrict()` does not accept. The
42
+ multi-session validation passed, so the switch is default-on again.
43
+
32
44
  ## Install this plugin
33
45
 
34
46
  ```powershell
35
- dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.0
47
+ dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.4
36
48
  # or
37
49
  dsh plugin --profile desktop add dsh-win-pwsh7
38
50
  ```
@@ -70,6 +82,7 @@ The count lives in the current process and resets when DSH restarts.
70
82
  | Key | Default | Meaning |
71
83
  |---|---|---|
72
84
  | `overridePath` | unset | Priority ① explicit `pwsh.exe`. It must spawn and report PowerShell 7+; a failed validation falls back to auto-detection. **It only changes probing and recommendations, not executor wiring.** |
85
+ | `dshOpsCompat` | `true` | Coexist with dsh-ops (on by default): writes and enables its Compatibility mode, hides duplicate official tools, and keeps `pwsh` + `ops_bash`. Requires a restart. |
73
86
 
74
87
  `overridePath` is `volatile()`: the plugin-page configuration section saves it live; it can also be
75
88
  set in the profile patch:
@@ -113,7 +126,8 @@ executor / disable the plugin.
113
126
  ## Plugin page
114
127
 
115
128
  The status section is a native card showing the state, runtime and executor path, with Re-detect
116
- at the top right; the configuration section exposes only `overridePath`.
129
+ at the top right; the configuration section exposes `overridePath` and the default-on "Keep native
130
+ pwsh alongside dsh-ops" native switch.
117
131
 
118
132
  ## Disable or uninstall
119
133
 
package/README.md CHANGED
@@ -20,15 +20,23 @@
20
20
  ## 它不做什么
21
21
 
22
22
  - 不在启动时自动安装。
23
- - 不写执行器的 `pwshPath`,也不修改 profile 补丁。
23
+ - 不写执行器的 `pwshPath`;只会在开启共存时写一条带标记的 dsh-ops 兼容覆盖。
24
24
  - 不卸载由 Store 安装的 PowerShell 7。
25
25
  - 不做 VPN 自动检测,也不做计量连接预检。
26
26
 
27
+ ## 与 dsh-ops 共存
28
+
29
+ 默认开启。启用由 `cordis.patch.yml` 的声明式 `dsh-ops-compat` 覆盖负责:只要 dsh-win-pwsh7 的 bundle 安装且位于 dsh-ops 之后,重启 DSH 后官方 `pwsh` 恢复,`ops_bash` 保留。关闭开关时只写入一条带标记的 disable override;卸载 bundle 后启用覆盖消失,cleanup 会尽力删除 disable override。
30
+
31
+ 0.2.1 的桥接层在计算隐藏集合时读取了限制后的可见工具,会在隐藏/恢复之间振荡并卡死宿主;0.2.2 改为读取限制前的 `knownNames` / `restrictableNames`,并过滤 `tools.restrict()` 不接受的名字。多会话验证通过后已恢复默认开启。
32
+
33
+ 桥接层还会在对应 OPS 工具可见时隐藏官方 `read` / `glob` / `grep`,以及重复的官方 `bash` / `pwsh_persistent`,让工具面保持 `pwsh` + `ops_bash`。桥接实现独立在 `lib/dsh-ops-bridge.js`,删除该模块和入口挂载即可移除。
34
+
27
35
  ## 安装本插件
28
36
 
29
37
  ```powershell
30
38
  # 从 GitHub 安装(推荐锁定 tag)
31
- dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.0
39
+ dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.4
32
40
  # 或 npm
33
41
  dsh plugin --profile desktop add dsh-win-pwsh7
34
42
  ```
@@ -61,6 +69,7 @@ dsh plugin --profile desktop add dsh-win-pwsh7
61
69
  | 键 | 默认 | 说明 |
62
70
  |---|---|---|
63
71
  | `overridePath` | 未设 | 优先级①的显式 `pwsh.exe`。必须能启动并报告 PowerShell 7+;未通过校验就回落到自动探测。**只影响探测与推荐,不接线**。 |
72
+ | `dshOpsCompat` | `true` | 与 dsh-ops 共存(默认开启):自动写入并启用兼容模式,隐藏重复的官方工具,保留 `pwsh` + `ops_bash`。需要重启。 |
64
73
 
65
74
  `overridePath` 是 `volatile()`:插件页配置区保存即生效;也可以写进 profile 补丁:
66
75
 
@@ -96,7 +105,7 @@ The executor resolves to the version-stable Store family alias: … Nothing to c
96
105
 
97
106
  ## 插件页
98
107
 
99
- 插件页的状态区是原生卡片,显示状态、运行时和执行器路径,右上角有「重新检测」;配置区只有 `overridePath`。
108
+ 插件页的状态区是原生卡片,显示状态、运行时和执行器路径,右上角有「重新检测」;配置区有 `overridePath` 和默认开启的「与 dsh-ops 共存」原生开关。
100
109
 
101
110
  ## 禁用或卸载
102
111
 
package/client.js CHANGED
@@ -1,6 +1,7 @@
1
- // client.js — the browser half of dsh-win-pwsh7: the configure section, the diagnostics
2
- // section, and the re-detect action on the plugin pages. Both render as flat native detail
3
- // sections — no card chrome, and the host page's own 32px section rhythm does the spacing.
1
+ // client.js — the browser half of dsh-win-pwsh7: the configure cards and the status card
2
+ // on the plugin pages. Both use the same chrome as the installed dsh-ops branch — a 0.5px
3
+ // hairline card on radius-lg, a shaded card body, 13px/600 titles and max-content fact
4
+ // labels — while the Host page keeps its own 32px rhythm between detail sections.
4
5
  //
5
6
  // One operation, two callers: the diagnostics section and the re-detect button call the
6
7
  // same Host method (`status`) that the `pwsh7_status` agent tool runs — over the Remote
@@ -12,6 +13,7 @@ window.__ModuleLoader__.load({
12
13
  id: 'dsh-win-pwsh7',
13
14
  factory(require) {
14
15
  const React = require('react')
16
+ const { Switch } = require('@deepseek-ai/dsh-client-ui-primitives')
15
17
  const h = React.createElement
16
18
  const { useEffect, useState, useSyncExternalStore } = React
17
19
 
@@ -96,8 +98,12 @@ window.__ModuleLoader__.load({
96
98
  redetect: 'Re-detect',
97
99
  redetecting: 'Detecting…',
98
100
  card_title: 'Configuration',
99
- field_overridePath: 'Explicit path (overridePath)',
100
- overridePath_hint: 'Priority ① explicit pwsh.exe path. Empty = auto-detect. It must spawn and report PowerShell 7 or newer; the Store family alias is accepted, while a version-numbered Store path is never pinned. This only changes what the plugin probes and recommends — it does not wire the pwsh tool. Use the patch in the status section for that.',
101
+ executor_title: 'PowerShell 7 executor',
102
+ executor_hint: 'The runtime the pwsh tool runs. This page only probes and recommends; the profile patch in the status section does the wiring.',
103
+ field_overridePath: 'PowerShell 7 path',
104
+ overridePath_hint: 'Optional explicit pwsh.exe path (`overridePath`). Empty = auto-detect. It must spawn and report PowerShell 7 or newer; the Store family alias is accepted, while a version-numbered Store path is never pinned. This only changes what the plugin probes and recommends — it does not wire the pwsh tool. Use the patch in the status section for that.',
105
+ field_dshOpsCompat: 'Coexist with dsh-ops',
106
+ dshOpsCompat_hint: 'On by default. Keeps the native pwsh tool beside ops_bash by enabling dsh-ops Compatibility and hiding its duplicate official tools. Requires a DSH restart.',
101
107
  save: 'Save',
102
108
  reset: 'Reset',
103
109
  saved: 'Saved {at}',
@@ -138,8 +144,12 @@ window.__ModuleLoader__.load({
138
144
  redetect: '重新检测',
139
145
  redetecting: '检测中…',
140
146
  card_title: '配置',
141
- field_overridePath: '指定路径(overridePath)',
142
- overridePath_hint: '优先级①的显式 pwsh.exe 路径。留空 = 自动探测。必须能启动并报告 PowerShell 7 及以上;Store 家族别名可用,版本号目录永不被 pin。这里只影响插件的探测与推荐,不会给 pwsh 工具接线;接线请用状态区里的补丁。',
147
+ executor_title: 'PowerShell 7 执行器',
148
+ executor_hint: 'pwsh 工具实际运行的 PowerShell。本页只做探测与推荐;接线用状态区里的 profile 补丁。',
149
+ field_overridePath: 'PowerShell 7 路径',
150
+ overridePath_hint: '可选的显式 pwsh.exe 路径(`overridePath`)。留空 = 自动探测。必须能启动并报告 PowerShell 7 及以上;Store 家族别名可用,版本号目录永不被 pin。这里只影响插件的探测与推荐,不会给 pwsh 工具接线;接线请用状态区里的补丁。',
151
+ field_dshOpsCompat: '与 dsh-ops 共存',
152
+ dshOpsCompat_hint: '默认开启。通过启用 dsh-ops 的 Compatibility 模式并隐藏重复的官方工具,让原生 pwsh 与 ops_bash 并存。需要重启 DSH。',
143
153
  save: '保存',
144
154
  reset: '重置',
145
155
  saved: '已保存 {at}',
@@ -159,46 +169,61 @@ window.__ModuleLoader__.load({
159
169
  // while its section is mounted, so both sections carry it: a page can render the status
160
170
  // section without ever rendering the configure section.
161
171
  const CSS = `
162
- .dw7-section{display:flex;flex-direction:column;gap:12px;min-width:0;}
163
- .dw7-head{display:flex;align-items:baseline;gap:10px;min-width:0;}
164
- .dw7-title{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:500;line-height:20px;}
165
- .dw7-status{box-sizing:border-box;display:flex;flex-direction:column;gap:10px;padding:14px;border:.5px solid var(--dsw-alias-border-l1);border-radius:10px;font-size:13px;min-width:0;}
166
- .dw7-status-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-width:0;}
167
- .dw7-status-title{flex:none;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:20px;}
168
- .dw7-status-metric{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;}
169
- .dw7-status-action{margin-left:auto;flex:none;}
170
- .dw7-estimate{display:grid;gap:10px;padding:12px;border-radius:8px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.6;min-width:0;}
171
- .dw7-estimate p{margin:0;}
172
- .dw7-facts{display:grid;gap:6px;margin:0;}
173
- .dw7-facts>div{display:grid;grid-template-columns:72px minmax(0,1fr);gap:10px;}
174
- .dw7-facts dt{color:var(--dsw-alias-label-tertiary);}
175
- .dw7-facts dd{margin:0;overflow-wrap:anywhere;}
176
- .dw7-note{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6;}
172
+ .dw7-settings{display:flex;flex-direction:column;gap:12px;min-width:0;color:var(--dsw-alias-label-primary);font:inherit;}
173
+ .dw7-settings p{margin:0;line-height:1.55;overflow-wrap:anywhere;}
174
+ .dw7-group{display:flex;flex-direction:column;min-width:0;}
175
+ .dw7-card{border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);padding:14px;gap:10px;}
176
+ .dw7-group-head{display:flex;align-items:flex-start;gap:16px;min-width:0;}
177
+ .dw7-card .dw7-group-head{align-items:flex-start;}
178
+ .dw7-group-head .dw7-field-text{display:flex;flex-direction:column;flex:1 1 auto;min-width:0;gap:2px;}
179
+ .dw7-group-head > button[role=switch]{flex:none;margin-top:1px;}
180
+ .dw7-group-title{margin:0 0 4px;font-size:13px;font-weight:600;line-height:20px;color:var(--dsw-alias-label-secondary);}
181
+ .dw7-card .dw7-group-title{margin:0;}
182
+ .dw7-card-body{display:flex;flex-direction:column;min-width:0;padding:12px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover);}
183
+ .dw7-card-body > .dw7-field{padding:10px 0;}
184
+ .dw7-card-body > .dw7-field:first-child{padding-top:0;}
185
+ .dw7-card-body > .dw7-field:last-child{padding-bottom:0;}
186
+ .dw7-card-body .dw7-field + .dw7-field{border-top:.5px solid var(--dsw-alias-border-l2);}
177
187
  .dw7-field{display:flex;flex-direction:column;gap:6px;padding:12px 0;min-width:0;}
178
188
  .dw7-field + .dw7-field{border-top:.5px solid var(--dsw-alias-border-l2);}
179
- .dw7-label{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:1.5;}
180
- .dw7-input{box-sizing:border-box;width:100%;height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);font-family:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary);}
189
+ .dw7-field-text{display:flex;flex-direction:column;flex:1 1 auto;gap:4px;min-width:0;}
190
+ .dw7-field-label{font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary);}
191
+ .dw7-field-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary);}
192
+ .dw7-input{box-sizing:border-box;width:100%;height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:1.5;}
181
193
  .dw7-input:focus-visible{outline:none;border-color:var(--dsw-alias-state-business-primary);}
182
194
  .dw7-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default;}
183
- .dw7-hint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;}
195
+ .dw7-input[aria-invalid=true]{border-color:var(--dsw-alias-state-error-primary);}
196
+ .dw7-field-error{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary);}
184
197
  .dw7-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:4px;}
185
198
  .dw7-save{appearance:none;border:1px solid transparent;border-radius:var(--dsw-radius-md);padding:5px 14px;font:inherit;font-size:13px;line-height:1.5;cursor:pointer;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);}
186
199
  .dw7-save:disabled{opacity:.4;cursor:default;}
187
- .dw7-save:focus-visible,.dw7-reset:focus-visible,.dw7-small:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px;}
188
200
  .dw7-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer;}
189
- .dw7-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary);}
190
- .dw7-reset:disabled{cursor:default;}
201
+ .dw7-reset:disabled{opacity:.4;cursor:default;}
191
202
  .dw7-small{box-sizing:border-box;height:28px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm);background:transparent;font:inherit;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);cursor:pointer;}
192
- .dw7-small:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);}
193
203
  .dw7-small:disabled{opacity:.4;cursor:not-allowed;}
204
+ .dw7-detail{display:flex;flex-direction:column;gap:12px;min-width:0;}
205
+ .dw7-detail-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-width:0;}
206
+ .dw7-detail-title{margin:0;flex:none;font-size:13px;font-weight:600;line-height:20px;color:var(--dsw-alias-label-primary);}
207
+ .dw7-detail-metric{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);}
208
+ .dw7-detail-action{margin-left:auto;flex:none;}
209
+ .dw7-status-card{display:flex;flex-direction:column;gap:10px;padding:14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);min-width:0;}
210
+ .dw7-status-body{padding:12px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover);min-width:0;}
211
+ .dw7-status-lead{margin:0 0 12px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.6;}
212
+ .dw7-facts{display:grid;gap:6px;margin:0;}
213
+ .dw7-facts > div{display:grid;grid-template-columns:max-content minmax(0,1fr);align-items:baseline;column-gap:14px;}
214
+ .dw7-facts dt{color:var(--dsw-alias-label-tertiary);font-size:13px;}
215
+ .dw7-facts dd{margin:0;overflow-wrap:anywhere;font-size:13px;}
216
+ .dw7-status-note{margin:12px 0 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6;}
217
+ .dw7-note{margin:0;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-tertiary);}
194
218
  .dw7-report{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere;}
195
- .dw7-empty{margin:0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;}
196
219
  .dw7-error{margin:0;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;overflow-wrap:anywhere;}
197
220
  .dw7-ok{margin:0;color:var(--dsw-alias-state-success-primary);font-size:12px;line-height:1.5;}
198
- .dw7-patch{display:flex;flex-direction:column;gap:8px;min-width:0;}
199
- .dw7-patch-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;}
200
- .dw7-patch-note{margin:0;flex:1;min-width:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;}
221
+ .dw7-preview{display:flex;flex-direction:column;gap:10px;padding:12px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover);font-size:13px;}
222
+ .dw7-preview-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;}
223
+ .dw7-preview-note{margin:0;flex:1;min-width:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;}
201
224
  .dw7-code{box-sizing:border-box;margin:0;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:12px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere;}
225
+ .dw7-save:focus-visible,.dw7-reset:focus-visible,.dw7-small:focus-visible,.dw7-input:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px;}
226
+ @media(max-width:440px){.dw7-facts > div{grid-template-columns:1fr;gap:2px;}}
202
227
  `
203
228
 
204
229
  return {
@@ -362,40 +387,43 @@ window.__ModuleLoader__.load({
362
387
  }
363
388
  }
364
389
  const patchBlock = patch !== null
365
- ? h('div', { className: 'dw7-patch' },
366
- h('div', { className: 'dw7-patch-head' },
367
- h('p', { className: 'dw7-patch-note' }, t('patch_note')),
368
- h('button', { className: 'dw7-small', onClick: () => void copyPatch() }, copied ? t('copied') : t('copy_patch')),
390
+ ? h('div', { className: 'dw7-preview' },
391
+ h('div', { className: 'dw7-preview-head' },
392
+ h('p', { className: 'dw7-preview-note' }, t('patch_note')),
393
+ h('button', { type: 'button', className: 'dw7-small', onClick: () => void copyPatch() }, copied ? t('copied') : t('copy_patch')),
369
394
  copyNote ? h('span', { className: 'dw7-error' }, copyNote) : null),
370
395
  h('pre', { className: 'dw7-code' }, patch))
371
396
  : null
372
- return h('section', { className: 'dw7-status' },
397
+ const body = fallback
398
+ ? h('p', { className: 'dw7-report', key: 'report' }, report)
399
+ : h('div', { className: 'dw7-status-body', key: 'body' },
400
+ h('p', { className: 'dw7-status-lead' }, view.lead),
401
+ h('dl', { className: 'dw7-facts' },
402
+ h('div', { key: 'status' },
403
+ h('dt', null, t('status_row_status')),
404
+ h('dd', null, view.status)),
405
+ h('div', { key: 'runtime' },
406
+ h('dt', null, t('status_row_runtime')),
407
+ h('dd', null, view.runtime)),
408
+ h('div', { key: 'path' },
409
+ h('dt', null, t('status_row_path')),
410
+ h('dd', null, path ?? t('status_no_path')))),
411
+ h('p', { className: 'dw7-status-note' }, view.note))
412
+ return h('section', { className: 'dw7-detail' },
373
413
  h('style', null, CSS),
374
- h('div', { className: 'dw7-status-head' },
375
- h('strong', { className: 'dw7-status-title' }, t('diagnostics_title')),
376
- snap.at ? h('span', { className: 'dw7-status-metric' }, t('refreshed_at', { at: snap.at })) : null,
377
- h('button', {
378
- className: 'dw7-small dw7-status-action',
379
- onClick: () => void refresh(),
380
- disabled: snap.state === 'loading',
381
- }, snap.state === 'loading' ? t('redetecting') : t('redetect'))),
382
- fallback
383
- ? h('p', { className: 'dw7-report' }, report)
384
- : h('div', { className: 'dw7-estimate' },
385
- h('p', null, view.lead),
386
- h('dl', { className: 'dw7-facts' },
387
- h('div', { key: 'status' },
388
- h('dt', null, t('status_row_status')),
389
- h('dd', null, view.status)),
390
- h('div', { key: 'runtime' },
391
- h('dt', null, t('status_row_runtime')),
392
- h('dd', null, view.runtime)),
393
- h('div', { key: 'path' },
394
- h('dt', null, t('status_row_path')),
395
- h('dd', null, path ?? t('status_no_path')))),
396
- h('small', { className: 'dw7-note' }, view.note)),
397
- patchBlock,
398
- snap.error !== null ? h('p', { className: 'dw7-error' }, t('status_error', { error: snap.error })) : null,
414
+ h('div', { className: 'dw7-status-card' },
415
+ h('div', { className: 'dw7-detail-head' },
416
+ h('h3', { className: 'dw7-detail-title' }, t('diagnostics_title')),
417
+ snap.at ? h('span', { className: 'dw7-detail-metric' }, t('refreshed_at', { at: snap.at })) : null,
418
+ h('button', {
419
+ type: 'button',
420
+ className: 'dw7-small dw7-detail-action',
421
+ onClick: () => void refresh(),
422
+ disabled: snap.state === 'loading',
423
+ }, snap.state === 'loading' ? t('redetecting') : t('redetect'))),
424
+ body,
425
+ patchBlock,
426
+ snap.error !== null ? h('p', { className: 'dw7-error' }, t('status_error', { error: snap.error })) : null),
399
427
  )
400
428
  }
401
429
 
@@ -415,9 +443,10 @@ window.__ModuleLoader__.load({
415
443
  const unavailable = snap.status === 'unavailable'
416
444
  const served = {
417
445
  overridePath: typeof snap.value?.overridePath === 'string' ? snap.value.overridePath : '',
446
+ dshOpsCompat: snap.value?.dshOpsCompat !== false,
418
447
  }
419
448
  const values = draft ?? served
420
- const dirty = draft !== null && values.overridePath !== served.overridePath
449
+ const dirty = draft !== null && (values.overridePath !== served.overridePath || values.dshOpsCompat !== served.dshOpsCompat)
421
450
  const editable = !loading && !unavailable && snap.writable !== false && !busy
422
451
 
423
452
  async function save() {
@@ -431,6 +460,9 @@ window.__ModuleLoader__.load({
431
460
  } else if (nextPath !== served.overridePath) {
432
461
  ok = await form.set('overridePath', nextPath) && ok
433
462
  }
463
+ if (values.dshOpsCompat !== served.dshOpsCompat) {
464
+ ok = await form.set('dshOpsCompat', values.dshOpsCompat) && ok
465
+ }
434
466
  if (ok) {
435
467
  setDraft(null)
436
468
  setSavedAt(new Date().toLocaleTimeString())
@@ -444,37 +476,63 @@ window.__ModuleLoader__.load({
444
476
  }
445
477
  }
446
478
 
447
- return h('section', { className: 'dw7-section' },
479
+ return h('form', {
480
+ className: 'dw7-settings',
481
+ onSubmit: (event) => {
482
+ event.preventDefault()
483
+ void save()
484
+ },
485
+ },
448
486
  h('style', null, CSS),
449
- h('div', { className: 'dw7-head' },
450
- h('h4', { className: 'dw7-title' }, t('card_title'))),
451
487
  loading || unavailable
452
- ? h('p', { className: 'dw7-empty' }, loading ? t('form_loading') : t('form_unavailable'))
488
+ ? h('p', { className: 'dw7-note' }, loading ? t('form_loading') : t('form_unavailable'))
453
489
  : [
454
- h('div', { className: 'dw7-field', key: 'overridePath' },
455
- h('label', { className: 'dw7-label', htmlFor: 'dw7-override-path' }, t('field_overridePath')),
456
- h('input', {
457
- id: 'dw7-override-path',
458
- className: 'dw7-input',
459
- type: 'text',
460
- spellCheck: false,
461
- value: values.overridePath,
462
- disabled: !editable,
463
- placeholder: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
464
- onChange: (event) => {
465
- setDraft({ overridePath: event.target.value })
466
- setNote(null)
467
- setSavedAt(null)
468
- },
469
- }),
470
- h('p', { className: 'dw7-hint' }, t('overridePath_hint'))),
490
+ h('section', { className: 'dw7-group dw7-card', key: 'executor' },
491
+ h('div', { className: 'dw7-group-head' },
492
+ h('div', { className: 'dw7-field-text' },
493
+ h('h4', { className: 'dw7-group-title' }, t('executor_title')),
494
+ h('p', { className: 'dw7-field-hint' }, t('executor_hint')))),
495
+ h('div', { className: 'dw7-card-body' },
496
+ h('div', { className: 'dw7-field' },
497
+ h('label', { className: 'dw7-field-label', htmlFor: 'dw7-override-path' }, t('field_overridePath')),
498
+ h('input', {
499
+ id: 'dw7-override-path',
500
+ className: 'dw7-input',
501
+ type: 'text',
502
+ spellCheck: false,
503
+ value: values.overridePath,
504
+ disabled: !editable,
505
+ placeholder: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
506
+ onChange: (event) => {
507
+ setDraft({ ...values, overridePath: event.target.value })
508
+ setNote(null)
509
+ setSavedAt(null)
510
+ },
511
+ }),
512
+ h('p', { className: 'dw7-field-hint' }, t('overridePath_hint'))))),
513
+ h('section', { className: 'dw7-group dw7-card', key: 'compat' },
514
+ h('div', { className: 'dw7-group-head' },
515
+ h('div', { className: 'dw7-field-text' },
516
+ h('h4', { className: 'dw7-group-title' }, t('field_dshOpsCompat')),
517
+ h('p', { className: 'dw7-field-hint' }, t('dshOpsCompat_hint'))),
518
+ h(Switch, {
519
+ checked: values.dshOpsCompat,
520
+ disabled: !editable,
521
+ label: t('field_dshOpsCompat'),
522
+ onChange: (checked) => {
523
+ setDraft({ ...values, dshOpsCompat: checked })
524
+ setNote(null)
525
+ setSavedAt(null)
526
+ },
527
+ }))),
471
528
  h('div', { className: 'dw7-actions', key: 'actions' },
472
529
  h('button', {
530
+ type: 'submit',
473
531
  className: 'dw7-save',
474
- onClick: () => void save(),
475
532
  disabled: !editable || (!dirty && note === null),
476
533
  }, t('save')),
477
534
  h('button', {
535
+ type: 'button',
478
536
  className: 'dw7-reset',
479
537
  onClick: () => {
480
538
  setDraft(null)
package/cordis.patch.yml CHANGED
@@ -4,3 +4,9 @@
4
4
  - insert:
5
5
  - id: win-pwsh7
6
6
  name: dsh-win-pwsh7
7
+ # Coexistence override: keep dsh-ops Compatibility on while this bundle is installed. The
8
+ # loader warns and skips the override when dsh-ops is absent; the profile switch writes the
9
+ # matching disable row only when the user turns coexistence off.
10
+ - id: dsh-ops-compat
11
+ name: dsh-ops/compat
12
+ disabled: false
package/index.js CHANGED
@@ -46,6 +46,11 @@ import { Service } from '@deepseek-ai/cordis'
46
46
  import { defineTool } from '@deepseek-ai/dsh-tools'
47
47
  import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
48
48
  import z from '@deepseek-ai/schemastery'
49
+ import {
50
+ ensureDshOpsCompat,
51
+ mountDshOpsBridge,
52
+ removeDshOpsCompatPatch,
53
+ } from './lib/dsh-ops-bridge.js'
49
54
 
50
55
  export const name = 'dsh-win-pwsh7'
51
56
  export const inject = ['subprocess', 'tools', 'configEditor', 'systemPrompt']
@@ -58,9 +63,13 @@ export const inject = ['subprocess', 'tools', 'configEditor', 'systemPrompt']
58
63
  * - id: win-pwsh7
59
64
  * config:
60
65
  * overridePath: 'D:\tools\pwsh\pwsh.exe' # priority ① — validated, must be an ordinary PowerShell 7+
66
+ * dshOpsCompat: true # default; keeps pwsh beside dsh-ops ops_bash
61
67
  */
62
68
  export const Config = z.object({
63
69
  overridePath: z.string().volatile(),
70
+ // Default-on after the multi-session validation of the pre-restriction bridge. Turning it
71
+ // off removes the managed compatibility override; turning it on restores it.
72
+ dshOpsCompat: z.boolean().default(true).volatile(),
64
73
  })
65
74
 
66
75
  /**
@@ -343,6 +352,27 @@ export function apply(ctx, config) {
343
352
  const current = () => readRowConfig(ctx, config)
344
353
  hostContext = ctx
345
354
  const state = runtimeState(ctx)
355
+ const syncCompat = () => {
356
+ void ensureDshOpsCompat(ctx, current()).then((result) => {
357
+ if (result.changed) {
358
+ const action = result.state === 'bundle'
359
+ ? 'delegated to the bundle patch'
360
+ : result.state === 'legacy-order'
361
+ ? 'kept as a profile fallback because the bundle order is reversed'
362
+ : result.state === 'disabled' ? 'disabled in the profile' : 'removed'
363
+ ctx.logger?.info?.('dsh-win-pwsh7: dsh-ops compatibility %s; restart DSH to apply', action)
364
+ }
365
+ }, (error) => {
366
+ ctx.logger?.warn?.('dsh-win-pwsh7: dsh-ops compatibility patch failed: %s', describe(error))
367
+ })
368
+ }
369
+
370
+ ctx.effect(() => {
371
+ syncCompat()
372
+ return () => { removeDshOpsCompatPatch(ctx) }
373
+ }, 'dsh-win-pwsh7: manage the dsh-ops compatibility patch')
374
+
375
+ ctx.effect(() => mountDshOpsBridge(ctx, current), 'dsh-win-pwsh7: hide duplicate official tools while dsh-ops is compatible')
346
376
 
347
377
  ctx.effect(() => {
348
378
  void activationSweep(ctx, current()).then((report) => {
@@ -473,8 +503,8 @@ export function apply(ctx, config) {
473
503
  ctx.effect(() => {
474
504
  if (typeof ctx.on !== 'function') return
475
505
  const disposers = [
476
- ctx.on('app-boot/config-reload', () => { void refreshGate(ctx, current()) }),
477
- ctx.on('plugin-manager/changed', () => { void refreshGate(ctx, current()) }),
506
+ ctx.on('app-boot/config-reload', () => { void refreshGate(ctx, current()); syncCompat() }),
507
+ ctx.on('plugin-manager/changed', () => { void refreshGate(ctx, current()); syncCompat() }),
478
508
  ]
479
509
  return () => {
480
510
  for (const dispose of disposers) dispose?.()
@@ -502,11 +532,13 @@ let hostContext = null
502
532
  function readRowConfig(ctx, fallback) {
503
533
  const merged = {
504
534
  overridePath: typeof fallback?.overridePath === 'string' ? fallback.overridePath : undefined,
535
+ dshOpsCompat: fallback?.dshOpsCompat !== false,
505
536
  }
506
537
  try {
507
538
  const entry = ctx.configEditor.entries().find((candidate) => candidate?.options?.name === 'dsh-win-pwsh7')
508
539
  const values = entry?.options?.config
509
540
  if (isSet(values?.overridePath)) merged.overridePath = String(values.overridePath)
541
+ if (values?.dshOpsCompat !== undefined) merged.dshOpsCompat = values.dshOpsCompat !== false
510
542
  } catch { /* fall back to the captured config */ }
511
543
  return merged
512
544
  }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Optional dsh-ops coexistence bridge.
3
+ *
4
+ * The bundle patch declares the compatibility override; this module only owns the switch-off
5
+ * override and the agent-scoped hiding of duplicate official tools. Keeping the enable path
6
+ * declarative is what makes the feature removable with the bundle.
7
+ */
8
+ import { readFileSync, renameSync, writeFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ const isSet = (value) => typeof value === 'string' && value.length > 0
12
+ const describe = (error) => error instanceof Error ? error.message : String(error)
13
+
14
+ /** Markers for the only profile blocks this plugin may own. */
15
+ const DSH_OPS_PATCH_MARKER = '# dsh-win-pwsh7: dsh-ops compatibility (managed)'
16
+ const DSH_OPS_PATCH_BLOCK = `${DSH_OPS_PATCH_MARKER}\n- id: dsh-ops-compat\n disabled: false\n`
17
+ const DSH_OPS_DISABLE_MARKER = '# dsh-win-pwsh7: dsh-ops compatibility disabled (managed)'
18
+ const DSH_OPS_DISABLE_BLOCK = `${DSH_OPS_DISABLE_MARKER}\n- id: dsh-ops-compat\n disabled: true\n`
19
+
20
+ /**
21
+ * The active profile directory. DSH exposes it to profile-launched processes; the
22
+ * plugin-manager instance carries the same directory as a fallback for hosts that do not.
23
+ * @param ctx - the row's Host context.
24
+ * @returns the profile directory, or `undefined` when it cannot be established safely.
25
+ */
26
+ function profileDirectory(ctx) {
27
+ if (isSet(process.env.DSH_PROFILE_DIR)) return process.env.DSH_PROFILE_DIR
28
+ const manager = ctx.get('pluginManager')
29
+ const directory = manager?.profile?.dir
30
+ return isSet(directory) ? directory : undefined
31
+ }
32
+
33
+ /**
34
+ * Whether dsh-ops is installed in this profile, read through the plugin manager's public
35
+ * bundle view. A missing manager or an unreadable view is "not detected", never an error.
36
+ * @param ctx - the row's Host context.
37
+ * @returns true when the dsh-ops bundle is present.
38
+ */
39
+ async function dshOpsInstalled(ctx) {
40
+ const manager = ctx.get('pluginManager')
41
+ if (manager === undefined || typeof manager.listBundles !== 'function') return false
42
+ try {
43
+ const bundles = await manager.listBundles()
44
+ const ops = Array.isArray(bundles)
45
+ ? bundles.find((bundle) => bundle?.name === 'dsh-ops' && bundle.installed !== false)
46
+ : undefined
47
+ return ops !== undefined && Array.isArray(ops.rows) && ops.rows.some((row) => row?.moduleName === 'dsh-ops/compat')
48
+ } catch {
49
+ return false
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Whether dsh-ops is layered before dsh-win-pwsh7. The bundle override can only target a row
55
+ * inserted by an earlier layer; a reversed order would be warned and skipped by the loader.
56
+ * @param ctx - the row's Host context.
57
+ * @returns true when the order is usable or cannot be established.
58
+ */
59
+ function bundleOrderOk(ctx) {
60
+ const directory = profileDirectory(ctx)
61
+ if (directory === undefined) return true
62
+ try {
63
+ const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'))
64
+ const bundles = manifest?.dsh?.profile?.bundles
65
+ if (!Array.isArray(bundles)) return true
66
+ const ops = bundles.indexOf('dsh-ops')
67
+ const pwsh7 = bundles.indexOf('dsh-win-pwsh7')
68
+ return ops < 0 || pwsh7 < 0 || ops < pwsh7
69
+ } catch {
70
+ return true
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Read and rewrite the profile patch atomically; a failed write leaves the previous file.
76
+ * @param path - the profile patch file.
77
+ * @param transform - receives the current text and returns the next text.
78
+ * @returns true when the file changed.
79
+ */
80
+ function rewriteProfilePatch(path, transform) {
81
+ let current = ''
82
+ try {
83
+ current = readFileSync(path, 'utf8')
84
+ } catch {
85
+ current = ''
86
+ }
87
+ const next = transform(current)
88
+ if (next === current) return false
89
+ const temporary = `${path}.${process.pid}.tmp`
90
+ writeFileSync(temporary, next, 'utf8')
91
+ renameSync(temporary, path)
92
+ return true
93
+ }
94
+
95
+ /**
96
+ * Remove the marked block only; a user's own `dsh-ops-compat` override is not touched.
97
+ * @param text - the profile patch text.
98
+ * @returns the text without this plugin's block.
99
+ */
100
+ function withoutCompatBlock(text) {
101
+ return text
102
+ .replace(/# dsh-win-pwsh7: dsh-ops compatibility \(managed\)\r?\n- id: dsh-ops-compat\r?\n disabled: false\r?\n?/u, '')
103
+ .replace(/# dsh-win-pwsh7: dsh-ops compatibility disabled \(managed\)\r?\n- id: dsh-ops-compat\r?\n disabled: true\r?\n?/u, '')
104
+ }
105
+
106
+ /** Whether the user already owns a dsh-ops-compat override outside our markers. */
107
+ function hasForeignCompatEntry(text) {
108
+ return /^\s*-\s*(?:\{\s*)?id:\s*['"]?dsh-ops-compat\b/mu.test(withoutCompatBlock(text))
109
+ }
110
+
111
+ /**
112
+ * Ensure the bundle owns the enabled state, and the profile owns only an explicit switch-off.
113
+ * @param ctx - the row's Host context.
114
+ * @param config - the live row configuration.
115
+ * @returns `{ changed, state }` for logging; never throws.
116
+ */
117
+ export async function ensureDshOpsCompat(ctx, config) {
118
+ const directory = profileDirectory(ctx)
119
+ if (directory === undefined) return { changed: false, state: 'no-profile' }
120
+ const path = join(directory, 'cordis.patch.yml')
121
+ const installed = await dshOpsInstalled(ctx)
122
+ const enabled = config?.dshOpsCompat !== false
123
+ if (!installed) {
124
+ return { changed: rewriteProfilePatch(path, (text) => withoutCompatBlock(text)), state: 'not-installed' }
125
+ }
126
+ if (enabled) {
127
+ if (bundleOrderOk(ctx)) {
128
+ // The bundle patch enables compatibility. Remove only legacy managed blocks so an old
129
+ // profile does not keep a duplicate override; a user's own entry is untouched.
130
+ const changed = rewriteProfilePatch(path, (text) => {
131
+ if (!text.includes(DSH_OPS_PATCH_MARKER) && !text.includes(DSH_OPS_DISABLE_MARKER)) return text
132
+ return withoutCompatBlock(text)
133
+ })
134
+ return { changed, state: 'bundle' }
135
+ }
136
+ // Older/foreign profiles can list pwsh7 before dsh-ops; the bundle override would be
137
+ // skipped. Keep the legacy profile enable block as a working fallback.
138
+ const changed = rewriteProfilePatch(path, (text) => {
139
+ if (hasForeignCompatEntry(text)) return text
140
+ if (text.includes(DSH_OPS_PATCH_MARKER)) return text
141
+ const base = withoutCompatBlock(text)
142
+ const normalized = base.trim() === '[]' ? '' : base
143
+ const separator = normalized === '' || normalized.endsWith('\n') ? '' : '\n'
144
+ return `${normalized}${separator}${DSH_OPS_PATCH_BLOCK}`
145
+ })
146
+ return { changed, state: 'legacy-order' }
147
+ }
148
+ const changed = rewriteProfilePatch(path, (text) => {
149
+ // Respect a user-owned override instead of fighting it with a second id.
150
+ if (hasForeignCompatEntry(text)) return withoutCompatBlock(text)
151
+ if (text.includes(DSH_OPS_DISABLE_MARKER)) return text
152
+ const base = withoutCompatBlock(text)
153
+ const normalized = base.trim() === '[]' ? '' : base
154
+ const separator = normalized === '' || normalized.endsWith('\n') ? '' : '\n'
155
+ return `${normalized}${separator}${DSH_OPS_DISABLE_BLOCK}`
156
+ })
157
+ return { changed, state: 'disabled' }
158
+ }
159
+
160
+ /** Synchronous best-effort removal for plugin disposal and config changes. */
161
+ export function removeDshOpsCompatPatch(ctx) {
162
+ const directory = profileDirectory(ctx)
163
+ if (directory === undefined) return false
164
+ try {
165
+ return rewriteProfilePatch(join(directory, 'cordis.patch.yml'), (text) => withoutCompatBlock(text))
166
+ } catch (error) {
167
+ ctx.logger?.warn?.('dsh-win-pwsh7: could not remove the dsh-ops compatibility patch: %s', describe(error))
168
+ return false
169
+ }
170
+ }
171
+
172
+ /**
173
+ * The tool-name facts of one agent, read from the host's pre-restriction view when it is
174
+ * available.
175
+ *
176
+ * `visible` is POST-restriction: hiding read/glob/grep removes them from it, so the next
177
+ * reconcile pass no longer saw a counterpart pair, lifted the mask, saw the pair again, and
178
+ * restored it. Each flip emitted `tools/change`, which called this reconcile again — an
179
+ * endless hide/unhide loop that starved the Host main thread once many agents were created.
180
+ * `knownNames` / `restrictableNames` keep the pre-restriction facts and are also the only
181
+ * names `tools.restrict()` accepts, so the deny list is stable and valid.
182
+ * @param tools - the tool registry.
183
+ * @param agent - the agent scope.
184
+ * @param previousDeny - names this bridge already hid, used to keep older hosts stable.
185
+ * @returns `{ names, restrictable }`, both empty when the surface cannot be read.
186
+ */
187
+ export function toolNameFacts(tools, agent, previousDeny = []) {
188
+ try {
189
+ const view = tools.view?.(agent)
190
+ const known = view?.knownNames
191
+ if (known !== undefined && typeof known.has === 'function') {
192
+ const restrictable = view?.restrictableNames
193
+ const facts = {
194
+ names: new Set(known),
195
+ restrictable: new Set(restrictable !== undefined && typeof restrictable.has === 'function' ? restrictable : known),
196
+ }
197
+ for (const name of previousDeny) {
198
+ facts.names.add(name)
199
+ facts.restrictable.add(name)
200
+ }
201
+ return facts
202
+ }
203
+ // Older hosts expose only the visible map or the schema projection. It is
204
+ // post-restriction, but unioning the names this bridge already hid keeps a
205
+ // hide/unhide loop from forming in the fallback path too.
206
+ const visible = view?.visible
207
+ let names
208
+ if (visible !== undefined && typeof visible.keys === 'function') {
209
+ names = new Set(visible.keys())
210
+ } else {
211
+ const schemas = tools.schemas?.(agent)
212
+ names = new Set(Array.isArray(schemas) ? schemas.map((schema) => schema.name) : [])
213
+ }
214
+ for (const name of previousDeny) names.add(name)
215
+ return { names, restrictable: new Set(names) }
216
+ } catch {
217
+ return { names: new Set(), restrictable: new Set() }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * The official tools this bridge re-hides when their OPS counterparts are visible. The
223
+ * counterpart test is deliberate: without it the bridge would hide official tools on a
224
+ * machine where dsh-ops is absent or Compatibility is off. Candidates are filtered to the
225
+ * names the host will actually accept in `tools.restrict()`, so a scope-local name such as
226
+ * a session-owned `ops_bash` can never make the whole mask throw.
227
+ * @param facts - the agent's stable tool-name facts.
228
+ * @returns the official names to deny.
229
+ */
230
+ export function bridgeDenyNames(facts) {
231
+ const { names } = facts
232
+ const deny = []
233
+ if (names.has('ops_inspect_local_file') && names.has('read')) deny.push('read')
234
+ if (names.has('ops_glob') && names.has('glob')) deny.push('glob')
235
+ if (names.has('ops_grep') && names.has('grep')) deny.push('grep')
236
+ if (names.has('ops_bash')) {
237
+ if (names.has('bash')) deny.push('bash')
238
+ // Compatibility would otherwise expose both PowerShell tools beside ops_bash; keep
239
+ // the ordinary `pwsh` and remove the persistent duplicate.
240
+ if (names.has('pwsh') && names.has('pwsh_persistent')) deny.push('pwsh_persistent')
241
+ }
242
+ return deny.filter((name) => facts.restrictable.has(name))
243
+ }
244
+
245
+ /**
246
+ * Keep the official file tools and duplicate shell out of the model's view when dsh-ops
247
+ * Compatibility is on. Agent-scoped `tools.restrict` is the only seam that hides rather
248
+ * than denies; every mask is lifted with its agent or with this effect.
249
+ * @param ctx - the row's Host context.
250
+ * @param current - reads the live row configuration.
251
+ * @returns a disposer for the bridge.
252
+ */
253
+ export function mountDshOpsBridge(ctx, current) {
254
+ if (typeof ctx.on !== 'function') return
255
+ const tools = ctx.tools ?? ctx.get('tools')
256
+ const agents = ctx.get('agents')
257
+ if (tools === undefined || agents === undefined || typeof tools.restrict !== 'function') return
258
+ const entries = new Map()
259
+ let stopped = false
260
+ let mutating = false
261
+ const lift = (entry) => {
262
+ entry.deny = []
263
+ for (const dispose of entry.lifts.splice(0)) {
264
+ try {
265
+ dispose?.()
266
+ } catch (error) {
267
+ ctx.logger?.warn?.('dsh-win-pwsh7: bridge restriction cleanup failed: %s', describe(error))
268
+ }
269
+ }
270
+ }
271
+ const reconcile = () => {
272
+ if (stopped || mutating) return
273
+ mutating = true
274
+ try {
275
+ const enabled = current()?.dshOpsCompat !== false
276
+ if (!enabled) {
277
+ for (const entry of entries.values()) lift(entry)
278
+ entries.clear()
279
+ return
280
+ }
281
+ for (const agent of agents.list?.() ?? []) {
282
+ if (!entries.has(agent)) entries.set(agent, { lifts: [], deny: [] })
283
+ }
284
+ for (const [agent, entry] of entries) {
285
+ const deny = bridgeDenyNames(toolNameFacts(tools, agent, entry.deny))
286
+ if (deny.length === entry.deny.length && deny.every((name) => entry.deny.includes(name))) continue
287
+ lift(entry)
288
+ if (deny.length === 0) continue
289
+ try {
290
+ if (agent?.ctx?.tools === undefined) continue
291
+ entry.lifts.push(agent.ctx.tools.restrict({ deny }))
292
+ entry.deny = deny
293
+ } catch (error) {
294
+ ctx.logger?.warn?.('dsh-win-pwsh7: bridge could not hide %s: %s', deny.join(', '), describe(error))
295
+ }
296
+ }
297
+ } finally {
298
+ mutating = false
299
+ }
300
+ }
301
+ const disposers = []
302
+ if (typeof ctx.on === 'function') {
303
+ disposers.push(ctx.on('agent/created', ({ agent } = {}) => {
304
+ if (agent !== undefined) entries.set(agent, { lifts: [], deny: [] })
305
+ reconcile()
306
+ }))
307
+ disposers.push(ctx.on('agent/disposed', ({ agent } = {}) => {
308
+ const entry = entries.get(agent)
309
+ if (entry !== undefined) {
310
+ lift(entry)
311
+ entries.delete(agent)
312
+ }
313
+ }))
314
+ disposers.push(ctx.on('tools/change', () => { reconcile() }))
315
+ disposers.push(ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
316
+ reconcile()
317
+ return next()
318
+ }))
319
+ disposers.push(ctx.on('app-boot/config-reload', () => { reconcile() }))
320
+ disposers.push(ctx.on('plugin-manager/changed', () => { reconcile() }))
321
+ }
322
+ reconcile()
323
+ return () => {
324
+ stopped = true
325
+ for (const dispose of disposers) dispose?.()
326
+ for (const entry of entries.values()) lift(entry)
327
+ entries.clear()
328
+ }
329
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-win-pwsh7",
3
- "version": "0.2.0",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "description": "Detect and validate PowerShell 7 for the Harness pwsh tool on Windows, and install it on request through the Microsoft Store — scripted or manual — while the pwsh tool stays blocked until a PowerShell 7 runtime actually resolves.",
6
6
  "engines": {
@@ -32,6 +32,7 @@
32
32
  "index.js",
33
33
  "client.js",
34
34
  "lib/typert.host.js",
35
+ "lib/dsh-ops-bridge.js",
35
36
  "cordis.patch.yml",
36
37
  "locale/*.json",
37
38
  "icon.svg",
@@ -64,7 +65,8 @@
64
65
  "immediately": true,
65
66
  "inject": [
66
67
  "@deepseek-ai/dsh-client-ui-plugin-manager",
67
- "@deepseek-ai/dsh-api-remotes"
68
+ "@deepseek-ai/dsh-api-remotes",
69
+ "@deepseek-ai/dsh-client-ui-primitives"
68
70
  ]
69
71
  }
70
72
  }
@@ -38,6 +38,18 @@ const React = {
38
38
  },
39
39
  }
40
40
 
41
+ /** The native Switch primitive, rendered as the button role the real component uses. */
42
+ function Switch({ checked, onChange, label, disabled }) {
43
+ return React.createElement('button', {
44
+ type: 'button',
45
+ role: 'switch',
46
+ 'aria-checked': checked,
47
+ 'aria-label': label,
48
+ disabled,
49
+ onClick: () => onChange(!checked),
50
+ })
51
+ }
52
+
41
53
  /** Every string rendered anywhere in the tree, in document order. */
42
54
  function textsOf(node, found = []) {
43
55
  if (typeof node === 'string' || typeof node === 'number') {
@@ -119,6 +131,7 @@ await import('../client.js')
119
131
  assert.ok(entry !== undefined, 'client.js did not call window.__ModuleLoader__.load()')
120
132
  const client = entry.factory((name) => {
121
133
  if (name === 'react') return React
134
+ if (name === '@deepseek-ai/dsh-client-ui-primitives') return { Switch }
122
135
  throw new Error(`the client half required an unexpected module: ${name}`)
123
136
  })
124
137
  client.apply(ctx)
@@ -141,34 +154,41 @@ const cardTree = card({ view: 'page' })
141
154
  const cardTexts = textsOf(cardTree)
142
155
 
143
156
  check('the card renders its labels from the dictionaries, never as bare config keys', () => {
144
- for (const key of ['overridePath']) {
157
+ for (const key of ['overridePath', 'dshOpsCompat']) {
145
158
  assert.ok(!cardTexts.includes(key),
146
159
  `the card rendered the bare config key "${key}" — a label was written as a literal instead of t()`)
147
160
  }
148
- for (const expected of [dictionaries.zh.field_overridePath]) {
161
+ for (const expected of [dictionaries.zh.field_overridePath, dictionaries.zh.field_dshOpsCompat]) {
149
162
  assert.ok(cardTexts.includes(expected), `the card does not render the label "${expected}"`)
150
163
  }
151
164
  })
152
- check('each field label also keeps its raw config key, so the form maps to cordis.patch.yml', () => {
153
- const labels = elementsOf(cardTree, 'label').map((element) => textsOf(element).join(''))
154
- assert.equal(labels.length, 1, `expected one label, found ${labels.length}`)
155
- for (const [index, key] of ['overridePath'].entries()) {
156
- assert.match(labels[index], new RegExp(key, 'u'), `label ${index} drops the raw key ${key}`)
157
- assert.match(labels[index], /[\u4e00-\u9fff]/u, `label ${index} is not localized: ${labels[index]}`)
158
- }
165
+ check('the coexist row uses the native Switch primitive, not a custom checkbox', () => {
166
+ const switches = elementsOf(cardTree, Switch)
167
+ assert.equal(switches.length, 1, `expected one native switch, found ${switches.length}`)
168
+ assert.equal(switches[0].props.checked, true)
169
+ assert.equal(switches[0].props.label, dictionaries.zh.field_dshOpsCompat)
159
170
  })
160
171
  check('the card renders every hint in Chinese', () => {
161
- for (const hint of [dictionaries.zh.overridePath_hint]) {
172
+ for (const hint of [dictionaries.zh.overridePath_hint, dictionaries.zh.dshOpsCompat_hint]) {
162
173
  assert.ok(cardTexts.includes(hint), `the card does not render the hint "${hint.slice(0, 24)}…"`)
163
174
  }
164
175
  })
165
176
  check('the summary view renders nothing, as the row list expects', () => {
166
177
  assert.equal(card({ view: 'summary' }), null)
167
178
  })
168
- check('the configure surface is one flat section, not a card inside the native page', () => {
179
+ check('the configure surface uses the dsh-ops card language', () => {
180
+ // Aligned with the installed dsh-ops branch: one hairline card per group, a shaded
181
+ // card body for editable fields, and the toggle in the card head.
169
182
  const sections = elementsOf(cardTree, 'section')
170
- assert.equal(sections.length, 1, `expected one section, found ${sections.length}`)
171
- assert.equal(sections[0].props.className, 'dw7-section')
183
+ assert.equal(sections.length, 2, `expected two cards, found ${sections.length}`)
184
+ for (const section of sections) {
185
+ assert.match(String(section.props.className), /\bdw7-card\b/u)
186
+ assert.match(String(section.props.className), /\bdw7-group\b/u)
187
+ }
188
+ const bodies = elementsOf(cardTree, 'div').filter(node => node.props.className === 'dw7-card-body')
189
+ assert.equal(bodies.length, 1, `expected one shaded body for the editable card, found ${bodies.length}`)
190
+ assert.ok(!elementsOf(cardTree, 'section').some(node => /\bdw7-section\b/u.test(String(node.props.className))),
191
+ 'the retired flat section class survived')
172
192
  })
173
193
 
174
194
  // A second application with no controller for this row — the state that used to throw inside the
@@ -245,16 +265,28 @@ check('the en dictionary carries no Chinese', () => {
245
265
  const cjk = en.match(/[\u4e00-\u9fff]/gu)
246
266
  assert.equal(cjk, null, `Chinese in the en dictionary: ${cjk === null ? '' : cjk.join('')}`)
247
267
  })
248
- check('one flat stylesheet covers both sections and keeps the native control scale', () => {
268
+ check('one card-language stylesheet covers both surfaces', () => {
249
269
  assert.ok(/const CSS = `/u.test(source), 'the shared stylesheet is missing')
250
270
  assert.ok(!/CSS_CARD|CSS_DIAG/u.test(source), 'the old per-card stylesheets survived')
251
- for (const selector of ['.dw7-section{', '.dw7-status{', '.dw7-estimate{', '.dw7-facts{', '.dw7-input{', '.dw7-save{', '.dw7-small{', '.dw7-code{']) {
271
+ for (const selector of ['.dw7-card{', '.dw7-card-body{', '.dw7-group-title{', '.dw7-detail{', '.dw7-status-card{',
272
+ '.dw7-status-body{', '.dw7-facts{', '.dw7-input{', '.dw7-save{', '.dw7-small{', '.dw7-code{']) {
252
273
  assert.ok(source.includes(selector), `the stylesheet does not define ${selector}`)
253
274
  }
275
+ // The dsh-ops branch's card chrome: a 0.5px hairline on radius-lg with a shaded body,
276
+ // 13px/600 group titles, and description-list labels sized to their own text.
277
+ assert.ok(source.includes('border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);padding:14px;gap:10px;'),
278
+ 'the card chrome no longer matches the dsh-ops card')
279
+ assert.ok(source.includes('background:var(--dsw-alias-interactive-bg-hover);'),
280
+ 'the card body no longer uses the sunken surface')
281
+ assert.ok(source.includes('font-size:13px;font-weight:600;line-height:20px;'),
282
+ 'the group title metrics no longer match the dsh-ops card')
283
+ assert.ok(source.includes('grid-template-columns:max-content minmax(0,1fr);align-items:baseline;column-gap:14px;'),
284
+ 'the fact list no longer uses the native max-content label column')
254
285
  const start = source.indexOf('const CSS = `')
255
286
  const css = source.slice(start, source.indexOf('`', start + 20))
256
287
  assert.ok(!/#[0-9a-f]{3,8}\b/iu.test(css), 'the stylesheet hard-codes a color instead of using a theme token')
257
288
  })
289
+
258
290
  check('no metered-network copy or branch remains', () => {
259
291
  assert.ok(!/metered/iu.test(source), 'the removed metered-network feature still has copy or a branch')
260
292
  })
@@ -12,7 +12,7 @@
12
12
  * from a table; the install checks run the job body against that stub, never Microsoft Store.
13
13
  */
14
14
  import { register } from 'node:module'
15
- import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
15
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
16
16
  import { dirname, join } from 'node:path'
17
17
  import { tmpdir } from 'node:os'
18
18
  import assert from 'node:assert/strict'
@@ -52,6 +52,7 @@ process.env.LOCALAPPDATA = gateHome
52
52
  process.env.PATH = join(gateHome, 'bin')
53
53
 
54
54
  const { apply } = await import('../index.js')
55
+ const { bridgeDenyNames, toolNameFacts } = await import('../lib/dsh-ops-bridge.js')
55
56
 
56
57
  /** An ordinary executable that exists on every Windows machine; its version comes from the stub. */
57
58
  const ORDINARY = 'C:\\Windows\\System32\\cmd.exe'
@@ -78,7 +79,7 @@ function makeStoreFixtures() {
78
79
  * A stub Host context: records registrations, prompt sections and job specs, answers probes,
79
80
  * spawns nothing. `jobs: false` omits the job registry.
80
81
  */
81
- function makeContext({ rows = [], versions = {}, jobs = false, shellPath, shellObject } = {}) {
82
+ function makeContext({ rows = [], versions = {}, jobs = false, shellPath, shellObject, pluginManager, agents, visible } = {}) {
82
83
  const registered = new Map()
83
84
  const sections = []
84
85
  const orderNames = []
@@ -105,6 +106,8 @@ function makeContext({ rows = [], versions = {}, jobs = false, shellPath, shellO
105
106
  plugin() {},
106
107
  get: (key) => {
107
108
  if (key === 'configForms') return { get: () => ({}) }
109
+ if (key === 'pluginManager') return pluginManager
110
+ if (key === 'agents') return agents
108
111
  if (key === 'jobs' && jobs) return jobRegistry
109
112
  // The pwsh tool executes through `ctx.shell`; the executor's own `pwshPath` is the
110
113
  // authoritative "what will run" fact. A context without it keeps the row-based reading.
@@ -129,6 +132,13 @@ function makeContext({ rows = [], versions = {}, jobs = false, shellPath, shellO
129
132
  if (index >= 0) guards.splice(index, 1)
130
133
  }
131
134
  },
135
+ restrict() { return () => {} },
136
+ view() {
137
+ return { visible: new Map((visible ?? []).map((name) => [name, {}])) }
138
+ },
139
+ schemas() {
140
+ return (visible ?? []).map((name) => ({ name }))
141
+ },
132
142
  },
133
143
  on(event, listener) {
134
144
  const set = listeners.get(event) ?? new Set()
@@ -641,6 +651,150 @@ if (existsSync(MSI)) {
641
651
  else process.env.ProgramFiles = savedProgramFiles
642
652
  }
643
653
 
654
+ // --- dsh-ops compatibility patch ---------------------------------------------------------
655
+ const compatDir = mkdtempSync(join(tmpdir(), 'dsh-win-pwsh7-compat-'))
656
+ const compatPatchPath = join(compatDir, 'cordis.patch.yml')
657
+ writeFileSync(compatPatchPath, '# dsh-win-pwsh7: dsh-ops compatibility (managed)\n- id: dsh-ops-compat\n disabled: false\n- id: keep-me\n name: keep-me\n', 'utf8')
658
+ const savedProfileDir = process.env.DSH_PROFILE_DIR
659
+ process.env.DSH_PROFILE_DIR = compatDir
660
+ const compatMachine = makeContext({
661
+ pluginManager: {
662
+ async listBundles() {
663
+ return [{ name: 'dsh-ops', installed: true, rows: [{ moduleName: 'dsh-ops/compat' }] }]
664
+ },
665
+ },
666
+ })
667
+ apply(compatMachine.ctx, {})
668
+ await new Promise((resolve) => setTimeout(resolve, 50))
669
+ const compatWritten = readFileSync(compatPatchPath, 'utf8')
670
+ check('the enabled path delegates to the bundle and removes a legacy managed override', () => {
671
+ assert.ok(!compatWritten.includes('dsh-win-pwsh7: dsh-ops compatibility'), 'a managed block survived')
672
+ assert.ok(!compatWritten.includes('- id: dsh-ops-compat'), 'the bundle-owned override was written into the profile')
673
+ assert.match(compatWritten, /- id: keep-me/u, 'the user patch entry was lost')
674
+ })
675
+ apply(compatMachine.ctx, { dshOpsCompat: false })
676
+ await new Promise((resolve) => setTimeout(resolve, 50))
677
+ const compatDisabled = readFileSync(compatPatchPath, 'utf8')
678
+ check('turning coexistence off writes only the managed disable override', () => {
679
+ assert.match(compatDisabled, /dsh-win-pwsh7: dsh-ops compatibility disabled \(managed\)/u)
680
+ assert.match(compatDisabled, /- id: dsh-ops-compat\n disabled: true/u)
681
+ assert.ok(!compatDisabled.includes('dsh-win-pwsh7: dsh-ops compatibility (managed)'), 'the enable marker survived')
682
+ assert.ok(compatDisabled.includes('- id: keep-me'), 'the user patch entry was removed')
683
+ })
684
+ apply(compatMachine.ctx, { dshOpsCompat: true })
685
+ await new Promise((resolve) => setTimeout(resolve, 50))
686
+ const compatReenabled = readFileSync(compatPatchPath, 'utf8')
687
+ check('turning coexistence back on removes the disable override for the bundle', () => {
688
+ assert.ok(!compatReenabled.includes('dsh-win-pwsh7: dsh-ops compatibility'), 'a managed block survived')
689
+ assert.ok(!compatReenabled.includes('- id: dsh-ops-compat'), 'the bundle-owned override was written into the profile')
690
+ assert.ok(compatReenabled.includes('- id: keep-me'), 'the user patch entry was removed')
691
+ })
692
+ if (savedProfileDir === undefined) delete process.env.DSH_PROFILE_DIR
693
+ else process.env.DSH_PROFILE_DIR = savedProfileDir
694
+ rmSync(compatDir, { recursive: true, force: true })
695
+
696
+ check('the bundle patch owns the enabled compatibility override', () => {
697
+ const patch = readFileSync(new URL('../cordis.patch.yml', import.meta.url), 'utf8')
698
+ assert.match(patch, /^- id: dsh-ops-compat\n name: dsh-ops\/compat\n disabled: false$/mu)
699
+ })
700
+
701
+ const orderDir = mkdtempSync(join(tmpdir(), 'dsh-win-pwsh7-order-'))
702
+ writeFileSync(join(orderDir, 'package.json'), JSON.stringify({
703
+ dsh: { profile: { bundles: ['dsh-win-pwsh7', 'dsh-ops'] } },
704
+ }), 'utf8')
705
+ writeFileSync(join(orderDir, 'cordis.patch.yml'), '- id: keep-me\n name: keep-me\n', 'utf8')
706
+ const savedOrderDir = process.env.DSH_PROFILE_DIR
707
+ process.env.DSH_PROFILE_DIR = orderDir
708
+ const orderMachine = makeContext({
709
+ pluginManager: {
710
+ async listBundles() {
711
+ return [{ name: 'dsh-ops', installed: true, rows: [{ moduleName: 'dsh-ops/compat' }] }]
712
+ },
713
+ },
714
+ })
715
+ apply(orderMachine.ctx, {})
716
+ await new Promise((resolve) => setTimeout(resolve, 50))
717
+ const orderPatch = readFileSync(join(orderDir, 'cordis.patch.yml'), 'utf8')
718
+ check('a reversed bundle order keeps the profile fallback instead of silently disabling compatibility', () => {
719
+ assert.match(orderPatch, /dsh-win-pwsh7: dsh-ops compatibility \(managed\)/u)
720
+ assert.match(orderPatch, /- id: dsh-ops-compat\n disabled: false/u)
721
+ assert.ok(orderPatch.includes('- id: keep-me'))
722
+ })
723
+ if (savedOrderDir === undefined) delete process.env.DSH_PROFILE_DIR
724
+ else process.env.DSH_PROFILE_DIR = savedOrderDir
725
+ rmSync(orderDir, { recursive: true, force: true })
726
+
727
+ // --- agent bridge hides only the duplicate tools ------------------------------------------
728
+ const bridgeDenies = []
729
+ const bridgeAgent = {
730
+ ctx: {
731
+ tools: {
732
+ restrict({ deny }) {
733
+ bridgeDenies.push(deny)
734
+ return () => {}
735
+ },
736
+ },
737
+ },
738
+ }
739
+ const bridgeMachine = makeContext({
740
+ agents: { list: () => [bridgeAgent] },
741
+ visible: [
742
+ 'read', 'ops_inspect_local_file',
743
+ 'glob', 'ops_glob',
744
+ 'grep', 'ops_grep',
745
+ 'bash', 'pwsh', 'pwsh_persistent', 'ops_bash',
746
+ ],
747
+ })
748
+ apply(bridgeMachine.ctx, {})
749
+ check('dsh-ops bridge hides duplicate official tools but keeps pwsh', () => {
750
+ const deny = bridgeDenies.at(-1) ?? []
751
+ for (const name of ['read', 'glob', 'grep', 'bash', 'pwsh_persistent']) {
752
+ assert.ok(deny.includes(name), `bridge did not hide ${name}: ${deny.join(', ')}`)
753
+ }
754
+ assert.ok(!deny.includes('pwsh'), 'bridge hid the native pwsh tool')
755
+ assert.ok(!deny.includes('ops_bash'), 'bridge hid ops_bash')
756
+ })
757
+
758
+ const bridgeNames = new Set([
759
+ 'read', 'ops_inspect_local_file',
760
+ 'glob', 'ops_glob',
761
+ 'grep', 'ops_grep',
762
+ 'bash', 'pwsh', 'pwsh_persistent', 'ops_bash',
763
+ ])
764
+ const bridgeRestrictable = new Set([...bridgeNames].filter((name) => name !== 'ops_bash'))
765
+ const bridgeTools = {
766
+ view() {
767
+ return { knownNames: bridgeNames, restrictableNames: bridgeRestrictable }
768
+ },
769
+ }
770
+ check('the bridge plans from pre-restriction names, so hiding cannot flip it', () => {
771
+ const first = bridgeDenyNames(toolNameFacts(bridgeTools, {}, []))
772
+ assert.deepEqual(first, ['read', 'glob', 'grep', 'bash', 'pwsh_persistent'])
773
+ // `visible` is now post-restriction; knownNames is unchanged, as the host's view guarantees.
774
+ const second = bridgeDenyNames(toolNameFacts(bridgeTools, {}, first))
775
+ assert.deepEqual(second, first)
776
+ })
777
+
778
+ check('the fallback view latches the names already hidden instead of oscillating', () => {
779
+ const visible = new Set(bridgeNames)
780
+ const fallbackTools = {
781
+ view() {
782
+ return { visible: new Map([...visible].map((name) => [name, {}])) }
783
+ },
784
+ }
785
+ const first = bridgeDenyNames(toolNameFacts(fallbackTools, {}, []))
786
+ for (const name of first) visible.delete(name)
787
+ const second = bridgeDenyNames(toolNameFacts(fallbackTools, {}, first))
788
+ assert.deepEqual(second, first)
789
+ })
790
+
791
+ check('a scope-local name cannot make the whole restriction plan throw', () => {
792
+ const facts = toolNameFacts(bridgeTools, {}, [])
793
+ const deny = bridgeDenyNames({ ...facts, restrictable: new Set([...bridgeRestrictable].filter((name) => name !== 'glob')) })
794
+ assert.ok(!deny.includes('glob'), 'a non-restrictable name survived the filter')
795
+ assert.ok(!deny.includes('ops_bash'), 'a scope-local counterpart was put in the deny list')
796
+ })
797
+
644
798
  // --- The client half's policy copy must not drift (static; no browser needed) -------------
645
799
 
646
800
  const client = readFileSync(new URL('../client.js', import.meta.url), 'utf8')