dsh-gitbash-shell 0.34.3 → 0.35.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.
package/src/index.js CHANGED
@@ -56,6 +56,8 @@ import { fileURLToPath } from 'node:url'
56
56
 
57
57
  import { PRESET_META, minimalPluginsFor, pluginsFor } from './compositions.js'
58
58
  import { loadSchemastery } from './schemastery.js'
59
+ import { GITBASH_MSYS_ADDENDUM, GITBASH_ROUTE_ADDENDUM, mountInlineTools, resolveHostPwsh7 } from './inline-tools.js'
60
+ import { mountFastctx } from './fastctx-bridge.js'
59
61
 
60
62
  /**
61
63
  * The executor base class's own Config schema, when this deployment has it.
@@ -119,6 +121,21 @@ const MARKER_FILE = '.plugin-managed.json'
119
121
  /** Default preset ids this plugin materializes, in roster order (configurable). */
120
122
  export const PRESET_IDS = ['standard-gitbash', 'minimal-gitbash', 'code-gitbash', 'cordis-gitbash']
121
123
 
124
+ /**
125
+ * Variant preset id -> the OFFICIAL successor id, for the deployment-default
126
+ * migration (v0.35.0). Every target is verified against the LIVE roster
127
+ * before any write — the official ids renamed once already (code→ptc), so a
128
+ * missing successor stands the migration down instead of writing an unknown
129
+ * id. `code-gitbash` deliberately maps to `ptc`: that variant has described
130
+ * the official PTC mode since dsh 0.1.2 renamed it.
131
+ */
132
+ export const VARIANT_DEFAULT_SUCCESSORS = Object.freeze({
133
+ 'standard-gitbash': 'standard',
134
+ 'minimal-gitbash': 'minimal',
135
+ 'code-gitbash': 'ptc',
136
+ 'cordis-gitbash': 'cordis',
137
+ })
138
+
122
139
  /**
123
140
  * The variant dsh-ptc-cordis-preset already covers while cooperating: its
124
141
  * `PTC 创造模式` is materialized against Git Bash too, so our own
@@ -474,6 +491,30 @@ export const Config = Schema === null ? undefined : Schema.object({
474
491
  * changing it on either side changes it here.
475
492
  */
476
493
  suppressPeerCordis: live(Schema.boolean().default(false)),
494
+ /**
495
+ * Serve the four `* · Git Bash` VARIANT presets (v0.34.x behaviour) instead
496
+ * of relying on the inline tool patch (v0.35.0). Default OFF for every user
497
+ * — new and existing — per the owner's explicit call: the official presets
498
+ * carry Git Bash through the per-agent tool patch, the roster stays the
499
+ * official four, and the variants remain one switch away for anyone who
500
+ * wants the duplicated roster back. OFF also stops the reconcile from
501
+ * registering variants; a session pinned to a variant id must re-select an
502
+ * official preset (the roster flip is announced in the CHANGELOG).
503
+ */
504
+ variantPresets: live(Schema.boolean().default(false)),
505
+ /**
506
+ * Publish `gitbash_pwsh` — a REAL PowerShell 7 tool over ctx.subprocess —
507
+ * while the host `pwsh` tool is denied (its executor is Git Bash on this
508
+ * host). Default ON; requires pwsh 7 on the machine (never a bundled
509
+ * payload) and danger-full-access per session.
510
+ */
511
+ pwshTool: live(Schema.boolean().default(true)),
512
+ /**
513
+ * Publish the FastCtx file tools (`gb_*`) through the local MCP bridge.
514
+ * Default ON; Windows-only payload, stands down entirely when dsh-ops is
515
+ * present (its ops_* tools already cover the same ground).
516
+ */
517
+ fastctxTools: live(Schema.boolean().default(true)),
477
518
  })
478
519
 
479
520
  /** Read one Config value across eras: Volatile ref (>= 0.1.7) or plain value. */
