Adom Desktop - Puppeteer Bridge
Public Made by Adomby adom
The Adom Desktop Puppeteer (pup) bridge — drive a real headful browser on the user's desktop from the cloud: open/screenshot/eval/record. Cold-start drives the browser ALREADY on the machine (installed Chrome, else Microsoft Edge — every Windows PC ships with Edge) with a fresh isolated profile, so the common case needs NO download; Chrome for Testing is fetched only as a last resort. Two artifacts: the Releases zip is the bridge runtime (also bundled in Adom Desktop); the pkg installs the container-side Claude skills.
name: pup-bridge-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. |
status → browser.bridgeRunning |
Corroborates the LED. false = pup not serving. |
bridge_check_updates → liveVersion / 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 flickers → flapping (respawn
loop). Read
bridge_log_read— spawn-with-no-reap every ~N seconds confirms it. Go to §Crash loop. browser_open_windowreturnsok:true(full response) but seconds laterbrowser_list_windowsis empty / the window is gone / evendesktop_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 immediatelyon 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):
- Fire ONE
browser_open_window— it returnsbridge_startingfast and triggers the spawn. - Then poll
browser_list_windows(also auto-spawning) with backoff until it answers. - 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_bridgeis 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):
- Stamp the KNOWN-GOOD icon (adom-pup-browser3.ico) on one window. Still white -> not your ICO.
- Stamp WITHOUT relaunchCommand. Still white -> not the Relaunch trio.
- Fresh window after clean registrations. Still white -> not per-window state.
- 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:
- Structural (
Shell_TrayWndvia UIA): enumerate taskbar buttons, assertbranded_buttons == sessionsandraw-Chrome buttons == 0. Any button still named "Google Chrome for Testing" is an unbranded pup window. The count check catches duplicate sessions. - Deterministic category: read each
Adom.Pup.<sid>.<cat>.<gen>key'sIconResourceand compare against the category derived from that session's own tab URLs. Get the URLs frombrowser_list_tabsPER SESSION -browser_list_windowsreturns a singleurlfield, and using it graded a correct mixed window as a failure. - 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 wasif (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:blankwhile its navigation is in flight, so opening two tabs back-to-back cached the wrong category in_lastCatand froze the icon there forever. Fixed by rechecking onframenavigated(debounced). - Never cache a category whose stamp did not land.
_lastCatwas 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>-winXXXXduplicate and leaving the tagged one dead. Then force_lastCat = undefined+refreshWindowChromeso 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
pupCategorywas null and the stamp used.plain. That REGISTERED anAdom.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
.plainassociation (no list) → the menu blipped shut. The second right-click re-resolved to the current.categoryAUMID (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
.plainwhile a real URL is still loading (stampPupIdentityreturns a pending sentinel whenpupCategoryis null and a tab holds a non-blank http(s) URL; the retry loop + theframenavigatedrecheck 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.plainafter ~6 attempts. brandPupWindowno longer badges on a pending stamp when identity IS supported (a false is "not yet", not "fall back to the legacy overlay badge").unregisterPupAumidclears 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.
updateWikiJumplistset each task's iconPath frompupIconPath()(no arg) → PUP_ICON_DEFAULT → adom-pup-browser3.ico (the retired "P + card" pup mark). UsepupIconPath(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/convertare 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:trueper 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) forshortcut:falseregistrations. 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 theiconPathpassed todesktop_set_window_identity).RelaunchCommandMUST also be set or the shell IGNORES the relaunch icon for the header (keeps the exe's icon, e.g. Chrome). The bridge already passesrelaunchCommand(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:
- Get the HWND:
desktop_find_window {titleContains:"session: <sid>"}→ best.hwnd (works even when the window is backgrounded, unlike UIA child enumeration). - Read
PKEY_AppUserModel_RelaunchIconResource(fmtid {9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3} pid 3) viaSHGetPropertyStoreForWindow. 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
RdtoRemove-Item— never name a PS functionRd, it silently never runs. PropVariantToStringAllocis exported from propsys.dll, not shlwapi.dll.- PowerShell (5.1) CANNOT bind a COM object from a P/Invoke
outparam to anAdd-Typecustom interface (__ComObject does not contain a method named 'GetValue', and[IPropertyStore]$xthrows). Compile a tiny C# reader withFramework64\v4.0.30319\csc.exeand 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/commanddispatch for anybrowser_*verb that is not inPASSIVE_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:
RelaunchIconResourcewas being written as<path>\icon.ico,0. The trailing,0is the PE-MODULE icon-location form (<exe-or-dll>,<index>). A standalone.icohas 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.icoand,<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
.icofor 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:
- You MUST pass
hwnd. With onlyappId, AD cannot locate the window to write the per-window Relaunch* props on, and returnsheaderBranded:falsewith an EMPTYheaderFieldsand no error.titleContainsALSO fails here (headerBranded:false) even though it works fine ondesktop_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 withdesktop_find_window {titleContains:"(session: <id>"}->best.hwndfirst. - 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:
- Key the dedupe on
appId|view, not view alone. Keyed on view alone, every refresh after an appId change re-committed. - 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>.
---
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. |
| `status` → `browser.bridgeRunning` | Corroborates the LED. `false` = pup not serving. |
| `bridge_check_updates` → `liveVersion` / `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 flickers** → **flapping** (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_window`s 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 `taskkill`ed "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:
```js
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: <id>)" 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: <id>)" 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:
```powershell
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>`.