dsh-gitbash-shell 0.39.0 → 0.39.2

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,84 @@
3
3
  > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。
4
4
  > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。
5
5
 
6
+ ## v0.39.2 — 2026-10-13
7
+
8
+ **类型**:fix(route B 工具/预设生命周期、动态配置与客户端回归) / test。
9
+
10
+ - **复现与裁定**:两名 Sol 分区审查、Grok 交叉反证与主代理隔离复验确认了工具面、生命周期、
11
+ 组合、代码扫描和客户端状态转换的问题;普通子 scope 的原始冲突仍由 v0.39.1 的 `shell` 隔离修复。
12
+ FastCtx 全文路径回显是既定策略,不误当文件工具替换问题;minimal 的 persistent PTY 不与全工具
13
+ 变体的执行器来源混为一谈。只修既有功能,不新增功能、改 root shell 或市场 patch。
14
+ - **变体接线**:Windows 全工具 Git Bash 变体不再启用依赖 root PowerShell 的官方 `tool-bash`;
15
+ 保留官方 pwsh 面,经同一行内补位通道接入私有 Git Bash 工具。minimal 的 persistent 通道保持。
16
+ - **工具生命周期**:存活 agent 的 `pwshTool` 双向翻转同步解除/重建自身的 restrict;异步导入/
17
+ 挂载在插件或 agent 退役后不再迟到注册;jobs 服务卸载按真实 Cordis 活跃状态恢复前台 bash。
18
+ - **降级分支**:plain definition 的 required 标志递归转换为合法 JSON Schema;升级助手不可用
19
+ 时仍解析并传递每会话 standing policy,不错误回退部署默认。两项为模块解析降级的受控复现,
20
+ 不宣称本机生产已发生导入失败,也不改变 Windows 受限模式明示 unconfined 的既定取舍。
21
+ - **预设与默认值**:串行 reconcile 退役时关闭新任务、清理晚到 register 并等待 unregister;
22
+ 动态关闭变体在退役前迁回部署默认,避免后续新会话引用不存在的 preset。既有钉住会话不迁移。
23
+ - **路径与终端**:run_code 扫描辨认关键字后的正则并在不确定时整份 no-op,不改正则数据;
24
+ FastCtx 入/出参获得 execution 上下文并服从总方言、委托与虚拟挂载开关;better-sidebar 的
25
+ adoptSidebar 动态翻转恢复接管前值并保护接管后的用户手选,不改变官方 autoTerminalShell
26
+ 关后只停止未来写入的独立语义。
27
+ - **FastCtx 错误**:子进程/写管道 error 进入初始化 Promise,失败清理 pending 计时器与连接;
28
+ MCP `isError:true` 按官方桥约定抛错,不再把失败包装成宿主成功 value。
29
+ 发现与连接启动采用 single-flight,拥有在途连接并在卸载时取消/等待,不让并发 agent 的发现流程
30
+ 覆盖已在使用的 lazy client。该丢失所有权链已用受控 MCP 传输复现,并新增关闭/晚注册回归。
31
+ - **客户端**:设置卡所有 Hooks 在 readiness early-return 前调用;工具链进度轮询在网络/HTTP
32
+ 暂态失败后重试,卸载或失效时取消,不因单次请求失败永久卡在忙碌状态。
33
+ - **回归入口**:新增可移植客户端/桥/运行时/宿主回归与实际 Core preview 集成用例,接入现有
34
+ `npm test`/`npm run test:preview`;不新增依赖、不跳过旧断言或 Node 22 矩阵。对齐上游 v0.39.1
35
+ (`521be01`)后完整测试 **156/156**、实际 Core 无头 preview **14/14**、打包 dry-run 和 diff 检查
36
+ 通过。新增执行级回归保留 #21 的 real-array glob 契约与 stringified-array 响亮拒绝;不改已发布
37
+ 版本号、历史记录或 npm 发布流程。Grok 对修复 diff 的只读反证未发现确定遗漏。
38
+ 首轮全量检查发现三条旧源码正则仍锁旧实现,改为对应新保护条件并补同值/手选行为测试,没有删掉
39
+ 保护断言。原 [Issue #19](https://github.com/KannaKuron/dsh-gitbash-shell/issues/19) 和
40
+ [PR #20](https://github.com/KannaKuron/dsh-gitbash-shell/pull/20) 已完成;这些后续修复由新 PR 承载,
41
+ Windows/Linux × Node 22/24 的远端结果见其检查。preview 不等同于完整 Electron GUI 或真 MCP
42
+ exe 并发验收,模块解析降级使用受控夹具复现。
43
+
44
+ ## v0.39.1 — 2026-10-13
45
+
46
+ **类型**:fix(route B 私有执行器的真实服务隔离与读取授权) / test / ci。
47
+
48
+ - **根因**:普通 `ctx.plugin()` 子 fiber 只隔离副作用所有权,不隔离服务命名空间。
49
+ `GitBashSandboxExecutor` 仍经 `SandboxBashExecutor → LocalBashExecutor → ShellExecutor → Service`
50
+ 注册 `shell`,与 route B 保留的 `SandboxPwshExecutor` 冲突,报
51
+ `service "shell" has been registered at <SandboxPwshExecutor>`;bash 工具尚未注册即中断。
52
+ Git Bash 路径已成功解析时同样发生,不是工具发现或路径探测问题。
53
+ - **修复**:`mountPrivateExecutor` 仅对 `shell` 调用 `ctx.isolate('shell')`,其余宿主依赖原样继承;
54
+ 类插件仍走官方 `plugin()` 通道,Config/volatile/static inject 与生命周期不变。
55
+ 空 scope 通过 `get('shell')` 读取活跃私有实例,工具就绪检查用 `get('tools')`,避免
56
+ 缺少 inject 声明的属性访问再度中断补位。失败时销毁已建 scope,成功/失败均清理挂载计时器。
57
+ - **边界**:不撤官方 PowerShell 执行器、不改 bundle patch、不改 shell 执行/沙箱/后台协议,
58
+ 不抬 Core 兼容下限,不发版、不触发 npm 发布。
59
+ - **回归**:新增 5 项真实 Cordis 测试,覆盖普通子 scope 冲突、显式隔离后的 Config/inject 与
60
+ 根 shell 保留、卸载、构造失败清理/再挂载、缺服务失败与挂载超时清理;不是仅正则源码检查。
61
+ 既有 minimal PTY 测试的非 Windows 断言现在显式模拟 Linux,不再在恢复 win32 后误断言 `/bin/bash`。
62
+ 首轮远端 CI 发现两项独立 error-content 测试误嵌套在父测试中,Node 22 会取消未等待的子测试;
63
+ 恢复独立结构并保留全部断言,不跳过测试、不移除 Node 22 矩阵。
64
+ - **验证入口**:`npm test` 合并 smoke 与真实 Cordis 回归;新增 `npm run test:preview`,通过
65
+ 显式 `DSH_PREVIEW_HOST_DIR` 只读解析目标宿主包,在新 Context + 临时 cwd 做无头运行时 preview。
66
+ Windows 本机已通过:Core **0.2.1-alpha.2**,宿主 Cordis **4.0.5-alpha.1**,Node **24.13.0**;
67
+ 原路径真实重现服务冲突,修复后 bash 出现在实际 ToolRuntime schema 中并执行
68
+ `command -v bash`/`uname -s`/sentinel(退出码 0),官方 pwsh 执行 `Get-Process`,
69
+ `pwshTool` 开/关两态与卸载恢复均通过。**不等同于完整 GUI、PTY、后台任务或受限模式验收**。
70
+ - **CI**:新增非发布 `ci` 工作流(push 修复分支 / PR / 手动触发),Windows/Linux × Node 22/24,
71
+ 锁定依赖、禁止生命周期脚本与可选载荷、完整测试及 `npm pack --dry-run --ignore-scripts`。
72
+ 本机完整测试 **127/127 通过**,打包预检与 `git diff --check` 通过;远端矩阵结果见对应 PR 检查。
73
+ 复验命令和覆盖边界见 `tests/README.md`。
74
+ - **gb_glob 守卫封杀了上游主用法([#21](https://github.com/KannaKuron/dsh-gitbash-shell/issues/21),同日报告)**:
75
+ 上游 `tools/list` 的 glob `pattern` 是 `string | string[]` 的 untagged enum——**数组才是主形态**
76
+ (schema 示例本身就是数组),v0.38.5 的只认字符串守卫把它一并拒绝;且模型常把数组 JSON 序列化后
77
+ 传入(实测 25 次调用 24 次为 `["*.txt"]` 字符串),上游按**一个字面 glob** 匹配 → 静默返回空
78
+ ——恰好是守卫要防的「静默错误答案」。修法(`validateFastctxPattern`,提取为纯导出函数):
79
+ glob 接受非空字符串或非空字符串数组;**字符串化数组响亮报错并给出正确形状**(绝不代模型反序列化
80
+ ——那会掩盖错误);replace 维持单正则字符串契约;其他类型报错带类型名。gb_glob 的 description
81
+ 增补 pattern 形状声明(模型侧修正源)。**教训**:给上游加防御守卫必须从 `tools/list` 的
82
+ `inputSchema` 原文出发,单一探针会误判(v0.38.5 的「静默当全部文件」实测即单一探针结论)。
83
+
6
84
  ## v0.39.0 — 2026-10-13
7
85
 
8
86
  **类型**:feat(架构转向:route B——插件自带 bash 工具,不再替换宿主执行器;DSH STORE 收录合规)。
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-gitbash-shell",
3
- "version": "0.39.0",
3
+ "version": "0.39.2",
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": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gitbash-shell",
3
- "version": "0.39.0",
3
+ "version": "0.39.2",
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": {
@@ -52,7 +52,8 @@
52
52
  "icon.svg"
53
53
  ],
54
54
  "scripts": {
55
- "test": "node --test tests/smoke.mjs",
55
+ "test": "node --test tests/smoke.mjs tests/private-executor.mjs tests/client-regressions.mjs tests/bridge-regressions.mjs tests/runtime-regressions.mjs tests/host-regressions.mjs",
56
+ "test:preview": "node --import ./tests/host-loader.mjs --test tests/preview.mjs tests/bridge-preview.mjs tests/runtime-preview.mjs tests/host-preview.mjs",
56
57
  "version": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('dsh.plugin.json','utf8'));const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;m.version=v;fs.writeFileSync('dsh.plugin.json',JSON.stringify(m,null,2)+'\\n')\" && git add dsh.plugin.json"
57
58
  },
58
59
  "keywords": [
package/src/bash-tool.js CHANGED
@@ -11,8 +11,8 @@
11
11
  * instead ships an OWN tool:
12
12
  *
13
13
  * - the executor is a private `GitBashSandboxExecutor` instance constructed
14
- * through cordis's own plugin path in a PRIVATE scope (never registered as
15
- * `ctx.shell`, which stays the official PowerShell executor); it inherits
14
+ * through cordis's own plugin path in a `shell`-isolated scope (never
15
+ * registered as the ROOT `ctx.shell`, which stays PowerShell); it inherits
16
16
  * the shipped bash-sandbox semantics (deadline, output capture, argv
17
17
  * execution) and points them at Git for Windows' bash.exe;
18
18
  * - the tool definition mirrors `@deepseek-ai/dsh-tool-bash` call-for-call
@@ -248,20 +248,29 @@ function outputSchemaSpec() {
248
248
  }
249
249
  }
