← Commit history
README.md+2−2232
@@ -27,19 +27,11 @@ That installs the **container-side** pieces:  The desktop app, connected to two containers, with the bridge bar and live activity log: -![Adom Desktop GUI](gui.png)+![Adom Desktop GUI](docs/screenshot-gui.png)  Driving KiCad end-to-end from the container — symbol, footprint, 3D model, schematic, board, gerbers: -![KiCad driven by Adom Desktop](kicad-shot.png)--Driving **Fusion 360 Electronics** from the cloud — open an EAGLE board, lay out copper, render the populated 3D PCB, export Gerbers / BOM / STEP. Real boards: TI DRV8323SR + DRV8411A motor drivers and an ST VL53L8 time-of-flight sensor:--![Drive Fusion 360 from anywhere](fusion-hero.png)--Driving real **Chrome-for-Testing** windows from the container — TI, ST, Mouser, Adafruit, SparkFun, Espressif, every one scriptable (navigate, eval, screenshot, record):--![Drive real Chrome from anywhere](pup-hero.png)+![KiCad driven by Adom Desktop](docs/screenshot-kicad.png)  The **laptop-side GUI** installs separately on the user's Windows machine — a signed NSIS installer served from this page (the package manifest's `assets` map / `version.json` carry the exact URL + SHA256; Hydrogen Desktop's setup installs it automatically). Once installed, the GUI auto-updates itself from the wiki's `version.json` manifest (prompt by default, every 4h + on launch). @@ -56,2228 +48,6 @@ Adom container (this package)            User's laptop  The CLI talks to the user's GUI over the Adom relay (or the direct HTTP API when co-resident). Verbs: `desktop_*`, `browser_*` (pup), `kicad_*`, `fusion_*`, `hd_*`, `shell_execute`, file transfer, and more — run `adom-desktop help` after install. - ## Versioning  This adompkg version tracks the Adom Desktop release version (`X.Y.Z`). The package itself is a small scaffold; the multi-MB binaries live as blobs on this page and are fetched at install time with SHA256 verification — the package manifest's `assets` map carries server-computed checksums per platform.---## Command reference--Every verb and subcommand `adom-desktop` supports — **210 across 12 categories** — inlined below (the same names are the dispatch keys the cloud AI uses over the relay). Invoke as `adom-desktop <verb> '<json args>'`; bridge verbs (`kicad_*`, `fusion_*`, `browser_*`, `hd_*`) auto-spawn their bridge on first call. Every *failing* response also carries a `_hint` telling you the next step — surface it verbatim. Generated from the live help-map; run `adom-desktop commands` for the machine-readable source.--**Categories:** [Connectivity](#connectivity) · [Setup](#setup) · [Shell & process](#shell-process) · [File transfer](#file-transfer) · [Desktop & bridge mgmt](#desktop-bridge-mgmt) · [Screen recording](#screen-recording) · [Notifications](#notifications) · [Project watch](#project-watch) · [Browser (Puppeteer)](#browser-puppeteer) · [KiCad bridge](#kicad-bridge) · [Fusion 360 bridge](#fusion-360-bridge) · [Hydrogen Desktop](#hydrogen-desktop)--## Connectivity--_Liveness, status, multi-AD targeting._ (5 verbs)--### health--Check if the relay server HTTP API is reachable--**Returns:** Relay server health status---### kick_all--Force-disconnect all WebSocket clients (they auto-reconnect in ~5s)--**Note:** Use when connections are stale or duplicated---### ping--Round-trip connectivity test (5s timeout)--**Args:**-- `message` — optional string, echoed back--**Returns:** echo, roundTripMs, status--**Note:** Always run this first to verify desktop connection---### status--Query connected clients, capabilities, and app installation status--**Returns:** clients count, appName, hostname, capabilities[], desktop app status (kicad/fusion/browser installed/running)---### targets--v1.8.133+: list the Adom Desktops connected to this relay so you can address one by name. Each AD has a name (its --client-name / Settings value, defaulting to the OS hostname). Use this to discover names, then run any verb with the global --target <name> selector, or --target all to fan out to every AD.--**Returns:** {count, targets:[{name, hostname, version, os, connectedAt}], _hint}--**Note:** See _targeting_note at the top of this map for how --target works.---## Setup--_One-time desktop setup._ (1 verbs)--### setup_desktop--Generate installer URL and server config JSON for first-time desktop app setup--**Returns:** installer_url, server_config JSON, steps[]---## Shell & process--_Run shell commands, scripts, WSL, and launch/locate apps on the laptop._ (6 verbs)--### find_exe--Find an executable without launching it. Searches: (1) absolute path as-is, (2) glob expansion for webdeploy-style version directories, (3) Start Menu .lnk targets, (4) PATH. Returns the resolved absolute path or an error.--**Args:**-- `name` — required: bare name ('Fusion360.exe'), glob ('.../webdeploy/production/*/Fusion360.exe'), or full path-- `searchPaths` — optional array of extra dirs to scan--**Returns:** path, source (absolute|glob|startmenu|path)---### args---### run_script--v1.8.46+: Execute a base64-encoded script via a chosen interpreter (cmd / powershell / bash). The script bytes never touch a shell-string parser, so NO quoting/escaping survives to be mangled by cmd.exe, PowerShell's parser quirks, or MSYS path conversion — caller never hand-escapes anything. Host-side: cmd / powershell get a temp file (.cmd / .ps1); bash gets the script via stdin (no temp file). Output decoded as UTF-8 (UTF-16LE detection for PowerShell 5.1 output). Returns stdout/stderr/exitCode and cleans up any temp file.--**Args:**-- `allowPopupRisk` — optional bool (v1.8.117+) — bypass the popup-risk pre-flight on cmd scripts. AD refuses (errorCode `popup_risk`, failedAt `popup_scan`, scriptLine N) any `start <target>` line whose target doesn't resolve — that flashes a modal 'Windows cannot find' dialog on the user's desktop. powershell/bash scripts aren't scanned (no ShellExecute popup failure mode).-- `interpreter` — required string: 'cmd' | 'powershell' | 'bash'. PowerShell prefers pwsh (PS 7+) if on PATH, falls back to powershell.exe (5.1). Bash uses whichever is first on PATH — on Windows that's typically WSL2's System32\bash.exe (the intended interpreter; stdin piping means no path-form mismatch). For explicit distro / user control on WSL, use wsl_exec.-- `scriptB64` — required string: base64-encoded script content (no length limit beyond practical WS message size — keep under a few MB for sanity).-- `timeoutSeconds` — optional int (default 60). The CLI's WS timeout is auto-sized to scriptTimeout + 15s margin.--**Returns:** On success: {success:true, ok:true, stdout, stderr, exitCode, durationMs, interpreter, [_hint?]}. On failure: {success:false, ok:false, stdout:'', stderr:'', exitCode:-1, error, failedAt, _hint}. failedAt is one of: 'args' (interpreter unknown / scriptB64 empty), 'decode' (base64 not parseable), 'tempfile' (cmd/powershell temp-file write failed — see tempPath in payload), 'spawn' (interpreter exe couldn't launch — see spawn_failure_hint output), 'timeout' (script ran longer than timeoutSeconds). EVERY failure path carries a _hint with the specific recovery recipe — READ the hint, don't guess. **v1.8.60+ ADVISORY**: success responses may also include an optional `_hint` field when the script looks like a direct HD build (raw `cargo`/`pnpm` against `hydrogen-desktop`/`crates/hd-*` paths). The hint is additive — exit code / stdout / stderr are UNCHANGED — and points at the dedicated `hd_build_*` / `hd_ship` verbs.--**Note:** Requires shell auto-approve to be active — same trust level as shell_execute. Enable with `shell_auto_approve '{"duration_secs": 86400}'` (24h) or `'{"permanent": true}'` (no expiry). Embedded mode defaults to permanent. Refuses cleanly with an actionable error if not approved (no per-call modal — these are programmatic verbs, not interactive).---### shell_execute--Execute a shell command on the user's desktop (requires user approval). Escape hatch for things that don't have a structured verb yet — most common operations are covered by desktop_install_node / desktop_install_kicad / desktop_list_files / desktop_watch_files / desktop_list_browsers etc., which DON'T require per-call approval. Use shell_execute only when no structured verb exists for the task.--**Args:**-- `allowPopupRisk` — optional bool (v1.8.117+) — bypass the popup-risk pre-flight. AD refuses (errorCode `popup_risk`) any `start <target>` whose target doesn't resolve, because that flashes a modal 'Windows cannot find' dialog on the user's desktop. Verify targets first (find_exe / launch / desktop_open_url); only pass true when the command creates its own target and the scan misjudged it.-- `command` — required string — full shell command (Windows: runs through `cmd /c`; macOS/Linux: through `sh -c`)-- `cwd` — optional working directory-- `timeoutSeconds` — optional int (default 30, max effectively unbounded — pass whatever your task needs). v1.7.3+ this is actually honored end-to-end. Pre-v1.7.3 the CLI accepted it for its own HTTP socket but dropped it before forwarding, so the desktop side defaulted to 120s and killed long-running tasks regardless. Long rust builds / ffmpeg renders / docker pulls etc. should set 600-1800s.--**Returns:** stdout, stderr, exitCode--**Note:** User must approve via in-app dialog unless auto-approve is active. v1.7.3+ timeout forwarding bug fixed — your timeoutSeconds is now respected by the desktop-side shell killer. v1.7.16+ approval-polling fixed — the CLI now polls the relay's deferred-result endpoint with the original requestId instead of re-sending the command every second (which v1.7.15 did, causing infinite 'Another shell command is already waiting for approval' loops because every retry created a fresh approval the user could never out-click).---### shell_kill_all--Kill all running shell command processes---### wsl_exec--v1.8.46+: Execute a base64-encoded script inside a WSL distro via the canonical `wsl -d <distro> -u <user> -- bash -lc <decoded>` argv array. Eliminates the --, nested-quote, and $_ pitfalls because 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, then bash's -c sees the entire script as ONE string with no outer-shell interpretation. The exact 50×-per-session call pattern.--**Args:**-- `distro` — required string: WSL distro name (e.g. 'Adom-Workspace', 'Ubuntu'). `wsl -l -v` enumerates installed distros.-- `scriptB64` — required string: base64-encoded bash script. Decoded content must be valid UTF-8.-- `timeoutSeconds` — optional int (default 60). The CLI's WS timeout auto-sizes to scriptTimeout + 15s margin.-- `user` — required string: WSL user to run as (e.g. 'adom', 'root'). Must exist in the distro.--**Returns:** On success: {success:true, ok:true, stdout, stderr, exitCode, durationMs, interpreter:'wsl', distro, user, [_hint?]}. wsl.exe's UTF-16LE output is decoded as text automatically (BOM-detect + odd-position-NULL heuristic, fall back to UTF-8 lossy). On failure: {success:false, ok:false, stdout:'', stderr:'', exitCode:-1, error, failedAt, _hint}. failedAt is one of: 'args' (distro/user/scriptB64 empty), 'decode' (base64 invalid), 'utf8' (decoded bytes not UTF-8), 'spawn' (wsl.exe couldn't launch — see wsl_error_hint), 'timeout' (script exceeded timeoutSeconds). EVERY failure carries an actionable _hint. The most common spawn failure is 'distro not installed' — the hint will tell you to run `wsl -l -v` to enumerate, or fall back to run_script interpreter:bash for the default distro. **v1.8.60+ ADVISORY**: success responses may carry an additive `_hint` when the script looks like a direct HD build (raw `cargo`/`pnpm` targeting `hydrogen-desktop`/`crates/hd-*`); the hint points at the dedicated `hd_*` verbs that handle the exe lock + SHA verification.---## File transfer--_Move files between the cloud container and the laptop (streamed, SHA-verified)._ (2 verbs)--### pull_file--Pull files from the desktop back to Docker. Streams as binary WS frames (1 MiB chunks) — works for files up to several hundred MB. The legacy 30s base64-JSON path is gone; pull_file v1.4.3+ has no practical size limit.--**Args:**-- `filePaths` — required array of absolute Windows paths (e.g. "C:\Users\\me\\Downloads\\big.pdf")-- `saveTo` — optional directory on Docker (default /tmp). Created if missing.--**Returns:** files: [{name, path, size, sha256, chunks}] — sha256 verified before file is finalized; mismatch deletes the partial and reports failure. chunks is the number of 1 MiB binary frames received.--**Note:** Per-file timeout is 600s (enough for ~600 MB at 1 MB/s). For 75 MB files expect ~12s on a typical home connection. errors[] is included alongside files[] when at least one but not all files succeed; success is true iff at least one file landed.---### send_files--Send files from Docker container to the desktop--**Args:**-- `destinationFolder` — optional relative subfolder-- `filePaths` — required array of absolute paths-- `targetApp` — optional: general|kicad|fusion360---## Desktop & bridge mgmt--_Windows, screenshots, files, embedded mode, relay servers, tunnels, and bridge lifecycle._ (44 verbs)--### bridge_check_updates--READ-ONLY: report, for every installed bridge, the version AD runs now vs the latest version on the wiki, and whether an update is available. Downloads NOTHING — this is the 'what's updatable?' probe. v1.8.144+. Use it before refresh_bridges to decide what to pull. Mirrors the GUI's 'Check for updates' button.--**Returns:** {success:true, bridges:[{name, current, latest, updateAvailable:bool, source, wikiReachable:bool}], updatesAvailable:int, _hint}. current = what AD runs now (cache if newer, else bundled). latest = wiki manifest version (null if unreachable / not wiki-hosted). updateAvailable = wiki strictly newer than current.--**Note:** A bundled bridge shipped AHEAD of the wiki (and bundled-only puppeteer, whose deps exceed the wiki file limit) correctly reports updateAvailable=false — AD keeps the newer bundled copy. wikiReachable=false means offline or the bridge isn't wiki-hosted. To APPLY an update: refresh_bridges {"name":"<bridge>"} for one, or refresh_bridges for all.---### bridge_install--Install a bridge from a wiki manifest URL. The manifest's referenced zip is downloaded, sha256-verified, extracted into the cache, and the runtime registry is rescanned. Third-party bridges (Altium, MATLAB, oscilloscope vendors, etc.) ship via wiki using the same scripts/release-bridge.sh format. v1.8.0+.--**Args:**-- `manifestUrl` — required URL pointing at a <bridge>-bridge-manifest.json (e.g. https://wiki.adom.inc/api/v1/pages/adom-desktop/files/altium-bridge-manifest.json)--**Returns:** {success, ok, bridge:<name from manifest>, version, installPath, _hint}--**Note:** The bridge zip MUST contain a bridge.json at its root with name + spawn.{kind,entrypoint,port,...} + verbPrefixes. See the bridge SDK guide at https://wiki.adom.inc/adom/adom-desktop-bridges for the schema. To update an existing bridge, install again — the cache atomic-swaps.---### bridge_list--List every installed bridge (kicad, puppeteer, fusion360, plus any third-party bridges installed via bridge_install). v1.8.0+. Each entry reports name, displayName, version, source (bundled/cache/third_party), installPath, paused flag, spawn kind, ports, verbPrefixes the bridge owns, and verbCount.--**Returns:** {success:true, bridges:[{name, displayName, version, description, source, paused, spawn:{kind, manifestPort, runtimePort, entrypoint}, verbPrefixes, verbCount, ...}], summary:{total, bundled, cache, third_party, paused}, _hint}--**Note:** v1.8.0 made the bridge list dynamic. Bridges are discovered by scanning %LOCALAPPDATA%\Adom Desktop\bridges-cache\<name>\bridge.json (writable; wiki-streamed + third-party installs) and the bundled installer location (read-only fallback). Cache shadows bundled when both exist. Third-party bridges are installed via bridge_install. v1.8.31+: bridge ports are dynamic, no more 8772/8773/8851 hardcoded — eliminates collision with HD.--**Ports:** v1.8.31+: bridges use DYNAMIC ports. `manifestPort` is what the bridge.json declares (0 = 'let adom-desktop pick'). `runtimePort` is what the bridge is ACTUALLY bound to right now (null if not yet spawned). CALLERS NEVER NEED EITHER. Always route bridge verbs via adom-desktop's CLI/direct-API by bridge id (`kicad_*`, `fusion_*`, etc.) — adom-desktop forwards to whatever port the bridge is on. Bridge ports are pure internal plumbing; if you find yourself reading them, you're probably doing something wrong.---### bridge_pause--Pause a bridge: subsequent calls to its verb prefixes (e.g. kicad_*) will fail with bridge_paused until you bridge_resume. The running server process is NOT killed; this is a routing-layer flag.--**Args:**-- `name` — required — bridge name from bridge_list (e.g. 'kicad')--**Returns:** {success, name, paused:true, _hint}---### bridge_resume--Resume a paused bridge — flip the routing flag back. Inverse of bridge_pause.--**Args:**-- `name` — required — bridge name--**Returns:** {success, name, paused:false, _hint}---### bridge_uninstall--Remove a bridge from the cache. v1.8.0+. Bundled bridges (kicad, puppeteer, fusion360 in current installer) revert to the bundled fallback; third-party bridges are fully removed. The running bridge server process is NOT killed automatically; the response _hint includes the kill command if you want it gone immediately.--**Args:**-- `name` — required — bridge name--**Returns:** {success, name, cacheRemoved, stillInstalledViaBundled, _hint}---### desktop_bring_to_front--Bring a window to the foreground--**Args:**-- `hwnd` — optional int-- `state` — optional: maximize or restore (default: preserve current state)-- `titleContains` — optional string (case-insensitive)---### desktop_caption--Show a large always-on-top, click-through text overlay on the user's DESKTOP (not a chat caption) — for narrating what you're doing on their screen, screen-share / demo labels, status banners. Win32 layered window over every app.--**Args:**-- `action` — optional: "hide" (dismiss this id, or all if no id) | "force-clear" (nuke ALL caption windows)-- `duration` — optional ms before auto-clear. Omitted or 0 → 30000 (30s) default. Clamped to max 600000 (10 min). NOT 'forever' anymore.-- `fontSize` — optional int px (8–400), overrides size-- `id` — optional string — captions with the SAME id replace each other (and re-issuing RESETS the timer = how you extend); different ids coexist. Default id "_default".-- `persist` — optional bool. true → sticky, NO auto-clear (supervised use only, e.g. demo recordings) — YOU must dismiss it.-- `position` — optional: top | center | bottom (default) | top-left | top-right | bottom-left | bottom-right-- `size` — optional: small (20px) | medium (32px, default) | large (72px)-- `text` — the caption text (omit, or action:"hide", to dismiss)-- `x` — optional 0.0–1.0 horizontal fraction (overrides position)-- `y` — optional 0.0–1.0 vertical fraction (overrides position)--**Returns:** {visible, text, id, persist, expiresInMs (null if persist), _hint}--**Note:** AUTO-EXPIRE by design so a forgotten caption can't be stranded on the user's screen. Default 30s. To keep one up LONGER: re-issue the same id before it expires (resets the timer — a heartbeat) OR pass a bigger duration (≤10 min). Only persist:true is truly sticky, and then you MUST clean up with {"action":"hide","id":"<id>"}. Always dismiss captions when the narrated action is done.---### desktop_connect_all--v1.8.42+: programmatically trigger 'Connect All' for every enabled server entry. Mirrors the tray-menu button and HD's tray-menu 'Connect All' item. Spawns ws_loop for any entry that isn't already connected.--**Returns:** {ok, success, totalEnabled, spawned, alreadyConnected, _hint}---### desktop_disconnect_all--v1.8.42+: programmatically drop every live WS connection. Server entries stay in config (use server_remove to delete). Mirrors HD's tray 'Disconnect All'.--**Returns:** {ok, success, disconnected, _hint}---### desktop_embedded_enter--v1.8.73+: explicit runtime adopt — HD calls this to switch a running standalone AD into embedded mode immediately (no 60s wait for the embedded_monitor tick). Always sets `entered_via=runtime-adopt`, meaning: when HD subsequently dies, AD will REVERT to standalone (not exit). If HD wants AD to exit on HD-death (the no-hanging-chad rule), HD must spawn AD with `--embedded` instead — that sets `entered_via=launch-flag`. Idempotent: returns `was_embedded:true` if AD was already embedded; no-ops in that case.--**Returns:** {ok:true, success:true, was_embedded:bool, embedded:true, entered_via:'runtime-adopt', owner:'hydrogen-desktop', _hint}--**See also:** desktop_embedded_release (revert), desktop_embedded_status (introspect). HD-side recipe in plugins/hd/SKILL.md.---### desktop_embedded_release--v1.8.73+: explicit transition out of embedded mode. v1.8.77+ ALWAYS reverts to standalone, regardless of `entered_via`. AD's process REMAINS RUNNING after this call — tray restored, footer pill hidden, updater poller resumed, marker deleted. The two operations are now explicitly separate: this verb says 'stop being embedded, become standalone'; use `desktop_shutdown` to exit AD. Pre-v1.8.77 a launch-flag AD would exit on release, which fought HD's spawn supervisor (HD calls release → AD exits → supervisor respawns → looks like AD didn't exit). v1.8.77 decoupling fixes that.--**Returns:** {ok:true, success:true, was_embedded:bool, entered_via_before:'launch-flag'|'runtime-adopt'|null, will_exit:false, _hint}. `will_exit` is always false in v1.8.77+ — kept for back-compat with HD code that checked it pre-v1.8.77.--**See also:** desktop_embedded_enter (adopt), desktop_shutdown (explicit AD exit). The 60s embedded_monitor's HD-process-death branch is unchanged: ungraceful HD death still uses entered_via to decide exit-vs-revert (LaunchFlag→exit, RuntimeAdopt→revert).---### desktop_embedded_status--v1.8.42+: introspect whether AD is currently running in embedded mode (HD bundling AD). v1.8.73+ also reports `enteredVia` — `launch-flag` means HD spawned AD as its child (AD exits when HD dies, no orphan); `runtime-adopt` means AD was running standalone and HD adopted it at runtime via `/embedded/enter` (AD reverts to standalone when HD dies, keeps running). Standalone AD returns embedded=false.--**Returns:** {ok, success, embedded:bool, owner:string|null, source:string|null, enteredVia:'launch-flag'|'runtime-adopt'|null, pendingRelayUrl, pendingRelayName, startHidden:bool, markerPath, markerExists:bool, _hint}---### desktop_install_kicad--Unattended KiCad install via winget (Windows only, v1.6.0+). Single-purpose: runs `winget install --id KiCad.KiCad --silent --accept-package-agreements --accept-source-agreements`. Long-running (2-5 min — downloads ~700 MB then installs). Returns the winget exit code + stdout/stderr so the caller can decide what to do. Doesn't go through shell_execute approval — it's a fixed-command verb the AI calls deliberately, not a free-form shell.--**Returns:** On success: {ok:true, exitCode, stdout, stderr, _hint:"Run kicad_list_versions to verify"}. On winget unavailable: {ok:false, errorCode:"winget_unavailable", _hint}. On winget failure: {ok:false, errorCode:"winget_install_failed", exitCode, stdout, stderr, _hint}.--**Note:** Trigger this when kicad_list_versions returns errorCode:"kicad_not_installed". After install completes, restart Adom Desktop OR call kicad_list_versions again — the kicad bridge caches detection results, so a fresh detection scan may be needed to pick up the freshly-installed binaries.---### desktop_install_node--Unattended Node.js LTS install via winget (Windows only, v1.7.0+). Single-purpose: runs `winget install --id OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements`. ~1-2 min depending on network. Trigger this when ANY browser_* command returns errorCode:"node_not_found" — that error fires when the puppeteer bridge can't auto-spawn because Node isn't installed. After install, retry the original browser_* command — the bridge reads node.exe location from the Windows registry (which winget writes), so a restart isn't needed.--**Returns:** On success: {ok:true, exitCode:0, stdout, stderr, _hint:"Retry the original browser_* command"}. On winget unavailable: {ok:false, errorCode:"winget_unavailable", _hint}. On install failure: {ok:false, errorCode:"winget_install_failed", exitCode, stdout, stderr, _hint}.--**Note:** v1.7.0 closes the fresh-Windows-machine pup gap. Pre-v1.7.0, a user without Node hit a generic 'bridge not running' error and had to debug it. Now: errorCode:"node_not_found" → desktop_install_node → retry. Adom Desktop also pre-fetches Node + Chrome for Testing on first launch after install (background task), so most users won't ever see node_not_found.---### desktop_list_browsers--Catalog of every installed native browser on the desktop, with each browser's profiles + their signed-in account info (when known). Reads each browser's canonical metadata file directly — Chromium-based browsers (Chrome/Edge/Brave) all use the same `Local State` JSON layout; Firefox uses `profiles.ini`. No shell, no version-probing trial-and-error. Use this to introspect what's available BEFORE calling desktop_open_url with a specific browser+profile.--**Returns:** {ok, default:"chrome"|"edge"|"firefox"|"brave"|"unknown" (OS-registered URL handler), browsers:[{name, displayName, exePath, version?, profiles:[{id, name, gaia?, isDefault?}]}]}. Profiles are listed default-first, then alphabetical. id is the filesystem dirname (Chromium: "Default", "Profile 1"; Firefox: profile name). gaia is the signed-in Google/Microsoft account email — populated for Chromium browsers when the profile is signed in, absent otherwise. isDefault is the browser's last-used profile.--**Note:** Typical flow: call desktop_list_browsers, find the profile whose gaia matches what the URL needs (e.g. work email for adom.inc Google Workspace links, personal Gmail address for [email protected] pages, media account for YouTube channel work), then pass that profile.id to desktop_open_url. Falls back gracefully — browsers with no install are absent from the list, profiles with no metadata file (browser never opened) return as an empty list. v1.7.1+.---### desktop_list_files--List files in a directory matching a shell-glob, optionally filtered to those modified after a given timestamp. Pure filesystem op (no shell) — replaces the shell_execute + PowerShell-poll-Downloads pattern that callers like chip-fetcher used to use to wait for a download. NOT recursive; lists one directory only.--**Args:**-- `glob` — optional string (default "*"). Shell-glob: * = any non-separator run; ? = single non-separator char. Case-insensitive on Windows (matches the filesystem's own behavior). Examples: "ul_*.zip", "LIB_*.zip", "*.pdf", "*.step", "datasheet_*.pdf".-- `modifiedSince` — optional. Number (unix seconds) OR string (unix-seconds-as-text OR ISO-8601/RFC-3339 e.g. "2026-05-04T22:30:00Z"). Files with mtime <= since are filtered out. Use to find files that arrived after a click.-- `path` — required string. Windows abs path. ~, %USERPROFILE%, %APPDATA%, %LOCALAPPDATA%, %TEMP% are expanded. Forward slashes (/) and back slashes (\\) both work.--**Returns:** {ok, path, glob, files: [{path, name, size, mtime}, ...] sorted newest-first, count}--**Note:** Common pattern: record `since = $(date +%s)` BEFORE you click a download button, then list_files after. The newest-first sort means files[0] is your fresh download. For a watch-until-arrived loop, use desktop_watch_files instead — same args, but blocks until a match appears.---### desktop_list_windows--List all visible windows with HWNDs, titles, positions---### desktop_logout--v1.8.42+: clear AD's cached Adom Cloud session (~/.adom/session.json). HD calls this on its own sign-out so AD doesn't display a stale email in its footer. Equivalent to the GUI's Sign-out link.--**Returns:** {ok, success, _hint}. After logout, adom_whoami returns null until the user signs in again (via the GUI footer link or another handoff).---### desktop_open_folder--Open file/folder in Windows Explorer--**Args:**-- `path` — required string---### desktop_open_url--Open a URL in the user's NATIVE OS BROWSER — i.e. their everyday daily-driver Edge / Chrome / Firefox / Brave (whatever Windows has registered as the default URL handler), with their existing logged-in sessions, saved passwords, history, bookmarks, and extensions. This is NOT Puppeteer / Chrome for Testing — it's the real browser the human uses. Use this when a HUMAN needs to interact with a page that requires being signed in (claude.ai, GitHub, internal tools, banking, etc.). v1.7.1+ supports `profile` for picking a specific Chrome/Edge/Firefox profile (work vs personal vs media account, etc.) — call desktop_list_browsers first to discover available profile IDs.--**Args:**-- `browser` — optional string: "default" (let Windows pick the user's registered default — they could have Edge, Chrome, Firefox, Brave set as default), "chrome", "edge", "firefox", "brave" (force a specific one if you know they prefer it). Default = "default".-- `profile` — optional string (v1.7.1+) — filesystem profile ID. Chromium-based: pass exact id from desktop_list_browsers (e.g. "Default", "Profile 1", "Profile 2"); maps to --profile-directory. Firefox: pass profile name; maps to -P. Ignored when browser="default" (no clean way to inject profile flags through the OS URL handler). Use desktop_list_browsers to enumerate profiles + their gaia (signed-in account email) so you can pick the right one.-- `url` — required string (https://... or any URL the OS shell can dispatch)--**Returns:** {ok:bool, browser, exePath?, url, profile?} — profile echoed if it was applied--**Note:** desktop_open_url vs browser_open_window — pick the right one:-  • desktop_open_url => NATIVE OS BROWSER (Edge/Chrome/Firefox/Brave the human uses every day). Has saved logins. Human will interact with the page. Claude CANNOT drive it afterward — once you hand off, you're done.-  • browser_open_window => PUPPETEER-controlled CHROME FOR TESTING (separate Chromium build, isolated profile under plugins/puppeteer/profiles/). NO saved logins. Claude DRIVES it programmatically (screenshots, clicks, eval, recording).-  Rule of thumb: human-in-the-loop login flow => desktop_open_url. AI-driven automation => browser_open_window.-  v1.7.1+: when the user has multiple Chrome profiles (work + personal + media account, etc.) call desktop_list_browsers first to enumerate, then pass the matching `profile` ID. The response surfaces gaia/email per profile so you can match by account context.---### desktop_pull_glob--Convenience orchestrator: list_files (or watch_files if wait:true) → for each match, pull_file streams the bytes to a container-side absolute path. One command instead of three. Designed for the canonical chip-fetcher pattern: record before-timestamp, click, wait, pull.--**Args:**-- `glob` — optional string (default "*").-- `path` — required string. Windows abs path; same expansion as desktop_list_files.-- `pollMs` — optional (only used when wait:true). Default 1000.-- `saveTo` — required string. Container-side absolute path (a directory). Parent dirs auto-created. Each pulled file lands at saveTo/<basename>.-- `since` — optional unix-seconds OR ISO-8601. Only files with mtime > since are pulled.-- `timeoutMs` — optional (only used when wait:true). Default 60000.-- `wait` — optional bool (default false). When true, blocks via desktop_watch_files until at least one match appears, then re-lists and pulls everything matching (so .crdownload + final .zip both come along when the browser writes them in the same poll tick). When false, just lists what's there now.--**Returns:** {ok, files:[{name, path, size, sha256, chunks}, ...], errors:[...], matchedCount}--**Note:** Use this instead of shell_execute('powershell -c ... while not exists ...; sleep 1') — that pattern is deprecated for new code. shell_execute itself remains as an escape hatch but should not be used for download polling.---### desktop_revoke_approvals--Clear all shell auto-approve permissions and deny pending approvals---### desktop_screenshot_screen--Capture the entire desktop (all monitors). v1.8.62+ produces full + safe-resized PNG pair to %TEMP%\adom-desktop-shots\screen-<ts>.{full,safe}.png. v1.8.61+ universal screenshot delivery contract applies (CLI auto-pulls both to /tmp/ad-shots/, enriches response with local* + dimensions + narrative). See desktop_screenshot_window for the full return shape — same except hwnd-specific prefix is replaced with `screen-`.--**Returns:** Same shape as desktop_screenshot_window's returns (top-level fullPath + safePath + dimensions; CLI-side local* fields added by v1.8.61 auto-pull).---### desktop_screenshot_window--Capture a specific window by HWND. v1.8.62+ writes BOTH a lossless full-res PNG + a Claude-safe-resized copy (≤resizeMax) to %TEMP%\adom-desktop-shots\window-<hwnd>-<ts>.{full,safe}.png. v1.8.61+ universal screenshot delivery contract: the CLI auto-pulls both PNGs to /tmp/ad-shots/<basename> and enriches the response with localFullPath, localSafePath, localFullExists, localSafeExists, fullBytes, safeBytes, source, narrative. Backward compatible: existing data.image base64 + sizeBytes still inside `output` JSON string.--**Args:**-- `hwnd` — required int-- `resizeMax` — optional int, default 1400. Longest side of the safe-resized PNG.--**Returns:** Top-level: success, output (JSON string with the existing message/data/image/format/encoding/sizeBytes shape), error, fullPath, safePath, fullWidth, fullHeight, safeWidth, safeHeight, fullBytesHost, safeBytesHost, resizeMax. After v1.8.61+ CLI auto-pull: localFullPath, localSafePath, localFullExists, localSafeExists, fullBytes (= bytes on caller side), safeBytes, source, narrative. On disk-write failure (rare): same shape minus the file fields + `_hint` pointing at %TEMP% / AV diagnosis.---### desktop_set_window_state--Set window state without necessarily foregrounding--**Args:**-- `hwnd` — optional int-- `state` — required: maximize|minimize|restore|show|hide-- `titleContains` — optional string--**Note:** Use bring_to_front to foreground + maximize in one call. Use this for minimize/hide/show.---### desktop_show_ui--v1.8.137+: stage an in-window UI element so it can be captured via desktop_screenshot_window (no computer-use). Emits the ad-show-ui frontend event, waits ~350ms for render, returns.--**Args:**-- `show` — optional bool, default true (false hides/resets)-- `target` — required: settings | build_info | direct_api | shell_approval | none (reset)--**Note:** Workflow for docs screenshots: desktop_show_ui {"target":"settings"} then desktop_screenshot_window on the AD window. For embedded-vs-standalone, toggle desktop_embedded_enter / desktop_embedded_release first, then screenshot.---### desktop_shutdown--v1.8.42+: graceful AD exit — stop bridges (kicad, fusion, puppeteer) then exit. HD's tray 'Quit' uses this to take AD down with HD. Standalone callers can use it for clean reset between dev iterations.--**Returns:** {ok, success, output, _hint}. The exit happens ~500ms after the response is sent so the caller doesn't see connection-reset.---### desktop_watch_files--Block until a file matching the glob appears in the directory (mtime > since), or timeoutMs elapses. Pure filesystem op, no shell. Polls fs.read_dir every pollMs (default 1000ms). Replaces the shell_execute + PowerShell while-loop pattern for waiting on a download.--**Args:**-- `glob` — optional string (default "*"). Same semantics as desktop_list_files.-- `path` — required string. Same expansion semantics as desktop_list_files.-- `pollMs` — optional int (default 1000, floor 100). How often to re-list the directory.-- `since` — optional. Same parse rules as desktop_list_files.modifiedSince. DEFAULTS TO NOW — without an explicit `since`, only files created AFTER this watch call started are reported. That matches the canonical 'click then watch' flow; pass an explicit `since` if you might miss the file by starting the watch slightly late.-- `timeoutMs` — optional int (default 60000, max 600000 = 10 min). After this elapses, returns {ok:false, error:"timeout", elapsedMs, _hint}.--**Returns:** On match: {ok:true, path, glob, file:{path,name,size,mtime}, elapsedMs}. On timeout: {ok:false, error:"timeout", elapsedMs, _hint, path, glob}.--**Note:** Returns the FIRST file that matches — sorted newest-first within a poll cycle. Use desktop_pull_glob if you want the watch + multi-file pull in one call.---### desktop_window_hide--v1.8.42+: hide AD's main window. App keeps running in the background; use desktop_window_show to bring it back. Standalone AD users typically use the window's X button which is bound to hide-not-quit.--**Returns:** {ok, success, _hint}---### desktop_window_show--v1.8.42+: show + focus AD's main window. Used by Hydrogen Desktop's tray menu when bundling AD in embedded mode (HD's tray entry 'Open Adom Desktop' fires this). Also reachable standalone — any caller can bring AD to the foreground.--**Returns:** {ok, success, _hint}---### direct_api--INFORMATIONAL — not a CLI verb. v1.8.25+ adds a local HTTP listener on `http://127.0.0.1:47200` inside the adom-desktop GUI. Sibling apps on the same machine (Hydrogen Desktop, future Adom-family apps) can POST commands straight in without spawning the CLI or going through the WS relay. Loopback-only bind. The port (47200) lives in the IANA dynamic/private range (49152-65535-adjacent), squarely outside HD's 47083-47185 fallback range — clearly an Adom-family port, but no collision potential. Same `{app, command, args}` envelope as the WS protocol; same response payload (including `_hint` fields) as the CLI returns. Endpoints: `GET /health` (cheap probe), `GET /status` (capability inventory + `cliRequired` list), `POST /command` (verb dispatch). The dispatcher is `commands::handle_command` — exactly the same code the WS path uses.--**Note:** If you (Docker Claude) are talking to a user authoring a sibling Tauri app and they ask 'how do I call adom-desktop without invoking the CLI', point them at this endpoint + the `direct-api-SKILL.md` asset on the wiki. For your own use (cross-machine Docker → Windows), keep using the CLI — the direct API is loopback-only by design and won't be reachable. Streaming verbs (pull_file, send_files, shell_execute) still require the CLI even for sibling apps; the direct API refuses them with `errorCode:"cli_required"`.--**Env vars:** `ADOM_DIRECT_URL=off` forces relay-only. `ADOM_DIRECT_URL=http://127.0.0.1:47209` skips probe + uses that URL. `ADOM_DIRECT_LISTEN_ADDR=127.0.0.1:47205` (GUI-side) overrides the default bind primary; the 47200-47209 fallback range still applies.--**Discovery:** Port can fall back from 47200 if taken (zombie socket, dev instance, third-party collision). The GUI walks 47200..47209 picking the first free port, then writes its chosen `host:port` to `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`). Sibling apps and the CLI read that file first, then fall back to scanning 47200..47209. SO_REUSEADDR + graceful shutdown ensure zombie sockets from prior crashes don't block the next launch. v1.8.32 and earlier used 8770..8779 — that range is no longer probed; auto-update from the wiki carries users forward.--**Timeouts:** Direct API timeouts are now per-verb (matching the CLI dispatcher table): walk_cloud_tree + search_cloud_files = 620s; heavy exports = 320s; gerbers = 200s; drc/erc = 180s; bridge_install + fusion_start = 300s; default = 120s. Caller can override via `args.timeout` (seconds, clamped to 1800).---### rdp--v1.8.85+: convenience wrapper around `tunnel` + `launch_rdp_client`. Opens a tunnel to the given RDP target then spawns mstsc.exe on the desktop pointed at the local end. One verb = RDP session established (the user still has to type credentials in mstsc). v1.8.86+: defaults local OFF 3389 (which collides with the user's own Windows RDP service on most laptops); spawns mstsc at the ACTUALLY-bound port when the requested port was in use.--**Args:**-- `local` — optional int — local listener port on the desktop. Default 13389 (NOT 3389 — Windows' own Remote Desktop service usually owns 3389 and the bind silently coexists pre-v1.8.86). If the requested local is taken, AD falls back to an OS-assigned ephemeral port; the response's `localPort` + `actualPort` reflect what mstsc was actually pointed at.-- `to` — REQUIRED string — RDP target in `host:port` form. If port omitted, defaults to 3389.--**Returns:** {ok, tunnelId, localPort, requestedLocal, fellBack, toHost, toPort, open, launch, _hint}. `localPort` is the actually-bound port (= what mstsc is connecting to). `fellBack:true` means the requested port was unavailable and AD picked ephemeral.---### refresh_bridges--Force an immediate wiki sync of the bridge cache (kicad / puppeteer / fusion360 + any installed third-party bridges). v1.7.19+: bridges live as standalone wiki assets (decoupled from the Tauri installer ship cycle) and auto-update on launch + every 4 hours. This verb triggers the sync NOW. v1.8.144+: pass {"name":"<bridge>"} to update JUST that bridge; omit to sync all. Tip: run bridge_check_updates FIRST (read-only) to see what's actually updatable before downloading.--**Args:**-- `name` — optional — bridge name from bridge_list (e.g. 'kicad'). Omit to sync every bridge.--**Returns:** {success:true, results:[{bridge, ok, from, to, action}], _hint}. action ∈ {current, current_offline, current_bundled_newer, bootstrapped, updated}. After 'updated' the bridge SERVER PROCESS (python.exe / node.exe) needs to restart to pick up new code — the cache update is on-disk, not in-memory.--**Note:** Updates the writable cache at %LOCALAPPDATA%\Adom Desktop\bridges-cache\<bridge>\. The bridge servers resolve their payload from there (cache-first, bundled-fallback). To FORCE the running bridge to pick up new cache content, follow with: taskkill /f /im python.exe (kicad+fusion) or taskkill /f /im node.exe (puppeteer). On next *_command call the bridge respawns with the new code.---### register_container--v1.8.37+: register THIS cloud Docker container in the user's Adom Cloud profile so Adom Desktop on their Windows laptop auto-discovers + connects after sign-in. Runs entirely on the cloud Docker side — uses /var/run/adom/api-key (the Adom-cloud-mounted session token). PATCHes three carbon-prefs keys: desktop.container.<id> (full ServerEntry as JSON-string), desktop.last_slug, desktop.enabled=true. No relay/desktop dependency. The user's flow: run this verb from cloud Docker → install AD on Windows → sign in → container appears automatically.--**Args:**-- `auth_token` — optional string — relay auth token (defaults to adom-dev-token-2025, which is what the cloud relay accepts by default)-- `auto_connect` — optional bool — auto-dial on AD launch (default true)-- `enabled` — optional bool — enabled in AD's server list (default true)-- `id` — optional string — container id slug (defaults to uuid v4). Stays stable across re-runs if you provide it; the same id PATCHes the same key, overwriting any prior entry.-- `name` — REQUIRED string — human-friendly label for AD's server list (e.g. 'galliaApril')-- `url` — REQUIRED string — public wss:// URL the desktop will dial (typically wss://<container-slug>.adom.cloud/proxy/8765/)--**Returns:** {ok:true, id, name, url, _hint:'now install AD on your Windows laptop + sign in'} on success. On missing args / Carbon failure / api-key unavailable: {ok:false, error, _hint} envelope explaining what to fix.--**User flow:** Cloud Docker: adom-desktop register_container '{"name":"my-container","url":"wss://..."}'  →  user installs Adom Desktop on Windows from https://wiki.adom.inc/adom/adom-desktop  →  user clicks 'Sign in' in AD's footer + completes the browser flow  →  AD's boot-time cloud-config pull (Phase 2) reads desktop.container.* from the user's carbon-prefs + merges into the GUI's server list. Without register_container the user has to paste-JSON manually; with it the container appears automatically.--**See also:** Designed to work with Phase 1 (auth in AD), Phase 2 (cloud-config sync in AD), and Phase 4 (HD→AD session handoff if AD is bundled with Hydrogen Desktop). The full bundle landed across v1.8.33 - v1.8.37.---### relaunch_self--v1.8.103+: visible self-restart helper for dev iteration. Spawns the standalone ad-relauncher.exe (a small signed Rust app shipped in the NSIS bundle) which runs in HOST context and drives the redeploy + relaunch sequence: preflight → stop AD → deploy (atomic temp+rename) → launch (CreateProcessW DETACHED_PROCESS) → verify reachable (polls AD's ephemeral direct-api port from the discovery file, v1.8.118+) → verify version (running PE FileVersion matches source) → verify visible (EnumWindows for the new PID). A visible egui window shows per-step progress + a green HOST / red SANDBOX context banner so the sandbox trap is impossible to miss. Replaces all the prior ad-hoc shell glue — no taskkill, no `start`, no PowerShell, no `.bat`. AD itself goes down partway through Stop AD; the relauncher is detached and survives. Poll progress via `relauncher_status` — v1.8.118+ it returns the terminal outcome from a persisted file even after the relauncher's GUI auto-closes, so the poll loop always terminates.--**Args:**-- `destExe` — optional dest path (default: %LOCALAPPDATA%\Adom Desktop\adom-desktop.exe)-- `noAutoStart` — optional bool — if true, the relauncher shows its GUI but waits for POST /start instead of running immediately-- `port` — optional HTTP API port for the relauncher (default: 47210)-- `sourceExe` — optional path to the freshly-built adom-desktop.exe (default: src-tauri/target/release/adom-desktop.exe relative to the relauncher's location)--**Returns:** {ok, success, relauncherPid, relauncherExe, relauncherPort, _hint}--**See also:** relauncher_status (poll progress), skills/AD_RELAUNCH.md (full recipe).---### relauncher_status--v1.8.103+: query the ad-relauncher's HTTP API and return its full state JSON. AD proxies GET http://127.0.0.1:47210/status. While AD is going down during Stop AD this call will fail with relauncherRunning:false — that's expected; once AD comes back up post-Launch the status appears again. Returns the relauncher's overall status, the current step, per-step progress + events, stopping/launching VersionInfo (PID, path, FileVersion, sha256, signature, window-visible), HOST/SANDBOX context, and the log tail. Poll every 1-2s.--**Returns:** {ok, relauncherRunning, context:{kind:'host'|'sandbox',package_name?}, overall:'pending'|'running'|'done_ok'|'done_fail'|'cancelled', failedStep, currentStep, steps:[{name,status,progress,message,events}], stopping:{pid,path,fileVersion,sha256,signature,size,windowVisible,windowTitle}, launching:{...same shape}, runId, logTail:[{seq,ts_ms,level,text}], _hint}--**See also:** relaunch_self (spawn), skills/AD_RELAUNCH.md (recipe).---### server_add--Register a relay server connection programmatically — equivalent to the GUI's Quick Add paste-box + connect button, but driven by CLI. Upserts by `name`: if a server with the same name already exists, its URL (and optionally authToken) is updated rather than creating a duplicate. By default the connection is established immediately (autoConnect:true). If the URL is unchanged AND we're already connected, no churn — returns ok and leaves the live connection in place. Use this when an external app (e.g. Hydrogen Desktop's Docker container) needs to auto-register its relay at startup without the user pasting JSON. v1.8.22+.--**Args:**-- `authToken` — optional string — defaults to 'adom-dev-token-2025'. Empty string means 'keep existing token if this is an update; otherwise use default'.-- `autoConnect` — optional bool, default true — connect right after upsert. Set false to add the entry without dialling out.-- `name` — required string — human-friendly server label. Acts as the dedup key for upserts.-- `url` — required string — relay WebSocket URL (e.g. ws://localhost:8765 or wss://your-host/proxy/8765/).--**Returns:** {ok:true, name, url, id, connected:bool, created:bool, _hint}. `created` is true when a new entry was inserted, false when an existing one was updated. `connected` is true once the WS loop has been registered (the actual transport handshake may still be pending — call server_list to see the live state).--**Note:** Persists to the same ~/.adom/config.json the GUI uses, so the entry survives a GUI restart. The ws_client supervisor (runs every 30s inside the GUI) auto-reconnects on next launch via the entry's auto_connect flag. To remove: use server_remove '{"name":"<the-name>"}'.---### server_list--Return the current persisted server list with live connection status. Same data the GUI's server-list panel renders, exposed as JSON for scripts/external apps. v1.8.22+.--**Returns:** {ok:true, servers:[{name, url, id, autoConnect, enabled, connected:bool, clientCount:0|1, status:'connected'|'reconnecting'|'disconnected'}], count, _hint}. `connected` is the strict boolean (true only when the WS link is in the Connected state, not Reconnecting). `clientCount` is 0 or 1 per server (each adom-desktop contributes at most one client) — left in the schema for symmetry with the relay's own client-count terminology.---### server_remove--Disconnect (if connected) and delete a relay server entry by name. Inverse of server_add. Idempotent: removing a non-existent server returns ok with removed:false. v1.8.22+.--**Args:**-- `name` — required string — the same `name` you passed to server_add.--**Returns:** {ok:true, removed:bool, wasConnected:bool, name, _hint}. `removed` is true iff an entry was actually deleted; false means no such server was registered.---### shell_auto_approve--Programmatically enable/revoke shell_execute auto-approve. v1.8.3+. v1.8.116+: `permanent` is the DURABLE user preference — it writes AD's Settings checkbox ('Auto-approve shell commands permanently', config-backed, default OFF), so it survives restarts by design. Timed grants (duration_secs) are session-scoped. In embedded mode HD owns this preference: pass `--shell-approval <prompt|1h|24h|permanent>` when spawning AD, and/or drive this verb at runtime; AD's own checkbox goes read-only ('Managed by Hydrogen Desktop').--**Args:**-- `duration_secs` — optional int — seconds to enable for (e.g. 86400 = 24h), session-scoped. Pass 0 to revoke immediately (also clears the durable preference). Ignored if `permanent: true`.-- `permanent` — optional bool — true sets the durable Settings preference + arms it now; false clears it (full off). Default: absent (no preference change for timed grants).--**Returns:** {success, output, active:bool, remainingSecs, permanent:bool}--**Note:** Use sparingly. Auto-approve means anything that can reach the relay can run arbitrary shell commands without user consent. The PUBLIC default is per-command approval — leave it that way for end users; permanent is an explicit owner opt-in. Typical patterns:-  - Dev session: `adom-desktop shell_auto_approve '{"duration_secs": 86400}'` (24h, then expires).-  - Owner's box, durable: `adom-desktop shell_auto_approve '{"permanent": true}'` (equivalent to ticking the Settings checkbox).-  - HD embedded spawn: pass `--shell-approval permanent` (or the user's HD-side choice) on AD's command line — session-only, never persisted by AD.-  - Revoke everything: `adom-desktop shell_auto_approve '{"duration_secs": 0}'`.---### tunnel--v1.8.85+: open a generic TCP port-forward tunnel from a desktop-side 127.0.0.1:<local> listener to a cloud-side `--to host:port` upstream, riding the existing AD↔relay WebSocket. First use case: RDP back into the laptop's user session via mstsc, but the path is identical for any TCP service (SSH, VNC, HTTP, postgres, ...). No new auth path — the WS is already authed; the tunnel is just bytes routed by tunnelId+connId on top of it.--**Args:**-- `local` — REQUIRED int — the local 127.0.0.1 port the desktop should bind. mstsc / your TCP client connects HERE.-- `to` — REQUIRED string — upstream target in `host:port` form. The relay dials this when bytes arrive on the local port.-- `tunnelId` — optional u32 — caller-chosen ID. Defaults to a fresh random u32. Reuse across re-invocations to address the same tunnel.--**Returns:** {ok, tunnelId, toHost, toPort, clientId, actualPort, error?, _hint}. On success, mstsc/anything connecting to 127.0.0.1:<actualPort> on the desktop has its bytes relayed.--**See also:** rdp (one-shot RDP wrapper), tunnel_list, tunnel_close. For multi-connection tests, every fresh client TCP connection to 127.0.0.1:<local> gets its own connId routed independently.---### tunnel_close--v1.8.85+: tear down a tunnel (or one conn within a tunnel). Without `connId`, drops the listener + all conns. With `connId`, drops only that one TCP connection.--**Args:**-- `connId` — optional u32 — if given, close only this conn; otherwise close the whole tunnel-- `tunnelId` — REQUIRED u32 — from `tunnel` / `tunnel_list`--**Returns:** {ok, tunnelId, connId?, scope:'tunnel'|'conn', _hint}---### tunnel_list--v1.8.85+: list all active tunnels — both the relay's view and AD's view (should agree).--**Returns:** {ok, relay:[{tunnelId, toHost, toPort, clientId, connCount}, ...], desktop:{tunnels:[...]}, _hint}---## Screen recording--_Record the desktop/monitors to video; manage the recorder window._ (7 verbs)--### desktop_list_monitors--List available monitors. v1 returns the primary only; full enumeration is v1.1.--**Returns:** monitors: [{index, name, x, y, width, height, primary}]---### desktop_record_list--List completed desktop recording .webm files on disk with their sidecar metadata.--**Returns:** recordings: [{recordingId, filePath, sizeKB, durationMs, reason, mtime}]---### desktop_record_start--Start a WHOLE-DESKTOP recording (every pixel on screen, including unrelated apps) via Chrome MediaRecorder + getDisplayMedia. Opens HUD if needed. Reason is REQUIRED. HUD persists across multiple clips in one session — use desktop_recorder_close to dismiss.--**Args:**-- `audio` — optional bool (default false)-- `confirmDesktopNotTabRecording` — REQUIRED bool — must be true. Acknowledges that you understand this is full-desktop recording, NOT pup tab recording. If user said 'record a pup' / 'record this Chrome window' / 'record the page', use browser_record_start instead. If user wants generic 'record my screen', Hydrogen's built-in `recording start --share screen` is usually preferable. desktop_record_* is mainly for parallel desktop video alongside Hydrogen tab recording (e.g. ralph-loop screenshot tests).-- `fps` — optional int (default 30)-- `maxDurationMs` — optional int (default 600000)-- `monitor` — optional 'primary'|'all'|int (default 'primary')-- `reason` — REQUIRED string — explain to the user why you're recording (renders in the HUD title and sidecar JSON)-- `videoBitsPerSecond` — optional int (default 4000000)--**Returns:** recordingId, filePath (.webm), startedAt, monitor, reason--**Note:** PICK THE RIGHT VERB: 'record a pup' / 'record the tab' / 'record this Chrome page' => browser_record_start (tab-scoped, ~50fps, no foreground requirement). 'record my whole desktop' / 'record across multiple apps' => desktop_record_start with confirmDesktopNotTabRecording:true. Prefer Hydrogen's `recording start --share screen` for casual desktop recording — adom-desktop's desktop recording is mainly for the parallel-with-Hydrogen-tab-capture case.--**Error code:** desktop_record_needs_confirmation when confirmDesktopNotTabRecording is missing/false — read the error and decide if you really want full-desktop or pup-tab recording---### desktop_record_status--Status of the desktop recorder HUD and any active clip.--**Returns:** hudOpen, reason, currentRecording, clipsThisSession---### desktop_record_stop--Stop the active desktop clip. Finalizes the .webm. HUD stays open in Ready state for the next clip in this session.--**Args:**-- `recordingId` — optional (active clip if omitted)--**Returns:** recordingId, filePath, sizeKB, durationMs, stopReason---### desktop_recorder_close--Close the desktop recorder HUD (ends the session). Auto-stops any active clip first.--**Returns:** sessionSummary: {clipCount, totalSizeKB, reason, clips}---### desktop_recorder_open--Open the WHOLE-DESKTOP recorder HUD (visible bottom-right always-on-top window) without starting a clip. Reason is REQUIRED. Same scope as desktop_record_start — the HUD will record the entire desktop, NOT a pup tab.--**Args:**-- `reason` — required string explaining why you're recording--**Returns:** opened, reason--**Note:** PICK THE RIGHT VERB: for pup tab recording use browser_record_start (no HUD needed, tab-scoped). For generic 'record my screen' use Hydrogen's `recording start --share screen`. Use desktop_recorder_open only when you want a visible HUD on screen during a multi-clip session (e.g. parallel desktop video alongside Hydrogen tab capture).---## Notifications--_Native desktop toast notifications._ (1 verbs)--### notify_user--Send a Windows desktop toast notification--**Args:**-- `actions` — optional array of button labels-- `body` — required string-- `level` — optional: info|warning|error|emergency (default: info)-- `title` — required string---## Project watch--_Watch a local folder + sync changes._ (1 verbs)--### args---## Browser (Puppeteer)--_Drive Chrome-for-Testing: navigate, eval, screenshot, record, tabs/windows, credentials._ (31 verbs)--### browser_alert_window--Flash the Chrome window's taskbar icon (does NOT steal foreground)--**Args:**-- `sessionId` — required---### browser_close--Close pup sessions. With sessionId: closes ONLY that session (Chrome window dies, others stay). Without sessionId: closes ALL sessions and Chromes (the bridge itself stays running). Use the sessionId form for targeted cleanup; use the no-arg form when you genuinely want a clean slate.--**Args:**-- `sessionId` — optional — close just this one session--**Note:** If you want the SESSION's tab closed but the Chrome window kept alive (other sessions on the same profile share Chrome), use browser_close_window or browser_close_tab instead.---### browser_close_tab--Close one tab; session + other tabs remain--**Args:**-- `sessionId` — required-- `tabId` — required--**Returns:** remaining tab count---### browser_close_window--Close a session's Chrome window (closes ALL tabs in that session)--**Args:**-- `sessionId` — required---### browser_errors--Get console errors and failed network requests. Optional tabId for per-tab errors. v1.4.10: a stale tabId now returns errorCode:"tab_not_found" instead of silently returning the active tab's errors.--**Args:**-- `clear` — optional bool (default true)-- `sessionId` — optional-- `tabId` — optional---### browser_eval--Evaluate JavaScript in page context. Pass optional tabId to target a specific tab; omit to use the active tab. v1.4.10: passing a stale tabId now returns errorCode:"tab_not_found" with currentTabs + lastKnownUrl (when the missing tab was a recently-closed popup) instead of silently evaluating against the active tab.--**Args:**-- `expr` — required JS string-- `sessionId` — optional-- `tabId` — optional---### browser_fetch_url--Fetch an arbitrary URL with the session's cookies and return the raw bytes. Bypasses Chrome's PDF Viewer wrapper — critical for grabbing PDF binaries from popup tabs where in-page fetch(location.href) returns the viewer HTML wrapper (~200 KB stub) instead of the actual PDF. Also handles ZIPs, CAD bundles, anything served as a binary content-type.--**Args:**-- `body` — optional raw string body for POST etc.-- `desktopSaveTo` — optional DESKTOP-side absolute path (Windows) — bridge writes via fs.writeFileSync. Use when the user wants the file local on the desktop (e.g. dropping a CAD bundle into Downloads). Returns `desktopSavedTo`. Independent of saveTo; you can pass both.-- `headers` — optional object — Cookie auto-included from session-- `method` — optional (default GET)-- `saveTo` — optional CONTAINER-side absolute path. CLI handles the write after receiving bytes from the bridge; parent dirs auto-created. Returned `savedTo` is the canonical absolute path verified to exist on disk (v1.4.9+ — v1.4.8 had a bug where this could lie). saveTo and bodyBase64 are mutually exclusive in the response.-- `sessionId` — optional (defaults to active session)-- `tabId` — optional (picks cookie context — defaults to active tab; cookies are per-browser-context so any tab in the session works)-- `url` — required--**Returns:** {ok, bytes, contentType, status, sessionId, tabId, plus: savedTo + savedToFilesystem:"container" if saveTo was set; desktopSavedTo (or desktopSaveError) if desktopSaveTo was set; bodyBase64 if neither saveTo nor desktopSaveTo was set}--**Note:** Use this when a click spawns a popup tab whose Content-Type is application/pdf — Chrome PDF Viewer wraps it and in-page fetch returns the wrapper HTML. browser_fetch_url re-issues the request through puppeteer's BrowserContext.request which doesn't go through the page render layer. RIGHT PATTERN with popup tabs: read the popup's url from browser_list_tabs (immediately — popups can auto-close), then browser_fetch_url with that URL. NEVER eval-against-popup-then-fetch (popup may have closed; eval would error with errorCode:tab_not_found and a hint pointing here).---### browser_focus_window--Activate the session's tab WITHIN Chrome (Puppeteer page.bringToFront). Does NOT raise the OS window — for that, use browser_raise_os_window.--**Args:**-- `sessionId` — required---### browser_input_dispatch--Dispatch TRUSTED input via Chromium's CDP-backed page.mouse / page.keyboard. Events have isTrusted=true (vs page-side dispatchEvent which is isTrusted=false and silently no-ops on framework gates / anti-automation checks). USE THIS instead of browser_eval-clicks when a click 'lands but does nothing' — most modern frameworks (React/Vue/Svelte) and vendor anti-automation gates check event.isTrusted before running handlers.--**Args:**-- `button` — click only — left|right|middle (default left)-- `clickCount` — click only — int (default 1; use 2 for double-click)-- `delay` — click/type/key only — ms between mousedown+mouseup or keystrokes (default 0)-- `firstMatch` — click only — bool (default false). When true, skips the visible+text+area filter and uses document-order first match. Useful when you've explicitly verified the first match is correct.-- `key` — key only — Enter|Escape|Tab|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|F1|... (any Puppeteer KeyInput name)-- `selector` — click only — CSS selector. v1.4.8+ uses smart-pick when the selector matches multiple elements: filters to visible elements with non-empty text, tiebreaks by largest visual area. v1.4.9 added modal-scoped restriction: if a modal is open, candidates are restricted to elements inside it. v1.4.10 added the isPlausibleModal filter: empty overlay containers (Vue-Toastification toast wrappers, notification mount points) no longer fool modal-detection — only roots containing at least one visible interactive element with text count as modals. Pass firstMatch:true to skip all this and use document-order first match.-- `sessionId` — optional (defaults to active session)-- `steps` — move only — number of intermediate positions for human-like movement (default 1)-- `tabId` — optional (defaults to active tab)-- `text` — type only — string to type into the focused element. Pass selector to focus that element first.-- `type` — required: click | move | type | key-- `x` — click/move only — viewport X coord-- `y` — click/move only — viewport Y coord--**Returns:** click via selector: {ok, sessionId, tabId, dispatched:"click", via:"selector", selector, matchedCount, chosenIndex, clickedRect:{x,y,w,h}, clickedText, insideModal, modalDetected, modalRoot:{tag,id,cls,z,role,ariaModal}|null, pickStrategy:"only-match"|"first-match"|"modal-scoped-largest"|"visible-text-largest"|"visible-text-largest-no-modal-match"|"visible-largest"|"fallback-first-document-order"}. click via coords: {ok, ..., via:"coords", x, y}. move/type/key return {ok, dispatched, ...}.--**Note:** Common cases: vendor 'Generate' / 'Download' buttons that open popups (use type=click + selector); login forms (selector to focus + type=type for username/password + key=Enter); modal dismissal (key=Escape). After a click that should produce a popup tab, follow up with browser_list_tabs — popup tabs auto-track since v1.4.7 with opener="popup" + openerTabId. To grab bytes from a PDF popup (Chrome PDF viewer otherwise serves only the wrapper HTML on in-page fetch), use browser_fetch_url with the popup's URL. v1.4.10: when smart-pick lands on the wrong element, inspect the response's `modalRoot` field to see which DOM node won the modal-detection heuristic — useful for diagnosing toast-container false-positives.---### browser_list_tabs--List all tabs in a session, including auto-tracked popups. Tabs that the page itself opens via window.open() / target=_blank / form-submit-with-target=_blank are tracked automatically since v1.4.7 (Target.targetCreated listener) and appear here alongside Claude-opened tabs.--**Args:**-- `sessionId` — required--**Returns:** tabs: [{tabId, url, title, active, errorCount, opener, openerTabId}], activeTabId, count. opener="user" for tabs Claude opened (browser_open_window/browser_open_tab); opener="popup" for tabs the page spawned (openerTabId names the originating tab — e.g. a product page that spawned a datasheet popup).---### browser_list_windows--List all open browser sessions with URLs and titles---### browser_lower_os_window--Minimize the OS window hosting this pup session (give the user back their desktop after Claude is done with a flow).--**Args:**-- `sessionId` — required--**Returns:** sessionId, hwnd, lowered, error?---### browser_navigate--Navigate to a new URL. Optional tabId to target a specific tab; omit to use the active tab. v1.4.10: passing a stale tabId now returns errorCode:"tab_not_found" with currentTabs + lastKnownUrl (when the missing tab was a recently-closed popup) instead of silently navigating the active tab.--**Args:**-- `sessionId` — optional-- `tabId` — optional-- `url` — required---### browser_open_tab--Add a tab to an existing session (same Chrome window as other tabs in that session — avoids new taskbar icon)--**Args:**-- `sessionId` — required-- `url` — required--**Returns:** tabId, url, title, active---### browser_open_window--Open a Chrome window with a URL (auto-starts Puppeteer bridge). v1.6.3+: every session opens in FULL CAPABILITY MODE by default — JS-triggered downloads are allowed (Page.setDownloadBehavior=allow on every page + framenavigated), clipboard read/write is granted for paste-into-form flows, notifications + geolocation are DENIED so prompts don't pop up under automation. Pup is a tool-driving surface, not a user-facing browser; nearly every chip-fetcher-style flow needs these defaults. Pass strictPermissions:true to opt out (rare).--**Args:**-- `downloadPath` — optional Windows abs path (default `%USERPROFILE%\Downloads`). Where scripted downloads land. Defaults to the OS Downloads folder, which is what desktop_watch_files polls by default — the two ends meet without configuration.-- `freshProfile` — optional bool-- `profile` — required string-- `sessionId` — required string-- `strictPermissions` — optional bool (default false). When true, skip ALL v1.6.3+ defaults: no clipboard permissions, no scripted-download policy, no notification/geolocation suppression. Chromium's normal permission prompts apply, scripted downloads may be silently dropped. Use only when the user genuinely needs to gate everything (very rare).-- `url` — required string--**Returns:** {ok, sessionId, profile, url, tabId, defaultPermissionsApplied (true unless strictPermissions was passed), defaultPermissions (e.g. ['clipboard-read=granted','clipboard-write=granted','notifications=denied','geolocation=denied','midi=denied']), downloadsEnabled (true unless strictPermissions), downloadPath (the resolved absolute path for downloads), restoredTabsClosed, ...}--**Note:** v1.6.3+ unblocks the silent-download-failure scenario. Before v1.6.3, JS-triggered downloads (clicks on `<a download>`, fetch+blob saves, vendor 'Download Symbol' buttons that do `window.location = url`) could be silently dropped by Chrome — no error, no UI, no file in ~/Downloads. The fix: applyDownloadBehavior() sets Page.setDownloadBehavior on every page + framenavigated. Combined with desktop_watch_files for the wait-for-arrival pattern, downloads now Just Work without any caller setup.---### browser_raise_os_window--Raise the OS window hosting this pup session above all desktop apps (real foreground, defeats Windows foreground lock). Use BEFORE recording / screenshots / animations so Chrome doesn't throttle the page (document.hidden=true → ~1Hz rAF). Internally: focuses the tab, then EnumWindows-finds the Chrome window by title containing '(session: <sessionId>)' and runs the AttachThreadInput + keybd_event bypass.--**Args:**-- `sessionId` — required--**Returns:** sessionId, hwnd, title, raised, error?---### browser_record_list--List completed tab recording .webm files on disk with sidecar metadata.--**Returns:** recordings: [{recordingId, filePath, sizeKB, durationMs, fps, sessionId, tabId, mtime}]---### browser_record_start--Record a pup tab via CDP Page.startScreencast + Page.screencastFrameAck (Chrome's native compositor pipeline). Tab-scoped at the protocol level — no cross-tab/cross-Chrome leakage. Output is a single finished VP9 .webm file (no tar bundle, no Docker-side mux). Real-time playback via VFR concat-mux. Concurrent: one recording per tab; many tabs OK. CALL browser_raise_os_window FIRST so Chrome doesn't paint-throttle the page (anti-throttle flags help when occluded but a foregrounded window is always best).--**Args:**-- `fps` — optional int (default 30) — actual fps may exceed target on rAF-animated pages-- `maxDurationMs` — optional int (default 600000 = 10 min)-- `quality` — optional int 0-100 JPEG quality (default 85)-- `sessionId` — required-- `tabId` — optional (defaults to active tab)--**Returns:** recordingId, sessionId, tabId, filePath (.webm), startedAt---### browser_record_status--List active tab recordings with live capture stats.--**Args:**-- `sessionId` — optional (filter to one session)--**Returns:** active: [{recordingId, sessionId, tabId, filePath, fpsTarget, fpsActual, frameCount, durationMs, sizeBytesApprox, startedAt}]---### browser_record_stop--Stop a tab recording. Mux runs synchronously on stop (ffmpeg concat demuxer with per-frame wall-clock durations). Returns a single finished .webm — pull_file directly, no extraction step.--**Args:**-- `recordingId` — required-- `sessionId` — optional--**Returns:** recordingId, sessionId, tabId, filePath (.webm), sizeKB, durationMs, frameCount, fpsTarget, fpsActual, stopReason--**Note:** fpsActual reports the measured rate. If it falls below ~20 when target was 30, the pup window was occluded — call browser_raise_os_window before the next start, and verify the pup Chrome was launched with the anti-throttle flags (sessions opened before bridge v1.3.32 don't have them — close + reopen the session to refresh).---### browser_reload--Reload page and clear error log. Optional tabId. v1.4.10: a stale tabId now returns errorCode:"tab_not_found" instead of silently reloading the active tab.--**Args:**-- `sessionId` — optional-- `tabId` — optional---### browser_rescan--Recover orphaned Chrome windows whose CDP socket dropped (network blip, sleep/wake, puppeteer-side hiccup). 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 from each page's title. v1.5.1+ — paired with strict resolveSession (passing a known-but-disconnected sessionId now returns errorCode:"session_disconnected" instead of silently retargeting an unrelated window).--**Args:**-- `adoptOrphans` — optional bool (default false). When true, pages with no (session: X) title tag get attached under a generated sessionId so the caller can drive them. Useful when Chrome has tabs the bridge has never seen (user opened them manually). Default false to avoid surprising callers with random tabs in their session list.--**Returns:** {ok, rescanned, profilesReconnected, profilesUnreachable, reattached, orphansAdopted, liveSessions, disconnectedSessions, _hint}--**Note:** Idempotent — safe to call repeatedly. The 30s health check now also auto-attempts reconnect, so most transient blips recover before you notice. Use this manually when you suspect the bridge has lost a window (e.g. browser_navigate suddenly errors with errorCode:"session_disconnected").---### browser_screenshot--Capture lossless PNG screenshot (auto-resized to <=1568px at the bridge, then capped to <=1500px in the CLI's defense-in-depth layer). Optional tabId for per-tab capture. Safe to Read into Claude — the two-layer resize is mandatory so the image always fits the many-image API limit.--**Args:**-- `fullPage` — optional bool-- `maxWidth` — optional int (default 1568)-- `sessionId` — optional-- `tabId` — optional--**Returns:** savedTo (file path), sizeKB, width (final on-disk px), height, origWidth (pre-resize, what Chrome captured), origHeight, resized (bool — true if the cap fired), tabId. v1.8.40+ adds the width/height/origWidth/origHeight/resized fields so the caller knows exactly what dims they got without having to probe the file.---### browser_screenshot_full_res--Capture FULL RESOLUTION screenshot (NO resize). WARNING: Do NOT Read the resulting file in Claude — images >2000px crash many-image sessions at the Claude API limit. Use browser_screenshot for AI-readable screenshots. This verb is for saving to disk / wiki / external export only (e.g. handing a designer a high-DPI render). v1.8.40+.--**Args:**-- `fullPage` — optional bool-- `sessionId` — optional-- `tabId` — optional--**Returns:** savedTo (file path), sizeKB, width, height, tabId, _warning. Width/height are the actual capture dimensions (typically 2559x1398 on a standard 1920x1080 viewport with hi-DPI). _warning field repeats the do-not-Read advisory so it's visible in the response payload.--**Note:** Implementation symmetry with browser_screenshot: same path resolution, same disk-save flow at the bridge. Only difference is the sharp resize+recompress step is skipped + the Rust-side save_screenshot's resize cap (defense-in-depth) is bypassed.---### browser_status--Check all sessions: URLs, error counts, alive status---### browser_switch_tab--Make a specific tab the active one (calls bringToFront within Chrome)--**Args:**-- `sessionId` — required-- `tabId` — required---### browser_switch_window--Switch the bridge's active session (which window commands without explicit sessionId target)--**Args:**-- `sessionId` — required---### browser_wait--Wait for content to settle--**Args:**-- `ms` — optional int (default 3000)---### credential_delete--Remove a credential entry by exact host pattern. Drops the password from the OS keychain and the entry from the index.--**Args:**-- `host` — required (must match the host pattern used at credential_set time, exactly)--**Returns:** {ok, host, removedFromKeychain, removedFromIndex}---### credential_list--List stored host patterns + their associated usernames. Passwords are NEVER returned.--**Returns:** {ok, credentials: [{host, username, addedAt, updatedAt}]}---### credential_set--Store HTTP Basic Auth credentials for a host pattern. The password goes into the OS keychain (encrypted at rest by the user's session key); the index file holds host+username only. Subsequent pup navigations to a matching host auto-authenticate.--**Args:**-- `host` — required (e.g. "*.componentsearchengine.com" or exact "nxp.componentsearchengine.com")-- `password` — required (NEVER echoed back via list/get)-- `username` — required--**Returns:** {ok, host, username}---## KiCad bridge--_Drive KiCad 7/8/9/10: open designs, DRC/ERC, libraries, 3D, screenshots, lint._ (27 verbs)--> **Note:** All kicad_* commands are forwarded to the KiCad bridge (port 8772). Use kicad_<command> format.--### kicad_bridge_call--Generic passthrough: call any RPC method on the adom_bridge plugin running inside a specific KiCad exe. Picks the newest alive instance for that exe automatically (or use pid arg to pin to a specific PID). For export_svg / export_pdf use a larger timeout (default 10s; plot can take 5-15s).--**Args:**-- `exeName` — required (kicad, pcbnew, eeschema, kicad-cli)-- `method` — required — see methods list-- `params` — optional dict — method-specific-- `pid` — optional int — pin to a specific process PID (multi-instance disambiguation, v1.7.6+)-- `timeout` — optional float seconds (default 10; bump to 30-60 for exports)--**Returns:** {ok, result:<rpc result>, exeName, pid, port} OR {ok:false, errorCode:"plugin_not_running"|"rpc_error", ...}--**Note:** Methods (v1.7.9): introspection — ping, list_frames, get_menu_ids, wm_command{frame_index,menu_id}, get_board_info / get_schematic_info / get_open_editors. PCB graph (pcbnew only) — get_pcb_footprints, get_pcb_layers, get_pcb_nets, get_pcb_design_rules, get_pcb_tracks (start/end/width/layer/netName), get_pcb_pads (per-footprint pads with position/size/drill/netName), get_pcb_vias (position/diameter/drill/viaType/top+bottom layer), get_pcb_drawings (silk text + edge cuts + dimensions), get_pcb_zones (copper pour inventory with priority/isFilled/layers/boundingBox), get_drc_markers (existing markers from last DRC run, grouped by severity), get_footprint_connections{ref} (net-graph: pin → netName → connected (ref,pin) list). Export (pcbnew only) — export_svg / export_pdf via in-process PLOT_CONTROLLER. Library nav — navigate_symbol{symbolName} / navigate_footprint{footprintName} (filter only — does NOT load). select_and_load_symbol{symbolName, useSendInput?:false} / select_and_load_footprint{footprintName, useSendInput?:false}: filter + Down+Enter keystroke. v0.7.0+ (Phase 2.ac) adds optional useSendInput:true that escalates from wx.PostEvent to real Win32 SendInput — more reliable but briefly steals foreground. v0.8.0+ (Phase 2.ad, bridge bundled in v1.7.17) hardens the foreground acquisition: AttachThreadInput dance + SendInput mouse-click at the search ctrl's screen center, with cursor pos snapshot/restore. Pre-v0.8.0 the naive SetForegroundWindow was frequently downgraded to a taskbar flash and SendInput keystrokes landed in the terminal instead of KiCad. Pair with verify_loaded{kind,expectedName?} to confirm load succeeded (reads frame title). DRC trigger — run_drc_inprocess (posts WM_COMMAND 20088 to PCB Editor; ~2-5s; then poll get_drc_markers). Length-matching — get_track_paths_by_net{netName}. Net graph — get_net_topology{netName} returns components on the net + their other-net bridges (1-hop graph).---### kicad_bridge_status--Enumerate every running KiCad GUI process that has the adom_bridge.py reverse-bridge plugin loaded. v1.7.6+ supports multi-instance disambiguation: discovery files are per-PID (adom-kicad-bridge-<exe>-<pid>.json) so two pcbnew windows on two different boards each get their own row. Probes each /status to confirm live + returns the full inventory.--**Args:**-- `exeName` — optional — filter to one exe (kicad / pcbnew / eeschema / kicad-cli)-- `pid` — optional int — filter to a specific PID-- `pruneStale` — optional bool (default false) — delete discovery files whose plugin doesn't respond to /status--**Returns:** {ok, plugins:[{exeName, pid, port, version, endpoint, alive, uptimeMs, requestCount, discoveryFile, pruned?}], summary:{total, alive}, pluginAutoInstall:{...}, _hint}--**Note:** Phase 2 reverse bridge (v1.7.x+). If summary.alive=0 but you expect a running KiCad, the plugin isn't deployed — call kicad_install_plugin to force-deploy. Stale discovery files (process exited mid-session) show alive=false; pass pruneStale:true to clean them up.---### kicad_click--Click at coordinates in a KiCad window--**Args:**-- `hwnd` — required-- `relative` — optional bool (default true, 0.0-1.0 range)-- `x` — required-- `y` — required--**Note:** Relative coords are scale-independent — estimate position as percentage from screenshot.---### kicad_close--Close all KiCad windows---### kicad_close_3d_viewer--Close 3D Viewer window---### kicad_close_footprint_editor--Close Footprint Editor window---### kicad_close_symbol_editor--Close Symbol Editor window---### kicad_fix_keyboard--Release stuck modifier keys + unlock foreground focus---### kicad_format_upgrade--Upgrade a KiCad file to the current format via kicad-cli. v1.7.13+. Wraps `kicad-cli {pcb,sch,sym,fp} upgrade`. MUTATES the file in place (kicad-cli has no --dry-run). Use kicad_lint_library/board/schematic first for inspection without write.--**Args:**-- `filePath` — required file or directory-- `force` — optional bool (default false) — passes --force to resave even when already current-- `kind` — optional pcb|sch|sym|fp (auto-detected from extension if omitted)--**Returns:** {success, exitCode, kind, stdout, stderr, source_path, _hint}--**Note:** Common use: lint_library reports fileFormatVersions includes old (pre-20231101) entries, surface that to the user, ask permission to upgrade, then call kicad_format_upgrade. After upgrade, ANY OPEN KiCad windows holding this file must be closed + reopened (they have stale in-memory state).---### kicad_install_library--Install a symbol or footprint library--**Args:**-- `kicadVersion` — optional (default = newest installed; library installs into THAT version's sym-lib-table)-- `libraryName` — optional-- `libraryPath` — required-- `libraryType` — required---### kicad_install_plugin--Explicitly (re)deploy the Phase 2 reverse-bridge plugin to every detected KiCad version's USER_SITE. Use when auto-install (which runs once per bridge boot) didn't fire or you need to force a fresh deploy after updating the plugin payload.--**Args:**-- `force` — optional bool (default true). If false, returns the cached auto-install result instead of redoing the work.--**Returns:** {ok, result:{summary, payloadVersion, perVersion:[...], runningKiCad:[...], _hint}, _hint}--**Note:** Idempotent — SHA256 hash compare skips a no-op deploy. If KiCad is currently running when this fires, the running processes still have the OLD plugin in memory until restart — surface result.runningKiCad to the user and tell them to close+reopen KiCad.---### kicad_install_symbol--Decode base64 symbol, install as library, open editor--**Args:**-- `fileContent` — required base64-- `fileName` — required-- `kicadVersion` — optional (default = newest installed)---### kicad_lint_board--Headless lint pre-flight for a .kicad_pcb via kicad-cli. v1.7.12+. Comprehensive: file-existence + magic-byte + DRC with --severity-all + --all-track-errors + --schematic-parity (default on). USE THIS BEFORE kicad_open_board for any file you didn't just generate — catches DRC errors, parse issues, format-upgrade-needed BEFORE the GUI opens and possibly hangs on a modal dialog.--**Args:**-- `filePath` — required .kicad_pcb path-- `schematicParity` — optional bool (default true) — set false if no matching .kicad_sch exists in the project dir--**Returns:** {success, data:{violations[], summary:{total,errors,warnings,info}, fileFormat:{fileVersion}, schematicParityChecked, source_file}, _hint with tiered guidance}--**Note:** Distinct from kicad_run_drc: lint_board adds file-format sanity + schematic-parity + an AI-friendly tiered _hint that EXPLICITLY tells you to proceed / fix / upgrade based on the result. For schematic + board cross-check during a full design pass: kicad_lint_schematic FIRST (catches ERC issues at the source), THEN kicad_lint_board (catches what made it to the board).---### kicad_lint_library--Validate a KiCad library file or directory before install. v1.7.13+. Accepts a single .kicad_sym, single .kicad_mod, .pretty directory, or directory containing .kicad_sym files. Parses S-expression structure (no kicad-cli call — sym/fp upgrade has no --dry-run and would rewrite files). USE BEFORE kicad_install_library / kicad_install_symbol / kicad_install_footprint for any file you didn't just generate.--**Args:**-- `libraryPath` — required path to .kicad_sym / .kicad_mod / .pretty dir / dir with .kicad_sym files--**Returns:** {success, data:{kind, summary:{symbolCount, footprintCount, symFileErrorCount, modFileErrorCount, fileFormatVersions}, symbols:[{name,pinCount,unitCount,fileVersion,generator,path}], footprints:[{name,padCount,layers,fileVersion,generator,path}]}, _hint with tiered guidance}--**Note:** Detects outdated file formats (pre-KiCad-7 versions <20231101) and suggests kicad_format_upgrade. Magic-byte sniff catches wrong-file-type (e.g. .kicad_pcb mis-named .kicad_sym). Failed-parse files surface as _error rows so you can pinpoint which file in a bulk install is broken.---### kicad_lint_schematic--Headless lint pre-flight for a .kicad_sch via kicad-cli. v1.7.12+. File-existence + magic-byte + ERC with --severity-all. USE THIS BEFORE kicad_open_schematic for any file you didn't just generate.--**Args:**-- `filePath` — required .kicad_sch path--**Returns:** {success, data:{violations[], summary:{total,errors,warnings,info}, fileFormat:{fileVersion}, source_file}, _hint with tiered guidance}--**Note:** ERC catches schematic-only errors (unconnected pins, label mismatches, no-driver nets). For board-level validation use kicad_lint_board. Lint schematic FIRST so you fix issues at their source — DRC may surface them differently downstream.---### kicad_list_versions--List every installed KiCad version with paths. Returns versions:[{version, base_dir, kicad_exe, default}], default, count.--**Note:** Run this first if you're not sure which versions are installed. Pass the desired version as kicadVersion on any open_* command to override the default (newest).---### kicad_open_3d_viewer--Open KiCad 3D Viewer (board or footprint).--**Args:**-- `editor` — optional: "auto" (default) | "pcb"|"board" (the PCB's 3D view) | "fp"|"footprint". "auto" prefers the PCB/board viewer when BOTH the PCB and Footprint editors are open — so you do NOT need to close sub-editors to see the board in 3D. Pass editor="pcb" to force the board, editor="fp" for the active footprint.-- `kicadVersion` — optional (default = newest installed)---### kicad_open_board--Open .kicad_pcb in PCB editor--**Args:**-- `filePath` — required-- `kicadVersion` — optional (e.g. "9.0" or "10.0"; default = newest installed)---### kicad_open_editors--Cross-process inventory of every open KiCad editor frame, sourced via the reverse bridge (NOT screen scraping). Hits each alive plugin's get_open_editors method and concatenates. Replaces the kicad_window_info screenshot-poll pattern for the common case.--**Returns:** {ok, editors:[{kind, exeName, pid, frameTitle, hwnd, isShown}], byKind:{schematic_editor:[...], pcb_editor:[...], symbol_editor:[...], footprint_editor:[...], project_manager:[...]}, summary:{totalEditors, probedPlugins, failedPlugins, byKindCounts}, _hint}--**Note:** v1.7.6+. If totalEditors=0 but the user has KiCad open, the plugins likely aren't deployed or KiCad was launched before plugin install. Use kicad_bridge_status to confirm, kicad_install_plugin to deploy, then ask user to restart KiCad.---### kicad_open_footprint_editor--Open Footprint Editor--**Args:**-- `footprintName` — optional-- `kicadVersion` — optional (default = newest installed)-- `libraryName` — optional---### kicad_open_schematic--Open .kicad_sch in schematic editor--**Args:**-- `filePath` — required-- `kicadVersion` — optional (e.g. "9.0" or "10.0"; default = newest installed)---### kicad_open_symbol_editor--Open Symbol Editor--**Args:**-- `kicadVersion` — optional (default = newest installed)-- `libraryName` — optional-- `symbolName` — optional---### kicad_run_drc--Run Design Rule Check on a PCB via kicad-cli (headless, fast, structured JSON). Equivalent to right-clicking DRC in pcbnew but no GUI. Returns {data:{violations[], summary:{total,errors,warnings}}}.--**Args:**-- `filePath` — required-- `kicadVersion` — optional (default = newest installed)--**Note:** v1.7.12+ ships kicad_lint_board with the same DRC + file-format checks + schematic-parity in one structured response — prefer it over kicad_run_drc for new code.---### kicad_run_erc--Run Electrical Rule Check on a .kicad_sch via kicad-cli. v1.7.12+. Mirrors kicad_run_drc but for schematics — catches unconnected pins, duplicate references, hierarchical-label mismatches, no-driver nets, etc. Headless, ~1-3s, structured JSON.--**Args:**-- `filePath` — required .kicad_sch path--**Returns:** {success, data:{violations[], summary:{total,errors,warnings,info}, fileFormat:{fileVersion}, source_file}, _hint guiding next steps}---### kicad_screenshot_all--Screenshot all KiCad windows in one shot--**Returns:** Array of {type, title, hwnd, savedTo, sizeKB}--**Note:** Images downscaled to max 1568px. Use relative coords (0.0-1.0) for clicking — they map to actual window size regardless of image scale.---### kicad_send_key--Send keystroke to KiCad window. Supports modifier combos.--**Args:**-- `hwnd` — optional target HWND-- `key` — required: a named key (enter/escape/tab/space/f1-f12), a single char, OR a modifier combo like "ctrl+s", "alt+3", "shift+f10", "ctrl+shift+s". Modifiers: ctrl/control, alt/menu, shift, win/super. Case-insensitive.--**Note:** Menu hotkeys work via combos (e.g. "alt+3" = View>3D Viewer in pcbnew, "ctrl+s" = save). For dialogs prefer 'enter'/'escape'. Use click only if tab order doesn't reach the button.---### kicad_window_info--List all KiCad windows (project manager, editors, dialogs) with HWNDs and rects---## Fusion 360 bridge--_Drive Fusion 360: CAD export, cloud docs, EAGLE electronics, manufacturing, UI._ (63 verbs)--> **Note:** Two-tier architecture: bridge (port 8773) handles launch/detection; AdomBridge add-in (port 8774) handles exports/queries inside Fusion.--> **Workflow:** fusion_start -> fusion_open_cloud_file or fusion_open_board -> work -> fusion_close--### fusion_export_3mf--Export active design as 3MF--**Args:**-- `outputPath` — required--**Prerequisite:** Active 3D design---### fusion_export_dwg--Export active design as DWG--**Args:**-- `outputPath` — required--**Note:** Not available via API (dialog only). Use fusion_export_step or fusion_export_dxf instead.--**Prerequisite:** Active 3D design---### fusion_export_dxf--Export active design as DXF--**Args:**-- `outputPath` — required--**Note:** Not available via API (dialog only). Use fusion_export_step or fusion_export_iges instead.--**Prerequisite:** Active 3D design---### fusion_export_f3d--Export active design as Fusion 360 archive (.f3d)--**Args:**-- `outputPath` — required--**Note:** Native Fusion format — preserves parametric features, history, component references--**Prerequisite:** Active 3D design---### fusion_export_fbx--Export active design as FBX--**Args:**-- `outputPath` — required--**Note:** FBX only — no fallback. Not available in current Fusion builds. Use fusion_export_usdz or fusion_export_step instead.--**Prerequisite:** Active 3D design---### fusion_export_iges--Export active design as IGES (.igs)--**Args:**-- `outputPath` — required--**Note:** Legacy CAD interchange. STEP is preferred for modern workflows.--**Prerequisite:** Active 3D design---### fusion_export_obj--Export active design as OBJ--**Args:**-- `outputPath` — required--**Note:** Not available via API (dialog only). Use fusion_export_stl or fusion_export_usdz instead.--**Prerequisite:** Active 3D design---### fusion_export_sat--Export active design as SAT (.sat, ACIS solid model)--**Args:**-- `outputPath` — required--**Note:** ACIS kernel format — opens in SolidWorks, SpaceClaim.--**Prerequisite:** Active 3D design---### fusion_export_skp--Export active design as SKP (SketchUp)--**Args:**-- `outputPath` — required--**Note:** Not available via API (dialog only). Use fusion_export_step or fusion_export_stl instead.--**Prerequisite:** Active 3D design---### fusion_export_step--Export active design as STEP--**Args:**-- `outputPath` — required--**Prerequisite:** Active 3D design (not Electronics)---### fusion_export_stl--Export active design as STL--**Args:**-- `outputPath` — required--**Prerequisite:** Active 3D design---### fusion_export_usdz--Export active design as USDZ (best for digital twins + GLB)--**Args:**-- `outputPath` — required--**Note:** Preserves component hierarchy, PBR materials, named nodes. Ideal for Blender→GLB or direct iOS/macOS AR viewing.--**Prerequisite:** Active 3D design---### fusion_get_design_info--Get active design metadata (bodies, components)--**Prerequisite:** Active 3D design---### fusion_get_parameters--List user parameters from active design---### fusion_import_step--Import STEP/STL/IGES/SAT/OBJ/F3D file into Fusion--**Args:**-- `filePath` — required Windows path---### fusion_set_parameter--Modify a user parameter--**Args:**-- `name` — required-- `value` — required---### fusion_take_screenshot--Capture Fusion viewport as PNG with camera orientation control--**Args:**-- `height` — optional (default 1080)-- `orientation` — optional: home, front, back, top, bottom, left, right-- `outputPath` — required-- `width` — optional (default 1920)--**Note:** Uses Fusion's render API (Capture Image) — not a window screenshot. Supports all standard views for product images/icons.---### fusion_check_recovery--Check if a cloud file has crash recovery data--**Args:**-- `fileName` — required-- `folderPath` — optional-- `projectName` — optional--**Note:** Check BEFORE opening files to avoid blocking recovery dialog---### fusion_create_cloud_folder--Create folder (idempotent)--**Args:**-- `folderName` — required-- `parentPath` — optional-- `projectName` — optional---### fusion_delete_cloud_file--Delete a cloud file (must not be open)--**Args:**-- `fileName` — required-- `folderPath` — optional-- `projectName` — optional---### fusion_export_cloud_file--Export active document to local file (step/stl/f3d/iges/sat/smt)--**Args:**-- `format` — optional (default: step)-- `outputPath` — required---### fusion_list_cloud_files--List files and subfolders in a cloud project/folder--**Args:**-- `folderPath` — optional-- `projectName` — optional---### fusion_list_cloud_projects--List all cloud projects in user's hub---### fusion_open_cloud_file--Open a cloud file by name. STOPS if recovery exists — you must specify recovery='open' or recovery='discard'--**Args:**-- `fileName` — required-- `folderPath` — optional-- `projectName` — optional-- `recovery` — optional: open|discard---### fusion_save_to_cloud--Save active document to Fusion cloud--**Args:**-- `description` — optional-- `folderPath` — optional-- `name` — required-- `projectName` — optional---### fusion_search_cloud_files--Search a cloud project / subfolder by file-name substring. Returns matches + a detailed scope analysis. Use this for targeted lookups; for exhaustive whole-cloud walks prefer fusion_walk_cloud_tree.--**Args:**-- `folderPath` — optional starting subfolder (default: project root). NARROW THE SCOPE HERE — naming the right subfolder can reduce search time 10-50x.-- `folderTimeout` — optional int seconds per-folder cloud-API timeout (default 30)-- `maxDepth` — optional int (default 2, no upper cap)-- `maxFolders` — optional int (default 10, no upper cap). For exhaustive 100+ folder searches consider walk_cloud_tree.-- `maxResults` — optional int (default 20, no upper cap)-- `projectName` — optional (defaults to active project)-- `query` — required case-insensitive substring of the file name-- `recursive` — optional bool (default false). false = root folder only (fast); true = descend into subfolders.-- `searchTimeout` — optional int seconds overall wall-clock timeout (default 120). If you set this >180, also pass timeout:<N> to the bridge so the HTTP waiter doesn't give up first.--**Returns:** data{files[], totalFound, foldersSearched, foldersSkipped, filesSkipped, searchComplete, truncated, folderLimitReached, searchTimedOut, elapsedSeconds, costAnalysis{foldersPerSecond, estimatedSecondsPer100Folders}, hint}--**Note:** All defenses (per-folder timeout, doEvents, per-file try/except, watchdog) make hard caps unnecessary. Defaults are conservative; raise maxFolders/maxDepth/maxResults for thorough searches.---### fusion_walk_cloud_tree--Walk a cloud folder tree iteratively (BFS) and return a flat list of folders + files with full paths.--**Args:**-- `extensions` — optional list of extensions to include, e.g. ["f3d","fprj"]-- `folderPath` — optional starting folder (defaults to root)-- `includeFiles` — optional bool (default true) — set false for folder-only tree-- `maxDepth` — optional int (default 10)-- `maxFolders` — optional int (default 500, truncates if exceeded)-- `nameContains` — optional case-insensitive substring filter on file names-- `projectName` — optional (defaults to active)--**Returns:** folders[], files[] (each with path), stats{foldersVisited, filesFound, truncated}--**Note:** Single add-in call does the entire walk. No sleeps, no batching. For targeted finds use nameContains; for exports use extensions.---### fusion_board_info--Get structured board data: components, nets, traces, DRC violations--**Note:** Much richer than a screenshot — exact coordinates and connectivity--**Prerequisite:** Board open in PCB Editor---### fusion_electron_run--Execute EAGLE command (WINDOW FIT, DISPLAY ALL, RATSNEST, DRC, etc.)--**Args:**-- `command` — required EAGLE command string--**Returns:** activeWorkspace, fusionOperations[] (diff of internal log), _hint--**Note:** Fire-and-forget: never returns output. Use fusion_board_info for data. Full command reference with safe/blocking split: skills/eagle-commands.md (ships next to this CLI binary). Avoid blocking commands: WRITE, ADD, SHOW, MOVE, CHANGE, EDIT.---### fusion_execute_text_command--Low-level Fusion app.executeTextCommand() access--**Args:**-- `command` — required Fusion text command---### fusion_export_eagle_source--Export active electronics document as plain EAGLE .sch or .brd XML — extracts from Fusion's .fsch/.fbrd ZIP container--**Args:**-- `outputPath` — required — full path ending in .sch or .brd--**Note:** Produces valid EAGLE XML (<?xml><eagle version=...>) parseable by standalone EAGLE, KiCad import, or any XML tool. For Fusion-native format use fusion_export_source instead.--**Prerequisite:** MUST have correct editor active: PCB Editor / Board Layout for .brd, Schematic Editor for .sch---### fusion_export_lbr--Export library as .scr script (no 3D refs)--**Args:**-- `outputPath` — optional---### fusion_export_source--Export active electronics document as .fsch (schematic), .fbrd (board), or .flbr (library) source file--**Args:**-- `outputPath` — required — full path with extension (.fsch, .fbrd, or .flbr)--**Note:** Always export .fbrd FIRST (from board view), then switch to schematic for .fsch. If file not created, wrong workspace is active — screenshot to check.--**Prerequisite:** MUST have correct editor active: Board Layout for .fbrd, Schematic Editor for .fsch---### fusion_import_electronics--Import Fusion-native .fsch/.fbrd/.flbr file as new local electronics project--**Args:**-- `filePath` — required Windows path to .fsch, .fbrd, or .flbr file--**Note:** Creates a NEW local document via Document.newDesignFromLocal. Use fusion_save_to_cloud to persist afterward. For legacy EAGLE files use fusion_open_schematic (.sch), fusion_open_board (.brd), or fusion_open_lbr (.lbr).---### fusion_list_text_commands--List available EAGLE text commands in current context---### fusion_open_board--Open .brd in Board Layout editor--**Args:**-- `filePath` — required---### fusion_open_electronics--Check if Electronics workspace is active---### fusion_open_lbr--Open .lbr library in Electronics Library editor--**Args:**-- `filePath` — required---### fusion_open_schematic--Open .sch in Schematic Editor--**Args:**-- `filePath` — required---### fusion_show_2d_board--Switch back to 2D board layout from 3D view--**Note:** EAGLE commands only work in 2D view---### fusion_show_3d_board--Switch to 3D PCB view (board needs outline on layer 20)--**Prerequisite:** Board must be open in PCB Editor---### fusion_activate_document--Switch to an open document tab by name or type--**Args:**-- `documentType` — optional: Electronics|PCB|FusionDesign|Drawing-- `name` — optional substring match---### fusion_close--Close Fusion 360 gracefully---### fusion_close_all_documents--Close all open documents--**Args:**-- `saveChanges` — optional bool (default: false)--**Note:** Call before force-killing Fusion to prevent recovery files---### fusion_close_document--Close a document without save dialog (avoids modal blocking)--**Args:**-- `name` — optional (default: active)-- `save` — optional bool (default: false)---### fusion_close_window--Close a specific Fusion dialog window by sending WM_CLOSE (equivalent to clicking its X button). Works on dialogs that Escape doesn't close — e.g. 'Recovered Documents'.--**Args:**-- `hwnd` — required — HWND from fusion_window_info or fusion_dismiss_blocking_dialogs remaining[]--**Note:** Does NOT force-kill — the dialog can still prompt for confirmation. Use when fusion_dismiss_blocking_dialogs reports remaining dialogs that Escape couldn't clear.---### fusion_dismiss_blocking_dialogs--First-class blocking-dialog killer. Enumerates all Fusion Qt windows, matches them against a known list of blocking/picker patterns ('What do you want to design?', error toasts, recovery prompts, etc.), and sends Escape to each offending HWND until the add-in main thread reports responsive. Whenever any fusion_* command returns errorCode 'fusion_addin_not_responding', call this FIRST before retrying.--**Args:**-- `extraPatterns` — optional array of case-insensitive title substrings to also treat as blocking-- `maxAttempts` — optional int (default 5) — retry loop cap--**Returns:** dismissed[{hwnd,title,pattern}], remaining[], mainThreadResponsive, attempts--**Note:** Idempotent. Safe to call any time. Returns immediately with empty dismissed[] when no blocking dialogs are present.---### fusion_document_info--List all open document tabs with cloud metadata for active doc--**Returns:** openDocuments[], activeDoc cloud details (project, folder, version)---### fusion_get_app_state--Query open documents, active workspace, Electronics status--**Returns:** workspace, openDocuments[], electronicsActive---### fusion_start--First-class Fusion 360 startup. Discovers Fusion360.exe via glob (so version-indirected webdeploy hashes don't matter), verifies it exists BEFORE spawning (no 'Windows cannot find' GUI dialogs), launches it if not already running, waits for the AdomBridge add-in to come online, and auto-dismisses any blocking dialogs via fusion_dismiss_blocking_dialogs. Returns when Fusion is fully ready.--**Args:**-- `dismissBlockingDialogs` — optional bool (default true) — auto-clear the 'What do you want to design?' picker, error toasts, and other known blocking modals-- `timeoutSeconds` — optional int (default 120) — total wait for add-in + dialog dismissal--**Returns:** resolvedPath, pid (null if already running), addinReady, dialogsDismissed[], elapsedMs, alreadyRunning--**Note:** ALWAYS use this instead of launching Fusion by hand. It's idempotent — safe to call when Fusion is already running. Process-detection gate means it will NEVER trigger Autodesk's 'Multiple instances are not supported' dialog.---### fusion_detect_layers--Auto-detect PCB copper layer count (2 or 4) using ULP + CAM comparison--**Returns:** layerCount, is4Layer, method, copperLayers[], nextSteps[]--**Note:** Run first — informs gerber CAM job selection and design rule choice---### fusion_export_board_image--Export PNG image with layer presets (top_copper, assembly_top, fabrication, etc.)--**Args:**-- `dpi` — optional int (default: 300, max: 600)-- `layers` — optional int[] — custom layer numbers (overrides preset)-- `listPresets` — optional bool — returns preset list instead of exporting-- `monochrome` — optional bool-- `outputPath` — optional (default: C:/tmp/adom-board.png)-- `preset` — optional: all|top_copper|bottom_copper|top_silkscreen|bottom_silkscreen|top_soldermask|bottom_soldermask|top_paste|bottom_paste|board_outline|drill|assembly_top|assembly_bottom|fabrication--**Returns:** outputPath, dpi, preset, fileSize, nextSteps[]---### fusion_export_bom--Export Bill of Materials as CSV (grouped by value+package)--**Args:**-- `grouped` — optional bool (default: true) — false for one row per component-- `outputPath` — optional (default: C:/tmp/adom-bom.csv)--**Returns:** outputPath, componentCount, uniquePartCount, nextSteps[]--**Note:** Format: Comment, Designator, Footprint, Quantity, Library---### fusion_export_cpl--Export Component Placement List (pick-and-place) as CSV--**Args:**-- `outputPath` — optional (default: C:/tmp/adom-cpl.csv)-- `side` — optional: all (default) | top | bottom--**Returns:** outputPath, totalPlacements, topCount, bottomCount, nextSteps[]--**Note:** Format: Designator, Mid X, Mid Y, Layer, Rotation (mm). Compatible with JLCPCB/PCBWay.---### fusion_export_gerbers--Export Gerber RS-274X + Excellon drill files as ZIP (auto-detects layers, selects CAM job)--**Args:**-- `boardName` — optional (default: from active doc name)-- `layers` — optional: auto | 2 | 4-- `outputDir` — optional (default: C:/tmp/adom-gerbers/)--**Returns:** zipPath, zipSizeKB, layerCount, files[], nextSteps[]--**Note:** ZIP contains GTL, GBL, GTS, GBS, GTP, GBP, GTO, GBO, GKO, XLN. Ready for fab upload.---### fusion_set_design_rules--Apply Adom/JLCPCB design rules (.edru files) — auto-detects 2/4 layer--**Args:**-- `action` — optional: apply (default) | export | show-- `layers` — optional: auto (default) | 2 | 4-- `outputPath` — optional, for export action--**Returns:** edruFile, layerCount, description, nextSteps[]--**Note:** Run DRC after applying rules to check for violations---### fusion_dismiss_recovery--Dismiss recovery dialogs + relocate recovery files to ~/.adom/recovery/--**Note:** Handles both 'Recovered Documents' list and 'Open recovery instead?' prompts---### fusion_relocate_recovery--Move crash recovery files to ~/.adom/recovery/fusion/ (call before launch to prevent dialogs)--**Note:** Files preserved, not deleted. User can manually restore if needed.---### fusion_click_fusion--Click at coordinates in Fusion window or a specific dialog--**Args:**-- `hwnd` — optional — target a specific dialog HWND instead of main window-- `relative` — optional bool (default: true, 0.0-1.0)-- `x` — required float-- `y` — required float--**Note:** Relative coords (0.0-1.0) are scale-independent — estimate position as percentage from screenshot. Prefer fusion_send_key for dialogs (enter/escape/tab covers most cases).---### fusion_screenshot_all--Screenshot main window + list all dialog windows with HWNDs--**Note:** Use fusion_screenshot_fusion with dialog HWND, or desktop_screenshot_window---### fusion_screenshot_fusion--Capture Fusion main window or a specific dialog as PNG--**Args:**-- `hwnd` — optional — dialog HWND to screenshot instead of main window--**Returns:** savedTo file path--**Note:** Image is downscaled to max 1568px. Use relative coords (0.0-1.0) for clicking — they map to actual window size regardless of image scale.---### fusion_send_key--Send keyboard input to Fusion or a specific dialog--**Args:**-- `hwnd` — optional — target a specific dialog HWND-- `key` — required string--**Note:** Preferred way to dismiss dialogs: 'enter' to confirm, 'escape' to cancel, 'tab' to cycle buttons. Use click only if tab order doesn't reach the right button.---### fusion_window_info--Get main window HWND, title, rect, and all Qt dialog windows--**Returns:** hwnd, title, rect, dialogs[{hwnd, title, className, rect}]--**Note:** Essential for detecting blocking dialogs after operations---## Hydrogen Desktop--_Build, ship, screenshot, and drive the embedded Hydrogen Desktop._ (22 verbs)--> **Note:** v1.8.15+: hd_* verbs proxy to Hydrogen Desktop's local API on 127.0.0.1:9001. HD is a sibling Tauri v2 app that runs alongside adom-desktop on the user's machine. These are built-in proxy handlers (NOT a separate bridge process) — when HD isn't running, hd_status returns {ok:false, error:'HD not running'} cleanly instead of crashing.--### hd_api--Generic HTTP pass-through to HD's control API. The canonical way to call ANY HD endpoint from a script/AI without shelling out to curl (which the relay mangles). Auto-discovers HD's control port from %APPDATA%/hydrogen-desktop/ports.json (key 'control'); falls back to 47084. Public-build denylist refuses destructive paths (e.g. /wsl/unregister, /setup/virgin-reset) — use the hd-dev internal build for those.--**Args:**-- `body` — optional object — JSON body for POST/PUT/PATCH. Passed through verbatim (no escaping).-- `method` — optional string (default 'GET') — 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'-- `path` — required string starting with '/' — e.g. '/setup/run-step', '/wsl/status', '/claude/start-auth'-- `port` — optional int — override discovered port; for testing--**Returns:** {success:bool, ok:bool, status:int, body:any, method:str, path:str, port:int} — `body` is the parsed JSON HD returned (or a string if non-JSON)--**Example:** `adom-desktop hd_api '{"method":"POST","path":"/setup/run-step","body":{"id":"welcome"}}'`---### hd_browser_profiles--Enumerate browsers + profiles HD detects on the user's machine (GET /browser-profiles). Use the returned profileDir values when calling hd_open_url.--**Returns:** HD's /browser-profiles response — typically {browsers:[{name, profiles:[{dir, name, gaia?}, ...]}]}---### hd_build--Full HD rebuild (async): git pull → pnpm build → cargo build (debug). Returns immediately with {pid, logPath}; the build runs detached so the relay's 30s HTTP timeout doesn't kill it. Each step appends to %TEMP%\hd-build.log (overwritten at start); the script writes BUILD_OK or BUILD_FAILED:<reason> as its final line so callers can detect completion without scraping tool-specific output. v1.8.16+.--**Args:**-- `lingerSecs` — optional int (default 30 when show=true, 0 when show=false) — v1.8.23+. After the build's final BUILD_OK/BUILD_FAILED line, the visible window stays open for this many seconds before auto-closing. 30s default lets the user read the error; pass 0 to close immediately; pass a larger number for more reading time. Capped at 3600s. Ignored entirely when show=false (no window to linger).-- `show` — optional bool (default false) — true opens a visible console window the user can watch the build scroll in; false runs hidden--**Returns:** {ok, pid, logPath, flavor:'full', show, lingerSecs, _hint}--**Note:** DO NOT call hd_launch until hd_build_status shows succeeded=true. Poll hd_build_status or stream output with hd_build_tail {offset:N}. Build PID is written to %TEMP%\hd-build.pid by the PowerShell script itself so it's authoritative regardless of the wrapper process tree. v1.8.23+: visible build windows now auto-close after lingerSecs (default 30) so build-heavy sessions don't accumulate orphan consoles.---### hd_build_frontend--Frontend-only HD build (async): `pnpm build` from the repo root. Same log/PID/sentinel conventions as hd_build. Use when only Svelte/TypeScript changed and the Rust binary is unchanged — much faster than a full build.--**Args:**-- `lingerSecs` — optional int (default 30 when show=true) — see hd_build for details. v1.8.23+.-- `show` — optional bool (default false)--**Returns:** On dispatch success: {ok, pid, logPath, flavor:'frontend', show, lingerSecs, builtSha, exeMtimeBefore, exeSizeBefore, _hint}. On lock contention (v1.8.57+): {ok:false, refused:true, failedAt:'lock', verb, holderPid, holderVerb, sha, elapsedSec, narrative, _next} — another hd_build_*/hd_ship is already running. NO QUEUE: just refused immediately. The lock auto-releases when the holder's PowerShell PID dies.--**Note:** Skip when the Rust src-tauri/ side changed — those need hd_build or hd_build_rust to rebuild the binary. v1.8.57+ adds a global build lock — two concurrent hd_build_*/hd_ship calls cannot race.---### hd_build_log--Return the FULL build log from %TEMP%\hd-build.log. Use this after hd_build_status reports failed to read the complete output and find the error line.--**Returns:** {ok, log:'<full text>', lines, logPath, _hint}---### hd_build_rust--Rust-only HD build (async): `cargo build` from src-tauri/. Same log/PID/sentinel conventions as hd_build. Use when only Rust code changed (no frontend changes). 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.--**Args:**-- `lingerSecs` — optional int (default 30 when show=true) — see hd_build for details. v1.8.23+.-- `show` — optional bool (default false)--**Returns:** {ok, pid, logPath, flavor:'rust', show, lingerSecs, builtSha, exeMtimeBefore, exeSizeBefore, _hint}--**Note:** Builds the debug binary at target/debug/hydrogen-desktop.exe. The frontend's build/ output is NOT regenerated — re-use whatever was last built by hd_build / hd_build_frontend.---### hd_build_status--Synchronous build-state probe. v1.8.52+ enriched: returns compiledCrates (parsed from 'Compiling X' lines so you KNOW your crate recompiled vs cargo found nothing to do — eliminates the most common stale-relink trap), relinkOnly (true when succeeded but compiledCrates is empty), exeMtimeBefore/After + exeSizeBefore/After + exeChanged (proves the binary actually got rewritten), exeLocked + holderPids (when cargo failed because hydrogen-desktop.exe is held by another process — most often a still-running HD instance), builtSha (the SHA hd_build_rust / hd_build was dispatched at — compare to /buildinfo.build_sha after launch), elapsedSecs (from dispatch to now). State machine: no log → idle; log present without sentinel + PID alive → building; log ends with BUILD_OK → succeeded; log ends with BUILD_FAILED → failed; log present without sentinel + PID dead → treated as failed (orphaned). v1.8.16+.--**Returns:** {ok:bool, building:bool, succeeded:bool, failed:bool, lastLines:[string] (last ~40 lines of stderr), logPath:string, pid:int (cargo PID or 0), pidAlive:bool, failureReason:string (when failed:true — e.g. 'compile_error', 'exe_locked', 'orphaned_no_sentinel'), compiledCrates:[string] (names parsed from 'Compiling X v...' lines — EMPTY means relink-only, your code didn't actually rebuild), relinkOnly:bool (true iff succeeded:true && compiledCrates empty), exeChanged:bool (mtime OR size after differs from before — definitive proof a fresh binary was written), exeMtimeBefore:int (unix seconds), exeMtimeAfter:int (unix seconds), exeSizeBefore:int (bytes), exeSizeAfter:int (bytes), exeLocked:bool (true when build failed because hydrogen-desktop.exe was held by another PID), holderPids:[int] (the PIDs holding the exe — taskkill these), builtSha:string|null (the git SHA hd_build was dispatched at — compare to /buildinfo.build_sha after launch), elapsedSecs:int, flavor:string ('rust'|'frontend'|'full'), _hint:string}--**Note:** **relinkOnly:true on success is the #1 trap to look for** — your push didn't actually change the binary because cargo found nothing to recompile. Usually means your source changes weren't pulled, or you changed a file the workspace doesn't track. Use hd_ship with clean:true to force a fresh build. **exeLocked:true on failure** plus holderPids tells you exactly which PID to taskkill; or just use hd_ship which kills HD before building.---### hd_build_tail--Streaming tail of the build log. Pass offset=0 on first call; the response includes newOffset which you pass back on the next call to get just the new bytes. Lines are split on \n; a partial trailing line (no newline yet) is held back and re-included on the next call. Use this to follow a running build like `tail -f` without re-fetching the whole log every poll. v1.8.16+.--**Args:**-- `offset` — optional int (default 0) — byte offset into the log file from the previous call's newOffset--**Returns:** {ok, lines:[], newOffset, totalBytes, done:bool, succeeded:bool, failureReason, _hint}--**Note:** done=true means BUILD_OK or BUILD_FAILED has appeared in the log — stop polling. done=false means more output expected; sleep 2-3s and call again with newOffset.---### hd_container_exec--[MOVED to hd-dev in v1.8.72] Run a command inside HD's Docker container. Public Adom Desktop refuses with verb_moved_to_hd_dev. Workaround: hd_api '{"method":"POST","path":"/container-exec","body":{"command":"..."}}'.--**Args:**-- `command` — required string--**Returns:** Public build: refusal payload. hd-dev internal build: HD's /container-exec response.---### hd_eval--[MOVED to hd-dev in v1.8.72] Evaluate JS in HD's main webview. Public Adom Desktop returns {error:'verb_moved_to_hd_dev'} with a _hint pointing at the internal build. Workaround: hd_api '{"method":"POST","path":"/eval","body":{"js":"..."}}'.--**Args:**-- `js` — required string — JS expression or statement--**Returns:** Public build: refusal payload. hd-dev internal build: HD's /eval response.---### hd_iframe_eval--[MOVED to hd-dev in v1.8.72] Evaluate JS inside HD's code-server iframe via CDP. Public Adom Desktop refuses with verb_moved_to_hd_dev. Workaround: hd_api '{"method":"POST","path":"/iframe-eval","body":{"js":"..."}}'.--**Args:**-- `contextIndex` — optional int-- `js` — required string--**Returns:** Public build: refusal payload. hd-dev internal build: HD's /iframe-eval response.---### hd_launch--Start HD's debug binary detached. v1.8.52+ returns structured launch state so you NEVER mistake 'attached to a stale running process' for 'launched fresh code'. Three guards refuse the launch: (1) build_in_progress — a hd_build/hd_build_frontend/hd_build_rust is still running; (2) build_failed — last build ended with BUILD_FAILED; (3) already_running — hydrogen-desktop.exe is in the process list. The already_running guard can be overridden with `killExisting:true` (taskkills the existing instance then spawns fresh; killedFirst=true in the response). On success, sleeps briefly then reports HD's actual PID (looked up via tasklist) since the cmd-wrapper PID is gone immediately. v1.8.16+.--**Args:**-- `killExisting` — optional bool (default false). When false and HD is already running, refuse with reason=already_running so the caller doesn't accidentally attach to a stale process. When true, taskkill the existing instance with a 600ms grace period before launching fresh; killedFirst=true in the response.--**Returns:** On launch: {ok:true, launched:true, wasAlreadyRunning:bool, pid:int (HD's actual PID, not the cmd-wrapper), killedFirst:bool (true iff killExisting was honored), exePath:string, builtSha:string|null (the SHA the binary was built at — compare to /buildinfo.build_sha to confirm fresh code), _hint}. On refusal: {ok:false, launched:false, wasAlreadyRunning:bool, pid:int|null, killedFirst:false, reason:string, _hint}. reason ∈ {'build_in_progress' (a hd_build/hd_build_rust/hd_build_frontend is still running — wait for hd_build_status.building:false), 'build_failed' (last build ended with BUILD_FAILED — fix and retry hd_build), 'already_running' (HD is up — pass killExisting:true to force-kill+relaunch, or use hd_restart), 'binary_missing' (target/debug/hydrogen-desktop.exe not on disk — run hd_build first)}.--**Note:** **The launched + wasAlreadyRunning + killedFirst combination is the proof you need** — `launched:true, wasAlreadyRunning:false, killedFirst:false` means truly fresh code is running; any other combination requires you to think about whether your changes actually took effect. builtSha is included so you can immediately compare to /buildinfo.build_sha without a separate call.---### hd_log--Read the tail of HD's log file at %APPDATA%\hydrogen-desktop\hydrogen-desktop.log. Doesn't require HD to be running — reads the file directly from disk. Use this when HD seems stuck or has crashed to see what it said last.--**Args:**-- `tail` — optional int (default 30) — number of trailing lines to return--**Returns:** {ok, lines:[...], path, totalLines, returnedLines}---### hd_open_url--Open a URL in a specific browser profile via HD's /open-in-profile endpoint. HD knows which browsers + profiles are installed (see hd_browser_profiles) and dispatches to the right one with the right --profile-directory / -P flag.--**Args:**-- `browser` — optional (default 'chrome') — 'chrome', 'edge', 'firefox', 'brave'-- `profileDir` — optional (default 'Default') — profile directory name from hd_browser_profiles-- `url` — required string--**Returns:** HD's /open-in-profile response (typically {ok, pid, profile})---### hd_reload_vscode--[MOVED to hd-dev in v1.8.72] Reload the VS Code iframe inside HD. Public Adom Desktop refuses with verb_moved_to_hd_dev. Workaround: hd_api '{"method":"POST","path":"/reload-vscode"}' (allowed by the public denylist).--**Returns:** Public build: refusal payload. hd-dev internal build: HD's /reload-vscode response.---### hd_restart--Stop + launch in one call. Same guards as hd_launch (build_in_progress / build_failed / binary_missing all refuse). If HD is running it's taskkilled with a 600ms grace period before relaunch to release the exe lock. v1.8.16+.--**Returns:** {ok, killedPid?, pid, exePath, _hint}--**Note:** Use after `hd_build_status` shows succeeded=true to pick up the new binary. Don't call when build is in progress — the launch will fail.---### hd_screenshot--Capture the Hydrogen Desktop window as a lossless PNG. Finds the window by title ('Hydrogen Desktop') via the same Win32 enum the desktop bridge uses, then captures via PrintWindow.--**Returns:** {success, savedTo, sizeKB, hwnd, data:{image (base64), format:'png', encoding, sizeBytes}}--**Note:** Requires HD to be running AND its window to be visible (PrintWindow can't capture a hidden window). If HD isn't found, returns 'Hydrogen Desktop window not found' — verify HD is up via hd_status.---### hd_send_files--Send local files DIRECTLY into Hydrogen Desktop's WSL2 project. Relays the bytes to HD's control API, which writes them (as adom) into <project>/<subdir> (default 'downloads', created on first use). Lands in the WSL2 ext4 filesystem so the AI sees them IMMEDIATELY — avoids the plain send_files path (→ Windows Downloads → /mnt/c mount) whose ~5s WSL2 listing-cache makes freshly-dropped files look missing. HD owns the write (it knows its container/bind-mount layout + runs as adom), so ownership + visibility are correct. Requires HD running (hd_launch) and an HD build exposing POST /files/inject.--**Args:**-- `filePaths` — required array of absolute local file paths to send (same as send_files)-- `subdir` — optional string (default 'downloads') — subfolder under HD's project dir to write into; sits alongside the project's screenshots/ folder--**Returns:** send_files-style result: {success:bool, filesReceived:int, destinationPaths:[ '<project>/<subdir>/<name>', ... ], error}. destinationPaths are the WSL2 paths HD actually wrote, so the AI can find them straight away. HD down or endpoint missing → success:false + a clear error.--**Note:** Use this instead of send_files whenever the destination is HD's WSL2 project (it's the cure for the /mnt/c cache-lag 'file isn't there' confusion).---### hd_ship--v1.8.49+: atomic 'rebuild from the exact SHA I just pushed + relaunch + verify it's actually running' sequence. v1.8.57+ adds three guardrails BEFORE doing destructive work: (a) expectSha pre-validation — `git fetch origin main` then `git cat-file -t <sha>` must be `commit` and `git merge-base --is-ancestor <sha> origin/main` must succeed; (b) global build lock at `%TEMP%\hd-build.lock` — refuses immediately if another hd_build_*/hd_ship is in flight (no queue); (c) single-flight lifecycle mutex — refuses if hd_launch/hd_stop/hd_restart/hd_ship is already running. Pipeline after guards pass: (1) taskkill HD + poll until zero (frees exe lock); (2) git fetch + reset --hard origin/main + assert HEAD == expectSha; (3) optional cargo clean; (4) cargo build; (5) launch + poll http://127.0.0.1:47084/buildinfo until SHA matches.--**Args:**-- `clean` — optional bool, default false. true forces `cargo clean -p hydrogen-desktop` before build to defeat the stale-relink trap — use when build said 'Finished' but the running SHA doesn't match expectSha on a prior hd_ship attempt.-- `expectSha` — REQUIRED string — full or prefix SHA of the commit you want running. Matching is bidirectional-prefix (so 'abc123' matches the full SHA starting with abc123 and vice versa). v1.8.57+ pre-validates against origin/main; hallucinated/typo'd/not-yet-pushed SHAs are refused at zero cost before any destructive work.--**Returns:** On success: {ok:true, builtSha (== expectSha), runningSha (== expectSha per /buildinfo), compiled:bool, elapsedSecs, _hint}. On pipeline failure: {ok:false, failedAt: 'args'|'kill'|'git'|'clean'|'build'|'launch'|'verify', error, builtSha|null, runningSha|null, compiled|null, elapsedSecs, _hint}. On v1.8.57+ guard refusal: {ok:false, refused:true, failedAt: 'sha_validate'|'git_fetch'|'lock'|'lifecycle_lock', error, narrative, _youProbablyMeant|_next}. Hallucinated SHA → 'sha_validate' + _youProbablyMeant tells you what real SHAs look like. Lock held → 'lock' + holderPid/holderVerb/elapsedSec tells you who's holding it.--**Note:** Long-running: cold cargo build can take 2-3 min. CLI WS timeout is 12 min — call as a single sync invocation and wait. v1.8.57+: arg validation, SHA validation, and lock acquisition all happen BEFORE any destructive action — if you get `refused:true` at the top, the running HD was not touched. `compiled:false` on success means cargo found nothing to recompile (either cached or HEAD didn't actually change). The verify step requires HD to expose http://127.0.0.1:47084/buildinfo returning JSON with `build_sha` (or `buildSha` or `sha`).---### hd_shot--v1.8.54+: Capture a named region of Hydrogen Desktop, produce BOTH a full-res PNG and a Claude-safe-resized copy, and return rich metadata so the AI never has to chain screenshot→pull→convert→crop→resize→Read manually. Regions are deterministic (no AI-supplied pixel coords) — they resolve via HD window bounds + CDP element rects queried through HD's /eval endpoint. 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. v1.8.61+ universal screenshot delivery contract: the CLI auto-pulls BOTH PNGs to /tmp/ad-shots/<basename>, verifies each landed + is non-empty, and enriches the response with `localFullPath` + `localSafePath` + `localFullExists` + `localSafeExists` + `fullBytes` + `safeBytes` + `source:'hd_shot'` + `narrative` telling you which path to Read for analysis (the safe one).--**Args:**-- `format` — optional string, default 'png' (only PNG supported in v1).-- `region` — REQUIRED string. One of: 'full' (whole HD window), 'titlebar' (top ~40px strip — build SHA / timestamp readout), 'vscode' (VS Code iframe pane via CDP element rect), 'setup-panel' (Setup Steps panel via .setup-panel or .setup-panel-output), 'wiki' (wiki/browser tab pane), 'claude' (Claude Code panel). If unknown, response is {ok:false, error, knownRegions:[...]}.-- `resizeMax` — optional int, default 1400. Longest side of the safe-resized copy. Use lower (≈800) if you want to fit more shots in one Claude turn; higher (1568) for max fidelity.--**Returns:** {ok, region, fullPath, safePath, fullSize:[w,h], safeSize:[w,h], clipRect:[x,y,w,h], capturedAtLocal, runningBuildSha, runningBuildLocal, hdWindowFound:bool, regionFound:bool, windowOuter:[w,h], capturedSize:[w,h], _hints:{pull, fullForArchive, staleWindow, regionMiss}}--**Note:** Always pull_file the SAFE path and Read THAT — it's already sized for Claude's image analysis. The FULL path is meant for shotlog / disk archival, NOT direct Read (may exceed Claude's per-image budget). **regionFound:false** when 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. Either way, the shot is still produced (falls back to the full window) so you have something to look at. **runningBuildSha** can be null if HD's /buildinfo isn't exposed yet — same SHA HD's launch path uses, so you can compare to hd_launch's builtSha. All child spawns use CREATE_NO_WINDOW (no cmd flashes). v1.8.54+.---### hd_status--v1.8.58+ COMPOSED + RELAYED snapshot of HD's state; v1.8.129+ churn-honest. AD owns host/process/repo facts; HD owns runtime state (relayed verbatim from HD's control API, port discovered from %APPDATA%/hydrogen-desktop/ports.json key 'control', fallback 47084 — NOT a hardcoded port). v1.8.129 reports THREE orthogonal signals instead of one boolean so you can reason about HD's constant build-relaunch churn: processPresent (hydrogen-desktop.exe exists, sampled with retry to ride the ~1s kill→respawn gap), controlApiReachable (control port actually listening), and embedded (AD's OWN mode — answers 'is AD embedded under HD?'). `running` = processPresent||controlApiReachable.--**Returns:** {ok:true, running:bool (process||control), processPresent:bool, controlApiReachable:bool, controlPort:int (the dynamically-discovered port), embedded:bool (AD's own mode), embeddedOwner:string|null, embeddedVia:string|null ('LaunchFlag'|'RuntimeAdopt'), laptopHeadSha:string, originMainSha:string, shaMatchesOrigin:bool, runningSha:string|null, workspaceHealth:object|null (HD's /workspace/health relayed verbatim), buildinfo:object|null (HD's /buildinfo relayed verbatim), narrative:string (honest about up-serving vs up-churning vs down, plus AD's embedded mode), _next:string}--**Note:** v1.8.129 fixes the misleading 'HD is NOT running' that fired when a single tasklist snapshot landed mid-relaunch. If you see running:true + controlApiReachable:false, HD is mid-build/relaunch — retry in a few seconds, don't treat it as down. Use this as the entry point for 'what's HD's state?' — it tells you which downstream verb to run next (hd_ship, hd_launch, hd_shot, etc.).---### hd_stop--Kill hydrogen-desktop.exe. `wasRunning` distinguishes 'killed it' from 'nothing to do'. v1.8.16+.--**Returns:** {ok, wasRunning:bool, killedPid?, _hint}
SKILL.md+132−33
@@ -83,11 +83,11 @@ don't exist or miss ones that do.   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+).+- **Auto-update via wiki manifest** (v1.3.39+; single canonical wiki.adom.inc).   AD polls a `version.json` manifest every 4h + on launch from-  `wiki.adom.inc`. Dead-end guard probes the-  `installer_url` before flipping `update_available` (HEAD + Range-GET-  fallback). Footer banner Install Now / Later;+  `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. @@ -463,6 +463,65 @@ 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"}`). |+| `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.@@ -1083,8 +1142,8 @@ 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.adom.inc/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. 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.adom.inc/adom/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; **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.@@ -1357,20 +1416,34 @@ 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"}'`+- `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:@@ -1381,7 +1454,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. @@ -1395,7 +1469,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) @@ -2044,41 +2122,48 @@ Edge cases:  **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` / `rdp` (v1.8.85+)+### 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. -Reach a TCP service that's only addressable from the **cloud side** of the bridge through the **laptop side** — by binding a 127.0.0.1:&lt;port&gt; listener on the user's laptop and pumping bytes over the existing AD↔relay WebSocket. First use case: RDP into a Windows VM that lives behind an NSG only the cloud container can reach — the laptop connects mstsc to a local port and is talking through the cloud. Same path works for SSH, VNC, HTTP, postgres, any other TCP service.+**`tunnel` is a grouped verb** (`rdp` / `tunnel_close` / `tunnel_list` remain back-compat aliases):  ```bash-# Open a generic tunnel — bytes flow laptop:9000 → cloud → host:port-adom-desktop tunnel '{"local":9000,"to":"<host>:<port>"}'+# 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"}' -# Convenience: open + spawn mstsc on the laptop pointed at it.-# rdp defaults `local` to 13389 (NOT 3389 — see "Port collisions" below).-adom-desktop rdp '{"to":"<host>:3389"}'+# 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 -# Inspect / tear down-adom-desktop tunnel_list-adom-desktop tunnel_close '{"tunnelId":<u32>}'+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) ``` -**Port collisions (v1.8.86+ — read this if you're shipping the user an RDP recipe).** Windows' own Remote Desktop service usually owns `0.0.0.0:3389` on a developer's laptop. Pre-v1.8.86, AD bound `127.0.0.1:<requested>` with `SO_REUSEADDR`, so a request for `local:3389` *appeared* to succeed (`actualPort:3389`, `ok:true`), but the kernel routed inbound `mstsc` connections to Windows' own RDP, not AD's tunnel listener — `tunnel_list` would show `connCount:0` while the user's mstsc hit their own machine's login screen instead of the remote VM. v1.8.86 fixes this two ways:+**`--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. -1. **`rdp` defaults `local` to 13389**, not 3389 — a simple way to dodge the collision without thinking about it.-2. **The listener binds with `SO_EXCLUSIVEADDRUSE` on Windows**, so a real port collision returns `WSAEADDRINUSE`. When that happens, AD auto-falls-back to an OS-assigned ephemeral port and returns it in `actualPort`. The response sets `fellBack:true` and includes an `_hint` warning the caller.+**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`. -When using `rdp`, **always read `localPort` from the response** — that's the port mstsc was actually pointed at, which may differ from your requested `local`. The wrapper handles this automatically; you just need to surface it to the user (the `_hint` field has a copy-paste sentence for them).+**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. -Each fresh TCP connection on the laptop side gets its own connId multiplexed over one tunnelId, so concurrent users / mstsc reconnects / parallel SSH sessions all work without re-opening the tunnel. The relay dials `--to` lazily on first byte (idle tunnels cost ~nothing), and half-close (FIN) propagates symmetrically.+**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_open` returns `{ok, tunnelId, actualPort, clientId, error?, _hint}`. On `ok:false`, common causes are (a) AD isn't connected to this relay, (b) the local port is already bound by something else (try a different `local`), or (c) `--to` is unreachable from the cloud side (test from `gallia` directly with `nc` or `curl`). `rdp` returns the tunnel open result + the `mstsc.exe` spawn pid; the user still has to type credentials in the mstsc credential prompt.+**`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:-- The tunnel is bytes-only — no parsing of RDP/SSH/etc. payload. So **any TCP service works**, but TLS/cert-pinning to a specific hostname still pins to whatever the cloud-side dial resolves; for RDP this means the upstream certificate's CN must match what mstsc expects.-- Backpressure is bounded (256-slot mpsc per conn, 64 KiB read buf), so a slow consumer can't OOM the relay — the TCP window closes upstream naturally.-- `tunnel_list` returns both `relay` view + `desktop` view; they should agree. Mismatch = one side leaked an entry, file a bug.-- Tunnels don't survive AD restarts or relay restarts. If `mstsc` disconnects mid-session, just re-open the tunnel.+- 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 workaround to drop**: previously, the only way to RDP to a laptop-isolated VM from a roaming laptop was to whitelist the laptop's WAN IP in the NSG every time the IP changed. `tunnel` / `rdp` ride the existing auth, so the laptop is reachable via the cloud relay regardless of WAN IP.+**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) @@ -2141,6 +2226,20 @@ Pitfalls these verbs eliminate: - 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`.+ ## 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:
dist/linux/adom-desktopadded
⋯ 1 unchanged line ⋯
docs/hero.pngadded
⋯ 1 unchanged line ⋯
docs/screenshot-fusion.pngadded
⋯ 1 unchanged line ⋯
docs/screenshot-gui.pngadded
⋯ 1 unchanged line ⋯
docs/screenshot-kicad.pngadded
⋯ 1 unchanged line ⋯
docs/screenshot-pup.pngadded
⋯ 1 unchanged line ⋯
install.sh+49−34
@@ -1,53 +1,68 @@ #!/usr/bin/env bash-# adom-desktop installer — runs in an Adom container (Linux/WSL2), like every adompkg.+# adom-desktop installer — runs in the Adom container via `adompkg install`. #-# Installs the container-side pieces:-#   1. the BUNDLED Linux CLI companion binary as `adom-desktop` on PATH-#   2. the Claude skill bundle into ~/.claude/skills/+# Bundled-artifact model (v1.8.147+): the Linux CLI companion + the Claude skill+# bundle ship INSIDE this package's tarball (dist/linux/, skills/). We SYMLINK+# them onto PATH / into ~/.claude/skills via the adompkg sh-helpers — the+# lint-sanctioned convention (no copy-into-standard-targets). No download, no+# version.json dependency: the binary is right here, SHA-fixed by the published+# tarball. #-# It does NOT install the Windows/macOS GUI — that runs on the user's laptop.-# This package gives the *container* the CLI + skills that drive that GUI over-# the Adom relay. The Windows GUI installs from the signed NSIS (see below).+# What it does NOT do: install the Windows/macOS GUI. That runs on the user's+# laptop; the signed Windows installer is bundled at dist/win/ for Hydrogen+# Desktop to pull + run there. The skills tell Claude how to get the GUI onto+# the laptop. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)"-OS="$(uname -s | tr '[:upper:]' '[:lower:]')"+cd "$HERE"   # sh-helpers symlink FROM $PWD; `adompkg install` sets cwd here too.++# adompkg shell helpers: adompkg-link-bin / adompkg-link-skill (ln -sfn ...).+if ! command -v adompkg >/dev/null 2>&1; then+  echo "[adom-desktop] FATAL: adompkg not on PATH — install via 'adompkg install adom-desktop'." >&2+  exit 1+fi+# shellcheck disable=SC1090+source "$(adompkg sh-helpers)" +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" case "$OS" in   linux)-    BIN="$HERE/bin/adom-desktop"-    if [ ! -f "$BIN" ]; then-      echo "[adom-desktop] FATAL: bundled bin/adom-desktop missing from the package." >&2+    CLI="dist/linux/adom-desktop"+    if [ ! -f "$CLI" ]; then+      echo "[adom-desktop] FATAL: bundled Linux CLI missing ($CLI). Package was published without staging." >&2       exit 1     fi-    chmod +x "$BIN" 2>/dev/null || true-    if sudo -n install -m 755 "$BIN" /usr/local/bin/adom-desktop 2>/dev/null; then-      DEST="/usr/local/bin/adom-desktop"-    else-      mkdir -p "$HOME/.local/bin"-      install -m 755 "$BIN" "$HOME/.local/bin/adom-desktop"-      DEST="$HOME/.local/bin/adom-desktop"-      case ":$PATH:" in-        *":$HOME/.local/bin:"*) ;;-        *) echo "[adom-desktop] NOTE: $HOME/.local/bin is not on PATH — add it to your shell rc." ;;-      esac-    fi-    echo "[adom-desktop] CLI -> $DEST ($("$DEST" --version 2>/dev/null || echo installed))"+    chmod +x "$CLI"+    # Symlink ~/.local/bin/adom-desktop -> $PWD/dist/linux/adom-desktop.+    adompkg-link-bin adom-desktop "$CLI"+    case ":$PATH:" in+      *":$HOME/.local/bin:"*) ;;+      *) echo "[adom-desktop] NOTE: $HOME/.local/bin is not on PATH — add it to your shell rc." ;;+    esac+    echo "[adom-desktop] CLI -> $("$HOME/.local/bin/adom-desktop" --version 2>/dev/null || echo installed)"+    ;;+  *mingw*|*msys*|*cygwin*)+    echo "[adom-desktop] Windows GUI installer is bundled at: $HERE/dist/win/ (run the *_x64-setup.exe), or pull it from https://wiki.adom.inc/adom/adom-desktop"+    ;;+  darwin)+    echo "[adom-desktop] macOS GUI not bundled yet — see https://wiki.adom.inc/adom/adom-desktop"     ;;   *)-    echo "[adom-desktop] No container CLI for OS '$OS' — installing skills only." >&2+    echo "[adom-desktop] unsupported OS: $OS" >&2+    exit 1     ;; esac -# Claude skill bundle — each skills/<name>/SKILL.md installs as its own skill.-if [ -d "$HERE/skills" ]; then-  for d in "$HERE/skills"/*/; do+# Claude skill bundle — symlink each skills/<slug>/ into ~/.claude/skills/<slug>.+# Slugs staged at publish time: adom-desktop, adom-desktop-pup, adom-desktop-kicad,+# adom-desktop-fusion, adom-desktop-direct-api, adom-desktop-installer,+# adom-desktop-discovery.+if [ -d "skills" ]; then+  for d in skills/*/; do     [ -f "$d/SKILL.md" ] || continue-    name="$(basename "$d")"-    mkdir -p "$HOME/.claude/skills/$name"-    cp -f "$d/SKILL.md" "$HOME/.claude/skills/$name/SKILL.md"-    echo "[adom-desktop] skill -> $name"+    slug="$(basename "$d")"+    adompkg-link-skill "$slug" "skills/$slug"   done fi -echo "[adom-desktop] done. Try: adom-desktop ping   (needs the Adom Desktop GUI running on the user's laptop)."-echo "[adom-desktop] Windows GUI: signed installer at https://github.com/adom-inc/adom-desktop/releases/latest (auto-updates once installed)."+echo "[adom-desktop] done. Try: adom-desktop ping   (needs the Adom Desktop GUI running on the user's laptop)"
package.json+17−11
@@ -1,8 +1,7 @@ {   "slug": "adom-desktop",-  "version": "1.8.148",+  "version": "1.8.160",   "type": "app",-  "visibility": "public",   "description": "Laptop bridge: file transfer, screenshots, notifications, KiCad + Fusion 360 control, real-Chrome (Puppeteer) automation, and Hydrogen Desktop proxy. One install gives Claude every desktop verb it needs.",   "keywords": [     "adom-desktop",@@ -16,6 +15,17 @@     "websocket",     "cli",     "screenshot",+    "recording",+    "desktop-automation"+  ],+  "tags": [+    "adom-desktop",+    "tauri",+    "claude-code",+    "bridge",+    "kicad",+    "fusion360",+    "puppeteer",     "desktop-automation"   ],   "scripts": {@@ -25,18 +35,14 @@   "install": {     "binary_name": "adom-desktop"   },+  "dependencies": {},   "files": [     "SKILL.md",     "README.md",-    "package.json",     "install.sh",     "uninstall.sh",+    "docs/**",     "skills/**",-    "screenshots/**",-    "bin/**"-  ],-  "metadata": {-    "platform": "windows-gui + linux-cli companion"-  },-  "dependencies": {}-}+    "dist/**"+  ]+}\ No newline at end of file
page.json+17−20
@@ -1,10 +1,7 @@ {   "slug": "adom-desktop",-  "version": "1.8.148",+  "version": "1.8.160",   "type": "app",-  "visibility": {-    "public": true-  },   "description": "Laptop bridge: file transfer, screenshots, notifications, KiCad + Fusion 360 control, real-Chrome (Puppeteer) automation, and Hydrogen Desktop proxy. One install gives Claude every desktop verb it needs.",   "keywords": [     "adom-desktop",@@ -18,6 +15,17 @@     "websocket",     "cli",     "screenshot",+    "recording",+    "desktop-automation"+  ],+  "tags": [+    "adom-desktop",+    "tauri",+    "claude-code",+    "bridge",+    "kicad",+    "fusion360",+    "puppeteer",     "desktop-automation"   ],   "scripts": {@@ -27,32 +35,21 @@   "install": {     "binary_name": "adom-desktop"   },+  "dependencies": {},   "files": [     "SKILL.md",     "README.md",-    "package.json",     "install.sh",     "uninstall.sh",+    "docs/**",     "skills/**",-    "screenshots/**",-    "bin/**"+    "dist/**"   ],-  "metadata": {-    "platform": "windows-gui + linux-cli companion"-  },-  "dependencies": {},+  "visibility": "public",   "org": "adom",+  "tag": "latest",   "author": {     "name": "Kyle Bergstedt",     "email": "[email protected]"-  },-  "title": "Adom Desktop",-  "changelog": "Wire the page hero billboard on the main Adom Desktop page (was missing while all sub-pages had one)",-  "updated_at": "2026-06-21T12:37:58.635Z",-  "hero_type": "image",-  "hero_path": "hero-billboard-v2.png",-  "hero": {-    "type": "image",-    "path": "hero-billboard-v2.png"   } }\ No newline at end of file
skills/adom-desktop-direct-api/SKILL.md+36−3
@@ -101,12 +101,43 @@ curl -sf http://127.0.0.1:47200/status     "envelope": "{\"app\": <namespace>, \"command\": <verb>, \"args\": <args object>}",     "responseShape": "Identical to what `adom-desktop <verb>` returns — same `_hint` fields, same `success`/`ok`/`error` keys, same payload structure. The CLI and direct paths converge in `commands::handle_command`."   },-  "_hint": "POST /command with {app, command, args}. See https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-desktop for the verb catalog."+  "wsl": {+    "wslProcessCount": 18,+    "wedged": false,+    "_hint": null+  },+  "_hint": "POST /command with {app, command, args}. See https://wiki.adom.inc/adom/adom-desktop for the verb catalog." } ``` +The **`wsl` block (v1.8.157+)** surfaces WSL health: `wslProcessCount` is a cheap Toolhelp snapshot of live `wsl.exe`; `wedged:true` (at/above 50) warns that orphaned/hung clients are piling up. When wedged, `_hint` points to `wsl_recover`. If a `wsl_exec`/`run_script` call returns `errorCode:"wsl_wedged"` (refuse-cap at 80), the machine is wedging — call `wsl_recover` (dry-run, then `{confirm:true}`) before retrying, rather than spawning more doomed clients. `wsl_recover` is reachable via `POST /command {app:"desktop", command:"wsl_recover"}` (the `desktop_` prefix is optional).+ The `schema` field is the contract version. v1 is the only one shipped. If `schema > 1` ever appears, expect a breaking change in the envelope/response shape and read this skill again. +### `GET /commands` (v1.8.156+)++Verb discovery — what `{app, command}` can a `/command` caller reach? A bridge delegating to AD no longer has to probe verb-by-verb and hit "Unknown desktop command".++```bash+curl -sf http://127.0.0.1:47200/commands+```++```json+{+  "ok": true,+  "desktop": {+    "app": "desktop",+    "commands": ["list_windows","find_window","screenshot_window","taskbar","flash_window","activity_filter","run_script","process_list", "..."],+    "note": "POST /command {app:\"desktop\", command}. The desktop_ prefix is OPTIONAL (desktop_taskbar == taskbar). Bad/missing args → the verb's _hint returns the arg schema."+  },+  "bridges": [{"name":"native-browser","verbPrefixes":["nbrowser_"],"verbs":["..."],"paused":false}],+  "cliRequired": ["pull_file","send_files","shell_execute"],+  "_hint": "Desktop verbs → app:\"desktop\" (prefix optional). Bridge verbs → app:\"dynamic\", command = full verb incl. prefix. cliRequired verbs need the CLI, not this HTTP API."+}+```++Pair it with the per-verb schema hints: call a verb with missing args and its `_hint` returns `required`/`optional`/`example`. Between `/commands` (what exists) and the bad-args hint (how to call it), a fresh AI needs no prior knowledge.+ ### `POST /command`  Dispatches a verb. Body envelope is identical to the WS protocol's `CommandPayload`:@@ -428,9 +459,11 @@ The `cliRequired` array in `GET /status` lists the verbs that need the CLI binar  **The prefix-stripping rule varies per namespace.** Easiest mental model: look at the CLI's `cli/src/commands.rs` dispatch line for the verb you want. Whatever string it passes as the second arg of `relay::desktop_command(app, command, ...)` is exactly what your direct-API envelope's `command` field should be. +**v1.8.153+: for `app:"desktop"` the `desktop_` prefix is OPTIONAL — POST the public verb name as-is.** A bridge delegating via `ADOM_DIRECT_API_URL/command` can send the exact verb it knows (`desktop_taskbar`, `desktop_flash_window`, `desktop_list_windows`, `desktop_find_window`, `desktop_screenshot_window`, `desktop_record_window_start/stop/status`) OR the stripped form (`taskbar`, …) — both dispatch. (Before 1.8.153 the dispatch arms were unprefixed, so `desktop_taskbar` returned "Unknown desktop command" while `run_script` worked — that asymmetry is gone.)+ | `app` value | What goes in `command` | Examples (CLI verb → direct envelope) | |---|---|---|-| `desktop` | full verb name (no prefix to strip) | `server_add` → `{app:"desktop", command:"server_add"}` |+| `desktop` | public verb name; `desktop_` prefix optional (v1.8.153+) | `desktop_taskbar` → `{app:"desktop", command:"desktop_taskbar"}` (or `"taskbar"`); `server_add` → `{app:"desktop", command:"server_add"}` | | `kicad` | **stripped** — drop the `kicad_` prefix | `kicad_open_board` → `{app:"kicad", command:"open_board"}` | | `fusion360` | **stripped** — drop the `fusion_` prefix | `fusion_start` → `{app:"fusion360", command:"launch"}` (note: also the verb name shifts internally) | | `browser` | **kept** — pup bridge dispatches on the full name | `browser_open_window` → `{app:"browser", command:"browser_open_window"}` |@@ -471,4 +504,4 @@ If `GET /status` returns `version < 1.8.25` OR fails entirely OR `schema` is mis - [`README.md`](../README.md) — feature list - [`skills/SKILL.md`](SKILL.md) — full verb catalog (the entries Docker callers read) - [`src-tauri/src/direct_api.rs`](../src-tauri/src/direct_api.rs) — implementation-- [Adom Wiki — apps/adom-desktop](https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-desktop) — public verb reference+- [Adom Wiki — apps/adom-desktop](https://wiki.adom.inc/adom/adom-desktop) — public verb reference
skills/adom-desktop-fusion/SKILL.md+1−1
@@ -61,7 +61,7 @@ Check `fusion_addin_status` first — if the add-in isn't loaded, these will fai  ### Performance + the slow-search etiquette -Cloud search runs at ~1 folder/sec because Autodesk's free Fusion 360 Python API has no indexed search endpoint — the bridge walks the cloud Data API one folder at a time. **This is an Autodesk limitation, not adom-desktop's.** When telling the user the search is slow, attribute it to Autodesk / Fusion 360. Autodesk's paid Autodesk Platform Services (APS, formerly Forge) Data Management API does have indexed search; teams with APS credentials can fork `plugins/fusion360/addin/AdomBridge/` to add an `aps_search` verb and PR back to https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-desktop — the community would benefit.+Cloud search runs at ~1 folder/sec because Autodesk's free Fusion 360 Python API has no indexed search endpoint — the bridge walks the cloud Data API one folder at a time. **This is an Autodesk limitation, not adom-desktop's.** When telling the user the search is slow, attribute it to Autodesk / Fusion 360. Autodesk's paid Autodesk Platform Services (APS, formerly Forge) Data Management API does have indexed search; teams with APS credentials can fork `plugins/fusion360/addin/AdomBridge/` to add an `aps_search` verb and PR back to https://wiki.adom.inc/adom/adom-desktop — the community would benefit.  **Before firing a broad search**, ask the user which subfolder is likely. Naming a folder like "Molecules" cuts 5-40 min searches down to ~10s. The verb's response includes a `costAnalysis` block (`foldersPerSecond`, `estimatedSecondsPer100Folders`) so the AI can budget realistically. 
skills/adom-desktop-installer/SKILL.md+8−8
@@ -233,9 +233,9 @@ the latest registered version and its assets in one or two GETs — note `?org=adom` is REQUIRED (the slug exists in multiple orgs; bare URLs 400):  ```-https://git-wiki-ktqxite5iglh.adom.cloud/api/v1/packages/adom-desktop/dist-tags?org=adom+https://wiki.adom.inc/api/v1/packages/adom-desktop/dist-tags?org=adom   → {"dist_tags":{"latest":"X.Y.Z"}}-https://git-wiki-ktqxite5iglh.adom.cloud/api/v1/packages/adom-desktop/manifest?org=adom+https://wiki.adom.inc/api/v1/packages/adom-desktop/manifest?org=adom   → { version, ..., assets: { "windows-x64": { filename, url, sha256, size } } } ``` @@ -246,8 +246,8 @@ server-relative (`/blob/app/adom-desktop/<file>`); prefix the git-wiki host. **Fallback / updater path: version.json**, V2-first with V1 fallback:  ```-https://git-wiki-ktqxite5iglh.adom.cloud/api/v1/pages/adom-desktop/files/version.json-https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-desktop/version.json+https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json+https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json ```  The V2 manifest's `windows.installer_url` / `cli.<plat>.binary_url` point at@@ -267,10 +267,10 @@ should fetch the latest via the V2-first pattern: # Try V2 first, fall back to V1. fetch_manifest() {   curl -fL --max-time 15 \-    "https://git-wiki-ktqxite5iglh.adom.cloud/api/v1/pages/adom-desktop/files/version.json" \+    "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json" \     2>/dev/null \   || curl -fL --max-time 15 \-    "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-desktop/version.json"+    "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json" }  MANIFEST=$(fetch_manifest)@@ -331,7 +331,7 @@ is renamed to `adom-desktop` so the command users type doesn't change:  ```bash # Linux example — install the CLI companion-curl -fL "https://git-wiki-ktqxite5iglh.adom.cloud/api/v1/pages/adom-desktop/files/adom-desktop-cli_1.8.113_linux_x86_64" \+curl -fL "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/adom-desktop-cli_1.8.113_linux_x86_64" \   -o /tmp/ad sudo install -m 755 /tmp/ad /usr/local/bin/adom-desktop adom-desktop --version@@ -441,7 +441,7 @@ if [ -n "$LINUX_CLI_URL_NEW" ]; then   curl -fL "$LINUX_CLI_URL_NEW" -o /tmp/ad else   # Falling back — likely talking to a pre-v1.8.113 manifest-  curl -fL "https://git-wiki-ktqxite5iglh.adom.cloud/api/v1/pages/adom-desktop/files/adom-desktop-linux" \+  curl -fL "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/adom-desktop-linux" \     -o /tmp/ad fi ```
skills/adom-desktop-kicad/SKILL.md+2−2
@@ -47,7 +47,7 @@ If you pass a version that's not installed, the bridge returns a hint listing th | `kicad_open_board` | `open_board` | Open a `.kicad_pcb` in the PCB Editor | `filePath`, optional `kicadVersion` | | `kicad_open_symbol_editor` | `open_symbol_editor` | Launch Symbol Editor (optional: open specific library/symbol) | `libraryName`, `symbolName`, optional `kicadVersion` | | `kicad_open_footprint_editor` | `open_footprint_editor` | Launch Footprint Editor | `libraryName`, `footprintName`, optional `kicadVersion` |-| `kicad_open_3d_viewer` | `open_3d_viewer` | Show the board in 3D viewer | optional `kicadVersion` |+| `kicad_open_3d_viewer` | `open_3d_viewer` | Show the board (or footprint) in 3D viewer | `editor` ("auto"/"pcb"/"fp" — "auto" prefers the board when both editors are open, no need to close sub-editors), optional `kicadVersion` | | `kicad_close_symbol_editor` | `close_symbol_editor` | Close Symbol Editor | — | | `kicad_close_footprint_editor` | `close_footprint_editor` | Close Footprint Editor | — | | `kicad_close_3d_viewer` | `close_3d_viewer` | Close 3D viewer | — |@@ -59,7 +59,7 @@ If you pass a version that's not installed, the bridge returns a hint listing th | `kicad_run_drc` | `run_drc` | Run DRC on the current board, return violations | `filePath`, optional `kicadVersion` | | `kicad_fix_keyboard` | `fix_keyboard` | Workaround stuck-modifier bug after screen lock | — | | `kicad_screenshot_all` | `screenshot_all` | Capture every open KiCad window (lossless PNGs) | — |-| `kicad_send_key` | `send_key` | Send a keyboard shortcut to a KiCad window | `hwnd`, `key` |+| `kicad_send_key` | `send_key` | Send a keystroke to a KiCad window — supports modifier combos ("alt+3", "ctrl+s", "ctrl+shift+s") | `hwnd`, `key` | | `kicad_click` | `click` | Send a click at (x, y) in a KiCad window | `hwnd`, `x`, `y` | | `kicad_adom_library_status` | `adom_library_status` | Report whether the Adom shared library is registered | — | 
skills/adom-desktop/SKILL.md+133−35
@@ -83,12 +83,11 @@ don't exist or miss ones that do.   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+, V2-first since v1.8.111).-  AD polls a `version.json` manifest every 4h + on launch. V2-first-  fallback chain: tries `git-wiki-ktqxite5iglh.adom.cloud` (V2 git-wiki),-  falls back to `wiki-ufypy5dpx93o.adom.cloud` (V1). Dead-end guard probes the-  `installer_url` before flipping `update_available` (HEAD + Range-GET-  fallback for V2's HEAD-404 bug). Footer banner Install Now / Later;+- **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. @@ -464,6 +463,65 @@ 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"}`). |+| `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.@@ -1084,8 +1142,8 @@ 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. 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.adom.inc/adom/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; **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.@@ -1358,20 +1416,34 @@ 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"}'`+- `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:@@ -1382,7 +1454,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. @@ -1396,7 +1469,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) @@ -2045,41 +2122,48 @@ Edge cases:  **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` / `rdp` (v1.8.85+)+### 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. -Reach a TCP service that's only addressable from the **cloud side** of the bridge through the **laptop side** — by binding a 127.0.0.1:&lt;port&gt; listener on the user's laptop and pumping bytes over the existing AD↔relay WebSocket. First use case: RDP into a Windows VM that lives behind an NSG only the cloud container can reach — the laptop connects mstsc to a local port and is talking through the cloud. Same path works for SSH, VNC, HTTP, postgres, any other TCP service.+**`tunnel` is a grouped verb** (`rdp` / `tunnel_close` / `tunnel_list` remain back-compat aliases):  ```bash-# Open a generic tunnel — bytes flow laptop:9000 → cloud → host:port-adom-desktop tunnel '{"local":9000,"to":"<host>:<port>"}'+# 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"}' -# Convenience: open + spawn mstsc on the laptop pointed at it.-# rdp defaults `local` to 13389 (NOT 3389 — see "Port collisions" below).-adom-desktop rdp '{"to":"<host>:3389"}'+# 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 -# Inspect / tear down-adom-desktop tunnel_list-adom-desktop tunnel_close '{"tunnelId":<u32>}'+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) ``` -**Port collisions (v1.8.86+ — read this if you're shipping the user an RDP recipe).** Windows' own Remote Desktop service usually owns `0.0.0.0:3389` on a developer's laptop. Pre-v1.8.86, AD bound `127.0.0.1:<requested>` with `SO_REUSEADDR`, so a request for `local:3389` *appeared* to succeed (`actualPort:3389`, `ok:true`), but the kernel routed inbound `mstsc` connections to Windows' own RDP, not AD's tunnel listener — `tunnel_list` would show `connCount:0` while the user's mstsc hit their own machine's login screen instead of the remote VM. v1.8.86 fixes this two ways:+**`--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. -1. **`rdp` defaults `local` to 13389**, not 3389 — a simple way to dodge the collision without thinking about it.-2. **The listener binds with `SO_EXCLUSIVEADDRUSE` on Windows**, so a real port collision returns `WSAEADDRINUSE`. When that happens, AD auto-falls-back to an OS-assigned ephemeral port and returns it in `actualPort`. The response sets `fellBack:true` and includes an `_hint` warning the caller.+**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`. -When using `rdp`, **always read `localPort` from the response** — that's the port mstsc was actually pointed at, which may differ from your requested `local`. The wrapper handles this automatically; you just need to surface it to the user (the `_hint` field has a copy-paste sentence for them).+**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. -Each fresh TCP connection on the laptop side gets its own connId multiplexed over one tunnelId, so concurrent users / mstsc reconnects / parallel SSH sessions all work without re-opening the tunnel. The relay dials `--to` lazily on first byte (idle tunnels cost ~nothing), and half-close (FIN) propagates symmetrically.+**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_open` returns `{ok, tunnelId, actualPort, clientId, error?, _hint}`. On `ok:false`, common causes are (a) AD isn't connected to this relay, (b) the local port is already bound by something else (try a different `local`), or (c) `--to` is unreachable from the cloud side (test from `gallia` directly with `nc` or `curl`). `rdp` returns the tunnel open result + the `mstsc.exe` spawn pid; the user still has to type credentials in the mstsc credential prompt.+**`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:-- The tunnel is bytes-only — no parsing of RDP/SSH/etc. payload. So **any TCP service works**, but TLS/cert-pinning to a specific hostname still pins to whatever the cloud-side dial resolves; for RDP this means the upstream certificate's CN must match what mstsc expects.-- Backpressure is bounded (256-slot mpsc per conn, 64 KiB read buf), so a slow consumer can't OOM the relay — the TCP window closes upstream naturally.-- `tunnel_list` returns both `relay` view + `desktop` view; they should agree. Mismatch = one side leaked an entry, file a bug.-- Tunnels don't survive AD restarts or relay restarts. If `mstsc` disconnects mid-session, just re-open the tunnel.+- 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 workaround to drop**: previously, the only way to RDP to a laptop-isolated VM from a roaming laptop was to whitelist the laptop's WAN IP in the NSG every time the IP changed. `tunnel` / `rdp` ride the existing auth, so the laptop is reachable via the cloud relay regardless of WAN IP.+**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) @@ -2142,6 +2226,20 @@ Pitfalls these verbs eliminate: - 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`.+ ## 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: