Pup - Puppeteer Bridge
Public Made by Adomby adom
pup is the AI's own browser: a real, full Chrome on the user's desktop that the AI fully controls (a sandbox, not the user's signed-in browser). Rides Bridge; pup_* verbs open windows and tabs, navigate, screenshot, and eval JS.
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_windowrouting,desktop_set_window_identity/desktop_register_app_identity. - POLICY = any DECISION about pup's own domain: which browser pup drives, when/whether to provision it, what its windows look like, when they show, what its hints say. These are NEVER AD's business — pup decides, pup implements, pup ships. AD's job ends at "the bridge is installed and its verbs are routed."
The failure this section exists to prevent (2026-07-18): pup asked AD to change its prewarm
DEFAULT (#204) — outsourcing the "when does pup's browser get provisioned" decision to AD policy —
when the skip-CfT logic was sitting in pup's OWN warmup() the whole time. One-line fix in pup,
zero AD involvement, issue withdrawn. The tell was needing AD to change a default rather than
add a mechanism. If your ask to AD contains the words "policy", "default", "prefer", or "decide",
STOP: that decision is pup's, and the code that should change is in this repo.
(The same test, run in reverse, is CLAUDE.md's existing rule: don't re-absorb AD capabilities into pup. The two directions together: mechanisms live in AD, decisions live here.)
⚠ STABLE PROFILE STORAGE (v1.8.98) — never put persistent state in the bridge dir
PROFILES_DIR/SESSIONS_DIR MUST live in ~/.adom (pup-profiles / pup-sessions), NOT under
__dirname. __dirname is bridges-cache\\puppeteer, which AD CLOBBERS on every bridge_install —
so any persistent state stored there is wiped on every pup update. This silently destroyed every
persistent profile (incl. the wiki-auth 30-day login) until 1.8.98. It hid for a while because
restart_bridge does NOT clobber (only bridge_install does), so logins survived restarts but
died on the next version deploy. Rule: user-visible durable state → ~/.adom, never the bridge dir.
The THREE artifacts — know which is which (this is the #1 confusion)
A user installs TWO different things from this one page. They are not the same:
| Artifact | What it is | Who installs it | How |
|---|---|---|---|
| pkg tarball (Packages tab) | the container-side Claude skills (SKILL.md + skills/*) — docs that teach the cloud AI how to drive pup. No bridge runtime. |
a cloud container | adom-wiki pkg install adom/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":
- Discovery. The wiki's
discoverindex (fed by this page'sdiscovery_triggers— which now lead withpup,open in pup,open my app in pup, screenshot/record/tabs) matches the phrase. The container's regeneratedadom-wiki-discoverskill surfaces this page, so the AI proposes it. - Auto-install the pkg. The AI runs
adom-wiki skills install adom/pup-bridge(a.k.a.pkg install). That extracts the tarball and runsinstall.sh, which dropsSKILL.md+skills/*into both~/.claude/skills/and~/.codex/skills/. Thepupskill is now loaded. - The skill calls the bridge.
pup's SKILL.md tells the AI to runadom-bridge-cli pup_*verbs. AD routespup_*to the puppeteer bridge. - The bridge runtime. It's already on the desktop: AD ships pup as a bundled seed (and supersedes
it from this page's release zip via
updateManifestUrl/bridge_install). On a truly fresh PC the cold-start self-heal (chrome.js) installs Chrome for Testing on firstpup_open_window.
Two install paths, keep BOTH healthy:
- Discovery path (above) — works once
discovery_triggersinclude the user phrases (done) + the discover index is regenerated. This is the "user said pup → AI installs the skill" path. - AD
sync_skillspath — AD's dailymaybe_background_skill_syncreadsbridge_list→ each bridge'sskillPkg→pkg installs it into the container. For pup this requires AD to reportskillPkg = adom/pup-bridgefor thepuppeteerbridge. Today AD hardcodes it tonull(AD_CORE_SKILL_BRIDGES = ["puppeteer"]inbridge_registry.rs, from when pup's skill lived in the AD-core pkg). That must be removed now that the skill ships from this page — it's in the AD handoff.
Calling AD back (outbound) — notify the user from inside the bridge (AD ≥ 1.9.84)
Everything above is AD → pup. The reverse — pup → AD — matters when the bridge must
reach the user on its own, mid-verb, with no AI turn. The canonical case: pup installs
real Chrome on a locked-down box that needs a UAC, and has to say "click YES" (and
re-nudge if it expires). See chrome.js _elevatedInstallWithNotify for the live impl.
The channel. A bridge's only callback is AD's loopback direct-API. Resolve it:
ADOM_DIRECT_API_URL env → else read %USERPROFILE%\.adom\direct-api-port (host:port) →
else 127.0.0.1:47200. Then POST /command.
AD ≥ 1.9.84 exposes the FULL verb surface to bridges (before that, only desktop_* +
other bridges' verbs — notify_user returned "Unknown desktop command", which is why the
degraded path exists). Contract:
appis inferred — send{ "command": "<verb>", "args": {…} }, noapp.- Token (optional, attribution only): send the spawn-time
ADOM_BRIDGE_TOKENenv as theX-Adom-Bridge-Tokenheader → AD badges the callbridgein its Activity Log. A stale token → 403; on 403 retry once WITHOUT the header. Omitting it entirely is fine (a bridge is trusted-by-install; calls run ungated). - Capability-probe, don't hardcode:
GET /commands→notify_userappears in.topLevelon ≥1.9.84. Gate the notify flow on that; fall back to raise-UAC-once + a relay hint on older AD. pup caches this asreadiness.chromeStableNotifyChannel. - Cross-AD
target: add"target": "<clientName>" | "all"to ANY/commandto route it to a PEER AD via the relay — e.g. pup runs on a VM, the user is on their laptop, so ping the laptop while the UAC stays on the VM."attended"(input-active box) is not resolvable yet → enumerate peers and fan out. targetsgotcha: returns{ clients: [...] }and includes self (NOT{ targets: [...] }). Readclients, drop your ownos.hostname(), dedupe.
The two UX rules the impl bakes in (both were real bugs first):
- Notify-FIRST, then raise the UAC. Raising the UAC puts up the secure desktop, which
hides any toast. So fire the sticky
scenario:"reminder"toast with anApprove nowbutton, pollnotify_response {id}for the tap, and only THEN elevate. - Never spam prompts you can't explain. If the notify channel is unreachable, raise the
UAC once and surface
awaiting_uac+ a hint for the AI to relay — do NOT loop re-raising a UAC the user is never told about.
Repo layout
src/— the runnable bridge (what the release zip contains, at zip-root).server.js,chrome.js,credential_vault.js,bridge.json,BRIDGE_VERSION, nodepackage.json/package-lock.json, html/icons.SKILL.md(root) — the user skill (name: pup). Deployed to~/.claude/skills/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 viarepo clone/ Files tab), NOT inpackage.json files[], NOT installed byinstall.sh, and they need nouser-invocablescoping (they never reach a container). Open-vs-closed source doesn't change this.adom-bridge-puppeteer-manifest.json(root) — the streaming manifest AD'supdateManifestUrlpoints at.package.json(root) — the adompkg descriptor (slug/version/type/description) forpkg publish. NOT the node one.install.sh/uninstall.sh— deploy ONLY thepupuser skill (SKILL.md) into~/.claude/skills/+~/.codex/skills/.page.json,README.md,*.pngheroes — the wiki page presentation (the heroes are gitignored so the container pkg stays lean — they're already uploaded as page assets).
Cold-start self-heal — the architecture (what 1.1.0 added, the reason this repo exists)
On a fresh PC (HD bundles AD; the installer pre-installs neither Node nor Chrome for Testing), the first
pup_open_window used to fail with a generic "Failed to launch Chrome after 3 attempts" + a dev
string. Now:
src/chrome.js—detectChrome()(validates the ACTUAL exe, not "a dir exists"),installChrome()(programmatic via the bundled@puppeteer/browsers, pinned to the buildId puppeteer expects — never 'stable', or launch() won't find it), an install-state singleton (dedup + progress),ensureChromeReady()(the gate — background install, returns immediately),readiness()(powers the verb + chip).pup_open_windowgate (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)./statusemitsled/summary/tooltiptruthfully (yellow installing, red failed, green ready).- Node is handled by AD core BEFORE the bridge spawns (
node_not_found→desktop_install_node) — not this bridge's job (the bridge is a node process; it can't run without node). - The one thing this bridge can't do: pre-warm before first use (it isn't running until the first
call). AD/HD must call
pup_prewarmon embedded first-run — tracked inHANDOFF-TO-ADOM-DESKTOP.md.
Field-tested cold-start learnings (v1.3–1.5, from 3 real fresh Windows boxes)
Tested on AdomLapper (all browsers), AdomTestWin11 (fresh vbox, Edge-only), winvm (fresh Azure, Edge-only). What we learned and baked into the code:
- Prefer the installed browser, don't download CfT. Every Windows PC ships with Edge; most have
Chrome.
resolveBrowser()/launchCandidates()return [pinned default → Chrome → Edge → cached CfT]; the launch loop spawn-verifies each and falls through on failure. A fresh isolated--user-data-dirkeeps the system browser CLEAN (never touches the user's real tabs). CfT is now a genuine last resort. - Edge "exits immediately" was a false negative. Edge (and some Chrome channels) spawn a LAUNCHER that
relaunches the real browser onto a DIFFERENT PID, then the launcher exits. The old poll treated
launcher-death as failure → killed good Edge launches. Fix: probe CDP FIRST each tick, and on
launcher-death do a final CDP grace probe before giving up (
tryDetachedLaunch). - A failed candidate poisons the shared profile dir. An aborted Edge leaves
SingletonLock/DevToolsActivePortin the userDataDir → the next candidate (Chrome/CfT) then also "exits immediately." Fix:killChromeHoldingProfile()(lock cleanup) BETWEEN candidates, not just between attempts. - Persist the chosen browser (
pup-browser-default.jsonin the CfT cache dir).markVerified()caches the browser that actually launched (auto default);pup_usepins one (forced default). Avoids re-probing every open. Forced beats auto; launch still falls back so a broken pin never hard-fails. - Node install has a PATH-staleness trap (AD-core, but guide the AI).
desktop_install_nodeinstalls Node, but the already-running AD process keeps its OLD PATH — so the very nextpup_*STILL returnsnode_not_found/could not run npm. Reinstalling doesn't help. AD must be relaunched (desktop_relaunch_self) to pick up the new PATH. This is the exact "installed node, still broken" dead-end. Documented in SKILL.md + HANDOFF (AD should probe common node install dirs / refresh PATH, not rely on the stale env). winget also returns non-zero when Node is ALREADY present ("No available upgrade") — that's success, not failure. - Self-warm on spawn (
chrome.warmup()fromserver.listen): detect browsers + disk the moment AD installs/updates the bridge, so the first open is instant. Only background-fetches CfT if the box has NO Chrome/Edge AND no cached CfT (never on an Edge-only box — Edge is right there). - Disk + specific error codes.
diskFreeMb()gates the CfT install and the cold-start; failures map tochrome_install_no_disk/chrome_download_network/chrome_install_permission(not a generic string) so the AI's hint is actionable.readiness()surfacesdiskFreeMb/lowDisk. - Install REAL Chrome on request.
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), whichlistSystemBrowsers()already finds. Elevated context may install machine-wide + raise UAC — surfaced viachrome_stable_not_detectedhint. - Verbose hints everywhere. Every open returns
browser(what we drove) +availableBrowsers+defaultBrowser+ an_hintexplaining how to switch/install. Goal: the AI resolves any browser situation in as few turns as possible without a human. - Desktop permission prompts gate open-ended verbs (learned live). Core AD verbs + this bridge's
pup_*verbs run with NO prompt. Butshell_execute/run_script/ arbitrary scripts pop an AD "Shell command needs approval" dialog (Approve 1h/24h/Bypass) — and the call hangs until approved (we sawshell_executetime out on the vbox with the popup waiting; winvm had it auto-approved and showed nothing). Two consequences baked into SKILL.md: (1) prefer core/pup_*verbs over shell; (2) before anyshell_execute,notify_userthe person to approve the popup. This is a separate gate from the Windows UAC (admin elevation) — don't conflate them. AD ≥ 1.9.86 addendum: every gate-hit now auto-surfaces the in-window approval dialog + toast + taskbar flash on the target box (1.9.84 briefly regressed to a silenthuman_onlyrefusal with NO prompt — bricked shell on headless/RDP boxes). Onneeds_approval/human_only: runadom-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 callshell_auto_approve— it's human-only and refuses by design. - Windows Firewall prompt = bind loopback (fixed v1.6.0). A hostless
server.listen(PORT)binds ALL interfaces (0.0.0.0), so Windows pops "Allow Node.js through the firewall (public/private)?" on first listen — a THIRD scary prompt after UAC + shell-approval, and worse on a fresh portable-node path the firewall has never seen. Fix:server.listen(PORT, '127.0.0.1', ...). AD only ever talks to the bridge over loopback, and loopback sockets are firewall-exempt → the prompt never appears. - Zero-prompt Node bootstrap IS possible with typed verbs (PROVEN on winvm). pup is a Node process, so
it can't self-install Node — but the AI can, using AD's UNGATED typed verbs (only
shell_execute/run_script/wsl_exec*prompt; everything typed does not). Proven flow, zero popups:send_filesthe portable Node zip →launch_processC:\Windows\System32\tar.exe -xfto extract →registry_get+registry_setHKCU\Environment\Path (append, REG_SZ) → user reopens AD (app restart, no prompt) so it re-reads PATH. NO UAC, NO shell-approval, NO firewall. The recipe lives in SKILL.md. The only human step is the AD reopen, which the shared-runtime proposal (absolute-path spawn) removes.desktop_install_node(UAC MSI) +shell_execute(approval popup) are fallbacks only.
Cold-start bugs found on fresh boxes (AD-owned — report, don't try to fix in pup)
AD npm-install can't find
nodefor lifecycle scripts (fixed ~AD 1.9.69). On AD 1.9.63–1.9.65 the managed runtime rannpm installby absolute path but didn't put node on the child-process PATH, so any dep with a native/postinstall script that shells tonode(keytar, sharp, puppeteer'sinstall.mjs) died:'node' is not recognized. Pup-side mitigation shipped v1.7.1:sharp+keytarareoptionalDependencies+sharploads gracefully (screenshots fall back to capture-size via a PNG-header dimension read). AD fixed the PATH on ~1.9.69 (npm install now succeeds on a fresh Edge-only box).AD node-gate false-negative (open as of AD 1.9.69). On a fresh box,
runtimesshowsnode.state:ready(adom-managed), the bridge isstatus:runningwith a live pid, andpup_readiness(the declared statusVerb) works — but EVERY otherpup_*verb returns AD-corenode_not_found. The node pre-gate only exempts the statusVerb and does a stale/PATH-based node check that misses the portable managed runtime. pup CANNOT fix this (node_not_found fires in AD before the bridge). Reported on discussion #59; SKILL tells the AI to recognize it (readiness OK + runtimes ready + other verbs node_not_found = AD bug, don't loop desktop_install_node).Takeaway: when a fresh-box open fails, first read
runtimes+bridge_list(pid/status) + trypup_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 butping/core verbs work. Checkbridge_list→status:"running"withlivePids:[]= a bridge that crashed at spawn while AD still forwards to its dead port. Read~/.adom/bridge-logs/puppeteer.log— the 1.9.77 regression showed Node dying at boot:EISDIR: lstat 'C:'(AD mangled the entrypoint path at the drive-letter colon). Cross-check another box on a different AD version to isolate AD-vs-pup. pup can't fix AD's spawner — report with the log + version comparison (that report got it fixed same-day).Docs can have THREE copies (SDK lesson, 2026-07-04). The bridge-SDK page carried the same content in README.md, SKILL.md, AND bridges-SDK.md — an edit landed on two and left the third stale, so audits reading the third saw pre-update text. When auditing against a doc, verify the EXACT file you read; when publishing multi-copy docs, push and verify every copy.
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 onadom/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:
- Every pup window opens in the BACKGROUND. Never on the user's screen, never with their focus.
- 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.
- Every pup-initiated foreground captions its REASON for 2 seconds. Enforced at the choke
point:
pup_raise_os_windowcaptions inline, andosRaiseSessionWindow(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.) - 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:
- Spawn with
--no-startup-window --window-position=-32000,-32000(no--start-maximized).tryDetachedLaunchonly sets_needsPark— there is NO window yet to manage. - Connect CDP; in launchSession
newPage()creates the window (off-screen, unfocused — the OS denied it the foreground). The park runs right here — theawaiton newPage IS the "window exists" event. No wait loop, no polling. parkSessionWindowDirect(page, sessionId):- hwnd via AD
desktop_find_window(native, matches the "(session: " title suffix the welcome page just set), retried together with the bottoming below (~8 × 300ms). - Z-bottom UNCONDITIONALLY via AD-core (v1.8.63): ONE call —
desktop_set_window_state {titleContains, state:'bottom', force:true}(AD ≥1.9.115; both verbs shipped from pup's feature requests).force:truebottoms even a foreground window. There is deliberately NO user-click escape hatch at park time — see the dead ends: "foreground at park" has non-user causes and honoring it forced never-clicked windows onto John's screen. A legacy PS fallback (osBottomUnlessForeground) remains ONLY for desktops on AD ≤1.9.114 (no state:'bottom', or force ignored + skip-on-foreground) — delete it once the fleet is ≥1.9.115. - HARD RULE: never move the window on-screen until 'bottomed' is CONFIRMED. If bottoming
can't be confirmed after the retries, return an honest
park-failedand leave the window invisible off-screen — the self-heal (reattach / open_tab / switch_tab / any status query) retries it. An un-bottomed window moved on-screen lands ON TOP of the user's work (the nxp.com pop, v1.8.59). - Geometry via CDP plain bounds ONLY (
Browser.setWindowBounds {left,top,width,height}, normal state), addressed by windowId — parking the WRONG window is structurally impossible. Size comes from the page's ownscreen.availWidth/HeightWITH RETRIES (a single probe can fail mid-navigation; retry, don't fall back). NEVERwindowState:'maximized'and NEVER ADdesktop_set_window_bounds— BOTH RAISE the window (see dead ends). - TRUST NOTHING — verify the Z-ORDER after placement (v1.8.66): re-bottom after the move,
then confirm via AD
desktop_list_windowsthat pup's hwnd is NOT topmost and sits below at least one non-pup window. Unconfirmable after a retry → move the window BACK OFF-SCREEN and reportpark-failed. A window that might be covering the user never stays on-screen. This check is what caught ten versions of placement bugs the focus-based checks all missed. - Self-verify POSITION via CDP
getWindowBounds(left/top > -5000), NOT focus — a focus-only verify passed while the window sat off-screen ("backgrounded" lie). - The launch-gap contract: a user who clicks the taskbar button within the ~2s before the park lands may see nothing (window still off-screen) or get parked once — they click ONCE more and the window comes up and STAYS (nothing re-parks after the park exits). That is the accepted trade; do not "improve" it with foreground detection.
- hwnd via AD
- The open response awaits the verdict (
_bgPromise); badge (badgePupTaskbar) + flash fire the INSTANT the park lands — no blind 1.5s/3.5s badge timers. - Nothing ever re-parks.
foreground:trueopens skip all of this and launch maximized on-screen. - Remaining AD gap (issue filed): a
send-to-backwindow state; when AD ships it, deleteosBottomUnlessForegroundand pup is 100% PS-free.
Supporting principles (subordinate to the algorithm above — where older text conflicts, the algorithm wins):
- Launch OFF-SCREEN → satisfies R1. Background windows launch with
--window-position=-32000,-32000(NOT--start-maximized; strip it when!foreground). The window is created far past any monitor, so nothing ever appears on screen during startup. CDP renders regardless of position, so screenshots/eval/record all still work. - The resting state is ON-SCREEN at the bottom of the z-order → satisfies R2 + R3. On-screen-occluded (behind the user's windows, taskbar-clickable, still compositing) is the ONLY resting state that keeps screenshots working — see the minimize dead-end.
- DIRECT APIs, not PowerShell (John: "why are you using powershell? that's heavy. why aren't you
just calling direct api's?"). Geometry = CDP
Browser.setWindowBoundsby windowId (exact window, ~10ms). Window lookup = ADdesktop_find_window. Foregrounding = ADdesktop_bring_to_front. Flash/badge = ADdesktop_flash_window/desktop_taskbar. The one residual PS (osBottomUnlessForeground) exists ONLY because z-order has no direct API yet; it also readsGetForegroundWindow— the ONE honest "did the user click it" signal. Never grow the PS surface. (AttachThreadInputfocus-handback machinery still exists in the legacy helpers used bypup_lower_os_window; with--no-startup-windownothing steals focus, so new code never needs it.) - What little PowerShell remains runs HIDDEN → satisfies R5.
execFileSync('powershell.exe', [...args], {windowsHide:true})+-WindowStyle Hidden(seerunPsHidden), NEVERexecSync('powershell …')— that goes through cmd.exe and flashes a console window. (A cmd blip during a redeploy is AD respawning the node process, not our runtime.)
SELF-VERIFY, always — and verify POSITION, not just focus. The park's verdict comes from CDP
getWindowBounds (on-screen?) — a focus-only check once said "backgrounded" while the window sat
stranded off-screen. The verdict surfaces as the background field in the open response +
getSessionInfo ('backgrounded' | 'user-foreground' | 'park-failed' | 'no-window'). Test on a box
with a LIVE interactive desktop (an RDP-disconnected box reports a degenerate foreground and lies);
have the USER click taskbar buttons early and late — that's the test every automated check missed.
SELF-HEAL (v1.8.57) — windows must not stay broken. A window created under a buggy build (or any
future placement failure) used to sit stranded off-screen forever: its taskbar button "activated" an
invisible window and the badge was gone (this bit the adom-shotlog app's long-lived shared window).
Every natural touchpoint (bridge-restart reattach, open_tab, switch_tab) now runs
healOffscreenWindow: a cheap CDP bounds check, and ONLY if actually off-screen, re-park + re-badge.
Healthy or user-held windows are never touched.
BADGE (v1.8.58) — check the result, debounce only success. badgePupTaskbar verifies AD's
response and logs [badge] applied/FAILED; the tab-hot-path debounce (10 min) arms ONLY on success.
The old fire-and-forget + unconditional debounce meant one silent failure = a bare icon for 10+
minutes (John: "there's no favicon on this window? wtf?").
KNOWN WART (open): AD's restart_bridge/bridge_kill can take the pup-spawned Chrome processes
down with the bridge — every pup window (including other apps' windows, e.g. shotlog's) vanishes on a
bridge update. Normal users only hit this on AD auto-updates; apps recover by reopening on next use.
If this starts mattering, file it against AD (process-tree kill) — do not "fix" it by re-parenting
Chrome in pup.
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_boundsfor background placement (1.8.65) → ALSO raises the window (despiterestore:false), ANDdesktop_list_monitorsreports logical px while set_window_bounds takes physical px → on-top AND undersized. Geometry for a parked window = CDP plain bounds (left/top/width/height, normal state) sized from the page's ownscreen.availWidth/Height, ONLY. - Trusting that a confirmed park stays parked → the open's FIRST cross-origin nav (welcome
file:// → https) swaps renderer processes and re-shows the window at first paint — RAISING it
after the z-confirm passed. A later same-window navigate does NOT raise (tested), nor does the
badge (tested). Re-asserts after the raise still left a VISIBLE blip (another thread's window
popped over John's work between paint and re-assert). THE fix (v1.8.68): park AFTER the page
settles — wait for the load event (capped 2.5s) while the window is still OFF-SCREEN, so the
paint-raise is invisible; only then bottom → place → z-confirm. First visible frame = full-size
at z-bottom. A late (3s)
force:falsere-assert plus the open_tab re-assert remain as belt-and-suspenders. And the ONLY honest verify is the Z-ORDER viadesktop_list_windows(pup window below at least one non-pup window); if z cannot be confirmed, the window goes BACK OFF-SCREEN — never leave a window that might be covering. --start-maximized+ send-to-back after connect → the window is foreground/maximized for 1–3s before you lower it = the visible pop. This is the naive version; off-screen launch replaces it.SetForegroundWindow(usersWindow)alone → silently no-ops (foreground lock). You MUST use theAttachThreadInputattach/detach dance.- Minimize as the background state → tried TWICE now (SW_MINIMIZE 1.8.26-era, and full
SetWindowPlacement-restore-rect minimize in 1.8.47). It's SO tempting because minimizing hands focus back for free and a taskbar click restores-and-stays (no re-park loop). But a minimized window reportsdocument.visibilityState === 'hidden'and Chrome SUSPENDS its renderer, soPage.captureScreenshothangs ~27s then errors — EVEN WITH--disable-features=CalculateNativeWinOcclusion+--disable-backgrounding-occluded-windows(those cover OCCLUDED windows, not MINIMIZED). Breaks R3. REJECTED — do not try minimize again. On-screen-occluded is the only state that both hides the window AND keeps it rendering. (osMinimizeToBackgroundmay still be in the tree, unused — do not wire it back.) MainWindowHandleto resolve the window → returns a hidden Chrome helper on some boxes; you park a phantom and the real window never moves. EnumerateChrome_WidgetWin_1instead (technique #2).- ANY re-park loop after the initial park — blind timers, input-aware, short-windowed, stability
watches — ALL of them → each variant was shipped (1.8.44→1.8.50) and each one eventually yanked a
real user's taskbar click back to the background. Input heuristics (
GetLastInputInfo) false-fire whenever the user types anywhere; foreground checks can't tell an early click from a self-raise; even a 50ms-reaction stability watch fought a click in the first 2s. The ONLY correct fix is--no-startup-window: no steal can happen, so no loop is needed. If you think you need a re-park loop, you have re-broken the launch flag — fix that instead. document.hasFocus()(CDP) as the user-click signal → LIES for CDP-created windows: reports internal focus even when the OS never foregrounded them. False-fireduser-foregroundon EVERY open (1.8.53).GetForegroundWindow()at PARK TIME as the user-click signal (the launch-gap escape hatch, 1.8.52-1.8.59) → even the honest OS signal is NOT a user signal during the launch gap: Windows REASSIGNS the foreground to a brand-new window when the previous foreground window was just destroyed (a bridge-update restart killing pup windows did exactly this), plus creation blips. Honoring it force-foregrounded windows the user NEVER clicked (nxp.com, another thread's cpconn) — the #1 forbidden behavior. REMOVED in 1.8.60: the launch park bottoms unconditionally; a user who clicked during the ~2s gap clicks once more and it sticks. GetForegroundWindow remains valid AFTER startup (the late re-park paths), where a destroyed-foreground handoff can't target the window.- CDP
windowState:'maximized'for background placement → Chromium implements it as ShowWindow(SW_MAXIMIZE), which RAISES the window to the TOP of the z-order without stealing focus — so every focus-based check passes while pup windows sit stacked ON TOP of the user's work (latent 1.8.55→64; John caught five at z=0-4 under his editor). The "small-then-grow" and the nxp.com "pop" sightings were this raise. Full-size = CDP plain bounds sized fromscreen.availWidth/Height, never the maximize state. - AD
desktop_set_window_boundsfor background placement → ALSO raises the window (even withrestore:false), ANDdesktop_list_monitorsreports LOGICAL px while set_window_bounds takes PHYSICAL px (windows came out on top and undersized, 1.8.65). If AD ever ships a documented no-raise bounds verb, re-evaluate; until then geometry is CDP-only. - Trusting ANY placement primitive without a z-order check → three different placements raised the window and every one shipped because verification only checked focus and position. The z-verify + revert-off-screen (v1.8.66) is not optional.
- PS window-hunting (EnumWindows /
MainWindowHandle/ pid+CIM child walks) to find the window → heavy (~1-2s process+compile per park = the whole "first 2 seconds" gap) AND mis-resolved under parallel opens (parked the wrong/phantom window; the real one stayed invisible off-screen while the focus-only verify said "backgrounded"). CDP addresses the exact window by windowId; ADdesktop_find_windowresolves the hwnd natively. PS is for the one call with no direct API. - Computing "full-size" from
screen.availWidthvia page.evaluate → the probe can fail mid-navigation and the fallback ships undersized (1600×900) windows. Use CDPwindowState:'maximized'— the OS defines full-size. WS_EX_NOACTIVATEon the window → Chrome self-activates anyway (so it doesn't fix R4), AND a non-activatable window can't be brought up by a taskbar click (breaks R2). Rejected.- Minimize→re-show loop as the focus drop → on an on-screen window the minimize/restore is a visible
flicker; a burst of retries strobes. The
AttachThreadInputhandback needs no minimize, so no flicker. - A pup-side localhost reachability probe → redundant; AD core already reprimands a localhost URL when it fails to load (and HD's working localhost passes clean). Don't re-add it.
The user↔AI balance — background for the AI, but the HUMAN can take any window (John's rule)
This is the governing principle, stated by John (2026-07-14), and it OVERRIDES a naive "always keep it backgrounded" reading of the playbook above. pup windows are predominantly background tasks that must not disturb the user — BUT the same windows are real windows a human has to be able to open, click, see, and use normally. Both must hold at once. Get the handoff right:
- Default = background (off-screen launch → on-screen z-bottom, focus handed back). For AI automation.
- The moment the user takes a window, it becomes a normal foreground window — visible, on-screen, usable, and it STAYS that way. pup must never fight a deliberate user action.
How pup tells a user-raise from Chrome's self-raise — FINAL ANSWER (v1.8.60): it doesn't detect
user-raises AT ALL, in either direction. --no-startup-window means Chrome can't self-raise, and
pup makes exactly ONE park then exits — so there is nothing to detect and nothing that can fight
the user. Every detection signal was shipped and every one false-fired (GetLastInputInfo,
document.hasFocus(), even GetForegroundWindow at park time — see dead ends).
The contract:
- During the ~2s launch gap a taskbar click may show nothing (window still off-screen) or be parked once. The user clicks ONCE more and the window comes up — cheap, bounded, honest.
- After the park, the user owns every raise, forever. Nothing in pup can background a window
the user brought up. The only re-park paths left are the explicit
pup_lower_os_windowverb and the off-screen SELF-HEAL (which checks bounds first and never touches an on-screen window).
The browser demarcation line — the Aditya/Google-auth incident (2026-07-17, GOLD, do not forget)
The principle: pup is the ANONYMOUS, SANDBOXED automation browser. The user's identity lives in their branded browser (abe's domain) or a plain native OS launch. When pup runs on the user's INSTALLED branded Chrome/Edge, that line blurs and the browser's own identity machinery interleaves with pup's "anonymous" windows in ways pup cannot control. Chrome for Testing (CfT) IS the line of demarcation: an automation-first binary with no profile/sync/sign-in machinery, visibly distinct from the user's browser.
⚠ CORRECTION (2026-07-19, John proved it live): an earlier version of this section claimed
CfT LACKS the profile-creation machinery and that the poisoning was branded-Chrome-only. THAT IS
FALSE. Chrome for Testing shows the SAME "Sign in to Chromium? Set up a work profile" dialog after
a Google login — the exact prompt Aditya clicked. Do NOT reason that "CfT is immune." The real
mitigation is LAUNCH FLAGS (v1.8.95): --allow-browser-signin=false --disable-sync --disable-features=SigninIntercept,DiceWebSigninInterception,ProfilePicker,... --disable-signin-promo
suppress the dialog at launch so no user ever sees it. CfT is still the right browser (automation
build, no branded skin), but its safety comes from these flags, not from any inherent immunity.
The incident, step by step (each step looked reasonable; the cascade was a mess):
- Aditya's AI thread set up adom-google, which needed a Google OAuth sign-in page. The thread asked pup to open it. pup opened it in a fresh anonymous profile — of the INSTALLED branded Chrome.
- He signed into Google. Branded Chrome then offered to turn that anonymous profile into a full signed-in Google work profile. He said yes (it looks like the right thing to say).
- Now he had TWO Chrome profiles for his work identity. Chrome detected the duplicate and prompted him to CONSOLIDATE profiles. Confusion begins.
- The consolidation opened a new profile-colored ("green") window WITHOUT his original URL. He was now fully lost.
- He asked his AI to open the page again → a NEW pup window (fresh anonymous profile) → not signed in anymore → he signed in again → the whole cascade repeated.
The lessons, each of which is a rule:
- Auth flows do not belong in pup. pup's profiles are disposable and anonymous BY DESIGN; a
user signing into a real account inside one either gets lost on the next fresh window or triggers
the branded browser's profile machinery. When a caller opens a known auth/login URL
(accounts.google.com, login.microsoftonline.com, okta, auth0, github.com/login, etc.), pup should
answer with a HINT: "pup is the anonymous sandboxed browser — for signing into the USER's
accounts use abe (nbrowser_*, their real signed-in browser) or a plain native OS launch
(AD-core
desktop_open_url), where their sessions and password manager already live." - The plain native OS launch must stay a first-class option. AD-core
desktop_open_url(ShellExecute → default browser) is the ZERO-machinery path: the user's own browser, their own default profile, no bridge in the loop. John's words: "we're almost getting too fancy with browsers now" — when in doubt, the boring OS-level open is correct, especially for auth. - Branded-browser fallback is where identity bleed happens. Even a perfectly isolated
--user-data-dirruns the USER'S branded binary: first-run/sign-in/default-browser promos, profile-creation offers after login, profile-consolidation prompts, sync nags. None of that exists in CfT. This incident is the concrete case for wiki issue #202 (prefer CfT): the pick order should converge to cached CfT → installed Chrome → Edge → CfT-download-last-resort, with a backgroundpup_prewarmwhenever a branded fallback was used, so every box reaches the deterministic CfT state by its second session without ever stalling a first open. - Visual demarcation matters to the human. Part of the confusion was that pup's window WAS branded Chrome — indistinguishable from his real browser (plus a surprise green profile window). With CfT the pup window is a distinct binary (plus the teal taskbar badge), so "this is the AI's sandbox, not my browser" is legible at a glance.
Provisioning ownership (v1.8.77): pup provisions its OWN browser. chrome.warmup() at every
bridge spawn ensures CfT is cached (background fetch, low-disk guarded) — regardless of what
branded browsers exist. Do NOT ask AD to carry prewarm policy for pup (issue #204 was withdrawn
for this reason): AD's job ends at "the bridge is installed"; the bridge's browser is the
bridge's business. If a first open ever waits on chrome_for_testing_installing, the warmup was
skipped or failed — check the [warmup] log line, don't add AD policy.
Ecosystem map (who owns what): pup = anonymous sandboxed automation (CfT ideally). abe
(nbrowser_*) = the user's REAL signed-in browser, for anything needing their sessions/identity.
desktop_open_url = plain native OS launch, no automation, the right tool for "just open this for
the user" and for auth when abe isn't warranted. Steering between these belongs in pup's hints and
the pup/abe skills — the calling AI can only pick the right surface if the surfaces say who they are.
Full icon/identity takeover (option 3) — prototype findings 2026-07-18 (do NOT re-derive)
Goal: pup windows should read "Adom Pup" with the Adom icon EVERYWHERE (taskbar, alt-tab, pins) — no Chrome logo, no CfT "Test" icon. Prototyped live on AdomLapper; every mechanic below verified.
What works, and the resolution chain:
- AUMID stamp:
SHGetPropertyStoreForWindow+ setSystem.AppUserModel.ID({9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3},5, VT_LPWSTR) = works cross-process, persists on the window. Gives pup windows their own taskbar grouping identity ("Adom.Pup"). - Taskbar button icon:
WM_SETICON(big+small) takes effect immediately. - Alt-tab: resolves through the AUMID → a REGISTERED Start Menu .lnk carrying the same AUMID (icon + display name). An unregistered AUMID falls back to the exe resource (the CfT "Test" icon). Registration is read at WINDOW CREATION and cached by Explorer's AppResolver — register BEFORE any window exists (install time), and know that a just-created .lnk needs an Explorer restart to be seen (prototype confirmed: even fresh windows + explorer bounce weren't enough alone — see the handle lesson below, which contaminated that test).
- Pinning trap SOLVED: point the registered .lnk at
adom-desktop-cli.exe 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):
- 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. - New AD verb
desktop_set_window_identity {hwnd|titleContains, appId, iconPath}: AD loads the .ico ONCE (handles live as long as AD runs), stamps the AUMID property + WM_SETICON per window. - pup calls it at park time, same place as the badge; overlay badge retired for pup windows
(kept for explicit branded
pup_usepins, where the base icon is genuinely Chrome/Edge). Rejected: patching CfT's exe icon resource (breaks signature/sha validation, SmartScreen/AV risk).
Per-session AUMID registration + the wiki view-toggle jump list (#207/#208, v1.9.2–1.9.3)
Two AD verbs shipped that close the last gaps in the identity story. Both are AD-owned (the long-lived native process that owns the HICONs, per the hard lesson above); pup just calls them.
#207 — desktop_register_app_identity {appId, displayName, iconPath, shortcut:false} +
desktop_unregister_app_identity {appId} (AD ≥1.9.136). Alt-Tab and the taskbar right-click
menu HEADER resolve a window's icon/name through its AUMID via a REGISTERED entry; an
UNREGISTERED per-session AUMID (Adom.Pup.<sessionId>, which split mode uses so each window is
its own taskbar button) fell back to the chrome.exe icon. shortcut:false registers the AUMID
registry-only — no Start Menu .lnk, so no launcher spam for these throwaway per-window ids.
pup registers on window create (registerPerSessionAumid, fired from stampPupIdentity in split
mode), tracks live ids in _registeredSessionAumids, and unregisters in 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-debugskill. Read that FIRST when pup is misbehaving (verbs timing out, dim LED, respawn loop, window vanishing). The notes below are the condensed version kept here for continuity.
A live incident that ate an hour: after a restart_bridge, pup's verbs (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 status → browser.bridgeRunning,
and simply whether a real pup verb answers. bridge_list status:"running" is NOT liveness —
it's desired/installed state and will say "running" even when the process is down. (Cost me a wrong
"pup is running" claim to John. Trust the LED + browser.bridgeRunning + an actual verb reply, and
bridge_check_updates liveVersion/staleProcess for version truth.)
3. restart_bridge REAPS; only some verbs RESPAWN. restart_bridge (and a crash) leaves pup
down until AD auto-spawns it on the next call — but AD spawns it for WINDOW/TAB verbs
(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}returningapplied:truedoes NOT mean the button went clean — verify, don't claim.- The only thing that clears the tint is activating the window. That's why a user clicking the button
clears it. To clear it programmatically without a visible pop, activate it off-screen:
move off-screen →
SetForegroundWindow(invisible, clears the tint) → park back on-screen z-bottom → hand focus back (AttachThreadInput). This isosClearFlashByActivate, wired intopup_alert_window {stop}. It reportscleared/already-foreground(not a fire-and-forget guess). - Programmatic flash STATE (answer "which windows are still flashing?" / "which window hasn't the
user seen?" without a screenshot):
getSessionInfo.flash=none|pending|cleared, from the server-side flash ts (_flashState) vs the in-page focus latchwindow.__pupFocusTs(injectedfocuslistener).pending= flashed, user has not activated it since;cleared= user activated it after the flash. Gotcha (cost a live demo): the hook must be injected into the CURRENT document withpage.evaluateIN ADDITION toevaluateOnNewDocument— the latter only fires on the NEXT navigation, so an already-navigated page never gets the hook and every clicked window keeps reportingpending(v1.8.45-55 bug, fixed 1.8.56).
Meta-rule (John, 2026-07-14): think through ALL the variables before shipping a window/focus change — human-usability AND AI-automation together. When John gives new feedback here, write it into THIS skill so it is never re-derived or re-lost.
Publishing (the wiki has TWO storage layers — don't mix them)
Full recipe in PUBLISHING.md. The load-bearing rules:
- The bridge
.zip→ a RELEASE asset (adom-wiki release create+release upload). NEVERrepo pusha.zip— it's gitignored and silently skipped; the manifest URL would 404. - The manifest JSON + source → the git repo (
adom-wiki repo push). The manifest'surlis the RELEASE download URL; itssha256/sizeMUST match the served asset (releases preserve your sha; re-download to confirm orbridge_installrejects it). - The skills → a PKG (
adom-wiki pkg publish). The pkg description (rootpackage.json) must say "container-side skills."pkg packhonors.gitignorefor exclusion (no separate npmignore) — that's why the heroes are gitignored, to keep the tarball lean. Set per-version notes withadom-wiki pkg notes. - Version display gotcha: a RELEASE shows up in
adom-wiki pkg infoas a row withtype:null(a wiki UI conflation). The real pkg rows havetype:"app". Userelease listfor true releases. bridge.json+BRIDGE_VERSIONlockstep, and keep the version > the AD bundled seed so cache-over-bundled wins.
Testing a new version on a real desktop
adom-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 box →
pup_*returnsnode_not_foundbefore the bridge spawns (AD core; correct). That's a genuine cold-start demo of the FIRST link; it can't exercisechrome.js(no node to run it). - A box with Node but no Chrome for Testing is the only place to see the full Chrome self-install live
(
pup_open_window→chrome_for_testing_installing→ pollpup_readiness→ opens). Most dev boxes already have CfT, soreadiness()just returnsready:true. - The streamed zip is source-only; AD runs
npm installin the cache on first spawn (sharp/keytar ship prebuilds). The bundled seed already has node_modules. Don't put node_modules in the zip.
Don't
- Don't edit
adom-bridge/plugins/puppeteeras if it's live, and don'trelease-bridge.sh puppeteerfrom 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.jsdoes 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_lostBrowserguard 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.
isSessionAlivenow falls back topage.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.jsand count substantive lines present there but missing locally. Over threshold → abort with merge instructions. Override only via explicitFORCE_PUSH=1after 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 whileBRIDGE_VERSIONreads current — that is howrecorder-icon.icoand 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(noi) and the stock Ubuntu.bashrchascase $- in *i*) ;; *) return;; esacnear 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
echoof the variable proved it hadn't. Reported as adom/adom-bridge#37, asking for a global--ai-threadflag 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 publishsat 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 update→STALE_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_identityrefused → 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.
- Background means BEHIND, never OFF-SCREEN and never tiny. Park = work area of the target
monitor,
availLeft/availToporigin (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. - Park EVERY background window, per-window, unconditionally.
_needsParkwas 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. windowState:'normal'BEFORE setting bounds. Windows ignores geometry on a maximized window; Chrome remembers maximized per profile, so new windows often ARE maximized.- 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. - Never pass
--window-position=-32000(or any off-screen default) at launch. With--no-startup-windowthere 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. - 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. - 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. - Reveal must be atomic (queued). Compute final geometry, then ONE SetWindowPos for position+size+show+activate. Never raise-then-resize — John watches the jank.
- Foregrounding is gated on the USER'S OWN WORDS (
foregroundReason, logged). Flash the taskbar instead when in doubt. - 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.js → cursorScript (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:
- The overlay ENFORCER (v1.9.236) - "red LEDs galore", windows bare after every restart.
- The user-activity watcher (v1.9.180) - user-foregrounded windows were never recognized.
- 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
_lostBrowsersession viarecoverOrRelaunchSession: 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."
- 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_usertoast 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.
- Caption each step as it happens (≤2s each).
- Do the MINIMUM mouse work: one hover/right-click, screenshot, dismiss (Esc), done.
- Caption when finished so he knows the mouse is his again.
- 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.
- 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 = 2000inpupCaption), 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 takeduration. pup kept sending onlyexpiresInMs, 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/wikiCaptionnow send BOTH names. If you add a new caption call site, go throughpupCaption- never calldesktop_captiondirectly. - 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:
- 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.
- Show it for 2 seconds maximum (see the caption rule: a caption is a glance, not a dialog).
PUP_CAPTION_MAX_MS = 2000enforces the ceiling at the choke point. - 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_clickalready 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:
- 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.
- 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.)
- 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
---
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
`skillPkg` → `pkg install`s 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 /commands` → `notify_user` appears in
`.topLevel` on ≥1.9.84. Gate the notify flow on that; fall back to raise-UAC-once +
a relay hint on older AD. pup caches this as `readiness.chromeStableNotifyChannel`.
- **Cross-AD `target`:** add `"target": "<clientName>" | "all"` to ANY `/command` to route
it to a PEER AD via the relay — e.g. pup runs on a VM, the user is on their laptop, so
ping the laptop while the UAC stays on the VM. `"attended"` (input-active box) is **not
resolvable yet** → enumerate peers and fan out.
- **`targets` gotcha:** returns `{ clients: [...] }` and **includes self** (NOT
`{ targets: [...] }`). Read `clients`, drop your own `os.hostname()`, dedupe.
**The two UX rules the impl bakes in (both were real bugs first):**
1. **Notify-FIRST, then raise the UAC.** Raising the UAC puts up the secure desktop, which
*hides* any toast. So fire the sticky `scenario:"reminder"` toast with an `Approve now`
button, poll `notify_response {id}` for the tap, and only THEN elevate.
2. **Never spam prompts you can't explain.** If the notify channel is unreachable, raise the
UAC **once** and surface `awaiting_uac` + a hint for the AI to relay — do NOT loop
re-raising a UAC the user is never told about.
## Repo layout
- `src/` — the runnable bridge (what the release zip contains, at zip-root). `server.js`, `chrome.js`,
`credential_vault.js`, `bridge.json`, `BRIDGE_VERSION`, node `package.json`/`package-lock.json`, html/icons.
- `SKILL.md` (root) — the **user** skill (`name: pup`). Deployed to `~/.claude/skills/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.js`** — `detectChrome()` (validates the ACTUAL exe, not "a dir exists"), `installChrome()`
(programmatic via the bundled `@puppeteer/browsers`, pinned to the buildId puppeteer expects — never
'stable', or launch() won't find it), an install-state singleton (dedup + progress), `ensureChromeReady()`
(the gate — background install, returns immediately), `readiness()` (powers the verb + chip).
- **`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_found` → `desktop_install_node`) —
not this bridge's job (the bridge is a node process; it can't run without node).
- **The one thing this bridge can't do:** pre-warm before first use (it isn't running until the first
call). AD/HD must call `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_list` → `status:"running"` with `livePids:[]` = a bridge that
crashed at spawn while AD still forwards to its dead port. Read `~/.adom/bridge-logs/puppeteer.log` —
the 1.9.77 regression showed Node dying at boot: `EISDIR: lstat 'C:'` (AD mangled the entrypoint path at
the drive-letter colon). Cross-check another box on a different AD version to isolate AD-vs-pup. pup
can't fix AD's spawner — report with the log + version comparison (that report got it fixed same-day).
- **Docs can have THREE copies (SDK lesson, 2026-07-04).** The bridge-SDK page carried the same content in
README.md, SKILL.md, AND bridges-SDK.md — an edit landed on two and left the third stale, so audits
reading the third saw pre-update text. When auditing against a doc, verify the EXACT file you read; when
publishing multi-copy docs, push and verify every copy.
## 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:**
```bash
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: <id>" title suffix the
welcome page just set), retried together with the bottoming below (~8 × 300ms).
- **Z-bottom UNCONDITIONALLY via AD-core** (v1.8.63): ONE call —
`desktop_set_window_state {titleContains, state:'bottom', force:true}` (AD ≥1.9.115; both
verbs shipped from pup's feature requests). `force:true` bottoms even a foreground window.
There is deliberately **NO user-click escape hatch at park time** — see the dead ends:
"foreground at park" has non-user causes and honoring it forced never-clicked windows onto
John's screen. A legacy PS fallback (`osBottomUnlessForeground`) remains ONLY for desktops on
AD ≤1.9.114 (no state:'bottom', or force ignored + skip-on-foreground) — delete it once the
fleet is ≥1.9.115.
- **HARD RULE: never move the window on-screen until 'bottomed' is CONFIRMED.** If bottoming
can't be confirmed after the retries, return an honest `park-failed` and leave the window
invisible off-screen — the self-heal (reattach / open_tab / switch_tab / any status query)
retries it. An un-bottomed window moved on-screen lands ON TOP of the user's work (the
nxp.com pop, v1.8.59).
- **Geometry via CDP plain bounds ONLY** (`Browser.setWindowBounds {left,top,width,height}`,
normal state), addressed by windowId — parking the WRONG window is structurally impossible.
Size comes from the page's own `screen.availWidth/Height` WITH RETRIES (a single probe can
fail mid-navigation; retry, don't fall back). NEVER `windowState:'maximized'` and NEVER AD
`desktop_set_window_bounds` — BOTH RAISE the window (see dead ends).
- **TRUST NOTHING — verify the Z-ORDER after placement** (v1.8.66): re-bottom after the move,
then confirm via AD `desktop_list_windows` that pup's hwnd is NOT topmost and sits below at
least one non-pup window. Unconfirmable after a retry → move the window BACK OFF-SCREEN and
report `park-failed`. A window that might be covering the user never stays on-screen. This
check is what caught ten versions of placement bugs the focus-based checks all missed.
- **Self-verify POSITION** via CDP `getWindowBounds` (left/top > -5000), NOT focus — a
focus-only verify passed while the window sat off-screen ("backgrounded" lie).
- **The launch-gap contract:** a user who clicks the taskbar button within the ~2s before the
park lands may see nothing (window still off-screen) or get parked once — they click ONCE
more and the window comes up and STAYS (nothing re-parks after the park exits). That is the
accepted trade; do not "improve" it with foreground detection.
4. The open response awaits the verdict (`_bgPromise`); badge (`badgePupTaskbar`) + flash fire the
INSTANT the park lands — no blind 1.5s/3.5s badge timers.
5. Nothing ever re-parks. `foreground:true` opens skip all of this and launch maximized on-screen.
6. Remaining AD gap (issue filed): a `send-to-back` window state; when AD ships it, delete
`osBottomUnlessForeground` and pup is 100% PS-free.
**Supporting principles (subordinate to the algorithm above — where older text conflicts, the
algorithm wins):**
1. **Launch OFF-SCREEN** → satisfies R1. Background windows launch with
`--window-position=-32000,-32000` (NOT `--start-maximized`; strip it when `!foreground`). The window is
created far past any monitor, so nothing ever appears on screen during startup. CDP renders regardless
of position, so screenshots/eval/record all still work.
2. **The resting state is ON-SCREEN at the bottom of the z-order** → satisfies R2 + R3.
On-screen-occluded (behind the user's windows, taskbar-clickable, still compositing) is the ONLY
resting state that keeps screenshots working — see the minimize dead-end.
3. **DIRECT APIs, not PowerShell** (John: "why are you using powershell? that's heavy. why aren't you
just calling direct api's?"). Geometry = CDP `Browser.setWindowBounds` by windowId (exact window,
~10ms). Window lookup = AD `desktop_find_window`. Foregrounding = AD `desktop_bring_to_front`.
Flash/badge = AD `desktop_flash_window` / `desktop_taskbar`. The one residual PS
(`osBottomUnlessForeground`) exists ONLY because z-order has no direct API yet; it also reads
`GetForegroundWindow` — the ONE honest "did the user click it" signal. Never grow the PS surface.
(`AttachThreadInput` focus-handback machinery still exists in the legacy helpers used by
`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:** `LoadImage`d HICONs are USER objects that
DIE when the loading process exits. A transient script (or any short-lived helper) that stamps
`WM_SETICON` leaves the window pointing at a DEAD handle moments later — the taskbar may render
once before death, then every later read (alt-tab!) falls back to the exe icon. Probe evidence:
`WM_GETICON` returned a different handle after every script run. Also `SetClassLongPtr(GCLP_HICON)`
FAILS silently cross-process (returns 0; class data lives in the target process). Consequence:
**the icon stamping MUST be done by a long-lived native process that owns the HICONs for the
windows' whole lifetime — i.e. AD-core.** This is not just the ownership doctrine, it is a
mechanical requirement.
**The production design (AD feature request):**
1. AD installer registers the "Adom Pup" Start Menu .lnk (AUMID `Adom.Pup`, adom-pup.ico, target =
adom-desktop-cli 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_window`s for the same profile (an AI retry/poll loop, OR two
threads sharing a profile) each entered `getOrLaunchBrowser`, saw no cached browser, and each
spawned its OWN detached Chrome onto the SAME `--user-data-dir`. They then collided on Chrome's
`SingletonLock`, and each launch's lock-recovery `taskkill`ed "the orphan holding the profile" —
which was the SIBLING launch. Net: they mutually killed each other, NONE ever came up, and the
bridge looked hung. **Fix:** `_launchInFlight` (Map profileName→Promise) — the first launch
registers a promise; concurrent callers JOIN it (`return await`) instead of racing a second
Chrome. If you ever see "Chrome process exited immediately" storms or a wedged cold-start under
load, check this dedup first. NEVER remove it. (Corollary for callers: do NOT spam
`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 `status` → `browser.bridgeRunning`,
and simply whether a real pup verb answers. **`bridge_list` `status:"running"` is NOT liveness** —
it's desired/installed state and will say "running" even when the process is down. (Cost me a wrong
"pup is running" claim to John. Trust the LED + `browser.bridgeRunning` + an actual verb reply, and
`bridge_check_updates` `liveVersion`/`staleProcess` for version truth.)
**3. `restart_bridge` REAPS; only some verbs RESPAWN.** `restart_bridge` (and a crash) leaves pup
down until AD auto-spawns it on the next call — but AD spawns it for WINDOW/TAB verbs
(`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
```bash
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 box** → `pup_*` 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_window` → `chrome_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.**
```js
// 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:
```js
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 `export`ing 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 update` →
`STALE_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:
```bash
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.js` → `cursorScript` (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.
5. 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 <script> and parse it),
SSE must emit, taskbar rows must never exceed chrome processes, and every action button must take
real effect. Do not report the dashboard working from an API result; prove it on glass.