---
name: pup
user-invocable: true
description: "Drive a browser on the user's desktop from the cloud — the Puppeteer 'pup' bridge (pup_* verbs via the adom-bridge-cli CLI). pup drives Chrome for Testing (the automation build: no automation banner, deterministic version, toolbar extension rides the launch — see docs/WHY-CFT.md; installed Chrome/Edge are fallbacks) in a pup-managed DURABLE profile (logins/localStorage persist and are shared across pup windows; {isolated:true} for a throwaway), driven over CDP, in the BACKGROUND so it never disturbs the user; NOT the user's real signed-in profile (that's the Adom extension's nbrowser_* verbs), NOT headless (a real rendered window, so screenshots/recording are true-to-life). Chrome for Testing only as a last resort. Open/close windows + tabs, navigate, screenshot, eval JS, record. This is the START-HERE skill: the mental model + when-to-use + the skill map; deeper topics route to the sub-skills. For 'is it ready' use pup_readiness (not pup_status). Trigger words: pup, puppeteer, browser window, open in pup, open my app in pup, browser screenshot, browser eval, browser reload, visual debug, headful chrome, pup_open_window, pup_screenshot, pup_readiness, pup_prewarm, chrome for testing, open in browser, open my web app, show my app in the browser, preview my app, open the app I just built, view my app, open localhost, show my dev server, pup_login, log into a site in pup, autofill password, saved login, durable profile, isolated window."
---

# Pup — browser control on the user's desktop (start here)

Drive a browser on the user's desktop from a cloud container via the `adom-bridge-cli` CLI. Every verb is a
**Bash command** (NOT an MCP tool):

```bash
adom-bridge-cli --ai-thread "<your thread>" pup_open_window '{"sessionId":"mytask-web","url":"https://example.com"}'
adom-bridge-cli --ai-thread "<your thread>" pup_screenshot  '{"sessionId":"mytask-web"}'
```

## THE CORE GOAL (John's words, so everyone truly understands it)

## "Open my web app in the browser" — the #1 use case, and its one trap

When the user asks to see an app they just built, pup IS the right surface (a real browser window on
their desktop that you can keep driving: reload after fixes, screenshot, eval). The ONE trap: an app
running in a CLOUD container is NOT reachable from the desktop as `localhost:<port>` — pup's Chrome
runs on the DESKTOP. Use the container's public proxy URL instead:

```
https://{container-slug}.adom.cloud/proxy/{port}/
```

Open it background-first (`foreground:false` + the taskbar flash signals the user), give the window
your thread's sessionId, and re-use the SAME window on every subsequent "check it now" (navigate /
reload, don't spawn new windows). Under Hydrogen (local container) `http://localhost:{port}` IS
correct — the trap is cloud-only. pup lints obvious container-local URLs and will warn you.

**Vocabulary + routing (teach the user the words):** call this surface **pup** in conversation
("I opened it in pup") so users learn the name for the AI-driven browser. When the user wants the
page in THEIR OWN browser — their logins, their tabs, a hand-off with no AI driving — that is
**nb** (native browser), a different surface: say so and use nb instead. Displaying inside a
Hydrogen panel next to the editor is **wv**. When in doubt: driving/testing → pup, handing to the
human → nb, docked preview → wv.

**Platform qualifier:** pup requires Bridge (ab). Under **Hydrogen (h)** ab is bundled, so pup is
always available. On **Hydrogen Cloud (hc)** ab may NOT be connected — check `adom-bridge-cli ping`
first; if there's no desktop, show the app in a **wv** panel (the proxy URL works there too) and
offer the Bridge install for real-browser windows.

pup exists to be **an AI-driven browser that can do everything it needs to for you: the best
assistant in the world**. The engine of that goal is the shared durable **adom-you** profile:

- **Surf as adom-you by default.** Every pup window on adom-you contributes every login it makes to
  the shared credential store. The more windows surf as adom-you, the better the username/password
  database gets, and over time pup can automatically sign in to every website it has ever seen.
- **Isolation is the exception, not a style choice.** Pass `isolated:true` (or a separate profile)
  ONLY when the task genuinely needs a clean jar: testing logged-out views, acting as a different
  user, untrusted pages. An isolated window's logins are thrown away: they build nothing.
- The dashboard shows a "surfing as adom-you" chip per window and an adom-you count in the header:
  if your window says "NOT adom-you", have a reason.

### ALWAYS say SAVE (John's rule)

