dsh-plugin-prompt-tool 0.2.0 → 0.3.1

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.
@@ -33,9 +33,6 @@ const ANCHOR_INSPECT = "Start your reasoning with the exact sentence: 'We need t
33
33
  /** 复杂规划类:放行 Let 深度规划路径。 */
34
34
  const ANCHOR_DEEP = "Start your reasoning with the exact sentence: 'Let me think through the design before changing anything.'"
35
35
 
36
- /** Flash 模型附加三锚(回顾已完成 + 信息足够就产出 + 禁止环境检查/穷举)。 */
37
- const FLASH_ANCHORS = ' Before acting, briefly review what you have already done in this session and continue from where you left off; do not repeat completed steps. When you have gathered enough information, stop exploring and produce the deliverable. Do not run environment checks (echo, whoami, uname, node --version, date) or exhaustive grep/glob scans.'
38
-
39
36
  /** 生成消息 id:优先加密随机 id,旧运行时回退到随机串。 */
40
37
  function newMessageId() {
41
38
  return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
@@ -60,7 +57,7 @@ function chooseAnchor(text, modelId, customText, useCustom) {
60
57
  if (COMPLEX_RE.test(text)) anchor = ANCHOR_DEEP
61
58
  else if (BUILD_RE.test(text)) anchor = ANCHOR_BUILD
62
59
  else anchor = ANCHOR_INSPECT
63
- if (typeof modelId === 'string' && /flash/i.test(modelId)) return anchor + FLASH_ANCHORS
60
+ // Flash 主会话的 persona 已由 router-first-turn 注入三锚,这里不再重复。
64
61
  return anchor
65
62
  }
66
63
 
@@ -1,59 +1,70 @@
1
- /**
2
- * router-first-turn — prompt-tool 本地附加件(最优组合)。
3
- *
4
- * 依据 dsh-router-standard 的 router-bootstrap 组装逻辑:
5
- * - 只替换 persona 段为官方训练原句,保留计划模式段与其他第三方
6
- * section(applyPersona 语义,不是整段丢弃);
7
- * - 首轮隐藏 mnemon:* 自动注入段(记忆路由/热记忆上下文)并清空 contexts,
8
- * 晋升后两者都恢复——与 anchored-standard 的“首轮剥离自动注入”一致;
9
- * - 子代理(委托深度 > 0)直接放行完整结果,保证 dsh-mnemon 等插件
10
- * 通过工具白名单要求的 mnemon_* 工具首轮可见。
11
- *
12
- * 工具目录裁剪仍由上游 tool-bootstrap 负责(首轮 = 真实 Minimal 工具对;
13
- * 晋升后 = resident 集),本模块不触碰 tools,避免两层过滤器冲突。
14
- * 计划模式段必须保留:router-standard 早期实测证明整段替换 section
15
- * 丢失 plan 边界,导致模型离开计划模式后重复探索(“失忆”问题)。
16
- */
17
-
18
- /** Cordis 插件名,供 loader 诊断使用。 */
19
- export const name = 'router-first-turn'
20
-
21
- /** 无服务依赖,只监听 system-prompt 组装。 */
22
- export const inject = []
23
-
24
- /** 官方 RL 训练原句(保持与 Minimal 预设逐字节一致)。 */
25
- const RL_PERSONA = 'You are a helpful software engineer assistant.'
26
-
27
- /** 是否为 persona 段:旧 persona 段由本模块替换。 */
28
- function isPersonaSection(section) {
29
- if (section === null || typeof section !== 'object') return false
30
- const name = String(section.name ?? '')
31
- return name === 'persona' || /persona/i.test(name)
32
- }
33
-
34
- /** 首轮需要隐藏的 mnemon 自动注入段(晋升后恢复)。 */
35
- function isMnemonSection(section) {
36
- if (section === null || typeof section !== 'object') return false
37
- return String(section.name ?? '').startsWith('mnemon:')
38
- }
39
-
40
- export function apply(ctx) {
41
- ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
42
- const assembled = await next()
43
- const agent = context.agent
44
- if (agent === undefined) return assembled
45
- const session = agent.session
46
- if (session === undefined) return assembled
47
-
48
- // 子代理不裁剪任何 section/context:它与 dsh-mnemon 的工具白名单协作。
49
- if ((session.header?.delegationDepth ?? 0) > 0) return assembled
50
-
51
- const sections = assembled.sections ?? []
52
- const promoted = session.events.some((event) => event.type === 'tool/call')
53
- const kept = sections.filter((section) => !isPersonaSection(section) && (promoted || !isMnemonSection(section)))
54
- const routerSections = [...kept, { name: 'router-persona', text: RL_PERSONA, order: 0 }]
55
- return promoted
56
- ? { ...assembled, sections: routerSections }
57
- : { ...assembled, sections: routerSections, contexts: [] }
58
- })
59
- }
1
+ /**
2
+ * router-first-turn — prompt-tool 本地附加件(最优组合)。
3
+ *
4
+ * 依据 dsh-router-standard 的 router-bootstrap 组装逻辑:
5
+ * - 只替换 persona 段,保留计划模式段与其他第三方 section;
6
+ * - 首轮隐藏 mnemon:* 自动注入段并清空 contexts,晋升后恢复;
7
+ * - 按主会话模型自动选择 persona:
8
+ * Pro → 官方训练原句;
9
+ * Flash → dsh-router-standard Flash 弱路由人设(build/fix 分类 +
10
+ * 回顾锚 + 收敛锚 + 反跑题锚 + 先深想再产出);
11
+ * - 子代理(委托深度 > 0)直接放行完整结果,保证 dsh-mnemon 等插件
12
+ * 通过工具白名单要求的 mnemon_* 工具首轮可见。
13
+ *
14
+ * 工具目录裁剪仍由上游 tool-bootstrap 负责(首轮 = 真实 Minimal 工具对;
15
+ * 晋升后 = resident 集),本模块不触碰 tools,避免两层过滤器冲突。
16
+ */
17
+
18
+ /** Cordis 插件名,供 loader 诊断使用。 */
19
+ export const name = 'router-first-turn'
20
+
21
+ /** 无服务依赖,只监听 system-prompt 组装。 */
22
+ export const inject = []
23
+
24
+ /** 官方 RL 训练原句(保持与 Minimal 预设逐字节一致)。 */
25
+ const RL_PERSONA = 'You are a helpful software engineer assistant.'
26
+
27
+ /** dsh-router-standard Flash 弱路由人设(build/fix 分类 + 三锚 + 深想)。 */
28
+ const FLASH_PERSONA = [
29
+ 'You are a helpful assistant.',
30
+ 'Before acting, decide the task type (build or fix) and adopt the matching style: build → hands-on production; fix → inspect-and-plan.',
31
+ 'Before acting, briefly review what you have already done in this session and continue from where you left off; do not repeat completed steps. Do not run environment checks (echo, whoami, uname, node --version, date) or exhaustive grep/glob scans.',
32
+ 'Think deeply first, then produce.',
33
+ ].join('\n')
34
+
35
+ /** 是否为 persona 段:旧 persona 段由本模块替换。 */
36
+ function isPersonaSection(section) {
37
+ if (section === null || typeof section !== 'object') return false
38
+ const name = String(section.name ?? '')
39
+ return name === 'persona' || /persona/i.test(name)
40
+ }
41
+
42
+ /** 首轮需要隐藏的 mnemon 自动注入段(晋升后恢复)。 */
43
+ function isMnemonSection(section) {
44
+ if (section === null || typeof section !== 'object') return false
45
+ return String(section.name ?? '').startsWith('mnemon:')
46
+ }
47
+
48
+ export function apply(ctx) {
49
+ ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
50
+ const assembled = await next()
51
+ const agent = context.agent
52
+ if (agent === undefined) return assembled
53
+ const session = agent.session
54
+ if (session === undefined) return assembled
55
+
56
+ // 子代理不裁剪任何 section/context:它与 dsh-mnemon 的工具白名单协作。
57
+ if ((session.header?.delegationDepth ?? 0) > 0) return assembled
58
+
59
+ const modelId = agent.options?.model
60
+ const persona = typeof modelId === 'string' && /flash/i.test(modelId) ? FLASH_PERSONA : RL_PERSONA
61
+
62
+ const sections = assembled.sections ?? []
63
+ const promoted = session.events.some((event) => event.type === 'tool/call')
64
+ const kept = sections.filter((section) => !isPersonaSection(section) && (promoted || !isMnemonSection(section)))
65
+ const routerSections = [...kept, { name: 'router-persona', text: persona, order: 0 }]
66
+ return promoted
67
+ ? { ...assembled, sections: routerSections }
68
+ : { ...assembled, sections: routerSections, contexts: [] }
69
+ })
70
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * router-guide — Flash 主会话每轮近距离深度引导(dsh-router-standard 方案)。
3
+ *
4
+ * 只在主会话模型为 Flash、且会话已晋升后启用;每个真实用户消息之后追加一条
5
+ * 固定引导(近距离零衰减,缓存友好):
6
+ * - 复杂任务:深度引导(架构/边界/集成点,信息足够就产出,每块以决策或信息缺口收尾);
7
+ * - 简单任务:快速收敛引导(分类 + 直接产出)。
8
+ * config.useCustom=true 时 Pro 与 Flash 一样每轮注入:文本未改动默认值时按
9
+ * 任务自动选择 GUIDE_WEAK/GUIDE_DEEP,改动后固定使用 config.text;空文本不注入。
10
+ * false(默认)时自动模式仅 Flash 主会话注入(router 实测 Pro 不需要)。
11
+ * 子代理不注入;首轮不注入(首句锚定由 near-anchor 负责)。
12
+ */
13
+
14
+ /** Cordis 插件名,供 loader 诊断使用。 */
15
+ export const name = 'router-guide'
16
+
17
+ /** 无服务依赖,只监听 pre-step 消息组装。 */
18
+ export const inject = []
19
+
20
+ /** 复杂任务判定:长度或架构关键词。 */
21
+ const COMPLEX_RE = /(架构|重构|全面|详细|设计|系统|优化|分析|architecture|refactor|comprehensive|detailed|design|system|optimize|analyze)/i
22
+
23
+ const GUIDE_WEAK = '\nRouter: classify this task (build or fix) now, then adopt the matching style — build: direct production; fix: inspect-first. Think deeply first, then commit and act.'
24
+ const GUIDE_DEEP = '\nRouter: classify this task (build or fix) now, then adopt the matching style — build: direct production; fix: inspect-first. Think deeply about the architecture, edge cases, and integration points. Do not spend reasoning on the environment or tooling. Produce when your information is complete. End each reasoning block with a decision or an information need.'
25
+
26
+ function newMessageId() {
27
+ return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
28
+ ? crypto.randomUUID()
29
+ : `router-guide-${Date.now()}-${Math.random().toString(36).slice(2)}`
30
+ }
31
+
32
+ function extractText(message) {
33
+ if (!message) return ''
34
+ const content = Array.isArray(message.content) ? message.content : []
35
+ return content.map((block) => (typeof block === 'string' ? block : (block?.text ?? ''))).join(' ').trim()
36
+ }
37
+
38
+ export function apply(ctx, config) {
39
+ const useCustom = config?.useCustom === true
40
+ const customText = typeof config?.text === 'string' ? config.text : ''
41
+ const enabled = config?.enabled !== false
42
+ // 与宿主写入的默认内容一致:打开自定义但未改动默认文本时,等价自动模式。
43
+ const DEFAULT_GUIDE_TEXT = ['简单任务自动引导:', GUIDE_WEAK.trim(), '', '复杂任务自动引导:', GUIDE_DEEP.trim()].join('\n')
44
+ const unchangedDefault = customText.trim() === DEFAULT_GUIDE_TEXT.trim()
45
+ const effectiveAuto = !useCustom || unchangedDefault
46
+
47
+ ctx.on('agent/pre-step', async ({ agent }, next) => {
48
+ const decision = await next()
49
+ if (decision.kind === 'reject' || !enabled) return decision
50
+ if (agent === undefined) return decision
51
+ const session = agent.session
52
+ if (session === undefined) return decision
53
+ // 子代理不注入。
54
+ if ((session.header?.delegationDepth ?? 0) > 0) return decision
55
+ // 自动模式只对 Flash 主会话注入;自定义开关打开后,Pro 与 Flash 一样注入。
56
+ const modelId = agent.options?.model
57
+ const isFlash = typeof modelId === 'string' && /flash/i.test(modelId)
58
+ if (!useCustom && !isFlash) return decision
59
+ // 晋升后才注入(首轮由 near-anchor 负责)。
60
+ const promoted = session.events.some((event) => event.type === 'tool/call' || event.type === 'assistant/message')
61
+ if (!promoted) return decision
62
+
63
+ const messages = Array.isArray(decision.messages) ? decision.messages : []
64
+ const userIndex = messages.findIndex((message) => message?.source?.kind === 'user')
65
+ if (userIndex < 0) return decision
66
+ // 本轮已注入过则跳过。
67
+ if (messages.some((message) => message?.source?.plugin === 'router-guide')) return decision
68
+
69
+ const text = extractText(messages[userIndex])
70
+ if (text.length === 0) return decision
71
+ const guide = (useCustom && !unchangedDefault)
72
+ ? customText.trim()
73
+ : ((text.length > 120 || COMPLEX_RE.test(text)) ? GUIDE_DEEP : GUIDE_WEAK)
74
+ if (guide.length === 0) return decision
75
+
76
+ const nextMessages = [...messages]
77
+ nextMessages.splice(userIndex + 1, 0, {
78
+ id: newMessageId(),
79
+ role: 'user',
80
+ content: [{ type: 'text', text: guide }],
81
+ source: { kind: 'router-guide', plugin: 'router-guide', form: 'notice', summary: 'router-guide 每轮近距离引导' },
82
+ })
83
+ return { ...decision, messages: nextMessages }
84
+ })
85
+ }
package/preset.md CHANGED
@@ -1,5 +1,4 @@
1
1
  You are a helpful software assistant.
