dsh-gitbash-shell 0.45.0 → 0.47.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/CHANGELOG.md CHANGED
@@ -3,6 +3,41 @@
3
3
  > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。
4
4
  > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。
5
5
 
6
+ ## [0.47.0] — 2026-10-30
7
+
8
+ ### feat:适配 dsh 0.2.2-alpha.2(下限抬到 alpha.2,清旧宿主回退)
9
+
10
+ - **宿主下限**:`engines.dsh`、peer `@deepseek-ai/dsh`、`dsh.compatibility.dsh` 统一到 `>=0.2.2-alpha.2`,`dshReleases` 只保留 alpha.2。本版组合只产出 alpha.2 形状,不再为 0.1.7–0.2.1 保留 `tool-presentation(mode: ptc)` 回退。
11
+ - **组合跟随(无条件)**:PTC 变体固定挂 `tool-executor-ptc`;`tool-subagent-list-agents` 行删除(list_agents 已并入 `tool-subagent-control`);`tool-subagent` 固定 `modelSelection` + `modelDiscovery`;`tool-plugin-manager` / `tool-plugin-market` 随 `profileContext` live 服务启停。time-context / tool-schedule / plugin-market 的包探针保留:包缺失只少行、不拒绝整棵挂载。
12
+ - **assemble 工具面**:0.2.2-alpha.2 的 `system-prompt/assemble` 只带 `toolSet`,不再带 `tools`;`runCodeHintFor` / `applyToolRouting` 改为读 `assembleContext.toolSet` 并调用 `ctx.get('tools').schemas(agent, toolSet)`,不再回退全量 `schemas(agent)`。
13
+ - **测试(342 pass + 1 平台 skip)**:compatibility-regressions 的监听器夹具改为 `toolSet` + `ctx.get('tools').schemas`;smoke 的旧宿主回退断言替换为 alpha.2 无条件形态;market 在无 profileContext 时默认 disabled。
14
+
15
+ ## [0.46.0] — 2026-10-28
16
+
17
+ ### fix:跟随 dsh-ops 0.4.3 的 preset-mode 行集(ops_bash 剥离不再失效)
18
+
19
+ - **背景(dsh-ops 0.4.3 Breaking change)**:dsh-ops 把七个组件行换成了八行 preset-mode 开关
20
+ (`dsh-ops-mode-minimal` / `-standard` / `-ptc` / `-cordis` / `dsh-ops-discovery` /
21
+ `dsh-ops-compat` / `dsh-ops-panel` / `dsh-ops-tui`),旧的 `dsh-ops-shell` / `dsh-ops-file` /
22
+ `dsh-ops-ptc` / `dsh-ops-hide-official` 行在 bundle 中不再存在。v0.42.0 的剥离逻辑只认
23
+ `dsh-ops-shell` 一行,升级 0.4.3 后 `planOpsAdopt` 恒为 `skip-row-absent`——配置层的
24
+ `ops_bash` 剥离静默失效(可见性层面只剩本插件对 `ops_bash`/`ops_run` 的逐 agent deny 兜底)。
25
+ - **修复**:`src/ops-adopt.js` 的行集扩为 `OPS_SHELL_ROW_IDS`(旧行 + 四个 mode 行)。四行都
26
+ 可能发 Windows shell(dsh-ops `MODE_PLANS[*].shell = true`),因此对**在场的每一行**都写
27
+ `publishBashTool=false`;行不存在绝不写,混合 profile(旧行 + 新行同时在场,或只装旧版 dsh-ops)
28
+ 也能正确处理。写入/回读/还原仍是官方 `configEditor` 通道,回读逐行验证;`disabled` 的 mode 行
29
+ 不写(它本来就不发布),但还原仍会撤回我们留在其上的值。日志行会列出实际处理的每个行 id。
30
+ - **还原**:dispose 钩子逐行撤回我们写的 `false`(恢复 inherited 值或删键),用户自己把值改回
31
+ `true` 的行视为其选择、不动(与 v0.42.0 同规则)。
32
+ - **边界(写进 README)**:dsh-ops 0.4.3+ 还按 **preset id** 门控工具,只认官方四个 preset;
33
+ 本插件的 `ptc-cordis` 与「· Git Bash」变体 id 不在其中 → 那些会话里 dsh-ops 既不发布也不隐藏
34
+ 任何工具,我们的 bash 是唯一命令工具,四条 mode 行的开关在那里不会触发。旧版 dsh-ops 升级到
35
+ 0.4.3 时,profile patch 里遗留的旧行覆盖会产生 dsh-ops 自己的 `patch: entry ... not found`
36
+ 警告——那是 dsh-ops 官方升级须知里要求手动删除的残留,与本插件的写入无关(我们只写活行)。
37
+ - **测试(343 项:342 pass + 1 平台 skip)**:`tests/ops-adopt.mjs` 新增四组 case——行集完整性
38
+ (旧行 + 四 mode 行、顺序)、0.4.3 profile 逐行写入 + 日志行 id、混合 profile 只写仍在发布的行、
39
+ disabled 行不写且全 disabled 静默收口、逐行还原(恢复 inherited / 删键 / 保留用户改成 true 的行)。
40
+
6
41
  ## [0.45.0] — 2026-10-28
7
42
 
8
43
  ### feat:bash 初始化脚本 `initScript`(BASH_ENV;issue #37 的 fnm/nvm 场景)
package/README.md CHANGED
@@ -47,23 +47,26 @@ POSIX 宿主上完整保留;
47
47
  `/c/` 方言统一:`ops_*` 的入参(含 `root`/`operations[].path`/`protect`)
48
48
  自动转换、返回与报错路径自动回流 `/c/`。
49
49
 
50
- - **与 dsh-ops 并存:bash 只留一份(v0.42.0)**:宿主机制决定「可见但拒绝」到不了「看不见」——
50
+ - **与 dsh-ops 并存:bash 只留一份(v0.42.0;v0.46.0 跟随 0.4.3 行集)**:宿主机制决定「可见但拒绝」到不了「看不见」——
51
51
  `tools.restrict` 只能过滤继承面,而 `ops_bash` 注册在 agent 自有层。于是本插件直接接管 dsh-ops
52
52
  自带的 `publishBashTool` 开关(经官方 configEditor 通道写为 `false`):安装本插件时 `ops_bash`