250
250
 
251
- /** Plain JSON-schema variant of the same union (fallback when defineTool is absent). */
252
- function outputSchemaJson() {
253
- const convert = (item) => {
254
- const out = { type: item.type, additionalProperties: item.additionalProperties, properties: {} }
255
- const required = []
256
- for (const [key, value] of Object.entries(item.properties)) {
257
- if (value.required === true) { required.push(key) }
258
- const { required: _drop, ...rest } = value
259
- if (rest.oneOf !== undefined) delete rest.required
260
- out.properties[key] = rest
251
+ /** Convert key-level spec flags at every depth, including union branches. */
252
+ function schemaSpecToJson(spec) {
253
+ const { required: marker, ...out } = spec
254
+ if (Array.isArray(marker)) out.required = [...marker]
255
+ if (spec.properties !== undefined) {
256
+ const required = Array.isArray(marker) ? [...marker] : []
257
+ out.properties = {}
258
+ for (const [key, value] of Object.entries(spec.properties)) {
259
+ if (value.required === true && !required.includes(key)) required.push(key)
260
+ out.properties[key] = schemaSpecToJson(value)
261
261
  }
262
- return required.length > 0 ? { ...out, required } : out
262
+ if (required.length > 0) out.required = required
263
263
  }
264
- return { oneOf: outputSchemaSpec().oneOf.map(convert) }
264
+ for (const key of ['oneOf', 'anyOf', 'allOf']) {
265
+ if (Array.isArray(spec[key])) out[key] = spec[key].map(schemaSpecToJson)
266
+ }
267
+ if (spec.items !== undefined) out.items = schemaSpecToJson(spec.items)
268
+ return out
269
+ }
270
+
271
+ /** Plain JSON-schema variant of the same union (fallback when defineTool is absent). */
272
+ function outputSchemaJson() {
273
+ return schemaSpecToJson(outputSchemaSpec())
265
274
  }
266
275
 
267
276
  /** The official args validation (index.ts:73). */
@@ -320,8 +329,9 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm
320
329
  const defaultMode = executor.sandboxMode
321
330
  const escalationReady = defaultMode !== undefined && typeof approveEscalation === 'function'
322
331
  const escalationModes = escalationReady ? ESCALATION_TARGETS : []
332
+ // Missing approval helpers narrow escalation, not the session's standing policy.
323
333
  const resolveSandboxPolicy = (exec) => {
324
- if (!escalationReady) return undefined
334
+ if (defaultMode === undefined) return undefined
325
335
  const policy = ctx.get('sandboxPolicy')
326
336
  return policy ? policy.resolve(exec?.agent === undefined ? {} : { session: exec.agent.session }) : undefined
327
337
  }
@@ -548,7 +558,7 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm
548
558
  // keys reach execute too, where enableRunInBackground enforces them
549
559
  // ("Undeclared keys are allowed, so schema omission also needs
550
560
  // enforcement", index.ts:498).
551
- parameters: { type: 'object', properties: parametersSpec(), required: ['description', 'command'] },
561
+ parameters: schemaSpecToJson({ type: 'object', properties: parametersSpec() }),
552
562
  output: {
553
563
  schema: outputSchemaJson(),
554
564
  render: (_args, value) => [{
@@ -580,7 +590,8 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm
580
590
  jobCtx.effect(() => () => {
581
591
  unregister()
582
592
  try {
583
- if (ctx.fiber?.state === 'active') foregroundOnly = ctx.tools.register(bashTool(undefined))
593
+ // Cordis FiberState.ACTIVE is the numeric enum member 2.
594
+ if (ctx.fiber?.state === 2) foregroundOnly = ctx.tools.register(bashTool(undefined))
584
595
  } catch { /* the scope is going away */ }
585
596
  })
586
597
  })
package/src/client.js CHANGED
@@ -2063,6 +2063,42 @@ window.__ModuleLoader__.load({
2063
2063
  if (scope && typeof scope.getSnapshot === "function") snap = scope.getSnapshot();
2064
2064
  } catch (error_) { /* keep unavailable */ }
2065
2065
 
2066
+ var pageView = props.view === "page";
2067
+
2068
+ // Hooks run for every snapshot state; readiness only gates their work.
2069
+ useEffect(function () {
2070
+ if (snap.status !== "ready" || (!open && !pageView)) return undefined;
2071
+ if (toolsLoaded || toolBusy) return undefined;
2072
+ loadTools();
2073
+ return undefined;
2074
+ }, [open, pageView, snap.status]);
2075
+
2076
+ /* Poll ONLY while a job is unfinished, and through the cheap ?job=1
2077
+ form. A transient failure retries without discarding progress. */
2078
+ useEffect(function () {
2079
+ if (snap.status !== "ready" || toolJob === null || toolJob.finished) return undefined;
2080
+ var alive = true;
2081
+ var timer;
2082
+ function schedule() {
2083
+ timer = setTimeout(function () {
2084
+ fetch(TOOLS_URL + "?job=1", { headers: { accept: "application/json" } })
2085
+ .then(function (res) {
2086
+ if (!res.ok) throw new Error("HTTP " + res.status);
2087
+ return res.json();
2088
+ })
2089
+ .then(function (data) {
2090
+ if (!alive) return;
2091
+ var next = data !== null && typeof data === "object" && data.job ? data.job : null;
2092
+ setToolJob(next);
2093
+ if (next !== null && next.finished) loadTools();
2094
+ })
2095
+ .catch(function () { if (alive) schedule(); });
2096
+ }, 1200);
2097
+ }
2098
+ schedule();
2099
+ return function () { alive = false; clearTimeout(timer); };
2100
+ }, [toolJob, snap.status]);
2101
+
2066
2102
  if (snap.status !== "ready") return null;
2067
2103
 
2068
2104
  var value = snap.value || {};
@@ -2135,7 +2171,6 @@ window.__ModuleLoader__.load({
2135
2171
  // dsh 0.1.6-alpha.2: the Plugins page renders this card through the
2136
2172
  // `plugins.bundle.config` slot with view="page" — the page draws the
2137
2173
  // title itself; the collapsible shell only shows on the popup seat.
2138
- var pageView = props.view === "page";
2139
2174
 
2140
2175
  var mounts = snap.value.virtualMounts !== false;
2141
2176
  var errs = snap.value.errorDialect !== false;
@@ -2469,30 +2504,6 @@ window.__ModuleLoader__.load({
2469
2504
  .then(function () { setToolBusy(false); });
2470
2505
  }
2471
2506
 
2472
- useEffect(function () {
2473
- if (!open && !pageView) return undefined;
2474
- if (toolsLoaded || toolBusy) return undefined;
2475
- loadTools();
2476
- return undefined;
2477
- }, [open]);
2478
-
2479
- /* Poll ONLY while a job is unfinished, and through the cheap ?job=1
2480
- form: a full probe re-runs three winget commands per second. */
2481
- useEffect(function () {
2482
- if (toolJob === null || toolJob.finished) return undefined;
2483
- var timer = setTimeout(function () {
2484
- fetch(TOOLS_URL + "?job=1", { headers: { accept: "application/json" } })
2485
- .then(function (res) { return res.json(); })
2486
- .then(function (data) {
2487
- var next = data !== null && typeof data === "object" && data.job ? data.job : null;
2488
- setToolJob(next);
2489
- if (next !== null && next.finished) loadTools();
2490
- })
2491
- .catch(function () { /* keep the last known progress */ });
2492
- }, 1200);
2493
- return function () { clearTimeout(timer); };
2494
- }, [toolJob]);
2495
-
2496
2507
  var toolsSection = fold("sec.tools",
2497
2508
  toolsData === null && toolErr === "" ? E("p", { className: "gb-hint" }, t("tools.loading")) : null,
2498
2509
  toolErr !== "" ? E("p", { className: "gb-error" }, toolErr) : null,
@@ -9,10 +9,10 @@
9
9
  *
10
10
  * Every variant mirrors the official 0.1.7 preset it replaces (packages/
11
11
  * bundle/web-app/presets/{standard,minimal,ptc,cordis}.patch.yml) with the
12
- * one Git Bash delta: the bash rows are always on and the pwsh rows are
13
- * always off (the plugin ships its OWN bash tool over a private executor —
14
- * on non-Windows hosts the official stack is native anyway, which is exactly
15
- * what the old materialized variants encoded). A second, conditional delta exists while
12
+ * Git Bash routing: full variants keep the official platform-gated shell
13
+ * rows, and the inline patch adds OUR bash over a private executor on Windows.
14
+ * The minimal variant instead keeps its persistent Git Bash terminal rows.
15
+ * On non-Windows hosts the official stack stays native. A conditional delta exists while
16
16
  * dsh-ptc-cordis-preset reports its experimental-CPython backend ACTIVE: the
17
17
  * two workflow rows follow the official Python composition and go off (see
18
18
  * `pythonActive` below). While that backend is off — the default, and also
@@ -56,7 +56,8 @@ const planSectionFor = (hostEra021) => hostEra021
56
56
  * @param {'standard'|'ptc'|'cordis'} input.kind - which official preset this
57
57
  * variant mirrors (workflow rows follow it: enabled on standard/cordis,
58
58
  * disabled on ptc).
59
- * @param {boolean} input.gitBash - bash rows always on, pwsh rows always off.
59
+ * @param {boolean} input.gitBash - retained for composition-call compatibility;
60
+ * full variants obtain Git Bash from the per-agent inline patch on Windows.
60
61
  * @param {string|undefined} input.skillsDir - Creation authoring skills
61
62
  * directory (cordis variant only; resolved beside the agent-preset package).
62
63
  * @param {boolean} [input.pythonActive] - whether the experimental CPython
@@ -75,8 +76,11 @@ const planSectionFor = (hostEra021) => hostEra021
75
76
  */
76
77
  export function pluginsFor({ kind, gitBash, skillsDir, pythonActive = false, hostExtras = {} }) {
77
78
  const win = typeof process !== 'undefined' && process.platform === 'win32'
78
- const bashDisabled = gitBash ? false : win
79
- const pwshDisabled = gitBash ? true : !win
79
+ // Route B preserves the root PowerShell executor. On Windows the official
80
+ // tool-bash would execute through that executor, so full variants retain the
81
+ // official pwsh face and let the same per-agent inline patch add OUR bash.
82
+ const bashDisabled = win
83
+ const pwshDisabled = !win
80
84
  const workflowOn = kind !== 'ptc' && pythonActive !== true
81
85
  // dsh 0.2.1 added two rows to every full-tool official preset — time-context
82
86
  // (durable clock readings) and tool-schedule (the reminder tools) — plus a
@@ -106,20 +106,21 @@ export class McpStdio {
106
106
  this.child.stderr.on('data', (chunk) => {
107
107
  this.stderrTail = (this.stderrTail + chunk).slice(-2000)
108
108
  })
109
- this.child.on('exit', (code) => {
110
- this.dead = true
111
- for (const [, { reject }] of [...this.pending]) {
112
- reject(new Error(`the FastCtx server exited (code ${code ?? 'null'})`))
113
- }
114
- this.pending.clear()
115
- })
116
- await this.#request('initialize', {
117
- protocolVersion: '2024-11-05',
118
- capabilities: {},
119
- clientInfo: { name: 'dsh-gitbash-shell', version: '0.35.0' },
120
- })
121
- this.#notify('notifications/initialized')
122
- return this
109
+ this.child.on('error', (error) => this.#fail(error))
110
+ this.child.stdin.on('error', (error) => this.#fail(error))
111
+ this.child.on('exit', (code) => this.#fail(new Error(`the FastCtx server exited (code ${code ?? 'null'})`)))
112
+ try {
113
+ await this.#request('initialize', {
114
+ protocolVersion: '2024-11-05',
115
+ capabilities: {},
116
+ clientInfo: { name: 'dsh-gitbash-shell', version: '0.35.0' },
117
+ })
118
+ this.#notify('notifications/initialized')
119
+ return this
120
+ } catch (error) {
121
+ await this.close()
122
+ throw error
123
+ }
123
124
  }
124
125
 
125
126
  /** List the server's tools: [{ name, description, inputSchema }]. */
@@ -140,15 +141,21 @@ export class McpStdio {
140
141
  this.dead = true
141
142
  const child = this.child
142
143
  this.child = undefined
143
- for (const [, { reject }] of [...this.pending]) {
144
- reject(new Error('the FastCtx bridge was stopped'))
145
- }
146
- this.pending.clear()
144
+ this.#fail(new Error('the FastCtx bridge was stopped'))
147
145
  if (child && child.exitCode === null && !child.killed) {
148
146
  try { child.kill() } catch { /* already gone */ }
149
147
  }
150
148
  }
151
149
 
150
+ #fail(error) {
151
+ this.dead = true
152
+ for (const { reject, timer } of this.pending.values()) {
153
+ clearTimeout(timer)
154
+ reject(error)
155
+ }
156
+ this.pending.clear()
157
+ }
158
+
152
159
  #onChunk(chunk) {
153
160
  this.buffer += chunk
154
161
  let at = this.buffer.indexOf('\n')
@@ -209,6 +216,42 @@ export class McpStdio {
209
216
  * the fastctx shapes: `path`, `file_path`, `files[].path`, and an absolute
210
217
  * glob `pattern`. Pure.
211
218
  */
219
+ /**
220
+ * Validate one pattern argument BEFORE dispatch (v0.38.5 guard, reshaped in
221
+ * v0.39.1 around the upstream SCHEMA — issue #21). glob's `pattern` is an
222
+ * untagged enum of string | string[] in tools/list (the array is the PRIMARY
223
+ * form — the schema's own example is an array), so a string-only check bans
224
+ * the main usage; and a JSON-stringified array ("[\"*.txt\"]") is matched by
225
+ * the upstream as ONE literal glob and silently returns nothing — the exact
226
+ * silent-wrong-answer this guard exists to prevent. replace's pattern is a
227
+ * single regex string. Pure; returns an error message or undefined.
228
+ * @param {string} rawName - upstream tool name ('glob' | 'replace' | ...).
229
+ * @param {unknown} pattern - the raw pattern argument.
230
+ * @returns {string|undefined}
231
+ */
232
+ export function validateFastctxPattern(rawName, pattern) {
233
+ if (rawName !== 'glob' && rawName !== 'replace') return undefined
234
+ const describe = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v)
235
+ if (rawName === 'replace') {
236
+ if (typeof pattern !== 'string' || pattern.trim() === '') {
237
+ return `invalid arguments: ${gbName(rawName)} pattern must be a non-empty string (got ${describe(pattern)})`
238
+ }
239
+ return undefined
240
+ }
241
+ // A stringified array would match as one literal glob and return an empty
242
+ // set: name the mistake, never guess the intent (un-serializing on the
243
+ // model's behalf would hide the error this guard is here to surface).
244
+ if (typeof pattern === 'string' && pattern.length >= 2 && pattern.startsWith('[') && pattern.endsWith(']')) {
245
+ return `invalid arguments: ${gbName(rawName)} pattern looks like a JSON-stringified array (${String(pattern).slice(0, 60)}). Pass a real array of strings (e.g. ["*.js","*.py"]) or one plain glob string — a stringified array matches nothing.`
246
+ }
247
+ const ok = (typeof pattern === 'string' && pattern.trim() !== '')
248
+ || (Array.isArray(pattern) && pattern.length > 0 && pattern.every((p) => typeof p === 'string' && p.trim() !== ''))
249
+ if (!ok) {
250
+ return `invalid arguments: ${gbName(rawName)} pattern must be a non-empty string or a non-empty array of non-empty strings (got ${describe(pattern)})`
251
+ }
252
+ return undefined
253
+ }
254
+
212
255
  export function translateFastctxArgs(args, msysToWin) {
213
256
  if (!msysToWin || !args || typeof args !== 'object') return args
214
257
  const translate = (value) => (typeof value === 'string' ? msysToWin(value) : value)
@@ -274,6 +317,10 @@ export function mountFastctx(ctx, deps) {
274
317
  let client = undefined
275
318
  let decided = false
276
319
  let withdrawnByOps = false
320
+ let closed = false
321
+ let publishing
322
+ let connecting
323
+ const starting = new Set()
277
324
 
278
325
  const unpublish = (reason) => {
279
326
  if (disposer) {
@@ -284,16 +331,34 @@ export function mountFastctx(ctx, deps) {
284
331
  }
285
332
 
286
333
  const callWithReconnect = async (rawName, args) => {
334
+ if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped')
287
335
  if (client === undefined || client.isDead) {
288
- const { file } = resolveFastctx(ctx, createRequireFrom)
289
- if (file === '') throw new Error('the FastCtx payload is not installed; repair the profile dependencies (frozen install) and retry')
290
- client = await new McpStdio({ file, log }).start()
336
+ if (!connecting) {
337
+ connecting = (async () => {
338
+ const { file } = resolveFastctx(ctx, createRequireFrom)
339
+ if (file === '') throw new Error('the FastCtx payload is not installed; repair the profile dependencies (frozen install) and retry')
340
+ const fresh = new McpStdio({ file, log })
341
+ starting.add(fresh)
342
+ try {
343
+ await fresh.start()
344
+ if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped')
345
+ client = fresh
346
+ } catch (error) {
347
+ await fresh.close()
348
+ throw error
349
+ } finally {
350
+ starting.delete(fresh)
351
+ }
352
+ })().finally(() => { connecting = undefined })
353
+ }
354
+ await connecting
291
355
  }
356
+ if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped')
292
357
  return client.callTool(rawName, args)
293
358
  }
294
359
 
295
- const publish = async () => {
296
- if (disposer || decided && withdrawnByOps) return
360
+ const publishOnce = async () => {
361
+ if (closed || disposer || decided && withdrawnByOps) return
297
362
  const registry = tools(ctx)
298
363
  if (!registry || typeof registry.register !== 'function') return
299
364
  const { file, source } = resolveFastctx(ctx, createRequireFrom)
@@ -304,6 +369,7 @@ export function mountFastctx(ctx, deps) {
304
369
  return
305
370
  }
306
371
  const fresh = new McpStdio({ file, log })
372
+ starting.add(fresh)
307
373
  let toolList
308
374
  try {
309
375
  await fresh.start()
@@ -314,9 +380,12 @@ export function mountFastctx(ctx, deps) {
314
380
  decided = true
315
381
  withdrawnByOps = true
316
382
  return
383
+ } finally {
384
+ starting.delete(fresh)
317
385
  }
318
386
  await fresh.close()
319
- client = undefined // the first call re-launches lazily; boot stays cheap
387
+ if (closed || withdrawnByOps) return
388
+ // Discovery never overwrites the independently owned lazy call client.
320
389
  const wanted = toolList.filter((tool) => FASTCTX_FILE_TOOLS.includes(String(tool?.name)))
321
390
  const registered = []
322
391
  const registeredNames = []
@@ -334,29 +403,36 @@ export function mountFastctx(ctx, deps) {
334
403
  : rawName === 'grep'
335
404
  ? '\nIgnore rules: this tool honors .gitignore and skips .git — unlike gb_glob (bare .ignore only) and the official glob (ignores nothing).'
336
405
  : ''
406
+ // Pattern-shape transparency (v0.39.1, issue #21): models keep
407
+ // JSON-stringifying the glob array (24 of 25 measured calls), which the
408
+ // upstream matches as one literal glob -> silent empty. State the
409
+ // accepted shapes on the tool.
410
+ const patternNote = rawName === 'glob'
411
+ ? '\npattern accepts one glob string OR an array of glob strings (e.g. ["**/*.rs", "!tests/**"]). Pass a real array — never a JSON-stringified array.'
412
+ : ''
337
413
  try {
338
414
  const dispose = registry.register({
339
415
  name: gbName(rawName),
340
- description: `${String(tool.description ?? rawName)}\n${FASTCTX_SANDBOX_NOTE}${ignoreNote}`,
416
+ description: `${String(tool.description ?? rawName)}\n${FASTCTX_SANDBOX_NOTE}${ignoreNote}${patternNote}`,
341
417
  parameters: schema,
342
418
  output: {
343
419
  schema: TOOL_OUTPUT_SCHEMA,
344
420
  render: (_args, value) => renderTextContent(value),
345
421
  },
346
- async execute(args) {
347
- // Fail LOUD on malformed patterns (v0.38.5, the field smoke
348
- // report): the upstream server silently treats a non-string
349
- // pattern as "everything", which turns a typo into a full-tree
350
- // result set — a silent wrong answer is worse than an error.
351
- if ((rawName === 'glob' || rawName === 'replace') && args !== null && typeof args === 'object' && 'pattern' in args) {
352
- const pattern = args.pattern
353
- if (typeof pattern !== 'string' || pattern.trim() === '') {
354
- throw new Error(`invalid arguments: ${gbName(rawName)} pattern must be a non-empty string (got ${pattern === null ? 'null' : typeof pattern})`)
355
- }
422
+ async execute(args, execution) {
423
+ // Preserve v0.39.1 / #21: glob accepts real string arrays,
424
+ // while stringified arrays and malformed patterns fail loudly.
425
+ if (args !== null && typeof args === 'object' && 'pattern' in args) {
426
+ const invalid = validateFastctxPattern(rawName, args.pattern)
427
+ if (invalid) throw new Error(invalid)
356
428
  }
357
- const translated = translateFastctxArgs(args, msysToWin)
429
+ const translated = translateFastctxArgs(args, msysToWin ? (path) => msysToWin(path, execution) : undefined)
358
430
  const result = await callWithReconnect(rawName, translated)
359
- return { content: [{ type: 'text', text: renderFastctxResult(rawName, result, echo) }] }
431
+ const text = renderFastctxResult(rawName, result, echo ? (value) => echo(value, execution) : undefined)
432
+ // The host treats a returned value as success; MCP failures must
433
+ // throw, just as its official MCP client does.
434
+ if (result?.isError === true) throw new Error(text)
435
+ return { content: [{ type: 'text', text }] }
360
436
  },
361
437
  })
362
438
  if (typeof dispose === 'function') {
@@ -377,7 +453,13 @@ export function mountFastctx(ctx, deps) {
377
453
  log(`FastCtx bridge active (${source}): ${registeredNames.join(', ')}`)
378
454
  }
379
455
 
456
+ const publish = () => {
457
+ if (!publishing) publishing = publishOnce().finally(() => { publishing = undefined })
458
+ return publishing
459
+ }
460
+
380
461
  const reconcile = (agent) => {
462
+ if (closed) return
381
463
  if (!enabled()) { unpublish('FastCtx bridge disabled by the fastctxTools switch'); decided = true; withdrawnByOps = true; return }
382
464
  if (decided && withdrawnByOps) return
383
465
  let names = []
@@ -401,8 +483,12 @@ export function mountFastctx(ctx, deps) {
401
483
  log(`FastCtx bridge wiring failed: ${error?.message ?? error}`)
402
484
  return
403
485
  }
404
- ctx.effect(() => () => {
486
+ ctx.effect(() => async () => {
487
+ closed = true
405
488
  unpublish()
406
- if (client) { void client.close().catch(() => {}) }
489
+ const owned = new Set([...starting, client].filter(Boolean))
490
+ await Promise.allSettled([...owned].map((connection) => connection.close()))
491
+ await Promise.allSettled([publishing, connecting].filter(Boolean))
492
+ client = undefined
407
493
  }, 'dsh-gitbash-shell: FastCtx bridge')
408
494
  }