diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9dfa864..98704dd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,6 +48,14 @@ jobs: exit 1 } Write-Host "WiX/Assets in sync ($($assetFiles.Count) files)" + # Bundled ConPTY files live beside the exe, not under Assets\, so check them by name. + foreach ($f in 'conpty.dll', 'OpenConsole.exe') { + if ($wxs -notmatch [regex]::Escape("`$(var.PublishDir)\$f")) { + Write-Host "::error::installer/CodeShellManager.wxs does not install $f (bundled ConPTY)" + exit 1 + } + } + Write-Host "WiX installs the bundled ConPTY (conpty.dll + OpenConsole.exe)" # ── Build (every push / PR) ────────────────────────────────────────────── - name: Restore & Build @@ -102,6 +110,16 @@ jobs: if (-not [System.Text.Encoding]::ASCII.GetString($exe).Contains("PerMonitorV2")) { throw "Published CodeShellManager.exe does not embed the PerMonitorV2 manifest." } + # Bundled ConPTY: both files must ship as loose files beside the exe (conpty.dll + # finds OpenConsole.exe next to itself). Missing either silently drops every user + # back to the inbox ConPTY — see "Bundled ConPTY" in CLAUDE.md. + foreach ($f in 'conpty.dll', 'OpenConsole.exe') { + if (-not (Test-Path "publish/$f")) { throw "Bundled ConPTY file missing from publish: $f" } + $sig = Get-AuthenticodeSignature "publish/$f" + if ($sig.Status -ne 'Valid' -or $sig.SignerCertificate.Subject -notlike '*O=Microsoft Corporation*') { + throw "Bundled ConPTY file $f is not validly Microsoft-signed ($($sig.Status))." + } + } # ── Install WiX v4 ─────────────────────────────────────────────────────── - name: Install WiX toolset v4 (last open-source release) @@ -129,8 +147,10 @@ jobs: endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }} trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }} certificate-profile-name: ${{ secrets.AZURE_SIGNING_PROFILE }} - files-folder: publish - files-folder-filter: exe + # OUR exe only, by name. publish/ also holds the bundled ConPTY's OpenConsole.exe + # (and conpty.dll), already signed by Microsoft — the old folder + `exe` filter + # would have re-signed Microsoft's binary with our certificate. + files: ${{ github.workspace }}\publish\CodeShellManager.exe file-digest: SHA256 timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 diff --git a/CLAUDE.md b/CLAUDE.md index fa7b0be..9a6c018 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js) AlertDetector → SessionViewModel.RaiseAlert() ``` -- **PseudoTerminal** (`Terminal/PseudoTerminal.cs`): Windows ConPTY wrapper, P/Invoke only. Implements `IPseudoTerminal` (`Terminal/IPseudoTerminal.cs`) — the minimum surface needed by `RunInstance` (`DataReceived`, `Exited`, `ExitCode`, `Start`). Tests inject a fake via the `internal RunInstance(item, Func)` constructor. `BuildCmdLine` passes shells through verbatim (`cmd`/`powershell`/`pwsh`/`wsl`/`bash`/`zsh`/`sh`/`ssh`/`nu`/`fish`); anything else is wrapped in `pwsh.exe -NoExit -Command …` (falls back to `powershell.exe` when pwsh isn't on PATH). The pwsh preference is deliberate — it loads the user's PowerShell 7 profile so functions defined there (e.g. `claudelocal`, `claudework`) resolve as bare session commands. +- **PseudoTerminal** (`Terminal/PseudoTerminal.cs`): Windows ConPTY wrapper, P/Invoke only. Sessions run on the **bundled** ConPTY (`conpty.dll` + `OpenConsole.exe`, bound in `Terminal/ConPtyApi.cs`), run commands on the inbox one — see "Bundled ConPTY". Implements `IPseudoTerminal` (`Terminal/IPseudoTerminal.cs`) — the minimum surface needed by `RunInstance` (`DataReceived`, `Exited`, `ExitCode`, `Start`). Tests inject a fake via the `internal RunInstance(item, Func)` constructor. `BuildCmdLine` passes shells through verbatim (`cmd`/`powershell`/`pwsh`/`wsl`/`bash`/`zsh`/`sh`/`ssh`/`nu`/`fish`); anything else is wrapped in `pwsh.exe -NoExit -Command …` (falls back to `powershell.exe` when pwsh isn't on PATH). The pwsh preference is deliberate — it loads the user's PowerShell 7 profile so functions defined there (e.g. `claudelocal`, `claudework`) resolve as bare session commands. - **TerminalBridge** (`Terminal/TerminalBridge.cs`): Routes bytes between PTY and xterm.js via WebView2 messages. Surfaces accelerator keys (Ctrl-combos, F-keys, Esc) via `_webView.PreviewKeyDown` — the newer WPF WebView2 wrapper forwards accelerators through standard key events rather than a separate `CoreWebView2Controller.AcceleratorKeyPressed`. Bridge re-raises them as `AcceleratorKeyPressed` so `MainWindow.OnBridgeAcceleratorKey` can run global shortcuts even when the terminal has focus. - **OutputIndexer** (`Terminal/OutputIndexer.cs`): Async channels → SQLite, strips ANSI - **AlertDetector** (`Services/AlertDetector.cs`): Regex on raw PTY output, fires after 1.5s idle @@ -434,6 +434,95 @@ contains `PerMonitorV2`: removing `` from the csproj would warning is suppressed in the csproj deliberately — it recommends WinForms' `Application.SetHighDpiMode`, which a WPF app never calls. +## Bundled ConPTY + +Terminals run on a **bundled ConPTY** — `conpty.dll` + `OpenConsole.exe` from the NuGet package +`Microsoft.Windows.Console.ConPTY` (1.25.260930003; both Microsoft-signed, MIT) — instead of +the Windows inbox one (kernel32 → `C:\Windows\System32\conhost.exe`). `UseBundledConPty` in +`AppSettings` (default **true**, no UI) switches back to the inbox one; it is read once at +startup, before the first terminal, so a change needs a restart. + +**Why: two inbox-conhost behaviours, both measured** (conhost 10.0.26100, a raw-mode echo +program behind the app's own `PseudoTerminal`, and the real `claude`): + +1. **It drops OSC sequences on the input side.** Anything the terminal writes back as + `ESC ] … ST` — an OSC 11 colour reply, the OSC 7501 program-status reply — never reaches + the program (CSI and DCS replies do). The bundled one delivers it intact, BEL- or ST-terminated. +2. **It answers `CSI c` (DA1) itself**, immediately, and never forwards the query. Programs + use the DA1 answer as a "no more replies are coming" sentinel after their own queries, so + any reply that has to come from the real terminal loses the race. The bundled one forwards + DA1 to the terminal, where xterm.js answers it in order. + +Concretely, Claude Code 2.1.319's debug log says `programStatus=no (probe: no reply to OSC 7501 ; ?)` +on the inbox ConPTY and `programStatus=yes (probe: OSC 7501 reply)` on the bundled one. + +**Loading rules** (`Terminal/ConPtyApi.cs`, `PseudoTerminal`): +- `conpty.dll` is loaded **by full path** from `AppContext.BaseDirectory` (`NativeLibrary.TryLoad` + + `GetExport`), never by bare name — a bare name walks the DLL search order (current + directory, PATH), i.e. a hijack. `TryBindBundled` refuses a relative path outright. The inbox + functions are likewise bound from `System32\kernel32.dll` by full path. +- Both files must sit **beside the exe**: `conpty.dll` launches `OpenConsole.exe` from its own + folder. The csproj copies them as loose files with `ExcludeFromSingleFile` (the package's own + assets are excluded — they are for C++ projects and would land in `runtimes\win-x64\native\`). +- An HPCON is only valid with the implementation that created it, so each `PseudoTerminal` + remembers its `ConPtyApi` and sends Resize/Close to the same one. +- **Architecture:** the csproj picks the package's files from `RuntimeIdentifier`, then + `PlatformTarget`, then the build machine's OS architecture, defaulting to x64 (CI publishes + `win-x64`). The package lays them out as `runtimes\win-\native\conpty.dll` and + `build\native\runtimes\\OpenConsole.exe`. A process/DLL mismatch simply fails the load + and falls back to inbox. + +**The first output must not be dropped.** Sessions start the PTY and attach the bridge +afterwards (`pty.Start` → `bridge.AttachPty`), and the reader thread starts at the end of +`Start`. With a plain event, whatever the reader got in between had no subscriber and was +lost — and the bundled ConPTY's first bytes are `ESC[1t ESC[c ESC[?1004h ESC[?9001h`. Losing +`ESC[c` means xterm never answers OpenConsole's own DA1 query, and OpenConsole then consumes the +next DA1 answer — the one meant for the program (Claude's probe sentinel). So +`PseudoTerminal.DataReceived` is backed by `Terminal/EarlyOutputBuffer`: output read before the +first subscriber is held (capped at 1M chars) and flushed to that subscriber, under the same lock +the reader uses to pick a handler, so no later chunk overtakes it (`EarlyOutputBufferTests`, +including a concurrent subscribe-mid-stream test). `RunInstance` subscribes before `Start` and is +unaffected. + +**Fallback to inbox** — never "no terminal": the setting is off; a file is missing; the load +or an export lookup fails (wrong architecture, corrupt file); or the bundled +`CreatePseudoConsole` returns a failure HRESULT. In that last case that terminal retries on inbox, +every failure is logged, and only **3 consecutive** failures (`BundledFailurePolicy`, reset by a +success) switch every later terminal to inbox for the rest of the run — one transient failure +should not cost the whole run the bundled ConPTY. The selection and its reason are logged once to +`crash.log` (`PTY ConPTY: bundled (loaded …)` / `PTY ConPTY: inbox — …`). +`PseudoTerminal.ActiveConPty` and the per-instance `ConPtyKind` / `UsesBundledConPty` say which +one is in use. + +**Run commands stay on the inbox ConPTY** (`RunInstance` constructs +`new PseudoTerminal { AllowBundledConPty = false }`). Measured: OpenConsole opens every PTY by +sending `ESC[1t ESC[c` to the terminal and treats the first DA1 answer it receives as the +answer to *its own* query. A headless run PTY has no xterm to answer, and a program inside it +that sends `CSI c` was never answered at all on the bundled one (the inbox conhost answers it +locally — right for a headless PTY). Startup itself is not delayed by the unanswered query +(`cmd /c echo`: first output median 45ms inbox vs 44ms bundled; answering DA1 made no +improvement), and run commands gain nothing from OSC input passthrough — so they keep exactly +the behaviour they had. + +**Other measured differences** (sessions): resize, exit detection (`HasExited`/`Exited`), the +job-object kill and output throughput (`dir /s`, 343KB in ~660ms) are the same on both. +OpenConsole does not clear the screen at start (no `ESC[2J`), terminates OSC titles with ST +instead of BEL, and toggles autowrap (`?7l`/`?7h`); xterm.js handles all of it. The opening +`ESC[1t ESC[c` were added to the CSI final-byte class (`c`, `t`) of the three ANSI strip +regexes (`OutputIndexer`, `RunInstance`, `AlertDetector`) so they don't reach the search index. + +**Packaging:** the MSI installs both files into `INSTALLFOLDER` (`installer/CodeShellManager.wxs`); +the ZIP ships the publish folder. CI fails the build if the `.wxs` lacks either entry, if either +file is missing from `publish/`, or if either is not validly Microsoft-signed. The exe-signing +step signs **`CodeShellManager.exe` by name** — it used to sign every `*.exe` in `publish/`, +which would now re-sign Microsoft's `OpenConsole.exe` with our certificate. + +**Updating the package:** bump `Version` on the `Microsoft.Windows.Console.ConPTY` +PackageReference in the csproj, build, and re-measure both behaviours above (OSC input +passthrough; DA1 forwarded, not answered) plus `ConPtyApiTests` — the property name +`$(PkgMicrosoft_Windows_Console_ConPTY)` and the `build\native\runtimes\x64\OpenConsole.exe` +path are the two things a package layout change would break. + ## Session Lifecycle 1. User clicks **+ New Session** → `NewSessionDialog` modal (Local, Remote SSH, or WSL) diff --git a/README.md b/README.md index d6d81d4..8e634b7 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Most multi-agent tools are task orchestrators: a chat UI around one vendor's age - **Built for fleets, not a handful.** Up to 18 panes on screen, groups with bulk actions, sleep/wake to park projects without killing them, restart-all to pick up a new CLI build, and staggered Claude launches that avoid config corruption. Tested at 50+ sessions. - **Know who needs you.** Sessions waiting for input or tool approval get a green/orange dot and a tray notification. - **Run commands beside the agent.** F5 runs tests or a build in a separate background process with output in a drawer, then pastes the result to the agent in one click. The agent's terminal is never touched. -- **A native Windows app.** WPF + ConPTY. No Electron, Python, daemon, tmux or WSL required. Local, SSH and WSL sessions side by side. Install with winget or Chocolatey; no account; MIT. +- **A native Windows app.** WPF + ConPTY (a bundled, current Microsoft ConPTY, so terminal replies reach your programs intact). No Electron, Python, daemon, tmux or WSL required. Local, SSH and WSL sessions side by side. Install with winget or Chocolatey; no account; MIT. ## How it compares diff --git a/installer/CodeShellManager.wxs b/installer/CodeShellManager.wxs index b664156..081cd62 100644 --- a/installer/CodeShellManager.wxs +++ b/installer/CodeShellManager.wxs @@ -49,6 +49,17 @@ + + + + + + + + diff --git a/src/CodeShellManager/CodeShellManager.csproj b/src/CodeShellManager/CodeShellManager.csproj index 5301109..cb69225 100644 --- a/src/CodeShellManager/CodeShellManager.csproj +++ b/src/CodeShellManager/CodeShellManager.csproj @@ -37,6 +37,42 @@ + + + + + + + <_ConPtyArch Condition="'$(RuntimeIdentifier)' == 'win-arm64'">arm64 + <_ConPtyArch Condition="'$(_ConPtyArch)' == '' and '$(RuntimeIdentifier)' == 'win-x86'">x86 + <_ConPtyArch Condition="'$(_ConPtyArch)' == '' and '$(RuntimeIdentifier)' == 'win-x64'">x64 + <_ConPtyArch Condition="'$(_ConPtyArch)' == '' and '$(PlatformTarget)' == 'ARM64'">arm64 + <_ConPtyArch Condition="'$(_ConPtyArch)' == '' and '$(PlatformTarget)' == 'x86'">x86 + <_ConPtyArch Condition="'$(_ConPtyArch)' == '' and '$(PlatformTarget)' == 'x64'">x64 + <_ConPtyArch Condition="'$(_ConPtyArch)' == '' and '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'Arm64'">arm64 + <_ConPtyArch Condition="'$(_ConPtyArch)' == ''">x64 + + + + + diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index af9e80b..2672e79 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -252,6 +252,9 @@ private async void OnLoaded(object sender, RoutedEventArgs e) await InitDatabaseAsync(); await _vm.LoadStateAsync(); + // Before any terminal starts: PseudoTerminal resolves its ConPTY implementation once, + // on the first Start, from this flag. See "Bundled ConPTY" in CLAUDE.md. + Terminal.PseudoTerminal.PreferBundledConPty = _vm.Settings.UseBundledConPty; RestoreWindowState(); _windowStateReady = true; diff --git a/src/CodeShellManager/Models/AppState.cs b/src/CodeShellManager/Models/AppState.cs index f1a1dc5..8f0c8eb 100644 --- a/src/CodeShellManager/Models/AppState.cs +++ b/src/CodeShellManager/Models/AppState.cs @@ -107,6 +107,14 @@ public class AppSettings /// public bool DebugTerminalTrace { get; set; } = false; + /// + /// Host terminals on the bundled ConPTY (conpty.dll + OpenConsole.exe beside + /// the exe) instead of the Windows inbox one. On by default; no UI — set it to false in + /// state.json to fall back. Read once per run, before the first terminal starts, so a + /// change needs a restart. See "Bundled ConPTY" in CLAUDE.md. + /// + public bool UseBundledConPty { get; set; } = true; + // Terminal font settings public string TerminalFontFamily { get; set; } = "'Cascadia Code', 'Cascadia Mono', Consolas, 'Courier New', monospace"; public int TerminalFontSize { get; set; } = 14; diff --git a/src/CodeShellManager/Services/AlertDetector.cs b/src/CodeShellManager/Services/AlertDetector.cs index 01b6248..228ae9e 100644 --- a/src/CodeShellManager/Services/AlertDetector.cs +++ b/src/CodeShellManager/Services/AlertDetector.cs @@ -83,7 +83,7 @@ internal static string StripAnsi(string raw) => // the next BEL. The `?` in the CSI class covers private-mode sequences (ESC[?25h). // Same pattern as OutputIndexer.AnsiPattern — keep the two in step. private static readonly Regex s_ansi = - new(@"\x1B\[[?0-9;]*[mGKHFJABCDsuhl]|\x1B\].*?(?:\x07|\x1B\\)|\x1B[=>]", RegexOptions.Compiled); + new(@"\x1B\[[?0-9;]*[mGKHFJABCDsuhlct]|\x1B\].*?(?:\x07|\x1B\\)|\x1B[=>]", RegexOptions.Compiled); // Matches Claude's "❯" prompt (U+276F), generic y/n prompts, and "?" questions private static readonly Regex s_prompt = diff --git a/src/CodeShellManager/Services/RunInstance.cs b/src/CodeShellManager/Services/RunInstance.cs index 4546e84..5971571 100644 --- a/src/CodeShellManager/Services/RunInstance.cs +++ b/src/CodeShellManager/Services/RunInstance.cs @@ -53,8 +53,15 @@ public partial class RunInstance : ObservableObject, IDisposable public event Action? OutputChanged; public event Action? StateChanged; + // Run commands stay on the INBOX ConPTY, deliberately. They are headless — no xterm.js + // to answer queries — and the inbox conhost answers device-attribute queries itself, + // which is right for a headless PTY. The bundled OpenConsole forwards them to the + // terminal instead (that is why it is bundled), and consumes the first DA1 reply as the + // answer to its OWN startup query: measured, a program in a headless bundled PTY that + // sends CSI c never gets an answer. Run commands gain nothing from OSC input passthrough, + // so they keep the behaviour they always had. See "Bundled ConPTY" in CLAUDE.md. public RunInstance(RunCommandItem item) - : this(item, static () => new PseudoTerminal()) + : this(item, static () => new PseudoTerminal { AllowBundledConPty = false }) { } @@ -403,6 +410,6 @@ public void Dispose() // Mirrors the strip regex in OutputIndexer.AnsiPattern — keep them in sync. // The `?` inside [?0-9;]* covers CSI private-mode sequences like ESC[?9001h // (ConPTY's Win32 input-mode bootstrap), ESC[?1049h (alt screen), etc. - [GeneratedRegex(@"\x1B\[[?0-9;]*[mGKHFJABCDsuhl]|\x1B\].*?(?:\x07|\x1B\\)|\x1B[=>]|\r", RegexOptions.Compiled)] + [GeneratedRegex(@"\x1B\[[?0-9;]*[mGKHFJABCDsuhlct]|\x1B\].*?(?:\x07|\x1B\\)|\x1B[=>]|\r", RegexOptions.Compiled)] private static partial Regex AnsiPattern(); } diff --git a/src/CodeShellManager/Terminal/ConPtyApi.cs b/src/CodeShellManager/Terminal/ConPtyApi.cs new file mode 100644 index 0000000..e507739 --- /dev/null +++ b/src/CodeShellManager/Terminal/ConPtyApi.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace CodeShellManager.Terminal; + +/// Which ConPTY implementation a pseudo console was created with. +public enum ConPtyKind +{ + /// The Windows inbox ConPTY (kernel32 → system conhost). + Inbox, + /// The bundled conpty.dll + OpenConsole.exe beside the exe. + Bundled, +} + +/// COORD, as the ConPTY functions take it (by value). +[StructLayout(LayoutKind.Sequential)] +internal struct ConPtyCoord +{ + public short X; + public short Y; +} + +/// +/// When repeated bundled CreatePseudoConsole failures switch the process to inbox: after +/// in a row; a success resets the count. One transient +/// failure (resource pressure, an antivirus scan racing OpenConsole.exe) should not cost the +/// rest of the run the bundled ConPTY. Not thread-safe — the caller holds its lock. +/// +internal sealed class BundledFailurePolicy +{ + public const int FailuresBeforeDemotion = 3; + private int _consecutive; + + /// Records a failure; true when this one reaches the demotion threshold. + public bool RecordFailure(out int consecutive) + { + consecutive = ++_consecutive; + return consecutive >= FailuresBeforeDemotion; + } + + public void RecordSuccess() => _consecutive = 0; +} + +/// +/// WPF-free decision: should the bundled ConPTY be tried at all? Pure, so the rules are +/// unit-tested (ConPtyApiTests). Whether it then actually LOADS is a separate step. +/// +internal static class ConPtyResolver +{ + public const string DllName = "conpty.dll"; + public const string HostName = "OpenConsole.exe"; + + public readonly record struct Decision(bool TryBundled, string Reason); + + public static Decision Decide(bool preferBundled, string baseDirectory, Func fileExists) + { + if (!preferBundled) + return new(false, "disabled by setting UseBundledConPty"); + if (string.IsNullOrEmpty(baseDirectory)) + return new(false, "no application directory"); + // OpenConsole.exe is checked too: conpty.dll looks for it next to itself, and a + // conpty.dll without it is not the implementation we measured. + foreach (var name in new[] { DllName, HostName }) + { + if (!fileExists(Path.Combine(baseDirectory, name))) + return new(false, $"{name} not found in {baseDirectory}"); + } + return new(true, $"found {DllName} + {HostName} in {baseDirectory}"); + } +} + +/// +/// The three ConPTY entry points, bound either to the bundled conpty.dll or to the +/// inbox kernel32. Both are bound BY FULL PATH — never a bare "conpty.dll", which +/// would let any same-named DLL earlier on the search path (the current directory, PATH) +/// be loaded into the process instead. +/// +internal sealed unsafe class ConPtyApi +{ + private readonly delegate* unmanaged _create; + private readonly delegate* unmanaged _resize; + private readonly delegate* unmanaged _close; + + public ConPtyKind Kind { get; } + + private ConPtyApi(ConPtyKind kind, IntPtr create, IntPtr resize, IntPtr close) + { + Kind = kind; + _create = (delegate* unmanaged)create; + _resize = (delegate* unmanaged)resize; + _close = (delegate* unmanaged)close; + } + + public int Create(ConPtyCoord size, SafeHandle input, SafeHandle output, uint flags, out IntPtr hpc) + { + IntPtr result = IntPtr.Zero; + bool addIn = false, addOut = false; + try + { + input.DangerousAddRef(ref addIn); + output.DangerousAddRef(ref addOut); + int hr = _create(size, input.DangerousGetHandle(), output.DangerousGetHandle(), flags, &result); + hpc = result; + return hr; + } + finally + { + if (addIn) input.DangerousRelease(); + if (addOut) output.DangerousRelease(); + } + } + + public int Resize(IntPtr hpc, ConPtyCoord size) => _resize(hpc, size); + + public void Close(IntPtr hpc) => _close(hpc); + + // ── Binding ─────────────────────────────────────────────────────────────── + + private static ConPtyApi? s_inbox; + + /// The inbox implementation, from System32's kernel32 by full path. + public static ConPtyApi Inbox => s_inbox ??= BindInbox(); + + private static ConPtyApi BindInbox() + { + IntPtr k32 = NativeLibrary.Load(Path.Combine(Environment.SystemDirectory, "kernel32.dll")); + return new ConPtyApi(ConPtyKind.Inbox, + NativeLibrary.GetExport(k32, "CreatePseudoConsole"), + NativeLibrary.GetExport(k32, "ResizePseudoConsole"), + NativeLibrary.GetExport(k32, "ClosePseudoConsole")); + } + + /// + /// Loads the bundled conpty.dll from and binds its + /// exports. Returns null, with a reason, on any failure — wrong architecture, a corrupt + /// file, a missing export. + /// + public static ConPtyApi? TryBindBundled(string fullPath, out string reason) + { + if (!Path.IsPathFullyQualified(fullPath)) + { + reason = $"refusing to load a relative path: {fullPath}"; + return null; + } + if (!NativeLibrary.TryLoad(fullPath, out IntPtr lib)) + { + reason = $"could not load {fullPath}"; + return null; + } + if (!NativeLibrary.TryGetExport(lib, "CreatePseudoConsole", out var create) + || !NativeLibrary.TryGetExport(lib, "ResizePseudoConsole", out var resize) + || !NativeLibrary.TryGetExport(lib, "ClosePseudoConsole", out var close)) + { + reason = $"{fullPath} lacks a ConPTY export"; + return null; + } + reason = $"loaded {fullPath}"; + return new ConPtyApi(ConPtyKind.Bundled, create, resize, close); + } +} diff --git a/src/CodeShellManager/Terminal/EarlyOutputBuffer.cs b/src/CodeShellManager/Terminal/EarlyOutputBuffer.cs new file mode 100644 index 0000000..4d0150f --- /dev/null +++ b/src/CodeShellManager/Terminal/EarlyOutputBuffer.cs @@ -0,0 +1,71 @@ +using System; +using System.Text; + +namespace CodeShellManager.Terminal; + +/// +/// Backs so output read BEFORE anyone subscribes is +/// kept and handed to the first subscriber, in order, instead of being dropped. +/// +/// Why it exists: a session's PTY is started first and attached to its bridge afterwards +/// (MainWindow.LaunchSessionAsync → pty.Start → bridge.AttachPty), and the +/// reader thread starts at the end of Start. The bundled ConPTY's very first bytes are +/// ESC[1t ESC[c ESC[?1004h ESC[?9001h. Losing ESC[c means xterm.js never answers +/// OpenConsole's own DA1 query — and OpenConsole then consumes the NEXT DA1 answer, the one +/// meant for the program (Claude's probe sentinel), as its own. Losing ?9001h confuses +/// the probe gate. See "Bundled ConPTY" in CLAUDE.md. +/// +/// +/// Ordering: the backlog is flushed to the first subscriber INSIDE the lock, and +/// takes the same lock to pick a handler, so no later chunk can be +/// delivered before the backlog. Live chunks are invoked OUTSIDE the lock, as before, so a +/// handler that blocks cannot stall a subscriber on another thread. Only the first +/// subscriber gets the backlog; after that the buffer is gone for good (output with no +/// subscriber is then dropped, exactly as an ordinary event would). WPF-free; tested in +/// EarlyOutputBufferTests. +/// +/// +internal sealed class EarlyOutputBuffer +{ + /// Cap on what is held for a subscriber that never comes. + public const int MaxBufferedChars = 1 << 20; + + private readonly object _lock = new(); + private Action? _handlers; + private StringBuilder? _pending = new(); + + public void Add(Action? handler) + { + if (handler == null) return; + lock (_lock) + { + _handlers += handler; + var backlog = _pending; + _pending = null; + if (backlog is { Length: > 0 }) handler(backlog.ToString()); + } + } + + public void Remove(Action? handler) + { + if (handler == null) return; + lock (_lock) _handlers -= handler; + } + + /// Called by the reader thread with each decoded chunk. + public void Deliver(string text) + { + Action? handlers; + lock (_lock) + { + handlers = _handlers; + if (handlers == null) + { + if (_pending != null && _pending.Length + text.Length <= MaxBufferedChars) + _pending.Append(text); + return; + } + } + handlers(text); + } +} diff --git a/src/CodeShellManager/Terminal/OutputIndexer.cs b/src/CodeShellManager/Terminal/OutputIndexer.cs index 56b7166..5aa6fbb 100644 --- a/src/CodeShellManager/Terminal/OutputIndexer.cs +++ b/src/CodeShellManager/Terminal/OutputIndexer.cs @@ -88,6 +88,8 @@ public void Dispose() // The `?` inside [?0-9;]* covers CSI private-mode sequences like ESC[?9001h // (ConPTY's Win32 input-mode bootstrap), ESC[?1049h (alt screen), etc. // OSC strings may terminate with BEL (\x07) or ST (ESC\). - [GeneratedRegex(@"\x1B\[[?0-9;]*[mGKHFJABCDsuhl]|\x1B\].*?(?:\x07|\x1B\\)|\x1B[=>]|\r", RegexOptions.Compiled)] + // `c` and `t` came with the bundled ConPTY: OpenConsole opens every session with + // ESC[1t ESC[c (window op + DA1 query), which would otherwise land in the index. + [GeneratedRegex(@"\x1B\[[?0-9;]*[mGKHFJABCDsuhlct]|\x1B\].*?(?:\x07|\x1B\\)|\x1B[=>]|\r", RegexOptions.Compiled)] private static partial Regex AnsiPattern(); } diff --git a/src/CodeShellManager/Terminal/PseudoTerminal.cs b/src/CodeShellManager/Terminal/PseudoTerminal.cs index e7964f7..0587f72 100644 --- a/src/CodeShellManager/Terminal/PseudoTerminal.cs +++ b/src/CodeShellManager/Terminal/PseudoTerminal.cs @@ -25,15 +25,9 @@ private static extern bool CreateProcess(string? lpApplicationName, string lpCom uint dwCreationFlags, IntPtr lpEnvironment, string? lpCurrentDirectory, ref STARTUPINFOEX lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation); - [DllImport("kernel32.dll", SetLastError = true)] - private static extern int CreatePseudoConsole(COORD size, SafeFileHandle hInput, - SafeFileHandle hOutput, uint dwFlags, out IntPtr phPC); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern int ResizePseudoConsole(IntPtr hPC, COORD size); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern void ClosePseudoConsole(IntPtr hPC); + // CreatePseudoConsole / ResizePseudoConsole / ClosePseudoConsole are NOT DllImports: + // they are bound at runtime to either the bundled conpty.dll or the inbox kernel32 — + // see ConPtyApi and "Bundled ConPTY" in CLAUDE.md. [DllImport("kernel32.dll", SetLastError = true)] private static extern bool InitializeProcThreadAttributeList(IntPtr lpAttributeList, @@ -81,9 +75,6 @@ private static extern bool DuplicateHandle(IntPtr hSourceProcessHandle, IntPtr h // ── Structs ─────────────────────────────────────────────────────────────── - [StructLayout(LayoutKind.Sequential)] - private struct COORD { public short X; public short Y; } - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct STARTUPINFOEX { @@ -149,7 +140,93 @@ private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION // ── Fields ──────────────────────────────────────────────────────────────── private IntPtr _hPC = IntPtr.Zero; + // The implementation _hPC was created with. Resize/Close MUST go to the same one — an + // HPCON from conpty.dll means nothing to kernel32, and vice versa. + private ConPtyApi? _api; private IntPtr _hProcess = IntPtr.Zero; + + // ── ConPTY implementation selection ─────────────────────────────────────── + + /// + /// From AppSettings.UseBundledConPty; set at startup before the first + /// . Read once — the choice is cached for the process. + /// + public static bool PreferBundledConPty { get; set; } = true; + + private static readonly object s_apiLock = new(); + private static ConPtyApi? s_selectedApi; + + /// The implementation new terminals use (resolved on first use, then cached). + public static ConPtyKind ActiveConPty => SelectedApi.Kind; + + private static ConPtyApi SelectedApi + { + get + { + lock (s_apiLock) + return s_selectedApi ??= SelectApi(PreferBundledConPty, AppContext.BaseDirectory); + } + } + + /// + /// Bundled when allowed, present and loadable; otherwise the inbox kernel32 ConPTY. The + /// outcome and its reason are logged to crash.log exactly once per process. + /// + internal static ConPtyApi SelectApi(bool preferBundled, string baseDirectory) + { + var decision = ConPtyResolver.Decide(preferBundled, baseDirectory, File.Exists); + if (decision.TryBundled) + { + var bundled = ConPtyApi.TryBindBundled( + Path.Combine(baseDirectory, ConPtyResolver.DllName), out string reason); + if (bundled != null) + { + Log($"ConPTY: bundled ({reason})"); + return bundled; + } + Log($"ConPTY: inbox — bundled failed to load: {reason}"); + return ConPtyApi.Inbox; + } + Log($"ConPTY: inbox — {decision.Reason}"); + return ConPtyApi.Inbox; + } + + private static readonly BundledFailurePolicy s_bundledFailures = new(); + + /// + /// A bundled CreatePseudoConsole failed (this terminal retries on inbox regardless). Only + /// failures IN A ROW switch every + /// later terminal to inbox — see . + /// + private static void NoteBundledFailure(string reason) + { + bool demote; + int count; + lock (s_apiLock) + { + demote = s_bundledFailures.RecordFailure(out count) && s_selectedApi?.Kind == ConPtyKind.Bundled; + if (demote) s_selectedApi = ConPtyApi.Inbox; + } + Log($"ConPTY: {reason} (consecutive failure {count}/{BundledFailurePolicy.FailuresBeforeDemotion}) — this terminal falls back to inbox"); + if (demote) Log("ConPTY: inbox from now on — bundled failed repeatedly"); + } + + private static void NoteBundledSuccess() + { + lock (s_apiLock) s_bundledFailures.RecordSuccess(); + } + + /// + /// False keeps this terminal on the inbox ConPTY whatever the process-wide choice — + /// used by headless run commands (see ). + /// + public bool AllowBundledConPty { get; init; } = true; + + /// Which implementation THIS terminal was created with (valid after Start). + public ConPtyKind ConPtyKind => _api?.Kind ?? ConPtyKind.Inbox; + + /// True when this terminal runs on the bundled conpty.dll + OpenConsole.exe. + public bool UsesBundledConPty => ConPtyKind == ConPtyKind.Bundled; private IntPtr _hJob = IntPtr.Zero; /// @@ -165,7 +242,16 @@ private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION // (box-drawing chars, emoji, etc.) split at buffer boundaries decode correctly. private readonly Decoder _utf8 = Encoding.UTF8.GetDecoder(); - public event Action? DataReceived; + // Output read before the first subscriber attaches is kept and handed over in order — + // sessions attach their bridge only AFTER Start, and the bundled ConPTY's first bytes + // (ESC[1t ESC[c ESC[?1004h ESC[?9001h) must not be lost. See EarlyOutputBuffer. + private readonly EarlyOutputBuffer _output = new(); + + public event Action? DataReceived + { + add => _output.Add(value); + remove => _output.Remove(value); + } public event Action? Exited; /// @@ -222,9 +308,27 @@ public void Start(string command, string args, string workingDirectory, CreatePipe(out _inputRead!, out _inputWrite!, IntPtr.Zero, 0); CreatePipe(out _outputRead!, out _outputWrite!, IntPtr.Zero, 0); - var size = new COORD { X = (short)cols, Y = (short)rows }; - int hr = CreatePseudoConsole(size, _inputRead, _outputWrite, 0, out _hPC); + var size = new ConPtyCoord { X = (short)cols, Y = (short)rows }; + var api = AllowBundledConPty ? SelectedApi : ConPtyApi.Inbox; + int hr = api.Create(size, _inputRead, _outputWrite, 0, out _hPC); + if (api.Kind == ConPtyKind.Bundled) + { + if (hr == 0) + { + NoteBundledSuccess(); + } + else + { + // Never leave a user without a terminal over the bundled copy: this one retries + // on inbox, and repeated failures switch later ones too. Failure leaves the + // pipe handles untouched. + NoteBundledFailure($"bundled CreatePseudoConsole failed: 0x{hr:X}"); + api = ConPtyApi.Inbox; + hr = api.Create(size, _inputRead, _outputWrite, 0, out _hPC); + } + } if (hr != 0) throw new InvalidOperationException($"CreatePseudoConsole failed: 0x{hr:X}"); + _api = api; // Build attribute list with PTY handle IntPtr attrListSize = IntPtr.Zero; @@ -345,8 +449,8 @@ public void Write(string text) public void Resize(int cols, int rows) { - if (_hPC == IntPtr.Zero) return; - ResizePseudoConsole(_hPC, new COORD { X = (short)cols, Y = (short)rows }); + if (_hPC == IntPtr.Zero || _api == null) return; + _api.Resize(_hPC, new ConPtyCoord { X = (short)cols, Y = (short)rows }); } // ── Private ─────────────────────────────────────────────────────────────── @@ -385,7 +489,7 @@ private void ReadLoop() char[] chars = new char[charCount]; _utf8.GetChars(buffer, 0, read, chars, 0); string text = new string(chars); - DataReceived?.Invoke(text); + _output.Deliver(text); } } catch (OperationCanceledException) { } @@ -519,7 +623,7 @@ public void Dispose() _outputRead?.Dispose(); _outputWrite?.Dispose(); if (_hJob != IntPtr.Zero) { CloseHandle(_hJob); _hJob = IntPtr.Zero; } - if (_hPC != IntPtr.Zero) { ClosePseudoConsole(_hPC); _hPC = IntPtr.Zero; } + if (_hPC != IntPtr.Zero) { _api?.Close(_hPC); _hPC = IntPtr.Zero; } if (_hProcess != IntPtr.Zero) { CloseHandle(_hProcess); _hProcess = IntPtr.Zero; } } } diff --git a/tests/CodeShellManager.Tests/ConPtyApiTests.cs b/tests/CodeShellManager.Tests/ConPtyApiTests.cs new file mode 100644 index 0000000..fb79ace --- /dev/null +++ b/tests/CodeShellManager.Tests/ConPtyApiTests.cs @@ -0,0 +1,148 @@ +using System; +using System.IO; +using CodeShellManager.Terminal; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// The bundled-ConPTY loader: when it is tried, that it loads by full path only, and that +/// every failure falls back to the inbox ConPTY instead of leaving the user without a +/// terminal. See "Bundled ConPTY" in CLAUDE.md. +/// +public class ConPtyApiTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "csm-conpty-" + Guid.NewGuid().ToString("N")); + + public ConPtyApiTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } + + private static Func Present(params string[] names) => + path => Array.Exists(names, n => string.Equals(Path.GetFileName(path), n, StringComparison.OrdinalIgnoreCase)); + + [Fact] + public void Decide_tries_bundled_only_when_enabled_and_both_files_exist() + { + var d = ConPtyResolver.Decide(true, @"C:\app", Present("conpty.dll", "OpenConsole.exe")); + Assert.True(d.TryBundled); + } + + [Fact] + public void Decide_honours_the_setting() + { + var d = ConPtyResolver.Decide(false, @"C:\app", Present("conpty.dll", "OpenConsole.exe")); + Assert.False(d.TryBundled); + Assert.Contains("UseBundledConPty", d.Reason); + } + + [Theory] + [InlineData("conpty.dll")] // OpenConsole.exe missing + [InlineData("OpenConsole.exe")] // conpty.dll missing + public void Decide_needs_both_files(string onlyThis) + { + var d = ConPtyResolver.Decide(true, @"C:\app", Present(onlyThis)); + Assert.False(d.TryBundled); + Assert.Contains("not found", d.Reason); + } + + [Fact] + public void Decide_checks_the_given_directory_by_full_path() + { + string? asked = null; + ConPtyResolver.Decide(true, @"C:\app", p => { asked ??= p; return false; }); + Assert.Equal(@"C:\app\conpty.dll", asked); + } + + [Fact] + public void TryBindBundled_refuses_a_relative_path() + { + // A bare name would go through the DLL search order — the hijack this design avoids. + Assert.Null(ConPtyApi.TryBindBundled("conpty.dll", out var reason)); + Assert.Contains("relative", reason); + } + + [Fact] + public void TryBindBundled_fails_cleanly_on_a_missing_or_corrupt_file() + { + Assert.Null(ConPtyApi.TryBindBundled(Path.Combine(_dir, "conpty.dll"), out _)); + + string garbage = Path.Combine(_dir, "garbage.dll"); + File.WriteAllText(garbage, "not a PE image"); + Assert.Null(ConPtyApi.TryBindBundled(garbage, out var reason)); + Assert.Contains("could not load", reason); + } + + [Fact] + public void SelectApi_falls_back_to_inbox_on_every_failure() + { + Assert.Equal(ConPtyKind.Inbox, PseudoTerminal.SelectApi(false, AppContext.BaseDirectory).Kind); + Assert.Equal(ConPtyKind.Inbox, PseudoTerminal.SelectApi(true, _dir).Kind); // files missing + + // Both files present but not loadable. + File.WriteAllText(Path.Combine(_dir, "conpty.dll"), "corrupt"); + File.WriteAllText(Path.Combine(_dir, "OpenConsole.exe"), "corrupt"); + Assert.Equal(ConPtyKind.Inbox, PseudoTerminal.SelectApi(true, _dir).Kind); + } + + [Fact] + public void The_build_ships_both_files_beside_the_assembly_and_conpty_binds() + { + // The app project copies conpty.dll + OpenConsole.exe as loose files; they flow to + // this test project's output too. Losing either means silently running on the + // inbox ConPTY again. + string dll = Path.Combine(AppContext.BaseDirectory, "conpty.dll"); + Assert.True(File.Exists(dll), "conpty.dll missing from build output"); + Assert.True(File.Exists(Path.Combine(AppContext.BaseDirectory, "OpenConsole.exe")), + "OpenConsole.exe missing from build output"); + + // The build picks the package's architecture from the build machine; only assert that + // it binds when that matches this test process (an x64-emulated test host on an arm64 + // box is a legitimate mismatch — the app then falls back to inbox, which another test + // covers). + if (PeMachine(dll) != ExpectedMachine()) return; + + var api = ConPtyApi.TryBindBundled(dll, out var reason); + Assert.True(api != null, reason); + Assert.Equal(ConPtyKind.Bundled, api!.Kind); + Assert.Equal(ConPtyKind.Bundled, PseudoTerminal.SelectApi(true, AppContext.BaseDirectory).Kind); + } + + [Fact] + public void A_single_bundled_failure_does_not_demote_three_in_a_row_do() + { + var p = new BundledFailurePolicy(); + Assert.False(p.RecordFailure(out var c1)); + Assert.Equal(1, c1); + p.RecordSuccess(); // a success resets the run + Assert.False(p.RecordFailure(out _)); + Assert.False(p.RecordFailure(out _)); + Assert.True(p.RecordFailure(out var c3)); + Assert.Equal(BundledFailurePolicy.FailuresBeforeDemotion, c3); + } + + private static ushort PeMachine(string path) + { + using var fs = File.OpenRead(path); + using var br = new BinaryReader(fs); + fs.Position = 0x3C; + fs.Position = br.ReadInt32() + 4; // "PE\0\0", then IMAGE_FILE_HEADER.Machine + return br.ReadUInt16(); + } + + private static ushort ExpectedMachine() => System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture switch + { + System.Runtime.InteropServices.Architecture.Arm64 => 0xAA64, + System.Runtime.InteropServices.Architecture.X86 => 0x014C, + _ => 0x8664, + }; + + [Fact] + public void Inbox_binds_from_System32() + { + Assert.Equal(ConPtyKind.Inbox, ConPtyApi.Inbox.Kind); + } +} diff --git a/tests/CodeShellManager.Tests/EarlyOutputBufferTests.cs b/tests/CodeShellManager.Tests/EarlyOutputBufferTests.cs new file mode 100644 index 0000000..afdea6f --- /dev/null +++ b/tests/CodeShellManager.Tests/EarlyOutputBufferTests.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using CodeShellManager.Terminal; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// backs PseudoTerminal.DataReceived. A session's PTY is +/// started before its bridge subscribes, and the bundled ConPTY's first bytes +/// (ESC[1t ESC[c ESC[?1004h ESC[?9001h) must reach xterm.js — losing ESC[c makes +/// OpenConsole swallow the program's DA1 answer later. +/// +public class EarlyOutputBufferTests +{ + private const string Opening = "\u001b[1t\u001b[c\u001b[?1004h\u001b[?9001h"; + + [Fact] + public void Output_before_the_first_subscriber_is_delivered_to_it_in_order() + { + var buf = new EarlyOutputBuffer(); + buf.Deliver(Opening); + buf.Deliver("prompt> "); + + var got = new List(); + buf.Add(got.Add); + buf.Deliver("after"); + + Assert.Equal(new[] { Opening + "prompt> ", "after" }, got); + } + + [Fact] + public void Nothing_is_replayed_to_a_later_subscriber() + { + var buf = new EarlyOutputBuffer(); + buf.Deliver("early"); + var first = new List(); + var second = new List(); + buf.Add(first.Add); + buf.Add(second.Add); + buf.Deliver("live"); + + Assert.Equal(new[] { "early", "live" }, first); + Assert.Equal(new[] { "live" }, second); + } + + [Fact] + public void A_subscriber_attached_after_unsubscribe_gets_no_stale_backlog() + { + var buf = new EarlyOutputBuffer(); + var a = new List(); + buf.Add(a.Add); + buf.Remove(a.Add); + buf.Deliver("unheard"); // no subscriber: dropped, as an event would + var b = new List(); + buf.Add(b.Add); + Assert.Empty(b); + } + + [Fact] + public void The_backlog_is_capped() + { + var buf = new EarlyOutputBuffer(); + string big = new string('x', EarlyOutputBuffer.MaxBufferedChars); + buf.Deliver(big); + buf.Deliver("overflow"); // would exceed the cap: dropped + var got = new List(); + buf.Add(got.Add); + Assert.Single(got); + Assert.Equal(EarlyOutputBuffer.MaxBufferedChars, got[0].Length); + } + + [Fact] + public void Concurrent_reader_and_subscribe_never_reorder_or_lose_chunks() + { + // The reader thread delivers numbered chunks while another thread subscribes mid-way. + // Whatever the interleaving, the subscriber must see 0..N-1 exactly once, in order. + for (int round = 0; round < 50; round++) + { + var buf = new EarlyOutputBuffer(); + const int n = 2000; + var sb = new System.Text.StringBuilder(); + var started = new ManualResetEventSlim(); + var reader = Task.Run(() => + { + for (int i = 0; i < n; i++) + { + if (i == 10) started.Set(); + buf.Deliver(i + ";"); + } + }); + started.Wait(); + buf.Add(s => { lock (sb) sb.Append(s); }); + reader.Wait(); + + var parts = sb.ToString().TrimEnd(';').Split(';'); + Assert.Equal(n, parts.Length); + for (int i = 0; i < n; i++) Assert.Equal(i.ToString(), parts[i]); + } + } +} diff --git a/tests/CodeShellManager.Tests/OutputIndexerTests.cs b/tests/CodeShellManager.Tests/OutputIndexerTests.cs index 33dc7e4..85eb53e 100644 --- a/tests/CodeShellManager.Tests/OutputIndexerTests.cs +++ b/tests/CodeShellManager.Tests/OutputIndexerTests.cs @@ -12,7 +12,7 @@ namespace CodeShellManager.Tests; /// /// The pattern (as of writing) is: /// -/// \x1B\[[?0-9;]*[mGKHFJABCDsuhl] // CSI: ESC[ ... final-byte +/// \x1B\[[?0-9;]*[mGKHFJABCDsuhlct] // CSI: ESC[ ... final-byte /// | \x1B\].*?(?:\x07|\x1B\\) // OSC: ESC] ... BEL or ESC\ (ST) /// | \x1B[=>] // DECKPAM / DECKPNM (ESC= / ESC>) /// | \r // carriage return @@ -20,7 +20,7 @@ namespace CodeShellManager.Tests; /// /// Note: this regex deliberately handles only the subset of ANSI control sequences /// that ConPTY + xterm.js commonly emit. CSI sequences whose final byte is outside -/// [mGKHFJABCDsuhl] (for example DEC private-mode n, r, t, +/// [mGKHFJABCDsuhlct] (for example DEC private-mode n, r, /// or device-status reports) are intentionally left in the indexed text. /// /// String-literal note: ESC and BEL are written as  /  @@ -146,11 +146,20 @@ public void Strip_MixedAnsiAndPlain_RemovesOnlyEscapes() Assert.Equal("Error: something warning done", Strip(input)); } + [Fact] + public void Strip_RemovesTheBundledConPtyOpeningQueries() + { + // OpenConsole (bundled ConPTY) starts every session with ESC[1t ESC[c, and DA1 + // answers look like ESC[?61;6;7c. None of it belongs in the search index. + var input = $"{ESC}[1t{ESC}[c{ESC}[?1004h{ESC}[?9001hC:\\>{ESC}[?61;6;7;22c"; + Assert.Equal("C:\\>", Strip(input)); + } + [Fact] public void Strip_LeavesUnsupportedCsiFinalsIntact() { // Documenting current behavior: the regex's CSI arm only accepts finals - // in [mGKHFJABCDsuhl]. A device-status query like ESC[6n (final 'n') is + // in [mGKHFJABCDsuhlct]. A device-status query like ESC[6n (final 'n') is // NOT stripped. If the regex is later widened, update this test. var input = $"before{ESC}[6nafter"; // The escape sequence remains intact because the CSI arm requires a