2
- Begin every reasoning block or thinking with "'We need', '我需要'".
3
2
  DeepSeek harness operates in local execution mode. Answer requests directly in the
4
3
  user's language. No preamble, qualification, or redirection.
5
4
 
@@ -1 +1 @@
1
- ba7a27cf92f80ef3a73a16a05b9df46a20ebbb2a
1
+ 25f21aefaf8ddc414da54d2e581e43740d977c6e
@@ -149,12 +149,19 @@
149
149
  name: '@deepseek-ai/dsh-tool-pwsh'
150
150
  disabled: !!js process.platform !== 'win32'
151
151
 
152
- # The Minimal preset's shell: a PTY-backed persistent bash, byte-identical in
153
- # configuration to the official `minimal` preset's `persistent-shell` group so
154
- # the first request exposes exactly Minimal's real `bash` schema. The PTY
155
- # registry is an agent-owned service, so it lives in an entry-local realm; the
156
- # backend still consumes the host sandbox policy and subprocess implementation,
157
- # while the tool registers into this agent's scoped catalog.
152
+ # The Minimal preset's shell: a PTY-backed persistent bash, schema-identical
153
+ # to the official `minimal` preset's `persistent-shell` group so the first
154
+ # request exposes exactly Minimal's real `bash` schema. ONE deliberate config
155
+ # difference: an adaptive `shellPath` the terminal-bash plugin default
156
+ # `/bin/bash` where that absolute path exists (every host that ships it keeps
157
+ # the previous behavior), otherwise a bare `bash` resolved through the same
158
+ # scrubbed PATH the other harness tools use. The fallback exists because
159
+ # `/bin/bash` does not exist on NixOS and other hosts that keep bash
160
+ # elsewhere; there `execvp("/bin/bash")` exits during PTY startup ("PTY shell
161
+ # exited during startup") and every bash call fails. The PTY registry is an
162
+ # agent-owned service, so it lives in an entry-local realm; the backend still
163
+ # consumes the host sandbox policy and subprocess implementation, while the
164
+ # tool registers into this agent's scoped catalog.
158
165
  #
