← Commit history

hd -> hydrogen prose; rename docs/adom-hydrogen.md

Kyle Bergstedt ·fe3195a675 ·2mo ago ·parent 4bb3b6a
1 file changed +532−532
skills/adom-desktop-direct-api/SKILL.md+532−532
@@ -1,532 +1,532 @@----
-name: adom-desktop-direct-api
-description: Direct HTTP API on the adom-desktop GUI. Use when authoring a sibling Tauri app (Hydrogen Desktop, future Adom-family apps) on the same Windows machine that needs to send commands into adom-desktop 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 no longer fixed.** As of v1.8.89, AD's direct API binds an OS-assigned ephemeral port whenever the fixed 47200..47209 range is unavailable (Windows HNS / Hyper-V silently reserves chunks of that range on most dev machines — see [PORTS.md](PORTS.md) for the full story). **Sibling apps MUST read the discovery file** `%USERPROFILE%\.adom\direct-api-port` (single-line `host:port`, e.g. `127.0.0.1:55272`) before any hardcoded probe. The legacy 47200..47209 scan stays as a back-compat fallback for older AD installs.
-
-Old: hardcoded `:47200`
-
-Sibling apps running on the same Windows host as adom-desktop can POST JSON commands directly into the running GUI process — no CLI process spawn, no WebSocket relay, no auth-token dance. The endpoint lives inside the GUI 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-desktop <verb>` returns from the CLI.
-
-This skill is for **sibling-app authors** (Hydrogen Desktop's Tauri side, plus any future "Adom-family" desktop app). Docker 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.
-
-Shipped in adom-desktop v1.8.25+. Requires the adom-desktop GUI to be running on the host.
-
-## When to use this (vs. the CLI)
-
-| You are… | Use |
-|---|---|
-| A sibling Tauri app on the same Windows machine as adom-desktop, calling sync verbs like `server_add`, `bridge_list`, `hd_status`, `desktop_screenshot_window` | **Direct API.** One HTTP round-trip, ~5 ms, no process spawn. |
-| Inside Hydrogen Desktop's local Docker container, using the `adom-desktop` CLI | **Just use the CLI.** As of v1.8.27 it auto-detects the direct API at `host.docker.internal:47200` on every invocation and routes there transparently. Zero config in HD's container. The verb returns identical JSON to the cross-machine path. |
-| On the Windows host, running the `adom-desktop.exe` CLI directly | **Same — just use the CLI.** Auto-detects `127.0.0.1:47200` and routes through the direct API. ~60 ms warm per call vs ~150 ms for the relay path. |
-| Docker / Linux / cross-machine caller | The CLI `adom-desktop <verb>` (probe fails, falls through to the wss proxy → relay → GUI WS path — same behavior as v1.8.26 and earlier). |
-| 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 (v1.8.27+)
-
-When the `adom-desktop` 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 (v1.8.31+, fastest path):** `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`). Contents are `host:port` (e.g. `127.0.0.1:47201`) and the CLI tries this first if present. The GUI 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 (v1.8.31+):** 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 <5 ms on Windows loopback.
-- **Connect timeout**: 400 ms per candidate — 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 now 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 (v1.8.31+, ephemeral fallback in v1.8.89+)
-
-The GUI 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 us and the other listener).
-2. **Reuse-bind** with `SO_REUSEADDR` set via socket2 BEFORE bind. On Windows, this lets us claim a port held by a dead PID's zombie LISTEN socket — provided that prior process also used SO_REUSEADDR. (Pre-v1.8.31 zombies are stuck until reboot; v1.8.31+ zombies always release on the next launch.)
-3. **Walk the range** `47200`..`47209` on three categorical errors: `AddrInUse` (10048), `PermissionDenied` (10013 — Windows' code for "can't take over a non-REUSEADDR zombie"), or live-owner skip.
-4. **(NEW in v1.8.89) Ephemeral fallback**: if all of 47200..47209 fail (always WSAEADDRINUSE — which is what happens when HNS / Hyper-V has silently reserved the whole chunk; see [PORTS.md](PORTS.md) for why), bind `127.0.0.1:0` and let the OS pick. Whatever it picks goes to step 5. This case is COMMON on dev machines with Docker Desktop / WSL2 / Hyper-V VMs — the 47200..47299 chunk is reserved invisibly (doesn't show in netstat, doesn't show in excludedportrange, only fails-to-bind reveals it).
-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.
-
-This handles all five practical cases:
-- **Zombie from v1.8.31+ adom-desktop** (was SO_REUSEADDR-bound) — new bind succeeds, takes over the same port.
-- **Zombie from older code OR force-killed third-party** (exclusive bind) — WSAEACCES on bind; walk to next port.
-- **Live owner** (Hydrogen Desktop's bridges, dev instance, anything responding) — probe detects, skip to next port.
-- **Empty port** — bind cleanly.
-- **HNS reserved the whole range** (v1.8.89+) — ephemeral fallback wins.
-
-### Graceful shutdown (v1.8.31+)
-
-When the GUI exits via the tray "Quit" menu (or any path that calls `app.exit(0)`), Tauri's `RunEvent::Exit` 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 zombie sockets on **clean exits**. For **force-kill** scenarios (`taskkill /F`, crash, BSOD), zombies can still exist temporarily — but the v1.8.31+ 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 GUI running" before falling through to the CLI fallback.
-
-```bash
-curl -sf http://127.0.0.1:47200/health
-# → {"ok":true,"service":"adom-desktop"}
-```
-
-Returns 200 + JSON when the GUI is up and the listener bound successfully. Connection refused (or timeout) means: GUI not running OR the port was already taken by something else when the GUI started. The CLI binary's `serve` mode does NOT bind 47200 — only the GUI 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-desktop",
-  "version": "1.8.25",
-  "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-desktop` CLI binary for those.",
-    "envelope": "{\"app\": <namespace>, \"command\": <verb>, \"args\": <args object>}",
-    "responseShape": "Identical to what `adom-desktop <verb>` returns — same `_hint` fields, same `success`/`ok`/`error` keys, same payload structure. The CLI and direct paths converge in `commands::handle_command`."
-  },
-  "wsl": {
-    "wslProcessCount": 18,
-    "wedged": false,
-    "_hint": null
-  },
-  "_hint": "POST /command with {app, command, args}. See https://wiki.adom.inc/adom/adom-desktop for the verb catalog."
-}
-```
-
-The **`wsl` block (v1.8.157+)** surfaces WSL health: `wslProcessCount` is a cheap Toolhelp snapshot of live `wsl.exe`; `wedged:true` (at/above 50) warns that orphaned/hung clients are piling up. When wedged, `_hint` points to `wsl_recover`. If a `wsl_exec`/`run_script` call returns `errorCode:"wsl_wedged"` (refuse-cap at 80), the machine is wedging — call `wsl_recover` (dry-run, then `{confirm:true}`) before retrying, rather than spawning more doomed clients. `wsl_recover` is reachable via `POST /command {app:"desktop", command:"wsl_recover"}` (the `desktop_` prefix is optional).
-
-The `schema` field is the contract version. v1 is the only one shipped. If `schema > 1` ever appears, expect a breaking change in the envelope/response shape and read this skill again.
-
-### `GET /commands` (v1.8.156+)
-
-Verb discovery — what `{app, command}` can a `/command` caller reach? A bridge delegating to AD no longer has to probe verb-by-verb and hit "Unknown desktop command".
-
-```bash
-curl -sf http://127.0.0.1:47200/commands
-```
-
-```json
-{
-  "ok": true,
-  "desktop": {
-    "app": "desktop",
-    "commands": ["list_windows","find_window","screenshot_window","taskbar","flash_window","activity_filter","run_script","process_list", "..."],
-    "note": "POST /command {app:\"desktop\", command}. The desktop_ prefix is OPTIONAL (desktop_taskbar == taskbar). Bad/missing args → the verb's _hint returns the arg schema."
-  },
-  "bridges": [{"name":"native-browser","verbPrefixes":["nbrowser_"],"verbs":["..."],"paused":false}],
-  "cliRequired": ["pull_file","send_files","shell_execute"],
-  "_hint": "Desktop verbs → app:\"desktop\" (prefix optional). Bridge verbs → app:\"dynamic\", command = full verb incl. prefix. cliRequired verbs need the CLI, not this HTTP API."
-}
-```
-
-Pair it with the per-verb schema hints: call a verb with missing args and its `_hint` returns `required`/`optional`/`example`. Between `/commands` (what exists) and the bad-args hint (how to call it), a fresh AI needs no prior knowledge.
-
-### `POST /command`
-
-Dispatches a verb. Body envelope (`app` is OPTIONAL as of v1.9.84 — inferred from the command):
-
-```json
-{
-  "command": "server_add",
-  "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true }
-}
-```
-
-#### Full verb surface + cross-AD (v1.9.84)
-
-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_*`→kicad, `fusion_*`→fusion360, `browser_*`→browser, `hd_*`→hd, `aps_*`→aps, any installed bridge's prefix→dynamic, else desktop). Pass `app` explicitly to override.
-- **Top-level verbs now reachable:** `notify_user` (toast → returns `{action:"displayed"}`), `notify_response`, `targets` (list the OTHER ADs on the relay), `ping` (this AD's liveness+version), plus `bridge_list`/`bridge_info`/`refresh_bridges`/`runtimes`/`status`.
-- **`target` (cross-AD):** add `"target": "<clientName>"` (from `targets`) or `"all"` to route the call to a PEER AD via the relay — e.g. a bridge on a VM notifies the user on their laptop. `"self"`/`"local"`/absent = local. `"attended"` → `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; AD 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; PERMISSION_MODEL.md). A stale/wrong token → 403; absent → 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 toast to the user's laptop while running on a VM:
-```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":"AdomLapper","args":{"title":"Action needed","body":"Approve the Chrome dialog on your laptop"}}'
-```
-
-Plain single-AD 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-desktop. Use server_list to see all connections."
-}
-```
-
-#### Error responses
-
-| HTTP | When | Body shape |
-|---|---|---|
-| `400 Bad Request` | Envelope missing `app` or `command` | `{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 (v1.8.31+)
-
-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 @ ~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 (v1.8.134+) — HD-driven embedded auto-update
-
-AD owns its own updater (fetch → SHA-verify → stage). HD does **not** download or
-install AD's bytes — it reads AD's update state and, at a safe moment, "clicks
-AD's Update button" via `POST /update/apply`. The contract is intentionally tiny
-and is meant to generalize to other HD-bundled children (e.g. the Adom screensaver):
-`{ update_status, apply, busy }`.
-
-### `GET /update/status`
-
-```bash
-curl -sf http://127.0.0.1:47200/update/status
-```
-
-```json
-{
-  "current_version": "1.8.133",
-  "available_version": "1.8.134",
-  "update_ready": true,
-  "state": "staged",
-  "busy": false
-}
-```
-
-| Field | Meaning |
-|---|---|
-| `current_version` | The running AD version. Plain dotted semver; compare component-wise, numeric, never-downgrade. |
-| `available_version` | Latest version AD knows about from its 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 + staged on disk** — appliable with **no further network**. Equivalent to `state == "staged"`. |
-| `state` | One of `idle \| checking \| downloading \| staged \| applying`. |
-| `busy` | `true` while AD is mid-operation it shouldn't be interrupted for — an in-flight screenshot, a file transfer, or a bridge (kicad/fusion/puppeteer) command awaiting its reply. **HD should defer the apply until `busy == false`.** (A screen-share/getDisplayMedia source is reserved for when AD adds that feature.) |
-
-`state` machine: `idle` → `checking` (a manifest check is running) → `downloading`
-(fetching + verifying the installer) → `staged` (verified on disk; `update_ready=true`)
-→ `applying` (apply triggered; AD 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 AD 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 / blocking UI. AD returns this JSON **promptly** (before it
-  restarts), runs the staged installer, then exits cleanly ~1.5 s later.
-- **Comes back in embedded mode automatically.** AD's 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 AD re-enters embedded mode via the marker + the HD-liveness
-  probe. HD's normal adopt (`POST /embedded/enter`) on the next supervisor tick re-asserts it
-  idempotently.
-
-#### Restart handshake — HD MUST suspend respawn for the apply window
-
-Because the apply runs the NSIS installer **over the running AD exe**, Windows' mmap lock on
-the running image means the file replacement only succeeds once AD has fully exited. **If HD's
-supervisor respawns AD during the install window, it re-locks the exe and the replacement
-silently no-ops** (AD comes back on the OLD version). So:
-
-1. `GET /update/status` → wait for `update_ready == true && busy == false`.
-2. `POST /update/apply` → on `{ applying: true }`, **stop respawning AD** (suspend the ~5 s
-   health-respawn) for ~60 s.
-3. AD exits → the staged installer replaces files → relaunches AD (installer POSTINSTALL).
-   AD re-embeds via its marker.
-4. HD polls `GET /update/status` (or `/health`/`/buildinfo`); when AD answers again with
-   `current_version ==` the new version, resume normal supervision + re-adopt
-   (`POST /embedded/enter`).
-
-HD initiated the apply, so HD already knows the restart is intentional — treat AD's
-disappearance in this window as expected, not a crash.
-
-### `POST /update/check`
-
-Force a manifest check now. If a newer version is found, AD 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 AD:** the normal user-facing "Update available → Update" footer button +
-  flow are unchanged. `auto` mode still silently self-updates; `prompt` shows the banner;
-  `off` disables checks (and staging).
-- **Embedded AD:** AD checks + **stages** on its own (so `update_ready` becomes true for HD)
-  but **never auto-applies** — HD 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 adom-desktop's Activity Log panel with a `direct:47200` 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 `localhost` badge attached to WS-relay traffic going through the localhost dev server at `ws://localhost:8865`.
-
-At startup, a single `direct-api` event-tagged entry says "direct-api listening on http://127.0.0.1:47200 (loopback-only). Sibling apps can POST /command without spawning the CLI." If the bind ever fails (port taken), 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.
-- **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: Hydrogen Desktop startup
-
-This is the canonical pattern HD uses to register its container relay with adom-desktop at boot.
-
-### Rust (HD'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_desktop(workspace_name: &str, relay_url: &str) -> Result<(), String> {
-    // 1. Cheap probe — is adom-desktop 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-desktop not running. Either tell the user to launch it,
-        // or shell out to the CLI as the legacy fallback. Don't block
-        // HD startup.
-        log::warn!("adom-desktop 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-desktop registered: name={workspace_name}, url={relay_url}, _hint={}",
-            body.get("_hint").and_then(|v| v.as_str()).unwrap_or("")
-        );
-        Ok(())
-    } else {
-        Err(format!("adom-desktop refused: {body}"))
-    }
-}
-```
-
-### TypeScript (HD's frontend, if it ever calls directly)
-
-```typescript
-async function registerWithAdomDesktop(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-desktop 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 (HD bundling AD, v1.8.42+)
-
-When HD bundles AD, the direct API is the channel for HD's tray menu items and runtime control. HD spawns AD with `--embedded --start-hidden --relay-url ... --session-token ...`, then drives it via these direct-API calls:
-
-| HD action | direct-API envelope |
-|---|---|
-| "Open Adom Desktop" tray menu item | `{app:"desktop", command:"window_show"}` |
-| Hide AD's window again | `{app:"desktop", command:"window_hide"}` |
-| "Connect All" / "Disconnect All" tray items | `{app:"desktop", command:"connect_all"}` / `disconnect_all` |
-| HD's "Quit" menu (cascade-stops AD) | `{app:"desktop", command:"shutdown"}` |
-| HD sign-out propagation | `{app:"desktop", command:"logout"}` |
-| Introspect embedded state | `{app:"desktop", command:"embedded_status"}` |
-| Force permanent shell auto-approve | `{app:"desktop", command:"set_shell_auto_approve", args:{permanent:true}}` |
-
-`embedded_status` returns `{embedded, owner, source, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` — useful for HD's tray to know whether AD already booted embedded or it's running standalone.
-
-```typescript
-// HD's tray-click handler — example
-async function openAdomDesktop() {
-  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: HD typically calls `set_shell_auto_approve {permanent: true}` only as a recovery path — AD already defaults to permanent at embedded boot. Use it when toggling user-controlled preferences (e.g. an HD setting like "Auto-approve AD shell commands ON/OFF").
-
-## 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-desktop <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.
-
-**v1.8.153+: for `app:"desktop"` the `desktop_` prefix is OPTIONAL — POST the public verb name as-is.** A bridge delegating via `ADOM_DIRECT_API_URL/command` can send the exact verb it knows (`desktop_taskbar`, `desktop_flash_window`, `desktop_list_windows`, `desktop_find_window`, `desktop_screenshot_window`, `desktop_record_window_start/stop/status`) OR the stripped form (`taskbar`, …) — both dispatch. (Before 1.8.153 the dispatch arms were unprefixed, so `desktop_taskbar` returned "Unknown desktop command" while `run_script` worked — that asymmetry is gone.)
-
-| `app` value | What goes in `command` | Examples (CLI verb → direct envelope) |
-|---|---|---|
-| `desktop` | public verb name; `desktop_` prefix optional (v1.8.153+) | `desktop_taskbar` → `{app:"desktop", command:"desktop_taskbar"}` (or `"taskbar"`); `server_add` → `{app:"desktop", command:"server_add"}` |
-| `kicad` | **stripped** — drop the `kicad_` prefix | `kicad_open_board` → `{app:"kicad", command:"open_board"}` |
-| `fusion360` | **stripped** — drop the `fusion_` prefix | `fusion_start` → `{app:"fusion360", command:"launch"}` (note: also the verb name shifts internally) |
-| `browser` | **kept** — pup bridge dispatches on the full name | `browser_open_window` → `{app:"browser", command:"browser_open_window"}` |
-| `hd` | **stripped** — drop the `hd_` prefix | `hd_status` → `{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`, `set_shell_auto_approve` | All sync, no prefix stripping. |
-| `hd` | `status`, `eval`, `log`, `screenshot`, `build`, `build_frontend`, `build_rust`, `build_status`, `build_log`, `build_tail`, `launch`, `stop`, `restart` | `build*` returns instantly with `{pid, logPath}`; poll `build_status` or `build_tail` for progress. |
-| `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 GUI is a clean "not available" branch, not a noisy 10-second timeout on every dispatch.
-2. **Per-request timeouts ≥ the verb's natural duration.** Most verbs are sub-second; `hd_build*` returns in <2 s; `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.).
-
-## Version skew
-
-If `GET /status` returns `version < 1.8.25` OR fails entirely OR `schema` is missing, the direct API isn't present (older GUI). Fall back to spawning the CLI binary. The CLI has been the supported integration path since v1.7.x; 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 GUI and gates non-CLI User-Agents. 47200 is direct to the GUI, no auth, loopback-only.
-- **Different process lifecycle.** 8766 needs `adom-desktop serve` running (a separate process). 47200 is inside the GUI — if the GUI is running, 47200 is up.
-- **No risk of breaking Docker.** Docker callers keep using 8766 + the WS proxy. Sibling apps get 47200. 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 Docker callers read)
-- [`src-tauri/src/direct_api.rs`](../src-tauri/src/direct_api.rs) — implementation
-- [Adom Wiki — apps/adom-desktop](https://wiki.adom.inc/adom/adom-desktop) — public verb reference
+---+name: adom-desktop-direct-api+description: Direct HTTP API on the adom-desktop GUI. Use when authoring a sibling Tauri app (Hydrogen Desktop, future Adom-family apps) on the same Windows machine that needs to send commands into adom-desktop 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 no longer fixed.** As of v1.8.89, AD's direct API binds an OS-assigned ephemeral port whenever the fixed 47200..47209 range is unavailable (Windows HNS / Hyper-V silently reserves chunks of that range on most dev machines — see [PORTS.md](PORTS.md) for the full story). **Sibling apps MUST read the discovery file** `%USERPROFILE%\.adom\direct-api-port` (single-line `host:port`, e.g. `127.0.0.1:55272`) before any hardcoded probe. The legacy 47200..47209 scan stays as a back-compat fallback for older AD installs.++Old: hardcoded `:47200`++Sibling apps running on the same Windows host as adom-desktop can POST JSON commands directly into the running GUI process — no CLI process spawn, no WebSocket relay, no auth-token dance. The endpoint lives inside the GUI 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-desktop <verb>` returns from the CLI.++This skill is for **sibling-app authors** (Hydrogen Desktop's Tauri side, plus any future "Adom-family" desktop app). Docker 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.++Shipped in adom-desktop v1.8.25+. Requires the adom-desktop GUI to be running on the host.++## When to use this (vs. the CLI)++| You are… | Use |+|---|---|+| A sibling Tauri app on the same Windows machine as adom-desktop, calling sync verbs like `server_add`, `bridge_list`, `hd_status`, `desktop_screenshot_window` | **Direct API.** One HTTP round-trip, ~5 ms, no process spawn. |+| Inside Hydrogen Desktop's local Docker container, using the `adom-desktop` CLI | **Just use the CLI.** As of v1.8.27 it auto-detects the direct API at `host.docker.internal:47200` on every invocation and routes there transparently. Zero config in Hydrogen's container. The verb returns identical JSON to the cross-machine path. |+| On the Windows host, running the `adom-desktop.exe` CLI directly | **Same — just use the CLI.** Auto-detects `127.0.0.1:47200` and routes through the direct API. ~60 ms warm per call vs ~150 ms for the relay path. |+| Docker / Linux / cross-machine caller | The CLI `adom-desktop <verb>` (probe fails, falls through to the wss proxy → relay → GUI WS path — same behavior as v1.8.26 and earlier). |+| 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 (v1.8.27+)++When the `adom-desktop` 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 (v1.8.31+, fastest path):** `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`). Contents are `host:port` (e.g. `127.0.0.1:47201`) and the CLI tries this first if present. The GUI 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 (v1.8.31+):** 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 <5 ms on Windows loopback.+- **Connect timeout**: 400 ms per candidate — 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 now 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 (v1.8.31+, ephemeral fallback in v1.8.89+)++The GUI 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 us and the other listener).+2. **Reuse-bind** with `SO_REUSEADDR` set via socket2 BEFORE bind. On Windows, this lets us claim a port held by a dead PID's zombie LISTEN socket — provided that prior process also used SO_REUSEADDR. (Pre-v1.8.31 zombies are stuck until reboot; v1.8.31+ zombies always release on the next launch.)+3. **Walk the range** `47200`..`47209` on three categorical errors: `AddrInUse` (10048), `PermissionDenied` (10013 — Windows' code for "can't take over a non-REUSEADDR zombie"), or live-owner skip.+4. **(NEW in v1.8.89) Ephemeral fallback**: if all of 47200..47209 fail (always WSAEADDRINUSE — which is what happens when HNS / Hyper-V has silently reserved the whole chunk; see [PORTS.md](PORTS.md) for why), bind `127.0.0.1:0` and let the OS pick. Whatever it picks goes to step 5. This case is COMMON on dev machines with Docker Desktop / WSL2 / Hyper-V VMs — the 47200..47299 chunk is reserved invisibly (doesn't show in netstat, doesn't show in excludedportrange, only fails-to-bind reveals it).+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.++This handles all five practical cases:+- **Zombie from v1.8.31+ adom-desktop** (was SO_REUSEADDR-bound) — new bind succeeds, takes over the same port.+- **Zombie from older code OR force-killed third-party** (exclusive bind) — WSAEACCES on bind; walk to next port.+- **Live owner** (Hydrogen Desktop's bridges, dev instance, anything responding) — probe detects, skip to next port.+- **Empty port** — bind cleanly.+- **HNS reserved the whole range** (v1.8.89+) — ephemeral fallback wins.++### Graceful shutdown (v1.8.31+)++When the GUI exits via the tray "Quit" menu (or any path that calls `app.exit(0)`), Tauri's `RunEvent::Exit` 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 zombie sockets on **clean exits**. For **force-kill** scenarios (`taskkill /F`, crash, BSOD), zombies can still exist temporarily — but the v1.8.31+ 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 GUI running" before falling through to the CLI fallback.++```bash+curl -sf http://127.0.0.1:47200/health+# → {"ok":true,"service":"adom-desktop"}+```++Returns 200 + JSON when the GUI is up and the listener bound successfully. Connection refused (or timeout) means: GUI not running OR the port was already taken by something else when the GUI started. The CLI binary's `serve` mode does NOT bind 47200 — only the GUI 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-desktop",+  "version": "1.8.25",+  "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-desktop` CLI binary for those.",+    "envelope": "{\"app\": <namespace>, \"command\": <verb>, \"args\": <args object>}",+    "responseShape": "Identical to what `adom-desktop <verb>` returns — same `_hint` fields, same `success`/`ok`/`error` keys, same payload structure. The CLI and direct paths converge in `commands::handle_command`."+  },+  "wsl": {+    "wslProcessCount": 18,+    "wedged": false,+    "_hint": null+  },+  "_hint": "POST /command with {app, command, args}. See https://wiki.adom.inc/adom/adom-desktop for the verb catalog."+}+```++The **`wsl` block (v1.8.157+)** surfaces WSL health: `wslProcessCount` is a cheap Toolhelp snapshot of live `wsl.exe`; `wedged:true` (at/above 50) warns that orphaned/hung clients are piling up. When wedged, `_hint` points to `wsl_recover`. If a `wsl_exec`/`run_script` call returns `errorCode:"wsl_wedged"` (refuse-cap at 80), the machine is wedging — call `wsl_recover` (dry-run, then `{confirm:true}`) before retrying, rather than spawning more doomed clients. `wsl_recover` is reachable via `POST /command {app:"desktop", command:"wsl_recover"}` (the `desktop_` prefix is optional).++The `schema` field is the contract version. v1 is the only one shipped. If `schema > 1` ever appears, expect a breaking change in the envelope/response shape and read this skill again.++### `GET /commands` (v1.8.156+)++Verb discovery — what `{app, command}` can a `/command` caller reach? A bridge delegating to AD no longer has to probe verb-by-verb and hit "Unknown desktop command".++```bash+curl -sf http://127.0.0.1:47200/commands+```++```json+{+  "ok": true,+  "desktop": {+    "app": "desktop",+    "commands": ["list_windows","find_window","screenshot_window","taskbar","flash_window","activity_filter","run_script","process_list", "..."],+    "note": "POST /command {app:\"desktop\", command}. The desktop_ prefix is OPTIONAL (desktop_taskbar == taskbar). Bad/missing args → the verb's _hint returns the arg schema."+  },+  "bridges": [{"name":"native-browser","verbPrefixes":["nbrowser_"],"verbs":["..."],"paused":false}],+  "cliRequired": ["pull_file","send_files","shell_execute"],+  "_hint": "Desktop verbs → app:\"desktop\" (prefix optional). Bridge verbs → app:\"dynamic\", command = full verb incl. prefix. cliRequired verbs need the CLI, not this HTTP API."+}+```++Pair it with the per-verb schema hints: call a verb with missing args and its `_hint` returns `required`/`optional`/`example`. Between `/commands` (what exists) and the bad-args hint (how to call it), a fresh AI needs no prior knowledge.++### `POST /command`++Dispatches a verb. Body envelope (`app` is OPTIONAL as of v1.9.84 — inferred from the command):++```json+{+  "command": "server_add",+  "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true }+}+```++#### Full verb surface + cross-AD (v1.9.84)++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_*`→kicad, `fusion_*`→fusion360, `browser_*`→browser, `hd_*`→hydrogen, `aps_*`→aps, any installed bridge's prefix→dynamic, else desktop). Pass `app` explicitly to override.+- **Top-level verbs now reachable:** `notify_user` (toast → returns `{action:"displayed"}`), `notify_response`, `targets` (list the OTHER ADs on the relay), `ping` (this AD's liveness+version), plus `bridge_list`/`bridge_info`/`refresh_bridges`/`runtimes`/`status`.+- **`target` (cross-AD):** add `"target": "<clientName>"` (from `targets`) or `"all"` to route the call to a PEER AD via the relay — e.g. a bridge on a VM notifies the user on their laptop. `"self"`/`"local"`/absent = local. `"attended"` → `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; AD 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; PERMISSION_MODEL.md). A stale/wrong token → 403; absent → 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 toast to the user's laptop while running on a VM:+```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":"AdomLapper","args":{"title":"Action needed","body":"Approve the Chrome dialog on your laptop"}}'+```++Plain single-AD 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-desktop. Use server_list to see all connections."+}+```++#### Error responses++| HTTP | When | Body shape |+|---|---|---|+| `400 Bad Request` | Envelope missing `app` or `command` | `{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 (v1.8.31+)++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 @ ~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 (v1.8.134+) — HD-driven embedded auto-update++AD owns its own updater (fetch → SHA-verify → stage). Hydrogen does **not** download or+install AD's bytes — it reads AD's update state and, at a safe moment, "clicks+AD's Update button" via `POST /update/apply`. The contract is intentionally tiny+and is meant to generalize to other HD-bundled children (e.g. the Adom screensaver):+`{ update_status, apply, busy }`.++### `GET /update/status`++```bash+curl -sf http://127.0.0.1:47200/update/status+```++```json+{+  "current_version": "1.8.133",+  "available_version": "1.8.134",+  "update_ready": true,+  "state": "staged",+  "busy": false+}+```++| Field | Meaning |+|---|---|+| `current_version` | The running AD version. Plain dotted semver; compare component-wise, numeric, never-downgrade. |+| `available_version` | Latest version AD knows about from its 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 + staged on disk** — appliable with **no further network**. Equivalent to `state == "staged"`. |+| `state` | One of `idle \| checking \| downloading \| staged \| applying`. |+| `busy` | `true` while AD is mid-operation it shouldn't be interrupted for — an in-flight screenshot, a file transfer, or a bridge (kicad/fusion/puppeteer) command awaiting its reply. **Hydrogen should defer the apply until `busy == false`.** (A screen-share/getDisplayMedia source is reserved for when AD adds that feature.) |++`state` machine: `idle` → `checking` (a manifest check is running) → `downloading`+(fetching + verifying the installer) → `staged` (verified on disk; `update_ready=true`)+→ `applying` (apply triggered; AD 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 AD 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 / blocking UI. AD returns this JSON **promptly** (before it+  restarts), runs the staged installer, then exits cleanly ~1.5 s later.+- **Comes back in embedded mode automatically.** AD's 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 AD re-enters embedded mode via the marker + the HD-liveness+  probe. Hydrogen's normal adopt (`POST /embedded/enter`) on the next supervisor tick re-asserts it+  idempotently.++#### Restart handshake — Hydrogen MUST suspend respawn for the apply window++Because the apply runs the NSIS installer **over the running AD exe**, Windows' mmap lock on+the running image means the file replacement only succeeds once AD has fully exited. **If Hydrogen's+supervisor respawns AD during the install window, it re-locks the exe and the replacement+silently no-ops** (AD comes back on the OLD version). So:++1. `GET /update/status` → wait for `update_ready == true && busy == false`.+2. `POST /update/apply` → on `{ applying: true }`, **stop respawning AD** (suspend the ~5 s+   health-respawn) for ~60 s.+3. AD exits → the staged installer replaces files → relaunches AD (installer POSTINSTALL).+   AD re-embeds via its marker.+4. Hydrogen polls `GET /update/status` (or `/health`/`/buildinfo`); when AD answers again with+   `current_version ==` the new version, resume normal supervision + re-adopt+   (`POST /embedded/enter`).++Hydrogen initiated the apply, so Hydrogen already knows the restart is intentional — treat AD's+disappearance in this window as expected, not a crash.++### `POST /update/check`++Force a manifest check now. If a newer version is found, AD 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 AD:** the normal user-facing "Update available → Update" footer button ++  flow are unchanged. `auto` mode still silently self-updates; `prompt` shows the banner;+  `off` disables checks (and staging).+- **Embedded AD:** AD checks + **stages** on its own (so `update_ready` becomes true for Hydrogen)+  but **never auto-applies** — Hydrogen 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 adom-desktop's Activity Log panel with a `direct:47200` badge (the port-explicit "server name" the dispatcher attaches to direct-API calls) plus the verb's normal event tag (`desktop`, `kicad`, `hydrogen`, etc.). This is visually distinct from the `localhost` badge attached to WS-relay traffic going through the localhost dev server at `ws://localhost:8865`.++At startup, a single `direct-api` event-tagged entry says "direct-api listening on http://127.0.0.1:47200 (loopback-only). Sibling apps can POST /command without spawning the CLI." If the bind ever fails (port taken), 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.+- **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: Hydrogen Desktop startup++This is the canonical pattern Hydrogen uses to register its container relay with adom-desktop 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_desktop(workspace_name: &str, relay_url: &str) -> Result<(), String> {+    // 1. Cheap probe — is adom-desktop 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-desktop 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-desktop 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-desktop registered: name={workspace_name}, url={relay_url}, _hint={}",+            body.get("_hint").and_then(|v| v.as_str()).unwrap_or("")+        );+        Ok(())+    } else {+        Err(format!("adom-desktop refused: {body}"))+    }+}+```++### TypeScript (Hydrogen's frontend, if it ever calls directly)++```typescript+async function registerWithAdomDesktop(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-desktop 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 AD, v1.8.42+)++When Hydrogen bundles AD, the direct API is the channel for Hydrogen's tray menu items and runtime control. Hydrogen spawns AD with `--embedded --start-hidden --relay-url ... --session-token ...`, then drives it via these direct-API calls:++| Hydrogen action | direct-API envelope |+|---|---|+| "Open Adom Desktop" tray menu item | `{app:"desktop", command:"window_show"}` |+| Hide AD's window again | `{app:"desktop", command:"window_hide"}` |+| "Connect All" / "Disconnect All" tray items | `{app:"desktop", command:"connect_all"}` / `disconnect_all` |+| Hydrogen's "Quit" menu (cascade-stops AD) | `{app:"desktop", command:"shutdown"}` |+| Hydrogen sign-out propagation | `{app:"desktop", command:"logout"}` |+| Introspect embedded state | `{app:"desktop", command:"embedded_status"}` |+| Force permanent shell auto-approve | `{app:"desktop", command:"set_shell_auto_approve", args:{permanent:true}}` |++`embedded_status` returns `{embedded, owner, source, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` — useful for Hydrogen's tray to know whether AD already booted embedded or it's running standalone.++```typescript+// Hydrogen's tray-click handler — example+async function openAdomDesktop() {+  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: Hydrogen typically calls `set_shell_auto_approve {permanent: true}` only as a recovery path — AD already defaults to permanent at embedded boot. Use it when toggling user-controlled preferences (e.g. an Hydrogen setting like "Auto-approve AD shell commands ON/OFF").++## 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-desktop <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.++**v1.8.153+: for `app:"desktop"` the `desktop_` prefix is OPTIONAL — POST the public verb name as-is.** A bridge delegating via `ADOM_DIRECT_API_URL/command` can send the exact verb it knows (`desktop_taskbar`, `desktop_flash_window`, `desktop_list_windows`, `desktop_find_window`, `desktop_screenshot_window`, `desktop_record_window_start/stop/status`) OR the stripped form (`taskbar`, …) — both dispatch. (Before 1.8.153 the dispatch arms were unprefixed, so `desktop_taskbar` returned "Unknown desktop command" while `run_script` worked — that asymmetry is gone.)++| `app` value | What goes in `command` | Examples (CLI verb → direct envelope) |+|---|---|---|+| `desktop` | public verb name; `desktop_` prefix optional (v1.8.153+) | `desktop_taskbar` → `{app:"desktop", command:"desktop_taskbar"}` (or `"taskbar"`); `server_add` → `{app:"desktop", command:"server_add"}` |+| `kicad` | **stripped** — drop the `kicad_` prefix | `kicad_open_board` → `{app:"kicad", command:"open_board"}` |+| `fusion360` | **stripped** — drop the `fusion_` prefix | `fusion_start` → `{app:"fusion360", command:"launch"}` (note: also the verb name shifts internally) |+| `browser` | **kept** — pup bridge dispatches on the full name | `browser_open_window` → `{app:"browser", command:"browser_open_window"}` |+| `hydrogen` | **stripped** — drop the `hd_` prefix | `hd_status` → `{app:"hydrogen", 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`, `set_shell_auto_approve` | All sync, no prefix stripping. |+| `hydrogen` | `status`, `eval`, `log`, `screenshot`, `build`, `build_frontend`, `build_rust`, `build_status`, `build_log`, `build_tail`, `launch`, `stop`, `restart` | `build*` returns instantly with `{pid, logPath}`; poll `build_status` or `build_tail` for progress. |+| `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 GUI is a clean "not available" branch, not a noisy 10-second timeout on every dispatch.+2. **Per-request timeouts ≥ the verb's natural duration.** Most verbs are sub-second; `hd_build*` returns in <2 s; `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.).++## Version skew++If `GET /status` returns `version < 1.8.25` OR fails entirely OR `schema` is missing, the direct API isn't present (older GUI). Fall back to spawning the CLI binary. The CLI has been the supported integration path since v1.7.x; 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 GUI and gates non-CLI User-Agents. 47200 is direct to the GUI, no auth, loopback-only.+- **Different process lifecycle.** 8766 needs `adom-desktop serve` running (a separate process). 47200 is inside the GUI — if the GUI is running, 47200 is up.+- **No risk of breaking Docker.** Docker callers keep using 8766 + the WS proxy. Sibling apps get 47200. 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 Docker callers read)+- [`src-tauri/src/direct_api.rs`](../src-tauri/src/direct_api.rs) — implementation+- [Adom Wiki — apps/adom-desktop](https://wiki.adom.inc/adom/adom-desktop) — public verb reference