[Bug] Windows 10: attached background sessions ignore Esc / Ctrl+C / Ctrl+X / Ctrl+S, and the mouse wheel never reaches the session (inbox ConPTY drops mouse input)
Environment
- Claude Code 2.1.289 (native install). First noticed on 2.1.270 (2026-09-14); still present on every version since.
- Windows 10 Pro 22H2 (build 19045), inbox
conhost.exe10.0.19041.4522 - Terminal: VS Code integrated terminal running Git Bash. A foreground
claudein the same terminal handles all of the keys below, and the mouse wheel, normally.
Symptom
In a background session opened from the agents view (attached), none of these have any effect:
- Esc (cannot interrupt a running turn)
- Ctrl+C
- Ctrl+X (including the
ctrl+x ctrl+ssend-now chord) - Ctrl+S
- Mouse wheel (cannot scroll the transcript)
Printable characters, Enter and Ctrl+Z (detach) do work. On 2.1.270 the transcripts of background sessions contained zero [Request interrupted by user] markers, while foreground sessions from the same day had several, so the interrupt never reaches the session at all.
Process topology
VS Code -> OpenConsole.exe (VS Code's bundled conpty) -> bash -> claude.exe (attached client)
claude daemon -> claude.exe --bg-pty-host \\.\pipe\cc-daemon-... -> C:\Windows\system32\conhost.exe --headless -> claude.exe --session-id ...
The background session's pseudo-console comes from kernel32 CreatePseudoConsole, so on Windows 10 it is the old inbox conhost rather than a bundled conpty.dll / OpenConsole.exe. I could not find a setting or env var that changes this.
A/B test: identical bytes through both ConPTY implementations
A small ctypes harness (below) creates a pseudo-console (a) with kernel32 CreatePseudoConsole (inbox conhost) and (b) with VS Code's bundled conpty.dll (OpenConsole.exe). It starts a child that opens CONIN$, turns on mouse tracking the way the fullscreen renderer does (ESC[?1000h ESC[?1002h ESC[?1006h), and logs every console input record it receives. The parent then writes identical bytes into the input pipe. Both VT input mode (ENABLE_VIRTUAL_TERMINAL_INPUT) and classic mode (ENABLE_MOUSE_INPUT) were tested; the results were the same in both.
| Bytes written to the input pipe | inbox conhost 10.0.19041 | bundled OpenConsole |
|---|---|---|
| ESC (lone) | delivered | delivered |
| 0x18 Ctrl+X, 0x13 Ctrl+S, 0x03 Ctrl+C | delivered | delivered |
| ESC[<65;10;5M (SGR wheel) | dropped, no input record at all | delivered (as a VT sequence, or as a MOUSE_EVENT wheel record) |
| child's ESC[?1000h ... on the output side | not forwarded to the output pipe | forwarded |
Conclusion: two separate defects
- Mouse wheel: the Windows 10 inbox ConPTY neither forwards the mouse-tracking request nor passes mouse input. To make the wheel work on Windows 10, the bg pty host would need to ship and use a bundled
conpty.dll+OpenConsole.exe, the way VS Code's node-pty does (useConptyDll). - Esc / Ctrl+C / Ctrl+X / Ctrl+S: the inbox ConPTY delivers these bytes correctly, so they are lost earlier, somewhere on the attach client ->
--bg-pty-hostforwarding path.
Possibly related: #68465 (closed as not planned: dropped ESC in Windows background sessions), #93788 (ESC-sequence keys dead in 2.1.269), #95133 (Windows daemon PTY size).
Workaround
Run sessions that need Esc or scrolling in the foreground. To stop a background turn, detach with Ctrl+Z and stop the session from the agents view, or run claude stop <id>.
<details>
<summary>Test harness (Python 3.12, no dependencies): conpty_child.py</summary>
# Child inside the pseudo-console: logs the console input records it receives.
# argv: <out.jsonl> <vt|novt>
import ctypes, ctypes.wintypes as w, sys, time, json
k32 = ctypes.WinDLL('kernel32', use_last_error=True)
k32.CreateFileW.restype = w.HANDLE
class KEY(ctypes.Structure):
_fields_ = [('bKeyDown', w.BOOL), ('wRepeatCount', w.WORD), ('wVirtualKeyCode', w.WORD),
('wVirtualScanCode', w.WORD), ('uChar', w.WCHAR), ('dwControlKeyState', w.DWORD)]
class MOUSE(ctypes.Structure):
_fields_ = [('X', w.SHORT), ('Y', w.SHORT), ('dwButtonState', w.DWORD),
('dwControlKeyState', w.DWORD), ('dwEventFlags', w.DWORD)]
class U(ctypes.Union):
_fields_ = [('Key', KEY), ('Mouse', MOUSE), ('pad', ctypes.c_byte * 16)]
class IR(ctypes.Structure):
_fields_ = [('EventType', w.WORD), ('Event', U)]
out_path, mode = sys.argv[1], sys.argv[2]
log = open(out_path, 'w', encoding='utf-8')
def rec(**kw):
log.write(json.dumps(kw) + '\n'); log.flush()
HANDLER = ctypes.WINFUNCTYPE(w.BOOL, w.DWORD)
def on_ctrl(ev):
rec(signal=ev)
return True
h = HANDLER(on_ctrl)
k32.SetConsoleCtrlHandler(h, True)
hin = k32.CreateFileW('CONIN$', 0xC0000000, 3, None, 3, 0, None)
hout = k32.CreateFileW('CONOUT$', 0xC0000000, 3, None, 3, 0, None)
k32.SetConsoleMode(hout, 0x1 | 0x4) # processed output + VT processing
in_mode = 0x80 | 0x8 # extended flags + window input (raw)
in_mode |= 0x200 if mode == 'vt' else 0x10 # VT input, or classic mouse input
rec(set_in_mode=hex(in_mode), ok=bool(k32.SetConsoleMode(hin, in_mode)))
n = w.DWORD()
seq = '\x1b[?1000h\x1b[?1002h\x1b[?1006hREADY\r\n' # enable mouse tracking like the fullscreen renderer
k32.WriteConsoleW(hout, seq, len(seq), ctypes.byref(n), None)
buf = (IR * 64)()
end = time.time() + 10
while time.time() < end:
if k32.WaitForSingleObject(hin, 100) != 0:
continue
if not k32.ReadConsoleInputW(hin, buf, 64, ctypes.byref(n)):
break
for i in range(n.value):
r = buf[i]
if r.EventType == 1:
k = r.Event.Key
if k.bKeyDown:
rec(key=k.wVirtualKeyCode, ch=ord(k.uChar), ctrl=k.dwControlKeyState)
if k.uChar == 'z':
end = 0
elif r.EventType == 2:
m = r.Event.Mouse
rec(mouse=True, buttons=hex(m.dwButtonState), flags=m.dwEventFlags)
log.close()
</details>
<details>
<summary>Test harness: conpty_test.py (set VSC to your VS Code install)</summary>
# Feed identical bytes into inbox conhost (kernel32) and VS Code's OpenConsole (conpty.dll); compare what the child receives.
import ctypes, ctypes.wintypes as w, sys, os, time, threading, json
TMP = os.path.dirname(os.path.abspath(__file__))
PY = sys.executable
VSC = r'<VS Code install dir>\<commit>\resources\app\node_modules.asar.unpacked\node-pty\build\Release\conpty\conpty.dll'
k32 = ctypes.WinDLL('kernel32', use_last_error=True)
class COORD(ctypes.Structure):
_fields_ = [('X', w.SHORT), ('Y', w.SHORT)]
class SI(ctypes.Structure):
_fields_ = [('cb', w.DWORD), ('lpReserved', w.LPWSTR), ('lpDesktop', w.LPWSTR), ('lpTitle', w.LPWSTR),
('dwX', w.DWORD), ('dwY', w.DWORD), ('dwXSize', w.DWORD), ('dwYSize', w.DWORD),
('dwXCountChars', w.DWORD), ('dwYCountChars', w.DWORD), ('dwFillAttribute', w.DWORD),
('dwFlags', w.DWORD), ('wShowWindow', w.WORD), ('cbReserved2', w.WORD),
('lpReserved2', ctypes.c_void_p), ('hStdInput', w.HANDLE), ('hStdOutput', w.HANDLE),
('hStdError', w.HANDLE)]
class SIEX(ctypes.Structure):
_fields_ = [('StartupInfo', SI), ('lpAttributeList', ctypes.c_void_p)]
class PI(ctypes.Structure):
_fields_ = [('hProcess', w.HANDLE), ('hThread', w.HANDLE), ('dwProcessId', w.DWORD), ('dwThreadId', w.DWORD)]
# (label, bytes, pause before next input)
INPUTS = [('plain a', b'a', .5), ('Esc', b'\x1b', .8), ('plain b', b'b', .5),
('wheel down (SGR)', b'\x1b[<65;10;5M', .8), ('Ctrl+X', b'\x18', .5), ('Ctrl+S', b'\x13', .5),
('Ctrl+C', b'\x03', .5), ('end z', b'z', 1.0)]
def run(host, mode):
if host == 'system':
create, close = k32.CreatePseudoConsole, k32.ClosePseudoConsole
else:
dll = ctypes.WinDLL(VSC)
create, close = dll.CreatePseudoConsole, dll.ClosePseudoConsole
create.argtypes = [COORD, w.HANDLE, w.HANDLE, w.DWORD, ctypes.POINTER(w.HANDLE)]
create.restype = ctypes.c_long
close.argtypes = [w.HANDLE]
inR, inW, outR, outW = w.HANDLE(), w.HANDLE(), w.HANDLE(), w.HANDLE()
k32.CreatePipe(ctypes.byref(inR), ctypes.byref(inW), None, 0)
k32.CreatePipe(ctypes.byref(outR), ctypes.byref(outW), None, 0)
hpc = w.HANDLE()
hr = create(COORD(120, 30), inR, outW, 0, ctypes.byref(hpc))
assert hr == 0, hex(hr & 0xffffffff)
k32.CloseHandle(inR); k32.CloseHandle(outW)
size = ctypes.c_size_t()
k32.InitializeProcThreadAttributeList(None, 1, 0, ctypes.byref(size))
attrs = ctypes.create_string_buffer(size.value)
assert k32.InitializeProcThreadAttributeList(attrs, 1, 0, ctypes.byref(size))
k32.UpdateProcThreadAttribute.argtypes = [ctypes.c_void_p, w.DWORD, ctypes.c_size_t, ctypes.c_void_p,
ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p]
assert k32.UpdateProcThreadAttribute(attrs, 0, 0x00020016, hpc.value, ctypes.sizeof(w.HANDLE), None, None)
siex = SIEX(); siex.StartupInfo.cb = ctypes.sizeof(SIEX); siex.lpAttributeList = ctypes.addressof(attrs)
pi = PI()
out_file = os.path.join(TMP, f'conpty_{host}_{mode}.jsonl')
cmd = ctypes.create_unicode_buffer(f'"{PY}" "{os.path.join(TMP, "conpty_child.py")}" "{out_file}" {mode}')
assert k32.CreateProcessW(None, cmd, None, None, False, 0x80000, None, None,
ctypes.byref(siex.StartupInfo), ctypes.byref(pi)), ctypes.get_last_error()
screen = bytearray()
def drain():
b = ctypes.create_string_buffer(4096); n = w.DWORD()
while k32.ReadFile(outR, b, 4096, ctypes.byref(n), None) and n.value:
screen.extend(b.raw[:n.value])
threading.Thread(target=drain, daemon=True).start()
t0 = time.time()
while b'READY' not in screen and time.time() - t0 < 8:
time.sleep(.1)
n = w.DWORD()
for _, data, pause in INPUTS:
k32.WriteFile(inW, data, len(data), ctypes.byref(n), None)
time.sleep(pause)
k32.WaitForSingleObject(pi.hProcess, 4000)
k32.TerminateProcess(pi.hProcess, 1)
close(hpc)
got = [json.loads(l) for l in open(out_file, encoding='utf-8')] if os.path.exists(out_file) else []
return {'ready': b'READY' in screen, 'mouse_mode_forwarded': b'?1000h' in screen or b'?1006h' in screen, 'got': got}
for host in ('system', 'vscode'):
for mode in ('vt', 'novt'):
r = run(host, mode)
print(f'== {host} / {mode}: ready={r["ready"]} mouse_mode_forwarded={r["mouse_mode_forwarded"]}')
for g in r['got']:
print(' ', g)
</details>
<details>
<summary>Output on Windows 10 19045 (set_in_mode lines omitted; the vscode/vt SGR bytes abridged)</summary>
== system / vt: ready=True mouse_mode_forwarded=False
{'key': 0, 'ch': 97, 'ctrl': 0}
{'key': 0, 'ch': 27, 'ctrl': 0}
{'key': 0, 'ch': 98, 'ctrl': 0}
{'key': 17, 'ch': 0, 'ctrl': 8}
{'key': 0, 'ch': 24, 'ctrl': 0}
{'key': 17, 'ch': 0, 'ctrl': 8}
{'key': 0, 'ch': 19, 'ctrl': 0}
{'key': 0, 'ch': 3, 'ctrl': 0}
{'key': 0, 'ch': 122, 'ctrl': 0}
== system / novt: ready=True mouse_mode_forwarded=False
{'key': 65, 'ch': 97, 'ctrl': 0}
{'key': 27, 'ch': 27, 'ctrl': 0}
{'key': 66, 'ch': 98, 'ctrl': 0}
{'key': 17, 'ch': 0, 'ctrl': 8}
{'key': 88, 'ch': 24, 'ctrl': 8}
{'key': 17, 'ch': 0, 'ctrl': 8}
{'key': 83, 'ch': 19, 'ctrl': 8}
{'key': 67, 'ch': 3, 'ctrl': 8}
{'key': 90, 'ch': 122, 'ctrl': 0}
== vscode / vt: ready=True mouse_mode_forwarded=True
{'key': 0, 'ch': 97, 'ctrl': 0}
{'key': 0, 'ch': 27, 'ctrl': 0}
{'key': 0, 'ch': 98, 'ctrl': 0}
{'key': 0, 'ch': 27, 'ctrl': 0} <- ESC [ < 6 5 ; 1 0 ; 5 M (wheel arrives)
... (rest of the SGR sequence)
{'key': 0, 'ch': 24, 'ctrl': 0}
{'key': 0, 'ch': 19, 'ctrl': 0}
{'key': 0, 'ch': 3, 'ctrl': 0}
{'key': 0, 'ch': 122, 'ctrl': 0}
== vscode / novt: ready=True mouse_mode_forwarded=True
{'key': 65, 'ch': 97, 'ctrl': 0}
{'key': 27, 'ch': 27, 'ctrl': 0}
{'key': 66, 'ch': 98, 'ctrl': 0}
{'mouse': True, 'buttons': '0xff800000', 'flags': 4} <- MOUSE_WHEELED
{'key': 17, 'ch': 0, 'ctrl': 8}
{'key': 88, 'ch': 24, 'ctrl': 8}
{'key': 17, 'ch': 0, 'ctrl': 8}
{'key': 83, 'ch': 19, 'ctrl': 8}
{'key': 67, 'ch': 3, 'ctrl': 8}
{'key': 90, 'ch': 122, 'ctrl': 0}
</details>