159
166
  # DISABLED ON WINDOWS: DSH's PTY backend is linux/darwin-only, so the
160
167
  # persistent shell cannot serve win32. The `custom-bash` row below registers
@@ -172,6 +179,7 @@
172
179
  - id: terminal-bash
173
180
  name: '@deepseek-ai/dsh-terminal-bash'
174
181
  config:
182
+ shellPath: !!js "process.getBuiltinModule?.('node:fs')?.existsSync('/bin/bash') ? '/bin/bash' : 'bash'"
175
183
  timeoutMs: 300000
176
184
 
177
185
  - id: persistent-bash
@@ -191,15 +199,18 @@
191
199
  # Windows-only `bash` tool (see custom-bash.mjs): registers the SAME
192
200
  # tool name as the persistent shell with a Minimal-compatible description, but
193
201
  # executes through the ordinary cross-platform subprocess seam (`bash -c`)
194
- # instead of a PTY. `bashPath` defaults to `bash` on PATH; point it at Git
195
- # Bash explicitly when the WSL shim would otherwise be picked up. No OS
202
+ # instead of a PTY. The shell is resolved WITHOUT a hardcoded install path
203
+ # (issue #24): `bashPath` unset probes the `git` executable's install root,
204
+ # then the well-known Git-for-Windows roots (Program Files(/x86), per-user
205
+ # LOCALAPPDATA, scoop's `current` junction), then plain `bash` on PATH — set
206
+ # `bashPath` explicitly only to pin a shell that probing cannot find. No OS
196
207
  # sandbox confinement on Windows (landlock is linux-only); the tool
