app
Adom Bridge
Public Made by Adomby adom
Adom Bridge unleashes your AI onto your full PC: full power, full safety. The AI breaks out of the container it runs in and onto your real machine, managing and running your entire laptop as you: launch apps, move files, drive any window, control KiCad, Fusion 360 or your real signed-in browser. Works with any AI, cloud or local (Claude Desktop, Claude Code, Codex), no Hydrogen required. Bridge and its bridges are an operating system for AI, with managed Node and Python runtimes, and a human-onl
← Commit history
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.