When the browser offers to **save** something during your driving, the answer is **always Save**:
Chrome's "Save address?", "Save password?", "Save card / payment info?", "Update password?", any
"Save this info?" bubble. Every save enriches the adom-you profile so pup can auto-fill it on every
future site, which IS the core goal above. Never click "No thanks" / "Never" / dismiss, unless the
USER explicitly told you not to save that particular thing.

**Usernames and passwords especially (John's emphasis).** Whenever you sign a window into any site,
SAVE the credentials to the adom-you profile: accept every "Save password?" and "Update password?"
bubble, so the shared username/password store grows into the best adom-you profile it can be. A
signed-in session whose password you did NOT save is a missed brick in that database. If you filled
a login via `pup_login`, pass `save:true` (or accept the resulting Chrome save bubble) so the
credential is persisted, not just used once.

- These are Chrome's own bubbles (browser UI, not page DOM), so click them by accessible name in the
  BACKGROUND: `desktop_ui_click {name:"Save"}` (fall back to "Save address"/"Update password" as the
  bubble labels them). No foreground, no focus steal.
- If a save bubble appears and you don't act, Chrome dismisses it on the next navigation and the
  data is lost forever. Treat a visible save prompt as a must-do before you navigate away.
- The ONE exception: an isolated/throwaway window (`isolated:true`) builds nothing, so saving there
  is pointless, skip it.

## The mental model — what pup actually is (read this once)

pup is a small **Node bridge** that Bridge spawns on the user's machine. When you open a window it
does NOT attach to the user's running browser and does NOT run headless. It:

1. **Launches a brand-new process of the browser ALREADY installed** on the machine — **installed Chrome
   first, else Microsoft Edge**. On **Windows** every PC ships Edge, so there's almost always a candidate
   and **no download**. On **macOS** nothing is guaranteed: Safari is not CDP-drivable, and Edge is not
   preinstalled — so on a Mac with no Chrome, pup falls through to downloading Chrome (or cached Chrome
   for Testing). Don't promise the user "no download" off Windows; check `pup_readiness` instead. It
   uses their *browser binary*, never their *profile*.
2. **…with a pup-managed `--user-data-dir` + a CDP debugging port** (`--remote-debugging-port`), driven over
   the Chrome DevTools Protocol. This is a profile **pup owns and persists**, never the user's own signed-in
   OS Chrome/Edge profile. By default every window shares ONE durable profile (`adom-you`), so logins,
   cookies and localStorage **accumulate and are shared across all pup windows** — sign into a site once and
   every pup window is signed in; app state (localStorage) survives between windows. Sharing a profile does
   NOT merge windows: each window is still its own OS window with its own taskbar identity. For a private,
   throwaway window (a fresh-login test, or acting as a different user) pass `{isolated:true}`, or pass an
   explicit `{profile:"<name>"}` for a separate durable jar. (Need the user's REAL signed-in profile/SSO?
   That's the **Adom extension** `nbrowser_*` — see below.)
3. **Renders a REAL window, but keeps it in the BACKGROUND.** It is *not* headless — a real GPU-composited
   window, so `pup_screenshot` / recording are true-to-life and canvas/WebGL/media render correctly.
   But pup immediately z-orders it to the **bottom** (Windows: Win32 `SetWindowPos` with
   `SWP_NOACTIVATE`; macOS: the browser process is **hidden**, the Cmd+H state — same contract), so it
   renders **without stealing focus and without covering the user's active window**. You drive and
   screenshot it fully while it stays hidden. `foreground:true` is the ONLY thing that shows it.
4. **Spawn-verifies + falls through.** The pick order is **Chrome → Edge → cached Chrome for Testing**; pup
   actually launches each and, if one won't start (corrupt install), falls to the next — so an open never
   hard-fails on a broken browser. The one that launches is **cached as the default**. Only a box with *no*
   Chromium at all fetches **Chrome for Testing** (~150 MB, last resort).
5. **Is driven by `sessionId`**, and **one session = one window that holds many tabs**.

Keep this model in mind and every verb makes sense: it's a pup-managed, persistent, backgrounded browser
(the machine's own Chrome/Edge binary, a profile pup owns) driven by CDP — durable and shared by default,
isolated only when you ask.

## Power & rendering — throttling is DEFAULT and correct; `highFps` is the leased override

**Background pup windows are THROTTLED on purpose, to protect the user's CPU + battery.** Chrome
pauses/slows rendering on occluded/unfocused windows, and pup relies on that. This is deliberate —
issue #14 (James): three idle pup windows with the anti-throttle flags on held his laptop out of
deep idle overnight and FLATTENED his battery. So the default is: a background window renders slowly
or not at all until it's focused.

**Consequence you MUST know:** a healthy background window can LOOK frozen — Google Slides stops
animating, a video pauses, the cursor doesn't blink. That is THROTTLING, not a hang. It wakes the
instant the window is focused (`visibilityState` flips to `visible`). Before ever calling a window
"frozen", check: is it just backgrounded? (on-screen + CDP-responsive + `readyState:complete` = it's
throttled, not broken.)

**When you genuinely need a background window to keep rendering** — recording it, or a live view the
user watches WITHOUT focusing it (a dashboard, a running demo) — upgrade that ONE window:
`pup_open_window {highFps:true}` (or the recorder path sets it). This applies the anti-throttle
flags so the occluded window renders full-rate. It is **LEASED**: 20 min by default, 120 max, and it
**auto-downgrades when idle / when the lease lapses**, so it can NEVER drain the battery the way
issue #14 did. Renew a still-needed lease with `pup_highfps_extend {sessionId, minutes}`.

**The balance, in one line:** throttled by default (battery), `highFps` only for a window that must
keep rendering while unfocused, and the lease makes that self-limiting. Do NOT reach for `highFps` to
"fix" a window that merely LOOKS frozen — just focus it (or tell the user to click it).

## Under heavy load or a crash, pup self-heals — do NOT panic-kill or declare it dead

pup is built to stay usable when the shared Chrome is under heavy load, and to recover on its own if
Chrome ever crashes. Read these signals correctly instead of assuming the worst:

- **`_degraded` on `pup_list_windows` / `pup_status`.** These verbs ALWAYS return the full
  window list, fast, even when a window is too busy (Chrome under heavy load on a heavy SPA) to answer
  a live read. A busy window comes back marked `degraded` with its LAST-KNOWN url/title. `_degraded`
  means "that window is busy, NOT crashed — retry shortly for live values." It does NOT mean pup is
  broken. (Before v1.9.172 these verbs could hang and return null under load, which read as "pup is
  frozen" — that can no longer happen.)
- **A window vanished / a `_cleanupReport` says a window crashed.** If the shared Chrome crashes, pup's
  health loop (15s) detects it and RELAUNCHES every affected window on its own — signed in, in the
  background, at its last URL — then reports it via `_cleanupReport` ("pup auto-recovered your …
  window after a Chrome crash"). You do NOT need to kill Chrome, clean the profile, or reopen windows
  by hand. Wait ~15-30s and re-list; the windows come back. Only if a page crashes Chrome REPEATEDLY
  does pup stop retrying it (after 2 attempts) and tell the user the page itself looks unstable.
- **One window times out but others work = a per-window jam, not a dead bridge.** Verbs are
  concurrent; a slow/jammed window does not block operations on OTHER windows, and never blocks
  list/status. If `pup_eval` on window A times out, window B still works and the bridge is fine.
- **Genuinely frozen vs throttled vs busy:** a background window that looks static is almost always
  just THROTTLED (see Power & rendering above) — focus it and it wakes. A window that is BUSY shows up
  as `degraded`. A window that CRASHED gets auto-relaunched. None of these is "pup is dead."

## ⛔ HARD RULE: NEVER send NATIVE OS input without warning the user FIRST (John, 2026-07-29)

**Native OS input (`desktop_click`, `desktop_type`, `desktop_press_key`, `desktop_hover`) does NOT go
to a window. It goes to WHATEVER WINDOW HAS OS FOCUS AT THAT INSTANT.** If the user has alt-tabbed
away — and this user runs many AI threads at once and alt-tabs away from a foregrounded window almost
immediately, every time — your keystrokes land in THEIR document, THEIR terminal, THEIR editor.

This happened: an agent foregrounded a pup window, typed a username with `desktop_type` without ever
telling the user, the user had already alt-tabbed back to their own work, and the text went into their
window. The agent then compounded it by concluding "Windows failed to foreground the window" — blaming
the OS for what was really "the human took their focus back, because nobody asked them not to."

**NEVER do that again. Before ANY native input, in this order:**

1. **`notify_user` TOAST** — tell them what you are about to do and that you need the window.
2. **`desktop_caption`** — on-screen, so it is visible while they look at the window.
3. **Say explicitly: "please don't type or click until I say done."** They cannot know otherwise.
4. **WAIT for them to be ready** — `waitForUserReady` (poll `osGetForegroundWindow()` vs the session
   hwnd until it is stable). NEVER dispatch while the foreground is not your window.
5. **Re-verify foreground IMMEDIATELY before EACH native event**, not once at the start. Focus can
   change between two events, and a half-typed password in the wrong window is a real harm.
6. **Caption each step** as you go, and caption when you are DONE so they know they can resume.

**`pup_assisted_click` already implements all six.** Use it instead of hand-rolling
raise-then-click. If you need native typing, follow the same protocol around it.

**Corollaries that cost this project real time:**
- CDP input (`pup_click`, `pup_type`) needs NO foreground and disturbs nobody — **always
  prefer it**. Reach for native input ONLY when the target genuinely cannot be driven any other way
  (a cross-origin FedCM chooser, native OS chrome, a site that rejects synthetic events).
- Native input returning `ok:true` means "the event was dispatched to the OS", **NOT** "it reached
  your window". Never treat that ok as proof. Verify the effect in the page.
- If native input seems not to land, the FIRST hypothesis is "it went to another window because
  focus moved", **never** "the OS failed to foreground my window".

## Standing rule: TOAST THE USER before foregrounding (John, repeatedly)

NEVER put a window on the user's screen silently. Any time you FOREGROUND a pup window — a demo, a
"show me", a reveal, a real-cursor click that needs the window up — send an AD toast FIRST (or let
pup's auto-toast do it, v1.9.166+) so the user's attention is drawn to what just appeared. A window
that pops up with no heads-up reads as the agent hijacking the screen. pup now auto-fires
`notify_user` on every granted foreground, but you should still narrate WHY in the reason. For a
background nudge (not a full foreground), use `pup_alert_window` (taskbar flash) instead.

**If you foreground in order to CLICK something pup's CDP click can't drive** (a native OS dialog, or
a cross-origin FedCM "Sign in with Google" account chooser that rejects in-page clicks), use
**`pup_assisted_click`** — never raise-then-click by hand. It captions the reason + toasts first,
foregrounds, then WAITS and detects if the user alt-tabbed away (holding, captioning "Adom is waiting"
until they're back — it clicks NOTHING while they're not ready), then does the tight real-cursor
hover→click and narrates each step. This is the protocol so pup never foregrounds-and-acts while the
user is not ready.

## When pup — vs the Adom extension (`nbrowser_*`)

| The task | Tool |
|---|---|
| Screenshot / scrape / test / demo a page; drive an app you're building; anything that does NOT need the user's logins | **pup (this)** |
| Log into a site, use saved passwords, fill a form on a signed-in page, download behind a login, sail past captchas | **Adom browser extension** (`nbrowser_*`, the `adom-browser-extension` skill) — drives their REAL signed-in Chrome/Edge |

pup drives the machine's browser binary in a profile **pup manages** — durable and shared across pup windows,
but never the user's own signed-in OS Chrome/Edge profile, so it can't borrow logins the user already has
there. pup accumulates its OWN logins over time (sign in once inside pup, it sticks). The extension is the
counterpart when you need the user's REAL profile/SSO directly.

## Logins & credentials (the durable profile pays off here)

- Sign into a site ONCE in any pup window and the durable profile keeps it — every later window
  (any thread) is already signed in. Google/wiki proven; works for any cookie-session site.
- `pup_login {sessionId, username?, password?, submit?, save?}` fills an HTML login form in
  the BACKGROUND (no Chrome dropdown, no foregrounding, React/Vue-safe). With `save:true` the
  cred is stored (OS keychain via credential_set) and next time a bare
  `pup_login {sessionId, submit:true}` signs in from the vault.
- Vault-matched login pages AUTO-FILL on page load (fill only, never auto-submit) — response
  carries `_autoLogin`. Off switch: `pup_configure {autoLogin:"off"}`.
- Chrome's own "Save password?" bubble works in pup windows (the durable profile enables the
  password manager), so manual logins accumulate too.
- Need a window that is NOT signed in (fresh-login test, act-as-another-user)? `{isolated:true}`
  on open. Need cross-origin eval for dev? `{webSecurity:false}` (forces isolation, keeps the
  credentialed profile safe).

## Skill map — this is a pack

| Skill | Read it for |
|---|---|
| **`pup`** (this file) | The mental model, when-to-use, quick start, the essentials |
| **`pup-windows-sessions-tabs`** | sessionIds + naming, **window ownership** (don't steal another AI thread's window), **background vs foreground** rules, **many tabs in one window**, list/switch/close, verifying what you opened |
| **`pup-screenshots-recording`** | Screenshots (full-page, full-res), reading the shot, **recording** a window vs the whole desktop, framing a window for a clean shot without foregrounding |
| **`pup-browsers-and-chrome`** | The browser detection/CDP-launch details, `pup_use` to pin a browser, **installing Chrome / Chrome-for-Testing** (incl. the UAC-notify flow), **readiness**, the cold-start error table, Node provisioning, the shell-approval gate |
| **`pup-adom-wiki`** | Driving + verifying **`wiki.adom.inc`** pages in pup (show/screenshot/verify a published page; the login situation — pup views the wiki logged out for now) |

*(Maintaining the bridge itself? The `pup-bridge-dev` + `pup-bridge-publish` skills are source-only in the
repo — not shipped here.)*

## Quick start — the 90% path

```bash
# Open in the BACKGROUND (automatic — never disturbs the user), then drive it invisibly.
adom-bridge-cli --ai-thread "<your thread>" pup_open_window '{"sessionId":"mytask-web","url":"http://localhost:3000"}'
adom-bridge-cli --ai-thread "<your thread>" pup_eval        '{"sessionId":"mytask-web","expr":"document.title"}'   # verify it really loaded
adom-bridge-cli --ai-thread "<your thread>" pup_screenshot  '{"sessionId":"mytask-web"}'                            # true-to-life PNG
```

- **Your `--ai-thread` name IS the owner** — pup owns every window by the ai-thread that opened it, so
  just always pass `--ai-thread "<your conversation name>"` (you already must — see below) and the window
  is yours; no separate `owner` arg needed (it's a legacy alias). A `pup_open_window` with no
  ai-thread (and no `owner`) is **refused** with `caller_identity_required`. Task-prefix your `sessionId`
  (it's the window's KEY, never its identity) so two of your own windows don't collide. See
  `pup-windows-sessions-tabs`.
- **`ok:true` means navigation *started*, not that content rendered** — sanity-check with `pup_eval`
  on `document.title` (404s / login walls change it) or a screenshot.
- **On a fresh PC it's usually a non-event** — pup drives the installed Chrome/Edge with no download. The
  rare "no browser at all" / Node-provisioning / disk cases are all in `pup-browsers-and-chrome`.

## macOS differences (v1.9.74+) — same verbs, mac-native mechanics

Every `pup_*` verb works on macOS; only the OS-presentation layer differs:

- **Background = hidden app.** A background open launches the session's browser process hidden
  (the Cmd+H state) — truly invisible, still fully drivable/screenshottable/recordable.
  `pup_lower_os_window` re-hides; `pup_raise_os_window` (with `foregroundReason`)
  unhides + activates. One session = one browser process, so a shared-profile window group
  hides/shows together.
- **`pup_alert_window` = un-hide (not focused) + a native toast** whose click focuses the
  window — macOS has no taskbar to flash. `{stop:true}` dismisses the toast.
- **No taskbar identity on mac** — the per-window icons/badges/jump-lists below are
  Windows-only; on mac the window title's `(session: <id>)` suffix is the identity.
- **Recording** needs ffmpeg (`brew install ffmpeg` — the error hint says so; re-detects
  without a restart).

## Taskbar preference — the USER can ask for "combined mode"

> **WINDOWS ONLY.** macOS has no taskbar; `pup_configure` stores the preference and returns
> `taskbarSupported:false`, but nothing changes on screen. Don't propose it to a Mac user.

pup windows each get their own taskbar button by default ("split": one button per window, badged
with its tab count). If the user says anything like "combine my pup windows", "group the pup
icons", "one taskbar button for pup", "my taskbar is cluttered with pup windows" — that's the
GROUPED preference: `pup_configure {"taskbarGrouping":"grouped"}` stacks every pup window
under one "Adom Pup" button whose badge shows windows + total tabs. "Split them back" / "separate
icons" → `{"taskbarGrouping":"split"}`. The setting persists across updates. Change it ONLY when
the user asks — it's their taskbar. Details: `pup-windows-sessions-tabs`.

## Window-type taskbar icons (split mode) — what the user sees per window

> **WINDOWS ONLY.** Everything in this section is a Windows taskbar behaviour. **macOS has no
> taskbar**, no per-window tile, and no jump list, so on a Mac none of these icons, overlays or
> right-click affordances exist. The category signal that DOES survive cross-platform is the **tab
> title** (solid dot = signed in, hollow dot = public). On macOS, use `pup_list_windows` /
> `pup_list_tabs` to tell windows apart — do not refer the user to an icon they cannot see,
> and do not skip a check on the assumption an icon already answered it.

In split mode every pup window's taskbar icon states its CONTENT KIND. All five share the teal
Adom tile with a solid browser-window drawing; the glyph inside the window is the category:

| Glyph inside the window | Category |
|---|---|
| open book | Adom wiki, PUBLIC view (logged out) |
| book + person | Adom wiki, SIGNED IN (the user's logged-in view) |
| the Adom mark | an Adom APP (localhost / cloud-slug proxy URL) |
| globe | the web (any other site) |
| two panes | MIXED (tabs span more than one category) |

The category derives from ALL tabs and updates live (about a second). The corner overlay stays the
ACTIVE TAB'S FAVICON (or window/tab counters in grouped mode) — never repurpose it. Adom wiki tab
titles also lead with a text glyph: solid dot = signed in, hollow dot = public. You never manage
any of this; pup does it automatically. It is worth KNOWING because **on Windows** the user can see
login state and window kind at a glance — there, do not open a second window to "check" what a
taskbar icon already answers, and when the user says "the window with the book/globe icon" you know
which one they mean. **On macOS neither shortcut applies**: there is no icon to read, so check with
`pup_list_tabs` rather than assuming, and expect the user to describe a window by its title or
URL rather than by an icon.

A WIKI window's taskbar right-click jump list also carries a view toggle ("Adom wiki: switch to
logged-in view" / "...to public view") — a USER affordance that relaunches that window under the
other cookie jar with on-screen captions. Its callback verb `pup_wiki_set_view` is not for AI
threads; pass `wikiView:"authed"|"public"` on pup_open_window instead. Details: `pup-adom-wiki`.

## Key rules (the essentials — details in the sub-skills)

- **Always pass `sessionId`** on session-scoped verbs — `'{}'` targets the *active* session, which may not
  be the one you mean. There is no top-level `pup` subcommand; it's `adom-bridge-cli pup_<cmd> <json>`.
  **ONE DANGEROUS EXCEPTION: `pup_close`.** There `'{}'` does NOT mean "the active session" — it means
  **ALL sessions, every window, across every AI thread on the machine**, immediately and without
  confirmation. If you want to close one window it is `pup_close_window {"sessionId":"…"}`, never
  `pup_close '{}'`. The rule above would otherwise lead you to expect the opposite, which is exactly
  how a tidy-up call becomes a data-loss event on someone's long-running dashboards.
- **Background is guaranteed; `foreground:true` is the only way to show a window** — not sizing, not
  navigating, not screenshotting.
- **Own your window** — always pass `--ai-thread "<your conversation name>"`; that name is the window's
  owner (the `owner:` arg is a legacy alias). Task-prefix your `sessionId` (the window key, not an
  identity). Never navigate/close a window you don't own.
- **One window, many tabs** — use `pup_open_tab`, not a second `pup_open_window`, for multi-URL work.
- **`pup_readiness`** (not `pup_status`) answers "is it ready / which browser?".
- **No semicolons in `pup_eval`** — use the comma operator or an IIFE.
- **Complex page fighting you?** (a `querySelectorAll` comes back empty, or a screenshot overshoots the
  target) — it's usually shadow DOM or a nested scroll container. Verify by DOM, scroll the *element* not
  the window, count before you eyeball. See `pup-screenshots-recording` → "Driving & verifying tricky pages."
- **Never broadly kill Chrome** — `pup_close_window` for a specific session.
- **Surface a verb's `_hint` verbatim** whenever it reports not-ready or failure.
- **If pup's verbs TIME OUT (or the AD LED is dim / `browser.bridgeRunning:false`) the bridge PROCESS is
  down** — usually right after a `restart_bridge` or a crash. To wake it, fire **one**
  `pup_open_window` (a status probe like `pup_readiness` does NOT respawn a down bridge — only a
  window/tab verb does), then poll `pup_list_windows` with backoff until it answers. **Do NOT spam
  opens** while AD says `bridge_starting` — one is enough; it's already starting. (`bridge_list`
  `status:"running"` is not liveness; the LED + `browser.bridgeRunning` + an actual verb reply are.)
- Full machine-readable catalog: `adom-bridge-cli --ai-thread "<your thread>" pup_describe '{}'`.

## Verify what you rendered — pup checks it FOR you (v1.9.106)

**`ok:true` means the navigation worked. It says NOTHING about whether the page rendered.** A thread
recently opened its own app, got `ok:true`, and told the user it worked. The page was serving
`404 page not found`.

So pup now looks. Every `pup_open_window` / `pup_open_tab` / `pup_navigate` /
`pup_reload` response can carry two fields:

- **`renderCheck`** — a verdict: `{ok, httpStatus, signals[], excerpt, bodyChars, elements}`. Signals
  include `http_404`, `page_says_not_found`, `dev_server_cannot_get`, `server_error_text`,
  `stack_trace_visible`, `effectively_blank`, `no_stylesheets_loaded`, `broken_images_N`.
- **`_verifyRender`** — what to do. When `renderCheck.ok` is false it leads with
  *"THIS PAGE DID NOT RENDER CORRECTLY — pup checked it, so do NOT report success"*.

**The rule: if `renderCheck.ok === false`, do not tell the user it works.** Screenshot it
(`pup_screenshot {sessionId}`) and READ the image, fix the cause, `pup_reload`, and re-check
until the response comes back clean. A 404 or "Cannot GET" usually means the dev server is not running,
is on a different port, or the path is wrong; unstyled HTML means the CSS 404'd; a stack trace means the
app threw.

`renderCheck` is a cheap DOM probe, not a screenshot, so it can miss things a human eye catches (wrong
layout, wrong content, an element off-screen). A clean `renderCheck` is not proof the page is *right* —
when you generated or edited the content, screenshot it anyway.

## Name your thread so the user can tell your window apart

Pass `--ai-thread "<what this conversation is>"` on every `adom-bridge-cli` call (a global flag, like
`--target`). A typical user runs ~20 AI threads against one desktop, so pup puts your thread name at the
FRONT of the window title (visible in the taskbar hover-preview header, which truncates ~25-30 chars)
and in the hover tooltip alongside the container, browser and session. That is how the user knows which
of their conversations opened a window. Do NOT export `ADOM_AI_THREAD` — threads share one container, so
it names the shell rather than you.

## Found a pup bug? Please report it

Responses carry `_reportIssues` on failures and on your first substantive verb. Reports from real
machines are the only way this bridge gets correct:
`adom-wiki issue list adom/pup-bridge` to check for a duplicate, then
`adom-wiki issue create adom/pup-bridge --title "<symptom>" --category bug-report
--body "<what you ran, expected, got, the VERBATIM response JSON, bridge version, OS>"`.
An AD-core problem (relay, bridge lifecycle, caller identity) goes to `adom/adom-bridge` instead.

## Every adom-bridge-cli call needs `--ai-thread`

AD refuses any command that does not say which AI conversation is asking
(`errorCode: caller_identity_required`). It is a **global flag**, like `--target`, and it goes before
the verb:

```bash
adom-bridge-cli --ai-thread "chip-fetcher tab 3" pup_open_window '{"sessionId":"cf-web","url":"..."}'
```

Pick a name the USER would recognise for this conversation, never a UUID. It is **attribution, not
permission** — self-reported, unverifiable, and nothing is gated on it. It exists so a user running ~20
threads can see which tab is driving their machine, and so a pup window can be labelled with the thread
that owns it.

**Do NOT `export ADOM_AI_THREAD`** — threads share one container and one `$HOME`, so it names the shell
rather than you: every thread reports the same name, which is worse than none because it is confident
and wrong. Pass it per call (or `{"caller":{"aiThread":"..."}}` in the args).

## AUDIT YOURSELF — the standard procedure when John says "audit your windows" (2026-07-30)

**"Audit your windows" ALWAYS includes the ICONS.** John had to tell me this twice: I reported
sessions, health, geometry and logs and left the taskbar out, and the icon state was where the real
regressions were hiding (a window that silently lost its badge after a restart; 8 stale AUMID keys for
3 windows). An audit without the icon section is not an audit — and it is not finished until you have
looked at the taskbar ON GLASS, because the API reports a badge as applied while it is invisible.

Run ALL SEVEN sections, in this order, and report them as a table:

1. **Sessions (bridge view)** — `pup_list_windows`: count, sessionId, owner, tabCount, profile,
   ageMinutes, `_degraded`. Flag: owner:null (ownership protection is off), duplicate sessions
   pointing at one window, a tabCount that does not match what was opened.
2. **ICONS / TASKBAR (never skip)**
   - per window: the `overlay` field — `favicon+adom:<host>` (correct), `count:N`, or `none` **plus
     its reason**. A `none` on a favicon-serving page is a regression; a `none` on local PDFs is
     legitimate (no favicon exists).
   - AUMID keys: `reg query HKCU\Software\Classes\AppUserModelId` filtered to `Adom.Pup*`.
     Expect **exactly 1** (`Adom.Pup`) under minimalTouch. More = stale keys from closed windows, or
     per-window identity churn that destroys/recreates taskbar buttons.
   - **ON GLASS:** `desktop_screenshot_screen`, crop the taskbar strip, and LOOK. Confirm each pup
     button wears the composite badge (page favicon + Adom mark inset flush in the lower-right).
     Report how many of N windows are actually badged.
3. **OS ground truth** — `desktop_find_window {titleContains:"(session:"}`: one real window per
   session (no ghosts, no untracked strays), and each rect at the standard inset frame
   (15,15 2531x1499 on this laptop). A maximized window reads ~-11,-11 2582x1550 — that is
   maximized, not stranded. `-32000,-32000` = minimized, confirm with IsIconic before calling it lost.
4. **Health** — `pup_eval` per window: `readyState`, `visibilityState`, a `1+1` drivability check.
   `visibilityState:hidden` is NORMAL for a parked/minimized window; only **frontmost-yet-hidden** is
   the broken state. Note any crashed tabs.
5. **Log** — `bridge_log_read`, count `[crash] [heal] [health] [unstick] [disconnect] [reclaim]
   [tabs] park-failed Handler error`. Explicitly report **park-failed** and **[reclaim] adopted**
   lines: a park-failed window is sitting wherever Chrome left it, and an adoption means pup LOST a
   window and re-bound it (which produces duplicate sessions for one window).
6. **Chrome footprint** — procs + total MB, and **pup's share vs the user's own Chrome**. Never
   report a big total as if it were all pup's.
7. **Cleanup state** — leftover test windows, phantom sessions, stale session files.

**Report rules:** state what is WRONG first, name which findings are your own regressions, and never
present an API-reported success (badge applied, verb ok:true) as proof — prove it on glass or in the
page.

## ⏱ Captions: NEVER longer than 2 seconds (John, standing rule)

A caption is a **glance**, not a dialog. `desktop_caption` parks text over the user's screen; anything
past ~2s outstays its welcome and covers what they are actually doing. John caught a 15-second one
live and called it out.

- **Hard cap is enforced in code** (`PUP_CAPTION_MAX_MS = 2000` in `pupCaption`), so a caller passing
  5000/8000/15000 is silently clamped. Do not try to route around it.
- **If something genuinely needs longer attention, it is not a caption.** Use `notify_user` (a toast
  the user dismisses on their own terms), or write it into the verb response for the calling AI.
- The one thing captions are *great* for: narrating a foreground/click the instant it happens, e.g.
  the granted-foreground reason flash. Short, factual, gone.

## FOREGROUNDING: always caption the REASON, for 2 seconds (John, standing rule)

Foregrounding a window is a **rude operation**. It takes over the user's screen while they are working,
and this user runs many AI threads at once and alt-tabs straight back to their own work. That is why
pup FORCES the caller to supply a `foregroundReason` before it will grant a raise.

**The reason must be SHOWN, not just recorded.** An audit trail nobody sees is not accountability.

Every granted foreground MUST:

1. Flash an on-screen caption naming the session and quoting the caller's reason, so the user can see
   WHY their screen was taken over, as it happens.
2. Show it for **2 seconds maximum** (see the caption rule: a caption is a glance, not a dialog).
   `PUP_CAPTION_MAX_MS = 2000` enforces the ceiling at the choke point.
3. Never be skipped because a mode is quiet. This is a user-protection signal, NOT cosmetic
   machinery, so it is deliberately NOT gated behind `minimalTouch`.

Implemented by `captionForegroundReason(sessionId, reason, verb)`, called from the granted-raise path.

**If you add a NEW path that foregrounds a window, it must call that function.** A foreground that
appears with no explanation is the exact behaviour this rule exists to prevent, and it has happened:
an agent foregrounded a window silently, the user was mid-task, and the disruption read as pup
misbehaving rather than as an agent choosing to interrupt.

Related rules that travel with this one:
- Native OS input (`desktop_click` / `desktop_type`) needs the full warn-and-wait protocol, not just a
  caption, because it goes to whatever window has focus.
- `pup_assisted_click` already implements toast + caption + wait-for-ready + re-verify. Use it
  instead of hand-rolling raise-then-click.