@@ -533,6 +574,13 @@ function makeLiveReader(ctx, config) {
533
574
  adoptSidebar: valueOf(config.adoptSidebar) !== false,
534
575
  // Default ON (v0.29.0): the official terminal is ours unless opted out.
535
576
  autoTerminalShell: valueOf(config.autoTerminalShell) !== false,
577
+ // Default OFF (v0.35.0): the official presets carry Git Bash through the
578
+ // inline tool patch; the variant roster is opt-in.
579
+ variantPresets: valueOf(config.variantPresets) === true,
580
+ // Default ON (v0.35.0): the pwsh replacement fills the denied host tool.
581
+ pwshTool: valueOf(config.pwshTool) !== false,
582
+ // Default ON (v0.35.0): the FastCtx bridge stands down when dsh-ops is present.
583
+ fastctxTools: valueOf(config.fastctxTools) !== false,
536
584
  }
537
585
  }
538
586
  return {
@@ -540,6 +588,9 @@ function makeLiveReader(ctx, config) {
540
588
  posix: () => dialect().posixPaths === true,
541
589
  adoptSidebar: () => dialect().adoptSidebar !== false,
542
590
  autoTerminalShell: () => dialect().autoTerminalShell !== false,
591
+ variantPresets: () => (hasConfig ? valueOf(config.variantPresets) === true : false),
592
+ pwshTool: () => (hasConfig ? valueOf(config.pwshTool) !== false : true),
593
+ fastctxTools: () => (hasConfig ? valueOf(config.fastctxTools) !== false : true),
543
594
  // Default OFF: only an explicit true asks for deduplication.
544
595
  suppressPeerCordis: () => (hasConfig ? valueOf(config.suppressPeerCordis) === true : false),
545
596
  }
@@ -1742,7 +1793,7 @@ async function registerVariant(ctx, presetId, { gitBashActive, skillsDir, python
1742
1793
  * variant's PTY row reads it once per registration (issue #15).
1743
1794
  * @returns {Promise<void>} resolves once the first reconcile finished.
1744
1795
  */
1745
- async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolution) {
1796
+ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolution, readVariants) {
1746
1797
  cleanupLegacyTrees(presetIds)
1747
1798
  const gitBashActive = process.platform === 'win32'
1748
1799
  const skillsDir = await resolveSkillsDir()
@@ -1758,7 +1809,12 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1758
1809
  let registeredPython = false
1759
1810
  let serial = Promise.resolve()
1760
1811
 
1761
- const desired = () => effectivePresetIds(presetIds, { suppress: readSuppress(), peerGitBash })
1812
+ // v0.35.0: the variant roster is opt-in (`variantPresets`, default OFF).
1813
+ // OFF means an EMPTY desired set — the cleanup above still runs, so a
1814
+ // legacy materialized tree never survives just because the switch is off.
1815
+ const desired = () => (readVariants && readVariants() === true
1816
+ ? effectivePresetIds(presetIds, { suppress: readSuppress(), peerGitBash })
1817
+ : [])
1762
1818
 
1763
1819
  const reconcile = () => {
1764
1820
  serial = serial.then(async () => {
@@ -1811,7 +1867,10 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1811
1867
  }
1812
1868
 
1813
1869
  await reconcile()
1814
- console.log(TAG + ' registered ' + live.size + ' preset(s) declaratively (' + [...live.keys()].join(', ') + ')' + (hostExtras.timeContext || hostExtras.toolSchedule
1870
+ console.log(TAG + ' registered ' + live.size + ' preset(s) declaratively (' + [...live.keys()].join(', ') + ')'
1871
+ + (live.size === 0 ? ' — variantPresets is off: the official presets carry Git Bash through the inline tool patch (see the CHANGELOG)'
1872
+ : '')
1873
+ + (hostExtras.timeContext || hostExtras.toolSchedule
1815
1874
  ? ' [dsh 0.2.1 additions: ' + (hostExtras.timeContext ? 'time-context' : '') + (hostExtras.timeContext && hostExtras.toolSchedule ? ' + ' : '') + (hostExtras.toolSchedule ? 'tool-schedule (subagents denied)' : '') + ']'
1816
1875
  : ''))
1817
1876
 
@@ -1854,12 +1913,13 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1854
1913
  console.log(TAG + ' peer capability wiring failed: ' + (error && error.message ? error.message : error))
1855
1914
  }
1856
1915
 
1857
- // The switch itself: a volatile Config edit on this row lands as a
1858
- // loader/volatile-update event on this fiber.
1916
+ // The switches themselves: a volatile Config edit on this row lands as a
1917
+ // loader/volatile-update event on this fiber (v0.35.0 adds variantPresets).
1859
1918
  try {
1860
1919
  ctx.on('loader/volatile-update', (paths) => {
1861
1920
  try {
1862
- if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && path[0] === 'suppressPeerCordis')) return
1921
+ if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path)
1922
+ && (path[0] === 'suppressPeerCordis' || path[0] === 'variantPresets'))) return
1863
1923
  void reconcile()
1864
1924
  } catch (error) {
1865
1925
  console.log(TAG + ' dedupe switch handling failed: ' + (error && error.message ? error.message : error))
@@ -1921,7 +1981,17 @@ export async function apply(ctx, config = {}) {
1921
1981
  const readConfiguredBashPath = () => effectiveConfiguredBashPath(executorConfiguredBashPath(ctx), rowBashPath(config))
1922
1982
  const readBashResolution = () => resolveGitBashCached({ configured: readConfiguredBashPath() })
1923
1983
  const gitBashCapability = {
1984
+ // v0.35.0 semantics, unchanged shape: `active` answers "does command
1985
+ // execution run on Git Bash on this host" — TRUE regardless of the
1986
+ // variantPresets switch, because the inline tool patch carries the
1987
+ // official presets too. Peers (dsh-ptc-cordis-preset) key their roster
1988
+ // wording and dedupe on exactly this fact.
1924
1989
  active: process.platform === 'win32',
1990
+ // Additive (v0.35.1): the inline tool patch gives EVERY OFFICIAL preset
1991
+ // Git Bash, so a roster suffix like "· Git Bash" no longer distinguishes
1992
+ // anything — peers reading this flag drop the suffix (dsh-ptc-cordis-preset
1993
+ // v0.17.3+); older peers ignore it and keep the v0.34 wording.
1994
+ get inlinePatched() { return process.platform === 'win32' },
1925
1995
  // Kept for existing consumers (peer plugins read `bashPath`): the resolved
1926
1996
  // interpreter. When resolution failed we report the explicit value (or the
1927
1997
  // historical default) so the ENOENT a consumer may hit names a real path —
@@ -2015,6 +2085,21 @@ export async function apply(ctx, config = {}) {
2015
2085
  no run_code hint — so they meet the shell they were composed for. */
2016
2086
  const dialect = liveSettings.dialect()
2017
2087
  const applies = dialectApplies(dialect, assembleContext && assembleContext.agent)
2088
+ // v0.35.0: shell-routing addendum on the `bash` tool's description.
2089
+ // Description is schema-level text, so even the minimal preset's
2090
+ // complete-persona cannot strip it; the ROUTING sentence applies to
2091
+ // every agent (routing is dialect-independent), while the MSYS
2092
+ // sentence only describes the world the dialect switch creates.
2093
+ // Idempotent by marker: assemble may run more than once per agent.
2094
+ if (assembly && typeof assembly === 'object' && Array.isArray(assembly.tools)) {
2095
+ for (const tool of assembly.tools) {
2096
+ if (tool && tool.name === 'bash' && typeof tool.description === 'string'
2097
+ && !tool.description.includes('Prefer this bash tool')) {
2098
+ tool.description += '\n' + GITBASH_ROUTE_ADDENDUM
2099
+ + (applies && dialect.posixPaths === true ? '\n' + GITBASH_MSYS_ADDENDUM : '')
2100
+ }
2101
+ }
2102
+ }
2018
2103
  if (applies && dialect.posixPaths === true && assembly && typeof assembly === 'object') {
2019
2104
  for (const section of Array.isArray(assembly.sections) ? assembly.sections : []) {
2020
2105
  if (section && typeof section.text === 'string') section.text = windowsToMsys(section.text)
@@ -2509,13 +2594,149 @@ export async function apply(ctx, config = {}) {
2509
2594
  })
2510
2595
  }
2511
2596
 
2597
+ // ── inline tool patch (v0.35.0): Git Bash for the OFFICIAL presets ──────
2598
+ // The official preset rows compose `tool-bash` as win32-disabled, and the
2599
+ // patch layer cannot reach rows nested in a preset declaration (buildMap
2600
+ // only descends into group rows), so the fix runs at the tool layer: per
2601
+ // agent, a face of "pwsh yes / bash no" gets the OFFICIAL tool-bash
2602
+ // activated in an agent-owned scope plus a deny on the fake pwsh (its
2603
+ // executor is ctx.shell, which here IS Git Bash), and the assemble
2604
+ // waterfall carries the bash-first routing sentence. The variantPresets
2605
+ // switch is honored BY OBSERVATION — a variant agent's face already has
2606
+ // bash, so this patch never fires there.
2607
+ if (process.platform === 'win32') {
2608
+ try {
2609
+ mountInlineTools(ctx, {
2610
+ tools: (c) => { try { return c.get('tools') } catch { return undefined } },
2611
+ createScope: (agentCtx) => {
2612
+ let childCtx
2613
+ const fiber = agentCtx.plugin((c) => { childCtx = c })
2614
+ return {
2615
+ ctx: childCtx,
2616
+ dispose: async () => { try { await fiber?.dispose?.() } catch { /* already gone */ } },
2617
+ }
2618
+ },
2619
+ allowed: (session) => {
2620
+ try { return ctx.get('sandboxPolicy')?.resolve({ session })?.mode === 'danger-full-access' } catch { return false }
2621
+ },
2622
+ spawn: (spec) => {
2623
+ const subprocess = ctx.get('subprocess')
2624
+ if (!subprocess || typeof subprocess.spawn !== 'function') throw new Error('the host subprocess service is not available on this deployment')
2625
+ return subprocess.spawn(spec)
2626
+ },
2627
+ pwshPath: resolveHostPwsh7(),
2628
+ pwshEnabled: () => liveSettings.pwshTool(),
2629
+ msysToWin: (p) => translateMsysPath(p, buildTranslateEnv(readConfiguredBashPath())),
2630
+ echo: (text) => (liveSettings.dialect().posixPaths === true ? windowsToMsys(text) : text),
2631
+ log: (m) => console.log(TAG + ' ' + m),
2632
+ })
2633
+ console.log(TAG + ' inline tool patch armed (official presets get Git Bash; bash-first routing enforced)')
2634
+ } catch (error) {
2635
+ console.log(TAG + ' inline tool patch wiring failed: ' + (error?.message ?? error))
2636
+ }
2637
+ }
2638
+
2639
+ // ── default-preset migration (v0.35.0, user request) ─────────────────────
2640
+ // A session records its preset by ID (`agent-preset/selected`), and the
2641
+ // registry resolves EVERY mount through `definitions.get(id)` — a variant
2642
+ // id that is not registered fails `agent-preset/not-found`, and the
2643
+ // official `select` refuses mid-session switches (`agent-preset/locked`),
2644
+ // so PINNED SESSIONS cannot be migrated by any official API. The DEPLOYMENT
2645
+ // DEFAULT, however, is one writable setting: `selectedDefault` on the
2646
+ // `agent-preset-registry` row (volatile; the official preset picker writes
2647
+ // it through `settings.update('agent-preset-registry', ...)`). While
2648
+ // variantPresets is OFF (the user did NOT keep the old modes) and that
2649
+ // default still names one of OUR variants, migrate it to the official
2650
+ // successor once — idempotent (a migrated or user-chosen official default
2651
+ // never matches PRESET_IDS again), roster-checked (the official id renamed
2652
+ // once already: code→ptc — a missing target stands down instead of
2653
+ // guessing), written through the OFFICIAL settings seam, and verified by
2654
+ // read-back (a write that did not stick is reported, never claimed).
2655
+ // With variantPresets ON nothing here runs: the variants stay registered,
2656
+ // so a variant default keeps working as-is.
2657
+ try {
2658
+ if (ctx.agentPresets && typeof ctx.agentPresets.register === 'function') {
2659
+ ctx.inject(['settings'], (settingsCtx) => {
2660
+ void (async () => {
2661
+ try {
2662
+ const settings = settingsCtx.settings
2663
+ if (!settings || typeof settings.update !== 'function') return
2664
+ // The switch is the whole contract: keep-old-modes ON means a
2665
+ // variant default is a legitimate choice and stays untouched.
2666
+ if (liveSettings.variantPresets() === true) return
2667
+ const readDefault = () => { try { return ctx.agentPresets.defaultId } catch { return undefined } }
2668
+ const current = readDefault()
2669
+ if (typeof current !== 'string' || !PRESET_IDS.includes(current)) return
2670
+ const target = VARIANT_DEFAULT_SUCCESSORS[current]
2671
+ if (typeof target !== 'string') return
2672
+ // The successor must exist in the CURRENT roster: official ids
2673
+ // renamed once already (code→ptc), and writing an unknown id
2674
+ // would trade a known failure for a worse one.
2675
+ const roster = await ctx.agentPresets.list()
2676
+ if (!Array.isArray(roster) || !roster.some((row) => row && row.id === target)) {
2677
+ console.warn(TAG + ' default-preset migration stood down: the deployment default is the variant "'
2678
+ + current + '" but the successor "' + target + '" is not registered on this host.'
2679
+ + ' Set the default manually among the OFFICIAL presets, or turn variantPresets on.')
2680
+ return
2681
+ }
2682
+ try {
2683
+ await settings.update('agent-preset-registry', { selectedDefault: target })
2684
+ } catch (error) {
2685
+ console.warn(TAG + ' default-preset migration write failed (' + (error?.message ?? error)
2686
+ + '): the deployment default is still the variant "' + current
2687
+ + '" — NEW sessions would fail with agent-preset/not-found.'
2688
+ + ' Fix: set an OFFICIAL default in the preset picker, or turn variantPresets on.')
2689
+ return
2690
+ }
2691
+ const after = readDefault()
2692
+ if (after === target) {
2693
+ console.log(TAG + ' deployment default migrated: "' + current + '" -> "' + target
2694
+ + '" (variantPresets is off). Old sessions pinned to a variant still need a NEW'
2695
+ + ' session — the official agent-preset/locked rule blocks a mid-session switch.')
2696
+ } else {
2697
+ console.warn(TAG + ' default-preset migration did not stick (read back "'
2698
+ + (after === undefined ? 'unreadable' : after) + '"). Set the default manually in'
2699
+ + ' Settings -> agent preset, or turn variantPresets on.')
2700
+ }
2701
+ } catch (error) {
2702
+ console.log(TAG + ' default-preset migration failed: ' + (error?.message ?? error))
2703
+ }
2704
+ })()
2705
+ })
2706
+ }
2707
+ } catch (error) {
2708
+ console.log(TAG + ' default-preset migration wiring failed: ' + (error?.message ?? error))
2709
+ }
2710
+
2711
+ // ── FastCtx bridge (v0.35.0): the gb_* file tools, default ON ────────────
2712
+ // Additions, never replacements: the official read/grep/glob/edit tools and
2713
+ // their file sandbox stay first choice in every description. The bridge
2714
+ // mounts nothing outside win32, nothing when the payload is absent, and
2715
+ // stands down entirely the moment dsh-ops (ops_* tools) is detected.
2716
+ if (process.platform === 'win32') {
2717
+ try {
2718
+ const { createRequire } = await import('node:module')
2719
+ mountFastctx(ctx, {
2720
+ platform: process.platform,
2721
+ enabled: () => liveSettings.fastctxTools(),
2722
+ createRequireFrom: (base) => createRequire(base ?? import.meta.url),
2723
+ tools: (c) => { try { return c.get('tools') } catch { return undefined } },
2724
+ msysToWin: (p) => translateMsysPath(p, buildTranslateEnv(readConfiguredBashPath())),
2725
+ echo: (text) => (liveSettings.dialect().posixPaths === true ? windowsToMsys(text) : text),
2726
+ log: (m) => console.log(TAG + ' ' + m),
2727
+ })
2728
+ } catch (error) {
2729
+ console.log(TAG + ' FastCtx bridge wiring failed: ' + (error?.message ?? error))
2730
+ }
2731
+ }
2732
+
2512
2733
  // ── era split: declarative registration on dsh >= 0.1.7 ──────────────────
2513
2734
  // The register method IS the era signal. On the new host the whole
2514
2735
  // materialization path below is dead code (nothing reads the directory any
2515
2736
  // more), so this branch serves the variants and returns.
2516
2737
  if (ctx.agentPresets && typeof ctx.agentPresets.register === 'function') {
2517
2738
  try {
2518
- await runDeclarativeEra(ctx, presetIds, () => liveSettings.suppressPeerCordis(), readBashResolution)
2739
+ await runDeclarativeEra(ctx, presetIds, () => liveSettings.suppressPeerCordis(), readBashResolution, () => liveSettings.variantPresets())
2519
2740
  } catch (error) {
2520
2741
  console.log(TAG + ' declarative registration failed: ' + (error && error.message ? error.message : error))
2521
2742
  }
@@ -2524,4 +2745,4 @@ export async function apply(ctx, config = {}) {
2524
2745
 
2525
2746
  }
2526
2747
  // Test surface: pure helpers, no Cordis context required.
2527
- export const _internal = { PRESET_IDS, PEER_COVERED_PRESET_ID, PEER_CAPABILITY, isDelegatedAgent, dialectApplies, PEER_PYTHON_FIELD, PEER_PYTHON_BACKEND_FIELD, peerFact, peerBackend, pythonBackendActive, effectivePresetIds, translateDispatch, translateMsysPath, translatePathArguments, rewriteCodePaths, scanCodeLiterals, programPrelude, translateGlobArguments, buildTranslateEnv, rewriteErrorContent, rewriteErrorMessage, rewriteFailureMessage, msysEcho, driveToMsys, pathEcho, ERROR_CONTENT_TOOLS, windowsToMsys, rewriteResultPaths, adoptSidebarShell, MARKER_FILE, classify, hashTree, installRegisterShim, probeHostExtras }
2748
+ export const _internal = { PRESET_IDS, VARIANT_DEFAULT_SUCCESSORS, PEER_COVERED_PRESET_ID, PEER_CAPABILITY, isDelegatedAgent, dialectApplies, PEER_PYTHON_FIELD, PEER_PYTHON_BACKEND_FIELD, peerFact, peerBackend, pythonBackendActive, effectivePresetIds, translateDispatch, translateMsysPath, translatePathArguments, rewriteCodePaths, scanCodeLiterals, programPrelude, translateGlobArguments, buildTranslateEnv, rewriteErrorContent, rewriteErrorMessage, rewriteFailureMessage, msysEcho, driveToMsys, pathEcho, ERROR_CONTENT_TOOLS, windowsToMsys, rewriteResultPaths, adoptSidebarShell, MARKER_FILE, classify, hashTree, installRegisterShim, probeHostExtras }