Commit 83b8e9b9 authored by shrabbit's avatar shrabbit
Browse files

feat(RequestTools): 子进程直接继承控制台获得真实 TTY,改为轮询屏幕缓冲区增量推送,Windows 支持捕获,其他平台仅交互

parent 9e98f513
Loading
Loading
Loading
Loading
+51 −71
Original line number Diff line number Diff line
@@ -44,8 +44,10 @@ class Program
    }

    /// <summary>
    /// 启动用户指定的自定义交互式程序,并将其输出同时转发到本控制台(用户可见)
    /// 与命名管道(Agent 观察);stdin 由本控制台转发给程序。
    /// 启动用户指定的自定义交互式程序,子进程直接继承本控制台(真实 TTY),
    /// 从而获得真正的交互能力。同时在后台轮询屏幕缓冲区,把新增内容通过命名
    /// 管道推送给 Agent 观察。仅 Windows 支持屏幕捕获;其他平台仍可交互,
    /// 但 Agent 无法读取输出。
    /// </summary>
    private static void RunWatchable(SendWatchableTransport s)
    {
@@ -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);
@@ -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)
        {
@@ -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 { }
@@ -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>
+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;
        }
    }
}