@mario.andreschak/mcp-bash 3.42.0 → 3.43.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/dist/tools.js CHANGED
@@ -150,9 +150,14 @@ function isWindowsWslBashLauncher(candidate) {
150
150
  if (process.platform !== 'win32')
151
151
  return false;
152
152
  const systemRoot = getEnvCaseInsensitive('SystemRoot') ?? getEnvCaseInsensitive('windir');
153
- if (!systemRoot)
154
- return false;
155
- return path.resolve(candidate).toLowerCase() === path.resolve(systemRoot, 'System32', 'bash.exe').toLowerCase();
153
+ const resolved = path.resolve(candidate).toLowerCase();
154
+ // The legacy WSL relay may be reached through System32, SysWOW64, or a
155
+ // SystemRoot-relative shim. It is not a usable POSIX interpreter without a
156
+ // provisioned distro, so never select it as the Bash MCP's `bash` shell.
157
+ if (systemRoot && resolved.startsWith(`${path.resolve(systemRoot).toLowerCase()}${path.sep}`)
158
+ && path.basename(resolved) === 'bash.exe')
159
+ return true;
160
+ return /[\\/]WindowsApps[\\/].*wsl.*[\\/]bash\.exe$/i.test(resolved);
156
161
  }
157
162
  let cachedBashPath;
158
163
  function resolveBashExecutable() {
@@ -211,11 +216,96 @@ function resolveCmdExecutable() {
211
216
  path.join(getEnvCaseInsensitive('SystemRoot') ?? 'C:\\Windows', 'System32', 'cmd.exe'),
212
217
  ]);
213
218
  }
219
+ function resolveShExecutable() {
220
+ if (process.platform === 'win32')
221
+ return findExecutableOnPath('sh');
222
+ return firstExistingFile(['/bin/sh', findExecutableOnPath('sh')]);
223
+ }
224
+ /**
225
+ * Interpreters and utilities probed by `shell_info` (issue #364) so a caller can
226
+ * discover the machine up front instead of rediscovering it by failed commands.
227
+ */
228
+ const PROBED_BINARIES = [
229
+ 'python', 'python3', 'node', 'npm', 'npx', 'git', 'rg',
230
+ 'ffmpeg', 'ffprobe', 'curl', 'tar', 'grep', 'sed', 'awk', 'head', 'tail',
231
+ ];
232
+ /**
233
+ * A zero-byte Windows Store app-execution alias (typically `python3.exe` under
234
+ * `WindowsApps`) resolves on PATH but only opens the Store when executed.
235
+ */
236
+ function isWindowsStoreAliasStub(candidate) {
237
+ if (!candidate || process.platform !== 'win32')
238
+ return false;
239
+ if (!/[\\/]WindowsApps[\\/]/i.test(candidate))
240
+ return false;
241
+ try {
242
+ return fs.statSync(candidate).size === 0;
243
+ }
244
+ catch {
245
+ return true;
246
+ }
247
+ }
248
+ function shellEntry(shell, resolved) {
249
+ return { shell, available: Boolean(resolved), path: resolved };
250
+ }
251
+ let cachedShellInfo;
252
+ /** Describe the shells and interpreters actually available on this machine. */
253
+ export function collectShellInfo() {
254
+ if (cachedShellInfo)
255
+ return cachedShellInfo;
256
+ const defaultPlan = buildSpawn('', 'default');
257
+ const shells = [
258
+ shellEntry('pwsh', resolvePwshExecutable()),
259
+ shellEntry('powershell', resolveWindowsPowerShellExecutable()),
260
+ shellEntry('bash', resolveBashExecutable()),
261
+ shellEntry('cmd', resolveCmdExecutable()),
262
+ shellEntry('sh', resolveShExecutable()),
263
+ ];
264
+ const binaries = PROBED_BINARIES.map((name) => {
265
+ const resolved = findExecutableOnPath(name);
266
+ if (isWindowsStoreAliasStub(resolved)) {
267
+ return { name, found: false, path: resolved, alias: true };
268
+ }
269
+ return { name, found: Boolean(resolved), path: resolved };
270
+ });
271
+ const notes = [
272
+ 'Tool output merges stdout and stderr into one "output" field; "isError" reflects the process exit code only.',
273
+ `The "default" shell on this machine is "${defaultPlan.effectiveShell}".`,
274
+ 'Pass shell:"bash" for POSIX syntax (&&, pipes into head/grep, $(…)); an unavailable explicit shell fails fast.',
275
+ ];
276
+ if (!shells.find((entry) => entry.shell === 'pwsh')?.available
277
+ && shells.find((entry) => entry.shell === 'powershell')?.available) {
278
+ notes.push('Only Windows PowerShell 5.1 is installed: "&&" and "||" are not valid statement separators there.');
279
+ }
280
+ cachedShellInfo = {
281
+ platform: process.platform,
282
+ defaultShell: defaultPlan.effectiveShell,
283
+ defaultShellPath: defaultPlan.file || null,
284
+ shells,
285
+ binaries,
286
+ notes,
287
+ };
288
+ return cachedShellInfo;
289
+ }
290
+ function availableShellNames() {
291
+ return collectShellInfo().shells.filter((entry) => entry.available).map((entry) => entry.shell);
292
+ }
293
+ function unavailableShellHint(shell) {
294
+ if (shell === 'bash' && process.platform === 'win32'
295
+ && findExecutablesOnPath('bash').some(isWindowsWslBashLauncher)) {
296
+ return 'Only the WSL bash launcher was found and no Linux distribution provides /bin/bash. Install Git for Windows (Git Bash) or a WSL distro.';
297
+ }
298
+ return 'Call "shell_info" to see which shells and interpreters exist on this machine.';
299
+ }
300
+ function shellInfoTool() {
301
+ return textResult(collectShellInfo());
302
+ }
214
303
  /** Test-only: forget cached shell-executable lookups. */
215
304
  export function _resetBashShellCacheForTests() {
216
305
  cachedBashPath = undefined;
217
306
  cachedPwshPath = undefined;
218
307
  cachedWindowsPowerShellPath = undefined;
308
+ cachedShellInfo = undefined;
219
309
  }
220
310
  function sessions() {
221
311
  if (!global.__flujo_bash_sessions)
@@ -323,6 +413,27 @@ async function resolveCwd(input, roots) {
323
413
  }
324
414
  return resolved;
325
415
  }
