dsh-plugin-prompt-tool 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * skill-search — on-demand skill discovery and loading, replacing
3
+ * `dsh-tool-skill`'s full-catalog injection.
4
+ *
5
+ * WHY: the available-skills reminder (`<available_skills>`, ~9KB with many
6
+ * skills) is injected into the first step by dsh-tool-skill and again after
7
+ * every promotion/compaction. That large injected block perturbs the
8
+ * trajectory (issue #6: 0/9 anchored with the catalog present vs ~81%
9
+ * without). We remove the catalog injection entirely and expose two small
10
+ * tools instead — the Claude tool-search pattern:
11
+ *
12
+ * - `skill_search` — list skills whose name/description match a query
13
+ * (summaries only, bounded; no bodies). The model discovers what exists
14
+ * without a 9KB dump.
15
+ * - `skill_load` — load ONE skill's full instructions by exact name and
16
+ * inject them for the NEXT request via `agent.inject` (the non-waking
17
+ * next-step inbox). The model (or the user) calls this only when the
18
+ * skill is actually needed.
19
+ *
20
+ * Discovery reads `ctx.skills` scoped to the calling agent, exactly like
21
+ * dsh-tool-skill. If skills are unavailable the tools answer with a short
22
+ * message instead of throwing.
23
+ *
24
+ * NOTE: this plugin REPLACES the `dsh-tool-skill` row in the composition —
25
+ * the composition must NOT mount both, or the catalog injection returns.
26
+ */
27
+
28
+ /** Cordis plugin name used by loader diagnostics. */
29
+ export const name = 'skill-search'
30
+
31
+ /** The agent, tools, and skills services must exist before these tools can register. */
32
+ export const inject = ['agents', 'tools', 'skills']
33
+
34
+ const MAX_RESULTS = 20
35
+
36
+ /** Minimal JSON schema compiler for tool parameters (zero dependencies). */
37
+ function toJsonSchema(spec) {
38
+ const properties = {}
39
+ const required = []
40
+ for (const [key, meta] of Object.entries(spec || {})) {
41
+ const prop = { type: meta.type }
42
+ if (meta.description) prop.description = meta.description
43
+ properties[key] = prop
44
+ if (meta.required) required.push(key)
45
+ }
46
+ return { type: 'object', properties, required, additionalProperties: false }
47
+ }
48
+
49
+ /** Register the two on-demand skill tools. */
50
+ export function apply(ctx) {
51
+ /** Normalize a query into lowercase tokens for simple substring matching. */
52
+ const tokens = (text) => (text || '').toLowerCase().split(/[^a-z0-9_-]+/).filter(Boolean)
53
+
54
+ ctx.tools.register({
55
+ name: 'skill_search',
56
+ description: 'Search the available skills by keyword and return matching skill names with short descriptions. This session keeps NO skill catalog in the prompt — if a task looks like it matches a skill (document conversion, image processing, game reviews, markdown, PDF, spreadsheets, …), call skill_search FIRST to find it, then skill_load to activate it. Do NOT assume skill names from memory.',
57
+ parameters: toJsonSchema({
58
+ query: { type: 'string', required: true, description: 'search keywords (e.g. "pdf", "obsidian", "game review")' },
59
+ }),
60
+ output: {
61
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } }, required: ['text'] },
62
+ render: (_a, v) => [{ type: 'text', text: v.text }],
63
+ },
64
+ async execute(args, exec) {
65
+ const wanted = tokens(args.query)
66
+ const scope = exec?.agent ?? ctx
67
+ try {
68
+ const all = await ctx.skills.list({
69
+ scope,
70
+ cwd: exec?.agent?.session?.header?.cwd,
71
+ signal: exec?.signal,
72
+ })
73
+ const matches = all.filter((skill) => {
74
+ if (wanted.length === 0) return true
75
+ const haystack = tokens(`${skill.name} ${skill.description ?? ''} ${skill.whenToUse ?? ''}`).join(' ')
76
+ return wanted.every((token) => haystack.includes(token))
77
+ })
78
+ const head = matches.slice(0, MAX_RESULTS)
79
+ const lines = head.map((skill) => {
80
+ const desc = (skill.description || '').split('\n')[0]
81
+ return `- ${skill.name}: ${desc}`
82
+ })
83
+ if (lines.length === 0) return { text: `No skills match "${args.query}". Use skill_search with other keywords.` }
84
+ const extra = matches.length > MAX_RESULTS ? `\n…(${matches.length - MAX_RESULTS} more)` : ''
85
+ return { text: `Matching skills (${matches.length}):\n${lines.join('\n')}${extra}\n\nLoad one with skill_load (exact name).` }
86
+ } catch (error) {
87
+ return { text: `skill_search unavailable: ${String((error && error.message) || error)}` }
88
+ }
89
+ },
90
+ })
91
+
92
+ ctx.tools.register({
93
+ name: 'skill_load',
94
+ description: 'Load the full instructions of ONE skill by its exact name (from skill_search results) and inject them for the next request. Call this before acting on a task that matches the skill.',
95
+ parameters: toJsonSchema({
96
+ name: { type: 'string', required: true, description: 'exact skill name (kebab-case, from skill_search)' },
97
+ }),
98
+ output: {
99
+ schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } }, required: ['text'] },
100
+ render: (_a, v) => [{ type: 'text', text: v.text }],
101
+ },
102
+ async execute(args, exec) {
103
+ try {
104
+ const agent = exec?.agent
105
+ if (agent === undefined) return { text: 'skill_load requires an agent context.' }
106
+ const skill = await ctx.skills.get(args.name, {
107
+ scope: agent,
108
+ cwd: agent.session.header.cwd,
109
+ signal: exec?.signal,
110
+ })
111
+ if (skill === undefined) {
112
+ return { text: `No skill named "${args.name}". Run skill_search to list available skills.` }
113
+ }
114
+ const body = extractSkillBody(skill)
115
+ if (body.length === 0) {
116
+ return { text: `Skill "${args.name}" has no loadable body.` }
117
+ }
118
+ // Queue the skill content as a non-waking next-step context message,
119
+ // exactly like dsh-tool-skill's invocation injection.
120
+ agent.inject({
121
+ id: `skill-load-${args.name}-${Date.now()}`,
122
+ role: 'user',
123
+ content: [{ type: 'text', text: body }],
124
+ source: { kind: 'skill-invocation', name: args.name, form: 'instructions' },
125
+ })
126
+ return { text: `Skill "${args.name}" loaded; its instructions will be injected for the next request.` }
127
+ } catch (error) {
128
+ return { text: `skill_load failed: ${String((error && error.message) || error)}` }
129
+ }
130
+ },
131
+ })
132
+ }
133
+
134
+ /** Extract the model-facing body of a loaded skill definition. */
135
+ function extractSkillBody(skill) {
136
+ const content = skill?.content ?? skill?.instructions ?? skill?.body
137
+ if (typeof content === 'string') return content
138
+ if (Array.isArray(content)) {
139
+ return content.map((part) => (typeof part === 'string' ? part : JSON.stringify(part))).join('\n')
140
+ }
141
+ return ''
142
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Anchored tool bootstrap — keep the FIRST model request on the Minimal
3
+ * preset's REAL tool schema (persistent `bash` + `str_replace_editor`), then
4
+ * keep the assembled catalog once the session has produced its first durable
5
+ * promotion signal; `usePtcMode` optionally switches the wire presentation to
6
+ * Code Mode (PTC). Injected-context control lives in the companion
7
+ * `context-gate` plugin, not here.
8
+ *
9
+ * The phase is derived from durable session events, so resume and reload
10
+ * preserve it. By default (`promoteOn: 'either'`) a session promotes after the
11
+ * first `tool/call` OR the first `assistant/message`, whichever comes first:
12
+ * request #1 always sees the bootstrap catalog and later requests keep the
13
+ * assembled catalog. The original `'tool-call'` mode is kept for compatibility,
14
+ * but it can trap a session in bootstrap forever when the first model reply
15
+ * makes no tool call — the `'either'` default removes that trap while keeping
16
+ * the first-request anchor intact.
17
+ *
18
+ * First-request conditions established by the reproduction work (issues #6
19
+ * and #11, 2026-08-15):
20
+ *
21
+ * 1. Tool schema. The API-visible first-request catalog decides whether the
22
+ * session anchors on the Minimal trajectory. At the adapter-default
23
+ * maxTokens (256000 on the official endpoint) the Minimal tool pair —
24
+ * persistent `bash` + `str_replace_editor` — anchored 5/5 runs with zero
25
+ * `let me` first-lines, while every standard-family schema (pwsh/read,
26
+ * pwsh only, sandboxed bash/read) fell into standard-like behavior
27
+ * (11/11). Bootstrap therefore exposes exactly the Minimal pair, not
28
+ * Standard's `pwsh`/`read`.
29
+ *
30
+ * 2. Output budget. On the official endpoint the first request's `max_tokens`
31
+ * also dominated the trajectory anchor at 1024 (`We need` style in 26/32
32
+ * runs against 0/5 at 256000, independent of tool descriptions). The
33
+ * Minimal tool schema, however, anchors at 256000 WITHOUT any cap, and the
34
+ * cap's delivery depends on the profile package's `prepareCall` behavior
35
+ * (it reaches the request on the 0.1.0-rc.5 source checkout; a prebuilt
36
+ * rc.6-reporting profile package observed in issue #11 overwrote it with
37
+ * `adapterDefaults.maxTokens`). `bootstrapMaxTokens` is therefore OPT-IN:
38
+ * leave it unset to run the Minimal schema at the adapter default, or set
39
+ * it to cap the first request. When set, the cap is stripped after
40
+ * promotion — the next request's seed proposal carries the previous
41
+ * header's maxTokens forward, so the release must be explicit.
42
+ *
43
+ * 3. Injected context is NOT this plugin's concern: the companion
44
+ * `context-gate` plugin (shared/context-gate.mjs, mounted as the FIRST
45
+ * row) owns the unified injection control — runtime-context suppression
46
+ * on the assembly path and a claimed-baseline deny on the pre-step
47
+ * waterfall, both keyed to the same epoch-aware promotion phase. Mount it
48
+ * separately for context control alone; this file narrows only the tool
49
+ * catalog (plus the optional output cap below).
50
+ *
51
+ * SUBAGENTS: by default subagents (delegationDepth > 0) are always promoted
52
+ * (assembled catalog from their first request). `includeSubagents: true`
53
+ * makes them follow the same bootstrap phase — their first request also sees
54
+ * the bootstrap pair, and their own first reply or tool call promotes them.
55
+ * Keep this flag in sync with the context-gate row's flag.
56
+ *
57
+ * POST-PROMOTION CATALOG (prompt-tool patch): after promotion both modes
58
+ * keep the assembled catalog. `usePtcMode` switches the wire presentation
59
+ * to Code Mode (PTC, single run_code) instead of narrowing the resident set.
60
+ * The controlled phase below still narrows the catalog before promotion and
61
+ * after compaction.
62
+ * COMPACTION (local addition): a compaction rewrites the whole surface, so the
63
+ * first post-compaction request is a "second first request". Promotion is
64
+ * epoch-aware (see compaction-epoch.mjs): after `compaction/end` the session
65
+ * falls back to the controlled phase — the bootstrap pair plus
66
+ * `compactionTools` (a core work set, default none) — until a NEW durable
67
+ * promotion signal exists past that boundary. The model is mid-task and needs
68
+ * to keep working, but still faces a small catalog instead of the full
69
+ * Standard set.
70
+ *
71
+ * Robustness:
72
+ * - Promotion decisions are memoized per session id for this process; the
73
+ * durable event scan runs once per session per process, then O(1).
74
+ * - Subagents (delegationDepth > 0) are always promoted (assembled catalog)
75
+ * unless `includeSubagents: true`.
76
+ * - A missing bootstrap tool degrades to the full catalog with a one-time
77
+ * warning instead of throwing, so a composition drift can never brick
78
+ * every request of a session.
79
+ * - Invalid config (bad tool lists, unknown `promoteOn`, malformed flags,
80
+ * non-positive `bootstrapMaxTokens`) fails at apply time, i.e. at preset
81
+ * mount, where it is visible and fixable.
82
+ */
83
+
84
+ import { createEpochPromotion } from './compaction-epoch.mjs'
85
+ import { booleanOption, createWarnOnce, parsePromoteOn, validateConfig } from './shared.mjs'
86
+
87
+ /** Cordis plugin name used by loader diagnostics. */
88
+ export const name = 'anchored-tool-bootstrap'
89
+
90
+ /**
91
+ * Deliberately NO inject list: the listeners only touch services at event
92
+ * time. Keep this row right AFTER the context-gate row in agent.cordis.yml:
93
+ * waterfall after-next transforms apply in reverse registration order, so the
94
+ * tool filter here must register before any plugin that touches the same
95
+ * assembly. The optional budget listener registers with `prepend: true` so a
96
+ * later listener can never override the first-round cap after we set it.
97
+ */
98
+ export const inject = []
99
+
100
+ /** Every config key this plugin accepts — anything else is a typo. */
101
+ const ALLOWED_KEYS = new Set(['bootstrapTools', 'promoteOn', 'bootstrapMaxTokens', 'compactionTools', 'includeSubagents', 'usePtcMode'])
102
+
103
+
104
+ /**
105
+ * The default first-request catalog: the OFFICIAL Minimal preset's exact tool
106
+ * pair — the persistent `bash` shell and `str_replace_editor`. Issue #11
107
+ * measured this schema anchoring 5/5 at the adapter-default maxTokens while
108
+ * every standard-family schema failed 11/11.
109
+ */
110
+ const DEFAULT_BOOTSTRAP_TOOLS = ['bash', 'str_replace_editor']
111
+
112
+ /** Non-empty string list config validator. */
113
+ function stringList(value, field) {
114
+ if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string' || item.length === 0)) {
115
+ throw new TypeError(`${name}: ${field} must be a non-empty array of non-empty strings`)
116
+ }
117
+ return [...new Set(value)]
118
+ }
119
+
120
+ function stringListOrEmpty(value, field) {
121
+ if (value === undefined) return []
122
+ return stringList(value, field)
123
+ }
124
+
125
+
126
+ /**
127
+ * Validate the optional first-request output cap. `undefined` means NO cap:
128
+ * the Minimal tool schema anchors at the adapter-default maxTokens, and the
129
+ * cap's delivery is profile-package dependent (see the header note), so it is
130
+ * opt-in rather than the default.
131
+ */
132
+ function optionalPositiveInt(value, field) {
133
+ if (value === undefined) return undefined
134
+ if (!Number.isSafeInteger(value) || value <= 0) {
135
+ throw new TypeError(`${name}: ${field} must be a positive safe integer`)
136
+ }
137
+ return value
138
+ }
139
+
140
+ /** Register the per-session bootstrap filters. */
141
+ export function apply(ctx, config) {
142
+ const source = validateConfig(name, config, ALLOWED_KEYS)
143
+ const bootstrapTools = stringList(source.bootstrapTools, 'bootstrapTools')
144
+ const promoteEvents = parsePromoteOn(name, source.promoteOn)
145
+ const bootstrapMaxTokens = optionalPositiveInt(source.bootstrapMaxTokens, 'bootstrapMaxTokens')
146
+ const includeSubagents = booleanOption(name, source.includeSubagents, 'includeSubagents', false)
147
+ const usePtcMode = booleanOption(name, source.usePtcMode, 'usePtcMode', true)
148
+ // Core work set exposed after a compaction, before re-promotion. Empty
149
+ // means "no compaction recovery catalog": the session stays on the
150
+ // bootstrap pair until a new promotion signal.
151
+ const compactionTools = stringListOrEmpty(source.compactionTools, 'compactionTools')
152
+
153
+ const promotion = createEpochPromotion(promoteEvents, { includeSubagents })
154
+
155
+ // prompt-tool patch: optional Code Mode (PTC) wire presentation after promotion.
156
+ const presentationBySession = new WeakMap()
157
+ const agentBySession = new WeakMap()
158
+ const presentationState = (session) => {
159
+ let state = presentationBySession.get(session)
160
+ if (state === undefined) {
161
+ state = { applied: false, disposer: undefined }
162
+ presentationBySession.set(session, state)
163
+ }
164
+ return state
165
+ }
166
+ const applyCodePresentation = (agent) => {
167
+ const session = agent?.session
168
+ if (session === undefined) return
169
+ const state = presentationState(session)
170
+ if (state.applied) return
171
+ const tools = agent.ctx?.tools
172
+ if (tools === undefined || typeof tools.presentAs !== 'function') return
173
+ state.disposer = tools.presentAs('code')
174
+ state.applied = true
175
+ }
176
+ const releaseCodePresentation = (session) => {
177
+ const state = presentationBySession.get(session)
178
+ if (state === undefined) return
179
+ if (typeof state.disposer === 'function') {
180
+ try { state.disposer() } catch { /* never brick the session */ }
181
+ }
182
+ state.disposer = undefined
183
+ state.applied = false
184
+ }
185
+ ctx.on('session/event', (session, event) => promotion.observe(session, event))
186
+
187
+ ctx.on('session/event', (session, event) => {
188
+ if (!usePtcMode) return
189
+ if (event.type === 'compaction/end') {
190
+ releaseCodePresentation(session)
191
+ return
192
+ }
193
+ if (event.type !== 'step/end' && event.type !== 'turn/end') return
194
+ const agent = agentBySession.get(session)
195
+ if (agent !== undefined && promotion.status(agent).promoted) applyCodePresentation(agent)
196
+ })
197
+
198
+ const warnOnce = createWarnOnce(ctx, name)
199
+
200
+ /** Narrow the assembled catalog to a keep-set; validate required names. */
201
+ const keepTools = (assembled, keep, missingAllowsFullCatalog) => {
202
+ const available = new Set(assembled.tools.map((tool) => tool.name))
203
+ const missing = [...keep].filter((toolName) => !available.has(toolName))
204
+ if (missing.length > 0) {
205
+ warnOnce(
206
+ `${name}: expected every phase tool; missing=${JSON.stringify(missing)} — `
207
+ + (missingAllowsFullCatalog ? 'bootstrap disabled, full catalog exposed' : 'continuing with what is available'),
208
+ )
209
+ if (missingAllowsFullCatalog) return assembled
210
+ }
211
+ return {
212
+ ...assembled,
213
+ tools: assembled.tools.filter((tool) => keep.has(tool.name)),
214
+ }
215
+ }
216
+
217
+ ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
218
+ // Downstream errors propagate untouched; only this filter's own logic is guarded.
219
+ const assembled = await next()
220
+ try {
221
+ const agent = context.agent
222
+ if (agent === undefined) return assembled
223
+ // prompt-tool patch: subagents skip catalog narrowing and use assembled tools directly.
224
+ // Callers (such as dsh-mnemon) already filter assembled.tools through their own whitelists.
225
+ // New plugin tools with any prefix therefore appear in the subagent first session automatically.
226
+ agentBySession.set(agent.session, agent)
227
+ if ((agent.session?.header?.delegationDepth ?? 0) > 0) {
228
+ if (usePtcMode) applyCodePresentation(agent)
229
+ return assembled
230
+ }
231
+ const status = promotion.status(agent)
232
+ if (status.promoted) {
233
+ // prompt-tool patch: both modes keep the assembled catalog after promotion.
234
+ // usePtcMode switches the wire presentation instead of narrowing resident tools.
235
+ if (usePtcMode) applyCodePresentation(agent)
236
+ return assembled
237
+ }
238
+ // Controlled phase: the bootstrap pair; after a compaction, plus the
239
+ // compaction work set so mid-task work can continue. Context control is
240
+ // NOT here: the companion `context-gate` plugin owns it (see the header
241
+ // note), so this filter touches only the tool catalog.
242
+ const { boundary } = status
243
+ const keep = new Set(bootstrapTools)
244
+ if (boundary >= 0) for (const toolName of compactionTools) keep.add(toolName)
245
+ return keepTools(assembled, keep, true)
246
+ } catch (error) {
247
+ // A filter bug must never brick a session: degrade to the full catalog.
248
+ warnOnce(`${name}: bootstrap filter failed, exposing the full catalog: ${String((error && error.message) || error)}`)
249
+ return assembled
250
+ }
251
+ })
252
+
253
+ // Optionally cap the first model request's output budget while bootstrapping.
254
+ // Unset (`bootstrapMaxTokens` omitted) means the adapter default flows — the
255
+ // Minimal tool schema anchors at 256000 without a cap (issue #11).
256
+ if (bootstrapMaxTokens !== undefined) {
257
+ // Same registration discipline as the pre-step strip below: `prepend`
258
+ // keeps this listener the OUTERMOST transform of the agent/request
259
+ // waterfall for the same registration-order reasons (loader row
260
+ // application is concurrent; row order alone does not decide listener
261
+ // order — see issue #6 and upstream PR #13), so a later listener can
262
+ // never override the first-round budget after we set it.
263
+ ctx.on('agent/request', async (payload, next) => {
264
+ const resolved = await next()
265
+ const agent = payload.agent
266
+ if (promotion.status(agent).promoted) {
267
+ // The next request's seed proposal carries the previous header's
268
+ // maxTokens forward, so the injected cap must be stripped explicitly —
269
+ // otherwise it would persist for the whole session.
270
+ if (resolved.maxTokens === bootstrapMaxTokens) {
271
+ const { maxTokens: _bootstrap, ...rest } = resolved
272
+ return rest
273
+ }
274
+ return resolved
275
+ }
276
+ return {
277
+ ...resolved,
278
+ maxTokens: bootstrapMaxTokens,
279
+ }
280
+ }, { prepend: true })
281
+ }
282
+ }
@@ -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