dsh-win-multi-bash 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,13 +8,15 @@
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
16
  import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell';
16
- import { processOutcome } from "./background.js";
17
- import { parseExitStatus, renderProcessRead, renderResult } from "./render.js";
17
+ import { ownExecutor } from "./backend.js";
18
+ import { processJob, processOutcome, processSources } from "./background.js";
19
+ import { parseExitStatus, renderResult } from "./render.js";
18
20
  const DIALECT_FACTS = {
19
21
  posix: { shell: 'bash', invoke: 'bash -c', paths: 'POSIX paths', env: '$VAR', toolchain: 'the full Unix toolchain' },
20
22
  msys: {
@@ -31,10 +33,19 @@ const DIALECT_FACTS = {
31
33
  env: '$VAR',
32
34
  },
33
35
  };
34
- /** Runtime configuration schema for a shell tool instance. */
35
- export const Config = z.object({
36
+ /** The tool-layer keys every shell tool instance carries, before its backend partition. */
37
+ const TOOL_CONFIG = {
36
38
  enableRunInBackground: z.boolean().default(true),
37
- });
39
+ };
40
+ /**
41
+ * Cross-call exit-status guidance for one shell tool instance. It belongs in the
42
+ * prompt rather than the tool schema because the trap is not about one call's
43
+ * arguments but about how a multi-step command is composed: `;` never stops on
44
+ * failure, and a pipeline reports only its last command's status — so the
45
+ * reflex of bounding long output with `| tail`, which this runtime already
46
+ * truncates for the caller, returns `tail`'s status instead of the command's.
47
+ */
48
+ 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`.';
38
49
  /**
39
50
  * The model-facing description of one shell tool instance. The POSIX variant
40
51
  * keeps the legacy wording byte-for-byte (the ACP/headless tool-schema
@@ -73,6 +84,8 @@ export function shellDescription(dialect, backgroundEnabled, escalationModes) {
73
84
  + `Current harness environment facts are exposed through managed \`\$${DSH_ENV_PREFIX}*\` variables; inspect them when needed. `
74
85
  + '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. '
75
86
  + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
87
+ + '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. '
88
+ + 'An unset variable expands to an empty string, so guard variables in such paths with `${VAR:?}`. '
76
89
  + background;
77
90
  return base + escalationTail(escalationModes);
78
91
  }
@@ -146,11 +159,38 @@ function presentShellResult(args, result) {
146
159
  const { body, ...exit } = parseExitStatus(raw);
147
160
  return { card: 'terminal', output: body, ...exit };
148
161
  }
162
+ /**
163
+ * Translate the bash-dialect drive forms a model may hand in as `workdir` into
164
+ * the native path the process spawn needs. `node:path` calls `/d/WorkSpace` and
165
+ * the WSL automount view `/mnt/d/WorkSpace` absolute, but Windows resolves them
166
+ * against the current drive (`G:\g\LAB\...`), so the spawn fails and reports
167
+ * ENOENT against the shell executable — `bash.exe` / `wsl.exe` — which reads as
168
+ * a missing shell rather than an unusable directory. Only a single-letter first
169
+ * segment (optionally under `/mnt/`) whose drive exists qualifies, so MSYS POSIX
170
+ * roots (`/etc`, `/usr`, `/tmp`, `/dev`, …) and distro-side paths (`/mnt/data`)
171
+ * are never drive-mapped.
172
+ * @param workdir - an absolute model-supplied workdir.
173
+ * @returns its native Windows form, or the input when it is not a drive form.
174
+ */
175
+ export function toNativeWorkdir(workdir) {
176
+ if (process.platform !== 'win32')
177
+ return workdir;
178
+ const match = /^(?:\/mnt)?\/([A-Za-z])(?:\/(.*))?$/.exec(workdir);
179
+ if (match === null)
180
+ return workdir;
181
+ const drive = match[1].toUpperCase();
182
+ if (!existsSync(`${drive}:\\`))
183
+ return workdir;
184
+ const rest = match[2];
185
+ return rest === undefined ? `${drive}:\\` : `${drive}:\\${rest.replace(/\//g, '\\')}`;
186
+ }
149
187
  /**
150
188
  * Resolve an explicit workdir first, making a relative one session-workspace-relative;
151
189
  * otherwise use the filesystem identity of the session cwd and leave executor
152
190
  * defaulting as the fallback. A resolved sandbox-policy root wins so workdir
153
- * and confinement use the exact same per-call identity.
191
+ * and confinement use the exact same per-call identity. An absolute dialect-form
192
+ * workdir is translated to its native form (see {@link toNativeWorkdir}) because
193
+ * the executor hands the value straight to `spawn` as the child's `cwd`.
154
194
  */