53
53
  不再出现在模型工具面;**ops 的 pwsh7 overlay(给官方 pwsh 工具供路径)与文件/后台增强不受影响**
54
54
  ——publishBashTool 的门只套在 `mountSessionBash` 上,pwsh 走 `mountBundledPwsh` 独立路径。
55
- **`ops_run` 无需处理:它在 dsh-ops 的 bundle 装配中本就没有发布路径**——bundle 只有
56
- shell/file/background/ptc 四行,全插件唯一一处 `new FastCtxTools` 只在 background 组件挂
57
- 后台四件套(`run_background`/`job_*`),前台 `ops_run` 只是未被任何调用点使用的默认参数;
58
- 本机 `dsh-ops-background` 行默认关闭,后台四件套同样不存在。
55
+ **行集随 dsh-ops 0.4.3 变化**:旧版开关在 `dsh-ops-shell` 组件行上;0.4.3 起该行消失,改成四个
56
+ preset-mode 行 `dsh-ops-mode-minimal` / `-standard` / `-ptc` / `-cordis`(四行的执行计划都含
57
+ Windows shell,都能发 `ops_bash`),本插件对**在场的每一行**都写 `false`,旧行仍在的混合 profile
58
+ 也照样处理。**`ops_run` 无需处理:它在 dsh-ops 的 bundle 装配中本就没有发布路径**——bundle 只有
59
+ mode/discovery/compat/panel/tui 八行,前台 `ops_run` 只是未被任何调用点使用的默认参数。
59
60
  **停用或卸载本插件的瞬间自动还原**(dispose 钩子撤回覆盖,同 row 的其它设置原样保留),重新启用
60
- 又会自动接管。两个边界:①关机状态下卸载的残余覆盖,用 dsh-ops 设置卡「恢复默认」一键撤回;
61
- ②停用时若覆盖值已被你手动改回 `true`,视为你的选择、不动它。无设置开关——想用 `ops_bash`
62
- 就停用本插件,还原是自动的。
63
- **后台任务与文件/PTC 增强全部让给 dsh-ops(2026-10-19 用户决策)**:剔除清单被守卫测试
64
- 锁死为 `ops_bash` + `ops_run` 两项,永不含 `ops_run_background`/`ops_job_*`——你启用
65
- dsh-ops 的「后台任务」组件后,这组全局工具在普通会话与 PTC 创造模式下原样可见可用,
66
- 本插件不做任何 deny。bash 归 gitbash,后台与增强归 dsh-ops,职责互不越界。
61
+ 又会自动接管。三个边界:①关机状态下卸载的残余覆盖,用 dsh-ops 设置卡「恢复默认」一键撤回;
62
+ ②停用时若覆盖值已被你手动改回 `true`,视为你的选择、不动它;③**dsh-ops 0.4.3 起按 preset id
63
+ 门控**:它只认官方四个 preset,本插件的 `ptc-cordis` 与「· Git Bash」变体 id 不在其中——那些
64
+ 会话里 dsh-ops 什么也不发布(也不隐藏官方 pwsh/read/glob/grep),我们的 bash 是唯一命令工具。
65
+ 无设置开关——想用 `ops_bash` 就停用本插件,还原是自动的。
66
+ **后台任务与文件/PTC 增强全部让给 dsh-ops(2026-10-19 用户决策)**:剔除清单被守卫测试
67
+ 锁死为 `ops_bash` + `ops_run` 两项,永不含 `ops_run_background`/`ops_job_*`——你启用
68
+ dsh-ops 的「后台任务」组件后,这组全局工具在普通会话与 PTC 创造模式下原样可见可用,
69
+ 本插件不做任何 deny。bash 归 gitbash,后台与增强归 dsh-ops,职责互不越界。
67
70
 
68
71
  四个「· Git Bash」**变体 preset 仍保留,默认关闭**(`variantPresets` 开关,
69
72
  设置卡可开):开启后注册 `standard-gitbash`/`minimal-gitbash`/`code-gitbash`/
package/README_EN.md CHANGED
@@ -215,6 +215,32 @@ Two boundaries: whatever the script prints lands in the command's output (do not
215
215
  in it), and the file must exist — bash silently ignores a missing `BASH_ENV`, so a typo
216
216
  behaves exactly like "not set". `~` expands to the home directory.
217
217
 
218
+ ## Cooperation with dsh-ops: one bash per face (v0.42.0; rows follow 0.4.3)
219
+
220
+ The host cannot turn "visible but denied" into "hidden": `tools.restrict` filters
221
+ only the inherited surface, while dsh-ops registers `ops_bash` in each agent's
222
+ OWN layer. So this plugin drives dsh-ops's own `publishBashTool` switch instead —
223
+ `false`, through the official `configEditor` channel — and `ops_bash` stops
224
+ being registered at all. dsh-ops keeps its pwsh7 overlay, file tools and
225
+ background jobs (`publishBashTool` gates `mountSessionBash` only).
226
+
227
+ **dsh-ops 0.4.3 replaced the component roster with preset-mode rows.** The
228
+ switch used to live on the single `dsh-ops-shell` row; today four rows carry it —
229
+ `dsh-ops-mode-minimal`, `-standard`, `-ptc`, `-cordis` — and every one of them
230
+ plans a Windows shell (`MODE_PLANS[*].shell`), so every one can publish
231
+ `ops_bash`. v0.46.0 walks the whole set: the legacy row when an older dsh-ops is
232
+ mounted, the four mode rows on 0.4.3+, and a mixed profile handles whichever
233
+ rows are actually mounted. Disabling or uninstalling this plugin lifts the
234
+ override again (dispose hook) and leaves every unrelated setting in the same row
235
+ untouched.
236
+
237
+ Two edges: (1) an override left behind by uninstalling while dsh was shut down
238
+ is one click away from gone — dsh-ops's own settings card offers "restore
239
+ defaults" for each row; (2) dsh-ops 0.4.3+ also gates its tools on the agent's
240
+ preset id and knows only the four official ones — this plugin's `ptc-cordis`
241
+ preset and the `· Git Bash` variant ids match no mode, so dsh-ops publishes
242
+ nothing there (and hides nothing either): our bash is the only command tool.
243
+
218
244
  ## Cooperation with dsh-ptc-cordis-preset
219
245
 
