name: pup-bridge-debug description: "DEVELOPER debugging playbook for a MISBEHAVING Adom Desktop Puppeteer (pup) bridge — read this the moment pup feels broken, BEFORE re-diagnosing from scratch. Covers: pup verbs time out / hang, the AD bridge LED is dim, browser.bridgeRunning:false, the bridge keeps restarting / respawning every ~60s, opens succeed but the window vanishes or browser_list_windows is empty seconds later, a cold-start that never finishes, 'Chrome process exited immediately' storms, and how to tell a pup self-crash from AD reaping. Encodes the 2026-07-19 root causes (concurrent-launch wedge, crash-poison recovery loop, restart-reap-vs-respawn) so nobody re-derives them. Trigger words: pup timing out, pup verbs hang, pup not responding, pup bridge down, dim LED, bridgeRunning false, pup keeps restarting, pup respawn loop, bridge flapping, spawned with no reap, bridge_log_read, cold start stuck, Chrome exited immediately, window disappears, list_windows empty, pup wedged, recoverSessions crash, SingletonLock collision, pup diagnose."

Parent skill: pup-bridge-dev (build/publish). Companion: pup (user skill).

pup-bridge-debug — diagnosing a misbehaving pup bridge (do NOT re-derive)

You are here because pup feels broken: verbs time out, the AD LED is dim, the bridge keeps restarting, a window opened then vanished, or a cold start never finishes. Every pattern below was root-caused live on 2026-07-19 and cost real hours. Read this first — the answer is almost certainly here. The golden rule the whole day taught: stop hammering a sick bridge and READ THE LOG.

Step 0 — the diagnostic toolkit (reach for these BEFORE guessing)

All are AD-core verbs (they do NOT route through pup, so they answer even when pup is dead):

Tool What it tells you
adom-desktop --target <box> bridge_log_read '{"name":"puppeteer","lines":40}' THE tell. The AD lifecycle log (v1.9.93). Repeated spawned process pid … (node.exe) with no reap between them = pup is self-crashing and AD keeps respawning it. Note the cadence.
The AD GUI bridge LED Accurate liveness. Dim = the pup process is not actively serving. Green = a verb spawned it and it's alive.
statusbrowser.bridgeRunning Corroborates the LED. false = pup not serving.
bridge_check_updatesliveVersion / staleProcess Version truth: liveVersion reads the RUNNING process's /status; staleProcess:true = running old code.
an actual pup verb replying The ultimate liveness proof.

Liveness TRAP (cost a wrong "it's running" claim to John): bridge_list status:"running" is NOT liveness — it's desired/installed state and says "running" even when the process is down. Trust the LED + browser.bridgeRunning + a real verb reply, never bridge_list status.

