dsh-gitbash-shell 0.39.1 → 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,44 @@
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
+
6
44
  ## v0.39.1 — 2026-10-13
7
45
 
8
46
  **类型**:fix(route B 私有执行器的真实服务隔离与读取授权) / test / ci。
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-gitbash-shell",
3
- "version": "0.39.1",
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.1",
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,8 +52,8 @@
52
52
  "icon.svg"
53
53
  ],
54
54
  "scripts": {
55
- "test": "node --test tests/smoke.mjs tests/private-executor.mjs",
56
- "test:preview": "node --import ./tests/host-loader.mjs --test tests/preview.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",
57
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"
58
58
  },
59
59
  "keywords": [
package/src/bash-tool.js CHANGED
@@ -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')
@@ -310,6 +317,10 @@ export function mountFastctx(ctx, deps) {
310
317
  let client = undefined
311
318
  let decided = false
312
319
  let withdrawnByOps = false
320
+ let closed = false
321
+ let publishing
322
+ let connecting
323
+ const starting = new Set()
313
324
 
314
325
  const unpublish = (reason) => {
315
326
  if (disposer) {
@@ -320,16 +331,34 @@ export function mountFastctx(ctx, deps) {
320
331
  }
321
332
 
322
333
  const callWithReconnect = async (rawName, args) => {
334
+ if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped')
323
335
  if (client === undefined || client.isDead) {
324
- const { file } = resolveFastctx(ctx, createRequireFrom)
325
- if (file === '') throw new Error('the FastCtx payload is not installed; repair the profile dependencies (frozen install) and retry')
326
- 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
327
355
  }
356
+ if (closed || withdrawnByOps) throw new Error('the FastCtx bridge was stopped')
328
357
  return client.callTool(rawName, args)
329
358
  }
330
359
 
331
- const publish = async () => {
332
- if (disposer || decided && withdrawnByOps) return
360
+ const publishOnce = async () => {
361
+ if (closed || disposer || decided && withdrawnByOps) return
333
362
  const registry = tools(ctx)
334
363
  if (!registry || typeof registry.register !== 'function') return
335
364
  const { file, source } = resolveFastctx(ctx, createRequireFrom)
@@ -340,6 +369,7 @@ export function mountFastctx(ctx, deps) {
340
369
  return
341
370
  }
342
371
  const fresh = new McpStdio({ file, log })
372
+ starting.add(fresh)
343
373
  let toolList
344
374
  try {
345
375
  await fresh.start()
@@ -350,9 +380,12 @@ export function mountFastctx(ctx, deps) {
350
380
  decided = true
351
381
  withdrawnByOps = true
352
382
  return
383
+ } finally {
384
+ starting.delete(fresh)
353
385
  }
354
386
  await fresh.close()
355
- 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.
356
389
  const wanted = toolList.filter((tool) => FASTCTX_FILE_TOOLS.includes(String(tool?.name)))
357
390
  const registered = []
358
391
  const registeredNames = []
@@ -386,16 +419,20 @@ export function mountFastctx(ctx, deps) {
386
419
  schema: TOOL_OUTPUT_SCHEMA,
387
420
  render: (_args, value) => renderTextContent(value),
388
421
  },
389
- async execute(args) {
390
- // Fail LOUD on malformed patterns (v0.38.5; reshaped v0.39.1,
391
- // issue #21 — see validateFastctxPattern).
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.
392
425
  if (args !== null && typeof args === 'object' && 'pattern' in args) {
393
426
  const invalid = validateFastctxPattern(rawName, args.pattern)
394
427
  if (invalid) throw new Error(invalid)
395
428
  }
396
- const translated = translateFastctxArgs(args, msysToWin)
429
+ const translated = translateFastctxArgs(args, msysToWin ? (path) => msysToWin(path, execution) : undefined)
397
430
  const result = await callWithReconnect(rawName, translated)
398
- 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 }] }
399
436
  },
400
437
  })
401
438
  if (typeof dispose === 'function') {
@@ -416,7 +453,13 @@ export function mountFastctx(ctx, deps) {
416
453
  log(`FastCtx bridge active (${source}): ${registeredNames.join(', ')}`)
417
454
  }
418
455
 
456
+ const publish = () => {
457
+ if (!publishing) publishing = publishOnce().finally(() => { publishing = undefined })
458
+ return publishing
459
+ }
460
+
419
461
  const reconcile = (agent) => {
462
+ if (closed) return
420
463
  if (!enabled()) { unpublish('FastCtx bridge disabled by the fastctxTools switch'); decided = true; withdrawnByOps = true; return }
421
464
  if (decided && withdrawnByOps) return
422
465
  let names = []
@@ -440,8 +483,12 @@ export function mountFastctx(ctx, deps) {
440
483
  log(`FastCtx bridge wiring failed: ${error?.message ?? error}`)
441
484
  return
442
485
  }
443
- ctx.effect(() => () => {
486
+ ctx.effect(() => async () => {
487
+ closed = true
444
488
  unpublish()
445
- 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
446
493
  }, 'dsh-gitbash-shell: FastCtx bridge')
447
494
  }
package/src/index.js CHANGED
@@ -1168,7 +1168,13 @@ const CODE_LITERAL_SAFE = /^[A-Za-z0-9._@+~\-/ ]+$/
1168
1168
  /** Previous significant character that lets a '/' start a regex literal. */
1169
1169
  function regexCanStart(previous) {
1170
1170
  if (previous === '') return true
1171
- return '(,=:[!&|?{};+-*%<>~^'.indexOf(previous) >= 0
1171
+ if (['return', 'throw', 'yield', 'await', 'case', 'delete', 'void', 'typeof', 'in', 'instanceof', 'of', 'else', 'do', 'break', 'continue', 'debugger'].includes(previous)) return true
1172
+ return previous.length === 1 && '(,=:[!&|?{;+-*%<>~^'.includes(previous)
1173
+ }
1174
+
1175
+ /** Read an identifier as ONE token: keyword-led regexes are not division. */
1176
+ function codeWordAt(code, index) {
1177
+ return /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(code.slice(index))?.[0]
1172
1178
  }
1173
1179
 
1174
1180
  /** Index after the closing quote of the literal at `index`, or -1. */
@@ -1239,6 +1245,15 @@ function skipBracedExpression(code, index) {
1239
1245
  last = 'x'
1240
1246
  continue
1241
1247
  }
1248
+ // A slash after a closing group may start a statement regex OR division.
1249
+ // Without a parser that distinction is uncertain: abort the WHOLE scan.
1250
+ if (c === '/' && (last === ')' || last === '}')) return -1
1251
+ const word = codeWordAt(code, i)
1252
+ if (word) {
1253
+ last = last === '.' ? 'x' : word
1254
+ i += word.length
1255
+ continue
1256
+ }
1242
1257
  if (c === '/' && regexCanStart(last)) {
1243
1258
  const end = skipRegexLiteral(code, i)
1244
1259
  if (end === -1) return -1
@@ -1320,6 +1335,15 @@ export function scanCodeLiterals(code) {
1320
1335
  last = 'x'
1321
1336
  continue
1322
1337
  }
1338
+ // A slash after a closing group may start a statement regex OR division.
1339
+ // Without a parser that distinction is uncertain: abort the WHOLE scan.
1340
+ if (c === '/' && (last === ')' || last === '}')) return null
1341
+ const word = codeWordAt(code, i)
1342
+ if (word) {
1343
+ last = last === '.' ? 'x' : word
1344
+ i += word.length
1345
+ continue
1346
+ }
1323
1347
  if (c === '/' && regexCanStart(last)) {
1324
1348
  const end = skipRegexLiteral(code, i)
1325
1349
  if (end === -1) return null
@@ -1380,8 +1404,8 @@ export function rewriteCodePaths(code, env) {
1380
1404
  // is written into, and a quote inside it (a home directory with an
1381
1405
  // apostrophe) would end that literal early — refuse rather than corrupt.
1382
1406
  const quote = code[span.start - 1]
1383
- if (translated.indexOf(quote) >= 0) continue
1384
- if (quote === '`' && translated.indexOf('${') >= 0) continue
1407
+ if (translated.indexOf(quote) >= 0) return code
1408
+ if (quote === '`' && translated.indexOf('${') >= 0) return code
1385
1409
  const source = translated.replace(/\\/g, '\\\\')
1386
1410
  out += code.slice(cursor, span.start) + source
1387
1411
  cursor = span.end
@@ -1469,9 +1493,6 @@ const SIDEBAR_NS = 'dsh-better-sidebar'
1469
1493
  const SIDEBAR_TRIES = 12
1470
1494
  const SIDEBAR_RETRY_MS = 1500
1471
1495
 
1472
- // Reconciler installed by adoptSidebarShell; a no-op until then.
1473
- let reconcileSidebar = () => {}
1474
-
1475
1496
  /**
1476
1497
  * Reconcile the better-sidebar `terminalShell` takeover with the adoptSidebar
1477
1498
  * setting (v0.15.0, default ON). ON writes our bashPath (remembering what was
@@ -1491,95 +1512,69 @@ function adoptSidebarShell(ctx, bashPath, readAdopt) {
1491
1512
  let previous = ''
1492
1513
  let tried = 0
1493
1514
  let timer = null
1494
- ctx.effect(() => () => { if (timer) clearTimeout(timer) }, 'dsh-gitbash-shell: sidebar adoption polling')
1495
-
1515
+ let closed = false
1516
+ let serial = Promise.resolve()
1496
1517
  const readShell = () => {
1497
1518
  const settings = ctx.get('settings')
1498
1519
  if (!settings || typeof settings.update !== 'function') return null
1499
1520
  try {
1500
- // The sidebar's value lives in ITS row Config, projected into the
1501
- // settings describe() list; the service exposes describe/update only
1502
- // (the old `get(ns)` reader died with dsh 0.1.6).
1503
- if (typeof settings.describe === 'function') {
1504
- const entry = settings.describe().find((row) => row !== null && typeof row === 'object' && row.ns === SIDEBAR_NS)
1505
- const value = entry === undefined ? undefined : entry.value
1506
- return value && typeof value === 'object' && typeof value.terminalShell === 'string' ? value.terminalShell : ''
1507
- }
1508
- return null
1509
- } catch {
1510
- return null // namespace not registered yet / sidebar absent
1521
+ if (typeof settings.describe !== 'function') return null
1522
+ const entry = settings.describe().find((row) => row && row.ns === SIDEBAR_NS)
1523
+ const value = entry?.value
1524
+ return value && typeof value.terminalShell === 'string' ? value.terminalShell : ''
1525
+ } catch { return null }
1526
+ }
1527
+ const restore = async () => {
1528
+ if (!adopted) return
1529
+ const now = readShell()
1530
+ if (now === null) return
1531
+ // The user may have selected another shell since our takeover.
1532
+ if (now === bashPath) {
1533
+ await ctx.get('settings').update(SIDEBAR_NS, { terminalShell: previous })
1534
+ console.log(TAG + " better-sidebar shell restored ('" + previous + "')")
1511
1535
  }
1536
+ adopted = false
1512
1537
  }
1513
-
1514
- reconcileSidebar = (desired) => {
1538
+ const reconcileSidebar = async (desired) => {
1515
1539
  const current = readShell()
1516
1540
  if (current === null) return false
1517
- const settings = ctx.get('settings')
1518
- if (desired && current !== bashPath) {
1519
- const was = current
1520
- settings.update(SIDEBAR_NS, { terminalShell: bashPath })
1521
- .then(() => {
1522
- previous = was
1523
- adopted = true
1524
- console.log(
1525
- `${TAG} dsh-better-sidebar terminal shell -> Git Bash (${bashPath})` +
1526
- (was ? ` (took over from '${was}')` : ''),
1527
- )
1528
- })
1529
- .catch((error) => {
1530
- console.log(`${TAG} better-sidebar shell adoption unavailable: ${error?.message ?? error}`)
1531
- })
1532
- return true
1533
- }
1534
- if (desired && current === bashPath) {
1535
- adopted = true
1536
- return true
1537
- }
1538
- if (!desired && adopted && current === bashPath) {
1539
- settings.update(SIDEBAR_NS, { terminalShell: previous })
1540
- .then(() => {
1541
- adopted = false
1542
- console.log(`${TAG} dsh-better-sidebar terminal shell restored ('${previous || 'sidebar default'}') — adoptSidebar off`)
1543
- })
1544
- .catch(() => {})
1545
- return true
1546
- }
1541
+ if (!desired) { await restore(); return true }
1542
+ if (adopted || current === bashPath) return true
1543
+ previous = current
1544
+ await ctx.get('settings').update(SIDEBAR_NS, { terminalShell: bashPath })
1545
+ adopted = true
1546
+ console.log(TAG + ' dsh-better-sidebar terminal shell -> Git Bash (' + bashPath + ')')
1547
1547
  return true
1548
1548
  }
1549
-
1550
1549
  const run = () => {
1551
- tried += 1
1552
- const ok = reconcileSidebar(readAdopt())
1553
- if (ok) return
1554
- if (tried < SIDEBAR_TRIES) {
1555
- timer = setTimeout(run, SIDEBAR_RETRY_MS)
1556
- return
1557
- }
1558
- console.log(`${TAG} better-sidebar shell adoption skipped: settings service unavailable after ${tried} tries`)
1559
- }
1560
- run.watched = false
1561
- void run()
1562
-
1563
- // Dispose: while we still hold the value, restore what was there before.
1564
- let reverted = false
1565
- ctx.effect(
1566
- () => () => {
1567
- if (reverted || !adopted) return
1568
- reverted = true
1569
- const s = ctx.get('settings')
1570
- if (!s || typeof s.update !== 'function') return
1571
- // Era-aware read (v0.25.1): `settings.get(ns)` is gone on dsh >= 0.1.7, so
1572
- // the old call threw straight into the catch and the takeover was NEVER
1573
- // reverted when this plugin unloaded — same missed-era-read class as
1574
- // issue #8 (readShell above carries both branches).
1575
- const now = readShell()
1576
- if (now === null) return
1577
- if (String(now) === bashPath) {
1578
- s.update(SIDEBAR_NS, { terminalShell: previous }).catch(() => {})
1579
- }
1580
- },
1581
- 'dsh-gitbash-shell: sidebar shell revert',
1582
- )
1550
+ if (closed) return serial
1551
+ if (timer) { clearTimeout(timer); timer = null }
1552
+ serial = serial.then(async () => {
1553
+ if (closed) return
1554
+ tried += 1
1555
+ const ok = await reconcileSidebar(readAdopt())
1556
+ if (ok || closed) return
1557
+ if (tried < SIDEBAR_TRIES) timer = setTimeout(run, SIDEBAR_RETRY_MS)
1558
+ else console.log(TAG + ' better-sidebar shell adoption skipped: settings unavailable')
1559
+ }).catch((error) => {
1560
+ console.log(TAG + ' better-sidebar shell adoption unavailable: ' + (error?.message ?? error))
1561
+ })
1562
+ return serial
1563
+ }
1564
+ try {
1565
+ ctx.on('loader/volatile-update', (paths) => {
1566
+ if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && path[0] === 'adoptSidebar')) return
1567
+ tried = 0
1568
+ void run()
1569
+ })
1570
+ } catch { /* a diagnostic context may have no event service */ }
1571
+ ctx.effect(() => async () => {
1572
+ closed = true
1573
+ if (timer) clearTimeout(timer)
1574
+ await serial
1575
+ await restore()
1576
+ }, 'dsh-gitbash-shell: sidebar shell revert')
1577
+ return run()
1583
1578
  }
1584
1579
 
1585
1580
  // ── plugin ──────────────────────────────────────────────────────────────────
@@ -1759,6 +1754,38 @@ export function minimalPtyBashPath(readResolution) {
1759
1754
  * failures stay visible through the roster's broken diagnostic instead of
1760
1755
  * failing the boot.
1761
1756
  */
1757
+ async function migrateVariantDefault(ctx, readVariants) {
1758
+ if (readVariants() === true) return true
1759
+ const readDefault = () => { try { return ctx.agentPresets.defaultId } catch { return undefined } }
1760
+ const current = readDefault()
1761
+ if (typeof current !== 'string' || !PRESET_IDS.includes(current)) return true
1762
+ const target = VARIANT_DEFAULT_SUCCESSORS[current]
1763
+ try {
1764
+ const settings = ctx.get('settings')
1765
+ if (!settings || typeof settings.update !== 'function') return false
1766
+ const roster = await ctx.agentPresets.list()
1767
+ if (!Array.isArray(roster) || !roster.some((row) => row && row.id === target)) {
1768
+ console.warn(TAG + ' default-preset migration stood down: successor "' + target + '" is not registered. Set an OFFICIAL default or turn variantPresets on.')
1769
+ return false
1770
+ }
1771
+ // A roster read is asynchronous: never overwrite a newer user selection.
1772
+ if (readVariants() === true || readDefault() !== current) return true
1773
+ await settings.update('agent-preset-registry', { selectedDefault: target })
1774
+ const after = readDefault()
1775
+ if (after !== target) {
1776
+ console.warn(TAG + ' default-preset migration did not stick (read back "' + after + '"). Set an OFFICIAL default or turn variantPresets on.')
1777
+ return false
1778
+ }
1779
+ console.log(TAG + ' deployment default migrated: "' + current + '" -> "' + target
1780
+ + '" (variantPresets is off). Pinned sessions are unchanged; agent-preset/locked still requires a NEW session.')
1781
+ return true
1782
+ } catch (error) {
1783
+ console.warn(TAG + ' default-preset migration failed: ' + (error?.message ?? error)
1784
+ + '. Set an OFFICIAL default or turn variantPresets on; variants were not retired.')
1785
+ return false
1786
+ }
1787
+ }
1788
+
1762
1789
  async function registerVariant(ctx, presetId, { gitBashActive, skillsDir, pythonActive, hostExtras = {}, readBashResolution }) {
1763
1790
  const meta = PRESET_META[presetId]
1764
1791
  if (!meta) return undefined
@@ -1811,6 +1838,17 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1811
1838
  /** The CPython fact the LIVE registrations were built with. */
1812
1839
  let registeredPython = false
1813
1840
  let serial = Promise.resolve()
1841
+ let closed = false
1842
+ ctx.effect(() => async () => {
1843
+ closed = true
1844
+ await serial
1845
+ const failures = []
1846
+ for (const [presetId, off] of [...live]) {
1847
+ live.delete(presetId)
1848
+ try { await off() } catch (error) { failures.push(error) }
1849
+ }
1850
+ if (failures.length) throw new AggregateError(failures, 'Git Bash preset cleanup failed')
1851
+ }, 'dsh-gitbash-shell: declarative preset registrations')
1814
1852
 
1815
1853
  // v0.35.0: the variant roster is opt-in (`variantPresets`, default OFF).
1816
1854
  // OFF means an EMPTY desired set — the cleanup above still runs, so a
@@ -1820,8 +1858,16 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1820
1858
  : [])
1821
1859
 
1822
1860
  const reconcile = () => {
1861
+ if (closed) return serial
1823
1862
  serial = serial.then(async () => {
1863
+ if (closed) return
1824
1864
  const want = desired()
1865
+ const pythonActive = peerPython
1866
+ // Dynamic OFF repairs the deployment default BEFORE retiring its id.
1867
+ // Keep registrations if the official migration cannot be verified.
1868
+ if (live.size > 0 && readVariants() !== true
1869
+ && !await migrateVariantDefault(ctx, readVariants)) return
1870
+ if (closed) return
1825
1871
  // The peer's EFFECTIVE CPython backend rewrites the workflow rows of
1826
1872
  // EVERY variant (the official Python composition disables them, and
1827
1873
  // workflow-ptc refuses a non-TypeScript backend), so a flip re-registers
@@ -1829,14 +1875,16 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1829
1875
  // as the backend swap itself: the peer's patch is evaluated at boot. A
1830
1876
  // bare INTENT without the effective backend changes nothing here — the
1831
1877
  // composition still runs Node, so the workflow rows must stay live.
1832
- if (live.size > 0 && registeredPython !== peerPython) {
1878
+ if (live.size > 0 && registeredPython !== pythonActive) {
1833
1879
  for (const [presetId, off] of [...live]) {
1834
1880
  live.delete(presetId)
1835
1881
  try {
1836
1882
  await off()
1837
1883
  console.log(TAG + " preset '" + presetId + "' retired (peer CPython backend changed; rows are rebuilt)")
1838
1884
  } catch (error) {
1885
+ live.set(presetId, off)
1839
1886
  console.log(TAG + " preset '" + presetId + "' retirement failed: " + (error && error.message ? error.message : error))
1887
+ return // never register replacements after an unverified retirement
1840
1888
  }
1841
1889
  }
1842
1890
  }
@@ -1847,14 +1895,18 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1847
1895
  await off()
1848
1896
  console.log(TAG + " preset '" + presetId + "' retired (dsh-ptc-cordis-preset covers Creation mode on Git Bash)")
1849
1897
  } catch (error) {
1898
+ live.set(presetId, off)
1850
1899
  console.log(TAG + " preset '" + presetId + "' retirement failed: " + (error && error.message ? error.message : error))
1900
+ return
1851
1901
  }
1852
1902
  }
1853
1903
  for (const presetId of want) {
1904
+ if (closed) break
1854
1905
  if (live.has(presetId)) continue
1855
1906
  try {
1856
- const unregister = await registerVariant(ctx, presetId, { gitBashActive, skillsDir, pythonActive: peerPython, hostExtras, readBashResolution })
1907
+ const unregister = await registerVariant(ctx, presetId, { gitBashActive, skillsDir, pythonActive, hostExtras, readBashResolution })
1857
1908
  if (unregister) {
1909
+ if (closed) { await unregister(); break }
1858
1910
  live.set(presetId, unregister)
1859
1911
  console.log(TAG + " preset '" + presetId + "' registered declaratively")
1860
1912
  }
@@ -1862,7 +1914,7 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1862
1914
  console.log(TAG + " preset '" + presetId + "' registration failed: " + (error && error.message ? error.message : error))
1863
1915
  }
1864
1916
  }
1865
- registeredPython = peerPython
1917
+ registeredPython = pythonActive
1866
1918
  }).catch((error) => {
1867
1919
  console.log(TAG + ' preset reconcile failed: ' + (error && error.message ? error.message : error))
1868
1920
  })
@@ -1870,6 +1922,7 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1870
1922
  }
1871
1923
 
1872
1924
  await reconcile()
1925
+ if (closed) return
1873
1926
  console.log(TAG + ' registered ' + live.size + ' preset(s) declaratively (' + [...live.keys()].join(', ') + ')'
1874
1927
  + (live.size === 0 ? ' — variantPresets is off: the official presets carry Git Bash through the inline tool patch (see the CHANGELOG)'
1875
1928
  : '')
@@ -1932,7 +1985,8 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1932
1985
  console.log(TAG + ' dedupe switch wiring failed: ' + (error && error.message ? error.message : error))
1933
1986
  }
1934
1987
 
1935
- ctx.effect(() => () => { for (const off of live.values()) void off() }, 'dsh-gitbash-shell: declarative preset registrations')
1988
+ // If settings appears after an OFF transition failed closed, retry it.
1989
+ ctx.inject(['settings'], () => { void reconcile() })
1936
1990
  }
1937
1991
 
1938
1992
  export async function apply(ctx, config = {}) {
@@ -2611,8 +2665,8 @@ export async function apply(ctx, config = {}) {
2611
2665
  // official tool surface mirrored call-for-call) plus a HIDE on the real pwsh
2612
2666
  // tool while the pwsh switch is off (route B: the official executor stays in
2613
2667
  // place, so pwsh is real PowerShell and hiding it is a product choice). The
2614
- // variantPresets switch is honored BY OBSERVATION — a variant agent's face
2615
- // already has bash, so this patch never fires there.
2668
+ // Full Windows variants keep the same pwsh-only face as official presets,
2669
+ // so they receive this same private-executor patch. Minimal stays persistent.
2616
2670
  if (process.platform === 'win32') {
2617
2671
  try {
2618
2672
  mountInlineTools(ctx, {
@@ -2661,55 +2715,10 @@ export async function apply(ctx, config = {}) {
2661
2715
  // With variantPresets ON nothing here runs: the variants stay registered,
2662
2716
  // so a variant default keeps working as-is.
2663
2717
  try {
2664
- if (ctx.agentPresets && typeof ctx.agentPresets.register === 'function') {
2665
- ctx.inject(['settings'], (settingsCtx) => {
2666
- void (async () => {
2667
- try {
2668
- const settings = settingsCtx.settings
2669
- if (!settings || typeof settings.update !== 'function') return
2670
- // The switch is the whole contract: keep-old-modes ON means a
2671
- // variant default is a legitimate choice and stays untouched.
2672
- if (liveSettings.variantPresets() === true) return
2673
- const readDefault = () => { try { return ctx.agentPresets.defaultId } catch { return undefined } }
2674
- const current = readDefault()
2675
- if (typeof current !== 'string' || !PRESET_IDS.includes(current)) return
2676
- const target = VARIANT_DEFAULT_SUCCESSORS[current]
2677
- if (typeof target !== 'string') return
2678
- // The successor must exist in the CURRENT roster: official ids
2679
- // renamed once already (code→ptc), and writing an unknown id
2680
- // would trade a known failure for a worse one.
2681
- const roster = await ctx.agentPresets.list()
2682
- if (!Array.isArray(roster) || !roster.some((row) => row && row.id === target)) {
2683
- console.warn(TAG + ' default-preset migration stood down: the deployment default is the variant "'
2684
- + current + '" but the successor "' + target + '" is not registered on this host.'
2685
- + ' Set the default manually among the OFFICIAL presets, or turn variantPresets on.')
2686
- return
2687
- }
2688
- try {
2689
- await settings.update('agent-preset-registry', { selectedDefault: target })
2690
- } catch (error) {
2691
- console.warn(TAG + ' default-preset migration write failed (' + (error?.message ?? error)
2692
- + '): the deployment default is still the variant "' + current
2693
- + '" — NEW sessions would fail with agent-preset/not-found.'
2694
- + ' Fix: set an OFFICIAL default in the preset picker, or turn variantPresets on.')
2695
- return
2696
- }
2697
- const after = readDefault()
2698
- if (after === target) {
2699
- console.log(TAG + ' deployment default migrated: "' + current + '" -> "' + target
2700
- + '" (variantPresets is off). Old sessions pinned to a variant still need a NEW'
2701
- + ' session — the official agent-preset/locked rule blocks a mid-session switch.')
2702
- } else {
2703
- console.warn(TAG + ' default-preset migration did not stick (read back "'
2704
- + (after === undefined ? 'unreadable' : after) + '"). Set the default manually in'
2705
- + ' Settings -> agent preset, or turn variantPresets on.')
2706
- }
2707
- } catch (error) {
2708
- console.log(TAG + ' default-preset migration failed: ' + (error?.message ?? error))
2709
- }
2710
- })()
2711
- })
2712
- }
2718
+ ctx.inject(['settings'], () => {
2719
+ // Boot migration is detached: registry.list() may await the Loader boot.
2720
+ void migrateVariantDefault(ctx, () => liveSettings.variantPresets())
2721
+ })
2713
2722
  } catch (error) {
2714
2723
  console.log(TAG + ' default-preset migration wiring failed: ' + (error?.message ?? error))
2715
2724
  }
@@ -2727,8 +2736,21 @@ export async function apply(ctx, config = {}) {
2727
2736
  enabled: () => liveSettings.fastctxTools(),
2728
2737
  createRequireFrom: (base) => createRequire(base ?? import.meta.url),
2729
2738
  tools: (c) => { try { return c.get('tools') } catch { return undefined } },
2730
- msysToWin: (p) => translateMsysPath(p, buildTranslateEnv(readConfiguredBashPath())),
2731
- echo: (text) => (liveSettings.dialect().posixPaths === true ? windowsToMsys(text) : text),
2739
+ msysToWin: (p, execution) => {
2740
+ const dialect = liveSettings.dialect()
2741
+ if (dialect.posixPaths !== true || !dialectApplies(dialect, execution?.agent)) return p
2742
+ if (!dialect.virtualMounts) {
2743
+ return typeof p === 'string' && (MSYS_DRIVE_ROOT.test(p) || MSYS_BARE_DRIVE.test(p))
2744
+ ? translateMsysPath(p, null) : p
2745
+ }
2746
+ return translateMsysPath(p, buildTranslateEnv(readConfiguredBashPath()))
2747
+ },
2748
+ echo: (text, execution) => {
2749
+ const dialect = liveSettings.dialect()
2750
+ if (dialect.posixPaths !== true || !dialectApplies(dialect, execution?.agent)) return text
2751
+ const env = dialect.virtualMounts ? buildTranslateEnv(readConfiguredBashPath()) : null
2752
+ return env ? msysEcho(text, env) : windowsToMsys(text)
2753
+ },
2732
2754
  log: (m) => console.log(TAG + ' ' + m),
2733
2755
  })
2734
2756
  } catch (error) {
@@ -191,8 +191,25 @@ export async function mountPrivateExecutor(ctx, GitBashSandboxExecutor, createSc
191
191
  */
192
192
  export function mountInlineTools(ctx, deps) {
193
193
  const { tools, agents, createScope, pwshEnabled, log } = deps
194
- /** agent -> { timer, scope, denyPwsh }; named `tracked` because `agents` above is the host service */
194
+ /** Per-agent mounts, masks and cancellation; `agents` above is the host service. */
195
195
  const tracked = new Map()
196
+ const pending = new Set()
197
+ const controller = new AbortController()
198
+ let closed = false
199
+ const isLive = (agent, state) => !closed && tracked.get(agent) === state && !state.controller.signal.aborted
200
+ const newState = () => ({ timer: undefined, scope: undefined, denyPwsh: undefined, pending: undefined, controller: new AbortController() })
201
+ // Loader imports cannot be cancelled, but no continuation may outlive its
202
+ // owner. Join our cancelled continuations without waiting on a stuck loader.
203
+ const untilDisposed = async (promise, signal) => {
204
+ signal.throwIfAborted()
205
+ let abort
206
+ const cancelled = new Promise((_, reject) => {
207
+ abort = () => reject(signal.reason)
208
+ signal.addEventListener('abort', abort, { once: true })
209
+ })
210
+ try { return await Promise.race([promise, cancelled]) }
211
+ finally { signal.removeEventListener('abort', abort) }
212
+ }
196
213
  /** Resolved once per process: { executor, executorScope, module }. */
197
214
  let bashOnce
198
215
  /** Set as soon as any face shows ops_* tools (the call-layer guard reads it). */
@@ -214,58 +231,90 @@ export function mountInlineTools(ctx, deps) {
214
231
  * declarations — the §4k schemastery semantics come for free), so it never
215
232
  * touches the root `ctx.shell` service slot the official pwsh executor
216
233
  * owns. The executor scope lives for the plugin's lifetime and is disposed
217
- * by the effect registered at the bottom of this function.
234
+ * by the ownership effect registered before lifecycle wiring.
218
235
  */
219
236
  const bashOncePromise = async () => {
237
+ controller.signal.throwIfAborted()
220
238
  if (bashOnce) return bashOnce
221
- bashOnce = (async () => {
222
- const { GitBashSandboxExecutor } = await import('./shell.js')
223
- if (!GitBashSandboxExecutor) throw new Error('executor class unavailable')
224
- if (!GitBashSandboxExecutor.Config) {
225
- throw new Error('executor Config unavailable (schemastery unresolved; see the schemastery log lines above)')
226
- }
227
- if (typeof createScope !== 'function') throw new Error('no createScope seam')
228
- const { executor, executorScope } = await mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope)
229
- const hostModules = {}
230
- for (const [key, name] of HOST_MODULES) {
231
- try {
232
- hostModules[key] = await resolveHostModule(ctx, name)
233
- } catch { hostModules[key] = undefined }
239
+ const mounting = (async () => {
240
+ let executorScope
241
+ try {
242
+ const { GitBashSandboxExecutor } = await untilDisposed(import('./shell.js'), controller.signal)
243
+ if (!GitBashSandboxExecutor) throw new Error('executor class unavailable')
244
+ if (!GitBashSandboxExecutor.Config) {
245
+ throw new Error('executor Config unavailable (schemastery unresolved; see the schemastery log lines above)')
246
+ }
247
+ if (typeof createScope !== 'function') throw new Error('no createScope seam')
248
+ controller.signal.throwIfAborted()
249
+ const mounted = await mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope)
250
+ executorScope = mounted.executorScope
251
+ controller.signal.throwIfAborted()
252
+ const hostModules = {}
253
+ for (const [key, name] of HOST_MODULES) {
254
+ hostModules[key] = await untilDisposed(resolveHostModule(ctx, name), controller.signal)
255
+ }
256
+ const { createBashToolModule } = await untilDisposed(import('./bash-tool.js'), controller.signal)
257
+ const module = createBashToolModule({ executor: mounted.executor, log, ...hostModules })
258
+ return { executor: mounted.executor, executorScope, module }
259
+ } catch (error) {
260
+ if (executorScope) await executorScope.dispose()
261
+ throw error
234
262
  }
235
- const { createBashToolModule } = await import('./bash-tool.js')
236
- const module = createBashToolModule({ executor, log, ...hostModules })
237
- return { executor, executorScope, module }
238
263
  })()
239
- bashOnce.catch(() => { bashOnce = undefined })
240
- return bashOnce
264
+ bashOnce = mounting
265
+ mounting.catch(() => { if (bashOnce === mounting) bashOnce = undefined })
266
+ return mounting
241
267
  }
242
268
 
243
269
  /** Register the own bash tool inside one agent-owned scope. */
244
270
  const provideBash = async (agent, state) => {
245
- if (state.scope) return
271
+ let scope
272
+ let retained = false
246
273
  try {
247
- const { executor, module } = await bashOncePromise()
274
+ const { executor, module } = await untilDisposed(bashOncePromise(), state.controller.signal)
275
+ if (!isLive(agent, state)) return
248
276
  log(`own bash tool ready (private Git Bash executor at ${executor.bashPath || '(unresolved)'}; official pwsh executor untouched)`)
249
- const scope = createScope(agent.ctx)
277
+ scope = createScope(agent.ctx)
278
+ state.scope = scope // Own the scope BEFORE either asynchronous mount.
250
279
  // Same asynchronous-fiber shape the official module needed: wait for
251
280
  // the apply callback to capture scope.ctx before touching its tools.
252
- if (scope.fiber) { try { await scope.fiber } catch { /* disposed */ } }
281
+ if (scope.fiber) await untilDisposed(scope.fiber, state.controller.signal)
282
+ if (!isLive(agent, state)) return
253
283
  const registry = scope.ctx?.get('tools')
254
- if (!registry || typeof scope.ctx.plugin !== 'function') {
255
- void scope.dispose().catch(() => {})
256
- return
257
- }
284
+ if (!registry || typeof scope.ctx.plugin !== 'function') return
258
285
  const fiber = scope.ctx.plugin(module)
259
- await fiber
260
- state.scope = scope
286
+ await untilDisposed(fiber, state.controller.signal)
287
+ if (!isLive(agent, state)) return
288
+ retained = true
261
289
  log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)')
262
290
  } catch (error) {
263
- log(`own bash tool activation failed for this agent: ${error?.message ?? error}`)
291
+ if (isLive(agent, state)) log(`own bash tool activation failed for this agent: ${error?.message ?? error}`)
292
+ } finally {
293
+ if (scope && !retained) {
294
+ if (state.scope === scope) state.scope = undefined
295
+ await scope.dispose()
296
+ }
264
297
  }
265
298
  }
266
299
 
300
+ const startBash = (agent, state) => {
301
+ if (state.scope || state.pending || !isLive(agent, state)) return
302
+ const task = provideBash(agent, state)
303
+ state.pending = task
304
+ pending.add(task)
305
+ void task.finally(() => {
306
+ pending.delete(task)
307
+ if (state.pending === task) state.pending = undefined
308
+ }).catch((error) => log(`own bash tool cleanup failed: ${error?.message ?? error}`))
309
+ }
310
+
267
311
  const reconcile = (agent, { attempt = 0 } = {}) => {
268
- if (!agent?.ctx) return
312
+ if (closed || !agent?.ctx) return
313
+ // Undo ONLY our mask before observing the face. schemas() filters a
314
+ // restricted-away pwsh, so it cannot tell us what an ON flip must restore.
315
+ const previous = tracked.get(agent)
316
+ if (previous?.timer) { clearTimeout(previous.timer); previous.timer = undefined }
317
+ if (previous?.denyPwsh) { previous.denyPwsh(); previous.denyPwsh = undefined }
269
318
  const names = faceNames(agent)
270
319
  // The composed face may not be wired yet at created-time. Retry bounded:
271
320
  // a face with NEITHER tool carries no verdict. Five tries at 250ms, then
@@ -275,7 +324,7 @@ export function mountInlineTools(ctx, deps) {
275
324
  timer.unref?.()
276
325
  const state = tracked.get(agent)
277
326
  if (state) state.timer = timer
278
- else tracked.set(agent, { timer, scope: undefined, denyPwsh: undefined })
327
+ else tracked.set(agent, { ...newState(), timer })
279
328
  return
280
329
  }
281
330
  const pwshVisible = deps.pwshEnabled?.() === true
@@ -284,7 +333,7 @@ export function mountInlineTools(ctx, deps) {
284
333
  let state = tracked.get(agent)
285
334
  if (state?.timer) { clearTimeout(state.timer); state.timer = undefined }
286
335
  if (!state) {
287
- state = { timer: undefined, scope: undefined, denyPwsh: undefined }
336
+ state = newState()
288
337
  tracked.set(agent, state)
289
338
  }
290
339
  // The pwsh face (route B): the host tool is REAL PowerShell now, so the
@@ -307,22 +356,46 @@ export function mountInlineTools(ctx, deps) {
307
356
  }
308
357
  }
309
358
  if (needs.needsBash && !state.scope) {
310
- void provideBash(agent, state)
359
+ startBash(agent, state)
311
360
  }
312
361
  }
313
362
 
314
- const teardown = (agent) => {
363
+ const teardown = async (agent) => {
315
364
  const state = tracked.get(agent)
316
365
  if (!state) return
317
366
  tracked.delete(agent)
367
+ state.controller.abort(new Error('inline bash agent disposed'))
318
368
  if (state.timer) clearTimeout(state.timer)
319
- if (state.denyPwsh) { try { state.denyPwsh() } catch { /* already lifted */ } }
320
- if (state.scope) { void state.scope.dispose().catch(() => {}) }
369
+ if (state.denyPwsh) { state.denyPwsh(); state.denyPwsh = undefined }
370
+ await Promise.all([state.scope?.dispose(), state.pending])
321
371
  }
322
372
 
373
+ // Register ownership before the sweep starts asynchronous work. A cancelled
374
+ // loader continuation and every per-agent mount must settle before disposal.
375
+ ctx.effect(() => async () => {
376
+ closed = true
377
+ const mounting = bashOnce
378
+ controller.abort(new Error('inline bash plugin disposed'))
379
+ await Promise.all([
380
+ ...[...tracked.keys()].map(teardown),
381
+ ...pending,
382
+ mounting?.then(({ executorScope }) => executorScope.dispose(), () => {}),
383
+ ])
384
+ tracked.clear()
385
+ bashOnce = undefined
386
+ }, 'dsh-gitbash-shell: inline tool patching')
387
+
323
388
  try {
324
389
  ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } })
325
- ctx.on('agent/disposed', ({ agent } = {}) => { try { teardown(agent) } catch { /* never break disposal */ } })
390
+ ctx.on('agent/disposed', ({ agent } = {}) => teardown(agent).catch((error) => {
391
+ log(`agent bash tool cleanup failed: ${error?.message ?? error}`)
392
+ }))
393
+ ctx.on('loader/volatile-update', (paths) => {
394
+ if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && path[0] === 'pwshTool')) return
395
+ for (const agent of tracked.keys()) {
396
+ try { reconcile(agent) } catch (error) { log(`pwsh switch update failed: ${error?.message ?? error}`) }
397
+ }
398
+ })
326
399
  // MOUNT-TIME SWEEP (v0.35.2): agents that already exist when this row
327
400
  // mounts never fire agent/created. Same shape the proven dsh-ops denial
328
401
  // uses.
@@ -346,16 +419,6 @@ export function mountInlineTools(ctx, deps) {
346
419
  return
347
420
  }
348
421
 
349
- ctx.effect(() => () => {
350
- for (const agent of [...tracked.keys()]) teardown(agent)
351
- tracked.clear()
352
- if (bashOnce) {
353
- void Promise.resolve(bashOnce).then(({ executorScope } = {}) => {
354
- try { executorScope?.dispose?.() } catch { /* already gone */ }
355
- }).catch(() => {})
356
- bashOnce = undefined
357
- }
358
- }, 'dsh-gitbash-shell: inline tool patching')
359
422
  }
360
423
 
361
424
  // The host-package resolution channel lives in bash-tool.js; import it for