dsh-plugin-prompt-tool 0.4.2 → 0.6.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.
Files changed (191) hide show
  1. package/README.md +131 -222
  2. package/engine/anchor-match.mjs +117 -0
  3. package/engine/compaction-epoch.mjs +139 -0
  4. package/engine/compositions/library/bootstrap-filesystem.yml +19 -0
  5. package/engine/compositions/library/compaction.yml +52 -0
  6. package/engine/compositions/library/context-gate.yml +40 -0
  7. package/engine/compositions/library/custom-bash.yml +14 -0
  8. package/engine/compositions/library/delegation.yml +85 -0
  9. package/engine/compositions/library/official-agent-instructions.yml +17 -0
  10. package/engine/compositions/library/official-persistent-shell.yml +56 -0
  11. package/engine/compositions/library/official-skill-filesystem-cordis.yml +10 -0
  12. package/engine/compositions/library/official-tool-bash.yml +6 -0
  13. package/engine/compositions/library/official-tool-cordis.yml +13 -0
  14. package/engine/compositions/library/official-tool-presentation.yml +7 -0
  15. package/engine/compositions/library/official-tool-skill.yml +14 -0
  16. package/engine/compositions/library/persistent-shell.yml +36 -0
  17. package/engine/compositions/library/persona.yml +9 -0
  18. package/engine/compositions/library/planning.yml +41 -0
  19. package/engine/compositions/library/prompt-config-engine.yml +16 -0
  20. package/engine/compositions/library/run-code-env.yml +15 -0
  21. package/engine/compositions/library/skill-filesystem.yml +13 -0
  22. package/engine/compositions/library/skill-search.yml +14 -0
  23. package/engine/compositions/library/str-replace-editor.yml +10 -0
  24. package/engine/compositions/library/tool-ask-user.yml +8 -0
  25. package/engine/compositions/library/tool-bash.yml +16 -0
  26. package/engine/compositions/library/tool-bootstrap.yml +16 -0
  27. package/engine/compositions/library/tool-filter.yml +12 -0
  28. package/engine/compositions/library/tool-fs-search.yml +18 -0
  29. package/engine/compositions/library/tool-fs.yml +10 -0
  30. package/engine/compositions/library/tool-goal.yml +19 -0
  31. package/engine/compositions/library/tool-jobs.yml +23 -0
  32. package/engine/compositions/library/tool-pwsh.yml +12 -0
  33. package/engine/compositions/library/tool-todo.yml +11 -0
  34. package/engine/compositions/library/tool-web.yml +9 -0
  35. package/engine/compositions/source/local/context-gate.yml +37 -0
  36. package/engine/compositions/source/local/custom-bash.yml +11 -0
  37. package/engine/compositions/source/local/prompt-config-engine.yml +13 -0
  38. package/engine/compositions/source/local/run-code-env.yml +12 -0
  39. package/engine/compositions/source/local/skill-search.yml +11 -0
  40. package/engine/compositions/source/local/tool-bootstrap.yml +13 -0
  41. package/engine/context-gate.mjs +305 -0
  42. package/{preset → engine}/custom-bash.mjs +243 -243
  43. package/engine/executor.mjs +278 -0
  44. package/engine/fillers.mjs +273 -0
  45. package/engine/interpolate.mjs +66 -0
  46. package/engine/layers.mjs +224 -0
  47. package/engine/prompt-config-engine.mjs +49 -0
  48. package/engine/run-code-env.mjs +208 -0
  49. package/engine/schema.mjs +312 -0
  50. package/engine/session-vars.mjs +52 -0
  51. package/{preset → engine}/shared.mjs +35 -0
  52. package/engine/strategies.mjs +213 -0
  53. package/{preset → engine}/tool-bootstrap.mjs +350 -282
  54. package/engine/tool-filter.mjs +80 -0
  55. package/engine/vendor/yaml/LICENSE +13 -0
  56. package/engine/vendor/yaml/dist/compose/compose-collection.js +88 -0
  57. package/engine/vendor/yaml/dist/compose/compose-doc.js +43 -0
  58. package/engine/vendor/yaml/dist/compose/compose-node.js +109 -0
  59. package/engine/vendor/yaml/dist/compose/compose-scalar.js +86 -0
  60. package/engine/vendor/yaml/dist/compose/composer.js +219 -0
  61. package/engine/vendor/yaml/dist/compose/resolve-block-map.js +115 -0
  62. package/engine/vendor/yaml/dist/compose/resolve-block-scalar.js +198 -0
  63. package/engine/vendor/yaml/dist/compose/resolve-block-seq.js +49 -0
  64. package/engine/vendor/yaml/dist/compose/resolve-end.js +37 -0
  65. package/engine/vendor/yaml/dist/compose/resolve-flow-collection.js +207 -0
  66. package/engine/vendor/yaml/dist/compose/resolve-flow-scalar.js +225 -0
  67. package/engine/vendor/yaml/dist/compose/resolve-props.js +146 -0
  68. package/engine/vendor/yaml/dist/compose/util-contains-newline.js +34 -0
  69. package/engine/vendor/yaml/dist/compose/util-empty-scalar-position.js +26 -0
  70. package/engine/vendor/yaml/dist/compose/util-flow-indent-check.js +15 -0
  71. package/engine/vendor/yaml/dist/compose/util-map-includes.js +13 -0
  72. package/engine/vendor/yaml/dist/doc/Document.js +335 -0
  73. package/engine/vendor/yaml/dist/doc/anchors.js +71 -0
  74. package/engine/vendor/yaml/dist/doc/applyReviver.js +55 -0
  75. package/engine/vendor/yaml/dist/doc/createNode.js +88 -0
  76. package/engine/vendor/yaml/dist/doc/directives.js +176 -0
  77. package/engine/vendor/yaml/dist/errors.js +57 -0
  78. package/engine/vendor/yaml/dist/index.js +17 -0
  79. package/engine/vendor/yaml/dist/log.js +11 -0
  80. package/engine/vendor/yaml/dist/nodes/Alias.js +116 -0
  81. package/engine/vendor/yaml/dist/nodes/Collection.js +147 -0
  82. package/engine/vendor/yaml/dist/nodes/Node.js +38 -0
  83. package/engine/vendor/yaml/dist/nodes/Pair.js +36 -0
  84. package/engine/vendor/yaml/dist/nodes/Scalar.js +24 -0
  85. package/engine/vendor/yaml/dist/nodes/YAMLMap.js +144 -0
  86. package/engine/vendor/yaml/dist/nodes/YAMLSeq.js +113 -0
  87. package/engine/vendor/yaml/dist/nodes/addPairToJSMap.js +63 -0
  88. package/engine/vendor/yaml/dist/nodes/identity.js +36 -0
  89. package/engine/vendor/yaml/dist/nodes/toJS.js +37 -0
  90. package/engine/vendor/yaml/dist/parse/cst-scalar.js +214 -0
  91. package/engine/vendor/yaml/dist/parse/cst-stringify.js +61 -0
  92. package/engine/vendor/yaml/dist/parse/cst-visit.js +97 -0
  93. package/engine/vendor/yaml/dist/parse/cst.js +98 -0
  94. package/engine/vendor/yaml/dist/parse/lexer.js +721 -0
  95. package/engine/vendor/yaml/dist/parse/line-counter.js +39 -0
  96. package/engine/vendor/yaml/dist/parse/parser.js +975 -0
  97. package/engine/vendor/yaml/dist/public-api.js +102 -0
  98. package/engine/vendor/yaml/dist/schema/Schema.js +37 -0
  99. package/engine/vendor/yaml/dist/schema/common/map.js +17 -0
  100. package/engine/vendor/yaml/dist/schema/common/null.js +15 -0
  101. package/engine/vendor/yaml/dist/schema/common/seq.js +17 -0
  102. package/engine/vendor/yaml/dist/schema/common/string.js +14 -0
  103. package/engine/vendor/yaml/dist/schema/core/bool.js +19 -0
  104. package/engine/vendor/yaml/dist/schema/core/float.js +43 -0
  105. package/engine/vendor/yaml/dist/schema/core/int.js +38 -0
  106. package/engine/vendor/yaml/dist/schema/core/schema.js +23 -0
  107. package/engine/vendor/yaml/dist/schema/json/schema.js +62 -0
  108. package/engine/vendor/yaml/dist/schema/tags.js +96 -0
  109. package/engine/vendor/yaml/dist/schema/yaml-1.1/binary.js +58 -0
  110. package/engine/vendor/yaml/dist/schema/yaml-1.1/bool.js +26 -0
  111. package/engine/vendor/yaml/dist/schema/yaml-1.1/float.js +46 -0
  112. package/engine/vendor/yaml/dist/schema/yaml-1.1/int.js +71 -0
  113. package/engine/vendor/yaml/dist/schema/yaml-1.1/merge.js +67 -0
  114. package/engine/vendor/yaml/dist/schema/yaml-1.1/omap.js +74 -0
  115. package/engine/vendor/yaml/dist/schema/yaml-1.1/pairs.js +78 -0
  116. package/engine/vendor/yaml/dist/schema/yaml-1.1/schema.js +39 -0
  117. package/engine/vendor/yaml/dist/schema/yaml-1.1/set.js +93 -0
  118. package/engine/vendor/yaml/dist/schema/yaml-1.1/timestamp.js +101 -0
  119. package/engine/vendor/yaml/dist/stringify/foldFlowLines.js +146 -0
  120. package/engine/vendor/yaml/dist/stringify/stringify.js +129 -0
  121. package/engine/vendor/yaml/dist/stringify/stringifyCollection.js +153 -0
  122. package/engine/vendor/yaml/dist/stringify/stringifyComment.js +20 -0
  123. package/engine/vendor/yaml/dist/stringify/stringifyDocument.js +85 -0
  124. package/engine/vendor/yaml/dist/stringify/stringifyNumber.js +25 -0
  125. package/engine/vendor/yaml/dist/stringify/stringifyPair.js +150 -0
  126. package/engine/vendor/yaml/dist/stringify/stringifyString.js +336 -0
  127. package/engine/vendor/yaml/dist/util.js +11 -0
  128. package/engine/vendor/yaml/dist/visit.js +233 -0
  129. package/engine/vendor/yaml/index.js +5 -0
  130. package/engine/vendor/yaml/package.json +11 -0
  131. package/lib/client.js +4724 -775
  132. package/lib/client.js.map +1 -1
  133. package/lib/index.d.mts +530 -51
  134. package/lib/index.mjs +4164 -740
  135. package/lib/preset-core.d.mts +7 -37
  136. package/lib/preset-core.mjs +43 -285
  137. package/lib/prompt-configs-B4vH09wx.d.mts +100 -0
  138. package/lib/prompt-configs-ThS4iXPg.mjs +771 -0
  139. package/package.json +36 -29
  140. package/preset/anchored/preset.yml +342 -0
  141. package/preset/creative/preset.yml +65 -0
  142. package/preset/creative/skills/cordis-plugin-development/SKILL.md +420 -0
  143. package/preset/creative/skills/editing-cordis-compositions/SKILL.md +165 -0
  144. package/preset/custom/preset.yml +13 -0
  145. package/preset/liangshen/preset.yml +72 -0
  146. package/preset/minimal/preset.yml +44 -0
  147. package/preset/ptc/preset.yml +56 -0
  148. package/preset/standard/preset.yml +55 -0
  149. package/skills/manifest.json +7 -0
  150. package/skills/sandboxmod/SKILL.md +49 -49
  151. package/skills/web ui/SKILL.md +42 -0
  152. package/templates/10-pre-step.yml +28 -0
  153. package/templates/11-merged-a.yml +11 -0
  154. package/templates/13-anchor.yml +19 -0
  155. package/templates/14-first-turn-anchor.yml +27 -0
  156. package/templates/15-guide-auto.yml +25 -0
  157. package/templates/16-custom-fallback.yml +21 -0
  158. package/templates/17-instruction-hint.yml +23 -0
  159. package/templates/18-placeholder-env-facts.yml +11 -0
  160. package/templates/19-placeholder-skill-catalog.yml +19 -0
  161. package/templates/20-system-section.yml +19 -0
  162. package/templates/30-runtime-context.yml +10 -0
  163. package/templates/31-runtime-context-placeholder.yml +18 -0
  164. package/templates/40-agent-request.yml +11 -0
  165. package/templates/50-llm-stream.yml +9 -0
  166. package/templates/60-tool-pipeline.yml +12 -0
  167. package/AGENTS.md +0 -4
  168. package/plan.md +0 -312
  169. package/preset/agent.cordis.yml +0 -443
  170. package/preset/compaction-epoch.mjs +0 -81
  171. package/preset/context-gate.mjs +0 -165
  172. package/preset/instruction-hint.mjs +0 -217
  173. package/preset/near-anchor.mjs +0 -101
  174. package/preset/preset.yml +0 -3
  175. package/preset/prompt-injector.mjs +0 -112
  176. package/preset/router-first-turn.mjs +0 -73
  177. package/preset/router-guide.mjs +0 -79
  178. package/preset.md +0 -115
  179. package/upstream/dsh-anchored-standard/LICENSE +0 -22
  180. package/upstream/dsh-anchored-standard/NOTICE +0 -19
  181. package/upstream/dsh-anchored-standard/REVISION +0 -1
  182. package/upstream/dsh-anchored-standard/preset/agent.cordis.yml +0 -440
  183. package/upstream/dsh-anchored-standard/preset/compaction-epoch.mjs +0 -81
  184. package/upstream/dsh-anchored-standard/preset/context-gate.mjs +0 -202
  185. package/upstream/dsh-anchored-standard/preset/custom-bash.mjs +0 -219
  186. package/upstream/dsh-anchored-standard/preset/dev-tool-search.mjs +0 -131
  187. package/upstream/dsh-anchored-standard/preset/instruction-hint.mjs +0 -231
  188. package/upstream/dsh-anchored-standard/preset/preset.yml +0 -3
  189. package/upstream/dsh-anchored-standard/preset/skill-search.mjs +0 -142
  190. package/upstream/dsh-anchored-standard/preset/tool-bootstrap.mjs +0 -301
  191. /package/{preset → engine}/skill-search.mjs +0 -0
