dsh-gitbash-shell 0.39.1 → 0.39.3

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
@@ -612,15 +612,15 @@ const POSIX_DIRECTIVE_TEXT_STRICT = 'The working shell is Git for Windows bash:
612
612
  * write paths into, so the sentence would be pure noise there. Cheap and
613
613
  * honest: the tool list of the very assembly being rendered decides.
614
614
  */
615
- const RUN_CODE_DIRECTIVE_TEXT = ' Path literals written inside a run_code program are translated the same way before the program runs; shell expansion is not available there, so spell paths out instead of relying on $VARS.'
616
- const RUN_CODE_DIRECTIVE_STRICT_TEXT = ' Path literals written inside a run_code program are translated before the program runs.'
615
+ const RUN_CODE_DIRECTIVE_TEXT = ' In a run_code program, compatible plain MSYS path literals are translated on a best-effort basis; interpolated or tagged templates and uncertain syntax are not rewritten. For direct native Node fs/path APIs, prefer explicit Windows drive-letter or cwd-relative paths. Do not rely on literal rewriting for dynamic paths, shell command strings, routes or search data; tool path arguments are translated separately. Shell expansion is unavailable in program strings, so do not rely on $VARS.'
616
+ const RUN_CODE_DIRECTIVE_OFF_TEXT = ' In a run_code program, automatic path-literal rewriting is OFF. Direct native Node fs/path APIs require Windows drive-letter or cwd-relative paths, not MSYS /c/... strings. Tool path arguments are still translated separately; program strings do not perform shell expansion.'
617
617
 
618
618
  /** The run_code sentence for this assembly, or '' when the tool is not offered. */
