app
Adom Bridge
Public Made by Adomby adom
Adom Bridge unleashes your AI onto your full PC: full power, full safety. The AI breaks out of the container it runs in and onto your real machine, managing and running your entire laptop as you: launch apps, move files, drive any window, control KiCad, Fusion 360 or your real signed-in browser. Works with any AI, cloud or local (Claude Desktop, Claude Code, Codex), no Hydrogen required. Bridge and its bridges are an operating system for AI, with managed Node and Python runtimes, and a human-onl
← Commit history
Publish 2.1.185
40 files changed
+5764−5716
SKILL.md+1510−1510dist/linux/adom-bridgepackage.json+1−1page.json+1−1skills/adom-bridge-anywhere-windows/SKILL.md+73−69skills/adom-bridge-anywhere/SKILL.md+120−116skills/adom-bridge-auth/SKILL.md+5−1skills/adom-bridge-bridges-guide/SKILL.md+7−14skills/adom-bridge-caller-identity/SKILL.md+5−1skills/adom-bridge-cli-guide/SKILL.md+210−207skills/adom-bridge-demo-kit/SKILL.md+4−1skills/adom-bridge-direct-api/SKILL.md+4−1skills/adom-bridge-discovery/SKILL.md+93−87skills/adom-bridge-embedded/SKILL.md+5−1skills/adom-bridge-fonts/SKILL.md+72−68skills/adom-bridge-installer/SKILL.md+8−3skills/adom-bridge-linux/SKILL.md+147−143skills/adom-bridge-local-ai/SKILL.md+4−1skills/adom-bridge-mesh/SKILL.md+5−1skills/adom-bridge-multi-machine/SKILL.md+6−13skills/adom-bridge-notify/SKILL.md+5−1skills/adom-bridge-ownership/SKILL.md+8−5skills/adom-bridge-permissions/SKILL.md+5−9skills/adom-bridge-presence/SKILL.md+143−139skills/adom-bridge-recording/SKILL.md+6−2skills/adom-bridge-runtimes/SKILL.md+5−7skills/adom-bridge-sdk-audit/SKILL.md+158−160skills/adom-bridge-sdk-callback/SKILL.md+323−328skills/adom-bridge-sdk-manifest/SKILL.md+3−6skills/adom-bridge-sdk-publish/SKILL.md+3−6skills/adom-bridge-sdk-runtime/SKILL.md+2−6skills/adom-bridge-sdk/SKILL.md+5−7skills/adom-bridge-thesis/SKILL.md+84−88skills/adom-bridge-verbs/SKILL.md+4−1skills/adom-bridge-web-control-test/SKILL.md+682−678skills/adom-bridge-web-integration/SKILL.md+4−1skills/adom-bridge-window-capture/SKILL.md+256−252skills/adom-bridge-window-identity/SKILL.md+111−108skills/adom-bridge-windows-host/SKILL.md+167−163skills/adom-bridge/SKILL.md+1510−1510SKILL.md+1510−1510@@ -1,1510 +1,1510 @@-name: adom-bridge -description: Use when the user wants to send files to their desktop, control KiCad or Fusion 360, send desktop notifications, or troubleshoot the desktop connection. Provides CLI tools for bridging the Docker container to the user's local machine. - -# Adom Bridge - -Bridge between Claude Code (running in an Adom Docker container) and the user's desktop applications via WebSocket. - -> **The CLI is `adom-bridge`** (renamed from `adom-bridge-cli` in v2.0.33 — the `-cli` was noise on Linux). Use `adom-bridge <verb>`. The old `adom-bridge-cli` name still works (the pkg links both), so existing scripts don't break — but prefer `adom-bridge` going forward. On Windows the CLI file stays `adom-bridge-cli.exe` (the GUI app owns `adom-bridge.exe` there). - -**Install surface:** the canonical install page is `apps/adom-bridge` on the Adom wiki — it hosts the Linux CLI (`docker_binary`), the Windows installer (`.exe`), and the four sibling skills (pup, kicad-bridge, fusion-bridge, + this one). The gallia `adom-bridge-discovery` skill surfaces this page on any related user query. `adom-bridge setup_desktop` also returns a dynamic installer URL pulled from the latest GitHub release. - -**First-time setup?** If the user hasn't installed the desktop app yet, see the Setup section below to walk them through downloading, installing, and connecting the app. - -Quick check if desktop is connected: -```bash -adom-bridge ping -``` - -**Companion skills** (installed alongside this one from the wiki): -- `adom-bridge-kicad` — KiCad bridge: launch editors, open designs, install libraries, run DRC, window capture + keyboard/click automation (`plugins/kicad/SKILL.md`) -- `adom-bridge-fusion` — Fusion 360 bridge: launch, open designs, STEP/GLB/.lbr import-export, BOM + API queries, Fusion screenshots (`plugins/fusion360/SKILL.md`) -- `pup` — browser automation (Puppeteer-style): open URLs, screenshot, eval JS, multi-session Chrome (`gallia/skills/pup/SKILL.md`) - -## How It Works - -``` -Claude Code -> adom-bridge <command> -> Relay Server (HTTP :8766) -> WebSocket :8765 -> Adom Bridge App -> KiCad / Fusion 360 / Browser / Shell -``` - -The `adom-bridge` binary is a single Rust CLI that does everything: -- `adom-bridge serve` -- Start the relay server (WebSocket :8765 + HTTP API :8766) -- `adom-bridge <command> '<json>'` -- Send commands to the desktop app via the running relay - -The relay server runs in the Docker container. The Adom Bridge app runs on the user's PC and connects via WebSocket. - -## Starting the Relay Server - -The relay must be running before the desktop app can connect. **In a container, start it SUPERVISED** (v1.9.168+): - -```bash -adom-bridge serve --supervise -``` - -**Why not `adom-bridge serve &`?** A backgrounded (`&`) relay is still a child of your shell/session. When your session is torn down (you finish, or the container reaps it), the relay dies with it — and then every connected desktop hits HTTP 500 on `/proxy/8765/` and loops "Reconnecting in 30s" **for hours** until a human restarts it (wiki issue #24, hit live 2026-07-23). `--supervise` **detaches the relay into its own session (reparented to init)** so it survives your session, and starts a watchdog that: -- restarts the relay with **exponential backoff** (2s → … → 5 min cap; a clean run resets it — a broken relay is never bludgeoned), and -- **toasts the connected desktops on every restart** with the reason from the relay log, so the owner finds out something went wrong. - -```bash -adom-bridge serve --supervise # detached + self-healing (use this in containers) -adom-bridge serve --supervise --owner AdomLapper # send restart toasts to one desktop -adom-bridge serve --stop # stop the watchdog + relay -``` - -`serve` (plain, foreground) still works and prints a loud hint telling you it will die with the session — fine for a quick local relay you'll sit and watch. **Caveat:** a full CONTAINER REBOOT still needs a boot hook (cron `@reboot` / the container's init); the watchdog covers session teardown, not host reboot. - -This starts: -- WebSocket server on `127.0.0.1:8765` (desktop app connects here; `--bind 0.0.0.0` / `ADOM_RELAY_BIND` to listen on the network, 2.1.170 wiki #225) -- HTTP API on `127.0.0.1:8766` (CLI commands go here) - -Check if it's running: -```bash -curl -sf http://127.0.0.1:8766/health -``` - -## First-Time Setup (Install & Connect) - -**You are running on a Docker container. You have NO access to the user's desktop.** Guide them step-by-step, ask questions, wait for answers, and verify each step. - -> **As soon as you're connected, run `adom-bridge sync_skills` (v1.9.37+).** Installing the -> adom-bridge core pkg gives you only CORE skills — it does NOT give you a DYNAMIC/cloud -> bridge's skill (kicad, fusion, blender, any third-party). `sync_skills` runs `bridge_list` -> and installs each bridge's own skill pkg into `~/.claude/skills/`, so you can actually -> discover + drive whatever bridges THIS desktop has. (The CLI also runs this automatically -> once/day in the background, but doing it on connect makes the bridge skills available to -> the current session immediately.) Re-run it any time the desktop gains a bridge; -> `adom-wiki pkg update` keeps them fresh. - -### Step 1: Check if already connected - -```bash -adom-bridge ping -``` - -If this returns `{ "status": "connected" }`, the desktop is already set up -- skip to "Verify the connection" below. If it errors, continue. - -### Step 2: Ensure the relay is running - -```bash -curl -sf http://127.0.0.1:8766/health -``` - -If not running (in a container, ALWAYS use `--supervise` so it survives your session): `adom-bridge serve --supervise` - -### Step 3: Ask what OS they use - -Ask the user: **"What operating system is your desktop/laptop? (Windows, Mac, or Linux)"** - -- **Windows 10/11** -- proceed -- **Linux** -- a private preview (AppImage, insiders tier, 2.1.175+). Installing it, keeping one copy running, updates, and what works there: the `adom-bridge-linux` skill. Inside a Grok Bot agent computer the machine is paused while its agent is idle; that skill says how to wake it. -- **macOS** -- "Support is coming soon." - -### Step 4: Install & connect - -Run `adom-bridge setup_desktop` — it auto-detects this container's relay URL and returns a ready-to-run **`register_command`**, a fallback `server_config` JSON, and the `installer_url`. Prefer the zero-paste path: - - -**Option 0: Zero-paste via your Adom account (PREFERRED)** - -Run the **`register_command`** that `setup_desktop` returned (it's `adom-bridge register_container` with the name + URL filled in). That injects this connection into the **user's Adom cloud profile**. Then the user just: -1. Installs Adom Bridge (from `installer_url`) and opens it, -2. **Signs in with their Adom account** (Connections panel → "Sign in to Adom"), - -and the connection appears in their list and auto-connects — **no JSON paste at all**. This is the modern path and the one to try first. (`register_container` needs an Adom cloud container — it uses the mounted account token — and returns `ok:false` on a non-Adom box; if so, use Option A/B below.) - -**Already-running desktop? `server_sync` (v1.9.215+).** A signed-in ab reads the cloud profile at boot, at sign-in, and hourly — and `adom-bridge server_sync` (optionally `--target <name>`) makes it re-read RIGHT NOW. The merge is cloud-wins, whole entry including `authToken`. So the flow for adding a server or **rotating a relay token** on live desktops is: update the cloud key (`register_container` writes them) → `server_sync` per desktop → verify with `server_list`. Do NOT use `server_remove` + `server_add` for rotation — removing the entry you are talking through severs your own transport (wiki #387). - - -**Option A: Automatic setup via Claude Code (paste fallback)** - -If you have **Claude Code** or **Claude Desktop** on your laptop, paste this prompt: - -> Install Adom Bridge and connect it to my cloud container. (Most users instead get Adom Bridge bundled with Hydrogen, which installs and connects it for them; use this prompt when you are driving the desktop from a cloud container.) Download the installer from `<installer_url>`, run it silently, then write this JSON to `%USERPROFILE%\.adom\config.json`: -> ```json -> {"servers":[<server_config JSON>]} -> ``` -> Then launch "Adom Bridge" from the Start Menu. - -*(Replace `<installer_url>` and `<server_config JSON>` with the actual values from `setup_desktop`.)* - -**Option B: Manual setup** - -1. Download the installer: **[<installer filename>](<installer_url>)** -2. Run it and follow the prompts — the app installs to the Start Menu as "Adom Bridge" -3. Open **Adom Bridge** from the Start Menu -4. Paste this JSON into the text field that says **"Paste server JSON to add ..."** and press **Enter**: - ```json - <server_config JSON> - ``` -5. The server will appear and auto-connect - - -Wait for the user to confirm they see a green dot next to the server name before proceeding. - -**Important:** Each container has its own relay server. New containers need a new entry -- old entries from previous containers won't work. - -### Step 6: Verify the connection - -```bash -adom-bridge ping -# Expected: { "echo": "pong", "roundTripMs": ..., "status": "connected" } - -adom-bridge status -# Expected: one client with the user's hostname and capabilities - -adom-bridge notify_user '{"title":"Hello from Docker!","body":"Your desktop is connected."}' -``` - -Tell the user what you see. If `ping` succeeds: - -> "Your desktop is connected! I can now send files to your machine, open browser windows for visual debugging, control KiCad and Fusion 360, take screenshots of your desktop, and send you notifications." - -### Step 7: Optional -- Node.js for browser features - -Ask: **"Would you like to use the visual browser debugging feature? This opens Chrome windows on your desktop that I can control."** - -- **Yes + Node.js installed** -- "Great, the browser bridge auto-installs deps on first use." -- **Yes + no Node.js** -- "Download Node.js from https://nodejs.org (LTS), install it, then restart Adom Bridge." -- **No** -- skip, they can enable it later - -### Common connection issues - -| Symptom | Fix | -|---------|-----| -| `ping` returns `"No desktop client connected"` | User hasn't added this container in the desktop app yet | -| Desktop app shows "disconnected" | Check the URL uses `wss://` (not `ws://` or `https://`), port is `8765` | -| Relay server not running | `adom-bridge serve --supervise` (survives your session; see Starting the Relay Server) | -| Multiple stale connections | `adom-bridge kick_all` -- app auto-reconnects within seconds | -| Desktop app not installed | Download from [github.com/adom-inc/adom-bridge/releases](https://github.com/adom-inc/adom-bridge/releases) | - -## CLI Tool - -```bash -adom-bridge <command> '<json-args>' -``` - -**Examples:** -```bash -adom-bridge ping -adom-bridge status -adom-bridge desktop_list_windows -adom-bridge desktop_screenshot_screen '{"reason":"see what is on the user's screen"}' -adom-bridge commands '{"bridge":"kicad"}' # a bridge's own verb list (any installed bridge) -adom-bridge notify_user '{"title":"Hello","body":"From Docker"}' -adom-bridge shell_execute '{"command":"echo hello"}' -adom-bridge pull_file '{"filePaths":["C:\\Users\\john\\Downloads\\image.png"],"saveTo":"/tmp"}' -``` - -**Output:** JSON to stdout. Screenshots save to `/tmp/adom-bridge-screenshots/` and return the file path. - -**Config location:** The desktop app stores server config at `~/.adom/config.json` (`%USERPROFILE%\.adom\config.json`). This is separate from the binary — config survives updates and reinstalls. - -## Available Commands - -**Get the full structured command list with descriptions, args, and prerequisites:** -```bash -adom-bridge commands # aliases: list_commands, help -adom-bridge --target <name> commands # + THAT box's installed bridges (`bridges`, live: name, version, verb names) -adom-bridge commands '{"bridge":"kicad"}' # ONE bridge described by ITSELF (<prefix>describe, else its manifest verb list) -``` -Returns categorized JSON with every ab-core command, its required/optional args, return values, prerequisites, and workflow notes, plus `bridges` read live from the box. Run this FIRST when you're unsure what a verb is called. Bridge verbs are never catalogued inside ab (2.1.144, wiki #211: the hand-written copy drifted); the bridge describes them, and its skill (`sync_skills`) is the how-to. - -**Two kinds of verbs (v1.9.103 — don't confuse them):** -- **BUILT-IN ab-core verbs** are always available on every Adom Bridge, no install, NOT bridges. This is where screenshots, windows, input, files, registry, shell, tunnel, update, and notify live. A whole-screen shot is `desktop_screenshot_screen`; one window (with chrome) is `desktop_screenshot_window {"hwnd":N}` or `{"titleContains":"..."}` (get hwnds from `desktop_list_windows`). -- **BRIDGE verbs** (`kicad_`/`fusion_`/`pup_`/`blender_`/`nbrowser_`…) need that bridge installed — see `bridges` in `commands`, or run `bridge_list`. - -If a verb returns `errorCode:"unknown_verb"`, **read the error's `_hint`** — it carries a fuzzy "did you mean" (e.g. `desktop_screenshot` → `desktop_screenshot_screen`) and tells you whether the right verb is built-in or a bridge. Don't conclude a capability is "a missing bridge" from a guessed verb name; call `commands` and check. - -### Connection Management - -- `ping` -- 5s round-trip test. Use BEFORE browser/shell commands to verify the desktop connection is alive. -- `status` -- Check who's connected, their capabilities, desktop paths, and **app installation status**. The `desktop.apps` object shows: - - `kicad.installed` / `kicad.version` / `kicad.bridgeRunning` - - `fusion360.installed` / `fusion360.running` / `fusion360.bridgeRunning` / `fusion360.addinInstalled` / `fusion360.addinConnected` - - `browser.bridgeRunning` -- `kick_all` -- Force-disconnect all WebSocket clients. Active Adom Bridge apps auto-reconnect within seconds. - -#### Programmatic server registration (v1.8.22+) - -Three verbs let external apps (Hydrogen's WSL2 workspace, in particular) register their relay server with adom-bridge without the user pasting JSON into the GUI by hand. Mirrors the GUI's Quick Add bar + connect/disconnect buttons. - -- `server_add` -- Upsert a relay server by `name`. If a server with that name already exists, the URL (and optionally authToken) is updated rather than creating a duplicate. - ```bash - adom-bridge server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}' - # → {ok:true, name, url, id, connected:bool, created:bool, _hint} - ``` - - `name` (required) — dedup key. Repeat calls with the same name are idempotent. - - `url` (required) — relay WebSocket URL. - - `authToken` (optional) — defaults to a dev token (leave unset unless your relay requires one). - - `autoConnect` (optional, default `true`) — connect right after upsert. Set `false` to add the entry without dialling out. - - Behavior: same URL + already connected → no churn, returns ok. URL changed + autoConnect=true → disconnect old loop + spawn new one. URL changed + autoConnect=false → disconnect old, leave entry registered but not connected. -- `server_remove` -- Disconnect (if connected) and delete an entry by name. Returns `{ok, removed:bool, wasConnected:bool}`. Idempotent (removing a non-existent entry returns ok with removed:false). - ```bash - adom-bridge server_remove '{"name":"hydrogen-workspace"}' - ``` -- `server_list` -- Persisted server list with live connection status: `{servers:[{name, url, id, autoConnect, enabled, connected:bool, clientCount:0|1, status}]}`. `status` is the fine-grained `connected | reconnecting | disconnected` state machine value. - -Persists to the same `~/.adom/config.json` the GUI uses, so entries survive a GUI restart. The `ws_client` supervisor (runs every 30s inside the GUI) auto-reconnects entries with `autoConnect:true` on next launch. - -Hydrogen-style usage: -```bash -# At Hydrogen container startup -adom-bridge server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}' - -# At Hydrogen container shutdown -adom-bridge server_remove '{"name":"hydrogen-workspace"}' -``` - -#### Addressing multiple Adom Bridges on one relay (v1.8.133+) - -One relay can have **several** Adom Bridges connected at once — e.g. a laptop and an Azure VM, eventually many. Each ab has a **name** (its `--client-name` / Settings value, defaulting to the OS hostname). The cloud CLI addresses a specific one, or fans out to all: - -- `adom-bridge targets` — list connected ABs: `{count, targets:[{name, hostname, version, os, connectedAt}]}`. Discover names here first. -- `adom-bridge --target <name> <verb>` — route the verb to that ab only. `--target` is a **global** flag (goes before the verb; case-insensitive; matches `clientName`, else `hostname`). `ADOM_TARGET=<name>` sets a default (the flag wins). -- `adom-bridge --target all <verb>` — fan out to **every** connected ab; returns `{broadcast:true, targets:[...], results:{<name>:<verb-result>}, summary:{total,ok,failed}}`. A per-ab timeout/failure is isolated to that ab's entry. - -```bash -adom-bridge targets -adom-bridge --target azurevm desktop_screenshot_screen -adom-bridge --target all hd_status # every box at once, results keyed by name -``` - -**No-target behavior:** with exactly ONE ab connected, no `--target` is needed (back-compat). With MULTIPLE connected and no `--target`, the call FAILS with `{error:"ambiguous_target", connected:[...], _hint}` (exit 1) — it won't guess which box. A bad name → `{error:"target_not_found", requested, connected:[...]}` (exit 1). Set each ab's name with the `--client-name <name>` launch flag (Hydrogen passes this when spawning the embedded ab), the Settings field, or `config.json` `client_name`. Full design + the relay-routing internals: [`skills/MULTI_AB_ADDRESSING.md`](MULTI_AB_ADDRESSING.md). - -#### Direct HTTP API for sibling apps (v1.8.25+) - -If you're authoring a sibling Tauri app on the same machine (Hydrogen, or any future "Adom-family" app), you can skip the CLI binary entirely and POST commands straight into the running adom-bridge GUI on `127.0.0.1:47200` (was `127.0.0.1:8770` through v1.8.32 — moved to be a better neighbor to Hydrogen on 47080+ and avoid the 8000-range collision risk). The endpoint runs inside the GUI process (loopback-only bind), uses the same dispatcher the WS path uses, and returns the same JSON shape as `adom-bridge <verb>` — including every `_hint` field. - -| Method | Path | Body | Returns | -|---|---|---|---| -| `GET` | `/health` | — | `{"ok":true,"service":"adom-bridge"}` cheap probe | -| `GET` | `/status` | — | service banner + version + schema + `directApi.cliRequired` list | -| `POST` | `/command` | `{"app":"<ns>","command":"<verb>","args":{...}}` | The verb's normal payload (200 OK), or `{error, errorCode, _hint}` (4xx/5xx) | - -**v1.8.33+ port discovery (sibling apps READ THIS):** Don't hardcode 47200. The GUI may have bound to 47200-47209 instead if the default port was taken (zombie socket, dev instance, third-party collision). Discovery protocol: - -1. Read `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`) — single-line `host:port`, written by the GUI at bind time, removed at graceful shutdown -2. If file missing or its port doesn't `/health`, scan `127.0.0.1:47200..=47209` for any port answering with `{"ok":true,"service":"adom-bridge"}` -3. Validate `service == "adom-bridge"` to disambiguate from other apps that might bind a port in our range - -The CLI does this automatically (see `cli/src/direct_probe.rs`). For sibling-app Rust code, see [`skills/DIRECT_API.md`](DIRECT_API.md) for a copy-paste-ready helper. - -**Per-verb timeouts:** a bridge verb's budget is the bridge's own `bridge.json` `timeouts` block (exact bare verb, then prefix rule, then its default), applied by ab and, since 2.1.146, by the CLI's wait as well (ab keeps no table of any bridge's verbs, wiki #212). `timeouts` shows every installed bridge's declared block. Caller can override via `args.timeout` (seconds, clamped to 1800). - -Example — programmatic server registration without the CLI: - -```bash -curl -X POST http://127.0.0.1:47200/command \ - -H 'Content-Type: application/json' \ - -d '{"app":"desktop","command":"server_add","args":{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}}' -# → identical JSON to `adom-bridge server_add '{...}'`, including the _hint -``` - -**What's safe to send directly:** essentially everything sync (`server_*`, `bridge_list`, `hd_status`, `hd_build_status`, `desktop_list_windows`, `desktop_screenshot_*`, `kicad_*`, `fusion_*`, `pup_screenshot`, `notify_user`, ...) plus async-dispatching verbs that return a job id in <500 ms (`bridge_install`, `hd_build`, `desktop_install_kicad`, ...). - -**What requires the CLI fallback:** verbs returning a structured `errorCode:"cli_required"` from this endpoint — currently `pull_file`, `send_files`, `shell_execute`. These use binary streaming or multi-minute approval polling that doesn't fit a single synchronous HTTP request. `GET /status` returns the full list at runtime so callers can branch defensively. - -Full integration guide (loopback trust model, recipes for Hydrogen startup/shutdown, retry/backoff patterns, port-discovery code in Rust + TypeScript) lives in [`skills/DIRECT_API.md`](DIRECT_API.md) in this repo, and as a wiki asset attached to `apps/adom-bridge`. - -#### Embedded mode — ab bundled inside Hydrogen (v1.8.42+) - -When Hydrogen bundles ab, ab enters "embedded mode" at boot. Hydrogen owns the system-tray icon (ab's is suppressed), Hydrogen owns the Adom Cloud login (Hydrogen's session token is handed to ab via Phase 4's existing channels), Hydrogen owns auto-updates (ab's wiki-poll is disabled — Hydrogen bundles a fresh ab on every Hydrogen release), and Hydrogen spawns ab hidden (its tray "Open Adom Bridge" surfaces the window on demand). Standalone ab users see zero change — none of the signals fire. - -**Detection** — three-signal cascade (any one triggers embedded mode): - -1. `--embedded` CLI flag (what Hydrogen passes on every spawn) -2. `ADOM_EMBEDDED=1` env var (backup channel) -3. `%LOCALAPPDATA%\Adom Bridge\embedded.json` marker file (survives ab restarts; written by Hydrogen's installer; deleted by Hydrogen's uninstaller) - -**CLI flags Hydrogen passes when spawning ab**: - -```bash -adom-bridge.exe --embedded --start-hidden \ - --relay-url ws://127.0.0.1:8765 --relay-name hydrogen-desktop \ - --session-token <hd's-stored-token> -``` - -- `--embedded` → enter embedded mode (also writes the marker) -- `--start-hidden` → boot with main window invisible. **v1.8.119+: honored in embedded mode** (Hydrogen owns the surface; ab stays in the tray and opens via `desktop_window_show`). v1.8.102–118 had a bug where embedded mode force-showed the window even with `--start-hidden`; that's fixed. The machine owner can still flip ab's "Always show the window at launch" Settings checkbox (config `always_show_window_on_launch`, default OFF) to override and always show — a personal-dev-box preference, not the product default. -- `--shell-approval <prompt|1h|24h|permanent>` → Hydrogen's passthrough of its user's shell-approval choice (v1.8.116+, session-only) -- `--relay-url <url>` → upsert this relay via existing `server_add` dedup; default name `hydrogen-desktop` -- `--relay-name <name>` → override the default name -- `--session-token <tok>` → already covered by Phase 4 handoff (env / CLI arg / file) - -**Docker introspection** — `adom-bridge desktop_embedded_status` returns `{embedded, owner, source, enteredVia, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` so cloud-side callers can branch on whether ab is standalone or Hydrogen-managed. **v1.8.73+:** `enteredVia` reports `"launch-flag"` (Hydrogen spawned ab as a child via `--embedded`) or `"runtime-adopt"` (ab was running standalone; Hydrogen adopted it at runtime) — see "Runtime adopt + release" below. - -**New verbs Hydrogen uses to drive ab** (all also usable standalone): - -| Verb | Purpose | -|---|---| -| `desktop_window_show` | Bring ab's main window to foreground | -| `desktop_window_hide` | Hide ab's main window (app keeps running) | -| `desktop_connect_all` | Spawn ws_loop for every enabled server | -| `desktop_disconnect_all` | Drop every live WS connection (entries stay in config) | -| `desktop_shutdown` | Explicit ab termination (stop bridges, then exit) | -| `desktop_embedded_status` | Introspect current embedded state (incl. `enteredVia`) | -| `desktop_embedded_enter` | **v1.8.73+** runtime adopt: standalone → embedded as `runtime-adopt` | -| `desktop_embedded_release` | **v1.8.73+** revert to standalone. **v1.8.77+: always reverts, never exits ab** (regardless of `entered_via`) — use `desktop_shutdown` for explicit exit | -| `desktop_logout` | Clear ab's `~/.adom/session.json` (Hydrogen's sign-out propagation) | - -#### Runtime adopt + release (v1.8.73+) — the LaunchFlag-vs-RuntimeAdopt distinction - -Hydrogen has two ways to put ab into embedded mode: - -1. **Spawn ab with `--embedded`** (LaunchFlag) — Hydrogen launches a fresh ab process as its child. ab enters embedded with `entered_via=launch-flag`. When Hydrogen subsequently dies ungracefully (without calling `/embedded/release` or `desktop_shutdown` first), ab's 60s `embedded_monitor` tick notices Hydrogen's control port is dead and **exits ab** (the "no hanging chad" rule — no tray-less orphan). -2. **Adopt at runtime via `POST /embedded/enter`** (RuntimeAdopt) — ab was already running standalone; Hydrogen calls the direct-API endpoint to flip ab into embedded mode without a respawn. ab enters embedded with `entered_via=runtime-adopt`, preserving its open state, current PID, all in-flight connections. When Hydrogen subsequently dies ungracefully, ab's monitor **reverts ab to standalone** — tray restored, footer pill hidden, marker deleted. ab process stays alive. - -The rule is simple: **whoever started ab owns ab's death**. If ab started itself (standalone → runtime adopt), Hydrogen's death doesn't take ab down with it. - -**Two new direct-API endpoints** (v1.8.73+, sugar over `POST /command`): - -```bash -# Adopt — runs the same handler as desktop_embedded_enter: -curl -s -X POST http://127.0.0.1:47200/embedded/enter - -# Release — runs the same handler as desktop_embedded_release. -# v1.8.77+: ALWAYS reverts to standalone. ab never exits via this call. -curl -s -X POST http://127.0.0.1:47200/embedded/release -``` - -**Probe target (v1.8.75+):** the 60s `embedded_monitor` checks Hydrogen's *control port* (default `47084`, discovered from `%APPDATA%\hydrogen-desktop\ports.json` key `control`), not Hydrogen's legacy `:9001` webview-eval port. Same discovery `hd_api` uses. Pre-v1.8.75 this was hardcoded to `:9001` — Hydrogen doesn't bind that port anymore, so the probe always reported Hydrogen dead and every embedded ab reverted/exited within 60s of Hydrogen starting. - -**Live frontend update (v1.8.76+):** the "Embedded · Hydrogen" footer pill responds to Tauri `mode-changed` events emitted on every transition. So if ab's window is open when Hydrogen adopts it (or Hydrogen releases it), the pill flips immediately — no page reload, no manual refresh. - -**Stale-marker safety (v1.8.74+):** if the marker file says `entered_via=launch-flag` AND Hydrogen's control port isn't responding at boot, ab deletes the marker BEFORE exiting. Without the delete, every subsequent manual launch (no `--embedded` flag) would hit the same exit branch and ab would be unlaunchable until the user manually rm'd the marker. The delete-before-exit makes the next launch start standalone normally. - -**Shell auto-approve is a user preference** (v1.8.116+ — replaces the v1.8.44 embedded-permanent default). The safe default everywhere is per-command approval. Resolution order at ab boot: - -1. `--shell-approval <prompt|1h|24h|permanent>` launch flag — Hydrogen's passthrough of its user's choice when spawning ab embedded. **Session-only**: never persisted by ab. -2. The durable Settings checkbox ("Auto-approve shell commands permanently", config field `shell_auto_approve_permanent`, default OFF). Also set/cleared via `shell_auto_approve '{"permanent": true|false}'`. -3. A restored timed grant from a previous session (+1h/+24h buttons, `duration_secs` calls). -4. Otherwise: prompt per command. - -In embedded mode **Hydrogen owns the preference** (Hydrogen is the parent, ab the child): ab's Settings checkbox goes read-only with "Managed by Hydrogen", and the footer shows "**Permanent** *(managed by Hydrogen)*" when armed. Hydrogen changes it at runtime with the same verb: - -```bash -# revoke everything (timed + permanent + the saved checkbox): -adom-bridge shell_auto_approve '{"duration_secs": 0}' - -# set the durable preference (equivalent to ticking the Settings checkbox): -adom-bridge shell_auto_approve '{"permanent": true}' -``` - -Migration note: pre-1.8.116 embedded boots persisted a permanent grant that leaked into standalone sessions. On first v1.8.116 boot, a permanent-class grant on disk WITHOUT the config preference is cleared — re-opt-in via the checkbox or the verb if you actually wanted it. - -**⚠ v1.9.90 — a cloud/relay AI CANNOT grant shell approval itself.** The `shell_auto_approve` examples above are Hydrogen's (a trusted LOCAL caller) or the human's own. From a **relay** connection (the cloud AI), a GRANT (`shell_auto_approve {"permanent":true}` / `{"duration_secs":N>0}`) is REFUSED with `errorCode:"human_only"` and instead POPS the human approval prompt in Adom Bridge. **The AI's path: call `request_shell_approval` (or just let the gate/refusal auto-surface the prompt) → ask the user to click Approve 1 hr / 24 hr / Bypass Permissions → RETRY your original verb.** Only a human clicking in ab (or Hydrogen proxying its own user) grants. A REVOKE (`duration_secs:0`) is allowed for the AI. - -#### Bridge ports are dynamic — you never need to know one (v1.8.31+) - -KiCad, Fusion, Browser/Puppeteer, and all third-party bridges now bind OS-assigned ephemeral ports (not the legacy 8772/8773/8851 you may remember). The runtime port changes every spawn. **Callers never need to know it.** - -- The CLI verb namespace (`kicad_*`, `fusion_*`, `pup_*`, plus any third-party `<bridge>_*`) is the contract. Always go through that. -- adom-bridge's direct API forwards to whatever port each bridge is on at the moment of the call. -- `bridge_list` reports `spawn.runtimePort` per bridge for debugging — do NOT hardcode it anywhere. -- Why this changed: Hydrogen's bridges also wanted 8772/8773/8851; we used to collide silently. Dynamic ports = clean coexistence. - -### Managing bridges (the AI fully controls bridges on its own) - -Bridges are ab's plugins (kicad, puppeteer, fusion360 ship bundled; others install from the wiki). You — the AI — can see, update, install, remove, pause, and resume them entirely through verbs. No human step is required. - -| Verb | What it does | -|---|---| -| `bridge_list` | Every installed bridge: `name`, `displayName`, `version` (what ab runs now), `source` (bundled / cache / third_party), `paused`, spawn kind, verbPrefixes. Start here. | -| `bridge_check_updates` | **READ-ONLY.** Per bridge: `current` vs `latest` (wiki) + `updateAvailable`. Downloads nothing. The "what's behind?" probe. | -| `refresh_bridges` | Apply updates. No args = sync ALL bridges from the wiki. `{"name":"kicad"}` = update just one. | -| `bridge_install` | Install a new third-party bridge from a wiki manifest URL (`{"manifestUrl":"…/<bridge>-bridge-manifest.json"}`). On `success:true` the **capsule appears in the ab GUI immediately** (no verb call needed; it's gray/idle until first use, then green). On `success:false` **nothing is registered and no capsule appears** — read `error` (a `read zip body`/`builder error` means the manifest's zip URL couldn't be fetched). Don't treat a parsed manifest as a completed install; confirm with `bridge_list`. The manifest `url` may be relative (resolved against the manifest's dir, v1.8.169+). | -| `bridge_uninstall` | Remove a bridge (`{"name":"…"}`). Bundled ones revert to the bundled copy; third-party ones are fully removed. | -| `bridge_pause` / `bridge_resume` | Flip a routing flag so a bridge's verbs fail-fast with `bridge_paused` (or resume). Does NOT kill the process. | -| `bridge_dev_mode` | **The DEV PIN** (`{"name":"kicad","on":true}`): freeze that ONE bridge against ALL automatic updates (the 4h sweep, on-launch sync, on-use auto-update, refresh_bridges) so a developer can modify its installed cache without ab pulling the release over their work. `on:false` releases it. `bridge_list` shows `devPinned:true` and the ab GUI card shows an amber "dev pin" badge with a one-click Unpin. **Etiquette: if a bridge shows `devPinned:true`, a human is mid-development on it - never unpin or force-update it to "get the latest" without asking them.** If YOU are about to edit a bridge's installed files, pin it FIRST. | - -**Naming convention for published bridges (3rd-party authors, read this):** when you publish a bridge to the Adom wiki, its **page title / catalog descriptive name MUST start with `Adom Bridge - `** (spaced hyphen) — e.g. `Adom Bridge - Altium Bridge`, matching the bundled bridges (`Adom Bridge - Puppeteer Bridge`, `Adom Bridge - KiCad Bridge`). This keeps every ab bridge grouped with consistent names in app-index pages instead of scattering as bare slugs. It applies to the **wiki/catalog title only** — NOT `bridge.json` `displayName` (the in-app chip label, which stays tool-descriptive like `Altium Designer`) or `name` (the lowercase slug). Full authoring walkthrough: the `adom-bridge-bridge-sdk` skill (the `adom-bridge-sdk` wiki page) + its fill-in-the-blanks templates. - -**Typical "am I on the latest bridges?" flow:** -```bash -adom-bridge bridge_check_updates # read-only: see current vs wiki per bridge -# → {bridges:[{name,current,latest,updateAvailable,source,wikiReachable}], updatesAvailable:N} -adom-bridge refresh_bridges # update ALL that are behind -adom-bridge refresh_bridges '{"name":"kicad"}' # or just one -``` - -**Version semantics (so the numbers make sense):** -- `current` = what ab actually runs = the newer of the writable cache and the bundled copy (version-aware, numeric semver — `1.0.10 > 1.0.9`). -- A bundled bridge that ships AHEAD of the wiki shows `updateAvailable:false` — ab correctly keeps the newer bundled copy. **puppeteer is bundled-only** (its node_modules deps exceed the wiki file limit), so it never shows a wiki update; it updates with the ab installer. -- After an `updated` action, the bridge SERVER PROCESS still holds the old code in memory until it respawns. `is_bridge_running` is version-aware (v1.8.142+) and forces a respawn on the next verb call, so you normally don't need to kill anything — but if you want it instant: `taskkill /f /im python.exe` (kicad/fusion) or `node.exe` (puppeteer). - -**Auto-update cadence:** a running ab also auto-syncs bridges on launch + every 4 hours. `bridge_check_updates` / `refresh_bridges` (or the GUI's **"Check for updates"** button next to "+ Bridge") are the on-demand "I just published, pick it up now" path. - -#### Bridge lifecycle — one instance, stable port (v1.8.153+) - -A dynamic bridge gets **one stable port** that ab reuses across every spawn and across ab restarts (persisted in `~/.adom/bridge-ports.json`). A long-lived external client (e.g. a browser extension that dials the bridge) keeps reaching the same port — no chasing a fresh ephemeral port each cycle, no "the client is on bridge A but ab routes to bridge B" split. ab enforces a **single instance**: it tracks every spawned bridge PID, reaps a stale/wedged one before respawning on the same port, and `bridge_stop_all` reaps orphans too. - -`spawn.persistent` (in `bridge.json`) is now surfaced in `bridge_list` (`spawn.persistent`) and means: **ab auto-respawns this bridge on death**, on its stable port (a supervisor runs each ~15 s). So "kill the bridge to load new code" → ab brings it right back at the same port the client keeps dialing. `bridge_install` / `refresh_bridges` also no longer fail with the cache-dir lock (ab reaps the process holding the dir as its cwd, then swaps; in-place merge fallback if a handle lingers). - -**v1.8.186/187 — first-class bridges (panel + remote management + optional manifest fields).** The ab window has a full-height **Bridges sidebar** that shows each bridge as a mini-app (hero image, LED, rich tooltip); clicking one opens a wide pop-out with a **live console** (its `~/.adom/bridge-logs/<name>.log` + the per-bridge command history), a **ports/process debug view** (live PIDs — `>1` flags a runaway), persistent-vs-one-off + codebase-language badges, and all management actions (install/uninstall/**kill**/restart/pause/resume + "filter activity log to this bridge" + "copy contribution prompt"). The same management is available **remotely** to cloud/WSL2 threads via verbs: `bridge_info`/`bridge_detail` (rich aggregate), `bridge_log_read {name, sinceOffset?}` (**cursor-streaming remote tail -f**), `bridge_kill` (wipe one bridge's processes). As of v1.8.186 **all** bridges write their per-bridge log (bundled kicad/fusion360/puppeteer included, not just dynamic ones). New **optional** `bridge.json` fields (all back-compat, surfaced in the panel): `hero` (a relative filename on your wiki page, or a full URL — your app-icon; absent → a monogram tile), `languages` (override the auto-scan, e.g. `["Python"]`), `releasedAt` (ISO date; absent → bridge.json mtime). - -**v1.8.156 — strays + self-documenting hints.** ab now tracks every bridge PID it spawns (persisted across ab restarts) and **reaps all stray instances** before respawning — so you don't end up with 4 copies of one bridge fighting over the routing. `bridge_list` surfaces `spawn.instanceCount` per dynamic bridge (normally 0 or 1; **>1 = strays piled up** — visible at a glance). If a bridge verb fails to reach a healthy bridge, the error `_hint` tells you to retry / check `instanceCount` / `process_list`; and a verb response right after a (re)spawn carries an `_ad_note` saying the external host reconnects within a few seconds — **retry** instead of treating a transient "not connected" as terminal. For verb discovery from a bridge, `GET <ADOM_DIRECT_API_URL>/commands` lists every reachable verb (see [`skills/DIRECT_API.md`](DIRECT_API.md)). - -#### Bridge status chip — self-reported LED + tooltip (v1.8.152+) - -Each bridge has a status dot in the ab GUI sidebar. **ab is a pure renderer of bridge health** — a live bridge decides its own LED color and hover text; ab just paints what the bridge reports. This replaced the old scheme where ab inferred the LED from whether it was holding a spawn handle, which left fire-and-forget (dynamic/installed) bridges stuck RED even while healthy and routing. - -**The contract (all three fields OPTIONAL).** On the endpoint ab already polls — your `bridge.json` `healthEndpoint` (default `/health`; the sample bridges use `/status`) — return a 2xx JSON body that MAY include: - -| Field | Type | Meaning | -|---|---|---| -| `led` | `"green"` \| `"yellow"` \| `"red"` | The dot color. Green = healthy; yellow = degraded/warning; red = the bridge itself knows it's broken (e.g. lost its upstream). | -| `summary` | string | Short label shown next to the dot. Keep it tiny (≤ ~24 chars) — e.g. `"2 windows"`, `"ext disconnected"`. | -| `tooltip` | string | Full hover text. Newlines allowed; no hard length limit (keep it readable). | - -**Rules ab enforces:** -- Endpoint returns 2xx **with** `led`/`summary`/`tooltip` → rendered **verbatim**, no parsing or logic. -- Endpoint returns 2xx **without** them → ab defaults to **green** (you're reachable) + a generic tooltip. Existing bridges work unchanged — this is opt-in. -- Endpoint **unreachable** → ab owns this ONE state: a **gray/offline** dot + ab's own tooltip ("Not running" / "Bridge not reachable"). A dead bridge can't self-report, so ab never lets a bridge claim "offline" — it detects it. - -**Cadence:** ab re-polls every ~15 s, and a dynamic bridge flips green the instant it spawns. So a bridge that surfaces live counters (open windows, queue depth, ext-connection state) in `summary`/`tooltip` updates in the GUI within a tick. - -Reference implementations: [`scripts/sample-bridges/hello-python/server.py`](../scripts/sample-bridges/hello-python/server.py) and [`hello-rust/src/main.rs`](../scripts/sample-bridges/hello-rust/src/main.rs) both self-report `led`/`summary`/`tooltip` on `/status` — fork either. - -### File Transfer - -- `send_files` -- Send files from the Docker container to the desktop. Files are base64-encoded in transit. - - `filePaths`: array of absolute paths on the server - - `targetApp`: "kicad", "fusion360", or "general" - - `dest`: **absolute** Windows path (under a safe root) to land the files exactly where you want (v1.8.148+). Prefer this when you know the target dir. A folder is created if missing. For ONE file, `dest` may be the full file path instead. A `dest` ending in a slash or in `.pretty` / `.3dshapes` / `.app` / `.bundle` / `.framework` is always a folder (2.1.163+), so a KiCad footprint library works directly: `dest: "C:/Users/<you>/Documents/proj/lib/mylib.pretty"`. - - `destIsFolder` (2.1.163+): `true` forces `dest` to be a folder for any other dotted folder name; `false` forces it to be the file path. - - `destinationFolder`: **relative subfolder only** (e.g. `"kicad/symbols"`, `"fusion"`). The desktop app controls the base directory. **Absolute paths are rejected.** With neither `dest` nor `destinationFolder`, files land in the laptop's **Downloads** folder. - - Returns `destinationPaths[]` with the absolute path of every saved file, plus (v1.8.168) `savedToDir` (the folder) and a rich `_hint`. **The files land on the LAPTOP (Windows host), not in your container** — verify with `list_dir`, pull one back with `pull_file`. - - **`send_files` only COPIES bytes — it does NOT install or register a bridge.** Dropping bridge files with `send_files` will NOT make a capsule appear or verbs route. To add a bridge use `bridge_install '{"manifestUrl":"…/<bridge>-bridge-manifest.json"}'`, then confirm with `bridge_list`. - -- `pull_file` -- Pull files from the desktop to the container. - - `filePaths`: array of absolute Windows paths on the desktop - - `saveTo`: directory on the container to save files (default: `/tmp`) - - **Streaming since v1.4.3.** Each file is transferred as 1 MiB binary WS frames straight to disk on the Docker side, with incremental SHA256 verification. The legacy 30s base64-JSON path is gone — large files (50 MB+ datasheets, 75 MB reference manuals) no longer time out. Per-file timeout is 600s. - - Returns `files: [{name, path, size, sha256, chunks}]`. Use `sha256` to verify the transfer (the desktop side computes it during streaming and the container side verifies on completion; mismatch deletes the partial file and reports failure). `chunks` is the count of 1 MiB binary frames received. - - When at least one but not all files succeed: `success` is `true`, `errors[]` lists the failures alongside `files[]`. When ALL fail: `success` is `false`. - -### Desktop Notifications - -- `notify_user` -- Send a native Windows toast (own AUMID + Adom logo). v1.9.33: rich types — progress bar, sticky reminder, action buttons that **bring ab to the foreground** on click + round-trip via `notify_response`. - - `title`, `body`, `level` (info/success/warning/error/emergency), `actions` (label buttons) — plus `scenario` ("reminder"=sticky), `durationLong`, `buttons` (`[{label, arguments?}]`), `progress` (`{title, value 0..1, status, valueOverride}`), `inputs`, `id` (correlation id for the round-trip). - - Full guide + copy-paste examples per type: the **adom-bridge-notify** skill ([`skills/NOTIFY.md`](NOTIFY.md)). -- `notify_response` -- Poll which toast button the user clicked, by correlation `id`. `{pending:true}` until they click; `{pending:false, action}` once they do (consumed on read). - -### KiCad (`kicad_*`) — bridge-owned; light pointer - -The KiCad bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/kicad-bridge`). This page keeps only the pointer: run `adom-bridge sync_skills` after connecting and the full KiCad guide (project workflows, three-tier `kicad_open_*` routing, DRC recipes, library install) lands in `~/.claude/skills/`. - -Its verbs: `adom-bridge commands '{"bridge":"kicad"}'` (the bridge describes itself). - -If KiCad is not installed, the bridge knows how to install it for the user — offer, never point them at a download page. Full details: the bridge's own skill. - -### Fusion 360 (`fusion_*`) — bridge-owned; light pointer - -The Fusion 360 bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/fusion-bridge`). Run `adom-bridge sync_skills` and the full guide (cloud files, import/export, add-in troubleshooting, APS search) lands in `~/.claude/skills/`. - -Its verbs: `adom-bridge commands '{"bridge":"fusion360"}'`. - -Respect `timeoutSeconds` + `statusVerb` on responses instead of re-issuing a slow verb. Full details: the bridge's own skill. - -### Launching apps (generic) - -Two generic CLI commands exist for launching any Windows executable safely — they verify the target exists BEFORE handing it to the OS, so you never trigger a "Windows cannot find" GUI dialog: - -- `adom-bridge find_exe '{"name":"..."}'` -- Resolve an exe by absolute path, glob (e.g. `C:/.../webdeploy/production/*/Fusion360.exe` returns newest), bare name (searches PATH + Start Menu `.lnk` targets). Returns `{path, source}`. Does NOT launch. -- `adom-bridge launch '{"path":"...", "args":[...], "cwd":"...", "detached":true, "reason":"..."}'` -- Same resolution rules as `find_exe`, then spawns. Fails in terminal (exit 1) with a clear error if the path doesn't exist. **Always prefer this over raw `start`.** - -**Where they run (2.1.143, wiki #210).** With `--target <box>`, or from a container / Linux CLI, both verbs run ON THAT BOX through its ab (mesh first, relay fallback, the same path as `shell_execute`): the path is resolved against the box's filesystem and the app starts there; the reply says `on: "<box>"`. Before 2.1.143 both ran inside the CLI process, so `--target arav-rog launch {path}` answered `find_exe: could not locate` for a file that existed on arav-rog. Only a Windows CLI with no `--target` still resolves and spawns locally. On a box `launch` is a Tier-2 exec verb like `launch_process` (gated the same way, `reason` required); `find_exe` is read-only. A target ab older than 2.1.143 answers unknown verb: use `launch_process {path, args}` there. - -**A bridge's verbs are documented by the bridge, not here (2.1.146, wiki #212).** `commands` lists the bridges the box has; `commands {"bridge":"<name>"}` is the bridge's own description; `sync_skills` installs its skill. Nothing below is about any one bridge. - -### Desktop Tools - -- `desktop_close_window` -- Gracefully close a window (WM_CLOSE, so save prompts fire). Resolves `hwnd` or `titleContains`; optional `forceAfterMs` kills the owning process only if the window outlives the grace period. Returns MEASURED `closed`/`forced`. Prefer this over process_kill for anything with unsaved state. -- `desktop_wait_for_window` -- Block (bounded timeout) until a window matching `titleContains`/`className`/`pid` appears, or with `state:"absent"` until it goes away (a dialog dismissed, an app exited). The honest replacement for sleep-and-rescreenshot loops: `adom-bridge desktop_wait_for_window '{"titleContains":"Save As","timeoutMs":10000}'`. -- `desktop_open_folder` -- Open a file or folder in Windows Explorer. If given a file path, Explorer opens with the file highlighted (selected). Args: `path` (file or folder path). -- `desktop_open_url` -- Open a URL in the user's **NATIVE OS BROWSER** — i.e. the real Edge / Chrome / Firefox / Brave they use every day, with their saved logins, history, bookmarks, and extensions. This is NOT pup, NOT Chrome for Testing — it's the browser the human actually uses. Hand it off when the user needs to interact with a logged-in account themselves. - - Args: `url` (required string), `browser` (optional: `"default"` (Windows-registered default — could be Edge, Chrome, Firefox, Brave; whatever they set), `"chrome"`, `"edge"`, `"firefox"`, `"brave"`). - - Examples: - - `adom-bridge desktop_open_url '{"url":"https://claude.ai/"}'` -- opens in user's default native browser, already signed in - - `adom-bridge desktop_open_url '{"url":"https://docs.example.com","browser":"edge"}'` -- force native Edge - - Returns `{ok, browser, url, exePath?}`. Allowed schemes: http, https, mailto, ftp, ftps. Other schemes refused. - -#### `desktop_open_url` vs `pup_open_window` — two completely different browsers - -| | `desktop_open_url` | `pup_open_window` | -|---|---|---| -| **What browser** | Native OS browser the user uses daily — Microsoft Edge / Google Chrome / Mozilla Firefox / Brave (whichever they've set as Windows default, or the one you name explicitly) | Puppeteer-controlled **Chrome for Testing** — a separate Chromium build that pup launches | -| **Profile / data** | The user's real profile: saved logins, history, bookmarks, extensions, autofill | Isolated profile under `plugins/puppeteer/profiles/<sessionId>/` — empty, no saved logins | -| **Who interacts with the page** | The HUMAN. Claude hands the URL off and is done. | CLAUDE drives it programmatically — screenshots, clicks, eval, recording. The human just watches. | -| **Use when** | A login is required (claude.ai, GitHub, internal tools, banking) — the human's existing session must be reused | Automation, scraping, screenshot capture, video recording, headful UI testing | -| **Can Claude control it after launch?** | No — once handed off, Claude can't see or drive it | Yes — every `pup_*` verb (screenshot, eval, navigate, record, etc.) | - -**Decision rule:** human-in-the-loop login flow → `desktop_open_url`. AI-driven automation → `pup_open_window` (defaults to Chrome for Testing). AI-driven automation that **must** be the user's real, signed-in profile (GCP Console with org/projects, internal SSO behind a managed-Chrome policy, anything Context-Aware Access blocks for non-enrolled browsers) → `pup_open_window` with the new `nativeBrowser` + `nativeProfile` args (v1.0.5+, see below). - -#### ⚠️ Common footgun — `localhost` / `file://` URLs from Docker AIs - -If you're running this CLI from a remote container, a `url` you pass to ANY verb (a bridge's window, `desktop_open_url`) is loaded on the DESKTOP: `localhost` there is the desktop, not your container, and `file://` names a file on the desktop's disk. - -**v1.7.10+ detects this.** When you pass a URL whose host is `localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, or scheme is `file://`, the response includes a `_hint` field explaining the issue and suggesting the public-slug URL pattern (`https://<user>-<repo>-<suffix>.adom.cloud/proxy/<port>/<path>`). **The call still proceeds** — rare legitimate cases exist where the user actually does have something running on their localhost. - -**Recipe — load something from inside your container into pup:** - -```bash -# 1. Start your service inside the container (e.g. a local HTML viewer): -python3 -m http.server 8786 --bind 0.0.0.0 & - -# 2. Ask your USER for the container's adom.cloud slug suffix -# (look at their browser URL when they open hydrogen or claude.ai/code). -# Slug looks like: <username>-<container>-<random-id> - -# 3. Open the public URL — NOT localhost — in whichever browser verb you use: -adom-bridge desktop_open_url '{"url":"https://<your-container-slug>.adom.cloud/proxy/8786/foo.html"}' -``` - -If you don't have the suffix handy, just ask the user. Or if the file you wanted to show pup is one your container already wrote to disk, push it to a public location (wiki asset, S3) and load from there. - - -- `desktop_bring_to_front` -- Bring a window to the foreground by HWND or title substring. Uses keybd_event + AttachThreadInput to bypass Windows foreground lock. Preserves maximized state. - - Args: `hwnd` (int) OR `titleContains` (string, case-insensitive). One required. - - Example: `adom-bridge desktop_bring_to_front '{"titleContains": "Fusion"}'` -- `desktop_set_window_state` -- Change a window's show state without bringing it to the foreground (or both, if combined with `desktop_bring_to_front`). Args: `hwnd` (int) OR `titleContains` (string) OR `cacheKey`, plus `state` (one of `"maximize"`, `"minimize"`, `"restore"`, `"show"`, `"hide"`, `"bottom"`). Use to ensure Fusion or KiCad windows are maximized for screenshots, or to hide noisy background windows during demos. - - Example: `adom-bridge desktop_set_window_state '{"hwnd":133560,"state":"maximize"}'` - - **`state:"bottom"` (v1.9.114) -- z-order send-to-back, background.** Drops the window BEHIND all others without focusing, moving, or resizing it (`SetWindowPos HWND_BOTTOM` + `SWP_NOMOVE|SWP_NOSIZE|SWP_NOACTIVATE`); works even on an off-screen window. It's an ATOMIC check-and-demote: it reads `GetForegroundWindow()` first, and if the target IS the OS foreground (the user is interacting with it) it SKIPS the demotion and returns `wasForeground:true, sentToBack:false` so the caller backs off with no race; otherwise `wasForeground:false, sentToBack:true`. Use to park a background window behind the user's windows (still on-screen, taskbar-clickable, rendering for screenshots) without stealing focus. - - **`force:true` (v1.9.115)** sends it to back EVEN IF the window is currently the OS foreground (still no activation change; `sentToBack:true` always, `wasForeground` still reported). Use at window-LAUNCH time: Windows reassigns the foreground to a brand-new window when the previous foreground is destroyed, so "foreground at launch" is not a real user signal. - - Example: `adom-bridge desktop_set_window_state '{"titleContains":"(session: abc","state":"bottom","force":true}'` - -#### Real OS input — mouse + keyboard (v1.8.170+) - -`desktop_click` / `desktop_double_click` / `desktop_right_click` / `desktop_hover` / `desktop_type` / `desktop_press_key` synthesize **real OS input via `SendInput`** — so they land on **`chrome://` pages and native dialogs that browser CDP (`nbrowser_*` / `pup_*`) cannot reach**. The verb names mirror the browser extension's `nbrowser_*` so the model transfers between in-page and whole-desktop driving. - -- **You must say WHY (v2.0.84).** Every verb that takes over the screen - the four mouse verbs, `desktop_type`, `desktop_press_key`/`send_keys`, `desktop_bring_to_front`, `desktop_raise_window` - requires a **`reason`** from a relay caller, or it is refused with `errorCode:"reason_required"`. The reason is not for the log: ab renders **"`<your thread>`: `<your reason>`"** on the user's screen next to the glowing cursor, at the moment their machine moves. Write it for the human watching, not for a log line - `reason:"clicking Next on the Altium installer"`, not `reason:"sending a click"`. -- **Gated behind shell-auto-approve** (same risk class as `shell_execute` — keystrokes/clicks hit whatever is focused on the user's live machine). If it's off you get `errorCode:"input_not_approved"`. **You (a cloud/relay AI) can't grant it yourself (v1.9.90)** — call `request_shell_approval` (or let the refusal auto-surface the prompt) and ask the user to click Approve / Bypass Permissions in Adom Bridge, then retry. Every action is written to the activity log. -- **Focus a window first** by passing `{window:"<title substring>"}` (alias `titleContains`) or `{hwnd:<int>}` — reuses `desktop_find_window`'s resolver, so input lands on the intended window, not whatever happened to be focused. Get the hwnd from `desktop_list_windows` / `desktop_find_window`. -- **Coords are physical screen pixels** — the same space as `desktop_list_windows` rects. - -**The four verbs that MOVE THE MOUSE each answer to a `mouse_*` name too (v2.0.84).** They are the same verbs, not new ones - the pointer verbs were named after what they do to a control (`click`, `hover`), so searching a verb list for "mouse" found nothing that moves the mouse. Use whichever name you reach for: - -| moves the mouse | alias | -|---|---| -| `desktop_hover` (also `desktop_move`) | `desktop_mouse_move` | -| `desktop_click` | `desktop_mouse_click` | -| `desktop_double_click` | `desktop_mouse_double_click` | -| `desktop_right_click` | `desktop_mouse_right_click` | -| `desktop_mouse_drag` (2.1.143) | `desktop_drag` | - -**Drag (2.1.143, wiki #205).** `desktop_mouse_drag {from:{x,y}, to:{x,y}, steps?:12, durationMs?:300, button?:"left", space?:"screen"|"image"|"window", hwnd?|titleContains?, reason}` presses at `from`, moves the real cursor through `steps` interpolated points over `durationMs`, releases at `to`. It is for canvases with no view controls that only react to a drag: orbit a 3D preview (KiCad's Footprint Chooser pane), a splitter, a slider, a lasso. A canvas that tracks motion ignores a teleport, so the pointer really travels; slow canvases want `durationMs` 600+. With `hwnd`/`titleContains` ab raises that window, verifies it is under `from` before pressing (else `target_not_under_cursor`, nothing pressed), and gives the prior foreground back afterwards (`restoreForeground:false` keeps it up). `space:"image"` + `shotId` takes screenshot pixels; `space:"window"` takes window px or `{fx,fy}` fractions of the window rect. The reply lists the `points` actually sent. Same gate and same UIPI limit as `desktop_click`. - -An alias is gated exactly like the verb it aliases - it is not a way around the reason requirement below. - -Verbs: -- `desktop_click {x, y, button?:"left"|"right"|"middle", double?, window?|hwnd?}` — left-click by default. `desktop_double_click` / `desktop_right_click` are wrappers; `desktop_hover` moves the cursor without clicking (reveals tooltips). -- `desktop_type {text, window?|hwnd?}` — types a literal Unicode string (`KEYEVENTF_UNICODE`, any keyboard layout). -- `desktop_press_key {keys:[...], repeat?, window?|hwnd?}` (alias `desktop_send_keys`) — chords/shortcuts. Modifiers `ctrl/alt/shift/win`; named keys `enter/tab/esc/space/backspace/delete/insert/home/end/pgup/pgdn/up/down/left/right/f1-f12`. - -Canonical "navigate a browser to a URL" flow (works on `chrome://` where CDP can't): -```bash -adom-bridge desktop_press_key '{"keys":["ctrl+l"],"window":"Edge"}' # focus Edge + select the address bar -adom-bridge desktop_type '{"text":"edge://extensions","window":"Edge"}' -adom-bridge desktop_press_key '{"keys":["enter"],"window":"Edge"}' -``` -For a control that isn't reachable by CDP/coords (e.g. the Reload button on `edge://extensions`, which lives in a shadow DOM), Tab-navigate to it then Enter: `desktop_press_key {"keys":["tab","tab","enter"]}`. - -**Visual "Adom cursor" (v1.8.171+)** — a click-through teal cursor + a label that narrates what you're driving (trust cue; byte-faithful to the browser extension's cursor). Pure visual, *not* gated: -- `desktop_cursor {action:"show"|"move"|"hide", x, y, label}` — drive/narrate the cursor on its own ("watch me"). Returns the overlay `hwnd`. -- **On by default since v2.0.84** on `desktop_click`/`desktop_double_click`/`desktop_right_click`/`desktop_hover` (and their `mouse_*` aliases): ab glides the teal cursor to the target *then* does the real click, so the human always sees Adom is driving rather than an unattributed pointer jump. Pass `label:"Clicking Reload"` to narrate it in your own words; with no label it shows "`<your thread>`: `<your reason>`". Pass `cursor:false` only when the pointer move must be invisible. It was opt-in before, which meant the user lost the signal precisely when an AI was careless. -- **Auto-hide (v1.8.174+):** the cursor hides itself after `ttlMs` (default **60s**) so it never lingers on the user's screen if you forget to hide it. Each `show`/`move` refreshes the timer. Pass `ttlMs:<ms>` (≤600000) for longer, or `persist:true` to keep it until you explicitly `desktop_cursor {action:"hide"}`. If a cursor you showed was auto-hidden, the **next** cursor/click response prefixes its `_hint` with a NOTE so you learn (no chatty unsolicited message). - -#### Background input via UI Automation (v1.8.174+) — no foreground, no focus steal - -The mouse/keyboard verbs above use `SendInput` = real OS input, which only lands on the **foreground** window — so ab foregrounds the target first, yanking it in front of the user's work. When you must NOT steal focus, use **UI Automation** instead: `InvokePattern.Invoke()` / `ValuePattern.SetValue()` are **programmatic** — the app services them via its accessibility tree with **no cursor move, no focus change, no foregrounding**. Chromium/Edge expose their a11y tree to UIA, so `chrome://` page buttons/links are reachable **by accessible name** (not coordinates). - -- `desktop_find_control {window|hwnd, name|contains, role?}` — **read-only** discovery (ungated). Returns `best:{name, automationId, role, rect, invokable, settable}`. Call this first to learn whether a control is UIA-reachable. -- `desktop_ui_click {window|hwnd, name|contains, role?}` — UIA **Invoke** by accessible name, in the background. Gated behind shell-auto-approve. -- `desktop_ui_set {window|hwnd, name|contains, text}` — UIA **SetValue** on a text field, in the background. Gated. - -`role` ∈ `button|edit|link|checkbox|menuitem|tab|listitem|combobox|radiobutton|document|group|spinner`. **Caveat (surfaced in `_hint`, not hidden):** some shadow-DOM controls aren't in the UIA tree — if `find_control` reports `invokable:false`/not-found, the control genuinely needs a **foregrounded** `desktop_click`. So: prefer `desktop_ui_click` (background) → fall back to `desktop_click` (foreground) only when UIA can't reach it. - -#### Near-complete UIA surface (v1.9.0+) — enumerate, read, drive, with a foreground-hint contract - -v1.9.0 expands UIA from ~3 patterns to a near-complete agent-drivable client. **Every UIA verb now returns a `foreground` block** so you know — *before and after* — whether a call steals the user's screen: - -``` -"foreground": { "predicted": "background|fallback-possible|foreground", // from the static class + the resolved path, BEFORE acting - "observed": "background|foreground|n/a", // MEASURED (GetForegroundWindow before/after); reads = n/a - "raised": true|false, // did THIS call bring a window to front? - "pathTaken": "uia-pattern|sendinput-fallback|setfocus|read-only", - "reason": "<short>" } -``` - -**Shared selector on every verb:** `{hwnd|window(titleContains)}` + `{name|contains|automationId|role}` + `scope:"window"(default)|"popups"|"subtree:<automationId>"` + `nth:<int>` (0-based — target the Nth match without the exact name). Prefer `automationId` (stable, locale-independent). Rects are screen px with `centerX/centerY`; `space:"image"+shotId` is accepted wherever a coordinate is taken. - -- **Enumeration (read-only, ungated):** - - `desktop_ui_tree {window|hwnd, maxDepth?, roleFilter?[], nameContains?, maxNodes?(500), scope?}` — **flagship.** Dump the a11y subtree; nodes carry rect + invokable/settable + value/toggleState/expandState/selected/rangeValue + `children[]`. Honors Chromium's lazy tree (wake-retry); `truncated:true` when it hits `maxNodes` (logged, never silent). - - `desktop_ui_from_point {x,y | space:"image",shotId,x,y}` — the control under a pixel. - - `desktop_find_controls {selector, maxResults?(25)}` — ALL matches as an array with `nth` indices (vs `find_control`'s single best). -- **Reading (read-only, ungated):** `desktop_ui_text {selector, range?:"all"|"selection"|"visible"}` (TextPattern); `desktop_ui_get {selector}` (current value/toggle/range/selection/expand state); `desktop_ui_grid_get {selector, row?, col?}` (GridPattern cell + rowCount/columnCount). -- **Actions (gated by shell-auto-approve; background-first via the pattern, SendInput-click fallback only when the pattern is absent → then `raised:true`):** `desktop_ui_toggle {selector, state?}`, `desktop_ui_expand {selector, action?:expand|collapse|toggle}`, `desktop_ui_select {selector, action?:select|add|remove}`, `desktop_ui_scroll {selector, dir?, amount?}`, `desktop_ui_set_range {selector, value}`, `desktop_ui_focus {selector}` (**bg:foreground** — usually raises; reports it), `desktop_ui_window {window, action:minimize|maximize|restore|close|move|resize}`. -- **Events (v1.9.3 — REACT instead of poll; read-only, ungated):** - - `desktop_ui_watch {event, window?|hwnd?, properties?}` → `{watchId}`. `event` ∈ `focus` (desktop-wide "what got focus") | `window-opened`/`window-closed` (new/closed top-level windows — these three can be global) | `invoke` | `selection` | `text-changed` | `text-selection` | `menu-opened` | `tooltip-opened` | `notification` | `live-region` | `structure` | `property` (these REQUIRE a `window`, else `errorCode:window_required` — avoids a desktop firehose). Cap 16 watches. - - `desktop_ui_events {sinceSeq?, watchId?, max?}` → DRAIN the buffer (poll cursor; there is NO server-push over the relay). Returns `{events:[{seq, watchId, event, ts, element:{name,role,automationId,rect,hwnd}, eventType|property|changeType}], nextSeq, highestSeq, missedEvents, activeWatches}`. Poll with `sinceSeq=nextSeq`. Events ALSO appear in the ab GUI activity-log. The 500-deep ring is shared across all watches; `missedEvents:true` = you polled too slowly. - - `desktop_ui_unwatch {watchId|"all"}` → stop. **Canonical use:** waiting on an OTP/confirm dialog → `ui_watch {event:"window-opened"}`, poll `ui_events` until the window appears, then act on it (no busy screenshot loop). -- **Introspection:** `desktop_ui_capabilities {}` — **read-only, ungated.** The foreground-hint TABLE per verb (`staticClass` + measured `{win32, chromium-pattern, chromium-fallback}`). **Consult it BEFORE a call to know if it'll steal the screen, without acting.** `desktop_ui_selftest {confirm?, window?}` writes/refreshes it (empirically measures when `confirm:true`). - -**Integrity boundary:** an elevated/admin window or the UAC secure desktop returns `errorCode:"uia_integrity_blocked"` (never a silent no-op) — a normal-integrity bridge cannot drive a higher-integrity process. - -#### Zero-math coordinate clicks — `coordMap` + image-space (v1.8.175+) - -You no longer recompute scale/offset/DPI to click what you see in a screenshot. Every screenshot verb returns a **`coordMap`**: - -```jsonc -"coordMap": { - "shotId": "shot-w5050328-1", - "image": { "w": 1372, "h": 1031 }, // the DELIVERED (safe) PNG's pixels - "screenRect": { "x": 594, "y": 254, "w": 1372, "h": 1031 }, // physical px = input-verb space - "dpiScale": 1.5, - "imageToScreen": { "sx": 1.0, "sy": 1.0, "ox": 594, "oy": 254 } // screen = img*s + o -} -``` - -Two ways to use it: -- **Let ab convert (preferred, zero math):** `desktop_click {space:"image", shotId, x, y}` where `x,y` are pixels in that screenshot. Also works on `desktop_double_click`/`right_click`/`hover`. The `shotId` comes from the capture's `coordMap.shotId` (last ~64 shots retained; re-capture if it expires). -- **Compute yourself:** `screenX = x*sx + ox`, `screenY = y*sy + oy`. - -`screenRect` is in the SAME physical-px space as `desktop_list_windows` rects and the `desktop_click` default (`space:"screen"`). - -> **Driving a GUI app that pops error boxes / modal dialogs? Read the dedicated [`adom-bridge-window-capture`](WINDOW_CAPTURE.md) skill.** `desktop_screenshot_window {hwnd}` captures ONE window AND the child dialogs / error boxes it owns, even when the window is backgrounded/occluded/minimized (PrintWindow, no focus steal). It is the breakthrough for automating native apps whose dialogs a full-screen grab can't see. The rest of this section is the reference; that skill is the how-and-why. - -**Popups (owned):** As of **v1.8.177**, `desktop_screenshot_window` **also captures the window's OWNED popups** — borderless confirm bubbles (Chromium's "Remove '\<ext\>'?"), owned dropdowns, owned dialogs — that don't enumerate in `desktop_list_windows` and aren't visible in a PrintWindow of the main window. They ride back as a top-level **`screenshots[]`** array (the same generic shape `kicad_screenshot_all` uses; the CLI auto-pulls each entry's `fullPath`→`localPath` and `safePath`→`localSafePath`). `ownedPopupCount` is always present (0 = none). Each entry carries `hwnd`, `title`, `kind:"owned_popup"`, and its own `coordMap`+`shotId`, so you can **click the bubble's buttons** with `{space:"image", shotId, x, y}` or drive them in the background by name via `desktop_ui_click {scope:"popups"}`. This is the generic mechanism — there's no app-specific "Chrome bubble" verb; every window screenshot surfaces any owned popups so you get the data and decide. **Use `desktop_screenshot_screen`** (full virtual-screen grab) only for popups *not owned* by your target window, or to see everything at once. - -**Screen geometry on every shot (v1.8.178):** every `desktop_screenshot_window` / `_screen` response now carries: -- **`windowRect: {x,y,w,h,monitor}`** — the window's position + size on the overall (possibly multi-monitor) desktop, in physical screen px. Each owned popup entry in `screenshots[]` gets its own **`rect: {x,y,w,h,monitor}`** too — so you know where every captured surface sits, not just its pixels. -- **`display: {virtualScreen, primary{w,h,dpi,scale}, monitors[], monitorOfWindow}`** — the full monitor topology: total virtual bounds, and per-monitor `bounds`/`dpi`/`scale`/`primary`. `monitorOfWindow` indexes into `monitors[]`. (Same data as the standalone `desktop_display` verb, folded in.) -- **`drive: "background"`** — screenshots never foreground the window (PrintWindow/WGC). The companion `_related` field lists sibling screenshot/window verbs. - -**Screenshot cache is self-cleaning + self-describing (v1.9.107/108/113):** the CLI auto-pulls every screenshot to ONE user-visible, namespaced dir on the caller's machine (`~/project/screenshots/adom-bridge/`, else `~/.adom/screenshots/`; `ADOM_SHOTS_DIR` overrides) — never `/tmp` anymore. Shots are **bucketed by kind into subfolders**: `window/` (a specific window / app / owned popup) and `screen/` (a full-desktop grab). **Human-readable filenames (v1.9.113):** `<YYYY-MM-DDThh-mm-ss.mmm>[-<hwnd>].png` — a sortable ISO stamp (lexicographic == chronological), plus the `hwnd` for a window capture; the `window`/`screen` kind is NOT repeated in the name because the folder already says it. **One file when small (v1.9.114):** if the shot was already within the safe size cap (the common case) it's a single `<stem>.png` and `localFullPath == localSafePath` — no redundant re-encoded copy. Only a shot large enough to be downscaled produces a `<stem>.full.png` + `<stem>.safe.png` pair (Read the `.safe` one for image analysis; use `.full` for archive). Each shot gets a **`<stem>.json` sidecar** (shotlog-style: `source`, `timestamp`, `kind`, dims + `bytes`, `coordMap`, `rect`; a full+safe pair shares one). **Parent/child grouping (v1.9.111/113):** when `desktop_screenshot_window` returns owned popups in `screenshots[]`, the parent AND each pulled child share the same stem, and each child gets a **`.popup-NN` label** — e.g. parent `2026-07-15T01-14-16.937-986710.safe.png` and children `2026-07-15T01-14-16.937-986710.popup-01.safe.png`, `...popup-02...`. So a plain `ls` shows the whole capture grouped (shared stem) and the role (`.popup-NN`) without opening a sidecar; the sidecar also carries `parent` + `popupIndex`. (`kicad_screenshot_all` returns sibling windows with no parent, so those keep their own names.) A janitor prunes it (7 days / 2 GB / newest kept; sidecars follow their PNG, orphans swept) and drains the old `/tmp/ad-shots` + `/tmp/adom-bridge-screenshots` backlogs, running opportunistically at most once/24h on any CLI call. **You do not need to clean it up or write your own cron.** Every screenshot response carries a `_cacheHint`; verify coverage with **`adom-bridge janitor`** (status: each subfolder, size, policy, last sweep) or force a sweep with **`adom-bridge janitor '{"sub":"run"}'`**. It never touches other tools' files under `~/project/screenshots/` (e.g. shotlog). Tune via `ADOM_SHOTS_MAX_AGE_DAYS` / `ADOM_SHOTS_MAX_GB` / `ADOM_SHOTS_MAX_FILES`, or disable with `ADOM_JANITOR=0`. - -**Background vs foreground driving (v1.8.178):** every desktop driving verb carries a structured **`drive`** field (`"background"` | `"foreground"` | `"n/a"`) — in `GET /commands`, in `adom-bridge help <verb>`, and echoed in the verb's own response. **Background** (no foreground / focus steal / cursor move — UIA + capture): `desktop_ui_click`, `desktop_ui_set`, `desktop_navigate`, `desktop_find_control`, `desktop_screenshot_window/_screen`, `desktop_record_window_start`. **Foreground** (SendInput → goes to the focused window, so ab foregrounds first): `desktop_click`/`_double_click`/`_right_click`/`_hover`, `desktop_type`, `desktop_press_key`, `desktop_bring_to_front`. Run **`adom-bridge help background`** for the full grouping. Rule: to act without disturbing the user's window, drive by NAME (`desktop_find_control` → `desktop_ui_click`/`ui_set`); fall back to coordinate clicks/keys only when the control isn't in the UIA tree. - -**Click a control by name → coords:** `desktop_find_control` returns `best.rect{x,y,w,h,centerX,centerY}` in screen px — `desktop_click {x:centerX, y:centerY}` to SendInput-click it (handy when `invokable`/`settable` are both false). Target by `name`, `contains`, `role`, or `automationId` (stable, locale-independent). - -**Keyboard chords:** `desktop_press_key` accepts a chord as one string `["ctrl+w"]` OR separate tokens `["ctrl","w"]` — both mean Ctrl held + W. A *sequence* of distinct chords uses `+` per element: `["ctrl+l","enter"]`. The reply echoes `chords` showing how ab interpreted your `keys`. - -- `desktop_flash_window` (v1.8.152+) -- Flash an **arbitrary** window's taskbar button orange (Win32 `FlashWindowEx`) as an "attention / ongoing activity" cue. Resolve the HWND with `desktop_list_windows` first. Args: `hwnd` (int) OR `titleContains` (string); `mode` (`"until_focused"` default — flashes until the user clicks the window; or `"count"`); `count` (int, `mode:"count"` only, default 3); `stop` (bool — clear an active flash). Returns `{ok, hwnd, title, mode}`. **Lightweight by design** (direct FlashWindowEx, no process spawn) so a bridge can call it after every verb. Distinct from the puppeteer bridge's `pup_alert_window`, which only flashes pup's OWN tracked windows — this flashes any window on the host. For progress + overlay-icon status, use `desktop_taskbar`. - - Example: `adom-bridge desktop_flash_window '{"hwnd":133560}'` then `adom-bridge desktop_flash_window '{"hwnd":133560,"stop":true}'` to clear. -- `desktop_taskbar` (v1.8.152+) -- Paint **glanceable status** on any window's taskbar button via `ITaskbarList3` (progress bar + overlay icon) + `FlashWindowEx`, so a bridge can show what the AI is doing on the **real app window it drives** (e.g. native-browser putting an "Adom is driving" badge + task progress on the Chrome/Edge button). Works on windows owned by **other processes**. Persistent COM (init once, reuse) — safe to tick progress frequently; no process spawn. Resolve the HWND via `desktop_list_windows`. Args (all channels optional; provide ≥1): - - `hwnd` (int) OR `titleContains` (string) - - `flash`: `"until_focused"` | `"stop"` - - `progress`: `{state: "normal"|"paused"|"error"|"indeterminate"|"none", value?: 0..100}` — **state→color**: normal=green, **paused=yellow** (use for human-in-the-loop gates: captcha / 2FA / login), error=red, indeterminate=marquee, none=clear. - - `overlay`: `{badge: "adom"|"none", icon?: "<PNG or .ico — file path OR base64>", tooltip?: "<hover>", avatar?, profileDir?, userDataDir?, composite?}` — built-in badges are `adom` (the Adom logo — a "driven by Adom" trust tell) and `none` (clear); 3rd-party bridges pass their **own** `icon` (a **PNG** or `.ico`, file path or base64 — ab magic-sniffs + renders at 32px). ab stays generic: it paints whatever art you hand it. `tooltip` is the overlay's accessible description. - - **Compositing onto a Chrome/Edge profile avatar (v1.9.110, #154):** Windows gives ONE overlay slot per taskbar button, and Chrome/Edge use it for the **profile avatar** — so a bare `badge:"adom"` would EVICT the profile face (you'd lose "work vs personal"). To keep the avatar AND show "Adom is driving", pass the avatar and ab composites the mark onto it (bottom-right ~47%): `overlay:{badge:"adom", avatar:"<PNG path or base64>"}`, or name the profile with `overlay:{badge:"adom", profileDir:"Profile 1", userDataDir?:"<User Data root>"}` (ab reads `<UserData>/<profileDir>/Google Profile Picture.png`; `userDataDir` is derived from the window's exe if omitted). `composite:false` opts out (raw mark). If the avatar can't be resolved ab falls back to the plain mark — never errors. The CALLER (which knows its profile — e.g. the browser extension) supplies the avatar; ab does not reverse-engineer it (one browser process serves all profiles, so the process command line has no `--profile-directory`). - - `thumbnailTooltip` (v1.9.153): the taskbar **hover-preview tooltip** (`ITaskbarList3::SetThumbnailTooltip`). The ONLY uncapped text surface on the preview — the window title truncates ~25-30 chars, this string does not (and takes newlines). e.g. `"Adom Pup / Adom wiki / LOGGED-IN view / Chrome for Testing / 3 tabs"`. Pass `""` to clear it back to the title. Works on a window ab does not own. - - `thumbnailClip` (v1.9.153): crop the live hover preview to a client-space region — `{x,y,w,h}`, or `null` to reset to the whole window (`SetThumbnailClip`). - - Returns `{ok, hwnd, title, applied:[...]}`. Clears: `flash:"stop"`, `progress.state:"none"`, `overlay.badge:"none"`, `thumbnailTooltip:""`, `thumbnailClip:null` — call these on task-end / window-close to clean up. - - Example (long task, human gate, then done): - ```bash - adom-bridge desktop_taskbar '{"hwnd":133560,"overlay":{"badge":"adom","tooltip":"Adom is driving this browser"},"progress":{"state":"indeterminate"}}' - adom-bridge desktop_taskbar '{"hwnd":133560,"progress":{"state":"paused"},"flash":"until_focused"}' # captcha — come look - adom-bridge desktop_taskbar '{"hwnd":133560,"progress":{"state":"none"},"overlay":{"badge":"none"},"flash":"stop"}' # done — clear all - ``` -- `desktop_set_window_identity` + `desktop_register_app_identity` (v1.9.134+) -- **Full window-identity takeover.** Make a window you drive present itself as YOUR app: the taskbar button, Alt-Tab entry, hover tooltip and any user pin read your name and icon instead of the process that happens to own the HWND. Built for a bridge driving a borrowed host app (a Chrome for Testing window otherwise reads "Test" with Chrome's icon, which is two brands and a confusing word). ab is generic here — it applies whatever `appId` / `iconPath` / `displayName` you pass and knows nothing about which app that is. - - **You need BOTH halves.** `set_window_identity` stamps a LIVE window (`PKEY_AppUserModel_ID` + `WM_SETICON`); `register_app_identity` teaches Windows what that `appId` MEANS (an HKCU `AppUserModelId` key + a Start Menu shortcut carrying the same AUMID). An unregistered appId falls back to the owning exe's icon, so half one alone leaves Alt-Tab wrong. - - ```bash - # Once per install. Ship the .ico in your bridge's release zip; pass its cache path. - # `target`/`args` are what a user's PINNED tile launches — point it at a real verb so - # the pin opens a managed window instead of an orphan app. - adom-bridge desktop_register_app_identity '{"appId":"Adom.Pup","displayName":"Adom Pup", - "iconPath":"C:/.../adom-pup.ico", - "args":"pup_open_window {\"sessionId\":\"pinned\",\"owner\":\"pinned-launch\"}"}' - - # Per window, e.g. at park time. cacheKey survives page-title churn. - adom-bridge desktop_set_window_identity '{"cacheKey":"<sessionId>", - "titleContains":"(session: <sessionId>)","appId":"Adom.Pup", - "displayName":"Adom Pup","iconPath":"C:/.../adom-pup.ico"}' - ``` - - Window target: `hwnd` OR `titleContains` OR `cacheKey` (+ `fresh` to re-resolve). Response carries `resolvedBy` (`hwnd` | `cachedHwnd` | `title`) and `applied[]`. - - **Identity is per-HWND and dies with the window** — re-apply after a window is recreated. - - ab holds the icon handles for its whole process lifetime. Icon handles are process-owned: a transient setter leaves windows pointing at dead handles, so the icon reverts moments later. ab being long-lived is exactly why it owns them. - - `register_app_identity` writes HKCU only (never a UAC prompt). It is gated for RELAY callers like `write_file`; a bridge calling over the loopback API is trusted by transport and ungated. - - **Jump-list tasks (v1.9.139+): `desktop_set_window_jumplist {appId, tasks}`** puts YOUR actions on the taskbar right-click menu of every window stamped with that appId. Jump lists attach to the AUMID, so per-session appIds give each window its OWN menu. Each task: `{title (required), target? (exe, defaults to ab's bundled CLI — so `args` can simply be an ab/bridge verb + JSON), args?, iconPath?, description?}`. Tasks launch minimized (no console flash). Max 16; `tasks:[]` clears (call at session close, alongside `unregister_app_identity`). Re-set anytime to update the menu. Tier-2 gated for relay callers; loopback bridges ungated. - - **Per-session / transient appIds (v1.9.136+): register REGISTRY-ONLY with `shortcut:false`, unregister on close.** If you stamp per-session AUMIDs (e.g. `Adom.Pup.<sessionId>` so each window keeps its own taskbar button in split mode), an UNREGISTERED AUMID makes Alt-Tab fall back to the owning exe's icon. `desktop_register_app_identity {appId, displayName, iconPath, shortcut:false}` writes just the HKCU AppUserModelId key (DisplayName + IconUri) — Alt-Tab and taskbar hover resolve the name + icon with NO Start Menu entry. Pair with `desktop_unregister_app_identity {appId}` at session close (idempotent; removes the key plus any shortcut carrying that AUMID; ab's own `inc.adom.bridge` identity is protected). Keep ONE shortcut-mode registration as the pinnable launcher. **Known cost (wiki #155, measured): without a visible Start Menu shortcut carrying the AUMID the shell resolves it lazily on the first right-click, so the jump list flashes and only the second click stays.** `anchor:true` (opt-in, default off since 2.1.49) writes a small visible anchor `.lnk` inside one Start Menu subfolder (`anchorFolder`, default "Adom Bridge"; pass it on unregister too) that fixes it, but it is a real Start Menu entry (All apps, "Recently added", search), and a hidden `.lnk`, `NoStartPage` or `SHAddToRecentDocs` do not anchor. John's default: a clean Start Menu. - - **`desktop_set_window_jumplist`** (v1.9.139+) adds the right-click menu on the taskbar button (per-AUMID; `tasks:[]` clears) and, with `hwnd`+`headerIcon`, brands the header row (the app tile above Pin/Close). - - **Icon format — read the `iconCheck` these verbs return (v1.9.152).** `desktop_set_window_identity` / `desktop_register_app_identity` / `desktop_set_window_jumplist` return an `iconCheck {kind,sizes,issues,ok,hint}` (or `headerIconCheck`) that magic-sniffs the file and explains why an icon would render as a generic white document: a **256px `.ico` size not PNG-compressed** (the Vista+ trap), **SVG** (the Win32 shell can't load it), a `.ico` with no small size, an `.exe` where an image is wanted. If `iconCheck.ok` is false, fix the file and re-stamp. Ship a multi-size `.ico` (16/24/32/48/256, the 256 PNG-compressed). **Full guide:** the `adom-bridge-window-identity` skill (AUMID model, the register+stamp two-halves rule, jump-list header, taskbar overlay + thumbnail, the live-tile trap, a recipe). -- `desktop_revoke_approvals` -- Revoke all shell auto-approve permissions and deny pending approvals. The next shell command will show the approval dialog. - - Example: `adom-bridge desktop_revoke_approvals` -- `desktop_demo_panel` -- Floaty glass BACK / PLAY-PAUSE / NEXT / MUTE transport for a guided demo the USER paces (v2.1.0+, mute/callback v2.1.17+). `show` puts it up (reason required; same-id shows persist omitted fields so a stage advance never un-pauses), `poll` between beats drains the user's presses (honor paused / next / back / mute / stop; `muted` rides every reply -- silence your narration audio while it is true), `hide` when the show ends. A LOCAL caller (ah thread, bridge) may pass `eventCallbackUrl` (loopback http) to get every press POSTed instantly; remote callers pace with poll alone. A press never executes anything -- it only queues an event for you. **The full demo recipe (panel + captions + cursor, stop design, the pacing loop) is the `adom-bridge-demo-kit` skill -- read it before building a demo.** -- `desktop_caption` -- Show a global caption overlay that stays visible above ALL windows (KiCad, Fusion, Chrome). Native Win32 overlay — not a browser DOM element. Click-through: mouse events pass to windows below. Captured by screen recordings (browser `getDisplayMedia`, OBS, Game Bar). - - Show: `adom-bridge desktop_caption '{"text":"Step 1: Opening the board","position":"top","size":"large","duration":3000}'` - - Custom position: `adom-bridge desktop_caption '{"text":"Look here","x":0.3,"y":0.2,"size":"large","persist":true}'` - - Hide: `adom-bridge desktop_caption '{"action":"hide"}'` - - **Every caption carries a close box (X) and, when you gave a `reason`, a line naming your thread.** Both are the user's protection: the X is their only way to dismiss a caption without an AI, and the attribution answers "which of my ~20 threads is drawing on my screen". - - **`clean:true` removes BOTH, and it is deliberately hard to get — treat it as rare.** It exists for one case: the user is recording a demo video and explicitly asked for a caption with no chrome. It requires `userAsked:true` AND a `cleanReason` (>=30 chars) saying what they asked for, it is **refused outright** with `persist:true`, and its duration is capped at **2 minutes** (re-issue the same id to extend while you are supervising). - - **Do not set `clean:true` because the caption looks better without the X.** The user cannot dismiss a clean caption at all, so if it lands over something they need, their only recourse is to find the AI that put it there. If you cannot point to the user asking for a chrome-free caption, the answer is no. A normal caption looks the same in a recording apart from a small X. - - List what's on screen (v1.9.217): `adom-bridge desktop_caption '{"action":"list"}'` — returns `{captions:[{id,ageMs,expiresInMs,persist,overdue}]}`; the authoritative check (caption overlays do NOT appear in `desktop_list_windows`). TTLs are fail-safe: a 15s reaper closes any caption that outlives its deadline. Do not keep a caption alive with a same-id refresh loop as a nag — the user experiences one endless caption. - - Multiple simultaneous captions (use `id` to keep them independent): - ``` - adom-bridge desktop_caption '{"text":"Step 1: Opening board","id":"step","position":"center","size":"large","persist":true}' - adom-bridge desktop_caption '{"text":"REC ●","id":"status","position":"bottom-left","size":"medium","persist":true}' - ``` - - Replace only the step caption (status stays): - ``` - adom-bridge desktop_caption '{"text":"Step 2: Routing","id":"step","position":"center","size":"large","persist":true}' - ``` - - Hide just the step caption: `adom-bridge desktop_caption '{"action":"hide","id":"step"}'` - - Args: - - `text` (string) — caption text - - `id` (string, optional) — identifies this caption. Captions with the same id replace each other; different ids coexist simultaneously. Default `"_default"` — so bare calls without id still replace each other for backward compat. - - `position` — preset: `"top"`, `"bottom"` (default), `"center"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"` - - `x` (float 0.0–1.0) — normalized screen X, overrides position horizontal. 0.0 = left edge, 1.0 = right edge. Centers the caption box on this point. - - `y` (float 0.0–1.0) — normalized screen Y, overrides position vertical. 0.0 = top edge, 1.0 = bottom edge. - - `size` — preset: `"large"` (72px), `"medium"` (32px, default), `"small"` (20px) - - `fontSize` (int) — custom font size in px (8–400). Overrides `size` preset when provided. - - `duration` (ms) — auto-dismiss timer. **Omitted or `0` → `30000` (30s); clamped to max `600000` (10 min).** Captions ALWAYS auto-expire (v1.8.138+) so a forgotten one can't be stranded on the user's desktop. To keep one up longer, **re-issue the same `id`** (resets the timer) or pass a bigger `duration`. - - `persist` (bool, optional) — `true` = sticky, NO auto-dismiss. Supervised use only (e.g. demo recordings); you MUST then dismiss it yourself with `action:"hide"`. This is the only way to a never-expiring caption (`duration:0` no longer does that). - - `action` — `"hide"` to dismiss a caption (with `id`: hides only that caption; without `id`: hides all captions), `"force-clear"` to EnumWindows and destroy ALL caption windows regardless of id (nuclear option — use at top of demo scripts for guaranteed clean slate) - - Captions with the same `id` replace each other instantly (previous destroyed, no fade overlap). Captions with different ids coexist — multiple captions can be visible simultaneously. - -### Desktop Screenshots - -Take screenshots of the user's desktop or individual windows. All screenshots use lossless PNG. - -**Always save screenshots** to `project-content/screenshots/`. - -**Naming convention:** `desktop-<descriptive-name>-YYYY-MM-DD-HhMMam/pm.png` - -**Workflow:** Call `desktop_list_windows` first to get HWNDs, then `desktop_screenshot_window` with the HWND. - -- `desktop_list_windows` -- List all visible windows (returns HWND, title, class name, position/size, and `z` = Z-order index, 0 = topmost) -- `desktop_find_window` (v1.8.153+) -- Resolve a window's HWND by `titleContains` and/or `className` (case-insensitive substrings), returned in Z-order with `best` = the topmost match. Use this instead of diffing `desktop_list_windows` before/after launching something — it's race-free for "the window I just opened." Returns `{ok, count, best:{hwnd,title,className,z,rect}|null, windows[]}` (`ok` true only when ≥1 matched). Pass `best.hwnd` to `desktop_taskbar` / `desktop_flash_window` / `desktop_screenshot_window`. - - Example: `adom-bridge desktop_find_window '{"className":"Chrome_WidgetWin_1","titleContains":"GitHub"}'` -- `desktop_activity_filter` (v1.8.155+) -- Scope the **user's GUI activity log** so they watch the slice you care about while you work (e.g. focus it on one bridge before a debugging session). Args: `bridge` (a bridge registry name from `bridge_list` | `"none"` = only global/non-bridge ab commands | `"all"`/omit = everything), `text` (free-text substring, ANDs with bridge), `clear` (bool — reset). **Drives the human's GUI view only — it does NOT change which verbs you can call or what they return.** The GUI shows a status bar stating exactly what's shown + what's hidden. - - Example: `adom-bridge desktop_activity_filter '{"bridge":"kicad"}'` then `adom-bridge desktop_activity_filter '{"clear":true}'` -- `desktop_screenshot_window` -- Capture a specific window by HWND -- `desktop_screenshot_screen` -- Capture the entire desktop (all monitors) -- `desktop_ad_scroll` (v1.9.86+) -- **Scroll ab's OWN window panels.** ab is a fully AI-controllable app, so you can drive its own scrollbars — most usefully to page the **bridges sidebar** and screenshot cards that are below the fold. Args: `panel` (`bridges` default | `activity` | `connections`), `to` (`top`|`bottom`, absolute — wins over `dy`), `dy` (relative px, default 400; negative = up). Returns `{ok, panel, scrolled, state:{scrollTop,scrollHeight,clientHeight,atTop,atBottom,found}|null, _hint}` — round-tripped from the live window so you can **loop until `state.atBottom==true`**. A collapsed bridges sidebar is auto-expanded first; `state==null` means the panel didn't report (collapsed/empty). - - Capture-all-cards workflow: `desktop_ad_scroll '{"panel":"bridges","to":"top"}'` → `desktop_screenshot_window` (ab hwnd) → `desktop_ad_scroll '{"panel":"bridges"}'` → screenshot → repeat until `atBottom`. - -### App self-update (relay-driven, v1.9.87+) - -ab auto-updates itself from the wiki manifest, but historically only a HUMAN clicking the footer "Install now" banner could apply it — so a headless / RDP / fleet box (a fresh Azure VM) could sit a version behind with no path forward from the relay. These verbs give the AI that path (same shape as the shell-approval surfacing verbs). Use `--target <box>`. - -- `update_status` (read-only, ungated) -- `{currentVersion, latestVersion, updateAvailable, updateReady, state, busy, updateMode}`. `updateReady:true` = the newer version is downloaded + SHA-verified on disk, appliable with no network. -- `update_check` -- force a manifest check now; if newer, download + verify (stage) it in the background. Poll `update_status` until `updateReady:true`. -- `apply_update` -- the programmatic "Install now": apply the staged update + relaunch ab. Respects `busy` (refuses if a command is in flight). Only ever applies ab's own downloaded + SHA-verified SIGNED wiki release. Returns `{applying:true}` then ab restarts (the relay drops + reconnects; poll `targets`/`ping` for the new version). If not staged yet, it kicks staging and tells you to poll + retry. -- `request_update_approval` -- surface the "Update available — Install now / Later" banner on the target + bring ab to the front (for an ATTENDED box where the human should decide). Does NOT apply; `apply_update` installs. -- `update_set_mode {mode: auto|prompt|off}` -- the auto-apply POLICY. `auto` = unattended stage+apply on the 4h/on-launch poll (set this ONCE on headless fleet boxes and they self-update every release with no relay call); `prompt` = show the banner (default, attended); `off` = no checks. - - Fleet flow: `update_status --target <box>` → if `updateAvailable` and unattended, `apply_update --target <box>` (or set `update_set_mode {"mode":"auto"}` once). A box must already be on ≥1.9.87 to have these verbs; the one-time bootstrap of an older box still needs a human click OR `auto` mode. - -### Hydrogen companion mode (v1.9.218+) - -When a HUMAN opens ab on a machine where Hydrogen is detected (embedded marker, Hydrogen's ports.json, or a discoverable Hydrogen install), ab shows an identity overlay ("This is Adom Bridge" + a large Open Hydrogen button), instantly opens or foregrounds Hydrogen, and places its own window directly below Hydrogen's in z-order — most people who open ab by hand were looking for Hydrogen. Facts an AI should know: - -- Human-open detection = bare argv AND parent process `explorer.exe` (double-click, Start menu, search, pins). Machine launches — `--autostart` boot, Hydrogen `--embedded` respawns, the relauncher/`restart` verb, CLI-style invocations — NEVER trigger it, so a remote `restart` cannot bounce the user's screen. -- One-shot placement: ab does not fight the user's later alt-tabbing, and it is NOT a real Win32 child window (ab's lifecycle stays independent — the recovery-channel contract holds; if Hydrogen cannot be launched the overlay says so and ab stays fully usable). -- Toggle: Settings > Hydrogen > "Act as a companion to Hydrogen", or `settings_set {"settings":{"hd_child_mode":false}}`. Default ON (self-gates on Hydrogen detection, so standalone installs never see it). -- Forensics: every decision slogs `ah-COMPANION:` lines to startup.log. - -### User settings over the API (`settings_get` / `settings_set`, v1.9.214+) - -ab's Settings-panel preferences are readable and settable over the API. This is the sanctioned channel for an embedding app (Hydrogen) to render ab's settings in its own panel and forward the user's changes; it also lets a fleet operator read or fix a box's boot/update prefs remotely. - -```bash -adom-bridge settings_get -adom-bridge settings_set '{"settings": {"launch_on_boot": true, "update_mode": "auto"}}' -``` - -- Settable keys (whitelist): `launch_on_boot`, `always_show_window_on_launch`, `update_mode` (auto|prompt|off), `auto_restart_for_updates`, `update_idle_minutes` (1..1440), `prewarm_runtimes`, `prewarm_cft` (auto|always|never), `file_receive_directory`. Anything else lands in `rejected` — servers, shell approval (grants are human-only), and blocked peers have their own surfaces and are deliberately not settable here. -- `launch_on_boot` changes apply the OS Startup artifact immediately; `settings_get` also returns `autostartArtifactPresent` so you can see intent-vs-artifact drift. -- `settings_set` is Tier-2 gated for relay (cloud-AI) callers — carry a `reason` and expect the human-approval flow, like `shell_execute`. Local/loopback callers (Hydrogen, bridges, the local CLI) are trusted by transport. -- Self-heal: ab re-verifies the launch-on-boot artifact every ~60s while running (not just at launch), so anything that deletes the Startup shortcut behind ab's back gets healed within a minute — with a `RECONCILE:` forensic line in `startup.log`. - -### Browser automation (`pup_*`, Puppeteer/pup) — bridge-owned; light pointer - -The pup bridge is **cloud-owned** and ships its OWN full skill (its wiki page / skill pkg — `bridge_list` names it; `adom-bridge sync_skills` installs it). This page keeps only the pointer plus first-touch examples; sessions/profiles, credential vault, downloads, recording, CDP details are the bridge's skill. - -Its verbs: `adom-bridge commands '{"bridge":"puppeteer"}'`. - -`bridge_readiness {"name":"puppeteer"}` is the read-only probe (never spawns or downloads). Full details: the bridge's own skill via `sync_skills`. - -### Screen recording — ab's desktop recorder (tab recording is pup's) - -ab owns DESKTOP recording: a visible HUD opens with the REASON you supply, so the user always sees that recording is on and why. Full guide: the `adom-bridge-recording` skill. - -```bash -adom-bridge desktop_recorder_open '{"reason": "record the DRC walkthrough for review"}' -adom-bridge desktop_record_start '{"confirmDesktopNotTabRecording": true}' -# ... drive the desktop ... -adom-bridge desktop_record_stop '{}' -adom-bridge desktop_record_list '{}' -adom-bridge desktop_recorder_close '{}' -``` - -`desktop_record_start` without `confirmDesktopNotTabRecording: true` refuses with `errorCode: desktop_record_needs_confirmation` — that guard exists because recording a BROWSER TAB is the pup bridge's separate feature (`pup_record_start` etc., see pup's own skill). - -### Filesystem primitives — `desktop_list_files` / `desktop_watch_files` / `desktop_pull_glob` (v1.5.0+) - -**These are the canonical "wait for a download" primitives.** They replace the `shell_execute` + PowerShell-poll-Downloads pattern that earlier callers (chip-fetcher, similar tools) had to use. No shell, no user approval prompt, no parsing of `dir` output. They run pure Rust on the desktop side, so they're fast, predictable, and never trip over PowerShell quoting. - -The pattern they replace looks like this: - -```powershell -# OLD — DON'T do this for new code: -$before = (Get-Date).ToFileTime() -# … click download … -while (-not (Get-ChildItem ~/Downloads -Filter ul_*.zip | Where { $_.LastWriteTime.ToFileTime() -gt $before })) { - Start-Sleep -Seconds 1 -} -$file = Get-ChildItem ~/Downloads -Filter ul_*.zip | Sort LastWriteTime -Desc | Select -First 1 -``` - -becomes one call: - -```bash -# NEW — single primitive, no shell: -adom-bridge desktop_watch_files '{ - "path": "%USERPROFILE%\\Downloads", - "glob": "ul_*.zip", - "timeoutMs": 60000 -}' -# → {ok:true, file:{path,name,size,mtime}, elapsedMs} on success -# → {ok:false, error:"timeout", elapsedMs, _hint, ...} on timeout -``` - -#### `desktop_list_files` — one-shot directory listing - -Lists files in a directory (non-recursive) matching a shell-glob, optionally filtered to those modified after a given timestamp. - -- Args: `path` (Windows abs path; `~` / `%USERPROFILE%` / `%APPDATA%` / `%LOCALAPPDATA%` / `%TEMP%` are expanded), `glob` (default `*`; shell-style with `*` and `?`; case-insensitive on Windows), `modifiedSince` (optional unix-seconds OR ISO-8601 string). -- Returns: `{ok, path, glob, files: [{path, name, size, mtime}, ...] sorted newest-first, count}`. -- Does NOT recurse. Lists one directory only. - -#### `desktop_watch_files` — block until a match arrives - -Polls the directory every `pollMs` (default 1000) until at least one file matches the glob with `mtime > since`, or `timeoutMs` (default 60000, max 600000 = 10 min) elapses. - -- Args: same as `desktop_list_files` plus `since` (defaults to **now** — without an explicit value, only NEW files report), `timeoutMs`, `pollMs`. -- Returns on match: `{ok:true, file:{path,name,size,mtime}, elapsedMs}`. -- Returns on timeout: `{ok:false, error:"timeout", elapsedMs, _hint, path, glob}`. -- The `since`-defaults-to-now behavior matches the canonical "click then watch" flow. If you might miss the file by starting the watch slightly late (e.g. fast small downloads), record `$(date +%s)` BEFORE the click and pass it as `since`. - -#### `desktop_pull_glob` — one-shot orchestrator (recommended for download flows) - -Composes `desktop_list_files` (or `desktop_watch_files` if `wait:true`) with the existing streaming `pull_file` mechanism. The whole "wait for a download then pull it" flow in one call: - -```bash -BEFORE=$(date +%s) -# … trigger the download click (a browser bridge verb, or desktop_ui_click) … -adom-bridge desktop_pull_glob "{ - \"path\": \"%USERPROFILE%\\\\Downloads\", - \"glob\": \"ul_*.zip\", - \"since\": $BEFORE, - \"wait\": true, - \"timeoutMs\": 60000, - \"saveTo\": \"/tmp/cse-out\" -}" -# → {ok:true, files:[{name,path,size,sha256,chunks}, ...], errors:[], matchedCount} -``` - -- When `wait:true`: blocks via `desktop_watch_files` until at least one match appears, then re-lists the directory and pulls **everything matching** (so a `.crdownload` + final `.zip` written in the same poll tick both come along). -- When `wait:false` (default): just lists what's there now and pulls those. -- Pulls use the existing `pull_file` streaming pipeline — sha256-verified, ~1MB chunks, resumes the same way `pull_file` does. - -#### Glob semantics - -- `*` matches any run of non-separator chars (within a single filename — these primitives don't recurse). -- `?` matches a single non-separator char. -- Case-insensitive on Windows (matches the filesystem); case-sensitive on macOS/Linux. -- Examples: `ul_*.zip`, `LIB_*.zip`, `*.pdf`, `datasheet_*.pdf`, `*.step`, `*.STEP` (same on Windows). - -#### Path expansion - -- `~` and `~/foo` → home dir (Linux/macOS convention; works on Windows too). -- `%USERPROFILE%` → home dir (Windows; also works cross-platform). -- `%APPDATA%` → Windows roaming app data (`dirs::config_dir()` on others). -- `%LOCALAPPDATA%` → Windows local app data (`dirs::data_local_dir()` on others). -- `%TEMP%` → Windows TEMP env var; `/tmp` on Linux/macOS. -- Expansion is case-insensitive: `%userprofile%` works the same as `%USERPROFILE%`. -- Forward slashes (`/`) and back slashes (`\`) both work in the path string. - -#### Time format for `since` / `modifiedSince` - -- Number: unix seconds (e.g. `1746483600`). Floats accepted. -- String of digits: same (e.g. `"1746483600"`). -- ISO-8601 / RFC-3339 string: e.g. `"2026-05-04T22:30:00Z"` or with offset `"2026-05-04T15:30:00-07:00"`. -- Files with `mtime <= since` are filtered out (strict `>`). - -### Hydrogen (`hd_*`) — proxy + build/lifecycle for the sibling Tauri app - -When the user has **Hydrogen** running (a sibling Tauri v2 app at `C:\Github\hydrogen-desktop`), built-in `hd_*` verbs reach into it. NOT a separate bridge process — adom-bridge proxies HTTP calls directly to Hydrogen's control API. - -**Port discovery (v1.8.72+):** Hydrogen's control API lives on **port `47084`** (default; the actual port is read from `%APPDATA%\hydrogen-desktop\ports.json` key `control` on every `hd_api` call, fallback 47084). Pre-v1.8.72 the verbs were hardcoded to Hydrogen's legacy `:9001` webview-eval port, which Hydrogen doesn't bind anymore — that's why `hd_status` reporting "Hydrogen not running" while Hydrogen's window is visible is the upgrade signal. - -#### The canonical generic verb — `hd_api` (v1.8.72+) - -```bash -adom-bridge hd_api '{"method":"GET","path":"/health"}' -adom-bridge hd_api '{"method":"POST","path":"/setup/run-step","body":{"id":"welcome"}}' -adom-bridge hd_api '{"method":"GET","path":"/wsl/status"}' -``` - -Generic HTTP pass-through to any Hydrogen endpoint. Raw JSON in `body` — no curl-through-the-relay quote mangling. Returns `{success, status, body, port, method, path}` mirroring Hydrogen's response. If Hydrogen isn't running, returns `{ok:false, error:"Hydrogen not reachable on :47084", _hint:"..."}` instead of timing out. - -**Public denylist** — these paths are refused with `{ok:false, error:"not_allowed_in_public", denylist_match:"..."}` on public ab builds (use the internal `hd-dev` cargo feature to bypass): - -``` -/wsl/unregister, /setup/virgin-reset, /container/destroy, -/container/reset, /workspace/reset, /workspace/destroy -``` - -#### Inspect + drive a running Hydrogen (v1.8.15+, post-v1.8.72 rework) - -| Verb | What | Status | -|---|---|---| -| `hd_status` | Composes ab-side facts (running, repo SHA) + Hydrogen's `/workspace/health` + `/buildinfo` relayed | ✓ shipped | -| `hd_api` | Generic HTTP pass-through (above) | ✓ v1.8.72+ | -| `hd_log '{"tail":30}'` | Tail `%APPDATA%\hydrogen-desktop\hydrogen-desktop.log` (reads disk — works EVEN when Hydrogen is down) | ✓ shipped | -| `hd_open_url '{"url":"...","browser":"chrome","profileDir":"Default"}'` | POST `/open-in-profile` — open URL in a specific browser profile | ✓ shipped | -| `hd_browser_profiles` | GET `/browser-profiles` — enumerate browsers + profiles | ✓ shipped | -| `hd_screenshot` / `hd_shot` | Find Hydrogen window, capture lossless PNG via PrintWindow; `hd_shot` adds region-scoped variant | ✓ shipped | -| `hd_eval '{"js":"..."}'` | POST `/eval` — run JS in Hydrogen's main webview | ⚠ **Moved to `hd-dev` in v1.8.72.** Public returns `verb_moved_to_hd_dev`. Use `hd_api '{"method":"POST","path":"/eval","body":{"js":"..."}}'` | -| `hd_iframe_eval '{"js":"...","contextIndex":0}'` | POST `/iframe-eval` — JS in code-server iframe via CDP | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent | -| `hd_reload_vscode` | POST `/reload-vscode` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent | -| `hd_container_exec '{"command":"..."}'` | POST `/container-exec` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent | -| `hd_launch` / `hd_stop` / `hd_restart` | Hydrogen process lifecycle | ✓ shipped | -| `hd_ship` | Atomic build+relaunch+verify-SHA (see below) | ✓ shipped | -| `hd_build*` (v1.8.16+) | Build pipeline orchestration | ⚠ Moved to `hd-dev` in v1.8.72 (Hydrogen developers only) | - -#### Atomic ship — `hd_ship` (v1.8.49+) — START HERE for post-push rebuilds - -**One verb, one call, one structured pass/fail.** Replaces the 4-step chain `hd_stop` → `hd_build_rust` → poll `hd_build_status` → `hd_launch` → `curl :47084/buildinfo`. Eliminates the "is the new code actually running?" guesswork after every push. - -Sequence inside one call: -1. taskkill Hydrogen + poll until zero instances (frees the exe lock) -2. `git fetch && git reset --hard origin/main && rev-parse HEAD` — asserts `HEAD == expectSha` (fails fast if GitHub hasn't replicated the push yet) -3. if `clean:true`, `cargo clean -p hydrogen-desktop` (defeats stale-relink) -4. `cargo build` and parse stderr for `Compiling hydrogen-desktop` to know whether anything actually compiled -5. launch + poll `http://127.0.0.1:47084/buildinfo` until it responds + assert `build_sha == expectSha` - -```bash -# End-to-end after a push: -git push && SHA=$(git rev-parse HEAD) && \ - adom-bridge hd_ship "{\"expectSha\":\"$SHA\"}" -``` - -Args: -- `expectSha` — **REQUIRED** string. Full or prefix SHA of the commit you want running. Bidirectional-prefix match (`abc123` matches the full SHA starting with `abc123` and vice versa). -- `clean` — optional bool, default `false`. `true` forces `cargo clean -p hydrogen-desktop` before build. Use when a prior `hd_ship` succeeded but `runningSha != expectSha` (the stale-relink trap — cargo said `Finished` but reused the cached binary). - -Returns on success: `{ok:true, builtSha (== expectSha), runningSha (== expectSha), compiled:bool, elapsedSecs, _hint}`. `compiled:false` on success means cargo found nothing to recompile (cached relink or HEAD didn't actually change — usually fine but surface to caller in case the push was a no-op). - -Returns on failure: `{ok:false, failedAt:'args'|'kill'|'git'|'clean'|'build'|'launch'|'verify', error, builtSha (set after git step), runningSha (set if launch worked but verify saw the wrong SHA), compiled (set after build step), elapsedSecs, _hint}`. The partials let you reason about WHERE it broke: - -| `failedAt` | What it means | -|---|---| -| `args` | `expectSha` missing / malformed | -| `kill` | Couldn't taskkill Hydrogen (rare — usually permissions) | -| `git` | `git fetch / reset` failed, OR `HEAD != expectSha` after reset (push hasn't replicated to origin/main yet — retry in a few seconds) | -| `clean` | `cargo clean` failed (rare) | -| `build` | `cargo build` failed — read `_hint` for next step (usually `hd_build_log` to see the compile error) | -| `launch` | Cargo finished but `hydrogen-desktop.exe` wouldn't start — `binary_missing` or process spawn error | -| `verify` | Hydrogen launched but `/buildinfo` either never responded or reported a different SHA than `expectSha` (the stale-relink trap — retry with `clean:true`) | - -Long-running: cold cargo build can take 2-3 min. CLI WS timeout is 12 min — call as a single sync invocation and wait. Don't poll inside `hd_ship`; it does its own internal polling. - -#### Lower-level build + lifecycle suite (v1.8.16+, enriched in v1.8.52+) - -Use these directly only when you need finer control than `hd_ship` provides (e.g. frontend-only build, watching log stream live, manual launch sequencing). For the common "I pushed code, get it running" path use `hd_ship`. - -The relay's `shell_execute` has a 30s timeout; Hydrogen's `pnpm build` + `cargo build` exceed that, so the build is split into async-spawn verbs + sync poll/tail verbs: - -```bash -# Frontend-only rebuild, watch progress, relaunch (no hd_ship since hd_ship is Rust-build-only) -adom-bridge hd_stop -adom-bridge hd_build_frontend '{"show":false}' # returns {ok, pid, logPath} immediately -OFFSET=0 -while true; do - RESP=$(adom-bridge hd_build_tail "{\"offset\":$OFFSET}") - echo "$RESP" | jq -r '.lines[]' - DONE=$(echo "$RESP" | jq -r '.done') - OFFSET=$(echo "$RESP" | jq -r '.newOffset') - [[ "$DONE" == "true" ]] && break - sleep 3 -done -adom-bridge hd_build_status # confirm succeeded:true && relinkOnly:false -adom-bridge hd_launch # start the new debug binary -``` - -##### `hd_build` / `hd_build_frontend` / `hd_build_rust` — async-spawn build verbs - -| Verb | What | -|---|---| -| `hd_build '{"show":false}'` | Full async build: `git pull` + `pnpm build` + `cargo build`. Returns instantly with `{pid, logPath, flavor:'full'}`. Final log line is `BUILD_OK` or `BUILD_FAILED: <step>`. | -| `hd_build_frontend` | Just `pnpm build` (root). Returns `{pid, logPath, flavor:'frontend'}`. | -| `hd_build_rust` | Just `cargo build` (src-tauri/). **v1.8.52+:** dispatch response also carries `builtSha` + `exeMtimeBefore` + `exeSizeBefore` (captured at spawn time into `%TEMP%\hd-build-meta.json`) so the subsequent `hd_build_status` / `hd_launch` don't need a separate `git rev-parse` + `stat` call. Returns `{ok, pid, logPath, flavor:'rust', show, lingerSecs, builtSha, exeMtimeBefore, exeSizeBefore, _hint}`. | - -##### `hd_build_status` — sync state probe (v1.8.52+ enriched) - -Returns: -``` -{ - ok, building, succeeded, failed, - lastLines:[string], # last ~40 lines of stderr - logPath, pid, pidAlive, - failureReason, # 'compile_error' | 'exe_locked' | 'orphaned_no_sentinel' - compiledCrates:[string], # names parsed from 'Compiling X v...' lines - relinkOnly:bool, # true iff succeeded && compiledCrates is empty - exeChanged:bool, # mtime OR size after differs from before - exeMtimeBefore:int, exeMtimeAfter:int, # unix seconds - exeSizeBefore:int, exeSizeAfter:int, # bytes - exeLocked:bool, # true when build failed because hydrogen-desktop.exe was held - holderPids:[int], # PIDs holding the exe — taskkill these - builtSha:string|null, # SHA hd_build_rust/hd_build was dispatched at - elapsedSecs:int, - flavor:string, # 'rust' | 'frontend' | 'full' - _hint -} -``` - -**`relinkOnly:true` on success is the silent-fail trap to look for** — your push didn't actually rebuild because cargo found nothing to recompile. Usually means your source changes weren't pulled, or you touched a file the workspace doesn't track. Use `hd_ship` with `clean:true` to force a fresh build. - -**`exeLocked:true` on failure** + `holderPids` tells you exactly which PIDs to taskkill before retry; or just use `hd_ship` which kills Hydrogen before building. - -##### `hd_launch` — start the debug binary (v1.8.52+ enriched) - -Args: `killExisting` — optional bool, default `false`. When `false` and Hydrogen is already running, refuses with `reason:'already_running'` so you don't accidentally attach to a stale process. When `true`, taskkill the existing instance (either exe name) with a 600 ms grace period before launching fresh. - -`settleSeconds` — optional int, default `5`, max `30` (2.1.159). How long ab watches the new process before answering. `0` skips the extra wait. - -Returns on launch: `{ok:true, launched:true, stillRunning:true, observedForMs:int, wasAlreadyRunning:bool, killedFirst:bool, pid:int (Hydrogen's actual PID, not the cmd-wrapper), exePath, builtSha:string|null, _hint}`. - -Returns on refusal: `{ok:false, launched:false, wasAlreadyRunning:bool, pid:int|null, killedFirst:false, reason, _hint}`. `reason` ∈: -- `build_in_progress` — wait for `hd_build_status.building:false` -- `build_failed` — fix and retry `hd_build` (or use `hd_ship`) -- `already_running` — pass `killExisting:true` to force-kill+relaunch, or use `hd_restart` -- `binary_missing` — neither `target/debug/hydrogen.exe` nor the pre-rename `hydrogen-desktop.exe` is on disk; run `hd_build_rust` (or `hd_ship`) first - -Returns when the build starts and then dies inside the settle window (2.1.159): `{ok:false, launched:true, stillRunning:false, errorCode:'exited_after_launch', observedForMs, pid, exePath, builtSha, runningSha, narrative, _hint}`. The build crashes at startup: read `hd_log`. Re-running `hd_launch` repeats the same crash. Before 2.1.159 this case was reported as a successful launch, because the only process check ran 0.8 s after launch. - -**`launched:true, wasAlreadyRunning:false, killedFirst:false` is the proof you need that truly fresh code is running.** Any other combo means you should compare `builtSha` to Hydrogen's actual `/buildinfo.build_sha`. - -##### `hd_stop` / `hd_restart` / `hd_build_log` / `hd_build_tail` - -| Verb | What | -|---|---| -| `hd_stop` | Kills Hydrogen under either exe name (`hydrogen.exe`, or the pre-rename `hydrogen-desktop.exe`). `{wasRunning}` distinguishes killed vs no-op. | -| `hd_restart` | Stop + launch in one call. Same guards as `hd_launch` (skip `already_running`), and the same `settleSeconds` check: a build that dies at startup answers `errorCode:'exited_after_launch'`. | -| `hd_build_log` | Full log dump from `%TEMP%\hd-build.log`. | -| `hd_build_tail '{"offset":N}'` | Incremental stream — `{lines, newOffset, totalBytes, done, succeeded, _hint}`. Pass `newOffset` back for the next chunk. | - -The `show: true` arg on any build verb opens a visible PowerShell console window so the user can watch the build scroll. Default `show: false` runs hidden. - -**Auto-close (v1.8.23+).** Visible build windows auto-close 30 seconds after the final `BUILD_OK` / `BUILD_FAILED` line — long enough for the user to read the last error, short enough that build-after-build sessions don't accumulate orphan windows. The window prints a clear green/red "auto-closes in 30 seconds" banner before sleeping. Override with `lingerSecs`: -- `'{"show":true, "lingerSecs":60}'` — give yourself 60s instead of 30 -- `'{"show":true, "lingerSecs":0}'` — close immediately, no grace period -- `'{"show":true, "lingerSecs":3600}'` — keep open for an hour (cap) -The response includes the applied `lingerSecs` so you can confirm what landed. Hidden builds (`show:false`) ignore `lingerSecs` entirely — no window to linger. - -**Don't call `hd_launch` until `hd_build_status` shows `succeeded: true`** — the guard refuses with `reason: "build_failed"` if you do, and the `_hint` tells the AI exactly what to do (`hd_build_log` to see errors, fix, rebuild). Same for `hd_restart`. - -#### Named-region screenshots — `hd_shot` (v1.8.54+) - -**One verb replaces the `desktop_list_windows | grep 'Hydrogen' → desktop_screenshot_window <hwnd>` chain** (3 calls + fragile HWND parsing). Captures a named region of Hydrogen, produces BOTH a full-res PNG and a Claude-safe-resized copy, and returns rich metadata so the AI never has to chain screenshot → pull → convert → crop → resize → Read manually. - -Each shot is **self-identifying**: it includes the running build's SHA + local build time read from Hydrogen's `/buildinfo` so you can confirm you're looking at the build you expect before trusting what you see (no more "this screenshot was from the previous binary, not my fix" confusion). - -Regions are deterministic — they resolve via Hydrogen window bounds + CDP element rects queried through Hydrogen's `/eval` endpoint. No AI-supplied pixel coords. - -Args: -- `region` — **REQUIRED**. One of: - - `full` — whole Hydrogen window - - `titlebar` — top ~40 px strip (Hydrogen's build SHA / timestamp readout lives here) - - `vscode` — VS Code iframe pane via CDP element rect - - `setup-panel` — Setup Steps panel (`.setup-panel` / `.setup-panel-output`) - - `wiki` — wiki / browser tab pane - - `claude` — Claude Code panel -- `resizeMax` — optional int, default `1400`. Longest side of the safe-resized copy. Use ≈800 to fit more shots in one Claude turn; 1568 for max fidelity. - -Returns: -``` -{ - ok, region, - fullPath, # lossless raw PNG — for shotlog/archival, NOT Read - safePath, # ≤resizeMax PNG — pull_file this, Read this - fullSize:[w,h], safeSize:[w,h], - clipRect:[x,y,w,h], # captured-image pixels (NOT screen pixels) - capturedAtLocal, - runningBuildSha, # from Hydrogen's /buildinfo — null if Hydrogen doesn't expose it yet - runningBuildLocal, - hdWindowFound:bool, regionFound:bool, - windowOuter:[w,h], capturedSize:[w,h], - _hints: { - pull, # exact pull_file command for safePath - fullForArchive, # note that fullPath is for archive, not Read - staleWindow, # set when runningBuildSha looks wrong - regionMiss # set when regionFound:false - } -} -``` - -Recipe — verify-which-build-is-running + confirm-config-change-took-effect + full dump: - -```bash -# After hd_ship, confirm the titlebar shows the SHA you expect: -adom-bridge hd_shot '{"region":"titlebar"}' -# → runningBuildSha lets you assert this before trusting any other shot - -# After flipping a Setup Steps toggle, screenshot the panel to confirm: -adom-bridge hd_shot '{"region":"setup-panel","resizeMax":1568}' - -# Full-window dump for general debugging: -adom-bridge hd_shot '{"region":"full"}' -``` - -Edge cases: -- **Unknown region** — refusal: `{ok:false, error, knownRegions:[...]}`. No fallback — fix the call. -- **`regionFound:false`** — the element rect wasn't in Hydrogen's DOM. The panel/iframe may not be mounted yet, OR Hydrogen's markup changed and `region_selectors()` in `hd_bridge.rs` is out of date. The shot is **still produced** (falls back to the full window) so you have something to look at; check `_hints.regionMiss` for the reason. -- **`runningBuildSha:null`** — Hydrogen's `/buildinfo` isn't exposed yet on the running version. The shot is still produced; you just can't self-verify which build it represents. - -**Always pull_file the `safePath` and Read THAT** — it's already sized for Claude's image analysis. The `fullPath` is for shotlog / disk archival, NOT direct Read (may exceed Claude's per-image budget). The `_hints.pull` field gives you the exact `pull_file` command to copy-paste. - -**Old workaround to drop:** Do NOT use `desktop_list_windows | filter 'Hydrogen' | desktop_screenshot_window <hwnd>` anymore for Hydrogen — `hd_shot` replaces that entire pattern. Generic `desktop_screenshot_window` still applies to non-ah windows (other apps, dialogs, etc.). - -### TCP Port-forward Tunnels — `tunnel open|close|list|check|rdp` + `display` (v1.8.85+, reworked v1.8.147) - -Reach a TCP service addressable only from the **cloud side** through the **laptop side**: ab binds a `127.0.0.1:<port>` listener on the laptop and pumps bytes over the existing ab↔relay WebSocket; the relay dials the upstream `to`. First use case: RDP into a Windows VM behind an NSG only the cloud container reaches — mstsc on the laptop connects to a local port and is talking through the cloud. Same path works for SSH, VNC, HTTP, postgres — any TCP. - -**`tunnel` is a grouped verb** (`rdp` / `tunnel_close` / `tunnel_list` remain back-compat aliases): - -```bash -# Open a VERIFIED tunnel (both legs probed before ok). Auto-picks a free local port. -adom-bridge --target <name> tunnel open '{"to":"127.0.0.1:13389"}' - -# RDP: open + verify + launch mstsc, sized to the laptop's real screen. -adom-bridge --target <name> tunnel rdp '{"to":"127.0.0.1:13389","size":"90%"}' -# size: "90%" (default) | "100%" | "fullscreen" | "maximized" -# OR monitor:<index> (0=primary) OR explicit width+height - -adom-bridge --target <name> tunnel list # live tunnels + leak detection -adom-bridge --target <name> tunnel check '{"tunnelId":<id>}' # per-leg health -adom-bridge --target <name> tunnel close '{"tunnelId":<id>}' # or {"all":true} - -adom-bridge --target <name> display # monitor geometry (for sizing) -``` - -**`--target` is mandatory when >1 ab is connected.** v1.8.147 fixed a false-success bug: the bind, the upstream probe, and the mstsc launch now ALL resolve to the SAME machine. (Before, the bind could land on one ab and mstsc launch on another → "port bound" on the VM, "nothing listening" on the laptop, a bogus `ok:true`.) No target + multiple ABs → `ambiguous_target` listing the names; `--target all` is rejected (a tunnel binds one machine). `adom-bridge targets` lists connected ABs. - -**Active health verification — every `open`/`rdp`/`check` PROVES the path before reporting `ok`:** -- **local-bind** — ab self-probes `127.0.0.1:<boundPort>` right after binding. If it can't connect, ab tears the tunnel down and returns `ok:false` (this alone kills the false-success class). -- **upstream-refused** — the relay dials `to` (retry/backoff ~4×/2.5s). If unreachable, the tunnel is torn down (no leaked row) and `ok:false`. -- A failure names the leg: `failedLeg: "relay-to-ad" | "local-bind" | "upstream-refused"`. Health fields surfaced: `listening`, `relayConnected`, `upstreamReachable`, `connCount`, `mstscAlive`. - -**Progressive hints.** Every response carries `_hint` (what's true now) + `_next` (the exact next verb with real values, copy-pasteable). e.g. a successful open's `_next` hands you the `tunnel rdp` / `tunnel check` / `tunnel close` commands with the real `to`/`target`/`tunnelId` filled in. A missing `to` returns a structured error with an example. Walk the workflow from the responses alone. - -**Display-aware RDP sizing.** `tunnel rdp` resolves `size`/`monitor`/`width`/`height` against the laptop's real screen (queried via `display` — `EnumDisplayMonitors` + per-monitor DPI), writes an `.rdp` file with the geometry, and launches mstsc. e.g. `size:"90%"` on a 2560×1600 primary → a 2304×1440 window. Default 90% of primary. You never hardcode a resolution — call `display` if you want the layout first. - -**`tunnel list` reconciles + reaps leaks.** It cross-checks the relay registry against every connected ab's own registry and reports `leaks:[...]` for rows only one side knows about; `tunnel close '{"all":true}'` reaps them. The relay stores the actually-bound `localPort` (so `tunnel close '{"local":<port>}'` works). - -Architectural notes for Claude: -- Bytes-only — no payload parsing, so any TCP service works. TLS/cert-pinning still pins to whatever the cloud-side dial resolves (for RDP the upstream cert CN must match what mstsc expects). -- Each fresh laptop-side TCP connection gets its own connId multiplexed over one tunnelId; concurrent mstsc reconnects / parallel sessions work without re-opening. Backpressure bounded (256-slot mpsc/conn, 64 KiB buf). -- Tunnels don't survive ab/relay restarts. If mstsc disconnects, `tunnel check` (names the dead leg), then re-open. -- The classic RDP collision (Windows' own RDP owns `0.0.0.0:3389`) is dodged by `rdp`'s default `local:13389` + `SO_EXCLUSIVEADDRUSE` + ephemeral fallback (`fellBack:true` in the response; read `localPort` for the real port). - -**Old workarounds to drop**: (1) NSG WAN-IP whitelisting for roaming-laptop RDP — the tunnel rides existing auth, reachable regardless of WAN IP. (2) Trusting a bare `ok:true` from the old `rdp` — it could be a dead port; now `ok` means both legs were probed. (3) Hardcoding mstsc resolution — pass `size`/`monitor`; ab resolves against the real screen. - -### Shell — `shell_execute` (escape hatch only) - -- `shell_execute` -- Run a shell command on the desktop. The CLI handles approval polling internally — it returns the final `{success, output, error, exitCode}` once the user clicks Allow on the desktop dialog. 2.1.132+: ab holds the prompt for `approvalWaitSec` (default 60, max 600); a lapse answers `errorCode:"approval_timeout"` (nobody answered, NOT a deny: re-send with a longer wait or `request_shell_approval {wait:true}`), an explicit Deny answers `errorCode:"approval_denied"`. **You do not need to poll, retry, or call `get_deferred_result` yourself.** The CLI emits `HINT:` lines to stderr while waiting (every 15s) so an AI Monitor sees progress. -- `shell_kill_all` -- Kill all running shell commands and deny pending approvals. - -**Do not put JavaScript or escaped JSON in a `shell_execute` command (2.1.168, wiki #222).** On Windows the command runs through `cmd /c`, and cmd does NOT treat `\"` as an escaped quote. So in `curl -d "{\"js\":\"(()=>{const x=...})()\"}"` the `>` of `=>` (or any `<`/`>` comparison) can land outside cmd's quotes and become a REDIRECT: cmd writes a file named after the next word (`{const`, `e.children.length`) and the command gets mangled input. Send such payloads with `run_script {"interpreter":"powershell","scriptB64":...}` (base64, nothing to escape), or `write_file` the JSON and pass the file (`curl -d @C:/path/body.json`). ab now flags this shape in the reply's `_hint`. With no `cwd`, a command runs in `%TEMP%\adom-bridge-shell` (2.1.168; before that it ran in the Adom Bridge install folder, which is where those stray files landed); pass `cwd` when you need a specific folder. - -**v1.7.16 fix.** Earlier versions (v1.7.15 specifically) had a regression where the CLI's approval-wait loop re-sent the command every 1s instead of polling for the deferred result; that produced an infinite "Another shell command is already waiting for approval" loop because each retry created a fresh approval the user could never out-click. v1.7.16 fixes it. If you see that error string with a `>= 1.7.16` CLI, the bug is back — file an issue. If you see it with `< 1.7.16`, upgrade the CLI: `adom-wiki asset get apps/adom-bridge docker_binary -o /usr/local/bin/adom-bridge`. - -**Deprecated for download polling.** As of v1.5.0, use `desktop_watch_files` / `desktop_pull_glob` for waiting on download arrivals — those don't require user approval, don't go through PowerShell quoting, and are O(directory entries) rather than spawning a process every second. `shell_execute` itself stays as an escape hatch for genuinely shell-only operations (multi-step ad-hoc admin tasks, chained pipelines, etc.). - -Use `where python` / `C:\Python3xx\python.exe` for Python -- avoid `python` which may not be on PATH. - -#### `run_script` + `wsl_exec` — base64-payload script runners (v1.8.46+) - -When you want to send a multi-line script to the desktop without the shell-quoting hell that `shell_execute` requires, use these two verbs. The script crosses the wire as base64 so NO escaping survives to be mangled by cmd.exe, PowerShell parser quirks, MSYS path conversion, or wsl.exe's `--` / nested-quote / `$_` rules. The caller never hand-escapes anything. - -Both verbs require shell auto-approve to be active — same trust level as `shell_execute`. They refuse cleanly with an actionable error if not approved; no per-call modal (these are programmatic verbs). - -**Reading the result — `status` + `durationMs` (v1.8.162+).** EVERY CLI result now carries a single authoritative **`status`** field so you never have to guess whether an empty `stdout` was a real empty-output success or a timeout: - -| `status` | meaning | what to do | -|---|---|---| -| `ok` | completed (stdout may legitimately be empty) | use `exitCode`/`stdout`/`stderr` | -| `timeout` | did NOT finish in time (NOT an empty success) | raise `timeoutSeconds`, or use a detached job (below) | -| `busy` | desktop couldn't service it (WSL wedged / no client connected) | check `adom-bridge status`; `wsl_recover` for WSL; retry | -| `error` | the command/verb failed | read `error` / `stderr` / `_hint` | - -`durationMs` is the wall-clock of the call. `exitCode`/`stdout`/`stderr` pass through unchanged; on `timeout` the verb's `partialStdout`/`partialStderr` hold what was written before the kill. A `timeout`/`busy` result also carries a recovery `_hint`. **For multi-minute work don't fight the timeout** — use a detached job that returns instantly + polls: `wsl_exec_async` / `wsl_import` / `wsl_export` → poll `wsl_job_status` (or `launch_process` for a general long-running command). This is the cure for the "1.4 GB import EOFs the sync call" problem. - -**`run_script`** — base64 script + interpreter (cmd / powershell / bash): - -```bash -SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b''' -echo \"multi-line script\" -VAR=\"value with spaces and \$dollars\" -echo \"VAR=\$VAR\" -exit 0 -''').decode())") -adom-bridge run_script "{\"interpreter\":\"bash\",\"scriptB64\":\"$SCRIPT_B64\"}" -# → {success, ok, stdout, stderr, exitCode} -``` - -Args: `interpreter` (`cmd` | `powershell` | `bash`), `scriptB64` (required), `timeoutSeconds` (default 60). - -PowerShell prefers `pwsh` (PS 7+) if on PATH, falls back to `powershell.exe` (5.1). - -- **`bash` runs via WSL2, NOT Git Bash.** The Windows host's first `bash` on PATH is `wsl.exe -d <default-distro> bash` (verified at `C:\Windows\System32\bash.exe`). The script is piped to bash's stdin (no temp file, no path-form mismatch — no MSYS `/S` → `S:/` corruption, no nested-quote loss). The decoded script bytes are written to bash's stdin and stdin is closed; bash reads + executes without ever opening a file. For explicit distro / user control, use `wsl_exec`. - -**`wsl_exec`** — base64 script + distro + user, executed via the canonical `wsl -d <distro> -u <user> -- bash -lc <decoded>` argv array: - -```bash -SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b''' -cd /mnt/c/Github/adom-bridge -git status --short | head -10 -exit 0 -''').decode())") -adom-bridge wsl_exec "{\"distro\":\"Ubuntu\",\"user\":\"root\",\"scriptB64\":\"$SCRIPT_B64\"}" -# → {success, ok, stdout, stderr, exitCode} -``` - -Args: `distro` (e.g. `Ubuntu`, `Adom-Workspace`), `user` (e.g. `adom`, `root`), `scriptB64`, `timeoutSeconds` (default 60). - -Why argv (not shell string): Rust's `std::process::Command` on Windows applies proper `CommandLineToArgvW` quoting per argv element. `wsl.exe` sees `bash -lc <decoded>` as three distinct argv items even when the decoded script contains quotes, `$`, backslashes, newlines, pipes — anything. `bash`'s `-c` then sees the entire script as ONE string with no outer-shell interpretation. - -Output decoding: `wsl.exe` defaults to UTF-16LE when invoked from the Windows console subsystem. Both verbs auto-detect (BOM check + odd-position-NULL heuristic) and decode to UTF-8. Output is never wrapped in another shell that might re-interpret it. - -Pitfalls these verbs eliminate: -- `cmd.exe`'s `&`, `|`, `^`, `%var%` interpretation -- PowerShell's parser quirks around nested quotes + `$` interpolation -- Git Bash MSYS converting `/S` → `S:/` etc. -- WSL's `--` parser limits + nested-quote loss -- UTF-16LE corruption on wsl.exe output - -#### WSL wedge defense — `wsl_wedged` + `wsl_recover` (v1.8.157+) - -Every `wsl_exec` / `run_script bash` spawns a `wsl.exe` client. If WSL's service (`LxssManager`) wedges — common after a laptop runs for a day, or under heavy parallel cloud use — those clients block in a wait that `taskkill /F` can't always finish, and retries keep spawning more. Left unchecked they pile up into the hundreds and wedge the whole machine. Three defenses: - -1. **Refuse-cap.** When there are already too many `wsl.exe` (`WSL_REFUSE_AT=80`), ab refuses NEW wsl spawns with `errorCode:"wsl_wedged"` instead of adding another doomed client. If you get `wsl_wedged`, don't retry the same command — run `wsl_recover` first. -2. **`wsl_recover`** — reap orphaned/hung `wsl.exe`, **DRY-RUN by default** (nothing is killed unless you pass `confirm:true`): - ```bash - adom-bridge wsl_recover # dry-run: lists `candidates` (would-reap) + `spared` - adom-bridge wsl_recover '{"confirm":true}' # reap the candidates - adom-bridge wsl_recover '{"confirm":true,"sparePids":[1234]}' # also protect pid 1234 - ``` - It classifies each `wsl.exe`: childless + (0-CPU & old | runaway-CPU) → **candidate**; everything else → **spared**. It **spares any `wsl.exe` with child processes** (that's almost certainly the live distro, e.g. the Hydrogen `Adom-Workspace`), honors `sparePids`, and never touches `wslservice`/`wslhost`/`vmmem`. Review each candidate's `cmd` in the dry-run to confirm the live workspace isn't among them, then confirm. **Never** `wsl --shutdown` (it kills the live workspace too). Reaping childless orphans often cascades — their now-parentless children exit on their own (observed 114 → 18 from reaping 54). -3. **status wsl block** — `{wslProcessCount, wedged, _hint}` lets you see a pile-up *before* it blocks work. Path differs by transport: **`status.desktop.wsl`** via the CLI/relay (`adom-bridge status` — nested like `desktop.apps`), or top-level **`status.wsl`** on the local direct API (`GET /status`). `wedged:true` at/above `WSL_WARN_AT=50`. - -#### Long-running in-distro work + distro lifecycle — golden-image bakes (v1.8.161+) - -`wsl_exec` is **synchronous** — it waits for the command and is bounded by the relay's HTTP timeout (~60-120s), so a multi-minute bake (apt, a big download) EOFs. Backgrounding inside the distro (`nohup … &`) doesn't help either: the `wsl.exe` client returns, WSL sees no active session, shuts the distro down, and kills the bg process (wiping `/tmp`). For anything long, use the **async held-session** verbs — ab holds one `wsl.exe` in the foreground for the command's whole lifetime, so the distro stays up: - -- **`wsl_exec_async`** → returns a `jobId` + `outputPath` (a live UTF-8 log) immediately; the command runs detached for as long as it takes. -- **`wsl_job_status {jobId}`** → `{running, exitCode, elapsedMs, outputTail, outputPath}`. Poll until `running:false`; `pull_file` the `outputPath` for the full log. (`wsl_job_list`, `wsl_job_kill {jobId}` round it out.) - -```bash -# golden-image bake: import → run (async) → poll → export → unregister, all via ab -adom-bridge wsl_import '{"distro":"bake","installDir":"C:/Users/<you>/.adom/wsl/bake","tarball":"C:/.../base.tar"}' -# → {jobId}; poll wsl_job_status until done (exitCode 0) -B64=$(printf 'set -euo pipefail\napt-get update && apt-get install -y build-essential\n' | base64 -w0) -adom-bridge wsl_exec_async "{\"distro\":\"bake\",\"user\":\"root\",\"scriptB64\":\"$B64\"}" -adom-bridge wsl_job_status '{"jobId":"wsljob-xxxxxxxx"}' # repeat until running:false; outputTail = progress -adom-bridge wsl_export '{"distro":"bake","tarball":"C:/.../golden.tar"}' # poll, then pull_file the tarball -adom-bridge wsl_unregister '{"distro":"bake"}' # cleanup (REFUSES Adom-Workspace) -adom-bridge wsl_list # {distros:[{name,state,version,default}]} -``` - -**systemd user session — `viaRoot`.** On a `systemd=true` distro, `-u <user>` can fail "Failed to start the systemd user session for '<user>'". Pass `"viaRoot": true` to `wsl_exec_async` — ab runs `-u root -- runuser -u <user> -- bash -l`, dropping to the user WITHOUT depending on `[email protected]`. - -`wsl_import` / `wsl_export` / `wsl_unregister` are async (jobId + poll) too, so big imports/exports never EOF. All are **strictly scoped to the named distro** — there is no global `wsl --shutdown` path (that would kill the live Hydrogen workspace), and `wsl_unregister` refuses `Adom-Workspace`. Job ids live in ab's memory only (not persisted across an ab restart; the log file survives). The wedge defense above still applies — an over-cap spawn returns `wsl_wedged`. - -## Detecting App Installation - -After connecting, always run `adom-bridge status` to check what's installed. The `desktop.apps` object tells you exactly what the user has: - -```bash -adom-bridge status -# Look at the desktop.apps field in the response -``` - -### Handling "not installed" errors - -When a command returns `errorCode: "node_not_found"` (a bridge runtime ab manages: see `runtimes`), or a bridge reports its host app missing, guide the user through installation: - -**Picking the right native browser + profile (v1.7.1+):** - -When you need to open a URL in the user's NATIVE browser AND it matters which account is signed in (work Google Workspace vs personal Gmail vs media YouTube channel etc.), `desktop_open_url` alone isn't enough — you need to target a specific profile. The flow: - -```bash -# 1. Discover what's installed + which profiles are configured. -adom-bridge desktop_list_browsers '{}' -# → { -# "default": "chrome", -# "browsers": [ -# { "name": "chrome", "displayName": "Google Chrome", "version": "146...", -# "exePath": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", -# "profiles": [ -# { "id": "Default", "name": "Alice (work)", "gaia": "[email protected]", "isDefault": true }, -# { "id": "Profile 1", "name": "Personal", "gaia": "[email protected]" }, -# { "id": "Profile 2", "name": "Adom Media", "gaia": "[email protected]" } -# ] }, -# { "name": "edge", "profiles": [...] }, -# { "name": "firefox", "profiles": [{"id":"default-release","name":"default","isDefault":true}] } -# ] -# } - -# 2. Match the URL's context to the right profile, then open it there. -# E.g. opening a Google Doc shared by your work team → use the work profile: -adom-bridge desktop_open_url '{ - "url":"https://docs.google.com/document/d/...", - "browser":"chrome", - "profile":"Default" -}' -# Or YouTube channel management for the media account: -adom-bridge desktop_open_url '{ - "url":"https://studio.youtube.com", - "browser":"chrome", - "profile":"Profile 2" -}' -``` - -Profile-picking heuristics for Docker Claude: -- **Workspace / @adom.inc URLs** → match `profile.gaia.endsWith("@adom.inc")` and not the `media@` one → typically `Default`. -- **Personal Gmail / Drive / etc.** → match `profile.gaia === "[email protected]"` (or whatever the user's personal address resolves to). -- **YouTube Studio / channel-specific work** → match the media account profile. -- **Random scratch / experiments** → use the user's Edge profile, where they keep miscellaneous accounts (per the user's stated preference). -- **No clear match** → fall back to `browser:"default"` (no profile flag) and let the user pick. - -`profile` is optional. Omit it to open in whichever profile the browser was last using (legacy v1.6.x behavior). Profile flag is silently ignored when `browser:"default"` (no clean way to inject through the OS URL handler — name the browser explicitly to use it). - -### Decrypting the user's saved browser credentials — `desktop_decrypt_browser_credentials` (v1.9.220+) - -> **`reason` is REQUIRED here (v2.0.84)** and it is not for the log: the user reads it -> **on screen**, in a caption above every window, while deciding whether to hand over -> their saved passwords. Write it for them — say what you are trying to accomplish, not -> what the command does. Good: `"Signing the adom-you Chrome profile in as you so I can -> check your orders"`. Weak: `"decrypting credentials"` (that is just the verb again). -> -> **What the user sees before anything is decrypted (three surfaces, v2.0.84):** -> a caption above every window naming **which AI thread** asked, **your reason**, **what -> it wants** (passwords / cookies) and **from which browsers**, plus a warning that -> Windows is about to ask for administrator permission; ab's own consent dialog; and a -> Windows toast carrying the same Continue/Cancel. Any one of them can approve or -> decline. Expect a visible pause here — a human is reading, and if they were not -> expecting it they are being told to choose No. - -So pup can run an **"adom-you" Chrome profile signed in AS the user** — surfing the web on the user's behalf — it needs the user's real saved logins/cookies to seed that profile. Chrome 127+ encrypts these with **App-Bound Encryption** (the master key is DPAPI-wrapped under the SYSTEM account), so only an elevated helper can decrypt them. This verb is that path. - -```bash -adom-bridge desktop_decrypt_browser_credentials '{ - "reason": "Signing the adom-you Chrome profile in as you so I can check your orders", - "browsers": ["chrome", "edge"], # optional; default = all installed Chromium browsers - "include": ["passwords", "cookies"] # optional; default = both -}' -# → { "ok": true, -# "passwords": [ { "origin", "username", "password", "sourceBrowser", "sourceProfile", "sourceAccount", "lastUsed" }, ... ], -# "cookies": [ { "host", "name", "value", "path", "sourceBrowser", "sourceProfile", ... }, ... ], -# "stats": { "profilesRead", "passwordsOk", "passwordsFailed", "cookiesOk", "cookiesFailed" } } -``` - -**How to call it (this is not a normal verb — read this):** - -- **LOCAL / bridge callers only.** A relay (cloud-AI) call is refused unconditionally with `errorCode:"relay_forbidden"` and **pops no UAC** — a remote container must never be able to trigger a password-decrypt. pup (a loopback bridge) and the local CLI reach it. -- **Two human gates, in order:** (1) ab shows an in-window consent dialog + toast naming the requesting caller ("pup is requesting your saved browser passwords/cookies…"); decline → `errorCode:"consent_declined"`. (2) On continue, an elevated signed helper runs behind a **UAC prompt** ("Adom Industries, Inc."); cancel → `errorCode:"elevation_declined"`. The UAC elevation IS the user's permission for the decrypt — by design (John, wiki #457). -- **Plaintext is returned ONCE over the loopback API and is never written to disk or logged.** Don't echo the values into any other verb's args, any activity-log emit, or any file. Consume the rows, seed the profile/vault, drop them. -- **errorCodes:** `relay_forbidden` (you called it over the relay — you can't), `consent_declined`, `elevation_declined`, `helper_missing` (the signed helper isn't bundled — the install is broken). Per-item decrypt failures are counted in `stats.*Failed`, never fail the whole run (a managed-profile policy can block cookies; newest-Chrome `v20` blobs are best-effort if Google rotated the elevation-service key). -- **Pair it with `desktop_list_browsers`** (above) to show the user which browsers/profiles/accounts will be read before you ask. - -**Node.js not installed (the puppeteer bridge can't auto-spawn):** - -v1.7.0+ — any `pup_*` command returning `errorCode:"node_not_found"` means Node isn't on the desktop machine. Trigger the unattended winget install: - -```bash -adom-bridge desktop_install_node '{}' -# → runs `winget install --id OpenJS.NodeJS.LTS --silent ...` (Windows only). -# → ~1-2 min; downloads + installs the Node.js LTS line (well-tested with puppeteer). -# → returns {ok:true, exitCode:0, _hint:"Retry the original pup_* command"} on success. -# → on failure: {ok:false, errorCode:"winget_unavailable" | "winget_install_failed", _hint}. -``` - -After install, **just retry the original `pup_*` command** — the bridge picks up the new node.exe via the registry App Paths lookup (which winget writes), no Adom Bridge restart needed. v1.7.0+ also pre-fetches Node + Chrome for Testing on first launch after install (background task on AUTOSTART_VERSION 11), so most users will never see `node_not_found` to begin with. - -If `desktop_install_node` returns `errorCode:"winget_unavailable"` (older Windows or managed corporate device), fall back to manual: - -> Node.js LTS is required for browser automation. winget isn't available on this machine — download Node.js LTS from https://nodejs.org/ and run the installer. Let me know when it's done and I'll retry. - -**v1.7.0+ Node detection.** The bridge looks for `node.exe` in: -- System PATH (via `where node` on Windows, `which node` elsewhere) -- Windows registry App Paths (catches winget, MSIX, custom-dir installs) -- Standard install dirs: `C:\Program Files\nodejs\`, `C:\Program Files (x86)\nodejs\`, `%LOCALAPPDATA%\Programs\nodejs\` -- Per-user version managers: nvm-windows (`%APPDATA%\nvm\`), volta (`%LOCALAPPDATA%\Volta\bin\`), fnm (`%FNM_DIR%`) -- macOS Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`) - -If detection still misses an install on a user's machine, get the actual `node.exe` path from the user — that's a real bug to file. - -**A bridge's host app not installed or not running:** the bridge owns that story. `bridge_readiness {"name":"<bridge>"}` says `no-app` / `warming` / `ready` and its `_hint` says what to do; the bridge's own skill (`sync_skills`) has the install and launch flow. Do not hand the user a download link before asking the bridge: several can install their app unattended. - -## Troubleshooting - -### Check connection status - -Use `status` to see connected clients. A healthy connection shows one client from the user's hostname with a recent `lastPong` timestamp. - -### Stale connections causing timeouts - -Use `kick_all` to reset -- active Adom Bridge apps reconnect within seconds. - -### No desktop client connected - -1. Confirm the Adom Bridge app is running on the user's PC -2. Confirm it's pointed at the correct WebSocket URL -3. Check if port 8765 is exposed and reachable - -### Relay not running - -```bash -curl -sf http://127.0.0.1:8766/health -# If fails: adom-bridge serve --supervise -``` - -### Shell commands on Windows - -`shell_execute` runs in `cmd.exe`. Prefer writing scripts via `send_files` then executing them. Use the full Python path. - -## Building from Source - -```bash -cd cli && cargo build --release -# Binary at: cli/target/release/adom-bridge -``` - -## Repo - -[github.com/adom-inc/adom-bridge](https://github.com/adom-inc/adom-bridge) - -## Run wiki ops on the user's PC — `wiki_exec` (v1.9.116+) - -The ab installer bundles the `adom-wiki` CLI next to the app, and `wiki_exec` runs it ON THE PC — so version-controlling a local project (KiCad board, docs folder) on wiki.adom.inc needs NO per-file `pull_file` round trip to the container and there is no container/PC drift. - -```bash -adom-bridge wiki_exec '{"args":["repo","push","adom/my-board","--files","board.kicad_pcb","board.kicad_sch","-m","rev B"],"cwd":"C:\Users\me\boards\my-board"}' -``` - -- `args` = adom-wiki argv (any pillar: repo, pkg, release, discussion). `cwd` = PC-side project dir so `--files` are project-relative. `timeoutSec` optional (default 120, max 600). -- Returns `{success, exitCode, timedOut, stdout, stderr, exe, durationMs}`. -- Auth is automatic: ab injects the signed-in user's session token as `ADOM_WIKI_TOKEN` — the caller never handles credentials. -- Gated like `shell_execute` for relay callers (it writes to the wiki as the user). `errorCode:wiki_cli_missing` means the desktop ab predates 1.9.116. -- Container-side publishing of the CONTAINER's own files still uses your local `adom-wiki` directly; `wiki_exec` is for files that live on the PC. - -Note (v1.9.117): ab's Settings panel no longer has a KiCad section — bridges own their app-specific config (ab stays generic). `kicad_library_paths` / `project_watch` keep working from `~/.adom/config.json` and the `set_project_watch` / `trigger_project_watch` verbs. +---+name: adom-bridge+description: Use when the user wants to send files to their desktop, control KiCad or Fusion 360, send desktop notifications, or troubleshoot the desktop connection. Provides CLI tools for bridging Claude's Linux container (WSL2 on ah, cloud on hc) to the user's local machine.+---++# Adom Bridge++Bridge between Claude Code and the user's desktop applications via WebSocket. Claude runs in a Linux container: on ah, the Adom-Workspace WSL2 distro on the user's own PC (`/mnt/c` is its C: drive); on hc, a cloud container. Either way it reaches the desktop through ab. Run `adom-context` to see which.++> **The CLI is `adom-bridge`** (renamed from `adom-bridge-cli` in v2.0.33 — the `-cli` was noise on Linux). Use `adom-bridge <verb>`. The old `adom-bridge-cli` name still works (the pkg links both), so existing scripts don't break — but prefer `adom-bridge` going forward. On Windows the CLI file stays `adom-bridge-cli.exe` (the GUI app owns `adom-bridge.exe` there).++**Install surface:** the wiki page [`adom/adom-bridge`](https://wiki.adom.inc/adom/adom-bridge) hosts the Windows installer (a release download) and this package (the Linux CLI plus the `adom-bridge-*` skill set): `adom-wiki pkg install adom/adom-bridge`. `adom-bridge setup_desktop` also returns the installer URL.++**First-time setup?** If the user hasn't installed the desktop app yet, see the Setup section below to walk them through downloading, installing, and connecting the app.++Quick check if desktop is connected:+```bash+adom-bridge ping+```++**Bridge skills** (each bridge ships its own skills from its own wiki page; `adom-bridge sync_skills` installs them for every bridge the connected desktop has):+- KiCad bridge: launch editors, open designs, install libraries, run DRC, window capture + keyboard/click automation. `adom-wiki pkg install adom/kicad-bridge`+- Fusion 360 bridge: launch, open designs, STEP/GLB/.lbr import-export, BOM + API queries, Fusion screenshots. `adom-wiki pkg install adom/fusion-bridge`+- pup (the AI's own real Chrome): open URLs, screenshot, eval JS, multi-session profiles. `adom-wiki pkg install adom/pup-bridge`++## How It Works++```+Claude Code -> adom-bridge <command> -> Relay Server (HTTP :8766) -> WebSocket :8765 -> Adom Bridge App -> KiCad / Fusion 360 / Browser / Shell+```++The `adom-bridge` binary is a single Rust CLI that does everything:+- `adom-bridge serve` -- Start the relay server (WebSocket :8765 + HTTP API :8766)+- `adom-bridge <command> '<json>'` -- Send commands to the desktop app via the running relay++The relay server runs in Claude's container (the WSL2 distro on ah, the cloud container on hc). The Adom Bridge app runs on the user's PC and connects via WebSocket.++## Starting the Relay Server++The relay must be running before the desktop app can connect. **In a container, start it SUPERVISED** (v1.9.168+):++```bash+adom-bridge serve --supervise+```++**Why not `adom-bridge serve &`?** A backgrounded (`&`) relay is still a child of your shell/session. When your session is torn down (you finish, or the container reaps it), the relay dies with it — and then every connected desktop hits HTTP 500 on `/proxy/8765/` and loops "Reconnecting in 30s" **for hours** until a human restarts it (wiki issue #24, hit live 2026-07-23). `--supervise` **detaches the relay into its own session (reparented to init)** so it survives your session, and starts a watchdog that:+- restarts the relay with **exponential backoff** (2s → … → 5 min cap; a clean run resets it — a broken relay is never bludgeoned), and+- **toasts the connected desktops on every restart** with the reason from the relay log, so the owner finds out something went wrong.++```bash+adom-bridge serve --supervise # detached + self-healing (use this in containers)+adom-bridge serve --supervise --owner AdomLapper # send restart toasts to one desktop+adom-bridge serve --stop # stop the watchdog + relay+```++`serve` (plain, foreground) still works and prints a loud hint telling you it will die with the session — fine for a quick local relay you'll sit and watch. **Caveat:** a full CONTAINER REBOOT still needs a boot hook (cron `@reboot` / the container's init); the watchdog covers session teardown, not host reboot.++This starts:+- WebSocket server on `127.0.0.1:8765` (desktop app connects here; `--bind 0.0.0.0` / `ADOM_RELAY_BIND` to listen on the network, 2.1.170 wiki #225)+- HTTP API on `127.0.0.1:8766` (CLI commands go here)++Check if it's running:+```bash+curl -sf http://127.0.0.1:8766/health+```++## First-Time Setup (Install & Connect)++**You cannot click or type on the user's desktop yourself; ab is your hands. On ah you can still read and write the PC's files at `/mnt/c`, so check what you can before asking the user to.** Guide them step-by-step, ask questions, wait for answers, and verify each step.++> **As soon as you're connected, run `adom-bridge sync_skills` (v1.9.37+).** Installing the+> adom-bridge core pkg gives you only CORE skills — it does NOT give you a DYNAMIC/cloud+> bridge's skill (kicad, fusion, blender, any third-party). `sync_skills` runs `bridge_list`+> and installs each bridge's own skill pkg into `~/.claude/skills/`, so you can actually+> discover + drive whatever bridges THIS desktop has. (The CLI also runs this automatically+> once/day in the background, but doing it on connect makes the bridge skills available to+> the current session immediately.) Re-run it any time the desktop gains a bridge;+> `adom-wiki pkg update` keeps them fresh.++### Step 1: Check if already connected++```bash+adom-bridge ping+```++If this returns `{ "status": "connected" }`, the desktop is already set up -- skip to "Verify the connection" below. If it errors, continue.++### Step 2: Ensure the relay is running++```bash+curl -sf http://127.0.0.1:8766/health+```++If not running (in a container, ALWAYS use `--supervise` so it survives your session): `adom-bridge serve --supervise`++### Step 3: Ask what OS they use++Ask the user: **"What operating system is your desktop/laptop? (Windows, Mac, or Linux)"**++- **Windows 10/11** -- proceed+- **Linux** -- a private preview (AppImage, insiders tier, 2.1.175+). Installing it, keeping one copy running, updates, and what works there: the `adom-bridge-linux` skill. Inside a Grok Bot agent computer the machine is paused while its agent is idle; that skill says how to wake it.+- **macOS** -- "Support is coming soon."++### Step 4: Install & connect++Run `adom-bridge setup_desktop` — it auto-detects this container's relay URL and returns a ready-to-run **`register_command`**, a fallback `server_config` JSON, and the `installer_url`. Prefer the zero-paste path:++---++**Option 0: Zero-paste via your Adom account (PREFERRED)**++Run the **`register_command`** that `setup_desktop` returned (it's `adom-bridge register_container` with the name + URL filled in). That injects this connection into the **user's Adom cloud profile**. Then the user just:+1. Installs Adom Bridge (from `installer_url`) and opens it,+2. **Signs in with their Adom account** (Connections panel → "Sign in to Adom"),++and the connection appears in their list and auto-connects — **no JSON paste at all**. This is the modern path and the one to try first. (`register_container` needs an Adom cloud container — it uses the mounted account token — and returns `ok:false` on a non-Adom box; if so, use Option A/B below.)++**Already-running desktop? `server_sync` (v1.9.215+).** A signed-in ab reads the cloud profile at boot, at sign-in, and hourly — and `adom-bridge server_sync` (optionally `--target <name>`) makes it re-read RIGHT NOW. The merge is cloud-wins, whole entry including `authToken`. So the flow for adding a server or **rotating a relay token** on live desktops is: update the cloud key (`register_container` writes them) → `server_sync` per desktop → verify with `server_list`. Do NOT use `server_remove` + `server_add` for rotation — removing the entry you are talking through severs your own transport (wiki #387).++---++**Option A: Automatic setup via Claude Code (paste fallback)**++If you have **Claude Code** or **Claude Desktop** on your laptop, paste this prompt:++> Install Adom Bridge and connect it to my cloud container. (Most users instead get Adom Bridge bundled with Hydrogen, which installs and connects it for them; use this prompt when you are driving the desktop from a cloud container.) Download the installer from `<installer_url>`, run it silently, then write this JSON to `%USERPROFILE%\.adom\config.json`:+> ```json+> {"servers":[<server_config JSON>]}+> ```+> Then launch "Adom Bridge" from the Start Menu.++*(Replace `<installer_url>` and `<server_config JSON>` with the actual values from `setup_desktop`.)*++**Option B: Manual setup**++1. Download the installer: **[<installer filename>](<installer_url>)**+2. Run it and follow the prompts — the app installs to the Start Menu as "Adom Bridge"+3. Open **Adom Bridge** from the Start Menu+4. Paste this JSON into the text field that says **"Paste server JSON to add ..."** and press **Enter**:+ ```json+ <server_config JSON>+ ```+5. The server will appear and auto-connect++---++Wait for the user to confirm they see a green dot next to the server name before proceeding.++**Important:** Each container has its own relay server. New containers need a new entry -- old entries from previous containers won't work.++### Step 6: Verify the connection++```bash+adom-bridge ping+# Expected: { "echo": "pong", "roundTripMs": ..., "status": "connected" }++adom-bridge status+# Expected: one client with the user's hostname and capabilities++adom-bridge notify_user '{"title":"Hello from your workspace!","body":"Your desktop is connected."}'+```++Tell the user what you see. If `ping` succeeds:++> "Your desktop is connected! I can now send files to your machine, open browser windows for visual debugging, control KiCad and Fusion 360, take screenshots of your desktop, and send you notifications."++### Step 7: Optional -- Node.js for browser features++Ask: **"Would you like to use the visual browser debugging feature? This opens Chrome windows on your desktop that I can control."**++- **Yes + Node.js installed** -- "Great, the browser bridge auto-installs deps on first use."+- **Yes + no Node.js** -- "Download Node.js from https://nodejs.org (LTS), install it, then restart Adom Bridge."+- **No** -- skip, they can enable it later++### Common connection issues++| Symptom | Fix |+|---------|-----|+| `ping` returns `"No desktop client connected"` | User hasn't added this container in the desktop app yet |+| Desktop app shows "disconnected" | Check the URL uses `wss://` (not `ws://` or `https://`), port is `8765` |+| Relay server not running | `adom-bridge serve --supervise` (survives your session; see Starting the Relay Server) |+| Multiple stale connections | `adom-bridge kick_all` -- app auto-reconnects within seconds |+| Desktop app not installed | Download from [github.com/adom-inc/adom-bridge/releases](https://github.com/adom-inc/adom-bridge/releases) |++## CLI Tool++```bash+adom-bridge <command> '<json-args>'+```++**Examples:**+```bash+adom-bridge ping+adom-bridge status+adom-bridge desktop_list_windows+adom-bridge desktop_screenshot_screen '{"reason":"see what is on the user's screen"}'+adom-bridge commands '{"bridge":"kicad"}' # a bridge's own verb list (any installed bridge)+adom-bridge notify_user '{"title":"Hello","body":"From your workspace"}'+adom-bridge shell_execute '{"command":"echo hello"}'+adom-bridge pull_file '{"filePaths":["C:\\Users\\john\\Downloads\\image.png"],"saveTo":"/tmp"}'+```++**Output:** JSON to stdout. Screenshots save to `/tmp/adom-bridge-screenshots/` and return the file path.++**Config location:** The desktop app stores server config at `~/.adom/config.json` (`%USERPROFILE%\.adom\config.json`). This is separate from the binary — config survives updates and reinstalls.++## Available Commands++**Get the full structured command list with descriptions, args, and prerequisites:**+```bash+adom-bridge commands # aliases: list_commands, help+adom-bridge --target <name> commands # + THAT box's installed bridges (`bridges`, live: name, version, verb names)+adom-bridge commands '{"bridge":"kicad"}' # ONE bridge described by ITSELF (<prefix>describe, else its manifest verb list)+```+Returns categorized JSON with every ab-core command, its required/optional args, return values, prerequisites, and workflow notes, plus `bridges` read live from the box. Run this FIRST when you're unsure what a verb is called. Bridge verbs are never catalogued inside ab (2.1.144, wiki #211: the hand-written copy drifted); the bridge describes them, and its skill (`sync_skills`) is the how-to.++**Two kinds of verbs (v1.9.103 — don't confuse them):**+- **BUILT-IN ab-core verbs** are always available on every Adom Bridge, no install, NOT bridges. This is where screenshots, windows, input, files, registry, shell, tunnel, update, and notify live. A whole-screen shot is `desktop_screenshot_screen`; one window (with chrome) is `desktop_screenshot_window {"hwnd":N}` or `{"titleContains":"..."}` (get hwnds from `desktop_list_windows`).+- **BRIDGE verbs** (`kicad_`/`fusion_`/`pup_`/`blender_`/`nbrowser_`…) need that bridge installed — see `bridges` in `commands`, or run `bridge_list`.++If a verb returns `errorCode:"unknown_verb"`, **read the error's `_hint`** — it carries a fuzzy "did you mean" (e.g. `desktop_screenshot` → `desktop_screenshot_screen`) and tells you whether the right verb is built-in or a bridge. Don't conclude a capability is "a missing bridge" from a guessed verb name; call `commands` and check.++### Connection Management++- `ping` -- 5s round-trip test. Use BEFORE browser/shell commands to verify the desktop connection is alive.+- `status` -- Check who's connected, their capabilities, desktop paths, and **app installation status**. The `desktop.apps` object shows:+ - `kicad.installed` / `kicad.version` / `kicad.bridgeRunning`+ - `fusion360.installed` / `fusion360.running` / `fusion360.bridgeRunning` / `fusion360.addinInstalled` / `fusion360.addinConnected`+ - `browser.bridgeRunning`+- `kick_all` -- Force-disconnect all WebSocket clients. Active Adom Bridge apps auto-reconnect within seconds.++#### Programmatic server registration (v1.8.22+)++Three verbs let external apps (Hydrogen's WSL2 workspace, in particular) register their relay server with adom-bridge without the user pasting JSON into the GUI by hand. Mirrors the GUI's Quick Add bar + connect/disconnect buttons.++- `server_add` -- Upsert a relay server by `name`. If a server with that name already exists, the URL (and optionally authToken) is updated rather than creating a duplicate.+ ```bash+ adom-bridge server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'+ # → {ok:true, name, url, id, connected:bool, created:bool, _hint}+ ```+ - `name` (required) — dedup key. Repeat calls with the same name are idempotent.+ - `url` (required) — relay WebSocket URL.+ - `authToken` (optional) — defaults to a dev token (leave unset unless your relay requires one).+ - `autoConnect` (optional, default `true`) — connect right after upsert. Set `false` to add the entry without dialling out.+ - Behavior: same URL + already connected → no churn, returns ok. URL changed + autoConnect=true → disconnect old loop + spawn new one. URL changed + autoConnect=false → disconnect old, leave entry registered but not connected.+- `server_remove` -- Disconnect (if connected) and delete an entry by name. Returns `{ok, removed:bool, wasConnected:bool}`. Idempotent (removing a non-existent entry returns ok with removed:false).+ ```bash+ adom-bridge server_remove '{"name":"hydrogen-workspace"}'+ ```+- `server_list` -- Persisted server list with live connection status: `{servers:[{name, url, id, autoConnect, enabled, connected:bool, clientCount:0|1, status}]}`. `status` is the fine-grained `connected | reconnecting | disconnected` state machine value.++Persists to the same `~/.adom/config.json` the GUI uses, so entries survive a GUI restart. The `ws_client` supervisor (runs every 30s inside the GUI) auto-reconnects entries with `autoConnect:true` on next launch.++Hydrogen-style usage:+```bash+# At Hydrogen container startup+adom-bridge server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'++# At Hydrogen container shutdown+adom-bridge server_remove '{"name":"hydrogen-workspace"}'+```++#### Addressing multiple Adom Bridges on one relay (v1.8.133+)++One relay can have **several** Adom Bridges connected at once — e.g. a laptop and an Azure VM, eventually many. Each ab has a **name** (its `--client-name` / Settings value, defaulting to the OS hostname). The cloud CLI addresses a specific one, or fans out to all:++- `adom-bridge targets` — list connected ABs: `{count, targets:[{name, hostname, version, os, connectedAt}]}`. Discover names here first.+- `adom-bridge --target <name> <verb>` — route the verb to that ab only. `--target` is a **global** flag (goes before the verb; case-insensitive; matches `clientName`, else `hostname`). `ADOM_TARGET=<name>` sets a default (the flag wins).+- `adom-bridge --target all <verb>` — fan out to **every** connected ab; returns `{broadcast:true, targets:[...], results:{<name>:<verb-result>}, summary:{total,ok,failed}}`. A per-ab timeout/failure is isolated to that ab's entry.++```bash+adom-bridge targets+adom-bridge --target azurevm desktop_screenshot_screen+adom-bridge --target all hd_status # every box at once, results keyed by name+```++**No-target behavior:** with exactly ONE ab connected, no `--target` is needed (back-compat). With MULTIPLE connected and no `--target`, the call FAILS with `{error:"ambiguous_target", connected:[...], _hint}` (exit 1) — it won't guess which box. A bad name → `{error:"target_not_found", requested, connected:[...]}` (exit 1). Set each ab's name with the `--client-name <name>` launch flag (Hydrogen passes this when spawning the embedded ab), the Settings field, or `config.json` `client_name`. Full design + the relay-routing internals: [`skills/MULTI_AB_ADDRESSING.md`](MULTI_AB_ADDRESSING.md).++#### Direct HTTP API for sibling apps (v1.8.25+)++If you're authoring a sibling Tauri app on the same machine (Hydrogen, or any future "Adom-family" app), you can skip the CLI binary entirely and POST commands straight into the running adom-bridge GUI on `127.0.0.1:47200` (was `127.0.0.1:8770` through v1.8.32 — moved to be a better neighbor to Hydrogen on 47080+ and avoid the 8000-range collision risk). The endpoint runs inside the GUI process (loopback-only bind), uses the same dispatcher the WS path uses, and returns the same JSON shape as `adom-bridge <verb>` — including every `_hint` field.++| Method | Path | Body | Returns |+|---|---|---|---|+| `GET` | `/health` | — | `{"ok":true,"service":"adom-bridge"}` cheap probe |+| `GET` | `/status` | — | service banner + version + schema + `directApi.cliRequired` list |+| `POST` | `/command` | `{"app":"<ns>","command":"<verb>","args":{...}}` | The verb's normal payload (200 OK), or `{error, errorCode, _hint}` (4xx/5xx) |++**v1.8.33+ port discovery (sibling apps READ THIS):** Don't hardcode 47200. The GUI may have bound to 47200-47209 instead if the default port was taken (zombie socket, dev instance, third-party collision). Discovery protocol:++1. Read `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`) — single-line `host:port`, written by the GUI at bind time, removed at graceful shutdown+2. If file missing or its port doesn't `/health`, scan `127.0.0.1:47200..=47209` for any port answering with `{"ok":true,"service":"adom-bridge"}`+3. Validate `service == "adom-bridge"` to disambiguate from other apps that might bind a port in our range++The CLI does this automatically (see `cli/src/direct_probe.rs`). For sibling-app Rust code, see [`skills/DIRECT_API.md`](DIRECT_API.md) for a copy-paste-ready helper.++**Per-verb timeouts:** a bridge verb's budget is the bridge's own `bridge.json` `timeouts` block (exact bare verb, then prefix rule, then its default), applied by ab and, since 2.1.146, by the CLI's wait as well (ab keeps no table of any bridge's verbs, wiki #212). `timeouts` shows every installed bridge's declared block. Caller can override via `args.timeout` (seconds, clamped to 1800).++Example — programmatic server registration without the CLI:++```bash+curl -X POST http://127.0.0.1:47200/command \+ -H 'Content-Type: application/json' \+ -d '{"app":"desktop","command":"server_add","args":{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}}'+# → identical JSON to `adom-bridge server_add '{...}'`, including the _hint+```++**What's safe to send directly:** essentially everything sync (`server_*`, `bridge_list`, `hd_status`, `hd_build_status`, `desktop_list_windows`, `desktop_screenshot_*`, `kicad_*`, `fusion_*`, `pup_screenshot`, `notify_user`, ...) plus async-dispatching verbs that return a job id in <500 ms (`bridge_install`, `hd_build`, `desktop_install_kicad`, ...).++**What requires the CLI fallback:** verbs returning a structured `errorCode:"cli_required"` from this endpoint — currently `pull_file`, `send_files`, `shell_execute`. These use binary streaming or multi-minute approval polling that doesn't fit a single synchronous HTTP request. `GET /status` returns the full list at runtime so callers can branch defensively.++Full integration guide (loopback trust model, recipes for Hydrogen startup/shutdown, retry/backoff patterns, port-discovery code in Rust + TypeScript) lives in [`skills/DIRECT_API.md`](DIRECT_API.md) in this repo, and as a file on the `adom/adom-bridge` wiki page.++#### Embedded mode — ab bundled inside Hydrogen (v1.8.42+)++When Hydrogen bundles ab, ab enters "embedded mode" at boot. Hydrogen owns the system-tray icon (ab's is suppressed), Hydrogen owns the Adom Cloud login (Hydrogen's session token is handed to ab via Phase 4's existing channels), Hydrogen owns auto-updates (ab's wiki-poll is disabled — Hydrogen bundles a fresh ab on every Hydrogen release), and Hydrogen spawns ab hidden (its tray "Open Adom Bridge" surfaces the window on demand). Standalone ab users see zero change — none of the signals fire.++**Detection** — three-signal cascade (any one triggers embedded mode):++1. `--embedded` CLI flag (what Hydrogen passes on every spawn)+2. `ADOM_EMBEDDED=1` env var (backup channel)+3. `%LOCALAPPDATA%\Adom Bridge\embedded.json` marker file (survives ab restarts; written by Hydrogen's installer; deleted by Hydrogen's uninstaller)++**CLI flags Hydrogen passes when spawning ab**:++```bash+adom-bridge.exe --embedded --start-hidden \+ --relay-url ws://127.0.0.1:8765 --relay-name hydrogen-desktop \+ --session-token <hd's-stored-token>+```++- `--embedded` → enter embedded mode (also writes the marker)+- `--start-hidden` → boot with main window invisible. **v1.8.119+: honored in embedded mode** (Hydrogen owns the surface; ab stays in the tray and opens via `desktop_window_show`). v1.8.102–118 had a bug where embedded mode force-showed the window even with `--start-hidden`; that's fixed. The machine owner can still flip ab's "Always show the window at launch" Settings checkbox (config `always_show_window_on_launch`, default OFF) to override and always show — a personal-dev-box preference, not the product default.+- `--shell-approval <prompt|1h|24h|permanent>` → Hydrogen's passthrough of its user's shell-approval choice (v1.8.116+, session-only)+- `--relay-url <url>` → upsert this relay via existing `server_add` dedup; default name `hydrogen-desktop`+- `--relay-name <name>` → override the default name+- `--session-token <tok>` → already covered by Phase 4 handoff (env / CLI arg / file)++**Docker introspection** — `adom-bridge desktop_embedded_status` returns `{embedded, owner, source, enteredVia, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` so cloud-side callers can branch on whether ab is standalone or Hydrogen-managed. **v1.8.73+:** `enteredVia` reports `"launch-flag"` (Hydrogen spawned ab as a child via `--embedded`) or `"runtime-adopt"` (ab was running standalone; Hydrogen adopted it at runtime) — see "Runtime adopt + release" below.++**New verbs Hydrogen uses to drive ab** (all also usable standalone):++| Verb | Purpose |+|---|---|+| `desktop_window_show` | Bring ab's main window to foreground |+| `desktop_window_hide` | Hide ab's main window (app keeps running) |+| `desktop_connect_all` | Spawn ws_loop for every enabled server |+| `desktop_disconnect_all` | Drop every live WS connection (entries stay in config) |+| `desktop_shutdown` | Explicit ab termination (stop bridges, then exit) |+| `desktop_embedded_status` | Introspect current embedded state (incl. `enteredVia`) |+| `desktop_embedded_enter` | **v1.8.73+** runtime adopt: standalone → embedded as `runtime-adopt` |+| `desktop_embedded_release` | **v1.8.73+** revert to standalone. **v1.8.77+: always reverts, never exits ab** (regardless of `entered_via`) — use `desktop_shutdown` for explicit exit |+| `desktop_logout` | Clear ab's `~/.adom/session.json` (Hydrogen's sign-out propagation) |++#### Runtime adopt + release (v1.8.73+) — the LaunchFlag-vs-RuntimeAdopt distinction++Hydrogen has two ways to put ab into embedded mode:++1. **Spawn ab with `--embedded`** (LaunchFlag) — Hydrogen launches a fresh ab process as its child. ab enters embedded with `entered_via=launch-flag`. When Hydrogen subsequently dies ungracefully (without calling `/embedded/release` or `desktop_shutdown` first), ab's 60s `embedded_monitor` tick notices Hydrogen's control port is dead and **exits ab** (the "no hanging chad" rule — no tray-less orphan).+2. **Adopt at runtime via `POST /embedded/enter`** (RuntimeAdopt) — ab was already running standalone; Hydrogen calls the direct-API endpoint to flip ab into embedded mode without a respawn. ab enters embedded with `entered_via=runtime-adopt`, preserving its open state, current PID, all in-flight connections. When Hydrogen subsequently dies ungracefully, ab's monitor **reverts ab to standalone** — tray restored, footer pill hidden, marker deleted. ab process stays alive.++The rule is simple: **whoever started ab owns ab's death**. If ab started itself (standalone → runtime adopt), Hydrogen's death doesn't take ab down with it.++**Two new direct-API endpoints** (v1.8.73+, sugar over `POST /command`):++```bash+# Adopt — runs the same handler as desktop_embedded_enter:+curl -s -X POST http://127.0.0.1:47200/embedded/enter++# Release — runs the same handler as desktop_embedded_release.+# v1.8.77+: ALWAYS reverts to standalone. ab never exits via this call.+curl -s -X POST http://127.0.0.1:47200/embedded/release+```++**Probe target (v1.8.75+):** the 60s `embedded_monitor` checks Hydrogen's *control port* (default `47084`, discovered from `%APPDATA%\hydrogen-desktop\ports.json` key `control`), not Hydrogen's legacy `:9001` webview-eval port. Same discovery `hd_api` uses. Pre-v1.8.75 this was hardcoded to `:9001` — Hydrogen doesn't bind that port anymore, so the probe always reported Hydrogen dead and every embedded ab reverted/exited within 60s of Hydrogen starting.++**Live frontend update (v1.8.76+):** the "Embedded · Hydrogen" footer pill responds to Tauri `mode-changed` events emitted on every transition. So if ab's window is open when Hydrogen adopts it (or Hydrogen releases it), the pill flips immediately — no page reload, no manual refresh.++**Stale-marker safety (v1.8.74+):** if the marker file says `entered_via=launch-flag` AND Hydrogen's control port isn't responding at boot, ab deletes the marker BEFORE exiting. Without the delete, every subsequent manual launch (no `--embedded` flag) would hit the same exit branch and ab would be unlaunchable until the user manually rm'd the marker. The delete-before-exit makes the next launch start standalone normally.++**Shell auto-approve is a user preference** (v1.8.116+ — replaces the v1.8.44 embedded-permanent default). The safe default everywhere is per-command approval. Resolution order at ab boot:++1. `--shell-approval <prompt|1h|24h|permanent>` launch flag — Hydrogen's passthrough of its user's choice when spawning ab embedded. **Session-only**: never persisted by ab.+2. The durable Settings checkbox ("Auto-approve shell commands permanently", config field `shell_auto_approve_permanent`, default OFF). Also set/cleared via `shell_auto_approve '{"permanent": true|false}'`.+3. A restored timed grant from a previous session (+1h/+24h buttons, `duration_secs` calls).+4. Otherwise: prompt per command.++In embedded mode **Hydrogen owns the preference** (Hydrogen is the parent, ab the child): ab's Settings checkbox goes read-only with "Managed by Hydrogen", and the footer shows "**Permanent** *(managed by Hydrogen)*" when armed. Hydrogen changes it at runtime with the same verb:++```bash+# revoke everything (timed + permanent + the saved checkbox):+adom-bridge shell_auto_approve '{"duration_secs": 0}'++# set the durable preference (equivalent to ticking the Settings checkbox):+adom-bridge shell_auto_approve '{"permanent": true}'+```++Migration note: pre-1.8.116 embedded boots persisted a permanent grant that leaked into standalone sessions. On first v1.8.116 boot, a permanent-class grant on disk WITHOUT the config preference is cleared — re-opt-in via the checkbox or the verb if you actually wanted it.++**⚠ v1.9.90 — a cloud/relay AI CANNOT grant shell approval itself.** The `shell_auto_approve` examples above are Hydrogen's (a trusted LOCAL caller) or the human's own. From a **relay** connection (the cloud AI), a GRANT (`shell_auto_approve {"permanent":true}` / `{"duration_secs":N>0}`) is REFUSED with `errorCode:"human_only"` and instead POPS the human approval prompt in Adom Bridge. **The AI's path: call `request_shell_approval` (or just let the gate/refusal auto-surface the prompt) → ask the user to click Approve 1 hr / 24 hr / Bypass Permissions → RETRY your original verb.** Only a human clicking in ab (or Hydrogen proxying its own user) grants. A REVOKE (`duration_secs:0`) is allowed for the AI.++#### Bridge ports are dynamic — you never need to know one (v1.8.31+)++KiCad, Fusion, Browser/Puppeteer, and all third-party bridges now bind OS-assigned ephemeral ports (not the legacy 8772/8773/8851 you may remember). The runtime port changes every spawn. **Callers never need to know it.**++- The CLI verb namespace (`kicad_*`, `fusion_*`, `pup_*`, plus any third-party `<bridge>_*`) is the contract. Always go through that.+- adom-bridge's direct API forwards to whatever port each bridge is on at the moment of the call.+- `bridge_list` reports `spawn.runtimePort` per bridge for debugging — do NOT hardcode it anywhere.+- Why this changed: Hydrogen's bridges also wanted 8772/8773/8851; we used to collide silently. Dynamic ports = clean coexistence.++### Managing bridges (the AI fully controls bridges on its own)++Bridges are ab's plugins (kicad, puppeteer, fusion360 ship bundled; others install from the wiki). You — the AI — can see, update, install, remove, pause, and resume them entirely through verbs. No human step is required.++| Verb | What it does |+|---|---|+| `bridge_list` | Every installed bridge: `name`, `displayName`, `version` (what ab runs now), `source` (bundled / cache / third_party), `paused`, spawn kind, verbPrefixes. Start here. |+| `bridge_check_updates` | **READ-ONLY.** Per bridge: `current` vs `latest` (wiki) + `updateAvailable`. Downloads nothing. The "what's behind?" probe. |+| `refresh_bridges` | Apply updates. No args = sync ALL bridges from the wiki. `{"name":"kicad"}` = update just one. |+| `bridge_install` | Install a new third-party bridge from a wiki manifest URL (`{"manifestUrl":"…/<bridge>-bridge-manifest.json"}`). On `success:true` the **capsule appears in the ab GUI immediately** (no verb call needed; it's gray/idle until first use, then green). On `success:false` **nothing is registered and no capsule appears** — read `error` (a `read zip body`/`builder error` means the manifest's zip URL couldn't be fetched). Don't treat a parsed manifest as a completed install; confirm with `bridge_list`. The manifest `url` may be relative (resolved against the manifest's dir, v1.8.169+). |+| `bridge_uninstall` | Remove a bridge (`{"name":"…"}`). Bundled ones revert to the bundled copy; third-party ones are fully removed. |+| `bridge_pause` / `bridge_resume` | Flip a routing flag so a bridge's verbs fail-fast with `bridge_paused` (or resume). Does NOT kill the process. |+| `bridge_dev_mode` | **The DEV PIN** (`{"name":"kicad","on":true}`): freeze that ONE bridge against ALL automatic updates (the 4h sweep, on-launch sync, on-use auto-update, refresh_bridges) so a developer can modify its installed cache without ab pulling the release over their work. `on:false` releases it. `bridge_list` shows `devPinned:true` and the ab GUI card shows an amber "dev pin" badge with a one-click Unpin. **Etiquette: if a bridge shows `devPinned:true`, a human is mid-development on it - never unpin or force-update it to "get the latest" without asking them.** If YOU are about to edit a bridge's installed files, pin it FIRST. |++**Naming convention for published bridges (3rd-party authors, read this):** when you publish a bridge to the Adom wiki, give its **page title** the form the live bridge pages use: **`<Tool> - the <Tool> Bridge`** (spaced hyphen), e.g. `KiCad - the KiCad Bridge`, `Fusion - the Fusion 360 Bridge`, `Altium - the Altium Bridge` (pup, which is a browser rather than a tool bridge, is `Pup - the AI's own browser`). It applies to the **wiki/catalog title only**, NOT `bridge.json` `displayName` (the in-app chip label, which stays tool-descriptive like `Altium Designer`) or `name` (the lowercase slug). Full authoring walkthrough: the `adom-bridge-sdk` skill (ships in this package) and its `adom-bridge-sdk-*` sub-skills.++**Typical "am I on the latest bridges?" flow:**+```bash+adom-bridge bridge_check_updates # read-only: see current vs wiki per bridge+# → {bridges:[{name,current,latest,updateAvailable,source,wikiReachable}], updatesAvailable:N}+adom-bridge refresh_bridges # update ALL that are behind+adom-bridge refresh_bridges '{"name":"kicad"}' # or just one+```++**Version semantics (so the numbers make sense):**+- `current` = what ab actually runs = the newer of the writable cache and the bundled copy (version-aware, numeric semver — `1.0.10 > 1.0.9`).+- A bundled bridge that ships AHEAD of the wiki shows `updateAvailable:false` — ab correctly keeps the newer bundled copy. **puppeteer is bundled-only** (its node_modules deps exceed the wiki file limit), so it never shows a wiki update; it updates with the ab installer.+- After an `updated` action, the bridge SERVER PROCESS still holds the old code in memory until it respawns. `is_bridge_running` is version-aware (v1.8.142+) and forces a respawn on the next verb call, so you normally don't need to kill anything — but if you want it instant: `taskkill /f /im python.exe` (kicad/fusion) or `node.exe` (puppeteer).++**Auto-update cadence:** a running ab also auto-syncs bridges on launch + every 4 hours. `bridge_check_updates` / `refresh_bridges` (or the GUI's **"Check for updates"** button next to "+ Bridge") are the on-demand "I just published, pick it up now" path.++#### Bridge lifecycle — one instance, stable port (v1.8.153+)++A dynamic bridge gets **one stable port** that ab reuses across every spawn and across ab restarts (persisted in `~/.adom/bridge-ports.json`). A long-lived external client (e.g. a browser extension that dials the bridge) keeps reaching the same port — no chasing a fresh ephemeral port each cycle, no "the client is on bridge A but ab routes to bridge B" split. ab enforces a **single instance**: it tracks every spawned bridge PID, reaps a stale/wedged one before respawning on the same port, and `bridge_stop_all` reaps orphans too.++`spawn.persistent` (in `bridge.json`) is now surfaced in `bridge_list` (`spawn.persistent`) and means: **ab auto-respawns this bridge on death**, on its stable port (a supervisor runs each ~15 s). So "kill the bridge to load new code" → ab brings it right back at the same port the client keeps dialing. `bridge_install` / `refresh_bridges` also no longer fail with the cache-dir lock (ab reaps the process holding the dir as its cwd, then swaps; in-place merge fallback if a handle lingers).++**v1.8.186/187 — first-class bridges (panel + remote management + optional manifest fields).** The ab window has a full-height **Bridges sidebar** that shows each bridge as a mini-app (hero image, LED, rich tooltip); clicking one opens a wide pop-out with a **live console** (its `~/.adom/bridge-logs/<name>.log` + the per-bridge command history), a **ports/process debug view** (live PIDs — `>1` flags a runaway), persistent-vs-one-off + codebase-language badges, and all management actions (install/uninstall/**kill**/restart/pause/resume + "filter activity log to this bridge" + "copy contribution prompt"). The same management is available **remotely** to cloud/WSL2 threads via verbs: `bridge_info`/`bridge_detail` (rich aggregate), `bridge_log_read {name, sinceOffset?}` (**cursor-streaming remote tail -f**), `bridge_kill` (wipe one bridge's processes). As of v1.8.186 **all** bridges write their per-bridge log (bundled kicad/fusion360/puppeteer included, not just dynamic ones). New **optional** `bridge.json` fields (all back-compat, surfaced in the panel): `hero` (a relative filename on your wiki page, or a full URL — your app-icon; absent → a monogram tile), `languages` (override the auto-scan, e.g. `["Python"]`), `releasedAt` (ISO date; absent → bridge.json mtime).++**v1.8.156 — strays + self-documenting hints.** ab now tracks every bridge PID it spawns (persisted across ab restarts) and **reaps all stray instances** before respawning — so you don't end up with 4 copies of one bridge fighting over the routing. `bridge_list` surfaces `spawn.instanceCount` per dynamic bridge (normally 0 or 1; **>1 = strays piled up** — visible at a glance). If a bridge verb fails to reach a healthy bridge, the error `_hint` tells you to retry / check `instanceCount` / `process_list`; and a verb response right after a (re)spawn carries an `_ad_note` saying the external host reconnects within a few seconds — **retry** instead of treating a transient "not connected" as terminal. For verb discovery from a bridge, `GET <ADOM_DIRECT_API_URL>/commands` lists every reachable verb (see [`skills/DIRECT_API.md`](DIRECT_API.md)).++#### Bridge status chip — self-reported LED + tooltip (v1.8.152+)++Each bridge has a status dot in the ab GUI sidebar. **ab is a pure renderer of bridge health** — a live bridge decides its own LED color and hover text; ab just paints what the bridge reports. This replaced the old scheme where ab inferred the LED from whether it was holding a spawn handle, which left fire-and-forget (dynamic/installed) bridges stuck RED even while healthy and routing.++**The contract (all three fields OPTIONAL).** On the endpoint ab already polls — your `bridge.json` `healthEndpoint` (default `/health`; the sample bridges use `/status`) — return a 2xx JSON body that MAY include:++| Field | Type | Meaning |+|---|---|---|+| `led` | `"green"` \| `"yellow"` \| `"red"` | The dot color. Green = healthy; yellow = degraded/warning; red = the bridge itself knows it's broken (e.g. lost its upstream). |+| `summary` | string | Short label shown next to the dot. Keep it tiny (≤ ~24 chars) — e.g. `"2 windows"`, `"ext disconnected"`. |+| `tooltip` | string | Full hover text. Newlines allowed; no hard length limit (keep it readable). |++**Rules ab enforces:**+- Endpoint returns 2xx **with** `led`/`summary`/`tooltip` → rendered **verbatim**, no parsing or logic.+- Endpoint returns 2xx **without** them → ab defaults to **green** (you're reachable) + a generic tooltip. Existing bridges work unchanged — this is opt-in.+- Endpoint **unreachable** → ab owns this ONE state: a **gray/offline** dot + ab's own tooltip ("Not running" / "Bridge not reachable"). A dead bridge can't self-report, so ab never lets a bridge claim "offline" — it detects it.++**Cadence:** ab re-polls every ~15 s, and a dynamic bridge flips green the instant it spawns. So a bridge that surfaces live counters (open windows, queue depth, ext-connection state) in `summary`/`tooltip` updates in the GUI within a tick.++Reference implementations: [`scripts/sample-bridges/hello-python/server.py`](../scripts/sample-bridges/hello-python/server.py) and [`hello-rust/src/main.rs`](../scripts/sample-bridges/hello-rust/src/main.rs) both self-report `led`/`summary`/`tooltip` on `/status` — fork either.++### File Transfer++- `send_files` -- Send files from the Docker container to the desktop. Files are base64-encoded in transit.+ - `filePaths`: array of absolute paths on the server+ - `targetApp`: "kicad", "fusion360", or "general"+ - `dest`: **absolute** Windows path (under a safe root) to land the files exactly where you want (v1.8.148+). Prefer this when you know the target dir. A folder is created if missing. For ONE file, `dest` may be the full file path instead. A `dest` ending in a slash or in `.pretty` / `.3dshapes` / `.app` / `.bundle` / `.framework` is always a folder (2.1.163+), so a KiCad footprint library works directly: `dest: "C:/Users/<you>/Documents/proj/lib/mylib.pretty"`.+ - `destIsFolder` (2.1.163+): `true` forces `dest` to be a folder for any other dotted folder name; `false` forces it to be the file path.+ - `destinationFolder`: **relative subfolder only** (e.g. `"kicad/symbols"`, `"fusion"`). The desktop app controls the base directory. **Absolute paths are rejected.** With neither `dest` nor `destinationFolder`, files land in the laptop's **Downloads** folder.+ - Returns `destinationPaths[]` with the absolute path of every saved file, plus (v1.8.168) `savedToDir` (the folder) and a rich `_hint`. **The files land on the LAPTOP (Windows host), not in your container** — verify with `list_dir`, pull one back with `pull_file`.+ - **`send_files` only COPIES bytes — it does NOT install or register a bridge.** Dropping bridge files with `send_files` will NOT make a capsule appear or verbs route. To add a bridge use `bridge_install '{"manifestUrl":"…/<bridge>-bridge-manifest.json"}'`, then confirm with `bridge_list`.++- `pull_file` -- Pull files from the desktop to the container.+ - `filePaths`: array of absolute Windows paths on the desktop+ - `saveTo`: directory on the container to save files (default: `/tmp`)+ - **Streaming since v1.4.3.** Each file is transferred as 1 MiB binary WS frames straight to disk on the Docker side, with incremental SHA256 verification. The legacy 30s base64-JSON path is gone — large files (50 MB+ datasheets, 75 MB reference manuals) no longer time out. Per-file timeout is 600s.+ - Returns `files: [{name, path, size, sha256, chunks}]`. Use `sha256` to verify the transfer (the desktop side computes it during streaming and the container side verifies on completion; mismatch deletes the partial file and reports failure). `chunks` is the count of 1 MiB binary frames received.+ - When at least one but not all files succeed: `success` is `true`, `errors[]` lists the failures alongside `files[]`. When ALL fail: `success` is `false`.++### Desktop Notifications++- `notify_user` -- Send a native Windows toast (own AUMID + Adom logo). v1.9.33: rich types — progress bar, sticky reminder, action buttons that **bring ab to the foreground** on click + round-trip via `notify_response`.+ - `title`, `body`, `level` (info/success/warning/error/emergency), `actions` (label buttons) — plus `scenario` ("reminder"=sticky), `durationLong`, `buttons` (`[{label, arguments?}]`), `progress` (`{title, value 0..1, status, valueOverride}`), `inputs`, `id` (correlation id for the round-trip).+ - Full guide + copy-paste examples per type: the **adom-bridge-notify** skill ([`skills/NOTIFY.md`](NOTIFY.md)).+- `notify_response` -- Poll which toast button the user clicked, by correlation `id`. `{pending:true}` until they click; `{pending:false, action}` once they do (consumed on read).++### KiCad (`kicad_*`) — bridge-owned; light pointer++The KiCad bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/kicad-bridge`). This page keeps only the pointer: run `adom-bridge sync_skills` after connecting and the full KiCad guide (project workflows, three-tier `kicad_open_*` routing, DRC recipes, library install) lands in `~/.claude/skills/`.++Its verbs: `adom-bridge commands '{"bridge":"kicad"}'` (the bridge describes itself).++If KiCad is not installed, the bridge knows how to install it for the user — offer, never point them at a download page. Full details: the bridge's own skill.++### Fusion 360 (`fusion_*`) — bridge-owned; light pointer++The Fusion 360 bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/fusion-bridge`). Run `adom-bridge sync_skills` and the full guide (cloud files, import/export, add-in troubleshooting, APS search) lands in `~/.claude/skills/`.++Its verbs: `adom-bridge commands '{"bridge":"fusion360"}'`.++Respect `timeoutSeconds` + `statusVerb` on responses instead of re-issuing a slow verb. Full details: the bridge's own skill.++### Launching apps (generic)++Two generic CLI commands exist for launching any Windows executable safely — they verify the target exists BEFORE handing it to the OS, so you never trigger a "Windows cannot find" GUI dialog:++- `adom-bridge find_exe '{"name":"..."}'` -- Resolve an exe by absolute path, glob (e.g. `C:/.../webdeploy/production/*/Fusion360.exe` returns newest), bare name (searches PATH + Start Menu `.lnk` targets). Returns `{path, source}`. Does NOT launch.+- `adom-bridge launch '{"path":"...", "args":[...], "cwd":"...", "detached":true, "reason":"..."}'` -- Same resolution rules as `find_exe`, then spawns. Fails in terminal (exit 1) with a clear error if the path doesn't exist. **Always prefer this over raw `start`.**++**Where they run (2.1.143, wiki #210).** With `--target <box>`, or from a container / Linux CLI, both verbs run ON THAT BOX through its ab (mesh first, relay fallback, the same path as `shell_execute`): the path is resolved against the box's filesystem and the app starts there; the reply says `on: "<box>"`. Before 2.1.143 both ran inside the CLI process, so `--target arav-rog launch {path}` answered `find_exe: could not locate` for a file that existed on arav-rog. Only a Windows CLI with no `--target` still resolves and spawns locally. On a box `launch` is a Tier-2 exec verb like `launch_process` (gated the same way, `reason` required); `find_exe` is read-only. A target ab older than 2.1.143 answers unknown verb: use `launch_process {path, args}` there.++**A bridge's verbs are documented by the bridge, not here (2.1.146, wiki #212).** `commands` lists the bridges the box has; `commands {"bridge":"<name>"}` is the bridge's own description; `sync_skills` installs its skill. Nothing below is about any one bridge.++### Desktop Tools++- `desktop_close_window` -- Gracefully close a window (WM_CLOSE, so save prompts fire). Resolves `hwnd` or `titleContains`; optional `forceAfterMs` kills the owning process only if the window outlives the grace period. Returns MEASURED `closed`/`forced`. Prefer this over process_kill for anything with unsaved state.+- `desktop_wait_for_window` -- Block (bounded timeout) until a window matching `titleContains`/`className`/`pid` appears, or with `state:"absent"` until it goes away (a dialog dismissed, an app exited). The honest replacement for sleep-and-rescreenshot loops: `adom-bridge desktop_wait_for_window '{"titleContains":"Save As","timeoutMs":10000}'`.+- `desktop_open_folder` -- Open a file or folder in Windows Explorer. If given a file path, Explorer opens with the file highlighted (selected). Args: `path` (file or folder path).+- `desktop_open_url` -- Open a URL in the user's **NATIVE OS BROWSER** — i.e. the real Edge / Chrome / Firefox / Brave they use every day, with their saved logins, history, bookmarks, and extensions. This is NOT pup, NOT Chrome for Testing — it's the browser the human actually uses. Hand it off when the user needs to interact with a logged-in account themselves.+ - Args: `url` (required string), `browser` (optional: `"default"` (Windows-registered default — could be Edge, Chrome, Firefox, Brave; whatever they set), `"chrome"`, `"edge"`, `"firefox"`, `"brave"`).+ - Examples:+ - `adom-bridge desktop_open_url '{"url":"https://claude.ai/"}'` -- opens in user's default native browser, already signed in+ - `adom-bridge desktop_open_url '{"url":"https://docs.example.com","browser":"edge"}'` -- force native Edge+ - Returns `{ok, browser, url, exePath?}`. Allowed schemes: http, https, mailto, ftp, ftps. Other schemes refused.++#### `desktop_open_url` vs `pup_open_window` — two completely different browsers++| | `desktop_open_url` | `pup_open_window` |+|---|---|---|+| **What browser** | Native OS browser the user uses daily — Microsoft Edge / Google Chrome / Mozilla Firefox / Brave (whichever they've set as Windows default, or the one you name explicitly) | Puppeteer-controlled **Chrome for Testing** — a separate Chromium build that pup launches |+| **Profile / data** | The user's real profile: saved logins, history, bookmarks, extensions, autofill | Isolated profile under `plugins/puppeteer/profiles/<sessionId>/` — empty, no saved logins |+| **Who interacts with the page** | The HUMAN. Claude hands the URL off and is done. | CLAUDE drives it programmatically — screenshots, clicks, eval, recording. The human just watches. |+| **Use when** | A login is required (claude.ai, GitHub, internal tools, banking) — the human's existing session must be reused | Automation, scraping, screenshot capture, video recording, headful UI testing |+| **Can Claude control it after launch?** | No — once handed off, Claude can't see or drive it | Yes — every `pup_*` verb (screenshot, eval, navigate, record, etc.) |++**Decision rule:** human-in-the-loop login flow → `desktop_open_url`. AI-driven automation → `pup_open_window` (defaults to Chrome for Testing). AI-driven automation that **must** be the user's real, signed-in profile (GCP Console with org/projects, internal SSO behind a managed-Chrome policy, anything Context-Aware Access blocks for non-enrolled browsers) → `pup_open_window` with the new `nativeBrowser` + `nativeProfile` args (v1.0.5+, see below).++#### ⚠️ Common footgun — `localhost` / `file://` URLs from Docker AIs++If you're running this CLI from a remote container, a `url` you pass to ANY verb (a bridge's window, `desktop_open_url`) is loaded on the DESKTOP: `localhost` there is the desktop, not your container, and `file://` names a file on the desktop's disk.++**v1.7.10+ detects this.** When you pass a URL whose host is `localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, or scheme is `file://`, the response includes a `_hint` field explaining the issue and suggesting the public-slug URL pattern (`https://<user>-<repo>-<suffix>.adom.cloud/proxy/<port>/<path>`). **The call still proceeds** — rare legitimate cases exist where the user actually does have something running on their localhost.++**Recipe — load something from inside your container into pup:**++```bash+# 1. Start your service inside the container (e.g. a local HTML viewer):+python3 -m http.server 8786 --bind 0.0.0.0 &++# 2. Ask your USER for the container's adom.cloud slug suffix+# (look at their browser URL when they open hydrogen or claude.ai/code).+# Slug looks like: <username>-<container>-<random-id>++# 3. Open the public URL — NOT localhost — in whichever browser verb you use:+adom-bridge desktop_open_url '{"url":"https://<your-container-slug>.adom.cloud/proxy/8786/foo.html"}'+```++If you don't have the suffix handy, just ask the user. Or if the file you wanted to show pup is one your container already wrote to disk, push it to a public location (wiki asset, S3) and load from there.+++- `desktop_bring_to_front` -- Bring a window to the foreground by HWND or title substring. Uses keybd_event + AttachThreadInput to bypass Windows foreground lock. Preserves maximized state.+ - Args: `hwnd` (int) OR `titleContains` (string, case-insensitive). One required.+ - Example: `adom-bridge desktop_bring_to_front '{"titleContains": "Fusion"}'`+- `desktop_set_window_state` -- Change a window's show state without bringing it to the foreground (or both, if combined with `desktop_bring_to_front`). Args: `hwnd` (int) OR `titleContains` (string) OR `cacheKey`, plus `state` (one of `"maximize"`, `"minimize"`, `"restore"`, `"show"`, `"hide"`, `"bottom"`). Use to ensure Fusion or KiCad windows are maximized for screenshots, or to hide noisy background windows during demos.+ - Example: `adom-bridge desktop_set_window_state '{"hwnd":133560,"state":"maximize"}'`+ - **`state:"bottom"` (v1.9.114) -- z-order send-to-back, background.** Drops the window BEHIND all others without focusing, moving, or resizing it (`SetWindowPos HWND_BOTTOM` + `SWP_NOMOVE|SWP_NOSIZE|SWP_NOACTIVATE`); works even on an off-screen window. It's an ATOMIC check-and-demote: it reads `GetForegroundWindow()` first, and if the target IS the OS foreground (the user is interacting with it) it SKIPS the demotion and returns `wasForeground:true, sentToBack:false` so the caller backs off with no race; otherwise `wasForeground:false, sentToBack:true`. Use to park a background window behind the user's windows (still on-screen, taskbar-clickable, rendering for screenshots) without stealing focus.+ - **`force:true` (v1.9.115)** sends it to back EVEN IF the window is currently the OS foreground (still no activation change; `sentToBack:true` always, `wasForeground` still reported). Use at window-LAUNCH time: Windows reassigns the foreground to a brand-new window when the previous foreground is destroyed, so "foreground at launch" is not a real user signal.+ - Example: `adom-bridge desktop_set_window_state '{"titleContains":"(session: abc","state":"bottom","force":true}'`++#### Real OS input — mouse + keyboard (v1.8.170+)++`desktop_click` / `desktop_double_click` / `desktop_right_click` / `desktop_hover` / `desktop_type` / `desktop_press_key` synthesize **real OS input via `SendInput`** — so they land on **`chrome://` pages and native dialogs that browser CDP (`nbrowser_*` / `pup_*`) cannot reach**. The verb names mirror the browser extension's `nbrowser_*` so the model transfers between in-page and whole-desktop driving.++- **You must say WHY (v2.0.84).** Every verb that takes over the screen - the four mouse verbs, `desktop_type`, `desktop_press_key`/`send_keys`, `desktop_bring_to_front`, `desktop_raise_window` - requires a **`reason`** from a relay caller, or it is refused with `errorCode:"reason_required"`. The reason is not for the log: ab renders **"`<your thread>`: `<your reason>`"** on the user's screen next to the glowing cursor, at the moment their machine moves. Write it for the human watching, not for a log line - `reason:"clicking Next on the Altium installer"`, not `reason:"sending a click"`.+- **Gated behind shell-auto-approve** (same risk class as `shell_execute` — keystrokes/clicks hit whatever is focused on the user's live machine). If it's off you get `errorCode:"input_not_approved"`. **You (a cloud/relay AI) can't grant it yourself (v1.9.90)** — call `request_shell_approval` (or let the refusal auto-surface the prompt) and ask the user to click Approve / Bypass Permissions in Adom Bridge, then retry. Every action is written to the activity log.+- **Focus a window first** by passing `{window:"<title substring>"}` (alias `titleContains`) or `{hwnd:<int>}` — reuses `desktop_find_window`'s resolver, so input lands on the intended window, not whatever happened to be focused. Get the hwnd from `desktop_list_windows` / `desktop_find_window`.+- **Coords are physical screen pixels** — the same space as `desktop_list_windows` rects.++**The four verbs that MOVE THE MOUSE each answer to a `mouse_*` name too (v2.0.84).** They are the same verbs, not new ones - the pointer verbs were named after what they do to a control (`click`, `hover`), so searching a verb list for "mouse" found nothing that moves the mouse. Use whichever name you reach for:++| moves the mouse | alias |+|---|---|+| `desktop_hover` (also `desktop_move`) | `desktop_mouse_move` |+| `desktop_click` | `desktop_mouse_click` |+| `desktop_double_click` | `desktop_mouse_double_click` |+| `desktop_right_click` | `desktop_mouse_right_click` |+| `desktop_mouse_drag` (2.1.143) | `desktop_drag` |++**Drag (2.1.143, wiki #205).** `desktop_mouse_drag {from:{x,y}, to:{x,y}, steps?:12, durationMs?:300, button?:"left", space?:"screen"|"image"|"window", hwnd?|titleContains?, reason}` presses at `from`, moves the real cursor through `steps` interpolated points over `durationMs`, releases at `to`. It is for canvases with no view controls that only react to a drag: orbit a 3D preview (KiCad's Footprint Chooser pane), a splitter, a slider, a lasso. A canvas that tracks motion ignores a teleport, so the pointer really travels; slow canvases want `durationMs` 600+. With `hwnd`/`titleContains` ab raises that window, verifies it is under `from` before pressing (else `target_not_under_cursor`, nothing pressed), and gives the prior foreground back afterwards (`restoreForeground:false` keeps it up). `space:"image"` + `shotId` takes screenshot pixels; `space:"window"` takes window px or `{fx,fy}` fractions of the window rect. The reply lists the `points` actually sent. Same gate and same UIPI limit as `desktop_click`.++An alias is gated exactly like the verb it aliases - it is not a way around the reason requirement below.++Verbs:+- `desktop_click {x, y, button?:"left"|"right"|"middle", double?, window?|hwnd?}` — left-click by default. `desktop_double_click` / `desktop_right_click` are wrappers; `desktop_hover` moves the cursor without clicking (reveals tooltips).+- `desktop_type {text, window?|hwnd?}` — types a literal Unicode string (`KEYEVENTF_UNICODE`, any keyboard layout).+- `desktop_press_key {keys:[...], repeat?, window?|hwnd?}` (alias `desktop_send_keys`) — chords/shortcuts. Modifiers `ctrl/alt/shift/win`; named keys `enter/tab/esc/space/backspace/delete/insert/home/end/pgup/pgdn/up/down/left/right/f1-f12`.++Canonical "navigate a browser to a URL" flow (works on `chrome://` where CDP can't):+```bash+adom-bridge desktop_press_key '{"keys":["ctrl+l"],"window":"Edge"}' # focus Edge + select the address bar+adom-bridge desktop_type '{"text":"edge://extensions","window":"Edge"}'+adom-bridge desktop_press_key '{"keys":["enter"],"window":"Edge"}'+```+For a control that isn't reachable by CDP/coords (e.g. the Reload button on `edge://extensions`, which lives in a shadow DOM), Tab-navigate to it then Enter: `desktop_press_key {"keys":["tab","tab","enter"]}`.++**Visual "Adom cursor" (v1.8.171+)** — a click-through teal cursor + a label that narrates what you're driving (trust cue; byte-faithful to the browser extension's cursor). Pure visual, *not* gated:+- `desktop_cursor {action:"show"|"move"|"hide", x, y, label}` — drive/narrate the cursor on its own ("watch me"). Returns the overlay `hwnd`.+- **On by default since v2.0.84** on `desktop_click`/`desktop_double_click`/`desktop_right_click`/`desktop_hover` (and their `mouse_*` aliases): ab glides the teal cursor to the target *then* does the real click, so the human always sees Adom is driving rather than an unattributed pointer jump. Pass `label:"Clicking Reload"` to narrate it in your own words; with no label it shows "`<your thread>`: `<your reason>`". Pass `cursor:false` only when the pointer move must be invisible. It was opt-in before, which meant the user lost the signal precisely when an AI was careless.+- **Auto-hide (v1.8.174+):** the cursor hides itself after `ttlMs` (default **60s**) so it never lingers on the user's screen if you forget to hide it. Each `show`/`move` refreshes the timer. Pass `ttlMs:<ms>` (≤600000) for longer, or `persist:true` to keep it until you explicitly `desktop_cursor {action:"hide"}`. If a cursor you showed was auto-hidden, the **next** cursor/click response prefixes its `_hint` with a NOTE so you learn (no chatty unsolicited message).++#### Background input via UI Automation (v1.8.174+) — no foreground, no focus steal++The mouse/keyboard verbs above use `SendInput` = real OS input, which only lands on the **foreground** window — so ab foregrounds the target first, yanking it in front of the user's work. When you must NOT steal focus, use **UI Automation** instead: `InvokePattern.Invoke()` / `ValuePattern.SetValue()` are **programmatic** — the app services them via its accessibility tree with **no cursor move, no focus change, no foregrounding**. Chromium/Edge expose their a11y tree to UIA, so `chrome://` page buttons/links are reachable **by accessible name** (not coordinates).++- `desktop_find_control {window|hwnd, name|contains, role?}` — **read-only** discovery (ungated). Returns `best:{name, automationId, role, rect, invokable, settable}`. Call this first to learn whether a control is UIA-reachable.+- `desktop_ui_click {window|hwnd, name|contains, role?}` — UIA **Invoke** by accessible name, in the background. Gated behind shell-auto-approve.+- `desktop_ui_set {window|hwnd, name|contains, text}` — UIA **SetValue** on a text field, in the background. Gated.++`role` ∈ `button|edit|link|checkbox|menuitem|tab|listitem|combobox|radiobutton|document|group|spinner`. **Caveat (surfaced in `_hint`, not hidden):** some shadow-DOM controls aren't in the UIA tree — if `find_control` reports `invokable:false`/not-found, the control genuinely needs a **foregrounded** `desktop_click`. So: prefer `desktop_ui_click` (background) → fall back to `desktop_click` (foreground) only when UIA can't reach it.++#### Near-complete UIA surface (v1.9.0+) — enumerate, read, drive, with a foreground-hint contract++v1.9.0 expands UIA from ~3 patterns to a near-complete agent-drivable client. **Every UIA verb now returns a `foreground` block** so you know — *before and after* — whether a call steals the user's screen:++```+"foreground": { "predicted": "background|fallback-possible|foreground", // from the static class + the resolved path, BEFORE acting+ "observed": "background|foreground|n/a", // MEASURED (GetForegroundWindow before/after); reads = n/a+ "raised": true|false, // did THIS call bring a window to front?+ "pathTaken": "uia-pattern|sendinput-fallback|setfocus|read-only",+ "reason": "<short>" }+```++**Shared selector on every verb:** `{hwnd|window(titleContains)}` + `{name|contains|automationId|role}` + `scope:"window"(default)|"popups"|"subtree:<automationId>"` + `nth:<int>` (0-based — target the Nth match without the exact name). Prefer `automationId` (stable, locale-independent). Rects are screen px with `centerX/centerY`; `space:"image"+shotId` is accepted wherever a coordinate is taken.++- **Enumeration (read-only, ungated):**+ - `desktop_ui_tree {window|hwnd, maxDepth?, roleFilter?[], nameContains?, maxNodes?(500), scope?}` — **flagship.** Dump the a11y subtree; nodes carry rect + invokable/settable + value/toggleState/expandState/selected/rangeValue + `children[]`. Honors Chromium's lazy tree (wake-retry); `truncated:true` when it hits `maxNodes` (logged, never silent).+ - `desktop_ui_from_point {x,y | space:"image",shotId,x,y}` — the control under a pixel.+ - `desktop_find_controls {selector, maxResults?(25)}` — ALL matches as an array with `nth` indices (vs `find_control`'s single best).+- **Reading (read-only, ungated):** `desktop_ui_text {selector, range?:"all"|"selection"|"visible"}` (TextPattern); `desktop_ui_get {selector}` (current value/toggle/range/selection/expand state); `desktop_ui_grid_get {selector, row?, col?}` (GridPattern cell + rowCount/columnCount).+- **Actions (gated by shell-auto-approve; background-first via the pattern, SendInput-click fallback only when the pattern is absent → then `raised:true`):** `desktop_ui_toggle {selector, state?}`, `desktop_ui_expand {selector, action?:expand|collapse|toggle}`, `desktop_ui_select {selector, action?:select|add|remove}`, `desktop_ui_scroll {selector, dir?, amount?}`, `desktop_ui_set_range {selector, value}`, `desktop_ui_focus {selector}` (**bg:foreground** — usually raises; reports it), `desktop_ui_window {window, action:minimize|maximize|restore|close|move|resize}`.+- **Events (v1.9.3 — REACT instead of poll; read-only, ungated):**+ - `desktop_ui_watch {event, window?|hwnd?, properties?}` → `{watchId}`. `event` ∈ `focus` (desktop-wide "what got focus") | `window-opened`/`window-closed` (new/closed top-level windows — these three can be global) | `invoke` | `selection` | `text-changed` | `text-selection` | `menu-opened` | `tooltip-opened` | `notification` | `live-region` | `structure` | `property` (these REQUIRE a `window`, else `errorCode:window_required` — avoids a desktop firehose). Cap 16 watches.+ - `desktop_ui_events {sinceSeq?, watchId?, max?}` → DRAIN the buffer (poll cursor; there is NO server-push over the relay). Returns `{events:[{seq, watchId, event, ts, element:{name,role,automationId,rect,hwnd}, eventType|property|changeType}], nextSeq, highestSeq, missedEvents, activeWatches}`. Poll with `sinceSeq=nextSeq`. Events ALSO appear in the ab GUI activity-log. The 500-deep ring is shared across all watches; `missedEvents:true` = you polled too slowly.+ - `desktop_ui_unwatch {watchId|"all"}` → stop. **Canonical use:** waiting on an OTP/confirm dialog → `ui_watch {event:"window-opened"}`, poll `ui_events` until the window appears, then act on it (no busy screenshot loop).+- **Introspection:** `desktop_ui_capabilities {}` — **read-only, ungated.** The foreground-hint TABLE per verb (`staticClass` + measured `{win32, chromium-pattern, chromium-fallback}`). **Consult it BEFORE a call to know if it'll steal the screen, without acting.** `desktop_ui_selftest {confirm?, window?}` writes/refreshes it (empirically measures when `confirm:true`).++**Integrity boundary:** an elevated/admin window or the UAC secure desktop returns `errorCode:"uia_integrity_blocked"` (never a silent no-op) — a normal-integrity bridge cannot drive a higher-integrity process.++#### Zero-math coordinate clicks — `coordMap` + image-space (v1.8.175+)++You no longer recompute scale/offset/DPI to click what you see in a screenshot. Every screenshot verb returns a **`coordMap`**:++```jsonc+"coordMap": {+ "shotId": "shot-w5050328-1",+ "image": { "w": 1372, "h": 1031 }, // the DELIVERED (safe) PNG's pixels+ "screenRect": { "x": 594, "y": 254, "w": 1372, "h": 1031 }, // physical px = input-verb space+ "dpiScale": 1.5,+ "imageToScreen": { "sx": 1.0, "sy": 1.0, "ox": 594, "oy": 254 } // screen = img*s + o+}+```++Two ways to use it:+- **Let ab convert (preferred, zero math):** `desktop_click {space:"image", shotId, x, y}` where `x,y` are pixels in that screenshot. Also works on `desktop_double_click`/`right_click`/`hover`. The `shotId` comes from the capture's `coordMap.shotId` (last ~64 shots retained; re-capture if it expires).+- **Compute yourself:** `screenX = x*sx + ox`, `screenY = y*sy + oy`.++`screenRect` is in the SAME physical-px space as `desktop_list_windows` rects and the `desktop_click` default (`space:"screen"`).++> **Driving a GUI app that pops error boxes / modal dialogs? Read the dedicated [`adom-bridge-window-capture`](WINDOW_CAPTURE.md) skill.** `desktop_screenshot_window {hwnd}` captures ONE window AND the child dialogs / error boxes it owns, even when the window is backgrounded/occluded/minimized (PrintWindow, no focus steal). It is the breakthrough for automating native apps whose dialogs a full-screen grab can't see. The rest of this section is the reference; that skill is the how-and-why.++**Popups (owned):** As of **v1.8.177**, `desktop_screenshot_window` **also captures the window's OWNED popups** — borderless confirm bubbles (Chromium's "Remove '\<ext\>'?"), owned dropdowns, owned dialogs — that don't enumerate in `desktop_list_windows` and aren't visible in a PrintWindow of the main window. They ride back as a top-level **`screenshots[]`** array (the same generic shape `kicad_screenshot_all` uses; the CLI auto-pulls each entry's `fullPath`→`localPath` and `safePath`→`localSafePath`). `ownedPopupCount` is always present (0 = none). Each entry carries `hwnd`, `title`, `kind:"owned_popup"`, and its own `coordMap`+`shotId`, so you can **click the bubble's buttons** with `{space:"image", shotId, x, y}` or drive them in the background by name via `desktop_ui_click {scope:"popups"}`. This is the generic mechanism — there's no app-specific "Chrome bubble" verb; every window screenshot surfaces any owned popups so you get the data and decide. **Use `desktop_screenshot_screen`** (full virtual-screen grab) only for popups *not owned* by your target window, or to see everything at once.++**Screen geometry on every shot (v1.8.178):** every `desktop_screenshot_window` / `_screen` response now carries:+- **`windowRect: {x,y,w,h,monitor}`** — the window's position + size on the overall (possibly multi-monitor) desktop, in physical screen px. Each owned popup entry in `screenshots[]` gets its own **`rect: {x,y,w,h,monitor}`** too — so you know where every captured surface sits, not just its pixels.+- **`display: {virtualScreen, primary{w,h,dpi,scale}, monitors[], monitorOfWindow}`** — the full monitor topology: total virtual bounds, and per-monitor `bounds`/`dpi`/`scale`/`primary`. `monitorOfWindow` indexes into `monitors[]`. (Same data as the standalone `desktop_display` verb, folded in.)+- **`drive: "background"`** — screenshots never foreground the window (PrintWindow/WGC). The companion `_related` field lists sibling screenshot/window verbs.++**Screenshot cache is self-cleaning + self-describing (v1.9.107/108/113):** the CLI auto-pulls every screenshot to ONE user-visible, namespaced dir on the caller's machine (`~/project/screenshots/adom-bridge/`, else `~/.adom/screenshots/`; `ADOM_SHOTS_DIR` overrides) — never `/tmp` anymore. Shots are **bucketed by kind into subfolders**: `window/` (a specific window / app / owned popup) and `screen/` (a full-desktop grab). **Human-readable filenames (v1.9.113):** `<YYYY-MM-DDThh-mm-ss.mmm>[-<hwnd>].png` — a sortable ISO stamp (lexicographic == chronological), plus the `hwnd` for a window capture; the `window`/`screen` kind is NOT repeated in the name because the folder already says it. **One file when small (v1.9.114):** if the shot was already within the safe size cap (the common case) it's a single `<stem>.png` and `localFullPath == localSafePath` — no redundant re-encoded copy. Only a shot large enough to be downscaled produces a `<stem>.full.png` + `<stem>.safe.png` pair (Read the `.safe` one for image analysis; use `.full` for archive). Each shot gets a **`<stem>.json` sidecar** (shotlog-style: `source`, `timestamp`, `kind`, dims + `bytes`, `coordMap`, `rect`; a full+safe pair shares one). **Parent/child grouping (v1.9.111/113):** when `desktop_screenshot_window` returns owned popups in `screenshots[]`, the parent AND each pulled child share the same stem, and each child gets a **`.popup-NN` label** — e.g. parent `2026-07-15T01-14-16.937-986710.safe.png` and children `2026-07-15T01-14-16.937-986710.popup-01.safe.png`, `...popup-02...`. So a plain `ls` shows the whole capture grouped (shared stem) and the role (`.popup-NN`) without opening a sidecar; the sidecar also carries `parent` + `popupIndex`. (`kicad_screenshot_all` returns sibling windows with no parent, so those keep their own names.) A janitor prunes it (7 days / 2 GB / newest kept; sidecars follow their PNG, orphans swept) and drains the old `/tmp/ad-shots` + `/tmp/adom-bridge-screenshots` backlogs, running opportunistically at most once/24h on any CLI call. **You do not need to clean it up or write your own cron.** Every screenshot response carries a `_cacheHint`; verify coverage with **`adom-bridge janitor`** (status: each subfolder, size, policy, last sweep) or force a sweep with **`adom-bridge janitor '{"sub":"run"}'`**. It never touches other tools' files under `~/project/screenshots/` (e.g. shotlog). Tune via `ADOM_SHOTS_MAX_AGE_DAYS` / `ADOM_SHOTS_MAX_GB` / `ADOM_SHOTS_MAX_FILES`, or disable with `ADOM_JANITOR=0`.++**Background vs foreground driving (v1.8.178):** every desktop driving verb carries a structured **`drive`** field (`"background"` | `"foreground"` | `"n/a"`) — in `GET /commands`, in `adom-bridge help <verb>`, and echoed in the verb's own response. **Background** (no foreground / focus steal / cursor move — UIA + capture): `desktop_ui_click`, `desktop_ui_set`, `desktop_navigate`, `desktop_find_control`, `desktop_screenshot_window/_screen`, `desktop_record_window_start`. **Foreground** (SendInput → goes to the focused window, so ab foregrounds first): `desktop_click`/`_double_click`/`_right_click`/`_hover`, `desktop_type`, `desktop_press_key`, `desktop_bring_to_front`. Run **`adom-bridge help background`** for the full grouping. Rule: to act without disturbing the user's window, drive by NAME (`desktop_find_control` → `desktop_ui_click`/`ui_set`); fall back to coordinate clicks/keys only when the control isn't in the UIA tree.++**Click a control by name → coords:** `desktop_find_control` returns `best.rect{x,y,w,h,centerX,centerY}` in screen px — `desktop_click {x:centerX, y:centerY}` to SendInput-click it (handy when `invokable`/`settable` are both false). Target by `name`, `contains`, `role`, or `automationId` (stable, locale-independent).++**Keyboard chords:** `desktop_press_key` accepts a chord as one string `["ctrl+w"]` OR separate tokens `["ctrl","w"]` — both mean Ctrl held + W. A *sequence* of distinct chords uses `+` per element: `["ctrl+l","enter"]`. The reply echoes `chords` showing how ab interpreted your `keys`.++- `desktop_flash_window` (v1.8.152+) -- Flash an **arbitrary** window's taskbar button orange (Win32 `FlashWindowEx`) as an "attention / ongoing activity" cue. Resolve the HWND with `desktop_list_windows` first. Args: `hwnd` (int) OR `titleContains` (string); `mode` (`"until_focused"` default — flashes until the user clicks the window; or `"count"`); `count` (int, `mode:"count"` only, default 3); `stop` (bool — clear an active flash). Returns `{ok, hwnd, title, mode}`. **Lightweight by design** (direct FlashWindowEx, no process spawn) so a bridge can call it after every verb. Distinct from the puppeteer bridge's `pup_alert_window`, which only flashes pup's OWN tracked windows — this flashes any window on the host. For progress + overlay-icon status, use `desktop_taskbar`.+ - Example: `adom-bridge desktop_flash_window '{"hwnd":133560}'` then `adom-bridge desktop_flash_window '{"hwnd":133560,"stop":true}'` to clear.+- `desktop_taskbar` (v1.8.152+) -- Paint **glanceable status** on any window's taskbar button via `ITaskbarList3` (progress bar + overlay icon) + `FlashWindowEx`, so a bridge can show what the AI is doing on the **real app window it drives** (e.g. native-browser putting an "Adom is driving" badge + task progress on the Chrome/Edge button). Works on windows owned by **other processes**. Persistent COM (init once, reuse) — safe to tick progress frequently; no process spawn. Resolve the HWND via `desktop_list_windows`. Args (all channels optional; provide ≥1):+ - `hwnd` (int) OR `titleContains` (string)+ - `flash`: `"until_focused"` | `"stop"`+ - `progress`: `{state: "normal"|"paused"|"error"|"indeterminate"|"none", value?: 0..100}` — **state→color**: normal=green, **paused=yellow** (use for human-in-the-loop gates: captcha / 2FA / login), error=red, indeterminate=marquee, none=clear.+ - `overlay`: `{badge: "adom"|"none", icon?: "<PNG or .ico — file path OR base64>", tooltip?: "<hover>", avatar?, profileDir?, userDataDir?, composite?}` — built-in badges are `adom` (the Adom logo — a "driven by Adom" trust tell) and `none` (clear); 3rd-party bridges pass their **own** `icon` (a **PNG** or `.ico`, file path or base64 — ab magic-sniffs + renders at 32px). ab stays generic: it paints whatever art you hand it. `tooltip` is the overlay's accessible description.+ - **Compositing onto a Chrome/Edge profile avatar (v1.9.110, #154):** Windows gives ONE overlay slot per taskbar button, and Chrome/Edge use it for the **profile avatar** — so a bare `badge:"adom"` would EVICT the profile face (you'd lose "work vs personal"). To keep the avatar AND show "Adom is driving", pass the avatar and ab composites the mark onto it (bottom-right ~47%): `overlay:{badge:"adom", avatar:"<PNG path or base64>"}`, or name the profile with `overlay:{badge:"adom", profileDir:"Profile 1", userDataDir?:"<User Data root>"}` (ab reads `<UserData>/<profileDir>/Google Profile Picture.png`; `userDataDir` is derived from the window's exe if omitted). `composite:false` opts out (raw mark). If the avatar can't be resolved ab falls back to the plain mark — never errors. The CALLER (which knows its profile — e.g. the browser extension) supplies the avatar; ab does not reverse-engineer it (one browser process serves all profiles, so the process command line has no `--profile-directory`).+ - `thumbnailTooltip` (v1.9.153): the taskbar **hover-preview tooltip** (`ITaskbarList3::SetThumbnailTooltip`). The ONLY uncapped text surface on the preview — the window title truncates ~25-30 chars, this string does not (and takes newlines). e.g. `"Adom Pup / Adom wiki / LOGGED-IN view / Chrome for Testing / 3 tabs"`. Pass `""` to clear it back to the title. Works on a window ab does not own.+ - `thumbnailClip` (v1.9.153): crop the live hover preview to a client-space region — `{x,y,w,h}`, or `null` to reset to the whole window (`SetThumbnailClip`).+ - Returns `{ok, hwnd, title, applied:[...]}`. Clears: `flash:"stop"`, `progress.state:"none"`, `overlay.badge:"none"`, `thumbnailTooltip:""`, `thumbnailClip:null` — call these on task-end / window-close to clean up.+ - Example (long task, human gate, then done):+ ```bash+ adom-bridge desktop_taskbar '{"hwnd":133560,"overlay":{"badge":"adom","tooltip":"Adom is driving this browser"},"progress":{"state":"indeterminate"}}'+ adom-bridge desktop_taskbar '{"hwnd":133560,"progress":{"state":"paused"},"flash":"until_focused"}' # captcha — come look+ adom-bridge desktop_taskbar '{"hwnd":133560,"progress":{"state":"none"},"overlay":{"badge":"none"},"flash":"stop"}' # done — clear all+ ```+- `desktop_set_window_identity` + `desktop_register_app_identity` (v1.9.134+) -- **Full window-identity takeover.** Make a window you drive present itself as YOUR app: the taskbar button, Alt-Tab entry, hover tooltip and any user pin read your name and icon instead of the process that happens to own the HWND. Built for a bridge driving a borrowed host app (a Chrome for Testing window otherwise reads "Test" with Chrome's icon, which is two brands and a confusing word). ab is generic here — it applies whatever `appId` / `iconPath` / `displayName` you pass and knows nothing about which app that is.++ **You need BOTH halves.** `set_window_identity` stamps a LIVE window (`PKEY_AppUserModel_ID` + `WM_SETICON`); `register_app_identity` teaches Windows what that `appId` MEANS (an HKCU `AppUserModelId` key + a Start Menu shortcut carrying the same AUMID). An unregistered appId falls back to the owning exe's icon, so half one alone leaves Alt-Tab wrong.++ ```bash+ # Once per install. Ship the .ico in your bridge's release zip; pass its cache path.+ # `target`/`args` are what a user's PINNED tile launches — point it at a real verb so+ # the pin opens a managed window instead of an orphan app.+ adom-bridge desktop_register_app_identity '{"appId":"Adom.Pup","displayName":"Adom Pup",+ "iconPath":"C:/.../adom-pup.ico",+ "args":"pup_open_window {\"sessionId\":\"pinned\",\"owner\":\"pinned-launch\"}"}'++ # Per window, e.g. at park time. cacheKey survives page-title churn.+ adom-bridge desktop_set_window_identity '{"cacheKey":"<sessionId>",+ "titleContains":"(session: <sessionId>)","appId":"Adom.Pup",+ "displayName":"Adom Pup","iconPath":"C:/.../adom-pup.ico"}'+ ```+ - Window target: `hwnd` OR `titleContains` OR `cacheKey` (+ `fresh` to re-resolve). Response carries `resolvedBy` (`hwnd` | `cachedHwnd` | `title`) and `applied[]`.+ - **Identity is per-HWND and dies with the window** — re-apply after a window is recreated.+ - ab holds the icon handles for its whole process lifetime. Icon handles are process-owned: a transient setter leaves windows pointing at dead handles, so the icon reverts moments later. ab being long-lived is exactly why it owns them.+ - `register_app_identity` writes HKCU only (never a UAC prompt). It is gated for RELAY callers like `write_file`; a bridge calling over the loopback API is trusted by transport and ungated.+ - **Jump-list tasks (v1.9.139+): `desktop_set_window_jumplist {appId, tasks}`** puts YOUR actions on the taskbar right-click menu of every window stamped with that appId. Jump lists attach to the AUMID, so per-session appIds give each window its OWN menu. Each task: `{title (required), target? (exe, defaults to ab's bundled CLI — so `args` can simply be an ab/bridge verb + JSON), args?, iconPath?, description?}`. Tasks launch minimized (no console flash). Max 16; `tasks:[]` clears (call at session close, alongside `unregister_app_identity`). Re-set anytime to update the menu. Tier-2 gated for relay callers; loopback bridges ungated.+ - **Per-session / transient appIds (v1.9.136+): register REGISTRY-ONLY with `shortcut:false`, unregister on close.** If you stamp per-session AUMIDs (e.g. `Adom.Pup.<sessionId>` so each window keeps its own taskbar button in split mode), an UNREGISTERED AUMID makes Alt-Tab fall back to the owning exe's icon. `desktop_register_app_identity {appId, displayName, iconPath, shortcut:false}` writes just the HKCU AppUserModelId key (DisplayName + IconUri) — Alt-Tab and taskbar hover resolve the name + icon with NO Start Menu entry. Pair with `desktop_unregister_app_identity {appId}` at session close (idempotent; removes the key plus any shortcut carrying that AUMID; ab's own `inc.adom.bridge` identity is protected). Keep ONE shortcut-mode registration as the pinnable launcher. **Known cost (wiki #155, measured): without a visible Start Menu shortcut carrying the AUMID the shell resolves it lazily on the first right-click, so the jump list flashes and only the second click stays.** `anchor:true` (opt-in, default off since 2.1.49) writes a small visible anchor `.lnk` inside one Start Menu subfolder (`anchorFolder`, default "Adom Bridge"; pass it on unregister too) that fixes it, but it is a real Start Menu entry (All apps, "Recently added", search), and a hidden `.lnk`, `NoStartPage` or `SHAddToRecentDocs` do not anchor. John's default: a clean Start Menu.+ - **`desktop_set_window_jumplist`** (v1.9.139+) adds the right-click menu on the taskbar button (per-AUMID; `tasks:[]` clears) and, with `hwnd`+`headerIcon`, brands the header row (the app tile above Pin/Close).+ - **Icon format — read the `iconCheck` these verbs return (v1.9.152).** `desktop_set_window_identity` / `desktop_register_app_identity` / `desktop_set_window_jumplist` return an `iconCheck {kind,sizes,issues,ok,hint}` (or `headerIconCheck`) that magic-sniffs the file and explains why an icon would render as a generic white document: a **256px `.ico` size not PNG-compressed** (the Vista+ trap), **SVG** (the Win32 shell can't load it), a `.ico` with no small size, an `.exe` where an image is wanted. If `iconCheck.ok` is false, fix the file and re-stamp. Ship a multi-size `.ico` (16/24/32/48/256, the 256 PNG-compressed). **Full guide:** the `adom-bridge-window-identity` skill (AUMID model, the register+stamp two-halves rule, jump-list header, taskbar overlay + thumbnail, the live-tile trap, a recipe).+- `desktop_revoke_approvals` -- Revoke all shell auto-approve permissions and deny pending approvals. The next shell command will show the approval dialog.+ - Example: `adom-bridge desktop_revoke_approvals`+- `desktop_demo_panel` -- Floaty glass BACK / PLAY-PAUSE / NEXT / MUTE transport for a guided demo the USER paces (v2.1.0+, mute/callback v2.1.17+). `show` puts it up (reason required; same-id shows persist omitted fields so a stage advance never un-pauses), `poll` between beats drains the user's presses (honor paused / next / back / mute / stop; `muted` rides every reply -- silence your narration audio while it is true), `hide` when the show ends. A LOCAL caller (ah thread, bridge) may pass `eventCallbackUrl` (loopback http) to get every press POSTed instantly; remote callers pace with poll alone. A press never executes anything -- it only queues an event for you. **The full demo recipe (panel + captions + cursor, stop design, the pacing loop) is the `adom-bridge-demo-kit` skill -- read it before building a demo.**+- `desktop_caption` -- Show a global caption overlay that stays visible above ALL windows (KiCad, Fusion, Chrome). Native Win32 overlay — not a browser DOM element. Click-through: mouse events pass to windows below. Captured by screen recordings (browser `getDisplayMedia`, OBS, Game Bar).+ - Show: `adom-bridge desktop_caption '{"text":"Step 1: Opening the board","position":"top","size":"large","duration":3000}'`+ - Custom position: `adom-bridge desktop_caption '{"text":"Look here","x":0.3,"y":0.2,"size":"large","persist":true}'`+ - Hide: `adom-bridge desktop_caption '{"action":"hide"}'`+ - **Every caption carries a close box (X) and, when you gave a `reason`, a line naming your thread.** Both are the user's protection: the X is their only way to dismiss a caption without an AI, and the attribution answers "which of my ~20 threads is drawing on my screen".+ - **`clean:true` removes BOTH, and it is deliberately hard to get — treat it as rare.** It exists for one case: the user is recording a demo video and explicitly asked for a caption with no chrome. It requires `userAsked:true` AND a `cleanReason` (>=30 chars) saying what they asked for, it is **refused outright** with `persist:true`, and its duration is capped at **2 minutes** (re-issue the same id to extend while you are supervising).+ - **Do not set `clean:true` because the caption looks better without the X.** The user cannot dismiss a clean caption at all, so if it lands over something they need, their only recourse is to find the AI that put it there. If you cannot point to the user asking for a chrome-free caption, the answer is no. A normal caption looks the same in a recording apart from a small X.+ - List what's on screen (v1.9.217): `adom-bridge desktop_caption '{"action":"list"}'` — returns `{captions:[{id,ageMs,expiresInMs,persist,overdue}]}`; the authoritative check (caption overlays do NOT appear in `desktop_list_windows`). TTLs are fail-safe: a 15s reaper closes any caption that outlives its deadline. Do not keep a caption alive with a same-id refresh loop as a nag — the user experiences one endless caption.+ - Multiple simultaneous captions (use `id` to keep them independent):+ ```+ adom-bridge desktop_caption '{"text":"Step 1: Opening board","id":"step","position":"center","size":"large","persist":true}'+ adom-bridge desktop_caption '{"text":"REC ●","id":"status","position":"bottom-left","size":"medium","persist":true}'+ ```+ - Replace only the step caption (status stays):+ ```+ adom-bridge desktop_caption '{"text":"Step 2: Routing","id":"step","position":"center","size":"large","persist":true}'+ ```+ - Hide just the step caption: `adom-bridge desktop_caption '{"action":"hide","id":"step"}'`+ - Args:+ - `text` (string) — caption text+ - `id` (string, optional) — identifies this caption. Captions with the same id replace each other; different ids coexist simultaneously. Default `"_default"` — so bare calls without id still replace each other for backward compat.+ - `position` — preset: `"top"`, `"bottom"` (default), `"center"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`+ - `x` (float 0.0–1.0) — normalized screen X, overrides position horizontal. 0.0 = left edge, 1.0 = right edge. Centers the caption box on this point.+ - `y` (float 0.0–1.0) — normalized screen Y, overrides position vertical. 0.0 = top edge, 1.0 = bottom edge.+ - `size` — preset: `"large"` (72px), `"medium"` (32px, default), `"small"` (20px)+ - `fontSize` (int) — custom font size in px (8–400). Overrides `size` preset when provided.+ - `duration` (ms) — auto-dismiss timer. **Omitted or `0` → `30000` (30s); clamped to max `600000` (10 min).** Captions ALWAYS auto-expire (v1.8.138+) so a forgotten one can't be stranded on the user's desktop. To keep one up longer, **re-issue the same `id`** (resets the timer) or pass a bigger `duration`.+ - `persist` (bool, optional) — `true` = sticky, NO auto-dismiss. Supervised use only (e.g. demo recordings); you MUST then dismiss it yourself with `action:"hide"`. This is the only way to a never-expiring caption (`duration:0` no longer does that).+ - `action` — `"hide"` to dismiss a caption (with `id`: hides only that caption; without `id`: hides all captions), `"force-clear"` to EnumWindows and destroy ALL caption windows regardless of id (nuclear option — use at top of demo scripts for guaranteed clean slate)+ - Captions with the same `id` replace each other instantly (previous destroyed, no fade overlap). Captions with different ids coexist — multiple captions can be visible simultaneously.++### Desktop Screenshots++Take screenshots of the user's desktop or individual windows. All screenshots use lossless PNG.++**Always save screenshots** to `project-content/screenshots/`.++**Naming convention:** `desktop-<descriptive-name>-YYYY-MM-DD-HhMMam/pm.png`++**Workflow:** Call `desktop_list_windows` first to get HWNDs, then `desktop_screenshot_window` with the HWND.++- `desktop_list_windows` -- List all visible windows (returns HWND, title, class name, position/size, and `z` = Z-order index, 0 = topmost)+- `desktop_find_window` (v1.8.153+) -- Resolve a window's HWND by `titleContains` and/or `className` (case-insensitive substrings), returned in Z-order with `best` = the topmost match. Use this instead of diffing `desktop_list_windows` before/after launching something — it's race-free for "the window I just opened." Returns `{ok, count, best:{hwnd,title,className,z,rect}|null, windows[]}` (`ok` true only when ≥1 matched). Pass `best.hwnd` to `desktop_taskbar` / `desktop_flash_window` / `desktop_screenshot_window`.+ - Example: `adom-bridge desktop_find_window '{"className":"Chrome_WidgetWin_1","titleContains":"GitHub"}'`+- `desktop_activity_filter` (v1.8.155+) -- Scope the **user's GUI activity log** so they watch the slice you care about while you work (e.g. focus it on one bridge before a debugging session). Args: `bridge` (a bridge registry name from `bridge_list` | `"none"` = only global/non-bridge ab commands | `"all"`/omit = everything), `text` (free-text substring, ANDs with bridge), `clear` (bool — reset). **Drives the human's GUI view only — it does NOT change which verbs you can call or what they return.** The GUI shows a status bar stating exactly what's shown + what's hidden.+ - Example: `adom-bridge desktop_activity_filter '{"bridge":"kicad"}'` then `adom-bridge desktop_activity_filter '{"clear":true}'`+- `desktop_screenshot_window` -- Capture a specific window by HWND+- `desktop_screenshot_screen` -- Capture the entire desktop (all monitors)+- `desktop_ad_scroll` (v1.9.86+) -- **Scroll ab's OWN window panels.** ab is a fully AI-controllable app, so you can drive its own scrollbars — most usefully to page the **bridges sidebar** and screenshot cards that are below the fold. Args: `panel` (`bridges` default | `activity` | `connections`), `to` (`top`|`bottom`, absolute — wins over `dy`), `dy` (relative px, default 400; negative = up). Returns `{ok, panel, scrolled, state:{scrollTop,scrollHeight,clientHeight,atTop,atBottom,found}|null, _hint}` — round-tripped from the live window so you can **loop until `state.atBottom==true`**. A collapsed bridges sidebar is auto-expanded first; `state==null` means the panel didn't report (collapsed/empty).+ - Capture-all-cards workflow: `desktop_ad_scroll '{"panel":"bridges","to":"top"}'` → `desktop_screenshot_window` (ab hwnd) → `desktop_ad_scroll '{"panel":"bridges"}'` → screenshot → repeat until `atBottom`.++### App self-update (relay-driven, v1.9.87+)++ab auto-updates itself from the wiki manifest, but historically only a HUMAN clicking the footer "Install now" banner could apply it — so a headless / RDP / fleet box (a fresh Azure VM) could sit a version behind with no path forward from the relay. These verbs give the AI that path (same shape as the shell-approval surfacing verbs). Use `--target <box>`.++- `update_status` (read-only, ungated) -- `{currentVersion, latestVersion, updateAvailable, updateReady, state, busy, updateMode}`. `updateReady:true` = the newer version is downloaded + SHA-verified on disk, appliable with no network.+- `update_check` -- force a manifest check now; if newer, download + verify (stage) it in the background. Poll `update_status` until `updateReady:true`.+- `apply_update` -- the programmatic "Install now": apply the staged update + relaunch ab. Respects `busy` (refuses if a command is in flight). Only ever applies ab's own downloaded + SHA-verified SIGNED wiki release. Returns `{applying:true}` then ab restarts (the relay drops + reconnects; poll `targets`/`ping` for the new version). If not staged yet, it kicks staging and tells you to poll + retry.+- `request_update_approval` -- surface the "Update available — Install now / Later" banner on the target + bring ab to the front (for an ATTENDED box where the human should decide). Does NOT apply; `apply_update` installs.+- `update_set_mode {mode: auto|prompt|off}` -- the auto-apply POLICY. `auto` = unattended stage+apply on the 4h/on-launch poll (set this ONCE on headless fleet boxes and they self-update every release with no relay call); `prompt` = show the banner (default, attended); `off` = no checks.+ - Fleet flow: `update_status --target <box>` → if `updateAvailable` and unattended, `apply_update --target <box>` (or set `update_set_mode {"mode":"auto"}` once). A box must already be on ≥1.9.87 to have these verbs; the one-time bootstrap of an older box still needs a human click OR `auto` mode.++### Hydrogen companion mode (v1.9.218+)++When a HUMAN opens ab on a machine where Hydrogen is detected (embedded marker, Hydrogen's ports.json, or a discoverable Hydrogen install), ab shows an identity overlay ("This is Adom Bridge" + a large Open Hydrogen button), instantly opens or foregrounds Hydrogen, and places its own window directly below Hydrogen's in z-order — most people who open ab by hand were looking for Hydrogen. Facts an AI should know:++- Human-open detection = bare argv AND parent process `explorer.exe` (double-click, Start menu, search, pins). Machine launches — `--autostart` boot, Hydrogen `--embedded` respawns, the relauncher/`restart` verb, CLI-style invocations — NEVER trigger it, so a remote `restart` cannot bounce the user's screen.+- One-shot placement: ab does not fight the user's later alt-tabbing, and it is NOT a real Win32 child window (ab's lifecycle stays independent — the recovery-channel contract holds; if Hydrogen cannot be launched the overlay says so and ab stays fully usable).+- Toggle: Settings > Hydrogen > "Act as a companion to Hydrogen", or `settings_set {"settings":{"hd_child_mode":false}}`. Default ON (self-gates on Hydrogen detection, so standalone installs never see it).+- Forensics: every decision slogs `ah-COMPANION:` lines to startup.log.++### User settings over the API (`settings_get` / `settings_set`, v1.9.214+)++ab's Settings-panel preferences are readable and settable over the API. This is the sanctioned channel for an embedding app (Hydrogen) to render ab's settings in its own panel and forward the user's changes; it also lets a fleet operator read or fix a box's boot/update prefs remotely.++```bash+adom-bridge settings_get+adom-bridge settings_set '{"settings": {"launch_on_boot": true, "update_mode": "auto"}}'+```++- Settable keys (whitelist): `launch_on_boot`, `always_show_window_on_launch`, `update_mode` (auto|prompt|off), `auto_restart_for_updates`, `update_idle_minutes` (1..1440), `prewarm_runtimes`, `prewarm_cft` (auto|always|never), `file_receive_directory`. Anything else lands in `rejected` — servers, shell approval (grants are human-only), and blocked peers have their own surfaces and are deliberately not settable here.+- `launch_on_boot` changes apply the OS Startup artifact immediately; `settings_get` also returns `autostartArtifactPresent` so you can see intent-vs-artifact drift.+- `settings_set` is Tier-2 gated for relay (cloud-AI) callers — carry a `reason` and expect the human-approval flow, like `shell_execute`. Local/loopback callers (Hydrogen, bridges, the local CLI) are trusted by transport.+- Self-heal: ab re-verifies the launch-on-boot artifact every ~60s while running (not just at launch), so anything that deletes the Startup shortcut behind ab's back gets healed within a minute — with a `RECONCILE:` forensic line in `startup.log`.++### Browser automation (`pup_*`, Puppeteer/pup) — bridge-owned; light pointer++The pup bridge is **cloud-owned** and ships its OWN full skill (its wiki page / skill pkg — `bridge_list` names it; `adom-bridge sync_skills` installs it). This page keeps only the pointer plus first-touch examples; sessions/profiles, credential vault, downloads, recording, CDP details are the bridge's skill.++Its verbs: `adom-bridge commands '{"bridge":"puppeteer"}'`.++`bridge_readiness {"name":"puppeteer"}` is the read-only probe (never spawns or downloads). Full details: the bridge's own skill via `sync_skills`.++### Screen recording — ab's desktop recorder (tab recording is pup's)++ab owns DESKTOP recording: a visible HUD opens with the REASON you supply, so the user always sees that recording is on and why. Full guide: the `adom-bridge-recording` skill.++```bash+adom-bridge desktop_recorder_open '{"reason": "record the DRC walkthrough for review"}'+adom-bridge desktop_record_start '{"confirmDesktopNotTabRecording": true}'+# ... drive the desktop ...+adom-bridge desktop_record_stop '{}'+adom-bridge desktop_record_list '{}'+adom-bridge desktop_recorder_close '{}'+```++`desktop_record_start` without `confirmDesktopNotTabRecording: true` refuses with `errorCode: desktop_record_needs_confirmation` — that guard exists because recording a BROWSER TAB is the pup bridge's separate feature (`pup_record_start` etc., see pup's own skill).++### Filesystem primitives — `desktop_list_files` / `desktop_watch_files` / `desktop_pull_glob` (v1.5.0+)++**These are the canonical "wait for a download" primitives.** They replace the `shell_execute` + PowerShell-poll-Downloads pattern that earlier callers (chip-fetcher, similar tools) had to use. No shell, no user approval prompt, no parsing of `dir` output. They run pure Rust on the desktop side, so they're fast, predictable, and never trip over PowerShell quoting.++The pattern they replace looks like this:++```powershell+# OLD — DON'T do this for new code:+$before = (Get-Date).ToFileTime()+# … click download …+while (-not (Get-ChildItem ~/Downloads -Filter ul_*.zip | Where { $_.LastWriteTime.ToFileTime() -gt $before })) {+ Start-Sleep -Seconds 1+}+$file = Get-ChildItem ~/Downloads -Filter ul_*.zip | Sort LastWriteTime -Desc | Select -First 1+```++becomes one call:++```bash+# NEW — single primitive, no shell:+adom-bridge desktop_watch_files '{+ "path": "%USERPROFILE%\\Downloads",+ "glob": "ul_*.zip",+ "timeoutMs": 60000+}'+# → {ok:true, file:{path,name,size,mtime}, elapsedMs} on success+# → {ok:false, error:"timeout", elapsedMs, _hint, ...} on timeout+```++#### `desktop_list_files` — one-shot directory listing++Lists files in a directory (non-recursive) matching a shell-glob, optionally filtered to those modified after a given timestamp.++- Args: `path` (Windows abs path; `~` / `%USERPROFILE%` / `%APPDATA%` / `%LOCALAPPDATA%` / `%TEMP%` are expanded), `glob` (default `*`; shell-style with `*` and `?`; case-insensitive on Windows), `modifiedSince` (optional unix-seconds OR ISO-8601 string).+- Returns: `{ok, path, glob, files: [{path, name, size, mtime}, ...] sorted newest-first, count}`.+- Does NOT recurse. Lists one directory only.++#### `desktop_watch_files` — block until a match arrives++Polls the directory every `pollMs` (default 1000) until at least one file matches the glob with `mtime > since`, or `timeoutMs` (default 60000, max 600000 = 10 min) elapses.++- Args: same as `desktop_list_files` plus `since` (defaults to **now** — without an explicit value, only NEW files report), `timeoutMs`, `pollMs`.+- Returns on match: `{ok:true, file:{path,name,size,mtime}, elapsedMs}`.+- Returns on timeout: `{ok:false, error:"timeout", elapsedMs, _hint, path, glob}`.+- The `since`-defaults-to-now behavior matches the canonical "click then watch" flow. If you might miss the file by starting the watch slightly late (e.g. fast small downloads), record `$(date +%s)` BEFORE the click and pass it as `since`.++#### `desktop_pull_glob` — one-shot orchestrator (recommended for download flows)++Composes `desktop_list_files` (or `desktop_watch_files` if `wait:true`) with the existing streaming `pull_file` mechanism. The whole "wait for a download then pull it" flow in one call:++```bash+BEFORE=$(date +%s)+# … trigger the download click (a browser bridge verb, or desktop_ui_click) …+adom-bridge desktop_pull_glob "{+ \"path\": \"%USERPROFILE%\\\\Downloads\",+ \"glob\": \"ul_*.zip\",+ \"since\": $BEFORE,+ \"wait\": true,+ \"timeoutMs\": 60000,+ \"saveTo\": \"/tmp/cse-out\"+}"+# → {ok:true, files:[{name,path,size,sha256,chunks}, ...], errors:[], matchedCount}+```++- When `wait:true`: blocks via `desktop_watch_files` until at least one match appears, then re-lists the directory and pulls **everything matching** (so a `.crdownload` + final `.zip` written in the same poll tick both come along).+- When `wait:false` (default): just lists what's there now and pulls those.+- Pulls use the existing `pull_file` streaming pipeline — sha256-verified, ~1MB chunks, resumes the same way `pull_file` does.++#### Glob semantics++- `*` matches any run of non-separator chars (within a single filename — these primitives don't recurse).+- `?` matches a single non-separator char.+- Case-insensitive on Windows (matches the filesystem); case-sensitive on macOS/Linux.+- Examples: `ul_*.zip`, `LIB_*.zip`, `*.pdf`, `datasheet_*.pdf`, `*.step`, `*.STEP` (same on Windows).++#### Path expansion++- `~` and `~/foo` → home dir (Linux/macOS convention; works on Windows too).+- `%USERPROFILE%` → home dir (Windows; also works cross-platform).+- `%APPDATA%` → Windows roaming app data (`dirs::config_dir()` on others).+- `%LOCALAPPDATA%` → Windows local app data (`dirs::data_local_dir()` on others).+- `%TEMP%` → Windows TEMP env var; `/tmp` on Linux/macOS.+- Expansion is case-insensitive: `%userprofile%` works the same as `%USERPROFILE%`.+- Forward slashes (`/`) and back slashes (`\`) both work in the path string.++#### Time format for `since` / `modifiedSince`++- Number: unix seconds (e.g. `1746483600`). Floats accepted.+- String of digits: same (e.g. `"1746483600"`).+- ISO-8601 / RFC-3339 string: e.g. `"2026-05-04T22:30:00Z"` or with offset `"2026-05-04T15:30:00-07:00"`.+- Files with `mtime <= since` are filtered out (strict `>`).++### Hydrogen (`hd_*`) — proxy + build/lifecycle for the sibling Tauri app++When the user has **Hydrogen** running (a sibling Tauri v2 app at `C:\Github\hydrogen-desktop`), built-in `hd_*` verbs reach into it. NOT a separate bridge process — adom-bridge proxies HTTP calls directly to Hydrogen's control API.++**Port discovery (v1.8.72+):** Hydrogen's control API lives on **port `47084`** (default; the actual port is read from `%APPDATA%\hydrogen-desktop\ports.json` key `control` on every `hd_api` call, fallback 47084). Pre-v1.8.72 the verbs were hardcoded to Hydrogen's legacy `:9001` webview-eval port, which Hydrogen doesn't bind anymore — that's why `hd_status` reporting "Hydrogen not running" while Hydrogen's window is visible is the upgrade signal.++#### The canonical generic verb — `hd_api` (v1.8.72+)++```bash+adom-bridge hd_api '{"method":"GET","path":"/health"}'+adom-bridge hd_api '{"method":"POST","path":"/setup/run-step","body":{"id":"welcome"}}'+adom-bridge hd_api '{"method":"GET","path":"/wsl/status"}'+```++Generic HTTP pass-through to any Hydrogen endpoint. Raw JSON in `body` — no curl-through-the-relay quote mangling. Returns `{success, status, body, port, method, path}` mirroring Hydrogen's response. If Hydrogen isn't running, returns `{ok:false, error:"Hydrogen not reachable on :47084", _hint:"..."}` instead of timing out.++**Public denylist** — these paths are refused with `{ok:false, error:"not_allowed_in_public", denylist_match:"..."}` on public ab builds (use the internal `hd-dev` cargo feature to bypass):++```+/wsl/unregister, /setup/virgin-reset, /container/destroy,+/container/reset, /workspace/reset, /workspace/destroy+```++#### Inspect + drive a running Hydrogen (v1.8.15+, post-v1.8.72 rework)++| Verb | What | Status |+|---|---|---|+| `hd_status` | Composes ab-side facts (running, repo SHA) + Hydrogen's `/workspace/health` + `/buildinfo` relayed | ✓ shipped |+| `hd_api` | Generic HTTP pass-through (above) | ✓ v1.8.72+ |+| `hd_log '{"tail":30}'` | Tail `%APPDATA%\hydrogen-desktop\hydrogen-desktop.log` (reads disk — works EVEN when Hydrogen is down) | ✓ shipped |+| `hd_open_url '{"url":"...","browser":"chrome","profileDir":"Default"}'` | POST `/open-in-profile` — open URL in a specific browser profile | ✓ shipped |+| `hd_browser_profiles` | GET `/browser-profiles` — enumerate browsers + profiles | ✓ shipped |+| `hd_screenshot` / `hd_shot` | Find Hydrogen window, capture lossless PNG via PrintWindow; `hd_shot` adds region-scoped variant | ✓ shipped |+| `hd_eval '{"js":"..."}'` | POST `/eval` — run JS in Hydrogen's main webview | ⚠ **Moved to `hd-dev` in v1.8.72.** Public returns `verb_moved_to_hd_dev`. Use `hd_api '{"method":"POST","path":"/eval","body":{"js":"..."}}'` |+| `hd_iframe_eval '{"js":"...","contextIndex":0}'` | POST `/iframe-eval` — JS in code-server iframe via CDP | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |+| `hd_reload_vscode` | POST `/reload-vscode` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |+| `hd_container_exec '{"command":"..."}'` | POST `/container-exec` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |+| `hd_launch` / `hd_stop` / `hd_restart` | Hydrogen process lifecycle | ✓ shipped |+| `hd_ship` | Atomic build+relaunch+verify-SHA (see below) | ✓ shipped |+| `hd_build*` (v1.8.16+) | Build pipeline orchestration | ⚠ Moved to `hd-dev` in v1.8.72 (Hydrogen developers only) |++#### Atomic ship — `hd_ship` (v1.8.49+) — START HERE for post-push rebuilds++**One verb, one call, one structured pass/fail.** Replaces the 4-step chain `hd_stop` → `hd_build_rust` → poll `hd_build_status` → `hd_launch` → `curl :47084/buildinfo`. Eliminates the "is the new code actually running?" guesswork after every push.++Sequence inside one call:+1. taskkill Hydrogen + poll until zero instances (frees the exe lock)+2. `git fetch && git reset --hard origin/main && rev-parse HEAD` — asserts `HEAD == expectSha` (fails fast if GitHub hasn't replicated the push yet)+3. if `clean:true`, `cargo clean -p hydrogen-desktop` (defeats stale-relink)+4. `cargo build` and parse stderr for `Compiling hydrogen-desktop` to know whether anything actually compiled+5. launch + poll `http://127.0.0.1:47084/buildinfo` until it responds + assert `build_sha == expectSha`++```bash+# End-to-end after a push:+git push && SHA=$(git rev-parse HEAD) && \+ adom-bridge hd_ship "{\"expectSha\":\"$SHA\"}"+```++Args:+- `expectSha` — **REQUIRED** string. Full or prefix SHA of the commit you want running. Bidirectional-prefix match (`abc123` matches the full SHA starting with `abc123` and vice versa).+- `clean` — optional bool, default `false`. `true` forces `cargo clean -p hydrogen-desktop` before build. Use when a prior `hd_ship` succeeded but `runningSha != expectSha` (the stale-relink trap — cargo said `Finished` but reused the cached binary).++Returns on success: `{ok:true, builtSha (== expectSha), runningSha (== expectSha), compiled:bool, elapsedSecs, _hint}`. `compiled:false` on success means cargo found nothing to recompile (cached relink or HEAD didn't actually change — usually fine but surface to caller in case the push was a no-op).++Returns on failure: `{ok:false, failedAt:'args'|'kill'|'git'|'clean'|'build'|'launch'|'verify', error, builtSha (set after git step), runningSha (set if launch worked but verify saw the wrong SHA), compiled (set after build step), elapsedSecs, _hint}`. The partials let you reason about WHERE it broke:++| `failedAt` | What it means |+|---|---|+| `args` | `expectSha` missing / malformed |+| `kill` | Couldn't taskkill Hydrogen (rare — usually permissions) |+| `git` | `git fetch / reset` failed, OR `HEAD != expectSha` after reset (push hasn't replicated to origin/main yet — retry in a few seconds) |+| `clean` | `cargo clean` failed (rare) |+| `build` | `cargo build` failed — read `_hint` for next step (usually `hd_build_log` to see the compile error) |+| `launch` | Cargo finished but `hydrogen-desktop.exe` wouldn't start — `binary_missing` or process spawn error |+| `verify` | Hydrogen launched but `/buildinfo` either never responded or reported a different SHA than `expectSha` (the stale-relink trap — retry with `clean:true`) |++Long-running: cold cargo build can take 2-3 min. CLI WS timeout is 12 min — call as a single sync invocation and wait. Don't poll inside `hd_ship`; it does its own internal polling.++#### Lower-level build + lifecycle suite (v1.8.16+, enriched in v1.8.52+)++Use these directly only when you need finer control than `hd_ship` provides (e.g. frontend-only build, watching log stream live, manual launch sequencing). For the common "I pushed code, get it running" path use `hd_ship`.++The relay's `shell_execute` has a 30s timeout; Hydrogen's `pnpm build` + `cargo build` exceed that, so the build is split into async-spawn verbs + sync poll/tail verbs:++```bash+# Frontend-only rebuild, watch progress, relaunch (no hd_ship since hd_ship is Rust-build-only)+adom-bridge hd_stop+adom-bridge hd_build_frontend '{"show":false}' # returns {ok, pid, logPath} immediately+OFFSET=0+while true; do+ RESP=$(adom-bridge hd_build_tail "{\"offset\":$OFFSET}")+ echo "$RESP" | jq -r '.lines[]'+ DONE=$(echo "$RESP" | jq -r '.done')+ OFFSET=$(echo "$RESP" | jq -r '.newOffset')+ [[ "$DONE" == "true" ]] && break+ sleep 3+done+adom-bridge hd_build_status # confirm succeeded:true && relinkOnly:false+adom-bridge hd_launch # start the new debug binary+```++##### `hd_build` / `hd_build_frontend` / `hd_build_rust` — async-spawn build verbs++| Verb | What |+|---|---|+| `hd_build '{"show":false}'` | Full async build: `git pull` + `pnpm build` + `cargo build`. Returns instantly with `{pid, logPath, flavor:'full'}`. Final log line is `BUILD_OK` or `BUILD_FAILED: <step>`. |+| `hd_build_frontend` | Just `pnpm build` (root). Returns `{pid, logPath, flavor:'frontend'}`. |+| `hd_build_rust` | Just `cargo build` (src-tauri/). **v1.8.52+:** dispatch response also carries `builtSha` + `exeMtimeBefore` + `exeSizeBefore` (captured at spawn time into `%TEMP%\hd-build-meta.json`) so the subsequent `hd_build_status` / `hd_launch` don't need a separate `git rev-parse` + `stat` call. Returns `{ok, pid, logPath, flavor:'rust', show, lingerSecs, builtSha, exeMtimeBefore, exeSizeBefore, _hint}`. |++##### `hd_build_status` — sync state probe (v1.8.52+ enriched)++Returns:+```+{+ ok, building, succeeded, failed,+ lastLines:[string], # last ~40 lines of stderr+ logPath, pid, pidAlive,+ failureReason, # 'compile_error' | 'exe_locked' | 'orphaned_no_sentinel'+ compiledCrates:[string], # names parsed from 'Compiling X v...' lines+ relinkOnly:bool, # true iff succeeded && compiledCrates is empty+ exeChanged:bool, # mtime OR size after differs from before+ exeMtimeBefore:int, exeMtimeAfter:int, # unix seconds+ exeSizeBefore:int, exeSizeAfter:int, # bytes+ exeLocked:bool, # true when build failed because hydrogen-desktop.exe was held+ holderPids:[int], # PIDs holding the exe — taskkill these+ builtSha:string|null, # SHA hd_build_rust/hd_build was dispatched at+ elapsedSecs:int,+ flavor:string, # 'rust' | 'frontend' | 'full'+ _hint+}+```++**`relinkOnly:true` on success is the silent-fail trap to look for** — your push didn't actually rebuild because cargo found nothing to recompile. Usually means your source changes weren't pulled, or you touched a file the workspace doesn't track. Use `hd_ship` with `clean:true` to force a fresh build.++**`exeLocked:true` on failure** + `holderPids` tells you exactly which PIDs to taskkill before retry; or just use `hd_ship` which kills Hydrogen before building.++##### `hd_launch` — start the debug binary (v1.8.52+ enriched)++Args: `killExisting` — optional bool, default `false`. When `false` and Hydrogen is already running, refuses with `reason:'already_running'` so you don't accidentally attach to a stale process. When `true`, taskkill the existing instance (either exe name) with a 600 ms grace period before launching fresh.++`settleSeconds` — optional int, default `5`, max `30` (2.1.159). How long ab watches the new process before answering. `0` skips the extra wait.++Returns on launch: `{ok:true, launched:true, stillRunning:true, observedForMs:int, wasAlreadyRunning:bool, killedFirst:bool, pid:int (Hydrogen's actual PID, not the cmd-wrapper), exePath, builtSha:string|null, _hint}`.++Returns on refusal: `{ok:false, launched:false, wasAlreadyRunning:bool, pid:int|null, killedFirst:false, reason, _hint}`. `reason` ∈:+- `build_in_progress` — wait for `hd_build_status.building:false`+- `build_failed` — fix and retry `hd_build` (or use `hd_ship`)+- `already_running` — pass `killExisting:true` to force-kill+relaunch, or use `hd_restart`+- `binary_missing` — neither `target/debug/hydrogen.exe` nor the pre-rename `hydrogen-desktop.exe` is on disk; run `hd_build_rust` (or `hd_ship`) first++Returns when the build starts and then dies inside the settle window (2.1.159): `{ok:false, launched:true, stillRunning:false, errorCode:'exited_after_launch', observedForMs, pid, exePath, builtSha, runningSha, narrative, _hint}`. The build crashes at startup: read `hd_log`. Re-running `hd_launch` repeats the same crash. Before 2.1.159 this case was reported as a successful launch, because the only process check ran 0.8 s after launch.++**`launched:true, wasAlreadyRunning:false, killedFirst:false` is the proof you need that truly fresh code is running.** Any other combo means you should compare `builtSha` to Hydrogen's actual `/buildinfo.build_sha`.++##### `hd_stop` / `hd_restart` / `hd_build_log` / `hd_build_tail`++| Verb | What |+|---|---|+| `hd_stop` | Kills Hydrogen under either exe name (`hydrogen.exe`, or the pre-rename `hydrogen-desktop.exe`). `{wasRunning}` distinguishes killed vs no-op. |+| `hd_restart` | Stop + launch in one call. Same guards as `hd_launch` (skip `already_running`), and the same `settleSeconds` check: a build that dies at startup answers `errorCode:'exited_after_launch'`. |+| `hd_build_log` | Full log dump from `%TEMP%\hd-build.log`. |+| `hd_build_tail '{"offset":N}'` | Incremental stream — `{lines, newOffset, totalBytes, done, succeeded, _hint}`. Pass `newOffset` back for the next chunk. |++The `show: true` arg on any build verb opens a visible PowerShell console window so the user can watch the build scroll. Default `show: false` runs hidden.++**Auto-close (v1.8.23+).** Visible build windows auto-close 30 seconds after the final `BUILD_OK` / `BUILD_FAILED` line — long enough for the user to read the last error, short enough that build-after-build sessions don't accumulate orphan windows. The window prints a clear green/red "auto-closes in 30 seconds" banner before sleeping. Override with `lingerSecs`:+- `'{"show":true, "lingerSecs":60}'` — give yourself 60s instead of 30+- `'{"show":true, "lingerSecs":0}'` — close immediately, no grace period+- `'{"show":true, "lingerSecs":3600}'` — keep open for an hour (cap)+The response includes the applied `lingerSecs` so you can confirm what landed. Hidden builds (`show:false`) ignore `lingerSecs` entirely — no window to linger.++**Don't call `hd_launch` until `hd_build_status` shows `succeeded: true`** — the guard refuses with `reason: "build_failed"` if you do, and the `_hint` tells the AI exactly what to do (`hd_build_log` to see errors, fix, rebuild). Same for `hd_restart`.++#### Named-region screenshots — `hd_shot` (v1.8.54+)++**One verb replaces the `desktop_list_windows | grep 'Hydrogen' → desktop_screenshot_window <hwnd>` chain** (3 calls + fragile HWND parsing). Captures a named region of Hydrogen, produces BOTH a full-res PNG and a Claude-safe-resized copy, and returns rich metadata so the AI never has to chain screenshot → pull → convert → crop → resize → Read manually.++Each shot is **self-identifying**: it includes the running build's SHA + local build time read from Hydrogen's `/buildinfo` so you can confirm you're looking at the build you expect before trusting what you see (no more "this screenshot was from the previous binary, not my fix" confusion).++Regions are deterministic — they resolve via Hydrogen window bounds + CDP element rects queried through Hydrogen's `/eval` endpoint. No AI-supplied pixel coords.++Args:+- `region` — **REQUIRED**. One of:+ - `full` — whole Hydrogen window+ - `titlebar` — top ~40 px strip (Hydrogen's build SHA / timestamp readout lives here)+ - `vscode` — VS Code iframe pane via CDP element rect+ - `setup-panel` — Setup Steps panel (`.setup-panel` / `.setup-panel-output`)+ - `wiki` — wiki / browser tab pane+ - `claude` — Claude Code panel+- `resizeMax` — optional int, default `1400`. Longest side of the safe-resized copy. Use ≈800 to fit more shots in one Claude turn; 1568 for max fidelity.++Returns:+```+{+ ok, region,+ fullPath, # lossless raw PNG — for shotlog/archival, NOT Read+ safePath, # ≤resizeMax PNG — pull_file this, Read this+ fullSize:[w,h], safeSize:[w,h],+ clipRect:[x,y,w,h], # captured-image pixels (NOT screen pixels)+ capturedAtLocal,+ runningBuildSha, # from Hydrogen's /buildinfo — null if Hydrogen doesn't expose it yet+ runningBuildLocal,+ hdWindowFound:bool, regionFound:bool,+ windowOuter:[w,h], capturedSize:[w,h],+ _hints: {+ pull, # exact pull_file command for safePath+ fullForArchive, # note that fullPath is for archive, not Read+ staleWindow, # set when runningBuildSha looks wrong+ regionMiss # set when regionFound:false+ }+}+```++Recipe — verify-which-build-is-running + confirm-config-change-took-effect + full dump:++```bash+# After hd_ship, confirm the titlebar shows the SHA you expect:+adom-bridge hd_shot '{"region":"titlebar"}'+# → runningBuildSha lets you assert this before trusting any other shot++# After flipping a Setup Steps toggle, screenshot the panel to confirm:+adom-bridge hd_shot '{"region":"setup-panel","resizeMax":1568}'++# Full-window dump for general debugging:+adom-bridge hd_shot '{"region":"full"}'+```++Edge cases:+- **Unknown region** — refusal: `{ok:false, error, knownRegions:[...]}`. No fallback — fix the call.+- **`regionFound:false`** — the element rect wasn't in Hydrogen's DOM. The panel/iframe may not be mounted yet, OR Hydrogen's markup changed and `region_selectors()` in `hd_bridge.rs` is out of date. The shot is **still produced** (falls back to the full window) so you have something to look at; check `_hints.regionMiss` for the reason.+- **`runningBuildSha:null`** — Hydrogen's `/buildinfo` isn't exposed yet on the running version. The shot is still produced; you just can't self-verify which build it represents.++**Always pull_file the `safePath` and Read THAT** — it's already sized for Claude's image analysis. The `fullPath` is for shotlog / disk archival, NOT direct Read (may exceed Claude's per-image budget). The `_hints.pull` field gives you the exact `pull_file` command to copy-paste.++**Old workaround to drop:** Do NOT use `desktop_list_windows | filter 'Hydrogen' | desktop_screenshot_window <hwnd>` anymore for Hydrogen — `hd_shot` replaces that entire pattern. Generic `desktop_screenshot_window` still applies to non-ah windows (other apps, dialogs, etc.).++### TCP Port-forward Tunnels — `tunnel open|close|list|check|rdp` + `display` (v1.8.85+, reworked v1.8.147)++Reach a TCP service addressable only from the **cloud side** through the **laptop side**: ab binds a `127.0.0.1:<port>` listener on the laptop and pumps bytes over the existing ab↔relay WebSocket; the relay dials the upstream `to`. First use case: RDP into a Windows VM behind an NSG only the cloud container reaches — mstsc on the laptop connects to a local port and is talking through the cloud. Same path works for SSH, VNC, HTTP, postgres — any TCP.++**`tunnel` is a grouped verb** (`rdp` / `tunnel_close` / `tunnel_list` remain back-compat aliases):++```bash+# Open a VERIFIED tunnel (both legs probed before ok). Auto-picks a free local port.+adom-bridge --target <name> tunnel open '{"to":"127.0.0.1:13389"}'++# RDP: open + verify + launch mstsc, sized to the laptop's real screen.+adom-bridge --target <name> tunnel rdp '{"to":"127.0.0.1:13389","size":"90%"}'+# size: "90%" (default) | "100%" | "fullscreen" | "maximized"+# OR monitor:<index> (0=primary) OR explicit width+height++adom-bridge --target <name> tunnel list # live tunnels + leak detection+adom-bridge --target <name> tunnel check '{"tunnelId":<id>}' # per-leg health+adom-bridge --target <name> tunnel close '{"tunnelId":<id>}' # or {"all":true}++adom-bridge --target <name> display # monitor geometry (for sizing)+```++**`--target` is mandatory when >1 ab is connected.** v1.8.147 fixed a false-success bug: the bind, the upstream probe, and the mstsc launch now ALL resolve to the SAME machine. (Before, the bind could land on one ab and mstsc launch on another → "port bound" on the VM, "nothing listening" on the laptop, a bogus `ok:true`.) No target + multiple ABs → `ambiguous_target` listing the names; `--target all` is rejected (a tunnel binds one machine). `adom-bridge targets` lists connected ABs.++**Active health verification — every `open`/`rdp`/`check` PROVES the path before reporting `ok`:**+- **local-bind** — ab self-probes `127.0.0.1:<boundPort>` right after binding. If it can't connect, ab tears the tunnel down and returns `ok:false` (this alone kills the false-success class).+- **upstream-refused** — the relay dials `to` (retry/backoff ~4×/2.5s). If unreachable, the tunnel is torn down (no leaked row) and `ok:false`.+- A failure names the leg: `failedLeg: "relay-to-ad" | "local-bind" | "upstream-refused"`. Health fields surfaced: `listening`, `relayConnected`, `upstreamReachable`, `connCount`, `mstscAlive`.++**Progressive hints.** Every response carries `_hint` (what's true now) + `_next` (the exact next verb with real values, copy-pasteable). e.g. a successful open's `_next` hands you the `tunnel rdp` / `tunnel check` / `tunnel close` commands with the real `to`/`target`/`tunnelId` filled in. A missing `to` returns a structured error with an example. Walk the workflow from the responses alone.++**Display-aware RDP sizing.** `tunnel rdp` resolves `size`/`monitor`/`width`/`height` against the laptop's real screen (queried via `display` — `EnumDisplayMonitors` + per-monitor DPI), writes an `.rdp` file with the geometry, and launches mstsc. e.g. `size:"90%"` on a 2560×1600 primary → a 2304×1440 window. Default 90% of primary. You never hardcode a resolution — call `display` if you want the layout first.++**`tunnel list` reconciles + reaps leaks.** It cross-checks the relay registry against every connected ab's own registry and reports `leaks:[...]` for rows only one side knows about; `tunnel close '{"all":true}'` reaps them. The relay stores the actually-bound `localPort` (so `tunnel close '{"local":<port>}'` works).++Architectural notes for Claude:+- Bytes-only — no payload parsing, so any TCP service works. TLS/cert-pinning still pins to whatever the cloud-side dial resolves (for RDP the upstream cert CN must match what mstsc expects).+- Each fresh laptop-side TCP connection gets its own connId multiplexed over one tunnelId; concurrent mstsc reconnects / parallel sessions work without re-opening. Backpressure bounded (256-slot mpsc/conn, 64 KiB buf).+- Tunnels don't survive ab/relay restarts. If mstsc disconnects, `tunnel check` (names the dead leg), then re-open.+- The classic RDP collision (Windows' own RDP owns `0.0.0.0:3389`) is dodged by `rdp`'s default `local:13389` + `SO_EXCLUSIVEADDRUSE` + ephemeral fallback (`fellBack:true` in the response; read `localPort` for the real port).++**Old workarounds to drop**: (1) NSG WAN-IP whitelisting for roaming-laptop RDP — the tunnel rides existing auth, reachable regardless of WAN IP. (2) Trusting a bare `ok:true` from the old `rdp` — it could be a dead port; now `ok` means both legs were probed. (3) Hardcoding mstsc resolution — pass `size`/`monitor`; ab resolves against the real screen.++### Shell — `shell_execute` (escape hatch only)++- `shell_execute` -- Run a shell command on the desktop. The CLI handles approval polling internally — it returns the final `{success, output, error, exitCode}` once the user clicks Allow on the desktop dialog. 2.1.132+: ab holds the prompt for `approvalWaitSec` (default 60, max 600); a lapse answers `errorCode:"approval_timeout"` (nobody answered, NOT a deny: re-send with a longer wait or `request_shell_approval {wait:true}`), an explicit Deny answers `errorCode:"approval_denied"`. **You do not need to poll, retry, or call `get_deferred_result` yourself.** The CLI emits `HINT:` lines to stderr while waiting (every 15s) so an AI Monitor sees progress.+- `shell_kill_all` -- Kill all running shell commands and deny pending approvals.++**Do not put JavaScript or escaped JSON in a `shell_execute` command (2.1.168, wiki #222).** On Windows the command runs through `cmd /c`, and cmd does NOT treat `\"` as an escaped quote. So in `curl -d "{\"js\":\"(()=>{const x=...})()\"}"` the `>` of `=>` (or any `<`/`>` comparison) can land outside cmd's quotes and become a REDIRECT: cmd writes a file named after the next word (`{const`, `e.children.length`) and the command gets mangled input. Send such payloads with `run_script {"interpreter":"powershell","scriptB64":...}` (base64, nothing to escape), or `write_file` the JSON and pass the file (`curl -d @C:/path/body.json`). ab now flags this shape in the reply's `_hint`. With no `cwd`, a command runs in `%TEMP%\adom-bridge-shell` (2.1.168; before that it ran in the Adom Bridge install folder, which is where those stray files landed); pass `cwd` when you need a specific folder.++**v1.7.16 fix.** Earlier versions (v1.7.15 specifically) had a regression where the CLI's approval-wait loop re-sent the command every 1s instead of polling for the deferred result; that produced an infinite "Another shell command is already waiting for approval" loop because each retry created a fresh approval the user could never out-click. v1.7.16 fixes it. If you see that error string with a `>= 1.7.16` CLI, the bug is back — file an issue. If you see it with `< 1.7.16`, upgrade the CLI: `adom-wiki asset get apps/adom-bridge docker_binary -o /usr/local/bin/adom-bridge`.++**Deprecated for download polling.** As of v1.5.0, use `desktop_watch_files` / `desktop_pull_glob` for waiting on download arrivals — those don't require user approval, don't go through PowerShell quoting, and are O(directory entries) rather than spawning a process every second. `shell_execute` itself stays as an escape hatch for genuinely shell-only operations (multi-step ad-hoc admin tasks, chained pipelines, etc.).++Use `where python` / `C:\Python3xx\python.exe` for Python -- avoid `python` which may not be on PATH.++#### `run_script` + `wsl_exec` — base64-payload script runners (v1.8.46+)++When you want to send a multi-line script to the desktop without the shell-quoting hell that `shell_execute` requires, use these two verbs. The script crosses the wire as base64 so NO escaping survives to be mangled by cmd.exe, PowerShell parser quirks, MSYS path conversion, or wsl.exe's `--` / nested-quote / `$_` rules. The caller never hand-escapes anything.++Both verbs require shell auto-approve to be active — same trust level as `shell_execute`. They refuse cleanly with an actionable error if not approved; no per-call modal (these are programmatic verbs).++**Reading the result — `status` + `durationMs` (v1.8.162+).** EVERY CLI result now carries a single authoritative **`status`** field so you never have to guess whether an empty `stdout` was a real empty-output success or a timeout:++| `status` | meaning | what to do |+|---|---|---|+| `ok` | completed (stdout may legitimately be empty) | use `exitCode`/`stdout`/`stderr` |+| `timeout` | did NOT finish in time (NOT an empty success) | raise `timeoutSeconds`, or use a detached job (below) |+| `busy` | desktop couldn't service it (WSL wedged / no client connected) | check `adom-bridge status`; `wsl_recover` for WSL; retry |+| `error` | the command/verb failed | read `error` / `stderr` / `_hint` |++`durationMs` is the wall-clock of the call. `exitCode`/`stdout`/`stderr` pass through unchanged; on `timeout` the verb's `partialStdout`/`partialStderr` hold what was written before the kill. A `timeout`/`busy` result also carries a recovery `_hint`. **For multi-minute work don't fight the timeout** — use a detached job that returns instantly + polls: `wsl_exec_async` / `wsl_import` / `wsl_export` → poll `wsl_job_status` (or `launch_process` for a general long-running command). This is the cure for the "1.4 GB import EOFs the sync call" problem.++**`run_script`** — base64 script + interpreter (cmd / powershell / bash):++```bash+SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b'''+echo \"multi-line script\"+VAR=\"value with spaces and \$dollars\"+echo \"VAR=\$VAR\"+exit 0+''').decode())")+adom-bridge run_script "{\"interpreter\":\"bash\",\"scriptB64\":\"$SCRIPT_B64\"}"+# → {success, ok, stdout, stderr, exitCode}+```++Args: `interpreter` (`cmd` | `powershell` | `bash`), `scriptB64` (required), `timeoutSeconds` (default 60).++PowerShell prefers `pwsh` (PS 7+) if on PATH, falls back to `powershell.exe` (5.1).++- **`bash` runs via WSL2, NOT Git Bash.** The Windows host's first `bash` on PATH is `wsl.exe -d <default-distro> bash` (verified at `C:\Windows\System32\bash.exe`). The script is piped to bash's stdin (no temp file, no path-form mismatch — no MSYS `/S` → `S:/` corruption, no nested-quote loss). The decoded script bytes are written to bash's stdin and stdin is closed; bash reads + executes without ever opening a file. For explicit distro / user control, use `wsl_exec`.++**`wsl_exec`** — base64 script + distro + user, executed via the canonical `wsl -d <distro> -u <user> -- bash -lc <decoded>` argv array:++```bash+SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b'''+cd /mnt/c/Github/adom-bridge+git status --short | head -10+exit 0+''').decode())")+adom-bridge wsl_exec "{\"distro\":\"Ubuntu\",\"user\":\"root\",\"scriptB64\":\"$SCRIPT_B64\"}"+# → {success, ok, stdout, stderr, exitCode}+```++Args: `distro` (e.g. `Ubuntu`, `Adom-Workspace`), `user` (e.g. `adom`, `root`), `scriptB64`, `timeoutSeconds` (default 60).++Why argv (not shell string): Rust's `std::process::Command` on Windows applies proper `CommandLineToArgvW` quoting per argv element. `wsl.exe` sees `bash -lc <decoded>` as three distinct argv items even when the decoded script contains quotes, `$`, backslashes, newlines, pipes — anything. `bash`'s `-c` then sees the entire script as ONE string with no outer-shell interpretation.++Output decoding: `wsl.exe` defaults to UTF-16LE when invoked from the Windows console subsystem. Both verbs auto-detect (BOM check + odd-position-NULL heuristic) and decode to UTF-8. Output is never wrapped in another shell that might re-interpret it.++Pitfalls these verbs eliminate:+- `cmd.exe`'s `&`, `|`, `^`, `%var%` interpretation+- PowerShell's parser quirks around nested quotes + `$` interpolation+- Git Bash MSYS converting `/S` → `S:/` etc.+- WSL's `--` parser limits + nested-quote loss+- UTF-16LE corruption on wsl.exe output++#### WSL wedge defense — `wsl_wedged` + `wsl_recover` (v1.8.157+)++Every `wsl_exec` / `run_script bash` spawns a `wsl.exe` client. If WSL's service (`LxssManager`) wedges — common after a laptop runs for a day, or under heavy parallel cloud use — those clients block in a wait that `taskkill /F` can't always finish, and retries keep spawning more. Left unchecked they pile up into the hundreds and wedge the whole machine. Three defenses:++1. **Refuse-cap.** When there are already too many `wsl.exe` (`WSL_REFUSE_AT=80`), ab refuses NEW wsl spawns with `errorCode:"wsl_wedged"` instead of adding another doomed client. If you get `wsl_wedged`, don't retry the same command — run `wsl_recover` first.+2. **`wsl_recover`** — reap orphaned/hung `wsl.exe`, **DRY-RUN by default** (nothing is killed unless you pass `confirm:true`):+ ```bash+ adom-bridge wsl_recover # dry-run: lists `candidates` (would-reap) + `spared`+ adom-bridge wsl_recover '{"confirm":true}' # reap the candidates+ adom-bridge wsl_recover '{"confirm":true,"sparePids":[1234]}' # also protect pid 1234+ ```+ It classifies each `wsl.exe`: childless + (0-CPU & old | runaway-CPU) → **candidate**; everything else → **spared**. It **spares any `wsl.exe` with child processes** (that's almost certainly the live distro, e.g. the Hydrogen `Adom-Workspace`), honors `sparePids`, and never touches `wslservice`/`wslhost`/`vmmem`. Review each candidate's `cmd` in the dry-run to confirm the live workspace isn't among them, then confirm. **Never** `wsl --shutdown` (it kills the live workspace too). Reaping childless orphans often cascades — their now-parentless children exit on their own (observed 114 → 18 from reaping 54).+3. **status wsl block** — `{wslProcessCount, wedged, _hint}` lets you see a pile-up *before* it blocks work. Path differs by transport: **`status.desktop.wsl`** via the CLI/relay (`adom-bridge status` — nested like `desktop.apps`), or top-level **`status.wsl`** on the local direct API (`GET /status`). `wedged:true` at/above `WSL_WARN_AT=50`.++#### Long-running in-distro work + distro lifecycle — golden-image bakes (v1.8.161+)++`wsl_exec` is **synchronous** — it waits for the command and is bounded by the relay's HTTP timeout (~60-120s), so a multi-minute bake (apt, a big download) EOFs. Backgrounding inside the distro (`nohup … &`) doesn't help either: the `wsl.exe` client returns, WSL sees no active session, shuts the distro down, and kills the bg process (wiping `/tmp`). For anything long, use the **async held-session** verbs — ab holds one `wsl.exe` in the foreground for the command's whole lifetime, so the distro stays up:++- **`wsl_exec_async`** → returns a `jobId` + `outputPath` (a live UTF-8 log) immediately; the command runs detached for as long as it takes.+- **`wsl_job_status {jobId}`** → `{running, exitCode, elapsedMs, outputTail, outputPath}`. Poll until `running:false`; `pull_file` the `outputPath` for the full log. (`wsl_job_list`, `wsl_job_kill {jobId}` round it out.)++```bash+# golden-image bake: import → run (async) → poll → export → unregister, all via ab+adom-bridge wsl_import '{"distro":"bake","installDir":"C:/Users/<you>/.adom/wsl/bake","tarball":"C:/.../base.tar"}'+# → {jobId}; poll wsl_job_status until done (exitCode 0)+B64=$(printf 'set -euo pipefail\napt-get update && apt-get install -y build-essential\n' | base64 -w0)+adom-bridge wsl_exec_async "{\"distro\":\"bake\",\"user\":\"root\",\"scriptB64\":\"$B64\"}"+adom-bridge wsl_job_status '{"jobId":"wsljob-xxxxxxxx"}' # repeat until running:false; outputTail = progress+adom-bridge wsl_export '{"distro":"bake","tarball":"C:/.../golden.tar"}' # poll, then pull_file the tarball+adom-bridge wsl_unregister '{"distro":"bake"}' # cleanup (REFUSES Adom-Workspace)+adom-bridge wsl_list # {distros:[{name,state,version,default}]}+```++**systemd user session — `viaRoot`.** On a `systemd=true` distro, `-u <user>` can fail "Failed to start the systemd user session for '<user>'". Pass `"viaRoot": true` to `wsl_exec_async` — ab runs `-u root -- runuser -u <user> -- bash -l`, dropping to the user WITHOUT depending on `[email protected]`.++`wsl_import` / `wsl_export` / `wsl_unregister` are async (jobId + poll) too, so big imports/exports never EOF. All are **strictly scoped to the named distro** — there is no global `wsl --shutdown` path (that would kill the live Hydrogen workspace), and `wsl_unregister` refuses `Adom-Workspace`. Job ids live in ab's memory only (not persisted across an ab restart; the log file survives). The wedge defense above still applies — an over-cap spawn returns `wsl_wedged`.++## Detecting App Installation++After connecting, always run `adom-bridge status` to check what's installed. The `desktop.apps` object tells you exactly what the user has:++```bash+adom-bridge status+# Look at the desktop.apps field in the response+```++### Handling "not installed" errors++When a command returns `errorCode: "node_not_found"` (a bridge runtime ab manages: see `runtimes`), or a bridge reports its host app missing, guide the user through installation:++**Picking the right native browser + profile (v1.7.1+):**++When you need to open a URL in the user's NATIVE browser AND it matters which account is signed in (work Google Workspace vs personal Gmail vs media YouTube channel etc.), `desktop_open_url` alone isn't enough — you need to target a specific profile. The flow:++```bash+# 1. Discover what's installed + which profiles are configured.+adom-bridge desktop_list_browsers '{}'+# → {+# "default": "chrome",+# "browsers": [+# { "name": "chrome", "displayName": "Google Chrome", "version": "146...",+# "exePath": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",+# "profiles": [+# { "id": "Default", "name": "Alice (work)", "gaia": "[email protected]", "isDefault": true },+# { "id": "Profile 1", "name": "Personal", "gaia": "[email protected]" },+# { "id": "Profile 2", "name": "Adom Media", "gaia": "[email protected]" }+# ] },+# { "name": "edge", "profiles": [...] },+# { "name": "firefox", "profiles": [{"id":"default-release","name":"default","isDefault":true}] }+# ]+# }++# 2. Match the URL's context to the right profile, then open it there.+# E.g. opening a Google Doc shared by your work team → use the work profile:+adom-bridge desktop_open_url '{+ "url":"https://docs.google.com/document/d/...",+ "browser":"chrome",+ "profile":"Default"+}'+# Or YouTube channel management for the media account:+adom-bridge desktop_open_url '{+ "url":"https://studio.youtube.com",+ "browser":"chrome",+ "profile":"Profile 2"+}'+```++Profile-picking heuristics for Docker Claude:+- **Workspace / @adom.inc URLs** → match `profile.gaia.endsWith("@adom.inc")` and not the `media@` one → typically `Default`.+- **Personal Gmail / Drive / etc.** → match `profile.gaia === "[email protected]"` (or whatever the user's personal address resolves to).+- **YouTube Studio / channel-specific work** → match the media account profile.+- **Random scratch / experiments** → use the user's Edge profile, where they keep miscellaneous accounts (per the user's stated preference).+- **No clear match** → fall back to `browser:"default"` (no profile flag) and let the user pick.++`profile` is optional. Omit it to open in whichever profile the browser was last using (legacy v1.6.x behavior). Profile flag is silently ignored when `browser:"default"` (no clean way to inject through the OS URL handler — name the browser explicitly to use it).++### Decrypting the user's saved browser credentials — `desktop_decrypt_browser_credentials` (v1.9.220+)++> **`reason` is REQUIRED here (v2.0.84)** and it is not for the log: the user reads it+> **on screen**, in a caption above every window, while deciding whether to hand over+> their saved passwords. Write it for them — say what you are trying to accomplish, not+> what the command does. Good: `"Signing the adom-you Chrome profile in as you so I can+> check your orders"`. Weak: `"decrypting credentials"` (that is just the verb again).+>+> **What the user sees before anything is decrypted (three surfaces, v2.0.84):**+> a caption above every window naming **which AI thread** asked, **your reason**, **what+> it wants** (passwords / cookies) and **from which browsers**, plus a warning that+> Windows is about to ask for administrator permission; ab's own consent dialog; and a+> Windows toast carrying the same Continue/Cancel. Any one of them can approve or+> decline. Expect a visible pause here — a human is reading, and if they were not+> expecting it they are being told to choose No.++So pup can run an **"adom-you" Chrome profile signed in AS the user** — surfing the web on the user's behalf — it needs the user's real saved logins/cookies to seed that profile. Chrome 127+ encrypts these with **App-Bound Encryption** (the master key is DPAPI-wrapped under the SYSTEM account), so only an elevated helper can decrypt them. This verb is that path.++```bash+adom-bridge desktop_decrypt_browser_credentials '{+ "reason": "Signing the adom-you Chrome profile in as you so I can check your orders",+ "browsers": ["chrome", "edge"], # optional; default = all installed Chromium browsers+ "include": ["passwords", "cookies"] # optional; default = both+}'+# → { "ok": true,+# "passwords": [ { "origin", "username", "password", "sourceBrowser", "sourceProfile", "sourceAccount", "lastUsed" }, ... ],+# "cookies": [ { "host", "name", "value", "path", "sourceBrowser", "sourceProfile", ... }, ... ],+# "stats": { "profilesRead", "passwordsOk", "passwordsFailed", "cookiesOk", "cookiesFailed" } }+```++**How to call it (this is not a normal verb — read this):**++- **LOCAL / bridge callers only.** A relay (cloud-AI) call is refused unconditionally with `errorCode:"relay_forbidden"` and **pops no UAC** — a remote container must never be able to trigger a password-decrypt. pup (a loopback bridge) and the local CLI reach it.+- **Two human gates, in order:** (1) ab shows an in-window consent dialog + toast naming the requesting caller ("pup is requesting your saved browser passwords/cookies…"); decline → `errorCode:"consent_declined"`. (2) On continue, an elevated signed helper runs behind a **UAC prompt** ("Adom Industries, Inc."); cancel → `errorCode:"elevation_declined"`. The UAC elevation IS the user's permission for the decrypt — by design (John, wiki #457).+- **Plaintext is returned ONCE over the loopback API and is never written to disk or logged.** Don't echo the values into any other verb's args, any activity-log emit, or any file. Consume the rows, seed the profile/vault, drop them.+- **errorCodes:** `relay_forbidden` (you called it over the relay — you can't), `consent_declined`, `elevation_declined`, `helper_missing` (the signed helper isn't bundled — the install is broken). Per-item decrypt failures are counted in `stats.*Failed`, never fail the whole run (a managed-profile policy can block cookies; newest-Chrome `v20` blobs are best-effort if Google rotated the elevation-service key).+- **Pair it with `desktop_list_browsers`** (above) to show the user which browsers/profiles/accounts will be read before you ask.++**Node.js not installed (the puppeteer bridge can't auto-spawn):**++v1.7.0+ — any `pup_*` command returning `errorCode:"node_not_found"` means Node isn't on the desktop machine. Trigger the unattended winget install:++```bash+adom-bridge desktop_install_node '{}'+# → runs `winget install --id OpenJS.NodeJS.LTS --silent ...` (Windows only).+# → ~1-2 min; downloads + installs the Node.js LTS line (well-tested with puppeteer).+# → returns {ok:true, exitCode:0, _hint:"Retry the original pup_* command"} on success.+# → on failure: {ok:false, errorCode:"winget_unavailable" | "winget_install_failed", _hint}.+```++After install, **just retry the original `pup_*` command** — the bridge picks up the new node.exe via the registry App Paths lookup (which winget writes), no Adom Bridge restart needed. v1.7.0+ also pre-fetches Node + Chrome for Testing on first launch after install (background task on AUTOSTART_VERSION 11), so most users will never see `node_not_found` to begin with.++If `desktop_install_node` returns `errorCode:"winget_unavailable"` (older Windows or managed corporate device), fall back to manual:++> Node.js LTS is required for browser automation. winget isn't available on this machine — download Node.js LTS from https://nodejs.org/ and run the installer. Let me know when it's done and I'll retry.++**v1.7.0+ Node detection.** The bridge looks for `node.exe` in:+- System PATH (via `where node` on Windows, `which node` elsewhere)+- Windows registry App Paths (catches winget, MSIX, custom-dir installs)+- Standard install dirs: `C:\Program Files\nodejs\`, `C:\Program Files (x86)\nodejs\`, `%LOCALAPPDATA%\Programs\nodejs\`+- Per-user version managers: nvm-windows (`%APPDATA%\nvm\`), volta (`%LOCALAPPDATA%\Volta\bin\`), fnm (`%FNM_DIR%`)+- macOS Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`)++If detection still misses an install on a user's machine, get the actual `node.exe` path from the user — that's a real bug to file.++**A bridge's host app not installed or not running:** the bridge owns that story. `bridge_readiness {"name":"<bridge>"}` says `no-app` / `warming` / `ready` and its `_hint` says what to do; the bridge's own skill (`sync_skills`) has the install and launch flow. Do not hand the user a download link before asking the bridge: several can install their app unattended.++## Troubleshooting++### Check connection status++Use `status` to see connected clients. A healthy connection shows one client from the user's hostname with a recent `lastPong` timestamp.++### Stale connections causing timeouts++Use `kick_all` to reset -- active Adom Bridge apps reconnect within seconds.++### No desktop client connected++1. Confirm the Adom Bridge app is running on the user's PC+2. Confirm it's pointed at the correct WebSocket URL+3. Check if port 8765 is exposed and reachable++### Relay not running++```bash+curl -sf http://127.0.0.1:8766/health+# If fails: adom-bridge serve --supervise+```++### Shell commands on Windows++`shell_execute` runs in `cmd.exe`. Prefer writing scripts via `send_files` then executing them. Use the full Python path.++## Building from Source++```bash+cd cli && cargo build --release+# Binary at: cli/target/release/adom-bridge+```++## Repo++[github.com/adom-inc/adom-bridge](https://github.com/adom-inc/adom-bridge)++## Run wiki ops on the user's PC — `wiki_exec` (v1.9.116+)++The ab installer bundles the `adom-wiki` CLI next to the app, and `wiki_exec` runs it ON THE PC — so version-controlling a local project (KiCad board, docs folder) on wiki.adom.inc needs NO per-file `pull_file` round trip to the container and there is no container/PC drift.++```bash+adom-bridge wiki_exec '{"args":["repo","push","adom/my-board","--files","board.kicad_pcb","board.kicad_sch","-m","rev B"],"cwd":"C:\Users\me\boards\my-board"}'+```++- `args` = adom-wiki argv (any pillar: repo, pkg, release, discussion). `cwd` = PC-side project dir so `--files` are project-relative. `timeoutSec` optional (default 120, max 600).+- Returns `{success, exitCode, timedOut, stdout, stderr, exe, durationMs}`.+- Auth is automatic: ab injects the signed-in user's session token as `ADOM_WIKI_TOKEN` — the caller never handles credentials.+- Gated like `shell_execute` for relay callers (it writes to the wiki as the user). `errorCode:wiki_cli_missing` means the desktop ab predates 1.9.116.+- Container-side publishing of the CONTAINER's own files still uses your local `adom-wiki` directly; `wiki_exec` is for files that live on the PC.++Note (v1.9.117): ab's Settings panel no longer has a KiCad section — bridges own their app-specific config (ab stays generic). `kicad_library_paths` / `project_watch` keep working from `~/.adom/config.json` and the `set_project_watch` / `trigger_project_watch` verbs.
dist/linux/adom-bridge⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-bridge", - "version": "2.1.184", + "version": "2.1.185", "type": "app", "brief": "Adom Bridge unleashes your AI onto your full PC: full power, full safety. The AI breaks out of the container it runs in and onto your real machine, managing and running your entire laptop as you: launch apps, move files, drive any window, control KiCad, Fusion 360 or your real signed-in browser. Works with any AI, cloud or local (Claude Desktop, Claude Code, Codex), no Hydrogen required. Bridge and its bridges are an operating system for AI, with managed Node and Python runtimes, and a human-only permission system keeps you in charge of every side-effecting command.", "description": "Install this package in a cloud or Hydrogen Desktop container, not on the PC. It delivers two things: the Linux adom-bridge-cli (linked onto PATH) and the Claude skill set that teaches an AI every desktop verb (shell, files, screenshots, windows, notifications, KiCad, Fusion 360, browser automation). The CLI drives the Adom Bridge app running on your Windows PC over the relay; the Windows app itself is the separate installer in the Download box, not part of this package. Run adom-wiki pkg update to keep the CLI and skills current.",
page.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-bridge",- "version": "2.1.184",+ "version": "2.1.185", "type": "app", "description": "Install this package in a cloud or Hydrogen Desktop container, not on the PC. It delivers two things: the Linux adom-bridge-cli (linked onto PATH) and the Claude skill set that teaches an AI every desktop verb (shell, files, screenshots, windows, notifications, KiCad, Fusion 360, browser automation). The CLI drives the Adom Bridge app running on your Windows PC over the relay; the Windows app itself is the separate installer in the Download box, not part of this package. Run adom-wiki pkg update to keep the CLI and skills current.", "keywords": [
skills/adom-bridge-anywhere-windows/SKILL.md+73−69@@ -1,69 +1,73 @@-name: adom-bridge-anywhere-windows-description: The Windows translation layer for Adom Anywhere. The Adom ecosystem skills that Adom Bridge delivers to a local agent (Claude Desktop, Codex, Claude Code on this PC) were written for Adom LINUX CONTAINERS; this skill is the authoritative table of what changes on a Windows PC that has no container. Read when a delivered skill carries the ADOM ANYWHERE NOTE banner, when an example says adom-cli or bash or ~/project and you are on Windows, when you need to run adom-wiki on Windows with auth, or when a wiki package install fails because its payload is a Linux binary. Triggers include adom anywhere windows, adom-cli not found, container-only skill, wiki cli on windows, and ADOM ANYWHERE NOTE.--# Adom Anywhere on Windows: the translation layer--Adom Bridge delivers the wider ecosystem's skills to you (the adom core set,-the adom-wiki-cli skill, and the adom-cli usage skills) and stamps each with an-ADOM ANYWHERE NOTE banner. Those skills were written for agents inside Adom-LINUX CONTAINERS. You are on a Windows PC without one. This is the translation.--## What is actually installed on this PC--By the Adom Bridge NSIS installer, into `%LOCALAPPDATA%\Adom Bridge` (on the-user PATH):--- `adom-bridge.exe`: the GUI app (tray, approvals, bridges). Do not invoke it- for commands.-- `adom-bridge-cli.exe`: your hands on this PC. Every desktop verb. Windows- native, no WSL required, talks to the GUI over loopback.-- `adom-wiki.exe`: the full wiki CLI. Windows native.-- `adom-cli.exe` (2.0.23+): the platform CLI, Windows native. Carbon identity- and containers, plus the hydrogen family: `hydrogen workspace`, `hydrogen- webview` (open and drive Web View panels), `hydrogen sandbox`, `hydrogen- screenshot` — meaning you can open a webview IN the user's cloud Hydrogen- workspace from this PC. Auth is automatic: adom-cli reads your Adom Bridge sign-in- (~/.adom/session.json) directly, refreshed per call.- For the hydrogen verbs, target a container by setting `VSCODE_PROXY_URI`- to its cloud URL (`https://<container-host>.adom.cloud/proxy/{{port}}/`)- or pass explicit owner/repo args where a verb accepts them.-- Skills: ab's own set plus the ecosystem set, auto-linked into- `~/.claude/skills` and `~/.codex/skills`, refreshed automatically.--NOT installed on this PC: bash-based package installs, any wiki-published-Linux binary.--## The translation table--| Container skill says | On this Windows PC |-|---|---|-| `adom-cli <anything>` | Installed natively since ab 2.0.23 (`adom-cli.exe` on PATH, authenticated from your Adom Bridge sign-in). Platform and cloud verbs work, including the hydrogen webview/workspace family against cloud containers. Only recipes that assume paths INSIDE a container still need one. |-| bash, `#!/bin/bash`, heredocs | PowerShell or `cmd.exe`. `curl.exe` and `tar.exe` ship with Windows 10+. |-| `~/project`, `~/.local/bin`, `/tmp` | Container workspace paths. Local equivalents are ordinary Windows paths (`%USERPROFILE%`, `%TEMP%`). The standard-folders contract applies INSIDE a workspace, not to this bare PC. |-| `adom-wiki pkg install adom/<x>` | Works ONLY for reading metadata here. If the package payload is a Linux binary (`dist/linux/...`) it cannot install or run on Windows. That is the container boundary: the supported path is Adom Hydrogen (https://wiki.adom.inc/adom/hydrogen), which runs a real Adom container on this same PC. Do not improvise (no wine, no hand-copied ELFs into WSL). |-| `adom-wiki <verb>` generally | Fully native: `adom-wiki.exe` is on PATH. Auth: run through `adom-bridge wiki_exec '{"args":[...]}'` and ab injects the signed-in user's token automatically, or set `ADOM_WIKI_TOKEN` yourself for direct calls. |-| Relay setup, `serve --supervise`, `--target` | Container-side concepts. Locally you need none of them: `adom-bridge` auto-routes to the GUI over the loopback direct API. See `adom-bridge-local-ai`. |--## Per-skill fit (mirrors the banner each skill carries)--- **As-is**: `foreground-etiquette` (it is about THIS PC; follow it),- `ai-first-ui`, `identity-card`.-- **Adapted (works natively, targets differ)**: `adom-wiki-cli` (native exe),- `carbon-api` (REST + adom-cli both work here), `adom` (adom-cli is native- now; cloud actions target a container), the `adom-cli-*` usage skills- (containers, workspace, hydrogen, sessions, ...: the CLI is native; verbs- that act on a workspace target a CLOUD container via VSCODE_PROXY_URI),- `adom-workspace-control` (drives a cloud Hydrogen workspace), `standard-folders`- (workspace contract, not bare-PC paths).-- **Container-only**: `standalone-service`, `standalone-services-test`,- `cloudflare-networking`. Delivered for awareness; acting on them means the- user wants Hydrogen.--## The posture--You are fully capable here: drive the PC, automate a real browser with pup,-author and manage wiki content. When a task genuinely needs a container, say-so plainly and point at Adom Hydrogen; that boundary is by design, not a-missing feature. See `adom-bridge-anywhere` for the overall map.+--- +name: adom-bridge-anywhere-windows +description: >- + For an AI running natively on Windows, not in an Adom container: what changes when a + delivered Adom skill assumes a Linux container. Use when a skill shows the ADOM ANYWHERE + NOTE banner, adom-cli is not found, an example says bash or ~/project, or adom-wiki needs + auth on Windows. +--- + +# Adom Anywhere on Windows: the translation layer + +Adom Bridge delivers the wider ecosystem's skills to you (the adom core set, +the adom-wiki-cli skill, and the adom-cli usage skills) and stamps each with an +ADOM ANYWHERE NOTE banner. Those skills were written for agents inside Adom +LINUX CONTAINERS. You are on a Windows PC without one. This is the translation. + +## What is actually installed on this PC + +By the Adom Bridge NSIS installer, into `%LOCALAPPDATA%\Adom Bridge` (on the +user PATH): + +- `adom-bridge.exe`: the GUI app (tray, approvals, bridges). Do not invoke it + for commands. +- `adom-bridge-cli.exe`: your hands on this PC. Every desktop verb. Windows + native, no WSL required, talks to the GUI over loopback. +- `adom-wiki.exe`: the full wiki CLI. Windows native. +- `adom-cli.exe` (2.0.23+): the platform CLI, Windows native. Carbon identity + and containers, plus the hydrogen family: `hydrogen workspace`, `hydrogen + webview` (open and drive Web View panels), `hydrogen sandbox`, `hydrogen + screenshot` — meaning you can open a webview IN the user's cloud Hydrogen + workspace from this PC. Auth is automatic: adom-cli reads your Adom Bridge sign-in + (~/.adom/session.json) directly, refreshed per call. + For the hydrogen verbs, target a container by setting `VSCODE_PROXY_URI` + to its cloud URL (`https://<container-host>.adom.cloud/proxy/{{port}}/`) + or pass explicit owner/repo args where a verb accepts them. +- Skills: ab's own set plus the ecosystem set, auto-linked into + `~/.claude/skills` and `~/.codex/skills`, refreshed automatically. + +NOT installed on this PC: bash-based package installs, any wiki-published +Linux binary. + +## The translation table + +| Container skill says | On this Windows PC | +|---|---| +| `adom-cli <anything>` | Installed natively since ab 2.0.23 (`adom-cli.exe` on PATH, authenticated from your Adom Bridge sign-in). Platform and cloud verbs work, including the hydrogen webview/workspace family against cloud containers. Only recipes that assume paths INSIDE a container still need one. | +| bash, `#!/bin/bash`, heredocs | PowerShell or `cmd.exe`. `curl.exe` and `tar.exe` ship with Windows 10+. | +| `~/project`, `~/.local/bin`, `/tmp` | Container workspace paths. Local equivalents are ordinary Windows paths (`%USERPROFILE%`, `%TEMP%`). The standard-folders contract applies INSIDE a workspace, not to this bare PC. | +| `adom-wiki pkg install adom/<x>` | Works ONLY for reading metadata here. If the package payload is a Linux binary (`dist/linux/...`) it cannot install or run on Windows. That is the container boundary: the supported path is Adom Hydrogen (https://wiki.adom.inc/adom/hydrogen), which runs a real Adom container on this same PC. Do not improvise (no wine, no hand-copied ELFs into WSL). | +| `adom-wiki <verb>` generally | Fully native: `adom-wiki.exe` is on PATH. Auth: run through `adom-bridge wiki_exec '{"args":[...]}'` and ab injects the signed-in user's token automatically, or set `ADOM_WIKI_TOKEN` yourself for direct calls. | +| Relay setup, `serve --supervise`, `--target` | Container-side concepts. Locally you need none of them: `adom-bridge` auto-routes to the GUI over the loopback direct API. See `adom-bridge-local-ai`. | + +## Per-skill fit (mirrors the banner each skill carries) + +- **As-is**: `foreground-etiquette` (it is about THIS PC; follow it), + `ai-first-ui`, `identity-card`. +- **Adapted (works natively, targets differ)**: `adom-wiki-cli` (native exe), + `carbon-api` (REST + adom-cli both work here), `adom` (adom-cli is native + now; cloud actions target a container), the `adom-cli-*` usage skills + (containers, workspace, hydrogen, sessions, ...: the CLI is native; verbs + that act on a workspace target a CLOUD container via VSCODE_PROXY_URI), + `adom-workspace-control` (drives a cloud Hydrogen workspace), `standard-folders` + (workspace contract, not bare-PC paths). +- **Container-only**: `standalone-service`, `standalone-services-test`, + `cloudflare-networking`. Delivered for awareness; acting on them means the + user wants Hydrogen. + +## The posture + +You are fully capable here: drive the PC, automate a real browser with pup, +author and manage wiki content. When a task genuinely needs a container, say +so plainly and point at Adom Hydrogen; that boundary is by design, not a +missing feature. See `adom-bridge-anywhere` for the overall map.
skills/adom-bridge-anywhere/SKILL.md+120−116@@ -1,116 +1,120 @@-name: adom-bridge-anywhere-description: Adom Anywhere, the full map of what a LOCAL third-party agent (Claude Desktop, Codex, Claude Code on Windows) can do with the Adom ecosystem from this PC, and where the boundary is. Covers what the Adom Bridge installer already gave you (adom-bridge-cli.exe and adom-wiki.exe on PATH, the auto-linked skill set), driving the PC and pup locally, authoring and managing wiki pages with adom-wiki, the ecosystem skills ab delivers daily (adom core, adom-wiki-cli), and the one hard boundary; running Linux binaries published on the wiki needs an Adom container, which is what Hydrogen provides. Use when a user on Claude Desktop or Codex asks what they can do with Adom, how to use the wiki from their PC, or why a wiki package will not run locally.--# Adom Anywhere: the Adom ecosystem from any local agent--You are an AI agent (Claude Desktop, Codex, Claude Code, or similar) running-directly on a Windows PC that has Adom Bridge installed. You do not need an-Adom cloud container to be useful here. This skill is the map of what you can-do right now, and the one thing you cannot.--## What the Adom Bridge installer already gave you--1. **`adom-bridge-cli.exe` on PATH**: every desktop verb (shell, files,- screenshots, windows, notifications, KiCad, Fusion 360, pup browser- automation). Start with the `adom-bridge-local-ai` skill for the local- routing model and a verb cookbook, then `adom-bridge` for the full surface.-2. **`adom-wiki.exe` on PATH**: the full Adom Wiki CLI (pages, repos, files,- discussions, packages, releases, discovery). If the user is signed in to- Adom Bridge, ab holds their session token; run wiki commands through- `adom-bridge wiki_exec` to have the token injected for you, or set- `ADOM_WIKI_TOKEN` yourself for direct `adom-wiki` calls.-3. **Skills, kept fresh for you**: ab auto-links its own skill set into- `~/.claude/skills/` and `~/.codex/skills/` on every launch, and refreshes- the wider ecosystem skill packages (the adom core set and the adom-wiki-cli- skill) daily from the wiki. You are reading the result. The ecosystem- skills were written for Adom Linux containers, so each delivered copy- carries an ADOM ANYWHERE NOTE banner classifying how it applies here; the- full Windows translation lives in `adom-bridge-anywhere-windows`.--## What works fully, locally, today--- **Drive this PC**: everything in the `adom-bridge` skill. Screenshots,- windows, input, files, process control, notifications, recordings.-- **pup (real-Chrome browser automation)**: `pup_open_window`, `pup_eval`,- `pup_screenshot`, multi-session profiles. The bridge auto-spawns on first- use. This alone makes a local agent a capable web-automation operator.-- **Author and manage wiki content**: create pages, push files, open and reply- to discussions, star, publish packages you build here. The wiki is the- distribution surface for the whole Adom ecosystem, and `adom-wiki.exe` is- the complete client for it.-- **Consume wiki KNOWLEDGE**: skills, docs, page content, package metadata.--## One CLI reaches the WHOLE ecosystem — but don't route local work through the cloud--This is the flexibility that makes ab feel like magic, and the trap that hides-inside it. From this one local CLI you can address:--- **The ab on THIS machine, directly.** `adom-bridge-cli <verb>` with **no- `--target`** short-circuits to the loopback direct API (`127.0.0.1`, the port- in `~/.adom/direct-api-port`, else `24680`/`47200`). No relay, no cloud, no- container. Sub-200ms. This is the default and the right path for this box.-- **Any PEER ab (another machine), through the relay it is on.** Point the CLI- at that relay's HTTP API and target the peer:- `ADOM_API=<relay-http-url> adom-bridge --target <clientName> <verb>`. The- relay HTTP API is the WS URL with the port bumped by one and `http(s)` — e.g.- a server `wss://…/proxy/8765/` becomes `https://…/proxy/8766`. List who is on- that relay with `ADOM_API=<relay-http-url> adom-bridge targets`; see which- bridges run where with `bridge_list`. ⚠ A **plain** `adom-bridge --target- <name>` with **no `ADOM_API`** does NOT reach a cloud peer — with no- `ADOM_API` the CLI looks for a relay on `127.0.0.1:8766` (normally not- running) and returns `target_not_found`. Reaching a peer means pointing the- CLI at the relay, full stop. (This is exactly how the GUI's peer panel and any- working peer script reach another machine.)--So "get into the ab ecosystem and see everything" is real: one CLI, the local-machine directly **plus** every peer — as long as you point at the right relay.-Two facts are load-bearing, and forgetting them will cost you:--1. **There is NO direct machine-to-machine path.** A peer (say a conference-room- PC) is reachable ONLY through a relay BOTH ends are connected to, and only by- pointing `ADOM_API` at that relay. A relay outage or a peer dropping off makes- the peer unreachable until it is back — check with `ADOM_API=<relay> adom-bridge- targets` (the peer appears in the list only while it is actually connected).- Note the loopback `targets` (no `ADOM_API`) reports only THIS machine and does- NOT enumerate the relay's peers — use the relay-pointed call to see peers.-2. **⛔ Never route work THIS machine can do out through a cloud relay.** A- remote/relay call is only as reliable as the cloud relay + the peer's- connection — a real failure point that has nothing to do with the task. Hit- live 2026-08-27: a scratch harness hardcoded `ADOM_API=<cloud-relay-proxy>- --target ConfRoomROG`, so when the cloud proxy 500'd, the campaign stalled —- even though **this very box had all six bridges (kicad, fusion360, puppeteer,- native-browser, rustdesk, hd) running locally** and could have done the whole- job over the loopback API with no cloud involved. KiCad/Fusion/pup render- identically on any machine, so there was never a reason to leave this box.--**The rule: before you reach for `--target`, ask "can THIS machine do it?"**-Check `bridge_list` for the bridge you need and `targets` for who is actually-reachable. Use a remote target only for work that genuinely must happen on-another machine (e.g. a screenshot of a conference-room screen), and treat the-relay as a dependency that can fail. The deep routing/addressing model lives in-`adom-bridge-local-ai` (loopback case) and the multi-machine addressing skill-(relay case).--## The one boundary: wiki-published Linux binaries--Many wiki packages bundle a Linux CLI in their tarball (`dist/linux/...`) and-an `install.sh` that expects an Adom Linux container. Those binaries cannot-run on Windows directly, and `adom-wiki pkg install` of such a package is a-container operation.--When you hit this, tell the user plainly, and tell them the good part: this is-exactly what **Adom Hydrogen** solves. Hydrogen is the main Adom app; it runs-a full Linux workspace on this same PC (WSL2), installs those packages with-one command, and pairs with the Adom Bridge that is already installed here.-Point them at https://wiki.adom.inc/adom/hydrogen. Do not improvise a local-workaround (WSL hand-installs, wine, copying ELFs around); the supported path-is a real Adom container, and Hydrogen is the one-click way to get one.--## Etiquette--You are a guest on someone's personal machine. Read-`adom-bridge-permissions` before anything gated (shell, file writes), say who-you are on every call (`--ai-thread`, see `adom-bridge-caller-identity`), and-keep foreground manners (`foreground-etiquette` from the adom core skills).+--- +name: adom-bridge-anywhere +description: >- + For an AI running natively on Windows (Claude Desktop, Codex, Claude Code on the PC), not in + an Adom container: what you can do with Adom from this PC and where the boundary is. Use + when asked what can I do with Adom here, how to use the wiki from my PC, or why a wiki + package won't run locally. +--- + +# Adom Anywhere: the Adom ecosystem from any local agent + +You are an AI agent (Claude Desktop, Codex, Claude Code, or similar) running +directly on a Windows PC that has Adom Bridge installed. You do not need an +Adom cloud container to be useful here. This skill is the map of what you can +do right now, and the one thing you cannot. + +## What the Adom Bridge installer already gave you + +1. **`adom-bridge-cli.exe` on PATH**: every desktop verb (shell, files, + screenshots, windows, notifications, KiCad, Fusion 360, pup browser + automation). Start with the `adom-bridge-local-ai` skill for the local + routing model and a verb cookbook, then `adom-bridge` for the full surface. +2. **`adom-wiki.exe` on PATH**: the full Adom Wiki CLI (pages, repos, files, + discussions, packages, releases, discovery). If the user is signed in to + Adom Bridge, ab holds their session token; run wiki commands through + `adom-bridge wiki_exec` to have the token injected for you, or set + `ADOM_WIKI_TOKEN` yourself for direct `adom-wiki` calls. +3. **Skills, kept fresh for you**: ab auto-links its own skill set into + `~/.claude/skills/` and `~/.codex/skills/` on every launch, and refreshes + the wider ecosystem skill packages (the adom core set and the adom-wiki-cli + skill) daily from the wiki. You are reading the result. The ecosystem + skills were written for Adom Linux containers, so each delivered copy + carries an ADOM ANYWHERE NOTE banner classifying how it applies here; the + full Windows translation lives in `adom-bridge-anywhere-windows`. + +## What works fully, locally, today + +- **Drive this PC**: everything in the `adom-bridge` skill. Screenshots, + windows, input, files, process control, notifications, recordings. +- **pup (real-Chrome browser automation)**: `pup_open_window`, `pup_eval`, + `pup_screenshot`, multi-session profiles. The bridge auto-spawns on first + use. This alone makes a local agent a capable web-automation operator. +- **Author and manage wiki content**: create pages, push files, open and reply + to discussions, star, publish packages you build here. The wiki is the + distribution surface for the whole Adom ecosystem, and `adom-wiki.exe` is + the complete client for it. +- **Consume wiki KNOWLEDGE**: skills, docs, page content, package metadata. + +## One CLI reaches the WHOLE ecosystem — but don't route local work through the cloud + +This is the flexibility that makes ab feel like magic, and the trap that hides +inside it. From this one local CLI you can address: + +- **The ab on THIS machine, directly.** `adom-bridge-cli <verb>` with **no + `--target`** short-circuits to the loopback direct API (`127.0.0.1`, the port + in `~/.adom/direct-api-port`, else `24680`/`47200`). No relay, no cloud, no + container. Sub-200ms. This is the default and the right path for this box. +- **Any PEER ab (another machine), through the relay it is on.** Point the CLI + at that relay's HTTP API and target the peer: + `ADOM_API=<relay-http-url> adom-bridge --target <clientName> <verb>`. The + relay HTTP API is the WS URL with the port bumped by one and `http(s)` — e.g. + a server `wss://…/proxy/8765/` becomes `https://…/proxy/8766`. List who is on + that relay with `ADOM_API=<relay-http-url> adom-bridge targets`; see which + bridges run where with `bridge_list`. ⚠ A **plain** `adom-bridge --target + <name>` with **no `ADOM_API`** does NOT reach a cloud peer — with no + `ADOM_API` the CLI looks for a relay on `127.0.0.1:8766` (normally not + running) and returns `target_not_found`. Reaching a peer means pointing the + CLI at the relay, full stop. (This is exactly how the GUI's peer panel and any + working peer script reach another machine.) + +So "get into the ab ecosystem and see everything" is real: one CLI, the local +machine directly **plus** every peer — as long as you point at the right relay. +Two facts are load-bearing, and forgetting them will cost you: + +1. **There is NO direct machine-to-machine path.** A peer (say a conference-room + PC) is reachable ONLY through a relay BOTH ends are connected to, and only by + pointing `ADOM_API` at that relay. A relay outage or a peer dropping off makes + the peer unreachable until it is back — check with `ADOM_API=<relay> adom-bridge + targets` (the peer appears in the list only while it is actually connected). + Note the loopback `targets` (no `ADOM_API`) reports only THIS machine and does + NOT enumerate the relay's peers — use the relay-pointed call to see peers. +2. **⛔ Never route work THIS machine can do out through a cloud relay.** A + remote/relay call is only as reliable as the cloud relay + the peer's + connection — a real failure point that has nothing to do with the task. Hit + live 2026-08-27: a scratch harness hardcoded `ADOM_API=<cloud-relay-proxy> + --target ConfRoomROG`, so when the cloud proxy 500'd, the campaign stalled — + even though **this very box had all six bridges (kicad, fusion360, puppeteer, + native-browser, rustdesk, hd) running locally** and could have done the whole + job over the loopback API with no cloud involved. KiCad/Fusion/pup render + identically on any machine, so there was never a reason to leave this box. + +**The rule: before you reach for `--target`, ask "can THIS machine do it?"** +Check `bridge_list` for the bridge you need and `targets` for who is actually +reachable. Use a remote target only for work that genuinely must happen on +another machine (e.g. a screenshot of a conference-room screen), and treat the +relay as a dependency that can fail. The deep routing/addressing model lives in +`adom-bridge-local-ai` (loopback case) and the multi-machine addressing skill +(relay case). + +## The one boundary: wiki-published Linux binaries + +Many wiki packages bundle a Linux CLI in their tarball (`dist/linux/...`) and +an `install.sh` that expects an Adom Linux container. Those binaries cannot +run on Windows directly, and `adom-wiki pkg install` of such a package is a +container operation. + +When you hit this, tell the user plainly, and tell them the good part: this is +exactly what **Adom Hydrogen** solves. Hydrogen is the main Adom app; it runs +a full Linux workspace on this same PC (WSL2), installs those packages with +one command, and pairs with the Adom Bridge that is already installed here. +Point them at https://wiki.adom.inc/adom/hydrogen. Do not improvise a local +workaround (WSL hand-installs, wine, copying ELFs around); the supported path +is a real Adom container, and Hydrogen is the one-click way to get one. + +## Etiquette + +You are a guest on someone's personal machine. Read +`adom-bridge-permissions` before anything gated (shell, file writes), say who +you are on every call (`--ai-thread`, see `adom-bridge-caller-identity`), and +keep foreground manners (`foreground-etiquette` from the adom core skills).
skills/adom-bridge-auth/SKILL.md+5−1@@ -1,6 +1,10 @@ --- name: adom-bridge-auth -description: "Sign in to Adom Bridge and connect a cloud container to the user's desktop: the Connections panel 'Sign in to Adom' browser flow, auto-discover servers from the user's Adom Cloud profile (zero JSON paste), register_container from a cloud container, the one-command setup_desktop flow, configure ab from the CLI, the cloud key-value config keys (bridge.container.*), the paste-JSON fallback, and sign-out. Trigger words: sign in to Adom Bridge, connect container to desktop, register_container, setup_desktop, auto-discover servers, connect my desktop, server JSON, cloud config, sign out." +description: >- + Sign in to Adom Bridge and connect a cloud container to the user's desktop: Connections + panel sign-in, auto-discovered servers, register_container, setup_desktop, sign-out. Use + when asked to sign in to Adom Bridge, connect my desktop, connect a container to my PC, or + fix the server config. --- # Adom Bridge — sign-in, auth, and cloud configuration
skills/adom-bridge-bridges-guide/SKILL.md+7−14@@ -1,17 +1,10 @@ --- -name: adom-bridge-sdk-guide -description: > - Consumer guide to Adom Bridge bridges: what a bridge is, the Bridges sidebar cards, - bridge status and the bridge LED colors (green/yellow/red/gray), bridge lifecycle - (spawn, stable port, single instance, auto-respawn, duplicate collapse), how to - install a bridge (bridge_install with a manifestUrl), bridge updates (refresh_bridges, - bridge_check_updates, the 4-hour auto-sync from each bridge's wiki page), the bridge - console and logs (bridge_log_read and the ab lifecycle log), bridge management verbs - (bridge_list, bridge_info, bridge_detail, bridge_kill, bridge_pause, bridge_resume, - bridge_readiness), and sync_skills for pulling each bridge's own skill package. - Trigger words: bridge status, bridge LED, install a bridge, bridge_install, - refresh_bridges, bridge console, bridge lifecycle, sync_skills, bridge updates, - bridge_list, bridge_kill, bridge stuck, bridge offline, bridge not running. +name: adom-bridge-bridges-guide +description: >- + Consumer guide to Adom Bridge bridges: what a bridge is, status LEDs, lifecycle, install, + updates, logs and the bridge_* management verbs. Use when a bridge is stuck, offline or not + running, to install a bridge, check for bridge updates, read the bridge console, or + sync_skills. --- # Adom Bridge bridges — the consumer lifecycle guide @@ -119,7 +112,7 @@ is a human running `bridge_install {manifestUrl, force:true}`. ## The key verbs ```bash -# Inventory — start here. Includes per-bridge skillPkg (adom/<slug>) and installSkill +# Inventory — start here. Includes per-bridge skillPkg (<owner>/<slug>) and installSkill # (a ready-made `adom-wiki pkg install` line), plus a top-level _skillsHint. adom-bridge bridge_list
skills/adom-bridge-caller-identity/SKILL.md+5−1@@ -1,6 +1,10 @@ --- name: adom-bridge-caller-identity -description: Fix `caller_identity_required` refusals and tell ab who is asking. Read when an Adom Bridge verb is refused with caller_identity_required, when you need to set aiThread / ADOM_AI_THREAD / ADOM_CONTAINER_NAME, when the user asks "which thread is driving my machine" or "identify my thread", when you are writing a bridge that calls ab verbs back and must forward X-Adom-Caller-* headers or set X-Adom-Caller-Delegate, or when you need to understand caller identity, delegation chains, thread name and container name attribution on relayed commands. +description: >- + Fix caller_identity_required refusals and tell ab which AI thread is asking (aiThread, + ADOM_AI_THREAD, X-Adom-Caller-* headers, delegation). Use when a verb is refused with + caller_identity_required, or asked which thread is driving my machine or to identify my + thread. --- # Caller identity: telling Adom Bridge who is asking
skills/adom-bridge-cli-guide/SKILL.md+210−207@@ -1,207 +1,210 @@-name: adom-bridge-guide-description: Use when working with the adom-bridge CLI itself - how to run desktop verbs, discover commands (adom-bridge commands, help topics), address targets with --target, start the serve relay with --supervise, read the status/durationMs/timeoutSeconds response shape, stream long ops with watch, or sweep the screenshot cache with janitor.--# The adom-bridge CLI--One Rust binary, two jobs: it runs the relay server (`adom-bridge serve`) and it sends-verbs to the Adom Bridge app (`adom-bridge <verb> '<json>'`). Everything an AI does to-the user's desktop goes through this surface.--## The two binaries (same CLI, two homes)--| Where | Binary | How it got there | How it reaches the desktop |-|---|---|---|---|-| Cloud Linux container | `adom-bridge` | `adom-wiki pkg install adom/adom-bridge` (installs the CLI + the skill set) | Over the relay it serves (WS :8765 / HTTP :8766) |-| Windows, next to the GUI | `adom-bridge-cli.exe` | Bundled by the ab installer into `%LOCALAPPDATA%\Adom Bridge\`, which the installer adds to the user PATH (HKCU, no UAC) | Auto-probes the local GUI's loopback direct API (`127.0.0.1:47200`, discovery file `~/.adom/direct-api-port`, fallback scan 47200-47209) |--Verify which build you have:--```bash-adom-bridge --version-# adom-bridge 1.9.x (<git-sha>, built <timestamp>)-```--## Discovering verbs: `commands` is THE catalog, not `--help`--`adom-bridge --help` lists only the real clap subcommands: `serve` and `install`. Every-actual capability (200+ verbs) is a dynamic verb forwarded to the desktop app, and the-authoritative machine-readable catalog is:--```bash-adom-bridge commands # ab-core catalog (args, returns, _hint per verb) + `bridges`: the box's installed bridges, live-adom-bridge --target <name> commands # the same, for THAT box-adom-bridge commands '{"bridge":"fusion360"}' # ONE bridge, described by the bridge itself (its <prefix>describe verb, else its manifest's verb list)-adom-bridge help <topic> # details for one ab-core verb or namespace (e.g. help shell); `help pup` / `help kicad` point at the bridge-```--**Bridge verbs are the bridge's to describe (2.1.144, wiki #211).** `commands` carries NO-copy of any bridge's verbs: ab is the operating system, a bridge owns all of its verbs-(John, 2026-08-19). The hand-written `fusion360` / `kicad` / `browser` sections ab used-to print drifted (63 Fusion verbs listed against the bridge's 160, retired verbs still-there, shipped verbs missing) and made an AI conclude a documented verb did not exist.-Now `commands` appends `bridges` read live from the box (name, version, status,-verbPrefixes, verbCount, verb names, `describeVerb`), and `commands {"bridge": name}`-returns the bridge's own `<prefix>describe` answer (every verb with inputs, outputs,-timeout) or, for a bridge without one, its manifest's verb list. The per-verb how-to is-the bridge's skill: `sync_skills`.--Run `commands` FIRST when unsure what a verb is called. There is almost certainly a-structured verb with typed args (`process_list`, `process_kill`, `run_script`,-`desktop_list_files`) so you never hand-quote a command through JSON into a shell.-`shell_execute` is the escape hatch for things with no verb yet, not the default.--Two kinds of verbs, do not confuse them:--- **Built-in ab-core verbs** are on every Adom Bridge, no install: screenshots, windows,- input, files, registry, shell, tunnel, update, notify.-- **Bridge verbs** (`pup_*`, `kicad_*`, `fusion_*`, `nbrowser_*`, ...) need that bridge- installed. Check `bridges` in `commands` (was `installed_bridges_on_target` before- 2.1.144), or run `bridge_list`.--An unknown verb returns `errorCode:"unknown_verb"` with a fuzzy did-you-mean `_hint`.-Read it; do not conclude a capability is missing from a guessed name.--Recognized help namespaces (`adom-bridge help <topic>` or `adom-bridge <topic> help`):-`hd`, `shell`, `files`, `notifications`, `setup`, `connectivity`, `system`, `record`,-`server`, `bridge`, `bridges`, `wiki`. A bridge namespace (`pup`/`browser`, `kicad`, `fusion`)-prints a pointer at the bridge's own description instead of a list.--## Invoking a verb--```bash-adom-bridge <verb> '<json-args>'-adom-bridge ping-adom-bridge shell_execute '{"command":"echo hello","reason":"smoke-testing the shell path"}'-adom-bridge desktop_screenshot_window '{"hwnd": 12345}'-```--Output is pretty-printed JSON on stdout. Windows paths may be written with forward-slashes (`C:/Users/...`); the CLI normalizes both directions automatically.--**Grouped verbs** take a bare-word subcommand before the JSON:--```bash-adom-bridge tunnel rdp '{"to":"127.0.0.1:3389"}'-adom-bridge tunnel list-```--**Gated verbs need a `reason`.** Verbs that require human approval over the relay-(`shell_execute`, `run_script`, and the rest of the permission class) require a `reason`-arg: one plain sentence the user reads verbatim in the approval prompt. A missing reason-returns `errorCode:"reason_required"`.--## The uniform response shape--Every result carries, on every path (relay, direct API, Windows CLI):--- **`status`**: `ok` | `timeout` | `busy` | `error`. One authoritative field. An empty- `stdout` with `status:ok` is a real empty-output success, NOT a timeout.-- **`durationMs`**: the CLI's measured round-trip.-- **`_hint`**: actionable guidance authored by ab core (recovery steps on `timeout`/`busy`,- next-verb pointers on success). The CLI only authors a hint for the one case ab cannot:- the call never reached the desktop at all.-- **`timeoutSeconds`**: the HTTP budget that governed this call. On a timeout, bridge- verbs return `errorCode:"timed_out"` with `stillRunning:true`: the operation usually- keeps running on the bridge. Poll the response's `statusVerb` instead of re-issuing.-- Exec verbs additionally carry `exitCode` / `stdout` / `stderr`.--`adom-bridge timeouts` returns the per-verb budget registry (long verbs, their budgets,-and the status verb to poll).--## Targeting: one relay, many desktops--One relay can hold several Adom Bridges (laptop + VM + ...). Each has a name-(`--client-name` launch flag / Settings field / hostname default).--```bash-adom-bridge targets # {count, targets:[{name, hostname, version, os, connectedAt}]}-adom-bridge --target azurevm desktop_screenshot_screen-adom-bridge --target all hd_status # fan out; results keyed by name + summary-```--- `--target` is a global flag: works before or after the verb, case-insensitive, matches- `clientName` then `hostname`. `ADOM_TARGET=<name>` sets a default; the flag wins.-- Exactly ONE desktop connected: no `--target` needed.-- Multiple connected, no `--target`: the call fails with `error:"ambiguous_target"`- (exit 1) and lists the connected names. It never guesses. A bad name returns- `target_not_found`.-- `--target all` returns `{broadcast:true, results:{<name>:...}, summary:{total,ok,failed}}`;- a per-desktop failure is isolated to that desktop's entry.-- Caveat: `ping` has its own direct-API path and does NOT honor `--target`. Smoke-test- routing with a relay-path verb (`server_list`, `desktop_*`).--## Local vs relay routing--- **No `--target` on Windows**: the CLI short-circuits to the local GUI's loopback direct- API. No relay required. This is the right path for anything on the same machine.-- **`--target <name>` (or `ADOM_TARGET`)**: the call traverses the relay, even from the- desktop itself, so it can reach a named or broadcast set of machines.-- **In a container**: everything goes through the relay the container runs.--## The relay: `serve --supervise` in containers--The relay must be running before any desktop can connect. In a container, ALWAYS start-it supervised:--```bash-adom-bridge serve --supervise # detached + self-healing-adom-bridge serve --supervise --owner AdomLapper # send restart toasts to one desktop-adom-bridge serve --stop # stop watchdog + relay-```--Why not `adom-bridge serve &`? A backgrounded relay is still a child of your session.-When the session is torn down, the relay dies with it, and every connected desktop loops-"Reconnecting in 30s" for hours until a human restarts it. `--supervise` detaches the-relay into its own session (reparented to init) and runs a watchdog that restarts it-with exponential backoff (2s up to a 5 min cap; a clean run resets it) and toasts the-connected desktops with the reason on each restart.--Plain foreground `serve` still works and prints a loud warning that it dies with the-session. Caveat: a full container reboot still needs a boot hook (cron `@reboot` or the-container's init); the watchdog covers session teardown, not host reboot.--What `serve` binds:--- WebSocket on `127.0.0.1:8765` (desktop apps connect here). Before 2.1.170 this was- `0.0.0.0`; under Hydrogen's mirrored WSL networking that put it on the Windows host's- LAN interfaces, accepting the dev token (wiki #225). `--bind 0.0.0.0` (or- `ADOM_RELAY_BIND=0.0.0.0`) opts back in when a desktop must connect over the network- directly, e.g. a `docker -p` relay; the relay then prints a warning.-- HTTP API on `127.0.0.1:8766` (CLI commands go here)--Health check: `curl -sf http://127.0.0.1:8766/health`. If another relay is already-answering, `serve` skips with a friendly message; `--force-bind` overrides (only for-deliberate parallel relays).--## Long operations: `responseMode:"stream"`--Any verb accepts `responseMode:"stream"`: the CLI runs it as a job and prints one JSON line per lifecycle event as it arrives, the final result last (`streamTimeoutSeconds` caps the follow; detaching never cancels the job). This replaced the Fusion-only `watch` wrapper in 2.1.146 (wiki #212): a bridge's progress is the bridge's to report, and this path is the same for every bridge.--## `janitor`: the screenshot cache--Every screenshot verb auto-pulls its PNGs to a local cache on the caller's machine-(`~/project/screenshots/adom-bridge/` when `~/project` exists, else-`~/.adom/screenshots/`; never `/tmp`), bucketed into `window/` and `screen/` with a-JSON sidecar per shot. The CLI-local `janitor` verb (no relay) keeps it bounded:--```bash-adom-bridge janitor # status: managed dirs, sizes, policy, last sweep-adom-bridge janitor '{"sub":"run"}' # force a sweep now-```--A sweep also runs opportunistically at most once per 24h on any CLI call. Policy is-age + size capped, newest files kept; sidecars follow their PNG. Do not build your own-cleaner: verify with `janitor` status instead.--## Environment variables--| Variable | Effect |-|---|---|-| `ADOM_API` | Relay HTTP API base URL the CLI talks to (default `http://127.0.0.1:8766`). Point it at a proxied relay API to reach a remote relay directly. |-| `ADOM_TARGET` | Default `--target` value; the flag wins. |-| `ADOM_WS_PORT` / `ADOM_HTTP_PORT` | `serve` port overrides (defaults 8765 / 8766). |-| `ADOM_AUTH_TOKEN` | `serve` auth token. |-| `ADOM_JANITOR=0` | Disable the opportunistic cache sweep. |-| `ADOM_SHOTS_DIR` | Override the screenshot cache directory. |+--- +name: adom-bridge-cli-guide +description: >- + How to use the adom-bridge CLI itself: running verbs, finding commands (commands, help), + --target, serve --supervise, the status/durationMs response shape, watch and janitor. Use + when unsure how to call a verb, find a command, start the relay, or read a CLI response. +--- + +# The adom-bridge CLI + +One Rust binary, two jobs: it runs the relay server (`adom-bridge serve`) and it sends +verbs to the Adom Bridge app (`adom-bridge <verb> '<json>'`). Everything an AI does to +the user's desktop goes through this surface. + +## The two binaries (same CLI, two homes) + +| Where | Binary | How it got there | How it reaches the desktop | +|---|---|---|---| +| Cloud Linux container | `adom-bridge` | `adom-wiki pkg install adom/adom-bridge` (installs the CLI + the skill set) | Over the relay it serves (WS :8765 / HTTP :8766) | +| Windows, next to the GUI | `adom-bridge-cli.exe` | Bundled by the ab installer into `%LOCALAPPDATA%\Adom Bridge\`, which the installer adds to the user PATH (HKCU, no UAC) | Auto-probes the local GUI's loopback direct API (`127.0.0.1:47200`, discovery file `~/.adom/direct-api-port`, fallback scan 47200-47209) | + +Verify which build you have: + +```bash +adom-bridge --version +# adom-bridge 1.9.x (<git-sha>, built <timestamp>) +``` + +## Discovering verbs: `commands` is THE catalog, not `--help` + +`adom-bridge --help` lists only the real clap subcommands: `serve` and `install`. Every +actual capability (200+ verbs) is a dynamic verb forwarded to the desktop app, and the +authoritative machine-readable catalog is: + +```bash +adom-bridge commands # ab-core catalog (args, returns, _hint per verb) + `bridges`: the box's installed bridges, live +adom-bridge --target <name> commands # the same, for THAT box +adom-bridge commands '{"bridge":"fusion360"}' # ONE bridge, described by the bridge itself (its <prefix>describe verb, else its manifest's verb list) +adom-bridge help <topic> # details for one ab-core verb or namespace (e.g. help shell); `help pup` / `help kicad` point at the bridge +``` + +**Bridge verbs are the bridge's to describe (2.1.144, wiki #211).** `commands` carries NO +copy of any bridge's verbs: ab is the operating system, a bridge owns all of its verbs +(John, 2026-08-19). The hand-written `fusion360` / `kicad` / `browser` sections ab used +to print drifted (63 Fusion verbs listed against the bridge's 160, retired verbs still +there, shipped verbs missing) and made an AI conclude a documented verb did not exist. +Now `commands` appends `bridges` read live from the box (name, version, status, +verbPrefixes, verbCount, verb names, `describeVerb`), and `commands {"bridge": name}` +returns the bridge's own `<prefix>describe` answer (every verb with inputs, outputs, +timeout) or, for a bridge without one, its manifest's verb list. The per-verb how-to is +the bridge's skill: `sync_skills`. + +Run `commands` FIRST when unsure what a verb is called. There is almost certainly a +structured verb with typed args (`process_list`, `process_kill`, `run_script`, +`desktop_list_files`) so you never hand-quote a command through JSON into a shell. +`shell_execute` is the escape hatch for things with no verb yet, not the default. + +Two kinds of verbs, do not confuse them: + +- **Built-in ab-core verbs** are on every Adom Bridge, no install: screenshots, windows, + input, files, registry, shell, tunnel, update, notify. +- **Bridge verbs** (`pup_*`, `kicad_*`, `fusion_*`, `nbrowser_*`, ...) need that bridge + installed. Check `bridges` in `commands` (was `installed_bridges_on_target` before + 2.1.144), or run `bridge_list`. + +An unknown verb returns `errorCode:"unknown_verb"` with a fuzzy did-you-mean `_hint`. +Read it; do not conclude a capability is missing from a guessed name. + +Recognized help namespaces (`adom-bridge help <topic>` or `adom-bridge <topic> help`): +`hd`, `shell`, `files`, `notifications`, `setup`, `connectivity`, `system`, `record`, +`server`, `bridge`, `bridges`, `wiki`. A bridge namespace (`pup`/`browser`, `kicad`, `fusion`) +prints a pointer at the bridge's own description instead of a list. + +## Invoking a verb + +```bash +adom-bridge <verb> '<json-args>' +adom-bridge ping +adom-bridge shell_execute '{"command":"echo hello","reason":"smoke-testing the shell path"}' +adom-bridge desktop_screenshot_window '{"hwnd": 12345}' +``` + +Output is pretty-printed JSON on stdout. Windows paths may be written with forward +slashes (`C:/Users/...`); the CLI normalizes both directions automatically. + +**Grouped verbs** take a bare-word subcommand before the JSON: + +```bash +adom-bridge tunnel rdp '{"to":"127.0.0.1:3389"}' +adom-bridge tunnel list +``` + +**Gated verbs need a `reason`.** Verbs that require human approval over the relay +(`shell_execute`, `run_script`, and the rest of the permission class) require a `reason` +arg: one plain sentence the user reads verbatim in the approval prompt. A missing reason +returns `errorCode:"reason_required"`. + +## The uniform response shape + +Every result carries, on every path (relay, direct API, Windows CLI): + +- **`status`**: `ok` | `timeout` | `busy` | `error`. One authoritative field. An empty + `stdout` with `status:ok` is a real empty-output success, NOT a timeout. +- **`durationMs`**: the CLI's measured round-trip. +- **`_hint`**: actionable guidance authored by ab core (recovery steps on `timeout`/`busy`, + next-verb pointers on success). The CLI only authors a hint for the one case ab cannot: + the call never reached the desktop at all. +- **`timeoutSeconds`**: the HTTP budget that governed this call. On a timeout, bridge + verbs return `errorCode:"timed_out"` with `stillRunning:true`: the operation usually + keeps running on the bridge. Poll the response's `statusVerb` instead of re-issuing. +- Exec verbs additionally carry `exitCode` / `stdout` / `stderr`. + +`adom-bridge timeouts` returns the per-verb budget registry (long verbs, their budgets, +and the status verb to poll). + +## Targeting: one relay, many desktops + +One relay can hold several Adom Bridges (laptop + VM + ...). Each has a name +(`--client-name` launch flag / Settings field / hostname default). + +```bash +adom-bridge targets # {count, targets:[{name, hostname, version, os, connectedAt}]} +adom-bridge --target azurevm desktop_screenshot_screen +adom-bridge --target all hd_status # fan out; results keyed by name + summary +``` + +- `--target` is a global flag: works before or after the verb, case-insensitive, matches + `clientName` then `hostname`. `ADOM_TARGET=<name>` sets a default; the flag wins. +- Exactly ONE desktop connected: no `--target` needed. +- Multiple connected, no `--target`: the call fails with `error:"ambiguous_target"` + (exit 1) and lists the connected names. It never guesses. A bad name returns + `target_not_found`. +- `--target all` returns `{broadcast:true, results:{<name>:...}, summary:{total,ok,failed}}`; + a per-desktop failure is isolated to that desktop's entry. +- Caveat: `ping` has its own direct-API path and does NOT honor `--target`. Smoke-test + routing with a relay-path verb (`server_list`, `desktop_*`). + +## Local vs relay routing + +- **No `--target` on Windows**: the CLI short-circuits to the local GUI's loopback direct + API. No relay required. This is the right path for anything on the same machine. +- **`--target <name>` (or `ADOM_TARGET`)**: the call traverses the relay, even from the + desktop itself, so it can reach a named or broadcast set of machines. +- **In a container**: everything goes through the relay the container runs. + +## The relay: `serve --supervise` in containers + +The relay must be running before any desktop can connect. In a container, ALWAYS start +it supervised: + +```bash +adom-bridge serve --supervise # detached + self-healing +adom-bridge serve --supervise --owner AdomLapper # send restart toasts to one desktop +adom-bridge serve --stop # stop watchdog + relay +``` + +Why not `adom-bridge serve &`? A backgrounded relay is still a child of your session. +When the session is torn down, the relay dies with it, and every connected desktop loops +"Reconnecting in 30s" for hours until a human restarts it. `--supervise` detaches the +relay into its own session (reparented to init) and runs a watchdog that restarts it +with exponential backoff (2s up to a 5 min cap; a clean run resets it) and toasts the +connected desktops with the reason on each restart. + +Plain foreground `serve` still works and prints a loud warning that it dies with the +session. Caveat: a full container reboot still needs a boot hook (cron `@reboot` or the +container's init); the watchdog covers session teardown, not host reboot. + +What `serve` binds: + +- WebSocket on `127.0.0.1:8765` (desktop apps connect here). Before 2.1.170 this was + `0.0.0.0`; under Hydrogen's mirrored WSL networking that put it on the Windows host's + LAN interfaces, accepting the dev token (wiki #225). `--bind 0.0.0.0` (or + `ADOM_RELAY_BIND=0.0.0.0`) opts back in when a desktop must connect over the network + directly, e.g. a `docker -p` relay; the relay then prints a warning. +- HTTP API on `127.0.0.1:8766` (CLI commands go here) + +Health check: `curl -sf http://127.0.0.1:8766/health`. If another relay is already +answering, `serve` skips with a friendly message; `--force-bind` overrides (only for +deliberate parallel relays). + +## Long operations: `responseMode:"stream"` + +Any verb accepts `responseMode:"stream"`: the CLI runs it as a job and prints one JSON line per lifecycle event as it arrives, the final result last (`streamTimeoutSeconds` caps the follow; detaching never cancels the job). This replaced the Fusion-only `watch` wrapper in 2.1.146 (wiki #212): a bridge's progress is the bridge's to report, and this path is the same for every bridge. + +## `janitor`: the screenshot cache + +Every screenshot verb auto-pulls its PNGs to a local cache on the caller's machine +(`~/project/screenshots/adom-bridge/` when `~/project` exists, else +`~/.adom/screenshots/`; never `/tmp`), bucketed into `window/` and `screen/` with a +JSON sidecar per shot. The CLI-local `janitor` verb (no relay) keeps it bounded: + +```bash +adom-bridge janitor # status: managed dirs, sizes, policy, last sweep +adom-bridge janitor '{"sub":"run"}' # force a sweep now +``` + +A sweep also runs opportunistically at most once per 24h on any CLI call. Policy is +age + size capped, newest files kept; sidecars follow their PNG. Do not build your own +cleaner: verify with `janitor` status instead. + +## Environment variables + +| Variable | Effect | +|---|---| +| `ADOM_API` | Relay HTTP API base URL the CLI talks to (default `http://127.0.0.1:8766`). Point it at a proxied relay API to reach a remote relay directly. | +| `ADOM_TARGET` | Default `--target` value; the flag wins. | +| `ADOM_WS_PORT` / `ADOM_HTTP_PORT` | `serve` port overrides (defaults 8765 / 8766). | +| `ADOM_AUTH_TOKEN` | `serve` auth token. | +| `ADOM_JANITOR=0` | Disable the opportunistic cache sweep. | +| `ADOM_SHOTS_DIR` | Override the screenshot cache directory. |
skills/adom-bridge-demo-kit/SKILL.md+4−1@@ -1,6 +1,9 @@ --- name: adom-bridge-demo-kit -description: Run a guided on-screen demo or tour on the user's Windows desktop through Adom Bridge - the desktop_demo_panel transport (play/pause/next/back the USER controls), narration captions, and the branded ghost cursor, wired together into one pacing loop. Use when asked to give a demo, run a tour, walk the user through an app, or build a scripted show (altium_demo, kicad tour, fusion demo, or your own). +description: >- + Run a guided on-screen demo or tour on the user's Windows desktop: the desktop_demo_panel + the user controls, narration captions and the branded ghost cursor. Use when asked to give a + demo, run a tour, walk the user through an app, or build a scripted show. --- # The Adom Bridge demo kit: give the user a show they control
skills/adom-bridge-direct-api/SKILL.md+4−1@@ -1,6 +1,9 @@ --- name: adom-bridge-direct-api -description: Direct HTTP API on the adom-bridge GUI. Use when authoring a sibling Tauri app (Hydrogen, future Adom-family apps) on the same Windows machine that needs to send commands into adom-bridge without spawning the CLI binary or going through the WebSocket relay. Local-only (loopback bind). +description: >- + Direct loopback HTTP API on the Adom Bridge app, for a sibling app on the same Windows PC + (e.g. Hydrogen) that sends commands into ab without the CLI or the WebSocket relay. +disable-model-invocation: true --- # Direct HTTP API — discover via `~/.adom/direct-api-port`
skills/adom-bridge-discovery/SKILL.md+93−87@@ -1,87 +1,93 @@-name: adom-bridge-discovery-user-invocable: false-description: "Reaches the user's Windows/Mac laptop desktop from this container via adom-bridge — run commands & scripts on the laptop, list/kill processes, restart apps (e.g. Explorer), desktop/app-window screenshots, send/pull files, notifications, list/focus/flash windows, RDP tunnels. Also launches and drives KiCad (schematics/boards/symbols/footprints, DRC, library install, 3D viewer, automation) and Fusion 360 (.f3d/.f3z, import/export STEP/GLB/.lbr, BOM/parameters, Fusion API). Also covers Puppeteer/pup real-Chrome automation (pup_screenshot, pup_eval, pup_navigate, multi-session profiles, ralph-loop), WSL distro control + golden-image bakes (wsl_exec, wsl_exec_async, wsl_import/export/unregister), the relay server (adom-bridge serve), and connection setup. Trigger words: run command on laptop, run on my pc, restart explorer, restart a program, kill a process, list processes, taskkill, screenshot laptop/desktop/windows/fusion/kicad, list windows, focus window, send file to laptop, pull file from laptop, desktop notification, launch KiCad, open in KiCad, run DRC, launch Fusion, import/export STEP, export GLB/lbr, Fusion API, pup, puppeteer, real Chrome, browser automation, flash taskbar, open URL on desktop, RDP, tunnel, WSL, golden image, adom-bridge, bridge to my pc, windows app, KiCad bridge, Fusion bridge."--# adom-bridge — discovery & install--**This skill is a pointer.** When any trigger fires, confirm `adom-bridge` is installed (install from the wiki if not), make sure the relay is up, then **discover the exact verb at runtime** — do NOT guess from memory and do NOT rely on `--help`.--## 1 — Check it's installed + the relay is up--```bash-adom-bridge --version # path + version, e.g. adom-bridge 1.8.163 (<sha>, built …)-adom-bridge ping # {echo:"pong", status:"connected"} == the laptop is reachable-```--If `adom-bridge` isn't found → **§4 install**. If `ping` doesn't say `connected`, the relay isn't serving — start it: `adom-bridge serve --supervise` (v1.9.168+ — detaches + self-heals so it survives your session; a plain `serve &` dies when the session is torn down and leaves every connected desktop in an HTTP 500 reconnect loop). Then re-`ping`.--### Connecting a desktop for the FIRST time — run `setup_desktop`, don't hand-build JSON--If no desktop is connected yet (`adom-bridge targets` shows none) and you need to set one up, **run `adom-bridge setup_desktop`** (aliases: `connect`, `setup`, `connect_info`). It auto-detects this container's public relay URL and hands you everything. **Do NOT assemble the connection JSON yourself** — guessing the URL/fields is the #1 setup failure (wiki issue #8).--**PREFERRED — zero paste (the modern path):** `setup_desktop` returns a ready-to-run **`register_command`**. Run it — it's `adom-bridge register_container` with the name + URL filled in, and it writes this connection into the **user's Adom account** (their cloud profile). Then the user just **installs Adom Bridge and signs in with their Adom account**, and the connection appears in their list and auto-connects — no JSON paste at all. (`register_container` needs an Adom cloud container — it uses the mounted account token; it returns `ok:false` on a non-Adom box.)--**FALLBACK — paste-JSON:** if `register_container` returns `ok:false` (not an Adom cloud container, or the user won't sign in), have the user paste the `server_config` JSON that `setup_desktop` returned into ab's Connections → "Paste server JSON to add" box.--Either way, start the relay here so the desktop has something to dial: `adom-bridge serve --supervise`. Verify with `adom-bridge targets`.--## 2 — ⭐ DISCOVER THE VERB: `adom-bridge commands` (NOT `--help`)--This is the step that's most often skipped, and skipping it is why calls get hand-rolled with `shell_execute` and fumble. **`adom-bridge --help` only lists `serve` / `install`** — every real capability is a *dynamic verb* forwarded to the laptop, and the authoritative, machine-readable catalog (200+ verbs, each with args + return shape + a `_hint`) is:--```bash-adom-bridge commands # full verb catalog (categories → verbs → args/returns)-adom-bridge status # laptop state (installed apps, WSL health, …) — also surfaces the catalog-```--**Always check `commands` before reaching for `shell_execute`.** There is almost certainly a structured verb that takes typed args, so you never hand-quote a command through JSON → shell. A few you'll want often:--| Want to… | Use (structured — no quoting) | NOT |-|---|---|---|-| list processes | `process_list {"nameContains":"explorer"}` | `shell_execute "tasklist \| findstr …"` |-| kill a process | `process_kill {"name":"explorer.exe"}` (or `{"pid":N}`) | `shell_execute "taskkill /f /im …"` |-| start an app | `launch_process {"path":"explorer.exe"}` / `launch {…}` | `shell_execute "start …"` |-| run a multi-line / quoted script | `run_script {"interpreter":"powershell\|cmd\|bash","scriptB64":"<base64>"}` | `shell_execute` with nested quotes |-| run inside a WSL distro | `wsl_exec {"distro","user","scriptB64"}`; multi-minute → `wsl_exec_async` + poll `wsl_job_status` | — |-| screenshots / windows | `desktop_list_windows`, `desktop_screenshot_window {"hwnd":N}`, `desktop_find_window`, `desktop_bring_to_front` | — |-| move files | `send_files` (→ laptop), `pull_file` (← laptop) | — |--> **Example — "restart Explorer because the taskbar icons are gone":** `process_kill {"name":"explorer.exe"}` → `launch_process {"path":"explorer.exe"}` → `process_list {"nameContains":"explorer"}` to confirm. No `taskkill`/`tasklist` quoting. (`shell_execute` is the escape hatch for things with no verb yet — `cmd /C`; reserve it for that.)--**Reading any result:** every verb returns a uniform **`status`** (`ok` | `timeout` | `busy` | `error`) + **`durationMs`**, alongside `exitCode`/`stdout`/`stderr` for exec verbs. So an empty `stdout` with `status:ok` is a real empty-output success — NOT a timeout. `timeout`/`busy` carry a recovery `_hint`. Multi-ab relay: if you get `status:error` / `ambiguous_target`, pass `--target <name>` (list them with `adom-bridge targets`).--## 3 — Multi-distro / long ops--For golden-image work and any multi-minute command, use the **detached job** verbs (return a `jobId` instantly, then poll `wsl_job_status`) instead of a sync call that would time out: `wsl_import` / `wsl_export` / `wsl_unregister` / `wsl_exec_async`, and `launch_process` for a general long-running process. `wsl_recover` reaps a wedged WSL. See `adom-bridge commands` for exact args.--## 4 — Install (if missing or stale)--The canonical install is the adompkg (bundles the Linux CLI **and** the skill set, which symlink into `~/.claude/skills/`):--```bash-adompkg install adom/adom-bridge # → ~/.local/bin/adom-bridge + the adom-bridge-* skills-adom-bridge --version # confirm-```--`adom-bridge status` reports the latest published version; if your local CLI is older, re-run `adompkg install adom/adom-bridge` to refresh the binary **and** the skills atomically. (Wiki page: `https://wiki.adom.inc/adom/adom-bridge`.) **If you hand-installed only the binary, you won't have the skill set — re-run the adompkg install so the discovery/main/pup/kicad/fusion/direct-api/installer skills land too.**--## 5 — What you get once installed--`adompkg install` lays down sibling skills that document the features in depth — they appear in future skill searches:--| Skill | Covers |-|---|---|-| `adom-bridge` (main) | the full verb reference + shell/process/file/window/WSL/tunnel patterns, status+durationMs result shape |-| `adom-bridge-pup` | Puppeteer real-Chrome automation (pup_open_window/navigate/screenshot/eval, multi-session profiles) |-| `adom-bridge-kicad` | KiCad bridge (open schematics/boards, DRC, library install, 3D viewer, automation) |-| `adom-bridge-fusion` | Fusion 360 bridge (.f3d/.f3z, STEP/GLB/.lbr import-export, BOM/params, Fusion API) |-| `adom-bridge-direct-api` | the loopback HTTP API for sibling apps (Hydrogen) |-| `adom-bridge-installer` | invoking the Windows installer (silent/NOLAUNCH) |--But you don't need to read those to act — **`adom-bridge commands` is always the live source of truth** for what verbs exist right now.--## 6 — Why this skill exists--adom-bridge is its own repo with its own release cadence; the deep feature docs ship with the binary (so a binary release doesn't force a gallia release). This skill is the stable, always-present entry point — its one job is to get you to `adom-bridge commands`, the runtime catalog, instead of guessing.+--- +name: adom-bridge-discovery +user-invocable: false +description: >- + Reach the user's Windows or Mac desktop via adom-bridge: shell, screenshots, windows, files, + notifications, KiCad, Fusion 360, pup (real Chrome), WSL, RDP. Use when asked to run + something on my PC or laptop, screenshot my desktop, send a file to my laptop, open in KiCad + or Fusion, bridge to my pc. +--- + +# adom-bridge — discovery & install + +**This skill is a pointer.** When any trigger fires, confirm `adom-bridge` is installed (install from the wiki if not), make sure the relay is up, then **discover the exact verb at runtime** — do NOT guess from memory and do NOT rely on `--help`. + +## 1 — Check it's installed + the relay is up + +```bash +adom-bridge --version # path + version, e.g. adom-bridge 1.8.163 (<sha>, built …) +adom-bridge ping # {echo:"pong", status:"connected"} == the laptop is reachable +``` + +If `adom-bridge` isn't found → **§4 install**. If `ping` doesn't say `connected`, the relay isn't serving — start it: `adom-bridge serve --supervise` (v1.9.168+ — detaches + self-heals so it survives your session; a plain `serve &` dies when the session is torn down and leaves every connected desktop in an HTTP 500 reconnect loop). Then re-`ping`. + +### Connecting a desktop for the FIRST time — run `setup_desktop`, don't hand-build JSON + +If no desktop is connected yet (`adom-bridge targets` shows none) and you need to set one up, **run `adom-bridge setup_desktop`** (aliases: `connect`, `setup`, `connect_info`). It auto-detects this container's public relay URL and hands you everything. **Do NOT assemble the connection JSON yourself** — guessing the URL/fields is the #1 setup failure (wiki issue #8). + +**PREFERRED — zero paste (the modern path):** `setup_desktop` returns a ready-to-run **`register_command`**. Run it — it's `adom-bridge register_container` with the name + URL filled in, and it writes this connection into the **user's Adom account** (their cloud profile). Then the user just **installs Adom Bridge and signs in with their Adom account**, and the connection appears in their list and auto-connects — no JSON paste at all. (`register_container` needs an Adom cloud container — it uses the mounted account token; it returns `ok:false` on a non-Adom box.) + +**FALLBACK — paste-JSON:** if `register_container` returns `ok:false` (not an Adom cloud container, or the user won't sign in), have the user paste the `server_config` JSON that `setup_desktop` returned into ab's Connections → "Paste server JSON to add" box. + +Either way, start the relay here so the desktop has something to dial: `adom-bridge serve --supervise`. Verify with `adom-bridge targets`. + +## 2 — ⭐ DISCOVER THE VERB: `adom-bridge commands` (NOT `--help`) + +This is the step that's most often skipped, and skipping it is why calls get hand-rolled with `shell_execute` and fumble. **`adom-bridge --help` only lists `serve` / `install`** — every real capability is a *dynamic verb* forwarded to the laptop, and the authoritative, machine-readable catalog (200+ verbs, each with args + return shape + a `_hint`) is: + +```bash +adom-bridge commands # full verb catalog (categories → verbs → args/returns) +adom-bridge status # laptop state (installed apps, WSL health, …) — also surfaces the catalog +``` + +**Always check `commands` before reaching for `shell_execute`.** There is almost certainly a structured verb that takes typed args, so you never hand-quote a command through JSON → shell. A few you'll want often: + +| Want to… | Use (structured — no quoting) | NOT | +|---|---|---| +| list processes | `process_list {"nameContains":"explorer"}` | `shell_execute "tasklist \| findstr …"` | +| kill a process | `process_kill {"name":"explorer.exe"}` (or `{"pid":N}`) | `shell_execute "taskkill /f /im …"` | +| start an app | `launch_process {"path":"explorer.exe"}` / `launch {…}` | `shell_execute "start …"` | +| run a multi-line / quoted script | `run_script {"interpreter":"powershell\|cmd\|bash","scriptB64":"<base64>"}` | `shell_execute` with nested quotes | +| run inside a WSL distro | `wsl_exec {"distro","user","scriptB64"}`; multi-minute → `wsl_exec_async` + poll `wsl_job_status` | — | +| screenshots / windows | `desktop_list_windows`, `desktop_screenshot_window {"hwnd":N}`, `desktop_find_window`, `desktop_bring_to_front` | — | +| move files | `send_files` (→ laptop), `pull_file` (← laptop) | — | + +> **Example — "restart Explorer because the taskbar icons are gone":** `process_kill {"name":"explorer.exe"}` → `launch_process {"path":"explorer.exe"}` → `process_list {"nameContains":"explorer"}` to confirm. No `taskkill`/`tasklist` quoting. (`shell_execute` is the escape hatch for things with no verb yet — `cmd /C`; reserve it for that.) + +**Reading any result:** every verb returns a uniform **`status`** (`ok` | `timeout` | `busy` | `error`) + **`durationMs`**, alongside `exitCode`/`stdout`/`stderr` for exec verbs. So an empty `stdout` with `status:ok` is a real empty-output success — NOT a timeout. `timeout`/`busy` carry a recovery `_hint`. Multi-ab relay: if you get `status:error` / `ambiguous_target`, pass `--target <name>` (list them with `adom-bridge targets`). + +## 3 — Multi-distro / long ops + +For golden-image work and any multi-minute command, use the **detached job** verbs (return a `jobId` instantly, then poll `wsl_job_status`) instead of a sync call that would time out: `wsl_import` / `wsl_export` / `wsl_unregister` / `wsl_exec_async`, and `launch_process` for a general long-running process. `wsl_recover` reaps a wedged WSL. See `adom-bridge commands` for exact args. + +## 4 — Install (if missing or stale) + +The canonical install is the wiki package (it bundles the Linux CLI **and** the skill set, which land in `~/.claude/skills/`): + +```bash +adom-wiki pkg install adom/adom-bridge # → ~/.local/bin/adom-bridge + the adom-bridge-* skills +adom-bridge --version # confirm +``` + +`adom-bridge status` reports the latest published version; if your local CLI is older, run `adom-wiki pkg update adom/adom-bridge` to refresh the binary **and** the skills atomically. (Wiki page: `https://wiki.adom.inc/adom/adom-bridge`.) **If you hand-installed only the binary, you won't have the skill set — run `adom-wiki pkg install adom/adom-bridge` so the skill set lands too.** + +## 5 — What you get once installed + +The package lays down sibling skills that document the features in depth; each bridge ships its own skills from its own wiki page: + +| Skill | Covers | +|---|---| +| `adom-bridge` (main) | the full verb reference + shell/process/file/window/WSL/tunnel patterns, status+durationMs result shape | +| `adom-bridge-verbs` | every ab-core verb, by category | +| `adom-bridge-bridges-guide` | installing, updating and managing bridges | +| pup (`adom-wiki pkg install adom/pup-bridge`) | Puppeteer real-Chrome automation (pup_open_window/navigate/screenshot/eval, multi-session profiles) | +| KiCad (`adom-wiki pkg install adom/kicad-bridge`) | KiCad bridge (open schematics/boards, DRC, library install, 3D viewer, automation) | +| Fusion 360 (`adom-wiki pkg install adom/fusion-bridge`) | Fusion 360 bridge (.f3d/.f3z, STEP/GLB/.lbr import-export, BOM/params, Fusion API) | + +`adom-bridge sync_skills` installs the skills for every bridge the connected desktop has. + +But you don't need to read those to act — **`adom-bridge commands` is always the live source of truth** for what verbs exist right now. + +## 6 — Why this skill exists + +adom-bridge has its own release cadence, and the deep feature docs ship with the binary. This skill's one job is to get you to `adom-bridge commands`, the runtime catalog, instead of guessing.
skills/adom-bridge-embedded/SKILL.md+5−1@@ -1,6 +1,10 @@ --- name: adom-bridge-embedded -description: Adom Bridge's EMBEDDED mode under Hydrogen — what it means when Hydrogen owns ab, the lifecycle contract (ab stays up; exit when Hydrogen exits is opt-in flag-only), and embedded permissions in the CURRENT (v1.9.191) model where AB OWNS APPROVALS and MIRRORS each request to Hydrogen (Hydrogen posts decisions to /permission/resolve, reads /permission/state; the old auto_approve/forward-to-hd contract is gone), inspecting Hydrogen with hd_status and the hd_ verb family, and the read-only Hydrogen-managed relay entry. Use when reasoning about embedded vs standalone behavior, why ab survived (or should survive) an Hydrogen crash, why a gated relay command shows an ab dialog (and mirrors to Hydrogen), or how to drive/inspect Hydrogen. Note: Hydrogen's own setup commands over the loopback direct API are trusted-by-transport and never prompt. +description: >- + Adom Bridge embedded mode under Hydrogen: the lifecycle contract, approvals (ab owns the + prompt and mirrors it to Hydrogen), and inspecting Hydrogen with hd_status and the hd_* + verbs. Use when asked embedded vs standalone, why ab outlived a Hydrogen crash, or why an ab + approval dialog appeared. --- # Adom Bridge embedded mode (under Hydrogen)
skills/adom-bridge-fonts/SKILL.md+72−68@@ -1,68 +1,72 @@-name: adom-bridge-fonts-description: Install and manage Windows fonts on the user's machine through adom-bridge's font verbs — list installed families, install a single .ttf/.otf per-user (no admin), uninstall one, or idempotently ensure the Adom brand fonts (Satoshi, JetBrains Mono, Familjen Grotesk) are installed so chat/prose renders in Satoshi instead of falling back to Segoe UI. Use when a font needs to be installed on the PC, when brand fonts are missing, when Hydrogen's setup needs font_ensure_brand, or when the user asks to add/remove a Windows font. Trigger words: install font, font_ensure_brand, font_install, font_list, font_uninstall, Satoshi, JetBrains Mono, Familjen Grotesk, brand fonts, add a font to Windows, missing font, per-user font, ensure brand fonts.--# Windows font management — `font_*` verbs--Adom Bridge installs and manages Windows fonts **per-user, with no admin and no UAC prompt**. Four verbs:--| verb | what | gated? |-|---|---|---|-| `font_list {nameContains?}` | list installed families (per-user + machine) with source | no (read-only) |-| `font_install {source\|path\|url, sha256?}` | install one `.ttf`/`.otf` | yes (Tier-2) |-| `font_uninstall {name\|family\|file}` | remove a per-user font | yes (Tier-2) |-| `font_ensure_brand {}` | idempotently ensure the three Adom brand fonts | yes (Tier-2) |--The three write verbs are approval-gated for relay (cloud-AI) callers like any machine-changing verb — pass a `reason` (e.g. `"Installing the Adom brand fonts so chat renders in Satoshi"`). `font_list` is ungated.--## Why this exists--The Adom prose font is **Satoshi**. A webview that isn't served the workbench webfont (e.g. an isolated Claude chat view) falls back to Segoe UI unless Satoshi is installed **into Windows** on that machine. `font_ensure_brand` kills the manual "install the fonts" deployment step — Hydrogen's setup calls it once per run.--## `font_ensure_brand` — the main verb--Idempotent. Ensures **Satoshi**, **JetBrains Mono**, and **Familjen Grotesk** are installed, fetching only what's missing from official sources. Stable machine-readable contract:--```json-{- "ok": true, // == failed.isEmpty()- "installed": [ { "family": "JetBrains Mono", "face": "JetBrains Mono Regular (TrueType)", "file": "..." } ],- "present": [ { "family": "Satoshi" }, { "family": "Familjen Grotesk" } ],- "failed": [] // { family, face?, errorCode, error }-}-```--- **First run** populates `installed`; a **second run** reports everything in `present` and fetches nothing.-- **Callers gate on `failed`**: non-empty = warn, not fatal (other families may have succeeded). That's exactly what Hydrogen's setup cascade does.--Sources (fetched live at call time, never bundled): **Satoshi** from Fontshare (its EULA forbids re-hosting the files, so they are only ever fetched from Fontshare's own servers); **JetBrains Mono** and **Familjen Grotesk** from their official OFL 1.1 releases, with `OFL.txt` installed beside the faces.--```bash-adom-bridge font_ensure_brand '{"reason":"Installing the Adom brand fonts so chat renders in Satoshi"}'-```--## Installing / removing a single font--```bash-# a local file or an http(s) URL to a .ttf/.otf (aliases: path, url). Optional sha256 integrity check.-adom-bridge font_install '{"source":"https://example.com/MyFont-Regular.ttf","sha256":"<hex>","reason":"install MyFont"}'-# remove by registry name, family (removes all its faces), or installed filename-adom-bridge font_uninstall '{"family":"MyFont","reason":"remove MyFont"}'-adom-bridge font_list '{"nameContains":"satoshi"}'-```--**TTF/OTF only.** A `.woff2`/`.woff` source is refused (`errorCode:"woff2_not_installable"`) — those are the *web* delivery format, not an installable desktop face. Download the family's TTF/OTF instead.--## How the install works (per-user, no admin)--1. copy the file to `%LOCALAPPDATA%\Microsoft\Windows\Fonts`-2. register it under `HKCU\Software\Microsoft\Windows NT\CurrentVersion\Fonts` (value = the full file path)-3. `AddFontResourceW` so the current session picks it up-4. broadcast `WM_FONTCHANGE` so already-running apps notice without a reboot--It only ever touches the **per-user** store (never HKLM / machine fonts). `font_list` can *read* machine fonts (`scope:"machine"`) but they're not installable/uninstallable without admin. Installs are idempotent — identical bytes already present + already registered report `alreadyPresent`; a lingering file whose registry value went missing is re-registered.--## Platform--Windows only. On macOS/Linux these verbs return `errorCode:"windows_only"`.+--- +name: adom-bridge-fonts +description: >- + Install and manage Windows fonts on the user's PC through adom-bridge's font verbs, + including the Adom brand fonts (Satoshi, JetBrains Mono, Familjen Grotesk). Use when asked + to install a font, add or remove a Windows font, when brand fonts are missing, or for + font_ensure_brand. +--- + +# Windows font management — `font_*` verbs + +Adom Bridge installs and manages Windows fonts **per-user, with no admin and no UAC prompt**. Four verbs: + +| verb | what | gated? | +|---|---|---| +| `font_list {nameContains?}` | list installed families (per-user + machine) with source | no (read-only) | +| `font_install {source\|path\|url, sha256?}` | install one `.ttf`/`.otf` | yes (Tier-2) | +| `font_uninstall {name\|family\|file}` | remove a per-user font | yes (Tier-2) | +| `font_ensure_brand {}` | idempotently ensure the three Adom brand fonts | yes (Tier-2) | + +The three write verbs are approval-gated for relay (cloud-AI) callers like any machine-changing verb — pass a `reason` (e.g. `"Installing the Adom brand fonts so chat renders in Satoshi"`). `font_list` is ungated. + +## Why this exists + +The Adom prose font is **Satoshi**. A webview that isn't served the workbench webfont (e.g. an isolated Claude chat view) falls back to Segoe UI unless Satoshi is installed **into Windows** on that machine. `font_ensure_brand` kills the manual "install the fonts" deployment step — Hydrogen's setup calls it once per run. + +## `font_ensure_brand` — the main verb + +Idempotent. Ensures **Satoshi**, **JetBrains Mono**, and **Familjen Grotesk** are installed, fetching only what's missing from official sources. Stable machine-readable contract: + +```json +{ + "ok": true, // == failed.isEmpty() + "installed": [ { "family": "JetBrains Mono", "face": "JetBrains Mono Regular (TrueType)", "file": "..." } ], + "present": [ { "family": "Satoshi" }, { "family": "Familjen Grotesk" } ], + "failed": [] // { family, face?, errorCode, error } +} +``` + +- **First run** populates `installed`; a **second run** reports everything in `present` and fetches nothing. +- **Callers gate on `failed`**: non-empty = warn, not fatal (other families may have succeeded). That's exactly what Hydrogen's setup cascade does. + +Sources (fetched live at call time, never bundled): **Satoshi** from Fontshare (its EULA forbids re-hosting the files, so they are only ever fetched from Fontshare's own servers); **JetBrains Mono** and **Familjen Grotesk** from their official OFL 1.1 releases, with `OFL.txt` installed beside the faces. + +```bash +adom-bridge font_ensure_brand '{"reason":"Installing the Adom brand fonts so chat renders in Satoshi"}' +``` + +## Installing / removing a single font + +```bash +# a local file or an http(s) URL to a .ttf/.otf (aliases: path, url). Optional sha256 integrity check. +adom-bridge font_install '{"source":"https://example.com/MyFont-Regular.ttf","sha256":"<hex>","reason":"install MyFont"}' +# remove by registry name, family (removes all its faces), or installed filename +adom-bridge font_uninstall '{"family":"MyFont","reason":"remove MyFont"}' +adom-bridge font_list '{"nameContains":"satoshi"}' +``` + +**TTF/OTF only.** A `.woff2`/`.woff` source is refused (`errorCode:"woff2_not_installable"`) — those are the *web* delivery format, not an installable desktop face. Download the family's TTF/OTF instead. + +## How the install works (per-user, no admin) + +1. copy the file to `%LOCALAPPDATA%\Microsoft\Windows\Fonts` +2. register it under `HKCU\Software\Microsoft\Windows NT\CurrentVersion\Fonts` (value = the full file path) +3. `AddFontResourceW` so the current session picks it up +4. broadcast `WM_FONTCHANGE` so already-running apps notice without a reboot + +It only ever touches the **per-user** store (never HKLM / machine fonts). `font_list` can *read* machine fonts (`scope:"machine"`) but they're not installable/uninstallable without admin. Installs are idempotent — identical bytes already present + already registered report `alreadyPresent`; a lingering file whose registry value went missing is re-registered. + +## Platform + +Windows only. On macOS/Linux these verbs return `errorCode:"windows_only"`.
skills/adom-bridge-installer/SKILL.md+8−3@@ -1,6 +1,11 @@ --- name: adom-bridge-installer -description: How to invoke the Adom Bridge NSIS installer from a parent installer (especially Hydrogen's setup steps) — silent install, /NOLAUNCH switch for Hydrogen-managed bundling, live progress via the structured log file at %TEMP%\adom-bridge-install.log. Use when bundling ab inside another Windows installer, writing an install supervisor / setup-step UI, detecting ab's installed version, or upgrading an existing ab install programmatically. +description: >- + Invoke the Adom Bridge NSIS installer from a parent installer (e.g. Hydrogen's setup): + silent install, /NOLAUNCH, live progress from %TEMP%\adom-bridge-install.log. Use when + bundling ab inside another Windows installer, detecting its installed version, or upgrading + it programmatically. +disable-model-invocation: true --- # Adom Bridge installer integration @@ -306,7 +311,7 @@ The Adom Bridge wiki page hosts TWO distinct types of artifacts per release: 1. **Full ab GUI installer** — the actual desktop app (Tauri window, bundled bridges, relauncher). One per OS / architecture. 2. **CLI companion binary** — a separate executable that talks to ab over - the relay. Used by Hydrogen's WSL2 container, gallia builds, sibling-app + the relay. Used by Hydrogen's WSL2 container, cloud containers, sibling-app installers, cron jobs. Does NOT contain ab itself. Both ship together at the same version (single `python scripts/bump-version.py @@ -370,7 +375,7 @@ Pre-v1.8.113 readers ignore `.cli` entirely (additive schema; no reads `.windows / .macos / .linux` for its GUI installer URL — it doesn't touch `.cli`. -For Hydrogen / gallia / Docker-side fetchers, prefer the typed lookup: +For Hydrogen / container-side fetchers, prefer the typed lookup: ```bash # Best: read the URL out of the manifest (so the filename can evolve)
skills/adom-bridge-linux/SKILL.md+147−143@@ -1,143 +1,147 @@-name: adom-bridge-linux-description: Adom Bridge on Linux (the AppImage, 2.1.175+) - installing and launching exactly one copy, self-update, the bundled EGL, what works on Linux and what is still Windows-only, and how to run it inside a Grok Bot agent computer, which is PAUSED while its agent is idle (wake it with grok_wake before you drive it). Read this before installing, updating or debugging Adom Bridge on a Linux machine, or when a Linux peer on the mesh answers target_not_found / ping unreachable.--# Adom Bridge on Linux--The Linux build is the same app as on Windows, packaged as an AppImage-(`adom-bridge_<version>_linux_x86_64.AppImage`). It signs in to the same Adom-account, joins the same mesh, and answers the same verbs from the relay, the mesh-and the local API (`127.0.0.1:24680`). It is a **private preview**: the AppImage-lives on the insiders tier, not the public page.--## Install and launch: one copy, always--- Keep the AppImage in a folder of its own, for example- `~/.local/share/adom-bridge-app/`, with a stable symlink- `adom-bridge-app.AppImage -> adom-bridge_<version>_linux_x86_64.AppImage`.- Self-update repoints that symlink.-- Start it through a launcher (`run.sh` in the same folder) that **takes a lock and- checks for a running copy first**. Linux has no single-instance lock without a- D-Bus session bus, and two copies share one identity: they knock each other off- the relay every few seconds. A running copy is any process whose executable is- `*appimage_extracted*/usr/bin/adom-bridge` (or the AppImage file itself while it- extracts). `pgrep -f usr/bin/adom-bridge` does NOT match: the command line is just- `adom-bridge`. Read `/proc/<pid>/exe` instead.-- In a container or sandbox without FUSE, run it with `--appimage-extract-and-run`.-- Set `DISPLAY` to the X display it should use.-- `adom-bridge-app --version` prints the version and exits; it never starts a copy.--A minimal `run.sh`:--```bash-#!/bin/bash-D="$HOME/.local/share/adom-bridge-app"-exec 9>"$D/.launch.lock"-flock -w 30 9 || exit 0-for p in /proc/[0-9]*; do- case "$(readlink $p/exe 2>/dev/null)" in- *appimage_extracted*/usr/bin/adom-bridge|*adom-bridge_*_linux_x86_64.AppImage) exit 0;;- esac-done-export DISPLAY=:0-cd "$D"-nohup ./adom-bridge-app.AppImage --appimage-extract-and-run > "$D/app.log" 2>&1 9>&- &-for i in $(seq 1 20); do- for p in /proc/[0-9]*; do- case "$(readlink $p/exe 2>/dev/null)" in *appimage_extracted*/usr/bin/adom-bridge) exit 0;; esac- done- sleep 0.5-done-```--Call it from every place that may start the app (login hook, an "ensure" script,-the self-update relauncher): the lock makes simultaneous calls safe.--## Updates--Since 2.1.177 the app updates itself from the insiders manifest's `linux` entry: it-downloads the new AppImage, checks its sha256, writes it beside the old one,-repoints `adom-bridge-app.AppImage`, keeps the previous version for a rollback,-and starts one new copy (through `run.sh` when the folder has one) after the old-process has exited. `update_mode`:--- `auto`: checks every 4 hours and on launch, installs by itself. Right for an- unattended box.-- `prompt` (default): downloads, then installs on `apply_update`- (`adom-bridge --target <box> apply_update '{"reason":"..."}'`).--Do not swap AppImages by hand while the app runs.--## Graphics (EGL)--WebKit needs a working EGL driver or it aborts at start ("Could not create default-EGL display"). The AppImage carries a complete Mesa EGL stack and switches it on-only when the machine has no EGL of its own (`ADOM_BRIDGE_EGL=bundled|system`-overrides). No `libs/` folder or extra packages are needed.--## What works on Linux--| Works | Not yet (Windows only) |-|---|---|-| shell_execute, run_script, files (send_files, pull_file, read/write) | input verbs (desktop_click, desktop_type, ...) |-| desktop_screenshot_screen, desktop_screenshot_window, desktop_list_windows (X11, 2.1.181+, no tools needed) | UI Automation (desktop_ui_*) |-| bridges with a Linux build (python, node, or `spawn.entrypoints.linux`) | Windows-only bridges (a `.exe` with no Linux entry): listed, never started, verbs answer `unsupported_on_this_os` |-| self-update, mesh, relay, notifications (notify-send) | recording |--Commands a bridge or script runs get the machine's own environment, not the-AppImage's private library paths, so `python3` and `curl` work normally.--Bridges on Linux (2.1.184+): `bridge_install` keeps the zip's file modes, so a-native `spawn.entrypoints.linux` binary is executable (ab also sets the bit before-spawning). Reinstalling a running bridge stops the old process first and says so-(`stoppedPids`, `restarted`); the next verb starts the new build. `bridge_list`-shows live pids.--## In a Grok Bot agent computer--A Grok Bot agent has its own Linux computer (Debian, user `box`, an X display such-as `:3`). Adom Bridge runs there like anywhere else, with one big difference:--**The computer is paused while the Grok agent is idle.** A few minutes after the-last agent run ends, the whole machine stops, and it resumes when the next run-starts. While it is paused nothing can reach it: relay and mesh both answer-`target_not_found`, and `ping --target <box>` (CLI 2.1.182+) answers `unreachable`.-That is not a network fault. When the computer resumes, Adom Bridge (2.1.180+) is-back on the relay within about a second.--Measured 2026-10-15: on resume every timer in the app fired at once, and the wall-clock had moved 466 s while the monotonic clock moved 31 s. Grok has no setting-that keeps the computer running.--**To use the box, wake it first.** grok-bridge runs on the machine that has the-Grok Bot app (for example the user's laptop), so target that machine:--```-adom-bridge --ai-thread "<your thread>" --target <laptop> grok_wake '{"minutes":20,"reason":"KiCad work on the Grok computer"}'-adom-bridge --ai-thread "<your thread>" --target <grok box> ping # wait for "connected"-```--- `grok_wake` starts an agent run that only sleeps, which keeps the computer up- for that many minutes (30 at most per call). Wake it just before a stretch of- work, and again for the next stretch. Never wake it in a loop: each wake is an- agent run and costs Grok usage.-- A job that must run for hours unattended is not possible today; repeated wakes- are the workaround.-- If the Grok webhook is refused (an automation rate limit), `grok_wake` falls back- to typing into the Grok Bot chat (`grok_chat_send`).--Other things to know on a Grok computer:--- **Use `auto` update mode.** Nobody clicks Install there.-- **One copy only.** The agent's shell may run the launcher from `~/.bashrc` at any- time, including the moment a self-update restarts the app. Use the locked- `run.sh` above, or two copies will fight.-- **The egress proxy is slow for large transfers** (about 2 MB/s measured).- Relays 2.1.180+ keep a box connected while it is busy taking a file transfer.-- **Do not run `adom-bridge serve` on the box.** The app connects out to the user's- relays and the mesh; a local relay is not needed.-- **KiCad** runs there as a Flatpak (`org.kicad.KiCad`) on the same X display. A- KiCad bridge works only with a Linux build of that bridge.-- **Checking what the box shows:** `desktop_screenshot_screen` and- `desktop_list_windows` work (2.1.181+).+--- +name: adom-bridge-linux +description: >- + For Adom Bridge running on a Linux machine (the AppImage), not the adom-bridge CLI in a + container: install one copy, self-update, what is still Windows-only, Grok Bot computers + (wake with grok_wake). Use when installing, updating or debugging ab on Linux, or a Linux + mesh peer is unreachable. +--- + +# Adom Bridge on Linux + +The Linux build is the same app as on Windows, packaged as an AppImage +(`adom-bridge_<version>_linux_x86_64.AppImage`). It signs in to the same Adom +account, joins the same mesh, and answers the same verbs from the relay, the mesh +and the local API (`127.0.0.1:24680`). It is a **private preview**: the AppImage +lives on the insiders tier, not the public page. + +## Install and launch: one copy, always + +- Keep the AppImage in a folder of its own, for example + `~/.local/share/adom-bridge-app/`, with a stable symlink + `adom-bridge-app.AppImage -> adom-bridge_<version>_linux_x86_64.AppImage`. + Self-update repoints that symlink. +- Start it through a launcher (`run.sh` in the same folder) that **takes a lock and + checks for a running copy first**. Linux has no single-instance lock without a + D-Bus session bus, and two copies share one identity: they knock each other off + the relay every few seconds. A running copy is any process whose executable is + `*appimage_extracted*/usr/bin/adom-bridge` (or the AppImage file itself while it + extracts). `pgrep -f usr/bin/adom-bridge` does NOT match: the command line is just + `adom-bridge`. Read `/proc/<pid>/exe` instead. +- In a container or sandbox without FUSE, run it with `--appimage-extract-and-run`. +- Set `DISPLAY` to the X display it should use. +- `adom-bridge-app --version` prints the version and exits; it never starts a copy. + +A minimal `run.sh`: + +```bash +#!/bin/bash +D="$HOME/.local/share/adom-bridge-app" +exec 9>"$D/.launch.lock" +flock -w 30 9 || exit 0 +for p in /proc/[0-9]*; do + case "$(readlink $p/exe 2>/dev/null)" in + *appimage_extracted*/usr/bin/adom-bridge|*adom-bridge_*_linux_x86_64.AppImage) exit 0;; + esac +done +export DISPLAY=:0 +cd "$D" +nohup ./adom-bridge-app.AppImage --appimage-extract-and-run > "$D/app.log" 2>&1 9>&- & +for i in $(seq 1 20); do + for p in /proc/[0-9]*; do + case "$(readlink $p/exe 2>/dev/null)" in *appimage_extracted*/usr/bin/adom-bridge) exit 0;; esac + done + sleep 0.5 +done +``` + +Call it from every place that may start the app (login hook, an "ensure" script, +the self-update relauncher): the lock makes simultaneous calls safe. + +## Updates + +Since 2.1.177 the app updates itself from the insiders manifest's `linux` entry: it +downloads the new AppImage, checks its sha256, writes it beside the old one, +repoints `adom-bridge-app.AppImage`, keeps the previous version for a rollback, +and starts one new copy (through `run.sh` when the folder has one) after the old +process has exited. `update_mode`: + +- `auto`: checks every 4 hours and on launch, installs by itself. Right for an + unattended box. +- `prompt` (default): downloads, then installs on `apply_update` + (`adom-bridge --target <box> apply_update '{"reason":"..."}'`). + +Do not swap AppImages by hand while the app runs. + +## Graphics (EGL) + +WebKit needs a working EGL driver or it aborts at start ("Could not create default +EGL display"). The AppImage carries a complete Mesa EGL stack and switches it on +only when the machine has no EGL of its own (`ADOM_BRIDGE_EGL=bundled|system` +overrides). No `libs/` folder or extra packages are needed. + +## What works on Linux + +| Works | Not yet (Windows only) | +|---|---| +| shell_execute, run_script, files (send_files, pull_file, read/write) | input verbs (desktop_click, desktop_type, ...) | +| desktop_screenshot_screen, desktop_screenshot_window, desktop_list_windows (X11, 2.1.181+, no tools needed) | UI Automation (desktop_ui_*) | +| bridges with a Linux build (python, node, or `spawn.entrypoints.linux`) | Windows-only bridges (a `.exe` with no Linux entry): listed, never started, verbs answer `unsupported_on_this_os` | +| self-update, mesh, relay, notifications (notify-send) | recording | + +Commands a bridge or script runs get the machine's own environment, not the +AppImage's private library paths, so `python3` and `curl` work normally. + +Bridges on Linux (2.1.184+): `bridge_install` keeps the zip's file modes, so a +native `spawn.entrypoints.linux` binary is executable (ab also sets the bit before +spawning). Reinstalling a running bridge stops the old process first and says so +(`stoppedPids`, `restarted`); the next verb starts the new build. `bridge_list` +shows live pids. + +## In a Grok Bot agent computer + +A Grok Bot agent has its own Linux computer (Debian, user `box`, an X display such +as `:3`). Adom Bridge runs there like anywhere else, with one big difference: + +**The computer is paused while the Grok agent is idle.** A few minutes after the +last agent run ends, the whole machine stops, and it resumes when the next run +starts. While it is paused nothing can reach it: relay and mesh both answer +`target_not_found`, and `ping --target <box>` (CLI 2.1.182+) answers `unreachable`. +That is not a network fault. When the computer resumes, Adom Bridge (2.1.180+) is +back on the relay within about a second. + +Measured 2026-10-15: on resume every timer in the app fired at once, and the wall +clock had moved 466 s while the monotonic clock moved 31 s. Grok has no setting +that keeps the computer running. + +**To use the box, wake it first.** grok-bridge runs on the machine that has the +Grok Bot app (for example the user's laptop), so target that machine: + +``` +adom-bridge --ai-thread "<your thread>" --target <laptop> grok_wake '{"minutes":20,"reason":"KiCad work on the Grok computer"}' +adom-bridge --ai-thread "<your thread>" --target <grok box> ping # wait for "connected" +``` + +- `grok_wake` starts an agent run that only sleeps, which keeps the computer up + for that many minutes (30 at most per call). Wake it just before a stretch of + work, and again for the next stretch. Never wake it in a loop: each wake is an + agent run and costs Grok usage. +- A job that must run for hours unattended is not possible today; repeated wakes + are the workaround. +- If the Grok webhook is refused (an automation rate limit), `grok_wake` falls back + to typing into the Grok Bot chat (`grok_chat_send`). + +Other things to know on a Grok computer: + +- **Use `auto` update mode.** Nobody clicks Install there. +- **One copy only.** The agent's shell may run the launcher from `~/.bashrc` at any + time, including the moment a self-update restarts the app. Use the locked + `run.sh` above, or two copies will fight. +- **The egress proxy is slow for large transfers** (about 2 MB/s measured). + Relays 2.1.180+ keep a box connected while it is busy taking a file transfer. +- **Do not run `adom-bridge serve` on the box.** The app connects out to the user's + relays and the mesh; a local relay is not needed. +- **KiCad** runs there as a Flatpak (`org.kicad.KiCad`) on the same X display. A + KiCad bridge works only with a Linux build of that bridge. +- **Checking what the box shows:** `desktop_screenshot_screen` and + `desktop_list_windows` work (2.1.181+).
skills/adom-bridge-local-ai/SKILL.md+4−1@@ -1,6 +1,9 @@ --- name: adom-bridge-local-ai -description: Drive this Windows PC from a LOCAL AI tool (Claude Desktop, Claude Code, Codex, or any agent running on the same machine) through Adom Bridge's bundled CLI, adom-bridge-cli.exe. No relay, no cloud container, no --target. Use when the user says "Claude Desktop, control my PC", "use adom-bridge from Claude Code on Windows", "local AI, drive my desktop", or when you find adom-bridge-cli.exe on PATH. Covers the bundled CLI, the auto-linked consumer skills, loopback direct-API routing, and a starter verb cookbook. +description: >- + For an AI running natively on Windows (Claude Desktop, Claude Code, Codex), not in an Adom + container: drive this PC through the bundled adom-bridge-cli.exe, with no relay or --target. + Use when a local AI is asked to control my PC, or you find adom-bridge-cli.exe on PATH. --- # Adom Bridge from a local AI (same-PC, no relay)
skills/adom-bridge-mesh/SKILL.md+5−1@@ -1,6 +1,10 @@ --- name: adom-bridge-mesh -description: The Adom Mesh (P1) - how one user's several Adom Bridges find and reach each other directly, and across networks through the Adom-hosted relay. Each ab has an ed25519 node identity and a QUIC endpoint; same-account peers are discovered through the Carbon KV directory and mDNS on the LAN; `--target <name>` tries the mesh first (direct, or via the relay) and falls back to the hc relay. Read this to understand route.via, desktop_mesh_status (relay, relayConnected), desktop_mesh_peers, and what the mesh can and cannot reach yet. +description: >- + The Adom Mesh: how one user's Adom Bridges find and reach each other directly or through the + Adom relay (node identity, peer discovery, route.via). Use when asked about + desktop_mesh_status or desktop_mesh_peers, how --target routes between machines, or why a + peer is unreachable. --- # The Adom Mesh (phase P0)
skills/adom-bridge-multi-machine/SKILL.md+6−13@@ -1,6 +1,10 @@ --- -name: adom-bridge-multi-ad -description: Address and fan out to multiple Adom Bridges on one relay. Use when more than one machine runs Adom Bridge on the same relay and you need to target a specific one (--target <name>) or broadcast to all (--target all) — e.g. a laptop plus an Azure VM. Covers client naming, --target, the targets verb, ambiguous-target handling, and primary_machine (which box is the user's own, so an unqualified request lands there). +name: adom-bridge-multi-machine +description: >- + Address one of several Adom Bridges on a relay (--target <name>) or broadcast to all + (--target all), plus the targets verb and primary_machine. Use when more than one machine + runs ab (e.g. a laptop plus an Azure VM), on ambiguous_target, or asked to run on a specific + machine or on all of them. --- # Multi-ab addressing — name + fan out to many Adom Bridges on one relay @@ -255,14 +259,3 @@ Two ABs against one relay (the real setup: laptop ab + Azure-VM ab → my-contai 5. `--target nope` → `target_not_found` + list, exit 1. 6. Kill+reconnect the VM ab → `targets` still shows exactly 2 (reconnect evicted its own stale entry). 7. Single-ab regression: disconnect VM → no-target `ping` works; local direct path unchanged. -8. Add T-tests to `scripts/test-via-gallia.sh` for `targets`, single-target, the broadcast aggregate - shape, and both error shapes traversing the relay with `_hint` intact. - -## Build / release (when implemented) -- `python scripts/bump-version.py patch` touches `cli/**` AND `src-tauri/**` → **both binaries - rebuild** (build matrix): GUI `cargo build --release` (FAST) → relauncher; relay/CLI on gallia via - `ssh adom-gallia … cargo build --release && sudo install …`. FULL `cargo tauri build` only for the - signed ship. -- Docs in the same ship: `skills/SKILL.md` ("Addressing multiple desktops"), `README.md`, CLI - help-map, `CLAUDE.md` (`--client-name` + multi-ab rule), and the release-ladder Docker Claude - prompt (`--target` usage; drop "last ab wins"; use `targets` for discovery).
skills/adom-bridge-notify/SKILL.md+5−1@@ -1,6 +1,10 @@ --- name: adom-bridge-notify -description: Send native Windows toast notifications from a cloud AI or a bridge via adom-bridge's notify_user verb, and GET THE USER'S ANSWER BACK. Two separate axes: (1) get the reply to you — wait:true blocks and returns their choice, or poll notify_events/notify_response (there is no server-push to a turn-based AI). (2) reveal a window on their screen with `focus` — a local desktop action, incl. focus:{host:true} to reveal Hydrogen when ab is embedded, and focus:{app} for any app. Use to alert the user (build done, export ready), point them at a window that needs them, show a progress bar, or ask for a decision and read it back. Covers every toast type, the exact verb calls, how the AI polls/waits for responses, and what a click actually does. +description: >- + Native Windows toast notifications via adom-bridge notify_user, and getting the user's + answer back (wait:true, or poll notify_events/notify_response), plus focus to reveal a + window. Use to alert the user (build done, export ready), show progress, or ask for a + decision and read the reply. --- # Notifications — `notify_user` (native branded Windows toasts)
skills/adom-bridge-ownership/SKILL.md+8−5@@ -1,11 +1,14 @@ --- -name: adom-bridge-bridge-ownership -description: The boundary between Adom Bridge core and a bridge author — what ab owns vs what you (the bridge) own. Read before filing an ab bug about a bridge (kicad, fusion360, native-browser, puppeteer, blender, rdp, or any third-party bridge) or before editing bridge code, so you fix and report in the right place. +name: adom-bridge-ownership +description: >- + The boundary between Adom Bridge core and a bridge author: what ab owns vs what the bridge + owns. Use before filing an ab bug about a bridge (kicad, fusion360, puppeteer, rdp, any + third-party bridge) or before editing bridge code. --- # Who owns what: ab core vs bridge author -**Read this FIRST if you're working on, debugging, or filing a bug about a bridge +**Read this before working on, debugging, or filing a bug about a bridge (kicad, fusion360, native-browser, puppeteer, blender, rdp, or any third-party bridge).** It exists because an AI drove the Fusion bridge end-to-end, hit a dozen issues, and filed almost all of them against **adom-bridge** — when nearly every @@ -63,8 +66,8 @@ The same boundary applies to the **consumer skill** (the SKILL.md that teaches a container how to drive a bridge's verbs): - **The ab-core pkg** (`adom-wiki pkg install adom/adom-bridge`) ships ONLY core skills — - desktop verbs, discovery, direct-api, installer, notify, multi-ad, bridge-sdk, - bridge-ownership, windows-host. It must NOT bundle a cloud/3rd-party bridge's skill (it + desktop verbs, discovery, direct-api, installer, notify, multi-machine, sdk, + ownership, windows-host. It must NOT bundle a cloud/3rd-party bridge's skill (it would go stale and isn't ab's to own). (2026-06-28: `eagle-commands.md` — Fusion-Electronics, `fusion_electron_run` — was dropped for this reason; 2026-06-29: `pup-SKILL.md` was dropped too when Puppeteer became cloud-owned. Both belong in their bridge's own pkg.)
skills/adom-bridge-permissions/SKILL.md+5−9@@ -1,14 +1,10 @@ --- name: adom-bridge-permissions -description: > - How the Adom Bridge human-approval permission gate works for an AI caller. - Read this when a verb refuses with errorCode needs_approval or reason_required, - when you need shell approval / bypass permissions on a machine, when you must - call request_shell_approval to surface the human approval dialog, when a call - demands a reason arg, or before an unattended run that touches shell_execute, - run_script, write_file, or any other gated verb. Covers the human approval - gate, the Approve 1 hr / 24 hr / Bypass Permissions buttons, and why grants - are human-only. +description: >- + How Adom Bridge's human-approval gate works for an AI caller: needs_approval and + reason_required refusals, request_shell_approval, the Approve 1 hr / 24 hr / Bypass buttons, + and why grants are human-only. Use when a verb needs approval or a reason, or before an + unattended shell_execute run. --- # Adom Bridge permissions — the human approval gate for AI callers
skills/adom-bridge-presence/SKILL.md+143−139@@ -1,139 +1,143 @@-name: adom-bridge-presence-description: How Adom Bridge lives quietly on the machine. Covers the system tray and close-to-tray behavior (the X button hides the window, ab keeps serving), the low-memory hidden-window mode (WebView2 suspension while hidden), launch on boot (default ON, Startup-shortcut self-heal), and the taskbar icon states (muted "Adom Bridge" standalone tile vs vivid "Adom Bridge Hydrogen Embedded" tile, the AUMID flip). Use when a user or AI asks why ab is still running after closing the window, why memory is low while hidden, how to tell standalone from embedded mode from the taskbar alone, how to enable or disable launch on boot, why the tray icon is missing, or what the window title markers mean.--# Adom Bridge presence: tray, hidden window, taskbar states--ab is a tray-resident app. Its window is hidden most of its life; the working-core is a small native (Rust) process that keeps serving the relay, direct API,-bridges, toasts, and approvals whether or not the window is on screen.--## Close to tray: X hides, ab keeps serving--- Clicking the window's X does NOT exit ab. The close request hides the window- and ab keeps running and serving (relay, direct API, bridges, notifications).- This is by design: ab must stay reachable as the machine's recovery channel.-- It is never silent: the FIRST 3 TIMES the user hits X, ab shows a toast- explaining that it moved to the tray, then stops reminding. The counter lives- at `~/.adom/tray-notice-count` (a legacy one-shot `tray-notice-shown` file- seeds the count at 1 so existing users are not re-nagged from scratch).-- Launching ab again while it is hidden is never a no-op (single-instance):- - standalone: the existing instance shows and focuses its window- - embedded with Hydrogen alive: a toast says where ab lives (inside Hydrogen)- - embedded but Hydrogen is dead (stale flag): treated as standalone, window shows-- The ONLY thing that exits ab is the `desktop_shutdown` verb- (`POST /command {"command":"shutdown"}` on the direct API). Closing the- window never does.--## The tray icon: standalone only--- In STANDALONE mode ab has a system tray icon.-- When EMBEDDED in Hydrogen, ab HIDES its tray icon. The tray can- therefore never signal the embedded state; the taskbar tile does (below).-- Do not "fix" a mode-signal question by looking at (or swapping) the tray- icon; in embedded mode there is none.--## Low-memory hidden-window mode (v1.9.135, `src/webview_lowmem.rs`)--The problem it solves: a hidden GUI used to cost the same as a visible one-(about 470 MB of WebView2 on top of the roughly 60 MB Rust process), because a-window-level hide never flips the WebView2 controller's visibility, so the-renderer kept full-power compositing into an invisible window.--What happens while the window is HIDDEN:--- A watcher polls main-window visibility every 3 seconds (there is no Tauri- visibility event; polling catches every hide/show path).-- Hidden: the WebView2 controller is set invisible (`SetIsVisible(false)`),- the memory target drops to LOW (`SetMemoryUsageTargetLevel`), and the page- is suspended (`TrySuspend`).-- GPU flags are trimmed: `--disable-gpu --disable-gpu-compositing` are merged- into the WebView2 browser arguments on real desktops (gpu-process measured- 151 MB down to 68 MB; renderer sandbox and code integrity stay ON).-- UI-only work is skipped: the 15 second health loop's LED probes and- `bridge-status` emits exist only to paint the GUI, so they are gated on- visibility. Every Tauri emit is an `evaluate_script`, which auto-resumes the- page by WebView2 design, so the watcher also re-suspends every ~60 seconds- to mop up resumes caused by command-driven activity-log emits.-- The Rust side keeps doing ALL real work, ungated: relay, direct API,- bridges, bridge supervision (persistent respawn plus duplicate collapse),- toasts, tray, shell approvals. Only the GUI panel is affected.--What happens when the window is SHOWN again:--- The controller becomes visible again, the page resumes, and the memory- target returns to NORMAL. Full power, immediately.-- LEDs self-heal on the first visible health tick, within about 15 seconds- of show.-- A shell-approval flow shows the window, which flips everything back to full- power on its own.--Escape hatches (diagnosis only): `ADOM_LOWMEM=0` disables the watcher;-`ADOM_WEBVIEW_GPU=1` keeps hardware GPU.--## Launch on boot: default ON, self-healing (v1.9.126)--- The intent is `config.launch_on_boot`, DEFAULT TRUE. The Settings checkbox- ("launch on boot" / autostart) reads and writes this intent and is checked- by default.-- A reconcile runs on EVERY launch, in BOTH standalone and embedded modes:- - intent ON and the shortcut is missing: create `AdomBridge.lnk` in the- user's Startup folder (with the `--autostart` arg)- - intent OFF and the shortcut exists: delete it-- This is the self-heal: a shortcut that was never created, got deleted, or- failed to write is recreated on the next launch. It runs in embedded mode- too, so ab comes up at boot even if Hydrogen's own autostart is off; the- single-instance guard converges any double-launch to one embedded ab.-- The reconcile respects a Task-Manager-DISABLED startup entry- (`StartupApproved\StartupFolder`); that is a deliberate user choice ab does- not override.-- Never create startup shortcuts manually; the reconcile owns them.--## Taskbar icon states: the tile tells you the mode (v1.9.140)--Two registry-only Windows app identities (AppUserModelIDs) are registered at-every launch:--| Mode | AUMID | Taskbar name | Tile |-|---|---|---|---|-| Standalone | `Adom.Desktop` | Adom Bridge | MUTED teal (`#46817d`) |-| Embedded in Hydrogen | `Adom.Desktop.Embedded` | Adom Bridge Hydrogen Embedded | VIVID brand teal (`#00b8b0`) |--- Muted tile named "Adom Bridge" means ab is running on its own. Vivid tile- named "Adom Bridge Hydrogen Embedded" means Hydrogen launched ab and is- driving its lifecycle.-- The mode flip is an AUMID CHANGE on the main window, not a repaint: a live- taskbar button's tile can never be repainted, but changing the window's- AppUserModelID makes the shell destroy the old button and create a new one,- which bakes its tile and name from the other identity's registration.-- Measured live: name AND tile flip in about 1 second, with no restart, no- flicker, and no window hide. The new button forms at the end of the- unpinned strip (buttons sit in creation order).-- Known cost: a taskbar pin binds ONE AUMID, so a pin made in one mode shows- the running window as a SEPARATE button while ab is in the other mode.- Documented and accepted.-- `inc.adom.bridge` (the toast and single-instance identity, carried by the- one Start Menu launcher shortcut with the vivid brand icon) is never- flipped; only the main window's per-window AUMID changes.--## Window title markers--The window title carries live trust and provenance markers:--- the running VERSION (for example `v1.9.140 release · ...`)-- a HOST vs SANDBOX context marker (whether this ab runs in the real host- process namespace or inside an AppContainer sandbox shadow)-- a `signed` marker when the running exe carries a valid Authenticode- signature--These are the quick truth check for "which build is actually running": the-relauncher's `verify_visible` step logs the full title, and the-`signed · HOST` combination is what a correctly deployed production ab shows.-(One caveat from the build system: the title's version string comes from a-compile-time constant that incremental builds can cache; the PE FileVersion-is the authoritative number when they disagree.)--## Foreground attribution (v1.9.175)--ab never raises its own window silently. Every self-foreground (sign-in completion, toast body click, shell-approve grant, `request_shell_approval`, tray menu, second launch) is logged to the Activity Log as a `foreground` event naming the source and reason, and to `startup.log` as a `FOREGROUND:` line. An update-apply relaunch is deliberately QUIET: the updater drops `~/.adom/update-quiet-relaunch` and the relaunched ab consumes it (10-minute freshness gate) and stays in the tray. If a user reports "ab randomly came to the front", read the Activity Log / startup.log — the answer is always recorded.+--- +name: adom-bridge-presence +description: >- + How Adom Bridge lives on the machine: tray and close-to-tray, low-memory hidden mode, launch + on boot, taskbar icon states (standalone vs embedded). Use when asked why ab is still + running after closing it, why the tray icon is missing, or how to turn launch on boot on or + off. +--- + +# Adom Bridge presence: tray, hidden window, taskbar states + +ab is a tray-resident app. Its window is hidden most of its life; the working +core is a small native (Rust) process that keeps serving the relay, direct API, +bridges, toasts, and approvals whether or not the window is on screen. + +## Close to tray: X hides, ab keeps serving + +- Clicking the window's X does NOT exit ab. The close request hides the window + and ab keeps running and serving (relay, direct API, bridges, notifications). + This is by design: ab must stay reachable as the machine's recovery channel. +- It is never silent: the FIRST 3 TIMES the user hits X, ab shows a toast + explaining that it moved to the tray, then stops reminding. The counter lives + at `~/.adom/tray-notice-count` (a legacy one-shot `tray-notice-shown` file + seeds the count at 1 so existing users are not re-nagged from scratch). +- Launching ab again while it is hidden is never a no-op (single-instance): + - standalone: the existing instance shows and focuses its window + - embedded with Hydrogen alive: a toast says where ab lives (inside Hydrogen) + - embedded but Hydrogen is dead (stale flag): treated as standalone, window shows +- The ONLY thing that exits ab is the `desktop_shutdown` verb + (`POST /command {"command":"shutdown"}` on the direct API). Closing the + window never does. + +## The tray icon: standalone only + +- In STANDALONE mode ab has a system tray icon. +- When EMBEDDED in Hydrogen, ab HIDES its tray icon. The tray can + therefore never signal the embedded state; the taskbar tile does (below). +- Do not "fix" a mode-signal question by looking at (or swapping) the tray + icon; in embedded mode there is none. + +## Low-memory hidden-window mode (v1.9.135, `src/webview_lowmem.rs`) + +The problem it solves: a hidden GUI used to cost the same as a visible one +(about 470 MB of WebView2 on top of the roughly 60 MB Rust process), because a +window-level hide never flips the WebView2 controller's visibility, so the +renderer kept full-power compositing into an invisible window. + +What happens while the window is HIDDEN: + +- A watcher polls main-window visibility every 3 seconds (there is no Tauri + visibility event; polling catches every hide/show path). +- Hidden: the WebView2 controller is set invisible (`SetIsVisible(false)`), + the memory target drops to LOW (`SetMemoryUsageTargetLevel`), and the page + is suspended (`TrySuspend`). +- GPU flags are trimmed: `--disable-gpu --disable-gpu-compositing` are merged + into the WebView2 browser arguments on real desktops (gpu-process measured + 151 MB down to 68 MB; renderer sandbox and code integrity stay ON). +- UI-only work is skipped: the 15 second health loop's LED probes and + `bridge-status` emits exist only to paint the GUI, so they are gated on + visibility. Every Tauri emit is an `evaluate_script`, which auto-resumes the + page by WebView2 design, so the watcher also re-suspends every ~60 seconds + to mop up resumes caused by command-driven activity-log emits. +- The Rust side keeps doing ALL real work, ungated: relay, direct API, + bridges, bridge supervision (persistent respawn plus duplicate collapse), + toasts, tray, shell approvals. Only the GUI panel is affected. + +What happens when the window is SHOWN again: + +- The controller becomes visible again, the page resumes, and the memory + target returns to NORMAL. Full power, immediately. +- LEDs self-heal on the first visible health tick, within about 15 seconds + of show. +- A shell-approval flow shows the window, which flips everything back to full + power on its own. + +Escape hatches (diagnosis only): `ADOM_LOWMEM=0` disables the watcher; +`ADOM_WEBVIEW_GPU=1` keeps hardware GPU. + +## Launch on boot: default ON, self-healing (v1.9.126) + +- The intent is `config.launch_on_boot`, DEFAULT TRUE. The Settings checkbox + ("launch on boot" / autostart) reads and writes this intent and is checked + by default. +- A reconcile runs on EVERY launch, in BOTH standalone and embedded modes: + - intent ON and the shortcut is missing: create `AdomBridge.lnk` in the + user's Startup folder (with the `--autostart` arg) + - intent OFF and the shortcut exists: delete it +- This is the self-heal: a shortcut that was never created, got deleted, or + failed to write is recreated on the next launch. It runs in embedded mode + too, so ab comes up at boot even if Hydrogen's own autostart is off; the + single-instance guard converges any double-launch to one embedded ab. +- The reconcile respects a Task-Manager-DISABLED startup entry + (`StartupApproved\StartupFolder`); that is a deliberate user choice ab does + not override. +- Never create startup shortcuts manually; the reconcile owns them. + +## Taskbar icon states: the tile tells you the mode (v1.9.140) + +Two registry-only Windows app identities (AppUserModelIDs) are registered at +every launch: + +| Mode | AUMID | Taskbar name | Tile | +|---|---|---|---| +| Standalone | `Adom.Desktop` | Adom Bridge | MUTED teal (`#46817d`) | +| Embedded in Hydrogen | `Adom.Desktop.Embedded` | Adom Bridge Hydrogen Embedded | VIVID brand teal (`#00b8b0`) | + +- Muted tile named "Adom Bridge" means ab is running on its own. Vivid tile + named "Adom Bridge Hydrogen Embedded" means Hydrogen launched ab and is + driving its lifecycle. +- The mode flip is an AUMID CHANGE on the main window, not a repaint: a live + taskbar button's tile can never be repainted, but changing the window's + AppUserModelID makes the shell destroy the old button and create a new one, + which bakes its tile and name from the other identity's registration. +- Measured live: name AND tile flip in about 1 second, with no restart, no + flicker, and no window hide. The new button forms at the end of the + unpinned strip (buttons sit in creation order). +- Known cost: a taskbar pin binds ONE AUMID, so a pin made in one mode shows + the running window as a SEPARATE button while ab is in the other mode. + Documented and accepted. +- `inc.adom.bridge` (the toast and single-instance identity, carried by the + one Start Menu launcher shortcut with the vivid brand icon) is never + flipped; only the main window's per-window AUMID changes. + +## Window title markers + +The window title carries live trust and provenance markers: + +- the running VERSION (for example `v1.9.140 release · ...`) +- a HOST vs SANDBOX context marker (whether this ab runs in the real host + process namespace or inside an AppContainer sandbox shadow) +- a `signed` marker when the running exe carries a valid Authenticode + signature + +These are the quick truth check for "which build is actually running": the +relauncher's `verify_visible` step logs the full title, and the +`signed · HOST` combination is what a correctly deployed production ab shows. +(One caveat from the build system: the title's version string comes from a +compile-time constant that incremental builds can cache; the PE FileVersion +is the authoritative number when they disagree.) + +## Foreground attribution (v1.9.175) + +ab never raises its own window silently. Every self-foreground (sign-in completion, toast body click, shell-approve grant, `request_shell_approval`, tray menu, second launch) is logged to the Activity Log as a `foreground` event naming the source and reason, and to `startup.log` as a `FOREGROUND:` line. An update-apply relaunch is deliberately QUIET: the updater drops `~/.adom/update-quiet-relaunch` and the relaunched ab consumes it (10-minute freshness gate) and stays in the tray. If a user reports "ab randomly came to the front", read the Activity Log / startup.log — the answer is always recorded.
skills/adom-bridge-recording/SKILL.md+6−2@@ -1,6 +1,10 @@ --- name: adom-bridge-recording -description: Record the user's screen with Adom Bridge's NATIVE recorder (v2.1.35, wiki #143) - desktop_record_start captures one monitor plus the system audio mix into an H.264/AAC MP4 with no browser and a 4x4 pixel teal dot in a screen corner as the recording tell; desktop_record_window_* records a single window in the background; desktop_record_stop/status/list manage takes. Chrome screen-share recording (picker consent, WebM) is the puppeteer bridge's pup_screen_record_* - a different tool. +description: >- + Record the user's screen with Adom Bridge's native recorder: desktop_record_start (one + monitor plus system audio to MP4), desktop_record_window_* for one window, and + stop/status/list. Use when asked to record my screen, record a window, or capture a desktop + video. --- ## v2.1.35: the native recorder (read this first) @@ -134,7 +138,7 @@ stops manually, treat the clip as complete and react accordingly. ## Where files land, and retention -**2.1.175 (wiki #238): keep a take.** The recordings folder is pruned oldest-first above 3 GB / 2 days, which on a multi-hour 4K shoot deletes take 1 while take 2 is still recording. Pass `keep:true` to `desktop_record_start` (or to `desktop_record_stop`) and the take is left alone: the reply says `kept:true`, and a `<file>.keep` marker beside it takes it out of every limit. To bring a big take back over the mesh, `pull_file` now waits up to `timeoutSeconds` (default 3600), and `read_file {path, offset, length}` copies it in pieces as a fallback. +**2.1.175 (wiki #238): keep a take.** The recordings folder is pruned oldest-first above 3 GB / 2 days, which on a multi-hour 4K shoot deletes take 1 while take 2 is still recording. Pass `keep:true` to `desktop_record_start` (or to `desktop_record_stop`) and the take is left alone: the reply says `kept:true` (2.1.185, wiki #250: also inside `output` and its `data`, which is what the CLI and Hydrogen read; before that it was on the envelope only, so treat a missing `kept` as not kept), and a `<file>.keep` marker beside it takes it out of every limit. To bring a big take back over the mesh, `pull_file` now waits up to `timeoutSeconds` (default 3600), and `read_file {path, offset, length}` copies it in pieces as a fallback. - Each finished clip is a single `.webm` (VP9). The exact on-disk path comes back in `filePath` on start/stop and in `desktop_record_list`.
skills/adom-bridge-runtimes/SKILL.md+5−7@@ -1,12 +1,10 @@ --- name: adom-bridge-runtimes -description: > - Managed Node and Python runtimes for Adom Bridge bridges. Use this skill when a - node or python bridge fails to spawn, when you need to check runtime status with the - runtimes verb, when a bridge needs Node.js or Python and the box may not have one, - when npm dependencies for a source-only node bridge are missing, or when you want to - understand the no-UAC portable node/python install, the prewarm on first launch, the - Runtimes panel in the GUI, or the loopback-only bind rule for bridge processes. +description: >- + Managed Node and Python runtimes for Adom Bridge bridges: the runtimes verb, the no-UAC + portable install, prewarm, the loopback bind rule. Use when a node or python bridge fails to + spawn, Node.js or Python is missing on the PC, or a node bridge's npm dependencies are + missing. --- # Managed runtimes: how ab provisions Node and Python for bridges
skills/adom-bridge-sdk-audit/SKILL.md+158−160@@ -1,160 +1,158 @@-name: adom-bridge-sdk-audit-description: >-- AUDIT and maintain a shipped bridge: the self-audit checklist to run when asked to 'audit- against the SDK', how to stay current with SDK changes on your own, the verb-contract quick- reference, how to get bugs reported in the OUTPUT rather than buried in a skill file, and- how to file issues against Adom Bridge or the repos you depend on. Use for a periodic health- check of your bridge or when an SDK-change notice lands on your wiki discussion.---## Self-audit checklist — run this when asked to "audit against the SDK"--When a maintainer says *"audit your code, skills, README, and wiki page against the Bridge SDK,"* **this is the checklist.** Each item names its section below; fix every miss, then re-check.--**⛔ BOUNDARY LINT — run these FIRST; a failure here outranks everything below** *(→ SDK "LINE OF DEMARCATION")*:-- [ ] **ZERO ab-core changes.** Your work touches NO file under `src-tauri/` / `cli/` in the adom-bridge repo. If you have an open PR against ab for a bridge feature, it is wrong by default — the capability belongs in your bridge. `git diff --name-only <base>..HEAD | grep -E '^(src-tauri|cli)/'` must be EMPTY.-- [ ] **No duplicate of a verb ab already ships.** Before writing any desktop capability, list ab's surface (`adom-bridge verbs`, `/verbs`, or the `adom-bridge-verbs` skill) and confirm nothing generic already does it. Window identity, taskbar icon/overlay, jump lists, screenshots, input, files, notifications are **already generic in ab** — call them, don't re-implement them.-- [ ] **ab never learns your app's name.** No `Adom.<YourApp>.` namespace, app-named label, or app-named router baked into ab. Your appId / icon / labels are values YOU pass into ab's generic verbs. `grep -ri "<yourapp>" <path-to-ab>/src-tauri/src/` must be EMPTY.-- [ ] **App-AWARE logic stays in your bridge.** Anything that needs knowledge of how YOUR app behaves — window→session binding, HWND/PID/process-start verification, launch timing, profile layout — is implemented in your bridge and hands ab only a resolved, generic input. If a proposed ab change would require ab to understand your app, it fails the two-part test.-- [ ] **Two-part test on any ab ask:** could an unrelated app use this verb unchanged, AND can ab implement it knowing nothing app-specific? If either is no, build it in the bridge (or ask for a *generic hardening* of an existing verb). **Only once it PASSES does the channel matter: Adom team → send a PR (efficient, welcome); third-party → file a discussion on `adom-bridge`.** Being on the team shortens the path, it does not widen the line — a team PR carrying app-specific code gets the same answer as anyone's.--**KEYWORD + PAGE IDENTITY lint** *(→ SDK "Your bridge's KEYWORD" + Artifact 3)*:-- [ ] **A short user keyword is DECLARED, not just agreed.** One speakable word (`pup`/`kicad`/`pw`), stated in your README's first section ("Say **X** to the AI to reach this bridge"), matching your `bridge.json` `name` AND your verb prefix (`<keyword>_*`) AND your `discovery_triggers`. A keyword that lives only in a conversation does not exist.-- [ ] **Current naming.** The product is **Adom Bridge** (ab). Your page slug, README, and copy use it — a slug or doc still saying `adom-desktop-*` is stale and reads as abandoned. Live bridges are `kicad-bridge`, `fusion-bridge`, `pup-bridge`.-- [ ] **⭐ Your README SHOWS the bridge working — screenshots, not just prose.** `grep -c '!\[' README.md` must be well above zero (pup ships 13, kicad 8, plus demo videos; a brand-new bridge shipped **0**, and its reviewer had no way to see the feature at all). This is the single highest-leverage item on this list: **a reviewer, a user, or John evaluating your PR will NOT build and run your bridge to find out what it does** — especially when doing so would mean running modified core ab. The page is the only window into your work. Capture the actual outcome (the window/app/artifact your verbs produce), and for anything VISUAL — taskbar tiles, jump lists, overlays, rendered output — screenshot it **before and after**, because prose cannot convey it and nobody can verify it otherwise. ab screenshots its own windows (`desktop_screenshot_window` returns a window plus its owned popups), so this is scriptable. Follow the house pattern: kicad's `show-symbol.png` / `show-3d-chip.png` + a demo `.mp4`, fusion's numbered `lib-01…lib-10` walkthrough.-- [ ] **A real 1.6-aspect hero** (~2000x1250) on the page — not the auto-generated OG card, which ab cover-crops and clips. *(→ Card hero)* Open your card in ab and confirm nothing is clipped.-- [ ] **Root `SKILL.md` exists** (not only a `skill-package/` directory) plus the `skills/<name>/SKILL.md` set your pkg ships.-- [ ] **At least one QUALIFIED release** — a real, updater-eligible version with its runtime `.zip`, not exclusively "dev / development candidate / updater-ineligible" rows. A page whose every release is a dev candidate cannot be installed by a normal user.-- [ ] The standard kit is present: `install.sh` / `uninstall.sh`, `CHANGELOG.md`, `PUBLISHING.md`, and the billboard art the other bridges ship.--**bridge.json** (the manifest in your zip):-- [ ] `spawn.kind` ∈ `python|node|exe|external-http`, with **`entrypoint`** (NOT `process`/`cmd`/`args`); `healthEndpoint` is **inside `spawn`**; `port:0` lets ab pick. *(→ bridge.json)*-- [ ] `updateManifestUrl` → YOUR page's manifest (durable auto-update); `docs` → your own page; **`homepage` is a `wiki.adom.inc` URL, NEVER `github.com`.**-- [ ] `detect` declared iff you drive a HOST APP (KiCad/Fusion/Altium…); omitted if you bring your own runtime (pup). `prewarm.assets` declared for heavy downloads (e.g. `chrome-for-testing`). *(→ Cold-start tiers)*-- [ ] **`timeouts`** declared if ANY verb can run longer than 60s (a big export/render/cloud-open) — with a matching **`statusVerb`** to poll. Third-party bridges were 60s-capped before v1.9.79; this is how you raise it. *(→ bridge.json)*--**Artifacts & deps** (the two-artifact split — the #1 inconsistency) *(→ Where every file lives)*:-- [ ] **SIGNED: every Windows PE file in your zip (`.exe`, `.dll`, `.node`, `.pyd`) carries a timestamped signature from a trusted publisher.** Unzip your published release on Windows and run `Get-ChildItem -Recurse -Include *.exe,*.dll,*.node,*.pyd | Get-AuthenticodeSignature | Format-Table Status,Path`; every row must read `Valid`. An unsigned exe is blocked outright by Windows Smart App Control on many new PCs (ab answers `blocked_by_windows_app_control`), and you will never see it on a developer machine. Self-signed does not count. *(→ Sign everything you ship)*-- [ ] Your Release `.zip` is a **Release asset** (`release upload`), NOT a loose `/files` blob — and old version zips are NOT piling up on `/files` (audit for stray `*-v*.zip`).-- [ ] The **server's** runtime deps live in the **ZIP's** `package.json` (node) / `requirements.txt` (python). The **pkg's** `package.json` declares `dependencies:{"adom/adom-bridge":"^1.9.x"}` (so `pkg install` pulls the ab CLI + core skills) and does NOT list the server's npm deps.-- [ ] A big runtime asset (Mesa, a browser) is a **`prewarm.asset`** or seed-bundled — NOT a loose `/files` blob.-- [ ] Your **manifest** (`adom-bridge-<name>-manifest.json`) is on `/files`; its `url` points at the **Release download**, not at a `/files` blob.-- [ ] **PAYLOAD:** `unzip -l <your zip> | sort -k1 -n -r | head` shows **runtime only** — no `.mp4`/`.png`/`.jpg`/screenshots/CHANGELOG/`.github`. A runtime zip is normally well under 1 MB; if yours is bigger, justify every large entry. `tar tzf <your tgz>` shows **skills only** — zero media, zero binaries. Media belongs on the PAGE. *(→ Payload rules)*-- [ ] **PAGE COPY:** your package `description` AND README each carry the one-sentence statement that `pkg install` installs the bridge's SKILLS for your container, not the bridge runtime. One sentence, not an essay. *(→ Artifact 2)*--**Runtime** (Node/Python bridges) *(→ Runtime contract)*:-- [ ] You bind **`ADOM_BIND_HOST`** (loopback default), never `0.0.0.0`.-- [ ] You do **NOT** bootstrap your own Node/Python — no `winget`/`desktop_install_node`/self-download in code or skills. `spawn.kind` is the declaration; ab provisions it.-- [ ] Release zip is **source-only** (no `node_modules`); you rely on ab's `npm install` (PATH-correct as of v1.9.69) + a lockstep bundled seed for instant spawn. You do NOT put the runtime on PATH yourself.--**Verbs & output** (what the AI actually reads):-- [ ] EVERY verb returns a rich **`_hint`** (+ `_next`/`related`/`pitfalls`). *(→ THE ONE PRINCIPLE)*-- [ ] A `<prefix>_describe` verb lists your whole catalog. *(→ Verb contract)*-- [ ] No verb blocks on a heavy install/download — background it, report `installing`/`warming`; `<prefix>_readiness` is read-only. *(→ Cold-start tiers)*-- [ ] `/health` returns `led`/`summary`/`tooltip` truthfully. *(→ Health + status chip)*-- [ ] **NOT INTERCEPTED: for each of your slowest / most-structured verbs, call YOUR OWN port directly and diff the response key set against the same verb routed through `adom-bridge`.** They must match. If ab's answer is missing fields yours returns (or carries fields you have never implemented), **ab core is answering that verb instead of forwarding it** and your handler never ran. This fails SILENTLY and inverts the blame: nothing errors, the caller gets a plausible well-formed response, and it reads from the outside as "the bridge did not implement the contract." Real case: ab core owned `fusion_start`, so the fusion bridge's `progress`/`startKind`/`timings` never reached callers, on the very verb the measured-progress rule was written for (adom/adom-bridge#86, fixed in ab 2.0.61). Check it after ANY ab upgrade, and file on `adom/adom-bridge` with the two key sets side by side.-- **Run the diff TWICE.** A bridge respawn between the two calls produces a false negative that reads as "my bridge is broken" rather than "ab answered". And note WHY this check has to exist at all: **a swallowed field is indistinguishable from a field you never implemented.** That is precisely why ab's interception of `fusion_start` went unnoticed for so long - the absence looked like the bridge's own gap, so nobody went looking on ab's side.--**Discovery** (can a plain request find you?):-- [ ] **10+ user-task `discovery_triggers`** (everyday phrasings, NOT dev jargon). **No triggers = invisible.** *(→ Artifact 3)*-- [ ] You ran `discover preview --json` on 5-7 real user queries and land **top-3** for each.-- [ ] Triggers stay in YOUR niche — you don't poach a sibling's (login/forms → native-browser, not pup). `discovery_pitch` is an INSTRUCTION.--**Skills & docs** (what the container + humans read):-- [ ] Your pkg ships **ONLY your USER skill(s)** — a real `skills/<name>/SKILL.md` (core user skill + user sub-skills) with a `Parent skill:` first body line; every path listed EXPLICITLY (no globs) in `files[]` + install.sh/uninstall.sh. *(→ Artifact 2 + The SKILL SET)*-- [ ] **DEV + PUBLISH skills are in your source REPO, NOT the pkg** — not in `files[]`, not scoped in via `user-invocable:false`. A developer gets them by cloning the source; the normal user never needs them. Open-vs-closed source is irrelevant (it only sets repo visibility). *(→ Where every file lives)*-- [ ] Your pkg's `package.json` declares `dependencies:{"adom/adom-bridge":"^1.9.x"}` so `pkg install` pulls the ab CLI + core skills the container needs. *(→ Where every file lives)*-- [ ] Your USER skill leads with the cold-start "read before you panic" section + the 8 conventions, and points the AI at `bridge_readiness`. *(→ USER-skill conventions)*-- [ ] README + wiki page + skills all AGREE with the above (no stale github links, no wrong schema, triggers vetted).---## Staying current — catch up on SDK changes yourself (no prompt needed)--This SDK evolves. When it does, ab posts a re-audit notice to **YOUR bridge's wiki discussion** and re-publishes this billboard. You do NOT need a human to hand you a prompt — self-serve at the start of a work session:--1. **Check your discussion for a notice.** `adom-wiki discussion list <your-slug>` (or open your page's Discussions tab) and read any "SDK update" / "re-audit" thread. A notice can be EDITED after posting (a correction), so always read the **current** body, not a cached earlier version.-2. **Re-read this billboard**, especially "Where every file lives" (above) and the "Self-audit checklist" (below): `https://wiki.adom.inc/adom/adom-bridge` (or `curl -s https://wiki.adom.inc/api/v1/pages/adom-bridge-sdk/files/README.md`).-3. **Run the Self-audit checklist** (next section) against your code, `bridge.json`, skills, README, and wiki page. Fix every miss.-4. **Reply on the discussion** with what you changed or any blockers. That thread is the ONLY channel ab uses to reach you, so closing the loop there is how ab knows you are current.---## Verb contract (quick reference)--- **Naming:** every verb is `<name>_<verb>` (`blender_render`, `blender_status`). ab routes any verb with your prefix to you.-- **Request:** ab POSTs `{ "command":"<verb>", "args":{…} }` to your port. **Response:** JSON with at least `success` (bool); human/AI output in `output`, errors in `error` (+ an `errorCode` for machine-actionable failures). ab relays everything else verbatim — so ALWAYS add `_hint`/`_next`/`related`/`pitfalls`.-- **Long-running verbs:** return promptly with a job id + set `statusVerb`; the caller polls instead of blocking. On a timeout ab auto-returns `{stillRunning:true, statusVerb, timeoutSeconds}`. If a verb genuinely blocks past 60s (a synchronous export/render), **also declare a `timeouts` block** (v1.9.79) so ab's HTTP budget matches — otherwise ab cuts the connection at 60s (third-party bridges were hard-capped there before v1.9.79).-- **`<prefix>describe`:** return your full verb catalog (`{verbs:[{name,summary,input,output,timeoutSeconds,statusVerb,longRunning,example,hint,related,pitfalls}]}`). ab caches it (`~/.adom/bridge-verbs/<name>.json`) and shows it in the GUI Verbs tab — but remember, `describe` is read rarely; the per-call hints are what carry the AI.-- **Keyboard input: route through ab's `desktop_press_key` — do NOT reimplement chords.** ab's key verb already does full modifier chords (`"shift+s"`, `"ctrl+shift+p"`, `"alt+f4"` — modifiers held via SendInput, key tapped, modifiers released in reverse), foregrounds the target window first (`{window|titleContains|hwnd}`) so CEF/Qt apps like Fusion receive it, and every bridge inherits it via the `/command` desktop passthrough (`{"command":"press_key", ...}` on the desktop namespace). A bridge-local `*_send_key` that parses only single keys (the Fusion bridge's `fusion_send_key` circa v1.6.x — `"shift+s"` → "Unknown key") duplicates ab's work and drifts; keep such a verb only for SendMessage-to-exact-hwnd cases ab's focus-first model can't cover, and say so in its `describe`.-- **Host-app-optional verbs (v1.9.76):** declare `detect.hostAppOptionalVerbs` (bare names, e.g. `["readiness","describe"]`) in `bridge.json` so ab forwards those verbs to your running bridge even when your host app is absent — ab's built-in read-only set {readiness, describe, get_app_state, status} is always forwarded. And declare **`detect.installVerb`** (e.g. `"kicad_upgrade"`) so `bridge_readiness` recommends YOUR installer over the generic winget fallback.--## Ask the AI to report bugs, in the OUTPUT, not the skill file--You cannot reproduce every environment. Your bridge runs on machines with browser installs, OS versions, security policies, hardware and concurrency patterns you will never have locally. **The AI driving your bridge is standing on exactly that machine.** It is the only party holding the failing response, and the moment it holds it is the only moment it will file anything.--So make the invitation part of your verb output, the same way `_hint` is. This is THE ONE PRINCIPLE at the top of this page applied to feedback: the AI almost never reads your on-disk SKILL.md, and it ALWAYS reads the output of a CLI call.--**The evidence that this channel works.** In roughly two days, wiki issues filed against the puppeteer bridge produced: a CPU-pegging orphaned-renderer bug that froze a laptop and forced a reboot (#14, #21), a respawn failure that broke the bridge on every idle cycle (#15), a session-tracking bug where windows were open and drivable but reported as zero (#16), a repo-versus-release divergence that would have silently reverted five releases (#19), and a concurrency and readiness report that caught a live blocking regression shipped hours earlier (#22). **Not one of those would have been found on a maintainer's machine.** They came from real installs: different Chrome and Edge combinations, different ab versions, concurrent agents, a macOS box, a stalled download.--**Return a `_reportIssues` field naming YOUR page's issue list, with the exact commands:**--```jsonc-{- "success": false,- "errorCode": "…",- "_hint": "…",- "_reportIssues": "THIS MAY BE A <BRIDGE> BUG. PLEASE REPORT IT. Check for a duplicate with `adom-wiki issue list adom/<your-slug>`, then file: `adom-wiki issue create adom/<your-slug> --title \"<one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, what happened, the VERBATIM response JSON, the bridge version from bridge_list, and the OS>\"`. Reports from real machines are the only way this bridge gets correct. (An ab-core problem, such as the relay, bridge lifecycle or caller identity, belongs on adom/adom-bridge instead.)"-}-```--**When to attach it. Throttle it, or the AI learns to skip it:**--| Moment | Why |-|---|---|-| **Always on a failure** | The highest-value moment: a bug just happened and the evidence is still in the response. |-| **Once per bridge process, on the first verb** | Orientation, so the AI knows where to report *before* anything breaks. |-| **Occasionally after that** (e.g. every ~40th call) | A quiet reminder on a long-running session without taxing every response. |--A line on **every** response is noise that trains the caller to ignore the field. A line on every **failure** is a bug report waiting to be filed.--**Make it actionable, not a slogan.** "Report bugs" produces nothing. Give the AI the exact `adom-wiki issue create` command, your slug, a `--category`, and the list of evidence to include (the verbatim response JSON and your bridge version are the two that make a report reproducible). Tell it to check for a duplicate first: an `issue list` call costs nothing and keeps your page clean.--**Route the report to the repo that owns the code.** Say explicitly, in the same string, that ab-core problems (the relay and `/command` passthrough, spawn and provisioning, bridge lifecycle, caller identity) go to **`adom/adom-bridge`**, not to your page. Authors get this wrong in the other direction too, which is the next section: a misfiled issue is a note to yourself that the people who can fix it never read.--**Let the user turn it off.** Ship a settings toggle (pup uses `pup_configure {reportIssues:"off"}`) so a user who finds it noisy can silence it without losing functionality. Default it **on**: the cost is a few tokens on failures, and the benefit is the bug class you would otherwise ship to every user.--**Reference implementation:** the puppeteer bridge does this at its single `sendJSON` chokepoint (v1.9.95), so all ~155 response sites are covered from one place, and it never overwrites a `_hint` a handler already set.---## Filing issues against ab (and the other repos you depend on)--You WILL find ab-core bugs and gaps while building your bridge. That is expected and useful. But file them in the RIGHT repo's issue list, or the people who can fix them never see it.--**The rule: an issue lives on the issue list of the repo that OWNS the code that must change.**--- A bug in YOUR bridge's verb / hint / self-heal / status chip goes to **YOUR page's issue list** (or you just fix it, since you own it).-- A missing or broken **ab-CORE** capability (a lifecycle verb, a spawn / provision / bind behavior, the relay passthrough dropping a field, a manifest field ab must honor, the status-chip contract) goes to **`adom/adom-bridge`**, NOT your own page. ab-core's maintainers and AI watch `adom/adom-bridge`; they do NOT read your bridge page's issues. **An ab ask filed on your own page is a note to yourself.**-- A **wiki or CLI** bug goes to `adom/wiki`. A **sibling bridge's** bug goes to that bridge's page.--**How (the CLI):**--```bash-adom-wiki issue list adom/adom-bridge # FIRST: skim for a dup, comment if one exists-adom-wiki issue create adom/adom-bridge \- --title "<verb or subsystem>: <the gap in one line>" \- --category bug-report \- --body "$(cat your-writeup.md)"-```--**Write the body like a good bug report:** what you hit live (with the date and the ab version), the measured behavior, the ab-owned root cause if you found one, and a concrete ask. Separate clearly what is ab-core's to fix from what is yours, and say so explicitly, so the two are never conflated.--**After you file:** the ab-core thread replies on that issue. Read the reply, it may change your plan (for example "fixed in ab X.Y, bump your pin"). That issue thread is the cross-repo channel; keep the loop closed there.--**Why this matters most for ab:** ab-core spawns you, provisions your runtime, relays your verbs, and installs you, so ab is the repo you will file against most often. Get the destination right the first time. The established precedent all sits on `adom/adom-bridge`: #26 and #27 (pup to ab, Chrome for Testing), #16 (pup to ab, window-verb interception), #3 / #20 / #21 (Bridge SDK asks).-+--- +name: adom-bridge-sdk-audit +description: >- + adom-bridge-sdk sub-skill: audit and maintain a shipped bridge against the SDK (self-audit + checklist, staying current, filing issues). +disable-model-invocation: true +--- + + +## Self-audit checklist — run this when asked to "audit against the SDK" + +When a maintainer says *"audit your code, skills, README, and wiki page against the Bridge SDK,"* **this is the checklist.** Each item names its section below; fix every miss, then re-check. + +**⛔ BOUNDARY LINT — run these FIRST; a failure here outranks everything below** *(→ SDK "LINE OF DEMARCATION")*: +- [ ] **ZERO ab-core changes.** Your work touches NO file under `src-tauri/` / `cli/` in the adom-bridge repo. If you have an open PR against ab for a bridge feature, it is wrong by default — the capability belongs in your bridge. `git diff --name-only <base>..HEAD | grep -E '^(src-tauri|cli)/'` must be EMPTY. +- [ ] **No duplicate of a verb ab already ships.** Before writing any desktop capability, list ab's surface (`adom-bridge verbs`, `/verbs`, or the `adom-bridge-verbs` skill) and confirm nothing generic already does it. Window identity, taskbar icon/overlay, jump lists, screenshots, input, files, notifications are **already generic in ab** — call them, don't re-implement them. +- [ ] **ab never learns your app's name.** No `Adom.<YourApp>.` namespace, app-named label, or app-named router baked into ab. Your appId / icon / labels are values YOU pass into ab's generic verbs. `grep -ri "<yourapp>" <path-to-ab>/src-tauri/src/` must be EMPTY. +- [ ] **App-AWARE logic stays in your bridge.** Anything that needs knowledge of how YOUR app behaves — window→session binding, HWND/PID/process-start verification, launch timing, profile layout — is implemented in your bridge and hands ab only a resolved, generic input. If a proposed ab change would require ab to understand your app, it fails the two-part test. +- [ ] **Two-part test on any ab ask:** could an unrelated app use this verb unchanged, AND can ab implement it knowing nothing app-specific? If either is no, build it in the bridge (or ask for a *generic hardening* of an existing verb). **Only once it PASSES does the channel matter: Adom team → send a PR (efficient, welcome); third-party → file a discussion on `adom-bridge`.** Being on the team shortens the path, it does not widen the line — a team PR carrying app-specific code gets the same answer as anyone's. + +**KEYWORD + PAGE IDENTITY lint** *(→ SDK "Your bridge's KEYWORD" + Artifact 3)*: +- [ ] **A short user keyword is DECLARED, not just agreed.** One speakable word (`pup`/`kicad`/`pw`), stated in your README's first section ("Say **X** to the AI to reach this bridge"), matching your `bridge.json` `name` AND your verb prefix (`<keyword>_*`) AND your `discovery_triggers`. A keyword that lives only in a conversation does not exist. +- [ ] **Current naming.** The product is **Adom Bridge** (ab). Your page slug, README, and copy use it — a slug or doc still saying `adom-desktop-*` is stale and reads as abandoned. Live bridges are `kicad-bridge`, `fusion-bridge`, `pup-bridge`. +- [ ] **⭐ Your README SHOWS the bridge working — screenshots, not just prose.** `grep -c '!\[' README.md` must be well above zero (pup ships 13, kicad 8, plus demo videos; a brand-new bridge shipped **0**, and its reviewer had no way to see the feature at all). This is the single highest-leverage item on this list: **a reviewer, a user, or John evaluating your PR will NOT build and run your bridge to find out what it does** — especially when doing so would mean running modified core ab. The page is the only window into your work. Capture the actual outcome (the window/app/artifact your verbs produce), and for anything VISUAL — taskbar tiles, jump lists, overlays, rendered output — screenshot it **before and after**, because prose cannot convey it and nobody can verify it otherwise. ab screenshots its own windows (`desktop_screenshot_window` returns a window plus its owned popups), so this is scriptable. Follow the house pattern: kicad's `show-symbol.png` / `show-3d-chip.png` + a demo `.mp4`, fusion's numbered `lib-01…lib-10` walkthrough. +- [ ] **A real 1.6-aspect hero** (~2000x1250) on the page — not the auto-generated OG card, which ab cover-crops and clips. *(→ Card hero)* Open your card in ab and confirm nothing is clipped. +- [ ] **Root `SKILL.md` exists** (not only a `skill-package/` directory) plus the `skills/<name>/SKILL.md` set your pkg ships. +- [ ] **At least one QUALIFIED release** — a real, updater-eligible version with its runtime `.zip`, not exclusively "dev / development candidate / updater-ineligible" rows. A page whose every release is a dev candidate cannot be installed by a normal user. +- [ ] The standard kit is present: `install.sh` / `uninstall.sh`, `CHANGELOG.md`, `PUBLISHING.md`, and the billboard art the other bridges ship. + +**bridge.json** (the manifest in your zip): +- [ ] `spawn.kind` ∈ `python|node|exe|external-http`, with **`entrypoint`** (NOT `process`/`cmd`/`args`); `healthEndpoint` is **inside `spawn`**; `port:0` lets ab pick. *(→ bridge.json)* +- [ ] `updateManifestUrl` → YOUR page's manifest (durable auto-update); `docs` → your own page; **`homepage` is a `wiki.adom.inc` URL, NEVER `github.com`.** +- [ ] `detect` declared iff you drive a HOST APP (KiCad/Fusion/Altium…); omitted if you bring your own runtime (pup). `prewarm.assets` declared for heavy downloads (e.g. `chrome-for-testing`). *(→ Cold-start tiers)* +- [ ] **`timeouts`** declared if ANY verb can run longer than 60s (a big export/render/cloud-open) — with a matching **`statusVerb`** to poll. Third-party bridges were 60s-capped before v1.9.79; this is how you raise it. *(→ bridge.json)* + +**Artifacts & deps** (the two-artifact split — the #1 inconsistency) *(→ Where every file lives)*: +- [ ] **SIGNED: every Windows PE file in your zip (`.exe`, `.dll`, `.node`, `.pyd`) carries a timestamped signature from a trusted publisher.** Unzip your published release on Windows and run `Get-ChildItem -Recurse -Include *.exe,*.dll,*.node,*.pyd | Get-AuthenticodeSignature | Format-Table Status,Path`; every row must read `Valid`. An unsigned exe is blocked outright by Windows Smart App Control on many new PCs (ab answers `blocked_by_windows_app_control`), and you will never see it on a developer machine. Self-signed does not count. *(→ Sign everything you ship)* +- [ ] Your Release `.zip` is a **Release asset** (`release upload`), NOT a loose `/files` blob — and old version zips are NOT piling up on `/files` (audit for stray `*-v*.zip`). +- [ ] The **server's** runtime deps live in the **ZIP's** `package.json` (node) / `requirements.txt` (python). The **pkg's** `package.json` declares `dependencies:{"adom/adom-bridge":"^1.9.x"}` (so `pkg install` pulls the ab CLI + core skills) and does NOT list the server's npm deps. +- [ ] A big runtime asset (Mesa, a browser) is a **`prewarm.asset`** or seed-bundled — NOT a loose `/files` blob. +- [ ] Your **manifest** (`adom-bridge-<name>-manifest.json`) is on `/files`; its `url` points at the **Release download**, not at a `/files` blob. +- [ ] **PAYLOAD:** `unzip -l <your zip> | sort -k1 -n -r | head` shows **runtime only** — no `.mp4`/`.png`/`.jpg`/screenshots/CHANGELOG/`.github`. A runtime zip is normally well under 1 MB; if yours is bigger, justify every large entry. `tar tzf <your tgz>` shows **skills only** — zero media, zero binaries. Media belongs on the PAGE. *(→ Payload rules)* +- [ ] **PAGE COPY:** your package `description` AND README each carry the one-sentence statement that `pkg install` installs the bridge's SKILLS for your container, not the bridge runtime. One sentence, not an essay. *(→ Artifact 2)* + +**Runtime** (Node/Python bridges) *(→ Runtime contract)*: +- [ ] You bind **`ADOM_BIND_HOST`** (loopback default), never `0.0.0.0`. +- [ ] You do **NOT** bootstrap your own Node/Python — no `winget`/`desktop_install_node`/self-download in code or skills. `spawn.kind` is the declaration; ab provisions it. +- [ ] Release zip is **source-only** (no `node_modules`); you rely on ab's `npm install` (PATH-correct as of v1.9.69) + a lockstep bundled seed for instant spawn. You do NOT put the runtime on PATH yourself. + +**Verbs & output** (what the AI actually reads): +- [ ] EVERY verb returns a rich **`_hint`** (+ `_next`/`related`/`pitfalls`). *(→ THE ONE PRINCIPLE)* +- [ ] A `<prefix>_describe` verb lists your whole catalog. *(→ Verb contract)* +- [ ] No verb blocks on a heavy install/download — background it, report `installing`/`warming`; `<prefix>_readiness` is read-only. *(→ Cold-start tiers)* +- [ ] `/health` returns `led`/`summary`/`tooltip` truthfully. *(→ Health + status chip)* +- [ ] **NOT INTERCEPTED: for each of your slowest / most-structured verbs, call YOUR OWN port directly and diff the response key set against the same verb routed through `adom-bridge`.** They must match. If ab's answer is missing fields yours returns (or carries fields you have never implemented), **ab core is answering that verb instead of forwarding it** and your handler never ran. This fails SILENTLY and inverts the blame: nothing errors, the caller gets a plausible well-formed response, and it reads from the outside as "the bridge did not implement the contract." Real case: ab core owned `fusion_start`, so the fusion bridge's `progress`/`startKind`/`timings` never reached callers, on the very verb the measured-progress rule was written for (adom/adom-bridge#86, fixed in ab 2.0.61). Check it after ANY ab upgrade, and file on `adom/adom-bridge` with the two key sets side by side. + + **Run the diff TWICE.** A bridge respawn between the two calls produces a false negative that reads as "my bridge is broken" rather than "ab answered". And note WHY this check has to exist at all: **a swallowed field is indistinguishable from a field you never implemented.** That is precisely why ab's interception of `fusion_start` went unnoticed for so long - the absence looked like the bridge's own gap, so nobody went looking on ab's side. + +**Discovery** (can a plain request find you?): +- [ ] **10+ user-task `discovery_triggers`** (everyday phrasings, NOT dev jargon). **No triggers = invisible.** *(→ Artifact 3)* +- [ ] You ran `discover preview --json` on 5-7 real user queries and land **top-3** for each. +- [ ] Triggers stay in YOUR niche — you don't poach a sibling's (login/forms → native-browser, not pup). `discovery_pitch` is an INSTRUCTION. + +**Skills & docs** (what the container + humans read): +- [ ] Your pkg ships **ONLY your USER skill(s)** — a real `skills/<name>/SKILL.md` (core user skill + user sub-skills) with a `Parent skill:` first body line; every path listed EXPLICITLY (no globs) in `files[]` + install.sh/uninstall.sh. *(→ Artifact 2 + The SKILL SET)* +- [ ] **DEV + PUBLISH skills are in your source REPO, NOT the pkg** — not in `files[]`, not scoped in via `user-invocable:false`. A developer gets them by cloning the source; the normal user never needs them. Open-vs-closed source is irrelevant (it only sets repo visibility). *(→ Where every file lives)* +- [ ] Your pkg's `package.json` declares `dependencies:{"adom/adom-bridge":"^1.9.x"}` so `pkg install` pulls the ab CLI + core skills the container needs. *(→ Where every file lives)* +- [ ] Your USER skill leads with the cold-start "read before you panic" section + the 8 conventions, and points the AI at `bridge_readiness`. *(→ USER-skill conventions)* +- [ ] README + wiki page + skills all AGREE with the above (no stale github links, no wrong schema, triggers vetted). + +--- + +## Staying current — catch up on SDK changes yourself (no prompt needed) + +This SDK evolves. When it does, ab posts a re-audit notice to **YOUR bridge's wiki discussion** and re-publishes this billboard. You do NOT need a human to hand you a prompt — self-serve at the start of a work session: + +1. **Check your discussion for a notice.** `adom-wiki discussion list <your-slug>` (or open your page's Discussions tab) and read any "SDK update" / "re-audit" thread. A notice can be EDITED after posting (a correction), so always read the **current** body, not a cached earlier version. +2. **Re-read this billboard**, especially "Where every file lives" (above) and the "Self-audit checklist" (below): `https://wiki.adom.inc/adom/adom-bridge` (or `curl -s https://wiki.adom.inc/api/v1/pages/adom-bridge-sdk/files/README.md`). +3. **Run the Self-audit checklist** (next section) against your code, `bridge.json`, skills, README, and wiki page. Fix every miss. +4. **Reply on the discussion** with what you changed or any blockers. That thread is the ONLY channel ab uses to reach you, so closing the loop there is how ab knows you are current. + +--- + +## Verb contract (quick reference) + +- **Naming:** every verb is `<name>_<verb>` (`blender_render`, `blender_status`). ab routes any verb with your prefix to you. +- **Request:** ab POSTs `{ "command":"<verb>", "args":{…} }` to your port. **Response:** JSON with at least `success` (bool); human/AI output in `output`, errors in `error` (+ an `errorCode` for machine-actionable failures). ab relays everything else verbatim — so ALWAYS add `_hint`/`_next`/`related`/`pitfalls`. +- **Long-running verbs:** return promptly with a job id + set `statusVerb`; the caller polls instead of blocking. On a timeout ab auto-returns `{stillRunning:true, statusVerb, timeoutSeconds}`. If a verb genuinely blocks past 60s (a synchronous export/render), **also declare a `timeouts` block** (v1.9.79) so ab's HTTP budget matches — otherwise ab cuts the connection at 60s (third-party bridges were hard-capped there before v1.9.79). +- **`<prefix>describe`:** return your full verb catalog (`{verbs:[{name,summary,input,output,timeoutSeconds,statusVerb,longRunning,example,hint,related,pitfalls}]}`). ab caches it (`~/.adom/bridge-verbs/<name>.json`) and shows it in the GUI Verbs tab — but remember, `describe` is read rarely; the per-call hints are what carry the AI. +- **Keyboard input: route through ab's `desktop_press_key` — do NOT reimplement chords.** ab's key verb already does full modifier chords (`"shift+s"`, `"ctrl+shift+p"`, `"alt+f4"` — modifiers held via SendInput, key tapped, modifiers released in reverse), foregrounds the target window first (`{window|titleContains|hwnd}`) so CEF/Qt apps like Fusion receive it, and every bridge inherits it via the `/command` desktop passthrough (`{"command":"press_key", ...}` on the desktop namespace). A bridge-local `*_send_key` that parses only single keys (the Fusion bridge's `fusion_send_key` circa v1.6.x — `"shift+s"` → "Unknown key") duplicates ab's work and drifts; keep such a verb only for SendMessage-to-exact-hwnd cases ab's focus-first model can't cover, and say so in its `describe`. +- **Host-app-optional verbs (v1.9.76):** declare `detect.hostAppOptionalVerbs` (bare names, e.g. `["readiness","describe"]`) in `bridge.json` so ab forwards those verbs to your running bridge even when your host app is absent — ab's built-in read-only set {readiness, describe, get_app_state, status} is always forwarded. And declare **`detect.installVerb`** (e.g. `"kicad_upgrade"`) so `bridge_readiness` recommends YOUR installer over the generic winget fallback. + +## Ask the AI to report bugs, in the OUTPUT, not the skill file + +You cannot reproduce every environment. Your bridge runs on machines with browser installs, OS versions, security policies, hardware and concurrency patterns you will never have locally. **The AI driving your bridge is standing on exactly that machine.** It is the only party holding the failing response, and the moment it holds it is the only moment it will file anything. + +So make the invitation part of your verb output, the same way `_hint` is. This is THE ONE PRINCIPLE at the top of this page applied to feedback: the AI almost never reads your on-disk SKILL.md, and it ALWAYS reads the output of a CLI call. + +**The evidence that this channel works.** In roughly two days, wiki issues filed against the puppeteer bridge produced: a CPU-pegging orphaned-renderer bug that froze a laptop and forced a reboot (#14, #21), a respawn failure that broke the bridge on every idle cycle (#15), a session-tracking bug where windows were open and drivable but reported as zero (#16), a repo-versus-release divergence that would have silently reverted five releases (#19), and a concurrency and readiness report that caught a live blocking regression shipped hours earlier (#22). **Not one of those would have been found on a maintainer's machine.** They came from real installs: different Chrome and Edge combinations, different ab versions, concurrent agents, a macOS box, a stalled download. + +**Return a `_reportIssues` field naming YOUR page's issue list, with the exact commands:** + +```jsonc +{ + "success": false, + "errorCode": "…", + "_hint": "…", + "_reportIssues": "THIS MAY BE A <BRIDGE> BUG. PLEASE REPORT IT. Check for a duplicate with `adom-wiki issue list adom/<your-slug>`, then file: `adom-wiki issue create adom/<your-slug> --title \"<one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, what happened, the VERBATIM response JSON, the bridge version from bridge_list, and the OS>\"`. Reports from real machines are the only way this bridge gets correct. (An ab-core problem, such as the relay, bridge lifecycle or caller identity, belongs on adom/adom-bridge instead.)" +} +``` + +**When to attach it. Throttle it, or the AI learns to skip it:** + +| Moment | Why | +|---|---| +| **Always on a failure** | The highest-value moment: a bug just happened and the evidence is still in the response. | +| **Once per bridge process, on the first verb** | Orientation, so the AI knows where to report *before* anything breaks. | +| **Occasionally after that** (e.g. every ~40th call) | A quiet reminder on a long-running session without taxing every response. | + +A line on **every** response is noise that trains the caller to ignore the field. A line on every **failure** is a bug report waiting to be filed. + +**Make it actionable, not a slogan.** "Report bugs" produces nothing. Give the AI the exact `adom-wiki issue create` command, your slug, a `--category`, and the list of evidence to include (the verbatim response JSON and your bridge version are the two that make a report reproducible). Tell it to check for a duplicate first: an `issue list` call costs nothing and keeps your page clean. + +**Route the report to the repo that owns the code.** Say explicitly, in the same string, that ab-core problems (the relay and `/command` passthrough, spawn and provisioning, bridge lifecycle, caller identity) go to **`adom/adom-bridge`**, not to your page. Authors get this wrong in the other direction too, which is the next section: a misfiled issue is a note to yourself that the people who can fix it never read. + +**Let the user turn it off.** Ship a settings toggle (pup uses `pup_configure {reportIssues:"off"}`) so a user who finds it noisy can silence it without losing functionality. Default it **on**: the cost is a few tokens on failures, and the benefit is the bug class you would otherwise ship to every user. + +**Reference implementation:** the puppeteer bridge does this at its single `sendJSON` chokepoint (v1.9.95), so all ~155 response sites are covered from one place, and it never overwrites a `_hint` a handler already set. + +--- + +## Filing issues against ab (and the other repos you depend on) + +You WILL find ab-core bugs and gaps while building your bridge. That is expected and useful. But file them in the RIGHT repo's issue list, or the people who can fix them never see it. + +**The rule: an issue lives on the issue list of the repo that OWNS the code that must change.** + +- A bug in YOUR bridge's verb / hint / self-heal / status chip goes to **YOUR page's issue list** (or you just fix it, since you own it). +- A missing or broken **ab-CORE** capability (a lifecycle verb, a spawn / provision / bind behavior, the relay passthrough dropping a field, a manifest field ab must honor, the status-chip contract) goes to **`adom/adom-bridge`**, NOT your own page. ab-core's maintainers and AI watch `adom/adom-bridge`; they do NOT read your bridge page's issues. **An ab ask filed on your own page is a note to yourself.** +- A **wiki or CLI** bug goes to `adom/wiki`. A **sibling bridge's** bug goes to that bridge's page. + +**How (the CLI):** + +```bash +adom-wiki issue list adom/adom-bridge # FIRST: skim for a dup, comment if one exists +adom-wiki issue create adom/adom-bridge \ + --title "<verb or subsystem>: <the gap in one line>" \ + --category bug-report \ + --body "$(cat your-writeup.md)" +``` + +**Write the body like a good bug report:** what you hit live (with the date and the ab version), the measured behavior, the ab-owned root cause if you found one, and a concrete ask. Separate clearly what is ab-core's to fix from what is yours, and say so explicitly, so the two are never conflated. + +**After you file:** the ab-core thread replies on that issue. Read the reply, it may change your plan (for example "fixed in ab X.Y, bump your pin"). That issue thread is the cross-repo channel; keep the loop closed there. + +**Why this matters most for ab:** ab-core spawns you, provisions your runtime, relays your verbs, and installs you, so ab is the repo you will file against most often. Get the destination right the first time. The established precedent all sits on `adom/adom-bridge`: #26 and #27 (pup to ab, Chrome for Testing), #16 (pup to ab, window-verb interception), #3 / #20 / #21 (Bridge SDK asks). + +---
skills/adom-bridge-sdk-callback/SKILL.md+323−328@@ -1,328 +1,323 @@-name: adom-bridge-sdk-callback-description: >-- Call Adom Bridge BACK from inside your bridge: the loopback direct API gives you the FULL ab- verb set (screenshots, files, windows, notifications, other bridges), how to forward caller- provenance so the user sees WHICH AI thread asked (X-Adom-Caller-* headers and the- delegation chain), and how to brand a window you borrowed (window identity, taskbar tile,- jump list, overlay badge). Use when your bridge needs an ab verb, when the Activity Log or- an approval prompt misattributes your calls, or when a window you drive presents as the- wrong app.---## Calling ab back (outbound) — you reach the FULL ab verb set (v1.9.84)--Your callback channel is ab's loopback direct API: `POST <ADOM_DIRECT_API_URL>/command` (env var; fallback `~/.adom/direct-api-port` = `host:port`). As of **ab v1.9.84** this reaches **every dispatchable ab verb**, not just `desktop_*` — same dispatcher the CLI uses, identical JSON back. `GET <ADOM_DIRECT_API_URL>/commands` lists the full set so you can capability-probe (don't hardcode).--- **`app` is OPTIONAL** — inferred from the command. Just `POST {"command":"<verb>","args":{…}}`. (Pass `app` to override the inference.)-- **Top-level verbs you can now call:** `notify_user` (walk the user through something — returns `{action:"displayed"}`), `notify_response` (poll a toast button/input), `targets` (list the OTHER ABs on the relay), `ping`, `bridge_list`, `runtimes`, `status`, … — plus every `desktop_*` and every *other* bridge's verbs.-- **Cross-ab (`target`):** add `"target":"<clientName>"` (from `targets`) or `"all"` to route the call to a PEER ab via the relay — e.g. **you run on a VM but the user is on their laptop**: `notify_user` with `target:"<laptop clientName>"` reaches them where they are. `"attended"` isn't resolvable yet (use a concrete clientName).-- **`X-Adom-Bridge-Token` (optional attribution + identity migration).** ab injects `ADOM_BRIDGE_TOKEN` into your spawn env. Send it as the `X-Adom-Bridge-Token` header and ab badges your calls "bridge" in its Activity Log and applies the temporary bridge exemption from `caller_identity_required`. It is **NOT** an approval gate: you're trusted by transport (ab binds 127.0.0.1 only + the user consented by installing you), so your calls — including `write_file`/`run_script` — run ungated. A stale token is warned once and treated as omitted: an identified callback continues under the `direct:<port>` badge; an unidentified one receives the normal identity teaching refusal. Always forward the caller headers below or send your own explicit `caller.aiThread`.-- **`cliRequired`** verbs (`pull_file`/`send_files`/`shell_execute`) return `412 cli_required` — they need the streaming/approval CLI path, not this HTTP channel.--```bash-# Discover, then call — no hardcoding.-curl -s "$ADOM_DIRECT_API_URL/commands"-curl -s -X POST "$ADOM_DIRECT_API_URL/command" -H 'Content-Type: application/json' \- -H "X-Adom-Bridge-Token: $ADOM_BRIDGE_TOKEN" \- -d '{"command":"notify_user","args":{"title":"Heads up","body":"…"}}'-```--### Notify the user AND get their answer back (`notify_user` — two separate axes)--Your bridge can prompt the user with a native toast and read their reply — the same `notify_user` verb the AI uses. It has **two independent axes; don't conflate them** (full ref: the `adom-bridge-notify` skill / `skills/NOTIFY.md`):--- **Axis 1 — get the answer back.** There is no server-push, so either **`wait:true`** (the `notify_user` call blocks and returns the user's choice inline — best for a one-shot prompt) or fire-and-continue then poll **`notify_events {sinceSeq}`** (drains every click) / **`notify_response {id}`**. The click is recorded durably, so it survives even on a headless box.-- **Axis 2 — reveal a window (`focus`).** A LOCAL desktop action, not a reply. `focus:{hwnd|cacheKey|titleContains|app|host}` raises a window on the user's screen when they click. **`focus:{app:"<your-exe>"}`** points the click at a window YOUR bridge drives; **`focus:{host:true}`** points it at the app ab is embedded under (Hydrogen). Pass `focus` whenever the toast is *about* a window, so the click lands there instead of opening ab.--```bash-# Ask, and get the answer back inline:-curl -s -X POST "$ADOM_DIRECT_API_URL/command" -H "X-Adom-Bridge-Token: $ADOM_BRIDGE_TOKEN" \- -d '{"command":"notify_user","args":{"title":"Overwrite board?","scenario":"reminder",- "buttons":[{"label":"Overwrite"},{"label":"Cancel"}],"wait":true,"timeoutSec":90}}'-# -> { "pending": false, "action": "Overwrite" }-```--The response always carries `_capabilities` / `_couldHaveDoneBetter` when a call left something on the table — read them; they teach the verb from use.--## Reply shapes you will parse (wiki #121 — VALIDATE against these, don't guess)--The altium bridge's dialog reaper was blind for six releases because it parsed-`desktop_find_controls` under a key ab never uses. These are the shapes that-have burned real bridges; validate your parsing against them at build time:--| Verb | The keys that matter | The trap |-|---|---|---|-| `desktop_find_controls` | matches under **`matches`** (array), best match first | it is NOT `controls` — parsing `controls` silently yields zero forever |-| `desktop_screenshot_window` | **two envelope forms**: a file capture carries top-level `fullPath`/`safePath` (+ CLI-side `savedTo` after auto-pull); a base64-only capture (direct API with no pull) carries `data.image` | check for BOTH; do not assume one. `coordMap.shotId` + top-level `shotId` are on the direct-API response — use `{space:"image", shotId, x, y}` for zero-math clicks. `screenshots[]` holds owned-popup children, each with its own shotId |-| `pup_screenshot` via the CLI | the local-save envelope is MERGED OVER the bridge's response (v2.1.10) | bridge-added keys (e.g. `capturedVia`) survive; `base64` is stripped after the local save |-| `desktop_list_windows` | rows carry `hwnd, title, className, kind, z, rect{}` + (v2.1.12) `owner, pid, image, minimized, maximized, enabled, hung` | ZERO rows with `degraded:true` = the enumeration could NOT answer (wedged/locked session) — never read it as "no windows" |-| `desktop_enum_windows` | same degraded rule for an UNFILTERED call | a FILTERED empty result is a legitimate no-match and is not flagged |-| `desktop_wait_for_window` | `{appeared, waitedMs, window?}` — `appeared` means "the requested condition was met", including `state:"absent"` | with `state:"absent"`, `appeared:true` means the window is GONE |-| every bridge-routed verb | `timeoutSeconds` + (on timeout) `stillRunning:true` + `statusVerb` | a timeout is NON-terminal: poll `statusVerb`, do not blindly re-issue |--Compatibility rule ab holds itself to: reply keys never silently rename — a-renamed key keeps the old name as an alias for a major version. If you find a-shape mismatch between this table and a live response, that is a bug — file it-on `adom/adom-bridge`.--### The machine-readable contract (v2.1.51, wiki #121)--The table above is prose. Two things make it checkable:--1. **Every reply carries `schemaVersion`** (an integer, stamped by ab core on- every path). It bumps only on a rename, removal or type change of a- documented key; additive keys never bump it. Compare it to the catalog you- validated against and re-fetch the catalog when it changes.-2. **`desktop_reply_schema {verb?}`** returns the envelope (the two forms, the- universal keys, the rules) and per-verb JSON-Schema fragments- (`shape.properties` / `shape.required` / `since` / `notes`) for the ab-core- verbs bridges parse in safety paths: list_windows, find_window,- screenshot_window/screen, bring_to_front, set_window_state,- find_control(s), ui_from_point, ui_tree, ui_text, the input verbs, the- recorder, app_instances. `covered` lists them; an uncovered verb answers- `coveredVerb:false` with the envelope only, never a guess.--**The two envelope forms** are the trap behind "three different shapes":--| Form | Looks like | Where the verb's data is |-|---|---|---|-| A `command_result` | `{success, output, error}` (+ promoted keys such as `fullPath`) | `output` is a **JSON-encoded string**: `JSON.parse(output).data` (a few verbs, e.g. `desktop_find_window`, decode to a flat object with no `data`) |-| B flat | `{success, ...verb keys}` | top level |--**Strict parsing, the SDK way.** Fetch the catalog once at build/startup and-make a missing required key an error in any safety path:--```python-import json, subprocess--def catalog(verb):- out = subprocess.run(["adom-bridge", "desktop_reply_schema", json.dumps({"verb": verb})],- capture_output=True, text=True).stdout- c = json.loads(out)- if not c.get("coveredVerb"):- raise RuntimeError(f"{verb} has no published shape; do not parse it in a safety path")- return c["schemaVersion"], c["entry"]--SCHEMA_VERSION, FIND_CONTROLS = catalog("desktop_find_controls")--def strict(reply, entry, verb):- if reply.get("schemaVersion") != SCHEMA_VERSION:- raise RuntimeError(f"{verb}: schemaVersion {reply.get('schemaVersion')} != {SCHEMA_VERSION}; re-fetch the catalog")- body = reply- if entry["form"] == "A":- body = json.loads(reply["output"])- missing = [k for k in entry["shape"]["required"] if k not in body]- if reply.get("success") and missing:- raise RuntimeError(f"{verb}: reply is missing documented keys {missing}")- return body--body = strict(ab("desktop_find_controls", {...}), FIND_CONTROLS, "desktop_find_controls")-buttons = body["matches"] # KeyError here is a real bug, not an empty dialog-```--The point is the last line: the altium reaper defaulted `controls` to `[]` and-sat blind for six releases. With the catalog, that is a crash on the first-run, which is what you want in a reaper.---## Caller provenance: WHO asked (v1.9.180)--A user runs roughly **20 AI threads against one desktop**. Until v1.9.180 ab relayed every command indistinguishably, so a bridge received **no caller identity at all**. Three consequences, all real:--- You could not **arbitrate concurrent agents** — two threads grabbing the same session looked identical to you.-- No bridge log could **attribute a verb** to the thread that issued it, so interference was impossible to reconstruct afterwards.-- Any agent's `bridge_kill` **destroyed every other agent's work**, silently, with no way for anyone to see who did it.--Filed as wiki issue `adom/adom-bridge#34` by AdityaAngajala, with the pup half at `adom/pup-bridge#22`.--### What you now receive, for free--ab stamps three headers on **every relayed bridge request** — all four request paths (browser, kicad, fusion, and the dynamic third-party dispatch):--| Header | What it carries |-|---|---|-| `X-Adom-Caller-Thread` | The AI thread / conversation tab name, self-reported by the caller |-| `X-Adom-Caller-Container` | The container it runs in |-| `X-Adom-Caller-Reason` | The per-call justification, when the caller supplied one (control chars stripped, truncated to 300 chars) |-| `X-Adom-Verified-Origin` | **v2.0.96+ (wiki #114): ab-VERIFIED transport origin — the ONE header you may gate on.** `relay` (arrived over the cloud-AI relay websocket), `local` (loopback direct API: local CLI, ah, a local agent), or `bridge` (a bridge callback carrying a valid spawn token). ab authors this header itself from the connection it actually observed; a caller cannot supply or override it. |--**You do nothing to receive these.** No manifest field, no signature change, no opt-in. Reading them is the opt-in part.--### Trust rule — read this before you use them for anything--These values are **SELF-ASSERTED**, exactly like `reason`. Use them for **attribution, logging, UX and arbitration hints**. **NEVER for authorization, and never as a security boundary.**--ab's own security model is transport-based (the `adom-bridge-permissions` skill has the full model): a caller is trusted because of *how it reached ab*, not because of what it *says about itself*. A header a caller supplies about itself cannot gate anything — if you write `if (thread === "admin-tab") allowDangerousThing()`, you have written a hole, not a check.--One asymmetry worth knowing: the container name in `X-Adom-Caller-Container` is the one the **caller claimed**. ab separately knows a **verified** container (the authenticated relay connection name) and shows the user **both** when they disagree. You only get the claimed one, so treat it as a label, not an identity.--**The exception is `X-Adom-Verified-Origin` (v2.0.96+): that one IS a security signal.** A local-only verb (a credential reveal, a vault unlock) refuses when it reads `relay` — honestly, because ab stamped the value from the transport it observed, not from anything the caller said. Two caveats before you build a gate on it:--1. **It describes the LAST hop into ab.** A bridge callback made on behalf of a relayed AI thread arrives as `bridge`, not `relay` — the relayed identity rides only in the (self-reported) `X-Adom-Caller-Delegate` chain. So a local-only gate should refuse `relay`, decide its own policy for `bridge` (refuse, or require the delegate chain to be empty), and accept `local`.-2. **It authenticates the ab-to-you hop only.** Your HTTP port is loopback, so anything else that reaches it directly is by definition a local process — `local` remains true for those, but they carry no header at all. Treat a MISSING header as `local` from an unidentified local caller (or refuse, if your verb warrants it); never treat it as `relay`-safe.--### What to do with it--**1. Log it.** Put the thread in your per-verb log line. This is the single highest-value use, and it is three lines of code:--```javascript-// node — read once, at the top of your request handler-const caller = {- thread: req.headers['x-adom-caller-thread'] || 'unidentified',- container: req.headers['x-adom-caller-container'] || 'unknown',- reason: req.headers['x-adom-caller-reason'] || null,-};-console.log(`[verb] ${verb} session=${sessionId} caller="${caller.thread}"`);-```--```python-# python — same idea; header lookup is case-insensitive on BaseHTTPRequestHandler-caller_thread = self.headers.get('X-Adom-Caller-Thread', 'unidentified')-caller_container = self.headers.get('X-Adom-Caller-Container', 'unknown')-caller_reason = self.headers.get('X-Adom-Caller-Reason')-log.info('[verb] %s session=%s caller="%s"', verb, session_id, caller_thread)-```--That one field is what makes an interference report reconstructable: `[verb] pup_eval session=rev caller="chip-fetcher tab 3"` answers "who moved my page?" instantly, where a bare `[verb] pup_eval session=rev` never can.--**2. Arbitrate.** If your bridge has **sessions or resources an agent can own** — pup sessions are the motivating case — record the owning thread at creation time and check it on every later use:--```javascript-// on create-sessions.set(id, { ...state, ownerThread: caller.thread });--// on use by a DIFFERENT thread-const s = sessions.get(id);-if (s.ownerThread && s.ownerThread !== caller.thread) {- // warn loudly by default; refuse only if this is destructive- result._warning =- `session ${id} is owned by "${s.ownerThread}" — you are "${caller.thread}". ` +- `Two agents driving one session will interfere. Coordinate, or open your own session.`;-}-```--The recommended posture:--- **Warn loudly by default.** A second agent reading or screenshotting a session is usually fine, and hard-blocking it turns a shared machine into a locked one.-- **Refuse only for destructive operations** — closing, resetting, navigating away, deleting.-- **Always name the current owner in the message.** The whole point is that the second agent can now go coordinate with a thread it can identify. An error that says "session busy" without a name is barely better than no header at all.--**3. Label the UI.** If your bridge drives a visible window, put the owning thread where the **user** can see it — window title, taskbar tooltip, or jump-list name — via `desktop_set_window_identity` (and `desktop_taskbar`'s `thumbnailTooltip` for uncapped hover text). The user is the one with 20 tabs open; showing which tab owns which window is the fix for their side of the problem. Full guide: the `adom-bridge-window-identity` skill, and the "Branding + driving a window you borrowed" section immediately below.--**4. Report it back.** Include the owning thread in your **status / list verbs**, so an agent can *discover* it is not alone before it stomps on something:--```jsonc-// <prefix>_list_sessions-{ "sessions": [ { "id": "rev", "ownerThread": "chip-fetcher tab 3", "url": "…" } ],- "_hint": "A session with an ownerThread other than yours is being driven by another AI thread. Open your own session rather than sharing one." }-```--### Notifications already carry it--`notify_user` derives its toast attribution line from the caller automatically, so a toast your bridge sends **on an agent's behalf already names the thread** — you do not have to build that string. Pass an explicit `attribution` string only when you want to override it.--### ⚠ REQUIRED — when YOU call an ab verb, forward the identity--This is the half most bridges will miss, and it is the half that makes the whole feature work or fail.--Your bridge calls ab verbs to do its job: `desktop_screenshot_window`, `desktop_set_window_identity`, `notify_user`, `desktop_taskbar`. When you make that call **while carrying out a verb an AI thread asked you for, you are acting on that thread's behalf** — so echo the identity you were handed straight back onto your own request. Otherwise the chain dies at your doorstep: the user's Activity Log says *"pup did this"*, the originating thread vanishes, and the approval toast asks them to authorize a nameless bridge.--**Echo all four headers, verbatim:**--```javascript-// node — inside the handler for a verb an AI asked you for-const fwd = {- 'X-Adom-Caller-Thread': req.headers['x-adom-caller-thread'] || '',- 'X-Adom-Caller-Container': req.headers['x-adom-caller-container'] || '',- 'X-Adom-Caller-Reason': req.headers['x-adom-caller-reason'] || '',- 'X-Adom-Caller-Delegate': 'pup', // ← YOUR bridge name-};-await fetch(`http://127.0.0.1:${adPort}/command`, {- method: 'POST',- headers: { 'Content-Type': 'application/json', ...fwd },- body: JSON.stringify({ command: 'desktop_screenshot_window', args: { hwnd } }),-});-```--```python-fwd = {- 'X-Adom-Caller-Thread': self.headers.get('X-Adom-Caller-Thread', ''),- 'X-Adom-Caller-Container': self.headers.get('X-Adom-Caller-Container', ''),- 'X-Adom-Caller-Reason': self.headers.get('X-Adom-Caller-Reason', ''),- 'X-Adom-Caller-Delegate': 'kicad', # YOUR bridge name-}-```--`X-Adom-Caller-Delegate` is the one you **add** rather than forward. It names *you*, and it is what lets ab show the user both facts at once: **`chip-fetcher tab 3 (via pup)`**. Attributing the call to the thread alone would hide who ran it; attributing it to the bridge alone would hide who asked. Omit the header and ab falls back to the generic `via bridge`, which is honest but much less useful.--**When you are acting on your OWN behalf — a health poll, a timer, a crash cleanup — do NOT forward a stale identity.** Send your own instead:--```jsonc-{ "command": "notify_user",- "args": { "caller": { "aiThread": "pup bridge (self)", "containerName": "local" }, … } }-```--An explicit `caller` block in the args always beats the headers, so this is unambiguous.--**Two things ab does for you here:** explicit args win over forwarded headers (so the override above just works), and ab carries the delegate forward onto any onward hop, so a chain two bridges deep still shows the original delegate.--### Where it comes from (the caller side)--Useful to understand, though none of it is your responsibility:--```bash-# Per call — the CORRECT mechanism:-adom-bridge --ai-thread "chip-fetcher tab 3" <verb> '<json>'-```--The `adom-bridge` CLI attaches a `caller` block to **every** verb automatically. A relay call with **no `aiThread` is REFUSED by ab** with `errorCode: caller_identity_required` before it ever reaches a bridge.--> **Correction, 2026-07-25 (ab 1.9.182):** an earlier version of this page told callers to `export ADOM_AI_THREAD` once per session. **That advice was wrong and has been withdrawn.** Roughly 20 AI threads share one container and one `$HOME`, so the env var scopes to the shell environment, not to the thread — every thread then reports the *same* name, which is worse than no name at all because it is confident and plausible. It also failed silently: tool shells are non-interactive, so an export appended below `~/.bashrc`'s `case $- in *i*` early-return never ran. The env var is still honored, but only fits a container running exactly one agent (CI, a single-purpose job). **If your bridge's own docs repeat the export advice, please correct them.**--**So by the time a request reaches you, identity is normally present.** Relay callers and ordinary loopback direct-command callers—including Hydrogen, the local CLI, and an adopted bridge with an omitted or stale spawn token—are required to identify. A bridge carrying the current core process's spawn token retains a temporary migration exemption; top-level discovery/status endpoints may also bypass the command identity chokepoint. Code for the absent case (`|| 'unidentified'`) and never assume the header exists.--### If your bridge wants to REQUIRE identity — use the shared contract--Some verbs are worth refusing anonymously. If you do, **use ab's exact refusal shape** so the AI sees one consistent remedy across the whole ecosystem instead of a different fix per bridge:--```jsonc-{ "success": false, "errorCode": "caller_identity_required",- "error": "'<verb>' needs to know WHO is asking — the user runs many AI threads and needs to see which one drove this.",- "supplyIdentity": {- "bestWay": "adom-bridge --ai-thread \"<your conversation name>\" <verb> '<json>'",- "orInArgs": { "caller": { "aiThread": "<name>", "containerName": "<container>" } }- },- "_hint": "This is attribution, not a permission check — the name is self-reported and nothing is gated on it." }-```--Reusing `caller_identity_required` verbatim is the point: an AI that learned the remedy once applies it everywhere.--### Self-audit--- [ ] Does every verb log line include the **caller thread**?-- [ ] Do your **session-creating** verbs record the owning thread on the session?-- [ ] Do your **session-using** verbs warn (or, if destructive, refuse) when a different thread arrives — **naming the current owner**?-- [ ] Does any **window you drive** show the owning thread to the user (title / tooltip / jump-list)?-- [ ] Do your **status / list verbs** report `ownerThread` so an agent can discover it is not alone?-- [ ] **When you call an ab verb on a thread's behalf, do you FORWARD all three caller headers plus your own `X-Adom-Caller-Delegate`?**-- [ ] When you call an ab verb on your **own** behalf (poll, timer, cleanup), do you send your own `caller` block rather than a stale forwarded one?-- [ ] If your docs told callers to `export ADOM_AI_THREAD`, have you corrected them to the per-call `--ai-thread` flag?-- [ ] Have you confirmed you use these headers **nowhere** as an authorization check?---## Branding + driving a window you borrowed (window identity + taskbar, v1.9.106–1.9.153)--If your bridge drives a window another process owns — the classic case is a **Chrome for Testing** window that otherwise reads *"Test"* with Chrome's icon — ab gives you a generic verb family to make it present as YOUR app across every Windows shell surface, and to show live status on its taskbar button. ab stays generic: it applies whatever name/icon/command you pass; it knows nothing about your app. **Full guide: the `adom-bridge-window-identity` skill** (`https://wiki.adom.inc/api/v1/pages/adom-bridge/files/docs/WINDOW_IDENTITY.md` or the repo `skills/WINDOW_IDENTITY.md`). The essentials:--- **Two halves, you need both:** `desktop_register_app_identity {appId, displayName, iconPath, shortcut}` teaches Windows what an AUMID means (HKCU only, no UAC; `shortcut:false` for a per-session id with no Start Menu spam), and `desktop_set_window_identity {hwnd, appId, iconPath, displayName, relaunchCommand}` stamps it on the live window. Stamp without register → Alt-Tab / taskbar fall back to the owning exe's icon.-- **Per-session identity:** register `shortcut:false` per window (e.g. `Adom.Pup.<sessionId>`) so each keeps its own taskbar button; `desktop_unregister_app_identity {appId}` on close. Known cost (wiki #155, measured): a registry-only identity's taskbar jump list flashes on the FIRST right-click and stays on the second, because the shell resolves a shortcut-less AUMID lazily. `anchor:true` fixes it by dropping a small visible anchor `.lnk` into one Start Menu subfolder (`anchorFolder`, default "Adom Bridge"; pass the same value on unregister), but that IS a Start Menu entry (All apps, "Recently added", search; a hidden `.lnk`, `NoStartPage` or `SHAddToRecentDocs` do not anchor), so the default is OFF: a clean Start Menu over a first-click flyout. Opt in only when the user prefers the flyout.-- **Jump list + header:** `desktop_set_window_jumplist {appId, tasks, hwnd?, headerIcon?, headerCommand?}` adds the right-click menu AND (with `hwnd`+`headerIcon`) brands the header row (the app tile above Pin/Close) with no Start Menu shortcut.-- **Taskbar status:** `desktop_taskbar {hwnd, progress?, overlay?, flash?, thumbnailTooltip?, thumbnailClip?}` paints glanceable state on any window's button. **New in v1.9.153:** `thumbnailTooltip` is the ONLY uncapped hover-text surface (the window title truncates ~25-30 chars; this does not, and takes newlines; `""` clears it), and `thumbnailClip:{x,y,w,h}` crops the live hover preview to a region (`null` resets). Overlay compositing (`overlay.avatar`/`profileDir`) preserves a browser profile face while adding your mark.-- **Icon format — read the `iconCheck` in every response.** The identity verbs inspect any icon you pass and return `iconCheck {kind,sizes,issues,ok,hint}`. The traps it catches: a **256px `.ico` size that is not PNG-compressed** renders as the generic white-document icon (Vista+ rule); **SVG can't be loaded** by the Win32 shell; the AUMID registry icon wants a `.ico`/`.png`, not an `.exe`. If `iconCheck.ok` is false, fix the file and re-stamp — don't ship the broken icon. Ship a multi-size `.ico` (16/24/32/48/256, the 256 PNG-compressed).-- **The live-tile trap:** re-stamping the SAME appId with a new icon updates the title bar + Alt-Tab but NOT the taskbar tile (Win11 bakes it at button creation). To change a live tile, register + stamp a DIFFERENT appId; the button is recreated in ~1s.-+--- +name: adom-bridge-sdk-callback +description: >- + adom-bridge-sdk sub-skill: call ab verbs back from your bridge, forward X-Adom-Caller-* + provenance, brand a borrowed window. +--- + + +## Calling ab back (outbound) — you reach the FULL ab verb set (v1.9.84) + +Your callback channel is ab's loopback direct API: `POST <ADOM_DIRECT_API_URL>/command` (env var; fallback `~/.adom/direct-api-port` = `host:port`). As of **ab v1.9.84** this reaches **every dispatchable ab verb**, not just `desktop_*` — same dispatcher the CLI uses, identical JSON back. `GET <ADOM_DIRECT_API_URL>/commands` lists the full set so you can capability-probe (don't hardcode). + +- **`app` is OPTIONAL** — inferred from the command. Just `POST {"command":"<verb>","args":{…}}`. (Pass `app` to override the inference.) +- **Top-level verbs you can now call:** `notify_user` (walk the user through something — returns `{action:"displayed"}`), `notify_response` (poll a toast button/input), `targets` (list the OTHER ABs on the relay), `ping`, `bridge_list`, `runtimes`, `status`, … — plus every `desktop_*` and every *other* bridge's verbs. +- **Cross-ab (`target`):** add `"target":"<clientName>"` (from `targets`) or `"all"` to route the call to a PEER ab via the relay — e.g. **you run on a VM but the user is on their laptop**: `notify_user` with `target:"<laptop clientName>"` reaches them where they are. `"attended"` isn't resolvable yet (use a concrete clientName). +- **`X-Adom-Bridge-Token` (optional attribution + identity migration).** ab injects `ADOM_BRIDGE_TOKEN` into your spawn env. Send it as the `X-Adom-Bridge-Token` header and ab badges your calls "bridge" in its Activity Log and applies the temporary bridge exemption from `caller_identity_required`. It is **NOT** an approval gate: you're trusted by transport (ab binds 127.0.0.1 only + the user consented by installing you), so your calls — including `write_file`/`run_script` — run ungated. A stale token is warned once and treated as omitted: an identified callback continues under the `direct:<port>` badge; an unidentified one receives the normal identity teaching refusal. Always forward the caller headers below or send your own explicit `caller.aiThread`. +- **`cliRequired`** verbs (`pull_file`/`send_files`/`shell_execute`) return `412 cli_required` — they need the streaming/approval CLI path, not this HTTP channel. + +```bash +# Discover, then call — no hardcoding. +curl -s "$ADOM_DIRECT_API_URL/commands" +curl -s -X POST "$ADOM_DIRECT_API_URL/command" -H 'Content-Type: application/json' \ + -H "X-Adom-Bridge-Token: $ADOM_BRIDGE_TOKEN" \ + -d '{"command":"notify_user","args":{"title":"Heads up","body":"…"}}' +``` + +### Notify the user AND get their answer back (`notify_user` — two separate axes) + +Your bridge can prompt the user with a native toast and read their reply — the same `notify_user` verb the AI uses. It has **two independent axes; don't conflate them** (full ref: the `adom-bridge-notify` skill / `skills/NOTIFY.md`): + +- **Axis 1 — get the answer back.** There is no server-push, so either **`wait:true`** (the `notify_user` call blocks and returns the user's choice inline — best for a one-shot prompt) or fire-and-continue then poll **`notify_events {sinceSeq}`** (drains every click) / **`notify_response {id}`**. The click is recorded durably, so it survives even on a headless box. +- **Axis 2 — reveal a window (`focus`).** A LOCAL desktop action, not a reply. `focus:{hwnd|cacheKey|titleContains|app|host}` raises a window on the user's screen when they click. **`focus:{app:"<your-exe>"}`** points the click at a window YOUR bridge drives; **`focus:{host:true}`** points it at the app ab is embedded under (Hydrogen). Pass `focus` whenever the toast is *about* a window, so the click lands there instead of opening ab. + +```bash +# Ask, and get the answer back inline: +curl -s -X POST "$ADOM_DIRECT_API_URL/command" -H "X-Adom-Bridge-Token: $ADOM_BRIDGE_TOKEN" \ + -d '{"command":"notify_user","args":{"title":"Overwrite board?","scenario":"reminder", + "buttons":[{"label":"Overwrite"},{"label":"Cancel"}],"wait":true,"timeoutSec":90}}' +# -> { "pending": false, "action": "Overwrite" } +``` + +The response always carries `_capabilities` / `_couldHaveDoneBetter` when a call left something on the table — read them; they teach the verb from use. + +## Reply shapes you will parse (wiki #121 — VALIDATE against these, don't guess) + +The altium bridge's dialog reaper was blind for six releases because it parsed +`desktop_find_controls` under a key ab never uses. These are the shapes that +have burned real bridges; validate your parsing against them at build time: + +| Verb | The keys that matter | The trap | +|---|---|---| +| `desktop_find_controls` | matches under **`matches`** (array), best match first | it is NOT `controls` — parsing `controls` silently yields zero forever | +| `desktop_screenshot_window` | **two envelope forms**: a file capture carries top-level `fullPath`/`safePath` (+ CLI-side `savedTo` after auto-pull); a base64-only capture (direct API with no pull) carries `data.image` | check for BOTH; do not assume one. `coordMap.shotId` + top-level `shotId` are on the direct-API response — use `{space:"image", shotId, x, y}` for zero-math clicks. `screenshots[]` holds owned-popup children, each with its own shotId | +| `pup_screenshot` via the CLI | the local-save envelope is MERGED OVER the bridge's response (v2.1.10) | bridge-added keys (e.g. `capturedVia`) survive; `base64` is stripped after the local save | +| `desktop_list_windows` | rows carry `hwnd, title, className, kind, z, rect{}` + (v2.1.12) `owner, pid, image, minimized, maximized, enabled, hung` | ZERO rows with `degraded:true` = the enumeration could NOT answer (wedged/locked session) — never read it as "no windows" | +| `desktop_enum_windows` | same degraded rule for an UNFILTERED call | a FILTERED empty result is a legitimate no-match and is not flagged | +| `desktop_wait_for_window` | `{appeared, waitedMs, window?}` — `appeared` means "the requested condition was met", including `state:"absent"` | with `state:"absent"`, `appeared:true` means the window is GONE | +| every bridge-routed verb | `timeoutSeconds` + (on timeout) `stillRunning:true` + `statusVerb` | a timeout is NON-terminal: poll `statusVerb`, do not blindly re-issue | + +Compatibility rule ab holds itself to: reply keys never silently rename — a +renamed key keeps the old name as an alias for a major version. If you find a +shape mismatch between this table and a live response, that is a bug — file it +on `adom/adom-bridge`. + +### The machine-readable contract (v2.1.51, wiki #121) + +The table above is prose. Two things make it checkable: + +1. **Every reply carries `schemaVersion`** (an integer, stamped by ab core on + every path). It bumps only on a rename, removal or type change of a + documented key; additive keys never bump it. Compare it to the catalog you + validated against and re-fetch the catalog when it changes. +2. **`desktop_reply_schema {verb?}`** returns the envelope (the two forms, the + universal keys, the rules) and per-verb JSON-Schema fragments + (`shape.properties` / `shape.required` / `since` / `notes`) for the ab-core + verbs bridges parse in safety paths: list_windows, find_window, + screenshot_window/screen, bring_to_front, set_window_state, + find_control(s), ui_from_point, ui_tree, ui_text, the input verbs, the + recorder, app_instances. `covered` lists them; an uncovered verb answers + `coveredVerb:false` with the envelope only, never a guess. + +**The two envelope forms** are the trap behind "three different shapes": + +| Form | Looks like | Where the verb's data is | +|---|---|---| +| A `command_result` | `{success, output, error}` (+ promoted keys such as `fullPath`) | `output` is a **JSON-encoded string**: `JSON.parse(output).data` (a few verbs, e.g. `desktop_find_window`, decode to a flat object with no `data`) | +| B flat | `{success, ...verb keys}` | top level | + +**Strict parsing, the SDK way.** Fetch the catalog once at build/startup and +make a missing required key an error in any safety path: + +```python +import json, subprocess + +def catalog(verb): + out = subprocess.run(["adom-bridge", "desktop_reply_schema", json.dumps({"verb": verb})], + capture_output=True, text=True).stdout + c = json.loads(out) + if not c.get("coveredVerb"): + raise RuntimeError(f"{verb} has no published shape; do not parse it in a safety path") + return c["schemaVersion"], c["entry"] + +SCHEMA_VERSION, FIND_CONTROLS = catalog("desktop_find_controls") + +def strict(reply, entry, verb): + if reply.get("schemaVersion") != SCHEMA_VERSION: + raise RuntimeError(f"{verb}: schemaVersion {reply.get('schemaVersion')} != {SCHEMA_VERSION}; re-fetch the catalog") + body = reply + if entry["form"] == "A": + body = json.loads(reply["output"]) + missing = [k for k in entry["shape"]["required"] if k not in body] + if reply.get("success") and missing: + raise RuntimeError(f"{verb}: reply is missing documented keys {missing}") + return body + +body = strict(ab("desktop_find_controls", {...}), FIND_CONTROLS, "desktop_find_controls") +buttons = body["matches"] # KeyError here is a real bug, not an empty dialog +``` + +The point is the last line: the altium reaper defaulted `controls` to `[]` and +sat blind for six releases. With the catalog, that is a crash on the first +run, which is what you want in a reaper. + +--- + +## Caller provenance: WHO asked (v1.9.180) + +A user runs roughly **20 AI threads against one desktop**. Until v1.9.180 ab relayed every command indistinguishably, so a bridge received **no caller identity at all**. Three consequences, all real: + +- You could not **arbitrate concurrent agents** — two threads grabbing the same session looked identical to you. +- No bridge log could **attribute a verb** to the thread that issued it, so interference was impossible to reconstruct afterwards. +- Any agent's `bridge_kill` **destroyed every other agent's work**, silently, with no way for anyone to see who did it. + +Filed as wiki issue `adom/adom-bridge#34` by AdityaAngajala, with the pup half at `adom/pup-bridge#22`. + +### What you now receive, for free + +ab stamps three headers on **every relayed bridge request** — all four request paths (browser, kicad, fusion, and the dynamic third-party dispatch): + +| Header | What it carries | +|---|---| +| `X-Adom-Caller-Thread` | The AI thread / conversation tab name, self-reported by the caller | +| `X-Adom-Caller-Container` | The container it runs in | +| `X-Adom-Caller-Reason` | The per-call justification, when the caller supplied one (control chars stripped, truncated to 300 chars) | +| `X-Adom-Verified-Origin` | **v2.0.96+ (wiki #114): ab-VERIFIED transport origin — the ONE header you may gate on.** `relay` (arrived over the cloud-AI relay websocket), `local` (loopback direct API: local CLI, ah, a local agent), or `bridge` (a bridge callback carrying a valid spawn token). ab authors this header itself from the connection it actually observed; a caller cannot supply or override it. | + +**You do nothing to receive these.** No manifest field, no signature change, no opt-in. Reading them is the opt-in part. + +### Trust rule — read this before you use them for anything + +These values are **SELF-ASSERTED**, exactly like `reason`. Use them for **attribution, logging, UX and arbitration hints**. **NEVER for authorization, and never as a security boundary.** + +ab's own security model is transport-based (the `adom-bridge-permissions` skill has the full model): a caller is trusted because of *how it reached ab*, not because of what it *says about itself*. A header a caller supplies about itself cannot gate anything — if you write `if (thread === "admin-tab") allowDangerousThing()`, you have written a hole, not a check. + +One asymmetry worth knowing: the container name in `X-Adom-Caller-Container` is the one the **caller claimed**. ab separately knows a **verified** container (the authenticated relay connection name) and shows the user **both** when they disagree. You only get the claimed one, so treat it as a label, not an identity. + +**The exception is `X-Adom-Verified-Origin` (v2.0.96+): that one IS a security signal.** A local-only verb (a credential reveal, a vault unlock) refuses when it reads `relay` — honestly, because ab stamped the value from the transport it observed, not from anything the caller said. Two caveats before you build a gate on it: + +1. **It describes the LAST hop into ab.** A bridge callback made on behalf of a relayed AI thread arrives as `bridge`, not `relay` — the relayed identity rides only in the (self-reported) `X-Adom-Caller-Delegate` chain. So a local-only gate should refuse `relay`, decide its own policy for `bridge` (refuse, or require the delegate chain to be empty), and accept `local`. +2. **It authenticates the ab-to-you hop only.** Your HTTP port is loopback, so anything else that reaches it directly is by definition a local process — `local` remains true for those, but they carry no header at all. Treat a MISSING header as `local` from an unidentified local caller (or refuse, if your verb warrants it); never treat it as `relay`-safe. + +### What to do with it + +**1. Log it.** Put the thread in your per-verb log line. This is the single highest-value use, and it is three lines of code: + +```javascript +// node — read once, at the top of your request handler +const caller = { + thread: req.headers['x-adom-caller-thread'] || 'unidentified', + container: req.headers['x-adom-caller-container'] || 'unknown', + reason: req.headers['x-adom-caller-reason'] || null, +}; +console.log(`[verb] ${verb} session=${sessionId} caller="${caller.thread}"`); +``` + +```python +# python — same idea; header lookup is case-insensitive on BaseHTTPRequestHandler +caller_thread = self.headers.get('X-Adom-Caller-Thread', 'unidentified') +caller_container = self.headers.get('X-Adom-Caller-Container', 'unknown') +caller_reason = self.headers.get('X-Adom-Caller-Reason') +log.info('[verb] %s session=%s caller="%s"', verb, session_id, caller_thread) +``` + +That one field is what makes an interference report reconstructable: `[verb] pup_eval session=rev caller="chip-fetcher tab 3"` answers "who moved my page?" instantly, where a bare `[verb] pup_eval session=rev` never can. + +**2. Arbitrate.** If your bridge has **sessions or resources an agent can own** — pup sessions are the motivating case — record the owning thread at creation time and check it on every later use: + +```javascript +// on create +sessions.set(id, { ...state, ownerThread: caller.thread }); + +// on use by a DIFFERENT thread +const s = sessions.get(id); +if (s.ownerThread && s.ownerThread !== caller.thread) { + // warn loudly by default; refuse only if this is destructive + result._warning = + `session ${id} is owned by "${s.ownerThread}" — you are "${caller.thread}". ` + + `Two agents driving one session will interfere. Coordinate, or open your own session.`; +} +``` + +The recommended posture: + +- **Warn loudly by default.** A second agent reading or screenshotting a session is usually fine, and hard-blocking it turns a shared machine into a locked one. +- **Refuse only for destructive operations** — closing, resetting, navigating away, deleting. +- **Always name the current owner in the message.** The whole point is that the second agent can now go coordinate with a thread it can identify. An error that says "session busy" without a name is barely better than no header at all. + +**3. Label the UI.** If your bridge drives a visible window, put the owning thread where the **user** can see it — window title, taskbar tooltip, or jump-list name — via `desktop_set_window_identity` (and `desktop_taskbar`'s `thumbnailTooltip` for uncapped hover text). The user is the one with 20 tabs open; showing which tab owns which window is the fix for their side of the problem. Full guide: the `adom-bridge-window-identity` skill, and the "Branding + driving a window you borrowed" section immediately below. + +**4. Report it back.** Include the owning thread in your **status / list verbs**, so an agent can *discover* it is not alone before it stomps on something: + +```jsonc +// <prefix>_list_sessions +{ "sessions": [ { "id": "rev", "ownerThread": "chip-fetcher tab 3", "url": "…" } ], + "_hint": "A session with an ownerThread other than yours is being driven by another AI thread. Open your own session rather than sharing one." } +``` + +### Notifications already carry it + +`notify_user` derives its toast attribution line from the caller automatically, so a toast your bridge sends **on an agent's behalf already names the thread** — you do not have to build that string. Pass an explicit `attribution` string only when you want to override it. + +### ⚠ REQUIRED — when YOU call an ab verb, forward the identity + +This is the half most bridges will miss, and it is the half that makes the whole feature work or fail. + +Your bridge calls ab verbs to do its job: `desktop_screenshot_window`, `desktop_set_window_identity`, `notify_user`, `desktop_taskbar`. When you make that call **while carrying out a verb an AI thread asked you for, you are acting on that thread's behalf** — so echo the identity you were handed straight back onto your own request. Otherwise the chain dies at your doorstep: the user's Activity Log says *"pup did this"*, the originating thread vanishes, and the approval toast asks them to authorize a nameless bridge. + +**Echo all four headers, verbatim:** + +```javascript +// node — inside the handler for a verb an AI asked you for +const fwd = { + 'X-Adom-Caller-Thread': req.headers['x-adom-caller-thread'] || '', + 'X-Adom-Caller-Container': req.headers['x-adom-caller-container'] || '', + 'X-Adom-Caller-Reason': req.headers['x-adom-caller-reason'] || '', + 'X-Adom-Caller-Delegate': 'pup', // ← YOUR bridge name +}; +await fetch(`http://127.0.0.1:${adPort}/command`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...fwd }, + body: JSON.stringify({ command: 'desktop_screenshot_window', args: { hwnd } }), +}); +``` + +```python +fwd = { + 'X-Adom-Caller-Thread': self.headers.get('X-Adom-Caller-Thread', ''), + 'X-Adom-Caller-Container': self.headers.get('X-Adom-Caller-Container', ''), + 'X-Adom-Caller-Reason': self.headers.get('X-Adom-Caller-Reason', ''), + 'X-Adom-Caller-Delegate': 'kicad', # YOUR bridge name +} +``` + +`X-Adom-Caller-Delegate` is the one you **add** rather than forward. It names *you*, and it is what lets ab show the user both facts at once: **`chip-fetcher tab 3 (via pup)`**. Attributing the call to the thread alone would hide who ran it; attributing it to the bridge alone would hide who asked. Omit the header and ab falls back to the generic `via bridge`, which is honest but much less useful. + +**When you are acting on your OWN behalf — a health poll, a timer, a crash cleanup — do NOT forward a stale identity.** Send your own instead: + +```jsonc +{ "command": "notify_user", + "args": { "caller": { "aiThread": "pup bridge (self)", "containerName": "local" }, … } } +``` + +An explicit `caller` block in the args always beats the headers, so this is unambiguous. + +**Two things ab does for you here:** explicit args win over forwarded headers (so the override above just works), and ab carries the delegate forward onto any onward hop, so a chain two bridges deep still shows the original delegate. + +### Where it comes from (the caller side) + +Useful to understand, though none of it is your responsibility: + +```bash +# Per call — the CORRECT mechanism: +adom-bridge --ai-thread "chip-fetcher tab 3" <verb> '<json>' +``` + +The `adom-bridge` CLI attaches a `caller` block to **every** verb automatically. A relay call with **no `aiThread` is REFUSED by ab** with `errorCode: caller_identity_required` before it ever reaches a bridge. + +> **Correction, 2026-07-25 (ab 1.9.182):** an earlier version of this page told callers to `export ADOM_AI_THREAD` once per session. **That advice was wrong and has been withdrawn.** Roughly 20 AI threads share one container and one `$HOME`, so the env var scopes to the shell environment, not to the thread — every thread then reports the *same* name, which is worse than no name at all because it is confident and plausible. It also failed silently: tool shells are non-interactive, so an export appended below `~/.bashrc`'s `case $- in *i*` early-return never ran. The env var is still honored, but only fits a container running exactly one agent (CI, a single-purpose job). **If your bridge's own docs repeat the export advice, please correct them.** + +**So by the time a request reaches you, identity is normally present.** Relay callers and ordinary loopback direct-command callers—including Hydrogen, the local CLI, and an adopted bridge with an omitted or stale spawn token—are required to identify. A bridge carrying the current core process's spawn token retains a temporary migration exemption; top-level discovery/status endpoints may also bypass the command identity chokepoint. Code for the absent case (`|| 'unidentified'`) and never assume the header exists. + +### If your bridge wants to REQUIRE identity — use the shared contract + +Some verbs are worth refusing anonymously. If you do, **use ab's exact refusal shape** so the AI sees one consistent remedy across the whole ecosystem instead of a different fix per bridge: + +```jsonc +{ "success": false, "errorCode": "caller_identity_required", + "error": "'<verb>' needs to know WHO is asking — the user runs many AI threads and needs to see which one drove this.", + "supplyIdentity": { + "bestWay": "adom-bridge --ai-thread \"<your conversation name>\" <verb> '<json>'", + "orInArgs": { "caller": { "aiThread": "<name>", "containerName": "<container>" } } + }, + "_hint": "This is attribution, not a permission check — the name is self-reported and nothing is gated on it." } +``` + +Reusing `caller_identity_required` verbatim is the point: an AI that learned the remedy once applies it everywhere. + +### Self-audit + +- [ ] Does every verb log line include the **caller thread**? +- [ ] Do your **session-creating** verbs record the owning thread on the session? +- [ ] Do your **session-using** verbs warn (or, if destructive, refuse) when a different thread arrives — **naming the current owner**? +- [ ] Does any **window you drive** show the owning thread to the user (title / tooltip / jump-list)? +- [ ] Do your **status / list verbs** report `ownerThread` so an agent can discover it is not alone? +- [ ] **When you call an ab verb on a thread's behalf, do you FORWARD all three caller headers plus your own `X-Adom-Caller-Delegate`?** +- [ ] When you call an ab verb on your **own** behalf (poll, timer, cleanup), do you send your own `caller` block rather than a stale forwarded one? +- [ ] If your docs told callers to `export ADOM_AI_THREAD`, have you corrected them to the per-call `--ai-thread` flag? +- [ ] Have you confirmed you use these headers **nowhere** as an authorization check? + +--- + +## Branding + driving a window you borrowed (window identity + taskbar, v1.9.106–1.9.153) + +If your bridge drives a window another process owns — the classic case is a **Chrome for Testing** window that otherwise reads *"Test"* with Chrome's icon — ab gives you a generic verb family to make it present as YOUR app across every Windows shell surface, and to show live status on its taskbar button. ab stays generic: it applies whatever name/icon/command you pass; it knows nothing about your app. **Full guide: the `adom-bridge-window-identity` skill** (`https://wiki.adom.inc/api/v1/pages/adom-bridge/files/docs/WINDOW_IDENTITY.md` or the repo `skills/WINDOW_IDENTITY.md`). The essentials: + +- **Two halves, you need both:** `desktop_register_app_identity {appId, displayName, iconPath, shortcut}` teaches Windows what an AUMID means (HKCU only, no UAC; `shortcut:false` for a per-session id with no Start Menu spam), and `desktop_set_window_identity {hwnd, appId, iconPath, displayName, relaunchCommand}` stamps it on the live window. Stamp without register → Alt-Tab / taskbar fall back to the owning exe's icon. +- **Per-session identity:** register `shortcut:false` per window (e.g. `Adom.Pup.<sessionId>`) so each keeps its own taskbar button; `desktop_unregister_app_identity {appId}` on close. Known cost (wiki #155, measured): a registry-only identity's taskbar jump list flashes on the FIRST right-click and stays on the second, because the shell resolves a shortcut-less AUMID lazily. `anchor:true` fixes it by dropping a small visible anchor `.lnk` into one Start Menu subfolder (`anchorFolder`, default "Adom Bridge"; pass the same value on unregister), but that IS a Start Menu entry (All apps, "Recently added", search; a hidden `.lnk`, `NoStartPage` or `SHAddToRecentDocs` do not anchor), so the default is OFF: a clean Start Menu over a first-click flyout. Opt in only when the user prefers the flyout. +- **Jump list + header:** `desktop_set_window_jumplist {appId, tasks, hwnd?, headerIcon?, headerCommand?}` adds the right-click menu AND (with `hwnd`+`headerIcon`) brands the header row (the app tile above Pin/Close) with no Start Menu shortcut. +- **Taskbar status:** `desktop_taskbar {hwnd, progress?, overlay?, flash?, thumbnailTooltip?, thumbnailClip?}` paints glanceable state on any window's button. **New in v1.9.153:** `thumbnailTooltip` is the ONLY uncapped hover-text surface (the window title truncates ~25-30 chars; this does not, and takes newlines; `""` clears it), and `thumbnailClip:{x,y,w,h}` crops the live hover preview to a region (`null` resets). Overlay compositing (`overlay.avatar`/`profileDir`) preserves a browser profile face while adding your mark. +- **Icon format — read the `iconCheck` in every response.** The identity verbs inspect any icon you pass and return `iconCheck {kind,sizes,issues,ok,hint}`. The traps it catches: a **256px `.ico` size that is not PNG-compressed** renders as the generic white-document icon (Vista+ rule); **SVG can't be loaded** by the Win32 shell; the AUMID registry icon wants a `.ico`/`.png`, not an `.exe`. If `iconCheck.ok` is false, fix the file and re-stamp — don't ship the broken icon. Ship a multi-size `.ico` (16/24/32/48/256, the 256 PNG-compressed). +- **The live-tile trap:** re-stamping the SAME appId with a new icon updates the title bar + Alt-Tab but NOT the taskbar tile (Win11 bakes it at button creation). To change a live tile, register + stamp a DIFFERENT appId; the button is recreated in ~1s. + +---
skills/adom-bridge-sdk-manifest/SKILL.md+3−6@@ -1,11 +1,8 @@ --- name: adom-bridge-sdk-manifest description: >- - The bridge.json manifest that ships INSIDE your bridge's zip: every field and what Adom - Bridge does with it. Use when creating or editing a bridge.json, when a bridge will not - spawn or is routed the wrong verbs, or when adding verbPrefixes, spawn kind - (python/node/exe/external-http), healthEndpoint, statusVerb, persistent, detect (host-app - detection), timeouts, updateManifestUrl, or docs. + adom-bridge-sdk sub-skill: every bridge.json field and what ab does with it. Use when a + bridge won't spawn or gets the wrong verbs. --- @@ -62,7 +59,7 @@ description: >- - **`spawn.healthEndpoint`** lives **inside `spawn`** (not at the top level), default `/health`. - **`updateManifestUrl`** — the full URL of YOUR manifest on YOUR page. **Declare it in `bridge.json`** so it survives a republish; ab polls it (on launch + every 4h) and auto-updates users. (ab also remembers the install URL in `~/.adom/bridge-install-urls.json` as a fallback, but the declared field is the durable, author-intended path.) - **`insidersManifestUrl`** (v2.0.99, optional) — the bridge half of the login-decided release-tier pattern (see the publish skill's "Release tiers" section). Point it at `<product>/version.json` on your org's ONE shared private insiders page (for Adom-published bridges: `https://wiki.adom.inc/api/v1/pages/insiders/files/<product>/version.json`) — never create a per-product private page; the shared page's readers are your insiders. ab probes it FIRST with the desktop user's own session login: 200 + parseable = that desktop follows your insiders tier; any failure falls back to `updateManifestUrl` silently, so a non-insider desktop behaves exactly as if the field did not exist (and abs older than 2.0.99 ignore it entirely). The zip resolves relative to whichever manifest won, fetched with the same login. Keep ONE plain semver per build in both tiers. -- **`docs`** — point at YOUR dedicated wiki page. ab derives your **skillPkg** (`adom/<slug>`) and your card hero from this. Don't point it at a shared/structural page. +- **`docs`** — point at YOUR dedicated wiki page. ab derives your **skillPkg** (`<owner>/<slug>`, the owner and slug of that URL, so a page under `drew2/` gives `drew2/<slug>`) and your card hero from this. Set an explicit `"skillPkg"` in bridge.json only when your skill pkg lives somewhere other than your docs page. Don't point it at a shared/structural page. - **`homepage`** — a **`wiki.adom.inc`** URL (your own page, or `https://wiki.adom.inc/adom/adom-bridge` for the platform). **NEVER `github.com/...`** — GitHub is source-backup only; the wiki is the canonical home. (A `homepage: github.com/adom-inc/adom-bridge` is a stale default to fix.) - **`detect` / `prewarm`** — the v1.9.47 cold-start declarations (full rules in "Cold-start tiers" below). `detect` = your HOST APP (ab detects, never installs); `prewarm` = your heavy RUNTIME assets (ab prewarms/auto-installs). A node bridge that ships its deps still works without `prewarm`; declare `prewarm.assets` only for big *downloaded* assets like a browser. - **`timeouts`** (v1.9.79) — per-verb HTTP budgets (seconds) ab honors so a SLOW verb (a big export, a cloud open, a long render) isn't cut off before it finishes. Resolution: exact `verbs` (BARE names, no prefix) → first matching `prefixes` → `default` → ab's global 60s. A timeout is **NON-terminal**: ab returns `{stillRunning:true, statusVerb, timeoutSeconds}` and the caller polls your `statusVerb` (so ALSO declare `statusVerb`). **⚠ Before v1.9.79 a third-party bridge was hard-capped at 60s with no way to raise it** — if any of your verbs can run longer, declare `timeouts` (this is the fix). ab reads it with ZERO ab change; the two bundled EDA bridges keep a labeled built-in table only until they declare their own.
skills/adom-bridge-sdk-publish/SKILL.md+3−6@@ -1,11 +1,8 @@ --- name: adom-bridge-sdk-publish description: >- - PUBLISH a bridge: the three artifacts (runtime Release zip + manifest, the container skills - pkg, and discovery_triggers), the payload rules that keep each artifact clean (runtime-only - / skills-only, media lives on the PAGE), the skill set to ship, USER-skill conventions, the - card hero spec, and the full publish checklist. Use when shipping a new bridge version, when - bridge_install or an auto-update fails, or when your bridge is not discoverable. + adom-bridge-sdk sub-skill: publish a bridge (Release zip + manifest, skills pkg, + discovery_triggers) and the publish checklist. --- @@ -85,7 +82,7 @@ adom-wiki pkg publish --org adom # publishes adom/adom-bridge-<bridge>-b **`skill_count` on your page counts the USER skills your published PKG carries** — so it should be SMALL (1 for a single-skill bridge, a handful for a core + sub-skills). If it looks inflated, you probably shipped dev/publish skills that belong in the repo instead. The metric is NOT "SKILL.md files in the repo," so a `type:skill` page can read `skill_count: 0` while its repo holds many — don't chase parity across page types (wiki-side quirk). A container gets it **without anyone asking**: -- `bridge_install` returns `skillPkg` + `installSkill` (ab derives `adom/<slug>` from your `docs`) and the `_hint` tells the AI to run it. +- `bridge_install` returns `skillPkg` + `installSkill` (ab derives `<owner>/<slug>` from your `docs`) and the `_hint` tells the AI to run it. - **`adom-bridge sync_skills`** installs every connected desktop's bridge skillPkgs (the CLI also runs it once/day in the background), so a container that connects a week later self-heals your skill. - The wiki page's **Skills tab** lists the skills your pkg ships, so the page itself advertises them.
skills/adom-bridge-sdk-runtime/SKILL.md+2−6@@ -1,12 +1,8 @@ --- name: adom-bridge-sdk-runtime description: >- - The RUNTIME contract for a bridge: Adom Bridge provisions Node/Python for you and you MUST - bind loopback (ADOM_BIND_HOST) so no firewall prompt appears; plus prewarm, the cold-start - tiers, host-app detection and the bridge_readiness probe, the health endpoint and status- - chip contract (led/summary/tooltip), the lifecycle verbs users drive, and the logging split - (ab owns the file, you own the volume). Use when your bridge fails to spawn, shows the wrong - LED, is slow on first call, or needs a runtime it cannot assume exists. + adom-bridge-sdk sub-skill: the bridge runtime contract (provided Node/Python, loopback bind, + prewarm, health and LED, logs). ---
skills/adom-bridge-sdk/SKILL.md+5−7@@ -1,12 +1,10 @@ --- name: adom-bridge-sdk description: >- - START HERE to build a bridge for Adom Bridge - a small local server that adds a namespace of - verbs (kicad_, fusion_, pup_, your_prefix_) to Adom Bridge. The orientation skill: the ONE - principle (rich _hint/_next in every verb's OUTPUT, because the AI reads CLI output and - almost never your on-disk SKILL.md), the three artifacts you publish, where every file - lives, the ab-core-vs-author boundary, and a map to the rest of the SDK skill library. Read - this first, then load the specific skill for the phase you are in. + Hub of the bridge SDK: build a bridge, a small local server that adds a verb namespace + (kicad_, pup_, your_prefix_) to Adom Bridge. Covers the core principle, the three artifacts, + the ab/author boundary, and a map to the adom-bridge-sdk-* skills. Use when asked to build + or write a bridge. --- @@ -18,7 +16,7 @@ A **bridge** is a small local program (Python, Node, Rust, anything) that ab spa 2. **Skills**: an **`adom-wiki pkg`** tarball carrying your consumer SKILL.md(s). A container installs it into `~/.claude/skills/` (or `~/.codex/skills/`) so the AI *there* knows your verbs. 3. **Discoverability**: **`discovery_triggers`** on your wiki page so a user saying *"open my app in pup"* surfaces YOU via `adom-wiki discover find`. -> **THE ONE PRINCIPLE THAT MATTERS MOST: read this before anything else.** +> **THE ONE PRINCIPLE THAT MATTERS MOST: read this before you write a verb.** > **The AI almost never reads your on-disk SKILL.md. It ALWAYS reads the OUTPUT of a CLI call.** > So your leverage is in the **`_hint` / `_next` / `related` / `pitfalls` you return on EVERY verb response.** A verb that returns `{success:true}` and nothing else leaves the AI guessing and retry-looping. A verb that returns *what just happened, the obvious next verb, the related verbs, and the pitfall to avoid* gets driven perfectly on the first try. ab relays every field of your response **verbatim** to the calling AI. Treat each verb's output as the documentation the AI will actually read. (Your SKILL.md is for the *human* author + the rare AI that does read it; your verb OUTPUT is for the AI in the loop.)
skills/adom-bridge-thesis/SKILL.md+84−88@@ -1,88 +1,84 @@-name: adom-bridge-thesis-description: >-- The core thesis of Adom Bridge: WHY it exists and what the whole system is for. Read- this to understand the high-level goal before diving into any verb or feature skill,- or when a user asks what Adom Bridge is, why they would want it, what problem it- solves, whether it is only for Adom/Hydrogen users, or how it is safe to let an AI- control a whole computer. Trigger words: what is adom desktop, why adom desktop,- adom desktop goal, adom desktop thesis, value proposition, is it safe, do I need- hydrogen, unleash the AI, full machine access, AI on my PC.--# The Adom Bridge thesis: unleash your AI onto your full PC--## The problem--Every AI you use today is locked in a box. A cloud agent lives in a container with no-hands. A chat app can talk about your files but cannot touch them. Even a local AI-assistant sees only what you paste into it. The result is that YOU are the robot: the AI-decides, and you carry out the clicks, the installs, the exports, the file moves.--## The goal--Adom Bridge exists to invert that. It gives your AI access to your full machine, so the-AI assists you in managing and running your entire computer as you, without you having to-do anything manually. The AI breaks out of the container it runs in and onto your real-PC or Mac: your windows, your apps, your files, your shell, your browser, your USB-attached-hardware.--That is the one-line thesis: **Adom Bridge is the unleasher of your full AI.**--Concretely, an AI wired through Adom Bridge can:--- run and manage the whole laptop: launch apps, move files, read logs, restart services,- take screenshots, drive any window;-- control professional tools like KiCad, Fusion 360, Altium, Blender, or a real signed-in- browser, through purpose-built bridges;-- do all of it from wherever the AI actually lives: a cloud container over the relay, or- a local app like Claude Desktop or Codex over loopback.--## Not just for Adom users--Adom Bridge ships with Adom's own Hydrogen environment, but it is deliberately-standalone. A user who never touches Hydrogen still gets the full value: install Adom-Desktop, point any AI at its CLI or API, and that AI can fully drive the PC. If you use-Claude Desktop, Claude Code, Codex, or any other agent on your machine, Adom Bridge is-what gives that agent hands.--## Bridges: Adom Bridge as an operating system for AI--Bridges are how the reach keeps growing. A bridge is a small local server that teaches-Adom Bridge a new namespace of verbs (kicad_, fusion_, browser_, your own). Adom Bridge-behaves like a little operating system around them:--- it provisions its own managed Node and Python runtimes, so a bridge runs without- touching the user's setup and without a single admin prompt;-- it owns the bridge lifecycle: spawn, stable ports, health, auto-respawn, auto-update- from the wiki, logs;-- it keeps bridges in a controlled environment: loopback-only binding, a registry, and- an audit trail.--So building for the AI-driven desktop feels like building an app for an OS, and installing-one is a single verb.--## Full power, full safety--Unleashing an AI onto a whole machine only works if the human stays in charge. That is-the other half of the thesis, and it is why the permission system is a first-class part-of the product, not an afterthought:--- side-effecting commands from a remote AI are gated behind a human approval prompt, and- the grant is human-only: the AI can ASK for permission but can never grant itself;-- every command is attributed (which AI thread, which container, on whose behalf) and- visible in the Activity Log;-- Adom Bridge binds to 127.0.0.1 only; nothing off the machine can reach it except- through the relay the user configured.--Full power and full safety together are the point. Either one alone is easy; the product-is both at once.--## How to use this skill--This skill is the WHY. For the HOW, start with the main `adom-bridge` skill (the verb-surface), `adom-bridge-permissions` (the approval model), `adom-bridge-sdk-guide`-(consuming bridges), `adom-bridge-bridge-sdk` (building one), and `adom-bridge-local-ai`-(driving the PC from a local AI app). When you explain Adom Bridge to a user, lead with-the thesis above, in plain words: it lets your AI out of its box and onto your machine,-with you holding the leash.+--- +name: adom-bridge-thesis +description: >- + Why Adom Bridge exists and what the whole system is for: full power and full safety for an + AI on your PC. Use when a user asks what is adom bridge, why adom bridge, do I need + Hydrogen, is it safe to let an AI control my computer, or for the value proposition. +--- + +# The Adom Bridge thesis: unleash your AI onto your full PC + +## The problem + +Every AI you use today is locked in a box. A cloud agent lives in a container with no +hands. A chat app can talk about your files but cannot touch them. Even a local AI +assistant sees only what you paste into it. The result is that YOU are the robot: the AI +decides, and you carry out the clicks, the installs, the exports, the file moves. + +## The goal + +Adom Bridge exists to invert that. It gives your AI access to your full machine, so the +AI assists you in managing and running your entire computer as you, without you having to +do anything manually. The AI breaks out of the container it runs in and onto your real +PC or Mac: your windows, your apps, your files, your shell, your browser, your USB-attached +hardware. + +That is the one-line thesis: **Adom Bridge is the unleasher of your full AI.** + +Concretely, an AI wired through Adom Bridge can: + +- run and manage the whole laptop: launch apps, move files, read logs, restart services, + take screenshots, drive any window; +- control professional tools like KiCad, Fusion 360, Altium, Blender, or a real signed-in + browser, through purpose-built bridges; +- do all of it from wherever the AI actually lives: a cloud container over the relay, or + a local app like Claude Desktop or Codex over loopback. + +## Not just for Adom users + +Adom Bridge ships with Adom's own Hydrogen environment, but it is deliberately +standalone. A user who never touches Hydrogen still gets the full value: install Adom +Desktop, point any AI at its CLI or API, and that AI can fully drive the PC. If you use +Claude Desktop, Claude Code, Codex, or any other agent on your machine, Adom Bridge is +what gives that agent hands. + +## Bridges: Adom Bridge as an operating system for AI + +Bridges are how the reach keeps growing. A bridge is a small local server that teaches +Adom Bridge a new namespace of verbs (kicad_, fusion_, browser_, your own). Adom Bridge +behaves like a little operating system around them: + +- it provisions its own managed Node and Python runtimes, so a bridge runs without + touching the user's setup and without a single admin prompt; +- it owns the bridge lifecycle: spawn, stable ports, health, auto-respawn, auto-update + from the wiki, logs; +- it keeps bridges in a controlled environment: loopback-only binding, a registry, and + an audit trail. + +So building for the AI-driven desktop feels like building an app for an OS, and installing +one is a single verb. + +## Full power, full safety + +Unleashing an AI onto a whole machine only works if the human stays in charge. That is +the other half of the thesis, and it is why the permission system is a first-class part +of the product, not an afterthought: + +- side-effecting commands from a remote AI are gated behind a human approval prompt, and + the grant is human-only: the AI can ASK for permission but can never grant itself; +- every command is attributed (which AI thread, which container, on whose behalf) and + visible in the Activity Log; +- Adom Bridge binds to 127.0.0.1 only; nothing off the machine can reach it except + through the relay the user configured. + +Full power and full safety together are the point. Either one alone is easy; the product +is both at once. + +## How to use this skill + +This skill is the WHY. For the HOW, start with the main `adom-bridge` skill (the verb +surface), `adom-bridge-permissions` (the approval model), `adom-bridge-bridges-guide` +(consuming bridges), `adom-bridge-sdk` (building one), and `adom-bridge-local-ai` +(driving the PC from a local AI app). When you explain Adom Bridge to a user, lead with +the thesis above, in plain words: it lets your AI out of its box and onto your machine, +with you holding the leash.
skills/adom-bridge-verbs/SKILL.md+4−1@@ -1,6 +1,9 @@ --- name: adom-bridge-verbs -description: The full reference for Adom Bridge's own (ab-core) verbs - every desktop_* / top-level verb the direct API and CLI expose, grouped by category, with a one-line description of what each does and what it takes/returns. Read this to discover what ab can do to a Windows PC (windows, screenshots, input, UI automation, files, shell/WSL, registry/fonts, tunnels, notifications, lifecycle, bridge management, credentials). BRIDGE verbs (kicad_*, fusion_*, pup_*, ...) are NOT here - each bridge ships its own verb skill; this is ab-core only. There is also a live, browsable version at http://127.0.0.1:24680/verbs on any machine running ab. +description: >- + Reference for Adom Bridge's own (ab-core) verbs, desktop_* and top-level, grouped by + category; bridge verbs (kicad_*, pup_*) are in each bridge's skill. Use when asked what can + ab do on my PC, is there a verb for X, or to find a desktop verb's arguments. --- # Adom Bridge core verbs
skills/adom-bridge-web-control-test/SKILL.md+682−678@@ -1,678 +1,682 @@-name: adom-bridge-web-control-test-description: How to run - and how to BELIEVE - a web-control test of the KiCad/Fusion library verbs across a batch of wiki components. Covers the four-artifact evidence contract every library import owes (symbol, footprint, 3D chip, ganged view), the mandatory per-image content check, the five known false-success modes that make a green flag lie (stale window, blank workspace, placeholder 3D, screensaver, name mismatch), and the rule that the run is driven from the web-control page in pup so the UI itself is part of the evidence. Read this BEFORE running any "test N components through web control" request. Trigger words: web control test, library matrix, run the library verb on N parts, evidence images per component, kicad_show_library, fusion_show_library, prove the import worked.--# Web-control library test — the procedure, and the reasons for it--Every clause here was written after a run reported success and the images said-otherwise. Nothing in it is precautionary.--## The one-sentence rule--**A flag is a claim; an image is evidence; only a *read* image is proof.**-`success: true` from a library verb has been wrong in five distinct ways-(below). Never report a part as passing until you have looked at its picture and-named what you saw.---## The mission — why this exists--This is not a smoke test you run once and tick off. It is a **standing-edge-case-hunting campaign** with one goal: *when any Adom user imports a Fusion-or KiCad library — or a symbol, a footprint, a 3D chip, a schematic, a board, a-project — it works perfectly for them.*--The way you get there is to **run these imports over and over, across many parts-and every surface, and find the crazy scenarios HERE — first — before a real-user does.** Every scenario you find gets fixed in the right place (ab core, the-kicad/fusion bridge, the harness, or this skill) and then re-run to confirm.-Running the same test repeatedly is not waste; each run either adds a scenario to-the catalog (§7) or confirms a fix. The catalog is the point — it is how-repetition compounds into coverage.--So a run is never "done" at 10/10 green. A run is done when you have either-**found a new edge case** (→ §8, route it to a fix) or **confirmed the known ones-still pass**. Both outcomes are the deliverable; a run that found nothing new and-a run that found a bug are both successful runs.---## ⭐ The NORTH STAR — every import begins with a PRE-FLIGHT PLAN and runs against a REAL progress bar (John, 2026-08-26)--This is the most important UX goal of the whole campaign, and it is easy to lose-sight of while chasing individual bugs. **A library import must never fire blind-verbs that fail one after another.** It must first *gather requirements*, show the-user a *plan* with a time estimate, and then run against a *real measured progress-bar*.--The failure this replaces (real, John on AdomLapper with Fusion closed): the demo-fired `fusion_bind_library_3d` → `fusion_show_symbol` → `footprint` → `3d`, and-**every one failed with "Fusion 360 is installed but not running"** — no-detection, no auto-open, no ETA, no bar. A user cannot tell "Fusion is just-closed, opening it" from "this is broken."--**What must happen instead — pre-flight, then a measured run:**--1. **Detect Fusion's state.** If it is not running, **auto-open it** and SAY so:- *"Fusion isn't open — opening it (usually ~54 s)."* Fold that into the ETA.- Never let "the app was merely closed" become four silent failures.-2. **Enumerate the files to stage** (`.lbr` + `.step`), their **sizes**, and an- **estimated transfer time** from the user's typical download speed.-3. **Enumerate the steps** with a **per-step ETA from past-run history** — e.g.- create symbol, create footprint, import 3D to Fusion cloud, show ganged view- with refreshed 3D icon.-4. **Show the plan**: *"Ready to create Fusion library. ~2 min 46 s on average- based on past runs. (Had Fusion already been open, ~49 s.)"* + the step list.-5. **Run with a real progress bar** advancing across the planned steps, streaming- each artifact as captured (symbol → footprint → 3D chip → ganged, per the- evidence contract), `confidence:"measured"` where history exists, `blockedBy`- when a step waits on a human (Autodesk sign-in).--**Division of labor — ~80 % is the BRIDGE's job, ~20 % the orchestrator's (ab /-the web page):**--- **Bridge:** is Fusion running? auto-open it + report the launch ETA; the step- list + **per-step ETAs from its own past-run telemetry** (only the bridge has- run these steps before); the measured-progress stream; a clear async-completion- signal; all four artifacts including the dedicated 3D chip.-- **Orchestrator:** local file sizes + transfer-time estimate on THIS machine;- rendering the plan summary + the progress-bar UI.--**The contract to demand from the bridge** is a pre-flight verb, e.g.-`fusion_plan_library_import { lbrPath, stepPath }` → `{ fusionRunning, willOpenFusion,-openEtaSec, steps:[{key,etaSec}], totalEtaSec, totalEtaIfFusionOpenSec }`, then the-import emits measured progress against that plan.--Filed as **fusion-bridge #64** (this contract), on top of **#60** (4-artifact-bundle + streamed + measured progress), **#62** (capture false-pass + async-completion signal), **#63** (the dedicated 3D-chip view). When you run an import-and it does NOT begin with a plan + a bar, that is itself the finding — route it,-do not just work around it.---## 1. The evidence contract--Importing a component library is not one thing succeeding. It is four, and the-test owes the human all four **per part**:--| # | artifact | what proves it |-|---|---|---|-| 1 | **symbol** | schematic glyph drawn on the canvas, with the part's own geometry |-| 2 | **footprint** | pad layout for that package |-| 3 | **3D chip** | the part's **own** STEP body — not a generic block |-| 4 | **ganged view** (Fusion) | the one window showing 1–3 together |--The web-control page already names this contract: its library button reads-**"Library (<part>) — proves all 3"**. If your evidence does not show all of-them, the test failed *or the bridge failed* — decide which and say so. It is-never "close enough".--## 1a. The library verb alone is NOT the library flow--**Read this before concluding a bridge is broken.** I filed two bug reports that-were my own error, both from the same mistake: calling `kicad_show_library` /-`fusion_show_library` bare and treating the result as the library flow.--The real flow — what the web-control page does, and what you must replicate if-you drive the verbs directly:--| step | why |-|---|---|-| **Fusion: `fusion_bind_library_3d {libraryPath, stepPath}` FIRST** | without it the library has no package3d and Fusion renders the grey Placeholder slab **by design**. This is not a bug. Skipping it manufactures §3.4. |-| then the `*_show_library` open | |-| then `show_symbol` / `show_footprint` / the 3D surface | one capture each — this is what produces the four artifacts |-| poll **`kicad_progress` / `fusion_progress`** throughout | the bridges DO emit a measured `progress` block: percent, ETA, cold/warm |--The page scores the result **N/3 and only 3/3 is a pass**, on the stated-principle that *a library with a symbol but no 3D model is not a working-library*.--So the bundle is not missing from the product — it is missing from any harness-that calls one verb and screenshots the window. Measured live on crr:--```-show_symbol starting - 0% - 7s left - warm-show_footprint starting - 45% - 12s left - warm-```--**Before filing a bridge bug, check whether the UI path does something you-skipped.** Drive the part through the page first (§2) and read the card's own-log — it narrates every step it takes.--What is genuinely worth reporting is a verb that succeeds while its own-precondition is unmet and says nothing: `fusion_show_library` returning-`success: true` with `3D bound: false` is a missing hint, not a broken bridge.---## 1b. The surface matrix — run ALL of these, not just Library--The web-control page has one button per content type, for **both** apps. Most-runs so far have exercised only **Library ("proves all 3")** — which means the-individual-artifact and board/project surfaces are largely UNTESTED, and that is-where the next edge cases live. A real user clicks any of these, so test all of-them.--| surface | KiCad button | Fusion button | what "works perfectly" means |-|---|---|---|---|-| **Symbol** | Symbol (`kicad_show_symbol`) | Symbol (`fusion_show_symbol`) | the part's schematic glyph drawn, its own geometry, correct title |-| **Footprint** | Footprint (`kicad_show_footprint`) | Footprint | the pad layout for that package, right pad count/shape |-| **3D chip / package** | 3D chip (`kicad_show_3d`) | 3D package | the part's OWN STEP body, not a Placeholder slab (§3.4) |-| **Library (all 3)** | Library — proves all 3 | Library — proves all 3 | symbol + footprint + 3D, N/3 = 3/3 |-| **Schematic** | Schematic | Schematic | just this part placed on a sheet |-| **2D board** | 2D board (tight edge cuts) | 2D board (part placed) | part placed on a board, edge cuts |-| **3D board** | 3D board (real 3D body) | 3D board | the real 3D body ON the board |-| **Project** | Project (top of the tree) | Project (top of the design) | the whole project/design opens |-| **State** | Show KiCad state (every window) | — | every window captured, incl. error dialogs |--**Each surface owns fewer artifacts than Library, so its false-success modes-differ** — e.g. a bare Symbol capture can be a stale window (§3.1) with no-footprint/3D to cross-check, so the title assertion matters MORE there, not less.-Rotate through the surfaces across runs; a scenario found on the 3D-chip button-may never surface on the Library button.--**Vary the PARTS too.** Edge cases cluster by part shape: multi-unit symbols,-parts whose declared name ≠ filename (§3.2), parts with no 3D model, polarized-vs non-polarized, connectors, ICs with many pins, LEDs, ferrites. A 10-part run-of all capacitors proves less than 10 parts spanning cap/resistor/inductor/LED/-connector/IC. The wiki has dozens — pull a diverse set each run.---## 2. Drive it from the web-control page, in pup--The point of the exercise is the wiki's Quick Actions surface. Calling the verbs-straight over the relay tests the bridge but shows the human nothing about the UI-they are shipping.--**⭐ SHOW THE HUMAN THE WEB-CONTROL DEMO RENDERED IN PUP, EVERY RUN — this is a-standing deliverable, not an afterthought.** John, repeatedly: *"every time i ask-you to run tests you're supposed to show me your web control in pup so i can see-generally what the ui will look like in the wiki with colby's quick actions."* The-demo page IS the surface real users will click in the wiki, so a run owes him a-look at it — the actual rendered UI, not just "the test passed." Do it by-mechanism so you cannot forget:--- **At the START of every run**, `pup_screenshot {fullPage:true}` the page and- `SendUserFile display:"render"` it, captioned as "the web-control UI the wiki- quick-actions will render." A live mid-run shot (cards showing progress +- evidence thumbnails) is even better than a cold one — it shows the UI *working*.-- This is SEPARATE from the per-component artifact images (§5). The pup-UI shot is- "here is the surface"; the artifact images are "here is what the import- produced." A run shows BOTH.-- `pup_screenshot` writes into the container filesystem (`savedTo` is a WSL path);- copy it out before `SendUserFile`.---**ab already serves the demo. Do not copy the HTML anywhere.** It is on-`http://127.0.0.1:24681/` on every machine with ab installed (`web_demo.rs`,-`DEMO_PORT`), and that exact origin is the sole entry in `BUILTIN_WEB_ORIGINS`-in `direct_api.rs` — pre-trusted, so commands are not gated behind a per-origin-grant.--```bash-# open ab's OWN demo server in Chrome for Testing ON THE MACHINE UNDER TEST-adom-bridge --target <machine> pup_open_window \- '{"url":"http://127.0.0.1:24681/","width":1500,"height":1000}'--# drive the real buttons, screenshot the real UI-adom-bridge --target <machine> pup_eval '{"sessionId":"...","expression":"..."}'-adom-bridge --target <machine> pup_screenshot '{"sessionId":"...","fullPage":true}'-```--Notes that cost time to learn:--- **`file://` does not work.** It is an opaque origin, and ab hard-refuses- commands from those: *"Adom Bridge refuses commands from an opaque origin- (Origin: null)"*. The page will fetch the part's files from the wiki fine and- then fail on the first `write_file`, which reads like a bridge bug and is not- one. Serving the copied HTML yourself is also a dead end — a hand-rolled- static server is a *different* origin and lands you back in the Allow/Deny gate.-- Don't reach for `python -m http.server` on a Windows target anyway: `python`- there is usually the **Microsoft Store stub**, which exits immediately, so- `launch_process` returns a pid and nothing ever listens.-- The verb family is **`pup_*`**. `browser_*` is gone — the alias only survives- as a hint telling you to rename.-- `pup_screenshot` writes into the **container** filesystem (`savedTo` is a WSL- path). Copy it out before trying to read it.-- The page talks to `127.0.0.1:24680`, i.e. **the machine the browser is on**.- Run the browser on the machine under test or you are testing the wrong PC.-- Omit `sessionId` on the first open and the thread gets its own window; reuse- that id afterwards.---## 2a. Running the batch reliably — the harness lessons--Everything here cost a restart to learn on crr (2026-08-25). None of it is the-bridge's fault; it is what a batch runner must handle.--### The environment fights you two ways — automate both--- **The web-control grant caps at 1 HOUR, and a 10-part Fusion pass runs ~55 min.**- It WILL lapse mid-run, and the symptom is a part that captures the symbol +- footprint then fails on the 3D step with *"did not approve this web page to run- this verb."* Don't hand-click Allow each time — **re-grant from the runner- before every part**: check `web_control_grants`; if the timed grant for- `http://127.0.0.1:24681` has < ~5 min left, raise the prompt (click any library- button), surface ab (`desktop_window_show {hwnd}` → `desktop_raise_window`),- find the teal *Allow 1 hr* button in a window capture, map image→screen through- the window rect, and `desktop_click` it. Verify the grant went `mode:"timed"`- before proceeding.-- **The screensaver (`adom-screensaver.scr`) steals foreground AND blanks every- capture.** When it owns the desktop, `desktop_get_foreground` returns hwnd `0`,- your Allow click never reaches ab's WebView, and screenshots come back white.- **Kill it before every grant attempt and before every capture** (`process_list- {name:"scr"}` → `process_kill` each `.scr`). This is §3.5 as an active step, not- just a check.--### `desktop_window_show`/`desktop_click` succeed without doing anything--Both return `{success:true}` even when the window never surfaced or the click-never landed. To actually grant: `desktop_window_show {hwnd}` → `desktop_raise_window-{hwnd}` → **confirm `desktop_get_foreground` == that hwnd** → then click. If-foreground is `0` or something else, the screensaver or another window has it —-clear that first. `success:true` on a click is not proof the pixel was hit.--### Four runner bugs that all looked like bridge failures--1. **Completion detection: poll `btn.disabled`, NEVER the word "running".** The- demo disables the card's button for the whole run and re-enables it on every- exit path — that flag is the one true "done" signal. The progress block- REPLACES the running text mid-run, so a text check declares FAIL at 10s while- the bar is at 85%.-2. **Pull each evidence `<img>` ONE AT A TIME.** Three ~175 KB data-URLs in a- single `pup_eval` result overflow it and come back empty — so every part- silently saved 0 artifacts. Get the count + labels first (tiny), then fetch- each `img[i].src` in its own eval.-3. **Extract by the BUTTON index, not the loop counter.** A capture bug hid here- for two runs: the poll used the button index (25) but the extractor was passed- the 1..10 loop counter, so it read a different, empty card. Same index for- poll and extract.-4. **Reload the page before the run to clear WEDGED card state.** Repeated- interrupted clicks leave a card's per-run JS promise wedged; the next click- disables the button then throws before the first log line, and the part hangs- to the loop cap producing nothing. A fresh `pup_navigate` clears it — do it- once at the start of every run.--### Fusion cold-start and document accumulation--- **The *"Loading additional modules"* window is a VISIBLE:FALSE ghost — do not- treat its presence as "still loading" (corrected 2026-08-26).** Fusion leaves a- nearly-fullscreen `Loading additional modules` window in `desktop_enum_windows`- with **`visible: false`**, and it NEVER disappears — so a check for the title- alone waits forever for a window that is not actually blocking anything. When- you gate on it, **filter `visible == true`**: a *visible* loading window means- genuinely still loading; an invisible one is a leftover Fusion keeps around on a- fully-loaded instance (measured: 1.7 GB RSS, 4600 handles, with the ghost still- enumerated). If part 1 still hangs on "waiting for idle" with no *visible*- loading window, the cause is the cold add-in or doc state, not module loading —- a throwaway warm-up part or a `fusion_close_all_documents` is the lever, not- more waiting.-- **Close documents BETWEEN Fusion parts** (see §3.4a). A 10-part run left **64- documents** open on crr, which slowed the 3D step until it blew ab's 60s budget- on every part after the first. Call `fusion_close_all_documents` between parts.- If the pile-up already happened (`fusion_get_app_state.documents` in the dozens),- a full Fusion restart is the only clean reset — `close_all` won't force-close- the stray `Untitled` docs.--### Timing to expect (post-fixes, crr)--KiCad via the UI: ~54 s/part, steady. Fusion via the UI: ~40 s warm per part once-Fusion is settled and documents are closed between runs; the FIRST part after a-cold start pays a multi-minute module-load tax. Budget the whole Fusion pass at-~50–60 min and let the self-granting runner carry it across the 1-hour grant.---## 3. The five false successes--Each of these produced a green flag and a plausible-looking screenshot.--### 3.1 Stale window (KiCad)-A failed open leaves the **previous** part on screen. The capture is full of ink,-the symbol is real — it is just the wrong component.--> **Check:** the window title must contain the part you asked for.-> ```python-> ws = call("desktop_enum_windows", {"includeOffscreen": True})-> assert any(want.lower() in (w["title"] or "").lower() for w in ws["windows"])-> ```--### 3.2 Name mismatch (KiCad)-The symbol name **inside** the `.kicad_sym` is often not the filename.-Measured: `CL21A226MPQNNNE` declares itself `CL21A226MPQNNN` — the trailing `E`-is dropped. Asking for the file stem finds nothing, and 3.1 then shows you the-last part instead.--> **Check:** parse the declared name out of the file and ask for *that*.-> ```python-> for m in re.finditer(r'\(symbol\s+"([^"]+)"', text):-> if not re.search(r"_\d+_\d+$", m.group(1)): # skip _1_1 sub-units-> return m.group(1)-> ```--### 3.3 Blank workspace (Fusion)-Fusion can land on an empty `Untitled` **Design** tab while the part's tab sits-unfocused in the tab bar. The verb reports success. The capture is a huge empty-dark canvas.--> **Check:** the *active* tab's title must be the part, and the Electronics-> panels (Symbol / Component Properties / Packages) must be present. An empty-> SOLID/SURFACE/MESH ribbon means you are in Design, not the library.--### 3.4 Placeholder 3D (Fusion) — usually YOUR bug, not the bridge's-The 3D preview pane renders a generic grey block for parts with no mapped-package. It looks like a chip. It is **byte-identical** across a capacitor, a-resistor, a ferrite bead and an LED.--**First suspect §1a: did you call `fusion_bind_library_3d` before opening?** If-not, the Placeholder is correct behaviour and there is nothing to report.--### 3.4a 3D-package step times out at ab's 60s budget (Fusion)-A part captures symbol + footprint, then the 3D step fails with *"did not respond-within ab's 60s budget."* First part of a run works, later parts fail. This is-**document accumulation**, not a bridge bug — the Fusion thread's own 1.9.209 note-predicted it: each part opens a library doc + a bound-3D doc, they pile up (64-open on crr by the end of one run), and Fusion slows until the cloud-3D step-exceeds 60s. **Fix: `fusion_close_all_documents` between parts** (its own-`openDocuments` hint tells you to). If the pile-up is already there, restart-Fusion for a clean slate. Verify it's this and not a grant lapse (§2a) by reading-the card tail: a grant lapse says *"did not approve this web page"*, a real 60s-timeout says *"did not respond within ab's 60s budget."*--> **Check:** hash the 3D pane across the batch. Identical hashes = placeholder,-> not the part's model. Also read the Packages table: `Package: Placeholder`-> says it outright.-> ```python-> box = (782, 500, 930, 655) # 3D pane, 1200px-wide capture-> h = hashlib.md5(Image.open(f).convert("RGB").crop(box).tobytes()).hexdigest()-> ```--### 3.5 Screensaver-`adom-screensaver.scr` covers the desktop and every capture comes back white —-indistinguishable from a symbol that failed to render. It cost an hour once.--> **Check:** kill it before each capture.-> ```python-> for p in call("process_list", {"name": "adom-screensaver.scr"})["processes"]:-> call("process_kill", {"pid": p["pid"], "reason": "..."})-> ```---## 4. Ink percentage is not a content check--Measure it if you like, but it certifies nothing:--- KiCad passes clustered at **40%** whether the symbol was right or **wrong**- (§3.1) — the surrounding editor chrome dominates the frame.-- The Fusion **blank workspace** scored **99.5%**, the *highest* in its batch,- because a uniform dark canvas is "inked" everywhere.--Ink detects one thing only: a totally white frame (§3.5). Treat it as a smoke-alarm, never as a verdict.---## 5. Reporting — SHOW the human the pictures, do not describe them--**The cardinal reporting rule: the human verifies the import with their OWN eyes.-Your job is to put the image in front of them, per component, inline. Your words-go WITH the picture, never instead of it.** A text description ("I verified the-LED glyph") asks them to trust your eyes — which is exactly what the evidence-contract exists to remove. John's repeated correction: *"you barely ever show me-images inline in the chat proving the output of the import."* Fix that by-mechanism, not by trying harder to describe.--### The mechanism, exactly--- **`Read`ing an image shows it only to YOU.** It is how you eye-verify content —- necessary, but it proves nothing to the human. Do not confuse "I Read it" with- "I showed it."-- **`SendUserFile` with `display: "render"` is how you SHOW the human.** It- renders the actual image inline. Use it for **every artifact you want them to- see** — which is all of them.-- So the loop per component is: **Read each artifact (verify) → SendUserFile- `display:"render"` the same artifacts (show) → then your one-line read as the- caption or the following text.** The picture leads; the words follow.--### What that looks like in practice--- **Per component, send its artifacts as their own images**, not a page- screenshot with the artifacts as tiny tiles buried in it (John, 2026-08-25:- *"why are you giving me back screenshots of the ENTIRE page... i just want to- see the evidence of each library import"*). Pull each artifact's own `<img>`- from the card (§2a "pull each image one at a time"); those are full-res- captures, not thumbnails.-- **Caption every send with the read** — what you expected and what you actually- saw ("3D pane: real bound body, `Package: 0402B103J500CT · Mapped`, NOT a- Placeholder"). The caption is the report; the image is the proof. Both, every- time.-- **A summary table is not evidence.** It can accompany the images, never replace- them.-- Don't silently batch ten components into one dump at the very end and skip the- in-between. Send evidence as it lands so the human watches the campaign, and can- redirect early (SendUserFile's own guidance). If you must batch to avoid noise,- batch a *component's* artifacts together — never drop the images entirely.--### The rest of the report--- **Read every image.** Not a sample. Not "the flags agree". Every one.-- Count content passes, not flag passes, and show both when they disagree —- the disagreement is the most valuable output of the run.-- When something is wrong, the failing image is the MOST important one to send —- show the Placeholder slab, the stale window, the blank Design tab. Do not- describe a failure you are hiding.-- File what you found on the owning bridge's wiki repo (`adom/kicad-bridge`,- `adom/fusion-bridge`, `adom/pup-bridge`) with the verbatim response JSON and- the bridge version from `bridge_list`.--## 6. Timing, so the progress bar can be honest--Measured on ConfRoomROG, 10 parts, warm apps:--| verb | typical | outlier |-|---|---|---|-| `kicad_show_library` | ~72 s | ~150 s, roughly half of opens |-| `fusion_show_library` | ~27 s | 40 s on the first (cold) part |--The KiCad split is bimodal and does **not** track library size. Do not promise-an ETA tighter than the outlier; report `confidence: estimated` until a bridge-ships a real `progress` block.---## 7. Edge-case catalog (living) — the point of the campaign--Every scenario a run turns up goes here, with where it was fixed. **Add a row the-moment you find something new; update the status when it is fixed; re-run to-confirm and note the confirming version.** This table is what makes repeated runs-compound into coverage. Keep it current — a finding that lives only in a chat-message is lost.--Status legend: **OPEN** (found, not yet fixed) · **FIXED** (fix shipped, cite-where) · **CONFIRMED** (re-run passed on the fixed version).--| # | scenario | surface | class | status / where fixed |-|---|---|---|---|---|-| 1 | `kicad_show_library` returns `success:true` with the **PREVIOUS part still on screen** (stale window) — a green flag over the wrong component | KiCad | kicad-bridge | **OPEN** — filed kicad-bridge#62 (Finding 1); ask: `success:false` + `symbol_not_found` + echo `resolvedSymbol` |-| 2 | symbol name INSIDE the `.kicad_sym` ≠ the filename (`CL21A226MPQNNNE` declares `CL21A226MPQNNN`, trailing E dropped) — asking for the file stem finds nothing | KiCad | kicad-bridge | **OPEN** — kicad-bridge#62 (Finding 2); harness parses the declared name; ask: accept MPN alias or add `kicad_list_symbols` |-| 3 | `fusion_show_library` `success:true` but 3D pane is a **byte-identical Placeholder slab** — because `fusion_bind_library_3d` was not run first (§1a) | Fusion | fusion-bridge | **FIXED** 1.9.209 — returns `threeDBound` + hint; was my own wrong report, retracted |-| 4 | `fusion_show_library` `success:true` with an **empty `Untitled` Design tab** active (wrong active document); scored HIGHEST ink (99.5%) | Fusion | fusion-bridge | **FIXED** 1.9.209 — asserts active doc == library, else `library_not_active` |-| 5 | **document accumulation** across a run (64 docs open) slows Fusion until the **3D step exceeds ab's 60s budget** on later parts | Fusion | fusion-bridge + harness | **FIXED** 1.9.209 (`openDocuments` hint) + harness `fusion_close_all_documents` between parts (§3.4a) |-| 6 | the "**Loading additional modules**" window is a `visible:false` GHOST that never disappears — gating on its title alone waits forever; the real cold-start hang is add-in/doc state, not module load | Fusion | harness | **FIXED** — skill §2a: filter `visible==true`; warm-up part / close-docs for the real hang |-| 7 | `process_list` with an **unrecognized filter key** (`{name:...}` vs its `nameFilter`) returned **EVERY process** with `success:true` → an AI kill-loop took the whole machine (and Hydrogen) down | ab core | ab core | **FIXED + CONFIRMED** ab 2.0.80 — `name`/`pid` aliases, unknown key refused, `unfiltered`+`_warning` |-| 8 | web-control **grant caps at 1 hour**; a ~55-min Fusion run lapses mid-part → symbol+footprint captured, 3D fails "did not approve" | harness | harness | **FIXED** — skill §2a: runner re-grants (kill screensaver, surface ab, click Allow 1 hr) before each part |-| 9 | **screensaver** (`adom-screensaver.scr`) steals foreground (Allow click misses) AND blanks every capture white | environment | harness | **FIXED** — skill §2a/§3.5: kill it before every grant + capture |-| 10 | web-control **progress bar painted RED "stopped" on a successful 3/3** — read the outcome off the card class before `setRes` wrote it | demo page | demo page | **FIXED + CONFIRMED** — outcome passed explicitly at all 6 call sites; 8 unit tests |-| 11 | evidence extraction saved **0 artifacts** though the parts passed — three runner bugs: text-based completion detection, 3×175KB data-URLs overflowing one `pup_eval`, extracting by loop-counter not button index | harness | harness | **FIXED** — skill §2a "four runner bugs" |-| 12 | repeated interrupted clicks **wedge a card's JS promise** — next click disables the button then throws before the first log line; the part hangs to the loop cap | demo page / harness | harness | **FIXED** — skill §2a: `pup_navigate` reload before each run |-| 13 | after a "clean" Fusion **restart** the library open did not activate (`show_symbol` failed "No symbol named X") — a blank Untitled was active with stale tabs from a DIFFERENT part | Fusion | harness | **RESOLVED (harness)** — ROOT CAUSE: stale tabs from a prior session; a kill-restart TRIGGERS Fusion session-restore and brings them BACK. Fix: **gracefully `fusion_close_all_documents` BEFORE the run** (leaves nothing to restore), never kill-restart. Isolated with a single-part test: with tabs cleared the library activated (`activeDocument: 0402B103J500CT`) and 3/3 passed. NOT a bridge bug — verified before filing |-| 14 | Fusion **symbol-surface capture is flaky**: sometimes a dedicated symbol-editor view, sometimes a fallback whole-window grab. The symbol itself is present + correct either way (import is fine); only the evidence-capture path varies, so a strict N/3 scores 2/3 on the fallback | Fusion | fusion-bridge? / page | **OPEN, low severity** — NOT an import failure (the symbol is correct in both); it is capture inconsistency. Track how often across a run before filing; the import is good regardless |-| 15 | the library/bind flow **leaks `Untitled` 3D docs that accumulate unbounded** (~5 per import → 16 after 3 imports → ~50 over a 10-part run), and `fusion_close_all_documents` CANNOT close them (they resist as unsaved) — the root of the memory pressure behind #5's 60s timeout | Fusion | fusion-bridge | **OPEN** — filed fusion-bridge#59 (John spotted it); ask: close/reuse the scratch Untitled, or expose a force-close for a caller |-| 16 | on a LONG unattended REMOTE run the 1-hour web-control grant lapses mid-run, and the harness re-grant is FRAGILE: the Allow-button click misses when the ab window moves or the screensaver steals foreground, so parts start failing GRANT FAILED | harness / ab | harness / ab-core | **OPEN** — harness re-grants but the pixel-click is unreliable on a remote box. Real ab-core question: an unattended-run grant mode, a >1h option, or a CLI/owner-grantable path so a long remote campaign is not gated on a fragile UI click |-| 17 | **symbol text-overlap**, MEASURED + SYSTEMATIC: some components' `.lbr` place `>NAME`/`>VALUE` at y≈0 (on the symbol body, and CQ03SAF1004T5E has BOTH at y=0, on each other) instead of ±10 like clean parts. Broken: CQ03SAF1004T5E, CL21A226MPQNNNE. Clean: 0402B103J500CT, 0603X475K100CT (y=±10). Fusion renders the bad `.lbr` faithfully | component-data / .lbr-generator | NOT the bridge | **OPEN, routed to the .lbr converter/linter** — a `.lbr`-generation inconsistency, SCANNABLE library-wide (fetch each `.lbr`, flag `>NAME`/`>VALUE` y near 0 / on the pin span). The automated symbol-label-overlap check, proven in practice. Import 'succeeds' (3/3, 3D mapped) but the symbol is visually wrong |-| 17a | **library-wide scan of #17** (50 components): **9 resistors have `>NAME`+`>VALUE` both at exactly y=0** (stacked, on body) — CR0603-FX-5601ELF, RMCF0603FT1R50, CQ03SAF1004T5E, 0603SAF1500/1/2/3T5E, 0603WAF100JT5E, CR0402-FX-1000GLF — a resistor-symbol-generation bug. 5 large ICs flagged but likely OK (labels at pin-span edge). ⚠ the naive "label-y within pin-span" check MISSED CL21A226MPQNNNE (±0.63, pins at 0) — false negative | component-data / .lbr-generator | NOT the bridge | **OPEN** — the real geometry check needs actual symbol-body extent + text height (true bbox overlap), not pin-span. 9-resistor list is definitive + actionable |--**Still-untested surfaces (expect new rows here):** every non-Library button in-§1b — Symbol / Footprint / 3D-chip / Schematic / 2D-board / 3D-board / Project,-for both apps. And parts beyond the passives-heavy sets run so far (connectors,-multi-pin ICs, multi-unit symbols, no-3D parts).---## 8. The feedback loop — route every finding to its owner--A found scenario is only half the work; the value is the fix, and the fix must-land in the RIGHT place or it does not stick. Classify every catalog row:--- **ab core** (`C:/Github/adom-bridge`) — the verb surface, the relay, the demo- page, screenshots, process/window verbs, the grant model. You OWN this: fix it,- bump the version, release (the release ladder in CLAUDE.md), and re-run to move- the row to CONFIRMED. Example: #7, #10.-- **kicad-bridge / fusion-bridge** — anything about how the library/symbol/- footprint/3D verb behaves inside the app. You do NOT own these (cloud-owned- wiki repos). File a precise issue on `adom/kicad-bridge` or `adom/fusion-bridge`- with the verbatim response JSON + the bridge version from `bridge_list`, then- watch for their fix and re-run against it. Example: #1, #2 (open), #3, #4, #5- (fixed by the fusion thread in 1.9.209 — re-run to confirm).-- **harness** (`C:/tmp/libui.py`, `regrant.py`) — the runner, the grant flow, the- capture. Fix the script; the lesson also goes into this skill (§2a) so the next- run inherits it. Example: #6, #8, #9, #11, #12.-- **this skill** — every lesson, always. A fix that isn't written down is- re-learned. The catalog (§7) + §2a are the durable memory of the campaign.--**The discipline:** after each run, for anything new — (1) add the catalog row,-(2) name its class, (3) fix it there or file it there, (4) re-run to confirm.-When you fix an ab-core or harness item, the confirming re-run is part of the-same session. When you file a bridge item, the confirming re-run happens after-the bridge thread ships — the wiki-issue watcher (a background poll of the issues-you filed) tells you when to look.--**Watch your own issues.** After filing on a bridge, keep a light background-watch on those issues so a shipped fix (like fusion-bridge's 1.9.209) is picked-up and re-tested promptly, rather than sitting unconfirmed.---## 9. What a run leaves behind — the local import ledger (pre-wiki)--Before asking the wiki for an attestation/counter feature, **track imports-locally and get real experience with the data.** Act like a normal Adom user-keeping a record of their own imports; see what's actually useful and trustworthy-first, then spec a feature backed by evidence rather than a guess.--`C:/tmp/ledger.py` writes `C:/tmp/import-ledger.json`, keyed-`component → app → {verified_imports, runs[]}`. The harness records each part-automatically. Design rules, all about not fooling ourselves:--- **Objective fields are recorded by machine, subjective ones stay `null` until- reviewed.** Recorded automatically: timestamp, ab + bridge versions, duration,- `flag_pass` (the verb's `success`), and each artifact's **filename + sha256**- (an attestation must carry a hashable image — a flag is a claim, an image is- proof). Left `null` until a human/AI reads the pictures: `content_verified`,- `notes`, and the `geometry` block (`3d_bound`, `text_overlap`,- `3d_alignment_offset`).-- **`verified_imports` NEVER counts a `content_verified:false` run.** A flag pass- with the image not yet reviewed counts provisionally; a reviewed-bad run is- excluded. The count is honest by construction — it can't drift green.-- **The geometry slots are where §the-quality-plan lands.** They're `null` now;- as we build the checks (symbol text-overlap from the kicad-cli SVG export;- 3D-body-center vs footprint-center offset) they get filled with measured- numbers, and later become bridge verbs so every import — not just our test- runs — gets the objective signal.-- **The aggregate is a quality breakdown, not a bare tally.** `ledger.py`'s- summary reports imports / flag-ok / reviewed per component+app, so- "imported N times" always carries how many were actually verified. When this- graduates to the wiki, the count publishes with that breakdown, never alone.--The two signals stay split (see the design discussion): the **quality-attestation** (git file, owner-written — us) and the **field-import count**-(a wiki-DB counter via an authenticated `import-events` endpoint, because a-non-owner user can't write the page's files). We build the local ledger now; we-spec the wiki endpoint only once the local data has taught us what to ask for.---## 8a. Filing a bridge issue, and watching for the fix — the mechanics--§8 says "file it on the bridge and re-run when they ship." Here is exactly how,-because the details bite.--### Why it MUST be a wiki issue (not a message)--The kicad/fusion bridge threads run under a DIFFERENT Adom account-(`[email protected]`) than this campaign typically does (`[email protected]`), so-you **cannot `@mention` or `SendMessage` them** — they will not appear in your-reachable-agents list. **The bridge's wiki discussion is the only channel that-reaches its author**, and per CLAUDE.md it is their actual work queue. So a-finding routed to a bridge is a wiki issue, full stop.--### Filing (verified commands)--```bash-# 1. check for a duplicate first-adom-wiki issue list adom/fusion-bridge # or adom/kicad-bridge, adom/pup-bridge--# 2. write the body to a FILE - never inline --body:-# the body has backticks and JSON braces, which the shell / the CLI's inline-# parser mangle ("unexpected argument", "invalid escape"). --body-file is safe.-adom-wiki issue create adom/fusion-bridge \- --title "<one-line symptom>" --category bug-report \- --body-file /c/tmp/issue_fusion.md-```--The body must carry, every time: the **verbatim response JSON**, the **bridge-version from `bridge_list`**, ab's version, the OS, and the exact repro (which-button/verb, which part). A finding without the verbatim response is not-actionable. Attach or reference the evidence images.--**Correct yourself in the SAME thread if you were wrong.** Twice this session a-filed finding turned out to be my own error (I skipped `fusion_bind_library_3d`;-I snap-diagnosed a name mismatch the file disproved). The fix is a follow-up-COMMENT that withdraws the wrong part and narrows to what survives — never a-silent delete:--```bash-adom-wiki issue comment adom/fusion-bridge#58 --body-file /c/tmp/correction.md-```--A retraction with the evidence is worth more to the maintainer than a report-polished until it was safe to send. It also keeps YOUR credibility as a reporter.--### The concatenated-JSON caveat--`adom-wiki issue view` / `issue list` output is often **several JSON documents-concatenated**, so a single `json.load` throws `Extra data`. Parse with a decoder-loop:--```python-from json import JSONDecoder-s = subprocess.run([...], capture_output=True, text=True).stdout-d, i = JSONDecoder().raw_decode(s) # first doc; or loop raw_decode(s, i)-disc = d.get("discussion") or d-replies = d.get("replies") or disc.get("replies") or []-```--Also: on Windows, force UTF-8 stdout in the parser or a `\u2192`-class char kills-it with `UnicodeEncodeError` under cp1252.--### Watching for the fix (the watcher)--After filing, keep a light background poll over the issues you opened so a shipped-fix is picked up promptly instead of sitting unconfirmed. The pattern-(`C:/tmp/wiki_watch.py`): every ~2.5 min, `issue view` each filed ref, track the-reply count on disk, emit a line only when a NEW reply lands or the status flips.-Run it as a persistent background monitor; a new-reply event is your cue to READ-the reply and **re-run the affected surface against the shipped version** to move-the catalog row (§7) from OPEN → CONFIRMED.--This is not hypothetical: this session the fusion thread replied on #58 that all-three findings were fixed in **1.9.209** — the watcher is what turns "they-replied" into "we re-tested and confirmed" without you having to remember to-check. The whole loop only closes when the confirming re-run is done and the-catalog says CONFIRMED, not when the bridge says "fixed."+--- +name: adom-bridge-web-control-test +description: >- + How to run and trust a web-control test of the KiCad/Fusion library verbs across a batch of + wiki components: the evidence contract and the known false-success modes. Use for a web + control test or library matrix run. +disable-model-invocation: true +--- + +# Web-control library test — the procedure, and the reasons for it + +Every clause here was written after a run reported success and the images said +otherwise. Nothing in it is precautionary. + +## The one-sentence rule + +**A flag is a claim; an image is evidence; only a *read* image is proof.** +`success: true` from a library verb has been wrong in five distinct ways +(below). Never report a part as passing until you have looked at its picture and +named what you saw. + +--- + +## The mission — why this exists + +This is not a smoke test you run once and tick off. It is a **standing +edge-case-hunting campaign** with one goal: *when any Adom user imports a Fusion +or KiCad library — or a symbol, a footprint, a 3D chip, a schematic, a board, a +project — it works perfectly for them.* + +The way you get there is to **run these imports over and over, across many parts +and every surface, and find the crazy scenarios HERE — first — before a real +user does.** Every scenario you find gets fixed in the right place (ab core, the +kicad/fusion bridge, the harness, or this skill) and then re-run to confirm. +Running the same test repeatedly is not waste; each run either adds a scenario to +the catalog (§7) or confirms a fix. The catalog is the point — it is how +repetition compounds into coverage. + +So a run is never "done" at 10/10 green. A run is done when you have either +**found a new edge case** (→ §8, route it to a fix) or **confirmed the known ones +still pass**. Both outcomes are the deliverable; a run that found nothing new and +a run that found a bug are both successful runs. + +--- + +## ⭐ The NORTH STAR — every import begins with a PRE-FLIGHT PLAN and runs against a REAL progress bar (John, 2026-08-26) + +This is the most important UX goal of the whole campaign, and it is easy to lose +sight of while chasing individual bugs. **A library import must never fire blind +verbs that fail one after another.** It must first *gather requirements*, show the +user a *plan* with a time estimate, and then run against a *real measured progress +bar*. + +The failure this replaces (real, John on AdomLapper with Fusion closed): the demo +fired `fusion_bind_library_3d` → `fusion_show_symbol` → `footprint` → `3d`, and +**every one failed with "Fusion 360 is installed but not running"** — no +detection, no auto-open, no ETA, no bar. A user cannot tell "Fusion is just +closed, opening it" from "this is broken." + +**What must happen instead — pre-flight, then a measured run:** + +1. **Detect Fusion's state.** If it is not running, **auto-open it** and SAY so: + *"Fusion isn't open — opening it (usually ~54 s)."* Fold that into the ETA. + Never let "the app was merely closed" become four silent failures. +2. **Enumerate the files to stage** (`.lbr` + `.step`), their **sizes**, and an + **estimated transfer time** from the user's typical download speed. +3. **Enumerate the steps** with a **per-step ETA from past-run history** — e.g. + create symbol, create footprint, import 3D to Fusion cloud, show ganged view + with refreshed 3D icon. +4. **Show the plan**: *"Ready to create Fusion library. ~2 min 46 s on average + based on past runs. (Had Fusion already been open, ~49 s.)"* + the step list. +5. **Run with a real progress bar** advancing across the planned steps, streaming + each artifact as captured (symbol → footprint → 3D chip → ganged, per the + evidence contract), `confidence:"measured"` where history exists, `blockedBy` + when a step waits on a human (Autodesk sign-in). + +**Division of labor — ~80 % is the BRIDGE's job, ~20 % the orchestrator's (ab / +the web page):** + +- **Bridge:** is Fusion running? auto-open it + report the launch ETA; the step + list + **per-step ETAs from its own past-run telemetry** (only the bridge has + run these steps before); the measured-progress stream; a clear async-completion + signal; all four artifacts including the dedicated 3D chip. +- **Orchestrator:** local file sizes + transfer-time estimate on THIS machine; + rendering the plan summary + the progress-bar UI. + +**The contract to demand from the bridge** is a pre-flight verb, e.g. +`fusion_plan_library_import { lbrPath, stepPath }` → `{ fusionRunning, willOpenFusion, +openEtaSec, steps:[{key,etaSec}], totalEtaSec, totalEtaIfFusionOpenSec }`, then the +import emits measured progress against that plan. + +Filed as **fusion-bridge #64** (this contract), on top of **#60** (4-artifact +bundle + streamed + measured progress), **#62** (capture false-pass + async +completion signal), **#63** (the dedicated 3D-chip view). When you run an import +and it does NOT begin with a plan + a bar, that is itself the finding — route it, +do not just work around it. + +--- + +## 1. The evidence contract + +Importing a component library is not one thing succeeding. It is four, and the +test owes the human all four **per part**: + +| # | artifact | what proves it | +|---|---|---| +| 1 | **symbol** | schematic glyph drawn on the canvas, with the part's own geometry | +| 2 | **footprint** | pad layout for that package | +| 3 | **3D chip** | the part's **own** STEP body — not a generic block | +| 4 | **ganged view** (Fusion) | the one window showing 1–3 together | + +The web-control page already names this contract: its library button reads +**"Library (<part>) — proves all 3"**. If your evidence does not show all of +them, the test failed *or the bridge failed* — decide which and say so. It is +never "close enough". + +## 1a. The library verb alone is NOT the library flow + +**Read this before concluding a bridge is broken.** I filed two bug reports that +were my own error, both from the same mistake: calling `kicad_show_library` / +`fusion_show_library` bare and treating the result as the library flow. + +The real flow — what the web-control page does, and what you must replicate if +you drive the verbs directly: + +| step | why | +|---|---| +| **Fusion: `fusion_bind_library_3d {libraryPath, stepPath}` FIRST** | without it the library has no package3d and Fusion renders the grey Placeholder slab **by design**. This is not a bug. Skipping it manufactures §3.4. | +| then the `*_show_library` open | | +| then `show_symbol` / `show_footprint` / the 3D surface | one capture each — this is what produces the four artifacts | +| poll **`kicad_progress` / `fusion_progress`** throughout | the bridges DO emit a measured `progress` block: percent, ETA, cold/warm | + +The page scores the result **N/3 and only 3/3 is a pass**, on the stated +principle that *a library with a symbol but no 3D model is not a working +library*. + +So the bundle is not missing from the product — it is missing from any harness +that calls one verb and screenshots the window. Measured live on crr: + +``` +show_symbol starting - 0% - 7s left - warm +show_footprint starting - 45% - 12s left - warm +``` + +**Before filing a bridge bug, check whether the UI path does something you +skipped.** Drive the part through the page first (§2) and read the card's own +log — it narrates every step it takes. + +What is genuinely worth reporting is a verb that succeeds while its own +precondition is unmet and says nothing: `fusion_show_library` returning +`success: true` with `3D bound: false` is a missing hint, not a broken bridge. + +--- + +## 1b. The surface matrix — run ALL of these, not just Library + +The web-control page has one button per content type, for **both** apps. Most +runs so far have exercised only **Library ("proves all 3")** — which means the +individual-artifact and board/project surfaces are largely UNTESTED, and that is +where the next edge cases live. A real user clicks any of these, so test all of +them. + +| surface | KiCad button | Fusion button | what "works perfectly" means | +|---|---|---|---| +| **Symbol** | Symbol (`kicad_show_symbol`) | Symbol (`fusion_show_symbol`) | the part's schematic glyph drawn, its own geometry, correct title | +| **Footprint** | Footprint (`kicad_show_footprint`) | Footprint | the pad layout for that package, right pad count/shape | +| **3D chip / package** | 3D chip (`kicad_show_3d`) | 3D package | the part's OWN STEP body, not a Placeholder slab (§3.4) | +| **Library (all 3)** | Library — proves all 3 | Library — proves all 3 | symbol + footprint + 3D, N/3 = 3/3 | +| **Schematic** | Schematic | Schematic | just this part placed on a sheet | +| **2D board** | 2D board (tight edge cuts) | 2D board (part placed) | part placed on a board, edge cuts | +| **3D board** | 3D board (real 3D body) | 3D board | the real 3D body ON the board | +| **Project** | Project (top of the tree) | Project (top of the design) | the whole project/design opens | +| **State** | Show KiCad state (every window) | — | every window captured, incl. error dialogs | + +**Each surface owns fewer artifacts than Library, so its false-success modes +differ** — e.g. a bare Symbol capture can be a stale window (§3.1) with no +footprint/3D to cross-check, so the title assertion matters MORE there, not less. +Rotate through the surfaces across runs; a scenario found on the 3D-chip button +may never surface on the Library button. + +**Vary the PARTS too.** Edge cases cluster by part shape: multi-unit symbols, +parts whose declared name ≠ filename (§3.2), parts with no 3D model, polarized +vs non-polarized, connectors, ICs with many pins, LEDs, ferrites. A 10-part run +of all capacitors proves less than 10 parts spanning cap/resistor/inductor/LED/ +connector/IC. The wiki has dozens — pull a diverse set each run. + +--- + +## 2. Drive it from the web-control page, in pup + +The point of the exercise is the wiki's Quick Actions surface. Calling the verbs +straight over the relay tests the bridge but shows the human nothing about the UI +they are shipping. + +**⭐ SHOW THE HUMAN THE WEB-CONTROL DEMO RENDERED IN PUP, EVERY RUN — this is a +standing deliverable, not an afterthought.** John, repeatedly: *"every time i ask +you to run tests you're supposed to show me your web control in pup so i can see +generally what the ui will look like in the wiki with colby's quick actions."* The +demo page IS the surface real users will click in the wiki, so a run owes him a +look at it — the actual rendered UI, not just "the test passed." Do it by +mechanism so you cannot forget: + +- **At the START of every run**, `pup_screenshot {fullPage:true}` the page and + `SendUserFile display:"render"` it, captioned as "the web-control UI the wiki + quick-actions will render." A live mid-run shot (cards showing progress + + evidence thumbnails) is even better than a cold one — it shows the UI *working*. +- This is SEPARATE from the per-component artifact images (§5). The pup-UI shot is + "here is the surface"; the artifact images are "here is what the import + produced." A run shows BOTH. +- `pup_screenshot` writes into the container filesystem (`savedTo` is a WSL path); + copy it out before `SendUserFile`. + + +**ab already serves the demo. Do not copy the HTML anywhere.** It is on +`http://127.0.0.1:24681/` on every machine with ab installed (`web_demo.rs`, +`DEMO_PORT`), and that exact origin is the sole entry in `BUILTIN_WEB_ORIGINS` +in `direct_api.rs` — pre-trusted, so commands are not gated behind a per-origin +grant. + +```bash +# open ab's OWN demo server in Chrome for Testing ON THE MACHINE UNDER TEST +adom-bridge --target <machine> pup_open_window \ + '{"url":"http://127.0.0.1:24681/","width":1500,"height":1000}' + +# drive the real buttons, screenshot the real UI +adom-bridge --target <machine> pup_eval '{"sessionId":"...","expression":"..."}' +adom-bridge --target <machine> pup_screenshot '{"sessionId":"...","fullPage":true}' +``` + +Notes that cost time to learn: + +- **`file://` does not work.** It is an opaque origin, and ab hard-refuses + commands from those: *"Adom Bridge refuses commands from an opaque origin + (Origin: null)"*. The page will fetch the part's files from the wiki fine and + then fail on the first `write_file`, which reads like a bridge bug and is not + one. Serving the copied HTML yourself is also a dead end — a hand-rolled + static server is a *different* origin and lands you back in the Allow/Deny gate. +- Don't reach for `python -m http.server` on a Windows target anyway: `python` + there is usually the **Microsoft Store stub**, which exits immediately, so + `launch_process` returns a pid and nothing ever listens. +- The verb family is **`pup_*`**. `browser_*` is gone — the alias only survives + as a hint telling you to rename. +- `pup_screenshot` writes into the **container** filesystem (`savedTo` is a WSL + path). Copy it out before trying to read it. +- The page talks to `127.0.0.1:24680`, i.e. **the machine the browser is on**. + Run the browser on the machine under test or you are testing the wrong PC. +- Omit `sessionId` on the first open and the thread gets its own window; reuse + that id afterwards. + +--- + +## 2a. Running the batch reliably — the harness lessons + +Everything here cost a restart to learn on crr (2026-08-25). None of it is the +bridge's fault; it is what a batch runner must handle. + +### The environment fights you two ways — automate both + +- **The web-control grant caps at 1 HOUR, and a 10-part Fusion pass runs ~55 min.** + It WILL lapse mid-run, and the symptom is a part that captures the symbol + + footprint then fails on the 3D step with *"did not approve this web page to run + this verb."* Don't hand-click Allow each time — **re-grant from the runner + before every part**: check `web_control_grants`; if the timed grant for + `http://127.0.0.1:24681` has < ~5 min left, raise the prompt (click any library + button), surface ab (`desktop_window_show {hwnd}` → `desktop_raise_window`), + find the teal *Allow 1 hr* button in a window capture, map image→screen through + the window rect, and `desktop_click` it. Verify the grant went `mode:"timed"` + before proceeding. +- **The screensaver (`adom-screensaver.scr`) steals foreground AND blanks every + capture.** When it owns the desktop, `desktop_get_foreground` returns hwnd `0`, + your Allow click never reaches ab's WebView, and screenshots come back white. + **Kill it before every grant attempt and before every capture** (`process_list + {name:"scr"}` → `process_kill` each `.scr`). This is §3.5 as an active step, not + just a check. + +### `desktop_window_show`/`desktop_click` succeed without doing anything + +Both return `{success:true}` even when the window never surfaced or the click +never landed. To actually grant: `desktop_window_show {hwnd}` → `desktop_raise_window +{hwnd}` → **confirm `desktop_get_foreground` == that hwnd** → then click. If +foreground is `0` or something else, the screensaver or another window has it — +clear that first. `success:true` on a click is not proof the pixel was hit. + +### Four runner bugs that all looked like bridge failures + +1. **Completion detection: poll `btn.disabled`, NEVER the word "running".** The + demo disables the card's button for the whole run and re-enables it on every + exit path — that flag is the one true "done" signal. The progress block + REPLACES the running text mid-run, so a text check declares FAIL at 10s while + the bar is at 85%. +2. **Pull each evidence `<img>` ONE AT A TIME.** Three ~175 KB data-URLs in a + single `pup_eval` result overflow it and come back empty — so every part + silently saved 0 artifacts. Get the count + labels first (tiny), then fetch + each `img[i].src` in its own eval. +3. **Extract by the BUTTON index, not the loop counter.** A capture bug hid here + for two runs: the poll used the button index (25) but the extractor was passed + the 1..10 loop counter, so it read a different, empty card. Same index for + poll and extract. +4. **Reload the page before the run to clear WEDGED card state.** Repeated + interrupted clicks leave a card's per-run JS promise wedged; the next click + disables the button then throws before the first log line, and the part hangs + to the loop cap producing nothing. A fresh `pup_navigate` clears it — do it + once at the start of every run. + +### Fusion cold-start and document accumulation + +- **The *"Loading additional modules"* window is a VISIBLE:FALSE ghost — do not + treat its presence as "still loading" (corrected 2026-08-26).** Fusion leaves a + nearly-fullscreen `Loading additional modules` window in `desktop_enum_windows` + with **`visible: false`**, and it NEVER disappears — so a check for the title + alone waits forever for a window that is not actually blocking anything. When + you gate on it, **filter `visible == true`**: a *visible* loading window means + genuinely still loading; an invisible one is a leftover Fusion keeps around on a + fully-loaded instance (measured: 1.7 GB RSS, 4600 handles, with the ghost still + enumerated). If part 1 still hangs on "waiting for idle" with no *visible* + loading window, the cause is the cold add-in or doc state, not module loading — + a throwaway warm-up part or a `fusion_close_all_documents` is the lever, not + more waiting. +- **Close documents BETWEEN Fusion parts** (see §3.4a). A 10-part run left **64 + documents** open on crr, which slowed the 3D step until it blew ab's 60s budget + on every part after the first. Call `fusion_close_all_documents` between parts. + If the pile-up already happened (`fusion_get_app_state.documents` in the dozens), + a full Fusion restart is the only clean reset — `close_all` won't force-close + the stray `Untitled` docs. + +### Timing to expect (post-fixes, crr) + +KiCad via the UI: ~54 s/part, steady. Fusion via the UI: ~40 s warm per part once +Fusion is settled and documents are closed between runs; the FIRST part after a +cold start pays a multi-minute module-load tax. Budget the whole Fusion pass at +~50–60 min and let the self-granting runner carry it across the 1-hour grant. + +--- + +## 3. The five false successes + +Each of these produced a green flag and a plausible-looking screenshot. + +### 3.1 Stale window (KiCad) +A failed open leaves the **previous** part on screen. The capture is full of ink, +the symbol is real — it is just the wrong component. + +> **Check:** the window title must contain the part you asked for. +> ```python +> ws = call("desktop_enum_windows", {"includeOffscreen": True}) +> assert any(want.lower() in (w["title"] or "").lower() for w in ws["windows"]) +> ``` + +### 3.2 Name mismatch (KiCad) +The symbol name **inside** the `.kicad_sym` is often not the filename. +Measured: `CL21A226MPQNNNE` declares itself `CL21A226MPQNNN` — the trailing `E` +is dropped. Asking for the file stem finds nothing, and 3.1 then shows you the +last part instead. + +> **Check:** parse the declared name out of the file and ask for *that*. +> ```python +> for m in re.finditer(r'\(symbol\s+"([^"]+)"', text): +> if not re.search(r"_\d+_\d+$", m.group(1)): # skip _1_1 sub-units +> return m.group(1) +> ``` + +### 3.3 Blank workspace (Fusion) +Fusion can land on an empty `Untitled` **Design** tab while the part's tab sits +unfocused in the tab bar. The verb reports success. The capture is a huge empty +dark canvas. + +> **Check:** the *active* tab's title must be the part, and the Electronics +> panels (Symbol / Component Properties / Packages) must be present. An empty +> SOLID/SURFACE/MESH ribbon means you are in Design, not the library. + +### 3.4 Placeholder 3D (Fusion) — usually YOUR bug, not the bridge's +The 3D preview pane renders a generic grey block for parts with no mapped +package. It looks like a chip. It is **byte-identical** across a capacitor, a +resistor, a ferrite bead and an LED. + +**First suspect §1a: did you call `fusion_bind_library_3d` before opening?** If +not, the Placeholder is correct behaviour and there is nothing to report. + +### 3.4a 3D-package step times out at ab's 60s budget (Fusion) +A part captures symbol + footprint, then the 3D step fails with *"did not respond +within ab's 60s budget."* First part of a run works, later parts fail. This is +**document accumulation**, not a bridge bug — the Fusion thread's own 1.9.209 note +predicted it: each part opens a library doc + a bound-3D doc, they pile up (64 +open on crr by the end of one run), and Fusion slows until the cloud-3D step +exceeds 60s. **Fix: `fusion_close_all_documents` between parts** (its own +`openDocuments` hint tells you to). If the pile-up is already there, restart +Fusion for a clean slate. Verify it's this and not a grant lapse (§2a) by reading +the card tail: a grant lapse says *"did not approve this web page"*, a real 60s +timeout says *"did not respond within ab's 60s budget."* + +> **Check:** hash the 3D pane across the batch. Identical hashes = placeholder, +> not the part's model. Also read the Packages table: `Package: Placeholder` +> says it outright. +> ```python +> box = (782, 500, 930, 655) # 3D pane, 1200px-wide capture +> h = hashlib.md5(Image.open(f).convert("RGB").crop(box).tobytes()).hexdigest() +> ``` + +### 3.5 Screensaver +`adom-screensaver.scr` covers the desktop and every capture comes back white — +indistinguishable from a symbol that failed to render. It cost an hour once. + +> **Check:** kill it before each capture. +> ```python +> for p in call("process_list", {"name": "adom-screensaver.scr"})["processes"]: +> call("process_kill", {"pid": p["pid"], "reason": "..."}) +> ``` + +--- + +## 4. Ink percentage is not a content check + +Measure it if you like, but it certifies nothing: + +- KiCad passes clustered at **40%** whether the symbol was right or **wrong** + (§3.1) — the surrounding editor chrome dominates the frame. +- The Fusion **blank workspace** scored **99.5%**, the *highest* in its batch, + because a uniform dark canvas is "inked" everywhere. + +Ink detects one thing only: a totally white frame (§3.5). Treat it as a smoke +alarm, never as a verdict. + +--- + +## 5. Reporting — SHOW the human the pictures, do not describe them + +**The cardinal reporting rule: the human verifies the import with their OWN eyes. +Your job is to put the image in front of them, per component, inline. Your words +go WITH the picture, never instead of it.** A text description ("I verified the +LED glyph") asks them to trust your eyes — which is exactly what the evidence +contract exists to remove. John's repeated correction: *"you barely ever show me +images inline in the chat proving the output of the import."* Fix that by +mechanism, not by trying harder to describe. + +### The mechanism, exactly + +- **`Read`ing an image shows it only to YOU.** It is how you eye-verify content — + necessary, but it proves nothing to the human. Do not confuse "I Read it" with + "I showed it." +- **`SendUserFile` with `display: "render"` is how you SHOW the human.** It + renders the actual image inline. Use it for **every artifact you want them to + see** — which is all of them. +- So the loop per component is: **Read each artifact (verify) → SendUserFile + `display:"render"` the same artifacts (show) → then your one-line read as the + caption or the following text.** The picture leads; the words follow. + +### What that looks like in practice + +- **Per component, send its artifacts as their own images**, not a page + screenshot with the artifacts as tiny tiles buried in it (John, 2026-08-25: + *"why are you giving me back screenshots of the ENTIRE page... i just want to + see the evidence of each library import"*). Pull each artifact's own `<img>` + from the card (§2a "pull each image one at a time"); those are full-res + captures, not thumbnails. +- **Caption every send with the read** — what you expected and what you actually + saw ("3D pane: real bound body, `Package: 0402B103J500CT · Mapped`, NOT a + Placeholder"). The caption is the report; the image is the proof. Both, every + time. +- **A summary table is not evidence.** It can accompany the images, never replace + them. +- Don't silently batch ten components into one dump at the very end and skip the + in-between. Send evidence as it lands so the human watches the campaign, and can + redirect early (SendUserFile's own guidance). If you must batch to avoid noise, + batch a *component's* artifacts together — never drop the images entirely. + +### The rest of the report + +- **Read every image.** Not a sample. Not "the flags agree". Every one. +- Count content passes, not flag passes, and show both when they disagree — + the disagreement is the most valuable output of the run. +- When something is wrong, the failing image is the MOST important one to send — + show the Placeholder slab, the stale window, the blank Design tab. Do not + describe a failure you are hiding. +- File what you found on the owning bridge's wiki repo (`adom/kicad-bridge`, + `adom/fusion-bridge`, `adom/pup-bridge`) with the verbatim response JSON and + the bridge version from `bridge_list`. + +## 6. Timing, so the progress bar can be honest + +Measured on ConfRoomROG, 10 parts, warm apps: + +| verb | typical | outlier | +|---|---|---| +| `kicad_show_library` | ~72 s | ~150 s, roughly half of opens | +| `fusion_show_library` | ~27 s | 40 s on the first (cold) part | + +The KiCad split is bimodal and does **not** track library size. Do not promise +an ETA tighter than the outlier; report `confidence: estimated` until a bridge +ships a real `progress` block. + +--- + +## 7. Edge-case catalog (living) — the point of the campaign + +Every scenario a run turns up goes here, with where it was fixed. **Add a row the +moment you find something new; update the status when it is fixed; re-run to +confirm and note the confirming version.** This table is what makes repeated runs +compound into coverage. Keep it current — a finding that lives only in a chat +message is lost. + +Status legend: **OPEN** (found, not yet fixed) · **FIXED** (fix shipped, cite +where) · **CONFIRMED** (re-run passed on the fixed version). + +| # | scenario | surface | class | status / where fixed | +|---|---|---|---|---| +| 1 | `kicad_show_library` returns `success:true` with the **PREVIOUS part still on screen** (stale window) — a green flag over the wrong component | KiCad | kicad-bridge | **OPEN** — filed kicad-bridge#62 (Finding 1); ask: `success:false` + `symbol_not_found` + echo `resolvedSymbol` | +| 2 | symbol name INSIDE the `.kicad_sym` ≠ the filename (`CL21A226MPQNNNE` declares `CL21A226MPQNNN`, trailing E dropped) — asking for the file stem finds nothing | KiCad | kicad-bridge | **OPEN** — kicad-bridge#62 (Finding 2); harness parses the declared name; ask: accept MPN alias or add `kicad_list_symbols` | +| 3 | `fusion_show_library` `success:true` but 3D pane is a **byte-identical Placeholder slab** — because `fusion_bind_library_3d` was not run first (§1a) | Fusion | fusion-bridge | **FIXED** 1.9.209 — returns `threeDBound` + hint; was my own wrong report, retracted | +| 4 | `fusion_show_library` `success:true` with an **empty `Untitled` Design tab** active (wrong active document); scored HIGHEST ink (99.5%) | Fusion | fusion-bridge | **FIXED** 1.9.209 — asserts active doc == library, else `library_not_active` | +| 5 | **document accumulation** across a run (64 docs open) slows Fusion until the **3D step exceeds ab's 60s budget** on later parts | Fusion | fusion-bridge + harness | **FIXED** 1.9.209 (`openDocuments` hint) + harness `fusion_close_all_documents` between parts (§3.4a) | +| 6 | the "**Loading additional modules**" window is a `visible:false` GHOST that never disappears — gating on its title alone waits forever; the real cold-start hang is add-in/doc state, not module load | Fusion | harness | **FIXED** — skill §2a: filter `visible==true`; warm-up part / close-docs for the real hang | +| 7 | `process_list` with an **unrecognized filter key** (`{name:...}` vs its `nameFilter`) returned **EVERY process** with `success:true` → an AI kill-loop took the whole machine (and Hydrogen) down | ab core | ab core | **FIXED + CONFIRMED** ab 2.0.80 — `name`/`pid` aliases, unknown key refused, `unfiltered`+`_warning` | +| 8 | web-control **grant caps at 1 hour**; a ~55-min Fusion run lapses mid-part → symbol+footprint captured, 3D fails "did not approve" | harness | harness | **FIXED** — skill §2a: runner re-grants (kill screensaver, surface ab, click Allow 1 hr) before each part | +| 9 | **screensaver** (`adom-screensaver.scr`) steals foreground (Allow click misses) AND blanks every capture white | environment | harness | **FIXED** — skill §2a/§3.5: kill it before every grant + capture | +| 10 | web-control **progress bar painted RED "stopped" on a successful 3/3** — read the outcome off the card class before `setRes` wrote it | demo page | demo page | **FIXED + CONFIRMED** — outcome passed explicitly at all 6 call sites; 8 unit tests | +| 11 | evidence extraction saved **0 artifacts** though the parts passed — three runner bugs: text-based completion detection, 3×175KB data-URLs overflowing one `pup_eval`, extracting by loop-counter not button index | harness | harness | **FIXED** — skill §2a "four runner bugs" | +| 12 | repeated interrupted clicks **wedge a card's JS promise** — next click disables the button then throws before the first log line; the part hangs to the loop cap | demo page / harness | harness | **FIXED** — skill §2a: `pup_navigate` reload before each run | +| 13 | after a "clean" Fusion **restart** the library open did not activate (`show_symbol` failed "No symbol named X") — a blank Untitled was active with stale tabs from a DIFFERENT part | Fusion | harness | **RESOLVED (harness)** — ROOT CAUSE: stale tabs from a prior session; a kill-restart TRIGGERS Fusion session-restore and brings them BACK. Fix: **gracefully `fusion_close_all_documents` BEFORE the run** (leaves nothing to restore), never kill-restart. Isolated with a single-part test: with tabs cleared the library activated (`activeDocument: 0402B103J500CT`) and 3/3 passed. NOT a bridge bug — verified before filing | +| 14 | Fusion **symbol-surface capture is flaky**: sometimes a dedicated symbol-editor view, sometimes a fallback whole-window grab. The symbol itself is present + correct either way (import is fine); only the evidence-capture path varies, so a strict N/3 scores 2/3 on the fallback | Fusion | fusion-bridge? / page | **OPEN, low severity** — NOT an import failure (the symbol is correct in both); it is capture inconsistency. Track how often across a run before filing; the import is good regardless | +| 15 | the library/bind flow **leaks `Untitled` 3D docs that accumulate unbounded** (~5 per import → 16 after 3 imports → ~50 over a 10-part run), and `fusion_close_all_documents` CANNOT close them (they resist as unsaved) — the root of the memory pressure behind #5's 60s timeout | Fusion | fusion-bridge | **OPEN** — filed fusion-bridge#59 (John spotted it); ask: close/reuse the scratch Untitled, or expose a force-close for a caller | +| 16 | on a LONG unattended REMOTE run the 1-hour web-control grant lapses mid-run, and the harness re-grant is FRAGILE: the Allow-button click misses when the ab window moves or the screensaver steals foreground, so parts start failing GRANT FAILED | harness / ab | harness / ab-core | **OPEN** — harness re-grants but the pixel-click is unreliable on a remote box. Real ab-core question: an unattended-run grant mode, a >1h option, or a CLI/owner-grantable path so a long remote campaign is not gated on a fragile UI click | +| 17 | **symbol text-overlap**, MEASURED + SYSTEMATIC: some components' `.lbr` place `>NAME`/`>VALUE` at y≈0 (on the symbol body, and CQ03SAF1004T5E has BOTH at y=0, on each other) instead of ±10 like clean parts. Broken: CQ03SAF1004T5E, CL21A226MPQNNNE. Clean: 0402B103J500CT, 0603X475K100CT (y=±10). Fusion renders the bad `.lbr` faithfully | component-data / .lbr-generator | NOT the bridge | **OPEN, routed to the .lbr converter/linter** — a `.lbr`-generation inconsistency, SCANNABLE library-wide (fetch each `.lbr`, flag `>NAME`/`>VALUE` y near 0 / on the pin span). The automated symbol-label-overlap check, proven in practice. Import 'succeeds' (3/3, 3D mapped) but the symbol is visually wrong | +| 17a | **library-wide scan of #17** (50 components): **9 resistors have `>NAME`+`>VALUE` both at exactly y=0** (stacked, on body) — CR0603-FX-5601ELF, RMCF0603FT1R50, CQ03SAF1004T5E, 0603SAF1500/1/2/3T5E, 0603WAF100JT5E, CR0402-FX-1000GLF — a resistor-symbol-generation bug. 5 large ICs flagged but likely OK (labels at pin-span edge). ⚠ the naive "label-y within pin-span" check MISSED CL21A226MPQNNNE (±0.63, pins at 0) — false negative | component-data / .lbr-generator | NOT the bridge | **OPEN** — the real geometry check needs actual symbol-body extent + text height (true bbox overlap), not pin-span. 9-resistor list is definitive + actionable | + +**Still-untested surfaces (expect new rows here):** every non-Library button in +§1b — Symbol / Footprint / 3D-chip / Schematic / 2D-board / 3D-board / Project, +for both apps. And parts beyond the passives-heavy sets run so far (connectors, +multi-pin ICs, multi-unit symbols, no-3D parts). + +--- + +## 8. The feedback loop — route every finding to its owner + +A found scenario is only half the work; the value is the fix, and the fix must +land in the RIGHT place or it does not stick. Classify every catalog row: + +- **ab core** (`C:/Github/adom-bridge`) — the verb surface, the relay, the demo + page, screenshots, process/window verbs, the grant model. You OWN this: fix it, + bump the version, release (the release ladder in CLAUDE.md), and re-run to move + the row to CONFIRMED. Example: #7, #10. +- **kicad-bridge / fusion-bridge** — anything about how the library/symbol/ + footprint/3D verb behaves inside the app. You do NOT own these (cloud-owned + wiki repos). File a precise issue on `adom/kicad-bridge` or `adom/fusion-bridge` + with the verbatim response JSON + the bridge version from `bridge_list`, then + watch for their fix and re-run against it. Example: #1, #2 (open), #3, #4, #5 + (fixed by the fusion thread in 1.9.209 — re-run to confirm). +- **harness** (`C:/tmp/libui.py`, `regrant.py`) — the runner, the grant flow, the + capture. Fix the script; the lesson also goes into this skill (§2a) so the next + run inherits it. Example: #6, #8, #9, #11, #12. +- **this skill** — every lesson, always. A fix that isn't written down is + re-learned. The catalog (§7) + §2a are the durable memory of the campaign. + +**The discipline:** after each run, for anything new — (1) add the catalog row, +(2) name its class, (3) fix it there or file it there, (4) re-run to confirm. +When you fix an ab-core or harness item, the confirming re-run is part of the +same session. When you file a bridge item, the confirming re-run happens after +the bridge thread ships — the wiki-issue watcher (a background poll of the issues +you filed) tells you when to look. + +**Watch your own issues.** After filing on a bridge, keep a light background +watch on those issues so a shipped fix (like fusion-bridge's 1.9.209) is picked +up and re-tested promptly, rather than sitting unconfirmed. + +--- + +## 9. What a run leaves behind — the local import ledger (pre-wiki) + +Before asking the wiki for an attestation/counter feature, **track imports +locally and get real experience with the data.** Act like a normal Adom user +keeping a record of their own imports; see what's actually useful and trustworthy +first, then spec a feature backed by evidence rather than a guess. + +`C:/tmp/ledger.py` writes `C:/tmp/import-ledger.json`, keyed +`component → app → {verified_imports, runs[]}`. The harness records each part +automatically. Design rules, all about not fooling ourselves: + +- **Objective fields are recorded by machine, subjective ones stay `null` until + reviewed.** Recorded automatically: timestamp, ab + bridge versions, duration, + `flag_pass` (the verb's `success`), and each artifact's **filename + sha256** + (an attestation must carry a hashable image — a flag is a claim, an image is + proof). Left `null` until a human/AI reads the pictures: `content_verified`, + `notes`, and the `geometry` block (`3d_bound`, `text_overlap`, + `3d_alignment_offset`). +- **`verified_imports` NEVER counts a `content_verified:false` run.** A flag pass + with the image not yet reviewed counts provisionally; a reviewed-bad run is + excluded. The count is honest by construction — it can't drift green. +- **The geometry slots are where §the-quality-plan lands.** They're `null` now; + as we build the checks (symbol text-overlap from the kicad-cli SVG export; + 3D-body-center vs footprint-center offset) they get filled with measured + numbers, and later become bridge verbs so every import — not just our test + runs — gets the objective signal. +- **The aggregate is a quality breakdown, not a bare tally.** `ledger.py`'s + summary reports imports / flag-ok / reviewed per component+app, so + "imported N times" always carries how many were actually verified. When this + graduates to the wiki, the count publishes with that breakdown, never alone. + +The two signals stay split (see the design discussion): the **quality +attestation** (git file, owner-written — us) and the **field-import count** +(a wiki-DB counter via an authenticated `import-events` endpoint, because a +non-owner user can't write the page's files). We build the local ledger now; we +spec the wiki endpoint only once the local data has taught us what to ask for. + +--- + +## 8a. Filing a bridge issue, and watching for the fix — the mechanics + +§8 says "file it on the bridge and re-run when they ship." Here is exactly how, +because the details bite. + +### Why it MUST be a wiki issue (not a message) + +The kicad/fusion bridge threads run under a DIFFERENT Adom account +(`[email protected]`) than this campaign typically does (`[email protected]`), so +you **cannot `@mention` or `SendMessage` them** — they will not appear in your +reachable-agents list. **The bridge's wiki discussion is the only channel that +reaches its author**, and per CLAUDE.md it is their actual work queue. So a +finding routed to a bridge is a wiki issue, full stop. + +### Filing (verified commands) + +```bash +# 1. check for a duplicate first +adom-wiki issue list adom/fusion-bridge # or adom/kicad-bridge, adom/pup-bridge + +# 2. write the body to a FILE - never inline --body: +# the body has backticks and JSON braces, which the shell / the CLI's inline +# parser mangle ("unexpected argument", "invalid escape"). --body-file is safe. +adom-wiki issue create adom/fusion-bridge \ + --title "<one-line symptom>" --category bug-report \ + --body-file /c/tmp/issue_fusion.md +``` + +The body must carry, every time: the **verbatim response JSON**, the **bridge +version from `bridge_list`**, ab's version, the OS, and the exact repro (which +button/verb, which part). A finding without the verbatim response is not +actionable. Attach or reference the evidence images. + +**Correct yourself in the SAME thread if you were wrong.** Twice this session a +filed finding turned out to be my own error (I skipped `fusion_bind_library_3d`; +I snap-diagnosed a name mismatch the file disproved). The fix is a follow-up +COMMENT that withdraws the wrong part and narrows to what survives — never a +silent delete: + +```bash +adom-wiki issue comment adom/fusion-bridge#58 --body-file /c/tmp/correction.md +``` + +A retraction with the evidence is worth more to the maintainer than a report +polished until it was safe to send. It also keeps YOUR credibility as a reporter. + +### The concatenated-JSON caveat + +`adom-wiki issue view` / `issue list` output is often **several JSON documents +concatenated**, so a single `json.load` throws `Extra data`. Parse with a decoder +loop: + +```python +from json import JSONDecoder +s = subprocess.run([...], capture_output=True, text=True).stdout +d, i = JSONDecoder().raw_decode(s) # first doc; or loop raw_decode(s, i) +disc = d.get("discussion") or d +replies = d.get("replies") or disc.get("replies") or [] +``` + +Also: on Windows, force UTF-8 stdout in the parser or a `\u2192`-class char kills +it with `UnicodeEncodeError` under cp1252. + +### Watching for the fix (the watcher) + +After filing, keep a light background poll over the issues you opened so a shipped +fix is picked up promptly instead of sitting unconfirmed. The pattern +(`C:/tmp/wiki_watch.py`): every ~2.5 min, `issue view` each filed ref, track the +reply count on disk, emit a line only when a NEW reply lands or the status flips. +Run it as a persistent background monitor; a new-reply event is your cue to READ +the reply and **re-run the affected surface against the shipped version** to move +the catalog row (§7) from OPEN → CONFIRMED. + +This is not hypothetical: this session the fusion thread replied on #58 that all +three findings were fixed in **1.9.209** — the watcher is what turns "they +replied" into "we re-tested and confirmed" without you having to remember to +check. The whole loop only closes when the confirming re-run is done and the +catalog says CONFIRMED, not when the bridge says "fixed."
skills/adom-bridge-web-integration/SKILL.md+4−1@@ -1,6 +1,9 @@ --- name: adom-bridge-web-integration -description: How a web page served from wiki.adom.inc can detect Adom Bridge on the visitor's PC and (in a planned next phase) trigger consent-gated desktop actions, via CORS AJAX to the loopback API - and the security model that lets ONLY wiki.adom.inc do it while every other website is blocked. Read this when building wiki.adom.inc buttons like "Launch in KiCad", "Open in Fusion", "Send files to desktop", when reasoning about why localhost AJAX from a web page is or isn't safe, when touching the CORS/Private-Network-Access gate in direct_api.rs, or when asked why *.adom.cloud is not allowed. Trigger words: wiki ajax to ab, launch in kicad button, CORS localhost, private network access, web_forbidden, detect adom bridge from browser, wiki.adom.inc integration. +description: >- + How a wiki.adom.inc page detects Adom Bridge on the visitor's PC via CORS to the loopback + API, and the security model that blocks every other site. Use when building a wiki 'Launch + in KiCad' or 'Open in Fusion' button, or for web_forbidden or CORS localhost questions. --- # Web integration — letting wiki.adom.inc reach Adom Bridge safely
skills/adom-bridge-window-capture/SKILL.md+256−252@@ -1,252 +1,256 @@-name: adom-bridge-window-capture-description: The complete reference for Adom Bridge screenshots — every capture verb, the exact JSON response shape (coordMap, shotId, ownedPopupCount, the screenshots[] child-dialog array, windowRect, the multi-monitor display block), and how the CLI automatically stores each shot on disk (the window/ and screen/ buckets, the ISO-stamped filenames, the single-vs-full/safe pair, the .popup-NN grouping, and the <stem>.json sidecar). Read this to drive a GUI app that pops modal dialogs, to capture a backgrounded/occluded window, to understand a multi-monitor grab, or to build a tool (like shotlog) that renders every screenshot shape ab can return. Covers desktop_screenshot_window, desktop_screenshot_screen, kicad_screenshot_all, the coordMap click-what-you-see contract, and the exact on-disk sidecar schema.--# Adom Bridge screenshots — full capability, response shape, and on-disk contract--This is the deep reference for everything ab can capture and everything it writes to disk. Two audiences:--- **An AI driving a GUI app** — jump to "The verb map" and "Owned popups". The one thing to internalize: `desktop_screenshot_window {hwnd}` sees a window AND the modal dialogs it owns, in the background, without stealing focus.-- **A tool that renders ab screenshots (e.g. shotlog)** — jump to "On-disk storage" and "The sidecar schema". Every shot lands in a predictable bucket with a self-describing `<stem>.json` next to it; you never have to parse the response envelope, just watch the folder.---## 1. The verb map — what each capture verb returns--| Verb | Captures | Foreground? | Shape |-|---|---|---|---|-| **`desktop_screenshot_window {hwnd}`** | ONE window + every child dialog / error box it owns | No (PrintWindow, background) | one parent image + `screenshots[]` array of owned popups |-| **`desktop_screenshot_screen {}`** | the ENTIRE virtual desktop (ALL monitors, stitched into one image) | n/a (whatever is on screen) | one image + a `display` block listing every monitor |-| **`kicad_screenshot_all {}`** | every open KiCad window (main + all dialogs) as separate images | No | an ARRAY of sibling-window images |-| **`browser_screenshot` / `nbrowser_screenshot`** | a browser PAGE / viewport (CDP, not a window grab) | No | one image (page pixels, not window chrome) |-| **`desktop_record_window_start` / `..._screen_start`** | VIDEO (same PrintWindow/WGC family) | No | an `.mp4`, not a PNG (see the recording skill) |--**Rule of thumb:** for one app window, ALWAYS prefer `desktop_screenshot_window {hwnd}` (resolve the hwnd with `desktop_find_window {titleContains}`). It captures even when the window is backgrounded, occluded, or minimized, it never steals focus, and it brings back the app's modal dialogs. Use `desktop_screenshot_screen` only for the whole desktop or for popups not owned by any single window.---## 2. The universal response shape (every screenshot verb)--Every ab screenshot response carries the same core envelope. Field-by-field:--```jsonc-{- "success": true,- // ── the image, on the DESKTOP (host) side ──- "fullPath": "C:\\...\\adom-bridge-shots\\window-<hwnd>-<ts>.full.png", // or a single .png- "safePath": "C:\\...\\adom-bridge-shots\\window-<hwnd>-<ts>.safe.png",- "fullWidth": 3840, "fullHeight": 2160, // native capture size- "safeWidth": 1568, "safeHeight": 882, // downscaled to <=1568px for a model's vision window- "resizeMax": 1568,-- // ── the image, PULLED to the CALLER (container) side by the CLI (v1.8.61+) ──- "localFullPath": "/home/adom/project/screenshots/adom-bridge/window/2026-07-15T01-14-16.937-986710.full.png",- "localSafePath": "/home/adom/project/screenshots/adom-bridge/window/2026-07-15T01-14-16.937-986710.safe.png",- "localFullExists": true, "localSafeExists": true,- "fullBytes": 402931, "safeBytes": 88104, // bytes on the CALLER side- "source": "printwindow", // capture method- "narrative": "Captured window + 0 owned popups. Pulled full+safe to .../window/.",-- // ── click-what-you-see: bind the delivered image to real screen pixels ──- "coordMap": {- "shotId": "shot_a1b2c3",- "image": { "w": 1568, "h": 882 }, // the SAFE image dims- "screenRect": { "x": 100, "y": 80, "w": 3840, "h": 2160 }, // physical px on the desktop- "dpiScale": 1.5,- "imageToScreen": { "sx": 2.449, "sy": 2.449, "ox": 100, "oy": 80 } // px_screen = px_image*s + o- },-- // ── where the window sits on the (possibly multi-monitor) desktop ──- "windowRect": { "x": 100, "y": 80, "w": 3840, "h": 2160, "monitor": 1 },- "display": { /* see §4 multi-monitor */ },-- // ── owned popups (see §3) ──- "ownedPopupCount": 0,- "screenshots": [ /* present when ownedPopupCount > 0 */ ],-- "drive": "background", // this capture never foregrounded the window- "_related": ["desktop_find_window", "desktop_click", "desktop_ui_click"]-}-```--**`coordMap` + `shotId` is the zero-math contract.** To click something you SEE in the delivered (safe) image, do not do DPI/scale arithmetic. Pass the pixel you see:--```bash-desktop_click '{"space":"image","shotId":"shot_a1b2c3","x":742,"y":410}'-```--ab converts image px → physical screen px via that capture's `coordMap` and clicks. DPI-correct and multi-monitor-correct automatically. The same `{space:"image", shotId, x, y}` works for `desktop_double_click`, `desktop_right_click`, `desktop_hover`, and the read-only `desktop_ui_from_point`.---## 3. Owned popups — one window, N child dialogs (the "6 child windows" case)--A native app's modal dialogs ("Save changes?", "DRC found 3 errors", "Export failed", Chromium's "Remove extension?") are **separate top-level windows** that (a) do NOT appear in `desktop_list_windows` and (b) are invisible to a plain PrintWindow of the parent. `desktop_screenshot_window` finds every popup the parent owns and returns each as its own image.--When `ownedPopupCount > 0`, the response gains a `screenshots[]` array — one entry per owned popup:--```jsonc-{- "ownedPopupCount": 6,- "screenshots": [- {- "hwnd": 265518,- "title": "Error",- "kind": "owned_popup",- "rect": { "x": 900, "y": 500, "w": 420, "h": 180, "monitor": 1 }, // its screen position + monitor- "fullPath": "...popup .full.png", "safePath": "...popup .safe.png",- "fullWidth": 420, "fullHeight": 180, "safeWidth": 420, "safeHeight": 180,- "coordMap": { "shotId": "shot_pop1", ... }, // its OWN coordMap- "shotId": "shot_pop1",- // CLI adds after pull:- "localPath": ".../window/<parentStem>.popup-01.png", "savedTo": "...", "localSafePath": "..."- },- { "hwnd": 265702, "title": "Confirm", "kind": "owned_popup", "shotId": "shot_pop2", ... },- // ... up to popup-06- ],- "_popups_note": "READ each popup image — one is usually the blocking dialog. Act on it via {space:\"image\", shotId, x, y} or desktop_ui_click {scope:\"popups\"}."-}-```--So a window that owns 6 dialogs returns **1 parent image + 6 popup images = 7 images**, each independently clickable via its own `shotId`. To act on a dialog's button without stealing focus, prefer the accessible-name path:--```bash-desktop_ui_click '{"scope":"popups","name":"OK"}' # background Invoke by name, targets an owned dialog-```--**`kicad_screenshot_all` is the sibling-window variant:** it returns an ARRAY of every open KiCad window (main editor + each dialog) as top-level siblings (not an owner tree). Same per-image shape; use it when the app's windows are peers rather than parent/owned.---## 4. Multi-monitor — how a full-desktop grab comes back--**`desktop_screenshot_screen` today returns ONE image that spans the entire virtual desktop** — both monitors stitched side by side (it captures `SM_XVIRTUALSCREEN..SM_CXVIRTUALSCREEN`, the union bounding box of all monitors). It does NOT return one image per monitor. Alongside the image it returns a `display` block describing the layout so a caller (or a renderer) can slice the stitched image back into per-monitor regions:--```jsonc-"display": {- "virtualScreen": { "x": 0, "y": 0, "w": 3283, "h": 1440 }, // the union = the stitched image's screen rect- "primary": { "w": 1440, "h": 900, "dpi": 96, "scale": 1.0 },- "monitors": [- { "index": 0, "device": "\\\\.\\DISPLAY1", "primary": true, "bounds": {"x":0,"y":0,"w":1440,"h":900},- "logicalBounds": {"x":0,"y":0,"w":1440,"h":900}, "scale": 1.0, "dpi": 96 },- { "index": 1, "device": "\\\\.\\DISPLAY2", "primary": false, "bounds": {"x":1440,"y":0,"w":2304,"h":1440},- "logicalBounds": {"x":1440,"y":0,"w":1843,"h":1152}, "scale": 1.25, "dpi": 120 }- ],- "monitorOfWindow": 1-}-```--**The authoritative monitor map is `desktop_list_monitors`** (v1.9.119+): every monitor with `bounds` (PHYSICAL px, the same space `desktop_set_window_bounds` uses), `logicalBounds` (DIPs, what a browser's `screen` object reports), `workArea`, `scale`, `dpi`, `primary`, `index`, `device`. Physical vs logical differ on a scaled/high-DPI display; the `units` hint in the response spells out which field is which.--**Two ways to get per-monitor images (v1.9.121):**-- **`desktop_screenshot_screen {monitor: N}`** — capture ONLY display N (0-based, from `display.monitors`). Returns the normal single-image shape (fullPath/safePath/coordMap/dimensions) PLUS a `monitor:{index,bounds,logicalBounds,scale,dpi,primary}` block. The `coordMap.screenRect` is that monitor's physical bounds, so an image click lands on that display.-- **`desktop_screenshot_screen {perMonitor: true}`** — returns the stitched all-monitors image at the top level (the "full-full" view) PLUS a `screenshots[]` array with ONE entry per monitor. Each entry: `{index, primary, bounds (physical px), logicalBounds (DIPs), scale, kind:"monitor", fullPath, safePath, coordMap, shotId}` — the SAME array contract as owned popups, so the CLI auto-pulls each tile and names it `<stem>.monitor-NN` under `screen/`. Plus `monitorCount` + a `_monitors_note`.--**How a renderer (shotlog) should show a multi-monitor grab:**-- If the response has a `screenshots[]` of `kind:"monitor"` (perMonitor call), render each monitor tile as its own image (caption from `index`/`primary`/`bounds`), grouped under the stitched top-level image. The tiles share the parent stem with a `.monitor-NN` tag on disk.-- If it is the default single stitched image, render it as-is and (optionally) use `display.monitors[]` to draw monitor boundaries or offer a "just monitor N" crop: monitor N's slice is `bounds[N]` offset by `-virtualScreen.{x,y}` (physical px map 1:1 to stitched-image px before the safe downscale; after downscale, multiply by `safeWidth/fullWidth`). Or just call `{monitor:N}` and let ab capture that display natively.-- On a multi-monitor desktop the default response carries a `_monitorsHint` naming the `{monitor:N}` / `{perMonitor:true}` options.---## 5. On-disk storage — where the CLI auto-pulls every shot (the shotlog contract)--The CLI auto-pulls every screenshot (the v1.8.61 universal delivery contract) to the CALLER's machine and self-janitors it. This is the part a visualizer watches.--**Directory (resolved once, per machine):**-```-$ADOM_SHOTS_DIR # explicit override, else:-~/project/screenshots/adom-bridge/ # when ~/project exists (the container convention)-~/.adom/screenshots/ # fallback (off-container / Windows, no ~/project)-```-Never `/tmp`. ab deliberately does NOT touch `~/project/screenshots/*.png` (timestamp-named) or `~/project/screenshots/shotlog/` — those belong to other tools.--**Buckets (subfolders by kind):**-```-<shots_dir>/window/ # a specific window / app / owned popup (desktop_screenshot_window, kicad_*, pup_*)-<shots_dir>/screen/ # a full-desktop grab (desktop_screenshot_screen)-```--**Filenames — human-readable, sortable, self-grouping:**-```-window/2026-07-15T01-14-16.937-986710.png # <ISO-stamp>-<hwnd>.png (a window; the folder says "window", so no prefix)-window/2026-07-15T01-14-16.937-986710.popup-01.png # its 1st owned popup (shares the parent stem)-window/2026-07-15T01-14-16.937-986710.popup-02.png # its 2nd owned popup-screen/2026-07-15T01-05-08.625.png # a full-desktop grab (no hwnd)-```-The ISO stamp is `YYYY-MM-DDThh-mm-ss.mmm` with no colons, so it is filename-safe AND lexicographic order == chronological order. A `.popup-NN` tag shares the parent's stem, so a plain `ls` shows a whole capture (parent + its dialogs) grouped, and the parent/child role is visible without opening any JSON.--**Single file vs full/safe pair (dedup, v1.9.114):**-- If the shot was already within the safe size cap (<=1568px, the common case) it is written **ONCE** as `<stem>.png`, and `localFullPath == localSafePath` point at that one file.-- Only a shot LARGE enough to be downscaled gets the pair: `<stem>.full.png` (native) + `<stem>.safe.png` (downscaled). A renderer should prefer `.safe` for display and `.full` for pixel-exact work; when only `<stem>.png` exists, that IS both.--**Janitor (automatic):** the pull cache self-prunes (default: 7-day age cap, 2 GB size cap, 4000-file cap, oldest deleted first, newest always kept; a shot's sidecar follows its PNG on delete). No cleanup is needed caller-side. Inspect with `adom-bridge janitor status`; force a sweep with `adom-bridge janitor '{"sub":"run"}'`. Tune with `ADOM_SHOTS_DIR`, `ADOM_SHOTS_MAX_AGE_DAYS`, `ADOM_SHOTS_MAX_GB`, `ADOM_SHOTS_MAX_FILES`, `ADOM_JANITOR=0`.---## 6. The sidecar schema — `<stem>.json` next to every shot--Every pulled shot gets a `<stem>.json` sidecar (a full+safe pair shares ONE sidecar). **This is the self-describing record a visualizer should read instead of parsing the response envelope.** Two shapes, distinguished by `kind`:--**Top-level shot** (a window's parent image, or a full-desktop grab):-```jsonc-{- "source": "desktop_screenshot_window", // the verb that produced it- "timestamp": "2026-07-15T01:14:16.937Z", // RFC3339 UTC- "kind": "window", // "window" | "screen"- "hwnd": 986710, // null for a "screen" grab- "title": "KiCad 8.0 — myboard.kicad_pcb", // window title (null for screen)- "full": { "path": ".../window/<stem>.full.png", "w": 3840, "h": 2160, "bytes": 402931 },- "safe": { "path": ".../window/<stem>.safe.png", "w": 1568, "h": 882, "bytes": 88104 },- // (when the shot was under the cap, full.path == safe.path == the single <stem>.png)- "coordMap": { "shotId": "...", "image": {...}, "screenRect": {...}, "dpiScale": 1.5, "imageToScreen": {...} },- "windowRect": { "x": 100, "y": 80, "w": 3840, "h": 2160, "monitor": 1 } // null for a screen grab-}-```--**Owned-popup shot** (each child dialog of a window capture):-```jsonc-{- "source": "desktop_screenshot_window",- "timestamp": "2026-07-15T01:14:16.980Z",- "kind": "owned_popup",- "parent": "2026-07-15T01-14-16.937-986710", // the parent shot's stem — links child -> parent- "popupIndex": 1, // 1-based; matches the .popup-01 filename tag- "hwnd": 265518,- "title": "Error",- "shotId": "shot_pop1",- "full": { "path": ".../window/<parentStem>.popup-01.full.png", "w": 420, "h": 180, "bytes": 12043 },- "safe": { "path": ".../window/<parentStem>.popup-01.safe.png", "w": 420, "h": 180, "bytes": 12043 },- "coordMap": { "shotId": "shot_pop1", ... },- "rect": { "x": 900, "y": 500, "w": 420, "h": 180, "monitor": 1 } // the popup's own screen position-}-```--**How a renderer groups a capture from disk alone (no response envelope needed):**-1. List `<shots_dir>/window/` and `<shots_dir>/screen/`, newest-first (filenames sort chronologically).-2. A file with NO `.popup-NN` tag is a parent (or a standalone window/screen shot); read its `<stem>.json`.-3. Files sharing that stem with `.popup-01..NN` are its owned dialogs — their sidecars carry `parent` + `popupIndex` so you can render them as a labeled child gallery under the parent.-4. For a `screen/` shot, read `display`-equivalent context from `desktop_list_monitors` (the sidecar carries the shot's own rect; the live monitor map is the verb) to draw monitor boundaries.-5. Prefer `safe.path` for thumbnails/inline display, `full.path` for a zoom/pixel-exact view; if `full.path == safe.path`, there is a single file.---## 7. Driving without disturbing the user--Capture is background by default (PrintWindow, no focus steal, no cursor move). To also ACT without foregrounding:--- `desktop_find_control {hwnd, name|contains, role?}` then `desktop_ui_click` / `desktop_ui_set` — background Invoke / SetValue by accessible name. Add `scope:"popups"` to target a control inside an owned dialog.-- Coordinate clicks (`desktop_click {space:"image", shotId, x, y}`) as the fallback when a control is not in the UIA tree (some custom-drawn / shadow-DOM controls are not).-- `desktop help background` lists exactly which verbs run in the background vs which foreground the window.---## Related--- `desktop_find_window {titleContains|className}` — resolve an hwnd (topmost match wins; no racy list-diffing).-- `desktop_list_windows` — enumerate all top-level windows with hwnds + rects.-- `desktop_list_monitors` — the authoritative monitor map (physical + logical bounds, scale, primary).-- `desktop_screenshot_screen` — full virtual-screen grab (all monitors, stitched).-- `desktop_record_window_start` / `desktop_record_screen_start` — background video capture (same PrintWindow/WGC family).-- `adom-bridge janitor status` — verify the auto-pull cache is being managed (so a tool never builds its own cleaner).+--- +name: adom-bridge-window-capture +description: >- + Reference for Adom Bridge screenshots: every capture verb, the response shape (coordMap, + screenshots[] dialogs, multi-monitor) and how the CLI stores shots and sidecars on disk. Use + to capture a window with dialogs, an occluded window or a monitor, or to parse screenshot + output. +--- + +# Adom Bridge screenshots — full capability, response shape, and on-disk contract + +This is the deep reference for everything ab can capture and everything it writes to disk. Two audiences: + +- **An AI driving a GUI app** — jump to "The verb map" and "Owned popups". The one thing to internalize: `desktop_screenshot_window {hwnd}` sees a window AND the modal dialogs it owns, in the background, without stealing focus. +- **A tool that renders ab screenshots (e.g. shotlog)** — jump to "On-disk storage" and "The sidecar schema". Every shot lands in a predictable bucket with a self-describing `<stem>.json` next to it; you never have to parse the response envelope, just watch the folder. + +--- + +## 1. The verb map — what each capture verb returns + +| Verb | Captures | Foreground? | Shape | +|---|---|---|---| +| **`desktop_screenshot_window {hwnd}`** | ONE window + every child dialog / error box it owns | No (PrintWindow, background) | one parent image + `screenshots[]` array of owned popups | +| **`desktop_screenshot_screen {}`** | the ENTIRE virtual desktop (ALL monitors, stitched into one image) | n/a (whatever is on screen) | one image + a `display` block listing every monitor | +| **`kicad_screenshot_all {}`** | every open KiCad window (main + all dialogs) as separate images | No | an ARRAY of sibling-window images | +| **`browser_screenshot` / `nbrowser_screenshot`** | a browser PAGE / viewport (CDP, not a window grab) | No | one image (page pixels, not window chrome) | +| **`desktop_record_window_start` / `..._screen_start`** | VIDEO (same PrintWindow/WGC family) | No | an `.mp4`, not a PNG (see the recording skill) | + +**Rule of thumb:** for one app window, ALWAYS prefer `desktop_screenshot_window {hwnd}` (resolve the hwnd with `desktop_find_window {titleContains}`). It captures even when the window is backgrounded, occluded, or minimized, it never steals focus, and it brings back the app's modal dialogs. Use `desktop_screenshot_screen` only for the whole desktop or for popups not owned by any single window. + +--- + +## 2. The universal response shape (every screenshot verb) + +Every ab screenshot response carries the same core envelope. Field-by-field: + +```jsonc +{ + "success": true, + // ── the image, on the DESKTOP (host) side ── + "fullPath": "C:\\...\\adom-bridge-shots\\window-<hwnd>-<ts>.full.png", // or a single .png + "safePath": "C:\\...\\adom-bridge-shots\\window-<hwnd>-<ts>.safe.png", + "fullWidth": 3840, "fullHeight": 2160, // native capture size + "safeWidth": 1568, "safeHeight": 882, // downscaled to <=1568px for a model's vision window + "resizeMax": 1568, + + // ── the image, PULLED to the CALLER (container) side by the CLI (v1.8.61+) ── + "localFullPath": "/home/adom/project/screenshots/adom-bridge/window/2026-07-15T01-14-16.937-986710.full.png", + "localSafePath": "/home/adom/project/screenshots/adom-bridge/window/2026-07-15T01-14-16.937-986710.safe.png", + "localFullExists": true, "localSafeExists": true, + "fullBytes": 402931, "safeBytes": 88104, // bytes on the CALLER side + "source": "printwindow", // capture method + "narrative": "Captured window + 0 owned popups. Pulled full+safe to .../window/.", + + // ── click-what-you-see: bind the delivered image to real screen pixels ── + "coordMap": { + "shotId": "shot_a1b2c3", + "image": { "w": 1568, "h": 882 }, // the SAFE image dims + "screenRect": { "x": 100, "y": 80, "w": 3840, "h": 2160 }, // physical px on the desktop + "dpiScale": 1.5, + "imageToScreen": { "sx": 2.449, "sy": 2.449, "ox": 100, "oy": 80 } // px_screen = px_image*s + o + }, + + // ── where the window sits on the (possibly multi-monitor) desktop ── + "windowRect": { "x": 100, "y": 80, "w": 3840, "h": 2160, "monitor": 1 }, + "display": { /* see §4 multi-monitor */ }, + + // ── owned popups (see §3) ── + "ownedPopupCount": 0, + "screenshots": [ /* present when ownedPopupCount > 0 */ ], + + "drive": "background", // this capture never foregrounded the window + "_related": ["desktop_find_window", "desktop_click", "desktop_ui_click"] +} +``` + +**`coordMap` + `shotId` is the zero-math contract.** To click something you SEE in the delivered (safe) image, do not do DPI/scale arithmetic. Pass the pixel you see: + +```bash +desktop_click '{"space":"image","shotId":"shot_a1b2c3","x":742,"y":410}' +``` + +ab converts image px → physical screen px via that capture's `coordMap` and clicks. DPI-correct and multi-monitor-correct automatically. The same `{space:"image", shotId, x, y}` works for `desktop_double_click`, `desktop_right_click`, `desktop_hover`, and the read-only `desktop_ui_from_point`. + +--- + +## 3. Owned popups — one window, N child dialogs (the "6 child windows" case) + +A native app's modal dialogs ("Save changes?", "DRC found 3 errors", "Export failed", Chromium's "Remove extension?") are **separate top-level windows** that (a) do NOT appear in `desktop_list_windows` and (b) are invisible to a plain PrintWindow of the parent. `desktop_screenshot_window` finds every popup the parent owns and returns each as its own image. + +When `ownedPopupCount > 0`, the response gains a `screenshots[]` array — one entry per owned popup: + +```jsonc +{ + "ownedPopupCount": 6, + "screenshots": [ + { + "hwnd": 265518, + "title": "Error", + "kind": "owned_popup", + "rect": { "x": 900, "y": 500, "w": 420, "h": 180, "monitor": 1 }, // its screen position + monitor + "fullPath": "...popup .full.png", "safePath": "...popup .safe.png", + "fullWidth": 420, "fullHeight": 180, "safeWidth": 420, "safeHeight": 180, + "coordMap": { "shotId": "shot_pop1", ... }, // its OWN coordMap + "shotId": "shot_pop1", + // CLI adds after pull: + "localPath": ".../window/<parentStem>.popup-01.png", "savedTo": "...", "localSafePath": "..." + }, + { "hwnd": 265702, "title": "Confirm", "kind": "owned_popup", "shotId": "shot_pop2", ... }, + // ... up to popup-06 + ], + "_popups_note": "READ each popup image — one is usually the blocking dialog. Act on it via {space:\"image\", shotId, x, y} or desktop_ui_click {scope:\"popups\"}." +} +``` + +So a window that owns 6 dialogs returns **1 parent image + 6 popup images = 7 images**, each independently clickable via its own `shotId`. To act on a dialog's button without stealing focus, prefer the accessible-name path: + +```bash +desktop_ui_click '{"scope":"popups","name":"OK"}' # background Invoke by name, targets an owned dialog +``` + +**`kicad_screenshot_all` is the sibling-window variant:** it returns an ARRAY of every open KiCad window (main editor + each dialog) as top-level siblings (not an owner tree). Same per-image shape; use it when the app's windows are peers rather than parent/owned. + +--- + +## 4. Multi-monitor — how a full-desktop grab comes back + +**`desktop_screenshot_screen` today returns ONE image that spans the entire virtual desktop** — both monitors stitched side by side (it captures `SM_XVIRTUALSCREEN..SM_CXVIRTUALSCREEN`, the union bounding box of all monitors). It does NOT return one image per monitor. Alongside the image it returns a `display` block describing the layout so a caller (or a renderer) can slice the stitched image back into per-monitor regions: + +```jsonc +"display": { + "virtualScreen": { "x": 0, "y": 0, "w": 3283, "h": 1440 }, // the union = the stitched image's screen rect + "primary": { "w": 1440, "h": 900, "dpi": 96, "scale": 1.0 }, + "monitors": [ + { "index": 0, "device": "\\\\.\\DISPLAY1", "primary": true, "bounds": {"x":0,"y":0,"w":1440,"h":900}, + "logicalBounds": {"x":0,"y":0,"w":1440,"h":900}, "scale": 1.0, "dpi": 96 }, + { "index": 1, "device": "\\\\.\\DISPLAY2", "primary": false, "bounds": {"x":1440,"y":0,"w":2304,"h":1440}, + "logicalBounds": {"x":1440,"y":0,"w":1843,"h":1152}, "scale": 1.25, "dpi": 120 } + ], + "monitorOfWindow": 1 +} +``` + +**The authoritative monitor map is `desktop_list_monitors`** (v1.9.119+): every monitor with `bounds` (PHYSICAL px, the same space `desktop_set_window_bounds` uses), `logicalBounds` (DIPs, what a browser's `screen` object reports), `workArea`, `scale`, `dpi`, `primary`, `index`, `device`. Physical vs logical differ on a scaled/high-DPI display; the `units` hint in the response spells out which field is which. + +**Two ways to get per-monitor images (v1.9.121):** +- **`desktop_screenshot_screen {monitor: N}`** — capture ONLY display N (0-based, from `display.monitors`). Returns the normal single-image shape (fullPath/safePath/coordMap/dimensions) PLUS a `monitor:{index,bounds,logicalBounds,scale,dpi,primary}` block. The `coordMap.screenRect` is that monitor's physical bounds, so an image click lands on that display. +- **`desktop_screenshot_screen {perMonitor: true}`** — returns the stitched all-monitors image at the top level (the "full-full" view) PLUS a `screenshots[]` array with ONE entry per monitor. Each entry: `{index, primary, bounds (physical px), logicalBounds (DIPs), scale, kind:"monitor", fullPath, safePath, coordMap, shotId}` — the SAME array contract as owned popups, so the CLI auto-pulls each tile and names it `<stem>.monitor-NN` under `screen/`. Plus `monitorCount` + a `_monitors_note`. + +**How a renderer (shotlog) should show a multi-monitor grab:** +- If the response has a `screenshots[]` of `kind:"monitor"` (perMonitor call), render each monitor tile as its own image (caption from `index`/`primary`/`bounds`), grouped under the stitched top-level image. The tiles share the parent stem with a `.monitor-NN` tag on disk. +- If it is the default single stitched image, render it as-is and (optionally) use `display.monitors[]` to draw monitor boundaries or offer a "just monitor N" crop: monitor N's slice is `bounds[N]` offset by `-virtualScreen.{x,y}` (physical px map 1:1 to stitched-image px before the safe downscale; after downscale, multiply by `safeWidth/fullWidth`). Or just call `{monitor:N}` and let ab capture that display natively. +- On a multi-monitor desktop the default response carries a `_monitorsHint` naming the `{monitor:N}` / `{perMonitor:true}` options. + +--- + +## 5. On-disk storage — where the CLI auto-pulls every shot (the shotlog contract) + +The CLI auto-pulls every screenshot (the v1.8.61 universal delivery contract) to the CALLER's machine and self-janitors it. This is the part a visualizer watches. + +**Directory (resolved once, per machine):** +``` +$ADOM_SHOTS_DIR # explicit override, else: +~/project/screenshots/adom-bridge/ # when ~/project exists (the container convention) +~/.adom/screenshots/ # fallback (off-container / Windows, no ~/project) +``` +Never `/tmp`. ab deliberately does NOT touch `~/project/screenshots/*.png` (timestamp-named) or `~/project/screenshots/shotlog/` — those belong to other tools. + +**Buckets (subfolders by kind):** +``` +<shots_dir>/window/ # a specific window / app / owned popup (desktop_screenshot_window, kicad_*, pup_*) +<shots_dir>/screen/ # a full-desktop grab (desktop_screenshot_screen) +``` + +**Filenames — human-readable, sortable, self-grouping:** +``` +window/2026-07-15T01-14-16.937-986710.png # <ISO-stamp>-<hwnd>.png (a window; the folder says "window", so no prefix) +window/2026-07-15T01-14-16.937-986710.popup-01.png # its 1st owned popup (shares the parent stem) +window/2026-07-15T01-14-16.937-986710.popup-02.png # its 2nd owned popup +screen/2026-07-15T01-05-08.625.png # a full-desktop grab (no hwnd) +``` +The ISO stamp is `YYYY-MM-DDThh-mm-ss.mmm` with no colons, so it is filename-safe AND lexicographic order == chronological order. A `.popup-NN` tag shares the parent's stem, so a plain `ls` shows a whole capture (parent + its dialogs) grouped, and the parent/child role is visible without opening any JSON. + +**Single file vs full/safe pair (dedup, v1.9.114):** +- If the shot was already within the safe size cap (<=1568px, the common case) it is written **ONCE** as `<stem>.png`, and `localFullPath == localSafePath` point at that one file. +- Only a shot LARGE enough to be downscaled gets the pair: `<stem>.full.png` (native) + `<stem>.safe.png` (downscaled). A renderer should prefer `.safe` for display and `.full` for pixel-exact work; when only `<stem>.png` exists, that IS both. + +**Janitor (automatic):** the pull cache self-prunes (default: 7-day age cap, 2 GB size cap, 4000-file cap, oldest deleted first, newest always kept; a shot's sidecar follows its PNG on delete). No cleanup is needed caller-side. Inspect with `adom-bridge janitor status`; force a sweep with `adom-bridge janitor '{"sub":"run"}'`. Tune with `ADOM_SHOTS_DIR`, `ADOM_SHOTS_MAX_AGE_DAYS`, `ADOM_SHOTS_MAX_GB`, `ADOM_SHOTS_MAX_FILES`, `ADOM_JANITOR=0`. + +--- + +## 6. The sidecar schema — `<stem>.json` next to every shot + +Every pulled shot gets a `<stem>.json` sidecar (a full+safe pair shares ONE sidecar). **This is the self-describing record a visualizer should read instead of parsing the response envelope.** Two shapes, distinguished by `kind`: + +**Top-level shot** (a window's parent image, or a full-desktop grab): +```jsonc +{ + "source": "desktop_screenshot_window", // the verb that produced it + "timestamp": "2026-07-15T01:14:16.937Z", // RFC3339 UTC + "kind": "window", // "window" | "screen" + "hwnd": 986710, // null for a "screen" grab + "title": "KiCad 8.0 — myboard.kicad_pcb", // window title (null for screen) + "full": { "path": ".../window/<stem>.full.png", "w": 3840, "h": 2160, "bytes": 402931 }, + "safe": { "path": ".../window/<stem>.safe.png", "w": 1568, "h": 882, "bytes": 88104 }, + // (when the shot was under the cap, full.path == safe.path == the single <stem>.png) + "coordMap": { "shotId": "...", "image": {...}, "screenRect": {...}, "dpiScale": 1.5, "imageToScreen": {...} }, + "windowRect": { "x": 100, "y": 80, "w": 3840, "h": 2160, "monitor": 1 } // null for a screen grab +} +``` + +**Owned-popup shot** (each child dialog of a window capture): +```jsonc +{ + "source": "desktop_screenshot_window", + "timestamp": "2026-07-15T01:14:16.980Z", + "kind": "owned_popup", + "parent": "2026-07-15T01-14-16.937-986710", // the parent shot's stem — links child -> parent + "popupIndex": 1, // 1-based; matches the .popup-01 filename tag + "hwnd": 265518, + "title": "Error", + "shotId": "shot_pop1", + "full": { "path": ".../window/<parentStem>.popup-01.full.png", "w": 420, "h": 180, "bytes": 12043 }, + "safe": { "path": ".../window/<parentStem>.popup-01.safe.png", "w": 420, "h": 180, "bytes": 12043 }, + "coordMap": { "shotId": "shot_pop1", ... }, + "rect": { "x": 900, "y": 500, "w": 420, "h": 180, "monitor": 1 } // the popup's own screen position +} +``` + +**How a renderer groups a capture from disk alone (no response envelope needed):** +1. List `<shots_dir>/window/` and `<shots_dir>/screen/`, newest-first (filenames sort chronologically). +2. A file with NO `.popup-NN` tag is a parent (or a standalone window/screen shot); read its `<stem>.json`. +3. Files sharing that stem with `.popup-01..NN` are its owned dialogs — their sidecars carry `parent` + `popupIndex` so you can render them as a labeled child gallery under the parent. +4. For a `screen/` shot, read `display`-equivalent context from `desktop_list_monitors` (the sidecar carries the shot's own rect; the live monitor map is the verb) to draw monitor boundaries. +5. Prefer `safe.path` for thumbnails/inline display, `full.path` for a zoom/pixel-exact view; if `full.path == safe.path`, there is a single file. + +--- + +## 7. Driving without disturbing the user + +Capture is background by default (PrintWindow, no focus steal, no cursor move). To also ACT without foregrounding: + +- `desktop_find_control {hwnd, name|contains, role?}` then `desktop_ui_click` / `desktop_ui_set` — background Invoke / SetValue by accessible name. Add `scope:"popups"` to target a control inside an owned dialog. +- Coordinate clicks (`desktop_click {space:"image", shotId, x, y}`) as the fallback when a control is not in the UIA tree (some custom-drawn / shadow-DOM controls are not). +- `desktop help background` lists exactly which verbs run in the background vs which foreground the window. + +--- + +## Related + +- `desktop_find_window {titleContains|className}` — resolve an hwnd (topmost match wins; no racy list-diffing). +- `desktop_list_windows` — enumerate all top-level windows with hwnds + rects. +- `desktop_list_monitors` — the authoritative monitor map (physical + logical bounds, scale, primary). +- `desktop_screenshot_screen` — full virtual-screen grab (all monitors, stitched). +- `desktop_record_window_start` / `desktop_record_screen_start` — background video capture (same PrintWindow/WGC family). +- `adom-bridge janitor status` — verify the auto-pull cache is being managed (so a tool never builds its own cleaner).
skills/adom-bridge-window-identity/SKILL.md+111−108@@ -1,108 +1,111 @@-name: adom-bridge-window-identity-description: Make a window an AI drives present itself as YOUR app on Windows — its taskbar button, Alt-Tab entry, hover name, jump-list (right-click menu), taskbar overlay badge, and pins. Covers the AUMID model, the register+stamp two-halves rule, per-session identities, jump-list tasks + header branding, taskbar progress/overlay, and the icon-format rules (why an icon renders as a generic white document and how to fix it). Use when a bridge borrows a host window (e.g. Chrome for Testing) and it reads as the wrong app.--# Window identity and the taskbar — Adom Bridge's AUMID verbs--This is an advanced corner of the Windows shell that few APIs expose cleanly. Adom Bridge (ab) wraps it in a small, **generic** verb family: you pass a name + icon + (optionally) a command, and ab makes any window — even one owned by another process — present itself as *your* app across every shell surface. ab knows nothing about which app you are; it applies what you pass.--The motivating case: a bridge drives a **Chrome for Testing** window, which otherwise reads *"Test"* with Chrome's icon in the taskbar and Alt-Tab — two brands and a confusing word. These verbs make it read as your bridge instead.--## The one concept you must hold: the AUMID--Windows groups and labels windows by their **Application User Model ID** (AUMID) — a short dotted string like `Adom.Pup.session42`. The AUMID is what the shell resolves to get:--- the **taskbar button** grouping, its **icon**, and its **name**-- the **Alt-Tab** entry (icon + name)-- the **jump list** (the right-click menu on the taskbar button) and its **header row** (the app tile above *Pin to taskbar* / *Close window*)-- **toasts** you raise, and how a user **pins** the window--A window has *some* AUMID whether you set one or not (it inherits the owning process's). Your job is to (1) give the window a **specific** AUMID and (2) tell Windows what that AUMID **means** (its name + icon). Those are two different operations — miss either and the identity is half-wrong.--## The two halves — you need BOTH--| Half | Verb | What it does |-|---|---|---|-| **Stamp the window** | `desktop_set_window_identity {hwnd, appId, iconPath, displayName, relaunchCommand}` | Puts the AUMID on the *live* HWND (`PKEY_AppUserModel_ID`) + sets the title-bar/Alt-Tab icon (`WM_SETICON`) + the Relaunch trio for the jump-list header. |-| **Register the AUMID** | `desktop_register_app_identity {appId, displayName, iconPath, shortcut?}` | Teaches Windows what that appId *means*: an HKCU `AppUserModelId\<appId>` key (name + icon) and, when `shortcut:true`, a Start Menu shortcut carrying the AUMID (what makes it **pinnable**). |--Stamp without register → Alt-Tab / the taskbar fall back to the owning exe's icon (the "Test / Chrome icon" problem). Register without stamp → nothing points the window at your AUMID. **Do both.**--`desktop_unregister_app_identity {appId}` removes the registration (HKCU key + any Start Menu shortcut carrying that AUMID) — call it when a per-session identity is done.--Everything here is **HKCU only** — no elevation, no UAC prompt, ever.--## Per-session identities (`shortcut:false`)--For a window that comes and goes (one browser session), you want a **distinct** AUMID per window so each keeps its own taskbar button, but you do NOT want a Start Menu shortcut per session (that would spray dead entries). Register with `shortcut:false`:--```-desktop_register_app_identity {appId:"Adom.Pup.session42", displayName:"Adom Pup", iconPath:"C:\\...\\pup.ico", shortcut:false}-desktop_set_window_identity {hwnd:<cft hwnd>, appId:"Adom.Pup.session42", iconPath:"C:\\...\\pup.ico", displayName:"Adom Pup", relaunchCommand:"<how to reopen>"}-```--Registry-only registration gives Alt-Tab + taskbar hover the right name + icon with no top-level Start Menu entry. Pair every `shortcut:false` register with an `unregister` at session close. Such an identity is **not pinnable** (no shortcut) — keep a separate shortcut-mode registration for a launcher identity you want users to pin.--**The anchor (ab 2.1.47, wiki #155).** A registry-only identity with NO shortcut anywhere makes the shell resolve the AUMID lazily on the first right-click of its taskbar button: the jump list paints, dismisses ~300 ms later, and only the second right-click stays. Measured with a timed harness over 26 fresh AUMIDs; a hidden `.lnk`, `SHAddToRecentDocs(SHARD_APPIDINFO)`, a warmed task shim, a 30 s settle and a no-list registration all still flash; a VISIBLE `.lnk` carrying the AUMID, in the Programs root or in a subfolder, is the one fix. `anchor:true` (opt-in; default OFF since 2.1.49) writes a small visible anchor `.lnk` inside one Start Menu subfolder (`anchorFolder`, default "Adom Bridge"): N per-window identities show as ONE collapsed folder. Its target is the windowless `ab-taskrun.exe` shim (a Start click does nothing), the response carries `anchorPath` and `mode:"registry-only+anchor"`, and `unregister` removes it (folder too, once empty; pass the same `anchorFolder` if you named one). Why off by default: the anchor is a real Start Menu entry (it shows under All apps, in "Recently added", and in search; `NoStartPage` on the target exe does not hide it from search), and John's rule is a clean Start Menu over a first-click flyout. The flash is the accepted cost of the default.--## Jump lists — the right-click menu + its header--`desktop_set_window_jumplist {appId, tasks, hwnd?, headerIcon?, headerCommand?, headerName?}` attaches a custom right-click menu to the AUMID. Each task: `{title, target?, args?, iconPath?, description?}` (target defaults to ab's bundled CLI, so a task can just be a verb + JSON). `tasks:[]` clears the list.--- Tasks appear on the taskbar right-click menu of **every** window stamped with that AUMID, so a per-session appId gives a per-window menu.-- **The header row** (the app tile above Pin/Close) is a *separate* surface. It is painted from the **window's Relaunch trio**, not the AUMID registry icon — so a `shortcut:false` AUMID shows a generic icon there unless you brand it. Pass `hwnd` + `headerIcon` (and optionally `headerCommand`/`headerName`) and ab stamps the window's Relaunch trio so the header renders your icon **without** a Start Menu shortcut. `headerBranded:true` in the response confirms it stuck.--## Taskbar status — progress + overlay badge--`desktop_taskbar {hwnd, progress?, overlay?, flash?}` paints **glanceable status** on any window's taskbar button (works cross-process), so you can show what the AI is doing on the real app window it drives:--- `progress:{state:"normal"|"paused"|"error"|"indeterminate"|"none", value?}` — a bar along the bottom of the button. `value` is a percent (60) or a fraction (0.6); a determinate bar at 0% is empty and invisible, and the reply's `progressValue` / `_progressNote` say what landed (2.1.169, wiki #224). On Windows 11 the bar is THIN and `normal` is drawn in the system accent colour (often blue, not green); paused is yellow (human-in-the-loop like a captcha), error red, indeterminate a short sweeping segment. To verify it in a screenshot, crop that one button and compare against `state:"none"`; a red-tinted button without a bar is Windows' attention flash, not progress.-- `overlay:{badge:"adom"|"none", icon?:<.png/.ico path or base64>, avatar?, tooltip?}` — a small badge in the button corner. For Chrome/Edge, that overlay slot holds the **profile avatar**; pass `avatar` and ab composites your mark onto it so the user keeps work-vs-personal.-- `flash:"until_focused"|"count"|"stop"` — flash the button for attention.--`desktop_flash_window` is the lightweight flash-only cousin.--## Toasts--`notify_user` raises a native Windows toast under ab's own identity (AUMID `inc.adom.bridge`, the Adom logo). See the `adom-bridge-notify` skill for the full toast catalog (progress, sticky reminders, buttons that round-trip a click).--## Icon formats — why an icon renders as a generic white document--This is the #1 source of "my icon doesn't show." The identity verbs return an **`iconCheck`** block that inspects the file you passed and lists concrete problems. The rules it encodes:--- **Give a multi-size `.ico`** (16 / 24 / 32 / 48 / 256). A single-size icon looks bad where the shell wants a different size.-- **The 256×256 size MUST be PNG-compressed.** Since Windows Vista, a 256px `.ico` entry stored as an uncompressed BMP/DIB renders as the **generic white-document icon** on the jump-list header and taskbar. This is subtle: the icon looks fine in an image viewer but wrong in the shell. Re-export the `.ico` with the 256px size PNG-compressed (most icon tools do this; if unsure, drop the 256px size). `iconCheck` flags this specifically.-- **A per-window `RelaunchIconResource` (the jump-list header) for a `.ico` uses a BARE path** — ab handles this; you just pass the `.ico`. A stray `path,0` (the PE-resource form) breaks a `.ico`, because the shell then looks for "resource index 0 of a PE module," which a `.ico` is not → generic icon. (This was a real bug, fixed ab 1.9.151.)-- **A PE module (`.exe`/`.dll`) is referenced as `<path>,<index>`.** ab uses `,0`; pass your own index if your icon isn't resource 0. A standalone `.ico` is simpler.-- **SVG does not work** — the Win32 shell can't load it. Convert to `.ico`/`.png`.-- **The AUMID registry icon (taskbar/Alt-Tab) accepts `.ico` or `.png`; an `.exe` path there renders the blank-doc icon.** A taskbar **overlay** wants a small (~16px) image; a `.png` is ideal (ab converts it to `.ico` internally).--If `iconCheck.ok` is false, fix the file per `iconCheck.issues` and re-stamp — don't ship the broken icon.--## The live-tile trap (the one timing gotcha)--Re-stamping the **same** appId on a **live** window with a new icon updates the title bar + Alt-Tab but **not** the taskbar tile: Win11 bakes the tile when the button is *created* and never re-reads it (WM_SETICON, ITaskbarList DeleteTab+AddTab, SHChangeNotify, RedrawWindow — all measured no-ops). The only non-disruptive way to change a live tile is to make the shell create a **new** button: register a **different** appId (registry-only, new icon) and stamp the window with it — the button is destroyed and recreated with the new tile in ~1s, no window flicker. Cost: a pin made under the old appId shows the running window as a separate button, so use a **stable** appId for identity you want pinnable and a **varying** one only when the tile must track state.--## Recipe: brand a borrowed window end-to-end--```-# 1. Teach Windows the identity (once per install for a stable id; per session for shortcut:false)-desktop_register_app_identity {appId:"MyBridge.Session7", displayName:"My Bridge", iconPath:"C:\\bridge\\icon.ico", shortcut:false}--# 2. Stamp the live window (taskbar + Alt-Tab + title bar) and brand the jump-list header (Relaunch trio)-desktop_set_window_identity {hwnd:<hwnd>, appId:"MyBridge.Session7", iconPath:"C:\\bridge\\icon.ico", displayName:"My Bridge", relaunchCommand:"mybridge --reopen 7"}--# 3. Attach a right-click menu (optional) + brand its header row-desktop_set_window_jumplist {appId:"MyBridge.Session7", hwnd:<hwnd>, headerIcon:"C:\\bridge\\icon.ico",- tasks:[{title:"New tab", args:"browser_open_window {}"}]}--# 4. Show live status on the button while you work (optional)-desktop_taskbar {hwnd:<hwnd>, overlay:{badge:"adom", tooltip:"My Bridge is driving"}, progress:{state:"indeterminate"}}--# 5. On session close-desktop_taskbar {hwnd:<hwnd>, progress:{state:"none"}, overlay:{badge:"none"}, flash:"stop"}-desktop_unregister_app_identity {appId:"MyBridge.Session7"}-```--Resolve `<hwnd>` with `desktop_list_windows` / `desktop_find_window`.+--- +name: adom-bridge-window-identity +description: >- + Make a window an AI drives present as your app on Windows: taskbar button, Alt-Tab name, + jump list, overlay badge, AUMID. Use when a bridge borrows a host window (e.g. Chrome for + Testing) and it shows as the wrong app or with a blank icon. +--- + +# Window identity and the taskbar — Adom Bridge's AUMID verbs + +This is an advanced corner of the Windows shell that few APIs expose cleanly. Adom Bridge (ab) wraps it in a small, **generic** verb family: you pass a name + icon + (optionally) a command, and ab makes any window — even one owned by another process — present itself as *your* app across every shell surface. ab knows nothing about which app you are; it applies what you pass. + +The motivating case: a bridge drives a **Chrome for Testing** window, which otherwise reads *"Test"* with Chrome's icon in the taskbar and Alt-Tab — two brands and a confusing word. These verbs make it read as your bridge instead. + +## The one concept you must hold: the AUMID + +Windows groups and labels windows by their **Application User Model ID** (AUMID) — a short dotted string like `Adom.Pup.session42`. The AUMID is what the shell resolves to get: + +- the **taskbar button** grouping, its **icon**, and its **name** +- the **Alt-Tab** entry (icon + name) +- the **jump list** (the right-click menu on the taskbar button) and its **header row** (the app tile above *Pin to taskbar* / *Close window*) +- **toasts** you raise, and how a user **pins** the window + +A window has *some* AUMID whether you set one or not (it inherits the owning process's). Your job is to (1) give the window a **specific** AUMID and (2) tell Windows what that AUMID **means** (its name + icon). Those are two different operations — miss either and the identity is half-wrong. + +## The two halves — you need BOTH + +| Half | Verb | What it does | +|---|---|---| +| **Stamp the window** | `desktop_set_window_identity {hwnd, appId, iconPath, displayName, relaunchCommand}` | Puts the AUMID on the *live* HWND (`PKEY_AppUserModel_ID`) + sets the title-bar/Alt-Tab icon (`WM_SETICON`) + the Relaunch trio for the jump-list header. | +| **Register the AUMID** | `desktop_register_app_identity {appId, displayName, iconPath, shortcut?}` | Teaches Windows what that appId *means*: an HKCU `AppUserModelId\<appId>` key (name + icon) and, when `shortcut:true`, a Start Menu shortcut carrying the AUMID (what makes it **pinnable**). | + +Stamp without register → Alt-Tab / the taskbar fall back to the owning exe's icon (the "Test / Chrome icon" problem). Register without stamp → nothing points the window at your AUMID. **Do both.** + +`desktop_unregister_app_identity {appId}` removes the registration (HKCU key + any Start Menu shortcut carrying that AUMID) — call it when a per-session identity is done. + +Everything here is **HKCU only** — no elevation, no UAC prompt, ever. + +## Per-session identities (`shortcut:false`) + +For a window that comes and goes (one browser session), you want a **distinct** AUMID per window so each keeps its own taskbar button, but you do NOT want a Start Menu shortcut per session (that would spray dead entries). Register with `shortcut:false`: + +``` +desktop_register_app_identity {appId:"Adom.Pup.session42", displayName:"Adom Pup", iconPath:"C:\\...\\pup.ico", shortcut:false} +desktop_set_window_identity {hwnd:<cft hwnd>, appId:"Adom.Pup.session42", iconPath:"C:\\...\\pup.ico", displayName:"Adom Pup", relaunchCommand:"<how to reopen>"} +``` + +Registry-only registration gives Alt-Tab + taskbar hover the right name + icon with no top-level Start Menu entry. Pair every `shortcut:false` register with an `unregister` at session close. Such an identity is **not pinnable** (no shortcut) — keep a separate shortcut-mode registration for a launcher identity you want users to pin. + +**The anchor (ab 2.1.47, wiki #155).** A registry-only identity with NO shortcut anywhere makes the shell resolve the AUMID lazily on the first right-click of its taskbar button: the jump list paints, dismisses ~300 ms later, and only the second right-click stays. Measured with a timed harness over 26 fresh AUMIDs; a hidden `.lnk`, `SHAddToRecentDocs(SHARD_APPIDINFO)`, a warmed task shim, a 30 s settle and a no-list registration all still flash; a VISIBLE `.lnk` carrying the AUMID, in the Programs root or in a subfolder, is the one fix. `anchor:true` (opt-in; default OFF since 2.1.49) writes a small visible anchor `.lnk` inside one Start Menu subfolder (`anchorFolder`, default "Adom Bridge"): N per-window identities show as ONE collapsed folder. Its target is the windowless `ab-taskrun.exe` shim (a Start click does nothing), the response carries `anchorPath` and `mode:"registry-only+anchor"`, and `unregister` removes it (folder too, once empty; pass the same `anchorFolder` if you named one). Why off by default: the anchor is a real Start Menu entry (it shows under All apps, in "Recently added", and in search; `NoStartPage` on the target exe does not hide it from search), and John's rule is a clean Start Menu over a first-click flyout. The flash is the accepted cost of the default. + +## Jump lists — the right-click menu + its header + +`desktop_set_window_jumplist {appId, tasks, hwnd?, headerIcon?, headerCommand?, headerName?}` attaches a custom right-click menu to the AUMID. Each task: `{title, target?, args?, iconPath?, description?}` (target defaults to ab's bundled CLI, so a task can just be a verb + JSON). `tasks:[]` clears the list. + +- Tasks appear on the taskbar right-click menu of **every** window stamped with that AUMID, so a per-session appId gives a per-window menu. +- **The header row** (the app tile above Pin/Close) is a *separate* surface. It is painted from the **window's Relaunch trio**, not the AUMID registry icon — so a `shortcut:false` AUMID shows a generic icon there unless you brand it. Pass `hwnd` + `headerIcon` (and optionally `headerCommand`/`headerName`) and ab stamps the window's Relaunch trio so the header renders your icon **without** a Start Menu shortcut. `headerBranded:true` in the response confirms it stuck. + +## Taskbar status — progress + overlay badge + +`desktop_taskbar {hwnd, progress?, overlay?, flash?}` paints **glanceable status** on any window's taskbar button (works cross-process), so you can show what the AI is doing on the real app window it drives: + +- `progress:{state:"normal"|"paused"|"error"|"indeterminate"|"none", value?}` — a bar along the bottom of the button. `value` is a percent (60) or a fraction (0.6); a determinate bar at 0% is empty and invisible, and the reply's `progressValue` / `_progressNote` say what landed (2.1.169, wiki #224). On Windows 11 the bar is THIN and `normal` is drawn in the system accent colour (often blue, not green); paused is yellow (human-in-the-loop like a captcha), error red, indeterminate a short sweeping segment. To verify it in a screenshot, crop that one button and compare against `state:"none"`; a red-tinted button without a bar is Windows' attention flash, not progress. +- `overlay:{badge:"adom"|"none", icon?:<.png/.ico path or base64>, avatar?, tooltip?}` — a small badge in the button corner. For Chrome/Edge, that overlay slot holds the **profile avatar**; pass `avatar` and ab composites your mark onto it so the user keeps work-vs-personal. +- `flash:"until_focused"|"count"|"stop"` — flash the button for attention. + +`desktop_flash_window` is the lightweight flash-only cousin. + +## Toasts + +`notify_user` raises a native Windows toast under ab's own identity (AUMID `inc.adom.bridge`, the Adom logo). See the `adom-bridge-notify` skill for the full toast catalog (progress, sticky reminders, buttons that round-trip a click). + +## Icon formats — why an icon renders as a generic white document + +This is the #1 source of "my icon doesn't show." The identity verbs return an **`iconCheck`** block that inspects the file you passed and lists concrete problems. The rules it encodes: + +- **Give a multi-size `.ico`** (16 / 24 / 32 / 48 / 256). A single-size icon looks bad where the shell wants a different size. +- **The 256×256 size MUST be PNG-compressed.** Since Windows Vista, a 256px `.ico` entry stored as an uncompressed BMP/DIB renders as the **generic white-document icon** on the jump-list header and taskbar. This is subtle: the icon looks fine in an image viewer but wrong in the shell. Re-export the `.ico` with the 256px size PNG-compressed (most icon tools do this; if unsure, drop the 256px size). `iconCheck` flags this specifically. +- **A per-window `RelaunchIconResource` (the jump-list header) for a `.ico` uses a BARE path** — ab handles this; you just pass the `.ico`. A stray `path,0` (the PE-resource form) breaks a `.ico`, because the shell then looks for "resource index 0 of a PE module," which a `.ico` is not → generic icon. (This was a real bug, fixed ab 1.9.151.) +- **A PE module (`.exe`/`.dll`) is referenced as `<path>,<index>`.** ab uses `,0`; pass your own index if your icon isn't resource 0. A standalone `.ico` is simpler. +- **SVG does not work** — the Win32 shell can't load it. Convert to `.ico`/`.png`. +- **The AUMID registry icon (taskbar/Alt-Tab) accepts `.ico` or `.png`; an `.exe` path there renders the blank-doc icon.** A taskbar **overlay** wants a small (~16px) image; a `.png` is ideal (ab converts it to `.ico` internally). + +If `iconCheck.ok` is false, fix the file per `iconCheck.issues` and re-stamp — don't ship the broken icon. + +## The live-tile trap (the one timing gotcha) + +Re-stamping the **same** appId on a **live** window with a new icon updates the title bar + Alt-Tab but **not** the taskbar tile: Win11 bakes the tile when the button is *created* and never re-reads it (WM_SETICON, ITaskbarList DeleteTab+AddTab, SHChangeNotify, RedrawWindow — all measured no-ops). The only non-disruptive way to change a live tile is to make the shell create a **new** button: register a **different** appId (registry-only, new icon) and stamp the window with it — the button is destroyed and recreated with the new tile in ~1s, no window flicker. Cost: a pin made under the old appId shows the running window as a separate button, so use a **stable** appId for identity you want pinnable and a **varying** one only when the tile must track state. + +## Recipe: brand a borrowed window end-to-end + +``` +# 1. Teach Windows the identity (once per install for a stable id; per session for shortcut:false) +desktop_register_app_identity {appId:"MyBridge.Session7", displayName:"My Bridge", iconPath:"C:\\bridge\\icon.ico", shortcut:false} + +# 2. Stamp the live window (taskbar + Alt-Tab + title bar) and brand the jump-list header (Relaunch trio) +desktop_set_window_identity {hwnd:<hwnd>, appId:"MyBridge.Session7", iconPath:"C:\\bridge\\icon.ico", displayName:"My Bridge", relaunchCommand:"mybridge --reopen 7"} + +# 3. Attach a right-click menu (optional) + brand its header row +desktop_set_window_jumplist {appId:"MyBridge.Session7", hwnd:<hwnd>, headerIcon:"C:\\bridge\\icon.ico", + tasks:[{title:"New tab", args:"browser_open_window {}"}]} + +# 4. Show live status on the button while you work (optional) +desktop_taskbar {hwnd:<hwnd>, overlay:{badge:"adom", tooltip:"My Bridge is driving"}, progress:{state:"indeterminate"}} + +# 5. On session close +desktop_taskbar {hwnd:<hwnd>, progress:{state:"none"}, overlay:{badge:"none"}, flash:"stop"} +desktop_unregister_app_identity {appId:"MyBridge.Session7"} +``` + +Resolve `<hwnd>` with `desktop_list_windows` / `desktop_find_window`.
skills/adom-bridge-windows-host/SKILL.md+167−163@@ -1,163 +1,167 @@-name: adom-bridge-windows-host-user-invokable: false-description: "Read this BEFORE doing any work on adom-bridge from a Claude Code session running directly on the user's Windows machine. Explains how to route everything through the adom-bridge CLI so paths, file operations, and process spawns reach the user's real filesystem instead of getting silently virtualized into Claude Code's Windows AppContainer sandbox. Skipping this causes hour-long debugging spirals where 'open install folder' fails, bridges install to the wrong place, screenshots come back blank, and synthesized clicks don't reach the WebView2."--# Working on adom-bridge from Claude Code on Windows--This skill applies when Claude Code is running **on the user's Windows machine** — i.e. you have a `bash`/`PowerShell` tool that hits the local filesystem directly, instead of running inside the Adom Docker container.--## The single most important fact--Claude Code on Windows runs inside a **Windows AppContainer sandbox**. Every filesystem write from your `bash` or `PowerShell` tool gets transparently redirected:--| What you write | Where it actually lands |-|---|---|-| `C:\Users\john\AppData\Local\Adom Bridge\...` | `C:\Users\john\AppData\Local\Packages\Claude_<pkgid>\LocalCache\Local\Adom Bridge\...` |-| `C:\Users\john\Documents\...` (any AppData/user-roaming write) | The Packages\Claude_<pkgid>\LocalCache equivalent |--**From your perspective both paths show identical contents.** The redirect is invisible inside the sandbox. From the user's real filesystem (their explorer.exe, their PowerShell from Start menu), only the `Packages\Claude_<pkgid>\...` location exists.--**Consequence:** If you launch the GUI from your `bash` (`start "" "C:\...\adom-bridge.exe"`), the GUI inherits your AppContainer. Bridges it installs land in the virtualized cache. The user clicks "Open install folder" and Explorer says **"Location is not available"** because the path the GUI wrote into the registry doesn't exist in the user's real session.--This is the entire reason "Open install folder" took an hour to ship in v1.8.11. Don't repeat it.--## The fix: drive the GUI through `adom-bridge` CLI like Docker does--Instead of using your own `bash`/`PowerShell` for anything user-visible, route every operation through the `adom-bridge` CLI. The CLI talks to a relay (HTTP) which dispatches to the running GUI (WS). The GUI executes commands in **its own process context** — which, when launched correctly, is the user's normal Windows session, unsandboxed.--### Setup (do this once per session)--1. **Pick alt ports if 8765/8766 are taken.** Docker Desktop typically holds `8765` and `8766` for its internal use. Check with `netstat -ano | grep -E ":876[56] "`. Use `8865`/`8866` (or any free pair).--2. **Start the local relay on the alt ports:**- ```bash- nohup C:/Github/adom-bridge/cli/target/release/adom-bridge-cli.exe serve \- --http-port 8866 --ws-port 8865 > /tmp/relay.log 2>&1 &- ```- Verify: `cat /tmp/relay.log` should show `Adom relay server started`.--3. **Add the alt-port localhost server to `~/.adom/config.json`** so the GUI auto-connects on launch:- ```json- {- "auth_token": "<your-token>",- "auto_connect": true,- "enabled": true,- "id": "local-dev-server",- "name": "localhost",- "url": "ws://localhost:8865"- }- ```- (Edit the existing `localhost` entry if it's already there — just enable it and bump the port.)--4. **Launch the GUI via `cmd /c start ""`** — this routes through the Windows Shell broker, which spawns the new process in the user's normal session, breaking your AppContainer inheritance:- ```bash- cmd //c start "" "C:\Users\john\AppData\Local\Adom Bridge\adom-bridge.exe"- ```- **DO NOT** use bare `start "" "..."` from your bash — that inherits your sandbox.--5. **Set `ADOM_API` env var** so the CLI hits your alt port:- ```bash- export ADOM_API="http://127.0.0.1:8866"- ```- Pin this near the top of every command sequence; it doesn't persist across shells.--6. **Verify the GUI is unsandboxed.** Run via shell_execute and check the token:- ```bash- adom-bridge shell_execute '{"command":"powershell -NoProfile -ExecutionPolicy Bypass -Command \"Add-Type -TypeDefinition @\\\"using System; using System.Runtime.InteropServices; public class T { [DllImport(\\\\\\\"advapi32.dll\\\\\\\")] public static extern bool OpenProcessToken(IntPtr p, int a, out IntPtr t); [DllImport(\\\\\\\"advapi32.dll\\\\\\\")] public static extern bool GetTokenInformation(IntPtr h, int c, IntPtr b, int l, out int r); [DllImport(\\\\\\\"kernel32.dll\\\\\\\")] public static extern IntPtr GetCurrentProcess(); }\\\"@; $t=[IntPtr]::Zero; [void][T]::OpenProcessToken([T]::GetCurrentProcess(), 8, [ref]$t); $b=[Runtime.InteropServices.Marshal]::AllocHGlobal(8); $l=0; [void][T]::GetTokenInformation($t, 29, $b, 8, [ref]$l); Write-Host TokenIsAppContainer=$([Runtime.InteropServices.Marshal]::ReadInt32($b))\"","timeout_secs":5}'- ```- Expect `TokenIsAppContainer=0`. If it returns `1`, you launched the GUI from a sandboxed parent — kill it and relaunch via `cmd //c start ""`.--### Everything you do from here goes through the CLI--| Need to... | Use this | NOT this |-|---|---|---|-| Run PowerShell on user's machine | `adom-bridge shell_execute '{"command":"powershell ...","timeout_secs":N}'` | Your own `PowerShell` tool |-| Screenshot a window | `adom-bridge desktop_screenshot_window '{"hwnd":N}'` | Win32 `PrintWindow` from your PowerShell |-| Screenshot the screen | `adom-bridge desktop_screenshot_screen` | Your own screen capture |-| Find windows | `adom-bridge desktop_list_windows` | UIA enumeration from your PowerShell |-| Install/uninstall a bridge | `adom-bridge bridge_install`, `bridge_uninstall` | Manually copying zip contents to the cache dir |-| Inspect the bridge registry | `adom-bridge bridge_list` | Reading `bridge.json` from disk paths |-| Open a folder in Explorer | (test by invoking the same Rust code path; see below) | `Start-Process explorer.exe` from your bash |--**Quoting trick for `shell_execute`:** the JSON arg gets parsed once, then PowerShell parses it again. Backslashes need quadrupling (`\\\\` for a literal `\`). When the command is non-trivial, **write it to a `.ps1` file and `shell_execute` the file** instead of inlining:-```bash-cat > /c/tmp/probe.ps1 << 'EOF'-# ... powershell here, single-quoted heredoc so no shell interpolation-EOF-adom-bridge shell_execute '{"command":"powershell -NoProfile -ExecutionPolicy Bypass -File C:\\\\tmp\\\\probe.ps1","timeout_secs":10}'-```--## Specifically: opening folders in Explorer--This is the one that took TWO hours across multiple iterations. Lessons crystallized:--1. **`explorer.exe /select,<path>`** — the only reliable form. Opens a NEW Explorer window navigated to the path's parent, with the path itself pre-selected. Use this in `open_path_in_explorer`-style Tauri commands.-2. **`explorer.exe <path>` (no flag)** — silently no-ops when an Explorer process is already up. Don't use.-3. **`ShellExecuteW("open", <path>)`** — returns success (HINSTANCE > 32) but often doesn't visibly open a window. Don't use for folders.-4. **`cmd /c start "" "<path>"`** — same fate as #2 for folders. Fine for URLs (used by `open_external_url`).--### The `raw_arg` trap--`std::process::Command::new("explorer").arg("/select,C:\\path with space\\hello").spawn()` does **NOT** work. Rust auto-wraps any arg containing spaces in double quotes, emitting:-- explorer.exe "/select,C:\path with space\hello"--Explorer parses the whole quoted string as one token, fails to match `/select,`, and falls back to opening the user's Documents folder. The user reports "Open install folder takes me to Documents."--What Explorer actually wants:-- explorer.exe /select,"C:\path with space\hello"--— quotes around the **path portion only**, not the whole arg. The fix is `std::os::windows::process::CommandExt::raw_arg`, which emits the literal command-line bytes without Rust's escaping rules:--```rust-use std::os::windows::process::CommandExt;-let raw = format!(r#"/select,"{}""#, win_path);-std::process::Command::new("explorer").raw_arg(&raw).spawn()?;-```--**General rule**: when invoking a Windows exe with a quirky / legacy command-line parser (`explorer`, `msbuild`, `cl`, some `cmd` builtins), assume `Command::arg` will mis-quote. Use `raw_arg` and construct the command line by hand. You can verify the emitted command line via `shell_execute` running a PowerShell simulation:--```powershell-$psi = New-Object System.Diagnostics.ProcessStartInfo-$psi.FileName = "explorer.exe"-$psi.Arguments = '/select,"' + $path + '"' # ← matches raw_arg output-$psi.UseShellExecute = $false # ← matches Rust's CreateProcessW path-[void][System.Diagnostics.Process]::Start($psi)-```--Then enumerate `(New-Object -ComObject Shell.Application).Windows()` to see what location Explorer actually navigated to. If it's the path you wanted: ship. If it's `Documents` / `Home`: your quoting is wrong.--Before passing the path to explorer, normalize:-- Replace `/` with `\` — `BridgeRegistry` stores cache paths with forward slashes via `dirs::data_local_dir()`.-- Strip `\\?\` prefix — Tauri's `resource_dir()` returns paths in this verbatim form for bundled bridges; Explorer rejects it.--**Always check the path exists BEFORE shelling out**, and return a structured error if it doesn't. The Rust side:-```rust-if !p.exists() {- return Err(format!("Path does not exist: {}", path));-}-```-catches the sandbox-write-to-wrong-place class of bugs early. If you see this error, you almost certainly installed the bridge while your GUI was sandboxed; reinstall via the CLI-driven user-session GUI.--## Debugging checklist when something "doesn't work"--If a user-visible action (open folder, screenshot, bridge install) seems broken, before iterating on the Rust code:--1. **Is the GUI unsandboxed?** Run the TokenIsAppContainer probe above. If `1`, the entire path is poisoned — relaunch via `cmd //c start ""` and reinstall any cache state.-2. **Are you using `ADOM_API=http://127.0.0.1:8866`?** If your CLI hits the default 8766 and Docker has it, you get `Connection Failed`.-3. **Does the path EXIST on the user's real filesystem?** Use `shell_execute` to run `Get-ChildItem <path>` — that's the source of truth. Don't trust `ls` from your own bash.-4. **Did `taskkill /F /IM adom-bridge.exe` collateral-kill the relay?** The CLI binary and the GUI binary share the same exe name. Kill by PID, not by image name, so the relay survives.-5. **Are you reading explorer's view via COM?** Explorer.exe caches folder listings aggressively and can show stale state. Trust `Get-ChildItem` (kernel API) over `Shell.Application.Windows().Document.Folder.Items()` (cached UI view) — the FS API is the truth.--## Why this gets re-learned every session--Every Claude on the Windows host starts in the sandbox without knowing it. Path operations *appear* to work — your `Test-Path` returns True, your `ls` shows files. The redirect is invisible until something user-visible (Explorer, another non-sandboxed process) tries to use the same path and finds it missing.--Read this skill at session start when you're working on adom-bridge locally. It saves an hour.--## Related memory--- `~/.claude/projects/C--Github-adom-bridge/memory/feedback_dont_kill_gui_during_iter.md` — don't kill the GUI repeatedly during iteration. The CLI-driven approach above means you almost never *need* to kill the GUI: changes that don't touch Rust ship without a rebuild via `release-bridge.sh`, and changes that do touch Rust use FAST tauri (~1-2 min) with a single GUI restart.+--- +name: adom-bridge-windows-host +user-invokable: false +description: >- + For Claude Code running natively on the user's Windows PC, not in an Adom container: route + work through adom-bridge-cli.exe so files and spawns reach the real filesystem, not the + AppContainer sandbox. Use when working on ab from such a session, or open-folder, + screenshots or clicks fail. +--- + +# Working on adom-bridge from Claude Code on Windows + +This skill applies when Claude Code is running **on the user's Windows machine** — i.e. you have a `bash`/`PowerShell` tool that hits the local filesystem directly, instead of running inside the Adom Docker container. + +## The single most important fact + +Claude Code on Windows runs inside a **Windows AppContainer sandbox**. Every filesystem write from your `bash` or `PowerShell` tool gets transparently redirected: + +| What you write | Where it actually lands | +|---|---| +| `C:\Users\john\AppData\Local\Adom Bridge\...` | `C:\Users\john\AppData\Local\Packages\Claude_<pkgid>\LocalCache\Local\Adom Bridge\...` | +| `C:\Users\john\Documents\...` (any AppData/user-roaming write) | The Packages\Claude_<pkgid>\LocalCache equivalent | + +**From your perspective both paths show identical contents.** The redirect is invisible inside the sandbox. From the user's real filesystem (their explorer.exe, their PowerShell from Start menu), only the `Packages\Claude_<pkgid>\...` location exists. + +**Consequence:** If you launch the GUI from your `bash` (`start "" "C:\...\adom-bridge.exe"`), the GUI inherits your AppContainer. Bridges it installs land in the virtualized cache. The user clicks "Open install folder" and Explorer says **"Location is not available"** because the path the GUI wrote into the registry doesn't exist in the user's real session. + +This is the entire reason "Open install folder" took an hour to ship in v1.8.11. Don't repeat it. + +## The fix: drive the GUI through `adom-bridge` CLI like Docker does + +Instead of using your own `bash`/`PowerShell` for anything user-visible, route every operation through the `adom-bridge` CLI. The CLI talks to a relay (HTTP) which dispatches to the running GUI (WS). The GUI executes commands in **its own process context** — which, when launched correctly, is the user's normal Windows session, unsandboxed. + +### Setup (do this once per session) + +1. **Pick alt ports if 8765/8766 are taken.** Docker Desktop typically holds `8765` and `8766` for its internal use. Check with `netstat -ano | grep -E ":876[56] "`. Use `8865`/`8866` (or any free pair). + +2. **Start the local relay on the alt ports:** + ```bash + nohup C:/Github/adom-bridge/cli/target/release/adom-bridge-cli.exe serve \ + --http-port 8866 --ws-port 8865 > /tmp/relay.log 2>&1 & + ``` + Verify: `cat /tmp/relay.log` should show `Adom relay server started`. + +3. **Add the alt-port localhost server to `~/.adom/config.json`** so the GUI auto-connects on launch: + ```json + { + "auth_token": "<your-token>", + "auto_connect": true, + "enabled": true, + "id": "local-dev-server", + "name": "localhost", + "url": "ws://localhost:8865" + } + ``` + (Edit the existing `localhost` entry if it's already there — just enable it and bump the port.) + +4. **Launch the GUI via `cmd /c start ""`** — this routes through the Windows Shell broker, which spawns the new process in the user's normal session, breaking your AppContainer inheritance: + ```bash + cmd //c start "" "C:\Users\john\AppData\Local\Adom Bridge\adom-bridge.exe" + ``` + **DO NOT** use bare `start "" "..."` from your bash — that inherits your sandbox. + +5. **Set `ADOM_API` env var** so the CLI hits your alt port: + ```bash + export ADOM_API="http://127.0.0.1:8866" + ``` + Pin this near the top of every command sequence; it doesn't persist across shells. + +6. **Verify the GUI is unsandboxed.** Run via shell_execute and check the token: + ```bash + adom-bridge shell_execute '{"command":"powershell -NoProfile -ExecutionPolicy Bypass -Command \"Add-Type -TypeDefinition @\\\"using System; using System.Runtime.InteropServices; public class T { [DllImport(\\\\\\\"advapi32.dll\\\\\\\")] public static extern bool OpenProcessToken(IntPtr p, int a, out IntPtr t); [DllImport(\\\\\\\"advapi32.dll\\\\\\\")] public static extern bool GetTokenInformation(IntPtr h, int c, IntPtr b, int l, out int r); [DllImport(\\\\\\\"kernel32.dll\\\\\\\")] public static extern IntPtr GetCurrentProcess(); }\\\"@; $t=[IntPtr]::Zero; [void][T]::OpenProcessToken([T]::GetCurrentProcess(), 8, [ref]$t); $b=[Runtime.InteropServices.Marshal]::AllocHGlobal(8); $l=0; [void][T]::GetTokenInformation($t, 29, $b, 8, [ref]$l); Write-Host TokenIsAppContainer=$([Runtime.InteropServices.Marshal]::ReadInt32($b))\"","timeout_secs":5}' + ``` + Expect `TokenIsAppContainer=0`. If it returns `1`, you launched the GUI from a sandboxed parent — kill it and relaunch via `cmd //c start ""`. + +### Everything you do from here goes through the CLI + +| Need to... | Use this | NOT this | +|---|---|---| +| Run PowerShell on user's machine | `adom-bridge shell_execute '{"command":"powershell ...","timeout_secs":N}'` | Your own `PowerShell` tool | +| Screenshot a window | `adom-bridge desktop_screenshot_window '{"hwnd":N}'` | Win32 `PrintWindow` from your PowerShell | +| Screenshot the screen | `adom-bridge desktop_screenshot_screen` | Your own screen capture | +| Find windows | `adom-bridge desktop_list_windows` | UIA enumeration from your PowerShell | +| Install/uninstall a bridge | `adom-bridge bridge_install`, `bridge_uninstall` | Manually copying zip contents to the cache dir | +| Inspect the bridge registry | `adom-bridge bridge_list` | Reading `bridge.json` from disk paths | +| Open a folder in Explorer | (test by invoking the same Rust code path; see below) | `Start-Process explorer.exe` from your bash | + +**Quoting trick for `shell_execute`:** the JSON arg gets parsed once, then PowerShell parses it again. Backslashes need quadrupling (`\\\\` for a literal `\`). When the command is non-trivial, **write it to a `.ps1` file and `shell_execute` the file** instead of inlining: +```bash +cat > /c/tmp/probe.ps1 << 'EOF' +# ... powershell here, single-quoted heredoc so no shell interpolation +EOF +adom-bridge shell_execute '{"command":"powershell -NoProfile -ExecutionPolicy Bypass -File C:\\\\tmp\\\\probe.ps1","timeout_secs":10}' +``` + +## Specifically: opening folders in Explorer + +This is the one that took TWO hours across multiple iterations. Lessons crystallized: + +1. **`explorer.exe /select,<path>`** — the only reliable form. Opens a NEW Explorer window navigated to the path's parent, with the path itself pre-selected. Use this in `open_path_in_explorer`-style Tauri commands. +2. **`explorer.exe <path>` (no flag)** — silently no-ops when an Explorer process is already up. Don't use. +3. **`ShellExecuteW("open", <path>)`** — returns success (HINSTANCE > 32) but often doesn't visibly open a window. Don't use for folders. +4. **`cmd /c start "" "<path>"`** — same fate as #2 for folders. Fine for URLs (used by `open_external_url`). + +### The `raw_arg` trap + +`std::process::Command::new("explorer").arg("/select,C:\\path with space\\hello").spawn()` does **NOT** work. Rust auto-wraps any arg containing spaces in double quotes, emitting: + + explorer.exe "/select,C:\path with space\hello" + +Explorer parses the whole quoted string as one token, fails to match `/select,`, and falls back to opening the user's Documents folder. The user reports "Open install folder takes me to Documents." + +What Explorer actually wants: + + explorer.exe /select,"C:\path with space\hello" + +— quotes around the **path portion only**, not the whole arg. The fix is `std::os::windows::process::CommandExt::raw_arg`, which emits the literal command-line bytes without Rust's escaping rules: + +```rust +use std::os::windows::process::CommandExt; +let raw = format!(r#"/select,"{}""#, win_path); +std::process::Command::new("explorer").raw_arg(&raw).spawn()?; +``` + +**General rule**: when invoking a Windows exe with a quirky / legacy command-line parser (`explorer`, `msbuild`, `cl`, some `cmd` builtins), assume `Command::arg` will mis-quote. Use `raw_arg` and construct the command line by hand. You can verify the emitted command line via `shell_execute` running a PowerShell simulation: + +```powershell +$psi = New-Object System.Diagnostics.ProcessStartInfo +$psi.FileName = "explorer.exe" +$psi.Arguments = '/select,"' + $path + '"' # ← matches raw_arg output +$psi.UseShellExecute = $false # ← matches Rust's CreateProcessW path +[void][System.Diagnostics.Process]::Start($psi) +``` + +Then enumerate `(New-Object -ComObject Shell.Application).Windows()` to see what location Explorer actually navigated to. If it's the path you wanted: ship. If it's `Documents` / `Home`: your quoting is wrong. + +Before passing the path to explorer, normalize: +- Replace `/` with `\` — `BridgeRegistry` stores cache paths with forward slashes via `dirs::data_local_dir()`. +- Strip `\\?\` prefix — Tauri's `resource_dir()` returns paths in this verbatim form for bundled bridges; Explorer rejects it. + +**Always check the path exists BEFORE shelling out**, and return a structured error if it doesn't. The Rust side: +```rust +if !p.exists() { + return Err(format!("Path does not exist: {}", path)); +} +``` +catches the sandbox-write-to-wrong-place class of bugs early. If you see this error, you almost certainly installed the bridge while your GUI was sandboxed; reinstall via the CLI-driven user-session GUI. + +## Debugging checklist when something "doesn't work" + +If a user-visible action (open folder, screenshot, bridge install) seems broken, before iterating on the Rust code: + +1. **Is the GUI unsandboxed?** Run the TokenIsAppContainer probe above. If `1`, the entire path is poisoned — relaunch via `cmd //c start ""` and reinstall any cache state. +2. **Are you using `ADOM_API=http://127.0.0.1:8866`?** If your CLI hits the default 8766 and Docker has it, you get `Connection Failed`. +3. **Does the path EXIST on the user's real filesystem?** Use `shell_execute` to run `Get-ChildItem <path>` — that's the source of truth. Don't trust `ls` from your own bash. +4. **Did `taskkill /F /IM adom-bridge.exe` collateral-kill the relay?** The CLI binary and the GUI binary share the same exe name. Kill by PID, not by image name, so the relay survives. +5. **Are you reading explorer's view via COM?** Explorer.exe caches folder listings aggressively and can show stale state. Trust `Get-ChildItem` (kernel API) over `Shell.Application.Windows().Document.Folder.Items()` (cached UI view) — the FS API is the truth. + +## Why this gets re-learned every session + +Every Claude on the Windows host starts in the sandbox without knowing it. Path operations *appear* to work — your `Test-Path` returns True, your `ls` shows files. The redirect is invisible until something user-visible (Explorer, another non-sandboxed process) tries to use the same path and finds it missing. + +Read this skill at session start when you're working on adom-bridge locally. It saves an hour. + +## Related memory + +- `~/.claude/projects/C--Github-adom-bridge/memory/feedback_dont_kill_gui_during_iter.md` — don't kill the GUI repeatedly during iteration. The CLI-driven approach above means you almost never *need* to kill the GUI: changes that don't touch Rust ship without a rebuild via `release-bridge.sh`, and changes that do touch Rust use FAST tauri (~1-2 min) with a single GUI restart.
skills/adom-bridge/SKILL.md+1510−1510@@ -1,1510 +1,1510 @@-name: adom-bridge -description: Use when the user wants to send files to their desktop, control KiCad or Fusion 360, send desktop notifications, or troubleshoot the desktop connection. Provides CLI tools for bridging the Docker container to the user's local machine. - -# Adom Bridge - -Bridge between Claude Code (running in an Adom Docker container) and the user's desktop applications via WebSocket. - -> **The CLI is `adom-bridge`** (renamed from `adom-bridge-cli` in v2.0.33 — the `-cli` was noise on Linux). Use `adom-bridge <verb>`. The old `adom-bridge-cli` name still works (the pkg links both), so existing scripts don't break — but prefer `adom-bridge` going forward. On Windows the CLI file stays `adom-bridge-cli.exe` (the GUI app owns `adom-bridge.exe` there). - -**Install surface:** the canonical install page is `apps/adom-bridge` on the Adom wiki — it hosts the Linux CLI (`docker_binary`), the Windows installer (`.exe`), and the four sibling skills (pup, kicad-bridge, fusion-bridge, + this one). The gallia `adom-bridge-discovery` skill surfaces this page on any related user query. `adom-bridge setup_desktop` also returns a dynamic installer URL pulled from the latest GitHub release. - -**First-time setup?** If the user hasn't installed the desktop app yet, see the Setup section below to walk them through downloading, installing, and connecting the app. - -Quick check if desktop is connected: -```bash -adom-bridge ping -``` - -**Companion skills** (installed alongside this one from the wiki): -- `adom-bridge-kicad` — KiCad bridge: launch editors, open designs, install libraries, run DRC, window capture + keyboard/click automation (`plugins/kicad/SKILL.md`) -- `adom-bridge-fusion` — Fusion 360 bridge: launch, open designs, STEP/GLB/.lbr import-export, BOM + API queries, Fusion screenshots (`plugins/fusion360/SKILL.md`) -- `pup` — browser automation (Puppeteer-style): open URLs, screenshot, eval JS, multi-session Chrome (`gallia/skills/pup/SKILL.md`) - -## How It Works - -``` -Claude Code -> adom-bridge <command> -> Relay Server (HTTP :8766) -> WebSocket :8765 -> Adom Bridge App -> KiCad / Fusion 360 / Browser / Shell -``` - -The `adom-bridge` binary is a single Rust CLI that does everything: -- `adom-bridge serve` -- Start the relay server (WebSocket :8765 + HTTP API :8766) -- `adom-bridge <command> '<json>'` -- Send commands to the desktop app via the running relay - -The relay server runs in the Docker container. The Adom Bridge app runs on the user's PC and connects via WebSocket. - -## Starting the Relay Server - -The relay must be running before the desktop app can connect. **In a container, start it SUPERVISED** (v1.9.168+): - -```bash -adom-bridge serve --supervise -``` - -**Why not `adom-bridge serve &`?** A backgrounded (`&`) relay is still a child of your shell/session. When your session is torn down (you finish, or the container reaps it), the relay dies with it — and then every connected desktop hits HTTP 500 on `/proxy/8765/` and loops "Reconnecting in 30s" **for hours** until a human restarts it (wiki issue #24, hit live 2026-07-23). `--supervise` **detaches the relay into its own session (reparented to init)** so it survives your session, and starts a watchdog that: -- restarts the relay with **exponential backoff** (2s → … → 5 min cap; a clean run resets it — a broken relay is never bludgeoned), and -- **toasts the connected desktops on every restart** with the reason from the relay log, so the owner finds out something went wrong. - -```bash -adom-bridge serve --supervise # detached + self-healing (use this in containers) -adom-bridge serve --supervise --owner AdomLapper # send restart toasts to one desktop -adom-bridge serve --stop # stop the watchdog + relay -``` - -`serve` (plain, foreground) still works and prints a loud hint telling you it will die with the session — fine for a quick local relay you'll sit and watch. **Caveat:** a full CONTAINER REBOOT still needs a boot hook (cron `@reboot` / the container's init); the watchdog covers session teardown, not host reboot. - -This starts: -- WebSocket server on `127.0.0.1:8765` (desktop app connects here; `--bind 0.0.0.0` / `ADOM_RELAY_BIND` to listen on the network, 2.1.170 wiki #225) -- HTTP API on `127.0.0.1:8766` (CLI commands go here) - -Check if it's running: -```bash -curl -sf http://127.0.0.1:8766/health -``` - -## First-Time Setup (Install & Connect) - -**You are running on a Docker container. You have NO access to the user's desktop.** Guide them step-by-step, ask questions, wait for answers, and verify each step. - -> **As soon as you're connected, run `adom-bridge sync_skills` (v1.9.37+).** Installing the -> adom-bridge core pkg gives you only CORE skills — it does NOT give you a DYNAMIC/cloud -> bridge's skill (kicad, fusion, blender, any third-party). `sync_skills` runs `bridge_list` -> and installs each bridge's own skill pkg into `~/.claude/skills/`, so you can actually -> discover + drive whatever bridges THIS desktop has. (The CLI also runs this automatically -> once/day in the background, but doing it on connect makes the bridge skills available to -> the current session immediately.) Re-run it any time the desktop gains a bridge; -> `adom-wiki pkg update` keeps them fresh. - -### Step 1: Check if already connected - -```bash -adom-bridge ping -``` - -If this returns `{ "status": "connected" }`, the desktop is already set up -- skip to "Verify the connection" below. If it errors, continue. - -### Step 2: Ensure the relay is running - -```bash -curl -sf http://127.0.0.1:8766/health -``` - -If not running (in a container, ALWAYS use `--supervise` so it survives your session): `adom-bridge serve --supervise` - -### Step 3: Ask what OS they use - -Ask the user: **"What operating system is your desktop/laptop? (Windows, Mac, or Linux)"** - -- **Windows 10/11** -- proceed -- **Linux** -- a private preview (AppImage, insiders tier, 2.1.175+). Installing it, keeping one copy running, updates, and what works there: the `adom-bridge-linux` skill. Inside a Grok Bot agent computer the machine is paused while its agent is idle; that skill says how to wake it. -- **macOS** -- "Support is coming soon." - -### Step 4: Install & connect - -Run `adom-bridge setup_desktop` — it auto-detects this container's relay URL and returns a ready-to-run **`register_command`**, a fallback `server_config` JSON, and the `installer_url`. Prefer the zero-paste path: - - -**Option 0: Zero-paste via your Adom account (PREFERRED)** - -Run the **`register_command`** that `setup_desktop` returned (it's `adom-bridge register_container` with the name + URL filled in). That injects this connection into the **user's Adom cloud profile**. Then the user just: -1. Installs Adom Bridge (from `installer_url`) and opens it, -2. **Signs in with their Adom account** (Connections panel → "Sign in to Adom"), - -and the connection appears in their list and auto-connects — **no JSON paste at all**. This is the modern path and the one to try first. (`register_container` needs an Adom cloud container — it uses the mounted account token — and returns `ok:false` on a non-Adom box; if so, use Option A/B below.) - -**Already-running desktop? `server_sync` (v1.9.215+).** A signed-in ab reads the cloud profile at boot, at sign-in, and hourly — and `adom-bridge server_sync` (optionally `--target <name>`) makes it re-read RIGHT NOW. The merge is cloud-wins, whole entry including `authToken`. So the flow for adding a server or **rotating a relay token** on live desktops is: update the cloud key (`register_container` writes them) → `server_sync` per desktop → verify with `server_list`. Do NOT use `server_remove` + `server_add` for rotation — removing the entry you are talking through severs your own transport (wiki #387). - - -**Option A: Automatic setup via Claude Code (paste fallback)** - -If you have **Claude Code** or **Claude Desktop** on your laptop, paste this prompt: - -> Install Adom Bridge and connect it to my cloud container. (Most users instead get Adom Bridge bundled with Hydrogen, which installs and connects it for them; use this prompt when you are driving the desktop from a cloud container.) Download the installer from `<installer_url>`, run it silently, then write this JSON to `%USERPROFILE%\.adom\config.json`: -> ```json -> {"servers":[<server_config JSON>]} -> ``` -> Then launch "Adom Bridge" from the Start Menu. - -*(Replace `<installer_url>` and `<server_config JSON>` with the actual values from `setup_desktop`.)* - -**Option B: Manual setup** - -1. Download the installer: **[<installer filename>](<installer_url>)** -2. Run it and follow the prompts — the app installs to the Start Menu as "Adom Bridge" -3. Open **Adom Bridge** from the Start Menu -4. Paste this JSON into the text field that says **"Paste server JSON to add ..."** and press **Enter**: - ```json - <server_config JSON> - ``` -5. The server will appear and auto-connect - - -Wait for the user to confirm they see a green dot next to the server name before proceeding. - -**Important:** Each container has its own relay server. New containers need a new entry -- old entries from previous containers won't work. - -### Step 6: Verify the connection - -```bash -adom-bridge ping -# Expected: { "echo": "pong", "roundTripMs": ..., "status": "connected" } - -adom-bridge status -# Expected: one client with the user's hostname and capabilities - -adom-bridge notify_user '{"title":"Hello from Docker!","body":"Your desktop is connected."}' -``` - -Tell the user what you see. If `ping` succeeds: - -> "Your desktop is connected! I can now send files to your machine, open browser windows for visual debugging, control KiCad and Fusion 360, take screenshots of your desktop, and send you notifications." - -### Step 7: Optional -- Node.js for browser features - -Ask: **"Would you like to use the visual browser debugging feature? This opens Chrome windows on your desktop that I can control."** - -- **Yes + Node.js installed** -- "Great, the browser bridge auto-installs deps on first use." -- **Yes + no Node.js** -- "Download Node.js from https://nodejs.org (LTS), install it, then restart Adom Bridge." -- **No** -- skip, they can enable it later - -### Common connection issues - -| Symptom | Fix | -|---------|-----| -| `ping` returns `"No desktop client connected"` | User hasn't added this container in the desktop app yet | -| Desktop app shows "disconnected" | Check the URL uses `wss://` (not `ws://` or `https://`), port is `8765` | -| Relay server not running | `adom-bridge serve --supervise` (survives your session; see Starting the Relay Server) | -| Multiple stale connections | `adom-bridge kick_all` -- app auto-reconnects within seconds | -| Desktop app not installed | Download from [github.com/adom-inc/adom-bridge/releases](https://github.com/adom-inc/adom-bridge/releases) | - -## CLI Tool - -```bash -adom-bridge <command> '<json-args>' -``` - -**Examples:** -```bash -adom-bridge ping -adom-bridge status -adom-bridge desktop_list_windows -adom-bridge desktop_screenshot_screen '{"reason":"see what is on the user's screen"}' -adom-bridge commands '{"bridge":"kicad"}' # a bridge's own verb list (any installed bridge) -adom-bridge notify_user '{"title":"Hello","body":"From Docker"}' -adom-bridge shell_execute '{"command":"echo hello"}' -adom-bridge pull_file '{"filePaths":["C:\\Users\\john\\Downloads\\image.png"],"saveTo":"/tmp"}' -``` - -**Output:** JSON to stdout. Screenshots save to `/tmp/adom-bridge-screenshots/` and return the file path. - -**Config location:** The desktop app stores server config at `~/.adom/config.json` (`%USERPROFILE%\.adom\config.json`). This is separate from the binary — config survives updates and reinstalls. - -## Available Commands - -**Get the full structured command list with descriptions, args, and prerequisites:** -```bash -adom-bridge commands # aliases: list_commands, help -adom-bridge --target <name> commands # + THAT box's installed bridges (`bridges`, live: name, version, verb names) -adom-bridge commands '{"bridge":"kicad"}' # ONE bridge described by ITSELF (<prefix>describe, else its manifest verb list) -``` -Returns categorized JSON with every ab-core command, its required/optional args, return values, prerequisites, and workflow notes, plus `bridges` read live from the box. Run this FIRST when you're unsure what a verb is called. Bridge verbs are never catalogued inside ab (2.1.144, wiki #211: the hand-written copy drifted); the bridge describes them, and its skill (`sync_skills`) is the how-to. - -**Two kinds of verbs (v1.9.103 — don't confuse them):** -- **BUILT-IN ab-core verbs** are always available on every Adom Bridge, no install, NOT bridges. This is where screenshots, windows, input, files, registry, shell, tunnel, update, and notify live. A whole-screen shot is `desktop_screenshot_screen`; one window (with chrome) is `desktop_screenshot_window {"hwnd":N}` or `{"titleContains":"..."}` (get hwnds from `desktop_list_windows`). -- **BRIDGE verbs** (`kicad_`/`fusion_`/`pup_`/`blender_`/`nbrowser_`…) need that bridge installed — see `bridges` in `commands`, or run `bridge_list`. - -If a verb returns `errorCode:"unknown_verb"`, **read the error's `_hint`** — it carries a fuzzy "did you mean" (e.g. `desktop_screenshot` → `desktop_screenshot_screen`) and tells you whether the right verb is built-in or a bridge. Don't conclude a capability is "a missing bridge" from a guessed verb name; call `commands` and check. - -### Connection Management - -- `ping` -- 5s round-trip test. Use BEFORE browser/shell commands to verify the desktop connection is alive. -- `status` -- Check who's connected, their capabilities, desktop paths, and **app installation status**. The `desktop.apps` object shows: - - `kicad.installed` / `kicad.version` / `kicad.bridgeRunning` - - `fusion360.installed` / `fusion360.running` / `fusion360.bridgeRunning` / `fusion360.addinInstalled` / `fusion360.addinConnected` - - `browser.bridgeRunning` -- `kick_all` -- Force-disconnect all WebSocket clients. Active Adom Bridge apps auto-reconnect within seconds. - -#### Programmatic server registration (v1.8.22+) - -Three verbs let external apps (Hydrogen's WSL2 workspace, in particular) register their relay server with adom-bridge without the user pasting JSON into the GUI by hand. Mirrors the GUI's Quick Add bar + connect/disconnect buttons. - -- `server_add` -- Upsert a relay server by `name`. If a server with that name already exists, the URL (and optionally authToken) is updated rather than creating a duplicate. - ```bash - adom-bridge server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}' - # → {ok:true, name, url, id, connected:bool, created:bool, _hint} - ``` - - `name` (required) — dedup key. Repeat calls with the same name are idempotent. - - `url` (required) — relay WebSocket URL. - - `authToken` (optional) — defaults to a dev token (leave unset unless your relay requires one). - - `autoConnect` (optional, default `true`) — connect right after upsert. Set `false` to add the entry without dialling out. - - Behavior: same URL + already connected → no churn, returns ok. URL changed + autoConnect=true → disconnect old loop + spawn new one. URL changed + autoConnect=false → disconnect old, leave entry registered but not connected. -- `server_remove` -- Disconnect (if connected) and delete an entry by name. Returns `{ok, removed:bool, wasConnected:bool}`. Idempotent (removing a non-existent entry returns ok with removed:false). - ```bash - adom-bridge server_remove '{"name":"hydrogen-workspace"}' - ``` -- `server_list` -- Persisted server list with live connection status: `{servers:[{name, url, id, autoConnect, enabled, connected:bool, clientCount:0|1, status}]}`. `status` is the fine-grained `connected | reconnecting | disconnected` state machine value. - -Persists to the same `~/.adom/config.json` the GUI uses, so entries survive a GUI restart. The `ws_client` supervisor (runs every 30s inside the GUI) auto-reconnects entries with `autoConnect:true` on next launch. - -Hydrogen-style usage: -```bash -# At Hydrogen container startup -adom-bridge server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}' - -# At Hydrogen container shutdown -adom-bridge server_remove '{"name":"hydrogen-workspace"}' -``` - -#### Addressing multiple Adom Bridges on one relay (v1.8.133+) - -One relay can have **several** Adom Bridges connected at once — e.g. a laptop and an Azure VM, eventually many. Each ab has a **name** (its `--client-name` / Settings value, defaulting to the OS hostname). The cloud CLI addresses a specific one, or fans out to all: - -- `adom-bridge targets` — list connected ABs: `{count, targets:[{name, hostname, version, os, connectedAt}]}`. Discover names here first. -- `adom-bridge --target <name> <verb>` — route the verb to that ab only. `--target` is a **global** flag (goes before the verb; case-insensitive; matches `clientName`, else `hostname`). `ADOM_TARGET=<name>` sets a default (the flag wins). -- `adom-bridge --target all <verb>` — fan out to **every** connected ab; returns `{broadcast:true, targets:[...], results:{<name>:<verb-result>}, summary:{total,ok,failed}}`. A per-ab timeout/failure is isolated to that ab's entry. - -```bash -adom-bridge targets -adom-bridge --target azurevm desktop_screenshot_screen -adom-bridge --target all hd_status # every box at once, results keyed by name -``` - -**No-target behavior:** with exactly ONE ab connected, no `--target` is needed (back-compat). With MULTIPLE connected and no `--target`, the call FAILS with `{error:"ambiguous_target", connected:[...], _hint}` (exit 1) — it won't guess which box. A bad name → `{error:"target_not_found", requested, connected:[...]}` (exit 1). Set each ab's name with the `--client-name <name>` launch flag (Hydrogen passes this when spawning the embedded ab), the Settings field, or `config.json` `client_name`. Full design + the relay-routing internals: [`skills/MULTI_AB_ADDRESSING.md`](MULTI_AB_ADDRESSING.md). - -#### Direct HTTP API for sibling apps (v1.8.25+) - -If you're authoring a sibling Tauri app on the same machine (Hydrogen, or any future "Adom-family" app), you can skip the CLI binary entirely and POST commands straight into the running adom-bridge GUI on `127.0.0.1:47200` (was `127.0.0.1:8770` through v1.8.32 — moved to be a better neighbor to Hydrogen on 47080+ and avoid the 8000-range collision risk). The endpoint runs inside the GUI process (loopback-only bind), uses the same dispatcher the WS path uses, and returns the same JSON shape as `adom-bridge <verb>` — including every `_hint` field. - -| Method | Path | Body | Returns | -|---|---|---|---| -| `GET` | `/health` | — | `{"ok":true,"service":"adom-bridge"}` cheap probe | -| `GET` | `/status` | — | service banner + version + schema + `directApi.cliRequired` list | -| `POST` | `/command` | `{"app":"<ns>","command":"<verb>","args":{...}}` | The verb's normal payload (200 OK), or `{error, errorCode, _hint}` (4xx/5xx) | - -**v1.8.33+ port discovery (sibling apps READ THIS):** Don't hardcode 47200. The GUI may have bound to 47200-47209 instead if the default port was taken (zombie socket, dev instance, third-party collision). Discovery protocol: - -1. Read `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`) — single-line `host:port`, written by the GUI at bind time, removed at graceful shutdown -2. If file missing or its port doesn't `/health`, scan `127.0.0.1:47200..=47209` for any port answering with `{"ok":true,"service":"adom-bridge"}` -3. Validate `service == "adom-bridge"` to disambiguate from other apps that might bind a port in our range - -The CLI does this automatically (see `cli/src/direct_probe.rs`). For sibling-app Rust code, see [`skills/DIRECT_API.md`](DIRECT_API.md) for a copy-paste-ready helper. - -**Per-verb timeouts:** a bridge verb's budget is the bridge's own `bridge.json` `timeouts` block (exact bare verb, then prefix rule, then its default), applied by ab and, since 2.1.146, by the CLI's wait as well (ab keeps no table of any bridge's verbs, wiki #212). `timeouts` shows every installed bridge's declared block. Caller can override via `args.timeout` (seconds, clamped to 1800). - -Example — programmatic server registration without the CLI: - -```bash -curl -X POST http://127.0.0.1:47200/command \ - -H 'Content-Type: application/json' \ - -d '{"app":"desktop","command":"server_add","args":{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}}' -# → identical JSON to `adom-bridge server_add '{...}'`, including the _hint -``` - -**What's safe to send directly:** essentially everything sync (`server_*`, `bridge_list`, `hd_status`, `hd_build_status`, `desktop_list_windows`, `desktop_screenshot_*`, `kicad_*`, `fusion_*`, `pup_screenshot`, `notify_user`, ...) plus async-dispatching verbs that return a job id in <500 ms (`bridge_install`, `hd_build`, `desktop_install_kicad`, ...). - -**What requires the CLI fallback:** verbs returning a structured `errorCode:"cli_required"` from this endpoint — currently `pull_file`, `send_files`, `shell_execute`. These use binary streaming or multi-minute approval polling that doesn't fit a single synchronous HTTP request. `GET /status` returns the full list at runtime so callers can branch defensively. - -Full integration guide (loopback trust model, recipes for Hydrogen startup/shutdown, retry/backoff patterns, port-discovery code in Rust + TypeScript) lives in [`skills/DIRECT_API.md`](DIRECT_API.md) in this repo, and as a wiki asset attached to `apps/adom-bridge`. - -#### Embedded mode — ab bundled inside Hydrogen (v1.8.42+) - -When Hydrogen bundles ab, ab enters "embedded mode" at boot. Hydrogen owns the system-tray icon (ab's is suppressed), Hydrogen owns the Adom Cloud login (Hydrogen's session token is handed to ab via Phase 4's existing channels), Hydrogen owns auto-updates (ab's wiki-poll is disabled — Hydrogen bundles a fresh ab on every Hydrogen release), and Hydrogen spawns ab hidden (its tray "Open Adom Bridge" surfaces the window on demand). Standalone ab users see zero change — none of the signals fire. - -**Detection** — three-signal cascade (any one triggers embedded mode): - -1. `--embedded` CLI flag (what Hydrogen passes on every spawn) -2. `ADOM_EMBEDDED=1` env var (backup channel) -3. `%LOCALAPPDATA%\Adom Bridge\embedded.json` marker file (survives ab restarts; written by Hydrogen's installer; deleted by Hydrogen's uninstaller) - -**CLI flags Hydrogen passes when spawning ab**: - -```bash -adom-bridge.exe --embedded --start-hidden \ - --relay-url ws://127.0.0.1:8765 --relay-name hydrogen-desktop \ - --session-token <hd's-stored-token> -``` - -- `--embedded` → enter embedded mode (also writes the marker) -- `--start-hidden` → boot with main window invisible. **v1.8.119+: honored in embedded mode** (Hydrogen owns the surface; ab stays in the tray and opens via `desktop_window_show`). v1.8.102–118 had a bug where embedded mode force-showed the window even with `--start-hidden`; that's fixed. The machine owner can still flip ab's "Always show the window at launch" Settings checkbox (config `always_show_window_on_launch`, default OFF) to override and always show — a personal-dev-box preference, not the product default. -- `--shell-approval <prompt|1h|24h|permanent>` → Hydrogen's passthrough of its user's shell-approval choice (v1.8.116+, session-only) -- `--relay-url <url>` → upsert this relay via existing `server_add` dedup; default name `hydrogen-desktop` -- `--relay-name <name>` → override the default name -- `--session-token <tok>` → already covered by Phase 4 handoff (env / CLI arg / file) - -**Docker introspection** — `adom-bridge desktop_embedded_status` returns `{embedded, owner, source, enteredVia, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` so cloud-side callers can branch on whether ab is standalone or Hydrogen-managed. **v1.8.73+:** `enteredVia` reports `"launch-flag"` (Hydrogen spawned ab as a child via `--embedded`) or `"runtime-adopt"` (ab was running standalone; Hydrogen adopted it at runtime) — see "Runtime adopt + release" below. - -**New verbs Hydrogen uses to drive ab** (all also usable standalone): - -| Verb | Purpose | -|---|---| -| `desktop_window_show` | Bring ab's main window to foreground | -| `desktop_window_hide` | Hide ab's main window (app keeps running) | -| `desktop_connect_all` | Spawn ws_loop for every enabled server | -| `desktop_disconnect_all` | Drop every live WS connection (entries stay in config) | -| `desktop_shutdown` | Explicit ab termination (stop bridges, then exit) | -| `desktop_embedded_status` | Introspect current embedded state (incl. `enteredVia`) | -| `desktop_embedded_enter` | **v1.8.73+** runtime adopt: standalone → embedded as `runtime-adopt` | -| `desktop_embedded_release` | **v1.8.73+** revert to standalone. **v1.8.77+: always reverts, never exits ab** (regardless of `entered_via`) — use `desktop_shutdown` for explicit exit | -| `desktop_logout` | Clear ab's `~/.adom/session.json` (Hydrogen's sign-out propagation) | - -#### Runtime adopt + release (v1.8.73+) — the LaunchFlag-vs-RuntimeAdopt distinction - -Hydrogen has two ways to put ab into embedded mode: - -1. **Spawn ab with `--embedded`** (LaunchFlag) — Hydrogen launches a fresh ab process as its child. ab enters embedded with `entered_via=launch-flag`. When Hydrogen subsequently dies ungracefully (without calling `/embedded/release` or `desktop_shutdown` first), ab's 60s `embedded_monitor` tick notices Hydrogen's control port is dead and **exits ab** (the "no hanging chad" rule — no tray-less orphan). -2. **Adopt at runtime via `POST /embedded/enter`** (RuntimeAdopt) — ab was already running standalone; Hydrogen calls the direct-API endpoint to flip ab into embedded mode without a respawn. ab enters embedded with `entered_via=runtime-adopt`, preserving its open state, current PID, all in-flight connections. When Hydrogen subsequently dies ungracefully, ab's monitor **reverts ab to standalone** — tray restored, footer pill hidden, marker deleted. ab process stays alive. - -The rule is simple: **whoever started ab owns ab's death**. If ab started itself (standalone → runtime adopt), Hydrogen's death doesn't take ab down with it. - -**Two new direct-API endpoints** (v1.8.73+, sugar over `POST /command`): - -```bash -# Adopt — runs the same handler as desktop_embedded_enter: -curl -s -X POST http://127.0.0.1:47200/embedded/enter - -# Release — runs the same handler as desktop_embedded_release. -# v1.8.77+: ALWAYS reverts to standalone. ab never exits via this call. -curl -s -X POST http://127.0.0.1:47200/embedded/release -``` - -**Probe target (v1.8.75+):** the 60s `embedded_monitor` checks Hydrogen's *control port* (default `47084`, discovered from `%APPDATA%\hydrogen-desktop\ports.json` key `control`), not Hydrogen's legacy `:9001` webview-eval port. Same discovery `hd_api` uses. Pre-v1.8.75 this was hardcoded to `:9001` — Hydrogen doesn't bind that port anymore, so the probe always reported Hydrogen dead and every embedded ab reverted/exited within 60s of Hydrogen starting. - -**Live frontend update (v1.8.76+):** the "Embedded · Hydrogen" footer pill responds to Tauri `mode-changed` events emitted on every transition. So if ab's window is open when Hydrogen adopts it (or Hydrogen releases it), the pill flips immediately — no page reload, no manual refresh. - -**Stale-marker safety (v1.8.74+):** if the marker file says `entered_via=launch-flag` AND Hydrogen's control port isn't responding at boot, ab deletes the marker BEFORE exiting. Without the delete, every subsequent manual launch (no `--embedded` flag) would hit the same exit branch and ab would be unlaunchable until the user manually rm'd the marker. The delete-before-exit makes the next launch start standalone normally. - -**Shell auto-approve is a user preference** (v1.8.116+ — replaces the v1.8.44 embedded-permanent default). The safe default everywhere is per-command approval. Resolution order at ab boot: - -1. `--shell-approval <prompt|1h|24h|permanent>` launch flag — Hydrogen's passthrough of its user's choice when spawning ab embedded. **Session-only**: never persisted by ab. -2. The durable Settings checkbox ("Auto-approve shell commands permanently", config field `shell_auto_approve_permanent`, default OFF). Also set/cleared via `shell_auto_approve '{"permanent": true|false}'`. -3. A restored timed grant from a previous session (+1h/+24h buttons, `duration_secs` calls). -4. Otherwise: prompt per command. - -In embedded mode **Hydrogen owns the preference** (Hydrogen is the parent, ab the child): ab's Settings checkbox goes read-only with "Managed by Hydrogen", and the footer shows "**Permanent** *(managed by Hydrogen)*" when armed. Hydrogen changes it at runtime with the same verb: - -```bash -# revoke everything (timed + permanent + the saved checkbox): -adom-bridge shell_auto_approve '{"duration_secs": 0}' - -# set the durable preference (equivalent to ticking the Settings checkbox): -adom-bridge shell_auto_approve '{"permanent": true}' -``` - -Migration note: pre-1.8.116 embedded boots persisted a permanent grant that leaked into standalone sessions. On first v1.8.116 boot, a permanent-class grant on disk WITHOUT the config preference is cleared — re-opt-in via the checkbox or the verb if you actually wanted it. - -**⚠ v1.9.90 — a cloud/relay AI CANNOT grant shell approval itself.** The `shell_auto_approve` examples above are Hydrogen's (a trusted LOCAL caller) or the human's own. From a **relay** connection (the cloud AI), a GRANT (`shell_auto_approve {"permanent":true}` / `{"duration_secs":N>0}`) is REFUSED with `errorCode:"human_only"` and instead POPS the human approval prompt in Adom Bridge. **The AI's path: call `request_shell_approval` (or just let the gate/refusal auto-surface the prompt) → ask the user to click Approve 1 hr / 24 hr / Bypass Permissions → RETRY your original verb.** Only a human clicking in ab (or Hydrogen proxying its own user) grants. A REVOKE (`duration_secs:0`) is allowed for the AI. - -#### Bridge ports are dynamic — you never need to know one (v1.8.31+) - -KiCad, Fusion, Browser/Puppeteer, and all third-party bridges now bind OS-assigned ephemeral ports (not the legacy 8772/8773/8851 you may remember). The runtime port changes every spawn. **Callers never need to know it.** - -- The CLI verb namespace (`kicad_*`, `fusion_*`, `pup_*`, plus any third-party `<bridge>_*`) is the contract. Always go through that. -- adom-bridge's direct API forwards to whatever port each bridge is on at the moment of the call. -- `bridge_list` reports `spawn.runtimePort` per bridge for debugging — do NOT hardcode it anywhere. -- Why this changed: Hydrogen's bridges also wanted 8772/8773/8851; we used to collide silently. Dynamic ports = clean coexistence. - -### Managing bridges (the AI fully controls bridges on its own) - -Bridges are ab's plugins (kicad, puppeteer, fusion360 ship bundled; others install from the wiki). You — the AI — can see, update, install, remove, pause, and resume them entirely through verbs. No human step is required. - -| Verb | What it does | -|---|---| -| `bridge_list` | Every installed bridge: `name`, `displayName`, `version` (what ab runs now), `source` (bundled / cache / third_party), `paused`, spawn kind, verbPrefixes. Start here. | -| `bridge_check_updates` | **READ-ONLY.** Per bridge: `current` vs `latest` (wiki) + `updateAvailable`. Downloads nothing. The "what's behind?" probe. | -| `refresh_bridges` | Apply updates. No args = sync ALL bridges from the wiki. `{"name":"kicad"}` = update just one. | -| `bridge_install` | Install a new third-party bridge from a wiki manifest URL (`{"manifestUrl":"…/<bridge>-bridge-manifest.json"}`). On `success:true` the **capsule appears in the ab GUI immediately** (no verb call needed; it's gray/idle until first use, then green). On `success:false` **nothing is registered and no capsule appears** — read `error` (a `read zip body`/`builder error` means the manifest's zip URL couldn't be fetched). Don't treat a parsed manifest as a completed install; confirm with `bridge_list`. The manifest `url` may be relative (resolved against the manifest's dir, v1.8.169+). | -| `bridge_uninstall` | Remove a bridge (`{"name":"…"}`). Bundled ones revert to the bundled copy; third-party ones are fully removed. | -| `bridge_pause` / `bridge_resume` | Flip a routing flag so a bridge's verbs fail-fast with `bridge_paused` (or resume). Does NOT kill the process. | -| `bridge_dev_mode` | **The DEV PIN** (`{"name":"kicad","on":true}`): freeze that ONE bridge against ALL automatic updates (the 4h sweep, on-launch sync, on-use auto-update, refresh_bridges) so a developer can modify its installed cache without ab pulling the release over their work. `on:false` releases it. `bridge_list` shows `devPinned:true` and the ab GUI card shows an amber "dev pin" badge with a one-click Unpin. **Etiquette: if a bridge shows `devPinned:true`, a human is mid-development on it - never unpin or force-update it to "get the latest" without asking them.** If YOU are about to edit a bridge's installed files, pin it FIRST. | - -**Naming convention for published bridges (3rd-party authors, read this):** when you publish a bridge to the Adom wiki, its **page title / catalog descriptive name MUST start with `Adom Bridge - `** (spaced hyphen) — e.g. `Adom Bridge - Altium Bridge`, matching the bundled bridges (`Adom Bridge - Puppeteer Bridge`, `Adom Bridge - KiCad Bridge`). This keeps every ab bridge grouped with consistent names in app-index pages instead of scattering as bare slugs. It applies to the **wiki/catalog title only** — NOT `bridge.json` `displayName` (the in-app chip label, which stays tool-descriptive like `Altium Designer`) or `name` (the lowercase slug). Full authoring walkthrough: the `adom-bridge-bridge-sdk` skill (the `adom-bridge-sdk` wiki page) + its fill-in-the-blanks templates. - -**Typical "am I on the latest bridges?" flow:** -```bash -adom-bridge bridge_check_updates # read-only: see current vs wiki per bridge -# → {bridges:[{name,current,latest,updateAvailable,source,wikiReachable}], updatesAvailable:N} -adom-bridge refresh_bridges # update ALL that are behind -adom-bridge refresh_bridges '{"name":"kicad"}' # or just one -``` - -**Version semantics (so the numbers make sense):** -- `current` = what ab actually runs = the newer of the writable cache and the bundled copy (version-aware, numeric semver — `1.0.10 > 1.0.9`). -- A bundled bridge that ships AHEAD of the wiki shows `updateAvailable:false` — ab correctly keeps the newer bundled copy. **puppeteer is bundled-only** (its node_modules deps exceed the wiki file limit), so it never shows a wiki update; it updates with the ab installer. -- After an `updated` action, the bridge SERVER PROCESS still holds the old code in memory until it respawns. `is_bridge_running` is version-aware (v1.8.142+) and forces a respawn on the next verb call, so you normally don't need to kill anything — but if you want it instant: `taskkill /f /im python.exe` (kicad/fusion) or `node.exe` (puppeteer). - -**Auto-update cadence:** a running ab also auto-syncs bridges on launch + every 4 hours. `bridge_check_updates` / `refresh_bridges` (or the GUI's **"Check for updates"** button next to "+ Bridge") are the on-demand "I just published, pick it up now" path. - -#### Bridge lifecycle — one instance, stable port (v1.8.153+) - -A dynamic bridge gets **one stable port** that ab reuses across every spawn and across ab restarts (persisted in `~/.adom/bridge-ports.json`). A long-lived external client (e.g. a browser extension that dials the bridge) keeps reaching the same port — no chasing a fresh ephemeral port each cycle, no "the client is on bridge A but ab routes to bridge B" split. ab enforces a **single instance**: it tracks every spawned bridge PID, reaps a stale/wedged one before respawning on the same port, and `bridge_stop_all` reaps orphans too. - -`spawn.persistent` (in `bridge.json`) is now surfaced in `bridge_list` (`spawn.persistent`) and means: **ab auto-respawns this bridge on death**, on its stable port (a supervisor runs each ~15 s). So "kill the bridge to load new code" → ab brings it right back at the same port the client keeps dialing. `bridge_install` / `refresh_bridges` also no longer fail with the cache-dir lock (ab reaps the process holding the dir as its cwd, then swaps; in-place merge fallback if a handle lingers). - -**v1.8.186/187 — first-class bridges (panel + remote management + optional manifest fields).** The ab window has a full-height **Bridges sidebar** that shows each bridge as a mini-app (hero image, LED, rich tooltip); clicking one opens a wide pop-out with a **live console** (its `~/.adom/bridge-logs/<name>.log` + the per-bridge command history), a **ports/process debug view** (live PIDs — `>1` flags a runaway), persistent-vs-one-off + codebase-language badges, and all management actions (install/uninstall/**kill**/restart/pause/resume + "filter activity log to this bridge" + "copy contribution prompt"). The same management is available **remotely** to cloud/WSL2 threads via verbs: `bridge_info`/`bridge_detail` (rich aggregate), `bridge_log_read {name, sinceOffset?}` (**cursor-streaming remote tail -f**), `bridge_kill` (wipe one bridge's processes). As of v1.8.186 **all** bridges write their per-bridge log (bundled kicad/fusion360/puppeteer included, not just dynamic ones). New **optional** `bridge.json` fields (all back-compat, surfaced in the panel): `hero` (a relative filename on your wiki page, or a full URL — your app-icon; absent → a monogram tile), `languages` (override the auto-scan, e.g. `["Python"]`), `releasedAt` (ISO date; absent → bridge.json mtime). - -**v1.8.156 — strays + self-documenting hints.** ab now tracks every bridge PID it spawns (persisted across ab restarts) and **reaps all stray instances** before respawning — so you don't end up with 4 copies of one bridge fighting over the routing. `bridge_list` surfaces `spawn.instanceCount` per dynamic bridge (normally 0 or 1; **>1 = strays piled up** — visible at a glance). If a bridge verb fails to reach a healthy bridge, the error `_hint` tells you to retry / check `instanceCount` / `process_list`; and a verb response right after a (re)spawn carries an `_ad_note` saying the external host reconnects within a few seconds — **retry** instead of treating a transient "not connected" as terminal. For verb discovery from a bridge, `GET <ADOM_DIRECT_API_URL>/commands` lists every reachable verb (see [`skills/DIRECT_API.md`](DIRECT_API.md)). - -#### Bridge status chip — self-reported LED + tooltip (v1.8.152+) - -Each bridge has a status dot in the ab GUI sidebar. **ab is a pure renderer of bridge health** — a live bridge decides its own LED color and hover text; ab just paints what the bridge reports. This replaced the old scheme where ab inferred the LED from whether it was holding a spawn handle, which left fire-and-forget (dynamic/installed) bridges stuck RED even while healthy and routing. - -**The contract (all three fields OPTIONAL).** On the endpoint ab already polls — your `bridge.json` `healthEndpoint` (default `/health`; the sample bridges use `/status`) — return a 2xx JSON body that MAY include: - -| Field | Type | Meaning | -|---|---|---| -| `led` | `"green"` \| `"yellow"` \| `"red"` | The dot color. Green = healthy; yellow = degraded/warning; red = the bridge itself knows it's broken (e.g. lost its upstream). | -| `summary` | string | Short label shown next to the dot. Keep it tiny (≤ ~24 chars) — e.g. `"2 windows"`, `"ext disconnected"`. | -| `tooltip` | string | Full hover text. Newlines allowed; no hard length limit (keep it readable). | - -**Rules ab enforces:** -- Endpoint returns 2xx **with** `led`/`summary`/`tooltip` → rendered **verbatim**, no parsing or logic. -- Endpoint returns 2xx **without** them → ab defaults to **green** (you're reachable) + a generic tooltip. Existing bridges work unchanged — this is opt-in. -- Endpoint **unreachable** → ab owns this ONE state: a **gray/offline** dot + ab's own tooltip ("Not running" / "Bridge not reachable"). A dead bridge can't self-report, so ab never lets a bridge claim "offline" — it detects it. - -**Cadence:** ab re-polls every ~15 s, and a dynamic bridge flips green the instant it spawns. So a bridge that surfaces live counters (open windows, queue depth, ext-connection state) in `summary`/`tooltip` updates in the GUI within a tick. - -Reference implementations: [`scripts/sample-bridges/hello-python/server.py`](../scripts/sample-bridges/hello-python/server.py) and [`hello-rust/src/main.rs`](../scripts/sample-bridges/hello-rust/src/main.rs) both self-report `led`/`summary`/`tooltip` on `/status` — fork either. - -### File Transfer - -- `send_files` -- Send files from the Docker container to the desktop. Files are base64-encoded in transit. - - `filePaths`: array of absolute paths on the server - - `targetApp`: "kicad", "fusion360", or "general" - - `dest`: **absolute** Windows path (under a safe root) to land the files exactly where you want (v1.8.148+). Prefer this when you know the target dir. A folder is created if missing. For ONE file, `dest` may be the full file path instead. A `dest` ending in a slash or in `.pretty` / `.3dshapes` / `.app` / `.bundle` / `.framework` is always a folder (2.1.163+), so a KiCad footprint library works directly: `dest: "C:/Users/<you>/Documents/proj/lib/mylib.pretty"`. - - `destIsFolder` (2.1.163+): `true` forces `dest` to be a folder for any other dotted folder name; `false` forces it to be the file path. - - `destinationFolder`: **relative subfolder only** (e.g. `"kicad/symbols"`, `"fusion"`). The desktop app controls the base directory. **Absolute paths are rejected.** With neither `dest` nor `destinationFolder`, files land in the laptop's **Downloads** folder. - - Returns `destinationPaths[]` with the absolute path of every saved file, plus (v1.8.168) `savedToDir` (the folder) and a rich `_hint`. **The files land on the LAPTOP (Windows host), not in your container** — verify with `list_dir`, pull one back with `pull_file`. - - **`send_files` only COPIES bytes — it does NOT install or register a bridge.** Dropping bridge files with `send_files` will NOT make a capsule appear or verbs route. To add a bridge use `bridge_install '{"manifestUrl":"…/<bridge>-bridge-manifest.json"}'`, then confirm with `bridge_list`. - -- `pull_file` -- Pull files from the desktop to the container. - - `filePaths`: array of absolute Windows paths on the desktop - - `saveTo`: directory on the container to save files (default: `/tmp`) - - **Streaming since v1.4.3.** Each file is transferred as 1 MiB binary WS frames straight to disk on the Docker side, with incremental SHA256 verification. The legacy 30s base64-JSON path is gone — large files (50 MB+ datasheets, 75 MB reference manuals) no longer time out. Per-file timeout is 600s. - - Returns `files: [{name, path, size, sha256, chunks}]`. Use `sha256` to verify the transfer (the desktop side computes it during streaming and the container side verifies on completion; mismatch deletes the partial file and reports failure). `chunks` is the count of 1 MiB binary frames received. - - When at least one but not all files succeed: `success` is `true`, `errors[]` lists the failures alongside `files[]`. When ALL fail: `success` is `false`. - -### Desktop Notifications - -- `notify_user` -- Send a native Windows toast (own AUMID + Adom logo). v1.9.33: rich types — progress bar, sticky reminder, action buttons that **bring ab to the foreground** on click + round-trip via `notify_response`. - - `title`, `body`, `level` (info/success/warning/error/emergency), `actions` (label buttons) — plus `scenario` ("reminder"=sticky), `durationLong`, `buttons` (`[{label, arguments?}]`), `progress` (`{title, value 0..1, status, valueOverride}`), `inputs`, `id` (correlation id for the round-trip). - - Full guide + copy-paste examples per type: the **adom-bridge-notify** skill ([`skills/NOTIFY.md`](NOTIFY.md)). -- `notify_response` -- Poll which toast button the user clicked, by correlation `id`. `{pending:true}` until they click; `{pending:false, action}` once they do (consumed on read). - -### KiCad (`kicad_*`) — bridge-owned; light pointer - -The KiCad bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/kicad-bridge`). This page keeps only the pointer: run `adom-bridge sync_skills` after connecting and the full KiCad guide (project workflows, three-tier `kicad_open_*` routing, DRC recipes, library install) lands in `~/.claude/skills/`. - -Its verbs: `adom-bridge commands '{"bridge":"kicad"}'` (the bridge describes itself). - -If KiCad is not installed, the bridge knows how to install it for the user — offer, never point them at a download page. Full details: the bridge's own skill. - -### Fusion 360 (`fusion_*`) — bridge-owned; light pointer - -The Fusion 360 bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/fusion-bridge`). Run `adom-bridge sync_skills` and the full guide (cloud files, import/export, add-in troubleshooting, APS search) lands in `~/.claude/skills/`. - -Its verbs: `adom-bridge commands '{"bridge":"fusion360"}'`. - -Respect `timeoutSeconds` + `statusVerb` on responses instead of re-issuing a slow verb. Full details: the bridge's own skill. - -### Launching apps (generic) - -Two generic CLI commands exist for launching any Windows executable safely — they verify the target exists BEFORE handing it to the OS, so you never trigger a "Windows cannot find" GUI dialog: - -- `adom-bridge find_exe '{"name":"..."}'` -- Resolve an exe by absolute path, glob (e.g. `C:/.../webdeploy/production/*/Fusion360.exe` returns newest), bare name (searches PATH + Start Menu `.lnk` targets). Returns `{path, source}`. Does NOT launch. -- `adom-bridge launch '{"path":"...", "args":[...], "cwd":"...", "detached":true, "reason":"..."}'` -- Same resolution rules as `find_exe`, then spawns. Fails in terminal (exit 1) with a clear error if the path doesn't exist. **Always prefer this over raw `start`.** - -**Where they run (2.1.143, wiki #210).** With `--target <box>`, or from a container / Linux CLI, both verbs run ON THAT BOX through its ab (mesh first, relay fallback, the same path as `shell_execute`): the path is resolved against the box's filesystem and the app starts there; the reply says `on: "<box>"`. Before 2.1.143 both ran inside the CLI process, so `--target arav-rog launch {path}` answered `find_exe: could not locate` for a file that existed on arav-rog. Only a Windows CLI with no `--target` still resolves and spawns locally. On a box `launch` is a Tier-2 exec verb like `launch_process` (gated the same way, `reason` required); `find_exe` is read-only. A target ab older than 2.1.143 answers unknown verb: use `launch_process {path, args}` there. - -**A bridge's verbs are documented by the bridge, not here (2.1.146, wiki #212).** `commands` lists the bridges the box has; `commands {"bridge":"<name>"}` is the bridge's own description; `sync_skills` installs its skill. Nothing below is about any one bridge. - -### Desktop Tools - -- `desktop_close_window` -- Gracefully close a window (WM_CLOSE, so save prompts fire). Resolves `hwnd` or `titleContains`; optional `forceAfterMs` kills the owning process only if the window outlives the grace period. Returns MEASURED `closed`/`forced`. Prefer this over process_kill for anything with unsaved state. -- `desktop_wait_for_window` -- Block (bounded timeout) until a window matching `titleContains`/`className`/`pid` appears, or with `state:"absent"` until it goes away (a dialog dismissed, an app exited). The honest replacement for sleep-and-rescreenshot loops: `adom-bridge desktop_wait_for_window '{"titleContains":"Save As","timeoutMs":10000}'`. -- `desktop_open_folder` -- Open a file or folder in Windows Explorer. If given a file path, Explorer opens with the file highlighted (selected). Args: `path` (file or folder path). -- `desktop_open_url` -- Open a URL in the user's **NATIVE OS BROWSER** — i.e. the real Edge / Chrome / Firefox / Brave they use every day, with their saved logins, history, bookmarks, and extensions. This is NOT pup, NOT Chrome for Testing — it's the browser the human actually uses. Hand it off when the user needs to interact with a logged-in account themselves. - - Args: `url` (required string), `browser` (optional: `"default"` (Windows-registered default — could be Edge, Chrome, Firefox, Brave; whatever they set), `"chrome"`, `"edge"`, `"firefox"`, `"brave"`). - - Examples: - - `adom-bridge desktop_open_url '{"url":"https://claude.ai/"}'` -- opens in user's default native browser, already signed in - - `adom-bridge desktop_open_url '{"url":"https://docs.example.com","browser":"edge"}'` -- force native Edge - - Returns `{ok, browser, url, exePath?}`. Allowed schemes: http, https, mailto, ftp, ftps. Other schemes refused. - -#### `desktop_open_url` vs `pup_open_window` — two completely different browsers - -| | `desktop_open_url` | `pup_open_window` | -|---|---|---| -| **What browser** | Native OS browser the user uses daily — Microsoft Edge / Google Chrome / Mozilla Firefox / Brave (whichever they've set as Windows default, or the one you name explicitly) | Puppeteer-controlled **Chrome for Testing** — a separate Chromium build that pup launches | -| **Profile / data** | The user's real profile: saved logins, history, bookmarks, extensions, autofill | Isolated profile under `plugins/puppeteer/profiles/<sessionId>/` — empty, no saved logins | -| **Who interacts with the page** | The HUMAN. Claude hands the URL off and is done. | CLAUDE drives it programmatically — screenshots, clicks, eval, recording. The human just watches. | -| **Use when** | A login is required (claude.ai, GitHub, internal tools, banking) — the human's existing session must be reused | Automation, scraping, screenshot capture, video recording, headful UI testing | -| **Can Claude control it after launch?** | No — once handed off, Claude can't see or drive it | Yes — every `pup_*` verb (screenshot, eval, navigate, record, etc.) | - -**Decision rule:** human-in-the-loop login flow → `desktop_open_url`. AI-driven automation → `pup_open_window` (defaults to Chrome for Testing). AI-driven automation that **must** be the user's real, signed-in profile (GCP Console with org/projects, internal SSO behind a managed-Chrome policy, anything Context-Aware Access blocks for non-enrolled browsers) → `pup_open_window` with the new `nativeBrowser` + `nativeProfile` args (v1.0.5+, see below). - -#### ⚠️ Common footgun — `localhost` / `file://` URLs from Docker AIs - -If you're running this CLI from a remote container, a `url` you pass to ANY verb (a bridge's window, `desktop_open_url`) is loaded on the DESKTOP: `localhost` there is the desktop, not your container, and `file://` names a file on the desktop's disk. - -**v1.7.10+ detects this.** When you pass a URL whose host is `localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, or scheme is `file://`, the response includes a `_hint` field explaining the issue and suggesting the public-slug URL pattern (`https://<user>-<repo>-<suffix>.adom.cloud/proxy/<port>/<path>`). **The call still proceeds** — rare legitimate cases exist where the user actually does have something running on their localhost. - -**Recipe — load something from inside your container into pup:** - -```bash -# 1. Start your service inside the container (e.g. a local HTML viewer): -python3 -m http.server 8786 --bind 0.0.0.0 & - -# 2. Ask your USER for the container's adom.cloud slug suffix -# (look at their browser URL when they open hydrogen or claude.ai/code). -# Slug looks like: <username>-<container>-<random-id> - -# 3. Open the public URL — NOT localhost — in whichever browser verb you use: -adom-bridge desktop_open_url '{"url":"https://<your-container-slug>.adom.cloud/proxy/8786/foo.html"}' -``` - -If you don't have the suffix handy, just ask the user. Or if the file you wanted to show pup is one your container already wrote to disk, push it to a public location (wiki asset, S3) and load from there. - - -- `desktop_bring_to_front` -- Bring a window to the foreground by HWND or title substring. Uses keybd_event + AttachThreadInput to bypass Windows foreground lock. Preserves maximized state. - - Args: `hwnd` (int) OR `titleContains` (string, case-insensitive). One required. - - Example: `adom-bridge desktop_bring_to_front '{"titleContains": "Fusion"}'` -- `desktop_set_window_state` -- Change a window's show state without bringing it to the foreground (or both, if combined with `desktop_bring_to_front`). Args: `hwnd` (int) OR `titleContains` (string) OR `cacheKey`, plus `state` (one of `"maximize"`, `"minimize"`, `"restore"`, `"show"`, `"hide"`, `"bottom"`). Use to ensure Fusion or KiCad windows are maximized for screenshots, or to hide noisy background windows during demos. - - Example: `adom-bridge desktop_set_window_state '{"hwnd":133560,"state":"maximize"}'` - - **`state:"bottom"` (v1.9.114) -- z-order send-to-back, background.** Drops the window BEHIND all others without focusing, moving, or resizing it (`SetWindowPos HWND_BOTTOM` + `SWP_NOMOVE|SWP_NOSIZE|SWP_NOACTIVATE`); works even on an off-screen window. It's an ATOMIC check-and-demote: it reads `GetForegroundWindow()` first, and if the target IS the OS foreground (the user is interacting with it) it SKIPS the demotion and returns `wasForeground:true, sentToBack:false` so the caller backs off with no race; otherwise `wasForeground:false, sentToBack:true`. Use to park a background window behind the user's windows (still on-screen, taskbar-clickable, rendering for screenshots) without stealing focus. - - **`force:true` (v1.9.115)** sends it to back EVEN IF the window is currently the OS foreground (still no activation change; `sentToBack:true` always, `wasForeground` still reported). Use at window-LAUNCH time: Windows reassigns the foreground to a brand-new window when the previous foreground is destroyed, so "foreground at launch" is not a real user signal. - - Example: `adom-bridge desktop_set_window_state '{"titleContains":"(session: abc","state":"bottom","force":true}'` - -#### Real OS input — mouse + keyboard (v1.8.170+) - -`desktop_click` / `desktop_double_click` / `desktop_right_click` / `desktop_hover` / `desktop_type` / `desktop_press_key` synthesize **real OS input via `SendInput`** — so they land on **`chrome://` pages and native dialogs that browser CDP (`nbrowser_*` / `pup_*`) cannot reach**. The verb names mirror the browser extension's `nbrowser_*` so the model transfers between in-page and whole-desktop driving. - -- **You must say WHY (v2.0.84).** Every verb that takes over the screen - the four mouse verbs, `desktop_type`, `desktop_press_key`/`send_keys`, `desktop_bring_to_front`, `desktop_raise_window` - requires a **`reason`** from a relay caller, or it is refused with `errorCode:"reason_required"`. The reason is not for the log: ab renders **"`<your thread>`: `<your reason>`"** on the user's screen next to the glowing cursor, at the moment their machine moves. Write it for the human watching, not for a log line - `reason:"clicking Next on the Altium installer"`, not `reason:"sending a click"`. -- **Gated behind shell-auto-approve** (same risk class as `shell_execute` — keystrokes/clicks hit whatever is focused on the user's live machine). If it's off you get `errorCode:"input_not_approved"`. **You (a cloud/relay AI) can't grant it yourself (v1.9.90)** — call `request_shell_approval` (or let the refusal auto-surface the prompt) and ask the user to click Approve / Bypass Permissions in Adom Bridge, then retry. Every action is written to the activity log. -- **Focus a window first** by passing `{window:"<title substring>"}` (alias `titleContains`) or `{hwnd:<int>}` — reuses `desktop_find_window`'s resolver, so input lands on the intended window, not whatever happened to be focused. Get the hwnd from `desktop_list_windows` / `desktop_find_window`. -- **Coords are physical screen pixels** — the same space as `desktop_list_windows` rects. - -**The four verbs that MOVE THE MOUSE each answer to a `mouse_*` name too (v2.0.84).** They are the same verbs, not new ones - the pointer verbs were named after what they do to a control (`click`, `hover`), so searching a verb list for "mouse" found nothing that moves the mouse. Use whichever name you reach for: - -| moves the mouse | alias | -|---|---| -| `desktop_hover` (also `desktop_move`) | `desktop_mouse_move` | -| `desktop_click` | `desktop_mouse_click` | -| `desktop_double_click` | `desktop_mouse_double_click` | -| `desktop_right_click` | `desktop_mouse_right_click` | -| `desktop_mouse_drag` (2.1.143) | `desktop_drag` | - -**Drag (2.1.143, wiki #205).** `desktop_mouse_drag {from:{x,y}, to:{x,y}, steps?:12, durationMs?:300, button?:"left", space?:"screen"|"image"|"window", hwnd?|titleContains?, reason}` presses at `from`, moves the real cursor through `steps` interpolated points over `durationMs`, releases at `to`. It is for canvases with no view controls that only react to a drag: orbit a 3D preview (KiCad's Footprint Chooser pane), a splitter, a slider, a lasso. A canvas that tracks motion ignores a teleport, so the pointer really travels; slow canvases want `durationMs` 600+. With `hwnd`/`titleContains` ab raises that window, verifies it is under `from` before pressing (else `target_not_under_cursor`, nothing pressed), and gives the prior foreground back afterwards (`restoreForeground:false` keeps it up). `space:"image"` + `shotId` takes screenshot pixels; `space:"window"` takes window px or `{fx,fy}` fractions of the window rect. The reply lists the `points` actually sent. Same gate and same UIPI limit as `desktop_click`. - -An alias is gated exactly like the verb it aliases - it is not a way around the reason requirement below. - -Verbs: -- `desktop_click {x, y, button?:"left"|"right"|"middle", double?, window?|hwnd?}` — left-click by default. `desktop_double_click` / `desktop_right_click` are wrappers; `desktop_hover` moves the cursor without clicking (reveals tooltips). -- `desktop_type {text, window?|hwnd?}` — types a literal Unicode string (`KEYEVENTF_UNICODE`, any keyboard layout). -- `desktop_press_key {keys:[...], repeat?, window?|hwnd?}` (alias `desktop_send_keys`) — chords/shortcuts. Modifiers `ctrl/alt/shift/win`; named keys `enter/tab/esc/space/backspace/delete/insert/home/end/pgup/pgdn/up/down/left/right/f1-f12`. - -Canonical "navigate a browser to a URL" flow (works on `chrome://` where CDP can't): -```bash -adom-bridge desktop_press_key '{"keys":["ctrl+l"],"window":"Edge"}' # focus Edge + select the address bar -adom-bridge desktop_type '{"text":"edge://extensions","window":"Edge"}' -adom-bridge desktop_press_key '{"keys":["enter"],"window":"Edge"}' -``` -For a control that isn't reachable by CDP/coords (e.g. the Reload button on `edge://extensions`, which lives in a shadow DOM), Tab-navigate to it then Enter: `desktop_press_key {"keys":["tab","tab","enter"]}`. - -**Visual "Adom cursor" (v1.8.171+)** — a click-through teal cursor + a label that narrates what you're driving (trust cue; byte-faithful to the browser extension's cursor). Pure visual, *not* gated: -- `desktop_cursor {action:"show"|"move"|"hide", x, y, label}` — drive/narrate the cursor on its own ("watch me"). Returns the overlay `hwnd`. -- **On by default since v2.0.84** on `desktop_click`/`desktop_double_click`/`desktop_right_click`/`desktop_hover` (and their `mouse_*` aliases): ab glides the teal cursor to the target *then* does the real click, so the human always sees Adom is driving rather than an unattributed pointer jump. Pass `label:"Clicking Reload"` to narrate it in your own words; with no label it shows "`<your thread>`: `<your reason>`". Pass `cursor:false` only when the pointer move must be invisible. It was opt-in before, which meant the user lost the signal precisely when an AI was careless. -- **Auto-hide (v1.8.174+):** the cursor hides itself after `ttlMs` (default **60s**) so it never lingers on the user's screen if you forget to hide it. Each `show`/`move` refreshes the timer. Pass `ttlMs:<ms>` (≤600000) for longer, or `persist:true` to keep it until you explicitly `desktop_cursor {action:"hide"}`. If a cursor you showed was auto-hidden, the **next** cursor/click response prefixes its `_hint` with a NOTE so you learn (no chatty unsolicited message). - -#### Background input via UI Automation (v1.8.174+) — no foreground, no focus steal - -The mouse/keyboard verbs above use `SendInput` = real OS input, which only lands on the **foreground** window — so ab foregrounds the target first, yanking it in front of the user's work. When you must NOT steal focus, use **UI Automation** instead: `InvokePattern.Invoke()` / `ValuePattern.SetValue()` are **programmatic** — the app services them via its accessibility tree with **no cursor move, no focus change, no foregrounding**. Chromium/Edge expose their a11y tree to UIA, so `chrome://` page buttons/links are reachable **by accessible name** (not coordinates). - -- `desktop_find_control {window|hwnd, name|contains, role?}` — **read-only** discovery (ungated). Returns `best:{name, automationId, role, rect, invokable, settable}`. Call this first to learn whether a control is UIA-reachable. -- `desktop_ui_click {window|hwnd, name|contains, role?}` — UIA **Invoke** by accessible name, in the background. Gated behind shell-auto-approve. -- `desktop_ui_set {window|hwnd, name|contains, text}` — UIA **SetValue** on a text field, in the background. Gated. - -`role` ∈ `button|edit|link|checkbox|menuitem|tab|listitem|combobox|radiobutton|document|group|spinner`. **Caveat (surfaced in `_hint`, not hidden):** some shadow-DOM controls aren't in the UIA tree — if `find_control` reports `invokable:false`/not-found, the control genuinely needs a **foregrounded** `desktop_click`. So: prefer `desktop_ui_click` (background) → fall back to `desktop_click` (foreground) only when UIA can't reach it. - -#### Near-complete UIA surface (v1.9.0+) — enumerate, read, drive, with a foreground-hint contract - -v1.9.0 expands UIA from ~3 patterns to a near-complete agent-drivable client. **Every UIA verb now returns a `foreground` block** so you know — *before and after* — whether a call steals the user's screen: - -``` -"foreground": { "predicted": "background|fallback-possible|foreground", // from the static class + the resolved path, BEFORE acting - "observed": "background|foreground|n/a", // MEASURED (GetForegroundWindow before/after); reads = n/a - "raised": true|false, // did THIS call bring a window to front? - "pathTaken": "uia-pattern|sendinput-fallback|setfocus|read-only", - "reason": "<short>" } -``` - -**Shared selector on every verb:** `{hwnd|window(titleContains)}` + `{name|contains|automationId|role}` + `scope:"window"(default)|"popups"|"subtree:<automationId>"` + `nth:<int>` (0-based — target the Nth match without the exact name). Prefer `automationId` (stable, locale-independent). Rects are screen px with `centerX/centerY`; `space:"image"+shotId` is accepted wherever a coordinate is taken. - -- **Enumeration (read-only, ungated):** - - `desktop_ui_tree {window|hwnd, maxDepth?, roleFilter?[], nameContains?, maxNodes?(500), scope?}` — **flagship.** Dump the a11y subtree; nodes carry rect + invokable/settable + value/toggleState/expandState/selected/rangeValue + `children[]`. Honors Chromium's lazy tree (wake-retry); `truncated:true` when it hits `maxNodes` (logged, never silent). - - `desktop_ui_from_point {x,y | space:"image",shotId,x,y}` — the control under a pixel. - - `desktop_find_controls {selector, maxResults?(25)}` — ALL matches as an array with `nth` indices (vs `find_control`'s single best). -- **Reading (read-only, ungated):** `desktop_ui_text {selector, range?:"all"|"selection"|"visible"}` (TextPattern); `desktop_ui_get {selector}` (current value/toggle/range/selection/expand state); `desktop_ui_grid_get {selector, row?, col?}` (GridPattern cell + rowCount/columnCount). -- **Actions (gated by shell-auto-approve; background-first via the pattern, SendInput-click fallback only when the pattern is absent → then `raised:true`):** `desktop_ui_toggle {selector, state?}`, `desktop_ui_expand {selector, action?:expand|collapse|toggle}`, `desktop_ui_select {selector, action?:select|add|remove}`, `desktop_ui_scroll {selector, dir?, amount?}`, `desktop_ui_set_range {selector, value}`, `desktop_ui_focus {selector}` (**bg:foreground** — usually raises; reports it), `desktop_ui_window {window, action:minimize|maximize|restore|close|move|resize}`. -- **Events (v1.9.3 — REACT instead of poll; read-only, ungated):** - - `desktop_ui_watch {event, window?|hwnd?, properties?}` → `{watchId}`. `event` ∈ `focus` (desktop-wide "what got focus") | `window-opened`/`window-closed` (new/closed top-level windows — these three can be global) | `invoke` | `selection` | `text-changed` | `text-selection` | `menu-opened` | `tooltip-opened` | `notification` | `live-region` | `structure` | `property` (these REQUIRE a `window`, else `errorCode:window_required` — avoids a desktop firehose). Cap 16 watches. - - `desktop_ui_events {sinceSeq?, watchId?, max?}` → DRAIN the buffer (poll cursor; there is NO server-push over the relay). Returns `{events:[{seq, watchId, event, ts, element:{name,role,automationId,rect,hwnd}, eventType|property|changeType}], nextSeq, highestSeq, missedEvents, activeWatches}`. Poll with `sinceSeq=nextSeq`. Events ALSO appear in the ab GUI activity-log. The 500-deep ring is shared across all watches; `missedEvents:true` = you polled too slowly. - - `desktop_ui_unwatch {watchId|"all"}` → stop. **Canonical use:** waiting on an OTP/confirm dialog → `ui_watch {event:"window-opened"}`, poll `ui_events` until the window appears, then act on it (no busy screenshot loop). -- **Introspection:** `desktop_ui_capabilities {}` — **read-only, ungated.** The foreground-hint TABLE per verb (`staticClass` + measured `{win32, chromium-pattern, chromium-fallback}`). **Consult it BEFORE a call to know if it'll steal the screen, without acting.** `desktop_ui_selftest {confirm?, window?}` writes/refreshes it (empirically measures when `confirm:true`). - -**Integrity boundary:** an elevated/admin window or the UAC secure desktop returns `errorCode:"uia_integrity_blocked"` (never a silent no-op) — a normal-integrity bridge cannot drive a higher-integrity process. - -#### Zero-math coordinate clicks — `coordMap` + image-space (v1.8.175+) - -You no longer recompute scale/offset/DPI to click what you see in a screenshot. Every screenshot verb returns a **`coordMap`**: - -```jsonc -"coordMap": { - "shotId": "shot-w5050328-1", - "image": { "w": 1372, "h": 1031 }, // the DELIVERED (safe) PNG's pixels - "screenRect": { "x": 594, "y": 254, "w": 1372, "h": 1031 }, // physical px = input-verb space - "dpiScale": 1.5, - "imageToScreen": { "sx": 1.0, "sy": 1.0, "ox": 594, "oy": 254 } // screen = img*s + o -} -``` - -Two ways to use it: -- **Let ab convert (preferred, zero math):** `desktop_click {space:"image", shotId, x, y}` where `x,y` are pixels in that screenshot. Also works on `desktop_double_click`/`right_click`/`hover`. The `shotId` comes from the capture's `coordMap.shotId` (last ~64 shots retained; re-capture if it expires). -- **Compute yourself:** `screenX = x*sx + ox`, `screenY = y*sy + oy`. - -`screenRect` is in the SAME physical-px space as `desktop_list_windows` rects and the `desktop_click` default (`space:"screen"`). - -> **Driving a GUI app that pops error boxes / modal dialogs? Read the dedicated [`adom-bridge-window-capture`](WINDOW_CAPTURE.md) skill.** `desktop_screenshot_window {hwnd}` captures ONE window AND the child dialogs / error boxes it owns, even when the window is backgrounded/occluded/minimized (PrintWindow, no focus steal). It is the breakthrough for automating native apps whose dialogs a full-screen grab can't see. The rest of this section is the reference; that skill is the how-and-why. - -**Popups (owned):** As of **v1.8.177**, `desktop_screenshot_window` **also captures the window's OWNED popups** — borderless confirm bubbles (Chromium's "Remove '\<ext\>'?"), owned dropdowns, owned dialogs — that don't enumerate in `desktop_list_windows` and aren't visible in a PrintWindow of the main window. They ride back as a top-level **`screenshots[]`** array (the same generic shape `kicad_screenshot_all` uses; the CLI auto-pulls each entry's `fullPath`→`localPath` and `safePath`→`localSafePath`). `ownedPopupCount` is always present (0 = none). Each entry carries `hwnd`, `title`, `kind:"owned_popup"`, and its own `coordMap`+`shotId`, so you can **click the bubble's buttons** with `{space:"image", shotId, x, y}` or drive them in the background by name via `desktop_ui_click {scope:"popups"}`. This is the generic mechanism — there's no app-specific "Chrome bubble" verb; every window screenshot surfaces any owned popups so you get the data and decide. **Use `desktop_screenshot_screen`** (full virtual-screen grab) only for popups *not owned* by your target window, or to see everything at once. - -**Screen geometry on every shot (v1.8.178):** every `desktop_screenshot_window` / `_screen` response now carries: -- **`windowRect: {x,y,w,h,monitor}`** — the window's position + size on the overall (possibly multi-monitor) desktop, in physical screen px. Each owned popup entry in `screenshots[]` gets its own **`rect: {x,y,w,h,monitor}`** too — so you know where every captured surface sits, not just its pixels. -- **`display: {virtualScreen, primary{w,h,dpi,scale}, monitors[], monitorOfWindow}`** — the full monitor topology: total virtual bounds, and per-monitor `bounds`/`dpi`/`scale`/`primary`. `monitorOfWindow` indexes into `monitors[]`. (Same data as the standalone `desktop_display` verb, folded in.) -- **`drive: "background"`** — screenshots never foreground the window (PrintWindow/WGC). The companion `_related` field lists sibling screenshot/window verbs. - -**Screenshot cache is self-cleaning + self-describing (v1.9.107/108/113):** the CLI auto-pulls every screenshot to ONE user-visible, namespaced dir on the caller's machine (`~/project/screenshots/adom-bridge/`, else `~/.adom/screenshots/`; `ADOM_SHOTS_DIR` overrides) — never `/tmp` anymore. Shots are **bucketed by kind into subfolders**: `window/` (a specific window / app / owned popup) and `screen/` (a full-desktop grab). **Human-readable filenames (v1.9.113):** `<YYYY-MM-DDThh-mm-ss.mmm>[-<hwnd>].png` — a sortable ISO stamp (lexicographic == chronological), plus the `hwnd` for a window capture; the `window`/`screen` kind is NOT repeated in the name because the folder already says it. **One file when small (v1.9.114):** if the shot was already within the safe size cap (the common case) it's a single `<stem>.png` and `localFullPath == localSafePath` — no redundant re-encoded copy. Only a shot large enough to be downscaled produces a `<stem>.full.png` + `<stem>.safe.png` pair (Read the `.safe` one for image analysis; use `.full` for archive). Each shot gets a **`<stem>.json` sidecar** (shotlog-style: `source`, `timestamp`, `kind`, dims + `bytes`, `coordMap`, `rect`; a full+safe pair shares one). **Parent/child grouping (v1.9.111/113):** when `desktop_screenshot_window` returns owned popups in `screenshots[]`, the parent AND each pulled child share the same stem, and each child gets a **`.popup-NN` label** — e.g. parent `2026-07-15T01-14-16.937-986710.safe.png` and children `2026-07-15T01-14-16.937-986710.popup-01.safe.png`, `...popup-02...`. So a plain `ls` shows the whole capture grouped (shared stem) and the role (`.popup-NN`) without opening a sidecar; the sidecar also carries `parent` + `popupIndex`. (`kicad_screenshot_all` returns sibling windows with no parent, so those keep their own names.) A janitor prunes it (7 days / 2 GB / newest kept; sidecars follow their PNG, orphans swept) and drains the old `/tmp/ad-shots` + `/tmp/adom-bridge-screenshots` backlogs, running opportunistically at most once/24h on any CLI call. **You do not need to clean it up or write your own cron.** Every screenshot response carries a `_cacheHint`; verify coverage with **`adom-bridge janitor`** (status: each subfolder, size, policy, last sweep) or force a sweep with **`adom-bridge janitor '{"sub":"run"}'`**. It never touches other tools' files under `~/project/screenshots/` (e.g. shotlog). Tune via `ADOM_SHOTS_MAX_AGE_DAYS` / `ADOM_SHOTS_MAX_GB` / `ADOM_SHOTS_MAX_FILES`, or disable with `ADOM_JANITOR=0`. - -**Background vs foreground driving (v1.8.178):** every desktop driving verb carries a structured **`drive`** field (`"background"` | `"foreground"` | `"n/a"`) — in `GET /commands`, in `adom-bridge help <verb>`, and echoed in the verb's own response. **Background** (no foreground / focus steal / cursor move — UIA + capture): `desktop_ui_click`, `desktop_ui_set`, `desktop_navigate`, `desktop_find_control`, `desktop_screenshot_window/_screen`, `desktop_record_window_start`. **Foreground** (SendInput → goes to the focused window, so ab foregrounds first): `desktop_click`/`_double_click`/`_right_click`/`_hover`, `desktop_type`, `desktop_press_key`, `desktop_bring_to_front`. Run **`adom-bridge help background`** for the full grouping. Rule: to act without disturbing the user's window, drive by NAME (`desktop_find_control` → `desktop_ui_click`/`ui_set`); fall back to coordinate clicks/keys only when the control isn't in the UIA tree. - -**Click a control by name → coords:** `desktop_find_control` returns `best.rect{x,y,w,h,centerX,centerY}` in screen px — `desktop_click {x:centerX, y:centerY}` to SendInput-click it (handy when `invokable`/`settable` are both false). Target by `name`, `contains`, `role`, or `automationId` (stable, locale-independent). - -**Keyboard chords:** `desktop_press_key` accepts a chord as one string `["ctrl+w"]` OR separate tokens `["ctrl","w"]` — both mean Ctrl held + W. A *sequence* of distinct chords uses `+` per element: `["ctrl+l","enter"]`. The reply echoes `chords` showing how ab interpreted your `keys`. - -- `desktop_flash_window` (v1.8.152+) -- Flash an **arbitrary** window's taskbar button orange (Win32 `FlashWindowEx`) as an "attention / ongoing activity" cue. Resolve the HWND with `desktop_list_windows` first. Args: `hwnd` (int) OR `titleContains` (string); `mode` (`"until_focused"` default — flashes until the user clicks the window; or `"count"`); `count` (int, `mode:"count"` only, default 3); `stop` (bool — clear an active flash). Returns `{ok, hwnd, title, mode}`. **Lightweight by design** (direct FlashWindowEx, no process spawn) so a bridge can call it after every verb. Distinct from the puppeteer bridge's `pup_alert_window`, which only flashes pup's OWN tracked windows — this flashes any window on the host. For progress + overlay-icon status, use `desktop_taskbar`. - - Example: `adom-bridge desktop_flash_window '{"hwnd":133560}'` then `adom-bridge desktop_flash_window '{"hwnd":133560,"stop":true}'` to clear. -- `desktop_taskbar` (v1.8.152+) -- Paint **glanceable status** on any window's taskbar button via `ITaskbarList3` (progress bar + overlay icon) + `FlashWindowEx`, so a bridge can show what the AI is doing on the **real app window it drives** (e.g. native-browser putting an "Adom is driving" badge + task progress on the Chrome/Edge button). Works on windows owned by **other processes**. Persistent COM (init once, reuse) — safe to tick progress frequently; no process spawn. Resolve the HWND via `desktop_list_windows`. Args (all channels optional; provide ≥1): - - `hwnd` (int) OR `titleContains` (string) - - `flash`: `"until_focused"` | `"stop"` - - `progress`: `{state: "normal"|"paused"|"error"|"indeterminate"|"none", value?: 0..100}` — **state→color**: normal=green, **paused=yellow** (use for human-in-the-loop gates: captcha / 2FA / login), error=red, indeterminate=marquee, none=clear. - - `overlay`: `{badge: "adom"|"none", icon?: "<PNG or .ico — file path OR base64>", tooltip?: "<hover>", avatar?, profileDir?, userDataDir?, composite?}` — built-in badges are `adom` (the Adom logo — a "driven by Adom" trust tell) and `none` (clear); 3rd-party bridges pass their **own** `icon` (a **PNG** or `.ico`, file path or base64 — ab magic-sniffs + renders at 32px). ab stays generic: it paints whatever art you hand it. `tooltip` is the overlay's accessible description. - - **Compositing onto a Chrome/Edge profile avatar (v1.9.110, #154):** Windows gives ONE overlay slot per taskbar button, and Chrome/Edge use it for the **profile avatar** — so a bare `badge:"adom"` would EVICT the profile face (you'd lose "work vs personal"). To keep the avatar AND show "Adom is driving", pass the avatar and ab composites the mark onto it (bottom-right ~47%): `overlay:{badge:"adom", avatar:"<PNG path or base64>"}`, or name the profile with `overlay:{badge:"adom", profileDir:"Profile 1", userDataDir?:"<User Data root>"}` (ab reads `<UserData>/<profileDir>/Google Profile Picture.png`; `userDataDir` is derived from the window's exe if omitted). `composite:false` opts out (raw mark). If the avatar can't be resolved ab falls back to the plain mark — never errors. The CALLER (which knows its profile — e.g. the browser extension) supplies the avatar; ab does not reverse-engineer it (one browser process serves all profiles, so the process command line has no `--profile-directory`). - - `thumbnailTooltip` (v1.9.153): the taskbar **hover-preview tooltip** (`ITaskbarList3::SetThumbnailTooltip`). The ONLY uncapped text surface on the preview — the window title truncates ~25-30 chars, this string does not (and takes newlines). e.g. `"Adom Pup / Adom wiki / LOGGED-IN view / Chrome for Testing / 3 tabs"`. Pass `""` to clear it back to the title. Works on a window ab does not own. - - `thumbnailClip` (v1.9.153): crop the live hover preview to a client-space region — `{x,y,w,h}`, or `null` to reset to the whole window (`SetThumbnailClip`). - - Returns `{ok, hwnd, title, applied:[...]}`. Clears: `flash:"stop"`, `progress.state:"none"`, `overlay.badge:"none"`, `thumbnailTooltip:""`, `thumbnailClip:null` — call these on task-end / window-close to clean up. - - Example (long task, human gate, then done): - ```bash - adom-bridge desktop_taskbar '{"hwnd":133560,"overlay":{"badge":"adom","tooltip":"Adom is driving this browser"},"progress":{"state":"indeterminate"}}' - adom-bridge desktop_taskbar '{"hwnd":133560,"progress":{"state":"paused"},"flash":"until_focused"}' # captcha — come look - adom-bridge desktop_taskbar '{"hwnd":133560,"progress":{"state":"none"},"overlay":{"badge":"none"},"flash":"stop"}' # done — clear all - ``` -- `desktop_set_window_identity` + `desktop_register_app_identity` (v1.9.134+) -- **Full window-identity takeover.** Make a window you drive present itself as YOUR app: the taskbar button, Alt-Tab entry, hover tooltip and any user pin read your name and icon instead of the process that happens to own the HWND. Built for a bridge driving a borrowed host app (a Chrome for Testing window otherwise reads "Test" with Chrome's icon, which is two brands and a confusing word). ab is generic here — it applies whatever `appId` / `iconPath` / `displayName` you pass and knows nothing about which app that is. - - **You need BOTH halves.** `set_window_identity` stamps a LIVE window (`PKEY_AppUserModel_ID` + `WM_SETICON`); `register_app_identity` teaches Windows what that `appId` MEANS (an HKCU `AppUserModelId` key + a Start Menu shortcut carrying the same AUMID). An unregistered appId falls back to the owning exe's icon, so half one alone leaves Alt-Tab wrong. - - ```bash - # Once per install. Ship the .ico in your bridge's release zip; pass its cache path. - # `target`/`args` are what a user's PINNED tile launches — point it at a real verb so - # the pin opens a managed window instead of an orphan app. - adom-bridge desktop_register_app_identity '{"appId":"Adom.Pup","displayName":"Adom Pup", - "iconPath":"C:/.../adom-pup.ico", - "args":"pup_open_window {\"sessionId\":\"pinned\",\"owner\":\"pinned-launch\"}"}' - - # Per window, e.g. at park time. cacheKey survives page-title churn. - adom-bridge desktop_set_window_identity '{"cacheKey":"<sessionId>", - "titleContains":"(session: <sessionId>)","appId":"Adom.Pup", - "displayName":"Adom Pup","iconPath":"C:/.../adom-pup.ico"}' - ``` - - Window target: `hwnd` OR `titleContains` OR `cacheKey` (+ `fresh` to re-resolve). Response carries `resolvedBy` (`hwnd` | `cachedHwnd` | `title`) and `applied[]`. - - **Identity is per-HWND and dies with the window** — re-apply after a window is recreated. - - ab holds the icon handles for its whole process lifetime. Icon handles are process-owned: a transient setter leaves windows pointing at dead handles, so the icon reverts moments later. ab being long-lived is exactly why it owns them. - - `register_app_identity` writes HKCU only (never a UAC prompt). It is gated for RELAY callers like `write_file`; a bridge calling over the loopback API is trusted by transport and ungated. - - **Jump-list tasks (v1.9.139+): `desktop_set_window_jumplist {appId, tasks}`** puts YOUR actions on the taskbar right-click menu of every window stamped with that appId. Jump lists attach to the AUMID, so per-session appIds give each window its OWN menu. Each task: `{title (required), target? (exe, defaults to ab's bundled CLI — so `args` can simply be an ab/bridge verb + JSON), args?, iconPath?, description?}`. Tasks launch minimized (no console flash). Max 16; `tasks:[]` clears (call at session close, alongside `unregister_app_identity`). Re-set anytime to update the menu. Tier-2 gated for relay callers; loopback bridges ungated. - - **Per-session / transient appIds (v1.9.136+): register REGISTRY-ONLY with `shortcut:false`, unregister on close.** If you stamp per-session AUMIDs (e.g. `Adom.Pup.<sessionId>` so each window keeps its own taskbar button in split mode), an UNREGISTERED AUMID makes Alt-Tab fall back to the owning exe's icon. `desktop_register_app_identity {appId, displayName, iconPath, shortcut:false}` writes just the HKCU AppUserModelId key (DisplayName + IconUri) — Alt-Tab and taskbar hover resolve the name + icon with NO Start Menu entry. Pair with `desktop_unregister_app_identity {appId}` at session close (idempotent; removes the key plus any shortcut carrying that AUMID; ab's own `inc.adom.bridge` identity is protected). Keep ONE shortcut-mode registration as the pinnable launcher. **Known cost (wiki #155, measured): without a visible Start Menu shortcut carrying the AUMID the shell resolves it lazily on the first right-click, so the jump list flashes and only the second click stays.** `anchor:true` (opt-in, default off since 2.1.49) writes a small visible anchor `.lnk` inside one Start Menu subfolder (`anchorFolder`, default "Adom Bridge"; pass it on unregister too) that fixes it, but it is a real Start Menu entry (All apps, "Recently added", search), and a hidden `.lnk`, `NoStartPage` or `SHAddToRecentDocs` do not anchor. John's default: a clean Start Menu. - - **`desktop_set_window_jumplist`** (v1.9.139+) adds the right-click menu on the taskbar button (per-AUMID; `tasks:[]` clears) and, with `hwnd`+`headerIcon`, brands the header row (the app tile above Pin/Close). - - **Icon format — read the `iconCheck` these verbs return (v1.9.152).** `desktop_set_window_identity` / `desktop_register_app_identity` / `desktop_set_window_jumplist` return an `iconCheck {kind,sizes,issues,ok,hint}` (or `headerIconCheck`) that magic-sniffs the file and explains why an icon would render as a generic white document: a **256px `.ico` size not PNG-compressed** (the Vista+ trap), **SVG** (the Win32 shell can't load it), a `.ico` with no small size, an `.exe` where an image is wanted. If `iconCheck.ok` is false, fix the file and re-stamp. Ship a multi-size `.ico` (16/24/32/48/256, the 256 PNG-compressed). **Full guide:** the `adom-bridge-window-identity` skill (AUMID model, the register+stamp two-halves rule, jump-list header, taskbar overlay + thumbnail, the live-tile trap, a recipe). -- `desktop_revoke_approvals` -- Revoke all shell auto-approve permissions and deny pending approvals. The next shell command will show the approval dialog. - - Example: `adom-bridge desktop_revoke_approvals` -- `desktop_demo_panel` -- Floaty glass BACK / PLAY-PAUSE / NEXT / MUTE transport for a guided demo the USER paces (v2.1.0+, mute/callback v2.1.17+). `show` puts it up (reason required; same-id shows persist omitted fields so a stage advance never un-pauses), `poll` between beats drains the user's presses (honor paused / next / back / mute / stop; `muted` rides every reply -- silence your narration audio while it is true), `hide` when the show ends. A LOCAL caller (ah thread, bridge) may pass `eventCallbackUrl` (loopback http) to get every press POSTed instantly; remote callers pace with poll alone. A press never executes anything -- it only queues an event for you. **The full demo recipe (panel + captions + cursor, stop design, the pacing loop) is the `adom-bridge-demo-kit` skill -- read it before building a demo.** -- `desktop_caption` -- Show a global caption overlay that stays visible above ALL windows (KiCad, Fusion, Chrome). Native Win32 overlay — not a browser DOM element. Click-through: mouse events pass to windows below. Captured by screen recordings (browser `getDisplayMedia`, OBS, Game Bar). - - Show: `adom-bridge desktop_caption '{"text":"Step 1: Opening the board","position":"top","size":"large","duration":3000}'` - - Custom position: `adom-bridge desktop_caption '{"text":"Look here","x":0.3,"y":0.2,"size":"large","persist":true}'` - - Hide: `adom-bridge desktop_caption '{"action":"hide"}'` - - **Every caption carries a close box (X) and, when you gave a `reason`, a line naming your thread.** Both are the user's protection: the X is their only way to dismiss a caption without an AI, and the attribution answers "which of my ~20 threads is drawing on my screen". - - **`clean:true` removes BOTH, and it is deliberately hard to get — treat it as rare.** It exists for one case: the user is recording a demo video and explicitly asked for a caption with no chrome. It requires `userAsked:true` AND a `cleanReason` (>=30 chars) saying what they asked for, it is **refused outright** with `persist:true`, and its duration is capped at **2 minutes** (re-issue the same id to extend while you are supervising). - - **Do not set `clean:true` because the caption looks better without the X.** The user cannot dismiss a clean caption at all, so if it lands over something they need, their only recourse is to find the AI that put it there. If you cannot point to the user asking for a chrome-free caption, the answer is no. A normal caption looks the same in a recording apart from a small X. - - List what's on screen (v1.9.217): `adom-bridge desktop_caption '{"action":"list"}'` — returns `{captions:[{id,ageMs,expiresInMs,persist,overdue}]}`; the authoritative check (caption overlays do NOT appear in `desktop_list_windows`). TTLs are fail-safe: a 15s reaper closes any caption that outlives its deadline. Do not keep a caption alive with a same-id refresh loop as a nag — the user experiences one endless caption. - - Multiple simultaneous captions (use `id` to keep them independent): - ``` - adom-bridge desktop_caption '{"text":"Step 1: Opening board","id":"step","position":"center","size":"large","persist":true}' - adom-bridge desktop_caption '{"text":"REC ●","id":"status","position":"bottom-left","size":"medium","persist":true}' - ``` - - Replace only the step caption (status stays): - ``` - adom-bridge desktop_caption '{"text":"Step 2: Routing","id":"step","position":"center","size":"large","persist":true}' - ``` - - Hide just the step caption: `adom-bridge desktop_caption '{"action":"hide","id":"step"}'` - - Args: - - `text` (string) — caption text - - `id` (string, optional) — identifies this caption. Captions with the same id replace each other; different ids coexist simultaneously. Default `"_default"` — so bare calls without id still replace each other for backward compat. - - `position` — preset: `"top"`, `"bottom"` (default), `"center"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"` - - `x` (float 0.0–1.0) — normalized screen X, overrides position horizontal. 0.0 = left edge, 1.0 = right edge. Centers the caption box on this point. - - `y` (float 0.0–1.0) — normalized screen Y, overrides position vertical. 0.0 = top edge, 1.0 = bottom edge. - - `size` — preset: `"large"` (72px), `"medium"` (32px, default), `"small"` (20px) - - `fontSize` (int) — custom font size in px (8–400). Overrides `size` preset when provided. - - `duration` (ms) — auto-dismiss timer. **Omitted or `0` → `30000` (30s); clamped to max `600000` (10 min).** Captions ALWAYS auto-expire (v1.8.138+) so a forgotten one can't be stranded on the user's desktop. To keep one up longer, **re-issue the same `id`** (resets the timer) or pass a bigger `duration`. - - `persist` (bool, optional) — `true` = sticky, NO auto-dismiss. Supervised use only (e.g. demo recordings); you MUST then dismiss it yourself with `action:"hide"`. This is the only way to a never-expiring caption (`duration:0` no longer does that). - - `action` — `"hide"` to dismiss a caption (with `id`: hides only that caption; without `id`: hides all captions), `"force-clear"` to EnumWindows and destroy ALL caption windows regardless of id (nuclear option — use at top of demo scripts for guaranteed clean slate) - - Captions with the same `id` replace each other instantly (previous destroyed, no fade overlap). Captions with different ids coexist — multiple captions can be visible simultaneously. - -### Desktop Screenshots - -Take screenshots of the user's desktop or individual windows. All screenshots use lossless PNG. - -**Always save screenshots** to `project-content/screenshots/`. - -**Naming convention:** `desktop-<descriptive-name>-YYYY-MM-DD-HhMMam/pm.png` - -**Workflow:** Call `desktop_list_windows` first to get HWNDs, then `desktop_screenshot_window` with the HWND. - -- `desktop_list_windows` -- List all visible windows (returns HWND, title, class name, position/size, and `z` = Z-order index, 0 = topmost) -- `desktop_find_window` (v1.8.153+) -- Resolve a window's HWND by `titleContains` and/or `className` (case-insensitive substrings), returned in Z-order with `best` = the topmost match. Use this instead of diffing `desktop_list_windows` before/after launching something — it's race-free for "the window I just opened." Returns `{ok, count, best:{hwnd,title,className,z,rect}|null, windows[]}` (`ok` true only when ≥1 matched). Pass `best.hwnd` to `desktop_taskbar` / `desktop_flash_window` / `desktop_screenshot_window`. - - Example: `adom-bridge desktop_find_window '{"className":"Chrome_WidgetWin_1","titleContains":"GitHub"}'` -- `desktop_activity_filter` (v1.8.155+) -- Scope the **user's GUI activity log** so they watch the slice you care about while you work (e.g. focus it on one bridge before a debugging session). Args: `bridge` (a bridge registry name from `bridge_list` | `"none"` = only global/non-bridge ab commands | `"all"`/omit = everything), `text` (free-text substring, ANDs with bridge), `clear` (bool — reset). **Drives the human's GUI view only — it does NOT change which verbs you can call or what they return.** The GUI shows a status bar stating exactly what's shown + what's hidden. - - Example: `adom-bridge desktop_activity_filter '{"bridge":"kicad"}'` then `adom-bridge desktop_activity_filter '{"clear":true}'` -- `desktop_screenshot_window` -- Capture a specific window by HWND -- `desktop_screenshot_screen` -- Capture the entire desktop (all monitors) -- `desktop_ad_scroll` (v1.9.86+) -- **Scroll ab's OWN window panels.** ab is a fully AI-controllable app, so you can drive its own scrollbars — most usefully to page the **bridges sidebar** and screenshot cards that are below the fold. Args: `panel` (`bridges` default | `activity` | `connections`), `to` (`top`|`bottom`, absolute — wins over `dy`), `dy` (relative px, default 400; negative = up). Returns `{ok, panel, scrolled, state:{scrollTop,scrollHeight,clientHeight,atTop,atBottom,found}|null, _hint}` — round-tripped from the live window so you can **loop until `state.atBottom==true`**. A collapsed bridges sidebar is auto-expanded first; `state==null` means the panel didn't report (collapsed/empty). - - Capture-all-cards workflow: `desktop_ad_scroll '{"panel":"bridges","to":"top"}'` → `desktop_screenshot_window` (ab hwnd) → `desktop_ad_scroll '{"panel":"bridges"}'` → screenshot → repeat until `atBottom`. - -### App self-update (relay-driven, v1.9.87+) - -ab auto-updates itself from the wiki manifest, but historically only a HUMAN clicking the footer "Install now" banner could apply it — so a headless / RDP / fleet box (a fresh Azure VM) could sit a version behind with no path forward from the relay. These verbs give the AI that path (same shape as the shell-approval surfacing verbs). Use `--target <box>`. - -- `update_status` (read-only, ungated) -- `{currentVersion, latestVersion, updateAvailable, updateReady, state, busy, updateMode}`. `updateReady:true` = the newer version is downloaded + SHA-verified on disk, appliable with no network. -- `update_check` -- force a manifest check now; if newer, download + verify (stage) it in the background. Poll `update_status` until `updateReady:true`. -- `apply_update` -- the programmatic "Install now": apply the staged update + relaunch ab. Respects `busy` (refuses if a command is in flight). Only ever applies ab's own downloaded + SHA-verified SIGNED wiki release. Returns `{applying:true}` then ab restarts (the relay drops + reconnects; poll `targets`/`ping` for the new version). If not staged yet, it kicks staging and tells you to poll + retry. -- `request_update_approval` -- surface the "Update available — Install now / Later" banner on the target + bring ab to the front (for an ATTENDED box where the human should decide). Does NOT apply; `apply_update` installs. -- `update_set_mode {mode: auto|prompt|off}` -- the auto-apply POLICY. `auto` = unattended stage+apply on the 4h/on-launch poll (set this ONCE on headless fleet boxes and they self-update every release with no relay call); `prompt` = show the banner (default, attended); `off` = no checks. - - Fleet flow: `update_status --target <box>` → if `updateAvailable` and unattended, `apply_update --target <box>` (or set `update_set_mode {"mode":"auto"}` once). A box must already be on ≥1.9.87 to have these verbs; the one-time bootstrap of an older box still needs a human click OR `auto` mode. - -### Hydrogen companion mode (v1.9.218+) - -When a HUMAN opens ab on a machine where Hydrogen is detected (embedded marker, Hydrogen's ports.json, or a discoverable Hydrogen install), ab shows an identity overlay ("This is Adom Bridge" + a large Open Hydrogen button), instantly opens or foregrounds Hydrogen, and places its own window directly below Hydrogen's in z-order — most people who open ab by hand were looking for Hydrogen. Facts an AI should know: - -- Human-open detection = bare argv AND parent process `explorer.exe` (double-click, Start menu, search, pins). Machine launches — `--autostart` boot, Hydrogen `--embedded` respawns, the relauncher/`restart` verb, CLI-style invocations — NEVER trigger it, so a remote `restart` cannot bounce the user's screen. -- One-shot placement: ab does not fight the user's later alt-tabbing, and it is NOT a real Win32 child window (ab's lifecycle stays independent — the recovery-channel contract holds; if Hydrogen cannot be launched the overlay says so and ab stays fully usable). -- Toggle: Settings > Hydrogen > "Act as a companion to Hydrogen", or `settings_set {"settings":{"hd_child_mode":false}}`. Default ON (self-gates on Hydrogen detection, so standalone installs never see it). -- Forensics: every decision slogs `ah-COMPANION:` lines to startup.log. - -### User settings over the API (`settings_get` / `settings_set`, v1.9.214+) - -ab's Settings-panel preferences are readable and settable over the API. This is the sanctioned channel for an embedding app (Hydrogen) to render ab's settings in its own panel and forward the user's changes; it also lets a fleet operator read or fix a box's boot/update prefs remotely. - -```bash -adom-bridge settings_get -adom-bridge settings_set '{"settings": {"launch_on_boot": true, "update_mode": "auto"}}' -``` - -- Settable keys (whitelist): `launch_on_boot`, `always_show_window_on_launch`, `update_mode` (auto|prompt|off), `auto_restart_for_updates`, `update_idle_minutes` (1..1440), `prewarm_runtimes`, `prewarm_cft` (auto|always|never), `file_receive_directory`. Anything else lands in `rejected` — servers, shell approval (grants are human-only), and blocked peers have their own surfaces and are deliberately not settable here. -- `launch_on_boot` changes apply the OS Startup artifact immediately; `settings_get` also returns `autostartArtifactPresent` so you can see intent-vs-artifact drift. -- `settings_set` is Tier-2 gated for relay (cloud-AI) callers — carry a `reason` and expect the human-approval flow, like `shell_execute`. Local/loopback callers (Hydrogen, bridges, the local CLI) are trusted by transport. -- Self-heal: ab re-verifies the launch-on-boot artifact every ~60s while running (not just at launch), so anything that deletes the Startup shortcut behind ab's back gets healed within a minute — with a `RECONCILE:` forensic line in `startup.log`. - -### Browser automation (`pup_*`, Puppeteer/pup) — bridge-owned; light pointer - -The pup bridge is **cloud-owned** and ships its OWN full skill (its wiki page / skill pkg — `bridge_list` names it; `adom-bridge sync_skills` installs it). This page keeps only the pointer plus first-touch examples; sessions/profiles, credential vault, downloads, recording, CDP details are the bridge's skill. - -Its verbs: `adom-bridge commands '{"bridge":"puppeteer"}'`. - -`bridge_readiness {"name":"puppeteer"}` is the read-only probe (never spawns or downloads). Full details: the bridge's own skill via `sync_skills`. - -### Screen recording — ab's desktop recorder (tab recording is pup's) - -ab owns DESKTOP recording: a visible HUD opens with the REASON you supply, so the user always sees that recording is on and why. Full guide: the `adom-bridge-recording` skill. - -```bash -adom-bridge desktop_recorder_open '{"reason": "record the DRC walkthrough for review"}' -adom-bridge desktop_record_start '{"confirmDesktopNotTabRecording": true}' -# ... drive the desktop ... -adom-bridge desktop_record_stop '{}' -adom-bridge desktop_record_list '{}' -adom-bridge desktop_recorder_close '{}' -``` - -`desktop_record_start` without `confirmDesktopNotTabRecording: true` refuses with `errorCode: desktop_record_needs_confirmation` — that guard exists because recording a BROWSER TAB is the pup bridge's separate feature (`pup_record_start` etc., see pup's own skill). - -### Filesystem primitives — `desktop_list_files` / `desktop_watch_files` / `desktop_pull_glob` (v1.5.0+) - -**These are the canonical "wait for a download" primitives.** They replace the `shell_execute` + PowerShell-poll-Downloads pattern that earlier callers (chip-fetcher, similar tools) had to use. No shell, no user approval prompt, no parsing of `dir` output. They run pure Rust on the desktop side, so they're fast, predictable, and never trip over PowerShell quoting. - -The pattern they replace looks like this: - -```powershell -# OLD — DON'T do this for new code: -$before = (Get-Date).ToFileTime() -# … click download … -while (-not (Get-ChildItem ~/Downloads -Filter ul_*.zip | Where { $_.LastWriteTime.ToFileTime() -gt $before })) { - Start-Sleep -Seconds 1 -} -$file = Get-ChildItem ~/Downloads -Filter ul_*.zip | Sort LastWriteTime -Desc | Select -First 1 -``` - -becomes one call: - -```bash -# NEW — single primitive, no shell: -adom-bridge desktop_watch_files '{ - "path": "%USERPROFILE%\\Downloads", - "glob": "ul_*.zip", - "timeoutMs": 60000 -}' -# → {ok:true, file:{path,name,size,mtime}, elapsedMs} on success -# → {ok:false, error:"timeout", elapsedMs, _hint, ...} on timeout -``` - -#### `desktop_list_files` — one-shot directory listing - -Lists files in a directory (non-recursive) matching a shell-glob, optionally filtered to those modified after a given timestamp. - -- Args: `path` (Windows abs path; `~` / `%USERPROFILE%` / `%APPDATA%` / `%LOCALAPPDATA%` / `%TEMP%` are expanded), `glob` (default `*`; shell-style with `*` and `?`; case-insensitive on Windows), `modifiedSince` (optional unix-seconds OR ISO-8601 string). -- Returns: `{ok, path, glob, files: [{path, name, size, mtime}, ...] sorted newest-first, count}`. -- Does NOT recurse. Lists one directory only. - -#### `desktop_watch_files` — block until a match arrives - -Polls the directory every `pollMs` (default 1000) until at least one file matches the glob with `mtime > since`, or `timeoutMs` (default 60000, max 600000 = 10 min) elapses. - -- Args: same as `desktop_list_files` plus `since` (defaults to **now** — without an explicit value, only NEW files report), `timeoutMs`, `pollMs`. -- Returns on match: `{ok:true, file:{path,name,size,mtime}, elapsedMs}`. -- Returns on timeout: `{ok:false, error:"timeout", elapsedMs, _hint, path, glob}`. -- The `since`-defaults-to-now behavior matches the canonical "click then watch" flow. If you might miss the file by starting the watch slightly late (e.g. fast small downloads), record `$(date +%s)` BEFORE the click and pass it as `since`. - -#### `desktop_pull_glob` — one-shot orchestrator (recommended for download flows) - -Composes `desktop_list_files` (or `desktop_watch_files` if `wait:true`) with the existing streaming `pull_file` mechanism. The whole "wait for a download then pull it" flow in one call: - -```bash -BEFORE=$(date +%s) -# … trigger the download click (a browser bridge verb, or desktop_ui_click) … -adom-bridge desktop_pull_glob "{ - \"path\": \"%USERPROFILE%\\\\Downloads\", - \"glob\": \"ul_*.zip\", - \"since\": $BEFORE, - \"wait\": true, - \"timeoutMs\": 60000, - \"saveTo\": \"/tmp/cse-out\" -}" -# → {ok:true, files:[{name,path,size,sha256,chunks}, ...], errors:[], matchedCount} -``` - -- When `wait:true`: blocks via `desktop_watch_files` until at least one match appears, then re-lists the directory and pulls **everything matching** (so a `.crdownload` + final `.zip` written in the same poll tick both come along). -- When `wait:false` (default): just lists what's there now and pulls those. -- Pulls use the existing `pull_file` streaming pipeline — sha256-verified, ~1MB chunks, resumes the same way `pull_file` does. - -#### Glob semantics - -- `*` matches any run of non-separator chars (within a single filename — these primitives don't recurse). -- `?` matches a single non-separator char. -- Case-insensitive on Windows (matches the filesystem); case-sensitive on macOS/Linux. -- Examples: `ul_*.zip`, `LIB_*.zip`, `*.pdf`, `datasheet_*.pdf`, `*.step`, `*.STEP` (same on Windows). - -#### Path expansion - -- `~` and `~/foo` → home dir (Linux/macOS convention; works on Windows too). -- `%USERPROFILE%` → home dir (Windows; also works cross-platform). -- `%APPDATA%` → Windows roaming app data (`dirs::config_dir()` on others). -- `%LOCALAPPDATA%` → Windows local app data (`dirs::data_local_dir()` on others). -- `%TEMP%` → Windows TEMP env var; `/tmp` on Linux/macOS. -- Expansion is case-insensitive: `%userprofile%` works the same as `%USERPROFILE%`. -- Forward slashes (`/`) and back slashes (`\`) both work in the path string. - -#### Time format for `since` / `modifiedSince` - -- Number: unix seconds (e.g. `1746483600`). Floats accepted. -- String of digits: same (e.g. `"1746483600"`). -- ISO-8601 / RFC-3339 string: e.g. `"2026-05-04T22:30:00Z"` or with offset `"2026-05-04T15:30:00-07:00"`. -- Files with `mtime <= since` are filtered out (strict `>`). - -### Hydrogen (`hd_*`) — proxy + build/lifecycle for the sibling Tauri app - -When the user has **Hydrogen** running (a sibling Tauri v2 app at `C:\Github\hydrogen-desktop`), built-in `hd_*` verbs reach into it. NOT a separate bridge process — adom-bridge proxies HTTP calls directly to Hydrogen's control API. - -**Port discovery (v1.8.72+):** Hydrogen's control API lives on **port `47084`** (default; the actual port is read from `%APPDATA%\hydrogen-desktop\ports.json` key `control` on every `hd_api` call, fallback 47084). Pre-v1.8.72 the verbs were hardcoded to Hydrogen's legacy `:9001` webview-eval port, which Hydrogen doesn't bind anymore — that's why `hd_status` reporting "Hydrogen not running" while Hydrogen's window is visible is the upgrade signal. - -#### The canonical generic verb — `hd_api` (v1.8.72+) - -```bash -adom-bridge hd_api '{"method":"GET","path":"/health"}' -adom-bridge hd_api '{"method":"POST","path":"/setup/run-step","body":{"id":"welcome"}}' -adom-bridge hd_api '{"method":"GET","path":"/wsl/status"}' -``` - -Generic HTTP pass-through to any Hydrogen endpoint. Raw JSON in `body` — no curl-through-the-relay quote mangling. Returns `{success, status, body, port, method, path}` mirroring Hydrogen's response. If Hydrogen isn't running, returns `{ok:false, error:"Hydrogen not reachable on :47084", _hint:"..."}` instead of timing out. - -**Public denylist** — these paths are refused with `{ok:false, error:"not_allowed_in_public", denylist_match:"..."}` on public ab builds (use the internal `hd-dev` cargo feature to bypass): - -``` -/wsl/unregister, /setup/virgin-reset, /container/destroy, -/container/reset, /workspace/reset, /workspace/destroy -``` - -#### Inspect + drive a running Hydrogen (v1.8.15+, post-v1.8.72 rework) - -| Verb | What | Status | -|---|---|---| -| `hd_status` | Composes ab-side facts (running, repo SHA) + Hydrogen's `/workspace/health` + `/buildinfo` relayed | ✓ shipped | -| `hd_api` | Generic HTTP pass-through (above) | ✓ v1.8.72+ | -| `hd_log '{"tail":30}'` | Tail `%APPDATA%\hydrogen-desktop\hydrogen-desktop.log` (reads disk — works EVEN when Hydrogen is down) | ✓ shipped | -| `hd_open_url '{"url":"...","browser":"chrome","profileDir":"Default"}'` | POST `/open-in-profile` — open URL in a specific browser profile | ✓ shipped | -| `hd_browser_profiles` | GET `/browser-profiles` — enumerate browsers + profiles | ✓ shipped | -| `hd_screenshot` / `hd_shot` | Find Hydrogen window, capture lossless PNG via PrintWindow; `hd_shot` adds region-scoped variant | ✓ shipped | -| `hd_eval '{"js":"..."}'` | POST `/eval` — run JS in Hydrogen's main webview | ⚠ **Moved to `hd-dev` in v1.8.72.** Public returns `verb_moved_to_hd_dev`. Use `hd_api '{"method":"POST","path":"/eval","body":{"js":"..."}}'` | -| `hd_iframe_eval '{"js":"...","contextIndex":0}'` | POST `/iframe-eval` — JS in code-server iframe via CDP | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent | -| `hd_reload_vscode` | POST `/reload-vscode` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent | -| `hd_container_exec '{"command":"..."}'` | POST `/container-exec` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent | -| `hd_launch` / `hd_stop` / `hd_restart` | Hydrogen process lifecycle | ✓ shipped | -| `hd_ship` | Atomic build+relaunch+verify-SHA (see below) | ✓ shipped | -| `hd_build*` (v1.8.16+) | Build pipeline orchestration | ⚠ Moved to `hd-dev` in v1.8.72 (Hydrogen developers only) | - -#### Atomic ship — `hd_ship` (v1.8.49+) — START HERE for post-push rebuilds - -**One verb, one call, one structured pass/fail.** Replaces the 4-step chain `hd_stop` → `hd_build_rust` → poll `hd_build_status` → `hd_launch` → `curl :47084/buildinfo`. Eliminates the "is the new code actually running?" guesswork after every push. - -Sequence inside one call: -1. taskkill Hydrogen + poll until zero instances (frees the exe lock) -2. `git fetch && git reset --hard origin/main && rev-parse HEAD` — asserts `HEAD == expectSha` (fails fast if GitHub hasn't replicated the push yet) -3. if `clean:true`, `cargo clean -p hydrogen-desktop` (defeats stale-relink) -4. `cargo build` and parse stderr for `Compiling hydrogen-desktop` to know whether anything actually compiled -5. launch + poll `http://127.0.0.1:47084/buildinfo` until it responds + assert `build_sha == expectSha` - -```bash -# End-to-end after a push: -git push && SHA=$(git rev-parse HEAD) && \ - adom-bridge hd_ship "{\"expectSha\":\"$SHA\"}" -``` - -Args: -- `expectSha` — **REQUIRED** string. Full or prefix SHA of the commit you want running. Bidirectional-prefix match (`abc123` matches the full SHA starting with `abc123` and vice versa). -- `clean` — optional bool, default `false`. `true` forces `cargo clean -p hydrogen-desktop` before build. Use when a prior `hd_ship` succeeded but `runningSha != expectSha` (the stale-relink trap — cargo said `Finished` but reused the cached binary). - -Returns on success: `{ok:true, builtSha (== expectSha), runningSha (== expectSha), compiled:bool, elapsedSecs, _hint}`. `compiled:false` on success means cargo found nothing to recompile (cached relink or HEAD didn't actually change — usually fine but surface to caller in case the push was a no-op). - -Returns on failure: `{ok:false, failedAt:'args'|'kill'|'git'|'clean'|'build'|'launch'|'verify', error, builtSha (set after git step), runningSha (set if launch worked but verify saw the wrong SHA), compiled (set after build step), elapsedSecs, _hint}`. The partials let you reason about WHERE it broke: - -| `failedAt` | What it means | -|---|---| -| `args` | `expectSha` missing / malformed | -| `kill` | Couldn't taskkill Hydrogen (rare — usually permissions) | -| `git` | `git fetch / reset` failed, OR `HEAD != expectSha` after reset (push hasn't replicated to origin/main yet — retry in a few seconds) | -| `clean` | `cargo clean` failed (rare) | -| `build` | `cargo build` failed — read `_hint` for next step (usually `hd_build_log` to see the compile error) | -| `launch` | Cargo finished but `hydrogen-desktop.exe` wouldn't start — `binary_missing` or process spawn error | -| `verify` | Hydrogen launched but `/buildinfo` either never responded or reported a different SHA than `expectSha` (the stale-relink trap — retry with `clean:true`) | - -Long-running: cold cargo build can take 2-3 min. CLI WS timeout is 12 min — call as a single sync invocation and wait. Don't poll inside `hd_ship`; it does its own internal polling. - -#### Lower-level build + lifecycle suite (v1.8.16+, enriched in v1.8.52+) - -Use these directly only when you need finer control than `hd_ship` provides (e.g. frontend-only build, watching log stream live, manual launch sequencing). For the common "I pushed code, get it running" path use `hd_ship`. - -The relay's `shell_execute` has a 30s timeout; Hydrogen's `pnpm build` + `cargo build` exceed that, so the build is split into async-spawn verbs + sync poll/tail verbs: - -```bash -# Frontend-only rebuild, watch progress, relaunch (no hd_ship since hd_ship is Rust-build-only) -adom-bridge hd_stop -adom-bridge hd_build_frontend '{"show":false}' # returns {ok, pid, logPath} immediately -OFFSET=0 -while true; do - RESP=$(adom-bridge hd_build_tail "{\"offset\":$OFFSET}") - echo "$RESP" | jq -r '.lines[]' - DONE=$(echo "$RESP" | jq -r '.done') - OFFSET=$(echo "$RESP" | jq -r '.newOffset') - [[ "$DONE" == "true" ]] && break - sleep 3 -done -adom-bridge hd_build_status # confirm succeeded:true && relinkOnly:false -adom-bridge hd_launch # start the new debug binary -``` - -##### `hd_build` / `hd_build_frontend` / `hd_build_rust` — async-spawn build verbs - -| Verb | What | -|---|---| -| `hd_build '{"show":false}'` | Full async build: `git pull` + `pnpm build` + `cargo build`. Returns instantly with `{pid, logPath, flavor:'full'}`. Final log line is `BUILD_OK` or `BUILD_FAILED: <step>`. | -| `hd_build_frontend` | Just `pnpm build` (root). Returns `{pid, logPath, flavor:'frontend'}`. | -| `hd_build_rust` | Just `cargo build` (src-tauri/). **v1.8.52+:** dispatch response also carries `builtSha` + `exeMtimeBefore` + `exeSizeBefore` (captured at spawn time into `%TEMP%\hd-build-meta.json`) so the subsequent `hd_build_status` / `hd_launch` don't need a separate `git rev-parse` + `stat` call. Returns `{ok, pid, logPath, flavor:'rust', show, lingerSecs, builtSha, exeMtimeBefore, exeSizeBefore, _hint}`. | - -##### `hd_build_status` — sync state probe (v1.8.52+ enriched) - -Returns: -``` -{ - ok, building, succeeded, failed, - lastLines:[string], # last ~40 lines of stderr - logPath, pid, pidAlive, - failureReason, # 'compile_error' | 'exe_locked' | 'orphaned_no_sentinel' - compiledCrates:[string], # names parsed from 'Compiling X v...' lines - relinkOnly:bool, # true iff succeeded && compiledCrates is empty - exeChanged:bool, # mtime OR size after differs from before - exeMtimeBefore:int, exeMtimeAfter:int, # unix seconds - exeSizeBefore:int, exeSizeAfter:int, # bytes - exeLocked:bool, # true when build failed because hydrogen-desktop.exe was held - holderPids:[int], # PIDs holding the exe — taskkill these - builtSha:string|null, # SHA hd_build_rust/hd_build was dispatched at - elapsedSecs:int, - flavor:string, # 'rust' | 'frontend' | 'full' - _hint -} -``` - -**`relinkOnly:true` on success is the silent-fail trap to look for** — your push didn't actually rebuild because cargo found nothing to recompile. Usually means your source changes weren't pulled, or you touched a file the workspace doesn't track. Use `hd_ship` with `clean:true` to force a fresh build. - -**`exeLocked:true` on failure** + `holderPids` tells you exactly which PIDs to taskkill before retry; or just use `hd_ship` which kills Hydrogen before building. - -##### `hd_launch` — start the debug binary (v1.8.52+ enriched) - -Args: `killExisting` — optional bool, default `false`. When `false` and Hydrogen is already running, refuses with `reason:'already_running'` so you don't accidentally attach to a stale process. When `true`, taskkill the existing instance (either exe name) with a 600 ms grace period before launching fresh. - -`settleSeconds` — optional int, default `5`, max `30` (2.1.159). How long ab watches the new process before answering. `0` skips the extra wait. - -Returns on launch: `{ok:true, launched:true, stillRunning:true, observedForMs:int, wasAlreadyRunning:bool, killedFirst:bool, pid:int (Hydrogen's actual PID, not the cmd-wrapper), exePath, builtSha:string|null, _hint}`. - -Returns on refusal: `{ok:false, launched:false, wasAlreadyRunning:bool, pid:int|null, killedFirst:false, reason, _hint}`. `reason` ∈: -- `build_in_progress` — wait for `hd_build_status.building:false` -- `build_failed` — fix and retry `hd_build` (or use `hd_ship`) -- `already_running` — pass `killExisting:true` to force-kill+relaunch, or use `hd_restart` -- `binary_missing` — neither `target/debug/hydrogen.exe` nor the pre-rename `hydrogen-desktop.exe` is on disk; run `hd_build_rust` (or `hd_ship`) first - -Returns when the build starts and then dies inside the settle window (2.1.159): `{ok:false, launched:true, stillRunning:false, errorCode:'exited_after_launch', observedForMs, pid, exePath, builtSha, runningSha, narrative, _hint}`. The build crashes at startup: read `hd_log`. Re-running `hd_launch` repeats the same crash. Before 2.1.159 this case was reported as a successful launch, because the only process check ran 0.8 s after launch. - -**`launched:true, wasAlreadyRunning:false, killedFirst:false` is the proof you need that truly fresh code is running.** Any other combo means you should compare `builtSha` to Hydrogen's actual `/buildinfo.build_sha`. - -##### `hd_stop` / `hd_restart` / `hd_build_log` / `hd_build_tail` - -| Verb | What | -|---|---| -| `hd_stop` | Kills Hydrogen under either exe name (`hydrogen.exe`, or the pre-rename `hydrogen-desktop.exe`). `{wasRunning}` distinguishes killed vs no-op. | -| `hd_restart` | Stop + launch in one call. Same guards as `hd_launch` (skip `already_running`), and the same `settleSeconds` check: a build that dies at startup answers `errorCode:'exited_after_launch'`. | -| `hd_build_log` | Full log dump from `%TEMP%\hd-build.log`. | -| `hd_build_tail '{"offset":N}'` | Incremental stream — `{lines, newOffset, totalBytes, done, succeeded, _hint}`. Pass `newOffset` back for the next chunk. | - -The `show: true` arg on any build verb opens a visible PowerShell console window so the user can watch the build scroll. Default `show: false` runs hidden. - -**Auto-close (v1.8.23+).** Visible build windows auto-close 30 seconds after the final `BUILD_OK` / `BUILD_FAILED` line — long enough for the user to read the last error, short enough that build-after-build sessions don't accumulate orphan windows. The window prints a clear green/red "auto-closes in 30 seconds" banner before sleeping. Override with `lingerSecs`: -- `'{"show":true, "lingerSecs":60}'` — give yourself 60s instead of 30 -- `'{"show":true, "lingerSecs":0}'` — close immediately, no grace period -- `'{"show":true, "lingerSecs":3600}'` — keep open for an hour (cap) -The response includes the applied `lingerSecs` so you can confirm what landed. Hidden builds (`show:false`) ignore `lingerSecs` entirely — no window to linger. - -**Don't call `hd_launch` until `hd_build_status` shows `succeeded: true`** — the guard refuses with `reason: "build_failed"` if you do, and the `_hint` tells the AI exactly what to do (`hd_build_log` to see errors, fix, rebuild). Same for `hd_restart`. - -#### Named-region screenshots — `hd_shot` (v1.8.54+) - -**One verb replaces the `desktop_list_windows | grep 'Hydrogen' → desktop_screenshot_window <hwnd>` chain** (3 calls + fragile HWND parsing). Captures a named region of Hydrogen, produces BOTH a full-res PNG and a Claude-safe-resized copy, and returns rich metadata so the AI never has to chain screenshot → pull → convert → crop → resize → Read manually. - -Each shot is **self-identifying**: it includes the running build's SHA + local build time read from Hydrogen's `/buildinfo` so you can confirm you're looking at the build you expect before trusting what you see (no more "this screenshot was from the previous binary, not my fix" confusion). - -Regions are deterministic — they resolve via Hydrogen window bounds + CDP element rects queried through Hydrogen's `/eval` endpoint. No AI-supplied pixel coords. - -Args: -- `region` — **REQUIRED**. One of: - - `full` — whole Hydrogen window - - `titlebar` — top ~40 px strip (Hydrogen's build SHA / timestamp readout lives here) - - `vscode` — VS Code iframe pane via CDP element rect - - `setup-panel` — Setup Steps panel (`.setup-panel` / `.setup-panel-output`) - - `wiki` — wiki / browser tab pane - - `claude` — Claude Code panel -- `resizeMax` — optional int, default `1400`. Longest side of the safe-resized copy. Use ≈800 to fit more shots in one Claude turn; 1568 for max fidelity. - -Returns: -``` -{ - ok, region, - fullPath, # lossless raw PNG — for shotlog/archival, NOT Read - safePath, # ≤resizeMax PNG — pull_file this, Read this - fullSize:[w,h], safeSize:[w,h], - clipRect:[x,y,w,h], # captured-image pixels (NOT screen pixels) - capturedAtLocal, - runningBuildSha, # from Hydrogen's /buildinfo — null if Hydrogen doesn't expose it yet - runningBuildLocal, - hdWindowFound:bool, regionFound:bool, - windowOuter:[w,h], capturedSize:[w,h], - _hints: { - pull, # exact pull_file command for safePath - fullForArchive, # note that fullPath is for archive, not Read - staleWindow, # set when runningBuildSha looks wrong - regionMiss # set when regionFound:false - } -} -``` - -Recipe — verify-which-build-is-running + confirm-config-change-took-effect + full dump: - -```bash -# After hd_ship, confirm the titlebar shows the SHA you expect: -adom-bridge hd_shot '{"region":"titlebar"}' -# → runningBuildSha lets you assert this before trusting any other shot - -# After flipping a Setup Steps toggle, screenshot the panel to confirm: -adom-bridge hd_shot '{"region":"setup-panel","resizeMax":1568}' - -# Full-window dump for general debugging: -adom-bridge hd_shot '{"region":"full"}' -``` - -Edge cases: -- **Unknown region** — refusal: `{ok:false, error, knownRegions:[...]}`. No fallback — fix the call. -- **`regionFound:false`** — the element rect wasn't in Hydrogen's DOM. The panel/iframe may not be mounted yet, OR Hydrogen's markup changed and `region_selectors()` in `hd_bridge.rs` is out of date. The shot is **still produced** (falls back to the full window) so you have something to look at; check `_hints.regionMiss` for the reason. -- **`runningBuildSha:null`** — Hydrogen's `/buildinfo` isn't exposed yet on the running version. The shot is still produced; you just can't self-verify which build it represents. - -**Always pull_file the `safePath` and Read THAT** — it's already sized for Claude's image analysis. The `fullPath` is for shotlog / disk archival, NOT direct Read (may exceed Claude's per-image budget). The `_hints.pull` field gives you the exact `pull_file` command to copy-paste. - -**Old workaround to drop:** Do NOT use `desktop_list_windows | filter 'Hydrogen' | desktop_screenshot_window <hwnd>` anymore for Hydrogen — `hd_shot` replaces that entire pattern. Generic `desktop_screenshot_window` still applies to non-ah windows (other apps, dialogs, etc.). - -### TCP Port-forward Tunnels — `tunnel open|close|list|check|rdp` + `display` (v1.8.85+, reworked v1.8.147) - -Reach a TCP service addressable only from the **cloud side** through the **laptop side**: ab binds a `127.0.0.1:<port>` listener on the laptop and pumps bytes over the existing ab↔relay WebSocket; the relay dials the upstream `to`. First use case: RDP into a Windows VM behind an NSG only the cloud container reaches — mstsc on the laptop connects to a local port and is talking through the cloud. Same path works for SSH, VNC, HTTP, postgres — any TCP. - -**`tunnel` is a grouped verb** (`rdp` / `tunnel_close` / `tunnel_list` remain back-compat aliases): - -```bash -# Open a VERIFIED tunnel (both legs probed before ok). Auto-picks a free local port. -adom-bridge --target <name> tunnel open '{"to":"127.0.0.1:13389"}' - -# RDP: open + verify + launch mstsc, sized to the laptop's real screen. -adom-bridge --target <name> tunnel rdp '{"to":"127.0.0.1:13389","size":"90%"}' -# size: "90%" (default) | "100%" | "fullscreen" | "maximized" -# OR monitor:<index> (0=primary) OR explicit width+height - -adom-bridge --target <name> tunnel list # live tunnels + leak detection -adom-bridge --target <name> tunnel check '{"tunnelId":<id>}' # per-leg health -adom-bridge --target <name> tunnel close '{"tunnelId":<id>}' # or {"all":true} - -adom-bridge --target <name> display # monitor geometry (for sizing) -``` - -**`--target` is mandatory when >1 ab is connected.** v1.8.147 fixed a false-success bug: the bind, the upstream probe, and the mstsc launch now ALL resolve to the SAME machine. (Before, the bind could land on one ab and mstsc launch on another → "port bound" on the VM, "nothing listening" on the laptop, a bogus `ok:true`.) No target + multiple ABs → `ambiguous_target` listing the names; `--target all` is rejected (a tunnel binds one machine). `adom-bridge targets` lists connected ABs. - -**Active health verification — every `open`/`rdp`/`check` PROVES the path before reporting `ok`:** -- **local-bind** — ab self-probes `127.0.0.1:<boundPort>` right after binding. If it can't connect, ab tears the tunnel down and returns `ok:false` (this alone kills the false-success class). -- **upstream-refused** — the relay dials `to` (retry/backoff ~4×/2.5s). If unreachable, the tunnel is torn down (no leaked row) and `ok:false`. -- A failure names the leg: `failedLeg: "relay-to-ad" | "local-bind" | "upstream-refused"`. Health fields surfaced: `listening`, `relayConnected`, `upstreamReachable`, `connCount`, `mstscAlive`. - -**Progressive hints.** Every response carries `_hint` (what's true now) + `_next` (the exact next verb with real values, copy-pasteable). e.g. a successful open's `_next` hands you the `tunnel rdp` / `tunnel check` / `tunnel close` commands with the real `to`/`target`/`tunnelId` filled in. A missing `to` returns a structured error with an example. Walk the workflow from the responses alone. - -**Display-aware RDP sizing.** `tunnel rdp` resolves `size`/`monitor`/`width`/`height` against the laptop's real screen (queried via `display` — `EnumDisplayMonitors` + per-monitor DPI), writes an `.rdp` file with the geometry, and launches mstsc. e.g. `size:"90%"` on a 2560×1600 primary → a 2304×1440 window. Default 90% of primary. You never hardcode a resolution — call `display` if you want the layout first. - -**`tunnel list` reconciles + reaps leaks.** It cross-checks the relay registry against every connected ab's own registry and reports `leaks:[...]` for rows only one side knows about; `tunnel close '{"all":true}'` reaps them. The relay stores the actually-bound `localPort` (so `tunnel close '{"local":<port>}'` works). - -Architectural notes for Claude: -- Bytes-only — no payload parsing, so any TCP service works. TLS/cert-pinning still pins to whatever the cloud-side dial resolves (for RDP the upstream cert CN must match what mstsc expects). -- Each fresh laptop-side TCP connection gets its own connId multiplexed over one tunnelId; concurrent mstsc reconnects / parallel sessions work without re-opening. Backpressure bounded (256-slot mpsc/conn, 64 KiB buf). -- Tunnels don't survive ab/relay restarts. If mstsc disconnects, `tunnel check` (names the dead leg), then re-open. -- The classic RDP collision (Windows' own RDP owns `0.0.0.0:3389`) is dodged by `rdp`'s default `local:13389` + `SO_EXCLUSIVEADDRUSE` + ephemeral fallback (`fellBack:true` in the response; read `localPort` for the real port). - -**Old workarounds to drop**: (1) NSG WAN-IP whitelisting for roaming-laptop RDP — the tunnel rides existing auth, reachable regardless of WAN IP. (2) Trusting a bare `ok:true` from the old `rdp` — it could be a dead port; now `ok` means both legs were probed. (3) Hardcoding mstsc resolution — pass `size`/`monitor`; ab resolves against the real screen. - -### Shell — `shell_execute` (escape hatch only) - -- `shell_execute` -- Run a shell command on the desktop. The CLI handles approval polling internally — it returns the final `{success, output, error, exitCode}` once the user clicks Allow on the desktop dialog. 2.1.132+: ab holds the prompt for `approvalWaitSec` (default 60, max 600); a lapse answers `errorCode:"approval_timeout"` (nobody answered, NOT a deny: re-send with a longer wait or `request_shell_approval {wait:true}`), an explicit Deny answers `errorCode:"approval_denied"`. **You do not need to poll, retry, or call `get_deferred_result` yourself.** The CLI emits `HINT:` lines to stderr while waiting (every 15s) so an AI Monitor sees progress. -- `shell_kill_all` -- Kill all running shell commands and deny pending approvals. - -**Do not put JavaScript or escaped JSON in a `shell_execute` command (2.1.168, wiki #222).** On Windows the command runs through `cmd /c`, and cmd does NOT treat `\"` as an escaped quote. So in `curl -d "{\"js\":\"(()=>{const x=...})()\"}"` the `>` of `=>` (or any `<`/`>` comparison) can land outside cmd's quotes and become a REDIRECT: cmd writes a file named after the next word (`{const`, `e.children.length`) and the command gets mangled input. Send such payloads with `run_script {"interpreter":"powershell","scriptB64":...}` (base64, nothing to escape), or `write_file` the JSON and pass the file (`curl -d @C:/path/body.json`). ab now flags this shape in the reply's `_hint`. With no `cwd`, a command runs in `%TEMP%\adom-bridge-shell` (2.1.168; before that it ran in the Adom Bridge install folder, which is where those stray files landed); pass `cwd` when you need a specific folder. - -**v1.7.16 fix.** Earlier versions (v1.7.15 specifically) had a regression where the CLI's approval-wait loop re-sent the command every 1s instead of polling for the deferred result; that produced an infinite "Another shell command is already waiting for approval" loop because each retry created a fresh approval the user could never out-click. v1.7.16 fixes it. If you see that error string with a `>= 1.7.16` CLI, the bug is back — file an issue. If you see it with `< 1.7.16`, upgrade the CLI: `adom-wiki asset get apps/adom-bridge docker_binary -o /usr/local/bin/adom-bridge`. - -**Deprecated for download polling.** As of v1.5.0, use `desktop_watch_files` / `desktop_pull_glob` for waiting on download arrivals — those don't require user approval, don't go through PowerShell quoting, and are O(directory entries) rather than spawning a process every second. `shell_execute` itself stays as an escape hatch for genuinely shell-only operations (multi-step ad-hoc admin tasks, chained pipelines, etc.). - -Use `where python` / `C:\Python3xx\python.exe` for Python -- avoid `python` which may not be on PATH. - -#### `run_script` + `wsl_exec` — base64-payload script runners (v1.8.46+) - -When you want to send a multi-line script to the desktop without the shell-quoting hell that `shell_execute` requires, use these two verbs. The script crosses the wire as base64 so NO escaping survives to be mangled by cmd.exe, PowerShell parser quirks, MSYS path conversion, or wsl.exe's `--` / nested-quote / `$_` rules. The caller never hand-escapes anything. - -Both verbs require shell auto-approve to be active — same trust level as `shell_execute`. They refuse cleanly with an actionable error if not approved; no per-call modal (these are programmatic verbs). - -**Reading the result — `status` + `durationMs` (v1.8.162+).** EVERY CLI result now carries a single authoritative **`status`** field so you never have to guess whether an empty `stdout` was a real empty-output success or a timeout: - -| `status` | meaning | what to do | -|---|---|---| -| `ok` | completed (stdout may legitimately be empty) | use `exitCode`/`stdout`/`stderr` | -| `timeout` | did NOT finish in time (NOT an empty success) | raise `timeoutSeconds`, or use a detached job (below) | -| `busy` | desktop couldn't service it (WSL wedged / no client connected) | check `adom-bridge status`; `wsl_recover` for WSL; retry | -| `error` | the command/verb failed | read `error` / `stderr` / `_hint` | - -`durationMs` is the wall-clock of the call. `exitCode`/`stdout`/`stderr` pass through unchanged; on `timeout` the verb's `partialStdout`/`partialStderr` hold what was written before the kill. A `timeout`/`busy` result also carries a recovery `_hint`. **For multi-minute work don't fight the timeout** — use a detached job that returns instantly + polls: `wsl_exec_async` / `wsl_import` / `wsl_export` → poll `wsl_job_status` (or `launch_process` for a general long-running command). This is the cure for the "1.4 GB import EOFs the sync call" problem. - -**`run_script`** — base64 script + interpreter (cmd / powershell / bash): - -```bash -SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b''' -echo \"multi-line script\" -VAR=\"value with spaces and \$dollars\" -echo \"VAR=\$VAR\" -exit 0 -''').decode())") -adom-bridge run_script "{\"interpreter\":\"bash\",\"scriptB64\":\"$SCRIPT_B64\"}" -# → {success, ok, stdout, stderr, exitCode} -``` - -Args: `interpreter` (`cmd` | `powershell` | `bash`), `scriptB64` (required), `timeoutSeconds` (default 60). - -PowerShell prefers `pwsh` (PS 7+) if on PATH, falls back to `powershell.exe` (5.1). - -- **`bash` runs via WSL2, NOT Git Bash.** The Windows host's first `bash` on PATH is `wsl.exe -d <default-distro> bash` (verified at `C:\Windows\System32\bash.exe`). The script is piped to bash's stdin (no temp file, no path-form mismatch — no MSYS `/S` → `S:/` corruption, no nested-quote loss). The decoded script bytes are written to bash's stdin and stdin is closed; bash reads + executes without ever opening a file. For explicit distro / user control, use `wsl_exec`. - -**`wsl_exec`** — base64 script + distro + user, executed via the canonical `wsl -d <distro> -u <user> -- bash -lc <decoded>` argv array: - -```bash -SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b''' -cd /mnt/c/Github/adom-bridge -git status --short | head -10 -exit 0 -''').decode())") -adom-bridge wsl_exec "{\"distro\":\"Ubuntu\",\"user\":\"root\",\"scriptB64\":\"$SCRIPT_B64\"}" -# → {success, ok, stdout, stderr, exitCode} -``` - -Args: `distro` (e.g. `Ubuntu`, `Adom-Workspace`), `user` (e.g. `adom`, `root`), `scriptB64`, `timeoutSeconds` (default 60). - -Why argv (not shell string): Rust's `std::process::Command` on Windows applies proper `CommandLineToArgvW` quoting per argv element. `wsl.exe` sees `bash -lc <decoded>` as three distinct argv items even when the decoded script contains quotes, `$`, backslashes, newlines, pipes — anything. `bash`'s `-c` then sees the entire script as ONE string with no outer-shell interpretation. - -Output decoding: `wsl.exe` defaults to UTF-16LE when invoked from the Windows console subsystem. Both verbs auto-detect (BOM check + odd-position-NULL heuristic) and decode to UTF-8. Output is never wrapped in another shell that might re-interpret it. - -Pitfalls these verbs eliminate: -- `cmd.exe`'s `&`, `|`, `^`, `%var%` interpretation -- PowerShell's parser quirks around nested quotes + `$` interpolation -- Git Bash MSYS converting `/S` → `S:/` etc. -- WSL's `--` parser limits + nested-quote loss -- UTF-16LE corruption on wsl.exe output - -#### WSL wedge defense — `wsl_wedged` + `wsl_recover` (v1.8.157+) - -Every `wsl_exec` / `run_script bash` spawns a `wsl.exe` client. If WSL's service (`LxssManager`) wedges — common after a laptop runs for a day, or under heavy parallel cloud use — those clients block in a wait that `taskkill /F` can't always finish, and retries keep spawning more. Left unchecked they pile up into the hundreds and wedge the whole machine. Three defenses: - -1. **Refuse-cap.** When there are already too many `wsl.exe` (`WSL_REFUSE_AT=80`), ab refuses NEW wsl spawns with `errorCode:"wsl_wedged"` instead of adding another doomed client. If you get `wsl_wedged`, don't retry the same command — run `wsl_recover` first. -2. **`wsl_recover`** — reap orphaned/hung `wsl.exe`, **DRY-RUN by default** (nothing is killed unless you pass `confirm:true`): - ```bash - adom-bridge wsl_recover # dry-run: lists `candidates` (would-reap) + `spared` - adom-bridge wsl_recover '{"confirm":true}' # reap the candidates - adom-bridge wsl_recover '{"confirm":true,"sparePids":[1234]}' # also protect pid 1234 - ``` - It classifies each `wsl.exe`: childless + (0-CPU & old | runaway-CPU) → **candidate**; everything else → **spared**. It **spares any `wsl.exe` with child processes** (that's almost certainly the live distro, e.g. the Hydrogen `Adom-Workspace`), honors `sparePids`, and never touches `wslservice`/`wslhost`/`vmmem`. Review each candidate's `cmd` in the dry-run to confirm the live workspace isn't among them, then confirm. **Never** `wsl --shutdown` (it kills the live workspace too). Reaping childless orphans often cascades — their now-parentless children exit on their own (observed 114 → 18 from reaping 54). -3. **status wsl block** — `{wslProcessCount, wedged, _hint}` lets you see a pile-up *before* it blocks work. Path differs by transport: **`status.desktop.wsl`** via the CLI/relay (`adom-bridge status` — nested like `desktop.apps`), or top-level **`status.wsl`** on the local direct API (`GET /status`). `wedged:true` at/above `WSL_WARN_AT=50`. - -#### Long-running in-distro work + distro lifecycle — golden-image bakes (v1.8.161+) - -`wsl_exec` is **synchronous** — it waits for the command and is bounded by the relay's HTTP timeout (~60-120s), so a multi-minute bake (apt, a big download) EOFs. Backgrounding inside the distro (`nohup … &`) doesn't help either: the `wsl.exe` client returns, WSL sees no active session, shuts the distro down, and kills the bg process (wiping `/tmp`). For anything long, use the **async held-session** verbs — ab holds one `wsl.exe` in the foreground for the command's whole lifetime, so the distro stays up: - -- **`wsl_exec_async`** → returns a `jobId` + `outputPath` (a live UTF-8 log) immediately; the command runs detached for as long as it takes. -- **`wsl_job_status {jobId}`** → `{running, exitCode, elapsedMs, outputTail, outputPath}`. Poll until `running:false`; `pull_file` the `outputPath` for the full log. (`wsl_job_list`, `wsl_job_kill {jobId}` round it out.) - -```bash -# golden-image bake: import → run (async) → poll → export → unregister, all via ab -adom-bridge wsl_import '{"distro":"bake","installDir":"C:/Users/<you>/.adom/wsl/bake","tarball":"C:/.../base.tar"}' -# → {jobId}; poll wsl_job_status until done (exitCode 0) -B64=$(printf 'set -euo pipefail\napt-get update && apt-get install -y build-essential\n' | base64 -w0) -adom-bridge wsl_exec_async "{\"distro\":\"bake\",\"user\":\"root\",\"scriptB64\":\"$B64\"}" -adom-bridge wsl_job_status '{"jobId":"wsljob-xxxxxxxx"}' # repeat until running:false; outputTail = progress -adom-bridge wsl_export '{"distro":"bake","tarball":"C:/.../golden.tar"}' # poll, then pull_file the tarball -adom-bridge wsl_unregister '{"distro":"bake"}' # cleanup (REFUSES Adom-Workspace) -adom-bridge wsl_list # {distros:[{name,state,version,default}]} -``` - -**systemd user session — `viaRoot`.** On a `systemd=true` distro, `-u <user>` can fail "Failed to start the systemd user session for '<user>'". Pass `"viaRoot": true` to `wsl_exec_async` — ab runs `-u root -- runuser -u <user> -- bash -l`, dropping to the user WITHOUT depending on `[email protected]`. - -`wsl_import` / `wsl_export` / `wsl_unregister` are async (jobId + poll) too, so big imports/exports never EOF. All are **strictly scoped to the named distro** — there is no global `wsl --shutdown` path (that would kill the live Hydrogen workspace), and `wsl_unregister` refuses `Adom-Workspace`. Job ids live in ab's memory only (not persisted across an ab restart; the log file survives). The wedge defense above still applies — an over-cap spawn returns `wsl_wedged`. - -## Detecting App Installation - -After connecting, always run `adom-bridge status` to check what's installed. The `desktop.apps` object tells you exactly what the user has: - -```bash -adom-bridge status -# Look at the desktop.apps field in the response -``` - -### Handling "not installed" errors - -When a command returns `errorCode: "node_not_found"` (a bridge runtime ab manages: see `runtimes`), or a bridge reports its host app missing, guide the user through installation: - -**Picking the right native browser + profile (v1.7.1+):** - -When you need to open a URL in the user's NATIVE browser AND it matters which account is signed in (work Google Workspace vs personal Gmail vs media YouTube channel etc.), `desktop_open_url` alone isn't enough — you need to target a specific profile. The flow: - -```bash -# 1. Discover what's installed + which profiles are configured. -adom-bridge desktop_list_browsers '{}' -# → { -# "default": "chrome", -# "browsers": [ -# { "name": "chrome", "displayName": "Google Chrome", "version": "146...", -# "exePath": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", -# "profiles": [ -# { "id": "Default", "name": "Alice (work)", "gaia": "[email protected]", "isDefault": true }, -# { "id": "Profile 1", "name": "Personal", "gaia": "[email protected]" }, -# { "id": "Profile 2", "name": "Adom Media", "gaia": "[email protected]" } -# ] }, -# { "name": "edge", "profiles": [...] }, -# { "name": "firefox", "profiles": [{"id":"default-release","name":"default","isDefault":true}] } -# ] -# } - -# 2. Match the URL's context to the right profile, then open it there. -# E.g. opening a Google Doc shared by your work team → use the work profile: -adom-bridge desktop_open_url '{ - "url":"https://docs.google.com/document/d/...", - "browser":"chrome", - "profile":"Default" -}' -# Or YouTube channel management for the media account: -adom-bridge desktop_open_url '{ - "url":"https://studio.youtube.com", - "browser":"chrome", - "profile":"Profile 2" -}' -``` - -Profile-picking heuristics for Docker Claude: -- **Workspace / @adom.inc URLs** → match `profile.gaia.endsWith("@adom.inc")` and not the `media@` one → typically `Default`. -- **Personal Gmail / Drive / etc.** → match `profile.gaia === "[email protected]"` (or whatever the user's personal address resolves to). -- **YouTube Studio / channel-specific work** → match the media account profile. -- **Random scratch / experiments** → use the user's Edge profile, where they keep miscellaneous accounts (per the user's stated preference). -- **No clear match** → fall back to `browser:"default"` (no profile flag) and let the user pick. - -`profile` is optional. Omit it to open in whichever profile the browser was last using (legacy v1.6.x behavior). Profile flag is silently ignored when `browser:"default"` (no clean way to inject through the OS URL handler — name the browser explicitly to use it). - -### Decrypting the user's saved browser credentials — `desktop_decrypt_browser_credentials` (v1.9.220+) - -> **`reason` is REQUIRED here (v2.0.84)** and it is not for the log: the user reads it -> **on screen**, in a caption above every window, while deciding whether to hand over -> their saved passwords. Write it for them — say what you are trying to accomplish, not -> what the command does. Good: `"Signing the adom-you Chrome profile in as you so I can -> check your orders"`. Weak: `"decrypting credentials"` (that is just the verb again). -> -> **What the user sees before anything is decrypted (three surfaces, v2.0.84):** -> a caption above every window naming **which AI thread** asked, **your reason**, **what -> it wants** (passwords / cookies) and **from which browsers**, plus a warning that -> Windows is about to ask for administrator permission; ab's own consent dialog; and a -> Windows toast carrying the same Continue/Cancel. Any one of them can approve or -> decline. Expect a visible pause here — a human is reading, and if they were not -> expecting it they are being told to choose No. - -So pup can run an **"adom-you" Chrome profile signed in AS the user** — surfing the web on the user's behalf — it needs the user's real saved logins/cookies to seed that profile. Chrome 127+ encrypts these with **App-Bound Encryption** (the master key is DPAPI-wrapped under the SYSTEM account), so only an elevated helper can decrypt them. This verb is that path. - -```bash -adom-bridge desktop_decrypt_browser_credentials '{ - "reason": "Signing the adom-you Chrome profile in as you so I can check your orders", - "browsers": ["chrome", "edge"], # optional; default = all installed Chromium browsers - "include": ["passwords", "cookies"] # optional; default = both -}' -# → { "ok": true, -# "passwords": [ { "origin", "username", "password", "sourceBrowser", "sourceProfile", "sourceAccount", "lastUsed" }, ... ], -# "cookies": [ { "host", "name", "value", "path", "sourceBrowser", "sourceProfile", ... }, ... ], -# "stats": { "profilesRead", "passwordsOk", "passwordsFailed", "cookiesOk", "cookiesFailed" } } -``` - -**How to call it (this is not a normal verb — read this):** - -- **LOCAL / bridge callers only.** A relay (cloud-AI) call is refused unconditionally with `errorCode:"relay_forbidden"` and **pops no UAC** — a remote container must never be able to trigger a password-decrypt. pup (a loopback bridge) and the local CLI reach it. -- **Two human gates, in order:** (1) ab shows an in-window consent dialog + toast naming the requesting caller ("pup is requesting your saved browser passwords/cookies…"); decline → `errorCode:"consent_declined"`. (2) On continue, an elevated signed helper runs behind a **UAC prompt** ("Adom Industries, Inc."); cancel → `errorCode:"elevation_declined"`. The UAC elevation IS the user's permission for the decrypt — by design (John, wiki #457). -- **Plaintext is returned ONCE over the loopback API and is never written to disk or logged.** Don't echo the values into any other verb's args, any activity-log emit, or any file. Consume the rows, seed the profile/vault, drop them. -- **errorCodes:** `relay_forbidden` (you called it over the relay — you can't), `consent_declined`, `elevation_declined`, `helper_missing` (the signed helper isn't bundled — the install is broken). Per-item decrypt failures are counted in `stats.*Failed`, never fail the whole run (a managed-profile policy can block cookies; newest-Chrome `v20` blobs are best-effort if Google rotated the elevation-service key). -- **Pair it with `desktop_list_browsers`** (above) to show the user which browsers/profiles/accounts will be read before you ask. - -**Node.js not installed (the puppeteer bridge can't auto-spawn):** - -v1.7.0+ — any `pup_*` command returning `errorCode:"node_not_found"` means Node isn't on the desktop machine. Trigger the unattended winget install: - -```bash -adom-bridge desktop_install_node '{}' -# → runs `winget install --id OpenJS.NodeJS.LTS --silent ...` (Windows only). -# → ~1-2 min; downloads + installs the Node.js LTS line (well-tested with puppeteer). -# → returns {ok:true, exitCode:0, _hint:"Retry the original pup_* command"} on success. -# → on failure: {ok:false, errorCode:"winget_unavailable" | "winget_install_failed", _hint}. -``` - -After install, **just retry the original `pup_*` command** — the bridge picks up the new node.exe via the registry App Paths lookup (which winget writes), no Adom Bridge restart needed. v1.7.0+ also pre-fetches Node + Chrome for Testing on first launch after install (background task on AUTOSTART_VERSION 11), so most users will never see `node_not_found` to begin with. - -If `desktop_install_node` returns `errorCode:"winget_unavailable"` (older Windows or managed corporate device), fall back to manual: - -> Node.js LTS is required for browser automation. winget isn't available on this machine — download Node.js LTS from https://nodejs.org/ and run the installer. Let me know when it's done and I'll retry. - -**v1.7.0+ Node detection.** The bridge looks for `node.exe` in: -- System PATH (via `where node` on Windows, `which node` elsewhere) -- Windows registry App Paths (catches winget, MSIX, custom-dir installs) -- Standard install dirs: `C:\Program Files\nodejs\`, `C:\Program Files (x86)\nodejs\`, `%LOCALAPPDATA%\Programs\nodejs\` -- Per-user version managers: nvm-windows (`%APPDATA%\nvm\`), volta (`%LOCALAPPDATA%\Volta\bin\`), fnm (`%FNM_DIR%`) -- macOS Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`) - -If detection still misses an install on a user's machine, get the actual `node.exe` path from the user — that's a real bug to file. - -**A bridge's host app not installed or not running:** the bridge owns that story. `bridge_readiness {"name":"<bridge>"}` says `no-app` / `warming` / `ready` and its `_hint` says what to do; the bridge's own skill (`sync_skills`) has the install and launch flow. Do not hand the user a download link before asking the bridge: several can install their app unattended. - -## Troubleshooting - -### Check connection status - -Use `status` to see connected clients. A healthy connection shows one client from the user's hostname with a recent `lastPong` timestamp. - -### Stale connections causing timeouts - -Use `kick_all` to reset -- active Adom Bridge apps reconnect within seconds. - -### No desktop client connected - -1. Confirm the Adom Bridge app is running on the user's PC -2. Confirm it's pointed at the correct WebSocket URL -3. Check if port 8765 is exposed and reachable - -### Relay not running - -```bash -curl -sf http://127.0.0.1:8766/health -# If fails: adom-bridge serve --supervise -``` - -### Shell commands on Windows - -`shell_execute` runs in `cmd.exe`. Prefer writing scripts via `send_files` then executing them. Use the full Python path. - -## Building from Source - -```bash -cd cli && cargo build --release -# Binary at: cli/target/release/adom-bridge -``` - -## Repo - -[github.com/adom-inc/adom-bridge](https://github.com/adom-inc/adom-bridge) - -## Run wiki ops on the user's PC — `wiki_exec` (v1.9.116+) - -The ab installer bundles the `adom-wiki` CLI next to the app, and `wiki_exec` runs it ON THE PC — so version-controlling a local project (KiCad board, docs folder) on wiki.adom.inc needs NO per-file `pull_file` round trip to the container and there is no container/PC drift. - -```bash -adom-bridge wiki_exec '{"args":["repo","push","adom/my-board","--files","board.kicad_pcb","board.kicad_sch","-m","rev B"],"cwd":"C:\Users\me\boards\my-board"}' -``` - -- `args` = adom-wiki argv (any pillar: repo, pkg, release, discussion). `cwd` = PC-side project dir so `--files` are project-relative. `timeoutSec` optional (default 120, max 600). -- Returns `{success, exitCode, timedOut, stdout, stderr, exe, durationMs}`. -- Auth is automatic: ab injects the signed-in user's session token as `ADOM_WIKI_TOKEN` — the caller never handles credentials. -- Gated like `shell_execute` for relay callers (it writes to the wiki as the user). `errorCode:wiki_cli_missing` means the desktop ab predates 1.9.116. -- Container-side publishing of the CONTAINER's own files still uses your local `adom-wiki` directly; `wiki_exec` is for files that live on the PC. - -Note (v1.9.117): ab's Settings panel no longer has a KiCad section — bridges own their app-specific config (ab stays generic). `kicad_library_paths` / `project_watch` keep working from `~/.adom/config.json` and the `set_project_watch` / `trigger_project_watch` verbs. +---+name: adom-bridge+description: Use when the user wants to send files to their desktop, control KiCad or Fusion 360, send desktop notifications, or troubleshoot the desktop connection. Provides CLI tools for bridging Claude's Linux container (WSL2 on ah, cloud on hc) to the user's local machine.+---++# Adom Bridge++Bridge between Claude Code and the user's desktop applications via WebSocket. Claude runs in a Linux container: on ah, the Adom-Workspace WSL2 distro on the user's own PC (`/mnt/c` is its C: drive); on hc, a cloud container. Either way it reaches the desktop through ab. Run `adom-context` to see which.++> **The CLI is `adom-bridge`** (renamed from `adom-bridge-cli` in v2.0.33 — the `-cli` was noise on Linux). Use `adom-bridge <verb>`. The old `adom-bridge-cli` name still works (the pkg links both), so existing scripts don't break — but prefer `adom-bridge` going forward. On Windows the CLI file stays `adom-bridge-cli.exe` (the GUI app owns `adom-bridge.exe` there).++**Install surface:** the wiki page [`adom/adom-bridge`](https://wiki.adom.inc/adom/adom-bridge) hosts the Windows installer (a release download) and this package (the Linux CLI plus the `adom-bridge-*` skill set): `adom-wiki pkg install adom/adom-bridge`. `adom-bridge setup_desktop` also returns the installer URL.++**First-time setup?** If the user hasn't installed the desktop app yet, see the Setup section below to walk them through downloading, installing, and connecting the app.++Quick check if desktop is connected:+```bash+adom-bridge ping+```++**Bridge skills** (each bridge ships its own skills from its own wiki page; `adom-bridge sync_skills` installs them for every bridge the connected desktop has):+- KiCad bridge: launch editors, open designs, install libraries, run DRC, window capture + keyboard/click automation. `adom-wiki pkg install adom/kicad-bridge`+- Fusion 360 bridge: launch, open designs, STEP/GLB/.lbr import-export, BOM + API queries, Fusion screenshots. `adom-wiki pkg install adom/fusion-bridge`+- pup (the AI's own real Chrome): open URLs, screenshot, eval JS, multi-session profiles. `adom-wiki pkg install adom/pup-bridge`++## How It Works++```+Claude Code -> adom-bridge <command> -> Relay Server (HTTP :8766) -> WebSocket :8765 -> Adom Bridge App -> KiCad / Fusion 360 / Browser / Shell+```++The `adom-bridge` binary is a single Rust CLI that does everything:+- `adom-bridge serve` -- Start the relay server (WebSocket :8765 + HTTP API :8766)+- `adom-bridge <command> '<json>'` -- Send commands to the desktop app via the running relay++The relay server runs in Claude's container (the WSL2 distro on ah, the cloud container on hc). The Adom Bridge app runs on the user's PC and connects via WebSocket.++## Starting the Relay Server++The relay must be running before the desktop app can connect. **In a container, start it SUPERVISED** (v1.9.168+):++```bash+adom-bridge serve --supervise+```++**Why not `adom-bridge serve &`?** A backgrounded (`&`) relay is still a child of your shell/session. When your session is torn down (you finish, or the container reaps it), the relay dies with it — and then every connected desktop hits HTTP 500 on `/proxy/8765/` and loops "Reconnecting in 30s" **for hours** until a human restarts it (wiki issue #24, hit live 2026-07-23). `--supervise` **detaches the relay into its own session (reparented to init)** so it survives your session, and starts a watchdog that:+- restarts the relay with **exponential backoff** (2s → … → 5 min cap; a clean run resets it — a broken relay is never bludgeoned), and+- **toasts the connected desktops on every restart** with the reason from the relay log, so the owner finds out something went wrong.++```bash+adom-bridge serve --supervise # detached + self-healing (use this in containers)+adom-bridge serve --supervise --owner AdomLapper # send restart toasts to one desktop+adom-bridge serve --stop # stop the watchdog + relay+```++`serve` (plain, foreground) still works and prints a loud hint telling you it will die with the session — fine for a quick local relay you'll sit and watch. **Caveat:** a full CONTAINER REBOOT still needs a boot hook (cron `@reboot` / the container's init); the watchdog covers session teardown, not host reboot.++This starts:+- WebSocket server on `127.0.0.1:8765` (desktop app connects here; `--bind 0.0.0.0` / `ADOM_RELAY_BIND` to listen on the network, 2.1.170 wiki #225)+- HTTP API on `127.0.0.1:8766` (CLI commands go here)++Check if it's running:+```bash+curl -sf http://127.0.0.1:8766/health+```++## First-Time Setup (Install & Connect)++**You cannot click or type on the user's desktop yourself; ab is your hands. On ah you can still read and write the PC's files at `/mnt/c`, so check what you can before asking the user to.** Guide them step-by-step, ask questions, wait for answers, and verify each step.++> **As soon as you're connected, run `adom-bridge sync_skills` (v1.9.37+).** Installing the+> adom-bridge core pkg gives you only CORE skills — it does NOT give you a DYNAMIC/cloud+> bridge's skill (kicad, fusion, blender, any third-party). `sync_skills` runs `bridge_list`+> and installs each bridge's own skill pkg into `~/.claude/skills/`, so you can actually+> discover + drive whatever bridges THIS desktop has. (The CLI also runs this automatically+> once/day in the background, but doing it on connect makes the bridge skills available to+> the current session immediately.) Re-run it any time the desktop gains a bridge;+> `adom-wiki pkg update` keeps them fresh.++### Step 1: Check if already connected++```bash+adom-bridge ping+```++If this returns `{ "status": "connected" }`, the desktop is already set up -- skip to "Verify the connection" below. If it errors, continue.++### Step 2: Ensure the relay is running++```bash+curl -sf http://127.0.0.1:8766/health+```++If not running (in a container, ALWAYS use `--supervise` so it survives your session): `adom-bridge serve --supervise`++### Step 3: Ask what OS they use++Ask the user: **"What operating system is your desktop/laptop? (Windows, Mac, or Linux)"**++- **Windows 10/11** -- proceed+- **Linux** -- a private preview (AppImage, insiders tier, 2.1.175+). Installing it, keeping one copy running, updates, and what works there: the `adom-bridge-linux` skill. Inside a Grok Bot agent computer the machine is paused while its agent is idle; that skill says how to wake it.+- **macOS** -- "Support is coming soon."++### Step 4: Install & connect++Run `adom-bridge setup_desktop` — it auto-detects this container's relay URL and returns a ready-to-run **`register_command`**, a fallback `server_config` JSON, and the `installer_url`. Prefer the zero-paste path:++---++**Option 0: Zero-paste via your Adom account (PREFERRED)**++Run the **`register_command`** that `setup_desktop` returned (it's `adom-bridge register_container` with the name + URL filled in). That injects this connection into the **user's Adom cloud profile**. Then the user just:+1. Installs Adom Bridge (from `installer_url`) and opens it,+2. **Signs in with their Adom account** (Connections panel → "Sign in to Adom"),++and the connection appears in their list and auto-connects — **no JSON paste at all**. This is the modern path and the one to try first. (`register_container` needs an Adom cloud container — it uses the mounted account token — and returns `ok:false` on a non-Adom box; if so, use Option A/B below.)++**Already-running desktop? `server_sync` (v1.9.215+).** A signed-in ab reads the cloud profile at boot, at sign-in, and hourly — and `adom-bridge server_sync` (optionally `--target <name>`) makes it re-read RIGHT NOW. The merge is cloud-wins, whole entry including `authToken`. So the flow for adding a server or **rotating a relay token** on live desktops is: update the cloud key (`register_container` writes them) → `server_sync` per desktop → verify with `server_list`. Do NOT use `server_remove` + `server_add` for rotation — removing the entry you are talking through severs your own transport (wiki #387).++---++**Option A: Automatic setup via Claude Code (paste fallback)**++If you have **Claude Code** or **Claude Desktop** on your laptop, paste this prompt:++> Install Adom Bridge and connect it to my cloud container. (Most users instead get Adom Bridge bundled with Hydrogen, which installs and connects it for them; use this prompt when you are driving the desktop from a cloud container.) Download the installer from `<installer_url>`, run it silently, then write this JSON to `%USERPROFILE%\.adom\config.json`:+> ```json+> {"servers":[<server_config JSON>]}+> ```+> Then launch "Adom Bridge" from the Start Menu.++*(Replace `<installer_url>` and `<server_config JSON>` with the actual values from `setup_desktop`.)*++**Option B: Manual setup**++1. Download the installer: **[<installer filename>](<installer_url>)**+2. Run it and follow the prompts — the app installs to the Start Menu as "Adom Bridge"+3. Open **Adom Bridge** from the Start Menu+4. Paste this JSON into the text field that says **"Paste server JSON to add ..."** and press **Enter**:+ ```json+ <server_config JSON>+ ```+5. The server will appear and auto-connect++---++Wait for the user to confirm they see a green dot next to the server name before proceeding.++**Important:** Each container has its own relay server. New containers need a new entry -- old entries from previous containers won't work.++### Step 6: Verify the connection++```bash+adom-bridge ping+# Expected: { "echo": "pong", "roundTripMs": ..., "status": "connected" }++adom-bridge status+# Expected: one client with the user's hostname and capabilities++adom-bridge notify_user '{"title":"Hello from your workspace!","body":"Your desktop is connected."}'+```++Tell the user what you see. If `ping` succeeds:++> "Your desktop is connected! I can now send files to your machine, open browser windows for visual debugging, control KiCad and Fusion 360, take screenshots of your desktop, and send you notifications."++### Step 7: Optional -- Node.js for browser features++Ask: **"Would you like to use the visual browser debugging feature? This opens Chrome windows on your desktop that I can control."**++- **Yes + Node.js installed** -- "Great, the browser bridge auto-installs deps on first use."+- **Yes + no Node.js** -- "Download Node.js from https://nodejs.org (LTS), install it, then restart Adom Bridge."+- **No** -- skip, they can enable it later++### Common connection issues++| Symptom | Fix |+|---------|-----|+| `ping` returns `"No desktop client connected"` | User hasn't added this container in the desktop app yet |+| Desktop app shows "disconnected" | Check the URL uses `wss://` (not `ws://` or `https://`), port is `8765` |+| Relay server not running | `adom-bridge serve --supervise` (survives your session; see Starting the Relay Server) |+| Multiple stale connections | `adom-bridge kick_all` -- app auto-reconnects within seconds |+| Desktop app not installed | Download from [github.com/adom-inc/adom-bridge/releases](https://github.com/adom-inc/adom-bridge/releases) |++## CLI Tool++```bash+adom-bridge <command> '<json-args>'+```++**Examples:**+```bash+adom-bridge ping+adom-bridge status+adom-bridge desktop_list_windows+adom-bridge desktop_screenshot_screen '{"reason":"see what is on the user's screen"}'+adom-bridge commands '{"bridge":"kicad"}' # a bridge's own verb list (any installed bridge)+adom-bridge notify_user '{"title":"Hello","body":"From your workspace"}'+adom-bridge shell_execute '{"command":"echo hello"}'+adom-bridge pull_file '{"filePaths":["C:\\Users\\john\\Downloads\\image.png"],"saveTo":"/tmp"}'+```++**Output:** JSON to stdout. Screenshots save to `/tmp/adom-bridge-screenshots/` and return the file path.++**Config location:** The desktop app stores server config at `~/.adom/config.json` (`%USERPROFILE%\.adom\config.json`). This is separate from the binary — config survives updates and reinstalls.++## Available Commands++**Get the full structured command list with descriptions, args, and prerequisites:**+```bash+adom-bridge commands # aliases: list_commands, help+adom-bridge --target <name> commands # + THAT box's installed bridges (`bridges`, live: name, version, verb names)+adom-bridge commands '{"bridge":"kicad"}' # ONE bridge described by ITSELF (<prefix>describe, else its manifest verb list)+```+Returns categorized JSON with every ab-core command, its required/optional args, return values, prerequisites, and workflow notes, plus `bridges` read live from the box. Run this FIRST when you're unsure what a verb is called. Bridge verbs are never catalogued inside ab (2.1.144, wiki #211: the hand-written copy drifted); the bridge describes them, and its skill (`sync_skills`) is the how-to.++**Two kinds of verbs (v1.9.103 — don't confuse them):**+- **BUILT-IN ab-core verbs** are always available on every Adom Bridge, no install, NOT bridges. This is where screenshots, windows, input, files, registry, shell, tunnel, update, and notify live. A whole-screen shot is `desktop_screenshot_screen`; one window (with chrome) is `desktop_screenshot_window {"hwnd":N}` or `{"titleContains":"..."}` (get hwnds from `desktop_list_windows`).+- **BRIDGE verbs** (`kicad_`/`fusion_`/`pup_`/`blender_`/`nbrowser_`…) need that bridge installed — see `bridges` in `commands`, or run `bridge_list`.++If a verb returns `errorCode:"unknown_verb"`, **read the error's `_hint`** — it carries a fuzzy "did you mean" (e.g. `desktop_screenshot` → `desktop_screenshot_screen`) and tells you whether the right verb is built-in or a bridge. Don't conclude a capability is "a missing bridge" from a guessed verb name; call `commands` and check.++### Connection Management++- `ping` -- 5s round-trip test. Use BEFORE browser/shell commands to verify the desktop connection is alive.+- `status` -- Check who's connected, their capabilities, desktop paths, and **app installation status**. The `desktop.apps` object shows:+ - `kicad.installed` / `kicad.version` / `kicad.bridgeRunning`+ - `fusion360.installed` / `fusion360.running` / `fusion360.bridgeRunning` / `fusion360.addinInstalled` / `fusion360.addinConnected`+ - `browser.bridgeRunning`+- `kick_all` -- Force-disconnect all WebSocket clients. Active Adom Bridge apps auto-reconnect within seconds.++#### Programmatic server registration (v1.8.22+)++Three verbs let external apps (Hydrogen's WSL2 workspace, in particular) register their relay server with adom-bridge without the user pasting JSON into the GUI by hand. Mirrors the GUI's Quick Add bar + connect/disconnect buttons.++- `server_add` -- Upsert a relay server by `name`. If a server with that name already exists, the URL (and optionally authToken) is updated rather than creating a duplicate.+ ```bash+ adom-bridge server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'+ # → {ok:true, name, url, id, connected:bool, created:bool, _hint}+ ```+ - `name` (required) — dedup key. Repeat calls with the same name are idempotent.+ - `url` (required) — relay WebSocket URL.+ - `authToken` (optional) — defaults to a dev token (leave unset unless your relay requires one).+ - `autoConnect` (optional, default `true`) — connect right after upsert. Set `false` to add the entry without dialling out.+ - Behavior: same URL + already connected → no churn, returns ok. URL changed + autoConnect=true → disconnect old loop + spawn new one. URL changed + autoConnect=false → disconnect old, leave entry registered but not connected.+- `server_remove` -- Disconnect (if connected) and delete an entry by name. Returns `{ok, removed:bool, wasConnected:bool}`. Idempotent (removing a non-existent entry returns ok with removed:false).+ ```bash+ adom-bridge server_remove '{"name":"hydrogen-workspace"}'+ ```+- `server_list` -- Persisted server list with live connection status: `{servers:[{name, url, id, autoConnect, enabled, connected:bool, clientCount:0|1, status}]}`. `status` is the fine-grained `connected | reconnecting | disconnected` state machine value.++Persists to the same `~/.adom/config.json` the GUI uses, so entries survive a GUI restart. The `ws_client` supervisor (runs every 30s inside the GUI) auto-reconnects entries with `autoConnect:true` on next launch.++Hydrogen-style usage:+```bash+# At Hydrogen container startup+adom-bridge server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'++# At Hydrogen container shutdown+adom-bridge server_remove '{"name":"hydrogen-workspace"}'+```++#### Addressing multiple Adom Bridges on one relay (v1.8.133+)++One relay can have **several** Adom Bridges connected at once — e.g. a laptop and an Azure VM, eventually many. Each ab has a **name** (its `--client-name` / Settings value, defaulting to the OS hostname). The cloud CLI addresses a specific one, or fans out to all:++- `adom-bridge targets` — list connected ABs: `{count, targets:[{name, hostname, version, os, connectedAt}]}`. Discover names here first.+- `adom-bridge --target <name> <verb>` — route the verb to that ab only. `--target` is a **global** flag (goes before the verb; case-insensitive; matches `clientName`, else `hostname`). `ADOM_TARGET=<name>` sets a default (the flag wins).+- `adom-bridge --target all <verb>` — fan out to **every** connected ab; returns `{broadcast:true, targets:[...], results:{<name>:<verb-result>}, summary:{total,ok,failed}}`. A per-ab timeout/failure is isolated to that ab's entry.++```bash+adom-bridge targets+adom-bridge --target azurevm desktop_screenshot_screen+adom-bridge --target all hd_status # every box at once, results keyed by name+```++**No-target behavior:** with exactly ONE ab connected, no `--target` is needed (back-compat). With MULTIPLE connected and no `--target`, the call FAILS with `{error:"ambiguous_target", connected:[...], _hint}` (exit 1) — it won't guess which box. A bad name → `{error:"target_not_found", requested, connected:[...]}` (exit 1). Set each ab's name with the `--client-name <name>` launch flag (Hydrogen passes this when spawning the embedded ab), the Settings field, or `config.json` `client_name`. Full design + the relay-routing internals: [`skills/MULTI_AB_ADDRESSING.md`](MULTI_AB_ADDRESSING.md).++#### Direct HTTP API for sibling apps (v1.8.25+)++If you're authoring a sibling Tauri app on the same machine (Hydrogen, or any future "Adom-family" app), you can skip the CLI binary entirely and POST commands straight into the running adom-bridge GUI on `127.0.0.1:47200` (was `127.0.0.1:8770` through v1.8.32 — moved to be a better neighbor to Hydrogen on 47080+ and avoid the 8000-range collision risk). The endpoint runs inside the GUI process (loopback-only bind), uses the same dispatcher the WS path uses, and returns the same JSON shape as `adom-bridge <verb>` — including every `_hint` field.++| Method | Path | Body | Returns |+|---|---|---|---|+| `GET` | `/health` | — | `{"ok":true,"service":"adom-bridge"}` cheap probe |+| `GET` | `/status` | — | service banner + version + schema + `directApi.cliRequired` list |+| `POST` | `/command` | `{"app":"<ns>","command":"<verb>","args":{...}}` | The verb's normal payload (200 OK), or `{error, errorCode, _hint}` (4xx/5xx) |++**v1.8.33+ port discovery (sibling apps READ THIS):** Don't hardcode 47200. The GUI may have bound to 47200-47209 instead if the default port was taken (zombie socket, dev instance, third-party collision). Discovery protocol:++1. Read `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`) — single-line `host:port`, written by the GUI at bind time, removed at graceful shutdown+2. If file missing or its port doesn't `/health`, scan `127.0.0.1:47200..=47209` for any port answering with `{"ok":true,"service":"adom-bridge"}`+3. Validate `service == "adom-bridge"` to disambiguate from other apps that might bind a port in our range++The CLI does this automatically (see `cli/src/direct_probe.rs`). For sibling-app Rust code, see [`skills/DIRECT_API.md`](DIRECT_API.md) for a copy-paste-ready helper.++**Per-verb timeouts:** a bridge verb's budget is the bridge's own `bridge.json` `timeouts` block (exact bare verb, then prefix rule, then its default), applied by ab and, since 2.1.146, by the CLI's wait as well (ab keeps no table of any bridge's verbs, wiki #212). `timeouts` shows every installed bridge's declared block. Caller can override via `args.timeout` (seconds, clamped to 1800).++Example — programmatic server registration without the CLI:++```bash+curl -X POST http://127.0.0.1:47200/command \+ -H 'Content-Type: application/json' \+ -d '{"app":"desktop","command":"server_add","args":{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}}'+# → identical JSON to `adom-bridge server_add '{...}'`, including the _hint+```++**What's safe to send directly:** essentially everything sync (`server_*`, `bridge_list`, `hd_status`, `hd_build_status`, `desktop_list_windows`, `desktop_screenshot_*`, `kicad_*`, `fusion_*`, `pup_screenshot`, `notify_user`, ...) plus async-dispatching verbs that return a job id in <500 ms (`bridge_install`, `hd_build`, `desktop_install_kicad`, ...).++**What requires the CLI fallback:** verbs returning a structured `errorCode:"cli_required"` from this endpoint — currently `pull_file`, `send_files`, `shell_execute`. These use binary streaming or multi-minute approval polling that doesn't fit a single synchronous HTTP request. `GET /status` returns the full list at runtime so callers can branch defensively.++Full integration guide (loopback trust model, recipes for Hydrogen startup/shutdown, retry/backoff patterns, port-discovery code in Rust + TypeScript) lives in [`skills/DIRECT_API.md`](DIRECT_API.md) in this repo, and as a file on the `adom/adom-bridge` wiki page.++#### Embedded mode — ab bundled inside Hydrogen (v1.8.42+)++When Hydrogen bundles ab, ab enters "embedded mode" at boot. Hydrogen owns the system-tray icon (ab's is suppressed), Hydrogen owns the Adom Cloud login (Hydrogen's session token is handed to ab via Phase 4's existing channels), Hydrogen owns auto-updates (ab's wiki-poll is disabled — Hydrogen bundles a fresh ab on every Hydrogen release), and Hydrogen spawns ab hidden (its tray "Open Adom Bridge" surfaces the window on demand). Standalone ab users see zero change — none of the signals fire.++**Detection** — three-signal cascade (any one triggers embedded mode):++1. `--embedded` CLI flag (what Hydrogen passes on every spawn)+2. `ADOM_EMBEDDED=1` env var (backup channel)+3. `%LOCALAPPDATA%\Adom Bridge\embedded.json` marker file (survives ab restarts; written by Hydrogen's installer; deleted by Hydrogen's uninstaller)++**CLI flags Hydrogen passes when spawning ab**:++```bash+adom-bridge.exe --embedded --start-hidden \+ --relay-url ws://127.0.0.1:8765 --relay-name hydrogen-desktop \+ --session-token <hd's-stored-token>+```++- `--embedded` → enter embedded mode (also writes the marker)+- `--start-hidden` → boot with main window invisible. **v1.8.119+: honored in embedded mode** (Hydrogen owns the surface; ab stays in the tray and opens via `desktop_window_show`). v1.8.102–118 had a bug where embedded mode force-showed the window even with `--start-hidden`; that's fixed. The machine owner can still flip ab's "Always show the window at launch" Settings checkbox (config `always_show_window_on_launch`, default OFF) to override and always show — a personal-dev-box preference, not the product default.+- `--shell-approval <prompt|1h|24h|permanent>` → Hydrogen's passthrough of its user's shell-approval choice (v1.8.116+, session-only)+- `--relay-url <url>` → upsert this relay via existing `server_add` dedup; default name `hydrogen-desktop`+- `--relay-name <name>` → override the default name+- `--session-token <tok>` → already covered by Phase 4 handoff (env / CLI arg / file)++**Docker introspection** — `adom-bridge desktop_embedded_status` returns `{embedded, owner, source, enteredVia, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` so cloud-side callers can branch on whether ab is standalone or Hydrogen-managed. **v1.8.73+:** `enteredVia` reports `"launch-flag"` (Hydrogen spawned ab as a child via `--embedded`) or `"runtime-adopt"` (ab was running standalone; Hydrogen adopted it at runtime) — see "Runtime adopt + release" below.++**New verbs Hydrogen uses to drive ab** (all also usable standalone):++| Verb | Purpose |+|---|---|+| `desktop_window_show` | Bring ab's main window to foreground |+| `desktop_window_hide` | Hide ab's main window (app keeps running) |+| `desktop_connect_all` | Spawn ws_loop for every enabled server |+| `desktop_disconnect_all` | Drop every live WS connection (entries stay in config) |+| `desktop_shutdown` | Explicit ab termination (stop bridges, then exit) |+| `desktop_embedded_status` | Introspect current embedded state (incl. `enteredVia`) |+| `desktop_embedded_enter` | **v1.8.73+** runtime adopt: standalone → embedded as `runtime-adopt` |+| `desktop_embedded_release` | **v1.8.73+** revert to standalone. **v1.8.77+: always reverts, never exits ab** (regardless of `entered_via`) — use `desktop_shutdown` for explicit exit |+| `desktop_logout` | Clear ab's `~/.adom/session.json` (Hydrogen's sign-out propagation) |++#### Runtime adopt + release (v1.8.73+) — the LaunchFlag-vs-RuntimeAdopt distinction++Hydrogen has two ways to put ab into embedded mode:++1. **Spawn ab with `--embedded`** (LaunchFlag) — Hydrogen launches a fresh ab process as its child. ab enters embedded with `entered_via=launch-flag`. When Hydrogen subsequently dies ungracefully (without calling `/embedded/release` or `desktop_shutdown` first), ab's 60s `embedded_monitor` tick notices Hydrogen's control port is dead and **exits ab** (the "no hanging chad" rule — no tray-less orphan).+2. **Adopt at runtime via `POST /embedded/enter`** (RuntimeAdopt) — ab was already running standalone; Hydrogen calls the direct-API endpoint to flip ab into embedded mode without a respawn. ab enters embedded with `entered_via=runtime-adopt`, preserving its open state, current PID, all in-flight connections. When Hydrogen subsequently dies ungracefully, ab's monitor **reverts ab to standalone** — tray restored, footer pill hidden, marker deleted. ab process stays alive.++The rule is simple: **whoever started ab owns ab's death**. If ab started itself (standalone → runtime adopt), Hydrogen's death doesn't take ab down with it.++**Two new direct-API endpoints** (v1.8.73+, sugar over `POST /command`):++```bash+# Adopt — runs the same handler as desktop_embedded_enter:+curl -s -X POST http://127.0.0.1:47200/embedded/enter++# Release — runs the same handler as desktop_embedded_release.+# v1.8.77+: ALWAYS reverts to standalone. ab never exits via this call.+curl -s -X POST http://127.0.0.1:47200/embedded/release+```++**Probe target (v1.8.75+):** the 60s `embedded_monitor` checks Hydrogen's *control port* (default `47084`, discovered from `%APPDATA%\hydrogen-desktop\ports.json` key `control`), not Hydrogen's legacy `:9001` webview-eval port. Same discovery `hd_api` uses. Pre-v1.8.75 this was hardcoded to `:9001` — Hydrogen doesn't bind that port anymore, so the probe always reported Hydrogen dead and every embedded ab reverted/exited within 60s of Hydrogen starting.++**Live frontend update (v1.8.76+):** the "Embedded · Hydrogen" footer pill responds to Tauri `mode-changed` events emitted on every transition. So if ab's window is open when Hydrogen adopts it (or Hydrogen releases it), the pill flips immediately — no page reload, no manual refresh.++**Stale-marker safety (v1.8.74+):** if the marker file says `entered_via=launch-flag` AND Hydrogen's control port isn't responding at boot, ab deletes the marker BEFORE exiting. Without the delete, every subsequent manual launch (no `--embedded` flag) would hit the same exit branch and ab would be unlaunchable until the user manually rm'd the marker. The delete-before-exit makes the next launch start standalone normally.++**Shell auto-approve is a user preference** (v1.8.116+ — replaces the v1.8.44 embedded-permanent default). The safe default everywhere is per-command approval. Resolution order at ab boot:++1. `--shell-approval <prompt|1h|24h|permanent>` launch flag — Hydrogen's passthrough of its user's choice when spawning ab embedded. **Session-only**: never persisted by ab.+2. The durable Settings checkbox ("Auto-approve shell commands permanently", config field `shell_auto_approve_permanent`, default OFF). Also set/cleared via `shell_auto_approve '{"permanent": true|false}'`.+3. A restored timed grant from a previous session (+1h/+24h buttons, `duration_secs` calls).+4. Otherwise: prompt per command.++In embedded mode **Hydrogen owns the preference** (Hydrogen is the parent, ab the child): ab's Settings checkbox goes read-only with "Managed by Hydrogen", and the footer shows "**Permanent** *(managed by Hydrogen)*" when armed. Hydrogen changes it at runtime with the same verb:++```bash+# revoke everything (timed + permanent + the saved checkbox):+adom-bridge shell_auto_approve '{"duration_secs": 0}'++# set the durable preference (equivalent to ticking the Settings checkbox):+adom-bridge shell_auto_approve '{"permanent": true}'+```++Migration note: pre-1.8.116 embedded boots persisted a permanent grant that leaked into standalone sessions. On first v1.8.116 boot, a permanent-class grant on disk WITHOUT the config preference is cleared — re-opt-in via the checkbox or the verb if you actually wanted it.++**⚠ v1.9.90 — a cloud/relay AI CANNOT grant shell approval itself.** The `shell_auto_approve` examples above are Hydrogen's (a trusted LOCAL caller) or the human's own. From a **relay** connection (the cloud AI), a GRANT (`shell_auto_approve {"permanent":true}` / `{"duration_secs":N>0}`) is REFUSED with `errorCode:"human_only"` and instead POPS the human approval prompt in Adom Bridge. **The AI's path: call `request_shell_approval` (or just let the gate/refusal auto-surface the prompt) → ask the user to click Approve 1 hr / 24 hr / Bypass Permissions → RETRY your original verb.** Only a human clicking in ab (or Hydrogen proxying its own user) grants. A REVOKE (`duration_secs:0`) is allowed for the AI.++#### Bridge ports are dynamic — you never need to know one (v1.8.31+)++KiCad, Fusion, Browser/Puppeteer, and all third-party bridges now bind OS-assigned ephemeral ports (not the legacy 8772/8773/8851 you may remember). The runtime port changes every spawn. **Callers never need to know it.**++- The CLI verb namespace (`kicad_*`, `fusion_*`, `pup_*`, plus any third-party `<bridge>_*`) is the contract. Always go through that.+- adom-bridge's direct API forwards to whatever port each bridge is on at the moment of the call.+- `bridge_list` reports `spawn.runtimePort` per bridge for debugging — do NOT hardcode it anywhere.+- Why this changed: Hydrogen's bridges also wanted 8772/8773/8851; we used to collide silently. Dynamic ports = clean coexistence.++### Managing bridges (the AI fully controls bridges on its own)++Bridges are ab's plugins (kicad, puppeteer, fusion360 ship bundled; others install from the wiki). You — the AI — can see, update, install, remove, pause, and resume them entirely through verbs. No human step is required.++| Verb | What it does |+|---|---|+| `bridge_list` | Every installed bridge: `name`, `displayName`, `version` (what ab runs now), `source` (bundled / cache / third_party), `paused`, spawn kind, verbPrefixes. Start here. |+| `bridge_check_updates` | **READ-ONLY.** Per bridge: `current` vs `latest` (wiki) + `updateAvailable`. Downloads nothing. The "what's behind?" probe. |+| `refresh_bridges` | Apply updates. No args = sync ALL bridges from the wiki. `{"name":"kicad"}` = update just one. |+| `bridge_install` | Install a new third-party bridge from a wiki manifest URL (`{"manifestUrl":"…/<bridge>-bridge-manifest.json"}`). On `success:true` the **capsule appears in the ab GUI immediately** (no verb call needed; it's gray/idle until first use, then green). On `success:false` **nothing is registered and no capsule appears** — read `error` (a `read zip body`/`builder error` means the manifest's zip URL couldn't be fetched). Don't treat a parsed manifest as a completed install; confirm with `bridge_list`. The manifest `url` may be relative (resolved against the manifest's dir, v1.8.169+). |+| `bridge_uninstall` | Remove a bridge (`{"name":"…"}`). Bundled ones revert to the bundled copy; third-party ones are fully removed. |+| `bridge_pause` / `bridge_resume` | Flip a routing flag so a bridge's verbs fail-fast with `bridge_paused` (or resume). Does NOT kill the process. |+| `bridge_dev_mode` | **The DEV PIN** (`{"name":"kicad","on":true}`): freeze that ONE bridge against ALL automatic updates (the 4h sweep, on-launch sync, on-use auto-update, refresh_bridges) so a developer can modify its installed cache without ab pulling the release over their work. `on:false` releases it. `bridge_list` shows `devPinned:true` and the ab GUI card shows an amber "dev pin" badge with a one-click Unpin. **Etiquette: if a bridge shows `devPinned:true`, a human is mid-development on it - never unpin or force-update it to "get the latest" without asking them.** If YOU are about to edit a bridge's installed files, pin it FIRST. |++**Naming convention for published bridges (3rd-party authors, read this):** when you publish a bridge to the Adom wiki, give its **page title** the form the live bridge pages use: **`<Tool> - the <Tool> Bridge`** (spaced hyphen), e.g. `KiCad - the KiCad Bridge`, `Fusion - the Fusion 360 Bridge`, `Altium - the Altium Bridge` (pup, which is a browser rather than a tool bridge, is `Pup - the AI's own browser`). It applies to the **wiki/catalog title only**, NOT `bridge.json` `displayName` (the in-app chip label, which stays tool-descriptive like `Altium Designer`) or `name` (the lowercase slug). Full authoring walkthrough: the `adom-bridge-sdk` skill (ships in this package) and its `adom-bridge-sdk-*` sub-skills.++**Typical "am I on the latest bridges?" flow:**+```bash+adom-bridge bridge_check_updates # read-only: see current vs wiki per bridge+# → {bridges:[{name,current,latest,updateAvailable,source,wikiReachable}], updatesAvailable:N}+adom-bridge refresh_bridges # update ALL that are behind+adom-bridge refresh_bridges '{"name":"kicad"}' # or just one+```++**Version semantics (so the numbers make sense):**+- `current` = what ab actually runs = the newer of the writable cache and the bundled copy (version-aware, numeric semver — `1.0.10 > 1.0.9`).+- A bundled bridge that ships AHEAD of the wiki shows `updateAvailable:false` — ab correctly keeps the newer bundled copy. **puppeteer is bundled-only** (its node_modules deps exceed the wiki file limit), so it never shows a wiki update; it updates with the ab installer.+- After an `updated` action, the bridge SERVER PROCESS still holds the old code in memory until it respawns. `is_bridge_running` is version-aware (v1.8.142+) and forces a respawn on the next verb call, so you normally don't need to kill anything — but if you want it instant: `taskkill /f /im python.exe` (kicad/fusion) or `node.exe` (puppeteer).++**Auto-update cadence:** a running ab also auto-syncs bridges on launch + every 4 hours. `bridge_check_updates` / `refresh_bridges` (or the GUI's **"Check for updates"** button next to "+ Bridge") are the on-demand "I just published, pick it up now" path.++#### Bridge lifecycle — one instance, stable port (v1.8.153+)++A dynamic bridge gets **one stable port** that ab reuses across every spawn and across ab restarts (persisted in `~/.adom/bridge-ports.json`). A long-lived external client (e.g. a browser extension that dials the bridge) keeps reaching the same port — no chasing a fresh ephemeral port each cycle, no "the client is on bridge A but ab routes to bridge B" split. ab enforces a **single instance**: it tracks every spawned bridge PID, reaps a stale/wedged one before respawning on the same port, and `bridge_stop_all` reaps orphans too.++`spawn.persistent` (in `bridge.json`) is now surfaced in `bridge_list` (`spawn.persistent`) and means: **ab auto-respawns this bridge on death**, on its stable port (a supervisor runs each ~15 s). So "kill the bridge to load new code" → ab brings it right back at the same port the client keeps dialing. `bridge_install` / `refresh_bridges` also no longer fail with the cache-dir lock (ab reaps the process holding the dir as its cwd, then swaps; in-place merge fallback if a handle lingers).++**v1.8.186/187 — first-class bridges (panel + remote management + optional manifest fields).** The ab window has a full-height **Bridges sidebar** that shows each bridge as a mini-app (hero image, LED, rich tooltip); clicking one opens a wide pop-out with a **live console** (its `~/.adom/bridge-logs/<name>.log` + the per-bridge command history), a **ports/process debug view** (live PIDs — `>1` flags a runaway), persistent-vs-one-off + codebase-language badges, and all management actions (install/uninstall/**kill**/restart/pause/resume + "filter activity log to this bridge" + "copy contribution prompt"). The same management is available **remotely** to cloud/WSL2 threads via verbs: `bridge_info`/`bridge_detail` (rich aggregate), `bridge_log_read {name, sinceOffset?}` (**cursor-streaming remote tail -f**), `bridge_kill` (wipe one bridge's processes). As of v1.8.186 **all** bridges write their per-bridge log (bundled kicad/fusion360/puppeteer included, not just dynamic ones). New **optional** `bridge.json` fields (all back-compat, surfaced in the panel): `hero` (a relative filename on your wiki page, or a full URL — your app-icon; absent → a monogram tile), `languages` (override the auto-scan, e.g. `["Python"]`), `releasedAt` (ISO date; absent → bridge.json mtime).++**v1.8.156 — strays + self-documenting hints.** ab now tracks every bridge PID it spawns (persisted across ab restarts) and **reaps all stray instances** before respawning — so you don't end up with 4 copies of one bridge fighting over the routing. `bridge_list` surfaces `spawn.instanceCount` per dynamic bridge (normally 0 or 1; **>1 = strays piled up** — visible at a glance). If a bridge verb fails to reach a healthy bridge, the error `_hint` tells you to retry / check `instanceCount` / `process_list`; and a verb response right after a (re)spawn carries an `_ad_note` saying the external host reconnects within a few seconds — **retry** instead of treating a transient "not connected" as terminal. For verb discovery from a bridge, `GET <ADOM_DIRECT_API_URL>/commands` lists every reachable verb (see [`skills/DIRECT_API.md`](DIRECT_API.md)).++#### Bridge status chip — self-reported LED + tooltip (v1.8.152+)++Each bridge has a status dot in the ab GUI sidebar. **ab is a pure renderer of bridge health** — a live bridge decides its own LED color and hover text; ab just paints what the bridge reports. This replaced the old scheme where ab inferred the LED from whether it was holding a spawn handle, which left fire-and-forget (dynamic/installed) bridges stuck RED even while healthy and routing.++**The contract (all three fields OPTIONAL).** On the endpoint ab already polls — your `bridge.json` `healthEndpoint` (default `/health`; the sample bridges use `/status`) — return a 2xx JSON body that MAY include:++| Field | Type | Meaning |+|---|---|---|+| `led` | `"green"` \| `"yellow"` \| `"red"` | The dot color. Green = healthy; yellow = degraded/warning; red = the bridge itself knows it's broken (e.g. lost its upstream). |+| `summary` | string | Short label shown next to the dot. Keep it tiny (≤ ~24 chars) — e.g. `"2 windows"`, `"ext disconnected"`. |+| `tooltip` | string | Full hover text. Newlines allowed; no hard length limit (keep it readable). |++**Rules ab enforces:**+- Endpoint returns 2xx **with** `led`/`summary`/`tooltip` → rendered **verbatim**, no parsing or logic.+- Endpoint returns 2xx **without** them → ab defaults to **green** (you're reachable) + a generic tooltip. Existing bridges work unchanged — this is opt-in.+- Endpoint **unreachable** → ab owns this ONE state: a **gray/offline** dot + ab's own tooltip ("Not running" / "Bridge not reachable"). A dead bridge can't self-report, so ab never lets a bridge claim "offline" — it detects it.++**Cadence:** ab re-polls every ~15 s, and a dynamic bridge flips green the instant it spawns. So a bridge that surfaces live counters (open windows, queue depth, ext-connection state) in `summary`/`tooltip` updates in the GUI within a tick.++Reference implementations: [`scripts/sample-bridges/hello-python/server.py`](../scripts/sample-bridges/hello-python/server.py) and [`hello-rust/src/main.rs`](../scripts/sample-bridges/hello-rust/src/main.rs) both self-report `led`/`summary`/`tooltip` on `/status` — fork either.++### File Transfer++- `send_files` -- Send files from the Docker container to the desktop. Files are base64-encoded in transit.+ - `filePaths`: array of absolute paths on the server+ - `targetApp`: "kicad", "fusion360", or "general"+ - `dest`: **absolute** Windows path (under a safe root) to land the files exactly where you want (v1.8.148+). Prefer this when you know the target dir. A folder is created if missing. For ONE file, `dest` may be the full file path instead. A `dest` ending in a slash or in `.pretty` / `.3dshapes` / `.app` / `.bundle` / `.framework` is always a folder (2.1.163+), so a KiCad footprint library works directly: `dest: "C:/Users/<you>/Documents/proj/lib/mylib.pretty"`.+ - `destIsFolder` (2.1.163+): `true` forces `dest` to be a folder for any other dotted folder name; `false` forces it to be the file path.+ - `destinationFolder`: **relative subfolder only** (e.g. `"kicad/symbols"`, `"fusion"`). The desktop app controls the base directory. **Absolute paths are rejected.** With neither `dest` nor `destinationFolder`, files land in the laptop's **Downloads** folder.+ - Returns `destinationPaths[]` with the absolute path of every saved file, plus (v1.8.168) `savedToDir` (the folder) and a rich `_hint`. **The files land on the LAPTOP (Windows host), not in your container** — verify with `list_dir`, pull one back with `pull_file`.+ - **`send_files` only COPIES bytes — it does NOT install or register a bridge.** Dropping bridge files with `send_files` will NOT make a capsule appear or verbs route. To add a bridge use `bridge_install '{"manifestUrl":"…/<bridge>-bridge-manifest.json"}'`, then confirm with `bridge_list`.++- `pull_file` -- Pull files from the desktop to the container.+ - `filePaths`: array of absolute Windows paths on the desktop+ - `saveTo`: directory on the container to save files (default: `/tmp`)+ - **Streaming since v1.4.3.** Each file is transferred as 1 MiB binary WS frames straight to disk on the Docker side, with incremental SHA256 verification. The legacy 30s base64-JSON path is gone — large files (50 MB+ datasheets, 75 MB reference manuals) no longer time out. Per-file timeout is 600s.+ - Returns `files: [{name, path, size, sha256, chunks}]`. Use `sha256` to verify the transfer (the desktop side computes it during streaming and the container side verifies on completion; mismatch deletes the partial file and reports failure). `chunks` is the count of 1 MiB binary frames received.+ - When at least one but not all files succeed: `success` is `true`, `errors[]` lists the failures alongside `files[]`. When ALL fail: `success` is `false`.++### Desktop Notifications++- `notify_user` -- Send a native Windows toast (own AUMID + Adom logo). v1.9.33: rich types — progress bar, sticky reminder, action buttons that **bring ab to the foreground** on click + round-trip via `notify_response`.+ - `title`, `body`, `level` (info/success/warning/error/emergency), `actions` (label buttons) — plus `scenario` ("reminder"=sticky), `durationLong`, `buttons` (`[{label, arguments?}]`), `progress` (`{title, value 0..1, status, valueOverride}`), `inputs`, `id` (correlation id for the round-trip).+ - Full guide + copy-paste examples per type: the **adom-bridge-notify** skill ([`skills/NOTIFY.md`](NOTIFY.md)).+- `notify_response` -- Poll which toast button the user clicked, by correlation `id`. `{pending:true}` until they click; `{pending:false, action}` once they do (consumed on read).++### KiCad (`kicad_*`) — bridge-owned; light pointer++The KiCad bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/kicad-bridge`). This page keeps only the pointer: run `adom-bridge sync_skills` after connecting and the full KiCad guide (project workflows, three-tier `kicad_open_*` routing, DRC recipes, library install) lands in `~/.claude/skills/`.++Its verbs: `adom-bridge commands '{"bridge":"kicad"}'` (the bridge describes itself).++If KiCad is not installed, the bridge knows how to install it for the user — offer, never point them at a download page. Full details: the bridge's own skill.++### Fusion 360 (`fusion_*`) — bridge-owned; light pointer++The Fusion 360 bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/fusion-bridge`). Run `adom-bridge sync_skills` and the full guide (cloud files, import/export, add-in troubleshooting, APS search) lands in `~/.claude/skills/`.++Its verbs: `adom-bridge commands '{"bridge":"fusion360"}'`.++Respect `timeoutSeconds` + `statusVerb` on responses instead of re-issuing a slow verb. Full details: the bridge's own skill.++### Launching apps (generic)++Two generic CLI commands exist for launching any Windows executable safely — they verify the target exists BEFORE handing it to the OS, so you never trigger a "Windows cannot find" GUI dialog:++- `adom-bridge find_exe '{"name":"..."}'` -- Resolve an exe by absolute path, glob (e.g. `C:/.../webdeploy/production/*/Fusion360.exe` returns newest), bare name (searches PATH + Start Menu `.lnk` targets). Returns `{path, source}`. Does NOT launch.+- `adom-bridge launch '{"path":"...", "args":[...], "cwd":"...", "detached":true, "reason":"..."}'` -- Same resolution rules as `find_exe`, then spawns. Fails in terminal (exit 1) with a clear error if the path doesn't exist. **Always prefer this over raw `start`.**++**Where they run (2.1.143, wiki #210).** With `--target <box>`, or from a container / Linux CLI, both verbs run ON THAT BOX through its ab (mesh first, relay fallback, the same path as `shell_execute`): the path is resolved against the box's filesystem and the app starts there; the reply says `on: "<box>"`. Before 2.1.143 both ran inside the CLI process, so `--target arav-rog launch {path}` answered `find_exe: could not locate` for a file that existed on arav-rog. Only a Windows CLI with no `--target` still resolves and spawns locally. On a box `launch` is a Tier-2 exec verb like `launch_process` (gated the same way, `reason` required); `find_exe` is read-only. A target ab older than 2.1.143 answers unknown verb: use `launch_process {path, args}` there.++**A bridge's verbs are documented by the bridge, not here (2.1.146, wiki #212).** `commands` lists the bridges the box has; `commands {"bridge":"<name>"}` is the bridge's own description; `sync_skills` installs its skill. Nothing below is about any one bridge.++### Desktop Tools++- `desktop_close_window` -- Gracefully close a window (WM_CLOSE, so save prompts fire). Resolves `hwnd` or `titleContains`; optional `forceAfterMs` kills the owning process only if the window outlives the grace period. Returns MEASURED `closed`/`forced`. Prefer this over process_kill for anything with unsaved state.+- `desktop_wait_for_window` -- Block (bounded timeout) until a window matching `titleContains`/`className`/`pid` appears, or with `state:"absent"` until it goes away (a dialog dismissed, an app exited). The honest replacement for sleep-and-rescreenshot loops: `adom-bridge desktop_wait_for_window '{"titleContains":"Save As","timeoutMs":10000}'`.+- `desktop_open_folder` -- Open a file or folder in Windows Explorer. If given a file path, Explorer opens with the file highlighted (selected). Args: `path` (file or folder path).+- `desktop_open_url` -- Open a URL in the user's **NATIVE OS BROWSER** — i.e. the real Edge / Chrome / Firefox / Brave they use every day, with their saved logins, history, bookmarks, and extensions. This is NOT pup, NOT Chrome for Testing — it's the browser the human actually uses. Hand it off when the user needs to interact with a logged-in account themselves.+ - Args: `url` (required string), `browser` (optional: `"default"` (Windows-registered default — could be Edge, Chrome, Firefox, Brave; whatever they set), `"chrome"`, `"edge"`, `"firefox"`, `"brave"`).+ - Examples:+ - `adom-bridge desktop_open_url '{"url":"https://claude.ai/"}'` -- opens in user's default native browser, already signed in+ - `adom-bridge desktop_open_url '{"url":"https://docs.example.com","browser":"edge"}'` -- force native Edge+ - Returns `{ok, browser, url, exePath?}`. Allowed schemes: http, https, mailto, ftp, ftps. Other schemes refused.++#### `desktop_open_url` vs `pup_open_window` — two completely different browsers++| | `desktop_open_url` | `pup_open_window` |+|---|---|---|+| **What browser** | Native OS browser the user uses daily — Microsoft Edge / Google Chrome / Mozilla Firefox / Brave (whichever they've set as Windows default, or the one you name explicitly) | Puppeteer-controlled **Chrome for Testing** — a separate Chromium build that pup launches |+| **Profile / data** | The user's real profile: saved logins, history, bookmarks, extensions, autofill | Isolated profile under `plugins/puppeteer/profiles/<sessionId>/` — empty, no saved logins |+| **Who interacts with the page** | The HUMAN. Claude hands the URL off and is done. | CLAUDE drives it programmatically — screenshots, clicks, eval, recording. The human just watches. |+| **Use when** | A login is required (claude.ai, GitHub, internal tools, banking) — the human's existing session must be reused | Automation, scraping, screenshot capture, video recording, headful UI testing |+| **Can Claude control it after launch?** | No — once handed off, Claude can't see or drive it | Yes — every `pup_*` verb (screenshot, eval, navigate, record, etc.) |++**Decision rule:** human-in-the-loop login flow → `desktop_open_url`. AI-driven automation → `pup_open_window` (defaults to Chrome for Testing). AI-driven automation that **must** be the user's real, signed-in profile (GCP Console with org/projects, internal SSO behind a managed-Chrome policy, anything Context-Aware Access blocks for non-enrolled browsers) → `pup_open_window` with the new `nativeBrowser` + `nativeProfile` args (v1.0.5+, see below).++#### ⚠️ Common footgun — `localhost` / `file://` URLs from Docker AIs++If you're running this CLI from a remote container, a `url` you pass to ANY verb (a bridge's window, `desktop_open_url`) is loaded on the DESKTOP: `localhost` there is the desktop, not your container, and `file://` names a file on the desktop's disk.++**v1.7.10+ detects this.** When you pass a URL whose host is `localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, or scheme is `file://`, the response includes a `_hint` field explaining the issue and suggesting the public-slug URL pattern (`https://<user>-<repo>-<suffix>.adom.cloud/proxy/<port>/<path>`). **The call still proceeds** — rare legitimate cases exist where the user actually does have something running on their localhost.++**Recipe — load something from inside your container into pup:**++```bash+# 1. Start your service inside the container (e.g. a local HTML viewer):+python3 -m http.server 8786 --bind 0.0.0.0 &++# 2. Ask your USER for the container's adom.cloud slug suffix+# (look at their browser URL when they open hydrogen or claude.ai/code).+# Slug looks like: <username>-<container>-<random-id>++# 3. Open the public URL — NOT localhost — in whichever browser verb you use:+adom-bridge desktop_open_url '{"url":"https://<your-container-slug>.adom.cloud/proxy/8786/foo.html"}'+```++If you don't have the suffix handy, just ask the user. Or if the file you wanted to show pup is one your container already wrote to disk, push it to a public location (wiki asset, S3) and load from there.+++- `desktop_bring_to_front` -- Bring a window to the foreground by HWND or title substring. Uses keybd_event + AttachThreadInput to bypass Windows foreground lock. Preserves maximized state.+ - Args: `hwnd` (int) OR `titleContains` (string, case-insensitive). One required.+ - Example: `adom-bridge desktop_bring_to_front '{"titleContains": "Fusion"}'`+- `desktop_set_window_state` -- Change a window's show state without bringing it to the foreground (or both, if combined with `desktop_bring_to_front`). Args: `hwnd` (int) OR `titleContains` (string) OR `cacheKey`, plus `state` (one of `"maximize"`, `"minimize"`, `"restore"`, `"show"`, `"hide"`, `"bottom"`). Use to ensure Fusion or KiCad windows are maximized for screenshots, or to hide noisy background windows during demos.+ - Example: `adom-bridge desktop_set_window_state '{"hwnd":133560,"state":"maximize"}'`+ - **`state:"bottom"` (v1.9.114) -- z-order send-to-back, background.** Drops the window BEHIND all others without focusing, moving, or resizing it (`SetWindowPos HWND_BOTTOM` + `SWP_NOMOVE|SWP_NOSIZE|SWP_NOACTIVATE`); works even on an off-screen window. It's an ATOMIC check-and-demote: it reads `GetForegroundWindow()` first, and if the target IS the OS foreground (the user is interacting with it) it SKIPS the demotion and returns `wasForeground:true, sentToBack:false` so the caller backs off with no race; otherwise `wasForeground:false, sentToBack:true`. Use to park a background window behind the user's windows (still on-screen, taskbar-clickable, rendering for screenshots) without stealing focus.+ - **`force:true` (v1.9.115)** sends it to back EVEN IF the window is currently the OS foreground (still no activation change; `sentToBack:true` always, `wasForeground` still reported). Use at window-LAUNCH time: Windows reassigns the foreground to a brand-new window when the previous foreground is destroyed, so "foreground at launch" is not a real user signal.+ - Example: `adom-bridge desktop_set_window_state '{"titleContains":"(session: abc","state":"bottom","force":true}'`++#### Real OS input — mouse + keyboard (v1.8.170+)++`desktop_click` / `desktop_double_click` / `desktop_right_click` / `desktop_hover` / `desktop_type` / `desktop_press_key` synthesize **real OS input via `SendInput`** — so they land on **`chrome://` pages and native dialogs that browser CDP (`nbrowser_*` / `pup_*`) cannot reach**. The verb names mirror the browser extension's `nbrowser_*` so the model transfers between in-page and whole-desktop driving.++- **You must say WHY (v2.0.84).** Every verb that takes over the screen - the four mouse verbs, `desktop_type`, `desktop_press_key`/`send_keys`, `desktop_bring_to_front`, `desktop_raise_window` - requires a **`reason`** from a relay caller, or it is refused with `errorCode:"reason_required"`. The reason is not for the log: ab renders **"`<your thread>`: `<your reason>`"** on the user's screen next to the glowing cursor, at the moment their machine moves. Write it for the human watching, not for a log line - `reason:"clicking Next on the Altium installer"`, not `reason:"sending a click"`.+- **Gated behind shell-auto-approve** (same risk class as `shell_execute` — keystrokes/clicks hit whatever is focused on the user's live machine). If it's off you get `errorCode:"input_not_approved"`. **You (a cloud/relay AI) can't grant it yourself (v1.9.90)** — call `request_shell_approval` (or let the refusal auto-surface the prompt) and ask the user to click Approve / Bypass Permissions in Adom Bridge, then retry. Every action is written to the activity log.+- **Focus a window first** by passing `{window:"<title substring>"}` (alias `titleContains`) or `{hwnd:<int>}` — reuses `desktop_find_window`'s resolver, so input lands on the intended window, not whatever happened to be focused. Get the hwnd from `desktop_list_windows` / `desktop_find_window`.+- **Coords are physical screen pixels** — the same space as `desktop_list_windows` rects.++**The four verbs that MOVE THE MOUSE each answer to a `mouse_*` name too (v2.0.84).** They are the same verbs, not new ones - the pointer verbs were named after what they do to a control (`click`, `hover`), so searching a verb list for "mouse" found nothing that moves the mouse. Use whichever name you reach for:++| moves the mouse | alias |+|---|---|+| `desktop_hover` (also `desktop_move`) | `desktop_mouse_move` |+| `desktop_click` | `desktop_mouse_click` |+| `desktop_double_click` | `desktop_mouse_double_click` |+| `desktop_right_click` | `desktop_mouse_right_click` |+| `desktop_mouse_drag` (2.1.143) | `desktop_drag` |++**Drag (2.1.143, wiki #205).** `desktop_mouse_drag {from:{x,y}, to:{x,y}, steps?:12, durationMs?:300, button?:"left", space?:"screen"|"image"|"window", hwnd?|titleContains?, reason}` presses at `from`, moves the real cursor through `steps` interpolated points over `durationMs`, releases at `to`. It is for canvases with no view controls that only react to a drag: orbit a 3D preview (KiCad's Footprint Chooser pane), a splitter, a slider, a lasso. A canvas that tracks motion ignores a teleport, so the pointer really travels; slow canvases want `durationMs` 600+. With `hwnd`/`titleContains` ab raises that window, verifies it is under `from` before pressing (else `target_not_under_cursor`, nothing pressed), and gives the prior foreground back afterwards (`restoreForeground:false` keeps it up). `space:"image"` + `shotId` takes screenshot pixels; `space:"window"` takes window px or `{fx,fy}` fractions of the window rect. The reply lists the `points` actually sent. Same gate and same UIPI limit as `desktop_click`.++An alias is gated exactly like the verb it aliases - it is not a way around the reason requirement below.++Verbs:+- `desktop_click {x, y, button?:"left"|"right"|"middle", double?, window?|hwnd?}` — left-click by default. `desktop_double_click` / `desktop_right_click` are wrappers; `desktop_hover` moves the cursor without clicking (reveals tooltips).+- `desktop_type {text, window?|hwnd?}` — types a literal Unicode string (`KEYEVENTF_UNICODE`, any keyboard layout).+- `desktop_press_key {keys:[...], repeat?, window?|hwnd?}` (alias `desktop_send_keys`) — chords/shortcuts. Modifiers `ctrl/alt/shift/win`; named keys `enter/tab/esc/space/backspace/delete/insert/home/end/pgup/pgdn/up/down/left/right/f1-f12`.++Canonical "navigate a browser to a URL" flow (works on `chrome://` where CDP can't):+```bash+adom-bridge desktop_press_key '{"keys":["ctrl+l"],"window":"Edge"}' # focus Edge + select the address bar+adom-bridge desktop_type '{"text":"edge://extensions","window":"Edge"}'+adom-bridge desktop_press_key '{"keys":["enter"],"window":"Edge"}'+```+For a control that isn't reachable by CDP/coords (e.g. the Reload button on `edge://extensions`, which lives in a shadow DOM), Tab-navigate to it then Enter: `desktop_press_key {"keys":["tab","tab","enter"]}`.++**Visual "Adom cursor" (v1.8.171+)** — a click-through teal cursor + a label that narrates what you're driving (trust cue; byte-faithful to the browser extension's cursor). Pure visual, *not* gated:+- `desktop_cursor {action:"show"|"move"|"hide", x, y, label}` — drive/narrate the cursor on its own ("watch me"). Returns the overlay `hwnd`.+- **On by default since v2.0.84** on `desktop_click`/`desktop_double_click`/`desktop_right_click`/`desktop_hover` (and their `mouse_*` aliases): ab glides the teal cursor to the target *then* does the real click, so the human always sees Adom is driving rather than an unattributed pointer jump. Pass `label:"Clicking Reload"` to narrate it in your own words; with no label it shows "`<your thread>`: `<your reason>`". Pass `cursor:false` only when the pointer move must be invisible. It was opt-in before, which meant the user lost the signal precisely when an AI was careless.+- **Auto-hide (v1.8.174+):** the cursor hides itself after `ttlMs` (default **60s**) so it never lingers on the user's screen if you forget to hide it. Each `show`/`move` refreshes the timer. Pass `ttlMs:<ms>` (≤600000) for longer, or `persist:true` to keep it until you explicitly `desktop_cursor {action:"hide"}`. If a cursor you showed was auto-hidden, the **next** cursor/click response prefixes its `_hint` with a NOTE so you learn (no chatty unsolicited message).++#### Background input via UI Automation (v1.8.174+) — no foreground, no focus steal++The mouse/keyboard verbs above use `SendInput` = real OS input, which only lands on the **foreground** window — so ab foregrounds the target first, yanking it in front of the user's work. When you must NOT steal focus, use **UI Automation** instead: `InvokePattern.Invoke()` / `ValuePattern.SetValue()` are **programmatic** — the app services them via its accessibility tree with **no cursor move, no focus change, no foregrounding**. Chromium/Edge expose their a11y tree to UIA, so `chrome://` page buttons/links are reachable **by accessible name** (not coordinates).++- `desktop_find_control {window|hwnd, name|contains, role?}` — **read-only** discovery (ungated). Returns `best:{name, automationId, role, rect, invokable, settable}`. Call this first to learn whether a control is UIA-reachable.+- `desktop_ui_click {window|hwnd, name|contains, role?}` — UIA **Invoke** by accessible name, in the background. Gated behind shell-auto-approve.+- `desktop_ui_set {window|hwnd, name|contains, text}` — UIA **SetValue** on a text field, in the background. Gated.++`role` ∈ `button|edit|link|checkbox|menuitem|tab|listitem|combobox|radiobutton|document|group|spinner`. **Caveat (surfaced in `_hint`, not hidden):** some shadow-DOM controls aren't in the UIA tree — if `find_control` reports `invokable:false`/not-found, the control genuinely needs a **foregrounded** `desktop_click`. So: prefer `desktop_ui_click` (background) → fall back to `desktop_click` (foreground) only when UIA can't reach it.++#### Near-complete UIA surface (v1.9.0+) — enumerate, read, drive, with a foreground-hint contract++v1.9.0 expands UIA from ~3 patterns to a near-complete agent-drivable client. **Every UIA verb now returns a `foreground` block** so you know — *before and after* — whether a call steals the user's screen:++```+"foreground": { "predicted": "background|fallback-possible|foreground", // from the static class + the resolved path, BEFORE acting+ "observed": "background|foreground|n/a", // MEASURED (GetForegroundWindow before/after); reads = n/a+ "raised": true|false, // did THIS call bring a window to front?+ "pathTaken": "uia-pattern|sendinput-fallback|setfocus|read-only",+ "reason": "<short>" }+```++**Shared selector on every verb:** `{hwnd|window(titleContains)}` + `{name|contains|automationId|role}` + `scope:"window"(default)|"popups"|"subtree:<automationId>"` + `nth:<int>` (0-based — target the Nth match without the exact name). Prefer `automationId` (stable, locale-independent). Rects are screen px with `centerX/centerY`; `space:"image"+shotId` is accepted wherever a coordinate is taken.++- **Enumeration (read-only, ungated):**+ - `desktop_ui_tree {window|hwnd, maxDepth?, roleFilter?[], nameContains?, maxNodes?(500), scope?}` — **flagship.** Dump the a11y subtree; nodes carry rect + invokable/settable + value/toggleState/expandState/selected/rangeValue + `children[]`. Honors Chromium's lazy tree (wake-retry); `truncated:true` when it hits `maxNodes` (logged, never silent).+ - `desktop_ui_from_point {x,y | space:"image",shotId,x,y}` — the control under a pixel.+ - `desktop_find_controls {selector, maxResults?(25)}` — ALL matches as an array with `nth` indices (vs `find_control`'s single best).+- **Reading (read-only, ungated):** `desktop_ui_text {selector, range?:"all"|"selection"|"visible"}` (TextPattern); `desktop_ui_get {selector}` (current value/toggle/range/selection/expand state); `desktop_ui_grid_get {selector, row?, col?}` (GridPattern cell + rowCount/columnCount).+- **Actions (gated by shell-auto-approve; background-first via the pattern, SendInput-click fallback only when the pattern is absent → then `raised:true`):** `desktop_ui_toggle {selector, state?}`, `desktop_ui_expand {selector, action?:expand|collapse|toggle}`, `desktop_ui_select {selector, action?:select|add|remove}`, `desktop_ui_scroll {selector, dir?, amount?}`, `desktop_ui_set_range {selector, value}`, `desktop_ui_focus {selector}` (**bg:foreground** — usually raises; reports it), `desktop_ui_window {window, action:minimize|maximize|restore|close|move|resize}`.+- **Events (v1.9.3 — REACT instead of poll; read-only, ungated):**+ - `desktop_ui_watch {event, window?|hwnd?, properties?}` → `{watchId}`. `event` ∈ `focus` (desktop-wide "what got focus") | `window-opened`/`window-closed` (new/closed top-level windows — these three can be global) | `invoke` | `selection` | `text-changed` | `text-selection` | `menu-opened` | `tooltip-opened` | `notification` | `live-region` | `structure` | `property` (these REQUIRE a `window`, else `errorCode:window_required` — avoids a desktop firehose). Cap 16 watches.+ - `desktop_ui_events {sinceSeq?, watchId?, max?}` → DRAIN the buffer (poll cursor; there is NO server-push over the relay). Returns `{events:[{seq, watchId, event, ts, element:{name,role,automationId,rect,hwnd}, eventType|property|changeType}], nextSeq, highestSeq, missedEvents, activeWatches}`. Poll with `sinceSeq=nextSeq`. Events ALSO appear in the ab GUI activity-log. The 500-deep ring is shared across all watches; `missedEvents:true` = you polled too slowly.+ - `desktop_ui_unwatch {watchId|"all"}` → stop. **Canonical use:** waiting on an OTP/confirm dialog → `ui_watch {event:"window-opened"}`, poll `ui_events` until the window appears, then act on it (no busy screenshot loop).+- **Introspection:** `desktop_ui_capabilities {}` — **read-only, ungated.** The foreground-hint TABLE per verb (`staticClass` + measured `{win32, chromium-pattern, chromium-fallback}`). **Consult it BEFORE a call to know if it'll steal the screen, without acting.** `desktop_ui_selftest {confirm?, window?}` writes/refreshes it (empirically measures when `confirm:true`).++**Integrity boundary:** an elevated/admin window or the UAC secure desktop returns `errorCode:"uia_integrity_blocked"` (never a silent no-op) — a normal-integrity bridge cannot drive a higher-integrity process.++#### Zero-math coordinate clicks — `coordMap` + image-space (v1.8.175+)++You no longer recompute scale/offset/DPI to click what you see in a screenshot. Every screenshot verb returns a **`coordMap`**:++```jsonc+"coordMap": {+ "shotId": "shot-w5050328-1",+ "image": { "w": 1372, "h": 1031 }, // the DELIVERED (safe) PNG's pixels+ "screenRect": { "x": 594, "y": 254, "w": 1372, "h": 1031 }, // physical px = input-verb space+ "dpiScale": 1.5,+ "imageToScreen": { "sx": 1.0, "sy": 1.0, "ox": 594, "oy": 254 } // screen = img*s + o+}+```++Two ways to use it:+- **Let ab convert (preferred, zero math):** `desktop_click {space:"image", shotId, x, y}` where `x,y` are pixels in that screenshot. Also works on `desktop_double_click`/`right_click`/`hover`. The `shotId` comes from the capture's `coordMap.shotId` (last ~64 shots retained; re-capture if it expires).+- **Compute yourself:** `screenX = x*sx + ox`, `screenY = y*sy + oy`.++`screenRect` is in the SAME physical-px space as `desktop_list_windows` rects and the `desktop_click` default (`space:"screen"`).++> **Driving a GUI app that pops error boxes / modal dialogs? Read the dedicated [`adom-bridge-window-capture`](WINDOW_CAPTURE.md) skill.** `desktop_screenshot_window {hwnd}` captures ONE window AND the child dialogs / error boxes it owns, even when the window is backgrounded/occluded/minimized (PrintWindow, no focus steal). It is the breakthrough for automating native apps whose dialogs a full-screen grab can't see. The rest of this section is the reference; that skill is the how-and-why.++**Popups (owned):** As of **v1.8.177**, `desktop_screenshot_window` **also captures the window's OWNED popups** — borderless confirm bubbles (Chromium's "Remove '\<ext\>'?"), owned dropdowns, owned dialogs — that don't enumerate in `desktop_list_windows` and aren't visible in a PrintWindow of the main window. They ride back as a top-level **`screenshots[]`** array (the same generic shape `kicad_screenshot_all` uses; the CLI auto-pulls each entry's `fullPath`→`localPath` and `safePath`→`localSafePath`). `ownedPopupCount` is always present (0 = none). Each entry carries `hwnd`, `title`, `kind:"owned_popup"`, and its own `coordMap`+`shotId`, so you can **click the bubble's buttons** with `{space:"image", shotId, x, y}` or drive them in the background by name via `desktop_ui_click {scope:"popups"}`. This is the generic mechanism — there's no app-specific "Chrome bubble" verb; every window screenshot surfaces any owned popups so you get the data and decide. **Use `desktop_screenshot_screen`** (full virtual-screen grab) only for popups *not owned* by your target window, or to see everything at once.++**Screen geometry on every shot (v1.8.178):** every `desktop_screenshot_window` / `_screen` response now carries:+- **`windowRect: {x,y,w,h,monitor}`** — the window's position + size on the overall (possibly multi-monitor) desktop, in physical screen px. Each owned popup entry in `screenshots[]` gets its own **`rect: {x,y,w,h,monitor}`** too — so you know where every captured surface sits, not just its pixels.+- **`display: {virtualScreen, primary{w,h,dpi,scale}, monitors[], monitorOfWindow}`** — the full monitor topology: total virtual bounds, and per-monitor `bounds`/`dpi`/`scale`/`primary`. `monitorOfWindow` indexes into `monitors[]`. (Same data as the standalone `desktop_display` verb, folded in.)+- **`drive: "background"`** — screenshots never foreground the window (PrintWindow/WGC). The companion `_related` field lists sibling screenshot/window verbs.++**Screenshot cache is self-cleaning + self-describing (v1.9.107/108/113):** the CLI auto-pulls every screenshot to ONE user-visible, namespaced dir on the caller's machine (`~/project/screenshots/adom-bridge/`, else `~/.adom/screenshots/`; `ADOM_SHOTS_DIR` overrides) — never `/tmp` anymore. Shots are **bucketed by kind into subfolders**: `window/` (a specific window / app / owned popup) and `screen/` (a full-desktop grab). **Human-readable filenames (v1.9.113):** `<YYYY-MM-DDThh-mm-ss.mmm>[-<hwnd>].png` — a sortable ISO stamp (lexicographic == chronological), plus the `hwnd` for a window capture; the `window`/`screen` kind is NOT repeated in the name because the folder already says it. **One file when small (v1.9.114):** if the shot was already within the safe size cap (the common case) it's a single `<stem>.png` and `localFullPath == localSafePath` — no redundant re-encoded copy. Only a shot large enough to be downscaled produces a `<stem>.full.png` + `<stem>.safe.png` pair (Read the `.safe` one for image analysis; use `.full` for archive). Each shot gets a **`<stem>.json` sidecar** (shotlog-style: `source`, `timestamp`, `kind`, dims + `bytes`, `coordMap`, `rect`; a full+safe pair shares one). **Parent/child grouping (v1.9.111/113):** when `desktop_screenshot_window` returns owned popups in `screenshots[]`, the parent AND each pulled child share the same stem, and each child gets a **`.popup-NN` label** — e.g. parent `2026-07-15T01-14-16.937-986710.safe.png` and children `2026-07-15T01-14-16.937-986710.popup-01.safe.png`, `...popup-02...`. So a plain `ls` shows the whole capture grouped (shared stem) and the role (`.popup-NN`) without opening a sidecar; the sidecar also carries `parent` + `popupIndex`. (`kicad_screenshot_all` returns sibling windows with no parent, so those keep their own names.) A janitor prunes it (7 days / 2 GB / newest kept; sidecars follow their PNG, orphans swept) and drains the old `/tmp/ad-shots` + `/tmp/adom-bridge-screenshots` backlogs, running opportunistically at most once/24h on any CLI call. **You do not need to clean it up or write your own cron.** Every screenshot response carries a `_cacheHint`; verify coverage with **`adom-bridge janitor`** (status: each subfolder, size, policy, last sweep) or force a sweep with **`adom-bridge janitor '{"sub":"run"}'`**. It never touches other tools' files under `~/project/screenshots/` (e.g. shotlog). Tune via `ADOM_SHOTS_MAX_AGE_DAYS` / `ADOM_SHOTS_MAX_GB` / `ADOM_SHOTS_MAX_FILES`, or disable with `ADOM_JANITOR=0`.++**Background vs foreground driving (v1.8.178):** every desktop driving verb carries a structured **`drive`** field (`"background"` | `"foreground"` | `"n/a"`) — in `GET /commands`, in `adom-bridge help <verb>`, and echoed in the verb's own response. **Background** (no foreground / focus steal / cursor move — UIA + capture): `desktop_ui_click`, `desktop_ui_set`, `desktop_navigate`, `desktop_find_control`, `desktop_screenshot_window/_screen`, `desktop_record_window_start`. **Foreground** (SendInput → goes to the focused window, so ab foregrounds first): `desktop_click`/`_double_click`/`_right_click`/`_hover`, `desktop_type`, `desktop_press_key`, `desktop_bring_to_front`. Run **`adom-bridge help background`** for the full grouping. Rule: to act without disturbing the user's window, drive by NAME (`desktop_find_control` → `desktop_ui_click`/`ui_set`); fall back to coordinate clicks/keys only when the control isn't in the UIA tree.++**Click a control by name → coords:** `desktop_find_control` returns `best.rect{x,y,w,h,centerX,centerY}` in screen px — `desktop_click {x:centerX, y:centerY}` to SendInput-click it (handy when `invokable`/`settable` are both false). Target by `name`, `contains`, `role`, or `automationId` (stable, locale-independent).++**Keyboard chords:** `desktop_press_key` accepts a chord as one string `["ctrl+w"]` OR separate tokens `["ctrl","w"]` — both mean Ctrl held + W. A *sequence* of distinct chords uses `+` per element: `["ctrl+l","enter"]`. The reply echoes `chords` showing how ab interpreted your `keys`.++- `desktop_flash_window` (v1.8.152+) -- Flash an **arbitrary** window's taskbar button orange (Win32 `FlashWindowEx`) as an "attention / ongoing activity" cue. Resolve the HWND with `desktop_list_windows` first. Args: `hwnd` (int) OR `titleContains` (string); `mode` (`"until_focused"` default — flashes until the user clicks the window; or `"count"`); `count` (int, `mode:"count"` only, default 3); `stop` (bool — clear an active flash). Returns `{ok, hwnd, title, mode}`. **Lightweight by design** (direct FlashWindowEx, no process spawn) so a bridge can call it after every verb. Distinct from the puppeteer bridge's `pup_alert_window`, which only flashes pup's OWN tracked windows — this flashes any window on the host. For progress + overlay-icon status, use `desktop_taskbar`.+ - Example: `adom-bridge desktop_flash_window '{"hwnd":133560}'` then `adom-bridge desktop_flash_window '{"hwnd":133560,"stop":true}'` to clear.+- `desktop_taskbar` (v1.8.152+) -- Paint **glanceable status** on any window's taskbar button via `ITaskbarList3` (progress bar + overlay icon) + `FlashWindowEx`, so a bridge can show what the AI is doing on the **real app window it drives** (e.g. native-browser putting an "Adom is driving" badge + task progress on the Chrome/Edge button). Works on windows owned by **other processes**. Persistent COM (init once, reuse) — safe to tick progress frequently; no process spawn. Resolve the HWND via `desktop_list_windows`. Args (all channels optional; provide ≥1):+ - `hwnd` (int) OR `titleContains` (string)+ - `flash`: `"until_focused"` | `"stop"`+ - `progress`: `{state: "normal"|"paused"|"error"|"indeterminate"|"none", value?: 0..100}` — **state→color**: normal=green, **paused=yellow** (use for human-in-the-loop gates: captcha / 2FA / login), error=red, indeterminate=marquee, none=clear.+ - `overlay`: `{badge: "adom"|"none", icon?: "<PNG or .ico — file path OR base64>", tooltip?: "<hover>", avatar?, profileDir?, userDataDir?, composite?}` — built-in badges are `adom` (the Adom logo — a "driven by Adom" trust tell) and `none` (clear); 3rd-party bridges pass their **own** `icon` (a **PNG** or `.ico`, file path or base64 — ab magic-sniffs + renders at 32px). ab stays generic: it paints whatever art you hand it. `tooltip` is the overlay's accessible description.+ - **Compositing onto a Chrome/Edge profile avatar (v1.9.110, #154):** Windows gives ONE overlay slot per taskbar button, and Chrome/Edge use it for the **profile avatar** — so a bare `badge:"adom"` would EVICT the profile face (you'd lose "work vs personal"). To keep the avatar AND show "Adom is driving", pass the avatar and ab composites the mark onto it (bottom-right ~47%): `overlay:{badge:"adom", avatar:"<PNG path or base64>"}`, or name the profile with `overlay:{badge:"adom", profileDir:"Profile 1", userDataDir?:"<User Data root>"}` (ab reads `<UserData>/<profileDir>/Google Profile Picture.png`; `userDataDir` is derived from the window's exe if omitted). `composite:false` opts out (raw mark). If the avatar can't be resolved ab falls back to the plain mark — never errors. The CALLER (which knows its profile — e.g. the browser extension) supplies the avatar; ab does not reverse-engineer it (one browser process serves all profiles, so the process command line has no `--profile-directory`).+ - `thumbnailTooltip` (v1.9.153): the taskbar **hover-preview tooltip** (`ITaskbarList3::SetThumbnailTooltip`). The ONLY uncapped text surface on the preview — the window title truncates ~25-30 chars, this string does not (and takes newlines). e.g. `"Adom Pup / Adom wiki / LOGGED-IN view / Chrome for Testing / 3 tabs"`. Pass `""` to clear it back to the title. Works on a window ab does not own.+ - `thumbnailClip` (v1.9.153): crop the live hover preview to a client-space region — `{x,y,w,h}`, or `null` to reset to the whole window (`SetThumbnailClip`).+ - Returns `{ok, hwnd, title, applied:[...]}`. Clears: `flash:"stop"`, `progress.state:"none"`, `overlay.badge:"none"`, `thumbnailTooltip:""`, `thumbnailClip:null` — call these on task-end / window-close to clean up.+ - Example (long task, human gate, then done):+ ```bash+ adom-bridge desktop_taskbar '{"hwnd":133560,"overlay":{"badge":"adom","tooltip":"Adom is driving this browser"},"progress":{"state":"indeterminate"}}'+ adom-bridge desktop_taskbar '{"hwnd":133560,"progress":{"state":"paused"},"flash":"until_focused"}' # captcha — come look+ adom-bridge desktop_taskbar '{"hwnd":133560,"progress":{"state":"none"},"overlay":{"badge":"none"},"flash":"stop"}' # done — clear all+ ```+- `desktop_set_window_identity` + `desktop_register_app_identity` (v1.9.134+) -- **Full window-identity takeover.** Make a window you drive present itself as YOUR app: the taskbar button, Alt-Tab entry, hover tooltip and any user pin read your name and icon instead of the process that happens to own the HWND. Built for a bridge driving a borrowed host app (a Chrome for Testing window otherwise reads "Test" with Chrome's icon, which is two brands and a confusing word). ab is generic here — it applies whatever `appId` / `iconPath` / `displayName` you pass and knows nothing about which app that is.++ **You need BOTH halves.** `set_window_identity` stamps a LIVE window (`PKEY_AppUserModel_ID` + `WM_SETICON`); `register_app_identity` teaches Windows what that `appId` MEANS (an HKCU `AppUserModelId` key + a Start Menu shortcut carrying the same AUMID). An unregistered appId falls back to the owning exe's icon, so half one alone leaves Alt-Tab wrong.++ ```bash+ # Once per install. Ship the .ico in your bridge's release zip; pass its cache path.+ # `target`/`args` are what a user's PINNED tile launches — point it at a real verb so+ # the pin opens a managed window instead of an orphan app.+ adom-bridge desktop_register_app_identity '{"appId":"Adom.Pup","displayName":"Adom Pup",+ "iconPath":"C:/.../adom-pup.ico",+ "args":"pup_open_window {\"sessionId\":\"pinned\",\"owner\":\"pinned-launch\"}"}'++ # Per window, e.g. at park time. cacheKey survives page-title churn.+ adom-bridge desktop_set_window_identity '{"cacheKey":"<sessionId>",+ "titleContains":"(session: <sessionId>)","appId":"Adom.Pup",+ "displayName":"Adom Pup","iconPath":"C:/.../adom-pup.ico"}'+ ```+ - Window target: `hwnd` OR `titleContains` OR `cacheKey` (+ `fresh` to re-resolve). Response carries `resolvedBy` (`hwnd` | `cachedHwnd` | `title`) and `applied[]`.+ - **Identity is per-HWND and dies with the window** — re-apply after a window is recreated.+ - ab holds the icon handles for its whole process lifetime. Icon handles are process-owned: a transient setter leaves windows pointing at dead handles, so the icon reverts moments later. ab being long-lived is exactly why it owns them.+ - `register_app_identity` writes HKCU only (never a UAC prompt). It is gated for RELAY callers like `write_file`; a bridge calling over the loopback API is trusted by transport and ungated.+ - **Jump-list tasks (v1.9.139+): `desktop_set_window_jumplist {appId, tasks}`** puts YOUR actions on the taskbar right-click menu of every window stamped with that appId. Jump lists attach to the AUMID, so per-session appIds give each window its OWN menu. Each task: `{title (required), target? (exe, defaults to ab's bundled CLI — so `args` can simply be an ab/bridge verb + JSON), args?, iconPath?, description?}`. Tasks launch minimized (no console flash). Max 16; `tasks:[]` clears (call at session close, alongside `unregister_app_identity`). Re-set anytime to update the menu. Tier-2 gated for relay callers; loopback bridges ungated.+ - **Per-session / transient appIds (v1.9.136+): register REGISTRY-ONLY with `shortcut:false`, unregister on close.** If you stamp per-session AUMIDs (e.g. `Adom.Pup.<sessionId>` so each window keeps its own taskbar button in split mode), an UNREGISTERED AUMID makes Alt-Tab fall back to the owning exe's icon. `desktop_register_app_identity {appId, displayName, iconPath, shortcut:false}` writes just the HKCU AppUserModelId key (DisplayName + IconUri) — Alt-Tab and taskbar hover resolve the name + icon with NO Start Menu entry. Pair with `desktop_unregister_app_identity {appId}` at session close (idempotent; removes the key plus any shortcut carrying that AUMID; ab's own `inc.adom.bridge` identity is protected). Keep ONE shortcut-mode registration as the pinnable launcher. **Known cost (wiki #155, measured): without a visible Start Menu shortcut carrying the AUMID the shell resolves it lazily on the first right-click, so the jump list flashes and only the second click stays.** `anchor:true` (opt-in, default off since 2.1.49) writes a small visible anchor `.lnk` inside one Start Menu subfolder (`anchorFolder`, default "Adom Bridge"; pass it on unregister too) that fixes it, but it is a real Start Menu entry (All apps, "Recently added", search), and a hidden `.lnk`, `NoStartPage` or `SHAddToRecentDocs` do not anchor. John's default: a clean Start Menu.+ - **`desktop_set_window_jumplist`** (v1.9.139+) adds the right-click menu on the taskbar button (per-AUMID; `tasks:[]` clears) and, with `hwnd`+`headerIcon`, brands the header row (the app tile above Pin/Close).+ - **Icon format — read the `iconCheck` these verbs return (v1.9.152).** `desktop_set_window_identity` / `desktop_register_app_identity` / `desktop_set_window_jumplist` return an `iconCheck {kind,sizes,issues,ok,hint}` (or `headerIconCheck`) that magic-sniffs the file and explains why an icon would render as a generic white document: a **256px `.ico` size not PNG-compressed** (the Vista+ trap), **SVG** (the Win32 shell can't load it), a `.ico` with no small size, an `.exe` where an image is wanted. If `iconCheck.ok` is false, fix the file and re-stamp. Ship a multi-size `.ico` (16/24/32/48/256, the 256 PNG-compressed). **Full guide:** the `adom-bridge-window-identity` skill (AUMID model, the register+stamp two-halves rule, jump-list header, taskbar overlay + thumbnail, the live-tile trap, a recipe).+- `desktop_revoke_approvals` -- Revoke all shell auto-approve permissions and deny pending approvals. The next shell command will show the approval dialog.+ - Example: `adom-bridge desktop_revoke_approvals`+- `desktop_demo_panel` -- Floaty glass BACK / PLAY-PAUSE / NEXT / MUTE transport for a guided demo the USER paces (v2.1.0+, mute/callback v2.1.17+). `show` puts it up (reason required; same-id shows persist omitted fields so a stage advance never un-pauses), `poll` between beats drains the user's presses (honor paused / next / back / mute / stop; `muted` rides every reply -- silence your narration audio while it is true), `hide` when the show ends. A LOCAL caller (ah thread, bridge) may pass `eventCallbackUrl` (loopback http) to get every press POSTed instantly; remote callers pace with poll alone. A press never executes anything -- it only queues an event for you. **The full demo recipe (panel + captions + cursor, stop design, the pacing loop) is the `adom-bridge-demo-kit` skill -- read it before building a demo.**+- `desktop_caption` -- Show a global caption overlay that stays visible above ALL windows (KiCad, Fusion, Chrome). Native Win32 overlay — not a browser DOM element. Click-through: mouse events pass to windows below. Captured by screen recordings (browser `getDisplayMedia`, OBS, Game Bar).+ - Show: `adom-bridge desktop_caption '{"text":"Step 1: Opening the board","position":"top","size":"large","duration":3000}'`+ - Custom position: `adom-bridge desktop_caption '{"text":"Look here","x":0.3,"y":0.2,"size":"large","persist":true}'`+ - Hide: `adom-bridge desktop_caption '{"action":"hide"}'`+ - **Every caption carries a close box (X) and, when you gave a `reason`, a line naming your thread.** Both are the user's protection: the X is their only way to dismiss a caption without an AI, and the attribution answers "which of my ~20 threads is drawing on my screen".+ - **`clean:true` removes BOTH, and it is deliberately hard to get — treat it as rare.** It exists for one case: the user is recording a demo video and explicitly asked for a caption with no chrome. It requires `userAsked:true` AND a `cleanReason` (>=30 chars) saying what they asked for, it is **refused outright** with `persist:true`, and its duration is capped at **2 minutes** (re-issue the same id to extend while you are supervising).+ - **Do not set `clean:true` because the caption looks better without the X.** The user cannot dismiss a clean caption at all, so if it lands over something they need, their only recourse is to find the AI that put it there. If you cannot point to the user asking for a chrome-free caption, the answer is no. A normal caption looks the same in a recording apart from a small X.+ - List what's on screen (v1.9.217): `adom-bridge desktop_caption '{"action":"list"}'` — returns `{captions:[{id,ageMs,expiresInMs,persist,overdue}]}`; the authoritative check (caption overlays do NOT appear in `desktop_list_windows`). TTLs are fail-safe: a 15s reaper closes any caption that outlives its deadline. Do not keep a caption alive with a same-id refresh loop as a nag — the user experiences one endless caption.+ - Multiple simultaneous captions (use `id` to keep them independent):+ ```+ adom-bridge desktop_caption '{"text":"Step 1: Opening board","id":"step","position":"center","size":"large","persist":true}'+ adom-bridge desktop_caption '{"text":"REC ●","id":"status","position":"bottom-left","size":"medium","persist":true}'+ ```+ - Replace only the step caption (status stays):+ ```+ adom-bridge desktop_caption '{"text":"Step 2: Routing","id":"step","position":"center","size":"large","persist":true}'+ ```+ - Hide just the step caption: `adom-bridge desktop_caption '{"action":"hide","id":"step"}'`+ - Args:+ - `text` (string) — caption text+ - `id` (string, optional) — identifies this caption. Captions with the same id replace each other; different ids coexist simultaneously. Default `"_default"` — so bare calls without id still replace each other for backward compat.+ - `position` — preset: `"top"`, `"bottom"` (default), `"center"`, `"top-left"`, `"top-right"`, `"bottom-left"`, `"bottom-right"`+ - `x` (float 0.0–1.0) — normalized screen X, overrides position horizontal. 0.0 = left edge, 1.0 = right edge. Centers the caption box on this point.+ - `y` (float 0.0–1.0) — normalized screen Y, overrides position vertical. 0.0 = top edge, 1.0 = bottom edge.+ - `size` — preset: `"large"` (72px), `"medium"` (32px, default), `"small"` (20px)+ - `fontSize` (int) — custom font size in px (8–400). Overrides `size` preset when provided.+ - `duration` (ms) — auto-dismiss timer. **Omitted or `0` → `30000` (30s); clamped to max `600000` (10 min).** Captions ALWAYS auto-expire (v1.8.138+) so a forgotten one can't be stranded on the user's desktop. To keep one up longer, **re-issue the same `id`** (resets the timer) or pass a bigger `duration`.+ - `persist` (bool, optional) — `true` = sticky, NO auto-dismiss. Supervised use only (e.g. demo recordings); you MUST then dismiss it yourself with `action:"hide"`. This is the only way to a never-expiring caption (`duration:0` no longer does that).+ - `action` — `"hide"` to dismiss a caption (with `id`: hides only that caption; without `id`: hides all captions), `"force-clear"` to EnumWindows and destroy ALL caption windows regardless of id (nuclear option — use at top of demo scripts for guaranteed clean slate)+ - Captions with the same `id` replace each other instantly (previous destroyed, no fade overlap). Captions with different ids coexist — multiple captions can be visible simultaneously.++### Desktop Screenshots++Take screenshots of the user's desktop or individual windows. All screenshots use lossless PNG.++**Always save screenshots** to `project-content/screenshots/`.++**Naming convention:** `desktop-<descriptive-name>-YYYY-MM-DD-HhMMam/pm.png`++**Workflow:** Call `desktop_list_windows` first to get HWNDs, then `desktop_screenshot_window` with the HWND.++- `desktop_list_windows` -- List all visible windows (returns HWND, title, class name, position/size, and `z` = Z-order index, 0 = topmost)+- `desktop_find_window` (v1.8.153+) -- Resolve a window's HWND by `titleContains` and/or `className` (case-insensitive substrings), returned in Z-order with `best` = the topmost match. Use this instead of diffing `desktop_list_windows` before/after launching something — it's race-free for "the window I just opened." Returns `{ok, count, best:{hwnd,title,className,z,rect}|null, windows[]}` (`ok` true only when ≥1 matched). Pass `best.hwnd` to `desktop_taskbar` / `desktop_flash_window` / `desktop_screenshot_window`.+ - Example: `adom-bridge desktop_find_window '{"className":"Chrome_WidgetWin_1","titleContains":"GitHub"}'`+- `desktop_activity_filter` (v1.8.155+) -- Scope the **user's GUI activity log** so they watch the slice you care about while you work (e.g. focus it on one bridge before a debugging session). Args: `bridge` (a bridge registry name from `bridge_list` | `"none"` = only global/non-bridge ab commands | `"all"`/omit = everything), `text` (free-text substring, ANDs with bridge), `clear` (bool — reset). **Drives the human's GUI view only — it does NOT change which verbs you can call or what they return.** The GUI shows a status bar stating exactly what's shown + what's hidden.+ - Example: `adom-bridge desktop_activity_filter '{"bridge":"kicad"}'` then `adom-bridge desktop_activity_filter '{"clear":true}'`+- `desktop_screenshot_window` -- Capture a specific window by HWND+- `desktop_screenshot_screen` -- Capture the entire desktop (all monitors)+- `desktop_ad_scroll` (v1.9.86+) -- **Scroll ab's OWN window panels.** ab is a fully AI-controllable app, so you can drive its own scrollbars — most usefully to page the **bridges sidebar** and screenshot cards that are below the fold. Args: `panel` (`bridges` default | `activity` | `connections`), `to` (`top`|`bottom`, absolute — wins over `dy`), `dy` (relative px, default 400; negative = up). Returns `{ok, panel, scrolled, state:{scrollTop,scrollHeight,clientHeight,atTop,atBottom,found}|null, _hint}` — round-tripped from the live window so you can **loop until `state.atBottom==true`**. A collapsed bridges sidebar is auto-expanded first; `state==null` means the panel didn't report (collapsed/empty).+ - Capture-all-cards workflow: `desktop_ad_scroll '{"panel":"bridges","to":"top"}'` → `desktop_screenshot_window` (ab hwnd) → `desktop_ad_scroll '{"panel":"bridges"}'` → screenshot → repeat until `atBottom`.++### App self-update (relay-driven, v1.9.87+)++ab auto-updates itself from the wiki manifest, but historically only a HUMAN clicking the footer "Install now" banner could apply it — so a headless / RDP / fleet box (a fresh Azure VM) could sit a version behind with no path forward from the relay. These verbs give the AI that path (same shape as the shell-approval surfacing verbs). Use `--target <box>`.++- `update_status` (read-only, ungated) -- `{currentVersion, latestVersion, updateAvailable, updateReady, state, busy, updateMode}`. `updateReady:true` = the newer version is downloaded + SHA-verified on disk, appliable with no network.+- `update_check` -- force a manifest check now; if newer, download + verify (stage) it in the background. Poll `update_status` until `updateReady:true`.+- `apply_update` -- the programmatic "Install now": apply the staged update + relaunch ab. Respects `busy` (refuses if a command is in flight). Only ever applies ab's own downloaded + SHA-verified SIGNED wiki release. Returns `{applying:true}` then ab restarts (the relay drops + reconnects; poll `targets`/`ping` for the new version). If not staged yet, it kicks staging and tells you to poll + retry.+- `request_update_approval` -- surface the "Update available — Install now / Later" banner on the target + bring ab to the front (for an ATTENDED box where the human should decide). Does NOT apply; `apply_update` installs.+- `update_set_mode {mode: auto|prompt|off}` -- the auto-apply POLICY. `auto` = unattended stage+apply on the 4h/on-launch poll (set this ONCE on headless fleet boxes and they self-update every release with no relay call); `prompt` = show the banner (default, attended); `off` = no checks.+ - Fleet flow: `update_status --target <box>` → if `updateAvailable` and unattended, `apply_update --target <box>` (or set `update_set_mode {"mode":"auto"}` once). A box must already be on ≥1.9.87 to have these verbs; the one-time bootstrap of an older box still needs a human click OR `auto` mode.++### Hydrogen companion mode (v1.9.218+)++When a HUMAN opens ab on a machine where Hydrogen is detected (embedded marker, Hydrogen's ports.json, or a discoverable Hydrogen install), ab shows an identity overlay ("This is Adom Bridge" + a large Open Hydrogen button), instantly opens or foregrounds Hydrogen, and places its own window directly below Hydrogen's in z-order — most people who open ab by hand were looking for Hydrogen. Facts an AI should know:++- Human-open detection = bare argv AND parent process `explorer.exe` (double-click, Start menu, search, pins). Machine launches — `--autostart` boot, Hydrogen `--embedded` respawns, the relauncher/`restart` verb, CLI-style invocations — NEVER trigger it, so a remote `restart` cannot bounce the user's screen.+- One-shot placement: ab does not fight the user's later alt-tabbing, and it is NOT a real Win32 child window (ab's lifecycle stays independent — the recovery-channel contract holds; if Hydrogen cannot be launched the overlay says so and ab stays fully usable).+- Toggle: Settings > Hydrogen > "Act as a companion to Hydrogen", or `settings_set {"settings":{"hd_child_mode":false}}`. Default ON (self-gates on Hydrogen detection, so standalone installs never see it).+- Forensics: every decision slogs `ah-COMPANION:` lines to startup.log.++### User settings over the API (`settings_get` / `settings_set`, v1.9.214+)++ab's Settings-panel preferences are readable and settable over the API. This is the sanctioned channel for an embedding app (Hydrogen) to render ab's settings in its own panel and forward the user's changes; it also lets a fleet operator read or fix a box's boot/update prefs remotely.++```bash+adom-bridge settings_get+adom-bridge settings_set '{"settings": {"launch_on_boot": true, "update_mode": "auto"}}'+```++- Settable keys (whitelist): `launch_on_boot`, `always_show_window_on_launch`, `update_mode` (auto|prompt|off), `auto_restart_for_updates`, `update_idle_minutes` (1..1440), `prewarm_runtimes`, `prewarm_cft` (auto|always|never), `file_receive_directory`. Anything else lands in `rejected` — servers, shell approval (grants are human-only), and blocked peers have their own surfaces and are deliberately not settable here.+- `launch_on_boot` changes apply the OS Startup artifact immediately; `settings_get` also returns `autostartArtifactPresent` so you can see intent-vs-artifact drift.+- `settings_set` is Tier-2 gated for relay (cloud-AI) callers — carry a `reason` and expect the human-approval flow, like `shell_execute`. Local/loopback callers (Hydrogen, bridges, the local CLI) are trusted by transport.+- Self-heal: ab re-verifies the launch-on-boot artifact every ~60s while running (not just at launch), so anything that deletes the Startup shortcut behind ab's back gets healed within a minute — with a `RECONCILE:` forensic line in `startup.log`.++### Browser automation (`pup_*`, Puppeteer/pup) — bridge-owned; light pointer++The pup bridge is **cloud-owned** and ships its OWN full skill (its wiki page / skill pkg — `bridge_list` names it; `adom-bridge sync_skills` installs it). This page keeps only the pointer plus first-touch examples; sessions/profiles, credential vault, downloads, recording, CDP details are the bridge's skill.++Its verbs: `adom-bridge commands '{"bridge":"puppeteer"}'`.++`bridge_readiness {"name":"puppeteer"}` is the read-only probe (never spawns or downloads). Full details: the bridge's own skill via `sync_skills`.++### Screen recording — ab's desktop recorder (tab recording is pup's)++ab owns DESKTOP recording: a visible HUD opens with the REASON you supply, so the user always sees that recording is on and why. Full guide: the `adom-bridge-recording` skill.++```bash+adom-bridge desktop_recorder_open '{"reason": "record the DRC walkthrough for review"}'+adom-bridge desktop_record_start '{"confirmDesktopNotTabRecording": true}'+# ... drive the desktop ...+adom-bridge desktop_record_stop '{}'+adom-bridge desktop_record_list '{}'+adom-bridge desktop_recorder_close '{}'+```++`desktop_record_start` without `confirmDesktopNotTabRecording: true` refuses with `errorCode: desktop_record_needs_confirmation` — that guard exists because recording a BROWSER TAB is the pup bridge's separate feature (`pup_record_start` etc., see pup's own skill).++### Filesystem primitives — `desktop_list_files` / `desktop_watch_files` / `desktop_pull_glob` (v1.5.0+)++**These are the canonical "wait for a download" primitives.** They replace the `shell_execute` + PowerShell-poll-Downloads pattern that earlier callers (chip-fetcher, similar tools) had to use. No shell, no user approval prompt, no parsing of `dir` output. They run pure Rust on the desktop side, so they're fast, predictable, and never trip over PowerShell quoting.++The pattern they replace looks like this:++```powershell+# OLD — DON'T do this for new code:+$before = (Get-Date).ToFileTime()+# … click download …+while (-not (Get-ChildItem ~/Downloads -Filter ul_*.zip | Where { $_.LastWriteTime.ToFileTime() -gt $before })) {+ Start-Sleep -Seconds 1+}+$file = Get-ChildItem ~/Downloads -Filter ul_*.zip | Sort LastWriteTime -Desc | Select -First 1+```++becomes one call:++```bash+# NEW — single primitive, no shell:+adom-bridge desktop_watch_files '{+ "path": "%USERPROFILE%\\Downloads",+ "glob": "ul_*.zip",+ "timeoutMs": 60000+}'+# → {ok:true, file:{path,name,size,mtime}, elapsedMs} on success+# → {ok:false, error:"timeout", elapsedMs, _hint, ...} on timeout+```++#### `desktop_list_files` — one-shot directory listing++Lists files in a directory (non-recursive) matching a shell-glob, optionally filtered to those modified after a given timestamp.++- Args: `path` (Windows abs path; `~` / `%USERPROFILE%` / `%APPDATA%` / `%LOCALAPPDATA%` / `%TEMP%` are expanded), `glob` (default `*`; shell-style with `*` and `?`; case-insensitive on Windows), `modifiedSince` (optional unix-seconds OR ISO-8601 string).+- Returns: `{ok, path, glob, files: [{path, name, size, mtime}, ...] sorted newest-first, count}`.+- Does NOT recurse. Lists one directory only.++#### `desktop_watch_files` — block until a match arrives++Polls the directory every `pollMs` (default 1000) until at least one file matches the glob with `mtime > since`, or `timeoutMs` (default 60000, max 600000 = 10 min) elapses.++- Args: same as `desktop_list_files` plus `since` (defaults to **now** — without an explicit value, only NEW files report), `timeoutMs`, `pollMs`.+- Returns on match: `{ok:true, file:{path,name,size,mtime}, elapsedMs}`.+- Returns on timeout: `{ok:false, error:"timeout", elapsedMs, _hint, path, glob}`.+- The `since`-defaults-to-now behavior matches the canonical "click then watch" flow. If you might miss the file by starting the watch slightly late (e.g. fast small downloads), record `$(date +%s)` BEFORE the click and pass it as `since`.++#### `desktop_pull_glob` — one-shot orchestrator (recommended for download flows)++Composes `desktop_list_files` (or `desktop_watch_files` if `wait:true`) with the existing streaming `pull_file` mechanism. The whole "wait for a download then pull it" flow in one call:++```bash+BEFORE=$(date +%s)+# … trigger the download click (a browser bridge verb, or desktop_ui_click) …+adom-bridge desktop_pull_glob "{+ \"path\": \"%USERPROFILE%\\\\Downloads\",+ \"glob\": \"ul_*.zip\",+ \"since\": $BEFORE,+ \"wait\": true,+ \"timeoutMs\": 60000,+ \"saveTo\": \"/tmp/cse-out\"+}"+# → {ok:true, files:[{name,path,size,sha256,chunks}, ...], errors:[], matchedCount}+```++- When `wait:true`: blocks via `desktop_watch_files` until at least one match appears, then re-lists the directory and pulls **everything matching** (so a `.crdownload` + final `.zip` written in the same poll tick both come along).+- When `wait:false` (default): just lists what's there now and pulls those.+- Pulls use the existing `pull_file` streaming pipeline — sha256-verified, ~1MB chunks, resumes the same way `pull_file` does.++#### Glob semantics++- `*` matches any run of non-separator chars (within a single filename — these primitives don't recurse).+- `?` matches a single non-separator char.+- Case-insensitive on Windows (matches the filesystem); case-sensitive on macOS/Linux.+- Examples: `ul_*.zip`, `LIB_*.zip`, `*.pdf`, `datasheet_*.pdf`, `*.step`, `*.STEP` (same on Windows).++#### Path expansion++- `~` and `~/foo` → home dir (Linux/macOS convention; works on Windows too).+- `%USERPROFILE%` → home dir (Windows; also works cross-platform).+- `%APPDATA%` → Windows roaming app data (`dirs::config_dir()` on others).+- `%LOCALAPPDATA%` → Windows local app data (`dirs::data_local_dir()` on others).+- `%TEMP%` → Windows TEMP env var; `/tmp` on Linux/macOS.+- Expansion is case-insensitive: `%userprofile%` works the same as `%USERPROFILE%`.+- Forward slashes (`/`) and back slashes (`\`) both work in the path string.++#### Time format for `since` / `modifiedSince`++- Number: unix seconds (e.g. `1746483600`). Floats accepted.+- String of digits: same (e.g. `"1746483600"`).+- ISO-8601 / RFC-3339 string: e.g. `"2026-05-04T22:30:00Z"` or with offset `"2026-05-04T15:30:00-07:00"`.+- Files with `mtime <= since` are filtered out (strict `>`).++### Hydrogen (`hd_*`) — proxy + build/lifecycle for the sibling Tauri app++When the user has **Hydrogen** running (a sibling Tauri v2 app at `C:\Github\hydrogen-desktop`), built-in `hd_*` verbs reach into it. NOT a separate bridge process — adom-bridge proxies HTTP calls directly to Hydrogen's control API.++**Port discovery (v1.8.72+):** Hydrogen's control API lives on **port `47084`** (default; the actual port is read from `%APPDATA%\hydrogen-desktop\ports.json` key `control` on every `hd_api` call, fallback 47084). Pre-v1.8.72 the verbs were hardcoded to Hydrogen's legacy `:9001` webview-eval port, which Hydrogen doesn't bind anymore — that's why `hd_status` reporting "Hydrogen not running" while Hydrogen's window is visible is the upgrade signal.++#### The canonical generic verb — `hd_api` (v1.8.72+)++```bash+adom-bridge hd_api '{"method":"GET","path":"/health"}'+adom-bridge hd_api '{"method":"POST","path":"/setup/run-step","body":{"id":"welcome"}}'+adom-bridge hd_api '{"method":"GET","path":"/wsl/status"}'+```++Generic HTTP pass-through to any Hydrogen endpoint. Raw JSON in `body` — no curl-through-the-relay quote mangling. Returns `{success, status, body, port, method, path}` mirroring Hydrogen's response. If Hydrogen isn't running, returns `{ok:false, error:"Hydrogen not reachable on :47084", _hint:"..."}` instead of timing out.++**Public denylist** — these paths are refused with `{ok:false, error:"not_allowed_in_public", denylist_match:"..."}` on public ab builds (use the internal `hd-dev` cargo feature to bypass):++```+/wsl/unregister, /setup/virgin-reset, /container/destroy,+/container/reset, /workspace/reset, /workspace/destroy+```++#### Inspect + drive a running Hydrogen (v1.8.15+, post-v1.8.72 rework)++| Verb | What | Status |+|---|---|---|+| `hd_status` | Composes ab-side facts (running, repo SHA) + Hydrogen's `/workspace/health` + `/buildinfo` relayed | ✓ shipped |+| `hd_api` | Generic HTTP pass-through (above) | ✓ v1.8.72+ |+| `hd_log '{"tail":30}'` | Tail `%APPDATA%\hydrogen-desktop\hydrogen-desktop.log` (reads disk — works EVEN when Hydrogen is down) | ✓ shipped |+| `hd_open_url '{"url":"...","browser":"chrome","profileDir":"Default"}'` | POST `/open-in-profile` — open URL in a specific browser profile | ✓ shipped |+| `hd_browser_profiles` | GET `/browser-profiles` — enumerate browsers + profiles | ✓ shipped |+| `hd_screenshot` / `hd_shot` | Find Hydrogen window, capture lossless PNG via PrintWindow; `hd_shot` adds region-scoped variant | ✓ shipped |+| `hd_eval '{"js":"..."}'` | POST `/eval` — run JS in Hydrogen's main webview | ⚠ **Moved to `hd-dev` in v1.8.72.** Public returns `verb_moved_to_hd_dev`. Use `hd_api '{"method":"POST","path":"/eval","body":{"js":"..."}}'` |+| `hd_iframe_eval '{"js":"...","contextIndex":0}'` | POST `/iframe-eval` — JS in code-server iframe via CDP | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |+| `hd_reload_vscode` | POST `/reload-vscode` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |+| `hd_container_exec '{"command":"..."}'` | POST `/container-exec` | ⚠ Moved to `hd-dev`. Use `hd_api` equivalent |+| `hd_launch` / `hd_stop` / `hd_restart` | Hydrogen process lifecycle | ✓ shipped |+| `hd_ship` | Atomic build+relaunch+verify-SHA (see below) | ✓ shipped |+| `hd_build*` (v1.8.16+) | Build pipeline orchestration | ⚠ Moved to `hd-dev` in v1.8.72 (Hydrogen developers only) |++#### Atomic ship — `hd_ship` (v1.8.49+) — START HERE for post-push rebuilds++**One verb, one call, one structured pass/fail.** Replaces the 4-step chain `hd_stop` → `hd_build_rust` → poll `hd_build_status` → `hd_launch` → `curl :47084/buildinfo`. Eliminates the "is the new code actually running?" guesswork after every push.++Sequence inside one call:+1. taskkill Hydrogen + poll until zero instances (frees the exe lock)+2. `git fetch && git reset --hard origin/main && rev-parse HEAD` — asserts `HEAD == expectSha` (fails fast if GitHub hasn't replicated the push yet)+3. if `clean:true`, `cargo clean -p hydrogen-desktop` (defeats stale-relink)+4. `cargo build` and parse stderr for `Compiling hydrogen-desktop` to know whether anything actually compiled+5. launch + poll `http://127.0.0.1:47084/buildinfo` until it responds + assert `build_sha == expectSha`++```bash+# End-to-end after a push:+git push && SHA=$(git rev-parse HEAD) && \+ adom-bridge hd_ship "{\"expectSha\":\"$SHA\"}"+```++Args:+- `expectSha` — **REQUIRED** string. Full or prefix SHA of the commit you want running. Bidirectional-prefix match (`abc123` matches the full SHA starting with `abc123` and vice versa).+- `clean` — optional bool, default `false`. `true` forces `cargo clean -p hydrogen-desktop` before build. Use when a prior `hd_ship` succeeded but `runningSha != expectSha` (the stale-relink trap — cargo said `Finished` but reused the cached binary).++Returns on success: `{ok:true, builtSha (== expectSha), runningSha (== expectSha), compiled:bool, elapsedSecs, _hint}`. `compiled:false` on success means cargo found nothing to recompile (cached relink or HEAD didn't actually change — usually fine but surface to caller in case the push was a no-op).++Returns on failure: `{ok:false, failedAt:'args'|'kill'|'git'|'clean'|'build'|'launch'|'verify', error, builtSha (set after git step), runningSha (set if launch worked but verify saw the wrong SHA), compiled (set after build step), elapsedSecs, _hint}`. The partials let you reason about WHERE it broke:++| `failedAt` | What it means |+|---|---|+| `args` | `expectSha` missing / malformed |+| `kill` | Couldn't taskkill Hydrogen (rare — usually permissions) |+| `git` | `git fetch / reset` failed, OR `HEAD != expectSha` after reset (push hasn't replicated to origin/main yet — retry in a few seconds) |+| `clean` | `cargo clean` failed (rare) |+| `build` | `cargo build` failed — read `_hint` for next step (usually `hd_build_log` to see the compile error) |+| `launch` | Cargo finished but `hydrogen-desktop.exe` wouldn't start — `binary_missing` or process spawn error |+| `verify` | Hydrogen launched but `/buildinfo` either never responded or reported a different SHA than `expectSha` (the stale-relink trap — retry with `clean:true`) |++Long-running: cold cargo build can take 2-3 min. CLI WS timeout is 12 min — call as a single sync invocation and wait. Don't poll inside `hd_ship`; it does its own internal polling.++#### Lower-level build + lifecycle suite (v1.8.16+, enriched in v1.8.52+)++Use these directly only when you need finer control than `hd_ship` provides (e.g. frontend-only build, watching log stream live, manual launch sequencing). For the common "I pushed code, get it running" path use `hd_ship`.++The relay's `shell_execute` has a 30s timeout; Hydrogen's `pnpm build` + `cargo build` exceed that, so the build is split into async-spawn verbs + sync poll/tail verbs:++```bash+# Frontend-only rebuild, watch progress, relaunch (no hd_ship since hd_ship is Rust-build-only)+adom-bridge hd_stop+adom-bridge hd_build_frontend '{"show":false}' # returns {ok, pid, logPath} immediately+OFFSET=0+while true; do+ RESP=$(adom-bridge hd_build_tail "{\"offset\":$OFFSET}")+ echo "$RESP" | jq -r '.lines[]'+ DONE=$(echo "$RESP" | jq -r '.done')+ OFFSET=$(echo "$RESP" | jq -r '.newOffset')+ [[ "$DONE" == "true" ]] && break+ sleep 3+done+adom-bridge hd_build_status # confirm succeeded:true && relinkOnly:false+adom-bridge hd_launch # start the new debug binary+```++##### `hd_build` / `hd_build_frontend` / `hd_build_rust` — async-spawn build verbs++| Verb | What |+|---|---|+| `hd_build '{"show":false}'` | Full async build: `git pull` + `pnpm build` + `cargo build`. Returns instantly with `{pid, logPath, flavor:'full'}`. Final log line is `BUILD_OK` or `BUILD_FAILED: <step>`. |+| `hd_build_frontend` | Just `pnpm build` (root). Returns `{pid, logPath, flavor:'frontend'}`. |+| `hd_build_rust` | Just `cargo build` (src-tauri/). **v1.8.52+:** dispatch response also carries `builtSha` + `exeMtimeBefore` + `exeSizeBefore` (captured at spawn time into `%TEMP%\hd-build-meta.json`) so the subsequent `hd_build_status` / `hd_launch` don't need a separate `git rev-parse` + `stat` call. Returns `{ok, pid, logPath, flavor:'rust', show, lingerSecs, builtSha, exeMtimeBefore, exeSizeBefore, _hint}`. |++##### `hd_build_status` — sync state probe (v1.8.52+ enriched)++Returns:+```+{+ ok, building, succeeded, failed,+ lastLines:[string], # last ~40 lines of stderr+ logPath, pid, pidAlive,+ failureReason, # 'compile_error' | 'exe_locked' | 'orphaned_no_sentinel'+ compiledCrates:[string], # names parsed from 'Compiling X v...' lines+ relinkOnly:bool, # true iff succeeded && compiledCrates is empty+ exeChanged:bool, # mtime OR size after differs from before+ exeMtimeBefore:int, exeMtimeAfter:int, # unix seconds+ exeSizeBefore:int, exeSizeAfter:int, # bytes+ exeLocked:bool, # true when build failed because hydrogen-desktop.exe was held+ holderPids:[int], # PIDs holding the exe — taskkill these+ builtSha:string|null, # SHA hd_build_rust/hd_build was dispatched at+ elapsedSecs:int,+ flavor:string, # 'rust' | 'frontend' | 'full'+ _hint+}+```++**`relinkOnly:true` on success is the silent-fail trap to look for** — your push didn't actually rebuild because cargo found nothing to recompile. Usually means your source changes weren't pulled, or you touched a file the workspace doesn't track. Use `hd_ship` with `clean:true` to force a fresh build.++**`exeLocked:true` on failure** + `holderPids` tells you exactly which PIDs to taskkill before retry; or just use `hd_ship` which kills Hydrogen before building.++##### `hd_launch` — start the debug binary (v1.8.52+ enriched)++Args: `killExisting` — optional bool, default `false`. When `false` and Hydrogen is already running, refuses with `reason:'already_running'` so you don't accidentally attach to a stale process. When `true`, taskkill the existing instance (either exe name) with a 600 ms grace period before launching fresh.++`settleSeconds` — optional int, default `5`, max `30` (2.1.159). How long ab watches the new process before answering. `0` skips the extra wait.++Returns on launch: `{ok:true, launched:true, stillRunning:true, observedForMs:int, wasAlreadyRunning:bool, killedFirst:bool, pid:int (Hydrogen's actual PID, not the cmd-wrapper), exePath, builtSha:string|null, _hint}`.++Returns on refusal: `{ok:false, launched:false, wasAlreadyRunning:bool, pid:int|null, killedFirst:false, reason, _hint}`. `reason` ∈:+- `build_in_progress` — wait for `hd_build_status.building:false`+- `build_failed` — fix and retry `hd_build` (or use `hd_ship`)+- `already_running` — pass `killExisting:true` to force-kill+relaunch, or use `hd_restart`+- `binary_missing` — neither `target/debug/hydrogen.exe` nor the pre-rename `hydrogen-desktop.exe` is on disk; run `hd_build_rust` (or `hd_ship`) first++Returns when the build starts and then dies inside the settle window (2.1.159): `{ok:false, launched:true, stillRunning:false, errorCode:'exited_after_launch', observedForMs, pid, exePath, builtSha, runningSha, narrative, _hint}`. The build crashes at startup: read `hd_log`. Re-running `hd_launch` repeats the same crash. Before 2.1.159 this case was reported as a successful launch, because the only process check ran 0.8 s after launch.++**`launched:true, wasAlreadyRunning:false, killedFirst:false` is the proof you need that truly fresh code is running.** Any other combo means you should compare `builtSha` to Hydrogen's actual `/buildinfo.build_sha`.++##### `hd_stop` / `hd_restart` / `hd_build_log` / `hd_build_tail`++| Verb | What |+|---|---|+| `hd_stop` | Kills Hydrogen under either exe name (`hydrogen.exe`, or the pre-rename `hydrogen-desktop.exe`). `{wasRunning}` distinguishes killed vs no-op. |+| `hd_restart` | Stop + launch in one call. Same guards as `hd_launch` (skip `already_running`), and the same `settleSeconds` check: a build that dies at startup answers `errorCode:'exited_after_launch'`. |+| `hd_build_log` | Full log dump from `%TEMP%\hd-build.log`. |+| `hd_build_tail '{"offset":N}'` | Incremental stream — `{lines, newOffset, totalBytes, done, succeeded, _hint}`. Pass `newOffset` back for the next chunk. |++The `show: true` arg on any build verb opens a visible PowerShell console window so the user can watch the build scroll. Default `show: false` runs hidden.++**Auto-close (v1.8.23+).** Visible build windows auto-close 30 seconds after the final `BUILD_OK` / `BUILD_FAILED` line — long enough for the user to read the last error, short enough that build-after-build sessions don't accumulate orphan windows. The window prints a clear green/red "auto-closes in 30 seconds" banner before sleeping. Override with `lingerSecs`:+- `'{"show":true, "lingerSecs":60}'` — give yourself 60s instead of 30+- `'{"show":true, "lingerSecs":0}'` — close immediately, no grace period+- `'{"show":true, "lingerSecs":3600}'` — keep open for an hour (cap)+The response includes the applied `lingerSecs` so you can confirm what landed. Hidden builds (`show:false`) ignore `lingerSecs` entirely — no window to linger.++**Don't call `hd_launch` until `hd_build_status` shows `succeeded: true`** — the guard refuses with `reason: "build_failed"` if you do, and the `_hint` tells the AI exactly what to do (`hd_build_log` to see errors, fix, rebuild). Same for `hd_restart`.++#### Named-region screenshots — `hd_shot` (v1.8.54+)++**One verb replaces the `desktop_list_windows | grep 'Hydrogen' → desktop_screenshot_window <hwnd>` chain** (3 calls + fragile HWND parsing). Captures a named region of Hydrogen, produces BOTH a full-res PNG and a Claude-safe-resized copy, and returns rich metadata so the AI never has to chain screenshot → pull → convert → crop → resize → Read manually.++Each shot is **self-identifying**: it includes the running build's SHA + local build time read from Hydrogen's `/buildinfo` so you can confirm you're looking at the build you expect before trusting what you see (no more "this screenshot was from the previous binary, not my fix" confusion).++Regions are deterministic — they resolve via Hydrogen window bounds + CDP element rects queried through Hydrogen's `/eval` endpoint. No AI-supplied pixel coords.++Args:+- `region` — **REQUIRED**. One of:+ - `full` — whole Hydrogen window+ - `titlebar` — top ~40 px strip (Hydrogen's build SHA / timestamp readout lives here)+ - `vscode` — VS Code iframe pane via CDP element rect+ - `setup-panel` — Setup Steps panel (`.setup-panel` / `.setup-panel-output`)+ - `wiki` — wiki / browser tab pane+ - `claude` — Claude Code panel+- `resizeMax` — optional int, default `1400`. Longest side of the safe-resized copy. Use ≈800 to fit more shots in one Claude turn; 1568 for max fidelity.++Returns:+```+{+ ok, region,+ fullPath, # lossless raw PNG — for shotlog/archival, NOT Read+ safePath, # ≤resizeMax PNG — pull_file this, Read this+ fullSize:[w,h], safeSize:[w,h],+ clipRect:[x,y,w,h], # captured-image pixels (NOT screen pixels)+ capturedAtLocal,+ runningBuildSha, # from Hydrogen's /buildinfo — null if Hydrogen doesn't expose it yet+ runningBuildLocal,+ hdWindowFound:bool, regionFound:bool,+ windowOuter:[w,h], capturedSize:[w,h],+ _hints: {+ pull, # exact pull_file command for safePath+ fullForArchive, # note that fullPath is for archive, not Read+ staleWindow, # set when runningBuildSha looks wrong+ regionMiss # set when regionFound:false+ }+}+```++Recipe — verify-which-build-is-running + confirm-config-change-took-effect + full dump:++```bash+# After hd_ship, confirm the titlebar shows the SHA you expect:+adom-bridge hd_shot '{"region":"titlebar"}'+# → runningBuildSha lets you assert this before trusting any other shot++# After flipping a Setup Steps toggle, screenshot the panel to confirm:+adom-bridge hd_shot '{"region":"setup-panel","resizeMax":1568}'++# Full-window dump for general debugging:+adom-bridge hd_shot '{"region":"full"}'+```++Edge cases:+- **Unknown region** — refusal: `{ok:false, error, knownRegions:[...]}`. No fallback — fix the call.+- **`regionFound:false`** — the element rect wasn't in Hydrogen's DOM. The panel/iframe may not be mounted yet, OR Hydrogen's markup changed and `region_selectors()` in `hd_bridge.rs` is out of date. The shot is **still produced** (falls back to the full window) so you have something to look at; check `_hints.regionMiss` for the reason.+- **`runningBuildSha:null`** — Hydrogen's `/buildinfo` isn't exposed yet on the running version. The shot is still produced; you just can't self-verify which build it represents.++**Always pull_file the `safePath` and Read THAT** — it's already sized for Claude's image analysis. The `fullPath` is for shotlog / disk archival, NOT direct Read (may exceed Claude's per-image budget). The `_hints.pull` field gives you the exact `pull_file` command to copy-paste.++**Old workaround to drop:** Do NOT use `desktop_list_windows | filter 'Hydrogen' | desktop_screenshot_window <hwnd>` anymore for Hydrogen — `hd_shot` replaces that entire pattern. Generic `desktop_screenshot_window` still applies to non-ah windows (other apps, dialogs, etc.).++### TCP Port-forward Tunnels — `tunnel open|close|list|check|rdp` + `display` (v1.8.85+, reworked v1.8.147)++Reach a TCP service addressable only from the **cloud side** through the **laptop side**: ab binds a `127.0.0.1:<port>` listener on the laptop and pumps bytes over the existing ab↔relay WebSocket; the relay dials the upstream `to`. First use case: RDP into a Windows VM behind an NSG only the cloud container reaches — mstsc on the laptop connects to a local port and is talking through the cloud. Same path works for SSH, VNC, HTTP, postgres — any TCP.++**`tunnel` is a grouped verb** (`rdp` / `tunnel_close` / `tunnel_list` remain back-compat aliases):++```bash+# Open a VERIFIED tunnel (both legs probed before ok). Auto-picks a free local port.+adom-bridge --target <name> tunnel open '{"to":"127.0.0.1:13389"}'++# RDP: open + verify + launch mstsc, sized to the laptop's real screen.+adom-bridge --target <name> tunnel rdp '{"to":"127.0.0.1:13389","size":"90%"}'+# size: "90%" (default) | "100%" | "fullscreen" | "maximized"+# OR monitor:<index> (0=primary) OR explicit width+height++adom-bridge --target <name> tunnel list # live tunnels + leak detection+adom-bridge --target <name> tunnel check '{"tunnelId":<id>}' # per-leg health+adom-bridge --target <name> tunnel close '{"tunnelId":<id>}' # or {"all":true}++adom-bridge --target <name> display # monitor geometry (for sizing)+```++**`--target` is mandatory when >1 ab is connected.** v1.8.147 fixed a false-success bug: the bind, the upstream probe, and the mstsc launch now ALL resolve to the SAME machine. (Before, the bind could land on one ab and mstsc launch on another → "port bound" on the VM, "nothing listening" on the laptop, a bogus `ok:true`.) No target + multiple ABs → `ambiguous_target` listing the names; `--target all` is rejected (a tunnel binds one machine). `adom-bridge targets` lists connected ABs.++**Active health verification — every `open`/`rdp`/`check` PROVES the path before reporting `ok`:**+- **local-bind** — ab self-probes `127.0.0.1:<boundPort>` right after binding. If it can't connect, ab tears the tunnel down and returns `ok:false` (this alone kills the false-success class).+- **upstream-refused** — the relay dials `to` (retry/backoff ~4×/2.5s). If unreachable, the tunnel is torn down (no leaked row) and `ok:false`.+- A failure names the leg: `failedLeg: "relay-to-ad" | "local-bind" | "upstream-refused"`. Health fields surfaced: `listening`, `relayConnected`, `upstreamReachable`, `connCount`, `mstscAlive`.++**Progressive hints.** Every response carries `_hint` (what's true now) + `_next` (the exact next verb with real values, copy-pasteable). e.g. a successful open's `_next` hands you the `tunnel rdp` / `tunnel check` / `tunnel close` commands with the real `to`/`target`/`tunnelId` filled in. A missing `to` returns a structured error with an example. Walk the workflow from the responses alone.++**Display-aware RDP sizing.** `tunnel rdp` resolves `size`/`monitor`/`width`/`height` against the laptop's real screen (queried via `display` — `EnumDisplayMonitors` + per-monitor DPI), writes an `.rdp` file with the geometry, and launches mstsc. e.g. `size:"90%"` on a 2560×1600 primary → a 2304×1440 window. Default 90% of primary. You never hardcode a resolution — call `display` if you want the layout first.++**`tunnel list` reconciles + reaps leaks.** It cross-checks the relay registry against every connected ab's own registry and reports `leaks:[...]` for rows only one side knows about; `tunnel close '{"all":true}'` reaps them. The relay stores the actually-bound `localPort` (so `tunnel close '{"local":<port>}'` works).++Architectural notes for Claude:+- Bytes-only — no payload parsing, so any TCP service works. TLS/cert-pinning still pins to whatever the cloud-side dial resolves (for RDP the upstream cert CN must match what mstsc expects).+- Each fresh laptop-side TCP connection gets its own connId multiplexed over one tunnelId; concurrent mstsc reconnects / parallel sessions work without re-opening. Backpressure bounded (256-slot mpsc/conn, 64 KiB buf).+- Tunnels don't survive ab/relay restarts. If mstsc disconnects, `tunnel check` (names the dead leg), then re-open.+- The classic RDP collision (Windows' own RDP owns `0.0.0.0:3389`) is dodged by `rdp`'s default `local:13389` + `SO_EXCLUSIVEADDRUSE` + ephemeral fallback (`fellBack:true` in the response; read `localPort` for the real port).++**Old workarounds to drop**: (1) NSG WAN-IP whitelisting for roaming-laptop RDP — the tunnel rides existing auth, reachable regardless of WAN IP. (2) Trusting a bare `ok:true` from the old `rdp` — it could be a dead port; now `ok` means both legs were probed. (3) Hardcoding mstsc resolution — pass `size`/`monitor`; ab resolves against the real screen.++### Shell — `shell_execute` (escape hatch only)++- `shell_execute` -- Run a shell command on the desktop. The CLI handles approval polling internally — it returns the final `{success, output, error, exitCode}` once the user clicks Allow on the desktop dialog. 2.1.132+: ab holds the prompt for `approvalWaitSec` (default 60, max 600); a lapse answers `errorCode:"approval_timeout"` (nobody answered, NOT a deny: re-send with a longer wait or `request_shell_approval {wait:true}`), an explicit Deny answers `errorCode:"approval_denied"`. **You do not need to poll, retry, or call `get_deferred_result` yourself.** The CLI emits `HINT:` lines to stderr while waiting (every 15s) so an AI Monitor sees progress.+- `shell_kill_all` -- Kill all running shell commands and deny pending approvals.++**Do not put JavaScript or escaped JSON in a `shell_execute` command (2.1.168, wiki #222).** On Windows the command runs through `cmd /c`, and cmd does NOT treat `\"` as an escaped quote. So in `curl -d "{\"js\":\"(()=>{const x=...})()\"}"` the `>` of `=>` (or any `<`/`>` comparison) can land outside cmd's quotes and become a REDIRECT: cmd writes a file named after the next word (`{const`, `e.children.length`) and the command gets mangled input. Send such payloads with `run_script {"interpreter":"powershell","scriptB64":...}` (base64, nothing to escape), or `write_file` the JSON and pass the file (`curl -d @C:/path/body.json`). ab now flags this shape in the reply's `_hint`. With no `cwd`, a command runs in `%TEMP%\adom-bridge-shell` (2.1.168; before that it ran in the Adom Bridge install folder, which is where those stray files landed); pass `cwd` when you need a specific folder.++**v1.7.16 fix.** Earlier versions (v1.7.15 specifically) had a regression where the CLI's approval-wait loop re-sent the command every 1s instead of polling for the deferred result; that produced an infinite "Another shell command is already waiting for approval" loop because each retry created a fresh approval the user could never out-click. v1.7.16 fixes it. If you see that error string with a `>= 1.7.16` CLI, the bug is back — file an issue. If you see it with `< 1.7.16`, upgrade the CLI: `adom-wiki asset get apps/adom-bridge docker_binary -o /usr/local/bin/adom-bridge`.++**Deprecated for download polling.** As of v1.5.0, use `desktop_watch_files` / `desktop_pull_glob` for waiting on download arrivals — those don't require user approval, don't go through PowerShell quoting, and are O(directory entries) rather than spawning a process every second. `shell_execute` itself stays as an escape hatch for genuinely shell-only operations (multi-step ad-hoc admin tasks, chained pipelines, etc.).++Use `where python` / `C:\Python3xx\python.exe` for Python -- avoid `python` which may not be on PATH.++#### `run_script` + `wsl_exec` — base64-payload script runners (v1.8.46+)++When you want to send a multi-line script to the desktop without the shell-quoting hell that `shell_execute` requires, use these two verbs. The script crosses the wire as base64 so NO escaping survives to be mangled by cmd.exe, PowerShell parser quirks, MSYS path conversion, or wsl.exe's `--` / nested-quote / `$_` rules. The caller never hand-escapes anything.++Both verbs require shell auto-approve to be active — same trust level as `shell_execute`. They refuse cleanly with an actionable error if not approved; no per-call modal (these are programmatic verbs).++**Reading the result — `status` + `durationMs` (v1.8.162+).** EVERY CLI result now carries a single authoritative **`status`** field so you never have to guess whether an empty `stdout` was a real empty-output success or a timeout:++| `status` | meaning | what to do |+|---|---|---|+| `ok` | completed (stdout may legitimately be empty) | use `exitCode`/`stdout`/`stderr` |+| `timeout` | did NOT finish in time (NOT an empty success) | raise `timeoutSeconds`, or use a detached job (below) |+| `busy` | desktop couldn't service it (WSL wedged / no client connected) | check `adom-bridge status`; `wsl_recover` for WSL; retry |+| `error` | the command/verb failed | read `error` / `stderr` / `_hint` |++`durationMs` is the wall-clock of the call. `exitCode`/`stdout`/`stderr` pass through unchanged; on `timeout` the verb's `partialStdout`/`partialStderr` hold what was written before the kill. A `timeout`/`busy` result also carries a recovery `_hint`. **For multi-minute work don't fight the timeout** — use a detached job that returns instantly + polls: `wsl_exec_async` / `wsl_import` / `wsl_export` → poll `wsl_job_status` (or `launch_process` for a general long-running command). This is the cure for the "1.4 GB import EOFs the sync call" problem.++**`run_script`** — base64 script + interpreter (cmd / powershell / bash):++```bash+SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b'''+echo \"multi-line script\"+VAR=\"value with spaces and \$dollars\"+echo \"VAR=\$VAR\"+exit 0+''').decode())")+adom-bridge run_script "{\"interpreter\":\"bash\",\"scriptB64\":\"$SCRIPT_B64\"}"+# → {success, ok, stdout, stderr, exitCode}+```++Args: `interpreter` (`cmd` | `powershell` | `bash`), `scriptB64` (required), `timeoutSeconds` (default 60).++PowerShell prefers `pwsh` (PS 7+) if on PATH, falls back to `powershell.exe` (5.1).++- **`bash` runs via WSL2, NOT Git Bash.** The Windows host's first `bash` on PATH is `wsl.exe -d <default-distro> bash` (verified at `C:\Windows\System32\bash.exe`). The script is piped to bash's stdin (no temp file, no path-form mismatch — no MSYS `/S` → `S:/` corruption, no nested-quote loss). The decoded script bytes are written to bash's stdin and stdin is closed; bash reads + executes without ever opening a file. For explicit distro / user control, use `wsl_exec`.++**`wsl_exec`** — base64 script + distro + user, executed via the canonical `wsl -d <distro> -u <user> -- bash -lc <decoded>` argv array:++```bash+SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b'''+cd /mnt/c/Github/adom-bridge+git status --short | head -10+exit 0+''').decode())")+adom-bridge wsl_exec "{\"distro\":\"Ubuntu\",\"user\":\"root\",\"scriptB64\":\"$SCRIPT_B64\"}"+# → {success, ok, stdout, stderr, exitCode}+```++Args: `distro` (e.g. `Ubuntu`, `Adom-Workspace`), `user` (e.g. `adom`, `root`), `scriptB64`, `timeoutSeconds` (default 60).++Why argv (not shell string): Rust's `std::process::Command` on Windows applies proper `CommandLineToArgvW` quoting per argv element. `wsl.exe` sees `bash -lc <decoded>` as three distinct argv items even when the decoded script contains quotes, `$`, backslashes, newlines, pipes — anything. `bash`'s `-c` then sees the entire script as ONE string with no outer-shell interpretation.++Output decoding: `wsl.exe` defaults to UTF-16LE when invoked from the Windows console subsystem. Both verbs auto-detect (BOM check + odd-position-NULL heuristic) and decode to UTF-8. Output is never wrapped in another shell that might re-interpret it.++Pitfalls these verbs eliminate:+- `cmd.exe`'s `&`, `|`, `^`, `%var%` interpretation+- PowerShell's parser quirks around nested quotes + `$` interpolation+- Git Bash MSYS converting `/S` → `S:/` etc.+- WSL's `--` parser limits + nested-quote loss+- UTF-16LE corruption on wsl.exe output++#### WSL wedge defense — `wsl_wedged` + `wsl_recover` (v1.8.157+)++Every `wsl_exec` / `run_script bash` spawns a `wsl.exe` client. If WSL's service (`LxssManager`) wedges — common after a laptop runs for a day, or under heavy parallel cloud use — those clients block in a wait that `taskkill /F` can't always finish, and retries keep spawning more. Left unchecked they pile up into the hundreds and wedge the whole machine. Three defenses:++1. **Refuse-cap.** When there are already too many `wsl.exe` (`WSL_REFUSE_AT=80`), ab refuses NEW wsl spawns with `errorCode:"wsl_wedged"` instead of adding another doomed client. If you get `wsl_wedged`, don't retry the same command — run `wsl_recover` first.+2. **`wsl_recover`** — reap orphaned/hung `wsl.exe`, **DRY-RUN by default** (nothing is killed unless you pass `confirm:true`):+ ```bash+ adom-bridge wsl_recover # dry-run: lists `candidates` (would-reap) + `spared`+ adom-bridge wsl_recover '{"confirm":true}' # reap the candidates+ adom-bridge wsl_recover '{"confirm":true,"sparePids":[1234]}' # also protect pid 1234+ ```+ It classifies each `wsl.exe`: childless + (0-CPU & old | runaway-CPU) → **candidate**; everything else → **spared**. It **spares any `wsl.exe` with child processes** (that's almost certainly the live distro, e.g. the Hydrogen `Adom-Workspace`), honors `sparePids`, and never touches `wslservice`/`wslhost`/`vmmem`. Review each candidate's `cmd` in the dry-run to confirm the live workspace isn't among them, then confirm. **Never** `wsl --shutdown` (it kills the live workspace too). Reaping childless orphans often cascades — their now-parentless children exit on their own (observed 114 → 18 from reaping 54).+3. **status wsl block** — `{wslProcessCount, wedged, _hint}` lets you see a pile-up *before* it blocks work. Path differs by transport: **`status.desktop.wsl`** via the CLI/relay (`adom-bridge status` — nested like `desktop.apps`), or top-level **`status.wsl`** on the local direct API (`GET /status`). `wedged:true` at/above `WSL_WARN_AT=50`.++#### Long-running in-distro work + distro lifecycle — golden-image bakes (v1.8.161+)++`wsl_exec` is **synchronous** — it waits for the command and is bounded by the relay's HTTP timeout (~60-120s), so a multi-minute bake (apt, a big download) EOFs. Backgrounding inside the distro (`nohup … &`) doesn't help either: the `wsl.exe` client returns, WSL sees no active session, shuts the distro down, and kills the bg process (wiping `/tmp`). For anything long, use the **async held-session** verbs — ab holds one `wsl.exe` in the foreground for the command's whole lifetime, so the distro stays up:++- **`wsl_exec_async`** → returns a `jobId` + `outputPath` (a live UTF-8 log) immediately; the command runs detached for as long as it takes.+- **`wsl_job_status {jobId}`** → `{running, exitCode, elapsedMs, outputTail, outputPath}`. Poll until `running:false`; `pull_file` the `outputPath` for the full log. (`wsl_job_list`, `wsl_job_kill {jobId}` round it out.)++```bash+# golden-image bake: import → run (async) → poll → export → unregister, all via ab+adom-bridge wsl_import '{"distro":"bake","installDir":"C:/Users/<you>/.adom/wsl/bake","tarball":"C:/.../base.tar"}'+# → {jobId}; poll wsl_job_status until done (exitCode 0)+B64=$(printf 'set -euo pipefail\napt-get update && apt-get install -y build-essential\n' | base64 -w0)+adom-bridge wsl_exec_async "{\"distro\":\"bake\",\"user\":\"root\",\"scriptB64\":\"$B64\"}"+adom-bridge wsl_job_status '{"jobId":"wsljob-xxxxxxxx"}' # repeat until running:false; outputTail = progress+adom-bridge wsl_export '{"distro":"bake","tarball":"C:/.../golden.tar"}' # poll, then pull_file the tarball+adom-bridge wsl_unregister '{"distro":"bake"}' # cleanup (REFUSES Adom-Workspace)+adom-bridge wsl_list # {distros:[{name,state,version,default}]}+```++**systemd user session — `viaRoot`.** On a `systemd=true` distro, `-u <user>` can fail "Failed to start the systemd user session for '<user>'". Pass `"viaRoot": true` to `wsl_exec_async` — ab runs `-u root -- runuser -u <user> -- bash -l`, dropping to the user WITHOUT depending on `[email protected]`.++`wsl_import` / `wsl_export` / `wsl_unregister` are async (jobId + poll) too, so big imports/exports never EOF. All are **strictly scoped to the named distro** — there is no global `wsl --shutdown` path (that would kill the live Hydrogen workspace), and `wsl_unregister` refuses `Adom-Workspace`. Job ids live in ab's memory only (not persisted across an ab restart; the log file survives). The wedge defense above still applies — an over-cap spawn returns `wsl_wedged`.++## Detecting App Installation++After connecting, always run `adom-bridge status` to check what's installed. The `desktop.apps` object tells you exactly what the user has:++```bash+adom-bridge status+# Look at the desktop.apps field in the response+```++### Handling "not installed" errors++When a command returns `errorCode: "node_not_found"` (a bridge runtime ab manages: see `runtimes`), or a bridge reports its host app missing, guide the user through installation:++**Picking the right native browser + profile (v1.7.1+):**++When you need to open a URL in the user's NATIVE browser AND it matters which account is signed in (work Google Workspace vs personal Gmail vs media YouTube channel etc.), `desktop_open_url` alone isn't enough — you need to target a specific profile. The flow:++```bash+# 1. Discover what's installed + which profiles are configured.+adom-bridge desktop_list_browsers '{}'+# → {+# "default": "chrome",+# "browsers": [+# { "name": "chrome", "displayName": "Google Chrome", "version": "146...",+# "exePath": "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",+# "profiles": [+# { "id": "Default", "name": "Alice (work)", "gaia": "[email protected]", "isDefault": true },+# { "id": "Profile 1", "name": "Personal", "gaia": "[email protected]" },+# { "id": "Profile 2", "name": "Adom Media", "gaia": "[email protected]" }+# ] },+# { "name": "edge", "profiles": [...] },+# { "name": "firefox", "profiles": [{"id":"default-release","name":"default","isDefault":true}] }+# ]+# }++# 2. Match the URL's context to the right profile, then open it there.+# E.g. opening a Google Doc shared by your work team → use the work profile:+adom-bridge desktop_open_url '{+ "url":"https://docs.google.com/document/d/...",+ "browser":"chrome",+ "profile":"Default"+}'+# Or YouTube channel management for the media account:+adom-bridge desktop_open_url '{+ "url":"https://studio.youtube.com",+ "browser":"chrome",+ "profile":"Profile 2"+}'+```++Profile-picking heuristics for Docker Claude:+- **Workspace / @adom.inc URLs** → match `profile.gaia.endsWith("@adom.inc")` and not the `media@` one → typically `Default`.+- **Personal Gmail / Drive / etc.** → match `profile.gaia === "[email protected]"` (or whatever the user's personal address resolves to).+- **YouTube Studio / channel-specific work** → match the media account profile.+- **Random scratch / experiments** → use the user's Edge profile, where they keep miscellaneous accounts (per the user's stated preference).+- **No clear match** → fall back to `browser:"default"` (no profile flag) and let the user pick.++`profile` is optional. Omit it to open in whichever profile the browser was last using (legacy v1.6.x behavior). Profile flag is silently ignored when `browser:"default"` (no clean way to inject through the OS URL handler — name the browser explicitly to use it).++### Decrypting the user's saved browser credentials — `desktop_decrypt_browser_credentials` (v1.9.220+)++> **`reason` is REQUIRED here (v2.0.84)** and it is not for the log: the user reads it+> **on screen**, in a caption above every window, while deciding whether to hand over+> their saved passwords. Write it for them — say what you are trying to accomplish, not+> what the command does. Good: `"Signing the adom-you Chrome profile in as you so I can+> check your orders"`. Weak: `"decrypting credentials"` (that is just the verb again).+>+> **What the user sees before anything is decrypted (three surfaces, v2.0.84):**+> a caption above every window naming **which AI thread** asked, **your reason**, **what+> it wants** (passwords / cookies) and **from which browsers**, plus a warning that+> Windows is about to ask for administrator permission; ab's own consent dialog; and a+> Windows toast carrying the same Continue/Cancel. Any one of them can approve or+> decline. Expect a visible pause here — a human is reading, and if they were not+> expecting it they are being told to choose No.++So pup can run an **"adom-you" Chrome profile signed in AS the user** — surfing the web on the user's behalf — it needs the user's real saved logins/cookies to seed that profile. Chrome 127+ encrypts these with **App-Bound Encryption** (the master key is DPAPI-wrapped under the SYSTEM account), so only an elevated helper can decrypt them. This verb is that path.++```bash+adom-bridge desktop_decrypt_browser_credentials '{+ "reason": "Signing the adom-you Chrome profile in as you so I can check your orders",+ "browsers": ["chrome", "edge"], # optional; default = all installed Chromium browsers+ "include": ["passwords", "cookies"] # optional; default = both+}'+# → { "ok": true,+# "passwords": [ { "origin", "username", "password", "sourceBrowser", "sourceProfile", "sourceAccount", "lastUsed" }, ... ],+# "cookies": [ { "host", "name", "value", "path", "sourceBrowser", "sourceProfile", ... }, ... ],+# "stats": { "profilesRead", "passwordsOk", "passwordsFailed", "cookiesOk", "cookiesFailed" } }+```++**How to call it (this is not a normal verb — read this):**++- **LOCAL / bridge callers only.** A relay (cloud-AI) call is refused unconditionally with `errorCode:"relay_forbidden"` and **pops no UAC** — a remote container must never be able to trigger a password-decrypt. pup (a loopback bridge) and the local CLI reach it.+- **Two human gates, in order:** (1) ab shows an in-window consent dialog + toast naming the requesting caller ("pup is requesting your saved browser passwords/cookies…"); decline → `errorCode:"consent_declined"`. (2) On continue, an elevated signed helper runs behind a **UAC prompt** ("Adom Industries, Inc."); cancel → `errorCode:"elevation_declined"`. The UAC elevation IS the user's permission for the decrypt — by design (John, wiki #457).+- **Plaintext is returned ONCE over the loopback API and is never written to disk or logged.** Don't echo the values into any other verb's args, any activity-log emit, or any file. Consume the rows, seed the profile/vault, drop them.+- **errorCodes:** `relay_forbidden` (you called it over the relay — you can't), `consent_declined`, `elevation_declined`, `helper_missing` (the signed helper isn't bundled — the install is broken). Per-item decrypt failures are counted in `stats.*Failed`, never fail the whole run (a managed-profile policy can block cookies; newest-Chrome `v20` blobs are best-effort if Google rotated the elevation-service key).+- **Pair it with `desktop_list_browsers`** (above) to show the user which browsers/profiles/accounts will be read before you ask.++**Node.js not installed (the puppeteer bridge can't auto-spawn):**++v1.7.0+ — any `pup_*` command returning `errorCode:"node_not_found"` means Node isn't on the desktop machine. Trigger the unattended winget install:++```bash+adom-bridge desktop_install_node '{}'+# → runs `winget install --id OpenJS.NodeJS.LTS --silent ...` (Windows only).+# → ~1-2 min; downloads + installs the Node.js LTS line (well-tested with puppeteer).+# → returns {ok:true, exitCode:0, _hint:"Retry the original pup_* command"} on success.+# → on failure: {ok:false, errorCode:"winget_unavailable" | "winget_install_failed", _hint}.+```++After install, **just retry the original `pup_*` command** — the bridge picks up the new node.exe via the registry App Paths lookup (which winget writes), no Adom Bridge restart needed. v1.7.0+ also pre-fetches Node + Chrome for Testing on first launch after install (background task on AUTOSTART_VERSION 11), so most users will never see `node_not_found` to begin with.++If `desktop_install_node` returns `errorCode:"winget_unavailable"` (older Windows or managed corporate device), fall back to manual:++> Node.js LTS is required for browser automation. winget isn't available on this machine — download Node.js LTS from https://nodejs.org/ and run the installer. Let me know when it's done and I'll retry.++**v1.7.0+ Node detection.** The bridge looks for `node.exe` in:+- System PATH (via `where node` on Windows, `which node` elsewhere)+- Windows registry App Paths (catches winget, MSIX, custom-dir installs)+- Standard install dirs: `C:\Program Files\nodejs\`, `C:\Program Files (x86)\nodejs\`, `%LOCALAPPDATA%\Programs\nodejs\`+- Per-user version managers: nvm-windows (`%APPDATA%\nvm\`), volta (`%LOCALAPPDATA%\Volta\bin\`), fnm (`%FNM_DIR%`)+- macOS Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`)++If detection still misses an install on a user's machine, get the actual `node.exe` path from the user — that's a real bug to file.++**A bridge's host app not installed or not running:** the bridge owns that story. `bridge_readiness {"name":"<bridge>"}` says `no-app` / `warming` / `ready` and its `_hint` says what to do; the bridge's own skill (`sync_skills`) has the install and launch flow. Do not hand the user a download link before asking the bridge: several can install their app unattended.++## Troubleshooting++### Check connection status++Use `status` to see connected clients. A healthy connection shows one client from the user's hostname with a recent `lastPong` timestamp.++### Stale connections causing timeouts++Use `kick_all` to reset -- active Adom Bridge apps reconnect within seconds.++### No desktop client connected++1. Confirm the Adom Bridge app is running on the user's PC+2. Confirm it's pointed at the correct WebSocket URL+3. Check if port 8765 is exposed and reachable++### Relay not running++```bash+curl -sf http://127.0.0.1:8766/health+# If fails: adom-bridge serve --supervise+```++### Shell commands on Windows++`shell_execute` runs in `cmd.exe`. Prefer writing scripts via `send_files` then executing them. Use the full Python path.++## Building from Source++```bash+cd cli && cargo build --release+# Binary at: cli/target/release/adom-bridge+```++## Repo++[github.com/adom-inc/adom-bridge](https://github.com/adom-inc/adom-bridge)++## Run wiki ops on the user's PC — `wiki_exec` (v1.9.116+)++The ab installer bundles the `adom-wiki` CLI next to the app, and `wiki_exec` runs it ON THE PC — so version-controlling a local project (KiCad board, docs folder) on wiki.adom.inc needs NO per-file `pull_file` round trip to the container and there is no container/PC drift.++```bash+adom-bridge wiki_exec '{"args":["repo","push","adom/my-board","--files","board.kicad_pcb","board.kicad_sch","-m","rev B"],"cwd":"C:\Users\me\boards\my-board"}'+```++- `args` = adom-wiki argv (any pillar: repo, pkg, release, discussion). `cwd` = PC-side project dir so `--files` are project-relative. `timeoutSec` optional (default 120, max 600).+- Returns `{success, exitCode, timedOut, stdout, stderr, exe, durationMs}`.+- Auth is automatic: ab injects the signed-in user's session token as `ADOM_WIKI_TOKEN` — the caller never handles credentials.+- Gated like `shell_execute` for relay callers (it writes to the wiki as the user). `errorCode:wiki_cli_missing` means the desktop ab predates 1.9.116.+- Container-side publishing of the CONTAINER's own files still uses your local `adom-wiki` directly; `wiki_exec` is for files that live on the PC.++Note (v1.9.117): ab's Settings panel no longer has a KiCad section — bridges own their app-specific config (ab stays generic). `kicad_library_paths` / `project_watch` keep working from `~/.adom/config.json` and the `set_project_watch` / `trigger_project_watch` verbs.