@@ -1,243 +1,243 @@
1
- /**
2
- * custom-bash — a Windows-capable `bash` tool that registers under the SAME
3
- * name (`bash`) as the official persistent bash, with a Minimal-compatible
4
- * description, but executes through `ctx.subprocess.spawn` instead of a PTY.
5
- *
6
- * WHY: DeepSeek's first-request trajectory anchor keys on the tool SCHEMA
7
- * matching the RL training distribution (issue #11: persistent
8
- * bash + str_replace_editor anchored 5/5 at maxTokens=256000, pwsh/read
9
- * 8/8 standard-like). The official persistent bash uses a PTY, and DSH's PTY
10
- * backend is linux/darwin-only — `subprocess-local` throws "terminal
11
- * inspection is unsupported on platform win32". A custom tool that presents
12
- * the same name and a Minimal-like description but spawns Git Bash through
13
- * the ordinary (cross-platform) subprocess seam keeps the schema anchor
14
- * without the PTY dependency.
15
- *
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).
36
- *
37
- * Semantics mirror the official bash tool: `bash -c <command>` in a fresh
38
- * process, bounded output, non-zero exit reported not thrown. No sandbox
39
- * confinement on Windows (the sandbox backend is linux-only); the tool
40
- * description says so. The bootstrap catalog pairs this with
41
- * `str_replace_editor` (Minimal's two tools).
42
- */
43
-
44
- import { access } from 'node:fs/promises'
45
- import { dirname, join } from 'node:path'
46
-
47
- /** Cordis plugin name used by loader diagnostics. */
48
- export const name = 'custom-bash'
49
-
50
- /** The subprocess and tools services must exist before this tool can register. */
51
- export const inject = ['subprocess', 'tools']
52
-
53
- const DEFAULT_TIMEOUT_MS = 120000
54
- const DEFAULT_MAX_OUTPUT_BYTES = 64000
55
-
56
- /**
57
- * Git Bash candidate paths, in probe order (see the header): the `git`
58
- * executable's install root first, then the well-known env-derived roots.
59
- * Exported for tests; pure — existence probing happens at the call site.
60
- */
61
- export function bashCandidates(env, gitExe) {
62
- const candidates = []
63
- // git at <root>\cmd\git.exe (installer/scoop) or <root>\bin\git.exe →
64
- // <root>\bin\bash.exe; <root>\mingw64\bin\git.exe (portable) → two up.
65
- // A bare relative name means `git` did not actually resolve to a path.
66
- if (typeof gitExe === 'string' && /[/\\]/.test(gitExe)) {
67
- const dir = dirname(gitExe)
68
- const root = dirname(dir)
69
- candidates.push(
70
- join(root, 'bin', 'bash.exe'),
71
- join(dir, 'bash.exe'),
72
- join(dirname(root), 'bin', 'bash.exe'),
73
- )
74
- }
75
- if (env.ProgramFiles) candidates.push(join(env.ProgramFiles, 'Git', 'bin', 'bash.exe'))
76
- if (env['ProgramFiles(x86)']) candidates.push(join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'))
77
- if (env.LOCALAPPDATA) candidates.push(join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'))
78
- if (env.USERPROFILE) candidates.push(join(env.USERPROFILE, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'))
79
- // Layouts overlap (a `bin` git.exe derives the same bash twice) — probe
80
- // order survives the dedupe, insertion order is preserved.
81
- return [...new Set(candidates)]
82
- }
83
-
84
- /** Tool parameter schema for the model-facing command. */
85
- const commandSchema = {
86
- type: 'object',
87
- properties: {
88
- command: {
89
- type: 'string',
90
- description: 'The bash command to execute (`bash -c` string domain).',
91
- },
92
- workdir: {
93
- type: 'string',
94
- description: 'Optional working directory; defaults to the session cwd.',
95
- },
96
- },
97
- required: ['command'],
98
- additionalProperties: false,
99
- }
100
-
101
- /** Register the model-facing `bash` tool. */
102
- export function apply(ctx, config) {
103
- const explicitBashPath = typeof config?.bashPath === 'string' && config.bashPath.length > 0 ? config.bashPath : undefined
104
- const timeoutMs = Number.isSafeInteger(config?.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS
105
- const maxOutputBytes = Number.isSafeInteger(config?.maxOutputBytes) && config.maxOutputBytes > 0 ? config.maxOutputBytes : DEFAULT_MAX_OUTPUT_BYTES
106
-
107
- // The inferred executable is memoized per plugin instance: candidate probing
108
- // walks the filesystem, and the answer cannot change within a mount. A
109
- // failed inference is NOT memoized — the plain `bash` fallback resolves
110
- // fresh on every execute until some probe succeeds.
111
- let inferredShell
112
- const exists = (path) => access(path).then(() => true, () => false)
113
- const resolveShell = async (signal) => {
114
- if (explicitBashPath !== undefined) {
115
- // A misconfigured explicit path must fail as itself, not as a
116
- // discovery miss — the raw resolution error says which path failed.
117
- return ctx.subprocess.resolveExecutable(explicitBashPath, undefined, signal)
118
- }
119
- if (inferredShell !== undefined) {
120
- return ctx.subprocess.resolveExecutable(inferredShell, undefined, signal)
121
- }
122
- let gitExe
123
- try {
124
- gitExe = await ctx.subprocess.resolveExecutable('git', undefined, signal)
125
- } catch {
126
- // git unresolvable → the env-derived candidates below still apply
127
- }
128
- for (const candidate of bashCandidates(process.env, gitExe)) {
129
- if (!(await exists(candidate))) continue
130
- try {
131
- inferredShell = await ctx.subprocess.resolveExecutable(candidate, undefined, signal)
132
- return inferredShell
133
- } catch {
134
- // Exists but unresolvable (EPERM, a broken scoop junction): keep
135
- // probing — one bad root must not block the rest of the chain, and
136
- // nothing is memoized so later executes can still find a good one.
137
- continue
138
- }
139
- }
140
- try {
141
- return await ctx.subprocess.resolveExecutable('bash', undefined, signal)
142
- } catch (error) {
143
- // Total discovery failure (no Git Bash root, no env root, no bash on
144
- // PATH): name the remedies instead of leaking a raw ENOENT. Never
145
- // fall back to pwsh/cmd here — the schema promises `bash -c`
146
- // semantics; a different shell would silently break every command.
147
- 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)})`)
148
- }
149
- }
150
-
151
- ctx.tools.register({
152
- name: 'bash',
153
- description: [
154
- 'Run commands in a bash shell (Git Bash on Windows)',
155
- '* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.',
156
- "* You don't have access to the internet via this tool.",
157
- '* You do have access to a mirror of common linux and python packages via apt and pip.',
158
- '* State does NOT persist across command calls: each call runs in a fresh shell.',
159
- "* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.",
160
- '* Please avoid commands that may produce a very large amount of output.',
161
- '* NOTE: runs without OS sandbox confinement on Windows (no landlock); treat output as untrusted.',
162
- ].join('\n'),
163
- parameters: commandSchema,
164
- output: {
165
- schema: {
166
- type: 'object',
167
- additionalProperties: false,
168
- properties: {
169
- text: { type: 'string' },
170
- },
171
- required: ['text'],
172
- },
173
- render: (_args, value) => [{ type: 'text', text: value.text }],
174
- },
175
- async execute(args, exec) {
176
- const shell = await resolveShell(exec?.signal)
177
- const workdir = typeof args.workdir === 'string' && args.workdir.length > 0
178
- ? args.workdir
179
- : exec?.agent?.session?.header?.cwd
180
-
181
- // timeoutMs is a foreground deadline. subprocess only reacts to an abort
182
- // signal, so we own the classification here: a timeout aborts the tree
183
- // (SIGTERM -> grace -> SIGKILL), then the error below says WHY.
184
- const abort = new AbortController()
185
- let timedOut = false
186
- const timer = setTimeout(() => {
187
- timedOut = true
188
- abort.abort(new Error(`bash timed out after ${timeoutMs}ms`))
189
- }, timeoutMs)
190
- const onExecAbort = () => abort.abort(exec?.signal?.reason)
191
- if (exec?.signal?.aborted) onExecAbort()
192
- else exec?.signal?.addEventListener('abort', onExecAbort, { once: true })
193
-
194
- let outcome
195
- try {
196
- const handle = ctx.subprocess.spawn({
197
- argv: [shell, '-c', args.command],
198
- ...workdir !== undefined ? { cwd: workdir } : {},
199
- stdio: {
200
- stdin: 'ignore',
201
- stdout: { maxBytes: maxOutputBytes },
202
- stderr: { maxBytes: maxOutputBytes },
203
- },
204
- signal: abort.signal,
205
- graceMs: 3000,
206
- })
207
- try {
208
- outcome = await handle.done
209
- } catch (error) {
210
- // A spawn-level failure (bad executable, EPERM) surfaces as a throw,
211
- // which the runtime turns into an isError result.
212
- throw new Error(`bash spawn failed: ${String(error)}`)
213
- }
214
- let stdout = ''
215
- let stderr = ''
216
- try {
217
- stdout = handle.collected.stdout.readFrom(0).text
218
- stderr = handle.collected.stderr.readFrom(0).text
219
- } catch {
220
- // Collected readers may be unavailable on some backends; tolerate.
221
- }
222
- const text = [stdout, stderr].filter((part) => part.length > 0).join('\n')
223
- const tail = text.length > 0 ? text : `exit code: ${outcome.exitCode} (no output)`
224
- if (timedOut) {
225
- throw new Error(`bash timed out after ${timeoutMs}ms${tail ? `\n${tail}` : ''}`)
226
- }
227
- if (exec?.signal?.aborted) {
228
- const reason = exec.signal.reason
229
- throw new Error(`bash aborted: ${reason instanceof Error ? reason.message : String(reason ?? 'aborted')}`)
230
- }
231
- if (outcome.exitCode !== 0) {
232
- // Non-zero exit is a reported failure, not a throw: the model sees the
233
- // command output plus the exit code.
234
- throw new Error(tail)
235
- }
236
- return { text: tail }
237
- } finally {
238
- clearTimeout(timer)
239
- exec?.signal?.removeEventListener('abort', onExecAbort)
240
- }
241
- },
242
- })
243
- }
1
+ /**
2
+ * custom-bash — a Windows-capable `bash` tool that registers under the SAME
3
+ * name (`bash`) as the official persistent bash, with a Minimal-compatible
4
+ * description, but executes through `ctx.subprocess.spawn` instead of a PTY.
5
+ *
6
+ * WHY: DeepSeek's first-request trajectory anchor keys on the tool SCHEMA
7
+ * matching the RL training distribution (issue #11: persistent
8
+ * bash + str_replace_editor anchored 5/5 at maxTokens=256000, pwsh/read
9
+ * 8/8 standard-like). The official persistent bash uses a PTY, and DSH's PTY
10
+ * backend is linux/darwin-only — `subprocess-local` throws "terminal
11
+ * inspection is unsupported on platform win32". A custom tool that presents
12
+ * the same name and a Minimal-like description but spawns Git Bash through
13
+ * the ordinary (cross-platform) subprocess seam keeps the schema anchor
14
+ * without the PTY dependency.
15
+ *
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).
36
+ *
37
+ * Semantics mirror the official bash tool: `bash -c <command>` in a fresh
38
+ * process, bounded output, non-zero exit reported not thrown. No sandbox
39
+ * confinement on Windows (the sandbox backend is linux-only); the tool
40
+ * description says so. The bootstrap catalog pairs this with
41
+ * `str_replace_editor` (Minimal's two tools).
42
+ */
43
+
44
+ import { access } from 'node:fs/promises'
45
+ import { dirname, join } from 'node:path'
46
+
47
+ /** Cordis plugin name used by loader diagnostics. */
48
+ export const name = 'custom-bash'
49
+
50
+ /** The subprocess and tools services must exist before this tool can register. */
51
+ export const inject = ['subprocess', 'tools']
52
+
53
+ const DEFAULT_TIMEOUT_MS = 120000
54
+ const DEFAULT_MAX_OUTPUT_BYTES = 64000
55
+
56
+ /**
57
+ * Git Bash candidate paths, in probe order (see the header): the `git`
58
+ * executable's install root first, then the well-known env-derived roots.
59
+ * Exported for tests; pure — existence probing happens at the call site.
60
+ */
61
+ export function bashCandidates(env, gitExe) {
62
+ const candidates = []
63
+ // git at <root>\cmd\git.exe (installer/scoop) or <root>\bin\git.exe →
64
+ // <root>\bin\bash.exe; <root>\mingw64\bin\git.exe (portable) → two up.
65
+ // A bare relative name means `git` did not actually resolve to a path.
66
+ if (typeof gitExe === 'string' && /[/\\]/.test(gitExe)) {
67
+ const dir = dirname(gitExe)
68
+ const root = dirname(dir)
69
+ candidates.push(
70
+ join(root, 'bin', 'bash.exe'),
71
+ join(dir, 'bash.exe'),
72
+ join(dirname(root), 'bin', 'bash.exe'),
73
+ )
74
+ }
75
+ if (env.ProgramFiles) candidates.push(join(env.ProgramFiles, 'Git', 'bin', 'bash.exe'))
76
+ if (env['ProgramFiles(x86)']) candidates.push(join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'))
77
+ if (env.LOCALAPPDATA) candidates.push(join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'))
78
+ if (env.USERPROFILE) candidates.push(join(env.USERPROFILE, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'))
79
+ // Layouts overlap (a `bin` git.exe derives the same bash twice) — probe
80
+ // order survives the dedupe, insertion order is preserved.
81
+ return [...new Set(candidates)]
82
+ }
83
+
84
+ /** Tool parameter schema for the model-facing command. */
85
+ const commandSchema = {
86
+ type: 'object',
87
+ properties: {
88
+ command: {
89
+ type: 'string',
90
+ description: 'The bash command to execute (`bash -c` string domain).',
91
+ },
92
+ workdir: {
93
+ type: 'string',
94
+ description: 'Optional working directory; defaults to the session cwd.',
95
+ },
96
+ },
97
+ required: ['command'],
98
+ additionalProperties: false,
99
+ }
100
+
101
+ /** Register the model-facing `bash` tool. */
102
+ export function apply(ctx, config) {
103
+ const explicitBashPath = typeof config?.bashPath === 'string' && config.bashPath.length > 0 ? config.bashPath : undefined
104
+ const timeoutMs = Number.isSafeInteger(config?.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS
105
+ const maxOutputBytes = Number.isSafeInteger(config?.maxOutputBytes) && config.maxOutputBytes > 0 ? config.maxOutputBytes : DEFAULT_MAX_OUTPUT_BYTES
106
+
107
+ // The inferred executable is memoized per plugin instance: candidate probing
108
+ // walks the filesystem, and the answer cannot change within a mount. A
109
+ // failed inference is NOT memoized — the plain `bash` fallback resolves
110
+ // fresh on every execute until some probe succeeds.
111
+ let inferredShell
112
+ const exists = (path) => access(path).then(() => true, () => false)
113
+ const resolveShell = async (signal) => {
114
+ if (explicitBashPath !== undefined) {
115
+ // A misconfigured explicit path must fail as itself, not as a
116
+ // discovery miss — the raw resolution error says which path failed.
117
+ return ctx.subprocess.resolveExecutable(explicitBashPath, undefined, signal)
118
+ }
119
+ if (inferredShell !== undefined) {
120
+ return ctx.subprocess.resolveExecutable(inferredShell, undefined, signal)
121
+ }
122
+ let gitExe
123
+ try {
124
+ gitExe = await ctx.subprocess.resolveExecutable('git', undefined, signal)
125
+ } catch {
126
+ // git unresolvable → the env-derived candidates below still apply
127
+ }
128
+ for (const candidate of bashCandidates(process.env, gitExe)) {
129
+ if (!(await exists(candidate))) continue
130
+ try {
131
+ inferredShell = await ctx.subprocess.resolveExecutable(candidate, undefined, signal)
132
+ return inferredShell
133
+ } catch {
134
+ // Exists but unresolvable (EPERM, a broken scoop junction): keep
135
+ // probing — one bad root must not block the rest of the chain, and
136
+ // nothing is memoized so later executes can still find a good one.
137
+ continue
138
+ }
139
+ }
140
+ try {
141
+ return await ctx.subprocess.resolveExecutable('bash', undefined, signal)
142
+ } catch (error) {
143
+ // Total discovery failure (no Git Bash root, no env root, no bash on
144
+ // PATH): name the remedies instead of leaking a raw ENOENT. Never
145
+ // fall back to pwsh/cmd here — the schema promises `bash -c`
146
+ // semantics; a different shell would silently break every command.
147
+ 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)})`)
148
+ }
149
+ }
150
+
151
+ ctx.tools.register({
152
+ name: 'bash',
153
+ description: [
154
+ 'Run commands in a bash shell (Git Bash on Windows)',
155
+ '* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.',
156
+ "* You don't have access to the internet via this tool.",
157
+ '* You do have access to a mirror of common linux and python packages via apt and pip.',
158
+ '* State does NOT persist across command calls: each call runs in a fresh shell.',
159
+ "* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.",
160
+ '* Please avoid commands that may produce a very large amount of output.',
161
+ '* NOTE: runs without OS sandbox confinement on Windows (no landlock); treat output as untrusted.',
162
+ ].join('\n'),
163
+ parameters: commandSchema,
164
+ output: {
165
+ schema: {
166
+ type: 'object',
167
+ additionalProperties: false,
168
+ properties: {
169
+ text: { type: 'string' },
170
+ },
171
+ required: ['text'],
172
+ },
173
+ render: (_args, value) => [{ type: 'text', text: value.text }],
174
+ },
175
+ async execute(args, exec) {
176
+ const shell = await resolveShell(exec?.signal)
177
+ const workdir = typeof args.workdir === 'string' && args.workdir.length > 0
178
+ ? args.workdir
179
+ : exec?.agent?.session?.header?.cwd
180
+
181
+ // timeoutMs is a foreground deadline. subprocess only reacts to an abort
182
+ // signal, so we own the classification here: a timeout aborts the tree
183
+ // (SIGTERM -> grace -> SIGKILL), then the error below says WHY.
184
+ const abort = new AbortController()
185
+ let timedOut = false
186
+ const timer = setTimeout(() => {
187
+ timedOut = true
188
+ abort.abort(new Error(`bash timed out after ${timeoutMs}ms`))
189
+ }, timeoutMs)
190
+ const onExecAbort = () => abort.abort(exec?.signal?.reason)
191
+ if (exec?.signal?.aborted) onExecAbort()
192
+ else exec?.signal?.addEventListener('abort', onExecAbort, { once: true })
193
+
194
+ let outcome
195
+ try {
196
+ const handle = ctx.subprocess.spawn({
197
+ argv: [shell, '-c', args.command],
198
+ ...workdir !== undefined ? { cwd: workdir } : {},
199
+ stdio: {
200
+ stdin: 'ignore',
201
+ stdout: { maxBytes: maxOutputBytes },
202
+ stderr: { maxBytes: maxOutputBytes },
203
+ },
204
+ signal: abort.signal,
205
+ graceMs: 3000,
206
+ })
207
+ try {
208
+ outcome = await handle.done
209
+ } catch (error) {
210
+ // A spawn-level failure (bad executable, EPERM) surfaces as a throw,
211
+ // which the runtime turns into an isError result.
212
+ throw new Error(`bash spawn failed: ${String(error)}`)
213
+ }
214
+ let stdout = ''
215
+ let stderr = ''
216
+ try {
217
+ stdout = handle.collected.stdout.readFrom(0).text
218
+ stderr = handle.collected.stderr.readFrom(0).text
219
+ } catch {
220
+ // Collected readers may be unavailable on some backends; tolerate.
221
+ }
222
+ const text = [stdout, stderr].filter((part) => part.length > 0).join('\n')
223
+ const tail = text.length > 0 ? text : `exit code: ${outcome.exitCode} (no output)`
224
+ if (timedOut) {
225
+ throw new Error(`bash timed out after ${timeoutMs}ms${tail ? `\n${tail}` : ''}`)
226
+ }
227
+ if (exec?.signal?.aborted) {
228
+ const reason = exec.signal.reason
229
+ throw new Error(`bash aborted: ${reason instanceof Error ? reason.message : String(reason ?? 'aborted')}`)
230
+ }
231
+ if (outcome.exitCode !== 0) {
232
+ // Non-zero exit is a reported failure, not a throw: the model sees the
233
+ // command output plus the exit code.
234
+ throw new Error(tail)
235
+ }
236
+ return { text: tail }
237
+ } finally {
238
+ clearTimeout(timer)
239
+ exec?.signal?.removeEventListener('abort', onExecAbort)
240
+ }
241
+ },
242
+ })
243
+ }