← Commit history

hero -> billboard

John Lauer ·d11c226bff · ·parent be36408
1 file changed +1−1
page.json+1−1
@@ -12,5 +12,5 @@   },   "author_name": "John Lauer",   "updated_at": "2026-06-17T11:07:44.428Z",-  "content": "# Adom Desktop\n\nA Tauri v2 desktop application for Windows that bridges Adom's cloud AI tools to local desktop applications: KiCad, Fusion 360, real Chrome (Puppeteer), native shell, file transfer, screenshots, notifications — plus a Hydrogen Desktop proxy. One install gives Claude every desktop verb it needs.\n\n## Install (in an Adom container)\n\n```\nadompkg install adom-desktop\n```\n\nThat installs the **container-side** pieces:\n\n- the `adom-desktop` CLI binary (downloaded from this page's blob store, SHA256-verified against the bundled `version.json`)\n- the Claude skill bundle into `~/.claude/skills/`:\n\n| Skill | What Claude learns |\n|---|---|\n| `adom-desktop` | the main verb catalog — screenshots, files, shell, windows, notifications |\n| `adom-desktop-pup` | real-Chrome (Puppeteer) automation: sessions, tabs, eval, recording |\n| `adom-desktop-kicad` | KiCad control: open/edit schematics + PCBs, exports, 3D viewer |\n| `adom-desktop-fusion` | Fusion 360 control: designs, exports, BOM/CPL |\n| `adom-desktop-direct-api` | the loopback HTTP API for sibling apps |\n| `adom-desktop-installer` | how to drive the Windows installer (silent flags, logs) |\n| `adom-desktop-discovery` | how a fresh container finds + refreshes the CLI |\n\n## What it looks like\n\nThe desktop app, connected to two containers, with the bridge bar and live activity log:\n\n![Adom Desktop GUI](gui.png)\n\nDriving KiCad end-to-end from the container — symbol, footprint, 3D model, schematic, board, gerbers:\n\n![KiCad driven by Adom Desktop](kicad-shot.png)\n\nThe **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).\n\n## How it fits together\n\n```\nAdom container (this package)            User's laptop\n┌──────────────────────────┐             ┌──────────────────────────┐\n│ adom-desktop CLI + skills│ ⇆ relay ⇆  │ Adom Desktop GUI (Tauri) │\n│ (Claude drives verbs)    │             │ KiCad · Fusion · Chrome  │\n└──────────────────────────┘             │ shell · files · screens  │\n                                         └──────────────────────────┘\n```\n\nThe 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.\n\n\n## Versioning\n\nThis 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.\n\n- Source: <https://github.com/adom-inc/adom-desktop> (private during beta)\n\n![Drive the real Chrome on the laptop](pup.png)\n\n## Command reference\n\nEvery 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. This list is generated from the live help-map; run `adom-desktop commands` for the machine-readable source.\n\n**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)\n\n### Connectivity\n\n_Liveness, status, multi-AD targeting._ (5 verbs)\n\n#### `health`\n\nCheck if the relay server HTTP API is reachable\n\n**Returns:** Relay server health status\n\n\n#### `kick_all`\n\nForce-disconnect all WebSocket clients (they auto-reconnect in ~5s)\n\n**Note:** Use when connections are stale or duplicated\n\n\n#### `ping`\n\nRound-trip connectivity test (5s timeout)\n\n**Args:**\n- `message` — optional string, echoed back\n\n**Returns:** echo, roundTripMs, status\n\n**Note:** Always run this first to verify desktop connection\n\n\n#### `status`\n\nQuery connected clients, capabilities, and app installation status\n\n**Returns:** clients count, appName, hostname, capabilities[], desktop app status (kicad/fusion/browser installed/running)\n\n\n#### `targets`\n\nv1.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.\n\n**Returns:** {count, targets:[{name, hostname, version, os, connectedAt}], _hint}\n\n**Note:** See _targeting_note at the top of this map for how --target works.\n\n\n### Setup\n\n_One-time desktop setup._ (1 verbs)\n\n#### `setup_desktop`\n\nGenerate installer URL and server config JSON for first-time desktop app setup\n\n**Returns:** installer_url, server_config JSON, steps[]\n\n\n### Shell & process\n\n_Run shell commands, scripts, WSL, and launch/locate apps on the laptop._ (6 verbs)\n\n#### `find_exe`\n\nFind 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.\n\n**Args:**\n- `name` — required: bare name ('Fusion360.exe'), glob ('.../webdeploy/production/*/Fusion360.exe'), or full path\n- `searchPaths` — optional array of extra dirs to scan\n\n**Returns:** path, source (absolute|glob|startmenu|path)\n\n\n#### `args`\n\n\n#### `run_script`\n\nv1.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.\n\n**Args:**\n- `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).\n- `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.\n- `scriptB64` — required string: base64-encoded script content (no length limit beyond practical WS message size — keep under a few MB for sanity).\n- `timeoutSeconds` — optional int (default 60). The CLI's WS timeout is auto-sized to scriptTimeout + 15s margin.\n\n**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.\n\n**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).\n\n\n#### `shell_execute`\n\nExecute 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.\n\n**Args:**\n- `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.\n- `command` — required string — full shell command (Windows: runs through `cmd /c`; macOS/Linux: through `sh -c`)\n- `cwd` — optional working directory\n- `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.\n\n**Returns:** stdout, stderr, exitCode\n\n**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).\n\n\n#### `shell_kill_all`\n\nKill all running shell command processes\n\n\n#### `wsl_exec`\n\nv1.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.\n\n**Args:**\n- `distro` — required string: WSL distro name (e.g. 'Adom-Workspace', 'Ubuntu'). `wsl -l -v` enumerates installed distros.\n- `scriptB64` — required string: base64-encoded bash script. Decoded content must be valid UTF-8.\n- `timeoutSeconds` — optional int (default 60). The CLI's WS timeout auto-sizes to scriptTimeout + 15s margin.\n- `user` — required string: WSL user to run as (e.g. 'adom', 'root'). Must exist in the distro.\n\n**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.\n\n\n### File transfer\n\n_Move files between the cloud container and the laptop (streamed, SHA-verified)._ (2 verbs)\n\n#### `pull_file`\n\nPull 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.\n\n**Args:**\n- `filePaths` — required array of absolute Windows paths (e.g. \"C:\\Users\\\\me\\\\Downloads\\\\big.pdf\")\n- `saveTo` — optional directory on Docker (default /tmp). Created if missing.\n\n**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.\n\n**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.\n\n\n#### `send_files`\n\nSend files from Docker container to the desktop\n\n**Args:**\n- `destinationFolder` — optional relative subfolder\n- `filePaths` — required array of absolute paths\n- `targetApp` — optional: general|kicad|fusion360\n\n\n### Desktop & bridge mgmt\n\n_Windows, screenshots, files, embedded mode, relay servers, tunnels, and bridge lifecycle._ (44 verbs)\n\n#### `bridge_check_updates`\n\nREAD-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.\n\n**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.\n\n**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.\n\n\n#### `bridge_install`\n\nInstall 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+.\n\n**Args:**\n- `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)\n\n**Returns:** {success, ok, bridge:<name from manifest>, version, installPath, _hint}\n\n**Note:** The bridge zip MUST contain a bridge.json at its root with name + spawn.{kind,entrypoint,port,...} + verbPrefixes. See https://github.com/adom-inc/adom-desktop/blob/main/plugins/kicad/bridge.json for the schema. To update an existing bridge, install again — the cache atomic-swaps.\n\n\n#### `bridge_list`\n\nList 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.\n\n**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}\n\n**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.\n\n**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.\n\n\n#### `bridge_pause`\n\nPause 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.\n\n**Args:**\n- `name` — required — bridge name from bridge_list (e.g. 'kicad')\n\n**Returns:** {success, name, paused:true, _hint}\n\n\n#### `bridge_resume`\n\nResume a paused bridge — flip the routing flag back. Inverse of bridge_pause.\n\n**Args:**\n- `name` — required — bridge name\n\n**Returns:** {success, name, paused:false, _hint}\n\n\n#### `bridge_uninstall`\n\nRemove 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.\n\n**Args:**\n- `name` — required — bridge name\n\n**Returns:** {success, name, cacheRemoved, stillInstalledViaBundled, _hint}\n\n\n#### `desktop_bring_to_front`\n\nBring a window to the foreground\n\n**Args:**\n- `hwnd` — optional int\n- `state` — optional: maximize or restore (default: preserve current state)\n- `titleContains` — optional string (case-insensitive)\n\n\n#### `desktop_caption`\n\nShow 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.\n\n**Args:**\n- `action` — optional: \"hide\" (dismiss this id, or all if no id) | \"force-clear\" (nuke ALL caption windows)\n- `duration` — optional ms before auto-clear. Omitted or 0 → 30000 (30s) default. Clamped to max 600000 (10 min). NOT 'forever' anymore.\n- `fontSize` — optional int px (8–400), overrides size\n- `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\".\n- `persist` — optional bool. true → sticky, NO auto-clear (supervised use only, e.g. demo recordings) — YOU must dismiss it.\n- `position` — optional: top | center | bottom (default) | top-left | top-right | bottom-left | bottom-right\n- `size` — optional: small (20px) | medium (32px, default) | large (72px)\n- `text` — the caption text (omit, or action:\"hide\", to dismiss)\n- `x` — optional 0.0–1.0 horizontal fraction (overrides position)\n- `y` — optional 0.0–1.0 vertical fraction (overrides position)\n\n**Returns:** {visible, text, id, persist, expiresInMs (null if persist), _hint}\n\n**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.\n\n\n#### `desktop_connect_all`\n\nv1.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.\n\n**Returns:** {ok, success, totalEnabled, spawned, alreadyConnected, _hint}\n\n\n#### `desktop_disconnect_all`\n\nv1.8.42+: programmatically drop every live WS connection. Server entries stay in config (use server_remove to delete). Mirrors HD's tray 'Disconnect All'.\n\n**Returns:** {ok, success, disconnected, _hint}\n\n\n#### `desktop_embedded_enter`\n\nv1.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.\n\n**Returns:** {ok:true, success:true, was_embedded:bool, embedded:true, entered_via:'runtime-adopt', owner:'hydrogen-desktop', _hint}\n\n**See also:** desktop_embedded_release (revert), desktop_embedded_status (introspect). HD-side recipe in plugins/hd/SKILL.md.\n\n\n#### `desktop_embedded_release`\n\nv1.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.\n\n**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.\n\n**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).\n\n\n#### `desktop_embedded_status`\n\nv1.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.\n\n**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}\n\n\n#### `desktop_install_kicad`\n\nUnattended 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.\n\n**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}.\n\n**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.\n\n\n#### `desktop_install_node`\n\nUnattended 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.\n\n**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}.\n\n**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.\n\n\n#### `desktop_list_browsers`\n\nCatalog 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.\n\n**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.\n\n**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+.\n\n\n#### `desktop_list_files`\n\nList 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.\n\n**Args:**\n- `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\".\n- `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.\n- `path` — required string. Windows abs path. ~, %USERPROFILE%, %APPDATA%, %LOCALAPPDATA%, %TEMP% are expanded. Forward slashes (/) and back slashes (\\\\) both work.\n\n**Returns:** {ok, path, glob, files: [{path, name, size, mtime}, ...] sorted newest-first, count}\n\n**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.\n\n\n#### `desktop_list_windows`\n\nList all visible windows with HWNDs, titles, positions\n\n\n#### `desktop_logout`\n\nv1.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.\n\n**Returns:** {ok, success, _hint}. After logout, adom_whoami returns null until the user signs in again (via the GUI footer link or another handoff).\n\n\n#### `desktop_open_folder`\n\nOpen file/folder in Windows Explorer\n\n**Args:**\n- `path` — required string\n\n\n#### `desktop_open_url`\n\nOpen 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.\n\n**Args:**\n- `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\".\n- `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.\n- `url` — required string (https://... or any URL the OS shell can dispatch)\n\n**Returns:** {ok:bool, browser, exePath?, url, profile?} — profile echoed if it was applied\n\n**Note:** desktop_open_url vs browser_open_window — pick the right one:\n  • 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.\n  • 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).\n  Rule of thumb: human-in-the-loop login flow => desktop_open_url. AI-driven automation => browser_open_window.\n  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.\n\n\n#### `desktop_pull_glob`\n\nConvenience 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.\n\n**Args:**\n- `glob` — optional string (default \"*\").\n- `path` — required string. Windows abs path; same expansion as desktop_list_files.\n- `pollMs` — optional (only used when wait:true). Default 1000.\n- `saveTo` — required string. Container-side absolute path (a directory). Parent dirs auto-created. Each pulled file lands at saveTo/<basename>.\n- `since` — optional unix-seconds OR ISO-8601. Only files with mtime > since are pulled.\n- `timeoutMs` — optional (only used when wait:true). Default 60000.\n- `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.\n\n**Returns:** {ok, files:[{name, path, size, sha256, chunks}, ...], errors:[...], matchedCount}\n\n**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.\n\n\n#### `desktop_revoke_approvals`\n\nClear all shell auto-approve permissions and deny pending approvals\n\n\n#### `desktop_screenshot_screen`\n\nCapture 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-`.\n\n**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).\n\n\n#### `desktop_screenshot_window`\n\nCapture 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.\n\n**Args:**\n- `hwnd` — required int\n- `resizeMax` — optional int, default 1400. Longest side of the safe-resized PNG.\n\n**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.\n\n\n#### `desktop_set_window_state`\n\nSet window state without necessarily foregrounding\n\n**Args:**\n- `hwnd` — optional int\n- `state` — required: maximize|minimize|restore|show|hide\n- `titleContains` — optional string\n\n**Note:** Use bring_to_front to foreground + maximize in one call. Use this for minimize/hide/show.\n\n\n#### `desktop_show_ui`\n\nv1.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.\n\n**Args:**\n- `show` — optional bool, default true (false hides/resets)\n- `target` — required: settings | build_info | direct_api | shell_approval | none (reset)\n\n**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.\n\n\n#### `desktop_shutdown`\n\nv1.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.\n\n**Returns:** {ok, success, output, _hint}. The exit happens ~500ms after the response is sent so the caller doesn't see connection-reset.\n\n\n#### `desktop_watch_files`\n\nBlock 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.\n\n**Args:**\n- `glob` — optional string (default \"*\"). Same semantics as desktop_list_files.\n- `path` — required string. Same expansion semantics as desktop_list_files.\n- `pollMs` — optional int (default 1000, floor 100). How often to re-list the directory.\n- `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.\n- `timeoutMs` — optional int (default 60000, max 600000 = 10 min). After this elapses, returns {ok:false, error:\"timeout\", elapsedMs, _hint}.\n\n**Returns:** On match: {ok:true, path, glob, file:{path,name,size,mtime}, elapsedMs}. On timeout: {ok:false, error:\"timeout\", elapsedMs, _hint, path, glob}.\n\n**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.\n\n\n#### `desktop_window_hide`\n\nv1.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.\n\n**Returns:** {ok, success, _hint}\n\n\n#### `desktop_window_show`\n\nv1.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.\n\n**Returns:** {ok, success, _hint}\n\n\n#### `direct_api`\n\nINFORMATIONAL — 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.\n\n**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\"`.\n\n**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.\n\n**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.\n\n**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).\n\n\n#### `rdp`\n\nv1.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.\n\n**Args:**\n- `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.\n- `to` — REQUIRED string — RDP target in `host:port` form. If port omitted, defaults to 3389.\n\n**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.\n\n\n#### `refresh_bridges`\n\nForce 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.\n\n**Args:**\n- `name` — optional — bridge name from bridge_list (e.g. 'kicad'). Omit to sync every bridge.\n\n**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.\n\n**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.\n\n\n#### `register_container`\n\nv1.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.\n\n**Args:**\n- `auth_token` — optional string — relay auth token (defaults to adom-dev-token-2025, which is what the cloud relay accepts by default)\n- `auto_connect` — optional bool — auto-dial on AD launch (default true)\n- `enabled` — optional bool — enabled in AD's server list (default true)\n- `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.\n- `name` — REQUIRED string — human-friendly label for AD's server list (e.g. 'galliaApril')\n- `url` — REQUIRED string — public wss:// URL the desktop will dial (typically wss://<container-slug>.adom.cloud/proxy/8765/)\n\n**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.\n\n**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.\n\n**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.\n\n\n#### `relaunch_self`\n\nv1.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.\n\n**Args:**\n- `destExe` — optional dest path (default: %LOCALAPPDATA%\\Adom Desktop\\adom-desktop.exe)\n- `noAutoStart` — optional bool — if true, the relauncher shows its GUI but waits for POST /start instead of running immediately\n- `port` — optional HTTP API port for the relauncher (default: 47210)\n- `sourceExe` — optional path to the freshly-built adom-desktop.exe (default: src-tauri/target/release/adom-desktop.exe relative to the relauncher's location)\n\n**Returns:** {ok, success, relauncherPid, relauncherExe, relauncherPort, _hint}\n\n**See also:** relauncher_status (poll progress), skills/AD_RELAUNCH.md (full recipe).\n\n\n#### `relauncher_status`\n\nv1.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.\n\n**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}\n\n**See also:** relaunch_self (spawn), skills/AD_RELAUNCH.md (recipe).\n\n\n#### `server_add`\n\nRegister 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+.\n\n**Args:**\n- `authToken` — optional string — defaults to 'adom-dev-token-2025'. Empty string means 'keep existing token if this is an update; otherwise use default'.\n- `autoConnect` — optional bool, default true — connect right after upsert. Set false to add the entry without dialling out.\n- `name` — required string — human-friendly server label. Acts as the dedup key for upserts.\n- `url` — required string — relay WebSocket URL (e.g. ws://localhost:8765 or wss://your-host/proxy/8765/).\n\n**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).\n\n**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>\"}'.\n\n\n#### `server_list`\n\nReturn 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+.\n\n**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.\n\n\n#### `server_remove`\n\nDisconnect (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+.\n\n**Args:**\n- `name` — required string — the same `name` you passed to server_add.\n\n**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.\n\n\n#### `shell_auto_approve`\n\nProgrammatically 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').\n\n**Args:**\n- `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`.\n- `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).\n\n**Returns:** {success, output, active:bool, remainingSecs, permanent:bool}\n\n**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:\n  - Dev session: `adom-desktop shell_auto_approve '{\"duration_secs\": 86400}'` (24h, then expires).\n  - Owner's box, durable: `adom-desktop shell_auto_approve '{\"permanent\": true}'` (equivalent to ticking the Settings checkbox).\n  - 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.\n  - Revoke everything: `adom-desktop shell_auto_approve '{\"duration_secs\": 0}'`.\n\n\n#### `tunnel`\n\nv1.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.\n\n**Args:**\n- `local` — REQUIRED int — the local 127.0.0.1 port the desktop should bind. mstsc / your TCP client connects HERE.\n- `to` — REQUIRED string — upstream target in `host:port` form. The relay dials this when bytes arrive on the local port.\n- `tunnelId` — optional u32 — caller-chosen ID. Defaults to a fresh random u32. Reuse across re-invocations to address the same tunnel.\n\n**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.\n\n**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.\n\n\n#### `tunnel_close`\n\nv1.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.\n\n**Args:**\n- `connId` — optional u32 — if given, close only this conn; otherwise close the whole tunnel\n- `tunnelId` — REQUIRED u32 — from `tunnel` / `tunnel_list`\n\n**Returns:** {ok, tunnelId, connId?, scope:'tunnel'|'conn', _hint}\n\n\n#### `tunnel_list`\n\nv1.8.85+: list all active tunnels — both the relay's view and AD's view (should agree).\n\n**Returns:** {ok, relay:[{tunnelId, toHost, toPort, clientId, connCount}, ...], desktop:{tunnels:[...]}, _hint}\n\n\n### Screen recording\n\n_Record the desktop/monitors to video; manage the recorder window._ (7 verbs)\n\n#### `desktop_list_monitors`\n\nList available monitors. v1 returns the primary only; full enumeration is v1.1.\n\n**Returns:** monitors: [{index, name, x, y, width, height, primary}]\n\n\n#### `desktop_record_list`\n\nList completed desktop recording .webm files on disk with their sidecar metadata.\n\n**Returns:** recordings: [{recordingId, filePath, sizeKB, durationMs, reason, mtime}]\n\n\n#### `desktop_record_start`\n\nStart 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.\n\n**Args:**\n- `audio` — optional bool (default false)\n- `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).\n- `fps` — optional int (default 30)\n- `maxDurationMs` — optional int (default 600000)\n- `monitor` — optional 'primary'|'all'|int (default 'primary')\n- `reason` — REQUIRED string — explain to the user why you're recording (renders in the HUD title and sidecar JSON)\n- `videoBitsPerSecond` — optional int (default 4000000)\n\n**Returns:** recordingId, filePath (.webm), startedAt, monitor, reason\n\n**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.\n\n**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\n\n\n#### `desktop_record_status`\n\nStatus of the desktop recorder HUD and any active clip.\n\n**Returns:** hudOpen, reason, currentRecording, clipsThisSession\n\n\n#### `desktop_record_stop`\n\nStop the active desktop clip. Finalizes the .webm. HUD stays open in Ready state for the next clip in this session.\n\n**Args:**\n- `recordingId` — optional (active clip if omitted)\n\n**Returns:** recordingId, filePath, sizeKB, durationMs, stopReason\n\n\n#### `desktop_recorder_close`\n\nClose the desktop recorder HUD (ends the session). Auto-stops any active clip first.\n\n**Returns:** sessionSummary: {clipCount, totalSizeKB, reason, clips}\n\n\n#### `desktop_recorder_open`\n\nOpen 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.\n\n**Args:**\n- `reason` — required string explaining why you're recording\n\n**Returns:** opened, reason\n\n**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).\n\n\n### Notifications\n\n_Native desktop toast notifications._ (1 verbs)\n\n#### `notify_user`\n\nSend a Windows desktop toast notification\n\n**Args:**\n- `actions` — optional array of button labels\n- `body` — required string\n- `level` — optional: info|warning|error|emergency (default: info)\n- `title` — required string\n\n\n### Project watch\n\n_Watch a local folder + sync changes._ (1 verbs)\n\n#### `args`\n\n\n### Browser (Puppeteer)\n\n_Drive Chrome-for-Testing: navigate, eval, screenshot, record, tabs/windows, credentials._ (31 verbs)\n\n#### `browser_alert_window`\n\nFlash the Chrome window's taskbar icon (does NOT steal foreground)\n\n**Args:**\n- `sessionId` — required\n\n\n#### `browser_close`\n\nClose 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.\n\n**Args:**\n- `sessionId` — optional — close just this one session\n\n**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.\n\n\n#### `browser_close_tab`\n\nClose one tab; session + other tabs remain\n\n**Args:**\n- `sessionId` — required\n- `tabId` — required\n\n**Returns:** remaining tab count\n\n\n#### `browser_close_window`\n\nClose a session's Chrome window (closes ALL tabs in that session)\n\n**Args:**\n- `sessionId` — required\n\n\n#### `browser_errors`\n\nGet 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.\n\n**Args:**\n- `clear` — optional bool (default true)\n- `sessionId` — optional\n- `tabId` — optional\n\n\n#### `browser_eval`\n\nEvaluate 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.\n\n**Args:**\n- `expr` — required JS string\n- `sessionId` — optional\n- `tabId` — optional\n\n\n#### `browser_fetch_url`\n\nFetch 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.\n\n**Args:**\n- `body` — optional raw string body for POST etc.\n- `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.\n- `headers` — optional object — Cookie auto-included from session\n- `method` — optional (default GET)\n- `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.\n- `sessionId` — optional (defaults to active session)\n- `tabId` — optional (picks cookie context — defaults to active tab; cookies are per-browser-context so any tab in the session works)\n- `url` — required\n\n**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}\n\n**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).\n\n\n#### `browser_focus_window`\n\nActivate the session's tab WITHIN Chrome (Puppeteer page.bringToFront). Does NOT raise the OS window — for that, use browser_raise_os_window.\n\n**Args:**\n- `sessionId` — required\n\n\n#### `browser_input_dispatch`\n\nDispatch 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.\n\n**Args:**\n- `button` — click only — left|right|middle (default left)\n- `clickCount` — click only — int (default 1; use 2 for double-click)\n- `delay` — click/type/key only — ms between mousedown+mouseup or keystrokes (default 0)\n- `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.\n- `key` — key only — Enter|Escape|Tab|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|F1|... (any Puppeteer KeyInput name)\n- `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.\n- `sessionId` — optional (defaults to active session)\n- `steps` — move only — number of intermediate positions for human-like movement (default 1)\n- `tabId` — optional (defaults to active tab)\n- `text` — type only — string to type into the focused element. Pass selector to focus that element first.\n- `type` — required: click | move | type | key\n- `x` — click/move only — viewport X coord\n- `y` — click/move only — viewport Y coord\n\n**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, ...}.\n\n**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.\n\n\n#### `browser_list_tabs`\n\nList 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.\n\n**Args:**\n- `sessionId` — required\n\n**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).\n\n\n#### `browser_list_windows`\n\nList all open browser sessions with URLs and titles\n\n\n#### `browser_lower_os_window`\n\nMinimize the OS window hosting this pup session (give the user back their desktop after Claude is done with a flow).\n\n**Args:**\n- `sessionId` — required\n\n**Returns:** sessionId, hwnd, lowered, error?\n\n\n#### `browser_navigate`\n\nNavigate 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.\n\n**Args:**\n- `sessionId` — optional\n- `tabId` — optional\n- `url` — required\n\n\n#### `browser_open_tab`\n\nAdd a tab to an existing session (same Chrome window as other tabs in that session — avoids new taskbar icon)\n\n**Args:**\n- `sessionId` — required\n- `url` — required\n\n**Returns:** tabId, url, title, active\n\n\n#### `browser_open_window`\n\nOpen 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).\n\n**Args:**\n- `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.\n- `freshProfile` — optional bool\n- `profile` — required string\n- `sessionId` — required string\n- `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).\n- `url` — required string\n\n**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, ...}\n\n**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.\n\n\n#### `browser_raise_os_window`\n\nRaise 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.\n\n**Args:**\n- `sessionId` — required\n\n**Returns:** sessionId, hwnd, title, raised, error?\n\n\n#### `browser_record_list`\n\nList completed tab recording .webm files on disk with sidecar metadata.\n\n**Returns:** recordings: [{recordingId, filePath, sizeKB, durationMs, fps, sessionId, tabId, mtime}]\n\n\n#### `browser_record_start`\n\nRecord 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).\n\n**Args:**\n- `fps` — optional int (default 30) — actual fps may exceed target on rAF-animated pages\n- `maxDurationMs` — optional int (default 600000 = 10 min)\n- `quality` — optional int 0-100 JPEG quality (default 85)\n- `sessionId` — required\n- `tabId` — optional (defaults to active tab)\n\n**Returns:** recordingId, sessionId, tabId, filePath (.webm), startedAt\n\n\n#### `browser_record_status`\n\nList active tab recordings with live capture stats.\n\n**Args:**\n- `sessionId` — optional (filter to one session)\n\n**Returns:** active: [{recordingId, sessionId, tabId, filePath, fpsTarget, fpsActual, frameCount, durationMs, sizeBytesApprox, startedAt}]\n\n\n#### `browser_record_stop`\n\nStop 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.\n\n**Args:**\n- `recordingId` — required\n- `sessionId` — optional\n\n**Returns:** recordingId, sessionId, tabId, filePath (.webm), sizeKB, durationMs, frameCount, fpsTarget, fpsActual, stopReason\n\n**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).\n\n\n#### `browser_reload`\n\nReload 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.\n\n**Args:**\n- `sessionId` — optional\n- `tabId` — optional\n\n\n#### `browser_rescan`\n\nRecover 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).\n\n**Args:**\n- `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.\n\n**Returns:** {ok, rescanned, profilesReconnected, profilesUnreachable, reattached, orphansAdopted, liveSessions, disconnectedSessions, _hint}\n\n**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\").\n\n\n#### `browser_screenshot`\n\nCapture 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.\n\n**Args:**\n- `fullPage` — optional bool\n- `maxWidth` — optional int (default 1568)\n- `sessionId` — optional\n- `tabId` — optional\n\n**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.\n\n\n#### `browser_screenshot_full_res`\n\nCapture 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+.\n\n**Args:**\n- `fullPage` — optional bool\n- `sessionId` — optional\n- `tabId` — optional\n\n**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.\n\n**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.\n\n\n#### `browser_status`\n\nCheck all sessions: URLs, error counts, alive status\n\n\n#### `browser_switch_tab`\n\nMake a specific tab the active one (calls bringToFront within Chrome)\n\n**Args:**\n- `sessionId` — required\n- `tabId` — required\n\n\n#### `browser_switch_window`\n\nSwitch the bridge's active session (which window commands without explicit sessionId target)\n\n**Args:**\n- `sessionId` — required\n\n\n#### `browser_wait`\n\nWait for content to settle\n\n**Args:**\n- `ms` — optional int (default 3000)\n\n\n#### `credential_delete`\n\nRemove a credential entry by exact host pattern. Drops the password from the OS keychain and the entry from the index.\n\n**Args:**\n- `host` — required (must match the host pattern used at credential_set time, exactly)\n\n**Returns:** {ok, host, removedFromKeychain, removedFromIndex}\n\n\n#### `credential_list`\n\nList stored host patterns + their associated usernames. Passwords are NEVER returned.\n\n**Returns:** {ok, credentials: [{host, username, addedAt, updatedAt}]}\n\n\n#### `credential_set`\n\nStore 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.\n\n**Args:**\n- `host` — required (e.g. \"*.componentsearchengine.com\" or exact \"nxp.componentsearchengine.com\")\n- `password` — required (NEVER echoed back via list/get)\n- `username` — required\n\n**Returns:** {ok, host, username}\n\n\n### KiCad bridge\n\n_Drive KiCad 7/8/9/10: open designs, DRC/ERC, libraries, 3D, screenshots, lint._ (27 verbs)\n\n> **Note:** All kicad_* commands are forwarded to the KiCad bridge (port 8772). Use kicad_<command> format.\n\n#### `kicad_bridge_call`\n\nGeneric 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).\n\n**Args:**\n- `exeName` — required (kicad, pcbnew, eeschema, kicad-cli)\n- `method` — required — see methods list\n- `params` — optional dict — method-specific\n- `pid` — optional int — pin to a specific process PID (multi-instance disambiguation, v1.7.6+)\n- `timeout` — optional float seconds (default 10; bump to 30-60 for exports)\n\n**Returns:** {ok, result:<rpc result>, exeName, pid, port} OR {ok:false, errorCode:\"plugin_not_running\"|\"rpc_error\", ...}\n\n**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).\n\n\n#### `kicad_bridge_status`\n\nEnumerate 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.\n\n**Args:**\n- `exeName` — optional — filter to one exe (kicad / pcbnew / eeschema / kicad-cli)\n- `pid` — optional int — filter to a specific PID\n- `pruneStale` — optional bool (default false) — delete discovery files whose plugin doesn't respond to /status\n\n**Returns:** {ok, plugins:[{exeName, pid, port, version, endpoint, alive, uptimeMs, requestCount, discoveryFile, pruned?}], summary:{total, alive}, pluginAutoInstall:{...}, _hint}\n\n**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.\n\n\n#### `kicad_click`\n\nClick at coordinates in a KiCad window\n\n**Args:**\n- `hwnd` — required\n- `relative` — optional bool (default true, 0.0-1.0 range)\n- `x` — required\n- `y` — required\n\n**Note:** Relative coords are scale-independent — estimate position as percentage from screenshot.\n\n\n#### `kicad_close`\n\nClose all KiCad windows\n\n\n#### `kicad_close_3d_viewer`\n\nClose 3D Viewer window\n\n\n#### `kicad_close_footprint_editor`\n\nClose Footprint Editor window\n\n\n#### `kicad_close_symbol_editor`\n\nClose Symbol Editor window\n\n\n#### `kicad_fix_keyboard`\n\nRelease stuck modifier keys + unlock foreground focus\n\n\n#### `kicad_format_upgrade`\n\nUpgrade 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.\n\n**Args:**\n- `filePath` — required file or directory\n- `force` — optional bool (default false) — passes --force to resave even when already current\n- `kind` — optional pcb|sch|sym|fp (auto-detected from extension if omitted)\n\n**Returns:** {success, exitCode, kind, stdout, stderr, source_path, _hint}\n\n**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).\n\n\n#### `kicad_install_library`\n\nInstall a symbol or footprint library\n\n**Args:**\n- `kicadVersion` — optional (default = newest installed; library installs into THAT version's sym-lib-table)\n- `libraryName` — optional\n- `libraryPath` — required\n- `libraryType` — required\n\n\n#### `kicad_install_plugin`\n\nExplicitly (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.\n\n**Args:**\n- `force` — optional bool (default true). If false, returns the cached auto-install result instead of redoing the work.\n\n**Returns:** {ok, result:{summary, payloadVersion, perVersion:[...], runningKiCad:[...], _hint}, _hint}\n\n**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.\n\n\n#### `kicad_install_symbol`\n\nDecode base64 symbol, install as library, open editor\n\n**Args:**\n- `fileContent` — required base64\n- `fileName` — required\n- `kicadVersion` — optional (default = newest installed)\n\n\n#### `kicad_lint_board`\n\nHeadless 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.\n\n**Args:**\n- `filePath` — required .kicad_pcb path\n- `schematicParity` — optional bool (default true) — set false if no matching .kicad_sch exists in the project dir\n\n**Returns:** {success, data:{violations[], summary:{total,errors,warnings,info}, fileFormat:{fileVersion}, schematicParityChecked, source_file}, _hint with tiered guidance}\n\n**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).\n\n\n#### `kicad_lint_library`\n\nValidate 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.\n\n**Args:**\n- `libraryPath` — required path to .kicad_sym / .kicad_mod / .pretty dir / dir with .kicad_sym files\n\n**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}\n\n**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.\n\n\n#### `kicad_lint_schematic`\n\nHeadless 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.\n\n**Args:**\n- `filePath` — required .kicad_sch path\n\n**Returns:** {success, data:{violations[], summary:{total,errors,warnings,info}, fileFormat:{fileVersion}, source_file}, _hint with tiered guidance}\n\n**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.\n\n\n#### `kicad_list_versions`\n\nList every installed KiCad version with paths. Returns versions:[{version, base_dir, kicad_exe, default}], default, count.\n\n**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).\n\n\n#### `kicad_open_3d_viewer`\n\nOpen KiCad 3D Viewer (board or footprint).\n\n**Args:**\n- `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.\n- `kicadVersion` — optional (default = newest installed)\n\n\n#### `kicad_open_board`\n\nOpen .kicad_pcb in PCB editor\n\n**Args:**\n- `filePath` — required\n- `kicadVersion` — optional (e.g. \"9.0\" or \"10.0\"; default = newest installed)\n\n\n#### `kicad_open_editors`\n\nCross-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.\n\n**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}\n\n**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.\n\n\n#### `kicad_open_footprint_editor`\n\nOpen Footprint Editor\n\n**Args:**\n- `footprintName` — optional\n- `kicadVersion` — optional (default = newest installed)\n- `libraryName` — optional\n\n\n#### `kicad_open_schematic`\n\nOpen .kicad_sch in schematic editor\n\n**Args:**\n- `filePath` — required\n- `kicadVersion` — optional (e.g. \"9.0\" or \"10.0\"; default = newest installed)\n\n\n#### `kicad_open_symbol_editor`\n\nOpen Symbol Editor\n\n**Args:**\n- `kicadVersion` — optional (default = newest installed)\n- `libraryName` — optional\n- `symbolName` — optional\n\n\n#### `kicad_run_drc`\n\nRun 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}}}.\n\n**Args:**\n- `filePath` — required\n- `kicadVersion` — optional (default = newest installed)\n\n**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.\n\n\n#### `kicad_run_erc`\n\nRun 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.\n\n**Args:**\n- `filePath` — required .kicad_sch path\n\n**Returns:** {success, data:{violations[], summary:{total,errors,warnings,info}, fileFormat:{fileVersion}, source_file}, _hint guiding next steps}\n\n\n#### `kicad_screenshot_all`\n\nScreenshot all KiCad windows in one shot\n\n**Returns:** Array of {type, title, hwnd, savedTo, sizeKB}\n\n**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.\n\n\n#### `kicad_send_key`\n\nSend keystroke to KiCad window. Supports modifier combos.\n\n**Args:**\n- `hwnd` — optional target HWND\n- `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.\n\n**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.\n\n\n#### `kicad_window_info`\n\nList all KiCad windows (project manager, editors, dialogs) with HWNDs and rects\n\n\n### Fusion 360 bridge\n\n_Drive Fusion 360: CAD export, cloud docs, EAGLE electronics, manufacturing, UI._ (63 verbs)\n\n> **Note:** Two-tier architecture: bridge (port 8773) handles launch/detection; AdomBridge add-in (port 8774) handles exports/queries inside Fusion.\n\n> **Workflow:** fusion_start -> fusion_open_cloud_file or fusion_open_board -> work -> fusion_close\n\n#### `fusion_export_3mf`\n\nExport active design as 3MF\n\n**Args:**\n- `outputPath` — required\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_dwg`\n\nExport active design as DWG\n\n**Args:**\n- `outputPath` — required\n\n**Note:** Not available via API (dialog only). Use fusion_export_step or fusion_export_dxf instead.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_dxf`\n\nExport active design as DXF\n\n**Args:**\n- `outputPath` — required\n\n**Note:** Not available via API (dialog only). Use fusion_export_step or fusion_export_iges instead.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_f3d`\n\nExport active design as Fusion 360 archive (.f3d)\n\n**Args:**\n- `outputPath` — required\n\n**Note:** Native Fusion format — preserves parametric features, history, component references\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_fbx`\n\nExport active design as FBX\n\n**Args:**\n- `outputPath` — required\n\n**Note:** FBX only — no fallback. Not available in current Fusion builds. Use fusion_export_usdz or fusion_export_step instead.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_iges`\n\nExport active design as IGES (.igs)\n\n**Args:**\n- `outputPath` — required\n\n**Note:** Legacy CAD interchange. STEP is preferred for modern workflows.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_obj`\n\nExport active design as OBJ\n\n**Args:**\n- `outputPath` — required\n\n**Note:** Not available via API (dialog only). Use fusion_export_stl or fusion_export_usdz instead.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_sat`\n\nExport active design as SAT (.sat, ACIS solid model)\n\n**Args:**\n- `outputPath` — required\n\n**Note:** ACIS kernel format — opens in SolidWorks, SpaceClaim.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_skp`\n\nExport active design as SKP (SketchUp)\n\n**Args:**\n- `outputPath` — required\n\n**Note:** Not available via API (dialog only). Use fusion_export_step or fusion_export_stl instead.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_step`\n\nExport active design as STEP\n\n**Args:**\n- `outputPath` — required\n\n**Prerequisite:** Active 3D design (not Electronics)\n\n\n#### `fusion_export_stl`\n\nExport active design as STL\n\n**Args:**\n- `outputPath` — required\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_usdz`\n\nExport active design as USDZ (best for digital twins + GLB)\n\n**Args:**\n- `outputPath` — required\n\n**Note:** Preserves component hierarchy, PBR materials, named nodes. Ideal for Blender→GLB or direct iOS/macOS AR viewing.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_get_design_info`\n\nGet active design metadata (bodies, components)\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_get_parameters`\n\nList user parameters from active design\n\n\n#### `fusion_import_step`\n\nImport STEP/STL/IGES/SAT/OBJ/F3D file into Fusion\n\n**Args:**\n- `filePath` — required Windows path\n\n\n#### `fusion_set_parameter`\n\nModify a user parameter\n\n**Args:**\n- `name` — required\n- `value` — required\n\n\n#### `fusion_take_screenshot`\n\nCapture Fusion viewport as PNG with camera orientation control\n\n**Args:**\n- `height` — optional (default 1080)\n- `orientation` — optional: home, front, back, top, bottom, left, right\n- `outputPath` — required\n- `width` — optional (default 1920)\n\n**Note:** Uses Fusion's render API (Capture Image) — not a window screenshot. Supports all standard views for product images/icons.\n\n\n#### `fusion_check_recovery`\n\nCheck if a cloud file has crash recovery data\n\n**Args:**\n- `fileName` — required\n- `folderPath` — optional\n- `projectName` — optional\n\n**Note:** Check BEFORE opening files to avoid blocking recovery dialog\n\n\n#### `fusion_create_cloud_folder`\n\nCreate folder (idempotent)\n\n**Args:**\n- `folderName` — required\n- `parentPath` — optional\n- `projectName` — optional\n\n\n#### `fusion_delete_cloud_file`\n\nDelete a cloud file (must not be open)\n\n**Args:**\n- `fileName` — required\n- `folderPath` — optional\n- `projectName` — optional\n\n\n#### `fusion_export_cloud_file`\n\nExport active document to local file (step/stl/f3d/iges/sat/smt)\n\n**Args:**\n- `format` — optional (default: step)\n- `outputPath` — required\n\n\n#### `fusion_list_cloud_files`\n\nList files and subfolders in a cloud project/folder\n\n**Args:**\n- `folderPath` — optional\n- `projectName` — optional\n\n\n#### `fusion_list_cloud_projects`\n\nList all cloud projects in user's hub\n\n\n#### `fusion_open_cloud_file`\n\nOpen a cloud file by name. STOPS if recovery exists — you must specify recovery='open' or recovery='discard'\n\n**Args:**\n- `fileName` — required\n- `folderPath` — optional\n- `projectName` — optional\n- `recovery` — optional: open|discard\n\n\n#### `fusion_save_to_cloud`\n\nSave active document to Fusion cloud\n\n**Args:**\n- `description` — optional\n- `folderPath` — optional\n- `name` — required\n- `projectName` — optional\n\n\n#### `fusion_search_cloud_files`\n\nSearch 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.\n\n**Args:**\n- `folderPath` — optional starting subfolder (default: project root). NARROW THE SCOPE HERE — naming the right subfolder can reduce search time 10-50x.\n- `folderTimeout` — optional int seconds per-folder cloud-API timeout (default 30)\n- `maxDepth` — optional int (default 2, no upper cap)\n- `maxFolders` — optional int (default 10, no upper cap). For exhaustive 100+ folder searches consider walk_cloud_tree.\n- `maxResults` — optional int (default 20, no upper cap)\n- `projectName` — optional (defaults to active project)\n- `query` — required case-insensitive substring of the file name\n- `recursive` — optional bool (default false). false = root folder only (fast); true = descend into subfolders.\n- `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.\n\n**Returns:** data{files[], totalFound, foldersSearched, foldersSkipped, filesSkipped, searchComplete, truncated, folderLimitReached, searchTimedOut, elapsedSeconds, costAnalysis{foldersPerSecond, estimatedSecondsPer100Folders}, hint}\n\n**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.\n\n\n#### `fusion_walk_cloud_tree`\n\nWalk a cloud folder tree iteratively (BFS) and return a flat list of folders + files with full paths.\n\n**Args:**\n- `extensions` — optional list of extensions to include, e.g. [\"f3d\",\"fprj\"]\n- `folderPath` — optional starting folder (defaults to root)\n- `includeFiles` — optional bool (default true) — set false for folder-only tree\n- `maxDepth` — optional int (default 10)\n- `maxFolders` — optional int (default 500, truncates if exceeded)\n- `nameContains` — optional case-insensitive substring filter on file names\n- `projectName` — optional (defaults to active)\n\n**Returns:** folders[], files[] (each with path), stats{foldersVisited, filesFound, truncated}\n\n**Note:** Single add-in call does the entire walk. No sleeps, no batching. For targeted finds use nameContains; for exports use extensions.\n\n\n#### `fusion_board_info`\n\nGet structured board data: components, nets, traces, DRC violations\n\n**Note:** Much richer than a screenshot — exact coordinates and connectivity\n\n**Prerequisite:** Board open in PCB Editor\n\n\n#### `fusion_electron_run`\n\nExecute EAGLE command (WINDOW FIT, DISPLAY ALL, RATSNEST, DRC, etc.)\n\n**Args:**\n- `command` — required EAGLE command string\n\n**Returns:** activeWorkspace, fusionOperations[] (diff of internal log), _hint\n\n**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.\n\n\n#### `fusion_execute_text_command`\n\nLow-level Fusion app.executeTextCommand() access\n\n**Args:**\n- `command` — required Fusion text command\n\n\n#### `fusion_export_eagle_source`\n\nExport active electronics document as plain EAGLE .sch or .brd XML — extracts from Fusion's .fsch/.fbrd ZIP container\n\n**Args:**\n- `outputPath` — required — full path ending in .sch or .brd\n\n**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.\n\n**Prerequisite:** MUST have correct editor active: PCB Editor / Board Layout for .brd, Schematic Editor for .sch\n\n\n#### `fusion_export_lbr`\n\nExport library as .scr script (no 3D refs)\n\n**Args:**\n- `outputPath` — optional\n\n\n#### `fusion_export_source`\n\nExport active electronics document as .fsch (schematic), .fbrd (board), or .flbr (library) source file\n\n**Args:**\n- `outputPath` — required — full path with extension (.fsch, .fbrd, or .flbr)\n\n**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.\n\n**Prerequisite:** MUST have correct editor active: Board Layout for .fbrd, Schematic Editor for .fsch\n\n\n#### `fusion_import_electronics`\n\nImport Fusion-native .fsch/.fbrd/.flbr file as new local electronics project\n\n**Args:**\n- `filePath` — required Windows path to .fsch, .fbrd, or .flbr file\n\n**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).\n\n\n#### `fusion_list_text_commands`\n\nList available EAGLE text commands in current context\n\n\n#### `fusion_open_board`\n\nOpen .brd in Board Layout editor\n\n**Args:**\n- `filePath` — required\n\n\n#### `fusion_open_electronics`\n\nCheck if Electronics workspace is active\n\n\n#### `fusion_open_lbr`\n\nOpen .lbr library in Electronics Library editor\n\n**Args:**\n- `filePath` — required\n\n\n#### `fusion_open_schematic`\n\nOpen .sch in Schematic Editor\n\n**Args:**\n- `filePath` — required\n\n\n#### `fusion_show_2d_board`\n\nSwitch back to 2D board layout from 3D view\n\n**Note:** EAGLE commands only work in 2D view\n\n\n#### `fusion_show_3d_board`\n\nSwitch to 3D PCB view (board needs outline on layer 20)\n\n**Prerequisite:** Board must be open in PCB Editor\n\n\n#### `fusion_activate_document`\n\nSwitch to an open document tab by name or type\n\n**Args:**\n- `documentType` — optional: Electronics|PCB|FusionDesign|Drawing\n- `name` — optional substring match\n\n\n#### `fusion_close`\n\nClose Fusion 360 gracefully\n\n\n#### `fusion_close_all_documents`\n\nClose all open documents\n\n**Args:**\n- `saveChanges` — optional bool (default: false)\n\n**Note:** Call before force-killing Fusion to prevent recovery files\n\n\n#### `fusion_close_document`\n\nClose a document without save dialog (avoids modal blocking)\n\n**Args:**\n- `name` — optional (default: active)\n- `save` — optional bool (default: false)\n\n\n#### `fusion_close_window`\n\nClose 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'.\n\n**Args:**\n- `hwnd` — required — HWND from fusion_window_info or fusion_dismiss_blocking_dialogs remaining[]\n\n**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.\n\n\n#### `fusion_dismiss_blocking_dialogs`\n\nFirst-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.\n\n**Args:**\n- `extraPatterns` — optional array of case-insensitive title substrings to also treat as blocking\n- `maxAttempts` — optional int (default 5) — retry loop cap\n\n**Returns:** dismissed[{hwnd,title,pattern}], remaining[], mainThreadResponsive, attempts\n\n**Note:** Idempotent. Safe to call any time. Returns immediately with empty dismissed[] when no blocking dialogs are present.\n\n\n#### `fusion_document_info`\n\nList all open document tabs with cloud metadata for active doc\n\n**Returns:** openDocuments[], activeDoc cloud details (project, folder, version)\n\n\n#### `fusion_get_app_state`\n\nQuery open documents, active workspace, Electronics status\n\n**Returns:** workspace, openDocuments[], electronicsActive\n\n\n#### `fusion_start`\n\nFirst-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.\n\n**Args:**\n- `dismissBlockingDialogs` — optional bool (default true) — auto-clear the 'What do you want to design?' picker, error toasts, and other known blocking modals\n- `timeoutSeconds` — optional int (default 120) — total wait for add-in + dialog dismissal\n\n**Returns:** resolvedPath, pid (null if already running), addinReady, dialogsDismissed[], elapsedMs, alreadyRunning\n\n**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.\n\n\n#### `fusion_detect_layers`\n\nAuto-detect PCB copper layer count (2 or 4) using ULP + CAM comparison\n\n**Returns:** layerCount, is4Layer, method, copperLayers[], nextSteps[]\n\n**Note:** Run first — informs gerber CAM job selection and design rule choice\n\n\n#### `fusion_export_board_image`\n\nExport PNG image with layer presets (top_copper, assembly_top, fabrication, etc.)\n\n**Args:**\n- `dpi` — optional int (default: 300, max: 600)\n- `layers` — optional int[] — custom layer numbers (overrides preset)\n- `listPresets` — optional bool — returns preset list instead of exporting\n- `monochrome` — optional bool\n- `outputPath` — optional (default: C:/tmp/adom-board.png)\n- `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\n\n**Returns:** outputPath, dpi, preset, fileSize, nextSteps[]\n\n\n#### `fusion_export_bom`\n\nExport Bill of Materials as CSV (grouped by value+package)\n\n**Args:**\n- `grouped` — optional bool (default: true) — false for one row per component\n- `outputPath` — optional (default: C:/tmp/adom-bom.csv)\n\n**Returns:** outputPath, componentCount, uniquePartCount, nextSteps[]\n\n**Note:** Format: Comment, Designator, Footprint, Quantity, Library\n\n\n#### `fusion_export_cpl`\n\nExport Component Placement List (pick-and-place) as CSV\n\n**Args:**\n- `outputPath` — optional (default: C:/tmp/adom-cpl.csv)\n- `side` — optional: all (default) | top | bottom\n\n**Returns:** outputPath, totalPlacements, topCount, bottomCount, nextSteps[]\n\n**Note:** Format: Designator, Mid X, Mid Y, Layer, Rotation (mm). Compatible with JLCPCB/PCBWay.\n\n\n#### `fusion_export_gerbers`\n\nExport Gerber RS-274X + Excellon drill files as ZIP (auto-detects layers, selects CAM job)\n\n**Args:**\n- `boardName` — optional (default: from active doc name)\n- `layers` — optional: auto | 2 | 4\n- `outputDir` — optional (default: C:/tmp/adom-gerbers/)\n\n**Returns:** zipPath, zipSizeKB, layerCount, files[], nextSteps[]\n\n**Note:** ZIP contains GTL, GBL, GTS, GBS, GTP, GBP, GTO, GBO, GKO, XLN. Ready for fab upload.\n\n\n#### `fusion_set_design_rules`\n\nApply Adom/JLCPCB design rules (.edru files) — auto-detects 2/4 layer\n\n**Args:**\n- `action` — optional: apply (default) | export | show\n- `layers` — optional: auto (default) | 2 | 4\n- `outputPath` — optional, for export action\n\n**Returns:** edruFile, layerCount, description, nextSteps[]\n\n**Note:** Run DRC after applying rules to check for violations\n\n\n#### `fusion_dismiss_recovery`\n\nDismiss recovery dialogs + relocate recovery files to ~/.adom/recovery/\n\n**Note:** Handles both 'Recovered Documents' list and 'Open recovery instead?' prompts\n\n\n#### `fusion_relocate_recovery`\n\nMove crash recovery files to ~/.adom/recovery/fusion/ (call before launch to prevent dialogs)\n\n**Note:** Files preserved, not deleted. User can manually restore if needed.\n\n\n#### `fusion_click_fusion`\n\nClick at coordinates in Fusion window or a specific dialog\n\n**Args:**\n- `hwnd` — optional — target a specific dialog HWND instead of main window\n- `relative` — optional bool (default: true, 0.0-1.0)\n- `x` — required float\n- `y` — required float\n\n**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).\n\n\n#### `fusion_screenshot_all`\n\nScreenshot main window + list all dialog windows with HWNDs\n\n**Note:** Use fusion_screenshot_fusion with dialog HWND, or desktop_screenshot_window\n\n\n#### `fusion_screenshot_fusion`\n\nCapture Fusion main window or a specific dialog as PNG\n\n**Args:**\n- `hwnd` — optional — dialog HWND to screenshot instead of main window\n\n**Returns:** savedTo file path\n\n**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.\n\n\n#### `fusion_send_key`\n\nSend keyboard input to Fusion or a specific dialog\n\n**Args:**\n- `hwnd` — optional — target a specific dialog HWND\n- `key` — required string\n\n**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.\n\n\n#### `fusion_window_info`\n\nGet main window HWND, title, rect, and all Qt dialog windows\n\n**Returns:** hwnd, title, rect, dialogs[{hwnd, title, className, rect}]\n\n**Note:** Essential for detecting blocking dialogs after operations\n\n\n### Hydrogen Desktop\n\n_Build, ship, screenshot, and drive the embedded Hydrogen Desktop._ (22 verbs)\n\n> **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.\n\n#### `hd_api`\n\nGeneric 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.\n\n**Args:**\n- `body` — optional object — JSON body for POST/PUT/PATCH. Passed through verbatim (no escaping).\n- `method` — optional string (default 'GET') — 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'\n- `path` — required string starting with '/' — e.g. '/setup/run-step', '/wsl/status', '/claude/start-auth'\n- `port` — optional int — override discovered port; for testing\n\n**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)\n\n**Example:** `adom-desktop hd_api '{\"method\":\"POST\",\"path\":\"/setup/run-step\",\"body\":{\"id\":\"welcome\"}}'`\n\n\n#### `hd_browser_profiles`\n\nEnumerate browsers + profiles HD detects on the user's machine (GET /browser-profiles). Use the returned profileDir values when calling hd_open_url.\n\n**Returns:** HD's /browser-profiles response — typically {browsers:[{name, profiles:[{dir, name, gaia?}, ...]}]}\n\n\n#### `hd_build`\n\nFull 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+.\n\n**Args:**\n- `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).\n- `show` — optional bool (default false) — true opens a visible console window the user can watch the build scroll in; false runs hidden\n\n**Returns:** {ok, pid, logPath, flavor:'full', show, lingerSecs, _hint}\n\n**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.\n\n\n#### `hd_build_frontend`\n\nFrontend-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.\n\n**Args:**\n- `lingerSecs` — optional int (default 30 when show=true) — see hd_build for details. v1.8.23+.\n- `show` — optional bool (default false)\n\n**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.\n\n**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.\n\n\n#### `hd_build_log`\n\nReturn 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.\n\n**Returns:** {ok, log:'<full text>', lines, logPath, _hint}\n\n\n#### `hd_build_rust`\n\nRust-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.\n\n**Args:**\n- `lingerSecs` — optional int (default 30 when show=true) — see hd_build for details. v1.8.23+.\n- `show` — optional bool (default false)\n\n**Returns:** {ok, pid, logPath, flavor:'rust', show, lingerSecs, builtSha, exeMtimeBefore, exeSizeBefore, _hint}\n\n**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.\n\n\n#### `hd_build_status`\n\nSynchronous 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+.\n\n**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}\n\n**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.\n\n\n#### `hd_build_tail`\n\nStreaming 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+.\n\n**Args:**\n- `offset` — optional int (default 0) — byte offset into the log file from the previous call's newOffset\n\n**Returns:** {ok, lines:[], newOffset, totalBytes, done:bool, succeeded:bool, failureReason, _hint}\n\n**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.\n\n\n#### `hd_container_exec`\n\n[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\":\"...\"}}'.\n\n**Args:**\n- `command` — required string\n\n**Returns:** Public build: refusal payload. hd-dev internal build: HD's /container-exec response.\n\n\n#### `hd_eval`\n\n[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\":\"...\"}}'.\n\n**Args:**\n- `js` — required string — JS expression or statement\n\n**Returns:** Public build: refusal payload. hd-dev internal build: HD's /eval response.\n\n\n#### `hd_iframe_eval`\n\n[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\":\"...\"}}'.\n\n**Args:**\n- `contextIndex` — optional int\n- `js` — required string\n\n**Returns:** Public build: refusal payload. hd-dev internal build: HD's /iframe-eval response.\n\n\n#### `hd_launch`\n\nStart 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+.\n\n**Args:**\n- `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.\n\n**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)}.\n\n**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.\n\n\n#### `hd_log`\n\nRead 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.\n\n**Args:**\n- `tail` — optional int (default 30) — number of trailing lines to return\n\n**Returns:** {ok, lines:[...], path, totalLines, returnedLines}\n\n\n#### `hd_open_url`\n\nOpen 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.\n\n**Args:**\n- `browser` — optional (default 'chrome') — 'chrome', 'edge', 'firefox', 'brave'\n- `profileDir` — optional (default 'Default') — profile directory name from hd_browser_profiles\n- `url` — required string\n\n**Returns:** HD's /open-in-profile response (typically {ok, pid, profile})\n\n\n#### `hd_reload_vscode`\n\n[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).\n\n**Returns:** Public build: refusal payload. hd-dev internal build: HD's /reload-vscode response.\n\n\n#### `hd_restart`\n\nStop + 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+.\n\n**Returns:** {ok, killedPid?, pid, exePath, _hint}\n\n**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.\n\n\n#### `hd_screenshot`\n\nCapture 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.\n\n**Returns:** {success, savedTo, sizeKB, hwnd, data:{image (base64), format:'png', encoding, sizeBytes}}\n\n**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.\n\n\n#### `hd_send_files`\n\nSend 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.\n\n**Args:**\n- `filePaths` — required array of absolute local file paths to send (same as send_files)\n- `subdir` — optional string (default 'downloads') — subfolder under HD's project dir to write into; sits alongside the project's screenshots/ folder\n\n**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.\n\n**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).\n\n\n#### `hd_ship`\n\nv1.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.\n\n**Args:**\n- `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.\n- `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.\n\n**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.\n\n**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`).\n\n\n#### `hd_shot`\n\nv1.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).\n\n**Args:**\n- `format` — optional string, default 'png' (only PNG supported in v1).\n- `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:[...]}.\n- `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.\n\n**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}}\n\n**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+.\n\n\n#### `hd_status`\n\nv1.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.\n\n**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}\n\n**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.).\n\n\n#### `hd_stop`\n\nKill hydrogen-desktop.exe. `wasRunning` distinguishes 'killed it' from 'nothing to do'. v1.8.16+.\n\n**Returns:** {ok, wasRunning:bool, killedPid?, _hint}\n"+  "content": "# Adom Desktop\n\nA Tauri v2 desktop application for Windows that bridges Adom's cloud AI tools to local desktop applications: KiCad, Fusion 360, real Chrome (Puppeteer), native shell, file transfer, screenshots, notifications \u2014 plus a Hydrogen Desktop proxy. One install gives Claude every desktop verb it needs.\n\n## Install (in an Adom container)\n\n```\nadompkg install adom-desktop\n```\n\nThat installs the **container-side** pieces:\n\n- the `adom-desktop` CLI binary (downloaded from this page's blob store, SHA256-verified against the bundled `version.json`)\n- the Claude skill bundle into `~/.claude/skills/`:\n\n| Skill | What Claude learns |\n|---|---|\n| `adom-desktop` | the main verb catalog \u2014 screenshots, files, shell, windows, notifications |\n| `adom-desktop-pup` | real-Chrome (Puppeteer) automation: sessions, tabs, eval, recording |\n| `adom-desktop-kicad` | KiCad control: open/edit schematics + PCBs, exports, 3D viewer |\n| `adom-desktop-fusion` | Fusion 360 control: designs, exports, BOM/CPL |\n| `adom-desktop-direct-api` | the loopback HTTP API for sibling apps |\n| `adom-desktop-installer` | how to drive the Windows installer (silent flags, logs) |\n| `adom-desktop-discovery` | how a fresh container finds + refreshes the CLI |\n\n## What it looks like\n\nThe desktop app, connected to two containers, with the bridge bar and live activity log:\n\n![Adom Desktop GUI](gui.png)\n\nDriving KiCad end-to-end from the container \u2014 symbol, footprint, 3D model, schematic, board, gerbers:\n\n![KiCad driven by Adom Desktop](kicad-shot.png)\n\nThe **laptop-side GUI** installs separately on the user's Windows machine \u2014 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).\n\n## How it fits together\n\n```\nAdom container (this package)            User's laptop\n\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510             \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 adom-desktop CLI + skills\u2502 \u21c6 relay \u21c6  \u2502 Adom Desktop GUI (Tauri) \u2502\n\u2502 (Claude drives verbs)    \u2502             \u2502 KiCad \u00b7 Fusion \u00b7 Chrome  \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518             \u2502 shell \u00b7 files \u00b7 screens  \u2502\n                                         \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n```\n\nThe 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 \u2014 run `adom-desktop help` after install.\n\n\n## Versioning\n\nThis 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 \u2014 the package manifest's `assets` map carries server-computed checksums per platform.\n\n- Source: <https://github.com/adom-inc/adom-desktop> (private during beta)\n\n![Drive the real Chrome on the laptop](pup.png)\n\n## Command reference\n\nEvery verb and subcommand `adom-desktop` supports \u2014 **210 across 12 categories** \u2014 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 \u2014 surface it verbatim. This list is generated from the live help-map; run `adom-desktop commands` for the machine-readable source.\n\n**Categories:** [Connectivity](#connectivity) \u00b7 [Setup](#setup) \u00b7 [Shell & process](#shell-process) \u00b7 [File transfer](#file-transfer) \u00b7 [Desktop & bridge mgmt](#desktop-bridge-mgmt) \u00b7 [Screen recording](#screen-recording) \u00b7 [Notifications](#notifications) \u00b7 [Project watch](#project-watch) \u00b7 [Browser (Puppeteer)](#browser-puppeteer) \u00b7 [KiCad bridge](#kicad-bridge) \u00b7 [Fusion 360 bridge](#fusion-360-bridge) \u00b7 [Hydrogen Desktop](#hydrogen-desktop)\n\n### Connectivity\n\n_Liveness, status, multi-AD targeting._ (5 verbs)\n\n#### `health`\n\nCheck if the relay server HTTP API is reachable\n\n**Returns:** Relay server health status\n\n\n#### `kick_all`\n\nForce-disconnect all WebSocket clients (they auto-reconnect in ~5s)\n\n**Note:** Use when connections are stale or duplicated\n\n\n#### `ping`\n\nRound-trip connectivity test (5s timeout)\n\n**Args:**\n- `message` \u2014 optional string, echoed back\n\n**Returns:** echo, roundTripMs, status\n\n**Note:** Always run this first to verify desktop connection\n\n\n#### `status`\n\nQuery connected clients, capabilities, and app installation status\n\n**Returns:** clients count, appName, hostname, capabilities[], desktop app status (kicad/fusion/browser installed/running)\n\n\n#### `targets`\n\nv1.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.\n\n**Returns:** {count, targets:[{name, hostname, version, os, connectedAt}], _hint}\n\n**Note:** See _targeting_note at the top of this map for how --target works.\n\n\n### Setup\n\n_One-time desktop setup._ (1 verbs)\n\n#### `setup_desktop`\n\nGenerate installer URL and server config JSON for first-time desktop app setup\n\n**Returns:** installer_url, server_config JSON, steps[]\n\n\n### Shell & process\n\n_Run shell commands, scripts, WSL, and launch/locate apps on the laptop._ (6 verbs)\n\n#### `find_exe`\n\nFind 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.\n\n**Args:**\n- `name` \u2014 required: bare name ('Fusion360.exe'), glob ('.../webdeploy/production/*/Fusion360.exe'), or full path\n- `searchPaths` \u2014 optional array of extra dirs to scan\n\n**Returns:** path, source (absolute|glob|startmenu|path)\n\n\n#### `args`\n\n\n#### `run_script`\n\nv1.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 \u2014 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.\n\n**Args:**\n- `allowPopupRisk` \u2014 optional bool (v1.8.117+) \u2014 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 \u2014 that flashes a modal 'Windows cannot find' dialog on the user's desktop. powershell/bash scripts aren't scanned (no ShellExecute popup failure mode).\n- `interpreter` \u2014 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 \u2014 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.\n- `scriptB64` \u2014 required string: base64-encoded script content (no length limit beyond practical WS message size \u2014 keep under a few MB for sanity).\n- `timeoutSeconds` \u2014 optional int (default 60). The CLI's WS timeout is auto-sized to scriptTimeout + 15s margin.\n\n**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 \u2014 see tempPath in payload), 'spawn' (interpreter exe couldn't launch \u2014 see spawn_failure_hint output), 'timeout' (script ran longer than timeoutSeconds). EVERY failure path carries a _hint with the specific recovery recipe \u2014 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 \u2014 exit code / stdout / stderr are UNCHANGED \u2014 and points at the dedicated `hd_build_*` / `hd_ship` verbs.\n\n**Note:** Requires shell auto-approve to be active \u2014 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 \u2014 these are programmatic verbs, not interactive).\n\n\n#### `shell_execute`\n\nExecute a shell command on the user's desktop (requires user approval). Escape hatch for things that don't have a structured verb yet \u2014 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.\n\n**Args:**\n- `allowPopupRisk` \u2014 optional bool (v1.8.117+) \u2014 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.\n- `command` \u2014 required string \u2014 full shell command (Windows: runs through `cmd /c`; macOS/Linux: through `sh -c`)\n- `cwd` \u2014 optional working directory\n- `timeoutSeconds` \u2014 optional int (default 30, max effectively unbounded \u2014 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.\n\n**Returns:** stdout, stderr, exitCode\n\n**Note:** User must approve via in-app dialog unless auto-approve is active. v1.7.3+ timeout forwarding bug fixed \u2014 your timeoutSeconds is now respected by the desktop-side shell killer. v1.7.16+ approval-polling fixed \u2014 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).\n\n\n#### `shell_kill_all`\n\nKill all running shell command processes\n\n\n#### `wsl_exec`\n\nv1.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 \u2014 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\u00d7-per-session call pattern.\n\n**Args:**\n- `distro` \u2014 required string: WSL distro name (e.g. 'Adom-Workspace', 'Ubuntu'). `wsl -l -v` enumerates installed distros.\n- `scriptB64` \u2014 required string: base64-encoded bash script. Decoded content must be valid UTF-8.\n- `timeoutSeconds` \u2014 optional int (default 60). The CLI's WS timeout auto-sizes to scriptTimeout + 15s margin.\n- `user` \u2014 required string: WSL user to run as (e.g. 'adom', 'root'). Must exist in the distro.\n\n**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 \u2014 see wsl_error_hint), 'timeout' (script exceeded timeoutSeconds). EVERY failure carries an actionable _hint. The most common spawn failure is 'distro not installed' \u2014 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.\n\n\n### File transfer\n\n_Move files between the cloud container and the laptop (streamed, SHA-verified)._ (2 verbs)\n\n#### `pull_file`\n\nPull files from the desktop back to Docker. Streams as binary WS frames (1 MiB chunks) \u2014 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.\n\n**Args:**\n- `filePaths` \u2014 required array of absolute Windows paths (e.g. \"C:\\Users\\\\me\\\\Downloads\\\\big.pdf\")\n- `saveTo` \u2014 optional directory on Docker (default /tmp). Created if missing.\n\n**Returns:** files: [{name, path, size, sha256, chunks}] \u2014 sha256 verified before file is finalized; mismatch deletes the partial and reports failure. chunks is the number of 1 MiB binary frames received.\n\n**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.\n\n\n#### `send_files`\n\nSend files from Docker container to the desktop\n\n**Args:**\n- `destinationFolder` \u2014 optional relative subfolder\n- `filePaths` \u2014 required array of absolute paths\n- `targetApp` \u2014 optional: general|kicad|fusion360\n\n\n### Desktop & bridge mgmt\n\n_Windows, screenshots, files, embedded mode, relay servers, tunnels, and bridge lifecycle._ (44 verbs)\n\n#### `bridge_check_updates`\n\nREAD-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 \u2014 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.\n\n**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.\n\n**Note:** A bundled bridge shipped AHEAD of the wiki (and bundled-only puppeteer, whose deps exceed the wiki file limit) correctly reports updateAvailable=false \u2014 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.\n\n\n#### `bridge_install`\n\nInstall 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+.\n\n**Args:**\n- `manifestUrl` \u2014 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)\n\n**Returns:** {success, ok, bridge:<name from manifest>, version, installPath, _hint}\n\n**Note:** The bridge zip MUST contain a bridge.json at its root with name + spawn.{kind,entrypoint,port,...} + verbPrefixes. See https://github.com/adom-inc/adom-desktop/blob/main/plugins/kicad/bridge.json for the schema. To update an existing bridge, install again \u2014 the cache atomic-swaps.\n\n\n#### `bridge_list`\n\nList 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.\n\n**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}\n\n**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 \u2014 eliminates collision with HD.\n\n**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.) \u2014 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.\n\n\n#### `bridge_pause`\n\nPause 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.\n\n**Args:**\n- `name` \u2014 required \u2014 bridge name from bridge_list (e.g. 'kicad')\n\n**Returns:** {success, name, paused:true, _hint}\n\n\n#### `bridge_resume`\n\nResume a paused bridge \u2014 flip the routing flag back. Inverse of bridge_pause.\n\n**Args:**\n- `name` \u2014 required \u2014 bridge name\n\n**Returns:** {success, name, paused:false, _hint}\n\n\n#### `bridge_uninstall`\n\nRemove 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.\n\n**Args:**\n- `name` \u2014 required \u2014 bridge name\n\n**Returns:** {success, name, cacheRemoved, stillInstalledViaBundled, _hint}\n\n\n#### `desktop_bring_to_front`\n\nBring a window to the foreground\n\n**Args:**\n- `hwnd` \u2014 optional int\n- `state` \u2014 optional: maximize or restore (default: preserve current state)\n- `titleContains` \u2014 optional string (case-insensitive)\n\n\n#### `desktop_caption`\n\nShow a large always-on-top, click-through text overlay on the user's DESKTOP (not a chat caption) \u2014 for narrating what you're doing on their screen, screen-share / demo labels, status banners. Win32 layered window over every app.\n\n**Args:**\n- `action` \u2014 optional: \"hide\" (dismiss this id, or all if no id) | \"force-clear\" (nuke ALL caption windows)\n- `duration` \u2014 optional ms before auto-clear. Omitted or 0 \u2192 30000 (30s) default. Clamped to max 600000 (10 min). NOT 'forever' anymore.\n- `fontSize` \u2014 optional int px (8\u2013400), overrides size\n- `id` \u2014 optional string \u2014 captions with the SAME id replace each other (and re-issuing RESETS the timer = how you extend); different ids coexist. Default id \"_default\".\n- `persist` \u2014 optional bool. true \u2192 sticky, NO auto-clear (supervised use only, e.g. demo recordings) \u2014 YOU must dismiss it.\n- `position` \u2014 optional: top | center | bottom (default) | top-left | top-right | bottom-left | bottom-right\n- `size` \u2014 optional: small (20px) | medium (32px, default) | large (72px)\n- `text` \u2014 the caption text (omit, or action:\"hide\", to dismiss)\n- `x` \u2014 optional 0.0\u20131.0 horizontal fraction (overrides position)\n- `y` \u2014 optional 0.0\u20131.0 vertical fraction (overrides position)\n\n**Returns:** {visible, text, id, persist, expiresInMs (null if persist), _hint}\n\n**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 \u2014 a heartbeat) OR pass a bigger duration (\u226410 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.\n\n\n#### `desktop_connect_all`\n\nv1.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.\n\n**Returns:** {ok, success, totalEnabled, spawned, alreadyConnected, _hint}\n\n\n#### `desktop_disconnect_all`\n\nv1.8.42+: programmatically drop every live WS connection. Server entries stay in config (use server_remove to delete). Mirrors HD's tray 'Disconnect All'.\n\n**Returns:** {ok, success, disconnected, _hint}\n\n\n#### `desktop_embedded_enter`\n\nv1.8.73+: explicit runtime adopt \u2014 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 \u2014 that sets `entered_via=launch-flag`. Idempotent: returns `was_embedded:true` if AD was already embedded; no-ops in that case.\n\n**Returns:** {ok:true, success:true, was_embedded:bool, embedded:true, entered_via:'runtime-adopt', owner:'hydrogen-desktop', _hint}\n\n**See also:** desktop_embedded_release (revert), desktop_embedded_status (introspect). HD-side recipe in plugins/hd/SKILL.md.\n\n\n#### `desktop_embedded_release`\n\nv1.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 \u2014 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 \u2192 AD exits \u2192 supervisor respawns \u2192 looks like AD didn't exit). v1.8.77 decoupling fixes that.\n\n**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+ \u2014 kept for back-compat with HD code that checked it pre-v1.8.77.\n\n**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\u2192exit, RuntimeAdopt\u2192revert).\n\n\n#### `desktop_embedded_status`\n\nv1.8.42+: introspect whether AD is currently running in embedded mode (HD bundling AD). v1.8.73+ also reports `enteredVia` \u2014 `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.\n\n**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}\n\n\n#### `desktop_install_kicad`\n\nUnattended 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 \u2014 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 \u2014 it's a fixed-command verb the AI calls deliberately, not a free-form shell.\n\n**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}.\n\n**Note:** Trigger this when kicad_list_versions returns errorCode:\"kicad_not_installed\". After install completes, restart Adom Desktop OR call kicad_list_versions again \u2014 the kicad bridge caches detection results, so a fresh detection scan may be needed to pick up the freshly-installed binaries.\n\n\n#### `desktop_install_node`\n\nUnattended 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\" \u2014 that error fires when the puppeteer bridge can't auto-spawn because Node isn't installed. After install, retry the original browser_* command \u2014 the bridge reads node.exe location from the Windows registry (which winget writes), so a restart isn't needed.\n\n**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}.\n\n**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\" \u2192 desktop_install_node \u2192 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.\n\n\n#### `desktop_list_browsers`\n\nCatalog 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 \u2014 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.\n\n**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 \u2014 populated for Chromium browsers when the profile is signed in, absent otherwise. isDefault is the browser's last-used profile.\n\n**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 \u2014 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+.\n\n\n#### `desktop_list_files`\n\nList files in a directory matching a shell-glob, optionally filtered to those modified after a given timestamp. Pure filesystem op (no shell) \u2014 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.\n\n**Args:**\n- `glob` \u2014 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\".\n- `modifiedSince` \u2014 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.\n- `path` \u2014 required string. Windows abs path. ~, %USERPROFILE%, %APPDATA%, %LOCALAPPDATA%, %TEMP% are expanded. Forward slashes (/) and back slashes (\\\\) both work.\n\n**Returns:** {ok, path, glob, files: [{path, name, size, mtime}, ...] sorted newest-first, count}\n\n**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 \u2014 same args, but blocks until a match appears.\n\n\n#### `desktop_list_windows`\n\nList all visible windows with HWNDs, titles, positions\n\n\n#### `desktop_logout`\n\nv1.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.\n\n**Returns:** {ok, success, _hint}. After logout, adom_whoami returns null until the user signs in again (via the GUI footer link or another handoff).\n\n\n#### `desktop_open_folder`\n\nOpen file/folder in Windows Explorer\n\n**Args:**\n- `path` \u2014 required string\n\n\n#### `desktop_open_url`\n\nOpen a URL in the user's NATIVE OS BROWSER \u2014 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 \u2014 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.) \u2014 call desktop_list_browsers first to discover available profile IDs.\n\n**Args:**\n- `browser` \u2014 optional string: \"default\" (let Windows pick the user's registered default \u2014 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\".\n- `profile` \u2014 optional string (v1.7.1+) \u2014 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.\n- `url` \u2014 required string (https://... or any URL the OS shell can dispatch)\n\n**Returns:** {ok:bool, browser, exePath?, url, profile?} \u2014 profile echoed if it was applied\n\n**Note:** desktop_open_url vs browser_open_window \u2014 pick the right one:\n  \u2022 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 \u2014 once you hand off, you're done.\n  \u2022 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).\n  Rule of thumb: human-in-the-loop login flow => desktop_open_url. AI-driven automation => browser_open_window.\n  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.\n\n\n#### `desktop_pull_glob`\n\nConvenience orchestrator: list_files (or watch_files if wait:true) \u2192 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.\n\n**Args:**\n- `glob` \u2014 optional string (default \"*\").\n- `path` \u2014 required string. Windows abs path; same expansion as desktop_list_files.\n- `pollMs` \u2014 optional (only used when wait:true). Default 1000.\n- `saveTo` \u2014 required string. Container-side absolute path (a directory). Parent dirs auto-created. Each pulled file lands at saveTo/<basename>.\n- `since` \u2014 optional unix-seconds OR ISO-8601. Only files with mtime > since are pulled.\n- `timeoutMs` \u2014 optional (only used when wait:true). Default 60000.\n- `wait` \u2014 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.\n\n**Returns:** {ok, files:[{name, path, size, sha256, chunks}, ...], errors:[...], matchedCount}\n\n**Note:** Use this instead of shell_execute('powershell -c ... while not exists ...; sleep 1') \u2014 that pattern is deprecated for new code. shell_execute itself remains as an escape hatch but should not be used for download polling.\n\n\n#### `desktop_revoke_approvals`\n\nClear all shell auto-approve permissions and deny pending approvals\n\n\n#### `desktop_screenshot_screen`\n\nCapture 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 \u2014 same except hwnd-specific prefix is replaced with `screen-`.\n\n**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).\n\n\n#### `desktop_screenshot_window`\n\nCapture a specific window by HWND. v1.8.62+ writes BOTH a lossless full-res PNG + a Claude-safe-resized copy (\u2264resizeMax) 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.\n\n**Args:**\n- `hwnd` \u2014 required int\n- `resizeMax` \u2014 optional int, default 1400. Longest side of the safe-resized PNG.\n\n**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.\n\n\n#### `desktop_set_window_state`\n\nSet window state without necessarily foregrounding\n\n**Args:**\n- `hwnd` \u2014 optional int\n- `state` \u2014 required: maximize|minimize|restore|show|hide\n- `titleContains` \u2014 optional string\n\n**Note:** Use bring_to_front to foreground + maximize in one call. Use this for minimize/hide/show.\n\n\n#### `desktop_show_ui`\n\nv1.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.\n\n**Args:**\n- `show` \u2014 optional bool, default true (false hides/resets)\n- `target` \u2014 required: settings | build_info | direct_api | shell_approval | none (reset)\n\n**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.\n\n\n#### `desktop_shutdown`\n\nv1.8.42+: graceful AD exit \u2014 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.\n\n**Returns:** {ok, success, output, _hint}. The exit happens ~500ms after the response is sent so the caller doesn't see connection-reset.\n\n\n#### `desktop_watch_files`\n\nBlock 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.\n\n**Args:**\n- `glob` \u2014 optional string (default \"*\"). Same semantics as desktop_list_files.\n- `path` \u2014 required string. Same expansion semantics as desktop_list_files.\n- `pollMs` \u2014 optional int (default 1000, floor 100). How often to re-list the directory.\n- `since` \u2014 optional. Same parse rules as desktop_list_files.modifiedSince. DEFAULTS TO NOW \u2014 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.\n- `timeoutMs` \u2014 optional int (default 60000, max 600000 = 10 min). After this elapses, returns {ok:false, error:\"timeout\", elapsedMs, _hint}.\n\n**Returns:** On match: {ok:true, path, glob, file:{path,name,size,mtime}, elapsedMs}. On timeout: {ok:false, error:\"timeout\", elapsedMs, _hint, path, glob}.\n\n**Note:** Returns the FIRST file that matches \u2014 sorted newest-first within a poll cycle. Use desktop_pull_glob if you want the watch + multi-file pull in one call.\n\n\n#### `desktop_window_hide`\n\nv1.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.\n\n**Returns:** {ok, success, _hint}\n\n\n#### `desktop_window_show`\n\nv1.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 \u2014 any caller can bring AD to the foreground.\n\n**Returns:** {ok, success, _hint}\n\n\n#### `direct_api`\n\nINFORMATIONAL \u2014 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 \u2014 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` \u2014 exactly the same code the WS path uses.\n\n**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 \u2192 Windows), keep using the CLI \u2014 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\"`.\n\n**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.\n\n**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 \u2014 that range is no longer probed; auto-update from the wiki carries users forward.\n\n**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).\n\n\n#### `rdp`\n\nv1.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.\n\n**Args:**\n- `local` \u2014 optional int \u2014 local listener port on the desktop. Default 13389 (NOT 3389 \u2014 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.\n- `to` \u2014 REQUIRED string \u2014 RDP target in `host:port` form. If port omitted, defaults to 3389.\n\n**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.\n\n\n#### `refresh_bridges`\n\nForce 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.\n\n**Args:**\n- `name` \u2014 optional \u2014 bridge name from bridge_list (e.g. 'kicad'). Omit to sync every bridge.\n\n**Returns:** {success:true, results:[{bridge, ok, from, to, action}], _hint}. action \u2208 {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 \u2014 the cache update is on-disk, not in-memory.\n\n**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.\n\n\n#### `register_container`\n\nv1.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 \u2014 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 \u2192 install AD on Windows \u2192 sign in \u2192 container appears automatically.\n\n**Args:**\n- `auth_token` \u2014 optional string \u2014 relay auth token (defaults to adom-dev-token-2025, which is what the cloud relay accepts by default)\n- `auto_connect` \u2014 optional bool \u2014 auto-dial on AD launch (default true)\n- `enabled` \u2014 optional bool \u2014 enabled in AD's server list (default true)\n- `id` \u2014 optional string \u2014 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.\n- `name` \u2014 REQUIRED string \u2014 human-friendly label for AD's server list (e.g. 'galliaApril')\n- `url` \u2014 REQUIRED string \u2014 public wss:// URL the desktop will dial (typically wss://<container-slug>.adom.cloud/proxy/8765/)\n\n**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.\n\n**User flow:** Cloud Docker: adom-desktop register_container '{\"name\":\"my-container\",\"url\":\"wss://...\"}'  \u2192  user installs Adom Desktop on Windows from https://wiki.adom.inc/adom/adom-desktop  \u2192  user clicks 'Sign in' in AD's footer + completes the browser flow  \u2192  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.\n\n**See also:** Designed to work with Phase 1 (auth in AD), Phase 2 (cloud-config sync in AD), and Phase 4 (HD\u2192AD session handoff if AD is bundled with Hydrogen Desktop). The full bundle landed across v1.8.33 - v1.8.37.\n\n\n#### `relaunch_self`\n\nv1.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 \u2192 stop AD \u2192 deploy (atomic temp+rename) \u2192 launch (CreateProcessW DETACHED_PROCESS) \u2192 verify reachable (polls AD's ephemeral direct-api port from the discovery file, v1.8.118+) \u2192 verify version (running PE FileVersion matches source) \u2192 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 \u2014 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` \u2014 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.\n\n**Args:**\n- `destExe` \u2014 optional dest path (default: %LOCALAPPDATA%\\Adom Desktop\\adom-desktop.exe)\n- `noAutoStart` \u2014 optional bool \u2014 if true, the relauncher shows its GUI but waits for POST /start instead of running immediately\n- `port` \u2014 optional HTTP API port for the relauncher (default: 47210)\n- `sourceExe` \u2014 optional path to the freshly-built adom-desktop.exe (default: src-tauri/target/release/adom-desktop.exe relative to the relauncher's location)\n\n**Returns:** {ok, success, relauncherPid, relauncherExe, relauncherPort, _hint}\n\n**See also:** relauncher_status (poll progress), skills/AD_RELAUNCH.md (full recipe).\n\n\n#### `relauncher_status`\n\nv1.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 \u2014 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.\n\n**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}\n\n**See also:** relaunch_self (spawn), skills/AD_RELAUNCH.md (recipe).\n\n\n#### `server_add`\n\nRegister a relay server connection programmatically \u2014 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 \u2014 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+.\n\n**Args:**\n- `authToken` \u2014 optional string \u2014 defaults to 'adom-dev-token-2025'. Empty string means 'keep existing token if this is an update; otherwise use default'.\n- `autoConnect` \u2014 optional bool, default true \u2014 connect right after upsert. Set false to add the entry without dialling out.\n- `name` \u2014 required string \u2014 human-friendly server label. Acts as the dedup key for upserts.\n- `url` \u2014 required string \u2014 relay WebSocket URL (e.g. ws://localhost:8765 or wss://your-host/proxy/8765/).\n\n**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 \u2014 call server_list to see the live state).\n\n**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>\"}'.\n\n\n#### `server_list`\n\nReturn 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+.\n\n**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) \u2014 left in the schema for symmetry with the relay's own client-count terminology.\n\n\n#### `server_remove`\n\nDisconnect (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+.\n\n**Args:**\n- `name` \u2014 required string \u2014 the same `name` you passed to server_add.\n\n**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.\n\n\n#### `shell_auto_approve`\n\nProgrammatically enable/revoke shell_execute auto-approve. v1.8.3+. v1.8.116+: `permanent` is the DURABLE user preference \u2014 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').\n\n**Args:**\n- `duration_secs` \u2014 optional int \u2014 seconds to enable for (e.g. 86400 = 24h), session-scoped. Pass 0 to revoke immediately (also clears the durable preference). Ignored if `permanent: true`.\n- `permanent` \u2014 optional bool \u2014 true sets the durable Settings preference + arms it now; false clears it (full off). Default: absent (no preference change for timed grants).\n\n**Returns:** {success, output, active:bool, remainingSecs, permanent:bool}\n\n**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 \u2014 leave it that way for end users; permanent is an explicit owner opt-in. Typical patterns:\n  - Dev session: `adom-desktop shell_auto_approve '{\"duration_secs\": 86400}'` (24h, then expires).\n  - Owner's box, durable: `adom-desktop shell_auto_approve '{\"permanent\": true}'` (equivalent to ticking the Settings checkbox).\n  - HD embedded spawn: pass `--shell-approval permanent` (or the user's HD-side choice) on AD's command line \u2014 session-only, never persisted by AD.\n  - Revoke everything: `adom-desktop shell_auto_approve '{\"duration_secs\": 0}'`.\n\n\n#### `tunnel`\n\nv1.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\u2194relay 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 \u2014 the WS is already authed; the tunnel is just bytes routed by tunnelId+connId on top of it.\n\n**Args:**\n- `local` \u2014 REQUIRED int \u2014 the local 127.0.0.1 port the desktop should bind. mstsc / your TCP client connects HERE.\n- `to` \u2014 REQUIRED string \u2014 upstream target in `host:port` form. The relay dials this when bytes arrive on the local port.\n- `tunnelId` \u2014 optional u32 \u2014 caller-chosen ID. Defaults to a fresh random u32. Reuse across re-invocations to address the same tunnel.\n\n**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.\n\n**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.\n\n\n#### `tunnel_close`\n\nv1.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.\n\n**Args:**\n- `connId` \u2014 optional u32 \u2014 if given, close only this conn; otherwise close the whole tunnel\n- `tunnelId` \u2014 REQUIRED u32 \u2014 from `tunnel` / `tunnel_list`\n\n**Returns:** {ok, tunnelId, connId?, scope:'tunnel'|'conn', _hint}\n\n\n#### `tunnel_list`\n\nv1.8.85+: list all active tunnels \u2014 both the relay's view and AD's view (should agree).\n\n**Returns:** {ok, relay:[{tunnelId, toHost, toPort, clientId, connCount}, ...], desktop:{tunnels:[...]}, _hint}\n\n\n### Screen recording\n\n_Record the desktop/monitors to video; manage the recorder window._ (7 verbs)\n\n#### `desktop_list_monitors`\n\nList available monitors. v1 returns the primary only; full enumeration is v1.1.\n\n**Returns:** monitors: [{index, name, x, y, width, height, primary}]\n\n\n#### `desktop_record_list`\n\nList completed desktop recording .webm files on disk with their sidecar metadata.\n\n**Returns:** recordings: [{recordingId, filePath, sizeKB, durationMs, reason, mtime}]\n\n\n#### `desktop_record_start`\n\nStart 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 \u2014 use desktop_recorder_close to dismiss.\n\n**Args:**\n- `audio` \u2014 optional bool (default false)\n- `confirmDesktopNotTabRecording` \u2014 REQUIRED bool \u2014 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).\n- `fps` \u2014 optional int (default 30)\n- `maxDurationMs` \u2014 optional int (default 600000)\n- `monitor` \u2014 optional 'primary'|'all'|int (default 'primary')\n- `reason` \u2014 REQUIRED string \u2014 explain to the user why you're recording (renders in the HUD title and sidecar JSON)\n- `videoBitsPerSecond` \u2014 optional int (default 4000000)\n\n**Returns:** recordingId, filePath (.webm), startedAt, monitor, reason\n\n**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 \u2014 adom-desktop's desktop recording is mainly for the parallel-with-Hydrogen-tab-capture case.\n\n**Error code:** desktop_record_needs_confirmation when confirmDesktopNotTabRecording is missing/false \u2014 read the error and decide if you really want full-desktop or pup-tab recording\n\n\n#### `desktop_record_status`\n\nStatus of the desktop recorder HUD and any active clip.\n\n**Returns:** hudOpen, reason, currentRecording, clipsThisSession\n\n\n#### `desktop_record_stop`\n\nStop the active desktop clip. Finalizes the .webm. HUD stays open in Ready state for the next clip in this session.\n\n**Args:**\n- `recordingId` \u2014 optional (active clip if omitted)\n\n**Returns:** recordingId, filePath, sizeKB, durationMs, stopReason\n\n\n#### `desktop_recorder_close`\n\nClose the desktop recorder HUD (ends the session). Auto-stops any active clip first.\n\n**Returns:** sessionSummary: {clipCount, totalSizeKB, reason, clips}\n\n\n#### `desktop_recorder_open`\n\nOpen 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 \u2014 the HUD will record the entire desktop, NOT a pup tab.\n\n**Args:**\n- `reason` \u2014 required string explaining why you're recording\n\n**Returns:** opened, reason\n\n**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).\n\n\n### Notifications\n\n_Native desktop toast notifications._ (1 verbs)\n\n#### `notify_user`\n\nSend a Windows desktop toast notification\n\n**Args:**\n- `actions` \u2014 optional array of button labels\n- `body` \u2014 required string\n- `level` \u2014 optional: info|warning|error|emergency (default: info)\n- `title` \u2014 required string\n\n\n### Project watch\n\n_Watch a local folder + sync changes._ (1 verbs)\n\n#### `args`\n\n\n### Browser (Puppeteer)\n\n_Drive Chrome-for-Testing: navigate, eval, screenshot, record, tabs/windows, credentials._ (31 verbs)\n\n#### `browser_alert_window`\n\nFlash the Chrome window's taskbar icon (does NOT steal foreground)\n\n**Args:**\n- `sessionId` \u2014 required\n\n\n#### `browser_close`\n\nClose 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.\n\n**Args:**\n- `sessionId` \u2014 optional \u2014 close just this one session\n\n**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.\n\n\n#### `browser_close_tab`\n\nClose one tab; session + other tabs remain\n\n**Args:**\n- `sessionId` \u2014 required\n- `tabId` \u2014 required\n\n**Returns:** remaining tab count\n\n\n#### `browser_close_window`\n\nClose a session's Chrome window (closes ALL tabs in that session)\n\n**Args:**\n- `sessionId` \u2014 required\n\n\n#### `browser_errors`\n\nGet 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.\n\n**Args:**\n- `clear` \u2014 optional bool (default true)\n- `sessionId` \u2014 optional\n- `tabId` \u2014 optional\n\n\n#### `browser_eval`\n\nEvaluate 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.\n\n**Args:**\n- `expr` \u2014 required JS string\n- `sessionId` \u2014 optional\n- `tabId` \u2014 optional\n\n\n#### `browser_fetch_url`\n\nFetch an arbitrary URL with the session's cookies and return the raw bytes. Bypasses Chrome's PDF Viewer wrapper \u2014 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.\n\n**Args:**\n- `body` \u2014 optional raw string body for POST etc.\n- `desktopSaveTo` \u2014 optional DESKTOP-side absolute path (Windows) \u2014 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.\n- `headers` \u2014 optional object \u2014 Cookie auto-included from session\n- `method` \u2014 optional (default GET)\n- `saveTo` \u2014 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+ \u2014 v1.4.8 had a bug where this could lie). saveTo and bodyBase64 are mutually exclusive in the response.\n- `sessionId` \u2014 optional (defaults to active session)\n- `tabId` \u2014 optional (picks cookie context \u2014 defaults to active tab; cookies are per-browser-context so any tab in the session works)\n- `url` \u2014 required\n\n**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}\n\n**Note:** Use this when a click spawns a popup tab whose Content-Type is application/pdf \u2014 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 \u2014 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).\n\n\n#### `browser_focus_window`\n\nActivate the session's tab WITHIN Chrome (Puppeteer page.bringToFront). Does NOT raise the OS window \u2014 for that, use browser_raise_os_window.\n\n**Args:**\n- `sessionId` \u2014 required\n\n\n#### `browser_input_dispatch`\n\nDispatch 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' \u2014 most modern frameworks (React/Vue/Svelte) and vendor anti-automation gates check event.isTrusted before running handlers.\n\n**Args:**\n- `button` \u2014 click only \u2014 left|right|middle (default left)\n- `clickCount` \u2014 click only \u2014 int (default 1; use 2 for double-click)\n- `delay` \u2014 click/type/key only \u2014 ms between mousedown+mouseup or keystrokes (default 0)\n- `firstMatch` \u2014 click only \u2014 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.\n- `key` \u2014 key only \u2014 Enter|Escape|Tab|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|F1|... (any Puppeteer KeyInput name)\n- `selector` \u2014 click only \u2014 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 \u2014 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.\n- `sessionId` \u2014 optional (defaults to active session)\n- `steps` \u2014 move only \u2014 number of intermediate positions for human-like movement (default 1)\n- `tabId` \u2014 optional (defaults to active tab)\n- `text` \u2014 type only \u2014 string to type into the focused element. Pass selector to focus that element first.\n- `type` \u2014 required: click | move | type | key\n- `x` \u2014 click/move only \u2014 viewport X coord\n- `y` \u2014 click/move only \u2014 viewport Y coord\n\n**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, ...}.\n\n**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 \u2014 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 \u2014 useful for diagnosing toast-container false-positives.\n\n\n#### `browser_list_tabs`\n\nList 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.\n\n**Args:**\n- `sessionId` \u2014 required\n\n**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 \u2014 e.g. a product page that spawned a datasheet popup).\n\n\n#### `browser_list_windows`\n\nList all open browser sessions with URLs and titles\n\n\n#### `browser_lower_os_window`\n\nMinimize the OS window hosting this pup session (give the user back their desktop after Claude is done with a flow).\n\n**Args:**\n- `sessionId` \u2014 required\n\n**Returns:** sessionId, hwnd, lowered, error?\n\n\n#### `browser_navigate`\n\nNavigate 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.\n\n**Args:**\n- `sessionId` \u2014 optional\n- `tabId` \u2014 optional\n- `url` \u2014 required\n\n\n#### `browser_open_tab`\n\nAdd a tab to an existing session (same Chrome window as other tabs in that session \u2014 avoids new taskbar icon)\n\n**Args:**\n- `sessionId` \u2014 required\n- `url` \u2014 required\n\n**Returns:** tabId, url, title, active\n\n\n#### `browser_open_window`\n\nOpen a Chrome window with a URL (auto-starts Puppeteer bridge). v1.6.3+: every session opens in FULL CAPABILITY MODE by default \u2014 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).\n\n**Args:**\n- `downloadPath` \u2014 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 \u2014 the two ends meet without configuration.\n- `freshProfile` \u2014 optional bool\n- `profile` \u2014 required string\n- `sessionId` \u2014 required string\n- `strictPermissions` \u2014 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).\n- `url` \u2014 required string\n\n**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, ...}\n\n**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 \u2014 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.\n\n\n#### `browser_raise_os_window`\n\nRaise 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 \u2192 ~1Hz rAF). Internally: focuses the tab, then EnumWindows-finds the Chrome window by title containing '(session: <sessionId>)' and runs the AttachThreadInput + keybd_event bypass.\n\n**Args:**\n- `sessionId` \u2014 required\n\n**Returns:** sessionId, hwnd, title, raised, error?\n\n\n#### `browser_record_list`\n\nList completed tab recording .webm files on disk with sidecar metadata.\n\n**Returns:** recordings: [{recordingId, filePath, sizeKB, durationMs, fps, sessionId, tabId, mtime}]\n\n\n#### `browser_record_start`\n\nRecord a pup tab via CDP Page.startScreencast + Page.screencastFrameAck (Chrome's native compositor pipeline). Tab-scoped at the protocol level \u2014 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).\n\n**Args:**\n- `fps` \u2014 optional int (default 30) \u2014 actual fps may exceed target on rAF-animated pages\n- `maxDurationMs` \u2014 optional int (default 600000 = 10 min)\n- `quality` \u2014 optional int 0-100 JPEG quality (default 85)\n- `sessionId` \u2014 required\n- `tabId` \u2014 optional (defaults to active tab)\n\n**Returns:** recordingId, sessionId, tabId, filePath (.webm), startedAt\n\n\n#### `browser_record_status`\n\nList active tab recordings with live capture stats.\n\n**Args:**\n- `sessionId` \u2014 optional (filter to one session)\n\n**Returns:** active: [{recordingId, sessionId, tabId, filePath, fpsTarget, fpsActual, frameCount, durationMs, sizeBytesApprox, startedAt}]\n\n\n#### `browser_record_stop`\n\nStop a tab recording. Mux runs synchronously on stop (ffmpeg concat demuxer with per-frame wall-clock durations). Returns a single finished .webm \u2014 pull_file directly, no extraction step.\n\n**Args:**\n- `recordingId` \u2014 required\n- `sessionId` \u2014 optional\n\n**Returns:** recordingId, sessionId, tabId, filePath (.webm), sizeKB, durationMs, frameCount, fpsTarget, fpsActual, stopReason\n\n**Note:** fpsActual reports the measured rate. If it falls below ~20 when target was 30, the pup window was occluded \u2014 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 \u2014 close + reopen the session to refresh).\n\n\n#### `browser_reload`\n\nReload 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.\n\n**Args:**\n- `sessionId` \u2014 optional\n- `tabId` \u2014 optional\n\n\n#### `browser_rescan`\n\nRecover 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+ \u2014 paired with strict resolveSession (passing a known-but-disconnected sessionId now returns errorCode:\"session_disconnected\" instead of silently retargeting an unrelated window).\n\n**Args:**\n- `adoptOrphans` \u2014 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.\n\n**Returns:** {ok, rescanned, profilesReconnected, profilesUnreachable, reattached, orphansAdopted, liveSessions, disconnectedSessions, _hint}\n\n**Note:** Idempotent \u2014 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\").\n\n\n#### `browser_screenshot`\n\nCapture 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 \u2014 the two-layer resize is mandatory so the image always fits the many-image API limit.\n\n**Args:**\n- `fullPage` \u2014 optional bool\n- `maxWidth` \u2014 optional int (default 1568)\n- `sessionId` \u2014 optional\n- `tabId` \u2014 optional\n\n**Returns:** savedTo (file path), sizeKB, width (final on-disk px), height, origWidth (pre-resize, what Chrome captured), origHeight, resized (bool \u2014 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.\n\n\n#### `browser_screenshot_full_res`\n\nCapture FULL RESOLUTION screenshot (NO resize). WARNING: Do NOT Read the resulting file in Claude \u2014 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+.\n\n**Args:**\n- `fullPage` \u2014 optional bool\n- `sessionId` \u2014 optional\n- `tabId` \u2014 optional\n\n**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.\n\n**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.\n\n\n#### `browser_status`\n\nCheck all sessions: URLs, error counts, alive status\n\n\n#### `browser_switch_tab`\n\nMake a specific tab the active one (calls bringToFront within Chrome)\n\n**Args:**\n- `sessionId` \u2014 required\n- `tabId` \u2014 required\n\n\n#### `browser_switch_window`\n\nSwitch the bridge's active session (which window commands without explicit sessionId target)\n\n**Args:**\n- `sessionId` \u2014 required\n\n\n#### `browser_wait`\n\nWait for content to settle\n\n**Args:**\n- `ms` \u2014 optional int (default 3000)\n\n\n#### `credential_delete`\n\nRemove a credential entry by exact host pattern. Drops the password from the OS keychain and the entry from the index.\n\n**Args:**\n- `host` \u2014 required (must match the host pattern used at credential_set time, exactly)\n\n**Returns:** {ok, host, removedFromKeychain, removedFromIndex}\n\n\n#### `credential_list`\n\nList stored host patterns + their associated usernames. Passwords are NEVER returned.\n\n**Returns:** {ok, credentials: [{host, username, addedAt, updatedAt}]}\n\n\n#### `credential_set`\n\nStore 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.\n\n**Args:**\n- `host` \u2014 required (e.g. \"*.componentsearchengine.com\" or exact \"nxp.componentsearchengine.com\")\n- `password` \u2014 required (NEVER echoed back via list/get)\n- `username` \u2014 required\n\n**Returns:** {ok, host, username}\n\n\n### KiCad bridge\n\n_Drive KiCad 7/8/9/10: open designs, DRC/ERC, libraries, 3D, screenshots, lint._ (27 verbs)\n\n> **Note:** All kicad_* commands are forwarded to the KiCad bridge (port 8772). Use kicad_<command> format.\n\n#### `kicad_bridge_call`\n\nGeneric 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).\n\n**Args:**\n- `exeName` \u2014 required (kicad, pcbnew, eeschema, kicad-cli)\n- `method` \u2014 required \u2014 see methods list\n- `params` \u2014 optional dict \u2014 method-specific\n- `pid` \u2014 optional int \u2014 pin to a specific process PID (multi-instance disambiguation, v1.7.6+)\n- `timeout` \u2014 optional float seconds (default 10; bump to 30-60 for exports)\n\n**Returns:** {ok, result:<rpc result>, exeName, pid, port} OR {ok:false, errorCode:\"plugin_not_running\"|\"rpc_error\", ...}\n\n**Note:** Methods (v1.7.9): introspection \u2014 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) \u2014 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 \u2192 netName \u2192 connected (ref,pin) list). Export (pcbnew only) \u2014 export_svg / export_pdf via in-process PLOT_CONTROLLER. Library nav \u2014 navigate_symbol{symbolName} / navigate_footprint{footprintName} (filter only \u2014 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 \u2014 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 \u2014 run_drc_inprocess (posts WM_COMMAND 20088 to PCB Editor; ~2-5s; then poll get_drc_markers). Length-matching \u2014 get_track_paths_by_net{netName}. Net graph \u2014 get_net_topology{netName} returns components on the net + their other-net bridges (1-hop graph).\n\n\n#### `kicad_bridge_status`\n\nEnumerate 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.\n\n**Args:**\n- `exeName` \u2014 optional \u2014 filter to one exe (kicad / pcbnew / eeschema / kicad-cli)\n- `pid` \u2014 optional int \u2014 filter to a specific PID\n- `pruneStale` \u2014 optional bool (default false) \u2014 delete discovery files whose plugin doesn't respond to /status\n\n**Returns:** {ok, plugins:[{exeName, pid, port, version, endpoint, alive, uptimeMs, requestCount, discoveryFile, pruned?}], summary:{total, alive}, pluginAutoInstall:{...}, _hint}\n\n**Note:** Phase 2 reverse bridge (v1.7.x+). If summary.alive=0 but you expect a running KiCad, the plugin isn't deployed \u2014 call kicad_install_plugin to force-deploy. Stale discovery files (process exited mid-session) show alive=false; pass pruneStale:true to clean them up.\n\n\n#### `kicad_click`\n\nClick at coordinates in a KiCad window\n\n**Args:**\n- `hwnd` \u2014 required\n- `relative` \u2014 optional bool (default true, 0.0-1.0 range)\n- `x` \u2014 required\n- `y` \u2014 required\n\n**Note:** Relative coords are scale-independent \u2014 estimate position as percentage from screenshot.\n\n\n#### `kicad_close`\n\nClose all KiCad windows\n\n\n#### `kicad_close_3d_viewer`\n\nClose 3D Viewer window\n\n\n#### `kicad_close_footprint_editor`\n\nClose Footprint Editor window\n\n\n#### `kicad_close_symbol_editor`\n\nClose Symbol Editor window\n\n\n#### `kicad_fix_keyboard`\n\nRelease stuck modifier keys + unlock foreground focus\n\n\n#### `kicad_format_upgrade`\n\nUpgrade 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.\n\n**Args:**\n- `filePath` \u2014 required file or directory\n- `force` \u2014 optional bool (default false) \u2014 passes --force to resave even when already current\n- `kind` \u2014 optional pcb|sch|sym|fp (auto-detected from extension if omitted)\n\n**Returns:** {success, exitCode, kind, stdout, stderr, source_path, _hint}\n\n**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).\n\n\n#### `kicad_install_library`\n\nInstall a symbol or footprint library\n\n**Args:**\n- `kicadVersion` \u2014 optional (default = newest installed; library installs into THAT version's sym-lib-table)\n- `libraryName` \u2014 optional\n- `libraryPath` \u2014 required\n- `libraryType` \u2014 required\n\n\n#### `kicad_install_plugin`\n\nExplicitly (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.\n\n**Args:**\n- `force` \u2014 optional bool (default true). If false, returns the cached auto-install result instead of redoing the work.\n\n**Returns:** {ok, result:{summary, payloadVersion, perVersion:[...], runningKiCad:[...], _hint}, _hint}\n\n**Note:** Idempotent \u2014 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 \u2014 surface result.runningKiCad to the user and tell them to close+reopen KiCad.\n\n\n#### `kicad_install_symbol`\n\nDecode base64 symbol, install as library, open editor\n\n**Args:**\n- `fileContent` \u2014 required base64\n- `fileName` \u2014 required\n- `kicadVersion` \u2014 optional (default = newest installed)\n\n\n#### `kicad_lint_board`\n\nHeadless 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 \u2014 catches DRC errors, parse issues, format-upgrade-needed BEFORE the GUI opens and possibly hangs on a modal dialog.\n\n**Args:**\n- `filePath` \u2014 required .kicad_pcb path\n- `schematicParity` \u2014 optional bool (default true) \u2014 set false if no matching .kicad_sch exists in the project dir\n\n**Returns:** {success, data:{violations[], summary:{total,errors,warnings,info}, fileFormat:{fileVersion}, schematicParityChecked, source_file}, _hint with tiered guidance}\n\n**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).\n\n\n#### `kicad_lint_library`\n\nValidate 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 \u2014 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.\n\n**Args:**\n- `libraryPath` \u2014 required path to .kicad_sym / .kicad_mod / .pretty dir / dir with .kicad_sym files\n\n**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}\n\n**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.\n\n\n#### `kicad_lint_schematic`\n\nHeadless 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.\n\n**Args:**\n- `filePath` \u2014 required .kicad_sch path\n\n**Returns:** {success, data:{violations[], summary:{total,errors,warnings,info}, fileFormat:{fileVersion}, source_file}, _hint with tiered guidance}\n\n**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 \u2014 DRC may surface them differently downstream.\n\n\n#### `kicad_list_versions`\n\nList every installed KiCad version with paths. Returns versions:[{version, base_dir, kicad_exe, default}], default, count.\n\n**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).\n\n\n#### `kicad_open_3d_viewer`\n\nOpen KiCad 3D Viewer (board or footprint).\n\n**Args:**\n- `editor` \u2014 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 \u2014 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.\n- `kicadVersion` \u2014 optional (default = newest installed)\n\n\n#### `kicad_open_board`\n\nOpen .kicad_pcb in PCB editor\n\n**Args:**\n- `filePath` \u2014 required\n- `kicadVersion` \u2014 optional (e.g. \"9.0\" or \"10.0\"; default = newest installed)\n\n\n#### `kicad_open_editors`\n\nCross-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.\n\n**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}\n\n**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.\n\n\n#### `kicad_open_footprint_editor`\n\nOpen Footprint Editor\n\n**Args:**\n- `footprintName` \u2014 optional\n- `kicadVersion` \u2014 optional (default = newest installed)\n- `libraryName` \u2014 optional\n\n\n#### `kicad_open_schematic`\n\nOpen .kicad_sch in schematic editor\n\n**Args:**\n- `filePath` \u2014 required\n- `kicadVersion` \u2014 optional (e.g. \"9.0\" or \"10.0\"; default = newest installed)\n\n\n#### `kicad_open_symbol_editor`\n\nOpen Symbol Editor\n\n**Args:**\n- `kicadVersion` \u2014 optional (default = newest installed)\n- `libraryName` \u2014 optional\n- `symbolName` \u2014 optional\n\n\n#### `kicad_run_drc`\n\nRun 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}}}.\n\n**Args:**\n- `filePath` \u2014 required\n- `kicadVersion` \u2014 optional (default = newest installed)\n\n**Note:** v1.7.12+ ships kicad_lint_board with the same DRC + file-format checks + schematic-parity in one structured response \u2014 prefer it over kicad_run_drc for new code.\n\n\n#### `kicad_run_erc`\n\nRun Electrical Rule Check on a .kicad_sch via kicad-cli. v1.7.12+. Mirrors kicad_run_drc but for schematics \u2014 catches unconnected pins, duplicate references, hierarchical-label mismatches, no-driver nets, etc. Headless, ~1-3s, structured JSON.\n\n**Args:**\n- `filePath` \u2014 required .kicad_sch path\n\n**Returns:** {success, data:{violations[], summary:{total,errors,warnings,info}, fileFormat:{fileVersion}, source_file}, _hint guiding next steps}\n\n\n#### `kicad_screenshot_all`\n\nScreenshot all KiCad windows in one shot\n\n**Returns:** Array of {type, title, hwnd, savedTo, sizeKB}\n\n**Note:** Images downscaled to max 1568px. Use relative coords (0.0-1.0) for clicking \u2014 they map to actual window size regardless of image scale.\n\n\n#### `kicad_send_key`\n\nSend keystroke to KiCad window. Supports modifier combos.\n\n**Args:**\n- `hwnd` \u2014 optional target HWND\n- `key` \u2014 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.\n\n**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.\n\n\n#### `kicad_window_info`\n\nList all KiCad windows (project manager, editors, dialogs) with HWNDs and rects\n\n\n### Fusion 360 bridge\n\n_Drive Fusion 360: CAD export, cloud docs, EAGLE electronics, manufacturing, UI._ (63 verbs)\n\n> **Note:** Two-tier architecture: bridge (port 8773) handles launch/detection; AdomBridge add-in (port 8774) handles exports/queries inside Fusion.\n\n> **Workflow:** fusion_start -> fusion_open_cloud_file or fusion_open_board -> work -> fusion_close\n\n#### `fusion_export_3mf`\n\nExport active design as 3MF\n\n**Args:**\n- `outputPath` \u2014 required\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_dwg`\n\nExport active design as DWG\n\n**Args:**\n- `outputPath` \u2014 required\n\n**Note:** Not available via API (dialog only). Use fusion_export_step or fusion_export_dxf instead.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_dxf`\n\nExport active design as DXF\n\n**Args:**\n- `outputPath` \u2014 required\n\n**Note:** Not available via API (dialog only). Use fusion_export_step or fusion_export_iges instead.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_f3d`\n\nExport active design as Fusion 360 archive (.f3d)\n\n**Args:**\n- `outputPath` \u2014 required\n\n**Note:** Native Fusion format \u2014 preserves parametric features, history, component references\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_fbx`\n\nExport active design as FBX\n\n**Args:**\n- `outputPath` \u2014 required\n\n**Note:** FBX only \u2014 no fallback. Not available in current Fusion builds. Use fusion_export_usdz or fusion_export_step instead.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_iges`\n\nExport active design as IGES (.igs)\n\n**Args:**\n- `outputPath` \u2014 required\n\n**Note:** Legacy CAD interchange. STEP is preferred for modern workflows.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_obj`\n\nExport active design as OBJ\n\n**Args:**\n- `outputPath` \u2014 required\n\n**Note:** Not available via API (dialog only). Use fusion_export_stl or fusion_export_usdz instead.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_sat`\n\nExport active design as SAT (.sat, ACIS solid model)\n\n**Args:**\n- `outputPath` \u2014 required\n\n**Note:** ACIS kernel format \u2014 opens in SolidWorks, SpaceClaim.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_skp`\n\nExport active design as SKP (SketchUp)\n\n**Args:**\n- `outputPath` \u2014 required\n\n**Note:** Not available via API (dialog only). Use fusion_export_step or fusion_export_stl instead.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_step`\n\nExport active design as STEP\n\n**Args:**\n- `outputPath` \u2014 required\n\n**Prerequisite:** Active 3D design (not Electronics)\n\n\n#### `fusion_export_stl`\n\nExport active design as STL\n\n**Args:**\n- `outputPath` \u2014 required\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_export_usdz`\n\nExport active design as USDZ (best for digital twins + GLB)\n\n**Args:**\n- `outputPath` \u2014 required\n\n**Note:** Preserves component hierarchy, PBR materials, named nodes. Ideal for Blender\u2192GLB or direct iOS/macOS AR viewing.\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_get_design_info`\n\nGet active design metadata (bodies, components)\n\n**Prerequisite:** Active 3D design\n\n\n#### `fusion_get_parameters`\n\nList user parameters from active design\n\n\n#### `fusion_import_step`\n\nImport STEP/STL/IGES/SAT/OBJ/F3D file into Fusion\n\n**Args:**\n- `filePath` \u2014 required Windows path\n\n\n#### `fusion_set_parameter`\n\nModify a user parameter\n\n**Args:**\n- `name` \u2014 required\n- `value` \u2014 required\n\n\n#### `fusion_take_screenshot`\n\nCapture Fusion viewport as PNG with camera orientation control\n\n**Args:**\n- `height` \u2014 optional (default 1080)\n- `orientation` \u2014 optional: home, front, back, top, bottom, left, right\n- `outputPath` \u2014 required\n- `width` \u2014 optional (default 1920)\n\n**Note:** Uses Fusion's render API (Capture Image) \u2014 not a window screenshot. Supports all standard views for product images/icons.\n\n\n#### `fusion_check_recovery`\n\nCheck if a cloud file has crash recovery data\n\n**Args:**\n- `fileName` \u2014 required\n- `folderPath` \u2014 optional\n- `projectName` \u2014 optional\n\n**Note:** Check BEFORE opening files to avoid blocking recovery dialog\n\n\n#### `fusion_create_cloud_folder`\n\nCreate folder (idempotent)\n\n**Args:**\n- `folderName` \u2014 required\n- `parentPath` \u2014 optional\n- `projectName` \u2014 optional\n\n\n#### `fusion_delete_cloud_file`\n\nDelete a cloud file (must not be open)\n\n**Args:**\n- `fileName` \u2014 required\n- `folderPath` \u2014 optional\n- `projectName` \u2014 optional\n\n\n#### `fusion_export_cloud_file`\n\nExport active document to local file (step/stl/f3d/iges/sat/smt)\n\n**Args:**\n- `format` \u2014 optional (default: step)\n- `outputPath` \u2014 required\n\n\n#### `fusion_list_cloud_files`\n\nList files and subfolders in a cloud project/folder\n\n**Args:**\n- `folderPath` \u2014 optional\n- `projectName` \u2014 optional\n\n\n#### `fusion_list_cloud_projects`\n\nList all cloud projects in user's hub\n\n\n#### `fusion_open_cloud_file`\n\nOpen a cloud file by name. STOPS if recovery exists \u2014 you must specify recovery='open' or recovery='discard'\n\n**Args:**\n- `fileName` \u2014 required\n- `folderPath` \u2014 optional\n- `projectName` \u2014 optional\n- `recovery` \u2014 optional: open|discard\n\n\n#### `fusion_save_to_cloud`\n\nSave active document to Fusion cloud\n\n**Args:**\n- `description` \u2014 optional\n- `folderPath` \u2014 optional\n- `name` \u2014 required\n- `projectName` \u2014 optional\n\n\n#### `fusion_search_cloud_files`\n\nSearch 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.\n\n**Args:**\n- `folderPath` \u2014 optional starting subfolder (default: project root). NARROW THE SCOPE HERE \u2014 naming the right subfolder can reduce search time 10-50x.\n- `folderTimeout` \u2014 optional int seconds per-folder cloud-API timeout (default 30)\n- `maxDepth` \u2014 optional int (default 2, no upper cap)\n- `maxFolders` \u2014 optional int (default 10, no upper cap). For exhaustive 100+ folder searches consider walk_cloud_tree.\n- `maxResults` \u2014 optional int (default 20, no upper cap)\n- `projectName` \u2014 optional (defaults to active project)\n- `query` \u2014 required case-insensitive substring of the file name\n- `recursive` \u2014 optional bool (default false). false = root folder only (fast); true = descend into subfolders.\n- `searchTimeout` \u2014 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.\n\n**Returns:** data{files[], totalFound, foldersSearched, foldersSkipped, filesSkipped, searchComplete, truncated, folderLimitReached, searchTimedOut, elapsedSeconds, costAnalysis{foldersPerSecond, estimatedSecondsPer100Folders}, hint}\n\n**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.\n\n\n#### `fusion_walk_cloud_tree`\n\nWalk a cloud folder tree iteratively (BFS) and return a flat list of folders + files with full paths.\n\n**Args:**\n- `extensions` \u2014 optional list of extensions to include, e.g. [\"f3d\",\"fprj\"]\n- `folderPath` \u2014 optional starting folder (defaults to root)\n- `includeFiles` \u2014 optional bool (default true) \u2014 set false for folder-only tree\n- `maxDepth` \u2014 optional int (default 10)\n- `maxFolders` \u2014 optional int (default 500, truncates if exceeded)\n- `nameContains` \u2014 optional case-insensitive substring filter on file names\n- `projectName` \u2014 optional (defaults to active)\n\n**Returns:** folders[], files[] (each with path), stats{foldersVisited, filesFound, truncated}\n\n**Note:** Single add-in call does the entire walk. No sleeps, no batching. For targeted finds use nameContains; for exports use extensions.\n\n\n#### `fusion_board_info`\n\nGet structured board data: components, nets, traces, DRC violations\n\n**Note:** Much richer than a screenshot \u2014 exact coordinates and connectivity\n\n**Prerequisite:** Board open in PCB Editor\n\n\n#### `fusion_electron_run`\n\nExecute EAGLE command (WINDOW FIT, DISPLAY ALL, RATSNEST, DRC, etc.)\n\n**Args:**\n- `command` \u2014 required EAGLE command string\n\n**Returns:** activeWorkspace, fusionOperations[] (diff of internal log), _hint\n\n**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.\n\n\n#### `fusion_execute_text_command`\n\nLow-level Fusion app.executeTextCommand() access\n\n**Args:**\n- `command` \u2014 required Fusion text command\n\n\n#### `fusion_export_eagle_source`\n\nExport active electronics document as plain EAGLE .sch or .brd XML \u2014 extracts from Fusion's .fsch/.fbrd ZIP container\n\n**Args:**\n- `outputPath` \u2014 required \u2014 full path ending in .sch or .brd\n\n**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.\n\n**Prerequisite:** MUST have correct editor active: PCB Editor / Board Layout for .brd, Schematic Editor for .sch\n\n\n#### `fusion_export_lbr`\n\nExport library as .scr script (no 3D refs)\n\n**Args:**\n- `outputPath` \u2014 optional\n\n\n#### `fusion_export_source`\n\nExport active electronics document as .fsch (schematic), .fbrd (board), or .flbr (library) source file\n\n**Args:**\n- `outputPath` \u2014 required \u2014 full path with extension (.fsch, .fbrd, or .flbr)\n\n**Note:** Always export .fbrd FIRST (from board view), then switch to schematic for .fsch. If file not created, wrong workspace is active \u2014 screenshot to check.\n\n**Prerequisite:** MUST have correct editor active: Board Layout for .fbrd, Schematic Editor for .fsch\n\n\n#### `fusion_import_electronics`\n\nImport Fusion-native .fsch/.fbrd/.flbr file as new local electronics project\n\n**Args:**\n- `filePath` \u2014 required Windows path to .fsch, .fbrd, or .flbr file\n\n**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).\n\n\n#### `fusion_list_text_commands`\n\nList available EAGLE text commands in current context\n\n\n#### `fusion_open_board`\n\nOpen .brd in Board Layout editor\n\n**Args:**\n- `filePath` \u2014 required\n\n\n#### `fusion_open_electronics`\n\nCheck if Electronics workspace is active\n\n\n#### `fusion_open_lbr`\n\nOpen .lbr library in Electronics Library editor\n\n**Args:**\n- `filePath` \u2014 required\n\n\n#### `fusion_open_schematic`\n\nOpen .sch in Schematic Editor\n\n**Args:**\n- `filePath` \u2014 required\n\n\n#### `fusion_show_2d_board`\n\nSwitch back to 2D board layout from 3D view\n\n**Note:** EAGLE commands only work in 2D view\n\n\n#### `fusion_show_3d_board`\n\nSwitch to 3D PCB view (board needs outline on layer 20)\n\n**Prerequisite:** Board must be open in PCB Editor\n\n\n#### `fusion_activate_document`\n\nSwitch to an open document tab by name or type\n\n**Args:**\n- `documentType` \u2014 optional: Electronics|PCB|FusionDesign|Drawing\n- `name` \u2014 optional substring match\n\n\n#### `fusion_close`\n\nClose Fusion 360 gracefully\n\n\n#### `fusion_close_all_documents`\n\nClose all open documents\n\n**Args:**\n- `saveChanges` \u2014 optional bool (default: false)\n\n**Note:** Call before force-killing Fusion to prevent recovery files\n\n\n#### `fusion_close_document`\n\nClose a document without save dialog (avoids modal blocking)\n\n**Args:**\n- `name` \u2014 optional (default: active)\n- `save` \u2014 optional bool (default: false)\n\n\n#### `fusion_close_window`\n\nClose a specific Fusion dialog window by sending WM_CLOSE (equivalent to clicking its X button). Works on dialogs that Escape doesn't close \u2014 e.g. 'Recovered Documents'.\n\n**Args:**\n- `hwnd` \u2014 required \u2014 HWND from fusion_window_info or fusion_dismiss_blocking_dialogs remaining[]\n\n**Note:** Does NOT force-kill \u2014 the dialog can still prompt for confirmation. Use when fusion_dismiss_blocking_dialogs reports remaining dialogs that Escape couldn't clear.\n\n\n#### `fusion_dismiss_blocking_dialogs`\n\nFirst-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.\n\n**Args:**\n- `extraPatterns` \u2014 optional array of case-insensitive title substrings to also treat as blocking\n- `maxAttempts` \u2014 optional int (default 5) \u2014 retry loop cap\n\n**Returns:** dismissed[{hwnd,title,pattern}], remaining[], mainThreadResponsive, attempts\n\n**Note:** Idempotent. Safe to call any time. Returns immediately with empty dismissed[] when no blocking dialogs are present.\n\n\n#### `fusion_document_info`\n\nList all open document tabs with cloud metadata for active doc\n\n**Returns:** openDocuments[], activeDoc cloud details (project, folder, version)\n\n\n#### `fusion_get_app_state`\n\nQuery open documents, active workspace, Electronics status\n\n**Returns:** workspace, openDocuments[], electronicsActive\n\n\n#### `fusion_start`\n\nFirst-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.\n\n**Args:**\n- `dismissBlockingDialogs` \u2014 optional bool (default true) \u2014 auto-clear the 'What do you want to design?' picker, error toasts, and other known blocking modals\n- `timeoutSeconds` \u2014 optional int (default 120) \u2014 total wait for add-in + dialog dismissal\n\n**Returns:** resolvedPath, pid (null if already running), addinReady, dialogsDismissed[], elapsedMs, alreadyRunning\n\n**Note:** ALWAYS use this instead of launching Fusion by hand. It's idempotent \u2014 safe to call when Fusion is already running. Process-detection gate means it will NEVER trigger Autodesk's 'Multiple instances are not supported' dialog.\n\n\n#### `fusion_detect_layers`\n\nAuto-detect PCB copper layer count (2 or 4) using ULP + CAM comparison\n\n**Returns:** layerCount, is4Layer, method, copperLayers[], nextSteps[]\n\n**Note:** Run first \u2014 informs gerber CAM job selection and design rule choice\n\n\n#### `fusion_export_board_image`\n\nExport PNG image with layer presets (top_copper, assembly_top, fabrication, etc.)\n\n**Args:**\n- `dpi` \u2014 optional int (default: 300, max: 600)\n- `layers` \u2014 optional int[] \u2014 custom layer numbers (overrides preset)\n- `listPresets` \u2014 optional bool \u2014 returns preset list instead of exporting\n- `monochrome` \u2014 optional bool\n- `outputPath` \u2014 optional (default: C:/tmp/adom-board.png)\n- `preset` \u2014 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\n\n**Returns:** outputPath, dpi, preset, fileSize, nextSteps[]\n\n\n#### `fusion_export_bom`\n\nExport Bill of Materials as CSV (grouped by value+package)\n\n**Args:**\n- `grouped` \u2014 optional bool (default: true) \u2014 false for one row per component\n- `outputPath` \u2014 optional (default: C:/tmp/adom-bom.csv)\n\n**Returns:** outputPath, componentCount, uniquePartCount, nextSteps[]\n\n**Note:** Format: Comment, Designator, Footprint, Quantity, Library\n\n\n#### `fusion_export_cpl`\n\nExport Component Placement List (pick-and-place) as CSV\n\n**Args:**\n- `outputPath` \u2014 optional (default: C:/tmp/adom-cpl.csv)\n- `side` \u2014 optional: all (default) | top | bottom\n\n**Returns:** outputPath, totalPlacements, topCount, bottomCount, nextSteps[]\n\n**Note:** Format: Designator, Mid X, Mid Y, Layer, Rotation (mm). Compatible with JLCPCB/PCBWay.\n\n\n#### `fusion_export_gerbers`\n\nExport Gerber RS-274X + Excellon drill files as ZIP (auto-detects layers, selects CAM job)\n\n**Args:**\n- `boardName` \u2014 optional (default: from active doc name)\n- `layers` \u2014 optional: auto | 2 | 4\n- `outputDir` \u2014 optional (default: C:/tmp/adom-gerbers/)\n\n**Returns:** zipPath, zipSizeKB, layerCount, files[], nextSteps[]\n\n**Note:** ZIP contains GTL, GBL, GTS, GBS, GTP, GBP, GTO, GBO, GKO, XLN. Ready for fab upload.\n\n\n#### `fusion_set_design_rules`\n\nApply Adom/JLCPCB design rules (.edru files) \u2014 auto-detects 2/4 layer\n\n**Args:**\n- `action` \u2014 optional: apply (default) | export | show\n- `layers` \u2014 optional: auto (default) | 2 | 4\n- `outputPath` \u2014 optional, for export action\n\n**Returns:** edruFile, layerCount, description, nextSteps[]\n\n**Note:** Run DRC after applying rules to check for violations\n\n\n#### `fusion_dismiss_recovery`\n\nDismiss recovery dialogs + relocate recovery files to ~/.adom/recovery/\n\n**Note:** Handles both 'Recovered Documents' list and 'Open recovery instead?' prompts\n\n\n#### `fusion_relocate_recovery`\n\nMove crash recovery files to ~/.adom/recovery/fusion/ (call before launch to prevent dialogs)\n\n**Note:** Files preserved, not deleted. User can manually restore if needed.\n\n\n#### `fusion_click_fusion`\n\nClick at coordinates in Fusion window or a specific dialog\n\n**Args:**\n- `hwnd` \u2014 optional \u2014 target a specific dialog HWND instead of main window\n- `relative` \u2014 optional bool (default: true, 0.0-1.0)\n- `x` \u2014 required float\n- `y` \u2014 required float\n\n**Note:** Relative coords (0.0-1.0) are scale-independent \u2014 estimate position as percentage from screenshot. Prefer fusion_send_key for dialogs (enter/escape/tab covers most cases).\n\n\n#### `fusion_screenshot_all`\n\nScreenshot main window + list all dialog windows with HWNDs\n\n**Note:** Use fusion_screenshot_fusion with dialog HWND, or desktop_screenshot_window\n\n\n#### `fusion_screenshot_fusion`\n\nCapture Fusion main window or a specific dialog as PNG\n\n**Args:**\n- `hwnd` \u2014 optional \u2014 dialog HWND to screenshot instead of main window\n\n**Returns:** savedTo file path\n\n**Note:** Image is downscaled to max 1568px. Use relative coords (0.0-1.0) for clicking \u2014 they map to actual window size regardless of image scale.\n\n\n#### `fusion_send_key`\n\nSend keyboard input to Fusion or a specific dialog\n\n**Args:**\n- `hwnd` \u2014 optional \u2014 target a specific dialog HWND\n- `key` \u2014 required string\n\n**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.\n\n\n#### `fusion_window_info`\n\nGet main window HWND, title, rect, and all Qt dialog windows\n\n**Returns:** hwnd, title, rect, dialogs[{hwnd, title, className, rect}]\n\n**Note:** Essential for detecting blocking dialogs after operations\n\n\n### Hydrogen Desktop\n\n_Build, ship, screenshot, and drive the embedded Hydrogen Desktop._ (22 verbs)\n\n> **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) \u2014 when HD isn't running, hd_status returns {ok:false, error:'HD not running'} cleanly instead of crashing.\n\n#### `hd_api`\n\nGeneric 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) \u2014 use the hd-dev internal build for those.\n\n**Args:**\n- `body` \u2014 optional object \u2014 JSON body for POST/PUT/PATCH. Passed through verbatim (no escaping).\n- `method` \u2014 optional string (default 'GET') \u2014 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'\n- `path` \u2014 required string starting with '/' \u2014 e.g. '/setup/run-step', '/wsl/status', '/claude/start-auth'\n- `port` \u2014 optional int \u2014 override discovered port; for testing\n\n**Returns:** {success:bool, ok:bool, status:int, body:any, method:str, path:str, port:int} \u2014 `body` is the parsed JSON HD returned (or a string if non-JSON)\n\n**Example:** `adom-desktop hd_api '{\"method\":\"POST\",\"path\":\"/setup/run-step\",\"body\":{\"id\":\"welcome\"}}'`\n\n\n#### `hd_browser_profiles`\n\nEnumerate browsers + profiles HD detects on the user's machine (GET /browser-profiles). Use the returned profileDir values when calling hd_open_url.\n\n**Returns:** HD's /browser-profiles response \u2014 typically {browsers:[{name, profiles:[{dir, name, gaia?}, ...]}]}\n\n\n#### `hd_build`\n\nFull HD rebuild (async): git pull \u2192 pnpm build \u2192 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+.\n\n**Args:**\n- `lingerSecs` \u2014 optional int (default 30 when show=true, 0 when show=false) \u2014 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).\n- `show` \u2014 optional bool (default false) \u2014 true opens a visible console window the user can watch the build scroll in; false runs hidden\n\n**Returns:** {ok, pid, logPath, flavor:'full', show, lingerSecs, _hint}\n\n**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.\n\n\n#### `hd_build_frontend`\n\nFrontend-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 \u2014 much faster than a full build.\n\n**Args:**\n- `lingerSecs` \u2014 optional int (default 30 when show=true) \u2014 see hd_build for details. v1.8.23+.\n- `show` \u2014 optional bool (default false)\n\n**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} \u2014 another hd_build_*/hd_ship is already running. NO QUEUE: just refused immediately. The lock auto-releases when the holder's PowerShell PID dies.\n\n**Note:** Skip when the Rust src-tauri/ side changed \u2014 those need hd_build or hd_build_rust to rebuild the binary. v1.8.57+ adds a global build lock \u2014 two concurrent hd_build_*/hd_ship calls cannot race.\n\n\n#### `hd_build_log`\n\nReturn 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.\n\n**Returns:** {ok, log:'<full text>', lines, logPath, _hint}\n\n\n#### `hd_build_rust`\n\nRust-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.\n\n**Args:**\n- `lingerSecs` \u2014 optional int (default 30 when show=true) \u2014 see hd_build for details. v1.8.23+.\n- `show` \u2014 optional bool (default false)\n\n**Returns:** {ok, pid, logPath, flavor:'rust', show, lingerSecs, builtSha, exeMtimeBefore, exeSizeBefore, _hint}\n\n**Note:** Builds the debug binary at target/debug/hydrogen-desktop.exe. The frontend's build/ output is NOT regenerated \u2014 re-use whatever was last built by hd_build / hd_build_frontend.\n\n\n#### `hd_build_status`\n\nSynchronous 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 \u2014 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 \u2014 most often a still-running HD instance), builtSha (the SHA hd_build_rust / hd_build was dispatched at \u2014 compare to /buildinfo.build_sha after launch), elapsedSecs (from dispatch to now). State machine: no log \u2192 idle; log present without sentinel + PID alive \u2192 building; log ends with BUILD_OK \u2192 succeeded; log ends with BUILD_FAILED \u2192 failed; log present without sentinel + PID dead \u2192 treated as failed (orphaned). v1.8.16+.\n\n**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 \u2014 e.g. 'compile_error', 'exe_locked', 'orphaned_no_sentinel'), compiledCrates:[string] (names parsed from 'Compiling X v...' lines \u2014 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 \u2014 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 \u2014 taskkill these), builtSha:string|null (the git SHA hd_build was dispatched at \u2014 compare to /buildinfo.build_sha after launch), elapsedSecs:int, flavor:string ('rust'|'frontend'|'full'), _hint:string}\n\n**Note:** **relinkOnly:true on success is the #1 trap to look for** \u2014 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.\n\n\n#### `hd_build_tail`\n\nStreaming 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+.\n\n**Args:**\n- `offset` \u2014 optional int (default 0) \u2014 byte offset into the log file from the previous call's newOffset\n\n**Returns:** {ok, lines:[], newOffset, totalBytes, done:bool, succeeded:bool, failureReason, _hint}\n\n**Note:** done=true means BUILD_OK or BUILD_FAILED has appeared in the log \u2014 stop polling. done=false means more output expected; sleep 2-3s and call again with newOffset.\n\n\n#### `hd_container_exec`\n\n[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\":\"...\"}}'.\n\n**Args:**\n- `command` \u2014 required string\n\n**Returns:** Public build: refusal payload. hd-dev internal build: HD's /container-exec response.\n\n\n#### `hd_eval`\n\n[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\":\"...\"}}'.\n\n**Args:**\n- `js` \u2014 required string \u2014 JS expression or statement\n\n**Returns:** Public build: refusal payload. hd-dev internal build: HD's /eval response.\n\n\n#### `hd_iframe_eval`\n\n[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\":\"...\"}}'.\n\n**Args:**\n- `contextIndex` \u2014 optional int\n- `js` \u2014 required string\n\n**Returns:** Public build: refusal payload. hd-dev internal build: HD's /iframe-eval response.\n\n\n#### `hd_launch`\n\nStart 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 \u2014 a hd_build/hd_build_frontend/hd_build_rust is still running; (2) build_failed \u2014 last build ended with BUILD_FAILED; (3) already_running \u2014 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+.\n\n**Args:**\n- `killExisting` \u2014 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.\n\n**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 \u2014 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 \u2208 {'build_in_progress' (a hd_build/hd_build_rust/hd_build_frontend is still running \u2014 wait for hd_build_status.building:false), 'build_failed' (last build ended with BUILD_FAILED \u2014 fix and retry hd_build), 'already_running' (HD is up \u2014 pass killExisting:true to force-kill+relaunch, or use hd_restart), 'binary_missing' (target/debug/hydrogen-desktop.exe not on disk \u2014 run hd_build first)}.\n\n**Note:** **The launched + wasAlreadyRunning + killedFirst combination is the proof you need** \u2014 `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.\n\n\n#### `hd_log`\n\nRead the tail of HD's log file at %APPDATA%\\hydrogen-desktop\\hydrogen-desktop.log. Doesn't require HD to be running \u2014 reads the file directly from disk. Use this when HD seems stuck or has crashed to see what it said last.\n\n**Args:**\n- `tail` \u2014 optional int (default 30) \u2014 number of trailing lines to return\n\n**Returns:** {ok, lines:[...], path, totalLines, returnedLines}\n\n\n#### `hd_open_url`\n\nOpen 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.\n\n**Args:**\n- `browser` \u2014 optional (default 'chrome') \u2014 'chrome', 'edge', 'firefox', 'brave'\n- `profileDir` \u2014 optional (default 'Default') \u2014 profile directory name from hd_browser_profiles\n- `url` \u2014 required string\n\n**Returns:** HD's /open-in-profile response (typically {ok, pid, profile})\n\n\n#### `hd_reload_vscode`\n\n[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).\n\n**Returns:** Public build: refusal payload. hd-dev internal build: HD's /reload-vscode response.\n\n\n#### `hd_restart`\n\nStop + 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+.\n\n**Returns:** {ok, killedPid?, pid, exePath, _hint}\n\n**Note:** Use after `hd_build_status` shows succeeded=true to pick up the new binary. Don't call when build is in progress \u2014 the launch will fail.\n\n\n#### `hd_screenshot`\n\nCapture 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.\n\n**Returns:** {success, savedTo, sizeKB, hwnd, data:{image (base64), format:'png', encoding, sizeBytes}}\n\n**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' \u2014 verify HD is up via hd_status.\n\n\n#### `hd_send_files`\n\nSend 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 \u2014 avoids the plain send_files path (\u2192 Windows Downloads \u2192 /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.\n\n**Args:**\n- `filePaths` \u2014 required array of absolute local file paths to send (same as send_files)\n- `subdir` \u2014 optional string (default 'downloads') \u2014 subfolder under HD's project dir to write into; sits alongside the project's screenshots/ folder\n\n**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 \u2192 success:false + a clear error.\n\n**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).\n\n\n#### `hd_ship`\n\nv1.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 \u2014 `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` \u2014 refuses immediately if another hd_build_*/hd_ship is in flight (no queue); (c) single-flight lifecycle mutex \u2014 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.\n\n**Args:**\n- `clean` \u2014 optional bool, default false. true forces `cargo clean -p hydrogen-desktop` before build to defeat the stale-relink trap \u2014 use when build said 'Finished' but the running SHA doesn't match expectSha on a prior hd_ship attempt.\n- `expectSha` \u2014 REQUIRED string \u2014 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.\n\n**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 \u2192 'sha_validate' + _youProbablyMeant tells you what real SHAs look like. Lock held \u2192 'lock' + holderPid/holderVerb/elapsedSec tells you who's holding it.\n\n**Note:** Long-running: cold cargo build can take 2-3 min. CLI WS timeout is 12 min \u2014 call as a single sync invocation and wait. v1.8.57+: arg validation, SHA validation, and lock acquisition all happen BEFORE any destructive action \u2014 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`).\n\n\n#### `hd_shot`\n\nv1.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\u2192pull\u2192convert\u2192crop\u2192resize\u2192Read manually. Regions are deterministic (no AI-supplied pixel coords) \u2014 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).\n\n**Args:**\n- `format` \u2014 optional string, default 'png' (only PNG supported in v1).\n- `region` \u2014 REQUIRED string. One of: 'full' (whole HD window), 'titlebar' (top ~40px strip \u2014 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:[...]}.\n- `resizeMax` \u2014 optional int, default 1400. Longest side of the safe-resized copy. Use lower (\u2248800) if you want to fit more shots in one Claude turn; higher (1568) for max fidelity.\n\n**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}}\n\n**Note:** Always pull_file the SAFE path and Read THAT \u2014 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 \u2014 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 \u2014 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+.\n\n\n#### `hd_status`\n\nv1.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 \u2014 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\u2192respawn gap), controlApiReachable (control port actually listening), and embedded (AD's OWN mode \u2014 answers 'is AD embedded under HD?'). `running` = processPresent||controlApiReachable.\n\n**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}\n\n**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 \u2014 retry in a few seconds, don't treat it as down. Use this as the entry point for 'what's HD's state?' \u2014 it tells you which downstream verb to run next (hd_ship, hd_launch, hd_shot, etc.).\n\n\n#### `hd_stop`\n\nKill hydrogen-desktop.exe. `wasRunning` distinguishes 'killed it' from 'nothing to do'. v1.8.16+.\n\n**Returns:** {ok, wasRunning:bool, killedPid?, _hint}\n" }\ No newline at end of file