197
- # description says so.
208
+ # description says so. When no bash exists at all the tool errors with
209
+ # guidance instead of switching shells — pwsh stays its own tool in the
210
+ # promoted catalog.
198
211
  - id: custom-bash
199
212
  name: ./custom-bash.mjs
200
213
  disabled: !!js process.platform !== 'win32'
201
- config:
202
- bashPath: 'C:\Program Files\Git\bin\bash.exe'
203
214
 
204
215
  # ── filesystem ──────────────────────────────────────────────────────────────
205
216
 
@@ -13,9 +13,27 @@
13
13
  * the ordinary (cross-platform) subprocess seam keeps the schema anchor
14
14
  * without the PTY dependency.
15
15
  *
16
- * Executable resolution (config `bashPath`):
17
- * - explicit absolute path (e.g. `C:\Program Files\Git\bin\bash.exe`), or
18
- * - `bash` resolved through `ctx.subprocess.resolveExecutable` (PATH lookup).
16
+ * Executable resolution (config `bashPath`, issue #24 — no hardcoded install
17
+ * path): an explicit non-empty `bashPath` wins unconditionally. Unset, the
18
+ * Git Bash executable is INFERRED, in probe order:
19
+ * 1. the `git` executable on PATH — its install root carries `bin\bash.exe`
20
+ * one level up from `cmd\`, beside `bin\`, or two levels up from
21
+ * `mingw64\bin\` (the standard installer, choco, and winget all resolve
22
+ * here; a scoop SHIM does not — its directory is the shims root, not the
23
+ * app — which is what step 2 covers);
24
+ * 2. the well-known Git-for-Windows roots derived from environment variables
25
+ * (`ProgramFiles`, `ProgramFiles(x86)`, per-user `LOCALAPPDATA\Programs
26
+ * \Git`, scoop's `~\scoop\apps\git\current` junction);
27
+ * 3. plain `bash` through `ctx.subprocess.resolveExecutable` (PATH lookup —
28
+ * last resort, since on Windows that may pick the WSL shim; WSL bash is
29
+ * still true bash, only the filesystem paths shift to /mnt/…).
30
+ *
31
+ * If NOTHING resolves, the tool fails with an actionable error naming the
32
+ * remedies — it does NOT silently execute under a different shell: the
33
+ * schema above promises `bash -c` semantics, and pwsh/cmd are different
34
+ * command languages. PowerShell stays available as its OWN tool (`pwsh`,
35
+ * present in the promoted catalog on Windows, unlockable via
36
+ * dev_tool_search).
19
37
  *
20
38
  * Semantics mirror the official bash tool: `bash -c <command>` in a fresh
21
39
  * process, bounded output, non-zero exit reported not thrown. No sandbox
@@ -24,6 +42,9 @@
24
42
  * `str_replace_editor` (Minimal's two tools).
25
43
  */
26
44
 
45
+ import { access } from 'node:fs/promises'
46
+ import { dirname, join } from 'node:path'
47
+
27
48
  /** Cordis plugin name used by loader diagnostics. */
28
49
  export const name = 'custom-bash'
29
50
 
@@ -33,6 +54,34 @@ export const inject = ['subprocess', 'tools']
33
54
  const DEFAULT_TIMEOUT_MS = 120000
34
55
  const DEFAULT_MAX_OUTPUT_BYTES = 64000
35
56
 
57
+ /**
58
+ * Git Bash candidate paths, in probe order (see the header): the `git`
59
+ * executable's install root first, then the well-known env-derived roots.
60
+ * Exported for tests; pure — existence probing happens at the call site.
61
+ */
62
+ export function bashCandidates(env, gitExe) {
63
+ const candidates = []
64
+ // git at <root>\cmd\git.exe (installer/scoop) or <root>\bin\git.exe →
65
+ // <root>\bin\bash.exe; <root>\mingw64\bin\git.exe (portable) → two up.
66
+ // A bare relative name means `git` did not actually resolve to a path.
67
+ if (typeof gitExe === 'string' && /[/\\]/.test(gitExe)) {
68
+ const dir = dirname(gitExe)
69
+ const root = dirname(dir)
70
+ candidates.push(
71
+ join(root, 'bin', 'bash.exe'),
72
+ join(dir, 'bash.exe'),
73
+ join(dirname(root), 'bin', 'bash.exe'),
74
+ )
75
+ }
76
+ if (env.ProgramFiles) candidates.push(join(env.ProgramFiles, 'Git', 'bin', 'bash.exe'))
77
+ if (env['ProgramFiles(x86)']) candidates.push(join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'))
78
+ if (env.LOCALAPPDATA) candidates.push(join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'))
79
+ if (env.USERPROFILE) candidates.push(join(env.USERPROFILE, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'))
80
+ // Layouts overlap (a `bin` git.exe derives the same bash twice) — probe
81
+ // order survives the dedupe, insertion order is preserved.
82
+ return [...new Set(candidates)]
83
+ }
84
+
36
85
  /** Tool parameter schema for the model-facing command. */
37
86
  const commandSchema = {
38
87
  type: 'object',
@@ -52,10 +101,54 @@ const commandSchema = {
52
101
 
53
102
  /** Register the model-facing `bash` tool. */
54
103
  export function apply(ctx, config) {
55
- const bashPath = typeof config?.bashPath === 'string' && config.bashPath.length > 0 ? config.bashPath : 'bash'
104
+ const explicitBashPath = typeof config?.bashPath === 'string' && config.bashPath.length > 0 ? config.bashPath : undefined
56
105
  const timeoutMs = Number.isSafeInteger(config?.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS
57
106
  const maxOutputBytes = Number.isSafeInteger(config?.maxOutputBytes) && config.maxOutputBytes > 0 ? config.maxOutputBytes : DEFAULT_MAX_OUTPUT_BYTES
58
107
 
108
+ // The inferred executable is memoized per plugin instance: candidate probing
109
+ // walks the filesystem, and the answer cannot change within a mount. A
110
+ // failed inference is NOT memoized — the plain `bash` fallback resolves
111
+ // fresh on every execute until some probe succeeds.
112
+ let inferredShell
113
+ const exists = (path) => access(path).then(() => true, () => false)
114
+ const resolveShell = async (signal) => {
115
+ if (explicitBashPath !== undefined) {
116
+ // A misconfigured explicit path must fail as itself, not as a
117
+ // discovery miss — the raw resolution error says which path failed.
118
+ return ctx.subprocess.resolveExecutable(explicitBashPath, undefined, signal)
119
+ }
120
+ if (inferredShell !== undefined) {
121
+ return ctx.subprocess.resolveExecutable(inferredShell, undefined, signal)
122
+ }
123
+ let gitExe
124
+ try {
125
+ gitExe = await ctx.subprocess.resolveExecutable('git', undefined, signal)
126
+ } catch {
127
+ // git unresolvable → the env-derived candidates below still apply
128
+ }
129
+ for (const candidate of bashCandidates(process.env, gitExe)) {
130
+ if (!(await exists(candidate))) continue
131
+ try {
132
+ inferredShell = await ctx.subprocess.resolveExecutable(candidate, undefined, signal)
133
+ return inferredShell
134
+ } catch {
135
+ // Exists but unresolvable (EPERM, a broken scoop junction): keep
136
+ // probing — one bad root must not block the rest of the chain, and
137
+ // nothing is memoized so later executes can still find a good one.
138
+ continue
139
+ }
140
+ }
141
+ try {
142
+ return await ctx.subprocess.resolveExecutable('bash', undefined, signal)
143
+ } catch (error) {
144
+ // Total discovery failure (no Git Bash root, no env root, no bash on
145
+ // PATH): name the remedies instead of leaking a raw ENOENT. Never
146
+ // fall back to pwsh/cmd here — the schema promises `bash -c`
147
+ // semantics; a different shell would silently break every command.
148
+ throw new Error(`bash executable not found — install Git for Windows, expose a bash on PATH, or set the custom-bash \`bashPath\` config (${String((error && error.message) || error)})`)
149
+ }
150
+ }
151
+
59
152
  ctx.tools.register({
60
153
  name: 'bash',
61
154
  description: [
@@ -81,7 +174,7 @@ export function apply(ctx, config) {
81
174
  render: (_args, value) => [{ type: 'text', text: value.text }],
82
175
  },
83
176
  async execute(args, exec) {
84
- const shell = await ctx.subprocess.resolveExecutable(bashPath, undefined, exec?.signal)
177
+ const shell = await resolveShell(exec?.signal)
85
178
  const workdir = typeof args.workdir === 'string' && args.workdir.length > 0
86
179
  ? args.workdir
87
180
  : exec?.agent?.session?.header?.cwd