Loading src/McpBase.Request/Program.cs +51 −71 Original line number Diff line number Diff line Loading @@ -44,8 +44,10 @@ class Program } /// <summary> /// 启动用户指定的自定义交互式程序,并将其输出同时转发到本控制台(用户可见) /// 与命名管道(Agent 观察);stdin 由本控制台转发给程序。 /// 启动用户指定的自定义交互式程序,子进程直接继承本控制台(真实 TTY), /// 从而获得真正的交互能力。同时在后台轮询屏幕缓冲区,把新增内容通过命名 /// 管道推送给 Agent 观察。仅 Windows 支持屏幕捕获;其他平台仍可交互, /// 但 Agent 无法读取输出。 /// </summary> private static void RunWatchable(SendWatchableTransport s) { Loading @@ -66,22 +68,12 @@ class Program return; } var psi = new ProcessStartInfo(file) { UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, StandardOutputEncoding = Console.OutputEncoding, StandardErrorEncoding = Console.OutputEncoding, }; if (!string.IsNullOrEmpty(args)) psi.Arguments = args; // 连接观察管道(方向 Out:向服务端写入输出)。 // 连接观察管道(方向 Out:向服务端写入捕获的屏幕内容)。 // 即使连接失败也继续运行,仅 Agent 观察不可用。 NamedPipeClientStream? pipe = null; StreamWriter? pipeWriter = null; if (OperatingSystem.IsWindows()) { try { pipe = new NamedPipeClientStream(".", s.PipeName, PipeDirection.Out, PipeOptions.Asynchronous); Loading @@ -90,17 +82,22 @@ class Program } catch (Exception e) { // 管道不可用时仍可向本控制台输出,仅 Agent 观察不可用。 try { AnsiConsole.MarkupLine($"[yellow]警告:无法连接观察管道,Agent 将无法读取输出。({e.Message.EscapeMarkup()})[/]"); } catch { Console.WriteLine($"警告:无法连接观察管道。({e.Message})"); } pipeWriter?.Dispose(); pipe?.Dispose(); pipeWriter = null; pipe = null; } } Process child; try { child = new Process { StartInfo = psi }; if (!child.Start()) throw new InvalidOperationException("Process.Start 返回 false。"); // 直接启动,不重定向:子进程继承本进程的真实控制台,获得真 TTY。 child = Process.Start(file, args); if (child is null) throw new InvalidOperationException("Process.Start 返回 null。"); } catch (Exception e) { Loading @@ -112,38 +109,18 @@ class Program return; } // 子程序 stdout → 控制台 + 管道 var stdoutTask = Task.Run(() => Relay(child.StandardOutput, Console.Out, pipeWriter)); // 子程序 stderr → 控制台错误 + 管道 var stderrTask = Task.Run(() => Relay(child.StandardError, Console.Error, pipeWriter)); // 控制台 stdin → 子程序 stdin(fire-and-forget:子程序退出后随进程结束而终止) Task.Run(() => { try { string? line; while ((line = Console.In.ReadLine()) != null) { try { child.StandardInput.WriteLine(line); child.StandardInput.Flush(); } catch { break; // 子程序已关闭 stdin } } } catch { } try { child.StandardInput.Close(); } catch { } }); // 启动子进程后再重置锚点,避免捕获上面的横幅输出。 ScreenBufferReader.Reset(); // 后台轮询屏幕缓冲区:增量读取并写入管道。 var pollingCts = new CancellationTokenSource(); var pollingTask = Task.Run(() => ScreenPollingLoop(pipeWriter, pollingCts.Token)); child.WaitForExit(); // 等待输出排空(EOF 后 Read 返回 0,任务结束);带超时以防孙子进程持有 stdout。 // 注意:此处不能再调用 Console.ReadKey 等,因为上面的 stdin 转发线程正阻塞在控制台读取上。 try { Task.WaitAll(new[] { stdoutTask, stderrTask }, 2000); } catch { } try { child.StandardInput.Close(); } catch { } // 子进程退出后停止轮询,等待最后一次刷新。 pollingCts.Cancel(); try { pollingTask.Wait(2000); } catch { } // 关闭管道(让服务端读到 EOF)。本进程随即退出,窗口自动关闭。 try { pipeWriter?.Flush(); } catch { } Loading @@ -152,27 +129,30 @@ class Program } /// <summary> /// 将源流的输出同时写入控制台与(可选)观察管道。 /// 屏幕缓冲区轮询循环:每 150ms 读取增量并写入管道。 /// </summary> private static void Relay(StreamReader source, TextWriter console, StreamWriter? pipe) private static async Task ScreenPollingLoop(StreamWriter? pipeWriter, CancellationToken ct) { if (pipeWriter is null) return; var interval = TimeSpan.FromMilliseconds(150); try { var buf = new char[256]; int n; while ((n = source.Read(buf, 0, buf.Length)) > 0) while (!ct.IsCancellationRequested) { console.Write(buf, 0, n); console.Flush(); try var delta = ScreenBufferReader.ReadDelta(); if (!string.IsNullOrEmpty(delta)) { pipe?.Write(buf, 0, n); pipe?.Flush(); try { await pipeWriter.WriteAsync(delta.AsMemory(), ct); } catch { break; } // 管道断开,停止轮询 } catch { } try { await Task.Delay(interval, ct); } catch { break; } } } catch { } catch { // 轮询异常不影响主流程;管道中保留已写入内容。 } } /// <summary> Loading src/McpBase.Request/ScreenBufferReader.cs 0 → 100644 +139 −0 Original line number Diff line number Diff line using System.Runtime.InteropServices; using System.Text; namespace McpBase.Request; /// <summary> /// 通过 Windows 控制台屏幕缓冲区 API 增量读取控制台输出。 /// 原理:每次轮询时,读取从上次光标位置到当前光标位置之间的行内容, /// 裁剪行尾空格后返回新增文本。子进程直接继承本进程控制台(真 TTY), /// 因此交互完全正常,本类仅做"旁观"读取,不干扰子进程。 /// 仅 Windows 可用;其他平台返回空字符串。 /// </summary> internal static class ScreenBufferReader { private const int STD_OUTPUT_HANDLE = -11; private const int INVALID_HANDLE_VALUE = -1; [StructLayout(LayoutKind.Sequential)] private struct Coord { public short X; public short Y; } [StructLayout(LayoutKind.Sequential)] private struct SmallRect { public short Left; public short Top; public short Right; public short Bottom; } [StructLayout(LayoutKind.Sequential)] private struct ConsoleScreenBufferInfo { public Coord dwSize; public Coord dwCursorPosition; public ushort wAttributes; public SmallRect srWindow; public Coord dwMaximumWindowSize; } [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr GetStdHandle(int nStdHandle); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetConsoleScreenBufferInfo(IntPtr hConsoleOutput, out ConsoleScreenBufferInfo lpConsoleScreenBufferInfo); [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool ReadConsoleOutputCharacter( IntPtr hConsoleOutput, [Out] StringBuilder lpCharacter, uint nLength, Coord dwReadCoord, out uint lpNumberOfCharsRead); /// <summary> /// 上次读取到的行号(光标 Y)。首次调用前为 null。 /// 每次读取从该行起始位置读到当前光标所在行的前一行末尾。 /// </summary> private static int? _lastLine; private static readonly object _lock = new(); /// <summary> /// 读取自上次调用以来新增的屏幕内容。返回 null 表示无法读取(非 Windows / 句柄不可用)。 /// 线程安全:内部加锁,保证 _lastLine 与读取原子。 /// </summary> public static string? ReadDelta() { if (!OperatingSystem.IsWindows()) return null; lock (_lock) { var handle = GetStdHandle(STD_OUTPUT_HANDLE); if (handle == IntPtr.Zero || handle == (IntPtr)INVALID_HANDLE_VALUE) return null; if (!GetConsoleScreenBufferInfo(handle, out var info)) return null; var curY = info.dwCursorPosition.Y; var width = info.dwSize.X; // 首次:记录当前行,不读历史(避免把启动横幅重复读一次,横幅由 RunWatchable 直接输出)。 if (!_lastLine.HasValue) { _lastLine = curY; return string.Empty; } var startLine = _lastLine.Value; if (curY < startLine) { // 屏幕发生滚动或缓冲区被清空:重置锚点,丢失中间内容。 _lastLine = curY; return string.Empty; } if (curY == startLine) return string.Empty; // 光标未移动到新行 // 从 startLine 到 curY - 1 行,逐行读取 width 个字符并裁剪行尾空格。 var sb = new StringBuilder(); var lineBuf = new StringBuilder(width); var readCoord = new Coord { X = 0, Y = (short)startLine }; var linesToRead = curY - startLine; for (var i = 0; i < linesToRead; i++) { lineBuf.Clear(); if (ReadConsoleOutputCharacter(handle, lineBuf, (uint)width, readCoord, out var read) && read > 0) { var line = lineBuf.ToString(0, (int)read); sb.Append(line.TrimEnd()); } sb.Append('\n'); readCoord = new Coord { X = 0, Y = (short)(startLine + i + 1) }; } _lastLine = curY; return sb.ToString(); } } /// <summary> /// 重置读取锚点。在启动子进程前调用,避免捕获启动前的横幅输出。 /// </summary> public static void Reset() { lock (_lock) { _lastLine = null; } } } Loading
src/McpBase.Request/Program.cs +51 −71 Original line number Diff line number Diff line Loading @@ -44,8 +44,10 @@ class Program } /// <summary> /// 启动用户指定的自定义交互式程序,并将其输出同时转发到本控制台(用户可见) /// 与命名管道(Agent 观察);stdin 由本控制台转发给程序。 /// 启动用户指定的自定义交互式程序,子进程直接继承本控制台(真实 TTY), /// 从而获得真正的交互能力。同时在后台轮询屏幕缓冲区,把新增内容通过命名 /// 管道推送给 Agent 观察。仅 Windows 支持屏幕捕获;其他平台仍可交互, /// 但 Agent 无法读取输出。 /// </summary> private static void RunWatchable(SendWatchableTransport s) { Loading @@ -66,22 +68,12 @@ class Program return; } var psi = new ProcessStartInfo(file) { UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, StandardOutputEncoding = Console.OutputEncoding, StandardErrorEncoding = Console.OutputEncoding, }; if (!string.IsNullOrEmpty(args)) psi.Arguments = args; // 连接观察管道(方向 Out:向服务端写入输出)。 // 连接观察管道(方向 Out:向服务端写入捕获的屏幕内容)。 // 即使连接失败也继续运行,仅 Agent 观察不可用。 NamedPipeClientStream? pipe = null; StreamWriter? pipeWriter = null; if (OperatingSystem.IsWindows()) { try { pipe = new NamedPipeClientStream(".", s.PipeName, PipeDirection.Out, PipeOptions.Asynchronous); Loading @@ -90,17 +82,22 @@ class Program } catch (Exception e) { // 管道不可用时仍可向本控制台输出,仅 Agent 观察不可用。 try { AnsiConsole.MarkupLine($"[yellow]警告:无法连接观察管道,Agent 将无法读取输出。({e.Message.EscapeMarkup()})[/]"); } catch { Console.WriteLine($"警告:无法连接观察管道。({e.Message})"); } pipeWriter?.Dispose(); pipe?.Dispose(); pipeWriter = null; pipe = null; } } Process child; try { child = new Process { StartInfo = psi }; if (!child.Start()) throw new InvalidOperationException("Process.Start 返回 false。"); // 直接启动,不重定向:子进程继承本进程的真实控制台,获得真 TTY。 child = Process.Start(file, args); if (child is null) throw new InvalidOperationException("Process.Start 返回 null。"); } catch (Exception e) { Loading @@ -112,38 +109,18 @@ class Program return; } // 子程序 stdout → 控制台 + 管道 var stdoutTask = Task.Run(() => Relay(child.StandardOutput, Console.Out, pipeWriter)); // 子程序 stderr → 控制台错误 + 管道 var stderrTask = Task.Run(() => Relay(child.StandardError, Console.Error, pipeWriter)); // 控制台 stdin → 子程序 stdin(fire-and-forget:子程序退出后随进程结束而终止) Task.Run(() => { try { string? line; while ((line = Console.In.ReadLine()) != null) { try { child.StandardInput.WriteLine(line); child.StandardInput.Flush(); } catch { break; // 子程序已关闭 stdin } } } catch { } try { child.StandardInput.Close(); } catch { } }); // 启动子进程后再重置锚点,避免捕获上面的横幅输出。 ScreenBufferReader.Reset(); // 后台轮询屏幕缓冲区:增量读取并写入管道。 var pollingCts = new CancellationTokenSource(); var pollingTask = Task.Run(() => ScreenPollingLoop(pipeWriter, pollingCts.Token)); child.WaitForExit(); // 等待输出排空(EOF 后 Read 返回 0,任务结束);带超时以防孙子进程持有 stdout。 // 注意:此处不能再调用 Console.ReadKey 等,因为上面的 stdin 转发线程正阻塞在控制台读取上。 try { Task.WaitAll(new[] { stdoutTask, stderrTask }, 2000); } catch { } try { child.StandardInput.Close(); } catch { } // 子进程退出后停止轮询,等待最后一次刷新。 pollingCts.Cancel(); try { pollingTask.Wait(2000); } catch { } // 关闭管道(让服务端读到 EOF)。本进程随即退出,窗口自动关闭。 try { pipeWriter?.Flush(); } catch { } Loading @@ -152,27 +129,30 @@ class Program } /// <summary> /// 将源流的输出同时写入控制台与(可选)观察管道。 /// 屏幕缓冲区轮询循环:每 150ms 读取增量并写入管道。 /// </summary> private static void Relay(StreamReader source, TextWriter console, StreamWriter? pipe) private static async Task ScreenPollingLoop(StreamWriter? pipeWriter, CancellationToken ct) { if (pipeWriter is null) return; var interval = TimeSpan.FromMilliseconds(150); try { var buf = new char[256]; int n; while ((n = source.Read(buf, 0, buf.Length)) > 0) while (!ct.IsCancellationRequested) { console.Write(buf, 0, n); console.Flush(); try var delta = ScreenBufferReader.ReadDelta(); if (!string.IsNullOrEmpty(delta)) { pipe?.Write(buf, 0, n); pipe?.Flush(); try { await pipeWriter.WriteAsync(delta.AsMemory(), ct); } catch { break; } // 管道断开,停止轮询 } catch { } try { await Task.Delay(interval, ct); } catch { break; } } } catch { } catch { // 轮询异常不影响主流程;管道中保留已写入内容。 } } /// <summary> Loading
src/McpBase.Request/ScreenBufferReader.cs 0 → 100644 +139 −0 Original line number Diff line number Diff line using System.Runtime.InteropServices; using System.Text; namespace McpBase.Request; /// <summary> /// 通过 Windows 控制台屏幕缓冲区 API 增量读取控制台输出。 /// 原理:每次轮询时,读取从上次光标位置到当前光标位置之间的行内容, /// 裁剪行尾空格后返回新增文本。子进程直接继承本进程控制台(真 TTY), /// 因此交互完全正常,本类仅做"旁观"读取,不干扰子进程。 /// 仅 Windows 可用;其他平台返回空字符串。 /// </summary> internal static class ScreenBufferReader { private const int STD_OUTPUT_HANDLE = -11; private const int INVALID_HANDLE_VALUE = -1; [StructLayout(LayoutKind.Sequential)] private struct Coord { public short X; public short Y; } [StructLayout(LayoutKind.Sequential)] private struct SmallRect { public short Left; public short Top; public short Right; public short Bottom; } [StructLayout(LayoutKind.Sequential)] private struct ConsoleScreenBufferInfo { public Coord dwSize; public Coord dwCursorPosition; public ushort wAttributes; public SmallRect srWindow; public Coord dwMaximumWindowSize; } [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr GetStdHandle(int nStdHandle); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetConsoleScreenBufferInfo(IntPtr hConsoleOutput, out ConsoleScreenBufferInfo lpConsoleScreenBufferInfo); [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool ReadConsoleOutputCharacter( IntPtr hConsoleOutput, [Out] StringBuilder lpCharacter, uint nLength, Coord dwReadCoord, out uint lpNumberOfCharsRead); /// <summary> /// 上次读取到的行号(光标 Y)。首次调用前为 null。 /// 每次读取从该行起始位置读到当前光标所在行的前一行末尾。 /// </summary> private static int? _lastLine; private static readonly object _lock = new(); /// <summary> /// 读取自上次调用以来新增的屏幕内容。返回 null 表示无法读取(非 Windows / 句柄不可用)。 /// 线程安全:内部加锁,保证 _lastLine 与读取原子。 /// </summary> public static string? ReadDelta() { if (!OperatingSystem.IsWindows()) return null; lock (_lock) { var handle = GetStdHandle(STD_OUTPUT_HANDLE); if (handle == IntPtr.Zero || handle == (IntPtr)INVALID_HANDLE_VALUE) return null; if (!GetConsoleScreenBufferInfo(handle, out var info)) return null; var curY = info.dwCursorPosition.Y; var width = info.dwSize.X; // 首次:记录当前行,不读历史(避免把启动横幅重复读一次,横幅由 RunWatchable 直接输出)。 if (!_lastLine.HasValue) { _lastLine = curY; return string.Empty; } var startLine = _lastLine.Value; if (curY < startLine) { // 屏幕发生滚动或缓冲区被清空:重置锚点,丢失中间内容。 _lastLine = curY; return string.Empty; } if (curY == startLine) return string.Empty; // 光标未移动到新行 // 从 startLine 到 curY - 1 行,逐行读取 width 个字符并裁剪行尾空格。 var sb = new StringBuilder(); var lineBuf = new StringBuilder(width); var readCoord = new Coord { X = 0, Y = (short)startLine }; var linesToRead = curY - startLine; for (var i = 0; i < linesToRead; i++) { lineBuf.Clear(); if (ReadConsoleOutputCharacter(handle, lineBuf, (uint)width, readCoord, out var read) && read > 0) { var line = lineBuf.ToString(0, (int)read); sb.Append(line.TrimEnd()); } sb.Append('\n'); readCoord = new Coord { X = 0, Y = (short)(startLine + i + 1) }; } _lastLine = curY; return sb.ToString(); } } /// <summary> /// 重置读取锚点。在启动子进程前调用,避免捕获启动前的横幅输出。 /// </summary> public static void Reset() { lock (_lock) { _lastLine = null; } } }