Skip to main content
Glama
Tandem Agent Mouse

Tandem Agent Mouse

README.md
# tandem-agent-mouse

> [!WARNING]
> # ๐Ÿงช BETA โ€” v1.0.0b1
> **This is pre-release software.** Field-tested on Windows 11 26200
> (Bilibili, Word, Edge guest, four-round ZCode verification + a
> dual-round regression), but the API surface may still shift before 1.0.
> Pin your version and read the CHANGELOG before upgrading.

> **Background-first desktop automation for Windows as an MCP server: the
> agent sees and drives your apps โ€” without ever disturbing you.**
>
> Lets Claude (or any MCP client) read any window's UIA accessibility tree,
> take background screenshots, and perform clicks and typing. A mode works
> on the user's desktop in the background โ€” it never moves the real cursor
> or steals focus, and the forbidden APIs **raise on fetch**. B mode runs
> the agent's apps on a hidden second desktop that the user watches through
> a corner projection window; input is injected as window messages and the
> sandbox's whole process tree is guaranteed orphan-free by a Job Object.

**English** ยท [็ฎ€ไฝ“ไธญๆ–‡](README_zh.md)

**Version:** 1.0.0b1 **(beta)** | **Platform:** Windows 10/11 | **Tests:** 32

---

## Red lines (enforced in code, not by convention)

