dsh-gitbash-shell 0.32.0 → 0.33.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,52 @@
3
3
  > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。
4
4
  > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。
5
5
 
6
+ ## v0.33.0 — 2026-10-01
7
+
8
+ **类型**:fix(issue #13:Scoop 安装 Git 时 `bashPath` 配置正确但每次启动仍报 could NOT be resolved 并弹引导框)+ feat(解析链新增 `git --exec-path` 与 GitForWindows 注册表两级发现)
9
+
10
+ > 报告者 **ID-izlq-Github** 的定位是准确的:装配期冻结、两个读取器在 apply 时刻读空、自动链对 shim 布局盲区,三条全部成立。本条目按他的分析与建议落地(建议 1/2/3/4 全采纳)。
11
+
12
+ ### 修复一:显式层懒化,不再在装配期冻结(`src/index.js`)
13
+
14
+ - **冻结点**(issue 引用的 `index.js:1813`):apply() 时 `effectiveConfiguredBashPath(executorConfiguredBashPath(ctx), settingsBashPath(ctx))` 算一次就定死。那一刻 `ctx.loader.resolve('gitbash-executor')` 因行未进 store 而 throw → 被吞成 `''`;settings 服务也可能尚未注入(0.1.7+ 只有 describe()/update())。两者皆空 ⇒ 误判「未配置」⇒ 跑自动链。执行器(`shell.js` 的 `get bashPath()` 懒解析)却正常 —— **同一次启动、两条路径、相反结论**,这正是 issue 的内核。
15
+ - **修法**:冻结值换成两个读取器 `readConfiguredBashPath()` / `readBashResolution()`,**全部消费点逐次重读**:① `gitBash` capability 改 getter(bashPath/ok/source/configured/tried;ptc 侧只读静态 `active`,不受影响);② status 路由实时重读(弹窗因此不再误报 —— 行挂载完成后 `configured` 非空,解析成功,`ok:true`,弹窗根本不出现);③ pathmap 路由与 tools/execute / post-execute 的 `buildTranslateEnv`;④ 官方侧栏终端接管每次尝试重读;⑤ better-sidebar 接管改挂 settle 通知。
16
+ - **settings 绕行退役**:`gitbash-shell` 行的 `bashPath` 直接从 apply() 的 `config` 参数读(新 `rowBashPath(config)`:volatile ref / 普通值 / 缺席三态,绝不抛)—— 这就是同一行的值,比绕 `settings.describe()` 更直接,且 volatile 意味着用户改设置下一读即生效。宿主半不再 import `settingsBashPath`(shell.js 侧保留:executor 行运行时读 describe() 实测正常)。
17
+ - **启动日志 settle 化**:boot 打印一次;「失败且显式层为空」(行可能还在挂载)时轮询 250ms×20(仅 win32,5 秒封顶),显式层出现或解析翻转后补打 `(settled)` 段;成功或显式值被拒为最终态,不轮询。terminal 接管通过 `onBashSettled` 通知(better-sidebar 在 boot 失败→settled 成功时也能接管,无需重启)。
18
+
19
+ ### 修复一·续:executor 行读取在 0.2.0-rc.2 上恒断,不只是时序(`src/bash-path.js`)
20
+
21
+ - **真机实测(隔离实例 + 探针插件,报告场景复现)**:dsh 0.2.0-rc.2 上 bundle-patch insert 的行(`gitbash-executor`)挂在**所属插件的 subtree** 里,`loader.resolve('gitbash-executor')` 从 root 树查找**恒 throw**(探针 apply 时刻与 12 秒后各 7 次采样全部 throw)——报告者以为的「启动时序问题」在这台宿主上实际是**永远读不到**。旧代码把 throw 吞成 `''` ⇒ 显式层整个失效:patch 写 `gitbash-executor.config.bashPath` 的用户(issue #11 起推荐、#13 报告者的实际配置方式)在 0.2.0 上 boot/status/翻译层全部看不到该值,而执行器自己读 `this.config.bashPath` 正常 —— 「命令能用但每次启动报错+弹框」的另一半根因。**并连带违反 §4h 契约**:显式值(哪怕被拒)不得被自动链顶替 —— 实测坏值 `Q:/nope/bin/bash.exe` 被自动链的 default 层「顶替成功」,boot 谎报 resolved。
22
+ - **修法**:`executorConfiguredBashPath` 二段式 —— `resolve('gitbash-executor')` 先试(旧宿主行在 root 树,命中即返回,空串也是真实答案=自动链);throw 则 `loader.entries()` 迭代(**含 subtree**)按行自身 `options.id` 匹配(0.2.0 实测稳定可读,`bashPath` 值全程不变)。防回归用例锁死三种 loader 形状(root 命中 / subtree+entries / 行缺失)。
23
+ - **修复后 A/B 真机读数**(隔离实例,patch 写 `gitbash-executor.config.bashPath`):
24
+ - 坏值 `Q:/nope/...`:boot `bashPath is set to "Q:/nope/bin/bash.exe" and that value was rejected; nothing else is substituted`(fail-loud、无轮询、default 层不再顶替);status `ok:false, configured:"Q:/nope/bin/bash.exe", tried[0]={source:'configured',code:'missing'}` —— boot/status/执行器三方一致;
25
+ - 好值:boot `Git Bash resolved from the configured source`;status `ok:true, source:"configured"` —— 弹窗不出现。
26
+
27
+ ### 修复二:解析链补 shim 布局的发现方式(`src/bash-path.js`)
28
+
29
+ - **新 source `exec-path`**(位置:PATH 反推之后、注册表 PATH 之前):对 PATH 上第一个 `git.exe` 跑 `git --exec-path`(Scoop/choco 的 **shim 也能启动真身**,真身报告自己的家),从输出向上最多 5 级找「同时含 `cmd/git.exe` 与 `usr/bin/bash.exe`」的目录,产出 `<root>/bin/bash.exe`。纯函数 `execPathBashCandidates(execPath, exists)` 可测;`ucrt64`/`mingw64`/旧 flat `libexec` 三种布局全覆盖,MSYS2(`usr/libexec/git-core`,根上没有 `cmd/git.exe`)天然不产出候选。
30
+ - **GitForWindows 注册表兜底**:`HKLM\SOFTWARE\GitForWindows`、`HKLM\SOFTWARE\WOW6432Node\GitForWindows`、`HKCU\Software\GitForWindows` 的 `InstallPath`(官方安装器写的;Scoop/便携通常没有,故排在 exec-path 之后),同样走 `exec-path` source。
31
+ - **候选仍过全套判据**(黑名单 → 形状 → `cmd/git.exe`/`usr/bin/bash.exe` → mingw64/msys-2.0.dll → `.windows.` 版本指纹 → `uname -s` 一票否决):§4h 两条硬边界不变 —— 永不回退、只认 Git for Windows 的 bash。冒烟新增「WSL bash 经 exec-path 到达仍被拒」用例。
32
+ - **顺带**:`defaultIo().run` 从 execFileSync 换 spawnSync(piped stdio)—— execFileSync 的同步家族在子进程失败时把 stderr 直接泄漏到宿主日志(reg 的 GBK 错误文本 = 乱码),v0.28.0 起每次失败探测都在漏;spawnSync 捕获进 result 由 catch 丢弃。本机实测乱码消除、探测结果不变(exec-path 层产出 `C:/Program Files/Git/bin/bash.exe`,mingw64 布局反推正确,全链仍 `source=default` 先命中,新层是后备)。
33
+
34
+ ### 冒烟与真机
35
+
36
+ - 新增三组用例:①「exec-path covers Scoop layouts」(纯函数三布局 + MSYS2 不命中 + 全链 Scoop 场景 + WSL 否决);②「explicit tier is LAZY」(冻结模式防回归:断言 `const configuredBashPath = effectiveConfiguredBashPath(` 与 `const bashResolution = resolveGitBashCached(` 不得再现;懒读取器/status 实时重读/settle 通知必须存在;`rowBashPath` 三态);③「executor row lookup survives the subtree layout change」(loader 三形状:root 命中 / subtree+entries / 行缺失,空串=真实答案)。
37
+ - **顺手修掉存量失败**(0.32.1 在 win32 上 latent):「full apply() mount on a stub ctx」的 settings stub 还停在 `settings.get(ns)` 时代,readShell 自 v0.25.1 起只认 `describe()` ⇒ stub 恒 null ⇒ 轮询 12×1500ms 后断言必挂。stub 补 `describe()`,与现役读法对齐。
38
+ - 99 项全绿(96 存量 + 3 新增;toolchain PATH 用例的平台误判 v0.32.1 已修,本次全平台绿)。
39
+ - **真机验证**(隔离 `DSH_HOME`(Temp 目录)+ profile 副本 `cp -rL` 解引用 + 插件目录换本仓源码 + 探针插件采 loader 形状;全程独占 3080 —— 用户实例当时未运行,验证后实例已杀、410M 副本与探针已删净,`~/.dsh` 零触碰):默认场景 boot `resolved from the default source`、status `ok:true`;坏值/好值 A/B 见上文「修复一·续」;官方侧栏终端接管懒读路径正常(`already uses this Git Bash ... (nothing written)` 幂等);ptc 联动正常(cordis-gitbash 去重、对方注册 Git Bash rows —— capability getter 无碍);spawnSync 改造后启动日志**零乱码**。**未覆盖**:settle 轮询分支的翻转真机时序(0.2.0-rc.2 上 entries() 在 apply 时刻即稳定可读,轮询多数情况下不触发;接线由源码断言锁定,行为留待真实慢宿主检验)。
40
+
41
+ - 相关:issue #13(<https://github.com/KannaKuron/dsh-gitbash-shell/issues/13>)、issue #11(解析链的由来)、AGENTS.md §4h(顺序契约更新)。
42
+
43
+ ## v0.32.1 — 2026-09-28
44
+
45
+ **类型**:chore(清掉 v0.32.0 遗留的 legacy settings 死代码)+ fix(冒烟里 mac 平台误判的 toolchain 路径用例)
46
+
47
+ - **legacy settings 面整体删除**(v0.32.0 识别的死代码,当时保守留了一手):宿主 settings 服务自 0.1.7 起只有 describe()/update()(packages/settings/settings/src/index.ts 方法面核对),没有 register(SettingsScope)也没有 get(ns) ⇒ 全部探测分支不可达。删除:① host 半的 legacy settings namespace 注册块(legacySettings 探测 + ctx.inject(['settings']) 注册 schema + scope.watch 接线);② makeLiveReader 的 era 探测(legacy 恒 false,"legacy || !hasConfig" 收敛为 !hasConfig 的等价 fallback);③ 四个 settings.get(ns) 读取器(readPosixPaths / readDialectSettings / readAdoptSidebar / readSuppressPeerCordis,生产调用全在死分支里)与 SETTINGS_NAMESPACE 常量;④ sidebarScope(唯一赋值点在死代码里)及其 polling watch 挂钩;⑤ adoptSidebarShell.readShell 的 get(ns) 死分支(describe 分支保留,0.1.7 上在用);⑥ shell.js dialectSettings() 的同款 get(ns) 分支;⑦ client 半的 settingsScope 注入段与 settings.plugin.item 旧座位(设置卡只剩 plugins.bundle.config 详情页座位)。行数:src/index.js 2549 → 约 2470 行。
48
+ - **行为不变**:所有被删分支在 >=0.1.7-rc 宿主上均不可达;每个消费点的现役路径(describe() 读取 / 行 Config volatile 读取)逐字未动。冒烟 96 项全绿,新增防复活守卫(源码不得再出现 settings.register / SETTINGS_NAMESPACE)。
49
+ - **fix(tests):toolchain PATH 扫描用例在非 Windows 平台误判**——实现永远以 Windows 反斜杠拼接(该扫描只在 win32 跑,行为正确),期望却用 process.platform 的 sep 构造,mac/Linux 上必挂。期望改为字面量反斜杠;该用例从 v0.32.0 起的既有的平台失败就此消除。
50
+ - 同步:AGENTS.md 条目 0(死代码段改为「已整体删除」)、4b(SERVICE READ RULE 更新为 makeLiveReader)、§4e/§4g/§4i 的双时代措辞收敛;验证清单与发布 checklist 更新(含 minimumReleaseAge 冷却提示)。
51
+
6
52
  ## v0.32.0 — 2026-09-28
7
53
 
8
54
  **类型**:chore(宿主下限提高到 >= 0.1.7-rc:删除 0.1.6 以前的全部兼容路径,插件瘦身六分之一)
package/README.md CHANGED
@@ -126,7 +126,7 @@ dsh-ptc-cordis-preset 报告它的 `PTC 创造模式` 已经是 Git Bash 版。
126
126
  default: ptc-cordis # 或 standard-gitbash / minimal-gitbash
127
127
  ```
128
128
 
129
- ## bash 从哪里来:解析链与失败引导(v0.28.0,issue #11)
129
+ ## bash 从哪里来:解析链与失败引导(v0.28.0,issue #11;懒化与 exec-path 层 v0.33.0,issue #13)
130
130
 
131
131
  `bashPath` **默认留空 = 自动探测**;解析顺序就是契约:
132
132
 
@@ -136,8 +136,16 @@ dsh-ptc-cordis-preset 报告它的 `PTC 创造模式` 已经是 Git Bash 版。
136
136
  `%LOCALAPPDATA%\Programs\Git\bin\bash.exe`;
137
137
  3. **PATH**(用户 PATH + 系统 PATH,进程内已合并)逐目录找 `bash.exe`;
138
138
  4. **PATH 上的 `git.exe` 反推** `<gitdir>/../bin/bash.exe`;
139
- 5. **注册表 `Path`**(`HKCU\Environment`、`HKLM\...\Session Manager\Environment`)——覆盖"GUI 启动时 PATH 快照过期";
140
- 6. **全落空 ⇒ 报错 + 引导**(启动日志一份完整报告,客户端弹一次引导框)。
139
+ 5. **`git --exec-path` 反推 + GitForWindows 注册表**(v0.33.0):对 PATH 上的 `git.exe`(Scoop / choco 的
140
+ **shim** 也照常启动真身)跑 `--exec-path`,从输出向上找 Git 根 —— 兼容 `mingw64` / `ucrt64` 两种运行时
141
+ 布局,补上「Scoop 安装、PATH 上只有 shims、没有 `bash.exe`」的盲区;随后兜底读注册表
142
+ `HKLM\SOFTWARE\GitForWindows` 的 `InstallPath`;
143
+ 6. **注册表 `Path`**(`HKCU\Environment`、`HKLM\...\Session Manager\Environment`)——覆盖"GUI 启动时 PATH 快照过期";
144
+ 7. **全落空 ⇒ 报错 + 引导**(启动日志一份完整报告,客户端弹一次引导框)。
145
+
146
+ **解析结果是活的,不是启动时判一次**(v0.33.0,issue #13):启动那一刻插件行可能还没全部挂载,显式配置
147
+ 会被短暂读成空 —— 现在启动日志、状态接口、弹窗与侧栏终端接管都在**使用时实时重读**,执行器能用,
148
+ 它们就不会再说「解析失败」;启动日志也会在行挂载稳定后补一份最终结论。
141
149
 
142
150
  > **只认 Git for Windows 的 bash。** WSL(`C:\Windows\System32\bash.exe`,永远在 PATH 且排序靠前)、
143
151
  > WindowsApps 别名、MSYS2、Cygwin 的 bash **一律拒绝**;候选还要通过 Git 布局(`usr/bin` + `cmd\git.exe` +
package/README_EN.md CHANGED
@@ -91,9 +91,16 @@ Executor config:
91
91
 
92
92
  ```yaml
93
93
  config:
94
- bashPath: "D:/Tools/Git/bin/bash.exe" # default C:/Program Files/Git/bin/bash.exe
94
+ bashPath: "D:/Tools/Git/bin/bash.exe" # default '' (empty) = automatic chain
95
95
  ```
96
96
 
97
+ The automatic chain (empty `bashPath`) probes, in order: explicit settings → default
98
+ install locations → PATH → `git.exe` reverse lookup → `git --exec-path` (covers
99
+ Scoop/choco shim layouts and the `ucrt64` runtime, v0.33.0) + the GitForWindows
100
+ registry `InstallPath` → the registry `Path`. Resolution is re-read live per
101
+ consumer (never frozen at boot, v0.33.0), and only Git for Windows' own bash
102
+ counts — WSL / MSYS2 / Cygwin are refused with guidance, never substituted.
103
+
97
104
  ## Cooperation with dsh-ptc-cordis-preset
98
105
 
99
106
  This plugin publishes a `gitBash` host capability (`{ active, bashPath }`,
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-gitbash-shell",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "main": "./src/index.js",
5
5
  "description": "Windows 全模式 Git Bash:用 Git for Windows bash 替换 PowerShell 执行器,声明式注册 standard/minimal/code/cordis 的 Git Bash 变体 preset(preset id 永不变)。联动:dsh-ptc-cordis-preset(两者同装时 PTC 创造模式带 Git Bash 行且可去重)+ dsh-better-sidebar(终端 UI 标签与模型 terminal_* 工具自动切到 Git Bash)。",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gitbash-shell",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Windows 上让 agent 的每条 shell 命令改经 Git for Windows bash 执行;物化四个内置预设的 Git Bash 变体,统一 MSYS 盘根路径方言,侧栏终端接管可开关。· Run every agent shell command through Git for Windows bash on Windows; materialize Git Bash variants of the built-in presets, unify the MSYS drive-root path dialect, and adopt the sidebar terminal behind a switch.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/bash-path.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * arrives through `io`, so the whole chain is unit-testable off-Windows.
21
21
  */
22
22
 
23
- import { execFileSync } from 'node:child_process'
23
+ import { spawnSync } from 'node:child_process'
24
24
  import { existsSync } from 'node:fs'
25
25
  import { dirname } from 'node:path'
26
26
 
@@ -44,7 +44,7 @@ export const GIT_WINDOWS_VERSION_MARKER = '.windows.'
44
44
  export const GIT_BASH_UNAME = /^MINGW(32|64)_NT-/
45
45
 
46
46
  /** Exit codes/sources reported back to the caller. */
47
- export const BASH_SOURCES = ['configured', 'default', 'path', 'path-git', 'registry']
47
+ export const BASH_SOURCES = ['configured', 'default', 'path', 'path-git', 'exec-path', 'registry']
48
48
 
49
49
  /**
50
50
  * Normalize a Windows path for comparison: forward slashes, lowercase, no
@@ -132,11 +132,46 @@ export function classifyBashCandidate(candidate, io) {
132
132
  return { ok: true, path: candidate, root, gitExe }
133
133
  }
134
134
 
135
+ /**
136
+ * Walk UP from a `git --exec-path` output looking for the Git for Windows
137
+ * root, and return its `<root>/bin/bash.exe` candidate (issue #13).
138
+ *
139
+ * Why this tier exists: package managers that SHIM Git (Scoop, and portable
140
+ * installs generally) put `git.exe` on PATH in a directory (`E:/Scoop/shims`)
141
+ * that is neither `<root>/cmd` nor `<root>/bin`, so the PATH reverse lookup
142
+ * never fires and there is no `bash.exe` on PATH at all. The REAL git still
143
+ * knows where it lives — `git --exec-path` prints
144
+ * `<root>/<mingw64|ucrt64>/libexec/git-core` (or `<root>/libexec/git-core`
145
+ * on old layouts) even when launched through a shim — so climbing ≤5 levels
146
+ * and demanding BOTH `cmd/git.exe` and `usr/bin/bash.exe` finds the root on
147
+ * every layout. That pair is also the discriminator: MSYS2's git reports
148
+ * `<msys64>/usr/libexec/git-core`, whose root has `usr/bin/bash.exe` but NO
149
+ * `cmd/git.exe`, so it never produces a candidate here (and even if it did,
150
+ * classifyBashCandidate's blacklist + fingerprints would refuse it).
151
+ * @param {string|undefined} execPath - raw `git --exec-path` output.
152
+ * @param {(path: string) => boolean} exists - filesystem probe.
153
+ * @returns {string[]} zero or one candidate.
154
+ */
155
+ export function execPathBashCandidates(execPath, exists) {
156
+ if (typeof execPath !== 'string' || execPath.trim() === '') return []
157
+ let dir = execPath.replace(/\\/g, '/').replace(/\/+$/, '')
158
+ for (let i = 0; i < 5; i++) {
159
+ const parent = dirname(dir)
160
+ if (parent === dir || parent === '' || parent === '.') break
161
+ dir = parent
162
+ if (exists(dir + '/cmd/git.exe') && exists(dir + '/usr/bin/bash.exe')) {
163
+ return [dir + '/bin/bash.exe']
164
+ }
165
+ }
166
+ return []
167
+ }
168
+
135
169
  /**
136
170
  * The ordered candidate chain for one resolution, WITHOUT validation. Order is
137
171
  * the contract (issue #11): explicit setting → default install locations →
138
- * PATH → git.exe reverse lookup → registry PATH (the last one only helps when
139
- * the process PATH snapshot predates a user's PATH edit).
172
+ * PATH → git.exe reverse lookup → git --exec-path / GitForWindows registry
173
+ * (the last one only helps when the process PATH snapshot predates a user's
174
+ * PATH edit).
140
175
  * @param {object} input - resolution input.
141
176
  * @param {string} input.configured - explicit path ('' = none).
142
177
  * @param {object} io - injected facts.
@@ -152,6 +187,7 @@ export function bashCandidates({ configured }, io) {
152
187
  for (const path of io.defaults()) out.push({ path, source: 'default' })
153
188
  for (const path of io.pathBashCandidates()) out.push({ path, source: 'path' })
154
189
  for (const path of io.gitReverseCandidates()) out.push({ path, source: 'path-git' })
190
+ for (const path of io.execPathBashCandidates()) out.push({ path, source: 'exec-path' })
155
191
  for (const path of io.registryBashCandidates()) out.push({ path, source: 'registry' })
156
192
  return out
157
193
  }
@@ -262,23 +298,50 @@ export function effectiveConfiguredBashPath(ownRowValue, settingsValue) {
262
298
 
263
299
  /**
264
300
  * The `gitbash-executor` ROW's own `bashPath` — the highest-priority tier.
265
- * Read through the Loader's public `resolve(id)` (the row id is part of this
301
+ * Read through the Loader's public surface (the row id is part of this
266
302
  * plugin's own bundle patch), so the translation layer sees the same explicit
267
303
  * value the executor row carries instead of quietly resolving a different
268
304
  * one. Absent row / absent config / any loader shape change reads ''.
305
+ *
306
+ * TWO access paths, because hosts differ (issue #13, measured on 0.2.0-rc.2):
307
+ * bundle-patched rows live in the OWNING PLUGIN'S SUBTREE there, so a bare
308
+ * `resolve('gitbash-executor')` throws from the root tree FOREVER (not just
309
+ * at boot) — the explicit tier silently read '' while the executor's own lazy
310
+ * `this.config.bashPath` worked, i.e. the two halves of this plugin disagreed
311
+ * about the user's explicit answer. `entries()` iterates subtrees as well, so
312
+ * it finds the row on both layouts; the direct resolve stays as the first try
313
+ * for older hosts where the row sits in the root tree.
269
314
  * @param {object} ctx - a context exposing `loader`.
270
315
  * @returns {string} the row's configured path, '' when unset.
271
316
  */
272
317
  export function executorConfiguredBashPath(ctx) {
273
- try {
274
- const entry = ctx && ctx.loader && typeof ctx.loader.resolve === 'function'
275
- ? ctx.loader.resolve('gitbash-executor')
276
- : undefined
318
+ const read = (entry) => {
277
319
  const value = entry && entry.options ? entry.options.config : undefined
278
320
  return value && typeof value.bashPath === 'string' ? value.bashPath : ''
279
- } catch {
280
- return ''
281
321
  }
322
+ const loader = ctx && ctx.loader
323
+ if (!loader) return ''
324
+ // Subtree walk: finds the row wherever the host nests bundle-patched rows.
325
+ const walk = () => {
326
+ try {
327
+ if (typeof loader.entries !== 'function') return undefined
328
+ for (const entry of loader.entries()) {
329
+ if (entry && entry.options && entry.options.id === 'gitbash-executor') return entry
330
+ }
331
+ return undefined
332
+ } catch {
333
+ return undefined
334
+ }
335
+ }
336
+ try {
337
+ // Direct resolve first: the row sits in the ROOT tree on older hosts, and
338
+ // an EMPTY string it may carry is a real answer (= run the automatic
339
+ // chain) — only a throw (subtree row, newer hosts) falls through.
340
+ if (typeof loader.resolve === 'function') {
341
+ return read(loader.resolve('gitbash-executor'))
342
+ }
343
+ } catch { /* not in the root tree — walk the subtrees below */ }
344
+ return read(walk())
282
345
  }
283
346
 
284
347
  /**
@@ -289,19 +352,29 @@ export function executorConfiguredBashPath(ctx) {
289
352
  * @returns {object} the production io.
290
353
  */
291
354
  export function defaultIo() {
355
+ // spawnSync (not execFileSync): the sync-exec family LEAKS a failing
356
+ // child's stderr straight onto the host log (reg's GBK error text showed
357
+ // up as mojibake on every failed probe), while spawnSync with piped stdio
358
+ // captures it into the result where the catch below can drop it.
292
359
  const run = (file, args) => {
293
360
  try {
294
- return String(execFileSync(file, args, { encoding: 'utf8', timeout: 5000, windowsHide: true })).trim()
361
+ const result = spawnSync(file, args, { encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
362
+ if (result.error !== undefined || result.status !== 0) return undefined
363
+ return String(result.stdout ?? '').trim()
295
364
  } catch {
296
365
  return undefined
297
366
  }
298
367
  }
368
+ const registryValue = (hive, key, name) => {
369
+ const out = run('reg', ['query', hive + '\\' + key, '/v', name])
370
+ if (typeof out !== 'string') return undefined
371
+ const match = new RegExp(name + '\\s+REG_(?:EXPAND_)?SZ\\s+(.*)', 'i').exec(out)
372
+ return match === null ? undefined : match[1].trim()
373
+ }
299
374
  const registryPath = (hive, key) => {
300
- const out = run('reg', ['query', hive + '\\' + key, '/v', 'Path'])
301
- if (typeof out !== 'string') return []
302
- const match = /Path\s+REG_(?:EXPAND_)?SZ\s+(.*)/i.exec(out)
303
- if (match === null) return []
304
- return match[1].split(';').map((entry) => entry.trim()).filter((entry) => entry !== '')
375
+ const value = registryValue(hive, key, 'Path')
376
+ if (typeof value !== 'string') return []
377
+ return value.split(';').map((entry) => entry.trim()).filter((entry) => entry !== '')
305
378
  }
306
379
  const dirs = () => {
307
380
  const out = []
@@ -314,6 +387,23 @@ export function defaultIo() {
314
387
  return out
315
388
  }
316
389
  const pathDirs = () => String(process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').map((entry) => entry.trim()).filter((entry) => entry !== '')
390
+ const gitForWindowsInstalls = () => {
391
+ const roots = []
392
+ // The official installer writes these (machine-wide / per-user); the
393
+ // 32-bit-on-64-bit install lives under WOW6432Node. Scoop/portable usually
394
+ // has none of them, which is why this rides behind `git --exec-path`.
395
+ for (const [hive, key] of [
396
+ ['HKLM', 'SOFTWARE\\GitForWindows'],
397
+ ['HKLM', 'SOFTWARE\\WOW6432Node\\GitForWindows'],
398
+ ['HKCU', 'Software\\GitForWindows'],
399
+ ]) {
400
+ const install = registryValue(hive, key, 'InstallPath')
401
+ if (typeof install === 'string' && install.trim() !== '') {
402
+ roots.push(install.replace(/\\/g, '/').replace(/\/+$/, '') + '/bin/bash.exe')
403
+ }
404
+ }
405
+ return roots
406
+ }
317
407
  return {
318
408
  exists: (path) => {
319
409
  try { return existsSync(path) } catch { return false }
@@ -326,6 +416,19 @@ export function defaultIo() {
326
416
  const parent = dirname(dir.replace(/\\/g, '/'))
327
417
  return [parent + '/bin/bash.exe', dirname(parent) + '/bin/bash.exe']
328
418
  }),
419
+ // issue #13: shimmed Git layouts (Scoop, portable) have no bash.exe on PATH
420
+ // and a shim directory that defeats the cmd/bin reverse lookup. The REAL
421
+ // git reports its own home through --exec-path even when launched via a
422
+ // shim, and the official installer's InstallPath backs it up.
423
+ execPathBashCandidates: () => {
424
+ const gitExe = pathDirs()
425
+ .map((dir) => dir.replace(/\\/g, '/') + '/git.exe')
426
+ .find((path) => { try { return existsSync(path) } catch { return false } })
427
+ const execPath = gitExe === undefined ? undefined : run(gitExe, ['--exec-path'])
428
+ return [...execPathBashCandidates(execPath, (path) => {
429
+ try { return existsSync(path) } catch { return false }
430
+ }), ...gitForWindowsInstalls()]
431
+ },
329
432
  registryBashCandidates: () => [
330
433
  ...registryPath('HKCU', 'Environment'),
331
434
  ...registryPath('HKLM', 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment'),
package/src/client.js CHANGED
@@ -2,11 +2,10 @@
2
2
  * dsh-gitbash-shell — browser half (hand-written ModuleLoader bundle).
3
3
  *
4
4
  * ONE job: the Settings → Plugins card that gates the unified POSIX path
5
- * dialect. The Plugins tab dispatches the intersection of Host-served
6
- * namespaces and registered cards, so the card appears only when the host
7
- * half (src/index.js) has registered the 'gitbash-shell' settings namespace.
5
+ * dialect. The card lives on the plugin's detail page (the
6
+ * plugins.bundle.config seat, keyed by the package name).
8
7
  *
9
- * The card flips one boolean — posixPaths — through a bound settingsScope:
8
+ * The card flips one boolean — posixPaths — through this plugin's row Config form:
10
9
  * on → the host injects the order-126 directive (every tool takes MSYS
11
10
  * drive roots /c/...) and the tools/execute wrapper translates
12
11
  * path-argument fields (/x/ → X:/) for the Node-backed file tools;
@@ -21,7 +20,7 @@
21
20
  * (react and @deepseek-ai/dsh-client-ui-primitives only, smoke-enforced);
22
21
  * - plain React.createElement, no JSX/TS; components at module level;
23
22
  * - dsh.client.inject in package.json lists the packages that must load
24
- * first so locale / settingsScope / slots exist when this applies;
23
+ * first so locale / configForms / slots exist when this applies;
25
24
  * - copy ships in three layers: zh/en inline, one LOCALES entry per third
26
25
  * language (each preceded by a locale marker line), every dictionary
27
26
  * key-aligned with zh (smoke-enforced) and read through a live lookup —
@@ -1872,7 +1871,7 @@ window.__ModuleLoader__.load({
1872
1871
 
1873
1872
  // dsh 0.1.6-alpha.2: the Plugins page renders this card through the
1874
1873
  // `plugins.bundle.config` slot with view="page" — the page draws the
1875
- // title itself, so the collapsible shell is only for the legacy seat.
1874
+ // title itself; the collapsible shell only shows on the popup seat.
1876
1875
  var pageView = props.view === "page";
1877
1876
 
1878
1877
  var mounts = snap.value.virtualMounts !== false;
@@ -2637,32 +2636,16 @@ window.__ModuleLoader__.load({
2637
2636
 
2638
2637
  /**
2639
2638
  * Required client services: only era-guaranteed ones are hard-injected;
2640
- * the settings face is acquired OPTIONALLY (dsh 0.1.7 removed the
2641
- * settingsScope service and a hard inject would leave this fiber PENDING
2642
- * forever, taking the card down with it).
2639
+ * the settings face is acquired OPTIONALLY (a missing configForms on a
2640
+ * stripped-down host must not take the card down with it).
2643
2641
  */
2644
2642
  exports.inject = ["locale", "slots"];
2645
2643
 
2646
2644
  exports.apply = function (ctx) {
2647
- // Era-split settings face (same contract both eras: getSnapshot/set/unset).
2645
+ // The settings face: one ConfigForm per live profile entry; the form
2646
+ // key is the row id "gitbash-shell" (contract: getSnapshot/set/unset).
2648
2647
  var scope = null;
2649
2648
 
2650
- // OLD era (dsh <= 0.1.6): bound settings scope.
2651
- try {
2652
- ctx.inject(["settingsScope"], function (sctx) {
2653
- try {
2654
- var svc = sctx && sctx.settingsScope;
2655
- if (svc && typeof svc.bind === "function") scope = svc.bind({ namespace: SETTINGS_NAMESPACE });
2656
- } catch (error) {
2657
- console.warn(TAG + " settingsScope acquisition failed:", error && error.message ? error.message : error);
2658
- }
2659
- });
2660
- } catch (error) {
2661
- console.warn(TAG + " settingsScope wiring failed:", error && error.message ? error.message : error);
2662
- }
2663
-
2664
- // NEW era (dsh >= 0.1.7): one ConfigForm per live profile entry; the form
2665
- // key is the row id "gitbash-shell" (same string as the old namespace).
2666
2649
  try {
2667
2650
  ctx.inject(["configForms"], function (fctx) {
2668
2651
  try {
@@ -2714,23 +2697,8 @@ window.__ModuleLoader__.load({
2714
2697
  // members (top-level options fields do NOT reach the component).
2715
2698
  return { scope: scope, ctx: ctx };
2716
2699
  };
2717
- // Legacy seat (dsh <= 0.1.6-alpha.1): Settings → Plugins card.
2718
- slots.inject("settings.plugin.item", function () {
2719
- return slots.register({
2720
- name: "settings.plugin.item",
2721
- key: SETTINGS_NAMESPACE,
2722
- locale: NS,
2723
- inject: injected,
2724
- }, function CardWithLocale(props) {
2725
- /* The registration's locale field also hands out the framework's own
2726
- t seat; the plugin's lookup wins (21 locale tags against the catalog's
2727
- zh/en), passed last so no prop merge order can override it. */
2728
- return E(LocaleLive, { ctx: ctx, t: t, cardProps: props });
2729
- });
2730
- });
2731
- // dsh 0.1.6-alpha.2+: the Plugins page's bundle configuration seat,
2732
- // keyed by the PACKAGE name. Each inject waits for its own slot
2733
- // declaration, so exactly one seat is live on any host version.
2700
+ // The Plugins page's bundle configuration seat, keyed by the PACKAGE
2701
+ // name. Each inject waits for its own slot declaration.
2734
2702
  /* Root-level popup seat: rendered regardless of which page is open,
2735
2703
  which is the point — the user may never open settings. */
2736
2704
  slots.inject("shell.overlay", function () {
@@ -2750,7 +2718,7 @@ window.__ModuleLoader__.load({
2750
2718
  locale: NS,
2751
2719
  inject: injected,
2752
2720
  }, function BundleConfigWithBoundary(props) {
2753
- /* Same LocaleLive wrap as the legacy seat: the plugin's 21-tag
2721
+ /* Same LocaleLive wrap: the plugin's 21-tag
2754
2722
  dictionary wins over the catalog's zh/en t seat. */
2755
2723
  return E(LocaleLive, { ctx: ctx, t: t, cardProps: props });
2756
2724
  });
package/src/index.js CHANGED
@@ -276,7 +276,7 @@ export function effectivePresetIds(configured, { suppress, peerGitBash } = {}) {
276
276
  */
277
277
  import {
278
278
  DEFAULT_GIT_BASH, GIT_BASH_DOWNLOAD_URL, bashResolutionReport,
279
- effectiveConfiguredBashPath, executorConfiguredBashPath, resolveGitBashCached, settingsBashPath,
279
+ effectiveConfiguredBashPath, executorConfiguredBashPath, resolveGitBashCached,
280
280
  } from './bash-path.js'
281
281
  import { adoptTerminalShell, terminalAdoptReport } from './terminal-shell.js'
282
282
  import {
@@ -408,14 +408,12 @@ export function installRegisterShim(reg) {
408
408
  // ── unified path dialect: settings namespace + gate (v0.9.0) ───────────────
409
409
  //
410
410
  // The dialect (directive + translation wrapper) is OFF by default and gated
411
- // by the posixPaths boolean in the 'gitbash-shell' settings namespace,
412
- // flipped from the Settings → Plugins card (src/client.js). The directive's
413
- // text closure returns '' while off — empty context contributions are
414
- // dropped at assembly, so a disabled dialect adds zero prompt noise; the
411
+ // by the posixPaths boolean on this plugin's row Config (volatile, read live
412
+ // per dispatch), flipped from the plugin's settings card (src/client.js). The
413
+ // directive's text closure returns '' while off — empty context contributions
414
+ // are dropped at assembly, so a disabled dialect adds zero prompt noise; the
415
415
  // wrapper reads the same value per dispatch and passes calls through.
416
416
 
417
- const SETTINGS_NAMESPACE = 'gitbash-shell'
418
-
419
417
  /**
420
418
  * dsh >= 0.1.7 marks a Config field live-editable without remount; a
421
419
  * 0.1.6-era schemastery predates the method and the guard keeps this module
@@ -483,6 +481,24 @@ export function valueOf(value) {
483
481
  return value && typeof value.get === 'function' ? value.get() : value
484
482
  }
485
483
 
484
+ /**
485
+ * This row's OWN live `bashPath` (issue #13): apply()'s `config` parameter IS
486
+ * the `gitbash-shell` row's resolved Config, so reading it here replaces the
487
+ * settings-service detour (`settingsBashPath`) that could read '' at apply
488
+ * time on hosts where the service is describe()/update()-only or not injected
489
+ * yet. Volatile refs mean a settings edit lands on the next read, no listener.
490
+ * @param {object|undefined} config - apply()'s resolved Config (may be absent).
491
+ * @returns {string} the row's `bashPath`, '' when unset or unreadable.
492
+ */
493
+ export function rowBashPath(config) {
494
+ try {
495
+ const value = config && typeof config === 'object' ? valueOf(config.bashPath) : undefined
496
+ return typeof value === 'string' ? value.trim() : ''
497
+ } catch {
498
+ return ''
499
+ }
500
+ }
501
+
486
502
  /**
487
503
  * Era-aware live settings reader for everything wired inside apply(): the
488
504
  * NEW era reads this plugin's own Config refs (no listener needed — every
@@ -493,14 +509,16 @@ export function valueOf(value) {
493
509
  * @returns {{ dialect: () => object, posix: () => boolean, adoptSidebar: () => boolean, suppressPeerCordis: () => boolean }}
494
510
  */
495
511
  function makeLiveReader(ctx, config) {
496
- let legacy = false
497
- try {
498
- const settings = ctx.get('settings')
499
- legacy = !!(settings && typeof settings.register === 'function')
500
- } catch { legacy = false }
501
512
  const hasConfig = !!(config && typeof config === 'object')
502
513
  const dialect = () => {
503
- if (legacy || !hasConfig) return readDialectSettings(ctx)
514
+ if (!hasConfig) {
515
+ // No row Config (schemastery unresolvable): everything off except the
516
+ // opt-out booleans, the exact shape the old fallback returned.
517
+ return {
518
+ posixPaths: false, virtualMounts: false, globSplit: false, errorDialect: false,
519
+ codePaths: false, gitAutocrlf: false, bashPath: '', subagentDialect: true, autoTerminalShell: true,
520
+ }
521
+ }
504
522
  const bash = valueOf(config.bashPath)
505
523
  return {
506
524
  posixPaths: valueOf(config.posixPaths) === true,
@@ -522,8 +540,8 @@ function makeLiveReader(ctx, config) {
522
540
  posix: () => dialect().posixPaths === true,
523
541
  adoptSidebar: () => dialect().adoptSidebar !== false,
524
542
  autoTerminalShell: () => dialect().autoTerminalShell !== false,
525
- // Default OFF on both eras: only an explicit true asks for deduplication.
526
- suppressPeerCordis: () => (legacy || !hasConfig ? readSuppressPeerCordis(ctx) : valueOf(config.suppressPeerCordis) === true),
543
+ // Default OFF: only an explicit true asks for deduplication.
544
+ suppressPeerCordis: () => (hasConfig ? valueOf(config.suppressPeerCordis) === true : false),
527
545
  }
528
546
  }
529
547
 
@@ -568,79 +586,6 @@ const PATH_DIALECT_DESCRIPTION = 'Path dialect for tool calls and tool results:
568
586
  // They cannot ride the shell-env registry below: that registry accepts DSH_*
569
587
  // facts only, and a non-DSH key throws the whole contribution away.
570
588
 
571
- /** Read the posixPaths switch from the live settings service; never throws. */
572
- export function readPosixPaths(ctxLike) {
573
- try {
574
- const settings = ctxLike && typeof ctxLike.get === 'function' ? ctxLike.get('settings') : undefined
575
- const value = settings && typeof settings.get === 'function' ? settings.get(SETTINGS_NAMESPACE) : undefined
576
- return !!(value && value.posixPaths === true)
577
- } catch {
578
- return false
579
- }
580
- }
581
-
582
- /**
583
- * Read every dialect switch at once (v0.19.0): posixPaths gates the whole
584
- * dialect; virtualMounts gates the bash virtual mounts + ~ + $VAR expansion
585
- * (off = strict drive-root-only translation); globSplit gates the absolute
586
- * glob-pattern rewrite; errorDialect gates error-message translation and the
587
- * NUL guidance block; bashPath (optional) joins the Git-root probe candidates
588
- * first. All default ON (schema defaults); never throws.
589
- */
590
- export function readDialectSettings(ctxLike) {
591
- const fallback = { posixPaths: false, virtualMounts: false, globSplit: false, errorDialect: false, codePaths: false, gitAutocrlf: false, bashPath: '', subagentDialect: true, autoTerminalShell: true }
592
- try {
593
- const settings = ctxLike && typeof ctxLike.get === 'function' ? ctxLike.get('settings') : undefined
594
- const value = settings && typeof settings.get === 'function' ? settings.get(SETTINGS_NAMESPACE) : undefined
595
- const v = value && typeof value === 'object' ? value : {}
596
- return {
597
- posixPaths: v.posixPaths === true,
598
- virtualMounts: v.virtualMounts === true,
599
- globSplit: v.globSplit === true,
600
- errorDialect: v.errorDialect === true,
601
- codePaths: v.codePaths === true,
602
- // An old host without the key keeps the default (delegated agents covered).
603
- subagentDialect: v.subagentDialect !== false,
604
- // Default ON: a host without the key still gets the terminal adoption.
605
- autoTerminalShell: v.autoTerminalShell !== false,
606
- gitAutocrlf: v.gitAutocrlf === true,
607
- bashPath: typeof v.bashPath === 'string' ? v.bashPath : '',
608
- }
609
- } catch {
610
- return fallback
611
- }
612
- }
613
-
614
- /**
615
- * Read the adoptSidebar switch (v0.15.0, default ON). Unlike posixPaths the
616
- * default is on, so only an explicit false disables the takeover.
617
- */
618
- export function readAdoptSidebar(ctxLike) {
619
- try {
620
- const settings = ctxLike && typeof ctxLike.get === 'function' ? ctxLike.get('settings') : undefined
621
- const value = settings && typeof settings.get === 'function' ? settings.get(SETTINGS_NAMESPACE) : undefined
622
- return !(value && value.adoptSidebar === false)
623
- } catch {
624
- return true
625
- }
626
- }
627
-
628
- /**
629
- * Read the peer-dedupe switch (v0.25.0, default OFF) from the OLD era's
630
- * registered namespace — on dsh >= 0.1.7 the row Config is the surface and
631
- * `makeLiveReader` reads it directly. Never throws.
632
- */
633
- export function readSuppressPeerCordis(ctxLike) {
634
- try {
635
- const settings = ctxLike && typeof ctxLike.get === 'function' ? ctxLike.get('settings') : undefined
636
- const value = settings && typeof settings.get === 'function' ? settings.get(SETTINGS_NAMESPACE) : undefined
637
- return !!(value && value.suppressPeerCordis === true)
638
- } catch {
639
- return false
640
- }
641
- }
642
-
643
-
644
589
  // Windows absolute path -> MSYS drive-root form, for rewriting PROSE in place
645
590
  // (v0.10.0). Quoted spaced paths ("C:\Program Files\Git") are matched whole; a bare
646
591
  // path stops at a closing punctuation and crosses a space ONLY into a token that
@@ -1470,10 +1415,6 @@ const SIDEBAR_NS = 'dsh-better-sidebar'
1470
1415
  const SIDEBAR_TRIES = 12
1471
1416
  const SIDEBAR_RETRY_MS = 1500
1472
1417
 
1473
- // Owner scope of the gitbash-shell namespace once registered (shared with
1474
- // the register callback so a late registration can reconcile + watch).
1475
- let sidebarScope = undefined
1476
-
1477
1418
  // Reconciler installed by adoptSidebarShell; a no-op until then.
1478
1419
  let reconcileSidebar = () => {}
1479
1420
 
@@ -1502,14 +1443,9 @@ function adoptSidebarShell(ctx, bashPath, readAdopt) {
1502
1443
  const settings = ctx.get('settings')
1503
1444
  if (!settings || typeof settings.update !== 'function') return null
1504
1445
  try {
1505
- // <=0.1.6: the sidebar registers a settings namespace and `get(ns)` reads
1506
- // it. >=0.1.7: that API is gone (the service exposes describe/update
1507
- // only), so the value comes from the row Config's projected form —
1508
- // without this branch the adoption was silently skipped on the new host.
1509
- if (typeof settings.get === 'function') {
1510
- const value = settings.get(SIDEBAR_NS)
1511
- return value && typeof value === 'object' && typeof value.terminalShell === 'string' ? value.terminalShell : ''
1512
- }
1446
+ // The sidebar's value lives in ITS row Config, projected into the
1447
+ // settings describe() list; the service exposes describe/update only
1448
+ // (the old `get(ns)` reader died with dsh 0.1.6).
1513
1449
  if (typeof settings.describe === 'function') {
1514
1450
  const entry = settings.describe().find((row) => row !== null && typeof row === 'object' && row.ns === SIDEBAR_NS)
1515
1451
  const value = entry === undefined ? undefined : entry.value
@@ -1560,18 +1496,7 @@ function adoptSidebarShell(ctx, bashPath, readAdopt) {
1560
1496
  const run = () => {
1561
1497
  tried += 1
1562
1498
  const ok = reconcileSidebar(readAdopt())
1563
- if (ok) {
1564
- // The namespace scope may register after the settings service; hook
1565
- // the live watch once it exists (idempotent via the effect disposer).
1566
- if (sidebarScope && typeof sidebarScope.watch === 'function' && !run.watched) {
1567
- run.watched = true
1568
- try {
1569
- const off = sidebarScope.watch((next) => { reconcileSidebar(next && next.adoptSidebar !== false) })
1570
- ctx.effect(() => off, 'dsh-gitbash-shell: adoptSidebar watch (polling path)')
1571
- } catch { /* best effort */ }
1572
- }
1573
- return
1574
- }
1499
+ if (ok) return
1575
1500
  if (tried < SIDEBAR_TRIES) {
1576
1501
  timer = setTimeout(run, SIDEBAR_RETRY_MS)
1577
1502
  return
@@ -1898,106 +1823,48 @@ export async function apply(ctx, config = {}) {
1898
1823
  // materialize: service present, 'active: true' on Windows, 'active: false'
1899
1824
  // where the bundle is installed but the platform stack stayed native.
1900
1825
  /* The explicit tier of the resolution chain, in its documented priority:
1901
- the `gitbash-executor` row's own config > the `gitbash-shell` row/settings
1902
- field > empty (= run the automatic chain). Both this layer and the
1903
- executor resolve from this same value, so an explicit answer is never
1826
+ the `gitbash-executor` row's own config > the `gitbash-shell` row's
1827
+ `bashPath` field > empty (= run the automatic chain). Both this layer and
1828
+ the executor resolve from this same value, so an explicit answer is never
1904
1829
  substituted and never silently ignored.
1905
- (issue #11: the row config is where the reporter's workaround landed.) */
1906
- const configuredBashPath = effectiveConfiguredBashPath(executorConfiguredBashPath(ctx), settingsBashPath(ctx))
1907
- const bashResolution = resolveGitBashCached({ configured: configuredBashPath })
1830
+ (issue #11: the row config is where the reporter's workaround landed.)
1831
+ LAZY, never frozen (issue #13): at apply() time the loader store may not
1832
+ hold the executor row yet — `loader.resolve()` throws and reads '' — and
1833
+ the old settings-service detour could equally read '' before the service
1834
+ was injected. A frozen '' then ran the automatic chain (which fails on
1835
+ shimmed Git layouts) while the executor's own per-call lazy resolution
1836
+ worked, i.e. the same boot gave OPPOSITE verdicts. Every consumer below
1837
+ re-reads per use; the own row's value comes from apply()'s `config`
1838
+ parameter (this row's live Config refs — volatile, so a settings edit is
1839
+ visible on the next read without any listener). */
1840
+ const readConfiguredBashPath = () => effectiveConfiguredBashPath(executorConfiguredBashPath(ctx), rowBashPath(config))
1841
+ const readBashResolution = () => resolveGitBashCached({ configured: readConfiguredBashPath() })
1908
1842
  const gitBashCapability = {
1909
1843
  active: process.platform === 'win32',
1910
1844
  // Kept for existing consumers (peer plugins read `bashPath`): the resolved
1911
1845
  // interpreter. When resolution failed we report the explicit value (or the
1912
1846
  // historical default) so the ENOENT a consumer may hit names a real path —
1913
- // never a substitute shell.
1914
- bashPath: bashResolution.ok ? bashResolution.path : (configuredBashPath || DEFAULT_GIT_BASH),
1847
+ // never a substitute shell. Getters keep the capability live (issue #13):
1848
+ // a consumer reading it after the rows settle sees the settled verdict.
1849
+ get bashPath() {
1850
+ const resolution = readBashResolution()
1851
+ return resolution.ok ? resolution.path : (readConfiguredBashPath() || DEFAULT_GIT_BASH)
1852
+ },
1915
1853
  // Additive (v0.28.0): the full verdict, for the guided popup and peers.
1916
- ok: bashResolution.ok,
1917
- source: bashResolution.source,
1918
- configured: configuredBashPath,
1919
- tried: bashResolution.tried,
1854
+ get ok() { return readBashResolution().ok },
1855
+ get source() { return readBashResolution().source },
1856
+ get configured() { return readBashResolution().configured },
1857
+ get tried() { return readBashResolution().tried },
1920
1858
  downloadUrl: GIT_BASH_DOWNLOAD_URL,
1921
1859
  }
1922
1860
  const disposeGitBash = ctx.provide('gitBash', gitBashCapability)
1923
1861
  ctx.effect(() => disposeGitBash, 'dsh-gitbash-shell: gitBash capability')
1924
1862
 
1925
- // Era-aware live settings: NEW hosts read this plugin's own Config refs
1926
- // (volatile, re-read per dispatch/assembly), OLD hosts keep the registered
1927
- // namespace. Every consumer below already re-reads on every use, so a
1928
- // flipped knob lands on the next dispatch with zero wiring.
1863
+ // Era-aware live settings: every consumer below re-reads the row Config
1864
+ // refs on every use (volatile), so a flipped knob lands on the next
1865
+ // dispatch with zero wiring.
1929
1866
  const liveSettings = makeLiveReader(ctx, config)
1930
1867
 
1931
- // ── settings namespace: the posixPaths switch (default ON, v0.1.0) — OLD
1932
- // hosts only; on dsh >= 0.1.7 the row Config above IS the surface.
1933
-
1934
- // Served on the host so the Plugins tab pairs it with the browser card
1935
- // (the tab dispatches Host-served namespaces ∩ registered cards). Dynamic
1936
- // imports keep the zero-dependency smoke path importable; the schema MUST
1937
- // be a callable schemastery object (dsh-settings calls schema(merged)).
1938
- const legacySettings = (() => {
1939
- try {
1940
- const settings = ctx.get('settings')
1941
- return !!(settings && typeof settings.register === 'function')
1942
- } catch {
1943
- return false
1944
- }
1945
- })()
1946
- if (legacySettings) try {
1947
- ctx.inject(['settings'], (sctx) => {
1948
- Promise.resolve(import('@deepseek-ai/dsh-settings'))
1949
- .then((ds) => {
1950
- const settings = sctx && sctx.settings
1951
- if (!settings || typeof settings.register !== 'function') return
1952
- // The SAME copy the executor and the row Config were built from
1953
- // (issue #12): one resolution per process, so the two faces of this
1954
- // plugin can never disagree about the schema vocabulary.
1955
- if (!Schema) return
1956
- // Era probe: newer dsh register() takes a plain string; the older
1957
- // one accepted the branded helper — one call satisfies both.
1958
- const ns = typeof ds.settingsNamespace === 'function'
1959
- ? ds.settingsNamespace(SETTINGS_NAMESPACE)
1960
- : SETTINGS_NAMESPACE
1961
- const scope = settings.register(ns, Schema.object({
1962
- posixPaths: Schema.boolean().default(true),
1963
- virtualMounts: Schema.boolean().default(true),
1964
- globSplit: Schema.boolean().default(true),
1965
- errorDialect: Schema.boolean().default(true),
1966
- codePaths: Schema.boolean().default(true),
1967
- subagentDialect: Schema.boolean().default(true),
1968
- gitAutocrlf: Schema.boolean().default(true),
1969
- bashPath: Schema.string().default(''),
1970
- adoptSidebar: Schema.boolean().default(true),
1971
- // v0.29.0: official sidebar terminal adoption (default ON).
1972
- autoTerminalShell: Schema.boolean().default(true),
1973
- // v0.25.0: same switch as the row Config carries on dsh >= 0.1.7
1974
- // (the OLD era has no cross-namespace edits, so this namespace is
1975
- // the only side that can change it here).
1976
- suppressPeerCordis: Schema.boolean().default(false),
1977
- }))
1978
- sidebarScope = scope
1979
- console.log(`${TAG} settings namespace registered: ${SETTINGS_NAMESPACE} (posixPaths default on, adoptSidebar default on)`)
1980
- // A late register (settings service up after the adoption polling
1981
- // finished) still reconciles once, and the live watch flips the
1982
- // takeover when the user toggles the card switch.
1983
- try {
1984
- if (typeof scope?.watch === 'function') {
1985
- const off = scope.watch((next) => { reconcileSidebar(next && next.adoptSidebar !== false) })
1986
- ctx.effect(() => off, 'dsh-gitbash-shell: adoptSidebar watch')
1987
- }
1988
- reconcileSidebar(readAdoptSidebar(ctx))
1989
- } catch (error) {
1990
- console.log(`${TAG} adoptSidebar watch wiring failed: ${error?.message ?? error}`)
1991
- }
1992
- })
1993
- .catch((error) => {
1994
- console.log(`${TAG} settings namespace registration FAILED: ${error && error.stack || String(error)}`)
1995
- })
1996
- })
1997
- } catch (error) {
1998
- console.log(`${TAG} settings inject wiring failed: ${error?.message ?? error}`)
1999
- }
2000
-
2001
1868
  // ── official shell-env fact: DSH_PATH_DIALECT (Windows only, gated; v0.11.0) ──
2002
1869
  // dsh-shell-env owns the model-visible $DSH_* facts (host-plane service;
2003
1870
  // the web composition injects it and publishes DSH_WEB_URL beside the
@@ -2143,7 +2010,7 @@ export async function apply(ctx, config = {}) {
2143
2010
  // untouched and the result metadata is not echoed back in MSYS form.
2144
2011
  if (dialect.posixPaths && dialectApplies(dialect, exec && exec.agent)) {
2145
2012
  echo = true
2146
- const env = dialect.virtualMounts ? buildTranslateEnv(configuredBashPath) : null
2013
+ const env = dialect.virtualMounts ? buildTranslateEnv(readConfiguredBashPath()) : null
2147
2014
  echoEnv = env
2148
2015
  if (exec && exec.arguments && typeof exec.arguments === 'object') {
2149
2016
  const translated = translateDispatch(exec, dialect, env)
@@ -2198,7 +2065,7 @@ export async function apply(ctx, config = {}) {
2198
2065
  if (!dialectApplies(dialect, exec && exec.agent)) return next()
2199
2066
  if (dialect.posixPaths && dialect.errorDialect && exec && result && typeof result === 'object'
2200
2067
  && result.isError === true && Array.isArray(result.content)) {
2201
- const env = dialect.virtualMounts ? buildTranslateEnv(configuredBashPath) : null
2068
+ const env = dialect.virtualMounts ? buildTranslateEnv(readConfiguredBashPath()) : null
2202
2069
  if (exec.name === 'run_code') {
2203
2070
  // paths only — the /dev/null hint is for file tools
2204
2071
  const content = rewriteErrorContent(result.content, { nulHint: false, env })
@@ -2272,14 +2139,59 @@ export async function apply(ctx, config = {}) {
2272
2139
  // Now the boot log names the value, the whole probe chain and where to fix
2273
2140
  // it, and the client half reads the same verdict over HTTP to raise the
2274
2141
  // guided popup. Nothing here substitutes another shell — by design.
2275
- console.log(TAG + ' bash resolution: ' + bashResolutionReport(bashResolution).split('\n').join('\n' + TAG + ' '))
2142
+ //
2143
+ // issue #13: the boot verdict may legitimately be wrong for a few seconds —
2144
+ // at apply() time the loader store may not hold the executor row yet, so
2145
+ // the explicit tier reads '' while the executor's own per-call lazy
2146
+ // resolution succeeds. A FAILURE with an EMPTY configured value is therefore
2147
+ // not final: poll briefly for the explicit tier to appear as the rows
2148
+ // settle, print the settled verdict when it changes, and notify the
2149
+ // adoptions below (official terminal / better-sidebar) through
2150
+ // `onBashSettled`. A failure with a NON-empty configured value IS final (the
2151
+ // user's explicit answer was rejected), and so is any success.
2152
+ let settledBashResolution = null
2153
+ const bashSettleWaiters = []
2154
+ const onBashSettled = (listener) => {
2155
+ if (settledBashResolution !== null) listener(settledBashResolution)
2156
+ else bashSettleWaiters.push(listener)
2157
+ }
2158
+ const settleBashResolution = (resolution) => {
2159
+ settledBashResolution = resolution
2160
+ for (const listener of bashSettleWaiters.splice(0)) {
2161
+ try { listener(resolution) } catch { /* a waiter never blocks settling */ }
2162
+ }
2163
+ }
2164
+ const printBashResolution = (phase, resolution) => {
2165
+ console.log(TAG + ' bash resolution' + (phase === '' ? '' : ' (' + phase + ')') + ': '
2166
+ + bashResolutionReport(resolution).split('\n').join('\n' + TAG + ' '))
2167
+ }
2168
+ const bootBashResolution = readBashResolution()
2169
+ printBashResolution('boot', bootBashResolution)
2170
+ // Non-win32 hosts settle immediately: resolution failure there is the
2171
+ // designed state (nothing consumes the capability), so no poll.
2172
+ if (bootBashResolution.ok || bootBashResolution.configured !== '' || process.platform !== 'win32') {
2173
+ settleBashResolution(bootBashResolution)
2174
+ } else {
2175
+ let ticks = 0
2176
+ const timer = setInterval(() => {
2177
+ ticks += 1
2178
+ if (readConfiguredBashPath() === '' && ticks < 20) return // rows still mounting
2179
+ clearInterval(timer)
2180
+ const settled = readBashResolution()
2181
+ if (settled.ok || settled.configured !== '') printBashResolution('settled', settled)
2182
+ settleBashResolution(settled)
2183
+ }, 250)
2184
+ ctx.effect(() => () => clearInterval(timer), 'dsh-gitbash-shell: bash resolution settle poll')
2185
+ }
2276
2186
  if (process.platform === 'win32') {
2277
2187
  try {
2278
2188
  ctx.inject(['webServer'], (wctx) => {
2279
2189
  try {
2280
2190
  const route = '/dsh-gitbash-shell/api/status'
2281
2191
  const read = () => {
2282
- const resolution = resolveGitBashCached({ configured: configuredBashPath, fresh: true })
2192
+ // LIVE re-read (issue #13): the popup must see what the executor
2193
+ // actually resolves NOW, not a value frozen at apply() time.
2194
+ const resolution = resolveGitBashCached({ configured: readConfiguredBashPath(), fresh: true })
2283
2195
  return {
2284
2196
  ok: resolution.ok,
2285
2197
  platform: process.platform,
@@ -2342,7 +2254,7 @@ export async function apply(ctx, config = {}) {
2342
2254
  send(res, 403, { error: 'forbidden', reason: fence.reason })
2343
2255
  return
2344
2256
  }
2345
- const env = buildTranslateEnv(configuredBashPath)
2257
+ const env = buildTranslateEnv(readConfiguredBashPath())
2346
2258
  // One line per read: the browser half fetches this once per page load,
2347
2259
  // so its presence in the log is how a user can tell the rescue is live.
2348
2260
  console.log(TAG + ' path map read by ' + (req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : '?'))
@@ -2468,10 +2380,14 @@ export async function apply(ctx, config = {}) {
2468
2380
  attempts += 1
2469
2381
  let result
2470
2382
  try {
2383
+ // LAZY re-read per attempt (issue #13): the first try may run
2384
+ // before the loader store holds the executor row, when the
2385
+ // resolution legitimately still reads ''.
2386
+ const resolution = readBashResolution()
2471
2387
  result = await adoptTerminalShell(editorCtx.configEditor, {
2472
2388
  platform: process.platform,
2473
2389
  enabled: liveSettings.autoTerminalShell(),
2474
- resolved: bashResolution.ok ? bashResolution.path : '',
2390
+ resolved: resolution.ok ? resolution.path : '',
2475
2391
  })
2476
2392
  } catch (error) {
2477
2393
  // adoptTerminalShell reports expected conditions itself; reaching
@@ -2496,9 +2412,20 @@ export async function apply(ctx, config = {}) {
2496
2412
  // The sidebar resolves its terminal shell through the settings seam per
2497
2413
  // open; adopt it through that seam (see adoptSidebarShell for rationale).
2498
2414
  // Disable with `betterSidebarShell: false` in the plugin row config.
2415
+ // Rides the settle notification (issue #13): a boot-time failure with an
2416
+ // empty explicit tier may still settle into a success once the loader rows
2417
+ // mount, and the adoption then happens without a restart.
2499
2418
  if (config.betterSidebarShell !== false && process.platform === 'win32') {
2500
- if (bashResolution.ok) adoptSidebarShell(ctx, bashResolution.path, () => liveSettings.adoptSidebar())
2501
- else console.log(TAG + ' sidebar terminal adoption skipped: no Git Bash resolved (nothing is substituted)')
2419
+ let sidebarAdopted = false
2420
+ onBashSettled((resolution) => {
2421
+ if (resolution.ok) {
2422
+ if (sidebarAdopted) return
2423
+ sidebarAdopted = true
2424
+ adoptSidebarShell(ctx, resolution.path, () => liveSettings.adoptSidebar())
2425
+ return
2426
+ }
2427
+ console.log(TAG + ' sidebar terminal adoption skipped: no Git Bash resolved (nothing is substituted)')
2428
+ })
2502
2429
  }
2503
2430
 
2504
2431
  // ── era split: declarative registration on dsh >= 0.1.7 ──────────────────
@@ -2516,4 +2443,4 @@ export async function apply(ctx, config = {}) {
2516
2443
 
2517
2444
  }
2518
2445
  // Test surface: pure helpers, no Cordis context required.
2519
- export const _internal = { PRESET_IDS, PEER_COVERED_PRESET_ID, PEER_CAPABILITY, isDelegatedAgent, dialectApplies, PEER_PYTHON_FIELD, PEER_PYTHON_BACKEND_FIELD, peerFact, peerBackend, pythonBackendActive, effectivePresetIds, readSuppressPeerCordis, translateDispatch, translateMsysPath, translatePathArguments, rewriteCodePaths, scanCodeLiterals, programPrelude, translateGlobArguments, buildTranslateEnv, rewriteErrorContent, rewriteErrorMessage, rewriteFailureMessage, msysEcho, driveToMsys, pathEcho, ERROR_CONTENT_TOOLS, readPosixPaths, readAdoptSidebar, readDialectSettings, windowsToMsys, rewriteResultPaths, adoptSidebarShell, MARKER_FILE, classify, hashTree, installRegisterShim }
2446
+ export const _internal = { PRESET_IDS, PEER_COVERED_PRESET_ID, PEER_CAPABILITY, isDelegatedAgent, dialectApplies, PEER_PYTHON_FIELD, PEER_PYTHON_BACKEND_FIELD, peerFact, peerBackend, pythonBackendActive, effectivePresetIds, translateDispatch, translateMsysPath, translatePathArguments, rewriteCodePaths, scanCodeLiterals, programPrelude, translateGlobArguments, buildTranslateEnv, rewriteErrorContent, rewriteErrorMessage, rewriteFailureMessage, msysEcho, driveToMsys, pathEcho, ERROR_CONTENT_TOOLS, windowsToMsys, rewriteResultPaths, adoptSidebarShell, MARKER_FILE, classify, hashTree, installRegisterShim }
package/src/shell.js CHANGED
@@ -316,19 +316,16 @@ export class GitBashSandboxExecutor extends SandboxBashExecutor {
316
316
  * @returns {object} the request with the parity facts merged in
317
317
  */
318
318
  /**
319
- * The plugin row's live dialect settings, across eras. dsh 0.1.7 replaced the
320
- * settings namespace API: the service still exists but exposes only
321
- * `describe()` / `update()` — `get(ns)` is gone — so the old read silently
322
- * returned undefined on the new host and the Linux-parity env below never
323
- * applied. The new era reads the row Config's projected values from the form
324
- * descriptors (the same read dsh-agent-lang uses for the `locale` namespace).
319
+ * The plugin row's live dialect settings. The settings service exposes only
320
+ * `describe()` / `update()`, so the row Config's projected values come from
321
+ * the form descriptors (the same read dsh-agent-lang uses for the `locale`
322
+ * namespace).
325
323
  * @returns the row's settings object, or undefined when unavailable.
326
324
  */
327
325
  dialectSettings() {
328
326
  try {
329
327
  const settings = this.ctx && typeof this.ctx.get === 'function' ? this.ctx.get('settings') : undefined
330
328
  if (settings === undefined || settings === null) return undefined
331
- if (typeof settings.get === 'function') return settings.get(SETTINGS_NAMESPACE)
332
329
  if (typeof settings.describe === 'function') {
333
330
  const entry = settings.describe().find((row) => row !== null && typeof row === 'object' && row.ns === SETTINGS_NAMESPACE)
334
331
  return entry === undefined ? undefined : entry.value