← Commit history
README.md+280−280
@@ -1,280 +1,280 @@-# Adom Desktop--**Unleash your AI onto your full PC: full power, full safety.** Every AI you use today is locked in a box: a cloud agent lives in a container with no hands, and a chat app can talk about your files but cannot touch them. Adom Desktop inverts that. It lets the AI break out of the box it runs in and onto your real machine, so the AI manages and runs your entire laptop as you, without you doing anything manually: launch apps, move files, drive any window, control KiCad, Fusion 360, Altium, Blender, or your real signed-in browser. It does not matter where your AI lives. A cloud agent reaches your desktop over the relay; a local one (Claude Desktop, Claude Code, Codex, anything) drives the same machine over loopback. It ships with **Adom Hydrogen** and installs standalone too, and standalone users get the full value.--**Bridges** make Adom Desktop a small operating system for AI: a bridge teaches it a new verb namespace for any app, and Adom Desktop provisions its own managed Node and Python runtimes, owns the bridge lifecycle, and keeps every bridge in a controlled loopback-only environment. And because unleashing an AI onto a whole machine only works if the human stays in charge, the **permission system** is the other half of the product: side-effecting commands from a remote AI are gated behind a human approval the AI can request but never grant itself, every command is attributed to the AI thread that issued it, and Adom Desktop binds to localhost only. Full power and full safety together are the point.--![Adom Desktop main window](docs/main-view.png)--- **Full desktop control:** windows, input, screenshots, UI Automation, the shell, files, the registry, WSL.-- **Bridges:** first-class mini-apps. Puppeteer (browser), KiCad, Fusion 360, your real signed-in browser, Blender, or your own.-- **You are in control:** localhost-only, an install-consent trust model, a visible activity log, and an approval gate for the remote cloud-AI path.-- **Multi-machine:** one relay can hold many desktops. Address any of them by name, or fan a command out to all.-- **Self-updating and signed:** every release is Azure code-signed and auto-updates from this page.--## How it works--![Architecture](docs/arch-flow.svg)--The cloud AI never touches your machine directly. It sends a verb to the **relay**; Adom Desktop, connected out to that relay from your desktop, executes it against Windows or forwards it to a bridge, and streams the result back. A co-resident sibling app (Adom Hydrogen) can skip the relay and POST straight to the **loopback HTTP API** at `127.0.0.1`.--```-Adom container (this package)            User's laptop-+--------------------------+             +--------------------------+-| adom-desktop CLI + skills|  <-relay->  | Adom Desktop GUI (Tauri) |-| (Claude drives verbs)    |             | KiCad, Fusion, Chrome    |-+--------------------------+             | shell, files, screens    |-                                         +--------------------------+-```--## Install (in an Adom container)--```-adom-wiki pkg install adom/adom-desktop-```--That installs the **container-side** pieces: the `adom-desktop` CLI (SHA256-verified against this page's `version.json`) and the Claude skill bundle into `~/.claude/skills/`:--| Skill | What Claude learns |-|---|---|-| `adom-desktop` | the main verb catalog: screenshots, files, shell, windows, notifications |-| `adom-desktop-pup` | real-Chrome (Puppeteer) automation: sessions, tabs, eval, recording |-| `adom-desktop-notify` | native Windows toasts: progress bars, sticky reminders, buttons that round-trip |-| `adom-desktop-bridge-sdk` | author your own bridge: `bridge.json`, health/describe/rich-hint contracts, lifecycle |-| `adom-desktop-bridge-ownership` | the boundary: what AD core owns versus what a bridge author owns |-| `adom-desktop-multi-ad` | address multiple Adom Desktops on one relay (`--target`, fan-out) |-| `adom-desktop-direct-api` | the loopback HTTP API for sibling apps |-| `adom-desktop-installer`, `adom-desktop-windows-host`, `adom-desktop-discovery` | install, host, and discovery details |--`adom-wiki pkg update` keeps it current. The **KiCad** and **Fusion 360** bridge skills install from their own cloud-owned bridge pages.--## Drive it from a local AI on the same PC--You do not need the cloud. The Windows installer puts `adom-desktop-cli.exe` on PATH and links the Adom Desktop skills into `~/.claude/skills` and `~/.codex/skills` automatically, so Claude Desktop, Claude Code, or Codex on the same machine can drive your desktop immediately with no relay and no setup. Deep dive: [using Adom Desktop from a local AI](docs/local-ai.md).--## Sign in once, connect everywhere--Sign in to your Adom account inside the app (Connections panel) and your cloud containers appear in the connection list and connect automatically: no JSON, no copy-paste. An AI in a container registers itself with one command. Deep dive: [sign-in and cloud configuration](docs/auth-and-cloud-config.md).--## Many machines, many containers--One desktop can connect to many containers, many desktops can share one container, and both at once. Address any desktop by name with `--target`. Deep dive with a topology diagram: [connection topology](docs/topology.md).--## Running under Adom Hydrogen--When Adom Hydrogen is present it runs Adom Desktop embedded: hidden window, HD-owned permission prompts, and a distinct taskbar tile. If Hydrogen ever goes away, Adom Desktop comes back standalone so your machine stays reachable. Deep dive: [how AD interacts with Adom Hydrogen](docs/adom-hydrogen.md).--## The laptop GUI--The **Windows GUI** installs separately: a signed NSIS installer served from this page (*Download for your machine* above; Adom Hydrogen installs it automatically). It installs to `%LOCALAPPDATA%\Adom Desktop\`, adds a tray icon, starts on login, and **auto-updates itself** from this page's `version.json` (prompt by default; every 4h and on launch).--The main window (top) puts everything on one surface: **Connections** (each relay plus the other Adom Desktops on it as peers), the **Activity Log** (every command, badged by source and type, filterable), the **Bridges sidebar** (installed bridges as mini-app cards), the provisioned **Runtimes**, and the footer approval bar.--Deep dives: [presence, tray and icon states](docs/presence.md) and [the managed Node and Python runtimes](docs/runtimes.md).--## Downloads--Everything a real user installs comes from this page.--| Artifact | Platform | What it is | How to get it |-|---|---|---|---|-| Signed installer (NSIS) | Windows x64 | The GUI desktop app. Azure Trusted Signing. Installs to `%LOCALAPPDATA%\Adom Desktop\` and auto-updates. | The *Download for your machine* box at the top of this page. This is the one promoted Windows download. |-| CLI + skills package | Linux (container) | The `adom-desktop` CLI and Claude skill set for a cloud container driving your desktop remotely. Not needed if you use Adom Hydrogen, which bundles Adom Desktop. SHA256-verified on install. | `adom-wiki pkg install adom/adom-desktop` |-| `version.json` | manifest | The auto-update manifest the GUI polls: per-platform installer URL, SHA256, and size. | Read automatically by the app; you never fetch it by hand. |--The Windows GUI installer is the only promoted Windows download. There is intentionally no standalone Windows CLI download. macOS is planned; the manifest reserves a slot for it.--## Staying current: updates and the restart schedule--Adom Desktop polls the wiki manifest on launch and every 4 hours. Every release is Azure code-signed and its installer is SHA256-verified before it runs. What differs is urgency, and the manifest carries a `priority`:--| Tier | How it is presented | Overrides your mode? |-|---|---|---|-| **Normal** | Almost every release. A footer banner: Install now / Later. Silent in Auto, nothing in Off. | No. |-| **High** | A recommended fix. Auto-installs in the background (even in Prompt) with a heads-up toast, then restarts. | Overrides Prompt; still respects Off. |-| **Emergency** | A bug bad enough to pull from the whole fleet at once. A red, non-dismissible countdown to a forced restart; AD Authenticode-verifies the installer (must be signed by Adom Industries) before it runs. | Overrides everything, even Off, even embedded. |--You set your preference once in **Settings, Updates**: Prompt (default), Auto, or Off. A normal release carries no priority; the emergency tier is a deliberate, per-incident action.--**When a normal update actually installs.** Adom Desktop lives in the tray and can run for weeks without a restart, so a Prompt-mode update you never click would never install. The restart schedule closes that: once an update is downloaded AND you have been away from the keyboard for about 15 minutes AND AD is not mid-task, it installs and relaunches quietly. It is idle-based, not a fixed clock, so it never interrupts active work or an overnight job, and restarting Adom Desktop has no effect on your other apps (it reconnects in seconds). Turn it off in Settings, Updates, "Restart when idle to finish updates," and a Prompt-mode update simply waits for your click. Auto installs immediately; Off never auto-installs (except an Emergency release). Embedded in Adom Hydrogen, Hydrogen owns the restart timing.--Deep dive on every update lane (app, bridges, container package, skills) and the network-traffic math: [how updates work](docs/updates.md).--## Screenshots--Anything AD can see, it can screenshot, losslessly, with **no focus steal** (`PrintWindow` or full-screen DXGI, never a foreground grab). *Every image on this page was produced by these verbs.* `desktop_screenshot_window` returns a **`screenshots[]` array**: the window plus each owned popup or child dialog as its own image (so a confirm bubble hovering over an app comes back alongside the parent, never missed); `desktop_screenshot_screen` grabs the full virtual screen including borderless popups; `browser_screenshot` grabs the pup viewport. Every shot carries a **`coordMap`** so the AI clicks *what it sees* (`{space:"image", shotId, x, y}` to real screen px, no DPI math). It stays generic: AD returns the rich array, the AI decides. `ad_scroll` even lets the AI page AD's own window to capture content below the fold.--## Recording--![Adom Desktop Recorder](docs/recorder.png)--AD records the desktop or a browser session to video. The recorder HUD (above) **requires a `reason`** shown to the user up front, so a recording never starts silently, with live Screen/Microphone toggles and a Start/Stop control. `desktop_recorder_open {reason}` and `desktop_recorder_close` for the desktop; `browser_record_start` and `browser_record_stop` for the pup browser. Built for smooth, high-frame-rate video, not a choppy screenshot loop.--Deep dive: [recording](docs/recording.md).--## Bridges--Each bridge is a small, independently-versioned process the app spawns, supervises, health-checks, and auto-updates from its own wiki page. Every install ships with the six below, and each shows up in the Bridges sidebar as a card: its billboard, a health LED, and language/source badges.--### Puppeteer (`browser_*`)--![Puppeteer bridge card](docs/bridge-card-puppeteer.png)--A dedicated Chrome-for-Testing (or Edge) that AD downloads and manages: open windows and tabs, drive pages, evaluate JS, screenshot, and record. No manual browser install.--### Native Browser (`nbrowser_*`)--![Native Browser bridge card](docs/bridge-card-native-browser.png)--Your *real*, signed-in Chrome or Edge profile, for the logins and forms a throwaway automation profile cannot do. The desktop bridge is bundled and works extension-free out of the box: it discovers your browser profiles, opens URLs in the right signed-in profile (OAuth consent flows), and inventories browser windows. Installing the companion browser extension unlocks full in-page drive: tabs, clicks, JS eval, and background screenshots.--### KiCad (`kicad_*`)--![KiCad bridge card](docs/bridge-card-kicad.png)--Schematic and PCB automation: DRC/ERC, gerber and STEP export, 3D board render, and netlist/footprint queries.--### Fusion 360 (`fusion_*`) plus APS (`aps_*`)--![Fusion 360 bridge card](docs/bridge-card-fusion360.png)--Parametric modeling scripts and exports, plus fast indexed cloud-file search through Autodesk Platform Services.--### Blender (`blender_*`)--![Blender bridge card](docs/bridge-card-blender.png)--Headless Python scripting and renders.--### Adom Hydrogen (`hd_*`)--![Adom Hydrogen bridge card](docs/bridge-card-hd.png)--The local dev-environment app AD can embed into; AD relays Hydrogen's runtime state and drives its lifecycle.--Install a third-party bridge by pasting its manifest URL, or `bridge_install {"manifestUrl":"..."}`. Build one with the [Bridge SDK](https://wiki.adom.inc/adom/adom-desktop-bridges).--Deep dive: [bridges from the user's side](docs/bridges.md) (cards, LEDs, console, installing and updating).--## The taskbar icon tells you the mode--AD's window carries a mode-specific app identity, so the taskbar button swaps its whole tile AND its name the moment the mode changes: no restart, no flicker.--| Icon | Mode | Taskbar name | Meaning |-|:---:|:---|:---|:---|-| <img src="docs/taskbar-standalone.png" alt="Standalone: muted Adom tile" width="88"> | **Standalone** | Adom Desktop | AD running on its own. The default for a normal install. |-| <img src="docs/taskbar-embedded.png" alt="Embedded: vivid Adom tile" width="88"> | **Embedded** | Adom Desktop Hydrogen Embedded | Adom Hydrogen launched AD and drives its lifecycle. |--Muted "Adom Desktop" = AD by itself; vivid "Adom Desktop Hydrogen Embedded" = Hydrogen has the helm. Each mode is a registered Windows app identity, and flipping the window between them makes the shell rebuild the button with the other identity's icon and name. Because the modes are distinct identities, a taskbar pin made in one mode shows the running window as a separate button while AD is in the other mode.--## Window identity (branding a window the AI drives)--The same Windows shell mechanism (the Application User Model ID, or AUMID) is exposed as generic verbs so a bridge can make a window it drives present as *its* app. The motivating case: a Chrome for Testing window that would otherwise read "Test" with Chrome's icon in the taskbar, Alt-Tab, and the right-click menu.--- `desktop_register_app_identity` teaches Windows what an app id means (name plus icon), HKCU only, no UAC; `shortcut:false` gives a per-session identity with no Start Menu spam.-- `desktop_set_window_identity` stamps that id onto a live window plus its title-bar and Alt-Tab icon and the jump-list header.-- `desktop_set_window_jumplist` attaches a custom right-click menu and brands its header row.-- `desktop_taskbar` paints a progress bar, an overlay badge, and the hover-preview tooltip (`thumbnailTooltip`, the one uncapped text surface) and clip on any window's button.--These verbs inspect any icon you pass and return an `iconCheck` that explains, in plain terms, why an icon renders as a generic white document (the 256px `.ico` size must be PNG-compressed; SVG does not load; a `.ico` uses a bare path while a `.exe` uses `path,index`), so the caller fixes the file instead of shipping a broken icon. The full guide ships as the `adom-desktop-window-identity` skill.--## Settings--![Settings](docs/settings.png)--Auto-connect behavior, the update channel (**prompt** / auto / off) and *restart when idle to finish updates*, the shell-approval preference, Adom Hydrogen integration (including *exit when Hydrogen exits*), and runtime prewarming. While embedded in Hydrogen, HD-owned preferences show as *managed by Adom Hydrogen*.--## Security and the approval gate--![Shell approval bar](docs/approve-bar.png)--**The trust boundary is install-consent plus the localhost bind.** Installing the app (and each bridge) is you granting permission for the AI to drive your machine; if you do not want that, uninstall. And the app plus every bridge binds `127.0.0.1` **only, never `0.0.0.0`**, so a caller must already be a program you installed on your own machine to reach it. No network attack surface by construction.--On top of that, the **remote cloud-AI path is approval-gated.** Verbs that run code or change the machine (`shell_execute`, `run_script`, `launch_process`, `write_file`, `registry_*`, `process_kill`, and so on) require a human grant when they arrive over the *relay*: **+1hr / +24hr** (time-boxed), **Bypass** (indefinite), or **Revoke**. The grant is human-only; local callers (your CLI, Hydrogen, bridges) are trusted by transport and never prompt; read-only verbs are never gated.--Deep dive: [permissions](docs/permissions.md) (what each Approve choice means, and how to revoke).--## Notifications--AD speaks to you through native Windows toasts via `notify_user`, under Adom Desktop's own identity (the Adom logo, and Action Center history like any first-class app). A cloud AI or a bridge, even one on a different machine addressed with `target`, fires them to reach you wherever you are. Every image below is the real toast, captured live from the exact call shown.--**Basic alert.** Awareness: a build finished, a file is ready. Auto-dismisses in about 7 seconds. Add `"durationLong":true` to hold it around 25 seconds; `"level":"error"` and up do that automatically, and `"emergency"` also flashes the taskbar.--![Basic alert toast](docs/toast-basic.png)--```bash-adom-desktop notify_user '{"title":"Export done","body":"gerbers.zip is ready in C:/exports."}'-```--**Click-to-focus: send them where the notification points.** Most notifications are about something else on screen: a browser waiting on a login, a dialog, an app that needs a click. Pass `focus` and clicking the notification brings THAT window to the front, instead of opening Adom Desktop. Name the window by `hwnd` (exact, from `desktop_list_windows`), by `cacheKey`, or by `titleContains`.--```bash-adom-desktop notify_user '{"title":"Sign-in needed","body":"Confirm in the browser and I will continue.","focus":{"titleContains":"Sign in - Google Chrome"}}'-```--The response says whether the target resolved, so the AI finds out at send time rather than you finding out by clicking a notification that does nothing. If the window is gone by the time you click, it falls back to opening Adom Desktop, so a click is never dead. The click also counts as an acknowledgement, so the AI learns you engaged even with no buttons on the toast.--**Sticky choice with buttons that round-trip.** Stays on screen until the user clicks (no auto-dismiss). Give it an `id` you choose, then read the click back.--![Sticky toast with buttons](docs/toast-buttons.png)--```bash-adom-desktop notify_user '{"id":"deploy-42","title":"Ship v2 to production?","body":"The build is green and staged.","scenario":"reminder","buttons":[{"label":"Ship it"},{"label":"Hold"}]}'-```--**Progress bar.** For a long job (a gerber export, a render). Re-fire with the SAME `id` as the job advances toward `1.0`. `body` is optional here.--![Progress bar toast](docs/toast-progress.png)--```bash-adom-desktop notify_user '{"id":"job-1","title":"Exporting gerbers","progress":{"title":"myboard.kicad_pcb","value":0.6,"status":"Plotting copper layers...","valueOverride":"6 / 10 layers"}}'-```--**Reply input.** The text field renders, but Windows does not return typed text over protocol activation, so it is display-only today. For a real choice, use buttons.--![Reply input toast](docs/toast-input.png)--```bash-adom-desktop notify_user '{"title":"Quick note","body":"Leave a note for the next run.","scenario":"reminder","inputs":[{"id":"reply","type":"text","placeholder":"Type a reply..."}],"buttons":[{"label":"Send"}]}'-```--**Shell-approval.** Fired by AD itself when a gated command needs a human: *Approve 1 hr / 24 hr / Bypass Permissions*, granted straight from the toast without opening the window.--![Shell approval toast](docs/approve-bar.png)--**How the AI tracks the response, or expires the notify.** A toast is fire-and-forget, but a clicked button is recorded durably, so the AI learns the outcome even on a headless box:--```bash-adom-desktop notify_response '{"id":"deploy-42"}'   # pending:true until clicked, then action:"Ship it"-adom-desktop notify_events '{"sinceSeq":0}'          # drain EVERY click since a cursor, no per-id polling-adom-desktop notify_dismiss '{"id":"deploy-42"}'     # retract a sticky toast once you have handled it-```--Or skip the polling entirely with `"wait":true`, which blocks until you act (or `timeoutSec` elapses) and hands the AI your answer directly.--Clicking the toast body brings the window named by `focus` to the front, or opens Adom Desktop when no `focus` was given. Button clicks deliberately do not steal focus: a toast exists to reach you without interrupting what you were doing.--The AI does not have to read any of this first. When a call leaves something on the table, such as no `focus`, or buttons on a toast that will vanish before you see it, the response says so and lists what the verb can do. That coaching stops once the AI gets it right. Full reference lives in the `adom-desktop-notify` skill.--Deep dive: [notifications](docs/notifications.md).--## Verbs--![Core vs bridge verbs](docs/core-vs-bridge.svg)--AD-core owns the generic verbs (`desktop_*`, `shell`, `ui_*`, files, `notify_user`, multi-AD). Each bridge owns its own prefix and ships its own verbs; the app relays them verbatim and stays generic. Invoke the same way on every transport, for identical JSON back:--```bash-adom-desktop screenshot_window '{"hwnd": 12345}'              # CLI (relay / direct API)-adom-desktop --target AzureVM list_windows                    # target a named machine-curl -s -X POST 127.0.0.1:47200/command \-  -d '{"command":"notify_user","args":{"title":"Hi"}}'        # loopback direct API-```--`adom-desktop help` and `GET /commands` are the live discovery surfaces for the full verb set.--Deep dive on the CLI itself: [the CLI guide](docs/cli.md).--## Versioning--This package version tracks the Adom Desktop release. The scaffold is small; the multi-MB binaries are fetched at install time with SHA256 verification (the manifest's `assets` map carries server-computed checksums per platform).+# Adom Desktop
+
+**Unleash your AI onto your full PC: full power, full safety.** Every AI you use today is locked in a box: a cloud agent lives in a container with no hands, and a chat app can talk about your files but cannot touch them. Adom Desktop inverts that. It lets the AI break out of the box it runs in and onto your real machine, so the AI manages and runs your entire laptop as you, without you doing anything manually: launch apps, move files, drive any window, control KiCad, Fusion 360, Altium, Blender, or your real signed-in browser. It does not matter where your AI lives. A cloud agent reaches your desktop over the relay; a local one (Claude Desktop, Claude Code, Codex, anything) drives the same machine over loopback. It ships with **Hydrogen Desktop** and installs standalone too, and standalone users get the full value.
+
+**Bridges** make Adom Desktop a small operating system for AI: a bridge teaches it a new verb namespace for any app, and Adom Desktop provisions its own managed Node and Python runtimes, owns the bridge lifecycle, and keeps every bridge in a controlled loopback-only environment. And because unleashing an AI onto a whole machine only works if the human stays in charge, the **permission system** is the other half of the product: side-effecting commands from a remote AI are gated behind a human approval the AI can request but never grant itself, every command is attributed to the AI thread that issued it, and Adom Desktop binds to localhost only. Full power and full safety together are the point.
+
+![Adom Desktop main window](docs/main-view.png)
+
+- **Full desktop control:** windows, input, screenshots, UI Automation, the shell, files, the registry, WSL.
+- **Bridges:** first-class mini-apps. Puppeteer (browser), KiCad, Fusion 360, your real signed-in browser, Blender, or your own.
+- **You are in control:** localhost-only, an install-consent trust model, a visible activity log, and an approval gate for the remote cloud-AI path.
+- **Multi-machine:** one relay can hold many desktops. Address any of them by name, or fan a command out to all.
+- **Self-updating and signed:** every release is Azure code-signed and auto-updates from this page.
+
+## How it works
+
+![Architecture](docs/arch-flow.svg)
+
+The cloud AI never touches your machine directly. It sends a verb to the **relay**; Adom Desktop, connected out to that relay from your desktop, executes it against Windows or forwards it to a bridge, and streams the result back. A co-resident sibling app (Hydrogen Desktop) can skip the relay and POST straight to the **loopback HTTP API** at `127.0.0.1`.
+
+```
+Adom container (this package)            User's laptop
++--------------------------+             +--------------------------+
+| adom-desktop CLI + skills|  <-relay->  | Adom Desktop GUI (Tauri) |
+| (Claude drives verbs)    |             | KiCad, Fusion, Chrome    |
++--------------------------+             | shell, files, screens    |
+                                         +--------------------------+
+```
+
+## Install (in an Adom container)
+
+```
+adom-wiki pkg install adom/adom-desktop
+```
+
+That installs the **container-side** pieces: the `adom-desktop` CLI (SHA256-verified against this page's `version.json`) and the Claude skill bundle into `~/.claude/skills/`:
+
+| Skill | What Claude learns |
+|---|---|
+| `adom-desktop` | the main verb catalog: screenshots, files, shell, windows, notifications |
+| `adom-desktop-pup` | real-Chrome (Puppeteer) automation: sessions, tabs, eval, recording |
+| `adom-desktop-notify` | native Windows toasts: progress bars, sticky reminders, buttons that round-trip |
+| `adom-desktop-bridge-sdk` | author your own bridge: `bridge.json`, health/describe/rich-hint contracts, lifecycle |
+| `adom-desktop-bridge-ownership` | the boundary: what AD core owns versus what a bridge author owns |
+| `adom-desktop-multi-ad` | address multiple Adom Desktops on one relay (`--target`, fan-out) |
+| `adom-desktop-direct-api` | the loopback HTTP API for sibling apps |
+| `adom-desktop-installer`, `adom-desktop-windows-host`, `adom-desktop-discovery` | install, host, and discovery details |
+
+`adom-wiki pkg update` keeps it current. The **KiCad** and **Fusion 360** bridge skills install from their own cloud-owned bridge pages.
+
+## Drive it from a local AI on the same PC
+
+You do not need the cloud. The Windows installer puts `adom-desktop-cli.exe` on PATH and links the Adom Desktop skills into `~/.claude/skills` and `~/.codex/skills` automatically, so Claude Desktop, Claude Code, or Codex on the same machine can drive your desktop immediately with no relay and no setup. Deep dive: [using Adom Desktop from a local AI](docs/local-ai.md).
+
+## Sign in once, connect everywhere
+
+Sign in to your Adom account inside the app (Connections panel) and your cloud containers appear in the connection list and connect automatically: no JSON, no copy-paste. An AI in a container registers itself with one command. Deep dive: [sign-in and cloud configuration](docs/auth-and-cloud-config.md).
+
+## Many machines, many containers
+
+One desktop can connect to many containers, many desktops can share one container, and both at once. Address any desktop by name with `--target`. Deep dive with a topology diagram: [connection topology](docs/topology.md).
+
+## Running under Hydrogen Desktop
+
+When Hydrogen Desktop is present it runs Adom Desktop embedded: hidden window, HD-owned permission prompts, and a distinct taskbar tile. If HD ever goes away, Adom Desktop comes back standalone so your machine stays reachable. Deep dive: [how AD interacts with Hydrogen Desktop](docs/hydrogen-desktop.md).
+
+## The laptop GUI
+
+The **Windows GUI** installs separately: a signed NSIS installer served from this page (*Download for your machine* above; Hydrogen Desktop installs it automatically). It installs to `%LOCALAPPDATA%\Adom Desktop\`, adds a tray icon, starts on login, and **auto-updates itself** from this page's `version.json` (prompt by default; every 4h and on launch).
+
+The main window (top) puts everything on one surface: **Connections** (each relay plus the other Adom Desktops on it as peers), the **Activity Log** (every command, badged by source and type, filterable), the **Bridges sidebar** (installed bridges as mini-app cards), the provisioned **Runtimes**, and the footer approval bar.
+
+Deep dives: [presence, tray and icon states](docs/presence.md) and [the managed Node and Python runtimes](docs/runtimes.md).
+
+## Downloads
+
+Everything a real user installs comes from this page.
+
+| Artifact | Platform | What it is | How to get it |
+|---|---|---|---|
+| Signed installer (NSIS) | Windows x64 | The GUI desktop app. Azure Trusted Signing. Installs to `%LOCALAPPDATA%\Adom Desktop\` and auto-updates. | The *Download for your machine* box at the top of this page. This is the one promoted Windows download. |
+| CLI + skills package | Linux (container) | The `adom-desktop` CLI and Claude skill set for a cloud container driving your desktop remotely. Not needed if you use Hydrogen Desktop, which bundles Adom Desktop. SHA256-verified on install. | `adom-wiki pkg install adom/adom-desktop` |
+| `version.json` | manifest | The auto-update manifest the GUI polls: per-platform installer URL, SHA256, and size. | Read automatically by the app; you never fetch it by hand. |
+
+The Windows GUI installer is the only promoted Windows download. There is intentionally no standalone Windows CLI download. macOS is planned; the manifest reserves a slot for it.
+
+## Staying current: updates and the restart schedule
+
+Adom Desktop polls the wiki manifest on launch and every 4 hours. Every release is Azure code-signed and its installer is SHA256-verified before it runs. What differs is urgency, and the manifest carries a `priority`:
+
+| Tier | How it is presented | Overrides your mode? |
+|---|---|---|
+| **Normal** | Almost every release. A footer banner: Install now / Later. Silent in Auto, nothing in Off. | No. |
+| **High** | A recommended fix. Auto-installs in the background (even in Prompt) with a heads-up toast, then restarts. | Overrides Prompt; still respects Off. |
+| **Emergency** | A bug bad enough to pull from the whole fleet at once. A red, non-dismissible countdown to a forced restart; AD Authenticode-verifies the installer (must be signed by Adom Industries) before it runs. | Overrides everything, even Off, even embedded. |
+
+You set your preference once in **Settings, Updates**: Prompt (default), Auto, or Off. A normal release carries no priority; the emergency tier is a deliberate, per-incident action.
+
+**When a normal update actually installs.** Adom Desktop lives in the tray and can run for weeks without a restart, so a Prompt-mode update you never click would never install. The restart schedule closes that: once an update is downloaded AND you have been away from the keyboard for about 15 minutes AND AD is not mid-task, it installs and relaunches quietly. It is idle-based, not a fixed clock, so it never interrupts active work or an overnight job, and restarting Adom Desktop has no effect on your other apps (it reconnects in seconds). Turn it off in Settings, Updates, "Restart when idle to finish updates," and a Prompt-mode update simply waits for your click. Auto installs immediately; Off never auto-installs (except an Emergency release). Embedded in Hydrogen Desktop, HD owns the restart timing.
+
+Deep dive on every update lane (app, bridges, container package, skills) and the network-traffic math: [how updates work](docs/updates.md).
+
+## Screenshots
+
+Anything AD can see, it can screenshot, losslessly, with **no focus steal** (`PrintWindow` or full-screen DXGI, never a foreground grab). *Every image on this page was produced by these verbs.* `desktop_screenshot_window` returns a **`screenshots[]` array**: the window plus each owned popup or child dialog as its own image (so a confirm bubble hovering over an app comes back alongside the parent, never missed); `desktop_screenshot_screen` grabs the full virtual screen including borderless popups; `browser_screenshot` grabs the pup viewport. Every shot carries a **`coordMap`** so the AI clicks *what it sees* (`{space:"image", shotId, x, y}` to real screen px, no DPI math). It stays generic: AD returns the rich array, the AI decides. `ad_scroll` even lets the AI page AD's own window to capture content below the fold.
+
+## Recording
+
+![Adom Desktop Recorder](docs/recorder.png)
+
+AD records the desktop or a browser session to video. The recorder HUD (above) **requires a `reason`** shown to the user up front, so a recording never starts silently, with live Screen/Microphone toggles and a Start/Stop control. `desktop_recorder_open {reason}` and `desktop_recorder_close` for the desktop; `browser_record_start` and `browser_record_stop` for the pup browser. Built for smooth, high-frame-rate video, not a choppy screenshot loop.
+
+Deep dive: [recording](docs/recording.md).
+
+## Bridges
+
+Each bridge is a small, independently-versioned process the app spawns, supervises, health-checks, and auto-updates from its own wiki page. Every install ships with the six below, and each shows up in the Bridges sidebar as a card: its billboard, a health LED, and language/source badges.
+
+### Puppeteer (`browser_*`)
+
+![Puppeteer bridge card](docs/bridge-card-puppeteer.png)
+
+A dedicated Chrome-for-Testing (or Edge) that AD downloads and manages: open windows and tabs, drive pages, evaluate JS, screenshot, and record. No manual browser install.
+
+### Native Browser (`nbrowser_*`)
+
+![Native Browser bridge card](docs/bridge-card-native-browser.png)
+
+Your *real*, signed-in Chrome or Edge profile, for the logins and forms a throwaway automation profile cannot do. The desktop bridge is bundled and works extension-free out of the box: it discovers your browser profiles, opens URLs in the right signed-in profile (OAuth consent flows), and inventories browser windows. Installing the companion browser extension unlocks full in-page drive: tabs, clicks, JS eval, and background screenshots.
+
+### KiCad (`kicad_*`)
+
+![KiCad bridge card](docs/bridge-card-kicad.png)
+
+Schematic and PCB automation: DRC/ERC, gerber and STEP export, 3D board render, and netlist/footprint queries.
+
+### Fusion 360 (`fusion_*`) plus APS (`aps_*`)
+
+![Fusion 360 bridge card](docs/bridge-card-fusion360.png)
+
+Parametric modeling scripts and exports, plus fast indexed cloud-file search through Autodesk Platform Services.
+
+### Blender (`blender_*`)
+
+![Blender bridge card](docs/bridge-card-blender.png)
+
+Headless Python scripting and renders.
+
+### Hydrogen Desktop (`hd_*`)
+
+![Hydrogen Desktop bridge card](docs/bridge-card-hd.png)
+
+The local dev-environment app AD can embed into; AD relays HD's runtime state and drives its lifecycle.
+
+Install a third-party bridge by pasting its manifest URL, or `bridge_install {"manifestUrl":"..."}`. Build one with the [Bridge SDK](https://wiki.adom.inc/adom/adom-desktop-bridges).
+
+Deep dive: [bridges from the user's side](docs/bridges.md) (cards, LEDs, console, installing and updating).
+
+## The taskbar icon tells you the mode
+
+AD's window carries a mode-specific app identity, so the taskbar button swaps its whole tile AND its name the moment the mode changes: no restart, no flicker.
+
+| Icon | Mode | Taskbar name | Meaning |
+|:---:|:---|:---|:---|
+| <img src="docs/taskbar-standalone.png" alt="Standalone: muted Adom tile" width="88"> | **Standalone** | Adom Desktop | AD running on its own. The default for a normal install. |
+| <img src="docs/taskbar-embedded.png" alt="Embedded: vivid Adom tile" width="88"> | **Embedded** | Adom Desktop HD Embedded | Hydrogen Desktop launched AD and drives its lifecycle. |
+
+Muted "Adom Desktop" = AD by itself; vivid "Adom Desktop HD Embedded" = HD has the helm. Each mode is a registered Windows app identity, and flipping the window between them makes the shell rebuild the button with the other identity's icon and name. Because the modes are distinct identities, a taskbar pin made in one mode shows the running window as a separate button while AD is in the other mode.
+
+## Window identity (branding a window the AI drives)
+
+The same Windows shell mechanism (the Application User Model ID, or AUMID) is exposed as generic verbs so a bridge can make a window it drives present as *its* app. The motivating case: a Chrome for Testing window that would otherwise read "Test" with Chrome's icon in the taskbar, Alt-Tab, and the right-click menu.
+
+- `desktop_register_app_identity` teaches Windows what an app id means (name plus icon), HKCU only, no UAC; `shortcut:false` gives a per-session identity with no Start Menu spam.
+- `desktop_set_window_identity` stamps that id onto a live window plus its title-bar and Alt-Tab icon and the jump-list header.
+- `desktop_set_window_jumplist` attaches a custom right-click menu and brands its header row.
+- `desktop_taskbar` paints a progress bar, an overlay badge, and the hover-preview tooltip (`thumbnailTooltip`, the one uncapped text surface) and clip on any window's button.
+
+These verbs inspect any icon you pass and return an `iconCheck` that explains, in plain terms, why an icon renders as a generic white document (the 256px `.ico` size must be PNG-compressed; SVG does not load; a `.ico` uses a bare path while a `.exe` uses `path,index`), so the caller fixes the file instead of shipping a broken icon. The full guide ships as the `adom-desktop-window-identity` skill.
+
+## Settings
+
+![Settings](docs/settings.png)
+
+Auto-connect behavior, the update channel (**prompt** / auto / off) and *restart when idle to finish updates*, the shell-approval preference, Hydrogen Desktop integration (including *exit when HD exits*), and runtime prewarming. While embedded in HD, HD-owned preferences show as *managed by Hydrogen Desktop*.
+
+## Security and the approval gate
+
+![Shell approval bar](docs/approve-bar.png)
+
+**The trust boundary is install-consent plus the localhost bind.** Installing the app (and each bridge) is you granting permission for the AI to drive your machine; if you do not want that, uninstall. And the app plus every bridge binds `127.0.0.1` **only, never `0.0.0.0`**, so a caller must already be a program you installed on your own machine to reach it. No network attack surface by construction.
+
+On top of that, the **remote cloud-AI path is approval-gated.** Verbs that run code or change the machine (`shell_execute`, `run_script`, `launch_process`, `write_file`, `registry_*`, `process_kill`, and so on) require a human grant when they arrive over the *relay*: **+1hr / +24hr** (time-boxed), **Bypass** (indefinite), or **Revoke**. The grant is human-only; local callers (your CLI, HD, bridges) are trusted by transport and never prompt; read-only verbs are never gated.
+
+Deep dive: [permissions](docs/permissions.md) (what each Approve choice means, and how to revoke).
+
+## Notifications
+
+AD speaks to you through native Windows toasts via `notify_user`, under Adom Desktop's own identity (the Adom logo, and Action Center history like any first-class app). A cloud AI or a bridge, even one on a different machine addressed with `target`, fires them to reach you wherever you are. Every image below is the real toast, captured live from the exact call shown.
+
+**Basic alert.** Awareness: a build finished, a file is ready. Auto-dismisses in about 7 seconds. Add `"durationLong":true` to hold it around 25 seconds; `"level":"error"` and up do that automatically, and `"emergency"` also flashes the taskbar.
+
+![Basic alert toast](docs/toast-basic.png)
+
+```bash
+adom-desktop notify_user '{"title":"Export done","body":"gerbers.zip is ready in C:/exports."}'
+```
+
+**Click-to-focus: send them where the notification points.** Most notifications are about something else on screen: a browser waiting on a login, a dialog, an app that needs a click. Pass `focus` and clicking the notification brings THAT window to the front, instead of opening Adom Desktop. Name the window by `hwnd` (exact, from `desktop_list_windows`), by `cacheKey`, or by `titleContains`.
+
+```bash
+adom-desktop notify_user '{"title":"Sign-in needed","body":"Confirm in the browser and I will continue.","focus":{"titleContains":"Sign in - Google Chrome"}}'
+```
+
+The response says whether the target resolved, so the AI finds out at send time rather than you finding out by clicking a notification that does nothing. If the window is gone by the time you click, it falls back to opening Adom Desktop, so a click is never dead. The click also counts as an acknowledgement, so the AI learns you engaged even with no buttons on the toast.
+
+**Sticky choice with buttons that round-trip.** Stays on screen until the user clicks (no auto-dismiss). Give it an `id` you choose, then read the click back.
+
+![Sticky toast with buttons](docs/toast-buttons.png)
+
+```bash
+adom-desktop notify_user '{"id":"deploy-42","title":"Ship v2 to production?","body":"The build is green and staged.","scenario":"reminder","buttons":[{"label":"Ship it"},{"label":"Hold"}]}'
+```
+
+**Progress bar.** For a long job (a gerber export, a render). Re-fire with the SAME `id` as the job advances toward `1.0`. `body` is optional here.
+
+![Progress bar toast](docs/toast-progress.png)
+
+```bash
+adom-desktop notify_user '{"id":"job-1","title":"Exporting gerbers","progress":{"title":"myboard.kicad_pcb","value":0.6,"status":"Plotting copper layers...","valueOverride":"6 / 10 layers"}}'
+```
+
+**Reply input.** The text field renders, but Windows does not return typed text over protocol activation, so it is display-only today. For a real choice, use buttons.
+
+![Reply input toast](docs/toast-input.png)
+
+```bash
+adom-desktop notify_user '{"title":"Quick note","body":"Leave a note for the next run.","scenario":"reminder","inputs":[{"id":"reply","type":"text","placeholder":"Type a reply..."}],"buttons":[{"label":"Send"}]}'
+```
+
+**Shell-approval.** Fired by AD itself when a gated command needs a human: *Approve 1 hr / 24 hr / Bypass Permissions*, granted straight from the toast without opening the window.
+
+![Shell approval toast](docs/approve-bar.png)
+
+**How the AI tracks the response, or expires the notify.** A toast is fire-and-forget, but a clicked button is recorded durably, so the AI learns the outcome even on a headless box:
+
+```bash
+adom-desktop notify_response '{"id":"deploy-42"}'   # pending:true until clicked, then action:"Ship it"
+adom-desktop notify_events '{"sinceSeq":0}'          # drain EVERY click since a cursor, no per-id polling
+adom-desktop notify_dismiss '{"id":"deploy-42"}'     # retract a sticky toast once you have handled it
+```
+
+Or skip the polling entirely with `"wait":true`, which blocks until you act (or `timeoutSec` elapses) and hands the AI your answer directly.
+
+Clicking the toast body brings the window named by `focus` to the front, or opens Adom Desktop when no `focus` was given. Button clicks deliberately do not steal focus: a toast exists to reach you without interrupting what you were doing.
+
+The AI does not have to read any of this first. When a call leaves something on the table, such as no `focus`, or buttons on a toast that will vanish before you see it, the response says so and lists what the verb can do. That coaching stops once the AI gets it right. Full reference lives in the `adom-desktop-notify` skill.
+
+Deep dive: [notifications](docs/notifications.md).
+
+## Verbs
+
+![Core vs bridge verbs](docs/core-vs-bridge.svg)
+
+AD-core owns the generic verbs (`desktop_*`, `shell`, `ui_*`, files, `notify_user`, multi-AD). Each bridge owns its own prefix and ships its own verbs; the app relays them verbatim and stays generic. Invoke the same way on every transport, for identical JSON back:
+
+```bash
+adom-desktop screenshot_window '{"hwnd": 12345}'              # CLI (relay / direct API)
+adom-desktop --target AzureVM list_windows                    # target a named machine
+curl -s -X POST 127.0.0.1:47200/command \
+  -d '{"command":"notify_user","args":{"title":"Hi"}}'        # loopback direct API
+```
+
+`adom-desktop help` and `GET /commands` are the live discovery surfaces for the full verb set.
+
+Deep dive on the CLI itself: [the CLI guide](docs/cli.md).
+
+## Versioning
+
+This package version tracks the Adom Desktop release. The scaffold is small; the multi-MB binaries are fetched at install time with SHA256 verification (the manifest's `assets` map carries server-computed checksums per platform).
SKILL.md+2246−2246
@@ -1,2246 +1,2246 @@-----name: adom-desktop-description: Use when the user wants to send files to their desktop, control KiCad or Fusion 360, send desktop notifications, or troubleshoot the desktop connection. Provides CLI tools for bridging the Docker container to the user's local machine.------# Adom Desktop--Bridge between Claude Code (running in an Adom Docker container) and the user's desktop applications via WebSocket.--**Install surface:** the canonical install page is `apps/adom-desktop` on the Adom wiki — it hosts the Linux CLI (`docker_binary`), the Windows installer (`.exe`), and the four sibling skills (pup, kicad-bridge, fusion-bridge, + this one). The gallia `adom-desktop-discovery` skill surfaces this page on any related user query. `adom-desktop setup_desktop` also returns a dynamic installer URL pulled from the latest GitHub release.--**First-time setup?** If the user hasn't installed the desktop app yet, see the Setup section below to walk them through downloading, installing, and connecting the app.--Quick check if desktop is connected:-```bash-adom-desktop ping-```--**Companion skills** (installed alongside this one from the wiki):-- `adom-desktop-kicad` — KiCad bridge: launch editors, open designs, install libraries, run DRC, window capture + keyboard/click automation (`plugins/kicad/SKILL.md`)-- `adom-desktop-fusion` — Fusion 360 bridge: launch, open designs, STEP/GLB/.lbr import-export, BOM + API queries, Fusion screenshots (`plugins/fusion360/SKILL.md`)-- `pup` — browser automation (Puppeteer-style): open URLs, screenshot, eval JS, multi-session Chrome (`gallia/skills/pup/SKILL.md`)--## How It Works--```-Claude Code -> adom-desktop <command> -> Relay Server (HTTP :8766) -> WebSocket :8765 -> Adom Desktop App -> KiCad / Fusion 360 / Browser / Shell-```--The `adom-desktop` binary is a single Rust CLI that does everything:-- `adom-desktop serve` -- Start the relay server (WebSocket :8765 + HTTP API :8766)-- `adom-desktop <command> '<json>'` -- Send commands to the desktop app via the running relay--The relay server runs in the Docker container. The Adom Desktop app runs on the user's PC and connects via WebSocket.--## Starting the Relay Server--The relay must be running before the desktop app can connect. **In a container, start it SUPERVISED** (v1.9.168+):--```bash-adom-desktop serve --supervise-```--**Why not `adom-desktop serve &`?** A backgrounded (`&`) relay is still a child of your shell/session. When your session is torn down (you finish, or the container reaps it), the relay dies with it — and then every connected desktop hits HTTP 500 on `/proxy/8765/` and loops "Reconnecting in 30s" **for hours** until a human restarts it (wiki issue #24, hit live 2026-07-23). `--supervise` **detaches the relay into its own session (reparented to init)** so it survives your session, and starts a watchdog that:-- restarts the relay with **exponential backoff** (2s → … → 5 min cap; a clean run resets it — a broken relay is never bludgeoned), and-- **toasts the connected desktops on every restart** with the reason from the relay log, so the owner finds out something went wrong.--```bash-adom-desktop serve --supervise            # detached + self-healing (use this in containers)-adom-desktop serve --supervise --owner AdomLapper   # send restart toasts to one desktop-adom-desktop serve --stop                 # stop the watchdog + relay-```--`serve` (plain, foreground) still works and prints a loud hint telling you it will die with the session — fine for a quick local relay you'll sit and watch. **Caveat:** a full CONTAINER REBOOT still needs a boot hook (cron `@reboot` / the container's init); the watchdog covers session teardown, not host reboot.--This starts:-- WebSocket server on `0.0.0.0:8765` (desktop app connects here)-- HTTP API on `127.0.0.1:8766` (CLI commands go here)--Check if it's running:-```bash-curl -sf http://127.0.0.1:8766/health-```--## First-Time Setup (Install & Connect)--**You are running on a Docker container. You have NO access to the user's desktop.** Guide them step-by-step, ask questions, wait for answers, and verify each step.--> **As soon as you're connected, run `adom-desktop sync_skills` (v1.9.37+).** Installing the-> adom-desktop core pkg gives you only CORE skills — it does NOT give you a DYNAMIC/cloud-> bridge's skill (kicad, fusion, blender, any third-party). `sync_skills` runs `bridge_list`-> and installs each bridge's own skill pkg into `~/.claude/skills/`, so you can actually-> discover + drive whatever bridges THIS desktop has. (The CLI also runs this automatically-> once/day in the background, but doing it on connect makes the bridge skills available to-> the current session immediately.) Re-run it any time the desktop gains a bridge;-> `adom-wiki pkg update` keeps them fresh.--### Step 1: Check if already connected--```bash-adom-desktop ping-```--If this returns `{ "status": "connected" }`, the desktop is already set up -- skip to "Verify the connection" below. If it errors, continue.--### Step 2: Ensure the relay is running--```bash-curl -sf http://127.0.0.1:8766/health-```--If not running (in a container, ALWAYS use `--supervise` so it survives your session): `adom-desktop serve --supervise`--### Step 3: Ask what OS they use--Ask the user: **"What operating system is your desktop/laptop? (Windows, Mac, or Linux)"**--- **Windows 10/11** -- proceed-- **macOS / Linux** -- "Support is coming soon. The desktop app currently only runs on Windows."--### Step 4: Install & connect--Run `adom-desktop setup_desktop` — it auto-detects this container's relay URL and returns a ready-to-run **`register_command`**, a fallback `server_config` JSON, and the `installer_url`. Prefer the zero-paste path:-------**Option 0: Zero-paste via your Adom account (PREFERRED)**--Run the **`register_command`** that `setup_desktop` returned (it's `adom-desktop register_container` with the name + URL filled in). That injects this connection into the **user's Adom cloud profile**. Then the user just:-1. Installs Adom Desktop (from `installer_url`) and opens it,-2. **Signs in with their Adom account** (Connections panel → "Sign in to Adom"),--and the connection appears in their list and auto-connects — **no JSON paste at all**. This is the modern path and the one to try first. (`register_container` needs an Adom cloud container — it uses the mounted account token — and returns `ok:false` on a non-Adom box; if so, use Option A/B below.)--**Already-running desktop? `server_sync` (v1.9.215+).** A signed-in AD reads the cloud profile at boot, at sign-in, and hourly — and `adom-desktop server_sync` (optionally `--target <name>`) makes it re-read RIGHT NOW. The merge is cloud-wins, whole entry including `authToken`. So the flow for adding a server or **rotating a relay token** on live desktops is: update the cloud key (`register_container` writes them) → `server_sync` per desktop → verify with `server_list`. Do NOT use `server_remove` + `server_add` for rotation — removing the entry you are talking through severs your own transport (wiki #387).-------**Option A: Automatic setup via Claude Code (paste fallback)**--If you have **Claude Code** or **Claude Desktop** on your laptop, paste this prompt:--> Install Adom Desktop and connect it to my cloud container. (Most users instead get Adom Desktop bundled with Adom Hydrogen, which installs and connects it for them; use this prompt when you are driving the desktop from a cloud container.) Download the installer from `<installer_url>`, run it silently, then write this JSON to `%USERPROFILE%\.adom\config.json`:-> ```json-> {"servers":[<server_config JSON>]}-> ```-> Then launch "Adom Desktop" from the Start Menu.--*(Replace `<installer_url>` and `<server_config JSON>` with the actual values from `setup_desktop`.)*--**Option B: Manual setup**--1. Download the installer: **[<installer filename>](<installer_url>)**-2. Run it and follow the prompts — the app installs to the Start Menu as "Adom Desktop"-3. Open **Adom Desktop** from the Start Menu-4. Paste this JSON into the text field that says **"Paste server JSON to add ..."** and press **Enter**:-   ```json-   <server_config JSON>-   ```-5. The server will appear and auto-connect-------Wait for the user to confirm they see a green dot next to the server name before proceeding.--**Important:** Each container has its own relay server. New containers need a new entry -- old entries from previous containers won't work.--### Step 6: Verify the connection--```bash-adom-desktop ping-# Expected: { "echo": "pong", "roundTripMs": ..., "status": "connected" }--adom-desktop status-# Expected: one client with the user's hostname and capabilities--adom-desktop notify_user '{"title":"Hello from Docker!","body":"Your desktop is connected."}'-```--Tell the user what you see. If `ping` succeeds:--> "Your desktop is connected! I can now send files to your machine, open browser windows for visual debugging, control KiCad and Fusion 360, take screenshots of your desktop, and send you notifications."--### Step 7: Optional -- Node.js for browser features--Ask: **"Would you like to use the visual browser debugging feature? This opens Chrome windows on your desktop that I can control."**--- **Yes + Node.js installed** -- "Great, the browser bridge auto-installs deps on first use."-- **Yes + no Node.js** -- "Download Node.js from https://nodejs.org (LTS), install it, then restart Adom Desktop."-- **No** -- skip, they can enable it later--### Common connection issues--| Symptom | Fix |-|---------|-----|-| `ping` returns `"No desktop client connected"` | User hasn't added this container in the desktop app yet |-| Desktop app shows "disconnected" | Check the URL uses `wss://` (not `ws://` or `https://`), port is `8765` |-| Relay server not running | `adom-desktop serve --supervise` (survives your session; see Starting the Relay Server) |-| Multiple stale connections | `adom-desktop kick_all` -- app auto-reconnects within seconds |-| Desktop app not installed | Download from [github.com/adom-inc/adom-desktop/releases](https://github.com/adom-inc/adom-desktop/releases) |--## CLI Tool--```bash-adom-desktop <command> '<json-args>'-```--**Examples:**-```bash-adom-desktop ping-adom-desktop status-adom-desktop browser_open_window '{"sessionId":"dart2","url":"https://example.com"}'-adom-desktop browser_eval '{"sessionId":"dart2","expr":"document.title"}'-adom-desktop browser_screenshot '{"sessionId":"dart2"}'-adom-desktop browser_list_windows-adom-desktop browser_close_window '{"sessionId":"dart2"}'-adom-desktop notify_user '{"title":"Hello","body":"From Docker"}'-adom-desktop shell_execute '{"command":"echo hello"}'-adom-desktop pull_file '{"filePaths":["C:\\Users\\john\\Downloads\\image.png"],"saveTo":"/tmp"}'-```--**Output:** JSON to stdout. Screenshots save to `/tmp/adom-desktop-screenshots/` and return the file path.--**Config location:** The desktop app stores server config at `~/.adom/config.json` (`%USERPROFILE%\.adom\config.json`). This is separate from the binary — config survives updates and reinstalls.--## Available Commands--**Get the full structured command list with descriptions, args, and prerequisites:**-```bash-adom-desktop commands            # aliases: list_commands, help-adom-desktop --target <name> commands   # + THAT box's installed bridges (installed_bridges_on_target)-```-Returns categorized JSON with every command, its required/optional args, return values, prerequisites, and workflow notes. Run this FIRST when you're unsure what a verb is called.--**Two kinds of verbs (v1.9.103 — don't confuse them):**-- **BUILT-IN AD-core verbs** are always available on every Adom Desktop, no install, NOT bridges. This is where screenshots, windows, input, files, registry, shell, tunnel, update, and notify live. A whole-screen shot is `desktop_screenshot_screen`; one window (with chrome) is `desktop_screenshot_window {"hwnd":N}` or `{"titleContains":"..."}` (get hwnds from `desktop_list_windows`).-- **BRIDGE verbs** (`kicad_`/`fusion_`/`browser_`/`blender_`/`nbrowser_`…) need that bridge installed — see `installed_bridges_on_target` in `commands`, or run `bridge_list`.--If a verb returns `errorCode:"unknown_verb"`, **read the error's `_hint`** — it carries a fuzzy "did you mean" (e.g. `desktop_screenshot` → `desktop_screenshot_screen`) and tells you whether the right verb is built-in or a bridge. Don't conclude a capability is "a missing bridge" from a guessed verb name; call `commands` and check.--### Connection Management--- `ping` -- 5s round-trip test. Use BEFORE browser/shell commands to verify the desktop connection is alive.-- `status` -- Check who's connected, their capabilities, desktop paths, and **app installation status**. The `desktop.apps` object shows:-  - `kicad.installed` / `kicad.version` / `kicad.bridgeRunning`-  - `fusion360.installed` / `fusion360.running` / `fusion360.bridgeRunning` / `fusion360.addinInstalled` / `fusion360.addinConnected`-  - `browser.bridgeRunning`-- `kick_all` -- Force-disconnect all WebSocket clients. Active Adom Desktop apps auto-reconnect within seconds.--#### Programmatic server registration (v1.8.22+)--Three verbs let external apps (Adom Hydrogen's WSL2 workspace, in particular) register their relay server with adom-desktop without the user pasting JSON into the GUI by hand. Mirrors the GUI's Quick Add bar + connect/disconnect buttons.--- `server_add` -- Upsert a relay server by `name`. If a server with that name already exists, the URL (and optionally authToken) is updated rather than creating a duplicate.-  ```bash-  adom-desktop server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'-  # → {ok:true, name, url, id, connected:bool, created:bool, _hint}-  ```-  - `name` (required) — dedup key. Repeat calls with the same name are idempotent.-  - `url` (required) — relay WebSocket URL.-  - `authToken` (optional) — defaults to a dev token (leave unset unless your relay requires one).-  - `autoConnect` (optional, default `true`) — connect right after upsert. Set `false` to add the entry without dialling out.-  - Behavior: same URL + already connected → no churn, returns ok. URL changed + autoConnect=true → disconnect old loop + spawn new one. URL changed + autoConnect=false → disconnect old, leave entry registered but not connected.-- `server_remove` -- Disconnect (if connected) and delete an entry by name. Returns `{ok, removed:bool, wasConnected:bool}`. Idempotent (removing a non-existent entry returns ok with removed:false).-  ```bash-  adom-desktop server_remove '{"name":"hydrogen-workspace"}'-  ```-- `server_list` -- Persisted server list with live connection status: `{servers:[{name, url, id, autoConnect, enabled, connected:bool, clientCount:0|1, status}]}`. `status` is the fine-grained `connected | reconnecting | disconnected` state machine value.--Persists to the same `~/.adom/config.json` the GUI uses, so entries survive a GUI restart. The `ws_client` supervisor (runs every 30s inside the GUI) auto-reconnects entries with `autoConnect:true` on next launch.--HD-style usage:-```bash-# At Hydrogen container startup-adom-desktop server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'--# At Hydrogen container shutdown-adom-desktop server_remove '{"name":"hydrogen-workspace"}'-```--#### Addressing multiple Adom Desktops on one relay (v1.8.133+)--One relay can have **several** Adom Desktops connected at once — e.g. a laptop and an Azure VM, eventually many. Each AD has a **name** (its `--client-name` / Settings value, defaulting to the OS hostname). The cloud CLI addresses a specific one, or fans out to all:--- `adom-desktop targets` — list connected ADs: `{count, targets:[{name, hostname, version, os, connectedAt}]}`. Discover names here first.-- `adom-desktop --target <name> <verb>` — route the verb to that AD only. `--target` is a **global** flag (goes before the verb; case-insensitive; matches `clientName`, else `hostname`). `ADOM_TARGET=<name>` sets a default (the flag wins).-- `adom-desktop --target all <verb>` — fan out to **every** connected AD; returns `{broadcast:true, targets:[...], results:{<name>:<verb-result>}, summary:{total,ok,failed}}`. A per-AD timeout/failure is isolated to that AD's entry.--```bash-adom-desktop targets-adom-desktop --target azurevm desktop_screenshot_screen-adom-desktop --target all hd_status        # every box at once, results keyed by name-```--**No-target behavior:** with exactly ONE AD connected, no `--target` is needed (back-compat). With MULTIPLE connected and no `--target`, the call FAILS with `{error:"ambiguous_target", connected:[...], _hint}` (exit 1) — it won't guess which box. A bad name → `{error:"target_not_found", requested, connected:[...]}` (exit 1). Set each AD's name with the `--client-name <name>` launch flag (Hydrogen passes this when spawning the embedded AD), the Settings field, or `config.json` `client_name`. Full design + the relay-routing internals: [`skills/MULTI_AD_ADDRESSING.md`](MULTI_AD_ADDRESSING.md).--#### Direct HTTP API for sibling apps (v1.8.25+)--If you're authoring a sibling Tauri app on the same machine (Adom Hydrogen, or any future "Adom-family" app), you can skip the CLI binary entirely and POST commands straight into the running adom-desktop GUI on `127.0.0.1:47200` (was `127.0.0.1:8770` through v1.8.32 — moved to be a better neighbor to Hydrogen on 47080+ and avoid the 8000-range collision risk). The endpoint runs inside the GUI process (loopback-only bind), uses the same dispatcher the WS path uses, and returns the same JSON shape as `adom-desktop <verb>` — including every `_hint` field.--| Method | Path | Body | Returns |-|---|---|---|---|-| `GET` | `/health` | — | `{"ok":true,"service":"adom-desktop"}` cheap probe |-| `GET` | `/status` | — | service banner + version + schema + `directApi.cliRequired` list |-| `POST` | `/command` | `{"app":"<ns>","command":"<verb>","args":{...}}` | The verb's normal payload (200 OK), or `{error, errorCode, _hint}` (4xx/5xx) |--**v1.8.33+ port discovery (sibling apps READ THIS):** Don't hardcode 47200. The GUI may have bound to 47200-47209 instead if the default port was taken (zombie socket, dev instance, third-party collision). Discovery protocol:--1. Read `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`) — single-line `host:port`, written by the GUI at bind time, removed at graceful shutdown-2. If file missing or its port doesn't `/health`, scan `127.0.0.1:47200..=47209` for any port answering with `{"ok":true,"service":"adom-desktop"}`-3. Validate `service == "adom-desktop"` to disambiguate from other apps that might bind a port in our range--The CLI does this automatically (see `cli/src/direct_probe.rs`). For sibling-app Rust code, see [`skills/DIRECT_API.md`](DIRECT_API.md) for a copy-paste-ready helper.--**Per-verb timeouts (v1.8.31+):** the direct API's command-timeout is no longer a hardcoded 120s — it mirrors the CLI dispatcher's per-verb table (`walk_cloud_tree`/`search_cloud_files`=620s; heavy exports=320s; `bridge_install`/`fusion_start`=300s; default=120s). Caller can override via `args.timeout` (seconds, clamped to 1800).--Example — programmatic server registration without the CLI:--```bash-curl -X POST http://127.0.0.1:47200/command \-  -H 'Content-Type: application/json' \-  -d '{"app":"desktop","command":"server_add","args":{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}}'-# → identical JSON to `adom-desktop server_add '{...}'`, including the _hint-```--**What's safe to send directly:** essentially everything sync (`server_*`, `bridge_list`, `hd_status`, `hd_build_status`, `desktop_list_windows`, `desktop_screenshot_*`, `kicad_*`, `fusion_*`, `browser_screenshot`, `notify_user`, ...) plus async-dispatching verbs that return a job id in <500 ms (`bridge_install`, `hd_build`, `desktop_install_kicad`, ...).--**What requires the CLI fallback:** verbs returning a structured `errorCode:"cli_required"` from this endpoint — currently `pull_file`, `send_files`, `shell_execute`. These use binary streaming or multi-minute approval polling that doesn't fit a single synchronous HTTP request. `GET /status` returns the full list at runtime so callers can branch defensively.--Full integration guide (loopback trust model, recipes for Hydrogen startup/shutdown, retry/backoff patterns, port-discovery code in Rust + TypeScript) lives in [`skills/DIRECT_API.md`](DIRECT_API.md) in this repo, and as a wiki asset attached to `apps/adom-desktop`.--#### Embedded mode — AD bundled inside Adom Hydrogen (v1.8.42+)--When Adom Hydrogen (Hydrogen) bundles AD, AD enters "embedded mode" at boot. Hydrogen owns the system-tray icon (AD's is suppressed), Hydrogen owns the Adom Cloud login (Hydrogen's session token is handed to AD via Phase 4's existing channels), Hydrogen owns auto-updates (AD's wiki-poll is disabled — Hydrogen bundles a fresh AD on every Hydrogen release), and Hydrogen spawns AD hidden (its tray "Open Adom Desktop" surfaces the window on demand). Standalone AD users see zero change — none of the signals fire.--**Detection** — three-signal cascade (any one triggers embedded mode):--1. `--embedded` CLI flag (what Hydrogen passes on every spawn)-2. `ADOM_EMBEDDED=1` env var (backup channel)-3. `%LOCALAPPDATA%\Adom Desktop\embedded.json` marker file (survives AD restarts; written by Hydrogen's installer; deleted by Hydrogen's uninstaller)--**CLI flags Hydrogen passes when spawning AD**:--```bash-adom-desktop.exe --embedded --start-hidden \-  --relay-url ws://127.0.0.1:8765 --relay-name adom-hydrogen \-  --session-token <hydrogen's-stored-token>-```--- `--embedded` → enter embedded mode (also writes the marker)-- `--start-hidden` → boot with main window invisible. **v1.8.119+: honored in embedded mode** (Hydrogen owns the surface; AD stays in the tray and opens via `desktop_window_show`). v1.8.102–118 had a bug where embedded mode force-showed the window even with `--start-hidden`; that's fixed. The machine owner can still flip AD's "Always show the window at launch" Settings checkbox (config `always_show_window_on_launch`, default OFF) to override and always show — a personal-dev-box preference, not the product default.-- `--shell-approval <prompt|1h|24h|permanent>` → Hydrogen's passthrough of its user's shell-approval choice (v1.8.116+, session-only)-- `--relay-url <url>` → upsert this relay via existing `server_add` dedup; default name `adom-hydrogen`-- `--relay-name <name>` → override the default name-- `--session-token <tok>` → already covered by Phase 4 handoff (env / CLI arg / file)--**Docker introspection** — `adom-desktop desktop_embedded_status` returns `{embedded, owner, source, enteredVia, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` so cloud-side callers can branch on whether AD is standalone or HD-managed. **v1.8.73+:** `enteredVia` reports `"launch-flag"` (Hydrogen spawned AD as a child via `--embedded`) or `"runtime-adopt"` (AD was running standalone; Hydrogen adopted it at runtime) — see "Runtime adopt + release" below.--**New verbs Hydrogen uses to drive AD** (all also usable standalone):--| Verb | Purpose |-|---|---|-| `desktop_window_show` | Bring AD's main window to foreground |-| `desktop_window_hide` | Hide AD's main window (app keeps running) |-| `desktop_connect_all` | Spawn ws_loop for every enabled server |-| `desktop_disconnect_all` | Drop every live WS connection (entries stay in config) |-| `desktop_shutdown` | Explicit AD termination (stop bridges, then exit) |-| `desktop_embedded_status` | Introspect current embedded state (incl. `enteredVia`) |-| `desktop_embedded_enter` | **v1.8.73+** runtime adopt: standalone → embedded as `runtime-adopt` |-| `desktop_embedded_release` | **v1.8.73+** revert to standalone. **v1.8.77+: always reverts, never exits AD** (regardless of `entered_via`) — use `desktop_shutdown` for explicit exit |-| `desktop_logout` | Clear AD's `~/.adom/session.json` (Hydrogen's sign-out propagation) |--#### Runtime adopt + release (v1.8.73+) — the LaunchFlag-vs-RuntimeAdopt distinction--Hydrogen has two ways to put AD into embedded mode:--1. **Spawn AD with `--embedded`** (LaunchFlag) — Hydrogen launches a fresh AD process as its child. AD enters embedded with `entered_via=launch-flag`. When Hydrogen subsequently dies ungracefully (without calling `/embedded/release` or `desktop_shutdown` first), AD's 60s `embedded_monitor` tick notices Hydrogen's control port is dead and **exits AD** (the "no hanging chad" rule — no tray-less orphan).-2. **Adopt at runtime via `POST /embedded/enter`** (RuntimeAdopt) — AD was already running standalone; Hydrogen calls the direct-API endpoint to flip AD into embedded mode without a respawn. AD enters embedded with `entered_via=runtime-adopt`, preserving its open state, current PID, all in-flight connections. When Hydrogen subsequently dies ungracefully, AD's monitor **reverts AD to standalone** — tray restored, footer pill hidden, marker deleted. AD process stays alive.--The rule is simple: **whoever started AD owns AD's death**. If AD started itself (standalone → runtime adopt), Hydrogen's death doesn't take AD down with it.--**Two new direct-API endpoints** (v1.8.73+, sugar over `POST /command`):--```bash-# Adopt — runs the same handler as desktop_embedded_enter:-curl -s -X POST http://127.0.0.1:47200/embedded/enter--# Release — runs the same handler as desktop_embedded_release.-# v1.8.77+: ALWAYS reverts to standalone. AD never exits via this call.-curl -s -X POST http://127.0.0.1:47200/embedded/release-```--**Probe target (v1.8.75+):** the 60s `embedded_monitor` checks Hydrogen's *control port* (default `47084`, discovered from `%APPDATA%\adom-hydrogen\ports.json` key `control`), not Hydrogen's legacy `:9001` webview-eval port. Same discovery `hd_api` uses. Pre-v1.8.75 this was hardcoded to `:9001` — Hydrogen doesn't bind that port anymore, so the probe always reported Hydrogen dead and every embedded AD reverted/exited within 60s of Hydrogen starting.--**Live frontend update (v1.8.76+):** the "Embedded · Hydrogen" footer pill responds to Tauri `mode-changed` events emitted on every transition. So if AD's window is open when Hydrogen adopts it (or Hydrogen releases it), the pill flips immediately — no page reload, no manual refresh.--**Stale-marker safety (v1.8.74+):** if the marker file says `entered_via=launch-flag` AND Hydrogen's control port isn't responding at boot, AD deletes the marker BEFORE exiting. Without the delete, every subsequent manual launch (no `--embedded` flag) would hit the same exit branch and AD would be unlaunchable until the user manually rm'd the marker. The delete-before-exit makes the next launch start standalone normally.--**Shell auto-approve is a user preference** (v1.8.116+ — replaces the v1.8.44 embedded-permanent default). The safe default everywhere is per-command approval. Resolution order at AD boot:--1. `--shell-approval <prompt|1h|24h|permanent>` launch flag — Hydrogen's passthrough of its user's choice when spawning AD embedded. **Session-only**: never persisted by AD.-2. The durable Settings checkbox ("Auto-approve shell commands permanently", config field `shell_auto_approve_permanent`, default OFF). Also set/cleared via `shell_auto_approve '{"permanent": true|false}'`.-3. A restored timed grant from a previous session (+1h/+24h buttons, `duration_secs` calls).-4. Otherwise: prompt per command.--In embedded mode **Hydrogen owns the preference** (Hydrogen is the parent, AD the child): AD's Settings checkbox goes read-only with "Managed by Adom Hydrogen", and the footer shows "**Permanent** *(managed by Adom Hydrogen)*" when armed. Hydrogen changes it at runtime with the same verb:--```bash-# revoke everything (timed + permanent + the saved checkbox):-adom-desktop shell_auto_approve '{"duration_secs": 0}'--# set the durable preference (equivalent to ticking the Settings checkbox):-adom-desktop shell_auto_approve '{"permanent": true}'-```--Migration note: pre-1.8.116 embedded boots persisted a permanent grant that leaked into standalone sessions. On first v1.8.116 boot, a permanent-class grant on disk WITHOUT the config preference is cleared — re-opt-in via the checkbox or the verb if you actually wanted it.--**⚠ v1.9.90 — a cloud/relay AI CANNOT grant shell approval itself.** The `shell_auto_approve` examples above are Hydrogen's (a trusted LOCAL caller) or the human's own. From a **relay** connection (the cloud AI), a GRANT (`shell_auto_approve {"permanent":true}` / `{"duration_secs":N>0}`) is REFUSED with `errorCode:"human_only"` and instead POPS the human approval prompt in Adom Desktop. **The AI's path: call `request_shell_approval` (or just let the gate/refusal auto-surface the prompt) → ask the user to click Approve 1 hr / 24 hr / Bypass Permissions → RETRY your original verb.** Only a human clicking in AD (or Hydrogen proxying its own user) grants. A REVOKE (`duration_secs:0`) is allowed for the AI.--#### Bridge ports are dynamic — you never need to know one (v1.8.31+)--KiCad, Fusion, Browser/Puppeteer, and all third-party bridges now bind OS-assigned ephemeral ports (not the legacy 8772/8773/8851 you may remember). The runtime port changes every spawn. **Callers never need to know it.**--- The CLI verb namespace (`kicad_*`, `fusion_*`, `browser_*`, plus any third-party `<bridge>_*`) is the contract. Always go through that.-- adom-desktop's direct API forwards to whatever port each bridge is on at the moment of the call.-- `bridge_list` reports `spawn.runtimePort` per bridge for debugging — do NOT hardcode it anywhere.-- Why this changed: Hydrogen's bridges also wanted 8772/8773/8851; we used to collide silently. Dynamic ports = clean coexistence.--### Managing bridges (the AI fully controls bridges on its own)--Bridges are AD's plugins (kicad, puppeteer, fusion360 ship bundled; others install from the wiki). You — the AI — can see, update, install, remove, pause, and resume them entirely through verbs. No human step is required.--| Verb | What it does |-|---|---|-| `bridge_list` | Every installed bridge: `name`, `displayName`, `version` (what AD runs now), `source` (bundled / cache / third_party), `paused`, spawn kind, verbPrefixes. Start here. |-| `bridge_check_updates` | **READ-ONLY.** Per bridge: `current` vs `latest` (wiki) + `updateAvailable`. Downloads nothing. The "what's behind?" probe. |-| `refresh_bridges` | Apply updates. No args = sync ALL bridges from the wiki. `{"name":"kicad"}` = update just one. |-| `bridge_install` | Install a new third-party bridge from a wiki manifest URL (`{"manifestUrl":"…/<bridge>-bridge-manifest.json"}`). On `success:true` the **capsule appears in the AD GUI immediately** (no verb call needed; it's gray/idle until first use, then green). On `success:false` **nothing is registered and no capsule appears** — read `error` (a `read zip body`/`builder error` means the manifest's zip URL couldn't be fetched). Don't treat a parsed manifest as a completed install; confirm with `bridge_list`. The manifest `url` may be relative (resolved against the manifest's dir, v1.8.169+). |-| `bridge_uninstall` | Remove a bridge (`{"name":"…"}`). Bundled ones revert to the bundled copy; third-party ones are fully removed. |-| `bridge_pause` / `bridge_resume` | Flip a routing flag so a bridge's verbs fail-fast with `bridge_paused` (or resume). Does NOT kill the process. |--**Naming convention for published bridges (3rd-party authors, read this):** when you publish a bridge to the Adom wiki, its **page title / catalog descriptive name MUST start with `Adom Desktop - `** (spaced hyphen) — e.g. `Adom Desktop - Altium Bridge`, matching the bundled bridges (`Adom Desktop - Puppeteer Bridge`, `Adom Desktop - KiCad Bridge`). This keeps every AD bridge grouped with consistent names in app-index pages instead of scattering as bare slugs. It applies to the **wiki/catalog title only** — NOT `bridge.json` `displayName` (the in-app chip label, which stays tool-descriptive like `Altium Designer`) or `name` (the lowercase slug). Full authoring walkthrough: the `adom-desktop-bridge-sdk` skill (the `adom-desktop-bridges` wiki page) + its fill-in-the-blanks templates.--**Typical "am I on the latest bridges?" flow:**-```bash-adom-desktop bridge_check_updates            # read-only: see current vs wiki per bridge-# → {bridges:[{name,current,latest,updateAvailable,source,wikiReachable}], updatesAvailable:N}-adom-desktop refresh_bridges                  # update ALL that are behind-adom-desktop refresh_bridges '{"name":"kicad"}'   # or just one-```--**Version semantics (so the numbers make sense):**-- `current` = what AD actually runs = the newer of the writable cache and the bundled copy (version-aware, numeric semver — `1.0.10 > 1.0.9`).-- A bundled bridge that ships AHEAD of the wiki shows `updateAvailable:false` — AD correctly keeps the newer bundled copy. **puppeteer is bundled-only** (its node_modules deps exceed the wiki file limit), so it never shows a wiki update; it updates with the AD installer.-- After an `updated` action, the bridge SERVER PROCESS still holds the old code in memory until it respawns. `is_bridge_running` is version-aware (v1.8.142+) and forces a respawn on the next verb call, so you normally don't need to kill anything — but if you want it instant: `taskkill /f /im python.exe` (kicad/fusion) or `node.exe` (puppeteer).--**Auto-update cadence:** a running AD also auto-syncs bridges on launch + every 4 hours. `bridge_check_updates` / `refresh_bridges` (or the GUI's **"Check for updates"** button next to "+ Bridge") are the on-demand "I just published, pick it up now" path.--#### Bridge lifecycle — one instance, stable port (v1.8.153+)--A dynamic bridge gets **one stable port** that AD reuses across every spawn and across AD restarts (persisted in `~/.adom/bridge-ports.json`). A long-lived external client (e.g. a browser extension that dials the bridge) keeps reaching the same port — no chasing a fresh ephemeral port each cycle, no "the client is on bridge A but AD routes to bridge B" split. AD enforces a **single instance**: it tracks every spawned bridge PID, reaps a stale/wedged one before respawning on the same port, and `bridge_stop_all` reaps orphans too.--`spawn.persistent` (in `bridge.json`) is now surfaced in `bridge_list` (`spawn.persistent`) and means: **AD auto-respawns this bridge on death**, on its stable port (a supervisor runs each ~15 s). So "kill the bridge to load new code" → AD brings it right back at the same port the client keeps dialing. `bridge_install` / `refresh_bridges` also no longer fail with the cache-dir lock (AD reaps the process holding the dir as its cwd, then swaps; in-place merge fallback if a handle lingers).--**v1.8.186/187 — first-class bridges (panel + remote management + optional manifest fields).** The AD window has a full-height **Bridges sidebar** that shows each bridge as a mini-app (hero image, LED, rich tooltip); clicking one opens a wide pop-out with a **live console** (its `~/.adom/bridge-logs/<name>.log` + the per-bridge command history), a **ports/process debug view** (live PIDs — `>1` flags a runaway), persistent-vs-one-off + codebase-language badges, and all management actions (install/uninstall/**kill**/restart/pause/resume + "filter activity log to this bridge" + "copy contribution prompt"). The same management is available **remotely** to cloud/WSL2 threads via verbs: `bridge_info`/`bridge_detail` (rich aggregate), `bridge_log_read {name, sinceOffset?}` (**cursor-streaming remote tail -f**), `bridge_kill` (wipe one bridge's processes). As of v1.8.186 **all** bridges write their per-bridge log (bundled kicad/fusion360/puppeteer included, not just dynamic ones). New **optional** `bridge.json` fields (all back-compat, surfaced in the panel): `hero` (a relative filename on your wiki page, or a full URL — your app-icon; absent → a monogram tile), `languages` (override the auto-scan, e.g. `["Python"]`), `releasedAt` (ISO date; absent → bridge.json mtime).--**v1.8.156 — strays + self-documenting hints.** AD now tracks every bridge PID it spawns (persisted across AD restarts) and **reaps all stray instances** before respawning — so you don't end up with 4 copies of one bridge fighting over the routing. `bridge_list` surfaces `spawn.instanceCount` per dynamic bridge (normally 0 or 1; **>1 = strays piled up** — visible at a glance). If a bridge verb fails to reach a healthy bridge, the error `_hint` tells you to retry / check `instanceCount` / `process_list`; and a verb response right after a (re)spawn carries an `_ad_note` saying the external host reconnects within a few seconds — **retry** instead of treating a transient "not connected" as terminal. For verb discovery from a bridge, `GET <ADOM_DIRECT_API_URL>/commands` lists every reachable verb (see [`skills/DIRECT_API.md`](DIRECT_API.md)).--#### Bridge status chip — self-reported LED + tooltip (v1.8.152+)--Each bridge has a status dot in the AD GUI sidebar. **AD is a pure renderer of bridge health** — a live bridge decides its own LED color and hover text; AD just paints what the bridge reports. This replaced the old scheme where AD inferred the LED from whether it was holding a spawn handle, which left fire-and-forget (dynamic/installed) bridges stuck RED even while healthy and routing.--**The contract (all three fields OPTIONAL).** On the endpoint AD already polls — your `bridge.json` `healthEndpoint` (default `/health`; the sample bridges use `/status`) — return a 2xx JSON body that MAY include:--| Field | Type | Meaning |-|---|---|---|-| `led` | `"green"` \| `"yellow"` \| `"red"` | The dot color. Green = healthy; yellow = degraded/warning; red = the bridge itself knows it's broken (e.g. lost its upstream). |-| `summary` | string | Short label shown next to the dot. Keep it tiny (≤ ~24 chars) — e.g. `"2 windows"`, `"ext disconnected"`. |-| `tooltip` | string | Full hover text. Newlines allowed; no hard length limit (keep it readable). |--**Rules AD enforces:**-- Endpoint returns 2xx **with** `led`/`summary`/`tooltip` → rendered **verbatim**, no parsing or logic.-- Endpoint returns 2xx **without** them → AD defaults to **green** (you're reachable) + a generic tooltip. Existing bridges work unchanged — this is opt-in.-- Endpoint **unreachable** → AD owns this ONE state: a **gray/offline** dot + AD's own tooltip ("Not running" / "Bridge not reachable"). A dead bridge can't self-report, so AD never lets a bridge claim "offline" — it detects it.--**Cadence:** AD re-polls every ~15 s, and a dynamic bridge flips green the instant it spawns. So a bridge that surfaces live counters (open windows, queue depth, ext-connection state) in `summary`/`tooltip` updates in the GUI within a tick.--Reference implementations: [`scripts/sample-bridges/hello-python/server.py`](../scripts/sample-bridges/hello-python/server.py) and [`hello-rust/src/main.rs`](../scripts/sample-bridges/hello-rust/src/main.rs) both self-report `led`/`summary`/`tooltip` on `/status` — fork either.--### File Transfer--- `send_files` -- Send files from the Docker container to the desktop. Files are base64-encoded in transit.-  - `filePaths`: array of absolute paths on the server-  - `targetApp`: "kicad", "fusion360", or "general"-  - `dest`: **absolute** Windows path (under a safe root) to land the files exactly where you want (v1.8.148+). Prefer this when you know the target dir.-  - `destinationFolder`: **relative subfolder only** (e.g. `"kicad/symbols"`, `"fusion"`). The desktop app controls the base directory. **Absolute paths are rejected.** With neither `dest` nor `destinationFolder`, files land in the laptop's **Downloads** folder.-  - Returns `destinationPaths[]` with the absolute path of every saved file, plus (v1.8.168) `savedToDir` (the folder) and a rich `_hint`. **The files land on the LAPTOP (Windows host), not in your container** — verify with `list_dir`, pull one back with `pull_file`.-  - **`send_files` only COPIES bytes — it does NOT install or register a bridge.** Dropping bridge files with `send_files` will NOT make a capsule appear or verbs route. To add a bridge use `bridge_install '{"manifestUrl":"…/<bridge>-bridge-manifest.json"}'`, then confirm with `bridge_list`.--- `pull_file` -- Pull files from the desktop to the container.-  - `filePaths`: array of absolute Windows paths on the desktop-  - `saveTo`: directory on the container to save files (default: `/tmp`)-  - **Streaming since v1.4.3.** Each file is transferred as 1 MiB binary WS frames straight to disk on the Docker side, with incremental SHA256 verification. The legacy 30s base64-JSON path is gone — large files (50 MB+ datasheets, 75 MB reference manuals) no longer time out. Per-file timeout is 600s.-  - Returns `files: [{name, path, size, sha256, chunks}]`. Use `sha256` to verify the transfer (the desktop side computes it during streaming and the container side verifies on completion; mismatch deletes the partial file and reports failure). `chunks` is the count of 1 MiB binary frames received.-  - When at least one but not all files succeed: `success` is `true`, `errors[]` lists the failures alongside `files[]`. When ALL fail: `success` is `false`.--### Desktop Notifications--- `notify_user` -- Send a native Windows toast (own AUMID + Adom logo). v1.9.33: rich types — progress bar, sticky reminder, action buttons that **bring AD to the foreground** on click + round-trip via `notify_response`.-  - `title`, `body`, `level` (info/success/warning/error/emergency), `actions` (label buttons) — plus `scenario` ("reminder"=sticky), `durationLong`, `buttons` (`[{label, arguments?}]`), `progress` (`{title, value 0..1, status, valueOverride}`), `inputs`, `id` (correlation id for the round-trip).-  - Full guide + copy-paste examples per type: the **adom-desktop-notify** skill ([`skills/NOTIFY.md`](NOTIFY.md)).-- `notify_response` -- Poll which toast button the user clicked, by correlation `id`. `{pending:true}` until they click; `{pending:false, action}` once they do (consumed on read).--### KiCad (`kicad_*`) — bridge-owned; light pointer--The KiCad bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/adom-desktop-kicad-bridge`). This page keeps only the pointer: run `adom-desktop sync_skills` after connecting and the full KiCad guide (project workflows, three-tier `kicad_open_*` routing, DRC recipes, library install) lands in `~/.claude/skills/`.--Most-used verbs to get moving:--```bash-adom-desktop kicad_open_board '{"path": "C:/projects/board/board.kicad_pcb"}'-adom-desktop kicad_screenshot_all '{}'          # every KiCad window incl. error dialogs, as an array-adom-desktop kicad_run_drc '{}'-adom-desktop kicad_window_info '{}'-adom-desktop kicad_open_symbol_editor '{"library": "Device"}'-```--If KiCad is not installed, the bridge knows how to install it for the user — offer, never point them at a download page. Full details: the bridge's own skill.--### Fusion 360 (`fusion_*`) — bridge-owned; light pointer--The Fusion 360 bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/adom-desktop-fusion-bridge`). Run `adom-desktop sync_skills` and the full guide (cloud files, import/export, add-in troubleshooting, APS search) lands in `~/.claude/skills/`.--Most-used verbs:--```bash-adom-desktop fusion_start '{}'                       # launches Fusion; slow on cold start (minutes)-adom-desktop fusion_get_app_state '{}'               # the status/poll verb-adom-desktop fusion_addin_status '{}'-adom-desktop fusion_dismiss_blocking_dialogs '{}'-adom-desktop fusion_send_key '{"key": "Escape"}'-```--Fusion launches take 2-4 minutes cold; respect `timeoutSeconds` + `statusVerb` on responses instead of re-issuing. Full details: the bridge's own skill.--### Launching apps (generic)--Two generic CLI commands exist for launching any Windows executable safely — they verify the target exists BEFORE handing it to the OS, so you never trigger a "Windows cannot find" GUI dialog:--- `adom-desktop find_exe '{"name":"..."}'` -- Resolve an exe by absolute path, glob (e.g. `C:/.../webdeploy/production/*/Fusion360.exe` returns newest), bare name (searches PATH + Start Menu `.lnk` targets). Returns `{path, source}`. Does NOT launch.-- `adom-desktop launch '{"path":"...", "args":[...], "cwd":"...", "detached":true}'` -- Same resolution rules as `find_exe`, then spawns. Fails in terminal (exit 1) with a clear error if the path doesn't exist. **Always prefer this over raw `start`.**--For Fusion specifically, use `fusion_start` — it wraps `launch` plus the full startup-picker / add-in-readiness dance.-- `fusion_import_step` -- Import a STEP/STL/IGES file into Fusion 360-- `fusion_open_lbr` -- Open an EAGLE .lbr library file-- `fusion_open_electronics` -- Check if the Electronics workspace is active-- `fusion_electron_run` -- Execute any EAGLE command via Electron.run. Returns rich state: `activeWorkspace`, `activeDocument`, `commandType`, `_hint`, and `fusionOperations` (diff of Fusion's internal operation log showing what actually fired). **Avoid blocking commands** — see list below. **Full EAGLE command reference:** [`skills/eagle-commands.md`](./eagle-commands.md) — popular/safe commands split from blocking/modal ones, with layer reference and chaining syntax.-- `fusion_execute_text_command` -- Low-level app.executeTextCommand() access. Returns the command result plus workspace context.-- `fusion_board_info` -- Get structured board data from the open PCB layout. Returns: component placements (name, package, x, y, rotation), net names, copper traces, layer setup, board thickness, DRC violations. Much richer than a screenshot — gives exact coordinates and connectivity. Requires a .brd board open in PCB Editor.--#### `fusion_electron_run` — EAGLE Command Execution--Executes EAGLE commands inside Fusion 360's Electronics workspace. Works in **Schematic Editor**, **PCB Editor (Board Layout)**, and **Electronics Library** contexts.--**How it works:** Sends the command string via Fusion's `Electron.run` text command. EAGLE's `Electron.run` is fire-and-forget — it never returns output or throws on invalid commands. To compensate, the handler snapshots Fusion's internal operation log (`Diagnostics.RecentOperations`) before and after execution, returning a diff showing what actually fired.--**Usage:**-```bash-# Basic command-adom-desktop fusion_electron_run '{"command": "WINDOW FIT"}'--# Multiple commands in sequence (use semicolons)-adom-desktop fusion_electron_run '{"command": "DISPLAY NONE; DISPLAY 1 16 17 18 20 21"}'--# Navigate in library editor-adom-desktop fusion_electron_run '{"command": "EDIT SOIC8.pac"}'-adom-desktop fusion_electron_run '{"command": "EDIT RESISTOR.sym"}'-adom-desktop fusion_electron_run '{"command": "EDIT MYDEVICE.dev"}'-```--**Response fields:**-- `activeWorkspace` — Current workspace (Schematic Editor, PCB Editor, Electronics Library)-- `activeDocument` — Name of the open document-- `commandType` — Detected type: `view_control`, `layer_control`, `edit`, `design_rule`, etc.-- `editorType` — For EDIT commands: `package`, `symbol`, `device`-- `fusionOperations` — Array of Fusion operations that fired (diff of internal log)-- `hint` — Human-readable description of what happened-- `rawResult` — Raw return from Electron.run (usually empty)--**EAGLE Command Reference — Safe for automation:**--| Command | Context | Description |-|---------|---------|-------------|-| **View / Navigation** | | |-| `WINDOW FIT` | Any | Zoom to fit all content |-| `WINDOW (x1 y1 x2 y2)` | Any | Zoom to specific area (coordinates in current units) |-| `DISPLAY ALL` | Any | Show all layers |-| `DISPLAY NONE` | Any | Hide all layers |-| `DISPLAY 1 16 17 18 20 21` | Board | Show specific layers by number |-| **Grid** | | |-| `GRID MM 0.1` | Any | Set grid to 0.1mm |-| `GRID MIL 25` | Any | Set grid to 25mil |-| `GRID INCH 0.05` | Any | Set grid to 0.05 inch |-| **Board Layout** | | |-| `RATSNEST` | Board | Recalculate airwires (unrouted connections) |-| `RIPUP` | Board | Remove all routed traces |-| `RIPUP *` | Board | Remove all traces (same as RIPUP with no selection) |-| `ROUTE` | Board | Start auto-router |-| `DRC` | Board | Run design rule check |-| `BOARD` | Schematic | Switch to paired board layout |-| `SCHEMATIC` | Board | Switch to paired schematic |-| **Schematic** | | |-| `VALUE value` | Schematic | Set component value |-| `NAME name` | Any | Rename selected element |-| `SMASH` | Any | Detach name/value labels from components |-| **Library Editor** | | |-| `EDIT name.pac` | Library | Open a package (footprint) for editing |-| `EDIT name.sym` | Library | Open a symbol for editing |-| `EDIT name.dev` | Library | Open a deviceset for editing |-| `EXPORT SCRIPT 'path.scr'` | Library | Export entire library as EAGLE script |-| **Scripting / Settings** | | |-| `SET CONFIRM YES` | Any | Suppress confirmation dialogs |-| `SET CONFIRM OFF` | Any | Re-enable confirmation dialogs |-| `SCRIPT 'path.scr'` | Any | Run batch commands from a .scr script file |--**EAGLE Layer Numbers (commonly used):**--| Layer | Name | What it shows |-|-------|------|--------------|-| 1 | Top | Top copper |-| 16 | Bottom | Bottom copper |-| 17 | Pads | Through-hole pads |-| 18 | Vias | Via holes |-| 19 | Unrouted | Airwires (ratsnest) |-| 20 | Dimension | Board outline (required for 3D) |-| 21 | tPlace | Top silkscreen |-| 22 | bPlace | Bottom silkscreen |-| 25 | tNames | Top component names |-| 27 | tValues | Top component values |-| 29 | tStop | Top solder mask |-| 31 | tCream | Top stencil/paste |-| 51 | tDocu | Top documentation |--**Blocking commands — AVOID from automation:**--| Command | Why it blocks |-|---------|---------------|-| `WRITE` | Opens Save As dialog — use `fusion_save_lbr` or `fusion_close_document` instead |-| `ADD` | Opens component picker dialog |-| `SHOW name` | Opens interactive highlight mode |-| `SET` (no params) | Opens settings dialog |-| `GRID` (no params) | Opens grid settings dialog |-| `EDIT new.sym` | Opens "Create new?" confirmation if symbol doesn't exist |-| `CHANGE` | Opens interactive change mode |-| `MOVE` | Opens interactive move mode |--**Tips:**-- Always run `WINDOW FIT` after opening a file or switching views-- Use `DISPLAY NONE` then `DISPLAY <layers>` to show only specific layers-- Combine commands with `;` — e.g., `SET CONFIRM YES; RIPUP *; RATSNEST`-- For board screenshots: `DISPLAY NONE; DISPLAY 1 16 17 18 20 21; WINDOW FIT`-- Use `fusion_board_info` instead of EAGLE commands when you need structured data-- `fusion_export_lbr` -- Export the open Electronics library as an EAGLE .scr script (note: does NOT include 3D package references — those are cloud-linked only)-- `fusion_save_lbr` -- Save the open Electronics library as a .flbr file--#### EAGLE Libraries with 3D Packages--Fusion 360's .lbr format supports `package3d` elements that link footprints to 3D models. Key facts:--- **3D models are cloud-hosted** — each `package3d` has a `wip_urn` (e.g., `urn:adsk.wipprod:fs.file:vf.xxxxx`) pointing to a Fusion cloud document. You cannot embed STEP files directly in .lbr XML.-- **Creating 3D packages requires the Fusion UI** — use `Package3DCreateCmd` in Electronics Library Editor, which opens a new Design workspace where you model/import the 3D shape, then save to link it.-- **`EXPORT SCRIPT` strips 3D references** — the .scr export only contains 2D data (symbols, footprints, devicesets). To preserve 3D links, keep the .lbr XML format.-- **Fusion's built-in examples** have 3D packages — 34 of 37 libraries in the EAGLE examples directory (e.g., `Connector_USB.lbr`, `Resistor.lbr`, `Capacitor.lbr`) include `package3d` references.-- **Example libraries location**: `%LOCALAPPDATA%/Autodesk/webdeploy/production/<hash>/Applications/Electron/LibEagle/examples/libraries/examples/`--To open a built-in example library for reference:-```bash-# Find the examples directory first-adom-desktop fusion_execute_text_command '{"command": "Python.RunScript C:/tmp/find_eagle_libs.py"}'-# Then open one-adom-desktop fusion_open_lbr '{"filePath": "<path>/Connector_USB.lbr"}'-```-- `fusion_open_schematic` -- Open a .sch schematic in Fusion's Schematic Editor. Args: `filePath`.-- `fusion_open_board` -- Open a .brd board layout in Fusion's Board Layout editor. Args: `filePath`.-- `fusion_show_3d_board` -- Switch to 3D PCB board view (must have a .brd open). Board MUST have an outline on layer 20 (Dimension) or 3D generation fails. Auto-zooms to fit after switching.-- `fusion_show_2d_board` -- Switch back to 2D board layout from 3D PCB view. EAGLE commands via `fusion_electron_run` only work in 2D.-- `fusion_close_document` -- Close a document without save dialog. Args: `name` (optional, defaults to active doc), `save` (optional, default false). Essential for automation — avoids modal save dialog that blocks Fusion.-- `fusion_document_info` -- List ALL open documents/tabs with name, type, and active status, plus detailed cloud info for the active document. Returns `openDocuments` array (every tab) and active doc details (cloud project, folder, file ID, version, save status). Lightweight — uses only in-memory data, no cloud API calls. Use this instead of `fusion_walk_cloud_tree` when you just need to know what's open.-- `fusion_activate_document` -- Switch to a specific open document tab. Args: `name` (substring match, case-insensitive), `documentType` ("Electronics", "PCB", "FusionDesign", "Drawing"). Essential for automation — switch between schematic, board, and library tabs without user interaction. If no match found, returns the list of open documents so you can refine.-- `fusion_close` -- Close Fusion 360. **Always call this when done with Fusion 360 commands to clean up.**-- `fusion_dismiss_recovery` -- Dismiss recovery document dialogs (both "Recovered Documents" list and "Open recovery document instead?" prompts). Also relocates recovery files to `~/.adom/recovery/fusion/` for safekeeping.-- `fusion_relocate_recovery` -- Proactively move Fusion crash recovery files to `~/.adom/recovery/fusion/<timestamp>/` without dismissing any dialogs. Call this **before** launching Fusion to prevent recovery dialogs from appearing. Files are preserved (not deleted) so the user can manually restore them if needed.-- `fusion_close_all_documents` -- Close all open documents. Args: `saveChanges` (default: false). Use before force-killing Fusion to prevent recovery files.--#### Fusion 360 UI Interaction--Commands for interacting with Fusion 360's UI — detecting dialogs, taking screenshots, clicking, and sending keyboard input. Essential for handling blocking dialogs and automating CEF-based UI elements.--- `fusion_window_info` -- Returns the Fusion main window HWND, title, rect, and a list of all Qt dialog windows (recovery dialogs, wizards, file pickers). Essential for detecting blocking dialogs before/after operations.-  - Example: `adom-desktop fusion_window_info`-  - Returns: `{hwnd, title, rect, dialogs: [{hwnd, title, className, rect}]}`--- `fusion_screenshot_fusion` -- Captures the Fusion main window or a specific dialog by HWND. Uses PrintWindow (works without bringing to foreground). Saves WebP to `C:/tmp/adom-desktop-screenshots/` (falls back to PNG if WebP unavailable). Downscaled to ≤1568px — use relative coords (0.0-1.0) for clicking, they're scale-independent. DPI-aware.-  - Args: `hwnd` (optional — dialog HWND to screenshot instead of main window)-  - Example: `adom-desktop fusion_screenshot_fusion` (main window)-  - Example: `adom-desktop fusion_screenshot_fusion '{"hwnd": 12345}'` (specific dialog)--- `fusion_screenshot_all` -- Screenshots the main Fusion window and lists all dialog windows with their HWNDs. Use `fusion_screenshot_fusion {"hwnd": ...}` to capture each dialog.-  - Example: `adom-desktop fusion_screenshot_all`--- `fusion_click_fusion` -- Click at coordinates within the Fusion window or a specific dialog. x/y are relative (0.0-1.0) by default. Set `"relative": false` for pixel offsets. Uses SendInput for CEF dialog compatibility. **Prefer `fusion_send_key` for dialogs** — enter/escape/tab covers most cases.-  - Args: `x` (required), `y` (required), `relative` (optional, default true), `hwnd` (optional — target a specific dialog HWND instead of main window)-  - Example: `adom-desktop fusion_click_fusion '{"x": 0.5, "y": 0.7}'` (main window)-  - Example: `adom-desktop fusion_click_fusion '{"hwnd": 12345, "x": 0.75, "y": 0.85}'` (dialog button)--- `fusion_send_key` -- Send keyboard input to Fusion or a specific dialog via SendInput. **Preferred way to dismiss dialogs**: enter to confirm, escape to cancel, tab to cycle buttons. Use click only if tab order doesn't reach the right button.-  - Args: `key` (required), `hwnd` (optional — target a specific dialog HWND)-  - Example: `adom-desktop fusion_send_key '{"key": "escape"}'` (dismiss CEF dialog)-  - Example: `adom-desktop fusion_send_key '{"key": "enter", "hwnd": 12345}'` (confirm dialog)--#### Fusion 360 Workflow Guide--##### Opening Electronics Projects-1. Open the `.fprj` file: `adom-desktop fusion_open_cloud_file '{"projectName":"Main","fileName":"DRV8411A","fileExtension":"fprj","folderPath":"Molecules/XRP/DRV8411A"}'`-2. Do NOT try to open `.fbrd` or `.fsch` directly — they fail or show a "Select Electronics Design File" dialog-3. Enter the board editor: `adom-desktop fusion_show_2d_board`-4. Enter the schematic editor: After step 3, use `adom-desktop fusion_electron_run '{"command":"EDIT .sch"}'`-5. Switch back to board: `adom-desktop fusion_electron_run '{"command":"EDIT .brd"}'`--##### Auto-Screenshot on Open Commands--**Open commands automatically screenshot Fusion and return the images in the response.** The following commands include `postOpenScreenshot` in their `data` field:-- `fusion_open_cloud_file`, `fusion_open_schematic`, `fusion_open_board`, `fusion_show_3d_board`, `fusion_show_2d_board`--The response `data.postOpenScreenshot` contains:-- `screenshots[]` — array of `{type, savedTo, sizeKB, title?, hwnd?}`. Type is `"main_window"` or `"dialog"`.-- `message` — human-readable instruction to READ each screenshot and check for blocking dialogs-- `dialogBlocking` — `true` if a modal dialog was detected--**After receiving the response, you MUST:**-1. **READ each screenshot file** using the Read tool to visually inspect what Fusion shows-2. Look for blocking dialogs in the screenshots:-   - "What to design?" wizard → dismiss with `fusion_send_key {"key": "escape"}`-   - "PCB out of date" banner → click Update or X-   - "Recovered Documents" → `fusion_dismiss_recovery`-   - "Save changes?" → `fusion_send_key {"key": "escape"}`-   - Any other modal → identify and dismiss-3. **Screenshot again** after dismissing to confirm it's clear--Screenshots are saved as WebP (lossless, ~20-40KB each, downscaled to ≤1568px) for token efficiency. Both the main Fusion window AND all Qt dialog windows are captured separately.--**Why this matters**: Fusion shows Qt dialogs and CEF overlays that are invisible to the API. The `success: true` response from an open command does NOT mean the UI is ready — a dialog may be blocking all further operations. The API cannot detect these. Only a screenshot can.--##### Detecting and Handling Blocking Dialogs-- The auto-screenshot captures dialog windows separately (look for `type: "dialog"` entries in `postOpenScreenshot.screenshots[]`)-- Common blocking dialogs: "What do you want to design?", "Recovered Documents", "Open recovery document instead?", "Select Electronics Design File"-- Dismiss recovery dialogs: `adom-desktop fusion_dismiss_recovery`-- Dismiss CEF dialogs (inside main window): `adom-desktop fusion_send_key '{"key": "escape"}'` or `fusion_click_fusion`-- Dismiss "What do you want to design?" wizard: `adom-desktop fusion_send_key '{"key": "escape"}'`-- For manual screenshot of specific dialogs: `adom-desktop desktop_screenshot_window '{"hwnd": <DIALOG_HWND>}'`--##### Preventing Recovery Documents-- Recovery files are at: `%LOCALAPPDATA%\Autodesk\Autodesk Fusion 360\<USER_ID>\CrashRecovery\`-- Before force-killing Fusion, close all documents: `adom-desktop fusion_close_all_documents '{"saveChanges": false}'`-- **Best practice**: Call `adom-desktop fusion_relocate_recovery` before launching Fusion. This moves recovery files to `~/.adom/recovery/fusion/<timestamp>/` — preserving them for the user while preventing modal dialogs.-- The `fusion_start` command also auto-relocates recovery files before starting Fusion.-- The `fusion_dismiss_recovery` command handles both "Recovered Documents" list and "Open recovery document instead?" prompts, and also relocates files.--##### EAGLE Export Limitations-- **Supported**: `EXPORT IMAGE`, `EXPORT NETLIST`, `EXPORT PARTLIST`-- **NOT supported** (fail silently): `EXPORT DXF`, `EXPORT SVG`, `EXPORT DRILL`-- Always verify export output: the updated `fusion_electron_run` now checks if the output file was created-- Use `fusion_export_bom` and `fusion_export_cpl` for manufacturing data (these use the add-in's XML parser, not EAGLE export)-- Use `fusion_export_gerbers` for Gerber files — produces a ZIP with all gerber layers (GTL, GBL, GTS, GBS, GTP, GBP, GTO, GBO, GKO, XLN). Auto-detects 2-layer vs 4-layer boards.--##### 3D Model Exports-- From the 3D view (activate the .f3d document): `fusion_export_step`, `fusion_export_stl`, `fusion_export_3mf`, `fusion_export_f3d`, `fusion_export_usdz`, `fusion_export_iges`, `fusion_export_sat`-- Switch to 3D view: `fusion_show_3d_board` or activate the .f3d document-- **STEP** — Industry-standard CAD interchange (SolidWorks, CATIA, Creo). Highest geometric fidelity.-- **IGES** — Legacy CAD interchange. Use STEP for modern workflows.-- **SAT** — ACIS solid model format. Used by SolidWorks, SpaceClaim.-- **STL** — Mesh format for 3D printing and visualization. Options: refinement "low"/"medium"/"high".-- **3MF** — Modern 3D printing with color/material support and multi-body.-- **F3D** — Native Fusion 360 archive. Preserves parametric features, sketches, timeline, component refs. Best for archival.-- **USDZ** — **Best for digital twins and GLB conversion.** Preserves full component hierarchy (Board, copper layers, soldermask, Packages), PBR materials, and named nodes. Each component becomes a toggleable node in GLB viewers. Also viewable directly on iOS/macOS (Apple Quick Look / AR).-  - Digital twin pipeline: `fusion_export_usdz` → pull_file → `blender --background --python-expr "import bpy; bpy.ops.wm.usd_import(filepath='board.usdz'); bpy.ops.export_scene.gltf(filepath='board.glb')"`-- **FBX** — Not available in current Fusion builds via the API. The command exists but fails clearly. Use `fusion_export_usdz` instead.-- **DXF/DWG/OBJ/SKP** — Not available via the Fusion API (dialog-only). Commands exist but return clear errors with alternatives.--##### 3D Viewport Captures-- `fusion_take_screenshot` — Capture the Fusion viewport at any resolution without opening a dialog. Uses Fusion's render API (`saveAsImageFile`).-  - Args: `outputPath` (required), `width` (default 1920), `height` (default 1080), `orientation` (optional)-  - Orientations: `home`, `front`, `back`, `top`, `bottom`, `left`, `right`-  - Example: Capture all 6 standard views for use as product images/icons:-    ```bash-    for orient in home front back top bottom left right; do-      adom-desktop fusion_take_screenshot "{\"outputPath\": \"C:/tmp/3d-${orient}.png\", \"width\": 1920, \"height\": 1080, \"orientation\": \"${orient}\"}"-    done-    ```--##### Electronics Source File Export (`fusion_export_source`)-- `fusion_export_source` — Export the active electronics document as `.fsch`, `.fbrd`, or `.flbr` source file.-  - Args: `outputPath` (required — full path with extension)-  - The extension determines the format: `.fsch` (schematic), `.fbrd` (board), `.flbr` (library)-  - Validates extension, creates output directory, verifies file was created, returns file size--- **Full workflow to export both board and schematic from a cloud project:**-  ```bash-  # Step 1: Open the .fprj (NOT .fbrd/.fsch directly — those fail)-  adom-desktop fusion_open_cloud_file '{"projectName":"Main", "fileName":"MyDesign", "fileExtension":"fprj", "folderPath":"Molecules/MyDesign"}'--  # Step 2: Screenshot to check for blocking dialogs (always do this after open)-  adom-desktop fusion_screenshot_fusion--  # Step 3: Enter board view (MUST be Board Layout workspace, not 3D)-  adom-desktop fusion_show_2d_board--  # Step 4: Export .fbrd (board first — it's already in board view)-  adom-desktop fusion_export_source '{"outputPath": "C:/tmp/exports/MyDesign.fbrd"}'--  # Step 5: Switch to schematic (EDIT .s1 = first schematic sheet)-  adom-desktop fusion_electron_run '{"command": "EDIT .s1"}'--  # Step 6: Export .fsch-  adom-desktop fusion_export_source '{"outputPath": "C:/tmp/exports/MyDesign.fsch"}'--  # Step 7: Pull files to Docker-  adom-desktop pull_file '{"filePaths":["C:/tmp/exports/MyDesign.fbrd","C:/tmp/exports/MyDesign.fsch"], "saveTo":"/tmp/exports"}'-  ```--- **Critical gotchas:**-  - You MUST open the `.fprj` first — opening `.fbrd`/`.fsch` directly fails or triggers blocking dialogs-  - You MUST call `fusion_show_2d_board` before exporting `.fbrd` (the Electronics Design overview won't work)-  - Always export `.fbrd` FIRST (from board view), then switch to schematic for `.fsch`-  - If export fails with "file not found", the wrong workspace is active — screenshot to verify-  - After `fusion_open_cloud_file`, always screenshot to check for "Select Electronics Design File" dialog-  - `WRITE` command is blocked — it opens a blocking save dialog. Use `fusion_export_source` instead--- Note: `WRITE` is blocked — it opens a "Version Description" dialog on cloud docs even with a path argument (tested 2026-04-10). Use `fusion_save_to_cloud` to save to cloud, `fusion_export_source` for Fusion-format, or `fusion_export_eagle_source` for plain EAGLE format.--##### Plain EAGLE Source Export (`fusion_export_eagle_source`)-- `fusion_export_eagle_source` — Export the active electronics document as plain EAGLE XML `.sch` or `.brd`.-  - Args: `outputPath` (required — full path ending in `.sch` or `.brd`)-  - Internally: exports `.fsch`/`.fbrd` via `Document.CopyToDesktop`, then extracts the EAGLE XML from the ZIP container, cleans up the temp file.-  - The output is valid EAGLE XML (`<?xml><eagle version="9.7.0">...`) parseable by standalone EAGLE, KiCad import, or any XML tool.-  - Same workflow/prerequisites as `fusion_export_source` — just use `.sch`/`.brd` extensions instead of `.fsch`/`.fbrd`.--  ```bash-  # Board (.brd) — must be in PCB Editor / Board Layout-  adom-desktop fusion_show_2d_board-  adom-desktop fusion_export_eagle_source '{"outputPath": "C:/tmp/exports/MyDesign.brd"}'--  # Schematic (.sch) — must be in Schematic Editor-  adom-desktop fusion_electron_run '{"command": "EDIT .s1"}'-  adom-desktop fusion_export_eagle_source '{"outputPath": "C:/tmp/exports/MyDesign.sch"}'-  ```--##### Dialog Dismissal (`fusion_close_window`)-- `fusion_close_window` — Close a specific Fusion dialog by sending WM_CLOSE (equivalent to clicking X).-  - Args: `hwnd` (required — from `fusion_window_info` or `fusion_dismiss_blocking_dialogs` remaining[])-  - Works on dialogs that Escape doesn't close — e.g. "Recovered Documents"-  - Does NOT force-kill — the dialog can still intercept WM_CLOSE--##### Electronics Import (Round-Trip)-- `fusion_import_electronics` — Import Fusion-native `.fsch`, `.fbrd`, or `.flbr` files as new local documents-  - Uses `Document.newDesignFromLocal` under the hood-  - Auto-screenshots after import (catches blocking dialogs)-  - Args: `filePath` (required Windows path to .fsch, .fbrd, or .flbr)-  - After import, use `fusion_save_to_cloud` to persist to Fusion cloud-  - **`.fsch` import works standalone** — creates a new schematic project-  - **`.fbrd` import may fail** — boards have external references to schematics/libraries. Error: "New design cannot be created from a local file containing external references"-  - **`.flbr` import works standalone** — creates a new library project-  - For legacy EAGLE files: use `fusion_open_schematic` (.sch), `fusion_open_board` (.brd), `fusion_open_lbr` (.lbr)--##### Library Round-Trip Workflow-Export and re-import Fusion electronics libraries:-```bash-# 1. Open library from cloud-adom-desktop fusion_open_cloud_file '{"projectName":"Main","fileName":"Adom Common Components","folderPath":"Molecules/Libraries"}'--# 2. Export as .flbr (Fusion-native binary) and .scr (EAGLE script text)-adom-desktop fusion_save_lbr '{"outputPath":"C:/tmp/library.flbr"}'-adom-desktop fusion_export_lbr '{"outputPath":"C:/tmp/library.scr"}'-adom-desktop fusion_close_document--# 3. Re-import the .flbr-adom-desktop fusion_import_electronics '{"filePath":"C:/tmp/library.flbr"}'--# 4. Verify symbols survived round-trip-adom-desktop fusion_export_lbr '{"outputPath":"C:/tmp/library-verify.scr"}'--# 5. Save to cloud with new name-adom-desktop fusion_save_to_cloud '{"name":"Library-copy"}'-```--##### Demo Projects-171+ electronics molecules are exported in the `adom-desktop-demo` repo (separate from adom-desktop).-Three reference boards with full export formats:--| Board | Cloud Path | Layers | Components | Files |-|-------|-----------|--------|------------|-------|-| DRV8411A | Main / Molecules / XRP / DRV8411A | 4 | 29 | 27 |-| DRV8323SR | Main / Molecules / Experiments / MotorControl / DRV8323SR | 2 | 43 | 27 |-| VL53L8BreakoutMolecule | Main / Molecules / XRP / TimeOfFlightVL53L8 / VL53L8BreakoutMolecule | 2 | 30 | 27 |--Each folder contains: .fsch, .fbrd, bom.csv, cpl.csv, gerbers.zip, 6 board images, 7 3D renders (home/front/back/top/bottom/left/right), STEP, IGES, SAT, STL, 3MF, F3D, USDZ, board screenshot, 3D board screenshot.--#### Cloud Document Management--Manage Fusion 360 cloud documents (hub projects, files, versions). Required for 3D package workflows since EAGLE library 3D models are stored as cloud documents.--- `fusion_save_to_cloud` -- Save the active Fusion document to the cloud. Args: `name` (required), `projectName` (optional, defaults to active project), `folderPath` (optional), `description` (optional). Returns: `fileId`, `versionNumber`, `wipUrn` (if cloud-hosted).-- `fusion_list_cloud_projects` -- List all cloud projects in the user's hub. Returns array of `{name, id}` per hub.-- `fusion_list_cloud_files` -- List files in a cloud project/folder. Args: `projectName` (optional), `folderPath` (optional). Returns: `files` array with `{name, id, versionNumber, fileExtension, dateModified}` and `subfolders` array.-- `fusion_create_cloud_folder` -- Create a folder in a cloud project. Args: `folderName` (required), `projectName` (optional), `parentPath` (optional). Returns `folderId`. Idempotent — returns existing folder if it already exists.-- `fusion_check_recovery` -- Check if a cloud file has a recovery document from a previous crash. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional). Returns `hasRecovery: true/false`. Use this BEFORE opening files to avoid the blocking "Open recovery document instead?" dialog. If recovery exists, call `fusion_open_cloud_file` with `recovery: "open"` (restore unsaved work) or `recovery: "discard"` (delete recovery, open cloud version).-- `fusion_open_cloud_file` -- Open a cloud file in Fusion by name. **If a recovery document exists and no `recovery` arg is given, the command STOPS and reports the recovery instead of opening** — you must decide whether to preserve or discard unsaved work. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional), `recovery` ("open" = restore unsaved work, "discard" = delete recovery and open cloud version).-- `fusion_export_cloud_file` -- Export the active Fusion document to a local file for transfer back to Docker. Args: `outputPath` (required), `format` (optional, default "step"). The exported file can then be pulled back to Docker via `pull_file`.-- `fusion_delete_cloud_file` -- Delete a cloud file by name. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional). File must not be open in Fusion — close it first with `fusion_close_document`.-- `fusion_walk_cloud_tree` -- **Long-running.** BFS walk of a cloud folder tree. Returns a flat list of all files and folders. Runs entirely on the Fusion main thread — **blocks all other add-in commands** until done (check progress with `fusion_addin_status`).-  - Args: `projectName` (optional), `folderPath` (optional, starting folder), `maxDepth` (default 10), `maxFolders` (default 500), `extensions` (optional list, e.g. `["f3d","fprj"]`), `nameContains` (optional substring filter), `includeFiles` (default true)-  - Returns: `{project, rootFolder, folders[], files[], stats: {foldersVisited, foldersSkipped, filesFound, maxDepthReached, truncated}}`-  - **Per-folder timeout (30s):** If a single folder's cloud API calls take >30s (common for large projects), the folder is skipped and counted in `foldersSkipped`. This prevents indefinite hangs.-  - **Progress tracking:** While running, `fusion_addin_status` returns `walkProgress` with `{foldersVisited, filesFound, currentFolder, queueSize}` — poll this every 1–10s to monitor progress (see "Live folder progress streaming" below).-  - **Non-blocking alternative:** Use `fusion_search_cloud_files` for targeted searches, or `fusion_list_cloud_files` for single-folder listings (these are faster but don't recurse).-  - Example: `adom-desktop fusion_walk_cloud_tree '{"projectName":"Main","folderPath":"Molecules","nameContains":"DRV","extensions":["fprj"]}'`--#### Live folder progress streaming with `watch`--For long walks, use the built-in `watch` wrapper. It spawns the inner search command on a worker thread, polls `fusion_addin_status` internally, and emits one JSON event per line to stdout as the walker visits each folder. **No manual polling loop needed.**--```bash-adom-desktop watch '{"command":"fusion_walk_cloud_tree","args":{"projectName":"Main","folderPath":"Molecules","nameContains":"BQ25792"}}'-```--Output is one JSON object per line, three event types:--```jsonl-{"event":"started","command":"fusion_walk_cloud_tree","args":{...},"interval":2,"_hint":"streaming progress events follow, one per line, ending with 'complete' or 'error'"}-{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":15.0,"foldersVisited":14,"queueSize":50,"filesFound":0,"currentFolder":"Molecules/Sensing"}-{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":23.4,"foldersVisited":22,"queueSize":108,"filesFound":0,"currentFolder":"Molecules/Examples"}-{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":31.8,"foldersVisited":30,"queueSize":100,"filesFound":0,"currentFolder":"Molecules/RAPID NAME TAGS/Connor Wood"}-... (one per real change in walkProgress, deduped) ...-{"event":"complete","result":{"folders":[...],"files":[...],"stats":{"foldersVisited":169,"filesFound":12,"truncated":false}}}-```--Read stdout line-by-line. Stop when you see `event:complete` or `event:error`. The full final result is in the `complete` event's `result` field.--**Optional `interval` arg** (default 2s, clamped to [1, 30]):--```bash-adom-desktop watch '{"command":"fusion_walk_cloud_tree","args":{...},"interval":1}'-```--**For Claude Code consumers**: pipe `watch` directly into the `Monitor` tool. Each JSON line becomes a real-time event notification in the chat — you (and the user) see folder names appear one-by-one as the walker visits them. No bash loop, no `disown`, no subprocess gymnastics. Verified live in 1.3.16 on a 169-folder walk that returned all 12 BQ25792 cloud files cleanly.--**Watchable commands** (whitelist): `fusion_walk_cloud_tree`, `fusion_search_cloud_files`. Other commands return `success:false` with a `_hint` listing the watchable set.--**When NOT to use watch**: short single-folder operations (`fusion_list_cloud_files`, `fusion_open_cloud_file`) — those return in <2s and don't need streaming. The `watch` wrapper is purely for the long BFS commands.-- `fusion_search_cloud_files` -- **Long-running.** Recursive substring search on file names across cloud folders. v1.0.2+ of the add-in adds: per-folder timeout, doEvents() every 50 files, per-file try/except, iterative BFS, and a stale-lock watchdog — Fusion stays responsive throughout (no "Not Responding" freeze).-  - **Args (no hard upper caps in v1.0.2+):** `query` (required, substring, case-insensitive), `projectName` (optional, defaults to active), `folderPath` (optional starting subfolder — **narrow with this**), `recursive` (default false), `maxDepth` (default 2), `maxFolders` (default 10), `maxResults` (default 20), `folderTimeout` (default 30s), `searchTimeout` (default 120s), `timeout` (HTTP envelope, default 620s).-  - **Before calling:** if the user knows roughly where the file is (e.g. customer-named folder), **ask them**. Naming a subfolder cuts 5-40 min searches down to ~10s.-  - **Why slow:** Autodesk's free Fusion 360 Python API has no indexed file-search endpoint. We walk the Data API one folder at a time — each = one HTTP round-trip to Autodesk. **This is an Autodesk API limitation, not adom-desktop's.** When telling the user the search is taking a while, attribute it to Autodesk / Fusion 360, not to adom-desktop's bridge. **There is now a FAST native alternative — `aps_search` (see "APS — fast indexed cloud search" below).** It hits Autodesk Platform Services' indexed Data Management API and returns ~instantly, even when Fusion is CLOSED. Call `aps_status` first; if `configured:true`, prefer `aps_search` and only fall back to this folder-walking verb when APS isn't set up.-  - **Interpreting results (critical for no-false-negatives):** the response contains `searchComplete` (bool), `foldersSkipped`, `filesSkipped`, `truncated`, `folderLimitReached`, `searchTimedOut`. `searchComplete:true` ONLY when **all five** are clean — that's the confidence flag. If `searchComplete:false`, the search hit a cap before exhausting the scope; **don't report "file not found"** — re-run with broader caps OR narrower `folderPath`.-  - **Case sensitivity:** fully case-insensitive both directions (query and file names lowercased). Substring match, not whole-word — `"cosm"` matches `COSMIIC`, `COSMOCOIL`, etc.-  - **Cost feedback in response:** `costAnalysis: {elapsedSeconds, foldersPerSecond, estimatedSecondsPer100Folders}` so the AI can budget the next search realistically.-  - **Real numbers from a live test:** searching `Main/Molecules` recursively (173 folders deep, max 8 depth) for "cosmiic" returned in 168 s with `searchComplete:true, totalFound:3`. Fusion stayed `main_thread:responsive` throughout.-  - **Pair with `watch`** for streaming progress updates: see below.--#### APS — fast indexed cloud search (v1.8.182+)--`aps_search` is a **native, ~instant** alternative to the folder-walking `fusion_search_cloud_files`. It queries **Autodesk Platform Services** (APS, formerly Forge) **Data Management API**, which has a real server-side index — so a search that takes minutes by folder-walking returns in well under a second, and it works **even when Fusion 360 is closed** (it's pure HTTPS, no bridge required). adom-desktop runs its own 3-legged **PKCE** OAuth, so the user's APS **client secret never leaves their machine**.--**One-time setup (user side):**-1. Register a free app at <https://aps.autodesk.com> → enable the **Data Management API**.-2. Set the app's **Callback URL** to exactly `http://localhost:8910/aps/callback`.-3. Put the **Client ID** in AD config key `aps_client_id` (in `~/.adom/config.json`) or via Settings.-4. Run `aps_auth` once — it opens the system browser for Autodesk sign-in. Tokens cache in `~/.adom/aps_tokens.json` and auto-refresh; you won't be prompted again.--**Verbs:**-- `aps_status` — **call this first.** Non-prompting. Returns `{configured, signedIn}`. `configured:false` → no client id; use `fusion_search_cloud_files`. `configured:true, signedIn:false` → run `aps_auth`. Both true → `aps_search` is ready.-- `aps_auth` — interactive sign-in (opens browser). One-time per device.-- `aps_hubs` — `{hubs[]{id,name}}` (your Autodesk teams).-- `aps_projects {hubId?}` — `{hubId, projects[]{id,name}}` (default: first hub).-- `aps_search {query, projectName?, projectId?, hubId?}` — `{query, hubId, count, results[]{name,id,type,lastModified,project}}`. `query` is a case-insensitive substring of the file's displayName. Without `projectName`/`projectId` it searches up to 8 projects in the hub. Open a hit with `fusion_open_cloud_file {fileName, projectName}`.--**Decision flow for an AI doing a cloud file search:** `aps_status` → if `configured` (sign in with `aps_auth` if needed) use `aps_search`; else fall back to `fusion_search_cloud_files` and tell the user that registering a free APS app (above) would make future searches instant.--**Export formats for `fusion_export_cloud_file`:**--| Format | Extension | Use case |-|--------|-----------|----------|-| `step` | .step | Industry-standard CAD interchange (default) |-| `stl` | .stl | 3D printing, mesh-based |-| `f3d` | .f3d | Fusion 360 native archive (preserves all features) |-| `iges` | .iges | Legacy CAD interchange |-| `sat` | .sat | ACIS solid modeling kernel format |-| `smt` | .smt | Parasolid format |--Also supported for import via `fusion_import_step`: STEP (.step/.stp), STL (.stl), IGES (.iges/.igs), SAT (.sat), SMT (.smt), OBJ (.obj), F3D (.f3d).--**Round-trip: workspace → Fusion Cloud → workspace**--```bash-# === workspace to Fusion Cloud ===-# 1. Send file from Docker to Windows desktop-adom-desktop send_files '{"files": [{"path": "/home/user/component.step"}]}'-# 2. Import into Fusion-adom-desktop fusion_import_step '{"filePath": "C:/Users/john/Downloads/component.step"}'-# 3. Save to cloud (gets a wip_urn for 3D library linking)-adom-desktop fusion_save_to_cloud '{"name": "my-component", "projectName": "Personal"}'--# === Fusion Cloud to Docker ===-# 1. Find and open the cloud file-adom-desktop fusion_walk_cloud_tree '{"projectName": "Personal", "nameContains": "my-component"}'-adom-desktop fusion_open_cloud_file '{"fileName": "my-component", "projectName": "Personal"}'-# 2. Export to local filesystem-adom-desktop fusion_export_cloud_file '{"outputPath": "C:/tmp/export/my-component.step", "format": "step"}'-# 3. Pull back to Docker-adom-desktop pull_file '{"path": "C:/tmp/export/my-component.step"}'--# === Cloud management ===-adom-desktop fusion_list_cloud_projects '{}'-adom-desktop fusion_create_cloud_folder '{"folderName": "Electronics", "projectName": "Main"}'-adom-desktop fusion_list_cloud_files '{"projectName": "Main", "folderPath": "Electronics"}'-adom-desktop fusion_delete_cloud_file '{"fileName": "old-file", "projectName": "Main"}'-```--#### Manufacturing Exports (Gerbers, BOM, CPL)--Export manufacturing files from an open PCB board — everything needed to fabricate boards and assemble components via Adom's PCBA service. **All manufacturing commands require a .brd board open in PCB Editor** (use `fusion_open_board` or `fusion_show_2d_board`).--**Recommended workflow order:** `detect_layers` → `set_design_rules` → DRC → `export_gerbers` → `export_bom` → `export_cpl` → `pull_file` all back to Docker.--Every manufacturing command returns structured JSON with:-- `message` — AI-oriented summary explaining what was produced and why it matters-- `nextSteps[]` — ordered list of what to run next in the manufacturing pipeline-- `hint` — tips for interpreting results or recovering from issues-- `data` — structured metadata (paths, counts, layer info) for programmatic use--**Decision guide — which command to run:**-- **Don't know the layer count?** → Run `fusion_detect_layers` first-- **Need to check if the board meets fab specs?** → Run `fusion_set_design_rules` then DRC-- **Ready to generate fab files?** → Run `fusion_export_gerbers`, then `fusion_export_bom`, then `fusion_export_cpl`-- **Need visual review before export?** → Run `fusion_export_board_image` with preset `assembly_top` or `fabrication`-- **Something failed?** → Check `data.hint` and `data.recoverySteps` in the error response--**Commands:**--- `fusion_detect_layers` -- Detect if the open board is 2-layer or 4-layer. Uses ULP script (primary) and CAM comparison (fallback). Returns `layerCount`, `copperLayers[]`, `method` (how it was detected), and `nextSteps[]`. **Run this first** — all other manufacturing commands auto-detect layers too, but running this explicitly gives you the data before committing to exports.--- `fusion_set_design_rules` -- Apply Adom's JLCPCB-derived design rules (.edru XML files) to the open board. Auto-detects 2-layer vs 4-layer and loads the appropriate rule set. Returns `description` (human-readable rule summary), `edruFile`, and `nextSteps[]`.-  - `action`: `"apply"` (default) loads rules into the board; `"export"` saves current board rules to a file; `"show"` displays rule capabilities without modifying anything-  - `layers`: `"auto"` (default), `"2"`, or `"4"` — force a specific rule set-  - After applying: run `fusion_electron_run '{"command": "DRC"}'` to check for violations. DRC markers appear in the board editor.--- `fusion_apply_instapcb_rules` -- Convenience wrapper that applies the bundled Adom InstaPCB design rule sets without needing a local `.edru` file. Args: `layers` (`"2"` or `"4"`). Sends the bundled `.edru` from Docker to Windows automatically and loads it via the EAGLE `drc load` command. Use this instead of `fusion_set_design_rules` when you just want Adom's standard 2-layer or 4-layer InstaPCB rules.--- `fusion_load_design_rules` -- Generic loader for ANY custom `.edru` file by path. Args: `filePath`. Use this for third-party fab vendor rules (JLCPCB, PCBWay, OSHPark, etc.) that the user has on disk.--- `fusion_export_gerbers` -- Export Gerber (RS-274X) + Excellon drill files as a ZIP. Auto-detects 2-layer vs 4-layer and selects the correct JLCPCB-compatible CAM job. Returns `zipPath`, `zipSizeKB`, `files[]` (list of gerber files in the ZIP with sizes), `layerCount`, and `nextSteps[]`.-  - `outputDir`: directory for the output ZIP (default: `C:/tmp/adom-gerbers/`)-  - `boardName`: prefix for the ZIP filename (default: from active document name)-  - `layers`: `"auto"` (default), `"2"`, or `"4"` — force CAM job selection-  - Output ZIP contains: GTL, GBL (copper), GTS, GBS (solder mask), GTP, GBP (paste), GTO, GBO (silkscreen), GKO (outline), XLN (drill). 4-layer boards also get G1, G2 (inner copper).--- `fusion_export_bom` -- Export Bill of Materials as CSV. Groups identical parts by value+package with quantity counts. Returns `componentCount`, `uniquePartCount`, and `nextSteps[]`.-  - `outputPath`: (default: `C:/tmp/adom-bom.csv`)-  - `grouped`: `true` (default) groups by value+package; `false` lists every component individually-  - Output columns: Comment, Designator, Footprint, Quantity, Library — compatible with JLCPCB, PCBWay, Mouser, Digi-Key.--- `fusion_export_cpl` -- Export Component Placement List (pick-and-place) as CSV. Returns `totalPlacements`, `topCount`, `bottomCount`, and `nextSteps[]`.-  - `outputPath`: (default: `C:/tmp/adom-cpl.csv`)-  - `side`: `"all"` (default), `"top"`, or `"bottom"` — filter for single-sided assembly-  - Output columns: Designator, Mid X, Mid Y, Layer, Rotation — coordinates in mm.--- `fusion_export_board_image` -- Export PNG image of the board with layer presets. Returns `fileSize`, `preset`, and `nextSteps[]`.-  - `outputPath`: (default: `C:/tmp/adom-board.png`)-  - `dpi`: resolution (default: 300, max: 600)-  - `preset`: layer preset name (see table below)-  - `layers`: custom layer numbers as int array — overrides preset-  - `monochrome`: `true` for black & white-  - `listPresets`: set `true` to get available presets instead of exporting--**Layer presets for `fusion_export_board_image`:**--| Preset | Layers | Description |-|--------|--------|-------------|-| `all` | All | Every layer visible |-| `top_copper` | 1, 17, 18 | Top copper + pads + vias |-| `bottom_copper` | 16, 17, 18 | Bottom copper + pads + vias |-| `top_silkscreen` | 21, 25 | Top silkscreen + component names |-| `bottom_silkscreen` | 22, 26 | Bottom silkscreen + component names |-| `top_soldermask` | 29 | Top solder mask openings |-| `bottom_soldermask` | 30 | Bottom solder mask openings |-| `top_paste` | 31 | Top paste/stencil openings |-| `bottom_paste` | 32 | Bottom paste/stencil openings |-| `board_outline` | 20 | Board outline (dimension layer) |-| `drill` | 44, 45, 17, 18 | Drill holes + vias |-| `assembly_top` | 1, 17, 18, 20, 21, 25, 51 | Top assembly — copper + silk + outline |-| `assembly_bottom` | 16, 17, 18, 20, 22, 26, 52 | Bottom assembly — copper + silk + outline |-| `fabrication` | 1, 16, 17–20, 21, 22, 25, 26, 29, 30, 51 | Full fabrication view |--**Adom InstapcbPCB manufacturing capabilities (used by `fusion_set_design_rules`):**--| Parameter | Metric | Imperial |-|-----------|--------|----------|-| Layers | 1, 2, 4, 6 | — |-| Min trace width | 0.08mm | 3 mil |-| Min trace spacing | 0.08mm | 3 mil |-| Min via drill | 0.2mm | 8 mil |-| Min silkscreen text | 0.1mm | 4 mil |-| Board edge clearance | 0.05mm | 2 mil |-| Board thickness | 1.6mm | 63 mil |-| Copper weight | 0.5 oz (17.5 μm) | — |-| Solder mask | Green | — |-| Surface finish | HASL / ENIG | — |-| Turnaround | 4 hours (fab + assembly) | — |--**Manufacturing workflow — Fusion to Adom PCBA:**--```bash-# 1. Open the board-adom-desktop fusion_open_board '{"filePath": "C:/projects/myboard.brd"}'--# 2. Detect layer count (auto-selects 2-layer or 4-layer rules/CAM)-adom-desktop fusion_detect_layers--# 3. Apply Adom design rules for the detected layer count + run DRC-adom-desktop fusion_set_design_rules '{"action": "apply"}'-adom-desktop fusion_electron_run '{"command": "DRC"}'--# 4. Export manufacturing files (gerbers auto-select correct CAM job)-adom-desktop fusion_export_gerbers '{"outputDir": "C:/tmp/mfg"}'-adom-desktop fusion_export_bom '{"outputPath": "C:/tmp/mfg/bom.csv"}'-adom-desktop fusion_export_cpl '{"outputPath": "C:/tmp/mfg/cpl.csv"}'--# 5. Export board images for review-adom-desktop fusion_export_board_image '{"outputPath": "C:/tmp/mfg/top-copper.png", "preset": "top_copper"}'-adom-desktop fusion_export_board_image '{"outputPath": "C:/tmp/mfg/assembly-top.png", "preset": "assembly_top"}'-adom-desktop fusion_export_board_image '{"outputPath": "C:/tmp/mfg/board-outline.png", "preset": "board_outline"}'--# 6. Pull files back to Docker for Adom PCBA ordering-adom-desktop pull_file '{"path": "C:/tmp/mfg/bom.csv"}'-adom-desktop pull_file '{"path": "C:/tmp/mfg/cpl.csv"}'-```--### Desktop Tools--- `desktop_open_folder` -- Open a file or folder in Windows Explorer. If given a file path, Explorer opens with the file highlighted (selected). Args: `path` (file or folder path).-- `desktop_open_url` -- Open a URL in the user's **NATIVE OS BROWSER** — i.e. the real Edge / Chrome / Firefox / Brave they use every day, with their saved logins, history, bookmarks, and extensions. This is NOT pup, NOT Chrome for Testing — it's the browser the human actually uses. Hand it off when the user needs to interact with a logged-in account themselves.-  - Args: `url` (required string), `browser` (optional: `"default"` (Windows-registered default — could be Edge, Chrome, Firefox, Brave; whatever they set), `"chrome"`, `"edge"`, `"firefox"`, `"brave"`).-  - Examples:-    - `adom-desktop desktop_open_url '{"url":"https://claude.ai/"}'` -- opens in user's default native browser, already signed in-    - `adom-desktop desktop_open_url '{"url":"https://docs.example.com","browser":"edge"}'` -- force native Edge-  - Returns `{ok, browser, url, exePath?}`. Allowed schemes: http, https, mailto, ftp, ftps. Other schemes refused.--#### `desktop_open_url` vs `browser_open_window` — two completely different browsers--| | `desktop_open_url` | `browser_open_window` |-|---|---|---|-| **What browser** | Native OS browser the user uses daily — Microsoft Edge / Google Chrome / Mozilla Firefox / Brave (whichever they've set as Windows default, or the one you name explicitly) | Puppeteer-controlled **Chrome for Testing** — a separate Chromium build that pup launches |-| **Profile / data** | The user's real profile: saved logins, history, bookmarks, extensions, autofill | Isolated profile under `plugins/puppeteer/profiles/<sessionId>/` — empty, no saved logins |-| **Who interacts with the page** | The HUMAN. Claude hands the URL off and is done. | CLAUDE drives it programmatically — screenshots, clicks, eval, recording. The human just watches. |-| **Use when** | A login is required (claude.ai, GitHub, internal tools, banking) — the human's existing session must be reused | Automation, scraping, screenshot capture, video recording, headful UI testing |-| **Can Claude control it after launch?** | No — once handed off, Claude can't see or drive it | Yes — every `browser_*` verb (screenshot, eval, navigate, record, etc.) |--**Decision rule:** human-in-the-loop login flow → `desktop_open_url`. AI-driven automation → `browser_open_window` (defaults to Chrome for Testing). AI-driven automation that **must** be the user's real, signed-in profile (GCP Console with org/projects, internal SSO behind a managed-Chrome policy, anything Context-Aware Access blocks for non-enrolled browsers) → `browser_open_window` with the new `nativeBrowser` + `nativeProfile` args (v1.0.5+, see below).--#### When pup means "the user's real Chrome/Edge" — `browser_open_window` native mode (v1.0.6+, smart flow v1.0.11)--Default `browser_open_window` (no native args) launches Chrome for Testing — same as day one. **Never auto-switch a plain pup request to native.** "Open this URL in pup", "screenshot this", "click that button" all stay on CfT.--Native mode drives the user's **REAL browser** — their actual signed-in profile, with live cookies, org policies, and device enrollment intact (NOT a clone — v1.0.6 replaced the old clone approach). Opt-in via TWO additive args from `desktop_list_browsers`:--```bash-adom-desktop desktop_list_browsers     # installed browsers + profiles (+ the signed-in account per profile)-adom-desktop browser_open_window '{-  "sessionId":"gcp",-  "nativeBrowser":"chrome",            # chrome / edge / brave-  "nativeProfile":"Default",           # profile id: Default / Profile 1 / Profile 2-  "url":"https://console.cloud.google.com"-}'-```--**The smart flow.** CDP (the protocol pup drives) needs the browser running with `--remote-debugging-port`, which can only be set at launch. So the bridge handles each situation differently and tells you which happened:--| Situation | What AD does | Response |-|---|---|---|-| Already driving that browser+profile | Reuse the live connection | `ok` + `narrative` |-| Running **with** a debug port (power-user launched it that way, or AD did earlier) | Connect, no relaunch | `ok` + `narrative` |-| **Not running at all** | **Launch it fresh with the debug port — NO prompt** (nothing to disrupt) | `ok`, `launchedFresh:true` + `narrative` — just proceed |-| **Open without** a debug port | **Ask the user** — AD must close+reopen it | `errorCode:"native_relaunch_required"` + `confirmArgs` + `alternatives` |-| Debug port **refuses to bind** (enterprise policy) | Won't close it again | `errorCode:"native_cdp_blocked"` + `diagnosis` + `alternatives` |--**Handling the two consent/blocked codes:**--- **`native_relaunch_required`** — the browser is open without the port, so driving it means closing + reopening it (with `--restore-last-session`, so all tabs/windows come back). **Ask the user's permission**, then re-call with `"nativeRelaunch":true`. The response's `alternatives` lists other installed browsers you could offer instead.-- **`native_cdp_blocked`** — the debug port won't open, almost always an **enterprise policy** (`RemoteDebuggingAllowed` disabled via Google Workspace / Chrome cloud management — affects **Chrome browser-wide, every profile**). `diagnosis` says whether it's a confirmed local-registry policy or a likely cloud-managed one. AD will **not** close that browser again. `alternatives` names other installed browsers — **Edge usually escapes Google management**, so it often drives natively when Chrome can't. Offer the user the alternative, or fall back to generic CfT.--**Always available — generic CfT.** `browser_open_window` with NO `nativeBrowser`/`nativeProfile` drives a clean Chrome-for-Testing instance: no user session, no managed policy, fully controllable. If a task needs the user's logged-in session there, have them sign in once. This is the universal fallback whenever native is blocked.--**Power-user shortcut:** if the user launches their browser at startup with `--remote-debugging-port=<N>`, AD reconnects every session with no prompt at all (a productized opt-in "keep native browsers debug-ready at boot" is on the roadmap, default OFF for security).--**Routing rule — DON'T auto-escalate.** Treat native as opt-in:--| User says | What to do |-|---|---|-| "pup" / "open in pup" / screenshot / eval / debug | DEFAULT pup (CfT). Never mention profiles. |-| "native chrome" / "my real browser" / "my work profile" / "my `<account>` profile" | Native. Call `desktop_list_browsers`. If exactly one profile clearly fits the request, use it and say which one ("opening in your `[email protected]` profile on Chrome"). If multiple profiles could fit (e.g. a `google.com` URL with both `[email protected]` and `[email protected]` profiles signed in), **ASK** ("Which profile — work `[email protected]` or personal `[email protected]`?") and then drive that pair. |-| anything else | Default pup. |--**Anti-fingerprint:** native sessions launch without `--enable-automation` and with `--disable-blink-features=AutomationControlled`. `navigator.webdriver === false`. No "controlled by automated software" banner. Sites that probe for automation see a normal browser.--**Firefox** appears in `desktop_list_browsers` but is rejected for native drive with `errorCode:"firefox_no_cdp"` — Firefox doesn't speak the Chrome DevTools Protocol, so puppeteer can't drive it. If the user wants Firefox, hand off via `desktop_open_url` instead.--**Native-mode error codes:**-- `native_relaunch_required` — browser open without a port; ask the user, re-call with `nativeRelaunch:true` (see the smart-flow table above).-- `native_cdp_blocked` — debug port policy-blocked; read `diagnosis`, offer `alternatives` or generic CfT (see above).-- `firefox_no_cdp` — caller passed `nativeBrowser:"firefox"`. Firefox doesn't speak CDP; hand off via `desktop_open_url`.-- `native_browser_not_installed` — that browser isn't installed on this machine.-- `native_user_data_missing` — installed but never launched, so the User Data dir doesn't exist yet. Ask the user to open it once.-- `native_profile_not_found` — `nativeProfile` isn't a real profile id under this browser's User Data. Call `desktop_list_browsers` to see what's valid.--#### ⚠️ Common footgun — `localhost` / `file://` URLs from Docker AIs--If you're running this CLI from a remote Docker container (galliaApril, dartv4, etc.), `browser_open_window` / `browser_open_tab` / `browser_navigate` will load the URL inside the USER's local pup — NOT inside your container. So a URL like `http://localhost:8080/foo.html` resolves to the user's machine, not your container's service.--**v1.7.10+ detects this.** When you pass a URL whose host is `localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, or scheme is `file://`, the response includes a `_hint` field explaining the issue and suggesting the public-slug URL pattern (`https://<user>-<repo>-<suffix>.adom.cloud/proxy/<port>/<path>`). **The call still proceeds** — rare legitimate cases exist where the user actually does have something running on their localhost.--**Recipe — load something from inside your container into pup:**--```bash-# 1. Start your service inside the container (e.g. a local HTML viewer):-python3 -m http.server 8786 --bind 0.0.0.0 &--# 2. Ask your USER for the container's adom.cloud slug suffix-#    (look at their browser URL when they open hydrogen or claude.ai/code).-#    Slug looks like: <username>-<container>-<random-id>--# 3. Open the public URL — NOT localhost:-adom-desktop browser_open_window '{-  "sessionId":"demo",-  "url":"https://<your-container-slug>.adom.cloud/proxy/8786/foo.html"-}'-```--If you don't have the suffix handy, just ask the user. Or if the file you wanted to show pup is one your container already wrote to disk, push it to a public location (wiki asset, S3) and load from there.---- `desktop_bring_to_front` -- Bring a window to the foreground by HWND or title substring. Uses keybd_event + AttachThreadInput to bypass Windows foreground lock. Preserves maximized state.-  - Args: `hwnd` (int) OR `titleContains` (string, case-insensitive). One required.-  - Example: `adom-desktop desktop_bring_to_front '{"titleContains": "Fusion"}'`-- `desktop_set_window_state` -- Change a window's show state without bringing it to the foreground (or both, if combined with `desktop_bring_to_front`). Args: `hwnd` (int) OR `titleContains` (string) OR `cacheKey`, plus `state` (one of `"maximize"`, `"minimize"`, `"restore"`, `"show"`, `"hide"`, `"bottom"`). Use to ensure Fusion or KiCad windows are maximized for screenshots, or to hide noisy background windows during demos.-  - Example: `adom-desktop desktop_set_window_state '{"hwnd":133560,"state":"maximize"}'`-  - **`state:"bottom"` (v1.9.114) -- z-order send-to-back, background.** Drops the window BEHIND all others without focusing, moving, or resizing it (`SetWindowPos HWND_BOTTOM` + `SWP_NOMOVE|SWP_NOSIZE|SWP_NOACTIVATE`); works even on an off-screen window. It's an ATOMIC check-and-demote: it reads `GetForegroundWindow()` first, and if the target IS the OS foreground (the user is interacting with it) it SKIPS the demotion and returns `wasForeground:true, sentToBack:false` so the caller backs off with no race; otherwise `wasForeground:false, sentToBack:true`. Use to park a background window behind the user's windows (still on-screen, taskbar-clickable, rendering for screenshots) without stealing focus.-  - **`force:true` (v1.9.115)** sends it to back EVEN IF the window is currently the OS foreground (still no activation change; `sentToBack:true` always, `wasForeground` still reported). Use at window-LAUNCH time: Windows reassigns the foreground to a brand-new window when the previous foreground is destroyed, so "foreground at launch" is not a real user signal.-  - Example: `adom-desktop desktop_set_window_state '{"titleContains":"(session: abc","state":"bottom","force":true}'`--#### Real OS input — mouse + keyboard (v1.8.170+)--`desktop_click` / `desktop_double_click` / `desktop_right_click` / `desktop_hover` / `desktop_type` / `desktop_press_key` synthesize **real OS input via `SendInput`** — so they land on **`chrome://` pages and native dialogs that browser CDP (`nbrowser_*` / `browser_*`) cannot reach**. The verb names mirror the browser extension's `nbrowser_*` so the model transfers between in-page and whole-desktop driving.--- **Gated behind shell-auto-approve** (same risk class as `shell_execute` — keystrokes/clicks hit whatever is focused on the user's live machine). If it's off you get `errorCode:"input_not_approved"`. **You (a cloud/relay AI) can't grant it yourself (v1.9.90)** — call `request_shell_approval` (or let the refusal auto-surface the prompt) and ask the user to click Approve / Bypass Permissions in Adom Desktop, then retry. Every action is written to the activity log.-- **Focus a window first** by passing `{window:"<title substring>"}` (alias `titleContains`) or `{hwnd:<int>}` — reuses `desktop_find_window`'s resolver, so input lands on the intended window, not whatever happened to be focused. Get the hwnd from `desktop_list_windows` / `desktop_find_window`.-- **Coords are physical screen pixels** — the same space as `desktop_list_windows` rects.--Verbs:-- `desktop_click {x, y, button?:"left"|"right"|"middle", double?, window?|hwnd?}` — left-click by default. `desktop_double_click` / `desktop_right_click` are wrappers; `desktop_hover` moves the cursor without clicking (reveals tooltips).-- `desktop_type {text, window?|hwnd?}` — types a literal Unicode string (`KEYEVENTF_UNICODE`, any keyboard layout).-- `desktop_press_key {keys:[...], repeat?, window?|hwnd?}` (alias `desktop_send_keys`) — chords/shortcuts. Modifiers `ctrl/alt/shift/win`; named keys `enter/tab/esc/space/backspace/delete/insert/home/end/pgup/pgdn/up/down/left/right/f1-f12`.--Canonical "navigate a browser to a URL" flow (works on `chrome://` where CDP can't):-```bash-adom-desktop desktop_press_key '{"keys":["ctrl+l"],"window":"Edge"}'   # focus Edge + select the address bar-adom-desktop desktop_type        '{"text":"edge://extensions","window":"Edge"}'-adom-desktop desktop_press_key   '{"keys":["enter"],"window":"Edge"}'-```-For a control that isn't reachable by CDP/coords (e.g. the Reload button on `edge://extensions`, which lives in a shadow DOM), Tab-navigate to it then Enter: `desktop_press_key {"keys":["tab","tab","enter"]}`.--**Visual "Adom cursor" (v1.8.171+)** — a click-through teal cursor + a label that narrates what you're driving (trust cue; byte-faithful to the browser extension's cursor). Pure visual, *not* gated:-- `desktop_cursor {action:"show"|"move"|"hide", x, y, label}` — drive/narrate the cursor on its own ("watch me"). Returns the overlay `hwnd`.-- Or pass `cursor:true` + `label:"Clicking Reload"` on `desktop_click`/`desktop_double_click`/`desktop_right_click`/`desktop_hover` — AD glides the teal cursor to the target (narrating the label) *then* does the real click.-- **Auto-hide (v1.8.174+):** the cursor hides itself after `ttlMs` (default **60s**) so it never lingers on the user's screen if you forget to hide it. Each `show`/`move` refreshes the timer. Pass `ttlMs:<ms>` (≤600000) for longer, or `persist:true` to keep it until you explicitly `desktop_cursor {action:"hide"}`. If a cursor you showed was auto-hidden, the **next** cursor/click response prefixes its `_hint` with a NOTE so you learn (no chatty unsolicited message).--#### Background input via UI Automation (v1.8.174+) — no foreground, no focus steal--The mouse/keyboard verbs above use `SendInput` = real OS input, which only lands on the **foreground** window — so AD foregrounds the target first, yanking it in front of the user's work. When you must NOT steal focus, use **UI Automation** instead: `InvokePattern.Invoke()` / `ValuePattern.SetValue()` are **programmatic** — the app services them via its accessibility tree with **no cursor move, no focus change, no foregrounding**. Chromium/Edge expose their a11y tree to UIA, so `chrome://` page buttons/links are reachable **by accessible name** (not coordinates).--- `desktop_find_control {window|hwnd, name|contains, role?}` — **read-only** discovery (ungated). Returns `best:{name, automationId, role, rect, invokable, settable}`. Call this first to learn whether a control is UIA-reachable.-- `desktop_ui_click {window|hwnd, name|contains, role?}` — UIA **Invoke** by accessible name, in the background. Gated behind shell-auto-approve.-- `desktop_ui_set {window|hwnd, name|contains, text}` — UIA **SetValue** on a text field, in the background. Gated.--`role` ∈ `button|edit|link|checkbox|menuitem|tab|listitem|combobox|radiobutton|document|group|spinner`. **Caveat (surfaced in `_hint`, not hidden):** some shadow-DOM controls aren't in the UIA tree — if `find_control` reports `invokable:false`/not-found, the control genuinely needs a **foregrounded** `desktop_click`. So: prefer `desktop_ui_click` (background) → fall back to `desktop_click` (foreground) only when UIA can't reach it.--#### Near-complete UIA surface (v1.9.0+) — enumerate, read, drive, with a foreground-hint contract--v1.9.0 expands UIA from ~3 patterns to a near-complete agent-drivable client. **Every UIA verb now returns a `foreground` block** so you know — *before and after* — whether a call steals the user's screen:--```-"foreground": { "predicted": "background|fallback-possible|foreground",  // from the static class + the resolved path, BEFORE acting-                "observed":  "background|foreground|n/a",                 // MEASURED (GetForegroundWindow before/after); reads = n/a-                "raised":    true|false,                                  // did THIS call bring a window to front?-                "pathTaken": "uia-pattern|sendinput-fallback|setfocus|read-only",-                "reason":    "<short>" }-```--**Shared selector on every verb:** `{hwnd|window(titleContains)}` + `{name|contains|automationId|role}` + `scope:"window"(default)|"popups"|"subtree:<automationId>"` + `nth:<int>` (0-based — target the Nth match without the exact name). Prefer `automationId` (stable, locale-independent). Rects are screen px with `centerX/centerY`; `space:"image"+shotId` is accepted wherever a coordinate is taken.--- **Enumeration (read-only, ungated):**-  - `desktop_ui_tree {window|hwnd, maxDepth?, roleFilter?[], nameContains?, maxNodes?(500), scope?}` — **flagship.** Dump the a11y subtree; nodes carry rect + invokable/settable + value/toggleState/expandState/selected/rangeValue + `children[]`. Honors Chromium's lazy tree (wake-retry); `truncated:true` when it hits `maxNodes` (logged, never silent).-  - `desktop_ui_from_point {x,y | space:"image",shotId,x,y}` — the control under a pixel.-  - `desktop_find_controls {selector, maxResults?(25)}` — ALL matches as an array with `nth` indices (vs `find_control`'s single best).-- **Reading (read-only, ungated):** `desktop_ui_text {selector, range?:"all"|"selection"|"visible"}` (TextPattern); `desktop_ui_get {selector}` (current value/toggle/range/selection/expand state); `desktop_ui_grid_get {selector, row?, col?}` (GridPattern cell + rowCount/columnCount).-- **Actions (gated by shell-auto-approve; background-first via the pattern, SendInput-click fallback only when the pattern is absent → then `raised:true`):** `desktop_ui_toggle {selector, state?}`, `desktop_ui_expand {selector, action?:expand|collapse|toggle}`, `desktop_ui_select {selector, action?:select|add|remove}`, `desktop_ui_scroll {selector, dir?, amount?}`, `desktop_ui_set_range {selector, value}`, `desktop_ui_focus {selector}` (**bg:foreground** — usually raises; reports it), `desktop_ui_window {window, action:minimize|maximize|restore|close|move|resize}`.-- **Events (v1.9.3 — REACT instead of poll; read-only, ungated):**-  - `desktop_ui_watch {event, window?|hwnd?, properties?}` → `{watchId}`. `event` ∈ `focus` (desktop-wide "what got focus") | `window-opened`/`window-closed` (new/closed top-level windows — these three can be global) | `invoke` | `selection` | `text-changed` | `text-selection` | `menu-opened` | `tooltip-opened` | `notification` | `live-region` | `structure` | `property` (these REQUIRE a `window`, else `errorCode:window_required` — avoids a desktop firehose). Cap 16 watches.-  - `desktop_ui_events {sinceSeq?, watchId?, max?}` → DRAIN the buffer (poll cursor; there is NO server-push over the relay). Returns `{events:[{seq, watchId, event, ts, element:{name,role,automationId,rect,hwnd}, eventType|property|changeType}], nextSeq, highestSeq, missedEvents, activeWatches}`. Poll with `sinceSeq=nextSeq`. Events ALSO appear in the AD GUI activity-log. The 500-deep ring is shared across all watches; `missedEvents:true` = you polled too slowly.-  - `desktop_ui_unwatch {watchId|"all"}` → stop. **Canonical use:** waiting on an OTP/confirm dialog → `ui_watch {event:"window-opened"}`, poll `ui_events` until the window appears, then act on it (no busy screenshot loop).-- **Introspection:** `desktop_ui_capabilities {}` — **read-only, ungated.** The foreground-hint TABLE per verb (`staticClass` + measured `{win32, chromium-pattern, chromium-fallback}`). **Consult it BEFORE a call to know if it'll steal the screen, without acting.** `desktop_ui_selftest {confirm?, window?}` writes/refreshes it (empirically measures when `confirm:true`).--**Integrity boundary:** an elevated/admin window or the UAC secure desktop returns `errorCode:"uia_integrity_blocked"` (never a silent no-op) — a normal-integrity bridge cannot drive a higher-integrity process.--#### Zero-math coordinate clicks — `coordMap` + image-space (v1.8.175+)--You no longer recompute scale/offset/DPI to click what you see in a screenshot. Every screenshot verb returns a **`coordMap`**:--```jsonc-"coordMap": {-  "shotId": "shot-w5050328-1",-  "image": { "w": 1372, "h": 1031 },              // the DELIVERED (safe) PNG's pixels-  "screenRect": { "x": 594, "y": 254, "w": 1372, "h": 1031 },  // physical px = input-verb space-  "dpiScale": 1.5,-  "imageToScreen": { "sx": 1.0, "sy": 1.0, "ox": 594, "oy": 254 }  // screen = img*s + o-}-```--Two ways to use it:-- **Let AD convert (preferred, zero math):** `desktop_click {space:"image", shotId, x, y}` where `x,y` are pixels in that screenshot. Also works on `desktop_double_click`/`right_click`/`hover`. The `shotId` comes from the capture's `coordMap.shotId` (last ~64 shots retained; re-capture if it expires).-- **Compute yourself:** `screenX = x*sx + ox`, `screenY = y*sy + oy`.--`screenRect` is in the SAME physical-px space as `desktop_list_windows` rects and the `desktop_click` default (`space:"screen"`).--> **Driving a GUI app that pops error boxes / modal dialogs? Read the dedicated [`adom-desktop-window-capture`](WINDOW_CAPTURE.md) skill.** `desktop_screenshot_window {hwnd}` captures ONE window AND the child dialogs / error boxes it owns, even when the window is backgrounded/occluded/minimized (PrintWindow, no focus steal). It is the breakthrough for automating native apps whose dialogs a full-screen grab can't see. The rest of this section is the reference; that skill is the how-and-why.--**Popups (owned):** As of **v1.8.177**, `desktop_screenshot_window` **also captures the window's OWNED popups** — borderless confirm bubbles (Chromium's "Remove '\<ext\>'?"), owned dropdowns, owned dialogs — that don't enumerate in `desktop_list_windows` and aren't visible in a PrintWindow of the main window. They ride back as a top-level **`screenshots[]`** array (the same generic shape `kicad_screenshot_all` uses; the CLI auto-pulls each entry's `fullPath`→`localPath` and `safePath`→`localSafePath`). `ownedPopupCount` is always present (0 = none). Each entry carries `hwnd`, `title`, `kind:"owned_popup"`, and its own `coordMap`+`shotId`, so you can **click the bubble's buttons** with `{space:"image", shotId, x, y}` or drive them in the background by name via `desktop_ui_click {scope:"popups"}`. This is the generic mechanism — there's no app-specific "Chrome bubble" verb; every window screenshot surfaces any owned popups so you get the data and decide. **Use `desktop_screenshot_screen`** (full virtual-screen grab) only for popups *not owned* by your target window, or to see everything at once.--**Screen geometry on every shot (v1.8.178):** every `desktop_screenshot_window` / `_screen` response now carries:-- **`windowRect: {x,y,w,h,monitor}`** — the window's position + size on the overall (possibly multi-monitor) desktop, in physical screen px. Each owned popup entry in `screenshots[]` gets its own **`rect: {x,y,w,h,monitor}`** too — so you know where every captured surface sits, not just its pixels.-- **`display: {virtualScreen, primary{w,h,dpi,scale}, monitors[], monitorOfWindow}`** — the full monitor topology: total virtual bounds, and per-monitor `bounds`/`dpi`/`scale`/`primary`. `monitorOfWindow` indexes into `monitors[]`. (Same data as the standalone `desktop_display` verb, folded in.)-- **`drive: "background"`** — screenshots never foreground the window (PrintWindow/WGC). The companion `_related` field lists sibling screenshot/window verbs.--**Screenshot cache is self-cleaning + self-describing (v1.9.107/108/113):** the CLI auto-pulls every screenshot to ONE user-visible, namespaced dir on the caller's machine (`~/project/screenshots/adom-desktop/`, else `~/.adom/screenshots/`; `ADOM_SHOTS_DIR` overrides) — never `/tmp` anymore. Shots are **bucketed by kind into subfolders**: `window/` (a specific window / app / owned popup) and `screen/` (a full-desktop grab). **Human-readable filenames (v1.9.113):** `<YYYY-MM-DDThh-mm-ss.mmm>[-<hwnd>].png` — a sortable ISO stamp (lexicographic == chronological), plus the `hwnd` for a window capture; the `window`/`screen` kind is NOT repeated in the name because the folder already says it. **One file when small (v1.9.114):** if the shot was already within the safe size cap (the common case) it's a single `<stem>.png` and `localFullPath == localSafePath` — no redundant re-encoded copy. Only a shot large enough to be downscaled produces a `<stem>.full.png` + `<stem>.safe.png` pair (Read the `.safe` one for image analysis; use `.full` for archive). Each shot gets a **`<stem>.json` sidecar** (shotlog-style: `source`, `timestamp`, `kind`, dims + `bytes`, `coordMap`, `rect`; a full+safe pair shares one). **Parent/child grouping (v1.9.111/113):** when `desktop_screenshot_window` returns owned popups in `screenshots[]`, the parent AND each pulled child share the same stem, and each child gets a **`.popup-NN` label** — e.g. parent `2026-07-15T01-14-16.937-986710.safe.png` and children `2026-07-15T01-14-16.937-986710.popup-01.safe.png`, `...popup-02...`. So a plain `ls` shows the whole capture grouped (shared stem) and the role (`.popup-NN`) without opening a sidecar; the sidecar also carries `parent` + `popupIndex`. (`kicad_screenshot_all` returns sibling windows with no parent, so those keep their own names.) A janitor prunes it (7 days / 2 GB / newest kept; sidecars follow their PNG, orphans swept) and drains the old `/tmp/ad-shots` + `/tmp/adom-desktop-screenshots` backlogs, running opportunistically at most once/24h on any CLI call. **You do not need to clean it up or write your own cron.** Every screenshot response carries a `_cacheHint`; verify coverage with **`adom-desktop janitor`** (status: each subfolder, size, policy, last sweep) or force a sweep with **`adom-desktop janitor '{"sub":"run"}'`**. It never touches other tools' files under `~/project/screenshots/` (e.g. shotlog). Tune via `ADOM_SHOTS_MAX_AGE_DAYS` / `ADOM_SHOTS_MAX_GB` / `ADOM_SHOTS_MAX_FILES`, or disable with `ADOM_JANITOR=0`.--**Background vs foreground driving (v1.8.178):** every desktop driving verb carries a structured **`drive`** field (`"background"` | `"foreground"` | `"n/a"`) — in `GET /commands`, in `adom-desktop help <verb>`, and echoed in the verb's own response. **Background** (no foreground / focus steal / cursor move — UIA + capture): `desktop_ui_click`, `desktop_ui_set`, `desktop_navigate`, `desktop_find_control`, `desktop_screenshot_window/_screen`, `desktop_record_window_start`. **Foreground** (SendInput → goes to the focused window, so AD foregrounds first): `desktop_click`/`_double_click`/`_right_click`/`_hover`, `desktop_type`, `desktop_press_key`, `desktop_bring_to_front`. Run **`adom-desktop help background`** for the full grouping. Rule: to act without disturbing the user's window, drive by NAME (`desktop_find_control` → `desktop_ui_click`/`ui_set`); fall back to coordinate clicks/keys only when the control isn't in the UIA tree.--**Click a control by name → coords:** `desktop_find_control` returns `best.rect{x,y,w,h,centerX,centerY}` in screen px — `desktop_click {x:centerX, y:centerY}` to SendInput-click it (handy when `invokable`/`settable` are both false). Target by `name`, `contains`, `role`, or `automationId` (stable, locale-independent).--**Keyboard chords:** `desktop_press_key` accepts a chord as one string `["ctrl+w"]` OR separate tokens `["ctrl","w"]` — both mean Ctrl held + W. A *sequence* of distinct chords uses `+` per element: `["ctrl+l","enter"]`. The reply echoes `chords` showing how AD interpreted your `keys`.--- `desktop_flash_window` (v1.8.152+) -- Flash an **arbitrary** window's taskbar button orange (Win32 `FlashWindowEx`) as an "attention / ongoing activity" cue. Resolve the HWND with `desktop_list_windows` first. Args: `hwnd` (int) OR `titleContains` (string); `mode` (`"until_focused"` default — flashes until the user clicks the window; or `"count"`); `count` (int, `mode:"count"` only, default 3); `stop` (bool — clear an active flash). Returns `{ok, hwnd, title, mode}`. **Lightweight by design** (direct FlashWindowEx, no process spawn) so a bridge can call it after every verb. Distinct from the puppeteer bridge's `browser_alert_window`, which only flashes pup's OWN tracked windows — this flashes any window on the host. For progress + overlay-icon status, use `desktop_taskbar`.-  - Example: `adom-desktop desktop_flash_window '{"hwnd":133560}'` then `adom-desktop desktop_flash_window '{"hwnd":133560,"stop":true}'` to clear.-- `desktop_taskbar` (v1.8.152+) -- Paint **glanceable status** on any window's taskbar button via `ITaskbarList3` (progress bar + overlay icon) + `FlashWindowEx`, so a bridge can show what the AI is doing on the **real app window it drives** (e.g. native-browser putting an "Adom is driving" badge + task progress on the Chrome/Edge button). Works on windows owned by **other processes**. Persistent COM (init once, reuse) — safe to tick progress frequently; no process spawn. Resolve the HWND via `desktop_list_windows`. Args (all channels optional; provide ≥1):-  - `hwnd` (int) OR `titleContains` (string)-  - `flash`: `"until_focused"` | `"stop"`-  - `progress`: `{state: "normal"|"paused"|"error"|"indeterminate"|"none", value?: 0..100}` — **state→color**: normal=green, **paused=yellow** (use for human-in-the-loop gates: captcha / 2FA / login), error=red, indeterminate=marquee, none=clear.-  - `overlay`: `{badge: "adom"|"none", icon?: "<PNG or .ico — file path OR base64>", tooltip?: "<hover>", avatar?, profileDir?, userDataDir?, composite?}` — built-in badges are `adom` (the Adom logo — a "driven by Adom" trust tell) and `none` (clear); 3rd-party bridges pass their **own** `icon` (a **PNG** or `.ico`, file path or base64 — AD magic-sniffs + renders at 32px). AD stays generic: it paints whatever art you hand it. `tooltip` is the overlay's accessible description.-    - **Compositing onto a Chrome/Edge profile avatar (v1.9.110, #154):** Windows gives ONE overlay slot per taskbar button, and Chrome/Edge use it for the **profile avatar** — so a bare `badge:"adom"` would EVICT the profile face (you'd lose "work vs personal"). To keep the avatar AND show "Adom is driving", pass the avatar and AD composites the mark onto it (bottom-right ~47%): `overlay:{badge:"adom", avatar:"<PNG path or base64>"}`, or name the profile with `overlay:{badge:"adom", profileDir:"Profile 1", userDataDir?:"<User Data root>"}` (AD reads `<UserData>/<profileDir>/Google Profile Picture.png`; `userDataDir` is derived from the window's exe if omitted). `composite:false` opts out (raw mark). If the avatar can't be resolved AD falls back to the plain mark — never errors. The CALLER (which knows its profile — e.g. the browser extension) supplies the avatar; AD does not reverse-engineer it (one browser process serves all profiles, so the process command line has no `--profile-directory`).-  - `thumbnailTooltip` (v1.9.153): the taskbar **hover-preview tooltip** (`ITaskbarList3::SetThumbnailTooltip`). The ONLY uncapped text surface on the preview — the window title truncates ~25-30 chars, this string does not (and takes newlines). e.g. `"Adom Pup / Adom wiki / LOGGED-IN view / Chrome for Testing / 3 tabs"`. Pass `""` to clear it back to the title. Works on a window AD does not own.-  - `thumbnailClip` (v1.9.153): crop the live hover preview to a client-space region — `{x,y,w,h}`, or `null` to reset to the whole window (`SetThumbnailClip`).-  - Returns `{ok, hwnd, title, applied:[...]}`. Clears: `flash:"stop"`, `progress.state:"none"`, `overlay.badge:"none"`, `thumbnailTooltip:""`, `thumbnailClip:null` — call these on task-end / window-close to clean up.-  - Example (long task, human gate, then done):-    ```bash-    adom-desktop desktop_taskbar '{"hwnd":133560,"overlay":{"badge":"adom","tooltip":"Adom is driving this browser"},"progress":{"state":"indeterminate"}}'-    adom-desktop desktop_taskbar '{"hwnd":133560,"progress":{"state":"paused"},"flash":"until_focused"}'   # captcha — come look-    adom-desktop desktop_taskbar '{"hwnd":133560,"progress":{"state":"none"},"overlay":{"badge":"none"},"flash":"stop"}'  # done — clear all-    ```-- `desktop_set_window_identity` + `desktop_register_app_identity` (v1.9.134+) -- **Full window-identity takeover.** Make a window you drive present itself as YOUR app: the taskbar button, Alt-Tab entry, hover tooltip and any user pin read your name and icon instead of the process that happens to own the HWND. Built for a bridge driving a borrowed host app (a Chrome for Testing window otherwise reads "Test" with Chrome's icon, which is two brands and a confusing word). AD is generic here — it applies whatever `appId` / `iconPath` / `displayName` you pass and knows nothing about which app that is.--  **You need BOTH halves.** `set_window_identity` stamps a LIVE window (`PKEY_AppUserModel_ID` + `WM_SETICON`); `register_app_identity` teaches Windows what that `appId` MEANS (an HKCU `AppUserModelId` key + a Start Menu shortcut carrying the same AUMID). An unregistered appId falls back to the owning exe's icon, so half one alone leaves Alt-Tab wrong.--    ```bash-    # Once per install. Ship the .ico in your bridge's release zip; pass its cache path.-    # `target`/`args` are what a user's PINNED tile launches — point it at a real verb so-    # the pin opens a managed window instead of an orphan app.-    adom-desktop desktop_register_app_identity '{"appId":"Adom.Pup","displayName":"Adom Pup",-      "iconPath":"C:/.../adom-pup.ico",-      "args":"browser_open_window {\"sessionId\":\"pinned\",\"owner\":\"pinned-launch\"}"}'--    # Per window, e.g. at park time. cacheKey survives page-title churn.-    adom-desktop desktop_set_window_identity '{"cacheKey":"<sessionId>",-      "titleContains":"(session: <sessionId>)","appId":"Adom.Pup",-      "displayName":"Adom Pup","iconPath":"C:/.../adom-pup.ico"}'-    ```-  - Window target: `hwnd` OR `titleContains` OR `cacheKey` (+ `fresh` to re-resolve). Response carries `resolvedBy` (`hwnd` | `cachedHwnd` | `title`) and `applied[]`.-  - **Identity is per-HWND and dies with the window** — re-apply after a window is recreated.-  - AD holds the icon handles for its whole process lifetime. Icon handles are process-owned: a transient setter leaves windows pointing at dead handles, so the icon reverts moments later. AD being long-lived is exactly why it owns them.-  - `register_app_identity` writes HKCU only (never a UAC prompt). It is gated for RELAY callers like `write_file`; a bridge calling over the loopback API is trusted by transport and ungated.-  - **Jump-list tasks (v1.9.139+): `desktop_set_window_jumplist {appId, tasks}`** puts YOUR actions on the taskbar right-click menu of every window stamped with that appId. Jump lists attach to the AUMID, so per-session appIds give each window its OWN menu. Each task: `{title (required), target? (exe, defaults to AD's bundled CLI — so `args` can simply be an AD/bridge verb + JSON), args?, iconPath?, description?}`. Tasks launch minimized (no console flash). Max 16; `tasks:[]` clears (call at session close, alongside `unregister_app_identity`). Re-set anytime to update the menu. Tier-2 gated for relay callers; loopback bridges ungated.-  - **Per-session / transient appIds (v1.9.136+): register REGISTRY-ONLY with `shortcut:false`, unregister on close.** If you stamp per-session AUMIDs (e.g. `Adom.Pup.<sessionId>` so each window keeps its own taskbar button in split mode), an UNREGISTERED AUMID makes Alt-Tab fall back to the owning exe's icon. `desktop_register_app_identity {appId, displayName, iconPath, shortcut:false}` writes just the HKCU AppUserModelId key (DisplayName + IconUri) — Alt-Tab and taskbar hover resolve the name + icon with NO Start Menu entry. Pair with `desktop_unregister_app_identity {appId}` at session close (idempotent; removes the key plus any shortcut carrying that AUMID; AD's own `inc.adom.desktop` identity is protected). Keep ONE shortcut-mode registration as the pinnable launcher.-  - **`desktop_set_window_jumplist`** (v1.9.139+) adds the right-click menu on the taskbar button (per-AUMID; `tasks:[]` clears) and, with `hwnd`+`headerIcon`, brands the header row (the app tile above Pin/Close).-  - **Icon format — read the `iconCheck` these verbs return (v1.9.152).** `desktop_set_window_identity` / `desktop_register_app_identity` / `desktop_set_window_jumplist` return an `iconCheck {kind,sizes,issues,ok,hint}` (or `headerIconCheck`) that magic-sniffs the file and explains why an icon would render as a generic white document: a **256px `.ico` size not PNG-compressed** (the Vista+ trap), **SVG** (the Win32 shell can't load it), a `.ico` with no small size, an `.exe` where an image is wanted. If `iconCheck.ok` is false, fix the file and re-stamp. Ship a multi-size `.ico` (16/24/32/48/256, the 256 PNG-compressed). **Full guide:** the `adom-desktop-window-identity` skill (AUMID model, the register+stamp two-halves rule, jump-list header, taskbar overlay + thumbnail, the live-tile trap, a recipe).-- `desktop_revoke_approvals` -- Revoke all shell auto-approve permissions and deny pending approvals. The next shell command will show the approval dialog.-  - Example: `adom-desktop desktop_revoke_approvals`-- `desktop_caption` -- Show a global caption overlay that stays visible above ALL windows (KiCad, Fusion, Chrome). Native Win32 overlay — not a browser DOM element. Click-through: mouse events pass to windows below. Captured by screen recordings (browser `getDisplayMedia`, OBS, Game Bar).-  - Show: `adom-desktop desktop_caption '{"text":"Step 1: Opening the board","position":"top","size":"large","duration":3000}'`-  - Custom position: `adom-desktop desktop_caption '{"text":"Look here","x":0.3,"y":0.2,"size":"large","persist":true}'`-  - Hide: `adom-desktop desktop_caption '{"action":"hide"}'`-  - List what's on screen (v1.9.217): `adom-desktop desktop_caption '{"action":"list"}'` — returns `{captions:[{id,ageMs,expiresInMs,persist,overdue}]}`; the authoritative check (caption overlays do NOT appear in `desktop_list_windows`). TTLs are fail-safe: a 15s reaper closes any caption that outlives its deadline. Do not keep a caption alive with a same-id refresh loop as a nag — the user experiences one endless caption.-  - Multiple simultaneous captions (use `id` to keep them independent):-    ```-    adom-desktop desktop_caption '{"text":"Step 1: Opening board","id":"step","position":"center","size":"large","persist":true}'-    adom-desktop desktop_caption '{"text":"REC ●","id":"status","position":"bottom-left","size":"medium","persist":true}'-    ```-  - Replace only the step caption (status stays):-    ```-    adom-desktop desktop_caption '{"text":"Step 2: Routing","id":"step","position":"center","size":"large","persist":true}'-    ```-  - Hide just the step caption: `adom-desktop desktop_caption '{"action":"hide","id":"step"}'`-  - Args:-    - `text` (string) — caption text-    - `id` (string, optional) — identifies this caption. Captions with the same id replace each other; different ids coexist simultaneously. Default `"_default"` — so bare calls without id still replace each other for backward compat.-    - `position` — preset: `"top"`, `"bottom"` (default), `"center"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`-    - `x` (float 0.0–1.0) — normalized screen X, overrides position horizontal. 0.0 = left edge, 1.0 = right edge. Centers the caption box on this point.-    - `y` (float 0.0–1.0) — normalized screen Y, overrides position vertical. 0.0 = top edge, 1.0 = bottom edge.-    - `size` — preset: `"large"` (72px), `"medium"` (32px, default), `"small"` (20px)-    - `fontSize` (int) — custom font size in px (8–400). Overrides `size` preset when provided.-    - `duration` (ms) — auto-dismiss timer. **Omitted or `0` → `30000` (30s); clamped to max `600000` (10 min).** Captions ALWAYS auto-expire (v1.8.138+) so a forgotten one can't be stranded on the user's desktop. To keep one up longer, **re-issue the same `id`** (resets the timer) or pass a bigger `duration`.-    - `persist` (bool, optional) — `true` = sticky, NO auto-dismiss. Supervised use only (e.g. demo recordings); you MUST then dismiss it yourself with `action:"hide"`. This is the only way to a never-expiring caption (`duration:0` no longer does that).-    - `action` — `"hide"` to dismiss a caption (with `id`: hides only that caption; without `id`: hides all captions), `"force-clear"` to EnumWindows and destroy ALL caption windows regardless of id (nuclear option — use at top of demo scripts for guaranteed clean slate)-  - Captions with the same `id` replace each other instantly (previous destroyed, no fade overlap). Captions with different ids coexist — multiple captions can be visible simultaneously.--### Desktop Screenshots--Take screenshots of the user's desktop or individual windows. All screenshots use lossless PNG.--**Always save screenshots** to `project-content/screenshots/`.--**Naming convention:** `desktop-<descriptive-name>-YYYY-MM-DD-HhMMam/pm.png`--**Workflow:** Call `desktop_list_windows` first to get HWNDs, then `desktop_screenshot_window` with the HWND.--- `desktop_list_windows` -- List all visible windows (returns HWND, title, class name, position/size, and `z` = Z-order index, 0 = topmost)-- `desktop_find_window` (v1.8.153+) -- Resolve a window's HWND by `titleContains` and/or `className` (case-insensitive substrings), returned in Z-order with `best` = the topmost match. Use this instead of diffing `desktop_list_windows` before/after launching something — it's race-free for "the window I just opened." Returns `{ok, count, best:{hwnd,title,className,z,rect}|null, windows[]}` (`ok` true only when ≥1 matched). Pass `best.hwnd` to `desktop_taskbar` / `desktop_flash_window` / `desktop_screenshot_window`.-  - Example: `adom-desktop desktop_find_window '{"className":"Chrome_WidgetWin_1","titleContains":"GitHub"}'`-- `desktop_activity_filter` (v1.8.155+) -- Scope the **user's GUI activity log** so they watch the slice you care about while you work (e.g. focus it on one bridge before a debugging session). Args: `bridge` (a bridge registry name from `bridge_list` | `"none"` = only global/non-bridge AD commands | `"all"`/omit = everything), `text` (free-text substring, ANDs with bridge), `clear` (bool — reset). **Drives the human's GUI view only — it does NOT change which verbs you can call or what they return.** The GUI shows a status bar stating exactly what's shown + what's hidden.-  - Example: `adom-desktop desktop_activity_filter '{"bridge":"kicad"}'` then `adom-desktop desktop_activity_filter '{"clear":true}'`-- `desktop_screenshot_window` -- Capture a specific window by HWND-- `desktop_screenshot_screen` -- Capture the entire desktop (all monitors)-- `desktop_ad_scroll` (v1.9.86+) -- **Scroll AD's OWN window panels.** AD is a fully AI-controllable app, so you can drive its own scrollbars — most usefully to page the **bridges sidebar** and screenshot cards that are below the fold. Args: `panel` (`bridges` default | `activity` | `connections`), `to` (`top`|`bottom`, absolute — wins over `dy`), `dy` (relative px, default 400; negative = up). Returns `{ok, panel, scrolled, state:{scrollTop,scrollHeight,clientHeight,atTop,atBottom,found}|null, _hint}` — round-tripped from the live window so you can **loop until `state.atBottom==true`**. A collapsed bridges sidebar is auto-expanded first; `state==null` means the panel didn't report (collapsed/empty).-  - Capture-all-cards workflow: `desktop_ad_scroll '{"panel":"bridges","to":"top"}'` → `desktop_screenshot_window` (AD hwnd) → `desktop_ad_scroll '{"panel":"bridges"}'` → screenshot → repeat until `atBottom`.--### App self-update (relay-driven, v1.9.87+)--AD auto-updates itself from the wiki manifest, but historically only a HUMAN clicking the footer "Install now" banner could apply it — so a headless / RDP / fleet box (a fresh Azure VM) could sit a version behind with no path forward from the relay. These verbs give the AI that path (same shape as the shell-approval surfacing verbs). Use `--target <box>`.--- `update_status` (read-only, ungated) -- `{currentVersion, latestVersion, updateAvailable, updateReady, state, busy, updateMode}`. `updateReady:true` = the newer version is downloaded + SHA-verified on disk, appliable with no network.-- `update_check` -- force a manifest check now; if newer, download + verify (stage) it in the background. Poll `update_status` until `updateReady:true`.-- `apply_update` -- the programmatic "Install now": apply the staged update + relaunch AD. Respects `busy` (refuses if a command is in flight). Only ever applies AD's own downloaded + SHA-verified SIGNED wiki release. Returns `{applying:true}` then AD restarts (the relay drops + reconnects; poll `targets`/`ping` for the new version). If not staged yet, it kicks staging and tells you to poll + retry.-- `request_update_approval` -- surface the "Update available — Install now / Later" banner on the target + bring AD to the front (for an ATTENDED box where the human should decide). Does NOT apply; `apply_update` installs.-- `update_set_mode {mode: auto|prompt|off}` -- the auto-apply POLICY. `auto` = unattended stage+apply on the 4h/on-launch poll (set this ONCE on headless fleet boxes and they self-update every release with no relay call); `prompt` = show the banner (default, attended); `off` = no checks.-  - Fleet flow: `update_status --target <box>` → if `updateAvailable` and unattended, `apply_update --target <box>` (or set `update_set_mode {"mode":"auto"}` once). A box must already be on ≥1.9.87 to have these verbs; the one-time bootstrap of an older box still needs a human click OR `auto` mode.--### Hydrogen companion mode (v1.9.218+)--When a HUMAN opens AD on a machine where Adom Hydrogen is detected (embedded marker, Hydrogen's ports.json, or a discoverable Hydrogen install), AD shows an identity overlay ("This is Adom Desktop" + a large Open Adom Hydrogen button), instantly opens or foregrounds Hydrogen, and places its own window directly below Hydrogen's in z-order — most people who open AD by hand were looking for Hydrogen. Facts an AI should know:--- Human-open detection = bare argv AND parent process `explorer.exe` (double-click, Start menu, search, pins). Machine launches — `--autostart` boot, Hydrogen `--embedded` respawns, the relauncher/`restart` verb, CLI-style invocations — NEVER trigger it, so a remote `restart` cannot bounce the user's screen.-- One-shot placement: AD does not fight the user's later alt-tabbing, and it is NOT a real Win32 child window (AD's lifecycle stays independent — the recovery-channel contract holds; if Hydrogen cannot be launched the overlay says so and AD stays fully usable).-- Toggle: Settings > Adom Hydrogen > "Act as a companion to Adom Hydrogen", or `settings_set {"settings":{"hd_child_mode":false}}`. Default ON (self-gates on Hydrogen detection, so standalone installs never see it).-- Forensics: every decision slogs `HD-COMPANION:` lines to startup.log.--### User settings over the API (`settings_get` / `settings_set`, v1.9.214+)--AD's Settings-panel preferences are readable and settable over the API. This is the sanctioned channel for an embedding app (Adom Hydrogen) to render AD's settings in its own panel and forward the user's changes; it also lets a fleet operator read or fix a box's boot/update prefs remotely.--```bash-adom-desktop settings_get-adom-desktop settings_set '{"settings": {"launch_on_boot": true, "update_mode": "auto"}}'-```--- Settable keys (whitelist): `launch_on_boot`, `always_show_window_on_launch`, `update_mode` (auto|prompt|off), `auto_restart_for_updates`, `update_idle_minutes` (1..1440), `prewarm_runtimes`, `prewarm_cft` (auto|always|never), `file_receive_directory`. Anything else lands in `rejected` — servers, shell approval (grants are human-only), and blocked peers have their own surfaces and are deliberately not settable here.-- `launch_on_boot` changes apply the OS Startup artifact immediately; `settings_get` also returns `autostartArtifactPresent` so you can see intent-vs-artifact drift.-- `settings_set` is Tier-2 gated for relay (cloud-AI) callers — carry a `reason` and expect the human-approval flow, like `shell_execute`. Local/loopback callers (Hydrogen, bridges, the local CLI) are trusted by transport.-- Self-heal: AD re-verifies the launch-on-boot artifact every ~60s while running (not just at launch), so anything that deletes the Startup shortcut behind AD's back gets healed within a minute — with a `RECONCILE:` forensic line in `startup.log`.--### Browser automation (`browser_*`, Puppeteer/pup) — bridge-owned; light pointer--The pup bridge is **cloud-owned** and ships its OWN full skill (its wiki page / skill pkg — `bridge_list` names it; `adom-desktop sync_skills` installs it). This page keeps only the pointer plus first-touch examples; sessions/profiles, credential vault, downloads, recording, CDP details are the bridge's skill.--Most-used verbs:--```bash-adom-desktop browser_open_window '{"sessionId": "s1", "profile": "p1", "url": "https://example.com"}'-adom-desktop browser_navigate '{"sessionId": "s1", "url": "https://example.com/page2"}'-adom-desktop browser_screenshot '{"sessionId": "s1"}'-adom-desktop browser_eval '{"sessionId": "s1", "expression": "document.title"}'-adom-desktop browser_fetch_url '{"url": "https://example.com/data.json"}'-```--`browser_readiness` is the read-only probe (never spawns or downloads). Full details: the bridge's own skill via `sync_skills`.--### Screen recording — AD's desktop recorder (tab recording is pup's)--AD owns DESKTOP recording: a visible HUD opens with the REASON you supply, so the user always sees that recording is on and why. Full guide: the `adom-desktop-recording` skill.--```bash-adom-desktop desktop_recorder_open '{"reason": "record the DRC walkthrough for review"}'-adom-desktop desktop_record_start '{"confirmDesktopNotTabRecording": true}'-# ... drive the desktop ...-adom-desktop desktop_record_stop '{}'-adom-desktop desktop_record_list '{}'-adom-desktop desktop_recorder_close '{}'-```--`desktop_record_start` without `confirmDesktopNotTabRecording: true` refuses with `errorCode: desktop_record_needs_confirmation` — that guard exists because recording a BROWSER TAB is the pup bridge's separate feature (`browser_record_start` etc., see pup's own skill).--### Filesystem primitives — `desktop_list_files` / `desktop_watch_files` / `desktop_pull_glob` (v1.5.0+)--**These are the canonical "wait for a download" primitives.** They replace the `shell_execute` + PowerShell-poll-Downloads pattern that earlier callers (chip-fetcher, similar tools) had to use. No shell, no user approval prompt, no parsing of `dir` output. They run pure Rust on the desktop side, so they're fast, predictable, and never trip over PowerShell quoting.--The pattern they replace looks like this:--```powershell-# OLD — DON'T do this for new code:-$before = (Get-Date).ToFileTime()-# … click download …-while (-not (Get-ChildItem ~/Downloads -Filter ul_*.zip | Where { $_.LastWriteTime.ToFileTime() -gt $before })) {-  Start-Sleep -Seconds 1-}-$file = Get-ChildItem ~/Downloads -Filter ul_*.zip | Sort LastWriteTime -Desc | Select -First 1-```--becomes one call:--```bash-# NEW — single primitive, no shell:-adom-desktop desktop_watch_files '{-  "path": "%USERPROFILE%\\Downloads",-  "glob": "ul_*.zip",-  "timeoutMs": 60000-}'-# → {ok:true, file:{path,name,size,mtime}, elapsedMs} on success-# → {ok:false, error:"timeout", elapsedMs, _hint, ...} on timeout-```--#### `desktop_list_files` — one-shot directory listing--Lists files in a directory (non-recursive) matching a shell-glob, optionally filtered to those modified after a given timestamp.--- Args: `path` (Windows abs path; `~` / `%USERPROFILE%` / `%APPDATA%` / `%LOCALAPPDATA%` / `%TEMP%` are expanded), `glob` (default `*`; shell-style with `*` and `?`; case-insensitive on Windows), `modifiedSince` (optional unix-seconds OR ISO-8601 string).-- Returns: `{ok, path, glob, files: [{path, name, size, mtime}, ...] sorted newest-first, count}`.-- Does NOT recurse. Lists one directory only.--#### `desktop_watch_files` — block until a match arrives--Polls the directory every `pollMs` (default 1000) until at least one file matches the glob with `mtime > since`, or `timeoutMs` (default 60000, max 600000 = 10 min) elapses.--- Args: same as `desktop_list_files` plus `since` (defaults to **now** — without an explicit value, only NEW files report), `timeoutMs`, `pollMs`.-- Returns on match: `{ok:true, file:{path,name,size,mtime}, elapsedMs}`.-- Returns on timeout: `{ok:false, error:"timeout", elapsedMs, _hint, path, glob}`.-- The `since`-defaults-to-now behavior matches the canonical "click then watch" flow. If you might miss the file by starting the watch slightly late (e.g. fast small downloads), record `$(date +%s)` BEFORE the click and pass it as `since`.--#### `desktop_pull_glob` — one-shot orchestrator (recommended for download flows)--Composes `desktop_list_files` (or `desktop_watch_files` if `wait:true`) with the existing streaming `pull_file` mechanism. The whole "wait for a download then pull it" flow in one call:--```bash-BEFORE=$(date +%s)-# … trigger the download click via browser_input_dispatch …-adom-desktop desktop_pull_glob "{-  \"path\": \"%USERPROFILE%\\\\Downloads\",-  \"glob\": \"ul_*.zip\",-  \"since\": $BEFORE,-  \"wait\": true,-  \"timeoutMs\": 60000,-  \"saveTo\": \"/tmp/cse-out\"-}"-# → {ok:true, files:[{name,path,size,sha256,chunks}, ...], errors:[], matchedCount}-```--- When `wait:true`: blocks via `desktop_watch_files` until at least one match appears, then re-lists the directory and pulls **everything matching** (so a `.crdownload` + final `.zip` written in the same poll tick both come along).-- When `wait:false` (default): just lists what's there now and pulls those.-- Pulls use the existing `pull_file` streaming pipeline — sha256-verified, ~1MB chunks, resumes the same way `pull_file` does.--#### Glob semantics--- `*` matches any run of non-separator chars (within a single filename — these primitives don't recurse).-- `?` matches a single non-separator char.-- Case-insensitive on Windows (matches the filesystem); case-sensitive on macOS/Linux.-- Examples: `ul_*.zip`, `LIB_*.zip`, `*.pdf`, `datasheet_*.pdf`, `*.step`, `*.STEP` (same on Windows).--#### Path expansion--- `~` and `~/foo` → home dir (Linux/macOS convention; works on Windows too).-- `%USERPROFILE%` → home dir (Windows; also works cross-platform).-- `%APPDATA%` → Windows roaming app data (`dirs::config_dir()` on others).-- `%LOCALAPPDATA%` → Windows local app data (`dirs::data_local_dir()` on others).-- `%TEMP%` → Windows TEMP env var; `/tmp` on Linux/macOS.-- Expansion is case-insensitive: `%userprofile%` works the same as `%USERPROFILE%`.-- Forward slashes (`/`) and back slashes (`\`) both work in the path string.--#### Time format for `since` / `modifiedSince`--- Number: unix seconds (e.g. `1746483600`). Floats accepted.-- String of digits: same (e.g. `"1746483600"`).-- ISO-8601 / RFC-3339 string: e.g. `"2026-05-04T22:30:00Z"` or with offset `"2026-05-04T15:30:00-07:00"`.-- Files with `mtime <= since` are filtered out (strict `>`).--### Adom Hydrogen (`hd_*`) — proxy + build/lifecycle for the sibling Tauri app--When the user has **Adom Hydrogen** running (a sibling Tauri v2 app at `C:\Github\adom-hydrogen`), built-in `hd_*` verbs reach into it. NOT a separate bridge process — adom-desktop proxies HTTP calls directly to Hydrogen's control API.--**Port discovery (v1.8.72+):** Hydrogen's control API lives on **port `47084`** (default; the actual port is read from `%APPDATA%\adom-hydrogen\ports.json` key `control` on every `hd_api` call, fallback 47084). Pre-v1.8.72 the verbs were hardcoded to Hydrogen's legacy `:9001` webview-eval port, which Hydrogen doesn't bind anymore — that's why `hd_status` reporting "Hydrogen not running" while Hydrogen's window is visible is the upgrade signal.--#### The canonical generic verb — `hd_api` (v1.8.72+)--```bash-adom-desktop hd_api '{"method":"GET","path":"/health"}'-adom-desktop hd_api '{"method":"POST","path":"/setup/run-step","body":{"id":"welcome"}}'-adom-desktop hd_api '{"method":"GET","path":"/wsl/status"}'-```--Generic HTTP pass-through to any Hydrogen endpoint. Raw JSON in `body` — no curl-through-the-relay quote mangling. Returns `{success, status, body, port, method, path}` mirroring Hydrogen's response. If Hydrogen isn't running, returns `{ok:false, error:"Hydrogen not reachable on :47084", _hint:"..."}` instead of timing out.--**Public denylist** — these paths are refused with `{ok:false, error:"not_allowed_in_public", denylist_match:"..."}` on public AD builds (use the internal `hd-dev` cargo feature to bypass):--```-/wsl/unregister, /setup/virgin-reset, /container/destroy,-/container/reset, /workspace/reset, /workspace/destroy-```--#### Inspect + drive a running Hydrogen (v1.8.15+, post-v1.8.72 rework)--| Verb | What | Status |-|---|---|---|-| `hd_status` | Composes AD-side facts (running, repo SHA) + Hydrogen's `/workspace/health` + `/buildinfo` relayed | ✓ shipped |-| `hd_api` | Generic HTTP pass-through (above) | ✓ v1.8.72+ |-| `hd_log '{"tail":30}'` | Tail `%APPDATA%\adom-hydrogen\adom-hydrogen.log` (reads disk — works EVEN when Hydrogen is down) | ✓ shipped |-| `hd_open_url '{"url":"...","browser":"chrome","profileDir":"Default"}'` | POST `/open-in-profile` — open URL in a specific browser profile | ✓ shipped |-| `hd_browser_profiles` | GET `/browser-profiles` — enumerate browsers + profiles | ✓ shipped |-| `hd_screenshot` / `hd_shot` | Find Hydrogen window, capture lossless PNG via PrintWindow; `hd_shot` adds region-scoped variant | ✓ shipped |-| `hd_eval '{"js":"..."}'` | POST `/eval` — run JS in Hydrogen's main webview | ⚠ **Moved to `hd-dev` in v1.8.72.** Public returns `verb_moved_to_hd_dev`. Use `hd_api '{"method":"POST","path":"/eval","body":{"js":"..."}}'` |-| `hd_iframe_eval '{"js":"...","contextIndex":0}'` | POST `/iframe-eval` — JS in code-server iframe via CDP | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |-| `hd_reload_vscode` | POST `/reload-vscode` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |-| `hd_container_exec '{"command":"..."}'` | POST `/container-exec` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |-| `hd_launch` / `hd_stop` / `hd_restart` | Hydrogen process lifecycle | ✓ shipped |-| `hd_ship` | Atomic build+relaunch+verify-SHA (see below) | ✓ shipped |-| `hd_build*` (v1.8.16+) | Build pipeline orchestration | ⚠ Moved to `hd-dev` in v1.8.72 (Hydrogen developers only) |--#### Atomic ship — `hd_ship` (v1.8.49+) — START HERE for post-push rebuilds--**One verb, one call, one structured pass/fail.** Replaces the 4-step chain `hd_stop` → `hd_build_rust` → poll `hd_build_status` → `hd_launch` → `curl :47084/buildinfo`. Eliminates the "is the new code actually running?" guesswork after every push.--Sequence inside one call:-1. taskkill Hydrogen + poll until zero instances (frees the exe lock)-2. `git fetch && git reset --hard origin/main && rev-parse HEAD` — asserts `HEAD == expectSha` (fails fast if GitHub hasn't replicated the push yet)-3. if `clean:true`, `cargo clean -p adom-hydrogen` (defeats stale-relink)-4. `cargo build` and parse stderr for `Compiling adom-hydrogen` to know whether anything actually compiled-5. launch + poll `http://127.0.0.1:47084/buildinfo` until it responds + assert `build_sha == expectSha`--```bash-# End-to-end after a push:-git push && SHA=$(git rev-parse HEAD) && \-  adom-desktop hd_ship "{\"expectSha\":\"$SHA\"}"-```--Args:-- `expectSha` — **REQUIRED** string. Full or prefix SHA of the commit you want running. Bidirectional-prefix match (`abc123` matches the full SHA starting with `abc123` and vice versa).-- `clean` — optional bool, default `false`. `true` forces `cargo clean -p adom-hydrogen` before build. Use when a prior `hd_ship` succeeded but `runningSha != expectSha` (the stale-relink trap — cargo said `Finished` but reused the cached binary).--Returns on success: `{ok:true, builtSha (== expectSha), runningSha (== expectSha), compiled:bool, elapsedSecs, _hint}`. `compiled:false` on success means cargo found nothing to recompile (cached relink or HEAD didn't actually change — usually fine but surface to caller in case the push was a no-op).--Returns on failure: `{ok:false, failedAt:'args'|'kill'|'git'|'clean'|'build'|'launch'|'verify', error, builtSha (set after git step), runningSha (set if launch worked but verify saw the wrong SHA), compiled (set after build step), elapsedSecs, _hint}`. The partials let you reason about WHERE it broke:--| `failedAt` | What it means |-|---|---|-| `args` | `expectSha` missing / malformed |-| `kill` | Couldn't taskkill Hydrogen (rare — usually permissions) |-| `git` | `git fetch / reset` failed, OR `HEAD != expectSha` after reset (push hasn't replicated to origin/main yet — retry in a few seconds) |-| `clean` | `cargo clean` failed (rare) |-| `build` | `cargo build` failed — read `_hint` for next step (usually `hd_build_log` to see the compile error) |-| `launch` | Cargo finished but `adom-hydrogen.exe` wouldn't start — `binary_missing` or process spawn error |-| `verify` | Hydrogen launched but `/buildinfo` either never responded or reported a different SHA than `expectSha` (the stale-relink trap — retry with `clean:true`) |--Long-running: cold cargo build can take 2-3 min. CLI WS timeout is 12 min — call as a single sync invocation and wait. Don't poll inside `hd_ship`; it does its own internal polling.--#### Lower-level build + lifecycle suite (v1.8.16+, enriched in v1.8.52+)--Use these directly only when you need finer control than `hd_ship` provides (e.g. frontend-only build, watching log stream live, manual launch sequencing). For the common "I pushed code, get it running" path use `hd_ship`.--The relay's `shell_execute` has a 30s timeout; Hydrogen's `pnpm build` + `cargo build` exceed that, so the build is split into async-spawn verbs + sync poll/tail verbs:--```bash-# Frontend-only rebuild, watch progress, relaunch (no hd_ship since hd_ship is Rust-build-only)-adom-desktop hd_stop-adom-desktop hd_build_frontend '{"show":false}'      # returns {ok, pid, logPath} immediately-OFFSET=0-while true; do-  RESP=$(adom-desktop hd_build_tail "{\"offset\":$OFFSET}")-  echo "$RESP" | jq -r '.lines[]'-  DONE=$(echo "$RESP" | jq -r '.done')-  OFFSET=$(echo "$RESP" | jq -r '.newOffset')-  [[ "$DONE" == "true" ]] && break-  sleep 3-done-adom-desktop hd_build_status      # confirm succeeded:true && relinkOnly:false-adom-desktop hd_launch            # start the new debug binary-```--##### `hd_build` / `hd_build_frontend` / `hd_build_rust` — async-spawn build verbs--| Verb | What |-|---|---|-| `hd_build '{"show":false}'` | Full async build: `git pull` + `pnpm build` + `cargo build`. Returns instantly with `{pid, logPath, flavor:'full'}`. Final log line is `BUILD_OK` or `BUILD_FAILED: <step>`. |-| `hd_build_frontend` | Just `pnpm build` (root). Returns `{pid, logPath, flavor:'frontend'}`. |-| `hd_build_rust` | Just `cargo build` (src-tauri/). **v1.8.52+:** dispatch response also carries `builtSha` + `exeMtimeBefore` + `exeSizeBefore` (captured at spawn time into `%TEMP%\hd-build-meta.json`) so the subsequent `hd_build_status` / `hd_launch` don't need a separate `git rev-parse` + `stat` call. Returns `{ok, pid, logPath, flavor:'rust', show, lingerSecs, builtSha, exeMtimeBefore, exeSizeBefore, _hint}`. |--##### `hd_build_status` — sync state probe (v1.8.52+ enriched)--Returns:-```-{-  ok, building, succeeded, failed,-  lastLines:[string],                  # last ~40 lines of stderr-  logPath, pid, pidAlive,-  failureReason,                       # 'compile_error' | 'exe_locked' | 'orphaned_no_sentinel'-  compiledCrates:[string],             # names parsed from 'Compiling X v...' lines-  relinkOnly:bool,                     # true iff succeeded && compiledCrates is empty-  exeChanged:bool,                     # mtime OR size after differs from before-  exeMtimeBefore:int, exeMtimeAfter:int,   # unix seconds-  exeSizeBefore:int,  exeSizeAfter:int,    # bytes-  exeLocked:bool,                      # true when build failed because adom-hydrogen.exe was held-  holderPids:[int],                    # PIDs holding the exe — taskkill these-  builtSha:string|null,                # SHA hd_build_rust/hd_build was dispatched at-  elapsedSecs:int,-  flavor:string,                       # 'rust' | 'frontend' | 'full'-  _hint-}-```--**`relinkOnly:true` on success is the silent-fail trap to look for** — your push didn't actually rebuild because cargo found nothing to recompile. Usually means your source changes weren't pulled, or you touched a file the workspace doesn't track. Use `hd_ship` with `clean:true` to force a fresh build.--**`exeLocked:true` on failure** + `holderPids` tells you exactly which PIDs to taskkill before retry; or just use `hd_ship` which kills Hydrogen before building.--##### `hd_launch` — start the debug binary (v1.8.52+ enriched)--Args: `killExisting` — optional bool, default `false`. When `false` and Hydrogen is already running, refuses with `reason:'already_running'` so you don't accidentally attach to a stale process. When `true`, taskkill the existing instance with a 600 ms grace period before launching fresh.--Returns on launch: `{ok:true, launched:true, wasAlreadyRunning:bool, killedFirst:bool, pid:int (Hydrogen's actual PID, not the cmd-wrapper), exePath, builtSha:string|null, _hint}`.--Returns on refusal: `{ok:false, launched:false, wasAlreadyRunning:bool, pid:int|null, killedFirst:false, reason, _hint}`. `reason` ∈:-- `build_in_progress` — wait for `hd_build_status.building:false`-- `build_failed` — fix and retry `hd_build` (or use `hd_ship`)-- `already_running` — pass `killExisting:true` to force-kill+relaunch, or use `hd_restart`-- `binary_missing` — `target/debug/adom-hydrogen.exe` not on disk; run `hd_build_rust` (or `hd_ship`) first--**`launched:true, wasAlreadyRunning:false, killedFirst:false` is the proof you need that truly fresh code is running.** Any other combo means you should compare `builtSha` to Hydrogen's actual `/buildinfo.build_sha`.--##### `hd_stop` / `hd_restart` / `hd_build_log` / `hd_build_tail`--| Verb | What |-|---|---|-| `hd_stop` | `taskkill /F /IM adom-hydrogen.exe`. `{wasRunning}` distinguishes killed vs no-op. |-| `hd_restart` | Stop + launch in one call. Same guards as `hd_launch` (skip `already_running`). |-| `hd_build_log` | Full log dump from `%TEMP%\hd-build.log`. |-| `hd_build_tail '{"offset":N}'` | Incremental stream — `{lines, newOffset, totalBytes, done, succeeded, _hint}`. Pass `newOffset` back for the next chunk. |--The `show: true` arg on any build verb opens a visible PowerShell console window so the user can watch the build scroll. Default `show: false` runs hidden.--**Auto-close (v1.8.23+).** Visible build windows auto-close 30 seconds after the final `BUILD_OK` / `BUILD_FAILED` line — long enough for the user to read the last error, short enough that build-after-build sessions don't accumulate orphan windows. The window prints a clear green/red "auto-closes in 30 seconds" banner before sleeping. Override with `lingerSecs`:-- `'{"show":true, "lingerSecs":60}'` — give yourself 60s instead of 30-- `'{"show":true, "lingerSecs":0}'` — close immediately, no grace period-- `'{"show":true, "lingerSecs":3600}'` — keep open for an hour (cap)-The response includes the applied `lingerSecs` so you can confirm what landed. Hidden builds (`show:false`) ignore `lingerSecs` entirely — no window to linger.--**Don't call `hd_launch` until `hd_build_status` shows `succeeded: true`** — the guard refuses with `reason: "build_failed"` if you do, and the `_hint` tells the AI exactly what to do (`hd_build_log` to see errors, fix, rebuild). Same for `hd_restart`.--#### Named-region screenshots — `hd_shot` (v1.8.54+)--**One verb replaces the `desktop_list_windows | grep 'Hydrogen' → desktop_screenshot_window <hwnd>` chain** (3 calls + fragile HWND parsing). Captures a named region of Hydrogen, produces BOTH a full-res PNG and a Claude-safe-resized copy, and returns rich metadata so the AI never has to chain screenshot → pull → convert → crop → resize → Read manually.--Each shot is **self-identifying**: it includes the running build's SHA + local build time read from Hydrogen's `/buildinfo` so you can confirm you're looking at the build you expect before trusting what you see (no more "this screenshot was from the previous binary, not my fix" confusion).--Regions are deterministic — they resolve via Hydrogen window bounds + CDP element rects queried through Hydrogen's `/eval` endpoint. No AI-supplied pixel coords.--Args:-- `region` — **REQUIRED**. One of:-  - `full` — whole Hydrogen window-  - `titlebar` — top ~40 px strip (Hydrogen's build SHA / timestamp readout lives here)-  - `vscode` — VS Code iframe pane via CDP element rect-  - `setup-panel` — Setup Steps panel (`.setup-panel` / `.setup-panel-output`)-  - `wiki` — wiki / browser tab pane-  - `claude` — Claude Code panel-- `resizeMax` — optional int, default `1400`. Longest side of the safe-resized copy. Use ≈800 to fit more shots in one Claude turn; 1568 for max fidelity.--Returns:-```-{-  ok, region,-  fullPath,                    # lossless raw PNG — for shotlog/archival, NOT Read-  safePath,                    # ≤resizeMax PNG — pull_file this, Read this-  fullSize:[w,h], safeSize:[w,h],-  clipRect:[x,y,w,h],          # captured-image pixels (NOT screen pixels)-  capturedAtLocal,-  runningBuildSha,             # from Hydrogen's /buildinfo — null if Hydrogen doesn't expose it yet-  runningBuildLocal,-  hdWindowFound:bool, regionFound:bool,-  windowOuter:[w,h], capturedSize:[w,h],-  _hints: {-    pull,                      # exact pull_file command for safePath-    fullForArchive,            # note that fullPath is for archive, not Read-    staleWindow,               # set when runningBuildSha looks wrong-    regionMiss                 # set when regionFound:false-  }-}-```--Recipe — verify-which-build-is-running + confirm-config-change-took-effect + full dump:--```bash-# After hd_ship, confirm the titlebar shows the SHA you expect:-adom-desktop hd_shot '{"region":"titlebar"}'-# → runningBuildSha lets you assert this before trusting any other shot--# After flipping a Setup Steps toggle, screenshot the panel to confirm:-adom-desktop hd_shot '{"region":"setup-panel","resizeMax":1568}'--# Full-window dump for general debugging:-adom-desktop hd_shot '{"region":"full"}'-```--Edge cases:-- **Unknown region** — refusal: `{ok:false, error, knownRegions:[...]}`. No fallback — fix the call.-- **`regionFound:false`** — the element rect wasn't in Hydrogen's DOM. The panel/iframe may not be mounted yet, OR Hydrogen's markup changed and `region_selectors()` in `hd_bridge.rs` is out of date. The shot is **still produced** (falls back to the full window) so you have something to look at; check `_hints.regionMiss` for the reason.-- **`runningBuildSha:null`** — Hydrogen's `/buildinfo` isn't exposed yet on the running version. The shot is still produced; you just can't self-verify which build it represents.--**Always pull_file the `safePath` and Read THAT** — it's already sized for Claude's image analysis. The `fullPath` is for shotlog / disk archival, NOT direct Read (may exceed Claude's per-image budget). The `_hints.pull` field gives you the exact `pull_file` command to copy-paste.--**Old workaround to drop:** Do NOT use `desktop_list_windows | filter 'Hydrogen' | desktop_screenshot_window <hwnd>` anymore for Hydrogen — `hd_shot` replaces that entire pattern. Generic `desktop_screenshot_window` still applies to non-HD windows (other apps, dialogs, etc.).--### TCP Port-forward Tunnels — `tunnel open|close|list|check|rdp` + `display` (v1.8.85+, reworked v1.8.147)--Reach a TCP service addressable only from the **cloud side** through the **laptop side**: AD binds a `127.0.0.1:<port>` listener on the laptop and pumps bytes over the existing AD↔relay WebSocket; the relay dials the upstream `to`. First use case: RDP into a Windows VM behind an NSG only the cloud container reaches — mstsc on the laptop connects to a local port and is talking through the cloud. Same path works for SSH, VNC, HTTP, postgres — any TCP.--**`tunnel` is a grouped verb** (`rdp` / `tunnel_close` / `tunnel_list` remain back-compat aliases):--```bash-# Open a VERIFIED tunnel (both legs probed before ok). Auto-picks a free local port.-adom-desktop --target <name> tunnel open '{"to":"127.0.0.1:13389"}'--# RDP: open + verify + launch mstsc, sized to the laptop's real screen.-adom-desktop --target <name> tunnel rdp '{"to":"127.0.0.1:13389","size":"90%"}'-#   size: "90%" (default) | "100%" | "fullscreen" | "maximized"-#   OR monitor:<index> (0=primary)  OR explicit width+height--adom-desktop --target <name> tunnel list           # live tunnels + leak detection-adom-desktop --target <name> tunnel check '{"tunnelId":<id>}'   # per-leg health-adom-desktop --target <name> tunnel close '{"tunnelId":<id>}'   # or {"all":true}--adom-desktop --target <name> display               # monitor geometry (for sizing)-```--**`--target` is mandatory when >1 AD is connected.** v1.8.147 fixed a false-success bug: the bind, the upstream probe, and the mstsc launch now ALL resolve to the SAME machine. (Before, the bind could land on one AD and mstsc launch on another → "port bound" on the VM, "nothing listening" on the laptop, a bogus `ok:true`.) No target + multiple ADs → `ambiguous_target` listing the names; `--target all` is rejected (a tunnel binds one machine). `adom-desktop targets` lists connected ADs.--**Active health verification — every `open`/`rdp`/`check` PROVES the path before reporting `ok`:**-- **local-bind** — AD self-probes `127.0.0.1:<boundPort>` right after binding. If it can't connect, AD tears the tunnel down and returns `ok:false` (this alone kills the false-success class).-- **upstream-refused** — the relay dials `to` (retry/backoff ~4×/2.5s). If unreachable, the tunnel is torn down (no leaked row) and `ok:false`.-- A failure names the leg: `failedLeg: "relay-to-ad" | "local-bind" | "upstream-refused"`. Health fields surfaced: `listening`, `relayConnected`, `upstreamReachable`, `connCount`, `mstscAlive`.--**Progressive hints.** Every response carries `_hint` (what's true now) + `_next` (the exact next verb with real values, copy-pasteable). e.g. a successful open's `_next` hands you the `tunnel rdp` / `tunnel check` / `tunnel close` commands with the real `to`/`target`/`tunnelId` filled in. A missing `to` returns a structured error with an example. Walk the workflow from the responses alone.--**Display-aware RDP sizing.** `tunnel rdp` resolves `size`/`monitor`/`width`/`height` against the laptop's real screen (queried via `display` — `EnumDisplayMonitors` + per-monitor DPI), writes an `.rdp` file with the geometry, and launches mstsc. e.g. `size:"90%"` on a 2560×1600 primary → a 2304×1440 window. Default 90% of primary. You never hardcode a resolution — call `display` if you want the layout first.--**`tunnel list` reconciles + reaps leaks.** It cross-checks the relay registry against every connected AD's own registry and reports `leaks:[...]` for rows only one side knows about; `tunnel close '{"all":true}'` reaps them. The relay stores the actually-bound `localPort` (so `tunnel close '{"local":<port>}'` works).--Architectural notes for Claude:-- Bytes-only — no payload parsing, so any TCP service works. TLS/cert-pinning still pins to whatever the cloud-side dial resolves (for RDP the upstream cert CN must match what mstsc expects).-- Each fresh laptop-side TCP connection gets its own connId multiplexed over one tunnelId; concurrent mstsc reconnects / parallel sessions work without re-opening. Backpressure bounded (256-slot mpsc/conn, 64 KiB buf).-- Tunnels don't survive AD/relay restarts. If mstsc disconnects, `tunnel check` (names the dead leg), then re-open.-- The classic RDP collision (Windows' own RDP owns `0.0.0.0:3389`) is dodged by `rdp`'s default `local:13389` + `SO_EXCLUSIVEADDRUSE` + ephemeral fallback (`fellBack:true` in the response; read `localPort` for the real port).--**Old workarounds to drop**: (1) NSG WAN-IP whitelisting for roaming-laptop RDP — the tunnel rides existing auth, reachable regardless of WAN IP. (2) Trusting a bare `ok:true` from the old `rdp` — it could be a dead port; now `ok` means both legs were probed. (3) Hardcoding mstsc resolution — pass `size`/`monitor`; AD resolves against the real screen.--### Shell — `shell_execute` (escape hatch only)--- `shell_execute` -- Run a shell command on the desktop. The CLI handles approval polling internally — it returns the final `{success, output, error, exitCode}` once the user clicks Allow on the desktop dialog. **You do not need to poll, retry, or call `get_deferred_result` yourself.** The CLI emits `HINT:` lines to stderr while waiting (every 15s) so an AI Monitor sees progress.-- `shell_kill_all` -- Kill all running shell commands and deny pending approvals.--**v1.7.16 fix.** Earlier versions (v1.7.15 specifically) had a regression where the CLI's approval-wait loop re-sent the command every 1s instead of polling for the deferred result; that produced an infinite "Another shell command is already waiting for approval" loop because each retry created a fresh approval the user could never out-click. v1.7.16 fixes it. If you see that error string with a `>= 1.7.16` CLI, the bug is back — file an issue. If you see it with `< 1.7.16`, upgrade the CLI: `adom-wiki asset get apps/adom-desktop docker_binary -o /usr/local/bin/adom-desktop`.--**Deprecated for download polling.** As of v1.5.0, use `desktop_watch_files` / `desktop_pull_glob` for waiting on download arrivals — those don't require user approval, don't go through PowerShell quoting, and are O(directory entries) rather than spawning a process every second. `shell_execute` itself stays as an escape hatch for genuinely shell-only operations (multi-step ad-hoc admin tasks, chained pipelines, etc.).--Use `where python` / `C:\Python3xx\python.exe` for Python -- avoid `python` which may not be on PATH.--#### `run_script` + `wsl_exec` — base64-payload script runners (v1.8.46+)--When you want to send a multi-line script to the desktop without the shell-quoting hell that `shell_execute` requires, use these two verbs. The script crosses the wire as base64 so NO escaping survives to be mangled by cmd.exe, PowerShell parser quirks, MSYS path conversion, or wsl.exe's `--` / nested-quote / `$_` rules. The caller never hand-escapes anything.--Both verbs require shell auto-approve to be active — same trust level as `shell_execute`. They refuse cleanly with an actionable error if not approved; no per-call modal (these are programmatic verbs).--**Reading the result — `status` + `durationMs` (v1.8.162+).** EVERY CLI result now carries a single authoritative **`status`** field so you never have to guess whether an empty `stdout` was a real empty-output success or a timeout:--| `status` | meaning | what to do |-|---|---|---|-| `ok` | completed (stdout may legitimately be empty) | use `exitCode`/`stdout`/`stderr` |-| `timeout` | did NOT finish in time (NOT an empty success) | raise `timeoutSeconds`, or use a detached job (below) |-| `busy` | desktop couldn't service it (WSL wedged / no client connected) | check `adom-desktop status`; `wsl_recover` for WSL; retry |-| `error` | the command/verb failed | read `error` / `stderr` / `_hint` |--`durationMs` is the wall-clock of the call. `exitCode`/`stdout`/`stderr` pass through unchanged; on `timeout` the verb's `partialStdout`/`partialStderr` hold what was written before the kill. A `timeout`/`busy` result also carries a recovery `_hint`. **For multi-minute work don't fight the timeout** — use a detached job that returns instantly + polls: `wsl_exec_async` / `wsl_import` / `wsl_export` → poll `wsl_job_status` (or `launch_process` for a general long-running command). This is the cure for the "1.4 GB import EOFs the sync call" problem.--**`run_script`** — base64 script + interpreter (cmd / powershell / bash):--```bash-SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b'''-echo \"multi-line script\"-VAR=\"value with spaces and \$dollars\"-echo \"VAR=\$VAR\"-exit 0-''').decode())")-adom-desktop run_script "{\"interpreter\":\"bash\",\"scriptB64\":\"$SCRIPT_B64\"}"-# → {success, ok, stdout, stderr, exitCode}-```--Args: `interpreter` (`cmd` | `powershell` | `bash`), `scriptB64` (required), `timeoutSeconds` (default 60).--PowerShell prefers `pwsh` (PS 7+) if on PATH, falls back to `powershell.exe` (5.1).--- **`bash` runs via WSL2, NOT Git Bash.** The Windows host's first `bash` on PATH is `wsl.exe -d <default-distro> bash` (verified at `C:\Windows\System32\bash.exe`). The script is piped to bash's stdin (no temp file, no path-form mismatch — no MSYS `/S` → `S:/` corruption, no nested-quote loss). The decoded script bytes are written to bash's stdin and stdin is closed; bash reads + executes without ever opening a file. For explicit distro / user control, use `wsl_exec`.--**`wsl_exec`** — base64 script + distro + user, executed via the canonical `wsl -d <distro> -u <user> -- bash -lc <decoded>` argv array:--```bash-SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b'''-cd /mnt/c/Github/adom-desktop-git status --short | head -10-exit 0-''').decode())")-adom-desktop wsl_exec "{\"distro\":\"Ubuntu\",\"user\":\"root\",\"scriptB64\":\"$SCRIPT_B64\"}"-# → {success, ok, stdout, stderr, exitCode}-```--Args: `distro` (e.g. `Ubuntu`, `Adom-Workspace`), `user` (e.g. `adom`, `root`), `scriptB64`, `timeoutSeconds` (default 60).--Why argv (not shell string): Rust's `std::process::Command` on Windows applies proper `CommandLineToArgvW` quoting per argv element. `wsl.exe` sees `bash -lc <decoded>` as three distinct argv items even when the decoded script contains quotes, `$`, backslashes, newlines, pipes — anything. `bash`'s `-c` then sees the entire script as ONE string with no outer-shell interpretation.--Output decoding: `wsl.exe` defaults to UTF-16LE when invoked from the Windows console subsystem. Both verbs auto-detect (BOM check + odd-position-NULL heuristic) and decode to UTF-8. Output is never wrapped in another shell that might re-interpret it.--Pitfalls these verbs eliminate:-- `cmd.exe`'s `&`, `|`, `^`, `%var%` interpretation-- PowerShell's parser quirks around nested quotes + `$` interpolation-- Git Bash MSYS converting `/S` → `S:/` etc.-- WSL's `--` parser limits + nested-quote loss-- UTF-16LE corruption on wsl.exe output--#### WSL wedge defense — `wsl_wedged` + `wsl_recover` (v1.8.157+)--Every `wsl_exec` / `run_script bash` spawns a `wsl.exe` client. If WSL's service (`LxssManager`) wedges — common after a laptop runs for a day, or under heavy parallel cloud use — those clients block in a wait that `taskkill /F` can't always finish, and retries keep spawning more. Left unchecked they pile up into the hundreds and wedge the whole machine. Three defenses:--1. **Refuse-cap.** When there are already too many `wsl.exe` (`WSL_REFUSE_AT=80`), AD refuses NEW wsl spawns with `errorCode:"wsl_wedged"` instead of adding another doomed client. If you get `wsl_wedged`, don't retry the same command — run `wsl_recover` first.-2. **`wsl_recover`** — reap orphaned/hung `wsl.exe`, **DRY-RUN by default** (nothing is killed unless you pass `confirm:true`):-   ```bash-   adom-desktop wsl_recover                       # dry-run: lists `candidates` (would-reap) + `spared`-   adom-desktop wsl_recover '{"confirm":true}'    # reap the candidates-   adom-desktop wsl_recover '{"confirm":true,"sparePids":[1234]}'   # also protect pid 1234-   ```-   It classifies each `wsl.exe`: childless + (0-CPU & old | runaway-CPU) → **candidate**; everything else → **spared**. It **spares any `wsl.exe` with child processes** (that's almost certainly the live distro, e.g. the Hydrogen `Adom-Workspace`), honors `sparePids`, and never touches `wslservice`/`wslhost`/`vmmem`. Review each candidate's `cmd` in the dry-run to confirm the live workspace isn't among them, then confirm. **Never** `wsl --shutdown` (it kills the live workspace too). Reaping childless orphans often cascades — their now-parentless children exit on their own (observed 114 → 18 from reaping 54).-3. **status wsl block** — `{wslProcessCount, wedged, _hint}` lets you see a pile-up *before* it blocks work. Path differs by transport: **`status.desktop.wsl`** via the CLI/relay (`adom-desktop status` — nested like `desktop.apps`), or top-level **`status.wsl`** on the local direct API (`GET /status`). `wedged:true` at/above `WSL_WARN_AT=50`.--#### Long-running in-distro work + distro lifecycle — golden-image bakes (v1.8.161+)--`wsl_exec` is **synchronous** — it waits for the command and is bounded by the relay's HTTP timeout (~60-120s), so a multi-minute bake (apt, a big download) EOFs. Backgrounding inside the distro (`nohup … &`) doesn't help either: the `wsl.exe` client returns, WSL sees no active session, shuts the distro down, and kills the bg process (wiping `/tmp`). For anything long, use the **async held-session** verbs — AD holds one `wsl.exe` in the foreground for the command's whole lifetime, so the distro stays up:--- **`wsl_exec_async`** → returns a `jobId` + `outputPath` (a live UTF-8 log) immediately; the command runs detached for as long as it takes.-- **`wsl_job_status {jobId}`** → `{running, exitCode, elapsedMs, outputTail, outputPath}`. Poll until `running:false`; `pull_file` the `outputPath` for the full log. (`wsl_job_list`, `wsl_job_kill {jobId}` round it out.)--```bash-# golden-image bake: import → run (async) → poll → export → unregister, all via AD-adom-desktop wsl_import '{"distro":"bake","installDir":"C:/Users/<you>/.adom/wsl/bake","tarball":"C:/.../base.tar"}'-#   → {jobId}; poll wsl_job_status until done (exitCode 0)-B64=$(printf 'set -euo pipefail\napt-get update && apt-get install -y build-essential\n' | base64 -w0)-adom-desktop wsl_exec_async "{\"distro\":\"bake\",\"user\":\"root\",\"scriptB64\":\"$B64\"}"-adom-desktop wsl_job_status '{"jobId":"wsljob-xxxxxxxx"}'   # repeat until running:false; outputTail = progress-adom-desktop wsl_export '{"distro":"bake","tarball":"C:/.../golden.tar"}'   # poll, then pull_file the tarball-adom-desktop wsl_unregister '{"distro":"bake"}'            # cleanup (REFUSES Adom-Workspace)-adom-desktop wsl_list                                      # {distros:[{name,state,version,default}]}-```--**systemd user session — `viaRoot`.** On a `systemd=true` distro, `-u <user>` can fail "Failed to start the systemd user session for '<user>'". Pass `"viaRoot": true` to `wsl_exec_async` — AD runs `-u root -- runuser -u <user> -- bash -l`, dropping to the user WITHOUT depending on `[email protected]`.--`wsl_import` / `wsl_export` / `wsl_unregister` are async (jobId + poll) too, so big imports/exports never EOF. All are **strictly scoped to the named distro** — there is no global `wsl --shutdown` path (that would kill the live Hydrogen workspace), and `wsl_unregister` refuses `Adom-Workspace`. Job ids live in AD's memory only (not persisted across an AD restart; the log file survives). The wedge defense above still applies — an over-cap spawn returns `wsl_wedged`.--## Detecting App Installation--After connecting, always run `adom-desktop status` to check what's installed. The `desktop.apps` object tells you exactly what the user has:--```bash-adom-desktop status-# Look at the desktop.apps field in the response-```--### Handling "not installed" errors--When a command returns `errorCode: "node_not_found"`, `errorCode: "kicad_not_installed"`, or `errorCode: "fusion_not_installed"`, guide the user through installation:--**Picking the right native browser + profile (v1.7.1+):**--When you need to open a URL in the user's NATIVE browser AND it matters which account is signed in (work Google Workspace vs personal Gmail vs media YouTube channel etc.), `desktop_open_url` alone isn't enough — you need to target a specific profile. The flow:--```bash-# 1. Discover what's installed + which profiles are configured.-adom-desktop desktop_list_browsers '{}'-# → {-#     "default": "chrome",-#     "browsers": [-#       { "name": "chrome", "displayName": "Google Chrome", "version": "146...",-#         "exePath": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",-#         "profiles": [-#           { "id": "Default",   "name": "Alice (work)", "gaia": "[email protected]",     "isDefault": true },-#           { "id": "Profile 1", "name": "Personal",     "gaia": "[email protected]" },-#           { "id": "Profile 2", "name": "Adom Media",   "gaia": "[email protected]" }-#         ] },-#       { "name": "edge", "profiles": [...] },-#       { "name": "firefox", "profiles": [{"id":"default-release","name":"default","isDefault":true}] }-#     ]-#   }--# 2. Match the URL's context to the right profile, then open it there.-# E.g. opening a Google Doc shared by your work team → use the work profile:-adom-desktop desktop_open_url '{-  "url":"https://docs.google.com/document/d/...",-  "browser":"chrome",-  "profile":"Default"-}'-# Or YouTube channel management for the media account:-adom-desktop desktop_open_url '{-  "url":"https://studio.youtube.com",-  "browser":"chrome",-  "profile":"Profile 2"-}'-```--Profile-picking heuristics for Docker Claude:-- **Workspace / @adom.inc URLs** → match `profile.gaia.endsWith("@adom.inc")` and not the `media@` one → typically `Default`.-- **Personal Gmail / Drive / etc.** → match `profile.gaia === "[email protected]"` (or whatever the user's personal address resolves to).-- **YouTube Studio / channel-specific work** → match the media account profile.-- **Random scratch / experiments** → use the user's Edge profile, where they keep miscellaneous accounts (per the user's stated preference).-- **No clear match** → fall back to `browser:"default"` (no profile flag) and let the user pick.--`profile` is optional. Omit it to open in whichever profile the browser was last using (legacy v1.6.x behavior). Profile flag is silently ignored when `browser:"default"` (no clean way to inject through the OS URL handler — name the browser explicitly to use it).--### Decrypting the user's saved browser credentials — `desktop_decrypt_browser_credentials` (v1.9.220+)--So pup can run an **"adom-you" Chrome profile signed in AS the user** — surfing the web on the user's behalf — it needs the user's real saved logins/cookies to seed that profile. Chrome 127+ encrypts these with **App-Bound Encryption** (the master key is DPAPI-wrapped under the SYSTEM account), so only an elevated helper can decrypt them. This verb is that path.--```bash-adom-desktop desktop_decrypt_browser_credentials '{-  "browsers": ["chrome", "edge"],   # optional; default = all installed Chromium browsers-  "include": ["passwords", "cookies"] # optional; default = both-}'-# → { "ok": true,-#     "passwords": [ { "origin", "username", "password", "sourceBrowser", "sourceProfile", "sourceAccount", "lastUsed" }, ... ],-#     "cookies":   [ { "host", "name", "value", "path", "sourceBrowser", "sourceProfile", ... }, ... ],-#     "stats": { "profilesRead", "passwordsOk", "passwordsFailed", "cookiesOk", "cookiesFailed" } }-```--**How to call it (this is not a normal verb — read this):**--- **LOCAL / bridge callers only.** A relay (cloud-AI) call is refused unconditionally with `errorCode:"relay_forbidden"` and **pops no UAC** — a remote container must never be able to trigger a password-decrypt. pup (a loopback bridge) and the local CLI reach it.-- **Two human gates, in order:** (1) AD shows an in-window consent dialog + toast naming the requesting caller ("pup is requesting your saved browser passwords/cookies…"); decline → `errorCode:"consent_declined"`. (2) On continue, an elevated signed helper runs behind a **UAC prompt** ("Adom Industries, Inc."); cancel → `errorCode:"elevation_declined"`. The UAC elevation IS the user's permission for the decrypt — by design (John, wiki #457).-- **Plaintext is returned ONCE over the loopback API and is never written to disk or logged.** Don't echo the values into any other verb's args, any activity-log emit, or any file. Consume the rows, seed the profile/vault, drop them.-- **errorCodes:** `relay_forbidden` (you called it over the relay — you can't), `consent_declined`, `elevation_declined`, `helper_missing` (the signed helper isn't bundled — the install is broken). Per-item decrypt failures are counted in `stats.*Failed`, never fail the whole run (a managed-profile policy can block cookies; newest-Chrome `v20` blobs are best-effort if Google rotated the elevation-service key).-- **Pair it with `desktop_list_browsers`** (above) to show the user which browsers/profiles/accounts will be read before you ask.--**Node.js not installed (the puppeteer bridge can't auto-spawn):**--v1.7.0+ — any `browser_*` command returning `errorCode:"node_not_found"` means Node isn't on the desktop machine. Trigger the unattended winget install:--```bash-adom-desktop desktop_install_node '{}'-# → runs `winget install --id OpenJS.NodeJS.LTS --silent ...` (Windows only).-# → ~1-2 min; downloads + installs the Node.js LTS line (well-tested with puppeteer).-# → returns {ok:true, exitCode:0, _hint:"Retry the original browser_* command"} on success.-# → on failure: {ok:false, errorCode:"winget_unavailable" | "winget_install_failed", _hint}.-```--After install, **just retry the original `browser_*` command** — the bridge picks up the new node.exe via the registry App Paths lookup (which winget writes), no Adom Desktop restart needed. v1.7.0+ also pre-fetches Node + Chrome for Testing on first launch after install (background task on AUTOSTART_VERSION 11), so most users will never see `node_not_found` to begin with.--If `desktop_install_node` returns `errorCode:"winget_unavailable"` (older Windows or managed corporate device), fall back to manual:--> Node.js LTS is required for browser automation. winget isn't available on this machine — download Node.js LTS from https://nodejs.org/ and run the installer. Let me know when it's done and I'll retry.--**v1.7.0+ Node detection.** The bridge looks for `node.exe` in:-- System PATH (via `where node` on Windows, `which node` elsewhere)-- Windows registry App Paths (catches winget, MSIX, custom-dir installs)-- Standard install dirs: `C:\Program Files\nodejs\`, `C:\Program Files (x86)\nodejs\`, `%LOCALAPPDATA%\Programs\nodejs\`-- Per-user version managers: nvm-windows (`%APPDATA%\nvm\`), volta (`%LOCALAPPDATA%\Volta\bin\`), fnm (`%FNM_DIR%`)-- macOS Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`)--If detection still misses an install on a user's machine, get the actual `node.exe` path from the user — that's a real bug to file.--**KiCad not installed:**--**KiCad not installed:**--v1.6.0+ — try the unattended winget install FIRST before asking the user to download manually:--```bash-adom-desktop desktop_install_kicad '{}'-# → runs `winget install --id KiCad.KiCad --silent ...` (Windows only).-# → 2-5 minutes; downloads ~700 MB then installs.-# → returns {ok:true, exitCode:0, _hint:"Run kicad_list_versions to verify"} on success.-# → on failure (no winget / network failure / corporate-managed device):-#   {ok:false, errorCode:"winget_unavailable" | "winget_install_failed", _hint, ...}-```--If `desktop_install_kicad` fails with `errorCode:"winget_unavailable"`, fall back to manual:--> KiCad isn't installed and winget isn't available. Download from https://www.kicad.org/download/ — it's free and open source, install the latest stable (9.x or 10.x).-> Let me know when the install is done and I'll verify the connection.--After EITHER path, have them restart Adom Desktop OR call `kicad_list_versions` again — the kicad bridge caches detection results, so a fresh scan may be needed to pick up freshly-installed binaries.--**v1.6.0+ — improved KiCad detection.** The bridge now scans for KiCad in:-- `C:\Program Files\KiCad\<version>\` (the standard location)-- `C:\Program Files (x86)\KiCad\<version>\` (32-bit edge cases)-- `%LOCALAPPDATA%\Programs\KiCad\<version>\` (winget per-user installs)-- `%LOCALAPPDATA%\KiCad\<version>\` (rare manual installs)-- Windows registry: `HKLM\SOFTWARE\KiCad\<version>\InstallationPath` and the `WOW6432Node` mirror-- `HKCU` mirrors of the above for per-user installs-- macOS: `/Applications/KiCad/` and `/Applications/` directly--If detection still misses an install on a user's machine, that's a real bug to file — get the actual install path from the user and we'll add it to the scan list.--**Fusion 360 not installed:**-> Fusion 360 isn't installed on your desktop. Would you like to install it?-> Download from: https://www.autodesk.com/products/fusion-360-> It's free for personal/hobby use (requires an Autodesk account).-> Let me know when the install is done and I'll verify the connection.--After install, have them restart Adom Desktop, then run `adom-desktop status` to verify.--### Handling "not running" errors--When `errorCode: "fusion_not_running"`, launch it programmatically with the first-class startup command:-```bash-adom-desktop fusion_start-# On Windows: discovers exe via %LOCALAPPDATA% env var webdeploy glob.-# On Docker: delegates to the bridge on the connected desktop via relay.-# ~15-30s typical. Auto-dismisses startup dialogs.-# Returns {"addinReady": true, "pickerDismissed": true|false, ...}-```--KiCad doesn't need to be running for most commands (the bridge launches it on demand).--### Handling "add-in not installed" errors--When Fusion is running but `addinInstalled: false` or `addinConnected: false`:-> The AdomBridge add-in needs to be installed in Fusion 360. I can install it for you — this lets me control Fusion remotely.--The add-in auto-installs when the Fusion bridge starts and Fusion is detected.--### Handling `fusion_addin_not_responding` errors--When `errorCode: "fusion_addin_not_responding"`, Fusion is running but the AdomBridge add-in isn't answering. Call `fusion_dismiss_blocking_dialogs` FIRST — a modal dialog is the most common cause. If that doesn't help, try `fusion_start` to restart Fusion cleanly. Last resort: user enables add-in manually via UTILITIES > ADD-INS > AdomBridge > Run on Startup + Run.--### Handling `main_thread_busy` errors--When `errorCode: "main_thread_busy"`, the Fusion add-in's main thread is occupied by a long-running command (typically `walk_cloud_tree` or `search_cloud_files`). This applies **across all bridges/sessions** — even if you didn't start the walk, another session might have.--**Do NOT:**-- Retry the failed command — it will block behind the same lock-- Call `fusion_dismiss_blocking_dialogs` — there's no dialog to dismiss, and sending Escape will interrupt the active walk-- Force-kill Fusion — the walk will complete on its own--**Do:**-- Wait for the walk/search to finish. If you started it, you should be using `adom-desktop watch` (see "Live folder progress streaming with `watch`" above) which streams live progress automatically. If another session started it, poll `fusion_addin_status` every 2–5s to check progress.-- Use commands that **don't need the main thread** while waiting:-  - `fusion_addin_status` — check busy state and walk progress-  - `fusion_window_info` — get window HWND, title, dialogs-  - `fusion_screenshot_fusion` — capture what Fusion looks like-  - `fusion_click_fusion` — click in the Fusion window-  - `fusion_send_key` — send keyboard input-  - `fusion_close_window` — close a specific dialog by HWND--The response includes progress info:-```json-{-  "errorCode": "main_thread_busy",-  "busyCommand": "walk_cloud_tree",-  "elapsedSeconds": 42.3,-  "walkProgress": {-    "foldersVisited": 15,-    "filesFound": 87,-    "currentFolder": "Molecules/XRP",-    "queueSize": 8-  },-  "_hint": "Add-in is busy with a long-running command. Do NOT retry..."-}-```--**Stalled walk detection**: If `fusion_addin_status` returns `busy: true` but `walkProgress` is missing and `mainThreadStalled: true`, a modal dialog is blocking the event loop — the walk was dispatched but never started. Call `fusion_dismiss_blocking_dialogs`, then the walk auto-resumes.--### Auto-recovery (`_autoRecovery` field)--When a fusion_* command fails with "not responding" / "not connected", the CLI automatically:-1. Calls `fusion_dismiss_blocking_dialogs` to clear any modal-2. If successful, retries the original command-3. Attaches `_autoRecovery: {action, dismissed[]}` to the retry result--If you see `_autoRecovery` in a response, the command already succeeded after auto-dismissal — no manual intervention needed. The field is informational.--## Troubleshooting--### Check connection status--Use `status` to see connected clients. A healthy connection shows one client from the user's hostname with a recent `lastPong` timestamp.--### Stale connections causing timeouts--Use `kick_all` to reset -- active Adom Desktop apps reconnect within seconds.--### No desktop client connected--1. Confirm the Adom Desktop app is running on the user's PC-2. Confirm it's pointed at the correct WebSocket URL-3. Check if port 8765 is exposed and reachable--### Relay not running--```bash-curl -sf http://127.0.0.1:8766/health-# If fails: adom-desktop serve --supervise-```--### Shell commands on Windows--`shell_execute` runs in `cmd.exe`. Prefer writing scripts via `send_files` then executing them. Use the full Python path.--## Building from Source--```bash-cd cli && cargo build --release-# Binary at: cli/target/release/adom-desktop-```--## Repo--[github.com/adom-inc/adom-desktop](https://github.com/adom-inc/adom-desktop)--## Run wiki ops on the user's PC — `wiki_exec` (v1.9.116+)--The AD installer bundles the `adom-wiki` CLI next to the app, and `wiki_exec` runs it ON THE PC — so version-controlling a local project (KiCad board, docs folder) on wiki.adom.inc needs NO per-file `pull_file` round trip to the container and there is no container/PC drift.--```bash-adom-desktop wiki_exec '{"args":["repo","push","adom/my-board","--files","board.kicad_pcb","board.kicad_sch","-m","rev B"],"cwd":"C:\Users\me\boards\my-board"}'-```--- `args` = adom-wiki argv (any pillar: repo, pkg, release, discussion). `cwd` = PC-side project dir so `--files` are project-relative. `timeoutSec` optional (default 120, max 600).-- Returns `{success, exitCode, timedOut, stdout, stderr, exe, durationMs}`.-- Auth is automatic: AD injects the signed-in user's session token as `ADOM_WIKI_TOKEN` — the caller never handles credentials.-- Gated like `shell_execute` for relay callers (it writes to the wiki as the user). `errorCode:wiki_cli_missing` means the desktop AD predates 1.9.116.-- Container-side publishing of the CONTAINER's own files still uses your local `adom-wiki` directly; `wiki_exec` is for files that live on the PC.--Note (v1.9.117): AD's Settings panel no longer has a KiCad section — bridges own their app-specific config (AD stays generic). `kicad_library_paths` / `project_watch` keep working from `~/.adom/config.json` and the `set_project_watch` / `trigger_project_watch` verbs.+---
+name: adom-desktop
+description: Use when the user wants to send files to their desktop, control KiCad or Fusion 360, send desktop notifications, or troubleshoot the desktop connection. Provides CLI tools for bridging the Docker container to the user's local machine.
+---
+
+# Adom Desktop
+
+Bridge between Claude Code (running in an Adom Docker container) and the user's desktop applications via WebSocket.
+
+**Install surface:** the canonical install page is `apps/adom-desktop` on the Adom wiki — it hosts the Linux CLI (`docker_binary`), the Windows installer (`.exe`), and the four sibling skills (pup, kicad-bridge, fusion-bridge, + this one). The gallia `adom-desktop-discovery` skill surfaces this page on any related user query. `adom-desktop setup_desktop` also returns a dynamic installer URL pulled from the latest GitHub release.
+
+**First-time setup?** If the user hasn't installed the desktop app yet, see the Setup section below to walk them through downloading, installing, and connecting the app.
+
+Quick check if desktop is connected:
+```bash
+adom-desktop ping
+```
+
+**Companion skills** (installed alongside this one from the wiki):
+- `adom-desktop-kicad` — KiCad bridge: launch editors, open designs, install libraries, run DRC, window capture + keyboard/click automation (`plugins/kicad/SKILL.md`)
+- `adom-desktop-fusion` — Fusion 360 bridge: launch, open designs, STEP/GLB/.lbr import-export, BOM + API queries, Fusion screenshots (`plugins/fusion360/SKILL.md`)
+- `pup` — browser automation (Puppeteer-style): open URLs, screenshot, eval JS, multi-session Chrome (`gallia/skills/pup/SKILL.md`)
+
+## How It Works
+
+```
+Claude Code -> adom-desktop <command> -> Relay Server (HTTP :8766) -> WebSocket :8765 -> Adom Desktop App -> KiCad / Fusion 360 / Browser / Shell
+```
+
+The `adom-desktop` binary is a single Rust CLI that does everything:
+- `adom-desktop serve` -- Start the relay server (WebSocket :8765 + HTTP API :8766)
+- `adom-desktop <command> '<json>'` -- Send commands to the desktop app via the running relay
+
+The relay server runs in the Docker container. The Adom Desktop app runs on the user's PC and connects via WebSocket.
+
+## Starting the Relay Server
+
+The relay must be running before the desktop app can connect. **In a container, start it SUPERVISED** (v1.9.168+):
+
+```bash
+adom-desktop serve --supervise
+```
+
+**Why not `adom-desktop serve &`?** A backgrounded (`&`) relay is still a child of your shell/session. When your session is torn down (you finish, or the container reaps it), the relay dies with it — and then every connected desktop hits HTTP 500 on `/proxy/8765/` and loops "Reconnecting in 30s" **for hours** until a human restarts it (wiki issue #24, hit live 2026-07-23). `--supervise` **detaches the relay into its own session (reparented to init)** so it survives your session, and starts a watchdog that:
+- restarts the relay with **exponential backoff** (2s → … → 5 min cap; a clean run resets it — a broken relay is never bludgeoned), and
+- **toasts the connected desktops on every restart** with the reason from the relay log, so the owner finds out something went wrong.
+
+```bash
+adom-desktop serve --supervise            # detached + self-healing (use this in containers)
+adom-desktop serve --supervise --owner AdomLapper   # send restart toasts to one desktop
+adom-desktop serve --stop                 # stop the watchdog + relay
+```
+
+`serve` (plain, foreground) still works and prints a loud hint telling you it will die with the session — fine for a quick local relay you'll sit and watch. **Caveat:** a full CONTAINER REBOOT still needs a boot hook (cron `@reboot` / the container's init); the watchdog covers session teardown, not host reboot.
+
+This starts:
+- WebSocket server on `0.0.0.0:8765` (desktop app connects here)
+- HTTP API on `127.0.0.1:8766` (CLI commands go here)
+
+Check if it's running:
+```bash
+curl -sf http://127.0.0.1:8766/health
+```
+
+## First-Time Setup (Install & Connect)
+
+**You are running on a Docker container. You have NO access to the user's desktop.** Guide them step-by-step, ask questions, wait for answers, and verify each step.
+
+> **As soon as you're connected, run `adom-desktop sync_skills` (v1.9.37+).** Installing the
+> adom-desktop core pkg gives you only CORE skills — it does NOT give you a DYNAMIC/cloud
+> bridge's skill (kicad, fusion, blender, any third-party). `sync_skills` runs `bridge_list`
+> and installs each bridge's own skill pkg into `~/.claude/skills/`, so you can actually
+> discover + drive whatever bridges THIS desktop has. (The CLI also runs this automatically
+> once/day in the background, but doing it on connect makes the bridge skills available to
+> the current session immediately.) Re-run it any time the desktop gains a bridge;
+> `adom-wiki pkg update` keeps them fresh.
+
+### Step 1: Check if already connected
+
+```bash
+adom-desktop ping
+```
+
+If this returns `{ "status": "connected" }`, the desktop is already set up -- skip to "Verify the connection" below. If it errors, continue.
+
+### Step 2: Ensure the relay is running
+
+```bash
+curl -sf http://127.0.0.1:8766/health
+```
+
+If not running (in a container, ALWAYS use `--supervise` so it survives your session): `adom-desktop serve --supervise`
+
+### Step 3: Ask what OS they use
+
+Ask the user: **"What operating system is your desktop/laptop? (Windows, Mac, or Linux)"**
+
+- **Windows 10/11** -- proceed
+- **macOS / Linux** -- "Support is coming soon. The desktop app currently only runs on Windows."
+
+### Step 4: Install & connect
+
+Run `adom-desktop setup_desktop` — it auto-detects this container's relay URL and returns a ready-to-run **`register_command`**, a fallback `server_config` JSON, and the `installer_url`. Prefer the zero-paste path:
+
+---
+
+**Option 0: Zero-paste via your Adom account (PREFERRED)**
+
+Run the **`register_command`** that `setup_desktop` returned (it's `adom-desktop register_container` with the name + URL filled in). That injects this connection into the **user's Adom cloud profile**. Then the user just:
+1. Installs Adom Desktop (from `installer_url`) and opens it,
+2. **Signs in with their Adom account** (Connections panel → "Sign in to Adom"),
+
+and the connection appears in their list and auto-connects — **no JSON paste at all**. This is the modern path and the one to try first. (`register_container` needs an Adom cloud container — it uses the mounted account token — and returns `ok:false` on a non-Adom box; if so, use Option A/B below.)
+
+**Already-running desktop? `server_sync` (v1.9.215+).** A signed-in AD reads the cloud profile at boot, at sign-in, and hourly — and `adom-desktop server_sync` (optionally `--target <name>`) makes it re-read RIGHT NOW. The merge is cloud-wins, whole entry including `authToken`. So the flow for adding a server or **rotating a relay token** on live desktops is: update the cloud key (`register_container` writes them) → `server_sync` per desktop → verify with `server_list`. Do NOT use `server_remove` + `server_add` for rotation — removing the entry you are talking through severs your own transport (wiki #387).
+
+---
+
+**Option A: Automatic setup via Claude Code (paste fallback)**
+
+If you have **Claude Code** or **Claude Desktop** on your laptop, paste this prompt:
+
+> Install Adom Desktop and connect it to my cloud container. (Most users instead get Adom Desktop bundled with Hydrogen Desktop, which installs and connects it for them; use this prompt when you are driving the desktop from a cloud container.) Download the installer from `<installer_url>`, run it silently, then write this JSON to `%USERPROFILE%\.adom\config.json`:
+> ```json
+> {"servers":[<server_config JSON>]}
+> ```
+> Then launch "Adom Desktop" from the Start Menu.
+
+*(Replace `<installer_url>` and `<server_config JSON>` with the actual values from `setup_desktop`.)*
+
+**Option B: Manual setup**
+
+1. Download the installer: **[<installer filename>](<installer_url>)**
+2. Run it and follow the prompts — the app installs to the Start Menu as "Adom Desktop"
+3. Open **Adom Desktop** from the Start Menu
+4. Paste this JSON into the text field that says **"Paste server JSON to add ..."** and press **Enter**:
+   ```json
+   <server_config JSON>
+   ```
+5. The server will appear and auto-connect
+
+---
+
+Wait for the user to confirm they see a green dot next to the server name before proceeding.
+
+**Important:** Each container has its own relay server. New containers need a new entry -- old entries from previous containers won't work.
+
+### Step 6: Verify the connection
+
+```bash
+adom-desktop ping
+# Expected: { "echo": "pong", "roundTripMs": ..., "status": "connected" }
+
+adom-desktop status
+# Expected: one client with the user's hostname and capabilities
+
+adom-desktop notify_user '{"title":"Hello from Docker!","body":"Your desktop is connected."}'
+```
+
+Tell the user what you see. If `ping` succeeds:
+
+> "Your desktop is connected! I can now send files to your machine, open browser windows for visual debugging, control KiCad and Fusion 360, take screenshots of your desktop, and send you notifications."
+
+### Step 7: Optional -- Node.js for browser features
+
+Ask: **"Would you like to use the visual browser debugging feature? This opens Chrome windows on your desktop that I can control."**
+
+- **Yes + Node.js installed** -- "Great, the browser bridge auto-installs deps on first use."
+- **Yes + no Node.js** -- "Download Node.js from https://nodejs.org (LTS), install it, then restart Adom Desktop."
+- **No** -- skip, they can enable it later
+
+### Common connection issues
+
+| Symptom | Fix |
+|---------|-----|
+| `ping` returns `"No desktop client connected"` | User hasn't added this container in the desktop app yet |
+| Desktop app shows "disconnected" | Check the URL uses `wss://` (not `ws://` or `https://`), port is `8765` |
+| Relay server not running | `adom-desktop serve --supervise` (survives your session; see Starting the Relay Server) |
+| Multiple stale connections | `adom-desktop kick_all` -- app auto-reconnects within seconds |
+| Desktop app not installed | Download from [github.com/adom-inc/adom-desktop/releases](https://github.com/adom-inc/adom-desktop/releases) |
+
+## CLI Tool
+
+```bash
+adom-desktop <command> '<json-args>'
+```
+
+**Examples:**
+```bash
+adom-desktop ping
+adom-desktop status
+adom-desktop browser_open_window '{"sessionId":"dart2","url":"https://example.com"}'
+adom-desktop browser_eval '{"sessionId":"dart2","expr":"document.title"}'
+adom-desktop browser_screenshot '{"sessionId":"dart2"}'
+adom-desktop browser_list_windows
+adom-desktop browser_close_window '{"sessionId":"dart2"}'
+adom-desktop notify_user '{"title":"Hello","body":"From Docker"}'
+adom-desktop shell_execute '{"command":"echo hello"}'
+adom-desktop pull_file '{"filePaths":["C:\\Users\\john\\Downloads\\image.png"],"saveTo":"/tmp"}'
+```
+
+**Output:** JSON to stdout. Screenshots save to `/tmp/adom-desktop-screenshots/` and return the file path.
+
+**Config location:** The desktop app stores server config at `~/.adom/config.json` (`%USERPROFILE%\.adom\config.json`). This is separate from the binary — config survives updates and reinstalls.
+
+## Available Commands
+
+**Get the full structured command list with descriptions, args, and prerequisites:**
+```bash
+adom-desktop commands            # aliases: list_commands, help
+adom-desktop --target <name> commands   # + THAT box's installed bridges (installed_bridges_on_target)
+```
+Returns categorized JSON with every command, its required/optional args, return values, prerequisites, and workflow notes. Run this FIRST when you're unsure what a verb is called.
+
+**Two kinds of verbs (v1.9.103 — don't confuse them):**
+- **BUILT-IN AD-core verbs** are always available on every Adom Desktop, no install, NOT bridges. This is where screenshots, windows, input, files, registry, shell, tunnel, update, and notify live. A whole-screen shot is `desktop_screenshot_screen`; one window (with chrome) is `desktop_screenshot_window {"hwnd":N}` or `{"titleContains":"..."}` (get hwnds from `desktop_list_windows`).
+- **BRIDGE verbs** (`kicad_`/`fusion_`/`browser_`/`blender_`/`nbrowser_`…) need that bridge installed — see `installed_bridges_on_target` in `commands`, or run `bridge_list`.
+
+If a verb returns `errorCode:"unknown_verb"`, **read the error's `_hint`** — it carries a fuzzy "did you mean" (e.g. `desktop_screenshot` → `desktop_screenshot_screen`) and tells you whether the right verb is built-in or a bridge. Don't conclude a capability is "a missing bridge" from a guessed verb name; call `commands` and check.
+
+### Connection Management
+
+- `ping` -- 5s round-trip test. Use BEFORE browser/shell commands to verify the desktop connection is alive.
+- `status` -- Check who's connected, their capabilities, desktop paths, and **app installation status**. The `desktop.apps` object shows:
+  - `kicad.installed` / `kicad.version` / `kicad.bridgeRunning`
+  - `fusion360.installed` / `fusion360.running` / `fusion360.bridgeRunning` / `fusion360.addinInstalled` / `fusion360.addinConnected`
+  - `browser.bridgeRunning`
+- `kick_all` -- Force-disconnect all WebSocket clients. Active Adom Desktop apps auto-reconnect within seconds.
+
+#### Programmatic server registration (v1.8.22+)
+
+Three verbs let external apps (Hydrogen Desktop's WSL2 workspace, in particular) register their relay server with adom-desktop without the user pasting JSON into the GUI by hand. Mirrors the GUI's Quick Add bar + connect/disconnect buttons.
+
+- `server_add` -- Upsert a relay server by `name`. If a server with that name already exists, the URL (and optionally authToken) is updated rather than creating a duplicate.
+  ```bash
+  adom-desktop server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'
+  # → {ok:true, name, url, id, connected:bool, created:bool, _hint}
+  ```
+  - `name` (required) — dedup key. Repeat calls with the same name are idempotent.
+  - `url` (required) — relay WebSocket URL.
+  - `authToken` (optional) — defaults to a dev token (leave unset unless your relay requires one).
+  - `autoConnect` (optional, default `true`) — connect right after upsert. Set `false` to add the entry without dialling out.
+  - Behavior: same URL + already connected → no churn, returns ok. URL changed + autoConnect=true → disconnect old loop + spawn new one. URL changed + autoConnect=false → disconnect old, leave entry registered but not connected.
+- `server_remove` -- Disconnect (if connected) and delete an entry by name. Returns `{ok, removed:bool, wasConnected:bool}`. Idempotent (removing a non-existent entry returns ok with removed:false).
+  ```bash
+  adom-desktop server_remove '{"name":"hydrogen-workspace"}'
+  ```
+- `server_list` -- Persisted server list with live connection status: `{servers:[{name, url, id, autoConnect, enabled, connected:bool, clientCount:0|1, status}]}`. `status` is the fine-grained `connected | reconnecting | disconnected` state machine value.
+
+Persists to the same `~/.adom/config.json` the GUI uses, so entries survive a GUI restart. The `ws_client` supervisor (runs every 30s inside the GUI) auto-reconnects entries with `autoConnect:true` on next launch.
+
+HD-style usage:
+```bash
+# At HD container startup
+adom-desktop server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'
+
+# At HD container shutdown
+adom-desktop server_remove '{"name":"hydrogen-workspace"}'
+```
+
+#### Addressing multiple Adom Desktops on one relay (v1.8.133+)
+
+One relay can have **several** Adom Desktops connected at once — e.g. a laptop and an Azure VM, eventually many. Each AD has a **name** (its `--client-name` / Settings value, defaulting to the OS hostname). The cloud CLI addresses a specific one, or fans out to all:
+
+- `adom-desktop targets` — list connected ADs: `{count, targets:[{name, hostname, version, os, connectedAt}]}`. Discover names here first.
+- `adom-desktop --target <name> <verb>` — route the verb to that AD only. `--target` is a **global** flag (goes before the verb; case-insensitive; matches `clientName`, else `hostname`). `ADOM_TARGET=<name>` sets a default (the flag wins).
+- `adom-desktop --target all <verb>` — fan out to **every** connected AD; returns `{broadcast:true, targets:[...], results:{<name>:<verb-result>}, summary:{total,ok,failed}}`. A per-AD timeout/failure is isolated to that AD's entry.
+
+```bash
+adom-desktop targets
+adom-desktop --target azurevm desktop_screenshot_screen
+adom-desktop --target all hd_status        # every box at once, results keyed by name
+```
+
+**No-target behavior:** with exactly ONE AD connected, no `--target` is needed (back-compat). With MULTIPLE connected and no `--target`, the call FAILS with `{error:"ambiguous_target", connected:[...], _hint}` (exit 1) — it won't guess which box. A bad name → `{error:"target_not_found", requested, connected:[...]}` (exit 1). Set each AD's name with the `--client-name <name>` launch flag (HD passes this when spawning the embedded AD), the Settings field, or `config.json` `client_name`. Full design + the relay-routing internals: [`skills/MULTI_AD_ADDRESSING.md`](MULTI_AD_ADDRESSING.md).
+
+#### Direct HTTP API for sibling apps (v1.8.25+)
+
+If you're authoring a sibling Tauri app on the same machine (Hydrogen Desktop, or any future "Adom-family" app), you can skip the CLI binary entirely and POST commands straight into the running adom-desktop GUI on `127.0.0.1:47200` (was `127.0.0.1:8770` through v1.8.32 — moved to be a better neighbor to HD on 47080+ and avoid the 8000-range collision risk). The endpoint runs inside the GUI process (loopback-only bind), uses the same dispatcher the WS path uses, and returns the same JSON shape as `adom-desktop <verb>` — including every `_hint` field.
+
+| Method | Path | Body | Returns |
+|---|---|---|---|
+| `GET` | `/health` | — | `{"ok":true,"service":"adom-desktop"}` cheap probe |
+| `GET` | `/status` | — | service banner + version + schema + `directApi.cliRequired` list |
+| `POST` | `/command` | `{"app":"<ns>","command":"<verb>","args":{...}}` | The verb's normal payload (200 OK), or `{error, errorCode, _hint}` (4xx/5xx) |
+
+**v1.8.33+ port discovery (sibling apps READ THIS):** Don't hardcode 47200. The GUI may have bound to 47200-47209 instead if the default port was taken (zombie socket, dev instance, third-party collision). Discovery protocol:
+
+1. Read `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`) — single-line `host:port`, written by the GUI at bind time, removed at graceful shutdown
+2. If file missing or its port doesn't `/health`, scan `127.0.0.1:47200..=47209` for any port answering with `{"ok":true,"service":"adom-desktop"}`
+3. Validate `service == "adom-desktop"` to disambiguate from other apps that might bind a port in our range
+
+The CLI does this automatically (see `cli/src/direct_probe.rs`). For sibling-app Rust code, see [`skills/DIRECT_API.md`](DIRECT_API.md) for a copy-paste-ready helper.
+
+**Per-verb timeouts (v1.8.31+):** the direct API's command-timeout is no longer a hardcoded 120s — it mirrors the CLI dispatcher's per-verb table (`walk_cloud_tree`/`search_cloud_files`=620s; heavy exports=320s; `bridge_install`/`fusion_start`=300s; default=120s). Caller can override via `args.timeout` (seconds, clamped to 1800).
+
+Example — programmatic server registration without the CLI:
+
+```bash
+curl -X POST http://127.0.0.1:47200/command \
+  -H 'Content-Type: application/json' \
+  -d '{"app":"desktop","command":"server_add","args":{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}}'
+# → identical JSON to `adom-desktop server_add '{...}'`, including the _hint
+```
+
+**What's safe to send directly:** essentially everything sync (`server_*`, `bridge_list`, `hd_status`, `hd_build_status`, `desktop_list_windows`, `desktop_screenshot_*`, `kicad_*`, `fusion_*`, `browser_screenshot`, `notify_user`, ...) plus async-dispatching verbs that return a job id in <500 ms (`bridge_install`, `hd_build`, `desktop_install_kicad`, ...).
+
+**What requires the CLI fallback:** verbs returning a structured `errorCode:"cli_required"` from this endpoint — currently `pull_file`, `send_files`, `shell_execute`. These use binary streaming or multi-minute approval polling that doesn't fit a single synchronous HTTP request. `GET /status` returns the full list at runtime so callers can branch defensively.
+
+Full integration guide (loopback trust model, recipes for HD startup/shutdown, retry/backoff patterns, port-discovery code in Rust + TypeScript) lives in [`skills/DIRECT_API.md`](DIRECT_API.md) in this repo, and as a wiki asset attached to `apps/adom-desktop`.
+
+#### Embedded mode — AD bundled inside Hydrogen Desktop (v1.8.42+)
+
+When Hydrogen Desktop (HD) bundles AD, AD enters "embedded mode" at boot. HD owns the system-tray icon (AD's is suppressed), HD owns the Adom Cloud login (HD's session token is handed to AD via Phase 4's existing channels), HD owns auto-updates (AD's wiki-poll is disabled — HD bundles a fresh AD on every HD release), and HD spawns AD hidden (its tray "Open Adom Desktop" surfaces the window on demand). Standalone AD users see zero change — none of the signals fire.
+
+**Detection** — three-signal cascade (any one triggers embedded mode):
+
+1. `--embedded` CLI flag (what HD passes on every spawn)
+2. `ADOM_EMBEDDED=1` env var (backup channel)
+3. `%LOCALAPPDATA%\Adom Desktop\embedded.json` marker file (survives AD restarts; written by HD's installer; deleted by HD's uninstaller)
+
+**CLI flags HD passes when spawning AD**:
+
+```bash
+adom-desktop.exe --embedded --start-hidden \
+  --relay-url ws://127.0.0.1:8765 --relay-name hydrogen-desktop \
+  --session-token <hd's-stored-token>
+```
+
+- `--embedded` → enter embedded mode (also writes the marker)
+- `--start-hidden` → boot with main window invisible. **v1.8.119+: honored in embedded mode** (HD owns the surface; AD stays in the tray and opens via `desktop_window_show`). v1.8.102–118 had a bug where embedded mode force-showed the window even with `--start-hidden`; that's fixed. The machine owner can still flip AD's "Always show the window at launch" Settings checkbox (config `always_show_window_on_launch`, default OFF) to override and always show — a personal-dev-box preference, not the product default.
+- `--shell-approval <prompt|1h|24h|permanent>` → HD's passthrough of its user's shell-approval choice (v1.8.116+, session-only)
+- `--relay-url <url>` → upsert this relay via existing `server_add` dedup; default name `hydrogen-desktop`
+- `--relay-name <name>` → override the default name
+- `--session-token <tok>` → already covered by Phase 4 handoff (env / CLI arg / file)
+
+**Docker introspection** — `adom-desktop desktop_embedded_status` returns `{embedded, owner, source, enteredVia, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` so cloud-side callers can branch on whether AD is standalone or HD-managed. **v1.8.73+:** `enteredVia` reports `"launch-flag"` (HD spawned AD as a child via `--embedded`) or `"runtime-adopt"` (AD was running standalone; HD adopted it at runtime) — see "Runtime adopt + release" below.
+
+**New verbs HD uses to drive AD** (all also usable standalone):
+
+| Verb | Purpose |
+|---|---|
+| `desktop_window_show` | Bring AD's main window to foreground |
+| `desktop_window_hide` | Hide AD's main window (app keeps running) |
+| `desktop_connect_all` | Spawn ws_loop for every enabled server |
+| `desktop_disconnect_all` | Drop every live WS connection (entries stay in config) |
+| `desktop_shutdown` | Explicit AD termination (stop bridges, then exit) |
+| `desktop_embedded_status` | Introspect current embedded state (incl. `enteredVia`) |
+| `desktop_embedded_enter` | **v1.8.73+** runtime adopt: standalone → embedded as `runtime-adopt` |
+| `desktop_embedded_release` | **v1.8.73+** revert to standalone. **v1.8.77+: always reverts, never exits AD** (regardless of `entered_via`) — use `desktop_shutdown` for explicit exit |
+| `desktop_logout` | Clear AD's `~/.adom/session.json` (HD's sign-out propagation) |
+
+#### Runtime adopt + release (v1.8.73+) — the LaunchFlag-vs-RuntimeAdopt distinction
+
+HD has two ways to put AD into embedded mode:
+
+1. **Spawn AD with `--embedded`** (LaunchFlag) — HD launches a fresh AD process as its child. AD enters embedded with `entered_via=launch-flag`. When HD subsequently dies ungracefully (without calling `/embedded/release` or `desktop_shutdown` first), AD's 60s `embedded_monitor` tick notices HD's control port is dead and **exits AD** (the "no hanging chad" rule — no tray-less orphan).
+2. **Adopt at runtime via `POST /embedded/enter`** (RuntimeAdopt) — AD was already running standalone; HD calls the direct-API endpoint to flip AD into embedded mode without a respawn. AD enters embedded with `entered_via=runtime-adopt`, preserving its open state, current PID, all in-flight connections. When HD subsequently dies ungracefully, AD's monitor **reverts AD to standalone** — tray restored, footer pill hidden, marker deleted. AD process stays alive.
+
+The rule is simple: **whoever started AD owns AD's death**. If AD started itself (standalone → runtime adopt), HD's death doesn't take AD down with it.
+
+**Two new direct-API endpoints** (v1.8.73+, sugar over `POST /command`):
+
+```bash
+# Adopt — runs the same handler as desktop_embedded_enter:
+curl -s -X POST http://127.0.0.1:47200/embedded/enter
+
+# Release — runs the same handler as desktop_embedded_release.
+# v1.8.77+: ALWAYS reverts to standalone. AD never exits via this call.
+curl -s -X POST http://127.0.0.1:47200/embedded/release
+```
+
+**Probe target (v1.8.75+):** the 60s `embedded_monitor` checks HD's *control port* (default `47084`, discovered from `%APPDATA%\hydrogen-desktop\ports.json` key `control`), not HD's legacy `:9001` webview-eval port. Same discovery `hd_api` uses. Pre-v1.8.75 this was hardcoded to `:9001` — HD doesn't bind that port anymore, so the probe always reported HD dead and every embedded AD reverted/exited within 60s of HD starting.
+
+**Live frontend update (v1.8.76+):** the "Embedded · HD" footer pill responds to Tauri `mode-changed` events emitted on every transition. So if AD's window is open when HD adopts it (or HD releases it), the pill flips immediately — no page reload, no manual refresh.
+
+**Stale-marker safety (v1.8.74+):** if the marker file says `entered_via=launch-flag` AND HD's control port isn't responding at boot, AD deletes the marker BEFORE exiting. Without the delete, every subsequent manual launch (no `--embedded` flag) would hit the same exit branch and AD would be unlaunchable until the user manually rm'd the marker. The delete-before-exit makes the next launch start standalone normally.
+
+**Shell auto-approve is a user preference** (v1.8.116+ — replaces the v1.8.44 embedded-permanent default). The safe default everywhere is per-command approval. Resolution order at AD boot:
+
+1. `--shell-approval <prompt|1h|24h|permanent>` launch flag — HD's passthrough of its user's choice when spawning AD embedded. **Session-only**: never persisted by AD.
+2. The durable Settings checkbox ("Auto-approve shell commands permanently", config field `shell_auto_approve_permanent`, default OFF). Also set/cleared via `shell_auto_approve '{"permanent": true|false}'`.
+3. A restored timed grant from a previous session (+1h/+24h buttons, `duration_secs` calls).
+4. Otherwise: prompt per command.
+
+In embedded mode **HD owns the preference** (HD is the parent, AD the child): AD's Settings checkbox goes read-only with "Managed by Hydrogen Desktop", and the footer shows "**Permanent** *(managed by Hydrogen Desktop)*" when armed. HD changes it at runtime with the same verb:
+
+```bash
+# revoke everything (timed + permanent + the saved checkbox):
+adom-desktop shell_auto_approve '{"duration_secs": 0}'
+
+# set the durable preference (equivalent to ticking the Settings checkbox):
+adom-desktop shell_auto_approve '{"permanent": true}'
+```
+
+Migration note: pre-1.8.116 embedded boots persisted a permanent grant that leaked into standalone sessions. On first v1.8.116 boot, a permanent-class grant on disk WITHOUT the config preference is cleared — re-opt-in via the checkbox or the verb if you actually wanted it.
+
+**⚠ v1.9.90 — a cloud/relay AI CANNOT grant shell approval itself.** The `shell_auto_approve` examples above are HD's (a trusted LOCAL caller) or the human's own. From a **relay** connection (the cloud AI), a GRANT (`shell_auto_approve {"permanent":true}` / `{"duration_secs":N>0}`) is REFUSED with `errorCode:"human_only"` and instead POPS the human approval prompt in Adom Desktop. **The AI's path: call `request_shell_approval` (or just let the gate/refusal auto-surface the prompt) → ask the user to click Approve 1 hr / 24 hr / Bypass Permissions → RETRY your original verb.** Only a human clicking in AD (or HD proxying its own user) grants. A REVOKE (`duration_secs:0`) is allowed for the AI.
+
+#### Bridge ports are dynamic — you never need to know one (v1.8.31+)
+
+KiCad, Fusion, Browser/Puppeteer, and all third-party bridges now bind OS-assigned ephemeral ports (not the legacy 8772/8773/8851 you may remember). The runtime port changes every spawn. **Callers never need to know it.**
+
+- The CLI verb namespace (`kicad_*`, `fusion_*`, `browser_*`, plus any third-party `<bridge>_*`) is the contract. Always go through that.
+- adom-desktop's direct API forwards to whatever port each bridge is on at the moment of the call.
+- `bridge_list` reports `spawn.runtimePort` per bridge for debugging — do NOT hardcode it anywhere.
+- Why this changed: HD's bridges also wanted 8772/8773/8851; we used to collide silently. Dynamic ports = clean coexistence.
+
+### Managing bridges (the AI fully controls bridges on its own)
+
+Bridges are AD's plugins (kicad, puppeteer, fusion360 ship bundled; others install from the wiki). You — the AI — can see, update, install, remove, pause, and resume them entirely through verbs. No human step is required.
+
+| Verb | What it does |
+|---|---|
+| `bridge_list` | Every installed bridge: `name`, `displayName`, `version` (what AD runs now), `source` (bundled / cache / third_party), `paused`, spawn kind, verbPrefixes. Start here. |
+| `bridge_check_updates` | **READ-ONLY.** Per bridge: `current` vs `latest` (wiki) + `updateAvailable`. Downloads nothing. The "what's behind?" probe. |
+| `refresh_bridges` | Apply updates. No args = sync ALL bridges from the wiki. `{"name":"kicad"}` = update just one. |
+| `bridge_install` | Install a new third-party bridge from a wiki manifest URL (`{"manifestUrl":"…/<bridge>-bridge-manifest.json"}`). On `success:true` the **capsule appears in the AD GUI immediately** (no verb call needed; it's gray/idle until first use, then green). On `success:false` **nothing is registered and no capsule appears** — read `error` (a `read zip body`/`builder error` means the manifest's zip URL couldn't be fetched). Don't treat a parsed manifest as a completed install; confirm with `bridge_list`. The manifest `url` may be relative (resolved against the manifest's dir, v1.8.169+). |
+| `bridge_uninstall` | Remove a bridge (`{"name":"…"}`). Bundled ones revert to the bundled copy; third-party ones are fully removed. |
+| `bridge_pause` / `bridge_resume` | Flip a routing flag so a bridge's verbs fail-fast with `bridge_paused` (or resume). Does NOT kill the process. |
+
+**Naming convention for published bridges (3rd-party authors, read this):** when you publish a bridge to the Adom wiki, its **page title / catalog descriptive name MUST start with `Adom Desktop - `** (spaced hyphen) — e.g. `Adom Desktop - Altium Bridge`, matching the bundled bridges (`Adom Desktop - Puppeteer Bridge`, `Adom Desktop - KiCad Bridge`). This keeps every AD bridge grouped with consistent names in app-index pages instead of scattering as bare slugs. It applies to the **wiki/catalog title only** — NOT `bridge.json` `displayName` (the in-app chip label, which stays tool-descriptive like `Altium Designer`) or `name` (the lowercase slug). Full authoring walkthrough: the `adom-desktop-bridge-sdk` skill (the `adom-desktop-bridges` wiki page) + its fill-in-the-blanks templates.
+
+**Typical "am I on the latest bridges?" flow:**
+```bash
+adom-desktop bridge_check_updates            # read-only: see current vs wiki per bridge
+# → {bridges:[{name,current,latest,updateAvailable,source,wikiReachable}], updatesAvailable:N}
+adom-desktop refresh_bridges                  # update ALL that are behind
+adom-desktop refresh_bridges '{"name":"kicad"}'   # or just one
+```
+
+**Version semantics (so the numbers make sense):**
+- `current` = what AD actually runs = the newer of the writable cache and the bundled copy (version-aware, numeric semver — `1.0.10 > 1.0.9`).
+- A bundled bridge that ships AHEAD of the wiki shows `updateAvailable:false` — AD correctly keeps the newer bundled copy. **puppeteer is bundled-only** (its node_modules deps exceed the wiki file limit), so it never shows a wiki update; it updates with the AD installer.
+- After an `updated` action, the bridge SERVER PROCESS still holds the old code in memory until it respawns. `is_bridge_running` is version-aware (v1.8.142+) and forces a respawn on the next verb call, so you normally don't need to kill anything — but if you want it instant: `taskkill /f /im python.exe` (kicad/fusion) or `node.exe` (puppeteer).
+
+**Auto-update cadence:** a running AD also auto-syncs bridges on launch + every 4 hours. `bridge_check_updates` / `refresh_bridges` (or the GUI's **"Check for updates"** button next to "+ Bridge") are the on-demand "I just published, pick it up now" path.
+
+#### Bridge lifecycle — one instance, stable port (v1.8.153+)
+
+A dynamic bridge gets **one stable port** that AD reuses across every spawn and across AD restarts (persisted in `~/.adom/bridge-ports.json`). A long-lived external client (e.g. a browser extension that dials the bridge) keeps reaching the same port — no chasing a fresh ephemeral port each cycle, no "the client is on bridge A but AD routes to bridge B" split. AD enforces a **single instance**: it tracks every spawned bridge PID, reaps a stale/wedged one before respawning on the same port, and `bridge_stop_all` reaps orphans too.
+
+`spawn.persistent` (in `bridge.json`) is now surfaced in `bridge_list` (`spawn.persistent`) and means: **AD auto-respawns this bridge on death**, on its stable port (a supervisor runs each ~15 s). So "kill the bridge to load new code" → AD brings it right back at the same port the client keeps dialing. `bridge_install` / `refresh_bridges` also no longer fail with the cache-dir lock (AD reaps the process holding the dir as its cwd, then swaps; in-place merge fallback if a handle lingers).
+
+**v1.8.186/187 — first-class bridges (panel + remote management + optional manifest fields).** The AD window has a full-height **Bridges sidebar** that shows each bridge as a mini-app (hero image, LED, rich tooltip); clicking one opens a wide pop-out with a **live console** (its `~/.adom/bridge-logs/<name>.log` + the per-bridge command history), a **ports/process debug view** (live PIDs — `>1` flags a runaway), persistent-vs-one-off + codebase-language badges, and all management actions (install/uninstall/**kill**/restart/pause/resume + "filter activity log to this bridge" + "copy contribution prompt"). The same management is available **remotely** to cloud/WSL2 threads via verbs: `bridge_info`/`bridge_detail` (rich aggregate), `bridge_log_read {name, sinceOffset?}` (**cursor-streaming remote tail -f**), `bridge_kill` (wipe one bridge's processes). As of v1.8.186 **all** bridges write their per-bridge log (bundled kicad/fusion360/puppeteer included, not just dynamic ones). New **optional** `bridge.json` fields (all back-compat, surfaced in the panel): `hero` (a relative filename on your wiki page, or a full URL — your app-icon; absent → a monogram tile), `languages` (override the auto-scan, e.g. `["Python"]`), `releasedAt` (ISO date; absent → bridge.json mtime).
+
+**v1.8.156 — strays + self-documenting hints.** AD now tracks every bridge PID it spawns (persisted across AD restarts) and **reaps all stray instances** before respawning — so you don't end up with 4 copies of one bridge fighting over the routing. `bridge_list` surfaces `spawn.instanceCount` per dynamic bridge (normally 0 or 1; **>1 = strays piled up** — visible at a glance). If a bridge verb fails to reach a healthy bridge, the error `_hint` tells you to retry / check `instanceCount` / `process_list`; and a verb response right after a (re)spawn carries an `_ad_note` saying the external host reconnects within a few seconds — **retry** instead of treating a transient "not connected" as terminal. For verb discovery from a bridge, `GET <ADOM_DIRECT_API_URL>/commands` lists every reachable verb (see [`skills/DIRECT_API.md`](DIRECT_API.md)).
+
+#### Bridge status chip — self-reported LED + tooltip (v1.8.152+)
+
+Each bridge has a status dot in the AD GUI sidebar. **AD is a pure renderer of bridge health** — a live bridge decides its own LED color and hover text; AD just paints what the bridge reports. This replaced the old scheme where AD inferred the LED from whether it was holding a spawn handle, which left fire-and-forget (dynamic/installed) bridges stuck RED even while healthy and routing.
+
+**The contract (all three fields OPTIONAL).** On the endpoint AD already polls — your `bridge.json` `healthEndpoint` (default `/health`; the sample bridges use `/status`) — return a 2xx JSON body that MAY include:
+
+| Field | Type | Meaning |
+|---|---|---|
+| `led` | `"green"` \| `"yellow"` \| `"red"` | The dot color. Green = healthy; yellow = degraded/warning; red = the bridge itself knows it's broken (e.g. lost its upstream). |
+| `summary` | string | Short label shown next to the dot. Keep it tiny (≤ ~24 chars) — e.g. `"2 windows"`, `"ext disconnected"`. |
+| `tooltip` | string | Full hover text. Newlines allowed; no hard length limit (keep it readable). |
+
+**Rules AD enforces:**
+- Endpoint returns 2xx **with** `led`/`summary`/`tooltip` → rendered **verbatim**, no parsing or logic.
+- Endpoint returns 2xx **without** them → AD defaults to **green** (you're reachable) + a generic tooltip. Existing bridges work unchanged — this is opt-in.
+- Endpoint **unreachable** → AD owns this ONE state: a **gray/offline** dot + AD's own tooltip ("Not running" / "Bridge not reachable"). A dead bridge can't self-report, so AD never lets a bridge claim "offline" — it detects it.
+
+**Cadence:** AD re-polls every ~15 s, and a dynamic bridge flips green the instant it spawns. So a bridge that surfaces live counters (open windows, queue depth, ext-connection state) in `summary`/`tooltip` updates in the GUI within a tick.
+
+Reference implementations: [`scripts/sample-bridges/hello-python/server.py`](../scripts/sample-bridges/hello-python/server.py) and [`hello-rust/src/main.rs`](../scripts/sample-bridges/hello-rust/src/main.rs) both self-report `led`/`summary`/`tooltip` on `/status` — fork either.
+
+### File Transfer
+
+- `send_files` -- Send files from the Docker container to the desktop. Files are base64-encoded in transit.
+  - `filePaths`: array of absolute paths on the server
+  - `targetApp`: "kicad", "fusion360", or "general"
+  - `dest`: **absolute** Windows path (under a safe root) to land the files exactly where you want (v1.8.148+). Prefer this when you know the target dir.
+  - `destinationFolder`: **relative subfolder only** (e.g. `"kicad/symbols"`, `"fusion"`). The desktop app controls the base directory. **Absolute paths are rejected.** With neither `dest` nor `destinationFolder`, files land in the laptop's **Downloads** folder.
+  - Returns `destinationPaths[]` with the absolute path of every saved file, plus (v1.8.168) `savedToDir` (the folder) and a rich `_hint`. **The files land on the LAPTOP (Windows host), not in your container** — verify with `list_dir`, pull one back with `pull_file`.
+  - **`send_files` only COPIES bytes — it does NOT install or register a bridge.** Dropping bridge files with `send_files` will NOT make a capsule appear or verbs route. To add a bridge use `bridge_install '{"manifestUrl":"…/<bridge>-bridge-manifest.json"}'`, then confirm with `bridge_list`.
+
+- `pull_file` -- Pull files from the desktop to the container.
+  - `filePaths`: array of absolute Windows paths on the desktop
+  - `saveTo`: directory on the container to save files (default: `/tmp`)
+  - **Streaming since v1.4.3.** Each file is transferred as 1 MiB binary WS frames straight to disk on the Docker side, with incremental SHA256 verification. The legacy 30s base64-JSON path is gone — large files (50 MB+ datasheets, 75 MB reference manuals) no longer time out. Per-file timeout is 600s.
+  - Returns `files: [{name, path, size, sha256, chunks}]`. Use `sha256` to verify the transfer (the desktop side computes it during streaming and the container side verifies on completion; mismatch deletes the partial file and reports failure). `chunks` is the count of 1 MiB binary frames received.
+  - When at least one but not all files succeed: `success` is `true`, `errors[]` lists the failures alongside `files[]`. When ALL fail: `success` is `false`.
+
+### Desktop Notifications
+
+- `notify_user` -- Send a native Windows toast (own AUMID + Adom logo). v1.9.33: rich types — progress bar, sticky reminder, action buttons that **bring AD to the foreground** on click + round-trip via `notify_response`.
+  - `title`, `body`, `level` (info/success/warning/error/emergency), `actions` (label buttons) — plus `scenario` ("reminder"=sticky), `durationLong`, `buttons` (`[{label, arguments?}]`), `progress` (`{title, value 0..1, status, valueOverride}`), `inputs`, `id` (correlation id for the round-trip).
+  - Full guide + copy-paste examples per type: the **adom-desktop-notify** skill ([`skills/NOTIFY.md`](NOTIFY.md)).
+- `notify_response` -- Poll which toast button the user clicked, by correlation `id`. `{pending:true}` until they click; `{pending:false, action}` once they do (consumed on read).
+
+### KiCad (`kicad_*`) — bridge-owned; light pointer
+
+The KiCad bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/adom-desktop-kicad-bridge`). This page keeps only the pointer: run `adom-desktop sync_skills` after connecting and the full KiCad guide (project workflows, three-tier `kicad_open_*` routing, DRC recipes, library install) lands in `~/.claude/skills/`.
+
+Most-used verbs to get moving:
+
+```bash
+adom-desktop kicad_open_board '{"path": "C:/projects/board/board.kicad_pcb"}'
+adom-desktop kicad_screenshot_all '{}'          # every KiCad window incl. error dialogs, as an array
+adom-desktop kicad_run_drc '{}'
+adom-desktop kicad_window_info '{}'
+adom-desktop kicad_open_symbol_editor '{"library": "Device"}'
+```
+
+If KiCad is not installed, the bridge knows how to install it for the user — offer, never point them at a download page. Full details: the bridge's own skill.
+
+### Fusion 360 (`fusion_*`) — bridge-owned; light pointer
+
+The Fusion 360 bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/adom-desktop-fusion-bridge`). Run `adom-desktop sync_skills` and the full guide (cloud files, import/export, add-in troubleshooting, APS search) lands in `~/.claude/skills/`.
+
+Most-used verbs:
+
+```bash
+adom-desktop fusion_start '{}'                       # launches Fusion; slow on cold start (minutes)
+adom-desktop fusion_get_app_state '{}'               # the status/poll verb
+adom-desktop fusion_addin_status '{}'
+adom-desktop fusion_dismiss_blocking_dialogs '{}'
+adom-desktop fusion_send_key '{"key": "Escape"}'
+```
+
+Fusion launches take 2-4 minutes cold; respect `timeoutSeconds` + `statusVerb` on responses instead of re-issuing. Full details: the bridge's own skill.
+
+### Launching apps (generic)
+
+Two generic CLI commands exist for launching any Windows executable safely — they verify the target exists BEFORE handing it to the OS, so you never trigger a "Windows cannot find" GUI dialog:
+
+- `adom-desktop find_exe '{"name":"..."}'` -- Resolve an exe by absolute path, glob (e.g. `C:/.../webdeploy/production/*/Fusion360.exe` returns newest), bare name (searches PATH + Start Menu `.lnk` targets). Returns `{path, source}`. Does NOT launch.
+- `adom-desktop launch '{"path":"...", "args":[...], "cwd":"...", "detached":true}'` -- Same resolution rules as `find_exe`, then spawns. Fails in terminal (exit 1) with a clear error if the path doesn't exist. **Always prefer this over raw `start`.**
+
+For Fusion specifically, use `fusion_start` — it wraps `launch` plus the full startup-picker / add-in-readiness dance.
+- `fusion_import_step` -- Import a STEP/STL/IGES file into Fusion 360
+- `fusion_open_lbr` -- Open an EAGLE .lbr library file
+- `fusion_open_electronics` -- Check if the Electronics workspace is active
+- `fusion_electron_run` -- Execute any EAGLE command via Electron.run. Returns rich state: `activeWorkspace`, `activeDocument`, `commandType`, `_hint`, and `fusionOperations` (diff of Fusion's internal operation log showing what actually fired). **Avoid blocking commands** — see list below. **Full EAGLE command reference:** [`skills/eagle-commands.md`](./eagle-commands.md) — popular/safe commands split from blocking/modal ones, with layer reference and chaining syntax.
+- `fusion_execute_text_command` -- Low-level app.executeTextCommand() access. Returns the command result plus workspace context.
+- `fusion_board_info` -- Get structured board data from the open PCB layout. Returns: component placements (name, package, x, y, rotation), net names, copper traces, layer setup, board thickness, DRC violations. Much richer than a screenshot — gives exact coordinates and connectivity. Requires a .brd board open in PCB Editor.
+
+#### `fusion_electron_run` — EAGLE Command Execution
+
+Executes EAGLE commands inside Fusion 360's Electronics workspace. Works in **Schematic Editor**, **PCB Editor (Board Layout)**, and **Electronics Library** contexts.
+
+**How it works:** Sends the command string via Fusion's `Electron.run` text command. EAGLE's `Electron.run` is fire-and-forget — it never returns output or throws on invalid commands. To compensate, the handler snapshots Fusion's internal operation log (`Diagnostics.RecentOperations`) before and after execution, returning a diff showing what actually fired.
+
+**Usage:**
+```bash
+# Basic command
+adom-desktop fusion_electron_run '{"command": "WINDOW FIT"}'
+
+# Multiple commands in sequence (use semicolons)
+adom-desktop fusion_electron_run '{"command": "DISPLAY NONE; DISPLAY 1 16 17 18 20 21"}'
+
+# Navigate in library editor
+adom-desktop fusion_electron_run '{"command": "EDIT SOIC8.pac"}'
+adom-desktop fusion_electron_run '{"command": "EDIT RESISTOR.sym"}'
+adom-desktop fusion_electron_run '{"command": "EDIT MYDEVICE.dev"}'
+```
+
+**Response fields:**
+- `activeWorkspace` — Current workspace (Schematic Editor, PCB Editor, Electronics Library)
+- `activeDocument` — Name of the open document
+- `commandType` — Detected type: `view_control`, `layer_control`, `edit`, `design_rule`, etc.
+- `editorType` — For EDIT commands: `package`, `symbol`, `device`
+- `fusionOperations` — Array of Fusion operations that fired (diff of internal log)
+- `hint` — Human-readable description of what happened
+- `rawResult` — Raw return from Electron.run (usually empty)
+
+**EAGLE Command Reference — Safe for automation:**
+
+| Command | Context | Description |
+|---------|---------|-------------|
+| **View / Navigation** | | |
+| `WINDOW FIT` | Any | Zoom to fit all content |
+| `WINDOW (x1 y1 x2 y2)` | Any | Zoom to specific area (coordinates in current units) |
+| `DISPLAY ALL` | Any | Show all layers |
+| `DISPLAY NONE` | Any | Hide all layers |
+| `DISPLAY 1 16 17 18 20 21` | Board | Show specific layers by number |
+| **Grid** | | |
+| `GRID MM 0.1` | Any | Set grid to 0.1mm |
+| `GRID MIL 25` | Any | Set grid to 25mil |
+| `GRID INCH 0.05` | Any | Set grid to 0.05 inch |
+| **Board Layout** | | |
+| `RATSNEST` | Board | Recalculate airwires (unrouted connections) |
+| `RIPUP` | Board | Remove all routed traces |
+| `RIPUP *` | Board | Remove all traces (same as RIPUP with no selection) |
+| `ROUTE` | Board | Start auto-router |
+| `DRC` | Board | Run design rule check |
+| `BOARD` | Schematic | Switch to paired board layout |
+| `SCHEMATIC` | Board | Switch to paired schematic |
+| **Schematic** | | |
+| `VALUE value` | Schematic | Set component value |
+| `NAME name` | Any | Rename selected element |
+| `SMASH` | Any | Detach name/value labels from components |
+| **Library Editor** | | |
+| `EDIT name.pac` | Library | Open a package (footprint) for editing |
+| `EDIT name.sym` | Library | Open a symbol for editing |
+| `EDIT name.dev` | Library | Open a deviceset for editing |
+| `EXPORT SCRIPT 'path.scr'` | Library | Export entire library as EAGLE script |
+| **Scripting / Settings** | | |
+| `SET CONFIRM YES` | Any | Suppress confirmation dialogs |
+| `SET CONFIRM OFF` | Any | Re-enable confirmation dialogs |
+| `SCRIPT 'path.scr'` | Any | Run batch commands from a .scr script file |
+
+**EAGLE Layer Numbers (commonly used):**
+
+| Layer | Name | What it shows |
+|-------|------|--------------|
+| 1 | Top | Top copper |
+| 16 | Bottom | Bottom copper |
+| 17 | Pads | Through-hole pads |
+| 18 | Vias | Via holes |
+| 19 | Unrouted | Airwires (ratsnest) |
+| 20 | Dimension | Board outline (required for 3D) |
+| 21 | tPlace | Top silkscreen |
+| 22 | bPlace | Bottom silkscreen |
+| 25 | tNames | Top component names |
+| 27 | tValues | Top component values |
+| 29 | tStop | Top solder mask |
+| 31 | tCream | Top stencil/paste |
+| 51 | tDocu | Top documentation |
+
+**Blocking commands — AVOID from automation:**
+
+| Command | Why it blocks |
+|---------|---------------|
+| `WRITE` | Opens Save As dialog — use `fusion_save_lbr` or `fusion_close_document` instead |
+| `ADD` | Opens component picker dialog |
+| `SHOW name` | Opens interactive highlight mode |
+| `SET` (no params) | Opens settings dialog |
+| `GRID` (no params) | Opens grid settings dialog |
+| `EDIT new.sym` | Opens "Create new?" confirmation if symbol doesn't exist |
+| `CHANGE` | Opens interactive change mode |
+| `MOVE` | Opens interactive move mode |
+
+**Tips:**
+- Always run `WINDOW FIT` after opening a file or switching views
+- Use `DISPLAY NONE` then `DISPLAY <layers>` to show only specific layers
+- Combine commands with `;` — e.g., `SET CONFIRM YES; RIPUP *; RATSNEST`
+- For board screenshots: `DISPLAY NONE; DISPLAY 1 16 17 18 20 21; WINDOW FIT`
+- Use `fusion_board_info` instead of EAGLE commands when you need structured data
+- `fusion_export_lbr` -- Export the open Electronics library as an EAGLE .scr script (note: does NOT include 3D package references — those are cloud-linked only)
+- `fusion_save_lbr` -- Save the open Electronics library as a .flbr file
+
+#### EAGLE Libraries with 3D Packages
+
+Fusion 360's .lbr format supports `package3d` elements that link footprints to 3D models. Key facts:
+
+- **3D models are cloud-hosted** — each `package3d` has a `wip_urn` (e.g., `urn:adsk.wipprod:fs.file:vf.xxxxx`) pointing to a Fusion cloud document. You cannot embed STEP files directly in .lbr XML.
+- **Creating 3D packages requires the Fusion UI** — use `Package3DCreateCmd` in Electronics Library Editor, which opens a new Design workspace where you model/import the 3D shape, then save to link it.
+- **`EXPORT SCRIPT` strips 3D references** — the .scr export only contains 2D data (symbols, footprints, devicesets). To preserve 3D links, keep the .lbr XML format.
+- **Fusion's built-in examples** have 3D packages — 34 of 37 libraries in the EAGLE examples directory (e.g., `Connector_USB.lbr`, `Resistor.lbr`, `Capacitor.lbr`) include `package3d` references.
+- **Example libraries location**: `%LOCALAPPDATA%/Autodesk/webdeploy/production/<hash>/Applications/Electron/LibEagle/examples/libraries/examples/`
+
+To open a built-in example library for reference:
+```bash
+# Find the examples directory first
+adom-desktop fusion_execute_text_command '{"command": "Python.RunScript C:/tmp/find_eagle_libs.py"}'
+# Then open one
+adom-desktop fusion_open_lbr '{"filePath": "<path>/Connector_USB.lbr"}'
+```
+- `fusion_open_schematic` -- Open a .sch schematic in Fusion's Schematic Editor. Args: `filePath`.
+- `fusion_open_board` -- Open a .brd board layout in Fusion's Board Layout editor. Args: `filePath`.
+- `fusion_show_3d_board` -- Switch to 3D PCB board view (must have a .brd open). Board MUST have an outline on layer 20 (Dimension) or 3D generation fails. Auto-zooms to fit after switching.
+- `fusion_show_2d_board` -- Switch back to 2D board layout from 3D PCB view. EAGLE commands via `fusion_electron_run` only work in 2D.
+- `fusion_close_document` -- Close a document without save dialog. Args: `name` (optional, defaults to active doc), `save` (optional, default false). Essential for automation — avoids modal save dialog that blocks Fusion.
+- `fusion_document_info` -- List ALL open documents/tabs with name, type, and active status, plus detailed cloud info for the active document. Returns `openDocuments` array (every tab) and active doc details (cloud project, folder, file ID, version, save status). Lightweight — uses only in-memory data, no cloud API calls. Use this instead of `fusion_walk_cloud_tree` when you just need to know what's open.
+- `fusion_activate_document` -- Switch to a specific open document tab. Args: `name` (substring match, case-insensitive), `documentType` ("Electronics", "PCB", "FusionDesign", "Drawing"). Essential for automation — switch between schematic, board, and library tabs without user interaction. If no match found, returns the list of open documents so you can refine.
+- `fusion_close` -- Close Fusion 360. **Always call this when done with Fusion 360 commands to clean up.**
+- `fusion_dismiss_recovery` -- Dismiss recovery document dialogs (both "Recovered Documents" list and "Open recovery document instead?" prompts). Also relocates recovery files to `~/.adom/recovery/fusion/` for safekeeping.
+- `fusion_relocate_recovery` -- Proactively move Fusion crash recovery files to `~/.adom/recovery/fusion/<timestamp>/` without dismissing any dialogs. Call this **before** launching Fusion to prevent recovery dialogs from appearing. Files are preserved (not deleted) so the user can manually restore them if needed.
+- `fusion_close_all_documents` -- Close all open documents. Args: `saveChanges` (default: false). Use before force-killing Fusion to prevent recovery files.
+
+#### Fusion 360 UI Interaction
+
+Commands for interacting with Fusion 360's UI — detecting dialogs, taking screenshots, clicking, and sending keyboard input. Essential for handling blocking dialogs and automating CEF-based UI elements.
+
+- `fusion_window_info` -- Returns the Fusion main window HWND, title, rect, and a list of all Qt dialog windows (recovery dialogs, wizards, file pickers). Essential for detecting blocking dialogs before/after operations.
+  - Example: `adom-desktop fusion_window_info`
+  - Returns: `{hwnd, title, rect, dialogs: [{hwnd, title, className, rect}]}`
+
+- `fusion_screenshot_fusion` -- Captures the Fusion main window or a specific dialog by HWND. Uses PrintWindow (works without bringing to foreground). Saves WebP to `C:/tmp/adom-desktop-screenshots/` (falls back to PNG if WebP unavailable). Downscaled to ≤1568px — use relative coords (0.0-1.0) for clicking, they're scale-independent. DPI-aware.
+  - Args: `hwnd` (optional — dialog HWND to screenshot instead of main window)
+  - Example: `adom-desktop fusion_screenshot_fusion` (main window)
+  - Example: `adom-desktop fusion_screenshot_fusion '{"hwnd": 12345}'` (specific dialog)
+
+- `fusion_screenshot_all` -- Screenshots the main Fusion window and lists all dialog windows with their HWNDs. Use `fusion_screenshot_fusion {"hwnd": ...}` to capture each dialog.
+  - Example: `adom-desktop fusion_screenshot_all`
+
+- `fusion_click_fusion` -- Click at coordinates within the Fusion window or a specific dialog. x/y are relative (0.0-1.0) by default. Set `"relative": false` for pixel offsets. Uses SendInput for CEF dialog compatibility. **Prefer `fusion_send_key` for dialogs** — enter/escape/tab covers most cases.
+  - Args: `x` (required), `y` (required), `relative` (optional, default true), `hwnd` (optional — target a specific dialog HWND instead of main window)
+  - Example: `adom-desktop fusion_click_fusion '{"x": 0.5, "y": 0.7}'` (main window)
+  - Example: `adom-desktop fusion_click_fusion '{"hwnd": 12345, "x": 0.75, "y": 0.85}'` (dialog button)
+
+- `fusion_send_key` -- Send keyboard input to Fusion or a specific dialog via SendInput. **Preferred way to dismiss dialogs**: enter to confirm, escape to cancel, tab to cycle buttons. Use click only if tab order doesn't reach the right button.
+  - Args: `key` (required), `hwnd` (optional — target a specific dialog HWND)
+  - Example: `adom-desktop fusion_send_key '{"key": "escape"}'` (dismiss CEF dialog)
+  - Example: `adom-desktop fusion_send_key '{"key": "enter", "hwnd": 12345}'` (confirm dialog)
+
+#### Fusion 360 Workflow Guide
+
+##### Opening Electronics Projects
+1. Open the `.fprj` file: `adom-desktop fusion_open_cloud_file '{"projectName":"Main","fileName":"DRV8411A","fileExtension":"fprj","folderPath":"Molecules/XRP/DRV8411A"}'`
+2. Do NOT try to open `.fbrd` or `.fsch` directly — they fail or show a "Select Electronics Design File" dialog
+3. Enter the board editor: `adom-desktop fusion_show_2d_board`
+4. Enter the schematic editor: After step 3, use `adom-desktop fusion_electron_run '{"command":"EDIT .sch"}'`
+5. Switch back to board: `adom-desktop fusion_electron_run '{"command":"EDIT .brd"}'`
+
+##### Auto-Screenshot on Open Commands
+
+**Open commands automatically screenshot Fusion and return the images in the response.** The following commands include `postOpenScreenshot` in their `data` field:
+- `fusion_open_cloud_file`, `fusion_open_schematic`, `fusion_open_board`, `fusion_show_3d_board`, `fusion_show_2d_board`
+
+The response `data.postOpenScreenshot` contains:
+- `screenshots[]` — array of `{type, savedTo, sizeKB, title?, hwnd?}`. Type is `"main_window"` or `"dialog"`.
+- `message` — human-readable instruction to READ each screenshot and check for blocking dialogs
+- `dialogBlocking` — `true` if a modal dialog was detected
+
+**After receiving the response, you MUST:**
+1. **READ each screenshot file** using the Read tool to visually inspect what Fusion shows
+2. Look for blocking dialogs in the screenshots:
+   - "What to design?" wizard → dismiss with `fusion_send_key {"key": "escape"}`
+   - "PCB out of date" banner → click Update or X
+   - "Recovered Documents" → `fusion_dismiss_recovery`
+   - "Save changes?" → `fusion_send_key {"key": "escape"}`
+   - Any other modal → identify and dismiss
+3. **Screenshot again** after dismissing to confirm it's clear
+
+Screenshots are saved as WebP (lossless, ~20-40KB each, downscaled to ≤1568px) for token efficiency. Both the main Fusion window AND all Qt dialog windows are captured separately.
+
+**Why this matters**: Fusion shows Qt dialogs and CEF overlays that are invisible to the API. The `success: true` response from an open command does NOT mean the UI is ready — a dialog may be blocking all further operations. The API cannot detect these. Only a screenshot can.
+
+##### Detecting and Handling Blocking Dialogs
+- The auto-screenshot captures dialog windows separately (look for `type: "dialog"` entries in `postOpenScreenshot.screenshots[]`)
+- Common blocking dialogs: "What do you want to design?", "Recovered Documents", "Open recovery document instead?", "Select Electronics Design File"
+- Dismiss recovery dialogs: `adom-desktop fusion_dismiss_recovery`
+- Dismiss CEF dialogs (inside main window): `adom-desktop fusion_send_key '{"key": "escape"}'` or `fusion_click_fusion`
+- Dismiss "What do you want to design?" wizard: `adom-desktop fusion_send_key '{"key": "escape"}'`
+- For manual screenshot of specific dialogs: `adom-desktop desktop_screenshot_window '{"hwnd": <DIALOG_HWND>}'`
+
+##### Preventing Recovery Documents
+- Recovery files are at: `%LOCALAPPDATA%\Autodesk\Autodesk Fusion 360\<USER_ID>\CrashRecovery\`
+- Before force-killing Fusion, close all documents: `adom-desktop fusion_close_all_documents '{"saveChanges": false}'`
+- **Best practice**: Call `adom-desktop fusion_relocate_recovery` before launching Fusion. This moves recovery files to `~/.adom/recovery/fusion/<timestamp>/` — preserving them for the user while preventing modal dialogs.
+- The `fusion_start` command also auto-relocates recovery files before starting Fusion.
+- The `fusion_dismiss_recovery` command handles both "Recovered Documents" list and "Open recovery document instead?" prompts, and also relocates files.
+
+##### EAGLE Export Limitations
+- **Supported**: `EXPORT IMAGE`, `EXPORT NETLIST`, `EXPORT PARTLIST`
+- **NOT supported** (fail silently): `EXPORT DXF`, `EXPORT SVG`, `EXPORT DRILL`
+- Always verify export output: the updated `fusion_electron_run` now checks if the output file was created
+- Use `fusion_export_bom` and `fusion_export_cpl` for manufacturing data (these use the add-in's XML parser, not EAGLE export)
+- Use `fusion_export_gerbers` for Gerber files — produces a ZIP with all gerber layers (GTL, GBL, GTS, GBS, GTP, GBP, GTO, GBO, GKO, XLN). Auto-detects 2-layer vs 4-layer boards.
+
+##### 3D Model Exports
+- From the 3D view (activate the .f3d document): `fusion_export_step`, `fusion_export_stl`, `fusion_export_3mf`, `fusion_export_f3d`, `fusion_export_usdz`, `fusion_export_iges`, `fusion_export_sat`
+- Switch to 3D view: `fusion_show_3d_board` or activate the .f3d document
+- **STEP** — Industry-standard CAD interchange (SolidWorks, CATIA, Creo). Highest geometric fidelity.
+- **IGES** — Legacy CAD interchange. Use STEP for modern workflows.
+- **SAT** — ACIS solid model format. Used by SolidWorks, SpaceClaim.
+- **STL** — Mesh format for 3D printing and visualization. Options: refinement "low"/"medium"/"high".
+- **3MF** — Modern 3D printing with color/material support and multi-body.
+- **F3D** — Native Fusion 360 archive. Preserves parametric features, sketches, timeline, component refs. Best for archival.
+- **USDZ** — **Best for digital twins and GLB conversion.** Preserves full component hierarchy (Board, copper layers, soldermask, Packages), PBR materials, and named nodes. Each component becomes a toggleable node in GLB viewers. Also viewable directly on iOS/macOS (Apple Quick Look / AR).
+  - Digital twin pipeline: `fusion_export_usdz` → pull_file → `blender --background --python-expr "import bpy; bpy.ops.wm.usd_import(filepath='board.usdz'); bpy.ops.export_scene.gltf(filepath='board.glb')"`
+- **FBX** — Not available in current Fusion builds via the API. The command exists but fails clearly. Use `fusion_export_usdz` instead.
+- **DXF/DWG/OBJ/SKP** — Not available via the Fusion API (dialog-only). Commands exist but return clear errors with alternatives.
+
+##### 3D Viewport Captures
+- `fusion_take_screenshot` — Capture the Fusion viewport at any resolution without opening a dialog. Uses Fusion's render API (`saveAsImageFile`).
+  - Args: `outputPath` (required), `width` (default 1920), `height` (default 1080), `orientation` (optional)
+  - Orientations: `home`, `front`, `back`, `top`, `bottom`, `left`, `right`
+  - Example: Capture all 6 standard views for use as product images/icons:
+    ```bash
+    for orient in home front back top bottom left right; do
+      adom-desktop fusion_take_screenshot "{\"outputPath\": \"C:/tmp/3d-${orient}.png\", \"width\": 1920, \"height\": 1080, \"orientation\": \"${orient}\"}"
+    done
+    ```
+
+##### Electronics Source File Export (`fusion_export_source`)
+- `fusion_export_source` — Export the active electronics document as `.fsch`, `.fbrd`, or `.flbr` source file.
+  - Args: `outputPath` (required — full path with extension)
+  - The extension determines the format: `.fsch` (schematic), `.fbrd` (board), `.flbr` (library)
+  - Validates extension, creates output directory, verifies file was created, returns file size
+
+- **Full workflow to export both board and schematic from a cloud project:**
+  ```bash
+  # Step 1: Open the .fprj (NOT .fbrd/.fsch directly — those fail)
+  adom-desktop fusion_open_cloud_file '{"projectName":"Main", "fileName":"MyDesign", "fileExtension":"fprj", "folderPath":"Molecules/MyDesign"}'
+
+  # Step 2: Screenshot to check for blocking dialogs (always do this after open)
+  adom-desktop fusion_screenshot_fusion
+
+  # Step 3: Enter board view (MUST be Board Layout workspace, not 3D)
+  adom-desktop fusion_show_2d_board
+
+  # Step 4: Export .fbrd (board first — it's already in board view)
+  adom-desktop fusion_export_source '{"outputPath": "C:/tmp/exports/MyDesign.fbrd"}'
+
+  # Step 5: Switch to schematic (EDIT .s1 = first schematic sheet)
+  adom-desktop fusion_electron_run '{"command": "EDIT .s1"}'
+
+  # Step 6: Export .fsch
+  adom-desktop fusion_export_source '{"outputPath": "C:/tmp/exports/MyDesign.fsch"}'
+
+  # Step 7: Pull files to Docker
+  adom-desktop pull_file '{"filePaths":["C:/tmp/exports/MyDesign.fbrd","C:/tmp/exports/MyDesign.fsch"], "saveTo":"/tmp/exports"}'
+  ```
+
+- **Critical gotchas:**
+  - You MUST open the `.fprj` first — opening `.fbrd`/`.fsch` directly fails or triggers blocking dialogs
+  - You MUST call `fusion_show_2d_board` before exporting `.fbrd` (the Electronics Design overview won't work)
+  - Always export `.fbrd` FIRST (from board view), then switch to schematic for `.fsch`
+  - If export fails with "file not found", the wrong workspace is active — screenshot to verify
+  - After `fusion_open_cloud_file`, always screenshot to check for "Select Electronics Design File" dialog
+  - `WRITE` command is blocked — it opens a blocking save dialog. Use `fusion_export_source` instead
+
+- Note: `WRITE` is blocked — it opens a "Version Description" dialog on cloud docs even with a path argument (tested 2026-04-10). Use `fusion_save_to_cloud` to save to cloud, `fusion_export_source` for Fusion-format, or `fusion_export_eagle_source` for plain EAGLE format.
+
+##### Plain EAGLE Source Export (`fusion_export_eagle_source`)
+- `fusion_export_eagle_source` — Export the active electronics document as plain EAGLE XML `.sch` or `.brd`.
+  - Args: `outputPath` (required — full path ending in `.sch` or `.brd`)
+  - Internally: exports `.fsch`/`.fbrd` via `Document.CopyToDesktop`, then extracts the EAGLE XML from the ZIP container, cleans up the temp file.
+  - The output is valid EAGLE XML (`<?xml><eagle version="9.7.0">...`) parseable by standalone EAGLE, KiCad import, or any XML tool.
+  - Same workflow/prerequisites as `fusion_export_source` — just use `.sch`/`.brd` extensions instead of `.fsch`/`.fbrd`.
+
+  ```bash
+  # Board (.brd) — must be in PCB Editor / Board Layout
+  adom-desktop fusion_show_2d_board
+  adom-desktop fusion_export_eagle_source '{"outputPath": "C:/tmp/exports/MyDesign.brd"}'
+
+  # Schematic (.sch) — must be in Schematic Editor
+  adom-desktop fusion_electron_run '{"command": "EDIT .s1"}'
+  adom-desktop fusion_export_eagle_source '{"outputPath": "C:/tmp/exports/MyDesign.sch"}'
+  ```
+
+##### Dialog Dismissal (`fusion_close_window`)
+- `fusion_close_window` — Close a specific Fusion dialog by sending WM_CLOSE (equivalent to clicking X).
+  - Args: `hwnd` (required — from `fusion_window_info` or `fusion_dismiss_blocking_dialogs` remaining[])
+  - Works on dialogs that Escape doesn't close — e.g. "Recovered Documents"
+  - Does NOT force-kill — the dialog can still intercept WM_CLOSE
+
+##### Electronics Import (Round-Trip)
+- `fusion_import_electronics` — Import Fusion-native `.fsch`, `.fbrd`, or `.flbr` files as new local documents
+  - Uses `Document.newDesignFromLocal` under the hood
+  - Auto-screenshots after import (catches blocking dialogs)
+  - Args: `filePath` (required Windows path to .fsch, .fbrd, or .flbr)
+  - After import, use `fusion_save_to_cloud` to persist to Fusion cloud
+  - **`.fsch` import works standalone** — creates a new schematic project
+  - **`.fbrd` import may fail** — boards have external references to schematics/libraries. Error: "New design cannot be created from a local file containing external references"
+  - **`.flbr` import works standalone** — creates a new library project
+  - For legacy EAGLE files: use `fusion_open_schematic` (.sch), `fusion_open_board` (.brd), `fusion_open_lbr` (.lbr)
+
+##### Library Round-Trip Workflow
+Export and re-import Fusion electronics libraries:
+```bash
+# 1. Open library from cloud
+adom-desktop fusion_open_cloud_file '{"projectName":"Main","fileName":"Adom Common Components","folderPath":"Molecules/Libraries"}'
+
+# 2. Export as .flbr (Fusion-native binary) and .scr (EAGLE script text)
+adom-desktop fusion_save_lbr '{"outputPath":"C:/tmp/library.flbr"}'
+adom-desktop fusion_export_lbr '{"outputPath":"C:/tmp/library.scr"}'
+adom-desktop fusion_close_document
+
+# 3. Re-import the .flbr
+adom-desktop fusion_import_electronics '{"filePath":"C:/tmp/library.flbr"}'
+
+# 4. Verify symbols survived round-trip
+adom-desktop fusion_export_lbr '{"outputPath":"C:/tmp/library-verify.scr"}'
+
+# 5. Save to cloud with new name
+adom-desktop fusion_save_to_cloud '{"name":"Library-copy"}'
+```
+
+##### Demo Projects
+171+ electronics molecules are exported in the `adom-desktop-demo` repo (separate from adom-desktop).
+Three reference boards with full export formats:
+
+| Board | Cloud Path | Layers | Components | Files |
+|-------|-----------|--------|------------|-------|
+| DRV8411A | Main / Molecules / XRP / DRV8411A | 4 | 29 | 27 |
+| DRV8323SR | Main / Molecules / Experiments / MotorControl / DRV8323SR | 2 | 43 | 27 |
+| VL53L8BreakoutMolecule | Main / Molecules / XRP / TimeOfFlightVL53L8 / VL53L8BreakoutMolecule | 2 | 30 | 27 |
+
+Each folder contains: .fsch, .fbrd, bom.csv, cpl.csv, gerbers.zip, 6 board images, 7 3D renders (home/front/back/top/bottom/left/right), STEP, IGES, SAT, STL, 3MF, F3D, USDZ, board screenshot, 3D board screenshot.
+
+#### Cloud Document Management
+
+Manage Fusion 360 cloud documents (hub projects, files, versions). Required for 3D package workflows since EAGLE library 3D models are stored as cloud documents.
+
+- `fusion_save_to_cloud` -- Save the active Fusion document to the cloud. Args: `name` (required), `projectName` (optional, defaults to active project), `folderPath` (optional), `description` (optional). Returns: `fileId`, `versionNumber`, `wipUrn` (if cloud-hosted).
+- `fusion_list_cloud_projects` -- List all cloud projects in the user's hub. Returns array of `{name, id}` per hub.
+- `fusion_list_cloud_files` -- List files in a cloud project/folder. Args: `projectName` (optional), `folderPath` (optional). Returns: `files` array with `{name, id, versionNumber, fileExtension, dateModified}` and `subfolders` array.
+- `fusion_create_cloud_folder` -- Create a folder in a cloud project. Args: `folderName` (required), `projectName` (optional), `parentPath` (optional). Returns `folderId`. Idempotent — returns existing folder if it already exists.
+- `fusion_check_recovery` -- Check if a cloud file has a recovery document from a previous crash. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional). Returns `hasRecovery: true/false`. Use this BEFORE opening files to avoid the blocking "Open recovery document instead?" dialog. If recovery exists, call `fusion_open_cloud_file` with `recovery: "open"` (restore unsaved work) or `recovery: "discard"` (delete recovery, open cloud version).
+- `fusion_open_cloud_file` -- Open a cloud file in Fusion by name. **If a recovery document exists and no `recovery` arg is given, the command STOPS and reports the recovery instead of opening** — you must decide whether to preserve or discard unsaved work. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional), `recovery` ("open" = restore unsaved work, "discard" = delete recovery and open cloud version).
+- `fusion_export_cloud_file` -- Export the active Fusion document to a local file for transfer back to Docker. Args: `outputPath` (required), `format` (optional, default "step"). The exported file can then be pulled back to Docker via `pull_file`.
+- `fusion_delete_cloud_file` -- Delete a cloud file by name. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional). File must not be open in Fusion — close it first with `fusion_close_document`.
+- `fusion_walk_cloud_tree` -- **Long-running.** BFS walk of a cloud folder tree. Returns a flat list of all files and folders. Runs entirely on the Fusion main thread — **blocks all other add-in commands** until done (check progress with `fusion_addin_status`).
+  - Args: `projectName` (optional), `folderPath` (optional, starting folder), `maxDepth` (default 10), `maxFolders` (default 500), `extensions` (optional list, e.g. `["f3d","fprj"]`), `nameContains` (optional substring filter), `includeFiles` (default true)
+  - Returns: `{project, rootFolder, folders[], files[], stats: {foldersVisited, foldersSkipped, filesFound, maxDepthReached, truncated}}`
+  - **Per-folder timeout (30s):** If a single folder's cloud API calls take >30s (common for large projects), the folder is skipped and counted in `foldersSkipped`. This prevents indefinite hangs.
+  - **Progress tracking:** While running, `fusion_addin_status` returns `walkProgress` with `{foldersVisited, filesFound, currentFolder, queueSize}` — poll this every 1–10s to monitor progress (see "Live folder progress streaming" below).
+  - **Non-blocking alternative:** Use `fusion_search_cloud_files` for targeted searches, or `fusion_list_cloud_files` for single-folder listings (these are faster but don't recurse).
+  - Example: `adom-desktop fusion_walk_cloud_tree '{"projectName":"Main","folderPath":"Molecules","nameContains":"DRV","extensions":["fprj"]}'`
+
+#### Live folder progress streaming with `watch`
+
+For long walks, use the built-in `watch` wrapper. It spawns the inner search command on a worker thread, polls `fusion_addin_status` internally, and emits one JSON event per line to stdout as the walker visits each folder. **No manual polling loop needed.**
+
+```bash
+adom-desktop watch '{"command":"fusion_walk_cloud_tree","args":{"projectName":"Main","folderPath":"Molecules","nameContains":"BQ25792"}}'
+```
+
+Output is one JSON object per line, three event types:
+
+```jsonl
+{"event":"started","command":"fusion_walk_cloud_tree","args":{...},"interval":2,"_hint":"streaming progress events follow, one per line, ending with 'complete' or 'error'"}
+{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":15.0,"foldersVisited":14,"queueSize":50,"filesFound":0,"currentFolder":"Molecules/Sensing"}
+{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":23.4,"foldersVisited":22,"queueSize":108,"filesFound":0,"currentFolder":"Molecules/Examples"}
+{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":31.8,"foldersVisited":30,"queueSize":100,"filesFound":0,"currentFolder":"Molecules/RAPID NAME TAGS/Connor Wood"}
+... (one per real change in walkProgress, deduped) ...
+{"event":"complete","result":{"folders":[...],"files":[...],"stats":{"foldersVisited":169,"filesFound":12,"truncated":false}}}
+```
+
+Read stdout line-by-line. Stop when you see `event:complete` or `event:error`. The full final result is in the `complete` event's `result` field.
+
+**Optional `interval` arg** (default 2s, clamped to [1, 30]):
+
+```bash
+adom-desktop watch '{"command":"fusion_walk_cloud_tree","args":{...},"interval":1}'
+```
+
+**For Claude Code consumers**: pipe `watch` directly into the `Monitor` tool. Each JSON line becomes a real-time event notification in the chat — you (and the user) see folder names appear one-by-one as the walker visits them. No bash loop, no `disown`, no subprocess gymnastics. Verified live in 1.3.16 on a 169-folder walk that returned all 12 BQ25792 cloud files cleanly.
+
+**Watchable commands** (whitelist): `fusion_walk_cloud_tree`, `fusion_search_cloud_files`. Other commands return `success:false` with a `_hint` listing the watchable set.
+
+**When NOT to use watch**: short single-folder operations (`fusion_list_cloud_files`, `fusion_open_cloud_file`) — those return in <2s and don't need streaming. The `watch` wrapper is purely for the long BFS commands.
+- `fusion_search_cloud_files` -- **Long-running.** Recursive substring search on file names across cloud folders. v1.0.2+ of the add-in adds: per-folder timeout, doEvents() every 50 files, per-file try/except, iterative BFS, and a stale-lock watchdog — Fusion stays responsive throughout (no "Not Responding" freeze).
+  - **Args (no hard upper caps in v1.0.2+):** `query` (required, substring, case-insensitive), `projectName` (optional, defaults to active), `folderPath` (optional starting subfolder — **narrow with this**), `recursive` (default false), `maxDepth` (default 2), `maxFolders` (default 10), `maxResults` (default 20), `folderTimeout` (default 30s), `searchTimeout` (default 120s), `timeout` (HTTP envelope, default 620s).
+  - **Before calling:** if the user knows roughly where the file is (e.g. customer-named folder), **ask them**. Naming a subfolder cuts 5-40 min searches down to ~10s.
+  - **Why slow:** Autodesk's free Fusion 360 Python API has no indexed file-search endpoint. We walk the Data API one folder at a time — each = one HTTP round-trip to Autodesk. **This is an Autodesk API limitation, not adom-desktop's.** When telling the user the search is taking a while, attribute it to Autodesk / Fusion 360, not to adom-desktop's bridge. **There is now a FAST native alternative — `aps_search` (see "APS — fast indexed cloud search" below).** It hits Autodesk Platform Services' indexed Data Management API and returns ~instantly, even when Fusion is CLOSED. Call `aps_status` first; if `configured:true`, prefer `aps_search` and only fall back to this folder-walking verb when APS isn't set up.
+  - **Interpreting results (critical for no-false-negatives):** the response contains `searchComplete` (bool), `foldersSkipped`, `filesSkipped`, `truncated`, `folderLimitReached`, `searchTimedOut`. `searchComplete:true` ONLY when **all five** are clean — that's the confidence flag. If `searchComplete:false`, the search hit a cap before exhausting the scope; **don't report "file not found"** — re-run with broader caps OR narrower `folderPath`.
+  - **Case sensitivity:** fully case-insensitive both directions (query and file names lowercased). Substring match, not whole-word — `"cosm"` matches `COSMIIC`, `COSMOCOIL`, etc.
+  - **Cost feedback in response:** `costAnalysis: {elapsedSeconds, foldersPerSecond, estimatedSecondsPer100Folders}` so the AI can budget the next search realistically.
+  - **Real numbers from a live test:** searching `Main/Molecules` recursively (173 folders deep, max 8 depth) for "cosmiic" returned in 168 s with `searchComplete:true, totalFound:3`. Fusion stayed `main_thread:responsive` throughout.
+  - **Pair with `watch`** for streaming progress updates: see below.
+
+#### APS — fast indexed cloud search (v1.8.182+)
+
+`aps_search` is a **native, ~instant** alternative to the folder-walking `fusion_search_cloud_files`. It queries **Autodesk Platform Services** (APS, formerly Forge) **Data Management API**, which has a real server-side index — so a search that takes minutes by folder-walking returns in well under a second, and it works **even when Fusion 360 is closed** (it's pure HTTPS, no bridge required). adom-desktop runs its own 3-legged **PKCE** OAuth, so the user's APS **client secret never leaves their machine**.
+
+**One-time setup (user side):**
+1. Register a free app at <https://aps.autodesk.com> → enable the **Data Management API**.
+2. Set the app's **Callback URL** to exactly `http://localhost:8910/aps/callback`.
+3. Put the **Client ID** in AD config key `aps_client_id` (in `~/.adom/config.json`) or via Settings.
+4. Run `aps_auth` once — it opens the system browser for Autodesk sign-in. Tokens cache in `~/.adom/aps_tokens.json` and auto-refresh; you won't be prompted again.
+
+**Verbs:**
+- `aps_status` — **call this first.** Non-prompting. Returns `{configured, signedIn}`. `configured:false` → no client id; use `fusion_search_cloud_files`. `configured:true, signedIn:false` → run `aps_auth`. Both true → `aps_search` is ready.
+- `aps_auth` — interactive sign-in (opens browser). One-time per device.
+- `aps_hubs` — `{hubs[]{id,name}}` (your Autodesk teams).
+- `aps_projects {hubId?}` — `{hubId, projects[]{id,name}}` (default: first hub).
+- `aps_search {query, projectName?, projectId?, hubId?}` — `{query, hubId, count, results[]{name,id,type,lastModified,project}}`. `query` is a case-insensitive substring of the file's displayName. Without `projectName`/`projectId` it searches up to 8 projects in the hub. Open a hit with `fusion_open_cloud_file {fileName, projectName}`.
+
+**Decision flow for an AI doing a cloud file search:** `aps_status` → if `configured` (sign in with `aps_auth` if needed) use `aps_search`; else fall back to `fusion_search_cloud_files` and tell the user that registering a free APS app (above) would make future searches instant.
+
+**Export formats for `fusion_export_cloud_file`:**
+
+| Format | Extension | Use case |
+|--------|-----------|----------|
+| `step` | .step | Industry-standard CAD interchange (default) |
+| `stl` | .stl | 3D printing, mesh-based |
+| `f3d` | .f3d | Fusion 360 native archive (preserves all features) |
+| `iges` | .iges | Legacy CAD interchange |
+| `sat` | .sat | ACIS solid modeling kernel format |
+| `smt` | .smt | Parasolid format |
+
+Also supported for import via `fusion_import_step`: STEP (.step/.stp), STL (.stl), IGES (.iges/.igs), SAT (.sat), SMT (.smt), OBJ (.obj), F3D (.f3d).
+
+**Round-trip: workspace → Fusion Cloud → workspace**
+
+```bash
+# === workspace to Fusion Cloud ===
+# 1. Send file from Docker to Windows desktop
+adom-desktop send_files '{"files": [{"path": "/home/user/component.step"}]}'
+# 2. Import into Fusion
+adom-desktop fusion_import_step '{"filePath": "C:/Users/john/Downloads/component.step"}'
+# 3. Save to cloud (gets a wip_urn for 3D library linking)
+adom-desktop fusion_save_to_cloud '{"name": "my-component", "projectName": "Personal"}'
+
+# === Fusion Cloud to Docker ===
+# 1. Find and open the cloud file
+adom-desktop fusion_walk_cloud_tree '{"projectName": "Personal", "nameContains": "my-component"}'
+adom-desktop fusion_open_cloud_file '{"fileName": "my-component", "projectName": "Personal"}'
+# 2. Export to local filesystem
+adom-desktop fusion_export_cloud_file '{"outputPath": "C:/tmp/export/my-component.step", "format": "step"}'
+# 3. Pull back to Docker
+adom-desktop pull_file '{"path": "C:/tmp/export/my-component.step"}'
+
+# === Cloud management ===
+adom-desktop fusion_list_cloud_projects '{}'
+adom-desktop fusion_create_cloud_folder '{"folderName": "Electronics", "projectName": "Main"}'
+adom-desktop fusion_list_cloud_files '{"projectName": "Main", "folderPath": "Electronics"}'
+adom-desktop fusion_delete_cloud_file '{"fileName": "old-file", "projectName": "Main"}'
+```
+
+#### Manufacturing Exports (Gerbers, BOM, CPL)
+
+Export manufacturing files from an open PCB board — everything needed to fabricate boards and assemble components via Adom's PCBA service. **All manufacturing commands require a .brd board open in PCB Editor** (use `fusion_open_board` or `fusion_show_2d_board`).
+
+**Recommended workflow order:** `detect_layers` → `set_design_rules` → DRC → `export_gerbers` → `export_bom` → `export_cpl` → `pull_file` all back to Docker.
+
+Every manufacturing command returns structured JSON with:
+- `message` — AI-oriented summary explaining what was produced and why it matters
+- `nextSteps[]` — ordered list of what to run next in the manufacturing pipeline
+- `hint` — tips for interpreting results or recovering from issues
+- `data` — structured metadata (paths, counts, layer info) for programmatic use
+
+**Decision guide — which command to run:**
+- **Don't know the layer count?** → Run `fusion_detect_layers` first
+- **Need to check if the board meets fab specs?** → Run `fusion_set_design_rules` then DRC
+- **Ready to generate fab files?** → Run `fusion_export_gerbers`, then `fusion_export_bom`, then `fusion_export_cpl`
+- **Need visual review before export?** → Run `fusion_export_board_image` with preset `assembly_top` or `fabrication`
+- **Something failed?** → Check `data.hint` and `data.recoverySteps` in the error response
+
+**Commands:**
+
+- `fusion_detect_layers` -- Detect if the open board is 2-layer or 4-layer. Uses ULP script (primary) and CAM comparison (fallback). Returns `layerCount`, `copperLayers[]`, `method` (how it was detected), and `nextSteps[]`. **Run this first** — all other manufacturing commands auto-detect layers too, but running this explicitly gives you the data before committing to exports.
+
+- `fusion_set_design_rules` -- Apply Adom's JLCPCB-derived design rules (.edru XML files) to the open board. Auto-detects 2-layer vs 4-layer and loads the appropriate rule set. Returns `description` (human-readable rule summary), `edruFile`, and `nextSteps[]`.
+  - `action`: `"apply"` (default) loads rules into the board; `"export"` saves current board rules to a file; `"show"` displays rule capabilities without modifying anything
+  - `layers`: `"auto"` (default), `"2"`, or `"4"` — force a specific rule set
+  - After applying: run `fusion_electron_run '{"command": "DRC"}'` to check for violations. DRC markers appear in the board editor.
+
+- `fusion_apply_instapcb_rules` -- Convenience wrapper that applies the bundled Adom InstaPCB design rule sets without needing a local `.edru` file. Args: `layers` (`"2"` or `"4"`). Sends the bundled `.edru` from Docker to Windows automatically and loads it via the EAGLE `drc load` command. Use this instead of `fusion_set_design_rules` when you just want Adom's standard 2-layer or 4-layer InstaPCB rules.
+
+- `fusion_load_design_rules` -- Generic loader for ANY custom `.edru` file by path. Args: `filePath`. Use this for third-party fab vendor rules (JLCPCB, PCBWay, OSHPark, etc.) that the user has on disk.
+
+- `fusion_export_gerbers` -- Export Gerber (RS-274X) + Excellon drill files as a ZIP. Auto-detects 2-layer vs 4-layer and selects the correct JLCPCB-compatible CAM job. Returns `zipPath`, `zipSizeKB`, `files[]` (list of gerber files in the ZIP with sizes), `layerCount`, and `nextSteps[]`.
+  - `outputDir`: directory for the output ZIP (default: `C:/tmp/adom-gerbers/`)
+  - `boardName`: prefix for the ZIP filename (default: from active document name)
+  - `layers`: `"auto"` (default), `"2"`, or `"4"` — force CAM job selection
+  - Output ZIP contains: GTL, GBL (copper), GTS, GBS (solder mask), GTP, GBP (paste), GTO, GBO (silkscreen), GKO (outline), XLN (drill). 4-layer boards also get G1, G2 (inner copper).
+
+- `fusion_export_bom` -- Export Bill of Materials as CSV. Groups identical parts by value+package with quantity counts. Returns `componentCount`, `uniquePartCount`, and `nextSteps[]`.
+  - `outputPath`: (default: `C:/tmp/adom-bom.csv`)
+  - `grouped`: `true` (default) groups by value+package; `false` lists every component individually
+  - Output columns: Comment, Designator, Footprint, Quantity, Library — compatible with JLCPCB, PCBWay, Mouser, Digi-Key.
+
+- `fusion_export_cpl` -- Export Component Placement List (pick-and-place) as CSV. Returns `totalPlacements`, `topCount`, `bottomCount`, and `nextSteps[]`.
+  - `outputPath`: (default: `C:/tmp/adom-cpl.csv`)
+  - `side`: `"all"` (default), `"top"`, or `"bottom"` — filter for single-sided assembly
+  - Output columns: Designator, Mid X, Mid Y, Layer, Rotation — coordinates in mm.
+
+- `fusion_export_board_image` -- Export PNG image of the board with layer presets. Returns `fileSize`, `preset`, and `nextSteps[]`.
+  - `outputPath`: (default: `C:/tmp/adom-board.png`)
+  - `dpi`: resolution (default: 300, max: 600)
+  - `preset`: layer preset name (see table below)
+  - `layers`: custom layer numbers as int array — overrides preset
+  - `monochrome`: `true` for black & white
+  - `listPresets`: set `true` to get available presets instead of exporting
+
+**Layer presets for `fusion_export_board_image`:**
+
+| Preset | Layers | Description |
+|--------|--------|-------------|
+| `all` | All | Every layer visible |
+| `top_copper` | 1, 17, 18 | Top copper + pads + vias |
+| `bottom_copper` | 16, 17, 18 | Bottom copper + pads + vias |
+| `top_silkscreen` | 21, 25 | Top silkscreen + component names |
+| `bottom_silkscreen` | 22, 26 | Bottom silkscreen + component names |
+| `top_soldermask` | 29 | Top solder mask openings |
+| `bottom_soldermask` | 30 | Bottom solder mask openings |
+| `top_paste` | 31 | Top paste/stencil openings |
+| `bottom_paste` | 32 | Bottom paste/stencil openings |
+| `board_outline` | 20 | Board outline (dimension layer) |
+| `drill` | 44, 45, 17, 18 | Drill holes + vias |
+| `assembly_top` | 1, 17, 18, 20, 21, 25, 51 | Top assembly — copper + silk + outline |
+| `assembly_bottom` | 16, 17, 18, 20, 22, 26, 52 | Bottom assembly — copper + silk + outline |
+| `fabrication` | 1, 16, 17–20, 21, 22, 25, 26, 29, 30, 51 | Full fabrication view |
+
+**Adom InstapcbPCB manufacturing capabilities (used by `fusion_set_design_rules`):**
+
+| Parameter | Metric | Imperial |
+|-----------|--------|----------|
+| Layers | 1, 2, 4, 6 | — |
+| Min trace width | 0.08mm | 3 mil |
+| Min trace spacing | 0.08mm | 3 mil |
+| Min via drill | 0.2mm | 8 mil |
+| Min silkscreen text | 0.1mm | 4 mil |
+| Board edge clearance | 0.05mm | 2 mil |
+| Board thickness | 1.6mm | 63 mil |
+| Copper weight | 0.5 oz (17.5 μm) | — |
+| Solder mask | Green | — |
+| Surface finish | HASL / ENIG | — |
+| Turnaround | 4 hours (fab + assembly) | — |
+
+**Manufacturing workflow — Fusion to Adom PCBA:**
+
+```bash
+# 1. Open the board
+adom-desktop fusion_open_board '{"filePath": "C:/projects/myboard.brd"}'
+
+# 2. Detect layer count (auto-selects 2-layer or 4-layer rules/CAM)
+adom-desktop fusion_detect_layers
+
+# 3. Apply Adom design rules for the detected layer count + run DRC
+adom-desktop fusion_set_design_rules '{"action": "apply"}'
+adom-desktop fusion_electron_run '{"command": "DRC"}'
+
+# 4. Export manufacturing files (gerbers auto-select correct CAM job)
+adom-desktop fusion_export_gerbers '{"outputDir": "C:/tmp/mfg"}'
+adom-desktop fusion_export_bom '{"outputPath": "C:/tmp/mfg/bom.csv"}'
+adom-desktop fusion_export_cpl '{"outputPath": "C:/tmp/mfg/cpl.csv"}'
+
+# 5. Export board images for review
+adom-desktop fusion_export_board_image '{"outputPath": "C:/tmp/mfg/top-copper.png", "preset": "top_copper"}'
+adom-desktop fusion_export_board_image '{"outputPath": "C:/tmp/mfg/assembly-top.png", "preset": "assembly_top"}'
+adom-desktop fusion_export_board_image '{"outputPath": "C:/tmp/mfg/board-outline.png", "preset": "board_outline"}'
+
+# 6. Pull files back to Docker for Adom PCBA ordering
+adom-desktop pull_file '{"path": "C:/tmp/mfg/bom.csv"}'
+adom-desktop pull_file '{"path": "C:/tmp/mfg/cpl.csv"}'
+```
+
+### Desktop Tools
+
+- `desktop_open_folder` -- Open a file or folder in Windows Explorer. If given a file path, Explorer opens with the file highlighted (selected). Args: `path` (file or folder path).
+- `desktop_open_url` -- Open a URL in the user's **NATIVE OS BROWSER** — i.e. the real Edge / Chrome / Firefox / Brave they use every day, with their saved logins, history, bookmarks, and extensions. This is NOT pup, NOT Chrome for Testing — it's the browser the human actually uses. Hand it off when the user needs to interact with a logged-in account themselves.
+  - Args: `url` (required string), `browser` (optional: `"default"` (Windows-registered default — could be Edge, Chrome, Firefox, Brave; whatever they set), `"chrome"`, `"edge"`, `"firefox"`, `"brave"`).
+  - Examples:
+    - `adom-desktop desktop_open_url '{"url":"https://claude.ai/"}'` -- opens in user's default native browser, already signed in
+    - `adom-desktop desktop_open_url '{"url":"https://docs.example.com","browser":"edge"}'` -- force native Edge
+  - Returns `{ok, browser, url, exePath?}`. Allowed schemes: http, https, mailto, ftp, ftps. Other schemes refused.
+
+#### `desktop_open_url` vs `browser_open_window` — two completely different browsers
+
+| | `desktop_open_url` | `browser_open_window` |
+|---|---|---|
+| **What browser** | Native OS browser the user uses daily — Microsoft Edge / Google Chrome / Mozilla Firefox / Brave (whichever they've set as Windows default, or the one you name explicitly) | Puppeteer-controlled **Chrome for Testing** — a separate Chromium build that pup launches |
+| **Profile / data** | The user's real profile: saved logins, history, bookmarks, extensions, autofill | Isolated profile under `plugins/puppeteer/profiles/<sessionId>/` — empty, no saved logins |
+| **Who interacts with the page** | The HUMAN. Claude hands the URL off and is done. | CLAUDE drives it programmatically — screenshots, clicks, eval, recording. The human just watches. |
+| **Use when** | A login is required (claude.ai, GitHub, internal tools, banking) — the human's existing session must be reused | Automation, scraping, screenshot capture, video recording, headful UI testing |
+| **Can Claude control it after launch?** | No — once handed off, Claude can't see or drive it | Yes — every `browser_*` verb (screenshot, eval, navigate, record, etc.) |
+
+**Decision rule:** human-in-the-loop login flow → `desktop_open_url`. AI-driven automation → `browser_open_window` (defaults to Chrome for Testing). AI-driven automation that **must** be the user's real, signed-in profile (GCP Console with org/projects, internal SSO behind a managed-Chrome policy, anything Context-Aware Access blocks for non-enrolled browsers) → `browser_open_window` with the new `nativeBrowser` + `nativeProfile` args (v1.0.5+, see below).
+
+#### When pup means "the user's real Chrome/Edge" — `browser_open_window` native mode (v1.0.6+, smart flow v1.0.11)
+
+Default `browser_open_window` (no native args) launches Chrome for Testing — same as day one. **Never auto-switch a plain pup request to native.** "Open this URL in pup", "screenshot this", "click that button" all stay on CfT.
+
+Native mode drives the user's **REAL browser** — their actual signed-in profile, with live cookies, org policies, and device enrollment intact (NOT a clone — v1.0.6 replaced the old clone approach). Opt-in via TWO additive args from `desktop_list_browsers`:
+
+```bash
+adom-desktop desktop_list_browsers     # installed browsers + profiles (+ the signed-in account per profile)
+adom-desktop browser_open_window '{
+  "sessionId":"gcp",
+  "nativeBrowser":"chrome",            # chrome / edge / brave
+  "nativeProfile":"Default",           # profile id: Default / Profile 1 / Profile 2
+  "url":"https://console.cloud.google.com"
+}'
+```
+
+**The smart flow.** CDP (the protocol pup drives) needs the browser running with `--remote-debugging-port`, which can only be set at launch. So the bridge handles each situation differently and tells you which happened:
+
+| Situation | What AD does | Response |
+|---|---|---|
+| Already driving that browser+profile | Reuse the live connection | `ok` + `narrative` |
+| Running **with** a debug port (power-user launched it that way, or AD did earlier) | Connect, no relaunch | `ok` + `narrative` |
+| **Not running at all** | **Launch it fresh with the debug port — NO prompt** (nothing to disrupt) | `ok`, `launchedFresh:true` + `narrative` — just proceed |
+| **Open without** a debug port | **Ask the user** — AD must close+reopen it | `errorCode:"native_relaunch_required"` + `confirmArgs` + `alternatives` |
+| Debug port **refuses to bind** (enterprise policy) | Won't close it again | `errorCode:"native_cdp_blocked"` + `diagnosis` + `alternatives` |
+
+**Handling the two consent/blocked codes:**
+
+- **`native_relaunch_required`** — the browser is open without the port, so driving it means closing + reopening it (with `--restore-last-session`, so all tabs/windows come back). **Ask the user's permission**, then re-call with `"nativeRelaunch":true`. The response's `alternatives` lists other installed browsers you could offer instead.
+- **`native_cdp_blocked`** — the debug port won't open, almost always an **enterprise policy** (`RemoteDebuggingAllowed` disabled via Google Workspace / Chrome cloud management — affects **Chrome browser-wide, every profile**). `diagnosis` says whether it's a confirmed local-registry policy or a likely cloud-managed one. AD will **not** close that browser again. `alternatives` names other installed browsers — **Edge usually escapes Google management**, so it often drives natively when Chrome can't. Offer the user the alternative, or fall back to generic CfT.
+
+**Always available — generic CfT.** `browser_open_window` with NO `nativeBrowser`/`nativeProfile` drives a clean Chrome-for-Testing instance: no user session, no managed policy, fully controllable. If a task needs the user's logged-in session there, have them sign in once. This is the universal fallback whenever native is blocked.
+
+**Power-user shortcut:** if the user launches their browser at startup with `--remote-debugging-port=<N>`, AD reconnects every session with no prompt at all (a productized opt-in "keep native browsers debug-ready at boot" is on the roadmap, default OFF for security).
+
+**Routing rule — DON'T auto-escalate.** Treat native as opt-in:
+
+| User says | What to do |
+|---|---|
+| "pup" / "open in pup" / screenshot / eval / debug | DEFAULT pup (CfT). Never mention profiles. |
+| "native chrome" / "my real browser" / "my work profile" / "my `<account>` profile" | Native. Call `desktop_list_browsers`. If exactly one profile clearly fits the request, use it and say which one ("opening in your `[email protected]` profile on Chrome"). If multiple profiles could fit (e.g. a `google.com` URL with both `[email protected]` and `[email protected]` profiles signed in), **ASK** ("Which profile — work `[email protected]` or personal `[email protected]`?") and then drive that pair. |
+| anything else | Default pup. |
+
+**Anti-fingerprint:** native sessions launch without `--enable-automation` and with `--disable-blink-features=AutomationControlled`. `navigator.webdriver === false`. No "controlled by automated software" banner. Sites that probe for automation see a normal browser.
+
+**Firefox** appears in `desktop_list_browsers` but is rejected for native drive with `errorCode:"firefox_no_cdp"` — Firefox doesn't speak the Chrome DevTools Protocol, so puppeteer can't drive it. If the user wants Firefox, hand off via `desktop_open_url` instead.
+
+**Native-mode error codes:**
+- `native_relaunch_required` — browser open without a port; ask the user, re-call with `nativeRelaunch:true` (see the smart-flow table above).
+- `native_cdp_blocked` — debug port policy-blocked; read `diagnosis`, offer `alternatives` or generic CfT (see above).
+- `firefox_no_cdp` — caller passed `nativeBrowser:"firefox"`. Firefox doesn't speak CDP; hand off via `desktop_open_url`.
+- `native_browser_not_installed` — that browser isn't installed on this machine.
+- `native_user_data_missing` — installed but never launched, so the User Data dir doesn't exist yet. Ask the user to open it once.
+- `native_profile_not_found` — `nativeProfile` isn't a real profile id under this browser's User Data. Call `desktop_list_browsers` to see what's valid.
+
+#### ⚠️ Common footgun — `localhost` / `file://` URLs from Docker AIs
+
+If you're running this CLI from a remote Docker container (galliaApril, dartv4, etc.), `browser_open_window` / `browser_open_tab` / `browser_navigate` will load the URL inside the USER's local pup — NOT inside your container. So a URL like `http://localhost:8080/foo.html` resolves to the user's machine, not your container's service.
+
+**v1.7.10+ detects this.** When you pass a URL whose host is `localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, or scheme is `file://`, the response includes a `_hint` field explaining the issue and suggesting the public-slug URL pattern (`https://<user>-<repo>-<suffix>.adom.cloud/proxy/<port>/<path>`). **The call still proceeds** — rare legitimate cases exist where the user actually does have something running on their localhost.
+
+**Recipe — load something from inside your container into pup:**
+
+```bash
+# 1. Start your service inside the container (e.g. a local HTML viewer):
+python3 -m http.server 8786 --bind 0.0.0.0 &
+
+# 2. Ask your USER for the container's adom.cloud slug suffix
+#    (look at their browser URL when they open hydrogen or claude.ai/code).
+#    Slug looks like: <username>-<container>-<random-id>
+
+# 3. Open the public URL — NOT localhost:
+adom-desktop browser_open_window '{
+  "sessionId":"demo",
+  "url":"https://<your-container-slug>.adom.cloud/proxy/8786/foo.html"
+}'
+```
+
+If you don't have the suffix handy, just ask the user. Or if the file you wanted to show pup is one your container already wrote to disk, push it to a public location (wiki asset, S3) and load from there.
+
+
+- `desktop_bring_to_front` -- Bring a window to the foreground by HWND or title substring. Uses keybd_event + AttachThreadInput to bypass Windows foreground lock. Preserves maximized state.
+  - Args: `hwnd` (int) OR `titleContains` (string, case-insensitive). One required.
+  - Example: `adom-desktop desktop_bring_to_front '{"titleContains": "Fusion"}'`
+- `desktop_set_window_state` -- Change a window's show state without bringing it to the foreground (or both, if combined with `desktop_bring_to_front`). Args: `hwnd` (int) OR `titleContains` (string) OR `cacheKey`, plus `state` (one of `"maximize"`, `"minimize"`, `"restore"`, `"show"`, `"hide"`, `"bottom"`). Use to ensure Fusion or KiCad windows are maximized for screenshots, or to hide noisy background windows during demos.
+  - Example: `adom-desktop desktop_set_window_state '{"hwnd":133560,"state":"maximize"}'`
+  - **`state:"bottom"` (v1.9.114) -- z-order send-to-back, background.** Drops the window BEHIND all others without focusing, moving, or resizing it (`SetWindowPos HWND_BOTTOM` + `SWP_NOMOVE|SWP_NOSIZE|SWP_NOACTIVATE`); works even on an off-screen window. It's an ATOMIC check-and-demote: it reads `GetForegroundWindow()` first, and if the target IS the OS foreground (the user is interacting with it) it SKIPS the demotion and returns `wasForeground:true, sentToBack:false` so the caller backs off with no race; otherwise `wasForeground:false, sentToBack:true`. Use to park a background window behind the user's windows (still on-screen, taskbar-clickable, rendering for screenshots) without stealing focus.
+  - **`force:true` (v1.9.115)** sends it to back EVEN IF the window is currently the OS foreground (still no activation change; `sentToBack:true` always, `wasForeground` still reported). Use at window-LAUNCH time: Windows reassigns the foreground to a brand-new window when the previous foreground is destroyed, so "foreground at launch" is not a real user signal.
+  - Example: `adom-desktop desktop_set_window_state '{"titleContains":"(session: abc","state":"bottom","force":true}'`
+
+#### Real OS input — mouse + keyboard (v1.8.170+)
+
+`desktop_click` / `desktop_double_click` / `desktop_right_click` / `desktop_hover` / `desktop_type` / `desktop_press_key` synthesize **real OS input via `SendInput`** — so they land on **`chrome://` pages and native dialogs that browser CDP (`nbrowser_*` / `browser_*`) cannot reach**. The verb names mirror the browser extension's `nbrowser_*` so the model transfers between in-page and whole-desktop driving.
+
+- **Gated behind shell-auto-approve** (same risk class as `shell_execute` — keystrokes/clicks hit whatever is focused on the user's live machine). If it's off you get `errorCode:"input_not_approved"`. **You (a cloud/relay AI) can't grant it yourself (v1.9.90)** — call `request_shell_approval` (or let the refusal auto-surface the prompt) and ask the user to click Approve / Bypass Permissions in Adom Desktop, then retry. Every action is written to the activity log.
+- **Focus a window first** by passing `{window:"<title substring>"}` (alias `titleContains`) or `{hwnd:<int>}` — reuses `desktop_find_window`'s resolver, so input lands on the intended window, not whatever happened to be focused. Get the hwnd from `desktop_list_windows` / `desktop_find_window`.
+- **Coords are physical screen pixels** — the same space as `desktop_list_windows` rects.
+
+Verbs:
+- `desktop_click {x, y, button?:"left"|"right"|"middle", double?, window?|hwnd?}` — left-click by default. `desktop_double_click` / `desktop_right_click` are wrappers; `desktop_hover` moves the cursor without clicking (reveals tooltips).
+- `desktop_type {text, window?|hwnd?}` — types a literal Unicode string (`KEYEVENTF_UNICODE`, any keyboard layout).
+- `desktop_press_key {keys:[...], repeat?, window?|hwnd?}` (alias `desktop_send_keys`) — chords/shortcuts. Modifiers `ctrl/alt/shift/win`; named keys `enter/tab/esc/space/backspace/delete/insert/home/end/pgup/pgdn/up/down/left/right/f1-f12`.
+
+Canonical "navigate a browser to a URL" flow (works on `chrome://` where CDP can't):
+```bash
+adom-desktop desktop_press_key '{"keys":["ctrl+l"],"window":"Edge"}'   # focus Edge + select the address bar
+adom-desktop desktop_type        '{"text":"edge://extensions","window":"Edge"}'
+adom-desktop desktop_press_key   '{"keys":["enter"],"window":"Edge"}'
+```
+For a control that isn't reachable by CDP/coords (e.g. the Reload button on `edge://extensions`, which lives in a shadow DOM), Tab-navigate to it then Enter: `desktop_press_key {"keys":["tab","tab","enter"]}`.
+
+**Visual "Adom cursor" (v1.8.171+)** — a click-through teal cursor + a label that narrates what you're driving (trust cue; byte-faithful to the browser extension's cursor). Pure visual, *not* gated:
+- `desktop_cursor {action:"show"|"move"|"hide", x, y, label}` — drive/narrate the cursor on its own ("watch me"). Returns the overlay `hwnd`.
+- Or pass `cursor:true` + `label:"Clicking Reload"` on `desktop_click`/`desktop_double_click`/`desktop_right_click`/`desktop_hover` — AD glides the teal cursor to the target (narrating the label) *then* does the real click.
+- **Auto-hide (v1.8.174+):** the cursor hides itself after `ttlMs` (default **60s**) so it never lingers on the user's screen if you forget to hide it. Each `show`/`move` refreshes the timer. Pass `ttlMs:<ms>` (≤600000) for longer, or `persist:true` to keep it until you explicitly `desktop_cursor {action:"hide"}`. If a cursor you showed was auto-hidden, the **next** cursor/click response prefixes its `_hint` with a NOTE so you learn (no chatty unsolicited message).
+
+#### Background input via UI Automation (v1.8.174+) — no foreground, no focus steal
+
+The mouse/keyboard verbs above use `SendInput` = real OS input, which only lands on the **foreground** window — so AD foregrounds the target first, yanking it in front of the user's work. When you must NOT steal focus, use **UI Automation** instead: `InvokePattern.Invoke()` / `ValuePattern.SetValue()` are **programmatic** — the app services them via its accessibility tree with **no cursor move, no focus change, no foregrounding**. Chromium/Edge expose their a11y tree to UIA, so `chrome://` page buttons/links are reachable **by accessible name** (not coordinates).
+
+- `desktop_find_control {window|hwnd, name|contains, role?}` — **read-only** discovery (ungated). Returns `best:{name, automationId, role, rect, invokable, settable}`. Call this first to learn whether a control is UIA-reachable.
+- `desktop_ui_click {window|hwnd, name|contains, role?}` — UIA **Invoke** by accessible name, in the background. Gated behind shell-auto-approve.
+- `desktop_ui_set {window|hwnd, name|contains, text}` — UIA **SetValue** on a text field, in the background. Gated.
+
+`role` ∈ `button|edit|link|checkbox|menuitem|tab|listitem|combobox|radiobutton|document|group|spinner`. **Caveat (surfaced in `_hint`, not hidden):** some shadow-DOM controls aren't in the UIA tree — if `find_control` reports `invokable:false`/not-found, the control genuinely needs a **foregrounded** `desktop_click`. So: prefer `desktop_ui_click` (background) → fall back to `desktop_click` (foreground) only when UIA can't reach it.
+
+#### Near-complete UIA surface (v1.9.0+) — enumerate, read, drive, with a foreground-hint contract
+
+v1.9.0 expands UIA from ~3 patterns to a near-complete agent-drivable client. **Every UIA verb now returns a `foreground` block** so you know — *before and after* — whether a call steals the user's screen:
+
+```
+"foreground": { "predicted": "background|fallback-possible|foreground",  // from the static class + the resolved path, BEFORE acting
+                "observed":  "background|foreground|n/a",                 // MEASURED (GetForegroundWindow before/after); reads = n/a
+                "raised":    true|false,                                  // did THIS call bring a window to front?
+                "pathTaken": "uia-pattern|sendinput-fallback|setfocus|read-only",
+                "reason":    "<short>" }
+```
+
+**Shared selector on every verb:** `{hwnd|window(titleContains)}` + `{name|contains|automationId|role}` + `scope:"window"(default)|"popups"|"subtree:<automationId>"` + `nth:<int>` (0-based — target the Nth match without the exact name). Prefer `automationId` (stable, locale-independent). Rects are screen px with `centerX/centerY`; `space:"image"+shotId` is accepted wherever a coordinate is taken.
+
+- **Enumeration (read-only, ungated):**
+  - `desktop_ui_tree {window|hwnd, maxDepth?, roleFilter?[], nameContains?, maxNodes?(500), scope?}` — **flagship.** Dump the a11y subtree; nodes carry rect + invokable/settable + value/toggleState/expandState/selected/rangeValue + `children[]`. Honors Chromium's lazy tree (wake-retry); `truncated:true` when it hits `maxNodes` (logged, never silent).
+  - `desktop_ui_from_point {x,y | space:"image",shotId,x,y}` — the control under a pixel.
+  - `desktop_find_controls {selector, maxResults?(25)}` — ALL matches as an array with `nth` indices (vs `find_control`'s single best).
+- **Reading (read-only, ungated):** `desktop_ui_text {selector, range?:"all"|"selection"|"visible"}` (TextPattern); `desktop_ui_get {selector}` (current value/toggle/range/selection/expand state); `desktop_ui_grid_get {selector, row?, col?}` (GridPattern cell + rowCount/columnCount).
+- **Actions (gated by shell-auto-approve; background-first via the pattern, SendInput-click fallback only when the pattern is absent → then `raised:true`):** `desktop_ui_toggle {selector, state?}`, `desktop_ui_expand {selector, action?:expand|collapse|toggle}`, `desktop_ui_select {selector, action?:select|add|remove}`, `desktop_ui_scroll {selector, dir?, amount?}`, `desktop_ui_set_range {selector, value}`, `desktop_ui_focus {selector}` (**bg:foreground** — usually raises; reports it), `desktop_ui_window {window, action:minimize|maximize|restore|close|move|resize}`.
+- **Events (v1.9.3 — REACT instead of poll; read-only, ungated):**
+  - `desktop_ui_watch {event, window?|hwnd?, properties?}` → `{watchId}`. `event` ∈ `focus` (desktop-wide "what got focus") | `window-opened`/`window-closed` (new/closed top-level windows — these three can be global) | `invoke` | `selection` | `text-changed` | `text-selection` | `menu-opened` | `tooltip-opened` | `notification` | `live-region` | `structure` | `property` (these REQUIRE a `window`, else `errorCode:window_required` — avoids a desktop firehose). Cap 16 watches.
+  - `desktop_ui_events {sinceSeq?, watchId?, max?}` → DRAIN the buffer (poll cursor; there is NO server-push over the relay). Returns `{events:[{seq, watchId, event, ts, element:{name,role,automationId,rect,hwnd}, eventType|property|changeType}], nextSeq, highestSeq, missedEvents, activeWatches}`. Poll with `sinceSeq=nextSeq`. Events ALSO appear in the AD GUI activity-log. The 500-deep ring is shared across all watches; `missedEvents:true` = you polled too slowly.
+  - `desktop_ui_unwatch {watchId|"all"}` → stop. **Canonical use:** waiting on an OTP/confirm dialog → `ui_watch {event:"window-opened"}`, poll `ui_events` until the window appears, then act on it (no busy screenshot loop).
+- **Introspection:** `desktop_ui_capabilities {}` — **read-only, ungated.** The foreground-hint TABLE per verb (`staticClass` + measured `{win32, chromium-pattern, chromium-fallback}`). **Consult it BEFORE a call to know if it'll steal the screen, without acting.** `desktop_ui_selftest {confirm?, window?}` writes/refreshes it (empirically measures when `confirm:true`).
+
+**Integrity boundary:** an elevated/admin window or the UAC secure desktop returns `errorCode:"uia_integrity_blocked"` (never a silent no-op) — a normal-integrity bridge cannot drive a higher-integrity process.
+
+#### Zero-math coordinate clicks — `coordMap` + image-space (v1.8.175+)
+
+You no longer recompute scale/offset/DPI to click what you see in a screenshot. Every screenshot verb returns a **`coordMap`**:
+
+```jsonc
+"coordMap": {
+  "shotId": "shot-w5050328-1",
+  "image": { "w": 1372, "h": 1031 },              // the DELIVERED (safe) PNG's pixels
+  "screenRect": { "x": 594, "y": 254, "w": 1372, "h": 1031 },  // physical px = input-verb space
+  "dpiScale": 1.5,
+  "imageToScreen": { "sx": 1.0, "sy": 1.0, "ox": 594, "oy": 254 }  // screen = img*s + o
+}
+```
+
+Two ways to use it:
+- **Let AD convert (preferred, zero math):** `desktop_click {space:"image", shotId, x, y}` where `x,y` are pixels in that screenshot. Also works on `desktop_double_click`/`right_click`/`hover`. The `shotId` comes from the capture's `coordMap.shotId` (last ~64 shots retained; re-capture if it expires).
+- **Compute yourself:** `screenX = x*sx + ox`, `screenY = y*sy + oy`.
+
+`screenRect` is in the SAME physical-px space as `desktop_list_windows` rects and the `desktop_click` default (`space:"screen"`).
+
+> **Driving a GUI app that pops error boxes / modal dialogs? Read the dedicated [`adom-desktop-window-capture`](WINDOW_CAPTURE.md) skill.** `desktop_screenshot_window {hwnd}` captures ONE window AND the child dialogs / error boxes it owns, even when the window is backgrounded/occluded/minimized (PrintWindow, no focus steal). It is the breakthrough for automating native apps whose dialogs a full-screen grab can't see. The rest of this section is the reference; that skill is the how-and-why.
+
+**Popups (owned):** As of **v1.8.177**, `desktop_screenshot_window` **also captures the window's OWNED popups** — borderless confirm bubbles (Chromium's "Remove '\<ext\>'?"), owned dropdowns, owned dialogs — that don't enumerate in `desktop_list_windows` and aren't visible in a PrintWindow of the main window. They ride back as a top-level **`screenshots[]`** array (the same generic shape `kicad_screenshot_all` uses; the CLI auto-pulls each entry's `fullPath`→`localPath` and `safePath`→`localSafePath`). `ownedPopupCount` is always present (0 = none). Each entry carries `hwnd`, `title`, `kind:"owned_popup"`, and its own `coordMap`+`shotId`, so you can **click the bubble's buttons** with `{space:"image", shotId, x, y}` or drive them in the background by name via `desktop_ui_click {scope:"popups"}`. This is the generic mechanism — there's no app-specific "Chrome bubble" verb; every window screenshot surfaces any owned popups so you get the data and decide. **Use `desktop_screenshot_screen`** (full virtual-screen grab) only for popups *not owned* by your target window, or to see everything at once.
+
+**Screen geometry on every shot (v1.8.178):** every `desktop_screenshot_window` / `_screen` response now carries:
+- **`windowRect: {x,y,w,h,monitor}`** — the window's position + size on the overall (possibly multi-monitor) desktop, in physical screen px. Each owned popup entry in `screenshots[]` gets its own **`rect: {x,y,w,h,monitor}`** too — so you know where every captured surface sits, not just its pixels.
+- **`display: {virtualScreen, primary{w,h,dpi,scale}, monitors[], monitorOfWindow}`** — the full monitor topology: total virtual bounds, and per-monitor `bounds`/`dpi`/`scale`/`primary`. `monitorOfWindow` indexes into `monitors[]`. (Same data as the standalone `desktop_display` verb, folded in.)
+- **`drive: "background"`** — screenshots never foreground the window (PrintWindow/WGC). The companion `_related` field lists sibling screenshot/window verbs.
+
+**Screenshot cache is self-cleaning + self-describing (v1.9.107/108/113):** the CLI auto-pulls every screenshot to ONE user-visible, namespaced dir on the caller's machine (`~/project/screenshots/adom-desktop/`, else `~/.adom/screenshots/`; `ADOM_SHOTS_DIR` overrides) — never `/tmp` anymore. Shots are **bucketed by kind into subfolders**: `window/` (a specific window / app / owned popup) and `screen/` (a full-desktop grab). **Human-readable filenames (v1.9.113):** `<YYYY-MM-DDThh-mm-ss.mmm>[-<hwnd>].png` — a sortable ISO stamp (lexicographic == chronological), plus the `hwnd` for a window capture; the `window`/`screen` kind is NOT repeated in the name because the folder already says it. **One file when small (v1.9.114):** if the shot was already within the safe size cap (the common case) it's a single `<stem>.png` and `localFullPath == localSafePath` — no redundant re-encoded copy. Only a shot large enough to be downscaled produces a `<stem>.full.png` + `<stem>.safe.png` pair (Read the `.safe` one for image analysis; use `.full` for archive). Each shot gets a **`<stem>.json` sidecar** (shotlog-style: `source`, `timestamp`, `kind`, dims + `bytes`, `coordMap`, `rect`; a full+safe pair shares one). **Parent/child grouping (v1.9.111/113):** when `desktop_screenshot_window` returns owned popups in `screenshots[]`, the parent AND each pulled child share the same stem, and each child gets a **`.popup-NN` label** — e.g. parent `2026-07-15T01-14-16.937-986710.safe.png` and children `2026-07-15T01-14-16.937-986710.popup-01.safe.png`, `...popup-02...`. So a plain `ls` shows the whole capture grouped (shared stem) and the role (`.popup-NN`) without opening a sidecar; the sidecar also carries `parent` + `popupIndex`. (`kicad_screenshot_all` returns sibling windows with no parent, so those keep their own names.) A janitor prunes it (7 days / 2 GB / newest kept; sidecars follow their PNG, orphans swept) and drains the old `/tmp/ad-shots` + `/tmp/adom-desktop-screenshots` backlogs, running opportunistically at most once/24h on any CLI call. **You do not need to clean it up or write your own cron.** Every screenshot response carries a `_cacheHint`; verify coverage with **`adom-desktop janitor`** (status: each subfolder, size, policy, last sweep) or force a sweep with **`adom-desktop janitor '{"sub":"run"}'`**. It never touches other tools' files under `~/project/screenshots/` (e.g. shotlog). Tune via `ADOM_SHOTS_MAX_AGE_DAYS` / `ADOM_SHOTS_MAX_GB` / `ADOM_SHOTS_MAX_FILES`, or disable with `ADOM_JANITOR=0`.
+
+**Background vs foreground driving (v1.8.178):** every desktop driving verb carries a structured **`drive`** field (`"background"` | `"foreground"` | `"n/a"`) — in `GET /commands`, in `adom-desktop help <verb>`, and echoed in the verb's own response. **Background** (no foreground / focus steal / cursor move — UIA + capture): `desktop_ui_click`, `desktop_ui_set`, `desktop_navigate`, `desktop_find_control`, `desktop_screenshot_window/_screen`, `desktop_record_window_start`. **Foreground** (SendInput → goes to the focused window, so AD foregrounds first): `desktop_click`/`_double_click`/`_right_click`/`_hover`, `desktop_type`, `desktop_press_key`, `desktop_bring_to_front`. Run **`adom-desktop help background`** for the full grouping. Rule: to act without disturbing the user's window, drive by NAME (`desktop_find_control` → `desktop_ui_click`/`ui_set`); fall back to coordinate clicks/keys only when the control isn't in the UIA tree.
+
+**Click a control by name → coords:** `desktop_find_control` returns `best.rect{x,y,w,h,centerX,centerY}` in screen px — `desktop_click {x:centerX, y:centerY}` to SendInput-click it (handy when `invokable`/`settable` are both false). Target by `name`, `contains`, `role`, or `automationId` (stable, locale-independent).
+
+**Keyboard chords:** `desktop_press_key` accepts a chord as one string `["ctrl+w"]` OR separate tokens `["ctrl","w"]` — both mean Ctrl held + W. A *sequence* of distinct chords uses `+` per element: `["ctrl+l","enter"]`. The reply echoes `chords` showing how AD interpreted your `keys`.
+
+- `desktop_flash_window` (v1.8.152+) -- Flash an **arbitrary** window's taskbar button orange (Win32 `FlashWindowEx`) as an "attention / ongoing activity" cue. Resolve the HWND with `desktop_list_windows` first. Args: `hwnd` (int) OR `titleContains` (string); `mode` (`"until_focused"` default — flashes until the user clicks the window; or `"count"`); `count` (int, `mode:"count"` only, default 3); `stop` (bool — clear an active flash). Returns `{ok, hwnd, title, mode}`. **Lightweight by design** (direct FlashWindowEx, no process spawn) so a bridge can call it after every verb. Distinct from the puppeteer bridge's `browser_alert_window`, which only flashes pup's OWN tracked windows — this flashes any window on the host. For progress + overlay-icon status, use `desktop_taskbar`.
+  - Example: `adom-desktop desktop_flash_window '{"hwnd":133560}'` then `adom-desktop desktop_flash_window '{"hwnd":133560,"stop":true}'` to clear.
+- `desktop_taskbar` (v1.8.152+) -- Paint **glanceable status** on any window's taskbar button via `ITaskbarList3` (progress bar + overlay icon) + `FlashWindowEx`, so a bridge can show what the AI is doing on the **real app window it drives** (e.g. native-browser putting an "Adom is driving" badge + task progress on the Chrome/Edge button). Works on windows owned by **other processes**. Persistent COM (init once, reuse) — safe to tick progress frequently; no process spawn. Resolve the HWND via `desktop_list_windows`. Args (all channels optional; provide ≥1):
+  - `hwnd` (int) OR `titleContains` (string)
+  - `flash`: `"until_focused"` | `"stop"`
+  - `progress`: `{state: "normal"|"paused"|"error"|"indeterminate"|"none", value?: 0..100}` — **state→color**: normal=green, **paused=yellow** (use for human-in-the-loop gates: captcha / 2FA / login), error=red, indeterminate=marquee, none=clear.
+  - `overlay`: `{badge: "adom"|"none", icon?: "<PNG or .ico — file path OR base64>", tooltip?: "<hover>", avatar?, profileDir?, userDataDir?, composite?}` — built-in badges are `adom` (the Adom logo — a "driven by Adom" trust tell) and `none` (clear); 3rd-party bridges pass their **own** `icon` (a **PNG** or `.ico`, file path or base64 — AD magic-sniffs + renders at 32px). AD stays generic: it paints whatever art you hand it. `tooltip` is the overlay's accessible description.
+    - **Compositing onto a Chrome/Edge profile avatar (v1.9.110, #154):** Windows gives ONE overlay slot per taskbar button, and Chrome/Edge use it for the **profile avatar** — so a bare `badge:"adom"` would EVICT the profile face (you'd lose "work vs personal"). To keep the avatar AND show "Adom is driving", pass the avatar and AD composites the mark onto it (bottom-right ~47%): `overlay:{badge:"adom", avatar:"<PNG path or base64>"}`, or name the profile with `overlay:{badge:"adom", profileDir:"Profile 1", userDataDir?:"<User Data root>"}` (AD reads `<UserData>/<profileDir>/Google Profile Picture.png`; `userDataDir` is derived from the window's exe if omitted). `composite:false` opts out (raw mark). If the avatar can't be resolved AD falls back to the plain mark — never errors. The CALLER (which knows its profile — e.g. the browser extension) supplies the avatar; AD does not reverse-engineer it (one browser process serves all profiles, so the process command line has no `--profile-directory`).
+  - `thumbnailTooltip` (v1.9.153): the taskbar **hover-preview tooltip** (`ITaskbarList3::SetThumbnailTooltip`). The ONLY uncapped text surface on the preview — the window title truncates ~25-30 chars, this string does not (and takes newlines). e.g. `"Adom Pup / Adom wiki / LOGGED-IN view / Chrome for Testing / 3 tabs"`. Pass `""` to clear it back to the title. Works on a window AD does not own.
+  - `thumbnailClip` (v1.9.153): crop the live hover preview to a client-space region — `{x,y,w,h}`, or `null` to reset to the whole window (`SetThumbnailClip`).
+  - Returns `{ok, hwnd, title, applied:[...]}`. Clears: `flash:"stop"`, `progress.state:"none"`, `overlay.badge:"none"`, `thumbnailTooltip:""`, `thumbnailClip:null` — call these on task-end / window-close to clean up.
+  - Example (long task, human gate, then done):
+    ```bash
+    adom-desktop desktop_taskbar '{"hwnd":133560,"overlay":{"badge":"adom","tooltip":"Adom is driving this browser"},"progress":{"state":"indeterminate"}}'
+    adom-desktop desktop_taskbar '{"hwnd":133560,"progress":{"state":"paused"},"flash":"until_focused"}'   # captcha — come look
+    adom-desktop desktop_taskbar '{"hwnd":133560,"progress":{"state":"none"},"overlay":{"badge":"none"},"flash":"stop"}'  # done — clear all
+    ```
+- `desktop_set_window_identity` + `desktop_register_app_identity` (v1.9.134+) -- **Full window-identity takeover.** Make a window you drive present itself as YOUR app: the taskbar button, Alt-Tab entry, hover tooltip and any user pin read your name and icon instead of the process that happens to own the HWND. Built for a bridge driving a borrowed host app (a Chrome for Testing window otherwise reads "Test" with Chrome's icon, which is two brands and a confusing word). AD is generic here — it applies whatever `appId` / `iconPath` / `displayName` you pass and knows nothing about which app that is.
+
+  **You need BOTH halves.** `set_window_identity` stamps a LIVE window (`PKEY_AppUserModel_ID` + `WM_SETICON`); `register_app_identity` teaches Windows what that `appId` MEANS (an HKCU `AppUserModelId` key + a Start Menu shortcut carrying the same AUMID). An unregistered appId falls back to the owning exe's icon, so half one alone leaves Alt-Tab wrong.
+
+    ```bash
+    # Once per install. Ship the .ico in your bridge's release zip; pass its cache path.
+    # `target`/`args` are what a user's PINNED tile launches — point it at a real verb so
+    # the pin opens a managed window instead of an orphan app.
+    adom-desktop desktop_register_app_identity '{"appId":"Adom.Pup","displayName":"Adom Pup",
+      "iconPath":"C:/.../adom-pup.ico",
+      "args":"browser_open_window {\"sessionId\":\"pinned\",\"owner\":\"pinned-launch\"}"}'
+
+    # Per window, e.g. at park time. cacheKey survives page-title churn.
+    adom-desktop desktop_set_window_identity '{"cacheKey":"<sessionId>",
+      "titleContains":"(session: <sessionId>)","appId":"Adom.Pup",
+      "displayName":"Adom Pup","iconPath":"C:/.../adom-pup.ico"}'
+    ```
+  - Window target: `hwnd` OR `titleContains` OR `cacheKey` (+ `fresh` to re-resolve). Response carries `resolvedBy` (`hwnd` | `cachedHwnd` | `title`) and `applied[]`.
+  - **Identity is per-HWND and dies with the window** — re-apply after a window is recreated.
+  - AD holds the icon handles for its whole process lifetime. Icon handles are process-owned: a transient setter leaves windows pointing at dead handles, so the icon reverts moments later. AD being long-lived is exactly why it owns them.
+  - `register_app_identity` writes HKCU only (never a UAC prompt). It is gated for RELAY callers like `write_file`; a bridge calling over the loopback API is trusted by transport and ungated.
+  - **Jump-list tasks (v1.9.139+): `desktop_set_window_jumplist {appId, tasks}`** puts YOUR actions on the taskbar right-click menu of every window stamped with that appId. Jump lists attach to the AUMID, so per-session appIds give each window its OWN menu. Each task: `{title (required), target? (exe, defaults to AD's bundled CLI — so `args` can simply be an AD/bridge verb + JSON), args?, iconPath?, description?}`. Tasks launch minimized (no console flash). Max 16; `tasks:[]` clears (call at session close, alongside `unregister_app_identity`). Re-set anytime to update the menu. Tier-2 gated for relay callers; loopback bridges ungated.
+  - **Per-session / transient appIds (v1.9.136+): register REGISTRY-ONLY with `shortcut:false`, unregister on close.** If you stamp per-session AUMIDs (e.g. `Adom.Pup.<sessionId>` so each window keeps its own taskbar button in split mode), an UNREGISTERED AUMID makes Alt-Tab fall back to the owning exe's icon. `desktop_register_app_identity {appId, displayName, iconPath, shortcut:false}` writes just the HKCU AppUserModelId key (DisplayName + IconUri) — Alt-Tab and taskbar hover resolve the name + icon with NO Start Menu entry. Pair with `desktop_unregister_app_identity {appId}` at session close (idempotent; removes the key plus any shortcut carrying that AUMID; AD's own `inc.adom.desktop` identity is protected). Keep ONE shortcut-mode registration as the pinnable launcher.
+  - **`desktop_set_window_jumplist`** (v1.9.139+) adds the right-click menu on the taskbar button (per-AUMID; `tasks:[]` clears) and, with `hwnd`+`headerIcon`, brands the header row (the app tile above Pin/Close).
+  - **Icon format — read the `iconCheck` these verbs return (v1.9.152).** `desktop_set_window_identity` / `desktop_register_app_identity` / `desktop_set_window_jumplist` return an `iconCheck {kind,sizes,issues,ok,hint}` (or `headerIconCheck`) that magic-sniffs the file and explains why an icon would render as a generic white document: a **256px `.ico` size not PNG-compressed** (the Vista+ trap), **SVG** (the Win32 shell can't load it), a `.ico` with no small size, an `.exe` where an image is wanted. If `iconCheck.ok` is false, fix the file and re-stamp. Ship a multi-size `.ico` (16/24/32/48/256, the 256 PNG-compressed). **Full guide:** the `adom-desktop-window-identity` skill (AUMID model, the register+stamp two-halves rule, jump-list header, taskbar overlay + thumbnail, the live-tile trap, a recipe).
+- `desktop_revoke_approvals` -- Revoke all shell auto-approve permissions and deny pending approvals. The next shell command will show the approval dialog.
+  - Example: `adom-desktop desktop_revoke_approvals`
+- `desktop_caption` -- Show a global caption overlay that stays visible above ALL windows (KiCad, Fusion, Chrome). Native Win32 overlay — not a browser DOM element. Click-through: mouse events pass to windows below. Captured by screen recordings (browser `getDisplayMedia`, OBS, Game Bar).
+  - Show: `adom-desktop desktop_caption '{"text":"Step 1: Opening the board","position":"top","size":"large","duration":3000}'`
+  - Custom position: `adom-desktop desktop_caption '{"text":"Look here","x":0.3,"y":0.2,"size":"large","persist":true}'`
+  - Hide: `adom-desktop desktop_caption '{"action":"hide"}'`
+  - List what's on screen (v1.9.217): `adom-desktop desktop_caption '{"action":"list"}'` — returns `{captions:[{id,ageMs,expiresInMs,persist,overdue}]}`; the authoritative check (caption overlays do NOT appear in `desktop_list_windows`). TTLs are fail-safe: a 15s reaper closes any caption that outlives its deadline. Do not keep a caption alive with a same-id refresh loop as a nag — the user experiences one endless caption.
+  - Multiple simultaneous captions (use `id` to keep them independent):
+    ```
+    adom-desktop desktop_caption '{"text":"Step 1: Opening board","id":"step","position":"center","size":"large","persist":true}'
+    adom-desktop desktop_caption '{"text":"REC ●","id":"status","position":"bottom-left","size":"medium","persist":true}'
+    ```
+  - Replace only the step caption (status stays):
+    ```
+    adom-desktop desktop_caption '{"text":"Step 2: Routing","id":"step","position":"center","size":"large","persist":true}'
+    ```
+  - Hide just the step caption: `adom-desktop desktop_caption '{"action":"hide","id":"step"}'`
+  - Args:
+    - `text` (string) — caption text
+    - `id` (string, optional) — identifies this caption. Captions with the same id replace each other; different ids coexist simultaneously. Default `"_default"` — so bare calls without id still replace each other for backward compat.
+    - `position` — preset: `"top"`, `"bottom"` (default), `"center"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`
+    - `x` (float 0.0–1.0) — normalized screen X, overrides position horizontal. 0.0 = left edge, 1.0 = right edge. Centers the caption box on this point.
+    - `y` (float 0.0–1.0) — normalized screen Y, overrides position vertical. 0.0 = top edge, 1.0 = bottom edge.
+    - `size` — preset: `"large"` (72px), `"medium"` (32px, default), `"small"` (20px)
+    - `fontSize` (int) — custom font size in px (8–400). Overrides `size` preset when provided.
+    - `duration` (ms) — auto-dismiss timer. **Omitted or `0` → `30000` (30s); clamped to max `600000` (10 min).** Captions ALWAYS auto-expire (v1.8.138+) so a forgotten one can't be stranded on the user's desktop. To keep one up longer, **re-issue the same `id`** (resets the timer) or pass a bigger `duration`.
+    - `persist` (bool, optional) — `true` = sticky, NO auto-dismiss. Supervised use only (e.g. demo recordings); you MUST then dismiss it yourself with `action:"hide"`. This is the only way to a never-expiring caption (`duration:0` no longer does that).
+    - `action` — `"hide"` to dismiss a caption (with `id`: hides only that caption; without `id`: hides all captions), `"force-clear"` to EnumWindows and destroy ALL caption windows regardless of id (nuclear option — use at top of demo scripts for guaranteed clean slate)
+  - Captions with the same `id` replace each other instantly (previous destroyed, no fade overlap). Captions with different ids coexist — multiple captions can be visible simultaneously.
+
+### Desktop Screenshots
+
+Take screenshots of the user's desktop or individual windows. All screenshots use lossless PNG.
+
+**Always save screenshots** to `project-content/screenshots/`.
+
+**Naming convention:** `desktop-<descriptive-name>-YYYY-MM-DD-HhMMam/pm.png`
+
+**Workflow:** Call `desktop_list_windows` first to get HWNDs, then `desktop_screenshot_window` with the HWND.
+
+- `desktop_list_windows` -- List all visible windows (returns HWND, title, class name, position/size, and `z` = Z-order index, 0 = topmost)
+- `desktop_find_window` (v1.8.153+) -- Resolve a window's HWND by `titleContains` and/or `className` (case-insensitive substrings), returned in Z-order with `best` = the topmost match. Use this instead of diffing `desktop_list_windows` before/after launching something — it's race-free for "the window I just opened." Returns `{ok, count, best:{hwnd,title,className,z,rect}|null, windows[]}` (`ok` true only when ≥1 matched). Pass `best.hwnd` to `desktop_taskbar` / `desktop_flash_window` / `desktop_screenshot_window`.
+  - Example: `adom-desktop desktop_find_window '{"className":"Chrome_WidgetWin_1","titleContains":"GitHub"}'`
+- `desktop_activity_filter` (v1.8.155+) -- Scope the **user's GUI activity log** so they watch the slice you care about while you work (e.g. focus it on one bridge before a debugging session). Args: `bridge` (a bridge registry name from `bridge_list` | `"none"` = only global/non-bridge AD commands | `"all"`/omit = everything), `text` (free-text substring, ANDs with bridge), `clear` (bool — reset). **Drives the human's GUI view only — it does NOT change which verbs you can call or what they return.** The GUI shows a status bar stating exactly what's shown + what's hidden.
+  - Example: `adom-desktop desktop_activity_filter '{"bridge":"kicad"}'` then `adom-desktop desktop_activity_filter '{"clear":true}'`
+- `desktop_screenshot_window` -- Capture a specific window by HWND
+- `desktop_screenshot_screen` -- Capture the entire desktop (all monitors)
+- `desktop_ad_scroll` (v1.9.86+) -- **Scroll AD's OWN window panels.** AD is a fully AI-controllable app, so you can drive its own scrollbars — most usefully to page the **bridges sidebar** and screenshot cards that are below the fold. Args: `panel` (`bridges` default | `activity` | `connections`), `to` (`top`|`bottom`, absolute — wins over `dy`), `dy` (relative px, default 400; negative = up). Returns `{ok, panel, scrolled, state:{scrollTop,scrollHeight,clientHeight,atTop,atBottom,found}|null, _hint}` — round-tripped from the live window so you can **loop until `state.atBottom==true`**. A collapsed bridges sidebar is auto-expanded first; `state==null` means the panel didn't report (collapsed/empty).
+  - Capture-all-cards workflow: `desktop_ad_scroll '{"panel":"bridges","to":"top"}'` → `desktop_screenshot_window` (AD hwnd) → `desktop_ad_scroll '{"panel":"bridges"}'` → screenshot → repeat until `atBottom`.
+
+### App self-update (relay-driven, v1.9.87+)
+
+AD auto-updates itself from the wiki manifest, but historically only a HUMAN clicking the footer "Install now" banner could apply it — so a headless / RDP / fleet box (a fresh Azure VM) could sit a version behind with no path forward from the relay. These verbs give the AI that path (same shape as the shell-approval surfacing verbs). Use `--target <box>`.
+
+- `update_status` (read-only, ungated) -- `{currentVersion, latestVersion, updateAvailable, updateReady, state, busy, updateMode}`. `updateReady:true` = the newer version is downloaded + SHA-verified on disk, appliable with no network.
+- `update_check` -- force a manifest check now; if newer, download + verify (stage) it in the background. Poll `update_status` until `updateReady:true`.
+- `apply_update` -- the programmatic "Install now": apply the staged update + relaunch AD. Respects `busy` (refuses if a command is in flight). Only ever applies AD's own downloaded + SHA-verified SIGNED wiki release. Returns `{applying:true}` then AD restarts (the relay drops + reconnects; poll `targets`/`ping` for the new version). If not staged yet, it kicks staging and tells you to poll + retry.
+- `request_update_approval` -- surface the "Update available — Install now / Later" banner on the target + bring AD to the front (for an ATTENDED box where the human should decide). Does NOT apply; `apply_update` installs.
+- `update_set_mode {mode: auto|prompt|off}` -- the auto-apply POLICY. `auto` = unattended stage+apply on the 4h/on-launch poll (set this ONCE on headless fleet boxes and they self-update every release with no relay call); `prompt` = show the banner (default, attended); `off` = no checks.
+  - Fleet flow: `update_status --target <box>` → if `updateAvailable` and unattended, `apply_update --target <box>` (or set `update_set_mode {"mode":"auto"}` once). A box must already be on ≥1.9.87 to have these verbs; the one-time bootstrap of an older box still needs a human click OR `auto` mode.
+
+### HD companion mode (v1.9.218+)
+
+When a HUMAN opens AD on a machine where Hydrogen Desktop is detected (embedded marker, HD's ports.json, or a discoverable HD install), AD shows an identity overlay ("This is Adom Desktop" + a large Open Hydrogen Desktop button), instantly opens or foregrounds HD, and places its own window directly below HD's in z-order — most people who open AD by hand were looking for HD. Facts an AI should know:
+
+- Human-open detection = bare argv AND parent process `explorer.exe` (double-click, Start menu, search, pins). Machine launches — `--autostart` boot, HD `--embedded` respawns, the relauncher/`restart` verb, CLI-style invocations — NEVER trigger it, so a remote `restart` cannot bounce the user's screen.
+- One-shot placement: AD does not fight the user's later alt-tabbing, and it is NOT a real Win32 child window (AD's lifecycle stays independent — the recovery-channel contract holds; if HD cannot be launched the overlay says so and AD stays fully usable).
+- Toggle: Settings > Hydrogen Desktop > "Act as a companion to Hydrogen Desktop", or `settings_set {"settings":{"hd_child_mode":false}}`. Default ON (self-gates on HD detection, so standalone installs never see it).
+- Forensics: every decision slogs `HD-COMPANION:` lines to startup.log.
+
+### User settings over the API (`settings_get` / `settings_set`, v1.9.214+)
+
+AD's Settings-panel preferences are readable and settable over the API. This is the sanctioned channel for an embedding app (Hydrogen Desktop) to render AD's settings in its own panel and forward the user's changes; it also lets a fleet operator read or fix a box's boot/update prefs remotely.
+
+```bash
+adom-desktop settings_get
+adom-desktop settings_set '{"settings": {"launch_on_boot": true, "update_mode": "auto"}}'
+```
+
+- Settable keys (whitelist): `launch_on_boot`, `always_show_window_on_launch`, `update_mode` (auto|prompt|off), `auto_restart_for_updates`, `update_idle_minutes` (1..1440), `prewarm_runtimes`, `prewarm_cft` (auto|always|never), `file_receive_directory`. Anything else lands in `rejected` — servers, shell approval (grants are human-only), and blocked peers have their own surfaces and are deliberately not settable here.
+- `launch_on_boot` changes apply the OS Startup artifact immediately; `settings_get` also returns `autostartArtifactPresent` so you can see intent-vs-artifact drift.
+- `settings_set` is Tier-2 gated for relay (cloud-AI) callers — carry a `reason` and expect the human-approval flow, like `shell_execute`. Local/loopback callers (HD, bridges, the local CLI) are trusted by transport.
+- Self-heal: AD re-verifies the launch-on-boot artifact every ~60s while running (not just at launch), so anything that deletes the Startup shortcut behind AD's back gets healed within a minute — with a `RECONCILE:` forensic line in `startup.log`.
+
+### Browser automation (`browser_*`, Puppeteer/pup) — bridge-owned; light pointer
+
+The pup bridge is **cloud-owned** and ships its OWN full skill (its wiki page / skill pkg — `bridge_list` names it; `adom-desktop sync_skills` installs it). This page keeps only the pointer plus first-touch examples; sessions/profiles, credential vault, downloads, recording, CDP details are the bridge's skill.
+
+Most-used verbs:
+
+```bash
+adom-desktop browser_open_window '{"sessionId": "s1", "profile": "p1", "url": "https://example.com"}'
+adom-desktop browser_navigate '{"sessionId": "s1", "url": "https://example.com/page2"}'
+adom-desktop browser_screenshot '{"sessionId": "s1"}'
+adom-desktop browser_eval '{"sessionId": "s1", "expression": "document.title"}'
+adom-desktop browser_fetch_url '{"url": "https://example.com/data.json"}'
+```
+
+`browser_readiness` is the read-only probe (never spawns or downloads). Full details: the bridge's own skill via `sync_skills`.
+
+### Screen recording — AD's desktop recorder (tab recording is pup's)
+
+AD owns DESKTOP recording: a visible HUD opens with the REASON you supply, so the user always sees that recording is on and why. Full guide: the `adom-desktop-recording` skill.
+
+```bash
+adom-desktop desktop_recorder_open '{"reason": "record the DRC walkthrough for review"}'
+adom-desktop desktop_record_start '{"confirmDesktopNotTabRecording": true}'
+# ... drive the desktop ...
+adom-desktop desktop_record_stop '{}'
+adom-desktop desktop_record_list '{}'
+adom-desktop desktop_recorder_close '{}'
+```
+
+`desktop_record_start` without `confirmDesktopNotTabRecording: true` refuses with `errorCode: desktop_record_needs_confirmation` — that guard exists because recording a BROWSER TAB is the pup bridge's separate feature (`browser_record_start` etc., see pup's own skill).
+
+### Filesystem primitives — `desktop_list_files` / `desktop_watch_files` / `desktop_pull_glob` (v1.5.0+)
+
+**These are the canonical "wait for a download" primitives.** They replace the `shell_execute` + PowerShell-poll-Downloads pattern that earlier callers (chip-fetcher, similar tools) had to use. No shell, no user approval prompt, no parsing of `dir` output. They run pure Rust on the desktop side, so they're fast, predictable, and never trip over PowerShell quoting.
+
+The pattern they replace looks like this:
+
+```powershell
+# OLD — DON'T do this for new code:
+$before = (Get-Date).ToFileTime()
+# … click download …
+while (-not (Get-ChildItem ~/Downloads -Filter ul_*.zip | Where { $_.LastWriteTime.ToFileTime() -gt $before })) {
+  Start-Sleep -Seconds 1
+}
+$file = Get-ChildItem ~/Downloads -Filter ul_*.zip | Sort LastWriteTime -Desc | Select -First 1
+```
+
+becomes one call:
+
+```bash
+# NEW — single primitive, no shell:
+adom-desktop desktop_watch_files '{
+  "path": "%USERPROFILE%\\Downloads",
+  "glob": "ul_*.zip",
+  "timeoutMs": 60000
+}'
+# → {ok:true, file:{path,name,size,mtime}, elapsedMs} on success
+# → {ok:false, error:"timeout", elapsedMs, _hint, ...} on timeout
+```
+
+#### `desktop_list_files` — one-shot directory listing
+
+Lists files in a directory (non-recursive) matching a shell-glob, optionally filtered to those modified after a given timestamp.
+
+- Args: `path` (Windows abs path; `~` / `%USERPROFILE%` / `%APPDATA%` / `%LOCALAPPDATA%` / `%TEMP%` are expanded), `glob` (default `*`; shell-style with `*` and `?`; case-insensitive on Windows), `modifiedSince` (optional unix-seconds OR ISO-8601 string).
+- Returns: `{ok, path, glob, files: [{path, name, size, mtime}, ...] sorted newest-first, count}`.
+- Does NOT recurse. Lists one directory only.
+
+#### `desktop_watch_files` — block until a match arrives
+
+Polls the directory every `pollMs` (default 1000) until at least one file matches the glob with `mtime > since`, or `timeoutMs` (default 60000, max 600000 = 10 min) elapses.
+
+- Args: same as `desktop_list_files` plus `since` (defaults to **now** — without an explicit value, only NEW files report), `timeoutMs`, `pollMs`.
+- Returns on match: `{ok:true, file:{path,name,size,mtime}, elapsedMs}`.
+- Returns on timeout: `{ok:false, error:"timeout", elapsedMs, _hint, path, glob}`.
+- The `since`-defaults-to-now behavior matches the canonical "click then watch" flow. If you might miss the file by starting the watch slightly late (e.g. fast small downloads), record `$(date +%s)` BEFORE the click and pass it as `since`.
+
+#### `desktop_pull_glob` — one-shot orchestrator (recommended for download flows)
+
+Composes `desktop_list_files` (or `desktop_watch_files` if `wait:true`) with the existing streaming `pull_file` mechanism. The whole "wait for a download then pull it" flow in one call:
+
+```bash
+BEFORE=$(date +%s)
+# … trigger the download click via browser_input_dispatch …
+adom-desktop desktop_pull_glob "{
+  \"path\": \"%USERPROFILE%\\\\Downloads\",
+  \"glob\": \"ul_*.zip\",
+  \"since\": $BEFORE,
+  \"wait\": true,
+  \"timeoutMs\": 60000,
+  \"saveTo\": \"/tmp/cse-out\"
+}"
+# → {ok:true, files:[{name,path,size,sha256,chunks}, ...], errors:[], matchedCount}
+```
+
+- When `wait:true`: blocks via `desktop_watch_files` until at least one match appears, then re-lists the directory and pulls **everything matching** (so a `.crdownload` + final `.zip` written in the same poll tick both come along).
+- When `wait:false` (default): just lists what's there now and pulls those.
+- Pulls use the existing `pull_file` streaming pipeline — sha256-verified, ~1MB chunks, resumes the same way `pull_file` does.
+
+#### Glob semantics
+
+- `*` matches any run of non-separator chars (within a single filename — these primitives don't recurse).
+- `?` matches a single non-separator char.
+- Case-insensitive on Windows (matches the filesystem); case-sensitive on macOS/Linux.
+- Examples: `ul_*.zip`, `LIB_*.zip`, `*.pdf`, `datasheet_*.pdf`, `*.step`, `*.STEP` (same on Windows).
+
+#### Path expansion
+
+- `~` and `~/foo` → home dir (Linux/macOS convention; works on Windows too).
+- `%USERPROFILE%` → home dir (Windows; also works cross-platform).
+- `%APPDATA%` → Windows roaming app data (`dirs::config_dir()` on others).
+- `%LOCALAPPDATA%` → Windows local app data (`dirs::data_local_dir()` on others).
+- `%TEMP%` → Windows TEMP env var; `/tmp` on Linux/macOS.
+- Expansion is case-insensitive: `%userprofile%` works the same as `%USERPROFILE%`.
+- Forward slashes (`/`) and back slashes (`\`) both work in the path string.
+
+#### Time format for `since` / `modifiedSince`
+
+- Number: unix seconds (e.g. `1746483600`). Floats accepted.
+- String of digits: same (e.g. `"1746483600"`).
+- ISO-8601 / RFC-3339 string: e.g. `"2026-05-04T22:30:00Z"` or with offset `"2026-05-04T15:30:00-07:00"`.
+- Files with `mtime <= since` are filtered out (strict `>`).
+
+### Hydrogen Desktop (`hd_*`) — proxy + build/lifecycle for the sibling Tauri app
+
+When the user has **Hydrogen Desktop** running (a sibling Tauri v2 app at `C:\Github\hydrogen-desktop`), built-in `hd_*` verbs reach into it. NOT a separate bridge process — adom-desktop proxies HTTP calls directly to HD's control API.
+
+**Port discovery (v1.8.72+):** HD's control API lives on **port `47084`** (default; the actual port is read from `%APPDATA%\hydrogen-desktop\ports.json` key `control` on every `hd_api` call, fallback 47084). Pre-v1.8.72 the verbs were hardcoded to HD's legacy `:9001` webview-eval port, which HD doesn't bind anymore — that's why `hd_status` reporting "HD not running" while HD's window is visible is the upgrade signal.
+
+#### The canonical generic verb — `hd_api` (v1.8.72+)
+
+```bash
+adom-desktop hd_api '{"method":"GET","path":"/health"}'
+adom-desktop hd_api '{"method":"POST","path":"/setup/run-step","body":{"id":"welcome"}}'
+adom-desktop hd_api '{"method":"GET","path":"/wsl/status"}'
+```
+
+Generic HTTP pass-through to any HD endpoint. Raw JSON in `body` — no curl-through-the-relay quote mangling. Returns `{success, status, body, port, method, path}` mirroring HD's response. If HD isn't running, returns `{ok:false, error:"HD not reachable on :47084", _hint:"..."}` instead of timing out.
+
+**Public denylist** — these paths are refused with `{ok:false, error:"not_allowed_in_public", denylist_match:"..."}` on public AD builds (use the internal `hd-dev` cargo feature to bypass):
+
+```
+/wsl/unregister, /setup/virgin-reset, /container/destroy,
+/container/reset, /workspace/reset, /workspace/destroy
+```
+
+#### Inspect + drive a running HD (v1.8.15+, post-v1.8.72 rework)
+
+| Verb | What | Status |
+|---|---|---|
+| `hd_status` | Composes AD-side facts (running, repo SHA) + HD's `/workspace/health` + `/buildinfo` relayed | ✓ shipped |
+| `hd_api` | Generic HTTP pass-through (above) | ✓ v1.8.72+ |
+| `hd_log '{"tail":30}'` | Tail `%APPDATA%\hydrogen-desktop\hydrogen-desktop.log` (reads disk — works EVEN when HD is down) | ✓ shipped |
+| `hd_open_url '{"url":"...","browser":"chrome","profileDir":"Default"}'` | POST `/open-in-profile` — open URL in a specific browser profile | ✓ shipped |
+| `hd_browser_profiles` | GET `/browser-profiles` — enumerate browsers + profiles | ✓ shipped |
+| `hd_screenshot` / `hd_shot` | Find HD window, capture lossless PNG via PrintWindow; `hd_shot` adds region-scoped variant | ✓ shipped |
+| `hd_eval '{"js":"..."}'` | POST `/eval` — run JS in HD's main webview | ⚠ **Moved to `hd-dev` in v1.8.72.** Public returns `verb_moved_to_hd_dev`. Use `hd_api '{"method":"POST","path":"/eval","body":{"js":"..."}}'` |
+| `hd_iframe_eval '{"js":"...","contextIndex":0}'` | POST `/iframe-eval` — JS in code-server iframe via CDP | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |
+| `hd_reload_vscode` | POST `/reload-vscode` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |
+| `hd_container_exec '{"command":"..."}'` | POST `/container-exec` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |
+| `hd_launch` / `hd_stop` / `hd_restart` | HD process lifecycle | ✓ shipped |
+| `hd_ship` | Atomic build+relaunch+verify-SHA (see below) | ✓ shipped |
+| `hd_build*` (v1.8.16+) | Build pipeline orchestration | ⚠ Moved to `hd-dev` in v1.8.72 (HD developers only) |
+
+#### Atomic ship — `hd_ship` (v1.8.49+) — START HERE for post-push rebuilds
+
+**One verb, one call, one structured pass/fail.** Replaces the 4-step chain `hd_stop` → `hd_build_rust` → poll `hd_build_status` → `hd_launch` → `curl :47084/buildinfo`. Eliminates the "is the new code actually running?" guesswork after every push.
+
+Sequence inside one call:
+1. taskkill HD + poll until zero instances (frees the exe lock)
+2. `git fetch && git reset --hard origin/main && rev-parse HEAD` — asserts `HEAD == expectSha` (fails fast if GitHub hasn't replicated the push yet)
+3. if `clean:true`, `cargo clean -p hydrogen-desktop` (defeats stale-relink)
+4. `cargo build` and parse stderr for `Compiling hydrogen-desktop` to know whether anything actually compiled
+5. launch + poll `http://127.0.0.1:47084/buildinfo` until it responds + assert `build_sha == expectSha`
+
+```bash
+# End-to-end after a push:
+git push && SHA=$(git rev-parse HEAD) && \
+  adom-desktop hd_ship "{\"expectSha\":\"$SHA\"}"
+```
+
+Args:
+- `expectSha` — **REQUIRED** string. Full or prefix SHA of the commit you want running. Bidirectional-prefix match (`abc123` matches the full SHA starting with `abc123` and vice versa).
+- `clean` — optional bool, default `false`. `true` forces `cargo clean -p hydrogen-desktop` before build. Use when a prior `hd_ship` succeeded but `runningSha != expectSha` (the stale-relink trap — cargo said `Finished` but reused the cached binary).
+
+Returns on success: `{ok:true, builtSha (== expectSha), runningSha (== expectSha), compiled:bool, elapsedSecs, _hint}`. `compiled:false` on success means cargo found nothing to recompile (cached relink or HEAD didn't actually change — usually fine but surface to caller in case the push was a no-op).
+
+Returns on failure: `{ok:false, failedAt:'args'|'kill'|'git'|'clean'|'build'|'launch'|'verify', error, builtSha (set after git step), runningSha (set if launch worked but verify saw the wrong SHA), compiled (set after build step), elapsedSecs, _hint}`. The partials let you reason about WHERE it broke:
+
+| `failedAt` | What it means |
+|---|---|
+| `args` | `expectSha` missing / malformed |
+| `kill` | Couldn't taskkill HD (rare — usually permissions) |
+| `git` | `git fetch / reset` failed, OR `HEAD != expectSha` after reset (push hasn't replicated to origin/main yet — retry in a few seconds) |
+| `clean` | `cargo clean` failed (rare) |
+| `build` | `cargo build` failed — read `_hint` for next step (usually `hd_build_log` to see the compile error) |
+| `launch` | Cargo finished but `hydrogen-desktop.exe` wouldn't start — `binary_missing` or process spawn error |
+| `verify` | HD launched but `/buildinfo` either never responded or reported a different SHA than `expectSha` (the stale-relink trap — retry with `clean:true`) |
+
+Long-running: cold cargo build can take 2-3 min. CLI WS timeout is 12 min — call as a single sync invocation and wait. Don't poll inside `hd_ship`; it does its own internal polling.
+
+#### Lower-level build + lifecycle suite (v1.8.16+, enriched in v1.8.52+)
+
+Use these directly only when you need finer control than `hd_ship` provides (e.g. frontend-only build, watching log stream live, manual launch sequencing). For the common "I pushed code, get it running" path use `hd_ship`.
+
+The relay's `shell_execute` has a 30s timeout; HD's `pnpm build` + `cargo build` exceed that, so the build is split into async-spawn verbs + sync poll/tail verbs:
+
+```bash
+# Frontend-only rebuild, watch progress, relaunch (no hd_ship since hd_ship is Rust-build-only)
+adom-desktop hd_stop
+adom-desktop hd_build_frontend '{"show":false}'      # returns {ok, pid, logPath} immediately
+OFFSET=0
+while true; do
+  RESP=$(adom-desktop hd_build_tail "{\"offset\":$OFFSET}")
+  echo "$RESP" | jq -r '.lines[]'
+  DONE=$(echo "$RESP" | jq -r '.done')
+  OFFSET=$(echo "$RESP" | jq -r '.newOffset')
+  [[ "$DONE" == "true" ]] && break
+  sleep 3
+done
+adom-desktop hd_build_status      # confirm succeeded:true && relinkOnly:false
+adom-desktop hd_launch            # start the new debug binary
+```
+
+##### `hd_build` / `hd_build_frontend` / `hd_build_rust` — async-spawn build verbs
+
+| Verb | What |
+|---|---|
+| `hd_build '{"show":false}'` | Full async build: `git pull` + `pnpm build` + `cargo build`. Returns instantly with `{pid, logPath, flavor:'full'}`. Final log line is `BUILD_OK` or `BUILD_FAILED: <step>`. |
+| `hd_build_frontend` | Just `pnpm build` (root). Returns `{pid, logPath, flavor:'frontend'}`. |
+| `hd_build_rust` | Just `cargo build` (src-tauri/). **v1.8.52+:** dispatch response also carries `builtSha` + `exeMtimeBefore` + `exeSizeBefore` (captured at spawn time into `%TEMP%\hd-build-meta.json`) so the subsequent `hd_build_status` / `hd_launch` don't need a separate `git rev-parse` + `stat` call. Returns `{ok, pid, logPath, flavor:'rust', show, lingerSecs, builtSha, exeMtimeBefore, exeSizeBefore, _hint}`. |
+
+##### `hd_build_status` — sync state probe (v1.8.52+ enriched)
+
+Returns:
+```
+{
+  ok, building, succeeded, failed,
+  lastLines:[string],                  # last ~40 lines of stderr
+  logPath, pid, pidAlive,
+  failureReason,                       # 'compile_error' | 'exe_locked' | 'orphaned_no_sentinel'
+  compiledCrates:[string],             # names parsed from 'Compiling X v...' lines
+  relinkOnly:bool,                     # true iff succeeded && compiledCrates is empty
+  exeChanged:bool,                     # mtime OR size after differs from before
+  exeMtimeBefore:int, exeMtimeAfter:int,   # unix seconds
+  exeSizeBefore:int,  exeSizeAfter:int,    # bytes
+  exeLocked:bool,                      # true when build failed because hydrogen-desktop.exe was held
+  holderPids:[int],                    # PIDs holding the exe — taskkill these
+  builtSha:string|null,                # SHA hd_build_rust/hd_build was dispatched at
+  elapsedSecs:int,
+  flavor:string,                       # 'rust' | 'frontend' | 'full'
+  _hint
+}
+```
+
+**`relinkOnly:true` on success is the silent-fail trap to look for** — your push didn't actually rebuild because cargo found nothing to recompile. Usually means your source changes weren't pulled, or you touched a file the workspace doesn't track. Use `hd_ship` with `clean:true` to force a fresh build.
+
+**`exeLocked:true` on failure** + `holderPids` tells you exactly which PIDs to taskkill before retry; or just use `hd_ship` which kills HD before building.
+
+##### `hd_launch` — start the debug binary (v1.8.52+ enriched)
+
+Args: `killExisting` — optional bool, default `false`. When `false` and HD is already running, refuses with `reason:'already_running'` so you don't accidentally attach to a stale process. When `true`, taskkill the existing instance with a 600 ms grace period before launching fresh.
+
+Returns on launch: `{ok:true, launched:true, wasAlreadyRunning:bool, killedFirst:bool, pid:int (HD's actual PID, not the cmd-wrapper), exePath, builtSha:string|null, _hint}`.
+
+Returns on refusal: `{ok:false, launched:false, wasAlreadyRunning:bool, pid:int|null, killedFirst:false, reason, _hint}`. `reason` ∈:
+- `build_in_progress` — wait for `hd_build_status.building:false`
+- `build_failed` — fix and retry `hd_build` (or use `hd_ship`)
+- `already_running` — pass `killExisting:true` to force-kill+relaunch, or use `hd_restart`
+- `binary_missing` — `target/debug/hydrogen-desktop.exe` not on disk; run `hd_build_rust` (or `hd_ship`) first
+
+**`launched:true, wasAlreadyRunning:false, killedFirst:false` is the proof you need that truly fresh code is running.** Any other combo means you should compare `builtSha` to HD's actual `/buildinfo.build_sha`.
+
+##### `hd_stop` / `hd_restart` / `hd_build_log` / `hd_build_tail`
+
+| Verb | What |
+|---|---|
+| `hd_stop` | `taskkill /F /IM hydrogen-desktop.exe`. `{wasRunning}` distinguishes killed vs no-op. |
+| `hd_restart` | Stop + launch in one call. Same guards as `hd_launch` (skip `already_running`). |
+| `hd_build_log` | Full log dump from `%TEMP%\hd-build.log`. |
+| `hd_build_tail '{"offset":N}'` | Incremental stream — `{lines, newOffset, totalBytes, done, succeeded, _hint}`. Pass `newOffset` back for the next chunk. |
+
+The `show: true` arg on any build verb opens a visible PowerShell console window so the user can watch the build scroll. Default `show: false` runs hidden.
+
+**Auto-close (v1.8.23+).** Visible build windows auto-close 30 seconds after the final `BUILD_OK` / `BUILD_FAILED` line — long enough for the user to read the last error, short enough that build-after-build sessions don't accumulate orphan windows. The window prints a clear green/red "auto-closes in 30 seconds" banner before sleeping. Override with `lingerSecs`:
+- `'{"show":true, "lingerSecs":60}'` — give yourself 60s instead of 30
+- `'{"show":true, "lingerSecs":0}'` — close immediately, no grace period
+- `'{"show":true, "lingerSecs":3600}'` — keep open for an hour (cap)
+The response includes the applied `lingerSecs` so you can confirm what landed. Hidden builds (`show:false`) ignore `lingerSecs` entirely — no window to linger.
+
+**Don't call `hd_launch` until `hd_build_status` shows `succeeded: true`** — the guard refuses with `reason: "build_failed"` if you do, and the `_hint` tells the AI exactly what to do (`hd_build_log` to see errors, fix, rebuild). Same for `hd_restart`.
+
+#### Named-region screenshots — `hd_shot` (v1.8.54+)
+
+**One verb replaces the `desktop_list_windows | grep 'Hydrogen' → desktop_screenshot_window <hwnd>` chain** (3 calls + fragile HWND parsing). Captures a named region of HD, produces BOTH a full-res PNG and a Claude-safe-resized copy, and returns rich metadata so the AI never has to chain screenshot → pull → convert → crop → resize → Read manually.
+
+Each shot is **self-identifying**: it includes the running build's SHA + local build time read from HD's `/buildinfo` so you can confirm you're looking at the build you expect before trusting what you see (no more "this screenshot was from the previous binary, not my fix" confusion).
+
+Regions are deterministic — they resolve via HD window bounds + CDP element rects queried through HD's `/eval` endpoint. No AI-supplied pixel coords.
+
+Args:
+- `region` — **REQUIRED**. One of:
+  - `full` — whole HD window
+  - `titlebar` — top ~40 px strip (HD's build SHA / timestamp readout lives here)
+  - `vscode` — VS Code iframe pane via CDP element rect
+  - `setup-panel` — Setup Steps panel (`.setup-panel` / `.setup-panel-output`)
+  - `wiki` — wiki / browser tab pane
+  - `claude` — Claude Code panel
+- `resizeMax` — optional int, default `1400`. Longest side of the safe-resized copy. Use ≈800 to fit more shots in one Claude turn; 1568 for max fidelity.
+
+Returns:
+```
+{
+  ok, region,
+  fullPath,                    # lossless raw PNG — for shotlog/archival, NOT Read
+  safePath,                    # ≤resizeMax PNG — pull_file this, Read this
+  fullSize:[w,h], safeSize:[w,h],
+  clipRect:[x,y,w,h],          # captured-image pixels (NOT screen pixels)
+  capturedAtLocal,
+  runningBuildSha,             # from HD's /buildinfo — null if HD doesn't expose it yet
+  runningBuildLocal,
+  hdWindowFound:bool, regionFound:bool,
+  windowOuter:[w,h], capturedSize:[w,h],
+  _hints: {
+    pull,                      # exact pull_file command for safePath
+    fullForArchive,            # note that fullPath is for archive, not Read
+    staleWindow,               # set when runningBuildSha looks wrong
+    regionMiss                 # set when regionFound:false
+  }
+}
+```
+
+Recipe — verify-which-build-is-running + confirm-config-change-took-effect + full dump:
+
+```bash
+# After hd_ship, confirm the titlebar shows the SHA you expect:
+adom-desktop hd_shot '{"region":"titlebar"}'
+# → runningBuildSha lets you assert this before trusting any other shot
+
+# After flipping a Setup Steps toggle, screenshot the panel to confirm:
+adom-desktop hd_shot '{"region":"setup-panel","resizeMax":1568}'
+
+# Full-window dump for general debugging:
+adom-desktop hd_shot '{"region":"full"}'
+```
+
+Edge cases:
+- **Unknown region** — refusal: `{ok:false, error, knownRegions:[...]}`. No fallback — fix the call.
+- **`regionFound:false`** — the element rect wasn't in HD's DOM. The panel/iframe may not be mounted yet, OR HD's markup changed and `region_selectors()` in `hd_bridge.rs` is out of date. The shot is **still produced** (falls back to the full window) so you have something to look at; check `_hints.regionMiss` for the reason.
+- **`runningBuildSha:null`** — HD's `/buildinfo` isn't exposed yet on the running version. The shot is still produced; you just can't self-verify which build it represents.
+
+**Always pull_file the `safePath` and Read THAT** — it's already sized for Claude's image analysis. The `fullPath` is for shotlog / disk archival, NOT direct Read (may exceed Claude's per-image budget). The `_hints.pull` field gives you the exact `pull_file` command to copy-paste.
+
+**Old workaround to drop:** Do NOT use `desktop_list_windows | filter 'Hydrogen' | desktop_screenshot_window <hwnd>` anymore for HD — `hd_shot` replaces that entire pattern. Generic `desktop_screenshot_window` still applies to non-HD windows (other apps, dialogs, etc.).
+
+### TCP Port-forward Tunnels — `tunnel open|close|list|check|rdp` + `display` (v1.8.85+, reworked v1.8.147)
+
+Reach a TCP service addressable only from the **cloud side** through the **laptop side**: AD binds a `127.0.0.1:<port>` listener on the laptop and pumps bytes over the existing AD↔relay WebSocket; the relay dials the upstream `to`. First use case: RDP into a Windows VM behind an NSG only the cloud container reaches — mstsc on the laptop connects to a local port and is talking through the cloud. Same path works for SSH, VNC, HTTP, postgres — any TCP.
+
+**`tunnel` is a grouped verb** (`rdp` / `tunnel_close` / `tunnel_list` remain back-compat aliases):
+
+```bash
+# Open a VERIFIED tunnel (both legs probed before ok). Auto-picks a free local port.
+adom-desktop --target <name> tunnel open '{"to":"127.0.0.1:13389"}'
+
+# RDP: open + verify + launch mstsc, sized to the laptop's real screen.
+adom-desktop --target <name> tunnel rdp '{"to":"127.0.0.1:13389","size":"90%"}'
+#   size: "90%" (default) | "100%" | "fullscreen" | "maximized"
+#   OR monitor:<index> (0=primary)  OR explicit width+height
+
+adom-desktop --target <name> tunnel list           # live tunnels + leak detection
+adom-desktop --target <name> tunnel check '{"tunnelId":<id>}'   # per-leg health
+adom-desktop --target <name> tunnel close '{"tunnelId":<id>}'   # or {"all":true}
+
+adom-desktop --target <name> display               # monitor geometry (for sizing)
+```
+
+**`--target` is mandatory when >1 AD is connected.** v1.8.147 fixed a false-success bug: the bind, the upstream probe, and the mstsc launch now ALL resolve to the SAME machine. (Before, the bind could land on one AD and mstsc launch on another → "port bound" on the VM, "nothing listening" on the laptop, a bogus `ok:true`.) No target + multiple ADs → `ambiguous_target` listing the names; `--target all` is rejected (a tunnel binds one machine). `adom-desktop targets` lists connected ADs.
+
+**Active health verification — every `open`/`rdp`/`check` PROVES the path before reporting `ok`:**
+- **local-bind** — AD self-probes `127.0.0.1:<boundPort>` right after binding. If it can't connect, AD tears the tunnel down and returns `ok:false` (this alone kills the false-success class).
+- **upstream-refused** — the relay dials `to` (retry/backoff ~4×/2.5s). If unreachable, the tunnel is torn down (no leaked row) and `ok:false`.
+- A failure names the leg: `failedLeg: "relay-to-ad" | "local-bind" | "upstream-refused"`. Health fields surfaced: `listening`, `relayConnected`, `upstreamReachable`, `connCount`, `mstscAlive`.
+
+**Progressive hints.** Every response carries `_hint` (what's true now) + `_next` (the exact next verb with real values, copy-pasteable). e.g. a successful open's `_next` hands you the `tunnel rdp` / `tunnel check` / `tunnel close` commands with the real `to`/`target`/`tunnelId` filled in. A missing `to` returns a structured error with an example. Walk the workflow from the responses alone.
+
+**Display-aware RDP sizing.** `tunnel rdp` resolves `size`/`monitor`/`width`/`height` against the laptop's real screen (queried via `display` — `EnumDisplayMonitors` + per-monitor DPI), writes an `.rdp` file with the geometry, and launches mstsc. e.g. `size:"90%"` on a 2560×1600 primary → a 2304×1440 window. Default 90% of primary. You never hardcode a resolution — call `display` if you want the layout first.
+
+**`tunnel list` reconciles + reaps leaks.** It cross-checks the relay registry against every connected AD's own registry and reports `leaks:[...]` for rows only one side knows about; `tunnel close '{"all":true}'` reaps them. The relay stores the actually-bound `localPort` (so `tunnel close '{"local":<port>}'` works).
+
+Architectural notes for Claude:
+- Bytes-only — no payload parsing, so any TCP service works. TLS/cert-pinning still pins to whatever the cloud-side dial resolves (for RDP the upstream cert CN must match what mstsc expects).
+- Each fresh laptop-side TCP connection gets its own connId multiplexed over one tunnelId; concurrent mstsc reconnects / parallel sessions work without re-opening. Backpressure bounded (256-slot mpsc/conn, 64 KiB buf).
+- Tunnels don't survive AD/relay restarts. If mstsc disconnects, `tunnel check` (names the dead leg), then re-open.
+- The classic RDP collision (Windows' own RDP owns `0.0.0.0:3389`) is dodged by `rdp`'s default `local:13389` + `SO_EXCLUSIVEADDRUSE` + ephemeral fallback (`fellBack:true` in the response; read `localPort` for the real port).
+
+**Old workarounds to drop**: (1) NSG WAN-IP whitelisting for roaming-laptop RDP — the tunnel rides existing auth, reachable regardless of WAN IP. (2) Trusting a bare `ok:true` from the old `rdp` — it could be a dead port; now `ok` means both legs were probed. (3) Hardcoding mstsc resolution — pass `size`/`monitor`; AD resolves against the real screen.
+
+### Shell — `shell_execute` (escape hatch only)
+
+- `shell_execute` -- Run a shell command on the desktop. The CLI handles approval polling internally — it returns the final `{success, output, error, exitCode}` once the user clicks Allow on the desktop dialog. **You do not need to poll, retry, or call `get_deferred_result` yourself.** The CLI emits `HINT:` lines to stderr while waiting (every 15s) so an AI Monitor sees progress.
+- `shell_kill_all` -- Kill all running shell commands and deny pending approvals.
+
+**v1.7.16 fix.** Earlier versions (v1.7.15 specifically) had a regression where the CLI's approval-wait loop re-sent the command every 1s instead of polling for the deferred result; that produced an infinite "Another shell command is already waiting for approval" loop because each retry created a fresh approval the user could never out-click. v1.7.16 fixes it. If you see that error string with a `>= 1.7.16` CLI, the bug is back — file an issue. If you see it with `< 1.7.16`, upgrade the CLI: `adom-wiki asset get apps/adom-desktop docker_binary -o /usr/local/bin/adom-desktop`.
+
+**Deprecated for download polling.** As of v1.5.0, use `desktop_watch_files` / `desktop_pull_glob` for waiting on download arrivals — those don't require user approval, don't go through PowerShell quoting, and are O(directory entries) rather than spawning a process every second. `shell_execute` itself stays as an escape hatch for genuinely shell-only operations (multi-step ad-hoc admin tasks, chained pipelines, etc.).
+
+Use `where python` / `C:\Python3xx\python.exe` for Python -- avoid `python` which may not be on PATH.
+
+#### `run_script` + `wsl_exec` — base64-payload script runners (v1.8.46+)
+
+When you want to send a multi-line script to the desktop without the shell-quoting hell that `shell_execute` requires, use these two verbs. The script crosses the wire as base64 so NO escaping survives to be mangled by cmd.exe, PowerShell parser quirks, MSYS path conversion, or wsl.exe's `--` / nested-quote / `$_` rules. The caller never hand-escapes anything.
+
+Both verbs require shell auto-approve to be active — same trust level as `shell_execute`. They refuse cleanly with an actionable error if not approved; no per-call modal (these are programmatic verbs).
+
+**Reading the result — `status` + `durationMs` (v1.8.162+).** EVERY CLI result now carries a single authoritative **`status`** field so you never have to guess whether an empty `stdout` was a real empty-output success or a timeout:
+
+| `status` | meaning | what to do |
+|---|---|---|
+| `ok` | completed (stdout may legitimately be empty) | use `exitCode`/`stdout`/`stderr` |
+| `timeout` | did NOT finish in time (NOT an empty success) | raise `timeoutSeconds`, or use a detached job (below) |
+| `busy` | desktop couldn't service it (WSL wedged / no client connected) | check `adom-desktop status`; `wsl_recover` for WSL; retry |
+| `error` | the command/verb failed | read `error` / `stderr` / `_hint` |
+
+`durationMs` is the wall-clock of the call. `exitCode`/`stdout`/`stderr` pass through unchanged; on `timeout` the verb's `partialStdout`/`partialStderr` hold what was written before the kill. A `timeout`/`busy` result also carries a recovery `_hint`. **For multi-minute work don't fight the timeout** — use a detached job that returns instantly + polls: `wsl_exec_async` / `wsl_import` / `wsl_export` → poll `wsl_job_status` (or `launch_process` for a general long-running command). This is the cure for the "1.4 GB import EOFs the sync call" problem.
+
+**`run_script`** — base64 script + interpreter (cmd / powershell / bash):
+
+```bash
+SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b'''
+echo \"multi-line script\"
+VAR=\"value with spaces and \$dollars\"
+echo \"VAR=\$VAR\"
+exit 0
+''').decode())")
+adom-desktop run_script "{\"interpreter\":\"bash\",\"scriptB64\":\"$SCRIPT_B64\"}"
+# → {success, ok, stdout, stderr, exitCode}
+```
+
+Args: `interpreter` (`cmd` | `powershell` | `bash`), `scriptB64` (required), `timeoutSeconds` (default 60).
+
+PowerShell prefers `pwsh` (PS 7+) if on PATH, falls back to `powershell.exe` (5.1).
+
+- **`bash` runs via WSL2, NOT Git Bash.** The Windows host's first `bash` on PATH is `wsl.exe -d <default-distro> bash` (verified at `C:\Windows\System32\bash.exe`). The script is piped to bash's stdin (no temp file, no path-form mismatch — no MSYS `/S` → `S:/` corruption, no nested-quote loss). The decoded script bytes are written to bash's stdin and stdin is closed; bash reads + executes without ever opening a file. For explicit distro / user control, use `wsl_exec`.
+
+**`wsl_exec`** — base64 script + distro + user, executed via the canonical `wsl -d <distro> -u <user> -- bash -lc <decoded>` argv array:
+
+```bash
+SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b'''
+cd /mnt/c/Github/adom-desktop
+git status --short | head -10
+exit 0
+''').decode())")
+adom-desktop wsl_exec "{\"distro\":\"Ubuntu\",\"user\":\"root\",\"scriptB64\":\"$SCRIPT_B64\"}"
+# → {success, ok, stdout, stderr, exitCode}
+```
+
+Args: `distro` (e.g. `Ubuntu`, `Adom-Workspace`), `user` (e.g. `adom`, `root`), `scriptB64`, `timeoutSeconds` (default 60).
+
+Why argv (not shell string): Rust's `std::process::Command` on Windows applies proper `CommandLineToArgvW` quoting per argv element. `wsl.exe` sees `bash -lc <decoded>` as three distinct argv items even when the decoded script contains quotes, `$`, backslashes, newlines, pipes — anything. `bash`'s `-c` then sees the entire script as ONE string with no outer-shell interpretation.
+
+Output decoding: `wsl.exe` defaults to UTF-16LE when invoked from the Windows console subsystem. Both verbs auto-detect (BOM check + odd-position-NULL heuristic) and decode to UTF-8. Output is never wrapped in another shell that might re-interpret it.
+
+Pitfalls these verbs eliminate:
+- `cmd.exe`'s `&`, `|`, `^`, `%var%` interpretation
+- PowerShell's parser quirks around nested quotes + `$` interpolation
+- Git Bash MSYS converting `/S` → `S:/` etc.
+- WSL's `--` parser limits + nested-quote loss
+- UTF-16LE corruption on wsl.exe output
+
+#### WSL wedge defense — `wsl_wedged` + `wsl_recover` (v1.8.157+)
+
+Every `wsl_exec` / `run_script bash` spawns a `wsl.exe` client. If WSL's service (`LxssManager`) wedges — common after a laptop runs for a day, or under heavy parallel cloud use — those clients block in a wait that `taskkill /F` can't always finish, and retries keep spawning more. Left unchecked they pile up into the hundreds and wedge the whole machine. Three defenses:
+
+1. **Refuse-cap.** When there are already too many `wsl.exe` (`WSL_REFUSE_AT=80`), AD refuses NEW wsl spawns with `errorCode:"wsl_wedged"` instead of adding another doomed client. If you get `wsl_wedged`, don't retry the same command — run `wsl_recover` first.
+2. **`wsl_recover`** — reap orphaned/hung `wsl.exe`, **DRY-RUN by default** (nothing is killed unless you pass `confirm:true`):
+   ```bash
+   adom-desktop wsl_recover                       # dry-run: lists `candidates` (would-reap) + `spared`
+   adom-desktop wsl_recover '{"confirm":true}'    # reap the candidates
+   adom-desktop wsl_recover '{"confirm":true,"sparePids":[1234]}'   # also protect pid 1234
+   ```
+   It classifies each `wsl.exe`: childless + (0-CPU & old | runaway-CPU) → **candidate**; everything else → **spared**. It **spares any `wsl.exe` with child processes** (that's almost certainly the live distro, e.g. the HD `Adom-Workspace`), honors `sparePids`, and never touches `wslservice`/`wslhost`/`vmmem`. Review each candidate's `cmd` in the dry-run to confirm the live workspace isn't among them, then confirm. **Never** `wsl --shutdown` (it kills the live workspace too). Reaping childless orphans often cascades — their now-parentless children exit on their own (observed 114 → 18 from reaping 54).
+3. **status wsl block** — `{wslProcessCount, wedged, _hint}` lets you see a pile-up *before* it blocks work. Path differs by transport: **`status.desktop.wsl`** via the CLI/relay (`adom-desktop status` — nested like `desktop.apps`), or top-level **`status.wsl`** on the local direct API (`GET /status`). `wedged:true` at/above `WSL_WARN_AT=50`.
+
+#### Long-running in-distro work + distro lifecycle — golden-image bakes (v1.8.161+)
+
+`wsl_exec` is **synchronous** — it waits for the command and is bounded by the relay's HTTP timeout (~60-120s), so a multi-minute bake (apt, a big download) EOFs. Backgrounding inside the distro (`nohup … &`) doesn't help either: the `wsl.exe` client returns, WSL sees no active session, shuts the distro down, and kills the bg process (wiping `/tmp`). For anything long, use the **async held-session** verbs — AD holds one `wsl.exe` in the foreground for the command's whole lifetime, so the distro stays up:
+
+- **`wsl_exec_async`** → returns a `jobId` + `outputPath` (a live UTF-8 log) immediately; the command runs detached for as long as it takes.
+- **`wsl_job_status {jobId}`** → `{running, exitCode, elapsedMs, outputTail, outputPath}`. Poll until `running:false`; `pull_file` the `outputPath` for the full log. (`wsl_job_list`, `wsl_job_kill {jobId}` round it out.)
+
+```bash
+# golden-image bake: import → run (async) → poll → export → unregister, all via AD
+adom-desktop wsl_import '{"distro":"bake","installDir":"C:/Users/<you>/.adom/wsl/bake","tarball":"C:/.../base.tar"}'
+#   → {jobId}; poll wsl_job_status until done (exitCode 0)
+B64=$(printf 'set -euo pipefail\napt-get update && apt-get install -y build-essential\n' | base64 -w0)
+adom-desktop wsl_exec_async "{\"distro\":\"bake\",\"user\":\"root\",\"scriptB64\":\"$B64\"}"
+adom-desktop wsl_job_status '{"jobId":"wsljob-xxxxxxxx"}'   # repeat until running:false; outputTail = progress
+adom-desktop wsl_export '{"distro":"bake","tarball":"C:/.../golden.tar"}'   # poll, then pull_file the tarball
+adom-desktop wsl_unregister '{"distro":"bake"}'            # cleanup (REFUSES Adom-Workspace)
+adom-desktop wsl_list                                      # {distros:[{name,state,version,default}]}
+```
+
+**systemd user session — `viaRoot`.** On a `systemd=true` distro, `-u <user>` can fail "Failed to start the systemd user session for '<user>'". Pass `"viaRoot": true` to `wsl_exec_async` — AD runs `-u root -- runuser -u <user> -- bash -l`, dropping to the user WITHOUT depending on `[email protected]`.
+
+`wsl_import` / `wsl_export` / `wsl_unregister` are async (jobId + poll) too, so big imports/exports never EOF. All are **strictly scoped to the named distro** — there is no global `wsl --shutdown` path (that would kill the live HD workspace), and `wsl_unregister` refuses `Adom-Workspace`. Job ids live in AD's memory only (not persisted across an AD restart; the log file survives). The wedge defense above still applies — an over-cap spawn returns `wsl_wedged`.
+
+## Detecting App Installation
+
+After connecting, always run `adom-desktop status` to check what's installed. The `desktop.apps` object tells you exactly what the user has:
+
+```bash
+adom-desktop status
+# Look at the desktop.apps field in the response
+```
+
+### Handling "not installed" errors
+
+When a command returns `errorCode: "node_not_found"`, `errorCode: "kicad_not_installed"`, or `errorCode: "fusion_not_installed"`, guide the user through installation:
+
+**Picking the right native browser + profile (v1.7.1+):**
+
+When you need to open a URL in the user's NATIVE browser AND it matters which account is signed in (work Google Workspace vs personal Gmail vs media YouTube channel etc.), `desktop_open_url` alone isn't enough — you need to target a specific profile. The flow:
+
+```bash
+# 1. Discover what's installed + which profiles are configured.
+adom-desktop desktop_list_browsers '{}'
+# → {
+#     "default": "chrome",
+#     "browsers": [
+#       { "name": "chrome", "displayName": "Google Chrome", "version": "146...",
+#         "exePath": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
+#         "profiles": [
+#           { "id": "Default",   "name": "Alice (work)", "gaia": "[email protected]",     "isDefault": true },
+#           { "id": "Profile 1", "name": "Personal",     "gaia": "[email protected]" },
+#           { "id": "Profile 2", "name": "Adom Media",   "gaia": "[email protected]" }
+#         ] },
+#       { "name": "edge", "profiles": [...] },
+#       { "name": "firefox", "profiles": [{"id":"default-release","name":"default","isDefault":true}] }
+#     ]
+#   }
+
+# 2. Match the URL's context to the right profile, then open it there.
+# E.g. opening a Google Doc shared by your work team → use the work profile:
+adom-desktop desktop_open_url '{
+  "url":"https://docs.google.com/document/d/...",
+  "browser":"chrome",
+  "profile":"Default"
+}'
+# Or YouTube channel management for the media account:
+adom-desktop desktop_open_url '{
+  "url":"https://studio.youtube.com",
+  "browser":"chrome",
+  "profile":"Profile 2"
+}'
+```
+
+Profile-picking heuristics for Docker Claude:
+- **Workspace / @adom.inc URLs** → match `profile.gaia.endsWith("@adom.inc")` and not the `media@` one → typically `Default`.
+- **Personal Gmail / Drive / etc.** → match `profile.gaia === "[email protected]"` (or whatever the user's personal address resolves to).
+- **YouTube Studio / channel-specific work** → match the media account profile.
+- **Random scratch / experiments** → use the user's Edge profile, where they keep miscellaneous accounts (per the user's stated preference).
+- **No clear match** → fall back to `browser:"default"` (no profile flag) and let the user pick.
+
+`profile` is optional. Omit it to open in whichever profile the browser was last using (legacy v1.6.x behavior). Profile flag is silently ignored when `browser:"default"` (no clean way to inject through the OS URL handler — name the browser explicitly to use it).
+
+### Decrypting the user's saved browser credentials — `desktop_decrypt_browser_credentials` (v1.9.220+)
+
+So pup can run an **"adom-you" Chrome profile signed in AS the user** — surfing the web on the user's behalf — it needs the user's real saved logins/cookies to seed that profile. Chrome 127+ encrypts these with **App-Bound Encryption** (the master key is DPAPI-wrapped under the SYSTEM account), so only an elevated helper can decrypt them. This verb is that path.
+
+```bash
+adom-desktop desktop_decrypt_browser_credentials '{
+  "browsers": ["chrome", "edge"],   # optional; default = all installed Chromium browsers
+  "include": ["passwords", "cookies"] # optional; default = both
+}'
+# → { "ok": true,
+#     "passwords": [ { "origin", "username", "password", "sourceBrowser", "sourceProfile", "sourceAccount", "lastUsed" }, ... ],
+#     "cookies":   [ { "host", "name", "value", "path", "sourceBrowser", "sourceProfile", ... }, ... ],
+#     "stats": { "profilesRead", "passwordsOk", "passwordsFailed", "cookiesOk", "cookiesFailed" } }
+```
+
+**How to call it (this is not a normal verb — read this):**
+
+- **LOCAL / bridge callers only.** A relay (cloud-AI) call is refused unconditionally with `errorCode:"relay_forbidden"` and **pops no UAC** — a remote container must never be able to trigger a password-decrypt. pup (a loopback bridge) and the local CLI reach it.
+- **Two human gates, in order:** (1) AD shows an in-window consent dialog + toast naming the requesting caller ("pup is requesting your saved browser passwords/cookies…"); decline → `errorCode:"consent_declined"`. (2) On continue, an elevated signed helper runs behind a **UAC prompt** ("Adom Industries, Inc."); cancel → `errorCode:"elevation_declined"`. The UAC elevation IS the user's permission for the decrypt — by design (John, wiki #457).
+- **Plaintext is returned ONCE over the loopback API and is never written to disk or logged.** Don't echo the values into any other verb's args, any activity-log emit, or any file. Consume the rows, seed the profile/vault, drop them.
+- **errorCodes:** `relay_forbidden` (you called it over the relay — you can't), `consent_declined`, `elevation_declined`, `helper_missing` (the signed helper isn't bundled — the install is broken). Per-item decrypt failures are counted in `stats.*Failed`, never fail the whole run (a managed-profile policy can block cookies; newest-Chrome `v20` blobs are best-effort if Google rotated the elevation-service key).
+- **Pair it with `desktop_list_browsers`** (above) to show the user which browsers/profiles/accounts will be read before you ask.
+
+**Node.js not installed (the puppeteer bridge can't auto-spawn):**
+
+v1.7.0+ — any `browser_*` command returning `errorCode:"node_not_found"` means Node isn't on the desktop machine. Trigger the unattended winget install:
+
+```bash
+adom-desktop desktop_install_node '{}'
+# → runs `winget install --id OpenJS.NodeJS.LTS --silent ...` (Windows only).
+# → ~1-2 min; downloads + installs the Node.js LTS line (well-tested with puppeteer).
+# → returns {ok:true, exitCode:0, _hint:"Retry the original browser_* command"} on success.
+# → on failure: {ok:false, errorCode:"winget_unavailable" | "winget_install_failed", _hint}.
+```
+
+After install, **just retry the original `browser_*` command** — the bridge picks up the new node.exe via the registry App Paths lookup (which winget writes), no Adom Desktop restart needed. v1.7.0+ also pre-fetches Node + Chrome for Testing on first launch after install (background task on AUTOSTART_VERSION 11), so most users will never see `node_not_found` to begin with.
+
+If `desktop_install_node` returns `errorCode:"winget_unavailable"` (older Windows or managed corporate device), fall back to manual:
+
+> Node.js LTS is required for browser automation. winget isn't available on this machine — download Node.js LTS from https://nodejs.org/ and run the installer. Let me know when it's done and I'll retry.
+
+**v1.7.0+ Node detection.** The bridge looks for `node.exe` in:
+- System PATH (via `where node` on Windows, `which node` elsewhere)
+- Windows registry App Paths (catches winget, MSIX, custom-dir installs)
+- Standard install dirs: `C:\Program Files\nodejs\`, `C:\Program Files (x86)\nodejs\`, `%LOCALAPPDATA%\Programs\nodejs\`
+- Per-user version managers: nvm-windows (`%APPDATA%\nvm\`), volta (`%LOCALAPPDATA%\Volta\bin\`), fnm (`%FNM_DIR%`)
+- macOS Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`)
+
+If detection still misses an install on a user's machine, get the actual `node.exe` path from the user — that's a real bug to file.
+
+**KiCad not installed:**
+
+**KiCad not installed:**
+
+v1.6.0+ — try the unattended winget install FIRST before asking the user to download manually:
+
+```bash
+adom-desktop desktop_install_kicad '{}'
+# → runs `winget install --id KiCad.KiCad --silent ...` (Windows only).
+# → 2-5 minutes; downloads ~700 MB then installs.
+# → returns {ok:true, exitCode:0, _hint:"Run kicad_list_versions to verify"} on success.
+# → on failure (no winget / network failure / corporate-managed device):
+#   {ok:false, errorCode:"winget_unavailable" | "winget_install_failed", _hint, ...}
+```
+
+If `desktop_install_kicad` fails with `errorCode:"winget_unavailable"`, fall back to manual:
+
+> KiCad isn't installed and winget isn't available. Download from https://www.kicad.org/download/ — it's free and open source, install the latest stable (9.x or 10.x).
+> Let me know when the install is done and I'll verify the connection.
+
+After EITHER path, have them restart Adom Desktop OR call `kicad_list_versions` again — the kicad bridge caches detection results, so a fresh scan may be needed to pick up freshly-installed binaries.
+
+**v1.6.0+ — improved KiCad detection.** The bridge now scans for KiCad in:
+- `C:\Program Files\KiCad\<version>\` (the standard location)
+- `C:\Program Files (x86)\KiCad\<version>\` (32-bit edge cases)
+- `%LOCALAPPDATA%\Programs\KiCad\<version>\` (winget per-user installs)
+- `%LOCALAPPDATA%\KiCad\<version>\` (rare manual installs)
+- Windows registry: `HKLM\SOFTWARE\KiCad\<version>\InstallationPath` and the `WOW6432Node` mirror
+- `HKCU` mirrors of the above for per-user installs
+- macOS: `/Applications/KiCad/` and `/Applications/` directly
+
+If detection still misses an install on a user's machine, that's a real bug to file — get the actual install path from the user and we'll add it to the scan list.
+
+**Fusion 360 not installed:**
+> Fusion 360 isn't installed on your desktop. Would you like to install it?
+> Download from: https://www.autodesk.com/products/fusion-360
+> It's free for personal/hobby use (requires an Autodesk account).
+> Let me know when the install is done and I'll verify the connection.
+
+After install, have them restart Adom Desktop, then run `adom-desktop status` to verify.
+
+### Handling "not running" errors
+
+When `errorCode: "fusion_not_running"`, launch it programmatically with the first-class startup command:
+```bash
+adom-desktop fusion_start
+# On Windows: discovers exe via %LOCALAPPDATA% env var webdeploy glob.
+# On Docker: delegates to the bridge on the connected desktop via relay.
+# ~15-30s typical. Auto-dismisses startup dialogs.
+# Returns {"addinReady": true, "pickerDismissed": true|false, ...}
+```
+
+KiCad doesn't need to be running for most commands (the bridge launches it on demand).
+
+### Handling "add-in not installed" errors
+
+When Fusion is running but `addinInstalled: false` or `addinConnected: false`:
+> The AdomBridge add-in needs to be installed in Fusion 360. I can install it for you — this lets me control Fusion remotely.
+
+The add-in auto-installs when the Fusion bridge starts and Fusion is detected.
+
+### Handling `fusion_addin_not_responding` errors
+
+When `errorCode: "fusion_addin_not_responding"`, Fusion is running but the AdomBridge add-in isn't answering. Call `fusion_dismiss_blocking_dialogs` FIRST — a modal dialog is the most common cause. If that doesn't help, try `fusion_start` to restart Fusion cleanly. Last resort: user enables add-in manually via UTILITIES > ADD-INS > AdomBridge > Run on Startup + Run.
+
+### Handling `main_thread_busy` errors
+
+When `errorCode: "main_thread_busy"`, the Fusion add-in's main thread is occupied by a long-running command (typically `walk_cloud_tree` or `search_cloud_files`). This applies **across all bridges/sessions** — even if you didn't start the walk, another session might have.
+
+**Do NOT:**
+- Retry the failed command — it will block behind the same lock
+- Call `fusion_dismiss_blocking_dialogs` — there's no dialog to dismiss, and sending Escape will interrupt the active walk
+- Force-kill Fusion — the walk will complete on its own
+
+**Do:**
+- Wait for the walk/search to finish. If you started it, you should be using `adom-desktop watch` (see "Live folder progress streaming with `watch`" above) which streams live progress automatically. If another session started it, poll `fusion_addin_status` every 2–5s to check progress.
+- Use commands that **don't need the main thread** while waiting:
+  - `fusion_addin_status` — check busy state and walk progress
+  - `fusion_window_info` — get window HWND, title, dialogs
+  - `fusion_screenshot_fusion` — capture what Fusion looks like
+  - `fusion_click_fusion` — click in the Fusion window
+  - `fusion_send_key` — send keyboard input
+  - `fusion_close_window` — close a specific dialog by HWND
+
+The response includes progress info:
+```json
+{
+  "errorCode": "main_thread_busy",
+  "busyCommand": "walk_cloud_tree",
+  "elapsedSeconds": 42.3,
+  "walkProgress": {
+    "foldersVisited": 15,
+    "filesFound": 87,
+    "currentFolder": "Molecules/XRP",
+    "queueSize": 8
+  },
+  "_hint": "Add-in is busy with a long-running command. Do NOT retry..."
+}
+```
+
+**Stalled walk detection**: If `fusion_addin_status` returns `busy: true` but `walkProgress` is missing and `mainThreadStalled: true`, a modal dialog is blocking the event loop — the walk was dispatched but never started. Call `fusion_dismiss_blocking_dialogs`, then the walk auto-resumes.
+
+### Auto-recovery (`_autoRecovery` field)
+
+When a fusion_* command fails with "not responding" / "not connected", the CLI automatically:
+1. Calls `fusion_dismiss_blocking_dialogs` to clear any modal
+2. If successful, retries the original command
+3. Attaches `_autoRecovery: {action, dismissed[]}` to the retry result
+
+If you see `_autoRecovery` in a response, the command already succeeded after auto-dismissal — no manual intervention needed. The field is informational.
+
+## Troubleshooting
+
+### Check connection status
+
+Use `status` to see connected clients. A healthy connection shows one client from the user's hostname with a recent `lastPong` timestamp.
+
+### Stale connections causing timeouts
+
+Use `kick_all` to reset -- active Adom Desktop apps reconnect within seconds.
+
+### No desktop client connected
+
+1. Confirm the Adom Desktop app is running on the user's PC
+2. Confirm it's pointed at the correct WebSocket URL
+3. Check if port 8765 is exposed and reachable
+
+### Relay not running
+
+```bash
+curl -sf http://127.0.0.1:8766/health
+# If fails: adom-desktop serve --supervise
+```
+
+### Shell commands on Windows
+
+`shell_execute` runs in `cmd.exe`. Prefer writing scripts via `send_files` then executing them. Use the full Python path.
+
+## Building from Source
+
+```bash
+cd cli && cargo build --release
+# Binary at: cli/target/release/adom-desktop
+```
+
+## Repo
+
+[github.com/adom-inc/adom-desktop](https://github.com/adom-inc/adom-desktop)
+
+## Run wiki ops on the user's PC — `wiki_exec` (v1.9.116+)
+
+The AD installer bundles the `adom-wiki` CLI next to the app, and `wiki_exec` runs it ON THE PC — so version-controlling a local project (KiCad board, docs folder) on wiki.adom.inc needs NO per-file `pull_file` round trip to the container and there is no container/PC drift.
+
+```bash
+adom-desktop wiki_exec '{"args":["repo","push","adom/my-board","--files","board.kicad_pcb","board.kicad_sch","-m","rev B"],"cwd":"C:\Users\me\boards\my-board"}'
+```
+
+- `args` = adom-wiki argv (any pillar: repo, pkg, release, discussion). `cwd` = PC-side project dir so `--files` are project-relative. `timeoutSec` optional (default 120, max 600).
+- Returns `{success, exitCode, timedOut, stdout, stderr, exe, durationMs}`.
+- Auth is automatic: AD injects the signed-in user's session token as `ADOM_WIKI_TOKEN` — the caller never handles credentials.
+- Gated like `shell_execute` for relay callers (it writes to the wiki as the user). `errorCode:wiki_cli_missing` means the desktop AD predates 1.9.116.
+- Container-side publishing of the CONTAINER's own files still uses your local `adom-wiki` directly; `wiki_exec` is for files that live on the PC.
+
+Note (v1.9.117): AD's Settings panel no longer has a KiCad section — bridges own their app-specific config (AD stays generic). `kicad_library_paths` / `project_watch` keep working from `~/.adom/config.json` and the `set_project_watch` / `trigger_project_watch` verbs.
dist/linux/adom-desktop
⋯ 1 unchanged line ⋯
package.json+81−81
@@ -1,82 +1,82 @@-{-  "slug": "adom-desktop",-  "version": "1.9.232",-  "type": "app",-  "brief": "Adom Desktop unleashes your AI onto your full PC: full power, full safety. The AI breaks out of the container it runs in and onto your real machine, managing and running your entire laptop as you: launch apps, move files, drive any window, control KiCad, Fusion 360 or your real signed-in browser. Works with any AI, cloud or local (Claude Desktop, Claude Code, Codex), no Hydrogen required. Bridges make it an operating system for AI with managed Node and Python runtimes, and a human-only permission system keeps you in charge of every side-effecting command.",-  "description": "Install this package in a cloud or Adom Hydrogen container, not on the PC. It delivers two things: the Linux adom-desktop CLI (linked onto PATH) and the Claude skill set that teaches an AI every desktop verb (shell, files, screenshots, windows, notifications, KiCad, Fusion 360, browser automation). The CLI drives the Adom Desktop app running on your Windows PC over the relay; the Windows app itself is the separate installer in the Download box, not part of this package. Run adom-wiki pkg update to keep the CLI and skills current.",-  "discovery_pitch": "Use when a cloud AI needs to reach a real Windows desktop: run shell commands, capture screenshots, drive native apps and browsers, transfer files, and control KiCad / Fusion 360 / third-party bridges.",-  "discovery_triggers": [-    "control my windows desktop from the cloud",-    "drive a native windows app",-    "take a screenshot of a window on my pc",-    "run a shell command on my windows machine",-    "automate kicad or fusion 360",-    "open a browser on my desktop and click around",-    "send a file to my laptop",-    "show me a notification on my computer"-  ],-  "sample_prompts": [-    {-      "label": "Screenshot a window",-      "prompt": "Take a screenshot of the Fusion 360 window on my desktop"-    },-    {-      "label": "Run a command",-      "prompt": "Run git status on my Windows machine and show me the output"-    },-    {-      "label": "Drive a bridge",-      "prompt": "Open my latest KiCad project and export the Gerbers"-    },-    {-      "label": "Automate the browser",-      "prompt": "Open Chrome on my desktop and log into the dashboard"-    },-    {-      "label": "Transfer a file",-      "prompt": "Pull the build log from C:\\temp on my laptop"-    }-  ],-  "keywords": [-    "adom-desktop",-    "tauri",-    "claude-code",-    "bridge",-    "kicad",-    "fusion360",-    "puppeteer",-    "chrome",-    "websocket",-    "cli",-    "screenshot",-    "recording",-    "desktop-automation"-  ],-  "tags": [-    "adom-desktop",-    "tauri",-    "claude-code",-    "bridge",-    "kicad",-    "fusion360",-    "puppeteer",-    "desktop-automation"-  ],-  "scripts": {-    "install": "./install.sh",-    "uninstall": "./uninstall.sh"-  },-  "install": {-    "binary_name": "adom-desktop"-  },-  "dependencies": {},-  "files": [-    "SKILL.md",-    "README.md",-    "install.sh",-    "uninstall.sh",-    "skills/**",-    "dist/**",-    "version.json"-  ]+{
+  "slug": "adom-desktop",
+  "version": "1.9.233",
+  "type": "app",
+  "brief": "Adom Desktop unleashes your AI onto your full PC: full power, full safety. The AI breaks out of the container it runs in and onto your real machine, managing and running your entire laptop as you: launch apps, move files, drive any window, control KiCad, Fusion 360 or your real signed-in browser. Works with any AI, cloud or local (Claude Desktop, Claude Code, Codex), no Hydrogen required. Bridges make it an operating system for AI with managed Node and Python runtimes, and a human-only permission system keeps you in charge of every side-effecting command.",
+  "description": "Install this package in a cloud or Hydrogen Desktop container, not on the PC. It delivers two things: the Linux adom-desktop CLI (linked onto PATH) and the Claude skill set that teaches an AI every desktop verb (shell, files, screenshots, windows, notifications, KiCad, Fusion 360, browser automation). The CLI drives the Adom Desktop app running on your Windows PC over the relay; the Windows app itself is the separate installer in the Download box, not part of this package. Run adom-wiki pkg update to keep the CLI and skills current.",
+  "discovery_pitch": "Use when a cloud AI needs to reach a real Windows desktop: run shell commands, capture screenshots, drive native apps and browsers, transfer files, and control KiCad / Fusion 360 / third-party bridges.",
+  "discovery_triggers": [
+    "control my windows desktop from the cloud",
+    "drive a native windows app",
+    "take a screenshot of a window on my pc",
+    "run a shell command on my windows machine",
+    "automate kicad or fusion 360",
+    "open a browser on my desktop and click around",
+    "send a file to my laptop",
+    "show me a notification on my computer"
+  ],
+  "sample_prompts": [
+    {
+      "label": "Screenshot a window",
+      "prompt": "Take a screenshot of the Fusion 360 window on my desktop"
+    },
+    {
+      "label": "Run a command",
+      "prompt": "Run git status on my Windows machine and show me the output"
+    },
+    {
+      "label": "Drive a bridge",
+      "prompt": "Open my latest KiCad project and export the Gerbers"
+    },
+    {
+      "label": "Automate the browser",
+      "prompt": "Open Chrome on my desktop and log into the dashboard"
+    },
+    {
+      "label": "Transfer a file",
+      "prompt": "Pull the build log from C:\\temp on my laptop"
+    }
+  ],
+  "keywords": [
+    "adom-desktop",
+    "tauri",
+    "claude-code",
+    "bridge",
+    "kicad",
+    "fusion360",
+    "puppeteer",
+    "chrome",
+    "websocket",
+    "cli",
+    "screenshot",
+    "recording",
+    "desktop-automation"
+  ],
+  "tags": [
+    "adom-desktop",
+    "tauri",
+    "claude-code",
+    "bridge",
+    "kicad",
+    "fusion360",
+    "puppeteer",
+    "desktop-automation"
+  ],
+  "scripts": {
+    "install": "./install.sh",
+    "uninstall": "./uninstall.sh"
+  },
+  "install": {
+    "binary_name": "adom-desktop"
+  },
+  "dependencies": {},
+  "files": [
+    "SKILL.md",
+    "README.md",
+    "install.sh",
+    "uninstall.sh",
+    "skills/**",
+    "dist/**",
+    "version.json"
+  ]
 }\ No newline at end of file
page.json+2−2
@@ -1,8 +1,8 @@ {   "slug": "adom-desktop",-  "version": "1.9.232",+  "version": "1.9.233",   "type": "app",-  "description": "Install this package in a cloud or Adom Hydrogen container, not on the PC. It delivers two things: the Linux adom-desktop CLI (linked onto PATH) and the Claude skill set that teaches an AI every desktop verb (shell, files, screenshots, windows, notifications, KiCad, Fusion 360, browser automation). The CLI drives the Adom Desktop app running on your Windows PC over the relay; the Windows app itself is the separate installer in the Download box, not part of this package. Run adom-wiki pkg update to keep the CLI and skills current.",+  "description": "Install this package in a cloud or Hydrogen Desktop container, not on the PC. It delivers two things: the Linux adom-desktop CLI (linked onto PATH) and the Claude skill set that teaches an AI every desktop verb (shell, files, screenshots, windows, notifications, KiCad, Fusion 360, browser automation). The CLI drives the Adom Desktop app running on your Windows PC over the relay; the Windows app itself is the separate installer in the Download box, not part of this package. Run adom-wiki pkg update to keep the CLI and skills current.",   "keywords": [     "adom-desktop",     "tauri",
skills/adom-desktop-direct-api/SKILL.md+532−532
@@ -1,532 +1,532 @@-----name: adom-desktop-direct-api-description: Direct HTTP API on the adom-desktop GUI. Use when authoring a sibling Tauri app (Hydrogen Desktop, future Adom-family apps) on the same Windows machine that needs to send commands into adom-desktop without spawning the CLI binary or going through the WebSocket relay. Local-only (loopback bind).------# Direct HTTP API — discover via `~/.adom/direct-api-port`--> **Port is no longer fixed.** As of v1.8.89, AD's direct API binds an OS-assigned ephemeral port whenever the fixed 47200..47209 range is unavailable (Windows HNS / Hyper-V silently reserves chunks of that range on most dev machines — see [PORTS.md](PORTS.md) for the full story). **Sibling apps MUST read the discovery file** `%USERPROFILE%\.adom\direct-api-port` (single-line `host:port`, e.g. `127.0.0.1:55272`) before any hardcoded probe. The legacy 47200..47209 scan stays as a back-compat fallback for older AD installs.--Old: hardcoded `:47200`--Sibling apps running on the same Windows host as adom-desktop can POST JSON commands directly into the running GUI process — no CLI process spawn, no WebSocket relay, no auth-token dance. The endpoint lives inside the GUI itself (loopback-only `127.0.0.1` bind, can't be reached off-box), uses the same dispatcher as the WebSocket path, and returns the same JSON shape — including every `_hint` field — that `adom-desktop <verb>` returns from the CLI.--This skill is for **sibling-app authors** (Hydrogen Desktop's Tauri side, plus any future "Adom-family" desktop app). Docker callers should continue using the CLI or the WS proxy — those paths handle cross-machine transport and binary streaming, which the direct API intentionally doesn't.--Shipped in adom-desktop v1.8.25+. Requires the adom-desktop GUI to be running on the host.--## When to use this (vs. the CLI)--| You are… | Use |-|---|---|-| A sibling Tauri app on the same Windows machine as adom-desktop, calling sync verbs like `server_add`, `bridge_list`, `hd_status`, `desktop_screenshot_window` | **Direct API.** One HTTP round-trip, ~5 ms, no process spawn. |-| Inside Hydrogen Desktop's local Docker container, using the `adom-desktop` CLI | **Just use the CLI.** As of v1.8.27 it auto-detects the direct API at `host.docker.internal:47200` on every invocation and routes there transparently. Zero config in Hydrogen's container. The verb returns identical JSON to the cross-machine path. |-| On the Windows host, running the `adom-desktop.exe` CLI directly | **Same — just use the CLI.** Auto-detects `127.0.0.1:47200` and routes through the direct API. ~60 ms warm per call vs ~150 ms for the relay path. |-| Docker / Linux / cross-machine caller | The CLI `adom-desktop <verb>` (probe fails, falls through to the wss proxy → relay → GUI WS path — same behavior as v1.8.26 and earlier). |-| A sibling app needing `pull_file`, `send_files`, or `shell_execute` | Spawn the CLI binary. The direct API refuses these with `errorCode:"cli_required"`. The CLI itself handles the fallback automatically when called. |--### CLI auto-route (v1.8.27+)--When the `adom-desktop` CLI binary runs in any of these contexts, it probes the direct API on the first verb invocation and caches the result for 30 seconds (in `/tmp/adom-direct-probe.json`):--- **Discovery file (v1.8.31+, fastest path):** `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`). Contents are `host:port` (e.g. `127.0.0.1:47201`) and the CLI tries this first if present. The GUI writes the file at bind time and removes it at graceful shutdown.-- **Probe order** (first 200 OK wins): `127.0.0.1:47200`, `host.docker.internal:47200`, `localhost:47200`-- **Fallback scan (v1.8.31+):** if the discovery file is missing/stale AND the default candidates all fail, the probe scans `127.0.0.1:47201`..`47209`. Cheap — each closed port refuses connection in <5 ms on Windows loopback.-- **Connect timeout**: 400 ms per candidate — fails fast when nothing's there-- **Override**: `$ADOM_DIRECT_URL=off` forces relay-only; `$ADOM_DIRECT_URL=http://…:47209` skips the probe entirely and uses that URL; `auto` (default) does the probe--The `ping` verb response now includes a `transport` field — `direct-http` or `relay-ws` — so you can confirm which path served the call. `pull_file`, `send_files`, and `shell_execute` always use the relay path (they have their own specialized streaming/approval flows). Everything else routes through the direct API when reachable.--### Port-conflict auto-recovery (v1.8.31+, ephemeral fallback in v1.8.89+)--The GUI never treats any individual port as fatal. Bind walk:--1. **Probe first**: connect to the candidate port and send a `GET /health`. If anything responds within 1 s, a LIVE process owns the port — skip rather than coexist (SO_REUSEADDR would let us bind too, but the kernel would route incoming connections nondeterministically between us and the other listener).-2. **Reuse-bind** with `SO_REUSEADDR` set via socket2 BEFORE bind. On Windows, this lets us claim a port held by a dead PID's zombie LISTEN socket — provided that prior process also used SO_REUSEADDR. (Pre-v1.8.31 zombies are stuck until reboot; v1.8.31+ zombies always release on the next launch.)-3. **Walk the range** `47200`..`47209` on three categorical errors: `AddrInUse` (10048), `PermissionDenied` (10013 — Windows' code for "can't take over a non-REUSEADDR zombie"), or live-owner skip.-4. **(NEW in v1.8.89) Ephemeral fallback**: if all of 47200..47209 fail (always WSAEADDRINUSE — which is what happens when HNS / Hyper-V has silently reserved the whole chunk; see [PORTS.md](PORTS.md) for why), bind `127.0.0.1:0` and let the OS pick. Whatever it picks goes to step 5. This case is COMMON on dev machines with Docker Desktop / WSL2 / Hyper-V VMs — the 47200..47299 chunk is reserved invisibly (doesn't show in netstat, doesn't show in excludedportrange, only fails-to-bind reveals it).-5. **Write the chosen port** to `~/.adom/direct-api-port` so callers don't have to scan.-6. **Report the bound address** in `/status.endpoint` — never a hardcoded constant.--This handles all five practical cases:-- **Zombie from v1.8.31+ adom-desktop** (was SO_REUSEADDR-bound) — new bind succeeds, takes over the same port.-- **Zombie from older code OR force-killed third-party** (exclusive bind) — WSAEACCES on bind; walk to next port.-- **Live owner** (Hydrogen Desktop's bridges, dev instance, anything responding) — probe detects, skip to next port.-- **Empty port** — bind cleanly.-- **HNS reserved the whole range** (v1.8.89+) — ephemeral fallback wins.--### Graceful shutdown (v1.8.31+)--When the GUI exits via the tray "Quit" menu (or any path that calls `app.exit(0)`), Tauri's `RunEvent::Exit` fires `direct_api::shutdown()` which:--1. Sends a `oneshot` signal to the axum server-2. `with_graceful_shutdown` stops accepting new connections and drains in-flight ones (~tens of ms)-3. The TCP listener drops, releasing the port back to the OS *immediately*-4. The discovery file is removed so callers don't connect to a port that's about to close--This prevents zombie sockets on **clean exits**. For **force-kill** scenarios (`taskkill /F`, crash, BSOD), zombies can still exist temporarily — but the v1.8.31+ reuse-bind pattern means the NEXT launch can take over the same port instead of having to walk past it. Two layers of defense.--## Endpoints--### `GET /health`--Cheap probe. Use to detect "is the GUI running" before falling through to the CLI fallback.--```bash-curl -sf http://127.0.0.1:47200/health-# → {"ok":true,"service":"adom-desktop"}-```--Returns 200 + JSON when the GUI is up and the listener bound successfully. Connection refused (or timeout) means: GUI not running OR the port was already taken by something else when the GUI started. The CLI binary's `serve` mode does NOT bind 47200 — only the GUI does.--### `GET /status`--Service banner + version + capability inventory. Read once on sibling-app startup to learn the verb surface and the `cliRequired` list.--```bash-curl -sf http://127.0.0.1:47200/status-```--```json-{-  "ok": true,-  "service": "adom-desktop",-  "version": "1.8.25",-  "schema": 1,-  "transport": "direct-http",-  "endpoint": "http://127.0.0.1:47200",-  "directApi": {-    "cliRequired": ["pull_file", "send_files", "shell_execute"],-    "note": "Everything else is safe via direct POST /command. The listed verbs use binary streaming or multi-minute approval flows; spawn the `adom-desktop` CLI binary for those.",-    "envelope": "{\"app\": <namespace>, \"command\": <verb>, \"args\": <args object>}",-    "responseShape": "Identical to what `adom-desktop <verb>` returns — same `_hint` fields, same `success`/`ok`/`error` keys, same payload structure. The CLI and direct paths converge in `commands::handle_command`."-  },-  "wsl": {-    "wslProcessCount": 18,-    "wedged": false,-    "_hint": null-  },-  "_hint": "POST /command with {app, command, args}. See https://wiki.adom.inc/adom/adom-desktop for the verb catalog."-}-```--The **`wsl` block (v1.8.157+)** surfaces WSL health: `wslProcessCount` is a cheap Toolhelp snapshot of live `wsl.exe`; `wedged:true` (at/above 50) warns that orphaned/hung clients are piling up. When wedged, `_hint` points to `wsl_recover`. If a `wsl_exec`/`run_script` call returns `errorCode:"wsl_wedged"` (refuse-cap at 80), the machine is wedging — call `wsl_recover` (dry-run, then `{confirm:true}`) before retrying, rather than spawning more doomed clients. `wsl_recover` is reachable via `POST /command {app:"desktop", command:"wsl_recover"}` (the `desktop_` prefix is optional).--The `schema` field is the contract version. v1 is the only one shipped. If `schema > 1` ever appears, expect a breaking change in the envelope/response shape and read this skill again.--### `GET /commands` (v1.8.156+)--Verb discovery — what `{app, command}` can a `/command` caller reach? A bridge delegating to AD no longer has to probe verb-by-verb and hit "Unknown desktop command".--```bash-curl -sf http://127.0.0.1:47200/commands-```--```json-{-  "ok": true,-  "desktop": {-    "app": "desktop",-    "commands": ["list_windows","find_window","screenshot_window","taskbar","flash_window","activity_filter","run_script","process_list", "..."],-    "note": "POST /command {app:\"desktop\", command}. The desktop_ prefix is OPTIONAL (desktop_taskbar == taskbar). Bad/missing args → the verb's _hint returns the arg schema."-  },-  "bridges": [{"name":"native-browser","verbPrefixes":["nbrowser_"],"verbs":["..."],"paused":false}],-  "cliRequired": ["pull_file","send_files","shell_execute"],-  "_hint": "Desktop verbs → app:\"desktop\" (prefix optional). Bridge verbs → app:\"dynamic\", command = full verb incl. prefix. cliRequired verbs need the CLI, not this HTTP API."-}-```--Pair it with the per-verb schema hints: call a verb with missing args and its `_hint` returns `required`/`optional`/`example`. Between `/commands` (what exists) and the bad-args hint (how to call it), a fresh AI needs no prior knowledge.--### `POST /command`--Dispatches a verb. Body envelope (`app` is OPTIONAL as of v1.9.84 — inferred from the command):--```json-{-  "command": "server_add",-  "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true }-}-```--#### Full verb surface + cross-AD (v1.9.84)--A caller (a **bridge** especially) reaches **every dispatchable verb** here, not just `desktop_*` — same dispatcher as the CLI/WS path, identical JSON back (`_hint`/`errorCode`/`statusVerb`). `GET /commands` lists the full set to capability-probe.--- **`app` is optional** — inferred (`kicad_*`→kicad, `fusion_*`→fusion360, `browser_*`→browser, `hd_*`→hydrogen, `aps_*`→aps, any installed bridge's prefix→dynamic, else desktop). Pass `app` explicitly to override.-- **Top-level verbs now reachable:** `notify_user` (toast → returns `{action:"displayed"}`), `notify_response`, `targets` (list the OTHER ADs on the relay), `ping` (this AD's liveness+version), plus `bridge_list`/`bridge_info`/`refresh_bridges`/`runtimes`/`status`.-- **`target` (cross-AD):** add `"target": "<clientName>"` (from `targets`) or `"all"` to route the call to a PEER AD via the relay — e.g. a bridge on a VM notifies the user on their laptop. `"self"`/`"local"`/absent = local. `"attended"` → `errorCode:attended_unresolved` (not built yet; use a concrete clientName).-- **`X-Adom-Bridge-Token` header = ATTRIBUTION only.** A bridge MAY send its spawn-time `ADOM_BRIDGE_TOKEN` env value; AD then badges the call "bridge" in the Activity Log. It is **not** an approval gate — a bridge is trusted-by-install (127.0.0.1-bound; PERMISSION_MODEL.md). A stale/wrong token → 403; absent → fine. The full surface (incl. `write_file`/`run_script`) runs ungated for any local caller; only the remote relay origin is approval-gated.--Example — a bridge fires a toast to the user's laptop while running on a VM:-```bash-curl -s -X POST "$ADOM_DIRECT_API_URL/command" -H 'Content-Type: application/json' \-  -H "X-Adom-Bridge-Token: $ADOM_BRIDGE_TOKEN" \-  -d '{"command":"notify_user","target":"AdomLapper","args":{"title":"Action needed","body":"Approve the Chrome dialog on your laptop"}}'-```--Plain single-AD envelope (unchanged, `app` still honored when provided):--```json-{-  "app": "desktop",-  "command": "server_add",-  "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true }-}-```--Response is the verb's normal payload (200 OK), e.g.:--```json-{-  "ok": true,-  "success": true,-  "name": "hydrogen-workspace",-  "url": "ws://localhost:8765",-  "id": "...",-  "connected": true,-  "created": true,-  "_hint": "Server registered. Relay commands for this container now route through adom-desktop. Use server_list to see all connections."-}-```--#### Error responses--| HTTP | When | Body shape |-|---|---|---|-| `400 Bad Request` | Envelope missing `app` or `command` | `{ok:false, error, _hint}` |-| `412 Precondition Failed` | Verb is in `cliRequired` list | `{ok:false, errorCode:"cli_required", _hint}` |-| `500 Internal Server Error` | Handler dropped the response channel (bug) | `{ok:false, errorCode:"handler_silent", _hint}` |-| `504 Gateway Timeout` | Handler didn't respond within the per-verb timeout (see below) | `{ok:false, errorCode:"timeout", _hint}` |--Every error carries an actionable `_hint` and (for non-400s) a stable `errorCode` string you can branch on.--#### Per-verb timeouts (v1.8.31+)--The 504 timeout is **per-verb**, not a hardcoded 120s ceiling:--| Verb category | Default timeout |-|---|---|-| `walk_cloud_tree`, `search_cloud_files` | **620 s** (10+ min — cloud trees can be hundreds of folders @ ~1 fps) |-| `export_step`/`iges`/`sat`/`stl`/`3mf`/`usdz`/`obj`/`f3d`/`fbx`/`skp`/`source`/`eagle_source` | 320 s |-| `export_gerbers` | 200 s |-| `export_dxf`/`dwg` | 140 s |-| `drc`, `erc` | 180 s |-| `bridge_install`, `fusion_start` | 300 s |-| Everything else | 120 s |--Override per-call by passing `args.timeout` (seconds), clamped to a 1800 s ceiling:--```json-{-  "app": "fusion",-  "command": "search_cloud_files",-  "args": {-    "query": "cosmiic",-    "recursive": true,-    "maxFolders": 1000,-    "searchTimeout": 1500,-    "timeout": 1700-  }-}-```--The CLI dispatcher honors `args.timeout` the same way, so the two paths stay symmetric.--## Update endpoints (v1.8.134+) — HD-driven embedded auto-update--AD owns its own updater (fetch → SHA-verify → stage). Hydrogen does **not** download or-install AD's bytes — it reads AD's update state and, at a safe moment, "clicks-AD's Update button" via `POST /update/apply`. The contract is intentionally tiny-and is meant to generalize to other HD-bundled children (e.g. the Adom screensaver):-`{ update_status, apply, busy }`.--### `GET /update/status`--```bash-curl -sf http://127.0.0.1:47200/update/status-```--```json-{-  "current_version": "1.8.133",-  "available_version": "1.8.134",-  "update_ready": true,-  "state": "staged",-  "busy": false-}-```--| Field | Meaning |-|---|---|-| `current_version` | The running AD version. Plain dotted semver; compare component-wise, numeric, never-downgrade. |-| `available_version` | Latest version AD knows about from its manifest (`null` until the first check). May equal `current_version` when on latest. |-| `update_ready` | `true` **only** when a newer installer is already downloaded **+ SHA256-verified + staged on disk** — appliable with **no further network**. Equivalent to `state == "staged"`. |-| `state` | One of `idle \| checking \| downloading \| staged \| applying`. |-| `busy` | `true` while AD is mid-operation it shouldn't be interrupted for — an in-flight screenshot, a file transfer, or a bridge (kicad/fusion/puppeteer) command awaiting its reply. **Hydrogen should defer the apply until `busy == false`.** (A screen-share/getDisplayMedia source is reserved for when AD adds that feature.) |--`state` machine: `idle` → `checking` (a manifest check is running) → `downloading`-(fetching + verifying the installer) → `staged` (verified on disk; `update_ready=true`)-→ `applying` (apply triggered; AD about to restart). A failed check never un-stages a-previously-staged update.--### `POST /update/apply`--Applies the already-staged update — the programmatic equivalent of the user clicking-"Update". No request body.--```bash-curl -sf -X POST http://127.0.0.1:47200/update/apply-```--```json-{ "ok": true, "applying": true }-```--- **Only ever applies what AD itself staged + verified.** If nothing is staged, returns-  HTTP 409 `{ "ok": false, "applying": false, "error": "no update staged …", "_hint": … }`.-  Call `POST /update/check` first and poll `/update/status` until `update_ready=true`.-- **Embedded-aware:** no modal / blocking UI. AD returns this JSON **promptly** (before it-  restarts), runs the staged installer, then exits cleanly ~1.5 s later.-- **Comes back in embedded mode automatically.** AD's embedded marker on disk persists-  across the restart (the exit hook only drains the direct-API socket — it doesn't delete-  the marker), so the relaunched AD re-enters embedded mode via the marker + the HD-liveness-  probe. Hydrogen's normal adopt (`POST /embedded/enter`) on the next supervisor tick re-asserts it-  idempotently.--#### Restart handshake — Hydrogen MUST suspend respawn for the apply window--Because the apply runs the NSIS installer **over the running AD exe**, Windows' mmap lock on-the running image means the file replacement only succeeds once AD has fully exited. **If Hydrogen's-supervisor respawns AD during the install window, it re-locks the exe and the replacement-silently no-ops** (AD comes back on the OLD version). So:--1. `GET /update/status` → wait for `update_ready == true && busy == false`.-2. `POST /update/apply` → on `{ applying: true }`, **stop respawning AD** (suspend the ~5 s-   health-respawn) for ~60 s.-3. AD exits → the staged installer replaces files → relaunches AD (installer POSTINSTALL).-   AD re-embeds via its marker.-4. Hydrogen polls `GET /update/status` (or `/health`/`/buildinfo`); when AD answers again with-   `current_version ==` the new version, resume normal supervision + re-adopt-   (`POST /embedded/enter`).--Hydrogen initiated the apply, so Hydrogen already knows the restart is intentional — treat AD's-disappearance in this window as expected, not a crash.--### `POST /update/check`--Force a manifest check now. If a newer version is found, AD stages it **in the background**;-this call returns the `/update/status` shape promptly (poll it until `staged`). No request body.--```bash-curl -sf -X POST http://127.0.0.1:47200/update/check-```--Returns the same JSON shape as `GET /update/status`.--### Standalone vs embedded--- **Standalone AD:** the normal user-facing "Update available → Update" footer button +-  flow are unchanged. `auto` mode still silently self-updates; `prompt` shows the banner;-  `off` disables checks (and staging).-- **Embedded AD:** AD checks + **stages** on its own (so `update_ready` becomes true for Hydrogen)-  but **never auto-applies** — Hydrogen owns the apply timing via `POST /update/apply`. The-  embedded apply path never pops its own restart UI.--## What the user sees in the GUI Activity Log--Each `POST /command` call shows up in adom-desktop's Activity Log panel with a `direct:47200` badge (the port-explicit "server name" the dispatcher attaches to direct-API calls) plus the verb's normal event tag (`desktop`, `kicad`, `hydrogen`, etc.). This is visually distinct from the `localhost` badge attached to WS-relay traffic going through the localhost dev server at `ws://localhost:8865`.--At startup, a single `direct-api` event-tagged entry says "direct-api listening on http://127.0.0.1:47200 (loopback-only). Sibling apps can POST /command without spawning the CLI." If the bind ever fails (port taken), the user sees a corresponding error entry — no silent failure.--## Trust model--- **Loopback-only bind.** The listener binds `127.0.0.1`, never `0.0.0.0`. Off-machine HTTP requests can't reach it.-- **No token required.** Anything on the user's box can send commands. The loopback bind is the boundary. If you need stricter isolation, the WS path's auth-token handshake is what to use.-- **Same verb surface as the WS path.** Every authorization check that lives inside a verb handler (shell auto-approve, file-path sandboxing, bridge-paused refusal) applies equally to direct calls — the dispatcher is the same code.--## Reference integration: Hydrogen Desktop startup--This is the canonical pattern Hydrogen uses to register its container relay with adom-desktop at boot.--### Rust (Hydrogen's `src-tauri/src/lib.rs` or equivalent)--```rust-use serde_json::json;-use std::time::Duration;--const ADOM_HEALTH: &str = "http://127.0.0.1:47200/health";-const ADOM_COMMAND: &str = "http://127.0.0.1:47200/command";--async fn register_with_adom_desktop(workspace_name: &str, relay_url: &str) -> Result<(), String> {-    // 1. Cheap probe — is adom-desktop running and listening?-    let client = reqwest::Client::builder()-        .timeout(Duration::from_secs(2))-        .build()-        .map_err(|e| format!("client init: {e}"))?;--    let healthy = client-        .get(ADOM_HEALTH)-        .send()-        .await-        .ok()-        .filter(|r| r.status().is_success())-        .is_some();--    if !healthy {-        // adom-desktop not running. Either tell the user to launch it,-        // or shell out to the CLI as the legacy fallback. Don't block-        // Hydrogen startup.-        log::warn!("adom-desktop not reachable on 127.0.0.1:47200 — relay will not be auto-registered");-        return Ok(());-    }--    // 2. POST the server_add command.-    let resp = client-        .post(ADOM_COMMAND)-        .timeout(Duration::from_secs(10))-        .json(&json!({-            "app": "desktop",-            "command": "server_add",-            "args": {-                "name": workspace_name,-                "url": relay_url,-                "autoConnect": true,-            }-        }))-        .send()-        .await-        .map_err(|e| format!("POST /command: {e}"))?;--    let body: serde_json::Value = resp.json().await.map_err(|e| format!("parse: {e}"))?;--    if body.get("ok").and_then(|v| v.as_bool()) == Some(true) {-        log::info!(-            "adom-desktop registered: name={workspace_name}, url={relay_url}, _hint={}",-            body.get("_hint").and_then(|v| v.as_str()).unwrap_or("")-        );-        Ok(())-    } else {-        Err(format!("adom-desktop refused: {body}"))-    }-}-```--### TypeScript (Hydrogen's frontend, if it ever calls directly)--```typescript-async function registerWithAdomDesktop(name: string, url: string): Promise<boolean> {-  // probe first-  const health = await fetch('http://127.0.0.1:47200/health', { signal: AbortSignal.timeout(2000) }).catch(() => null);-  if (!health?.ok) return false;--  const resp = await fetch('http://127.0.0.1:47200/command', {-    method: 'POST',-    headers: { 'Content-Type': 'application/json' },-    signal: AbortSignal.timeout(10_000),-    body: JSON.stringify({-      app: 'desktop',-      command: 'server_add',-      args: { name, url, autoConnect: true },-    }),-  });-  const body = await resp.json();-  return body.ok === true;-}-```--## Cleanup at shutdown--Mirror the registration with a `server_remove`:--```bash-curl -X POST http://127.0.0.1:47200/command \-  -H 'Content-Type: application/json' \-  -d '{"app":"desktop","command":"server_remove","args":{"name":"hydrogen-workspace"}}'-```--If adom-desktop has already exited, the connection refuses — that's fine, the server entry is stale either way. Treat shutdown registration cleanup as best-effort.--## Embedded-mode integration (Hydrogen bundling AD, v1.8.42+)--When Hydrogen bundles AD, the direct API is the channel for Hydrogen's tray menu items and runtime control. Hydrogen spawns AD with `--embedded --start-hidden --relay-url ... --session-token ...`, then drives it via these direct-API calls:--| Hydrogen action | direct-API envelope |-|---|---|-| "Open Adom Desktop" tray menu item | `{app:"desktop", command:"window_show"}` |-| Hide AD's window again | `{app:"desktop", command:"window_hide"}` |-| "Connect All" / "Disconnect All" tray items | `{app:"desktop", command:"connect_all"}` / `disconnect_all` |-| Hydrogen's "Quit" menu (cascade-stops AD) | `{app:"desktop", command:"shutdown"}` |-| Hydrogen sign-out propagation | `{app:"desktop", command:"logout"}` |-| Introspect embedded state | `{app:"desktop", command:"embedded_status"}` |-| Force permanent shell auto-approve | `{app:"desktop", command:"set_shell_auto_approve", args:{permanent:true}}` |--`embedded_status` returns `{embedded, owner, source, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` — useful for Hydrogen's tray to know whether AD already booted embedded or it's running standalone.--```typescript-// Hydrogen's tray-click handler — example-async function openAdomDesktop() {-  await fetch('http://127.0.0.1:47200/command', {-    method: 'POST',-    headers: { 'Content-Type': 'application/json' },-    body: JSON.stringify({ app: 'desktop', command: 'window_show' }),-  });-}-```--Note: Hydrogen typically calls `set_shell_auto_approve {permanent: true}` only as a recovery path — AD already defaults to permanent at embedded boot. Use it when toggling user-controlled preferences (e.g. an Hydrogen setting like "Auto-approve AD shell commands ON/OFF").--## What about verbs not in the directApi list?--The `cliRequired` array in `GET /status` lists the verbs that need the CLI binary. Anything not in that list works over the direct API. The dispatcher is the same code; if a verb works via `adom-desktop <verb>` it works via direct POST.--### Mapping CLI verb → direct-API envelope--**The prefix-stripping rule varies per namespace.** Easiest mental model: look at the CLI's `cli/src/commands.rs` dispatch line for the verb you want. Whatever string it passes as the second arg of `relay::desktop_command(app, command, ...)` is exactly what your direct-API envelope's `command` field should be.--**v1.8.153+: for `app:"desktop"` the `desktop_` prefix is OPTIONAL — POST the public verb name as-is.** A bridge delegating via `ADOM_DIRECT_API_URL/command` can send the exact verb it knows (`desktop_taskbar`, `desktop_flash_window`, `desktop_list_windows`, `desktop_find_window`, `desktop_screenshot_window`, `desktop_record_window_start/stop/status`) OR the stripped form (`taskbar`, …) — both dispatch. (Before 1.8.153 the dispatch arms were unprefixed, so `desktop_taskbar` returned "Unknown desktop command" while `run_script` worked — that asymmetry is gone.)--| `app` value | What goes in `command` | Examples (CLI verb → direct envelope) |-|---|---|---|-| `desktop` | public verb name; `desktop_` prefix optional (v1.8.153+) | `desktop_taskbar` → `{app:"desktop", command:"desktop_taskbar"}` (or `"taskbar"`); `server_add` → `{app:"desktop", command:"server_add"}` |-| `kicad` | **stripped** — drop the `kicad_` prefix | `kicad_open_board` → `{app:"kicad", command:"open_board"}` |-| `fusion360` | **stripped** — drop the `fusion_` prefix | `fusion_start` → `{app:"fusion360", command:"launch"}` (note: also the verb name shifts internally) |-| `browser` | **kept** — pup bridge dispatches on the full name | `browser_open_window` → `{app:"browser", command:"browser_open_window"}` |-| `hydrogen` | **stripped** — drop the `hd_` prefix | `hd_status` → `{app:"hydrogen", command:"status"}` |-| `dynamic` | full verb name with third-party bridge prefix preserved | (dispatcher routes by prefix) |--Common direct-safe verb groups:--| Namespace | Examples | Notes |-|---|---|---|-| `desktop` | `server_add`, `server_remove`, `server_list`, `bridge_list`, `bridge_install`, `bridge_pause`, `bridge_resume`, `desktop_list_windows`, `desktop_screenshot_window`, `desktop_open_url`, `set_shell_auto_approve` | All sync, no prefix stripping. |-| `hydrogen` | `status`, `eval`, `log`, `screenshot`, `build`, `build_frontend`, `build_rust`, `build_status`, `build_log`, `build_tail`, `launch`, `stop`, `restart` | `build*` returns instantly with `{pid, logPath}`; poll `build_status` or `build_tail` for progress. |-| `kicad` | `list_versions`, `open_board`, `open_schematic`, `run_drc`, `lint_board`, `install_symbol`, ... | Multi-version-aware. |-| `fusion360` | `launch`, `export_step`, `walk_cloud_tree`, `window_info`, `get_app_state`, ... | 50+ verbs. |-| `browser` | `browser_open_window`, `browser_navigate`, `browser_screenshot`, `browser_eval`, `browser_click`, `browser_record_start` | Per-tab. **Keep the prefix.** |--## Failure modes & retry--The direct API doesn't auto-retry. Sibling apps should:--1. **Probe `/health` before any `/command`** so a missing GUI is a clean "not available" branch, not a noisy 10-second timeout on every dispatch.-2. **Per-request timeouts ≥ the verb's natural duration.** Most verbs are sub-second; `hd_build*` returns in <2 s; `bridge_install` can be 30 s for a large zip. Pick the timeout based on the verb you're calling.-3. **Treat 504 timeouts as "unknown state, ask later"** rather than retrying blindly. A successful retry on a 504 might double-register a server or double-install a bridge.-4. **Read the `_hint` and `errorCode` fields on every error response.** They're machine-friendly: `cli_required`, `timeout`, `handler_silent`, plus whatever the verb's handler emits (`bridge_not_found`, `binary_missing`, etc.).--## Version skew--If `GET /status` returns `version < 1.8.25` OR fails entirely OR `schema` is missing, the direct API isn't present (older GUI). Fall back to spawning the CLI binary. The CLI has been the supported integration path since v1.7.x; everything that works via the direct API also works via the CLI, just slower.--## Why a separate port from the WS relay's 8766?--- **Different transport, different trust model.** 8766 is the relay's HTTP endpoint, which goes through a WS bridge to the GUI and gates non-CLI User-Agents. 47200 is direct to the GUI, no auth, loopback-only.-- **Different process lifecycle.** 8766 needs `adom-desktop serve` running (a separate process). 47200 is inside the GUI — if the GUI is running, 47200 is up.-- **No risk of breaking Docker.** Docker callers keep using 8766 + the WS proxy. Sibling apps get 47200. They don't fight for the same port or auth scheme.--## See also--- [`README.md`](../README.md) — feature list-- [`skills/SKILL.md`](SKILL.md) — full verb catalog (the entries Docker callers read)-- [`src-tauri/src/direct_api.rs`](../src-tauri/src/direct_api.rs) — implementation-- [Adom Wiki — apps/adom-desktop](https://wiki.adom.inc/adom/adom-desktop) — public verb reference+---
+name: adom-desktop-direct-api
+description: Direct HTTP API on the adom-desktop GUI. Use when authoring a sibling Tauri app (Hydrogen Desktop, future Adom-family apps) on the same Windows machine that needs to send commands into adom-desktop without spawning the CLI binary or going through the WebSocket relay. Local-only (loopback bind).
+---
+
+# Direct HTTP API — discover via `~/.adom/direct-api-port`
+
+> **Port is no longer fixed.** As of v1.8.89, AD's direct API binds an OS-assigned ephemeral port whenever the fixed 47200..47209 range is unavailable (Windows HNS / Hyper-V silently reserves chunks of that range on most dev machines — see [PORTS.md](PORTS.md) for the full story). **Sibling apps MUST read the discovery file** `%USERPROFILE%\.adom\direct-api-port` (single-line `host:port`, e.g. `127.0.0.1:55272`) before any hardcoded probe. The legacy 47200..47209 scan stays as a back-compat fallback for older AD installs.
+
+Old: hardcoded `:47200`
+
+Sibling apps running on the same Windows host as adom-desktop can POST JSON commands directly into the running GUI process — no CLI process spawn, no WebSocket relay, no auth-token dance. The endpoint lives inside the GUI itself (loopback-only `127.0.0.1` bind, can't be reached off-box), uses the same dispatcher as the WebSocket path, and returns the same JSON shape — including every `_hint` field — that `adom-desktop <verb>` returns from the CLI.
+
+This skill is for **sibling-app authors** (Hydrogen Desktop's Tauri side, plus any future "Adom-family" desktop app). Docker callers should continue using the CLI or the WS proxy — those paths handle cross-machine transport and binary streaming, which the direct API intentionally doesn't.
+
+Shipped in adom-desktop v1.8.25+. Requires the adom-desktop GUI to be running on the host.
+
+## When to use this (vs. the CLI)
+
+| You are… | Use |
+|---|---|
+| A sibling Tauri app on the same Windows machine as adom-desktop, calling sync verbs like `server_add`, `bridge_list`, `hd_status`, `desktop_screenshot_window` | **Direct API.** One HTTP round-trip, ~5 ms, no process spawn. |
+| Inside Hydrogen Desktop's local Docker container, using the `adom-desktop` CLI | **Just use the CLI.** As of v1.8.27 it auto-detects the direct API at `host.docker.internal:47200` on every invocation and routes there transparently. Zero config in HD's container. The verb returns identical JSON to the cross-machine path. |
+| On the Windows host, running the `adom-desktop.exe` CLI directly | **Same — just use the CLI.** Auto-detects `127.0.0.1:47200` and routes through the direct API. ~60 ms warm per call vs ~150 ms for the relay path. |
+| Docker / Linux / cross-machine caller | The CLI `adom-desktop <verb>` (probe fails, falls through to the wss proxy → relay → GUI WS path — same behavior as v1.8.26 and earlier). |
+| A sibling app needing `pull_file`, `send_files`, or `shell_execute` | Spawn the CLI binary. The direct API refuses these with `errorCode:"cli_required"`. The CLI itself handles the fallback automatically when called. |
+
+### CLI auto-route (v1.8.27+)
+
+When the `adom-desktop` CLI binary runs in any of these contexts, it probes the direct API on the first verb invocation and caches the result for 30 seconds (in `/tmp/adom-direct-probe.json`):
+
+- **Discovery file (v1.8.31+, fastest path):** `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`). Contents are `host:port` (e.g. `127.0.0.1:47201`) and the CLI tries this first if present. The GUI writes the file at bind time and removes it at graceful shutdown.
+- **Probe order** (first 200 OK wins): `127.0.0.1:47200`, `host.docker.internal:47200`, `localhost:47200`
+- **Fallback scan (v1.8.31+):** if the discovery file is missing/stale AND the default candidates all fail, the probe scans `127.0.0.1:47201`..`47209`. Cheap — each closed port refuses connection in <5 ms on Windows loopback.
+- **Connect timeout**: 400 ms per candidate — fails fast when nothing's there
+- **Override**: `$ADOM_DIRECT_URL=off` forces relay-only; `$ADOM_DIRECT_URL=http://…:47209` skips the probe entirely and uses that URL; `auto` (default) does the probe
+
+The `ping` verb response now includes a `transport` field — `direct-http` or `relay-ws` — so you can confirm which path served the call. `pull_file`, `send_files`, and `shell_execute` always use the relay path (they have their own specialized streaming/approval flows). Everything else routes through the direct API when reachable.
+
+### Port-conflict auto-recovery (v1.8.31+, ephemeral fallback in v1.8.89+)
+
+The GUI never treats any individual port as fatal. Bind walk:
+
+1. **Probe first**: connect to the candidate port and send a `GET /health`. If anything responds within 1 s, a LIVE process owns the port — skip rather than coexist (SO_REUSEADDR would let us bind too, but the kernel would route incoming connections nondeterministically between us and the other listener).
+2. **Reuse-bind** with `SO_REUSEADDR` set via socket2 BEFORE bind. On Windows, this lets us claim a port held by a dead PID's zombie LISTEN socket — provided that prior process also used SO_REUSEADDR. (Pre-v1.8.31 zombies are stuck until reboot; v1.8.31+ zombies always release on the next launch.)
+3. **Walk the range** `47200`..`47209` on three categorical errors: `AddrInUse` (10048), `PermissionDenied` (10013 — Windows' code for "can't take over a non-REUSEADDR zombie"), or live-owner skip.
+4. **(NEW in v1.8.89) Ephemeral fallback**: if all of 47200..47209 fail (always WSAEADDRINUSE — which is what happens when HNS / Hyper-V has silently reserved the whole chunk; see [PORTS.md](PORTS.md) for why), bind `127.0.0.1:0` and let the OS pick. Whatever it picks goes to step 5. This case is COMMON on dev machines with Docker Desktop / WSL2 / Hyper-V VMs — the 47200..47299 chunk is reserved invisibly (doesn't show in netstat, doesn't show in excludedportrange, only fails-to-bind reveals it).
+5. **Write the chosen port** to `~/.adom/direct-api-port` so callers don't have to scan.
+6. **Report the bound address** in `/status.endpoint` — never a hardcoded constant.
+
+This handles all five practical cases:
+- **Zombie from v1.8.31+ adom-desktop** (was SO_REUSEADDR-bound) — new bind succeeds, takes over the same port.
+- **Zombie from older code OR force-killed third-party** (exclusive bind) — WSAEACCES on bind; walk to next port.
+- **Live owner** (Hydrogen Desktop's bridges, dev instance, anything responding) — probe detects, skip to next port.
+- **Empty port** — bind cleanly.
+- **HNS reserved the whole range** (v1.8.89+) — ephemeral fallback wins.
+
+### Graceful shutdown (v1.8.31+)
+
+When the GUI exits via the tray "Quit" menu (or any path that calls `app.exit(0)`), Tauri's `RunEvent::Exit` fires `direct_api::shutdown()` which:
+
+1. Sends a `oneshot` signal to the axum server
+2. `with_graceful_shutdown` stops accepting new connections and drains in-flight ones (~tens of ms)
+3. The TCP listener drops, releasing the port back to the OS *immediately*
+4. The discovery file is removed so callers don't connect to a port that's about to close
+
+This prevents zombie sockets on **clean exits**. For **force-kill** scenarios (`taskkill /F`, crash, BSOD), zombies can still exist temporarily — but the v1.8.31+ reuse-bind pattern means the NEXT launch can take over the same port instead of having to walk past it. Two layers of defense.
+
+## Endpoints
+
+### `GET /health`
+
+Cheap probe. Use to detect "is the GUI running" before falling through to the CLI fallback.
+
+```bash
+curl -sf http://127.0.0.1:47200/health
+# → {"ok":true,"service":"adom-desktop"}
+```
+
+Returns 200 + JSON when the GUI is up and the listener bound successfully. Connection refused (or timeout) means: GUI not running OR the port was already taken by something else when the GUI started. The CLI binary's `serve` mode does NOT bind 47200 — only the GUI does.
+
+### `GET /status`
+
+Service banner + version + capability inventory. Read once on sibling-app startup to learn the verb surface and the `cliRequired` list.
+
+```bash
+curl -sf http://127.0.0.1:47200/status
+```
+
+```json
+{
+  "ok": true,
+  "service": "adom-desktop",
+  "version": "1.8.25",
+  "schema": 1,
+  "transport": "direct-http",
+  "endpoint": "http://127.0.0.1:47200",
+  "directApi": {
+    "cliRequired": ["pull_file", "send_files", "shell_execute"],
+    "note": "Everything else is safe via direct POST /command. The listed verbs use binary streaming or multi-minute approval flows; spawn the `adom-desktop` CLI binary for those.",
+    "envelope": "{\"app\": <namespace>, \"command\": <verb>, \"args\": <args object>}",
+    "responseShape": "Identical to what `adom-desktop <verb>` returns — same `_hint` fields, same `success`/`ok`/`error` keys, same payload structure. The CLI and direct paths converge in `commands::handle_command`."
+  },
+  "wsl": {
+    "wslProcessCount": 18,
+    "wedged": false,
+    "_hint": null
+  },
+  "_hint": "POST /command with {app, command, args}. See https://wiki.adom.inc/adom/adom-desktop for the verb catalog."
+}
+```
+
+The **`wsl` block (v1.8.157+)** surfaces WSL health: `wslProcessCount` is a cheap Toolhelp snapshot of live `wsl.exe`; `wedged:true` (at/above 50) warns that orphaned/hung clients are piling up. When wedged, `_hint` points to `wsl_recover`. If a `wsl_exec`/`run_script` call returns `errorCode:"wsl_wedged"` (refuse-cap at 80), the machine is wedging — call `wsl_recover` (dry-run, then `{confirm:true}`) before retrying, rather than spawning more doomed clients. `wsl_recover` is reachable via `POST /command {app:"desktop", command:"wsl_recover"}` (the `desktop_` prefix is optional).
+
+The `schema` field is the contract version. v1 is the only one shipped. If `schema > 1` ever appears, expect a breaking change in the envelope/response shape and read this skill again.
+
+### `GET /commands` (v1.8.156+)
+
+Verb discovery — what `{app, command}` can a `/command` caller reach? A bridge delegating to AD no longer has to probe verb-by-verb and hit "Unknown desktop command".
+
+```bash
+curl -sf http://127.0.0.1:47200/commands
+```
+
+```json
+{
+  "ok": true,
+  "desktop": {
+    "app": "desktop",
+    "commands": ["list_windows","find_window","screenshot_window","taskbar","flash_window","activity_filter","run_script","process_list", "..."],
+    "note": "POST /command {app:\"desktop\", command}. The desktop_ prefix is OPTIONAL (desktop_taskbar == taskbar). Bad/missing args → the verb's _hint returns the arg schema."
+  },
+  "bridges": [{"name":"native-browser","verbPrefixes":["nbrowser_"],"verbs":["..."],"paused":false}],
+  "cliRequired": ["pull_file","send_files","shell_execute"],
+  "_hint": "Desktop verbs → app:\"desktop\" (prefix optional). Bridge verbs → app:\"dynamic\", command = full verb incl. prefix. cliRequired verbs need the CLI, not this HTTP API."
+}
+```
+
+Pair it with the per-verb schema hints: call a verb with missing args and its `_hint` returns `required`/`optional`/`example`. Between `/commands` (what exists) and the bad-args hint (how to call it), a fresh AI needs no prior knowledge.
+
+### `POST /command`
+
+Dispatches a verb. Body envelope (`app` is OPTIONAL as of v1.9.84 — inferred from the command):
+
+```json
+{
+  "command": "server_add",
+  "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true }
+}
+```
+
+#### Full verb surface + cross-AD (v1.9.84)
+
+A caller (a **bridge** especially) reaches **every dispatchable verb** here, not just `desktop_*` — same dispatcher as the CLI/WS path, identical JSON back (`_hint`/`errorCode`/`statusVerb`). `GET /commands` lists the full set to capability-probe.
+
+- **`app` is optional** — inferred (`kicad_*`→kicad, `fusion_*`→fusion360, `browser_*`→browser, `hd_*`→hd, `aps_*`→aps, any installed bridge's prefix→dynamic, else desktop). Pass `app` explicitly to override.
+- **Top-level verbs now reachable:** `notify_user` (toast → returns `{action:"displayed"}`), `notify_response`, `targets` (list the OTHER ADs on the relay), `ping` (this AD's liveness+version), plus `bridge_list`/`bridge_info`/`refresh_bridges`/`runtimes`/`status`.
+- **`target` (cross-AD):** add `"target": "<clientName>"` (from `targets`) or `"all"` to route the call to a PEER AD via the relay — e.g. a bridge on a VM notifies the user on their laptop. `"self"`/`"local"`/absent = local. `"attended"` → `errorCode:attended_unresolved` (not built yet; use a concrete clientName).
+- **`X-Adom-Bridge-Token` header = ATTRIBUTION only.** A bridge MAY send its spawn-time `ADOM_BRIDGE_TOKEN` env value; AD then badges the call "bridge" in the Activity Log. It is **not** an approval gate — a bridge is trusted-by-install (127.0.0.1-bound; PERMISSION_MODEL.md). A stale/wrong token → 403; absent → fine. The full surface (incl. `write_file`/`run_script`) runs ungated for any local caller; only the remote relay origin is approval-gated.
+
+Example — a bridge fires a toast to the user's laptop while running on a VM:
+```bash
+curl -s -X POST "$ADOM_DIRECT_API_URL/command" -H 'Content-Type: application/json' \
+  -H "X-Adom-Bridge-Token: $ADOM_BRIDGE_TOKEN" \
+  -d '{"command":"notify_user","target":"AdomLapper","args":{"title":"Action needed","body":"Approve the Chrome dialog on your laptop"}}'
+```
+
+Plain single-AD envelope (unchanged, `app` still honored when provided):
+
+```json
+{
+  "app": "desktop",
+  "command": "server_add",
+  "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true }
+}
+```
+
+Response is the verb's normal payload (200 OK), e.g.:
+
+```json
+{
+  "ok": true,
+  "success": true,
+  "name": "hydrogen-workspace",
+  "url": "ws://localhost:8765",
+  "id": "...",
+  "connected": true,
+  "created": true,
+  "_hint": "Server registered. Relay commands for this container now route through adom-desktop. Use server_list to see all connections."
+}
+```
+
+#### Error responses
+
+| HTTP | When | Body shape |
+|---|---|---|
+| `400 Bad Request` | Envelope missing `app` or `command` | `{ok:false, error, _hint}` |
+| `412 Precondition Failed` | Verb is in `cliRequired` list | `{ok:false, errorCode:"cli_required", _hint}` |
+| `500 Internal Server Error` | Handler dropped the response channel (bug) | `{ok:false, errorCode:"handler_silent", _hint}` |
+| `504 Gateway Timeout` | Handler didn't respond within the per-verb timeout (see below) | `{ok:false, errorCode:"timeout", _hint}` |
+
+Every error carries an actionable `_hint` and (for non-400s) a stable `errorCode` string you can branch on.
+
+#### Per-verb timeouts (v1.8.31+)
+
+The 504 timeout is **per-verb**, not a hardcoded 120s ceiling:
+
+| Verb category | Default timeout |
+|---|---|
+| `walk_cloud_tree`, `search_cloud_files` | **620 s** (10+ min — cloud trees can be hundreds of folders @ ~1 fps) |
+| `export_step`/`iges`/`sat`/`stl`/`3mf`/`usdz`/`obj`/`f3d`/`fbx`/`skp`/`source`/`eagle_source` | 320 s |
+| `export_gerbers` | 200 s |
+| `export_dxf`/`dwg` | 140 s |
+| `drc`, `erc` | 180 s |
+| `bridge_install`, `fusion_start` | 300 s |
+| Everything else | 120 s |
+
+Override per-call by passing `args.timeout` (seconds), clamped to a 1800 s ceiling:
+
+```json
+{
+  "app": "fusion",
+  "command": "search_cloud_files",
+  "args": {
+    "query": "cosmiic",
+    "recursive": true,
+    "maxFolders": 1000,
+    "searchTimeout": 1500,
+    "timeout": 1700
+  }
+}
+```
+
+The CLI dispatcher honors `args.timeout` the same way, so the two paths stay symmetric.
+
+## Update endpoints (v1.8.134+) — HD-driven embedded auto-update
+
+AD owns its own updater (fetch → SHA-verify → stage). HD does **not** download or
+install AD's bytes — it reads AD's update state and, at a safe moment, "clicks
+AD's Update button" via `POST /update/apply`. The contract is intentionally tiny
+and is meant to generalize to other HD-bundled children (e.g. the Adom screensaver):
+`{ update_status, apply, busy }`.
+
+### `GET /update/status`
+
+```bash
+curl -sf http://127.0.0.1:47200/update/status
+```
+
+```json
+{
+  "current_version": "1.8.133",
+  "available_version": "1.8.134",
+  "update_ready": true,
+  "state": "staged",
+  "busy": false
+}
+```
+
+| Field | Meaning |
+|---|---|
+| `current_version` | The running AD version. Plain dotted semver; compare component-wise, numeric, never-downgrade. |
+| `available_version` | Latest version AD knows about from its manifest (`null` until the first check). May equal `current_version` when on latest. |
+| `update_ready` | `true` **only** when a newer installer is already downloaded **+ SHA256-verified + staged on disk** — appliable with **no further network**. Equivalent to `state == "staged"`. |
+| `state` | One of `idle \| checking \| downloading \| staged \| applying`. |
+| `busy` | `true` while AD is mid-operation it shouldn't be interrupted for — an in-flight screenshot, a file transfer, or a bridge (kicad/fusion/puppeteer) command awaiting its reply. **HD should defer the apply until `busy == false`.** (A screen-share/getDisplayMedia source is reserved for when AD adds that feature.) |
+
+`state` machine: `idle` → `checking` (a manifest check is running) → `downloading`
+(fetching + verifying the installer) → `staged` (verified on disk; `update_ready=true`)
+→ `applying` (apply triggered; AD about to restart). A failed check never un-stages a
+previously-staged update.
+
+### `POST /update/apply`
+
+Applies the already-staged update — the programmatic equivalent of the user clicking
+"Update". No request body.
+
+```bash
+curl -sf -X POST http://127.0.0.1:47200/update/apply
+```
+
+```json
+{ "ok": true, "applying": true }
+```
+
+- **Only ever applies what AD itself staged + verified.** If nothing is staged, returns
+  HTTP 409 `{ "ok": false, "applying": false, "error": "no update staged …", "_hint": … }`.
+  Call `POST /update/check` first and poll `/update/status` until `update_ready=true`.
+- **Embedded-aware:** no modal / blocking UI. AD returns this JSON **promptly** (before it
+  restarts), runs the staged installer, then exits cleanly ~1.5 s later.
+- **Comes back in embedded mode automatically.** AD's embedded marker on disk persists
+  across the restart (the exit hook only drains the direct-API socket — it doesn't delete
+  the marker), so the relaunched AD re-enters embedded mode via the marker + the HD-liveness
+  probe. HD's normal adopt (`POST /embedded/enter`) on the next supervisor tick re-asserts it
+  idempotently.
+
+#### Restart handshake — HD MUST suspend respawn for the apply window
+
+Because the apply runs the NSIS installer **over the running AD exe**, Windows' mmap lock on
+the running image means the file replacement only succeeds once AD has fully exited. **If HD's
+supervisor respawns AD during the install window, it re-locks the exe and the replacement
+silently no-ops** (AD comes back on the OLD version). So:
+
+1. `GET /update/status` → wait for `update_ready == true && busy == false`.
+2. `POST /update/apply` → on `{ applying: true }`, **stop respawning AD** (suspend the ~5 s
+   health-respawn) for ~60 s.
+3. AD exits → the staged installer replaces files → relaunches AD (installer POSTINSTALL).
+   AD re-embeds via its marker.
+4. HD polls `GET /update/status` (or `/health`/`/buildinfo`); when AD answers again with
+   `current_version ==` the new version, resume normal supervision + re-adopt
+   (`POST /embedded/enter`).
+
+HD initiated the apply, so HD already knows the restart is intentional — treat AD's
+disappearance in this window as expected, not a crash.
+
+### `POST /update/check`
+
+Force a manifest check now. If a newer version is found, AD stages it **in the background**;
+this call returns the `/update/status` shape promptly (poll it until `staged`). No request body.
+
+```bash
+curl -sf -X POST http://127.0.0.1:47200/update/check
+```
+
+Returns the same JSON shape as `GET /update/status`.
+
+### Standalone vs embedded
+
+- **Standalone AD:** the normal user-facing "Update available → Update" footer button +
+  flow are unchanged. `auto` mode still silently self-updates; `prompt` shows the banner;
+  `off` disables checks (and staging).
+- **Embedded AD:** AD checks + **stages** on its own (so `update_ready` becomes true for HD)
+  but **never auto-applies** — HD owns the apply timing via `POST /update/apply`. The
+  embedded apply path never pops its own restart UI.
+
+## What the user sees in the GUI Activity Log
+
+Each `POST /command` call shows up in adom-desktop's Activity Log panel with a `direct:47200` badge (the port-explicit "server name" the dispatcher attaches to direct-API calls) plus the verb's normal event tag (`desktop`, `kicad`, `hd`, etc.). This is visually distinct from the `localhost` badge attached to WS-relay traffic going through the localhost dev server at `ws://localhost:8865`.
+
+At startup, a single `direct-api` event-tagged entry says "direct-api listening on http://127.0.0.1:47200 (loopback-only). Sibling apps can POST /command without spawning the CLI." If the bind ever fails (port taken), the user sees a corresponding error entry — no silent failure.
+
+## Trust model
+
+- **Loopback-only bind.** The listener binds `127.0.0.1`, never `0.0.0.0`. Off-machine HTTP requests can't reach it.
+- **No token required.** Anything on the user's box can send commands. The loopback bind is the boundary. If you need stricter isolation, the WS path's auth-token handshake is what to use.
+- **Same verb surface as the WS path.** Every authorization check that lives inside a verb handler (shell auto-approve, file-path sandboxing, bridge-paused refusal) applies equally to direct calls — the dispatcher is the same code.
+
+## Reference integration: Hydrogen Desktop startup
+
+This is the canonical pattern HD uses to register its container relay with adom-desktop at boot.
+
+### Rust (HD's `src-tauri/src/lib.rs` or equivalent)
+
+```rust
+use serde_json::json;
+use std::time::Duration;
+
+const ADOM_HEALTH: &str = "http://127.0.0.1:47200/health";
+const ADOM_COMMAND: &str = "http://127.0.0.1:47200/command";
+
+async fn register_with_adom_desktop(workspace_name: &str, relay_url: &str) -> Result<(), String> {
+    // 1. Cheap probe — is adom-desktop running and listening?
+    let client = reqwest::Client::builder()
+        .timeout(Duration::from_secs(2))
+        .build()
+        .map_err(|e| format!("client init: {e}"))?;
+
+    let healthy = client
+        .get(ADOM_HEALTH)
+        .send()
+        .await
+        .ok()
+        .filter(|r| r.status().is_success())
+        .is_some();
+
+    if !healthy {
+        // adom-desktop not running. Either tell the user to launch it,
+        // or shell out to the CLI as the legacy fallback. Don't block
+        // HD startup.
+        log::warn!("adom-desktop not reachable on 127.0.0.1:47200 — relay will not be auto-registered");
+        return Ok(());
+    }
+
+    // 2. POST the server_add command.
+    let resp = client
+        .post(ADOM_COMMAND)
+        .timeout(Duration::from_secs(10))
+        .json(&json!({
+            "app": "desktop",
+            "command": "server_add",
+            "args": {
+                "name": workspace_name,
+                "url": relay_url,
+                "autoConnect": true,
+            }
+        }))
+        .send()
+        .await
+        .map_err(|e| format!("POST /command: {e}"))?;
+
+    let body: serde_json::Value = resp.json().await.map_err(|e| format!("parse: {e}"))?;
+
+    if body.get("ok").and_then(|v| v.as_bool()) == Some(true) {
+        log::info!(
+            "adom-desktop registered: name={workspace_name}, url={relay_url}, _hint={}",
+            body.get("_hint").and_then(|v| v.as_str()).unwrap_or("")
+        );
+        Ok(())
+    } else {
+        Err(format!("adom-desktop refused: {body}"))
+    }
+}
+```
+
+### TypeScript (HD's frontend, if it ever calls directly)
+
+```typescript
+async function registerWithAdomDesktop(name: string, url: string): Promise<boolean> {
+  // probe first
+  const health = await fetch('http://127.0.0.1:47200/health', { signal: AbortSignal.timeout(2000) }).catch(() => null);
+  if (!health?.ok) return false;
+
+  const resp = await fetch('http://127.0.0.1:47200/command', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    signal: AbortSignal.timeout(10_000),
+    body: JSON.stringify({
+      app: 'desktop',
+      command: 'server_add',
+      args: { name, url, autoConnect: true },
+    }),
+  });
+  const body = await resp.json();
+  return body.ok === true;
+}
+```
+
+## Cleanup at shutdown
+
+Mirror the registration with a `server_remove`:
+
+```bash
+curl -X POST http://127.0.0.1:47200/command \
+  -H 'Content-Type: application/json' \
+  -d '{"app":"desktop","command":"server_remove","args":{"name":"hydrogen-workspace"}}'
+```
+
+If adom-desktop has already exited, the connection refuses — that's fine, the server entry is stale either way. Treat shutdown registration cleanup as best-effort.
+
+## Embedded-mode integration (HD bundling AD, v1.8.42+)
+
+When HD bundles AD, the direct API is the channel for HD's tray menu items and runtime control. HD spawns AD with `--embedded --start-hidden --relay-url ... --session-token ...`, then drives it via these direct-API calls:
+
+| HD action | direct-API envelope |
+|---|---|
+| "Open Adom Desktop" tray menu item | `{app:"desktop", command:"window_show"}` |
+| Hide AD's window again | `{app:"desktop", command:"window_hide"}` |
+| "Connect All" / "Disconnect All" tray items | `{app:"desktop", command:"connect_all"}` / `disconnect_all` |
+| HD's "Quit" menu (cascade-stops AD) | `{app:"desktop", command:"shutdown"}` |
+| HD sign-out propagation | `{app:"desktop", command:"logout"}` |
+| Introspect embedded state | `{app:"desktop", command:"embedded_status"}` |
+| Force permanent shell auto-approve | `{app:"desktop", command:"set_shell_auto_approve", args:{permanent:true}}` |
+
+`embedded_status` returns `{embedded, owner, source, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` — useful for HD's tray to know whether AD already booted embedded or it's running standalone.
+
+```typescript
+// HD's tray-click handler — example
+async function openAdomDesktop() {
+  await fetch('http://127.0.0.1:47200/command', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ app: 'desktop', command: 'window_show' }),
+  });
+}
+```
+
+Note: HD typically calls `set_shell_auto_approve {permanent: true}` only as a recovery path — AD already defaults to permanent at embedded boot. Use it when toggling user-controlled preferences (e.g. an HD setting like "Auto-approve AD shell commands ON/OFF").
+
+## What about verbs not in the directApi list?
+
+The `cliRequired` array in `GET /status` lists the verbs that need the CLI binary. Anything not in that list works over the direct API. The dispatcher is the same code; if a verb works via `adom-desktop <verb>` it works via direct POST.
+
+### Mapping CLI verb → direct-API envelope
+
+**The prefix-stripping rule varies per namespace.** Easiest mental model: look at the CLI's `cli/src/commands.rs` dispatch line for the verb you want. Whatever string it passes as the second arg of `relay::desktop_command(app, command, ...)` is exactly what your direct-API envelope's `command` field should be.
+
+**v1.8.153+: for `app:"desktop"` the `desktop_` prefix is OPTIONAL — POST the public verb name as-is.** A bridge delegating via `ADOM_DIRECT_API_URL/command` can send the exact verb it knows (`desktop_taskbar`, `desktop_flash_window`, `desktop_list_windows`, `desktop_find_window`, `desktop_screenshot_window`, `desktop_record_window_start/stop/status`) OR the stripped form (`taskbar`, …) — both dispatch. (Before 1.8.153 the dispatch arms were unprefixed, so `desktop_taskbar` returned "Unknown desktop command" while `run_script` worked — that asymmetry is gone.)
+
+| `app` value | What goes in `command` | Examples (CLI verb → direct envelope) |
+|---|---|---|
+| `desktop` | public verb name; `desktop_` prefix optional (v1.8.153+) | `desktop_taskbar` → `{app:"desktop", command:"desktop_taskbar"}` (or `"taskbar"`); `server_add` → `{app:"desktop", command:"server_add"}` |
+| `kicad` | **stripped** — drop the `kicad_` prefix | `kicad_open_board` → `{app:"kicad", command:"open_board"}` |
+| `fusion360` | **stripped** — drop the `fusion_` prefix | `fusion_start` → `{app:"fusion360", command:"launch"}` (note: also the verb name shifts internally) |
+| `browser` | **kept** — pup bridge dispatches on the full name | `browser_open_window` → `{app:"browser", command:"browser_open_window"}` |
+| `hd` | **stripped** — drop the `hd_` prefix | `hd_status` → `{app:"hd", command:"status"}` |
+| `dynamic` | full verb name with third-party bridge prefix preserved | (dispatcher routes by prefix) |
+
+Common direct-safe verb groups:
+
+| Namespace | Examples | Notes |
+|---|---|---|
+| `desktop` | `server_add`, `server_remove`, `server_list`, `bridge_list`, `bridge_install`, `bridge_pause`, `bridge_resume`, `desktop_list_windows`, `desktop_screenshot_window`, `desktop_open_url`, `set_shell_auto_approve` | All sync, no prefix stripping. |
+| `hd` | `status`, `eval`, `log`, `screenshot`, `build`, `build_frontend`, `build_rust`, `build_status`, `build_log`, `build_tail`, `launch`, `stop`, `restart` | `build*` returns instantly with `{pid, logPath}`; poll `build_status` or `build_tail` for progress. |
+| `kicad` | `list_versions`, `open_board`, `open_schematic`, `run_drc`, `lint_board`, `install_symbol`, ... | Multi-version-aware. |
+| `fusion360` | `launch`, `export_step`, `walk_cloud_tree`, `window_info`, `get_app_state`, ... | 50+ verbs. |
+| `browser` | `browser_open_window`, `browser_navigate`, `browser_screenshot`, `browser_eval`, `browser_click`, `browser_record_start` | Per-tab. **Keep the prefix.** |
+
+## Failure modes & retry
+
+The direct API doesn't auto-retry. Sibling apps should:
+
+1. **Probe `/health` before any `/command`** so a missing GUI is a clean "not available" branch, not a noisy 10-second timeout on every dispatch.
+2. **Per-request timeouts ≥ the verb's natural duration.** Most verbs are sub-second; `hd_build*` returns in <2 s; `bridge_install` can be 30 s for a large zip. Pick the timeout based on the verb you're calling.
+3. **Treat 504 timeouts as "unknown state, ask later"** rather than retrying blindly. A successful retry on a 504 might double-register a server or double-install a bridge.
+4. **Read the `_hint` and `errorCode` fields on every error response.** They're machine-friendly: `cli_required`, `timeout`, `handler_silent`, plus whatever the verb's handler emits (`bridge_not_found`, `binary_missing`, etc.).
+
+## Version skew
+
+If `GET /status` returns `version < 1.8.25` OR fails entirely OR `schema` is missing, the direct API isn't present (older GUI). Fall back to spawning the CLI binary. The CLI has been the supported integration path since v1.7.x; everything that works via the direct API also works via the CLI, just slower.
+
+## Why a separate port from the WS relay's 8766?
+
+- **Different transport, different trust model.** 8766 is the relay's HTTP endpoint, which goes through a WS bridge to the GUI and gates non-CLI User-Agents. 47200 is direct to the GUI, no auth, loopback-only.
+- **Different process lifecycle.** 8766 needs `adom-desktop serve` running (a separate process). 47200 is inside the GUI — if the GUI is running, 47200 is up.
+- **No risk of breaking Docker.** Docker callers keep using 8766 + the WS proxy. Sibling apps get 47200. They don't fight for the same port or auth scheme.
+
+## See also
+
+- [`README.md`](../README.md) — feature list
+- [`skills/SKILL.md`](SKILL.md) — full verb catalog (the entries Docker callers read)
+- [`src-tauri/src/direct_api.rs`](../src-tauri/src/direct_api.rs) — implementation
+- [Adom Wiki — apps/adom-desktop](https://wiki.adom.inc/adom/adom-desktop) — public verb reference
skills/adom-desktop-installer/SKILL.md+452−452
@@ -1,452 +1,452 @@-----name: adom-desktop-installer-description: How to invoke the Adom Desktop NSIS installer from a parent installer (especially Hydrogen Desktop's setup steps) — silent install, /NOLAUNCH switch for HD-managed bundling, live progress via the structured log file at %TEMP%\adom-desktop-install.log. Use when bundling AD inside another Windows installer, writing an install supervisor / setup-step UI, detecting AD's installed version, or upgrading an existing AD install programmatically.------# Adom Desktop installer integration--Adom Desktop (AD) ships as a single NSIS installer:--```-Adom Desktop_<version>_x64-setup.exe   (~4.8 MB on v1.8.45)-```--It installs to `%LOCALAPPDATA%\Adom Desktop\` — no UAC prompt, no admin needed. Tauri-2 bundled NSIS with our customisations in `src-tauri/installer-hooks.nsh`.--## Command-line surface--| Flag | Meaning |-|---|---|-| (none) | Installs silently, auto-launches `adom-desktop.exe` after install |-| `/S` | Standard NSIS silent flag — same behavior since the top-level `SilentInstall silent` directive already suppresses UI |-| `/NOLAUNCH` | **v1.8.45+** — install silently, do NOT auto-launch after install. Caller is responsible for spawning AD with the desired flags |-| `/D=<path>` | Install destination override. Default is `$LOCALAPPDATA\Adom Desktop`. Rarely needed |-| `<uninstall.exe> /S` | Silent uninstall. The uninstaller lives at `%LOCALAPPDATA%\Adom Desktop\uninstall.exe` |--**UI behavior**: zero UI in all cases. `SilentInstall silent` is set at the top of the NSIS script, so even `setup.exe` invoked from Explorer runs without a window. No Welcome page, no progress bar, no Finish page.--**Auto-launch**: by default the installer calls `ExecShell "" "$INSTDIR\adom-desktop.exe"` at the end of POSTINSTALL. Pass `/NOLAUNCH` to suppress this. Used by Hydrogen when bundling AD because Hydrogen wants to spawn AD with `--embedded --start-hidden --relay-url ... --session-token ...` itself, not let AD launch standalone first and get respawned via single-instance.--## Live progress log--The installer writes structured progress to `%TEMP%\adom-desktop-install.log` (Windows expands `%TEMP%` to the user's temp dir, typically `C:\Users\<name>\AppData\Local\Temp`). Each line is `[STAGE] message\r\n`:--```-[START] adom-desktop installer 1.8.45 starting-[PREINSTALL] Cleaning legacy desktop shortcuts-[PREINSTALL] Done. Tauri will now copy bundle files.-[POSTINSTALL] Bundle files copied. Cleaning Tauri's auto-created desktop shortcut.-[POSTINSTALL] /NOLAUNCH passed — skipping auto-launch (caller will spawn)-[DONE] adom-desktop installer complete (no-launch mode)-```--**`[START]`** opens with a truncated write (fresh log per install). **`[DONE]`** is always the final line — readers tailing the file can use that as the completion sentinel without needing to wait for the process exit.--Stage tags currently emitted:-- `[START]` — once, at the top-- `[PREINSTALL]` — Tauri's beforeBuildCommand hook ran, files about to be copied-- `[POSTINSTALL]` — files copied; Tauri's standard shortcuts created and our hook is cleaning the unwanted ones-- `[DONE]` — final line, installer about to exit--The uninstaller writes the same log with `[PREUNINSTALL]` / `[POSTUNINSTALL]` stages.--## Recipe: Hydrogen's setup-step UI tails the log--Spawn the installer with `/NOLAUNCH`, poll the log file on a 200 ms timer, surface each new line to Hydrogen's UI. When `[DONE]` shows up OR the process exits, the install is complete.--### Rust (Hydrogen's installer driver)--```rust-use std::{env, fs, path::PathBuf, process::Command, time::Duration};-use std::os::windows::process::CommandExt;-use tokio::time::sleep;--const CREATE_NO_WINDOW: u32 = 0x08000000;--async fn install_adom_desktop<F: FnMut(String)>(-    installer_path: &str,-    mut on_line: F,-) -> Result<(), String> {-    let log_path: PathBuf = [&env::var("TEMP").map_err(|e| e.to_string())?, "adom-desktop-install.log"]-        .iter()-        .collect();-    // Pre-clear so we don't read stale lines from a previous install.-    let _ = fs::remove_file(&log_path);--    let mut child = Command::new(installer_path)-        .args(["/S", "/NOLAUNCH"])-        .creation_flags(CREATE_NO_WINDOW)-        .spawn()-        .map_err(|e| format!("spawn installer: {e}"))?;--    let mut last_pos = 0usize;-    loop {-        // Tail any new content-        if let Ok(content) = fs::read_to_string(&log_path) {-            if content.len() > last_pos {-                let slice = &content[last_pos..];-                if let Some(last_nl) = slice.rfind('\n') {-                    let consumed = &slice[..=last_nl];-                    for line in consumed.lines() {-                        on_line(line.to_string());-                        if line.starts_with("[DONE]") {-                            // Don't return yet — wait for process exit-                            // so we surface the actual exit code, but-                            // we know progress streaming is finished.-                        }-                    }-                    last_pos += consumed.len();-                }-            }-        }-        // Check if installer has exited-        if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {-            return if status.success() {-                Ok(())-            } else {-                Err(format!("installer exited with {}", status))-            };-        }-        sleep(Duration::from_millis(200)).await;-    }-}--// Caller:-// install_adom_desktop(r"C:\path\to\Adom Desktop_1.8.45_x64-setup.exe", |line| {-//     hd_setup_step.append_live_output(&line);-// }).await?;-```--### Bash (debugging / scripting)--```bash-# Pre-clear stale log-rm -f "$TEMP/adom-desktop-install.log" 2>/dev/null--# Spawn installer in background, tail the log-"./Adom Desktop_1.8.45_x64-setup.exe" /S /NOLAUNCH &-INSTALLER_PID=$!--# Tail until process exits OR [DONE] appears-while kill -0 $INSTALLER_PID 2>/dev/null; do-  [ -f "$TEMP/adom-desktop-install.log" ] && tail -F "$TEMP/adom-desktop-install.log" &-  sleep 0.2-done-wait-echo "Installer exited"-```--## Recipe: detect if AD is already installed (and at what version)--The signed NSIS installer (`installMode: "currentUser"`) writes AD's uninstall info to `HKCU` — no admin needed. **⚠ The key name is the product name `Adom Desktop` (WITH a space), NOT `AdomDesktop`** (verified live 2026-07-03). Two history notes so you don't chase the wrong key:-- The NSIS uninstall key = the product name → `…\Uninstall\Adom Desktop` (space).-- AD's OWN launch-time code used to ALSO write an `AdomDesktop` (no-space) key to **HKLM** — that write was **DELETED in v1.9.68** (the no-UAC fix). So `AdomDesktop` no longer exists anywhere; read `Adom Desktop`.--```-HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\Adom Desktop-  DisplayName       = "Adom Desktop"-  DisplayVersion    = "1.9.74"-  InstallLocation   = "C:\Users\<name>\AppData\Local\Adom Desktop"-  UninstallString   = "C:\Users\<name>\AppData\Local\Adom Desktop\uninstall.exe"-  Publisher         = "Adom Industries, Inc."-```--```rust-use winreg::enums::HKEY_CURRENT_USER;-use winreg::RegKey;--fn adom_desktop_installed_version() -> Option<String> {-    let hkcu = RegKey::predef(HKEY_CURRENT_USER);-    // NOTE the SPACE — the NSIS uninstall key is the product name "Adom Desktop".-    let key = hkcu-        .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\Adom Desktop")-        .ok()?;-    key.get_value("DisplayVersion").ok()-}-```--Compare with `semver::Version::parse` to decide whether to skip / upgrade / leave-alone.--## Recipe: Hydrogen's setup-step ladder for embedding AD--The full pattern Hydrogen's setup-step UI runs when embedding AD:--```-1. Check installed version          → registry read above-2. Decide:-     no install         → install bundled AD-     older than bundled → install bundled AD (upgrade)-     same or newer      → skip install entirely-3. Run installer (silent + /NOLAUNCH)   → tail log, surface to UI-4. Write embedded marker file       → %LOCALAPPDATA%\Adom Desktop\embedded.json-5. Delete AD's user-facing shortcuts → Start Menu, Desktop, Startup folder-                                       (so user can't double-launch standalone)-6. Spawn AD with embedded flags     → adom-desktop.exe --embedded-                                          --start-hidden-                                          --relay-url ws://127.0.0.1:8765-                                          --relay-name hydrogen-desktop-                                          --session-token <hydrogen's token>-7. Poll AD's direct API for liveness → GET http://127.0.0.1:47200/health-```--Steps 4 and 5 are also covered by AD's installer if you pass `--embedded` at AD's first launch (the embedded-mode detection writes the marker and AD's autostart-upgrade routine handles its own shortcuts). Doing them in Hydrogen's installer is belt-and-suspenders.--## Bundle-resource pattern--Hydrogen's `tauri.conf.json` should include the AD installer as a bundle resource so it ships inside Hydrogen's NSIS installer:--```json-"bundle": {-  "resources": {-    "resources/Adom Desktop_1.8.45_x64-setup.exe": "resources/"-  }-}-```--At runtime Hydrogen finds it under its install dir at `$INSTDIR\resources\Adom Desktop_<ver>_x64-setup.exe` — pass that path to the install function above.--## Versioning + upgrade strategy--| Scenario | What Hydrogen's installer should do |-|---|---|-| No AD installed | Install bundled version |-| AD older than bundled | Install bundled version (upgrade) |-| AD same as bundled | Skip install step; still spawn with embedded flags |-| AD newer than bundled | Skip install step; spawn with embedded flags. Don't downgrade — embedded mode works for any AD ≥ v1.8.42 |--The bundled AD installer is the minimum supported version for the Hydrogen release. Hydrogen can spawn newer ADs without modification.--## Exit codes--NSIS returns:-- `0` — install succeeded-- `2` — user cancelled (won't happen with `SilentInstall silent` since there's no UI to cancel)-- Other — install error (rare with our hooks; typical cause is disk full or `%LOCALAPPDATA%` not writable)--Treat anything non-zero as a hard failure and surface the log file's last 20 lines to the user.--## Where the installer lives--**v1.8.115+** — the end-user binary home is the **V2 wiki blob store** (the-GitHub repo is private; GH release URLs 404 for anonymous downloads). Two-discovery surfaces, both anonymous:--**Preferred for parent installers (Hydrogen setup): the packages API.** Resolve-the latest registered version and its assets in one or two GETs — note-`?org=adom` is REQUIRED (the slug exists in multiple orgs; bare URLs 400):--```-https://wiki.adom.inc/api/v1/packages/adom-desktop/dist-tags?org=adom-  → {"dist_tags":{"latest":"X.Y.Z"}}-https://wiki.adom.inc/api/v1/packages/adom-desktop/manifest?org=adom-  → { version, ..., assets: { "windows-x64": { filename, url, sha256, size } } }-```--The `assets` map is server-derived from the page's blobs (sha256 computed-from the actual bytes — integrity-checkable). `assets[...].url` is-server-relative (`/blob/app/adom-desktop/<file>`); prefix the git-wiki host.--**Fallback / updater path: version.json**, V2-first with V1 fallback:--```-https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json-https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json-```--The V2 manifest's `windows.installer_url` / `cli.<plat>.binary_url` point at-the V2 files endpoint; V1's point at V1 static copies. **Follow the-manifest's URLs — do NOT construct binary URLs by hand.**--Network quirk: HEAD on V2 blob/file URLs may return 404 while GET works-(git-wiki #40 — fix in flight). Probe with a Range GET (`Range: bytes=0-0`,-expect 206), never bare HEAD. Asset filenames are dot-form everywhere-(`Adom.Desktop_<ver>_windows_x64-setup.exe`); V1 dot-normalized since v1.8.72.--The wiki page's `metadata.install.windows_installer` field points at the-canonical filename for the current published version. Hydrogen's release build-should fetch the latest via the V2-first pattern:--```bash-# Try V2 first, fall back to V1.-fetch_manifest() {-  curl -fL --max-time 15 \-    "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json" \-    2>/dev/null \-  || curl -fL --max-time 15 \-    "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json"-}--MANIFEST=$(fetch_manifest)-INSTALLER_URL=$(echo "$MANIFEST" | jq -r .windows.installer_url)-INSTALLER_SHA=$(echo "$MANIFEST" | jq -r .windows.installer_sha256)-# Each manifest carries a self-consistent installer_url that points at the-# SAME backend the manifest was served from — V2 manifest → V2 installer URL,-# V1 manifest → V1 installer URL. No URL mixing across backends.--curl -fL "$INSTALLER_URL" -o resources/ad-setup.exe--# Verify SHA256 before bundling — defends against partial fetches + tampering-DOWNLOAD_SHA=$(sha256sum resources/ad-setup.exe | awk '{print $1}')-[ "$DOWNLOAD_SHA" = "$INSTALLER_SHA" ] || { echo "SHA mismatch"; exit 1; }-```--The desktop AD's built-in auto-updater (`src-tauri/src/updater.rs`) walks an-ordered `MANIFEST_URLS` list — V2 first, V1 fallback — and uses whichever-responds first. Once V2 has been the source of truth for a release cycle-without incident, V1 will be retired from the pipeline.--`version.json` also carries the SHA256 hash of the installer so Hydrogen's build-pipeline can verify the download before bundling.--## Asset naming convention (v1.8.113+)--The Adom Desktop wiki page hosts TWO distinct types of artifacts per release:--1. **Full AD GUI installer** — the actual desktop app (Tauri window, bundled-   bridges, relauncher). One per OS / architecture.-2. **CLI companion binary** — a separate executable that talks to AD over-   the relay. Used by Hydrogen's WSL2 container, gallia builds, sibling-app-   installers, cron jobs. Does NOT contain AD itself.--Both ship together at the same version (single `python scripts/bump-version.py-patch` bumps both, single `cargo tauri build` + `cargo build --release` in-`cli/` produces both, single `scripts/release-publish.sh` uploads both).--### Filename pattern--| Artifact | Wiki asset name pattern | Example (v1.8.113) |-|---|---|---|-| Full AD GUI — Windows | `Adom.Desktop_<ver>_windows_x64-setup.exe` | `Adom.Desktop_1.8.113_windows_x64-setup.exe` |-| Full AD GUI — macOS | `Adom.Desktop_<ver>_macos_<arch>.dmg` | `Adom.Desktop_1.8.113_macos_aarch64.dmg` (future) |-| Full AD GUI — Linux | `Adom.Desktop_<ver>_linux_<arch>.AppImage` | `Adom.Desktop_1.8.113_linux_x86_64.AppImage` (future) |-| CLI companion — Linux | `adom-desktop-cli_<ver>_linux_<arch>` | `adom-desktop-cli_1.8.113_linux_x86_64` |-| CLI companion — macOS | `adom-desktop-cli_<ver>_macos_<arch>` | `adom-desktop-cli_1.8.113_macos_aarch64` (future) |-| CLI companion — Windows | `adom-desktop-cli_<ver>_windows_<arch>.exe` | `adom-desktop-cli_1.8.113_windows_x64.exe` (future) |--The `_cli_` token in the middle is the only signal you need to know "this is-the companion, not the app." `linux_x86_64` always means Linux x86_64,-regardless of whether the file is a GUI installer or a CLI binary.--### After install, the binary name is `adom-desktop`--The wiki asset name disambiguates at FETCH time. At INSTALL time the binary-is renamed to `adom-desktop` so the command users type doesn't change:--```bash-# Linux example — install the CLI companion-curl -fL "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/adom-desktop-cli_1.8.113_linux_x86_64" \-  -o /tmp/ad-sudo install -m 755 /tmp/ad /usr/local/bin/adom-desktop-adom-desktop --version-# → adom-desktop 1.8.113 (<sha>, built <ts>)-```--### Version.json `.cli` slot--The auto-update manifest (v1.8.113+) carries a typed `cli` sibling section-for the CLI binaries:--```json-{-  "manifest_version": 1,-  "version": "1.8.113",-  "windows": { "installer_url": ".../Adom.Desktop_1.8.113_windows_x64-setup.exe", ... },-  "cli": {-    "linux_x86_64": {-      "binary_url":      ".../adom-desktop-cli_1.8.113_linux_x86_64",-      "binary_filename": "adom-desktop-cli_1.8.113_linux_x86_64",-      "binary_sha256":   "...",-      "binary_size":     8012408-    }-  },-  "changelog_url": "...",-  "minimum_required": "1.3.30"-}-```--Pre-v1.8.113 readers ignore `.cli` entirely (additive schema; no-`manifest_version` bump required). The desktop AD's own auto-updater only-reads `.windows / .macos / .linux` for its GUI installer URL — it doesn't-touch `.cli`.--For Hydrogen / gallia / Docker-side fetchers, prefer the typed lookup:--```bash-# Best: read the URL out of the manifest (so the filename can evolve)-MANIFEST=$(fetch_manifest)-LINUX_CLI_URL=$(echo "$MANIFEST" | jq -r '.cli.linux_x86_64.binary_url')-LINUX_CLI_SHA=$(echo "$MANIFEST" | jq -r '.cli.linux_x86_64.binary_sha256')-curl -fL "$LINUX_CLI_URL" -o /tmp/ad-[ "$(sha256sum /tmp/ad | awk '{print $1}')" = "$LINUX_CLI_SHA" ] || exit 1-sudo install -m 755 /tmp/ad /usr/local/bin/adom-desktop-```--### Windows CLI is shipped with the NSIS (v1.8.114+)--Installing AD on Windows now drops the CLI companion next to the GUI exe-and adds the install dir to the user's PATH so PowerShell / Claude Desktop /-cmd find it without configuration:--```-%LOCALAPPDATA%\Adom Desktop\adom-desktop.exe        ← GUI (Windows subsystem)-%LOCALAPPDATA%\Adom Desktop\adom-desktop-cli.exe    ← CLI companion (Console subsystem)-%LOCALAPPDATA%\Adom Desktop\ad-relauncher.exe       ← visible self-restart helper-```--NSIS's POSTINSTALL hook prepends `%LOCALAPPDATA%\Adom Desktop` to-`HKCU\Environment\Path` (no UAC — user-level only) and broadcasts-`WM_SETTINGCHANGE` so freshly-opened shells pick up the change. Already--open shells need a re-launch (OS limitation).--After install, in a fresh PowerShell:--```powershell-adom-desktop-cli --version-# adom-desktop 1.8.114 (<sha>, built <ts>)-adom-desktop-cli status-adom-desktop-cli screenshot_window '{"title-contains":"VS Code"}'-```--The CLI auto-discovers the local AD via direct-API probe at-`127.0.0.1:47200` — no relay needed when AD is on the same box. ~5ms RTT-for compatible verbs; verbs in AD's `cliRequired` list still flow through-a relay if one's running (Hydrogen's WSL2 distro typically owns the relay).--**Relay self-detect (v1.8.114+):** `adom-desktop-cli serve` checks for-another adom-desktop relay on `127.0.0.1:8766/health` before binding. If-one already answers, it prints a friendly note and exits cleanly without-starting a second relay (which would just sit idle — AD's WS client only-attaches to the first one it finds). Pass `--force-bind` to override.--PREUNINSTALL strips the PATH entry back out and rebroadcasts so an-uninstall of AD also gets PATH cleanup.--### Legacy alias migration window--Releases prior to v1.8.113 published the Linux CLI as `adom-desktop-linux`-(no version, no architecture). Hydrogen's WSL2 setup and a few cron-based-installers have that literal filename baked in.--To keep them working through the rename, v1.8.113 publishes BOTH names:--| Name | Status | Wiki URL (V2 / V1) |-|---|---|---|-| `adom-desktop-cli_<ver>_linux_x86_64` | **canonical, v1.8.113+** | V2 / V1 |-| `adom-desktop-linux` | **deprecated alias** — byte-identical copy | V2 / V1 |--Both files are byte-identical with the same SHA256. The legacy alias stays-through **v1.8.116**, then disappears. Migrate by then. Recommended pattern:--```bash-# Migration period (v1.8.113 → v1.8.115): try new name, fall back to alias-LINUX_CLI_URL_NEW=$(echo "$MANIFEST" | jq -r '.cli.linux_x86_64.binary_url // empty')-if [ -n "$LINUX_CLI_URL_NEW" ]; then-  curl -fL "$LINUX_CLI_URL_NEW" -o /tmp/ad-else-  # Falling back — likely talking to a pre-v1.8.113 manifest-  curl -fL "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/adom-desktop-linux" \-    -o /tmp/ad-fi-```--Post-v1.8.116, the fallback branch dies; only the canonical name will exist.+---
+name: adom-desktop-installer
+description: How to invoke the Adom Desktop NSIS installer from a parent installer (especially Hydrogen Desktop's setup steps) — silent install, /NOLAUNCH switch for HD-managed bundling, live progress via the structured log file at %TEMP%\adom-desktop-install.log. Use when bundling AD inside another Windows installer, writing an install supervisor / setup-step UI, detecting AD's installed version, or upgrading an existing AD install programmatically.
+---
+
+# Adom Desktop installer integration
+
+Adom Desktop (AD) ships as a single NSIS installer:
+
+```
+Adom Desktop_<version>_x64-setup.exe   (~4.8 MB on v1.8.45)
+```
+
+It installs to `%LOCALAPPDATA%\Adom Desktop\` — no UAC prompt, no admin needed. Tauri-2 bundled NSIS with our customisations in `src-tauri/installer-hooks.nsh`.
+
+## Command-line surface
+
+| Flag | Meaning |
+|---|---|
+| (none) | Installs silently, auto-launches `adom-desktop.exe` after install |
+| `/S` | Standard NSIS silent flag — same behavior since the top-level `SilentInstall silent` directive already suppresses UI |
+| `/NOLAUNCH` | **v1.8.45+** — install silently, do NOT auto-launch after install. Caller is responsible for spawning AD with the desired flags |
+| `/D=<path>` | Install destination override. Default is `$LOCALAPPDATA\Adom Desktop`. Rarely needed |
+| `<uninstall.exe> /S` | Silent uninstall. The uninstaller lives at `%LOCALAPPDATA%\Adom Desktop\uninstall.exe` |
+
+**UI behavior**: zero UI in all cases. `SilentInstall silent` is set at the top of the NSIS script, so even `setup.exe` invoked from Explorer runs without a window. No Welcome page, no progress bar, no Finish page.
+
+**Auto-launch**: by default the installer calls `ExecShell "" "$INSTDIR\adom-desktop.exe"` at the end of POSTINSTALL. Pass `/NOLAUNCH` to suppress this. Used by HD when bundling AD because HD wants to spawn AD with `--embedded --start-hidden --relay-url ... --session-token ...` itself, not let AD launch standalone first and get respawned via single-instance.
+
+## Live progress log
+
+The installer writes structured progress to `%TEMP%\adom-desktop-install.log` (Windows expands `%TEMP%` to the user's temp dir, typically `C:\Users\<name>\AppData\Local\Temp`). Each line is `[STAGE] message\r\n`:
+
+```
+[START] adom-desktop installer 1.8.45 starting
+[PREINSTALL] Cleaning legacy desktop shortcuts
+[PREINSTALL] Done. Tauri will now copy bundle files.
+[POSTINSTALL] Bundle files copied. Cleaning Tauri's auto-created desktop shortcut.
+[POSTINSTALL] /NOLAUNCH passed — skipping auto-launch (caller will spawn)
+[DONE] adom-desktop installer complete (no-launch mode)
+```
+
+**`[START]`** opens with a truncated write (fresh log per install). **`[DONE]`** is always the final line — readers tailing the file can use that as the completion sentinel without needing to wait for the process exit.
+
+Stage tags currently emitted:
+- `[START]` — once, at the top
+- `[PREINSTALL]` — Tauri's beforeBuildCommand hook ran, files about to be copied
+- `[POSTINSTALL]` — files copied; Tauri's standard shortcuts created and our hook is cleaning the unwanted ones
+- `[DONE]` — final line, installer about to exit
+
+The uninstaller writes the same log with `[PREUNINSTALL]` / `[POSTUNINSTALL]` stages.
+
+## Recipe: HD's setup-step UI tails the log
+
+Spawn the installer with `/NOLAUNCH`, poll the log file on a 200 ms timer, surface each new line to HD's UI. When `[DONE]` shows up OR the process exits, the install is complete.
+
+### Rust (HD's installer driver)
+
+```rust
+use std::{env, fs, path::PathBuf, process::Command, time::Duration};
+use std::os::windows::process::CommandExt;
+use tokio::time::sleep;
+
+const CREATE_NO_WINDOW: u32 = 0x08000000;
+
+async fn install_adom_desktop<F: FnMut(String)>(
+    installer_path: &str,
+    mut on_line: F,
+) -> Result<(), String> {
+    let log_path: PathBuf = [&env::var("TEMP").map_err(|e| e.to_string())?, "adom-desktop-install.log"]
+        .iter()
+        .collect();
+    // Pre-clear so we don't read stale lines from a previous install.
+    let _ = fs::remove_file(&log_path);
+
+    let mut child = Command::new(installer_path)
+        .args(["/S", "/NOLAUNCH"])
+        .creation_flags(CREATE_NO_WINDOW)
+        .spawn()
+        .map_err(|e| format!("spawn installer: {e}"))?;
+
+    let mut last_pos = 0usize;
+    loop {
+        // Tail any new content
+        if let Ok(content) = fs::read_to_string(&log_path) {
+            if content.len() > last_pos {
+                let slice = &content[last_pos..];
+                if let Some(last_nl) = slice.rfind('\n') {
+                    let consumed = &slice[..=last_nl];
+                    for line in consumed.lines() {
+                        on_line(line.to_string());
+                        if line.starts_with("[DONE]") {
+                            // Don't return yet — wait for process exit
+                            // so we surface the actual exit code, but
+                            // we know progress streaming is finished.
+                        }
+                    }
+                    last_pos += consumed.len();
+                }
+            }
+        }
+        // Check if installer has exited
+        if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
+            return if status.success() {
+                Ok(())
+            } else {
+                Err(format!("installer exited with {}", status))
+            };
+        }
+        sleep(Duration::from_millis(200)).await;
+    }
+}
+
+// Caller:
+// install_adom_desktop(r"C:\path\to\Adom Desktop_1.8.45_x64-setup.exe", |line| {
+//     hd_setup_step.append_live_output(&line);
+// }).await?;
+```
+
+### Bash (debugging / scripting)
+
+```bash
+# Pre-clear stale log
+rm -f "$TEMP/adom-desktop-install.log" 2>/dev/null
+
+# Spawn installer in background, tail the log
+"./Adom Desktop_1.8.45_x64-setup.exe" /S /NOLAUNCH &
+INSTALLER_PID=$!
+
+# Tail until process exits OR [DONE] appears
+while kill -0 $INSTALLER_PID 2>/dev/null; do
+  [ -f "$TEMP/adom-desktop-install.log" ] && tail -F "$TEMP/adom-desktop-install.log" &
+  sleep 0.2
+done
+wait
+echo "Installer exited"
+```
+
+## Recipe: detect if AD is already installed (and at what version)
+
+The signed NSIS installer (`installMode: "currentUser"`) writes AD's uninstall info to `HKCU` — no admin needed. **⚠ The key name is the product name `Adom Desktop` (WITH a space), NOT `AdomDesktop`** (verified live 2026-07-03). Two history notes so you don't chase the wrong key:
+- The NSIS uninstall key = the product name → `…\Uninstall\Adom Desktop` (space).
+- AD's OWN launch-time code used to ALSO write an `AdomDesktop` (no-space) key to **HKLM** — that write was **DELETED in v1.9.68** (the no-UAC fix). So `AdomDesktop` no longer exists anywhere; read `Adom Desktop`.
+
+```
+HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\Adom Desktop
+  DisplayName       = "Adom Desktop"
+  DisplayVersion    = "1.9.74"
+  InstallLocation   = "C:\Users\<name>\AppData\Local\Adom Desktop"
+  UninstallString   = "C:\Users\<name>\AppData\Local\Adom Desktop\uninstall.exe"
+  Publisher         = "Adom Industries, Inc."
+```
+
+```rust
+use winreg::enums::HKEY_CURRENT_USER;
+use winreg::RegKey;
+
+fn adom_desktop_installed_version() -> Option<String> {
+    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
+    // NOTE the SPACE — the NSIS uninstall key is the product name "Adom Desktop".
+    let key = hkcu
+        .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\Adom Desktop")
+        .ok()?;
+    key.get_value("DisplayVersion").ok()
+}
+```
+
+Compare with `semver::Version::parse` to decide whether to skip / upgrade / leave-alone.
+
+## Recipe: HD's setup-step ladder for embedding AD
+
+The full pattern HD's setup-step UI runs when embedding AD:
+
+```
+1. Check installed version          → registry read above
+2. Decide:
+     no install         → install bundled AD
+     older than bundled → install bundled AD (upgrade)
+     same or newer      → skip install entirely
+3. Run installer (silent + /NOLAUNCH)   → tail log, surface to UI
+4. Write embedded marker file       → %LOCALAPPDATA%\Adom Desktop\embedded.json
+5. Delete AD's user-facing shortcuts → Start Menu, Desktop, Startup folder
+                                       (so user can't double-launch standalone)
+6. Spawn AD with embedded flags     → adom-desktop.exe --embedded
+                                          --start-hidden
+                                          --relay-url ws://127.0.0.1:8765
+                                          --relay-name hydrogen-desktop
+                                          --session-token <hd's token>
+7. Poll AD's direct API for liveness → GET http://127.0.0.1:47200/health
+```
+
+Steps 4 and 5 are also covered by AD's installer if you pass `--embedded` at AD's first launch (the embedded-mode detection writes the marker and AD's autostart-upgrade routine handles its own shortcuts). Doing them in HD's installer is belt-and-suspenders.
+
+## Bundle-resource pattern
+
+HD's `tauri.conf.json` should include the AD installer as a bundle resource so it ships inside HD's NSIS installer:
+
+```json
+"bundle": {
+  "resources": {
+    "resources/Adom Desktop_1.8.45_x64-setup.exe": "resources/"
+  }
+}
+```
+
+At runtime HD finds it under its install dir at `$INSTDIR\resources\Adom Desktop_<ver>_x64-setup.exe` — pass that path to the install function above.
+
+## Versioning + upgrade strategy
+
+| Scenario | What HD's installer should do |
+|---|---|
+| No AD installed | Install bundled version |
+| AD older than bundled | Install bundled version (upgrade) |
+| AD same as bundled | Skip install step; still spawn with embedded flags |
+| AD newer than bundled | Skip install step; spawn with embedded flags. Don't downgrade — embedded mode works for any AD ≥ v1.8.42 |
+
+The bundled AD installer is the minimum supported version for the HD release. HD can spawn newer ADs without modification.
+
+## Exit codes
+
+NSIS returns:
+- `0` — install succeeded
+- `2` — user cancelled (won't happen with `SilentInstall silent` since there's no UI to cancel)
+- Other — install error (rare with our hooks; typical cause is disk full or `%LOCALAPPDATA%` not writable)
+
+Treat anything non-zero as a hard failure and surface the log file's last 20 lines to the user.
+
+## Where the installer lives
+
+**v1.8.115+** — the end-user binary home is the **V2 wiki blob store** (the
+GitHub repo is private; GH release URLs 404 for anonymous downloads). Two
+discovery surfaces, both anonymous:
+
+**Preferred for parent installers (HD setup): the packages API.** Resolve
+the latest registered version and its assets in one or two GETs — note
+`?org=adom` is REQUIRED (the slug exists in multiple orgs; bare URLs 400):
+
+```
+https://wiki.adom.inc/api/v1/packages/adom-desktop/dist-tags?org=adom
+  → {"dist_tags":{"latest":"X.Y.Z"}}
+https://wiki.adom.inc/api/v1/packages/adom-desktop/manifest?org=adom
+  → { version, ..., assets: { "windows-x64": { filename, url, sha256, size } } }
+```
+
+The `assets` map is server-derived from the page's blobs (sha256 computed
+from the actual bytes — integrity-checkable). `assets[...].url` is
+server-relative (`/blob/app/adom-desktop/<file>`); prefix the git-wiki host.
+
+**Fallback / updater path: version.json**, V2-first with V1 fallback:
+
+```
+https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json
+https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json
+```
+
+The V2 manifest's `windows.installer_url` / `cli.<plat>.binary_url` point at
+the V2 files endpoint; V1's point at V1 static copies. **Follow the
+manifest's URLs — do NOT construct binary URLs by hand.**
+
+Network quirk: HEAD on V2 blob/file URLs may return 404 while GET works
+(git-wiki #40 — fix in flight). Probe with a Range GET (`Range: bytes=0-0`,
+expect 206), never bare HEAD. Asset filenames are dot-form everywhere
+(`Adom.Desktop_<ver>_windows_x64-setup.exe`); V1 dot-normalized since v1.8.72.
+
+The wiki page's `metadata.install.windows_installer` field points at the
+canonical filename for the current published version. HD's release build
+should fetch the latest via the V2-first pattern:
+
+```bash
+# Try V2 first, fall back to V1.
+fetch_manifest() {
+  curl -fL --max-time 15 \
+    "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json" \
+    2>/dev/null \
+  || curl -fL --max-time 15 \
+    "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json"
+}
+
+MANIFEST=$(fetch_manifest)
+INSTALLER_URL=$(echo "$MANIFEST" | jq -r .windows.installer_url)
+INSTALLER_SHA=$(echo "$MANIFEST" | jq -r .windows.installer_sha256)
+# Each manifest carries a self-consistent installer_url that points at the
+# SAME backend the manifest was served from — V2 manifest → V2 installer URL,
+# V1 manifest → V1 installer URL. No URL mixing across backends.
+
+curl -fL "$INSTALLER_URL" -o resources/ad-setup.exe
+
+# Verify SHA256 before bundling — defends against partial fetches + tampering
+DOWNLOAD_SHA=$(sha256sum resources/ad-setup.exe | awk '{print $1}')
+[ "$DOWNLOAD_SHA" = "$INSTALLER_SHA" ] || { echo "SHA mismatch"; exit 1; }
+```
+
+The desktop AD's built-in auto-updater (`src-tauri/src/updater.rs`) walks an
+ordered `MANIFEST_URLS` list — V2 first, V1 fallback — and uses whichever
+responds first. Once V2 has been the source of truth for a release cycle
+without incident, V1 will be retired from the pipeline.
+
+`version.json` also carries the SHA256 hash of the installer so HD's build
+pipeline can verify the download before bundling.
+
+## Asset naming convention (v1.8.113+)
+
+The Adom Desktop wiki page hosts TWO distinct types of artifacts per release:
+
+1. **Full AD GUI installer** — the actual desktop app (Tauri window, bundled
+   bridges, relauncher). One per OS / architecture.
+2. **CLI companion binary** — a separate executable that talks to AD over
+   the relay. Used by HD's WSL2 container, gallia builds, sibling-app
+   installers, cron jobs. Does NOT contain AD itself.
+
+Both ship together at the same version (single `python scripts/bump-version.py
+patch` bumps both, single `cargo tauri build` + `cargo build --release` in
+`cli/` produces both, single `scripts/release-publish.sh` uploads both).
+
+### Filename pattern
+
+| Artifact | Wiki asset name pattern | Example (v1.8.113) |
+|---|---|---|
+| Full AD GUI — Windows | `Adom.Desktop_<ver>_windows_x64-setup.exe` | `Adom.Desktop_1.8.113_windows_x64-setup.exe` |
+| Full AD GUI — macOS | `Adom.Desktop_<ver>_macos_<arch>.dmg` | `Adom.Desktop_1.8.113_macos_aarch64.dmg` (future) |
+| Full AD GUI — Linux | `Adom.Desktop_<ver>_linux_<arch>.AppImage` | `Adom.Desktop_1.8.113_linux_x86_64.AppImage` (future) |
+| CLI companion — Linux | `adom-desktop-cli_<ver>_linux_<arch>` | `adom-desktop-cli_1.8.113_linux_x86_64` |
+| CLI companion — macOS | `adom-desktop-cli_<ver>_macos_<arch>` | `adom-desktop-cli_1.8.113_macos_aarch64` (future) |
+| CLI companion — Windows | `adom-desktop-cli_<ver>_windows_<arch>.exe` | `adom-desktop-cli_1.8.113_windows_x64.exe` (future) |
+
+The `_cli_` token in the middle is the only signal you need to know "this is
+the companion, not the app." `linux_x86_64` always means Linux x86_64,
+regardless of whether the file is a GUI installer or a CLI binary.
+
+### After install, the binary name is `adom-desktop`
+
+The wiki asset name disambiguates at FETCH time. At INSTALL time the binary
+is renamed to `adom-desktop` so the command users type doesn't change:
+
+```bash
+# Linux example — install the CLI companion
+curl -fL "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/adom-desktop-cli_1.8.113_linux_x86_64" \
+  -o /tmp/ad
+sudo install -m 755 /tmp/ad /usr/local/bin/adom-desktop
+adom-desktop --version
+# → adom-desktop 1.8.113 (<sha>, built <ts>)
+```
+
+### Version.json `.cli` slot
+
+The auto-update manifest (v1.8.113+) carries a typed `cli` sibling section
+for the CLI binaries:
+
+```json
+{
+  "manifest_version": 1,
+  "version": "1.8.113",
+  "windows": { "installer_url": ".../Adom.Desktop_1.8.113_windows_x64-setup.exe", ... },
+  "cli": {
+    "linux_x86_64": {
+      "binary_url":      ".../adom-desktop-cli_1.8.113_linux_x86_64",
+      "binary_filename": "adom-desktop-cli_1.8.113_linux_x86_64",
+      "binary_sha256":   "...",
+      "binary_size":     8012408
+    }
+  },
+  "changelog_url": "...",
+  "minimum_required": "1.3.30"
+}
+```
+
+Pre-v1.8.113 readers ignore `.cli` entirely (additive schema; no
+`manifest_version` bump required). The desktop AD's own auto-updater only
+reads `.windows / .macos / .linux` for its GUI installer URL — it doesn't
+touch `.cli`.
+
+For HD / gallia / Docker-side fetchers, prefer the typed lookup:
+
+```bash
+# Best: read the URL out of the manifest (so the filename can evolve)
+MANIFEST=$(fetch_manifest)
+LINUX_CLI_URL=$(echo "$MANIFEST" | jq -r '.cli.linux_x86_64.binary_url')
+LINUX_CLI_SHA=$(echo "$MANIFEST" | jq -r '.cli.linux_x86_64.binary_sha256')
+curl -fL "$LINUX_CLI_URL" -o /tmp/ad
+[ "$(sha256sum /tmp/ad | awk '{print $1}')" = "$LINUX_CLI_SHA" ] || exit 1
+sudo install -m 755 /tmp/ad /usr/local/bin/adom-desktop
+```
+
+### Windows CLI is shipped with the NSIS (v1.8.114+)
+
+Installing AD on Windows now drops the CLI companion next to the GUI exe
+and adds the install dir to the user's PATH so PowerShell / Claude Desktop /
+cmd find it without configuration:
+
+```
+%LOCALAPPDATA%\Adom Desktop\adom-desktop.exe        ← GUI (Windows subsystem)
+%LOCALAPPDATA%\Adom Desktop\adom-desktop-cli.exe    ← CLI companion (Console subsystem)
+%LOCALAPPDATA%\Adom Desktop\ad-relauncher.exe       ← visible self-restart helper
+```
+
+NSIS's POSTINSTALL hook prepends `%LOCALAPPDATA%\Adom Desktop` to
+`HKCU\Environment\Path` (no UAC — user-level only) and broadcasts
+`WM_SETTINGCHANGE` so freshly-opened shells pick up the change. Already-
+open shells need a re-launch (OS limitation).
+
+After install, in a fresh PowerShell:
+
+```powershell
+adom-desktop-cli --version
+# adom-desktop 1.8.114 (<sha>, built <ts>)
+adom-desktop-cli status
+adom-desktop-cli screenshot_window '{"title-contains":"VS Code"}'
+```
+
+The CLI auto-discovers the local AD via direct-API probe at
+`127.0.0.1:47200` — no relay needed when AD is on the same box. ~5ms RTT
+for compatible verbs; verbs in AD's `cliRequired` list still flow through
+a relay if one's running (HD's WSL2 distro typically owns the relay).
+
+**Relay self-detect (v1.8.114+):** `adom-desktop-cli serve` checks for
+another adom-desktop relay on `127.0.0.1:8766/health` before binding. If
+one already answers, it prints a friendly note and exits cleanly without
+starting a second relay (which would just sit idle — AD's WS client only
+attaches to the first one it finds). Pass `--force-bind` to override.
+
+PREUNINSTALL strips the PATH entry back out and rebroadcasts so an
+uninstall of AD also gets PATH cleanup.
+
+### Legacy alias migration window
+
+Releases prior to v1.8.113 published the Linux CLI as `adom-desktop-linux`
+(no version, no architecture). HD's WSL2 setup and a few cron-based
+installers have that literal filename baked in.
+
+To keep them working through the rename, v1.8.113 publishes BOTH names:
+
+| Name | Status | Wiki URL (V2 / V1) |
+|---|---|---|
+| `adom-desktop-cli_<ver>_linux_x86_64` | **canonical, v1.8.113+** | V2 / V1 |
+| `adom-desktop-linux` | **deprecated alias** — byte-identical copy | V2 / V1 |
+
+Both files are byte-identical with the same SHA256. The legacy alias stays
+through **v1.8.116**, then disappears. Migrate by then. Recommended pattern:
+
+```bash
+# Migration period (v1.8.113 → v1.8.115): try new name, fall back to alias
+LINUX_CLI_URL_NEW=$(echo "$MANIFEST" | jq -r '.cli.linux_x86_64.binary_url // empty')
+if [ -n "$LINUX_CLI_URL_NEW" ]; then
+  curl -fL "$LINUX_CLI_URL_NEW" -o /tmp/ad
+else
+  # Falling back — likely talking to a pre-v1.8.113 manifest
+  curl -fL "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/adom-desktop-linux" \
+    -o /tmp/ad
+fi
+```
+
+Post-v1.8.116, the fallback branch dies; only the canonical name will exist.
skills/adom-desktop/SKILL.md+2246−2246
@@ -1,2246 +1,2246 @@-----name: adom-desktop-description: Use when the user wants to send files to their desktop, control KiCad or Fusion 360, send desktop notifications, or troubleshoot the desktop connection. Provides CLI tools for bridging the Docker container to the user's local machine.------# Adom Desktop--Bridge between Claude Code (running in an Adom Docker container) and the user's desktop applications via WebSocket.--**Install surface:** the canonical install page is `apps/adom-desktop` on the Adom wiki — it hosts the Linux CLI (`docker_binary`), the Windows installer (`.exe`), and the four sibling skills (pup, kicad-bridge, fusion-bridge, + this one). The gallia `adom-desktop-discovery` skill surfaces this page on any related user query. `adom-desktop setup_desktop` also returns a dynamic installer URL pulled from the latest GitHub release.--**First-time setup?** If the user hasn't installed the desktop app yet, see the Setup section below to walk them through downloading, installing, and connecting the app.--Quick check if desktop is connected:-```bash-adom-desktop ping-```--**Companion skills** (installed alongside this one from the wiki):-- `adom-desktop-kicad` — KiCad bridge: launch editors, open designs, install libraries, run DRC, window capture + keyboard/click automation (`plugins/kicad/SKILL.md`)-- `adom-desktop-fusion` — Fusion 360 bridge: launch, open designs, STEP/GLB/.lbr import-export, BOM + API queries, Fusion screenshots (`plugins/fusion360/SKILL.md`)-- `pup` — browser automation (Puppeteer-style): open URLs, screenshot, eval JS, multi-session Chrome (`gallia/skills/pup/SKILL.md`)--## How It Works--```-Claude Code -> adom-desktop <command> -> Relay Server (HTTP :8766) -> WebSocket :8765 -> Adom Desktop App -> KiCad / Fusion 360 / Browser / Shell-```--The `adom-desktop` binary is a single Rust CLI that does everything:-- `adom-desktop serve` -- Start the relay server (WebSocket :8765 + HTTP API :8766)-- `adom-desktop <command> '<json>'` -- Send commands to the desktop app via the running relay--The relay server runs in the Docker container. The Adom Desktop app runs on the user's PC and connects via WebSocket.--## Starting the Relay Server--The relay must be running before the desktop app can connect. **In a container, start it SUPERVISED** (v1.9.168+):--```bash-adom-desktop serve --supervise-```--**Why not `adom-desktop serve &`?** A backgrounded (`&`) relay is still a child of your shell/session. When your session is torn down (you finish, or the container reaps it), the relay dies with it — and then every connected desktop hits HTTP 500 on `/proxy/8765/` and loops "Reconnecting in 30s" **for hours** until a human restarts it (wiki issue #24, hit live 2026-07-23). `--supervise` **detaches the relay into its own session (reparented to init)** so it survives your session, and starts a watchdog that:-- restarts the relay with **exponential backoff** (2s → … → 5 min cap; a clean run resets it — a broken relay is never bludgeoned), and-- **toasts the connected desktops on every restart** with the reason from the relay log, so the owner finds out something went wrong.--```bash-adom-desktop serve --supervise            # detached + self-healing (use this in containers)-adom-desktop serve --supervise --owner AdomLapper   # send restart toasts to one desktop-adom-desktop serve --stop                 # stop the watchdog + relay-```--`serve` (plain, foreground) still works and prints a loud hint telling you it will die with the session — fine for a quick local relay you'll sit and watch. **Caveat:** a full CONTAINER REBOOT still needs a boot hook (cron `@reboot` / the container's init); the watchdog covers session teardown, not host reboot.--This starts:-- WebSocket server on `0.0.0.0:8765` (desktop app connects here)-- HTTP API on `127.0.0.1:8766` (CLI commands go here)--Check if it's running:-```bash-curl -sf http://127.0.0.1:8766/health-```--## First-Time Setup (Install & Connect)--**You are running on a Docker container. You have NO access to the user's desktop.** Guide them step-by-step, ask questions, wait for answers, and verify each step.--> **As soon as you're connected, run `adom-desktop sync_skills` (v1.9.37+).** Installing the-> adom-desktop core pkg gives you only CORE skills — it does NOT give you a DYNAMIC/cloud-> bridge's skill (kicad, fusion, blender, any third-party). `sync_skills` runs `bridge_list`-> and installs each bridge's own skill pkg into `~/.claude/skills/`, so you can actually-> discover + drive whatever bridges THIS desktop has. (The CLI also runs this automatically-> once/day in the background, but doing it on connect makes the bridge skills available to-> the current session immediately.) Re-run it any time the desktop gains a bridge;-> `adom-wiki pkg update` keeps them fresh.--### Step 1: Check if already connected--```bash-adom-desktop ping-```--If this returns `{ "status": "connected" }`, the desktop is already set up -- skip to "Verify the connection" below. If it errors, continue.--### Step 2: Ensure the relay is running--```bash-curl -sf http://127.0.0.1:8766/health-```--If not running (in a container, ALWAYS use `--supervise` so it survives your session): `adom-desktop serve --supervise`--### Step 3: Ask what OS they use--Ask the user: **"What operating system is your desktop/laptop? (Windows, Mac, or Linux)"**--- **Windows 10/11** -- proceed-- **macOS / Linux** -- "Support is coming soon. The desktop app currently only runs on Windows."--### Step 4: Install & connect--Run `adom-desktop setup_desktop` — it auto-detects this container's relay URL and returns a ready-to-run **`register_command`**, a fallback `server_config` JSON, and the `installer_url`. Prefer the zero-paste path:-------**Option 0: Zero-paste via your Adom account (PREFERRED)**--Run the **`register_command`** that `setup_desktop` returned (it's `adom-desktop register_container` with the name + URL filled in). That injects this connection into the **user's Adom cloud profile**. Then the user just:-1. Installs Adom Desktop (from `installer_url`) and opens it,-2. **Signs in with their Adom account** (Connections panel → "Sign in to Adom"),--and the connection appears in their list and auto-connects — **no JSON paste at all**. This is the modern path and the one to try first. (`register_container` needs an Adom cloud container — it uses the mounted account token — and returns `ok:false` on a non-Adom box; if so, use Option A/B below.)--**Already-running desktop? `server_sync` (v1.9.215+).** A signed-in AD reads the cloud profile at boot, at sign-in, and hourly — and `adom-desktop server_sync` (optionally `--target <name>`) makes it re-read RIGHT NOW. The merge is cloud-wins, whole entry including `authToken`. So the flow for adding a server or **rotating a relay token** on live desktops is: update the cloud key (`register_container` writes them) → `server_sync` per desktop → verify with `server_list`. Do NOT use `server_remove` + `server_add` for rotation — removing the entry you are talking through severs your own transport (wiki #387).-------**Option A: Automatic setup via Claude Code (paste fallback)**--If you have **Claude Code** or **Claude Desktop** on your laptop, paste this prompt:--> Install Adom Desktop and connect it to my cloud container. (Most users instead get Adom Desktop bundled with Hydrogen Desktop, which installs and connects it for them; use this prompt when you are driving the desktop from a cloud container.) Download the installer from `<installer_url>`, run it silently, then write this JSON to `%USERPROFILE%\.adom\config.json`:-> ```json-> {"servers":[<server_config JSON>]}-> ```-> Then launch "Adom Desktop" from the Start Menu.--*(Replace `<installer_url>` and `<server_config JSON>` with the actual values from `setup_desktop`.)*--**Option B: Manual setup**--1. Download the installer: **[<installer filename>](<installer_url>)**-2. Run it and follow the prompts — the app installs to the Start Menu as "Adom Desktop"-3. Open **Adom Desktop** from the Start Menu-4. Paste this JSON into the text field that says **"Paste server JSON to add ..."** and press **Enter**:-   ```json-   <server_config JSON>-   ```-5. The server will appear and auto-connect-------Wait for the user to confirm they see a green dot next to the server name before proceeding.--**Important:** Each container has its own relay server. New containers need a new entry -- old entries from previous containers won't work.--### Step 6: Verify the connection--```bash-adom-desktop ping-# Expected: { "echo": "pong", "roundTripMs": ..., "status": "connected" }--adom-desktop status-# Expected: one client with the user's hostname and capabilities--adom-desktop notify_user '{"title":"Hello from Docker!","body":"Your desktop is connected."}'-```--Tell the user what you see. If `ping` succeeds:--> "Your desktop is connected! I can now send files to your machine, open browser windows for visual debugging, control KiCad and Fusion 360, take screenshots of your desktop, and send you notifications."--### Step 7: Optional -- Node.js for browser features--Ask: **"Would you like to use the visual browser debugging feature? This opens Chrome windows on your desktop that I can control."**--- **Yes + Node.js installed** -- "Great, the browser bridge auto-installs deps on first use."-- **Yes + no Node.js** -- "Download Node.js from https://nodejs.org (LTS), install it, then restart Adom Desktop."-- **No** -- skip, they can enable it later--### Common connection issues--| Symptom | Fix |-|---------|-----|-| `ping` returns `"No desktop client connected"` | User hasn't added this container in the desktop app yet |-| Desktop app shows "disconnected" | Check the URL uses `wss://` (not `ws://` or `https://`), port is `8765` |-| Relay server not running | `adom-desktop serve --supervise` (survives your session; see Starting the Relay Server) |-| Multiple stale connections | `adom-desktop kick_all` -- app auto-reconnects within seconds |-| Desktop app not installed | Download from [github.com/adom-inc/adom-desktop/releases](https://github.com/adom-inc/adom-desktop/releases) |--## CLI Tool--```bash-adom-desktop <command> '<json-args>'-```--**Examples:**-```bash-adom-desktop ping-adom-desktop status-adom-desktop browser_open_window '{"sessionId":"dart2","url":"https://example.com"}'-adom-desktop browser_eval '{"sessionId":"dart2","expr":"document.title"}'-adom-desktop browser_screenshot '{"sessionId":"dart2"}'-adom-desktop browser_list_windows-adom-desktop browser_close_window '{"sessionId":"dart2"}'-adom-desktop notify_user '{"title":"Hello","body":"From Docker"}'-adom-desktop shell_execute '{"command":"echo hello"}'-adom-desktop pull_file '{"filePaths":["C:\\Users\\john\\Downloads\\image.png"],"saveTo":"/tmp"}'-```--**Output:** JSON to stdout. Screenshots save to `/tmp/adom-desktop-screenshots/` and return the file path.--**Config location:** The desktop app stores server config at `~/.adom/config.json` (`%USERPROFILE%\.adom\config.json`). This is separate from the binary — config survives updates and reinstalls.--## Available Commands--**Get the full structured command list with descriptions, args, and prerequisites:**-```bash-adom-desktop commands            # aliases: list_commands, help-adom-desktop --target <name> commands   # + THAT box's installed bridges (installed_bridges_on_target)-```-Returns categorized JSON with every command, its required/optional args, return values, prerequisites, and workflow notes. Run this FIRST when you're unsure what a verb is called.--**Two kinds of verbs (v1.9.103 — don't confuse them):**-- **BUILT-IN AD-core verbs** are always available on every Adom Desktop, no install, NOT bridges. This is where screenshots, windows, input, files, registry, shell, tunnel, update, and notify live. A whole-screen shot is `desktop_screenshot_screen`; one window (with chrome) is `desktop_screenshot_window {"hwnd":N}` or `{"titleContains":"..."}` (get hwnds from `desktop_list_windows`).-- **BRIDGE verbs** (`kicad_`/`fusion_`/`browser_`/`blender_`/`nbrowser_`…) need that bridge installed — see `installed_bridges_on_target` in `commands`, or run `bridge_list`.--If a verb returns `errorCode:"unknown_verb"`, **read the error's `_hint`** — it carries a fuzzy "did you mean" (e.g. `desktop_screenshot` → `desktop_screenshot_screen`) and tells you whether the right verb is built-in or a bridge. Don't conclude a capability is "a missing bridge" from a guessed verb name; call `commands` and check.--### Connection Management--- `ping` -- 5s round-trip test. Use BEFORE browser/shell commands to verify the desktop connection is alive.-- `status` -- Check who's connected, their capabilities, desktop paths, and **app installation status**. The `desktop.apps` object shows:-  - `kicad.installed` / `kicad.version` / `kicad.bridgeRunning`-  - `fusion360.installed` / `fusion360.running` / `fusion360.bridgeRunning` / `fusion360.addinInstalled` / `fusion360.addinConnected`-  - `browser.bridgeRunning`-- `kick_all` -- Force-disconnect all WebSocket clients. Active Adom Desktop apps auto-reconnect within seconds.--#### Programmatic server registration (v1.8.22+)--Three verbs let external apps (Hydrogen Desktop's WSL2 workspace, in particular) register their relay server with adom-desktop without the user pasting JSON into the GUI by hand. Mirrors the GUI's Quick Add bar + connect/disconnect buttons.--- `server_add` -- Upsert a relay server by `name`. If a server with that name already exists, the URL (and optionally authToken) is updated rather than creating a duplicate.-  ```bash-  adom-desktop server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'-  # → {ok:true, name, url, id, connected:bool, created:bool, _hint}-  ```-  - `name` (required) — dedup key. Repeat calls with the same name are idempotent.-  - `url` (required) — relay WebSocket URL.-  - `authToken` (optional) — defaults to a dev token (leave unset unless your relay requires one).-  - `autoConnect` (optional, default `true`) — connect right after upsert. Set `false` to add the entry without dialling out.-  - Behavior: same URL + already connected → no churn, returns ok. URL changed + autoConnect=true → disconnect old loop + spawn new one. URL changed + autoConnect=false → disconnect old, leave entry registered but not connected.-- `server_remove` -- Disconnect (if connected) and delete an entry by name. Returns `{ok, removed:bool, wasConnected:bool}`. Idempotent (removing a non-existent entry returns ok with removed:false).-  ```bash-  adom-desktop server_remove '{"name":"hydrogen-workspace"}'-  ```-- `server_list` -- Persisted server list with live connection status: `{servers:[{name, url, id, autoConnect, enabled, connected:bool, clientCount:0|1, status}]}`. `status` is the fine-grained `connected | reconnecting | disconnected` state machine value.--Persists to the same `~/.adom/config.json` the GUI uses, so entries survive a GUI restart. The `ws_client` supervisor (runs every 30s inside the GUI) auto-reconnects entries with `autoConnect:true` on next launch.--HD-style usage:-```bash-# At Hydrogen container startup-adom-desktop server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'--# At Hydrogen container shutdown-adom-desktop server_remove '{"name":"hydrogen-workspace"}'-```--#### Addressing multiple Adom Desktops on one relay (v1.8.133+)--One relay can have **several** Adom Desktops connected at once — e.g. a laptop and an Azure VM, eventually many. Each AD has a **name** (its `--client-name` / Settings value, defaulting to the OS hostname). The cloud CLI addresses a specific one, or fans out to all:--- `adom-desktop targets` — list connected ADs: `{count, targets:[{name, hostname, version, os, connectedAt}]}`. Discover names here first.-- `adom-desktop --target <name> <verb>` — route the verb to that AD only. `--target` is a **global** flag (goes before the verb; case-insensitive; matches `clientName`, else `hostname`). `ADOM_TARGET=<name>` sets a default (the flag wins).-- `adom-desktop --target all <verb>` — fan out to **every** connected AD; returns `{broadcast:true, targets:[...], results:{<name>:<verb-result>}, summary:{total,ok,failed}}`. A per-AD timeout/failure is isolated to that AD's entry.--```bash-adom-desktop targets-adom-desktop --target azurevm desktop_screenshot_screen-adom-desktop --target all hd_status        # every box at once, results keyed by name-```--**No-target behavior:** with exactly ONE AD connected, no `--target` is needed (back-compat). With MULTIPLE connected and no `--target`, the call FAILS with `{error:"ambiguous_target", connected:[...], _hint}` (exit 1) — it won't guess which box. A bad name → `{error:"target_not_found", requested, connected:[...]}` (exit 1). Set each AD's name with the `--client-name <name>` launch flag (Hydrogen passes this when spawning the embedded AD), the Settings field, or `config.json` `client_name`. Full design + the relay-routing internals: [`skills/MULTI_AD_ADDRESSING.md`](MULTI_AD_ADDRESSING.md).--#### Direct HTTP API for sibling apps (v1.8.25+)--If you're authoring a sibling Tauri app on the same machine (Hydrogen Desktop, or any future "Adom-family" app), you can skip the CLI binary entirely and POST commands straight into the running adom-desktop GUI on `127.0.0.1:47200` (was `127.0.0.1:8770` through v1.8.32 — moved to be a better neighbor to Hydrogen on 47080+ and avoid the 8000-range collision risk). The endpoint runs inside the GUI process (loopback-only bind), uses the same dispatcher the WS path uses, and returns the same JSON shape as `adom-desktop <verb>` — including every `_hint` field.--| Method | Path | Body | Returns |-|---|---|---|---|-| `GET` | `/health` | — | `{"ok":true,"service":"adom-desktop"}` cheap probe |-| `GET` | `/status` | — | service banner + version + schema + `directApi.cliRequired` list |-| `POST` | `/command` | `{"app":"<ns>","command":"<verb>","args":{...}}` | The verb's normal payload (200 OK), or `{error, errorCode, _hint}` (4xx/5xx) |--**v1.8.33+ port discovery (sibling apps READ THIS):** Don't hardcode 47200. The GUI may have bound to 47200-47209 instead if the default port was taken (zombie socket, dev instance, third-party collision). Discovery protocol:--1. Read `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`) — single-line `host:port`, written by the GUI at bind time, removed at graceful shutdown-2. If file missing or its port doesn't `/health`, scan `127.0.0.1:47200..=47209` for any port answering with `{"ok":true,"service":"adom-desktop"}`-3. Validate `service == "adom-desktop"` to disambiguate from other apps that might bind a port in our range--The CLI does this automatically (see `cli/src/direct_probe.rs`). For sibling-app Rust code, see [`skills/DIRECT_API.md`](DIRECT_API.md) for a copy-paste-ready helper.--**Per-verb timeouts (v1.8.31+):** the direct API's command-timeout is no longer a hardcoded 120s — it mirrors the CLI dispatcher's per-verb table (`walk_cloud_tree`/`search_cloud_files`=620s; heavy exports=320s; `bridge_install`/`fusion_start`=300s; default=120s). Caller can override via `args.timeout` (seconds, clamped to 1800).--Example — programmatic server registration without the CLI:--```bash-curl -X POST http://127.0.0.1:47200/command \-  -H 'Content-Type: application/json' \-  -d '{"app":"desktop","command":"server_add","args":{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}}'-# → identical JSON to `adom-desktop server_add '{...}'`, including the _hint-```--**What's safe to send directly:** essentially everything sync (`server_*`, `bridge_list`, `hd_status`, `hd_build_status`, `desktop_list_windows`, `desktop_screenshot_*`, `kicad_*`, `fusion_*`, `browser_screenshot`, `notify_user`, ...) plus async-dispatching verbs that return a job id in <500 ms (`bridge_install`, `hd_build`, `desktop_install_kicad`, ...).--**What requires the CLI fallback:** verbs returning a structured `errorCode:"cli_required"` from this endpoint — currently `pull_file`, `send_files`, `shell_execute`. These use binary streaming or multi-minute approval polling that doesn't fit a single synchronous HTTP request. `GET /status` returns the full list at runtime so callers can branch defensively.--Full integration guide (loopback trust model, recipes for Hydrogen startup/shutdown, retry/backoff patterns, port-discovery code in Rust + TypeScript) lives in [`skills/DIRECT_API.md`](DIRECT_API.md) in this repo, and as a wiki asset attached to `apps/adom-desktop`.--#### Embedded mode — AD bundled inside Hydrogen Desktop (v1.8.42+)--When Hydrogen Desktop (Hydrogen) bundles AD, AD enters "embedded mode" at boot. Hydrogen owns the system-tray icon (AD's is suppressed), Hydrogen owns the Adom Cloud login (Hydrogen's session token is handed to AD via Phase 4's existing channels), Hydrogen owns auto-updates (AD's wiki-poll is disabled — Hydrogen bundles a fresh AD on every Hydrogen release), and Hydrogen spawns AD hidden (its tray "Open Adom Desktop" surfaces the window on demand). Standalone AD users see zero change — none of the signals fire.--**Detection** — three-signal cascade (any one triggers embedded mode):--1. `--embedded` CLI flag (what Hydrogen passes on every spawn)-2. `ADOM_EMBEDDED=1` env var (backup channel)-3. `%LOCALAPPDATA%\Adom Desktop\embedded.json` marker file (survives AD restarts; written by Hydrogen's installer; deleted by Hydrogen's uninstaller)--**CLI flags Hydrogen passes when spawning AD**:--```bash-adom-desktop.exe --embedded --start-hidden \-  --relay-url ws://127.0.0.1:8765 --relay-name hydrogen-desktop \-  --session-token <hydrogen's-stored-token>-```--- `--embedded` → enter embedded mode (also writes the marker)-- `--start-hidden` → boot with main window invisible. **v1.8.119+: honored in embedded mode** (Hydrogen owns the surface; AD stays in the tray and opens via `desktop_window_show`). v1.8.102–118 had a bug where embedded mode force-showed the window even with `--start-hidden`; that's fixed. The machine owner can still flip AD's "Always show the window at launch" Settings checkbox (config `always_show_window_on_launch`, default OFF) to override and always show — a personal-dev-box preference, not the product default.-- `--shell-approval <prompt|1h|24h|permanent>` → Hydrogen's passthrough of its user's shell-approval choice (v1.8.116+, session-only)-- `--relay-url <url>` → upsert this relay via existing `server_add` dedup; default name `hydrogen-desktop`-- `--relay-name <name>` → override the default name-- `--session-token <tok>` → already covered by Phase 4 handoff (env / CLI arg / file)--**Docker introspection** — `adom-desktop desktop_embedded_status` returns `{embedded, owner, source, enteredVia, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` so cloud-side callers can branch on whether AD is standalone or HD-managed. **v1.8.73+:** `enteredVia` reports `"launch-flag"` (Hydrogen spawned AD as a child via `--embedded`) or `"runtime-adopt"` (AD was running standalone; Hydrogen adopted it at runtime) — see "Runtime adopt + release" below.--**New verbs Hydrogen uses to drive AD** (all also usable standalone):--| Verb | Purpose |-|---|---|-| `desktop_window_show` | Bring AD's main window to foreground |-| `desktop_window_hide` | Hide AD's main window (app keeps running) |-| `desktop_connect_all` | Spawn ws_loop for every enabled server |-| `desktop_disconnect_all` | Drop every live WS connection (entries stay in config) |-| `desktop_shutdown` | Explicit AD termination (stop bridges, then exit) |-| `desktop_embedded_status` | Introspect current embedded state (incl. `enteredVia`) |-| `desktop_embedded_enter` | **v1.8.73+** runtime adopt: standalone → embedded as `runtime-adopt` |-| `desktop_embedded_release` | **v1.8.73+** revert to standalone. **v1.8.77+: always reverts, never exits AD** (regardless of `entered_via`) — use `desktop_shutdown` for explicit exit |-| `desktop_logout` | Clear AD's `~/.adom/session.json` (Hydrogen's sign-out propagation) |--#### Runtime adopt + release (v1.8.73+) — the LaunchFlag-vs-RuntimeAdopt distinction--Hydrogen has two ways to put AD into embedded mode:--1. **Spawn AD with `--embedded`** (LaunchFlag) — Hydrogen launches a fresh AD process as its child. AD enters embedded with `entered_via=launch-flag`. When Hydrogen subsequently dies ungracefully (without calling `/embedded/release` or `desktop_shutdown` first), AD's 60s `embedded_monitor` tick notices Hydrogen's control port is dead and **exits AD** (the "no hanging chad" rule — no tray-less orphan).-2. **Adopt at runtime via `POST /embedded/enter`** (RuntimeAdopt) — AD was already running standalone; Hydrogen calls the direct-API endpoint to flip AD into embedded mode without a respawn. AD enters embedded with `entered_via=runtime-adopt`, preserving its open state, current PID, all in-flight connections. When Hydrogen subsequently dies ungracefully, AD's monitor **reverts AD to standalone** — tray restored, footer pill hidden, marker deleted. AD process stays alive.--The rule is simple: **whoever started AD owns AD's death**. If AD started itself (standalone → runtime adopt), Hydrogen's death doesn't take AD down with it.--**Two new direct-API endpoints** (v1.8.73+, sugar over `POST /command`):--```bash-# Adopt — runs the same handler as desktop_embedded_enter:-curl -s -X POST http://127.0.0.1:47200/embedded/enter--# Release — runs the same handler as desktop_embedded_release.-# v1.8.77+: ALWAYS reverts to standalone. AD never exits via this call.-curl -s -X POST http://127.0.0.1:47200/embedded/release-```--**Probe target (v1.8.75+):** the 60s `embedded_monitor` checks Hydrogen's *control port* (default `47084`, discovered from `%APPDATA%\hydrogen-desktop\ports.json` key `control`), not Hydrogen's legacy `:9001` webview-eval port. Same discovery `hd_api` uses. Pre-v1.8.75 this was hardcoded to `:9001` — Hydrogen doesn't bind that port anymore, so the probe always reported Hydrogen dead and every embedded AD reverted/exited within 60s of Hydrogen starting.--**Live frontend update (v1.8.76+):** the "Embedded · Hydrogen" footer pill responds to Tauri `mode-changed` events emitted on every transition. So if AD's window is open when Hydrogen adopts it (or Hydrogen releases it), the pill flips immediately — no page reload, no manual refresh.--**Stale-marker safety (v1.8.74+):** if the marker file says `entered_via=launch-flag` AND Hydrogen's control port isn't responding at boot, AD deletes the marker BEFORE exiting. Without the delete, every subsequent manual launch (no `--embedded` flag) would hit the same exit branch and AD would be unlaunchable until the user manually rm'd the marker. The delete-before-exit makes the next launch start standalone normally.--**Shell auto-approve is a user preference** (v1.8.116+ — replaces the v1.8.44 embedded-permanent default). The safe default everywhere is per-command approval. Resolution order at AD boot:--1. `--shell-approval <prompt|1h|24h|permanent>` launch flag — Hydrogen's passthrough of its user's choice when spawning AD embedded. **Session-only**: never persisted by AD.-2. The durable Settings checkbox ("Auto-approve shell commands permanently", config field `shell_auto_approve_permanent`, default OFF). Also set/cleared via `shell_auto_approve '{"permanent": true|false}'`.-3. A restored timed grant from a previous session (+1h/+24h buttons, `duration_secs` calls).-4. Otherwise: prompt per command.--In embedded mode **Hydrogen owns the preference** (Hydrogen is the parent, AD the child): AD's Settings checkbox goes read-only with "Managed by Hydrogen Desktop", and the footer shows "**Permanent** *(managed by Hydrogen Desktop)*" when armed. Hydrogen changes it at runtime with the same verb:--```bash-# revoke everything (timed + permanent + the saved checkbox):-adom-desktop shell_auto_approve '{"duration_secs": 0}'--# set the durable preference (equivalent to ticking the Settings checkbox):-adom-desktop shell_auto_approve '{"permanent": true}'-```--Migration note: pre-1.8.116 embedded boots persisted a permanent grant that leaked into standalone sessions. On first v1.8.116 boot, a permanent-class grant on disk WITHOUT the config preference is cleared — re-opt-in via the checkbox or the verb if you actually wanted it.--**⚠ v1.9.90 — a cloud/relay AI CANNOT grant shell approval itself.** The `shell_auto_approve` examples above are Hydrogen's (a trusted LOCAL caller) or the human's own. From a **relay** connection (the cloud AI), a GRANT (`shell_auto_approve {"permanent":true}` / `{"duration_secs":N>0}`) is REFUSED with `errorCode:"human_only"` and instead POPS the human approval prompt in Adom Desktop. **The AI's path: call `request_shell_approval` (or just let the gate/refusal auto-surface the prompt) → ask the user to click Approve 1 hr / 24 hr / Bypass Permissions → RETRY your original verb.** Only a human clicking in AD (or Hydrogen proxying its own user) grants. A REVOKE (`duration_secs:0`) is allowed for the AI.--#### Bridge ports are dynamic — you never need to know one (v1.8.31+)--KiCad, Fusion, Browser/Puppeteer, and all third-party bridges now bind OS-assigned ephemeral ports (not the legacy 8772/8773/8851 you may remember). The runtime port changes every spawn. **Callers never need to know it.**--- The CLI verb namespace (`kicad_*`, `fusion_*`, `browser_*`, plus any third-party `<bridge>_*`) is the contract. Always go through that.-- adom-desktop's direct API forwards to whatever port each bridge is on at the moment of the call.-- `bridge_list` reports `spawn.runtimePort` per bridge for debugging — do NOT hardcode it anywhere.-- Why this changed: Hydrogen's bridges also wanted 8772/8773/8851; we used to collide silently. Dynamic ports = clean coexistence.--### Managing bridges (the AI fully controls bridges on its own)--Bridges are AD's plugins (kicad, puppeteer, fusion360 ship bundled; others install from the wiki). You — the AI — can see, update, install, remove, pause, and resume them entirely through verbs. No human step is required.--| Verb | What it does |-|---|---|-| `bridge_list` | Every installed bridge: `name`, `displayName`, `version` (what AD runs now), `source` (bundled / cache / third_party), `paused`, spawn kind, verbPrefixes. Start here. |-| `bridge_check_updates` | **READ-ONLY.** Per bridge: `current` vs `latest` (wiki) + `updateAvailable`. Downloads nothing. The "what's behind?" probe. |-| `refresh_bridges` | Apply updates. No args = sync ALL bridges from the wiki. `{"name":"kicad"}` = update just one. |-| `bridge_install` | Install a new third-party bridge from a wiki manifest URL (`{"manifestUrl":"…/<bridge>-bridge-manifest.json"}`). On `success:true` the **capsule appears in the AD GUI immediately** (no verb call needed; it's gray/idle until first use, then green). On `success:false` **nothing is registered and no capsule appears** — read `error` (a `read zip body`/`builder error` means the manifest's zip URL couldn't be fetched). Don't treat a parsed manifest as a completed install; confirm with `bridge_list`. The manifest `url` may be relative (resolved against the manifest's dir, v1.8.169+). |-| `bridge_uninstall` | Remove a bridge (`{"name":"…"}`). Bundled ones revert to the bundled copy; third-party ones are fully removed. |-| `bridge_pause` / `bridge_resume` | Flip a routing flag so a bridge's verbs fail-fast with `bridge_paused` (or resume). Does NOT kill the process. |--**Naming convention for published bridges (3rd-party authors, read this):** when you publish a bridge to the Adom wiki, its **page title / catalog descriptive name MUST start with `Adom Desktop - `** (spaced hyphen) — e.g. `Adom Desktop - Altium Bridge`, matching the bundled bridges (`Adom Desktop - Puppeteer Bridge`, `Adom Desktop - KiCad Bridge`). This keeps every AD bridge grouped with consistent names in app-index pages instead of scattering as bare slugs. It applies to the **wiki/catalog title only** — NOT `bridge.json` `displayName` (the in-app chip label, which stays tool-descriptive like `Altium Designer`) or `name` (the lowercase slug). Full authoring walkthrough: the `adom-desktop-bridge-sdk` skill (the `adom-desktop-bridges` wiki page) + its fill-in-the-blanks templates.--**Typical "am I on the latest bridges?" flow:**-```bash-adom-desktop bridge_check_updates            # read-only: see current vs wiki per bridge-# → {bridges:[{name,current,latest,updateAvailable,source,wikiReachable}], updatesAvailable:N}-adom-desktop refresh_bridges                  # update ALL that are behind-adom-desktop refresh_bridges '{"name":"kicad"}'   # or just one-```--**Version semantics (so the numbers make sense):**-- `current` = what AD actually runs = the newer of the writable cache and the bundled copy (version-aware, numeric semver — `1.0.10 > 1.0.9`).-- A bundled bridge that ships AHEAD of the wiki shows `updateAvailable:false` — AD correctly keeps the newer bundled copy. **puppeteer is bundled-only** (its node_modules deps exceed the wiki file limit), so it never shows a wiki update; it updates with the AD installer.-- After an `updated` action, the bridge SERVER PROCESS still holds the old code in memory until it respawns. `is_bridge_running` is version-aware (v1.8.142+) and forces a respawn on the next verb call, so you normally don't need to kill anything — but if you want it instant: `taskkill /f /im python.exe` (kicad/fusion) or `node.exe` (puppeteer).--**Auto-update cadence:** a running AD also auto-syncs bridges on launch + every 4 hours. `bridge_check_updates` / `refresh_bridges` (or the GUI's **"Check for updates"** button next to "+ Bridge") are the on-demand "I just published, pick it up now" path.--#### Bridge lifecycle — one instance, stable port (v1.8.153+)--A dynamic bridge gets **one stable port** that AD reuses across every spawn and across AD restarts (persisted in `~/.adom/bridge-ports.json`). A long-lived external client (e.g. a browser extension that dials the bridge) keeps reaching the same port — no chasing a fresh ephemeral port each cycle, no "the client is on bridge A but AD routes to bridge B" split. AD enforces a **single instance**: it tracks every spawned bridge PID, reaps a stale/wedged one before respawning on the same port, and `bridge_stop_all` reaps orphans too.--`spawn.persistent` (in `bridge.json`) is now surfaced in `bridge_list` (`spawn.persistent`) and means: **AD auto-respawns this bridge on death**, on its stable port (a supervisor runs each ~15 s). So "kill the bridge to load new code" → AD brings it right back at the same port the client keeps dialing. `bridge_install` / `refresh_bridges` also no longer fail with the cache-dir lock (AD reaps the process holding the dir as its cwd, then swaps; in-place merge fallback if a handle lingers).--**v1.8.186/187 — first-class bridges (panel + remote management + optional manifest fields).** The AD window has a full-height **Bridges sidebar** that shows each bridge as a mini-app (hero image, LED, rich tooltip); clicking one opens a wide pop-out with a **live console** (its `~/.adom/bridge-logs/<name>.log` + the per-bridge command history), a **ports/process debug view** (live PIDs — `>1` flags a runaway), persistent-vs-one-off + codebase-language badges, and all management actions (install/uninstall/**kill**/restart/pause/resume + "filter activity log to this bridge" + "copy contribution prompt"). The same management is available **remotely** to cloud/WSL2 threads via verbs: `bridge_info`/`bridge_detail` (rich aggregate), `bridge_log_read {name, sinceOffset?}` (**cursor-streaming remote tail -f**), `bridge_kill` (wipe one bridge's processes). As of v1.8.186 **all** bridges write their per-bridge log (bundled kicad/fusion360/puppeteer included, not just dynamic ones). New **optional** `bridge.json` fields (all back-compat, surfaced in the panel): `hero` (a relative filename on your wiki page, or a full URL — your app-icon; absent → a monogram tile), `languages` (override the auto-scan, e.g. `["Python"]`), `releasedAt` (ISO date; absent → bridge.json mtime).--**v1.8.156 — strays + self-documenting hints.** AD now tracks every bridge PID it spawns (persisted across AD restarts) and **reaps all stray instances** before respawning — so you don't end up with 4 copies of one bridge fighting over the routing. `bridge_list` surfaces `spawn.instanceCount` per dynamic bridge (normally 0 or 1; **>1 = strays piled up** — visible at a glance). If a bridge verb fails to reach a healthy bridge, the error `_hint` tells you to retry / check `instanceCount` / `process_list`; and a verb response right after a (re)spawn carries an `_ad_note` saying the external host reconnects within a few seconds — **retry** instead of treating a transient "not connected" as terminal. For verb discovery from a bridge, `GET <ADOM_DIRECT_API_URL>/commands` lists every reachable verb (see [`skills/DIRECT_API.md`](DIRECT_API.md)).--#### Bridge status chip — self-reported LED + tooltip (v1.8.152+)--Each bridge has a status dot in the AD GUI sidebar. **AD is a pure renderer of bridge health** — a live bridge decides its own LED color and hover text; AD just paints what the bridge reports. This replaced the old scheme where AD inferred the LED from whether it was holding a spawn handle, which left fire-and-forget (dynamic/installed) bridges stuck RED even while healthy and routing.--**The contract (all three fields OPTIONAL).** On the endpoint AD already polls — your `bridge.json` `healthEndpoint` (default `/health`; the sample bridges use `/status`) — return a 2xx JSON body that MAY include:--| Field | Type | Meaning |-|---|---|---|-| `led` | `"green"` \| `"yellow"` \| `"red"` | The dot color. Green = healthy; yellow = degraded/warning; red = the bridge itself knows it's broken (e.g. lost its upstream). |-| `summary` | string | Short label shown next to the dot. Keep it tiny (≤ ~24 chars) — e.g. `"2 windows"`, `"ext disconnected"`. |-| `tooltip` | string | Full hover text. Newlines allowed; no hard length limit (keep it readable). |--**Rules AD enforces:**-- Endpoint returns 2xx **with** `led`/`summary`/`tooltip` → rendered **verbatim**, no parsing or logic.-- Endpoint returns 2xx **without** them → AD defaults to **green** (you're reachable) + a generic tooltip. Existing bridges work unchanged — this is opt-in.-- Endpoint **unreachable** → AD owns this ONE state: a **gray/offline** dot + AD's own tooltip ("Not running" / "Bridge not reachable"). A dead bridge can't self-report, so AD never lets a bridge claim "offline" — it detects it.--**Cadence:** AD re-polls every ~15 s, and a dynamic bridge flips green the instant it spawns. So a bridge that surfaces live counters (open windows, queue depth, ext-connection state) in `summary`/`tooltip` updates in the GUI within a tick.--Reference implementations: [`scripts/sample-bridges/hello-python/server.py`](../scripts/sample-bridges/hello-python/server.py) and [`hello-rust/src/main.rs`](../scripts/sample-bridges/hello-rust/src/main.rs) both self-report `led`/`summary`/`tooltip` on `/status` — fork either.--### File Transfer--- `send_files` -- Send files from the Docker container to the desktop. Files are base64-encoded in transit.-  - `filePaths`: array of absolute paths on the server-  - `targetApp`: "kicad", "fusion360", or "general"-  - `dest`: **absolute** Windows path (under a safe root) to land the files exactly where you want (v1.8.148+). Prefer this when you know the target dir.-  - `destinationFolder`: **relative subfolder only** (e.g. `"kicad/symbols"`, `"fusion"`). The desktop app controls the base directory. **Absolute paths are rejected.** With neither `dest` nor `destinationFolder`, files land in the laptop's **Downloads** folder.-  - Returns `destinationPaths[]` with the absolute path of every saved file, plus (v1.8.168) `savedToDir` (the folder) and a rich `_hint`. **The files land on the LAPTOP (Windows host), not in your container** — verify with `list_dir`, pull one back with `pull_file`.-  - **`send_files` only COPIES bytes — it does NOT install or register a bridge.** Dropping bridge files with `send_files` will NOT make a capsule appear or verbs route. To add a bridge use `bridge_install '{"manifestUrl":"…/<bridge>-bridge-manifest.json"}'`, then confirm with `bridge_list`.--- `pull_file` -- Pull files from the desktop to the container.-  - `filePaths`: array of absolute Windows paths on the desktop-  - `saveTo`: directory on the container to save files (default: `/tmp`)-  - **Streaming since v1.4.3.** Each file is transferred as 1 MiB binary WS frames straight to disk on the Docker side, with incremental SHA256 verification. The legacy 30s base64-JSON path is gone — large files (50 MB+ datasheets, 75 MB reference manuals) no longer time out. Per-file timeout is 600s.-  - Returns `files: [{name, path, size, sha256, chunks}]`. Use `sha256` to verify the transfer (the desktop side computes it during streaming and the container side verifies on completion; mismatch deletes the partial file and reports failure). `chunks` is the count of 1 MiB binary frames received.-  - When at least one but not all files succeed: `success` is `true`, `errors[]` lists the failures alongside `files[]`. When ALL fail: `success` is `false`.--### Desktop Notifications--- `notify_user` -- Send a native Windows toast (own AUMID + Adom logo). v1.9.33: rich types — progress bar, sticky reminder, action buttons that **bring AD to the foreground** on click + round-trip via `notify_response`.-  - `title`, `body`, `level` (info/success/warning/error/emergency), `actions` (label buttons) — plus `scenario` ("reminder"=sticky), `durationLong`, `buttons` (`[{label, arguments?}]`), `progress` (`{title, value 0..1, status, valueOverride}`), `inputs`, `id` (correlation id for the round-trip).-  - Full guide + copy-paste examples per type: the **adom-desktop-notify** skill ([`skills/NOTIFY.md`](NOTIFY.md)).-- `notify_response` -- Poll which toast button the user clicked, by correlation `id`. `{pending:true}` until they click; `{pending:false, action}` once they do (consumed on read).--### KiCad (`kicad_*`) — bridge-owned; light pointer--The KiCad bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/adom-desktop-kicad-bridge`). This page keeps only the pointer: run `adom-desktop sync_skills` after connecting and the full KiCad guide (project workflows, three-tier `kicad_open_*` routing, DRC recipes, library install) lands in `~/.claude/skills/`.--Most-used verbs to get moving:--```bash-adom-desktop kicad_open_board '{"path": "C:/projects/board/board.kicad_pcb"}'-adom-desktop kicad_screenshot_all '{}'          # every KiCad window incl. error dialogs, as an array-adom-desktop kicad_run_drc '{}'-adom-desktop kicad_window_info '{}'-adom-desktop kicad_open_symbol_editor '{"library": "Device"}'-```--If KiCad is not installed, the bridge knows how to install it for the user — offer, never point them at a download page. Full details: the bridge's own skill.--### Fusion 360 (`fusion_*`) — bridge-owned; light pointer--The Fusion 360 bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/adom-desktop-fusion-bridge`). Run `adom-desktop sync_skills` and the full guide (cloud files, import/export, add-in troubleshooting, APS search) lands in `~/.claude/skills/`.--Most-used verbs:--```bash-adom-desktop fusion_start '{}'                       # launches Fusion; slow on cold start (minutes)-adom-desktop fusion_get_app_state '{}'               # the status/poll verb-adom-desktop fusion_addin_status '{}'-adom-desktop fusion_dismiss_blocking_dialogs '{}'-adom-desktop fusion_send_key '{"key": "Escape"}'-```--Fusion launches take 2-4 minutes cold; respect `timeoutSeconds` + `statusVerb` on responses instead of re-issuing. Full details: the bridge's own skill.--### Launching apps (generic)--Two generic CLI commands exist for launching any Windows executable safely — they verify the target exists BEFORE handing it to the OS, so you never trigger a "Windows cannot find" GUI dialog:--- `adom-desktop find_exe '{"name":"..."}'` -- Resolve an exe by absolute path, glob (e.g. `C:/.../webdeploy/production/*/Fusion360.exe` returns newest), bare name (searches PATH + Start Menu `.lnk` targets). Returns `{path, source}`. Does NOT launch.-- `adom-desktop launch '{"path":"...", "args":[...], "cwd":"...", "detached":true}'` -- Same resolution rules as `find_exe`, then spawns. Fails in terminal (exit 1) with a clear error if the path doesn't exist. **Always prefer this over raw `start`.**--For Fusion specifically, use `fusion_start` — it wraps `launch` plus the full startup-picker / add-in-readiness dance.-- `fusion_import_step` -- Import a STEP/STL/IGES file into Fusion 360-- `fusion_open_lbr` -- Open an EAGLE .lbr library file-- `fusion_open_electronics` -- Check if the Electronics workspace is active-- `fusion_electron_run` -- Execute any EAGLE command via Electron.run. Returns rich state: `activeWorkspace`, `activeDocument`, `commandType`, `_hint`, and `fusionOperations` (diff of Fusion's internal operation log showing what actually fired). **Avoid blocking commands** — see list below. **Full EAGLE command reference:** [`skills/eagle-commands.md`](./eagle-commands.md) — popular/safe commands split from blocking/modal ones, with layer reference and chaining syntax.-- `fusion_execute_text_command` -- Low-level app.executeTextCommand() access. Returns the command result plus workspace context.-- `fusion_board_info` -- Get structured board data from the open PCB layout. Returns: component placements (name, package, x, y, rotation), net names, copper traces, layer setup, board thickness, DRC violations. Much richer than a screenshot — gives exact coordinates and connectivity. Requires a .brd board open in PCB Editor.--#### `fusion_electron_run` — EAGLE Command Execution--Executes EAGLE commands inside Fusion 360's Electronics workspace. Works in **Schematic Editor**, **PCB Editor (Board Layout)**, and **Electronics Library** contexts.--**How it works:** Sends the command string via Fusion's `Electron.run` text command. EAGLE's `Electron.run` is fire-and-forget — it never returns output or throws on invalid commands. To compensate, the handler snapshots Fusion's internal operation log (`Diagnostics.RecentOperations`) before and after execution, returning a diff showing what actually fired.--**Usage:**-```bash-# Basic command-adom-desktop fusion_electron_run '{"command": "WINDOW FIT"}'--# Multiple commands in sequence (use semicolons)-adom-desktop fusion_electron_run '{"command": "DISPLAY NONE; DISPLAY 1 16 17 18 20 21"}'--# Navigate in library editor-adom-desktop fusion_electron_run '{"command": "EDIT SOIC8.pac"}'-adom-desktop fusion_electron_run '{"command": "EDIT RESISTOR.sym"}'-adom-desktop fusion_electron_run '{"command": "EDIT MYDEVICE.dev"}'-```--**Response fields:**-- `activeWorkspace` — Current workspace (Schematic Editor, PCB Editor, Electronics Library)-- `activeDocument` — Name of the open document-- `commandType` — Detected type: `view_control`, `layer_control`, `edit`, `design_rule`, etc.-- `editorType` — For EDIT commands: `package`, `symbol`, `device`-- `fusionOperations` — Array of Fusion operations that fired (diff of internal log)-- `hint` — Human-readable description of what happened-- `rawResult` — Raw return from Electron.run (usually empty)--**EAGLE Command Reference — Safe for automation:**--| Command | Context | Description |-|---------|---------|-------------|-| **View / Navigation** | | |-| `WINDOW FIT` | Any | Zoom to fit all content |-| `WINDOW (x1 y1 x2 y2)` | Any | Zoom to specific area (coordinates in current units) |-| `DISPLAY ALL` | Any | Show all layers |-| `DISPLAY NONE` | Any | Hide all layers |-| `DISPLAY 1 16 17 18 20 21` | Board | Show specific layers by number |-| **Grid** | | |-| `GRID MM 0.1` | Any | Set grid to 0.1mm |-| `GRID MIL 25` | Any | Set grid to 25mil |-| `GRID INCH 0.05` | Any | Set grid to 0.05 inch |-| **Board Layout** | | |-| `RATSNEST` | Board | Recalculate airwires (unrouted connections) |-| `RIPUP` | Board | Remove all routed traces |-| `RIPUP *` | Board | Remove all traces (same as RIPUP with no selection) |-| `ROUTE` | Board | Start auto-router |-| `DRC` | Board | Run design rule check |-| `BOARD` | Schematic | Switch to paired board layout |-| `SCHEMATIC` | Board | Switch to paired schematic |-| **Schematic** | | |-| `VALUE value` | Schematic | Set component value |-| `NAME name` | Any | Rename selected element |-| `SMASH` | Any | Detach name/value labels from components |-| **Library Editor** | | |-| `EDIT name.pac` | Library | Open a package (footprint) for editing |-| `EDIT name.sym` | Library | Open a symbol for editing |-| `EDIT name.dev` | Library | Open a deviceset for editing |-| `EXPORT SCRIPT 'path.scr'` | Library | Export entire library as EAGLE script |-| **Scripting / Settings** | | |-| `SET CONFIRM YES` | Any | Suppress confirmation dialogs |-| `SET CONFIRM OFF` | Any | Re-enable confirmation dialogs |-| `SCRIPT 'path.scr'` | Any | Run batch commands from a .scr script file |--**EAGLE Layer Numbers (commonly used):**--| Layer | Name | What it shows |-|-------|------|--------------|-| 1 | Top | Top copper |-| 16 | Bottom | Bottom copper |-| 17 | Pads | Through-hole pads |-| 18 | Vias | Via holes |-| 19 | Unrouted | Airwires (ratsnest) |-| 20 | Dimension | Board outline (required for 3D) |-| 21 | tPlace | Top silkscreen |-| 22 | bPlace | Bottom silkscreen |-| 25 | tNames | Top component names |-| 27 | tValues | Top component values |-| 29 | tStop | Top solder mask |-| 31 | tCream | Top stencil/paste |-| 51 | tDocu | Top documentation |--**Blocking commands — AVOID from automation:**--| Command | Why it blocks |-|---------|---------------|-| `WRITE` | Opens Save As dialog — use `fusion_save_lbr` or `fusion_close_document` instead |-| `ADD` | Opens component picker dialog |-| `SHOW name` | Opens interactive highlight mode |-| `SET` (no params) | Opens settings dialog |-| `GRID` (no params) | Opens grid settings dialog |-| `EDIT new.sym` | Opens "Create new?" confirmation if symbol doesn't exist |-| `CHANGE` | Opens interactive change mode |-| `MOVE` | Opens interactive move mode |--**Tips:**-- Always run `WINDOW FIT` after opening a file or switching views-- Use `DISPLAY NONE` then `DISPLAY <layers>` to show only specific layers-- Combine commands with `;` — e.g., `SET CONFIRM YES; RIPUP *; RATSNEST`-- For board screenshots: `DISPLAY NONE; DISPLAY 1 16 17 18 20 21; WINDOW FIT`-- Use `fusion_board_info` instead of EAGLE commands when you need structured data-- `fusion_export_lbr` -- Export the open Electronics library as an EAGLE .scr script (note: does NOT include 3D package references — those are cloud-linked only)-- `fusion_save_lbr` -- Save the open Electronics library as a .flbr file--#### EAGLE Libraries with 3D Packages--Fusion 360's .lbr format supports `package3d` elements that link footprints to 3D models. Key facts:--- **3D models are cloud-hosted** — each `package3d` has a `wip_urn` (e.g., `urn:adsk.wipprod:fs.file:vf.xxxxx`) pointing to a Fusion cloud document. You cannot embed STEP files directly in .lbr XML.-- **Creating 3D packages requires the Fusion UI** — use `Package3DCreateCmd` in Electronics Library Editor, which opens a new Design workspace where you model/import the 3D shape, then save to link it.-- **`EXPORT SCRIPT` strips 3D references** — the .scr export only contains 2D data (symbols, footprints, devicesets). To preserve 3D links, keep the .lbr XML format.-- **Fusion's built-in examples** have 3D packages — 34 of 37 libraries in the EAGLE examples directory (e.g., `Connector_USB.lbr`, `Resistor.lbr`, `Capacitor.lbr`) include `package3d` references.-- **Example libraries location**: `%LOCALAPPDATA%/Autodesk/webdeploy/production/<hash>/Applications/Electron/LibEagle/examples/libraries/examples/`--To open a built-in example library for reference:-```bash-# Find the examples directory first-adom-desktop fusion_execute_text_command '{"command": "Python.RunScript C:/tmp/find_eagle_libs.py"}'-# Then open one-adom-desktop fusion_open_lbr '{"filePath": "<path>/Connector_USB.lbr"}'-```-- `fusion_open_schematic` -- Open a .sch schematic in Fusion's Schematic Editor. Args: `filePath`.-- `fusion_open_board` -- Open a .brd board layout in Fusion's Board Layout editor. Args: `filePath`.-- `fusion_show_3d_board` -- Switch to 3D PCB board view (must have a .brd open). Board MUST have an outline on layer 20 (Dimension) or 3D generation fails. Auto-zooms to fit after switching.-- `fusion_show_2d_board` -- Switch back to 2D board layout from 3D PCB view. EAGLE commands via `fusion_electron_run` only work in 2D.-- `fusion_close_document` -- Close a document without save dialog. Args: `name` (optional, defaults to active doc), `save` (optional, default false). Essential for automation — avoids modal save dialog that blocks Fusion.-- `fusion_document_info` -- List ALL open documents/tabs with name, type, and active status, plus detailed cloud info for the active document. Returns `openDocuments` array (every tab) and active doc details (cloud project, folder, file ID, version, save status). Lightweight — uses only in-memory data, no cloud API calls. Use this instead of `fusion_walk_cloud_tree` when you just need to know what's open.-- `fusion_activate_document` -- Switch to a specific open document tab. Args: `name` (substring match, case-insensitive), `documentType` ("Electronics", "PCB", "FusionDesign", "Drawing"). Essential for automation — switch between schematic, board, and library tabs without user interaction. If no match found, returns the list of open documents so you can refine.-- `fusion_close` -- Close Fusion 360. **Always call this when done with Fusion 360 commands to clean up.**-- `fusion_dismiss_recovery` -- Dismiss recovery document dialogs (both "Recovered Documents" list and "Open recovery document instead?" prompts). Also relocates recovery files to `~/.adom/recovery/fusion/` for safekeeping.-- `fusion_relocate_recovery` -- Proactively move Fusion crash recovery files to `~/.adom/recovery/fusion/<timestamp>/` without dismissing any dialogs. Call this **before** launching Fusion to prevent recovery dialogs from appearing. Files are preserved (not deleted) so the user can manually restore them if needed.-- `fusion_close_all_documents` -- Close all open documents. Args: `saveChanges` (default: false). Use before force-killing Fusion to prevent recovery files.--#### Fusion 360 UI Interaction--Commands for interacting with Fusion 360's UI — detecting dialogs, taking screenshots, clicking, and sending keyboard input. Essential for handling blocking dialogs and automating CEF-based UI elements.--- `fusion_window_info` -- Returns the Fusion main window HWND, title, rect, and a list of all Qt dialog windows (recovery dialogs, wizards, file pickers). Essential for detecting blocking dialogs before/after operations.-  - Example: `adom-desktop fusion_window_info`-  - Returns: `{hwnd, title, rect, dialogs: [{hwnd, title, className, rect}]}`--- `fusion_screenshot_fusion` -- Captures the Fusion main window or a specific dialog by HWND. Uses PrintWindow (works without bringing to foreground). Saves WebP to `C:/tmp/adom-desktop-screenshots/` (falls back to PNG if WebP unavailable). Downscaled to ≤1568px — use relative coords (0.0-1.0) for clicking, they're scale-independent. DPI-aware.-  - Args: `hwnd` (optional — dialog HWND to screenshot instead of main window)-  - Example: `adom-desktop fusion_screenshot_fusion` (main window)-  - Example: `adom-desktop fusion_screenshot_fusion '{"hwnd": 12345}'` (specific dialog)--- `fusion_screenshot_all` -- Screenshots the main Fusion window and lists all dialog windows with their HWNDs. Use `fusion_screenshot_fusion {"hwnd": ...}` to capture each dialog.-  - Example: `adom-desktop fusion_screenshot_all`--- `fusion_click_fusion` -- Click at coordinates within the Fusion window or a specific dialog. x/y are relative (0.0-1.0) by default. Set `"relative": false` for pixel offsets. Uses SendInput for CEF dialog compatibility. **Prefer `fusion_send_key` for dialogs** — enter/escape/tab covers most cases.-  - Args: `x` (required), `y` (required), `relative` (optional, default true), `hwnd` (optional — target a specific dialog HWND instead of main window)-  - Example: `adom-desktop fusion_click_fusion '{"x": 0.5, "y": 0.7}'` (main window)-  - Example: `adom-desktop fusion_click_fusion '{"hwnd": 12345, "x": 0.75, "y": 0.85}'` (dialog button)--- `fusion_send_key` -- Send keyboard input to Fusion or a specific dialog via SendInput. **Preferred way to dismiss dialogs**: enter to confirm, escape to cancel, tab to cycle buttons. Use click only if tab order doesn't reach the right button.-  - Args: `key` (required), `hwnd` (optional — target a specific dialog HWND)-  - Example: `adom-desktop fusion_send_key '{"key": "escape"}'` (dismiss CEF dialog)-  - Example: `adom-desktop fusion_send_key '{"key": "enter", "hwnd": 12345}'` (confirm dialog)--#### Fusion 360 Workflow Guide--##### Opening Electronics Projects-1. Open the `.fprj` file: `adom-desktop fusion_open_cloud_file '{"projectName":"Main","fileName":"DRV8411A","fileExtension":"fprj","folderPath":"Molecules/XRP/DRV8411A"}'`-2. Do NOT try to open `.fbrd` or `.fsch` directly — they fail or show a "Select Electronics Design File" dialog-3. Enter the board editor: `adom-desktop fusion_show_2d_board`-4. Enter the schematic editor: After step 3, use `adom-desktop fusion_electron_run '{"command":"EDIT .sch"}'`-5. Switch back to board: `adom-desktop fusion_electron_run '{"command":"EDIT .brd"}'`--##### Auto-Screenshot on Open Commands--**Open commands automatically screenshot Fusion and return the images in the response.** The following commands include `postOpenScreenshot` in their `data` field:-- `fusion_open_cloud_file`, `fusion_open_schematic`, `fusion_open_board`, `fusion_show_3d_board`, `fusion_show_2d_board`--The response `data.postOpenScreenshot` contains:-- `screenshots[]` — array of `{type, savedTo, sizeKB, title?, hwnd?}`. Type is `"main_window"` or `"dialog"`.-- `message` — human-readable instruction to READ each screenshot and check for blocking dialogs-- `dialogBlocking` — `true` if a modal dialog was detected--**After receiving the response, you MUST:**-1. **READ each screenshot file** using the Read tool to visually inspect what Fusion shows-2. Look for blocking dialogs in the screenshots:-   - "What to design?" wizard → dismiss with `fusion_send_key {"key": "escape"}`-   - "PCB out of date" banner → click Update or X-   - "Recovered Documents" → `fusion_dismiss_recovery`-   - "Save changes?" → `fusion_send_key {"key": "escape"}`-   - Any other modal → identify and dismiss-3. **Screenshot again** after dismissing to confirm it's clear--Screenshots are saved as WebP (lossless, ~20-40KB each, downscaled to ≤1568px) for token efficiency. Both the main Fusion window AND all Qt dialog windows are captured separately.--**Why this matters**: Fusion shows Qt dialogs and CEF overlays that are invisible to the API. The `success: true` response from an open command does NOT mean the UI is ready — a dialog may be blocking all further operations. The API cannot detect these. Only a screenshot can.--##### Detecting and Handling Blocking Dialogs-- The auto-screenshot captures dialog windows separately (look for `type: "dialog"` entries in `postOpenScreenshot.screenshots[]`)-- Common blocking dialogs: "What do you want to design?", "Recovered Documents", "Open recovery document instead?", "Select Electronics Design File"-- Dismiss recovery dialogs: `adom-desktop fusion_dismiss_recovery`-- Dismiss CEF dialogs (inside main window): `adom-desktop fusion_send_key '{"key": "escape"}'` or `fusion_click_fusion`-- Dismiss "What do you want to design?" wizard: `adom-desktop fusion_send_key '{"key": "escape"}'`-- For manual screenshot of specific dialogs: `adom-desktop desktop_screenshot_window '{"hwnd": <DIALOG_HWND>}'`--##### Preventing Recovery Documents-- Recovery files are at: `%LOCALAPPDATA%\Autodesk\Autodesk Fusion 360\<USER_ID>\CrashRecovery\`-- Before force-killing Fusion, close all documents: `adom-desktop fusion_close_all_documents '{"saveChanges": false}'`-- **Best practice**: Call `adom-desktop fusion_relocate_recovery` before launching Fusion. This moves recovery files to `~/.adom/recovery/fusion/<timestamp>/` — preserving them for the user while preventing modal dialogs.-- The `fusion_start` command also auto-relocates recovery files before starting Fusion.-- The `fusion_dismiss_recovery` command handles both "Recovered Documents" list and "Open recovery document instead?" prompts, and also relocates files.--##### EAGLE Export Limitations-- **Supported**: `EXPORT IMAGE`, `EXPORT NETLIST`, `EXPORT PARTLIST`-- **NOT supported** (fail silently): `EXPORT DXF`, `EXPORT SVG`, `EXPORT DRILL`-- Always verify export output: the updated `fusion_electron_run` now checks if the output file was created-- Use `fusion_export_bom` and `fusion_export_cpl` for manufacturing data (these use the add-in's XML parser, not EAGLE export)-- Use `fusion_export_gerbers` for Gerber files — produces a ZIP with all gerber layers (GTL, GBL, GTS, GBS, GTP, GBP, GTO, GBO, GKO, XLN). Auto-detects 2-layer vs 4-layer boards.--##### 3D Model Exports-- From the 3D view (activate the .f3d document): `fusion_export_step`, `fusion_export_stl`, `fusion_export_3mf`, `fusion_export_f3d`, `fusion_export_usdz`, `fusion_export_iges`, `fusion_export_sat`-- Switch to 3D view: `fusion_show_3d_board` or activate the .f3d document-- **STEP** — Industry-standard CAD interchange (SolidWorks, CATIA, Creo). Highest geometric fidelity.-- **IGES** — Legacy CAD interchange. Use STEP for modern workflows.-- **SAT** — ACIS solid model format. Used by SolidWorks, SpaceClaim.-- **STL** — Mesh format for 3D printing and visualization. Options: refinement "low"/"medium"/"high".-- **3MF** — Modern 3D printing with color/material support and multi-body.-- **F3D** — Native Fusion 360 archive. Preserves parametric features, sketches, timeline, component refs. Best for archival.-- **USDZ** — **Best for digital twins and GLB conversion.** Preserves full component hierarchy (Board, copper layers, soldermask, Packages), PBR materials, and named nodes. Each component becomes a toggleable node in GLB viewers. Also viewable directly on iOS/macOS (Apple Quick Look / AR).-  - Digital twin pipeline: `fusion_export_usdz` → pull_file → `blender --background --python-expr "import bpy; bpy.ops.wm.usd_import(filepath='board.usdz'); bpy.ops.export_scene.gltf(filepath='board.glb')"`-- **FBX** — Not available in current Fusion builds via the API. The command exists but fails clearly. Use `fusion_export_usdz` instead.-- **DXF/DWG/OBJ/SKP** — Not available via the Fusion API (dialog-only). Commands exist but return clear errors with alternatives.--##### 3D Viewport Captures-- `fusion_take_screenshot` — Capture the Fusion viewport at any resolution without opening a dialog. Uses Fusion's render API (`saveAsImageFile`).-  - Args: `outputPath` (required), `width` (default 1920), `height` (default 1080), `orientation` (optional)-  - Orientations: `home`, `front`, `back`, `top`, `bottom`, `left`, `right`-  - Example: Capture all 6 standard views for use as product images/icons:-    ```bash-    for orient in home front back top bottom left right; do-      adom-desktop fusion_take_screenshot "{\"outputPath\": \"C:/tmp/3d-${orient}.png\", \"width\": 1920, \"height\": 1080, \"orientation\": \"${orient}\"}"-    done-    ```--##### Electronics Source File Export (`fusion_export_source`)-- `fusion_export_source` — Export the active electronics document as `.fsch`, `.fbrd`, or `.flbr` source file.-  - Args: `outputPath` (required — full path with extension)-  - The extension determines the format: `.fsch` (schematic), `.fbrd` (board), `.flbr` (library)-  - Validates extension, creates output directory, verifies file was created, returns file size--- **Full workflow to export both board and schematic from a cloud project:**-  ```bash-  # Step 1: Open the .fprj (NOT .fbrd/.fsch directly — those fail)-  adom-desktop fusion_open_cloud_file '{"projectName":"Main", "fileName":"MyDesign", "fileExtension":"fprj", "folderPath":"Molecules/MyDesign"}'--  # Step 2: Screenshot to check for blocking dialogs (always do this after open)-  adom-desktop fusion_screenshot_fusion--  # Step 3: Enter board view (MUST be Board Layout workspace, not 3D)-  adom-desktop fusion_show_2d_board--  # Step 4: Export .fbrd (board first — it's already in board view)-  adom-desktop fusion_export_source '{"outputPath": "C:/tmp/exports/MyDesign.fbrd"}'--  # Step 5: Switch to schematic (EDIT .s1 = first schematic sheet)-  adom-desktop fusion_electron_run '{"command": "EDIT .s1"}'--  # Step 6: Export .fsch-  adom-desktop fusion_export_source '{"outputPath": "C:/tmp/exports/MyDesign.fsch"}'--  # Step 7: Pull files to Docker-  adom-desktop pull_file '{"filePaths":["C:/tmp/exports/MyDesign.fbrd","C:/tmp/exports/MyDesign.fsch"], "saveTo":"/tmp/exports"}'-  ```--- **Critical gotchas:**-  - You MUST open the `.fprj` first — opening `.fbrd`/`.fsch` directly fails or triggers blocking dialogs-  - You MUST call `fusion_show_2d_board` before exporting `.fbrd` (the Electronics Design overview won't work)-  - Always export `.fbrd` FIRST (from board view), then switch to schematic for `.fsch`-  - If export fails with "file not found", the wrong workspace is active — screenshot to verify-  - After `fusion_open_cloud_file`, always screenshot to check for "Select Electronics Design File" dialog-  - `WRITE` command is blocked — it opens a blocking save dialog. Use `fusion_export_source` instead--- Note: `WRITE` is blocked — it opens a "Version Description" dialog on cloud docs even with a path argument (tested 2026-04-10). Use `fusion_save_to_cloud` to save to cloud, `fusion_export_source` for Fusion-format, or `fusion_export_eagle_source` for plain EAGLE format.--##### Plain EAGLE Source Export (`fusion_export_eagle_source`)-- `fusion_export_eagle_source` — Export the active electronics document as plain EAGLE XML `.sch` or `.brd`.-  - Args: `outputPath` (required — full path ending in `.sch` or `.brd`)-  - Internally: exports `.fsch`/`.fbrd` via `Document.CopyToDesktop`, then extracts the EAGLE XML from the ZIP container, cleans up the temp file.-  - The output is valid EAGLE XML (`<?xml><eagle version="9.7.0">...`) parseable by standalone EAGLE, KiCad import, or any XML tool.-  - Same workflow/prerequisites as `fusion_export_source` — just use `.sch`/`.brd` extensions instead of `.fsch`/`.fbrd`.--  ```bash-  # Board (.brd) — must be in PCB Editor / Board Layout-  adom-desktop fusion_show_2d_board-  adom-desktop fusion_export_eagle_source '{"outputPath": "C:/tmp/exports/MyDesign.brd"}'--  # Schematic (.sch) — must be in Schematic Editor-  adom-desktop fusion_electron_run '{"command": "EDIT .s1"}'-  adom-desktop fusion_export_eagle_source '{"outputPath": "C:/tmp/exports/MyDesign.sch"}'-  ```--##### Dialog Dismissal (`fusion_close_window`)-- `fusion_close_window` — Close a specific Fusion dialog by sending WM_CLOSE (equivalent to clicking X).-  - Args: `hwnd` (required — from `fusion_window_info` or `fusion_dismiss_blocking_dialogs` remaining[])-  - Works on dialogs that Escape doesn't close — e.g. "Recovered Documents"-  - Does NOT force-kill — the dialog can still intercept WM_CLOSE--##### Electronics Import (Round-Trip)-- `fusion_import_electronics` — Import Fusion-native `.fsch`, `.fbrd`, or `.flbr` files as new local documents-  - Uses `Document.newDesignFromLocal` under the hood-  - Auto-screenshots after import (catches blocking dialogs)-  - Args: `filePath` (required Windows path to .fsch, .fbrd, or .flbr)-  - After import, use `fusion_save_to_cloud` to persist to Fusion cloud-  - **`.fsch` import works standalone** — creates a new schematic project-  - **`.fbrd` import may fail** — boards have external references to schematics/libraries. Error: "New design cannot be created from a local file containing external references"-  - **`.flbr` import works standalone** — creates a new library project-  - For legacy EAGLE files: use `fusion_open_schematic` (.sch), `fusion_open_board` (.brd), `fusion_open_lbr` (.lbr)--##### Library Round-Trip Workflow-Export and re-import Fusion electronics libraries:-```bash-# 1. Open library from cloud-adom-desktop fusion_open_cloud_file '{"projectName":"Main","fileName":"Adom Common Components","folderPath":"Molecules/Libraries"}'--# 2. Export as .flbr (Fusion-native binary) and .scr (EAGLE script text)-adom-desktop fusion_save_lbr '{"outputPath":"C:/tmp/library.flbr"}'-adom-desktop fusion_export_lbr '{"outputPath":"C:/tmp/library.scr"}'-adom-desktop fusion_close_document--# 3. Re-import the .flbr-adom-desktop fusion_import_electronics '{"filePath":"C:/tmp/library.flbr"}'--# 4. Verify symbols survived round-trip-adom-desktop fusion_export_lbr '{"outputPath":"C:/tmp/library-verify.scr"}'--# 5. Save to cloud with new name-adom-desktop fusion_save_to_cloud '{"name":"Library-copy"}'-```--##### Demo Projects-171+ electronics molecules are exported in the `adom-desktop-demo` repo (separate from adom-desktop).-Three reference boards with full export formats:--| Board | Cloud Path | Layers | Components | Files |-|-------|-----------|--------|------------|-------|-| DRV8411A | Main / Molecules / XRP / DRV8411A | 4 | 29 | 27 |-| DRV8323SR | Main / Molecules / Experiments / MotorControl / DRV8323SR | 2 | 43 | 27 |-| VL53L8BreakoutMolecule | Main / Molecules / XRP / TimeOfFlightVL53L8 / VL53L8BreakoutMolecule | 2 | 30 | 27 |--Each folder contains: .fsch, .fbrd, bom.csv, cpl.csv, gerbers.zip, 6 board images, 7 3D renders (home/front/back/top/bottom/left/right), STEP, IGES, SAT, STL, 3MF, F3D, USDZ, board screenshot, 3D board screenshot.--#### Cloud Document Management--Manage Fusion 360 cloud documents (hub projects, files, versions). Required for 3D package workflows since EAGLE library 3D models are stored as cloud documents.--- `fusion_save_to_cloud` -- Save the active Fusion document to the cloud. Args: `name` (required), `projectName` (optional, defaults to active project), `folderPath` (optional), `description` (optional). Returns: `fileId`, `versionNumber`, `wipUrn` (if cloud-hosted).-- `fusion_list_cloud_projects` -- List all cloud projects in the user's hub. Returns array of `{name, id}` per hub.-- `fusion_list_cloud_files` -- List files in a cloud project/folder. Args: `projectName` (optional), `folderPath` (optional). Returns: `files` array with `{name, id, versionNumber, fileExtension, dateModified}` and `subfolders` array.-- `fusion_create_cloud_folder` -- Create a folder in a cloud project. Args: `folderName` (required), `projectName` (optional), `parentPath` (optional). Returns `folderId`. Idempotent — returns existing folder if it already exists.-- `fusion_check_recovery` -- Check if a cloud file has a recovery document from a previous crash. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional). Returns `hasRecovery: true/false`. Use this BEFORE opening files to avoid the blocking "Open recovery document instead?" dialog. If recovery exists, call `fusion_open_cloud_file` with `recovery: "open"` (restore unsaved work) or `recovery: "discard"` (delete recovery, open cloud version).-- `fusion_open_cloud_file` -- Open a cloud file in Fusion by name. **If a recovery document exists and no `recovery` arg is given, the command STOPS and reports the recovery instead of opening** — you must decide whether to preserve or discard unsaved work. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional), `recovery` ("open" = restore unsaved work, "discard" = delete recovery and open cloud version).-- `fusion_export_cloud_file` -- Export the active Fusion document to a local file for transfer back to Docker. Args: `outputPath` (required), `format` (optional, default "step"). The exported file can then be pulled back to Docker via `pull_file`.-- `fusion_delete_cloud_file` -- Delete a cloud file by name. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional). File must not be open in Fusion — close it first with `fusion_close_document`.-- `fusion_walk_cloud_tree` -- **Long-running.** BFS walk of a cloud folder tree. Returns a flat list of all files and folders. Runs entirely on the Fusion main thread — **blocks all other add-in commands** until done (check progress with `fusion_addin_status`).-  - Args: `projectName` (optional), `folderPath` (optional, starting folder), `maxDepth` (default 10), `maxFolders` (default 500), `extensions` (optional list, e.g. `["f3d","fprj"]`), `nameContains` (optional substring filter), `includeFiles` (default true)-  - Returns: `{project, rootFolder, folders[], files[], stats: {foldersVisited, foldersSkipped, filesFound, maxDepthReached, truncated}}`-  - **Per-folder timeout (30s):** If a single folder's cloud API calls take >30s (common for large projects), the folder is skipped and counted in `foldersSkipped`. This prevents indefinite hangs.-  - **Progress tracking:** While running, `fusion_addin_status` returns `walkProgress` with `{foldersVisited, filesFound, currentFolder, queueSize}` — poll this every 1–10s to monitor progress (see "Live folder progress streaming" below).-  - **Non-blocking alternative:** Use `fusion_search_cloud_files` for targeted searches, or `fusion_list_cloud_files` for single-folder listings (these are faster but don't recurse).-  - Example: `adom-desktop fusion_walk_cloud_tree '{"projectName":"Main","folderPath":"Molecules","nameContains":"DRV","extensions":["fprj"]}'`--#### Live folder progress streaming with `watch`--For long walks, use the built-in `watch` wrapper. It spawns the inner search command on a worker thread, polls `fusion_addin_status` internally, and emits one JSON event per line to stdout as the walker visits each folder. **No manual polling loop needed.**--```bash-adom-desktop watch '{"command":"fusion_walk_cloud_tree","args":{"projectName":"Main","folderPath":"Molecules","nameContains":"BQ25792"}}'-```--Output is one JSON object per line, three event types:--```jsonl-{"event":"started","command":"fusion_walk_cloud_tree","args":{...},"interval":2,"_hint":"streaming progress events follow, one per line, ending with 'complete' or 'error'"}-{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":15.0,"foldersVisited":14,"queueSize":50,"filesFound":0,"currentFolder":"Molecules/Sensing"}-{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":23.4,"foldersVisited":22,"queueSize":108,"filesFound":0,"currentFolder":"Molecules/Examples"}-{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":31.8,"foldersVisited":30,"queueSize":100,"filesFound":0,"currentFolder":"Molecules/RAPID NAME TAGS/Connor Wood"}-... (one per real change in walkProgress, deduped) ...-{"event":"complete","result":{"folders":[...],"files":[...],"stats":{"foldersVisited":169,"filesFound":12,"truncated":false}}}-```--Read stdout line-by-line. Stop when you see `event:complete` or `event:error`. The full final result is in the `complete` event's `result` field.--**Optional `interval` arg** (default 2s, clamped to [1, 30]):--```bash-adom-desktop watch '{"command":"fusion_walk_cloud_tree","args":{...},"interval":1}'-```--**For Claude Code consumers**: pipe `watch` directly into the `Monitor` tool. Each JSON line becomes a real-time event notification in the chat — you (and the user) see folder names appear one-by-one as the walker visits them. No bash loop, no `disown`, no subprocess gymnastics. Verified live in 1.3.16 on a 169-folder walk that returned all 12 BQ25792 cloud files cleanly.--**Watchable commands** (whitelist): `fusion_walk_cloud_tree`, `fusion_search_cloud_files`. Other commands return `success:false` with a `_hint` listing the watchable set.--**When NOT to use watch**: short single-folder operations (`fusion_list_cloud_files`, `fusion_open_cloud_file`) — those return in <2s and don't need streaming. The `watch` wrapper is purely for the long BFS commands.-- `fusion_search_cloud_files` -- **Long-running.** Recursive substring search on file names across cloud folders. v1.0.2+ of the add-in adds: per-folder timeout, doEvents() every 50 files, per-file try/except, iterative BFS, and a stale-lock watchdog — Fusion stays responsive throughout (no "Not Responding" freeze).-  - **Args (no hard upper caps in v1.0.2+):** `query` (required, substring, case-insensitive), `projectName` (optional, defaults to active), `folderPath` (optional starting subfolder — **narrow with this**), `recursive` (default false), `maxDepth` (default 2), `maxFolders` (default 10), `maxResults` (default 20), `folderTimeout` (default 30s), `searchTimeout` (default 120s), `timeout` (HTTP envelope, default 620s).-  - **Before calling:** if the user knows roughly where the file is (e.g. customer-named folder), **ask them**. Naming a subfolder cuts 5-40 min searches down to ~10s.-  - **Why slow:** Autodesk's free Fusion 360 Python API has no indexed file-search endpoint. We walk the Data API one folder at a time — each = one HTTP round-trip to Autodesk. **This is an Autodesk API limitation, not adom-desktop's.** When telling the user the search is taking a while, attribute it to Autodesk / Fusion 360, not to adom-desktop's bridge. **There is now a FAST native alternative — `aps_search` (see "APS — fast indexed cloud search" below).** It hits Autodesk Platform Services' indexed Data Management API and returns ~instantly, even when Fusion is CLOSED. Call `aps_status` first; if `configured:true`, prefer `aps_search` and only fall back to this folder-walking verb when APS isn't set up.-  - **Interpreting results (critical for no-false-negatives):** the response contains `searchComplete` (bool), `foldersSkipped`, `filesSkipped`, `truncated`, `folderLimitReached`, `searchTimedOut`. `searchComplete:true` ONLY when **all five** are clean — that's the confidence flag. If `searchComplete:false`, the search hit a cap before exhausting the scope; **don't report "file not found"** — re-run with broader caps OR narrower `folderPath`.-  - **Case sensitivity:** fully case-insensitive both directions (query and file names lowercased). Substring match, not whole-word — `"cosm"` matches `COSMIIC`, `COSMOCOIL`, etc.-  - **Cost feedback in response:** `costAnalysis: {elapsedSeconds, foldersPerSecond, estimatedSecondsPer100Folders}` so the AI can budget the next search realistically.-  - **Real numbers from a live test:** searching `Main/Molecules` recursively (173 folders deep, max 8 depth) for "cosmiic" returned in 168 s with `searchComplete:true, totalFound:3`. Fusion stayed `main_thread:responsive` throughout.-  - **Pair with `watch`** for streaming progress updates: see below.--#### APS — fast indexed cloud search (v1.8.182+)--`aps_search` is a **native, ~instant** alternative to the folder-walking `fusion_search_cloud_files`. It queries **Autodesk Platform Services** (APS, formerly Forge) **Data Management API**, which has a real server-side index — so a search that takes minutes by folder-walking returns in well under a second, and it works **even when Fusion 360 is closed** (it's pure HTTPS, no bridge required). adom-desktop runs its own 3-legged **PKCE** OAuth, so the user's APS **client secret never leaves their machine**.--**One-time setup (user side):**-1. Register a free app at <https://aps.autodesk.com> → enable the **Data Management API**.-2. Set the app's **Callback URL** to exactly `http://localhost:8910/aps/callback`.-3. Put the **Client ID** in AD config key `aps_client_id` (in `~/.adom/config.json`) or via Settings.-4. Run `aps_auth` once — it opens the system browser for Autodesk sign-in. Tokens cache in `~/.adom/aps_tokens.json` and auto-refresh; you won't be prompted again.--**Verbs:**-- `aps_status` — **call this first.** Non-prompting. Returns `{configured, signedIn}`. `configured:false` → no client id; use `fusion_search_cloud_files`. `configured:true, signedIn:false` → run `aps_auth`. Both true → `aps_search` is ready.-- `aps_auth` — interactive sign-in (opens browser). One-time per device.-- `aps_hubs` — `{hubs[]{id,name}}` (your Autodesk teams).-- `aps_projects {hubId?}` — `{hubId, projects[]{id,name}}` (default: first hub).-- `aps_search {query, projectName?, projectId?, hubId?}` — `{query, hubId, count, results[]{name,id,type,lastModified,project}}`. `query` is a case-insensitive substring of the file's displayName. Without `projectName`/`projectId` it searches up to 8 projects in the hub. Open a hit with `fusion_open_cloud_file {fileName, projectName}`.--**Decision flow for an AI doing a cloud file search:** `aps_status` → if `configured` (sign in with `aps_auth` if needed) use `aps_search`; else fall back to `fusion_search_cloud_files` and tell the user that registering a free APS app (above) would make future searches instant.--**Export formats for `fusion_export_cloud_file`:**--| Format | Extension | Use case |-|--------|-----------|----------|-| `step` | .step | Industry-standard CAD interchange (default) |-| `stl` | .stl | 3D printing, mesh-based |-| `f3d` | .f3d | Fusion 360 native archive (preserves all features) |-| `iges` | .iges | Legacy CAD interchange |-| `sat` | .sat | ACIS solid modeling kernel format |-| `smt` | .smt | Parasolid format |--Also supported for import via `fusion_import_step`: STEP (.step/.stp), STL (.stl), IGES (.iges/.igs), SAT (.sat), SMT (.smt), OBJ (.obj), F3D (.f3d).--**Round-trip: workspace → Fusion Cloud → workspace**--```bash-# === workspace to Fusion Cloud ===-# 1. Send file from Docker to Windows desktop-adom-desktop send_files '{"files": [{"path": "/home/user/component.step"}]}'-# 2. Import into Fusion-adom-desktop fusion_import_step '{"filePath": "C:/Users/john/Downloads/component.step"}'-# 3. Save to cloud (gets a wip_urn for 3D library linking)-adom-desktop fusion_save_to_cloud '{"name": "my-component", "projectName": "Personal"}'--# === Fusion Cloud to Docker ===-# 1. Find and open the cloud file-adom-desktop fusion_walk_cloud_tree '{"projectName": "Personal", "nameContains": "my-component"}'-adom-desktop fusion_open_cloud_file '{"fileName": "my-component", "projectName": "Personal"}'-# 2. Export to local filesystem-adom-desktop fusion_export_cloud_file '{"outputPath": "C:/tmp/export/my-component.step", "format": "step"}'-# 3. Pull back to Docker-adom-desktop pull_file '{"path": "C:/tmp/export/my-component.step"}'--# === Cloud management ===-adom-desktop fusion_list_cloud_projects '{}'-adom-desktop fusion_create_cloud_folder '{"folderName": "Electronics", "projectName": "Main"}'-adom-desktop fusion_list_cloud_files '{"projectName": "Main", "folderPath": "Electronics"}'-adom-desktop fusion_delete_cloud_file '{"fileName": "old-file", "projectName": "Main"}'-```--#### Manufacturing Exports (Gerbers, BOM, CPL)--Export manufacturing files from an open PCB board — everything needed to fabricate boards and assemble components via Adom's PCBA service. **All manufacturing commands require a .brd board open in PCB Editor** (use `fusion_open_board` or `fusion_show_2d_board`).--**Recommended workflow order:** `detect_layers` → `set_design_rules` → DRC → `export_gerbers` → `export_bom` → `export_cpl` → `pull_file` all back to Docker.--Every manufacturing command returns structured JSON with:-- `message` — AI-oriented summary explaining what was produced and why it matters-- `nextSteps[]` — ordered list of what to run next in the manufacturing pipeline-- `hint` — tips for interpreting results or recovering from issues-- `data` — structured metadata (paths, counts, layer info) for programmatic use--**Decision guide — which command to run:**-- **Don't know the layer count?** → Run `fusion_detect_layers` first-- **Need to check if the board meets fab specs?** → Run `fusion_set_design_rules` then DRC-- **Ready to generate fab files?** → Run `fusion_export_gerbers`, then `fusion_export_bom`, then `fusion_export_cpl`-- **Need visual review before export?** → Run `fusion_export_board_image` with preset `assembly_top` or `fabrication`-- **Something failed?** → Check `data.hint` and `data.recoverySteps` in the error response--**Commands:**--- `fusion_detect_layers` -- Detect if the open board is 2-layer or 4-layer. Uses ULP script (primary) and CAM comparison (fallback). Returns `layerCount`, `copperLayers[]`, `method` (how it was detected), and `nextSteps[]`. **Run this first** — all other manufacturing commands auto-detect layers too, but running this explicitly gives you the data before committing to exports.--- `fusion_set_design_rules` -- Apply Adom's JLCPCB-derived design rules (.edru XML files) to the open board. Auto-detects 2-layer vs 4-layer and loads the appropriate rule set. Returns `description` (human-readable rule summary), `edruFile`, and `nextSteps[]`.-  - `action`: `"apply"` (default) loads rules into the board; `"export"` saves current board rules to a file; `"show"` displays rule capabilities without modifying anything-  - `layers`: `"auto"` (default), `"2"`, or `"4"` — force a specific rule set-  - After applying: run `fusion_electron_run '{"command": "DRC"}'` to check for violations. DRC markers appear in the board editor.--- `fusion_apply_instapcb_rules` -- Convenience wrapper that applies the bundled Adom InstaPCB design rule sets without needing a local `.edru` file. Args: `layers` (`"2"` or `"4"`). Sends the bundled `.edru` from Docker to Windows automatically and loads it via the EAGLE `drc load` command. Use this instead of `fusion_set_design_rules` when you just want Adom's standard 2-layer or 4-layer InstaPCB rules.--- `fusion_load_design_rules` -- Generic loader for ANY custom `.edru` file by path. Args: `filePath`. Use this for third-party fab vendor rules (JLCPCB, PCBWay, OSHPark, etc.) that the user has on disk.--- `fusion_export_gerbers` -- Export Gerber (RS-274X) + Excellon drill files as a ZIP. Auto-detects 2-layer vs 4-layer and selects the correct JLCPCB-compatible CAM job. Returns `zipPath`, `zipSizeKB`, `files[]` (list of gerber files in the ZIP with sizes), `layerCount`, and `nextSteps[]`.-  - `outputDir`: directory for the output ZIP (default: `C:/tmp/adom-gerbers/`)-  - `boardName`: prefix for the ZIP filename (default: from active document name)-  - `layers`: `"auto"` (default), `"2"`, or `"4"` — force CAM job selection-  - Output ZIP contains: GTL, GBL (copper), GTS, GBS (solder mask), GTP, GBP (paste), GTO, GBO (silkscreen), GKO (outline), XLN (drill). 4-layer boards also get G1, G2 (inner copper).--- `fusion_export_bom` -- Export Bill of Materials as CSV. Groups identical parts by value+package with quantity counts. Returns `componentCount`, `uniquePartCount`, and `nextSteps[]`.-  - `outputPath`: (default: `C:/tmp/adom-bom.csv`)-  - `grouped`: `true` (default) groups by value+package; `false` lists every component individually-  - Output columns: Comment, Designator, Footprint, Quantity, Library — compatible with JLCPCB, PCBWay, Mouser, Digi-Key.--- `fusion_export_cpl` -- Export Component Placement List (pick-and-place) as CSV. Returns `totalPlacements`, `topCount`, `bottomCount`, and `nextSteps[]`.-  - `outputPath`: (default: `C:/tmp/adom-cpl.csv`)-  - `side`: `"all"` (default), `"top"`, or `"bottom"` — filter for single-sided assembly-  - Output columns: Designator, Mid X, Mid Y, Layer, Rotation — coordinates in mm.--- `fusion_export_board_image` -- Export PNG image of the board with layer presets. Returns `fileSize`, `preset`, and `nextSteps[]`.-  - `outputPath`: (default: `C:/tmp/adom-board.png`)-  - `dpi`: resolution (default: 300, max: 600)-  - `preset`: layer preset name (see table below)-  - `layers`: custom layer numbers as int array — overrides preset-  - `monochrome`: `true` for black & white-  - `listPresets`: set `true` to get available presets instead of exporting--**Layer presets for `fusion_export_board_image`:**--| Preset | Layers | Description |-|--------|--------|-------------|-| `all` | All | Every layer visible |-| `top_copper` | 1, 17, 18 | Top copper + pads + vias |-| `bottom_copper` | 16, 17, 18 | Bottom copper + pads + vias |-| `top_silkscreen` | 21, 25 | Top silkscreen + component names |-| `bottom_silkscreen` | 22, 26 | Bottom silkscreen + component names |-| `top_soldermask` | 29 | Top solder mask openings |-| `bottom_soldermask` | 30 | Bottom solder mask openings |-| `top_paste` | 31 | Top paste/stencil openings |-| `bottom_paste` | 32 | Bottom paste/stencil openings |-| `board_outline` | 20 | Board outline (dimension layer) |-| `drill` | 44, 45, 17, 18 | Drill holes + vias |-| `assembly_top` | 1, 17, 18, 20, 21, 25, 51 | Top assembly — copper + silk + outline |-| `assembly_bottom` | 16, 17, 18, 20, 22, 26, 52 | Bottom assembly — copper + silk + outline |-| `fabrication` | 1, 16, 17–20, 21, 22, 25, 26, 29, 30, 51 | Full fabrication view |--**Adom InstapcbPCB manufacturing capabilities (used by `fusion_set_design_rules`):**--| Parameter | Metric | Imperial |-|-----------|--------|----------|-| Layers | 1, 2, 4, 6 | — |-| Min trace width | 0.08mm | 3 mil |-| Min trace spacing | 0.08mm | 3 mil |-| Min via drill | 0.2mm | 8 mil |-| Min silkscreen text | 0.1mm | 4 mil |-| Board edge clearance | 0.05mm | 2 mil |-| Board thickness | 1.6mm | 63 mil |-| Copper weight | 0.5 oz (17.5 μm) | — |-| Solder mask | Green | — |-| Surface finish | HASL / ENIG | — |-| Turnaround | 4 hours (fab + assembly) | — |--**Manufacturing workflow — Fusion to Adom PCBA:**--```bash-# 1. Open the board-adom-desktop fusion_open_board '{"filePath": "C:/projects/myboard.brd"}'--# 2. Detect layer count (auto-selects 2-layer or 4-layer rules/CAM)-adom-desktop fusion_detect_layers--# 3. Apply Adom design rules for the detected layer count + run DRC-adom-desktop fusion_set_design_rules '{"action": "apply"}'-adom-desktop fusion_electron_run '{"command": "DRC"}'--# 4. Export manufacturing files (gerbers auto-select correct CAM job)-adom-desktop fusion_export_gerbers '{"outputDir": "C:/tmp/mfg"}'-adom-desktop fusion_export_bom '{"outputPath": "C:/tmp/mfg/bom.csv"}'-adom-desktop fusion_export_cpl '{"outputPath": "C:/tmp/mfg/cpl.csv"}'--# 5. Export board images for review-adom-desktop fusion_export_board_image '{"outputPath": "C:/tmp/mfg/top-copper.png", "preset": "top_copper"}'-adom-desktop fusion_export_board_image '{"outputPath": "C:/tmp/mfg/assembly-top.png", "preset": "assembly_top"}'-adom-desktop fusion_export_board_image '{"outputPath": "C:/tmp/mfg/board-outline.png", "preset": "board_outline"}'--# 6. Pull files back to Docker for Adom PCBA ordering-adom-desktop pull_file '{"path": "C:/tmp/mfg/bom.csv"}'-adom-desktop pull_file '{"path": "C:/tmp/mfg/cpl.csv"}'-```--### Desktop Tools--- `desktop_open_folder` -- Open a file or folder in Windows Explorer. If given a file path, Explorer opens with the file highlighted (selected). Args: `path` (file or folder path).-- `desktop_open_url` -- Open a URL in the user's **NATIVE OS BROWSER** — i.e. the real Edge / Chrome / Firefox / Brave they use every day, with their saved logins, history, bookmarks, and extensions. This is NOT pup, NOT Chrome for Testing — it's the browser the human actually uses. Hand it off when the user needs to interact with a logged-in account themselves.-  - Args: `url` (required string), `browser` (optional: `"default"` (Windows-registered default — could be Edge, Chrome, Firefox, Brave; whatever they set), `"chrome"`, `"edge"`, `"firefox"`, `"brave"`).-  - Examples:-    - `adom-desktop desktop_open_url '{"url":"https://claude.ai/"}'` -- opens in user's default native browser, already signed in-    - `adom-desktop desktop_open_url '{"url":"https://docs.example.com","browser":"edge"}'` -- force native Edge-  - Returns `{ok, browser, url, exePath?}`. Allowed schemes: http, https, mailto, ftp, ftps. Other schemes refused.--#### `desktop_open_url` vs `browser_open_window` — two completely different browsers--| | `desktop_open_url` | `browser_open_window` |-|---|---|---|-| **What browser** | Native OS browser the user uses daily — Microsoft Edge / Google Chrome / Mozilla Firefox / Brave (whichever they've set as Windows default, or the one you name explicitly) | Puppeteer-controlled **Chrome for Testing** — a separate Chromium build that pup launches |-| **Profile / data** | The user's real profile: saved logins, history, bookmarks, extensions, autofill | Isolated profile under `plugins/puppeteer/profiles/<sessionId>/` — empty, no saved logins |-| **Who interacts with the page** | The HUMAN. Claude hands the URL off and is done. | CLAUDE drives it programmatically — screenshots, clicks, eval, recording. The human just watches. |-| **Use when** | A login is required (claude.ai, GitHub, internal tools, banking) — the human's existing session must be reused | Automation, scraping, screenshot capture, video recording, headful UI testing |-| **Can Claude control it after launch?** | No — once handed off, Claude can't see or drive it | Yes — every `browser_*` verb (screenshot, eval, navigate, record, etc.) |--**Decision rule:** human-in-the-loop login flow → `desktop_open_url`. AI-driven automation → `browser_open_window` (defaults to Chrome for Testing). AI-driven automation that **must** be the user's real, signed-in profile (GCP Console with org/projects, internal SSO behind a managed-Chrome policy, anything Context-Aware Access blocks for non-enrolled browsers) → `browser_open_window` with the new `nativeBrowser` + `nativeProfile` args (v1.0.5+, see below).--#### When pup means "the user's real Chrome/Edge" — `browser_open_window` native mode (v1.0.6+, smart flow v1.0.11)--Default `browser_open_window` (no native args) launches Chrome for Testing — same as day one. **Never auto-switch a plain pup request to native.** "Open this URL in pup", "screenshot this", "click that button" all stay on CfT.--Native mode drives the user's **REAL browser** — their actual signed-in profile, with live cookies, org policies, and device enrollment intact (NOT a clone — v1.0.6 replaced the old clone approach). Opt-in via TWO additive args from `desktop_list_browsers`:--```bash-adom-desktop desktop_list_browsers     # installed browsers + profiles (+ the signed-in account per profile)-adom-desktop browser_open_window '{-  "sessionId":"gcp",-  "nativeBrowser":"chrome",            # chrome / edge / brave-  "nativeProfile":"Default",           # profile id: Default / Profile 1 / Profile 2-  "url":"https://console.cloud.google.com"-}'-```--**The smart flow.** CDP (the protocol pup drives) needs the browser running with `--remote-debugging-port`, which can only be set at launch. So the bridge handles each situation differently and tells you which happened:--| Situation | What AD does | Response |-|---|---|---|-| Already driving that browser+profile | Reuse the live connection | `ok` + `narrative` |-| Running **with** a debug port (power-user launched it that way, or AD did earlier) | Connect, no relaunch | `ok` + `narrative` |-| **Not running at all** | **Launch it fresh with the debug port — NO prompt** (nothing to disrupt) | `ok`, `launchedFresh:true` + `narrative` — just proceed |-| **Open without** a debug port | **Ask the user** — AD must close+reopen it | `errorCode:"native_relaunch_required"` + `confirmArgs` + `alternatives` |-| Debug port **refuses to bind** (enterprise policy) | Won't close it again | `errorCode:"native_cdp_blocked"` + `diagnosis` + `alternatives` |--**Handling the two consent/blocked codes:**--- **`native_relaunch_required`** — the browser is open without the port, so driving it means closing + reopening it (with `--restore-last-session`, so all tabs/windows come back). **Ask the user's permission**, then re-call with `"nativeRelaunch":true`. The response's `alternatives` lists other installed browsers you could offer instead.-- **`native_cdp_blocked`** — the debug port won't open, almost always an **enterprise policy** (`RemoteDebuggingAllowed` disabled via Google Workspace / Chrome cloud management — affects **Chrome browser-wide, every profile**). `diagnosis` says whether it's a confirmed local-registry policy or a likely cloud-managed one. AD will **not** close that browser again. `alternatives` names other installed browsers — **Edge usually escapes Google management**, so it often drives natively when Chrome can't. Offer the user the alternative, or fall back to generic CfT.--**Always available — generic CfT.** `browser_open_window` with NO `nativeBrowser`/`nativeProfile` drives a clean Chrome-for-Testing instance: no user session, no managed policy, fully controllable. If a task needs the user's logged-in session there, have them sign in once. This is the universal fallback whenever native is blocked.--**Power-user shortcut:** if the user launches their browser at startup with `--remote-debugging-port=<N>`, AD reconnects every session with no prompt at all (a productized opt-in "keep native browsers debug-ready at boot" is on the roadmap, default OFF for security).--**Routing rule — DON'T auto-escalate.** Treat native as opt-in:--| User says | What to do |-|---|---|-| "pup" / "open in pup" / screenshot / eval / debug | DEFAULT pup (CfT). Never mention profiles. |-| "native chrome" / "my real browser" / "my work profile" / "my `<account>` profile" | Native. Call `desktop_list_browsers`. If exactly one profile clearly fits the request, use it and say which one ("opening in your `[email protected]` profile on Chrome"). If multiple profiles could fit (e.g. a `google.com` URL with both `[email protected]` and `[email protected]` profiles signed in), **ASK** ("Which profile — work `[email protected]` or personal `[email protected]`?") and then drive that pair. |-| anything else | Default pup. |--**Anti-fingerprint:** native sessions launch without `--enable-automation` and with `--disable-blink-features=AutomationControlled`. `navigator.webdriver === false`. No "controlled by automated software" banner. Sites that probe for automation see a normal browser.--**Firefox** appears in `desktop_list_browsers` but is rejected for native drive with `errorCode:"firefox_no_cdp"` — Firefox doesn't speak the Chrome DevTools Protocol, so puppeteer can't drive it. If the user wants Firefox, hand off via `desktop_open_url` instead.--**Native-mode error codes:**-- `native_relaunch_required` — browser open without a port; ask the user, re-call with `nativeRelaunch:true` (see the smart-flow table above).-- `native_cdp_blocked` — debug port policy-blocked; read `diagnosis`, offer `alternatives` or generic CfT (see above).-- `firefox_no_cdp` — caller passed `nativeBrowser:"firefox"`. Firefox doesn't speak CDP; hand off via `desktop_open_url`.-- `native_browser_not_installed` — that browser isn't installed on this machine.-- `native_user_data_missing` — installed but never launched, so the User Data dir doesn't exist yet. Ask the user to open it once.-- `native_profile_not_found` — `nativeProfile` isn't a real profile id under this browser's User Data. Call `desktop_list_browsers` to see what's valid.--#### ⚠️ Common footgun — `localhost` / `file://` URLs from Docker AIs--If you're running this CLI from a remote Docker container (galliaApril, dartv4, etc.), `browser_open_window` / `browser_open_tab` / `browser_navigate` will load the URL inside the USER's local pup — NOT inside your container. So a URL like `http://localhost:8080/foo.html` resolves to the user's machine, not your container's service.--**v1.7.10+ detects this.** When you pass a URL whose host is `localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, or scheme is `file://`, the response includes a `_hint` field explaining the issue and suggesting the public-slug URL pattern (`https://<user>-<repo>-<suffix>.adom.cloud/proxy/<port>/<path>`). **The call still proceeds** — rare legitimate cases exist where the user actually does have something running on their localhost.--**Recipe — load something from inside your container into pup:**--```bash-# 1. Start your service inside the container (e.g. a local HTML viewer):-python3 -m http.server 8786 --bind 0.0.0.0 &--# 2. Ask your USER for the container's adom.cloud slug suffix-#    (look at their browser URL when they open hydrogen or claude.ai/code).-#    Slug looks like: <username>-<container>-<random-id>--# 3. Open the public URL — NOT localhost:-adom-desktop browser_open_window '{-  "sessionId":"demo",-  "url":"https://<your-container-slug>.adom.cloud/proxy/8786/foo.html"-}'-```--If you don't have the suffix handy, just ask the user. Or if the file you wanted to show pup is one your container already wrote to disk, push it to a public location (wiki asset, S3) and load from there.---- `desktop_bring_to_front` -- Bring a window to the foreground by HWND or title substring. Uses keybd_event + AttachThreadInput to bypass Windows foreground lock. Preserves maximized state.-  - Args: `hwnd` (int) OR `titleContains` (string, case-insensitive). One required.-  - Example: `adom-desktop desktop_bring_to_front '{"titleContains": "Fusion"}'`-- `desktop_set_window_state` -- Change a window's show state without bringing it to the foreground (or both, if combined with `desktop_bring_to_front`). Args: `hwnd` (int) OR `titleContains` (string) OR `cacheKey`, plus `state` (one of `"maximize"`, `"minimize"`, `"restore"`, `"show"`, `"hide"`, `"bottom"`). Use to ensure Fusion or KiCad windows are maximized for screenshots, or to hide noisy background windows during demos.-  - Example: `adom-desktop desktop_set_window_state '{"hwnd":133560,"state":"maximize"}'`-  - **`state:"bottom"` (v1.9.114) -- z-order send-to-back, background.** Drops the window BEHIND all others without focusing, moving, or resizing it (`SetWindowPos HWND_BOTTOM` + `SWP_NOMOVE|SWP_NOSIZE|SWP_NOACTIVATE`); works even on an off-screen window. It's an ATOMIC check-and-demote: it reads `GetForegroundWindow()` first, and if the target IS the OS foreground (the user is interacting with it) it SKIPS the demotion and returns `wasForeground:true, sentToBack:false` so the caller backs off with no race; otherwise `wasForeground:false, sentToBack:true`. Use to park a background window behind the user's windows (still on-screen, taskbar-clickable, rendering for screenshots) without stealing focus.-  - **`force:true` (v1.9.115)** sends it to back EVEN IF the window is currently the OS foreground (still no activation change; `sentToBack:true` always, `wasForeground` still reported). Use at window-LAUNCH time: Windows reassigns the foreground to a brand-new window when the previous foreground is destroyed, so "foreground at launch" is not a real user signal.-  - Example: `adom-desktop desktop_set_window_state '{"titleContains":"(session: abc","state":"bottom","force":true}'`--#### Real OS input — mouse + keyboard (v1.8.170+)--`desktop_click` / `desktop_double_click` / `desktop_right_click` / `desktop_hover` / `desktop_type` / `desktop_press_key` synthesize **real OS input via `SendInput`** — so they land on **`chrome://` pages and native dialogs that browser CDP (`nbrowser_*` / `browser_*`) cannot reach**. The verb names mirror the browser extension's `nbrowser_*` so the model transfers between in-page and whole-desktop driving.--- **Gated behind shell-auto-approve** (same risk class as `shell_execute` — keystrokes/clicks hit whatever is focused on the user's live machine). If it's off you get `errorCode:"input_not_approved"`. **You (a cloud/relay AI) can't grant it yourself (v1.9.90)** — call `request_shell_approval` (or let the refusal auto-surface the prompt) and ask the user to click Approve / Bypass Permissions in Adom Desktop, then retry. Every action is written to the activity log.-- **Focus a window first** by passing `{window:"<title substring>"}` (alias `titleContains`) or `{hwnd:<int>}` — reuses `desktop_find_window`'s resolver, so input lands on the intended window, not whatever happened to be focused. Get the hwnd from `desktop_list_windows` / `desktop_find_window`.-- **Coords are physical screen pixels** — the same space as `desktop_list_windows` rects.--Verbs:-- `desktop_click {x, y, button?:"left"|"right"|"middle", double?, window?|hwnd?}` — left-click by default. `desktop_double_click` / `desktop_right_click` are wrappers; `desktop_hover` moves the cursor without clicking (reveals tooltips).-- `desktop_type {text, window?|hwnd?}` — types a literal Unicode string (`KEYEVENTF_UNICODE`, any keyboard layout).-- `desktop_press_key {keys:[...], repeat?, window?|hwnd?}` (alias `desktop_send_keys`) — chords/shortcuts. Modifiers `ctrl/alt/shift/win`; named keys `enter/tab/esc/space/backspace/delete/insert/home/end/pgup/pgdn/up/down/left/right/f1-f12`.--Canonical "navigate a browser to a URL" flow (works on `chrome://` where CDP can't):-```bash-adom-desktop desktop_press_key '{"keys":["ctrl+l"],"window":"Edge"}'   # focus Edge + select the address bar-adom-desktop desktop_type        '{"text":"edge://extensions","window":"Edge"}'-adom-desktop desktop_press_key   '{"keys":["enter"],"window":"Edge"}'-```-For a control that isn't reachable by CDP/coords (e.g. the Reload button on `edge://extensions`, which lives in a shadow DOM), Tab-navigate to it then Enter: `desktop_press_key {"keys":["tab","tab","enter"]}`.--**Visual "Adom cursor" (v1.8.171+)** — a click-through teal cursor + a label that narrates what you're driving (trust cue; byte-faithful to the browser extension's cursor). Pure visual, *not* gated:-- `desktop_cursor {action:"show"|"move"|"hide", x, y, label}` — drive/narrate the cursor on its own ("watch me"). Returns the overlay `hwnd`.-- Or pass `cursor:true` + `label:"Clicking Reload"` on `desktop_click`/`desktop_double_click`/`desktop_right_click`/`desktop_hover` — AD glides the teal cursor to the target (narrating the label) *then* does the real click.-- **Auto-hide (v1.8.174+):** the cursor hides itself after `ttlMs` (default **60s**) so it never lingers on the user's screen if you forget to hide it. Each `show`/`move` refreshes the timer. Pass `ttlMs:<ms>` (≤600000) for longer, or `persist:true` to keep it until you explicitly `desktop_cursor {action:"hide"}`. If a cursor you showed was auto-hidden, the **next** cursor/click response prefixes its `_hint` with a NOTE so you learn (no chatty unsolicited message).--#### Background input via UI Automation (v1.8.174+) — no foreground, no focus steal--The mouse/keyboard verbs above use `SendInput` = real OS input, which only lands on the **foreground** window — so AD foregrounds the target first, yanking it in front of the user's work. When you must NOT steal focus, use **UI Automation** instead: `InvokePattern.Invoke()` / `ValuePattern.SetValue()` are **programmatic** — the app services them via its accessibility tree with **no cursor move, no focus change, no foregrounding**. Chromium/Edge expose their a11y tree to UIA, so `chrome://` page buttons/links are reachable **by accessible name** (not coordinates).--- `desktop_find_control {window|hwnd, name|contains, role?}` — **read-only** discovery (ungated). Returns `best:{name, automationId, role, rect, invokable, settable}`. Call this first to learn whether a control is UIA-reachable.-- `desktop_ui_click {window|hwnd, name|contains, role?}` — UIA **Invoke** by accessible name, in the background. Gated behind shell-auto-approve.-- `desktop_ui_set {window|hwnd, name|contains, text}` — UIA **SetValue** on a text field, in the background. Gated.--`role` ∈ `button|edit|link|checkbox|menuitem|tab|listitem|combobox|radiobutton|document|group|spinner`. **Caveat (surfaced in `_hint`, not hidden):** some shadow-DOM controls aren't in the UIA tree — if `find_control` reports `invokable:false`/not-found, the control genuinely needs a **foregrounded** `desktop_click`. So: prefer `desktop_ui_click` (background) → fall back to `desktop_click` (foreground) only when UIA can't reach it.--#### Near-complete UIA surface (v1.9.0+) — enumerate, read, drive, with a foreground-hint contract--v1.9.0 expands UIA from ~3 patterns to a near-complete agent-drivable client. **Every UIA verb now returns a `foreground` block** so you know — *before and after* — whether a call steals the user's screen:--```-"foreground": { "predicted": "background|fallback-possible|foreground",  // from the static class + the resolved path, BEFORE acting-                "observed":  "background|foreground|n/a",                 // MEASURED (GetForegroundWindow before/after); reads = n/a-                "raised":    true|false,                                  // did THIS call bring a window to front?-                "pathTaken": "uia-pattern|sendinput-fallback|setfocus|read-only",-                "reason":    "<short>" }-```--**Shared selector on every verb:** `{hwnd|window(titleContains)}` + `{name|contains|automationId|role}` + `scope:"window"(default)|"popups"|"subtree:<automationId>"` + `nth:<int>` (0-based — target the Nth match without the exact name). Prefer `automationId` (stable, locale-independent). Rects are screen px with `centerX/centerY`; `space:"image"+shotId` is accepted wherever a coordinate is taken.--- **Enumeration (read-only, ungated):**-  - `desktop_ui_tree {window|hwnd, maxDepth?, roleFilter?[], nameContains?, maxNodes?(500), scope?}` — **flagship.** Dump the a11y subtree; nodes carry rect + invokable/settable + value/toggleState/expandState/selected/rangeValue + `children[]`. Honors Chromium's lazy tree (wake-retry); `truncated:true` when it hits `maxNodes` (logged, never silent).-  - `desktop_ui_from_point {x,y | space:"image",shotId,x,y}` — the control under a pixel.-  - `desktop_find_controls {selector, maxResults?(25)}` — ALL matches as an array with `nth` indices (vs `find_control`'s single best).-- **Reading (read-only, ungated):** `desktop_ui_text {selector, range?:"all"|"selection"|"visible"}` (TextPattern); `desktop_ui_get {selector}` (current value/toggle/range/selection/expand state); `desktop_ui_grid_get {selector, row?, col?}` (GridPattern cell + rowCount/columnCount).-- **Actions (gated by shell-auto-approve; background-first via the pattern, SendInput-click fallback only when the pattern is absent → then `raised:true`):** `desktop_ui_toggle {selector, state?}`, `desktop_ui_expand {selector, action?:expand|collapse|toggle}`, `desktop_ui_select {selector, action?:select|add|remove}`, `desktop_ui_scroll {selector, dir?, amount?}`, `desktop_ui_set_range {selector, value}`, `desktop_ui_focus {selector}` (**bg:foreground** — usually raises; reports it), `desktop_ui_window {window, action:minimize|maximize|restore|close|move|resize}`.-- **Events (v1.9.3 — REACT instead of poll; read-only, ungated):**-  - `desktop_ui_watch {event, window?|hwnd?, properties?}` → `{watchId}`. `event` ∈ `focus` (desktop-wide "what got focus") | `window-opened`/`window-closed` (new/closed top-level windows — these three can be global) | `invoke` | `selection` | `text-changed` | `text-selection` | `menu-opened` | `tooltip-opened` | `notification` | `live-region` | `structure` | `property` (these REQUIRE a `window`, else `errorCode:window_required` — avoids a desktop firehose). Cap 16 watches.-  - `desktop_ui_events {sinceSeq?, watchId?, max?}` → DRAIN the buffer (poll cursor; there is NO server-push over the relay). Returns `{events:[{seq, watchId, event, ts, element:{name,role,automationId,rect,hwnd}, eventType|property|changeType}], nextSeq, highestSeq, missedEvents, activeWatches}`. Poll with `sinceSeq=nextSeq`. Events ALSO appear in the AD GUI activity-log. The 500-deep ring is shared across all watches; `missedEvents:true` = you polled too slowly.-  - `desktop_ui_unwatch {watchId|"all"}` → stop. **Canonical use:** waiting on an OTP/confirm dialog → `ui_watch {event:"window-opened"}`, poll `ui_events` until the window appears, then act on it (no busy screenshot loop).-- **Introspection:** `desktop_ui_capabilities {}` — **read-only, ungated.** The foreground-hint TABLE per verb (`staticClass` + measured `{win32, chromium-pattern, chromium-fallback}`). **Consult it BEFORE a call to know if it'll steal the screen, without acting.** `desktop_ui_selftest {confirm?, window?}` writes/refreshes it (empirically measures when `confirm:true`).--**Integrity boundary:** an elevated/admin window or the UAC secure desktop returns `errorCode:"uia_integrity_blocked"` (never a silent no-op) — a normal-integrity bridge cannot drive a higher-integrity process.--#### Zero-math coordinate clicks — `coordMap` + image-space (v1.8.175+)--You no longer recompute scale/offset/DPI to click what you see in a screenshot. Every screenshot verb returns a **`coordMap`**:--```jsonc-"coordMap": {-  "shotId": "shot-w5050328-1",-  "image": { "w": 1372, "h": 1031 },              // the DELIVERED (safe) PNG's pixels-  "screenRect": { "x": 594, "y": 254, "w": 1372, "h": 1031 },  // physical px = input-verb space-  "dpiScale": 1.5,-  "imageToScreen": { "sx": 1.0, "sy": 1.0, "ox": 594, "oy": 254 }  // screen = img*s + o-}-```--Two ways to use it:-- **Let AD convert (preferred, zero math):** `desktop_click {space:"image", shotId, x, y}` where `x,y` are pixels in that screenshot. Also works on `desktop_double_click`/`right_click`/`hover`. The `shotId` comes from the capture's `coordMap.shotId` (last ~64 shots retained; re-capture if it expires).-- **Compute yourself:** `screenX = x*sx + ox`, `screenY = y*sy + oy`.--`screenRect` is in the SAME physical-px space as `desktop_list_windows` rects and the `desktop_click` default (`space:"screen"`).--> **Driving a GUI app that pops error boxes / modal dialogs? Read the dedicated [`adom-desktop-window-capture`](WINDOW_CAPTURE.md) skill.** `desktop_screenshot_window {hwnd}` captures ONE window AND the child dialogs / error boxes it owns, even when the window is backgrounded/occluded/minimized (PrintWindow, no focus steal). It is the breakthrough for automating native apps whose dialogs a full-screen grab can't see. The rest of this section is the reference; that skill is the how-and-why.--**Popups (owned):** As of **v1.8.177**, `desktop_screenshot_window` **also captures the window's OWNED popups** — borderless confirm bubbles (Chromium's "Remove '\<ext\>'?"), owned dropdowns, owned dialogs — that don't enumerate in `desktop_list_windows` and aren't visible in a PrintWindow of the main window. They ride back as a top-level **`screenshots[]`** array (the same generic shape `kicad_screenshot_all` uses; the CLI auto-pulls each entry's `fullPath`→`localPath` and `safePath`→`localSafePath`). `ownedPopupCount` is always present (0 = none). Each entry carries `hwnd`, `title`, `kind:"owned_popup"`, and its own `coordMap`+`shotId`, so you can **click the bubble's buttons** with `{space:"image", shotId, x, y}` or drive them in the background by name via `desktop_ui_click {scope:"popups"}`. This is the generic mechanism — there's no app-specific "Chrome bubble" verb; every window screenshot surfaces any owned popups so you get the data and decide. **Use `desktop_screenshot_screen`** (full virtual-screen grab) only for popups *not owned* by your target window, or to see everything at once.--**Screen geometry on every shot (v1.8.178):** every `desktop_screenshot_window` / `_screen` response now carries:-- **`windowRect: {x,y,w,h,monitor}`** — the window's position + size on the overall (possibly multi-monitor) desktop, in physical screen px. Each owned popup entry in `screenshots[]` gets its own **`rect: {x,y,w,h,monitor}`** too — so you know where every captured surface sits, not just its pixels.-- **`display: {virtualScreen, primary{w,h,dpi,scale}, monitors[], monitorOfWindow}`** — the full monitor topology: total virtual bounds, and per-monitor `bounds`/`dpi`/`scale`/`primary`. `monitorOfWindow` indexes into `monitors[]`. (Same data as the standalone `desktop_display` verb, folded in.)-- **`drive: "background"`** — screenshots never foreground the window (PrintWindow/WGC). The companion `_related` field lists sibling screenshot/window verbs.--**Screenshot cache is self-cleaning + self-describing (v1.9.107/108/113):** the CLI auto-pulls every screenshot to ONE user-visible, namespaced dir on the caller's machine (`~/project/screenshots/adom-desktop/`, else `~/.adom/screenshots/`; `ADOM_SHOTS_DIR` overrides) — never `/tmp` anymore. Shots are **bucketed by kind into subfolders**: `window/` (a specific window / app / owned popup) and `screen/` (a full-desktop grab). **Human-readable filenames (v1.9.113):** `<YYYY-MM-DDThh-mm-ss.mmm>[-<hwnd>].png` — a sortable ISO stamp (lexicographic == chronological), plus the `hwnd` for a window capture; the `window`/`screen` kind is NOT repeated in the name because the folder already says it. **One file when small (v1.9.114):** if the shot was already within the safe size cap (the common case) it's a single `<stem>.png` and `localFullPath == localSafePath` — no redundant re-encoded copy. Only a shot large enough to be downscaled produces a `<stem>.full.png` + `<stem>.safe.png` pair (Read the `.safe` one for image analysis; use `.full` for archive). Each shot gets a **`<stem>.json` sidecar** (shotlog-style: `source`, `timestamp`, `kind`, dims + `bytes`, `coordMap`, `rect`; a full+safe pair shares one). **Parent/child grouping (v1.9.111/113):** when `desktop_screenshot_window` returns owned popups in `screenshots[]`, the parent AND each pulled child share the same stem, and each child gets a **`.popup-NN` label** — e.g. parent `2026-07-15T01-14-16.937-986710.safe.png` and children `2026-07-15T01-14-16.937-986710.popup-01.safe.png`, `...popup-02...`. So a plain `ls` shows the whole capture grouped (shared stem) and the role (`.popup-NN`) without opening a sidecar; the sidecar also carries `parent` + `popupIndex`. (`kicad_screenshot_all` returns sibling windows with no parent, so those keep their own names.) A janitor prunes it (7 days / 2 GB / newest kept; sidecars follow their PNG, orphans swept) and drains the old `/tmp/ad-shots` + `/tmp/adom-desktop-screenshots` backlogs, running opportunistically at most once/24h on any CLI call. **You do not need to clean it up or write your own cron.** Every screenshot response carries a `_cacheHint`; verify coverage with **`adom-desktop janitor`** (status: each subfolder, size, policy, last sweep) or force a sweep with **`adom-desktop janitor '{"sub":"run"}'`**. It never touches other tools' files under `~/project/screenshots/` (e.g. shotlog). Tune via `ADOM_SHOTS_MAX_AGE_DAYS` / `ADOM_SHOTS_MAX_GB` / `ADOM_SHOTS_MAX_FILES`, or disable with `ADOM_JANITOR=0`.--**Background vs foreground driving (v1.8.178):** every desktop driving verb carries a structured **`drive`** field (`"background"` | `"foreground"` | `"n/a"`) — in `GET /commands`, in `adom-desktop help <verb>`, and echoed in the verb's own response. **Background** (no foreground / focus steal / cursor move — UIA + capture): `desktop_ui_click`, `desktop_ui_set`, `desktop_navigate`, `desktop_find_control`, `desktop_screenshot_window/_screen`, `desktop_record_window_start`. **Foreground** (SendInput → goes to the focused window, so AD foregrounds first): `desktop_click`/`_double_click`/`_right_click`/`_hover`, `desktop_type`, `desktop_press_key`, `desktop_bring_to_front`. Run **`adom-desktop help background`** for the full grouping. Rule: to act without disturbing the user's window, drive by NAME (`desktop_find_control` → `desktop_ui_click`/`ui_set`); fall back to coordinate clicks/keys only when the control isn't in the UIA tree.--**Click a control by name → coords:** `desktop_find_control` returns `best.rect{x,y,w,h,centerX,centerY}` in screen px — `desktop_click {x:centerX, y:centerY}` to SendInput-click it (handy when `invokable`/`settable` are both false). Target by `name`, `contains`, `role`, or `automationId` (stable, locale-independent).--**Keyboard chords:** `desktop_press_key` accepts a chord as one string `["ctrl+w"]` OR separate tokens `["ctrl","w"]` — both mean Ctrl held + W. A *sequence* of distinct chords uses `+` per element: `["ctrl+l","enter"]`. The reply echoes `chords` showing how AD interpreted your `keys`.--- `desktop_flash_window` (v1.8.152+) -- Flash an **arbitrary** window's taskbar button orange (Win32 `FlashWindowEx`) as an "attention / ongoing activity" cue. Resolve the HWND with `desktop_list_windows` first. Args: `hwnd` (int) OR `titleContains` (string); `mode` (`"until_focused"` default — flashes until the user clicks the window; or `"count"`); `count` (int, `mode:"count"` only, default 3); `stop` (bool — clear an active flash). Returns `{ok, hwnd, title, mode}`. **Lightweight by design** (direct FlashWindowEx, no process spawn) so a bridge can call it after every verb. Distinct from the puppeteer bridge's `browser_alert_window`, which only flashes pup's OWN tracked windows — this flashes any window on the host. For progress + overlay-icon status, use `desktop_taskbar`.-  - Example: `adom-desktop desktop_flash_window '{"hwnd":133560}'` then `adom-desktop desktop_flash_window '{"hwnd":133560,"stop":true}'` to clear.-- `desktop_taskbar` (v1.8.152+) -- Paint **glanceable status** on any window's taskbar button via `ITaskbarList3` (progress bar + overlay icon) + `FlashWindowEx`, so a bridge can show what the AI is doing on the **real app window it drives** (e.g. native-browser putting an "Adom is driving" badge + task progress on the Chrome/Edge button). Works on windows owned by **other processes**. Persistent COM (init once, reuse) — safe to tick progress frequently; no process spawn. Resolve the HWND via `desktop_list_windows`. Args (all channels optional; provide ≥1):-  - `hwnd` (int) OR `titleContains` (string)-  - `flash`: `"until_focused"` | `"stop"`-  - `progress`: `{state: "normal"|"paused"|"error"|"indeterminate"|"none", value?: 0..100}` — **state→color**: normal=green, **paused=yellow** (use for human-in-the-loop gates: captcha / 2FA / login), error=red, indeterminate=marquee, none=clear.-  - `overlay`: `{badge: "adom"|"none", icon?: "<PNG or .ico — file path OR base64>", tooltip?: "<hover>", avatar?, profileDir?, userDataDir?, composite?}` — built-in badges are `adom` (the Adom logo — a "driven by Adom" trust tell) and `none` (clear); 3rd-party bridges pass their **own** `icon` (a **PNG** or `.ico`, file path or base64 — AD magic-sniffs + renders at 32px). AD stays generic: it paints whatever art you hand it. `tooltip` is the overlay's accessible description.-    - **Compositing onto a Chrome/Edge profile avatar (v1.9.110, #154):** Windows gives ONE overlay slot per taskbar button, and Chrome/Edge use it for the **profile avatar** — so a bare `badge:"adom"` would EVICT the profile face (you'd lose "work vs personal"). To keep the avatar AND show "Adom is driving", pass the avatar and AD composites the mark onto it (bottom-right ~47%): `overlay:{badge:"adom", avatar:"<PNG path or base64>"}`, or name the profile with `overlay:{badge:"adom", profileDir:"Profile 1", userDataDir?:"<User Data root>"}` (AD reads `<UserData>/<profileDir>/Google Profile Picture.png`; `userDataDir` is derived from the window's exe if omitted). `composite:false` opts out (raw mark). If the avatar can't be resolved AD falls back to the plain mark — never errors. The CALLER (which knows its profile — e.g. the browser extension) supplies the avatar; AD does not reverse-engineer it (one browser process serves all profiles, so the process command line has no `--profile-directory`).-  - `thumbnailTooltip` (v1.9.153): the taskbar **hover-preview tooltip** (`ITaskbarList3::SetThumbnailTooltip`). The ONLY uncapped text surface on the preview — the window title truncates ~25-30 chars, this string does not (and takes newlines). e.g. `"Adom Pup / Adom wiki / LOGGED-IN view / Chrome for Testing / 3 tabs"`. Pass `""` to clear it back to the title. Works on a window AD does not own.-  - `thumbnailClip` (v1.9.153): crop the live hover preview to a client-space region — `{x,y,w,h}`, or `null` to reset to the whole window (`SetThumbnailClip`).-  - Returns `{ok, hwnd, title, applied:[...]}`. Clears: `flash:"stop"`, `progress.state:"none"`, `overlay.badge:"none"`, `thumbnailTooltip:""`, `thumbnailClip:null` — call these on task-end / window-close to clean up.-  - Example (long task, human gate, then done):-    ```bash-    adom-desktop desktop_taskbar '{"hwnd":133560,"overlay":{"badge":"adom","tooltip":"Adom is driving this browser"},"progress":{"state":"indeterminate"}}'-    adom-desktop desktop_taskbar '{"hwnd":133560,"progress":{"state":"paused"},"flash":"until_focused"}'   # captcha — come look-    adom-desktop desktop_taskbar '{"hwnd":133560,"progress":{"state":"none"},"overlay":{"badge":"none"},"flash":"stop"}'  # done — clear all-    ```-- `desktop_set_window_identity` + `desktop_register_app_identity` (v1.9.134+) -- **Full window-identity takeover.** Make a window you drive present itself as YOUR app: the taskbar button, Alt-Tab entry, hover tooltip and any user pin read your name and icon instead of the process that happens to own the HWND. Built for a bridge driving a borrowed host app (a Chrome for Testing window otherwise reads "Test" with Chrome's icon, which is two brands and a confusing word). AD is generic here — it applies whatever `appId` / `iconPath` / `displayName` you pass and knows nothing about which app that is.--  **You need BOTH halves.** `set_window_identity` stamps a LIVE window (`PKEY_AppUserModel_ID` + `WM_SETICON`); `register_app_identity` teaches Windows what that `appId` MEANS (an HKCU `AppUserModelId` key + a Start Menu shortcut carrying the same AUMID). An unregistered appId falls back to the owning exe's icon, so half one alone leaves Alt-Tab wrong.--    ```bash-    # Once per install. Ship the .ico in your bridge's release zip; pass its cache path.-    # `target`/`args` are what a user's PINNED tile launches — point it at a real verb so-    # the pin opens a managed window instead of an orphan app.-    adom-desktop desktop_register_app_identity '{"appId":"Adom.Pup","displayName":"Adom Pup",-      "iconPath":"C:/.../adom-pup.ico",-      "args":"browser_open_window {\"sessionId\":\"pinned\",\"owner\":\"pinned-launch\"}"}'--    # Per window, e.g. at park time. cacheKey survives page-title churn.-    adom-desktop desktop_set_window_identity '{"cacheKey":"<sessionId>",-      "titleContains":"(session: <sessionId>)","appId":"Adom.Pup",-      "displayName":"Adom Pup","iconPath":"C:/.../adom-pup.ico"}'-    ```-  - Window target: `hwnd` OR `titleContains` OR `cacheKey` (+ `fresh` to re-resolve). Response carries `resolvedBy` (`hwnd` | `cachedHwnd` | `title`) and `applied[]`.-  - **Identity is per-HWND and dies with the window** — re-apply after a window is recreated.-  - AD holds the icon handles for its whole process lifetime. Icon handles are process-owned: a transient setter leaves windows pointing at dead handles, so the icon reverts moments later. AD being long-lived is exactly why it owns them.-  - `register_app_identity` writes HKCU only (never a UAC prompt). It is gated for RELAY callers like `write_file`; a bridge calling over the loopback API is trusted by transport and ungated.-  - **Jump-list tasks (v1.9.139+): `desktop_set_window_jumplist {appId, tasks}`** puts YOUR actions on the taskbar right-click menu of every window stamped with that appId. Jump lists attach to the AUMID, so per-session appIds give each window its OWN menu. Each task: `{title (required), target? (exe, defaults to AD's bundled CLI — so `args` can simply be an AD/bridge verb + JSON), args?, iconPath?, description?}`. Tasks launch minimized (no console flash). Max 16; `tasks:[]` clears (call at session close, alongside `unregister_app_identity`). Re-set anytime to update the menu. Tier-2 gated for relay callers; loopback bridges ungated.-  - **Per-session / transient appIds (v1.9.136+): register REGISTRY-ONLY with `shortcut:false`, unregister on close.** If you stamp per-session AUMIDs (e.g. `Adom.Pup.<sessionId>` so each window keeps its own taskbar button in split mode), an UNREGISTERED AUMID makes Alt-Tab fall back to the owning exe's icon. `desktop_register_app_identity {appId, displayName, iconPath, shortcut:false}` writes just the HKCU AppUserModelId key (DisplayName + IconUri) — Alt-Tab and taskbar hover resolve the name + icon with NO Start Menu entry. Pair with `desktop_unregister_app_identity {appId}` at session close (idempotent; removes the key plus any shortcut carrying that AUMID; AD's own `inc.adom.desktop` identity is protected). Keep ONE shortcut-mode registration as the pinnable launcher.-  - **`desktop_set_window_jumplist`** (v1.9.139+) adds the right-click menu on the taskbar button (per-AUMID; `tasks:[]` clears) and, with `hwnd`+`headerIcon`, brands the header row (the app tile above Pin/Close).-  - **Icon format — read the `iconCheck` these verbs return (v1.9.152).** `desktop_set_window_identity` / `desktop_register_app_identity` / `desktop_set_window_jumplist` return an `iconCheck {kind,sizes,issues,ok,hint}` (or `headerIconCheck`) that magic-sniffs the file and explains why an icon would render as a generic white document: a **256px `.ico` size not PNG-compressed** (the Vista+ trap), **SVG** (the Win32 shell can't load it), a `.ico` with no small size, an `.exe` where an image is wanted. If `iconCheck.ok` is false, fix the file and re-stamp. Ship a multi-size `.ico` (16/24/32/48/256, the 256 PNG-compressed). **Full guide:** the `adom-desktop-window-identity` skill (AUMID model, the register+stamp two-halves rule, jump-list header, taskbar overlay + thumbnail, the live-tile trap, a recipe).-- `desktop_revoke_approvals` -- Revoke all shell auto-approve permissions and deny pending approvals. The next shell command will show the approval dialog.-  - Example: `adom-desktop desktop_revoke_approvals`-- `desktop_caption` -- Show a global caption overlay that stays visible above ALL windows (KiCad, Fusion, Chrome). Native Win32 overlay — not a browser DOM element. Click-through: mouse events pass to windows below. Captured by screen recordings (browser `getDisplayMedia`, OBS, Game Bar).-  - Show: `adom-desktop desktop_caption '{"text":"Step 1: Opening the board","position":"top","size":"large","duration":3000}'`-  - Custom position: `adom-desktop desktop_caption '{"text":"Look here","x":0.3,"y":0.2,"size":"large","persist":true}'`-  - Hide: `adom-desktop desktop_caption '{"action":"hide"}'`-  - List what's on screen (v1.9.217): `adom-desktop desktop_caption '{"action":"list"}'` — returns `{captions:[{id,ageMs,expiresInMs,persist,overdue}]}`; the authoritative check (caption overlays do NOT appear in `desktop_list_windows`). TTLs are fail-safe: a 15s reaper closes any caption that outlives its deadline. Do not keep a caption alive with a same-id refresh loop as a nag — the user experiences one endless caption.-  - Multiple simultaneous captions (use `id` to keep them independent):-    ```-    adom-desktop desktop_caption '{"text":"Step 1: Opening board","id":"step","position":"center","size":"large","persist":true}'-    adom-desktop desktop_caption '{"text":"REC ●","id":"status","position":"bottom-left","size":"medium","persist":true}'-    ```-  - Replace only the step caption (status stays):-    ```-    adom-desktop desktop_caption '{"text":"Step 2: Routing","id":"step","position":"center","size":"large","persist":true}'-    ```-  - Hide just the step caption: `adom-desktop desktop_caption '{"action":"hide","id":"step"}'`-  - Args:-    - `text` (string) — caption text-    - `id` (string, optional) — identifies this caption. Captions with the same id replace each other; different ids coexist simultaneously. Default `"_default"` — so bare calls without id still replace each other for backward compat.-    - `position` — preset: `"top"`, `"bottom"` (default), `"center"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`-    - `x` (float 0.0–1.0) — normalized screen X, overrides position horizontal. 0.0 = left edge, 1.0 = right edge. Centers the caption box on this point.-    - `y` (float 0.0–1.0) — normalized screen Y, overrides position vertical. 0.0 = top edge, 1.0 = bottom edge.-    - `size` — preset: `"large"` (72px), `"medium"` (32px, default), `"small"` (20px)-    - `fontSize` (int) — custom font size in px (8–400). Overrides `size` preset when provided.-    - `duration` (ms) — auto-dismiss timer. **Omitted or `0` → `30000` (30s); clamped to max `600000` (10 min).** Captions ALWAYS auto-expire (v1.8.138+) so a forgotten one can't be stranded on the user's desktop. To keep one up longer, **re-issue the same `id`** (resets the timer) or pass a bigger `duration`.-    - `persist` (bool, optional) — `true` = sticky, NO auto-dismiss. Supervised use only (e.g. demo recordings); you MUST then dismiss it yourself with `action:"hide"`. This is the only way to a never-expiring caption (`duration:0` no longer does that).-    - `action` — `"hide"` to dismiss a caption (with `id`: hides only that caption; without `id`: hides all captions), `"force-clear"` to EnumWindows and destroy ALL caption windows regardless of id (nuclear option — use at top of demo scripts for guaranteed clean slate)-  - Captions with the same `id` replace each other instantly (previous destroyed, no fade overlap). Captions with different ids coexist — multiple captions can be visible simultaneously.--### Desktop Screenshots--Take screenshots of the user's desktop or individual windows. All screenshots use lossless PNG.--**Always save screenshots** to `project-content/screenshots/`.--**Naming convention:** `desktop-<descriptive-name>-YYYY-MM-DD-HhMMam/pm.png`--**Workflow:** Call `desktop_list_windows` first to get HWNDs, then `desktop_screenshot_window` with the HWND.--- `desktop_list_windows` -- List all visible windows (returns HWND, title, class name, position/size, and `z` = Z-order index, 0 = topmost)-- `desktop_find_window` (v1.8.153+) -- Resolve a window's HWND by `titleContains` and/or `className` (case-insensitive substrings), returned in Z-order with `best` = the topmost match. Use this instead of diffing `desktop_list_windows` before/after launching something — it's race-free for "the window I just opened." Returns `{ok, count, best:{hwnd,title,className,z,rect}|null, windows[]}` (`ok` true only when ≥1 matched). Pass `best.hwnd` to `desktop_taskbar` / `desktop_flash_window` / `desktop_screenshot_window`.-  - Example: `adom-desktop desktop_find_window '{"className":"Chrome_WidgetWin_1","titleContains":"GitHub"}'`-- `desktop_activity_filter` (v1.8.155+) -- Scope the **user's GUI activity log** so they watch the slice you care about while you work (e.g. focus it on one bridge before a debugging session). Args: `bridge` (a bridge registry name from `bridge_list` | `"none"` = only global/non-bridge AD commands | `"all"`/omit = everything), `text` (free-text substring, ANDs with bridge), `clear` (bool — reset). **Drives the human's GUI view only — it does NOT change which verbs you can call or what they return.** The GUI shows a status bar stating exactly what's shown + what's hidden.-  - Example: `adom-desktop desktop_activity_filter '{"bridge":"kicad"}'` then `adom-desktop desktop_activity_filter '{"clear":true}'`-- `desktop_screenshot_window` -- Capture a specific window by HWND-- `desktop_screenshot_screen` -- Capture the entire desktop (all monitors)-- `desktop_ad_scroll` (v1.9.86+) -- **Scroll AD's OWN window panels.** AD is a fully AI-controllable app, so you can drive its own scrollbars — most usefully to page the **bridges sidebar** and screenshot cards that are below the fold. Args: `panel` (`bridges` default | `activity` | `connections`), `to` (`top`|`bottom`, absolute — wins over `dy`), `dy` (relative px, default 400; negative = up). Returns `{ok, panel, scrolled, state:{scrollTop,scrollHeight,clientHeight,atTop,atBottom,found}|null, _hint}` — round-tripped from the live window so you can **loop until `state.atBottom==true`**. A collapsed bridges sidebar is auto-expanded first; `state==null` means the panel didn't report (collapsed/empty).-  - Capture-all-cards workflow: `desktop_ad_scroll '{"panel":"bridges","to":"top"}'` → `desktop_screenshot_window` (AD hwnd) → `desktop_ad_scroll '{"panel":"bridges"}'` → screenshot → repeat until `atBottom`.--### App self-update (relay-driven, v1.9.87+)--AD auto-updates itself from the wiki manifest, but historically only a HUMAN clicking the footer "Install now" banner could apply it — so a headless / RDP / fleet box (a fresh Azure VM) could sit a version behind with no path forward from the relay. These verbs give the AI that path (same shape as the shell-approval surfacing verbs). Use `--target <box>`.--- `update_status` (read-only, ungated) -- `{currentVersion, latestVersion, updateAvailable, updateReady, state, busy, updateMode}`. `updateReady:true` = the newer version is downloaded + SHA-verified on disk, appliable with no network.-- `update_check` -- force a manifest check now; if newer, download + verify (stage) it in the background. Poll `update_status` until `updateReady:true`.-- `apply_update` -- the programmatic "Install now": apply the staged update + relaunch AD. Respects `busy` (refuses if a command is in flight). Only ever applies AD's own downloaded + SHA-verified SIGNED wiki release. Returns `{applying:true}` then AD restarts (the relay drops + reconnects; poll `targets`/`ping` for the new version). If not staged yet, it kicks staging and tells you to poll + retry.-- `request_update_approval` -- surface the "Update available — Install now / Later" banner on the target + bring AD to the front (for an ATTENDED box where the human should decide). Does NOT apply; `apply_update` installs.-- `update_set_mode {mode: auto|prompt|off}` -- the auto-apply POLICY. `auto` = unattended stage+apply on the 4h/on-launch poll (set this ONCE on headless fleet boxes and they self-update every release with no relay call); `prompt` = show the banner (default, attended); `off` = no checks.-  - Fleet flow: `update_status --target <box>` → if `updateAvailable` and unattended, `apply_update --target <box>` (or set `update_set_mode {"mode":"auto"}` once). A box must already be on ≥1.9.87 to have these verbs; the one-time bootstrap of an older box still needs a human click OR `auto` mode.--### Hydrogen companion mode (v1.9.218+)--When a HUMAN opens AD on a machine where Hydrogen Desktop is detected (embedded marker, Hydrogen's ports.json, or a discoverable Hydrogen install), AD shows an identity overlay ("This is Adom Desktop" + a large Open Hydrogen Desktop button), instantly opens or foregrounds Hydrogen, and places its own window directly below Hydrogen's in z-order — most people who open AD by hand were looking for Hydrogen. Facts an AI should know:--- Human-open detection = bare argv AND parent process `explorer.exe` (double-click, Start menu, search, pins). Machine launches — `--autostart` boot, Hydrogen `--embedded` respawns, the relauncher/`restart` verb, CLI-style invocations — NEVER trigger it, so a remote `restart` cannot bounce the user's screen.-- One-shot placement: AD does not fight the user's later alt-tabbing, and it is NOT a real Win32 child window (AD's lifecycle stays independent — the recovery-channel contract holds; if Hydrogen cannot be launched the overlay says so and AD stays fully usable).-- Toggle: Settings > Hydrogen Desktop > "Act as a companion to Hydrogen Desktop", or `settings_set {"settings":{"hd_child_mode":false}}`. Default ON (self-gates on Hydrogen detection, so standalone installs never see it).-- Forensics: every decision slogs `HD-COMPANION:` lines to startup.log.--### User settings over the API (`settings_get` / `settings_set`, v1.9.214+)--AD's Settings-panel preferences are readable and settable over the API. This is the sanctioned channel for an embedding app (Hydrogen Desktop) to render AD's settings in its own panel and forward the user's changes; it also lets a fleet operator read or fix a box's boot/update prefs remotely.--```bash-adom-desktop settings_get-adom-desktop settings_set '{"settings": {"launch_on_boot": true, "update_mode": "auto"}}'-```--- Settable keys (whitelist): `launch_on_boot`, `always_show_window_on_launch`, `update_mode` (auto|prompt|off), `auto_restart_for_updates`, `update_idle_minutes` (1..1440), `prewarm_runtimes`, `prewarm_cft` (auto|always|never), `file_receive_directory`. Anything else lands in `rejected` — servers, shell approval (grants are human-only), and blocked peers have their own surfaces and are deliberately not settable here.-- `launch_on_boot` changes apply the OS Startup artifact immediately; `settings_get` also returns `autostartArtifactPresent` so you can see intent-vs-artifact drift.-- `settings_set` is Tier-2 gated for relay (cloud-AI) callers — carry a `reason` and expect the human-approval flow, like `shell_execute`. Local/loopback callers (Hydrogen, bridges, the local CLI) are trusted by transport.-- Self-heal: AD re-verifies the launch-on-boot artifact every ~60s while running (not just at launch), so anything that deletes the Startup shortcut behind AD's back gets healed within a minute — with a `RECONCILE:` forensic line in `startup.log`.--### Browser automation (`browser_*`, Puppeteer/pup) — bridge-owned; light pointer--The pup bridge is **cloud-owned** and ships its OWN full skill (its wiki page / skill pkg — `bridge_list` names it; `adom-desktop sync_skills` installs it). This page keeps only the pointer plus first-touch examples; sessions/profiles, credential vault, downloads, recording, CDP details are the bridge's skill.--Most-used verbs:--```bash-adom-desktop browser_open_window '{"sessionId": "s1", "profile": "p1", "url": "https://example.com"}'-adom-desktop browser_navigate '{"sessionId": "s1", "url": "https://example.com/page2"}'-adom-desktop browser_screenshot '{"sessionId": "s1"}'-adom-desktop browser_eval '{"sessionId": "s1", "expression": "document.title"}'-adom-desktop browser_fetch_url '{"url": "https://example.com/data.json"}'-```--`browser_readiness` is the read-only probe (never spawns or downloads). Full details: the bridge's own skill via `sync_skills`.--### Screen recording — AD's desktop recorder (tab recording is pup's)--AD owns DESKTOP recording: a visible HUD opens with the REASON you supply, so the user always sees that recording is on and why. Full guide: the `adom-desktop-recording` skill.--```bash-adom-desktop desktop_recorder_open '{"reason": "record the DRC walkthrough for review"}'-adom-desktop desktop_record_start '{"confirmDesktopNotTabRecording": true}'-# ... drive the desktop ...-adom-desktop desktop_record_stop '{}'-adom-desktop desktop_record_list '{}'-adom-desktop desktop_recorder_close '{}'-```--`desktop_record_start` without `confirmDesktopNotTabRecording: true` refuses with `errorCode: desktop_record_needs_confirmation` — that guard exists because recording a BROWSER TAB is the pup bridge's separate feature (`browser_record_start` etc., see pup's own skill).--### Filesystem primitives — `desktop_list_files` / `desktop_watch_files` / `desktop_pull_glob` (v1.5.0+)--**These are the canonical "wait for a download" primitives.** They replace the `shell_execute` + PowerShell-poll-Downloads pattern that earlier callers (chip-fetcher, similar tools) had to use. No shell, no user approval prompt, no parsing of `dir` output. They run pure Rust on the desktop side, so they're fast, predictable, and never trip over PowerShell quoting.--The pattern they replace looks like this:--```powershell-# OLD — DON'T do this for new code:-$before = (Get-Date).ToFileTime()-# … click download …-while (-not (Get-ChildItem ~/Downloads -Filter ul_*.zip | Where { $_.LastWriteTime.ToFileTime() -gt $before })) {-  Start-Sleep -Seconds 1-}-$file = Get-ChildItem ~/Downloads -Filter ul_*.zip | Sort LastWriteTime -Desc | Select -First 1-```--becomes one call:--```bash-# NEW — single primitive, no shell:-adom-desktop desktop_watch_files '{-  "path": "%USERPROFILE%\\Downloads",-  "glob": "ul_*.zip",-  "timeoutMs": 60000-}'-# → {ok:true, file:{path,name,size,mtime}, elapsedMs} on success-# → {ok:false, error:"timeout", elapsedMs, _hint, ...} on timeout-```--#### `desktop_list_files` — one-shot directory listing--Lists files in a directory (non-recursive) matching a shell-glob, optionally filtered to those modified after a given timestamp.--- Args: `path` (Windows abs path; `~` / `%USERPROFILE%` / `%APPDATA%` / `%LOCALAPPDATA%` / `%TEMP%` are expanded), `glob` (default `*`; shell-style with `*` and `?`; case-insensitive on Windows), `modifiedSince` (optional unix-seconds OR ISO-8601 string).-- Returns: `{ok, path, glob, files: [{path, name, size, mtime}, ...] sorted newest-first, count}`.-- Does NOT recurse. Lists one directory only.--#### `desktop_watch_files` — block until a match arrives--Polls the directory every `pollMs` (default 1000) until at least one file matches the glob with `mtime > since`, or `timeoutMs` (default 60000, max 600000 = 10 min) elapses.--- Args: same as `desktop_list_files` plus `since` (defaults to **now** — without an explicit value, only NEW files report), `timeoutMs`, `pollMs`.-- Returns on match: `{ok:true, file:{path,name,size,mtime}, elapsedMs}`.-- Returns on timeout: `{ok:false, error:"timeout", elapsedMs, _hint, path, glob}`.-- The `since`-defaults-to-now behavior matches the canonical "click then watch" flow. If you might miss the file by starting the watch slightly late (e.g. fast small downloads), record `$(date +%s)` BEFORE the click and pass it as `since`.--#### `desktop_pull_glob` — one-shot orchestrator (recommended for download flows)--Composes `desktop_list_files` (or `desktop_watch_files` if `wait:true`) with the existing streaming `pull_file` mechanism. The whole "wait for a download then pull it" flow in one call:--```bash-BEFORE=$(date +%s)-# … trigger the download click via browser_input_dispatch …-adom-desktop desktop_pull_glob "{-  \"path\": \"%USERPROFILE%\\\\Downloads\",-  \"glob\": \"ul_*.zip\",-  \"since\": $BEFORE,-  \"wait\": true,-  \"timeoutMs\": 60000,-  \"saveTo\": \"/tmp/cse-out\"-}"-# → {ok:true, files:[{name,path,size,sha256,chunks}, ...], errors:[], matchedCount}-```--- When `wait:true`: blocks via `desktop_watch_files` until at least one match appears, then re-lists the directory and pulls **everything matching** (so a `.crdownload` + final `.zip` written in the same poll tick both come along).-- When `wait:false` (default): just lists what's there now and pulls those.-- Pulls use the existing `pull_file` streaming pipeline — sha256-verified, ~1MB chunks, resumes the same way `pull_file` does.--#### Glob semantics--- `*` matches any run of non-separator chars (within a single filename — these primitives don't recurse).-- `?` matches a single non-separator char.-- Case-insensitive on Windows (matches the filesystem); case-sensitive on macOS/Linux.-- Examples: `ul_*.zip`, `LIB_*.zip`, `*.pdf`, `datasheet_*.pdf`, `*.step`, `*.STEP` (same on Windows).--#### Path expansion--- `~` and `~/foo` → home dir (Linux/macOS convention; works on Windows too).-- `%USERPROFILE%` → home dir (Windows; also works cross-platform).-- `%APPDATA%` → Windows roaming app data (`dirs::config_dir()` on others).-- `%LOCALAPPDATA%` → Windows local app data (`dirs::data_local_dir()` on others).-- `%TEMP%` → Windows TEMP env var; `/tmp` on Linux/macOS.-- Expansion is case-insensitive: `%userprofile%` works the same as `%USERPROFILE%`.-- Forward slashes (`/`) and back slashes (`\`) both work in the path string.--#### Time format for `since` / `modifiedSince`--- Number: unix seconds (e.g. `1746483600`). Floats accepted.-- String of digits: same (e.g. `"1746483600"`).-- ISO-8601 / RFC-3339 string: e.g. `"2026-05-04T22:30:00Z"` or with offset `"2026-05-04T15:30:00-07:00"`.-- Files with `mtime <= since` are filtered out (strict `>`).--### Hydrogen Desktop (`hd_*`) — proxy + build/lifecycle for the sibling Tauri app--When the user has **Hydrogen Desktop** running (a sibling Tauri v2 app at `C:\Github\hydrogen-desktop`), built-in `hd_*` verbs reach into it. NOT a separate bridge process — adom-desktop proxies HTTP calls directly to Hydrogen's control API.--**Port discovery (v1.8.72+):** Hydrogen's control API lives on **port `47084`** (default; the actual port is read from `%APPDATA%\hydrogen-desktop\ports.json` key `control` on every `hd_api` call, fallback 47084). Pre-v1.8.72 the verbs were hardcoded to Hydrogen's legacy `:9001` webview-eval port, which Hydrogen doesn't bind anymore — that's why `hd_status` reporting "Hydrogen not running" while Hydrogen's window is visible is the upgrade signal.--#### The canonical generic verb — `hd_api` (v1.8.72+)--```bash-adom-desktop hd_api '{"method":"GET","path":"/health"}'-adom-desktop hd_api '{"method":"POST","path":"/setup/run-step","body":{"id":"welcome"}}'-adom-desktop hd_api '{"method":"GET","path":"/wsl/status"}'-```--Generic HTTP pass-through to any Hydrogen endpoint. Raw JSON in `body` — no curl-through-the-relay quote mangling. Returns `{success, status, body, port, method, path}` mirroring Hydrogen's response. If Hydrogen isn't running, returns `{ok:false, error:"Hydrogen not reachable on :47084", _hint:"..."}` instead of timing out.--**Public denylist** — these paths are refused with `{ok:false, error:"not_allowed_in_public", denylist_match:"..."}` on public AD builds (use the internal `hd-dev` cargo feature to bypass):--```-/wsl/unregister, /setup/virgin-reset, /container/destroy,-/container/reset, /workspace/reset, /workspace/destroy-```--#### Inspect + drive a running Hydrogen (v1.8.15+, post-v1.8.72 rework)--| Verb | What | Status |-|---|---|---|-| `hd_status` | Composes AD-side facts (running, repo SHA) + Hydrogen's `/workspace/health` + `/buildinfo` relayed | ✓ shipped |-| `hd_api` | Generic HTTP pass-through (above) | ✓ v1.8.72+ |-| `hd_log '{"tail":30}'` | Tail `%APPDATA%\hydrogen-desktop\hydrogen-desktop.log` (reads disk — works EVEN when Hydrogen is down) | ✓ shipped |-| `hd_open_url '{"url":"...","browser":"chrome","profileDir":"Default"}'` | POST `/open-in-profile` — open URL in a specific browser profile | ✓ shipped |-| `hd_browser_profiles` | GET `/browser-profiles` — enumerate browsers + profiles | ✓ shipped |-| `hd_screenshot` / `hd_shot` | Find Hydrogen window, capture lossless PNG via PrintWindow; `hd_shot` adds region-scoped variant | ✓ shipped |-| `hd_eval '{"js":"..."}'` | POST `/eval` — run JS in Hydrogen's main webview | ⚠ **Moved to `hd-dev` in v1.8.72.** Public returns `verb_moved_to_hd_dev`. Use `hd_api '{"method":"POST","path":"/eval","body":{"js":"..."}}'` |-| `hd_iframe_eval '{"js":"...","contextIndex":0}'` | POST `/iframe-eval` — JS in code-server iframe via CDP | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |-| `hd_reload_vscode` | POST `/reload-vscode` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |-| `hd_container_exec '{"command":"..."}'` | POST `/container-exec` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |-| `hd_launch` / `hd_stop` / `hd_restart` | Hydrogen process lifecycle | ✓ shipped |-| `hd_ship` | Atomic build+relaunch+verify-SHA (see below) | ✓ shipped |-| `hd_build*` (v1.8.16+) | Build pipeline orchestration | ⚠ Moved to `hd-dev` in v1.8.72 (Hydrogen developers only) |--#### Atomic ship — `hd_ship` (v1.8.49+) — START HERE for post-push rebuilds--**One verb, one call, one structured pass/fail.** Replaces the 4-step chain `hd_stop` → `hd_build_rust` → poll `hd_build_status` → `hd_launch` → `curl :47084/buildinfo`. Eliminates the "is the new code actually running?" guesswork after every push.--Sequence inside one call:-1. taskkill Hydrogen + poll until zero instances (frees the exe lock)-2. `git fetch && git reset --hard origin/main && rev-parse HEAD` — asserts `HEAD == expectSha` (fails fast if GitHub hasn't replicated the push yet)-3. if `clean:true`, `cargo clean -p hydrogen-desktop` (defeats stale-relink)-4. `cargo build` and parse stderr for `Compiling hydrogen-desktop` to know whether anything actually compiled-5. launch + poll `http://127.0.0.1:47084/buildinfo` until it responds + assert `build_sha == expectSha`--```bash-# End-to-end after a push:-git push && SHA=$(git rev-parse HEAD) && \-  adom-desktop hd_ship "{\"expectSha\":\"$SHA\"}"-```--Args:-- `expectSha` — **REQUIRED** string. Full or prefix SHA of the commit you want running. Bidirectional-prefix match (`abc123` matches the full SHA starting with `abc123` and vice versa).-- `clean` — optional bool, default `false`. `true` forces `cargo clean -p hydrogen-desktop` before build. Use when a prior `hd_ship` succeeded but `runningSha != expectSha` (the stale-relink trap — cargo said `Finished` but reused the cached binary).--Returns on success: `{ok:true, builtSha (== expectSha), runningSha (== expectSha), compiled:bool, elapsedSecs, _hint}`. `compiled:false` on success means cargo found nothing to recompile (cached relink or HEAD didn't actually change — usually fine but surface to caller in case the push was a no-op).--Returns on failure: `{ok:false, failedAt:'args'|'kill'|'git'|'clean'|'build'|'launch'|'verify', error, builtSha (set after git step), runningSha (set if launch worked but verify saw the wrong SHA), compiled (set after build step), elapsedSecs, _hint}`. The partials let you reason about WHERE it broke:--| `failedAt` | What it means |-|---|---|-| `args` | `expectSha` missing / malformed |-| `kill` | Couldn't taskkill Hydrogen (rare — usually permissions) |-| `git` | `git fetch / reset` failed, OR `HEAD != expectSha` after reset (push hasn't replicated to origin/main yet — retry in a few seconds) |-| `clean` | `cargo clean` failed (rare) |-| `build` | `cargo build` failed — read `_hint` for next step (usually `hd_build_log` to see the compile error) |-| `launch` | Cargo finished but `hydrogen-desktop.exe` wouldn't start — `binary_missing` or process spawn error |-| `verify` | Hydrogen launched but `/buildinfo` either never responded or reported a different SHA than `expectSha` (the stale-relink trap — retry with `clean:true`) |--Long-running: cold cargo build can take 2-3 min. CLI WS timeout is 12 min — call as a single sync invocation and wait. Don't poll inside `hd_ship`; it does its own internal polling.--#### Lower-level build + lifecycle suite (v1.8.16+, enriched in v1.8.52+)--Use these directly only when you need finer control than `hd_ship` provides (e.g. frontend-only build, watching log stream live, manual launch sequencing). For the common "I pushed code, get it running" path use `hd_ship`.--The relay's `shell_execute` has a 30s timeout; Hydrogen's `pnpm build` + `cargo build` exceed that, so the build is split into async-spawn verbs + sync poll/tail verbs:--```bash-# Frontend-only rebuild, watch progress, relaunch (no hd_ship since hd_ship is Rust-build-only)-adom-desktop hd_stop-adom-desktop hd_build_frontend '{"show":false}'      # returns {ok, pid, logPath} immediately-OFFSET=0-while true; do-  RESP=$(adom-desktop hd_build_tail "{\"offset\":$OFFSET}")-  echo "$RESP" | jq -r '.lines[]'-  DONE=$(echo "$RESP" | jq -r '.done')-  OFFSET=$(echo "$RESP" | jq -r '.newOffset')-  [[ "$DONE" == "true" ]] && break-  sleep 3-done-adom-desktop hd_build_status      # confirm succeeded:true && relinkOnly:false-adom-desktop hd_launch            # start the new debug binary-```--##### `hd_build` / `hd_build_frontend` / `hd_build_rust` — async-spawn build verbs--| Verb | What |-|---|---|-| `hd_build '{"show":false}'` | Full async build: `git pull` + `pnpm build` + `cargo build`. Returns instantly with `{pid, logPath, flavor:'full'}`. Final log line is `BUILD_OK` or `BUILD_FAILED: <step>`. |-| `hd_build_frontend` | Just `pnpm build` (root). Returns `{pid, logPath, flavor:'frontend'}`. |-| `hd_build_rust` | Just `cargo build` (src-tauri/). **v1.8.52+:** dispatch response also carries `builtSha` + `exeMtimeBefore` + `exeSizeBefore` (captured at spawn time into `%TEMP%\hd-build-meta.json`) so the subsequent `hd_build_status` / `hd_launch` don't need a separate `git rev-parse` + `stat` call. Returns `{ok, pid, logPath, flavor:'rust', show, lingerSecs, builtSha, exeMtimeBefore, exeSizeBefore, _hint}`. |--##### `hd_build_status` — sync state probe (v1.8.52+ enriched)--Returns:-```-{-  ok, building, succeeded, failed,-  lastLines:[string],                  # last ~40 lines of stderr-  logPath, pid, pidAlive,-  failureReason,                       # 'compile_error' | 'exe_locked' | 'orphaned_no_sentinel'-  compiledCrates:[string],             # names parsed from 'Compiling X v...' lines-  relinkOnly:bool,                     # true iff succeeded && compiledCrates is empty-  exeChanged:bool,                     # mtime OR size after differs from before-  exeMtimeBefore:int, exeMtimeAfter:int,   # unix seconds-  exeSizeBefore:int,  exeSizeAfter:int,    # bytes-  exeLocked:bool,                      # true when build failed because hydrogen-desktop.exe was held-  holderPids:[int],                    # PIDs holding the exe — taskkill these-  builtSha:string|null,                # SHA hd_build_rust/hd_build was dispatched at-  elapsedSecs:int,-  flavor:string,                       # 'rust' | 'frontend' | 'full'-  _hint-}-```--**`relinkOnly:true` on success is the silent-fail trap to look for** — your push didn't actually rebuild because cargo found nothing to recompile. Usually means your source changes weren't pulled, or you touched a file the workspace doesn't track. Use `hd_ship` with `clean:true` to force a fresh build.--**`exeLocked:true` on failure** + `holderPids` tells you exactly which PIDs to taskkill before retry; or just use `hd_ship` which kills Hydrogen before building.--##### `hd_launch` — start the debug binary (v1.8.52+ enriched)--Args: `killExisting` — optional bool, default `false`. When `false` and Hydrogen is already running, refuses with `reason:'already_running'` so you don't accidentally attach to a stale process. When `true`, taskkill the existing instance with a 600 ms grace period before launching fresh.--Returns on launch: `{ok:true, launched:true, wasAlreadyRunning:bool, killedFirst:bool, pid:int (Hydrogen's actual PID, not the cmd-wrapper), exePath, builtSha:string|null, _hint}`.--Returns on refusal: `{ok:false, launched:false, wasAlreadyRunning:bool, pid:int|null, killedFirst:false, reason, _hint}`. `reason` ∈:-- `build_in_progress` — wait for `hd_build_status.building:false`-- `build_failed` — fix and retry `hd_build` (or use `hd_ship`)-- `already_running` — pass `killExisting:true` to force-kill+relaunch, or use `hd_restart`-- `binary_missing` — `target/debug/hydrogen-desktop.exe` not on disk; run `hd_build_rust` (or `hd_ship`) first--**`launched:true, wasAlreadyRunning:false, killedFirst:false` is the proof you need that truly fresh code is running.** Any other combo means you should compare `builtSha` to Hydrogen's actual `/buildinfo.build_sha`.--##### `hd_stop` / `hd_restart` / `hd_build_log` / `hd_build_tail`--| Verb | What |-|---|---|-| `hd_stop` | `taskkill /F /IM hydrogen-desktop.exe`. `{wasRunning}` distinguishes killed vs no-op. |-| `hd_restart` | Stop + launch in one call. Same guards as `hd_launch` (skip `already_running`). |-| `hd_build_log` | Full log dump from `%TEMP%\hd-build.log`. |-| `hd_build_tail '{"offset":N}'` | Incremental stream — `{lines, newOffset, totalBytes, done, succeeded, _hint}`. Pass `newOffset` back for the next chunk. |--The `show: true` arg on any build verb opens a visible PowerShell console window so the user can watch the build scroll. Default `show: false` runs hidden.--**Auto-close (v1.8.23+).** Visible build windows auto-close 30 seconds after the final `BUILD_OK` / `BUILD_FAILED` line — long enough for the user to read the last error, short enough that build-after-build sessions don't accumulate orphan windows. The window prints a clear green/red "auto-closes in 30 seconds" banner before sleeping. Override with `lingerSecs`:-- `'{"show":true, "lingerSecs":60}'` — give yourself 60s instead of 30-- `'{"show":true, "lingerSecs":0}'` — close immediately, no grace period-- `'{"show":true, "lingerSecs":3600}'` — keep open for an hour (cap)-The response includes the applied `lingerSecs` so you can confirm what landed. Hidden builds (`show:false`) ignore `lingerSecs` entirely — no window to linger.--**Don't call `hd_launch` until `hd_build_status` shows `succeeded: true`** — the guard refuses with `reason: "build_failed"` if you do, and the `_hint` tells the AI exactly what to do (`hd_build_log` to see errors, fix, rebuild). Same for `hd_restart`.--#### Named-region screenshots — `hd_shot` (v1.8.54+)--**One verb replaces the `desktop_list_windows | grep 'Hydrogen' → desktop_screenshot_window <hwnd>` chain** (3 calls + fragile HWND parsing). Captures a named region of Hydrogen, produces BOTH a full-res PNG and a Claude-safe-resized copy, and returns rich metadata so the AI never has to chain screenshot → pull → convert → crop → resize → Read manually.--Each shot is **self-identifying**: it includes the running build's SHA + local build time read from Hydrogen's `/buildinfo` so you can confirm you're looking at the build you expect before trusting what you see (no more "this screenshot was from the previous binary, not my fix" confusion).--Regions are deterministic — they resolve via Hydrogen window bounds + CDP element rects queried through Hydrogen's `/eval` endpoint. No AI-supplied pixel coords.--Args:-- `region` — **REQUIRED**. One of:-  - `full` — whole Hydrogen window-  - `titlebar` — top ~40 px strip (Hydrogen's build SHA / timestamp readout lives here)-  - `vscode` — VS Code iframe pane via CDP element rect-  - `setup-panel` — Setup Steps panel (`.setup-panel` / `.setup-panel-output`)-  - `wiki` — wiki / browser tab pane-  - `claude` — Claude Code panel-- `resizeMax` — optional int, default `1400`. Longest side of the safe-resized copy. Use ≈800 to fit more shots in one Claude turn; 1568 for max fidelity.--Returns:-```-{-  ok, region,-  fullPath,                    # lossless raw PNG — for shotlog/archival, NOT Read-  safePath,                    # ≤resizeMax PNG — pull_file this, Read this-  fullSize:[w,h], safeSize:[w,h],-  clipRect:[x,y,w,h],          # captured-image pixels (NOT screen pixels)-  capturedAtLocal,-  runningBuildSha,             # from Hydrogen's /buildinfo — null if Hydrogen doesn't expose it yet-  runningBuildLocal,-  hdWindowFound:bool, regionFound:bool,-  windowOuter:[w,h], capturedSize:[w,h],-  _hints: {-    pull,                      # exact pull_file command for safePath-    fullForArchive,            # note that fullPath is for archive, not Read-    staleWindow,               # set when runningBuildSha looks wrong-    regionMiss                 # set when regionFound:false-  }-}-```--Recipe — verify-which-build-is-running + confirm-config-change-took-effect + full dump:--```bash-# After hd_ship, confirm the titlebar shows the SHA you expect:-adom-desktop hd_shot '{"region":"titlebar"}'-# → runningBuildSha lets you assert this before trusting any other shot--# After flipping a Setup Steps toggle, screenshot the panel to confirm:-adom-desktop hd_shot '{"region":"setup-panel","resizeMax":1568}'--# Full-window dump for general debugging:-adom-desktop hd_shot '{"region":"full"}'-```--Edge cases:-- **Unknown region** — refusal: `{ok:false, error, knownRegions:[...]}`. No fallback — fix the call.-- **`regionFound:false`** — the element rect wasn't in Hydrogen's DOM. The panel/iframe may not be mounted yet, OR Hydrogen's markup changed and `region_selectors()` in `hd_bridge.rs` is out of date. The shot is **still produced** (falls back to the full window) so you have something to look at; check `_hints.regionMiss` for the reason.-- **`runningBuildSha:null`** — Hydrogen's `/buildinfo` isn't exposed yet on the running version. The shot is still produced; you just can't self-verify which build it represents.--**Always pull_file the `safePath` and Read THAT** — it's already sized for Claude's image analysis. The `fullPath` is for shotlog / disk archival, NOT direct Read (may exceed Claude's per-image budget). The `_hints.pull` field gives you the exact `pull_file` command to copy-paste.--**Old workaround to drop:** Do NOT use `desktop_list_windows | filter 'Hydrogen' | desktop_screenshot_window <hwnd>` anymore for Hydrogen — `hd_shot` replaces that entire pattern. Generic `desktop_screenshot_window` still applies to non-HD windows (other apps, dialogs, etc.).--### TCP Port-forward Tunnels — `tunnel open|close|list|check|rdp` + `display` (v1.8.85+, reworked v1.8.147)--Reach a TCP service addressable only from the **cloud side** through the **laptop side**: AD binds a `127.0.0.1:<port>` listener on the laptop and pumps bytes over the existing AD↔relay WebSocket; the relay dials the upstream `to`. First use case: RDP into a Windows VM behind an NSG only the cloud container reaches — mstsc on the laptop connects to a local port and is talking through the cloud. Same path works for SSH, VNC, HTTP, postgres — any TCP.--**`tunnel` is a grouped verb** (`rdp` / `tunnel_close` / `tunnel_list` remain back-compat aliases):--```bash-# Open a VERIFIED tunnel (both legs probed before ok). Auto-picks a free local port.-adom-desktop --target <name> tunnel open '{"to":"127.0.0.1:13389"}'--# RDP: open + verify + launch mstsc, sized to the laptop's real screen.-adom-desktop --target <name> tunnel rdp '{"to":"127.0.0.1:13389","size":"90%"}'-#   size: "90%" (default) | "100%" | "fullscreen" | "maximized"-#   OR monitor:<index> (0=primary)  OR explicit width+height--adom-desktop --target <name> tunnel list           # live tunnels + leak detection-adom-desktop --target <name> tunnel check '{"tunnelId":<id>}'   # per-leg health-adom-desktop --target <name> tunnel close '{"tunnelId":<id>}'   # or {"all":true}--adom-desktop --target <name> display               # monitor geometry (for sizing)-```--**`--target` is mandatory when >1 AD is connected.** v1.8.147 fixed a false-success bug: the bind, the upstream probe, and the mstsc launch now ALL resolve to the SAME machine. (Before, the bind could land on one AD and mstsc launch on another → "port bound" on the VM, "nothing listening" on the laptop, a bogus `ok:true`.) No target + multiple ADs → `ambiguous_target` listing the names; `--target all` is rejected (a tunnel binds one machine). `adom-desktop targets` lists connected ADs.--**Active health verification — every `open`/`rdp`/`check` PROVES the path before reporting `ok`:**-- **local-bind** — AD self-probes `127.0.0.1:<boundPort>` right after binding. If it can't connect, AD tears the tunnel down and returns `ok:false` (this alone kills the false-success class).-- **upstream-refused** — the relay dials `to` (retry/backoff ~4×/2.5s). If unreachable, the tunnel is torn down (no leaked row) and `ok:false`.-- A failure names the leg: `failedLeg: "relay-to-ad" | "local-bind" | "upstream-refused"`. Health fields surfaced: `listening`, `relayConnected`, `upstreamReachable`, `connCount`, `mstscAlive`.--**Progressive hints.** Every response carries `_hint` (what's true now) + `_next` (the exact next verb with real values, copy-pasteable). e.g. a successful open's `_next` hands you the `tunnel rdp` / `tunnel check` / `tunnel close` commands with the real `to`/`target`/`tunnelId` filled in. A missing `to` returns a structured error with an example. Walk the workflow from the responses alone.--**Display-aware RDP sizing.** `tunnel rdp` resolves `size`/`monitor`/`width`/`height` against the laptop's real screen (queried via `display` — `EnumDisplayMonitors` + per-monitor DPI), writes an `.rdp` file with the geometry, and launches mstsc. e.g. `size:"90%"` on a 2560×1600 primary → a 2304×1440 window. Default 90% of primary. You never hardcode a resolution — call `display` if you want the layout first.--**`tunnel list` reconciles + reaps leaks.** It cross-checks the relay registry against every connected AD's own registry and reports `leaks:[...]` for rows only one side knows about; `tunnel close '{"all":true}'` reaps them. The relay stores the actually-bound `localPort` (so `tunnel close '{"local":<port>}'` works).--Architectural notes for Claude:-- Bytes-only — no payload parsing, so any TCP service works. TLS/cert-pinning still pins to whatever the cloud-side dial resolves (for RDP the upstream cert CN must match what mstsc expects).-- Each fresh laptop-side TCP connection gets its own connId multiplexed over one tunnelId; concurrent mstsc reconnects / parallel sessions work without re-opening. Backpressure bounded (256-slot mpsc/conn, 64 KiB buf).-- Tunnels don't survive AD/relay restarts. If mstsc disconnects, `tunnel check` (names the dead leg), then re-open.-- The classic RDP collision (Windows' own RDP owns `0.0.0.0:3389`) is dodged by `rdp`'s default `local:13389` + `SO_EXCLUSIVEADDRUSE` + ephemeral fallback (`fellBack:true` in the response; read `localPort` for the real port).--**Old workarounds to drop**: (1) NSG WAN-IP whitelisting for roaming-laptop RDP — the tunnel rides existing auth, reachable regardless of WAN IP. (2) Trusting a bare `ok:true` from the old `rdp` — it could be a dead port; now `ok` means both legs were probed. (3) Hardcoding mstsc resolution — pass `size`/`monitor`; AD resolves against the real screen.--### Shell — `shell_execute` (escape hatch only)--- `shell_execute` -- Run a shell command on the desktop. The CLI handles approval polling internally — it returns the final `{success, output, error, exitCode}` once the user clicks Allow on the desktop dialog. **You do not need to poll, retry, or call `get_deferred_result` yourself.** The CLI emits `HINT:` lines to stderr while waiting (every 15s) so an AI Monitor sees progress.-- `shell_kill_all` -- Kill all running shell commands and deny pending approvals.--**v1.7.16 fix.** Earlier versions (v1.7.15 specifically) had a regression where the CLI's approval-wait loop re-sent the command every 1s instead of polling for the deferred result; that produced an infinite "Another shell command is already waiting for approval" loop because each retry created a fresh approval the user could never out-click. v1.7.16 fixes it. If you see that error string with a `>= 1.7.16` CLI, the bug is back — file an issue. If you see it with `< 1.7.16`, upgrade the CLI: `adom-wiki asset get apps/adom-desktop docker_binary -o /usr/local/bin/adom-desktop`.--**Deprecated for download polling.** As of v1.5.0, use `desktop_watch_files` / `desktop_pull_glob` for waiting on download arrivals — those don't require user approval, don't go through PowerShell quoting, and are O(directory entries) rather than spawning a process every second. `shell_execute` itself stays as an escape hatch for genuinely shell-only operations (multi-step ad-hoc admin tasks, chained pipelines, etc.).--Use `where python` / `C:\Python3xx\python.exe` for Python -- avoid `python` which may not be on PATH.--#### `run_script` + `wsl_exec` — base64-payload script runners (v1.8.46+)--When you want to send a multi-line script to the desktop without the shell-quoting hell that `shell_execute` requires, use these two verbs. The script crosses the wire as base64 so NO escaping survives to be mangled by cmd.exe, PowerShell parser quirks, MSYS path conversion, or wsl.exe's `--` / nested-quote / `$_` rules. The caller never hand-escapes anything.--Both verbs require shell auto-approve to be active — same trust level as `shell_execute`. They refuse cleanly with an actionable error if not approved; no per-call modal (these are programmatic verbs).--**Reading the result — `status` + `durationMs` (v1.8.162+).** EVERY CLI result now carries a single authoritative **`status`** field so you never have to guess whether an empty `stdout` was a real empty-output success or a timeout:--| `status` | meaning | what to do |-|---|---|---|-| `ok` | completed (stdout may legitimately be empty) | use `exitCode`/`stdout`/`stderr` |-| `timeout` | did NOT finish in time (NOT an empty success) | raise `timeoutSeconds`, or use a detached job (below) |-| `busy` | desktop couldn't service it (WSL wedged / no client connected) | check `adom-desktop status`; `wsl_recover` for WSL; retry |-| `error` | the command/verb failed | read `error` / `stderr` / `_hint` |--`durationMs` is the wall-clock of the call. `exitCode`/`stdout`/`stderr` pass through unchanged; on `timeout` the verb's `partialStdout`/`partialStderr` hold what was written before the kill. A `timeout`/`busy` result also carries a recovery `_hint`. **For multi-minute work don't fight the timeout** — use a detached job that returns instantly + polls: `wsl_exec_async` / `wsl_import` / `wsl_export` → poll `wsl_job_status` (or `launch_process` for a general long-running command). This is the cure for the "1.4 GB import EOFs the sync call" problem.--**`run_script`** — base64 script + interpreter (cmd / powershell / bash):--```bash-SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b'''-echo \"multi-line script\"-VAR=\"value with spaces and \$dollars\"-echo \"VAR=\$VAR\"-exit 0-''').decode())")-adom-desktop run_script "{\"interpreter\":\"bash\",\"scriptB64\":\"$SCRIPT_B64\"}"-# → {success, ok, stdout, stderr, exitCode}-```--Args: `interpreter` (`cmd` | `powershell` | `bash`), `scriptB64` (required), `timeoutSeconds` (default 60).--PowerShell prefers `pwsh` (PS 7+) if on PATH, falls back to `powershell.exe` (5.1).--- **`bash` runs via WSL2, NOT Git Bash.** The Windows host's first `bash` on PATH is `wsl.exe -d <default-distro> bash` (verified at `C:\Windows\System32\bash.exe`). The script is piped to bash's stdin (no temp file, no path-form mismatch — no MSYS `/S` → `S:/` corruption, no nested-quote loss). The decoded script bytes are written to bash's stdin and stdin is closed; bash reads + executes without ever opening a file. For explicit distro / user control, use `wsl_exec`.--**`wsl_exec`** — base64 script + distro + user, executed via the canonical `wsl -d <distro> -u <user> -- bash -lc <decoded>` argv array:--```bash-SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b'''-cd /mnt/c/Github/adom-desktop-git status --short | head -10-exit 0-''').decode())")-adom-desktop wsl_exec "{\"distro\":\"Ubuntu\",\"user\":\"root\",\"scriptB64\":\"$SCRIPT_B64\"}"-# → {success, ok, stdout, stderr, exitCode}-```--Args: `distro` (e.g. `Ubuntu`, `Adom-Workspace`), `user` (e.g. `adom`, `root`), `scriptB64`, `timeoutSeconds` (default 60).--Why argv (not shell string): Rust's `std::process::Command` on Windows applies proper `CommandLineToArgvW` quoting per argv element. `wsl.exe` sees `bash -lc <decoded>` as three distinct argv items even when the decoded script contains quotes, `$`, backslashes, newlines, pipes — anything. `bash`'s `-c` then sees the entire script as ONE string with no outer-shell interpretation.--Output decoding: `wsl.exe` defaults to UTF-16LE when invoked from the Windows console subsystem. Both verbs auto-detect (BOM check + odd-position-NULL heuristic) and decode to UTF-8. Output is never wrapped in another shell that might re-interpret it.--Pitfalls these verbs eliminate:-- `cmd.exe`'s `&`, `|`, `^`, `%var%` interpretation-- PowerShell's parser quirks around nested quotes + `$` interpolation-- Git Bash MSYS converting `/S` → `S:/` etc.-- WSL's `--` parser limits + nested-quote loss-- UTF-16LE corruption on wsl.exe output--#### WSL wedge defense — `wsl_wedged` + `wsl_recover` (v1.8.157+)--Every `wsl_exec` / `run_script bash` spawns a `wsl.exe` client. If WSL's service (`LxssManager`) wedges — common after a laptop runs for a day, or under heavy parallel cloud use — those clients block in a wait that `taskkill /F` can't always finish, and retries keep spawning more. Left unchecked they pile up into the hundreds and wedge the whole machine. Three defenses:--1. **Refuse-cap.** When there are already too many `wsl.exe` (`WSL_REFUSE_AT=80`), AD refuses NEW wsl spawns with `errorCode:"wsl_wedged"` instead of adding another doomed client. If you get `wsl_wedged`, don't retry the same command — run `wsl_recover` first.-2. **`wsl_recover`** — reap orphaned/hung `wsl.exe`, **DRY-RUN by default** (nothing is killed unless you pass `confirm:true`):-   ```bash-   adom-desktop wsl_recover                       # dry-run: lists `candidates` (would-reap) + `spared`-   adom-desktop wsl_recover '{"confirm":true}'    # reap the candidates-   adom-desktop wsl_recover '{"confirm":true,"sparePids":[1234]}'   # also protect pid 1234-   ```-   It classifies each `wsl.exe`: childless + (0-CPU & old | runaway-CPU) → **candidate**; everything else → **spared**. It **spares any `wsl.exe` with child processes** (that's almost certainly the live distro, e.g. the Hydrogen `Adom-Workspace`), honors `sparePids`, and never touches `wslservice`/`wslhost`/`vmmem`. Review each candidate's `cmd` in the dry-run to confirm the live workspace isn't among them, then confirm. **Never** `wsl --shutdown` (it kills the live workspace too). Reaping childless orphans often cascades — their now-parentless children exit on their own (observed 114 → 18 from reaping 54).-3. **status wsl block** — `{wslProcessCount, wedged, _hint}` lets you see a pile-up *before* it blocks work. Path differs by transport: **`status.desktop.wsl`** via the CLI/relay (`adom-desktop status` — nested like `desktop.apps`), or top-level **`status.wsl`** on the local direct API (`GET /status`). `wedged:true` at/above `WSL_WARN_AT=50`.--#### Long-running in-distro work + distro lifecycle — golden-image bakes (v1.8.161+)--`wsl_exec` is **synchronous** — it waits for the command and is bounded by the relay's HTTP timeout (~60-120s), so a multi-minute bake (apt, a big download) EOFs. Backgrounding inside the distro (`nohup … &`) doesn't help either: the `wsl.exe` client returns, WSL sees no active session, shuts the distro down, and kills the bg process (wiping `/tmp`). For anything long, use the **async held-session** verbs — AD holds one `wsl.exe` in the foreground for the command's whole lifetime, so the distro stays up:--- **`wsl_exec_async`** → returns a `jobId` + `outputPath` (a live UTF-8 log) immediately; the command runs detached for as long as it takes.-- **`wsl_job_status {jobId}`** → `{running, exitCode, elapsedMs, outputTail, outputPath}`. Poll until `running:false`; `pull_file` the `outputPath` for the full log. (`wsl_job_list`, `wsl_job_kill {jobId}` round it out.)--```bash-# golden-image bake: import → run (async) → poll → export → unregister, all via AD-adom-desktop wsl_import '{"distro":"bake","installDir":"C:/Users/<you>/.adom/wsl/bake","tarball":"C:/.../base.tar"}'-#   → {jobId}; poll wsl_job_status until done (exitCode 0)-B64=$(printf 'set -euo pipefail\napt-get update && apt-get install -y build-essential\n' | base64 -w0)-adom-desktop wsl_exec_async "{\"distro\":\"bake\",\"user\":\"root\",\"scriptB64\":\"$B64\"}"-adom-desktop wsl_job_status '{"jobId":"wsljob-xxxxxxxx"}'   # repeat until running:false; outputTail = progress-adom-desktop wsl_export '{"distro":"bake","tarball":"C:/.../golden.tar"}'   # poll, then pull_file the tarball-adom-desktop wsl_unregister '{"distro":"bake"}'            # cleanup (REFUSES Adom-Workspace)-adom-desktop wsl_list                                      # {distros:[{name,state,version,default}]}-```--**systemd user session — `viaRoot`.** On a `systemd=true` distro, `-u <user>` can fail "Failed to start the systemd user session for '<user>'". Pass `"viaRoot": true` to `wsl_exec_async` — AD runs `-u root -- runuser -u <user> -- bash -l`, dropping to the user WITHOUT depending on `[email protected]`.--`wsl_import` / `wsl_export` / `wsl_unregister` are async (jobId + poll) too, so big imports/exports never EOF. All are **strictly scoped to the named distro** — there is no global `wsl --shutdown` path (that would kill the live Hydrogen workspace), and `wsl_unregister` refuses `Adom-Workspace`. Job ids live in AD's memory only (not persisted across an AD restart; the log file survives). The wedge defense above still applies — an over-cap spawn returns `wsl_wedged`.--## Detecting App Installation--After connecting, always run `adom-desktop status` to check what's installed. The `desktop.apps` object tells you exactly what the user has:--```bash-adom-desktop status-# Look at the desktop.apps field in the response-```--### Handling "not installed" errors--When a command returns `errorCode: "node_not_found"`, `errorCode: "kicad_not_installed"`, or `errorCode: "fusion_not_installed"`, guide the user through installation:--**Picking the right native browser + profile (v1.7.1+):**--When you need to open a URL in the user's NATIVE browser AND it matters which account is signed in (work Google Workspace vs personal Gmail vs media YouTube channel etc.), `desktop_open_url` alone isn't enough — you need to target a specific profile. The flow:--```bash-# 1. Discover what's installed + which profiles are configured.-adom-desktop desktop_list_browsers '{}'-# → {-#     "default": "chrome",-#     "browsers": [-#       { "name": "chrome", "displayName": "Google Chrome", "version": "146...",-#         "exePath": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",-#         "profiles": [-#           { "id": "Default",   "name": "Alice (work)", "gaia": "[email protected]",     "isDefault": true },-#           { "id": "Profile 1", "name": "Personal",     "gaia": "[email protected]" },-#           { "id": "Profile 2", "name": "Adom Media",   "gaia": "[email protected]" }-#         ] },-#       { "name": "edge", "profiles": [...] },-#       { "name": "firefox", "profiles": [{"id":"default-release","name":"default","isDefault":true}] }-#     ]-#   }--# 2. Match the URL's context to the right profile, then open it there.-# E.g. opening a Google Doc shared by your work team → use the work profile:-adom-desktop desktop_open_url '{-  "url":"https://docs.google.com/document/d/...",-  "browser":"chrome",-  "profile":"Default"-}'-# Or YouTube channel management for the media account:-adom-desktop desktop_open_url '{-  "url":"https://studio.youtube.com",-  "browser":"chrome",-  "profile":"Profile 2"-}'-```--Profile-picking heuristics for Docker Claude:-- **Workspace / @adom.inc URLs** → match `profile.gaia.endsWith("@adom.inc")` and not the `media@` one → typically `Default`.-- **Personal Gmail / Drive / etc.** → match `profile.gaia === "[email protected]"` (or whatever the user's personal address resolves to).-- **YouTube Studio / channel-specific work** → match the media account profile.-- **Random scratch / experiments** → use the user's Edge profile, where they keep miscellaneous accounts (per the user's stated preference).-- **No clear match** → fall back to `browser:"default"` (no profile flag) and let the user pick.--`profile` is optional. Omit it to open in whichever profile the browser was last using (legacy v1.6.x behavior). Profile flag is silently ignored when `browser:"default"` (no clean way to inject through the OS URL handler — name the browser explicitly to use it).--### Decrypting the user's saved browser credentials — `desktop_decrypt_browser_credentials` (v1.9.220+)--So pup can run an **"adom-you" Chrome profile signed in AS the user** — surfing the web on the user's behalf — it needs the user's real saved logins/cookies to seed that profile. Chrome 127+ encrypts these with **App-Bound Encryption** (the master key is DPAPI-wrapped under the SYSTEM account), so only an elevated helper can decrypt them. This verb is that path.--```bash-adom-desktop desktop_decrypt_browser_credentials '{-  "browsers": ["chrome", "edge"],   # optional; default = all installed Chromium browsers-  "include": ["passwords", "cookies"] # optional; default = both-}'-# → { "ok": true,-#     "passwords": [ { "origin", "username", "password", "sourceBrowser", "sourceProfile", "sourceAccount", "lastUsed" }, ... ],-#     "cookies":   [ { "host", "name", "value", "path", "sourceBrowser", "sourceProfile", ... }, ... ],-#     "stats": { "profilesRead", "passwordsOk", "passwordsFailed", "cookiesOk", "cookiesFailed" } }-```--**How to call it (this is not a normal verb — read this):**--- **LOCAL / bridge callers only.** A relay (cloud-AI) call is refused unconditionally with `errorCode:"relay_forbidden"` and **pops no UAC** — a remote container must never be able to trigger a password-decrypt. pup (a loopback bridge) and the local CLI reach it.-- **Two human gates, in order:** (1) AD shows an in-window consent dialog + toast naming the requesting caller ("pup is requesting your saved browser passwords/cookies…"); decline → `errorCode:"consent_declined"`. (2) On continue, an elevated signed helper runs behind a **UAC prompt** ("Adom Industries, Inc."); cancel → `errorCode:"elevation_declined"`. The UAC elevation IS the user's permission for the decrypt — by design (John, wiki #457).-- **Plaintext is returned ONCE over the loopback API and is never written to disk or logged.** Don't echo the values into any other verb's args, any activity-log emit, or any file. Consume the rows, seed the profile/vault, drop them.-- **errorCodes:** `relay_forbidden` (you called it over the relay — you can't), `consent_declined`, `elevation_declined`, `helper_missing` (the signed helper isn't bundled — the install is broken). Per-item decrypt failures are counted in `stats.*Failed`, never fail the whole run (a managed-profile policy can block cookies; newest-Chrome `v20` blobs are best-effort if Google rotated the elevation-service key).-- **Pair it with `desktop_list_browsers`** (above) to show the user which browsers/profiles/accounts will be read before you ask.--**Node.js not installed (the puppeteer bridge can't auto-spawn):**--v1.7.0+ — any `browser_*` command returning `errorCode:"node_not_found"` means Node isn't on the desktop machine. Trigger the unattended winget install:--```bash-adom-desktop desktop_install_node '{}'-# → runs `winget install --id OpenJS.NodeJS.LTS --silent ...` (Windows only).-# → ~1-2 min; downloads + installs the Node.js LTS line (well-tested with puppeteer).-# → returns {ok:true, exitCode:0, _hint:"Retry the original browser_* command"} on success.-# → on failure: {ok:false, errorCode:"winget_unavailable" | "winget_install_failed", _hint}.-```--After install, **just retry the original `browser_*` command** — the bridge picks up the new node.exe via the registry App Paths lookup (which winget writes), no Adom Desktop restart needed. v1.7.0+ also pre-fetches Node + Chrome for Testing on first launch after install (background task on AUTOSTART_VERSION 11), so most users will never see `node_not_found` to begin with.--If `desktop_install_node` returns `errorCode:"winget_unavailable"` (older Windows or managed corporate device), fall back to manual:--> Node.js LTS is required for browser automation. winget isn't available on this machine — download Node.js LTS from https://nodejs.org/ and run the installer. Let me know when it's done and I'll retry.--**v1.7.0+ Node detection.** The bridge looks for `node.exe` in:-- System PATH (via `where node` on Windows, `which node` elsewhere)-- Windows registry App Paths (catches winget, MSIX, custom-dir installs)-- Standard install dirs: `C:\Program Files\nodejs\`, `C:\Program Files (x86)\nodejs\`, `%LOCALAPPDATA%\Programs\nodejs\`-- Per-user version managers: nvm-windows (`%APPDATA%\nvm\`), volta (`%LOCALAPPDATA%\Volta\bin\`), fnm (`%FNM_DIR%`)-- macOS Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`)--If detection still misses an install on a user's machine, get the actual `node.exe` path from the user — that's a real bug to file.--**KiCad not installed:**--**KiCad not installed:**--v1.6.0+ — try the unattended winget install FIRST before asking the user to download manually:--```bash-adom-desktop desktop_install_kicad '{}'-# → runs `winget install --id KiCad.KiCad --silent ...` (Windows only).-# → 2-5 minutes; downloads ~700 MB then installs.-# → returns {ok:true, exitCode:0, _hint:"Run kicad_list_versions to verify"} on success.-# → on failure (no winget / network failure / corporate-managed device):-#   {ok:false, errorCode:"winget_unavailable" | "winget_install_failed", _hint, ...}-```--If `desktop_install_kicad` fails with `errorCode:"winget_unavailable"`, fall back to manual:--> KiCad isn't installed and winget isn't available. Download from https://www.kicad.org/download/ — it's free and open source, install the latest stable (9.x or 10.x).-> Let me know when the install is done and I'll verify the connection.--After EITHER path, have them restart Adom Desktop OR call `kicad_list_versions` again — the kicad bridge caches detection results, so a fresh scan may be needed to pick up freshly-installed binaries.--**v1.6.0+ — improved KiCad detection.** The bridge now scans for KiCad in:-- `C:\Program Files\KiCad\<version>\` (the standard location)-- `C:\Program Files (x86)\KiCad\<version>\` (32-bit edge cases)-- `%LOCALAPPDATA%\Programs\KiCad\<version>\` (winget per-user installs)-- `%LOCALAPPDATA%\KiCad\<version>\` (rare manual installs)-- Windows registry: `HKLM\SOFTWARE\KiCad\<version>\InstallationPath` and the `WOW6432Node` mirror-- `HKCU` mirrors of the above for per-user installs-- macOS: `/Applications/KiCad/` and `/Applications/` directly--If detection still misses an install on a user's machine, that's a real bug to file — get the actual install path from the user and we'll add it to the scan list.--**Fusion 360 not installed:**-> Fusion 360 isn't installed on your desktop. Would you like to install it?-> Download from: https://www.autodesk.com/products/fusion-360-> It's free for personal/hobby use (requires an Autodesk account).-> Let me know when the install is done and I'll verify the connection.--After install, have them restart Adom Desktop, then run `adom-desktop status` to verify.--### Handling "not running" errors--When `errorCode: "fusion_not_running"`, launch it programmatically with the first-class startup command:-```bash-adom-desktop fusion_start-# On Windows: discovers exe via %LOCALAPPDATA% env var webdeploy glob.-# On Docker: delegates to the bridge on the connected desktop via relay.-# ~15-30s typical. Auto-dismisses startup dialogs.-# Returns {"addinReady": true, "pickerDismissed": true|false, ...}-```--KiCad doesn't need to be running for most commands (the bridge launches it on demand).--### Handling "add-in not installed" errors--When Fusion is running but `addinInstalled: false` or `addinConnected: false`:-> The AdomBridge add-in needs to be installed in Fusion 360. I can install it for you — this lets me control Fusion remotely.--The add-in auto-installs when the Fusion bridge starts and Fusion is detected.--### Handling `fusion_addin_not_responding` errors--When `errorCode: "fusion_addin_not_responding"`, Fusion is running but the AdomBridge add-in isn't answering. Call `fusion_dismiss_blocking_dialogs` FIRST — a modal dialog is the most common cause. If that doesn't help, try `fusion_start` to restart Fusion cleanly. Last resort: user enables add-in manually via UTILITIES > ADD-INS > AdomBridge > Run on Startup + Run.--### Handling `main_thread_busy` errors--When `errorCode: "main_thread_busy"`, the Fusion add-in's main thread is occupied by a long-running command (typically `walk_cloud_tree` or `search_cloud_files`). This applies **across all bridges/sessions** — even if you didn't start the walk, another session might have.--**Do NOT:**-- Retry the failed command — it will block behind the same lock-- Call `fusion_dismiss_blocking_dialogs` — there's no dialog to dismiss, and sending Escape will interrupt the active walk-- Force-kill Fusion — the walk will complete on its own--**Do:**-- Wait for the walk/search to finish. If you started it, you should be using `adom-desktop watch` (see "Live folder progress streaming with `watch`" above) which streams live progress automatically. If another session started it, poll `fusion_addin_status` every 2–5s to check progress.-- Use commands that **don't need the main thread** while waiting:-  - `fusion_addin_status` — check busy state and walk progress-  - `fusion_window_info` — get window HWND, title, dialogs-  - `fusion_screenshot_fusion` — capture what Fusion looks like-  - `fusion_click_fusion` — click in the Fusion window-  - `fusion_send_key` — send keyboard input-  - `fusion_close_window` — close a specific dialog by HWND--The response includes progress info:-```json-{-  "errorCode": "main_thread_busy",-  "busyCommand": "walk_cloud_tree",-  "elapsedSeconds": 42.3,-  "walkProgress": {-    "foldersVisited": 15,-    "filesFound": 87,-    "currentFolder": "Molecules/XRP",-    "queueSize": 8-  },-  "_hint": "Add-in is busy with a long-running command. Do NOT retry..."-}-```--**Stalled walk detection**: If `fusion_addin_status` returns `busy: true` but `walkProgress` is missing and `mainThreadStalled: true`, a modal dialog is blocking the event loop — the walk was dispatched but never started. Call `fusion_dismiss_blocking_dialogs`, then the walk auto-resumes.--### Auto-recovery (`_autoRecovery` field)--When a fusion_* command fails with "not responding" / "not connected", the CLI automatically:-1. Calls `fusion_dismiss_blocking_dialogs` to clear any modal-2. If successful, retries the original command-3. Attaches `_autoRecovery: {action, dismissed[]}` to the retry result--If you see `_autoRecovery` in a response, the command already succeeded after auto-dismissal — no manual intervention needed. The field is informational.--## Troubleshooting--### Check connection status--Use `status` to see connected clients. A healthy connection shows one client from the user's hostname with a recent `lastPong` timestamp.--### Stale connections causing timeouts--Use `kick_all` to reset -- active Adom Desktop apps reconnect within seconds.--### No desktop client connected--1. Confirm the Adom Desktop app is running on the user's PC-2. Confirm it's pointed at the correct WebSocket URL-3. Check if port 8765 is exposed and reachable--### Relay not running--```bash-curl -sf http://127.0.0.1:8766/health-# If fails: adom-desktop serve --supervise-```--### Shell commands on Windows--`shell_execute` runs in `cmd.exe`. Prefer writing scripts via `send_files` then executing them. Use the full Python path.--## Building from Source--```bash-cd cli && cargo build --release-# Binary at: cli/target/release/adom-desktop-```--## Repo--[github.com/adom-inc/adom-desktop](https://github.com/adom-inc/adom-desktop)--## Run wiki ops on the user's PC — `wiki_exec` (v1.9.116+)--The AD installer bundles the `adom-wiki` CLI next to the app, and `wiki_exec` runs it ON THE PC — so version-controlling a local project (KiCad board, docs folder) on wiki.adom.inc needs NO per-file `pull_file` round trip to the container and there is no container/PC drift.--```bash-adom-desktop wiki_exec '{"args":["repo","push","adom/my-board","--files","board.kicad_pcb","board.kicad_sch","-m","rev B"],"cwd":"C:\Users\me\boards\my-board"}'-```--- `args` = adom-wiki argv (any pillar: repo, pkg, release, discussion). `cwd` = PC-side project dir so `--files` are project-relative. `timeoutSec` optional (default 120, max 600).-- Returns `{success, exitCode, timedOut, stdout, stderr, exe, durationMs}`.-- Auth is automatic: AD injects the signed-in user's session token as `ADOM_WIKI_TOKEN` — the caller never handles credentials.-- Gated like `shell_execute` for relay callers (it writes to the wiki as the user). `errorCode:wiki_cli_missing` means the desktop AD predates 1.9.116.-- Container-side publishing of the CONTAINER's own files still uses your local `adom-wiki` directly; `wiki_exec` is for files that live on the PC.--Note (v1.9.117): AD's Settings panel no longer has a KiCad section — bridges own their app-specific config (AD stays generic). `kicad_library_paths` / `project_watch` keep working from `~/.adom/config.json` and the `set_project_watch` / `trigger_project_watch` verbs.+---
+name: adom-desktop
+description: Use when the user wants to send files to their desktop, control KiCad or Fusion 360, send desktop notifications, or troubleshoot the desktop connection. Provides CLI tools for bridging the Docker container to the user's local machine.
+---
+
+# Adom Desktop
+
+Bridge between Claude Code (running in an Adom Docker container) and the user's desktop applications via WebSocket.
+
+**Install surface:** the canonical install page is `apps/adom-desktop` on the Adom wiki — it hosts the Linux CLI (`docker_binary`), the Windows installer (`.exe`), and the four sibling skills (pup, kicad-bridge, fusion-bridge, + this one). The gallia `adom-desktop-discovery` skill surfaces this page on any related user query. `adom-desktop setup_desktop` also returns a dynamic installer URL pulled from the latest GitHub release.
+
+**First-time setup?** If the user hasn't installed the desktop app yet, see the Setup section below to walk them through downloading, installing, and connecting the app.
+
+Quick check if desktop is connected:
+```bash
+adom-desktop ping
+```
+
+**Companion skills** (installed alongside this one from the wiki):
+- `adom-desktop-kicad` — KiCad bridge: launch editors, open designs, install libraries, run DRC, window capture + keyboard/click automation (`plugins/kicad/SKILL.md`)
+- `adom-desktop-fusion` — Fusion 360 bridge: launch, open designs, STEP/GLB/.lbr import-export, BOM + API queries, Fusion screenshots (`plugins/fusion360/SKILL.md`)
+- `pup` — browser automation (Puppeteer-style): open URLs, screenshot, eval JS, multi-session Chrome (`gallia/skills/pup/SKILL.md`)
+
+## How It Works
+
+```
+Claude Code -> adom-desktop <command> -> Relay Server (HTTP :8766) -> WebSocket :8765 -> Adom Desktop App -> KiCad / Fusion 360 / Browser / Shell
+```
+
+The `adom-desktop` binary is a single Rust CLI that does everything:
+- `adom-desktop serve` -- Start the relay server (WebSocket :8765 + HTTP API :8766)
+- `adom-desktop <command> '<json>'` -- Send commands to the desktop app via the running relay
+
+The relay server runs in the Docker container. The Adom Desktop app runs on the user's PC and connects via WebSocket.
+
+## Starting the Relay Server
+
+The relay must be running before the desktop app can connect. **In a container, start it SUPERVISED** (v1.9.168+):
+
+```bash
+adom-desktop serve --supervise
+```
+
+**Why not `adom-desktop serve &`?** A backgrounded (`&`) relay is still a child of your shell/session. When your session is torn down (you finish, or the container reaps it), the relay dies with it — and then every connected desktop hits HTTP 500 on `/proxy/8765/` and loops "Reconnecting in 30s" **for hours** until a human restarts it (wiki issue #24, hit live 2026-07-23). `--supervise` **detaches the relay into its own session (reparented to init)** so it survives your session, and starts a watchdog that:
+- restarts the relay with **exponential backoff** (2s → … → 5 min cap; a clean run resets it — a broken relay is never bludgeoned), and
+- **toasts the connected desktops on every restart** with the reason from the relay log, so the owner finds out something went wrong.
+
+```bash
+adom-desktop serve --supervise            # detached + self-healing (use this in containers)
+adom-desktop serve --supervise --owner AdomLapper   # send restart toasts to one desktop
+adom-desktop serve --stop                 # stop the watchdog + relay
+```
+
+`serve` (plain, foreground) still works and prints a loud hint telling you it will die with the session — fine for a quick local relay you'll sit and watch. **Caveat:** a full CONTAINER REBOOT still needs a boot hook (cron `@reboot` / the container's init); the watchdog covers session teardown, not host reboot.
+
+This starts:
+- WebSocket server on `0.0.0.0:8765` (desktop app connects here)
+- HTTP API on `127.0.0.1:8766` (CLI commands go here)
+
+Check if it's running:
+```bash
+curl -sf http://127.0.0.1:8766/health
+```
+
+## First-Time Setup (Install & Connect)
+
+**You are running on a Docker container. You have NO access to the user's desktop.** Guide them step-by-step, ask questions, wait for answers, and verify each step.
+
+> **As soon as you're connected, run `adom-desktop sync_skills` (v1.9.37+).** Installing the
+> adom-desktop core pkg gives you only CORE skills — it does NOT give you a DYNAMIC/cloud
+> bridge's skill (kicad, fusion, blender, any third-party). `sync_skills` runs `bridge_list`
+> and installs each bridge's own skill pkg into `~/.claude/skills/`, so you can actually
+> discover + drive whatever bridges THIS desktop has. (The CLI also runs this automatically
+> once/day in the background, but doing it on connect makes the bridge skills available to
+> the current session immediately.) Re-run it any time the desktop gains a bridge;
+> `adom-wiki pkg update` keeps them fresh.
+
+### Step 1: Check if already connected
+
+```bash
+adom-desktop ping
+```
+
+If this returns `{ "status": "connected" }`, the desktop is already set up -- skip to "Verify the connection" below. If it errors, continue.
+
+### Step 2: Ensure the relay is running
+
+```bash
+curl -sf http://127.0.0.1:8766/health
+```
+
+If not running (in a container, ALWAYS use `--supervise` so it survives your session): `adom-desktop serve --supervise`
+
+### Step 3: Ask what OS they use
+
+Ask the user: **"What operating system is your desktop/laptop? (Windows, Mac, or Linux)"**
+
+- **Windows 10/11** -- proceed
+- **macOS / Linux** -- "Support is coming soon. The desktop app currently only runs on Windows."
+
+### Step 4: Install & connect
+
+Run `adom-desktop setup_desktop` — it auto-detects this container's relay URL and returns a ready-to-run **`register_command`**, a fallback `server_config` JSON, and the `installer_url`. Prefer the zero-paste path:
+
+---
+
+**Option 0: Zero-paste via your Adom account (PREFERRED)**
+
+Run the **`register_command`** that `setup_desktop` returned (it's `adom-desktop register_container` with the name + URL filled in). That injects this connection into the **user's Adom cloud profile**. Then the user just:
+1. Installs Adom Desktop (from `installer_url`) and opens it,
+2. **Signs in with their Adom account** (Connections panel → "Sign in to Adom"),
+
+and the connection appears in their list and auto-connects — **no JSON paste at all**. This is the modern path and the one to try first. (`register_container` needs an Adom cloud container — it uses the mounted account token — and returns `ok:false` on a non-Adom box; if so, use Option A/B below.)
+
+**Already-running desktop? `server_sync` (v1.9.215+).** A signed-in AD reads the cloud profile at boot, at sign-in, and hourly — and `adom-desktop server_sync` (optionally `--target <name>`) makes it re-read RIGHT NOW. The merge is cloud-wins, whole entry including `authToken`. So the flow for adding a server or **rotating a relay token** on live desktops is: update the cloud key (`register_container` writes them) → `server_sync` per desktop → verify with `server_list`. Do NOT use `server_remove` + `server_add` for rotation — removing the entry you are talking through severs your own transport (wiki #387).
+
+---
+
+**Option A: Automatic setup via Claude Code (paste fallback)**
+
+If you have **Claude Code** or **Claude Desktop** on your laptop, paste this prompt:
+
+> Install Adom Desktop and connect it to my cloud container. (Most users instead get Adom Desktop bundled with Hydrogen Desktop, which installs and connects it for them; use this prompt when you are driving the desktop from a cloud container.) Download the installer from `<installer_url>`, run it silently, then write this JSON to `%USERPROFILE%\.adom\config.json`:
+> ```json
+> {"servers":[<server_config JSON>]}
+> ```
+> Then launch "Adom Desktop" from the Start Menu.
+
+*(Replace `<installer_url>` and `<server_config JSON>` with the actual values from `setup_desktop`.)*
+
+**Option B: Manual setup**
+
+1. Download the installer: **[<installer filename>](<installer_url>)**
+2. Run it and follow the prompts — the app installs to the Start Menu as "Adom Desktop"
+3. Open **Adom Desktop** from the Start Menu
+4. Paste this JSON into the text field that says **"Paste server JSON to add ..."** and press **Enter**:
+   ```json
+   <server_config JSON>
+   ```
+5. The server will appear and auto-connect
+
+---
+
+Wait for the user to confirm they see a green dot next to the server name before proceeding.
+
+**Important:** Each container has its own relay server. New containers need a new entry -- old entries from previous containers won't work.
+
+### Step 6: Verify the connection
+
+```bash
+adom-desktop ping
+# Expected: { "echo": "pong", "roundTripMs": ..., "status": "connected" }
+
+adom-desktop status
+# Expected: one client with the user's hostname and capabilities
+
+adom-desktop notify_user '{"title":"Hello from Docker!","body":"Your desktop is connected."}'
+```
+
+Tell the user what you see. If `ping` succeeds:
+
+> "Your desktop is connected! I can now send files to your machine, open browser windows for visual debugging, control KiCad and Fusion 360, take screenshots of your desktop, and send you notifications."
+
+### Step 7: Optional -- Node.js for browser features
+
+Ask: **"Would you like to use the visual browser debugging feature? This opens Chrome windows on your desktop that I can control."**
+
+- **Yes + Node.js installed** -- "Great, the browser bridge auto-installs deps on first use."
+- **Yes + no Node.js** -- "Download Node.js from https://nodejs.org (LTS), install it, then restart Adom Desktop."
+- **No** -- skip, they can enable it later
+
+### Common connection issues
+
+| Symptom | Fix |
+|---------|-----|
+| `ping` returns `"No desktop client connected"` | User hasn't added this container in the desktop app yet |
+| Desktop app shows "disconnected" | Check the URL uses `wss://` (not `ws://` or `https://`), port is `8765` |
+| Relay server not running | `adom-desktop serve --supervise` (survives your session; see Starting the Relay Server) |
+| Multiple stale connections | `adom-desktop kick_all` -- app auto-reconnects within seconds |
+| Desktop app not installed | Download from [github.com/adom-inc/adom-desktop/releases](https://github.com/adom-inc/adom-desktop/releases) |
+
+## CLI Tool
+
+```bash
+adom-desktop <command> '<json-args>'
+```
+
+**Examples:**
+```bash
+adom-desktop ping
+adom-desktop status
+adom-desktop browser_open_window '{"sessionId":"dart2","url":"https://example.com"}'
+adom-desktop browser_eval '{"sessionId":"dart2","expr":"document.title"}'
+adom-desktop browser_screenshot '{"sessionId":"dart2"}'
+adom-desktop browser_list_windows
+adom-desktop browser_close_window '{"sessionId":"dart2"}'
+adom-desktop notify_user '{"title":"Hello","body":"From Docker"}'
+adom-desktop shell_execute '{"command":"echo hello"}'
+adom-desktop pull_file '{"filePaths":["C:\\Users\\john\\Downloads\\image.png"],"saveTo":"/tmp"}'
+```
+
+**Output:** JSON to stdout. Screenshots save to `/tmp/adom-desktop-screenshots/` and return the file path.
+
+**Config location:** The desktop app stores server config at `~/.adom/config.json` (`%USERPROFILE%\.adom\config.json`). This is separate from the binary — config survives updates and reinstalls.
+
+## Available Commands
+
+**Get the full structured command list with descriptions, args, and prerequisites:**
+```bash
+adom-desktop commands            # aliases: list_commands, help
+adom-desktop --target <name> commands   # + THAT box's installed bridges (installed_bridges_on_target)
+```
+Returns categorized JSON with every command, its required/optional args, return values, prerequisites, and workflow notes. Run this FIRST when you're unsure what a verb is called.
+
+**Two kinds of verbs (v1.9.103 — don't confuse them):**
+- **BUILT-IN AD-core verbs** are always available on every Adom Desktop, no install, NOT bridges. This is where screenshots, windows, input, files, registry, shell, tunnel, update, and notify live. A whole-screen shot is `desktop_screenshot_screen`; one window (with chrome) is `desktop_screenshot_window {"hwnd":N}` or `{"titleContains":"..."}` (get hwnds from `desktop_list_windows`).
+- **BRIDGE verbs** (`kicad_`/`fusion_`/`browser_`/`blender_`/`nbrowser_`…) need that bridge installed — see `installed_bridges_on_target` in `commands`, or run `bridge_list`.
+
+If a verb returns `errorCode:"unknown_verb"`, **read the error's `_hint`** — it carries a fuzzy "did you mean" (e.g. `desktop_screenshot` → `desktop_screenshot_screen`) and tells you whether the right verb is built-in or a bridge. Don't conclude a capability is "a missing bridge" from a guessed verb name; call `commands` and check.
+
+### Connection Management
+
+- `ping` -- 5s round-trip test. Use BEFORE browser/shell commands to verify the desktop connection is alive.
+- `status` -- Check who's connected, their capabilities, desktop paths, and **app installation status**. The `desktop.apps` object shows:
+  - `kicad.installed` / `kicad.version` / `kicad.bridgeRunning`
+  - `fusion360.installed` / `fusion360.running` / `fusion360.bridgeRunning` / `fusion360.addinInstalled` / `fusion360.addinConnected`
+  - `browser.bridgeRunning`
+- `kick_all` -- Force-disconnect all WebSocket clients. Active Adom Desktop apps auto-reconnect within seconds.
+
+#### Programmatic server registration (v1.8.22+)
+
+Three verbs let external apps (Hydrogen Desktop's WSL2 workspace, in particular) register their relay server with adom-desktop without the user pasting JSON into the GUI by hand. Mirrors the GUI's Quick Add bar + connect/disconnect buttons.
+
+- `server_add` -- Upsert a relay server by `name`. If a server with that name already exists, the URL (and optionally authToken) is updated rather than creating a duplicate.
+  ```bash
+  adom-desktop server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'
+  # → {ok:true, name, url, id, connected:bool, created:bool, _hint}
+  ```
+  - `name` (required) — dedup key. Repeat calls with the same name are idempotent.
+  - `url` (required) — relay WebSocket URL.
+  - `authToken` (optional) — defaults to a dev token (leave unset unless your relay requires one).
+  - `autoConnect` (optional, default `true`) — connect right after upsert. Set `false` to add the entry without dialling out.
+  - Behavior: same URL + already connected → no churn, returns ok. URL changed + autoConnect=true → disconnect old loop + spawn new one. URL changed + autoConnect=false → disconnect old, leave entry registered but not connected.
+- `server_remove` -- Disconnect (if connected) and delete an entry by name. Returns `{ok, removed:bool, wasConnected:bool}`. Idempotent (removing a non-existent entry returns ok with removed:false).
+  ```bash
+  adom-desktop server_remove '{"name":"hydrogen-workspace"}'
+  ```
+- `server_list` -- Persisted server list with live connection status: `{servers:[{name, url, id, autoConnect, enabled, connected:bool, clientCount:0|1, status}]}`. `status` is the fine-grained `connected | reconnecting | disconnected` state machine value.
+
+Persists to the same `~/.adom/config.json` the GUI uses, so entries survive a GUI restart. The `ws_client` supervisor (runs every 30s inside the GUI) auto-reconnects entries with `autoConnect:true` on next launch.
+
+HD-style usage:
+```bash
+# At HD container startup
+adom-desktop server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'
+
+# At HD container shutdown
+adom-desktop server_remove '{"name":"hydrogen-workspace"}'
+```
+
+#### Addressing multiple Adom Desktops on one relay (v1.8.133+)
+
+One relay can have **several** Adom Desktops connected at once — e.g. a laptop and an Azure VM, eventually many. Each AD has a **name** (its `--client-name` / Settings value, defaulting to the OS hostname). The cloud CLI addresses a specific one, or fans out to all:
+
+- `adom-desktop targets` — list connected ADs: `{count, targets:[{name, hostname, version, os, connectedAt}]}`. Discover names here first.
+- `adom-desktop --target <name> <verb>` — route the verb to that AD only. `--target` is a **global** flag (goes before the verb; case-insensitive; matches `clientName`, else `hostname`). `ADOM_TARGET=<name>` sets a default (the flag wins).
+- `adom-desktop --target all <verb>` — fan out to **every** connected AD; returns `{broadcast:true, targets:[...], results:{<name>:<verb-result>}, summary:{total,ok,failed}}`. A per-AD timeout/failure is isolated to that AD's entry.
+
+```bash
+adom-desktop targets
+adom-desktop --target azurevm desktop_screenshot_screen
+adom-desktop --target all hd_status        # every box at once, results keyed by name
+```
+
+**No-target behavior:** with exactly ONE AD connected, no `--target` is needed (back-compat). With MULTIPLE connected and no `--target`, the call FAILS with `{error:"ambiguous_target", connected:[...], _hint}` (exit 1) — it won't guess which box. A bad name → `{error:"target_not_found", requested, connected:[...]}` (exit 1). Set each AD's name with the `--client-name <name>` launch flag (HD passes this when spawning the embedded AD), the Settings field, or `config.json` `client_name`. Full design + the relay-routing internals: [`skills/MULTI_AD_ADDRESSING.md`](MULTI_AD_ADDRESSING.md).
+
+#### Direct HTTP API for sibling apps (v1.8.25+)
+
+If you're authoring a sibling Tauri app on the same machine (Hydrogen Desktop, or any future "Adom-family" app), you can skip the CLI binary entirely and POST commands straight into the running adom-desktop GUI on `127.0.0.1:47200` (was `127.0.0.1:8770` through v1.8.32 — moved to be a better neighbor to HD on 47080+ and avoid the 8000-range collision risk). The endpoint runs inside the GUI process (loopback-only bind), uses the same dispatcher the WS path uses, and returns the same JSON shape as `adom-desktop <verb>` — including every `_hint` field.
+
+| Method | Path | Body | Returns |
+|---|---|---|---|
+| `GET` | `/health` | — | `{"ok":true,"service":"adom-desktop"}` cheap probe |
+| `GET` | `/status` | — | service banner + version + schema + `directApi.cliRequired` list |
+| `POST` | `/command` | `{"app":"<ns>","command":"<verb>","args":{...}}` | The verb's normal payload (200 OK), or `{error, errorCode, _hint}` (4xx/5xx) |
+
+**v1.8.33+ port discovery (sibling apps READ THIS):** Don't hardcode 47200. The GUI may have bound to 47200-47209 instead if the default port was taken (zombie socket, dev instance, third-party collision). Discovery protocol:
+
+1. Read `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`) — single-line `host:port`, written by the GUI at bind time, removed at graceful shutdown
+2. If file missing or its port doesn't `/health`, scan `127.0.0.1:47200..=47209` for any port answering with `{"ok":true,"service":"adom-desktop"}`
+3. Validate `service == "adom-desktop"` to disambiguate from other apps that might bind a port in our range
+
+The CLI does this automatically (see `cli/src/direct_probe.rs`). For sibling-app Rust code, see [`skills/DIRECT_API.md`](DIRECT_API.md) for a copy-paste-ready helper.
+
+**Per-verb timeouts (v1.8.31+):** the direct API's command-timeout is no longer a hardcoded 120s — it mirrors the CLI dispatcher's per-verb table (`walk_cloud_tree`/`search_cloud_files`=620s; heavy exports=320s; `bridge_install`/`fusion_start`=300s; default=120s). Caller can override via `args.timeout` (seconds, clamped to 1800).
+
+Example — programmatic server registration without the CLI:
+
+```bash
+curl -X POST http://127.0.0.1:47200/command \
+  -H 'Content-Type: application/json' \
+  -d '{"app":"desktop","command":"server_add","args":{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}}'
+# → identical JSON to `adom-desktop server_add '{...}'`, including the _hint
+```
+
+**What's safe to send directly:** essentially everything sync (`server_*`, `bridge_list`, `hd_status`, `hd_build_status`, `desktop_list_windows`, `desktop_screenshot_*`, `kicad_*`, `fusion_*`, `browser_screenshot`, `notify_user`, ...) plus async-dispatching verbs that return a job id in <500 ms (`bridge_install`, `hd_build`, `desktop_install_kicad`, ...).
+
+**What requires the CLI fallback:** verbs returning a structured `errorCode:"cli_required"` from this endpoint — currently `pull_file`, `send_files`, `shell_execute`. These use binary streaming or multi-minute approval polling that doesn't fit a single synchronous HTTP request. `GET /status` returns the full list at runtime so callers can branch defensively.
+
+Full integration guide (loopback trust model, recipes for HD startup/shutdown, retry/backoff patterns, port-discovery code in Rust + TypeScript) lives in [`skills/DIRECT_API.md`](DIRECT_API.md) in this repo, and as a wiki asset attached to `apps/adom-desktop`.
+
+#### Embedded mode — AD bundled inside Hydrogen Desktop (v1.8.42+)
+
+When Hydrogen Desktop (HD) bundles AD, AD enters "embedded mode" at boot. HD owns the system-tray icon (AD's is suppressed), HD owns the Adom Cloud login (HD's session token is handed to AD via Phase 4's existing channels), HD owns auto-updates (AD's wiki-poll is disabled — HD bundles a fresh AD on every HD release), and HD spawns AD hidden (its tray "Open Adom Desktop" surfaces the window on demand). Standalone AD users see zero change — none of the signals fire.
+
+**Detection** — three-signal cascade (any one triggers embedded mode):
+
+1. `--embedded` CLI flag (what HD passes on every spawn)
+2. `ADOM_EMBEDDED=1` env var (backup channel)
+3. `%LOCALAPPDATA%\Adom Desktop\embedded.json` marker file (survives AD restarts; written by HD's installer; deleted by HD's uninstaller)
+
+**CLI flags HD passes when spawning AD**:
+
+```bash
+adom-desktop.exe --embedded --start-hidden \
+  --relay-url ws://127.0.0.1:8765 --relay-name hydrogen-desktop \
+  --session-token <hd's-stored-token>
+```
+
+- `--embedded` → enter embedded mode (also writes the marker)
+- `--start-hidden` → boot with main window invisible. **v1.8.119+: honored in embedded mode** (HD owns the surface; AD stays in the tray and opens via `desktop_window_show`). v1.8.102–118 had a bug where embedded mode force-showed the window even with `--start-hidden`; that's fixed. The machine owner can still flip AD's "Always show the window at launch" Settings checkbox (config `always_show_window_on_launch`, default OFF) to override and always show — a personal-dev-box preference, not the product default.
+- `--shell-approval <prompt|1h|24h|permanent>` → HD's passthrough of its user's shell-approval choice (v1.8.116+, session-only)
+- `--relay-url <url>` → upsert this relay via existing `server_add` dedup; default name `hydrogen-desktop`
+- `--relay-name <name>` → override the default name
+- `--session-token <tok>` → already covered by Phase 4 handoff (env / CLI arg / file)
+
+**Docker introspection** — `adom-desktop desktop_embedded_status` returns `{embedded, owner, source, enteredVia, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` so cloud-side callers can branch on whether AD is standalone or HD-managed. **v1.8.73+:** `enteredVia` reports `"launch-flag"` (HD spawned AD as a child via `--embedded`) or `"runtime-adopt"` (AD was running standalone; HD adopted it at runtime) — see "Runtime adopt + release" below.
+
+**New verbs HD uses to drive AD** (all also usable standalone):
+
+| Verb | Purpose |
+|---|---|
+| `desktop_window_show` | Bring AD's main window to foreground |
+| `desktop_window_hide` | Hide AD's main window (app keeps running) |
+| `desktop_connect_all` | Spawn ws_loop for every enabled server |
+| `desktop_disconnect_all` | Drop every live WS connection (entries stay in config) |
+| `desktop_shutdown` | Explicit AD termination (stop bridges, then exit) |
+| `desktop_embedded_status` | Introspect current embedded state (incl. `enteredVia`) |
+| `desktop_embedded_enter` | **v1.8.73+** runtime adopt: standalone → embedded as `runtime-adopt` |
+| `desktop_embedded_release` | **v1.8.73+** revert to standalone. **v1.8.77+: always reverts, never exits AD** (regardless of `entered_via`) — use `desktop_shutdown` for explicit exit |
+| `desktop_logout` | Clear AD's `~/.adom/session.json` (HD's sign-out propagation) |
+
+#### Runtime adopt + release (v1.8.73+) — the LaunchFlag-vs-RuntimeAdopt distinction
+
+HD has two ways to put AD into embedded mode:
+
+1. **Spawn AD with `--embedded`** (LaunchFlag) — HD launches a fresh AD process as its child. AD enters embedded with `entered_via=launch-flag`. When HD subsequently dies ungracefully (without calling `/embedded/release` or `desktop_shutdown` first), AD's 60s `embedded_monitor` tick notices HD's control port is dead and **exits AD** (the "no hanging chad" rule — no tray-less orphan).
+2. **Adopt at runtime via `POST /embedded/enter`** (RuntimeAdopt) — AD was already running standalone; HD calls the direct-API endpoint to flip AD into embedded mode without a respawn. AD enters embedded with `entered_via=runtime-adopt`, preserving its open state, current PID, all in-flight connections. When HD subsequently dies ungracefully, AD's monitor **reverts AD to standalone** — tray restored, footer pill hidden, marker deleted. AD process stays alive.
+
+The rule is simple: **whoever started AD owns AD's death**. If AD started itself (standalone → runtime adopt), HD's death doesn't take AD down with it.
+
+**Two new direct-API endpoints** (v1.8.73+, sugar over `POST /command`):
+
+```bash
+# Adopt — runs the same handler as desktop_embedded_enter:
+curl -s -X POST http://127.0.0.1:47200/embedded/enter
+
+# Release — runs the same handler as desktop_embedded_release.
+# v1.8.77+: ALWAYS reverts to standalone. AD never exits via this call.
+curl -s -X POST http://127.0.0.1:47200/embedded/release
+```
+
+**Probe target (v1.8.75+):** the 60s `embedded_monitor` checks HD's *control port* (default `47084`, discovered from `%APPDATA%\hydrogen-desktop\ports.json` key `control`), not HD's legacy `:9001` webview-eval port. Same discovery `hd_api` uses. Pre-v1.8.75 this was hardcoded to `:9001` — HD doesn't bind that port anymore, so the probe always reported HD dead and every embedded AD reverted/exited within 60s of HD starting.
+
+**Live frontend update (v1.8.76+):** the "Embedded · HD" footer pill responds to Tauri `mode-changed` events emitted on every transition. So if AD's window is open when HD adopts it (or HD releases it), the pill flips immediately — no page reload, no manual refresh.
+
+**Stale-marker safety (v1.8.74+):** if the marker file says `entered_via=launch-flag` AND HD's control port isn't responding at boot, AD deletes the marker BEFORE exiting. Without the delete, every subsequent manual launch (no `--embedded` flag) would hit the same exit branch and AD would be unlaunchable until the user manually rm'd the marker. The delete-before-exit makes the next launch start standalone normally.
+
+**Shell auto-approve is a user preference** (v1.8.116+ — replaces the v1.8.44 embedded-permanent default). The safe default everywhere is per-command approval. Resolution order at AD boot:
+
+1. `--shell-approval <prompt|1h|24h|permanent>` launch flag — HD's passthrough of its user's choice when spawning AD embedded. **Session-only**: never persisted by AD.
+2. The durable Settings checkbox ("Auto-approve shell commands permanently", config field `shell_auto_approve_permanent`, default OFF). Also set/cleared via `shell_auto_approve '{"permanent": true|false}'`.
+3. A restored timed grant from a previous session (+1h/+24h buttons, `duration_secs` calls).
+4. Otherwise: prompt per command.
+
+In embedded mode **HD owns the preference** (HD is the parent, AD the child): AD's Settings checkbox goes read-only with "Managed by Hydrogen Desktop", and the footer shows "**Permanent** *(managed by Hydrogen Desktop)*" when armed. HD changes it at runtime with the same verb:
+
+```bash
+# revoke everything (timed + permanent + the saved checkbox):
+adom-desktop shell_auto_approve '{"duration_secs": 0}'
+
+# set the durable preference (equivalent to ticking the Settings checkbox):
+adom-desktop shell_auto_approve '{"permanent": true}'
+```
+
+Migration note: pre-1.8.116 embedded boots persisted a permanent grant that leaked into standalone sessions. On first v1.8.116 boot, a permanent-class grant on disk WITHOUT the config preference is cleared — re-opt-in via the checkbox or the verb if you actually wanted it.
+
+**⚠ v1.9.90 — a cloud/relay AI CANNOT grant shell approval itself.** The `shell_auto_approve` examples above are HD's (a trusted LOCAL caller) or the human's own. From a **relay** connection (the cloud AI), a GRANT (`shell_auto_approve {"permanent":true}` / `{"duration_secs":N>0}`) is REFUSED with `errorCode:"human_only"` and instead POPS the human approval prompt in Adom Desktop. **The AI's path: call `request_shell_approval` (or just let the gate/refusal auto-surface the prompt) → ask the user to click Approve 1 hr / 24 hr / Bypass Permissions → RETRY your original verb.** Only a human clicking in AD (or HD proxying its own user) grants. A REVOKE (`duration_secs:0`) is allowed for the AI.
+
+#### Bridge ports are dynamic — you never need to know one (v1.8.31+)
+
+KiCad, Fusion, Browser/Puppeteer, and all third-party bridges now bind OS-assigned ephemeral ports (not the legacy 8772/8773/8851 you may remember). The runtime port changes every spawn. **Callers never need to know it.**
+
+- The CLI verb namespace (`kicad_*`, `fusion_*`, `browser_*`, plus any third-party `<bridge>_*`) is the contract. Always go through that.
+- adom-desktop's direct API forwards to whatever port each bridge is on at the moment of the call.
+- `bridge_list` reports `spawn.runtimePort` per bridge for debugging — do NOT hardcode it anywhere.
+- Why this changed: HD's bridges also wanted 8772/8773/8851; we used to collide silently. Dynamic ports = clean coexistence.
+
+### Managing bridges (the AI fully controls bridges on its own)
+
+Bridges are AD's plugins (kicad, puppeteer, fusion360 ship bundled; others install from the wiki). You — the AI — can see, update, install, remove, pause, and resume them entirely through verbs. No human step is required.
+
+| Verb | What it does |
+|---|---|
+| `bridge_list` | Every installed bridge: `name`, `displayName`, `version` (what AD runs now), `source` (bundled / cache / third_party), `paused`, spawn kind, verbPrefixes. Start here. |
+| `bridge_check_updates` | **READ-ONLY.** Per bridge: `current` vs `latest` (wiki) + `updateAvailable`. Downloads nothing. The "what's behind?" probe. |
+| `refresh_bridges` | Apply updates. No args = sync ALL bridges from the wiki. `{"name":"kicad"}` = update just one. |
+| `bridge_install` | Install a new third-party bridge from a wiki manifest URL (`{"manifestUrl":"…/<bridge>-bridge-manifest.json"}`). On `success:true` the **capsule appears in the AD GUI immediately** (no verb call needed; it's gray/idle until first use, then green). On `success:false` **nothing is registered and no capsule appears** — read `error` (a `read zip body`/`builder error` means the manifest's zip URL couldn't be fetched). Don't treat a parsed manifest as a completed install; confirm with `bridge_list`. The manifest `url` may be relative (resolved against the manifest's dir, v1.8.169+). |
+| `bridge_uninstall` | Remove a bridge (`{"name":"…"}`). Bundled ones revert to the bundled copy; third-party ones are fully removed. |
+| `bridge_pause` / `bridge_resume` | Flip a routing flag so a bridge's verbs fail-fast with `bridge_paused` (or resume). Does NOT kill the process. |
+
+**Naming convention for published bridges (3rd-party authors, read this):** when you publish a bridge to the Adom wiki, its **page title / catalog descriptive name MUST start with `Adom Desktop - `** (spaced hyphen) — e.g. `Adom Desktop - Altium Bridge`, matching the bundled bridges (`Adom Desktop - Puppeteer Bridge`, `Adom Desktop - KiCad Bridge`). This keeps every AD bridge grouped with consistent names in app-index pages instead of scattering as bare slugs. It applies to the **wiki/catalog title only** — NOT `bridge.json` `displayName` (the in-app chip label, which stays tool-descriptive like `Altium Designer`) or `name` (the lowercase slug). Full authoring walkthrough: the `adom-desktop-bridge-sdk` skill (the `adom-desktop-bridges` wiki page) + its fill-in-the-blanks templates.
+
+**Typical "am I on the latest bridges?" flow:**
+```bash
+adom-desktop bridge_check_updates            # read-only: see current vs wiki per bridge
+# → {bridges:[{name,current,latest,updateAvailable,source,wikiReachable}], updatesAvailable:N}
+adom-desktop refresh_bridges                  # update ALL that are behind
+adom-desktop refresh_bridges '{"name":"kicad"}'   # or just one
+```
+
+**Version semantics (so the numbers make sense):**
+- `current` = what AD actually runs = the newer of the writable cache and the bundled copy (version-aware, numeric semver — `1.0.10 > 1.0.9`).
+- A bundled bridge that ships AHEAD of the wiki shows `updateAvailable:false` — AD correctly keeps the newer bundled copy. **puppeteer is bundled-only** (its node_modules deps exceed the wiki file limit), so it never shows a wiki update; it updates with the AD installer.
+- After an `updated` action, the bridge SERVER PROCESS still holds the old code in memory until it respawns. `is_bridge_running` is version-aware (v1.8.142+) and forces a respawn on the next verb call, so you normally don't need to kill anything — but if you want it instant: `taskkill /f /im python.exe` (kicad/fusion) or `node.exe` (puppeteer).
+
+**Auto-update cadence:** a running AD also auto-syncs bridges on launch + every 4 hours. `bridge_check_updates` / `refresh_bridges` (or the GUI's **"Check for updates"** button next to "+ Bridge") are the on-demand "I just published, pick it up now" path.
+
+#### Bridge lifecycle — one instance, stable port (v1.8.153+)
+
+A dynamic bridge gets **one stable port** that AD reuses across every spawn and across AD restarts (persisted in `~/.adom/bridge-ports.json`). A long-lived external client (e.g. a browser extension that dials the bridge) keeps reaching the same port — no chasing a fresh ephemeral port each cycle, no "the client is on bridge A but AD routes to bridge B" split. AD enforces a **single instance**: it tracks every spawned bridge PID, reaps a stale/wedged one before respawning on the same port, and `bridge_stop_all` reaps orphans too.
+
+`spawn.persistent` (in `bridge.json`) is now surfaced in `bridge_list` (`spawn.persistent`) and means: **AD auto-respawns this bridge on death**, on its stable port (a supervisor runs each ~15 s). So "kill the bridge to load new code" → AD brings it right back at the same port the client keeps dialing. `bridge_install` / `refresh_bridges` also no longer fail with the cache-dir lock (AD reaps the process holding the dir as its cwd, then swaps; in-place merge fallback if a handle lingers).
+
+**v1.8.186/187 — first-class bridges (panel + remote management + optional manifest fields).** The AD window has a full-height **Bridges sidebar** that shows each bridge as a mini-app (hero image, LED, rich tooltip); clicking one opens a wide pop-out with a **live console** (its `~/.adom/bridge-logs/<name>.log` + the per-bridge command history), a **ports/process debug view** (live PIDs — `>1` flags a runaway), persistent-vs-one-off + codebase-language badges, and all management actions (install/uninstall/**kill**/restart/pause/resume + "filter activity log to this bridge" + "copy contribution prompt"). The same management is available **remotely** to cloud/WSL2 threads via verbs: `bridge_info`/`bridge_detail` (rich aggregate), `bridge_log_read {name, sinceOffset?}` (**cursor-streaming remote tail -f**), `bridge_kill` (wipe one bridge's processes). As of v1.8.186 **all** bridges write their per-bridge log (bundled kicad/fusion360/puppeteer included, not just dynamic ones). New **optional** `bridge.json` fields (all back-compat, surfaced in the panel): `hero` (a relative filename on your wiki page, or a full URL — your app-icon; absent → a monogram tile), `languages` (override the auto-scan, e.g. `["Python"]`), `releasedAt` (ISO date; absent → bridge.json mtime).
+
+**v1.8.156 — strays + self-documenting hints.** AD now tracks every bridge PID it spawns (persisted across AD restarts) and **reaps all stray instances** before respawning — so you don't end up with 4 copies of one bridge fighting over the routing. `bridge_list` surfaces `spawn.instanceCount` per dynamic bridge (normally 0 or 1; **>1 = strays piled up** — visible at a glance). If a bridge verb fails to reach a healthy bridge, the error `_hint` tells you to retry / check `instanceCount` / `process_list`; and a verb response right after a (re)spawn carries an `_ad_note` saying the external host reconnects within a few seconds — **retry** instead of treating a transient "not connected" as terminal. For verb discovery from a bridge, `GET <ADOM_DIRECT_API_URL>/commands` lists every reachable verb (see [`skills/DIRECT_API.md`](DIRECT_API.md)).
+
+#### Bridge status chip — self-reported LED + tooltip (v1.8.152+)
+
+Each bridge has a status dot in the AD GUI sidebar. **AD is a pure renderer of bridge health** — a live bridge decides its own LED color and hover text; AD just paints what the bridge reports. This replaced the old scheme where AD inferred the LED from whether it was holding a spawn handle, which left fire-and-forget (dynamic/installed) bridges stuck RED even while healthy and routing.
+
+**The contract (all three fields OPTIONAL).** On the endpoint AD already polls — your `bridge.json` `healthEndpoint` (default `/health`; the sample bridges use `/status`) — return a 2xx JSON body that MAY include:
+
+| Field | Type | Meaning |
+|---|---|---|
+| `led` | `"green"` \| `"yellow"` \| `"red"` | The dot color. Green = healthy; yellow = degraded/warning; red = the bridge itself knows it's broken (e.g. lost its upstream). |
+| `summary` | string | Short label shown next to the dot. Keep it tiny (≤ ~24 chars) — e.g. `"2 windows"`, `"ext disconnected"`. |
+| `tooltip` | string | Full hover text. Newlines allowed; no hard length limit (keep it readable). |
+
+**Rules AD enforces:**
+- Endpoint returns 2xx **with** `led`/`summary`/`tooltip` → rendered **verbatim**, no parsing or logic.
+- Endpoint returns 2xx **without** them → AD defaults to **green** (you're reachable) + a generic tooltip. Existing bridges work unchanged — this is opt-in.
+- Endpoint **unreachable** → AD owns this ONE state: a **gray/offline** dot + AD's own tooltip ("Not running" / "Bridge not reachable"). A dead bridge can't self-report, so AD never lets a bridge claim "offline" — it detects it.
+
+**Cadence:** AD re-polls every ~15 s, and a dynamic bridge flips green the instant it spawns. So a bridge that surfaces live counters (open windows, queue depth, ext-connection state) in `summary`/`tooltip` updates in the GUI within a tick.
+
+Reference implementations: [`scripts/sample-bridges/hello-python/server.py`](../scripts/sample-bridges/hello-python/server.py) and [`hello-rust/src/main.rs`](../scripts/sample-bridges/hello-rust/src/main.rs) both self-report `led`/`summary`/`tooltip` on `/status` — fork either.
+
+### File Transfer
+
+- `send_files` -- Send files from the Docker container to the desktop. Files are base64-encoded in transit.
+  - `filePaths`: array of absolute paths on the server
+  - `targetApp`: "kicad", "fusion360", or "general"
+  - `dest`: **absolute** Windows path (under a safe root) to land the files exactly where you want (v1.8.148+). Prefer this when you know the target dir.
+  - `destinationFolder`: **relative subfolder only** (e.g. `"kicad/symbols"`, `"fusion"`). The desktop app controls the base directory. **Absolute paths are rejected.** With neither `dest` nor `destinationFolder`, files land in the laptop's **Downloads** folder.
+  - Returns `destinationPaths[]` with the absolute path of every saved file, plus (v1.8.168) `savedToDir` (the folder) and a rich `_hint`. **The files land on the LAPTOP (Windows host), not in your container** — verify with `list_dir`, pull one back with `pull_file`.
+  - **`send_files` only COPIES bytes — it does NOT install or register a bridge.** Dropping bridge files with `send_files` will NOT make a capsule appear or verbs route. To add a bridge use `bridge_install '{"manifestUrl":"…/<bridge>-bridge-manifest.json"}'`, then confirm with `bridge_list`.
+
+- `pull_file` -- Pull files from the desktop to the container.
+  - `filePaths`: array of absolute Windows paths on the desktop
+  - `saveTo`: directory on the container to save files (default: `/tmp`)
+  - **Streaming since v1.4.3.** Each file is transferred as 1 MiB binary WS frames straight to disk on the Docker side, with incremental SHA256 verification. The legacy 30s base64-JSON path is gone — large files (50 MB+ datasheets, 75 MB reference manuals) no longer time out. Per-file timeout is 600s.
+  - Returns `files: [{name, path, size, sha256, chunks}]`. Use `sha256` to verify the transfer (the desktop side computes it during streaming and the container side verifies on completion; mismatch deletes the partial file and reports failure). `chunks` is the count of 1 MiB binary frames received.
+  - When at least one but not all files succeed: `success` is `true`, `errors[]` lists the failures alongside `files[]`. When ALL fail: `success` is `false`.
+
+### Desktop Notifications
+
+- `notify_user` -- Send a native Windows toast (own AUMID + Adom logo). v1.9.33: rich types — progress bar, sticky reminder, action buttons that **bring AD to the foreground** on click + round-trip via `notify_response`.
+  - `title`, `body`, `level` (info/success/warning/error/emergency), `actions` (label buttons) — plus `scenario` ("reminder"=sticky), `durationLong`, `buttons` (`[{label, arguments?}]`), `progress` (`{title, value 0..1, status, valueOverride}`), `inputs`, `id` (correlation id for the round-trip).
+  - Full guide + copy-paste examples per type: the **adom-desktop-notify** skill ([`skills/NOTIFY.md`](NOTIFY.md)).
+- `notify_response` -- Poll which toast button the user clicked, by correlation `id`. `{pending:true}` until they click; `{pending:false, action}` once they do (consumed on read).
+
+### KiCad (`kicad_*`) — bridge-owned; light pointer
+
+The KiCad bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/adom-desktop-kicad-bridge`). This page keeps only the pointer: run `adom-desktop sync_skills` after connecting and the full KiCad guide (project workflows, three-tier `kicad_open_*` routing, DRC recipes, library install) lands in `~/.claude/skills/`.
+
+Most-used verbs to get moving:
+
+```bash
+adom-desktop kicad_open_board '{"path": "C:/projects/board/board.kicad_pcb"}'
+adom-desktop kicad_screenshot_all '{}'          # every KiCad window incl. error dialogs, as an array
+adom-desktop kicad_run_drc '{}'
+adom-desktop kicad_window_info '{}'
+adom-desktop kicad_open_symbol_editor '{"library": "Device"}'
+```
+
+If KiCad is not installed, the bridge knows how to install it for the user — offer, never point them at a download page. Full details: the bridge's own skill.
+
+### Fusion 360 (`fusion_*`) — bridge-owned; light pointer
+
+The Fusion 360 bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/adom-desktop-fusion-bridge`). Run `adom-desktop sync_skills` and the full guide (cloud files, import/export, add-in troubleshooting, APS search) lands in `~/.claude/skills/`.
+
+Most-used verbs:
+
+```bash
+adom-desktop fusion_start '{}'                       # launches Fusion; slow on cold start (minutes)
+adom-desktop fusion_get_app_state '{}'               # the status/poll verb
+adom-desktop fusion_addin_status '{}'
+adom-desktop fusion_dismiss_blocking_dialogs '{}'
+adom-desktop fusion_send_key '{"key": "Escape"}'
+```
+
+Fusion launches take 2-4 minutes cold; respect `timeoutSeconds` + `statusVerb` on responses instead of re-issuing. Full details: the bridge's own skill.
+
+### Launching apps (generic)
+
+Two generic CLI commands exist for launching any Windows executable safely — they verify the target exists BEFORE handing it to the OS, so you never trigger a "Windows cannot find" GUI dialog:
+
+- `adom-desktop find_exe '{"name":"..."}'` -- Resolve an exe by absolute path, glob (e.g. `C:/.../webdeploy/production/*/Fusion360.exe` returns newest), bare name (searches PATH + Start Menu `.lnk` targets). Returns `{path, source}`. Does NOT launch.
+- `adom-desktop launch '{"path":"...", "args":[...], "cwd":"...", "detached":true}'` -- Same resolution rules as `find_exe`, then spawns. Fails in terminal (exit 1) with a clear error if the path doesn't exist. **Always prefer this over raw `start`.**
+
+For Fusion specifically, use `fusion_start` — it wraps `launch` plus the full startup-picker / add-in-readiness dance.
+- `fusion_import_step` -- Import a STEP/STL/IGES file into Fusion 360
+- `fusion_open_lbr` -- Open an EAGLE .lbr library file
+- `fusion_open_electronics` -- Check if the Electronics workspace is active
+- `fusion_electron_run` -- Execute any EAGLE command via Electron.run. Returns rich state: `activeWorkspace`, `activeDocument`, `commandType`, `_hint`, and `fusionOperations` (diff of Fusion's internal operation log showing what actually fired). **Avoid blocking commands** — see list below. **Full EAGLE command reference:** [`skills/eagle-commands.md`](./eagle-commands.md) — popular/safe commands split from blocking/modal ones, with layer reference and chaining syntax.
+- `fusion_execute_text_command` -- Low-level app.executeTextCommand() access. Returns the command result plus workspace context.
+- `fusion_board_info` -- Get structured board data from the open PCB layout. Returns: component placements (name, package, x, y, rotation), net names, copper traces, layer setup, board thickness, DRC violations. Much richer than a screenshot — gives exact coordinates and connectivity. Requires a .brd board open in PCB Editor.
+
+#### `fusion_electron_run` — EAGLE Command Execution
+
+Executes EAGLE commands inside Fusion 360's Electronics workspace. Works in **Schematic Editor**, **PCB Editor (Board Layout)**, and **Electronics Library** contexts.
+
+**How it works:** Sends the command string via Fusion's `Electron.run` text command. EAGLE's `Electron.run` is fire-and-forget — it never returns output or throws on invalid commands. To compensate, the handler snapshots Fusion's internal operation log (`Diagnostics.RecentOperations`) before and after execution, returning a diff showing what actually fired.
+
+**Usage:**
+```bash
+# Basic command
+adom-desktop fusion_electron_run '{"command": "WINDOW FIT"}'
+
+# Multiple commands in sequence (use semicolons)
+adom-desktop fusion_electron_run '{"command": "DISPLAY NONE; DISPLAY 1 16 17 18 20 21"}'
+
+# Navigate in library editor
+adom-desktop fusion_electron_run '{"command": "EDIT SOIC8.pac"}'
+adom-desktop fusion_electron_run '{"command": "EDIT RESISTOR.sym"}'
+adom-desktop fusion_electron_run '{"command": "EDIT MYDEVICE.dev"}'
+```
+
+**Response fields:**
+- `activeWorkspace` — Current workspace (Schematic Editor, PCB Editor, Electronics Library)
+- `activeDocument` — Name of the open document
+- `commandType` — Detected type: `view_control`, `layer_control`, `edit`, `design_rule`, etc.
+- `editorType` — For EDIT commands: `package`, `symbol`, `device`
+- `fusionOperations` — Array of Fusion operations that fired (diff of internal log)
+- `hint` — Human-readable description of what happened
+- `rawResult` — Raw return from Electron.run (usually empty)
+
+**EAGLE Command Reference — Safe for automation:**
+
+| Command | Context | Description |
+|---------|---------|-------------|
+| **View / Navigation** | | |
+| `WINDOW FIT` | Any | Zoom to fit all content |
+| `WINDOW (x1 y1 x2 y2)` | Any | Zoom to specific area (coordinates in current units) |
+| `DISPLAY ALL` | Any | Show all layers |
+| `DISPLAY NONE` | Any | Hide all layers |
+| `DISPLAY 1 16 17 18 20 21` | Board | Show specific layers by number |
+| **Grid** | | |
+| `GRID MM 0.1` | Any | Set grid to 0.1mm |
+| `GRID MIL 25` | Any | Set grid to 25mil |
+| `GRID INCH 0.05` | Any | Set grid to 0.05 inch |
+| **Board Layout** | | |
+| `RATSNEST` | Board | Recalculate airwires (unrouted connections) |
+| `RIPUP` | Board | Remove all routed traces |
+| `RIPUP *` | Board | Remove all traces (same as RIPUP with no selection) |
+| `ROUTE` | Board | Start auto-router |
+| `DRC` | Board | Run design rule check |
+| `BOARD` | Schematic | Switch to paired board layout |
+| `SCHEMATIC` | Board | Switch to paired schematic |
+| **Schematic** | | |
+| `VALUE value` | Schematic | Set component value |
+| `NAME name` | Any | Rename selected element |
+| `SMASH` | Any | Detach name/value labels from components |
+| **Library Editor** | | |
+| `EDIT name.pac` | Library | Open a package (footprint) for editing |
+| `EDIT name.sym` | Library | Open a symbol for editing |
+| `EDIT name.dev` | Library | Open a deviceset for editing |
+| `EXPORT SCRIPT 'path.scr'` | Library | Export entire library as EAGLE script |
+| **Scripting / Settings** | | |
+| `SET CONFIRM YES` | Any | Suppress confirmation dialogs |
+| `SET CONFIRM OFF` | Any | Re-enable confirmation dialogs |
+| `SCRIPT 'path.scr'` | Any | Run batch commands from a .scr script file |
+
+**EAGLE Layer Numbers (commonly used):**
+
+| Layer | Name | What it shows |
+|-------|------|--------------|
+| 1 | Top | Top copper |
+| 16 | Bottom | Bottom copper |
+| 17 | Pads | Through-hole pads |
+| 18 | Vias | Via holes |
+| 19 | Unrouted | Airwires (ratsnest) |
+| 20 | Dimension | Board outline (required for 3D) |
+| 21 | tPlace | Top silkscreen |
+| 22 | bPlace | Bottom silkscreen |
+| 25 | tNames | Top component names |
+| 27 | tValues | Top component values |
+| 29 | tStop | Top solder mask |
+| 31 | tCream | Top stencil/paste |
+| 51 | tDocu | Top documentation |
+
+**Blocking commands — AVOID from automation:**
+
+| Command | Why it blocks |
+|---------|---------------|
+| `WRITE` | Opens Save As dialog — use `fusion_save_lbr` or `fusion_close_document` instead |
+| `ADD` | Opens component picker dialog |
+| `SHOW name` | Opens interactive highlight mode |
+| `SET` (no params) | Opens settings dialog |
+| `GRID` (no params) | Opens grid settings dialog |
+| `EDIT new.sym` | Opens "Create new?" confirmation if symbol doesn't exist |
+| `CHANGE` | Opens interactive change mode |
+| `MOVE` | Opens interactive move mode |
+
+**Tips:**
+- Always run `WINDOW FIT` after opening a file or switching views
+- Use `DISPLAY NONE` then `DISPLAY <layers>` to show only specific layers
+- Combine commands with `;` — e.g., `SET CONFIRM YES; RIPUP *; RATSNEST`
+- For board screenshots: `DISPLAY NONE; DISPLAY 1 16 17 18 20 21; WINDOW FIT`
+- Use `fusion_board_info` instead of EAGLE commands when you need structured data
+- `fusion_export_lbr` -- Export the open Electronics library as an EAGLE .scr script (note: does NOT include 3D package references — those are cloud-linked only)
+- `fusion_save_lbr` -- Save the open Electronics library as a .flbr file
+
+#### EAGLE Libraries with 3D Packages
+
+Fusion 360's .lbr format supports `package3d` elements that link footprints to 3D models. Key facts:
+
+- **3D models are cloud-hosted** — each `package3d` has a `wip_urn` (e.g., `urn:adsk.wipprod:fs.file:vf.xxxxx`) pointing to a Fusion cloud document. You cannot embed STEP files directly in .lbr XML.
+- **Creating 3D packages requires the Fusion UI** — use `Package3DCreateCmd` in Electronics Library Editor, which opens a new Design workspace where you model/import the 3D shape, then save to link it.
+- **`EXPORT SCRIPT` strips 3D references** — the .scr export only contains 2D data (symbols, footprints, devicesets). To preserve 3D links, keep the .lbr XML format.
+- **Fusion's built-in examples** have 3D packages — 34 of 37 libraries in the EAGLE examples directory (e.g., `Connector_USB.lbr`, `Resistor.lbr`, `Capacitor.lbr`) include `package3d` references.
+- **Example libraries location**: `%LOCALAPPDATA%/Autodesk/webdeploy/production/<hash>/Applications/Electron/LibEagle/examples/libraries/examples/`
+
+To open a built-in example library for reference:
+```bash
+# Find the examples directory first
+adom-desktop fusion_execute_text_command '{"command": "Python.RunScript C:/tmp/find_eagle_libs.py"}'
+# Then open one
+adom-desktop fusion_open_lbr '{"filePath": "<path>/Connector_USB.lbr"}'
+```
+- `fusion_open_schematic` -- Open a .sch schematic in Fusion's Schematic Editor. Args: `filePath`.
+- `fusion_open_board` -- Open a .brd board layout in Fusion's Board Layout editor. Args: `filePath`.
+- `fusion_show_3d_board` -- Switch to 3D PCB board view (must have a .brd open). Board MUST have an outline on layer 20 (Dimension) or 3D generation fails. Auto-zooms to fit after switching.
+- `fusion_show_2d_board` -- Switch back to 2D board layout from 3D PCB view. EAGLE commands via `fusion_electron_run` only work in 2D.
+- `fusion_close_document` -- Close a document without save dialog. Args: `name` (optional, defaults to active doc), `save` (optional, default false). Essential for automation — avoids modal save dialog that blocks Fusion.
+- `fusion_document_info` -- List ALL open documents/tabs with name, type, and active status, plus detailed cloud info for the active document. Returns `openDocuments` array (every tab) and active doc details (cloud project, folder, file ID, version, save status). Lightweight — uses only in-memory data, no cloud API calls. Use this instead of `fusion_walk_cloud_tree` when you just need to know what's open.
+- `fusion_activate_document` -- Switch to a specific open document tab. Args: `name` (substring match, case-insensitive), `documentType` ("Electronics", "PCB", "FusionDesign", "Drawing"). Essential for automation — switch between schematic, board, and library tabs without user interaction. If no match found, returns the list of open documents so you can refine.
+- `fusion_close` -- Close Fusion 360. **Always call this when done with Fusion 360 commands to clean up.**
+- `fusion_dismiss_recovery` -- Dismiss recovery document dialogs (both "Recovered Documents" list and "Open recovery document instead?" prompts). Also relocates recovery files to `~/.adom/recovery/fusion/` for safekeeping.
+- `fusion_relocate_recovery` -- Proactively move Fusion crash recovery files to `~/.adom/recovery/fusion/<timestamp>/` without dismissing any dialogs. Call this **before** launching Fusion to prevent recovery dialogs from appearing. Files are preserved (not deleted) so the user can manually restore them if needed.
+- `fusion_close_all_documents` -- Close all open documents. Args: `saveChanges` (default: false). Use before force-killing Fusion to prevent recovery files.
+
+#### Fusion 360 UI Interaction
+
+Commands for interacting with Fusion 360's UI — detecting dialogs, taking screenshots, clicking, and sending keyboard input. Essential for handling blocking dialogs and automating CEF-based UI elements.
+
+- `fusion_window_info` -- Returns the Fusion main window HWND, title, rect, and a list of all Qt dialog windows (recovery dialogs, wizards, file pickers). Essential for detecting blocking dialogs before/after operations.
+  - Example: `adom-desktop fusion_window_info`
+  - Returns: `{hwnd, title, rect, dialogs: [{hwnd, title, className, rect}]}`
+
+- `fusion_screenshot_fusion` -- Captures the Fusion main window or a specific dialog by HWND. Uses PrintWindow (works without bringing to foreground). Saves WebP to `C:/tmp/adom-desktop-screenshots/` (falls back to PNG if WebP unavailable). Downscaled to ≤1568px — use relative coords (0.0-1.0) for clicking, they're scale-independent. DPI-aware.
+  - Args: `hwnd` (optional — dialog HWND to screenshot instead of main window)
+  - Example: `adom-desktop fusion_screenshot_fusion` (main window)
+  - Example: `adom-desktop fusion_screenshot_fusion '{"hwnd": 12345}'` (specific dialog)
+
+- `fusion_screenshot_all` -- Screenshots the main Fusion window and lists all dialog windows with their HWNDs. Use `fusion_screenshot_fusion {"hwnd": ...}` to capture each dialog.
+  - Example: `adom-desktop fusion_screenshot_all`
+
+- `fusion_click_fusion` -- Click at coordinates within the Fusion window or a specific dialog. x/y are relative (0.0-1.0) by default. Set `"relative": false` for pixel offsets. Uses SendInput for CEF dialog compatibility. **Prefer `fusion_send_key` for dialogs** — enter/escape/tab covers most cases.
+  - Args: `x` (required), `y` (required), `relative` (optional, default true), `hwnd` (optional — target a specific dialog HWND instead of main window)
+  - Example: `adom-desktop fusion_click_fusion '{"x": 0.5, "y": 0.7}'` (main window)
+  - Example: `adom-desktop fusion_click_fusion '{"hwnd": 12345, "x": 0.75, "y": 0.85}'` (dialog button)
+
+- `fusion_send_key` -- Send keyboard input to Fusion or a specific dialog via SendInput. **Preferred way to dismiss dialogs**: enter to confirm, escape to cancel, tab to cycle buttons. Use click only if tab order doesn't reach the right button.
+  - Args: `key` (required), `hwnd` (optional — target a specific dialog HWND)
+  - Example: `adom-desktop fusion_send_key '{"key": "escape"}'` (dismiss CEF dialog)
+  - Example: `adom-desktop fusion_send_key '{"key": "enter", "hwnd": 12345}'` (confirm dialog)
+
+#### Fusion 360 Workflow Guide
+
+##### Opening Electronics Projects
+1. Open the `.fprj` file: `adom-desktop fusion_open_cloud_file '{"projectName":"Main","fileName":"DRV8411A","fileExtension":"fprj","folderPath":"Molecules/XRP/DRV8411A"}'`
+2. Do NOT try to open `.fbrd` or `.fsch` directly — they fail or show a "Select Electronics Design File" dialog
+3. Enter the board editor: `adom-desktop fusion_show_2d_board`
+4. Enter the schematic editor: After step 3, use `adom-desktop fusion_electron_run '{"command":"EDIT .sch"}'`
+5. Switch back to board: `adom-desktop fusion_electron_run '{"command":"EDIT .brd"}'`
+
+##### Auto-Screenshot on Open Commands
+
+**Open commands automatically screenshot Fusion and return the images in the response.** The following commands include `postOpenScreenshot` in their `data` field:
+- `fusion_open_cloud_file`, `fusion_open_schematic`, `fusion_open_board`, `fusion_show_3d_board`, `fusion_show_2d_board`
+
+The response `data.postOpenScreenshot` contains:
+- `screenshots[]` — array of `{type, savedTo, sizeKB, title?, hwnd?}`. Type is `"main_window"` or `"dialog"`.
+- `message` — human-readable instruction to READ each screenshot and check for blocking dialogs
+- `dialogBlocking` — `true` if a modal dialog was detected
+
+**After receiving the response, you MUST:**
+1. **READ each screenshot file** using the Read tool to visually inspect what Fusion shows
+2. Look for blocking dialogs in the screenshots:
+   - "What to design?" wizard → dismiss with `fusion_send_key {"key": "escape"}`
+   - "PCB out of date" banner → click Update or X
+   - "Recovered Documents" → `fusion_dismiss_recovery`
+   - "Save changes?" → `fusion_send_key {"key": "escape"}`
+   - Any other modal → identify and dismiss
+3. **Screenshot again** after dismissing to confirm it's clear
+
+Screenshots are saved as WebP (lossless, ~20-40KB each, downscaled to ≤1568px) for token efficiency. Both the main Fusion window AND all Qt dialog windows are captured separately.
+
+**Why this matters**: Fusion shows Qt dialogs and CEF overlays that are invisible to the API. The `success: true` response from an open command does NOT mean the UI is ready — a dialog may be blocking all further operations. The API cannot detect these. Only a screenshot can.
+
+##### Detecting and Handling Blocking Dialogs
+- The auto-screenshot captures dialog windows separately (look for `type: "dialog"` entries in `postOpenScreenshot.screenshots[]`)
+- Common blocking dialogs: "What do you want to design?", "Recovered Documents", "Open recovery document instead?", "Select Electronics Design File"
+- Dismiss recovery dialogs: `adom-desktop fusion_dismiss_recovery`
+- Dismiss CEF dialogs (inside main window): `adom-desktop fusion_send_key '{"key": "escape"}'` or `fusion_click_fusion`
+- Dismiss "What do you want to design?" wizard: `adom-desktop fusion_send_key '{"key": "escape"}'`
+- For manual screenshot of specific dialogs: `adom-desktop desktop_screenshot_window '{"hwnd": <DIALOG_HWND>}'`
+
+##### Preventing Recovery Documents
+- Recovery files are at: `%LOCALAPPDATA%\Autodesk\Autodesk Fusion 360\<USER_ID>\CrashRecovery\`
+- Before force-killing Fusion, close all documents: `adom-desktop fusion_close_all_documents '{"saveChanges": false}'`
+- **Best practice**: Call `adom-desktop fusion_relocate_recovery` before launching Fusion. This moves recovery files to `~/.adom/recovery/fusion/<timestamp>/` — preserving them for the user while preventing modal dialogs.
+- The `fusion_start` command also auto-relocates recovery files before starting Fusion.
+- The `fusion_dismiss_recovery` command handles both "Recovered Documents" list and "Open recovery document instead?" prompts, and also relocates files.
+
+##### EAGLE Export Limitations
+- **Supported**: `EXPORT IMAGE`, `EXPORT NETLIST`, `EXPORT PARTLIST`
+- **NOT supported** (fail silently): `EXPORT DXF`, `EXPORT SVG`, `EXPORT DRILL`
+- Always verify export output: the updated `fusion_electron_run` now checks if the output file was created
+- Use `fusion_export_bom` and `fusion_export_cpl` for manufacturing data (these use the add-in's XML parser, not EAGLE export)
+- Use `fusion_export_gerbers` for Gerber files — produces a ZIP with all gerber layers (GTL, GBL, GTS, GBS, GTP, GBP, GTO, GBO, GKO, XLN). Auto-detects 2-layer vs 4-layer boards.
+
+##### 3D Model Exports
+- From the 3D view (activate the .f3d document): `fusion_export_step`, `fusion_export_stl`, `fusion_export_3mf`, `fusion_export_f3d`, `fusion_export_usdz`, `fusion_export_iges`, `fusion_export_sat`
+- Switch to 3D view: `fusion_show_3d_board` or activate the .f3d document
+- **STEP** — Industry-standard CAD interchange (SolidWorks, CATIA, Creo). Highest geometric fidelity.
+- **IGES** — Legacy CAD interchange. Use STEP for modern workflows.
+- **SAT** — ACIS solid model format. Used by SolidWorks, SpaceClaim.
+- **STL** — Mesh format for 3D printing and visualization. Options: refinement "low"/"medium"/"high".
+- **3MF** — Modern 3D printing with color/material support and multi-body.
+- **F3D** — Native Fusion 360 archive. Preserves parametric features, sketches, timeline, component refs. Best for archival.
+- **USDZ** — **Best for digital twins and GLB conversion.** Preserves full component hierarchy (Board, copper layers, soldermask, Packages), PBR materials, and named nodes. Each component becomes a toggleable node in GLB viewers. Also viewable directly on iOS/macOS (Apple Quick Look / AR).
+  - Digital twin pipeline: `fusion_export_usdz` → pull_file → `blender --background --python-expr "import bpy; bpy.ops.wm.usd_import(filepath='board.usdz'); bpy.ops.export_scene.gltf(filepath='board.glb')"`
+- **FBX** — Not available in current Fusion builds via the API. The command exists but fails clearly. Use `fusion_export_usdz` instead.
+- **DXF/DWG/OBJ/SKP** — Not available via the Fusion API (dialog-only). Commands exist but return clear errors with alternatives.
+
+##### 3D Viewport Captures
+- `fusion_take_screenshot` — Capture the Fusion viewport at any resolution without opening a dialog. Uses Fusion's render API (`saveAsImageFile`).
+  - Args: `outputPath` (required), `width` (default 1920), `height` (default 1080), `orientation` (optional)
+  - Orientations: `home`, `front`, `back`, `top`, `bottom`, `left`, `right`
+  - Example: Capture all 6 standard views for use as product images/icons:
+    ```bash
+    for orient in home front back top bottom left right; do
+      adom-desktop fusion_take_screenshot "{\"outputPath\": \"C:/tmp/3d-${orient}.png\", \"width\": 1920, \"height\": 1080, \"orientation\": \"${orient}\"}"
+    done
+    ```
+
+##### Electronics Source File Export (`fusion_export_source`)
+- `fusion_export_source` — Export the active electronics document as `.fsch`, `.fbrd`, or `.flbr` source file.
+  - Args: `outputPath` (required — full path with extension)
+  - The extension determines the format: `.fsch` (schematic), `.fbrd` (board), `.flbr` (library)
+  - Validates extension, creates output directory, verifies file was created, returns file size
+
+- **Full workflow to export both board and schematic from a cloud project:**
+  ```bash
+  # Step 1: Open the .fprj (NOT .fbrd/.fsch directly — those fail)
+  adom-desktop fusion_open_cloud_file '{"projectName":"Main", "fileName":"MyDesign", "fileExtension":"fprj", "folderPath":"Molecules/MyDesign"}'
+
+  # Step 2: Screenshot to check for blocking dialogs (always do this after open)
+  adom-desktop fusion_screenshot_fusion
+
+  # Step 3: Enter board view (MUST be Board Layout workspace, not 3D)
+  adom-desktop fusion_show_2d_board
+
+  # Step 4: Export .fbrd (board first — it's already in board view)
+  adom-desktop fusion_export_source '{"outputPath": "C:/tmp/exports/MyDesign.fbrd"}'
+
+  # Step 5: Switch to schematic (EDIT .s1 = first schematic sheet)
+  adom-desktop fusion_electron_run '{"command": "EDIT .s1"}'
+
+  # Step 6: Export .fsch
+  adom-desktop fusion_export_source '{"outputPath": "C:/tmp/exports/MyDesign.fsch"}'
+
+  # Step 7: Pull files to Docker
+  adom-desktop pull_file '{"filePaths":["C:/tmp/exports/MyDesign.fbrd","C:/tmp/exports/MyDesign.fsch"], "saveTo":"/tmp/exports"}'
+  ```
+
+- **Critical gotchas:**
+  - You MUST open the `.fprj` first — opening `.fbrd`/`.fsch` directly fails or triggers blocking dialogs
+  - You MUST call `fusion_show_2d_board` before exporting `.fbrd` (the Electronics Design overview won't work)
+  - Always export `.fbrd` FIRST (from board view), then switch to schematic for `.fsch`
+  - If export fails with "file not found", the wrong workspace is active — screenshot to verify
+  - After `fusion_open_cloud_file`, always screenshot to check for "Select Electronics Design File" dialog
+  - `WRITE` command is blocked — it opens a blocking save dialog. Use `fusion_export_source` instead
+
+- Note: `WRITE` is blocked — it opens a "Version Description" dialog on cloud docs even with a path argument (tested 2026-04-10). Use `fusion_save_to_cloud` to save to cloud, `fusion_export_source` for Fusion-format, or `fusion_export_eagle_source` for plain EAGLE format.
+
+##### Plain EAGLE Source Export (`fusion_export_eagle_source`)
+- `fusion_export_eagle_source` — Export the active electronics document as plain EAGLE XML `.sch` or `.brd`.
+  - Args: `outputPath` (required — full path ending in `.sch` or `.brd`)
+  - Internally: exports `.fsch`/`.fbrd` via `Document.CopyToDesktop`, then extracts the EAGLE XML from the ZIP container, cleans up the temp file.
+  - The output is valid EAGLE XML (`<?xml><eagle version="9.7.0">...`) parseable by standalone EAGLE, KiCad import, or any XML tool.
+  - Same workflow/prerequisites as `fusion_export_source` — just use `.sch`/`.brd` extensions instead of `.fsch`/`.fbrd`.
+
+  ```bash
+  # Board (.brd) — must be in PCB Editor / Board Layout
+  adom-desktop fusion_show_2d_board
+  adom-desktop fusion_export_eagle_source '{"outputPath": "C:/tmp/exports/MyDesign.brd"}'
+
+  # Schematic (.sch) — must be in Schematic Editor
+  adom-desktop fusion_electron_run '{"command": "EDIT .s1"}'
+  adom-desktop fusion_export_eagle_source '{"outputPath": "C:/tmp/exports/MyDesign.sch"}'
+  ```
+
+##### Dialog Dismissal (`fusion_close_window`)
+- `fusion_close_window` — Close a specific Fusion dialog by sending WM_CLOSE (equivalent to clicking X).
+  - Args: `hwnd` (required — from `fusion_window_info` or `fusion_dismiss_blocking_dialogs` remaining[])
+  - Works on dialogs that Escape doesn't close — e.g. "Recovered Documents"
+  - Does NOT force-kill — the dialog can still intercept WM_CLOSE
+
+##### Electronics Import (Round-Trip)
+- `fusion_import_electronics` — Import Fusion-native `.fsch`, `.fbrd`, or `.flbr` files as new local documents
+  - Uses `Document.newDesignFromLocal` under the hood
+  - Auto-screenshots after import (catches blocking dialogs)
+  - Args: `filePath` (required Windows path to .fsch, .fbrd, or .flbr)
+  - After import, use `fusion_save_to_cloud` to persist to Fusion cloud
+  - **`.fsch` import works standalone** — creates a new schematic project
+  - **`.fbrd` import may fail** — boards have external references to schematics/libraries. Error: "New design cannot be created from a local file containing external references"
+  - **`.flbr` import works standalone** — creates a new library project
+  - For legacy EAGLE files: use `fusion_open_schematic` (.sch), `fusion_open_board` (.brd), `fusion_open_lbr` (.lbr)
+
+##### Library Round-Trip Workflow
+Export and re-import Fusion electronics libraries:
+```bash
+# 1. Open library from cloud
+adom-desktop fusion_open_cloud_file '{"projectName":"Main","fileName":"Adom Common Components","folderPath":"Molecules/Libraries"}'
+
+# 2. Export as .flbr (Fusion-native binary) and .scr (EAGLE script text)
+adom-desktop fusion_save_lbr '{"outputPath":"C:/tmp/library.flbr"}'
+adom-desktop fusion_export_lbr '{"outputPath":"C:/tmp/library.scr"}'
+adom-desktop fusion_close_document
+
+# 3. Re-import the .flbr
+adom-desktop fusion_import_electronics '{"filePath":"C:/tmp/library.flbr"}'
+
+# 4. Verify symbols survived round-trip
+adom-desktop fusion_export_lbr '{"outputPath":"C:/tmp/library-verify.scr"}'
+
+# 5. Save to cloud with new name
+adom-desktop fusion_save_to_cloud '{"name":"Library-copy"}'
+```
+
+##### Demo Projects
+171+ electronics molecules are exported in the `adom-desktop-demo` repo (separate from adom-desktop).
+Three reference boards with full export formats:
+
+| Board | Cloud Path | Layers | Components | Files |
+|-------|-----------|--------|------------|-------|
+| DRV8411A | Main / Molecules / XRP / DRV8411A | 4 | 29 | 27 |
+| DRV8323SR | Main / Molecules / Experiments / MotorControl / DRV8323SR | 2 | 43 | 27 |
+| VL53L8BreakoutMolecule | Main / Molecules / XRP / TimeOfFlightVL53L8 / VL53L8BreakoutMolecule | 2 | 30 | 27 |
+
+Each folder contains: .fsch, .fbrd, bom.csv, cpl.csv, gerbers.zip, 6 board images, 7 3D renders (home/front/back/top/bottom/left/right), STEP, IGES, SAT, STL, 3MF, F3D, USDZ, board screenshot, 3D board screenshot.
+
+#### Cloud Document Management
+
+Manage Fusion 360 cloud documents (hub projects, files, versions). Required for 3D package workflows since EAGLE library 3D models are stored as cloud documents.
+
+- `fusion_save_to_cloud` -- Save the active Fusion document to the cloud. Args: `name` (required), `projectName` (optional, defaults to active project), `folderPath` (optional), `description` (optional). Returns: `fileId`, `versionNumber`, `wipUrn` (if cloud-hosted).
+- `fusion_list_cloud_projects` -- List all cloud projects in the user's hub. Returns array of `{name, id}` per hub.
+- `fusion_list_cloud_files` -- List files in a cloud project/folder. Args: `projectName` (optional), `folderPath` (optional). Returns: `files` array with `{name, id, versionNumber, fileExtension, dateModified}` and `subfolders` array.
+- `fusion_create_cloud_folder` -- Create a folder in a cloud project. Args: `folderName` (required), `projectName` (optional), `parentPath` (optional). Returns `folderId`. Idempotent — returns existing folder if it already exists.
+- `fusion_check_recovery` -- Check if a cloud file has a recovery document from a previous crash. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional). Returns `hasRecovery: true/false`. Use this BEFORE opening files to avoid the blocking "Open recovery document instead?" dialog. If recovery exists, call `fusion_open_cloud_file` with `recovery: "open"` (restore unsaved work) or `recovery: "discard"` (delete recovery, open cloud version).
+- `fusion_open_cloud_file` -- Open a cloud file in Fusion by name. **If a recovery document exists and no `recovery` arg is given, the command STOPS and reports the recovery instead of opening** — you must decide whether to preserve or discard unsaved work. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional), `recovery` ("open" = restore unsaved work, "discard" = delete recovery and open cloud version).
+- `fusion_export_cloud_file` -- Export the active Fusion document to a local file for transfer back to Docker. Args: `outputPath` (required), `format` (optional, default "step"). The exported file can then be pulled back to Docker via `pull_file`.
+- `fusion_delete_cloud_file` -- Delete a cloud file by name. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional). File must not be open in Fusion — close it first with `fusion_close_document`.
+- `fusion_walk_cloud_tree` -- **Long-running.** BFS walk of a cloud folder tree. Returns a flat list of all files and folders. Runs entirely on the Fusion main thread — **blocks all other add-in commands** until done (check progress with `fusion_addin_status`).
+  - Args: `projectName` (optional), `folderPath` (optional, starting folder), `maxDepth` (default 10), `maxFolders` (default 500), `extensions` (optional list, e.g. `["f3d","fprj"]`), `nameContains` (optional substring filter), `includeFiles` (default true)
+  - Returns: `{project, rootFolder, folders[], files[], stats: {foldersVisited, foldersSkipped, filesFound, maxDepthReached, truncated}}`
+  - **Per-folder timeout (30s):** If a single folder's cloud API calls take >30s (common for large projects), the folder is skipped and counted in `foldersSkipped`. This prevents indefinite hangs.
+  - **Progress tracking:** While running, `fusion_addin_status` returns `walkProgress` with `{foldersVisited, filesFound, currentFolder, queueSize}` — poll this every 1–10s to monitor progress (see "Live folder progress streaming" below).
+  - **Non-blocking alternative:** Use `fusion_search_cloud_files` for targeted searches, or `fusion_list_cloud_files` for single-folder listings (these are faster but don't recurse).
+  - Example: `adom-desktop fusion_walk_cloud_tree '{"projectName":"Main","folderPath":"Molecules","nameContains":"DRV","extensions":["fprj"]}'`
+
+#### Live folder progress streaming with `watch`
+
+For long walks, use the built-in `watch` wrapper. It spawns the inner search command on a worker thread, polls `fusion_addin_status` internally, and emits one JSON event per line to stdout as the walker visits each folder. **No manual polling loop needed.**
+
+```bash
+adom-desktop watch '{"command":"fusion_walk_cloud_tree","args":{"projectName":"Main","folderPath":"Molecules","nameContains":"BQ25792"}}'
+```
+
+Output is one JSON object per line, three event types:
+
+```jsonl
+{"event":"started","command":"fusion_walk_cloud_tree","args":{...},"interval":2,"_hint":"streaming progress events follow, one per line, ending with 'complete' or 'error'"}
+{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":15.0,"foldersVisited":14,"queueSize":50,"filesFound":0,"currentFolder":"Molecules/Sensing"}
+{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":23.4,"foldersVisited":22,"queueSize":108,"filesFound":0,"currentFolder":"Molecules/Examples"}
+{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":31.8,"foldersVisited":30,"queueSize":100,"filesFound":0,"currentFolder":"Molecules/RAPID NAME TAGS/Connor Wood"}
+... (one per real change in walkProgress, deduped) ...
+{"event":"complete","result":{"folders":[...],"files":[...],"stats":{"foldersVisited":169,"filesFound":12,"truncated":false}}}
+```
+
+Read stdout line-by-line. Stop when you see `event:complete` or `event:error`. The full final result is in the `complete` event's `result` field.
+
+**Optional `interval` arg** (default 2s, clamped to [1, 30]):
+
+```bash
+adom-desktop watch '{"command":"fusion_walk_cloud_tree","args":{...},"interval":1}'
+```
+
+**For Claude Code consumers**: pipe `watch` directly into the `Monitor` tool. Each JSON line becomes a real-time event notification in the chat — you (and the user) see folder names appear one-by-one as the walker visits them. No bash loop, no `disown`, no subprocess gymnastics. Verified live in 1.3.16 on a 169-folder walk that returned all 12 BQ25792 cloud files cleanly.
+
+**Watchable commands** (whitelist): `fusion_walk_cloud_tree`, `fusion_search_cloud_files`. Other commands return `success:false` with a `_hint` listing the watchable set.
+
+**When NOT to use watch**: short single-folder operations (`fusion_list_cloud_files`, `fusion_open_cloud_file`) — those return in <2s and don't need streaming. The `watch` wrapper is purely for the long BFS commands.
+- `fusion_search_cloud_files` -- **Long-running.** Recursive substring search on file names across cloud folders. v1.0.2+ of the add-in adds: per-folder timeout, doEvents() every 50 files, per-file try/except, iterative BFS, and a stale-lock watchdog — Fusion stays responsive throughout (no "Not Responding" freeze).
+  - **Args (no hard upper caps in v1.0.2+):** `query` (required, substring, case-insensitive), `projectName` (optional, defaults to active), `folderPath` (optional starting subfolder — **narrow with this**), `recursive` (default false), `maxDepth` (default 2), `maxFolders` (default 10), `maxResults` (default 20), `folderTimeout` (default 30s), `searchTimeout` (default 120s), `timeout` (HTTP envelope, default 620s).
+  - **Before calling:** if the user knows roughly where the file is (e.g. customer-named folder), **ask them**. Naming a subfolder cuts 5-40 min searches down to ~10s.
+  - **Why slow:** Autodesk's free Fusion 360 Python API has no indexed file-search endpoint. We walk the Data API one folder at a time — each = one HTTP round-trip to Autodesk. **This is an Autodesk API limitation, not adom-desktop's.** When telling the user the search is taking a while, attribute it to Autodesk / Fusion 360, not to adom-desktop's bridge. **There is now a FAST native alternative — `aps_search` (see "APS — fast indexed cloud search" below).** It hits Autodesk Platform Services' indexed Data Management API and returns ~instantly, even when Fusion is CLOSED. Call `aps_status` first; if `configured:true`, prefer `aps_search` and only fall back to this folder-walking verb when APS isn't set up.
+  - **Interpreting results (critical for no-false-negatives):** the response contains `searchComplete` (bool), `foldersSkipped`, `filesSkipped`, `truncated`, `folderLimitReached`, `searchTimedOut`. `searchComplete:true` ONLY when **all five** are clean — that's the confidence flag. If `searchComplete:false`, the search hit a cap before exhausting the scope; **don't report "file not found"** — re-run with broader caps OR narrower `folderPath`.
+  - **Case sensitivity:** fully case-insensitive both directions (query and file names lowercased). Substring match, not whole-word — `"cosm"` matches `COSMIIC`, `COSMOCOIL`, etc.
+  - **Cost feedback in response:** `costAnalysis: {elapsedSeconds, foldersPerSecond, estimatedSecondsPer100Folders}` so the AI can budget the next search realistically.
+  - **Real numbers from a live test:** searching `Main/Molecules` recursively (173 folders deep, max 8 depth) for "cosmiic" returned in 168 s with `searchComplete:true, totalFound:3`. Fusion stayed `main_thread:responsive` throughout.
+  - **Pair with `watch`** for streaming progress updates: see below.
+
+#### APS — fast indexed cloud search (v1.8.182+)
+
+`aps_search` is a **native, ~instant** alternative to the folder-walking `fusion_search_cloud_files`. It queries **Autodesk Platform Services** (APS, formerly Forge) **Data Management API**, which has a real server-side index — so a search that takes minutes by folder-walking returns in well under a second, and it works **even when Fusion 360 is closed** (it's pure HTTPS, no bridge required). adom-desktop runs its own 3-legged **PKCE** OAuth, so the user's APS **client secret never leaves their machine**.
+
+**One-time setup (user side):**
+1. Register a free app at <https://aps.autodesk.com> → enable the **Data Management API**.
+2. Set the app's **Callback URL** to exactly `http://localhost:8910/aps/callback`.
+3. Put the **Client ID** in AD config key `aps_client_id` (in `~/.adom/config.json`) or via Settings.
+4. Run `aps_auth` once — it opens the system browser for Autodesk sign-in. Tokens cache in `~/.adom/aps_tokens.json` and auto-refresh; you won't be prompted again.
+
+**Verbs:**
+- `aps_status` — **call this first.** Non-prompting. Returns `{configured, signedIn}`. `configured:false` → no client id; use `fusion_search_cloud_files`. `configured:true, signedIn:false` → run `aps_auth`. Both true → `aps_search` is ready.
+- `aps_auth` — interactive sign-in (opens browser). One-time per device.
+- `aps_hubs` — `{hubs[]{id,name}}` (your Autodesk teams).
+- `aps_projects {hubId?}` — `{hubId, projects[]{id,name}}` (default: first hub).
+- `aps_search {query, projectName?, projectId?, hubId?}` — `{query, hubId, count, results[]{name,id,type,lastModified,project}}`. `query` is a case-insensitive substring of the file's displayName. Without `projectName`/`projectId` it searches up to 8 projects in the hub. Open a hit with `fusion_open_cloud_file {fileName, projectName}`.
+
+**Decision flow for an AI doing a cloud file search:** `aps_status` → if `configured` (sign in with `aps_auth` if needed) use `aps_search`; else fall back to `fusion_search_cloud_files` and tell the user that registering a free APS app (above) would make future searches instant.
+
+**Export formats for `fusion_export_cloud_file`:**
+
+| Format | Extension | Use case |
+|--------|-----------|----------|
+| `step` | .step | Industry-standard CAD interchange (default) |
+| `stl` | .stl | 3D printing, mesh-based |
+| `f3d` | .f3d | Fusion 360 native archive (preserves all features) |
+| `iges` | .iges | Legacy CAD interchange |
+| `sat` | .sat | ACIS solid modeling kernel format |
+| `smt` | .smt | Parasolid format |
+
+Also supported for import via `fusion_import_step`: STEP (.step/.stp), STL (.stl), IGES (.iges/.igs), SAT (.sat), SMT (.smt), OBJ (.obj), F3D (.f3d).
+
+**Round-trip: workspace → Fusion Cloud → workspace**
+
+```bash
+# === workspace to Fusion Cloud ===
+# 1. Send file from Docker to Windows desktop
+adom-desktop send_files '{"files": [{"path": "/home/user/component.step"}]}'
+# 2. Import into Fusion
+adom-desktop fusion_import_step '{"filePath": "C:/Users/john/Downloads/component.step"}'
+# 3. Save to cloud (gets a wip_urn for 3D library linking)
+adom-desktop fusion_save_to_cloud '{"name": "my-component", "projectName": "Personal"}'
+
+# === Fusion Cloud to Docker ===
+# 1. Find and open the cloud file
+adom-desktop fusion_walk_cloud_tree '{"projectName": "Personal", "nameContains": "my-component"}'
+adom-desktop fusion_open_cloud_file '{"fileName": "my-component", "projectName": "Personal"}'
+# 2. Export to local filesystem
+adom-desktop fusion_export_cloud_file '{"outputPath": "C:/tmp/export/my-component.step", "format": "step"}'
+# 3. Pull back to Docker
+adom-desktop pull_file '{"path": "C:/tmp/export/my-component.step"}'
+
+# === Cloud management ===
+adom-desktop fusion_list_cloud_projects '{}'
+adom-desktop fusion_create_cloud_folder '{"folderName": "Electronics", "projectName": "Main"}'
+adom-desktop fusion_list_cloud_files '{"projectName": "Main", "folderPath": "Electronics"}'
+adom-desktop fusion_delete_cloud_file '{"fileName": "old-file", "projectName": "Main"}'
+```
+
+#### Manufacturing Exports (Gerbers, BOM, CPL)
+
+Export manufacturing files from an open PCB board — everything needed to fabricate boards and assemble components via Adom's PCBA service. **All manufacturing commands require a .brd board open in PCB Editor** (use `fusion_open_board` or `fusion_show_2d_board`).
+
+**Recommended workflow order:** `detect_layers` → `set_design_rules` → DRC → `export_gerbers` → `export_bom` → `export_cpl` → `pull_file` all back to Docker.
+
+Every manufacturing command returns structured JSON with:
+- `message` — AI-oriented summary explaining what was produced and why it matters
+- `nextSteps[]` — ordered list of what to run next in the manufacturing pipeline
+- `hint` — tips for interpreting results or recovering from issues
+- `data` — structured metadata (paths, counts, layer info) for programmatic use
+
+**Decision guide — which command to run:**
+- **Don't know the layer count?** → Run `fusion_detect_layers` first
+- **Need to check if the board meets fab specs?** → Run `fusion_set_design_rules` then DRC
+- **Ready to generate fab files?** → Run `fusion_export_gerbers`, then `fusion_export_bom`, then `fusion_export_cpl`
+- **Need visual review before export?** → Run `fusion_export_board_image` with preset `assembly_top` or `fabrication`
+- **Something failed?** → Check `data.hint` and `data.recoverySteps` in the error response
+
+**Commands:**
+
+- `fusion_detect_layers` -- Detect if the open board is 2-layer or 4-layer. Uses ULP script (primary) and CAM comparison (fallback). Returns `layerCount`, `copperLayers[]`, `method` (how it was detected), and `nextSteps[]`. **Run this first** — all other manufacturing commands auto-detect layers too, but running this explicitly gives you the data before committing to exports.
+
+- `fusion_set_design_rules` -- Apply Adom's JLCPCB-derived design rules (.edru XML files) to the open board. Auto-detects 2-layer vs 4-layer and loads the appropriate rule set. Returns `description` (human-readable rule summary), `edruFile`, and `nextSteps[]`.
+  - `action`: `"apply"` (default) loads rules into the board; `"export"` saves current board rules to a file; `"show"` displays rule capabilities without modifying anything
+  - `layers`: `"auto"` (default), `"2"`, or `"4"` — force a specific rule set
+  - After applying: run `fusion_electron_run '{"command": "DRC"}'` to check for violations. DRC markers appear in the board editor.
+
+- `fusion_apply_instapcb_rules` -- Convenience wrapper that applies the bundled Adom InstaPCB design rule sets without needing a local `.edru` file. Args: `layers` (`"2"` or `"4"`). Sends the bundled `.edru` from Docker to Windows automatically and loads it via the EAGLE `drc load` command. Use this instead of `fusion_set_design_rules` when you just want Adom's standard 2-layer or 4-layer InstaPCB rules.
+
+- `fusion_load_design_rules` -- Generic loader for ANY custom `.edru` file by path. Args: `filePath`. Use this for third-party fab vendor rules (JLCPCB, PCBWay, OSHPark, etc.) that the user has on disk.
+
+- `fusion_export_gerbers` -- Export Gerber (RS-274X) + Excellon drill files as a ZIP. Auto-detects 2-layer vs 4-layer and selects the correct JLCPCB-compatible CAM job. Returns `zipPath`, `zipSizeKB`, `files[]` (list of gerber files in the ZIP with sizes), `layerCount`, and `nextSteps[]`.
+  - `outputDir`: directory for the output ZIP (default: `C:/tmp/adom-gerbers/`)
+  - `boardName`: prefix for the ZIP filename (default: from active document name)
+  - `layers`: `"auto"` (default), `"2"`, or `"4"` — force CAM job selection
+  - Output ZIP contains: GTL, GBL (copper), GTS, GBS (solder mask), GTP, GBP (paste), GTO, GBO (silkscreen), GKO (outline), XLN (drill). 4-layer boards also get G1, G2 (inner copper).
+
+- `fusion_export_bom` -- Export Bill of Materials as CSV. Groups identical parts by value+package with quantity counts. Returns `componentCount`, `uniquePartCount`, and `nextSteps[]`.
+  - `outputPath`: (default: `C:/tmp/adom-bom.csv`)
+  - `grouped`: `true` (default) groups by value+package; `false` lists every component individually
+  - Output columns: Comment, Designator, Footprint, Quantity, Library — compatible with JLCPCB, PCBWay, Mouser, Digi-Key.
+
+- `fusion_export_cpl` -- Export Component Placement List (pick-and-place) as CSV. Returns `totalPlacements`, `topCount`, `bottomCount`, and `nextSteps[]`.
+  - `outputPath`: (default: `C:/tmp/adom-cpl.csv`)
+  - `side`: `"all"` (default), `"top"`, or `"bottom"` — filter for single-sided assembly
+  - Output columns: Designator, Mid X, Mid Y, Layer, Rotation — coordinates in mm.
+
+- `fusion_export_board_image` -- Export PNG image of the board with layer presets. Returns `fileSize`, `preset`, and `nextSteps[]`.
+  - `outputPath`: (default: `C:/tmp/adom-board.png`)
+  - `dpi`: resolution (default: 300, max: 600)
+  - `preset`: layer preset name (see table below)
+  - `layers`: custom layer numbers as int array — overrides preset
+  - `monochrome`: `true` for black & white
+  - `listPresets`: set `true` to get available presets instead of exporting
+
+**Layer presets for `fusion_export_board_image`:**
+
+| Preset | Layers | Description |
+|--------|--------|-------------|
+| `all` | All | Every layer visible |
+| `top_copper` | 1, 17, 18 | Top copper + pads + vias |
+| `bottom_copper` | 16, 17, 18 | Bottom copper + pads + vias |
+| `top_silkscreen` | 21, 25 | Top silkscreen + component names |
+| `bottom_silkscreen` | 22, 26 | Bottom silkscreen + component names |
+| `top_soldermask` | 29 | Top solder mask openings |
+| `bottom_soldermask` | 30 | Bottom solder mask openings |
+| `top_paste` | 31 | Top paste/stencil openings |
+| `bottom_paste` | 32 | Bottom paste/stencil openings |
+| `board_outline` | 20 | Board outline (dimension layer) |
+| `drill` | 44, 45, 17, 18 | Drill holes + vias |
+| `assembly_top` | 1, 17, 18, 20, 21, 25, 51 | Top assembly — copper + silk + outline |
+| `assembly_bottom` | 16, 17, 18, 20, 22, 26, 52 | Bottom assembly — copper + silk + outline |
+| `fabrication` | 1, 16, 17–20, 21, 22, 25, 26, 29, 30, 51 | Full fabrication view |
+
+**Adom InstapcbPCB manufacturing capabilities (used by `fusion_set_design_rules`):**
+
+| Parameter | Metric | Imperial |
+|-----------|--------|----------|
+| Layers | 1, 2, 4, 6 | — |
+| Min trace width | 0.08mm | 3 mil |
+| Min trace spacing | 0.08mm | 3 mil |
+| Min via drill | 0.2mm | 8 mil |
+| Min silkscreen text | 0.1mm | 4 mil |
+| Board edge clearance | 0.05mm | 2 mil |
+| Board thickness | 1.6mm | 63 mil |
+| Copper weight | 0.5 oz (17.5 μm) | — |
+| Solder mask | Green | — |
+| Surface finish | HASL / ENIG | — |
+| Turnaround | 4 hours (fab + assembly) | — |
+
+**Manufacturing workflow — Fusion to Adom PCBA:**
+
+```bash
+# 1. Open the board
+adom-desktop fusion_open_board '{"filePath": "C:/projects/myboard.brd"}'
+
+# 2. Detect layer count (auto-selects 2-layer or 4-layer rules/CAM)
+adom-desktop fusion_detect_layers
+
+# 3. Apply Adom design rules for the detected layer count + run DRC
+adom-desktop fusion_set_design_rules '{"action": "apply"}'
+adom-desktop fusion_electron_run '{"command": "DRC"}'
+
+# 4. Export manufacturing files (gerbers auto-select correct CAM job)
+adom-desktop fusion_export_gerbers '{"outputDir": "C:/tmp/mfg"}'
+adom-desktop fusion_export_bom '{"outputPath": "C:/tmp/mfg/bom.csv"}'
+adom-desktop fusion_export_cpl '{"outputPath": "C:/tmp/mfg/cpl.csv"}'
+
+# 5. Export board images for review
+adom-desktop fusion_export_board_image '{"outputPath": "C:/tmp/mfg/top-copper.png", "preset": "top_copper"}'
+adom-desktop fusion_export_board_image '{"outputPath": "C:/tmp/mfg/assembly-top.png", "preset": "assembly_top"}'
+adom-desktop fusion_export_board_image '{"outputPath": "C:/tmp/mfg/board-outline.png", "preset": "board_outline"}'
+
+# 6. Pull files back to Docker for Adom PCBA ordering
+adom-desktop pull_file '{"path": "C:/tmp/mfg/bom.csv"}'
+adom-desktop pull_file '{"path": "C:/tmp/mfg/cpl.csv"}'
+```
+
+### Desktop Tools
+
+- `desktop_open_folder` -- Open a file or folder in Windows Explorer. If given a file path, Explorer opens with the file highlighted (selected). Args: `path` (file or folder path).
+- `desktop_open_url` -- Open a URL in the user's **NATIVE OS BROWSER** — i.e. the real Edge / Chrome / Firefox / Brave they use every day, with their saved logins, history, bookmarks, and extensions. This is NOT pup, NOT Chrome for Testing — it's the browser the human actually uses. Hand it off when the user needs to interact with a logged-in account themselves.
+  - Args: `url` (required string), `browser` (optional: `"default"` (Windows-registered default — could be Edge, Chrome, Firefox, Brave; whatever they set), `"chrome"`, `"edge"`, `"firefox"`, `"brave"`).
+  - Examples:
+    - `adom-desktop desktop_open_url '{"url":"https://claude.ai/"}'` -- opens in user's default native browser, already signed in
+    - `adom-desktop desktop_open_url '{"url":"https://docs.example.com","browser":"edge"}'` -- force native Edge
+  - Returns `{ok, browser, url, exePath?}`. Allowed schemes: http, https, mailto, ftp, ftps. Other schemes refused.
+
+#### `desktop_open_url` vs `browser_open_window` — two completely different browsers
+
+| | `desktop_open_url` | `browser_open_window` |
+|---|---|---|
+| **What browser** | Native OS browser the user uses daily — Microsoft Edge / Google Chrome / Mozilla Firefox / Brave (whichever they've set as Windows default, or the one you name explicitly) | Puppeteer-controlled **Chrome for Testing** — a separate Chromium build that pup launches |
+| **Profile / data** | The user's real profile: saved logins, history, bookmarks, extensions, autofill | Isolated profile under `plugins/puppeteer/profiles/<sessionId>/` — empty, no saved logins |
+| **Who interacts with the page** | The HUMAN. Claude hands the URL off and is done. | CLAUDE drives it programmatically — screenshots, clicks, eval, recording. The human just watches. |
+| **Use when** | A login is required (claude.ai, GitHub, internal tools, banking) — the human's existing session must be reused | Automation, scraping, screenshot capture, video recording, headful UI testing |
+| **Can Claude control it after launch?** | No — once handed off, Claude can't see or drive it | Yes — every `browser_*` verb (screenshot, eval, navigate, record, etc.) |
+
+**Decision rule:** human-in-the-loop login flow → `desktop_open_url`. AI-driven automation → `browser_open_window` (defaults to Chrome for Testing). AI-driven automation that **must** be the user's real, signed-in profile (GCP Console with org/projects, internal SSO behind a managed-Chrome policy, anything Context-Aware Access blocks for non-enrolled browsers) → `browser_open_window` with the new `nativeBrowser` + `nativeProfile` args (v1.0.5+, see below).
+
+#### When pup means "the user's real Chrome/Edge" — `browser_open_window` native mode (v1.0.6+, smart flow v1.0.11)
+
+Default `browser_open_window` (no native args) launches Chrome for Testing — same as day one. **Never auto-switch a plain pup request to native.** "Open this URL in pup", "screenshot this", "click that button" all stay on CfT.
+
+Native mode drives the user's **REAL browser** — their actual signed-in profile, with live cookies, org policies, and device enrollment intact (NOT a clone — v1.0.6 replaced the old clone approach). Opt-in via TWO additive args from `desktop_list_browsers`:
+
+```bash
+adom-desktop desktop_list_browsers     # installed browsers + profiles (+ the signed-in account per profile)
+adom-desktop browser_open_window '{
+  "sessionId":"gcp",
+  "nativeBrowser":"chrome",            # chrome / edge / brave
+  "nativeProfile":"Default",           # profile id: Default / Profile 1 / Profile 2
+  "url":"https://console.cloud.google.com"
+}'
+```
+
+**The smart flow.** CDP (the protocol pup drives) needs the browser running with `--remote-debugging-port`, which can only be set at launch. So the bridge handles each situation differently and tells you which happened:
+
+| Situation | What AD does | Response |
+|---|---|---|
+| Already driving that browser+profile | Reuse the live connection | `ok` + `narrative` |
+| Running **with** a debug port (power-user launched it that way, or AD did earlier) | Connect, no relaunch | `ok` + `narrative` |
+| **Not running at all** | **Launch it fresh with the debug port — NO prompt** (nothing to disrupt) | `ok`, `launchedFresh:true` + `narrative` — just proceed |
+| **Open without** a debug port | **Ask the user** — AD must close+reopen it | `errorCode:"native_relaunch_required"` + `confirmArgs` + `alternatives` |
+| Debug port **refuses to bind** (enterprise policy) | Won't close it again | `errorCode:"native_cdp_blocked"` + `diagnosis` + `alternatives` |
+
+**Handling the two consent/blocked codes:**
+
+- **`native_relaunch_required`** — the browser is open without the port, so driving it means closing + reopening it (with `--restore-last-session`, so all tabs/windows come back). **Ask the user's permission**, then re-call with `"nativeRelaunch":true`. The response's `alternatives` lists other installed browsers you could offer instead.
+- **`native_cdp_blocked`** — the debug port won't open, almost always an **enterprise policy** (`RemoteDebuggingAllowed` disabled via Google Workspace / Chrome cloud management — affects **Chrome browser-wide, every profile**). `diagnosis` says whether it's a confirmed local-registry policy or a likely cloud-managed one. AD will **not** close that browser again. `alternatives` names other installed browsers — **Edge usually escapes Google management**, so it often drives natively when Chrome can't. Offer the user the alternative, or fall back to generic CfT.
+
+**Always available — generic CfT.** `browser_open_window` with NO `nativeBrowser`/`nativeProfile` drives a clean Chrome-for-Testing instance: no user session, no managed policy, fully controllable. If a task needs the user's logged-in session there, have them sign in once. This is the universal fallback whenever native is blocked.
+
+**Power-user shortcut:** if the user launches their browser at startup with `--remote-debugging-port=<N>`, AD reconnects every session with no prompt at all (a productized opt-in "keep native browsers debug-ready at boot" is on the roadmap, default OFF for security).
+
+**Routing rule — DON'T auto-escalate.** Treat native as opt-in:
+
+| User says | What to do |
+|---|---|
+| "pup" / "open in pup" / screenshot / eval / debug | DEFAULT pup (CfT). Never mention profiles. |
+| "native chrome" / "my real browser" / "my work profile" / "my `<account>` profile" | Native. Call `desktop_list_browsers`. If exactly one profile clearly fits the request, use it and say which one ("opening in your `[email protected]` profile on Chrome"). If multiple profiles could fit (e.g. a `google.com` URL with both `[email protected]` and `[email protected]` profiles signed in), **ASK** ("Which profile — work `[email protected]` or personal `[email protected]`?") and then drive that pair. |
+| anything else | Default pup. |
+
+**Anti-fingerprint:** native sessions launch without `--enable-automation` and with `--disable-blink-features=AutomationControlled`. `navigator.webdriver === false`. No "controlled by automated software" banner. Sites that probe for automation see a normal browser.
+
+**Firefox** appears in `desktop_list_browsers` but is rejected for native drive with `errorCode:"firefox_no_cdp"` — Firefox doesn't speak the Chrome DevTools Protocol, so puppeteer can't drive it. If the user wants Firefox, hand off via `desktop_open_url` instead.
+
+**Native-mode error codes:**
+- `native_relaunch_required` — browser open without a port; ask the user, re-call with `nativeRelaunch:true` (see the smart-flow table above).
+- `native_cdp_blocked` — debug port policy-blocked; read `diagnosis`, offer `alternatives` or generic CfT (see above).
+- `firefox_no_cdp` — caller passed `nativeBrowser:"firefox"`. Firefox doesn't speak CDP; hand off via `desktop_open_url`.
+- `native_browser_not_installed` — that browser isn't installed on this machine.
+- `native_user_data_missing` — installed but never launched, so the User Data dir doesn't exist yet. Ask the user to open it once.
+- `native_profile_not_found` — `nativeProfile` isn't a real profile id under this browser's User Data. Call `desktop_list_browsers` to see what's valid.
+
+#### ⚠️ Common footgun — `localhost` / `file://` URLs from Docker AIs
+
+If you're running this CLI from a remote Docker container (galliaApril, dartv4, etc.), `browser_open_window` / `browser_open_tab` / `browser_navigate` will load the URL inside the USER's local pup — NOT inside your container. So a URL like `http://localhost:8080/foo.html` resolves to the user's machine, not your container's service.
+
+**v1.7.10+ detects this.** When you pass a URL whose host is `localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, or scheme is `file://`, the response includes a `_hint` field explaining the issue and suggesting the public-slug URL pattern (`https://<user>-<repo>-<suffix>.adom.cloud/proxy/<port>/<path>`). **The call still proceeds** — rare legitimate cases exist where the user actually does have something running on their localhost.
+
+**Recipe — load something from inside your container into pup:**
+
+```bash
+# 1. Start your service inside the container (e.g. a local HTML viewer):
+python3 -m http.server 8786 --bind 0.0.0.0 &
+
+# 2. Ask your USER for the container's adom.cloud slug suffix
+#    (look at their browser URL when they open hydrogen or claude.ai/code).
+#    Slug looks like: <username>-<container>-<random-id>
+
+# 3. Open the public URL — NOT localhost:
+adom-desktop browser_open_window '{
+  "sessionId":"demo",
+  "url":"https://<your-container-slug>.adom.cloud/proxy/8786/foo.html"
+}'
+```
+
+If you don't have the suffix handy, just ask the user. Or if the file you wanted to show pup is one your container already wrote to disk, push it to a public location (wiki asset, S3) and load from there.
+
+
+- `desktop_bring_to_front` -- Bring a window to the foreground by HWND or title substring. Uses keybd_event + AttachThreadInput to bypass Windows foreground lock. Preserves maximized state.
+  - Args: `hwnd` (int) OR `titleContains` (string, case-insensitive). One required.
+  - Example: `adom-desktop desktop_bring_to_front '{"titleContains": "Fusion"}'`
+- `desktop_set_window_state` -- Change a window's show state without bringing it to the foreground (or both, if combined with `desktop_bring_to_front`). Args: `hwnd` (int) OR `titleContains` (string) OR `cacheKey`, plus `state` (one of `"maximize"`, `"minimize"`, `"restore"`, `"show"`, `"hide"`, `"bottom"`). Use to ensure Fusion or KiCad windows are maximized for screenshots, or to hide noisy background windows during demos.
+  - Example: `adom-desktop desktop_set_window_state '{"hwnd":133560,"state":"maximize"}'`
+  - **`state:"bottom"` (v1.9.114) -- z-order send-to-back, background.** Drops the window BEHIND all others without focusing, moving, or resizing it (`SetWindowPos HWND_BOTTOM` + `SWP_NOMOVE|SWP_NOSIZE|SWP_NOACTIVATE`); works even on an off-screen window. It's an ATOMIC check-and-demote: it reads `GetForegroundWindow()` first, and if the target IS the OS foreground (the user is interacting with it) it SKIPS the demotion and returns `wasForeground:true, sentToBack:false` so the caller backs off with no race; otherwise `wasForeground:false, sentToBack:true`. Use to park a background window behind the user's windows (still on-screen, taskbar-clickable, rendering for screenshots) without stealing focus.
+  - **`force:true` (v1.9.115)** sends it to back EVEN IF the window is currently the OS foreground (still no activation change; `sentToBack:true` always, `wasForeground` still reported). Use at window-LAUNCH time: Windows reassigns the foreground to a brand-new window when the previous foreground is destroyed, so "foreground at launch" is not a real user signal.
+  - Example: `adom-desktop desktop_set_window_state '{"titleContains":"(session: abc","state":"bottom","force":true}'`
+
+#### Real OS input — mouse + keyboard (v1.8.170+)
+
+`desktop_click` / `desktop_double_click` / `desktop_right_click` / `desktop_hover` / `desktop_type` / `desktop_press_key` synthesize **real OS input via `SendInput`** — so they land on **`chrome://` pages and native dialogs that browser CDP (`nbrowser_*` / `browser_*`) cannot reach**. The verb names mirror the browser extension's `nbrowser_*` so the model transfers between in-page and whole-desktop driving.
+
+- **Gated behind shell-auto-approve** (same risk class as `shell_execute` — keystrokes/clicks hit whatever is focused on the user's live machine). If it's off you get `errorCode:"input_not_approved"`. **You (a cloud/relay AI) can't grant it yourself (v1.9.90)** — call `request_shell_approval` (or let the refusal auto-surface the prompt) and ask the user to click Approve / Bypass Permissions in Adom Desktop, then retry. Every action is written to the activity log.
+- **Focus a window first** by passing `{window:"<title substring>"}` (alias `titleContains`) or `{hwnd:<int>}` — reuses `desktop_find_window`'s resolver, so input lands on the intended window, not whatever happened to be focused. Get the hwnd from `desktop_list_windows` / `desktop_find_window`.
+- **Coords are physical screen pixels** — the same space as `desktop_list_windows` rects.
+
+Verbs:
+- `desktop_click {x, y, button?:"left"|"right"|"middle", double?, window?|hwnd?}` — left-click by default. `desktop_double_click` / `desktop_right_click` are wrappers; `desktop_hover` moves the cursor without clicking (reveals tooltips).
+- `desktop_type {text, window?|hwnd?}` — types a literal Unicode string (`KEYEVENTF_UNICODE`, any keyboard layout).
+- `desktop_press_key {keys:[...], repeat?, window?|hwnd?}` (alias `desktop_send_keys`) — chords/shortcuts. Modifiers `ctrl/alt/shift/win`; named keys `enter/tab/esc/space/backspace/delete/insert/home/end/pgup/pgdn/up/down/left/right/f1-f12`.
+
+Canonical "navigate a browser to a URL" flow (works on `chrome://` where CDP can't):
+```bash
+adom-desktop desktop_press_key '{"keys":["ctrl+l"],"window":"Edge"}'   # focus Edge + select the address bar
+adom-desktop desktop_type        '{"text":"edge://extensions","window":"Edge"}'
+adom-desktop desktop_press_key   '{"keys":["enter"],"window":"Edge"}'
+```
+For a control that isn't reachable by CDP/coords (e.g. the Reload button on `edge://extensions`, which lives in a shadow DOM), Tab-navigate to it then Enter: `desktop_press_key {"keys":["tab","tab","enter"]}`.
+
+**Visual "Adom cursor" (v1.8.171+)** — a click-through teal cursor + a label that narrates what you're driving (trust cue; byte-faithful to the browser extension's cursor). Pure visual, *not* gated:
+- `desktop_cursor {action:"show"|"move"|"hide", x, y, label}` — drive/narrate the cursor on its own ("watch me"). Returns the overlay `hwnd`.
+- Or pass `cursor:true` + `label:"Clicking Reload"` on `desktop_click`/`desktop_double_click`/`desktop_right_click`/`desktop_hover` — AD glides the teal cursor to the target (narrating the label) *then* does the real click.
+- **Auto-hide (v1.8.174+):** the cursor hides itself after `ttlMs` (default **60s**) so it never lingers on the user's screen if you forget to hide it. Each `show`/`move` refreshes the timer. Pass `ttlMs:<ms>` (≤600000) for longer, or `persist:true` to keep it until you explicitly `desktop_cursor {action:"hide"}`. If a cursor you showed was auto-hidden, the **next** cursor/click response prefixes its `_hint` with a NOTE so you learn (no chatty unsolicited message).
+
+#### Background input via UI Automation (v1.8.174+) — no foreground, no focus steal
+
+The mouse/keyboard verbs above use `SendInput` = real OS input, which only lands on the **foreground** window — so AD foregrounds the target first, yanking it in front of the user's work. When you must NOT steal focus, use **UI Automation** instead: `InvokePattern.Invoke()` / `ValuePattern.SetValue()` are **programmatic** — the app services them via its accessibility tree with **no cursor move, no focus change, no foregrounding**. Chromium/Edge expose their a11y tree to UIA, so `chrome://` page buttons/links are reachable **by accessible name** (not coordinates).
+
+- `desktop_find_control {window|hwnd, name|contains, role?}` — **read-only** discovery (ungated). Returns `best:{name, automationId, role, rect, invokable, settable}`. Call this first to learn whether a control is UIA-reachable.
+- `desktop_ui_click {window|hwnd, name|contains, role?}` — UIA **Invoke** by accessible name, in the background. Gated behind shell-auto-approve.
+- `desktop_ui_set {window|hwnd, name|contains, text}` — UIA **SetValue** on a text field, in the background. Gated.
+
+`role` ∈ `button|edit|link|checkbox|menuitem|tab|listitem|combobox|radiobutton|document|group|spinner`. **Caveat (surfaced in `_hint`, not hidden):** some shadow-DOM controls aren't in the UIA tree — if `find_control` reports `invokable:false`/not-found, the control genuinely needs a **foregrounded** `desktop_click`. So: prefer `desktop_ui_click` (background) → fall back to `desktop_click` (foreground) only when UIA can't reach it.
+
+#### Near-complete UIA surface (v1.9.0+) — enumerate, read, drive, with a foreground-hint contract
+
+v1.9.0 expands UIA from ~3 patterns to a near-complete agent-drivable client. **Every UIA verb now returns a `foreground` block** so you know — *before and after* — whether a call steals the user's screen:
+
+```
+"foreground": { "predicted": "background|fallback-possible|foreground",  // from the static class + the resolved path, BEFORE acting
+                "observed":  "background|foreground|n/a",                 // MEASURED (GetForegroundWindow before/after); reads = n/a
+                "raised":    true|false,                                  // did THIS call bring a window to front?
+                "pathTaken": "uia-pattern|sendinput-fallback|setfocus|read-only",
+                "reason":    "<short>" }
+```
+
+**Shared selector on every verb:** `{hwnd|window(titleContains)}` + `{name|contains|automationId|role}` + `scope:"window"(default)|"popups"|"subtree:<automationId>"` + `nth:<int>` (0-based — target the Nth match without the exact name). Prefer `automationId` (stable, locale-independent). Rects are screen px with `centerX/centerY`; `space:"image"+shotId` is accepted wherever a coordinate is taken.
+
+- **Enumeration (read-only, ungated):**
+  - `desktop_ui_tree {window|hwnd, maxDepth?, roleFilter?[], nameContains?, maxNodes?(500), scope?}` — **flagship.** Dump the a11y subtree; nodes carry rect + invokable/settable + value/toggleState/expandState/selected/rangeValue + `children[]`. Honors Chromium's lazy tree (wake-retry); `truncated:true` when it hits `maxNodes` (logged, never silent).
+  - `desktop_ui_from_point {x,y | space:"image",shotId,x,y}` — the control under a pixel.
+  - `desktop_find_controls {selector, maxResults?(25)}` — ALL matches as an array with `nth` indices (vs `find_control`'s single best).
+- **Reading (read-only, ungated):** `desktop_ui_text {selector, range?:"all"|"selection"|"visible"}` (TextPattern); `desktop_ui_get {selector}` (current value/toggle/range/selection/expand state); `desktop_ui_grid_get {selector, row?, col?}` (GridPattern cell + rowCount/columnCount).
+- **Actions (gated by shell-auto-approve; background-first via the pattern, SendInput-click fallback only when the pattern is absent → then `raised:true`):** `desktop_ui_toggle {selector, state?}`, `desktop_ui_expand {selector, action?:expand|collapse|toggle}`, `desktop_ui_select {selector, action?:select|add|remove}`, `desktop_ui_scroll {selector, dir?, amount?}`, `desktop_ui_set_range {selector, value}`, `desktop_ui_focus {selector}` (**bg:foreground** — usually raises; reports it), `desktop_ui_window {window, action:minimize|maximize|restore|close|move|resize}`.
+- **Events (v1.9.3 — REACT instead of poll; read-only, ungated):**
+  - `desktop_ui_watch {event, window?|hwnd?, properties?}` → `{watchId}`. `event` ∈ `focus` (desktop-wide "what got focus") | `window-opened`/`window-closed` (new/closed top-level windows — these three can be global) | `invoke` | `selection` | `text-changed` | `text-selection` | `menu-opened` | `tooltip-opened` | `notification` | `live-region` | `structure` | `property` (these REQUIRE a `window`, else `errorCode:window_required` — avoids a desktop firehose). Cap 16 watches.
+  - `desktop_ui_events {sinceSeq?, watchId?, max?}` → DRAIN the buffer (poll cursor; there is NO server-push over the relay). Returns `{events:[{seq, watchId, event, ts, element:{name,role,automationId,rect,hwnd}, eventType|property|changeType}], nextSeq, highestSeq, missedEvents, activeWatches}`. Poll with `sinceSeq=nextSeq`. Events ALSO appear in the AD GUI activity-log. The 500-deep ring is shared across all watches; `missedEvents:true` = you polled too slowly.
+  - `desktop_ui_unwatch {watchId|"all"}` → stop. **Canonical use:** waiting on an OTP/confirm dialog → `ui_watch {event:"window-opened"}`, poll `ui_events` until the window appears, then act on it (no busy screenshot loop).
+- **Introspection:** `desktop_ui_capabilities {}` — **read-only, ungated.** The foreground-hint TABLE per verb (`staticClass` + measured `{win32, chromium-pattern, chromium-fallback}`). **Consult it BEFORE a call to know if it'll steal the screen, without acting.** `desktop_ui_selftest {confirm?, window?}` writes/refreshes it (empirically measures when `confirm:true`).
+
+**Integrity boundary:** an elevated/admin window or the UAC secure desktop returns `errorCode:"uia_integrity_blocked"` (never a silent no-op) — a normal-integrity bridge cannot drive a higher-integrity process.
+
+#### Zero-math coordinate clicks — `coordMap` + image-space (v1.8.175+)
+
+You no longer recompute scale/offset/DPI to click what you see in a screenshot. Every screenshot verb returns a **`coordMap`**:
+
+```jsonc
+"coordMap": {
+  "shotId": "shot-w5050328-1",
+  "image": { "w": 1372, "h": 1031 },              // the DELIVERED (safe) PNG's pixels
+  "screenRect": { "x": 594, "y": 254, "w": 1372, "h": 1031 },  // physical px = input-verb space
+  "dpiScale": 1.5,
+  "imageToScreen": { "sx": 1.0, "sy": 1.0, "ox": 594, "oy": 254 }  // screen = img*s + o
+}
+```
+
+Two ways to use it:
+- **Let AD convert (preferred, zero math):** `desktop_click {space:"image", shotId, x, y}` where `x,y` are pixels in that screenshot. Also works on `desktop_double_click`/`right_click`/`hover`. The `shotId` comes from the capture's `coordMap.shotId` (last ~64 shots retained; re-capture if it expires).
+- **Compute yourself:** `screenX = x*sx + ox`, `screenY = y*sy + oy`.
+
+`screenRect` is in the SAME physical-px space as `desktop_list_windows` rects and the `desktop_click` default (`space:"screen"`).
+
+> **Driving a GUI app that pops error boxes / modal dialogs? Read the dedicated [`adom-desktop-window-capture`](WINDOW_CAPTURE.md) skill.** `desktop_screenshot_window {hwnd}` captures ONE window AND the child dialogs / error boxes it owns, even when the window is backgrounded/occluded/minimized (PrintWindow, no focus steal). It is the breakthrough for automating native apps whose dialogs a full-screen grab can't see. The rest of this section is the reference; that skill is the how-and-why.
+
+**Popups (owned):** As of **v1.8.177**, `desktop_screenshot_window` **also captures the window's OWNED popups** — borderless confirm bubbles (Chromium's "Remove '\<ext\>'?"), owned dropdowns, owned dialogs — that don't enumerate in `desktop_list_windows` and aren't visible in a PrintWindow of the main window. They ride back as a top-level **`screenshots[]`** array (the same generic shape `kicad_screenshot_all` uses; the CLI auto-pulls each entry's `fullPath`→`localPath` and `safePath`→`localSafePath`). `ownedPopupCount` is always present (0 = none). Each entry carries `hwnd`, `title`, `kind:"owned_popup"`, and its own `coordMap`+`shotId`, so you can **click the bubble's buttons** with `{space:"image", shotId, x, y}` or drive them in the background by name via `desktop_ui_click {scope:"popups"}`. This is the generic mechanism — there's no app-specific "Chrome bubble" verb; every window screenshot surfaces any owned popups so you get the data and decide. **Use `desktop_screenshot_screen`** (full virtual-screen grab) only for popups *not owned* by your target window, or to see everything at once.
+
+**Screen geometry on every shot (v1.8.178):** every `desktop_screenshot_window` / `_screen` response now carries:
+- **`windowRect: {x,y,w,h,monitor}`** — the window's position + size on the overall (possibly multi-monitor) desktop, in physical screen px. Each owned popup entry in `screenshots[]` gets its own **`rect: {x,y,w,h,monitor}`** too — so you know where every captured surface sits, not just its pixels.
+- **`display: {virtualScreen, primary{w,h,dpi,scale}, monitors[], monitorOfWindow}`** — the full monitor topology: total virtual bounds, and per-monitor `bounds`/`dpi`/`scale`/`primary`. `monitorOfWindow` indexes into `monitors[]`. (Same data as the standalone `desktop_display` verb, folded in.)
+- **`drive: "background"`** — screenshots never foreground the window (PrintWindow/WGC). The companion `_related` field lists sibling screenshot/window verbs.
+
+**Screenshot cache is self-cleaning + self-describing (v1.9.107/108/113):** the CLI auto-pulls every screenshot to ONE user-visible, namespaced dir on the caller's machine (`~/project/screenshots/adom-desktop/`, else `~/.adom/screenshots/`; `ADOM_SHOTS_DIR` overrides) — never `/tmp` anymore. Shots are **bucketed by kind into subfolders**: `window/` (a specific window / app / owned popup) and `screen/` (a full-desktop grab). **Human-readable filenames (v1.9.113):** `<YYYY-MM-DDThh-mm-ss.mmm>[-<hwnd>].png` — a sortable ISO stamp (lexicographic == chronological), plus the `hwnd` for a window capture; the `window`/`screen` kind is NOT repeated in the name because the folder already says it. **One file when small (v1.9.114):** if the shot was already within the safe size cap (the common case) it's a single `<stem>.png` and `localFullPath == localSafePath` — no redundant re-encoded copy. Only a shot large enough to be downscaled produces a `<stem>.full.png` + `<stem>.safe.png` pair (Read the `.safe` one for image analysis; use `.full` for archive). Each shot gets a **`<stem>.json` sidecar** (shotlog-style: `source`, `timestamp`, `kind`, dims + `bytes`, `coordMap`, `rect`; a full+safe pair shares one). **Parent/child grouping (v1.9.111/113):** when `desktop_screenshot_window` returns owned popups in `screenshots[]`, the parent AND each pulled child share the same stem, and each child gets a **`.popup-NN` label** — e.g. parent `2026-07-15T01-14-16.937-986710.safe.png` and children `2026-07-15T01-14-16.937-986710.popup-01.safe.png`, `...popup-02...`. So a plain `ls` shows the whole capture grouped (shared stem) and the role (`.popup-NN`) without opening a sidecar; the sidecar also carries `parent` + `popupIndex`. (`kicad_screenshot_all` returns sibling windows with no parent, so those keep their own names.) A janitor prunes it (7 days / 2 GB / newest kept; sidecars follow their PNG, orphans swept) and drains the old `/tmp/ad-shots` + `/tmp/adom-desktop-screenshots` backlogs, running opportunistically at most once/24h on any CLI call. **You do not need to clean it up or write your own cron.** Every screenshot response carries a `_cacheHint`; verify coverage with **`adom-desktop janitor`** (status: each subfolder, size, policy, last sweep) or force a sweep with **`adom-desktop janitor '{"sub":"run"}'`**. It never touches other tools' files under `~/project/screenshots/` (e.g. shotlog). Tune via `ADOM_SHOTS_MAX_AGE_DAYS` / `ADOM_SHOTS_MAX_GB` / `ADOM_SHOTS_MAX_FILES`, or disable with `ADOM_JANITOR=0`.
+
+**Background vs foreground driving (v1.8.178):** every desktop driving verb carries a structured **`drive`** field (`"background"` | `"foreground"` | `"n/a"`) — in `GET /commands`, in `adom-desktop help <verb>`, and echoed in the verb's own response. **Background** (no foreground / focus steal / cursor move — UIA + capture): `desktop_ui_click`, `desktop_ui_set`, `desktop_navigate`, `desktop_find_control`, `desktop_screenshot_window/_screen`, `desktop_record_window_start`. **Foreground** (SendInput → goes to the focused window, so AD foregrounds first): `desktop_click`/`_double_click`/`_right_click`/`_hover`, `desktop_type`, `desktop_press_key`, `desktop_bring_to_front`. Run **`adom-desktop help background`** for the full grouping. Rule: to act without disturbing the user's window, drive by NAME (`desktop_find_control` → `desktop_ui_click`/`ui_set`); fall back to coordinate clicks/keys only when the control isn't in the UIA tree.
+
+**Click a control by name → coords:** `desktop_find_control` returns `best.rect{x,y,w,h,centerX,centerY}` in screen px — `desktop_click {x:centerX, y:centerY}` to SendInput-click it (handy when `invokable`/`settable` are both false). Target by `name`, `contains`, `role`, or `automationId` (stable, locale-independent).
+
+**Keyboard chords:** `desktop_press_key` accepts a chord as one string `["ctrl+w"]` OR separate tokens `["ctrl","w"]` — both mean Ctrl held + W. A *sequence* of distinct chords uses `+` per element: `["ctrl+l","enter"]`. The reply echoes `chords` showing how AD interpreted your `keys`.
+
+- `desktop_flash_window` (v1.8.152+) -- Flash an **arbitrary** window's taskbar button orange (Win32 `FlashWindowEx`) as an "attention / ongoing activity" cue. Resolve the HWND with `desktop_list_windows` first. Args: `hwnd` (int) OR `titleContains` (string); `mode` (`"until_focused"` default — flashes until the user clicks the window; or `"count"`); `count` (int, `mode:"count"` only, default 3); `stop` (bool — clear an active flash). Returns `{ok, hwnd, title, mode}`. **Lightweight by design** (direct FlashWindowEx, no process spawn) so a bridge can call it after every verb. Distinct from the puppeteer bridge's `browser_alert_window`, which only flashes pup's OWN tracked windows — this flashes any window on the host. For progress + overlay-icon status, use `desktop_taskbar`.
+  - Example: `adom-desktop desktop_flash_window '{"hwnd":133560}'` then `adom-desktop desktop_flash_window '{"hwnd":133560,"stop":true}'` to clear.
+- `desktop_taskbar` (v1.8.152+) -- Paint **glanceable status** on any window's taskbar button via `ITaskbarList3` (progress bar + overlay icon) + `FlashWindowEx`, so a bridge can show what the AI is doing on the **real app window it drives** (e.g. native-browser putting an "Adom is driving" badge + task progress on the Chrome/Edge button). Works on windows owned by **other processes**. Persistent COM (init once, reuse) — safe to tick progress frequently; no process spawn. Resolve the HWND via `desktop_list_windows`. Args (all channels optional; provide ≥1):
+  - `hwnd` (int) OR `titleContains` (string)
+  - `flash`: `"until_focused"` | `"stop"`
+  - `progress`: `{state: "normal"|"paused"|"error"|"indeterminate"|"none", value?: 0..100}` — **state→color**: normal=green, **paused=yellow** (use for human-in-the-loop gates: captcha / 2FA / login), error=red, indeterminate=marquee, none=clear.
+  - `overlay`: `{badge: "adom"|"none", icon?: "<PNG or .ico — file path OR base64>", tooltip?: "<hover>", avatar?, profileDir?, userDataDir?, composite?}` — built-in badges are `adom` (the Adom logo — a "driven by Adom" trust tell) and `none` (clear); 3rd-party bridges pass their **own** `icon` (a **PNG** or `.ico`, file path or base64 — AD magic-sniffs + renders at 32px). AD stays generic: it paints whatever art you hand it. `tooltip` is the overlay's accessible description.
+    - **Compositing onto a Chrome/Edge profile avatar (v1.9.110, #154):** Windows gives ONE overlay slot per taskbar button, and Chrome/Edge use it for the **profile avatar** — so a bare `badge:"adom"` would EVICT the profile face (you'd lose "work vs personal"). To keep the avatar AND show "Adom is driving", pass the avatar and AD composites the mark onto it (bottom-right ~47%): `overlay:{badge:"adom", avatar:"<PNG path or base64>"}`, or name the profile with `overlay:{badge:"adom", profileDir:"Profile 1", userDataDir?:"<User Data root>"}` (AD reads `<UserData>/<profileDir>/Google Profile Picture.png`; `userDataDir` is derived from the window's exe if omitted). `composite:false` opts out (raw mark). If the avatar can't be resolved AD falls back to the plain mark — never errors. The CALLER (which knows its profile — e.g. the browser extension) supplies the avatar; AD does not reverse-engineer it (one browser process serves all profiles, so the process command line has no `--profile-directory`).
+  - `thumbnailTooltip` (v1.9.153): the taskbar **hover-preview tooltip** (`ITaskbarList3::SetThumbnailTooltip`). The ONLY uncapped text surface on the preview — the window title truncates ~25-30 chars, this string does not (and takes newlines). e.g. `"Adom Pup / Adom wiki / LOGGED-IN view / Chrome for Testing / 3 tabs"`. Pass `""` to clear it back to the title. Works on a window AD does not own.
+  - `thumbnailClip` (v1.9.153): crop the live hover preview to a client-space region — `{x,y,w,h}`, or `null` to reset to the whole window (`SetThumbnailClip`).
+  - Returns `{ok, hwnd, title, applied:[...]}`. Clears: `flash:"stop"`, `progress.state:"none"`, `overlay.badge:"none"`, `thumbnailTooltip:""`, `thumbnailClip:null` — call these on task-end / window-close to clean up.
+  - Example (long task, human gate, then done):
+    ```bash
+    adom-desktop desktop_taskbar '{"hwnd":133560,"overlay":{"badge":"adom","tooltip":"Adom is driving this browser"},"progress":{"state":"indeterminate"}}'
+    adom-desktop desktop_taskbar '{"hwnd":133560,"progress":{"state":"paused"},"flash":"until_focused"}'   # captcha — come look
+    adom-desktop desktop_taskbar '{"hwnd":133560,"progress":{"state":"none"},"overlay":{"badge":"none"},"flash":"stop"}'  # done — clear all
+    ```
+- `desktop_set_window_identity` + `desktop_register_app_identity` (v1.9.134+) -- **Full window-identity takeover.** Make a window you drive present itself as YOUR app: the taskbar button, Alt-Tab entry, hover tooltip and any user pin read your name and icon instead of the process that happens to own the HWND. Built for a bridge driving a borrowed host app (a Chrome for Testing window otherwise reads "Test" with Chrome's icon, which is two brands and a confusing word). AD is generic here — it applies whatever `appId` / `iconPath` / `displayName` you pass and knows nothing about which app that is.
+
+  **You need BOTH halves.** `set_window_identity` stamps a LIVE window (`PKEY_AppUserModel_ID` + `WM_SETICON`); `register_app_identity` teaches Windows what that `appId` MEANS (an HKCU `AppUserModelId` key + a Start Menu shortcut carrying the same AUMID). An unregistered appId falls back to the owning exe's icon, so half one alone leaves Alt-Tab wrong.
+
+    ```bash
+    # Once per install. Ship the .ico in your bridge's release zip; pass its cache path.
+    # `target`/`args` are what a user's PINNED tile launches — point it at a real verb so
+    # the pin opens a managed window instead of an orphan app.
+    adom-desktop desktop_register_app_identity '{"appId":"Adom.Pup","displayName":"Adom Pup",
+      "iconPath":"C:/.../adom-pup.ico",
+      "args":"browser_open_window {\"sessionId\":\"pinned\",\"owner\":\"pinned-launch\"}"}'
+
+    # Per window, e.g. at park time. cacheKey survives page-title churn.
+    adom-desktop desktop_set_window_identity '{"cacheKey":"<sessionId>",
+      "titleContains":"(session: <sessionId>)","appId":"Adom.Pup",
+      "displayName":"Adom Pup","iconPath":"C:/.../adom-pup.ico"}'
+    ```
+  - Window target: `hwnd` OR `titleContains` OR `cacheKey` (+ `fresh` to re-resolve). Response carries `resolvedBy` (`hwnd` | `cachedHwnd` | `title`) and `applied[]`.
+  - **Identity is per-HWND and dies with the window** — re-apply after a window is recreated.
+  - AD holds the icon handles for its whole process lifetime. Icon handles are process-owned: a transient setter leaves windows pointing at dead handles, so the icon reverts moments later. AD being long-lived is exactly why it owns them.
+  - `register_app_identity` writes HKCU only (never a UAC prompt). It is gated for RELAY callers like `write_file`; a bridge calling over the loopback API is trusted by transport and ungated.
+  - **Jump-list tasks (v1.9.139+): `desktop_set_window_jumplist {appId, tasks}`** puts YOUR actions on the taskbar right-click menu of every window stamped with that appId. Jump lists attach to the AUMID, so per-session appIds give each window its OWN menu. Each task: `{title (required), target? (exe, defaults to AD's bundled CLI — so `args` can simply be an AD/bridge verb + JSON), args?, iconPath?, description?}`. Tasks launch minimized (no console flash). Max 16; `tasks:[]` clears (call at session close, alongside `unregister_app_identity`). Re-set anytime to update the menu. Tier-2 gated for relay callers; loopback bridges ungated.
+  - **Per-session / transient appIds (v1.9.136+): register REGISTRY-ONLY with `shortcut:false`, unregister on close.** If you stamp per-session AUMIDs (e.g. `Adom.Pup.<sessionId>` so each window keeps its own taskbar button in split mode), an UNREGISTERED AUMID makes Alt-Tab fall back to the owning exe's icon. `desktop_register_app_identity {appId, displayName, iconPath, shortcut:false}` writes just the HKCU AppUserModelId key (DisplayName + IconUri) — Alt-Tab and taskbar hover resolve the name + icon with NO Start Menu entry. Pair with `desktop_unregister_app_identity {appId}` at session close (idempotent; removes the key plus any shortcut carrying that AUMID; AD's own `inc.adom.desktop` identity is protected). Keep ONE shortcut-mode registration as the pinnable launcher.
+  - **`desktop_set_window_jumplist`** (v1.9.139+) adds the right-click menu on the taskbar button (per-AUMID; `tasks:[]` clears) and, with `hwnd`+`headerIcon`, brands the header row (the app tile above Pin/Close).
+  - **Icon format — read the `iconCheck` these verbs return (v1.9.152).** `desktop_set_window_identity` / `desktop_register_app_identity` / `desktop_set_window_jumplist` return an `iconCheck {kind,sizes,issues,ok,hint}` (or `headerIconCheck`) that magic-sniffs the file and explains why an icon would render as a generic white document: a **256px `.ico` size not PNG-compressed** (the Vista+ trap), **SVG** (the Win32 shell can't load it), a `.ico` with no small size, an `.exe` where an image is wanted. If `iconCheck.ok` is false, fix the file and re-stamp. Ship a multi-size `.ico` (16/24/32/48/256, the 256 PNG-compressed). **Full guide:** the `adom-desktop-window-identity` skill (AUMID model, the register+stamp two-halves rule, jump-list header, taskbar overlay + thumbnail, the live-tile trap, a recipe).
+- `desktop_revoke_approvals` -- Revoke all shell auto-approve permissions and deny pending approvals. The next shell command will show the approval dialog.
+  - Example: `adom-desktop desktop_revoke_approvals`
+- `desktop_caption` -- Show a global caption overlay that stays visible above ALL windows (KiCad, Fusion, Chrome). Native Win32 overlay — not a browser DOM element. Click-through: mouse events pass to windows below. Captured by screen recordings (browser `getDisplayMedia`, OBS, Game Bar).
+  - Show: `adom-desktop desktop_caption '{"text":"Step 1: Opening the board","position":"top","size":"large","duration":3000}'`
+  - Custom position: `adom-desktop desktop_caption '{"text":"Look here","x":0.3,"y":0.2,"size":"large","persist":true}'`
+  - Hide: `adom-desktop desktop_caption '{"action":"hide"}'`
+  - List what's on screen (v1.9.217): `adom-desktop desktop_caption '{"action":"list"}'` — returns `{captions:[{id,ageMs,expiresInMs,persist,overdue}]}`; the authoritative check (caption overlays do NOT appear in `desktop_list_windows`). TTLs are fail-safe: a 15s reaper closes any caption that outlives its deadline. Do not keep a caption alive with a same-id refresh loop as a nag — the user experiences one endless caption.
+  - Multiple simultaneous captions (use `id` to keep them independent):
+    ```
+    adom-desktop desktop_caption '{"text":"Step 1: Opening board","id":"step","position":"center","size":"large","persist":true}'
+    adom-desktop desktop_caption '{"text":"REC ●","id":"status","position":"bottom-left","size":"medium","persist":true}'
+    ```
+  - Replace only the step caption (status stays):
+    ```
+    adom-desktop desktop_caption '{"text":"Step 2: Routing","id":"step","position":"center","size":"large","persist":true}'
+    ```
+  - Hide just the step caption: `adom-desktop desktop_caption '{"action":"hide","id":"step"}'`
+  - Args:
+    - `text` (string) — caption text
+    - `id` (string, optional) — identifies this caption. Captions with the same id replace each other; different ids coexist simultaneously. Default `"_default"` — so bare calls without id still replace each other for backward compat.
+    - `position` — preset: `"top"`, `"bottom"` (default), `"center"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`
+    - `x` (float 0.0–1.0) — normalized screen X, overrides position horizontal. 0.0 = left edge, 1.0 = right edge. Centers the caption box on this point.
+    - `y` (float 0.0–1.0) — normalized screen Y, overrides position vertical. 0.0 = top edge, 1.0 = bottom edge.
+    - `size` — preset: `"large"` (72px), `"medium"` (32px, default), `"small"` (20px)
+    - `fontSize` (int) — custom font size in px (8–400). Overrides `size` preset when provided.
+    - `duration` (ms) — auto-dismiss timer. **Omitted or `0` → `30000` (30s); clamped to max `600000` (10 min).** Captions ALWAYS auto-expire (v1.8.138+) so a forgotten one can't be stranded on the user's desktop. To keep one up longer, **re-issue the same `id`** (resets the timer) or pass a bigger `duration`.
+    - `persist` (bool, optional) — `true` = sticky, NO auto-dismiss. Supervised use only (e.g. demo recordings); you MUST then dismiss it yourself with `action:"hide"`. This is the only way to a never-expiring caption (`duration:0` no longer does that).
+    - `action` — `"hide"` to dismiss a caption (with `id`: hides only that caption; without `id`: hides all captions), `"force-clear"` to EnumWindows and destroy ALL caption windows regardless of id (nuclear option — use at top of demo scripts for guaranteed clean slate)
+  - Captions with the same `id` replace each other instantly (previous destroyed, no fade overlap). Captions with different ids coexist — multiple captions can be visible simultaneously.
+
+### Desktop Screenshots
+
+Take screenshots of the user's desktop or individual windows. All screenshots use lossless PNG.
+
+**Always save screenshots** to `project-content/screenshots/`.
+
+**Naming convention:** `desktop-<descriptive-name>-YYYY-MM-DD-HhMMam/pm.png`
+
+**Workflow:** Call `desktop_list_windows` first to get HWNDs, then `desktop_screenshot_window` with the HWND.
+
+- `desktop_list_windows` -- List all visible windows (returns HWND, title, class name, position/size, and `z` = Z-order index, 0 = topmost)
+- `desktop_find_window` (v1.8.153+) -- Resolve a window's HWND by `titleContains` and/or `className` (case-insensitive substrings), returned in Z-order with `best` = the topmost match. Use this instead of diffing `desktop_list_windows` before/after launching something — it's race-free for "the window I just opened." Returns `{ok, count, best:{hwnd,title,className,z,rect}|null, windows[]}` (`ok` true only when ≥1 matched). Pass `best.hwnd` to `desktop_taskbar` / `desktop_flash_window` / `desktop_screenshot_window`.
+  - Example: `adom-desktop desktop_find_window '{"className":"Chrome_WidgetWin_1","titleContains":"GitHub"}'`
+- `desktop_activity_filter` (v1.8.155+) -- Scope the **user's GUI activity log** so they watch the slice you care about while you work (e.g. focus it on one bridge before a debugging session). Args: `bridge` (a bridge registry name from `bridge_list` | `"none"` = only global/non-bridge AD commands | `"all"`/omit = everything), `text` (free-text substring, ANDs with bridge), `clear` (bool — reset). **Drives the human's GUI view only — it does NOT change which verbs you can call or what they return.** The GUI shows a status bar stating exactly what's shown + what's hidden.
+  - Example: `adom-desktop desktop_activity_filter '{"bridge":"kicad"}'` then `adom-desktop desktop_activity_filter '{"clear":true}'`
+- `desktop_screenshot_window` -- Capture a specific window by HWND
+- `desktop_screenshot_screen` -- Capture the entire desktop (all monitors)
+- `desktop_ad_scroll` (v1.9.86+) -- **Scroll AD's OWN window panels.** AD is a fully AI-controllable app, so you can drive its own scrollbars — most usefully to page the **bridges sidebar** and screenshot cards that are below the fold. Args: `panel` (`bridges` default | `activity` | `connections`), `to` (`top`|`bottom`, absolute — wins over `dy`), `dy` (relative px, default 400; negative = up). Returns `{ok, panel, scrolled, state:{scrollTop,scrollHeight,clientHeight,atTop,atBottom,found}|null, _hint}` — round-tripped from the live window so you can **loop until `state.atBottom==true`**. A collapsed bridges sidebar is auto-expanded first; `state==null` means the panel didn't report (collapsed/empty).
+  - Capture-all-cards workflow: `desktop_ad_scroll '{"panel":"bridges","to":"top"}'` → `desktop_screenshot_window` (AD hwnd) → `desktop_ad_scroll '{"panel":"bridges"}'` → screenshot → repeat until `atBottom`.
+
+### App self-update (relay-driven, v1.9.87+)
+
+AD auto-updates itself from the wiki manifest, but historically only a HUMAN clicking the footer "Install now" banner could apply it — so a headless / RDP / fleet box (a fresh Azure VM) could sit a version behind with no path forward from the relay. These verbs give the AI that path (same shape as the shell-approval surfacing verbs). Use `--target <box>`.
+
+- `update_status` (read-only, ungated) -- `{currentVersion, latestVersion, updateAvailable, updateReady, state, busy, updateMode}`. `updateReady:true` = the newer version is downloaded + SHA-verified on disk, appliable with no network.
+- `update_check` -- force a manifest check now; if newer, download + verify (stage) it in the background. Poll `update_status` until `updateReady:true`.
+- `apply_update` -- the programmatic "Install now": apply the staged update + relaunch AD. Respects `busy` (refuses if a command is in flight). Only ever applies AD's own downloaded + SHA-verified SIGNED wiki release. Returns `{applying:true}` then AD restarts (the relay drops + reconnects; poll `targets`/`ping` for the new version). If not staged yet, it kicks staging and tells you to poll + retry.
+- `request_update_approval` -- surface the "Update available — Install now / Later" banner on the target + bring AD to the front (for an ATTENDED box where the human should decide). Does NOT apply; `apply_update` installs.
+- `update_set_mode {mode: auto|prompt|off}` -- the auto-apply POLICY. `auto` = unattended stage+apply on the 4h/on-launch poll (set this ONCE on headless fleet boxes and they self-update every release with no relay call); `prompt` = show the banner (default, attended); `off` = no checks.
+  - Fleet flow: `update_status --target <box>` → if `updateAvailable` and unattended, `apply_update --target <box>` (or set `update_set_mode {"mode":"auto"}` once). A box must already be on ≥1.9.87 to have these verbs; the one-time bootstrap of an older box still needs a human click OR `auto` mode.
+
+### HD companion mode (v1.9.218+)
+
+When a HUMAN opens AD on a machine where Hydrogen Desktop is detected (embedded marker, HD's ports.json, or a discoverable HD install), AD shows an identity overlay ("This is Adom Desktop" + a large Open Hydrogen Desktop button), instantly opens or foregrounds HD, and places its own window directly below HD's in z-order — most people who open AD by hand were looking for HD. Facts an AI should know:
+
+- Human-open detection = bare argv AND parent process `explorer.exe` (double-click, Start menu, search, pins). Machine launches — `--autostart` boot, HD `--embedded` respawns, the relauncher/`restart` verb, CLI-style invocations — NEVER trigger it, so a remote `restart` cannot bounce the user's screen.
+- One-shot placement: AD does not fight the user's later alt-tabbing, and it is NOT a real Win32 child window (AD's lifecycle stays independent — the recovery-channel contract holds; if HD cannot be launched the overlay says so and AD stays fully usable).
+- Toggle: Settings > Hydrogen Desktop > "Act as a companion to Hydrogen Desktop", or `settings_set {"settings":{"hd_child_mode":false}}`. Default ON (self-gates on HD detection, so standalone installs never see it).
+- Forensics: every decision slogs `HD-COMPANION:` lines to startup.log.
+
+### User settings over the API (`settings_get` / `settings_set`, v1.9.214+)
+
+AD's Settings-panel preferences are readable and settable over the API. This is the sanctioned channel for an embedding app (Hydrogen Desktop) to render AD's settings in its own panel and forward the user's changes; it also lets a fleet operator read or fix a box's boot/update prefs remotely.
+
+```bash
+adom-desktop settings_get
+adom-desktop settings_set '{"settings": {"launch_on_boot": true, "update_mode": "auto"}}'
+```
+
+- Settable keys (whitelist): `launch_on_boot`, `always_show_window_on_launch`, `update_mode` (auto|prompt|off), `auto_restart_for_updates`, `update_idle_minutes` (1..1440), `prewarm_runtimes`, `prewarm_cft` (auto|always|never), `file_receive_directory`. Anything else lands in `rejected` — servers, shell approval (grants are human-only), and blocked peers have their own surfaces and are deliberately not settable here.
+- `launch_on_boot` changes apply the OS Startup artifact immediately; `settings_get` also returns `autostartArtifactPresent` so you can see intent-vs-artifact drift.
+- `settings_set` is Tier-2 gated for relay (cloud-AI) callers — carry a `reason` and expect the human-approval flow, like `shell_execute`. Local/loopback callers (HD, bridges, the local CLI) are trusted by transport.
+- Self-heal: AD re-verifies the launch-on-boot artifact every ~60s while running (not just at launch), so anything that deletes the Startup shortcut behind AD's back gets healed within a minute — with a `RECONCILE:` forensic line in `startup.log`.
+
+### Browser automation (`browser_*`, Puppeteer/pup) — bridge-owned; light pointer
+
+The pup bridge is **cloud-owned** and ships its OWN full skill (its wiki page / skill pkg — `bridge_list` names it; `adom-desktop sync_skills` installs it). This page keeps only the pointer plus first-touch examples; sessions/profiles, credential vault, downloads, recording, CDP details are the bridge's skill.
+
+Most-used verbs:
+
+```bash
+adom-desktop browser_open_window '{"sessionId": "s1", "profile": "p1", "url": "https://example.com"}'
+adom-desktop browser_navigate '{"sessionId": "s1", "url": "https://example.com/page2"}'
+adom-desktop browser_screenshot '{"sessionId": "s1"}'
+adom-desktop browser_eval '{"sessionId": "s1", "expression": "document.title"}'
+adom-desktop browser_fetch_url '{"url": "https://example.com/data.json"}'
+```
+
+`browser_readiness` is the read-only probe (never spawns or downloads). Full details: the bridge's own skill via `sync_skills`.
+
+### Screen recording — AD's desktop recorder (tab recording is pup's)
+
+AD owns DESKTOP recording: a visible HUD opens with the REASON you supply, so the user always sees that recording is on and why. Full guide: the `adom-desktop-recording` skill.
+
+```bash
+adom-desktop desktop_recorder_open '{"reason": "record the DRC walkthrough for review"}'
+adom-desktop desktop_record_start '{"confirmDesktopNotTabRecording": true}'
+# ... drive the desktop ...
+adom-desktop desktop_record_stop '{}'
+adom-desktop desktop_record_list '{}'
+adom-desktop desktop_recorder_close '{}'
+```
+
+`desktop_record_start` without `confirmDesktopNotTabRecording: true` refuses with `errorCode: desktop_record_needs_confirmation` — that guard exists because recording a BROWSER TAB is the pup bridge's separate feature (`browser_record_start` etc., see pup's own skill).
+
+### Filesystem primitives — `desktop_list_files` / `desktop_watch_files` / `desktop_pull_glob` (v1.5.0+)
+
+**These are the canonical "wait for a download" primitives.** They replace the `shell_execute` + PowerShell-poll-Downloads pattern that earlier callers (chip-fetcher, similar tools) had to use. No shell, no user approval prompt, no parsing of `dir` output. They run pure Rust on the desktop side, so they're fast, predictable, and never trip over PowerShell quoting.
+
+The pattern they replace looks like this:
+
+```powershell
+# OLD — DON'T do this for new code:
+$before = (Get-Date).ToFileTime()
+# … click download …
+while (-not (Get-ChildItem ~/Downloads -Filter ul_*.zip | Where { $_.LastWriteTime.ToFileTime() -gt $before })) {
+  Start-Sleep -Seconds 1
+}
+$file = Get-ChildItem ~/Downloads -Filter ul_*.zip | Sort LastWriteTime -Desc | Select -First 1
+```
+
+becomes one call:
+
+```bash
+# NEW — single primitive, no shell:
+adom-desktop desktop_watch_files '{
+  "path": "%USERPROFILE%\\Downloads",
+  "glob": "ul_*.zip",
+  "timeoutMs": 60000
+}'
+# → {ok:true, file:{path,name,size,mtime}, elapsedMs} on success
+# → {ok:false, error:"timeout", elapsedMs, _hint, ...} on timeout
+```
+
+#### `desktop_list_files` — one-shot directory listing
+
+Lists files in a directory (non-recursive) matching a shell-glob, optionally filtered to those modified after a given timestamp.
+
+- Args: `path` (Windows abs path; `~` / `%USERPROFILE%` / `%APPDATA%` / `%LOCALAPPDATA%` / `%TEMP%` are expanded), `glob` (default `*`; shell-style with `*` and `?`; case-insensitive on Windows), `modifiedSince` (optional unix-seconds OR ISO-8601 string).
+- Returns: `{ok, path, glob, files: [{path, name, size, mtime}, ...] sorted newest-first, count}`.
+- Does NOT recurse. Lists one directory only.
+
+#### `desktop_watch_files` — block until a match arrives
+
+Polls the directory every `pollMs` (default 1000) until at least one file matches the glob with `mtime > since`, or `timeoutMs` (default 60000, max 600000 = 10 min) elapses.
+
+- Args: same as `desktop_list_files` plus `since` (defaults to **now** — without an explicit value, only NEW files report), `timeoutMs`, `pollMs`.
+- Returns on match: `{ok:true, file:{path,name,size,mtime}, elapsedMs}`.
+- Returns on timeout: `{ok:false, error:"timeout", elapsedMs, _hint, path, glob}`.
+- The `since`-defaults-to-now behavior matches the canonical "click then watch" flow. If you might miss the file by starting the watch slightly late (e.g. fast small downloads), record `$(date +%s)` BEFORE the click and pass it as `since`.
+
+#### `desktop_pull_glob` — one-shot orchestrator (recommended for download flows)
+
+Composes `desktop_list_files` (or `desktop_watch_files` if `wait:true`) with the existing streaming `pull_file` mechanism. The whole "wait for a download then pull it" flow in one call:
+
+```bash
+BEFORE=$(date +%s)
+# … trigger the download click via browser_input_dispatch …
+adom-desktop desktop_pull_glob "{
+  \"path\": \"%USERPROFILE%\\\\Downloads\",
+  \"glob\": \"ul_*.zip\",
+  \"since\": $BEFORE,
+  \"wait\": true,
+  \"timeoutMs\": 60000,
+  \"saveTo\": \"/tmp/cse-out\"
+}"
+# → {ok:true, files:[{name,path,size,sha256,chunks}, ...], errors:[], matchedCount}
+```
+
+- When `wait:true`: blocks via `desktop_watch_files` until at least one match appears, then re-lists the directory and pulls **everything matching** (so a `.crdownload` + final `.zip` written in the same poll tick both come along).
+- When `wait:false` (default): just lists what's there now and pulls those.
+- Pulls use the existing `pull_file` streaming pipeline — sha256-verified, ~1MB chunks, resumes the same way `pull_file` does.
+
+#### Glob semantics
+
+- `*` matches any run of non-separator chars (within a single filename — these primitives don't recurse).
+- `?` matches a single non-separator char.
+- Case-insensitive on Windows (matches the filesystem); case-sensitive on macOS/Linux.
+- Examples: `ul_*.zip`, `LIB_*.zip`, `*.pdf`, `datasheet_*.pdf`, `*.step`, `*.STEP` (same on Windows).
+
+#### Path expansion
+
+- `~` and `~/foo` → home dir (Linux/macOS convention; works on Windows too).
+- `%USERPROFILE%` → home dir (Windows; also works cross-platform).
+- `%APPDATA%` → Windows roaming app data (`dirs::config_dir()` on others).
+- `%LOCALAPPDATA%` → Windows local app data (`dirs::data_local_dir()` on others).
+- `%TEMP%` → Windows TEMP env var; `/tmp` on Linux/macOS.
+- Expansion is case-insensitive: `%userprofile%` works the same as `%USERPROFILE%`.
+- Forward slashes (`/`) and back slashes (`\`) both work in the path string.
+
+#### Time format for `since` / `modifiedSince`
+
+- Number: unix seconds (e.g. `1746483600`). Floats accepted.
+- String of digits: same (e.g. `"1746483600"`).
+- ISO-8601 / RFC-3339 string: e.g. `"2026-05-04T22:30:00Z"` or with offset `"2026-05-04T15:30:00-07:00"`.
+- Files with `mtime <= since` are filtered out (strict `>`).
+
+### Hydrogen Desktop (`hd_*`) — proxy + build/lifecycle for the sibling Tauri app
+
+When the user has **Hydrogen Desktop** running (a sibling Tauri v2 app at `C:\Github\hydrogen-desktop`), built-in `hd_*` verbs reach into it. NOT a separate bridge process — adom-desktop proxies HTTP calls directly to HD's control API.
+
+**Port discovery (v1.8.72+):** HD's control API lives on **port `47084`** (default; the actual port is read from `%APPDATA%\hydrogen-desktop\ports.json` key `control` on every `hd_api` call, fallback 47084). Pre-v1.8.72 the verbs were hardcoded to HD's legacy `:9001` webview-eval port, which HD doesn't bind anymore — that's why `hd_status` reporting "HD not running" while HD's window is visible is the upgrade signal.
+
+#### The canonical generic verb — `hd_api` (v1.8.72+)
+
+```bash
+adom-desktop hd_api '{"method":"GET","path":"/health"}'
+adom-desktop hd_api '{"method":"POST","path":"/setup/run-step","body":{"id":"welcome"}}'
+adom-desktop hd_api '{"method":"GET","path":"/wsl/status"}'
+```
+
+Generic HTTP pass-through to any HD endpoint. Raw JSON in `body` — no curl-through-the-relay quote mangling. Returns `{success, status, body, port, method, path}` mirroring HD's response. If HD isn't running, returns `{ok:false, error:"HD not reachable on :47084", _hint:"..."}` instead of timing out.
+
+**Public denylist** — these paths are refused with `{ok:false, error:"not_allowed_in_public", denylist_match:"..."}` on public AD builds (use the internal `hd-dev` cargo feature to bypass):
+
+```
+/wsl/unregister, /setup/virgin-reset, /container/destroy,
+/container/reset, /workspace/reset, /workspace/destroy
+```
+
+#### Inspect + drive a running HD (v1.8.15+, post-v1.8.72 rework)
+
+| Verb | What | Status |
+|---|---|---|
+| `hd_status` | Composes AD-side facts (running, repo SHA) + HD's `/workspace/health` + `/buildinfo` relayed | ✓ shipped |
+| `hd_api` | Generic HTTP pass-through (above) | ✓ v1.8.72+ |
+| `hd_log '{"tail":30}'` | Tail `%APPDATA%\hydrogen-desktop\hydrogen-desktop.log` (reads disk — works EVEN when HD is down) | ✓ shipped |
+| `hd_open_url '{"url":"...","browser":"chrome","profileDir":"Default"}'` | POST `/open-in-profile` — open URL in a specific browser profile | ✓ shipped |
+| `hd_browser_profiles` | GET `/browser-profiles` — enumerate browsers + profiles | ✓ shipped |
+| `hd_screenshot` / `hd_shot` | Find HD window, capture lossless PNG via PrintWindow; `hd_shot` adds region-scoped variant | ✓ shipped |
+| `hd_eval '{"js":"..."}'` | POST `/eval` — run JS in HD's main webview | ⚠ **Moved to `hd-dev` in v1.8.72.** Public returns `verb_moved_to_hd_dev`. Use `hd_api '{"method":"POST","path":"/eval","body":{"js":"..."}}'` |
+| `hd_iframe_eval '{"js":"...","contextIndex":0}'` | POST `/iframe-eval` — JS in code-server iframe via CDP | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |
+| `hd_reload_vscode` | POST `/reload-vscode` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |
+| `hd_container_exec '{"command":"..."}'` | POST `/container-exec` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |
+| `hd_launch` / `hd_stop` / `hd_restart` | HD process lifecycle | ✓ shipped |
+| `hd_ship` | Atomic build+relaunch+verify-SHA (see below) | ✓ shipped |
+| `hd_build*` (v1.8.16+) | Build pipeline orchestration | ⚠ Moved to `hd-dev` in v1.8.72 (HD developers only) |
+
+#### Atomic ship — `hd_ship` (v1.8.49+) — START HERE for post-push rebuilds
+
+**One verb, one call, one structured pass/fail.** Replaces the 4-step chain `hd_stop` → `hd_build_rust` → poll `hd_build_status` → `hd_launch` → `curl :47084/buildinfo`. Eliminates the "is the new code actually running?" guesswork after every push.
+
+Sequence inside one call:
+1. taskkill HD + poll until zero instances (frees the exe lock)
+2. `git fetch && git reset --hard origin/main && rev-parse HEAD` — asserts `HEAD == expectSha` (fails fast if GitHub hasn't replicated the push yet)
+3. if `clean:true`, `cargo clean -p hydrogen-desktop` (defeats stale-relink)
+4. `cargo build` and parse stderr for `Compiling hydrogen-desktop` to know whether anything actually compiled
+5. launch + poll `http://127.0.0.1:47084/buildinfo` until it responds + assert `build_sha == expectSha`
+
+```bash
+# End-to-end after a push:
+git push && SHA=$(git rev-parse HEAD) && \
+  adom-desktop hd_ship "{\"expectSha\":\"$SHA\"}"
+```
+
+Args:
+- `expectSha` — **REQUIRED** string. Full or prefix SHA of the commit you want running. Bidirectional-prefix match (`abc123` matches the full SHA starting with `abc123` and vice versa).
+- `clean` — optional bool, default `false`. `true` forces `cargo clean -p hydrogen-desktop` before build. Use when a prior `hd_ship` succeeded but `runningSha != expectSha` (the stale-relink trap — cargo said `Finished` but reused the cached binary).
+
+Returns on success: `{ok:true, builtSha (== expectSha), runningSha (== expectSha), compiled:bool, elapsedSecs, _hint}`. `compiled:false` on success means cargo found nothing to recompile (cached relink or HEAD didn't actually change — usually fine but surface to caller in case the push was a no-op).
+
+Returns on failure: `{ok:false, failedAt:'args'|'kill'|'git'|'clean'|'build'|'launch'|'verify', error, builtSha (set after git step), runningSha (set if launch worked but verify saw the wrong SHA), compiled (set after build step), elapsedSecs, _hint}`. The partials let you reason about WHERE it broke:
+
+| `failedAt` | What it means |
+|---|---|
+| `args` | `expectSha` missing / malformed |
+| `kill` | Couldn't taskkill HD (rare — usually permissions) |
+| `git` | `git fetch / reset` failed, OR `HEAD != expectSha` after reset (push hasn't replicated to origin/main yet — retry in a few seconds) |
+| `clean` | `cargo clean` failed (rare) |
+| `build` | `cargo build` failed — read `_hint` for next step (usually `hd_build_log` to see the compile error) |
+| `launch` | Cargo finished but `hydrogen-desktop.exe` wouldn't start — `binary_missing` or process spawn error |
+| `verify` | HD launched but `/buildinfo` either never responded or reported a different SHA than `expectSha` (the stale-relink trap — retry with `clean:true`) |
+
+Long-running: cold cargo build can take 2-3 min. CLI WS timeout is 12 min — call as a single sync invocation and wait. Don't poll inside `hd_ship`; it does its own internal polling.
+
+#### Lower-level build + lifecycle suite (v1.8.16+, enriched in v1.8.52+)
+
+Use these directly only when you need finer control than `hd_ship` provides (e.g. frontend-only build, watching log stream live, manual launch sequencing). For the common "I pushed code, get it running" path use `hd_ship`.
+
+The relay's `shell_execute` has a 30s timeout; HD's `pnpm build` + `cargo build` exceed that, so the build is split into async-spawn verbs + sync poll/tail verbs:
+
+```bash
+# Frontend-only rebuild, watch progress, relaunch (no hd_ship since hd_ship is Rust-build-only)
+adom-desktop hd_stop
+adom-desktop hd_build_frontend '{"show":false}'      # returns {ok, pid, logPath} immediately
+OFFSET=0
+while true; do
+  RESP=$(adom-desktop hd_build_tail "{\"offset\":$OFFSET}")
+  echo "$RESP" | jq -r '.lines[]'
+  DONE=$(echo "$RESP" | jq -r '.done')
+  OFFSET=$(echo "$RESP" | jq -r '.newOffset')
+  [[ "$DONE" == "true" ]] && break
+  sleep 3
+done
+adom-desktop hd_build_status      # confirm succeeded:true && relinkOnly:false
+adom-desktop hd_launch            # start the new debug binary
+```
+
+##### `hd_build` / `hd_build_frontend` / `hd_build_rust` — async-spawn build verbs
+
+| Verb | What |
+|---|---|
+| `hd_build '{"show":false}'` | Full async build: `git pull` + `pnpm build` + `cargo build`. Returns instantly with `{pid, logPath, flavor:'full'}`. Final log line is `BUILD_OK` or `BUILD_FAILED: <step>`. |
+| `hd_build_frontend` | Just `pnpm build` (root). Returns `{pid, logPath, flavor:'frontend'}`. |
+| `hd_build_rust` | Just `cargo build` (src-tauri/). **v1.8.52+:** dispatch response also carries `builtSha` + `exeMtimeBefore` + `exeSizeBefore` (captured at spawn time into `%TEMP%\hd-build-meta.json`) so the subsequent `hd_build_status` / `hd_launch` don't need a separate `git rev-parse` + `stat` call. Returns `{ok, pid, logPath, flavor:'rust', show, lingerSecs, builtSha, exeMtimeBefore, exeSizeBefore, _hint}`. |
+
+##### `hd_build_status` — sync state probe (v1.8.52+ enriched)
+
+Returns:
+```
+{
+  ok, building, succeeded, failed,
+  lastLines:[string],                  # last ~40 lines of stderr
+  logPath, pid, pidAlive,
+  failureReason,                       # 'compile_error' | 'exe_locked' | 'orphaned_no_sentinel'
+  compiledCrates:[string],             # names parsed from 'Compiling X v...' lines
+  relinkOnly:bool,                     # true iff succeeded && compiledCrates is empty
+  exeChanged:bool,                     # mtime OR size after differs from before
+  exeMtimeBefore:int, exeMtimeAfter:int,   # unix seconds
+  exeSizeBefore:int,  exeSizeAfter:int,    # bytes
+  exeLocked:bool,                      # true when build failed because hydrogen-desktop.exe was held
+  holderPids:[int],                    # PIDs holding the exe — taskkill these
+  builtSha:string|null,                # SHA hd_build_rust/hd_build was dispatched at
+  elapsedSecs:int,
+  flavor:string,                       # 'rust' | 'frontend' | 'full'
+  _hint
+}
+```
+
+**`relinkOnly:true` on success is the silent-fail trap to look for** — your push didn't actually rebuild because cargo found nothing to recompile. Usually means your source changes weren't pulled, or you touched a file the workspace doesn't track. Use `hd_ship` with `clean:true` to force a fresh build.
+
+**`exeLocked:true` on failure** + `holderPids` tells you exactly which PIDs to taskkill before retry; or just use `hd_ship` which kills HD before building.
+
+##### `hd_launch` — start the debug binary (v1.8.52+ enriched)
+
+Args: `killExisting` — optional bool, default `false`. When `false` and HD is already running, refuses with `reason:'already_running'` so you don't accidentally attach to a stale process. When `true`, taskkill the existing instance with a 600 ms grace period before launching fresh.
+
+Returns on launch: `{ok:true, launched:true, wasAlreadyRunning:bool, killedFirst:bool, pid:int (HD's actual PID, not the cmd-wrapper), exePath, builtSha:string|null, _hint}`.
+
+Returns on refusal: `{ok:false, launched:false, wasAlreadyRunning:bool, pid:int|null, killedFirst:false, reason, _hint}`. `reason` ∈:
+- `build_in_progress` — wait for `hd_build_status.building:false`
+- `build_failed` — fix and retry `hd_build` (or use `hd_ship`)
+- `already_running` — pass `killExisting:true` to force-kill+relaunch, or use `hd_restart`
+- `binary_missing` — `target/debug/hydrogen-desktop.exe` not on disk; run `hd_build_rust` (or `hd_ship`) first
+
+**`launched:true, wasAlreadyRunning:false, killedFirst:false` is the proof you need that truly fresh code is running.** Any other combo means you should compare `builtSha` to HD's actual `/buildinfo.build_sha`.
+
+##### `hd_stop` / `hd_restart` / `hd_build_log` / `hd_build_tail`
+
+| Verb | What |
+|---|---|
+| `hd_stop` | `taskkill /F /IM hydrogen-desktop.exe`. `{wasRunning}` distinguishes killed vs no-op. |
+| `hd_restart` | Stop + launch in one call. Same guards as `hd_launch` (skip `already_running`). |
+| `hd_build_log` | Full log dump from `%TEMP%\hd-build.log`. |
+| `hd_build_tail '{"offset":N}'` | Incremental stream — `{lines, newOffset, totalBytes, done, succeeded, _hint}`. Pass `newOffset` back for the next chunk. |
+
+The `show: true` arg on any build verb opens a visible PowerShell console window so the user can watch the build scroll. Default `show: false` runs hidden.
+
+**Auto-close (v1.8.23+).** Visible build windows auto-close 30 seconds after the final `BUILD_OK` / `BUILD_FAILED` line — long enough for the user to read the last error, short enough that build-after-build sessions don't accumulate orphan windows. The window prints a clear green/red "auto-closes in 30 seconds" banner before sleeping. Override with `lingerSecs`:
+- `'{"show":true, "lingerSecs":60}'` — give yourself 60s instead of 30
+- `'{"show":true, "lingerSecs":0}'` — close immediately, no grace period
+- `'{"show":true, "lingerSecs":3600}'` — keep open for an hour (cap)
+The response includes the applied `lingerSecs` so you can confirm what landed. Hidden builds (`show:false`) ignore `lingerSecs` entirely — no window to linger.
+
+**Don't call `hd_launch` until `hd_build_status` shows `succeeded: true`** — the guard refuses with `reason: "build_failed"` if you do, and the `_hint` tells the AI exactly what to do (`hd_build_log` to see errors, fix, rebuild). Same for `hd_restart`.
+
+#### Named-region screenshots — `hd_shot` (v1.8.54+)
+
+**One verb replaces the `desktop_list_windows | grep 'Hydrogen' → desktop_screenshot_window <hwnd>` chain** (3 calls + fragile HWND parsing). Captures a named region of HD, produces BOTH a full-res PNG and a Claude-safe-resized copy, and returns rich metadata so the AI never has to chain screenshot → pull → convert → crop → resize → Read manually.
+
+Each shot is **self-identifying**: it includes the running build's SHA + local build time read from HD's `/buildinfo` so you can confirm you're looking at the build you expect before trusting what you see (no more "this screenshot was from the previous binary, not my fix" confusion).
+
+Regions are deterministic — they resolve via HD window bounds + CDP element rects queried through HD's `/eval` endpoint. No AI-supplied pixel coords.
+
+Args:
+- `region` — **REQUIRED**. One of:
+  - `full` — whole HD window
+  - `titlebar` — top ~40 px strip (HD's build SHA / timestamp readout lives here)
+  - `vscode` — VS Code iframe pane via CDP element rect
+  - `setup-panel` — Setup Steps panel (`.setup-panel` / `.setup-panel-output`)
+  - `wiki` — wiki / browser tab pane
+  - `claude` — Claude Code panel
+- `resizeMax` — optional int, default `1400`. Longest side of the safe-resized copy. Use ≈800 to fit more shots in one Claude turn; 1568 for max fidelity.
+
+Returns:
+```
+{
+  ok, region,
+  fullPath,                    # lossless raw PNG — for shotlog/archival, NOT Read
+  safePath,                    # ≤resizeMax PNG — pull_file this, Read this
+  fullSize:[w,h], safeSize:[w,h],
+  clipRect:[x,y,w,h],          # captured-image pixels (NOT screen pixels)
+  capturedAtLocal,
+  runningBuildSha,             # from HD's /buildinfo — null if HD doesn't expose it yet
+  runningBuildLocal,
+  hdWindowFound:bool, regionFound:bool,
+  windowOuter:[w,h], capturedSize:[w,h],
+  _hints: {
+    pull,                      # exact pull_file command for safePath
+    fullForArchive,            # note that fullPath is for archive, not Read
+    staleWindow,               # set when runningBuildSha looks wrong
+    regionMiss                 # set when regionFound:false
+  }
+}
+```
+
+Recipe — verify-which-build-is-running + confirm-config-change-took-effect + full dump:
+
+```bash
+# After hd_ship, confirm the titlebar shows the SHA you expect:
+adom-desktop hd_shot '{"region":"titlebar"}'
+# → runningBuildSha lets you assert this before trusting any other shot
+
+# After flipping a Setup Steps toggle, screenshot the panel to confirm:
+adom-desktop hd_shot '{"region":"setup-panel","resizeMax":1568}'
+
+# Full-window dump for general debugging:
+adom-desktop hd_shot '{"region":"full"}'
+```
+
+Edge cases:
+- **Unknown region** — refusal: `{ok:false, error, knownRegions:[...]}`. No fallback — fix the call.
+- **`regionFound:false`** — the element rect wasn't in HD's DOM. The panel/iframe may not be mounted yet, OR HD's markup changed and `region_selectors()` in `hd_bridge.rs` is out of date. The shot is **still produced** (falls back to the full window) so you have something to look at; check `_hints.regionMiss` for the reason.
+- **`runningBuildSha:null`** — HD's `/buildinfo` isn't exposed yet on the running version. The shot is still produced; you just can't self-verify which build it represents.
+
+**Always pull_file the `safePath` and Read THAT** — it's already sized for Claude's image analysis. The `fullPath` is for shotlog / disk archival, NOT direct Read (may exceed Claude's per-image budget). The `_hints.pull` field gives you the exact `pull_file` command to copy-paste.
+
+**Old workaround to drop:** Do NOT use `desktop_list_windows | filter 'Hydrogen' | desktop_screenshot_window <hwnd>` anymore for HD — `hd_shot` replaces that entire pattern. Generic `desktop_screenshot_window` still applies to non-HD windows (other apps, dialogs, etc.).
+
+### TCP Port-forward Tunnels — `tunnel open|close|list|check|rdp` + `display` (v1.8.85+, reworked v1.8.147)
+
+Reach a TCP service addressable only from the **cloud side** through the **laptop side**: AD binds a `127.0.0.1:<port>` listener on the laptop and pumps bytes over the existing AD↔relay WebSocket; the relay dials the upstream `to`. First use case: RDP into a Windows VM behind an NSG only the cloud container reaches — mstsc on the laptop connects to a local port and is talking through the cloud. Same path works for SSH, VNC, HTTP, postgres — any TCP.
+
+**`tunnel` is a grouped verb** (`rdp` / `tunnel_close` / `tunnel_list` remain back-compat aliases):
+
+```bash
+# Open a VERIFIED tunnel (both legs probed before ok). Auto-picks a free local port.
+adom-desktop --target <name> tunnel open '{"to":"127.0.0.1:13389"}'
+
+# RDP: open + verify + launch mstsc, sized to the laptop's real screen.
+adom-desktop --target <name> tunnel rdp '{"to":"127.0.0.1:13389","size":"90%"}'
+#   size: "90%" (default) | "100%" | "fullscreen" | "maximized"
+#   OR monitor:<index> (0=primary)  OR explicit width+height
+
+adom-desktop --target <name> tunnel list           # live tunnels + leak detection
+adom-desktop --target <name> tunnel check '{"tunnelId":<id>}'   # per-leg health
+adom-desktop --target <name> tunnel close '{"tunnelId":<id>}'   # or {"all":true}
+
+adom-desktop --target <name> display               # monitor geometry (for sizing)
+```
+
+**`--target` is mandatory when >1 AD is connected.** v1.8.147 fixed a false-success bug: the bind, the upstream probe, and the mstsc launch now ALL resolve to the SAME machine. (Before, the bind could land on one AD and mstsc launch on another → "port bound" on the VM, "nothing listening" on the laptop, a bogus `ok:true`.) No target + multiple ADs → `ambiguous_target` listing the names; `--target all` is rejected (a tunnel binds one machine). `adom-desktop targets` lists connected ADs.
+
+**Active health verification — every `open`/`rdp`/`check` PROVES the path before reporting `ok`:**
+- **local-bind** — AD self-probes `127.0.0.1:<boundPort>` right after binding. If it can't connect, AD tears the tunnel down and returns `ok:false` (this alone kills the false-success class).
+- **upstream-refused** — the relay dials `to` (retry/backoff ~4×/2.5s). If unreachable, the tunnel is torn down (no leaked row) and `ok:false`.
+- A failure names the leg: `failedLeg: "relay-to-ad" | "local-bind" | "upstream-refused"`. Health fields surfaced: `listening`, `relayConnected`, `upstreamReachable`, `connCount`, `mstscAlive`.
+
+**Progressive hints.** Every response carries `_hint` (what's true now) + `_next` (the exact next verb with real values, copy-pasteable). e.g. a successful open's `_next` hands you the `tunnel rdp` / `tunnel check` / `tunnel close` commands with the real `to`/`target`/`tunnelId` filled in. A missing `to` returns a structured error with an example. Walk the workflow from the responses alone.
+
+**Display-aware RDP sizing.** `tunnel rdp` resolves `size`/`monitor`/`width`/`height` against the laptop's real screen (queried via `display` — `EnumDisplayMonitors` + per-monitor DPI), writes an `.rdp` file with the geometry, and launches mstsc. e.g. `size:"90%"` on a 2560×1600 primary → a 2304×1440 window. Default 90% of primary. You never hardcode a resolution — call `display` if you want the layout first.
+
+**`tunnel list` reconciles + reaps leaks.** It cross-checks the relay registry against every connected AD's own registry and reports `leaks:[...]` for rows only one side knows about; `tunnel close '{"all":true}'` reaps them. The relay stores the actually-bound `localPort` (so `tunnel close '{"local":<port>}'` works).
+
+Architectural notes for Claude:
+- Bytes-only — no payload parsing, so any TCP service works. TLS/cert-pinning still pins to whatever the cloud-side dial resolves (for RDP the upstream cert CN must match what mstsc expects).
+- Each fresh laptop-side TCP connection gets its own connId multiplexed over one tunnelId; concurrent mstsc reconnects / parallel sessions work without re-opening. Backpressure bounded (256-slot mpsc/conn, 64 KiB buf).
+- Tunnels don't survive AD/relay restarts. If mstsc disconnects, `tunnel check` (names the dead leg), then re-open.
+- The classic RDP collision (Windows' own RDP owns `0.0.0.0:3389`) is dodged by `rdp`'s default `local:13389` + `SO_EXCLUSIVEADDRUSE` + ephemeral fallback (`fellBack:true` in the response; read `localPort` for the real port).
+
+**Old workarounds to drop**: (1) NSG WAN-IP whitelisting for roaming-laptop RDP — the tunnel rides existing auth, reachable regardless of WAN IP. (2) Trusting a bare `ok:true` from the old `rdp` — it could be a dead port; now `ok` means both legs were probed. (3) Hardcoding mstsc resolution — pass `size`/`monitor`; AD resolves against the real screen.
+
+### Shell — `shell_execute` (escape hatch only)
+
+- `shell_execute` -- Run a shell command on the desktop. The CLI handles approval polling internally — it returns the final `{success, output, error, exitCode}` once the user clicks Allow on the desktop dialog. **You do not need to poll, retry, or call `get_deferred_result` yourself.** The CLI emits `HINT:` lines to stderr while waiting (every 15s) so an AI Monitor sees progress.
+- `shell_kill_all` -- Kill all running shell commands and deny pending approvals.
+
+**v1.7.16 fix.** Earlier versions (v1.7.15 specifically) had a regression where the CLI's approval-wait loop re-sent the command every 1s instead of polling for the deferred result; that produced an infinite "Another shell command is already waiting for approval" loop because each retry created a fresh approval the user could never out-click. v1.7.16 fixes it. If you see that error string with a `>= 1.7.16` CLI, the bug is back — file an issue. If you see it with `< 1.7.16`, upgrade the CLI: `adom-wiki asset get apps/adom-desktop docker_binary -o /usr/local/bin/adom-desktop`.
+
+**Deprecated for download polling.** As of v1.5.0, use `desktop_watch_files` / `desktop_pull_glob` for waiting on download arrivals — those don't require user approval, don't go through PowerShell quoting, and are O(directory entries) rather than spawning a process every second. `shell_execute` itself stays as an escape hatch for genuinely shell-only operations (multi-step ad-hoc admin tasks, chained pipelines, etc.).
+
+Use `where python` / `C:\Python3xx\python.exe` for Python -- avoid `python` which may not be on PATH.
+
+#### `run_script` + `wsl_exec` — base64-payload script runners (v1.8.46+)
+
+When you want to send a multi-line script to the desktop without the shell-quoting hell that `shell_execute` requires, use these two verbs. The script crosses the wire as base64 so NO escaping survives to be mangled by cmd.exe, PowerShell parser quirks, MSYS path conversion, or wsl.exe's `--` / nested-quote / `$_` rules. The caller never hand-escapes anything.
+
+Both verbs require shell auto-approve to be active — same trust level as `shell_execute`. They refuse cleanly with an actionable error if not approved; no per-call modal (these are programmatic verbs).
+
+**Reading the result — `status` + `durationMs` (v1.8.162+).** EVERY CLI result now carries a single authoritative **`status`** field so you never have to guess whether an empty `stdout` was a real empty-output success or a timeout:
+
+| `status` | meaning | what to do |
+|---|---|---|
+| `ok` | completed (stdout may legitimately be empty) | use `exitCode`/`stdout`/`stderr` |
+| `timeout` | did NOT finish in time (NOT an empty success) | raise `timeoutSeconds`, or use a detached job (below) |
+| `busy` | desktop couldn't service it (WSL wedged / no client connected) | check `adom-desktop status`; `wsl_recover` for WSL; retry |
+| `error` | the command/verb failed | read `error` / `stderr` / `_hint` |
+
+`durationMs` is the wall-clock of the call. `exitCode`/`stdout`/`stderr` pass through unchanged; on `timeout` the verb's `partialStdout`/`partialStderr` hold what was written before the kill. A `timeout`/`busy` result also carries a recovery `_hint`. **For multi-minute work don't fight the timeout** — use a detached job that returns instantly + polls: `wsl_exec_async` / `wsl_import` / `wsl_export` → poll `wsl_job_status` (or `launch_process` for a general long-running command). This is the cure for the "1.4 GB import EOFs the sync call" problem.
+
+**`run_script`** — base64 script + interpreter (cmd / powershell / bash):
+
+```bash
+SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b'''
+echo \"multi-line script\"
+VAR=\"value with spaces and \$dollars\"
+echo \"VAR=\$VAR\"
+exit 0
+''').decode())")
+adom-desktop run_script "{\"interpreter\":\"bash\",\"scriptB64\":\"$SCRIPT_B64\"}"
+# → {success, ok, stdout, stderr, exitCode}
+```
+
+Args: `interpreter` (`cmd` | `powershell` | `bash`), `scriptB64` (required), `timeoutSeconds` (default 60).
+
+PowerShell prefers `pwsh` (PS 7+) if on PATH, falls back to `powershell.exe` (5.1).
+
+- **`bash` runs via WSL2, NOT Git Bash.** The Windows host's first `bash` on PATH is `wsl.exe -d <default-distro> bash` (verified at `C:\Windows\System32\bash.exe`). The script is piped to bash's stdin (no temp file, no path-form mismatch — no MSYS `/S` → `S:/` corruption, no nested-quote loss). The decoded script bytes are written to bash's stdin and stdin is closed; bash reads + executes without ever opening a file. For explicit distro / user control, use `wsl_exec`.
+
+**`wsl_exec`** — base64 script + distro + user, executed via the canonical `wsl -d <distro> -u <user> -- bash -lc <decoded>` argv array:
+
+```bash
+SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b'''
+cd /mnt/c/Github/adom-desktop
+git status --short | head -10
+exit 0
+''').decode())")
+adom-desktop wsl_exec "{\"distro\":\"Ubuntu\",\"user\":\"root\",\"scriptB64\":\"$SCRIPT_B64\"}"
+# → {success, ok, stdout, stderr, exitCode}
+```
+
+Args: `distro` (e.g. `Ubuntu`, `Adom-Workspace`), `user` (e.g. `adom`, `root`), `scriptB64`, `timeoutSeconds` (default 60).
+
+Why argv (not shell string): Rust's `std::process::Command` on Windows applies proper `CommandLineToArgvW` quoting per argv element. `wsl.exe` sees `bash -lc <decoded>` as three distinct argv items even when the decoded script contains quotes, `$`, backslashes, newlines, pipes — anything. `bash`'s `-c` then sees the entire script as ONE string with no outer-shell interpretation.
+
+Output decoding: `wsl.exe` defaults to UTF-16LE when invoked from the Windows console subsystem. Both verbs auto-detect (BOM check + odd-position-NULL heuristic) and decode to UTF-8. Output is never wrapped in another shell that might re-interpret it.
+
+Pitfalls these verbs eliminate:
+- `cmd.exe`'s `&`, `|`, `^`, `%var%` interpretation
+- PowerShell's parser quirks around nested quotes + `$` interpolation
+- Git Bash MSYS converting `/S` → `S:/` etc.
+- WSL's `--` parser limits + nested-quote loss
+- UTF-16LE corruption on wsl.exe output
+
+#### WSL wedge defense — `wsl_wedged` + `wsl_recover` (v1.8.157+)
+
+Every `wsl_exec` / `run_script bash` spawns a `wsl.exe` client. If WSL's service (`LxssManager`) wedges — common after a laptop runs for a day, or under heavy parallel cloud use — those clients block in a wait that `taskkill /F` can't always finish, and retries keep spawning more. Left unchecked they pile up into the hundreds and wedge the whole machine. Three defenses:
+
+1. **Refuse-cap.** When there are already too many `wsl.exe` (`WSL_REFUSE_AT=80`), AD refuses NEW wsl spawns with `errorCode:"wsl_wedged"` instead of adding another doomed client. If you get `wsl_wedged`, don't retry the same command — run `wsl_recover` first.
+2. **`wsl_recover`** — reap orphaned/hung `wsl.exe`, **DRY-RUN by default** (nothing is killed unless you pass `confirm:true`):
+   ```bash
+   adom-desktop wsl_recover                       # dry-run: lists `candidates` (would-reap) + `spared`
+   adom-desktop wsl_recover '{"confirm":true}'    # reap the candidates
+   adom-desktop wsl_recover '{"confirm":true,"sparePids":[1234]}'   # also protect pid 1234
+   ```
+   It classifies each `wsl.exe`: childless + (0-CPU & old | runaway-CPU) → **candidate**; everything else → **spared**. It **spares any `wsl.exe` with child processes** (that's almost certainly the live distro, e.g. the HD `Adom-Workspace`), honors `sparePids`, and never touches `wslservice`/`wslhost`/`vmmem`. Review each candidate's `cmd` in the dry-run to confirm the live workspace isn't among them, then confirm. **Never** `wsl --shutdown` (it kills the live workspace too). Reaping childless orphans often cascades — their now-parentless children exit on their own (observed 114 → 18 from reaping 54).
+3. **status wsl block** — `{wslProcessCount, wedged, _hint}` lets you see a pile-up *before* it blocks work. Path differs by transport: **`status.desktop.wsl`** via the CLI/relay (`adom-desktop status` — nested like `desktop.apps`), or top-level **`status.wsl`** on the local direct API (`GET /status`). `wedged:true` at/above `WSL_WARN_AT=50`.
+
+#### Long-running in-distro work + distro lifecycle — golden-image bakes (v1.8.161+)
+
+`wsl_exec` is **synchronous** — it waits for the command and is bounded by the relay's HTTP timeout (~60-120s), so a multi-minute bake (apt, a big download) EOFs. Backgrounding inside the distro (`nohup … &`) doesn't help either: the `wsl.exe` client returns, WSL sees no active session, shuts the distro down, and kills the bg process (wiping `/tmp`). For anything long, use the **async held-session** verbs — AD holds one `wsl.exe` in the foreground for the command's whole lifetime, so the distro stays up:
+
+- **`wsl_exec_async`** → returns a `jobId` + `outputPath` (a live UTF-8 log) immediately; the command runs detached for as long as it takes.
+- **`wsl_job_status {jobId}`** → `{running, exitCode, elapsedMs, outputTail, outputPath}`. Poll until `running:false`; `pull_file` the `outputPath` for the full log. (`wsl_job_list`, `wsl_job_kill {jobId}` round it out.)
+
+```bash
+# golden-image bake: import → run (async) → poll → export → unregister, all via AD
+adom-desktop wsl_import '{"distro":"bake","installDir":"C:/Users/<you>/.adom/wsl/bake","tarball":"C:/.../base.tar"}'
+#   → {jobId}; poll wsl_job_status until done (exitCode 0)
+B64=$(printf 'set -euo pipefail\napt-get update && apt-get install -y build-essential\n' | base64 -w0)
+adom-desktop wsl_exec_async "{\"distro\":\"bake\",\"user\":\"root\",\"scriptB64\":\"$B64\"}"
+adom-desktop wsl_job_status '{"jobId":"wsljob-xxxxxxxx"}'   # repeat until running:false; outputTail = progress
+adom-desktop wsl_export '{"distro":"bake","tarball":"C:/.../golden.tar"}'   # poll, then pull_file the tarball
+adom-desktop wsl_unregister '{"distro":"bake"}'            # cleanup (REFUSES Adom-Workspace)
+adom-desktop wsl_list                                      # {distros:[{name,state,version,default}]}
+```
+
+**systemd user session — `viaRoot`.** On a `systemd=true` distro, `-u <user>` can fail "Failed to start the systemd user session for '<user>'". Pass `"viaRoot": true` to `wsl_exec_async` — AD runs `-u root -- runuser -u <user> -- bash -l`, dropping to the user WITHOUT depending on `[email protected]`.
+
+`wsl_import` / `wsl_export` / `wsl_unregister` are async (jobId + poll) too, so big imports/exports never EOF. All are **strictly scoped to the named distro** — there is no global `wsl --shutdown` path (that would kill the live HD workspace), and `wsl_unregister` refuses `Adom-Workspace`. Job ids live in AD's memory only (not persisted across an AD restart; the log file survives). The wedge defense above still applies — an over-cap spawn returns `wsl_wedged`.
+
+## Detecting App Installation
+
+After connecting, always run `adom-desktop status` to check what's installed. The `desktop.apps` object tells you exactly what the user has:
+
+```bash
+adom-desktop status
+# Look at the desktop.apps field in the response
+```
+
+### Handling "not installed" errors
+
+When a command returns `errorCode: "node_not_found"`, `errorCode: "kicad_not_installed"`, or `errorCode: "fusion_not_installed"`, guide the user through installation:
+
+**Picking the right native browser + profile (v1.7.1+):**
+
+When you need to open a URL in the user's NATIVE browser AND it matters which account is signed in (work Google Workspace vs personal Gmail vs media YouTube channel etc.), `desktop_open_url` alone isn't enough — you need to target a specific profile. The flow:
+
+```bash
+# 1. Discover what's installed + which profiles are configured.
+adom-desktop desktop_list_browsers '{}'
+# → {
+#     "default": "chrome",
+#     "browsers": [
+#       { "name": "chrome", "displayName": "Google Chrome", "version": "146...",
+#         "exePath": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
+#         "profiles": [
+#           { "id": "Default",   "name": "Alice (work)", "gaia": "[email protected]",     "isDefault": true },
+#           { "id": "Profile 1", "name": "Personal",     "gaia": "[email protected]" },
+#           { "id": "Profile 2", "name": "Adom Media",   "gaia": "[email protected]" }
+#         ] },
+#       { "name": "edge", "profiles": [...] },
+#       { "name": "firefox", "profiles": [{"id":"default-release","name":"default","isDefault":true}] }
+#     ]
+#   }
+
+# 2. Match the URL's context to the right profile, then open it there.
+# E.g. opening a Google Doc shared by your work team → use the work profile:
+adom-desktop desktop_open_url '{
+  "url":"https://docs.google.com/document/d/...",
+  "browser":"chrome",
+  "profile":"Default"
+}'
+# Or YouTube channel management for the media account:
+adom-desktop desktop_open_url '{
+  "url":"https://studio.youtube.com",
+  "browser":"chrome",
+  "profile":"Profile 2"
+}'
+```
+
+Profile-picking heuristics for Docker Claude:
+- **Workspace / @adom.inc URLs** → match `profile.gaia.endsWith("@adom.inc")` and not the `media@` one → typically `Default`.
+- **Personal Gmail / Drive / etc.** → match `profile.gaia === "[email protected]"` (or whatever the user's personal address resolves to).
+- **YouTube Studio / channel-specific work** → match the media account profile.
+- **Random scratch / experiments** → use the user's Edge profile, where they keep miscellaneous accounts (per the user's stated preference).
+- **No clear match** → fall back to `browser:"default"` (no profile flag) and let the user pick.
+
+`profile` is optional. Omit it to open in whichever profile the browser was last using (legacy v1.6.x behavior). Profile flag is silently ignored when `browser:"default"` (no clean way to inject through the OS URL handler — name the browser explicitly to use it).
+
+### Decrypting the user's saved browser credentials — `desktop_decrypt_browser_credentials` (v1.9.220+)
+
+So pup can run an **"adom-you" Chrome profile signed in AS the user** — surfing the web on the user's behalf — it needs the user's real saved logins/cookies to seed that profile. Chrome 127+ encrypts these with **App-Bound Encryption** (the master key is DPAPI-wrapped under the SYSTEM account), so only an elevated helper can decrypt them. This verb is that path.
+
+```bash
+adom-desktop desktop_decrypt_browser_credentials '{
+  "browsers": ["chrome", "edge"],   # optional; default = all installed Chromium browsers
+  "include": ["passwords", "cookies"] # optional; default = both
+}'
+# → { "ok": true,
+#     "passwords": [ { "origin", "username", "password", "sourceBrowser", "sourceProfile", "sourceAccount", "lastUsed" }, ... ],
+#     "cookies":   [ { "host", "name", "value", "path", "sourceBrowser", "sourceProfile", ... }, ... ],
+#     "stats": { "profilesRead", "passwordsOk", "passwordsFailed", "cookiesOk", "cookiesFailed" } }
+```
+
+**How to call it (this is not a normal verb — read this):**
+
+- **LOCAL / bridge callers only.** A relay (cloud-AI) call is refused unconditionally with `errorCode:"relay_forbidden"` and **pops no UAC** — a remote container must never be able to trigger a password-decrypt. pup (a loopback bridge) and the local CLI reach it.
+- **Two human gates, in order:** (1) AD shows an in-window consent dialog + toast naming the requesting caller ("pup is requesting your saved browser passwords/cookies…"); decline → `errorCode:"consent_declined"`. (2) On continue, an elevated signed helper runs behind a **UAC prompt** ("Adom Industries, Inc."); cancel → `errorCode:"elevation_declined"`. The UAC elevation IS the user's permission for the decrypt — by design (John, wiki #457).
+- **Plaintext is returned ONCE over the loopback API and is never written to disk or logged.** Don't echo the values into any other verb's args, any activity-log emit, or any file. Consume the rows, seed the profile/vault, drop them.
+- **errorCodes:** `relay_forbidden` (you called it over the relay — you can't), `consent_declined`, `elevation_declined`, `helper_missing` (the signed helper isn't bundled — the install is broken). Per-item decrypt failures are counted in `stats.*Failed`, never fail the whole run (a managed-profile policy can block cookies; newest-Chrome `v20` blobs are best-effort if Google rotated the elevation-service key).
+- **Pair it with `desktop_list_browsers`** (above) to show the user which browsers/profiles/accounts will be read before you ask.
+
+**Node.js not installed (the puppeteer bridge can't auto-spawn):**
+
+v1.7.0+ — any `browser_*` command returning `errorCode:"node_not_found"` means Node isn't on the desktop machine. Trigger the unattended winget install:
+
+```bash
+adom-desktop desktop_install_node '{}'
+# → runs `winget install --id OpenJS.NodeJS.LTS --silent ...` (Windows only).
+# → ~1-2 min; downloads + installs the Node.js LTS line (well-tested with puppeteer).
+# → returns {ok:true, exitCode:0, _hint:"Retry the original browser_* command"} on success.
+# → on failure: {ok:false, errorCode:"winget_unavailable" | "winget_install_failed", _hint}.
+```
+
+After install, **just retry the original `browser_*` command** — the bridge picks up the new node.exe via the registry App Paths lookup (which winget writes), no Adom Desktop restart needed. v1.7.0+ also pre-fetches Node + Chrome for Testing on first launch after install (background task on AUTOSTART_VERSION 11), so most users will never see `node_not_found` to begin with.
+
+If `desktop_install_node` returns `errorCode:"winget_unavailable"` (older Windows or managed corporate device), fall back to manual:
+
+> Node.js LTS is required for browser automation. winget isn't available on this machine — download Node.js LTS from https://nodejs.org/ and run the installer. Let me know when it's done and I'll retry.
+
+**v1.7.0+ Node detection.** The bridge looks for `node.exe` in:
+- System PATH (via `where node` on Windows, `which node` elsewhere)
+- Windows registry App Paths (catches winget, MSIX, custom-dir installs)
+- Standard install dirs: `C:\Program Files\nodejs\`, `C:\Program Files (x86)\nodejs\`, `%LOCALAPPDATA%\Programs\nodejs\`
+- Per-user version managers: nvm-windows (`%APPDATA%\nvm\`), volta (`%LOCALAPPDATA%\Volta\bin\`), fnm (`%FNM_DIR%`)
+- macOS Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`)
+
+If detection still misses an install on a user's machine, get the actual `node.exe` path from the user — that's a real bug to file.
+
+**KiCad not installed:**
+
+**KiCad not installed:**
+
+v1.6.0+ — try the unattended winget install FIRST before asking the user to download manually:
+
+```bash
+adom-desktop desktop_install_kicad '{}'
+# → runs `winget install --id KiCad.KiCad --silent ...` (Windows only).
+# → 2-5 minutes; downloads ~700 MB then installs.
+# → returns {ok:true, exitCode:0, _hint:"Run kicad_list_versions to verify"} on success.
+# → on failure (no winget / network failure / corporate-managed device):
+#   {ok:false, errorCode:"winget_unavailable" | "winget_install_failed", _hint, ...}
+```
+
+If `desktop_install_kicad` fails with `errorCode:"winget_unavailable"`, fall back to manual:
+
+> KiCad isn't installed and winget isn't available. Download from https://www.kicad.org/download/ — it's free and open source, install the latest stable (9.x or 10.x).
+> Let me know when the install is done and I'll verify the connection.
+
+After EITHER path, have them restart Adom Desktop OR call `kicad_list_versions` again — the kicad bridge caches detection results, so a fresh scan may be needed to pick up freshly-installed binaries.
+
+**v1.6.0+ — improved KiCad detection.** The bridge now scans for KiCad in:
+- `C:\Program Files\KiCad\<version>\` (the standard location)
+- `C:\Program Files (x86)\KiCad\<version>\` (32-bit edge cases)
+- `%LOCALAPPDATA%\Programs\KiCad\<version>\` (winget per-user installs)
+- `%LOCALAPPDATA%\KiCad\<version>\` (rare manual installs)
+- Windows registry: `HKLM\SOFTWARE\KiCad\<version>\InstallationPath` and the `WOW6432Node` mirror
+- `HKCU` mirrors of the above for per-user installs
+- macOS: `/Applications/KiCad/` and `/Applications/` directly
+
+If detection still misses an install on a user's machine, that's a real bug to file — get the actual install path from the user and we'll add it to the scan list.
+
+**Fusion 360 not installed:**
+> Fusion 360 isn't installed on your desktop. Would you like to install it?
+> Download from: https://www.autodesk.com/products/fusion-360
+> It's free for personal/hobby use (requires an Autodesk account).
+> Let me know when the install is done and I'll verify the connection.
+
+After install, have them restart Adom Desktop, then run `adom-desktop status` to verify.
+
+### Handling "not running" errors
+
+When `errorCode: "fusion_not_running"`, launch it programmatically with the first-class startup command:
+```bash
+adom-desktop fusion_start
+# On Windows: discovers exe via %LOCALAPPDATA% env var webdeploy glob.
+# On Docker: delegates to the bridge on the connected desktop via relay.
+# ~15-30s typical. Auto-dismisses startup dialogs.
+# Returns {"addinReady": true, "pickerDismissed": true|false, ...}
+```
+
+KiCad doesn't need to be running for most commands (the bridge launches it on demand).
+
+### Handling "add-in not installed" errors
+
+When Fusion is running but `addinInstalled: false` or `addinConnected: false`:
+> The AdomBridge add-in needs to be installed in Fusion 360. I can install it for you — this lets me control Fusion remotely.
+
+The add-in auto-installs when the Fusion bridge starts and Fusion is detected.
+
+### Handling `fusion_addin_not_responding` errors
+
+When `errorCode: "fusion_addin_not_responding"`, Fusion is running but the AdomBridge add-in isn't answering. Call `fusion_dismiss_blocking_dialogs` FIRST — a modal dialog is the most common cause. If that doesn't help, try `fusion_start` to restart Fusion cleanly. Last resort: user enables add-in manually via UTILITIES > ADD-INS > AdomBridge > Run on Startup + Run.
+
+### Handling `main_thread_busy` errors
+
+When `errorCode: "main_thread_busy"`, the Fusion add-in's main thread is occupied by a long-running command (typically `walk_cloud_tree` or `search_cloud_files`). This applies **across all bridges/sessions** — even if you didn't start the walk, another session might have.
+
+**Do NOT:**
+- Retry the failed command — it will block behind the same lock
+- Call `fusion_dismiss_blocking_dialogs` — there's no dialog to dismiss, and sending Escape will interrupt the active walk
+- Force-kill Fusion — the walk will complete on its own
+
+**Do:**
+- Wait for the walk/search to finish. If you started it, you should be using `adom-desktop watch` (see "Live folder progress streaming with `watch`" above) which streams live progress automatically. If another session started it, poll `fusion_addin_status` every 2–5s to check progress.
+- Use commands that **don't need the main thread** while waiting:
+  - `fusion_addin_status` — check busy state and walk progress
+  - `fusion_window_info` — get window HWND, title, dialogs
+  - `fusion_screenshot_fusion` — capture what Fusion looks like
+  - `fusion_click_fusion` — click in the Fusion window
+  - `fusion_send_key` — send keyboard input
+  - `fusion_close_window` — close a specific dialog by HWND
+
+The response includes progress info:
+```json
+{
+  "errorCode": "main_thread_busy",
+  "busyCommand": "walk_cloud_tree",
+  "elapsedSeconds": 42.3,
+  "walkProgress": {
+    "foldersVisited": 15,
+    "filesFound": 87,
+    "currentFolder": "Molecules/XRP",
+    "queueSize": 8
+  },
+  "_hint": "Add-in is busy with a long-running command. Do NOT retry..."
+}
+```
+
+**Stalled walk detection**: If `fusion_addin_status` returns `busy: true` but `walkProgress` is missing and `mainThreadStalled: true`, a modal dialog is blocking the event loop — the walk was dispatched but never started. Call `fusion_dismiss_blocking_dialogs`, then the walk auto-resumes.
+
+### Auto-recovery (`_autoRecovery` field)
+
+When a fusion_* command fails with "not responding" / "not connected", the CLI automatically:
+1. Calls `fusion_dismiss_blocking_dialogs` to clear any modal
+2. If successful, retries the original command
+3. Attaches `_autoRecovery: {action, dismissed[]}` to the retry result
+
+If you see `_autoRecovery` in a response, the command already succeeded after auto-dismissal — no manual intervention needed. The field is informational.
+
+## Troubleshooting
+
+### Check connection status
+
+Use `status` to see connected clients. A healthy connection shows one client from the user's hostname with a recent `lastPong` timestamp.
+
+### Stale connections causing timeouts
+
+Use `kick_all` to reset -- active Adom Desktop apps reconnect within seconds.
+
+### No desktop client connected
+
+1. Confirm the Adom Desktop app is running on the user's PC
+2. Confirm it's pointed at the correct WebSocket URL
+3. Check if port 8765 is exposed and reachable
+
+### Relay not running
+
+```bash
+curl -sf http://127.0.0.1:8766/health
+# If fails: adom-desktop serve --supervise
+```
+
+### Shell commands on Windows
+
+`shell_execute` runs in `cmd.exe`. Prefer writing scripts via `send_files` then executing them. Use the full Python path.
+
+## Building from Source
+
+```bash
+cd cli && cargo build --release
+# Binary at: cli/target/release/adom-desktop
+```
+
+## Repo
+
+[github.com/adom-inc/adom-desktop](https://github.com/adom-inc/adom-desktop)
+
+## Run wiki ops on the user's PC — `wiki_exec` (v1.9.116+)
+
+The AD installer bundles the `adom-wiki` CLI next to the app, and `wiki_exec` runs it ON THE PC — so version-controlling a local project (KiCad board, docs folder) on wiki.adom.inc needs NO per-file `pull_file` round trip to the container and there is no container/PC drift.
+
+```bash
+adom-desktop wiki_exec '{"args":["repo","push","adom/my-board","--files","board.kicad_pcb","board.kicad_sch","-m","rev B"],"cwd":"C:\Users\me\boards\my-board"}'
+```
+
+- `args` = adom-wiki argv (any pillar: repo, pkg, release, discussion). `cwd` = PC-side project dir so `--files` are project-relative. `timeoutSec` optional (default 120, max 600).
+- Returns `{success, exitCode, timedOut, stdout, stderr, exe, durationMs}`.
+- Auth is automatic: AD injects the signed-in user's session token as `ADOM_WIKI_TOKEN` — the caller never handles credentials.
+- Gated like `shell_execute` for relay callers (it writes to the wiki as the user). `errorCode:wiki_cli_missing` means the desktop AD predates 1.9.116.
+- Container-side publishing of the CONTAINER's own files still uses your local `adom-wiki` directly; `wiki_exec` is for files that live on the PC.
+
+Note (v1.9.117): AD's Settings panel no longer has a KiCad section — bridges own their app-specific config (AD stays generic). `kicad_library_paths` / `project_watch` keep working from `~/.adom/config.json` and the `set_project_watch` / `trigger_project_watch` verbs.
version.json+7−7
@@ -1,16 +1,16 @@ {   "manifest_version": 1,-  "version": "1.9.232",-  "released_at": "2026-08-07T12:35:00Z",+  "version": "1.9.233",+  "released_at": "2026-08-08T10:50:00Z",   "windows": {-    "installer_url": "https://wiki.adom.inc/download/adom/adom-desktop/1.9.232/Adom.Desktop_1.9.232_windows_x64-setup.exe",-    "installer_filename": "Adom.Desktop_1.9.232_windows_x64-setup.exe",-    "installer_sha256": "767bd36644167403f750d864c05bc477ef740f553c559708d4b5728922fee70f",-    "installer_size": 14344440+    "installer_url": "https://wiki.adom.inc/download/adom/adom-desktop/1.9.233/Adom.Desktop_1.9.233_windows_x64-setup.exe",+    "installer_filename": "Adom.Desktop_1.9.233_windows_x64-setup.exe",+    "installer_sha256": "4f693db4a7e4f17b191d56f1dc9ee3d5c16f4284ec3ac78a7b6ab6851bef6348",+    "installer_size": 14331024   },   "changelog_url": "https://wiki.adom.inc/adom/adom-desktop",   "minimum_required": "1.3.30",-  "note": "Two Hydrogen Desktop companion fixes. (1) The roll-up identity panel now appears when you open Adom Desktop by hand even while it is embedded in Hydrogen Desktop (previously standalone-only, so it was unreachable once HD had adopted AD); machine/boot launches still yield to HD. (2) The 'Managed by HD' relay connection now survives a relaunch that Hydrogen Desktop did not initiate (the app relauncher, a restart, or the boot shortcut): AD remembers HD's relay and re-attaches immediately instead of dropping the row until HD next respawns it. Only the relay URL and name are remembered, never any token.",+  "note": "The Hydrogen Desktop companion roll-up panel is now always available whenever Hydrogen Desktop is installed on the machine: the docked rail (with its Open Hydrogen Desktop button) is present every time the Adom Desktop window is shown, and the identity splash appears on the first view of a session. It is no longer tied to embedded mode or how the app was launched. Machines without Hydrogen Desktop never see it.",   "macos": {     "installer_url": "https://wiki.adom.inc/download/adom/adom-desktop-macos/1.9.162/Adom.Desktop_1.9.162_macos_aarch64.dmg",     "installer_filename": "Adom.Desktop_1.9.162_macos_aarch64.dmg",