name: pup-bridge-dev description: "DEVELOPER skill for building, publishing, and maintaining the 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, pup-bridge, cold-start self-heal, pup_readiness internals, pup release, pup manifest, ship pup bridge."

Parent skill: pup-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/pup-bridge), extracted from adom-bridge/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/pup-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 Bridge, 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 Bridge; 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/pup-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-bridge-cli pup_* verbs. AD routes pup_* 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 pup_open_window.

Two install paths, keep BOTH healthy:

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

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

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

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

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

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

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

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

Repo layout

  • src/ — the runnable bridge (what the release zip contains, at zip-root). server.js, chrome.js, credential_vault.js, bridge.json, BRIDGE_VERSION, node package.json/package-lock.json, html/icons.
  • SKILL.md (root) — the user skill (name: pup). Deployed to ~/.claude/skills/pup-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 pup_open_window used to fail with a generic "Failed to launch Chrome after 3 attempts" + a dev string. Now:

  • src/chrome.jsdetectChrome() (validates the ACTUAL exe, not "a dir exists"), installChrome() (programmatic via the bundled @puppeteer/browsers, pinned to the buildId puppeteer expects — never 'stable', or launch() won't find it), an install-state singleton (dedup + progress), ensureChromeReady() (the gate — background install, returns immediately), readiness() (powers the verb + chip).
  • pup_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:"pup_readiness", _hint:"poll then retry"}. Install fail → chrome_install_failed/chrome_download_failed (no silent continue-to-doomed-launch).
  • pup_readiness (poll) + pup_prewarm (install without opening a window).
  • /status emits led/summary/tooltip truthfully (yellow installing, red failed, green ready).
  • Node is handled by AD core BEFORE the bridge spawns (node_not_founddesktop_install_node) — not this bridge's job (the bridge is a node process; it can't run without node).
  • The one thing this bridge can't do: pre-warm before first use (it isn't running until the first call). AD/HD must call pup_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); pup_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 pup_* 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. pup_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 pup_* 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/pup_* 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-bridge-cli --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 pup_readiness (the declared statusVerb) works — but EVERY other pup_* 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 pup_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 pup_* verb times out but ping/core verbs work. Check bridge_liststatus:"running" with livePids:[] = a bridge that crashed at spawn while AD still forwards to its dead port. Read ~/.adom/bridge-logs/puppeteer.log — the 1.9.77 regression showed Node dying at boot: EISDIR: lstat 'C:' (AD mangled the entrypoint path at the drive-letter colon). Cross-check another box on a different AD version to isolate AD-vs-pup. pup can't fix AD's spawner — report with the log + version comparison (that report got it fixed same-day).

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

Cross-repo issues — file an AD-core bug on adom/adom-bridge, NOT on your own page (John, 2026-07-24)

You depend MASSIVELY on AD-core (it spawns you, provisions your Node runtime, relays your verbs, installs you), so you will hit AD bugs constantly during pup dev. This is common enough that getting the DESTINATION right is its own discipline. The rule, verified with a live mistake:

An issue lives on the issue list of the repo that OWNS the code that must change.

  • A pup bug (a pup_* verb, a hint, the CfT self-heal, the status chip) is YOURS: fix it here, or track it on the pup page (adom/pup-bridge).
  • An AD-CORE gap (a lifecycle verb, spawn / provision / bind behavior, preserve_client_deps, the relay dropping a response field, the stdout capture, bridge_log_read, a manifest field AD must honor) goes on adom/adom-bridge's issue list, NOT the pup page. The AD-core thread watches its OWN page and does NOT read pup's issues. An AD ask on the pup page is a note to yourself that nobody who can fix it will ever see.
  • A wiki or CLI bug goes on adom/wiki. A sibling bridge bug goes on that bridge's page.

The live mistake (2026-07-24): three AD-core lifecycle asks (npm-install-on-lockfile-change, stdout log rotation, bridge_log_read interleaving) were filed as pup issue #18 (a self-note), then moved to adom/adom-bridge#28. The Bridge-SDK template ask went to adom/adom-bridge#29. Precedent that adom/adom-bridge IS the right home for pup->AD asks: AD #26/#27 (CfT), #16 (window-verb interception), #3/#20/#21 (Bridge SDK).

How:

adom-wiki issue list   adom/adom-bridge            # FIRST: skim for a dup, comment instead of re-filing
adom-wiki issue create adom/adom-bridge \
  --title "<verb or subsystem>: <the gap in one line>" \
  --category bug-report \
  --body "$(cat writeup.md)"                         # what you hit live + date + AD version + root cause + ask
# close/comment take the GLOBAL discussion id (the "id" field), NOT the per-page "number":
#   adom-wiki issue close <globalId> --comment "moved to adom/adom-bridge#N"

Separate what is AD-core's from what is yours IN the body, so the two are never conflated. After filing, read the AD-core reply on that thread (it may change your plan, e.g. "fixed in AD X.Y, bump your pin"): that issue is the cross-repo channel, keep the loop closed there. See the CLAUDE.md "You vs adom-bridge-cli (AD) core" boundary for capability-vs-policy calls.

⛔ THE IRON LAW OF WINDOW PLACEMENT (John, verbatim, 2026-07-31 — the ~100th time he has had to say it)

"we MUST open windows for pup in the bg, but if the user clicks that windows taskbar icon to bring it to the fg, you MUST do it immediately, which means if you're forcing it for 20 seconds to the background that is RUDE AS FUCK!"

FOUR clauses, all absolute, and clause 2 BOUNDS every mechanism built for clause 1:

  1. Every pup window opens in the BACKGROUND. Never on the user's screen, never with their focus.
  2. A user's taskbar click brings that window up IMMEDIATELY and it STAYS UP. No timer, watcher, guard, re-park loop, strike counter, or "smart" heuristic may EVER knock back or delay a window the user chose to raise. Anything that CAN fight the user's click is banned, no matter how clever, no matter what problem it claims to solve. If a placement bug needs fixing, fix it at the SOURCE (creation/park targeting), never with a loop that patrols the foreground afterward.
  3. Every pup-initiated foreground captions its REASON for 2 seconds. Enforced at the choke point: pup_raise_os_window captions inline, and osRaiseSessionWindow(session, sid, reason) captions for every other raise path (wiki view toggle, sign-in raise, auto-login, heal-retry). A new raise path without a reason caption is a bug. (Callers that already narrate, like assisted_click, pass reason=null to avoid a double caption.)
  4. The NO-FAULT WATCH (John, 2026-07-31): for 20s after a background open/nav, pup watches the OS foreground (only while armed, zero steady-state cost). If the window surfaces WITHOUT a pup raise, pup shows a 2s caption: "This window was NOT foregrounded by Adom: JavaScript, a focus change, Chrome itself, or your own click raised it. Not me!" - then marks it foreground and backs off. CAPTION ONLY. It never knocks the window back (that variant was v1.9.252, killed same day under clause 2). The watch exists for attribution, not enforcement: the user always knows WHO put a window on their screen.