416
+ /**
417
+ * Resolve an `outputFile` spool target under the same confinement rules as
418
+ * `cwd` (issue #364) and make sure its parent directory exists.
419
+ */
420
+ async function resolveOutputFile(input, cwd, roots) {
421
+ const raw = typeof input === 'string' ? input.trim() : '';
422
+ if (!raw)
423
+ throw new Error('"outputFile" must be a non-empty path.');
424
+ const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(cwd, raw);
425
+ if (await isProtectedPathsEnabled()) {
426
+ const prot = isProtected(resolved);
427
+ if (prot.denied) {
428
+ throw new Error(`outputFile "${resolved}" is within a protected location (${prot.matchedRoot}).`);
429
+ }
430
+ }
431
+ if (roots.length === 0 || !roots.some((root) => isInside(root, resolved))) {
432
+ throw new Error(`outputFile "${resolved}" is outside the configured bash roots.`);
433
+ }
434
+ await fs.promises.mkdir(path.dirname(resolved), { recursive: true });
435
+ return resolved;
436
+ }
326
437
  /**
327
438
  * Best-effort advisory scan (issue #260, item 4) of a command string for
328
439
  * absolute-looking paths that point OUTSIDE the configured roots or INTO a
@@ -359,6 +470,47 @@ function maskGlobOptionValues(command) {
359
470
  }
360
471
  return chars.join('');
361
472
  }
473
+ /**
474
+ * Windows utilities that take `/x`-style switches (issue #314, item D). A slash
475
+ * token is only demoted from "absolute path" to "switch" when one of these
476
+ * appears EARLIER IN THE SAME command segment, so `cd … && dir /b && rg …` and
477
+ * `echo dir /b` stay quiet while `echo /.git/config` keeps its advisory.
478
+ * Deliberately excludes names that are also common POSIX utilities (`find`,
479
+ * `sort`, `type`, `more`, `mkdir`, `rmdir`) — suppressing their arguments would
480
+ * silence genuine path advisories such as `find /etc -name passwd`.
481
+ */
482
+ const WINDOWS_SWITCH_UTILITIES = new Set([
483
+ 'dir', 'xcopy', 'robocopy', 'copy', 'move', 'del', 'erase', 'attrib', 'icacls', 'takeown',
484
+ 'findstr', 'tasklist', 'taskkill', 'sc', 'reg', 'net', 'subst', 'where', 'ren', 'rename',
485
+ 'md', 'rd', 'tree', 'fc', 'comp', 'cacls', 'schtasks', 'chkdsk', 'sfc', 'shutdown',
486
+ 'wmic', 'diskpart', 'label', 'vol', 'mklink', 'assoc', 'ftype', 'gpupdate', 'powercfg',
487
+ ]);
488
+ /**
489
+ * A cmd-style switch: `/b`, `/ad`, `/mir`, `/e:on`. Never matches a nested path
490
+ * (no interior separator), so `/etc/passwd` is untouched.
491
+ */
492
+ const WINDOWS_SWITCH_RE = /^\/[A-Za-z][A-Za-z0-9?*-]{0,15}(?::[^\s"']*)?$/;
493
+ /** Text of the command segment (split on shell separators) preceding `index`. */
494
+ function segmentTextBefore(command, index) {
495
+ const separators = /&&|\|\||[|;&\n]/g;
496
+ let start = 0;
497
+ let match;
498
+ while ((match = separators.exec(command)) !== null) {
499
+ if (match.index >= index)
500
+ break;
501
+ start = match.index + match[0].length;
502
+ }
503
+ return command.slice(start, index);
504
+ }
505
+ /** True when a slash-switch utility heads the segment containing `index`. */
506
+ function windowsSwitchUtilityPrecedes(command, index) {
507
+ for (const word of segmentTextBefore(command, index).match(/[^\s"'<>()]+/g) ?? []) {
508
+ const name = word.replace(/^.*[\\/]/, '').toLowerCase().replace(/\.(?:exe|com|bat|cmd)$/, '');
509
+ if (WINDOWS_SWITCH_UTILITIES.has(name))
510
+ return true;
511
+ }
512
+ return false;
513
+ }
362
514
  async function scanCommandForExternalPaths(command, cwd, roots) {
363
515
  const warnings = [];
364
516
  const protectedPathsEnabled = await isProtectedPathsEnabled();
@@ -369,10 +521,19 @@ async function scanCommandForExternalPaths(command, cwd, roots) {
369
521
  // Absolute-looking tokens: Windows drive (X:\ or X:/), UNC (\\host\share),
370
522
  // POSIX (/foo), and ~-prefixed home paths.
371
523
  const tokenRe = /(?:[A-Za-z]:[\\/][^\s"']*|\\\\[^\s"']+|~\/[^\s"']*|(?<![\w.])\/[^\s"']+)/g;
372
- const matches = (commandForPathScan.match(tokenRe) ?? []).filter(
373
- // A single-letter slash token is a Windows command switch, not a POSIX path.
374
- // Keep longer tokens so genuine POSIX absolute paths remain advisory notices.
375
- (token) => !(process.platform === 'win32' && /^\/[A-Za-z]$/.test(token)));
524
+ const matches = [...commandForPathScan.matchAll(tokenRe)]
525
+ .filter((match) => {
526
+ if (process.platform !== 'win32')
527
+ return true;
528
+ // Two independent conditions must hold before a token is dropped: it must
529
+ // LOOK like a cmd switch, and a slash-switch utility must appear earlier
530
+ // in the SAME segment (`… && dir /b && …`). This keeps `/etc/passwd` and
531
+ // other genuine POSIX paths advisory.
532
+ if (!WINDOWS_SWITCH_RE.test(match[0]))
533
+ return true;
534
+ return !windowsSwitchUtilityPrecedes(commandForPathScan, match.index ?? 0);
535
+ })
536
+ .map((match) => match[0]);
376
537
  const home = (() => {
377
538
  try {
378
539
  return getHomeDir();
@@ -499,6 +660,17 @@ function buildChildEnv(overrides = {}) {
499
660
  }
500
661
  out.PATHEXT = windowsExecutableExtensions().join(';');
501
662
  }
663
+ // Deterministic text encoding and number formatting for children (issue #364):
664
+ // without this, interpreters emit locale-dependent decimal commas and non-UTF-8
665
+ // bytes that come back as mojibake. Explicit per-call `env` still wins below.
666
+ if (out.PYTHONIOENCODING === undefined)
667
+ out.PYTHONIOENCODING = 'utf-8';
668
+ if (process.platform !== 'win32') {
669
+ if (!out.LANG)
670
+ out.LANG = 'C.UTF-8';
671
+ if (!out.LC_ALL)
672
+ out.LC_ALL = 'C.UTF-8';
673
+ }
502
674
  for (const [key, value] of Object.entries(overrides)) {
503
675
  if (process.platform === 'win32') {
504
676
  for (const existing of Object.keys(out)) {
@@ -528,6 +700,35 @@ function validateEnv(input) {
528
700
  }
529
701
  return { valid: true, env };
530
702
  }
703
+ /**
704
+ * PowerShell prelude (issue #364). It makes three platform traps go away for
705
+ * every command we run:
706
+ * - stderr from a native binary no longer turns into a terminating error, so
707
+ * the reported exit code is the native process' own,
708
+ * - console output is forced to UTF-8 (otherwise logs come back as UTF-16 or
709
+ * OEM mojibake),
710
+ * - the culture is pinned to invariant, so numbers never use a decimal comma.
711
+ * The epilogue re-surfaces the native exit code (`$LASTEXITCODE`), falling back
712
+ * to `$?` when no native command ran.
713
+ */
714
+ const POWERSHELL_PRELUDE = [
715
+ "$ErrorActionPreference='Continue'",
716
+ "$ProgressPreference='SilentlyContinue'",
717
+ 'try { [Console]::OutputEncoding=[Text.Encoding]::UTF8; $OutputEncoding=[Text.Encoding]::UTF8 } catch {}',
718
+ 'try { [Threading.Thread]::CurrentThread.CurrentCulture=[Globalization.CultureInfo]::InvariantCulture;'
719
+ + ' [Threading.Thread]::CurrentThread.CurrentUICulture=[Globalization.CultureInfo]::InvariantCulture } catch {}',
720
+ ].join('; ');
721
+ const POWERSHELL_EPILOGUE = 'exit $(if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 })';
722
+ export function wrapPowerShellCommand(command) {
723
+ return `${POWERSHELL_PRELUDE}\n${command}\n${POWERSHELL_EPILOGUE}`;
724
+ }
725
+ /** `chcp 65001` forces UTF-8 output for cmd.exe children (issue #364). */
726
+ export function wrapCmdCommand(command) {
727
+ return `chcp 65001>nul & ${command}`;
728
+ }
729
+ function powerShellArgs(command) {
730
+ return ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', wrapPowerShellCommand(command)];
731
+ }
531
732
  /**
532
733
  * Build the spawn arguments for the requested shell. Returns the command, argv
533
734
  * and whether Node's `shell:true` wrapping applies. Shells are resolved
@@ -539,7 +740,20 @@ function buildSpawn(command, shell) {
539
740
  if (shell === 'pwsh') {
540
741
  const resolved = resolvePwshExecutable();
541
742
  if (resolved) {
542
- return { file: resolved, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], useShell: false, effectiveShell: 'pwsh' };
743
+ return { file: resolved, args: powerShellArgs(command), useShell: false, effectiveShell: 'pwsh' };
744
+ }
745
+ const windowsPowerShell = resolveWindowsPowerShellExecutable();
746
+ if (windowsPowerShell) {
747
+ return {
748
+ file: windowsPowerShell,
749
+ args: powerShellArgs(command),
750
+ useShell: false,
751
+ effectiveShell: 'powershell',
752
+ shellSubstitution: {
753
+ requested: 'pwsh', used: 'powershell',
754
+ reason: 'PowerShell 7 (pwsh) is not installed on this machine.',
755
+ },
756
+ };
543
757
  }
544
758
  return { file: '', args: [], useShell: false, effectiveShell: 'pwsh', unavailableShell: 'pwsh' };
545
759
  }
@@ -555,7 +769,7 @@ function buildSpawn(command, shell) {
555
769
  if (resolved) {
556
770
  return {
557
771
  file: resolved,
558
- args: ['/d', '/s', '/c', command],
772
+ args: ['/d', '/s', '/c', wrapCmdCommand(command)],
559
773
  useShell: false,
560
774
  effectiveShell: 'cmd',
561
775
  windowsVerbatimArguments: true,
@@ -566,17 +780,17 @@ function buildSpawn(command, shell) {
566
780
  if (process.platform === 'win32') {
567
781
  const pwsh = resolvePwshExecutable();
568
782
  if (pwsh) {
569
- return { file: pwsh, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], useShell: false, effectiveShell: 'pwsh' };
783
+ return { file: pwsh, args: powerShellArgs(command), useShell: false, effectiveShell: 'pwsh' };
570
784
  }
571
785
  const windowsPowerShell = resolveWindowsPowerShellExecutable();
572
786
  if (windowsPowerShell) {
573
- return { file: windowsPowerShell, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], useShell: false, effectiveShell: 'powershell' };
787
+ return { file: windowsPowerShell, args: powerShellArgs(command), useShell: false, effectiveShell: 'powershell' };
574
788
  }
575
789
  const cmd = resolveCmdExecutable();
576
790
  if (cmd) {
577
791
  return {
578
792
  file: cmd,
579
- args: ['/d', '/s', '/c', command],
793
+ args: ['/d', '/s', '/c', wrapCmdCommand(command)],
580
794
  useShell: false,
581
795
  effectiveShell: 'cmd',
582
796
  windowsVerbatimArguments: true,
@@ -632,7 +846,7 @@ function invalidShellResult(requestedShell) {
632
846
  }, true);
633
847
  }
634
848
  function startChild(command, cwd, shell, env) {
635
- const { file, args, useShell, effectiveShell, unavailableShell, startError, windowsVerbatimArguments, } = buildSpawn(command, shell);
849
+ const { file, args, useShell, effectiveShell, unavailableShell, startError, windowsVerbatimArguments, shellSubstitution, } = buildSpawn(command, shell);
636
850
  if (unavailableShell)
637
851
  return { effectiveShell, unavailableShell };
638
852
  if (startError)
@@ -647,7 +861,7 @@ function startChild(command, cwd, shell, env) {
647
861
  detached,
648
862
  ...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
649
863
  });
650
- return { child, effectiveShell };
864
+ return { child, effectiveShell, shellSubstitution };
651
865
  }
652
866
  catch (err) {
653
867
  return {
@@ -656,21 +870,212 @@ function startChild(command, cwd, shell, env) {
656
870
  };
657
871
  }
658
872
  }
659
- function makeAppender(get, set) {
660
- return (chunk) => {
661
- let out = get();
662
- if (out.length >= MAX_OUTPUT_CHARS) {
663
- if (chunk)
664
- set(out, true);
873
+ /**
874
+ * Bounded output accumulator (issue #364). Retention is head + tail rather than
875
+ * head-only, so the command echo at the start AND the final result at the end
876
+ * both survive an output flood (e.g. a 50k-line ffmpeg progress log); the
877
+ * omitted middle is reported explicitly.
878
+ */
879
+ function makeAppender(get, set, maxChars = MAX_OUTPUT_CHARS) {
880
+ const limit = Math.max(1_000, Math.min(Math.floor(maxChars) || MAX_OUTPUT_CHARS, MAX_OUTPUT_CHARS));
881
+ const headLimit = Math.max(1, Math.floor(limit * 0.3));
882
+ const tailLimit = Math.max(1, limit - headLimit);
883
+ let head = '';
884
+ let tail = '';
885
+ let dropped = 0;
886
+ let total = 0;
887
+ const compose = () => (dropped > 0
888
+ ? head + '\n…[' + dropped + ' characters of output omitted]…\n' + tail
889
+ : head + tail);
890
+ const append = ((chunk) => {
891
+ if (!chunk)
665
892
  return;
893
+ total += chunk.length;
894
+ let rest = chunk;
895
+ if (head.length < headLimit) {
896
+ const take = Math.min(headLimit - head.length, rest.length);
897
+ head += rest.slice(0, take);
898
+ rest = rest.slice(take);
666
899
  }
667
- out += chunk;
668
- let truncated = false;
669
- if (out.length > MAX_OUTPUT_CHARS) {
670
- out = out.slice(0, MAX_OUTPUT_CHARS) + '\n…[output truncated]';
671
- truncated = true;
900
+ if (rest) {
901
+ tail += rest;
902
+ if (tail.length > tailLimit) {
903
+ const remove = tail.length - tailLimit;
904
+ tail = tail.slice(remove);
905
+ dropped += remove;
906
+ }
907
+ }
908
+ set(compose(), dropped > 0);
909
+ });
910
+ append.totalChars = () => total;
911
+ append.droppedChars = () => dropped;
912
+ // `get` is retained for call-site symmetry with the previous signature.
913
+ void get;
914
+ return append;
915
+ }
916
+ /**
917
+ * Shell-dialect trap detection (issue #364). Commands are NEVER rewritten —
918
+ * translating shell text between dialects is far riskier than executing what
919
+ * the caller wrote — so every finding is returned as an advisory
920
+ * `dialectWarnings` entry with a concrete suggested fix.
921
+ */
922
+ const POSIX_ONLY_BINARIES = new Set([
923
+ 'head', 'tail', 'grep', 'sed', 'awk', 'wc', 'cat', 'cut', 'tr', 'uniq', 'sort', 'xargs', 'touch', 'which',
924
+ 'rg', 'jq', 'find', 'ls', 'pwd', 'less', 'du', 'df',
925
+ ]);
926
+ const SHELL_BUILTINS = new Set([
927
+ 'cd', 'echo', 'exit', 'export', 'set', 'unset', 'if', 'then', 'else', 'fi', 'for', 'while', 'do', 'done',
928
+ 'true', 'false', 'test', 'type', 'alias', 'function', 'return', 'shift', 'source', '.', 'dir', 'cls',
929
+ 'copy', 'del', 'erase', 'md', 'mkdir', 'rd', 'rmdir', 'move', 'ren', 'rename', 'pushd', 'popd',
930
+ ]);
931
+ function isMissingCommandCandidate(head, shell) {
932
+ const name = path.basename(head).toLowerCase().replace(/\.exe$/, '');
933
+ if (!name || SHELL_BUILTINS.has(name) || /^[-/$]/.test(head) || /^[A-Za-z_][\w-]*=/.test(head))
934
+ return false;
935
+ if (/^[A-Za-z]+-[A-Za-z][A-Za-z-]*$/.test(head) && (shell === 'pwsh' || shell === 'powershell'))
936
+ return false;
937
+ return !/[\\/]/.test(head) && !/^\.?\.?$/.test(head);
938
+ }
939
+ /** Blank out quoted spans so operators inside string literals never trip us. */
940
+ function stripQuotedSegments(command) {
941
+ return command.replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, (match) => ' '.repeat(match.length));
942
+ }
943
+ function commandSegmentHeads(stripped) {
944
+ return stripped
945
+ .split(/&&|\|\||[|;\n]/)
946
+ .map((segment) => segment.trim().split(/\s+/)[0] ?? '')
947
+ .map((head) => head.replace(/^[('"`]+/, ''))
948
+ .filter(Boolean);
949
+ }
950
+ /** True when the command relies on POSIX `&&` / `||` statement chaining. */
951
+ export function commandUsesPosixChaining(command) {
952
+ return /&&|\|\|/.test(stripQuotedSegments(command));
953
+ }
954
+ export function detectDialectMismatch(command, shell, isAvailable = (name) => Boolean(findExecutableOnPath(name))) {
955
+ const warnings = [];
956
+ const stripped = stripQuotedSegments(command);
957
+ const heads = commandSegmentHeads(stripped);
958
+ const posixShell = shell === 'bash' || shell === 'sh';
959
+ if (!posixShell) {
960
+ for (const head of new Set(heads)) {
961
+ const name = path.basename(head).toLowerCase().replace(/\.exe$/, '');
962
+ if (!isMissingCommandCandidate(head, shell) || isAvailable(name))
963
+ continue;
964
+ if (POSIX_ONLY_BINARIES.has(name)) {
965
+ warnings.push(`"${name}" is a POSIX utility and is not available as an executable on this machine; `
966
+ + `under ${shell} it resolves to nothing (or to an unrelated alias). Pass shell:"bash" or use the native equivalent.`);
967
+ }
968
+ else {
969
+ warnings.push(`"${name}" was not found on PATH; under ${shell} this will fail with a "command not found" error. `
970
+ + 'Install it, use the native equivalent, or call "shell_info".');
971
+ }
972
+ }
973
+ }
974
+ if (shell === 'powershell' && /&&|\|\|/.test(stripped)) {
975
+ warnings.push('"&&" and "||" are not statement separators in Windows PowerShell 5.1 (they are a parse error). '
976
+ + 'Use ";" for unconditional chaining, or pass shell:"bash" / shell:"pwsh".');
977
+ }
978
+ if (shell === 'pwsh' || shell === 'powershell') {
979
+ if (/\$\(\s*([A-Za-z_][\w-]*)/.test(stripped)) {
980
+ const inner = /\$\(\s*([A-Za-z_][\w-]*)/.exec(stripped)?.[1] ?? '';
981
+ if (POSIX_ONLY_BINARIES.has(inner.toLowerCase()) && !isAvailable(inner.toLowerCase())) {
982
+ warnings.push(`"$(${inner} …)" is POSIX command substitution; PowerShell evaluates it as a subexpression `
983
+ + 'and cannot run that utility. Pass shell:"bash".');
984
+ }
985
+ }
986
+ if ((stripped.match(/`/g) ?? []).length >= 2) {
987
+ warnings.push('Backticks are the line-continuation/escape character in PowerShell, not command substitution. '
988
+ + 'Use "$(…)" for PowerShell, or pass shell:"bash".');
989
+ }
990
+ if (/2>&1/.test(stripped)) {
991
+ warnings.push('No "2>&1" redirection is needed: this tool already merges stdout and stderr into one "output" field, '
992
+ + 'and "isError" is derived from the exit code only.');
672
993
  }
673
- set(out, truncated);
994
+ }
995
+ if (shell === 'cmd') {
996
+ if (/'/.test(command.replace(/"(?:\\.|[^"\\])*"/g, ''))) {
997
+ warnings.push('cmd.exe does not treat single quotes as string delimiters; use double quotes.');
998
+ }
999
+ if (/\$\(|`/.test(stripped)) {
1000
+ warnings.push('Command substitution ($(…) / backticks) is not supported by cmd.exe. Pass shell:"bash" or shell:"pwsh".');
1001
+ }
1002
+ }
1003
+ if (posixShell && /\b(?:Get|Set|New|Remove|Write|Select|Out|Format|Test|Invoke|Start|Stop)-[A-Z][A-Za-z]+\b/.test(stripped)) {
1004
+ warnings.push('This looks like a PowerShell cmdlet but the command is running under a POSIX shell. Pass shell:"pwsh".');
1005
+ }
1006
+ return warnings;
1007
+ }
1008
+ function missingExecutableHint(exitCode, warnings) {
1009
+ if ((exitCode === 9009 || exitCode === 255 || exitCode === 1)
1010
+ && warnings.some((warning) => /not found on PATH|not available as an executable/.test(warning))) {
1011
+ return { hint: 'A referenced executable was not found — see dialectWarnings.' };
1012
+ }
1013
+ return {};
1014
+ }
1015
+ /** Commands that typically hang forever waiting on a pager or interactive prompt. */
1016
+ export function detectInteractiveHangRisk(command) {
1017
+ const hints = [];
1018
+ const stripped = stripQuotedSegments(command);
1019
+ if (/\bgit\b(?![^|;]*--no-pager)[^|;]*\b(?:log|diff|show|branch)\b/.test(stripped)) {
1020
+ hints.push('git paginates by default; use "git --no-pager …" (or append "| cat") for non-interactive runs.');
1021
+ }
1022
+ if (/(^|[|;\s])(?:less|more)(\s|$)/.test(stripped)) {
1023
+ hints.push('"less"/"more" are interactive pagers and will never exit here; drop them.');
1024
+ }
1025
+ if (/Format-(?:Table|List)\b/.test(stripped) && !/Out-String/.test(stripped)) {
1026
+ hints.push('Pipe Format-Table/Format-List into "| Out-String" so PowerShell does not wait on the host formatter.');
1027
+ }
1028
+ if (/(^|[|;\s])(?:npm|yarn|pnpm)\s+(?:init|login)(\s|$)/.test(stripped)) {
1029
+ hints.push('This command prompts interactively; add its non-interactive flag (e.g. "npm init -y").');
1030
+ }
1031
+ return hints;
1032
+ }
1033
+ function validateEncoding(input) {
1034
+ if (input === undefined)
1035
+ return 'auto';
1036
+ if (input === 'utf8' || input === 'utf16le' || input === 'auto')
1037
+ return input;
1038
+ return null;
1039
+ }
1040
+ function looksLikeUtf16le(buf) {
1041
+ const len = Math.min(buf.length, 64);
1042
+ if (len < 4)
1043
+ return false;
1044
+ let nuls = 0;
1045
+ for (let i = 0; i < len; i += 1)
1046
+ if (buf[i] === 0)
1047
+ nuls += 1;
1048
+ return nuls / len > 0.3;
1049
+ }
1050
+ /**
1051
+ * Decode child output deterministically (issue #364). Windows tooling happily
1052
+ * writes UTF-16LE (e.g. PowerShell redirection into a log) which the default
1053
+ * UTF-8 decode turns into NUL-riddled mojibake. `auto` sniffs a BOM or a high
1054
+ * NUL ratio in the first chunk and then stays with that choice.
1055
+ */
1056
+ export function createStreamDecoder(mode = 'auto') {
1057
+ let resolved = mode === 'auto' ? null : mode;
1058
+ let first = true;
1059
+ return (chunk) => {
1060
+ if (typeof chunk === 'string')
1061
+ return chunk;
1062
+ let buf = chunk;
1063
+ if (first) {
1064
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
1065
+ if (resolved === null)
1066
+ resolved = 'utf16le';
1067
+ if (resolved === 'utf16le')
1068
+ buf = buf.subarray(2);
1069
+ }
1070
+ else if (resolved === null) {
1071
+ resolved = looksLikeUtf16le(buf) ? 'utf16le' : 'utf8';
1072
+ }
1073
+ first = false;
1074
+ }
1075
+ let text = buf.toString(resolved ?? 'utf8');
1076
+ if (text.charCodeAt(0) === 0xfeff)
1077
+ text = text.slice(1);
1078
+ return text;
674
1079
  };
675
1080
  }
676
1081
  /** Resolve an interactive shell executable without wrapping a command string. */
@@ -817,7 +1222,25 @@ export function bashToolDefinitions() {
817
1222
  additionalProperties: { type: 'string' },
818
1223
  description: 'Environment variables to add or override for this command.',
819
1224
  };
1225
+ const encodingProp = {
1226
+ type: 'string',
1227
+ enum: ['auto', 'utf8', 'utf16le'],
1228
+ description: 'How to decode child output. "auto" (default) detects UTF-16LE (BOM or NUL-heavy) and otherwise uses UTF-8.',
1229
+ };
1230
+ const maxOutputCharsProp = {
1231
+ type: 'number',
1232
+ description: 'Cap the returned output (default and hard maximum 100000 characters). When exceeded, the head and tail are kept and the omitted middle is reported.',
1233
+ };
1234
+ const outputFileProp = {
1235
+ type: 'string',
1236
+ description: 'Spool the full merged output to this file (confined to the configured roots) while the result stays bounded.',
1237
+ };
820
1238
  return [
1239
+ {
1240
+ name: 'shell_info',
1241
+ description: 'Describe this machine before running anything: platform, which shell "default" resolves to, which of pwsh/powershell/bash/cmd/sh are available, and whether common interpreters (python, node, git, ffmpeg, …) are on PATH. Call this instead of discovering the environment through failed commands.',
1242
+ inputSchema: { type: 'object', properties: {} },
1243
+ },
821
1244
  {
822
1245
  name: 'open_terminal',
823
1246
  description: 'Open a real interactive pseudoterminal (PTY/ConPTY) and display its MCP App. Returns an owner-scoped terminal sessionId.',
@@ -884,7 +1307,9 @@ export function bashToolDefinitions() {
884
1307
  },
885
1308
  {
886
1309
  name: 'run',
887
- description: 'Run one command to completion. Output is sent as live progress when supported and returned with the exit code; use start/status for a persistent background session.',
1310
+ description: 'Run one command to completion. "output" is stdout and stderr MERGED (no 2>&1 needed) and "isError" reflects the process exit code only — text on stderr is not a failure. '
1311
+ + 'On an unknown machine call "shell_info" first; prefer shell:"bash" for POSIX syntax (&&, pipes into head/grep). Output is UTF-8 decoded and PowerShell runs with invariant culture, so numbers and text are stable. '
1312
+ + 'Use start/status for a persistent background session.',
888
1313
  inputSchema: {
889
1314
  type: 'object',
890
1315
  properties: {
@@ -892,15 +1317,18 @@ export function bashToolDefinitions() {
892
1317
  cwd: cwdProp,
893
1318
  shell: shellProp,
894
1319
  env: envProp,
895
- timeout: { type: 'number', description: 'Timeout in seconds (default 60, max 600).' },
1320
+ timeout: { type: 'number', description: 'Timeout in seconds (default 60, max 600). On timeout the whole process tree is killed and elapsedMs/timeoutMs are reported.' },
896
1321
  normalizeNewlines: { type: 'boolean', description: 'If true, CRLF/CR in the captured output are normalized to LF.' },
1322
+ encoding: encodingProp,
1323
+ maxOutputChars: maxOutputCharsProp,
1324
+ outputFile: outputFileProp,
897
1325
  },
898
1326
  required: ['command'],
899
1327
  },
900
1328
  },
901
1329
  {
902
1330
  name: 'start',
903
- description: 'Start an independent background command and return its sessionId. Multiple sessions may run in parallel; use status/wait, write_stdin, or kill.',
1331
+ description: 'Start an independent background command and return its sessionId. Output is stdout and stderr merged, decoded as UTF-8. Multiple sessions may run in parallel; use status/wait, write_stdin, or kill.',
904
1332
  inputSchema: {
905
1333
  type: 'object',
906
1334
  properties: {
@@ -908,6 +1336,9 @@ export function bashToolDefinitions() {
908
1336
  cwd: cwdProp,
909
1337
  shell: shellProp,
910
1338
  env: envProp,
1339
+ encoding: encodingProp,
1340
+ maxOutputChars: maxOutputCharsProp,
1341
+ outputFile: outputFileProp,
911
1342
  },
912
1343
  required: ['command'],
913
1344
  },
@@ -965,6 +1396,68 @@ export function bashToolDefinitions() {
965
1396
  function maybeNormalize(text, normalize) {
966
1397
  return normalize ? text.replace(/\r\n?/g, '\n') : text;
967
1398
  }
1399
+ /**
1400
+ * Only when the caller said `shell: "default"` may we pick a different shell
1401
+ * (issue #364): a command chained with POSIX `&&`/`||` cannot even parse under
1402
+ * Windows PowerShell 5.1, so prefer a shell that can run it. An explicitly
1403
+ * requested shell is never overridden.
1404
+ */
1405
+ function selectShell(command, requested) {
1406
+ if (requested !== 'default')
1407
+ return { shell: requested, autoSelected: false };
1408
+ const plan = buildSpawn(command, 'default');
1409
+ if (plan.effectiveShell === 'powershell' && commandUsesPosixChaining(command)) {
1410
+ if (resolvePwshExecutable())
1411
+ return { shell: 'pwsh', autoSelected: true };
1412
+ if (resolveBashExecutable())
1413
+ return { shell: 'bash', autoSelected: true };
1414
+ }
1415
+ return { shell: 'default', autoSelected: false };
1416
+ }
1417
+ /** Optional per-call output limits shared by `run` and `start`. */
1418
+ function resolveMaxOutputChars(input) {
1419
+ return typeof input === 'number' && input > 0
1420
+ ? Math.min(Math.floor(input), MAX_OUTPUT_CHARS)
1421
+ : MAX_OUTPUT_CHARS;
1422
+ }
1423
+ /** Spool the full merged output to a file while the tool result stays bounded. */
1424
+ function createOutputSpool(file) {
1425
+ if (!file)
1426
+ return { write: () => undefined, close: () => undefined, bytes: () => 0, error: () => undefined };
1427
+ let bytes = 0;
1428
+ let failure;
1429
+ let stream;
1430
+ try {
1431
+ stream = fs.createWriteStream(file, { encoding: 'utf8' });
1432
+ stream.on('error', (err) => { failure = err instanceof Error ? err.message : String(err); });
1433
+ }
1434
+ catch (err) {
1435
+ failure = err instanceof Error ? err.message : String(err);
1436
+ }
1437
+ return {
1438
+ write: (chunk) => {
1439
+ if (!stream || failure || !chunk)
1440
+ return;
1441
+ try {
1442
+ stream.write(chunk);
1443
+ bytes += Buffer.byteLength(chunk, 'utf8');
1444
+ }
1445
+ catch (err) {
1446
+ failure = err instanceof Error ? err.message : String(err);
1447
+ }
1448
+ },
1449
+ close: () => {
1450
+ try {
1451
+ stream?.end();
1452
+ }
1453
+ catch {
1454
+ /* best-effort */
1455
+ }
1456
+ },
1457
+ bytes: () => bytes,
1458
+ error: () => failure,
1459
+ };
1460
+ }
968
1461
  async function runTool(args, roots, context) {
969
1462
  const command = String(args?.command ?? '').trim();
970
1463
  if (!command)
@@ -979,27 +1472,69 @@ async function runTool(args, roots, context) {
979
1472
  if (context?.signal?.aborted) {
980
1473
  return textResult({ error: 'Command cancelled before it started.', cancelled: true, requestedShell }, true);
981
1474
  }
1475
+ const encodingMode = validateEncoding(args.encoding);
1476
+ if (!encodingMode) {
1477
+ return textResult({ error: '"encoding" must be one of "utf8", "utf16le", or "auto".' }, true);
1478
+ }
982
1479
  const cwd = await resolveCwd(args.cwd, roots);
983
1480
  const warnings = await scanCommandForExternalPaths(command, cwd, roots);
984
1481
  const warn = warnings.length ? { warnings } : {};
985
1482
  const normalize = args.normalizeNewlines === true;
986
1483
  const timeoutSec = typeof args.timeout === 'number' && args.timeout > 0 ? args.timeout : DEFAULT_TIMEOUT_MS / 1000;
987
1484
  const timeoutMs = Math.min(timeoutSec * 1000, MAX_TIMEOUT_MS);
1485
+ const maxOutputChars = resolveMaxOutputChars(args.maxOutputChars);
1486
+ let outputFilePath;
1487
+ if (args.outputFile !== undefined) {
1488
+ try {
1489
+ outputFilePath = await resolveOutputFile(args.outputFile, cwd, roots);
1490
+ }
1491
+ catch (err) {
1492
+ return textResult({ error: err instanceof Error ? err.message : String(err), cwd }, true);
1493
+ }
1494
+ }
1495
+ const selection = selectShell(command, requestedShell);
988
1496
  registerExitCleanup();
1497
+ const startedAt = Date.now();
989
1498
  return await new Promise((resolve) => {
990
1499
  let output = '';
991
1500
  let truncated = false;
992
1501
  let settled = false;
993
- const append = makeAppender(() => output, (v, t) => { output = v; truncated = t || truncated; });
1502
+ let stderrChars = 0;
1503
+ const append = makeAppender(() => output, (v, t) => { output = v; truncated = t || truncated; }, maxOutputChars);
1504
+ const spool = createOutputSpool(outputFilePath);
1505
+ const decodeStdout = createStreamDecoder(encodingMode);
1506
+ const decodeStderr = createStreamDecoder(encodingMode);
994
1507
  const progress = createCommandProgressReporter(context);
995
- const { child, startError, effectiveShell, unavailableShell } = startChild(command, cwd, requestedShell, envValidation.env);
1508
+ const { child, startError, effectiveShell, unavailableShell, shellSubstitution } = startChild(command, cwd, selection.shell, envValidation.env);
1509
+ const dialectWarnings = detectDialectMismatch(command, effectiveShell);
1510
+ const dialect = dialectWarnings.length ? { dialectWarnings } : {};
1511
+ const auto = selection.autoSelected ? { shellAutoSelected: true } : {};
1512
+ const substitution = shellSubstitution ? { shellSubstitution } : {};
1513
+ const spoolInfo = () => (outputFilePath
1514
+ ? { outputFile: outputFilePath, outputBytes: spool.bytes(), ...(spool.error() ? { outputFileError: spool.error() } : {}) }
1515
+ : {});
1516
+ const outputStats = () => ({
1517
+ truncated,
1518
+ stderrChars,
1519
+ outputChars: append.totalChars(),
1520
+ ...(append.droppedChars() > 0 ? { omittedChars: append.droppedChars() } : {}),
1521
+ });
996
1522
  if (unavailableShell) {
1523
+ spool.close();
997
1524
  void progress.stop().then(() => {
998
- resolve(textResult({ error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`, cwd, shell: unavailableShell }, true));
1525
+ resolve(textResult({
1526
+ error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`,
1527
+ cwd,
1528
+ requestedShell,
1529
+ shell: unavailableShell,
1530
+ availableShells: availableShellNames(),
1531
+ hint: unavailableShellHint(unavailableShell),
1532
+ }, true));
999
1533
  });
1000
1534
  return;
1001
1535
  }
1002
1536
  if (startError || !child) {
1537
+ spool.close();
1003
1538
  void progress.stop().then(() => {
1004
1539
  resolve(textResult({ error: `Failed to start command (${effectiveShell}): ${startError ?? 'unknown error'}`, cwd, shell: effectiveShell }, true));
1005
1540
  });
@@ -1018,8 +1553,12 @@ async function runTool(args, roots, context) {
1018
1553
  requestedShell,
1019
1554
  shell: effectiveShell,
1020
1555
  exitCode: null,
1021
- truncated,
1022
1556
  output: finalOut,
1557
+ ...outputStats(),
1558
+ ...spoolInfo(),
1559
+ ...dialect,
1560
+ ...substitution,
1561
+ ...auto,
1023
1562
  ...warn,
1024
1563
  }, true), true);
1025
1564
  };
@@ -1032,31 +1571,46 @@ async function runTool(args, roots, context) {
1032
1571
  context?.signal?.removeEventListener('abort', onAbort);
1033
1572
  if (!keepKillEscalation)
1034
1573
  cancelEscalation?.();
1574
+ spool.close();
1035
1575
  await progress.stop();
1036
1576
  resolve(result);
1037
1577
  };
1038
1578
  timer = setTimeout(() => {
1039
1579
  cancelEscalation = killProcessTree(child);
1040
1580
  const finalOut = maybeNormalize(output, normalize);
1581
+ const hangHints = detectInteractiveHangRisk(command);
1041
1582
  void finish(textResult({
1042
1583
  timedOut: true,
1043
1584
  cwd,
1044
1585
  requestedShell,
1045
1586
  shell: effectiveShell,
1046
1587
  exitCode: null,
1047
- truncated,
1588
+ timeoutMs,
1589
+ elapsedMs: Date.now() - startedAt,
1590
+ killedProcessTree: true,
1591
+ suggestion: `Command exceeded ${timeoutMs / 1000}s and its process tree was killed. `
1592
+ + 'Raise "timeout" (max 600s) or run it via "start" + "wait" for long jobs.',
1048
1593
  output: `${finalOut}${finalOut ? '\n' : ''}[killed after ${timeoutMs / 1000}s timeout]`,
1594
+ ...outputStats(),
1595
+ ...spoolInfo(),
1596
+ ...(hangHints.length ? { hangHints } : {}),
1597
+ ...dialect,
1598
+ ...substitution,
1599
+ ...auto,
1049
1600
  ...warn,
1050
1601
  }, true), true);
1051
1602
  }, timeoutMs);
1052
1603
  child.stdout?.on('data', (d) => {
1053
- const chunk = d.toString();
1604
+ const chunk = decodeStdout(d);
1054
1605
  append(chunk);
1606
+ spool.write(chunk);
1055
1607
  progress.push(maybeNormalize(chunk, normalize));
1056
1608
  });
1057
1609
  child.stderr?.on('data', (d) => {
1058
- const chunk = d.toString();
1610
+ const chunk = decodeStderr(d);
1611
+ stderrChars += chunk.length;
1059
1612
  append(chunk);
1613
+ spool.write(chunk);
1060
1614
  progress.push(maybeNormalize(chunk, normalize));
1061
1615
  });
1062
1616
  child.on('error', (err) => {
@@ -1071,7 +1625,20 @@ async function runTool(args, roots, context) {
1071
1625
  if (settled)
1072
1626
  return;
1073
1627
  const finalOut = maybeNormalize(output, normalize);
1074
- void finish(textResult({ exitCode: code, cwd, requestedShell, shell: effectiveShell, truncated, output: finalOut, ...warn }, code !== 0));
1628
+ void finish(textResult({
1629
+ exitCode: code,
1630
+ cwd,
1631
+ requestedShell,
1632
+ shell: effectiveShell,
1633
+ output: finalOut,
1634
+ ...outputStats(),
1635
+ ...spoolInfo(),
1636
+ ...dialect,
1637
+ ...missingExecutableHint(code, dialectWarnings),
1638
+ ...substitution,
1639
+ ...auto,
1640
+ ...warn,
1641
+ }, code !== 0));
1075
1642
  });
1076
1643
  if (context?.signal?.aborted)
1077
1644
  onAbort();
@@ -1113,12 +1680,34 @@ async function startTool(args, roots, ownerScope, context) {
1113
1680
  return textResult({ error: `Too many active background sessions (max ${MAX_SESSIONS}). Kill some first.` }, true);
1114
1681
  }
1115
1682
  registerExitCleanup();
1683
+ const encodingMode = validateEncoding(args.encoding);
1684
+ if (!encodingMode) {
1685
+ return textResult({ error: '"encoding" must be one of "utf8", "utf16le", or "auto".' }, true);
1686
+ }
1116
1687
  const cwd = await resolveCwd(args.cwd, roots);
1117
1688
  const warnings = await scanCommandForExternalPaths(command, cwd, roots);
1118
1689
  const warn = warnings.length ? { warnings } : {};
1119
- const { child, startError, effectiveShell, unavailableShell } = startChild(command, cwd, requestedShell, envValidation.env);
1690
+ const maxOutputChars = resolveMaxOutputChars(args.maxOutputChars);
1691
+ let outputFilePath;
1692
+ if (args.outputFile !== undefined) {
1693
+ try {
1694
+ outputFilePath = await resolveOutputFile(args.outputFile, cwd, roots);
1695
+ }
1696
+ catch (err) {
1697
+ return textResult({ error: err instanceof Error ? err.message : String(err), cwd }, true);
1698
+ }
1699
+ }
1700
+ const selection = selectShell(command, requestedShell);
1701
+ const { child, startError, effectiveShell, unavailableShell, shellSubstitution } = startChild(command, cwd, selection.shell, envValidation.env);
1120
1702
  if (unavailableShell) {
1121
- return textResult({ error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`, cwd, shell: unavailableShell }, true);
1703
+ return textResult({
1704
+ error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`,
1705
+ cwd,
1706
+ requestedShell,
1707
+ shell: unavailableShell,
1708
+ availableShells: availableShellNames(),
1709
+ hint: unavailableShellHint(unavailableShell),
1710
+ }, true);
1122
1711
  }
1123
1712
  if (startError || !child) {
1124
1713
  return textResult({ error: `Failed to start command (${effectiveShell}): ${startError ?? 'unknown error'}`, cwd, shell: effectiveShell }, true);
@@ -1137,9 +1726,21 @@ async function startTool(args, roots, ownerScope, context) {
1137
1726
  startedAt: Date.now(),
1138
1727
  };
1139
1728
  table.set(id, session);
1140
- const append = makeAppender(() => session.output, (v, t) => { session.output = v; session.truncated = t || session.truncated; });
1141
- child.stdout?.on('data', (d) => append(d.toString()));
1142
- child.stderr?.on('data', (d) => append(d.toString()));
1729
+ const append = makeAppender(() => session.output, (v, t) => { session.output = v; session.truncated = t || session.truncated; }, maxOutputChars);
1730
+ const spool = createOutputSpool(outputFilePath);
1731
+ const decodeStdout = createStreamDecoder(encodingMode);
1732
+ const decodeStderr = createStreamDecoder(encodingMode);
1733
+ child.stdout?.on('data', (d) => {
1734
+ const chunk = decodeStdout(d);
1735
+ append(chunk);
1736
+ spool.write(chunk);
1737
+ });
1738
+ child.stderr?.on('data', (d) => {
1739
+ const chunk = decodeStderr(d);
1740
+ session.stderrChars = (session.stderrChars ?? 0) + chunk.length;
1741
+ append(chunk);
1742
+ spool.write(chunk);
1743
+ });
1143
1744
  child.on('error', (err) => {
1144
1745
  append(`\n${err.message}`);
1145
1746
  session.running = false;
@@ -1150,9 +1751,22 @@ async function startTool(args, roots, ownerScope, context) {
1150
1751
  session.running = false;
1151
1752
  session.exitCode = code;
1152
1753
  session.endedAt = Date.now();
1754
+ spool.close();
1153
1755
  scheduleReap(session);
1154
1756
  });
1155
- return textResult({ sessionId: id, cwd, requestedShell, shell: effectiveShell, ...warn });
1757
+ const dialectWarnings = detectDialectMismatch(command, effectiveShell);
1758
+ session.missingExecutableWarning = dialectWarnings.some((warning) => /not found on PATH|not available as an executable/.test(warning));
1759
+ return textResult({
1760
+ sessionId: id,
1761
+ cwd,
1762
+ requestedShell,
1763
+ shell: effectiveShell,
1764
+ ...(shellSubstitution ? { shellSubstitution } : {}),
1765
+ ...(selection.autoSelected ? { shellAutoSelected: true } : {}),
1766
+ ...(dialectWarnings.length ? { dialectWarnings } : {}),
1767
+ ...(outputFilePath ? { outputFile: outputFilePath } : {}),
1768
+ ...warn,
1769
+ });
1156
1770
  }
1157
1771
  function snapshot(session, extra = {}) {
1158
1772
  return {
@@ -1161,6 +1775,9 @@ function snapshot(session, extra = {}) {
1161
1775
  exitCode: session.exitCode,
1162
1776
  output: session.output,
1163
1777
  truncated: session.truncated,
1778
+ stderrChars: session.stderrChars ?? 0,
1779
+ ...(session.missingExecutableWarning && (session.exitCode === 9009 || session.exitCode === 255 || session.exitCode === 1)
1780
+ ? { hint: 'A referenced executable was not found — see dialectWarnings.' } : {}),
1164
1781
  ...extra,
1165
1782
  };
1166
1783
  }
@@ -1304,7 +1921,11 @@ async function openTerminalTool(args, roots, ownerScope) {
1304
1921
  const cwd = await resolveCwd(args.cwd, roots);
1305
1922
  const plan = buildPtySpawn(shellValidation.shell);
1306
1923
  if (plan.unavailableShell) {
1307
- return textResult({ error: `Requested shell "${plan.unavailableShell}" is unavailable or could not be resolved.` }, true);
1924
+ return textResult({
1925
+ error: `Requested shell "${plan.unavailableShell}" is unavailable or could not be resolved.`,
1926
+ availableShells: availableShellNames(),
1927
+ hint: 'Call "shell_info" to see which shells and interpreters exist on this machine.',
1928
+ }, true);
1308
1929
  }
1309
1930
  if (plan.startError)
1310
1931
  return textResult({ error: plan.startError }, true);
@@ -1456,6 +2077,8 @@ export async function bashCallTool(toolName, args, callerNodeId, ownerScope, con
1456
2077
  return closeTerminalTool(args, scope);
1457
2078
  case 'terminal_list':
1458
2079
  return listTerminalsTool(scope);
2080
+ case 'shell_info':
2081
+ return shellInfoTool();
1459
2082
  case 'run': {
1460
2083
  const roots = await loadEffectiveRoots(BASH_SERVER_NAME, BASH_ROOT_ENV_VARS, callerNodeId);
1461
2084
  return await runTool(args, roots, context);