name: pup-bridge-dev description: "DEVELOPER skill for building, publishing, and maintaining the Adom Desktop Puppeteer (pup) bridge. NOT needed by general users — they want the pup skill. Read this when editing the bridge code, cutting a new version, publishing to the wiki, wiring the cold-start Chrome-for-Testing self-heal, or understanding the pkg-vs-release-vs-bundled artifact model. Trigger words: pup bridge dev, publish pup bridge, build puppeteer bridge, bridge_install pup, adom-desktop-puppeteer-bridge, cold-start self-heal, browser_readiness internals, pup release, pup manifest, ship pup bridge."

Parent skill: adom-desktop-puppeteer-bridge

pup-bridge-dev — building & shipping the Puppeteer bridge

The canonical source of truth for the pup bridge is this wiki repo (wiki.adom.inc/adom/adom-desktop-puppeteer-bridge), extracted from adom-desktop/plugins/puppeteer on 2026-06-29 to be cloud-owned like the kicad/fusion bridges. Read CLAUDE.md (ownership boundary) and PUBLISHING.md (step-by-step recipe) in this repo alongside this skill.

⚖ OWN YOUR DOMAIN — the capability-vs-policy test (John's rule, and he should never have had to say it)

Before filing ANYTHING against adom-desktop, apply this test:

  • CAPABILITY = a generic OS/native mechanism pup cannot perform itself (z-order set-to-back, window identity stamping with process-lifetime HICONs, taskbar overlay/flash, monitor enumeration). These belong in AD — generic, bridge-agnostic, useful to the next bridge too. Correct asks that followed this test: state:'bottom' + force:true, desktop_flash_window routing, desktop_set_window_identity / desktop_register_app_identity.
  • POLICY = any DECISION about pup's own domain: which browser pup drives, when/whether to provision it, what its windows look like, when they show, what its hints say. These are NEVER AD's business — pup decides, pup implements, pup ships. AD's job ends at "the bridge is installed and its verbs are routed."

The failure this section exists to prevent (2026-07-18): pup asked AD to change its prewarm DEFAULT (#204) — outsourcing the "when does pup's browser get provisioned" decision to AD policy — when the skip-CfT logic was sitting in pup's OWN warmup() the whole time. One-line fix in pup, zero AD involvement, issue withdrawn. The tell was needing AD to change a default rather than add a mechanism. If your ask to AD contains the words "policy", "default", "prefer", or "decide", STOP: that decision is pup's, and the code that should change is in this repo.

(The same test, run in reverse, is CLAUDE.md's existing rule: don't re-absorb AD capabilities into pup. The two directions together: mechanisms live in AD, decisions live here.)

⚠ STABLE PROFILE STORAGE (v1.8.98) — never put persistent state in the bridge dir

PROFILES_DIR/SESSIONS_DIR MUST live in ~/.adom (pup-profiles / pup-sessions), NOT under __dirname. __dirname is bridges-cache\\puppeteer, which AD CLOBBERS on every bridge_install — so any persistent state stored there is wiped on every pup update. This silently destroyed every persistent profile (incl. the wiki-auth 30-day login) until 1.8.98. It hid for a while because restart_bridge does NOT clobber (only bridge_install does), so logins survived restarts but died on the next version deploy. Rule: user-visible durable state → ~/.adom, never the bridge dir.

The THREE artifacts — know which is which (this is the #1 confusion)

A user installs TWO different things from this one page. They are not the same:

Artifact What it is Who installs it How
pkg tarball (Packages tab) the container-side Claude skills (SKILL.md + skills/*) — docs that teach the cloud AI how to drive pup. No bridge runtime. a cloud container adom-wiki pkg install adom/adom-desktop-puppeteer-bridge, or auto via AD's sync_skills
release zip (Releases tab) the bridge RUNTIME (server.js, chrome.js, bridge.json, node deps reconstructed on spawn) — the code that actually drives Chrome on the desktop Adom Desktop, streamed into bridges-cache\puppeteer bridge_install {manifestUrl} / refresh_bridges (via updateManifestUrl)
bundled seed (inside the AD installer) a copy of the same runtime, shipped in the NSIS for first-run/offline every AD install ships with Adom Desktop; superseded by a newer cache copy (cache-over-bundled, numeric version)

So: the pkg is skills (container). The bridge is the zip (desktop) + the bundled seed. Keep the pkg description saying exactly that, or people think the tarball contains the bridge — it does not.

How pup reaches a container — auto-discovery → auto-install → verbs

The full chain when a user on a fresh container says "open my app in pup":

  1. Discovery. The wiki's discover index (fed by this page's discovery_triggers — which now lead with pup, open in pup, open my app in pup, screenshot/record/tabs) matches the phrase. The container's regenerated adom-wiki-discover skill surfaces this page, so the AI proposes it.
  2. Auto-install the pkg. The AI runs adom-wiki skills install adom/adom-desktop-puppeteer-bridge (a.k.a. pkg install). That extracts the tarball and runs install.sh, which drops SKILL.md + skills/* into both ~/.claude/skills/ and ~/.codex/skills/. The pup skill is now loaded.
  3. The skill calls the bridge. pup's SKILL.md tells the AI to run adom-desktop browser_* verbs. AD routes browser_* to the puppeteer bridge.
  4. The bridge runtime. It's already on the desktop: AD ships pup as a bundled seed (and supersedes it from this page's release zip via updateManifestUrl / bridge_install). On a truly fresh PC the cold-start self-heal (chrome.js) installs Chrome for Testing on first browser_open_window.

Two install paths, keep BOTH healthy:

  • Discovery path (above) — works once discovery_triggers include the user phrases (done) + the discover index is regenerated. This is the "user said pup → AI installs the skill" path.
  • AD sync_skills path — AD's daily maybe_background_skill_sync reads bridge_list → each bridge's skillPkgpkg installs it into the container. For pup this requires AD to report skillPkg = adom/adom-desktop-puppeteer-bridge for the puppeteer bridge. Today AD hardcodes it to null (AD_CORE_SKILL_BRIDGES = ["puppeteer"] in bridge_registry.rs, from when pup's skill lived in the AD-core pkg). That must be removed now that the skill ships from this page — it's in the AD handoff.

Calling AD back (outbound) — notify the user from inside the bridge (AD ≥ 1.9.84)

Everything above is AD → pup. The reverse — pup → AD — matters when the bridge must reach the user on its own, mid-verb, with no AI turn. The canonical case: pup installs real Chrome on a locked-down box that needs a UAC, and has to say "click YES" (and re-nudge if it expires). See chrome.js _elevatedInstallWithNotify for the live impl.

The channel. A bridge's only callback is AD's loopback direct-API. Resolve it: ADOM_DIRECT_API_URL env → else read %USERPROFILE%\.adom\direct-api-port (host:port) → else 127.0.0.1:47200. Then POST /command.

AD ≥ 1.9.84 exposes the FULL verb surface to bridges (before that, only desktop_* + other bridges' verbs — notify_user returned "Unknown desktop command", which is why the degraded path exists). Contract:

  • app is inferred — send { "command": "<verb>", "args": {…} }, no app.
  • Token (optional, attribution only): send the spawn-time ADOM_BRIDGE_TOKEN env as the X-Adom-Bridge-Token header → AD badges the call bridge in its Activity Log. A stale token → 403; on 403 retry once WITHOUT the header. Omitting it entirely is fine (a bridge is trusted-by-install; calls run ungated).
  • Capability-probe, don't hardcode: GET /commandsnotify_user appears in .topLevel on ≥1.9.84. Gate the notify flow on that; fall back to raise-UAC-once + a relay hint on older AD. pup caches this as readiness.chromeStableNotifyChannel.
  • Cross-AD target: add "target": "<clientName>" | "all" to ANY /command to route it to a PEER AD via the relay — e.g. pup runs on a VM, the user is on their laptop, so ping the laptop while the UAC stays on the VM. "attended" (input-active box) is not resolvable yet → enumerate peers and fan out.
  • targets gotcha: returns { clients: [...] } and includes self (NOT { targets: [...] }). Read clients, drop your own os.hostname(), dedupe.

The two UX rules the impl bakes in (both were real bugs first):

  1. Notify-FIRST, then raise the UAC. Raising the UAC puts up the secure desktop, which hides any toast. So fire the sticky scenario:"reminder" toast with an Approve now button, poll notify_response {id} for the tap, and only THEN elevate.
  2. Never spam prompts you can't explain. If the notify channel is unreachable, raise the UAC once and surface awaiting_uac + a hint for the AI to relay — do NOT loop re-raising a UAC the user is never told about.

Repo layout

  • src/ — the runnable bridge (what the release zip contains, at zip-root). server.js, chrome.js, credential_vault.js, bridge.json, BRIDGE_VERSION, node package.json/package-lock.json, html/icons.
  • SKILL.md (root) — the user skill (name: pup). Deployed to ~/.claude/skills/adom-desktop-puppeteer-bridge/.
  • dev-skills/pup-bridge-dev/SKILL.md — THIS dev skill. publish-skills/pup-bridge-publish/SKILL.md — the publish recipe. Per the Bridge SDK, a pkg ships ONLY user skills, so these are source-only: in the repo (read via repo clone / Files tab), NOT in package.json files[], NOT installed by install.sh, and they need no user-invocable scoping (they never reach a container). Open-vs-closed source doesn't change this.
  • adom-bridge-puppeteer-manifest.json (root) — the streaming manifest AD's updateManifestUrl points at.
  • package.json (root) — the adompkg descriptor (slug/version/type/description) for pkg publish. NOT the node one.
  • install.sh / uninstall.sh — deploy ONLY the pup user skill (SKILL.md) into ~/.claude/skills/ + ~/.codex/skills/.
  • page.json, README.md, *.png heroes — the wiki page presentation (the heroes are gitignored so the container pkg stays lean — they're already uploaded as page assets).

Cold-start self-heal — the architecture (what 1.1.0 added, the reason this repo exists)

On a fresh PC (HD bundles AD; the installer pre-installs neither Node nor Chrome for Testing), the first browser_open_window used to fail with a generic "Failed to launch Chrome after 3 attempts" + a dev string. Now:

  • src/chrome.jsdetectChrome() (validates the ACTUAL exe, not "a dir exists"), installChrome() (programmatic via the bundled @puppeteer/browsers, pinned to the buildId puppeteer expects — never 'stable', or launch() won't find it), an install-state singleton (dedup + progress), ensureChromeReady() (the gate — background install, returns immediately), readiness() (powers the verb + chip).
  • browser_open_window gate (CfT path only; native-drive skips it): missing CfT → kick off a non-blocking install + return {errorCode:"chrome_for_testing_installing", statusVerb:"browser_readiness", _hint:"poll then retry"}. Install fail → chrome_install_failed/chrome_download_failed (no silent continue-to-doomed-launch).
  • browser_readiness (poll) + browser_prewarm (install without opening a window).
  • /status emits led/summary/tooltip truthfully (yellow installing, red failed, green ready).
  • Node is handled by AD core BEFORE the bridge spawns (node_not_founddesktop_install_node) — not this bridge's job (the bridge is a node process; it can't run without node).
  • The one thing this bridge can't do: pre-warm before first use (it isn't running until the first call). AD/HD must call browser_prewarm on embedded first-run — tracked in HANDOFF-TO-ADOM-DESKTOP.md.

Field-tested cold-start learnings (v1.3–1.5, from 3 real fresh Windows boxes)

Tested on AdomLapper (all browsers), AdomTestWin11 (fresh vbox, Edge-only), winvm (fresh Azure, Edge-only). What we learned and baked into the code:

  • Prefer the installed browser, don't download CfT. Every Windows PC ships with Edge; most have Chrome. resolveBrowser()/launchCandidates() return [pinned default → Chrome → Edge → cached CfT]; the launch loop spawn-verifies each and falls through on failure. A fresh isolated --user-data-dir keeps the system browser CLEAN (never touches the user's real tabs). CfT is now a genuine last resort.
  • Edge "exits immediately" was a false negative. Edge (and some Chrome channels) spawn a LAUNCHER that relaunches the real browser onto a DIFFERENT PID, then the launcher exits. The old poll treated launcher-death as failure → killed good Edge launches. Fix: probe CDP FIRST each tick, and on launcher-death do a final CDP grace probe before giving up (tryDetachedLaunch).
  • A failed candidate poisons the shared profile dir. An aborted Edge leaves SingletonLock / DevToolsActivePort in the userDataDir → the next candidate (Chrome/CfT) then also "exits immediately." Fix: killChromeHoldingProfile() (lock cleanup) BETWEEN candidates, not just between attempts.
  • Persist the chosen browser (pup-browser-default.json in the CfT cache dir). markVerified() caches the browser that actually launched (auto default); browser_use pins one (forced default). Avoids re-probing every open. Forced beats auto; launch still falls back so a broken pin never hard-fails.
  • Node install has a PATH-staleness trap (AD-core, but guide the AI). desktop_install_node installs Node, but the already-running AD process keeps its OLD PATH — so the very next browser_* STILL returns node_not_found / could not run npm. Reinstalling doesn't help. AD must be relaunched (desktop_relaunch_self) to pick up the new PATH. This is the exact "installed node, still broken" dead-end. Documented in SKILL.md + HANDOFF (AD should probe common node install dirs / refresh PATH, not rely on the stale env). winget also returns non-zero when Node is ALREADY present ("No available upgrade") — that's success, not failure.
  • Self-warm on spawn (chrome.warmup() from server.listen): detect browsers + disk the moment AD installs/updates the bridge, so the first open is instant. Only background-fetches CfT if the box has NO Chrome/Edge AND no cached CfT (never on an Edge-only box — Edge is right there).
  • Disk + specific error codes. diskFreeMb() gates the CfT install and the cold-start; failures map to chrome_install_no_disk / chrome_download_network / chrome_install_permission (not a generic string) so the AI's hint is actionable. readiness() surfaces diskFreeMb/lowDisk.
  • Install REAL Chrome on request. browser_use {browser:"chrome", install:true} downloads Google's official offline installer and runs it silently; run as the (non-admin) user it installs USER-SCOPED into %LOCALAPPDATA%\Google\Chrome (no UAC), which listSystemBrowsers() already finds. Elevated context may install machine-wide + raise UAC — surfaced via chrome_stable_not_detected hint.
  • Verbose hints everywhere. Every open returns browser (what we drove) + availableBrowsers + defaultBrowser + an _hint explaining how to switch/install. Goal: the AI resolves any browser situation in as few turns as possible without a human.
  • Desktop permission prompts gate open-ended verbs (learned live). Core AD verbs + this bridge's browser_* verbs run with NO prompt. But shell_execute / run_script / arbitrary scripts pop an AD "Shell command needs approval" dialog (Approve 1h/24h/Bypass) — and the call hangs until approved (we saw shell_execute time out on the vbox with the popup waiting; winvm had it auto-approved and showed nothing). Two consequences baked into SKILL.md: (1) prefer core/browser_* verbs over shell; (2) before any shell_execute, notify_user the person to approve the popup. This is a separate gate from the Windows UAC (admin elevation) — don't conflate them. AD ≥ 1.9.86 addendum: every gate-hit now auto-surfaces the in-window approval dialog + toast + taskbar flash on the target box (1.9.84 briefly regressed to a silent human_only refusal with NO prompt — bricked shell on headless/RDP boxes). On needs_approval/human_only: run adom-desktop --target <box> request_shell_approval (re-surfaces the dialog + foregrounds AD; surfaces only, never grants), have the human click Approve/Bypass, then RETRY the original verb. Never call shell_auto_approve — it's human-only and refuses by design.
  • Windows Firewall prompt = bind loopback (fixed v1.6.0). A hostless server.listen(PORT) binds ALL interfaces (0.0.0.0), so Windows pops "Allow Node.js through the firewall (public/private)?" on first listen — a THIRD scary prompt after UAC + shell-approval, and worse on a fresh portable-node path the firewall has never seen. Fix: server.listen(PORT, '127.0.0.1', ...). AD only ever talks to the bridge over loopback, and loopback sockets are firewall-exempt → the prompt never appears.
  • Zero-prompt Node bootstrap IS possible with typed verbs (PROVEN on winvm). pup is a Node process, so it can't self-install Node — but the AI can, using AD's UNGATED typed verbs (only shell_execute / run_script / wsl_exec* prompt; everything typed does not). Proven flow, zero popups: send_files the portable Node zip → launch_process C:\Windows\System32\tar.exe -xf to extract → registry_get + registry_set HKCU\Environment\Path (append, REG_SZ) → user reopens AD (app restart, no prompt) so it re-reads PATH. NO UAC, NO shell-approval, NO firewall. The recipe lives in SKILL.md. The only human step is the AD reopen, which the shared-runtime proposal (absolute-path spawn) removes. desktop_install_node (UAC MSI) + shell_execute (approval popup) are fallbacks only.

Cold-start bugs found on fresh boxes (AD-owned — report, don't try to fix in pup)

  • AD npm-install can't find node for lifecycle scripts (fixed ~AD 1.9.69). On AD 1.9.63–1.9.65 the managed runtime ran npm install by absolute path but didn't put node on the child-process PATH, so any dep with a native/postinstall script that shells to node (keytar, sharp, puppeteer's install.mjs) died: 'node' is not recognized. Pup-side mitigation shipped v1.7.1: sharp+keytar are optionalDependencies + sharp loads gracefully (screenshots fall back to capture-size via a PNG-header dimension read). AD fixed the PATH on ~1.9.69 (npm install now succeeds on a fresh Edge-only box).

  • AD node-gate false-negative (open as of AD 1.9.69). On a fresh box, runtimes shows node.state:ready (adom-managed), the bridge is status:running with a live pid, and browser_readiness (the declared statusVerb) works — but EVERY other browser_* verb returns AD-core node_not_found. The node pre-gate only exempts the statusVerb and does a stale/PATH-based node check that misses the portable managed runtime. pup CANNOT fix this (node_not_found fires in AD before the bridge). Reported on discussion #59; SKILL tells the AI to recognize it (readiness OK + runtimes ready + other verbs node_not_found = AD bug, don't loop desktop_install_node).

  • Takeaway: when a fresh-box open fails, first read runtimes + bridge_list (pid/status) + try browser_readiness. If the runtime is ready and the bridge is running but verbs fail, it's an AD gate/ provisioning bug — report it with that evidence; it is not a pup code bug.

  • Wedged-bridge diagnosis recipe (learned on AD 1.9.77). Symptoms: every browser_* verb times out but ping/core verbs work. Check bridge_liststatus:"running" with livePids:[] = a bridge that crashed at spawn while AD still forwards to its dead port. Read ~/.adom/bridge-logs/puppeteer.log — the 1.9.77 regression showed Node dying at boot: EISDIR: lstat 'C:' (AD mangled the entrypoint path at the drive-letter colon). Cross-check another box on a different AD version to isolate AD-vs-pup. pup can't fix AD's spawner — report with the log + version comparison (that report got it fixed same-day).

  • Docs can have THREE copies (SDK lesson, 2026-07-04). The bridge-SDK page carried the same content in README.md, SKILL.md, AND bridges-SDK.md — an edit landed on two and left the third stale, so audits reading the third saw pre-update text. When auditing against a doc, verify the EXACT file you read; when publishing multi-copy docs, push and verify every copy.

Background-by-default without stealing focus — THE SOLVED PLAYBOOK (do NOT re-derive this)

This is the single hardest problem in the bridge and it has been solved TWICE from scratch. If you are touching browser_open_window, window placement, focus, or "the pup window popped up / stole my typing / flashed a terminal", read this whole section before changing anything. The final design lives in osBackgroundWindowByPid + tryDetachedLaunch + the raise handler (search those).

The core problem: headful Chrome/Edge aggressively grab the OS foreground — they call SetForegroundWindow on their own window at launch and every time the window is shown. So a naive open (a) pops the window over the user's work, (b) steals keyboard focus for the ~1–3s of browser startup, so the user's keystrokes land in the invisible pup window. Requirements that must hold SIMULTANEOUSLY: R1 never visually pop / cover the user's work · R2 the window is reachable (taskbar-clickable) · R3 CDP browser_screenshot still works · R4 never steal keyboard focus · R5 no console/terminal flash.

The working design (v1.8.51+). The KEY insight, found after a week of warring with re-park loops: --no-startup-window eliminates the focus steal at the SOURCE, so there is NO war to fight.

Windows only grants a process the right to take the foreground AT LAUNCH. With --no-startup-window the browser process starts with NO window at all; pup creates the window via CDP (newPage()) moments later, by which time the browser is a background process and the OS itself denies it the foreground. No steal is possible → no watchdog, no timers, no re-park passes exist → a user's taskbar click at ANY moment (200ms in or 2 minutes in) has nothing running against it and always sticks. Do NOT add any loop that re-parks after the initial park — every such loop, no matter how clever (input-timing, short windows, stability watches), eventually yanks a real user's click. John proved each variant failed.

THE FINAL ALGORITHM (v1.8.55, John-verified "those seemed to open nicely") — DIRECT APIs, one tiny Win32 call, no PS window-hunting, no timers, no loops. Lives in tryDetachedLaunch (flag) + parkSessionWindowDirect + osBottomUnlessForeground:

  1. Spawn with --no-startup-window --window-position=-32000,-32000 (no --start-maximized). tryDetachedLaunch only sets _needsPark — there is NO window yet to manage.
  2. Connect CDP; in launchSession newPage() creates the window (off-screen, unfocused — the OS denied it the foreground). The park runs right here — the await on newPage IS the "window exists" event. No wait loop, no polling.
  3. parkSessionWindowDirect(page, sessionId):
    • hwnd via AD desktop_find_window (native, matches the "(session: " title suffix the welcome page just set), retried together with the bottoming below (~8 × 300ms).
    • Z-bottom UNCONDITIONALLY via AD-core (v1.8.63): ONE call — desktop_set_window_state {titleContains, state:'bottom', force:true} (AD ≥1.9.115; both verbs shipped from pup's feature requests). force:true bottoms even a foreground window. There is deliberately NO user-click escape hatch at park time — see the dead ends: "foreground at park" has non-user causes and honoring it forced never-clicked windows onto John's screen. A legacy PS fallback (osBottomUnlessForeground) remains ONLY for desktops on AD ≤1.9.114 (no state:'bottom', or force ignored + skip-on-foreground) — delete it once the fleet is ≥1.9.115.
    • HARD RULE: never move the window on-screen until 'bottomed' is CONFIRMED. If bottoming can't be confirmed after the retries, return an honest park-failed and leave the window invisible off-screen — the self-heal (reattach / open_tab / switch_tab / any status query) retries it. An un-bottomed window moved on-screen lands ON TOP of the user's work (the nxp.com pop, v1.8.59).
    • Geometry via CDP plain bounds ONLY (Browser.setWindowBounds {left,top,width,height}, normal state), addressed by windowId — parking the WRONG window is structurally impossible. Size comes from the page's own screen.availWidth/Height WITH RETRIES (a single probe can fail mid-navigation; retry, don't fall back). NEVER windowState:'maximized' and NEVER AD desktop_set_window_bounds — BOTH RAISE the window (see dead ends).
    • TRUST NOTHING — verify the Z-ORDER after placement (v1.8.66): re-bottom after the move, then confirm via AD desktop_list_windows that pup's hwnd is NOT topmost and sits below at least one non-pup window. Unconfirmable after a retry → move the window BACK OFF-SCREEN and report park-failed. A window that might be covering the user never stays on-screen. This check is what caught ten versions of placement bugs the focus-based checks all missed.
    • Self-verify POSITION via CDP getWindowBounds (left/top > -5000), NOT focus — a focus-only verify passed while the window sat off-screen ("backgrounded" lie).
    • The launch-gap contract: a user who clicks the taskbar button within the ~2s before the park lands may see nothing (window still off-screen) or get parked once — they click ONCE more and the window comes up and STAYS (nothing re-parks after the park exits). That is the accepted trade; do not "improve" it with foreground detection.
  4. The open response awaits the verdict (_bgPromise); badge (badgePupTaskbar) + flash fire the INSTANT the park lands — no blind 1.5s/3.5s badge timers.
  5. Nothing ever re-parks. foreground:true opens skip all of this and launch maximized on-screen.
  6. Remaining AD gap (issue filed): a send-to-back window state; when AD ships it, delete osBottomUnlessForeground and pup is 100% PS-free.

Supporting principles (subordinate to the algorithm above — where older text conflicts, the algorithm wins):

  1. Launch OFF-SCREEN → satisfies R1. Background windows launch with --window-position=-32000,-32000 (NOT --start-maximized; strip it when !foreground). The window is created far past any monitor, so nothing ever appears on screen during startup. CDP renders regardless of position, so screenshots/eval/record all still work.
  2. The resting state is ON-SCREEN at the bottom of the z-order → satisfies R2 + R3. On-screen-occluded (behind the user's windows, taskbar-clickable, still compositing) is the ONLY resting state that keeps screenshots working — see the minimize dead-end.
  3. DIRECT APIs, not PowerShell (John: "why are you using powershell? that's heavy. why aren't you just calling direct api's?"). Geometry = CDP Browser.setWindowBounds by windowId (exact window, ~10ms). Window lookup = AD desktop_find_window. Foregrounding = AD desktop_bring_to_front. Flash/badge = AD desktop_flash_window / desktop_taskbar. The one residual PS (osBottomUnlessForeground) exists ONLY because z-order has no direct API yet; it also reads GetForegroundWindow — the ONE honest "did the user click it" signal. Never grow the PS surface. (AttachThreadInput focus-handback machinery still exists in the legacy helpers used by browser_lower_os_window; with --no-startup-window nothing steals focus, so new code never needs it.)
  4. What little PowerShell remains runs HIDDEN → satisfies R5. execFileSync('powershell.exe', [...args], {windowsHide:true}) + -WindowStyle Hidden (see runPsHidden), NEVER execSync('powershell …') — that goes through cmd.exe and flashes a console window. (A cmd blip during a redeploy is AD respawning the node process, not our runtime.)

SELF-VERIFY, always — and verify POSITION, not just focus. The park's verdict comes from CDP getWindowBounds (on-screen?) — a focus-only check once said "backgrounded" while the window sat stranded off-screen. The verdict surfaces as the background field in the open response + getSessionInfo ('backgrounded' | 'user-foreground' | 'park-failed' | 'no-window'). Test on a box with a LIVE interactive desktop (an RDP-disconnected box reports a degenerate foreground and lies); have the USER click taskbar buttons early and late — that's the test every automated check missed.

SELF-HEAL (v1.8.57) — windows must not stay broken. A window created under a buggy build (or any future placement failure) used to sit stranded off-screen forever: its taskbar button "activated" an invisible window and the badge was gone (this bit the adom-shotlog app's long-lived shared window). Every natural touchpoint (bridge-restart reattach, open_tab, switch_tab) now runs healOffscreenWindow: a cheap CDP bounds check, and ONLY if actually off-screen, re-park + re-badge. Healthy or user-held windows are never touched.

BADGE (v1.8.58) — check the result, debounce only success. badgePupTaskbar verifies AD's response and logs [badge] applied/FAILED; the tab-hot-path debounce (10 min) arms ONLY on success. The old fire-and-forget + unconditional debounce meant one silent failure = a bare icon for 10+ minutes (John: "there's no favicon on this window? wtf?").

KNOWN WART (open): AD's restart_bridge/bridge_kill can take the pup-spawned Chrome processes down with the bridge — every pup window (including other apps' windows, e.g. shotlog's) vanishes on a bridge update. Normal users only hit this on AD auto-updates; apps recover by reopening on next use. If this starts mattering, file it against AD (process-tree kill) — do not "fix" it by re-parenting Chrome in pup.

browser_raise_os_window is the inverse: move the window on-screen + maximize via CDP Browser.setWindowBounds, then foreground it. If you ever add a non-activatable style (see below), raise must clear it first.

Dead ends — DO NOT reintroduce these (each was tried and is worse):

  • CDP windowState:'maximized' for background placement (1.8.55-1.8.64) → Chromium implements it as ShowWindow(SW_MAXIMIZE), which RAISES the window to the top of the z-order. No focus steal, so every focus-based check passed while five "backgrounded" windows sat stacked on top of John's editor. The earlier "small-then-grow" sighting was the same raise making the staging visible.
  • AD desktop_set_window_bounds for background placement (1.8.65) → ALSO raises the window (despite restore:false), AND desktop_list_monitors reports logical px while set_window_bounds takes physical px → on-top AND undersized. Geometry for a parked window = CDP plain bounds (left/top/width/height, normal state) sized from the page's own screen.availWidth/Height, ONLY.
  • Trusting that a confirmed park stays parked → the open's FIRST cross-origin nav (welcome file:// → https) swaps renderer processes and re-shows the window at first paint — RAISING it after the z-confirm passed. A later same-window navigate does NOT raise (tested), nor does the badge (tested). Re-asserts after the raise still left a VISIBLE blip (another thread's window popped over John's work between paint and re-assert). THE fix (v1.8.68): park AFTER the page settles — wait for the load event (capped 2.5s) while the window is still OFF-SCREEN, so the paint-raise is invisible; only then bottom → place → z-confirm. First visible frame = full-size at z-bottom. A late (3s) force:false re-assert plus the open_tab re-assert remain as belt-and-suspenders. And the ONLY honest verify is the Z-ORDER via desktop_list_windows (pup window below at least one non-pup window); if z cannot be confirmed, the window goes BACK OFF-SCREEN — never leave a window that might be covering.
  • --start-maximized + send-to-back after connect → the window is foreground/maximized for 1–3s before you lower it = the visible pop. This is the naive version; off-screen launch replaces it.
  • SetForegroundWindow(usersWindow) alone → silently no-ops (foreground lock). You MUST use the AttachThreadInput attach/detach dance.
  • Minimize as the background state → tried TWICE now (SW_MINIMIZE 1.8.26-era, and full SetWindowPlacement-restore-rect minimize in 1.8.47). It's SO tempting because minimizing hands focus back for free and a taskbar click restores-and-stays (no re-park loop). But a minimized window reports document.visibilityState === 'hidden' and Chrome SUSPENDS its renderer, so Page.captureScreenshot hangs ~27s then errors — EVEN WITH --disable-features=CalculateNativeWinOcclusion + --disable-backgrounding-occluded-windows (those cover OCCLUDED windows, not MINIMIZED). Breaks R3. REJECTED — do not try minimize again. On-screen-occluded is the only state that both hides the window AND keeps it rendering. (osMinimizeToBackground may still be in the tree, unused — do not wire it back.)
  • MainWindowHandle to resolve the window → returns a hidden Chrome helper on some boxes; you park a phantom and the real window never moves. Enumerate Chrome_WidgetWin_1 instead (technique #2).
  • ANY re-park loop after the initial park — blind timers, input-aware, short-windowed, stability watches — ALL of them → each variant was shipped (1.8.44→1.8.50) and each one eventually yanked a real user's taskbar click back to the background. Input heuristics (GetLastInputInfo) false-fire whenever the user types anywhere; foreground checks can't tell an early click from a self-raise; even a 50ms-reaction stability watch fought a click in the first 2s. The ONLY correct fix is --no-startup-window: no steal can happen, so no loop is needed. If you think you need a re-park loop, you have re-broken the launch flag — fix that instead.
  • document.hasFocus() (CDP) as the user-click signal → LIES for CDP-created windows: reports internal focus even when the OS never foregrounded them. False-fired user-foreground on EVERY open (1.8.53).
  • GetForegroundWindow() at PARK TIME as the user-click signal (the launch-gap escape hatch, 1.8.52-1.8.59) → even the honest OS signal is NOT a user signal during the launch gap: Windows REASSIGNS the foreground to a brand-new window when the previous foreground window was just destroyed (a bridge-update restart killing pup windows did exactly this), plus creation blips. Honoring it force-foregrounded windows the user NEVER clicked (nxp.com, another thread's cpconn) — the #1 forbidden behavior. REMOVED in 1.8.60: the launch park bottoms unconditionally; a user who clicked during the ~2s gap clicks once more and it sticks. GetForegroundWindow remains valid AFTER startup (the late re-park paths), where a destroyed-foreground handoff can't target the window.
  • CDP windowState:'maximized' for background placement → Chromium implements it as ShowWindow(SW_MAXIMIZE), which RAISES the window to the TOP of the z-order without stealing focus — so every focus-based check passes while pup windows sit stacked ON TOP of the user's work (latent 1.8.55→64; John caught five at z=0-4 under his editor). The "small-then-grow" and the nxp.com "pop" sightings were this raise. Full-size = CDP plain bounds sized from screen.availWidth/Height, never the maximize state.
  • AD desktop_set_window_bounds for background placement → ALSO raises the window (even with restore:false), AND desktop_list_monitors reports LOGICAL px while set_window_bounds takes PHYSICAL px (windows came out on top and undersized, 1.8.65). If AD ever ships a documented no-raise bounds verb, re-evaluate; until then geometry is CDP-only.
  • Trusting ANY placement primitive without a z-order check → three different placements raised the window and every one shipped because verification only checked focus and position. The z-verify + revert-off-screen (v1.8.66) is not optional.
  • PS window-hunting (EnumWindows / MainWindowHandle / pid+CIM child walks) to find the window → heavy (~1-2s process+compile per park = the whole "first 2 seconds" gap) AND mis-resolved under parallel opens (parked the wrong/phantom window; the real one stayed invisible off-screen while the focus-only verify said "backgrounded"). CDP addresses the exact window by windowId; AD desktop_find_window resolves the hwnd natively. PS is for the one call with no direct API.
  • Computing "full-size" from screen.availWidth via page.evaluate → the probe can fail mid-navigation and the fallback ships undersized (1600×900) windows. Use CDP windowState:'maximized' — the OS defines full-size.
  • WS_EX_NOACTIVATE on the window → Chrome self-activates anyway (so it doesn't fix R4), AND a non-activatable window can't be brought up by a taskbar click (breaks R2). Rejected.
  • Minimize→re-show loop as the focus drop → on an on-screen window the minimize/restore is a visible flicker; a burst of retries strobes. The AttachThreadInput handback needs no minimize, so no flicker.
  • A pup-side localhost reachability probe → redundant; AD core already reprimands a localhost URL when it fails to load (and HD's working localhost passes clean). Don't re-add it.

The user↔AI balance — background for the AI, but the HUMAN can take any window (John's rule)

This is the governing principle, stated by John (2026-07-14), and it OVERRIDES a naive "always keep it backgrounded" reading of the playbook above. pup windows are predominantly background tasks that must not disturb the user — BUT the same windows are real windows a human has to be able to open, click, see, and use normally. Both must hold at once. Get the handoff right:

  • Default = background (off-screen launch → on-screen z-bottom, focus handed back). For AI automation.
  • The moment the user takes a window, it becomes a normal foreground window — visible, on-screen, usable, and it STAYS that way. pup must never fight a deliberate user action.

How pup tells a user-raise from Chrome's self-raise — FINAL ANSWER (v1.8.60): it doesn't detect user-raises AT ALL, in either direction. --no-startup-window means Chrome can't self-raise, and pup makes exactly ONE park then exits — so there is nothing to detect and nothing that can fight the user. Every detection signal was shipped and every one false-fired (GetLastInputInfo, document.hasFocus(), even GetForegroundWindow at park time — see dead ends).

The contract:

  1. During the ~2s launch gap a taskbar click may show nothing (window still off-screen) or be parked once. The user clicks ONCE more and the window comes up — cheap, bounded, honest.
  2. After the park, the user owns every raise, forever. Nothing in pup can background a window the user brought up. The only re-park paths left are the explicit browser_lower_os_window verb and the off-screen SELF-HEAL (which checks bounds first and never touches an on-screen window).

The browser demarcation line — the Aditya/Google-auth incident (2026-07-17, GOLD, do not forget)

The principle: pup is the ANONYMOUS, SANDBOXED automation browser. The user's identity lives in their branded browser (abe's domain) or a plain native OS launch. When pup runs on the user's INSTALLED branded Chrome/Edge, that line blurs and the browser's own identity machinery interleaves with pup's "anonymous" windows in ways pup cannot control. Chrome for Testing (CfT) IS the line of demarcation: an automation-first binary with no profile/sync/sign-in machinery, visibly distinct from the user's browser.

⚠ CORRECTION (2026-07-19, John proved it live): an earlier version of this section claimed CfT LACKS the profile-creation machinery and that the poisoning was branded-Chrome-only. THAT IS FALSE. Chrome for Testing shows the SAME "Sign in to Chromium? Set up a work profile" dialog after a Google login — the exact prompt Aditya clicked. Do NOT reason that "CfT is immune." The real mitigation is LAUNCH FLAGS (v1.8.95): --allow-browser-signin=false --disable-sync --disable-features=SigninIntercept,DiceWebSigninInterception,ProfilePicker,... --disable-signin-promo suppress the dialog at launch so no user ever sees it. CfT is still the right browser (automation build, no branded skin), but its safety comes from these flags, not from any inherent immunity.

The incident, step by step (each step looked reasonable; the cascade was a mess):

  1. Aditya's AI thread set up adom-google, which needed a Google OAuth sign-in page. The thread asked pup to open it. pup opened it in a fresh anonymous profile — of the INSTALLED branded Chrome.
  2. He signed into Google. Branded Chrome then offered to turn that anonymous profile into a full signed-in Google work profile. He said yes (it looks like the right thing to say).
  3. Now he had TWO Chrome profiles for his work identity. Chrome detected the duplicate and prompted him to CONSOLIDATE profiles. Confusion begins.
  4. The consolidation opened a new profile-colored ("green") window WITHOUT his original URL. He was now fully lost.
  5. He asked his AI to open the page again → a NEW pup window (fresh anonymous profile) → not signed in anymore → he signed in again → the whole cascade repeated.

The lessons, each of which is a rule:

  1. Auth flows do not belong in pup. pup's profiles are disposable and anonymous BY DESIGN; a user signing into a real account inside one either gets lost on the next fresh window or triggers the branded browser's profile machinery. When a caller opens a known auth/login URL (accounts.google.com, login.microsoftonline.com, okta, auth0, github.com/login, etc.), pup should answer with a HINT: "pup is the anonymous sandboxed browser — for signing into the USER's accounts use abe (nbrowser_*, their real signed-in browser) or a plain native OS launch (AD-core desktop_open_url), where their sessions and password manager already live."
  2. The plain native OS launch must stay a first-class option. AD-core desktop_open_url (ShellExecute → default browser) is the ZERO-machinery path: the user's own browser, their own default profile, no bridge in the loop. John's words: "we're almost getting too fancy with browsers now" — when in doubt, the boring OS-level open is correct, especially for auth.
  3. Branded-browser fallback is where identity bleed happens. Even a perfectly isolated --user-data-dir runs the USER'S branded binary: first-run/sign-in/default-browser promos, profile-creation offers after login, profile-consolidation prompts, sync nags. None of that exists in CfT. This incident is the concrete case for wiki issue #202 (prefer CfT): the pick order should converge to cached CfT → installed Chrome → Edge → CfT-download-last-resort, with a background browser_prewarm whenever a branded fallback was used, so every box reaches the deterministic CfT state by its second session without ever stalling a first open.
  4. Visual demarcation matters to the human. Part of the confusion was that pup's window WAS branded Chrome — indistinguishable from his real browser (plus a surprise green profile window). With CfT the pup window is a distinct binary (plus the teal taskbar badge), so "this is the AI's sandbox, not my browser" is legible at a glance.

Provisioning ownership (v1.8.77): pup provisions its OWN browser. chrome.warmup() at every bridge spawn ensures CfT is cached (background fetch, low-disk guarded) — regardless of what branded browsers exist. Do NOT ask AD to carry prewarm policy for pup (issue #204 was withdrawn for this reason): AD's job ends at "the bridge is installed"; the bridge's browser is the bridge's business. If a first open ever waits on chrome_for_testing_installing, the warmup was skipped or failed — check the [warmup] log line, don't add AD policy.

Ecosystem map (who owns what): pup = anonymous sandboxed automation (CfT ideally). abe (nbrowser_*) = the user's REAL signed-in browser, for anything needing their sessions/identity. desktop_open_url = plain native OS launch, no automation, the right tool for "just open this for the user" and for auth when abe isn't warranted. Steering between these belongs in pup's hints and the pup/abe skills — the calling AI can only pick the right surface if the surfaces say who they are.

Full icon/identity takeover (option 3) — prototype findings 2026-07-18 (do NOT re-derive)

Goal: pup windows should read "Adom Pup" with the Adom icon EVERYWHERE (taskbar, alt-tab, pins) — no Chrome logo, no CfT "Test" icon. Prototyped live on AdomLapper; every mechanic below verified.

What works, and the resolution chain:

  • AUMID stamp: SHGetPropertyStoreForWindow + set System.AppUserModel.ID ({9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3},5, VT_LPWSTR) = works cross-process, persists on the window. Gives pup windows their own taskbar grouping identity ("Adom.Pup").
  • Taskbar button icon: WM_SETICON (big+small) takes effect immediately.
  • Alt-tab: resolves through the AUMID → a REGISTERED Start Menu .lnk carrying the same AUMID (icon + display name). An unregistered AUMID falls back to the exe resource (the CfT "Test" icon). Registration is read at WINDOW CREATION and cached by Explorer's AppResolver — register BEFORE any window exists (install time), and know that a just-created .lnk needs an Explorer restart to be seen (prototype confirmed: even fresh windows + explorer bounce weren't enough alone — see the handle lesson below, which contaminated that test).
  • Pinning trap SOLVED: point the registered .lnk at adom-desktop-cli.exe browser_open_window {...} — a pinned "Adom Pup" then launches a REAL managed pup window through the bridge instead of a bare orphan CfT.

THE HARD LESSON — icon handles are process-owned: LoadImaged HICONs are USER objects that DIE when the loading process exits. A transient script (or any short-lived helper) that stamps WM_SETICON leaves the window pointing at a DEAD handle moments later — the taskbar may render once before death, then every later read (alt-tab!) falls back to the exe icon. Probe evidence: WM_GETICON returned a different handle after every script run. Also SetClassLongPtr(GCLP_HICON) FAILS silently cross-process (returns 0; class data lives in the target process). Consequence: the icon stamping MUST be done by a long-lived native process that owns the HICONs for the windows' whole lifetime — i.e. AD-core. This is not just the ownership doctrine, it is a mechanical requirement.

The production design (AD feature request):

  1. AD installer registers the "Adom Pup" Start Menu .lnk (AUMID Adom.Pup, adom-pup.ico, target = adom-desktop-cli browser_open_window) — warm AppResolver cache before any window ever exists.
  2. New AD verb desktop_set_window_identity {hwnd|titleContains, appId, iconPath}: AD loads the .ico ONCE (handles live as long as AD runs), stamps the AUMID property + WM_SETICON per window.
  3. pup calls it at park time, same place as the badge; overlay badge retired for pup windows (kept for explicit branded browser_use pins, where the base icon is genuinely Chrome/Edge). Rejected: patching CfT's exe icon resource (breaks signature/sha validation, SmartScreen/AV risk).

Per-session AUMID registration + the wiki view-toggle jump list (#207/#208, v1.9.2–1.9.3)

Two AD verbs shipped that close the last gaps in the identity story. Both are AD-owned (the long-lived native process that owns the HICONs, per the hard lesson above); pup just calls them.

#207 — desktop_register_app_identity {appId, displayName, iconPath, shortcut:false} + desktop_unregister_app_identity {appId} (AD ≥1.9.136). Alt-Tab and the taskbar right-click menu HEADER resolve a window's icon/name through its AUMID via a REGISTERED entry; an UNREGISTERED per-session AUMID (Adom.Pup.<sessionId>, which split mode uses so each window is its own taskbar button) fell back to the chrome.exe icon. shortcut:false registers the AUMID registry-only — no Start Menu .lnk, so no launcher spam for these throwaway per-window ids. pup registers on window create (registerPerSessionAumid, fired from stampPupIdentity in split mode), tracks live ids in _registeredSessionAumids, and unregisters in browser_close_window. The base Adom.Pup stays the shortcut-registered pinnable launcher (grouped mode uses it).

#208 — desktop_set_window_jumplist {appId, tasks:[{title, target?, args?, iconPath?, description?}]} (AD ≥1.9.139). Attaches a jump list to an AUMID → a PER-WINDOW right-click taskbar menu (because split mode gives each window its own per-session AUMID). Clicking a task launches target args minimized; target defaults to AD's bundled CLI, so args is simply an AD/bridge verb + JSON — the click routes back through the LOCAL AD to pup with zero cloud round-trip. tasks:[] clears. The AUMID must be registered (from #207) or the shell ignores the list. pup uses this for the wiki view toggle: updateWikiJumplist(session, sessionId) sets one task on any Adom-URL window — "🔓 Switch to logged-in view" on a public window, "📖 Switch to public view" on an authed one — with args: browser_wiki_set_view {"sessionId":..,"view":..}. Called on open (900ms after brand, so the AUMID is registered) and on every navigate (adds on a wiki page, clears when the window leaves Adom); split mode only; idempotent per (session,view) via session._wikiJumplistView; self-gates via _jumplistSupported.

The callback — browser_wiki_set_view {sessionId, view} (pup verb). A view switch is a cookie-jar (userDataDir) switch, and the profile is FIXED at browser launch, so there is NO in-place cookie swap: it's a close-and-relaunch of the same sessionId under the other profile (WIKI_AUTH_PROFILE for authed, session._publicProfile || sessionId for public) at the same URL. It's a USER action (they clicked the taskbar task), so the relaunched window is brought on-screen — via CDP place+maximize + bringToFront (same on-screen move as the raise handler, no PowerShell), NOT the gated browser_raise_os_window path (which would demand a foregroundReason a click can't supply). It re-checks auth, refreshes the in-tab glyph/brand/overlay/jumplist, and if the authed jar hasn't done the one-time SSO yet returns wikiLoginNeeded so the user logs in once in that window. AI threads should NOT call this verb — they pass wikiView on browser_open_window; the verb exists for the taskbar affordance.

Liveness, restart, and the concurrent-launch wedge (2026-07-19 — do NOT re-derive)

→ Full symptom-indexed playbook: the pup-bridge-debug skill. Read that FIRST when pup is misbehaving (verbs timing out, dim LED, respawn loop, window vanishing). The notes below are the condensed version kept here for continuity.

A live incident that ate an hour: after a restart_bridge, pup's verbs (browser_readiness, browser_describe, browser_open_window) all timed out for MINUTES and the AD GUI LED was dim. Three separate lessons, all now fixed/documented:

1. The concurrent-launch wedge (pup's OWN bug, fixed v1.9.4 — the real root cause). A fresh profile is not in the browsers map until its Chrome launch FINISHES (~10-15s). So two near-simultaneous browser_open_windows for the same profile (an AI retry/poll loop, OR two threads sharing a profile) each entered getOrLaunchBrowser, saw no cached browser, and each spawned its OWN detached Chrome onto the SAME --user-data-dir. They then collided on Chrome's SingletonLock, and each launch's lock-recovery taskkilled "the orphan holding the profile" — which was the SIBLING launch. Net: they mutually killed each other, NONE ever came up, and the bridge looked hung. Fix: _launchInFlight (Map profileName→Promise) — the first launch registers a promise; concurrent callers JOIN it (return await) instead of racing a second Chrome. If you ever see "Chrome process exited immediately" storms or a wedged cold-start under load, check this dedup first. NEVER remove it. (Corollary for callers: do NOT spam browser_open_window while AD says bridge_starting — fire ONE and poll.)

2. LED / liveness — which signal is TRUTH. The AD GUI bridge LED is ACCURATE: dim = the pup PROCESS isn't actively serving. Corroborating truth signals: AD statusbrowser.bridgeRunning, and simply whether a real pup verb answers. bridge_list status:"running" is NOT liveness — it's desired/installed state and will say "running" even when the process is down. (Cost me a wrong "pup is running" claim to John. Trust the LED + browser.bridgeRunning + an actual verb reply, and bridge_check_updates liveVersion/staleProcess for version truth.)

3. restart_bridge REAPS; only some verbs RESPAWN. restart_bridge (and a crash) leaves pup down until AD auto-spawns it on the next call — but AD spawns it for WINDOW/TAB verbs (browser_open_window et al.), NOT for a bare status probe like browser_readiness/status. So polling browser_readiness to "wait for pup to come back" waits FOREVER (the probe never triggers a spawn and never reaches the down process). Recovery playbook: fire ONE browser_open_window (it returns bridge_starting fast and triggers the spawn), THEN poll browser_list_windows (also auto-spawning) with backoff until it answers. refresh_bridges {name:"puppeteer"} (AD ≥1.9.76) downloads-and-restarts in one step for the UPDATE path; restart_bridge is the kill-and-respawn.

Was it AD's fault? No — AD's reap-on-restart is normal and correct. The wedge was pup's missing launch dedup (#1); AD's 30s relay timeout + the dim LED just made a pup bug VISIBLE. Own it.

4. The ~60s respawn LOOP (crash-poison session file, fixed v1.9.6 — the deeper root cause). The AD build thread read the lifecycle log (adom-desktop bridge_log_read {name:"puppeteer"}) and saw pup being spawned every ~60s with no reap — i.e. the process was dying on its OWN and AD's supervisor kept respawning it, even while idle. Cause: recoverSessions() runs on every boot and reconnects to Chromes named in ~/.adom/pup-sessions/*.json. If a recovery op crashes pup natively (inside puppeteer.connect / a CDP call / a native module), the process dies HARD BEFORE the per-session try/catch can delete that file — so the SAME poison file re-crashes the next boot, forever. In-process try/catch cannot stop a native crash, and pup's uncaughtException/unhandledRejection handlers only catch JS throws, so neither helps here. Fix: a PERSISTENT circuit breaker — recoverSessions writes recoverAttempts:1 into the file BEFORE the risky ops; a file that still carries an INCOMPLETE attempt on the next boot is QUARANTINED (deleted, not retried); a clean recovery rewrites the file without the marker; a graceful failure deletes it. One strike quarantines, so any poison file self-heals in a single crash instead of an unbounded loop. The diagnostic that nails this class: bridge_log_read → spawn-with-no-reap cadence = pup self-crashing, NOT AD killing it (AD added that lifecycle log v1.9.93 for exactly this). If you see it, suspect a startup/recovery path crashing on poison on-disk state; clearing ~/.adom/pup-sessions/* is the manual stop-gap while the breaker ships.

The foreground gate (v1.8.70) — abe-parity dual key, and the AD interception hole

Foregrounding (open foreground:true, browser_raise_os_window, browser_focus_window) requires a foregroundReason (min 10 chars — quote/paraphrase of what the user said). Open-time: without it the open SUCCEEDS but stays background (foregroundDenied) — abe semantics: withhold the foregrounding, not the work. Raise-time: refused (foreground_reason_required). Granted foregrounds are logged ([foreground] ... GRANTED), recorded in lastAgentUpdate (FOREGROUND: <reason>), and surfaced as lastForeground {ts,verb,reason} in status. Mirrors abe's userRequestedForeground+foregroundReason (also accepted as an arg alias) so the contract is identical across Adom's browser surfaces.

HOLE CLOSED (AD 1.9.124, issue #163): AD-core used to INTERCEPT browser_raise_os_window / browser_focus_window natively (a pre-extraction fossil, flagged ~July 10 and finally deleted 2026-07-15) — which bypassed pup's raise gate entirely and let a thread foreground a window with no reason and no audit trail. As of AD 1.9.124 those verbs relay to pup like every other browser_* verb, so the gate + audit trail hold everywhere (verified: reasonless raise → pup's foreground_reason_required refusal; granted raise → [foreground] GRANTED log + lastForeground in status). If a reasonless raise EVER succeeds again, suspect a reintroduced AD interceptor first — the in-page focus latch window.__pupFocusTs remains the ground-truth "when did this window gain focus" for forensics. Desktops on AD ≤1.9.123 still have the ungated native path until they update.

The taskbar flash — pulse vs. the persistent tint (clearing it programmatically)

The flash SET routes through AD-core desktop_flash_window (flashViaAD); do not re-add pup's own FlashWindowEx. But clearing is subtle and cost us a false "it's cleared" claim:

  • FLASHW_STOP (AD {stop}) only stops the PULSING animation. On Win11 the button keeps its attention TINT (orange/pink bg + accent underline) until the window is ACTIVATED. So {stop} returning applied:true does NOT mean the button went clean — verify, don't claim.
  • The only thing that clears the tint is activating the window. That's why a user clicking the button clears it. To clear it programmatically without a visible pop, activate it off-screen: move off-screen → SetForegroundWindow (invisible, clears the tint) → park back on-screen z-bottom → hand focus back (AttachThreadInput). This is osClearFlashByActivate, wired into browser_alert_window {stop}. It reports cleared / already-foreground (not a fire-and-forget guess).
  • Programmatic flash STATE (answer "which windows are still flashing?" / "which window hasn't the user seen?" without a screenshot): getSessionInfo.flash = none|pending|cleared, from the server-side flash ts (_flashState) vs the in-page focus latch window.__pupFocusTs (injected focus listener). pending = flashed, user has not activated it since; cleared = user activated it after the flash. Gotcha (cost a live demo): the hook must be injected into the CURRENT document with page.evaluate IN ADDITION to evaluateOnNewDocument — the latter only fires on the NEXT navigation, so an already-navigated page never gets the hook and every clicked window keeps reporting pending (v1.8.45-55 bug, fixed 1.8.56).

Meta-rule (John, 2026-07-14): think through ALL the variables before shipping a window/focus change — human-usability AND AI-automation together. When John gives new feedback here, write it into THIS skill so it is never re-derived or re-lost.

Publishing (the wiki has TWO storage layers — don't mix them)

Full recipe in PUBLISHING.md. The load-bearing rules:

  1. The bridge .zip → a RELEASE asset (adom-wiki release create + release upload). NEVER repo push a .zip — it's gitignored and silently skipped; the manifest URL would 404.
  2. The manifest JSON + source → the git repo (adom-wiki repo push). The manifest's url is the RELEASE download URL; its sha256/size MUST match the served asset (releases preserve your sha; re-download to confirm or bridge_install rejects it).
  3. The skills → a PKG (adom-wiki pkg publish). The pkg description (root package.json) must say "container-side skills." pkg pack honors .gitignore for exclusion (no separate npmignore) — that's why the heroes are gitignored, to keep the tarball lean. Set per-version notes with adom-wiki pkg notes.
  4. Version display gotcha: a RELEASE shows up in adom-wiki pkg info as a row with type:null (a wiki UI conflation). The real pkg rows have type:"app". Use release list for true releases.
  5. bridge.json + BRIDGE_VERSION lockstep, and keep the version > the AD bundled seed so cache-over-bundled wins.

Testing a new version on a real desktop

adom-desktop targets    # multiple desktops → --target <name> is required
adom-desktop --target <name> bridge_install '{"manifestUrl":"https://wiki.adom.inc/api/v1/pages/adom-desktop-puppeteer-bridge/files/adom-bridge-puppeteer-manifest.json","force":true}'
adom-desktop --target <name> browser_readiness '{}'   # proves the new code is live
  • No Node on the boxbrowser_* returns node_not_found before the bridge spawns (AD core; correct). That's a genuine cold-start demo of the FIRST link; it can't exercise chrome.js (no node to run it).
  • A box with Node but no Chrome for Testing is the only place to see the full Chrome self-install live (browser_open_windowchrome_for_testing_installing → poll browser_readiness → opens). Most dev boxes already have CfT, so readiness() just returns ready:true.
  • The streamed zip is source-only; AD runs npm install in the cache on first spawn (sharp/keytar ship prebuilds). The bundled seed already has node_modules. Don't put node_modules in the zip.

Don't

  • Don't edit adom-desktop/plugins/puppeteer as if it's live, and don't release-bridge.sh puppeteer from the adom-desktop repo — that clobbers this cloud-owned page.
  • Don't ship the bridge runtime in the pkg tarball or the skills in the release zip. Two artifacts, two homes.
  • Don't install 'stable' Chrome — pin to puppeteer's expected buildId (chrome.js does this).