dsh-win-multi-bash 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.
@@ -8,16 +8,21 @@
8
8
  * @module @deepseek-ai/dsh-tool-bash/factory
9
9
  */
10
10
  import z from '@deepseek-ai/schemastery';
11
+ import { existsSync } from 'node:fs';
11
12
  import { isAbsolute, resolve as resolvePath } from 'node:path';
12
13
  import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools';
13
14
  import { HarnessError } from '@deepseek-ai/dsh-llm';
14
15
  import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox';
15
- import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell';
16
16
  import { ownExecutor } from "./backend.js";
17
17
  import { processJob, processOutcome, processSources } from "./background.js";
18
18
  import { parseExitStatus, renderResult } from "./render.js";
19
+ import { SHELL_FAMILY_ROW_SPECIFIER, composeToolDescription, familyRowComposed } from "./shell-family.js";
20
+ /**
21
+ * The dialect-owned facts of this plugin's two shell tools. Everything they
22
+ * share lives in one registered prompt section instead (see
23
+ * ./shell-family.js), so a fact stated here is stated only here.
24
+ */
19
25
  const DIALECT_FACTS = {
20
- posix: { shell: 'bash', invoke: 'bash -c', paths: 'POSIX paths', env: '$VAR', toolchain: 'the full Unix toolchain' },
21
26
  msys: {
22
27
  shell: 'Git Bash (MSYS2)',
23
28
  invoke: 'bash -c',
@@ -34,69 +39,26 @@ const DIALECT_FACTS = {
34
39
  };
35
40
  /** The tool-layer keys every shell tool instance carries, before its backend partition. */
36
41
  const TOOL_CONFIG = {
37
- enableRunInBackground: z.boolean().default(true),
42
+ enableRunInBackground: z.boolean().default(true).volatile(),
38
43
  };
39
44
  /**
40
- * The model-facing description of one shell tool instance. The POSIX variant
41
- * keeps the legacy wording byte-for-byte (the ACP/headless tool-schema
42
- * fixtures pin it); msys/wsl instances mirror the official tool-pwsh
43
- * skeleton (fresh shell, paths/env, exit codes, sandbox, truncation,
44
- * background, escalation) with only their dialect's shell/paths/env facts
45
- * plus at most one short dialect note.
45
+ * The model-facing description of one shell tool instance: only what this
46
+ * dialect owns — its shell and invocation, its path form, its variable syntax,
47
+ * and at most one short dialect note. Everything the family shares (fresh
48
+ * calls, exit markers, sandbox markers, truncation, path safety, background,
49
+ * escalation) is registered once for the composition by the
50
+ * `tool-shell-prompt` row, so no tool description repeats it.
46
51
  * @param dialect - the shell dialect the instance runs.
47
- * @param backgroundEnabled - whether `run_in_background` is advertised.
48
- * @param escalationModes - the escalation targets this composition advertises;
49
- * empty adds no same-turn escalation guidance.
50
52
  * @returns the tool description passed to `defineTool`.
51
53
  */
52
- export function shellDescription(dialect, backgroundEnabled, escalationModes) {
53
- const background = backgroundEnabled
54
- ? 'Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.'
55
- : 'Background execution is not available; long-running commands must finish within the timeout.';
56
- if (dialect === 'posix') {
57
- const base = 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
58
- + 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
59
- + 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. '
60
- + `Current harness environment facts are exposed through managed \`\$${DSH_ENV_PREFIX}*\` variables; inspect them when needed. `
61
- + 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. '
62
- + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
63
- + background;
64
- return base + escalationTail(escalationModes);
65
- }
54
+ export function shellDescription(dialect) {
66
55
  const facts = DIALECT_FACTS[dialect];
56
+ if (facts === undefined)
57
+ throw new Error(`unknown shell dialect "${dialect}"`);
67
58
  const note = facts.note === undefined ? '' : ` Note: ${facts.note}.`;
68
- const base = `Execute a ${facts.shell} command (${facts.invoke}) and return its stdout/stderr. `
69
- + 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
70
- + 'pass `workdir` instead of using `cd`. '
59
+ return `Execute a ${facts.shell} command (${facts.invoke}) and return its stdout/stderr. `
71
60
  + `Paths use ${facts.paths}; read environment variables with ${facts.env}.`
72
- + note + ' '
73
- + 'Non-zero exits are reported as `[exit code: N]`. '
74
- + `Current harness environment facts are exposed through managed \`\$${DSH_ENV_PREFIX}*\` variables; inspect them when needed. `
75
- + 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. '
76
- + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
77
- + background;
78
- return base + escalationTail(escalationModes);
79
- }
80
- /**
81
- * The same-turn escalation guidance appended after a denial marker. Kept in
82
- * one place because every dialect shares the exact approval contract.
83
- * @param escalationModes - the escalation targets this composition advertises.
84
- * @returns the guidance sentence, or '' when no escalation is advertised.
85
- */
86
- function escalationTail(escalationModes) {
87
- if (escalationModes.length === 0)
88
- return '';
89
- return ' Attempting a command the sandbox may deny is safe and expected: run it and read the '
90
- + 'marker rather than assuming the denial. When a command is denied and a wider mode would let it '
91
- + 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry '
92
- + 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) '
93
- + 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the '
94
- + 'approval prompt raised by that retry is how the user consents. If the session states approval '
95
- + 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. '
96
- + 'Never escalate speculatively: ground the request in a real denial — normally the one this command '
97
- + 'just hit; escalating up front is fine only when this session already denied the same access. '
98
- + 'A rejected escalation is final for that command — stop and explain, never work around '
99
- + 'it — but it does not forbid attempting or escalating other commands later.';
61
+ + note;
100
62
  }
101
63
  function validateShellArgs(args) {
102
64
  if (args.command.trim().length === 0) {
@@ -147,11 +109,38 @@ function presentShellResult(args, result) {
147
109
  const { body, ...exit } = parseExitStatus(raw);
148
110
  return { card: 'terminal', output: body, ...exit };
149
111
  }
112
+ /**
113
+ * Translate the bash-dialect drive forms a model may hand in as `workdir` into
114
+ * the native path the process spawn needs. `node:path` calls `/d/WorkSpace` and
115
+ * the WSL automount view `/mnt/d/WorkSpace` absolute, but Windows resolves them
116
+ * against the current drive (`G:\g\LAB\...`), so the spawn fails and reports
117
+ * ENOENT against the shell executable — `bash.exe` / `wsl.exe` — which reads as
118
+ * a missing shell rather than an unusable directory. Only a single-letter first
119
+ * segment (optionally under `/mnt/`) whose drive exists qualifies, so MSYS POSIX
120
+ * roots (`/etc`, `/usr`, `/tmp`, `/dev`, …) and distro-side paths (`/mnt/data`)
121
+ * are never drive-mapped.
122
+ * @param workdir - an absolute model-supplied workdir.
123
+ * @returns its native Windows form, or the input when it is not a drive form.
124
+ */
125
+ export function toNativeWorkdir(workdir) {
126
+ if (process.platform !== 'win32')
127
+ return workdir;
128
+ const match = /^(?:\/mnt)?\/([A-Za-z])(?:\/(.*))?$/.exec(workdir);
129
+ if (match === null)
130
+ return workdir;
131
+ const drive = match[1].toUpperCase();
132
+ if (!existsSync(`${drive}:\\`))
133
+ return workdir;
134
+ const rest = match[2];
135
+ return rest === undefined ? `${drive}:\\` : `${drive}:\\${rest.replace(/\//g, '\\')}`;
136
+ }
150
137
  /**
151
138
  * Resolve an explicit workdir first, making a relative one session-workspace-relative;
152
139
  * otherwise use the filesystem identity of the session cwd and leave executor
153
140
  * defaulting as the fallback. A resolved sandbox-policy root wins so workdir
154
- * and confinement use the exact same per-call identity.
141
+ * and confinement use the exact same per-call identity. An absolute dialect-form
142
+ * workdir is translated to its native form (see {@link toNativeWorkdir}) because
143
+ * the executor hands the value straight to `spawn` as the child's `cwd`.
155
144
  */
156
145
  function resolveWorkdir(modelWorkdir, exec, policyWorkspaceRoot) {
157
146
  const headerCwd = exec.agent?.session.header.cwd;
@@ -161,7 +150,7 @@ function resolveWorkdir(modelWorkdir, exec, policyWorkspaceRoot) {
161
150
  if (sessionCwd !== undefined && !isAbsolute(modelWorkdir)) {
162
151
  return resolvePath(sessionCwd, modelWorkdir);
163
152
  }
164
- return modelWorkdir;
153
+ return toNativeWorkdir(modelWorkdir);
165
154
  }
166
155
  /** Detach the executor DTO from readonly Service Definition types into plain JSON data. */
167
156
  function canonicalShellResult(result) {
@@ -215,12 +204,19 @@ export function defineShellTool(def) {
215
204
  });
216
205
  return {
217
206
  name: `tool-${def.toolName}`,
207
+ // The tool registers no prompt section of its own: the family's shared
208
+ // guidance is owned by the separate `tool-shell-prompt` row (see
209
+ // ./shell-family.js), so this row needs no `systemPrompt`.
218
210
  // The last three are the owned executor's own requirements; injecting
219
211
  // them here is what lets the backend be built during apply.
220
- inject: ['tools', 'systemPrompt', 'shellEnv', 'subprocess', 'sandbox', 'sandboxPolicy'],
212
+ inject: ['tools', 'shellEnv', 'subprocess', 'sandbox', 'sandboxPolicy'],
221
213
  Config,
222
214
  async apply(ctx, config = {}) {
223
- const backgroundEnabled = config.enableRunInBackground ?? true;
215
+ // Volatile: the loader hands a live reference, so a saved value is
216
+ // read here rather than off a snapshot.
217
+ const backgroundEnabled = config.enableRunInBackground?.get() ?? true;
218
+ const familyPresent = familyRowComposed(ctx);
219
+ if (!familyPresent) ctx.logger?.warn?.(`tool-${def.toolName}: the row that owns the shared shell prompt section (${SHELL_FAMILY_ROW_SPECIFIER}) is not composed, so this tool's description carries that guidance itself. Re-enable that row to state it once for the whole family.`);
224
220
  const { executor } = ownExecutor(ctx, def.Executor, config[def.configKey]);
225
221
  // The confinement probe is async on 0.1.7 (the provider's `confine`
226
222
  // returns a promise), so the escalation surface settles before the
@@ -270,15 +266,14 @@ export function defineShellTool(def) {
270
266
  signal: exec.signal,
271
267
  });
272
268
  };
273
- // Cross-call guidance belongs in the prompt rather than one-call schema prose.
274
- ctx.systemPrompt.section({
275
- name: `tool:${def.toolName}`,
276
- order: 105,
277
- text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.',
278
- });
279
269
  ctx.tools.register(defineTool({
280
270
  name: def.toolName,
281
- description: shellDescription(def.dialect, backgroundEnabled, escalationModes),
271
+ description: composeToolDescription({
272
+ own: shellDescription(def.dialect),
273
+ familyPresent,
274
+ background: backgroundEnabled,
275
+ escalation: escalationModes.length > 0,
276
+ }),
282
277
  parameters: {
283
278
  command: { type: 'string', required: true, description: 'The bash command to execute.' },
284
279
  description: {
@@ -289,7 +284,7 @@ export function defineShellTool(def) {
289
284
  + '"git status" → "Show working tree status"; "npm install" → "Install package dependencies".',
290
285
  },
291
286
  timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
292
- workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
287
+ workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. Native (`C:\\...`), MSYS drive (`/c/...`) and WSL automount (`/mnt/c/...`) forms are all accepted.' },
293
288
  ...backgroundEnabled ? {
294
289
  run_in_background: { type: 'boolean', description: 'Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies.' },
295
290
  } : {},
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The shared half of the shell guidance: what every shell tool call in this
3
+ * composition has in common, stated once for the whole family.
4
+ *
5
+ * Why the split exists (measured on 0.3.1: the two tool descriptions were 1299
6
+ * and 2164 characters, 1117 of them byte-identical, and the same exit-status
7
+ * paragraph was registered twice — once per tool instance):
8
+ * - A tool description is ambient only while that tool is mounted, so it can
9
+ * carry only what belongs to that tool. Anything the family shares belongs
10
+ * in one prompt section; anything dialect-specific (shell name, path form,
11
+ * MSYS note) stays with its own tool.
12
+ * - The section has exactly one owner — the `tool-shell-prompt` row — because
13
+ * prompt sections live in one global layer keyed by name: two rows
14
+ * registering the same name throw, and two names carrying the same text are
15
+ * the duplication this module exists to remove.
16
+ * - Its text is a function of the tools actually mounted
17
+ * (`ctx.tools.get(name, scope)`, read at every assembly), which is what
18
+ * keeps git_bash and wsl_bash independently switchable: any subset — either
19
+ * one, both, or neither — renders exactly one correct copy, and a
20
+ * composition with neither renders nothing at all.
21
+ * - dsh's own tool-pwsh description repeats some of this prose and is not ours
22
+ * to edit. This section stays self-sufficient on purpose: our tools must
23
+ * carry their full guidance whether or not pwsh is in the composition, so we
24
+ * never read facts out of another row's description.
25
+ * @module dsh-win-multi-bash/tool-bash/shell-family
26
+ */
27
+
28
+ /** The tool names this plugin contributes to the shell family. */
29
+ export const SHELL_FAMILY_TOOL_NAMES = ['git_bash', 'wsl_bash'];
30
+
31
+ /**
32
+ * The single prompt section carrying the family's shared guidance. Named for
33
+ * the plugin's tool family rather than for one tool, so neither git_bash nor
34
+ * wsl_bash owns it.
35
+ */
36
+ export const SHELL_FAMILY_SECTION_NAME = 'tool:win-mb-bash';
37
+
38
+ /** Fresh-shell fact plus the `workdir` rule that replaces a persistent `cd`. */
39
+ const SHELL_CALL_SECTION = 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`.';
40
+
41
+ /**
42
+ * Cross-call exit-status guidance. It belongs in the prompt rather than the
43
+ * tool schema because the trap is not about one call's arguments but about how
44
+ * a multi-step command is composed: `;` never stops on failure, and a pipeline
45
+ * reports only its last command's status — so the reflex of bounding long
46
+ * output with `| tail`, which this runtime already truncates for the caller,
47
+ * returns `tail`'s status instead of the command's.
48
+ */
49
+ export const SHELL_EXIT_STATUS_SECTION = 'Check the [exit code: N] marker on every bash result; investigate failures before moving on. Chain dependent steps with `&&` or `set -o pipefail`: `;` never stops on failure, and `cmd | tail` returns the status of `tail`, not of `cmd`.';
50
+
51
+ /** Managed `$DSH_*` environment facts are the same for every shell tool. */
52
+ const SHELL_HARNESS_FACTS_SECTION = 'Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed.';
53
+
54
+ /** The confinement marker one shared classifier produces for every dialect. */
55
+ const SHELL_SANDBOX_SECTION = 'Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way.';
56
+
57
+ /** Retention contract: the runtime truncates, so no call needs to bound output itself. */
58
+ const SHELL_OUTPUT_SECTION = 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available.';
59
+
60
+ /** Path-safety rules shared by every dialect (MSYS, Linux, and pwsh alike). */
61
+ const SHELL_PATH_SAFETY_SECTION = 'Before any delete or move, verify that the resolved absolute target path is the intended one; never run it against a computed path you have not checked. An unset variable expands to an empty string, so guard variables in such paths with `${VAR:?}`.';
62
+
63
+ /** Advertised only while some mounted family tool offers `run_in_background`. */
64
+ export const SHELL_BACKGROUND_SECTION = 'Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.';
65
+
66
+ /**
67
+ * The same-turn escalation guidance appended after a denial marker. Kept in one
68
+ * place because every dialect shares the exact approval contract, and included
69
+ * only while some mounted family tool advertises `sandbox_permissions`. The
70
+ * opening clause names that dependency because the section speaks for the whole
71
+ * family: a tool that advertises no such parameter has nothing to escalate
72
+ * with, and its own schema is what says so.
73
+ */
74
+ export const SHELL_ESCALATION_SECTION = 'Where a shell tool advertises `sandbox_permissions`: attempting a command the sandbox may deny is safe and expected — run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.';
75
+
76
+ /**
77
+ * Compose the family section from the facts that hold for the mounted set.
78
+ * @param advertised - what the mounted family tools advertise this composition.
79
+ * @param advertised.background - some mounted family tool offers `run_in_background`.
80
+ * @param advertised.escalation - some mounted family tool offers `sandbox_permissions`.
81
+ * @returns the section text, with no fact stated twice.
82
+ */
83
+ export function shellFamilySectionText({ background, escalation }) {
84
+ return [
85
+ SHELL_CALL_SECTION,
86
+ SHELL_EXIT_STATUS_SECTION,
87
+ SHELL_HARNESS_FACTS_SECTION,
88
+ SHELL_SANDBOX_SECTION,
89
+ SHELL_OUTPUT_SECTION,
90
+ SHELL_PATH_SAFETY_SECTION,
91
+ ...background ? [SHELL_BACKGROUND_SECTION] : [],
92
+ ...escalation ? [SHELL_ESCALATION_SECTION] : [],
93
+ ].join(' ');
94
+ }
95
+
96
+ /**
97
+ * The Loader specifier of the row that owns the family section. After the row
98
+ * merge that is the package row itself — the row whose `name` is the bare
99
+ * package specifier, which is also the row the Web client needs in order to
100
+ * find `dsh.client` and serve the browser half. Exported so the tool rows can
101
+ * tell whether that owner is composed: the section is the only carrier of the
102
+ * shared guidance, and the tool descriptions were slimmed on that assumption,
103
+ * so their absence has to be detected rather than assumed away.
104
+ */
105
+ export const SHELL_FAMILY_ROW_SPECIFIER = 'dsh-win-multi-bash';
106
+
107
+ /**
108
+ * Whether the row that owns the shared shell prompt section is composed and
109
+ * enabled. The tool descriptions carry only their dialect facts on the
110
+ * assumption that section states the rest, so this is worth knowing: without
111
+ * that row the shared guidance would silently vanish from the prompt.
112
+ *
113
+ * The Loader tree lists declared rows with their effective enablement before
114
+ * they activate, so the answer does not depend on row order. A loader that
115
+ * cannot be read answers `undefined` (treat as composed) — the default
116
+ * composition has the row, and guessing the other way would duplicate prose.
117
+ * @param ctx - a context that can reach the Loader (a row's own context).
118
+ * @returns true/false when known, `undefined` when the loader is unreadable.
119
+ */
120
+ export function familyRowComposed(ctx) {
121
+ const loader = ctx.get?.('loader') ?? ctx.loader;
122
+ if (loader === undefined || typeof loader.entries !== 'function') return undefined;
123
+ for (const entry of loader.entries()) {
124
+ if (entry.options?.name !== SHELL_FAMILY_ROW_SPECIFIER) continue;
125
+ return entry.disabled !== true;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ /**
131
+ * The family section text for one assembly, resolved from the tools actually
132
+ * mounted: it states nothing about a tool that is not there (a tool that
133
+ * advertises no `sandbox_permissions` also gets no escalation paragraph), and
134
+ * with no family tool mounted it renders no text at all — the section is then
135
+ * empty and dropped from the rendered prompt.
136
+ * @param ctx - a context with the `tools` service.
137
+ * @param scope - the assembly scope `tools.get` takes.
138
+ * @returns the section text, or `''` when this composition mounts no family tool.
139
+ */
140
+ export function shellFamilySectionTextFor(ctx, scope) {
141
+ const mounted = SHELL_FAMILY_TOOL_NAMES
142
+ .map((toolName) => ctx.tools.get(toolName, scope))
143
+ .filter((definition) => definition !== undefined);
144
+ if (mounted.length === 0) return '';
145
+ const advertises = (parameter) => mounted.some((definition) => definition.parameters?.properties?.[parameter] !== undefined);
146
+ return shellFamilySectionText({
147
+ background: advertises('run_in_background'),
148
+ escalation: advertises('sandbox_permissions'),
149
+ });
150
+ }
151
+
152
+ /**
153
+ * One tool's model-facing description. Normally that is only its dialect facts —
154
+ * the family section carries the rest once for the whole composition. When the
155
+ * owning row is **not** composed (a user switched it off, or wired a subset of
156
+ * the patch by hand), the shared text falls back into this description instead,
157
+ * so the model never silently loses the exit-marker, sandbox, truncation,
158
+ * path-safety, background and escalation guidance. The duplicated prose is the
159
+ * price of that fallback, and it only exists in a configuration that has no
160
+ * section at all.
161
+ * @param own - the dialect-owned description from {@link shellDescription}.
162
+ * @param familyPresent - whether the section's owning row is composed and enabled;
163
+ * only a definite `false` falls back, because an unknown state must never
164
+ * duplicate prose the section would also state.
165
+ * @param advertised - what this tool advertises, as {@link shellFamilySectionText} takes.
166
+ * @returns the description to register.
167
+ */
168
+ export function composeToolDescription({ own, familyPresent, background, escalation }) {
169
+ if (familyPresent !== false) return own;
170
+ return `${own} ${shellFamilySectionText({ background, escalation })}`;
171
+ }
172
+
173
+ /**
174
+ * Midpoint of the two placements dsh centrally allocates to shell tools, so the
175
+ * family section sorts between `tool:bash` and `tool:pwsh` instead of ahead of
176
+ * every other section. Resolved from the registry rather than hardcoded; the
177
+ * literals below are only the fallback for a registry that stopped exposing
178
+ * both names (the midpoint of TOOL_BASH 1000 / TOOL_PWSH 1010 at the time of
179
+ * writing).
180
+ * @param systemPrompt - the prompt registry the section is registered on.
181
+ * @returns a finite order value strictly between TOOL_BASH and TOOL_PWSH.
182
+ */
183
+ export function shellFamilySectionOrder(systemPrompt) {
184
+ const bash = systemPrompt.getSectionOrder('TOOL_BASH');
185
+ const pwsh = systemPrompt.getSectionOrder('TOOL_PWSH');
186
+ if (Number.isFinite(bash) && Number.isFinite(pwsh) && pwsh > bash)
187
+ return (bash + pwsh) / 2;
188
+ if (Number.isFinite(bash))
189
+ return bash + 1;
190
+ if (Number.isFinite(pwsh))
191
+ return pwsh - 1;
192
+ return 1005;
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-win-multi-bash",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "author": "Dinosaur_MC",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,6 +26,7 @@
26
26
  "main": "lib/index.js",
27
27
  "exports": {
28
28
  ".": "./lib/index.js",
29
+ "./client": "./lib/client.js",
29
30
  "./tool-git-bash": "./lib/tool-bash/types/git-bash.js",
30
31
  "./tool-wsl-bash": "./lib/tool-bash/types/wsl-bash.js",
31
32
  "./package.json": "./package.json"
@@ -33,6 +34,8 @@
33
34
  "files": [
34
35
  "lib/**/*.js",
35
36
  "cordis.patch.yml",
37
+ "install.ps1",
38
+ "uninstall.ps1",
36
39
  "README.md",
37
40
  "README.zh.md",
38
41
  "README.i18n.yaml",
@@ -45,6 +48,15 @@
45
48
  "dsh": {
46
49
  "bundle": {
47
50
  "patch": "./cordis.patch.yml"
51
+ },
52
+ "client": {
53
+ "platform": "web",
54
+ "inject": [
55
+ "@deepseek-ai/dsh-client-locale",
56
+ "@deepseek-ai/dsh-client-ui-settings",
57
+ "@deepseek-ai/dsh-client-ui-primitives",
58
+ "@deepseek-ai/dsh-client-ui-plugin-manager"
59
+ ]
48
60
  }
49
61
  },
50
62
  "peerDependencies": {
package/uninstall.ps1 ADDED
@@ -0,0 +1,50 @@
1
+ <#
2
+ .SYNOPSIS
3
+ 热拔 dsh-win-multi-bash(自包含版):从 profile 的 cordis.patch.yml 删除
4
+ managed 块,并移除 profile node_modules 里的插件 junction。
5
+ dsh web 会热重载,移除后新会话不再有 git_bash / wsl_bash 工具。
6
+
7
+ .PARAMETER ProfileName
8
+ 目标 profile 名,默认 web。
9
+ #>
10
+ [CmdletBinding()]
11
+ param(
12
+ [string]$ProfileName = 'web'
13
+ )
14
+
15
+ $ErrorActionPreference = 'Stop'
16
+
17
+ $dshHome = if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE '.dsh' }
18
+ $patchPath = Join-Path $dshHome "profiles\$ProfileName\cordis.patch.yml"
19
+ $linkPath = Join-Path $dshHome "profiles\$ProfileName\node_modules\dsh-win-multi-bash"
20
+
21
+ $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
22
+ $removed = $false
23
+
24
+ if (Test-Path $patchPath) {
25
+ $existing = [System.IO.File]::ReadAllText($patchPath, $utf8NoBom)
26
+ $pattern = '(?s)# --- dsh-win-multi-bash managed.*?# --- end dsh-win-multi-bash managed ---\r?\n?'
27
+ $removed = $existing -match $pattern
28
+ if ($removed) {
29
+ [System.IO.File]::WriteAllText($patchPath, [regex]::Replace($existing, $pattern, ''), $utf8NoBom)
30
+ Write-Host "[dsh-win-multi-bash] 已从 $patchPath 移除 managed 块。热重载后新会话不再有 git_bash / wsl_bash 工具。"
31
+ } else {
32
+ Write-Host "[dsh-win-multi-bash] $patchPath 中没有 managed 块。"
33
+ }
34
+ }
35
+
36
+ if (Test-Path $linkPath) {
37
+ $item = Get-Item $linkPath -Force
38
+ if ($item.LinkType -eq 'Junction') {
39
+ # PS 5.1 的 Remove-Item 删 junction 会抛 NullReferenceException(已知 bug),
40
+ # 用 cmd rmdir(只删链接本身,绝不触碰链接目标)。
41
+ cmd /c rmdir "$linkPath"
42
+ Write-Host "[dsh-win-multi-bash] 已移除插件 junction: $linkPath"
43
+ } else {
44
+ Write-Host "[dsh-win-multi-bash] $linkPath 存在但不是 junction,未动(请手动处理)"
45
+ }
46
+ }
47
+
48
+ if (-not $removed -and -not (Test-Path $linkPath)) {
49
+ Write-Host "[dsh-win-multi-bash] 若你是用 bundle 路径安装的(dsh plugin add),请运行: dsh plugin --profile $ProfileName remove dsh-win-multi-bash"
50
+ }