619
- export function runCodeHintFor(tools) {
619
+ export function runCodeHintFor(tools, codePaths = true) {
620
620
  if (!Array.isArray(tools)) return ''
621
621
  for (const tool of tools) {
622
622
  const name = tool && typeof tool.name === 'string' ? tool.name : ''
623
- if (name === 'run_code') return RUN_CODE_DIRECTIVE_TEXT
623
+ if (name === 'run_code') return codePaths === true ? RUN_CODE_DIRECTIVE_TEXT : RUN_CODE_DIRECTIVE_OFF_TEXT
624
624
  }
625
625
  return ''
626
626
  }
@@ -1155,20 +1155,26 @@ export function translatePathArguments(args, env) {
1155
1155
  // sees it (see AGENTS.md 4c). The same mount table is therefore applied to the
1156
1156
  // program's string literals — but ONLY when the whole program scans cleanly and
1157
1157
  // a literal's ENTIRE content is an absolute MSYS path. Anything unclear — an
1158
- // unterminated literal, a template with ${} interpolation, a regex we cannot
1159
- // place — turns the whole rewrite into a no-op: a half-rewritten program would
1158
+ // unterminated literal or a regex we cannot place — turns the whole rewrite
1159
+ // into a no-op. Interpolated/tagged templates are skipped as data. A partial guess would
1160
1160
  // be far worse than an untranslated one, and this layer never guesses.
1161
1161
  //
1162
1162
  // Deliberately NOT translated: shell-style '$VAR/x' (a JS literal is data, not
1163
1163
  // a shell word) and anything carrying a scheme ('http://...').
1164
1164
 
1165
1165
  /** Source text allowed inside a rewritten literal: no escapes, no dollar sign. */
1166
- const CODE_LITERAL_SAFE = /^[A-Za-z0-9._@+~\-/ ]+$/
1166
+ const CODE_LITERAL_SAFE = /^[\p{L}\p{M}\p{N}._@+~#\-/ ]+$/u
1167
1167
 
1168
1168
  /** Previous significant character that lets a '/' start a regex literal. */
1169
1169
  function regexCanStart(previous) {
1170
1170
  if (previous === '') return true
1171
- return '(,=:[!&|?{};+-*%<>~^'.indexOf(previous) >= 0
1171
+ if (['return', 'throw', 'yield', 'await', 'case', 'delete', 'void', 'typeof', 'in', 'instanceof', 'of', 'else', 'do', 'break', 'continue', 'debugger'].includes(previous)) return true
1172
+ return previous.length === 1 && '(,=:[!&|?{;+-*%<>~^'.includes(previous)
1173
+ }
1174
+
1175
+ /** Read an identifier as ONE token: keyword-led regexes are not division. */
1176
+ function codeWordAt(code, index) {
1177
+ return /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(code.slice(index))?.[0]
1172
1178
  }
1173
1179
 
1174
1180
  /** Index after the closing quote of the literal at `index`, or -1. */
@@ -1239,6 +1245,15 @@ function skipBracedExpression(code, index) {
1239
1245
  last = 'x'
1240
1246
  continue
1241
1247
  }
1248
+ // A slash after a closing group may start a statement regex OR division.
1249
+ // Without a parser that distinction is uncertain: abort the WHOLE scan.
1250
+ if (c === '/' && (last === ')' || last === '}')) return -1
1251
+ const word = codeWordAt(code, i)
1252
+ if (word) {
1253
+ last = last === '.' ? 'x' : word
1254
+ i += word.length
1255
+ continue
1256
+ }
1242
1257
  if (c === '/' && regexCanStart(last)) {
1243
1258
  const end = skipRegexLiteral(code, i)
1244
1259
  if (end === -1) return -1
@@ -1294,7 +1309,8 @@ export function scanCodeLiterals(code) {
1294
1309
  const next = code[i + 1]
1295
1310
  if (c === '/' && next === '/') {
1296
1311
  const nl = code.indexOf('\n', i)
1297
- if (nl === -1) return null
1312
+ // A line comment may legally terminate at EOF (outside interpolation).
1313
+ if (nl === -1) break
1298
1314
  i = nl
1299
1315
  continue
1300
1316
  }
@@ -1315,11 +1331,25 @@ export function scanCodeLiterals(code) {
1315
1331
  if (c === '`') {
1316
1332
  const template = skipTemplateLiteral(code, i)
1317
1333
  if (template === null) return null
1318
- if (template.plain) spans.push({ start: i + 1, end: template.end - 1 })
1334
+ // A preceding expression can be a tag (String.raw, fn(), obj[key], ...).
1335
+ // Tags own raw/cooked semantics: never rewrite their template data.
1336
+ // Contextual words can also be identifiers/tags; skip rather than guess.
1337
+ if (template.plain && regexCanStart(last) && !['of', 'await', 'yield'].includes(last)) {
1338
+ spans.push({ start: i + 1, end: template.end - 1 })
1339
+ }
1319
1340
  i = template.end
1320
1341
  last = 'x'
1321
1342
  continue
1322
1343
  }
1344
+ // A slash after a closing group may start a statement regex OR division.
1345
+ // Without a parser that distinction is uncertain: abort the WHOLE scan.
1346
+ if (c === '/' && (last === ')' || last === '}')) return null
1347
+ const word = codeWordAt(code, i)
1348
+ if (word) {
1349
+ last = last === '.' ? 'x' : word
1350
+ i += word.length
1351
+ continue
1352
+ }
1323
1353
  if (c === '/' && regexCanStart(last)) {
1324
1354
  const end = skipRegexLiteral(code, i)
1325
1355
  if (end === -1) return null
@@ -1380,8 +1410,8 @@ export function rewriteCodePaths(code, env) {
1380
1410
  // is written into, and a quote inside it (a home directory with an
1381
1411
  // apostrophe) would end that literal early — refuse rather than corrupt.
1382
1412
  const quote = code[span.start - 1]
1383
- if (translated.indexOf(quote) >= 0) continue
1384
- if (quote === '`' && translated.indexOf('${') >= 0) continue
1413
+ if (translated.indexOf(quote) >= 0) return code
1414
+ if (quote === '`' && translated.indexOf('${') >= 0) return code
1385
1415
  const source = translated.replace(/\\/g, '\\\\')
1386
1416
  out += code.slice(cursor, span.start) + source
1387
1417
  cursor = span.end
@@ -1465,13 +1495,15 @@ export function translateGlobArguments(args, env) {
1465
1495
  // captured and restored on disposal (reload/update/uninstall), so removing
1466
1496
  // this plugin returns the sidebar to what it was before.
1467
1497
 
1468
- const SIDEBAR_NS = 'dsh-better-sidebar'
1498
+ // The settings namespace is the row id in dsh-better-sidebar's own bundle
1499
+ // patch (`- id: better-sidebar`; verified against the published 0.25.0
1500
+ // tarball) — NOT the npm package name. Using the package name made
1501
+ // settings.describe() never match and every takeover silently no-op with one
1502
+ // "unavailable" log line (issue #25, Bug 3).
1503
+ const SIDEBAR_NS = 'better-sidebar'
1469
1504
  const SIDEBAR_TRIES = 12
1470
1505
  const SIDEBAR_RETRY_MS = 1500
1471
1506
 
1472
- // Reconciler installed by adoptSidebarShell; a no-op until then.
1473
- let reconcileSidebar = () => {}
1474
-
1475
1507
  /**
1476
1508
  * Reconcile the better-sidebar `terminalShell` takeover with the adoptSidebar
1477
1509
  * setting (v0.15.0, default ON). ON writes our bashPath (remembering what was
@@ -1491,95 +1523,69 @@ function adoptSidebarShell(ctx, bashPath, readAdopt) {
1491
1523
  let previous = ''
1492
1524
  let tried = 0
1493
1525
  let timer = null
1494
- ctx.effect(() => () => { if (timer) clearTimeout(timer) }, 'dsh-gitbash-shell: sidebar adoption polling')
1495
-
1526
+ let closed = false
1527
+ let serial = Promise.resolve()
1496
1528
  const readShell = () => {
1497
1529
  const settings = ctx.get('settings')
1498
1530
  if (!settings || typeof settings.update !== 'function') return null
1499
1531
  try {
1500
- // The sidebar's value lives in ITS row Config, projected into the
1501
- // settings describe() list; the service exposes describe/update only
1502
- // (the old `get(ns)` reader died with dsh 0.1.6).
1503
- if (typeof settings.describe === 'function') {
1504
- const entry = settings.describe().find((row) => row !== null && typeof row === 'object' && row.ns === SIDEBAR_NS)
1505
- const value = entry === undefined ? undefined : entry.value
1506
- return value && typeof value === 'object' && typeof value.terminalShell === 'string' ? value.terminalShell : ''
1507
- }
1508
- return null
1509
- } catch {
1510
- return null // namespace not registered yet / sidebar absent
1532
+ if (typeof settings.describe !== 'function') return null
1533
+ const entry = settings.describe().find((row) => row && row.ns === SIDEBAR_NS)
1534
+ const value = entry?.value
1535
+ return value && typeof value.terminalShell === 'string' ? value.terminalShell : ''
1536
+ } catch { return null }
1537
+ }
1538
+ const restore = async () => {
1539
+ if (!adopted) return
1540
+ const now = readShell()
1541
+ if (now === null) return
1542
+ // The user may have selected another shell since our takeover.
1543
+ if (now === bashPath) {
1544
+ await ctx.get('settings').update(SIDEBAR_NS, { terminalShell: previous })
1545
+ console.log(TAG + " better-sidebar shell restored ('" + previous + "')")
1511
1546
  }
1547
+ adopted = false
1512
1548
  }
1513
-
1514
- reconcileSidebar = (desired) => {
1549
+ const reconcileSidebar = async (desired) => {
1515
1550
  const current = readShell()
1516
1551
  if (current === null) return false
1517
- const settings = ctx.get('settings')
1518
- if (desired && current !== bashPath) {
1519
- const was = current
1520
- settings.update(SIDEBAR_NS, { terminalShell: bashPath })
1521
- .then(() => {
1522
- previous = was
1523
- adopted = true
1524
- console.log(
1525
- `${TAG} dsh-better-sidebar terminal shell -> Git Bash (${bashPath})` +
1526
- (was ? ` (took over from '${was}')` : ''),
1527
- )
1528
- })
1529
- .catch((error) => {
1530
- console.log(`${TAG} better-sidebar shell adoption unavailable: ${error?.message ?? error}`)
1531
- })
1532
- return true
1533
- }
1534
- if (desired && current === bashPath) {
1535
- adopted = true
1536
- return true
1537
- }
1538
- if (!desired && adopted && current === bashPath) {
1539
- settings.update(SIDEBAR_NS, { terminalShell: previous })
1540
- .then(() => {
1541
- adopted = false
1542
- console.log(`${TAG} dsh-better-sidebar terminal shell restored ('${previous || 'sidebar default'}') — adoptSidebar off`)
1543
- })
1544
- .catch(() => {})
1545
- return true
1546
- }
1552
+ if (!desired) { await restore(); return true }
1553
+ if (adopted || current === bashPath) return true
1554
+ previous = current
1555
+ await ctx.get('settings').update(SIDEBAR_NS, { terminalShell: bashPath })
1556
+ adopted = true
1557
+ console.log(TAG + ' dsh-better-sidebar terminal shell -> Git Bash (' + bashPath + ')')
1547
1558
  return true
1548
1559
  }
1549
-
1550
1560
  const run = () => {
1551
- tried += 1
1552
- const ok = reconcileSidebar(readAdopt())
1553
- if (ok) return
1554
- if (tried < SIDEBAR_TRIES) {
1555
- timer = setTimeout(run, SIDEBAR_RETRY_MS)
1556
- return
1557
- }
1558
- console.log(`${TAG} better-sidebar shell adoption skipped: settings service unavailable after ${tried} tries`)
1559
- }
1560
- run.watched = false
1561
- void run()
1562
-
1563
- // Dispose: while we still hold the value, restore what was there before.
1564
- let reverted = false
1565
- ctx.effect(
1566
- () => () => {
1567
- if (reverted || !adopted) return
1568
- reverted = true
1569
- const s = ctx.get('settings')
1570
- if (!s || typeof s.update !== 'function') return
1571
- // Era-aware read (v0.25.1): `settings.get(ns)` is gone on dsh >= 0.1.7, so
1572
- // the old call threw straight into the catch and the takeover was NEVER
1573
- // reverted when this plugin unloaded — same missed-era-read class as
1574
- // issue #8 (readShell above carries both branches).
1575
- const now = readShell()
1576
- if (now === null) return
1577
- if (String(now) === bashPath) {
1578
- s.update(SIDEBAR_NS, { terminalShell: previous }).catch(() => {})
1579
- }
1580
- },
1581
- 'dsh-gitbash-shell: sidebar shell revert',
1582
- )
1561
+ if (closed) return serial
1562
+ if (timer) { clearTimeout(timer); timer = null }
1563
+ serial = serial.then(async () => {
1564
+ if (closed) return
1565
+ tried += 1
1566
+ const ok = await reconcileSidebar(readAdopt())
1567
+ if (ok || closed) return
1568
+ if (tried < SIDEBAR_TRIES) timer = setTimeout(run, SIDEBAR_RETRY_MS)
1569
+ else console.log(TAG + ' better-sidebar shell adoption skipped: settings unavailable')
1570
+ }).catch((error) => {
1571
+ console.log(TAG + ' better-sidebar shell adoption unavailable: ' + (error?.message ?? error))
1572
+ })
1573
+ return serial
1574
+ }
1575
+ try {
1576
+ ctx.on('loader/volatile-update', (paths) => {
1577
+ if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && path[0] === 'adoptSidebar')) return
1578
+ tried = 0
1579
+ void run()
1580
+ })
1581
+ } catch { /* a diagnostic context may have no event service */ }
1582
+ ctx.effect(() => async () => {
1583
+ closed = true
1584
+ if (timer) clearTimeout(timer)
1585
+ await serial
1586
+ await restore()
1587
+ }, 'dsh-gitbash-shell: sidebar shell revert')
1588
+ return run()
1583
1589
  }
1584
1590
 
1585
1591
  // ── plugin ──────────────────────────────────────────────────────────────────
@@ -1759,6 +1765,38 @@ export function minimalPtyBashPath(readResolution) {
1759
1765
  * failures stay visible through the roster's broken diagnostic instead of
1760
1766
  * failing the boot.
1761
1767
  */
1768
+ async function migrateVariantDefault(ctx, readVariants) {
1769
+ if (readVariants() === true) return true
1770
+ const readDefault = () => { try { return ctx.agentPresets.defaultId } catch { return undefined } }
1771
+ const current = readDefault()
1772
+ if (typeof current !== 'string' || !PRESET_IDS.includes(current)) return true
1773
+ const target = VARIANT_DEFAULT_SUCCESSORS[current]
1774
+ try {
1775
+ const settings = ctx.get('settings')
1776
+ if (!settings || typeof settings.update !== 'function') return false
1777
+ const roster = await ctx.agentPresets.list()
1778
+ if (!Array.isArray(roster) || !roster.some((row) => row && row.id === target)) {
1779
+ console.warn(TAG + ' default-preset migration stood down: successor "' + target + '" is not registered. Set an OFFICIAL default or turn variantPresets on.')
1780
+ return false
1781
+ }
1782
+ // A roster read is asynchronous: never overwrite a newer user selection.
1783
+ if (readVariants() === true || readDefault() !== current) return true
1784
+ await settings.update('agent-preset-registry', { selectedDefault: target })
1785
+ const after = readDefault()
1786
+ if (after !== target) {
1787
+ console.warn(TAG + ' default-preset migration did not stick (read back "' + after + '"). Set an OFFICIAL default or turn variantPresets on.')
1788
+ return false
1789
+ }
1790
+ console.log(TAG + ' deployment default migrated: "' + current + '" -> "' + target
1791
+ + '" (variantPresets is off). Pinned sessions are unchanged; agent-preset/locked still requires a NEW session.')
1792
+ return true
1793
+ } catch (error) {
1794
+ console.warn(TAG + ' default-preset migration failed: ' + (error?.message ?? error)
1795
+ + '. Set an OFFICIAL default or turn variantPresets on; variants were not retired.')
1796
+ return false
1797
+ }
1798
+ }
1799
+
1762
1800
  async function registerVariant(ctx, presetId, { gitBashActive, skillsDir, pythonActive, hostExtras = {}, readBashResolution }) {
1763
1801
  const meta = PRESET_META[presetId]
1764
1802
  if (!meta) return undefined
@@ -1811,6 +1849,17 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1811
1849
  /** The CPython fact the LIVE registrations were built with. */
1812
1850
  let registeredPython = false
1813
1851
  let serial = Promise.resolve()
1852
+ let closed = false
1853
+ ctx.effect(() => async () => {
1854
+ closed = true
1855
+ await serial
1856
+ const failures = []
1857
+ for (const [presetId, off] of [...live]) {
1858
+ live.delete(presetId)
1859
+ try { await off() } catch (error) { failures.push(error) }
1860
+ }
1861
+ if (failures.length) throw new AggregateError(failures, 'Git Bash preset cleanup failed')
1862
+ }, 'dsh-gitbash-shell: declarative preset registrations')
1814
1863
 
1815
1864
  // v0.35.0: the variant roster is opt-in (`variantPresets`, default OFF).
1816
1865
  // OFF means an EMPTY desired set — the cleanup above still runs, so a
@@ -1820,8 +1869,16 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1820
1869
  : [])
1821
1870
 
1822
1871
  const reconcile = () => {
1872
+ if (closed) return serial
1823
1873
  serial = serial.then(async () => {
1874
+ if (closed) return
1824
1875
  const want = desired()
1876
+ const pythonActive = peerPython
1877
+ // Dynamic OFF repairs the deployment default BEFORE retiring its id.
1878
+ // Keep registrations if the official migration cannot be verified.
1879
+ if (live.size > 0 && readVariants() !== true
1880
+ && !await migrateVariantDefault(ctx, readVariants)) return
1881
+ if (closed) return
1825
1882
  // The peer's EFFECTIVE CPython backend rewrites the workflow rows of
1826
1883
  // EVERY variant (the official Python composition disables them, and
1827
1884
  // workflow-ptc refuses a non-TypeScript backend), so a flip re-registers
@@ -1829,14 +1886,16 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1829
1886
  // as the backend swap itself: the peer's patch is evaluated at boot. A
1830
1887
  // bare INTENT without the effective backend changes nothing here — the
1831
1888
  // composition still runs Node, so the workflow rows must stay live.
1832
- if (live.size > 0 && registeredPython !== peerPython) {
1889
+ if (live.size > 0 && registeredPython !== pythonActive) {
1833
1890
  for (const [presetId, off] of [...live]) {
1834
1891
  live.delete(presetId)
1835
1892
  try {
1836
1893
  await off()
1837
1894
  console.log(TAG + " preset '" + presetId + "' retired (peer CPython backend changed; rows are rebuilt)")
1838
1895
  } catch (error) {
1896
+ live.set(presetId, off)
1839
1897
  console.log(TAG + " preset '" + presetId + "' retirement failed: " + (error && error.message ? error.message : error))
1898
+ return // never register replacements after an unverified retirement
1840
1899
  }
1841
1900
  }
1842
1901
  }
@@ -1847,14 +1906,18 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1847
1906
  await off()
1848
1907
  console.log(TAG + " preset '" + presetId + "' retired (dsh-ptc-cordis-preset covers Creation mode on Git Bash)")
1849
1908
  } catch (error) {
1909
+ live.set(presetId, off)
1850
1910
  console.log(TAG + " preset '" + presetId + "' retirement failed: " + (error && error.message ? error.message : error))
1911
+ return
1851
1912
  }
1852
1913
  }
1853
1914
  for (const presetId of want) {
1915
+ if (closed) break
1854
1916
  if (live.has(presetId)) continue
1855
1917
  try {
1856
- const unregister = await registerVariant(ctx, presetId, { gitBashActive, skillsDir, pythonActive: peerPython, hostExtras, readBashResolution })
1918
+ const unregister = await registerVariant(ctx, presetId, { gitBashActive, skillsDir, pythonActive, hostExtras, readBashResolution })
1857
1919
  if (unregister) {
1920
+ if (closed) { await unregister(); break }
1858
1921
  live.set(presetId, unregister)
1859
1922
  console.log(TAG + " preset '" + presetId + "' registered declaratively")
1860
1923
  }
@@ -1862,7 +1925,7 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1862
1925
  console.log(TAG + " preset '" + presetId + "' registration failed: " + (error && error.message ? error.message : error))
1863
1926
  }
1864
1927
  }
1865
- registeredPython = peerPython
1928
+ registeredPython = pythonActive
1866
1929
  }).catch((error) => {
1867
1930
  console.log(TAG + ' preset reconcile failed: ' + (error && error.message ? error.message : error))
1868
1931
  })
@@ -1870,6 +1933,7 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1870
1933
  }
1871
1934
 
1872
1935
  await reconcile()
1936
+ if (closed) return
1873
1937
  console.log(TAG + ' registered ' + live.size + ' preset(s) declaratively (' + [...live.keys()].join(', ') + ')'
1874
1938
  + (live.size === 0 ? ' — variantPresets is off: the official presets carry Git Bash through the inline tool patch (see the CHANGELOG)'
1875
1939
  : '')
@@ -1932,7 +1996,8 @@ async function runDeclarativeEra(ctx, presetIds, readSuppress, readBashResolutio
1932
1996
  console.log(TAG + ' dedupe switch wiring failed: ' + (error && error.message ? error.message : error))
1933
1997
  }
1934
1998
 
1935
- ctx.effect(() => () => { for (const off of live.values()) void off() }, 'dsh-gitbash-shell: declarative preset registrations')
1999
+ // If settings appears after an OFF transition failed closed, retry it.
2000
+ ctx.inject(['settings'], () => { void reconcile() })
1936
2001
  }
1937
2002
 
1938
2003
  export async function apply(ctx, config = {}) {
@@ -2125,7 +2190,7 @@ export async function apply(ctx, config = {}) {
2125
2190
  // v0.20.1: the run_code sentence rides the SAME context entry, but
2126
2191
  // only when this assembly actually offers the tool (see
2127
2192
  // runCodeHintFor) — every other mode stays quiet.
2128
- const hint = runCodeHintFor(assembly.tools)
2193
+ const hint = runCodeHintFor(assembly.tools, dialect.codePaths === true)
2129
2194
  if (hint !== '') {
2130
2195
  for (const assembled of Array.isArray(assembly.contexts) ? assembly.contexts : []) {
2131
2196
  if (assembled && assembled.name === 'gitbash-shell:posix-paths' && typeof assembled.text === 'string') {
@@ -2611,8 +2676,8 @@ export async function apply(ctx, config = {}) {
2611
2676
  // official tool surface mirrored call-for-call) plus a HIDE on the real pwsh
2612
2677
  // tool while the pwsh switch is off (route B: the official executor stays in
2613
2678
  // place, so pwsh is real PowerShell and hiding it is a product choice). The
2614
- // variantPresets switch is honored BY OBSERVATION — a variant agent's face
2615
- // already has bash, so this patch never fires there.
2679
+ // Full Windows variants keep the same pwsh-only face as official presets,
2680
+ // so they receive this same private-executor patch. Minimal stays persistent.
2616
2681
  if (process.platform === 'win32') {
2617
2682
  try {
2618
2683
  mountInlineTools(ctx, {
@@ -2661,55 +2726,10 @@ export async function apply(ctx, config = {}) {
2661
2726
  // With variantPresets ON nothing here runs: the variants stay registered,
2662
2727
  // so a variant default keeps working as-is.
2663
2728
  try {
2664
- if (ctx.agentPresets && typeof ctx.agentPresets.register === 'function') {
2665
- ctx.inject(['settings'], (settingsCtx) => {
2666
- void (async () => {
2667
- try {
2668
- const settings = settingsCtx.settings
2669
- if (!settings || typeof settings.update !== 'function') return
2670
- // The switch is the whole contract: keep-old-modes ON means a
2671
- // variant default is a legitimate choice and stays untouched.
2672
- if (liveSettings.variantPresets() === true) return
2673
- const readDefault = () => { try { return ctx.agentPresets.defaultId } catch { return undefined } }
2674
- const current = readDefault()
2675
- if (typeof current !== 'string' || !PRESET_IDS.includes(current)) return
2676
- const target = VARIANT_DEFAULT_SUCCESSORS[current]
2677
- if (typeof target !== 'string') return
2678
- // The successor must exist in the CURRENT roster: official ids
2679
- // renamed once already (code→ptc), and writing an unknown id
2680
- // would trade a known failure for a worse one.
2681
- const roster = await ctx.agentPresets.list()
2682
- if (!Array.isArray(roster) || !roster.some((row) => row && row.id === target)) {
2683
- console.warn(TAG + ' default-preset migration stood down: the deployment default is the variant "'
2684
- + current + '" but the successor "' + target + '" is not registered on this host.'
2685
- + ' Set the default manually among the OFFICIAL presets, or turn variantPresets on.')
2686
- return
2687
- }
2688
- try {
2689
- await settings.update('agent-preset-registry', { selectedDefault: target })
2690
- } catch (error) {
2691
- console.warn(TAG + ' default-preset migration write failed (' + (error?.message ?? error)
2692
- + '): the deployment default is still the variant "' + current
2693
- + '" — NEW sessions would fail with agent-preset/not-found.'
2694
- + ' Fix: set an OFFICIAL default in the preset picker, or turn variantPresets on.')
2695
- return
2696
- }
2697
- const after = readDefault()
2698
- if (after === target) {
2699
- console.log(TAG + ' deployment default migrated: "' + current + '" -> "' + target
2700
- + '" (variantPresets is off). Old sessions pinned to a variant still need a NEW'
2701
- + ' session — the official agent-preset/locked rule blocks a mid-session switch.')
2702
- } else {
2703
- console.warn(TAG + ' default-preset migration did not stick (read back "'
2704
- + (after === undefined ? 'unreadable' : after) + '"). Set the default manually in'
2705
- + ' Settings -> agent preset, or turn variantPresets on.')
2706
- }
2707
- } catch (error) {
2708
- console.log(TAG + ' default-preset migration failed: ' + (error?.message ?? error))
2709
- }
2710
- })()
2711
- })
2712
- }
2729
+ ctx.inject(['settings'], () => {
2730
+ // Boot migration is detached: registry.list() may await the Loader boot.
2731
+ void migrateVariantDefault(ctx, () => liveSettings.variantPresets())
2732
+ })
2713
2733
  } catch (error) {
2714
2734
  console.log(TAG + ' default-preset migration wiring failed: ' + (error?.message ?? error))
2715
2735
  }
@@ -2727,8 +2747,21 @@ export async function apply(ctx, config = {}) {
2727
2747
  enabled: () => liveSettings.fastctxTools(),
2728
2748
  createRequireFrom: (base) => createRequire(base ?? import.meta.url),
2729
2749
  tools: (c) => { try { return c.get('tools') } catch { return undefined } },
2730
- msysToWin: (p) => translateMsysPath(p, buildTranslateEnv(readConfiguredBashPath())),
2731
- echo: (text) => (liveSettings.dialect().posixPaths === true ? windowsToMsys(text) : text),
2750
+ msysToWin: (p, execution) => {
2751
+ const dialect = liveSettings.dialect()
2752
+ if (dialect.posixPaths !== true || !dialectApplies(dialect, execution?.agent)) return p
2753
+ if (!dialect.virtualMounts) {
2754
+ return typeof p === 'string' && (MSYS_DRIVE_ROOT.test(p) || MSYS_BARE_DRIVE.test(p))
2755
+ ? translateMsysPath(p, null) : p
2756
+ }
2757
+ return translateMsysPath(p, buildTranslateEnv(readConfiguredBashPath()))
2758
+ },
2759
+ echo: (text, execution) => {
2760
+ const dialect = liveSettings.dialect()
2761
+ if (dialect.posixPaths !== true || !dialectApplies(dialect, execution?.agent)) return text
2762
+ const env = dialect.virtualMounts ? buildTranslateEnv(readConfiguredBashPath()) : null
2763
+ return env ? msysEcho(text, env) : windowsToMsys(text)
2764
+ },
2732
2765
  log: (m) => console.log(TAG + ' ' + m),
2733
2766
  })
2734
2767
  } catch (error) {