@mario.andreschak/mcp-bash 3.42.1 → 3.44.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
@@ -40,17 +40,71 @@ const BASH_SERVER_NAME = 'bash';
40
40
  export const BASH_TERMINAL_APP_URI = 'ui://bash/terminal';
41
41
  const log = createLogger('backend/services/mcp/internal/bashTools');
42
42
  const DEFAULT_TIMEOUT_MS = 60_000;
43
- const MAX_TIMEOUT_MS = 600_000;
43
+ /**
44
+ * Foreground commands and `wait` calls may wrap builds, renders, migrations,
45
+ * and other jobs that take far longer than the historical ten-minute ceiling.
46
+ * Keep a finite default safety ceiling, aligned with the background-session
47
+ * lifetime, while allowing deployments to lower or raise it. Passing -1
48
+ * disables this Bash-side timer; cancellation and shutdown still clean up.
49
+ */
50
+ const DEFAULT_MAX_TIMEOUT_MS = 12 * 60 * 60_000;
44
51
  const MAX_OUTPUT_CHARS = 100_000;
45
52
  const MAX_SESSIONS = 25;
46
53
  const MAX_TERMINAL_OUTPUT_CHARS = 2_000_000;
47
54
  /**
48
- * Lifecycle: live sessions end only by process exit, explicit kill, or Bash
49
- * server shutdown. Finished owner-scoped records remain readable for ten
50
- * minutes, then are reaped. An MCP View teardown may be an inline→dock handoff,
51
- * so it is not treated as an owner disconnect signal.
55
+ * Lifecycle (issue #413): a live session ends by process exit, explicit kill,
56
+ * OWNER RELEASE when its run finishes, idle/max-lifetime expiry, or Bash server
57
+ * shutdown. Finished owner-scoped records remain readable for ten minutes, then
58
+ * are reaped. An MCP View teardown may be an inline-to-dock handoff, so it is
59
+ * still not treated as an owner disconnect signal.
60
+ *
61
+ * Owner release is what closes the original leak: nothing used to tell this
62
+ * server that the run which started a background command had ended, so a
63
+ * `start`ed process (and its whole descendant tree) outlived its run and stayed
64
+ * resident for the lifetime of the FLUJO process.
52
65
  */
53
66
  const SESSION_TTL_MS = 10 * 60_000;
67
+ /**
68
+ * Host-wide cap across background AND terminal sessions. The per-owner cap alone
69
+ * could not bound the machine: N concurrent runs each stayed under their own
70
+ * limit while together they forked an unbounded number of live trees.
71
+ */
72
+ const MAX_LIVE_SESSIONS_HOST = 50;
73
+ /** A live session with no reads/writes/output for this long is expired. */
74
+ const SESSION_IDLE_TIMEOUT_MS = 60 * 60_000;
75
+ /** Absolute ceiling on a live session's age, idle or not. */
76
+ const SESSION_MAX_LIFETIME_MS = 12 * 60 * 60_000;
77
+ /** How often the expiry sweep runs. */
78
+ const SESSION_SWEEP_INTERVAL_MS = 60_000;
79
+ function positiveEnvNumber(name, fallback) {
80
+ const raw = Number(process.env[name]);
81
+ return Number.isFinite(raw) && raw > 0 ? raw : fallback;
82
+ }
83
+ function maxLiveSessionsHost() {
84
+ return Math.floor(positiveEnvNumber('FLUJO_BASH_MAX_LIVE_SESSIONS', MAX_LIVE_SESSIONS_HOST));
85
+ }
86
+ function sessionIdleTimeoutMs() {
87
+ return positiveEnvNumber('FLUJO_BASH_SESSION_IDLE_MS', SESSION_IDLE_TIMEOUT_MS);
88
+ }
89
+ function sessionMaxLifetimeMs() {
90
+ return positiveEnvNumber('FLUJO_BASH_SESSION_MAX_LIFETIME_MS', SESSION_MAX_LIFETIME_MS);
91
+ }
92
+ function commandMaxTimeoutMs() {
93
+ return positiveEnvNumber('FLUJO_BASH_COMMAND_MAX_TIMEOUT_MS', DEFAULT_MAX_TIMEOUT_MS);
94
+ }
95
+ /** `undefined` means no Bash-side timeout (the explicit timeout=-1 contract). */
96
+ function resolveCommandTimeoutMs(value) {
97
+ if (value === -1)
98
+ return undefined;
99
+ const requested = typeof value === 'number' && Number.isFinite(value) && value > 0
100
+ ? value * 1_000
101
+ : DEFAULT_TIMEOUT_MS;
102
+ return Math.min(requested, commandMaxTimeoutMs());
103
+ }
104
+ /** Test seam for the public timeout contract; not used by the runtime. */
105
+ export function _resolveCommandTimeoutMsForTests(value) {
106
+ return resolveCommandTimeoutMs(value);
107
+ }
54
108
  /**
55
109
  * Locate an executable by name on `PATH` ourselves (rather than relying on
56
110
  * `spawn`'s own resolution) so we can know in advance whether `pwsh`/`bash`
@@ -150,9 +204,14 @@ function isWindowsWslBashLauncher(candidate) {
150
204
  if (process.platform !== 'win32')
151
205
  return false;
152
206
  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();
207
+ const resolved = path.resolve(candidate).toLowerCase();
208
+ // The legacy WSL relay may be reached through System32, SysWOW64, or a
209
+ // SystemRoot-relative shim. It is not a usable POSIX interpreter without a
210
+ // provisioned distro, so never select it as the Bash MCP's `bash` shell.
211
+ if (systemRoot && resolved.startsWith(`${path.resolve(systemRoot).toLowerCase()}${path.sep}`)
212
+ && path.basename(resolved) === 'bash.exe')
213
+ return true;
214
+ return /[\\/]WindowsApps[\\/].*wsl.*[\\/]bash\.exe$/i.test(resolved);
156
215
  }
157
216
  let cachedBashPath;
158
217
  function resolveBashExecutable() {
@@ -211,11 +270,96 @@ function resolveCmdExecutable() {
211
270
  path.join(getEnvCaseInsensitive('SystemRoot') ?? 'C:\\Windows', 'System32', 'cmd.exe'),
212
271
  ]);
213
272
  }
273
+ function resolveShExecutable() {
274
+ if (process.platform === 'win32')
275
+ return findExecutableOnPath('sh');
276
+ return firstExistingFile(['/bin/sh', findExecutableOnPath('sh')]);
277
+ }
278
+ /**
279
+ * Interpreters and utilities probed by `shell_info` (issue #364) so a caller can
280
+ * discover the machine up front instead of rediscovering it by failed commands.
281
+ */
282
+ const PROBED_BINARIES = [
283
+ 'python', 'python3', 'node', 'npm', 'npx', 'git', 'rg',
284
+ 'ffmpeg', 'ffprobe', 'curl', 'tar', 'grep', 'sed', 'awk', 'head', 'tail',
285
+ ];
286
+ /**
287
+ * A zero-byte Windows Store app-execution alias (typically `python3.exe` under
288
+ * `WindowsApps`) resolves on PATH but only opens the Store when executed.
289
+ */
290
+ function isWindowsStoreAliasStub(candidate) {
291
+ if (!candidate || process.platform !== 'win32')
292
+ return false;
293
+ if (!/[\\/]WindowsApps[\\/]/i.test(candidate))
294
+ return false;
295
+ try {
296
+ return fs.statSync(candidate).size === 0;
297
+ }
298
+ catch {
299
+ return true;
300
+ }
301
+ }
302
+ function shellEntry(shell, resolved) {
303
+ return { shell, available: Boolean(resolved), path: resolved };
304
+ }
305
+ let cachedShellInfo;
306
+ /** Describe the shells and interpreters actually available on this machine. */
307
+ export function collectShellInfo() {
308
+ if (cachedShellInfo)
309
+ return cachedShellInfo;
310
+ const defaultPlan = buildSpawn('', 'default');
311
+ const shells = [
312
+ shellEntry('pwsh', resolvePwshExecutable()),
313
+ shellEntry('powershell', resolveWindowsPowerShellExecutable()),
314
+ shellEntry('bash', resolveBashExecutable()),
315
+ shellEntry('cmd', resolveCmdExecutable()),
316
+ shellEntry('sh', resolveShExecutable()),
317
+ ];
318
+ const binaries = PROBED_BINARIES.map((name) => {
319
+ const resolved = findExecutableOnPath(name);
320
+ if (isWindowsStoreAliasStub(resolved)) {
321
+ return { name, found: false, path: resolved, alias: true };
322
+ }
323
+ return { name, found: Boolean(resolved), path: resolved };
324
+ });
325
+ const notes = [
326
+ 'Tool output merges stdout and stderr into one "output" field; "isError" reflects the process exit code only.',
327
+ `The "default" shell on this machine is "${defaultPlan.effectiveShell}".`,
328
+ 'Pass shell:"bash" for POSIX syntax (&&, pipes into head/grep, $(…)); an unavailable explicit shell fails fast.',
329
+ ];
330
+ if (!shells.find((entry) => entry.shell === 'pwsh')?.available
331
+ && shells.find((entry) => entry.shell === 'powershell')?.available) {
332
+ notes.push('Only Windows PowerShell 5.1 is installed: "&&" and "||" are not valid statement separators there.');
333
+ }
334
+ cachedShellInfo = {
335
+ platform: process.platform,
336
+ defaultShell: defaultPlan.effectiveShell,
337
+ defaultShellPath: defaultPlan.file || null,
338
+ shells,
339
+ binaries,
340
+ notes,
341
+ };
342
+ return cachedShellInfo;
343
+ }
344
+ function availableShellNames() {
345
+ return collectShellInfo().shells.filter((entry) => entry.available).map((entry) => entry.shell);
346
+ }
347
+ function unavailableShellHint(shell) {
348
+ if (shell === 'bash' && process.platform === 'win32'
349
+ && findExecutablesOnPath('bash').some(isWindowsWslBashLauncher)) {
350
+ 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.';
351
+ }
352
+ return 'Call "shell_info" to see which shells and interpreters exist on this machine.';
353
+ }
354
+ function shellInfoTool() {
355
+ return textResult(collectShellInfo());
356
+ }
214
357
  /** Test-only: forget cached shell-executable lookups. */
