Skip to content

Instantly share code, notes, and snippets.

@frostbtn
Last active February 12, 2026 06:57
Show Gist options
  • Select an option

  • Save frostbtn/f85b574ce132c8853945df852e1e0d31 to your computer and use it in GitHub Desktop.

Select an option

Save frostbtn/f85b574ce132c8853945df852e1e0d31 to your computer and use it in GitHub Desktop.

ClaudePatcher

Runtime memory patcher for Claude Code's Windows markdown bug (since around v2.1.14). Hacks #14720.

Patches claude.exe in memory before it starts. Searches for

.normalize().replaceAll(`\r\n`,`\n`) and replaces it with

.normalize().replaceAll(`\v\n`,`\n`) - making it essentially a no-op since nobody's used vertical tabs (\v) since like the 70s.

Since 2.1.39, that patch alone started to not be enough as CC started to bypass this place to use Bun's built-in capacity. So, we need to disable call the Bun's function too:

if(typeof Bun<"u"){if(typeof Bun.wrapAnsi==="function")return Bun.wrapAnsi(H,$,A)} to if(false ){if(typeof Bun.wrapAnsi==="function")return Bun.wrapAnsi(H,$,A)}

The patcher looks for claude.exe next to itself first, then falls back to %USERPROFILE%\.local\bin\claude.exe where native Claude Code usually installs. Passes through all arguments as-is and stays silent unless something goes wrong.

Requires .NET 8.0 or later.

Build:

dotnet publish ClaudePatcher.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -p:PublishTrimmed=true -p:EnableCompressionInSingleFile=true

