Imported from BloomBooks/BloomDesktop (
.github/skills/bloom-automation/SKILL.md). Install upstream withnpx skills add BloomBooks/BloomDesktop --skill bloom-automation. Copyright stays with the author.
Bloom Exe CDP Automation
Outcome
Use the real embedded WebView2 inside Bloom.exe as the automation target. Determine whether Bloom is already running, whether it belongs to this worktree, stop the right processes when necessary, start the current worktree through a source-aware launcher, discover the live CDP target, and drive the UI through the embedded browser instead of Bloom APIs.
When To Use
- You need to know whether Bloom is already running.
- You need to know whether the running Bloom came from the wrong worktree.
- You need to kill a confusing stale Bloom or
dotnet watchparent process. - You need to start Bloom from the current worktree.
- You need to attach to the embedded WebView2 for DOM, console, network, and screenshot/debug access.
- You need Playwright tests to hit the actual exe instead of
http://localhost:8089/bloom/CURRENTPAGEin a separate browser tab.
Default Assumptions
- Current repo root is derived automatically by the checked-in helper.
- Bloom project path is
src/BloomExe/BloomExe.csproj. - Fresh automation launches must not start an already-built
Bloom.exedirectly, because that can miss local source changes. - Use a source-aware launcher. In this repo the current default is
./go.shat the repo root. go.shlaunches the coordinated dev flow fromsrc/BloomBrowserUI/scripts/go.mjs, which in turn starts the front-end and the exe together.- In automation mode Bloom writes a machine-readable
BLOOM_AUTOMATION_READY {...}line to the console as soon as it knows its HTTP, CDP, and process IDs. - Running Bloom reports its actual HTTP and CDP ports through
http://localhost:<port>/bloom/api/common/instanceInfo.
Commands
Examples below assume you are somewhere inside the repository and first compute the repo root once:
repo_root="$(git rev-parse --show-toplevel)"
Terminal:
- In this VS Code workspace, the shared bash terminal keeps whatever cwd the previous command left behind.
- Prefer running the helper through
$repo_root/.github/skills/...or$repo_root/scripts/...so the command does not depend on the current working directory.
Important:
- Agents using this skill MUST use the checked-in helper scripts below, not package.json aliases and not ad hoc
wmiccommands. - In this workspace, assume the default terminal is bash unless you explicitly opened another shell. Do not use cmd-only syntax such as
cd /d D:\...in bash. - Do not run raw
wmic ...commands from a bash terminal as part of this skill workflow. - Do not redirect WMIC output to temp files from bash.
- The VS Code bash terminals in this workspace have shown bracketed-paste/shell-integration problems where ad hoc WMIC commands appear to hang or are injected incorrectly. The checked-in Node wrappers avoid that by calling WMIC directly without going through shell redirection.
- Only fall back to raw Windows commands if the checked-in wrappers themselves are broken and you are explicitly debugging them. If you do that, prefer
cmd /cover bash redirection.
Status
node "$repo_root/.github/skills/bloom-automation/bloomProcessStatus.mjs"
node "$repo_root/.github/skills/bloom-automation/bloomProcessStatus.mjs" --json
node "$repo_root/.github/skills/bloom-automation/bloomProcessStatus.mjs" --running-bloom --json
node "$repo_root/.github/skills/bloom-automation/bloomProcessStatus.mjs" --http-port <httpPort> --json
Reports Bloom.exe processes, detected repo roots, attributable dotnet watch parents, ambiguous watchers, and whether the workspace API and CDP endpoint are reachable.
Launcher control (preferred whenever go.sh is running)
The go.sh launcher (scripts/watchBloomExe.mjs) runs a loopback-only HTTP
control server and advertises it in <repoRoot>/output/bloom-launcher.json
plus a BLOOM_LAUNCHER_READY {...} stdout line. launcherControl.mjs wraps
it; use it INSTEAD of kill-and-relaunch or asking the human to press Enter:
node "$repo_root/.github/skills/bloom-automation/launcherControl.mjs" --status --json
node "$repo_root/.github/skills/bloom-automation/launcherControl.mjs" --restart --wait-ready --json # rebuild + relaunch, any state
node "$repo_root/.github/skills/bloom-automation/launcherControl.mjs" --start --wait-ready --json # relaunch only when parked (awaiting-restart)
node "$repo_root/.github/skills/bloom-automation/launcherControl.mjs" --quit-bloom --json # graceful WM_CLOSE quit; also stops the watch child, so C# edits do NOT respawn Bloom
node "$repo_root/.github/skills/bloom-automation/launcherControl.mjs" --shutdown --json # whole stack: Bloom + dotnet watch + launcher + Vite
node "$repo_root/.github/skills/bloom-automation/launcherControl.mjs" --ensure-running --wait-ready --json # start the stack if nobody's home
Semantics agents rely on:
- Liveness is HTTP truth, never the file. A discovery file whose
controlUrldoes not answer means nobody is home (hard-killed launcher); the helper reportslauncherFound:false, staleFile:trueand exits 2. A fresh launcher overwrites the stale file. - A launch-in-progress is advertised too. go.mjs writes an early record
(
state:"starting", aphaseofstarting/init/dev-server/starting-bloom, and itsgoPid) from the first moment, before the control server exists. The helper reports it aslauncherFound:false, starting:true, phase:...(exit 2) and--ensure-runningWAITS for it instead of starting a second stack. Startup can take minutes:initmeans the worktree was uninitialized and go.mjs is running ./init.sh for you (go.mjs detects missing node_modules / lib/dotnet deps / output/browser bundles and runs init automatically — agents never need to run init.sh themselves). --statusstates:building,bloom-running,awaiting-restart(reached only via--quit-bloom— see below),restarting,launch-failed. During a dotnet-watch hot rebuild the state passes throughbuildingtransiently — poll forbloom-running(--wait-readydoes) rather than sampling once.- The human closing Bloom (window X) tears the whole stack down —
launcher, dotnet watch, and Vite all exit, so an idle stack stops holding
memory. If
--statussuddenly reports nobody home, that is the normal meaning; just--ensure-runningwhen you need Bloom again. (The launcher distinguishes this from a dotnet-watch rebuild via the watcher's file-changed output, so C# edits do not kill the stack.) Only--quit-bloomleaves the launcher parked inawaiting-restart. /statusalso reportssourceChangedSinceReady: whether dotnet watch has seen C# source changes since the current Bloom became ready — i.e. whether a--restartwould incorporate anything new. Bloom itself polls that field (only when launched by go.sh via--launcher-port, seeDevLauncher.cs) and shows a dev-only non-expiring toast whose "Restart" action posts the same/restart.--wait-readywaits for a NEW launch (launchNumberincreased) to reachbloom-runningand prints the freshhttpPort/cdpPort— this replaces grepping logs forBloom ready. HTTP.--ensure-runningstarts the stack decoupled from your session when no launcher answers: in an Orca terminal tab titled "go.sh" when the Orca runtime is reachable, else as a detached process logging tooutput/bloom-launcher.log(reported aslogPathin/status). Aoutput/bloom-launcher.starting.lockprevents two agents double-launching.- Every action prints a
[control] ... requestedline in the launcher's terminal so the human can see why Bloom moved. - The raw API (for tools, not typed curl):
GET /status,POST /restart,POST /start,POST /quit-bloom,POST /shutdownon thecontrolUrlfrom the discovery file.
The status/kill helpers below remain the fallback for Bloom instances that have no live launcher (started outside go.sh, or the launcher was killed).
Use --running-bloom when the user explicitly wants the already-running Bloom instead of a worktree-owned instance. This scans Bloom's standard HTTP port range, asks any running Bloom for common/instanceInfo, and reports the ports that instance says it is using.
Use --http-port <port> when you launched Bloom through ./go.sh or another repo-supported source-aware launcher and want the exact instance that owns that HTTP port. This is the preferred multi-instance workflow because it gives you the precise Bloom PID and CDP port even when several Blooms from the same worktree are running.
Kill Bloom
node "$repo_root/.github/skills/bloom-automation/killBloomProcess.mjs"
node "$repo_root/.github/skills/bloom-automation/killBloomProcess.mjs" --only-mismatched
node "$repo_root/.github/skills/bloom-automation/killBloomProcess.mjs" --http-port <httpPort>
node "$repo_root/.github/skills/bloom-automation/killBloomProcess.mjs" --pid 12345 --watch-pid 12340
Use the plain form to stop all detected Bloom-related processes. Use --only-mismatched to stop only the Bloom instance that does not belong to the current worktree.
Use --http-port <port> to stop the exact Bloom instance bound to that HTTP port, together with any dotnet parent in its process chain. Use --pid or --watch-pid only when you already know the exact process IDs you want to stop.
Important: if Bloom was started with dotnet watch run, killing only Bloom.exe is not enough because the watcher will restart it. Prefer launcherControl.mjs --quit-bloom / --shutdown when the launcher is live (they handle the watcher for you); otherwise use the provided kill script so the watcher and child process are both terminated.
Start Bloom
Preferred: launcherControl.mjs --ensure-running --wait-ready (see "Launcher
control" above) — it reuses a live launcher or starts one decoupled from your
session, so other agents and the human share it. Launch ./go.sh from your
own shell only when the control surface can't be used (e.g. you are debugging
the launcher itself):
"$repo_root/go.sh"
Use ./go.sh as the current default launcher for this repo unless a better repo-supported source-aware launcher is documented later. It starts the coordinated front-end and exe flow and still surfaces the Bloom ready. HTTP ..., CDP ..., Bloom PID ... line from the underlying startup script.
go.sh is intentionally long-lived: for normal launches it keeps the coordinated dev flow running until the launch session ends. If Bloom reports ready and then dies shortly afterward, treat that as a failed launch instead of silently succeeding.
Agent workflow for go.sh:
- Start it in a background terminal.
- Do not wait for the command to finish. A successful launch is the latest
Bloom ready. HTTP ..., CDP ..., Bloom PID ...line, not process exit. - If the launcher later reports that the Bloom PID exited shortly after reporting ready, treat that as a failed launch and do not target that HTTP port.
- After starting it, read or poll that background terminal's output until the
Bloom ready.line appears, then use the reported HTTP port as the identity of the new instance. - After you have the HTTP port, continue with
bloomProcessStatus.mjs --http-port <port> --json,webview2Targets.mjs --http-port <port> --json --wait, orswitchWorkspaceTab.mjs --http-port <port> --tab .... - Keep the background terminal open for the lifetime of that Bloom instance. If the underlying flow restarts Bloom, target the most recent
Bloom ready.line because Bloom may choose a different HTTP port on the restart.
Discover the CDP target
node "$repo_root/.github/skills/bloom-automation/webview2Targets.mjs"
node "$repo_root/.github/skills/bloom-automation/webview2Targets.mjs" --json --wait
node "$repo_root/.github/skills/bloom-automation/webview2Targets.mjs" --running-bloom --json --wait
node "$repo_root/.github/skills/bloom-automation/webview2Targets.mjs" --http-port <httpPort> --json --wait
Use --wait after startup so the command blocks until the embedded browser target is available.
Switch a workspace tab
node "$repo_root/.github/skills/bloom-automation/switchWorkspaceTab.mjs" --running-bloom --tab edit --json
node "$repo_root/.github/skills/bloom-automation/switchWorkspaceTab.mjs" --http-port <httpPort> --tab publish --json
This helper attaches to the reported WebView2 target over CDP, clicks the real top bar tab, waits for workspace/tabs to report it active, and prints the resulting state.
Screenshot
node "$repo_root/.claude/skills/run-bloom/screenshotBloom.mjs" --http-port <httpPort> --out output/screenshots/bloom.png --json
(Lives in the Claude-specific run-bloom skill directory but works for any agent; output/ is gitignored.)
Driving WinForms and OS dialogs (UI Automation, no pointer)
CDP reaches only the web content inside Bloom's WebView2s. Everything around it — the
collection Settings dialog's tabs and OK/Cancel/Help buttons, any other WinForms Form, and
the native OS dialogs Bloom opens (the file picker, a raw MessageBox) — is driven over
Windows UI Automation instead, with .github/skills/bloom-automation/winformsUia.ps1 (stock
PowerShell 5.1, nothing to install). It uses InvokePattern, SelectionItemPattern,
ValuePattern and WindowPattern, so it never moves the pointer, sends a keystroke or takes
focus; it works on an off-screen (headless) window and while the developer is typing in
another app. Never fall back to synthesizing mouse or keyboard input for these.
P=.github/skills/bloom-automation/winformsUia.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File $P windows -ProcessId <bloomPid>
powershell -NoProfile -ExecutionPolicy Bypass -File $P tree -ProcessId <bloomPid> -Window CollectionSettingsDialog -Depth 2
powershell -NoProfile -ExecutionPolicy Bypass -File $P select -ProcessId <bloomPid> -Window CollectionSettingsDialog -Control "Book Making"
powershell -NoProfile -ExecutionPolicy Bypass -File $P invoke -ProcessId <bloomPid> -Window CollectionSettingsDialog -Control _cancelButton
powershell -NoProfile -ExecutionPolicy Bypass -File $P setvalue -ProcessId <bloomPid> -Window Open -Control "File name:" -Value "C:\full\path\image.png"
powershell -NoProfile -ExecutionPolicy Bypass -File $P invoke -ProcessId <bloomPid> -Window Open -Control Open
Each of these works against a real ./go.sh Bloom:
- A WinForms dialog. Settings opened from the top bar (a CDP click on the web "Settings"
button);
selectswitched to the Book Making tab;invokeon_cancelButtonclosed it. - Bloom's own OS file picker. The image gallery's picker (
ImageGalleryApi, aBloomOpenFileDialog) came up as[Window] name='Open';setvalueon "File name:" plusinvokeon "Open" closed it, and Bloom's API returned the chosen path. - A message box inside a real process. Given a relative path, that picker raised its own
"file not found" box, owned by the picker;
treeread its text andinvoke ... -Control OKdismissed it.
What to know:
- Addressing. A WinForms control's UIA
AutomationIdis its designerName, so the dialog isCollectionSettingsDialog, its buttons_okButton/_cancelButton/_helpButton, its tab strip_tabwith tab items whoseNameis the visible caption ("Languages", "Book Making", ...). OS dialogs have no useful ids; use the visible names ("Open", "File name:", "Cancel", "OK").-Windowand-Controlaccept either.treeshows both, so dump first when unsure. When several controls share a name (the picker's "File name:" is a label, a ComboBox and an Edit), the script takes the outermost one that supports the command. - A modal dialog is not a top-level window in UIA. A WinForms dialog sits under its owner
(the
Shellwindow) as a descendant, and a message box raised by a dialog sits under that dialog; an OS file picker is top-level.listshows only top-level windows,windowsshows all of them, and every command searches both places. - Win32 controls need the client-side proxies, and PowerShell does not load them. Without
them every control in a file picker or message box is an inert
[Pane]with no patterns. The script registers them itself, through a compiled C# shim, because doing it from PowerShell throws. If atreeof an OS dialog shows only panes, that registration failed (the script says so on stderr). The proxies can also attach a beat late: the first dump right after a dialog appears once showed its "Open" button as a pane, and the next query showed it as a[Button]; re-query before concluding a control is not drivable. - Give the file picker an absolute path. A relative one makes the dialog raise a
"file not found" box and stay open; dismiss that with
invoke -Window <dialog> -Control OKand set the value again. - In an e2e test, still prefer arming the answer.
e2e/nextFileToChoosemakesBloomOpenFileDialog/BloomFolderChooseranswer without showing anything, which is faster and cannot be upset by whatever else is on the screen. UIA is for the cases that hook does not cover and for interactive/diagnostic driving. - Not reachable this way: WinForms
LinkLabels (the "Change..." language links on the Languages tab) expose no pattern at all, and the web content on the other tabs is a WebView2 — drive that over CDP.treeprunes WebView2 subtrees on purpose. - Reading a raw
MessageBox. It appears as a[Window]named by its caption (Errorfor the oneWebView2Browsershows when a WebView2 fails to initialize), class#32770, owned by the Shell;tree -Window Error -Depth 3prints its[Text]children, which is the whole message, andinvoke -Window Error -Control OKdismisses it. Read before dismissing: that particular one exits Bloom on close. A rawMessageBoxignoresBLOOM_AUTOMATION_MONITOR, so it lands on the developer's screen even in a headless run. closereally closes.close -Window Shellshuts Bloom down exactly like the title-bar X (and with the launcher, takes the whole stack with it). Use it only on the window you mean.- The dialog's own WebView2 and CDP. Outside
--e2e, every ReactControl gets its own WebView2 environment and browser process, and each is given the same--remote-debugging-port. It can happen that while the Book Making tab is showing, the CDP endpoint listed only the dialog's page, and the shell page came back when the dialog closed. So the endpoint can flip between browser processes; re-list targets after a WinForms dialog opens or closes rather than holding on to a page handle. - Under
--e2e, opening the Settings dialog currently kills Bloom. See "WinForms surfaces are invisible to CDP" insrc/BloomE2E/AUTOMATION-DEBT.mdfor the cause (a WebView2 DPI awareness mismatch against the shared e2e environment) before writing a test that opens it.
Driving Bloom HTTP APIs over CDP (host-header + IPv6 gotchas)
Sometimes you need to drive Bloom through its HTTP API (e.g. editView/topBar/layoutChoiceChange, editView/jumpToPage) rather than by clicking, for example when scripting a batch of layout/page changes for screenshots. Two gotchas bite hard here, and they pull in opposite directions:
-
CDP wants IPv4. On Windows, Node's
fetch/WebSocketresolvelocalhostto IPv6::1first, but the embedded WebView2 CDP debug port answers on IPv4127.0.0.1. Hittinghttp://localhost:<cdpPort>/jsonfrom Node can return an empty or wrong target list (often a loneabout:blank) whilecurl(IPv4) shows the realappBundletarget. Fix: in Node, usehttp://127.0.0.1:<cdpPort>/jsonand rewrite the returnedwebSocketDebuggerUrlfromlocalhostto127.0.0.1before connecting. -
Bloom's HTTP server rejects
Host: 127.0.0.1. If you then try to POST the API directly from Node tohttp://127.0.0.1:<httpPort>/bloom/api/..., Bloom's server returns 400 Bad Request (a generic HTML error page) because it validates theHostheader and only acceptslocalhost. You can't easily overrideHostfromfetch(it's a forbidden header).
The fix that satisfies both: issue the API call from inside the page via CDP Runtime.evaluate, using a relative URL. The page's own origin is http://localhost:<httpPort>, so the Host header is correct, while your Node→CDP connection still uses IPv4.
// connect (IPv4 for CDP)
const ts = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json();
const target = ts.find(t => /appBundle/.test(t.title||"")) || ts.find(t => t.type==="page");
const ws = new WebSocket(target.webSocketDebuggerUrl.replace("localhost","127.0.0.1"));
// ... Runtime.enable, then:
const evalJs = expr => send("Runtime.evaluate",{expression:expr,returnByValue:true,awaitPromise:true})
.then(r => r?.result?.value);
// POST JSON body (e.g. change layout):
await evalJs(`fetch('/bloom/api/editView/topBar/layoutChoiceChange',
{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({layoutChoiceId:'LetterPortrait'})}).then(r=>r.status)`);
// POST raw string body (e.g. jump to a page by id):
await evalJs(`fetch('/bloom/api/editView/jumpToPage',{method:'POST',body:'${pageId}'}).then(r=>r.status)`);
The edit-view page lives in the iframe named page; poll document.getElementById('page').contentDocument.querySelector('.bloom-page').className to confirm the layout class changed and the right page id is showing before you screenshot. Note this is the API-driven exception to the "drive via clicks" rule — use it for scripted batch operations (e.g. cycling every page size), not to fake a single user action you could click.
Minimal Running Bloom Attach Workflow
Use this exact path when the user says to reuse the already-running Bloom and you need the fewest possible steps.
- Report the running instance:
repo_root="$(git rev-parse --show-toplevel)" && node "$repo_root/.github/skills/bloom-automation/bloomProcessStatus.mjs" --running-bloom --json
- Switch the real running Bloom through the skill-local helper:
repo_root="$(git rev-parse --show-toplevel)" && node "$repo_root/.github/skills/bloom-automation/switchWorkspaceTab.mjs" --running-bloom --tab edit --json
- Only if you need low-level debugging evidence, inspect the exact CDP target:
repo_root="$(git rev-parse --show-toplevel)" && node "$repo_root/.github/skills/bloom-automation/webview2Targets.mjs" --running-bloom --json --wait
Notes:
switchWorkspaceTab.mjslives in this skill directory and loads Playwright fromsrc/BloomBrowserUI/react_components/component-testerautomatically.- The minimal action path is step 2 by itself. Run step 1 first only when you need to report the chosen HTTP/CDP ports.
- Run step 3 only when you need raw CDP target details for debugging.
Core Workflow
- Run
node .github/skills/bloom-automation/bloomProcessStatus.mjs --jsonif you need to know whether an ordinary current-worktree instance is already running. - If a current-worktree instance is already running and the user did not explicitly ask for a second instance, reuse it. If you need a fresh automation-owned instance instead, first kill the existing exact target with
node .github/skills/bloom-automation/killBloomProcess.mjsornode .github/skills/bloom-automation/killBloomProcess.mjs --http-port <httpPort>, then start the replacement with the current repo-supported source-aware launcher, which is./go.shunless the repo documents something better. - Copy the printed HTTP and CDP ports. If you need the exact PID later, run
node .github/skills/bloom-automation/bloomProcessStatus.mjs --http-port <httpPort> --json. - If you instead want to reuse a current-worktree instance that Bloom found by itself, only then use repo-root matching and
--only-mismatchedcleanup. - Run
node .github/skills/bloom-automation/webview2Targets.mjs --http-port <httpPort> --json --waitto discover the live WebView2 target for that exact instance when you need debugging detail. - Use
node .github/skills/bloom-automation/switchWorkspaceTab.mjs --http-port <httpPort> --tab <collection|edit|publish>for top bar interactions, or attach another confirmed client tohttp://localhost:<cdpPort>if you need lower-level inspection. - Manipulate the UI by clicking or typing in the attached browser context. Do not use Bloom API endpoints to simulate the user action itself.
- Use browser-native inspection for DOM, console, and network.
- If the task is test-related, run the exe-backed Playwright suite with
BLOOM_HTTP_PORT=<httpPort> pnpm exec playwright test --config playwright.bloom-exe.config.ts.
Running Bloom Workflow
Use this when the user says to reuse the already-running Bloom.
- Run
node .github/skills/bloom-automation/bloomProcessStatus.mjs --running-bloom --json. - If no running Bloom instance is reported, tell the user there is no running Bloom to reuse.
- If one is reported, do not kill or restart it because of worktree mismatch.
- Use
node .github/skills/bloom-automation/switchWorkspaceTab.mjs --running-bloom --tab <collection|edit|publish>for top bar actions, ornode .github/skills/bloom-automation/webview2Targets.mjs --running-bloom --json --waitwhen you need CDP target detail. - Attach to the reported instance and work only against the
httpPortandcdpPortit reported about itself.
Rules
Never probe a live Bloom to discover whether something exists
Bloom is a desktop app whose server expects only the requests its own UI makes. A request
for an unregistered API endpoint is treated as a program error, not a 404: the server raises
a modal "Bloom had a problem" dialog (ReportMissingApiEndpoint → NonFatalProblem.Report)
on top of whatever the user is doing, and the same goes for a missing file
(ReportMissingFile). So an exploratory GET /bloom/api/... "just to see if it's there" —
e.g. to identify a running instance, find the current book, or check whether a feature
exists — punishes the human at the screen even though the response looks harmless to you.
- To learn whether an endpoint exists, read the source: grep
src/BloomExe/web(and the callers ofapiHandler.RegisterEndpointHandler) for the endpoint string. - To identify or inspect a running instance, use the endpoints this skill already documents
(
common/instanceInfo, the launcher/status) or the helper scripts — nothing else. - Send a request to a live Bloom only when the code shows the endpoint is registered in the build that Bloom is running (a worktree on another branch may not have it).
Reuse the current worktree instance
- Reuse it.
- Attach over CDP and drive the UI directly.
- Do not restart unless the user explicitly wants a fresh run or you need to load new code.
- When you DO need to load new .NET code, use
launcherControl.mjs --restart --wait-ready(if the launcher is live) instead of killing and relaunching by hand or asking the human to quit Bloom.
Do not accumulate worktree-owned instances
- Do not start another Bloom from the same worktree just because explicit ports are available.
- Before any fresh launch, check whether a current-worktree instance is already running.
- If one is running and the user did not explicitly ask for multi-instance behavior, either reuse it or kill that exact instance before launching a replacement.
- Only keep multiple current-worktree Bloom instances alive when the user explicitly asked for that workflow or when the task itself is a verified multi-instance scenario.
Treat wrong-worktree Bloom as a blocker
- Treat that as a blocker because it produces extremely confusing results.
- Report the detected repo root from
node .github/skills/bloom-automation/bloomProcessStatus.mjs. - Kill the mismatched process with
node .github/skills/bloom-automation/killBloomProcess.mjs --only-mismatched. - Then start the current worktree.
Start with go.sh, not raw watch commands
- Start it from the current worktree.
- Use
./go.shfor fresh launches from this repo unless a better repo-supported source-aware launcher is documented. - Do not launch an already-built
Bloom.exedirectly, and do not callnode scripts/watchBloomExe.mjsdirectly unless you are debugging the launcher implementation itself. - Before using
./go.shfor a fresh launch, clean up any existing current-worktree instance unless the user explicitly asked for concurrent instances. - Treat the printed HTTP port as the identity of that instance. Use
bloomProcessStatus.mjs --http-port <port>,webview2Targets.mjs --http-port <port>, andkillBloomProcess.mjs --http-port <port>to target it precisely. - Never wait for
go.shto exit as a readiness signal. It is a long-lived launcher. Wait for the latestBloom ready.line in the background terminal output instead, and treat a laterBloom PID ... exited shortly after reporting readymessage as a failed launch.
Reuse the running Bloom when the user asks for it
- Run
node .github/skills/bloom-automation/bloomProcessStatus.mjs --running-bloom --json. - Reuse the returned running Bloom instance even if it does not match the current worktree.
- Use
node .github/skills/bloom-automation/switchWorkspaceTab.mjs --running-bloom --tab <collection|edit|publish>for direct top bar interaction, or discover its CDP target withnode .github/skills/bloom-automation/webview2Targets.mjs --running-bloom --json --waitwhen you need the raw target details. - Do not kill or restart it unless the user explicitly asks for that.
Prove browser-native access when needed
- Show the CDP target from
node .github/skills/bloom-automation/webview2Targets.mjs --json --wait. - Attach with Playwright.
- Demonstrate reading
body.className, the top-bar iframe, console messages, and theworkspace/selectTabrequest. - For multi-instance work, prefer
webview2Targets.mjs --http-port <port> --json --waitand the matchingcdpPortit reports.
Verified two-instance smoke path
- Launch one instance with
./go.shand record the HTTP port from itsBloom ready.line. - Launch a second instance with
./go.shand record the HTTP port from itsBloom ready.line. - Target the first instance with
switchWorkspaceTab.mjs --http-port <firstPort> --tab edit. - Target the second instance with
switchWorkspaceTab.mjs --http-port <secondPort> --tab publish. - Use the reported ports throughout; do not mix
--running-bloomwith this workflow.
Confirmed Path
playwrightNode library viahttp://localhost:<cdpPort>using thecdpPortreported bycommon/instanceInfoorwebview2Targets.mjs@playwright/testrunner via the exe-backed suite insrc/BloomBrowserUI/react_components/component-tester
Not confirmed here:
chrome-devtools-mcpas an attached client to Bloom's existing WebView2 target- the current Playwright MCP browser wrappers as an attached client to Bloom's existing WebView2 target
Reason: the current MCP wrappers in this environment control their own browser instance and do not expose a way to attach to an already-running external CDP endpoint. Until those tools add explicit attach support, prefer the go.sh launcher plus Playwright path above.
Tests
- Run from
src/BloomBrowserUI/react_components/component-tester. - Use
BLOOM_HTTP_PORT=<httpPort> pnpm exec playwright test --config playwright.bloom-exe.config.ts. - Run one file with
BLOOM_HTTP_PORT=<httpPort> pnpm exec playwright test --config playwright.bloom-exe.config.ts ../TopBar/component-tests/bloom-exe-tabs.uitest.ts.
These tests attach to the real Bloom.exe target over CDP and verify tab switching plus console and network observation.
Notes
- Prefer the Node helpers over PowerShell. The Node scripts use
wmic,taskkill, anddotnetdirectly because the PowerShell path proved too brittle. - Prefer the checked-in repo entrypoints and helper commands over raw Windows shell commands. Subagents should normally run
./go.sh,node .github/skills/bloom-automation/bloomProcessStatus.mjs --json,node .github/skills/bloom-automation/killBloomProcess.mjs --only-mismatched,node .github/skills/bloom-automation/webview2Targets.mjs --json --wait, andnode .github/skills/bloom-automation/switchWorkspaceTab.mjs --running-bloom --tab edit, not ad hocwmiccommands. If the repo later documents a better source-aware launcher than./go.sh, prefer that documented launcher instead. - For agent-driven launches, the background terminal is part of the control plane. Leave it running and poll its output for the latest
Bloom ready.line instead of waiting for command completion. - Exact-target cleanup is intentionally strict:
killBloomProcess.mjs --http-port <port>should only kill the instance that actually reports that HTTP port, and should fail without killing anything if that target cannot be resolved. - When reporting work, include the helper commands you used so reviewers can confirm the workflow stayed on the supported path.
- Wrong-worktree detection is authoritative when a real
Bloom.exechild exists or whendotnet watchwas started with an absolute--projectpath. - When more than one Bloom is running from the same worktree, repo-root matching is not enough. Use the explicit HTTP port workflow.
- For ad hoc local debugging in this workspace,
dev-browser --connect http://localhost:<cdpPort>can attach directly to the existing Bloom WebView2 target. Use it as a low-friction inspection client. - After attaching to Bloom's WebView2 target, if Bloom is on the Edit tab, the editable page content lives inside the iframe named
page; the top-level document mostly hosts shell UI plus the root dialog container. - Driving the "Edit with AI…" image editor (which spans a third frame — the editor overlay iframe — and needs the free "Local Dummy" model so tests cost nothing): see
ai-image-editor-driving.mdin this folder and thedriveAiImageEditor.mjshelper. - "Bloom had a problem" report dialogs. Bloom surfaces errors (including non-fatal ones, especially in Debug builds) as a modal "Bloom had a problem" dialog. It is hosted in its OWN WinForms window with its own WebView2, so it appears as a SEPARATE CDP page target — not inside the shell document or the
pageiframe — and in dev it is even served from the Vite port rather than the Bloom http port. Detect it by the.problem-dialogroot (fromproblemDialog/*.tsx) present in ANY page target. Never just leave one sitting on screen, and never move past it silently.- Use
node .github/skills/bloom-automation/dismissProblemDialog.mjs --http-port <httpPort> [--wait] [--json]. It (1) finds the dialog by DOM (so it never closes a legitimate modal), (2) GATHERS the underlying problem — it clicks the dialog's own "Learn More" to reveal the exception + missing-file/stack that Bloom would send, and prints it, and (3) closes the dialog with the SAME action as its Close button,POST /bloom/api/common/closeReactDialog, which does NOT submit. It drains a backlog (Bloom queues reports and shows them one at a time), gathering each, up to a cap. - NEVER click Submit / POST
problemReport/submitin automation: that sends a report (with a screenshot and the book) to Bloom's servers. - If the SAME problem keeps reappearing after being closed, it is a real recurring error in the code under test (e.g. a resource that 404s on every render) — read the gathered detail, fix the root cause, and re-test; do not just loop-dismiss. The Bloom log at
%TEMP%\SIL\Bloom\Log-*.txthas the same detail if you need it out-of-band, but note its writes can lag, so the dialog's own "Learn More" (what the helper scrapes) is the authoritative live source.
- Use
Field-verified gotchas (all hit in real agent runs)
-
Port 8089 is first-come, not per-worktree. Bloom's server starts at 8089 and falls forward to the next port block when it is taken, so a second worktree's Bloom lands on 8092/8094 without complaint. Always take httpPort/cdpPort from launcher
--status; anything that hardcodes 8089 (the canvas e2e suite's defaultBLOOM_CANVAS_E2E_URL) silently drives whichever Bloom got there first — possibly another worktree's build. -
data-toolidmeans two things in the toolbox. A section header's icon carries the tool's canonical id (canvas); the panel body carries the persisted "Tool"-suffixed name (canvasTool). A selector matching both finds the 16px icon first, which reads as "the panel renders empty" when the panel is fine — scope panel queries todiv[data-toolid="...Tool"]. -
Reader/audio highlight state lives in
CSS.highlights, not the DOM. Since BL-16558 there are no marker spans to count. Assert viaCSS.highlights.entries(), counting ranges where!range.collapsedandrange.getClientRects().lengthfor "actually painted". And no highlights is not proof markup is broken: the Leveled Reader panel's switch gates painting entirely — flip it on first. -
Coordinates differ between frames. The Edit tab is several iframes with their own screen/client/page coordinate systems, and page scaling (
transform: scale(...)) changes whatgetBoundingClientRect()returns. When measuring a drop point against a created element, compare in one consistent coordinate space and test at more than one zoom level. -
Ad-hoc driver scripts cannot
import "playwright"from a scratch directory — Node resolves from the script's own path. Do what the shipped drivers do:createRequire("<repo>/src/BloomBrowserUI/react_components/component-tester/package.json")andrequire("playwright")through that. -
WMI/wmic can go blind mid-session.
bloomProcessStatus.mjs(plain mode) andkillBloomProcess.mjsenumerate processes viawmic; WMI has stopped answering partway through a session — status reported zero Bloom processes while one was demonstrably serving HTTP, andGet-CimInstancehung for minutes. Trust the HTTP-based--running-bloomdiscovery andinstanceInfoover process enumeration. -
killBloomProcess.mjsunder-kills. Observed bothkilledProcessIds: []for a valid target and partial kills where thedotnet watchparents died but Bloom.exe survived. Always verify the port went dark (instanceInfocurl fails) and the Bloom PID is gone; PowerShellStop-Process -Id <pid> -Forceany survivors. (The launcher control API's--quit-bloom/--shutdownavoid this whole class of problem — prefer them whenever a launcher is live.) -
Orphaned
dotnet watchchains relaunch Bloom. If Bloom.exe is killed but its watcher chain survives (e.g. Task Manager kills), the watchers sit at "Waiting for a file to change" and respawn Bloom on the next C# edit. CheckbloomProcessStatus.mjs --json(watchProcesses) and stop stale ones. -
Never type
taskkill /PID ...in Git Bash — MSYS rewrites/PIDtoC:/Program Files/Git/PID. Use the node helpers or PowerShell. -
Grep launch logs for
Bloom ready\. HTTP, notBloom ready\.— early in the log watchBloomExe prints an instructional message that quotes the phrase'Bloom ready.', which matches the looser pattern long before launch completes. (Better: polllauncherControl.mjs --statusinstead of grepping logs at all.) -
dotnet watchnoise: the launch log contains scary⚠ msbuild: [Failure] Package 'X' was restored using .NETFramework...lines. They are warnings; launch still succeeds. Don't grep the log for bareFailure/erroras a failure signal — wait forBloom ready./exited shortly after, or use--status. -
bloomProcessStatus.mjsmay printAssertion failed: !(handle->flags & UV_HANDLE_CLOSING)(libuv, on exit, after the JSON is complete). Ignore it; the JSON on stdout is valid. -
Agent shells may auto-background long commands. Capture the task output file and read it; don't assume the inline result is the output.
-
body.classNameof the top-level page isdeveloperin dev builds; page URLs look likehttp://localhost:<port>/bloom/C%3A/...Temp/bloomXXXX.htm. -
Uninitialized worktrees self-heal at launch. go.mjs detects missing node_modules /
lib/dotnetdeps /output/browserbundles and runs./init.shitself (discovery file showsphase:"init", takes minutes). CS0246 (PodcastUtilitiesetc.) on other build paths still means init hasn't run. -
Bloom vanished with the whole stack? Rule out a crash before relaunching. A Debug-build
Debug.Assertfiring on any thread becomesEnvironment.FailFast: no dialog, Bloom exits, and the launcher tears everything down exactly as if the human had closed the window. When launched in an Orca terminal tab there is nooutput/bloom-launcher.logeither. Where the story is: (1) the Windows Application event log —Get-WinEvent -FilterHashtable @{LogName='Application'; StartTime=(Get-Date).AddMinutes(-30)}, provider.NET Runtime, event 1025 has the FailFast message and the full stack; (2) Bloom's own log at%LOCALAPPDATA%\Temp\SIL\Bloom\Log.txt(NOT under%LOCALAPPDATA%\SIL\Bloom); (3) the Freeze Doctor session file%LOCALAPPDATA%\SIL\BloomFreezeDoctor\sessions\bloom-<pid>.json— one with noExitblock is a run that ended badly. Do not expect a Doctor dump from a dev Bloom:RunFreezeDoctoris off in the dev user.config and the Debug build puts noBloomFreezeDoctor.exebesideBloom.exe. -
Setting a text hyperlink over CDP. The CKEditor link button is
.cke_button__setuplinkin thepageframe and exists only while text is selected. In the Choose Link Target dialog (top-level document) the URL box'sdata-testid="url-input"is on the MUI wrapper, so target[data-testid="url-input"] input, and it is pre-filled with#cover: select-all before typing or you get#coverhttps://.... Applying a link on top of an existing link nests a second<a>rather than replacing it. Page-list thumbnails are clickable by caption text (.thumbnailCaptionin thepageListframe); Collections → Edit is theEDIT THIS BOOKbutton.
Completion Checks
- Bloom's status is known: not running, running from current worktree, or running from different worktree.
- Any mismatched Bloom instance has been stopped before running the current worktree, unless you intentionally started a separate explicit-port instance.
- The chosen HTTP port returns
common/instanceInfo, including the exact Bloom PID and CDP port. - The reported CDP endpoint responds at
http://localhost:<cdpPort>/json/version. node .github/skills/bloom-automation/webview2Targets.mjs --http-port <httpPort> --json --waitreturns a real Bloom target.- The automation client can read DOM state and interact with the embedded top bar.
- If tests were requested, the exe-backed Playwright suite passes.
Output Contract
Report:
- whether Bloom was already running
- which repo root the running Bloom came from
- whether you killed a mismatched or stale process
- which command you used to start Bloom
- which HTTP/CDP ports were assigned
- which Bloom PID and
dotnetPID were associated with that instance - which client attached successfully
- what browser-native evidence you collected: DOM state, console output, network request, tab state, or test results
Example Prompts
troubleshoot why the page is refreshing when we open page settings
Debugging tips
Use node or bash scripts. Avoid powershell. Use the "dev-browser" cli instead of playwright for interactive debugging/driving Bloom. Use "dev-browser --help" to see the available commands and options. If the user hasn't installed dev-browser, ask them for permission to install it (https://github.com/SawyerHood/dev-browser).