dsh-gitbash-shell 0.39.3 → 0.40.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.
package/src/index.js CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  cpSync,
47
47
  existsSync,
48
48
  mkdirSync,
49
+ mkdtempSync,
49
50
  readFileSync,
50
51
  readdirSync,
51
52
  rmSync,
@@ -57,7 +58,7 @@ import { fileURLToPath } from 'node:url'
57
58
 
58
59
  import { PRESET_META, minimalPluginsFor, pluginsFor } from './compositions.js'
59
60
  import { loadSchemastery } from './schemastery.js'
60
- import { GITBASH_MSYS_ADDENDUM, GITBASH_ROUTE_ADDENDUM, GITBASH_ROUTE_ADDENDUM_NO_PWSH, mountInlineTools } from './inline-tools.js'
61
+ import { applyToolRouting, mountInlineTools } from './inline-tools.js'
61
62
  import { mountFastctx } from './fastctx-bridge.js'
62
63
 
63
64
  /**
@@ -512,12 +513,14 @@ export const Config = Schema === null ? undefined : Schema.object({
512
513
  * now controls the real thing.
513
514
  */
514
515
  pwshTool: live(Schema.boolean().default(false)),
516
+ // Maintainer PR #26 decision: one live opt-out for refusal and companions.
517
+ experimentalNative: live(Schema.boolean().default(true)),
515
518
  /**
516
519
  * Publish the FastCtx file tools (`gb_*`) through the local MCP bridge.
517
520
  * Default ON; Windows-only payload, stands down entirely when dsh-ops is
518
521
  * present (its ops_* tools already cover the same ground).
519
522
  */
520
- fastctxTools: live(Schema.boolean().default(true)),
523
+ fastctxTools: live(Schema.boolean().default(false)),
521
524
  })
522
525
 
523
526
  /** Read one Config value across eras: Volatile ref (>= 0.1.7) or plain value. */
@@ -582,8 +585,8 @@ function makeLiveReader(ctx, config) {
582
585
  variantPresets: valueOf(config.variantPresets) === true,
583
586
  // Default ON (v0.35.0): the pwsh replacement fills the denied host tool.
584
587
  pwshTool: valueOf(config.pwshTool) !== false,
585
- // Default ON (v0.35.0): the FastCtx bridge stands down when dsh-ops is present.
586
- fastctxTools: valueOf(config.fastctxTools) !== false,
588
+ // Default OFF (v0.39.4, dsh-ops 0.3.0): FastCtx is dead upstream and dsh-ops ships stronger native ops_* file tools; an explicit true re-enables the bridge. It still stands down when dsh-ops is present.
589
+ fastctxTools: valueOf(config.fastctxTools) === true,
587
590
  }
588
591
  }