Run the resulting ClaudePatcher.exe instead of claude.exe. Start with ClaudePatcher.exe -v it should print the CC's version as usual and exit.

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<OutputPath>.\</OutputPath>
<PublishDir>.\</PublishDir>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
</Project>
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
class ClaudePatcher
{
// Win32 API declarations
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress,
[Out] byte[] lpBuffer, int dwSize, out IntPtr lpNumberOfBytesRead);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress,
byte[] lpBuffer, int nSize, out IntPtr lpNumberOfBytesWritten);
[DllImport("kernel32.dll")]
static extern bool VirtualQueryEx(IntPtr hProcess, IntPtr lpAddress,
out MEMORY_BASIC_INFORMATION lpBuffer, uint dwLength);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool VirtualProtectEx(IntPtr hProcess, IntPtr lpAddress,
UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
static extern bool CreateProcess(
string? lpApplicationName,
string lpCommandLine,
IntPtr lpProcessAttributes,
IntPtr lpThreadAttributes,
bool bInheritHandles,
uint dwCreationFlags,
IntPtr lpEnvironment,
string? lpCurrentDirectory,
ref STARTUPINFO lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);
[DllImport("kernel32.dll", SetLastError = true)]
static extern uint ResumeThread(IntPtr hThread);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", SetLastError = true)]
static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
private const uint CREATE_SUSPENDED = 0x00000004;
private const uint INFINITE = 0xFFFFFFFF;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
struct STARTUPINFO
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[StructLayout(LayoutKind.Sequential)]
struct MEMORY_BASIC_INFORMATION
{
public IntPtr BaseAddress;
public IntPtr AllocationBase;
public uint AllocationProtect;
public IntPtr RegionSize;
public uint State;
public uint Protect;
public uint Type;
}
private const uint MEM_COMMIT = 0x1000;
private const uint PAGE_READONLY = 0x02;
private const uint PAGE_READWRITE = 0x04;
private const uint PAGE_EXECUTE_READ = 0x20;
private const uint PAGE_EXECUTE_READWRITE = 0x40;
static string? FindClaudeExe()
{
// Check same directory as patcher
string appDir = Path.GetDirectoryName(Environment.ProcessPath) ?? "";
string localPath = Path.Combine(appDir, "claude.exe");
if (File.Exists(localPath))
return localPath;
// Check user's .local/bin directory
string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string userLocalPath = Path.Combine(userProfile, ".local", "bin", "claude.exe");
if (File.Exists(userLocalPath))
return userLocalPath;
return null;
}
static void Main(string[] args)
{
string? claudeExe = FindClaudeExe();
if (claudeExe == null)
{
Console.WriteLine("Error: claude.exe not found");
Console.WriteLine("Searched locations:");
Console.WriteLine($" - {Path.GetDirectoryName(Environment.ProcessPath)}\\claude.exe");
string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
Console.WriteLine($" - {Path.Combine(userProfile, ".local", "bin", "claude.exe")}");
Environment.Exit(1);
}
try
{
STARTUPINFO si = new STARTUPINFO();
si.cb = Marshal.SizeOf(si);
PROCESS_INFORMATION pi;
string cmdLine = claudeExe + (args.Length > 0 ? " " + string.Join(" ", args) : "");
if (!CreateProcess(null, cmdLine, IntPtr.Zero, IntPtr.Zero, false,
CREATE_SUSPENDED, IntPtr.Zero, null, ref si, out pi))
{
Console.WriteLine($"Failed to create process. Error: {Marshal.GetLastWin32Error()}");
Environment.Exit(1);
}
StringBuilder diagnostics = new StringBuilder();
byte[] oldBytes;
byte[] newBytes;
int patchCount = 0;
oldBytes = Encoding.ASCII.GetBytes(".normalize().replaceAll(`\\r\n`,`\n`)");
newBytes = Encoding.ASCII.GetBytes(".normalize().replaceAll(`\\v\n`,`\n`)");
patchCount += SearchAndPatch(pi.hProcess, oldBytes, newBytes, diagnostics);
oldBytes = Encoding.ASCII.GetBytes("""if(typeof Bun<"u"){if(typeof Bun.wrapAnsi==="function")return Bun.wrapAnsi(H,$,A)}""");
newBytes = Encoding.ASCII.GetBytes("""if(false ){if(typeof Bun.wrapAnsi==="function")return Bun.wrapAnsi(H,$,A)}""");
patchCount += SearchAndPatch(pi.hProcess, oldBytes, newBytes, diagnostics);
ResumeThread(pi.hThread);
WaitForSingleObject(pi.hProcess, INFINITE);
uint exitCode;
GetExitCodeProcess(pi.hProcess, out exitCode);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
// Only print diagnostics if patching failed
if (patchCount < 1)
{
Console.WriteLine($"Warning: No patches applied");
Console.WriteLine(diagnostics.ToString());
}
Environment.Exit((int)exitCode);
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
Environment.Exit(1);
}
}
static int SearchAndPatch(
IntPtr hProcess,
byte[] oldBytes,
byte[] newBytes,
StringBuilder diagnostics)
{
if (oldBytes.Length != newBytes.Length)
{
diagnostics.AppendLine("Error: Search and replace patterns must be same length");
return 0;
}
int patchCount = 0;
IntPtr address = IntPtr.Zero;
MEMORY_BASIC_INFORMATION mbi = new MEMORY_BASIC_INFORMATION();
uint mbiSize = (uint)Marshal.SizeOf(typeof(MEMORY_BASIC_INFORMATION));
while (VirtualQueryEx(hProcess, address, out mbi, mbiSize))
{
// Advance to next region
long nextAddress = mbi.BaseAddress.ToInt64() + mbi.RegionSize.ToInt64();
if (nextAddress <= 0 || nextAddress > 0x7FFFFFFFFFFF)
break;
address = new IntPtr(nextAddress);
// Skip non-committed memory
if (mbi.State != MEM_COMMIT)
continue;
// Skip non-readable memory
if (mbi.Protect != PAGE_READONLY &&
mbi.Protect != PAGE_READWRITE &&
mbi.Protect != PAGE_EXECUTE_READ &&
mbi.Protect != PAGE_EXECUTE_READWRITE)
continue;
long regionSize = mbi.RegionSize.ToInt64();
// Skip invalid or excessively large regions (> 500MB)
if (regionSize <= 0 || regionSize >= 500 * 1024 * 1024)
continue;
byte[] buffer = new byte[regionSize];
if (!ReadProcessMemory(hProcess, mbi.BaseAddress, buffer,
(int)regionSize, out IntPtr bytesRead))
continue;
// Search for pattern in this region
for (long i = 0; i <= bytesRead.ToInt64() - oldBytes.Length; i++)
{
bool match = true;
for (int j = 0; j < oldBytes.Length; j++)
{
if (buffer[i + j] != oldBytes[j])
{
match = false;
break;
}
}
if (!match)
continue;
IntPtr patchAddress = IntPtr.Add(mbi.BaseAddress, (int)i);
diagnostics.AppendLine($"Found pattern at 0x{patchAddress.ToString("X")}");
uint oldProtect;
if (!VirtualProtectEx(hProcess, patchAddress, (UIntPtr)newBytes.Length,
PAGE_EXECUTE_READWRITE, out oldProtect))
{
diagnostics.AppendLine($" Failed to change protection (Error: {Marshal.GetLastWin32Error()})");
continue;
}
if (WriteProcessMemory(hProcess, patchAddress,
newBytes, newBytes.Length, out IntPtr written))
{
diagnostics.AppendLine($" Patched successfully");
patchCount++;
}
else
{
diagnostics.AppendLine($" Failed to write (Error: {Marshal.GetLastWin32Error()})");
}
VirtualProtectEx(hProcess, patchAddress, (UIntPtr)newBytes.Length,
oldProtect, out oldProtect);
}
}
return patchCount;
}
}
@frostbtn
Copy link
Author

Since 2.1.39 they started to optionally use Bun's capacity for wrapping long lines. We didn't like this option and disabled it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment