Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
91 changes: 90 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<IPseudoTerminal>)` 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<IPseudoTerminal>)` 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
Expand Down Expand Up @@ -434,6 +434,95 @@ contains `PerMonitorV2`: removing `<ApplicationManifest>` 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-<arch>\native\conpty.dll` and
`build\native\runtimes\<arch>\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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions installer/CodeShellManager.wxs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@
<File Source="$(var.PublishDir)\CodeShellManager.exe" KeyPath="yes" />
</Component>

<!-- Bundled ConPTY (Microsoft.Windows.Console.ConPTY, Microsoft-signed). Both MUST sit
beside CodeShellManager.exe: PseudoTerminal loads conpty.dll by full path from the
exe's folder, and conpty.dll starts OpenConsole.exe from its own folder. Missing
either silently falls back to the inbox ConPTY. The CI guard checks these entries. -->
<Component Directory="INSTALLFOLDER" Guid="*">
<File Source="$(var.PublishDir)\conpty.dll" KeyPath="yes" />
</Component>
<Component Directory="INSTALLFOLDER" Guid="*">
<File Source="$(var.PublishDir)\OpenConsole.exe" KeyPath="yes" />
</Component>

<!-- App icon (for Add/Remove Programs entry) -->
<Component Directory="AssetsFolder" Guid="*">
<File Source="$(var.PublishDir)\Assets\app.ico" KeyPath="yes" />
Expand Down
36 changes: 36 additions & 0 deletions src/CodeShellManager/CodeShellManager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,42 @@
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.14" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4314.35" />
<!-- Bundled ConPTY (conpty.dll + OpenConsole.exe, Microsoft-signed, MIT). See "Bundled
ConPTY" in CLAUDE.md for why. ExcludeAssets="all": the package's own build/runtime
assets are for C++ projects and would drop conpty.dll into runtimes\win-x64\native\;
both files are copied explicitly below instead, as LOOSE files beside the exe
(conpty.dll looks for OpenConsole.exe next to itself, and PseudoTerminal loads
conpty.dll by full path from AppContext.BaseDirectory). To update: bump Version
here, rebuild, and re-run the checks listed in CLAUDE.md. -->
<PackageReference Include="Microsoft.Windows.Console.ConPTY" Version="1.25.260930003"
GeneratePathProperty="true" ExcludeAssets="all" PrivateAssets="all" />
</ItemGroup>

<!-- Which architecture's ConPTY to ship. In order: an explicit RuntimeIdentifier (CI
publishes win-x64), then PlatformTarget, then the BUILD machine's OS architecture —
an AnyCPU build with no RID runs natively, so on an arm64 dev box that is arm64.
Defaults to x64. The package lays the two files out differently:
runtimes\win-<arch>\native\conpty.dll and build\native\runtimes\<arch>\OpenConsole.exe. -->
<PropertyGroup>
<_ConPtyArch Condition="'$(RuntimeIdentifier)' == 'win-arm64'">arm64</_ConPtyArch>
<_ConPtyArch Condition="'$(_ConPtyArch)' == '' and '$(RuntimeIdentifier)' == 'win-x86'">x86</_ConPtyArch>
<_ConPtyArch Condition="'$(_ConPtyArch)' == '' and '$(RuntimeIdentifier)' == 'win-x64'">x64</_ConPtyArch>
<_ConPtyArch Condition="'$(_ConPtyArch)' == '' and '$(PlatformTarget)' == 'ARM64'">arm64</_ConPtyArch>
<_ConPtyArch Condition="'$(_ConPtyArch)' == '' and '$(PlatformTarget)' == 'x86'">x86</_ConPtyArch>
<_ConPtyArch Condition="'$(_ConPtyArch)' == '' and '$(PlatformTarget)' == 'x64'">x64</_ConPtyArch>
<_ConPtyArch Condition="'$(_ConPtyArch)' == '' and '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'Arm64'">arm64</_ConPtyArch>
<_ConPtyArch Condition="'$(_ConPtyArch)' == ''">x64</_ConPtyArch>
</PropertyGroup>

<ItemGroup>
<None Include="$(PkgMicrosoft_Windows_Console_ConPTY)\runtimes\win-$(_ConPtyArch)\native\conpty.dll"
Link="conpty.dll" Visible="false"
CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest"
ExcludeFromSingleFile="true" />
<None Include="$(PkgMicrosoft_Windows_Console_ConPTY)\build\native\runtimes\$(_ConPtyArch)\OpenConsole.exe"
Link="OpenConsole.exe" Visible="false"
CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest"
ExcludeFromSingleFile="true" />
</ItemGroup>

<ItemGroup>
Expand Down
3 changes: 3 additions & 0 deletions src/CodeShellManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
8 changes: 8 additions & 0 deletions src/CodeShellManager/Models/AppState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ public class AppSettings
/// </summary>
public bool DebugTerminalTrace { get; set; } = false;

/// <summary>
/// Host terminals on the bundled ConPTY (<c>conpty.dll</c> + <c>OpenConsole.exe</c> 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.
/// </summary>
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;
Expand Down
2 changes: 1 addition & 1 deletion src/CodeShellManager/Services/AlertDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Loading