589
592
  return {
@@ -593,7 +596,8 @@ function makeLiveReader(ctx, config) {
593
596
  autoTerminalShell: () => dialect().autoTerminalShell !== false,
594
597
  variantPresets: () => (hasConfig ? valueOf(config.variantPresets) === true : false),
595
598
  pwshTool: () => (hasConfig ? valueOf(config.pwshTool) !== false : true),
596
- fastctxTools: () => (hasConfig ? valueOf(config.fastctxTools) !== false : true),
599
+ experimentalNative: () => (hasConfig ? valueOf(config.experimentalNative) !== false : true),
600
+ fastctxTools: () => (hasConfig ? valueOf(config.fastctxTools) === true : false),
597
601
  // Default OFF: only an explicit true asks for deduplication.
598
602
  suppressPeerCordis: () => (hasConfig ? valueOf(config.suppressPeerCordis) === true : false),
599
603
  }
@@ -773,7 +777,9 @@ function pathEcho(value, env) {
773
777
  * content VERBATIM: output is data, never rewritten (the same red line as
774
778
  * file content in successful results).
775
779
  */
776
- const ERROR_CONTENT_TOOLS = new Set(['read', 'read_image', 'write', 'edit', 'glob', 'grep', 'present'])
780
+ // ops_* six (v0.39.4, dsh-ops 0.3.0): their failures quote Windows paths too —
781
+ // the error content and error.message must answer in the same /c/ dialect.
782
+ const ERROR_CONTENT_TOOLS = new Set(['read', 'read_image', 'write', 'edit', 'glob', 'grep', 'present', 'ops_inspect_local_file', 'ops_glob', 'ops_grep', 'ops_replace', 'ops_batch', 'ops_result'])
777
783
 
778
784
  export function rewriteErrorContent(content, options) {
779
785
  if (!Array.isArray(content)) return content
@@ -868,6 +874,37 @@ export function rewriteResultPaths(name, value, env) {
868
874
  })
869
875
  return changed ? { ...value, files } : value
870
876
  }
877
+ // v0.39.4 (dsh-ops 0.3.0): ops_* tools answer with the same paths the
878
+ // model must keep speaking /c/ about. Their Rust worker names every path
879
+ // field `path` (items[] / matches[]) and ops_batch/ops_result echo the
880
+ // operation `root`; detailsRef ids are opaque, never rewritten.
881
+ if (name === 'ops_grep' && Array.isArray(value.matches)) {
882
+ let changed = false
883
+ const matches = value.matches.map((match) => {
884
+ if (!match || typeof match !== 'object' || typeof match.path !== 'string' || match.path === '') return match
885
+ const out = toMsys(match.path)
886
+ if (out === match.path) return match
887
+ changed = true
888
+ return { ...match, path: out }
889
+ })
890
+ return changed ? { ...value, matches } : value
891
+ }
892
+ if ((name === 'ops_inspect_local_file' || name === 'ops_glob' || name === 'ops_replace') && Array.isArray(value.items)) {
893
+ let changed = false
894
+ const items = value.items.map((item) => {
895
+ if (!item || typeof item !== 'object' || typeof item.path !== 'string' || item.path === '') return item
896
+ const out = toMsys(item.path)
897
+ if (out === item.path) return item
898
+ changed = true
899
+ return { ...item, path: out }
900
+ })
901
+ return changed ? { ...value, items } : value
902
+ }
903
+ if ((name === 'ops_batch' || name === 'ops_result') && typeof value.root === 'string' && value.root !== '') {
904
+ const out = toMsys(value.root)
905
+ if (out !== value.root) return { ...value, root: out }
906
+ return value
907
+ }
871
908
  if (name === 'glob' && Array.isArray(value.paths)) {
872
909
  let changed = false
873
910
  const paths = value.paths.map((p) => {
@@ -909,7 +946,9 @@ export function rewriteResultPaths(name, value, env) {
909
946
 
910
947
  const MSYS_DRIVE_ROOT = /^\/([a-z])\/(.*)$/i
911
948
  const MSYS_BARE_DRIVE = /^\/([a-z])$/i
912
- const TRANSLATABLE_PATH_FIELDS = new Set(['file_path', 'path', 'workdir'])
949
+ // 'root' (v0.39.4): dsh-ops ops_batch/ops_result name their path argument `root`;
950
+ // no official tool uses that key, so the extra alias is collision-free.
951
+ const TRANSLATABLE_PATH_FIELDS = new Set(['file_path', 'path', 'workdir', 'root'])
913
952
 
914
953
  // ── virtual mounts + env (v0.17.0) ─────────────────────────────────────
915
954
  //
@@ -1143,6 +1182,37 @@ export function translatePathArguments(args, env) {
1143
1182
  changed = true
1144
1183
  }
1145
1184
  }
1185
+ // v0.39.4 (dsh-ops 0.3.0): ops_batch carries its paths one level down —
1186
+ // operations[] objects with path/to, and protect[] as a plain string
1187
+ // array. Same deal as present's files[].path: the field-name whitelist
1188
+ // only sees top-level keys. The model speaks /c/ everywhere; these
1189
+ // arrive at the Rust worker as native absolute paths.
1190
+ if (Array.isArray(next.operations)) {
1191
+ const operations = next.operations.map((operation) => {
1192
+ if (!operation || typeof operation !== 'object') return operation
1193
+ let out = operation
1194
+ for (const field of ['path', 'to']) {
1195
+ if (typeof out[field] !== 'string') continue
1196
+ const translated = translateMsysPath(out[field], env)
1197
+ if (translated !== out[field]) out = { ...out, [field]: translated }
1198
+ }
1199
+ return out
1200
+ })
1201
+ if (operations.some((operation, index) => operation !== next.operations[index])) {
1202
+ next.operations = operations
1203
+ changed = true
1204
+ }
1205
+ }
1206
+ if (Array.isArray(next.protect)) {
1207
+ const protect = next.protect.map((entry) => {
1208
+ if (typeof entry !== 'string') return entry
1209
+ return translateMsysPath(entry, env)
1210
+ })
1211
+ if (protect.some((entry, index) => entry !== next.protect[index])) {
1212
+ next.protect = protect
1213
+ changed = true
1214
+ }
1215
+ }
1146
1216
  return changed ? next : args
1147
1217
  }
1148
1218
 
@@ -2153,25 +2223,15 @@ export async function apply(ctx, config = {}) {
2153
2223
  no run_code hint — so they meet the shell they were composed for. */
2154
2224
  const dialect = liveSettings.dialect()
2155
2225
  const applies = dialectApplies(dialect, assembleContext && assembleContext.agent)
2156
- // v0.35.0: shell-routing addendum on the `bash` tool's description.
2157
- // Description is schema-level text, so even the minimal preset's
2158
- // complete-persona cannot strip it; the ROUTING sentence applies to
2159
- // every agent (routing is dialect-independent), while the MSYS
2160
- // sentence only describes the world the dialect switch creates.
2161
- // Idempotent by marker: assemble may run more than once per agent.
2162
- // The routing sentence follows the pwsh switch (v0.37.0; route B
2163
- // v0.39.0): while the pwsh tool is hidden (the default) the sentence
2164
- // must NOT name it — a route to a tool the agent cannot see only
2165
- // teaches the model to call a hidden name.
2166
- if (assembly && typeof assembly === 'object' && Array.isArray(assembly.tools)) {
2167
- const routeSentence = liveSettings.pwshTool() === true ? GITBASH_ROUTE_ADDENDUM : GITBASH_ROUTE_ADDENDUM_NO_PWSH
2168
- for (const tool of assembly.tools) {
2169
- if (tool && tool.name === 'bash' && typeof tool.description === 'string'
2170
- && !tool.description.includes('Prefer this bash tool')) {
2171
- tool.description += '\n' + routeSentence
2172
- + (applies && dialect.posixPaths === true ? '\n' + GITBASH_MSYS_ADDENDUM : '')
2173
- }
2174
- }
2226
+ // Routing is dialect-independent and follows actual offered tools.
2227
+ // PTC may expose only run_code here: its generic SDK decision rule
2228
+ // relies on the registered bindings' own descriptions, not guessed
2229
+ // hidden names. Replace our block on reassembly/visibility changes.
2230
+ if (assembly && typeof assembly === 'object') {
2231
+ applyToolRouting(assembly.tools, {
2232
+ pwshVisible: liveSettings.pwshTool() === true,
2233
+ msys: applies && dialect.posixPaths === true,
2234
+ })
2175
2235
  }
2176
2236
  if (applies && dialect.posixPaths === true && assembly && typeof assembly === 'object') {
2177
2237
  for (const section of Array.isArray(assembly.sections) ? assembly.sections : []) {
@@ -2699,6 +2759,18 @@ export async function apply(ctx, config = {}) {
2699
2759
  }
2700
2760
  },
2701
2761
  pwshEnabled: () => liveSettings.pwshTool() === true,
2762
+ experimentalEnabled: () => liveSettings.experimentalNative(),
2763
+ // Explicit conversion does not rewrite source or change auto-dialect
2764
+ // settings. The same lazy, verified mount facts are used each call.
2765
+ translatePath: (value) => translateMsysPath(value, buildTranslateEnv(readConfiguredBashPath())),
2766
+ // Trusted host-owned output storage, invoked only after the native
2767
+ // executor's actual policy/argument/capability gate. Never model cwd.
2768
+ // Completed bounded artifacts remain readable like Core spill files;
2769
+ // this does not claim a cross-task quota or automatic retention purge.
2770
+ createArtifactDirectory: (_request, _exec, signal) => {
2771
+ signal?.throwIfAborted()
2772
+ return mkdtempSync(join(tmpdir(), 'dsh-gitbash-native-'))
2773
+ },
2702
2774
  log: (m) => console.log(TAG + ' ' + m),
2703
2775
  })
2704
2776
  console.log(TAG + ' inline tool patch armed (own bash tool over a private Git Bash executor; official pwsh executor untouched)')
@@ -101,14 +101,14 @@ export function inlineNeeds(names, { pwshVisible } = {}) {
101
101
  * routing. Both hold regardless of the path dialect a delegate sees.
102
102
  */
103
103
  export const GITBASH_ROUTE_ADDENDUM = [
104
- 'Prefer this bash tool for ALL commands; use the pwsh tool ONLY when a Windows-native capability (cmdlets, registry, services, WMI) is truly required.',
105
- 'Fix failed bash commands in bash — never switch shells mid-task, never embed `pwsh -Command` inside a bash command (variable expansion and output encoding break there).',
104
+ 'Prefer this bash tool for shell syntax, scripts, pipelines, redirection, globbing and POSIX utilities; use the pwsh tool ONLY when PowerShell cmdlets, registry, services or WMI are truly required.',
105
+ 'Fix failed bash syntax in bash; never embed `pwsh -Command` inside a bash command as a workaround (variable expansion and output encoding break there). Choose a separately offered native route for its documented purpose before execution, never as a policy-denial retry.',
106
106
  ].join('\n')
107
107
 
108
108
  /** The bash-only routing variant, used while the pwsh switch is off (the default). */
109
109
  export const GITBASH_ROUTE_ADDENDUM_NO_PWSH = [
110
- 'Prefer this bash tool for ALL commands — it is this host\'s shell.',
111
- 'Fix failed commands in bash; there is no PowerShell tool on this host.',
110
+ 'Prefer this bash tool for shell syntax, scripts, pipelines, redirection, globbing and POSIX utilities — it is this host\'s shell.',
111
+ 'Fix failed bash syntax in bash; no PowerShell tool is offered here. Do not invent a hidden tool or embed another interpreter as a workaround.',
112
112
  ].join('\n')
113
113
 
114
114
  /**
@@ -118,6 +118,38 @@ export const GITBASH_ROUTE_ADDENDUM_NO_PWSH = [
118
118
  */
119
119
  export const GITBASH_MSYS_ADDENDUM = 'This bash is Git for Windows (MSYS): paths use drive roots (/c/Users/...), and ~, /tmp, /dev/null and /usr/... resolve exactly as Git Bash resolves them.'
120
120
 
121
+ const ROUTING_START = '\n[gitbash tool routing]\n'
122
+ const ROUTING_END = '\n[/gitbash tool routing]'
123
+ const ROUTING_SAFETY = 'Choose the execution route before running. A policy/access denial is a stop condition: never switch tools, shells or permissions to bypass it. A decoding/display failure does not mean the command failed to run; preserve its exit facts and never blindly repeat a side-effecting command. Do not guess unknown or mixed encodings.'
124
+ const PTC_ROUTING = 'Inside run_code, call only bindings actually offered by the current SDK with await tools.<name>(...). Select by their descriptions: shell execution for shell syntax/pipelines; an offered typed native-executable binding for exact Windows argv or explicitly known per-stream encodings; an offered complete-path conversion binding before passing an MSYS value to Node fs/path. These are different operations, not substitutes for permissions. Never pass routes, search text or source code to a path converter. Program strings do not perform shell expansion. If a needed binding is absent, report the missing capability; do not invent its name. ' + ROUTING_SAFETY
125
+
126
+ /** Model guidance names only tools in this assembly, not a switch's intent. */
127
+ export function toolRoutingHintFor(tools, { pwshVisible = false } = {}) {
128
+ const names = new Set((Array.isArray(tools) ? tools : []).map(tool => tool?.name))
129
+ if (!names.has('bash')) return ''
130
+ const lines = [pwshVisible && names.has('pwsh') ? GITBASH_ROUTE_ADDENDUM : GITBASH_ROUTE_ADDENDUM_NO_PWSH]
131
+ if (names.has('gitbash_native')) lines.push('Use gitbash_native before execution when a Windows .exe needs exact literal argv such as /all or /c without MSYS conversion, or independently declared stdout/stderr encodings. Supply an absolute executable, mark each argument literal or path, and select only a known encoding (raw for unknown/mixed bytes). It does not implement shell pipelines, expansion or .cmd/.bat wrappers; only resolved danger-full-access can execute it.')
132
+ if (names.has('gitbash_to_native_path')) lines.push('Use gitbash_to_native_path only for a complete caller-declared MSYS filesystem path that a native Node fs/path API needs, especially a runtime-built value. Read native_path from the result. Never convert routes, search strings or shell/program source. Already-native or cwd-relative Node paths need no conversion; ordinary tool path fields already use their own translation. Conversion grants no access.')
133
+ lines.push(ROUTING_SAFETY)
134
+ return lines.join('\n')
135
+ }
136
+
137
+ /** Add a replaceable, idempotent block; a reused assembly follows visibility. */
138
+ export function applyToolRouting(tools, { pwshVisible = false, msys = false } = {}) {
139
+ if (!Array.isArray(tools)) return
140
+ for (const tool of tools) {
141
+ if (!tool || typeof tool.description !== 'string' || !['bash', 'run_code'].includes(tool.name)) continue
142
+ let description = tool.description
143
+ const start = description.indexOf(ROUTING_START)
144
+ const end = start < 0 ? -1 : description.indexOf(ROUTING_END, start)
145
+ if (end >= 0) description = description.slice(0, start) + description.slice(end + ROUTING_END.length)
146
+ const hint = tool.name === 'bash'
147
+ ? toolRoutingHintFor(tools, { pwshVisible }) + (msys ? '\n' + GITBASH_MSYS_ADDENDUM : '')
148
+ : PTC_ROUTING
149
+ tool.description = description + ROUTING_START + hint + ROUTING_END
150
+ }
151
+ }
152
+
121
153
  /** @deprecated composed form kept for tests: route + MSYS sentences. */
122
154
  export const GITBASH_BASH_ADDENDUM = GITBASH_ROUTE_ADDENDUM + '\n' + GITBASH_MSYS_ADDENDUM
123
155
 
@@ -158,7 +190,12 @@ export async function mountPrivateExecutor(ctx, GitBashSandboxExecutor, createSc
158
190
  if (executorScope.fiber) await executorScope.fiber
159
191
  const fiber = executorScope.ctx.plugin(GitBashSandboxExecutor)
160
192
  await fiber
161
- return executorScope.ctx.get('shell')
193
+ const executor = executorScope.ctx.get('shell')
194
+ const sandboxModule = await resolveHostModule(executorScope.ctx, '@deepseek-ai/dsh-sandbox')
195
+ if (typeof sandboxModule?.SandboxUnavailableError === 'function') {
196
+ executor.unavailableErrorType = sandboxModule.SandboxUnavailableError
197
+ }
198
+ return executor
162
199
  })(),
163
200
  mountTimeout,
164
201
  ])
@@ -198,6 +235,7 @@ export function mountInlineTools(ctx, deps) {
198
235
  let closed = false
199
236
  const isLive = (agent, state) => !closed && tracked.get(agent) === state && !state.controller.signal.aborted
200
237
  const newState = () => ({ timer: undefined, scope: undefined, denyPwsh: undefined, pending: undefined, controller: new AbortController() })
238
+ const experimentalEnabled = () => deps.experimentalEnabled?.() !== false
201
239
  // Loader imports cannot be cancelled, but no continuation may outlive its
202
240
  // owner. Join our cancelled continuations without waiting on a stuck loader.
203
241
  const untilDisposed = async (promise, signal) => {
@@ -247,6 +285,7 @@ export function mountInlineTools(ctx, deps) {
247
285
  if (typeof createScope !== 'function') throw new Error('no createScope seam')
248
286
  controller.signal.throwIfAborted()
249
287
  const mounted = await mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope)
288
+ mounted.executor.experimentalEnabled = experimentalEnabled
250
289
  executorScope = mounted.executorScope
251
290
  controller.signal.throwIfAborted()
252
291
  const hostModules = {}
@@ -255,7 +294,33 @@ export function mountInlineTools(ctx, deps) {
255
294
  }
256
295
  const { createBashToolModule } = await untilDisposed(import('./bash-tool.js'), controller.signal)
257
296
  const module = createBashToolModule({ executor: mounted.executor, log, ...hostModules })
258
- return { executor: mounted.executor, executorScope, module }
297
+ const extraModules = []
298
+ if (typeof deps.translatePath === 'function') {
299
+ const { createPathToolModule, PATH_TOOL_NAME } = await untilDisposed(import('./path-tool.js'), controller.signal)
300
+ extraModules.push({ name: PATH_TOOL_NAME, create: scope => createPathToolModule({
301
+ toolsModule: hostModules.toolsModule, translate: deps.translatePath, experimentalEnabled, log, scope,
302
+ }) })
303
+ try {
304
+ const localBashModule = await untilDisposed(resolveHostModule(ctx, '@deepseek-ai/dsh-bash-local'), controller.signal)
305
+ const outputModule = await untilDisposed(resolveHostModule(ctx, '@deepseek-ai/dsh-subprocess-local/output'), controller.signal)
306
+ if (typeof localBashModule?.LocalBashExecutor !== 'function'
307
+ || typeof outputModule?.OutputCollector !== 'function') {
308
+ throw new Error('public native executor/OutputCollector capability unavailable')
309
+ }
310
+ const { createNativeOutputAdapter } = await untilDisposed(import('./native-output.js'), controller.signal)
311
+ const { createNativeToolModule } = await untilDisposed(import('./native-tool.js'), controller.signal)
312
+ extraModules.push({ name: 'gitbash_native', create: scope => createNativeToolModule({
313
+ toolsModule: hostModules.toolsModule, sandboxModule: hostModules.sandboxModule,
314
+ localBashModule, createScope, translateMsysPath: deps.translatePath,
315
+ createNativeOutputAdapter, outputOptions: { OutputCollector: outputModule?.OutputCollector },
316
+ createArtifactDirectory: deps.createArtifactDirectory, experimentalEnabled, log, scope,
317
+ }) })
318
+ } catch (error) {
319
+ if (controller.signal.aborted) throw error
320
+ log(`native tool capability unavailable; bash/path tools remain available: ${error?.message ?? error}`)
321
+ }
322
+ }
323
+ return { executor: mounted.executor, executorScope, module, extraModules }
259
324
  } catch (error) {
260
325
  if (executorScope) await executorScope.dispose()
261
326
  throw error
@@ -271,7 +336,7 @@ export function mountInlineTools(ctx, deps) {
271
336
  let scope
272
337
  let retained = false
273
338
  try {
274
- const { executor, module } = await untilDisposed(bashOncePromise(), state.controller.signal)
339
+ const { executor, module, extraModules } = await untilDisposed(bashOncePromise(), state.controller.signal)
275
340
  if (!isLive(agent, state)) return
276
341
  log(`own bash tool ready (private Git Bash executor at ${executor.bashPath || '(unresolved)'}; official pwsh executor untouched)`)
277
342
  scope = createScope(agent.ctx)
@@ -285,19 +350,42 @@ export function mountInlineTools(ctx, deps) {
285
350
  const fiber = scope.ctx.plugin(module)
286
351
  await untilDisposed(fiber, state.controller.signal)
287
352
  if (!isLive(agent, state)) return
288
- retained = true
289
353
  // An INACTIVE fiber resolves its await() without ever running apply()
290
354
  // (issue #27): awaiting the plugin proves nothing, so read the registry
291
- // back before claiming success. A miss gets an explicit failure line —
292
- // never a silent no-tool session behind a success-shaped log.
355
+ // back before claiming success. A miss THROWS — the catch below logs the
356
+ // explicit activation failure and the finally disposes this half-mounted
357
+ // scope instead of leaking it for the agent's lifetime (shape credit:
358
+ // KouzakiUmi's PR #28).
293
359
  const registeredNames = scope.ctx.get !== undefined
294
360
  ? ((scope.ctx.get('tools')?.schemas?.(agent) ?? []).map((schema) => schema && typeof schema.name === 'string' ? schema.name : ''))
295
361
  : []
296
- if (registeredNames.includes('bash')) {
297
- log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)')
298
- } else {
299
- log('own bash tool did NOT register for this agent: the module fiber likely went INACTIVE (this host lacks an injected service the module requires) or the tools read-back is unavailable — bash is NOT available in this session; please report this with your dsh version (issue #27 pattern)')
362
+ if (registeredNames.includes('bash') === false) {
363
+ throw new Error('bash tool did not register for this agent (module fiber INACTIVE — this host lacks an injected service the module requires — or the tools read-back is unavailable); bash is NOT available in this session, please report this with your dsh version (issue #27)')
300
364
  }
365
+ for (const extra of extraModules ?? []) {
366
+ let extraFiber
367
+ try {
368
+ let applied = false
369
+ const module = extra.create(agent)
370
+ extraFiber = scope.ctx.plugin({ ...module, async apply(extraCtx, config) {
371
+ await module.apply(extraCtx, config)
372
+ applied = true
373
+ } })
374
+ await untilDisposed(extraFiber, state.controller.signal)
375
+ if (!isLive(agent, state)) return
376
+ if (!applied) {
377
+ throw new Error(`${extra.name} scope inactive (a required service may be missing)`)
378
+ }
379
+ // Registration/stand-down is reported by the shared live registrar.
380
+ log(`${extra.name} scope ready; registration follows experimentalNative and existing owners`)
381
+ } catch (error) {
382
+ if (!isLive(agent, state)) return
383
+ await extraFiber?.dispose()
384
+ log(`${extra.name} activation failed; bash remains available: ${error?.message ?? error}`)
385
+ }
386
+ }
387
+ retained = true
388
+ log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)')
301
389
  } catch (error) {
302
390
  if (isLive(agent, state)) log(`own bash tool activation failed for this agent: ${error?.message ?? error}`)
303
391
  } finally {
@@ -402,7 +490,7 @@ export function mountInlineTools(ctx, deps) {
402
490
  log(`agent bash tool cleanup failed: ${error?.message ?? error}`)
403
491
  }))
404
492
  ctx.on('loader/volatile-update', (paths) => {
405
- if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && path[0] === 'pwshTool')) return
493
+ if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && ['pwshTool', 'experimentalNative'].includes(path[0]))) return
406
494
  for (const agent of tracked.keys()) {
407
495
  try { reconcile(agent) } catch (error) { log(`pwsh switch update failed: ${error?.message ?? error}`) }
408
496
  }