← Commit history
install.sh+7
@@ -7,6 +7,13 @@ set -eu HERE=$(cd "$(dirname "$0")" && pwd) DEST="$HOME/.claude/skills" mkdir -p "$DEST"+# 1.9.190: purge the pre-rename skill dirs (adom-desktop-*) — they shadow the+# renamed adom-bridge-* skills (same `name:` frontmatter, nondeterministic+# loader pick). Same purge in uninstall.sh. Covers codex installs too.+for legacy in "$DEST"/adom-desktop-* "$HOME/.codex/skills"/adom-desktop-*; do+    [ -e "$legacy" ] || continue+    rm -rf "$legacy"+done n=0 for d in "$HERE"/skills/*/; do     name=$(basename "$d")
package.json+1−1
@@ -4,7 +4,7 @@   "type": "app",   "title": "Adom Bridge (macOS)",   "brief": "Adom Bridge for macOS: the menu-bar daemon that connects AI tools to your Mac.",-  "version": "1.9.188",+  "version": "1.9.190",   "description": "The macOS line of Adom Bridge (Apple Silicon). The app itself ships as the notarized dmg on this page (one-line install: curl -fsSL https://wiki.adom.inc/api/v1/pages/adom-bridge-macos/files/install.sh | sh). This package carries the sixteen macOS consumer skills that teach an AI the Bridge verb surface as it exists on a Mac: capture, input, Accessibility driving, notifications, permissions, presence, bridges, runtimes, auth, embedded mode, multi-machine addressing, and the direct API. Platform layers that need mac Bridge documentation should depend on this package rather than the Windows-line adom/adom-bridge package, whose skills describe the Windows app.",   "org": "adom",   "tags": [
page.json+1−1
@@ -1,6 +1,6 @@ {   "name": "adom-desktop-macos",-  "version": "1.9.188",+  "version": "1.9.190",   "type": "app",   "description": "The macOS line of Adom Bridge (Apple Silicon). The app itself ships as the notarized dmg on this page (one-line install: curl -fsSL https://wiki.adom.inc/api/v1/pages/adom-bridge-macos/files/install.sh | sh). This package carries the sixteen macOS consumer skills that teach an AI the Bridge verb surface as it exists on a Mac: capture, input, Accessibility driving, notifications, permissions, presence, bridges, runtimes, auth, embedded mode, multi-machine addressing, and the direct API. Platform layers that need mac Bridge documentation should depend on this package rather than the Windows-line adom/adom-bridge package, whose skills describe the Windows app.",   "dependencies": {},
skills/adom-bridge-auth/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-auth/SKILL.mdadded+195
@@ -0,0 +1,195 @@+---+name: adom-bridge-auth+description: "Sign in to Adom Bridge and connect a cloud container to the user's Mac: the Connections panel 'Sign in to Adom' browser flow, the shared macOS keychain session (one sign-in serves Bridge and Adom Hydrogen), 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, the cloud key-value config keys (desktop.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, keychain session."+---++# Adom Bridge: sign-in, auth, and cloud configuration++Adom Bridge (formerly Adom Bridge) runs on the user's Mac. You (the AI) usually+run in a cloud container. This skill covers how the two get connected through+the user's Adom account: the modern, zero-paste path, plus the fallbacks.++## The short version++1. From the cloud container, run `adom-bridge setup_desktop`. It returns a+   ready-to-run `register_command`.+2. Run that `register_command` (it is `register_container` with the name + URL+   filled in). This writes the connection into the USER'S ADOM ACCOUNT.+3. The user installs Adom Bridge on their Mac and signs in with their Adom+   account. The container appears in their Connections list and auto-connects.+   No JSON paste at all.+4. Keep the relay running on your side: `adom-bridge serve --supervise`.+   Verify with `adom-bridge targets`.++## How the user signs in (what happens inside Bridge)++The user clicks **"Sign in to Adom"**: a prominent CTA in the Connections+panel when they are signed out (there is also a sign-in link in the footer).+Bridge runs as a menu-bar agent, so a sign-in that needs the window summons it+automatically. Bridge then:++- Creates an auth intent with Carbon (the Adom auth backend) and opens the+  returned `auth_url` in the user's **default browser**.+- The user approves on the Carbon intent screen in the browser.+- Bridge polls the intent every 2 seconds (up to 15 minutes), validates the+  session via `GET /user`, and persists it.++On a fresh, signed-out, standalone launch, Bridge shows a 5-second auto-login+countdown ("Logging you into Adom in 5, 4, 3, 2, 1", with Cancel) and then+opens the sign-in link automatically.++If the browser flow fails or stalls, Bridge shows an in-window banner with a+one-click **Retry** (the 'failed' copy reminds the user to pick their Adom+account in the browser). The sign-in button stays retryable: a stuck poll+never dead-ends the UI.++## One sign-in, shared keychain++The session is stored as a **shared macOS keychain item** that serves BOTH+Adom Bridge and Adom Hydrogen: one Adom sign-in covers the whole family on+that Mac, and the session survives an uninstall/reinstall of either app.+The on-disk cache at `~/.adom/session.json` is just that, a cache: when it is+missing, Bridge rebuilds it from the keychain at launch. Sign-out deletes the+keychain item everywhere, including iCloud-synced copies.++## What sign-in unlocks: auto-discovered servers, zero JSON++Once signed in, Bridge pulls the user's registered containers from their Adom+Cloud profile (the `desktop.container.*` keys in their cloud preferences) and+merges them into the local Connections list:++- **Immediately after an interactive sign-in** (`sync_cloud_servers_now` runs+  as part of completing the flow, then connects the new entries right away:+  the LED goes green in seconds, not after a 30s supervisor tick).+- **At every boot** while a session is present (the boot-time cloud sync).++So the user never pastes server JSON: register the container from your side,+have them sign in, and the connection appears and auto-connects.++## register_container: register THIS container in the user's account++Run this on the cloud container (imperative: you do it, not the user):++```bash+adom-bridge register_container '{"name":"my-container","url":"wss://<container-slug>.adom.cloud/proxy/8765/"}'+```++- `name` (required): the human-friendly label shown in Bridge's server list.+- `url` (required): the public `wss://` URL the desktop will dial, typically+  `wss://<container-slug>.adom.cloud/proxy/8765/`.+- Optional: `id` (defaults to a uuid v4; a caller-provided id stays stable+  across re-runs), `auth_token` (defaults to `adom-dev-token-2025`, what the+  cloud relay accepts by default), `auto_connect` (default true), `enabled`+  (default true).++What it does: authenticates with the session token Adom infra mounts at+`/var/run/adom/api-key`, then PATCHes three cloud key-value config keys in the+user's carbon-preferences:++- `desktop.container.<id>`: the full server entry as a JSON string+- `desktop.last_slug`: the id of this container+- `desktop.enabled`: `true`++Bridge's cloud-config pull reads those keys after sign-in and at boot.++**Only works on Adom cloud containers.** On any other box the mounted token is+absent and the verb returns `ok:false` with an error explaining that; fall+back to paste-JSON (below). Other returns worth knowing:++- Success: `{ok:true, id, name, url, _hint}` with the user's next steps.+- **Idempotent by URL**: calling it again for a URL that is already registered+  reuses the existing entry (refreshes its fields) and returns `ok:true` with+  `was_duplicate:true` and `warning_code:"DUPLICATE_URL_NO_NEW_ENTRY_CREATED"`.+  URL is the natural key: call it once per container, not on every loop. The+  registration persists across sessions, machines, and Bridge reinstalls+  because it lives in the user's account, not on any one machine.+- To revert: PATCH `desktop.container.<id>` to `null` in carbon-prefs.++If Bridge is already installed and signed in when you register, the user can+restart Bridge (or wait: it also polls the cloud config at boot) to see the+new container.++## setup_desktop: the one-command setup flow++Don't hand-build the connection JSON (the number one setup mistake). Run:++```bash+adom-bridge setup_desktop+```++Aliases: `setup`, `connect`, `connect_info`, `connection`, `connection_setup`,+`server_json`; they all run the same thing.++It auto-detects this container's public relay URL and returns:++- `register_command`: the preferred path, a ready-to-run `register_container`+  invocation with this container's name + URL filled in. Run it.+- `server_config`: the fallback JSON to paste into Bridge if+  `register_container` returns `ok:false`.+- `installer_url` / `release_url`: where the user gets the Mac install. The+  one-line install is:++  ```bash+  curl -fsSL https://wiki.adom.inc/api/v1/pages/adom-bridge-macos/files/install.sh | sh+  ```++  (Downloads the notarized dmg from the `adom/adom-bridge-macos` wiki page,+  sha-verified. On machines running Adom Hydrogen, Hydrogen installs Bridge+  automatically.)+- `server_url`, `desktop_connected`, `steps[]`, `_hint`, `_next`.++Either path requires the relay to be running on the container so the desktop+has something to dial:++```bash+adom-bridge serve --supervise+```++Then verify:++```bash+adom-bridge targets+adom-bridge ping+```++`ping` returning `{"echo":"pong","status":"connected"}` means the desktop is+reachable. macOS (Apple Silicon) is the supported desktop platform for this+build. Each container has its own relay: a new container needs its own+registration; entries from previous containers won't work.++## Paste-JSON fallback (non-Adom boxes, or the user won't sign in)++If `register_container` returned `ok:false`, have the user:++1. Install Adom Bridge and open it (the window is summoned from the menu-bar+   item).+2. Paste the `server_config` JSON from `setup_desktop` into the Connections+   panel's **"Paste server JSON to add"** box and press Enter.+3. The server appears and auto-connects: wait for the green dot.++Use the `server_config` exactly as returned; do not hand-assemble it.++## Sign-out++- In the GUI: the **Sign-out** link. It clears the session everywhere: the+  shared keychain item is deleted (including iCloud-synced copies), and the+  `~/.adom/session.json` cache is cleared. Because the keychain item is+  shared, sign-out signs the user out of Adom Hydrogen on that Mac too.+- From the CLI/relay: `desktop_logout` does the same thing. Adom Hydrogen+  calls it on its own sign-out so Bridge doesn't show a stale email in its+  footer.++```bash+adom-bridge desktop_logout+```++After sign-out, Bridge reports no signed-in identity until the user signs in+again. Cloud-discovered servers stop syncing until the next sign-in.++## Related verbs++- `server_add` / `server_remove` / `server_list`: programmatic server+  registration on the DESKTOP side (upsert by name, idempotent); persists to+  the same `~/.adom/config.json` the GUI uses.+- `targets`: list connected desktops; `--target <name>` routes a verb to a+  specific one when several are connected.
skills/adom-bridge-bridge-ownership/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-bridge-ownership/SKILL.mdadded+85
@@ -0,0 +1,85 @@+---+name: adom-bridge-bridge-ownership+description: The boundary between Adom Bridge core and a bridge author — what AD owns vs what you (the bridge) own. Read before filing an AD 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.+---++# Who owns what: AD core vs bridge author++**Read this FIRST if you're 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+one was **bridge code**. The platform never stated the boundary, so the AI guessed+"AD," posted to the wrong place, and hand-edited the wrong copy. Don't repeat that.++## The split++### Adom Bridge (AD) core owns+- The `adom-bridge` CLI + the **relay / `/command` passthrough** (AD relays a+  bridge's response **verbatim** — incl. `_hint`/`_next`/`related` and every other+  top-level field the bridge sets; v1.9.30 made this a denylist, never an allowlist).+- **Bridge lifecycle**: discover/register/spawn/reap, stable ports, the+  `bridge_*` verbs, and **streaming a bridge from its wiki page into the local+  cache** (`bridge_install` / `refresh_bridges` / the 4-h auto-sync).+- The core `desktop_*` verbs (input, screenshot, UIA, tunnels, wsl, …).+- Generic, app-agnostic behavior. **AD returns good DATA so the calling AI decides+  — it does NOT bake in app-specific verbs** (see CLAUDE.md "AD stays GENERIC").++### The bridge author owns (NOT AD)+- **Every bridge-specific verb and its behavior** — args, outputs, and the per-call+  `_hint`/`_next`/`related`/`pitfalls` (the rich-hint contract). A wrong/missing+  hint is a **bridge** fix.+- **Any integration the bridge ships** — e.g. Fusion's **APS** cloud search: its+  OAuth/auth, its config, its `*_aps_*` verbs, its never-charge caps. AD does NOT+  manage it. Divergent results from the bridge's own status verb = a **bridge** bug.+- **The bridge's add-in / plugin code AND getting it deployed into the host app.**+  Editing the streamed cache source is not enough if the host app loads the add-in+  from elsewhere (e.g. Fusion loads from `%APPDATA%\…\API\AddIns\`, not the+  bridges-cache) — wiring cache→host-app is the **bridge's** concern.+- The bridge's `SKILL.md`, `describe` verb, hero/wiki page, and version.+- The **edit-source → publish → bump version → reinstall** loop.++## The cardinal rules++1. **NEVER hand-edit the streamed cache** (`%LOCALAPPDATA%\Adom Bridge\bridges-cache\<bridge>\`).+   It's a download. Edit the **source** (the bridge's own repo/wiki), `publish`,+   bump the version, then `bridge_install`/`refresh_bridges` to pull it. A+   hand-edit is overwritten on the next sync and may not even be what the host app+   runs (cache-vs-deployed-copy gotcha).+2. **Before filing a bridge bug against adom-bridge, ask "who owns this?"** If it's+   verb behavior, a hint, an integration, an add-in, or the bridge's docs → it's the+   **bridge author's** repo/wiki, not AD. Cloud-owned bridges (kicad, fusion, puppeteer)+   live in their OWN wiki repos (see CLAUDE.md "Cloud-owned bridge repos").+3. **AD's job is to relay + host, generically.** If AD is *dropping* or *mangling*+   a bridge's data, or missing a lifecycle/verb capability — THAT is an AD bug.+   Fix the AD VERB; don't re-absorb the bridge.++When in doubt: data-shape/relay/lifecycle/ports → **AD**; what a verb does, its+hints, its integration, its add-in, its docs → **the bridge**.++## Skill ownership — a bridge ships its OWN skill pkg (not bundled in AD-core)++The same boundary applies to the **consumer skill** (the SKILL.md that teaches a cloud+container how to drive a bridge's verbs):++- **The AD-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+  would go stale and isn't AD'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.)+- **Each cloud/3rd-party bridge publishes its OWN skill pkg** carrying its SKILL.md, e.g.+  `adom/adom-bridge-kicad-bridge`, `adom/adom-bridge-fusion-bridge`,+  `adom/adom-bridge-puppeteer-bridge`. The cloud thread that owns the bridge owns + bumps it.+  (Puppeteer's runtime still ships as an NSIS-bundled SEED for fresh-PC/offline spawn — but its+  *skill* now ships from its own page, like the other cloud bridges.)+- **How a container gets dynamic bridge skills:** it does NOT get them from the AD-core install.+  It (a) installs AD-core (CLI + core skills), then (b) `adom-bridge bridge_list` against the+  live laptop AD to see which bridges are present, then (c) `adom-wiki pkg install+  adom/adom-bridge-<bridge>-bridge` for each one it needs (and `adom-wiki pkg update` keeps them+  fresh, version-matched to the bridge the cloud thread published). The bridge's runtime PAYLOAD+  (what AD spawns on the laptop) is a SEPARATE artifact — the `/files` manifest+zip that+  `bridge_install`/`refresh_bridges` pull — don't conflate it with the skill pkg.+- **Discoverability gap / enhancement:** `bridge_list` should surface the exact+  `adom-wiki pkg install adom/adom-bridge-<name>-bridge` line per live bridge so a container+  knows precisely which skill pkg to pull (today it infers the slug from the bridge's `docs`).
skills/adom-bridge-bridge-sdk/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-bridge-sdk/SKILL.mdadded+573
@@ -0,0 +1,573 @@+---+name: adom-bridge-bridge-sdk+description: Author, PUBLISH, and make-discoverable an adom-bridge bridge — a small local server that adds a namespace of verbs (kicad_, fusion_, browser_, your_prefix_) to adom-bridge. Use when building, publishing, updating, prewarming, or debugging a bridge, or deciding what skills to ship. Covers the AD-core-vs-author boundary; the #1 rule (rich hints in EVERY verb's OUTPUT, because the AI reads CLI output, never your on-disk skill); bridge.json; where every file lives (the Release-zip-vs-skills-pkg split, the TWO package.json files [server deps in the zip; the pkg declares dependencies:{adom/adom-bridge}], manifest + runtime-asset placement); the pkg carries ONLY user skills while dev + publish skills live in your source repo (not the pkg); the THREE things you publish (runtime Release zip + manifest, container skills pkg, wiki discovery_triggers); prewarm; and the auto-discover → install-check → use flow.+---++# Bridge SDK — build, publish, and surface a bridge for Adom Bridge++A **bridge** is a small local program (Python, Node, Rust, anything) that AD spawns and routes a **namespace of verbs** to over HTTP. KiCad, Fusion 360, Puppeteer (pup), native-browser, Blender are bridges. The modern flow has THREE moving parts you own — get all three right and a cloud/WSL2 container can *discover* your bridge from a plain-English request, confirm it's installed, and drive it with zero tribal knowledge:++1. **Runtime** — a versioned **Release `.zip`** + a **manifest** on your wiki page. AD's `bridge_install` streams it onto the user's machine and spawns it.+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 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. AD 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.)++---++## Where every file lives — the two artifacts, their deps, and which skills go where++**Two DISTINCT artifacts, two DIFFERENT homes, two DIFFERENT consumers. Crossing them is the #1 inconsistency across bridge repos — pin it here before anything else.** (Live audit 2026-07-06: fusion + pup do this right; kicad had **61** loose `adom-bridge-kicad-v*.zip` on `/files` + a `mesa-llvmpipe-x64.zip`; blender dumped 2 loose zips too.)++| | **RUNTIME artifact** (Artifact 1) | **SKILLS pkg** (Artifact 2) |+|---|---|---|+| **What it is** | your bridge's actual CODE | your consumer `SKILL.md` docs |+| **Runs on** | the user's **DESKTOP** — AD spawns it | **nothing runs it** — docs the CONTAINER reads |+| **Published as** | a versioned **Release** (`release create` + `release upload --platform any`) | an **`adom-wiki pkg`** (`pkg publish --org adom`) |+| **Installed by** | `bridge_install {manifestUrl}` → AD streams, unzips, spawns | `pkg install adom/<slug>` / `sync_skills` → `~/.claude/skills` + `~/.codex/skills` |+| **Contains** | server code + `bridge.json` at the zip **ROOT** + assets. **Node: SOURCE ONLY, no `node_modules`.** | ONLY: ROOT `SKILL.md`, `skills/<name>/SKILL.md` (user skills), `package.json`, `install.sh`, `uninstall.sh`. **Text only — the whole tarball should be a few KB.** |+| **NEVER contains** | the skills pkg | **ANY image (hero / screenshots — those are WIKI PAGE assets, see below), any binary, the Release zip, `src/`, `node_modules`, dev/publish skills, or the server's npm deps.** If `tar tzf` shows a `.png`/`.jpg`/`.zip`/`.exe`, you bundled bloat — remove it. |+| **its `package.json`** | (node) the **bridge SERVER's runtime deps** AD installs at spawn: `dependencies:{ puppeteer, … }` | the **pkg manifest**: `files[]` (user skills), `discovery_triggers`, scripts, **AND `dependencies:{"adom/adom-bridge":"^1.9.x"}`** (the container needs the AD CLI + core skills to drive you) — but NOT the server's npm deps |++**Yes, a node bridge has TWO `package.json` files and they are NOT interchangeable.** One rides inside the Release zip and lists the **bridge server's** runtime deps (puppeteer, sharp, ...) that AD installs on the *desktop* at spawn. The other is the **pkg manifest** in the skills tarball: it lists your user-skill `files[]` + `discovery_triggers` AND declares **`dependencies:{"adom/adom-bridge":"^1.9.x"}`** — `pkg install` resolves that npm-style, so installing your bridge's skill pkg also pulls the adom-bridge CLI + core skills the container needs to drive you. What the pkg manifest must NOT list is the **server's** npm deps (those are desktop-side, in the zip) — put them there and you bloat every container install, since the container never runs your server.++**Where each file physically lives (this is the map that ends the inconsistency):**+- **`bridge.json`** → the **root of the Release zip** (AD reads it post-unzip). Carries `version` + `updateManifestUrl`.+- **The Release `.zip`** → upload it as a **Release asset** (`release upload … --platform any`) and point the manifest `url` at its **release-download path**: `https://wiki.adom.inc/download/adom/<slug>/<ver>/<file>.zip` (verified to serve anon 206). **fusion + pup do exactly this — copy them.** A loose `/files` blob technically serves too (206), so this is a HYGIENE + right-home rule, not "it won't work": the `/files` store is your git **page repo** (for the manifest, text, images), so a binary there accumulates forever (kicad has **61** stale `-v*.zip`), isn't pinned to a Release, and the `/blob/...` URL is just an **alias of the same blob** (same sha) — you can't tidy it. **⚠ Migrating off `/files` is a SEQUENCE, not a delete:** (1) `release upload` the zip, (2) repoint the manifest `url` at the `/download/` path, (3) verify it serves anon, (4) *only then* `repo rm` the old `/files` copies. **NEVER `repo rm` a zip while your manifest still points at it** (or at its `/blob` alias) — that breaks `bridge_install` + auto-update instantly.+- **The manifest** (`adom-bridge-<name>-manifest.json`, this naming is the consistent one — keep it) → POSTed to your page **`/files`**. Its `url` points at the **Release download** (`https://wiki.adom.inc/download/adom/<slug>/<ver>/<file>.zip`), base-relative + extension-agnostic. `updateManifestUrl` (in the manifest AND in bridge.json) points back at this manifest.+- **A big RUNTIME ASSET** (a headless-GL pack like Mesa, a browser download) → declare it in **`prewarm.assets`** so AD downloads + manages it, or bundle it in your seed. **NOT a loose `/files` blob** — kicad's `mesa-llvmpipe-x64.zip` on `/files` is the anti-pattern.+- **Your hero image + screenshots** → **WIKI PAGE assets** — set them on the page (the page's hero / `repo push` the image to the page), **NEVER in the skills tarball.** A container that `pkg install`s your skills never renders a hero, so a bundled hero (a real one was **1 MB**) is pure download bloat on every install. **Do NOT put `hero: docs/hero.png` + `docs/**` in the pkg `package.json`** — that is exactly what stuffs the image into the tarball.++**The three kinds of skill — where each goes (this IS the demarcation, one table):**++| Skill kind | Who reads it | Lives in | In the pkg tarball? | Public? |+|---|---|---|---|---|+| **USER** (the public / consumer skill) | a consumer AI driving your verbs | your **pkg** | **YES** — that is the pkg's whole job | yes (the pkg is public) |+| **DEV** | a maintainer editing the bridge | your **source repo** | **NO, never** | only if your repo is public |+| **PUBLISH** | a maintainer releasing the bridge | your **source repo** | **NO, never** | only if your repo is public |++**The one rule:** the pkg tarball carries **USER skills only**; **DEV + PUBLISH skills live in the source repo.** Do NOT "scope" dev/publish into the pkg with `user-invocable:false` — that still ships them (pure bloat). Open-vs-closed source only sets your repo's *visibility*; it never changes what's in the pkg. adom-bridge core does exactly this: user/integration skills in the pkg; `RELEASE` / `WIKI_PUBLISH` / `SIGNING` / `AD_RELAUNCH` live only in the source repo.++---++## The boundary — what AD owns vs what YOU own++The #1 mistake is filing AD bugs for bridge issues. The split:++**AD core owns** (don't reimplement): the CLI/relay/`/command` passthrough (AD relays your JSON verbatim — every field); spawning/reaping your process, the **stable port**, single-instance, the persistent-respawn supervisor, cache install + auto-update; host/process facts (running? pid? port?), the status-chip rendering, the GUI card, the activity log; `status`/`_hint` classification for timeouts/disconnects (AD authors those — you never stamp them).++**You own**: your verbs + their behavior + their **rich hints**; your `/health` (+ optional chip fields); your `<prefix>describe` catalog; your wiki page (hero, brief, `discovery_triggers`, docs); your published **manifest + Release zip**; and your **skills pkg**.++If a *bridge verb* misbehaves → fix the bridge. If a *generic AD capability* is missing (a new lifecycle verb, a relay field) → file it against AD, but don't re-absorb the bridge into AD.++---++## Calling AD back (outbound) — you reach the FULL AD verb set (v1.9.84)++Your callback channel is AD's loopback direct API: `POST <ADOM_DIRECT_API_URL>/command` (env var; fallback `~/.adom/direct-api-port` = `host:port`). As of **AD v1.9.84** this reaches **every dispatchable AD 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 ADs on the relay), `ping`, `bridge_list`, `runtimes`, `status`, … — plus every `desktop_*` and every *other* bridge's verbs.+- **Cross-AD (`target`):** add `"target":"<clientName>"` (from `targets`) or `"all"` to route the call to a PEER AD 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).** AD injects `ADOM_BRIDGE_TOKEN` into your spawn env. Send it as the `X-Adom-Bridge-Token` header and AD badges your calls "bridge" in its Activity Log — nice for the user's transparency. It is **NOT** an approval gate: you're trusted by transport (AD 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 → 403 (re-read it from your env after an AD restart); omitting it is fine.+- **`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 AD is embedded under (Adom Hydrogen). Pass `focus` whenever the toast is *about* a window, so the click lands there instead of opening AD.++```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.++---++## 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 — AD 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. AD 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.+- **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.++---++## Runtime contract — AD provisions Node/Python; you bind loopback (v1.9.63 / v1.9.69)++**The single most important change for a Node or Python bridge.** As of AD **v1.9.63** you no longer bootstrap your own interpreter, and you MUST bind loopback. Four rules:++1. **Don't bootstrap your own Node/Python.** AD owns a global, portable, **no-UAC** runtime under `~/.adom/adom-runtimes/`: a system install if one's on PATH, else a portable copy it downloads + prewarms in the background on first launch. AD spawns your `entrypoint` with that interpreter **by absolute path**. So **delete every `winget install node` / `desktop_install_node` / "please install Python" / self-download step** from your bridge and its skills. `spawn.kind: "node"|"python"` IS your runtime declaration — AD reads it and provisions the runtime. Anyone can check status with the **`runtimes`** verb (`state ∈ absent|installing|ready|failed`, `source ∈ system|cache`, version). AD pins the version (Node 22.11.0 / Python 3.12.13 today) — need a different major? ask the AD-core thread; don't ship your own.++2. **Bind `ADOM_BIND_HOST`, never `0.0.0.0`.** AD passes an **`ADOM_BIND_HOST`** env var (always `127.0.0.1`) to every bridge it spawns. A bare listen that defaults to `0.0.0.0` pops a **Windows Firewall "allow access?" dialog** on first run — and AD's hard guarantee to users is **no firewall prompt by default**.+   - Node: `server.listen(PORT, process.env.ADOM_BIND_HOST || '127.0.0.1', cb)`+   - Python: `HTTPServer((os.environ.get('ADOM_BIND_HOST', '127.0.0.1'), PORT), Handler)`++3. **Ship the Release zip source-only — AD installs your deps, and v1.9.69 makes it bulletproof.** A Node bridge's zip carries SOURCE only (no `node_modules`). AD reuses the bundled seed's `node_modules` via `NODE_PATH` when versions match, else runs **`npm install --include=optional`** for you with the managed runtime's own npm. **As of v1.9.69 AD puts the managed runtime's dir on the child PATH for that install AND for your spawned process** — so a dep's postinstall / `node-gyp` / `prebuild-install` that shells out to a **bare** `node`/`npm`/`npx` (puppeteer's own `node install.mjs`, `sharp`, `keytar`, `better-sqlite3`, `bcrypt`) resolves even on a machine with **no system Node**. **You do NOT add the runtime to PATH yourself** — AD guarantees it. (Python: the interpreter dir + `Scripts/` are on PATH too, so pip can build wheels.)++4. **Bundle deps in your SEED for an instant first spawn.** Source-only zip + a bundled-seed `node_modules` at the same version = AD reuses the seed via `NODE_PATH` (instant); a wiki version newer than the seed forces one cold `npm install` (now PATH-correct, but slower). Bump the seed in lockstep with each Release.++5. **⛔ Loopback is NOT an authorization boundary — a WEB PAGE the user visits can reach you.** Rule 2 stops the *network*; it does nothing about the *browser running on the same machine*. Any page the user opens can `fetch('http://127.0.0.1:<your-port>/…')`, and your port is discoverable by scanning. Three bridges independently shipped the same two defects (measured 2026-07-24), so assume you have them until you check:++   - **Never send `Access-Control-Allow-Origin: *`.** That header is what lets the attacking page *read your response* — without it the browser blocks the read even though the request was sent. Bridges add it to "make CORS work" during development; nothing in AD's own architecture needs it, because AD's callers are Rust and Python processes, not browsers. Send **no ACAO header at all**.+   - **Require `Content-Type: application/json` on every mutating request, and reject anything else.** This is the control that actually stops the request rather than the read. A browser sends `text/plain`, `application/x-www-form-urlencoded` and `multipart/form-data` cross-origin with **no preflight** — so a page can fire a state-changing POST at you and never ask permission. Demanding JSON forces a preflight, which your (absent) ACAO then fails. Parse-JSON-or-reject is not enough if you accept the body regardless of its declared type.++   **AD's own direct API is the reference implementation.** Measured against a live AD (2026-07-24), sending a hostile `Origin`:++   ```+   OPTIONS /command   ->  405 Method Not Allowed,  allow: POST,  no ACAO header+   POST /command      ->  415 Unsupported Media Type+     Content-Type: text/plain    "Expected request with `Content-Type: application/json`"+   ```++   Note the 415 arrives **before** the body is parsed — the request is refused on its declared type, not merely failed at parse time. That is the difference that matters: a bridge which happens to throw on non-JSON input has still *executed* whatever ran before the parse. Copy this shape (see `skills/DIRECT_API.md`); do not copy a dev-time `cors()` middleware into a release.++6. **⛔ macOS: NEVER write next to your own entrypoint.** On macOS the app bundle is a **signed, sealed, read-only artifact**. Adding *any* file under `Contents/Resources` — a log, a cache, a screenshot, a `__pycache__` — invalidates the code signature. The failure is silent at write time and shows up later as:++   ```+   codesign --verify --deep --strict "…/Adom Bridge.app"   ->  a sealed resource is missing or invalid+   spctl -a -vvv -t exec            "…/Adom Bridge.app"    ->  a sealed resource is missing or invalid+   ```++   **Windows has no analogue**, so nothing in a Windows-developed bridge's history will ever flag this. Three bridges had an instance of it on 2026-07-24. The pattern is always the same: a path anchored to the *executable's own directory* rather than to user state —++   | Language | The trap |+   |---|---|+   | Python | `__pycache__` written beside `server.py` (AD spawns you with `current_dir` = your bridge dir). AD now sets `PYTHONDONTWRITEBYTECODE=1` for you — **do not unset it**. |+   | Node | `path.join(__dirname, 'screenshots')`, `…, 'recordings')`, `…, 'logs')`. **No env var can save you here** — there is no Node equivalent of `PYTHONDONTWRITEBYTECODE`. You must not write the path. |+   | Any | a log file, a lockfile, a `.pid`, or a downloaded asset placed beside the entrypoint. |++   **Write user state under `~/.adom/<your-bridge>/` or the OS temp dir. Always.** A cache-installed bridge currently escapes this because AD materialises it outside the bundle — but that is *precedence*, not protection: a **fresh install before the first cache update, or an offline machine, runs your BUNDLED SEED from inside the .app**, which is exactly where the seal is. Do not rely on the mask.++7. **⛔ If YOU download something executable, YOU must verify it — a hash is not a check if it came from the same place as the URL.** The SDK tells you where artifacts *live* (`prewarm.assets`, Release downloads); it has never told you what to do when your bridge fetches a **browser, an installer, or a host app** itself. Several bridges do, with no verification at all.++   **First choice: don't.** Declare it in **`prewarm.assets`** and let AD download and manage it — one downloader to fix instead of N.++   > **⚠ Honest disclosure, measured 2026-07-24: delegating to AD does not currently buy you verification.** AD's own runtime downloader (`runtime_cache.rs`) fetches the Node and Python tarballs over HTTPS and extracts them with **no digest check, no signature check, no pinned hash, and no redirect policy** — every `sha`/`hash` token in that file is a `HashMap`. The `prewarm.assets` path is the same. So "declare it and let AD handle it" is the right *architecture* (one place to fix, and AD is where the fix belongs) but is **not, today, a security control**. It is listed first because centralising the problem is how it gets solved, not because it is already solved. Tracked on AD's side; do not cite this line as coverage you have.++   **If you must fetch it yourself**, all four:+   - **Pin the URL in your source.** A URL read from a remote document you also trust for the hash is one compromise, not two.+   - **Don't follow redirects to an arbitrary host.** Most HTTP clients follow up to ~10 hops by default (`reqwest`, `axios`, `requests` all do). Either disable redirects or re-check the scheme and host after each hop — otherwise the bytes you verify may come from somewhere you never named.+   - **Verify a digest you did NOT get from the same response or document.** A checksum served alongside the file, or read from the manifest that supplied the URL, proves only internal consistency. Pin the expected hash in your source, or fetch it from an independent origin.+   - **For anything you will EXECUTE, prefer the OS signature check over a hash** — `codesign --verify --deep --strict` plus a Team ID comparison on macOS, Authenticode plus a publisher check on Windows. A signature is unforgeable by whoever controls the download path; a hash is only as trustworthy as wherever you got it.++   **Why this rule is not theoretical.** On 2026-07-24 AD's *own* published manifest advertised one version while pointing at an older installer. The checksum in that manifest **matched the file it pointed at**, so verification passed and the wrong artifact would have installed cleanly — no attacker involved, just a stale URL in a trusted document. **Self-consistent verification detects nothing.** That is the failure this rule exists to prevent, and AD had it too.++**Net:** declare `spawn.kind`, bind `ADOM_BIND_HOST`, ship source-only, send no ACAO while demanding JSON, never write beside your entrypoint, and verify anything you download against something you pinned yourself. AD does the rest — no UAC, no firewall prompt, no `'node' is not recognized`.++---++## Staying current — catch up on SDK changes yourself (no prompt needed)++This SDK evolves. When it does, AD 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-bridges` (or `curl -s https://wiki.adom.inc/api/v1/pages/adom-bridge-bridges/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 AD uses to reach you, so closing the loop there is how AD knows you are current.++---++## 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.++**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 AD 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)*:+- [ ] 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 AD 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 send **no `Access-Control-Allow-Origin` header** — `grep -rn "Access-Control\|cors(" <your src>` returns nothing in shipping code. *(→ Runtime contract rule 5)*+- [ ] Every **mutating** request is rejected unless `Content-Type: application/json`; `OPTIONS` returns **405**, not a permissive preflight. Loopback binding does NOT protect you from a web page. *(→ Runtime contract rule 5)*+- [ ] If you **SPAWN** a child process or sub-server, it inherits `ADOM_BIND_HOST` (or binds loopback explicitly) — being a correct listener does not make you a correct spawner.+- [ ] **Anything you download and then EXECUTE** (a browser, an installer, a host app) is either a declared `prewarm.asset` (AD manages it) or: URL pinned in source, redirects not followed to an arbitrary host, digest pinned independently of the download, and an OS signature check (`codesign` + Team ID / Authenticode + publisher) before you run it. A checksum served with the file verifies nothing. *(→ Runtime contract rule 7)*+- [ ] **macOS seal:** no write path is anchored to your entrypoint's directory. `grep -rn "__dirname\|Path(__file__).parent\|dirname(__file__)" <your src>` — every hit that *writes* must move to `~/.adom/<your-bridge>/` or temp. Verify with `find "/Applications/Adom Bridge.app" -newer "…/Contents/Info.plist" -type f` after exercising your verbs: it must print nothing. *(→ Runtime contract rule 6)*+- [ ] You do **NOT** bootstrap your own Node/Python — no `winget`/`desktop_install_node`/self-download in code or skills. `spawn.kind` is the declaration; AD provisions it.+- [ ] Release zip is **source-only** (no `node_modules`); you rely on AD'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)*++**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 AD 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).++---++## bridge.json — the manifest that ships INSIDE your zip++```jsonc+{+  "manifest_version": 1,+  "name": "blender",                        // registry name → verb prefix "blender_"+  "displayName": "Blender",                 // in-app chip label (the tool's real name)+  "version": "1.2.0",                       // numeric; a newer CACHE copy supersedes the bundled seed+  "description": "Drive Blender headless + GUI",+  "author": "you",+  "license": "MIT",+  "docs": "https://wiki.adom.inc/adom/adom-bridge-blender-bridge",  // your OWN page (drives skillPkg + hero)+  "hero": "https://.../hero.png",           // optional; else AD uses your wiki page hero, else a monogram. Ship it at 1.6 aspect (≈2000×1250) — see "Card hero + update cost"+  "spawn": {+    "kind": "python",                       // python | node | exe | external-http  (NOT "process")+    "entrypoint": "server.py",              // the file/exe AD launches; AD AUTO-APPENDS `--port <port>` to argv+    "port": 0,                              // 0 = AD picks a STABLE port (recommended). Bind 127.0.0.1:<that port>.+    "persistent": true,                     // AD's supervisor respawns it if it dies+    "healthEndpoint": "/health",            // INSIDE spawn. AD probes it for the chip (default /health)+    "stopMethod": "kill",                   // kill | sigterm | graceful_endpoint+    "killImageName": "python.exe"           // image name for taskkill on stop (kind:python/node)+  },+  "verbPrefixes": ["blender_"],             // any verb with these prefixes routes to you+  "verbs": ["blender_render", "blender_status", "blender_describe"],  // every verb (routing + Verbs tab)+  "statusVerb": "blender_status",           // verb a caller polls after a long op times out+  "updateManifestUrl": "https://wiki.adom.inc/api/v1/pages/adom-bridge-blender-bridge/files/blender-manifest.json",++  "timeouts": {                             // v1.9.79 — per-verb HTTP budgets (seconds); see note below+    "default": 60,                          //   fallback for any verb not matched (absent → AD's global 60s)+    "verbs": { "render": 600, "export_step": 360 },        // exact BARE verb names (no prefix)+    "prefixes": [ { "prefix": "export_", "seconds": 120 } ] // fallback rules, first match wins+  },++  // v1.9.47 cold-start (see "Cold-start tiers" below):+  "detect": {                               // Tier 2 — how AD DETECTS your HOST APP. AD NEVER installs it.+    "hostApp": "Blender",                   // omit the whole `detect` block if your bridge brings its own runtime+    "appPathsExe": "blender.exe",           //   (e.g. pup ships its own browser → no host app)+    "paths": { "windows": ["%ProgramFiles%\\Blender Foundation\\*\\blender.exe"] }+  },+  "prewarm": { "assets": [] }               // Tier 1 — heavy runtime assets AD prewarms (e.g. ["chrome-for-testing"])+}+```++- **`spawn.kind`** is `python` | `node` | `exe` | `external-http` — there is **no `"process"` kind**, and no `cmd`/`args`/`cwd`. AD launches `spawn.entrypoint` and **automatically appends `--port <port>`** to its argv; your server parses `--port`. (`external-http` = AD doesn't spawn, only probes a port you declare via a `discovery` block.)+- **`spawn.port: 0`** → AD allocates a STABLE port (persisted across respawns + AD restarts) and passes it. Bind `127.0.0.1:<port>`. NEVER hardcode a port (Hyper-V/Docker silently reserve ranges — see PORTS.md).+- **`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; AD polls it (on launch + every 4h) and auto-updates users. (AD also remembers the install URL in `~/.adom/bridge-install-urls.json` as a fallback, but the declared field is the durable, author-intended path.)+- **`docs`** — point at YOUR dedicated wiki page. AD derives your **skillPkg** (`adom/<slug>`) and your card hero from this. 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 (AD detects, never installs); `prewarm` = your heavy RUNTIME assets (AD 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) AD 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` → AD's global 60s. A timeout is **NON-terminal**: AD 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). AD reads it with ZERO AD change; the two bundled EDA bridges keep a labeled built-in table only until they declare their own.++---++## Artifact 1 — the RUNTIME: publish your `.zip` as a RELEASE + a manifest++This is the code AD streams onto the user's machine. Two files on your page:++1. **A versioned Release `.zip`** of your bridge dir (server + `bridge.json` at the zip ROOT + any assets). Ship it as a **Release** (the downloadable, version-pinned artifact), not a loose `/files` blob:+   ```bash+   adom-wiki release create adom/adom-bridge-<bridge>-bridge <version> --title "..." --changelog "..."+   adom-wiki release upload adom/adom-bridge-<bridge>-bridge <version> your-bridge-v<version>.zip --platform any+   ```+   (Node bridges: the zip carries SOURCE only — keep `node_modules` out; AD/the seed handle deps. See Prewarm.)+2. **The manifest JSON** (e.g. `blender-manifest.json`) — identity + where the zip is — POSTed to your page `/files`:+   ```jsonc+   { "name":"blender", "version":"1.2.0",+     "url":"https://wiki.adom.inc/download/adom/adom-bridge-blender-bridge/1.2.0/blender-v1.2.0.zip",  // base-relative, extension-agnostic (.bin works where wiki blocks .zip)+     "sha256":"…", "size":123456, "verbPrefixes":["blender_"], "healthEndpoint":"/health",+     "updateManifestUrl":"https://wiki.adom.inc/api/v1/pages/adom-bridge-blender-bridge/files/blender-manifest.json" }+   ```++**Users install/update** with AD's `bridge_install {manifestUrl}` → AD downloads the zip, sha256-verifies, unzips into `%LOCALAPPDATA%\Adom Bridge\bridges-cache\<name>\`, rescans, and remembers the URL for auto-update.++**To ship a new version:** bump `version` in BOTH `bridge.json` (in the zip) and the manifest, re-`release upload` the zip, re-POST the manifest. Users auto-get it on the next 4h poll, or immediately via `refresh_bridges {name}`. **A bundled SEED** (a `plugins/<name>/` folder shipped in AD's NSIS) is the offline/first-run fallback; your published cache copy with a higher `version` supersedes it (cache-over-bundled, version-aware). ⚠ **A `release upload` REPLACES a same-platform asset ONLY when the FILENAME is IDENTICAL** — a different name (a version bump `-v1.2.0` → `-v1.3.0`) ADDS a second asset, leaving a stale zip on the release. Keep ONE asset per release: overwrite the same filename, or `adom-wiki release delete-asset <owner/slug> <ver> "<old-filename>"` after a rename. (A stale extra won't break the manifest-URL install, but it clutters the release.)++---++## Artifact 2 — the SKILLS pkg: container-side docs (`.claude/skills` / `.codex/skills`)++Your runtime runs on the user's DESKTOP; the AI driving it runs in a cloud/WSL2 CONTAINER. That container needs your **consumer SKILL.md** locally to know your verbs. Ship it as its own **`adom-wiki pkg`** (skills only, NO binaries):++**Layout** (reference: `adom/adom-wiki-skillpack`):++- **The pkg carries ONLY your USER skill(s).** Each is a real `skills/<name>/SKILL.md`. A ROOT `SKILL.md` (required by the registry) is the pack entry point: `user-invocable: true`, a `description` full of trigger words. Large surface → a core user skill + user sub-skills, but **every skill in the pkg is a *user* skill.**+- **DEV + PUBLISH skills do NOT go in the pkg — they live in your source repo** (a developer gets them by cloning to edit the bridge). Do NOT list them in `files[]`, and do NOT "scope" them in with `user-invocable:false` (that still ships them and bloats every container install).+- **Declare `dependencies:{"adom/adom-bridge":"^1.9.x"}`** in `package.json` — `pkg install` resolves it npm-style, so installing your bridge's skill pkg also pulls the AD CLI + core skills the container needs to drive you.+- **Each sub-skill's body opens with a `Parent skill:` line** (first line after the frontmatter): `Parent skill: **adom-bridge-<bridge>-bridge**`.+- **List every user-skill path EXPLICITLY — no globs — in `package.json` `files[]` AND `install.sh`/`uninstall.sh`** (one `install_skill <dest> <src>` line each, kept in sync). The publish linter REQUIRES explicit listing. (The old "use `skills/**`" advice is dead.)++```bash+# Stage dir: ROOT SKILL.md + skills/<name>/SKILL.md per USER skill (dev/publish stay in the repo),+# package.json {name:"adom-bridge-<bridge>-bridge", version, discovery_triggers,+#               dependencies:{"adom/adom-bridge":"^1.9.x"},+#               files:[ every USER-skill path, explicitly ], scripts:{install,uninstall}}.+# TWO traps that still bite — verify each with `tar tzf` before publishing:+#  1. ⚠ pkg pack/publish HONORS the stage dir's .gitignore — a gitignored skills/ ships+#     a 3 MB tarball with ZERO skills. Set .gitignore aside across pack+publish, restore after.+#  2. ⚠ Keep the tarball LEAN — NO release zip, NO src/ tree, NO hero PNGs (it was 16 MB+#     before guards). Skills + docs only.+adom-wiki pkg pack                       # then: tar tzf <tgz> → every skills/<name>/SKILL.md+                                         #   listed in files[] present, ZERO binaries+adom-wiki pkg publish --org adom         # publishes adom/adom-bridge-<bridge>-bridge+```++**`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` (AD derives `adom/<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.++**Install to BOTH agent homes.** A container may run Claude OR Codex — your pkg's `install.sh` must deploy the user skill to **`~/.claude/skills/`** AND **`~/.codex/skills/`** (Codex is a real second skills home now). `adom-wiki pkg update` keeps both fresh.++### ⭐ REQUIRED: say on your PAGE that `pkg install` gives SKILLS, not the bridge++Your page carries **two artifacts that look interchangeable and are not**, and users (and AIs) reasonably assume `adom-wiki pkg install adom/<your-bridge>` installs the bridge. **It does not.** Installing the package alone gives skills with no bridge; `bridge_install` alone gives a bridge whose calling AI has no skills. Most setups want both, and nothing on a default bridge page says so.++**So state it, in ONE plain sentence, in both your package `description` and your README.** Keep this line, or something equivalent:++> This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.++**Keep it to one sentence.** The package `description` renders as your page subtitle AND the text under the Install card, so a multi-paragraph architecture essay there actively hurts: it buries the one fact a visitor needs. (AD-core overshot exactly this and had to trim it back.) The long version belongs in your README body, not the card.++⚠ **Empirically, neither card renders a per-item caption today** (verified in-page 2026-07-07): the release title/changelog does not surface on the Download card, and the package description surfaces only as the page subtitle, never at the Install card itself. Setting both fields is still correct hygiene, and they do show on the Releases tab, but do not assume the cards self-explain until the wiki exposes a caption slot (filed as `adom/wiki` #93).++---++## ⛔ Payload rules — both artifacts are RUNTIME-ONLY / SKILLS-ONLY. Media lives on the PAGE.++This is the single easiest way to hurt every one of your users, and it is invisible from your machine.++**The real case (fusion, caught 2026-07-19):** `adom/adom-bridge-fusion-bridge`'s release zip was **16.7 MB, of which ~15.3 MB was demo MP4s, screenshots and repo docs**. Every user's machine streamed that on `bridge_install`, and again on every auto-update. Stripped to runtime-only it is **456 KB — 97% smaller — with zero functional change.**++Nobody was careless. The zip was built from `git ls-files` (or by zipping the repo), which sweeps in exactly the things that legitimately belong on your WIKI PAGE: heroes, screenshots, demo videos, architecture diagrams, long-form docs.++**Three destinations, three payloads:**++| destination | carries | never carries |+|---|---|---|+| **Release `.zip`** (streamed to every user's machine) | server code, handlers, add-in/plugin code, `bridge.json`, resources the runtime READS AT EXECUTION TIME | media of any kind, screenshots, demo videos, README/CHANGELOG, `.github/`, tests, dev docs, `node_modules` |+| **pkg tarball** (extracted into every container's `$HOME`) | `SKILL.md` + your USER `skills/**`, `install.sh`/`uninstall.sh`, `package.json`, `page.json` | media, the release zip, `src/`, dev/publish skills, binaries |+| **the PAGE** (`/files`, served to browsers) | hero, screenshots, demo videos, diagrams, long-form docs | binaries (see the release/pkg split) |++Media on the page is served to a browser when someone looks at it. Media in an artifact is **pushed to every machine that installs you**, forever, on every update. That is the whole distinction.++**How to not do this:**++1. **Build the zip from an explicit whitelist**, or from `git ls-files` **minus** media and repo cruft:+   ```bash+   git ls-files | grep -viE '\.(mp4|webm|png|jpg|jpeg|gif|svg|zip)$' \+                | grep -viE '^(screenshots|docs|demo|\.github)/' \+                | grep -viE '(CHANGELOG|PUBLISHING)\.md$' | zip -@ my-bridge-v1.2.3.zip+   ```+2. **Know your number.** A bridge runtime zip is normally **well under 1 MB**. If yours is bigger, that is not automatically wrong (a bridge with real runtime assets can be), but it IS your cue to **list the contents and justify every entry** before uploading.+3. **Inspect before you publish, every time — this is the step that actually catches it:**+   ```bash+   unzip -l my-bridge-v1.2.3.zip | sort -k1 -n -r | head -20   # biggest files first+   tar tzf my-pkg.tgz                                          # zero media, zero binaries+   ```+   Sorting by size is the trick: the offender is always at the top, and a glance tells you whether you are shipping code or a video.+4. **Keep `files[]` an explicit text-only list** (every USER-skill path, no broad globs). A glob is how images get in.++**A publish-time lint would catch this automatically** and has been asked for: a warning when a release asset or pkg tarball contains video/image files, with the size breakdown. That lives in the wiki CLI, not in your bridge, so until it ships **the inspect step above is the only thing standing between you and a 15 MB download on every user's machine.**++**Reference:** the AD-core pkg itself follows this — 18 files, zero `.png`/`.mp4`/`.exe`, and its `docs/**` images were removed from `files[]` in 2026-07-06 for exactly this reason (4.56 MB of PNGs, including a 1.38 MB hero, were shipping inside every container install).++---++## Artifact 3 — DISCOVERABILITY: `discovery_triggers` so a plain request finds you++The wiki has an **auto-discover** index, and it is the difference between a usable bridge and an **invisible** one. Declare `discovery_triggers` (the phrases a user actually says) on your page, and a plain request matches them via `adom-wiki discover find` / the in-app discovery an AI runs.++```bash+adom-wiki discover triggers adom/adom-bridge-<bridge>-bridge --add "open my pcb" "export gerbers"  # add triggers+adom-wiki discover preview --json "open my pcb design"          # what would surface + the SCORE + WHY+adom-wiki discover find    "screenshot my website"              # the live find an AI runs+```++**How scoring actually works (measured 2026-06-30 — know this or you'll guess wrong):**+- `score = 3×(distinct triggers matched) + 1×(tags) + 1×(brief/text)`. **Trigger matches dominate (3× each);** tags/title/brief barely move it.+- **NO TRIGGERS = INVISIBLE.** A bridge with a perfect title ("Adom Bridge - Blender Bridge") + a matching tag scored **ZERO** for the literal query *"blender"* — it never appeared in `discover preview`. The scorer ignores title/tags when there are no triggers. **Every shipped bridge MUST carry `discovery_triggers` or it cannot be found by the discovery an AI uses.** (`discover search` full-texts the readme, but that is NOT the path discovery takes.)+- **More good triggers = higher rank.** Each match is +3, so 10 user-task phrasings beat 2. The healthy bundled bridges carry **30-40** triggers; a struggling one had 14 — all dev jargon — and lost EVERY user query.++**The cardinal rule: triggers are USER-TASK phrasings, NOT dev jargon.** A real user types *"open my pcb design"* / *"export gerber files"* / *"design a circuit board"* — NOT *"reverse bridge"* / *"kicad_bridge_call"* / *"fork the kicad bridge"*. A trigger list full of architecture terms scores 0 on every real query (kicad's actual failure: 7/7 user queries missed, and the **Fusion** bridge out-ranked KiCad on PCB queries because Fusion happened to carry a couple of fuzzy "pcb"/"export" triggers). Put **10+ everyday task phrasings first**; technical aliases (verb names) are a small minority at the end.++**VET it — never ship triggers you didn't test:**+1. Write **5-7 queries a typical user would type** to get your tool (these double as your page `sample_prompts`).+2. `adom-wiki discover preview --json "<each query>"` → read `data.scoring[]` (array order = rank). **You must land in the top 3.** (Rate-limited: space calls ~2 s; expect a 429 after ~25.)+3. For every query where you're NOT top-3, **add that exact phrasing as a trigger** and re-test. If a sibling bridge out-ranks you on a shared word (Fusion vs KiCad on "pcb"), add MORE exact phrasings so your 3×-per-trigger total wins.++**Claim YOUR niche — don't poach a sibling's territory.** Discovery routes by whose triggers match; if two bridges match a query the user lands on whichever has *more* triggers, NOT whichever is actually right. So your triggers must describe what YOU are genuinely best at — and you must LEAVE OUT cases a sibling owns. **Canonical example: logging into a site / filling a form on a signed-in page is the native-browser extension's job — it drives the user's REAL browser with their REAL logins — NOT puppeteer's.** Pup is a fresh Chrome-for-Testing profile with no saved logins; it's for *non-login* automation (scraping, screenshots, testing, public pages). So pup must NOT carry `log in` / `fill a form on a site I'm signed into` triggers, and native-browser MUST. When a task needs a capability you DON'T have, make your `discovery_pitch` *point at the bridge that does* — and tell the user to install it (e.g. "for logged-in form-filling, install the native-browser extension") — rather than claim it and do it badly.++- **`discovery_pitch`** is the one-liner the snippet quotes back to the AI — auto-generated from `discovery_triggers` + `discovery_pitch`, **no separate upload step.** Make it an INSTRUCTION, not a tagline.+- **Make your `brief` / `discovery_pitch` an instruction** — it's what the AI reads from the find result. Tell it the next moves: *"…check `adom-bridge bridge_list` to confirm the bridge is installed; if it is, drive its `<prefix>_*` verbs. If the container lacks the skill, `adom-wiki pkg install adom/adom-bridge-<bridge>-bridge` (or `adom-bridge sync_skills`)."*++### The end-to-end flow your three artifacts enable++User says *"show me my app in pup"* → the AI:+1. `adom-wiki discover find "show me my app in pup"` → your bridge surfaces (Artifact 3).+2. `adom-bridge bridge_list` → is your bridge installed in AD? If not, `bridge_install {your manifestUrl}` (Artifact 1).+3. Is your skill pkg installed in THIS container (`~/.claude/skills/` or `~/.codex/skills/`)? If not, `adom-wiki pkg install adom/<slug>` / `sync_skills` (Artifact 2).+4. Drive your verbs — guided by the **rich hints you return** (the principle at the top).++---++## Prewarm — make the first call instant++A cold first call that pays multi-minute setup (npm install, downloading a runtime, a heavy import) blows the caller's timeout. Pay that cost in the background BEFORE the user asks:++- **Bundle deps in your SEED.** Pup ships a bundled `node_modules/` in its NSIS seed so a fresh PC can spawn with no `npm install`. When you re-seed from a Release zip, keep the bundled deps.+- **Idempotent warm-up.** Make spawning + a no-op health/setup pass idempotent so a background prewarm is a no-op once warm.+- **AD prefetches for you on first launch — standalone AND embedded (HD-bundled) — gated on availability.** (Pup's Node + Chrome-for-Testing prefetch runs once per install in both modes as of AD v1.9.45.) If your bridge needs a heavy runtime, expose a cheap idempotent "ensure ready" path AD/your seed can trigger, and surface a clean `errorCode` (e.g. `node_not_found`) when a prereq is missing so the AI can install it (`desktop_install_node`) instead of hanging.+- **`browser_readiness`-style verb.** Offer a fast verb that reports `{ready:true|false, …}` so a caller (or a fresh-PC test) can confirm warmth without triggering work.++---++## Card hero + update cost — two gotchas that bite++**Hero: ship a 1.6-aspect image, or AD crops the OG-card fallback.** AD's bridge card renders the hero in a banner that is `aspect-ratio: 1.6` with `object-fit: cover`. So:+- Ship a **1.6-aspect** hero (≈**2000×1250**), full-bleed — like pup & kicad. It fills the banner cleanly.+- If you ship **no** hero (`bridge.json` has no `hero` AND your wiki page has no `hero_path`), AD falls back to your page's **auto-generated OG card** (1200×630 = **1.90** aspect, the social-card size). `cover` scales that wider image to the banner's height and **clips the sides** — the left text panel gets cut off. (That cropped, off-center look is the tell of a hero-less bridge — it's not an AD bug.)+- Fix: publish a real hero at 1.6 (the `adom-wiki` page hero, or a `hero` URL in `bridge.json`). Verify by opening your card in AD — the title/text must not be clipped.++**Update cost: keep the Release zip source-only; re-seed the NSIS bundle in lockstep.** When a user clicks the card's "↑ v…" update badge, AD downloads your Release zip into the cache and (for a node bridge) provisions deps:+- **The Release zip MUST be source-only — no `node_modules`.** AD provisions node deps at spawn: it **reuses the bundled seed's `node_modules` via `NODE_PATH`** when the cache version ≤ the seed version (instant), and runs a real **`npm install`** only when the wiki version is *newer* than the bundled seed.+- So a wiki version much newer than your bundled seed forces a **cold `npm install`** on the next spawn — slow for a heavy tree. **When you publish a new Release, bump the bundled NSIS seed in lockstep** (same version) so most users get the instant NODE_PATH reuse, not a cold install.+- AD (**v1.9.52+**) never blocks the UI on this: the update downloads straight to cache (no wasteful seed re-copy — the old code deep-copied pup's 73 MB / 4698-file seed and then discarded it, the "updating… forever" hang), the badge shows live progress (`downloading…` → done) with a 30s "installing…" fallback, and your bridge **keeps serving the old version** until the new one is ready. A lean zip + a lockstep seed is what keeps it *fast*.++---++## Cold-start tiers, host-app detection & the `bridge_readiness` probe (v1.9.47)++A bridge has TWO kinds of dependency, and **AD treats them OPPOSITELY.** Get this right or a first-install probe (e.g. HD's "what tools do I have") triggers a slow install at the worst moment.++**Tier 1 — your RUNTIME (Node, Python, your own browser download): AD prewarms + auto-installs it.** This is your plumbing. On first boot AD **provisions** Node/Python in the background — a **portable, no-UAC** copy under `~/.adom/adom-runtimes/` (or a system install if one's on PATH), **NOT winget/MSI** (see **Runtime contract** above) — and prewarms heavy assets you declare. You may also download your own runtime asset (pup downloads Chrome-for-Testing) — fine, it's yours. **But do it in the BACKGROUND: a `*_status` / `*_readiness` / ANY probe verb must NEVER block on a heavy download.** Spawn your server fast (it usually doesn't need the asset to *start*), kick the download off detached, and report `state:"warming"` until ready. A synchronous-download-on-first-verb is the exact footgun this release fixed (pup's 150 MB Chrome fetch used to block the probe).++**Tier 2 — the HOST APP (KiCad, Fusion, Altium, …): AD DETECTS it by default and installs it ON REQUEST — never pre-emptively, and NEVER by telling the user to do it themselves.** Two rules that sound opposed but aren't:+- **Don't install it unasked.** A multi-GB app nobody asked for is rude, so by default AD only DETECTS presence (a machine with KiCad belongs to a KiCad user; EDA users are mono-tool — a Fusion user isn't a KiCad user). AD won't prewarm/spawn your bridge while the app is absent — a resource decision, not a "go away."+- **But the cardinal Adom rule overrides everything: the AI does EVERYTHING for the user — it must NEVER tell the user to do something the AI can do itself.** So your bridge MUST know how to install the host app (`desktop_install_kicad` / winget / your own install verb) and just DO it the instant the user wants it. **Your "not installed" hint must OFFER to install it** ("KiCad isn't installed — want me to install it for you?"), then do it on a yes — it must NEVER say *"download it from kicad.org."* Pointing the user at a manual download is the breach.++**Bridge install LEVELS — how eagerly each bridge gets onto the machine. The AI ALWAYS does the install; the level only sets the TIMING/CONSENT:**++| Level | Bridge(s) | Behavior |+|---|---|---|+| **0 · Built-in / MUST** | Adom Hydrogen (`hd`) | The platform itself — the editor AD lives in. Always present; not "installed" by AD. |+| **1 · Fundamental — pre-emptive** | Puppeteer (`pup`) | Browser automation is foundational to Adom. AD pre-installs its RUNTIME (Node + Chrome-for-Testing) ASAP on first boot, no asking. |+| **2 · Recommended — proactive offer** | native-browser (the browser EXTENSION half) | The user's REAL logged-in browser (login/forms). Since v1.9.142 the DESKTOP BRIDGE half ships bundled in the AD installer (0.2 MB, zero deps, `persistent:true` — its extension-free verbs like `nbrowser_open_os_window`/`nbrowser_profiles`/`nbrowser_os_windows` work day one). What remains level-2 is the in-browser EXTENSION: AD/the bridge proactively SUGGESTS it and it installs on a yes (a browser-extension step the user confirms), unlocking full in-page drive. |+| **3 · On-request host app** | KiCad, Fusion, Altium, … | Detect by default; NEVER pre-install (huge app, rude unasked). The moment the user wants it, the AI installs it AUTOMATICALLY (winget / `desktop_install_*`) — NEVER tells the user to do it manually. |+| **4 · Explicit third-party** | any `bridge_install`'d bridge | The user opts in by installing it. |++The through-line: **the AI never makes the user do something it can do itself.** The only knob is *when* — pre-emptive (pup), proactively-offered (native-browser), or on-request (host apps).++**Declare both in `bridge.json`:**+```json+"detect": {                          // Tier 2 — how AD finds your HOST APP (detection only)+  "hostApp": "KiCad",+  "appPathsExe": "kicad.exe",        // optional: Windows App Paths registry lookup (most reliable)+  "paths": {                         // optional: candidate paths, %VAR% expanded, one * = a version dir+    "windows": ["%ProgramFiles%\\KiCad\\*\\bin\\kicad.exe"],+    "macos":   ["/Applications/KiCad/KiCad.app"],+    "linux":   ["/usr/bin/kicad"]+  }+},+"prewarm": { "assets": ["chrome-for-testing"] }   // Tier 1 — heavy runtime assets AD prewarms + reports+```+A bridge that brings its OWN runtime (pup ships its own browser) **omits `detect`** — it has no external host app. AD infers the runtime from `spawn.kind` (node→Node, python→Python).++**The read-only probe: `bridge_readiness`.** AD aggregates all of this into ONE side-effect-free verb the setup AI calls — it **never spawns a bridge, never installs/downloads**. Per bridge: `{hostApp, hostAppInstalled, runtime, runtimeReady, assets, state}` where `state` ∈ **`ready`** (use it) | **`warming`** (runtime ok, a heavy asset still downloading in the background — re-poll, don't trigger it) | **`needs-runtime`** (node/python installing) | **`no-app`** (host app not installed → not relevant here). Plus a top-level `edaToolsInstalled` list — the one-line answer to "what EDA tools do I have." **Tell your users (in your skill): for a "what do I have / is it ready" question call `bridge_readiness`, NOT your `*_status` verb** — a `*_status` may spawn your bridge; readiness never does.++Your two own cold-start verbs (above) still apply: `<prefix>_readiness` (read-only `{ready, installing, installProgressPct, lastError}`) + `<prefix>_prewarm` (`{wait}`-able). Those drive YOUR bridge specifically; `bridge_readiness` is the AD-level aggregate that detects + summarizes across all bridges without spawning any.++---++## The SKILL SET you should ship (not one SKILL.md — a small library)++Don't cram everything into one file. Ship **three kinds** of skill, each with a different reader **and a different home** — the USER skill goes in the pkg; the DEV and PUBLISH skills stay in your source repo:++1. **A PUBLISH skill — lives in your source REPO** (for the bridge's OWN cloud/dev thread): how to cut the Release zip, POST the manifest, bump versions, publish the skills pkg (with the `.gitignore` trap), set `discovery_triggers`, and re-seed AD's bundled copy. This keeps *your* future self from getting the publish dance wrong.+2. **A DEV skill — lives in your source REPO** (for whoever edits the bridge): architecture, the verb contract, how to test locally, the boundary with AD — and, front and center, **the rich-hints-in-output rule**: *the AI never reads this file; it reads your verb OUTPUT, so every verb must return `_hint`/`_next`/`related`/`pitfalls`.* Make that the loudest thing in the dev skill.+3. **USER skill(s) — shipped in your pkg** → the container: how a cloud AI drives your verbs to get real work done. **If the verb surface is large, split it:** a small **core USER skill** (the 80% workflow + a pointer to the sub-skills) plus **sub-skills** by area (e.g. `…-recording`, `…-electronics`, `…-aps-search`). A 2,000-line monolith won't get read; a tight core skill that names the sub-skills will. (Pup ships exactly this shape: a core bridge skill + user sub-skills.)++**Only the USER skill(s) go in your pkg** (Artifact 2), each a real `skills/<name>/SKILL.md`. **The DEV + PUBLISH skills stay in your source repo** — a developer gets them by cloning to edit the bridge; they are NOT in the pkg's `files[]`, and you do NOT "scope" them into the pkg with `user-invocable:false` (that still ships them and bloats every container install). Open-vs-closed source doesn't change this — it only sets whether your repo's dev/publish files are publicly readable.++**Start from the templates — don't write these from scratch.** `skills/bridge-templates/` in the AD repo has a fill-in-the-blanks file for each of the three skill kinds, generalized from pup's battle-tested set: **`bridge-dev-template.md`** (architecture + the rich-hints rule), **`bridge-publish-template.md`** (the whole release/pkg/discovery dance + every trap), **`bridge-user-skill-template.md`** (the consumer skill, already encoding all eight conventions below). Copy the one you need, swap `<bridge>`/`<prefix>` for your names, delete what doesn't apply.++---++## USER-skill conventions — what every consumer skill must teach (from pup's shape)++These eight patterns are what make a cloud AI actually succeed with your bridge on the first try. The user-skill template encodes them; bake them into whatever you ship:++1. **Open with a "FIRST-TIME / COLD-START — read before you panic" section.** A fresh PC may lack your runtime or owe a one-time download, and the AI's default failure mode is to declare defeat. Lead with a **response → meaning → what-you-do** table that frames `*_installing` / `not_ready` as EXPECTED, keyed off `errorCode` (not prose).+2. **Standardize two cold-start verbs: `<prefix>_readiness` and `<prefix>_prewarm`.** `readiness` → `{ready, installing, installProgressPct, lastError}`; `prewarm` kicks off setup without real work and supports `{"wait":true}` to block. Gives every bridge a uniform "poll until ready, then proceed" loop. (Complements the runtime-side Prewarm section above.)+3. **End the `description:` frontmatter with a `Trigger words:` line** mixing verb names AND plain-English phrases a user would actually type ("take a screenshot of", "log into"). This is what makes a user-invocable skill activate — and it feeds your `discovery_triggers` (Artifact 3).+4. **Include a short "Core verbs" table** (Command | Description | Key args) for the 80% path, and point at `<prefix>_describe '{}'` for the full machine-readable catalog. The AI reads the table to act fast; `describe` is the completeness escape hatch — don't bloat the skill with every verb.+5. **"Background by default — don't disrupt the user."** Your bridge drives the AI's workspace, not the user's screen: lower/un-focus after automation, never force-foreground except to SHOW the user something, nudge via a taskbar flash (`*_alert_window`) instead of stealing focus. Standardize `*_lower_os_window` / `*_raise_os_window` / `*_alert_window`.+6. **Teach "`ok:true` is not enough" — verify what you did.** Verbs that return on operation *start* (not completion) need a cheap post-check (an eval/read verb, else a screenshot) before reporting success — 404s, login walls, and empty states land silently.+7. **Sessions: reuse-don't-reopen + stable identifiers.** Tell the AI to `*_status` before opening a new session (especially after compaction), reuse a match, and pick a stable `profile` so logins/state persist across runs. Prevents duplicate windows and the lost-login footgun.+8. **"Surface `_hint` verbatim."** Every verb returns an actionable `_hint` on not-ready/failure; instruct the AI to relay it to the user unchanged, and branch on the stable `errorCode` (not the prose). This closes the loop with THE ONE PRINCIPLE at the top — the hint IS your UI.++---++## Verb contract (quick reference)++- **Naming:** every verb is `<name>_<verb>` (`blender_render`, `blender_status`). AD routes any verb with your prefix to you.+- **Request:** AD 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). AD 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 AD 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 AD's HTTP budget matches — otherwise AD 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}]}`). AD 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 AD's `desktop_press_key` — do NOT reimplement chords.** AD'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 AD's work and drifts; keep such a verb only for SendMessage-to-exact-hwnd cases AD'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 AD forwards those verbs to your running bridge even when your host app is absent — AD'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.++## Health + status chip++Your `/health` (200) makes the chip green. Optionally return `led` (`green`/`yellow`/`red`), `summary` (short label), `tooltip` (full hover). AD owns the **offline** (gray) state when your endpoint is unreachable — you can never assert offline.++## Lifecycle verbs (AD gives these to USERS — know them to guide users)++`bridge_install {manifestUrl}` · `bridge_list` · `bridge_info`/`bridge_detail {name}` · `bridge_log_read {name, sinceOffset?}` · `bridge_kill {name}` · `bridge_pause {name}` · `refresh_bridges {name?}` · `bridge_check_updates`. A bridge installed via `bridge_install` auto-updates with no further opt-in; `updateManifestUrl` in `bridge.json` makes it durable across republish.++**"My bridge keeps exiting and I can't see why" (read this).** Your own stdout log truncates on every respawn, so a crash-then-restart erases its own cause. `bridge_log_read` therefore also tails **AD's lifecycle audit log** for your bridge, a separate append-only file (`~/.adom/bridge-logs/<name>.ad.log`) that survives respawns, shown under an `===== AD bridge lifecycle =====` header. It records every spawn, reap, duplicate-collapse, and `bridge_kill` with the reason, so you can tell "AD reaped it as a duplicate" or "the `bridge_kill` verb stopped it" or "it was reaped before a fresh spawn" apart from "it crashed on its own." If the audit log shows AD never stopped it, the exit is inside your process.++---++## Publish checklist (the whole modern flow)++1. **Page** — `adom-wiki page create adom/adom-bridge-<bridge>-bridge` with a hero + an **instructional `brief`** + **`discovery_triggers`**.+2. **Runtime** — `release create` + `release upload` the versioned `.zip`; POST the manifest to `/files`; set `updateManifestUrl` in BOTH the zip's `bridge.json` and the manifest. **Before uploading: `unzip -l <zip> | sort -k1 -n -r | head` — runtime only, no media.** *(→ Payload rules)*+3. **Skills pkg** — stage ROOT SKILL.md + `skills/<slug>/SKILL.md` (core USER skill + sub-skills) + `discovery_triggers`; `pkg pack` (**`tar tzf` it — skills only, zero media/binaries**) → `pkg publish --org adom` (mind the `.gitignore` trap). Your `description` carries the one-sentence "this installs SKILLS, not the runtime" line. *(→ Artifact 2)*+4. **Discovery** — `discover triggers` to confirm; `discover preview "<a real user phrase>"` to confirm you surface.+5. **Re-seed AD's bundled copy** (AD-core thread, not you) — extract your Release zip over `plugins/<name>/` keeping bundled deps; ships in the next NSIS as the offline fallback.+6. **Verify** — `bridge_install {manifestUrl}` on a desktop → `bridge_list` shows your `version` + `skillPkg`; `bridge_check_updates` reaches your page; a container `sync_skills` pulls your USER skill; `discover find "<phrase>"` surfaces you with an instructional brief.++**Reference bridge = pup** (`adom/adom-bridge-puppeteer-bridge`): live `discovery_triggers`, a skills pkg with a core + sub-skills, a Release zip + manifest with `updateManifestUrl`, a bundled seed with prewarmed `node_modules`, and rich per-verb hints. Copy its shape. (`scripts/sample-bridges/hello-python` + `hello-rust` are the minimal skeletons.) KiCad, Fusion, Blender, Puppeteer, and the **native-browser** bridge (in the `adom-browser-extension` repo) are cloud-owned — don't edit them from the AD repo. When an AD release changes a bridge-facing contract **— or when THIS SDK doc itself changes —** AD posts a migration notice to **each** of their wiki discussions (`adom-bridge-{kicad,fusion,blender,puppeteer}-bridge` + `adom-browser-extension`). **Author-facing takeaway: watch your bridge's own wiki DISCUSSION/issues list — that's where an "SDK changed, please re-audit against the billboard" notice lands, and where you reply with your plan or blockers.** It's the only channel AD uses to reach you; AD never edits your repo.
skills/adom-bridge-bridges-guide/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-bridges-guide/SKILL.mdadded+191
@@ -0,0 +1,191 @@+---+name: adom-bridge-bridges-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 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.+---++# Adom Bridge bridges: the consumer lifecycle guide++This is the USER/CONSUMER side of bridges: how they run, how you see their health, how+they install and update, and how you manage them with verbs. Building your own bridge is+a different doc: the Bridge SDK (`skills/BRIDGE_SDK.md`, published on the+`adom-bridge-bridges` wiki page).++## What a bridge is++A bridge is a small, independently-versioned **separate process** that Adom Bridge (the+app) spawns, supervises, health-checks, and auto-updates. Each bridge owns a **verb+prefix** (`browser_*` for the Puppeteer browser, `kicad_*`, `fusion_*`, `nbrowser_*` for+your real signed-in browser, `hydrogen_*`, plus any third-party prefix) and a small HTTP+endpoint on loopback. The app relays verbs to the bridge **verbatim** and renders its+health; the bridge validates its own args and answers with the same JSON shape on every+path (relay, direct API, CLI). The app stays generic: it never bakes in app-specific+logic.++The Mac install ships these bridges: Puppeteer, native-browser, KiCad, and Fusion 360+(+ APS), plus the Adom Hydrogen stub. Bundled seeds work offline on day one; the wiki+is for updates, not first-install (see `skills/FIRST_INSTALL.md`).++## The Bridges sidebar (what the user sees)++Each installed bridge is a mini-app card in the Bridge window's right sidebar: hero image+(the bridge's own wiki billboard), a health LED, language and source badges, star count.+A `?` help icon carries the full tooltip; clicking a card opens a wide pop-out with a+live console (the per-bridge log + command history), a ports/PID debug view, and all+management actions (install/uninstall/kill/restart/pause/resume). **+ Bridge** installs+a new one by manifest URL; **Check for updates** is the on-demand sync button. (Bridge+runs as a menu-bar agent; summon the window from the menu-bar item.)++### LED colors: the bridge self-reports, the app renders++The app is a pure renderer of bridge health. A live bridge decides its own LED, short+`summary` label, and hover `tooltip` on its health endpoint; the app paints them+verbatim, re-polling about every 15 seconds.++| LED | Meaning |+|---|---|+| green | Healthy. The bridge said so (or answered 2xx without self-reporting, which defaults to green). |+| yellow | Degraded/warning, per the bridge itself (e.g. an upstream partially down). |+| red | The bridge itself knows it is broken (e.g. it lost its upstream). |+| gray (offline) | The app owns this ONE state: the health endpoint is unreachable. A not-running bridge reads as off, not failed. A dead bridge cannot self-report. |++## Lifecycle: one instance, stable port, supervised++- **Spawn on first verb.** A bridge is auto-started on the first command of its prefix+  (e.g. the first `browser_*` call). No manual start.+- **Stable persisted port.** Each bridge keeps ONE runtime port across respawns AND app+  restarts (persisted in `~/.adom/bridge-ports.json`). External clients (a browser+  extension) keep dialing the same port. Ports are OS-assigned; never hardcode one.+  The verb prefix is the contract.+- **Single instance.** Spawns are serialized behind a per-bridge lock; the app tracks+  every spawned PID and reaps a stale/wedged instance before respawning. A background+  sweep (every ~15 s) collapses duplicates: it scans for any process (tracked or not)+  bound to a bridge port, health-probes it, keeps the instance on the assigned port,+  and reaps the rest, so relay verbs never round-robin between two copies.+- **Auto-respawn for persistent bridges.** `spawn.persistent: true` (surfaced in+  `bridge_list`) means a supervisor respawns the bridge on death, on its stable port.+  "Kill it to load new code" just brings it right back.++## Install levels: how eagerly each bridge gets onto the machine++The AI always does the install; the level only sets timing and consent:++| Level | Bridge(s) | Behavior |+|---|---|---|+| 0 built-in | Adom Hydrogen (`hd`) | The platform itself; a manifest stub only, never installed by Bridge. |+| 1 fundamental, pre-emptive | Puppeteer (`pup`) | Bundled seed; the app pre-installs its runtime (Node + Chrome-for-Testing) in the background on first boot. |+| 2 recommended, proactive offer | native-browser | The desktop-bridge half is bundled (works extension-free day one); the in-browser extension is proactively offered and installs on a yes. |+| 3 on-request host app | KiCad, Fusion, ... | The app detects the host app; it never pre-installs a multi-GB app unasked, and never tells the user to install it manually. The AI installs it the moment the user wants it. |+| 4 explicit third-party | any `bridge_install`'d bridge | The user opts in by installing it from its wiki manifest URL. |++## Updates: each bridge from its OWN wiki page++Bundled seeds auto-update from their own wiki pages: on launch + every 4 hours, the app+checks each registered bridge's `updateManifestUrl`; a strictly-newer version downloads+(sha256-verified) into `~/.adom/bridges-cache/` and cache-over-bundled supersedes the+seed. On demand:++```bash+adom-bridge bridge_check_updates            # READ-ONLY: current vs wiki latest per bridge+# -> {bridges:[{name,current,latest,updateAvailable,source,wikiReachable}], updatesAvailable:N}+adom-bridge refresh_bridges                 # apply updates for ALL bridges that are behind+adom-bridge refresh_bridges '{"name":"kicad"}'   # or just one+```++Version semantics: `current` = what the app actually runs = the newer of cache and+bundled (numeric semver). After an update, the running server process still holds old+code until it respawns; the version-aware liveness check forces a respawn on the next+verb call, so you normally do not need to kill anything.++## The key verbs++```bash+# Inventory: start here. Includes per-bridge skillPkg (adom/<slug>) and installSkill+# (a ready-made `adom-wiki pkg install` line), plus a top-level _skillsHint.+adom-bridge bridge_list++# Rich detail for ONE bridge (alias bridge_detail): log tail, resolved ports, full verb+# list, manifestUrl: and HONEST liveness: processLive is true only when the process is+# actually alive. (bridge_list's status is a cheap port-allocated heuristic; after a+# kill the stable port stays reserved by design, so trust bridge_info/bridge_detail.)+adom-bridge bridge_info '{"name":"puppeteer"}'++# Cursor-streaming remote tail -f of the bridge console. The tail also appends the app's+# own append-only lifecycle log (under an "===== AD bridge lifecycle =====" header) which+# SURVIVES respawns and records why a bridge started/stopped: an "[AD] REAPED ... as+# duplicates" line means the app killed it; an "[AD] spawned" line with NO preceding reap+# means the process exited on its own (a bridge-side crash).+adom-bridge bridge_log_read '{"name":"kicad"}'+adom-bridge bridge_log_read '{"name":"kicad","sinceOffset":41230}'++# Kill a bridge's processes (tracked PIDs + any image-verified orphan still bound to its+# port). Persistent bridges come right back on the same port.+adom-bridge bridge_kill '{"name":"native-browser"}'++# Pause/resume: a ROUTING flag only. Paused verbs fail fast with errorCode+# "bridge_paused"; the process is NOT killed.+adom-bridge bridge_pause '{"name":"fusion360"}'+adom-bridge bridge_resume '{"name":"fusion360"}'++# READ-ONLY readiness probe across every bridge: "what tools do I have / are they+# ready." NEVER spawns a bridge, never installs or downloads anything. Per bridge:+# {hostApp, hostAppInstalled, runtime, runtimeReady, assets, state} with state in+# ready | warming | needs-runtime | no-app, plus a top-level edaToolsInstalled list.+# Use THIS for "what's installed" questions, not a *_status verb (which may spawn).+adom-bridge bridge_readiness++# Install a third-party bridge from its wiki manifest URL. On success the card appears+# in the GUI immediately (gray until first use). On failure NOTHING is registered , +# read `error`; confirm with bridge_list. Never treat a parsed manifest as an install.+adom-bridge bridge_install '{"manifestUrl":"https://wiki.adom.inc/api/v1/pages/<bridge-page>/files/<bridge>-bridge-manifest.json"}'++# Remove one. Bundled bridges revert to the bundled copy; third-party are fully removed.+adom-bridge bridge_uninstall '{"name":"my-bridge"}'+```++## sync_skills: get each bridge's OWN skill++Installing the adom-bridge core pkg gives a container only CORE skills. A dynamic/cloud+bridge (kicad, fusion, any third-party) ships its consumer SKILL.md in its OWN wiki+skill pkg. **Run this right after connecting:**++```bash+adom-bridge sync_skills+```++It runs `bridge_list`, collects every non-null `skillPkg`, and installs each bridge's+own skill pkg into `~/.claude/skills/` so you can discover and drive whatever bridges+THIS desktop actually has. Idempotent; on a multi-machine relay it unions bridges across+all desktops. The CLI also runs it automatically about once a day in the background+(containers only; opt out with `ADOM_SKILL_SYNC=0`), but running it on connect makes the+skills available to the current session immediately. Re-run any time the desktop gains a+bridge. Do not hand-copy a bridge's SKILL.md into a container: sync_skills is the path.++## Prewarm: why a fresh Mac is briefly "warming"++On launch the app prewarms in the background: managed Node and Python runtimes+(downloaded only if not already on PATH, a few seconds after boot, gated by config+`prewarm_runtimes`, default on), and on install/upgrade a detached pup prefetch+(`npm install` + the ~150 MB Chrome-for-Testing) so the first `browser_open_window` is+not a 3-minute hang. A bridge verb during the download gets an honest `runtime_warming`+response (`stillRunning:true`, poll the `runtimes` verb, then retry): not a hang, not a+failure. Details: `skills/PREWARM.md` and `skills/FIRST_INSTALL.md`.++## The demarcation rule++Each bridge ships its own FULL consumer skill from its own wiki page (installed via+`sync_skills`). Adom Bridge's docs, this guide included, only cover the bridge LIFECYCLE+and management surface, and point at each bridge's page for how to actually drive it.+For KiCad workflows read the kicad bridge's skill; for browser automation read pup's;+and so on. `bridge_list`'s `installSkill` field gives you the exact install line per+bridge.
skills/adom-bridge-cli-guide/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-cli-guide/SKILL.mdadded+205
@@ -0,0 +1,205 @@+---+name: adom-bridge-cli-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 Mac goes through this surface. (The app is named Adom Bridge, formerly Adom+Desktop; the CLI binary name is unchanged.)++## 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) |+| The Mac, inside the app bundle | `adom-bridge-cli` at `Adom Bridge.app/Contents/Resources/` | Bundled by the Bridge installer (standalone: `/Applications`; Hydrogen-managed: `~/Library/Application Support/adom/bridge/`) | Auto-probes the local app'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                    # full catalog: categories, args, returns, _hint per verb+adom-bridge --target <name> commands    # plus THAT box's installed bridges+adom-bridge help <topic>                # details for one verb or namespace (e.g. help pup, help shell)+```++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 core verbs** are on every Adom Bridge, no install: screenshots, windows,+  input, files, shell, process, tunnel, update, notify, and the `desktop_ui_*` /+  `desktop_find_control` family (drive controls by NAME via macOS Accessibility;+  needs the Accessibility permission, granted to "Adom Bridge").+- **Bridge verbs** (`browser_*`, `kicad_*`, `fusion_*`, `nbrowser_*`, ...) need that bridge+  installed. Check `installed_bridges_on_target` in `commands`, 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`):+`pup`/`browser`, `kicad`, `fusion`, `hd`, `shell`, `files`, `notifications`, `watch`,+`setup`, `connectivity`, `system`, `record`, `server`, `bridge`, `wiki`.++## 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.++**Grouped verbs** take a bare-word subcommand before the JSON:++```bash+adom-bridge tunnel open '{"to":"127.0.0.1:5900"}'+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, bundled Mac 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 Bridge core (recovery steps on+  `timeout`/`busy`, next-verb pointers on success). The CLI only authors a hint for the+  one case Bridge 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 (a Mac laptop + another machine + ...). 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 mymac desktop_screenshot_screen+adom-bridge --target all ping                    # 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_*`).+- Mac clients report `os: macOS` in the `targets` listing.++## Local vs relay routing++- **No `--target` on the Mac**: the bundled CLI short-circuits to the local app'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 MyMac         # 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 `0.0.0.0:8765` (desktop apps connect here)+- 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).++## `watch`: stream long operations++For long-running walks, use the built-in `watch` wrapper instead of a polling loop. It+spawns the inner command, polls status internally, and emits one JSON event per line to+stdout as progress happens:++```bash+adom-bridge watch '{"command":"fusion_walk_cloud_tree","args":{"projectName":"Main","nameContains":"BQ25792"}}'+adom-bridge watch '{"command":"fusion_walk_cloud_tree","args":{...},"interval":1}'+```++Watchable commands (whitelist): `fusion_walk_cloud_tree`, `fusion_search_cloud_files`.+Others return `success:false` with a `_hint` listing the watchable set. Do not use it+for short operations that return in under 2 seconds. From Claude Code, pipe `watch`+into the Monitor tool for real-time events.++## `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-direct-api/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-direct-api/SKILL.mdadded+496
@@ -0,0 +1,496 @@+---+name: adom-bridge-direct-api+description: Direct HTTP API on the Adom Bridge app. Use when authoring a sibling app (Adom Hydrogen, future Adom-family apps) on the same Mac that needs to send commands into Adom Bridge without spawning the CLI binary or going through the WebSocket relay. Local-only (loopback bind).+---++# Direct HTTP API: discover via `~/.adom/direct-api-port`++> **Port is not guaranteed fixed.** Adom Bridge's direct API binds `127.0.0.1:47200` when it can, and falls back (range walk, then an OS-assigned ephemeral port) when that port is unavailable. **Sibling apps MUST read the discovery file** `~/.adom/direct-api-port` (single-line `host:port`, e.g. `127.0.0.1:47200`) before any hardcoded probe. The legacy 47200..47209 scan stays as a back-compat fallback.++Sibling apps running on the same Mac as Adom Bridge can POST JSON commands directly into the running app process: no CLI process spawn, no WebSocket relay, no auth-token dance. The endpoint lives inside the app itself (loopback-only `127.0.0.1` bind, can't be reached off-box), uses the same dispatcher as the WebSocket path, and returns the same JSON shape, including every `_hint` field, that `adom-bridge <verb>` returns from the CLI.++This skill is for **sibling-app authors** (Adom Hydrogen's native side, plus any future Adom-family desktop app). Container callers should continue using the CLI or the WS proxy: those paths handle cross-machine transport and binary streaming, which the direct API intentionally doesn't.++Requires the Adom Bridge app to be running on the Mac. Bridge is a menu-bar agent: it serves this API whether or not its window is open.++## When to use this (vs. the CLI)++| You are... | Use |+|---|---|+| A sibling app on the same Mac as Adom Bridge, calling sync verbs like `server_add`, `bridge_list`, `hydrogen_status`, `desktop_screenshot_window` | **Direct API.** One HTTP round-trip, ~5 ms, no process spawn. |+| Inside Adom Hydrogen's local container, using the `adom-bridge` CLI | **Just use the CLI.** It auto-detects the direct API (including `host.docker.internal`) on every invocation and routes there transparently. Zero config. The verb returns identical JSON to the cross-machine path. |+| On the Mac, running the bundled `adom-bridge-cli` directly | **Same: just use the CLI.** Auto-detects the local direct API and routes through it, faster than the relay path. |+| Container / Linux / cross-machine caller | The CLI `adom-bridge <verb>` (probe fails, falls through to the wss proxy, relay, app WS path). |+| A sibling app needing `pull_file`, `send_files`, or `shell_execute` | Spawn the CLI binary. The direct API refuses these with `errorCode:"cli_required"`. The CLI itself handles the fallback automatically when called. |++### CLI auto-route++When the `adom-bridge` CLI binary runs in any of these contexts, it probes the direct API on the first verb invocation and caches the result for 30 seconds (in `/tmp/adom-direct-probe.json`):++- **Discovery file (fastest path):** `~/.adom/direct-api-port`. Contents are `host:port` (e.g. `127.0.0.1:47200`) and the CLI tries this first if present. The app writes the file at bind time and removes it at graceful shutdown.+- **Probe order** (first 200 OK wins): `127.0.0.1:47200`, `host.docker.internal:47200`, `localhost:47200`+- **Fallback scan:** if the discovery file is missing/stale AND the default candidates all fail, the probe scans `127.0.0.1:47201`..`47209`. Cheap: each closed port refuses connection in a few ms on loopback.+- **Connect timeout**: 400 ms per candidate, so it fails fast when nothing's there+- **Override**: `$ADOM_DIRECT_URL=off` forces relay-only; `$ADOM_DIRECT_URL=http://...:47209` skips the probe entirely and uses that URL; `auto` (default) does the probe++The `ping` verb response includes a `transport` field, `direct-http` or `relay-ws`, so you can confirm which path served the call. `pull_file`, `send_files`, and `shell_execute` always use the relay path (they have their own specialized streaming/approval flows). Everything else routes through the direct API when reachable.++### Port-conflict auto-recovery++The app never treats any individual port as fatal. Bind walk:++1. **Probe first**: connect to the candidate port and send a `GET /health`. If anything responds within 1 s, a LIVE process owns the port: skip rather than coexist (SO_REUSEADDR would let us bind too, but the kernel would route incoming connections nondeterministically between the two listeners).+2. **Reuse-bind** with `SO_REUSEADDR` set before bind, so a dead process's lingering socket doesn't block the next launch.+3. **Walk the range** `47200`..`47209` on address-in-use, permission-denied, or live-owner skip.+4. **Ephemeral fallback**: if all of 47200..47209 fail, bind `127.0.0.1:0` and let the OS pick. Whatever it picks goes to step 5.+5. **Write the chosen port** to `~/.adom/direct-api-port` so callers don't have to scan.+6. **Report the bound address** in `/status.endpoint`, never a hardcoded constant.++The app also prefers a *remembered* port (`~/.adom/direct-api-port-preferred`), so in practice the port is stable across restarts and self-updates.++### Graceful shutdown++When the app exits cleanly (menu-bar Quit, `desktop_shutdown`, or any path that calls `app.exit(0)`), the exit hook fires `direct_api::shutdown()` which:++1. Sends a `oneshot` signal to the axum server+2. `with_graceful_shutdown` stops accepting new connections and drains in-flight ones (~tens of ms)+3. The TCP listener drops, releasing the port back to the OS immediately+4. The discovery file is removed so callers don't connect to a port that's about to close++This prevents lingering sockets on **clean exits**. For **force-kill** scenarios (`kill -9`, crash), the reuse-bind pattern means the NEXT launch can take over the same port instead of having to walk past it. Two layers of defense.++## Endpoints++### `GET /health`++Cheap probe. Use to detect "is the app running" before falling through to the CLI fallback.++```bash+curl -sf http://127.0.0.1:47200/health+# -> {"ok":true,"service":"adom-bridge"}+```++Returns 200 + JSON when the app is up and the listener bound successfully. Connection refused (or timeout) means: app not running OR the port was already taken by something else when the app started. The CLI binary's `serve` mode does NOT bind this port; only the app does.++### `GET /status`++Service banner + version + capability inventory. Read once on sibling-app startup to learn the verb surface and the `cliRequired` list.++```bash+curl -sf http://127.0.0.1:47200/status+```++```json+{+  "ok": true,+  "service": "adom-bridge",+  "version": "1.9.187",+  "schema": 1,+  "transport": "direct-http",+  "endpoint": "http://127.0.0.1:47200",+  "directApi": {+    "cliRequired": ["pull_file", "send_files", "shell_execute"],+    "note": "Everything else is safe via direct POST /command. The listed verbs use binary streaming or multi-minute approval flows; spawn the `adom-bridge` CLI binary for those.",+    "envelope": "{\"app\": <namespace>, \"command\": <verb>, \"args\": <args object>}",+    "responseShape": "Identical to what `adom-bridge <verb>` returns: same `_hint` fields, same `success`/`ok`/`error` keys, same payload structure. The CLI and direct paths converge in `commands::handle_command`."+  },+  "_hint": "POST /command with {app, command, args}. See https://wiki.adom.inc/adom/adom-bridge for the verb catalog."+}+```++The `schema` field is the contract version. v1 is the only one shipped. If `schema > 1` ever appears, expect a breaking change in the envelope/response shape and read this skill again.++### `GET /commands`++Verb discovery: what `{app, command}` can a `/command` caller reach? A bridge delegating to the app no longer has to probe verb-by-verb and hit "Unknown desktop command".++```bash+curl -sf http://127.0.0.1:47200/commands+```++```json+{+  "ok": true,+  "desktop": {+    "app": "desktop",+    "commands": ["list_windows","find_window","screenshot_window","run_script","process_list", "..."],+    "note": "POST /command {app:\"desktop\", command}. The desktop_ prefix is OPTIONAL (desktop_screenshot_window == screenshot_window). Bad/missing args -> the verb's _hint returns the arg schema."+  },+  "bridges": [{"name":"native-browser","verbPrefixes":["nbrowser_"],"verbs":["..."],"paused":false}],+  "cliRequired": ["pull_file","send_files","shell_execute"],+  "_hint": "Desktop verbs -> app:\"desktop\" (prefix optional). Bridge verbs -> app:\"dynamic\", command = full verb incl. prefix. cliRequired verbs need the CLI, not this HTTP API."+}+```++Pair it with the per-verb schema hints: call a verb with missing args and its `_hint` returns `required`/`optional`/`example`. Between `/commands` (what exists) and the bad-args hint (how to call it), a fresh AI needs no prior knowledge.++### `POST /command`++Dispatches a verb. Body envelope (`app` is OPTIONAL: inferred from the command):++```json+{+  "command": "server_add",+  "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true }+}+```++#### Full verb surface + cross-machine routing++A caller (a **bridge** especially) reaches **every dispatchable verb** here, not just `desktop_*`: same dispatcher as the CLI/WS path, identical JSON back (`_hint`/`errorCode`/`statusVerb`). `GET /commands` lists the full set to capability-probe.++- **`app` is optional**: inferred (`kicad_*` to kicad, `fusion_*` to fusion360, `browser_*` to browser, `hydrogen_*` to hd, `aps_*` to aps, any installed bridge's prefix to dynamic, else desktop). Pass `app` explicitly to override.+- **Top-level verbs reachable:** `notify_user` (notification, returns `{action:"displayed"}`), `notify_response`, `targets` (list the OTHER machines on the relay), `ping` (this machine's liveness + version), plus `bridge_list`/`bridge_info`/`refresh_bridges`/`runtimes`/`status` and the update verbs (`update_status`, `update_check`, `apply_update`, `request_update_approval`, `update_set_mode`).+- **`target` (cross-machine):** add `"target": "<clientName>"` (from `targets`) or `"all"` to route the call to a PEER machine via the relay, e.g. a bridge on one box notifies the user on their laptop. `"self"`/`"local"`/absent = local. `"attended"` returns `errorCode:attended_unresolved` (not built yet; use a concrete clientName).+- **`X-Adom-Bridge-Token` header = ATTRIBUTION only.** A bridge MAY send its spawn-time `ADOM_BRIDGE_TOKEN` env value; the app then badges the call "bridge" in the Activity Log. It is **not** an approval gate: a bridge is trusted-by-install (127.0.0.1-bound; see PERMISSION_MODEL.md). A stale/wrong token gets 403; absent is fine. The full surface (incl. `write_file`/`run_script`) runs ungated for any local caller; only the remote relay origin is approval-gated.++Example: a bridge fires a notification to the user's laptop while running on another machine:+```bash+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","target":"MyMac","args":{"title":"Action needed","body":"Approve the Chrome dialog on your laptop"}}'+```++Plain single-machine envelope (unchanged, `app` still honored when provided):++```json+{+  "app": "desktop",+  "command": "server_add",+  "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true }+}+```++Response is the verb's normal payload (200 OK), e.g.:++```json+{+  "ok": true,+  "success": true,+  "name": "hydrogen-workspace",+  "url": "ws://localhost:8765",+  "id": "...",+  "connected": true,+  "created": true,+  "_hint": "Server registered. Relay commands for this container now route through Adom Bridge. Use server_list to see all connections."+}+```++#### Error responses++| HTTP | When | Body shape |+|---|---|---|+| `400 Bad Request` | Envelope missing `command` (or unparseable) | `{ok:false, error, _hint}` |+| `412 Precondition Failed` | Verb is in `cliRequired` list | `{ok:false, errorCode:"cli_required", _hint}` |+| `500 Internal Server Error` | Handler dropped the response channel (bug) | `{ok:false, errorCode:"handler_silent", _hint}` |+| `504 Gateway Timeout` | Handler didn't respond within the per-verb timeout (see below) | `{ok:false, errorCode:"timeout", _hint}` |++Every error carries an actionable `_hint` and (for non-400s) a stable `errorCode` string you can branch on.++#### Per-verb timeouts++The 504 timeout is **per-verb**, not a hardcoded 120s ceiling:++| Verb category | Default timeout |+|---|---|+| `walk_cloud_tree`, `search_cloud_files` | **620 s** (10+ min: cloud trees can be hundreds of folders at ~1 fps) |+| `export_step`/`iges`/`sat`/`stl`/`3mf`/`usdz`/`obj`/`f3d`/`fbx`/`skp`/`source`/`eagle_source` | 320 s |+| `export_gerbers` | 200 s |+| `export_dxf`/`dwg` | 140 s |+| `drc`, `erc` | 180 s |+| `bridge_install`, `fusion_start` | 300 s |+| Everything else | 120 s |++Override per-call by passing `args.timeout` (seconds), clamped to a 1800 s ceiling:++```json+{+  "app": "fusion",+  "command": "search_cloud_files",+  "args": {+    "query": "cosmiic",+    "recursive": true,+    "maxFolders": 1000,+    "searchTimeout": 1500,+    "timeout": 1700+  }+}+```++The CLI dispatcher honors `args.timeout` the same way, so the two paths stay symmetric.++## Update endpoints: parent-driven embedded auto-update++The app owns its own updater (fetch, SHA-verify, stage). Adom Hydrogen does **not** download or install Bridge's bytes: it reads Bridge's update state and, at a safe moment, "clicks the Update button" via `POST /update/apply`. The contract is intentionally tiny and generalizes to other Hydrogen-bundled children: `{ update_status, apply, busy }`.++### `GET /update/status`++```bash+curl -sf http://127.0.0.1:47200/update/status+```++```json+{+  "current_version": "1.9.186",+  "available_version": "1.9.187",+  "update_ready": true,+  "state": "staged",+  "busy": false+}+```++| Field | Meaning |+|---|---|+| `current_version` | The running version. Plain dotted semver; compare component-wise, numeric, never-downgrade. |+| `available_version` | Latest version known from the manifest (`null` until the first check). May equal `current_version` when on latest. |+| `update_ready` | `true` **only** when a newer installer is already downloaded, SHA256-verified, and staged on disk: appliable with **no further network**. Equivalent to `state == "staged"`. |+| `state` | One of `idle \| checking \| downloading \| staged \| applying`. |+| `busy` | `true` while the app is mid-operation it shouldn't be interrupted for: an in-flight screenshot, a file transfer, or a bridge command awaiting its reply. **The parent should defer the apply until `busy == false`.** |++`state` machine: `idle`, then `checking` (a manifest check is running), then `downloading` (fetching + verifying), then `staged` (verified on disk; `update_ready=true`), then `applying` (apply triggered; the app is about to restart). A failed check never un-stages a previously-staged update.++### `POST /update/apply`++Applies the already-staged update: the programmatic equivalent of the user clicking "Update". No request body.++```bash+curl -sf -X POST http://127.0.0.1:47200/update/apply+```++```json+{ "ok": true, "applying": true }+```++- **Only ever applies what the app itself staged + verified.** If nothing is staged, returns HTTP 409 `{ "ok": false, "applying": false, "error": "no update staged ...", "_hint": ... }`. Call `POST /update/check` first and poll `/update/status` until `update_ready=true`.+- **Embedded-aware:** no modal or blocking UI. The app returns this JSON **promptly** (before it restarts), applies the staged update (on macOS: mount the staged dmg, rename-swap the `.app` bundle, relaunch), then exits cleanly moments later.+- **Comes back in embedded mode automatically.** The embedded marker on disk persists across the restart (the exit hook only drains the direct-API socket; it doesn't delete the marker), so the relaunched app re-enters embedded mode via the marker + the Hydrogen-liveness probe. Hydrogen's normal adopt (`POST /embedded/enter`) on the next supervisor tick re-asserts it idempotently.++#### Restart handshake: the parent MUST suspend respawn for the apply window++The apply swaps the running `.app` bundle and relaunches it. **If the parent's supervisor respawns Bridge mid-swap, the two launches race** and you can end up with a stale process still serving. So:++1. `GET /update/status`: wait for `update_ready == true && busy == false`.+2. `POST /update/apply`: on `{ applying: true }`, **stop respawning Bridge** (suspend the health-respawn) for ~60 s.+3. The app exits, the staged update replaces the bundle, the new build relaunches and re-embeds via its marker.+4. The parent polls `GET /update/status` (or `/health`); when the app answers again with `current_version ==` the new version, resume normal supervision + re-adopt (`POST /embedded/enter`).++The parent initiated the apply, so it already knows the restart is intentional: treat the app's disappearance in this window as expected, not a crash.++### `POST /update/check`++Force a manifest check now. If a newer version is found, the app stages it **in the background**; this call returns the `/update/status` shape promptly (poll it until `staged`). No request body.++```bash+curl -sf -X POST http://127.0.0.1:47200/update/check+```++Returns the same JSON shape as `GET /update/status`.++### Standalone vs embedded++- **Standalone:** the normal user-facing "Update available" flow is unchanged. `auto` mode silently self-updates; `prompt` shows the banner; `off` disables checks (and staging).+- **Embedded:** the app checks + **stages** on its own (so `update_ready` becomes true for the parent) but **never auto-applies**: the parent owns the apply timing via `POST /update/apply`. The embedded apply path never pops its own restart UI.++## What the user sees in the GUI Activity Log++Each `POST /command` call shows up in the Activity Log panel with a `direct:<port>` badge (the port-explicit "server name" the dispatcher attaches to direct-API calls) plus the verb's normal event tag (`desktop`, `kicad`, `hd`, etc.). This is visually distinct from the badge attached to WS-relay traffic.++At startup, a single `direct-api` event-tagged entry says the direct API is listening on its loopback address. If the bind ever fails, the user sees a corresponding error entry: no silent failure.++## Trust model++- **Loopback-only bind.** The listener binds `127.0.0.1`, never `0.0.0.0`. Off-machine HTTP requests can't reach it.+- **No token required.** Anything on the user's box can send commands. The loopback bind is the boundary. If you need stricter isolation, the WS path's auth-token handshake is what to use.+- **"Anything on the box" includes a WEB PAGE the user visits, unless the browser path stays closed.** A loopback bind stops off-machine callers; it does NOT stop a page in the user's own browser from issuing cross-origin requests to `127.0.0.1`. Measured 2026-07-24: the app is closed on every link of that chain, and **each one is load-bearing**: (a) no `Access-Control-Allow-Origin` on any response, so a page cannot READ results; (b) `OPTIONS` returns **405**, so a CORS preflight fails; (c) the body parser accepts **`application/json` only**: `application/x-www-form-urlencoded` and `text/plain` both 415, and those are precisely the two content types a page can send WITHOUT a preflight. (c) is what stops a page firing a verb blind.+  **None of those three were written as security controls, so do not treat them as incidental.** Adding an `OPTIONS` handler to quiet a CORS warning, or accepting form-encoded bodies "for convenience", silently converts this API into one that any visited web page can drive, with `shell_execute` and `write_file` behind it. If a browser-origin consumer is ever genuinely needed here, **the authentication gate must land in the same change as the CORS header, never after it.**+- **Same verb surface as the WS path.** Every authorization check that lives inside a verb handler (shell auto-approve, file-path sandboxing, bridge-paused refusal) applies equally to direct calls: the dispatcher is the same code.++## Reference integration: Adom Hydrogen startup++This is the canonical pattern Hydrogen uses to register its container relay with Adom Bridge at boot.++### Rust (Hydrogen's `src-tauri/src/lib.rs` or equivalent)++```rust+use serde_json::json;+use std::time::Duration;++const ADOM_HEALTH: &str = "http://127.0.0.1:47200/health";+const ADOM_COMMAND: &str = "http://127.0.0.1:47200/command";++async fn register_with_adom_bridge(workspace_name: &str, relay_url: &str) -> Result<(), String> {+    // 1. Cheap probe: is Adom Bridge running and listening?+    let client = reqwest::Client::builder()+        .timeout(Duration::from_secs(2))+        .build()+        .map_err(|e| format!("client init: {e}"))?;++    let healthy = client+        .get(ADOM_HEALTH)+        .send()+        .await+        .ok()+        .filter(|r| r.status().is_success())+        .is_some();++    if !healthy {+        // Adom Bridge not running. Either tell the user to launch it,+        // or shell out to the CLI as the legacy fallback. Don't block+        // Hydrogen startup.+        log::warn!("Adom Bridge not reachable on 127.0.0.1:47200 - relay will not be auto-registered");+        return Ok(());+    }++    // 2. POST the server_add command.+    let resp = client+        .post(ADOM_COMMAND)+        .timeout(Duration::from_secs(10))+        .json(&json!({+            "app": "desktop",+            "command": "server_add",+            "args": {+                "name": workspace_name,+                "url": relay_url,+                "autoConnect": true,+            }+        }))+        .send()+        .await+        .map_err(|e| format!("POST /command: {e}"))?;++    let body: serde_json::Value = resp.json().await.map_err(|e| format!("parse: {e}"))?;++    if body.get("ok").and_then(|v| v.as_bool()) == Some(true) {+        log::info!(+            "Adom Bridge registered: name={workspace_name}, url={relay_url}, _hint={}",+            body.get("_hint").and_then(|v| v.as_str()).unwrap_or("")+        );+        Ok(())+    } else {+        Err(format!("Adom Bridge refused: {body}"))+    }+}+```++(In production, read the port from `~/.adom/direct-api-port` instead of hardcoding 47200.)++### TypeScript (Hydrogen's frontend, if it ever calls directly)++```typescript+async function registerWithAdomBridge(name: string, url: string): Promise<boolean> {+  // probe first+  const health = await fetch('http://127.0.0.1:47200/health', { signal: AbortSignal.timeout(2000) }).catch(() => null);+  if (!health?.ok) return false;++  const resp = await fetch('http://127.0.0.1:47200/command', {+    method: 'POST',+    headers: { 'Content-Type': 'application/json' },+    signal: AbortSignal.timeout(10_000),+    body: JSON.stringify({+      app: 'desktop',+      command: 'server_add',+      args: { name, url, autoConnect: true },+    }),+  });+  const body = await resp.json();+  return body.ok === true;+}+```++## Cleanup at shutdown++Mirror the registration with a `server_remove`:++```bash+curl -X POST http://127.0.0.1:47200/command \+  -H 'Content-Type: application/json' \+  -d '{"app":"desktop","command":"server_remove","args":{"name":"hydrogen-workspace"}}'+```++If Adom Bridge has already exited, the connection refuses: that's fine, the server entry is stale either way. Treat shutdown registration cleanup as best-effort.++## Embedded-mode integration (Hydrogen bundling Bridge)++When Hydrogen bundles Bridge, the direct API is the channel for Hydrogen's menu items and runtime control. Hydrogen spawns Bridge with `--embedded --start-hidden --relay-url ... --session-token ...`, then drives it via these direct-API calls:++| Hydrogen action | direct-API envelope |+|---|---|+| "Open Adom Bridge" menu item | `{app:"desktop", command:"window_show"}` |+| Hide Bridge's window again | `{app:"desktop", command:"window_hide"}` |+| "Connect All" / "Disconnect All" items | `{app:"desktop", command:"connect_all"}` / `disconnect_all` |+| Hydrogen's "Quit" (cascade-stops Bridge) | `{app:"desktop", command:"shutdown"}` |+| Hydrogen sign-out propagation | `{app:"desktop", command:"logout"}` |+| Introspect embedded state | `{app:"desktop", command:"embedded_status"}` |++`embedded_status` returns `{embedded, owner, source, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}`: useful for Hydrogen to know whether Bridge already booted embedded or is running standalone.++```typescript+// Hydrogen's menu-click handler, example+async function openAdomBridge() {+  await fetch('http://127.0.0.1:47200/command', {+    method: 'POST',+    headers: { 'Content-Type': 'application/json' },+    body: JSON.stringify({ app: 'desktop', command: 'window_show' }),+  });+}+```++Note on shell approval: granting is human-only. A relay (cloud-AI) caller's grant via `set_shell_auto_approve` is refused (`errorCode:human_only`); a trusted local caller (Hydrogen proxying its own user's toggle, the GUI, a local human at the CLI) may still grant, and a revoke (`duration_secs:0`) is allowed for anyone.++## What about verbs not in the directApi list?++The `cliRequired` array in `GET /status` lists the verbs that need the CLI binary. Anything not in that list works over the direct API. The dispatcher is the same code; if a verb works via `adom-bridge <verb>` it works via direct POST.++### Mapping CLI verb → direct-API envelope++**The prefix-stripping rule varies per namespace.** Easiest mental model: look at the CLI's `cli/src/commands.rs` dispatch line for the verb you want. Whatever string it passes as the second arg of `relay::desktop_command(app, command, ...)` is exactly what your direct-API envelope's `command` field should be.++**For `app:"desktop"` the `desktop_` prefix is OPTIONAL: POST the public verb name as-is.** A bridge delegating via `ADOM_DIRECT_API_URL/command` can send the exact verb it knows (`desktop_list_windows`, `desktop_find_window`, `desktop_screenshot_window`, `desktop_record_window_start/stop/status`) OR the stripped form; both dispatch.++| `app` value | What goes in `command` | Examples (CLI verb, then direct envelope) |+|---|---|---|+| `desktop` | public verb name; `desktop_` prefix optional | `desktop_screenshot_window` becomes `{app:"desktop", command:"desktop_screenshot_window"}` (or `"screenshot_window"`); `server_add` becomes `{app:"desktop", command:"server_add"}` |+| `kicad` | **stripped**: drop the `kicad_` prefix | `kicad_open_board` becomes `{app:"kicad", command:"open_board"}` |+| `fusion360` | **stripped**: drop the `fusion_` prefix | `fusion_start` becomes `{app:"fusion360", command:"launch"}` (note: also the verb name shifts internally) |+| `browser` | **kept**: pup bridge dispatches on the full name | `browser_open_window` becomes `{app:"browser", command:"browser_open_window"}` |+| `hd` | **stripped**: drop the `hydrogen_` prefix | `hydrogen_status` becomes `{app:"hd", command:"status"}` |+| `dynamic` | full verb name with third-party bridge prefix preserved | (dispatcher routes by prefix) |++Common direct-safe verb groups:++| Namespace | Examples | Notes |+|---|---|---|+| `desktop` | `server_add`, `server_remove`, `server_list`, `bridge_list`, `bridge_install`, `bridge_pause`, `bridge_resume`, `desktop_list_windows`, `desktop_screenshot_window`, `desktop_open_url` | All sync, no prefix stripping. |+| `hd` | `status` | Relays Adom Hydrogen's own runtime state. |+| `kicad` | `list_versions`, `open_board`, `open_schematic`, `run_drc`, `lint_board`, `install_symbol`, ... | Multi-version-aware. |+| `fusion360` | `launch`, `export_step`, `walk_cloud_tree`, `window_info`, `get_app_state`, ... | 50+ verbs. |+| `browser` | `browser_open_window`, `browser_navigate`, `browser_screenshot`, `browser_eval`, `browser_click`, `browser_record_start` | Per-tab. **Keep the prefix.** |++## Failure modes & retry++The direct API doesn't auto-retry. Sibling apps should:++1. **Probe `/health` before any `/command`** so a missing app is a clean "not available" branch, not a noisy 10-second timeout on every dispatch.+2. **Per-request timeouts at or above the verb's natural duration.** Most verbs are sub-second; `bridge_install` can be 30 s for a large zip. Pick the timeout based on the verb you're calling.+3. **Treat 504 timeouts as "unknown state, ask later"** rather than retrying blindly. A successful retry on a 504 might double-register a server or double-install a bridge.+4. **Read the `_hint` and `errorCode` fields on every error response.** They're machine-friendly: `cli_required`, `timeout`, `handler_silent`, plus whatever the verb's handler emits (`bridge_not_found`, `binary_missing`, etc.).+5. **NEVER cache the resolved base forever: RE-READ the discovery file on any connection failure.** The app prefers a remembered port and only walks elsewhere when that one is taken or has a live owner, so in practice the port is **stable across restarts and self-updates**, and that stability is exactly what makes a missing invalidation path dangerous. A cached base works for weeks, so nothing ever exercises the re-discovery code; then one day something occupies the port during a restart, the app walks to a different one, and every caller holding the old base is permanently dead with no route back. `~/.adom/direct-api-port` is the single source of truth and the app rewrites it on every bind. The bug shape to grep your own code for: a module-level cache guarded by `if (base !== undefined)` that nothing ever resets (found live in a bridge 2026-07-24). **"It never changed during testing" is not evidence it is safe; it is the reason the bug survives to production.**++   Generalize it: **rarity of change is a risk factor, not a safety property.** A resource that changes often gets correct invalidation for free, because the bug is immediate and unmissable. A resource that changes rarely, like the remembered port, accumulates caches nobody invalidates, and the bug takes weeks to appear and lands on a user rather than a developer.++## Version skew++If `GET /status` fails entirely OR `schema` is missing, the direct API isn't present (much older app). Fall back to spawning the CLI binary. Everything that works via the direct API also works via the CLI, just slower.++## Why a separate port from the WS relay's 8766?++- **Different transport, different trust model.** 8766 is the relay's HTTP endpoint, which goes through a WS bridge to the app and gates non-CLI User-Agents. The direct port is direct to the app, no auth, loopback-only.+- **Different process lifecycle.** 8766 needs `adom-bridge serve` running (a separate process). The direct port is inside the app: if the app is running, it is up.+- **No risk of breaking containers.** Container callers keep using 8766 + the WS proxy. Sibling apps get the direct port. They don't fight for the same port or auth scheme.++## See also++- [`README.md`](../README.md): feature list+- [`skills/SKILL.md`](SKILL.md): full verb catalog (the entries container callers read)+- [`src-tauri/src/direct_api.rs`](../src-tauri/src/direct_api.rs): implementation+- [Adom Wiki, apps/adom-bridge](https://wiki.adom.inc/adom/adom-bridge): public verb reference
skills/adom-bridge-discovery/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-discovery/SKILL.mdadded+93
@@ -0,0 +1,93 @@+---+name: adom-bridge-discovery+user-invocable: false+description: "Reaches the user's Mac desktop from this container via Adom Bridge (CLI: adom-bridge). Run commands and scripts on the Mac, list/kill processes, restart apps, desktop and app-window screenshots, send/pull files, notifications, list/focus windows. 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 (browser_screenshot, browser_eval, browser_navigate, multi-session profiles, ralph-loop), the relay server (adom-bridge serve), and connection setup. Trigger words: run command on laptop, run on my mac, restart a program, kill a process, list processes, screenshot laptop/desktop/mac/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, open URL on desktop, adom bridge, adom-bridge, bridge to my mac, KiCad bridge, Fusion bridge."+---++# Adom Bridge: discovery & install++**This skill is a pointer.** Adom Bridge (formerly Adom Bridge) runs on the user's Mac; the CLI you drive it with is still named `adom-bridge`. When any trigger fires, confirm the `adom-bridge` CLI 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.9.x (<sha>, built ...)+adom-bridge ping               # {echo:"pong", status:"connected"} == the Mac is reachable+```++If `adom-bridge` isn't found: see section 4, install. If `ping` doesn't say `connected`, the relay isn't serving. Start it with `adom-bridge serve --supervise` (v1.9.168+: detaches and self-heals so it survives your session; a plain `serve &` dies when the session is torn down and leaves every connected desktop in a reconnect loop). Then re-run `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 and fields is the number one setup failure.++**PREFERRED: zero paste (the modern path):** `setup_desktop` returns a ready-to-run **`register_command`**. Run it. It is `adom-bridge register_container` with the name and URL filled in, and it writes this connection into the **user's Adom account** (their cloud profile). Then the user just installs Adom Bridge on their Mac 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 and 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 Bridge's Connections panel, "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 Mac, 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              # desktop state (installed apps, bridges, ...): 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 into a shell. A few you'll want often:++| Want to... | Use (structured: no quoting) | NOT |+|---|---|---|+| list processes | `process_list {"nameContains":"Finder"}` | `shell_execute "ps aux \| grep ..."` |+| kill a process | `process_kill {"name":"Dock"}` (or `{"pid":N}`) | `shell_execute "kill ..."` |+| start an app | `launch_process {"path":"/usr/bin/open","args":["-a","Safari"]}` / `launch {...}` | `shell_execute "open ..."` |+| run a multi-line / quoted script | `run_script {"interpreter":"bash\|zsh\|sh","scriptB64":"<base64>"}` | `shell_execute` with nested quotes |+| screenshots / windows | `desktop_list_windows`, `desktop_screenshot_window {"hwnd":N}`, `desktop_find_window`, `desktop_bring_to_front` | - |+| move files | `send_files` (to the Mac), `pull_file` (from the Mac) | - |++> **Example: "the Dock is acting up, restart it":** `process_kill {"name":"Dock"}` (macOS respawns the Dock automatically), then `process_list {"nameContains":"Dock"}` to confirm. No shell quoting. (`shell_execute` is the escape hatch for things with no verb yet; 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-machine relay: if you get `status:error` / `ambiguous_target`, pass `--target <name>` (list them with `adom-bridge targets`).++## 3: Long-running operations++For any multi-minute command, prefer verbs that return immediately and let you poll: `launch_process` with `detached:true` for a long-running process, and the `watch` wrapper for the whitelisted long walks (see the CLI guide skill). A sync call that outlives its `timeoutSeconds` budget returns `errorCode:"timed_out"` with `stillRunning:true`; poll the response's `statusVerb` instead of re-issuing.++## 4: Install (if missing or stale)++The canonical container install is the wiki package (bundles the Linux CLI **and** the skill set, which link into `~/.claude/skills/`):++```bash+adom-wiki pkg install adom/adom-bridge   # installs the adom-bridge CLI + the adom-bridge-* skills+adom-bridge --version                    # confirm+```++`adom-bridge status` reports the latest published version; if your local CLI is older, re-run the pkg install to refresh the binary **and** the skills atomically. **If you hand-installed only the binary, you won't have the skill set: re-run the pkg install so the discovery/main/direct-api skills land too.**++The user's Mac app installs separately (Apple Silicon, notarized dmg):++```bash+curl -fsSL https://wiki.adom.inc/api/v1/pages/adom-bridge-macos/files/install.sh | sh+```++On machines running Adom Hydrogen, Hydrogen installs and manages Bridge automatically; no manual install needed.++## 5: What you get once installed++The pkg 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 patterns, status+durationMs result shape |+| `adom-bridge-pup` | Puppeteer real-Chrome automation (browser_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 (Adom Hydrogen) |++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. Bridges installed on the Mac ship their own skills; `adom-bridge sync_skills` pulls them.++## 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 platform 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.
skills/adom-bridge-embedded/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-embedded/SKILL.mdadded+159
@@ -0,0 +1,159 @@+---+name: adom-bridge-embedded+description: Adom Bridge's EMBEDDED mode under Adom Hydrogen - what it means when Hydrogen owns Bridge, the lifecycle contract (Bridge stays up; exit when Hydrogen exits is opt-in flag-only), embedded permissions (Hydrogen's auto_approve on /embedded/enter, forwarding gated commands to Hydrogen's /permission/request, status.permissions.shell.embeddedPolicy), inspecting Hydrogen with hydrogen_status and the hydrogen_ verb family, and the read-only Hydrogen-managed relay entry. Use when reasoning about embedded vs standalone behavior, why Bridge survived (or should survive) a Hydrogen crash, why a gated command is waiting on Hydrogen instead of showing a Bridge dialog, or how to drive/inspect Hydrogen from a cloud caller.+---++# Adom Bridge embedded mode (under Adom Hydrogen)++## What embedded mode is++Adom Hydrogen bundles the signed Adom Bridge installer, installs it, and at+runtime spawns Bridge as its child:++```+adom-bridge --embedded --start-hidden --relay-url ws://127.0.0.1:8765 --relay-name adom-hydrogen [--session-token <hydrogen token>]+```++Install locations on macOS: a Hydrogen-managed Bridge lives under+`~/Library/Application Support/adom/bridge/` (Hydrogen 0.1.238+); a+standalone install is `Adom Bridge.app` in `/Applications`. Either way the+bundle id is `inc.adom.bridge` and the CLI binary names are unchanged+(`adom-bridge` / `adom-bridge-cli`).++While embedded:++- Bridge's window is hidden and Bridge HIDES its menu-bar item; Hydrogen is+  the app the human sees. With the window hidden, Bridge runs under the+  Accessory activation policy, so there is no Dock icon and no Cmd-Tab+  entry either. Bridge keeps serving everything (relay, direct API on+  127.0.0.1:47200, bridges) the whole time.+- Bridge writes an `embedded.json` marker (with `entered_via`) so a restart+  re-embeds if Hydrogen is still alive.+- Hydrogen injects a relay-back server entry tagged `added_by:"hd-spawn"`+  (see the read-only rule below).+- Sign-in is shared either way: one Adom sign-in serves both apps through a+  shared keychain item, so an embedded Bridge is signed in whenever Hydrogen+  is (and vice versa).++Entry paths: `POST /embedded/enter` on the direct API, or the `--embedded`+launch flag. Hydrogen polls `GET http://127.0.0.1:47200/health` after+spawning.++## The lifecycle contract (AD_LIFECYCLE.md, on one page)++1. **Bridge stays up.** It is the recovery channel for the machine: if+   Bridge exited whenever Hydrogen exited, one bad Hydrogen exit would leave+   the box unreachable to any remote agent (no log reads, no relaunch, no+   rollback).+2. **The ONLY thing that exits Bridge is an explicit shutdown**:+   `desktop_shutdown` / `POST /command {"command":"shutdown"}`, or the user+   quitting Bridge themselves.+3. **"Embedded" is a display state, not a lifecycle.** Hydrogen gone means+   Bridge drops the costume and reverts to a normal standalone menu-bar+   agent.++What happens when Hydrogen goes away:++| How Hydrogen goes away | What Bridge does |+|---|---|+| Graceful: Hydrogen sends `POST /embedded/release` before stopping | Revert to standalone (menu-bar item back, relay preserved), regardless of how Bridge entered embedded. |+| Ungraceful: Hydrogen's GUI crashes or is killed, no release sent | The `embedded_monitor` (10s probes, 3-miss debounce, ~30s) notices and reverts to standalone. Same end state. |+| A kill takes Hydrogen's whole process tree | Bridge dies WITH Hydrogen because it is Hydrogen's direct child process. This is the OS killing a child, NOT a lifecycle rule firing. Check for the tree-kill before blaming any Bridge logic. |++Reverting (`revert_to_standalone`) shows the menu-bar item again and keeps+the WS connection and the hd-spawn server entry; the supervisor reconnects+with backoff, so a remote caller sees `status=reconnecting`, never a+vanished row (Hydrogen's relay can outlive Hydrogen's GUI).++**Exit-with-Hydrogen is opt-in, flag-only (v1.9.165).** The only way to make+Bridge disposable is the spawner passing `--exit-when-hd-exits` on the+command line. Hydrogen does not pass it, so in practice Bridge always+survives. There is no config key (the old `config.exit_when_hd_exits` is+inert) and no Settings checkbox. The one flag-gated exit lives in+`embedded_monitor::transition_to_standalone`.++Forensics: both transitions write to `startup.log` (`EXIT:` / `REVERT:`+lines). No `crash.log` plus a `startup.log` that runs to `===` means it was+not a panic; an absent process with no crash.log is the signature of an+external (tree) kill.++## Embedded permissions: Hydrogen owns the prompt++Hydrogen sends `auto_approve` in `POST /embedded/enter`; it is authoritative:++- **absent**: Bridge keeps standalone behavior (older Hydrogen; the feature+  is inert until Hydrogen opts in).+- **`false`** (the new-contract default): Bridge does NOT auto-approve and+  shows no UI of its own. For each gated command from a remote relay caller,+  Bridge FORWARDS the request to Hydrogen's `POST /permission/request`+  (control port discovered fresh from Hydrogen's ports.json each call,+  blocking up to 180s) and applies Hydrogen's decision: `allow_once`,+  `allow_1h`, `allow_24h`, `bypass_while_running`, or `deny` (refused with+  `errorCode:denied_by_user`; do not auto-retry). `auto_approve:false`+  overrides any standing grant (the `--shell-approval` flag, Settings+  checkbox, or timer) for relay callers, so the forward always triggers; a+  forwarded allow_1h/24h/bypass sets a dedicated grant window that is+  honored without re-forwarding.+- **`true`**: auto-approve (the user explicitly opted in); behaves like a+  standing grant.++Fail-safe: if Hydrogen is unreachable, Bridge falls back to its OWN approval+dialog (the window summons itself from the menu bar; never hard-deny, never+silent-approve). Coverage runs through one chokepoint+(`consult_embedded_gate`): `shell_execute`, `run_script`, and the Tier-2+class (`launch_process`, `wiki_exec`, `write_file`, `delete_file`,+`process_kill`). Gated verbs accept a `reason` arg that is shown verbatim+(attributed as "The AI says:") on Hydrogen's modal and Bridge's dialog. Only+remote relay callers are gated; local callers (CLI, Hydrogen, bridges) are+trusted by transport.++**Discoverability:** `status.permissions.shell.embeddedPolicy` reports+`"standalone"` | `"forward-to-hd"` | `"auto-approve"`. If a gated call seems+to hang while embedded, a human is likely looking at Hydrogen's prompt; poll+rather than re-issue.++## Inspecting state: hydrogen_status's three orthogonal signals++Hydrogen is killed and respawned many times a day (upgrades, restarts), often+onto a different control port. `hydrogen_status` therefore never collapses its view+into one boolean; it reports THREE independent signals:++- `processPresent`: a process-list check (ps/pgrep on macOS), sampled with+  retry to ride the ~1s kill-then-respawn gap.+- `controlApiReachable`: TCP to Hydrogen's control port, discovered fresh per+  call (ports.json, never cached or hardcoded).+- `embedded`: Bridge's own state (its `AppState` / embedded marker).++`running = processPresent || controlApiReachable`. A mismatch between the+three is normal mid-churn; do not treat a single snapshot as Hydrogen being+dead (Bridge itself requires 3 consecutive misses over ~30s before leaving+embedded mode).++## The hydrogen_* verb family++Bridge exposes `hydrogen_*` verbs for driving and observing Hydrogen itself+(`hydrogen_status`, `hydrogen_stop` / `hydrogen_restart`, `hydrogen_send_files`). Architectural rule:+Bridge owns host/process facts (process present, install path); anything+about Hydrogen's OWN runtime state is fetched from Hydrogen's control API+endpoints (`GET /health`, `GET /buildinfo`, `GET /workspace/health`) and+relayed verbatim. The Hydrogen BUILD verbs (`hydrogen_ship`, `hydrogen_build_*`) are part+of the Windows dev pipeline and return a clean "not supported on macOS yet"+error on this fork.++Updates while embedded follow the same split: Bridge checks, downloads,+verifies and STAGES its own update (`GET /update/status` shows+`update_ready:true` when staged); Hydrogen owns the apply timing and triggers+`POST /update/apply`. Embedded Bridge never auto-applies.++## The Hydrogen-managed relay entry is READ-ONLY++The server entry Hydrogen injects at spawn is tagged `added_by == "hd-spawn"`+and is ephemeral (stripped on config persist, re-added each Hydrogen launch).+While embedded it cannot be disconnected, edited, or removed; the backend+refuses (`is_hd_managed` guard plus `HD_MANAGED_REFUSAL`) on+`disconnect_server`, `remove_server`, `update_server`, the upsert branch of+`add_server`, and the relay `server_add` / `server_remove` handlers, because+any of those would break the embedded session. The UI shows it as a "Managed+by HD" row with a locked note instead of editable fields. Do not try to work+around the refusal; the entry is torn down automatically when Bridge reverts+to standalone.
skills/adom-bridge-local-ai/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-local-ai/SKILL.mdadded+125
@@ -0,0 +1,125 @@+---+name: adom-bridge-local-ai+description: Drive this Mac 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. No relay, no cloud container, no --target. Use when the user says "Claude Desktop, control my Mac", "use Adom Bridge from Claude Code on my Mac", "local AI, drive my desktop", or when you find the bundled adom-bridge-cli. Covers the bundled CLI, the auto-linked consumer skills, loopback direct-API routing, and a starter verb cookbook.+---++# Adom Bridge from a local AI (same-Mac, no relay)++Adom Bridge (formerly Adom Bridge) is the automation surface for this Mac. It+runs as a menu-bar agent: signed-in launches go straight to the menu bar with+no window and no Dock icon, and the window is summoned from the menu-bar item+(or by launching the app again). If Bridge is installed here, any AI tool+running on the SAME machine (Claude Desktop, Claude Code, Codex, a local+script or agent) can drive the desktop through the bundled CLI. The+cloud-container path over the WebSocket relay is a different setup and is+covered elsewhere (see "When you DO need the relay" below).++## What the Mac install lays down for local AIs++- **The bundled CLI** lives inside the app bundle at+  `Adom Bridge.app/Contents/Resources/adom-bridge-cli`. Standalone installs+  put the app in `/Applications`; on machines managed by Adom Hydrogen the+  bundle lives under `~/Library/Application Support/adom/bridge/`. Invoke it+  by full path, or symlink it somewhere on your PATH.+- **Consumer skills are auto-linked into your agents.** The app bundles the+  consumer skill set, and on EVERY launch Bridge copies each skill into BOTH+  `~/.claude/skills/<slug>/` and `~/.codex/skills/<slug>/`. So a local+  Claude Desktop / Claude Code / Codex picks up Bridge's docs with zero user+  steps.++Auto-link behavior (from `src-tauri/src/skills_install.rs`):++- **Idempotent and cheap.** A skill is (re)written only when the destination+  is missing or its bytes differ from the bundled copy. A steady-state launch+  does zero disk writes.+- **`.adom-managed` marker.** Each directory Bridge creates carries a+  `.adom-managed` marker file. Bridge refreshes a skill (on an upgrade) only+  when the marker is present.+- **Never clobbers your own skills.** A same-slug directory WITHOUT the+  marker (hand-made, or installed by a package) is left untouched.+- **Opt out:** launch Bridge with `ADOM_SKILLS_LINK=0` to disable the+  auto-link.+- Runs in both standalone and embedded (under Adom Hydrogen) modes, and is+  best-effort: it can never fail or delay Bridge's launch.++## How a local AI calls Bridge++Run the CLI with a verb and a JSON args object, and NO `--target`:++```+adom-bridge-cli <verb> '<json-args>'+```++With no target set, the CLI short-circuits to the app's **loopback direct+API**: it reads `~/.adom/direct-api-port` (written by the app at bind time,+contents `host:port`, e.g. `127.0.0.1:47200`), falls back to probing+`127.0.0.1:47200` and scanning `47201..47209` if the file is missing, and+POSTs straight into the running app process. No relay, no container, no+WebSocket, no auth-token dance. Responses are the same JSON shape as every+other path, including `_hint`, `errorCode`, and `status` fields.++**Local callers are trusted by transport.** The direct API binds+`127.0.0.1` only, so a caller on this machine already got here by being+installed on this machine. The shell approval gate applies only to remote+relay (cloud AI) callers; the local CLI is not gated.++## Verb discovery++```+adom-bridge-cli commands+```++lists every available verb with args and return shapes (aliases: `help`,+`list_commands`). Unknown-verb responses also carry a hint pointing at the+discovery list.++## Starter cookbook++```bash+# Is Bridge up? (ping uses its own direct-API path)+adom-bridge-cli ping++# Enumerate visible top-level windows (returns window ids + titles)+adom-bridge-cli desktop_list_windows++# Capture one window, chrome included (lossless PNG)+adom-bridge-cli desktop_screenshot_window '{"hwnd": 123456}'+# ...or match by title:+adom-bridge-cli desktop_screenshot_window '{"titleContains": "TextEdit"}'++# Whole-screen capture+adom-bridge-cli desktop_screenshot_screen++# Show the user a native macOS notification+adom-bridge-cli notify_user '{"title":"Build done","body":"All tests passed"}'++# Launch an app (path-verified; fails loudly instead of an OS error dialog)+adom-bridge-cli launch '{"path":"/usr/bin/open","args":["-a","Safari"]}'++# Run a shell command through Bridge's structured verb+adom-bridge-cli shell_execute '{"command":"echo hello"}'+```++Prefer Bridge's structured verbs (`launch`, `find_exe`, `desktop_*`,+`notify_user`) over raw shell where one exists: they validate inputs and+return structured JSON with hints. For multi-line or quoted scripts use+`run_script` with `interpreter` set to `bash`, `zsh`, or `sh`.++## When you DO need the relay++The direct API only reaches the Bridge on THIS machine. To drive a REMOTE+machine (another computer's Bridge connected to the same relay), the CLI+routes over the relay with `--target <clientName>`; a cloud container always+uses the relay. That path, including multi-machine addressing and targeting+rules, is documented in the main `adom-bridge` skill (`skills/SKILL.md`) and+`skills/MULTI_AD_ADDRESSING.md`. This skill is only about the local,+no-relay case.++## Sibling apps: skip the CLI entirely++A sibling app or local process can also POST JSON commands directly to+Bridge's loopback HTTP API (`POST http://127.0.0.1:<port>/command` with+`{"command": "...", "args": {...}}`, port discovered from+`~/.adom/direct-api-port`) without spawning the CLI at all. The full+contract, including port discovery, endpoints, and update routes, is in the+`adom-bridge-direct-api` skill (`skills/DIRECT_API.md`).
skills/adom-bridge-multi-ad/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-multi-ad/SKILL.mdadded+127
@@ -0,0 +1,127 @@+---+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 Mac laptop plus a second desk machine. Covers client naming, --target, the targets verb, stable-identity eviction, and ambiguous-target handling.+---++# Multi-machine addressing: name + fan out to many Adom Bridges on one relay++> **Status: SHIPPED (v1.8.133+).** One relay holds many named Adom Bridge clients at once;+> each is addressable with `--target <name>`, and `--target all` fans a verb out to every+> connected machine with aggregated results. Client machines can be Macs (the auth payload+> reports `os: macOS`).+>+> **Trigger words:** multiple Adom Bridges, multiple desktops, two laptops, last one wins,+> relay fight, commands go to wrong machine, target a specific machine, --target,+> ADOM_TARGET, broadcast to all, fan out, name each machine, client name, machine name,+> relay multi-client.++---++## Context (why this exists)++Adom Bridge gets installed on multiple machines, all pointed at **one** cloud relay+(a container's `adom-bridge serve`). Before v1.8.133 they fought: when a client+authenticated, the relay evicted any existing client with the **same `appName`**, and+every client authed with the same appName, so the last machine to connect evicted the+others. The cloud AI's commands then landed on whichever box connected last, silently.++The shipped model: every Bridge stays connected and is **addressable by name**, with the+ability to **fan one command out to all** connected machines.++**Product decisions:**+1. **Friendly name, default = hostname.** Each machine has a configurable name+   (`--client-name` launch flag, the Settings field, or the OS hostname by default).+2. **`--target all` fan-out**: one command to every machine, results aggregated per+   machine.+3. **No target + multiple machines connected = ERROR** listing the connected names.+   The relay never guesses which box you meant.++## How it works++- **Auth payload:** each Bridge sends `clientName` (resolved friendly name), `version`,+  `hostname`, and `os` (`macOS` on the Mac build) in its relay auth handshake. The relay+  routes and de-duplicates on this.+- **Stable-identity eviction, never appName:** on auth, the relay evicts only a stale+  connection whose identity matches the new one. Identity = `clientName` when set, else+  `hostname`. Eviction fires on a `clientName` match always, and on a `hostname` match+  only when neither side has a `clientName`. So distinct named machines always coexist,+  and a genuine same-machine reconnect still kills its zombie connection.+- **CLI targeting:** `--target <name>` is stripped from raw argv before clap parses+  (it works before or after the verb). Precedence: flag, then `ADOM_TARGET` env. When a+  target is set the CLI always traverses the relay (the direct-API short-circuit is+  local-GUI-only). `"all"` broadcasts.+- **Broadcast** uses a dedicated awaiting/aggregating relay action: each target gets a+  clone of the message with a fresh message id, per-target timeouts and failures are+  isolated to that machine's entry, and the batch always returns.++## Using it++```bash+adom-bridge targets                       # list connected machines+# {count, targets:[{name, hostname, version, os, connectedAt}]}++adom-bridge --target mymac desktop_screenshot_screen+adom-bridge --target all ping             # note: use a relay-path verb for routing tests+adom-bridge --target all server_list+```++- Exactly one machine connected: no `--target` needed.+- `--target` matches `clientName` then `hostname`, case-insensitively.+- Caveat: `ping` has its own direct-API path and does NOT honor `--target`; smoke-test+  routing with a relay-path verb (`server_list`, any `desktop_*`).++### Result / error shapes++```jsonc+// --target all+{ "broadcast": true, "targets": ["mymac","deskmac"],+  "results": { "mymac": {/*verb payload*/},+               "deskmac": {"ok":false,"error":"Request timed out","timedOut":true} },+  "summary": { "total":2, "ok":1, "failed":1 } }          // duplicate names get a "#<idtail>" suffix++// no target, >1 machine connected (exit 1)+{ "error":"ambiguous_target",+  "_hint":"Multiple Adom Bridges connected. Re-run with --target <name> (or --target all).",+  "connected":[{"name":"mymac","hostname":"MYMAC","version":"1.9.x"}, ...] }++// --target names a machine that isn't connected (exit 1)+{ "error":"target_not_found", "requested":"deskmac",+  "_hint":"No connected Adom Bridge matches 'deskmac'. Connected: mymac, desk-mac-01.",+  "connected":[ /* same shape */ ] }+```++## Naming a machine++- **Settings field** in the Bridge window: "This machine's name (for cloud addressing)",+  default hostname. Writes `client_name` in `~/.adom/config.json` and reconnects.+- **`--client-name <name>` launch flag**: how a parent process (Adom Hydrogen) names a+  spawned Bridge. Distinct from `--relay-name` (the relay's display name in Bridge's+  server list, not this machine's self-name).++## Edge cases & back-compat++| Case | Behavior |+|---|---|+| Old client (no clientName) | Relay falls back to `hostname` for identity + addressing. |+| No `--target`, 1 machine | Routes to it, unchanged. |+| No `--target`, >1 machine | `ambiguous_target` (self-documenting); pass a target. |+| No target, local direct API up | Direct short-circuit preserved (fast local hop). |+| `--target <self>` | Skips direct, goes via relay: explicit and correct. |+| Same hostname, no clientName | They collide; set distinct `--client-name`s, then they coexist. `targets` shows the collision. |+| Reconnect (zombie) | Same name/hostname evicts the stale connection (intended). |+| Broadcast partial failure/timeout | Per-target error object; batch still returns with `summary.failed`. |+| `--target all`, 0 machines | `{broadcast:true,targets:[],results:{},summary:{total:0,...}}`, exit 0. |++## Verification recipe++Two Bridges against one relay:+1. Connect machine A (`--client-name mymac`) + machine B (`--client-name deskmac`);+   `adom-bridge targets` shows 2 rows with names + versions.+2. A no-target relay verb returns `ambiguous_target` + both names, exit 1.+3. `--target mymac` reaches A only; `--target deskmac` reaches B only.+4. `--target all` returns `{broadcast:true, results:{mymac, deskmac}}`.+5. `--target nope` returns `target_not_found` + the connected list, exit 1.+6. Kill and reconnect B: `targets` still shows exactly 2 (the reconnect evicted its own+   stale entry).+7. Single-machine regression: disconnect B; no-target verbs work and the local direct+   path is unchanged.
skills/adom-bridge-notify/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-notify/SKILL.mdadded+299
@@ -0,0 +1,299 @@+---+name: adom-bridge-notify+description: >-+  Send native macOS Notification Center 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, including focus:{host:true} to reveal Adom Hydrogen when Bridge 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, or ask for a decision and read it back. Covers the notification types, the exact verb calls, how the AI polls or waits for responses, and what a click actually does.+---++# Notifications: `notify_user` (macOS Notification Center)++`notify_user` fires a native macOS notification under Adom Bridge's own+identity (UNUserNotificationCenter via the notification plugin, with an+osascript fallback if the plugin is unavailable). It lands as a banner and in+Notification Center like any first-class app.++Reachable three ways, all identical shape:+- **CLI / cloud:** `adom-bridge notify_user '{"title":"...","body":"..."}'`+- **Direct API (sibling app):** `POST /command {"app":"desktop","command":"notify_user","args":{...}}`+- **From a bridge:** the same `notify_user` verb via the relay.++Returns `{ "status":"ok", "action":"displayed", "id":"...", "clickBehavior":"...", "_hint":"...", "_next":[...] }`+plus `_couldHaveDoneBetter` and `_capabilities` when the call left something on+the table. Read those; they are how you learn what this verb can do without+reading this file.++## TWO axes: do not confuse them++A notification can do two separate things. Almost all confusion comes from+mixing them up:++| Axis | Field(s) | What it does | Reply to you? |+|---|---|---|---|+| 1: GET THE USER'S ANSWER BACK | `wait:true` (block-and-return), or poll `notify_events` / `notify_response` | delivers the user's click/choice to you, the AI | YES, this is the one you usually want |+| 2: REVEAL A WINDOW | `focus:{...}` | raises a window on the user's SCREEN (Hydrogen, a browser, a dialog) | NO, pure local desktop action |++### Axis 1: how you get the answer back (there is NO server-push; you poll or you wait)++You are turn-based; Bridge cannot inject an event into your conversation. So+there are exactly two ways, fastest first:++```bash+# FAST PATH: wait:true. The call BLOCKS and returns the user's choice inline.+adom-bridge notify_user '{"title":"Ship v2 to prod?","scenario":"reminder","buttons":[{"label":"Ship it"},{"label":"Hold"}],"wait":true,"timeoutSec":120}'+#   -> { "pending": false, "action": "Ship it" }++# FIRE-AND-CONTINUE: do not block; drain events on a later turn.+adom-bridge notify_user '{"id":"deploy-42","title":"Ship v2?","scenario":"reminder","buttons":[{"label":"Ship it"},{"label":"Hold"}]}'+adom-bridge notify_events '{"sinceSeq":0}'   # every click since a cursor (recommended: no per-id polling)+adom-bridge notify_response '{"id":"deploy-42"}'   # or poll one id+```++The click is recorded durably (a cross-process file), so both paths work even+when the click is handled by a transient process. Details: sections 5 / 5b+below.++### Axis 2: `focus` (reveal a window) is NOT a reply++`focus` is covered next. It raises a window on screen; it does not tell you+anything by itself. A notification can do BOTH at once (a reminder with+buttons plus `focus:{host:true}` reveals Hydrogen on click AND reports the+click back via `wait` / `notify_events`).++## Start here: make the click DO something (`focus`)++Most notifications are about something else on screen: a browser waiting on a+login, a dialog, an app that needs a click. If you send a bare notification,+clicking it opens the Adom Bridge window, which is almost never what the user+wanted. They clicked because they wanted to get to the thing.++Pass `focus` and one click takes them straight there:++```bash+adom-bridge notify_user '{+  "title": "Sign-in needed",+  "body": "Google wants you to confirm before I can continue.",+  "focus": { "titleContains": "Sign in" }+}'+```++Ways to name the window, in order of preference:++| selector | when | note |+|---|---|---|+| `{"hwnd": 459604}` | best: you already have the handle | exact. On macOS this is the CGWindowList window id; get it from `desktop_list_windows`, or from whatever verb opened the window. |+| `{"cacheKey": "my-session-id"}` | you drove the window through a verb that cached it under a key | survives title churn, which a title match does not. |+| `{"app": "adom-hydrogen"}` | you know the owning APP, not the exact window | process/app name match, case-insensitive. Survives PID churn (a process that restarts keeps its name), so it beats a stale window id for a long-lived app that respawns. |+| `{"host": true}` | the notification is a prompt/approval Bridge is showing on behalf of Adom Hydrogen | "the app Bridge is embedded under." Bridge resolves Hydrogen's window itself; you do not need its id or title. A click reveals Hydrogen, not Bridge. Falls back to opening Bridge when standalone. |+| `{"titleContains": "Sign in"}` | you only know roughly what it says | substring, case-insensitive. Breaks the moment the title changes, and page titles change constantly. |++Bridge resolves the target when you SEND and tells you in the response whether+the click will land:++```json+"clickBehavior": "focus-window",+"focus": { "resolved": true, "hwnd": 1313916, "title": "Sign in", "resolvedBy": "title" }+```++`resolved:false` means no matching window exists right now. The notification+still shows, and Bridge re-resolves at click time using the selectors you+passed, so this is fine if the window has not been created yet, and a bug you+should fix if it should already exist. If it still cannot find the window on+click, it falls back to opening Adom Bridge rather than doing nothing.++**Rule of thumb: if your notification mentions a window, a browser, a dialog,+a tab, or asks the user to go click something, it should carry `focus`.**++## Fields++| field | type | notes |+|---|---|---|+| `title` | string | bold top line. Optional (missing/null renders as empty). |+| `body` | string | message text (use `\n` for a second line). Optional. |+| `level` | string | `info` (default), `success`, `warning`, `error`, `emergency`. macOS owns the notification chrome; `level` is carried for logging/urgency and does not restyle the banner. |+| `durationLong` | bool | request a longer display duration. macOS banners are transient by default; how long a banner stays is ultimately a macOS setting (banner vs alert style). |+| `scenario` | string | `reminder` / `alarm` / `incomingCall` mark the notification as needing acknowledgement. On macOS a banner still auto-dismisses unless the user has set Adom Bridge's notification style to Alerts; pair with buttons and `wait`/`notify_events` so you learn the outcome either way. |+| `buttons` | array | `[{ "label": "...", "arguments": "<optional uri>" }]`, action buttons. Omit `arguments` and Bridge synthesizes a round-trip URI so a click is reported via `notify_response`. Buttons only render if the user has ALLOWED notifications for Adom Bridge (System Settings > Notifications). |+| `actions` | array | back-compat: `["Label1","Label2"]`, simple label-only buttons (same round-trip as `buttons` without `arguments`). |+| `progress` | object | `{ "title":"...", "value":0.0-1.0, "status":"...", "valueOverride":"6 / 10" }`. Accepted cross-platform; macOS Notification Center has no progress-bar chrome, so treat this as status text on macOS. |+| `inputs` | array | `[{ "id":"reply", "type":"text", "placeholder":"..." }]`. Display only; see Limitations. For a real choice round-trip use `buttons`. |+| `id` | string | correlation id. Supply it so you can poll `notify_response {id}` for the clicked button. Auto-generated if omitted (and returned to you as `id`, so you can still poll it). |+| `focus` | object | `{hwnd}` \| `{cacheKey}` \| `{titleContains}` \| `{app}` \| `{host:true}`: the window this notification is ABOUT. A body click brings THAT window to the front instead of opening Bridge. See the section above. |+| `wait` | bool | block the verb until the user clicks (or `timeoutSec` elapses) and return their choice, instead of polling `notify_response`. Needs buttons (or a `focus` click) to be meaningful. |+| `timeoutSec` | number | how long `wait` blocks, 1-600. Default 60. |++## The types: copy/paste++**1. Basic** (transient banner)+```bash+adom-bridge notify_user '{"title":"Export done","body":"gerbers.zip is ready."}'+```++**2. Level** (carried in the log/urgency)+```bash+adom-bridge notify_user '{"title":"DRC failed","body":"3 clearance violations.","level":"error"}'+```++**3. Acknowledge-me reminder** (pair with buttons and read the answer back)+```bash+adom-bridge notify_user '{"title":"Review needed","body":"The board is ready for your sign-off.","scenario":"reminder","buttons":[{"label":"Acknowledge"}]}'+```++**4. Progress updates**: re-fire with the SAME `id` as the job advances.+```bash+adom-bridge notify_user '{"id":"export-1","title":"Exporting gerbers","progress":{"title":"InstaPCB","value":0.6,"status":"Working...","valueOverride":"6 / 10 layers"}}'+```++**5. Buttons that round-trip**: learn which one the user clicked+```bash+# Fire with a correlation id you choose:+adom-bridge notify_user '{"id":"approve-42","title":"Deploy v2?","body":"Ship to prod?","scenario":"reminder","buttons":[{"label":"Ship it"},{"label":"Hold"}]}'++# Then poll until the user acts (pending:true until they click):+adom-bridge notify_response '{"id":"approve-42"}'+#   pending -> { "pending": true,  "action": null }+#   clicked -> { "pending": false, "action": "Ship it" }+```+Poll every 1-2s; give up after a sensible timeout (the user may dismiss it).++The click is recorded durably (a cross-process file), so `notify_response`+works even when the activation is handled by a transient process, not just+when Bridge's window is open.++**5b. Get told without per-id polling: `notify_events`**+```bash+# One call returns EVERY click since your cursor (the relay has no true+# server-push, so this is the "tell me immediately" path):+adom-bridge notify_events '{"sinceSeq":0}'+#   -> { "events": [ { "event":"notify_clicked", "id":"approve-42", "action":"Ship it", "at":"..." } ],+#        "nextSeq": 1, "count": 1 }+# Pass the returned nextSeq as sinceSeq next time to get only new clicks.+```++**5c. Retract a delivered notification: `notify_dismiss`**+```bash+# Clear a notification from Notification Center once you have handled it,+# by the same correlation id you fired it with (best-effort):+adom-bridge notify_dismiss '{"id":"approve-42"}'   # -> { "dismissed": true }+```++**6. Explicit button URI**: drive an arbitrary Bridge action (advanced)+```bash+# A button can carry any adom-bridge:// URI; clicking dispatches it.+adom-bridge notify_user '{"title":"...","buttons":[{"label":"Approve 1 hr","arguments":"adom-bridge://shell-approve?dur=3600"}]}'+```++**7. Point the user at the window that needs them** (the most useful type)+```bash+# The whole notification is "go look at this", so make the click go there.+HWND=$(adom-bridge desktop_list_windows | jq '.windows[]|select(.title|test("Sign in"))|.hwnd')+adom-bridge notify_user '{"title":"Sign-in needed","body":"Confirm in the browser and I will continue.","focus":{"hwnd":'"$HWND"'}}'+```+Add `scenario:"reminder"` plus a button if you also need to know they dealt+with it. A focus click is itself recorded as an acknowledgement+(`action:"focus"`), so `notify_response` / `notify_events` tell you they+engaged even with no buttons at all.++## Click behaviour: what actually happens++Clicking the notification body or any button dispatches Bridge's+`adom-bridge:` URI protocol through the single-instance handler.++What a body click does depends on you:++| you sent | body click does |+|---|---|+| `focus: {...}` | brings that window to the front. Falls back to opening Bridge if the window is gone. Also records `action:"focus"` under your `id`. |+| nothing | opens the Adom Bridge window. Rarely what the user wanted. |++A button click does what its `arguments` URI says, defaulting to a round-trip+that reports the label back through `notify_response`. Buttons do not+foreground Bridge; a notification exists to reach someone WITHOUT interrupting+them, so acknowledging one must not yank them out of what they were typing.+The exceptions are deliberate: a body click (the conventional "show me the+app" gesture) and a shell-approval grant (you should see what you just+authorized).++### What a click can and cannot be wired to++`focus-window` is exposed as a click action; arbitrary verbs are not, and will+not be. The reason is that you write both the button label and its URI, so a+button could always be mislabelled. Only actions whose worst case is tolerable+under ANY label belong on a notification: raising a window is non-destructive,+immediately visible, and undone by one Cmd-Tab. Anything that executes,+writes, or grants is not; that is what the permission model is for.++## The response teaches you++Every `notify_user` response carries what a click will do and what to do next:++```json+{+  "status": "ok", "action": "displayed", "id": "t-42",+  "clickBehavior": "focus-window",+  "focus": { "resolved": true, "hwnd": 1313916, "title": "...", "resolvedBy": "hwnd" },+  "_hint": "Shown. Clicking the notification brings \"...\" to the front ...",+  "_next": [ "notify_response {\"id\":\"t-42\"}", "notify_events {\"sinceSeq\":N}", "desktop_list_windows" ]+}+```++When the call left something on the table you also get+`_couldHaveDoneBetter` (specific, e.g. "no `focus` was passed, so clicking+this just opens Adom Bridge") and `_capabilities` (the full menu of what+`notify_user` can do). These appear ONLY when there is something to fix, so a+well-formed call gets a short response.++Current coaching triggers:+- no `focus` (with an extra nudge when the wording sounds like it is asking+  the user to go do something)+- `focus` that resolves to no window right now+- buttons without `scenario:"reminder"` (a transient banner the user may+  never see)+- `wait:true` with nothing to click++## Limitations (macOS Notification Center rules, not Bridge bugs)++- **Notifications must be allowed.** The first notification triggers the+  macOS permission prompt; if the user declined, nothing shows. Fix in+  System Settings > Notifications > Adom Bridge.+- **Banners are transient by default.** macOS decides banner-vs-alert style+  per app; only the Alerts style keeps a notification on screen until acted+  on. A `scenario:"reminder"` cannot force persistence past the user's+  banner-style setting, and Focus / Do Not Disturb can suppress banners+  entirely (they still land in Notification Center).+- **Buttons require notification permission** and appear on hover/expand of+  the banner, per macOS convention.+- **Typed `inputs` are display-only.** For a real choice round-trip, use+  buttons (each choice is a button) and read `notify_response`.+- **A notification cannot truly block.** For a hard, must-click-to-proceed+  prompt, that is a real dialog window (the Bridge approval dialog), not a+  notification.++## Troubleshooting: "I got `status:ok` but saw no notification"++`notify_user` returning `status:ok` means only that Bridge accepted the+request and dispatched the notification; it does not confirm it displayed. If+nothing appeared:+1. **Check Notification Center** (click the clock in the menu bar). Focus /+   Do Not Disturb routes banners straight there. That is a macOS setting, not+   a Bridge bug; the user can allow Adom Bridge notifications during Focus in+   System Settings > Focus.+2. **Check notification permission**: System Settings > Notifications >+   Adom Bridge must be allowed.+3. **Check you are actually reaching that Bridge.** From a cloud container,+   `notify_user` traverses the relay the Bridge is connected to; a+   `--target <name>` aimed at a relay that is not running silently fails, so+   confirm with `adom-bridge targets` first. (The direct API does carry+   notify; a bridge or sibling app on the same machine can+   `POST /command {"command":"notify_user",...}` with no relay.)+4. When you must be SURE the human saw it, do not rely on the notification+   alone; also surface the message in your chat/response.++## When to use which+- **Awareness** ("export done", "build green"): basic, or `level:"success"`.+- **"Go look at this"** (a login, a dialog, an app waiting on them):+  `focus`, so the click lands on that window. This is the single+  highest-value field in the verb and the one most often forgotten.+- **Need the user to choose**: `buttons` plus `wait:true` (or poll+  `notify_response` / `notify_events`).+- **Long-running job**: re-fire with the same `id` as status advances.+- **Genuine emergency**: `level:"emergency"`, and consider also summoning a+  window via `focus` so it cannot be missed.
skills/adom-bridge-permissions/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-permissions/SKILL.mdadded+239
@@ -0,0 +1,239 @@+---+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, why grants are+  human-only, and the macOS TCC permissions (Screen Recording, Accessibility)+  that sit underneath Bridge's own gate.+---++# Adom Bridge permissions: the human approval gate for AI callers++## The trust model in one paragraph++Bridge binds `127.0.0.1` only, and installing Bridge (or a bridge plugin) is+the user's consent to be driven, so every LOCAL caller (the CLI on the same+box, Adom Hydrogen, and bridges on the loopback direct API) is trusted by+transport and never gated. The approval gate applies to exactly one origin:+the remote relay / cloud-AI path (`via_relay`), a thread driving the machine+from off-box that the user may not be watching. If you are calling over the+relay, the gated verbs below require a human grant. Read-only verbs+(screenshots, status, window and UI reads, discovery) are never gated.++## Which verbs gate++The gated class is "runs code / writes / deletes / kills processes /+synthesizes input":++- **Arbitrary code execution:** `shell_execute`, `run_script` (bash / zsh /+  sh), `launch_process`, `wiki_exec`+- **Filesystem:** `write_file`, `delete_file`+- **Process control:** `process_kill`+- **Synthetic input** (drive the machine as the user): `desktop_click` /+  `desktop_type` / `desktop_press_key` and the Accessibility (AX) action+  verbs; these ride the SAME grant window as shell++One human grant covers the whole class. A bridge's own exec verbs+(`browser_evaluate`, `nbrowser_eval`, `blender_run_python`,+`fusion_run_modeling_script`, ...) gate per the bridge's declared `risk`+field, independently of this class; check that bridge's describe output, not+this doc.++## The `reason` requirement: every gated call must explain itself++Every permission-class verb sent over the relay MUST carry a `reason` arg:+one plain sentence saying WHY you need this. The human sees it verbatim in+the approval prompt, attributed as "The AI says: ..." (omitting it shows+"(no reason given)", which a cautious user should decline). It also lands in+Bridge's activity log.++```bash+adom-bridge shell_execute '{"command":"brew install kicad",+  "reason":"Installing KiCad so I can export your gerbers"}'+```++Write the reason for the human, not for the log: say what you are trying to+accomplish, not what the command does. Good: "Installing the KiCad CLI so I+can export your gerbers". Weak: "running npm install" (that is just the+command again). The reason is capped at 300 chars and never replaces the+command preview; the human always sees both.++A permission-class call with NO reason is refused before any approval path:++```json+{+  "success": false,+  "errorCode": "reason_required",+  "error": "'write_file' needs human permission, so it REQUIRES a `reason`. Retry the SAME call with reason:\"...\".",+  "retryWith": { "reason": "<why you need this, in one plain sentence>" },+  "appliesTo": ["shell_execute", "run_script", "launch_process", "wiki_exec", "write_file", "delete_file", "process_kill"]+}+```++The rule is class-based and flat: the verbs in `appliesTo` ALWAYS carry a+reason (whether or not a grant is currently active); verbs not in that list+never need one. Learn it once: do not pay the round-trip again, and do not+add `reason` to ungated verbs.++## What a refusal looks like++When no grant is active, a gated relay call refuses with+`errorCode: "needs_approval"` plus a structured `approval` block; you never+need to read `~/.adom/config.json`:++```json+{+  "success": false,+  "errorCode": "needs_approval",+  "error": "'write_file' needs shell approval - it runs code / writes / deletes / kills a process (a Tier-2 verb, as dangerous as shell_execute).",+  "approval": {+    "required": true,+    "granted": false,+    "class": "shell",+    "verb": "write_file",+    "grantedBy": "human-only",+    "grantVerb": "request_shell_approval",+    "howToGrant": ["Ask the user to click Approve (1 hr / 24 hr) or Bypass Permissions ...",+                   "Call request_shell_approval to re-surface the prompt ...",+                   "The user can tick Settings > Shell Commands > Bypass Permissions ..."]+  }+}+```++The refusal itself already surfaced a human-facing prompt on the machine: the+Bridge window SUMMONS ITSELF to show the approval dialog (Bridge is normally a+menu-bar agent with no window; an approval flips it from Accessory to Regular,+shows the window, and activates it), and a Notification Center notification+fires as well. If the human actively clicks Deny (embedded mode), you get+`errorCode: "denied_by_user"` instead; do NOT auto-retry that one, ask the+user what they want.++## The correct AI flow on a gate hit++1. **Surface the prompt** (the gate already did once; re-surface on demand):++   ```bash+   adom-bridge request_shell_approval+   ```++   This is ungated; it only SURFACES, never grants. It re-shows the approval+   dialog and brings the Bridge window to the front, so "there is nothing to+   click" is never true.++2. **Ask the human** (in your conversation) to click **Approve 1 hr**,+   **Approve 24 hr**, or **Bypass Permissions** in the Adom Bridge window.++3. **RETRY the original verb unchanged** (with its `reason`). One grant+   covers the whole exec/write/kill/input class for the granted window.++## Grants are HUMAN-ONLY: you cannot self-grant++Granting is permanently human-only. The only grant paths are a human clicking+in Bridge: the approval dialog's Approve buttons and the Settings checkbox. A+GRANT attempt via `shell_auto_approve` / `set_shell_auto_approve` from a relay+(cloud-AI) caller is refused with `errorCode: "human_only"` and auto-surfaces+the human prompt instead. Your only lever is `request_shell_approval`; it+TRIGGERS the human decision, it does not perform it.++The one exception: a REVOKE is allowed for any caller (de-escalation is+always safe):++```bash+adom-bridge shell_auto_approve '{"duration_secs":0}'   # clears the checkbox AND the timer+```++Do not look for a workaround. `launch_process` is not a bypass of+`shell_execute` (it gates identically), and synthetic input (clicking the+Approve button yourself) rides the same grant. If the AI could grant, the+gate would be theater.++## The durable Settings checkbox++**Settings > Shell Commands > Bypass Permissions** (config+`shell_auto_approve_permanent`, default OFF) is the durable indefinite grant,+for machines the user fully trusts. The timed buttons (1 hr / 24 hr) set an+in-memory window instead. Unticking the checkbox, or+`shell_auto_approve {"duration_secs":0}`, returns to per-command approval+instantly.++## Embedded mode: Adom Hydrogen owns the decision++When Bridge runs embedded under Adom Hydrogen (Hydrogen passed `auto_approve`+in `POST /embedded/enter`), the human's app is Hydrogen and Bridge shows no UI+of its own. Each gated relay command is FORWARDED to Hydrogen's+`/permission/request`; Hydrogen renders the prompt (with your `reason` and a+decoded command preview) and returns the decision:++- `allow_once`: run this one command, no window+- `allow_1h` / `allow_24h`: set a timed grant window and run+- `bypass_while_running`: session-length grant (clears on restart)+- `deny`: you get `errorCode: "denied_by_user"`; do not auto-retry++While forwarding is active, a standing grant (flag / checkbox / timer) does+NOT suppress the forward; Hydrogen decides every time. If Hydrogen is+unreachable, Bridge falls back to its own dialog. `auto_approve: true` means+the user opted in to auto-approval.++## macOS TCC permissions (underneath Bridge's gate)++Bridge's approval gate governs WHO may drive the machine; macOS's own TCC+privacy system separately governs what the Bridge PROCESS may do, and no+Bridge grant can override it. In System Settings > Privacy & Security the app+appears as **Adom Bridge**:++- **Screen Recording**: required for window and screen screenshots. Without+  it, captures come back blank or fail.+- **Accessibility**: required for synthetic input (CGEvent mouse/keyboard)+  and the AX read/action verbs. Without it, gated input verbs fail with a+  clear error naming the missing grant (macOS silently ignores CGEvent+  posting otherwise, so Bridge checks `AXIsProcessTrusted` first and refuses+  loudly).++If a verb says the Accessibility or Screen Recording permission is missing,+that is a one-time human toggle in System Settings, not something+`request_shell_approval` can grant.++## Check state up front with `status`++Before an unattended run, read `status.permissions.shell` so you know whether+you will stall on a human gate:++```bash+adom-bridge status+```++```json+"permissions": {+  "shell": {+    "autoApproved": false,+    "willPrompt": true,+    "source": "none",              // "permanent" | "timed" | "none"+    "expiresInSec": null,+    "grantedBy": "human-only",+    "grantVerb": "request_shell_approval",+    "gatedFor": "relay (cloud-AI) callers only",+    "embeddedPolicy": "standalone" // "forward-to-hd" | "auto-approve" | "standalone"+  },+  "bridgeExec": { "note": "bridge exec verbs gate per the bridge's declared risk field" }+}+```++If `willPrompt` is true and no human is at the machine, get the grant FIRST+(`request_shell_approval` plus asking the user) before starting the run.++## Quick reference++| Situation | What you do |+|---|---|+| `errorCode: reason_required` | Resend the SAME call plus `reason:"<one plain sentence>"` |+| `errorCode: needs_approval` | `request_shell_approval`, ask the human to click Approve, retry |+| `errorCode: human_only` | You tried to self-grant. Ask the human instead |+| `errorCode: denied_by_user` | The human said no. Do not auto-retry; ask them |+| A capture/input verb reports a missing macOS permission | Ask the user to enable Adom Bridge under System Settings > Privacy & Security (Screen Recording or Accessibility) |+| Planning an unattended run | Check `status.permissions.shell.willPrompt` first |+| Need to drop a grant | `shell_auto_approve {"duration_secs":0}` (allowed for any caller) |
skills/adom-bridge-presence/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-presence/SKILL.mdadded+117
@@ -0,0 +1,117 @@+---+name: adom-bridge-presence+description: How Adom Bridge lives quietly on a Mac. Covers the menu-bar agent model (signed-in launches go straight to the menu bar with no window and no Dock icon), summoning the window from the menu-bar item, close-to-menu-bar behavior (the close button hides the window, Bridge keeps serving), launch at login via LaunchAgent (default ON, self-healing), the shared Adom sign-in in the keychain, and the window title markers. Use when a user or AI asks why Bridge is still running after closing the window, where the app went after launch, why there is no Dock icon, how to enable or disable launch at login, why the menu-bar item is missing, or what the window title markers mean.+---++# Adom Bridge presence: menu-bar agent, hidden window, launch at login++Adom Bridge is a menu-bar agent (since 1.9.186). 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, notifications, and approvals whether or not the+window is on screen.++## The menu-bar agent model++- A SIGNED-IN standalone launch goes straight to the menu bar: no window, and+  no Dock icon. Whenever the window is hidden, Bridge runs under the macOS+  Accessory activation policy (no Dock icon, no Cmd-Tab entry), in BOTH+  standalone and embedded modes. The menu-bar item is the always-present+  control surface.+- A signed-OUT launch still shows the window: the first-run sign-in flow needs+  a surface, and "installed it, nothing happened" on a fresh machine is a bug+  class we never want back.+- The window is a settings-and-approvals surface. It is summoned three ways:+  - the menu-bar item (Show Window / Settings)+  - launching the app a second time (single-instance: the running instance+    shows and focuses its window)+  - automatically, when Bridge needs the human: an approval dialog or the+    sign-in flow flips the app from Accessory to Regular (Dock icon appears)+    and activates the window. Hiding the window again returns to Accessory.+- Closing the window returns Bridge to the menu bar. It does not exit.++The menu-bar item's menu: Show Window, Connect All, Disconnect All, Settings,+Quit.++## Close to menu bar: the close button hides, Bridge keeps serving++- Clicking the window's close button does NOT exit Bridge. The close request+  hides the window and Bridge keeps running and serving (relay, direct API,+  bridges, notifications). This is by design: Bridge must stay reachable as+  the machine's recovery channel.+- It is never silent: the FIRST 3 TIMES the user closes the window, Bridge+  shows a notification explaining that it moved to the menu bar, 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 Bridge again while it is hidden is never a no-op (single-instance):+  - standalone: the existing instance shows and focuses its window+  - embedded with Adom Hydrogen alive: a notification says where Bridge lives+    (inside Hydrogen)+  - embedded but Hydrogen is dead (stale flag): treated as standalone, the+    window shows+- The ONLY thing that exits Bridge is the `desktop_shutdown` verb+  (`POST /command {"command":"shutdown"}` on the direct API) or Quit in the+  menu-bar menu. Closing the window never does.++## The menu-bar item: standalone only++- In STANDALONE mode Bridge has a menu-bar item.+- When EMBEDDED in Adom Hydrogen, Bridge HIDES its menu-bar item; Hydrogen is+  the app the human sees. When Hydrogen goes away, Bridge reverts to+  standalone and the menu-bar item comes back.+- The absence of the menu-bar item plus a running Bridge process is therefore+  the normal embedded state, not a failure.++## The hidden window costs nothing extra++There is no separate renderer-suspension machinery on macOS: a hidden window+is simply hidden, and the Accessory policy keeps Bridge out of the Dock and+Cmd-Tab. The Rust side keeps doing ALL real work while hidden: relay, direct+API, bridges, bridge supervision (persistent respawn plus duplicate collapse),+notifications, and shell approvals. UI-only work (LED probes, status emits+that exist to paint the GUI) is skipped while the window is hidden and+self-heals within one health tick of showing it.++## Launch at login: default ON, self-healing++- The intent is `config.launch_on_boot`, DEFAULT TRUE. The Settings checkbox+  (launch at login) reads and writes this intent and is checked by default.+- The mechanism is a per-user LaunchAgent:+  `~/Library/LaunchAgents/inc.adom.bridge.plist`.+- A reconcile runs on EVERY launch, in BOTH standalone and embedded modes:+  - intent ON and the plist is missing or stale: (re)write it+  - intent OFF and the plist exists: delete it+- This is the self-heal: a plist that was never created, got deleted, points+  at a moved app path, or failed to write is corrected on the next launch. An+  update that relocates the app rewrites the LaunchAgent to the new path.+- Never create the LaunchAgent manually; the reconcile owns it.++## Shared Adom sign-in (keychain)++One Adom sign-in serves both Adom Bridge and Adom Hydrogen: the session token+lives in a SHARED keychain item, and `~/.adom/session.json` is a rebuilt+cache. Consequences:++- Signing in to either app signs in both.+- The sign-in survives uninstall and reinstall (the keychain item persists;+  the file cache is rehydrated from it).+- Signing out deletes the keychain item, so it signs out everywhere.++## Window title markers++The window title carries live version and provenance markers: the running+VERSION (for example `Adom Bridge — v1.9.187 release`), the git SHA and build+timestamp, and a `signed` or `unsigned` marker for the running binary's code+signature. This is the quick truth check for which build is actually running.++## Foreground attribution (v1.9.175)++Bridge never raises its own window silently. Every self-foreground (sign-in+completion, notification click, shell-approve grant, `request_shell_approval`,+menu-bar 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 Bridge consumes it+(10-minute freshness gate) and stays in the menu bar. If a user reports+"Bridge randomly came to the front", read the Activity Log or startup.log; the+answer is always recorded.
skills/adom-bridge-runtimes/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-runtimes/SKILL.mdadded+150
@@ -0,0 +1,150 @@+---+name: adom-bridge-runtimes+description: >+  Managed Node and Python runtimes for Adom Bridge bridges on macOS. 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-elevation portable node/python install, the prewarm on first launch,+  the Runtimes panel in the GUI, or the loopback-only bind rule for bridge processes.+---++# Managed runtimes: how Adom Bridge provisions Node and Python for bridges++Adom Bridge owns a global, portable Node and Python that every bridge shares.+A bridge never installs its own interpreter and never needs Homebrew, sudo, or+an installer. Core logic lives in `src-tauri/src/runtime_cache.rs`.++## What the managed runtimes are++Resolution order for `ensure_runtime(app, Node|Python)`, race-safe via a+per-kind install lock (two bridges asking at once never double-download):++1. Already `Ready` in the in-memory state map: return the exe.+2. A **system runtime on PATH** is used as-is (`source: system`), no download.+   On a Mac this is typically a Homebrew or nvm/pyenv install; a found exe is+   accepted only if it reports a parseable version.+3. A prior **portable copy** under `~/.adom/adom-runtimes/<kind>-<ver>/`+   (`source: cache`).+4. **Download a portable copy**: stream-download, extract, atomic rename into+   the runtime dir, emitting `runtime-status` progress events.++Pinned portable versions (one place to bump, in `runtime_cache.rs`):++- Node 22.11.0 (official nodejs.org tarball; `darwin-arm64` on Apple Silicon,+  `darwin-x64` otherwise)+- Python 3.12.13 (astral-sh python-build-standalone `install_only` tar.gz for+  `aarch64-apple-darwin` / `x86_64-apple-darwin`; full CPython with `venv`+  and `pip`)++The two hard guarantees:++1. **Never an elevation or installer prompt.** The portable runtimes are plain+   archive extracts into the user-writable `~/.adom/adom-runtimes/`. No `.pkg`,+   no sudo, no admin rights, no Homebrew dependency.+2. **Never a firewall prompt by default.** Adom Bridge passes+   `ADOM_BIND_HOST=127.0.0.1` to every bridge process, and bridges must bind+   it. A bare `server.listen(PORT)` binds `0.0.0.0` and can trigger the macOS+   "accept incoming network connections" dialog; loopback binds never do.++Bridges are spawned with the interpreter's ABSOLUTE path (never a bare+`node`/`python` PATH lookup), so a shell whose PATH lacks the runtime cannot+break a spawn.++## The `runtimes` verb (read-only)++```bash+adom-bridge runtimes+```++Available over the relay and the direct API. Returns a per-runtime snapshot:++- `state`: `absent | installing | ready | failed`+- `source`: `system | cache`+- `version` and `exe` when ready, plus `pct`/`phase` while a download is in+  flight++This is the machine-readable "what is installed and at which version" for+cloud AIs. The human-facing mirror is the **Runtimes panel** in the GUI's+Bridges sidebar: a state dot, version, and source per runtime, with live+updates from the `runtime-status` event.++## Prewarm on first launch++`runtime_cache::prewarm_all` fires shortly after boot on **every** launch+(standalone and embedded), gated by config `prewarm_runtimes` (default true).+It kicks off both `ensure_runtime` calls fire-and-forget: a system or cached+runtime resolves in milliseconds; only a runtime-less box actually downloads.+Turning `prewarm_runtimes` off means runtimes fetch lazily on the first+bridge spawn instead.++Because prewarm is async, a bridge can need Node or Python before it+finishes. The bridge auto-start path calls a non-blocking `runtime_gate`:++- `Ready` -> spawn now (the common case).+- `Warming` -> the verb returns a non-fatal `runtime_warming` result with+  `stillRunning: true` and `statusVerb: "runtimes"`. Poll and retry; this is+  not a failure.+- `Failed` -> a `runtime_failed` result; a retry re-attempts the install.++```bash+# A bridge verb returned runtime_warming? Poll until ready, then retry:+adom-bridge runtimes+# ... wait for state=ready on the needed runtime ...+adom-bridge browser_open_window '{"url": "https://example.com"}'+```++## npm installs for source-only node bridges (`ensure_node_modules`)++A node bridge's wiki zip rightly ships source-only, so its cache dir has no+`node_modules`. At spawn, `ensure_node_modules` resolves dependencies in+this order:++1. The cache dir already has `node_modules`: use it, nothing to do.+2. A **bundled seed** of the same bridge name has `node_modules` AND the+   seed's `bridge.json` version is `>=` the cache version: reuse the seed's+   modules via `NODE_PATH` for the spawn. Instant, no copy, and the seed's+   native bindings are already built for this platform. The version gate is+   the guardrail: a cache strictly newer than the seed may have different+   deps, so stale bundled modules are never reused.+3. Otherwise run `npm install --include=optional` in the cache dir.++npm runs through the **managed runtime's own npm**+(`node <node_modules/npm/bin/npm-cli.js>`), because a portable Node is not+on PATH so a bare `npm` would fail. Bare npm is used only for a system Node.++## When a node or python bridge fails to spawn: what to check++1. **Runtime state first.**+   ```bash+   adom-bridge runtimes+   ```+   - `state: installing`: the portable download is in flight. Poll and retry+     the bridge verb; do not treat `runtime_warming` as an error.+   - `state: failed`: retry the bridge verb (a retry re-attempts the+     install), or check network reachability to nodejs.org / the+     python-build-standalone release host on GitHub.+   - `state: absent` with `prewarm_runtimes` off: the first bridge spawn+     will fetch lazily; expect a one-time delay.+2. **MODULE_NOT_FOUND from a node bridge** means its dependencies did not+   resolve: verify `ensure_node_modules` ran (cache `node_modules` present,+   or a same-or-newer bundled seed exists), or that the npm install in the+   cache dir succeeded.+3. **A macOS firewall dialog appeared**: the bridge bound `0.0.0.0`+   instead of honoring `ADOM_BIND_HOST`. That is a bridge bug; bridges must+   bind `process.env.ADOM_BIND_HOST || '127.0.0.1'` (Node) or+   `os.environ.get('ADOM_BIND_HOST', '127.0.0.1')` (Python).+4. **A strange or unparseable system runtime**: `detect_system` accepts a+   PATH exe only if it reports a parseable version; anything else falls+   through to the portable download, so a broken shim never gets pinned.++## Bridge author contract (summary)++1. Bind `ADOM_BIND_HOST` (loopback), never `0.0.0.0`.+2. Do not bootstrap your own Node or Python. Adom Bridge provisions the+   interpreter and spawns your entrypoint by absolute path, with no+   elevation. Shipping a source-only node bridge is fine; the app runs npm+   for you at spawn.++Dev internals and the do-not-regress rules: `skills/MANAGED_RUNTIMES.md`.+Launch-order timeline of every background prewarm task: `skills/PREWARM.md`.
skills/adom-bridge-window-capture/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge-window-capture/SKILL.mdadded+193
@@ -0,0 +1,193 @@+---+name: adom-bridge-window-capture+description: >-+  The complete reference for Adom Bridge screenshots on macOS: every capture verb, the exact JSON response shape, 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, and the <stem>.json sidecar). Read this to capture an app window without stealing focus, to grab the screen, to understand the Screen Recording permission requirement, or to build a tool (like shotlog) that renders every screenshot shape Adom Bridge can return. Covers desktop_screenshot_window, desktop_screenshot_screen, kicad_screenshot_all, desktop_find_window, desktop_list_windows, desktop_list_monitors, and the exact on-disk sidecar schema.+---++# Adom Bridge screenshots (macOS): full capability, response shape, and on-disk contract++This is the deep reference for everything Adom Bridge can capture on a Mac and everything it writes to disk. Two audiences:++- **An AI driving a GUI app**: jump to "The verb map". The one thing to internalize: `desktop_screenshot_window {hwnd}` captures ONE window by its window id, even when it is behind other windows, without stealing focus.+- **A tool that renders Adom Bridge 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 by its CGWindowID (`screencapture -l`) | No focus steal; works when occluded | one image (full/safe pair when large) |+| **`desktop_screenshot_screen {}`** | the MAIN display | n/a (whatever is on screen) | one image |+| **`kicad_screenshot_all {}`** | every open KiCad window (main + dialogs) as separate images | No | an ARRAY of sibling-window images |+| **`browser_screenshot`** | a browser PAGE / viewport (pup bridge, CDP; not a window grab) | No | one image (page pixels, not window chrome) |++**Rule of thumb:** for one app window, ALWAYS prefer `desktop_screenshot_window {hwnd}` (resolve the id with `desktop_find_window {titleContains}`). It captures the window's own content even when other windows cover it, and it never steals focus. Use `desktop_screenshot_screen` only when you genuinely want "what is on the user's screen right now."++**macOS mechanics and limits (all verified in `src-tauri/src/screenshot.rs`):**++- The `hwnd` field is the **CGWindowID** from `CGWindowListCopyWindowInfo`. Window enumeration (`desktop_list_windows`, `desktop_find_window`) lists **on-screen** windows only: a MINIMIZED window, or one on another Space, does not enumerate and cannot be captured. Occluded-but-on-screen windows are fine.+- Capture runs through the system `screencapture` tool (`-l <id>` per window; `-x` no shutter sound, `-o` no drop shadow, PNG output). Both window and screen captures are downscaled at capture time to at most **1568 px** on the longest side, so a Retina grab is never shipped raw.+- **App modal dialogs:** a sheet attached to a window renders INSIDE that window's capture. A separate dialog window is its own on-screen top-level window: find it with `desktop_list_windows` / `desktop_find_window` and capture it by its own id. There is no owned-popup `screenshots[]` array on macOS (that was a Windows mechanism); `kicad_screenshot_all` is the one verb that returns a multi-window array.+- **Multi-monitor:** `desktop_screenshot_screen` captures the MAIN display only. There are no `{monitor:N}` or `{perMonitor:true}` options on macOS. Enumerate displays with `desktop_list_monitors` (below).+- **No video recording on macOS.** The native recording verbs return a clean `recording_unsupported` error. Recording a browser TAB is the pup bridge's own CDP feature (`browser_record_start`), unrelated to this skill.++---++## 2. Permission: Screen Recording (required)++macOS gates all window and screen capture behind the **Screen Recording** TCC permission. Grant it once:++**System Settings > Privacy & Security > Screen Recording > enable "Adom Bridge"**, then relaunch the app.++Symptoms of a missing grant:++- Captures come back black or empty, or `screencapture` errors ("Check Screen Recording permission" appears in the error message).+- Window TITLES are missing: without the permission, `kCGWindowName` is not populated, so `desktop_list_windows` falls back to showing the owning APP's name as the title. If every window's title is just the app name, the permission is not granted.++Driving controls in the background (`desktop_find_control`, `desktop_ui_click`, `desktop_ui_set`) additionally needs the **Accessibility** permission (System Settings > Privacy & Security > Accessibility); a missing grant returns `errorCode: ax_not_trusted`.++---++## 3. The response shape++Every screenshot verb returns the same core envelope. Field by field, as produced by the app plus the CLI's auto-pull enrichment:++```jsonc+{+  "success": true,+  "output": "{...}",          // JSON string: { message, data: { image: <base64 PNG>, format, encoding, sizeBytes } }+  "error": "",++  // The image, on the DESKTOP (host) side: $TMPDIR/adom-bridge-shots/+  "fullPath":  ".../adom-bridge-shots/window-<hwnd>-<ts>.png",   // or a .full.png/.safe.png pair+  "safePath":  ".../adom-bridge-shots/window-<hwnd>-<ts>.png",+  "fullWidth": 1568, "fullHeight": 980,     // capture size (already capped at <=1568)+  "safeWidth": 1400, "safeHeight": 875,     // downscaled to <=resizeMax when the capture exceeded it+  "fullBytesHost": 402931, "safeBytesHost": 88104,+  "resizeMax": 1400,++  // The image, PULLED to the CALLER (container) side by the CLI:+  "fullPathHost": "...", "safePathHost": "...",   // host-path aliases+  "localFullPath": "/home/adom/project/screenshots/adom-bridge/window/2026-08-10T01-14-16.937-986710.png",+  "localSafePath": "/home/adom/project/screenshots/adom-bridge/window/2026-08-10T01-14-16.937-986710.png",+  "localFullExists": true, "localSafeExists": true,+  "savedTo": ".../window/2026-08-10T01-14-16.937-986710.png",   // = the safe copy (fallback full)+  "fullBytes": 402931, "safeBytes": 88104,   // bytes on the CALLER side+  "source": "desktop_screenshot_window",+  "narrative": "Captured and auto-pulled ONE PNG to .../window/ ...",+  "_cacheHint": "...",                        // where shots live + janitor recipe++  "drive": "background",                      // this capture never foregrounded the window+  "_related": "..."                           // sibling verbs+}+```++Args for `desktop_screenshot_window`: `hwnd` (required, from `desktop_find_window` / `desktop_list_windows`) and optional `resizeMax` (default 1400, clamped 64..4096): the longest side of the safe-resized copy.++**Clicking what you see:** pass SCREEN coordinates to `desktop_click` / `desktop_double_click` / `desktop_right_click` / `desktop_hover` (`{x, y}` in points, the same space as `desktop_list_windows` rects and `desktop_list_monitors` bounds). The Windows-only image-space contract (`{space:"image", shotId}` and the `coordMap` block) is NOT available on macOS: responses carry no `coordMap`/`shotId`. To click a control without doing coordinate math at all, prefer the Accessibility path: `desktop_find_control {hwnd, name|contains, role?}` returns the control's on-screen rect (`centerX`/`centerY`), and `desktop_ui_click` presses it in the background via AXPress.++---++## 4. Monitors: `desktop_list_monitors`++`desktop_list_monitors` (alias `display`) is the authoritative display map, via CGDisplay:++```jsonc+{+  "monitors": [+    {+      "index": 0, "primary": true, "device": "CGDisplay-1",+      "bounds":        { "x": 0, "y": 0, "width": 1512, "height": 982 },  // POINTS, same space as clicks + window rects+      "workArea":      { ... },            // == bounds on macOS (menu bar / Dock not excluded)+      "logicalBounds": { ... },            // == bounds+      "pixelSize":     { "width": 3024, "height": 1964 },  // native pixels+      "scale": 2.0,                        // Retina backing scale+      "dpi": 144                           // 72 * scale+    }+  ]+}+```++Ordering is stable: primary first, then left to right. On macOS everything the input and window verbs speak is POINTS: `bounds` here, `desktop_list_windows` rects, and `desktop_click {x,y}` all share one coordinate space, so no scale math is ever needed. `pixelSize`/`scale` exist only to tell you the display is Retina.++---++## 5. On-disk storage: where the CLI auto-pulls every shot (the shotlog contract)++The CLI auto-pulls every screenshot to the CALLER's machine and self-janitors the cache. 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 (no ~/project)+```+Never `/tmp`. The CLI 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 capture  (desktop_screenshot_window, kicad_*, browser_*)+<shots_dir>/screen/     # a full-screen grab               (desktop_screenshot_screen)+```++**Filenames: human-readable, sortable:**+```+window/2026-08-10T01-14-16.937-986710.png   # <ISO-stamp>-<windowId>.png (the folder says "window", so no prefix)+screen/2026-08-10T01-05-08.625.png          # a screen grab (no window id)+```+The ISO stamp is `YYYY-MM-DDThh-mm-ss.mmm` with no colons, so it is filename-safe AND lexicographic order == chronological order. Entries of a multi-window array (`kicad_screenshot_all`) keep each window's own name and gain `localPath`/`savedTo` per entry.++**Single file vs full/safe pair:**+- If the capture was already within the safe size cap (`resizeMax`, the common case on macOS since captures are pre-capped at 1568) 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` + `<stem>.safe.png`. 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 (defaults: 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, plus an orphan-sidecar sweep). 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.**++```jsonc+{+  "source": "desktop_screenshot_window",     // the verb that produced it+  "timestamp": "2026-08-10T01:14:16.937Z",   // RFC3339 UTC+  "kind": "window",                          // "window" | "screen"+  "hwnd": 986710,                            // CGWindowID; null for a "screen" grab+  "title": "KiCad 9.0 - myboard.kicad_pcb",  // window title (null for screen)+  "full": { "path": ".../window/<stem>.png", "w": 1568, "h": 980, "bytes": 402931 },+  "safe": { "path": ".../window/<stem>.png", "w": 1568, "h": 980, "bytes": 402931 },+  //  (when the shot was under the cap, full.path == safe.path == the single <stem>.png)+  "coordMap": null,                          // always null on macOS (Windows-only field)+  "windowRect": null                         // always null on macOS+}+```++Array-entry shots (each window of a `kicad_screenshot_all`) get the same sidecar plus `parent`/`popupIndex` slots (null when the response has no top-level parent, which is the `kicad_screenshot_all` case) and their own `rect` when the entry carried one.++**How a renderer groups a capture from disk alone:**+1. List `<shots_dir>/window/` and `<shots_dir>/screen/`, newest-first (filenames sort chronologically).+2. Read each `<stem>.json` for source, kind, window id, title, and dims.+3. Prefer `safe.path` for thumbnails/inline display, `full.path` for a zoom view; if `full.path == safe.path`, there is a single file.++---++## 7. Driving without disturbing the user++Capture is background by default (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 press / set-value by accessible name via the macOS Accessibility API (AXPress / AXValue). Requires the Accessibility permission; action verbs are gated by shell approval. When an element exposes no usable AX action, the fallback is a CGEvent click, which foregrounds and is reported as `pathTaken:"cgevent-fallback"`. Every response carries a `foreground` block whose `observed`/`raised` are MEASURED (frontmost app before/after), never asserted.+- Coordinate clicks (`desktop_click {x, y}` in points) as the fallback when a control is not in the AX tree.+- `desktop_ui_watch` / `desktop_ui_events` / `desktop_ui_selftest` are not yet implemented on macOS and return a clean `not_implemented_macos` error.++---++## Related++- `desktop_find_window {titleContains|className}`: resolve a window id; both are case-insensitive substrings, and `className` matches the owning APP's name on macOS. Topmost match wins.+- `desktop_list_windows`: enumerate all on-screen, titled windows with ids + rects (front-to-back Z-order).+- `desktop_list_monitors`: the display map (points bounds, pixel size, Retina scale, primary).+- `desktop_screenshot_screen`: main-display grab.+- `adom-bridge janitor status`: verify the auto-pull cache is being managed (so a tool never builds its own cleaner).
skills/adom-bridge/._SKILL.mdadded
⋯ 1 unchanged line ⋯
skills/adom-bridge/SKILL.mdadded+2031
@@ -0,0 +1,2031 @@+---+name: adom-bridge+description: Use when the user wants to send files to their Mac, control KiCad or Fusion 360, send desktop notifications, or troubleshoot the Adom Bridge connection. Provides CLI tools for bridging the Docker container to the user's local machine. The CLI binary is still named adom-bridge.+---++# Adom Bridge++Bridge between Claude Code (running in an Adom Docker container) and the user's Mac via WebSocket. The product is **Adom Bridge** (formerly named "Adom Bridge"); the CLI binary name is unchanged: `adom-bridge`. This build is macOS-only (Apple Silicon).++**Install surface:** the canonical install page is `adom/adom-bridge-macos` on the Adom wiki. One-line install on the Mac:++```bash+curl -fsSL https://wiki.adom.inc/api/v1/pages/adom-bridge-macos/files/install.sh | sh+```++or download the dmg from that page. Adom Hydrogen also installs Bridge automatically (Hydrogen-managed installs live under `~/Library/Application Support/adom/bridge/`). `adom-bridge setup_desktop` returns the install command and this container's server config.++**Bridge is a menu-bar agent.** A signed-in launch goes straight to the menu bar: no window, no Dock icon. The window is summoned from the menu-bar item (or by launching the app again) for settings, approvals, and sign-in. First run (signed out) shows the sign-in window. Launch-at-login is on by default via a LaunchAgent (`~/Library/LaunchAgents/inc.adom.bridge.plist`). One Adom sign-in is shared with Adom Hydrogen through a shared macOS keychain item; the session survives uninstall and reinstall, and sign-out deletes it everywhere.++**First-time setup?** If the user hasn't installed Bridge yet, see the Setup section below to walk them through 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 (installed by `sync_skills`)++## 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 Mac (as a menu-bar agent) and connects out 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 `0.0.0.0:8765` (desktop app connects here)+- 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: Confirm the machine++Ask the user: **"Is your machine a Mac on Apple Silicon?"**++- **macOS on Apple Silicon** -- proceed. This build of Adom Bridge is macOS-only.+- **Windows / Linux / Intel Mac** -- point them at the Windows product page (`adom/adom-bridge` on the wiki) or tell them support for their platform is separate from this build.++### 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`** plus a fallback `server_config` JSON. 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 (the one-liner: `curl -fsSL https://wiki.adom.inc/api/v1/pages/adom-bridge-macos/files/install.sh | sh`, or the dmg from the `adom/adom-bridge-macos` wiki page) and opens it,+2. **Signs in with their Adom account** (first run shows the sign-in window; after that, the app lives in the menu bar),++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.) One sign-in is shared with Adom Hydrogen via the keychain, so a user who already runs Hydrogen is already signed in.++---++**Option A: Automatic setup via a local agent (paste fallback)**++If the user has **Claude Code** or **Claude Desktop** on the Mac, they can paste this prompt:++> Install Adom Bridge and connect it to my cloud container. (Adom Hydrogen users get Bridge installed and connected automatically; use this prompt when driving the Mac from a cloud container.) Run `curl -fsSL https://wiki.adom.inc/api/v1/pages/adom-bridge-macos/files/install.sh | sh`, then write this JSON to `~/.adom/config.json`:+> ```json+> {"servers":[<server_config JSON>]}+> ```+> Then open "Adom Bridge" from /Applications.++*(Replace `<server_config JSON>` with the actual value from `setup_desktop`.)*++**Option B: Manual setup**++1. Download the dmg from the `adom/adom-bridge-macos` wiki page and drag **Adom Bridge.app** to /Applications+2. Open **Adom Bridge**; sign in when prompted (or skip to paste JSON)+3. Summon the window from the menu-bar item if it isn't already showing+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 Mac 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: Node.js for browser features++Nothing for the user to do: Bridge manages its own portable Node and Python runtimes (no admin rights, no Homebrew required). If a `browser_*` command ever reports Node missing, call `adom-bridge desktop_install_node '{}'` - it fetches the managed runtime and the bridge picks it up on the next call.++### Common connection issues++| Symptom | Fix |+|---------|-----|+| `ping` returns `"No bridge client connected"` | User hasn't added this container in the Bridge app yet |+| Bridge 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 |+| Bridge app not installed | `curl -fsSL https://wiki.adom.inc/api/v1/pages/adom-bridge-macos/files/install.sh \| sh`, or the dmg from the `adom/adom-bridge-macos` wiki page |+| App seems "not running" but no window | Normal: Bridge is a menu-bar agent with no Dock icon. Look for the menu-bar item; launching the app again summons the window. |++## CLI Tool++```bash+adom-bridge <command> '<json-args>'+```++**Examples:**+```bash+adom-bridge ping+adom-bridge status+adom-bridge browser_open_window '{"sessionId":"dart2","url":"https://example.com"}'+adom-bridge browser_eval '{"sessionId":"dart2","expr":"document.title"}'+adom-bridge browser_screenshot '{"sessionId":"dart2"}'+adom-bridge browser_list_windows+adom-bridge browser_close_window '{"sessionId":"dart2"}'+adom-bridge notify_user '{"title":"Hello","body":"From Docker"}'+adom-bridge shell_execute '{"command":"echo hello"}'+adom-bridge pull_file '{"filePaths":["/Users/john/Downloads/image.png"],"saveTo":"/tmp"}'+```++**Output:** JSON to stdout. Screenshots are auto-pulled to a local cache dir (see "Screenshot cache" below) and the response returns the file path.++**Config location:** The Bridge app stores server config at `~/.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 (installed_bridges_on_target)+```+Returns categorized JSON with every command, its required/optional args, return values, prerequisites, and workflow notes. Run this FIRST when you're unsure what a verb is called.++**Two kinds of verbs (v1.9.103 - don't confuse them):**+- **BUILT-IN Bridge-core verbs** are always available on every Adom Bridge, no install, NOT bridges. This is where screenshots, windows, input, accessibility (AX), files, 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 window ids from `desktop_list_windows`; on macOS the `hwnd` field carries the CGWindowID, same arg name for compatibility).+- **BRIDGE verbs** (`kicad_`/`fusion_`/`browser_`/`blender_`/`nbrowser_`…) need that bridge installed - see `installed_bridges_on_target` 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 (Adom Hydrogen's 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.++HD-style usage:+```bash+# At HD container startup+adom-bridge server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'++# At HD 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 Bridge 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 Bridges: `{count, targets:[{name, hostname, version, os, connectedAt}]}`. Discover names here first.+- `adom-bridge --target <name> <verb>` - route the verb to that Bridge 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 Bridge; returns `{broadcast:true, targets:[...], results:{<name>:<verb-result>}, summary:{total,ok,failed}}`. A per-Bridge timeout/failure is isolated to that Bridge's entry.++```bash+adom-bridge targets+adom-bridge --target azurevm desktop_screenshot_screen+adom-bridge --target all hydrogen_status        # every box at once, results keyed by name+```++**No-target behavior:** with exactly ONE Bridge 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 Bridge's name with the `--client-name <name>` launch flag (HD passes this when spawning the embedded Bridge), the Settings field, or `config.json` `client_name`. Full design + the relay-routing internals: [`skills/MULTI_AD_ADDRESSING.md`](MULTI_AD_ADDRESSING.md).++#### Direct HTTP API for sibling apps (v1.8.25+)++If you're authoring a sibling Tauri app on the same machine (Adom 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 HD on 47080+ and avoid the 8000-range collision risk). The endpoint runs inside the GUI process (loopback-only bind), uses the same dispatcher the WS path uses, and returns the same JSON shape as `adom-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` - 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 (v1.8.31+):** the direct API's command-timeout is no longer a hardcoded 120s - it mirrors the CLI dispatcher's per-verb table (`walk_cloud_tree`/`search_cloud_files`=620s; heavy exports=320s; `bridge_install`/`fusion_start`=300s; default=120s). 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`, `hydrogen_status`, `hydrogen_build_status`, `desktop_list_windows`, `desktop_screenshot_*`, `kicad_*`, `fusion_*`, `browser_screenshot`, `notify_user`, ...) plus async-dispatching verbs that return a job id in <500 ms (`bridge_install`, `hydrogen_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 HD 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 - Bridge managed by Adom Hydrogen (v1.8.42+)++When Adom Hydrogen (HD) manages Bridge, Bridge enters "embedded mode" at boot. HD owns the visible surface (Bridge's menu-bar item is suppressed), HD owns the Adom Cloud login (the shared keychain session), HD owns auto-updates, and HD spawns Bridge hidden (the window opens on demand via `desktop_window_show`). Standalone Bridge users see zero change - none of the signals fire.++**Detection** - three-signal cascade (any one triggers embedded mode):++1. `--embedded` CLI flag (what HD passes on every spawn)+2. `ADOM_EMBEDDED=1` env var (backup channel)+3. `~/.adom/embedded.json` marker file (survives Bridge restarts)++**CLI flags HD passes when spawning Bridge**:++```bash+"Adom Bridge.app/Contents/MacOS/adom-bridge" --embedded --start-hidden \+  --relay-url ws://127.0.0.1:8765 --relay-name adom-hydrogen \+  --session-token <hd's-stored-token>+```++- `--embedded` → enter embedded mode (also writes the marker)+- `--start-hidden` → boot with main window invisible (the normal daemon state; HD owns the surface and opens the window via `desktop_window_show`). The machine owner can still flip "Always show the window at launch" in Settings (config `always_show_window_on_launch`, default OFF) - a personal-dev-box preference, not the product default.+- `--shell-approval <prompt|1h|24h|permanent>` → HD's passthrough of its user's shell-approval choice (v1.8.116+, session-only)+- `--relay-url <url>` → upsert this relay via existing `server_add` dedup; default name `adom-hydrogen`+- `--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 Bridge is standalone or HD-managed. **v1.8.73+:** `enteredVia` reports `"launch-flag"` (HD spawned Bridge as a child via `--embedded`) or `"runtime-adopt"` (Bridge was running standalone; HD adopted it at runtime) - see "Runtime adopt + release" below.++**New verbs HD uses to drive Bridge** (all also usable standalone):++| Verb | Purpose |+|---|---|+| `desktop_window_show` | Bring Bridge's main window to foreground |+| `desktop_window_hide` | Hide Bridge'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 Bridge 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 Bridge** (regardless of `entered_via`) - use `desktop_shutdown` for explicit exit |+| `desktop_logout` | Clear Bridge's `~/.adom/session.json` (HD's sign-out propagation) |++#### Runtime adopt + release (v1.8.73+) - the LaunchFlag-vs-RuntimeAdopt distinction++HD has two ways to put Bridge into embedded mode:++1. **Spawn Bridge with `--embedded`** (LaunchFlag) - HD launches a fresh Bridge process as its child. Bridge enters embedded with `entered_via=launch-flag`. When HD subsequently dies ungracefully (without calling `/embedded/release` or `desktop_shutdown` first), Bridge's 60s `embedded_monitor` tick notices HD's control port is dead and **exits Bridge** (the "no hanging chad" rule - no tray-less orphan).+2. **Adopt at runtime via `POST /embedded/enter`** (RuntimeAdopt) - Bridge was already running standalone; HD calls the direct-API endpoint to flip Bridge into embedded mode without a respawn. Bridge enters embedded with `entered_via=runtime-adopt`, preserving its open state, current PID, all in-flight connections. When HD subsequently dies ungracefully, Bridge's monitor **reverts to standalone** - menu-bar item restored, footer pill hidden, marker deleted. The Bridge process stays alive.++The rule is simple: Bridge survives its parent unless the spawner explicitly asked otherwise. On ungraceful HD death Bridge reverts to standalone and keeps serving the relay (it is the machine's recovery channel).++**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. Bridge never exits via this call.+curl -s -X POST http://127.0.0.1:47200/embedded/release+```++**Probe target (v1.8.75+):** the `embedded_monitor` checks HD's *control port* (default `47084`, discovered from `~/.config/adom-hydrogen/ports.json` key `control`). Same discovery `hydrogen_api` uses.++**Live frontend update (v1.8.76+):** the "Embedded · HD" footer pill responds to Tauri `mode-changed` events emitted on every transition. So if Bridge's window is open when HD adopts it (or HD releases it), the pill flips immediately - no page reload, no manual refresh.++**Stale-marker safety (v1.8.74+):** a stale `entered_via=launch-flag` marker with no live HD at boot is cleaned up, so the next launch starts 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 Bridge boot:++1. `--shell-approval <prompt|1h|24h|permanent>` launch flag - HD's passthrough of its user's choice when spawning Bridge embedded. **Session-only**: never persisted by Bridge.+2. The durable Settings checkbox ("Auto-approve shell commands permanently", config field `shell_auto_approve_permanent`, default OFF). Also set/cleared via `shell_auto_approve '{"permanent": true|false}'`.+3. A restored timed grant from a previous session (+1h/+24h buttons, `duration_secs` calls).+4. Otherwise: prompt per command.++In embedded mode **HD owns the preference** (HD is the parent, Bridge the child): Bridge's Settings checkbox goes read-only with "Managed by Adom Hydrogen", and the footer shows "**Permanent** *(managed by Adom Hydrogen)*" when armed. HD 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 HD'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 Bridge (or HD 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_*`, `browser_*`, 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: HD's bridges also wanted 8772/8773/8851; we used to collide silently. Dynamic ports = clean coexistence.++### Managing bridges (the AI fully controls bridges on its own)++Bridges are Bridge'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 Bridge 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 Bridge GUI immediately** (no verb call needed; it's gray/idle until first use, then green). On `success:false` **nothing is registered and no capsule appears** - read `error` (a `read zip body`/`builder error` means the manifest's zip URL couldn't be fetched). Don't treat a parsed manifest as a completed install; confirm with `bridge_list`. The manifest `url` may be relative (resolved against the manifest's dir, v1.8.169+). |+| `bridge_uninstall` | Remove a bridge (`{"name":"…"}`). Bundled ones revert to the bundled copy; third-party ones are fully removed. |+| `bridge_pause` / `bridge_resume` | Flip a routing flag so a bridge's verbs fail-fast with `bridge_paused` (or resume). Does NOT kill the process. |++**Naming convention for published bridges (3rd-party authors, read this):** when you publish a bridge to the Adom wiki, its **page title / catalog descriptive name MUST start with `Adom Bridge - `** (spaced hyphen; the historical catalog prefix that existing bridge pages use) - e.g. `Adom Bridge - Altium Bridge`, matching the bundled bridges (`Adom Bridge - Puppeteer Bridge`, `Adom Bridge - KiCad Bridge`). This keeps every 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-bridges` 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 Bridge 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` - Bridge 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 Bridge 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, use `bridge_kill '{"name":"..."}'`.++**Auto-update cadence:** a running Bridge 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 Bridge reuses across every spawn and across Bridge 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 Bridge routes to bridge B" split. Bridge 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: **Bridge auto-respawns this bridge on death**, on its stable port (a supervisor runs each ~15 s). So "kill the bridge to load new code" → Bridge 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 (Bridge 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 Bridge 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 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.** Bridge now tracks every bridge PID it spawns (persisted across Bridge 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 Bridge GUI sidebar. **Bridge is a pure renderer of bridge health** - a live bridge decides its own LED color and hover text; Bridge just paints what the bridge reports. This replaced the old scheme where Bridge 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 Bridge 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 Bridge enforces:**+- Endpoint returns 2xx **with** `led`/`summary`/`tooltip` → rendered **verbatim**, no parsing or logic.+- Endpoint returns 2xx **without** them → Bridge defaults to **green** (you're reachable) + a generic tooltip. Existing bridges work unchanged - this is opt-in.+- Endpoint **unreachable** → Bridge owns this ONE state: a **gray/offline** dot + Bridge's own tooltip ("Not running" / "Bridge not reachable"). A dead bridge can't self-report, so Bridge never lets a bridge claim "offline" - it detects it.++**Cadence:** Bridge 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** path on the Mac (under a safe root) to land the files exactly where you want (v1.8.148+). Prefer this when you know the target dir.+  - `destinationFolder`: **relative subfolder only** (e.g. `"kicad/symbols"`, `"fusion"`). The Bridge app controls the base directory. **Absolute paths are rejected.** With neither `dest` nor `destinationFolder`, files land in the Mac'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 MAC, 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 Mac to the container.+  - `filePaths`: array of absolute paths on the Mac+  - `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 macOS notification (UNUserNotificationCenter, Adom branding). Rich types: action buttons + a round-trip via `notify_response`; `wait:true` blocks until the user answers.+  - `title`, `body`, `level` (info/success/warning/error/emergency), `actions`/`buttons` (label buttons), `id` (correlation id for the round-trip). Some rich fields from the Windows product (toast progress bars, inline inputs) do not exist on macOS; the verb ignores what the platform can't render.+  - Full guide + copy-paste examples per type: the **adom-bridge-notify** skill ([`skills/NOTIFY.md`](NOTIFY.md)).+- `notify_response` -- Poll which notification button the user clicked, by correlation `id`. `{pending:true}` until they click; `{pending:false, action}` once they do (consumed on read). `notify_events` drains the durable click stream.++### KiCad (`kicad_*`) - bridge-owned; light pointer++The KiCad bridge is **cloud-owned** and ships its OWN full skill (wiki pkg `adom/adom-bridge-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/`.++Most-used verbs to get moving:++```bash+adom-bridge kicad_open_board '{"path": "/Users/<you>/projects/board/board.kicad_pcb"}'+adom-bridge kicad_screenshot_all '{}'          # every KiCad window incl. error dialogs, as an array+adom-bridge kicad_run_drc '{}'+adom-bridge kicad_window_info '{}'+adom-bridge kicad_open_symbol_editor '{"library": "Device"}'+```++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/adom-bridge-fusion-bridge`). Run `adom-bridge sync_skills` and the full guide (cloud files, import/export, add-in troubleshooting, APS search) lands in `~/.claude/skills/`.++Most-used verbs:++```bash+adom-bridge fusion_start '{}'                       # launches Fusion; slow on cold start (minutes)+adom-bridge fusion_get_app_state '{}'               # the status/poll verb+adom-bridge fusion_addin_status '{}'+adom-bridge fusion_dismiss_blocking_dialogs '{}'+adom-bridge fusion_send_key '{"key": "Escape"}'+```++Fusion launches take 2-4 minutes cold; respect `timeoutSeconds` + `statusVerb` on responses instead of re-issuing. Full details: the bridge's own skill.++### Launching apps (generic)++Two generic CLI commands exist for launching any executable or app safely - they verify the target exists BEFORE handing it to the OS, so you never trigger a "not found" dialog:++- `adom-bridge find_exe '{"name":"..."}'` -- Resolve a binary by absolute path, glob (newest match wins), or bare name (searches PATH). Returns `{path, source}`. Does NOT launch.+- `adom-bridge launch '{"path":"...", "args":[...], "cwd":"...", "detached":true}'` -- 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 a raw shell `open`.**++For Fusion specifically, use `fusion_start` - it wraps `launch` plus the full startup-picker / add-in-readiness dance.+- `fusion_import_step` -- Import a STEP/STL/IGES file into Fusion 360+- `fusion_open_lbr` -- Open an EAGLE .lbr library file+- `fusion_open_electronics` -- Check if the Electronics workspace is active+- `fusion_electron_run` -- Execute any EAGLE command via Electron.run. Returns rich state: `activeWorkspace`, `activeDocument`, `commandType`, `_hint`, and `fusionOperations` (diff of Fusion's internal operation log showing what actually fired). **Avoid blocking commands** - see list below. **Full EAGLE command reference:** [`skills/eagle-commands.md`](./eagle-commands.md) - popular/safe commands split from blocking/modal ones, with layer reference and chaining syntax.+- `fusion_execute_text_command` -- Low-level app.executeTextCommand() access. Returns the command result plus workspace context.+- `fusion_board_info` -- Get structured board data from the open PCB layout. Returns: component placements (name, package, x, y, rotation), net names, copper traces, layer setup, board thickness, DRC violations. Much richer than a screenshot - gives exact coordinates and connectivity. Requires a .brd board open in PCB Editor.++#### `fusion_electron_run` - EAGLE Command Execution++Executes EAGLE commands inside Fusion 360's Electronics workspace. Works in **Schematic Editor**, **PCB Editor (Board Layout)**, and **Electronics Library** contexts.++**How it works:** Sends the command string via Fusion's `Electron.run` text command. EAGLE's `Electron.run` is fire-and-forget - it never returns output or throws on invalid commands. To compensate, the handler snapshots Fusion's internal operation log (`Diagnostics.RecentOperations`) before and after execution, returning a diff showing what actually fired.++**Usage:**+```bash+# Basic command+adom-bridge fusion_electron_run '{"command": "WINDOW FIT"}'++# Multiple commands in sequence (use semicolons)+adom-bridge fusion_electron_run '{"command": "DISPLAY NONE; DISPLAY 1 16 17 18 20 21"}'++# Navigate in library editor+adom-bridge fusion_electron_run '{"command": "EDIT SOIC8.pac"}'+adom-bridge fusion_electron_run '{"command": "EDIT RESISTOR.sym"}'+adom-bridge fusion_electron_run '{"command": "EDIT MYDEVICE.dev"}'+```++**Response fields:**+- `activeWorkspace` - Current workspace (Schematic Editor, PCB Editor, Electronics Library)+- `activeDocument` - Name of the open document+- `commandType` - Detected type: `view_control`, `layer_control`, `edit`, `design_rule`, etc.+- `editorType` - For EDIT commands: `package`, `symbol`, `device`+- `fusionOperations` - Array of Fusion operations that fired (diff of internal log)+- `hint` - Human-readable description of what happened+- `rawResult` - Raw return from Electron.run (usually empty)++**EAGLE Command Reference - Safe for automation:**++| Command | Context | Description |+|---------|---------|-------------|+| **View / Navigation** | | |+| `WINDOW FIT` | Any | Zoom to fit all content |+| `WINDOW (x1 y1 x2 y2)` | Any | Zoom to specific area (coordinates in current units) |+| `DISPLAY ALL` | Any | Show all layers |+| `DISPLAY NONE` | Any | Hide all layers |+| `DISPLAY 1 16 17 18 20 21` | Board | Show specific layers by number |+| **Grid** | | |+| `GRID MM 0.1` | Any | Set grid to 0.1mm |+| `GRID MIL 25` | Any | Set grid to 25mil |+| `GRID INCH 0.05` | Any | Set grid to 0.05 inch |+| **Board Layout** | | |+| `RATSNEST` | Board | Recalculate airwires (unrouted connections) |+| `RIPUP` | Board | Remove all routed traces |+| `RIPUP *` | Board | Remove all traces (same as RIPUP with no selection) |+| `ROUTE` | Board | Start auto-router |+| `DRC` | Board | Run design rule check |+| `BOARD` | Schematic | Switch to paired board layout |+| `SCHEMATIC` | Board | Switch to paired schematic |+| **Schematic** | | |+| `VALUE value` | Schematic | Set component value |+| `NAME name` | Any | Rename selected element |+| `SMASH` | Any | Detach name/value labels from components |+| **Library Editor** | | |+| `EDIT name.pac` | Library | Open a package (footprint) for editing |+| `EDIT name.sym` | Library | Open a symbol for editing |+| `EDIT name.dev` | Library | Open a deviceset for editing |+| `EXPORT SCRIPT 'path.scr'` | Library | Export entire library as EAGLE script |+| **Scripting / Settings** | | |+| `SET CONFIRM YES` | Any | Suppress confirmation dialogs |+| `SET CONFIRM OFF` | Any | Re-enable confirmation dialogs |+| `SCRIPT 'path.scr'` | Any | Run batch commands from a .scr script file |++**EAGLE Layer Numbers (commonly used):**++| Layer | Name | What it shows |+|-------|------|--------------|+| 1 | Top | Top copper |+| 16 | Bottom | Bottom copper |+| 17 | Pads | Through-hole pads |+| 18 | Vias | Via holes |+| 19 | Unrouted | Airwires (ratsnest) |+| 20 | Dimension | Board outline (required for 3D) |+| 21 | tPlace | Top silkscreen |+| 22 | bPlace | Bottom silkscreen |+| 25 | tNames | Top component names |+| 27 | tValues | Top component values |+| 29 | tStop | Top solder mask |+| 31 | tCream | Top stencil/paste |+| 51 | tDocu | Top documentation |++**Blocking commands - AVOID from automation:**++| Command | Why it blocks |+|---------|---------------|+| `WRITE` | Opens Save As dialog - use `fusion_save_lbr` or `fusion_close_document` instead |+| `ADD` | Opens component picker dialog |+| `SHOW name` | Opens interactive highlight mode |+| `SET` (no params) | Opens settings dialog |+| `GRID` (no params) | Opens grid settings dialog |+| `EDIT new.sym` | Opens "Create new?" confirmation if symbol doesn't exist |+| `CHANGE` | Opens interactive change mode |+| `MOVE` | Opens interactive move mode |++**Tips:**+- Always run `WINDOW FIT` after opening a file or switching views+- Use `DISPLAY NONE` then `DISPLAY <layers>` to show only specific layers+- Combine commands with `;` - e.g., `SET CONFIRM YES; RIPUP *; RATSNEST`+- For board screenshots: `DISPLAY NONE; DISPLAY 1 16 17 18 20 21; WINDOW FIT`+- Use `fusion_board_info` instead of EAGLE commands when you need structured data+- `fusion_export_lbr` -- Export the open Electronics library as an EAGLE .scr script (note: does NOT include 3D package references - those are cloud-linked only)+- `fusion_save_lbr` -- Save the open Electronics library as a .flbr file++#### EAGLE Libraries with 3D Packages++Fusion 360's .lbr format supports `package3d` elements that link footprints to 3D models. Key facts:++- **3D models are cloud-hosted** - each `package3d` has a `wip_urn` (e.g., `urn:adsk.wipprod:fs.file:vf.xxxxx`) pointing to a Fusion cloud document. You cannot embed STEP files directly in .lbr XML.+- **Creating 3D packages requires the Fusion UI** - use `Package3DCreateCmd` in Electronics Library Editor, which opens a new Design workspace where you model/import the 3D shape, then save to link it.+- **`EXPORT SCRIPT` strips 3D references** - the .scr export only contains 2D data (symbols, footprints, devicesets). To preserve 3D links, keep the .lbr XML format.+- **Fusion's built-in examples** have 3D packages - 34 of 37 libraries in the EAGLE examples directory (e.g., `Connector_USB.lbr`, `Resistor.lbr`, `Capacitor.lbr`) include `package3d` references.+- **Example libraries location** (macOS): under the Fusion install's `.../Applications/Electron/LibEagle/examples/libraries/examples/` (find the install with `find_exe` or ask the bridge; the webdeploy hash changes per build)++To open a built-in example library for reference:+```bash+# Find the examples directory first+adom-bridge fusion_execute_text_command '{"command": "Python.RunScript /tmp/find_eagle_libs.py"}'+# Then open one+adom-bridge fusion_open_lbr '{"filePath": "<path>/Connector_USB.lbr"}'+```+- `fusion_open_schematic` -- Open a .sch schematic in Fusion's Schematic Editor. Args: `filePath`.+- `fusion_open_board` -- Open a .brd board layout in Fusion's Board Layout editor. Args: `filePath`.+- `fusion_show_3d_board` -- Switch to 3D PCB board view (must have a .brd open). Board MUST have an outline on layer 20 (Dimension) or 3D generation fails. Auto-zooms to fit after switching.+- `fusion_show_2d_board` -- Switch back to 2D board layout from 3D PCB view. EAGLE commands via `fusion_electron_run` only work in 2D.+- `fusion_close_document` -- Close a document without save dialog. Args: `name` (optional, defaults to active doc), `save` (optional, default false). Essential for automation - avoids modal save dialog that blocks Fusion.+- `fusion_document_info` -- List ALL open documents/tabs with name, type, and active status, plus detailed cloud info for the active document. Returns `openDocuments` array (every tab) and active doc details (cloud project, folder, file ID, version, save status). Lightweight - uses only in-memory data, no cloud API calls. Use this instead of `fusion_walk_cloud_tree` when you just need to know what's open.+- `fusion_activate_document` -- Switch to a specific open document tab. Args: `name` (substring match, case-insensitive), `documentType` ("Electronics", "PCB", "FusionDesign", "Drawing"). Essential for automation - switch between schematic, board, and library tabs without user interaction. If no match found, returns the list of open documents so you can refine.+- `fusion_close` -- Close Fusion 360. **Always call this when done with Fusion 360 commands to clean up.**+- `fusion_dismiss_recovery` -- Dismiss recovery document dialogs (both "Recovered Documents" list and "Open recovery document instead?" prompts). Also relocates recovery files to `~/.adom/recovery/fusion/` for safekeeping.+- `fusion_relocate_recovery` -- Proactively move Fusion crash recovery files to `~/.adom/recovery/fusion/<timestamp>/` without dismissing any dialogs. Call this **before** launching Fusion to prevent recovery dialogs from appearing. Files are preserved (not deleted) so the user can manually restore them if needed.+- `fusion_close_all_documents` -- Close all open documents. Args: `saveChanges` (default: false). Use before force-killing Fusion to prevent recovery files.++#### Fusion 360 UI Interaction++Commands for interacting with Fusion 360's UI - detecting dialogs, taking screenshots, clicking, and sending keyboard input. Essential for handling blocking dialogs and automating CEF-based UI elements.++- `fusion_window_info` -- Returns the Fusion main window HWND, title, rect, and a list of all Qt dialog windows (recovery dialogs, wizards, file pickers). Essential for detecting blocking dialogs before/after operations.+  - Example: `adom-bridge fusion_window_info`+  - Returns: `{hwnd, title, rect, dialogs: [{hwnd, title, className, rect}]}`++- `fusion_screenshot_fusion` -- Captures the Fusion main window or a specific dialog by window id. Background capture (works without bringing to foreground). Saves WebP to `/tmp/adom-bridge-screenshots/` (falls back to PNG if WebP unavailable). Downscaled to ≤1568px - use relative coords (0.0-1.0) for clicking, they're scale-independent. DPI-aware.+  - Args: `hwnd` (optional - dialog HWND to screenshot instead of main window)+  - Example: `adom-bridge fusion_screenshot_fusion` (main window)+  - Example: `adom-bridge fusion_screenshot_fusion '{"hwnd": 12345}'` (specific dialog)++- `fusion_screenshot_all` -- Screenshots the main Fusion window and lists all dialog windows with their HWNDs. Use `fusion_screenshot_fusion {"hwnd": ...}` to capture each dialog.+  - Example: `adom-bridge fusion_screenshot_all`++- `fusion_click_fusion` -- Click at coordinates within the Fusion window or a specific dialog. x/y are relative (0.0-1.0) by default. Set `"relative": false` for pixel offsets. Uses synthetic OS input for CEF dialog compatibility. **Prefer `fusion_send_key` for dialogs** - enter/escape/tab covers most cases.+  - Args: `x` (required), `y` (required), `relative` (optional, default true), `hwnd` (optional - target a specific dialog HWND instead of main window)+  - Example: `adom-bridge fusion_click_fusion '{"x": 0.5, "y": 0.7}'` (main window)+  - Example: `adom-bridge fusion_click_fusion '{"hwnd": 12345, "x": 0.75, "y": 0.85}'` (dialog button)++- `fusion_send_key` -- Send keyboard input to Fusion or a specific dialog via synthetic OS input. **Preferred way to dismiss dialogs**: enter to confirm, escape to cancel, tab to cycle buttons. Use click only if tab order doesn't reach the right button.+  - Args: `key` (required), `hwnd` (optional - target a specific dialog HWND)+  - Example: `adom-bridge fusion_send_key '{"key": "escape"}'` (dismiss CEF dialog)+  - Example: `adom-bridge fusion_send_key '{"key": "enter", "hwnd": 12345}'` (confirm dialog)++#### Fusion 360 Workflow Guide++##### Opening Electronics Projects+1. Open the `.fprj` file: `adom-bridge fusion_open_cloud_file '{"projectName":"Main","fileName":"DRV8411A","fileExtension":"fprj","folderPath":"Molecules/XRP/DRV8411A"}'`+2. Do NOT try to open `.fbrd` or `.fsch` directly - they fail or show a "Select Electronics Design File" dialog+3. Enter the board editor: `adom-bridge fusion_show_2d_board`+4. Enter the schematic editor: After step 3, use `adom-bridge fusion_electron_run '{"command":"EDIT .sch"}'`+5. Switch back to board: `adom-bridge fusion_electron_run '{"command":"EDIT .brd"}'`++##### Auto-Screenshot on Open Commands++**Open commands automatically screenshot Fusion and return the images in the response.** The following commands include `postOpenScreenshot` in their `data` field:+- `fusion_open_cloud_file`, `fusion_open_schematic`, `fusion_open_board`, `fusion_show_3d_board`, `fusion_show_2d_board`++The response `data.postOpenScreenshot` contains:+- `screenshots[]` - array of `{type, savedTo, sizeKB, title?, hwnd?}`. Type is `"main_window"` or `"dialog"`.+- `message` - human-readable instruction to READ each screenshot and check for blocking dialogs+- `dialogBlocking` - `true` if a modal dialog was detected++**After receiving the response, you MUST:**+1. **READ each screenshot file** using the Read tool to visually inspect what Fusion shows+2. Look for blocking dialogs in the screenshots:+   - "What to design?" wizard → dismiss with `fusion_send_key {"key": "escape"}`+   - "PCB out of date" banner → click Update or X+   - "Recovered Documents" → `fusion_dismiss_recovery`+   - "Save changes?" → `fusion_send_key {"key": "escape"}`+   - Any other modal → identify and dismiss+3. **Screenshot again** after dismissing to confirm it's clear++Screenshots are saved as WebP (lossless, ~20-40KB each, downscaled to ≤1568px) for token efficiency. Both the main Fusion window AND all Qt dialog windows are captured separately.++**Why this matters**: Fusion shows Qt dialogs and CEF overlays that are invisible to the API. The `success: true` response from an open command does NOT mean the UI is ready - a dialog may be blocking all further operations. The API cannot detect these. Only a screenshot can.++##### Detecting and Handling Blocking Dialogs+- The auto-screenshot captures dialog windows separately (look for `type: "dialog"` entries in `postOpenScreenshot.screenshots[]`)+- Common blocking dialogs: "What do you want to design?", "Recovered Documents", "Open recovery document instead?", "Select Electronics Design File"+- Dismiss recovery dialogs: `adom-bridge fusion_dismiss_recovery`+- Dismiss CEF dialogs (inside main window): `adom-bridge fusion_send_key '{"key": "escape"}'` or `fusion_click_fusion`+- Dismiss "What do you want to design?" wizard: `adom-bridge fusion_send_key '{"key": "escape"}'`+- For manual screenshot of specific dialogs: `adom-bridge desktop_screenshot_window '{"hwnd": <DIALOG_HWND>}'`++##### Preventing Recovery Documents+- Recovery files are at: `~/Library/Application Support/Autodesk/Autodesk Fusion 360/<USER_ID>/CrashRecovery/`+- Before force-killing Fusion, close all documents: `adom-bridge fusion_close_all_documents '{"saveChanges": false}'`+- **Best practice**: Call `adom-bridge fusion_relocate_recovery` before launching Fusion. This moves recovery files to `~/.adom/recovery/fusion/<timestamp>/` - preserving them for the user while preventing modal dialogs.+- The `fusion_start` command also auto-relocates recovery files before starting Fusion.+- The `fusion_dismiss_recovery` command handles both "Recovered Documents" list and "Open recovery document instead?" prompts, and also relocates files.++##### EAGLE Export Limitations+- **Supported**: `EXPORT IMAGE`, `EXPORT NETLIST`, `EXPORT PARTLIST`+- **NOT supported** (fail silently): `EXPORT DXF`, `EXPORT SVG`, `EXPORT DRILL`+- Always verify export output: the updated `fusion_electron_run` now checks if the output file was created+- Use `fusion_export_bom` and `fusion_export_cpl` for manufacturing data (these use the add-in's XML parser, not EAGLE export)+- Use `fusion_export_gerbers` for Gerber files - produces a ZIP with all gerber layers (GTL, GBL, GTS, GBS, GTP, GBP, GTO, GBO, GKO, XLN). Auto-detects 2-layer vs 4-layer boards.++##### 3D Model Exports+- From the 3D view (activate the .f3d document): `fusion_export_step`, `fusion_export_stl`, `fusion_export_3mf`, `fusion_export_f3d`, `fusion_export_usdz`, `fusion_export_iges`, `fusion_export_sat`+- Switch to 3D view: `fusion_show_3d_board` or activate the .f3d document+- **STEP** - Industry-standard CAD interchange (SolidWorks, CATIA, Creo). Highest geometric fidelity.+- **IGES** - Legacy CAD interchange. Use STEP for modern workflows.+- **SAT** - ACIS solid model format. Used by SolidWorks, SpaceClaim.+- **STL** - Mesh format for 3D printing and visualization. Options: refinement "low"/"medium"/"high".+- **3MF** - Modern 3D printing with color/material support and multi-body.+- **F3D** - Native Fusion 360 archive. Preserves parametric features, sketches, timeline, component refs. Best for archival.+- **USDZ** - **Best for digital twins and GLB conversion.** Preserves full component hierarchy (Board, copper layers, soldermask, Packages), PBR materials, and named nodes. Each component becomes a toggleable node in GLB viewers. Also viewable directly on iOS/macOS (Apple Quick Look / AR).+  - Digital twin pipeline: `fusion_export_usdz` → pull_file → `blender --background --python-expr "import bpy; bpy.ops.wm.usd_import(filepath='board.usdz'); bpy.ops.export_scene.gltf(filepath='board.glb')"`+- **FBX** - Not available in current Fusion builds via the API. The command exists but fails clearly. Use `fusion_export_usdz` instead.+- **DXF/DWG/OBJ/SKP** - Not available via the Fusion API (dialog-only). Commands exist but return clear errors with alternatives.++##### 3D Viewport Captures+- `fusion_take_screenshot` - Capture the Fusion viewport at any resolution without opening a dialog. Uses Fusion's render API (`saveAsImageFile`).+  - Args: `outputPath` (required), `width` (default 1920), `height` (default 1080), `orientation` (optional)+  - Orientations: `home`, `front`, `back`, `top`, `bottom`, `left`, `right`+  - Example: Capture all 6 standard views for use as product images/icons:+    ```bash+    for orient in home front back top bottom left right; do+      adom-bridge fusion_take_screenshot "{\"outputPath\": \"/tmp/3d-${orient}.png\", \"width\": 1920, \"height\": 1080, \"orientation\": \"${orient}\"}"+    done+    ```++##### Electronics Source File Export (`fusion_export_source`)+- `fusion_export_source` - Export the active electronics document as `.fsch`, `.fbrd`, or `.flbr` source file.+  - Args: `outputPath` (required - full path with extension)+  - The extension determines the format: `.fsch` (schematic), `.fbrd` (board), `.flbr` (library)+  - Validates extension, creates output directory, verifies file was created, returns file size++- **Full workflow to export both board and schematic from a cloud project:**+  ```bash+  # Step 1: Open the .fprj (NOT .fbrd/.fsch directly - those fail)+  adom-bridge fusion_open_cloud_file '{"projectName":"Main", "fileName":"MyDesign", "fileExtension":"fprj", "folderPath":"Molecules/MyDesign"}'++  # Step 2: Screenshot to check for blocking dialogs (always do this after open)+  adom-bridge fusion_screenshot_fusion++  # Step 3: Enter board view (MUST be Board Layout workspace, not 3D)+  adom-bridge fusion_show_2d_board++  # Step 4: Export .fbrd (board first - it's already in board view)+  adom-bridge fusion_export_source '{"outputPath": "/tmp/exports/MyDesign.fbrd"}'++  # Step 5: Switch to schematic (EDIT .s1 = first schematic sheet)+  adom-bridge fusion_electron_run '{"command": "EDIT .s1"}'++  # Step 6: Export .fsch+  adom-bridge fusion_export_source '{"outputPath": "/tmp/exports/MyDesign.fsch"}'++  # Step 7: Pull files to Docker+  adom-bridge pull_file '{"filePaths":["/tmp/exports/MyDesign.fbrd","/tmp/exports/MyDesign.fsch"], "saveTo":"/tmp/exports"}'+  ```++- **Critical gotchas:**+  - You MUST open the `.fprj` first - opening `.fbrd`/`.fsch` directly fails or triggers blocking dialogs+  - You MUST call `fusion_show_2d_board` before exporting `.fbrd` (the Electronics Design overview won't work)+  - Always export `.fbrd` FIRST (from board view), then switch to schematic for `.fsch`+  - If export fails with "file not found", the wrong workspace is active - screenshot to verify+  - After `fusion_open_cloud_file`, always screenshot to check for "Select Electronics Design File" dialog+  - `WRITE` command is blocked - it opens a blocking save dialog. Use `fusion_export_source` instead++- Note: `WRITE` is blocked - it opens a "Version Description" dialog on cloud docs even with a path argument (tested 2026-04-10). Use `fusion_save_to_cloud` to save to cloud, `fusion_export_source` for Fusion-format, or `fusion_export_eagle_source` for plain EAGLE format.++##### Plain EAGLE Source Export (`fusion_export_eagle_source`)+- `fusion_export_eagle_source` - Export the active electronics document as plain EAGLE XML `.sch` or `.brd`.+  - Args: `outputPath` (required - full path ending in `.sch` or `.brd`)+  - Internally: exports `.fsch`/`.fbrd` via `Document.CopyToDesktop`, then extracts the EAGLE XML from the ZIP container, cleans up the temp file.+  - The output is valid EAGLE XML (`<?xml><eagle version="9.7.0">...`) parseable by standalone EAGLE, KiCad import, or any XML tool.+  - Same workflow/prerequisites as `fusion_export_source` - just use `.sch`/`.brd` extensions instead of `.fsch`/`.fbrd`.++  ```bash+  # Board (.brd) - must be in PCB Editor / Board Layout+  adom-bridge fusion_show_2d_board+  adom-bridge fusion_export_eagle_source '{"outputPath": "/tmp/exports/MyDesign.brd"}'++  # Schematic (.sch) - must be in Schematic Editor+  adom-bridge fusion_electron_run '{"command": "EDIT .s1"}'+  adom-bridge fusion_export_eagle_source '{"outputPath": "/tmp/exports/MyDesign.sch"}'+  ```++##### Dialog Dismissal (`fusion_close_window`)+- `fusion_close_window` - Close a specific Fusion dialog by sending WM_CLOSE (equivalent to clicking X).+  - Args: `hwnd` (required - from `fusion_window_info` or `fusion_dismiss_blocking_dialogs` remaining[])+  - Works on dialogs that Escape doesn't close - e.g. "Recovered Documents"+  - Does NOT force-kill - the dialog can still intercept WM_CLOSE++##### Electronics Import (Round-Trip)+- `fusion_import_electronics` - Import Fusion-native `.fsch`, `.fbrd`, or `.flbr` files as new local documents+  - Uses `Document.newDesignFromLocal` under the hood+  - Auto-screenshots after import (catches blocking dialogs)+  - Args: `filePath` (required path on the Mac to .fsch, .fbrd, or .flbr)+  - After import, use `fusion_save_to_cloud` to persist to Fusion cloud+  - **`.fsch` import works standalone** - creates a new schematic project+  - **`.fbrd` import may fail** - boards have external references to schematics/libraries. Error: "New design cannot be created from a local file containing external references"+  - **`.flbr` import works standalone** - creates a new library project+  - For legacy EAGLE files: use `fusion_open_schematic` (.sch), `fusion_open_board` (.brd), `fusion_open_lbr` (.lbr)++##### Library Round-Trip Workflow+Export and re-import Fusion electronics libraries:+```bash+# 1. Open library from cloud+adom-bridge fusion_open_cloud_file '{"projectName":"Main","fileName":"Adom Common Components","folderPath":"Molecules/Libraries"}'++# 2. Export as .flbr (Fusion-native binary) and .scr (EAGLE script text)+adom-bridge fusion_save_lbr '{"outputPath":"/tmp/library.flbr"}'+adom-bridge fusion_export_lbr '{"outputPath":"/tmp/library.scr"}'+adom-bridge fusion_close_document++# 3. Re-import the .flbr+adom-bridge fusion_import_electronics '{"filePath":"/tmp/library.flbr"}'++# 4. Verify symbols survived round-trip+adom-bridge fusion_export_lbr '{"outputPath":"/tmp/library-verify.scr"}'++# 5. Save to cloud with new name+adom-bridge fusion_save_to_cloud '{"name":"Library-copy"}'+```++##### Demo Projects+171+ electronics molecules are exported in the `adom-bridge-demo` repo (separate from adom-bridge).+Three reference boards with full export formats:++| Board | Cloud Path | Layers | Components | Files |+|-------|-----------|--------|------------|-------|+| DRV8411A | Main / Molecules / XRP / DRV8411A | 4 | 29 | 27 |+| DRV8323SR | Main / Molecules / Experiments / MotorControl / DRV8323SR | 2 | 43 | 27 |+| VL53L8BreakoutMolecule | Main / Molecules / XRP / TimeOfFlightVL53L8 / VL53L8BreakoutMolecule | 2 | 30 | 27 |++Each folder contains: .fsch, .fbrd, bom.csv, cpl.csv, gerbers.zip, 6 board images, 7 3D renders (home/front/back/top/bottom/left/right), STEP, IGES, SAT, STL, 3MF, F3D, USDZ, board screenshot, 3D board screenshot.++#### Cloud Document Management++Manage Fusion 360 cloud documents (hub projects, files, versions). Required for 3D package workflows since EAGLE library 3D models are stored as cloud documents.++- `fusion_save_to_cloud` -- Save the active Fusion document to the cloud. Args: `name` (required), `projectName` (optional, defaults to active project), `folderPath` (optional), `description` (optional). Returns: `fileId`, `versionNumber`, `wipUrn` (if cloud-hosted).+- `fusion_list_cloud_projects` -- List all cloud projects in the user's hub. Returns array of `{name, id}` per hub.+- `fusion_list_cloud_files` -- List files in a cloud project/folder. Args: `projectName` (optional), `folderPath` (optional). Returns: `files` array with `{name, id, versionNumber, fileExtension, dateModified}` and `subfolders` array.+- `fusion_create_cloud_folder` -- Create a folder in a cloud project. Args: `folderName` (required), `projectName` (optional), `parentPath` (optional). Returns `folderId`. Idempotent - returns existing folder if it already exists.+- `fusion_check_recovery` -- Check if a cloud file has a recovery document from a previous crash. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional). Returns `hasRecovery: true/false`. Use this BEFORE opening files to avoid the blocking "Open recovery document instead?" dialog. If recovery exists, call `fusion_open_cloud_file` with `recovery: "open"` (restore unsaved work) or `recovery: "discard"` (delete recovery, open cloud version).+- `fusion_open_cloud_file` -- Open a cloud file in Fusion by name. **If a recovery document exists and no `recovery` arg is given, the command STOPS and reports the recovery instead of opening** - you must decide whether to preserve or discard unsaved work. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional), `recovery` ("open" = restore unsaved work, "discard" = delete recovery and open cloud version).+- `fusion_export_cloud_file` -- Export the active Fusion document to a local file for transfer back to Docker. Args: `outputPath` (required), `format` (optional, default "step"). The exported file can then be pulled back to Docker via `pull_file`.+- `fusion_delete_cloud_file` -- Delete a cloud file by name. Args: `fileName` (required), `projectName` (optional), `folderPath` (optional). File must not be open in Fusion - close it first with `fusion_close_document`.+- `fusion_walk_cloud_tree` -- **Long-running.** BFS walk of a cloud folder tree. Returns a flat list of all files and folders. Runs entirely on the Fusion main thread - **blocks all other add-in commands** until done (check progress with `fusion_addin_status`).+  - Args: `projectName` (optional), `folderPath` (optional, starting folder), `maxDepth` (default 10), `maxFolders` (default 500), `extensions` (optional list, e.g. `["f3d","fprj"]`), `nameContains` (optional substring filter), `includeFiles` (default true)+  - Returns: `{project, rootFolder, folders[], files[], stats: {foldersVisited, foldersSkipped, filesFound, maxDepthReached, truncated}}`+  - **Per-folder timeout (30s):** If a single folder's cloud API calls take >30s (common for large projects), the folder is skipped and counted in `foldersSkipped`. This prevents indefinite hangs.+  - **Progress tracking:** While running, `fusion_addin_status` returns `walkProgress` with `{foldersVisited, filesFound, currentFolder, queueSize}` - poll this every 1–10s to monitor progress (see "Live folder progress streaming" below).+  - **Non-blocking alternative:** Use `fusion_search_cloud_files` for targeted searches, or `fusion_list_cloud_files` for single-folder listings (these are faster but don't recurse).+  - Example: `adom-bridge fusion_walk_cloud_tree '{"projectName":"Main","folderPath":"Molecules","nameContains":"DRV","extensions":["fprj"]}'`++#### Live folder progress streaming with `watch`++For long walks, use the built-in `watch` wrapper. It spawns the inner search command on a worker thread, polls `fusion_addin_status` internally, and emits one JSON event per line to stdout as the walker visits each folder. **No manual polling loop needed.**++```bash+adom-bridge watch '{"command":"fusion_walk_cloud_tree","args":{"projectName":"Main","folderPath":"Molecules","nameContains":"BQ25792"}}'+```++Output is one JSON object per line, three event types:++```jsonl+{"event":"started","command":"fusion_walk_cloud_tree","args":{...},"interval":2,"_hint":"streaming progress events follow, one per line, ending with 'complete' or 'error'"}+{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":15.0,"foldersVisited":14,"queueSize":50,"filesFound":0,"currentFolder":"Molecules/Sensing"}+{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":23.4,"foldersVisited":22,"queueSize":108,"filesFound":0,"currentFolder":"Molecules/Examples"}+{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":31.8,"foldersVisited":30,"queueSize":100,"filesFound":0,"currentFolder":"Molecules/RAPID NAME TAGS/Connor Wood"}+... (one per real change in walkProgress, deduped) ...+{"event":"complete","result":{"folders":[...],"files":[...],"stats":{"foldersVisited":169,"filesFound":12,"truncated":false}}}+```++Read stdout line-by-line. Stop when you see `event:complete` or `event:error`. The full final result is in the `complete` event's `result` field.++**Optional `interval` arg** (default 2s, clamped to [1, 30]):++```bash+adom-bridge watch '{"command":"fusion_walk_cloud_tree","args":{...},"interval":1}'+```++**For Claude Code consumers**: pipe `watch` directly into the `Monitor` tool. Each JSON line becomes a real-time event notification in the chat - you (and the user) see folder names appear one-by-one as the walker visits them. No bash loop, no `disown`, no subprocess gymnastics. Verified live in 1.3.16 on a 169-folder walk that returned all 12 BQ25792 cloud files cleanly.++**Watchable commands** (whitelist): `fusion_walk_cloud_tree`, `fusion_search_cloud_files`. Other commands return `success:false` with a `_hint` listing the watchable set.++**When NOT to use watch**: short single-folder operations (`fusion_list_cloud_files`, `fusion_open_cloud_file`) - those return in <2s and don't need streaming. The `watch` wrapper is purely for the long BFS commands.+- `fusion_search_cloud_files` -- **Long-running.** Recursive substring search on file names across cloud folders. v1.0.2+ of the add-in adds: per-folder timeout, doEvents() every 50 files, per-file try/except, iterative BFS, and a stale-lock watchdog - Fusion stays responsive throughout (no "Not Responding" freeze).+  - **Args (no hard upper caps in v1.0.2+):** `query` (required, substring, case-insensitive), `projectName` (optional, defaults to active), `folderPath` (optional starting subfolder - **narrow with this**), `recursive` (default false), `maxDepth` (default 2), `maxFolders` (default 10), `maxResults` (default 20), `folderTimeout` (default 30s), `searchTimeout` (default 120s), `timeout` (HTTP envelope, default 620s).+  - **Before calling:** if the user knows roughly where the file is (e.g. customer-named folder), **ask them**. Naming a subfolder cuts 5-40 min searches down to ~10s.+  - **Why slow:** Autodesk's free Fusion 360 Python API has no indexed file-search endpoint. We walk the Data API one folder at a time - each = one HTTP round-trip to Autodesk. **This is an Autodesk API limitation, not adom-bridge's.** When telling the user the search is taking a while, attribute it to Autodesk / Fusion 360, not to adom-bridge's bridge. **There is now a FAST native alternative - `aps_search` (see "APS - fast indexed cloud search" below).** It hits Autodesk Platform Services' indexed Data Management API and returns ~instantly, even when Fusion is CLOSED. Call `aps_status` first; if `configured:true`, prefer `aps_search` and only fall back to this folder-walking verb when APS isn't set up.+  - **Interpreting results (critical for no-false-negatives):** the response contains `searchComplete` (bool), `foldersSkipped`, `filesSkipped`, `truncated`, `folderLimitReached`, `searchTimedOut`. `searchComplete:true` ONLY when **all five** are clean - that's the confidence flag. If `searchComplete:false`, the search hit a cap before exhausting the scope; **don't report "file not found"** - re-run with broader caps OR narrower `folderPath`.+  - **Case sensitivity:** fully case-insensitive both directions (query and file names lowercased). Substring match, not whole-word - `"cosm"` matches `COSMIIC`, `COSMOCOIL`, etc.+  - **Cost feedback in response:** `costAnalysis: {elapsedSeconds, foldersPerSecond, estimatedSecondsPer100Folders}` so the AI can budget the next search realistically.+  - **Real numbers from a live test:** searching `Main/Molecules` recursively (173 folders deep, max 8 depth) for "cosmiic" returned in 168 s with `searchComplete:true, totalFound:3`. Fusion stayed `main_thread:responsive` throughout.+  - **Pair with `watch`** for streaming progress updates: see below.++#### APS - fast indexed cloud search (v1.8.182+)++`aps_search` is a **native, ~instant** alternative to the folder-walking `fusion_search_cloud_files`. It queries **Autodesk Platform Services** (APS, formerly Forge) **Data Management API**, which has a real server-side index - so a search that takes minutes by folder-walking returns in well under a second, and it works **even when Fusion 360 is closed** (it's pure HTTPS, no bridge required). adom-bridge runs its own 3-legged **PKCE** OAuth, so the user's APS **client secret never leaves their machine**.++**One-time setup (user side):**+1. Register a free app at <https://aps.autodesk.com> → enable the **Data Management API**.+2. Set the app's **Callback URL** to exactly `http://localhost:8910/aps/callback`.+3. Put the **Client ID** in Bridge config key `aps_client_id` (in `~/.adom/config.json`) or via Settings.+4. Run `aps_auth` once - it opens the system browser for Autodesk sign-in. Tokens cache in `~/.adom/aps_tokens.json` and auto-refresh; you won't be prompted again.++**Verbs:**+- `aps_status` - **call this first.** Non-prompting. Returns `{configured, signedIn}`. `configured:false` → no client id; use `fusion_search_cloud_files`. `configured:true, signedIn:false` → run `aps_auth`. Both true → `aps_search` is ready.+- `aps_auth` - interactive sign-in (opens browser). One-time per device.+- `aps_hubs` - `{hubs[]{id,name}}` (your Autodesk teams).+- `aps_projects {hubId?}` - `{hubId, projects[]{id,name}}` (default: first hub).+- `aps_search {query, projectName?, projectId?, hubId?}` - `{query, hubId, count, results[]{name,id,type,lastModified,project}}`. `query` is a case-insensitive substring of the file's displayName. Without `projectName`/`projectId` it searches up to 8 projects in the hub. Open a hit with `fusion_open_cloud_file {fileName, projectName}`.++**Decision flow for an AI doing a cloud file search:** `aps_status` → if `configured` (sign in with `aps_auth` if needed) use `aps_search`; else fall back to `fusion_search_cloud_files` and tell the user that registering a free APS app (above) would make future searches instant.++**Export formats for `fusion_export_cloud_file`:**++| Format | Extension | Use case |+|--------|-----------|----------|+| `step` | .step | Industry-standard CAD interchange (default) |+| `stl` | .stl | 3D printing, mesh-based |+| `f3d` | .f3d | Fusion 360 native archive (preserves all features) |+| `iges` | .iges | Legacy CAD interchange |+| `sat` | .sat | ACIS solid modeling kernel format |+| `smt` | .smt | Parasolid format |++Also supported for import via `fusion_import_step`: STEP (.step/.stp), STL (.stl), IGES (.iges/.igs), SAT (.sat), SMT (.smt), OBJ (.obj), F3D (.f3d).++**Round-trip: workspace → Fusion Cloud → workspace**++```bash+# === workspace to Fusion Cloud ===+# 1. Send file from Docker to the Mac+adom-bridge send_files '{"files": [{"path": "/home/user/component.step"}]}'+# 2. Import into Fusion+adom-bridge fusion_import_step '{"filePath": "/Users/<you>/Downloads/component.step"}'+# 3. Save to cloud (gets a wip_urn for 3D library linking)+adom-bridge fusion_save_to_cloud '{"name": "my-component", "projectName": "Personal"}'++# === Fusion Cloud to Docker ===+# 1. Find and open the cloud file+adom-bridge fusion_walk_cloud_tree '{"projectName": "Personal", "nameContains": "my-component"}'+adom-bridge fusion_open_cloud_file '{"fileName": "my-component", "projectName": "Personal"}'+# 2. Export to local filesystem+adom-bridge fusion_export_cloud_file '{"outputPath": "/tmp/export/my-component.step", "format": "step"}'+# 3. Pull back to Docker+adom-bridge pull_file '{"path": "/tmp/export/my-component.step"}'++# === Cloud management ===+adom-bridge fusion_list_cloud_projects '{}'+adom-bridge fusion_create_cloud_folder '{"folderName": "Electronics", "projectName": "Main"}'+adom-bridge fusion_list_cloud_files '{"projectName": "Main", "folderPath": "Electronics"}'+adom-bridge fusion_delete_cloud_file '{"fileName": "old-file", "projectName": "Main"}'+```++#### Manufacturing Exports (Gerbers, BOM, CPL)++Export manufacturing files from an open PCB board - everything needed to fabricate boards and assemble components via Adom's PCBA service. **All manufacturing commands require a .brd board open in PCB Editor** (use `fusion_open_board` or `fusion_show_2d_board`).++**Recommended workflow order:** `detect_layers` → `set_design_rules` → DRC → `export_gerbers` → `export_bom` → `export_cpl` → `pull_file` all back to Docker.++Every manufacturing command returns structured JSON with:+- `message` - AI-oriented summary explaining what was produced and why it matters+- `nextSteps[]` - ordered list of what to run next in the manufacturing pipeline+- `hint` - tips for interpreting results or recovering from issues+- `data` - structured metadata (paths, counts, layer info) for programmatic use++**Decision guide - which command to run:**+- **Don't know the layer count?** → Run `fusion_detect_layers` first+- **Need to check if the board meets fab specs?** → Run `fusion_set_design_rules` then DRC+- **Ready to generate fab files?** → Run `fusion_export_gerbers`, then `fusion_export_bom`, then `fusion_export_cpl`+- **Need visual review before export?** → Run `fusion_export_board_image` with preset `assembly_top` or `fabrication`+- **Something failed?** → Check `data.hint` and `data.recoverySteps` in the error response++**Commands:**++- `fusion_detect_layers` -- Detect if the open board is 2-layer or 4-layer. Uses ULP script (primary) and CAM comparison (fallback). Returns `layerCount`, `copperLayers[]`, `method` (how it was detected), and `nextSteps[]`. **Run this first** - all other manufacturing commands auto-detect layers too, but running this explicitly gives you the data before committing to exports.++- `fusion_set_design_rules` -- Apply Adom's JLCPCB-derived design rules (.edru XML files) to the open board. Auto-detects 2-layer vs 4-layer and loads the appropriate rule set. Returns `description` (human-readable rule summary), `edruFile`, and `nextSteps[]`.+  - `action`: `"apply"` (default) loads rules into the board; `"export"` saves current board rules to a file; `"show"` displays rule capabilities without modifying anything+  - `layers`: `"auto"` (default), `"2"`, or `"4"` - force a specific rule set+  - After applying: run `fusion_electron_run '{"command": "DRC"}'` to check for violations. DRC markers appear in the board editor.++- `fusion_apply_instapcb_rules` -- Convenience wrapper that applies the bundled Adom InstaPCB design rule sets without needing a local `.edru` file. Args: `layers` (`"2"` or `"4"`). Sends the bundled `.edru` from Docker to the Mac automatically and loads it via the EAGLE `drc load` command. Use this instead of `fusion_set_design_rules` when you just want Adom's standard 2-layer or 4-layer InstaPCB rules.++- `fusion_load_design_rules` -- Generic loader for ANY custom `.edru` file by path. Args: `filePath`. Use this for third-party fab vendor rules (JLCPCB, PCBWay, OSHPark, etc.) that the user has on disk.++- `fusion_export_gerbers` -- Export Gerber (RS-274X) + Excellon drill files as a ZIP. Auto-detects 2-layer vs 4-layer and selects the correct JLCPCB-compatible CAM job. Returns `zipPath`, `zipSizeKB`, `files[]` (list of gerber files in the ZIP with sizes), `layerCount`, and `nextSteps[]`.+  - `outputDir`: directory for the output ZIP (default: `/tmp/adom-gerbers/`)+  - `boardName`: prefix for the ZIP filename (default: from active document name)+  - `layers`: `"auto"` (default), `"2"`, or `"4"` - force CAM job selection+  - Output ZIP contains: GTL, GBL (copper), GTS, GBS (solder mask), GTP, GBP (paste), GTO, GBO (silkscreen), GKO (outline), XLN (drill). 4-layer boards also get G1, G2 (inner copper).++- `fusion_export_bom` -- Export Bill of Materials as CSV. Groups identical parts by value+package with quantity counts. Returns `componentCount`, `uniquePartCount`, and `nextSteps[]`.+  - `outputPath`: (default: `/tmp/adom-bom.csv`)+  - `grouped`: `true` (default) groups by value+package; `false` lists every component individually+  - Output columns: Comment, Designator, Footprint, Quantity, Library - compatible with JLCPCB, PCBWay, Mouser, Digi-Key.++- `fusion_export_cpl` -- Export Component Placement List (pick-and-place) as CSV. Returns `totalPlacements`, `topCount`, `bottomCount`, and `nextSteps[]`.+  - `outputPath`: (default: `/tmp/adom-cpl.csv`)+  - `side`: `"all"` (default), `"top"`, or `"bottom"` - filter for single-sided assembly+  - Output columns: Designator, Mid X, Mid Y, Layer, Rotation - coordinates in mm.++- `fusion_export_board_image` -- Export PNG image of the board with layer presets. Returns `fileSize`, `preset`, and `nextSteps[]`.+  - `outputPath`: (default: `/tmp/adom-board.png`)+  - `dpi`: resolution (default: 300, max: 600)+  - `preset`: layer preset name (see table below)+  - `layers`: custom layer numbers as int array - overrides preset+  - `monochrome`: `true` for black & white+  - `listPresets`: set `true` to get available presets instead of exporting++**Layer presets for `fusion_export_board_image`:**++| Preset | Layers | Description |+|--------|--------|-------------|+| `all` | All | Every layer visible |+| `top_copper` | 1, 17, 18 | Top copper + pads + vias |+| `bottom_copper` | 16, 17, 18 | Bottom copper + pads + vias |+| `top_silkscreen` | 21, 25 | Top silkscreen + component names |+| `bottom_silkscreen` | 22, 26 | Bottom silkscreen + component names |+| `top_soldermask` | 29 | Top solder mask openings |+| `bottom_soldermask` | 30 | Bottom solder mask openings |+| `top_paste` | 31 | Top paste/stencil openings |+| `bottom_paste` | 32 | Bottom paste/stencil openings |+| `board_outline` | 20 | Board outline (dimension layer) |+| `drill` | 44, 45, 17, 18 | Drill holes + vias |+| `assembly_top` | 1, 17, 18, 20, 21, 25, 51 | Top assembly - copper + silk + outline |+| `assembly_bottom` | 16, 17, 18, 20, 22, 26, 52 | Bottom assembly - copper + silk + outline |+| `fabrication` | 1, 16, 17–20, 21, 22, 25, 26, 29, 30, 51 | Full fabrication view |++**Adom InstapcbPCB manufacturing capabilities (used by `fusion_set_design_rules`):**++| Parameter | Metric | Imperial |+|-----------|--------|----------|+| Layers | 1, 2, 4, 6 | - |+| Min trace width | 0.08mm | 3 mil |+| Min trace spacing | 0.08mm | 3 mil |+| Min via drill | 0.2mm | 8 mil |+| Min silkscreen text | 0.1mm | 4 mil |+| Board edge clearance | 0.05mm | 2 mil |+| Board thickness | 1.6mm | 63 mil |+| Copper weight | 0.5 oz (17.5 μm) | - |+| Solder mask | Green | - |+| Surface finish | HASL / ENIG | - |+| Turnaround | 4 hours (fab + assembly) | - |++**Manufacturing workflow - Fusion to Adom PCBA:**++```bash+# 1. Open the board+adom-bridge fusion_open_board '{"filePath": "C:/projects/myboard.brd"}'++# 2. Detect layer count (auto-selects 2-layer or 4-layer rules/CAM)+adom-bridge fusion_detect_layers++# 3. Apply Adom design rules for the detected layer count + run DRC+adom-bridge fusion_set_design_rules '{"action": "apply"}'+adom-bridge fusion_electron_run '{"command": "DRC"}'++# 4. Export manufacturing files (gerbers auto-select correct CAM job)+adom-bridge fusion_export_gerbers '{"outputDir": "/tmp/mfg"}'+adom-bridge fusion_export_bom '{"outputPath": "/tmp/mfg/bom.csv"}'+adom-bridge fusion_export_cpl '{"outputPath": "/tmp/mfg/cpl.csv"}'++# 5. Export board images for review+adom-bridge fusion_export_board_image '{"outputPath": "/tmp/mfg/top-copper.png", "preset": "top_copper"}'+adom-bridge fusion_export_board_image '{"outputPath": "/tmp/mfg/assembly-top.png", "preset": "assembly_top"}'+adom-bridge fusion_export_board_image '{"outputPath": "/tmp/mfg/board-outline.png", "preset": "board_outline"}'++# 6. Pull files back to Docker for Adom PCBA ordering+adom-bridge pull_file '{"path": "/tmp/mfg/bom.csv"}'+adom-bridge pull_file '{"path": "/tmp/mfg/cpl.csv"}'+```++### Desktop Tools++- `desktop_open_folder` -- Open a file or folder in Finder. If given a file path, Finder opens with the file revealed (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"` (the macOS default browser - could be Safari, 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 `browser_open_window` - two completely different browsers++| | `desktop_open_url` | `browser_open_window` |+|---|---|---|+| **What browser** | Native OS browser the user uses daily - Safari / Google Chrome / Microsoft Edge / Firefox / Brave (whichever is the macOS 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 `browser_*` verb (screenshot, eval, navigate, record, etc.) |++**Decision rule:** human-in-the-loop login flow → `desktop_open_url`. AI-driven automation → `browser_open_window` (defaults to Chrome for Testing). AI-driven automation that **must** be the user's real, signed-in profile (GCP Console with org/projects, internal SSO behind a managed-Chrome policy, anything Context-Aware Access blocks for non-enrolled browsers) → `browser_open_window` with the new `nativeBrowser` + `nativeProfile` args (v1.0.5+, see below).++#### When pup means "the user's real Chrome/Edge" - `browser_open_window` native mode (v1.0.6+, smart flow v1.0.11)++Default `browser_open_window` (no native args) launches Chrome for Testing - same as day one. **Never auto-switch a plain pup request to native.** "Open this URL in pup", "screenshot this", "click that button" all stay on CfT.++Native mode drives the user's **REAL browser** - their actual signed-in profile, with live cookies, org policies, and device enrollment intact (NOT a clone - v1.0.6 replaced the old clone approach). Opt-in via TWO additive args from `desktop_list_browsers`:++```bash+adom-bridge desktop_list_browsers     # installed browsers + profiles (+ the signed-in account per profile)+adom-bridge browser_open_window '{+  "sessionId":"gcp",+  "nativeBrowser":"chrome",            # chrome / edge / brave+  "nativeProfile":"Default",           # profile id: Default / Profile 1 / Profile 2+  "url":"https://console.cloud.google.com"+}'+```++**The smart flow.** CDP (the protocol pup drives) needs the browser running with `--remote-debugging-port`, which can only be set at launch. So the bridge handles each situation differently and tells you which happened:++| Situation | What Bridge does | Response |+|---|---|---|+| Already driving that browser+profile | Reuse the live connection | `ok` + `narrative` |+| Running **with** a debug port (power-user launched it that way, or Bridge did earlier) | Connect, no relaunch | `ok` + `narrative` |+| **Not running at all** | **Launch it fresh with the debug port - NO prompt** (nothing to disrupt) | `ok`, `launchedFresh:true` + `narrative` - just proceed |+| **Open without** a debug port | **Ask the user** - Bridge must close+reopen it | `errorCode:"native_relaunch_required"` + `confirmArgs` + `alternatives` |+| Debug port **refuses to bind** (enterprise policy) | Won't close it again | `errorCode:"native_cdp_blocked"` + `diagnosis` + `alternatives` |++**Handling the two consent/blocked codes:**++- **`native_relaunch_required`** - the browser is open without the port, so driving it means closing + reopening it (with `--restore-last-session`, so all tabs/windows come back). **Ask the user's permission**, then re-call with `"nativeRelaunch":true`. The response's `alternatives` lists other installed browsers you could offer instead.+- **`native_cdp_blocked`** - the debug port won't open, almost always an **enterprise policy** (`RemoteDebuggingAllowed` disabled via Google Workspace / Chrome cloud management - affects **Chrome browser-wide, every profile**). `diagnosis` says whether it's a confirmed local policy or a likely cloud-managed one. Bridge will **not** close that browser again. `alternatives` names other installed browsers - **Edge usually escapes Google management**, so it often drives natively when Chrome can't. Offer the user the alternative, or fall back to generic CfT.++**Always available - generic CfT.** `browser_open_window` with NO `nativeBrowser`/`nativeProfile` drives a clean Chrome-for-Testing instance: no user session, no managed policy, fully controllable. If a task needs the user's logged-in session there, have them sign in once. This is the universal fallback whenever native is blocked.++**Power-user shortcut:** if the user launches their browser at startup with `--remote-debugging-port=<N>`, Bridge reconnects every session with no prompt at all (a productized opt-in "keep native browsers debug-ready at boot" is on the roadmap, default OFF for security).++**Routing rule - DON'T auto-escalate.** Treat native as opt-in:++| User says | What to do |+|---|---|+| "pup" / "open in pup" / screenshot / eval / debug | DEFAULT pup (CfT). Never mention profiles. |+| "native chrome" / "my real browser" / "my work profile" / "my `<account>` profile" | Native. Call `desktop_list_browsers`. If exactly one profile clearly fits the request, use it and say which one ("opening in your `[email protected]` profile on Chrome"). If multiple profiles could fit (e.g. a `google.com` URL with both `[email protected]` and `[email protected]` profiles signed in), **ASK** ("Which profile - work `[email protected]` or personal `[email protected]`?") and then drive that pair. |+| anything else | Default pup. |++**Anti-fingerprint:** native sessions launch without `--enable-automation` and with `--disable-blink-features=AutomationControlled`. `navigator.webdriver === false`. No "controlled by automated software" banner. Sites that probe for automation see a normal browser.++**Firefox** appears in `desktop_list_browsers` but is rejected for native drive with `errorCode:"firefox_no_cdp"` - Firefox doesn't speak the Chrome DevTools Protocol, so puppeteer can't drive it. If the user wants Firefox, hand off via `desktop_open_url` instead.++**Native-mode error codes:**+- `native_relaunch_required` - browser open without a port; ask the user, re-call with `nativeRelaunch:true` (see the smart-flow table above).+- `native_cdp_blocked` - debug port policy-blocked; read `diagnosis`, offer `alternatives` or generic CfT (see above).+- `firefox_no_cdp` - caller passed `nativeBrowser:"firefox"`. Firefox doesn't speak CDP; hand off via `desktop_open_url`.+- `native_browser_not_installed` - that browser isn't installed on this machine.+- `native_user_data_missing` - installed but never launched, so the User Data dir doesn't exist yet. Ask the user to open it once.+- `native_profile_not_found` - `nativeProfile` isn't a real profile id under this browser's User Data. Call `desktop_list_browsers` to see what's valid.++#### Common footgun - `localhost` / `file://` URLs from Docker AIs++If you're running this CLI from a remote Docker container (a cloud relay, dartv4, etc.), `browser_open_window` / `browser_open_tab` / `browser_navigate` will load the URL inside the USER's local pup - NOT inside your container. So a URL like `http://localhost:8080/foo.html` resolves to the user's machine, not your container's service.++**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:+adom-bridge browser_open_window '{+  "sessionId":"demo",+  "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 window id (`hwnd`) or title substring. Activates the owning app and raises the window. 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; works even on an off-screen window. It's an ATOMIC check-and-demote: it reads the OS foreground window first, and if the target IS the 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, 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: the OS often hands the foreground to a brand-new window, 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 CGEvent** - so they land on **`chrome://` pages and native dialogs that browser CDP (`nbrowser_*` / `browser_*`) cannot reach**. The verb names mirror the browser extension's `nbrowser_*` so the model transfers between in-page and whole-desktop driving.++- **Gated behind shell-auto-approve** (same risk class as `shell_execute` - keystrokes/clicks hit whatever is focused on the user's live machine). If it's off you get `errorCode:"input_not_approved"`. **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. Synthetic input also needs macOS **Accessibility** permission for the Bridge app (System Settings > Privacy & Security > Accessibility).+- **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 window id from `desktop_list_windows` / `desktop_find_window`.+- **Coords are physical screen pixels** - the same space as `desktop_list_windows` rects.++Verbs:+- `desktop_click {x, y, button?:"left"|"right"|"middle", double?, window?|hwnd?}` - left-click by default. `desktop_double_click` / `desktop_right_click` are wrappers; `desktop_hover` moves the cursor without clicking (reveals tooltips).+- `desktop_type {text, window?|hwnd?}` - types a literal Unicode string (any keyboard layout).+- `desktop_press_key {keys:[...], repeat?, window?|hwnd?}` (alias `desktop_send_keys`) - chords/shortcuts. Modifiers `ctrl/alt/shift/cmd` (aliases `meta/super/win` mean Command); named keys `enter/tab/esc/space/backspace/delete/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":["cmd+l"],"window":"Chrome"}'   # focus Chrome + select the address bar+adom-bridge desktop_type        '{"text":"chrome://extensions","window":"Chrome"}'+adom-bridge desktop_press_key   '{"keys":["enter"],"window":"Chrome"}'+```+For a control that isn't reachable by CDP/coords (e.g. a button inside a shadow DOM), Tab-navigate to it then Enter: `desktop_press_key {"keys":["tab","tab","enter"]}`.++**Visual "Adom cursor" (v1.8.171+)** - a click-through teal cursor + a label that narrates what you're driving (trust cue; byte-faithful to the browser extension's cursor). Pure visual, *not* gated:+- `desktop_cursor {action:"show"|"move"|"hide", x, y, label}` - drive/narrate the cursor on its own ("watch me"). Returns the overlay `hwnd`.+- Or pass `cursor:true` + `label:"Clicking Reload"` on `desktop_click`/`desktop_double_click`/`desktop_right_click`/`desktop_hover` - Bridge glides the teal cursor to the target (narrating the label) *then* does the real click.+- **Auto-hide (v1.8.174+):** the cursor hides itself after `ttlMs` (default **60s**) so it never lingers on the user's screen if you forget to hide it. Each `show`/`move` refreshes the timer. Pass `ttlMs:<ms>` (≤600000) for longer, or `persist:true` to keep it until you explicitly `desktop_cursor {action:"hide"}`. If a cursor you showed was auto-hidden, the **next** cursor/click response prefixes its `_hint` with a NOTE so you learn (no chatty unsolicited message).++#### Background input via macOS Accessibility (AX) - no foreground, no focus steal++The mouse/keyboard verbs above are real OS input, which lands on the **foreground** window - so Bridge foregrounds the target first, yanking it in front of the user's work. When you must NOT steal focus, use the **Accessibility (AX)** verbs instead: `AXPress` / setting `AXValue` are **programmatic** - the app services them via its accessibility tree with **no cursor move, no focus change, no foregrounding**. Chromium browsers expose their a11y tree to AX, so `chrome://` page buttons/links are reachable **by accessible name** (not coordinates). The verb names keep the `desktop_ui_*` spelling for cross-platform compatibility; on this build they are implemented on macOS AX (`desktop_ax`).++- `desktop_find_control {window|hwnd, name|contains, role?}` - **read-only** discovery (ungated). Returns `best:{name, role, rect, invokable, settable}`. Call this first to learn whether a control is AX-reachable.+- `desktop_ui_click {window|hwnd, name|contains, role?}` - AX **press** by accessible name, in the background. Gated behind shell-auto-approve.+- `desktop_ui_set {window|hwnd, name|contains, text}` - AX **set value** on a text field, in the background. Gated.+- `desktop_navigate {window, url}` - background browser navigation via the address bar's AX value. Gated like `ui_set`.++`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 AX 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 AX can't reach it.++**Permission:** all AX verbs require the Bridge app to be trusted in **System Settings > Privacy & Security > Accessibility**. Without it every AX call returns `errorCode:"ax_not_trusted"` with a hint - ask the user to enable it there, then retry.++#### The wider AX surface - enumerate, read, drive++Beyond click/set, a near-complete agent-drivable accessibility client is available (same shared selector everywhere):++**Shared selector on every verb:** `{hwnd|window(titleContains)}` + `{name|contains|role}` + `nth:<int>` (0-based - target the Nth match without the exact name). 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)}` - **flagship.** Dump the a11y subtree; nodes carry rect + invokable/settable + value/toggle/selection state + `children[]`. `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}`; `desktop_ui_get {selector}` (current value/toggle/range/selection state); `desktop_ui_grid_get {selector, row?, col?}` (table cell + rowCount/columnCount).+- **Actions (gated by shell-auto-approve; background via AX):** `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}` (usually raises; reports it), `desktop_ui_window {window, action:minimize|maximize|restore|close|move|resize}`.+- **Introspection:** `desktop_ui_capabilities {}` - **read-only, ungated.** Lists the read/action verb sets and what this platform implements.++**Not on macOS:** the event-subscription verbs (`desktop_ui_watch` / `desktop_ui_events` / `desktop_ui_unwatch` / `desktop_ui_selftest`) return a clean `not_implemented_macos` error. To wait for a dialog/window, poll `desktop_find_window` or `desktop_screenshot_window` instead.++#### 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 Bridge 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 without foregrounding it (per-window `screencapture -l`, 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.++**Dialogs / secondary windows:** an app's dialogs are separate windows in `desktop_list_windows` on macOS - capture each by its own window id, or use `kicad_screenshot_all` (returns every KiCad window incl. error dialogs as a `screenshots[]` array, each with its own `coordMap`+`shotId`). **Use `desktop_screenshot_screen`** (full-desktop grab) 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.+- **`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. 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/bridge-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 - AX + capture): `desktop_ui_click`, `desktop_ui_set`, `desktop_navigate`, `desktop_find_control`, `desktop_screenshot_window/_screen`, `desktop_record_window_start`. **Foreground** (synthetic input goes to the focused window, so Bridge 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 AX 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 click it (handy when `invokable`/`settable` are both false). Target by `name`, `contains`, or `role`.++**Keyboard chords:** `desktop_press_key` accepts a chord as one string `["cmd+w"]` OR separate tokens `["cmd","w"]` - both mean Command held + W. A *sequence* of distinct chords uses `+` per element: `["cmd+l","enter"]`. The reply echoes `chords` showing how Bridge interpreted your `keys`.++- `desktop_flash_window` (v1.8.152+) -- Request the user's attention for the Bridge app (a Dock bounce / attention request on macOS) as an "attention / ongoing activity" cue. Args: `hwnd` (int) OR `titleContains` (string); `mode` (`"until_focused"` default, or `"count"`); `count` (int, `mode:"count"` only, default 3); `stop` (bool -- clear an active request). Returns `{ok, hwnd, title, mode}`. Distinct from the puppeteer bridge's `browser_alert_window`, which flashes pup's OWN tracked windows.+  - Example: `adom-bridge desktop_flash_window '{"titleContains":"Fusion"}'` then add `"stop":true` to clear.+- `desktop_taskbar` (v1.8.152+) -- Glanceable status on macOS maps to the **Dock**: `flash` becomes a Dock attention bounce (`"until_focused"` | `"stop"`), and badge/progress channels update Bridge's own Dock tile. Windows-only channels from the original contract (per-window overlay icons, thumbnail tooltips/clips, painting OTHER apps' buttons) do not exist on macOS and are ignored or reported in the response's `applied[]`. Use `desktop_flash_window` / a notification for "come look" cues, and `desktop_caption` for on-screen narration.+- `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_caption` -- Show a global caption overlay that stays visible above ALL windows (KiCad, Fusion, Chrome). Native always-on-top overlay window, not a browser DOM element. Click-through: mouse events pass to windows below. Captured by screen recordings.+  - 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"}'`+  - 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 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 window ids, then `desktop_screenshot_window` with the id.++- `desktop_list_windows` -- List all visible windows (returns the window id as `hwnd` (the CGWindowID on macOS), title, owning app, position/size, and `z` = Z-order index, 0 = topmost)+- `desktop_find_window` (v1.8.153+) -- Resolve a window's id by `titleContains` (case-insensitive substring), 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,z,rect}|null, windows[]}` (`ok` true only when ≥1 matched). Pass `best.hwnd` to `desktop_flash_window` / `desktop_screenshot_window`.+  - Example: `adom-bridge desktop_find_window '{"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 Bridge 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 its id (`hwnd`)+- `desktop_screenshot_screen` -- Capture the entire desktop (all monitors)+- `desktop_ad_scroll` (v1.9.86+) -- **Scroll Bridge's OWN window panels.** Bridge 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` (Bridge hwnd) → `desktop_ad_scroll '{"panel":"bridges"}'` → screenshot → repeat until `atBottom`.++### App self-update (relay-driven, v1.9.87+)++Bridge auto-updates itself from the wiki manifest via a silent dmg swap (mount, rename-swap the .app, relaunch; manual-open fallback), but historically only a HUMAN clicking the footer "Install now" banner could apply it - so a headless / fleet box 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 Bridge. Respects `busy` (refuses if a command is in flight). Only ever applies Bridge's own downloaded + SHA-verified SIGNED wiki release. Returns `{applying:true}` then Bridge 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 Bridge 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.++### Browser automation (`browser_*`, 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.++Most-used verbs:++```bash+adom-bridge browser_open_window '{"sessionId": "s1", "profile": "p1", "url": "https://example.com"}'+adom-bridge browser_navigate '{"sessionId": "s1", "url": "https://example.com/page2"}'+adom-bridge browser_screenshot '{"sessionId": "s1"}'+adom-bridge browser_eval '{"sessionId": "s1", "expression": "document.title"}'+adom-bridge browser_fetch_url '{"url": "https://example.com/data.json"}'+```++`browser_readiness` is the read-only probe (never spawns or downloads). Full details: the bridge's own skill via `sync_skills`.++### Screen recording - Bridge's desktop recorder (tab recording is pup's)++Bridge 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. There is also a native screen recorder: `desktop_record_window_start` / `_stop` / `_status` records via macOS `screencapture -v` to a pullable `.mov` (on macOS it records the screen; graceful stop finalizes the file).++```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 (`browser_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 shell polling of the Downloads folder. No shell, no user approval prompt, no parsing of `ls` output. They run pure Rust on the desktop side, so they're fast and predictable.++The whole "wait for a file to arrive" flow is one call:++```bash+adom-bridge desktop_watch_files '{+  "path": "~/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` (abs path on the Mac; `~` / `%USERPROFILE%` / `%TEMP%` are expanded - the Windows-style tokens still map to home / temp for cross-platform callers), `glob` (default `*`; shell-style with `*` and `?`), `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 via browser_input_dispatch …+adom-bridge desktop_pull_glob "{+  \"path\": \"~/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-sensitive on macOS/Linux (matches the filesystem) - `*.step` and `*.STEP` are different globs here.+- Examples: `ul_*.zip`, `LIB_*.zip`, `*.pdf`, `datasheet_*.pdf`, `*.step`.++#### Path expansion++- `~` and `~/foo` → home dir.+- `%USERPROFILE%` → home dir (kept for cross-platform callers; case-insensitive).+- `%APPDATA%` / `%LOCALAPPDATA%` → the platform config / local-data dirs (`~/Library/...` on macOS).+- `%TEMP%` → the temp dir (`/tmp`-style on macOS).++#### 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 `>`).++### Adom Hydrogen (`hydrogen_*`) - proxy + build/lifecycle for the sibling Tauri app++When the user has **Adom Hydrogen** running (the sibling Tauri v2 app), built-in `hydrogen_*` verbs reach into it. NOT a separate bridge process - Bridge proxies HTTP calls directly to HD's control API.++**Port discovery (v1.8.72+):** HD's control API lives on **port `47084`** (default; the actual port is read from `~/.config/adom-hydrogen/ports.json` key `control` on every `hydrogen_api` call, fallback 47084).++#### The canonical generic verb - `hydrogen_api` (v1.8.72+)++```bash+adom-bridge hydrogen_api '{"method":"GET","path":"/health"}'+adom-bridge hydrogen_api '{"method":"POST","path":"/setup/run-step","body":{"id":"welcome"}}'+adom-bridge hydrogen_api '{"method":"GET","path":"/workspace/health"}'+```++Generic HTTP pass-through to any HD endpoint. Raw JSON in `body` - no curl-through-the-relay quote mangling. Returns `{success, status, body, port, method, path}` mirroring HD's response. If HD isn't running, returns `{ok:false, error:"HD not reachable on :47084", _hint:"..."}` instead of timing out.++**Public denylist** - these paths are refused with `{ok:false, error:"not_allowed_in_public", denylist_match:"..."}` on public Bridge builds (use the internal `hd-dev` cargo feature to bypass):++```+/wsl/unregister, /setup/virgin-reset, /container/destroy,+/container/reset, /workspace/reset, /workspace/destroy+```++#### Inspect + drive a running HD (v1.8.15+, post-v1.8.72 rework)++| Verb | What | Status |+|---|---|---|+| `hydrogen_status` | Composes Bridge-side facts (running, repo SHA) + HD's `/workspace/health` + `/buildinfo` relayed | ✓ shipped |+| `hydrogen_api` | Generic HTTP pass-through (above) | ✓ v1.8.72+ |+| `hydrogen_log '{"tail":30}'` | Tail HD's log file on disk (works EVEN when HD is down) | ✓ shipped |+| `hydrogen_open_url '{"url":"...","browser":"chrome","profileDir":"Default"}'` | POST `/open-in-profile` - open URL in a specific browser profile | ✓ shipped |+| `hydrogen_browser_profiles` | GET `/browser-profiles` - enumerate browsers + profiles | ✓ shipped |+| `hydrogen_screenshot` / `hydrogen_shot` | Find HD window, capture lossless PNG (background per-window capture); `hydrogen_shot` adds region-scoped variant | ✓ shipped |+| `hydrogen_eval '{"js":"..."}'` | POST `/eval` - run JS in HD's main webview | ⚠ **Moved to `hd-dev` in v1.8.72.** Public returns `verb_moved_to_hd_dev`. Use `hydrogen_api '{"method":"POST","path":"/eval","body":{"js":"..."}}'` |+| `hydrogen_iframe_eval '{"js":"...","contextIndex":0}'` | POST `/iframe-eval` - JS in code-server iframe via CDP | ⚠ Moved to `hd-dev`. Use `hydrogen_api` equivalent |+| `hydrogen_reload_vscode` | POST `/reload-vscode` | ⚠ Moved to `hd-dev`. Use `hydrogen_api` equivalent |+| `hydrogen_container_exec '{"command":"..."}'` | POST `/container-exec` | ⚠ Moved to `hd-dev`. Use `hydrogen_api` equivalent |+| `hydrogen_launch` / `hydrogen_stop` / `hydrogen_restart` | HD process lifecycle | ✓ shipped |+| `hydrogen_ship` | Atomic build+relaunch+verify-SHA (see below) | ✓ shipped |+| `hydrogen_build*` (v1.8.16+) | Build pipeline orchestration | ⚠ Moved to `hd-dev` in v1.8.72 (HD developers only) |++#### Atomic ship - `hydrogen_ship` (v1.8.49+) - START HERE for post-push rebuilds++**One verb, one call, one structured pass/fail.** Replaces the 4-step chain `hydrogen_stop` → `hydrogen_build_rust` → poll `hydrogen_build_status` → `hydrogen_launch` → `curl :47084/buildinfo`. Eliminates the "is the new code actually running?" guesswork after every push.++Sequence inside one call:+1. kill HD + poll until zero instances (frees the binary 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 adom-hydrogen` (defeats stale-relink)+4. `cargo build` and parse stderr for `Compiling adom-hydrogen` 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 hydrogen_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 adom-hydrogen` before build. Use when a prior `hydrogen_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 kill HD (rare - usually permissions) |+| `git` | `git fetch / reset` failed, OR `HEAD != expectSha` after reset (push hasn't replicated to origin/main yet - retry in a few seconds) |+| `clean` | `cargo clean` failed (rare) |+| `build` | `cargo build` failed - read `_hint` for next step (usually `hydrogen_build_log` to see the compile error) |+| `launch` | Cargo finished but the `adom-hydrogen` binary wouldn't start - `binary_missing` or process spawn error |+| `verify` | HD launched but `/buildinfo` either never responded or reported a different SHA than `expectSha` (the stale-relink trap - retry with `clean:true`) |++Long-running: cold cargo build can take 2-3 min. CLI WS timeout is 12 min - call as a single sync invocation and wait. Don't poll inside `hydrogen_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 `hydrogen_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 `hydrogen_ship`.++The relay's `shell_execute` has a 30s timeout; HD's `pnpm build` + `cargo build` exceed that, so the build is split into async-spawn verbs + sync poll/tail verbs:++```bash+# Frontend-only rebuild, watch progress, relaunch (no hydrogen_ship since hydrogen_ship is Rust-build-only)+adom-bridge hydrogen_stop+adom-bridge hydrogen_build_frontend '{"show":false}'      # returns {ok, pid, logPath} immediately+OFFSET=0+while true; do+  RESP=$(adom-bridge hydrogen_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 hydrogen_build_status      # confirm succeeded:true && relinkOnly:false+adom-bridge hydrogen_launch            # start the new debug binary+```++##### `hydrogen_build` / `hydrogen_build_frontend` / `hydrogen_build_rust` - async-spawn build verbs++| Verb | What |+|---|---|+| `hydrogen_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>`. |+| `hydrogen_build_frontend` | Just `pnpm build` (root). Returns `{pid, logPath, flavor:'frontend'}`. |+| `hydrogen_build_rust` | Just `cargo build` (src-tauri/). **v1.8.52+:** dispatch response also carries `builtSha` + `exeMtimeBefore` + `exeSizeBefore` (captured at spawn time into the temp dir's `hydrogen-build-meta.json`) so the subsequent `hydrogen_build_status` / `hydrogen_launch` don't need a separate `git rev-parse` + `stat` call. Returns `{ok, pid, logPath, flavor:'rust', show, lingerSecs, builtSha, exeMtimeBefore, exeSizeBefore, _hint}`. |++##### `hydrogen_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 the adom-hydrogen binary was held+  holderPids:[int],                    # PIDs holding the binary - kill these+  builtSha:string|null,                # SHA hydrogen_build_rust/hydrogen_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 `hydrogen_ship` with `clean:true` to force a fresh build.++**`exeLocked:true` on failure** + `holderPids` tells you exactly which PIDs to kill before retry; or just use `hydrogen_ship` which kills HD before building.++##### `hydrogen_launch` - start the debug binary (v1.8.52+ enriched)++Args: `killExisting` - optional bool, default `false`. When `false` and HD is already running, refuses with `reason:'already_running'` so you don't accidentally attach to a stale process. When `true`, kill the existing instance with a 600 ms grace period before launching fresh.++Returns on launch: `{ok:true, launched:true, wasAlreadyRunning:bool, killedFirst:bool, pid:int (HD's actual PID, not the cmd-wrapper), exePath, builtSha:string|null, _hint}`.++Returns on refusal: `{ok:false, launched:false, wasAlreadyRunning:bool, pid:int|null, killedFirst:false, reason, _hint}`. `reason` ∈:+- `build_in_progress` - wait for `hydrogen_build_status.building:false`+- `build_failed` - fix and retry `hydrogen_build` (or use `hydrogen_ship`)+- `already_running` - pass `killExisting:true` to force-kill+relaunch, or use `hydrogen_restart`+- `binary_missing` - the `target/debug/adom-hydrogen` binary not on disk; run `hydrogen_build_rust` (or `hydrogen_ship`) first++**`launched:true, wasAlreadyRunning:false, killedFirst:false` is the proof you need that truly fresh code is running.** Any other combo means you should compare `builtSha` to HD's actual `/buildinfo.build_sha`.++##### `hydrogen_stop` / `hydrogen_restart` / `hydrogen_build_log` / `hydrogen_build_tail`++| Verb | What |+|---|---|+| `hydrogen_stop` | Force-kills the adom-hydrogen process. `{wasRunning}` distinguishes killed vs no-op. |+| `hydrogen_restart` | Stop + launch in one call. Same guards as `hydrogen_launch` (skip `already_running`). |+| `hydrogen_build_log` | Full log dump from the temp dir's `hydrogen-build.log`. |+| `hydrogen_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 terminal 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 `hydrogen_launch` until `hydrogen_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 (`hydrogen_build_log` to see errors, fix, rebuild). Same for `hydrogen_restart`.++#### Named-region screenshots - `hydrogen_shot` (v1.8.54+)++**One verb replaces the `desktop_list_windows | grep 'Hydrogen' → desktop_screenshot_window <hwnd>` chain** (3 calls + fragile HWND parsing). Captures a named region of HD, produces BOTH a full-res PNG and a Claude-safe-resized copy, and returns rich metadata so the AI never has to chain screenshot → pull → convert → crop → resize → Read manually.++Each shot is **self-identifying**: it includes the running build's SHA + local build time read from HD's `/buildinfo` so you can confirm you're looking at the build you expect before trusting what you see (no more "this screenshot was from the previous binary, not my fix" confusion).++Regions are deterministic - they resolve via HD window bounds + CDP element rects queried through HD's `/eval` endpoint. No AI-supplied pixel coords.++Args:+- `region` - **REQUIRED**. One of:+  - `full` - whole HD window+  - `titlebar` - top ~40 px strip (HD's build SHA / timestamp readout lives here)+  - `vscode` - VS Code iframe pane via CDP element rect+  - `setup-panel` - Setup Steps panel (`.setup-panel` / `.setup-panel-output`)+  - `wiki` - wiki / browser tab pane+  - `claude` - Claude Code panel+- `resizeMax` - optional int, default `1400`. Longest side of the safe-resized copy. Use ≈800 to fit more shots in one Claude turn; 1568 for max fidelity.++Returns:+```+{+  ok, region,+  fullPath,                    # lossless raw PNG - for shotlog/archival, NOT Read+  safePath,                    # ≤resizeMax PNG - pull_file this, Read this+  fullSize:[w,h], safeSize:[w,h],+  clipRect:[x,y,w,h],          # captured-image pixels (NOT screen pixels)+  capturedAtLocal,+  runningBuildSha,             # from HD's /buildinfo - null if HD doesn't expose it yet+  runningBuildLocal,+  hdWindowFound:bool, regionFound:bool,+  windowOuter:[w,h], capturedSize:[w,h],+  _hints: {+    pull,                      # exact pull_file command for safePath+    fullForArchive,            # note that fullPath is for archive, not Read+    staleWindow,               # set when runningBuildSha looks wrong+    regionMiss                 # set when regionFound:false+  }+}+```++Recipe - verify-which-build-is-running + confirm-config-change-took-effect + full dump:++```bash+# After hydrogen_ship, confirm the titlebar shows the SHA you expect:+adom-bridge hydrogen_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 hydrogen_shot '{"region":"setup-panel","resizeMax":1568}'++# Full-window dump for general debugging:+adom-bridge hydrogen_shot '{"region":"full"}'+```++Edge cases:+- **Unknown region** - refusal: `{ok:false, error, knownRegions:[...]}`. No fallback - fix the call.+- **`regionFound:false`** - the element rect wasn't in HD's DOM. The panel/iframe may not be mounted yet, OR HD's markup changed and `region_selectors()` in `hydrogen_bridge.rs` is out of date. The shot is **still produced** (falls back to the full window) so you have something to look at; check `_hints.regionMiss` for the reason.+- **`runningBuildSha:null`** - HD's `/buildinfo` isn't exposed yet on the running version. The shot is still produced; you just can't self-verify which build it represents.++**Always pull_file the `safePath` and Read THAT** - it's already sized for Claude's image analysis. The `fullPath` is for shotlog / disk archival, NOT direct Read (may exceed Claude's per-image budget). The `_hints.pull` field gives you the exact `pull_file` command to copy-paste.++**Old workaround to drop:** Do NOT use `desktop_list_windows | filter 'Hydrogen' | desktop_screenshot_window <hwnd>` anymore for HD - `hydrogen_shot` replaces that entire pattern. Generic `desktop_screenshot_window` still applies to non-HD windows (other apps, dialogs, etc.).++### TCP Port-forward Tunnels - `tunnel open|close|list|check|rdp` + `display` (v1.8.85+, reworked v1.8.147)++Reach a TCP service addressable only from the **cloud side** through the **Mac side**: Bridge binds a `127.0.0.1:<port>` listener on the Mac and pumps bytes over the existing Bridge↔relay WebSocket; the relay dials the upstream `to`. First use case: RDP into a Windows VM behind an NSG only the cloud container reaches - the RDP client on the Mac 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 the RDP client, sized to the Mac'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 Bridge is connected.** v1.8.147 fixed a false-success bug: the bind, the upstream probe, and the client launch now ALL resolve to the SAME machine. No target + multiple Bridges → `ambiguous_target` listing the names; `--target all` is rejected (a tunnel binds one machine). `adom-bridge targets` lists connected Bridges.++**Active health verification - every `open`/`rdp`/`check` PROVES the path before reporting `ok`:**+- **local-bind** - Bridge self-probes `127.0.0.1:<boundPort>` right after binding. If it can't connect, Bridge 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`, `clientAlive`.++**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 Mac's real screen (queried via `display`), writes an `.rdp` file with the geometry, and opens it with the user's RDP client (Microsoft's Windows App / Remote Desktop). e.g. `size:"90%"` on a 2560x1600 primary → a 2304x1440 window. Default 90% of primary. The response carries `clientAlive` (did the client stay up past launch). 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 Bridge'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 the RDP client expects).+- Each fresh laptop-side TCP connection gets its own connId multiplexed over one tunnelId; concurrent client reconnects / parallel sessions work without re-opening. Backpressure bounded (256-slot mpsc/conn, 64 KiB buf).+- Tunnels don't survive Bridge/relay restarts. If the client disconnects, `tunnel check` (names the dead leg), then re-open.+- The default local port is `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 the RDP resolution - pass `size`/`monitor`; Bridge resolves against the real screen.++### Shell - `shell_execute` (escape hatch only)++- `shell_execute` -- Run a shell command on the desktop. The CLI handles approval polling internally - it returns the final `{success, output, error, exitCode}` once the user clicks Allow on the desktop dialog. **You do not need to poll, retry, or call `get_deferred_result` yourself.** The CLI emits `HINT:` lines to stderr while waiting (every 15s) so an AI Monitor sees progress.+- `shell_kill_all` -- Kill all running shell commands and deny pending approvals.++**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 shell 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 `python3` for Python -- a bare `python` may not exist on macOS.++#### `run_script` - base64-payload script runner (v1.8.46+)++When you want to send a multi-line script to the Mac without the shell-quoting hell that `shell_execute` requires, use `run_script`. The script crosses the wire as base64 so NO escaping survives to be mangled by any shell layer. The caller never hand-escapes anything.++It requires shell auto-approve to be active - same trust level as `shell_execute`. It refuses cleanly with an actionable error if not approved; no per-call modal (it is a programmatic verb).++**Reading the result - `status` + `durationMs` (v1.8.162+).** EVERY CLI result 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 (`launch_process`) |+| `busy` | desktop couldn't service it (no client connected) | check `adom-bridge status`; 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 `launch_process` (detached, returns instantly) and poll.++**`run_script`** - base64 script + interpreter (`bash` default | `zsh` | `sh`):++```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` (`bash` (default, /bin/bash 3.2) | `zsh` (the macOS login shell) | `sh` (POSIX)), `scriptB64` (required), `timeoutSeconds` (default 60).++The decoded script bytes are piped to the interpreter's **stdin** (no temp file); the interpreter reads and executes without ever opening a file, so nested quotes, `$`, backslashes, newlines and pipes all survive verbatim.++## 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"`, `errorCode: "kicad_not_installed"`, or `errorCode: "fusion_not_installed"`, 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": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",+#         "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.+- **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).++**Node.js not installed (the puppeteer bridge can't auto-spawn):**++Rare on this build: Bridge manages its own portable Node runtime (fetched on first launch, no admin rights). If a `browser_*` command ever returns `errorCode:"node_not_found"`, trigger the managed install:++```bash+adom-bridge desktop_install_node '{}'+# → fetches the managed portable Node runtime (~40 MB, ~30-90s; idempotent).+# → returns {ok:true, installed:true, source:"managed-portable", nodePath, _hint} on success.+```++After install, **just retry the original `browser_*` command** - the bridge auto-spawns with the managed node. Node detection also honors a system Node on PATH and Homebrew installs (`/opt/homebrew/bin`, `/usr/local/bin`). If detection misses a real install on a user's machine, get the actual `node` path from the user - that's a bug to file.++**KiCad not installed:**++`desktop_install_kicad` is not available on macOS (it was a winget verb). Install it FOR the user instead of pointing them at a download page: with shell approval granted, run `brew install --cask kicad` via `shell_execute` (or download the macOS dmg from https://www.kicad.org/download/macos/ and open it for them). Then call `kicad_list_versions` to verify - the kicad bridge caches detection results, so a fresh scan may be needed to pick up a fresh install.++**KiCad detection on macOS** scans `/Applications/KiCad/` and `/Applications/` directly. If detection misses a real install, that's a bug to file - get the actual install path from the user.++**Fusion 360 not installed:**+> Fusion 360 isn't installed on your desktop. Would you like to install it?+> Download from: https://www.autodesk.com/products/fusion-360+> It's free for personal/hobby use (requires an Autodesk account).+> Let me know when the install is done and I'll verify the connection.++After install, have them relaunch Adom Bridge (or just wait for the bridge's next scan), then run `adom-bridge status` to verify.++### Handling "not running" errors++When `errorCode: "fusion_not_running"`, launch it programmatically with the first-class startup command:+```bash+adom-bridge fusion_start+# Discovers the Fusion install on the Mac and launches it.+# From Docker: delegates to the bridge on the connected desktop via relay.+# ~15-30s typical. Auto-dismisses startup dialogs.+# Returns {"addinReady": true, "pickerDismissed": true|false, ...}+```++KiCad doesn't need to be running for most commands (the bridge launches it on demand).++### Handling "add-in not installed" errors++When Fusion is running but `addinInstalled: false` or `addinConnected: false`:+> The AdomBridge add-in needs to be installed in Fusion 360. I can install it for you - this lets me control Fusion remotely.++The add-in auto-installs when the Fusion bridge starts and Fusion is detected.++### Handling `fusion_addin_not_responding` errors++When `errorCode: "fusion_addin_not_responding"`, Fusion is running but the AdomBridge add-in isn't answering. Call `fusion_dismiss_blocking_dialogs` FIRST - a modal dialog is the most common cause. If that doesn't help, try `fusion_start` to restart Fusion cleanly. Last resort: user enables add-in manually via UTILITIES > ADD-INS > AdomBridge > Run on Startup + Run.++### Handling `main_thread_busy` errors++When `errorCode: "main_thread_busy"`, the Fusion add-in's main thread is occupied by a long-running command (typically `walk_cloud_tree` or `search_cloud_files`). This applies **across all bridges/sessions** - even if you didn't start the walk, another session might have.++**Do NOT:**+- Retry the failed command - it will block behind the same lock+- Call `fusion_dismiss_blocking_dialogs` - there's no dialog to dismiss, and sending Escape will interrupt the active walk+- Force-kill Fusion - the walk will complete on its own++**Do:**+- Wait for the walk/search to finish. If you started it, you should be using `adom-bridge watch` (see "Live folder progress streaming with `watch`" above) which streams live progress automatically. If another session started it, poll `fusion_addin_status` every 2–5s to check progress.+- Use commands that **don't need the main thread** while waiting:+  - `fusion_addin_status` - check busy state and walk progress+  - `fusion_window_info` - get window HWND, title, dialogs+  - `fusion_screenshot_fusion` - capture what Fusion looks like+  - `fusion_click_fusion` - click in the Fusion window+  - `fusion_send_key` - send keyboard input+  - `fusion_close_window` - close a specific dialog by HWND++The response includes progress info:+```json+{+  "errorCode": "main_thread_busy",+  "busyCommand": "walk_cloud_tree",+  "elapsedSeconds": 42.3,+  "walkProgress": {+    "foldersVisited": 15,+    "filesFound": 87,+    "currentFolder": "Molecules/XRP",+    "queueSize": 8+  },+  "_hint": "Add-in is busy with a long-running command. Do NOT retry..."+}+```++**Stalled walk detection**: If `fusion_addin_status` returns `busy: true` but `walkProgress` is missing and `mainThreadStalled: true`, a modal dialog is blocking the event loop - the walk was dispatched but never started. Call `fusion_dismiss_blocking_dialogs`, then the walk auto-resumes.++### Auto-recovery (`_autoRecovery` field)++When a fusion_* command fails with "not responding" / "not connected", the CLI automatically:+1. Calls `fusion_dismiss_blocking_dialogs` to clear any modal+2. If successful, retries the original command+3. Attaches `_autoRecovery: {action, dismissed[]}` to the retry result++If you see `_autoRecovery` in a response, the command already succeeded after auto-dismissal - no manual intervention needed. The field is informational.++## 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 bridge client connected++1. Confirm the Adom Bridge app is running on the user's Mac (menu-bar item present; no Dock icon is normal)+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 the Mac++`shell_execute` runs via `sh -c` on the Mac. For multi-line work prefer `run_script` (base64, no quoting hell). Use `python3`, not `python`.++## 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 Mac - `wiki_exec` (v1.9.116+)++Bridge bundles the `adom-wiki` CLI inside the app (Contents/Resources), and `wiki_exec` runs it ON THE MAC - 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/Mac drift.++```bash+adom-bridge wiki_exec '{"args":["repo","push","adom/my-board","--files","board.kicad_pcb","board.kicad_sch","-m","rev B"],"cwd":"/Users/me/boards/my-board"}'+```++- `args` = adom-wiki argv (any pillar: repo, pkg, release, discussion). `cwd` = Mac-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: Bridge 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 Bridge 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 Mac.++Note (v1.9.117): Bridge's Settings panel no longer has a KiCad section - bridges own their app-specific config (Bridge stays generic). `kicad_library_paths` / `project_watch` keep working from `~/.adom/config.json` and the `set_project_watch` / `trigger_project_watch` verbs.
uninstall.sh+8−7
@@ -1,12 +1,13 @@ #!/bin/sh # Remove the linked adom-bridge-macos skills from ~/.claude/skills. Does NOT # touch the Bridge app itself (drag Adom Bridge.app to Trash for that).+# 1.9.190: glob-based over BOTH name prefixes so pre-rename installs clean too. set -eu-HERE=$(cd "$(dirname "$0")" && pwd)-DEST="$HOME/.claude/skills"-n=0-for d in "$HERE"/skills/*/; do-    name=$(basename "$d")-    [ -e "$DEST/$name" ] && rm -rf "$DEST/$name" && n=$((n+1))+for base in "$HOME/.claude/skills" "$HOME/.codex/skills"; do+    [ -d "$base" ] || continue+    for d in "$base"/adom-bridge-* "$base"/adom-desktop-*; do+        [ -e "$d" ] || continue+        rm -rf "$d"+    done done-echo "adom-bridge-macos: removed $n skill(s) from $DEST"+echo "adom-bridge-macos: skills removed"