> Every red line below is structurally enforced by
> [guard.py](src/tandem_agent_mouse/win32/guard.py) โ€” it is not a prompt-level
> promise:
>
> - **A mode never touches the user's desktop state.** `SetForegroundWindow`,
>   `SetCursorPos`, `SendInput`, `keybd_event`, `mouse_event`,
>   `AttachThreadInput`, `SetFocus` raise `ForegroundViolation` on fetch in
>   the parent process โ€” not a runtime warning, a missing function pointer.
> - **The input desktop is NEVER switched โ€” at any time.** `SwitchDesktop`
>   is blocked by the same guard. Making the sandbox the input desktop would
>   let a "real cursor" work there, but it locks the user out of their own
>   machine while switched. Sandbox "human-like" input is instead paced
>   message injection plus a **virtual cursor** drawn inside the projection
>   window โ€” pixels in a 480 px corner window, never the user's pointer.
> - **Total background failure returns `needs_foreground`.** The user
>   decides whether to switch to B mode; the server never switches on its
>   own.
> - **B mode input is window-message injection only** (PostMessage into the
>   sandbox's windows). Measured on Win11 26200: SendInput always injects
>   into the INPUT desktop (Default while the user works) โ€” from a sandbox
>   thread it fails with ACCESS_DENIED, and from the default desktop it
>   would move the user's real cursor. Message injection makes "never
>   touches the user's cursor" structurally true.
> - **Zero orphaned sandbox processes.** Every sandbox process starts
>   CREATE_SUSPENDED, joins a `KILL_ON_JOB_CLOSE` Job Object, then resumes.
>   Closing the projection window, `set_mode A`, graceful server exit, a
>   hard server kill โ€” all four paths terminate the whole tree
>   (kernel-enforced).

---

## Why tandem-agent-mouse?

Existing desktop-automation approaches force a choice: pure vision
(screenshot + coordinate clicks โ€” slow and brittle) or foreground takeover
(steals the mouse and focus โ€” the user must stop working). This repository
provides a third path:

**The recognition layer is UIA, not pixels.** One unified element format
(shared by the UIA and visual paths):

```json
{
  "id": 17, "type": "button", "name": "OK", "value": null,
  "state": ["enabled", "offscreen"],
  "rect": [120, 80, 64, 24],
  "actions": ["invoke", "click", "toggle"],
  "depth": 3, "source": "uia"
}
```

Priority chain: **UIA CacheRequest full-tree scan โ†’ (empty tree) wake
probe + retry โ†’ (still failing) PrintWindow(PW_RENDERFULLCONTENT) โ†’
(blank) Windows Graphics Capture โ†’ honest errors**. Screenshots return MCP
ImageContent plus grid metadata; the client reads the image, targets a
point, and sends it back through `act`'s `click_at`.

**A/B modes, one tool surface.** A mode is the default: background UIA
reads, background captures, batch actions that return only the diff. B
mode is opt-in: hidden sandbox desktop + corner projection โ€” the user and
the agent each operate their own world, neither fighting for the mouse,
both able to watch (and nudge) the other.

---

## How it works

```mermaid
flowchart LR
    subgraph CLIENT["MCP client (Claude, ...)"]
        T["list_windows / observe / act /<br/>screenshot / set_mode /<br/>sandbox_*"]
    end
    T -->|stdio JSON-RPC<br/>zero SDK| SRV["server.py"]

    subgraph GUARD["win32/guard.py โ€” red-line guard"]
        G["SetForegroundWindow / SendInput /<br/>SetCursorPos / keybd_event<br/>raise on fetch"]
    end

    SRV -->|"A mode"| UIA["uia/ persistent STA thread<br/>CacheRequest single-pass scan<br/>RuntimeId-stable identity"]
    UIA --> ACT["core/executor batch run<br/>invoke/toggle/set_value<br/>+ diff"]
    ACT -->|honest failure| NF["needs_foreground"]

    SRV -->|"B mode"| SB["sandbox/"]
    SB --> D["desktop.py<br/>CreateDesktopW hidden desktop<br/>+ Job Object membership"]
    SB --> IN["input_thread.py<br/>PostMessage injection<br/>(no cursor touched)"]
    SB --> PJ["projection.py<br/>topmost corner window<br/>frame pump 0.5s/frame"]
    PJ -->|"user clicks projection"| IN2["mapped back to sandbox<br/>coords, forwarded"]
    PJ -->|"user clicks X"| STOP["stop()<br/>TerminateJobObject<br/>whole tree dies"]
```

Three things the diagram makes explicit:

- **The guard is the bottom layer**: any code reaching for a forbidden API
  passes through guard.py first.
- **A-mode failures are honest**: a UIA pattern that will not resolve, a
  message click that Chromium refuses โ€” reported as `needs_foreground`,
  never silently degraded into foreground theft.
- **B-mode shutdown is kernel-guaranteed**: the moment the projection
  window closes, the Job Object closes with it and every sandbox process โ€”
  including the real process behind an alias stub โ€” dies.

---

## Installation

```bash
cd "Tandem Agent Mouse"
uv pip install --system -e .
# Base (PrintWindow captures); add WGC for the Electron blank-frame fallback:
uv pip install --system winsdk
# Tests
python -m pytest tests/ -q
# stdio MCP server
python -m tandem_agent_mouse serve
```

### Registering in Claude Code / Claude Desktop

```json
{
  "mcpServers": {
    "tandem-agent-mouse": {
      "command": "python",
      "args": ["-m", "tandem_agent_mouse", "serve"]
    }
  }
}
```

Other MCP clients (ZCode and friends) take the same configuration.

---

## Quick start

Talk to the client in natural language. Typical flows:

**A mode (background, undisturbed):**

```text
"Write something in Notepad"  -> observe finds the edit area -> act set_value
"Show me this window"         -> screenshot returns image + grid + click_at usage
```

**B mode (sandbox + projection, you watch it work):**

```text
sandbox_mode "sandbox" -> the "Tandem Agent Mouse Sandbox" corner window appears
sandbox_launch Edge    -> Edge opens on the hidden desktop, visible in the projection
sandbox_click/type     -> message-injected search, navigation, playback
sandbox_human          -> paced, human-like input; virtual cursor drawn in the window
close the projection   -> the sandbox tears down, zero processes left
```

Live-verified example: Edge on the sandbox -> bilibili -> search
"Luo Xiaohei" -> open the Luo Xiaohei Zhanji playback page โ€” the user's
cursor never moved and no focus was stolen at any point.

---

## MCP tools

Two parallel tool families โ€” no A/B letters to remember:

- **`background_*`** โ€” work on the USER's desktop. Never touches the cursor
  or focus; forbidden APIs raise on fetch.
- **`sandbox_*`** โ€” work in the hidden sandbox desktop. The projection
  window shows it; all input is message-injected.

| Tool | Family | Description |
| --- | --- | --- |
| `background_windows` | background | Enumerate visible top-level windows (hwnd/pid/title/class/minimized) |
| `background_observe` | background | Structured element table for a window (UIA) |
| `background_act` | background | Batch action steps + incremental diff (invoke/click/toggle/set_value/click_at...) |
| `background_screenshot` | background | Background capture (PrintWindowโ†’WGC); image + grid |
| `sandbox_mode` | sandbox | Start (`mode: "sandbox"`) or stop (`mode: "background"`) the sandbox + projection โ€” user decision |
| `sandbox_launch` | sandbox | Launch a program on the hidden desktop (Chromium auto-wrapped: `cmd.exe /c` + `--force-renderer-accessibility`) |
| `sandbox_observe` | sandbox | UIA element table of a sandbox window |
| `sandbox_click` / `sandbox_type` | sandbox | Message-injected click / typing (no cursor, no switch) |
| `sandbox_scroll` | sandbox | Message-injected wheel scroll at a sandbox point (up/down, 1-10 notches) |
| `sandbox_wait` | sandbox | Poll the UIA tree until a condition holds (`text_exists`/`text_gone`) โ€” the semantic cure for timing races; honest timeout, not an error |
| `sandbox_human` | sandbox | **Human-like pacing**: the same message injection with human-speed paths โ€” every step is drawn as a virtual cursor (trail + click ripples) inside the projection window; no desktop switch, no real cursor, IME bypassed |
| `sandbox_screenshot` | sandbox | Capture the sandbox's current window; image + grid |
| `bridge_status` / `bridge_call` | legacy | windows-mcp bridge state / deprecated proxy call |

Legacy names (`list_windows`, `observe`, `act`, `screenshot`, `set_mode`)
remain as routing aliases for old clients but are no longer advertised.

`act` executes N steps per round trip and returns only the
added/removed/changed elements; the settle interval between steps is
configurable (default 350 ms).

---

## B mode v2: sandbox + corner projection

1. **Hidden sandbox desktop**: `CreateDesktopW` creates a second desktop;
   the agent's programs (e.g. Edge) open there via `STARTUPINFO.lpDesktop`,
   invisible to the user's desktop.
2. **Corner projection window**: a topmost bottom-right window (title
   "Tandem Agent Mouse Sandbox โ€” ๅ…ณ้—ญ=็ป“ๆŸๆฒ™็ฎฑ") fed by a frame pump thread
   that PrintWindows the sandbox's main window and blits it; user clicks
   inside the projection map back to real sandbox coordinates and are
   forwarded. A **virtual-cursor overlay** draws the agent's input steps
   (trail, cursor ring, click ripples) on top of the mirror, plus a status
   bar. **Closing the projection is the user's explicit decision to end the
   sandbox**; the pump detects the close and tears everything down.
3. **Message-injected input**: `sandbox_click/type` PostMessage
   WM_MOUSEMOVE/LBUTTONDOWN/CHAR directly to sandbox windows. No cursor
   moves, no focus changes โ€” the user keeps working while the agent works
   "next door".

### Lifecycle (no orphaned sandbox processes, by construction)

- **Job Object membership**: `sandbox_launch` starts the process
  CREATE_SUSPENDED, assigns it to a `KILL_ON_JOB_CLOSE` job, then resumes
  it โ€” the process cannot spawn children before it joins the job, so the
  whole tree is inside the net.
- **Four shutdown paths, all killing every sandbox process**:
  1. user closes the projection window โ†’ pump detects โ†’ `stop()` โ†’
     `TerminateJobObject`;
  2. the agent calls `set_mode A` โ†’ `stop()`;
  3. graceful server exit (stdio EOF) โ†’ `atexit.register(_sandbox.stop)`;
  4. hard server kill (TerminateProcess/crash) โ†’ atexit never runs, but
     the dying process closes the job handle and
     `KILL_ON_JOB_CLOSE` fires in the kernel.
- Verified on a live machine: after `stop()`, after projection close, and
  after `taskkill /F` on the server โ€” zero sandbox processes remain.

### Chelate Shell integration

This repository also ships as the `tandem-agent-mouse` skill of
[Chelate Shell](../Chelate_Shell/chelate-shell-v1.3.0/README.md)
(stage=`infrastructure`): `skills/tandem-agent-mouse/run.py` is a thin
adapter with zero copied logic โ€” everything routes into this package's
modules; missing `comtypes`/`pillow` degrade to an `UNVERIFIED` gate
instead of crashing the pipeline.

---

## Field-tested pitfalls (all verified on real machines; do not re-learn them)

### Recognition layer (Windows 11 + Python 3.12)

1. **CacheRequest.TreeScope must include the element itself** (`7` =
   element+children+descendants). Passing `4` (subtree only) fails with
   `E_INVALIDARG`.
2. **Pattern availability flags** have no `CachedIs*PatternAvailable`
   property; read `GetCachedPropertyValue(30031..30044)`.
3. **Do not put Value(30012) in the CacheRequest**: Chromium pane elements
   reject the property, truncating the whole cached walk to one level.
   Read Value per element with `GetCachedPropertyValue`.
4. **All UIA COM calls run on one persistent STA thread** (com_worker).
   Per-call threads + CoInitialize/CoUninitialize make Chromium providers
   return NULL pattern pointers on the second call.
5. **IsEnabled=30010, BoundingRectangle=30001** โ€” 30006 is AcceleratorKey;
   a wrong property ID only explodes when that element is read.
6. Chromium's accessibility tree is lazily activated: wake it with
   `ElementFromHandle` (sends WM_GETOBJECT), wait ~0.5 s, rescan; a
   minimized/restored app may sleep again.
7. Chromium background clicks: the browser chrome (title-bar buttons)
   accepts `WM_LBUTTON*`; web content does not (the renderer process owns
   input) โ€” web content goes through UIA Invoke, and failure reports
   `needs_foreground` honestly.
8. `PrintWindow(PW_RENDERFULLCONTENT)` captures most Electron/Chromium
   windows; the occasional all-blank result falls through to WGC
   (optional `winsdk` dependency).
9. Cross-round element identity uses the UIA RuntimeId (stable across
   scans on Chromium in practice).

### Sandbox (Windows 11 26200)

1. **`CreateDesktopW` lives in user32**: old documentation says kernel32;
   on Win11 it is user32.
2. **`SetThreadDesktop` must run BEFORE `CoInitialize`**: COM STA pins the
   thread's desktop; switching afterwards fails with ERROR_BUSY(170).
3. **`SendInput` cannot enter the sandbox** (key architectural finding):
   it always injects into the INPUT desktop. From a sandbox-desktop thread
   it fails with ACCESS_DENIED(5); from the default desktop it moves the
   user's real cursor โ€” exactly the red-line behavior. Sandbox input must
   be window messages.
4. **Cross-desktop GDI queries need thread-desktop affinity**:
   `GetWindowRect` against a sandbox window from a default-desktop thread
   returns 0x0.
5. **PAINTSTRUCT is 72 bytes** (x64): handing `BeginPaint` a raw 64-byte
   buffer overwrites 8 heap bytes per WM_PAINT; the symptom is a
   delayed, random access violation (often inside gc or an unrelated
   import). When calling Win32 through ctypes, declare restype/argtypes
   for EVERY function returning a handle or taking a struct pointer โ€”
   otherwise 64-bit handles truncate to 32-bit `c_int` and
   RegisterClass/DefWindowProc crash on garbage.
6. **Edge needs its own profile**: msedge without `--user-data-dir` is
   redirected by the single-instance mechanism to the user's existing
   desktop instance โ€” the launch "succeeds" (a pid comes back) but the
   URL lands in the USER's browser and the sandbox stays empty (45 s of
   confused polling, verified live). The dedicated profile forces a new
   instance that stays in the sandbox.