220
246
  This plugin publishes a `gitBash` host capability (`{ active, bashPath }`,
package/dsh.plugin.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-gitbash-shell",
3
- "version": "0.45.0",
3
+ "version": "0.47.0",
4
4
  "main": "./src/index.js",
5
5
  "description": "Windows 全模式 Git Bash:自带 bash 工具(私有 Git Bash 执行器实例,官方 PowerShell 执行器原样保留),官方四个模式经行内工具补位直接获得 Git Bash(v0.35.0 起;v0.39.0 起不再替换宿主执行器),四个 *·Git Bash 变体 preset 可开关;官方 pwsh 工具默认隐藏、开关可见(真 PowerShell);附带 gb_* FastCtx 文件工具,检测到 dsh-ops 自动让位。联动:dsh-ptc-cordis-preset(PTC 创造模式带 Git Bash 行且可去重)+ dsh-better-sidebar(终端标签自动切 Git Bash)。",
6
6
  "engines": {
7
- "dsh": ">=0.1.7-rc"
7
+ "dsh": ">=0.2.2-alpha.2"
8
8
  },
9
9
  "contributes": {
10
10
  "tools": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gitbash-shell",
3
- "version": "0.45.0",
3
+ "version": "0.47.0",
4
4
  "description": "Windows 上给 agent 一个真正的 Git Bash 命令工具:私有 Git Bash 执行器实例(官方 PowerShell 执行器原样保留),统一 MSYS 盘根路径方言,官方 pwsh 工具默认隐藏可开关,物化四个内置预设的 Git Bash 变体,侧栏终端接管可开关。· Give agents a real Git Bash tool on Windows: a private Git Bash executor instance (the official PowerShell executor stays untouched), a unified MSYS drive-root path dialect, an opt-in official pwsh tool, and four Git Bash preset variants.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "engines": {
19
19
  "node": ">=18",
20
- "dsh": ">=0.1.7-rc"
20
+ "dsh": ">=0.2.2-alpha.2"
21
21
  },
22
22
  "dsh": {
23
23
  "bundle": {
@@ -32,15 +32,9 @@
32
32
  "platform": "web"
33
33
  },
34
34
  "compatibility": {
35
- "dsh": ">=0.1.7-rc",
35
+ "dsh": ">=0.2.2-alpha.2",
36
36
  "dshReleases": {
37
- "0.2.0-rc.2": "compatible",
38
- "0.2.1-alpha.1": "compatible",
39
- "0.2.1-alpha.2": "compatible",
40
- "0.2.1-alpha.3": "compatible",
41
- "0.2.1-alpha.4": "compatible",
42
- "0.2.1-alpha.5": "compatible",
43
- "0.2.1-rc.1": "compatible"
37
+ "0.2.2-alpha.2": "compatible"
44
38
  }
45
39
  }
46
40
  },
@@ -72,7 +66,7 @@
72
66
  "license": "MIT",
73
67
  "peerDependencies": {
74
68
  "@deepseek-ai/cordis": "^4.0.1",
75
- "@deepseek-ai/dsh": ">=0.1.7-rc",
69
+ "@deepseek-ai/dsh": ">=0.2.2-alpha.2",
76
70
  "@deepseek-ai/schemastery": "^3.18.1"
77
71
  },
78
72
  "peerDependenciesMeta": {
@@ -90,19 +90,18 @@ export function pluginsFor({ kind, gitBash, skillsDir, pythonActive = false, hos
90
90
  // toolFilter deny from the subagent/subagent_fork configs (official
91
91
  // reminders reach subagents again — the deny was never load-bearing,
92
92
  // nothing replaces it); alpha.4 added a tool-plugin-market row to every
93
- // full preset. 0.2.2-alpha.2 then reworked the tool execution architecture
94
- // (`executorPlugin`): the official executors moved into explicit
95
- // tool-executor-native/-ptc rows, the subagent config renamed
96
- // `modelSelectionSettings` to `modelSelection` + opt-in `modelDiscovery`,
97
- // list-agents folded into the tool-subagent-control row itself (its
98
- // separate row is gone), and the manager/market rows enable under a
99
- // profile context instead of staying off outside cordis. A preset row whose
100
- // package is absent rejects the WHOLE mount, so these ride a host probe
101
- // (index.js probeHostExtras, resolved from ctx.baseUrl — the base rows
102
- // mount from); absent/older hosts keep the byte-identical earlier row set.
103
- // The minimal variant carries its own 0.2.1-alpha.3 delta (tool-fs +
104
- // compaction) behind the compaction flag.
105
- const { timeContext = false, toolSchedule = false, pluginMarket = false, compaction = false, executorPlugin = false, profileContext = false } = hostExtras
93
+ // full preset. 0.2.2-alpha.2 then reworked the tool execution architecture:
94
+ // the official executors moved into explicit tool-executor-native/-ptc
95
+ // rows, the subagent config renamed `modelSelectionSettings` to
96
+ // `modelSelection` + opt-in `modelDiscovery`, list-agents folded into the
97
+ // tool-subagent-control row itself (its separate row is gone), and the
98
+ // manager/market rows enable only under a profile context. That shape is
99
+ // unconditional now — the plugin's lower bound is 0.2.2-alpha.2. Optional
100
+ // 0.2.1 additions still ride a host probe (index.js probeHostExtras,
101
+ // resolved from ctx.baseUrl — the base rows mount from). The minimal
102
+ // variant carries its own 0.2.1-alpha.3 delta (tool-fs + compaction) behind
103
+ // the compaction flag.
104
+ const { timeContext = false, toolSchedule = false, pluginMarket = false, compaction = false, profileContext = false } = hostExtras
106
105
  const rows = [
107
106
  {
108
107
  id: 'persona',
@@ -176,14 +175,11 @@ export function pluginsFor({ kind, gitBash, skillsDir, pythonActive = false, hos
176
175
  config: [
177
176
  { id: 'tool-subagent-control', name: '@deepseek-ai/dsh-tool-subagent-control' },
178
177
  // 0.2.2-alpha.2 folded list_agents into the control row itself; the
179
- // separate child row is gone, so alpha.2+ hosts must not mount it.
180
- ...(executorPlugin ? [] : [{ id: 'tool-subagent-list-agents', name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' }]),
178
+ // separate child row is gone.
181
179
  {
182
180
  id: 'tool-subagent',
183
181
  name: '@deepseek-ai/dsh-tool-subagent',
184
- config: executorPlugin
185
- ? { provider: 'spawn', toolName: 'subagent', modelSelection: true, modelDiscovery: true, ...(timeContext ? {} : { backgroundMode: 'continuable' }) }
186
- : { provider: 'spawn', toolName: 'subagent', modelSelectionSettings: true, ...(timeContext ? {} : { backgroundMode: 'continuable' }) },
182
+ config: { provider: 'spawn', toolName: 'subagent', modelSelection: true, modelDiscovery: true, ...(timeContext ? {} : { backgroundMode: 'continuable' }) },
187
183
  },
188
184
  {
189
185
  id: 'tool-subagent-fork',
@@ -221,10 +217,8 @@ export function pluginsFor({ kind, gitBash, skillsDir, pythonActive = false, hos
221
217
  // 0.2.2-alpha.2 replaced the deprecated presentation selector with the
222
218
  // explicit PTC executor row (the base deployment keeps
223
219
  // tool-executor-native; a scoped executor row overrides it for this
224
- // agent). Older hosts keep the presentation row they ship.
225
- rows.push(executorPlugin
226
- ? { id: 'tool-executor-ptc', name: '@deepseek-ai/dsh-tool-executor-ptc' }
227
- : { id: 'tool-presentation', name: '@deepseek-ai/dsh-agent-tool-presentation', config: { mode: 'ptc' } })
220
+ // agent).
221
+ rows.push({ id: 'tool-executor-ptc', name: '@deepseek-ai/dsh-tool-executor-ptc' })
228
222
  }
229
223
  if (kind === 'cordis') {
230
224
  rows.push({ id: 'tool-cordis', name: '@deepseek-ai/dsh-tool-cordis' })
@@ -237,20 +231,15 @@ export function pluginsFor({ kind, gitBash, skillsDir, pythonActive = false, hos
237
231
  rows.push({
238
232
  id: 'tool-plugin-manager',
239
233
  name: '@deepseek-ai/dsh-plugin-manager/tools',
240
- // The official rows: before 0.2.2-alpha.2 ptc/standard ship it disabled and
241
- // cordis keeps it on (every web deployment has a profile context). From
242
- // 0.2.2-alpha.2 on every preset enables it exactly when a profile context
243
- // exists, so the probe decides for all three variants.
244
- ...(executorPlugin ? off(!profileContext) : off(kind !== 'cordis')),
234
+ // Every preset enables it exactly when a profile context exists.
235
+ ...off(!profileContext),
245
236
  })
246
237
  if (pluginMarket) {
247
238
  rows.push({
248
- // The official 0.2.1-alpha.4 rows, following the manager's split on
249
- // both architectures (alpha.4: disabled on standard/ptc, profile-gated
250
- // on cordis; alpha.2 of 0.2.2: profile-gated everywhere).
239
+ // The official 0.2.1-alpha.4 row, profile-gated like the manager.
251
240
  id: 'tool-plugin-market',
252
241
  name: '@deepseek-ai/dsh-plugin-market/tools',
253
- ...(executorPlugin ? off(!profileContext) : off(kind !== 'cordis')),
242
+ ...off(!profileContext),
254
243
  })
255
244
  }
256
245
  return rows
package/src/index.js CHANGED
@@ -1807,14 +1807,12 @@ function cleanupLegacyTrees(presetIds) {
1807
1807
  *
1808
1808
  * `timeContext` / `toolSchedule` / `pluginMarket` / `compaction` are the
1809
1809
  * 0.2.1 rows (durable clock readings, reminder tools, the plugin market row,
1810
- * the minimal tool-fs + compaction delta). `executorPlugin` marks the
1811
- * 0.2.2-alpha.2 tool-execution architecture: the packages tool-executor-native
1812
- * and tool-executor-ptc exist, the official presets mount the latter for PTC,
1813
- * the subagent config renamed `modelSelectionSettings` to
1814
- * `modelSelection` + `modelDiscovery`, and list-agents folded into the control
1815
- * row. `profileContext` is the host fact the manager/market rows gate on since
1816
- * alpha.2 (`!!js "!ctx.get('profileContext')"`) — computed from the live
1817
- * service, not the package set.
1810
+ * the minimal tool-fs + compaction delta). The 0.2.2-alpha.2 tool-execution
1811
+ * architecture is unconditional: the official presets mount
1812
+ * tool-executor-ptc for PTC, the subagent config uses `modelSelection` +
1813
+ * `modelDiscovery`, and list-agents folded into the control row.
1814
+ * `profileContext` is the live host fact the manager/market rows gate on
1815
+ * (`!!js "!ctx.get('profileContext')"`).
1818
1816
  *
1819
1817
  * A preset row whose package is absent rejects the WHOLE mount, so every row
1820
1818
  * rides this probe: each is added only when its package resolves from the
@@ -1827,8 +1825,7 @@ function cleanupLegacyTrees(presetIds) {
1827
1825
  * @param {Function} [resolve] - predicate override for tests; receives a
1828
1826
  * package specifier, resolves like `require.resolve`, returns boolean.
1829
1827
  * @returns {Promise<{timeContext: boolean, toolSchedule: boolean,
1830
- * pluginMarket: boolean, compaction: boolean, executorPlugin: boolean,
1831
- * profileContext: boolean}>}
1828
+ * pluginMarket: boolean, compaction: boolean, profileContext: boolean}>}
1832
1829
  */
1833
1830
  export async function probeHostExtras(ctx, resolve) {
1834
1831
  let ok = resolve
@@ -1843,12 +1840,11 @@ export async function probeHostExtras(ctx, resolve) {
1843
1840
  ok = () => false
1844
1841
  }
1845
1842
  }
1846
- const [timeContext, toolSchedule, pluginMarket, compaction, executorPlugin] = await Promise.all([
1843
+ const [timeContext, toolSchedule, pluginMarket, compaction] = await Promise.all([
1847
1844
  Promise.resolve(safeProbe(ok, '@deepseek-ai/dsh-time-context')).then(Boolean),
1848
1845
  Promise.resolve(safeProbe(ok, '@deepseek-ai/dsh-tool-schedule')).then(Boolean),
1849
1846
  Promise.resolve(safeProbe(ok, '@deepseek-ai/dsh-plugin-market/tools')).then(Boolean),
1850
1847
  Promise.resolve(safeProbe(ok, '@deepseek-ai/dsh-compaction-basic')).then(Boolean),
1851
- Promise.resolve(safeProbe(ok, '@deepseek-ai/dsh-tool-executor-ptc')).then(Boolean),
1852
1848
  ])
1853
1849
  let profileContext = false
1854
1850
  try {
@@ -1856,7 +1852,7 @@ export async function probeHostExtras(ctx, resolve) {
1856
1852
  } catch {
1857
1853
  profileContext = false
1858
1854
  }
1859
- return { timeContext, toolSchedule, pluginMarket, compaction, executorPlugin, profileContext }
1855
+ return { timeContext, toolSchedule, pluginMarket, compaction, profileContext }
1860
1856
  }
1861
1857
 
1862
1858
  /** Run one probe predicate; ANY failure reads as "absent" (a throwing
@@ -2304,18 +2300,18 @@ export async function apply(ctx, config = {}) {
2304
2300
  const dialect = liveSettings.dialect()
2305
2301
  const applies = dialectApplies(dialect, assembleContext && assembleContext.agent)
2306
2302
  // dsh 0.2.2-alpha.2 narrowed this event to prompt PRESENTATION: it no
2307
- // longer carries `tools` ("tool declarations remain provider-owned"),
2308
- // so read the same list back from the tools service; older hosts keep
2309
- // handing it over on the assembly.
2310
- const offeredTools = Array.isArray(assembly && assembly.tools)
2311
- ? assembly.tools
2312
- : (() => {
2313
- try {
2314
- return ctx.get('tools')?.schemas?.(assembleContext && assembleContext.agent) ?? []
2315
- } catch {
2316
- return []
2317
- }
2318
- })()
2303
+ // longer carries `tools` ("tool declarations remain provider-owned").
2304
+ // Read the step's admitted set back from the tools service — the same
2305
+ // toolSet the agent loop resolved for this step, so conditional tools
2306
+ // are judged exactly as execution will judge them.
2307
+ const toolSet = assembleContext && assembleContext.toolSet
2308
+ const offeredTools = (() => {
2309
+ try {
2310
+ return ctx.get('tools')?.schemas?.(assembleContext && assembleContext.agent, toolSet) ?? []
2311
+ } catch {
2312
+ return []
2313
+ }
2314
+ })()
2319
2315
  // Routing is dialect-independent and follows actual offered tools.
2320
2316
  // PTC may expose only run_code here: its generic SDK decision rule
2321
2317
  // relies on the registered bindings' own descriptions, not guessed
package/src/ops-adopt.js CHANGED
@@ -23,6 +23,24 @@
23
23
  * prompt-policy tool table drops the bash row. Setting it is therefore not a
24
24
  * hack into someone else's plugin; it is their own supported configuration.
25
25
  *
26
+ * WHICH ROWS CARRY IT (v0.46.0, dsh-ops 0.4.3 breaking change): up to 0.4.2
27
+ * the switch lived on the single `dsh-ops-shell` component row. 0.4.3 replaced
28
+ * the component roster with preset-mode switches — `dsh-ops-mode-minimal`,
29
+ * `-standard`, `-ptc` and `-cordis` — and EVERY one of them plans a Windows
30
+ * shell (`MODE_PLANS[*].shell = true` in src/modes/plan.ts), so every one of
31
+ * them can publish ops_bash. This module therefore walks the whole set
32
+ * ({@link OPS_SHELL_ROW_IDS}): the legacy row when an older dsh-ops is
33
+ * mounted, the four mode rows when 0.4.3+ is. A row that is not mounted is
34
+ * never written to (hard rule 1), which is what makes one walk serve both
35
+ * eras and mixed/partially-overridden profiles.
36
+ *
37
+ * NOT COVERED BY THIS MODULE (documented, not a bug): dsh-ops 0.4.3+ gates
38
+ * its tools on the AGENT'S PRESET ID (`modeOf` -> `modeByPreset`), and the
39
+ * four ids are the official ones. A custom preset such as ours (`ptc-cordis`)
40
+ * or the `· Git Bash` variant ids matches no mode, so dsh-ops publishes
41
+ * nothing there and the row switches we write simply never fire — the face
42
+ * keeps only our bash.
43
+ *
26
44
  * WHY THIS MODULE TALKS TO configEditor (same discipline as
27
45
  * terminal-shell.js): the write must survive restarts and must be undoable.
28
46
  * configEditor.edit(entry, change) is the channel the official settings UI
@@ -56,11 +74,24 @@
56
74
  * 3. NEVER claim success without reading the value back.
57
75
  * 4. Restore returns the INHERITED value for OUR key only: unrelated
58
76
  * overrides in the same row (the user's own dsh-ops settings) survive.
77
+ * 5. NEVER write to a disabled row (it publishes nothing); restore still
78
+ * lifts our value from a row the user disabled afterwards.
59
79
  */
60
80
 
61
- /** The Loader row id of the dsh-ops shell component. */
81
+ /** The Loader row id of the dsh-ops shell component (dsh-ops <= 0.4.2). */
62
82
  export const OPS_SHELL_ROW_ID = 'dsh-ops-shell'
63
83
 
84
+ /** dsh-ops 0.4.3+ preset-mode rows; every one plans a Windows shell. */
85
+ export const OPS_MODE_ROW_IDS = Object.freeze([
86
+ 'dsh-ops-mode-minimal',
87
+ 'dsh-ops-mode-standard',
88
+ 'dsh-ops-mode-ptc',
89
+ 'dsh-ops-mode-cordis',
90
+ ])
91
+
92
+ /** Every row that can carry the publishBashTool switch, legacy first. */
93
+ export const OPS_SHELL_ROW_IDS = Object.freeze([OPS_SHELL_ROW_ID, ...OPS_MODE_ROW_IDS])
94
+
64
95
  /**
65
96
  * The effective publishBashTool of the row: the profile patch override wins
66
97
  * over the inherited layer value; a missing key means the dsh-ops default
@@ -82,17 +113,20 @@ export function effectivePublishBashTool(row) {
82
113
  }
83
114
 
84
115
  /**
85
- * Decide what to do about ops_bash. Pure: every fact arrives as an argument,
86
- * so the whole matrix (absent / already overridden / off at the source /
87
- * to-adopt) is unit-testable anywhere.
88
- * @param {object|undefined} row - the dsh-ops-shell row from
89
- * configEditor.configuration(), or undefined when not mounted.
116
+ * Decide what to do about ops_bash FOR ONE ROW. Pure: every fact arrives as
117
+ * an argument, so the whole matrix (absent / already overridden / off at the
118
+ * source / disabled row / to-adopt) is unit-testable anywhere.
119
+ * @param {object|undefined} row - one row from configEditor.configuration(),
120
+ * or undefined when not mounted.
90
121
  * @returns {{action: string, reason: string}} the plan.
91
122
  */
92
123
  export function planOpsAdopt(row) {
93
124
  if (row === undefined || row === null) {
94
125
  return { action: 'skip-row-absent', reason: 'the dsh-ops shell row is not mounted in this profile' }
95
126
  }
127
+ if (row.entry?.options?.disabled === true) {
128
+ return { action: 'skip-disabled', reason: 'the row is disabled, so it publishes no bash tool' }
129
+ }
96
130
  const overrideValue = row.override !== null && row.override !== undefined ? row.override.publishBashTool : undefined
97
131
  if (typeof overrideValue === 'boolean' && overrideValue === false) {
98
132
  return { action: 'present', reason: 'the row override already sets publishBashTool=false' }
@@ -107,52 +141,105 @@ export function planOpsAdopt(row) {
107
141
  }
108
142
 
109
143
  /**
110
- * Find the dsh-ops shell row in an editor view, or undefined.
144
+ * The row id of one editor entry, '' when the shape is malformed.
145
+ * @param {object|undefined} row - one configEditor.configuration() entry.
146
+ * @returns {string} the id.
147
+ */
148
+ function rowIdOf(row) {
149
+ const id = row?.entry?.options?.id
150
+ return typeof id === 'string' ? id : ''
151
+ }
152
+
153
+ /**
154
+ * Every shell-carrying dsh-ops row in an editor view, legacy first then the
155
+ * mode rows in roster order. Rows for other dsh-ops ids are ignored.
156
+ * @param {object} editor - ctx.configEditor (or a stand-in in tests).
157
+ * @returns {object[]} the rows (possibly empty).
158
+ */
159
+ function opsRowsOf(editor) {
160
+ const rows = editor.configuration()
161
+ if (!Array.isArray(rows)) return []
162
+ const found = []
163
+ for (const id of OPS_SHELL_ROW_IDS) {
164
+ for (const candidate of rows) {
165
+ if (rowIdOf(candidate) === id) found.push(candidate)
166
+ }
167
+ }
168
+ return found
169
+ }
170
+
171
+ /**
172
+ * Read one row back by id, or undefined when it vanished.
111
173
  * @param {object} editor - ctx.configEditor (or a stand-in in tests).
112
- * @returns {object|undefined} the row.
174
+ * @param {string} id - the row id.
175
+ * @returns {object|undefined} the fresh row.
113
176
  */
114
- function opsRowOf(editor) {
177
+ function freshRowOf(editor, id) {
115
178
  const rows = editor.configuration()
116
- return Array.isArray(rows) ? rows.find((candidate) => candidate.entry?.options?.id === OPS_SHELL_ROW_ID) : undefined
179
+ return Array.isArray(rows) ? rows.find((candidate) => rowIdOf(candidate) === id) : undefined
117
180
  }
118
181
 
119
182
  /**
120
- * Apply the plan through the official config editor, then READ THE VALUE BACK.
183
+ * Apply the plan through the official config editor, then READ EVERY VALUE
184
+ * BACK. One walk serves both eras: the legacy `dsh-ops-shell` row and the four
185
+ * 0.4.3+ `dsh-ops-mode-*` rows; absent rows are skipped, never written.
121
186
  * @param {object} editor - ctx.configEditor (or a stand-in in tests).
122
- * @returns {Promise<{status: string, detail?: string}>} the outcome; the caller
123
- * logs it — this function never lies about success and never throws for an
124
- * expected condition.
187
+ * @returns {Promise<{status: string, rows: object[], detail?: string}>} the
188
+ * outcome; the caller logs it — this function never lies about success and
189
+ * never throws for an expected condition.
125
190
  */
126
191
  export async function adoptOpsBashRemoval(editor) {
127
192
  if (editor === null || editor === undefined || typeof editor.configuration !== 'function') {
128
- return { status: 'skip-row-absent', detail: 'the config editor is unavailable, so the dsh-ops row cannot be configured' }
193
+ return { status: 'skip-row-absent', rows: [], detail: 'the config editor is unavailable, so the dsh-ops row cannot be configured' }
129
194
  }
130
- const row = opsRowOf(editor)
131
- const plan = planOpsAdopt(row)
132
- if (plan.action !== 'adopt') return { status: plan.action, detail: plan.reason }
133
- if (typeof editor.edit !== 'function') {
134
- return { status: 'write-failed', detail: 'the config editor exposes no edit()' }
195
+ const rows = opsRowsOf(editor)
196
+ if (rows.length === 0) {
197
+ return { status: 'skip-row-absent', rows: [], detail: 'no dsh-ops shell or mode row is mounted in this profile' }
135
198
  }
136
- try {
137
- await editor.edit(row.entry, (raw) => ({ ...raw, publishBashTool: false }))
138
- } catch (error) {
139
- return { status: 'write-failed', detail: 'the official config editor refused the write: ' + (error && error.message ? error.message : String(error)) }
199
+ const plans = rows.map((row) => planOpsAdopt(row))
200
+ const results = rows.map((row, index) => ({ id: rowIdOf(row), action: plans[index].action, reason: plans[index].reason }))
201
+ const targets = rows.filter((row, index) => results[index].action === 'adopt')
202
+ if (targets.length > 0 && typeof editor.edit !== 'function') {
203
+ return { status: 'write-failed', rows: results, detail: 'the config editor exposes no edit()' }
140
204
  }
141
- // Read back: edit() resolving is NOT proof the row now carries our value.
142
- let after
143
- try {
144
- after = opsRowOf(editor)
145
- } catch (error) {
146
- return { status: 'write-failed', detail: 'the write succeeded but reading it back failed: ' + (error && error.message ? error.message : String(error)) }
205
+ for (const row of targets) {
206
+ const id = rowIdOf(row)
207
+ try {
208
+ await editor.edit(row.entry, (raw) => ({ ...raw, publishBashTool: false }))
209
+ } catch (error) {
210
+ return {
211
+ status: 'write-failed',
212
+ rows: results,
213
+ detail: 'the official config editor refused the write for ' + id + ': ' + (error && error.message ? error.message : String(error)),
214
+ }
215
+ }
147
216
  }
148
- if (after === undefined || after.override === null || after.override === undefined || after.override.publishBashTool !== false) {
149
- return {
150
- status: 'write-failed',
151
- detail: 'the write did not stick: the dsh-ops row does not read publishBashTool=false'
152
- + (after === undefined ? ' (the row vanished during the write)' : ''),
217
+ // Read back: edit() resolving is NOT proof the rows now carry our value.
218
+ for (const row of targets) {
219
+ const id = rowIdOf(row)
220
+ let after
221
+ try {
222
+ after = freshRowOf(editor, id)
223
+ } catch (error) {
224
+ return {
225
+ status: 'write-failed',
226
+ rows: results,
227
+ detail: 'the write succeeded but reading ' + id + ' back failed: ' + (error && error.message ? error.message : String(error)),
228
+ }
229
+ }
230
+ if (after === undefined || after.override === null || after.override === undefined || after.override.publishBashTool !== false) {
231
+ return {
232
+ status: 'write-failed',
233
+ rows: results,
234
+ detail: 'the write did not stick on ' + id + ': it does not read publishBashTool=false'
235
+ + (after === undefined ? ' (the row vanished during the write)' : ''),
236
+ }
153
237
  }
154
238
  }
155
- return { status: 'adopted' }
239
+ if (targets.length > 0) return { status: 'adopted', rows: results }
240
+ if (results.some((result) => result.action === 'present')) return { status: 'present', rows: results }
241
+ if (results.some((result) => result.action === 'unchanged-inherited-off')) return { status: 'unchanged-inherited-off', rows: results }
242
+ return { status: 'skip-disabled-rows', rows: results, detail: 'every dsh-ops shell row is disabled' }
156
243
  }
157
244
 
158
245
  /**
@@ -160,53 +247,88 @@ export async function adoptOpsBashRemoval(editor) {
160
247
  * fiber dispose hook (plugin disabled / uninstalled while dsh runs). Only OUR
161
248
  * key is touched: unrelated overrides in the same row survive.
162
249
  * @param {object} editor - ctx.configEditor (or a stand-in in tests).
163
- * @returns {Promise<{status: string, detail?: string}>} the outcome.
250
+ * @returns {Promise<{status: string, rows: object[], detail?: string}>} the outcome.
164
251
  */
165
252
  export async function restoreOpsBash(editor) {
166
253
  if (editor === null || editor === undefined || typeof editor.configuration !== 'function') {
167
- return { status: 'restore-failed', detail: 'the config editor is unavailable, so the override cannot be lifted' }
254
+ return { status: 'restore-failed', rows: [], detail: 'the config editor is unavailable, so the override cannot be lifted' }
168
255
  }
169
- const row = opsRowOf(editor)
170
- if (row === undefined) {
171
- return { status: 'skip-row-absent', detail: 'the dsh-ops shell row is no longer mounted — nothing to restore' }
256
+ const rows = opsRowsOf(editor)
257
+ if (rows.length === 0) {
258
+ return { status: 'skip-row-absent', rows: [], detail: 'no dsh-ops shell or mode row is mounted — nothing to restore' }
172
259
  }
173
- const overrideValue = row.override !== null && row.override !== undefined ? row.override.publishBashTool : undefined
174
- if (typeof overrideValue !== 'boolean') {
175
- return { status: 'unchanged', detail: 'no publishBashTool override to lift' }
260
+ // Only rows where OUR value is in force are touched. A row the user flipped
261
+ // back ON afterwards is their own later choice — leave it exactly where it is.
262
+ const results = []
263
+ const targets = []
264
+ for (const row of rows) {
265
+ const id = rowIdOf(row)
266
+ const overrideValue = row.override !== null && row.override !== undefined ? row.override.publishBashTool : undefined
267
+ if (typeof overrideValue !== 'boolean') {
268
+ results.push({ id, action: 'unchanged', reason: 'no publishBashTool override to lift' })
269
+ continue
270
+ }
271
+ if (overrideValue !== false) {
272
+ results.push({ id, action: 'unchanged', reason: 'the override reads publishBashTool=true — a later choice of the user, left alone' })
273
+ continue
274
+ }
275
+ results.push({ id, action: 'restore' })
276
+ targets.push(row)
176
277
  }
177
- if (overrideValue !== false) {
178
- // Somebody (the user, through the settings card) turned it back ON after
179
- // us. Lifting that would REPLACE their choice with the inherited default —
180
- // leave their value exactly where it is.
181
- return { status: 'unchanged', detail: 'the override reads publishBashTool=true — a later choice of the user, left alone' }
278
+ if (targets.length === 0) {
279
+ return { status: 'unchanged', rows: results, detail: results.map((row) => row.id + ': ' + row.reason).join('; ') }
182
280
  }
183
281
  if (typeof editor.edit !== 'function') {
184
- return { status: 'restore-failed', detail: 'the config editor exposes no edit()' }
282
+ return { status: 'restore-failed', rows: results, detail: 'the config editor exposes no edit()' }
185
283
  }
186
- try {
187
- await editor.edit(row.entry, (raw, inherited) => {
188
- const next = { ...raw }
189
- if (inherited !== null && inherited !== undefined && Object.hasOwn(inherited, 'publishBashTool')) {
190
- next.publishBashTool = inherited.publishBashTool
191
- } else {
192
- delete next.publishBashTool
284
+ for (const row of targets) {
285
+ const id = rowIdOf(row)
286
+ try {
287
+ await editor.edit(row.entry, (raw, inherited) => {
288
+ const next = { ...raw }
289
+ if (inherited !== null && inherited !== undefined && Object.hasOwn(inherited, 'publishBashTool')) {
290
+ next.publishBashTool = inherited.publishBashTool
291
+ } else {
292
+ delete next.publishBashTool
293
+ }
294
+ return next
295
+ })
296
+ } catch (error) {
297
+ return {
298
+ status: 'restore-failed',
299
+ rows: results,
300
+ detail: 'the official config editor refused the restore for ' + id + ': ' + (error && error.message ? error.message : String(error)),
193
301
  }
194
- return next
195
- })
196
- } catch (error) {
197
- return { status: 'restore-failed', detail: 'the official config editor refused the restore: ' + (error && error.message ? error.message : String(error)) }
198
- }
199
- let after
200
- try {
201
- after = opsRowOf(editor)
202
- } catch (error) {
203
- return { status: 'restore-failed', detail: 'the restore succeeded but reading it back failed: ' + (error && error.message ? error.message : String(error)) }
302
+ }
204
303
  }
205
- const left = after !== undefined && after.override !== null && after.override !== undefined ? after.override.publishBashTool : undefined
206
- if (typeof left === 'boolean' && left === false) {
207
- return { status: 'restore-failed', detail: 'the restore did not stick: the row still reads publishBashTool=false' }
304
+ for (const row of targets) {
305
+ const id = rowIdOf(row)
306
+ let after
307
+ try {
308
+ after = freshRowOf(editor, id)
309
+ } catch (error) {
310
+ return {
311
+ status: 'restore-failed',
312
+ rows: results,
313
+ detail: 'the restore succeeded but reading ' + id + ' back failed: ' + (error && error.message ? error.message : String(error)),
314
+ }
315
+ }
316
+ const left = after !== undefined && after.override !== null && after.override !== undefined ? after.override.publishBashTool : undefined
317
+ if (typeof left === 'boolean' && left === false) {
318
+ return { status: 'restore-failed', rows: results, detail: 'the restore did not stick on ' + id + ': it still reads publishBashTool=false' }
319
+ }
208
320
  }
209
- return { status: 'restored' }
321
+ return { status: 'restored', rows: results }
322
+ }
323
+
324
+ /**
325
+ * The row ids one result touched, for the log line.
326
+ * @param {object} result - an adopt/restore result.
327
+ * @returns {string} comma-joined ids, or ''.
328
+ */
329
+ export function opsRowList(result) {
330
+ const rows = Array.isArray(result?.rows) ? result.rows : []
331
+ return rows.map((row) => row.id).filter((id) => typeof id === 'string' && id !== '').join(', ')
210
332
  }
211
333
 
212
334
  /**
@@ -216,19 +338,22 @@ export async function restoreOpsBash(editor) {
216
338
  * @returns {string} the log line, without the plugin tag.
217
339
  */
218
340
  export function opsAdoptReport(result) {
341
+ const listed = opsRowList(result)
219
342
  switch (result.status) {
220
343
  case 'adopted':
221
- return 'dsh-ops bash removed from the agent face (publishBashTool=false through the official config editor): the host cannot hide own-layer tools, so dsh-ops must simply not register it — disable this plugin any time you want ops_bash back (the removal is lifted automatically)'
344
+ return 'dsh-ops bash removed from the agent face (publishBashTool=false on ' + listed + ' through the official config editor): the host cannot hide own-layer tools, so dsh-ops must simply not register it — disable this plugin any time you want ops_bash back (the removal is lifted automatically)'
222
345
  case 'present':
223
- return 'dsh-ops bash already removed: the dsh-ops-shell row override already reads publishBashTool=false (nothing written)'
346
+ return 'dsh-ops bash already removed: ' + listed + ' already read publishBashTool=false (nothing written)'
224
347
  case 'unchanged-inherited-off':
225
- return 'dsh-ops bash already off at the source: publishBashTool=false in the dsh-ops configuration (nothing written)'
348
+ return 'dsh-ops bash already off at the source: publishBashTool=false in the dsh-ops configuration of ' + listed + ' (nothing written)'
349
+ case 'skip-disabled-rows':
350
+ return 'dsh-ops bash removal not needed: ' + (result.detail ?? 'every dsh-ops shell row is disabled')
226
351
  case 'skip-row-absent':
227
352
  return 'dsh-ops bash removal skipped: ' + (result.detail ?? 'the dsh-ops shell row is not mounted')
228
353
  case 'write-failed':
229
354
  return 'dsh-ops bash removal FAILED — ' + (result.detail ?? '')
230
355
  + ' | next steps: ops_bash stays VISIBLE and our pre-execute denial keeps answering its calls;'
231
- + ' retry by restarting dsh, or set publishBashTool=false on the dsh-ops-shell row by hand'
356
+ + ' retry by restarting dsh, or set publishBashTool=false on the dsh-ops shell/mode row by hand'
232
357
  + ' (Settings -> the dsh-ops card -> publishBashTool), or turn to the README for the manual patch row.'
233
358
  default:
234
359
  return 'dsh-ops bash removal skipped: ' + (result.detail ?? result.status)
@@ -241,16 +366,17 @@ export function opsAdoptReport(result) {
241
366
  * @returns {string} the log line, without the plugin tag.
242
367
  */
243
368
  export function opsRestoreReport(result) {
369
+ const listed = opsRowList(result)
244
370
  switch (result.status) {
245
371
  case 'restored':
246
- return 'dsh-ops bash setting restored: the publishBashTool override is lifted, ops_bash follows the dsh-ops settings again'
372
+ return 'dsh-ops bash setting restored: the publishBashTool override is lifted on ' + (listed || 'the rows') + ', ops_bash follows the dsh-ops settings again'
247
373
  case 'unchanged':
248
374
  return 'dsh-ops bash restore not needed: ' + (result.detail ?? 'no override of ours to lift')
249
375
  case 'skip-row-absent':
250
376
  return 'dsh-ops bash restore skipped: ' + (result.detail ?? 'the dsh-ops shell row is gone')
251
377
  case 'restore-failed':
252
378
  return 'dsh-ops bash restore FAILED — ' + (result.detail ?? '')
253
- + ' | the dsh-ops-shell override may survive this plugin: lift it by hand'
379
+ + ' | the dsh-ops override may survive this plugin: lift it by hand'
254
380
  + ' (Settings -> the dsh-ops card -> restore defaults) if ops_bash should come back.'
255
381
  default:
256
382
  return 'dsh-ops bash restore skipped: ' + (result.detail ?? result.status)