Incident log for why this is written in blood: the v1.9.252 "fg-steal guard" (a 20s foreground patrol with a 3-strike rule) was added and REMOVED THE SAME DAY. It violated this law and it was built without re-reading this playbook - the exact prohibition below ("do NOT add any loop that re-parks... John proved each variant failed") already covered it. The actual disruption it was chasing was caused by the AUMID relapse (a stamp recreating the taskbar button right after open) plus a wrong-window background call (BestWindow picked the largest window of the process, not the session's own hwnd) - both source bugs, both fixed at the source.

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 pup_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 pup_screenshot still works · R4 never steal keyboard focus · R5 no console/terminal flash.

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

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

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

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

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

  1. Launch OFF-SCREEN → satisfies R1. Background windows launch with --window-position=-32000,-32000 (NOT --start-maximized; strip it when !foreground). The window is created far past any monitor, so nothing ever appears on screen during startup. CDP renders regardless of position, so screenshots/eval/record all still work.
  2. The resting state is ON-SCREEN at the bottom of the z-order → satisfies R2 + R3. On-screen-occluded (behind the user's windows, taskbar-clickable, still compositing) is the ONLY resting state that keeps screenshots working — see the minimize dead-end.
  3. DIRECT APIs, not PowerShell (John: "why are you using powershell? that's heavy. why aren't you just calling direct api's?"). Geometry = CDP Browser.setWindowBounds by windowId (exact window, ~10ms). Window lookup = AD desktop_find_window. Foregrounding = AD desktop_bring_to_front. Flash/badge = AD desktop_flash_window / desktop_taskbar. The one residual PS (osBottomUnlessForeground) exists ONLY because z-order has no direct API yet; it also reads GetForegroundWindow — the ONE honest "did the user click it" signal. Never grow the PS surface. (AttachThreadInput focus-handback machinery still exists in the legacy helpers used by pup_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.

pup_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 pup_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 pup_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 pup_open_window {...} — a pinned "Adom Pup" then launches a REAL managed pup window through the bridge instead of a bare orphan CfT.

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

The production design (AD feature request):

  1. AD installer registers the "Adom Pup" Start Menu .lnk (AUMID Adom.Pup, adom-pup.ico, target = adom-desktop-cli pup_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 pup_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 pup_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: pup_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 — pup_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 pup_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 pup_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 (pup_readiness, pup_describe, pup_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 pup_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 pup_open_window while AD says bridge_starting — fire ONE and poll.)

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

3. restart_bridge REAPS; only some verbs RESPAWN. restart_bridge (and a crash) leaves pup down until AD auto-spawns it on the next call — but AD spawns it for WINDOW/TAB verbs (pup_open_window et al.), NOT for a bare status probe like pup_readiness/status. So polling pup_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 pup_open_window (it returns bridge_starting fast and triggers the spawn), THEN poll pup_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-bridge-cli 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, pup_raise_os_window, pup_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 pup_raise_os_window / pup_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 pup_* 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 pup_alert_window {stop}. It reports cleared / already-foreground (not a fire-and-forget guess).
  • Programmatic flash STATE (answer "which windows are still flashing?" / "which window hasn't the user seen?" without a screenshot): getSessionInfo.flash = none|pending|cleared, from the server-side flash ts (_flashState) vs the in-page focus latch window.__pupFocusTs (injected focus listener). pending = flashed, user has not activated it since; cleared = user activated it after the flash. Gotcha (cost a live demo): the hook must be injected into the CURRENT document with page.evaluate IN ADDITION to evaluateOnNewDocument — the latter only fires on the NEXT navigation, so an already-navigated page never gets the hook and every clicked window keeps reporting pending (v1.8.45-55 bug, fixed 1.8.56).

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

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

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

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

Testing a new version on a real desktop

adom-bridge-cli targets    # multiple desktops → --target <name> is required
adom-bridge-cli --target <name> bridge_install '{"manifestUrl":"https://wiki.adom.inc/api/v1/pages/pup-bridge/files/adom-bridge-puppeteer-manifest.json","force":true}'
adom-bridge-cli --target <name> pup_readiness '{}'   # proves the new code is live
  • No Node on the boxpup_* 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 (pup_open_windowchrome_for_testing_installing → poll pup_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-bridge/plugins/puppeteer as if it's live, and don't release-bridge.sh puppeteer from the adom-bridge-cli 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).

DESIGN DECISION: why Chrome for Testing (CfT) over the user's native Chrome/Edge

This choice gets relitigated often (wiki issues #9, #15, #202, and the 2026-07-24 native-Chrome-150 diagnostic). Read this before touching the browser pick order (launchCandidates in chrome.js) so you argue from the real, current rationale — not the original one, which has partly changed.

The decision (v1.9.90, John, 2026-07-24 — FLIPPED to native-first): the user's INSTALLED browser is driven FIRST — Chrome, else Edge (ships on every Windows PC) — in a fresh isolated profile with the identity-suppression flags. Chrome-for-Testing is fetched (~150 MB, once) ONLY as the last resort when there is no installed Chromium at all (e.g. a Mac with no Chrome). Order lives in launchCandidates(); warmup() no longer force-downloads CfT when a native browser exists. A local pup_use {browser:"cft"} pin still selects CfT explicitly — for a managed/enterprise box whose policies interfere with automation.

Frame the question correctly. pup wants a GENERIC, anonymous, disposable window — a fresh empty profile, never the user's identity. Both a fresh --user-data-dir on the user's installed Chrome AND a CfT launch give you that fresh cookie jar. So the isolation is NOT the differentiator; the binary is. The only thing being chosen here is which Chromium executable to drive, and everything below is about that.

The original reason — and why it's now only HALF the story

CfT-first was introduced (v1.8.71) after the "Aditya/Google-auth incident": driving branded Chrome/Edge with an isolated --user-data-dir still popped the "Sign in to Chromium? / Set up a work profile" dialog, which poisoned pup's anonymous windows (duplicate profiles, consolidation prompts, lost user). CfT is automation-first with no sign-in/sync/profile machinery, so it dodged that.

BUT (measured live 2026-07-19): CfT shows the SAME dialog. Switching to CfT did NOT actually fix the poisoning. The real fix was launch flags applied to EVERY browser pup drives (noBrowserIdentityFlags in server.js: --allow-browser-signin=false, --disable-features= SigninIntercept,...,ProfilePicker, --disable-signin-promo, --disable-sync). So "CfT avoids the sign-in dialog" is NO LONGER a valid argument for CfT-first — the flags handle it regardless of binary.

The reasons that DO still hold for CfT-first

  • Version determinism. CfT is pinned to an exact build (via bundled puppeteer's expectedBuildId). The user's Chrome auto-updates underneath pup — uncontrolled version drift, and recent Chrome releases keep tightening remote-debugging/profile behavior. CfT can't shift under us mid-session.
  • A clean, policy-free, automation-sanctioned binary. A corporate/managed user's installed Chrome can carry enterprise policies (forced sign-in, blocked flags, restricted remote-debugging) and extensions that quietly break automation. CfT is a standalone, unmanaged binary that exists FOR being driven — none of that baggage, and it can't have a policy pushed onto it mid-session.
  • Automation-first UI. No branded surfaces (update nags, promo bars, first-run flows) that can intrude on a screenshot or a recording.
  • Fully bridge-controlled. We own its lifecycle and cache; we don't share a binary with the user's interactive browsing.

The COSTS of CfT-first (be honest about these)

  • Staleness. The pinned build is only as fresh as the bundled puppeteer. Measured 2026-07-24: the fleet was on CfT 146.0.7680.76 while current stable CfT was 152 — six majors behind, and an old patch of 146 at that. Bumping means bumping puppeteer (see the version-bump note) and re-publishing; the fleet then converges on next open. Native Chrome is always current with zero work.
  • Possibly tighter resource limits (issue #16) — UNVERIFIED HYPOTHESIS, do not repeat it as fact. The theory is that CfT runs leaner than desktop Chrome, so a heavy SPA tab (a 438 MB renderer was measured) plus an in-page eval tips a CfT renderer over more readily. It is a live SUSPECT for the "tab freezes under load" reports and the reason for the native-Chrome-150 diagnostic — but nobody has run CfT vs native under the SAME heavy-tab load and measured it. Treat it as a hypothesis to test, not a known cost. If you confirm or refute it, update this line with the measurement.
  • The ~150 MB download is the fragile part (issue #15). A half-populated cache made pup unusable; the download failing behind an npm allow-scripts gate is a real fleet failure mode. Native browsers need no download. This is exactly why we must NEVER block an open on the CfT download when a system browser exists (the v1.9.77 gate fix).

When native IS the right call

  • The no-download fast path on a fresh box (system browser used immediately). Native is now the DEFAULT, so this is simply the common path — CfT is no longer prewarmed to "converge" the box.
  • A CfT build regression — pin native with pup_use {browser:"chrome"|"edge"} as an escape hatch.
  • Diagnostics like ruling CfT out as the cause of a freeze.

Bottom line (the decision, FLIPPED 2026-07-24)

We did NOT actually pick CfT to avoid the profile/sign-in dialog — that reason collapsed the day we saw CfT show the same dialog, and launch flags became the real fix (they apply to native Chrome/Edge too). What was left for CfT was version determinism and a clean binary we fully control. Weighed against the costs — a build that goes stale (fleet was 6 majors behind on 146 while stable was 152), a fragile ~150 MB download, and the fleet dep-bump needed to move it (blocked by AD preserve_client_deps, adom/adom-bridge#28) — those advantages matter mainly for a MANAGED/enterprise fleet with policy-bound Chrome. For the Adom individual user base (unmanaged Chrome, Edge always present), native-first wins: no download, always the user's current battle-tested build, and every taskbar icon/identity override works on it identically (OS-window level, browser-agnostic — verified: John's box drove native Chrome 150 fully branded). The freeze that triggered this review was NOT a CfT bug — it was the close-reap leaving orphaned renderers (fixed v1.9.89), so it was never a reason to keep or drop CfT either way.

So the fleet default is native-first. CfT stays a first-class option for the one case it earns its weight (a policy-bound managed browser), reachable by pup_use {browser:"cft"}.

The rule

Native-first is the fleet default (installed Chrome → Edge → CfT-last-resort; launchCandidates() + warmup()). Do NOT flip back to CfT-first without NEW evidence that native is genuinely failing for the user base (e.g. a wave of policy-bound-Chrome breakage) — the identity-dialog argument is dead (flags fix it on any binary) and the freeze was our own reap bug. If a specific box has a policy-bound or broken Chrome, that is a per-machine pup_use {browser:"cft"} pin, not a fleet reversal. Keep the bundled puppeteer's CfT build current regardless, so the last-resort binary isn't ancient.

⛔ TEARDOWN + SWEEP DOCTRINE (2026-07-24 — this froze a user's laptop; do NOT re-derive)

A pup_close left orphaned Chromium renderers spinning at ~a full core each. On John's 16-core laptop overall CPU hit 88-98%, dwm (the cursor compositor) was starved to 38-63%, the mouse froze and he had to reboot. A second user independently reported pup at 90% CPU. James's issue #14 (three sessions running two days, battery drained) was the same root cause. Four rules came out of it, and they generalize to ANY cleanup code in this bridge.

1. NEVER fire-and-forget a cleanup whose success you report. The old reap was execFile(...) with no await, so pup_close returned ok before the sweep finished or failed. A close that left processes behind still said ok. If a caller can't distinguish "cleaned up" from "gave up", the verb is lying. Await the cleanup, then report a COUNT (chromeProcsRemaining, which must be 0) so the caller can verify rather than trust.

2. A cleanup that runs under load must not enumerate the whole world. The old sweep pulled the FULL Win32_Process table and filtered in PowerShell afterward, with an 8s timeout. At 88-98% CPU — the exact condition the runaway renderers create — that enumeration doesn't finish in 8s. It timed out and silently gave up, precisely when it was needed most. Push the filter server-side (Get-CimInstance -Filter "Name='chrome.exe' OR Name='msedge.exe'") so only candidate rows are materialized. A diagnostic that fails under the conditions it diagnoses is worse than none.

3. NEVER sweep from an EMPTY inventory. reapOrphanPupChromes decides "orphan" as "no live session owns this profile". An empty session map therefore makes EVERY window an orphan — the sweep would kill the user's live windows. The map is legitimately empty-but-pending at boot before recoverSessions() finishes (and, pre-#16-fix, after a resolver purged it). It now REFUSES to run when the session map is empty while session files still exist on disk. The AUMID prune already encodes the same doctrine ("with nothing to distinguish stale from pending, doing nothing is the correct answer"). Generalize: a destructive sweep must treat an empty inventory as "I don't know", never as "kill all".

4. Scope every destructive sweep by OWNERSHIP, and prove the scoping. The reap only matches processes whose command line contains pup's own profile dir, so it can never touch the user's real browser. Verified live: closing two demo windows reaped 17 processes to 0 while another AI thread's window (7 procs) was untouched. When you ship a sweep, test the sparing, not just the killing.

Verify with GROUND TRUTH, not your own reporting. Use AD's process_list ({nameFilter:"chrome"}, keys: pid/commandLine/parentPid) to count real OS processes before and after. pup asserting chromeProcsRemaining: 0 and AD independently showing 0 are two different claims; the second is proof.

Resolvers must be PURE — the session-map purge (issue #16, fixed v1.9.92)

Symptom that made no sense: pup_list_windows reported 0 windows while those windows were open on screen and fully drivable by sessionId. It survived bridge_kill + respawn, so it looked like persistent poison. It was neither a crash nor a wedge — it was a read accessor deleting its own data.

// getActiveSession() — a RESOLVER that silently purged the map
for (const [id, s] of sessions) {
  if (isSessionAlive(s)) { activeSessionId = id; return s; }
  sessions.delete(id);        // ← no _lostBrowser guard, unlike resolveSession()
}

Compounded by a liveness check that consulted only our bookkeeping:

function isSessionAlive(s) {            // OLD
  const be = browsers.get(s.profileName);
  return be && be.browser.isConnected();
}

handleBrowserDisconnect() does browsers.delete(profileName) on ANY CDP blip (sleep/wake, renderer crash, puppeteer hiccup) while deliberately KEEPING the sessions (_lostBrowser = true) and logging "run pup_rescan to recover". So one blip made every session on that profile read as dead, and the next verb that omitted a sessionId walked the map and deleted the very entries the disconnect handler had just preserved. bridge_kill didn't help because the respawn recovered from disk and then the first resolver call wiped it again.

Three durable rules:

  • A resolver must not mutate the collection it reads. Removal belongs to the close and rescan paths. Mark state (_lostBrowser = true) instead of deleting; let an explicit reaper own deletion.
  • Two functions doing the same job must agree. resolveSession() had the _lostBrowser guard and a comment explaining it; getActiveSession() didn't. When you find near-duplicate logic, diff the guards — a divergence between siblings is where this class of bug lives.
  • Ask the OBJECT, not your bookkeeping. isSessionAlive now falls back to page.browser().isConnected(). A session that can still be driven must never report as not-alive; a false "dead" is what fed the purge.

Reproduce this class cheaply before shipping a fix: replicate the data shapes in a standalone node snippet and run old-vs-new side by side. Mine printed OLD -> sessions remaining: 0 / NEW -> 2, which proved the mechanism in seconds without touching the live box. Then confirm on the real machine: kill a window's actual Chrome via process_kill, call a NO-sessionId verb (the trigger), and assert pup_list_windows still lists everything.

Multi-maintainer repo safety — last-push-wins WILL destroy work (issue #19)

Two maintainers each pushing a full src/ from their own clone, with no freshness check, means whoever pushes last silently wins. Kyle pushed a stale clone over my newer source; I compounded it by pushing 6-file SUBSETS and by cutting a release from a divergent line. Net state: the repo carried BRIDGE_VERSION 1.9.90 sitting on a 1.9.85 tree — worse than either mistake alone, because it looks correct. 364-line diff, 129 lines of his work missing from mine and all of 1.9.86-1.9.90 missing from his.

Nothing user-facing broke — installs follow the RELEASE (manifest + sha256), not the repo. But the repo stopped being a safe base to cut from, which is a silent trap for the next release.

Three gates now in /tmp/ship.sh, and every one of them exists because something got through:

  • GATE 0 — repo freshness, FAIL CLOSED. Before pushing, fetch the repo's current src/server.js and count substantive lines present there but missing locally. Over threshold → abort with merge instructions. Override only via explicit FORCE_PUSH=1 after a deliberate merge.
  • Full-tree push. Push every file under src/, never a subset. A subset push leaves the repo a mix of fresh and stale files while BRIDGE_VERSION reads current — that is how recorder-icon.ico and the mac icons vanished in the 1.9.86 line.
  • GATE 0b — stale metadata. Versioned files OUTSIDE src/ (e.g. page.json) can also roll backwards: my local read 1.8.38 against a published 1.9.74. Compare and refuse.

TEST YOUR GATE BY BREAKING SOMETHING. I stripped 59 lines from my local tree and attempted a real release: the gate caught 52 missing lines, refused, and nothing shipped. An untested safety gate is a belief, not a control.

Merge doctrine when trees diverge: take the RELEASED tree as the base (it is what users run), then re-apply the other party's additive hunks onto it. Classify every hunk first — their-addition (take) vs revert-of-my-work (skip) vs mixed (hand-merge). Say out loud in the issue which of their changes you did NOT take and why; silently overriding a colleague is how the next divergence starts.

The RELEASE and the PKG are different artifacts — shipping one does NOT ship the other

Caught by Kyle 2026-07-24: pup's published skills package was 17 releases behind the bridge. Every version after 1.9.74 returns 404 at /api/v1/packages/adom/<slug>/<ver>/tarball — those are release tags, not packages. So pkg install silently resolved to the 1.9.74 tarball: stale docs, and a published install.sh that wrote the start-here skill to ~/.claude/skills/pup-bridge/ while its own SKILL.md declares name: pup — a slug that contradicts its frontmatter, its trigger words, and every cross-reference in the sub-skills.

The trap: release create + release upload ships the RUNTIME. It does nothing for the SKILLS tarball. A bridge can be 17 releases current on the runtime and 17 releases stale on the docs its consumers actually read, with no error anywhere.

Rules: when SKILL.md / install.sh / uninstall.sh / package.json change, pkg publish in the SAME session as the release. Verify by fetching the tarball at the version you just shipped (a 404 means you published a release, not a package) and by diffing the packaged files against the repo copies — Kyle found the slug bug only because he compared all nine files, not just the docs he came for.

Ask the AI to report bugs — and scope anything you throttle at the response chokepoint

The Adom team filing wiki issues against this bridge has been the single most productive bug channel we have. In ~2 days it produced: the CPU-pegging orphan-renderer freeze (#14/#21), the respawn breakage (#15), the session-tracking purge (#16), the repo-vs-release divergence (#19), and a concurrency + readiness report that caught a live blocking regression hours after I shipped it (#22). None of those were findable on a maintainer's machine — they came from real installs with different browsers, AD versions, concurrent agents, a Mac, a stalled download.

So pup ASKS for reports, in the place the AI actually reads (the SDK's first principle: the caller reads your OUTPUT, never your on-disk skill). _reportIssues carries the repo slug, the exact adom-wiki issue list / issue create commands, and what evidence to include (verbatim response JSON + bridge version + OS). It also routes AD-core problems to adom/adom-bridge so reports land on the repo that owns the code. Opt out: pup_configure {reportIssues:"off"}.

Throttling is the whole design. A line on every response trains the caller to skip the field; a line on every FAILURE is a bug report waiting to be filed. So: always on a failure (evidence still in hand), once per bridge process on the first verb (orientation before anything breaks), then every ~40th call.

THE TRAP, and it is general: a response chokepoint carries MACHINE traffic, not just AI traffic. I put the invite in sendJSON (all ~155 response sites, one place — correct), then measured that the first real verb after a restart carried nothing. Cause: AD probes /health (and /status for the bridge chip) every ~15s, those replies flow through the same sendJSON, and the first probe after spawn silently consumed the once-per-process invitation. The fix is to scope it to real caller traffic (res._adomCommand is set only for actual verb dispatch), so a probe can neither consume the orientation invite nor advance the periodic counter.

Generalize: any budget, counter, once-per-process flag, rate limit, or sampling decision you attach to a shared response path must ask "who is this response FOR?" first. Health probes, status chips and supervisors will burn a caller-facing budget long before a caller ever arrives — and it fails silently, because the feature simply never appears. Verify by triggering the real path (restart, then inspect the FIRST verb), not by reading the code and assuming.

Caller identity is PER-CALL, never an env var in a shared container (2026-07-25)

AD ≥1.9.180 refuses every relayed command that doesn't name the AI thread issuing it (errorCode: caller_identity_required). The adom-desktop-caller-identity skill recommends setting ADOM_AI_THREAD once per session and calls the env var "strictly better". That guidance is wrong for Adom's topology, and following it makes the Activity Log lie. John caught it instantly: "an env var makes no sense... I typically have about 20 AI threads running... that env var would be getting changed by every AI thread non-stop."

Why: the env var scopes to a SHELL ENVIRONMENT, but many AI threads share ONE container and ONE $HOME (measured: many /tmp/claude-1001/-home-adom-project/<session-uuid>/ dirs, all this container). So either every thread reports the SAME name (I put it in ~/.bashrc as documented — every sibling thread would have claimed to be "pup bridge maintenance", which is worse than no attribution because it is plausible and wrong), or threads race to rewrite one shared value and concurrent calls are misattributed. Identity belongs to the thread; the environment belongs to the container.

Correction (John pushed back, and he was right): the env var MECHANISM is fine — it is exporting it DURABLY that breaks. Measured all three forms on this container:

form works? scope
ADOM_AI_THREAD="<name>" adom-bridge-cli … (prefix, no export) yes that ONE process — leaks nothing
a bare call right after that prefix refused confirms the prefix is scoped
export then N calls inside ONE shell invocation yes, all N that invocation only
'{"caller":{"aiThread":"<name>"}}' or flat '{"aiThread":"<name>"}' in args yes that call

Why an export CANNOT clobber a sibling thread (John's follow-up, worth understanding so you don't over-correct): a process's environment is COPIED INTO IT at spawn, never shared. Each AI thread's shell is its own process, so 20 threads can each export a different ADOM_AI_THREAD in the same instant and none can see or overwrite another's — demonstrated with three concurrent subshells: A exported thread-A and saw thread-A, B exported thread-B and saw thread-B, C never exported and saw empty. (Stricter still: an export does not even survive into the next tool call of the SAME thread.)

So the ONLY thing 20 threads can actually clobber is shared state on disk — i.e. exactly one file, ~/.bashrc. That was the whole bug, and nothing else.

Prefer the PREFIX form for ad-hoc calls — thread-accurate, zero shared state, and less invasive than restructuring a verb's JSON to add a caller object. export at the top of a multi-command script is equally safe (ship.sh does this: one invocation, one identity, per-process isolation). Never in a shared profile, which is the only thing that was actually wrong. Do not over-claim this as an AD design flaw: AD accepts env-var OR args, which is flexible enough; the defect is the doc's "set it once per session / strictly better" advice, since on a persistent multi-thread container the natural way to satisfy that is a profile edit.

Two traps worth remembering beyond identity:

  • A non-interactive shell does not read the tail of ~/.bashrc. Tool-driven shells are $- = hmtBc (no i) and the stock Ubuntu .bashrc has case $- in *i*) ;; *) return;; esac near the top, so anything appended below it NEVER RUNS. My "fix" produced no error and no identity — the export simply never executed. If an env var you set in a profile appears to have no effect, check that guard before anything else.
  • Verify a config change took effect in a FRESH shell, don't assume the write was the fix. I claimed the profile fix worked; one echo of the variable proved it hadn't. Reported as adom/adom-bridge#37, asking for a global --ai-thread flag so the CORRECT mechanism is also the ergonomic one.

Shipping behavior means shipping DOCS: skills, then pkg, then release (John, 2026-07-25)

When you change what a verb returns or how it behaves, the change is not delivered until the CONSUMER's skill says so. pup ships hints programmatically (_verifyRender, renderCheck, _reportIssues, _hint), and those are the primary channel — but the container-side SKILL.md is what an AI reads when it is deciding how to use pup at all, and a concept that appears only in a response is easy to skip.

The rule: on any behavior change, update SKILL.md FIRST, publish the pkg tarball SECOND, cut the release zip THIRD. Full recipe + the verification commands live in the pup-bridge-publish skill; the reason it is a rule and not a preference is that both halves failed silently in one day:

  • Stale skill. Native-first, renderCheck/_verifyRender, AI-thread naming and the bug-report invitation all shipped; a grep of the user-facing skill found 5 of 6 concepts absent. Users would have installed a skill describing a pup that no longer existed.
  • Release outran the pkg. Releases 1.9.94→1.9.104 shipped while the last pkg publish sat at 1.9.93. Because a release and a pkg share one version list and the resolver takes the MAX row, the max row was a tarball-less release and every constraint install of the page 404'd (pkg updateSTALE_INSTALL: the registry no longer resolves it). The release pipeline reported success throughout.

Why this class of bug keeps happening here: the release half is loud (gates, hashes, on-disk verification) and the docs/pkg half is silent. Nothing fails when the skill is stale. So it needs a checklist item and a gate (ship.sh GATE 2b now checks the pkg tarball resolves at the shipped version), not a good intention. I wrote the release-vs-pkg trap into this very skill in the morning and still walked into it in the afternoon, which is the argument for enforcement over knowledge.

⛔ Verify the path that ALREADY WORKED, not just the thing you built (2026-07-25, the worst regression of the day)

I added caller-identity forwarding to pup's AD callbacks (v1.9.104), verified the new behavior (delegate headers reached AD, attribution rendered), and shipped. What I did NOT verify is whether windows still branded and appeared. They did not. AD (>=1.9.183) refuses any command with no caller identity, and I had deliberately sent nothing when pup acted on its own behalf, reasoning that "no caller" was the honest answer. AD's answer to no caller is a refusal, so EVERY desktop_* call from the bridge was rejected. One cause, two user-visible failures:

  • desktop_set_window_identity refused → no branding: five example windows all fell back to Chrome's generic icon, including OTHER threads' windows. I broke it fleet-wide, not just for myself.
  • the on-screen park refused → windows stranded at -32768,-32768, so clicking their taskbar button showed nothing at all. John's words: "i can't even click these icons to open the window?"

The contract I had just read says, verbatim, "send your own caller block when the work is genuinely your own." I read that and implemented the opposite.

The fix: adCommand ALWAYS identifies a caller — the forwarded thread when there is one, else pup bridge (self) — sent as BOTH a header and an args caller block, because AD's precedence puts args above headers and a header-only path had already regressed once the same day.

THE RULE, and it is the highest-value line in this skill: when you touch a SHARED CALL PATH (adCommand, sendJSON, a resolver, a response composer), a passing test of your new feature proves nothing about what you broke. Open a pup window and confirm it is BRANDED and ON-SCREEN. That one observation exercises identity registration, the window stamp, the jump list and the placement park in a single shot, and it is the cheapest possible regression net for this bridge:

adom-bridge-cli --target <box> --ai-thread "<you>" pup_open_window '{"sessionId":"smoke","url":"https://example.com"}'
adom-bridge-cli --target <box> --ai-thread "<you>" bridge_log_read '{"name":"puppeteer","lines":40}'   # expect "stamped appId=", NOT "stamp FAILED"
adom-bridge-cli --target <box> --ai-thread "<you>" desktop_find_window '{"titleContains":"session: smoke"}'  # rect.left must NOT be ~-32768

stamp FAILED in the log and a rect.left of -32768 are the two tells. Both are one call away and both were sitting there the whole time I was telling John the feature worked.

Rescue procedure if windows are already stranded: a CLI call carries identity, so it is accepted even while the bridge's own calls are refused — desktop_set_window_state {hwnd, state:"restore"} then {state:"maximize"} per window.

Window UX doctrine (John, 2026-07-26 — the day of the placement war)

These are LAWS. Every one was paid for with a live failure on John's machine the day 1.9.133-1.9.140 shipped.

  1. Background means BEHIND, never OFF-SCREEN and never tiny. Park = work area of the target monitor, availLeft/availTop origin (taskbar on any edge), MINUS 10px on ALL sides (the deliberate signature frame), bottom z-order, no-activate. A user's taskbar click must be pure native Windows foregrounding a correctly-placed window — zero pup code in that path.
  2. Park EVERY background window, per-window, unconditionally. _needsPark was per-LAUNCH and consumed by the first park; with the shared durable profile every later window was NEVER parked. Gates keyed on the browser, guarding a per-window behavior, are a bug class — audit for them.
  3. windowState:'normal' BEFORE setting bounds. Windows ignores geometry on a maximized window; Chrome remembers maximized per profile, so new windows often ARE maximized.
  4. Re-assert the park once (~2.5s). Chrome's late first-run self-sizing can land after the park and clobber it. Both park attempts LOG ([park]) — a silent no-park is forbidden.
  5. Never pass --window-position=-32000 (or any off-screen default) at launch. With --no-startup-window there is no window to protect; the flag only poisons the process-wide default rect of every window Chrome itself creates (popups, restores) = stranded windows whose restore rect is off-screen. If a window is ever found at -32768: that's a MINIMIZED/restore rect obeying a poisoned default, not a pup park.
  6. ONE flash per logical operation. The launch flash sets _openFlashDone; the open handler CONSUMES it (.delete()) instead of flashing again. A time debounce is NOT operation scoping — heavy sites outrun any window. A second orange after the user clicked forces them to re-click every window (measured: the 10-window stress test double-flashed all of them). Every flash logs [flash] so a double can never hide.
  7. No terminal may EVER blip. Every powershell/cmd/reg/taskkill spawn carries windowsHide:true; the runPsHidden family doubles with -WindowStyle Hidden. Sweep ALL sites when adding any shell call.
  8. Reveal must be atomic (queued). Compute final geometry, then ONE SetWindowPos for position+size+show+activate. Never raise-then-resize — John watches the jank.
  9. Foregrounding is gated on the USER'S OWN WORDS (foregroundReason, logged). Flash the taskbar instead when in doubt.
  10. Patch the code that runs, prove on glass. I patched GetSystemMetrics PS sites for TWO versions while the real geometry lived in placeOnScreen (CDP). The park was "verified" only when a fresh window's OS rect read the inset (15,15 at 150% DPI). Parse-check BEFORE ship (a # comment in a JS array shipped a dead bridge), and a rect read beats any log line.

Hard-won rules from the 2026-07-29 session (read before touching these areas)

NEVER fire native OS input without the warn-and-wait protocol. desktop_click / desktop_type / desktop_press_key go to whatever window has OS focus, not to a window you name. I typed a username into JOHN'S OWN WINDOW this way, then compounded it by concluding "Windows failed to foreground the window" — blaming the OS for a human taking their focus back. He alt-tabs away from a foregrounded window almost immediately, every time, because he runs many threads. Required: toast + caption + an explicit "don't type or click until I say done" + wait-for-foreground + re-verify before EACH event + caption when done. ensureSafeForNativeInput() enforces it in the bridge; pup_assisted_click is the ready-made path. Prefer CDP input always — it needs no foreground.

Autonomous machinery needs three safety rails, or it becomes the bug. Twice in one day something I built to "help" broke the user's windows: the tab reconciler judged live tabs dead and destroyed two sessions mid-use, and the stuck-occlusion unstick misfired every 30s and clicked his screen. Both had the same shape — act on an inference, with no cap. The rails now enforced: (1) act only on DIRECT evidence, never on inference; (2) require the fault on TWO consecutive observations; (3) hard-cap anything user-visible, and refuse a change that would be wholesale (e.g. a reconcile that would empty a session is a probe failure, not reality).

Never invent UI that Adom already has. I hand-drew a pup cursor instead of using HD's. The canonical AI cursor is adom-browser-extension/extension/src/cdp.jscursorScript (itself a byte-faithful copy of HD's hd-control AI_CURSOR_JS). Copy it; do not redesign it. Same for anything with a brand surface — check HD and the extension FIRST.

Prove taskbar/visual work ON GLASS, never from the API. The badge API reported favicon+adom success while the badge was literally invisible (dark favicon on a dark plate). Three revisions were only caught by screenshotting the actual taskbar. desktop_screenshot_screen + crop the taskbar.

Distinguish "the window is busy/throttled" from "the window is broken". visibilityState:hidden is NORMAL for a parked window. Only frontmost-yet-hidden is the broken state. Getting this wrong is what made the unstick misfire whenever the user was simply viewing a different tab.

⛔ NEVER PAINT A TASKBAR HWND YOU CANNOT PROVE IS PUP'S (the Edge-overlay incident)

2026-08-02: John saw a pup overlay painted on his real MICROSOFT EDGE taskbar button. pup drives Chrome, never Edge - so how? HWND RECYCLING. pup cached a session's hwnd; the real window changed and the cached handle DIED; Windows reuses hwnd NUMBERS, so that number was later reassigned to an Edge window; pup's overlay paint trusted the stale cache and hit Edge.

The rule, enforced in code (hwndBelongsToPup): every taskbar paint AND flash resolves to an hwnd, then PROVES it belongs to a pup browser process (the window's owning pid, or its parent pid, is in pupBrowserPidSet) BEFORE calling desktop_taskbar/desktop_flash_window. If ownership cannot be proven, REFUSE the operation - never fall back to a bare title match or a raw cached hwnd. A missed overlay self-heals next sweep; painting on someone else's window does not. Corollaries:

  • A cached _hwnd is NEVER trusted blind. resolveSessionHwndByBounds revalidates it and discards a stale/foreign handle.
  • This is why pup can promise it "never touches Edge": not by intent, by proof-of-ownership on every single OS-level window operation.

⛔ THE GATED-INTERVAL DISEASE (three victims and counting — never again)

The main health interval opens with if (minimalTouch()) return;. Minimal-touch is the NORMAL mode, so anything placed inside that interval is dead code in production - and it fails silently, because the interval still runs, it just returns first. Three critical features died this way, each discovered only when John hit the missing behaviour live:

  1. The overlay ENFORCER (v1.9.236) - "red LEDs galore", windows bare after every restart.
  2. The user-activity watcher (v1.9.180) - user-foregrounded windows were never recognized.
  3. CRASH/LOST-BROWSER RECOVERY - after a day of lid-closed sleep, every window sat on "browser lost" forever (2026-08-01).

The rule: NEVER add machinery to the gated health interval. Every new loop gets its OWN interval with an EXPLICIT decision about the minimal-touch gate, written as a comment at the top: either "gated because it kills/restarts live Chrome" or "ungated because it is user protection / state restoration". When touching the health interval, list what is inside the gate and ask whether each item is meant to be dead under minimal-touch.

Sleep/wake recovery (John, 2026-08-01, standing requirement)

"A laptop going to sleep is quite a common occurrence so you need to make sure after a wake that you are able to get back to the previous state you were in, i.e. all your browsers should not be lost anymore."

  • The recovery interval (20s, UNGATED) detects sleep by timer gap (>90s between ticks) and recovers every _lostBrowser session via recoverOrRelaunchSession: reconnect if that profile's Chrome survived, else relaunch at the last URL (logins live on disk). Backoff via _healRelaunch.
  • The minimal-touch nuance, so nobody re-gates this: minimal-touch forbids KILLING or RESTARTING a LIVE Chrome behind the user's back. It does NOT forbid resurrecting DEAD windows - restoring the user's state after sleep/crash is what the user explicitly wants. Hung-Chrome teardown (which kills) stays gated; recovery does not.

Real screenshots via MOUSE TAKEOVER (John's standing grant, 2026-08-02)

"if there are certain screenshots you need where you need to take control of my mouse to hover stuff in the taskbar, you can do this by putting up an ad toast telling me, and put captions, and then i can yield to you and let you do your thing. ad lets you take over my mouse."

The protocol, in order, no steps skipped. UPDATED 2026-08-04 - THIS SUPERSEDES the earlier buttoned-consent rule. John, 2026-08-04, after I took his screen twice without following the skill (and exposed his private tax emails in the task-view thumbnails both times): "i don't need to click go ahead. i just need you to notify me that you're going to take over my screen."

  1. PRESENCE-GATED NOTIFY. No approval, no click, no wait - EVER. John (2026-08-04): "i don't want to have to approve it cuz i want you working non-stop even if i leave my laptop... but if i'm on my laptop, fucking tell me if you are going to take it over." So:
    • There is no consent gate. pup must keep working when he's away - never block on a human.
    • If he is PRESENT at the laptop, you MUST notify him BEFORE taking the mouse/screen. Taking an active user's screen with zero warning is the rude thing he keeps catching ("stop being a rude fucking asshole"). One notify_user toast that plainly says what's happening and why (e.g. "Taking your mouse ~5s to right-click a pup taskbar icon for a jump-list screenshot"). Then proceed immediately - do NOT poll, do NOT wait for a click.
    • If he is AWAY, just work - no toast needed (he's not there to be disturbed).
    • Gate presence on idle time: use osIdleMs() (GetLastInputInfo). Active = idle < ~60s → send the toast first. Away = idle > ~60s → proceed silently. When unsure, TREAT HIM AS PRESENT and notify - a needless toast is harmless; a surprise screen-grab is not.
  2. Caption each step as it happens (≤2s each).
  3. Do the MINIMUM mouse work: one hover/right-click, screenshot, dismiss (Esc), done.
  4. Caption when finished so he knows the mouse is his again.
  5. The two-strikes lesson (2026-08-04): I sent a NON-buttoned info toast, then a buttoned one I never waited on, then right-clicked anyway. Both times it (a) violated the notice rule in spirit, (b) surfaced his private content, and (c) produced the Win11 TASK-VIEW THUMBNAILS, not the jump-list flyout (see below) - so it didn't even work. Notify honestly, then act; don't half-toast.
  6. Real screenshots ONLY - John's verdict on a mocked jump list: "your mockup is horrible. only screenshots will tell the real story to the user." If a capture fails, say it failed; never substitute a mock and call it real.

Use this for: jump-list flyouts, hover states, thumbnails - anything that exists only under a real pointer. It does NOT loosen the iron law: this is announced, brief, purposeful input, not window management.

What actually WORKED (2026-08-02, the jump-list photo): AD-driven right-clicks kept producing hover thumbnails, never the flyout (desktop_click focuses the target first, and the Win11 jump-list blip eats the first click), and a host-side window-class watcher missed (class filter mismatch). The reliable pattern is the BURST CAPTURE: toast the user "right-click it and hold the menu open 3s", run desktop_screenshot_screen in a ~1s loop for ~15s, then auto-pick the frame whose above-taskbar region has the largest Win11-menu-colored (#2c2c2c-ish) area. Human does the 2-second gesture, pup does the photography. Use this before any cleverer scheme.

Canonical term: "TASKBAR FLASH" (John + Windows, 2026-08-02, supersedes "orange highlight")

The attention state on a taskbar button is called the taskbar flash ("flash" for short) in every user-visible string. Decision trail: John called it "the orange hilite", the UI briefly renamed to "orange highlight", then he asked what WINDOWS calls it - and the OS term is "flashing" (FlashWindowEx; Microsoft docs: "the taskbar button flashes"; the steady lit state is the "attention" state). It is also NOT reliably orange: the color follows the system accent/attention theme (salmon on John's Windows 11, classic orange on Windows 10). So the OS name wins. Where color is mentioned in a description, say "accent-colored" and note the theme dependence. Internal identifiers (flashViaAD, flash-all, _flashedHwnds) were never renamed through any of this.

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

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

  • Hard cap is enforced in code (PUP_CAPTION_MAX_MS = 2000 in pupCaption), so a caller passing 5000/8000/15000 is silently clamped. Do not try to route around it.
  • ⚠ The expiry must actually REACH AD. AD renamed the arg: old builds took expiresInMs, current builds take duration. pup kept sending only expiresInMs, AD ignored it, fell back to its 30s default, and John watched a "2-second" caption sit on his screen indefinitely (caught 2026-07-31). pupCaption/wikiCaption now send BOTH names. If you add a new caption call site, go through pupCaption - never call desktop_caption directly.
  • No em-dashes in caption/toast text (John's global no-em-dash rule applies to every user-visible string pup emits, not just chat).
  • If something genuinely needs longer attention, it is not a caption. Use notify_user (a toast the user dismisses on their own terms), or write it into the verb response for the calling AI.
  • The one thing captions are great for: narrating a foreground/click the instant it happens, e.g. the granted-foreground reason flash. Short, factual, gone.

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

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

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

Every granted foreground MUST:

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

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

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

Related rules that travel with this one:

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

The FG-STEAL GUARD (John, 2026-07-31: "why did that pup window that just opened disrupt me in the foreground?")

A background pup window must NEVER sit in the OS foreground without a granted foregroundReason. Chrome self-raises parked windows (late first paint, in-page focus()), and the scripted post-nav re-asserts can end before the last raise, so the steal used to stick. The guard: for 20s after any background open/navigation, pup polls the OS foreground (2.5s tick, ONLY while a guard is armed, zero steady-state cost) and immediately bottoms an un-granted takeover. Three knockbacks inside one guard window means a human is insisting, so the human wins (mark foreground, back off). This guard is deliberately NOT gated by minimal-touch: it is user protection, the same doctrine as the foreground-reason caption. (The v1.9.180 user-activity watcher was minimal-touch-gated and therefore dead in the normal mode - that gap is exactly how the adom-theme window disruption happened.)

The pup dashboard layout (John's spec, non-negotiable)

The dashboard (/dashboard, opened via pup_dashboard in its OWN window with its own icon) MUST present three levels, top to bottom:

  1. A mimic of the Windows taskbar at the very top - one button per REAL taskbar icon, in the SAME ORDER Windows shows them, each wearing its real overlay badge. This is a map of the actual taskbar, not a re-sort. Clicking a button jumps to that node in the tree below.
  2. A TREE under it - each taskbar icon is a parent node that expands to the window(s) grouped under it. (Grouping matches reality. Since v1.9.245: every window gets a ONE-TIME gentle AUMID stamp at creation - John: "when i say to pup 'open a window' i generally want them as separate taskbar icons" - so a stamped window = its own button, and only unstamped windows (stamp pending/failed) share their Chrome process's button. Stamp ONCE, never the category re-stamp churn, no jump-list work under minimal-touch; after a stamp the button is REBORN, so mark the overlay bare and let the enforcer repaint. Get this right or the counts lie.)
  3. A TREE under each window - the tabs open in that window, as child nodes.

So it is a genuine fancy TREE (taskbar icon -> windows -> tabs), collapsible, not a flat card list.

fg/bg buttons are STATIC, never a state-toggle (John, 2026-07-31: "the notion that you would swap the bring to fg to then be send to bg is asinine"). The user alt-tabs a window to the background in a nanosecond; pup's _foreground flag cannot track that, so a toggle that renders from believed state will routinely show the wrong label. Every window row always shows BOTH [bring to fg] and [send to bg]; never conditionally swap them on w.foreground.

Icons must be CORRECT at every level:

  • taskbar button: the real composited overlay badge pup last painted (served from the debug PNG).
  • window: same badge (it is that button's window).
  • tab: the tab's OWN favicon, fetched through the page so cookie-protected app icons resolve. A wrong or missing icon is a bug, not a cosmetic nit - the whole point is a visual cue.

The LED must never overclaim (John, 2026-07-31: "you are still lying to me in the dashboard"). Windows has NO API to read back what a taskbar button shows, so a "matches the taskbar" claim from bookkeeping alone is a lie. The header LED may only show MATCH when (a) every tracked window wears a verified-painted overlay AND (b) the pup-process window enumeration finds ZERO untracked windows (real buttons pup has no session for - zombie windows from lost sessions). Untracked windows render as dashed "?" ghost buttons in the strip and force the LED red with an "N UNTRACKED" label. When the glass cannot be verified, say so - never green by default.

Always ralph-test the dashboard (tests/dashboard_ralph.sh) before showing it: the page script must EXECUTE (a served-script syntax error renders only the static shell and is invisible to node --check, which cannot see inside the template literal - extract the