Adom Desktop - Puppeteer Bridge
Public Made by Adomby adom
The Adom Desktop Puppeteer (pup) bridge — drive a real headful browser on the user's desktop from the cloud: open/screenshot/eval/record. Cold-start drives the browser ALREADY on the machine (installed Chrome, else Microsoft Edge — every Windows PC ships with Edge) with a fresh isolated profile, so the common case needs NO download; Chrome for Testing is fetched only as a last resort. Two artifacts: the Releases zip is the bridge runtime (also bundled in Adom Desktop); the pkg installs the container-side Claude skills.
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_windowrouting,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":
- Discovery. The wiki's
discoverindex (fed by this page'sdiscovery_triggers— which now lead withpup,open in pup,open my app in pup, screenshot/record/tabs) matches the phrase. The container's regeneratedadom-wiki-discoverskill surfaces this page, so the AI proposes it. - 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 runsinstall.sh, which dropsSKILL.md+skills/*into both~/.claude/skills/and~/.codex/skills/. Thepupskill is now loaded. - The skill calls the bridge.
pup's SKILL.md tells the AI to runadom-desktop browser_*verbs. AD routesbrowser_*to the puppeteer bridge. - 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 firstbrowser_open_window.
Two install paths, keep BOTH healthy:
- Discovery path (above) — works once
discovery_triggersinclude the user phrases (done) + the discover index is regenerated. This is the "user said pup → AI installs the skill" path. - AD
sync_skillspath — AD's dailymaybe_background_skill_syncreadsbridge_list→ each bridge'sskillPkg→pkg installs it into the container. For pup this requires AD to reportskillPkg = adom/adom-desktop-puppeteer-bridgefor thepuppeteerbridge. Today AD hardcodes it tonull(AD_CORE_SKILL_BRIDGES = ["puppeteer"]inbridge_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:
appis inferred — send{ "command": "<verb>", "args": {…} }, noapp.- Token (optional, attribution only): send the spawn-time
ADOM_BRIDGE_TOKENenv as theX-Adom-Bridge-Tokenheader → AD badges the callbridgein 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 /commands→notify_userappears in.topLevelon ≥1.9.84. Gate the notify flow on that; fall back to raise-UAC-once + a relay hint on older AD. pup caches this asreadiness.chromeStableNotifyChannel. - Cross-AD
target: add"target": "<clientName>" | "all"to ANY/commandto 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. targetsgotcha: returns{ clients: [...] }and includes self (NOT{ targets: [...] }). Readclients, drop your ownos.hostname(), dedupe.
The two UX rules the impl bakes in (both were real bugs first):
- 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 anApprove nowbutton, pollnotify_response {id}for the tap, and only THEN elevate. - 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, nodepackage.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 viarepo clone/ Files tab), NOT inpackage.json files[], NOT installed byinstall.sh, and they need nouser-invocablescoping (they never reach a container). Open-vs-closed source doesn't change this.adom-bridge-puppeteer-manifest.json(root) — the streaming manifest AD'supdateManifestUrlpoints at.package.json(root) — the adompkg descriptor (slug/version/type/description) forpkg publish. NOT the node one.install.sh/uninstall.sh— deploy ONLY thepupuser skill (SKILL.md) into~/.claude/skills/+~/.codex/skills/.page.json,README.md,*.pngheroes — 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.js—detectChrome()(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_windowgate (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)./statusemitsled/summary/tooltiptruthfully (yellow installing, red failed, green ready).- Node is handled by AD core BEFORE the bridge spawns (
node_not_found→desktop_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_prewarmon embedded first-run — tracked inHANDOFF-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-dirkeeps 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/DevToolsActivePortin 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.jsonin the CfT cache dir).markVerified()caches the browser that actually launched (auto default);browser_usepins 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_nodeinstalls Node, but the already-running AD process keeps its OLD PATH — so the very nextbrowser_*STILL returnsnode_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()fromserver.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 tochrome_install_no_disk/chrome_download_network/chrome_install_permission(not a generic string) so the AI's hint is actionable.readiness()surfacesdiskFreeMb/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), whichlistSystemBrowsers()already finds. Elevated context may install machine-wide + raise UAC — surfaced viachrome_stable_not_detectedhint. - Verbose hints everywhere. Every open returns
browser(what we drove) +availableBrowsers+defaultBrowser+ an_hintexplaining 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. Butshell_execute/run_script/ arbitrary scripts pop an AD "Shell command needs approval" dialog (Approve 1h/24h/Bypass) — and the call hangs until approved (we sawshell_executetime 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 anyshell_execute,notify_userthe 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 silenthuman_onlyrefusal with NO prompt — bricked shell on headless/RDP boxes). Onneeds_approval/human_only: runadom-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 callshell_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_filesthe portable Node zip →launch_processC:\Windows\System32\tar.exe -xfto extract →registry_get+registry_setHKCU\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
nodefor lifecycle scripts (fixed ~AD 1.9.69). On AD 1.9.63–1.9.65 the managed runtime rannpm installby absolute path but didn't put node on the child-process PATH, so any dep with a native/postinstall script that shells tonode(keytar, sharp, puppeteer'sinstall.mjs) died:'node' is not recognized. Pup-side mitigation shipped v1.7.1:sharp+keytarareoptionalDependencies+sharploads 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,
runtimesshowsnode.state:ready(adom-managed), the bridge isstatus:runningwith a live pid, andbrowser_readiness(the declared statusVerb) works — but EVERY otherbrowser_*verb returns AD-corenode_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) + trybrowser_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 butping/core verbs work. Checkbridge_list→status:"running"withlivePids:[]= 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:
- Spawn with
--no-startup-window --window-position=-32000,-32000(no--start-maximized).tryDetachedLaunchonly sets_needsPark— there is NO window yet to manage. - Connect CDP; in launchSession
newPage()creates the window (off-screen, unfocused — the OS denied it the foreground). The park runs right here — theawaiton newPage IS the "window exists" event. No wait loop, no polling. 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:truebottoms 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-failedand 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 ownscreen.availWidth/HeightWITH RETRIES (a single probe can fail mid-navigation; retry, don't fall back). NEVERwindowState:'maximized'and NEVER ADdesktop_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_windowsthat 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 reportpark-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.
- hwnd via AD
- The open response awaits the verdict (
_bgPromise); badge (badgePupTaskbar) + flash fire the INSTANT the park lands — no blind 1.5s/3.5s badge timers. - Nothing ever re-parks.
foreground:trueopens skip all of this and launch maximized on-screen. - Remaining AD gap (issue filed): a
send-to-backwindow state; when AD ships it, deleteosBottomUnlessForegroundand pup is 100% PS-free.
Supporting principles (subordinate to the algorithm above — where older text conflicts, the algorithm wins):
- 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. - 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.
- 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.setWindowBoundsby windowId (exact window, ~10ms). Window lookup = ADdesktop_find_window. Foregrounding = ADdesktop_bring_to_front. Flash/badge = ADdesktop_flash_window/desktop_taskbar. The one residual PS (osBottomUnlessForeground) exists ONLY because z-order has no direct API yet; it also readsGetForegroundWindow— the ONE honest "did the user click it" signal. Never grow the PS surface. (AttachThreadInputfocus-handback machinery still exists in the legacy helpers used bybrowser_lower_os_window; with--no-startup-windownothing steals focus, so new code never needs it.) - What little PowerShell remains runs HIDDEN → satisfies R5.
execFileSync('powershell.exe', [...args], {windowsHide:true})+-WindowStyle Hidden(seerunPsHidden), NEVERexecSync('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_boundsfor background placement (1.8.65) → ALSO raises the window (despiterestore:false), ANDdesktop_list_monitorsreports 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 ownscreen.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:falsere-assert plus the open_tab re-assert remain as belt-and-suspenders. And the ONLY honest verify is the Z-ORDER viadesktop_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 theAttachThreadInputattach/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 reportsdocument.visibilityState === 'hidden'and Chrome SUSPENDS its renderer, soPage.captureScreenshothangs ~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. (osMinimizeToBackgroundmay still be in the tree, unused — do not wire it back.) MainWindowHandleto resolve the window → returns a hidden Chrome helper on some boxes; you park a phantom and the real window never moves. EnumerateChrome_WidgetWin_1instead (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-fireduser-foregroundon 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 fromscreen.availWidth/Height, never the maximize state. - AD
desktop_set_window_boundsfor background placement → ALSO raises the window (even withrestore:false), ANDdesktop_list_monitorsreports 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; ADdesktop_find_windowresolves the hwnd natively. PS is for the one call with no direct API. - Computing "full-size" from
screen.availWidthvia page.evaluate → the probe can fail mid-navigation and the fallback ships undersized (1600×900) windows. Use CDPwindowState:'maximized'— the OS defines full-size. WS_EX_NOACTIVATEon 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
AttachThreadInputhandback 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:
- 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.
- 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_windowverb 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):
- 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.
- 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).
- Now he had TWO Chrome profiles for his work identity. Chrome detected the duplicate and prompted him to CONSOLIDATE profiles. Confusion begins.
- The consolidation opened a new profile-colored ("green") window WITHOUT his original URL. He was now fully lost.
- 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:
- 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." - 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. - Branded-browser fallback is where identity bleed happens. Even a perfectly isolated
--user-data-dirruns 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 backgroundbrowser_prewarmwhenever a branded fallback was used, so every box reaches the deterministic CfT state by its second session without ever stalling a first open. - 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+ setSystem.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):
- 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. - 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. - pup calls it at park time, same place as the badge; overlay badge retired for pup windows
(kept for explicit branded
browser_usepins, 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-debugskill. 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 status → browser.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}returningapplied:truedoes 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 isosClearFlashByActivate, wired intobrowser_alert_window {stop}. It reportscleared/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 latchwindow.__pupFocusTs(injectedfocuslistener).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 withpage.evaluateIN ADDITION toevaluateOnNewDocument— the latter only fires on the NEXT navigation, so an already-navigated page never gets the hook and every clicked window keeps reportingpending(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:
- The bridge
.zip→ a RELEASE asset (adom-wiki release create+release upload). NEVERrepo pusha.zip— it's gitignored and silently skipped; the manifest URL would 404. - The manifest JSON + source → the git repo (
adom-wiki repo push). The manifest'surlis the RELEASE download URL; itssha256/sizeMUST match the served asset (releases preserve your sha; re-download to confirm orbridge_installrejects it). - The skills → a PKG (
adom-wiki pkg publish). The pkg description (rootpackage.json) must say "container-side skills."pkg packhonors.gitignorefor exclusion (no separate npmignore) — that's why the heroes are gitignored, to keep the tarball lean. Set per-version notes withadom-wiki pkg notes. - Version display gotcha: a RELEASE shows up in
adom-wiki pkg infoas a row withtype:null(a wiki UI conflation). The real pkg rows havetype:"app". Userelease listfor true releases. bridge.json+BRIDGE_VERSIONlockstep, 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 box →
browser_*returnsnode_not_foundbefore the bridge spawns (AD core; correct). That's a genuine cold-start demo of the FIRST link; it can't exercisechrome.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_window→chrome_for_testing_installing→ pollbrowser_readiness→ opens). Most dev boxes already have CfT, soreadiness()just returnsready:true. - The streamed zip is source-only; AD runs
npm installin 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/puppeteeras if it's live, and don'trelease-bridge.sh puppeteerfrom 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.jsdoes this).
---
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
`skillPkg` → `pkg install`s 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 /commands` → `notify_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.js`** — `detectChrome()` (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_found` → `desktop_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_list` → `status:"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: <id>" 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:** `LoadImage`d 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_window`s 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 `taskkill`ed "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 `status` → `browser.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
```bash
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 box** → `browser_*` 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_window` → `chrome_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).