215
358
  export function _resetBashShellCacheForTests() {
216
359
  cachedBashPath = undefined;
217
360
  cachedPwshPath = undefined;
218
361
  cachedWindowsPowerShellPath = undefined;
362
+ cachedShellInfo = undefined;
219
363
  }
220
364
  function sessions() {
221
365
  if (!global.__flujo_bash_sessions)
@@ -244,11 +388,203 @@ function ownedSession(id, ownerScope) {
244
388
  const session = sessions().get(id);
245
389
  return session?.ownerScope === ownerScope ? session : undefined;
246
390
  }
391
+ /** Mark a session as active so the idle sweep leaves it alone. */
392
+ function touchSession(session) {
393
+ session.lastActivityAt = Date.now();
394
+ }
395
+ /** Live sessions across BOTH tables — the quantity the host cap bounds. */
396
+ function liveSessionCount() {
397
+ let count = 0;
398
+ for (const session of sessions().values())
399
+ if (session.running)
400
+ count += 1;
401
+ for (const terminal of terminalSessions().values())
402
+ if (terminal.running)
403
+ count += 1;
404
+ return count;
405
+ }
406
+ /** Live sessions for one owner across both tables. */
407
+ function ownerLiveSessionCount(ownerScope) {
408
+ let count = 0;
409
+ for (const session of sessions().values()) {
410
+ if (session.running && session.ownerScope === ownerScope)
411
+ count += 1;
412
+ }
413
+ for (const terminal of terminalSessions().values()) {
414
+ if (terminal.running && terminal.ownerScope === ownerScope)
415
+ count += 1;
416
+ }
417
+ return count;
418
+ }
419
+ /** Terminate one background session's whole process tree and drop its timers. */
420
+ function terminateSession(session) {
421
+ if (session.running) {
422
+ try {
423
+ session.cancelEscalation = killProcessTree(session.child);
424
+ }
425
+ catch {
426
+ /* best-effort */
427
+ }
428
+ }
429
+ if (session.reapTimer) {
430
+ clearTimeout(session.reapTimer);
431
+ session.reapTimer = undefined;
432
+ }
433
+ }
434
+ /** Terminate one PTY session and drop its timers. */
435
+ function terminateTerminal(terminal) {
436
+ try {
437
+ if (terminal.running) {
438
+ terminal.running = false;
439
+ terminal.pty.kill();
440
+ }
441
+ }
442
+ catch {
443
+ /* best-effort */
444
+ }
445
+ if (terminal.reapTimer) {
446
+ clearTimeout(terminal.reapTimer);
447
+ terminal.reapTimer = undefined;
448
+ }
449
+ }
450
+ /**
451
+ * Atomically remove and terminate every NON-DETACHED session of one owner.
452
+ *
453
+ * Called when the owning FLUJO run reaches a terminal state. Removing the map
454
+ * entry BEFORE killing means a concurrent release (or a second call from a retry
455
+ * path) cannot observe the same session twice, so the operation is idempotent -
456
+ * repeated releases of the same scope are a no-op instead of a double kill
457
+ * against a possibly-recycled pid.
458
+ */
459
+ export function releaseOwnerScope(ownerScope) {
460
+ const result = {
461
+ ownerScope,
462
+ killedSessions: [],
463
+ closedTerminals: [],
464
+ retainedDetached: [],
465
+ };
466
+ for (const [id, session] of Array.from(sessions())) {
467
+ if (session.ownerScope !== ownerScope)
468
+ continue;
469
+ if (session.detached) {
470
+ if (session.running)
471
+ result.retainedDetached.push(id);
472
+ continue;
473
+ }
474
+ sessions().delete(id);
475
+ if (session.running)
476
+ result.killedSessions.push(id);
477
+ terminateSession(session);
478
+ }
479
+ for (const [id, terminal] of Array.from(terminalSessions())) {
480
+ if (terminal.ownerScope !== ownerScope)
481
+ continue;
482
+ if (terminal.detached) {
483
+ if (terminal.running)
484
+ result.retainedDetached.push(id);
485
+ continue;
486
+ }
487
+ terminalSessions().delete(id);
488
+ if (terminal.running)
489
+ result.closedTerminals.push(id);
490
+ terminateTerminal(terminal);
491
+ }
492
+ if (result.killedSessions.length || result.closedTerminals.length) {
493
+ log.info('Released owner-scoped bash sessions', {
494
+ ownerScope,
495
+ killed: result.killedSessions.length,
496
+ closedTerminals: result.closedTerminals.length,
497
+ retainedDetached: result.retainedDetached.length,
498
+ });
499
+ }
500
+ return result;
501
+ }
502
+ /**
503
+ * Expire live sessions that exceeded the idle timeout or the absolute lifetime.
504
+ *
505
+ * Detached sessions are exempt from IDLE expiry (being idle is often the point of
506
+ * a detached watcher) but NOT from the absolute lifetime ceiling - otherwise
507
+ * `detached` would be an unbounded leak with extra steps.
508
+ */
509
+ export function expireStaleSessions(now = Date.now()) {
510
+ const idleLimit = sessionIdleTimeoutMs();
511
+ const lifetimeLimit = sessionMaxLifetimeMs();
512
+ const expired = [];
513
+ for (const [id, session] of Array.from(sessions())) {
514
+ if (!session.running)
515
+ continue;
516
+ const tooOld = now - session.startedAt >= lifetimeLimit;
517
+ const tooIdle = !session.detached && now - session.lastActivityAt >= idleLimit;
518
+ if (!tooOld && !tooIdle)
519
+ continue;
520
+ sessions().delete(id);
521
+ terminateSession(session);
522
+ expired.push(id);
523
+ }
524
+ for (const [id, terminal] of Array.from(terminalSessions())) {
525
+ if (!terminal.running)
526
+ continue;
527
+ const tooOld = now - terminal.startedAt >= lifetimeLimit;
528
+ const tooIdle = !terminal.detached && now - terminal.lastActivityAt >= idleLimit;
529
+ if (!tooOld && !tooIdle)
530
+ continue;
531
+ terminalSessions().delete(id);
532
+ terminateTerminal(terminal);
533
+ expired.push(id);
534
+ }
535
+ if (expired.length)
536
+ log.info('Expired stale bash sessions', { count: expired.length });
537
+ return expired;
538
+ }
539
+ /** Bounded per-session diagnostics: identity/state only, never output. */
540
+ export function bashSessionDiagnostics() {
541
+ const now = Date.now();
542
+ const rows = [];
543
+ for (const session of sessions().values()) {
544
+ rows.push({
545
+ sessionId: session.id,
546
+ type: 'background',
547
+ ownerScope: session.ownerScope,
548
+ running: session.running,
549
+ detached: session.detached === true,
550
+ ageMs: now - session.startedAt,
551
+ idleMs: now - session.lastActivityAt,
552
+ });
553
+ }
554
+ for (const terminal of terminalSessions().values()) {
555
+ rows.push({
556
+ sessionId: terminal.id,
557
+ type: 'terminal',
558
+ ownerScope: terminal.ownerScope,
559
+ running: terminal.running,
560
+ detached: terminal.detached === true,
561
+ ageMs: now - terminal.startedAt,
562
+ idleMs: now - terminal.lastActivityAt,
563
+ });
564
+ }
565
+ return {
566
+ liveSessions: liveSessionCount(),
567
+ maxLiveSessions: maxLiveSessionsHost(),
568
+ sessions: rows,
569
+ };
570
+ }
247
571
  function terminalMeta(visibility = ['model', 'app']) {
248
572
  return { ui: { resourceUri: BASH_TERMINAL_APP_URI, visibility } };
249
573
  }
250
- /** Kill every live session's process tree — used on FLUJO process exit. */
574
+ /**
575
+ * Kill every live session's process tree - used on Bash server shutdown and on
576
+ * FLUJO process exit.
577
+ *
578
+ * Idempotent and DETACHED-INCLUSIVE: a detached session survives owner release,
579
+ * but nothing survives the death of the server that owns its pipes. Timers are
580
+ * cleared too, so a repeated call cannot leave a handle keeping the event loop
581
+ * alive after "shutdown".
582
+ */
251
583
  export function shutdownBashSessions() {
584
+ if (global.__flujo_bash_sweep_timer) {
585
+ clearInterval(global.__flujo_bash_sweep_timer);
586
+ global.__flujo_bash_sweep_timer = undefined;
587
+ }
252
588
  for (const child of foregroundChildren()) {
253
589
  try {
254
590
  killProcessTree(child);
@@ -266,8 +602,10 @@ export function shutdownBashSessions() {
266
602
  /* best-effort */
267
603
  }
268
604
  }
269
- if (s.reapTimer)
605
+ if (s.reapTimer) {
270
606
  clearTimeout(s.reapTimer);
607
+ s.reapTimer = undefined;
608
+ }
271
609
  }
272
610
  for (const terminal of terminalSessions().values()) {
273
611
  try {
@@ -279,11 +617,27 @@ export function shutdownBashSessions() {
279
617
  catch {
280
618
  /* best-effort */
281
619
  }
282
- if (terminal.reapTimer)
620
+ if (terminal.reapTimer) {
283
621
  clearTimeout(terminal.reapTimer);
622
+ terminal.reapTimer = undefined;
623
+ }
284
624
  }
285
625
  }
286
626
  function registerExitCleanup() {
627
+ if (!global.__flujo_bash_sweep_timer) {
628
+ // Idle/max-lifetime expiry needs a periodic sweep: a session that simply
629
+ // stops producing output never fires an event we could hang the check on.
630
+ const timer = setInterval(() => {
631
+ try {
632
+ expireStaleSessions();
633
+ }
634
+ catch {
635
+ /* a sweep failure must never crash the server */
636
+ }
637
+ }, SESSION_SWEEP_INTERVAL_MS);
638
+ timer.unref?.();
639
+ global.__flujo_bash_sweep_timer = timer;
640
+ }
287
641
  if (global.__flujo_bash_cleanup_registered)
288
642
  return;
289
643
  global.__flujo_bash_cleanup_registered = true;
@@ -323,6 +677,27 @@ async function resolveCwd(input, roots) {
323
677
  }
324
678
  return resolved;
325
679
  }
680
+ /**
681
+ * Resolve an `outputFile` spool target under the same confinement rules as
682
+ * `cwd` (issue #364) and make sure its parent directory exists.
683
+ */
684
+ async function resolveOutputFile(input, cwd, roots) {
685
+ const raw = typeof input === 'string' ? input.trim() : '';
686
+ if (!raw)
687
+ throw new Error('"outputFile" must be a non-empty path.');
688
+ const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(cwd, raw);
689
+ if (await isProtectedPathsEnabled()) {
690
+ const prot = isProtected(resolved);
691
+ if (prot.denied) {
692
+ throw new Error(`outputFile "${resolved}" is within a protected location (${prot.matchedRoot}).`);
693
+ }
694
+ }
695
+ if (roots.length === 0 || !roots.some((root) => isInside(root, resolved))) {
696
+ throw new Error(`outputFile "${resolved}" is outside the configured bash roots.`);
697
+ }
698
+ await fs.promises.mkdir(path.dirname(resolved), { recursive: true });
699
+ return resolved;
700
+ }
326
701
  /**
327
702
  * Best-effort advisory scan (issue #260, item 4) of a command string for
328
703
  * absolute-looking paths that point OUTSIDE the configured roots or INTO a
@@ -359,6 +734,47 @@ function maskGlobOptionValues(command) {
359
734
  }
360
735
  return chars.join('');
361
736
  }
737
+ /**
738
+ * Windows utilities that take `/x`-style switches (issue #314, item D). A slash
739
+ * token is only demoted from "absolute path" to "switch" when one of these
740
+ * appears EARLIER IN THE SAME command segment, so `cd … && dir /b && rg …` and
741
+ * `echo dir /b` stay quiet while `echo /.git/config` keeps its advisory.
742
+ * Deliberately excludes names that are also common POSIX utilities (`find`,
743
+ * `sort`, `type`, `more`, `mkdir`, `rmdir`) — suppressing their arguments would
744
+ * silence genuine path advisories such as `find /etc -name passwd`.
745
+ */
746
+ const WINDOWS_SWITCH_UTILITIES = new Set([
747
+ 'dir', 'xcopy', 'robocopy', 'copy', 'move', 'del', 'erase', 'attrib', 'icacls', 'takeown',
748
+ 'findstr', 'tasklist', 'taskkill', 'sc', 'reg', 'net', 'subst', 'where', 'ren', 'rename',
749
+ 'md', 'rd', 'tree', 'fc', 'comp', 'cacls', 'schtasks', 'chkdsk', 'sfc', 'shutdown',
750
+ 'wmic', 'diskpart', 'label', 'vol', 'mklink', 'assoc', 'ftype', 'gpupdate', 'powercfg',
751
+ ]);
752
+ /**
753
+ * A cmd-style switch: `/b`, `/ad`, `/mir`, `/e:on`. Never matches a nested path
754
+ * (no interior separator), so `/etc/passwd` is untouched.
755
+ */
756
+ const WINDOWS_SWITCH_RE = /^\/[A-Za-z][A-Za-z0-9?*-]{0,15}(?::[^\s"']*)?$/;
757
+ /** Text of the command segment (split on shell separators) preceding `index`. */
758
+ function segmentTextBefore(command, index) {
759
+ const separators = /&&|\|\||[|;&\n]/g;
760
+ let start = 0;
761
+ let match;
762
+ while ((match = separators.exec(command)) !== null) {
763
+ if (match.index >= index)
764
+ break;
765
+ start = match.index + match[0].length;
766
+ }
767
+ return command.slice(start, index);
768
+ }
769
+ /** True when a slash-switch utility heads the segment containing `index`. */
770
+ function windowsSwitchUtilityPrecedes(command, index) {
771
+ for (const word of segmentTextBefore(command, index).match(/[^\s"'<>()]+/g) ?? []) {
772
+ const name = word.replace(/^.*[\\/]/, '').toLowerCase().replace(/\.(?:exe|com|bat|cmd)$/, '');
773
+ if (WINDOWS_SWITCH_UTILITIES.has(name))
774
+ return true;
775
+ }
776
+ return false;
777
+ }
362
778
  async function scanCommandForExternalPaths(command, cwd, roots) {
363
779
  const warnings = [];
364
780
  const protectedPathsEnabled = await isProtectedPathsEnabled();
@@ -369,10 +785,19 @@ async function scanCommandForExternalPaths(command, cwd, roots) {
369
785
  // Absolute-looking tokens: Windows drive (X:\ or X:/), UNC (\\host\share),
370
786
  // POSIX (/foo), and ~-prefixed home paths.
371
787
  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)));
788
+ const matches = [...commandForPathScan.matchAll(tokenRe)]
789
+ .filter((match) => {
790
+ if (process.platform !== 'win32')
791
+ return true;
792
+ // Two independent conditions must hold before a token is dropped: it must
793
+ // LOOK like a cmd switch, and a slash-switch utility must appear earlier
794
+ // in the SAME segment (`… && dir /b && …`). This keeps `/etc/passwd` and
795
+ // other genuine POSIX paths advisory.
796
+ if (!WINDOWS_SWITCH_RE.test(match[0]))
797
+ return true;
798
+ return !windowsSwitchUtilityPrecedes(commandForPathScan, match.index ?? 0);
799
+ })
800
+ .map((match) => match[0]);
376
801
  const home = (() => {
377
802
  try {
378
803
  return getHomeDir();
@@ -499,6 +924,17 @@ function buildChildEnv(overrides = {}) {
499
924
  }
500
925
  out.PATHEXT = windowsExecutableExtensions().join(';');
501
926
  }
927
+ // Deterministic text encoding and number formatting for children (issue #364):
928
+ // without this, interpreters emit locale-dependent decimal commas and non-UTF-8
929
+ // bytes that come back as mojibake. Explicit per-call `env` still wins below.
930
+ if (out.PYTHONIOENCODING === undefined)
931
+ out.PYTHONIOENCODING = 'utf-8';
932
+ if (process.platform !== 'win32') {
933
+ if (!out.LANG)
934
+ out.LANG = 'C.UTF-8';
935
+ if (!out.LC_ALL)
936
+ out.LC_ALL = 'C.UTF-8';
937
+ }
502
938
  for (const [key, value] of Object.entries(overrides)) {
503
939
  if (process.platform === 'win32') {
504
940
  for (const existing of Object.keys(out)) {
@@ -528,6 +964,35 @@ function validateEnv(input) {
528
964
  }
529
965
  return { valid: true, env };
530
966
  }
967
+ /**
968
+ * PowerShell prelude (issue #364). It makes three platform traps go away for
969
+ * every command we run:
970
+ * - stderr from a native binary no longer turns into a terminating error, so
971
+ * the reported exit code is the native process' own,
972
+ * - console output is forced to UTF-8 (otherwise logs come back as UTF-16 or
973
+ * OEM mojibake),
974
+ * - the culture is pinned to invariant, so numbers never use a decimal comma.
975
+ * The epilogue re-surfaces the native exit code (`$LASTEXITCODE`), falling back
976
+ * to `$?` when no native command ran.
977
+ */
978
+ const POWERSHELL_PRELUDE = [
979
+ "$ErrorActionPreference='Continue'",
980
+ "$ProgressPreference='SilentlyContinue'",
981
+ 'try { [Console]::OutputEncoding=[Text.Encoding]::UTF8; $OutputEncoding=[Text.Encoding]::UTF8 } catch {}',
982
+ 'try { [Threading.Thread]::CurrentThread.CurrentCulture=[Globalization.CultureInfo]::InvariantCulture;'
983
+ + ' [Threading.Thread]::CurrentThread.CurrentUICulture=[Globalization.CultureInfo]::InvariantCulture } catch {}',
984
+ ].join('; ');
985
+ const POWERSHELL_EPILOGUE = 'exit $(if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 })';
986
+ export function wrapPowerShellCommand(command) {
987
+ return `${POWERSHELL_PRELUDE}\n${command}\n${POWERSHELL_EPILOGUE}`;
988
+ }
989
+ /** `chcp 65001` forces UTF-8 output for cmd.exe children (issue #364). */
990
+ export function wrapCmdCommand(command) {
991
+ return `chcp 65001>nul & ${command}`;
992
+ }
993
+ function powerShellArgs(command) {
994
+ return ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', wrapPowerShellCommand(command)];
995
+ }
531
996
  /**
532
997
  * Build the spawn arguments for the requested shell. Returns the command, argv
533
998
  * and whether Node's `shell:true` wrapping applies. Shells are resolved
@@ -539,7 +1004,20 @@ function buildSpawn(command, shell) {
539
1004
  if (shell === 'pwsh') {
540
1005
  const resolved = resolvePwshExecutable();
541
1006
  if (resolved) {
542
- return { file: resolved, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], useShell: false, effectiveShell: 'pwsh' };
1007
+ return { file: resolved, args: powerShellArgs(command), useShell: false, effectiveShell: 'pwsh' };
1008
+ }
1009
+ const windowsPowerShell = resolveWindowsPowerShellExecutable();
1010
+ if (windowsPowerShell) {
1011
+ return {
1012
+ file: windowsPowerShell,
1013
+ args: powerShellArgs(command),
1014
+ useShell: false,
1015
+ effectiveShell: 'powershell',
1016
+ shellSubstitution: {
1017
+ requested: 'pwsh', used: 'powershell',
1018
+ reason: 'PowerShell 7 (pwsh) is not installed on this machine.',
1019
+ },
1020
+ };
543
1021
  }
544
1022
  return { file: '', args: [], useShell: false, effectiveShell: 'pwsh', unavailableShell: 'pwsh' };
545
1023
  }
@@ -555,7 +1033,7 @@ function buildSpawn(command, shell) {
555
1033
  if (resolved) {
556
1034
  return {
557
1035
  file: resolved,
558
- args: ['/d', '/s', '/c', command],
1036
+ args: ['/d', '/s', '/c', wrapCmdCommand(command)],
559
1037
  useShell: false,
560
1038
  effectiveShell: 'cmd',
561
1039
  windowsVerbatimArguments: true,
@@ -566,17 +1044,17 @@ function buildSpawn(command, shell) {
566
1044
  if (process.platform === 'win32') {
567
1045
  const pwsh = resolvePwshExecutable();
568
1046
  if (pwsh) {
569
- return { file: pwsh, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], useShell: false, effectiveShell: 'pwsh' };
1047
+ return { file: pwsh, args: powerShellArgs(command), useShell: false, effectiveShell: 'pwsh' };
570
1048
  }
571
1049
  const windowsPowerShell = resolveWindowsPowerShellExecutable();
572
1050
  if (windowsPowerShell) {
573
- return { file: windowsPowerShell, args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], useShell: false, effectiveShell: 'powershell' };
1051
+ return { file: windowsPowerShell, args: powerShellArgs(command), useShell: false, effectiveShell: 'powershell' };
574
1052
  }
575
1053
  const cmd = resolveCmdExecutable();
576
1054
  if (cmd) {
577
1055
  return {
578
1056
  file: cmd,
579
- args: ['/d', '/s', '/c', command],
1057
+ args: ['/d', '/s', '/c', wrapCmdCommand(command)],
580
1058
  useShell: false,
581
1059
  effectiveShell: 'cmd',
582
1060
  windowsVerbatimArguments: true,
@@ -632,7 +1110,7 @@ function invalidShellResult(requestedShell) {
632
1110
  }, true);
633
1111
  }
634
1112
  function startChild(command, cwd, shell, env) {
635
- const { file, args, useShell, effectiveShell, unavailableShell, startError, windowsVerbatimArguments, } = buildSpawn(command, shell);
1113
+ const { file, args, useShell, effectiveShell, unavailableShell, startError, windowsVerbatimArguments, shellSubstitution, } = buildSpawn(command, shell);
636
1114
  if (unavailableShell)
637
1115
  return { effectiveShell, unavailableShell };
638
1116
  if (startError)
@@ -647,7 +1125,7 @@ function startChild(command, cwd, shell, env) {
647
1125
  detached,
648
1126
  ...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
649
1127
  });
650
- return { child, effectiveShell };
1128
+ return { child, effectiveShell, shellSubstitution };
651
1129
  }
652
1130
  catch (err) {
653
1131
  return {
@@ -656,21 +1134,212 @@ function startChild(command, cwd, shell, env) {
656
1134
  };
657
1135
  }
658
1136
  }
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);
1137
+ /**
1138
+ * Bounded output accumulator (issue #364). Retention is head + tail rather than
1139
+ * head-only, so the command echo at the start AND the final result at the end
1140
+ * both survive an output flood (e.g. a 50k-line ffmpeg progress log); the
1141
+ * omitted middle is reported explicitly.
1142
+ */
1143
+ function makeAppender(get, set, maxChars = MAX_OUTPUT_CHARS) {
1144
+ const limit = Math.max(1_000, Math.min(Math.floor(maxChars) || MAX_OUTPUT_CHARS, MAX_OUTPUT_CHARS));
1145
+ const headLimit = Math.max(1, Math.floor(limit * 0.3));
1146
+ const tailLimit = Math.max(1, limit - headLimit);
1147
+ let head = '';
1148
+ let tail = '';
1149
+ let dropped = 0;
1150
+ let total = 0;
1151
+ const compose = () => (dropped > 0
1152
+ ? head + '\n…[' + dropped + ' characters of output omitted]…\n' + tail
1153
+ : head + tail);
1154
+ const append = ((chunk) => {
1155
+ if (!chunk)
665
1156
  return;
1157
+ total += chunk.length;
1158
+ let rest = chunk;
1159
+ if (head.length < headLimit) {
1160
+ const take = Math.min(headLimit - head.length, rest.length);
1161
+ head += rest.slice(0, take);
1162
+ rest = rest.slice(take);
666
1163
  }
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;
1164
+ if (rest) {
1165
+ tail += rest;
1166
+ if (tail.length > tailLimit) {
1167
+ const remove = tail.length - tailLimit;
1168
+ tail = tail.slice(remove);
1169
+ dropped += remove;
1170
+ }
1171
+ }
1172
+ set(compose(), dropped > 0);
1173
+ });
1174
+ append.totalChars = () => total;
1175
+ append.droppedChars = () => dropped;
1176
+ // `get` is retained for call-site symmetry with the previous signature.
1177
+ void get;
1178
+ return append;
1179
+ }
1180
+ /**
1181
+ * Shell-dialect trap detection (issue #364). Commands are NEVER rewritten —
1182
+ * translating shell text between dialects is far riskier than executing what
1183
+ * the caller wrote — so every finding is returned as an advisory
1184
+ * `dialectWarnings` entry with a concrete suggested fix.
1185
+ */
1186
+ const POSIX_ONLY_BINARIES = new Set([
1187
+ 'head', 'tail', 'grep', 'sed', 'awk', 'wc', 'cat', 'cut', 'tr', 'uniq', 'sort', 'xargs', 'touch', 'which',
1188
+ 'rg', 'jq', 'find', 'ls', 'pwd', 'less', 'du', 'df',
1189
+ ]);
1190
+ const SHELL_BUILTINS = new Set([
1191
+ 'cd', 'echo', 'exit', 'export', 'set', 'unset', 'if', 'then', 'else', 'fi', 'for', 'while', 'do', 'done',
1192
+ 'true', 'false', 'test', 'type', 'alias', 'function', 'return', 'shift', 'source', '.', 'dir', 'cls',
1193
+ 'copy', 'del', 'erase', 'md', 'mkdir', 'rd', 'rmdir', 'move', 'ren', 'rename', 'pushd', 'popd',
1194
+ ]);
1195
+ function isMissingCommandCandidate(head, shell) {
1196
+ const name = path.basename(head).toLowerCase().replace(/\.exe$/, '');
1197
+ if (!name || SHELL_BUILTINS.has(name) || /^[-/$]/.test(head) || /^[A-Za-z_][\w-]*=/.test(head))
1198
+ return false;
1199
+ if (/^[A-Za-z]+-[A-Za-z][A-Za-z-]*$/.test(head) && (shell === 'pwsh' || shell === 'powershell'))
1200
+ return false;
1201
+ return !/[\\/]/.test(head) && !/^\.?\.?$/.test(head);
1202
+ }
1203
+ /** Blank out quoted spans so operators inside string literals never trip us. */
1204
+ function stripQuotedSegments(command) {
1205
+ return command.replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, (match) => ' '.repeat(match.length));
1206
+ }
1207
+ function commandSegmentHeads(stripped) {
1208
+ return stripped
1209
+ .split(/&&|\|\||[|;\n]/)
1210
+ .map((segment) => segment.trim().split(/\s+/)[0] ?? '')
1211
+ .map((head) => head.replace(/^[('"`]+/, ''))
1212
+ .filter(Boolean);
1213
+ }
1214
+ /** True when the command relies on POSIX `&&` / `||` statement chaining. */
1215
+ export function commandUsesPosixChaining(command) {
1216
+ return /&&|\|\|/.test(stripQuotedSegments(command));
1217
+ }
1218
+ export function detectDialectMismatch(command, shell, isAvailable = (name) => Boolean(findExecutableOnPath(name))) {
1219
+ const warnings = [];
1220
+ const stripped = stripQuotedSegments(command);
1221
+ const heads = commandSegmentHeads(stripped);
1222
+ const posixShell = shell === 'bash' || shell === 'sh';
1223
+ if (!posixShell) {
1224
+ for (const head of new Set(heads)) {
1225
+ const name = path.basename(head).toLowerCase().replace(/\.exe$/, '');
1226
+ if (!isMissingCommandCandidate(head, shell) || isAvailable(name))
1227
+ continue;
1228
+ if (POSIX_ONLY_BINARIES.has(name)) {
1229
+ warnings.push(`"${name}" is a POSIX utility and is not available as an executable on this machine; `
1230
+ + `under ${shell} it resolves to nothing (or to an unrelated alias). Pass shell:"bash" or use the native equivalent.`);
1231
+ }
1232
+ else {
1233
+ warnings.push(`"${name}" was not found on PATH; under ${shell} this will fail with a "command not found" error. `
1234
+ + 'Install it, use the native equivalent, or call "shell_info".');
1235
+ }
1236
+ }
1237
+ }
1238
+ if (shell === 'powershell' && /&&|\|\|/.test(stripped)) {
1239
+ warnings.push('"&&" and "||" are not statement separators in Windows PowerShell 5.1 (they are a parse error). '
1240
+ + 'Use ";" for unconditional chaining, or pass shell:"bash" / shell:"pwsh".');
1241
+ }
1242
+ if (shell === 'pwsh' || shell === 'powershell') {
1243
+ if (/\$\(\s*([A-Za-z_][\w-]*)/.test(stripped)) {
1244
+ const inner = /\$\(\s*([A-Za-z_][\w-]*)/.exec(stripped)?.[1] ?? '';
1245
+ if (POSIX_ONLY_BINARIES.has(inner.toLowerCase()) && !isAvailable(inner.toLowerCase())) {
1246
+ warnings.push(`"$(${inner} …)" is POSIX command substitution; PowerShell evaluates it as a subexpression `
1247
+ + 'and cannot run that utility. Pass shell:"bash".');
1248
+ }
1249
+ }
1250
+ if ((stripped.match(/`/g) ?? []).length >= 2) {
1251
+ warnings.push('Backticks are the line-continuation/escape character in PowerShell, not command substitution. '
1252
+ + 'Use "$(…)" for PowerShell, or pass shell:"bash".');
1253
+ }
1254
+ if (/2>&1/.test(stripped)) {
1255
+ warnings.push('No "2>&1" redirection is needed: this tool already merges stdout and stderr into one "output" field, '
1256
+ + 'and "isError" is derived from the exit code only.');
1257
+ }
1258
+ }
1259
+ if (shell === 'cmd') {
1260
+ if (/'/.test(command.replace(/"(?:\\.|[^"\\])*"/g, ''))) {
1261
+ warnings.push('cmd.exe does not treat single quotes as string delimiters; use double quotes.');
1262
+ }
1263
+ if (/\$\(|`/.test(stripped)) {
1264
+ warnings.push('Command substitution ($(…) / backticks) is not supported by cmd.exe. Pass shell:"bash" or shell:"pwsh".');
1265
+ }
1266
+ }
1267
+ if (posixShell && /\b(?:Get|Set|New|Remove|Write|Select|Out|Format|Test|Invoke|Start|Stop)-[A-Z][A-Za-z]+\b/.test(stripped)) {
1268
+ warnings.push('This looks like a PowerShell cmdlet but the command is running under a POSIX shell. Pass shell:"pwsh".');
1269
+ }
1270
+ return warnings;
1271
+ }
1272
+ function missingExecutableHint(exitCode, warnings) {
1273
+ if ((exitCode === 9009 || exitCode === 255 || exitCode === 1)
1274
+ && warnings.some((warning) => /not found on PATH|not available as an executable/.test(warning))) {
1275
+ return { hint: 'A referenced executable was not found — see dialectWarnings.' };
1276
+ }
1277
+ return {};
1278
+ }
1279
+ /** Commands that typically hang forever waiting on a pager or interactive prompt. */
1280
+ export function detectInteractiveHangRisk(command) {
1281
+ const hints = [];
1282
+ const stripped = stripQuotedSegments(command);
1283
+ if (/\bgit\b(?![^|;]*--no-pager)[^|;]*\b(?:log|diff|show|branch)\b/.test(stripped)) {
1284
+ hints.push('git paginates by default; use "git --no-pager …" (or append "| cat") for non-interactive runs.');
1285
+ }
1286
+ if (/(^|[|;\s])(?:less|more)(\s|$)/.test(stripped)) {
1287
+ hints.push('"less"/"more" are interactive pagers and will never exit here; drop them.');
1288
+ }
1289
+ if (/Format-(?:Table|List)\b/.test(stripped) && !/Out-String/.test(stripped)) {
1290
+ hints.push('Pipe Format-Table/Format-List into "| Out-String" so PowerShell does not wait on the host formatter.');
1291
+ }
1292
+ if (/(^|[|;\s])(?:npm|yarn|pnpm)\s+(?:init|login)(\s|$)/.test(stripped)) {
1293
+ hints.push('This command prompts interactively; add its non-interactive flag (e.g. "npm init -y").');
1294
+ }
1295
+ return hints;
1296
+ }
1297
+ function validateEncoding(input) {
1298
+ if (input === undefined)
1299
+ return 'auto';
1300
+ if (input === 'utf8' || input === 'utf16le' || input === 'auto')
1301
+ return input;
1302
+ return null;
1303
+ }
1304
+ function looksLikeUtf16le(buf) {
1305
+ const len = Math.min(buf.length, 64);
1306
+ if (len < 4)
1307
+ return false;
1308
+ let nuls = 0;
1309
+ for (let i = 0; i < len; i += 1)
1310
+ if (buf[i] === 0)
1311
+ nuls += 1;
1312
+ return nuls / len > 0.3;
1313
+ }
1314
+ /**
1315
+ * Decode child output deterministically (issue #364). Windows tooling happily
1316
+ * writes UTF-16LE (e.g. PowerShell redirection into a log) which the default
1317
+ * UTF-8 decode turns into NUL-riddled mojibake. `auto` sniffs a BOM or a high
1318
+ * NUL ratio in the first chunk and then stays with that choice.
1319
+ */
1320
+ export function createStreamDecoder(mode = 'auto') {
1321
+ let resolved = mode === 'auto' ? null : mode;
1322
+ let first = true;
1323
+ return (chunk) => {
1324
+ if (typeof chunk === 'string')
1325
+ return chunk;
1326
+ let buf = chunk;
1327
+ if (first) {
1328
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
1329
+ if (resolved === null)
1330
+ resolved = 'utf16le';
1331
+ if (resolved === 'utf16le')
1332
+ buf = buf.subarray(2);
1333
+ }
1334
+ else if (resolved === null) {
1335
+ resolved = looksLikeUtf16le(buf) ? 'utf16le' : 'utf8';
1336
+ }
1337
+ first = false;
672
1338
  }
673
- set(out, truncated);
1339
+ let text = buf.toString(resolved ?? 'utf8');
1340
+ if (text.charCodeAt(0) === 0xfeff)
1341
+ text = text.slice(1);
1342
+ return text;
674
1343
  };
675
1344
  }
676
1345
  /** Resolve an interactive shell executable without wrapping a command string. */
@@ -723,6 +1392,9 @@ function createCommandProgressReporter(context) {
723
1392
  return { push: () => undefined, stop: async () => undefined };
724
1393
  const startedAt = Date.now();
725
1394
  const maxMessageChars = 4_000;
1395
+ // MCP requires `progress` to increase with every notification. It is a
1396
+ // notification sequence here (not a byte count), because silent-command
1397
+ // heartbeats must advance it too.
726
1398
  let progress = 0;
727
1399
  let pending = '';
728
1400
  let flushTimer;
@@ -733,7 +1405,7 @@ function createCommandProgressReporter(context) {
733
1405
  const deliver = (message) => {
734
1406
  if (!message)
735
1407
  return;
736
- const snapshot = progress;
1408
+ const snapshot = ++progress;
737
1409
  chain = chain
738
1410
  .then(() => report({ progress: snapshot, message }))
739
1411
  .catch((error) => {
@@ -763,7 +1435,6 @@ function createCommandProgressReporter(context) {
763
1435
  push: (chunk) => {
764
1436
  if (stopped || !chunk)
765
1437
  return;
766
- progress += Buffer.byteLength(chunk, 'utf8');
767
1438
  const remaining = Math.max(0, MAX_OUTPUT_CHARS - forwardedChars);
768
1439
  if (remaining > 0) {
769
1440
  const accepted = chunk.slice(0, remaining);
@@ -817,7 +1488,25 @@ export function bashToolDefinitions() {
817
1488
  additionalProperties: { type: 'string' },
818
1489
  description: 'Environment variables to add or override for this command.',
819
1490
  };
1491
+ const encodingProp = {
1492
+ type: 'string',
1493
+ enum: ['auto', 'utf8', 'utf16le'],
1494
+ description: 'How to decode child output. "auto" (default) detects UTF-16LE (BOM or NUL-heavy) and otherwise uses UTF-8.',
1495
+ };
1496
+ const maxOutputCharsProp = {
1497
+ type: 'number',
1498
+ 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.',
1499
+ };
1500
+ const outputFileProp = {
1501
+ type: 'string',
1502
+ description: 'Spool the full merged output to this file (confined to the configured roots) while the result stays bounded.',
1503
+ };
820
1504
  return [
1505
+ {
1506
+ name: 'shell_info',
1507
+ 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.',
1508
+ inputSchema: { type: 'object', properties: {} },
1509
+ },
821
1510
  {
822
1511
  name: 'open_terminal',
823
1512
  description: 'Open a real interactive pseudoterminal (PTY/ConPTY) and display its MCP App. Returns an owner-scoped terminal sessionId.',
@@ -830,6 +1519,10 @@ export function bashToolDefinitions() {
830
1519
  env: envProp,
831
1520
  cols: { type: 'number', description: 'Initial terminal columns (20-400, default 100).' },
832
1521
  rows: { type: 'number', description: 'Initial terminal rows (5-200, default 30).' },
1522
+ detached: {
1523
+ type: 'boolean',
1524
+ description: 'Keep the terminal alive after the run that opened it finishes (default false). Still bounded by the host session cap and the absolute max lifetime.',
1525
+ },
833
1526
  },
834
1527
  },
835
1528
  },
@@ -884,7 +1577,9 @@ export function bashToolDefinitions() {
884
1577
  },
885
1578
  {
886
1579
  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.',
1580
+ 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. '
1581
+ + '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. '
1582
+ + 'Use start/status for a persistent background session.',
888
1583
  inputSchema: {
889
1584
  type: 'object',
890
1585
  properties: {
@@ -892,15 +1587,22 @@ export function bashToolDefinitions() {
892
1587
  cwd: cwdProp,
893
1588
  shell: shellProp,
894
1589
  env: envProp,
895
- timeout: { type: 'number', description: 'Timeout in seconds (default 60, max 600).' },
1590
+ timeout: {
1591
+ type: 'number',
1592
+ description: 'Bash-side timeout in seconds (default 60; -1 disables it; positive values are capped at 12 hours by default). '
1593
+ + 'While running, output and 10-second liveness heartbeats are sent as MCP progress. On timeout the whole process tree is killed.',
1594
+ },
896
1595
  normalizeNewlines: { type: 'boolean', description: 'If true, CRLF/CR in the captured output are normalized to LF.' },
1596
+ encoding: encodingProp,
1597
+ maxOutputChars: maxOutputCharsProp,
1598
+ outputFile: outputFileProp,
897
1599
  },
898
1600
  required: ['command'],
899
1601
  },
900
1602
  },
901
1603
  {
902
1604
  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.',
1605
+ 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
1606
  inputSchema: {
905
1607
  type: 'object',
906
1608
  properties: {
@@ -908,6 +1610,13 @@ export function bashToolDefinitions() {
908
1610
  cwd: cwdProp,
909
1611
  shell: shellProp,
910
1612
  env: envProp,
1613
+ encoding: encodingProp,
1614
+ maxOutputChars: maxOutputCharsProp,
1615
+ outputFile: outputFileProp,
1616
+ detached: {
1617
+ type: 'boolean',
1618
+ description: 'Survive the end of the run that started it (default false). Detached sessions are still bounded by the host session cap and the absolute max lifetime, and are always killed when the bash server shuts down.',
1619
+ },
911
1620
  },
912
1621
  required: ['command'],
913
1622
  },
@@ -928,7 +1637,10 @@ export function bashToolDefinitions() {
928
1637
  type: 'object',
929
1638
  properties: {
930
1639
  sessionId: { type: 'string', description: 'The id returned by start.' },
931
- timeout: { type: 'number', description: 'Max seconds to wait (default 60, max 600).' },
1640
+ timeout: {
1641
+ type: 'number',
1642
+ description: 'Max seconds to wait (default 60; -1 waits until completion or cancellation; positive values are capped at 12 hours by default).',
1643
+ },
932
1644
  },
933
1645
  required: ['sessionId'],
934
1646
  },
@@ -957,7 +1669,12 @@ export function bashToolDefinitions() {
957
1669
  },
958
1670
  {
959
1671
  name: 'list_sessions',
960
- description: 'List background sessions owned by this caller scope. Returns { sessions: [{ sessionId, command, running, exitCode, startedAt, endedAt }] }.',
1672
+ description: 'List background sessions owned by this caller scope. Returns { sessions: [{ sessionId, command, running, exitCode, detached, startedAt, endedAt }] }.',
1673
+ inputSchema: { type: 'object', properties: {} },
1674
+ },
1675
+ {
1676
+ name: 'release_owner',
1677
+ description: 'Kill and forget every non-detached background and terminal session owned by this caller scope. Idempotent. Sessions started with "detached": true are retained. FLUJO calls this when a run finishes; the scope is host-derived, so a caller can only release its own sessions. Returns { ownerScope, killedSessions, closedTerminals, retainedDetached }.',
961
1678
  inputSchema: { type: 'object', properties: {} },
962
1679
  },
963
1680
  ];
@@ -965,6 +1682,68 @@ export function bashToolDefinitions() {
965
1682
  function maybeNormalize(text, normalize) {
966
1683
  return normalize ? text.replace(/\r\n?/g, '\n') : text;
967
1684
  }
1685
+ /**
1686
+ * Only when the caller said `shell: "default"` may we pick a different shell
1687
+ * (issue #364): a command chained with POSIX `&&`/`||` cannot even parse under
1688
+ * Windows PowerShell 5.1, so prefer a shell that can run it. An explicitly
1689
+ * requested shell is never overridden.
1690
+ */
1691
+ function selectShell(command, requested) {
1692
+ if (requested !== 'default')
1693
+ return { shell: requested, autoSelected: false };
1694
+ const plan = buildSpawn(command, 'default');
1695
+ if (plan.effectiveShell === 'powershell' && commandUsesPosixChaining(command)) {
1696
+ if (resolvePwshExecutable())
1697
+ return { shell: 'pwsh', autoSelected: true };
1698
+ if (resolveBashExecutable())
1699
+ return { shell: 'bash', autoSelected: true };
1700
+ }
1701
+ return { shell: 'default', autoSelected: false };
1702
+ }
1703
+ /** Optional per-call output limits shared by `run` and `start`. */
1704
+ function resolveMaxOutputChars(input) {
1705
+ return typeof input === 'number' && input > 0
1706
+ ? Math.min(Math.floor(input), MAX_OUTPUT_CHARS)
1707
+ : MAX_OUTPUT_CHARS;
1708
+ }
1709
+ /** Spool the full merged output to a file while the tool result stays bounded. */
1710
+ function createOutputSpool(file) {
1711
+ if (!file)
1712
+ return { write: () => undefined, close: () => undefined, bytes: () => 0, error: () => undefined };
1713
+ let bytes = 0;
1714
+ let failure;
1715
+ let stream;
1716
+ try {
1717
+ stream = fs.createWriteStream(file, { encoding: 'utf8' });
1718
+ stream.on('error', (err) => { failure = err instanceof Error ? err.message : String(err); });
1719
+ }
1720
+ catch (err) {
1721
+ failure = err instanceof Error ? err.message : String(err);
1722
+ }
1723
+ return {
1724
+ write: (chunk) => {
1725
+ if (!stream || failure || !chunk)
1726
+ return;
1727
+ try {
1728
+ stream.write(chunk);
1729
+ bytes += Buffer.byteLength(chunk, 'utf8');
1730
+ }
1731
+ catch (err) {
1732
+ failure = err instanceof Error ? err.message : String(err);
1733
+ }
1734
+ },
1735
+ close: () => {
1736
+ try {
1737
+ stream?.end();
1738
+ }
1739
+ catch {
1740
+ /* best-effort */
1741
+ }
1742
+ },
1743
+ bytes: () => bytes,
1744
+ error: () => failure,
1745
+ };
1746
+ }
968
1747
  async function runTool(args, roots, context) {
969
1748
  const command = String(args?.command ?? '').trim();
970
1749
  if (!command)
@@ -979,27 +1758,68 @@ async function runTool(args, roots, context) {
979
1758
  if (context?.signal?.aborted) {
980
1759
  return textResult({ error: 'Command cancelled before it started.', cancelled: true, requestedShell }, true);
981
1760
  }
1761
+ const encodingMode = validateEncoding(args.encoding);
1762
+ if (!encodingMode) {
1763
+ return textResult({ error: '"encoding" must be one of "utf8", "utf16le", or "auto".' }, true);
1764
+ }
982
1765
  const cwd = await resolveCwd(args.cwd, roots);
983
1766
  const warnings = await scanCommandForExternalPaths(command, cwd, roots);
984
1767
  const warn = warnings.length ? { warnings } : {};
985
1768
  const normalize = args.normalizeNewlines === true;
986
- const timeoutSec = typeof args.timeout === 'number' && args.timeout > 0 ? args.timeout : DEFAULT_TIMEOUT_MS / 1000;
987
- const timeoutMs = Math.min(timeoutSec * 1000, MAX_TIMEOUT_MS);
1769
+ const timeoutMs = resolveCommandTimeoutMs(args.timeout);
1770
+ const maxOutputChars = resolveMaxOutputChars(args.maxOutputChars);
1771
+ let outputFilePath;
1772
+ if (args.outputFile !== undefined) {
1773
+ try {
1774
+ outputFilePath = await resolveOutputFile(args.outputFile, cwd, roots);
1775
+ }
1776
+ catch (err) {
1777
+ return textResult({ error: err instanceof Error ? err.message : String(err), cwd }, true);
1778
+ }
1779
+ }
1780
+ const selection = selectShell(command, requestedShell);
988
1781
  registerExitCleanup();
1782
+ const startedAt = Date.now();
989
1783
  return await new Promise((resolve) => {
990
1784
  let output = '';
991
1785
  let truncated = false;
992
1786
  let settled = false;
993
- const append = makeAppender(() => output, (v, t) => { output = v; truncated = t || truncated; });
1787
+ let stderrChars = 0;
1788
+ const append = makeAppender(() => output, (v, t) => { output = v; truncated = t || truncated; }, maxOutputChars);
1789
+ const spool = createOutputSpool(outputFilePath);
1790
+ const decodeStdout = createStreamDecoder(encodingMode);
1791
+ const decodeStderr = createStreamDecoder(encodingMode);
994
1792
  const progress = createCommandProgressReporter(context);
995
- const { child, startError, effectiveShell, unavailableShell } = startChild(command, cwd, requestedShell, envValidation.env);
1793
+ const { child, startError, effectiveShell, unavailableShell, shellSubstitution } = startChild(command, cwd, selection.shell, envValidation.env);
1794
+ const dialectWarnings = detectDialectMismatch(command, effectiveShell);
1795
+ const dialect = dialectWarnings.length ? { dialectWarnings } : {};
1796
+ const auto = selection.autoSelected ? { shellAutoSelected: true } : {};
1797
+ const substitution = shellSubstitution ? { shellSubstitution } : {};
1798
+ const spoolInfo = () => (outputFilePath
1799
+ ? { outputFile: outputFilePath, outputBytes: spool.bytes(), ...(spool.error() ? { outputFileError: spool.error() } : {}) }
1800
+ : {});
1801
+ const outputStats = () => ({
1802
+ truncated,
1803
+ stderrChars,
1804
+ outputChars: append.totalChars(),
1805
+ ...(append.droppedChars() > 0 ? { omittedChars: append.droppedChars() } : {}),
1806
+ });
996
1807
  if (unavailableShell) {
1808
+ spool.close();
997
1809
  void progress.stop().then(() => {
998
- resolve(textResult({ error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`, cwd, shell: unavailableShell }, true));
1810
+ resolve(textResult({
1811
+ error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`,
1812
+ cwd,
1813
+ requestedShell,
1814
+ shell: unavailableShell,
1815
+ availableShells: availableShellNames(),
1816
+ hint: unavailableShellHint(unavailableShell),
1817
+ }, true));
999
1818
  });
1000
1819
  return;
1001
1820
  }
1002
1821
  if (startError || !child) {
1822
+ spool.close();
1003
1823
  void progress.stop().then(() => {
1004
1824
  resolve(textResult({ error: `Failed to start command (${effectiveShell}): ${startError ?? 'unknown error'}`, cwd, shell: effectiveShell }, true));
1005
1825
  });
@@ -1018,8 +1838,12 @@ async function runTool(args, roots, context) {
1018
1838
  requestedShell,
1019
1839
  shell: effectiveShell,
1020
1840
  exitCode: null,
1021
- truncated,
1022
1841
  output: finalOut,
1842
+ ...outputStats(),
1843
+ ...spoolInfo(),
1844
+ ...dialect,
1845
+ ...substitution,
1846
+ ...auto,
1023
1847
  ...warn,
1024
1848
  }, true), true);
1025
1849
  };
@@ -1032,31 +1856,48 @@ async function runTool(args, roots, context) {
1032
1856
  context?.signal?.removeEventListener('abort', onAbort);
1033
1857
  if (!keepKillEscalation)
1034
1858
  cancelEscalation?.();
1859
+ spool.close();
1035
1860
  await progress.stop();
1036
1861
  resolve(result);
1037
1862
  };
1038
- timer = setTimeout(() => {
1039
- cancelEscalation = killProcessTree(child);
1040
- const finalOut = maybeNormalize(output, normalize);
1041
- void finish(textResult({
1042
- timedOut: true,
1043
- cwd,
1044
- requestedShell,
1045
- shell: effectiveShell,
1046
- exitCode: null,
1047
- truncated,
1048
- output: `${finalOut}${finalOut ? '\n' : ''}[killed after ${timeoutMs / 1000}s timeout]`,
1049
- ...warn,
1050
- }, true), true);
1051
- }, timeoutMs);
1863
+ if (timeoutMs !== undefined) {
1864
+ timer = setTimeout(() => {
1865
+ cancelEscalation = killProcessTree(child);
1866
+ const finalOut = maybeNormalize(output, normalize);
1867
+ const hangHints = detectInteractiveHangRisk(command);
1868
+ void finish(textResult({
1869
+ timedOut: true,
1870
+ cwd,
1871
+ requestedShell,
1872
+ shell: effectiveShell,
1873
+ exitCode: null,
1874
+ timeoutMs,
1875
+ elapsedMs: Date.now() - startedAt,
1876
+ killedProcessTree: true,
1877
+ suggestion: `Command exceeded ${timeoutMs / 1000}s and its process tree was killed. `
1878
+ + `Raise "timeout" (positive values cap at ${commandMaxTimeoutMs() / 1000}s), pass -1, or use "start" + "wait" for a persistent job.`,
1879
+ output: `${finalOut}${finalOut ? '\n' : ''}[killed after ${timeoutMs / 1000}s timeout]`,
1880
+ ...outputStats(),
1881
+ ...spoolInfo(),
1882
+ ...(hangHints.length ? { hangHints } : {}),
1883
+ ...dialect,
1884
+ ...substitution,
1885
+ ...auto,
1886
+ ...warn,
1887
+ }, true), true);
1888
+ }, timeoutMs);
1889
+ }
1052
1890
  child.stdout?.on('data', (d) => {
1053
- const chunk = d.toString();
1891
+ const chunk = decodeStdout(d);
1054
1892
  append(chunk);
1893
+ spool.write(chunk);
1055
1894
  progress.push(maybeNormalize(chunk, normalize));
1056
1895
  });
1057
1896
  child.stderr?.on('data', (d) => {
1058
- const chunk = d.toString();
1897
+ const chunk = decodeStderr(d);
1898
+ stderrChars += chunk.length;
1059
1899
  append(chunk);
1900
+ spool.write(chunk);
1060
1901
  progress.push(maybeNormalize(chunk, normalize));
1061
1902
  });
1062
1903
  child.on('error', (err) => {
@@ -1071,7 +1912,20 @@ async function runTool(args, roots, context) {
1071
1912
  if (settled)
1072
1913
  return;
1073
1914
  const finalOut = maybeNormalize(output, normalize);
1074
- void finish(textResult({ exitCode: code, cwd, requestedShell, shell: effectiveShell, truncated, output: finalOut, ...warn }, code !== 0));
1915
+ void finish(textResult({
1916
+ exitCode: code,
1917
+ cwd,
1918
+ requestedShell,
1919
+ shell: effectiveShell,
1920
+ output: finalOut,
1921
+ ...outputStats(),
1922
+ ...spoolInfo(),
1923
+ ...dialect,
1924
+ ...missingExecutableHint(code, dialectWarnings),
1925
+ ...substitution,
1926
+ ...auto,
1927
+ ...warn,
1928
+ }, code !== 0));
1075
1929
  });
1076
1930
  if (context?.signal?.aborted)
1077
1931
  onAbort();
@@ -1112,13 +1966,52 @@ async function startTool(args, roots, ownerScope, context) {
1112
1966
  if (table.size >= MAX_SESSIONS) {
1113
1967
  return textResult({ error: `Too many active background sessions (max ${MAX_SESSIONS}). Kill some first.` }, true);
1114
1968
  }
1969
+ // Issue #413: the HOST-wide live cap spans background and terminal sessions.
1970
+ // A per-owner limit alone let N concurrent runs each stay legal while together
1971
+ // forking an unbounded number of live process trees on one machine. Expire
1972
+ // stale sessions first so the cap is measured against genuinely live work.
1973
+ expireStaleSessions();
1974
+ const hostLimit = maxLiveSessionsHost();
1975
+ if (liveSessionCount() >= hostLimit) {
1976
+ return textResult({
1977
+ error: `Too many live sessions on this host (max ${hostLimit} across background and terminal sessions). Wait for one to finish or kill one first.`,
1978
+ liveSessions: liveSessionCount(),
1979
+ }, true);
1980
+ }
1981
+ if (ownerLiveSessionCount(ownerScope) >= MAX_SESSIONS) {
1982
+ return textResult({ error: `Too many live sessions for this caller (max ${MAX_SESSIONS}). Kill some first.` }, true);
1983
+ }
1984
+ // Surviving the owning run must be an explicit choice (see releaseOwnerScope).
1985
+ const detached = args.detached === true || args.persistAfterRun === true;
1115
1986
  registerExitCleanup();
1987
+ const encodingMode = validateEncoding(args.encoding);
1988
+ if (!encodingMode) {
1989
+ return textResult({ error: '"encoding" must be one of "utf8", "utf16le", or "auto".' }, true);
1990
+ }
1116
1991
  const cwd = await resolveCwd(args.cwd, roots);
1117
1992
  const warnings = await scanCommandForExternalPaths(command, cwd, roots);
1118
1993
  const warn = warnings.length ? { warnings } : {};
1119
- const { child, startError, effectiveShell, unavailableShell } = startChild(command, cwd, requestedShell, envValidation.env);
1994
+ const maxOutputChars = resolveMaxOutputChars(args.maxOutputChars);
1995
+ let outputFilePath;
1996
+ if (args.outputFile !== undefined) {
1997
+ try {
1998
+ outputFilePath = await resolveOutputFile(args.outputFile, cwd, roots);
1999
+ }
2000
+ catch (err) {
2001
+ return textResult({ error: err instanceof Error ? err.message : String(err), cwd }, true);
2002
+ }
2003
+ }
2004
+ const selection = selectShell(command, requestedShell);
2005
+ const { child, startError, effectiveShell, unavailableShell, shellSubstitution } = startChild(command, cwd, selection.shell, envValidation.env);
1120
2006
  if (unavailableShell) {
1121
- return textResult({ error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`, cwd, shell: unavailableShell }, true);
2007
+ return textResult({
2008
+ error: `Requested shell "${unavailableShell}" is unavailable or could not be resolved.`,
2009
+ cwd,
2010
+ requestedShell,
2011
+ shell: unavailableShell,
2012
+ availableShells: availableShellNames(),
2013
+ hint: unavailableShellHint(unavailableShell),
2014
+ }, true);
1122
2015
  }
1123
2016
  if (startError || !child) {
1124
2017
  return textResult({ error: `Failed to start command (${effectiveShell}): ${startError ?? 'unknown error'}`, cwd, shell: effectiveShell }, true);
@@ -1135,11 +2028,27 @@ async function startTool(args, roots, ownerScope, context) {
1135
2028
  running: true,
1136
2029
  exitCode: null,
1137
2030
  startedAt: Date.now(),
2031
+ lastActivityAt: Date.now(),
2032
+ ...(detached ? { detached: true } : {}),
1138
2033
  };
1139
2034
  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()));
2035
+ const append = makeAppender(() => session.output, (v, t) => { session.output = v; session.truncated = t || session.truncated; }, maxOutputChars);
2036
+ const spool = createOutputSpool(outputFilePath);
2037
+ const decodeStdout = createStreamDecoder(encodingMode);
2038
+ const decodeStderr = createStreamDecoder(encodingMode);
2039
+ child.stdout?.on('data', (d) => {
2040
+ const chunk = decodeStdout(d);
2041
+ touchSession(session);
2042
+ append(chunk);
2043
+ spool.write(chunk);
2044
+ });
2045
+ child.stderr?.on('data', (d) => {
2046
+ const chunk = decodeStderr(d);
2047
+ session.stderrChars = (session.stderrChars ?? 0) + chunk.length;
2048
+ touchSession(session);
2049
+ append(chunk);
2050
+ spool.write(chunk);
2051
+ });
1143
2052
  child.on('error', (err) => {
1144
2053
  append(`\n${err.message}`);
1145
2054
  session.running = false;
@@ -1150,9 +2059,23 @@ async function startTool(args, roots, ownerScope, context) {
1150
2059
  session.running = false;
1151
2060
  session.exitCode = code;
1152
2061
  session.endedAt = Date.now();
2062
+ spool.close();
1153
2063
  scheduleReap(session);
1154
2064
  });
1155
- return textResult({ sessionId: id, cwd, requestedShell, shell: effectiveShell, ...warn });
2065
+ const dialectWarnings = detectDialectMismatch(command, effectiveShell);
2066
+ session.missingExecutableWarning = dialectWarnings.some((warning) => /not found on PATH|not available as an executable/.test(warning));
2067
+ return textResult({
2068
+ sessionId: id,
2069
+ cwd,
2070
+ requestedShell,
2071
+ shell: effectiveShell,
2072
+ ...(detached ? { detached: true } : {}),
2073
+ ...(shellSubstitution ? { shellSubstitution } : {}),
2074
+ ...(selection.autoSelected ? { shellAutoSelected: true } : {}),
2075
+ ...(dialectWarnings.length ? { dialectWarnings } : {}),
2076
+ ...(outputFilePath ? { outputFile: outputFilePath } : {}),
2077
+ ...warn,
2078
+ });
1156
2079
  }
1157
2080
  function snapshot(session, extra = {}) {
1158
2081
  return {
@@ -1161,6 +2084,9 @@ function snapshot(session, extra = {}) {
1161
2084
  exitCode: session.exitCode,
1162
2085
  output: session.output,
1163
2086
  truncated: session.truncated,
2087
+ stderrChars: session.stderrChars ?? 0,
2088
+ ...(session.missingExecutableWarning && (session.exitCode === 9009 || session.exitCode === 255 || session.exitCode === 1)
2089
+ ? { hint: 'A referenced executable was not found — see dialectWarnings.' } : {}),
1164
2090
  ...extra,
1165
2091
  };
1166
2092
  }
@@ -1181,8 +2107,7 @@ async function waitTool(args, ownerScope, context) {
1181
2107
  if (context?.signal?.aborted) {
1182
2108
  return textResult(snapshot(session, { timedOut: false, cancelled: true }), true);
1183
2109
  }
1184
- const timeoutSec = typeof args.timeout === 'number' && args.timeout > 0 ? args.timeout : DEFAULT_TIMEOUT_MS / 1000;
1185
- const timeoutMs = Math.min(timeoutSec * 1000, MAX_TIMEOUT_MS);
2110
+ const timeoutMs = resolveCommandTimeoutMs(args.timeout);
1186
2111
  const progress = createCommandProgressReporter(context);
1187
2112
  const outcome = await new Promise((resolve) => {
1188
2113
  let settled = false;
@@ -1215,7 +2140,9 @@ async function waitTool(args, ownerScope, context) {
1215
2140
  }
1216
2141
  pollTimer = setTimeout(poll, 100);
1217
2142
  };
1218
- timeoutTimer = setTimeout(() => void finish('timedOut'), timeoutMs);
2143
+ if (timeoutMs !== undefined) {
2144
+ timeoutTimer = setTimeout(() => void finish('timedOut'), timeoutMs);
2145
+ }
1219
2146
  context?.signal?.addEventListener('abort', onAbort, { once: true });
1220
2147
  poll();
1221
2148
  });
@@ -1249,6 +2176,7 @@ function killTool(args, ownerScope) {
1249
2176
  if (session.running) {
1250
2177
  session.cancelEscalation = killProcessTree(session.child);
1251
2178
  }
2179
+ touchSession(session);
1252
2180
  return textResult({ sessionId: id, killed: true });
1253
2181
  }
1254
2182
  function listSessionsTool(ownerScope) {
@@ -1259,11 +2187,28 @@ function listSessionsTool(ownerScope) {
1259
2187
  command: s.command,
1260
2188
  running: s.running,
1261
2189
  exitCode: s.exitCode,
2190
+ detached: s.detached === true,
1262
2191
  startedAt: new Date(s.startedAt).toISOString(),
1263
2192
  endedAt: s.endedAt ? new Date(s.endedAt).toISOString() : undefined,
1264
2193
  }));
1265
2194
  return textResult({ sessions: list });
1266
2195
  }
2196
+ /**
2197
+ * Release every non-detached session of the CALLING scope.
2198
+ *
2199
+ * The scope is host-derived (`_meta.flujo.ownerScope`), never a tool argument, so
2200
+ * a caller can only ever release its own sessions - releasing by arbitrary scope
2201
+ * would be a cross-run kill primitive.
2202
+ */
2203
+ function releaseOwnerTool(ownerScope) {
2204
+ const released = releaseOwnerScope(ownerScope);
2205
+ return textResult({
2206
+ ownerScope: released.ownerScope,
2207
+ killedSessions: released.killedSessions,
2208
+ closedTerminals: released.closedTerminals,
2209
+ retainedDetached: released.retainedDetached,
2210
+ });
2211
+ }
1267
2212
  function terminalSnapshot(session) {
1268
2213
  return {
1269
2214
  sessionId: session.id,
@@ -1301,10 +2246,28 @@ async function openTerminalTool(args, roots, ownerScope) {
1301
2246
  if (ownedRunning.length >= MAX_SESSIONS) {
1302
2247
  return textResult({ error: `Too many active terminal sessions (max ${MAX_SESSIONS}). Close one first.` }, true);
1303
2248
  }
2249
+ // Issue #413: PTY sessions count against the SAME host-wide live cap as
2250
+ // background sessions - a terminal is just as much a live process tree.
2251
+ expireStaleSessions();
2252
+ const hostLimit = maxLiveSessionsHost();
2253
+ if (liveSessionCount() >= hostLimit) {
2254
+ return textResult({
2255
+ error: `Too many live sessions on this host (max ${hostLimit} across background and terminal sessions). Close one first.`,
2256
+ liveSessions: liveSessionCount(),
2257
+ }, true);
2258
+ }
2259
+ if (ownerLiveSessionCount(ownerScope) >= MAX_SESSIONS) {
2260
+ return textResult({ error: `Too many live sessions for this caller (max ${MAX_SESSIONS}). Close one first.` }, true);
2261
+ }
2262
+ registerExitCleanup();
1304
2263
  const cwd = await resolveCwd(args.cwd, roots);
1305
2264
  const plan = buildPtySpawn(shellValidation.shell);
1306
2265
  if (plan.unavailableShell) {
1307
- return textResult({ error: `Requested shell "${plan.unavailableShell}" is unavailable or could not be resolved.` }, true);
2266
+ return textResult({
2267
+ error: `Requested shell "${plan.unavailableShell}" is unavailable or could not be resolved.`,
2268
+ availableShells: availableShellNames(),
2269
+ hint: 'Call "shell_info" to see which shells and interpreters exist on this machine.',
2270
+ }, true);
1308
2271
  }
1309
2272
  if (plan.startError)
1310
2273
  return textResult({ error: plan.startError }, true);
@@ -1345,9 +2308,14 @@ async function openTerminalTool(args, roots, ownerScope) {
1345
2308
  running: true,
1346
2309
  exitCode: null,
1347
2310
  startedAt: Date.now(),
2311
+ lastActivityAt: Date.now(),
2312
+ ...(args.detached === true || args.persistAfterRun === true ? { detached: true } : {}),
1348
2313
  };
1349
2314
  terminalSessions().set(id, session);
1350
- pty.onData((chunk) => appendTerminalOutput(session, chunk));
2315
+ pty.onData((chunk) => {
2316
+ touchSession(session);
2317
+ appendTerminalOutput(session, chunk);
2318
+ });
1351
2319
  pty.onExit(({ exitCode }) => {
1352
2320
  session.running = false;
1353
2321
  session.exitCode = exitCode;
@@ -1365,6 +2333,7 @@ function readTerminalTool(args, ownerScope) {
1365
2333
  const session = ownedTerminal(id, ownerScope);
1366
2334
  if (!session)
1367
2335
  return textResult({ error: `No terminal session with id "${id}".` }, true);
2336
+ touchSession(session);
1368
2337
  const requested = Number.isFinite(args.cursor) ? Math.max(0, Math.floor(Number(args.cursor))) : session.outputStart;
1369
2338
  const reset = requested < session.outputStart || requested > session.nextCursor;
1370
2339
  const cursor = reset ? session.outputStart : requested;
@@ -1386,6 +2355,7 @@ function writeTerminalTool(args, ownerScope) {
1386
2355
  if (data.length > 65_536)
1387
2356
  return textResult({ error: 'Terminal input is limited to 65,536 characters per write.' }, true);
1388
2357
  try {
2358
+ touchSession(session);
1389
2359
  session.pty.write(data);
1390
2360
  return textResult({ sessionId: id, written: Buffer.byteLength(data, 'utf8') });
1391
2361
  }
@@ -1456,6 +2426,8 @@ export async function bashCallTool(toolName, args, callerNodeId, ownerScope, con
1456
2426
  return closeTerminalTool(args, scope);
1457
2427
  case 'terminal_list':
1458
2428
  return listTerminalsTool(scope);
2429
+ case 'shell_info':
2430
+ return shellInfoTool();
1459
2431
  case 'run': {
1460
2432
  const roots = await loadEffectiveRoots(BASH_SERVER_NAME, BASH_ROOT_ENV_VARS, callerNodeId);
1461
2433
  return await runTool(args, roots, context);
@@ -1474,6 +2446,8 @@ export async function bashCallTool(toolName, args, callerNodeId, ownerScope, con
1474
2446
  return killTool(args, scope);
1475
2447
  case 'list_sessions':
1476
2448
  return listSessionsTool(scope);
2449
+ case 'release_owner':
2450
+ return releaseOwnerTool(scope);
1477
2451
  default:
1478
2452
  return textResult({ error: `Unknown tool on the built-in bash server: ${toolName}` }, true);
1479
2453
  }