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.
- package/README.md +89 -64
- package/lib/client.js +129 -8
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +9 -2
- package/lib/index.mjs +57 -8
- package/lib/preset-core.d.mts +6 -2
- package/lib/preset-core.mjs +28 -9
- package/package.json +1 -1
- package/plan.md +4 -0
- package/preset/near-anchor.mjs +1 -4
- package/preset/router-first-turn.mjs +70 -59
- package/preset/router-guide.mjs +85 -0
- package/preset.md +0 -1
- package/upstream/dsh-anchored-standard/REVISION +1 -1
- package/upstream/dsh-anchored-standard/preset/agent.cordis.yml +22 -11
- package/upstream/dsh-anchored-standard/preset/custom-bash.mjs +98 -5
package/preset/near-anchor.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 +1 @@
|
|
|
1
|
-
|
|
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,
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
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.
|
|
195
|
-
#
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
|
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
|
|
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
|