← Commit history

Upload 1 file(s)

John Lauer ·ba317d22e5 ·4mo ago ·parent 8e07cba
1 file changed +733−57
main-SKILL.md+733−57
@@ -33,6 +33,105 @@ The `adom-desktop` binary is a single Rust CLI that does everything:  The relay server runs in the Docker container. The Adom Desktop app runs on the user's PC and connects via WebSocket. +## Recent additions (v1.8.103 → v1.8.125)++For readers of older copies of this skill: a lot has landed in the last+~14 releases. Quick summary so you don't try to call out to features that+don't exist or miss ones that do.++- **Embedded `--start-hidden` honored + window preference** (v1.8.119+) —+  when HD spawns AD with `--embedded --start-hidden`, AD now stays hidden+  (v1.8.102–118 force-showed it). The release default respects the launch+  flags; the machine owner can opt into always-show via the Settings+  checkbox (config `always_show_window_on_launch`). Same preference pattern+  as shell auto-approve.++- **Popup-risk pre-flight on shell commands** (v1.8.117+) — `shell_execute`+  and cmd-interpreter `run_script` are scanned BEFORE approval/execution+  for `start <target>` invocations whose target doesn't resolve. Those used+  to flash a modal "Windows cannot find '<x>'" dialog on the user's desktop+  (no silent mode exists — the box is cmd/ShellExecute's own UI). Now AD+  refuses with `errorCode: "popup_risk"` + a reprimand `_hint` pointing at+  `find_exe` / `launch` / `desktop_open_url`. If your command creates its+  own target mid-command and the scan misjudged it, re-send with+  `{"allowPopupRisk": true}`. http/https URL targets pass; cmd builtins+  pass; `copy x y && start y` patterns pass automatically.++- **Shell auto-approve is a user preference** (v1.8.116+) — per-command+  approval is the default everywhere now. Durable opt-in via AD's Settings+  checkbox or `shell_auto_approve '{"permanent": true}'`; HD passes+  `--shell-approval <prompt|1h|24h|permanent>` when spawning AD embedded+  (session-only) and owns the setting while embedded. The old v1.8.44+  embedded-permanent default (and its leak into standalone sessions) is+  gone — see "Shell auto-approve is a user preference" below.++- **Registry-driven install** (v1.8.115+) — `adompkg install adom-desktop`+  (with `?org=adom` on raw packages-API calls) now serves the current+  release: the V2 wiki page registers a real adompkg version per release+  and its manifest carries a server-derived `assets` map (url + sha256 per+  platform) for the installer binaries. The kicad v0.8.6 verbs+  (`kicad_export_*`, modifier-combo `kicad_send_key`, `kicad_open_3d_viewer`+  editor arg), puppeteer v1.0.6 native-drive changes+  (`native_relaunch_required` flow), and the embedded-mode fixes all reach+  end-user installers starting with this release.++- **`desktop_relaunch_self` + `desktop_relauncher_status`** (v1.8.103+) —+  visible self-restart helper. AD spawns a separate signed Rust GUI+  (`ad-relauncher.exe`) in HOST context that owns the entire swap-binary-+  and-restart sequence: preflight → stop AD → atomic deploy → CreateProcessW+  launch → verify reachable + PE FileVersion + window visible. Has its+  own HTTP API on `:47210`. Don't run shell glue for AD relaunches; use+  this. Full recipe: [`skills/AD_RELAUNCH.md`](AD_RELAUNCH.md).++- **Auto-update via wiki manifest** (v1.3.39+; single canonical wiki.adom.inc).+  AD polls a `version.json` manifest every 4h + on launch from+  `https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json`.+  Dead-end guard probes the `installer_url` before flipping+  `update_available` (HEAD + Range-GET fallback for the wiki's HEAD-404 bug). Footer banner Install Now / Later;+  user picks `prompt` / `auto` / `off` in Settings. SHA256-verified+  silent NSIS install + autostart relaunch.++- **`updater_get_status` payload extension** (v1.8.112+). `UpdaterStatus`+  now includes `manifest_url` (the URL that served the cached manifest)+  and `manifest_urls` (full priority chain). Sibling apps that read this+  endpoint can show users which wiki AD is checking. The Build-details+  tooltip on the footer version chip surfaces all of it + a clickable+  link to the wiki page.++- **Asset naming convention** (v1.8.113+). The full AD GUI installer and+  the CLI companion binary now disambiguate cleanly in the wiki:+    - **Full AD GUI:** `Adom.Desktop_<ver>_<os>_<arch>.<ext>`+      Example: `Adom.Desktop_1.8.114_windows_x64-setup.exe`+    - **CLI companion:** `adom-desktop-cli_<ver>_<os>_<arch>[.exe]`+      Example: `adom-desktop-cli_1.8.114_linux_x86_64`+  The pre-v1.8.113 Linux name `adom-desktop-linux` ships as a byte-+  identical alias through v1.8.116. After install, the binary on PATH is+  still `adom-desktop` — the wiki name disambiguates fetch time only.++- **`version.json` `.cli` slot** (v1.8.113+). Additive schema — pre-v1.8.113+  readers ignore. Each populated platform has `binary_url` / `binary_sha256`+  / `binary_size`. Today the manifest carries `cli.linux_x86_64` and+  `cli.windows_x64`. HD / sibling apps should prefer this typed lookup+  over hard-coding filenames.++- **Windows CLI bundled with AD** (v1.8.114+). Installing AD drops+  `adom-desktop-cli.exe` in `%LOCALAPPDATA%\Adom Desktop\` and adds the+  install dir to user PATH (HKCU, no UAC) via the NSIS POSTINSTALL hook.+  After install, `adom-desktop-cli screenshot_window …` works from any+  fresh PowerShell / cmd / Claude Desktop terminal. The CLI auto-finds+  the local AD via direct-API probe at `127.0.0.1:47200` — no relay+  required for verbs that don't need streaming.++- **CLI direct-API probe + relay self-detect** (v1.8.114+). The CLI's+  probe drops the dead `host.docker.internal` candidate (we don't use+  Docker Desktop anymore), adds WSL2 `/mnt/c/Users/*/.adom/direct-api-port`+  DrvFs glob for AD's discovery file, and adds a NAT-mode WSL2 fallback+  that resolves the Windows host IP from `/etc/resolv.conf`. The CLI's+  `serve` subcommand probes `127.0.0.1:8766/health` before binding and+  skips with a friendly message if another relay is already answering+  (override with `--force-bind`). Both fixes matter for the mirrored-+  networking WSL2 setup HD uses.+ ## Starting the Relay Server  The relay must be running before the desktop app can connect:@@ -218,6 +317,22 @@ adom-desktop server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765 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.@@ -272,12 +387,13 @@ adom-desktop.exe --embedded --start-hidden \ ```  - `--embedded` → enter embedded mode (also writes the marker)-- `--start-hidden` → boot with main window invisible+- `--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, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` so cloud-side callers can branch on whether AD is standalone or HD-managed.+**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): @@ -287,23 +403,56 @@ adom-desktop.exe --embedded --start-hidden \ | `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` | Graceful AD exit (stop bridges then exit) |-| `desktop_embedded_status` | Introspect current embedded state |+| `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) | -**Stale-marker safety**: if the marker file is the only signal AND HD isn't responding on `:9001`, AD logs a warning, deletes the stale marker, and falls back to standalone. Prevents an orphaned marker from a crashed HD uninstall from permanently hiding AD's tray.+#### 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: -**Shell auto-approve defaults to permanent in embedded mode** (v1.8.44+). When AD boots embedded, it sets `shell_auto_approve` to permanent (~100 years). Rationale: HD already has its own user-facing approval surface, so re-prompting through AD's banner would just add friction. The footer banner shows "Auto-approving shell commands — **Permanent** *(embedded-mode default)*" instead of a countdown. HD can revoke at any time:+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 (also works for the timed grants):+# revoke everything (timed + permanent + the saved checkbox): adom-desktop shell_auto_approve '{"duration_secs": 0}' -# re-enable permanent from anywhere (standalone too):+# set the durable preference (equivalent to ticking the Settings checkbox): adom-desktop shell_auto_approve '{"permanent": true}' ``` -Standalone AD users are unaffected — they still see the existing "+1hr / +24hr / Revoke" buttons and the persistence file behaves as before.+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.  #### Bridge ports are dynamic — you never need to know one (v1.8.31+) @@ -314,13 +463,74 @@ KiCad, Fusion, Browser/Puppeteer, and all third-party bridges now bind OS-assign - `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-author` skill.++**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.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"-  - `destinationFolder`: **relative subfolder only** (e.g. `"kicad/symbols"`, `"fusion"`). The desktop app controls the base directory. **Absolute paths are rejected.**-  - Returns `destinationPaths[]` with the absolute path of every saved file.+  - `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@@ -934,13 +1144,32 @@ adom-desktop watch '{"command":"fusion_walk_cloud_tree","args":{...},"interval": - `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. Autodesk's PAID Autodesk Platform Services (APS, formerly Forge) Data Management API has indexed search; teams with APS credentials can fork the bridge at `plugins/fusion360/addin/AdomBridge/` to add an `aps_search` verb and PR it back to https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-desktop — the community benefits.-  - **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; **do NOT tell the user "file not found"** — re-run with broader caps OR narrower `folderPath`.+  - **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 |@@ -1120,7 +1349,62 @@ adom-desktop pull_file '{"path": "C:/tmp/mfg/cpl.csv"}' | **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`.+**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 @@ -1153,20 +1437,104 @@ If you don't have the suffix handy, just ask the user. Or if the file you wanted   - 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), plus `state` (one of `"maximize"`, `"minimize"`, `"restore"`, `"show"`, `"hide"`). 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"}'`++#### 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"`; enable once with `adom-desktop shell_auto_approve '{"permanent":true}'`. 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.++#### 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"`).++**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.++**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?: "<.ico path or base64>", tooltip?: "<hover>"}` — built-in badges are `adom` (the Adom logo — a "driven by Adom" trust tell) and `none` (clear); 3rd-party bridges pass their own `icon` (`.ico` path or base64; PNG not yet supported). `tooltip` is the overlay's accessible description.+  - Returns `{ok, hwnd, title, applied:[...]}`. Clears: `flash:"stop"`, `progress.state:"none"`, `overlay.badge:"none"` — 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_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","duration":0}'`+  - 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"}'`   - Multiple simultaneous captions (use `id` to keep them independent):     ```-    adom-desktop desktop_caption '{"text":"Step 1: Opening board","id":"step","position":"center","size":"large","duration":0}'-    adom-desktop desktop_caption '{"text":"REC ●","id":"status","position":"bottom-left","size":"medium","duration":0}'+    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","duration":0}'+    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:@@ -1177,7 +1545,8 @@ If you don't have the suffix handy, just ask the user. Or if the file you wanted     - `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. Default 4000. 0 = stay until explicitly hidden.+    - `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. @@ -1191,7 +1560,11 @@ Take screenshots of the user's desktop or individual windows. All screenshots us  **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)+- `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) @@ -1205,7 +1578,7 @@ Two levels of granularity: **window** (one Chromium window per session, own task - `browser_rescan` -- (v1.5.1+) Recover orphaned Chrome windows whose CDP socket dropped. Walks every known profile (in-memory + on-disk session files), reconnects via the persisted DevToolsActivePort, then walks every page and rebuilds session entries by parsing the `(session: X)` tag injected into each page's title at launch. Idempotent. Args: `adoptOrphans?` (default false; when true, pages without a tag get attached under a generated sessionId — useful when Chrome has tabs the bridge has never seen).   - **When you need this**: any time `browser_navigate` / `browser_eval` / `browser_screenshot` errors with `errorCode: "session_disconnected"` (the bridge knows about your sessionId but its CDP socket dropped) or `errorCode: "session_not_found"` (after a bridge restart that rebuilt from disk but couldn't reach the live Chrome). The 30s health check now auto-attempts reconnect, so most transient blips recover before you notice — but `browser_rescan` is the explicit recovery primitive.   - Returns: `{ok, rescanned, profilesReconnected, profilesUnreachable, reattached, orphansAdopted, liveSessions, disconnectedSessions, _hint}`.-- `browser_open_window` -- Open a Chrome window. Args: `sessionId` (required), `profile` (required), `url` (required), `freshProfile` (optional), `strictPermissions` (optional, default `false`), `downloadPath` (optional, default `%USERPROFILE%\Downloads`).+- `browser_open_window` -- Open a Chrome window. **Default mode (CfT — Chrome for Testing):** args `sessionId` (required), `profile` (required), `url` (required), `freshProfile` (optional), `strictPermissions` (optional, default `false`), `downloadPath` (optional, default `%USERPROFILE%\Downloads`). **Native mode (v1.0.6+, opt-in — drives the user's REAL Chrome/Edge/Brave, actual signed-in profile):** instead of `profile`, pass both `nativeBrowser` (`chrome`/`edge`/`brave` from `desktop_list_browsers`) and `nativeProfile` (profile id: `Default`/`Profile 1`/`Profile 2`). The bridge smart-launches: fresh-launch if the browser is closed (no prompt, returns `launchedFresh:true`), reconnect if it already exposes a debug port, or returns `errorCode:"native_relaunch_required"` (ask the user, re-call with `nativeRelaunch:true`) if it's open without one. If the debug port is enterprise-policy-blocked it returns `errorCode:"native_cdp_blocked"` with a `diagnosis` + `alternatives` (other installed browsers — Edge often works when managed Chrome can't). See the "When pup means 'the user's real Chrome/Edge'" section above for the full flow + routing rules.   - **Full-capability default (v1.6.3+):** every pup session opens with the capabilities chip-fetcher-style flows actually need:     - **Scripted downloads enabled.** `Page.setDownloadBehavior` is called with `behavior: 'allow'` and `downloadPath: %USERPROFILE%\Downloads` (override via the `downloadPath` arg). Re-applied on every main-frame navigation as a safety belt. This unblocks the silent-download-failure scenario: before v1.6.3, JS-triggered downloads (clicks on `<a download>`, `fetch().then(blob → save)`, vendor "Download Symbol" buttons that do `window.location = url`) could be silently dropped by Chrome — no error, no UI, no file. chip-fetcher debugged this for hours before the fix.     - **Clipboard read/write granted.** Paste-into-vendor-form flows work without permission prompts.@@ -1323,10 +1696,10 @@ The vault stores credentials in the OS keychain (Windows DPAPI / macOS Keychain # 1. One-time setup (ask user for their CSE creds, then store) adom-desktop credential_set '{   "host":"*.componentsearchengine.com",-  "username":"[email protected]",+  "username":"[email protected]",   "password":"<paste>" }'-# → {ok:true, host:"*.componentsearchengine.com", username:"[email protected]"}+# → {ok:true, host:"*.componentsearchengine.com", username:"[email protected]"}  # 2. Navigate freely — no popup adom-desktop browser_open_window '{@@ -1339,7 +1712,7 @@ adom-desktop browser_eval '{"sessionId":"chip-fetcher","expr":"document.title"}'  # 3. Audit — passwords are never returned adom-desktop credential_list-# → {credentials:[{host:"*.componentsearchengine.com", username:"[email protected]", ...}]}+# → {credentials:[{host:"*.componentsearchengine.com", username:"[email protected]", ...}]}  # 4. Remove adom-desktop credential_delete '{"host":"*.componentsearchengine.com"}'@@ -1610,28 +1983,90 @@ adom-desktop desktop_pull_glob "{  ### 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`, HTTP control API on `127.0.0.1:9001`), 19 built-in `hd_*` verbs reach into it. These are NOT a separate bridge process — adom-desktop proxies HTTP calls directly to HD's :9001 endpoint and writes/reads its build state from `%TEMP%`.+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. -#### Inspect + drive a running HD (v1.8.15+)+**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. -| Verb | What |+#### 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 | |---|---|-| `hd_status` | GET `/health` — alive? Returns `{ok:false, error:"HD not running"}` cleanly when port 9001 is refused. |-| `hd_eval '{"js":"..."}'` | POST `/eval` — run JS in HD's main webview |-| `hd_iframe_eval '{"js":"...","contextIndex":0}'` | POST `/iframe-eval` — run JS inside HD's embedded code-server iframe via CDP |-| `hd_log '{"tail":30}'` | Tail `%APPDATA%\hydrogen-desktop\hydrogen-desktop.log` (reads disk — works EVEN when HD is down) |-| `hd_open_url '{"url":"...","browser":"chrome","profileDir":"Default"}'` | POST `/open-in-profile` — open URL in a specific browser profile |-| `hd_browser_profiles` | GET `/browser-profiles` — enumerate browsers + their profiles (gaia accounts surfaced) |-| `hd_screenshot` | Find HD window, capture lossless PNG via PrintWindow |-| `hd_reload_vscode` | POST `/reload-vscode` — reload the embedded VS Code iframe |-| `hd_container_exec '{"command":"..."}'` | POST `/container-exec` — run a shell inside HD's Docker container |+| `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. -#### Build + lifecycle suite (v1.8.16+)+#### Lower-level build + lifecycle suite (v1.8.16+, enriched in v1.8.52+) -The relay's `shell_execute` has a 30s timeout; HD's `pnpm build` + `cargo build` exceed that, so we split the build into async-spawn verbs + sync poll/tail verbs:+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-# Stop HD if running, then do a frontend-only rebuild, watch progress, relaunch+# 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@@ -1643,30 +2078,67 @@ while true; do   [[ "$DONE" == "true" ]] && break   sleep 3 done-adom-desktop hd_build_status      # confirm succeeded:true+adom-desktop hd_build_status      # confirm succeeded:true && relinkOnly:false adom-desktop hd_launch            # start the new debug binary ``` -Or the polling variant if streaming isn't needed:+##### `hd_build` / `hd_build_frontend` / `hd_build_rust` — async-spawn build verbs -```bash-adom-desktop hd_build_rust                            # just `cargo build` in src-tauri/-until adom-desktop hd_build_status | grep -q '"building": false'; do sleep 5; done-adom-desktop hd_build_status | jq '{succeeded, failureReason, lastLines}'-adom-desktop hd_launch+| 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_build '{"show":false}'` | Full async build: `git pull` + `pnpm build` + `cargo build`. Returns instantly with `{pid, logPath}`. Final log line is `BUILD_OK` or `BUILD_FAILED: <step>`. |-| `hd_build_frontend` | Just `pnpm build` (root) |-| `hd_build_rust` | Just `cargo build` (src-tauri/) |-| `hd_build_status` | Sync state probe — `{building, succeeded, failed, lastLines, logPath, pid, pidAlive, failureReason}`. Hint changes per state. |-| `hd_build_log` | Full log dump |-| `hd_build_tail '{"offset":N}'` | Incremental stream — `{lines, newOffset, totalBytes, done, succeeded, _hint}`. Pass `newOffset` back for the next chunk. |-| `hd_launch` | Start `target/debug/hydrogen-desktop.exe` detached. Refuses with structured `{reason}` when: `build_in_progress`, `build_failed`, `already_running`, or `binary_missing`. | | `hd_stop` | `taskkill /F /IM hydrogen-desktop.exe`. `{wasRunning}` distinguishes killed vs no-op. |-| `hd_restart` | Stop + launch in one call. Same guards as launch (skip `already_running`). |+| `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. @@ -1678,6 +2150,112 @@ The response includes the applied `lingerSecs` so you can confirm what landed. H  **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.@@ -1689,6 +2267,104 @@ The response includes the applied `lingerSecs` so you can confirm what landed. H  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:@@ -1715,8 +2391,8 @@ adom-desktop desktop_list_browsers '{}' #       { "name": "chrome", "displayName": "Google Chrome", "version": "146...", #         "exePath": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", #         "profiles": [-#           { "id": "Default",   "name": "John (work)",  "gaia": "[email protected]",      "isDefault": true },-#           { "id": "Profile 1", "name": "Personal",     "gaia": "[email protected]" },+#           { "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": [...] },@@ -1741,7 +2417,7 @@ adom-desktop desktop_open_url '{  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).+- **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.