7. **The `launch()` pid may not be the "real" process**: Win11
   notepad/msedge go through app-execution-alias stubs; the CreateProcess
   pid exits immediately and the real process is a different pid. Manage
   sandbox processes through the Job Object only.
8. **Chromium launchers die on a hidden desktop**: the msedge stub hands
   off to a new process and the handoff chain breaks โ€” the window never
   appears. Wrapping in `cmd.exe /c "..."` keeps a live job member for the
   whole browser lifetime. `sandbox_launch` does this automatically for
   Chromium/Electron commands.
9. **Sandbox Chromium's accessibility tree never activates by itself**:
   nothing ever "reads" it for assistive tech, so UIA scans return an
   empty tree / COMError. `sandbox_launch` appends
   `--force-renderer-accessibility` automatically; even then the first
   scan may return few elements โ€” scan, wait ~3 s, rescan (the wake
   probe's second pass gets the full tree: verified 40 โ†’ 166 elements).
10. **SwitchDesktop is FORBIDDEN, not "guarded"**: making the sandbox the
    INPUT desktop would let SendInput work there โ€” and lock the user out
    of their own machine while switched (plus a sandbox-affinity thread
    cannot re-open "Default", err 5). After a live incident the red line
    became absolute: the input desktop is never switched, at any time.
    `SwitchDesktop` now raises `ForegroundViolation` on fetch, and the
    "human-like" layer expresses people-shaped interaction as paced
    messages + a virtual cursor drawn in the projection window.
11. **The IME never interferes** (message mode): WM_CHAR bypasses the IME
    pipeline entirely โ€” no candidate window, no eaten Enter. What used to
    be a hazard of real-keyboard typing in the sandbox is now a structural
    non-issue, because real-keyboard typing in the sandbox does not exist.
12. **UIA rects can exceed the sandbox screen**: element rects come from
    the page layout, not the visible viewport โ€” a link at y=857 on an
    800-high sandbox screen is real but off-screen, and clicking it fails
    with "no sandbox window at (x, y)". Only click rects fully inside the
    sandbox screen; scroll first, or use `sandbox_observe`'s `offscreen`
    state to filter.
13. **Popups are part of the terrain**: a fresh Edge profile triggers the
    crash-restore bubble ("่ฟ˜ๅŽŸ้กต้ข"), sign-in/sync modals, and the
    "restore pages?" prompt โ€” each eats the first click and shrinks the
    UIA tree to a handful of elements. `sandbox_launch` appends
    `--hide-crash-restore-bubble --no-first-run --no-default-browser-check`
    automatically, and Edge additionally gets `--guest --disable-sync`:
    the SSO sync modal ("ๅŒๆญฅไฝ ็š„ๆต่งˆๆ•ฐๆฎ") fires even with a dedicated
    profile and non-first-run flags, because Edge auto-signs-in with the
    Windows account โ€” guest mode is the only flag that stops it
    (verified live; the dialog even outranked the main window in
    enumeration order once). Whatever still pops up: observe โ†’ dismiss
    via `sandbox_human` โ†’ observe again (never trust the first scan).
14. **Plugin install does not register the MCP server** (pit: "installed
    but unknown"): installing the plugin brings the agent + skill, but the
    MCP server needs wiring โ€” this repo now ships `.mcp.json` so Claude
    Code picks up `tandem-agent-mouse serve` at plugin load. If a session
    still reports the server as "Unknown", MCP registration does not hot
    reload: restart the session, or drive the server directly with a
    stdio JSON-RPC probe script. Verify the TOOL LIST, not the docs.
15. **Window pick order is enumeration order, not importance**: a modal
    dialog (Edge's sync dialog, 524ร—364) can be enumerated BEFORE the
    main window (1280ร—900) and win a naive `vis[0]` pick โ€” screenshots
    and UIA scans then capture the wrong object. All pickers now rank by
    visible area descending (class preference as tiebreak) through one
    shared `pick_main_window`; keep it that way when adding pickers.
16. **Word's start screen never renders on a hidden desktop**: `WINWORD.EXE`
    with no document shows the Start screen, which stays blank there
    (disabling hardware acceleration does not help โ€” verified live).
    Launch Word with a document instead: create a blank `.docx` (an empty
    file with the right ZIP shape, or `copy NUL` + let Word repair) and
    pass it on the command line; the document window renders fine and
    UIA sees the "Page 1" canvas.
17. **STA-pinned threads cannot switch desktops** (the screenshot bug):
    `run_on_sandbox_thread` initializes COM STA *after* the switch, pinning
    the thread โ€” any further `SetThreadDesktop` inside that worker fails
    with ERROR_BUSY/170. `enumerate_sandbox_windows` therefore skips the
    switch when the calling thread is already on the sandbox desktop
    (GetThreadDesktop == sandbox handle); call it from anywhere.

### B mode v1 (windows-mcp bridge, deprecated but kept)

1. **`selectors` cannot watch Windows pipes**: `DefaultSelector.select()`
   raises WinError 10038 on a child's stdout pipe (pipes are not sockets).
   Use a persistent pump thread + Queue.
2. **`threading.Lock` is not reentrant**: calling `_rpc()` while `start()`
   holds the lock deadlocks; the bridge lock must be an `RLock`.
3. **One reader thread per response steals lines**: concurrent
   `readline()` calls grab each other's responses; reads must converge to
   a single pump thread.

---

## Repository layout

```text
tandem-agent-mouse/
โ”œโ”€โ”€ src/tandem_agent_mouse/
โ”‚   โ”œโ”€โ”€ win32/      ctypes native layer + red-line guard (guard.py: raise on fetch)
โ”‚   โ”œโ”€โ”€ uia/        com_worker (persistent STA thread) + scanner (CacheRequest walk)
โ”‚   โ”‚               + actions (pattern invocation + message-click fallback)
โ”‚   โ”œโ”€โ”€ capture/    PrintWindow(PW_RENDERFULLCONTENT) โ†’ WGC chain
โ”‚   โ”œโ”€โ”€ sandbox/    hidden virtual desktop (desktop.py) + message injector
โ”‚   โ”‚               (input_thread.py) + corner projection (projection.py)
โ”‚   โ”‚               + manager (manager.py)
โ”‚   โ”œโ”€โ”€ bridge/     windows-mcp subprocess bridge (legacy B mode, deprecated)
โ”‚   โ”œโ”€โ”€ core/       Element model, RuntimeId registry, diff engine, batch executor
โ”‚   โ”œโ”€โ”€ vision/     PNG encoding, grid metadata, visual element wrapping
โ”‚   โ”œโ”€โ”€ server.py   hand-written stdio JSON-RPC MCP (zero SDK dependencies)
โ”‚   โ””โ”€โ”€ cli.py      command-line entry
โ”œโ”€โ”€ tests/          guard / diff / registry / executor / bridge policy / protocol (32)
โ”œโ”€โ”€ pyproject.toml  hatchling build; dependencies are comtypes + pillow only
โ”œโ”€โ”€ CHANGELOG.md
โ””โ”€โ”€ LICENSE         MIT
```

---

## Tests

```bash
python -m pytest tests/ -q
```

32 tests, all passing. Coverage: the red-line guard (forbidden APIs raise
on fetch), the diff engine, the RuntimeId registry, the batch executor,
the bridge exclusion policy, and the stdio protocol. The sandbox lifecycle
(`stop()` / projection close / hard server kill โ†’ zero surviving
processes) is verified separately on a live machine.

---

## Known limitations

Honest edge cases โ€” read before relying on any of this:

- **Minimized windows cannot be captured** (neither WGC nor PrintWindow
  can get content); reported honestly as `WindowMinimized`.
- **Elevated windows need an equally elevated process** for UIA.
- **Chromium web content rejects synthetic message clicks** (pitfall 7);
  inside the sandbox, message clicks do land once the target rect comes
  from UIA (the bilibili flow was verified end-to-end).
- **WGC reports `E_INVALIDARG` on some windows** (cause not identified;
  the fallback chain then fails and reports honestly).
- **Projection frame rate is ~2 fps** (0.5 s interval) โ€” enough to watch
  the agent work, not video-grade smoothness.
- **Windows 10/11 only**; UIA behavior varies with each app's
  accessibility implementation quality.

---

## Reporting issues

Regular bugs / feature requests: file them on the repository's Issues page
with reproduction steps, expected behavior, actual behavior, and the
environment (Windows version, Python version).

## License

MIT โ€” see [LICENSE](LICENSE). Dependencies: comtypes (MIT), Pillow
(MIT-CMU), optional winsdk (MIT). windows-mcp (Apache-2.0) is invoked as a
separate subprocess; its code is not linked.