---
name: pup-bridge-debug
description: "DEVELOPER debugging playbook for a MISBEHAVING 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 pup_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-bridge-cli --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 `pup_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.
- **`pup_open_window` returns `ok:true` (full response) but seconds later `pup_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** (`pup_open_window`, `pup_list_windows`), **NOT** for a
bare status probe (`pup_readiness`, `status`). So **polling `pup_readiness` to "wait for pup
to come back" waits forever** — the probe never triggers a spawn and never reaches the down process.
This burned ~15 min on 2026-07-19.

**Recovery playbook (a down bridge):**
1. Fire **ONE** `pup_open_window` — it returns `bridge_starting` fast and triggers the spawn.
2. Then poll `pup_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 `pup_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 `pup_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
(`pup_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) => `pup_wiki_set_view "${JSON.stringify({ sessionId, view: v }).replace(/"/g, '\\"')}"`;
// → pup_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-bridge-cli <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 `pup_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
`pup_open_window` then poll `pup_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
   `pup_list_tabs` PER SESSION - `pup_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 (`pup_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. `pup_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
  (`pup_close`, `pup_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
`pup_close` / `pup_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` (pup_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 `pup_*` 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: `pup_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>`.

## Symptom: after a REBOOT, pup windows come up in the FOREGROUND (v1.9.70)

Background-by-default was only ever enforced on the LAUNCH path. Two other paths create/adopt windows
and NEITHER parked them:

- **`recoverSessions`** re-attaches to windows that already exist. After a reboot the OS restores those
  Chrome/Edge windows in the FOREGROUND, the bridge reconnects, and nothing ever pushes them back down.
  This is the reboot symptom.
- **`reclaimUnmanagedWindows`** adopts windows pup did not launch (openerless popups, orphans whose
  session died). They arrive wherever the shell put them, usually on top.

Both now call `parkSessionWindowDirect` and log `[park] "<sid>" (recovered|reclaimed): <result>`.
Recovery skips the park when `session._foreground` is set, so a window the user deliberately raised is
not yanked back down.

**Do NOT park a drag-out adoption.** `adoptDraggedOutTabs` fires because the USER just dragged that tab
into its own window — it is legitimately in the foreground and burying it would be hostile.

Verified after the fix on BOTH a Chrome box and an Edge box (park is a Win32 z-order op, so Edge was
the suspect): open a window, diff a before/after full-screen capture. Mean pixel delta 1.05 and 0.58
respectively, i.e. the screen did not change. A foregrounded window moves that number well past 12.

## Log every incoming verb (v1.9.70)

John: "do you even log all the actions against you?" — fair hit. The log recorded what the bridge DID
(park / identity / overlay / jumplist) but never WHAT WAS ASKED of it, so "what foregrounded this
window?" was unanswerable after the fact. There is now one compact line per verb:

    [verb] pup_open_window session=parkedge url=https://example.com owner=parktest

It includes only the args that change behaviour (sessionId, url, foreground, foregroundReason, view,
tabId, owner) and skips `bridge_log_read` so reading the log does not pollute it.

## Battery drain / idle CPU: the four power flags were global (issue #14, fixed v1.9.76)

A user reported 3 pup Chrome processes running 2 days at ~6.5% CPU each, holding a Modern Standby
laptop out of deep idle and flattening the battery overnight. The filed root cause was "sessions were
never reaped." **That was wrong, and reaping would not have fixed it.**

The real cause: pup launched EVERY window with all four of Chrome's power-saving mechanisms disabled
(`--disable-renderer-backgrounding`, `--disable-backgrounding-occluded-windows`,
`--disable-background-timer-throttling`, `--disable-features=CalculateNativeWinOcclusion`). They exist
so a BACKGROUND RECORDING streams full-rate frames instead of the ~1-2 fps an occluded window paints.
Applied globally, they mean a pup window can never enter a low-power state. The tell John spotted:
"people leave Chrome open all the time and laptops sleep fine" — a normal Chrome THROTTLES when
occluded; ours structurally could not.

Fix: the flags are now **opt-in** (`highFps:true`) and **leased** (default 20 min, max 120). An
active recording holds the lease open indefinitely (authoritative via `tabRecordStatusImpl` /
`desktopRecorder.current`, not a heuristic), so a take is never cut off. On lapse the session is
**downgraded** by relaunching without the flags carrying its tabs, never closed.

**Do not re-add these flags globally.** If a recording looks choppy, the window needed `highFps`, not
a global flag. Docs: `RECORDING.md`.

**Lesson beyond this bug:** a filed issue can have a correct symptom and a wrong root cause. This one
was well written and specific, and still misattributed. Check the report's causal claim against your
own code before building the fix it asks for.

## Renderer crash wedges the window / crashed renderers not reaped (issue #16, fixed v1.9.78-1.9.80)

Many heavy-SPA tabs + rapid navigate/eval/screenshot churn on one tab crashed a Chrome-for-Testing
renderer; the window went unresponsive and 5 crashed CfT processes lingered at 8-12 MB. Four fixes:

1. **Detect** — `attachTab` wires `page.on('error')` AND a per-tab CDP `Inspector.targetCrashed`
   subscription; either marks `tab._crashed`. The crash is isolated: other tabs stay drivable.
2. **Fast-fail (pre-check)** — `resolveTabOrError` throws `tab_renderer_crashed` (with a
   reload/navigate-to-recover hint) for a crashed tab, so subsequent verbs never hang.
3. **Fast-fail (first verb)** — the crash event for `chrome://crash` can land ~20s late, right as the
   op times out, so the pre-check misses the FIRST verb. The dispatch's outer catch reclassifies a
   timed-out/detached error into the structured crash error when the tab is now `_crashed`. Net: the
   first discovering verb returns the crash error, not a generic 20s timeout.
4. **Reap on close** — `killBrowserProcess` sweeps any chrome/msedge process still launched with THIS
   profile's `--user-data-dir` after the tree kill, catching orphaned/crashed renderers that
   `taskkill /T` misses. Scoped to the profile dir, so it can never touch the user's real browser.

Recovery: `pup_reload` / `pup_navigate` accept a crashed tab (`allowCrashed`) and clear the
flag once the renderer respawns.

Verified: crash a tab -> first screenshot returns `errorCode:tab_renderer_crashed` (not a timeout);
sibling tab still evaluates; `pup_navigate` to a real URL recovers it; close leaves 0 profile
procs (measured 10 -> 0 via `process_list`, NOT PowerShell — a `powershell -File` invocation kept
dropping to an interactive banner here; `cmd /c` and AD's `process_list` verb are reliable).

**Test gotcha:** do NOT recover a crashed tab with `pup_reload` if you crashed it by navigating to
`chrome://crash` — reload re-loads `chrome://crash` and re-crashes. Use `pup_navigate` to a real
URL, or reload only when the tab's real URL is benign.

## The bridge "crashed" toast after closing several windows (v1.9.81) — never run sync exec on a hot path

AD toasted "restarting the pup browser bridge (it crashed earlier)" right after a run of test-window
closes. The bridge had not thrown — it was FROZEN. The v1.9.78 orphan-renderer reap ran a PowerShell
CIM query via `execFileSync` (timeout 8s) ON THE CLOSE PATH. A synchronous exec blocks Node's entire
event loop for its full duration, and PowerShell on this box is flaky (a broken profile drops `-File`
invocations to an interactive banner; CIM can just be slow), so each close could freeze the bridge
for up to 8s. Close five windows in a row and AD's health check reads the bridge as unresponsive and
respawns it. Self-inflicted: the "fix" for issue #16 caused the next incident.

Rules:
- **NEVER `execSync`/`execFileSync` inside a verb handler, close path, or timer callback.** Use the
  async `execFile` (or the runPsHidden*Async helpers) and treat the result as best-effort. The only
  acceptable sync execs run at startup, before the server accepts requests.
- A frozen event loop and a crash are indistinguishable from the outside (both = missed health
  pings = watchdog respawn). If AD reports a "crash" but there's no stack in the log, hunt for a
  BLOCKING call first.
- Verification that catches this: after any change to close/launch paths, do a burst test — N rapid
  `pup_close_window` calls each immediately followed by `ping`, asserting round-trip stays
  sub-second (measured ~600ms healthy; the sync version stalls multi-second).
- PowerShell reliability on user boxes is NOT a given. `powershell -File` can drop to an interactive
  banner (profile damage) and burn its whole timeout. For process queries prefer AD's `process_list`
  verb or `cmd /c` one-liners; keep PS for things only PS can do, always async, always with a timeout.

## ⛔ The jump-list "blip" (flyout shows ~200-300ms then vanishes) — UNRESOLVED, do NOT re-derive

**Symptom:** right-click a pup window's taskbar button, the jump list appears for ~200-300ms and
disappears. A second right-click works and stays. Reproduces on EVERY pup window, every time, from a
clean slate. The list CONTENT is correct when shown, and the tasks run.

**Status: not fixed. Tracked on `adom/adom-bridge#40`** (AD owns `desktop_set_window_identity` and the
shell interaction). Four separate pup-side fixes were attempted and all failed. Before you "fix" this,
read the eliminations — three of them cost a release each, and two of them I announced as fixes before
verifying, which was wrong.

| Hypothesis | How it was eliminated |
|---|---|
| Something pup does races the click | Captured the bridge log, waited **40s idle**, captured again: **0 new lines**. pup is silent while the user clicks. |
| AUMID churn recreates the button under the open flyout | Counted registrations per window: windows with **exactly 1** registration (no category flip) blip identically. |
| `shortcut:false` leaves the identity unresolvable | A/B: one window registered with `shortcut:true` (real Start Menu shortcut) vs siblings registry-only → **no behavioural difference** (John tested). |
| Jump list committed before the stamp recreates the button | Shipped a post-stamp re-commit (v1.9.112), verified 1 registration + 1 re-commit per window in the log → **all three fresh windows still blipped**. |

**What is left, and why pup cannot test it:** the per-window AUMID retag itself recreates the taskbar
button, and the shell's first flyout on the recreated button is dismissed. pup cannot avoid the retag (it
is the only way to brand a borrowed Chrome window) and cannot see what the shell does after.

**The decisive diagnostic, for whoever picks it up:** AD flips its OWN window's AUMID for the
embedded/standalone mode signal. Right-click **AD's own** taskbar button after a mode flip. If AD's own
flyout blips too, this is inherent to runtime AUMID retagging and reproduces with no bridge involved.

**Two process lessons, worth more than the bug:**
1. **Eliminate with measurement, not with a fix.** Each of the first three attempts was plausible,
   shipped, and wrong. The measurements (idle log diff, registrations-per-window, the A/B) are what
   actually narrowed it, and each took minutes.
2. **A user's "it seems ok now" is not a pass.** Mid-investigation John said the right-click was fine;
   it turned out he was clicking windows he had *already* right-clicked once (warm), while fresh windows
   all failed. Ask WHICH instance was tested before believing a fix, and prefer a freshly created
   subject.
