diff --git a/runtime/test_helpers.py b/runtime/test_helpers.py index 7585082f..edbb1271 100644 --- a/runtime/test_helpers.py +++ b/runtime/test_helpers.py @@ -246,6 +246,28 @@ class TestMutatingCommandsAreGuarded(unittest.TestCase): self.assertNotIn("key", coords) self.assertNotIn("type", coords) + def test_pointer_motion_uses_the_animation_gate(self): + """Coordinate actions must not jump while mouse animation is enabled.""" + source = _win_source() + self.assertIn("def _move_cursor_to", source) + self.assertIn("1 - (1 - progress) ** 3", source) + dispatcher = source.index("def main()") + for command in ("click", "drag", "move_mouse", "scroll"): + marker = f'if command == "{command}":' + start = source.index(marker, dispatcher) + body = source[start:start + 1400] + self.assertIn( + 'payload.get("animate", True)', + body, + f"{command} must honor the mouse-animation gate", + ) + + def test_helper_uses_per_monitor_dpi_coordinates(self): + source = _win_source() + self.assertIn("DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2", source) + self.assertIn("SetProcessDpiAwarenessContext", source) + self.assertIn("GetDpiForMonitor", source) + def test_every_mutating_branch_finalizes_the_lease(self): """No mutating branch may answer with a bare json_output. @@ -438,7 +460,7 @@ class TestDeliveryGuards(unittest.TestCase): class TestCursorBadge(unittest.TestCase): - """The Windows badge annotates the real cursor; it does not replace it.""" + """Windows renders the same virtual-cursor language as macOS.""" def test_badge_script_exists(self): self.assertTrue(CURSOR_BADGE.exists()) @@ -453,16 +475,17 @@ class TestCursorBadge(unittest.TestCase): source = CURSOR_BADGE.read_text(encoding="utf-8") tree = ast.parse(source) - create = next( + methods = [ node for node in ast.walk(tree) - if isinstance(node, ast.FunctionDef) and node.name == "create" - ) + if isinstance(node, ast.FunctionDef) + and node.name in {"create", "_tick"} + ] # Read the names actually combined into the window's ex-style, not # merely the ones defined somewhere in the file. A constant can be # defined and then left out of CreateWindowExW — which is exactly how # a click-through window quietly becomes a click-eating one. used = { - n.id for n in ast.walk(create) + n.id for method in methods for n in ast.walk(method) if isinstance(n, ast.Name) } for style in ("WS_EX_LAYERED", "WS_EX_TRANSPARENT", @@ -473,31 +496,91 @@ class TestCursorBadge(unittest.TestCase): ) self.assertIn("SW_SHOWNOACTIVATE", used) - def test_badge_does_not_draw_a_second_pointer(self): - """Windows has one real cursor and SendInput moves it. - - Drawing a fake pointer alongside it would show the user two cursors, - one of which is a lie about where the click will land. The macOS design - does not transfer, and the source says so explicitly. - """ + def test_overlay_draws_a_pointer_instead_of_a_text_banner(self): + """The old 168x30 label was the black bar reported by Windows users.""" source = CURSOR_BADGE.read_text(encoding="utf-8") - self.assertIn("annotation", source.lower()) + self.assertNotIn("DrawTextW", source) + self.assertIn("ARROW_POINTS", source) + self.assertIn("#0075f2", source.lower()) + self.assertIn("GaussianBlur", source) + self.assertIn("UpdateLayeredWindow", source) - def test_badge_fits_the_default_label(self): - tree = ast.parse(CURSOR_BADGE.read_text(encoding="utf-8")) - width_assignment = next( - node for node in tree.body - if isinstance(node, ast.Assign) - and any(isinstance(target, ast.Name) and target.id == "BADGE_W" - for target in node.targets) + def test_overlay_uses_the_agent_hotspot_not_a_cursor_offset(self): + source = CURSOR_BADGE.read_text(encoding="utf-8") + self.assertIn("HOTSPOT", source) + self.assertNotIn("CURSOR_OFFSET_X", source) + self.assertNotIn("CURSOR_OFFSET_Y", source) + + def test_overlay_is_per_monitor_dpi_aware(self): + """GetCursorPos and window placement must share physical coordinates.""" + source = CURSOR_BADGE.read_text(encoding="utf-8") + self.assertIn("DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2", source) + self.assertIn("SetProcessDpiAwarenessContext", source) + self.assertIn("GetDpiForMonitor", source) + self.assertIn("GetDpiForWindow", source) + + @unittest.skipUnless(IS_WINDOWS, "requires Pillow and the Windows module") + def test_overlay_renders_a_transparent_pointer_frame(self): + spec = importlib.util.spec_from_file_location("win_cursor_badge", CURSOR_BADGE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + frame = module._render_cursor(1.0, 0.0, None) + self.assertEqual(frame.size, (module.CANVAS_SIZE, module.CANVAS_SIZE)) + self.assertEqual(frame.getpixel((0, 0))[3], 0) + hotspot_x, hotspot_y = (round(value) for value in module.HOTSPOT) + hotspot_alpha = max( + frame.getpixel((hotspot_x + dx, hotspot_y + dy))[3] + for dx in range(-1, 2) + for dy in range(-1, 2) ) - self.assertIsInstance(width_assignment.value, ast.Constant) - self.assertGreaterEqual( - width_assignment.value.value, - 160, - 'the default "Claude is controlling" label must not be clipped', + self.assertGreater(hotspot_alpha, 200) + self.assertLess( + sum(pixel[3] > 8 for pixel in frame.getdata()), + module.CANVAS_SIZE * module.CANVAS_SIZE // 2, ) + @unittest.skipUnless(IS_WINDOWS, "requires Pillow and the Windows module") + def test_overlay_converts_frames_to_premultiplied_bgra(self): + spec = importlib.util.spec_from_file_location("win_cursor_badge", CURSOR_BADGE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + image = module.Image.new("RGBA", (1, 1), (100, 150, 200, 128)) + self.assertEqual( + module._premultiplied_bgra(image), + bytes((100, 75, 50, 128)), + ) + + def test_overlay_tracks_agent_input_and_not_the_users_pointer(self): + """A virtual cursor stays at the agent's last point when the user moves.""" + source = CURSOR_BADGE.read_text(encoding="utf-8") + self.assertIn("WH_MOUSE_LL", source) + self.assertIn("LLMHF_INJECTED", source) + self.assertIn("_agent_position", source) + self.assertNotIn("def _follow_cursor", source) + + def test_overlay_and_helper_share_a_session_input_tag(self): + overlay = CURSOR_BADGE.read_text(encoding="utf-8") + helper = _win_source() + env_name = "CC_HAHA_COMPUTER_USE_INPUT_TAG" + self.assertIn(env_name, overlay) + self.assertIn(env_name, helper) + self.assertIn("dwExtraInfo", overlay) + + def test_overlay_visibility_is_bound_to_the_controlled_window(self): + """Never strand the AI pointer over an unrelated covering app.""" + source = CURSOR_BADGE.read_text(encoding="utf-8") + self.assertIn("targetPid", source) + self.assertIn("WindowFromPoint", source) + self.assertIn("GetForegroundWindow", source) + + def test_overlay_readiness_precedes_the_first_action(self): + source = CURSOR_BADGE.read_text(encoding="utf-8") + self.assertIn('print("READY", flush=True)', source) + def test_badge_exits_with_its_parent(self): """An orphaned badge is worse than none. @@ -519,8 +602,7 @@ class TestCursorBadge(unittest.TestCase): self.assertIs(module.user32.CreateWindowExW.argtypes[3], module.wintypes.DWORD) self.assertIs(module.user32.DefWindowProcW.restype, module.LRESULT) for function in ( - module.user32.DrawTextW, - module.user32.SetLayeredWindowAttributes, + module.user32.UpdateLayeredWindow, module.user32.SetWindowPos, ): self.assertIsNotNone(function.argtypes) @@ -547,6 +629,8 @@ class TestCursorBadge(unittest.TestCase): if process.poll() is not None: assert process.stderr is not None self.fail(f"badge exited during startup: {process.stderr.read()}") + assert process.stdout is not None + self.assertEqual(process.stdout.readline().strip(), "READY") assert process.stdin is not None process.stdin.close() returncode = process.wait(timeout=5) @@ -556,6 +640,10 @@ class TestCursorBadge(unittest.TestCase): if process.poll() is None: process.kill() process.wait(timeout=5) + if process.stdout is not None: + process.stdout.close() + if process.stderr is not None: + process.stderr.close() self.assertEqual(returncode, 0, stderr) self.assertNotIn("Exception ignored on calling ctypes callback", stderr) diff --git a/runtime/win_cursor_badge.py b/runtime/win_cursor_badge.py index 69a37bf5..d68d1a08 100644 --- a/runtime/win_cursor_badge.py +++ b/runtime/win_cursor_badge.py @@ -1,43 +1,38 @@ #!/usr/bin/env python3 -"""Windows agent-activity badge — a click-through marker that follows the cursor. +"""Windows virtual cursor overlay. -WHY THIS IS NOT THE macOS VIRTUAL CURSOR ----------------------------------------- -On macOS the helper never moves the real pointer: `CGEvent.postToPid` carries -the click coordinate as metadata, so the drawn cursor IS the only cursor the -user sees move. It is a *replacement*. +The first Windows implementation drew a 168x30 text banner beside the system +cursor. Apart from looking unlike the macOS feature, +that window followed the user's pointer as well as Computer Use and was not +per-monitor-DPI aware, so it could float over the wrong application and at the +wrong coordinates. -Windows has no per-process event delivery. `pyautogui` bottoms out in -`SendInput`, which warps the one real cursor the user's hand is also on. We -cannot avoid that, so drawing a second fake pointer would be actively harmful: -two pointers, one of them a lie, with no way to tell which one the OS is -actually going to click with. +This process renders the same visual language as the native macOS helper: a +blue pointer with a white keyline, breathing halo, idle bob, and click ripple. +It observes the low-level mouse stream and follows injected movement only. The +real Windows pointer still has to move because Windows has no ``postToPid`` +equivalent, but the virtual cursor is co-located with that pointer during agent +movement and stays at the agent's last point if the user takes the mouse. -So this badge is an *annotation*, not a replacement. It rides just off the real -cursor and answers exactly one question the user cannot otherwise answer: -"is this thing moving because of me, or because of the agent?" On Windows that -question has real stakes — the agent is holding the user's mouse, and the user -needs to know before they grab it back mid-action. - -Runs as its own process because the Windows helper is a stateless one-shot CLI: -every command exits, so nothing in it can own a window across actions. - -The window is WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_NOACTIVATE — it never -takes focus, never appears in the taskbar or Alt-Tab, and passes every click -through to whatever is underneath. It cannot intercept the input it exists to -describe. - -Usage: - python win_cursor_badge.py --label "Claude" # runs until stdin closes +The parent sends one JSON line for each action and closes stdin at turn end. +The window is per-pixel-alpha, topmost, click-through, absent from Alt-Tab, and +never activates. Visual failures remain advisory and never block input. """ from __future__ import annotations import argparse import ctypes +import json +import math +import os import sys import threading +import time from ctypes import wintypes +from PIL import Image, ImageChops, ImageDraw, ImageFilter + + user32 = ctypes.windll.user32 gdi32 = ctypes.windll.gdi32 kernel32 = ctypes.windll.kernel32 @@ -49,35 +44,75 @@ WS_EX_TOOLWINDOW = 0x00000080 WS_EX_NOACTIVATE = 0x08000000 WS_POPUP = 0x80000000 +SW_HIDE = 0 SW_SHOWNOACTIVATE = 4 HWND_TOPMOST = wintypes.HWND(-1) SWP_NOACTIVATE = 0x0010 -SWP_NOSIZE = 0x0001 -SWP_NOZORDER = 0x0004 - -LWA_COLORKEY = 0x00000001 -LWA_ALPHA = 0x00000002 WM_DESTROY = 0x0002 WM_CLOSE = 0x0010 -WM_PAINT = 0x000F +WM_NCHITTEST = 0x0084 +WM_TIMER = 0x0113 +WM_MOUSEMOVE = 0x0200 +WM_LBUTTONDOWN = 0x0201 +WM_LBUTTONUP = 0x0202 +WM_RBUTTONDOWN = 0x0204 +WM_RBUTTONUP = 0x0205 +WM_MBUTTONDOWN = 0x0207 +WM_MBUTTONUP = 0x0208 +WM_MOUSEWHEEL = 0x020A +WM_MOUSEHWHEEL = 0x020E -# Leave enough room for the default label at common Windows text scales. The -# original 132px width clipped "Claude is controlling" on a 100%-scale display. -BADGE_W = 168 -BADGE_H = 30 -CURSOR_OFFSET_X = 18 -CURSOR_OFFSET_Y = 18 +HTTRANSPARENT = -1 +WH_MOUSE_LL = 14 +HC_ACTION = 0 +LLMHF_INJECTED = 0x00000001 +LLMHF_LOWER_IL_INJECTED = 0x00000002 -# Chroma key: pixels of this exact colour become fully transparent. Picked to -# be a colour nothing in the badge draws, so only the intended shape shows. -TRANSPARENT_KEY = 0x00FF00FF +ULW_ALPHA = 0x00000002 +AC_SRC_OVER = 0x00 +AC_SRC_ALPHA = 0x01 +DIB_RGB_COLORS = 0 +BI_RGB = 0 +GW_HWNDNEXT = 2 +GWL_EXSTYLE = -20 +MONITOR_DEFAULTTONEAREST = 2 +MDT_EFFECTIVE_DPI = 0 +INPUT_TAG_ENV = "CC_HAHA_COMPUTER_USE_INPUT_TAG" + +# Windows 10 1703+. Negative pseudo-handles are the documented ABI values. +DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = ctypes.c_void_p(-4) + +# Logical-point geometry mirrors VirtualCursorStyle.swift. The overlay is +# scaled to the current monitor's DPI before it reaches UpdateLayeredWindow. +CANVAS_SIZE = 76 +HOTSPOT = (32.0, 30.0) +ARROW_HEIGHT = 22.0 +ARROW_POINTS = ( + (0.0, 0.0), + (0.0, 16.5), + (4.0, 12.7), + (6.6, 18.5), + (9.2, 17.4), + (6.6, 11.6), + (11.6, 11.2), +) +HALO_DIAMETER = ARROW_HEIGHT * 2.4 +IDLE_BOB_AMPLITUDE = 1.5 +IDLE_BOB_PERIOD = 1.8 +HALO_BREATH_PERIOD = 2.4 +TIMER_ID = 1 +TIMER_INTERVAL_MS = 16 class POINT(ctypes.Structure): _fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)] +class SIZE(ctypes.Structure): + _fields_ = [("cx", wintypes.LONG), ("cy", wintypes.LONG)] + + class RECT(ctypes.Structure): _fields_ = [ ("left", wintypes.LONG), @@ -87,14 +122,51 @@ class RECT(ctypes.Structure): ] -class PAINTSTRUCT(ctypes.Structure): +class BLENDFUNCTION(ctypes.Structure): _fields_ = [ - ("hdc", wintypes.HDC), - ("fErase", wintypes.BOOL), - ("rcPaint", RECT), - ("fRestore", wintypes.BOOL), - ("fIncUpdate", wintypes.BOOL), - ("rgbReserved", ctypes.c_byte * 32), + ("BlendOp", ctypes.c_ubyte), + ("BlendFlags", ctypes.c_ubyte), + ("SourceConstantAlpha", ctypes.c_ubyte), + ("AlphaFormat", ctypes.c_ubyte), + ] + + +class BITMAPINFOHEADER(ctypes.Structure): + _fields_ = [ + ("biSize", wintypes.DWORD), + ("biWidth", wintypes.LONG), + ("biHeight", wintypes.LONG), + ("biPlanes", wintypes.WORD), + ("biBitCount", wintypes.WORD), + ("biCompression", wintypes.DWORD), + ("biSizeImage", wintypes.DWORD), + ("biXPelsPerMeter", wintypes.LONG), + ("biYPelsPerMeter", wintypes.LONG), + ("biClrUsed", wintypes.DWORD), + ("biClrImportant", wintypes.DWORD), + ] + + +class RGBQUAD(ctypes.Structure): + _fields_ = [ + ("rgbBlue", ctypes.c_ubyte), + ("rgbGreen", ctypes.c_ubyte), + ("rgbRed", ctypes.c_ubyte), + ("rgbReserved", ctypes.c_ubyte), + ] + + +class BITMAPINFO(ctypes.Structure): + _fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", RGBQUAD * 1)] + + +class MSLLHOOKSTRUCT(ctypes.Structure): + _fields_ = [ + ("pt", POINT), + ("mouseData", wintypes.DWORD), + ("flags", wintypes.DWORD), + ("time", wintypes.DWORD), + ("dwExtraInfo", ctypes.c_size_t), ] @@ -102,6 +174,9 @@ LRESULT = ctypes.c_ssize_t WNDPROC = ctypes.WINFUNCTYPE( LRESULT, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM ) +HOOKPROC = ctypes.WINFUNCTYPE( + LRESULT, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM +) class WNDCLASS(ctypes.Structure): @@ -119,38 +194,44 @@ class WNDCLASS(ctypes.Structure): ] -def _configure_win32() -> None: - """Declare every Win32 signature that carries a pointer-sized value. +def _enable_per_monitor_dpi_awareness() -> None: + """Make pointer, window, and screenshot coordinates all physical pixels.""" + try: + setter = user32.SetProcessDpiAwarenessContext + setter.argtypes = [wintypes.HANDLE] + setter.restype = wintypes.BOOL + if setter(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2): + return + except (AttributeError, OSError): + pass + try: + shcore = ctypes.windll.shcore + shcore.SetProcessDpiAwareness.argtypes = [ctypes.c_int] + shcore.SetProcessDpiAwareness.restype = ctypes.c_long + if shcore.SetProcessDpiAwareness(2) in (0, 0x80070005): + return + except (AttributeError, OSError): + pass + try: + user32.SetProcessDPIAware() + except (AttributeError, OSError): + pass - ctypes otherwise assumes ``c_int`` arguments and return values. That is - only 32 bits on 64-bit Windows, so HWND, WPARAM, LPARAM, and LRESULT values - are truncated before the badge's window procedure can use them. - """ + +def _configure_win32() -> None: kernel32.GetModuleHandleW.argtypes = [wintypes.LPCWSTR] kernel32.GetModuleHandleW.restype = wintypes.HINSTANCE user32.RegisterClassW.argtypes = [ctypes.POINTER(WNDCLASS)] user32.RegisterClassW.restype = wintypes.WORD user32.CreateWindowExW.argtypes = [ - wintypes.DWORD, - wintypes.LPCWSTR, - wintypes.LPCWSTR, - wintypes.DWORD, - ctypes.c_int, - ctypes.c_int, - ctypes.c_int, - ctypes.c_int, - wintypes.HWND, - wintypes.HANDLE, - wintypes.HINSTANCE, - wintypes.LPVOID, + wintypes.DWORD, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD, + ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, + wintypes.HWND, wintypes.HANDLE, wintypes.HINSTANCE, wintypes.LPVOID, ] user32.CreateWindowExW.restype = wintypes.HWND user32.DefWindowProcW.argtypes = [ - wintypes.HWND, - wintypes.UINT, - wintypes.WPARAM, - wintypes.LPARAM, + wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM, ] user32.DefWindowProcW.restype = LRESULT user32.DestroyWindow.argtypes = [wintypes.HWND] @@ -158,90 +239,260 @@ def _configure_win32() -> None: user32.PostQuitMessage.argtypes = [ctypes.c_int] user32.PostQuitMessage.restype = None user32.PostMessageW.argtypes = [ - wintypes.HWND, - wintypes.UINT, - wintypes.WPARAM, - wintypes.LPARAM, + wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM, ] user32.PostMessageW.restype = wintypes.BOOL user32.GetMessageW.argtypes = [ - ctypes.POINTER(wintypes.MSG), - wintypes.HWND, - wintypes.UINT, - wintypes.UINT, + ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT, ] - user32.GetMessageW.restype = wintypes.BOOL + user32.GetMessageW.restype = ctypes.c_int user32.TranslateMessage.argtypes = [ctypes.POINTER(wintypes.MSG)] user32.TranslateMessage.restype = wintypes.BOOL user32.DispatchMessageW.argtypes = [ctypes.POINTER(wintypes.MSG)] user32.DispatchMessageW.restype = LRESULT - - user32.BeginPaint.argtypes = [wintypes.HWND, ctypes.POINTER(PAINTSTRUCT)] - user32.BeginPaint.restype = wintypes.HDC - user32.EndPaint.argtypes = [wintypes.HWND, ctypes.POINTER(PAINTSTRUCT)] - user32.EndPaint.restype = wintypes.BOOL - user32.FillRect.argtypes = [ - wintypes.HDC, - ctypes.POINTER(RECT), - wintypes.HBRUSH, - ] - user32.FillRect.restype = ctypes.c_int - user32.DrawTextW.argtypes = [ - wintypes.HDC, - wintypes.LPCWSTR, - ctypes.c_int, - ctypes.POINTER(RECT), - wintypes.UINT, - ] - user32.DrawTextW.restype = ctypes.c_int - user32.SetLayeredWindowAttributes.argtypes = [ - wintypes.HWND, - wintypes.DWORD, - wintypes.BYTE, - wintypes.DWORD, - ] - user32.SetLayeredWindowAttributes.restype = wintypes.BOOL user32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] user32.ShowWindow.restype = wintypes.BOOL - user32.GetCursorPos.argtypes = [ctypes.POINTER(POINT)] - user32.GetCursorPos.restype = wintypes.BOOL user32.SetWindowPos.argtypes = [ - wintypes.HWND, - wintypes.HWND, - ctypes.c_int, - ctypes.c_int, - ctypes.c_int, - ctypes.c_int, - wintypes.UINT, + wintypes.HWND, wintypes.HWND, ctypes.c_int, ctypes.c_int, + ctypes.c_int, ctypes.c_int, wintypes.UINT, ] user32.SetWindowPos.restype = wintypes.BOOL + user32.SetTimer.argtypes = [wintypes.HWND, ctypes.c_size_t, wintypes.UINT, wintypes.LPVOID] + user32.SetTimer.restype = ctypes.c_size_t + user32.KillTimer.argtypes = [wintypes.HWND, ctypes.c_size_t] + user32.KillTimer.restype = wintypes.BOOL - gdi32.CreateSolidBrush.argtypes = [wintypes.DWORD] - gdi32.CreateSolidBrush.restype = wintypes.HBRUSH + user32.SetWindowsHookExW.argtypes = [ + ctypes.c_int, HOOKPROC, wintypes.HINSTANCE, wintypes.DWORD, + ] + user32.SetWindowsHookExW.restype = wintypes.HANDLE + user32.CallNextHookEx.argtypes = [ + wintypes.HANDLE, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM, + ] + user32.CallNextHookEx.restype = LRESULT + user32.UnhookWindowsHookEx.argtypes = [wintypes.HANDLE] + user32.UnhookWindowsHookEx.restype = wintypes.BOOL + + user32.UpdateLayeredWindow.argtypes = [ + wintypes.HWND, wintypes.HDC, ctypes.POINTER(POINT), ctypes.POINTER(SIZE), + wintypes.HDC, ctypes.POINTER(POINT), wintypes.DWORD, + ctypes.POINTER(BLENDFUNCTION), wintypes.DWORD, + ] + user32.UpdateLayeredWindow.restype = wintypes.BOOL + user32.GetDpiForWindow.argtypes = [wintypes.HWND] + user32.GetDpiForWindow.restype = wintypes.UINT + user32.MonitorFromPoint.argtypes = [POINT, wintypes.DWORD] + user32.MonitorFromPoint.restype = wintypes.HANDLE + user32.WindowFromPoint.argtypes = [POINT] + user32.WindowFromPoint.restype = wintypes.HWND + user32.GetForegroundWindow.argtypes = [] + user32.GetForegroundWindow.restype = wintypes.HWND + user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)] + user32.GetWindowThreadProcessId.restype = wintypes.DWORD + user32.GetTopWindow.argtypes = [wintypes.HWND] + user32.GetTopWindow.restype = wintypes.HWND + user32.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT] + user32.GetWindow.restype = wintypes.HWND + user32.IsWindowVisible.argtypes = [wintypes.HWND] + user32.IsWindowVisible.restype = wintypes.BOOL + user32.IsIconic.argtypes = [wintypes.HWND] + user32.IsIconic.restype = wintypes.BOOL + user32.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(RECT)] + user32.GetWindowRect.restype = wintypes.BOOL + user32.GetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int] + user32.GetWindowLongW.restype = ctypes.c_long + + gdi32.CreateCompatibleDC.argtypes = [wintypes.HDC] + gdi32.CreateCompatibleDC.restype = wintypes.HDC + gdi32.DeleteDC.argtypes = [wintypes.HDC] + gdi32.DeleteDC.restype = wintypes.BOOL + gdi32.CreateDIBSection.argtypes = [ + wintypes.HDC, ctypes.POINTER(BITMAPINFO), wintypes.UINT, + ctypes.POINTER(ctypes.c_void_p), wintypes.HANDLE, wintypes.DWORD, + ] + gdi32.CreateDIBSection.restype = wintypes.HBITMAP + gdi32.SelectObject.argtypes = [wintypes.HDC, wintypes.HANDLE] + gdi32.SelectObject.restype = wintypes.HANDLE gdi32.DeleteObject.argtypes = [wintypes.HANDLE] gdi32.DeleteObject.restype = wintypes.BOOL - gdi32.SetBkMode.argtypes = [wintypes.HDC, ctypes.c_int] - gdi32.SetBkMode.restype = ctypes.c_int - gdi32.SetTextColor.argtypes = [wintypes.HDC, wintypes.DWORD] - gdi32.SetTextColor.restype = wintypes.DWORD + + try: + shcore = ctypes.windll.shcore + shcore.GetDpiForMonitor.argtypes = [ + wintypes.HANDLE, ctypes.c_int, + ctypes.POINTER(wintypes.UINT), ctypes.POINTER(wintypes.UINT), + ] + shcore.GetDpiForMonitor.restype = ctypes.c_long + except (AttributeError, OSError): + pass +_enable_per_monitor_dpi_awareness() _configure_win32() -class CursorBadge: - def __init__(self, label: str) -> None: - self.label = label +def _pid_for_window(hwnd: int | None) -> int | None: + if not hwnd: + return None + pid = wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + return int(pid.value) or None + + +def _input_tag() -> int | None: + try: + value = int(os.environ[INPUT_TAG_ENV]) & 0xFFFFFFFF + return value or None + except (KeyError, TypeError, ValueError): + return None + + +def _dpi_at_point(point: tuple[int, int], hwnd: int) -> int: + """Return the DPI of the destination monitor before moving the overlay.""" + try: + monitor = user32.MonitorFromPoint( + POINT(point[0], point[1]), MONITOR_DEFAULTTONEAREST, + ) + dpi_x = wintypes.UINT() + dpi_y = wintypes.UINT() + if monitor and ctypes.windll.shcore.GetDpiForMonitor( + monitor, MDT_EFFECTIVE_DPI, ctypes.byref(dpi_x), ctypes.byref(dpi_y), + ) == 0: + return int(dpi_x.value) or 96 + except (AttributeError, OSError): + pass + return int(user32.GetDpiForWindow(hwnd)) or 96 + + +def _point_from_activity(command: str, payload: dict) -> tuple[int, int] | None: + if command == "drag": + point = payload.get("to") or {} + if "x" in point and "y" in point: + return int(point["x"]), int(point["y"]) + return None + if command in {"click", "move_mouse", "scroll"}: + if "x" in payload and "y" in payload: + return int(payload["x"]), int(payload["y"]) + return None + + +def _premultiplied_bgra(image: Image.Image) -> bytes: + red, green, blue, alpha = image.split() + return Image.merge("RGBA", ( + ImageChops.multiply(blue, alpha), + ImageChops.multiply(green, alpha), + ImageChops.multiply(red, alpha), + alpha, + )).tobytes() + + +def _render_cursor(scale: float, now: float, click: tuple[float, int] | None) -> Image.Image: + """Render one anti-aliased frame using the native helper's dimensions.""" + size = max(1, round(CANVAS_SIZE * scale)) + antialias = 3 + factor = scale * antialias + image = Image.new("RGBA", (size * antialias, size * antialias), (0, 0, 0, 0)) + + breath = (math.sin((now / HALO_BREATH_PERIOD) * math.tau - math.pi / 2) + 1) / 2 + halo_scale = 0.86 + breath * 0.28 + halo_alpha = 0.5 + breath * 0.5 + arrow_scale = ARROW_HEIGHT / 19.0 + bob = math.sin((now / IDLE_BOB_PERIOD) * math.tau) * IDLE_BOB_AMPLITUDE + + tip_x = HOTSPOT[0] * factor + tip_y = HOTSPOT[1] * factor + arrow_points = [ + ( + tip_x + x * arrow_scale * factor, + tip_y + (y * arrow_scale + bob) * factor, + ) + for x, y in ARROW_POINTS + ] + + arrow_width = max(x for x, _ in ARROW_POINTS) * arrow_scale + halo_center = ( + tip_x + arrow_width * factor / 2, + tip_y + ARROW_HEIGHT * factor / 2, + ) + halo_radius = HALO_DIAMETER * halo_scale * factor / 2 + halo = Image.new("RGBA", image.size, (0, 0, 0, 0)) + halo_draw = ImageDraw.Draw(halo) + halo_draw.ellipse( + ( + halo_center[0] - halo_radius, + halo_center[1] - halo_radius, + halo_center[0] + halo_radius, + halo_center[1] + halo_radius, + ), + fill=(38, 140, 255, round(107 * halo_alpha)), + ) + halo = halo.filter(ImageFilter.GaussianBlur(max(1, round(7 * factor)))) + image = Image.alpha_composite(image, halo) + + if click is not None: + started, kind = click + progress = min(1.0, max(0.0, (now - started) / 0.38)) + if progress < 1: + ripple = ImageDraw.Draw(image) + radius = (7 + 21 * progress) * factor + alpha = round(230 * (1 - progress)) + color = (255, 149, 0, alpha) if kind == WM_RBUTTONDOWN else (77, 158, 255, alpha) + width = max(1, round(2.5 * factor)) + ripple.ellipse( + (tip_x - radius, tip_y - radius, tip_x + radius, tip_y + radius), + outline=color, + width=width, + ) + pulse = 1.0 - 0.2 * math.sin(progress * math.pi) + arrow_points = [ + (tip_x + (x - tip_x) * pulse, tip_y + (y - tip_y) * pulse) + for x, y in arrow_points + ] + + shadow = Image.new("RGBA", image.size, (0, 0, 0, 0)) + shadow_draw = ImageDraw.Draw(shadow) + shadow_draw.polygon( + [(x, y + factor) for x, y in arrow_points], + fill=(0, 0, 0, 90), + ) + shadow = shadow.filter(ImageFilter.GaussianBlur(max(1, round(3 * factor)))) + image = Image.alpha_composite(image, shadow) + + arrow = ImageDraw.Draw(image) + outline_width = max(1, round(1.5 * factor)) + arrow.polygon(arrow_points, fill="#0075f2") + arrow.line( + [*arrow_points, arrow_points[0]], + fill="white", + width=outline_width, + joint="curve", + ) + + return image.resize((size, size), Image.Resampling.LANCZOS) + + +class VirtualCursorOverlay: + def __init__(self) -> None: self.hwnd: int | None = None self._stop = threading.Event() - # Held on the instance because ctypes does not keep the trampoline - # alive on its own; letting it be collected turns the next window - # message into a crash inside the message pump. + self._lock = threading.Lock() + self._agent_position: tuple[int, int] | None = None + self._destination: tuple[int, int] | None = None + self._target_pid: int | None = None + self._requested_visible = False + self._hidden_for_user = False + self._click: tuple[float, int] | None = None + self._shown = False + self._mouse_hook: int | None = None + self._input_tag = _input_tag() self._wndproc = WNDPROC(self._on_message) + self._hookproc = HOOKPROC(self._on_mouse_hook) def _on_message(self, hwnd, msg, wparam, lparam): - if msg == WM_PAINT: - self._paint(hwnd) + if msg == WM_NCHITTEST: + return HTTRANSPARENT + if msg == WM_TIMER and int(wparam) == TIMER_ID: + self._tick() return 0 if msg == WM_CLOSE: user32.DestroyWindow(hwnd) @@ -251,37 +502,100 @@ class CursorBadge: return 0 return user32.DefWindowProcW(hwnd, msg, wparam, lparam) - def _paint(self, hwnd: int) -> None: - ps = PAINTSTRUCT() - hdc = user32.BeginPaint(hwnd, ctypes.byref(ps)) - try: - rect = RECT(0, 0, BADGE_W, BADGE_H) - - # Fill with the chroma key first: everything we do not draw over - # becomes transparent, which is what gives the badge its shape. - key_brush = gdi32.CreateSolidBrush(TRANSPARENT_KEY) - user32.FillRect(hdc, ctypes.byref(rect), key_brush) - gdi32.DeleteObject(key_brush) - - body = RECT(0, 0, BADGE_W, BADGE_H) - bg = gdi32.CreateSolidBrush(0x00734B23) # BGR: a muted blue - user32.FillRect(hdc, ctypes.byref(body), bg) - gdi32.DeleteObject(bg) - - gdi32.SetBkMode(hdc, 1) # TRANSPARENT - gdi32.SetTextColor(hdc, 0x00FFFFFF) - text = f" {self.label} is controlling" - user32.DrawTextW( - hdc, text, len(text), ctypes.byref(body), - 0x00000004 | 0x00000100, # DT_VCENTER | DT_SINGLELINE + def _on_mouse_hook(self, code, wparam, lparam): + if code == HC_ACTION: + info = ctypes.cast(lparam, ctypes.POINTER(MSLLHOOKSTRUCT)).contents + injected = bool(info.flags & (LLMHF_INJECTED | LLMHF_LOWER_IL_INJECTED)) + agent_injected = ( + injected + and self._input_tag is not None + and int(info.dwExtraInfo) & 0xFFFFFFFF == self._input_tag ) - finally: - user32.EndPaint(hwnd, ctypes.byref(ps)) + event = int(wparam) + with self._lock: + if agent_injected: + self._agent_position = (int(info.pt.x), int(info.pt.y)) + if self._target_pid is None: + self._target_pid = self._pid_at_point(self._agent_position) + self._requested_visible = self._target_pid is not None + self._hidden_for_user = False + if event in {WM_LBUTTONDOWN, WM_RBUTTONDOWN, WM_MBUTTONDOWN}: + self._click = (time.monotonic(), event) + elif event in { + WM_MOUSEMOVE, WM_LBUTTONDOWN, WM_LBUTTONUP, + WM_RBUTTONDOWN, WM_RBUTTONUP, WM_MBUTTONDOWN, + WM_MBUTTONUP, WM_MOUSEWHEEL, WM_MOUSEHWHEEL, + }: + # Physical input means the user has taken the shared Windows + # pointer. Keep our logical position but hide until the next + # injected event, so the overlay never follows their hand. + self._hidden_for_user = True + return user32.CallNextHookEx(self._mouse_hook, code, wparam, lparam) + + def _pid_at_point(self, point: tuple[int, int]) -> int | None: + # This is called before the first reveal for each destination, so the + # direct API resolves the controlled window rather than this overlay. + hwnd = user32.WindowFromPoint(POINT(*point)) + if hwnd and int(hwnd) != int(self.hwnd or 0): + return _pid_for_window(hwnd) + return self._top_window_pid_at(point) + + def _top_window_pid_at(self, point: tuple[int, int]) -> int | None: + hwnd = user32.GetTopWindow(None) + while hwnd: + if int(hwnd) != int(self.hwnd or 0) and user32.IsWindowVisible(hwnd) and not user32.IsIconic(hwnd): + style = int(user32.GetWindowLongW(hwnd, GWL_EXSTYLE)) + rect = RECT() + if not (style & WS_EX_TRANSPARENT) and user32.GetWindowRect(hwnd, ctypes.byref(rect)): + if rect.left <= point[0] < rect.right and rect.top <= point[1] < rect.bottom: + pid = _pid_for_window(hwnd) + if pid and pid != os.getpid(): + return pid + hwnd = user32.GetWindow(hwnd, GW_HWNDNEXT) + return None + + def _target_is_visible(self, target_pid: int, destination: tuple[int, int]) -> bool: + foreground_pid = _pid_for_window(user32.GetForegroundWindow()) + return foreground_pid == target_pid or self._top_window_pid_at(destination) == target_pid + + def _on_activity(self, message: dict) -> None: + command = str(message.get("command") or "") + payload = message.get("payload") + if not isinstance(payload, dict): + payload = {} + point = _point_from_activity(command, payload) + raw_target_pid = message.get("targetPid") + target_pid = int(raw_target_pid) if isinstance(raw_target_pid, int) and raw_target_pid > 0 else None + + with self._lock: + if point is not None: + # Hide for the target lookup. A click-through layered window can + # still be returned by WindowFromPoint on some Windows builds. + if self.hwnd and self._shown: + user32.ShowWindow(self.hwnd, SW_HIDE) + self._shown = False + self._destination = point + self._target_pid = target_pid or self._pid_at_point(point) + self._requested_visible = False # reveal on injected motion + elif target_pid is not None: + self._target_pid = target_pid + + def _read_parent(self) -> None: + try: + for line in sys.stdin: + try: + message = json.loads(line) + if isinstance(message, dict): + self._on_activity(message) + except (TypeError, ValueError): + continue + except Exception: + pass + self.stop() def create(self) -> None: hinst = kernel32.GetModuleHandleW(None) - class_name = "CcHahaAgentCursorBadge" - + class_name = "CcHahaVirtualCursorOverlay" wc = WNDCLASS() wc.lpfnWndProc = self._wndproc wc.hInstance = hinst @@ -294,48 +608,94 @@ class CursorBadge: WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, class_name, None, WS_POPUP, - 0, 0, BADGE_W, BADGE_H, + 0, 0, CANVAS_SIZE, CANVAS_SIZE, None, None, hinst, None, ) if not self.hwnd: - raise OSError("CreateWindowExW failed for the cursor badge") + raise OSError("CreateWindowExW failed for the virtual cursor") - user32.SetLayeredWindowAttributes( - self.hwnd, TRANSPARENT_KEY, 225, LWA_COLORKEY | LWA_ALPHA + self._mouse_hook = user32.SetWindowsHookExW(WH_MOUSE_LL, self._hookproc, None, 0) + if not self._mouse_hook: + raise OSError("SetWindowsHookExW failed for the virtual cursor") + if not user32.SetTimer(self.hwnd, TIMER_ID, TIMER_INTERVAL_MS, None): + raise OSError("SetTimer failed for the virtual cursor") + + def _tick(self) -> None: + if not self.hwnd: + return + with self._lock: + position = self._agent_position + destination = self._destination + target_pid = self._target_pid + visible = self._requested_visible and not self._hidden_for_user + click = self._click + + if visible and position and destination and target_pid: + visible = self._target_is_visible(target_pid, destination) + else: + visible = False + + if not visible: + if self._shown: + user32.ShowWindow(self.hwnd, SW_HIDE) + self._shown = False + return + + assert position is not None + dpi = _dpi_at_point(position, self.hwnd) + scale = max(1.0, dpi / 96.0) + image = _render_cursor(scale, time.monotonic(), click) + left = round(position[0] - HOTSPOT[0] * scale) + top = round(position[1] - HOTSPOT[1] * scale) + self._update_layered_image(image, left, top) + if not self._shown: + user32.ShowWindow(self.hwnd, SW_SHOWNOACTIVATE) + self._shown = True + + def _update_layered_image(self, image: Image.Image, left: int, top: int) -> None: + assert self.hwnd is not None + width, height = image.size + bmi = BITMAPINFO() + bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bmi.bmiHeader.biWidth = width + bmi.bmiHeader.biHeight = -height # top-down DIB, same orientation as Pillow + bmi.bmiHeader.biPlanes = 1 + bmi.bmiHeader.biBitCount = 32 + bmi.bmiHeader.biCompression = BI_RGB + + bits = ctypes.c_void_p() + memory_dc = gdi32.CreateCompatibleDC(None) + bitmap = gdi32.CreateDIBSection( + memory_dc, ctypes.byref(bmi), DIB_RGB_COLORS, + ctypes.byref(bits), None, 0, ) - user32.ShowWindow(self.hwnd, SW_SHOWNOACTIVATE) + if not memory_dc or not bitmap or not bits.value: + if bitmap: + gdi32.DeleteObject(bitmap) + if memory_dc: + gdi32.DeleteDC(memory_dc) + return - def _follow_cursor(self) -> None: - """Reposition the badge next to the real pointer, ~60fps.""" - pt = POINT() - while not self._stop.is_set(): - try: - if user32.GetCursorPos(ctypes.byref(pt)) and self.hwnd: - user32.SetWindowPos( - self.hwnd, HWND_TOPMOST, - pt.x + CURSOR_OFFSET_X, pt.y + CURSOR_OFFSET_Y, - 0, 0, SWP_NOACTIVATE | SWP_NOSIZE, - ) - except Exception: - # The badge is advisory. It must never be the reason an action - # fails, so every error here is swallowed and the loop retries. - pass - self._stop.wait(0.016) - - def _wait_for_stdin_close(self) -> None: - """Exit when the parent goes away. - - The badge outliving its parent would leave a permanent 'the agent is - controlling your mouse' claim on screen with nothing behind it. Reading - stdin to EOF ties this process's lifetime to the parent's, including - the case where the parent is killed rather than exiting cleanly. - """ + previous = gdi32.SelectObject(memory_dc, bitmap) try: - for _ in sys.stdin: - pass - except Exception: - pass - self.stop() + pixels = _premultiplied_bgra(image) + ctypes.memmove(bits, pixels, len(pixels)) + destination = POINT(left, top) + size = SIZE(width, height) + source = POINT(0, 0) + blend = BLENDFUNCTION(AC_SRC_OVER, 0, 255, AC_SRC_ALPHA) + user32.UpdateLayeredWindow( + self.hwnd, None, ctypes.byref(destination), ctypes.byref(size), + memory_dc, ctypes.byref(source), 0, ctypes.byref(blend), ULW_ALPHA, + ) + user32.SetWindowPos( + self.hwnd, HWND_TOPMOST, left, top, width, height, SWP_NOACTIVATE, + ) + finally: + if previous: + gdi32.SelectObject(memory_dc, previous) + gdi32.DeleteObject(bitmap) + gdi32.DeleteDC(memory_dc) def stop(self) -> None: self._stop.set() @@ -344,24 +704,34 @@ class CursorBadge: def run(self) -> int: self.create() - threading.Thread(target=self._follow_cursor, daemon=True).start() - threading.Thread(target=self._wait_for_stdin_close, daemon=True).start() + threading.Thread(target=self._read_parent, daemon=True).start() + # The TypeScript bridge waits for this handshake before dispatching the + # first SendInput action, so the low-level hook cannot miss its movement. + print("READY", flush=True) msg = wintypes.MSG() - while user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0: + while not self._stop.is_set() and user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0: user32.TranslateMessage(ctypes.byref(msg)) user32.DispatchMessageW(ctypes.byref(msg)) + + if self.hwnd: + user32.KillTimer(self.hwnd, TIMER_ID) + if self._mouse_hook: + user32.UnhookWindowsHookEx(self._mouse_hook) + self._mouse_hook = None return 0 def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("--label", default="Claude") - args = parser.parse_args() + # Kept for compatibility with already-running parents from the previous + # runtime; labels are intentionally no longer rendered. + parser.add_argument("--label", default=None, help=argparse.SUPPRESS) + parser.parse_args() if sys.platform != "win32": print("win_cursor_badge.py is Windows-only", file=sys.stderr) return 1 - return CursorBadge(args.label).run() + return VirtualCursorOverlay().run() if __name__ == "__main__": diff --git a/runtime/win_helper.py b/runtime/win_helper.py index 6dc51d63..15c11d1d 100644 --- a/runtime/win_helper.py +++ b/runtime/win_helper.py @@ -28,12 +28,14 @@ from __future__ import annotations import argparse import base64 +import ctypes import json import os import subprocess import sys import threading import time +from ctypes import wintypes from io import BytesIO from pathlib import Path from typing import Any @@ -41,6 +43,36 @@ from typing import Any import mss from PIL import Image + +DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = ctypes.c_void_p(-4) + + +def _enable_per_monitor_dpi_awareness() -> None: + """Keep capture, SendInput, and overlay coordinates in physical pixels.""" + try: + setter = ctypes.windll.user32.SetProcessDpiAwarenessContext + setter.argtypes = [wintypes.HANDLE] + setter.restype = wintypes.BOOL + if setter(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2): + return + except (AttributeError, OSError): + pass + try: + shcore = ctypes.windll.shcore + shcore.SetProcessDpiAwareness.argtypes = [ctypes.c_int] + shcore.SetProcessDpiAwareness.restype = ctypes.c_long + if shcore.SetProcessDpiAwareness(2) in (0, 0x80070005): + return + except (AttributeError, OSError): + pass + try: + ctypes.windll.user32.SetProcessDPIAware() + except (AttributeError, OSError): + pass + + +_enable_per_monitor_dpi_awareness() + os.environ.setdefault("PYTHONDONTWRITEBYTECODE", "1") os.environ.setdefault("PYAUTOGUI_HIDE_SUPPORT_PROMPT", "1") @@ -169,18 +201,33 @@ def get_displays() -> list[dict[str, Any]]: def _get_monitor_scale(monitor: Any) -> float: - """Get the DPI scale factor for a monitor. Returns 1.0 on failure.""" + """Get one monitor's effective DPI scale. Returns 1.0 on failure.""" try: - import ctypes - # SetProcessDPIAware so we get real pixel values - ctypes.windll.user32.SetProcessDPIAware() - # Get DPI for the primary — simplified; per-monitor DPI is complex - hdc = ctypes.windll.user32.GetDC(0) - dpi = ctypes.windll.gdi32.GetDeviceCaps(hdc, 88) # LOGPIXELSX - ctypes.windll.user32.ReleaseDC(0, hdc) - return dpi / 96.0 + user = ctypes.windll.user32 + shcore = ctypes.windll.shcore + user.MonitorFromPoint.argtypes = [wintypes.POINT, wintypes.DWORD] + user.MonitorFromPoint.restype = wintypes.HMONITOR + shcore.GetDpiForMonitor.argtypes = [ + wintypes.HMONITOR, + ctypes.c_int, + ctypes.POINTER(wintypes.UINT), + ctypes.POINTER(wintypes.UINT), + ] + shcore.GetDpiForMonitor.restype = ctypes.c_long + point = wintypes.POINT( + int(monitor.x + monitor.width / 2), + int(monitor.y + monitor.height / 2), + ) + handle = user.MonitorFromPoint(point, 2) # MONITOR_DEFAULTTONEAREST + dpi_x = wintypes.UINT(96) + dpi_y = wintypes.UINT(96) + if handle and shcore.GetDpiForMonitor( + handle, 0, ctypes.byref(dpi_x), ctypes.byref(dpi_y) + ) == 0: + return max(1.0, float(dpi_x.value) / 96.0) except Exception: - return 1.0 + pass + return 1.0 def choose_display(display_id: int | None) -> dict[str, Any]: @@ -724,10 +771,6 @@ def paste_clipboard() -> None: # actions. The hook callback does constant-time bookkeeping only; all policy # decisions stay on the command thread. -import ctypes -from ctypes import wintypes - - WH_KEYBOARD_LL = 13 WH_MOUSE_LL = 14 HC_ACTION = 0 @@ -763,7 +806,12 @@ SM_CYVIRTUALSCREEN = 79 # Mouse low-level hooks preserve only the low 32 bits of dwExtraInfo on some # 64-bit Windows builds, while keyboard hooks preserve the full ULONG_PTR. # A random non-zero 32-bit tag therefore compares identically in both paths. -_INPUT_TAG = int.from_bytes(os.urandom(4), "little") or 0x43434841 +# The TypeScript parent shares one tag with the virtual-cursor process so that +# it follows our SendInput stream, not injected input from unrelated software. +try: + _INPUT_TAG = int(os.environ["CC_HAHA_COMPUTER_USE_INPUT_TAG"]) & 0xFFFFFFFF +except (KeyError, TypeError, ValueError): + _INPUT_TAG = int.from_bytes(os.urandom(4), "little") or 0x43434841 _LRESULT = ctypes.c_ssize_t _HOOKPROC = ctypes.WINFUNCTYPE( @@ -867,6 +915,8 @@ _user32.SendInput.argtypes = [ wintypes.UINT, ctypes.POINTER(_INPUT), ctypes.c_int, ] _user32.SendInput.restype = wintypes.UINT +_user32.GetCursorPos.argtypes = [ctypes.POINTER(wintypes.POINT)] +_user32.GetCursorPos.restype = wintypes.BOOL _user32.GetSystemMetrics.argtypes = [ctypes.c_int] _user32.GetSystemMetrics.restype = ctypes.c_int _user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT] @@ -1146,6 +1196,32 @@ def _absolute_mouse_move(x: int, y: int) -> _INPUT: ) +def _move_cursor_to(x: int, y: int, animate: bool) -> None: + """Move the shared pointer along a short eased path for overlay parity.""" + current = wintypes.POINT() + if not _user32.GetCursorPos(ctypes.byref(current)): + _send_inputs([_absolute_mouse_move(x, y)]) + return + + start_x, start_y = int(current.x), int(current.y) + distance = ((x - start_x) ** 2 + (y - start_y) ** 2) ** 0.5 + if not animate or distance < 2: + _send_inputs([_absolute_mouse_move(x, y)]) + return + + # 100-260ms is long enough to read as motion without slowing the agent. + duration = min(0.26, max(0.10, distance / 4000.0)) + steps = max(6, min(18, round(duration * 60))) + for step in range(1, steps + 1): + progress = step / steps + eased = 1 - (1 - progress) ** 3 + next_x = round(start_x + (x - start_x) * eased) + next_y = round(start_y + (y - start_y) * eased) + _send_inputs([_absolute_mouse_move(next_x, next_y)]) + if step < steps: + time.sleep(duration / steps) + + _VIRTUAL_KEYS = { "win": 0x5B, "ctrl": 0x11, @@ -1542,7 +1618,14 @@ def ensure_target_window_reachable(bundle_id: str | None) -> None: # Input actions (tagged, atomic SendInput batches) # --------------------------------------------------------------------------- -def click(x: int, y: int, button: str, count: int, modifiers: list[str] | None) -> None: +def click( + x: int, + y: int, + button: str, + count: int, + modifiers: list[str] | None, + animate: bool = True, +) -> None: buttons = { "left": (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), "right": (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP), @@ -1552,7 +1635,8 @@ def click(x: int, y: int, button: str, count: int, modifiers: list[str] | None) raise ValueError(f"Unsupported mouse button: {button}") normalized = [normalize_key(m) for m in (modifiers or [])] down_flag, up_flag = buttons[button] - events = [_absolute_mouse_move(x, y)] + _move_cursor_to(x, y, animate) + events = [] events.extend(_named_key_input(key) for key in normalized) for _ in range(max(1, count)): events.append(_mouse_input(down_flag)) @@ -1563,8 +1647,15 @@ def click(x: int, y: int, button: str, count: int, modifiers: list[str] | None) _send_inputs(events) -def scroll(x: int, y: int, delta_x: int, delta_y: int) -> None: - events = [_absolute_mouse_move(x, y)] +def scroll( + x: int, + y: int, + delta_x: int, + delta_y: int, + animate: bool = True, +) -> None: + _move_cursor_to(x, y, animate) + events = [] if delta_y: events.append(_mouse_input( MOUSEEVENTF_WHEEL, data=int(delta_y) * WHEEL_DELTA @@ -1753,7 +1844,7 @@ def main() -> int: type_text(str(payload.get("text") or "")) return _finish(lease, True) if command == "click": - click(int(payload["x"]), int(payload["y"]), str(payload.get("button") or "left"), int(payload.get("count") or 1), payload.get("modifiers")) + click(int(payload["x"]), int(payload["y"]), str(payload.get("button") or "left"), int(payload.get("count") or 1), payload.get("modifiers"), bool(payload.get("animate", True))) return _finish(lease, True) if command == "drag": from_point = payload.get("from") @@ -1765,25 +1856,27 @@ def main() -> int: start_y = int(from_point["y"]) target_x = int(payload["to"]["x"]) target_y = int(payload["to"]["y"]) - events = [ - _absolute_mouse_move(start_x, start_y), - _mouse_input(MOUSEEVENTF_LEFTDOWN), - ] + animate = bool(payload.get("animate", True)) + _move_cursor_to(start_x, start_y, animate) + _send_inputs([_mouse_input(MOUSEEVENTF_LEFTDOWN)]) + duration = 0.18 if animate else 0 for step in range(1, 13): - events.append(_absolute_mouse_move( + _send_inputs([_absolute_mouse_move( round(start_x + (target_x - start_x) * step / 12), round(start_y + (target_y - start_y) * step / 12), - )) - events.append(_mouse_input(MOUSEEVENTF_LEFTUP)) - _send_inputs(events) + )]) + if duration and step < 12: + time.sleep(duration / 12) + _send_inputs([_mouse_input(MOUSEEVENTF_LEFTUP)]) return _finish(lease, True) if command == "move_mouse": - _send_inputs([_absolute_mouse_move( - int(payload["x"]), int(payload["y"]) - )]) + _move_cursor_to( + int(payload["x"]), int(payload["y"]), + bool(payload.get("animate", True)), + ) return _finish(lease, True) if command == "scroll": - scroll(int(payload["x"]), int(payload["y"]), int(payload.get("deltaX") or 0), int(payload.get("deltaY") or 0)) + scroll(int(payload["x"]), int(payload["y"]), int(payload.get("deltaX") or 0), int(payload.get("deltaY") or 0), bool(payload.get("animate", True))) return _finish(lease, True) if command == "mouse_down": _send_inputs([_mouse_input(MOUSEEVENTF_LEFTDOWN)]) diff --git a/src/utils/computerUse/cleanup.ts b/src/utils/computerUse/cleanup.ts index be4b8c4a..cb559f1f 100644 --- a/src/utils/computerUse/cleanup.ts +++ b/src/utils/computerUse/cleanup.ts @@ -23,7 +23,7 @@ const OVERLAY_HIDE_TIMEOUT_MS = 2000 /** * Turn-end cleanup for the chicago MCP surface: drop the activity indicator - * (macOS cu-helper overlay, or the Windows cursor badge), auto-unhide apps + * (macOS cu-helper overlay, or the Windows virtual cursor), auto-unhide apps * that `prepareForAction` hid, then release the file-based lock. * * Called from three sites: natural turn end (`stopHooks.ts`), abort during diff --git a/src/utils/computerUse/computerUseBridge.integration.test.ts b/src/utils/computerUse/computerUseBridge.integration.test.ts new file mode 100644 index 00000000..738a2f85 --- /dev/null +++ b/src/utils/computerUse/computerUseBridge.integration.test.ts @@ -0,0 +1,210 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess, SpawnOptions } from 'node:child_process' +import { afterEach, describe, expect, test } from 'bun:test' +import { + __cursorBadgeIsRunning, + __resetCursorBadgeState, + hideCursorBadge, + showCursorBadge, +} from './winCursorBadge.js' +import { callHelper } from './helperBridge.js' + +function childHarness() { + const stdout = new EventEmitter() + const stderr = new EventEmitter() + const writes: string[] = [] + let ended = false + let killed = false + const child = new EventEmitter() as ChildProcess + Object.assign(child, { + exitCode: null, + stdout, + stderr, + stdin: { + writable: true, + write: (value: string) => { writes.push(value) }, + end: () => { ended = true }, + }, + kill: () => { + killed = true + return true + }, + }) + Object.defineProperty(child, 'killed', { get: () => killed }) + return { child, stdout, stderr, writes, ended: () => ended } +} + +function dependencies( + spawnChild: (file: string, args: readonly string[], options: SpawnOptions) => ChildProcess, +) { + return { + platform: 'win32' as const, + bootstrap: async () => {}, + command: () => ({ python: 'python.exe', script: 'cursor.py' }), + environment: () => ({ CC_HAHA_COMPUTER_USE_INPUT_TAG: '1234' }), + spawnChild: spawnChild as typeof import('node:child_process').spawn, + } +} + +async function waitForSpawn(spawned: () => boolean): Promise { + for (let attempt = 0; attempt < 10 && !spawned(); attempt++) { + await Promise.resolve() + } + expect(spawned()).toBe(true) +} + +afterEach(() => { + hideCursorBadge() + __resetCursorBadgeState() +}) + +describe('Windows virtual cursor process lifecycle', () => { + test('waits for READY, forwards the action, and reuses the turn process', async () => { + const harness = childHarness() + const spawns: Array<{ + file: string + args: readonly string[] + options: SpawnOptions + }> = [] + const deps = dependencies((file, args, options) => { + spawns.push({ file, args, options }) + return harness.child + }) + + const first = showCursorBadge('click', { x: 40, y: 50, pid: 91 }, deps) + await waitForSpawn(() => spawns.length === 1) + expect(harness.writes).toEqual([]) + harness.stdout.emit('data', Buffer.from('READY\r\n')) + await first + + expect(spawns).toHaveLength(1) + expect(spawns[0]).toMatchObject({ + file: 'python.exe', + args: ['cursor.py'], + options: { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + detached: false, + env: { CC_HAHA_COMPUTER_USE_INPUT_TAG: '1234' }, + }, + }) + expect(JSON.parse(harness.writes[0])).toEqual({ + command: 'click', + payload: { x: 40, y: 50, pid: 91 }, + targetPid: 91, + }) + harness.stderr.emit('data', Buffer.from('diagnostic fixture')) + + await showCursorBadge('move_mouse', { x: 60, y: 70 }, deps) + expect(spawns).toHaveLength(1) + expect(JSON.parse(harness.writes[1])).toEqual({ + command: 'move_mouse', + payload: { x: 60, y: 70 }, + }) + expect(__cursorBadgeIsRunning()).toBe(true) + }) + + test('clears exited children and contains stdin write failures', async () => { + const harness = childHarness() + let spawned = false + const starting = showCursorBadge('move_mouse', { x: 3, y: 4 }, dependencies(() => { + spawned = true + return harness.child + })) + await waitForSpawn(() => spawned) + harness.stdout.emit('data', Buffer.from('READY\n')) + await starting + + const stdin = harness.child.stdin as unknown as { write: (value: string) => void } + stdin.write = () => { throw new Error('closed pipe') } + await expect(showCursorBadge( + 'move_mouse', + { x: 5, y: 6 }, + dependencies(() => { throw new Error('must reuse child') }), + )).resolves.toBeUndefined() + expect(__cursorBadgeIsRunning()).toBe(true) + + harness.child.emit('exit', 0, null) + expect(__cursorBadgeIsRunning()).toBe(false) + }) + + test('turn cleanup closes stdin and terminates the tracked child', async () => { + const harness = childHarness() + let spawned = false + const running = showCursorBadge('scroll', { x: 10, y: 20 }, dependencies(() => { + spawned = true + return harness.child + })) + await waitForSpawn(() => spawned) + harness.stdout.emit('data', Buffer.from('READY\n')) + await running + + hideCursorBadge() + + expect(harness.ended()).toBe(true) + expect(harness.child.killed).toBe(true) + expect(__cursorBadgeIsRunning()).toBe(false) + }) + + test('process errors and spawn failures fail open without dispatching input twice', async () => { + const harness = childHarness() + let spawned = false + const starting = showCursorBadge('click', { x: 1, y: 2 }, dependencies(() => { + spawned = true + return harness.child + })) + await waitForSpawn(() => spawned) + harness.child.emit('error', new Error('overlay unavailable')) + await expect(starting).resolves.toBeUndefined() + expect(harness.writes).toEqual([]) + expect(__cursorBadgeIsRunning()).toBe(false) + + await expect(showCursorBadge('click', {}, dependencies(() => { + throw new Error('spawn refused') + }))).resolves.toBeUndefined() + expect(__cursorBadgeIsRunning()).toBe(false) + }) + + test('non-Windows callers do not bootstrap or spawn the Windows overlay', async () => { + let bootstrapped = false + const deps = { + ...dependencies(() => { throw new Error('must not spawn') }), + platform: 'darwin' as const, + bootstrap: async () => { bootstrapped = true }, + } + + await showCursorBadge('click', { x: 1, y: 2 }, deps) + + expect(bootstrapped).toBe(false) + expect(__cursorBadgeIsRunning()).toBe(false) + }) +}) + +describe('Windows helper and virtual cursor join', () => { + test('makes the overlay ready before dispatching an injecting command', async () => { + const order: string[] = [] + const result = await callHelper<{ ok: boolean }>('click', { x: 8, y: 9 }, { + platform: 'win32', + showCursorBadge: async (command, payload) => { + order.push(`overlay:${command}:${payload.x},${payload.y}`) + }, + callPy: async () => { + order.push('input') + return { ok: true } + }, + }) + + expect(result).toEqual({ ok: true }) + expect(order).toEqual(['overlay:click:8,9', 'input']) + }) + + test('does not start the overlay for a read-only Windows command', async () => { + let overlayCalls = 0 + await callHelper('screenshot', {}, { + platform: 'win32', + showCursorBadge: () => { overlayCalls++ }, + callPy: async () => ({ image: 'fixture' }), + }) + expect(overlayCalls).toBe(0) + }) +}) diff --git a/src/utils/computerUse/executor.codexEngine.test.ts b/src/utils/computerUse/executor.codexEngine.test.ts index 1eb6e804..4372bad6 100644 --- a/src/utils/computerUse/executor.codexEngine.test.ts +++ b/src/utils/computerUse/executor.codexEngine.test.ts @@ -315,6 +315,29 @@ describe('CLI executor Codex engine — daemon payload alignment', () => { }) }) +describe('Windows virtual cursor motion', () => { + test('the mouse-animation gate reaches every coordinate action', async () => { + if (process.platform !== 'darwin' && process.platform !== 'win32') return + const { createCliExecutor } = await import('./executor.js') + const exec = createCliExecutor({ + getMouseAnimationEnabled: () => true, + getHideBeforeActionEnabled: () => false, + }) + + await exec.click(10, 20, 'left', 1, []) + await exec.moveMouse(30, 40) + await exec.scroll(50, 60, 0, -1) + await exec.drag({ x: 70, y: 80 }, { x: 90, y: 100 }) + + expect(calls.slice(-4)).toEqual([ + { command: 'click', payload: { x: 10, y: 20, button: 'left', count: 1, modifiers: [], animate: true } }, + { command: 'move_mouse', payload: { x: 30, y: 40, animate: true } }, + { command: 'scroll', payload: { x: 50, y: 60, deltaX: 0, deltaY: -1, animate: true } }, + { command: 'drag', payload: { from: { x: 70, y: 80 }, to: { x: 90, y: 100 }, animate: true } }, + ]) + }) +}) + describe('handleToolCall ↔ engine end-to-end (tool face → daemon payload)', () => { beforeEach(() => { calls.length = 0 diff --git a/src/utils/computerUse/executor.ts b/src/utils/computerUse/executor.ts index 834fc376..2e353ded 100644 --- a/src/utils/computerUse/executor.ts +++ b/src/utils/computerUse/executor.ts @@ -332,7 +332,7 @@ async function typeViaClipboard(text: string): Promise { } } -export function createCliExecutor(_opts: { +export function createCliExecutor(opts: { getMouseAnimationEnabled: () => boolean getHideBeforeActionEnabled: () => boolean }): ComputerExecutor { @@ -436,7 +436,14 @@ export function createCliExecutor(_opts: { writeClipboard, async click(x, y, button, count, modifiers): Promise { - await callHelper('click', { x, y, button, count, modifiers }) + await callHelper('click', { + x, + y, + button, + count, + modifiers, + animate: opts.getMouseAnimationEnabled(), + }) await sleep(MOVE_SETTLE_MS) }, @@ -453,17 +460,31 @@ export function createCliExecutor(_opts: { }, async drag(from, to): Promise { - await callHelper('drag', { from, to }) + await callHelper('drag', { + from, + to, + animate: opts.getMouseAnimationEnabled(), + }) await sleep(MOVE_SETTLE_MS) }, async moveMouse(x, y): Promise { - await callHelper('move_mouse', { x, y }) + await callHelper('move_mouse', { + x, + y, + animate: opts.getMouseAnimationEnabled(), + }) await sleep(MOVE_SETTLE_MS) }, async scroll(x, y, dx, dy): Promise { - await callHelper('scroll', { x, y, deltaX: dx, deltaY: dy }) + await callHelper('scroll', { + x, + y, + deltaX: dx, + deltaY: dy, + animate: opts.getMouseAnimationEnabled(), + }) }, async getFrontmostApp(): Promise { diff --git a/src/utils/computerUse/helperBridge.test.ts b/src/utils/computerUse/helperBridge.test.ts index b1570bb3..9de9e819 100644 --- a/src/utils/computerUse/helperBridge.test.ts +++ b/src/utils/computerUse/helperBridge.test.ts @@ -360,18 +360,24 @@ describe('callHelper platform routing', () => { expect(used).toBe('py') }) - test('Windows marks agent activity on an injecting command', async () => { - // Windows drives through SendInput, so the user's real cursor moves. The - // badge is the only thing telling them the movement is not theirs, which - // matters because grabbing the mouse mid-action is what makes the two - // input streams interleave. - let badges = 0 + test('Windows prepares the virtual cursor before dispatching an injecting command', async () => { + const events: string[] = [] + let shownCommand = '' + let shownPayload: Record = {} await callHelper('click', { x: 1, y: 2 }, { platform: 'win32', - callPy: ok, - showCursorBadge: () => { badges += 1 }, + callPy: async () => { events.push('action'); return true as never }, + showCursorBadge: async (command, payload) => { + events.push('overlay:start') + shownCommand = command + shownPayload = payload + await Promise.resolve() + events.push('overlay:ready') + }, }) - expect(badges).toBe(1) + expect(events).toEqual(['overlay:start', 'overlay:ready', 'action']) + expect(shownCommand).toBe('click') + expect(shownPayload).toEqual({ x: 1, y: 2 }) }) test('Windows leaves the badge alone for read-only commands', async () => { diff --git a/src/utils/computerUse/helperBridge.ts b/src/utils/computerUse/helperBridge.ts index 3798fc48..4dd5fcff 100644 --- a/src/utils/computerUse/helperBridge.ts +++ b/src/utils/computerUse/helperBridge.ts @@ -95,16 +95,15 @@ function overlayTargetPayload( * cursor overlay; no cursor steal). There is no * stateless CLI fallback: the helper rejects direct one-shot * screenshot, mutation, clipboard and app commands. - * - Windows → the Python helper (`win_helper.py`); the native engine is - * macOS-only. + * - Windows → the Python helper (`win_helper.py`) plus a persistent virtual + * cursor overlay; the native engine is macOS-only. * - * The two platforms do NOT offer the same guarantee, and callers should not - * assume they do. macOS delivers input per-process and never touches the real - * pointer. Windows has no such API: input goes through `SendInput`, so the - * agent shares one cursor and one input stream with the user. The Windows - * side therefore gets a badge that marks agent activity rather than a virtual - * cursor that replaces it, and `win_helper.py` refuses actions it can already - * tell will not land. + * The input guarantees still differ: macOS delivers per-process without + * touching the real pointer, while Windows must use the shared `SendInput` + * stream. The Windows overlay follows only injected pointer events, binds + * visibility to the destination window, and is co-located with the real + * pointer during agent movement so its visual behavior matches macOS without + * misreporting where a click will land. * * `deps` is injectable for unit tests only. */ @@ -118,7 +117,10 @@ export async function callHelper( callPy?: HelperFn overlayShow?: (payload: Record) => void isOverlayShown?: () => boolean - showCursorBadge?: () => void + showCursorBadge?: ( + command: string, + payload: Record, + ) => void | Promise callFrontmost?: () => Promise<{ bundleId?: string } | null> shutdownDaemon?: () => void } = {}, @@ -129,7 +131,7 @@ export async function callHelper( const viaPython = deps.callPy ?? (callPythonHelper as HelperFn) const showOverlay = deps.overlayShow ?? overlayShow const overlayIsShown = deps.isOverlayShown ?? isOverlayShown - const showBadge = deps.showCursorBadge ?? (() => showCursorBadge()) + const showBadge = deps.showCursorBadge ?? showCursorBadge const restartDaemon = deps.shutdownDaemon ?? (() => void shutdownDaemon()) if (platform === 'darwin') { @@ -168,10 +170,10 @@ export async function callHelper( } if (INJECTION_COMMANDS.has(command)) { - // Same trigger set as the macOS overlay, so both platforms mark activity - // at the same moments. Fire-and-forget: the badge is advisory and must - // never sit on the mutation hot path. - showBadge() + // Wait only for the child's bounded READY handshake. That makes the first + // injected mouse event observable instead of racing process startup; the + // overlay owns its own 1.5s fail-open timeout and never rejects an action. + await showBadge(command, payload) } return viaPython(command, payload) } diff --git a/src/utils/computerUse/pipInstall.test.ts b/src/utils/computerUse/pipInstall.test.ts index 9b6a4208..55285ae5 100644 --- a/src/utils/computerUse/pipInstall.test.ts +++ b/src/utils/computerUse/pipInstall.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from 'bun:test' import { buildPipInstallAttempts } from './pipInstall.js' -import { installRuntimeDependencies, runPipInstallWithFallback } from './pythonBridge.js' +import { + getComputerUsePythonEnv, + getCursorBadgeCommand, + installRuntimeDependencies, + runPipInstallWithFallback, +} from './pythonBridge.js' describe('buildPipInstallAttempts', () => { test('tries the configured mirror before falling back to the default index', () => { @@ -66,6 +71,24 @@ describe('pythonBridge runPipInstallWithFallback', () => { }) }) +describe('Windows virtual cursor Python process identity', () => { + test('shares one non-zero input tag with helper subprocesses on Windows', () => { + const environment = getComputerUsePythonEnv() + const command = getCursorBadgeCommand() + + if (process.platform === 'win32') { + expect(environment?.PYTHONIOENCODING).toBe('utf-8') + expect(environment?.PYTHONUTF8).toBe('1') + expect(Number(environment?.CC_HAHA_COMPUTER_USE_INPUT_TAG)).toBeGreaterThan(0) + expect(command.python.endsWith('Scripts\\python.exe')).toBe(true) + } else { + expect(environment).toBeUndefined() + expect(command.python.endsWith('bin/python3')).toBe(true) + } + expect(command.script.endsWith('win_cursor_badge.py')).toBe(true) + }) +}) + describe('installRuntimeDependencies', () => { test('upgrades pip before installing requirements', async () => { const calls: string[] = [] diff --git a/src/utils/computerUse/pythonBridge.ts b/src/utils/computerUse/pythonBridge.ts index 42ff8732..cc241115 100644 --- a/src/utils/computerUse/pythonBridge.ts +++ b/src/utils/computerUse/pythonBridge.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto' +import { createHash, randomBytes } from 'node:crypto' import { readFile, mkdir, access, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -25,6 +25,7 @@ const venvRoot = path.join(runtimeStateRoot, 'venv') const installStampPath = path.join(runtimeStateRoot, 'requirements.sha256') const isWindows = process.platform === 'win32' +const windowsInputTag = randomBytes(4).readUInt32LE(0) || 0x43434841 // Always read from ~/.claude/.runtime/ — works in both dev and bundled mode. const requirementsPath = path.join(runtimeStateRoot, 'requirements.txt') @@ -37,12 +38,13 @@ const cursorBadgePath = path.join(runtimeStateRoot, cursorBadgeFileName) let bootstrapPromise: Promise | undefined -function getPythonCommandEnv(): NodeJS.ProcessEnv | undefined { +export function getComputerUsePythonEnv(): NodeJS.ProcessEnv | undefined { if (!isWindows) return undefined return { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1', + CC_HAHA_COMPUTER_USE_INPUT_TAG: String(windowsInputTag), } } @@ -175,7 +177,7 @@ export async function callPythonHelper(command: string, payload: Record