Hygiene: do NOT spam a sick bridge. Every pup verb against a down/wedged bridge burns a full 30s AD relay timeout and adds congestion (a retry loop firing browser_open_window every few seconds was itself part of the wedge on 2026-07-19). Use the AD-core tools above (they're instant), read the log, THEN act once.

Symptom → cause decision tree

  • pup verbs time out, but AD-core verbs (status, bridge_log_read) answer → the pup PROCESS is down or wedged. Go to §Liveness/restart.
  • verbs time out, then briefly work, then time out again; LED flickersflapping (respawn loop). Read bridge_log_read — spawn-with-no-reap every ~N seconds confirms it. Go to §Crash loop.
  • browser_open_window returns ok:true (full response) but seconds later browser_list_windows is empty / the window is gone / even desktop_list_windows (AD-core) can't find it → the process is crashing right after responding, then respawning fresh with no sessions. Go to §Crash loop.
  • cold start never finishes; several opens all say bridge_starting; readiness times out for minutes → the concurrent-launch wedge. Go to §Wedge.
  • Chrome process exited immediately on every attempt → stale profile lock / orphan Chrome, OR the wedge (two launches killing each other). Go to §Wedge.

§Liveness/restart — restart_bridge REAPS; only some verbs RESPAWN (do NOT re-derive)

restart_bridge (and a crash) leaves pup down until AD auto-spawns it on the next call. But AD spawns it for window/tab verbs (browser_open_window, browser_list_windows), NOT for a bare status probe (browser_readiness, status). So polling browser_readiness to "wait for pup to come back" waits forever — the probe never triggers a spawn and never reaches the down process. This burned ~15 min on 2026-07-19.

Recovery playbook (a down bridge):

  1. Fire ONE browser_open_window — it returns bridge_starting fast and triggers the spawn.
  2. Then poll browser_list_windows (also auto-spawning) with backoff until it answers.
  3. Do NOT spam opens while it says bridge_starting — one is enough; concurrent opens used to collide (§Wedge). refresh_bridges {name:"puppeteer"} (AD ≥1.9.76) is the download-and-restart path for UPDATES; restart_bridge is kill-and-respawn.

§Crash loop — the ~60s respawn loop (native-crash poison, fixed v1.9.6)

The pattern: bridge_log_read shows pup spawned every ~60s for a long stretch, even while idle, with no reaps. That cadence is AD's supervisor noticing a dead process and respawning it — i.e. pup is crashing on its own shortly after boot, not being killed by AD.

The mechanism (the key mental model): pup's process.on('uncaughtException') / unhandledRejection handlers only catch JS throws. A NATIVE crash — inside puppeteer.connect, a CDP call, or a native module (sharp, keytar) — kills the process HARD and bypasses those handlers entirely. So a try/catch around the risky op does nothing for a native crash. On 2026-07-19 the trigger was recoverSessions() on boot: it rehydrates Chromes named in ~/.adom/pup-sessions/*.json, and a native crash during that reconnect killed the process before the per-session catch could delete the offending file — so the same poison file re-crashed pup on every boot, forever.

Why a plain try/catch can't fix it, and what does: the only defense against a native-crash-on-boot loop is a PERSISTENT (on-disk) circuit breaker. Write an attempt-marker to the file before the risky op; a file that still carries an incomplete attempt on the next boot is quarantined (deleted, not retried); a clean recovery clears the marker; a graceful failure deletes the file. One strike quarantines, so any poison file self-heals after a single crash instead of looping. That is exactly what recoverSessions does now (recoverAttempts in the session file) — do not remove it. Any NEW startup/periodic path that touches on-disk state and can crash natively needs the same before-the-risky-op marker.

Immediate stop-gap (get a flapping box usable NOW): clear the recovery breadcrumbs — Remove-Item $env:USERPROFILE\.adom\pup-sessions\*.json. No files → nothing to rehydrate → the crash-on-boot can't fire. Then deploy the fixed bridge. Confirm the fix by the LOG: after the restart, bridge_log_read should show one fresh spawn and no more — a stable process, not a new one every ~60s.

§Wedge — the concurrent-launch collision (fixed v1.9.4)

The pattern: a cold start that never finishes; multiple browser_open_windows all stuck on bridge_starting; Chrome process exited immediately storms. Cause: a fresh profile is not in the browsers map until its Chrome launch FINISHES (~10-15s), so two near-simultaneous opens for the same profile (an AI retry/poll loop, OR two threads sharing a profile) each spawned their 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. They mutually killed each other; none came up; the bridge looked hung.

The fix (do not remove): _launchInFlight (Map profileName→Promise) in getOrLaunchBrowser — the first launch per profile registers a Promise and concurrent callers JOIN it instead of racing a second Chrome. Corollary for callers/AIs: never spam browser_open_window for the same session while it says bridge_starting.

§Jump-list task does NOTHING when clicked (Windows argv quoting, fixed v1.9.9)

Symptom: the user clicks a taskbar jump-list task, a cmd window flashes for ~50ms, and nothing happens. Cause: desktop_set_window_jumplist task args become a COMMAND LINE, and the CLI's argv parser STRIPS unescaped double quotes. Raw JSON args (browser_wiki_set_view {"sessionId":"x","view":"authed"}) therefore arrive as {sessionId:x,view:authed} and the CLI dies instantly:

{"error": "Invalid JSON args: key must be a string at line 1 column 2"}    exit 1

Fix: wrap the JSON in quotes with the inner quotes BACKSLASH-escaped, so argv delivers it as ONE intact argument. Any jump-list task passing JSON MUST do this:

const taskArgs = (v) => `browser_wiki_set_view "${JSON.stringify({ sessionId, view: v }).replace(/"/g, '\\"')}"`;
// → browser_wiki_set_view "{\"sessionId\":\"wiki-demo\",\"view\":\"authed\"}"

Diagnose it in one shot — run the task's command line yourself via shell_execute and read the exit code/stderr; a mangled-args failure is obvious and instant.

Also: a jump-list click is silent by nature and the pup action behind it can take seconds, so the user cannot tell a working click from a broken one. Give IMMEDIATE on-screen feedback with AD's desktop_caption ({text, duration, id, position} — reuse the same id so the "doing it…" caption is REPLACED by the result caption rather than stacking).

§AD verb works from the CLI but NOT from pup (direct-API verb subset, found v1.9.10)

Symptom: an AD verb (e.g. desktop_caption) works perfectly when tested via adom-desktop <verb> from the container, but pup's chrome.adCommand('<verb>', …) silently does nothing. Cause: the two paths hit DIFFERENT surfaces. The container CLI goes through the relay (full verb set). pup's loopback chrome.adCommand hits AD's direct API (~/.adom/direct-api-port, e.g. :62521), which exposes only a SUBSET of built-in desktop verbs. A verb missing there answers "'X' is not a built-in desktop verb" — and adCommand resolves null on failure, so an error-swallowing caller sees nothing at all. Confirmed live: desktop_caption = relay-only; desktop_set_window_identity / desktop_taskbar / desktop_flash_window = available on both. Rules: (1) NEVER test a bridge's AD call with the container CLI and call it verified — replicate the loopback (curl.exe POST to http://127.0.0.1:<direct-api-port>/command on the box). (2) LOG adCommand results in new call sites — it returns null/error-objects, it never throws. (3) If the verb is relay-only, either file the AD ask to add it to the direct API, or do it pup-side (e.g. the in-page wikiToast DOM pill replaced desktop_caption for wiki-toggle feedback).

⚠ THE TESTING LESSON (this is how the bug above survived to the user)

Test the ACTUAL entry point the user hits, not a convenient proxy for it. The wiki toggle was "verified" by calling browser_wiki_set_view directly over the CLI — which bypasses the jump-list command-line segment where the bug actually lived. The verb worked perfectly every time; the feature was 100% broken for the user. Directly-invoking the handler proves the DESTINATION works, never the PATH. For any user-triggered surface (jump-list task, pinned shortcut, protocol handler, tray item), reproduce the real invocation — run the literal command line, click the real control — before claiming it works. If you cannot press the control from here, at minimum execute the exact command line it would run.

Taskbar icon paints the generic WHITE PAGE while every API says success (Explorer cache)

Found 2026-07-20 after an AD downgrade/upgrade thrash. Symptom: desktop_set_window_identity reports applied [appId, relaunchIcon, displayName, icon] and iconCached:true, desktop_register_app_identity succeeds, the ICO files are valid and on disk - yet EVERY pup taskbar button paints Windows' generic white document icon. Even the old, proven icon. Even brand-new windows. Nothing you set changes it.

Root cause: EXPLORER's own icon/AppResolver cache was poisoned (the AUMID registrations churned under it during the AD version thrash). The shell serves its cached white fallback no matter what the window or registry now says. No pup or AD call can fix it because the bad state is inside explorer.exe.

Diagnostic ladder (each step isolates one layer):

  1. Stamp the KNOWN-GOOD icon (adom-pup-browser3.ico) on one window. Still white -> not your ICO.
  2. Stamp WITHOUT relaunchCommand. Still white -> not the Relaunch trio.
  3. Fresh window after clean registrations. Still white -> not per-window state.
  4. All three white -> it is the SHELL. Restart Explorer: taskkill /f /im explorer.exe && start explorer.exe (targeted and standard; taskbar blinks for a second). Icons paint immediately after.

Note on ICO format: pup's category icons ship with classic BMP frames (PIL bitmap_format="bmp"). PNG-compressed frames were suspected during this hunt and never re-tested after the Explorer fix, so BMP is kept as the safe choice; the PROVEN root cause of the white icons was the Explorer cache.

Category icons + one-session-one-window (v1.9.19 to 1.9.22, the design)

A pup window's taskbar icon states its CATEGORY: wiki (outline book), wiki signed-in (solid book), adom app (window+prompt), web (globe), mixed (stacked windows) - each a DARK TEAL #003d40 glyph on the teal tile (John: match the original pup tile; white glyphs broke the family), drawn on the 24x24 house grid per the brand icon law (no emoji, no extra colours; the signed-in signal is solid-vs- hollow, the one mass change that survives 24px). classifyUrl + pupCategory decide; mixed wins when tabs span categories; updateTabCountBadge re-stamps IMMEDIATELY on a category change (the 10-minute brand debounce only suppresses same-icon re-stamps). Tab titles use monochrome TEXT glyphs U+25CF / U+25CB (geometric shapes, not emoji). The favicon overlay is sacred: it keeps its slot untouched.

Load-bearing prerequisite: ONE SESSION = ONE OS WINDOW (createPageInNewWindow via CDP Target.createTarget {newWindow:true} when a profile is already in use). Without it, sessions sharing the authed profile collapse into TABS of one window, the "(session: )" title belongs only to the active tab, and every per-window operation (stamp, overlay, flash, jump list) silently fails for the background sessions - the original reason the login icon would not repaint after a view toggle.

Live taskbar tile changes: the category-appId swap (v1.9.27-28, AD-measured, do NOT re-derive)

Win11 BAKES a taskbar button's tile when the button is created and never re-reads it. AD measured every candidate mechanism (WIN11_TASKBAR_ICON.md Part 7): WM_SETICON, GCLP_HICON, ITaskbarList DeleteTab+AddTab, SHChangeNotify(ASSOCCHANGED), SWP_FRAMECHANGED, RedrawWindow, the TaskbarCreated broadcast - ALL leave the tile unchanged. The ONLY live repaint is making the shell create a NEW button, which stamping a DIFFERENT AppUserModelID does (~1s, no flicker).

pup therefore bakes the category INTO the per-session appId: Adom.Pup.<sid>.<category>. A category flip = register the new appId (registry-only, BEFORE the stamp so the fresh button resolves its icon immediately) -> desktop_set_window_identity with the new appId -> unregister the old appId. The jump list attaches to the AUMID, so it must re-attach after a swap (pup resets _wikiJumplistView). Session teardown captures _curAppId BEFORE sessions.delete or the cleanup misses and leaks the registration.

TRADEOFF (Windows constraint): a taskbar PIN binds to one AUMID, so pins do not follow category flips. Per-window: pick tile-tracks-state (varying appId) or pin continuity (stable appId) - never both. pup: split mode varies, grouped mode keeps the stable pinnable Adom.Pup.

THE RETRY LESSON (how the first live test failed): the category flip fires right after a tab opens, and the stamp resolves the window by its "(session: )" title suffix - which the new ACTIVE tab does not carry for a beat. The single-shot stamp silently returned false and the tile stayed latched, while the AUMID registration (no window needed) succeeded, making the log look half-done. Fixes that took it green: (1) stamp failures LOG their verdict (never silent-false a shell operation), (2) the category re-stamp retries with backoff exactly like the auth-state path. Verified live: first .mixed stamp FAILED on the title race, retry stamped it, tile repainted.

The one-paragraph field guide (if you read nothing else)

pup misbehaving → read bridge_log_read first. Spawn-with-no-reap every ~N seconds = pup self-crashing (native crash on boot; suspect on-disk poison in ~/.adom/pup-sessions/* → the recoverAttempts circuit breaker handles it, clearing the dir is the stop-gap). Verbs timing out with a dim LED but AD-core answers = process down; a status probe won't respawn it — fire ONE browser_open_window then poll browser_list_windows. Cold start stuck across several opens = concurrent-launch wedge (already fixed by _launchInFlight; don't spam opens). bridge_list status:"running" is not liveness — the LED + browser.bridgeRunning + a real verb reply are.

Symptom: a pup taskbar icon is WHITE (generic document) while its siblings look right

Measured and fixed 2026-07-20 (1.9.44 -> 1.9.46). Three separate defects stacked; check them in order.

1. The AUMID had no icon at all. desktop_register_app_identity accepts an iconPath but only materialises it when it ALSO builds a Start Menu shortcut. Our per-session path passes shortcut:false, so every key was created with a DisplayName and a NULL IconResource. Measured: 129 of 129 HKCU\Software\Classes\AppUserModelId\Adom.Pup.* keys were unbranded.

Check it FIRST, before touching any icon file:

Get-ChildItem "HKCU:\Software\Classes\AppUserModelId" | Where-Object { $_.PSChildName -like "Adom.Pup.*" } |
  ForEach-Object { "{0} -> {1}" -f $_.PSChildName, (Get-ItemProperty $_.PSPath).IconResource }

Fix: write IconResource ourselves with reg add. HKCU needs no UAC and the bridge already runs on the desktop, so there is no AD roundtrip and no shell-approval prompt (writeAumidIcon).

2. Why it was white on ONE window and fine on the others. The per-window Relaunch* properties DO brand a button, but they are a RACE against Win11 baking the tile at button creation. Win the race and the icon is right; lose it and Windows resolves the (unbranded) AUMID and paints the generic white page. So the same code produced correct icons on new windows and a white one on a long-lived window. A category or icon-file theory will send you the wrong way — an inconsistent icon across identical code usually means a race plus an unbranded fallback, not a classification bug.

3. Fixing the resolution does NOT repair an existing button. Win11 never re-reads a baked tile, and a window keeps one appId for life, so 1.9.44 fixed every future button and left the already-white one white. The ONLY repaint is a NEW appId forcing a NEW button. Hence PUP_ICON_GEN folded into the appId (Adom.Pup.<sid>.<cat>.<gen>): bump it whenever the icon files or the branding mechanism change and every live window rebuilds its button.

Two traps found while fixing the above — both destroyed user state

  • A crash breaker must not be able to delete healthy state. The old recovery breaker quarantined any session file carrying an unfinished-attempt marker, but "unfinished" is not "crashed": a normal restart that races the marker-clearing write leaves marks on healthy sessions, so one restart quarantined ALL SIX of John's windows (Recovering 6 session(s)... Recovery complete. 0 active). Replaced with a single sentinel naming the ONE session in flight, so a crash quarantines that file and everything else recovers. When a guard can delete user state, make it name its evidence.
  • Never sweep "stale" entries from an empty live set. The AUMID prune compared registry keys against live sessions and ran right after recovery. When recovery legitimately ended with zero sessions, every key looked stale and the sweep deleted all of them, unbranding windows that were about to be re-stamped. Now it is deferred 60s AND skipped entirely when the session map is empty: with nothing live there is no way to tell stale from pending, so doing nothing is the right answer.

Build a taskbar ORACLE before touching icon code (2026-07-20)

Every icon regression in this file was found by eye, days late, by the user. A taskbar audit that can actually FAIL takes about ten minutes to build and catches them in seconds. Three layers, each catching a class the others miss:

  1. Structural (Shell_TrayWnd via UIA): enumerate taskbar buttons, assert branded_buttons == sessions and raw-Chrome buttons == 0. Any button still named "Google Chrome for Testing" is an unbranded pup window. The count check catches duplicate sessions.
  2. Deterministic category: read each Adom.Pup.<sid>.<cat>.<gen> key's IconResource and compare against the category derived from that session's own tab URLs. Get the URLs from browser_list_tabs PER SESSION - browser_list_windows returns a single url field, and using it graded a correct mixed window as a failure.
  3. Pixel (crop each button's rect, measure it): assert the Adom teal tile is present. This is the layer that catches a white or generic tile, which is invisible to layers 1 and 2 because the registry can be perfectly correct while Windows paints something else entirely.

Do not try to tell the five category glyphs apart from pixels. Three attempts failed: whole-tile RMS put every category within 1-4 units of every other (the identical teal tile is ~85% of the pixels), and a glyph-mask IoU tripped over the dark-teal window body all five share, plus anti-aliasing. It always answered "wiki" while looking like it worked. Layer 2 already establishes category deterministically; layer 3 only needs to answer "is this our tile at all".

Guard the oracle against passing vacuously. The first run reported PASS with zero sessions, and a later one graded results measured after the desktop had disconnected. An audit that cannot distinguish "nothing is wrong" from "nothing was measured" is worse than none. Assert sessions > 0 and ping the desktop first; report INVALID, never PASS.

What the oracle found within minutes, that eyeballing had missed for days

  • A window whose page has no <title> never got its (session: <id>) marker, because the injector's guard was if (document.title && ...). AD resolves pup windows by that suffix, so such a window is INVISIBLE to every branding call - identity, category flip, overlay and jump list all fail with "No visible window with title containing (session: ...)". This is the single biggest cause of "the icon is stale / never updates", and plenty of app routes serve a title-less shell.
  • Category was only recomputed when a tab VERB ran, never when a page finished navigating. A tab is about:blank while its navigation is in flight, so opening two tabs back-to-back cached the wrong category in _lastCat and froze the icon there forever. Fixed by rechecking on framenavigated (debounced).
  • Never cache a category whose stamp did not land. _lastCat was set before the re-stamp was confirmed, so an exhausted retry loop permanently suppressed every future attempt.
  • Reclaim adopted windows mid-launch, before their own session registered, yielding two sessions for one window (8 sessions, 7 buttons). Fixed with a launch-in-flight skip plus a two-strike rule: only adopt a window seen unowned on two consecutive sweeps.

Also worth knowing

bridge_install + restart_bridge kills pup's detached Chromes (recovery then reports every PID dead and drops all sessions). Re-open test windows after any bridge upgrade, and do not read a post-restart empty taskbar as an icon bug.

Symptom: a pup window shows the OLD DEFAULT icon (teal "P" + card), category makes no sense

The taskbar oracle traced this to a session in the _lostBrowser state: its CDP link dropped ([disconnect] profile "<p>" disconnected — awaiting reconnect), which clears the session's tab list to ZERO. With no tabs, pupCategory returns null and pupIconPath falls back to adom-pup-browser3.ico (the default), and the window gets stamped .plain. But the Chrome WINDOW is still alive on the desktop, so the user sees a live pup button wearing the generic default icon while every sibling shows its category. John flagged exactly this on the shotlog window.

Two-alive/dead cases, both handled in reclaimUnmanagedWindows (v1.9.51):

  • Chrome still alive: reconnect to the profile's CDP port, then rescanProfile(..., {adoptOrphans:true}). rescanProfile reattaches by the (session: <id>) TITLE TAG, so it reuses the EXISTING session (un-zombies it) instead of minting a <profile>-winXXXX duplicate and leaving the tagged one dead. Then force _lastCat = undefined + refreshWindowChrome so the real category icon paints.
  • Chrome dead: drop the session AND unregisterPupAumid(session._curAppId) so no stale taskbar identity is left pointing at the default icon.

Do NOT "fix" this by changing the default icon — the default is correct for a genuinely category-less window. The bug is a live window being category-less at all; fix the session state, not the fallback.

Jump list: a "Close ALL Adom Pup windows" task on every window (v1.9.51)

updateWikiJumplist now always appends a close-all task (browser_close "{}"), so every pup window's taskbar right-click menu can close the whole fleet; wiki windows still get their view-toggle task above it. browser_close with no sessionId kills every pup Chrome, clears sessions, AND now unregisters each per-session AUMID + runs pruneStaleAumids, so closing all leaves zero stale taskbar identities.

Verifying a jump list is committed: read %APPDATA%\Microsoft\Windows\Recent\CustomDestinations\*.customDestinations-ms (binary). The task's DESCRIPTION and its ... adom-desktop-cli.exe <verb> "{}" args are stored as plaintext UTF-16 and greppable; the TITLE is a PKEY_Title property-store blob, so a plain string search for the title MISSES it — search the description or the args instead.

Symptom: pup jump list "blips out" on the first right-click, works on the second

Only pup's jump list did this; other apps' were fine. Root cause was AUMID CHURN from the category-carrying appId design (Adom.Pup.<sid>.<cat>.<gen>):

  • A freshly opened window sits on about:blank for a beat, so pupCategory was null and the stamp used .plain. That REGISTERED an Adom.Pup.<sid>.plain.<gen> AUMID — a full taskbar identity with NO jump list — and created a taskbar button under it. A moment later the URL resolved and the window was re-stamped under .<category>, creating ANOTHER button/association.
  • Windows caches the button→AUMID→jumplist association per button. On the first right-click Explorer resolved the stale .plain association (no list) → the menu blipped shut. The second right-click re-resolved to the current .category AUMID (which has the list) → it showed.
  • Every category flip also minted a new AUMID and orphaned the old one's jump-list file. Measured: 87 CustomDestinations files for 3 live windows. (Orphans for dead AUMIDs don't break a live window's resolve — different hash — but they are junk and muddy diagnosis.)

Fix (v1.9.52):

  • Never stamp a transient .plain while a real URL is still loading (stampPupIdentity returns a pending sentinel when pupCategory is null and a tab holds a non-blank http(s) URL; the retry loop + the framenavigated recheck re-stamp once the category is known). The button is now created ONCE, under its final category AUMID, with its jump list already attached → resolves on first click. A genuinely category-less window (never leaves about:blank) still gets .plain after ~6 attempts.
  • brandPupWindow no longer badges on a pending stamp when identity IS supported (a false is "not yet", not "fall back to the legacy overlay badge").
  • unregisterPupAumid clears the AUMID's jump list (tasks:[]) before unregistering, so a category flip / window close no longer orphans its CustomDestinations file. Both close paths (browser_close, browser_close_window) already unregister, so both now clear.
  • Verify: after opening a window there must be exactly ONE Adom.Pup.<sid>.* key (no .plain) and the pup jump-list file count must equal the window count. [jumplist] "<sid>" set N task(s) on <appId> logs each commit for diagnosis.

To purge historical orphans on a machine, delete pup-task *.customDestinations-ms files (they carry browser_close / browser_wiki_set_view in plaintext) older than a few minutes; live windows re-commit theirs within seconds.

Jump-list task rows: short titles + the retired default icon (v1.9.53–55)

  • Title truncation. Windows truncates a custom jump-list task label at ~30 chars at default DPI. "Adom wiki: switch to logged-in view" (35) clipped the important word ("...logged-in vi…"). Lead the TITLE with the action ("Switch to logged-in view" / "Switch to public view", ≤24 chars) and let the DESCRIPTION carry the "Adom wiki page" context — the description renders on its own line as the hover tooltip, untruncated. AD maps title→visible label, description→tooltip.
  • Task icon was the retired brand. updateWikiJumplist set each task's iconPath from pupIconPath() (no arg) → PUP_ICON_DEFAULT → adom-pup-browser3.ico (the retired "P + card" pup mark). Use pupIconPath(sessionId) so the rows wear the window's own category icon (pup-cat-*9.ico).
  • Retiring adom-pup-browser3.ico everywhere. It was also the fallback default (category-less windows + the base Adom Pup identity/Start Menu pin). Replaced PUP_ICON_DEFAULT with a new pup-cat-default9.ico that matches the teal-tile family (teal tile + dark-teal window body + neutral browser-chrome glyph, distinct from the 5 categories).
  • Building a taskbar ICO (PIL is NOT enough — its ICO writer emitted a single 16px frame). The working category ICOs are 7 BMP frames (16/24/32/48/64/128/256, all 32bpp, ~360KB). Render each size with cairosvg and hand-pack the ICO (ICONDIR + ICONDIRENTRY + per-frame BITMAPINFOHEADER with height=2H for the XOR bitmap + 1bpp AND mask, BGRA bottom-up). icotool/convert are also present.
  • Verify a task's committed icon+description by reading the CustomDestinations file (both are plaintext UTF-16); the TITLE lives in a serialized property-store blob and is not plainly greppable.

Jump-list HEADER row shows a generic (white document) icon — and why it's WAI (2026-07-20)

The bottom "app tile" row of a pup window's jump list (the "Adom Pup" line with Pin to taskbar / Close window) renders a generic white-document icon, even though the taskbar BUTTON and Alt-Tab show the correct category icon. Confirmed on John's box:

  • registry HKCU\...\AppUserModelId\Adom.Pup.<sid>.<cat>.i2\IconResource = ...pup-cat-<cat>9.ico,0 (correct — this is what brands the taskbar button).
  • The header row's icon is NOT sourced from that. Windows resolves the pinnable app-tile icon from a Start Menu shortcut that declares the AUMID. Pup registers per-session AUMIDs with shortcut:false (AD docs: "registry-only, no Start Menu entry — the mode for TRANSIENT per-session appIds ... zero Start Menu spam"). No shortcut ⇒ the header falls back to the generic doc icon.

This is a genuine tradeoff, not a fixable bug in the bridge:

  • Branding the header needs shortcut:true per session ⇒ one "Adom Pup" Start Menu entry PER OPEN WINDOW (8 windows = 8 entries). Not worth it. John chose to leave the header generic (2026-07-20).
  • The right fix is AD-side: let the jump-list header read the AUMID's registry IconResource (or accept a header-icon param) for shortcut:false registrations. Filed as an AD request.

IMPORTANT testing limitation this exposed: the live right-click jump-list menu (its header icon, and how each row actually renders) is drawn by Explorer on right-click and is NOT in any file/registry the bridge can read. The taskbar button, the registry AUMID, and the CustomDestinations task definitions ARE inspectable; the rendered menu is NOT. Do not claim the menu "looks right" — verify what's inspectable, and say plainly that the rendered menu needs the user's eyes.

Jump-list HEADER icon: FIXED by AD (v1.9.149) via the Relaunch trio — and how to VERIFY it

AD branded the header row (previously the generic white document — see the WAI note above). The mechanism is the window's Relaunch property trio*, NOT the registry IconResource:

  • RelaunchIconResource = the icon the header row shows (AD now derives it from the iconPath passed to desktop_set_window_identity).
  • RelaunchCommand MUST also be set or the shell IGNORES the relaunch icon for the header (keeps the exe's icon, e.g. Chrome). The bridge already passes relaunchCommand (browser_focus_window).
  • RelaunchDisplayNameResource = the header label ("Adom Pup").

The bridge needed NO change — it already passed iconPath (category icon) + relaunchCommand + displayName. AD ≥1.9.149 now wires those into the trio so the header paints the category icon.

Verify the header WITHOUT a right-click (the rendered menu is not screenshottable from the cloud, but its SOURCE property is readable). Read the window's Relaunch trio off the HWND:

  1. Get the HWND: desktop_find_window {titleContains:"session: <sid>"} → best.hwnd (works even when the window is backgrounded, unlike UIA child enumeration).
  2. Read PKEY_AppUserModel_RelaunchIconResource (fmtid {9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3} pid 3) via SHGetPropertyStoreForWindow. Expect ...pup-cat-<cat>9.ico,0.

Verified 2026-07-21: hdr-wiki→pup-cat-wiki9.ico, hdr-web→pup-cat-web9.ico, hdr-app→pup-cat-app9.ico.

P/Invoke gotchas that cost time here (use the compiled-C# path, it's reliable):

  • PowerShell aliases Rd to Remove-Item — never name a PS function Rd, it silently never runs.
  • PropVariantToStringAlloc is exported from propsys.dll, not shlwapi.dll.
  • PowerShell (5.1) CANNOT bind a COM object from a P/Invoke out param to an Add-Type custom interface (__ComObject does not contain a method named 'GetValue', and [IPropertyStore]$x throws). Compile a tiny C# reader with Framework64\v4.0.30319\csc.exe and run the exe instead — the interop binds correctly there. (PropRead.cs pattern in the repo history.)

The taskbar THUMBNAIL PREVIEW as a status surface (what Windows actually allows)

The hover-preview popup has five surfaces. Only two are reachable from a bridge today:

Surface API Reachable?
Caption (title line) it IS the window title YES today (we inject "○ Public · …")
Thumbnail tooltip ITaskbarList3::SetThumbnailTooltip needs an AD verb (easy: hwnd + string, no callback)
Thumbnail clip ITaskbarList3::SetThumbnailClip needs an AD verb (easy: hwnd + RECT)
Thumbnail toolbar (≤7 buttons) ThumbBarAddButtons BLOCKED for pup — see below
Custom iconic thumbnail DwmSetIconicThumbnail + DWMWA_FORCE_ICONIC_REPRESENTATION possible, but REPLACES the live preview

Why the thumbnail toolbar is blocked for us: the shell delivers thumbbar button clicks as WM_COMMAND to the window that owns the thumbnail. Pup windows are Chrome_WidgetWin_1 — owned by Chrome, not by AD. Receiving those clicks would require subclassing a third-party process's window proc (DLL injection). That is why Chrome can put media buttons on its own thumbnails and we cannot. Do not re-litigate this without a clean cross-process route.

Caption budget: the caption truncates at ~25-30 chars, so only the FIRST tokens survive ("○ Public · Hydrogen De…"). Keep the state glyph + word at the front, and put everything else (pup/Chrome/tab-count) in the thumbnail TOOLTIP once that verb exists — the tooltip is the only uncapped text surface on the popup.

Also already available and unused: desktop_taskbar {progress:{state,value}} paints a green/yellow/red progress bar on the button (ITaskbarList3 progress). No AD work needed.

Duplicate session tags bloat the caption (fixed v1.9.56)

Measured live: "○ Public · Hydrogen Desktop - Adom Wiki (session: hdr-wiki) (session: hdr-wiki)" (107 chars). TWO MutationObservers touch this title — the session-suffix injector and the wiki state-glyph injector — and a page that rewrites its own title can let both fire before either sees the other's write, so each appends. The old injector only asked "is my marker absent?", so a second copy was never collapsed. Fixed: the injector now COUNTS its own tag and normalises to exactly one (n===1 leave, n===0 append, n>1 strip-all-then-append). It only matches its OWN sid so it never fights retagSessionTitle on an adopted tab. Duplicated tags also risk AD's title-based window resolution landing on the wrong window — the exact class of bug behind earlier mis-branding.

Agent-activity indicator: taskbar progress marks the window a thread is driving (v1.9.57)

John runs many AI threads at once and could not tell which pup window an agent was working in. The taskbar PROGRESS bar answers it with ZERO AD work: desktop_taskbar {progress:{state,value}} already exists, it paints ON the button (glanceable across the whole taskbar), and it is a SEPARATE channel from the overlay icon, so the favicon overlay is untouched.

  • markSessionBusy(sessionId) is called from the /command dispatch for any browser_* verb that is not in PASSIVE_VERBS (list/status/readiness/configure/prewarm/rescan/wait). A screenshot or an eval counts as driving; polling does not, or every window would read permanently busy.
  • State is indeterminate (marquee), NOT a percentage. An agent's work has no known length and a fake percentage would be a lie.
  • Only transitions are sent to AD (idle->busy once, busy->idle once after BUSY_IDLE_MS = 2500). A thread firing 200 verbs costs 2 AD calls, not 200. Verified in the log: [activity] "act-a" agent driving — taskbar progress ON ... idle — taskbar progress OFF.
  • Cleared on both close paths (clearSessionBusy).
  • Opt-out: browser_configure {agentActivity:"off"} (default on). It is the user's taskbar.

Colour check (measured, matters): both indeterminate and normal render BLUE (the Windows accent), not red, so the bar reads as "working" and NOT as "error". Verified by painting indeterminate on one window and normal value 65 on a neighbour in the same screenshot: the first shows a sweeping blue marquee segment, the second a blue bar with a grey remainder. Do not assume a progress bar means failure just because an early frame looked reddish, that maroon button tint is the pre-existing AUTO-FLASH attention state (scheduleAutoFlash fires on mutating verbs), not progress.

RESOLVED: the jump-list header icon had TWO causes, one of them ours (AD 1.9.153 / pup 1.9.58)

AD found the first and it was NOT either hypothesis we filed:

  • RelaunchIconResource was being written as <path>\icon.ico,0. The trailing ,0 is the PE-MODULE icon-location form (<exe-or-dll>,<index>). A standalone .ico has no indexed resources, so the shell's classic parser fails and falls back to the generic white document — but ONLY on the jump-list header, because the taskbar button + Alt-Tab resolve through the AUMID registry IconUri (a plain image), not the per-window RelaunchIconResource. That asymmetry is exactly why the button was branded and the header was not. Fixed AD-side; AD now emits a BARE path for a .ico and ,<index> only for a real .exe/.dll. Nothing changes in our calls.
  • It was NOT baked-at-button-creation, and the shell CAN load a standalone .ico for the header.

The second cause was OURS. Every pup-cat-*9.ico I built had its 256x256 entry stored as an uncompressed BMP/DIB. Since Vista, a 256px .ico entry MUST be PNG-compressed or the shell renders the generic white document. My hand-rolled ICO packer wrote BMP for every frame. Confirmed by AD's new iconCheck:

ok: false
"the 256x256 entry is stored as an uncompressed BMP/DIB, not PNG ... or the jump-list
 header + taskbar render the GENERIC WHITE-DOCUMENT icon."

Rebuilt all six from their SVGs with BMP for 16/24/32/48/64 and PNG for 128/256; all six now report iconCheck.ok: true, issues: NONE. PUP_ICON_GEN bumped to i3 so every button is recreated against the fixed art.

Use iconCheck from now on. desktop_set_window_identity, desktop_register_app_identity and desktop_set_window_jumplist (with headerIcon) return {kind, sizes, issues, ok, hint}. It catches the 256-BMP trap, SVG (the Win32 shell cannot load it), a .ico with no small size, and an .exe passed where an image is wanted. Check ok before shipping an icon instead of shipping and squinting.

Thumbnail tooltip (AD >= 1.9.152: desktop_taskbar { thumbnailTooltip })

The hover-preview caption is the window title and truncates at ~25-30 chars. The tooltip is the only UNCAPPED text surface on that popup and it accepts NEWLINES, so it carries the real answer to "what is this window?":

Adom Pup · Adom wiki (SIGNED IN)
wiki.adom.inc/adom/bmv080
Chrome for Testing · 1 tab

mixed names the distinct hosts so the user can see WHY it is mixed without opening it. Pass "" to clear back to the title. Companion thumbnailClip: {x,y,w,h} | null crops the LIVE preview to a region.

Wiring gotcha that cost a release: the tooltip was first hooked only into refreshWindowChrome (drag-out / reclaim / relaunch) and the category-change hook. The NORMAL OPEN PATH calls brandPupWindow directly and touches neither, so a freshly opened window never got a tooltip and the log was silent. It now lives in updateTabCountBadge, which already runs on open AND on every tab open/close — exactly when the tooltip's content (category + tab count) changes. When adding per-window taskbar state, hook the function that runs on OPEN, not just the repair paths.

RESOLVED (for real): branding the jump-list HEADER row — pass headerIcon AND hwnd

AD >= 1.9.153 brands the header row via a headerIcon param on desktop_set_window_jumplist. Relying on the Relaunch trio alone (what we did through 1.9.59) never branded it. Two requirements, both measured, neither obvious:

  1. You MUST pass hwnd. With only appId, AD cannot locate the window to write the per-window Relaunch* props on, and returns headerBranded:false with an EMPTY headerFields and no error. titleContains ALSO fails here (headerBranded:false) even though it works fine on desktop_set_window_identity. Measured on the same window in one pass: appId only -> headerBranded false, headerFields [] appId + titleContains -> headerBranded false, headerFields [] appId + hwnd -> headerBranded TRUE, headerFields [appId, relaunchIcon, relaunchCommand, icon] Resolve the hwnd with desktop_find_window {titleContains:"(session: <id>"} -> best.hwnd first.
  2. The .ico must pass iconCheck (256px entry PNG-compressed). Otherwise the shell paints the generic document no matter how correctly the props are set.

This is finally verifiable in code. The response carries headerBranded, headerFields and headerIconCheck, so the header no longer needs a human to right-click and report back. Log it: [jumplist] "<sid>" set N task(s) ... headerBranded=true. If it ever reads false with empty fields, the hwnd did not resolve — do not go looking for icon problems.

Note the first attempt on a brand-new window can log headerBranded=undefined: the window is not yet findable by title, so no hwnd, so no headerIcon is sent. The normal retry/refresh path re-runs and brands it a beat later. Harmless, but do not read that first line as a failure.

The header icon obeys the LIVE-TILE TRAP too — brand it BEFORE the stamp (v1.9.61)

AD's adom-desktop-window-identity skill states the rule that explains months of this: Win11 bakes a taskbar button's art when the button is CREATED and never re-reads it. That applies to the jump-list HEADER row, not just the tile.

Our order was wrong. stampPupIdentity did:

register AUMID -> desktop_set_window_identity (THIS CREATES THE BUTTON) -> ... later ... jumplist + headerIcon

so the button was born before the Relaunch trio existed, baked the GENERIC document, and the later headerBranded:true was truthful but had no visible effect. That is why the response said branded and the user still saw white. Correct order:

register AUMID -> desktop_set_window_jumplist {appId, hwnd, headerIcon}  (writes the Relaunch trio)
               -> desktop_set_window_identity {hwnd, appId, ...}          (button is born branded)

updateWikiJumplist therefore takes an explicit {appId} override, because at pre-stamp time session._curAppId is still the OLD id.

When a response says "applied" but the user sees no change, suspect bake-order, not the value.

Jump-list commits dismiss an OPEN menu — commit once per (appId, view)

"First right-click does nothing, second one works" is caused by redundant CommitList calls: every desktop_set_window_jumplist rebuilds the menu, and one landing while the user is opening it makes the shell dismiss it.

Two fixes, both needed:

  1. Key the dedupe on appId|view, not view alone. Keyed on view alone, every refresh after an appId change re-committed.
  2. Claim the key BEFORE the await. Two callers (the new pre-stamp brand and a timer-driven refresh) both compute the same key, both find it unset, and both commit. Claim optimistically and roll back on failure. Measured effect on a fresh window: 3 commits -> 1 (a wiki window whose auth resolves still legitimately commits twice, public then authed, because its view really changed).

Watch it with: [jumplist] "<sid>" set N task(s) on <appId> (view=...) headerBranded=<bool>.