155
195
  function resolveWorkdir(modelWorkdir, exec, policyWorkspaceRoot) {
156
196
  const headerCwd = exec.agent?.session.header.cwd;
@@ -160,7 +200,7 @@ function resolveWorkdir(modelWorkdir, exec, policyWorkspaceRoot) {
160
200
  if (sessionCwd !== undefined && !isAbsolute(modelWorkdir)) {
161
201
  return resolvePath(sessionCwd, modelWorkdir);
162
202
  }
163
- return modelWorkdir;
203
+ return toNativeWorkdir(modelWorkdir);
164
204
  }
165
205
  /** Detach the executor DTO from readonly Service Definition types into plain JSON data. */
166
206
  function canonicalShellResult(result) {
@@ -197,18 +237,47 @@ const BACKGROUND_OUTPUT_PROPERTIES = {
197
237
  * tool name, the approval subject, the prompt section name (`tool:<toolName>`),
198
238
  * and the `ctx.jobs` kind; the instance module declares its kind in
199
239
  * {@link JobKindMap} via declaration merging.
240
+ *
241
+ * The tool owns its executor outright (see `./backend.js`): dsh 0.1.7 dropped
242
+ * the shell seam's routing field, so a tool can no longer ask a shared seat
243
+ * for a-backend-by-name. `def.Executor` is constructed once per tool load and
244
+ * driven directly, leaving `ctx.shell` to the base bundle's own executor.
200
245
  * @param def - the instance definition: `toolName` (also the job kind),
201
- * `shell` (routed to the selector executor when defined), and `dialect`.
246
+ * `dialect`, `Executor` (the backend class to own), and `configKey` (the
247
+ * Config partition that backend's settings live under).
202
248
  * @returns the complete plugin object (name/inject/Config/apply).
203
249
  */
204
250
  export function defineShellTool(def) {
251
+ const Config = z.object({
252
+ ...TOOL_CONFIG,
253
+ [def.configKey]: def.Executor.Config.default({}),
254
+ });
205
255
  return {
206
256
  name: `tool-${def.toolName}`,
207
- inject: ['tools', 'shell', 'systemPrompt', 'shellEnv'],
257
+ // The last three are the owned executor's own requirements; injecting
258
+ // them here is what lets the backend be built during apply.
259
+ inject: ['tools', 'systemPrompt', 'shellEnv', 'subprocess', 'sandbox', 'sandboxPolicy'],
208
260
  Config,
209
- apply(ctx, config = {}) {
261
+ async apply(ctx, config = {}) {
210
262
  const backgroundEnabled = config.enableRunInBackground ?? true;
211
- const defaultMode = ctx.shell.sandboxMode;
263
+ const { executor } = ownExecutor(ctx, def.Executor, config[def.configKey]);
264
+ // The confinement probe is async on 0.1.7 (the provider's `confine`
265
+ // returns a promise), so the escalation surface settles before the
266
+ // tool registers: a tool never advertises a confinement it cannot
267
+ // reach.
268
+ //
269
+ // A backend whose probe fails *loud* is deliberately swallowed here
270
+ // — that is the documented lazy contract: an explicit
271
+ // `sandbox: bwrap` without bubblewrap must not brick the tool row
272
+ // at load. It advertises no confinement now and its error
273
+ // resurfaces at the first routed command.
274
+ let defaultMode;
275
+ try {
276
+ defaultMode = await executor.resolveSandboxMode();
277
+ }
278
+ catch {
279
+ defaultMode = undefined;
280
+ }
212
281
  const escalationModes = defaultMode === undefined ? [] : ESCALATION_TARGETS;
213
282
  const sandboxPolicy = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy');
214
283
  if (defaultMode !== undefined && sandboxPolicy === undefined) {
@@ -244,7 +313,7 @@ export function defineShellTool(def) {
244
313
  ctx.systemPrompt.section({
245
314
  name: `tool:${def.toolName}`,
246
315
  order: 105,
247
- text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.',
316
+ text: SHELL_EXIT_STATUS_SECTION,
248
317
  });
249
318
  ctx.tools.register(defineTool({
250
319
  name: def.toolName,
@@ -259,7 +328,7 @@ export function defineShellTool(def) {
259
328
  + '"git status" → "Show working tree status"; "npm install" → "Install package dependencies".',
260
329
  },
261
330
  timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
262
- workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
331
+ 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.' },
263
332
  ...backgroundEnabled ? {
264
333
  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.' },
265
334
  } : {},
@@ -350,7 +419,6 @@ export function defineShellTool(def) {
350
419
  command: args.command,
351
420
  ...workdir !== undefined ? { workdir } : {},
352
421
  ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
353
- ...def.shell !== undefined ? { shell: def.shell } : {},
354
422
  dshEnv,
355
423
  ...policy !== undefined ? { sandboxPolicy: policy } : {},
356
424
  };
@@ -369,26 +437,33 @@ export function defineShellTool(def) {
369
437
  error.name = 'AbortError';
370
438
  throw error;
371
439
  }
440
+ // No deadline for a background command, and the process is
441
+ // spawned inside the starter, after the registry admitted it,
442
+ // so the pull sources bind lazily through the `proc` closure.
443
+ const spec = executor.resolve({ ...request, onExpiry: 'none' });
444
+ let proc;
372
445
  // Task preflight finishes before the starter can spawn a process.
373
446
  const id = jobs.start({
374
447
  kind: def.toolName,
375
448
  label: args.command,
376
- ...exec.agent ? { owner: exec.agent } : {},
449
+ ...exec.agent ? { owner: exec.agent.id } : {},
450
+ output: processSources(() => proc),
377
451
  run: () => {
378
- const proc = ctx.shell.start(ctx.shell.resolve(request));
379
- return {
380
- cancel: () => void proc.kill(),
381
- done: proc.done.then(() => processOutcome(proc)),
382
- readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
383
- };
452
+ const hooks = processJob(async (signal) => {
453
+ proc = await executor.execute({ ...spec, signal });
454
+ return proc;
455
+ }, (started) => processOutcome(started));
456
+ return { done: hooks.done, cancel: (reason) => hooks.cancel(reason) };
384
457
  },
385
458
  });
386
459
  return { kind: 'background', jobId: id };
387
460
  }
388
- const result = await ctx.shell.run(ctx.shell.resolve({
461
+ // Foreground is a property of awaiting the handle's result, not
462
+ // of the call: the same `execute` also serves `run_in_background`.
463
+ const result = await (await executor.execute(executor.resolve({
389
464
  ...request,
390
465
  signal: exec.signal,
391
- }));
466
+ }))).result();
392
467
  if (result.aborted) {
393
468
  const error = new HarnessError('tool call aborted', TOOL_ABORTED);
394
469
  error.name = 'AbortError';
@@ -1,13 +1,21 @@
1
1
  /**
2
- * The Git Bash (MSYS) tool instance: routes `request.shell` to the
3
- * 'git-bash' backend so a selector executor can serve it beside pwsh and WSL.
4
- * @module @deepseek-ai/dsh-tool-bash/git-bash
2
+ * The Git Bash (MSYS) tool instance: owns a {@link GitBashExecutor} outright
3
+ * and drives it directly. dsh 0.1.7 removed the shell seam's routing field
4
+ * (`ShellExecRequest.shell`), so there is no shared selector to route through
5
+ * any more — the tool itself is what knows it wants Git Bash.
6
+ * @module dsh-win-multi-bash/tool-bash/git-bash
5
7
  */
8
+ import { GitBashExecutor } from "../../bash-git/index.js";
6
9
  import { defineShellTool } from "./factory.js";
7
- /** The Git Bash tool: routes request.shell to the 'git-bash' backend. */
8
- const tool = defineShellTool({ toolName: 'git_bash', shell: 'git-bash', dialect: 'msys' });
10
+ /** The Git Bash tool: owns the git-bash backend. */
11
+ const tool = defineShellTool({
12
+ toolName: 'git_bash',
13
+ Executor: GitBashExecutor,
14
+ configKey: 'gitBash',
15
+ dialect: 'msys',
16
+ });
9
17
  export const name = tool.name;
10
18
  export const inject = tool.inject;
11
19
  export const Config = tool.Config;
12
20
  export const apply = tool.apply;
13
- //# sourceMappingURL=git-bash.js.map
21
+ //# sourceMappingURL=git-bash.js.map
@@ -57,35 +57,6 @@ export function renderResult(result, escalationModes = []) {
57
57
  body += '\n';
58
58
  return body + markers.join('\n');
59
59
  }
60
- /**
61
- * Shape one background-process read into the `job_output` delta the model
62
- * sees: the incremental delta, plus the lossy-read notice (with full-stream
63
- * spill paths) when in-memory truncation dropped unread bytes. Empty-delta
64
- * rendering (`(no new output)`) is the generic job controller's job.
65
- * @param read - one incremental read from the process handle.
66
- * @param sandbox - settled sandbox facts, when this was a confined process.
67
- * @param escalationModes - escalation targets advertised by this composition.
68
- * @returns the delta text with any loss or sandbox notice appended.
69
- */
70
- export function renderProcessRead(read, sandbox, escalationModes = []) {
71
- const notices = [];
72
- if (read.lossy) {
73
- const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path) => path !== undefined);
74
- notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`);
75
- }
76
- if (sandbox?.runnerFailed) {
77
- notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`);
78
- }
79
- else if (sandbox?.denied) {
80
- notices.push(sandboxDenialMarker(sandbox.mode));
81
- if (escalationModes.length > 0) {
82
- notices.push(escalationHintMarker('command'));
83
- }
84
- }
85
- if (notices.length === 0)
86
- return read.delta;
87
- return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`;
88
- }
89
60
  /**
90
61
  * The exit-status parse is the shared marker-contract half of the shell-tool
91
62
  * rendering story, owned by `@deepseek-ai/dsh-shell` so `dsh-tool-pwsh` reuses
@@ -1,13 +1,21 @@
1
1
  /**
2
- * The WSL bash tool instance: routes `request.shell` to the 'wsl-bash'
3
- * backend so a selector executor can serve it beside pwsh and Git Bash.
4
- * @module @deepseek-ai/dsh-tool-bash/wsl-bash
2
+ * The WSL bash tool instance: owns a {@link WslBashExecutor} outright and
3
+ * drives it directly. dsh 0.1.7 removed the shell seam's routing field
4
+ * (`ShellExecRequest.shell`), so there is no shared selector to route through
5
+ * any more — the tool itself is what knows it wants WSL.
6
+ * @module dsh-win-multi-bash/tool-bash/wsl-bash
5
7
  */
8
+ import { WslBashExecutor } from "../../bash-wsl/index.js";
6
9
  import { defineShellTool } from "./factory.js";
7
- /** The WSL bash tool: routes request.shell to the 'wsl-bash' backend. */
8
- const tool = defineShellTool({ toolName: 'wsl_bash', shell: 'wsl-bash', dialect: 'wsl' });
10
+ /** The WSL bash tool: owns the wsl-bash backend. */
11
+ const tool = defineShellTool({
12
+ toolName: 'wsl_bash',
13
+ Executor: WslBashExecutor,
14
+ configKey: 'wslBash',
15
+ dialect: 'wsl',
16
+ });
9
17
  export const name = tool.name;
10
18
  export const inject = tool.inject;
11
19
  export const Config = tool.Config;
12
20
  export const apply = tool.apply;
13
- //# sourceMappingURL=wsl-bash.js.map
21
+ //# sourceMappingURL=wsl-bash.js.map
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "dsh-win-multi-bash",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "author": "Dinosaur_MC",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/Dinosaur-MC/dsh-win-multi-bash.git"
8
8
  },
9
- "description": "Windows multi-bash plugin for dsh: git_bash / wsl_bash model tools plus a shell-select executor routing the single ctx.shell seat across Git Bash, WSL and pwsh (pwsh stays the default).",
9
+ "description": "Windows multi-bash plugin for dsh: git_bash / wsl_bash model tools, each owning its own Git Bash / WSL executor and leaving the ctx.shell seat to dsh's own pwsh executor.",
10
10
  "keywords": [
11
11
  "dsh-plugin",
12
12
  "windows",
@@ -26,7 +26,6 @@
26
26
  "main": "lib/index.js",
27
27
  "exports": {
28
28
  ".": "./lib/index.js",
29
- "./shell-select": "./lib/shell-select/index.js",
30
29
  "./tool-git-bash": "./lib/tool-bash/types/git-bash.js",
31
30
  "./tool-wsl-bash": "./lib/tool-bash/types/wsl-bash.js",
32
31
  "./package.json": "./package.json"
@@ -50,14 +49,11 @@
50
49
  },
51
50
  "peerDependencies": {
52
51
  "@deepseek-ai/cordis": ">=4.0.1",
53
- "@deepseek-ai/dsh-bash-local": ">=0.1.0-rc.7",
54
- "@deepseek-ai/dsh-llm": ">=0.1.0-rc.7",
55
- "@deepseek-ai/dsh-pwsh-local": ">=0.1.0-rc.7",
56
- "@deepseek-ai/dsh-pwsh-sandbox": ">=0.1.0-rc.7",
57
- "@deepseek-ai/dsh-sandbox": ">=0.1.0-rc.7",
58
- "@deepseek-ai/dsh-settings": ">=0.1.2-alpha.2",
59
- "@deepseek-ai/dsh-shell": ">=0.1.0-rc.7",
60
- "@deepseek-ai/dsh-tools": ">=0.1.0-rc.7",
52
+ "@deepseek-ai/dsh-bash-local": ">=0.1.7-rc.2",
53
+ "@deepseek-ai/dsh-llm": ">=0.1.7-rc.2",
54
+ "@deepseek-ai/dsh-sandbox": ">=0.1.7-rc.2",
55
+ "@deepseek-ai/dsh-shell": ">=0.1.7-rc.2",
56
+ "@deepseek-ai/dsh-tools": ">=0.1.7-rc.2",
61
57
  "@deepseek-ai/schemastery": ">=3.18.0"
62
58
  },
63
59
  "peerDependenciesMeta": {
@@ -70,18 +66,9 @@
70
66
  "@deepseek-ai/dsh-llm": {
71
67
  "optional": true
72
68
  },
73
- "@deepseek-ai/dsh-pwsh-local": {
74
- "optional": true
75
- },
76
- "@deepseek-ai/dsh-pwsh-sandbox": {
77
- "optional": true
78
- },
79
69
  "@deepseek-ai/dsh-sandbox": {
80
70
  "optional": true
81
71
  },
82
- "@deepseek-ai/dsh-settings": {
83
- "optional": true
84
- },
85
72
  "@deepseek-ai/dsh-shell": {
86
73
  "optional": true
87
74
  },
@@ -1,23 +0,0 @@
1
- //#region lib/types/invariant.js
2
- /**
3
- * Package-owned invariant companion for `@deepseek-ai/dsh-bash-git`.
4
- * @module @deepseek-ai/dsh-bash-git/invariant
5
- */
6
- const PACKAGE_NAME = "@deepseek-ai/dsh-bash-git";
7
- /** Cordis companion plugin name. */
8
- const name = "bash-git-invariant";
9
- /** Service required before the companion can reserve package ownership. */
10
- const inject = ["invariants"];
11
- /**
12
- * No runtime invariant: this package exposes no independent event sequence or mutable data relation
13
- * beyond contracts enforced at its owning seam.
14
- */
15
- const install = () => {};
16
- /**
17
- * Register this package's invariant companion.
18
- * @param ctx - Cordis context carrying the invariant service.
19
- * @returns the installed registration's disposer after setup succeeds.
20
- */
21
- const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
22
- //#endregion
23
- export { apply, inject, name };
@@ -1,23 +0,0 @@
1
- //#region lib/types/invariant.js
2
- /**
3
- * Package-owned invariant companion for `@deepseek-ai/dsh-bash-wsl`.
4
- * @module @deepseek-ai/dsh-bash-wsl/invariant
5
- */
6
- const PACKAGE_NAME = "@deepseek-ai/dsh-bash-wsl";
7
- /** Cordis companion plugin name. */
8
- const name = "bash-wsl-invariant";
9
- /** Service required before the companion can reserve package ownership. */
10
- const inject = ["invariants"];
11
- /**
12
- * No runtime invariant: this package exposes no independent event sequence or mutable data relation
13
- * beyond contracts enforced at its owning seam.
14
- */
15
- const install = () => {};
16
- /**
17
- * Register this package's invariant companion.
18
- * @param ctx - Cordis context carrying the invariant service.
19
- * @returns the installed registration's disposer after setup succeeds.
20
- */
21
- const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
22
- //#endregion
23
- export { apply, inject, name };
@@ -1,170 +0,0 @@
1
- import z from "@deepseek-ai/schemastery";
2
- import { HarnessError } from "@deepseek-ai/dsh-llm";
3
- import { SHELL_SETTINGS_NAMESPACE, ShellExecutor } from "@deepseek-ai/dsh-shell";
4
- import { GitBashExecutor } from "../bash-git/index.js";
5
- import { WslBashExecutor } from "../bash-wsl/index.js";
6
- import { SandboxPwshExecutor } from "@deepseek-ai/dsh-pwsh-sandbox";
7
- import { assertServiceableBashConfig } from "@deepseek-ai/dsh-bash-local";
8
- import { assertServiceablePwshConfig } from "@deepseek-ai/dsh-pwsh-local";
9
- //#region lib/types/index.js
10
- /**
11
- * Selector Service Provider for the bash capability seam: occupies the single
12
- * `ctx.shell` seat and routes each request to one of several backends held as
13
- * plain instances. The tool name selects the backend (`request.shell`); a
14
- * request without one goes to the configured `default`. Backends are
15
- * constructed on their own child fibers with isolated `shell`/`settings`
16
- * scopes so their inherited Service registration and settings wiring cannot
17
- * collide with this executor's own seat. win32 compositions only; POSIX
18
- * compositions keep a single backend directly on the seat.
19
- * @module @deepseek-ai/dsh-shell-select
20
- */
21
- /** Error code for routing to an unknown or disabled backend. */
22
- const SHELL_BACKEND_UNAVAILABLE = "SHELL_BACKEND_UNAVAILABLE";
23
- const DEFAULT_FACTORIES = {
24
- "git-bash": (ctx, config) => new GitBashExecutor(ctx, GitBashExecutor.Config(config)),
25
- "wsl-bash": (ctx, config) => new WslBashExecutor(ctx, WslBashExecutor.Config(config)),
26
- pwsh: (ctx, config) => new SandboxPwshExecutor(ctx, SandboxPwshExecutor.Config(config))
27
- };
28
- /**
29
- * Selector executor over the bash capability seam. Registers as `ctx.shell`
30
- * (the single seat), routes every request to one enabled backend by name, and
31
- * stamps the chosen name on the resolved spec as `shellBackend`. Enabled
32
- * names without a factory fail loud at the first rebuild; routing to an
33
- * unbuilt name throws `SHELL_BACKEND_UNAVAILABLE`.
34
- */
35
- var ShellSelectExecutor = class ShellSelectExecutor extends ShellExecutor {
36
- static inject = [
37
- "subprocess",
38
- "sandbox",
39
- "sandboxPolicy"
40
- ];
41
- static Config = z.object({
42
- backends: z.array(z.string()).default([
43
- "git-bash",
44
- "wsl-bash",
45
- "pwsh"
46
- ]),
47
- default: z.string().default("pwsh"),
48
- gitBash: GitBashExecutor.Config.default({}),
49
- wslBash: WslBashExecutor.Config.default({}),
50
- pwsh: SandboxPwshExecutor.Config.default({})
51
- });
52
- /** Test hook: replace backend construction wholesale (mirrors the sandbox-local internals pattern). */
53
- internals = {};
54
- /** The currently authoritative config: the settings section, or the composition entry. */
55
- source;
56
- backends = /* @__PURE__ */ new Map();
57
- fibers = [];
58
- constructor(ctx, config) {
59
- super(ctx);
60
- const entry = config;
61
- assertServiceableBashConfig(entry.gitBash);
62
- assertServiceableBashConfig(entry.wslBash);
63
- assertServiceablePwshConfig(entry.pwsh);
64
- this.source = () => entry;
65
- ctx.inject(["settings"], (settingsCtx) => {
66
- settingsCtx.settings.installSection(ctx, SHELL_SETTINGS_NAMESPACE, ShellSelectExecutor.Config, entry, {
67
- validate: (value) => {
68
- const v = value;
69
- assertServiceableBashConfig(v.gitBash);
70
- assertServiceableBashConfig(v.wslBash);
71
- assertServiceablePwshConfig(v.pwsh);
72
- },
73
- setSource: (current) => {
74
- this.source = current;
75
- },
76
- onChange: () => {
77
- this.rebuildBackends(this.config);
78
- }
79
- });
80
- });
81
- }
82
- /** Validated config (schemastery applied the defaults before construction). */
83
- get config() {
84
- return this.source();
85
- }
86
- /**
87
- * The first sandbox mode any enabled backend declares (tool-layer
88
- * advertisement). One backend's probe/verdict failure (e.g. explicit
89
- * `sandbox: bwrap` without bubblewrap installed) must not brick the whole
90
- * selector: that backend advertises no mode here and its error resurfaces
91
- * loudly at its first routed command — the documented lazy contract.
92
- */
93
- get sandboxMode() {
94
- this.ensureBackends();
95
- for (const name of this.config.backends) {
96
- let mode;
97
- try {
98
- mode = this.backends.get(name)?.sandboxMode;
99
- } catch {
100
- continue;
101
- }
102
- if (mode !== void 0) return mode;
103
- }
104
- }
105
- resolve(request) {
106
- this.ensureBackends();
107
- const name = request.shell ?? this.config.default;
108
- return {
109
- ...this.requireBackend(name).resolve(request),
110
- shellBackend: name
111
- };
112
- }
113
- run(spec) {
114
- this.ensureBackends();
115
- return this.requireBackend(spec.shellBackend ?? this.config.default).run(spec);
116
- }
117
- start(spec) {
118
- this.ensureBackends();
119
- return this.requireBackend(spec.shellBackend ?? this.config.default).start(spec);
120
- }
121
- requireBackend(name) {
122
- const backend = this.backends.get(name);
123
- if (backend === void 0) throw new HarnessError(`shell-select: backend "${name}" is not enabled (enabled: ${[...this.backends.keys()].join(", ")})`, SHELL_BACKEND_UNAVAILABLE);
124
- return backend;
125
- }
126
- /** The first rebuild runs at first use, after the internals hook is installed. */
127
- ensureBackends() {
128
- if (this.backends.size > 0) return;
129
- this.rebuildBackends(this.config);
130
- }
131
- /**
132
- * Construct one backend per enabled name on its own child fiber with
133
- * isolated shell/settings scopes (see the class doc), or throw for an
134
- * unknown name. Old fibers are disposed first so rebuilds never leak.
135
- */
136
- rebuildBackends(entry) {
137
- for (const fiber of this.fibers.splice(0)) fiber.dispose();
138
- this.backends.clear();
139
- for (const name of entry.backends) {
140
- const factory = this.internals.factories?.[name] ?? DEFAULT_FACTORIES[name];
141
- if (factory === void 0) throw new Error(`shell-select: unknown backend "${name}" (enabled: ${entry.backends.join(", ")})`);
142
- const fiber = this.ctx.plugin({
143
- inject: [
144
- "subprocess",
145
- "sandbox",
146
- "sandboxPolicy"
147
- ],
148
- apply: () => {}
149
- });
150
- this.fibers.push(fiber);
151
- const backendCtx = fiber.ctx.isolate("shell", Symbol(name)).isolate("settings", Symbol(name));
152
- this.backends.set(name, factory(backendCtx, backendConfig(name, entry)));
153
- }
154
- }
155
- };
156
- /**
157
- * The config partition for one backend name. Names without a dedicated
158
- * partition (test-hook factories) receive an empty config; the owning factory
159
- * decides what it needs.
160
- */
161
- function backendConfig(name, entry) {
162
- switch (name) {
163
- case "git-bash": return entry.gitBash;
164
- case "wsl-bash": return entry.wslBash;
165
- case "pwsh": return entry.pwsh;
166
- default: return {};
167
- }
168
- }
169
- //#endregion
170
- export { SHELL_BACKEND_UNAVAILABLE, ShellSelectExecutor, ShellSelectExecutor as default };
@@ -1,23 +0,0 @@
1
- //#region lib/types/invariant.js
2
- /**
3
- * Package-owned invariant companion for `@deepseek-ai/dsh-shell-select`.
4
- * @module @deepseek-ai/dsh-shell-select/invariant
5
- */
6
- const PACKAGE_NAME = "@deepseek-ai/dsh-shell-select";
7
- /** Cordis companion plugin name. */
8
- const name = "shell-select-invariant";
9
- /** Service required before the companion can reserve package ownership. */
10
- const inject = ["invariants"];
11
- /**
12
- * No runtime invariant: this package exposes no independent event sequence or
13
- * mutable data relation beyond contracts enforced at its owning seams.
14
- */
15
- const install = () => {};
16
- /**
17
- * Register this package's invariant companion.
18
- * @param ctx - Cordis context carrying the invariant service.
19
- * @returns the installed registration's disposer after setup succeeds.
20
- */
21
- const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
22
- //#endregion
23
- export { apply, inject, name };