Chrome was running. The debugger answered on port 9222, my automation could navigate pages and screenshot them, and every health check said the browser was alive and well. I could not see the window. It was not in Alt+Tab. It was not parked off-screen at some negative coordinate. It was in a different Windows session entirely, the services-only desktop no human ever logs into, and that one fact is why nothing I tried for the first hour worked.

This bug makes you doubt your own eyes, because two equally true statements contradict each other, and the reconciliation is a piece of Windows architecture that has been shipping since Vista and that almost nobody thinks about until it bites them.

The setup that produced the ghost

My agent cockpit runs Chrome with a dedicated profile so the automation has its own cookies and state, separate from my daily browser. The profile lives at a dedicated path on the machine, and Chrome comes up with --remote-debugging-port=9222 so the Chrome DevTools Protocol can drive it. A small launcher script starts the process and reports status.

That launcher used to be a Playwright-MCP setup. I had migrated it to chrome-devtools plus a dedicated launcher in a recent commit. The migration itself was clean. The windowless behavior arrived with it, quietly, and I did not connect the two until much later.

The symptom was specific and maddening. The launcher reported Chrome alive. CDP connected. Automation drove the browser fine, navigating and grabbing screenshots without complaint. But I could not put eyes on the actual window, and when I went looking for it the way you normally would, it was not there.

Two wrong answers before the right one

The first thing I did was check whether the window handle even existed:

Terminal window
Get-Process chrome | Where-Object MainWindowHandle -ne 0

Empty. Every single Chrome process had MainWindowHandle = 0. On Windows, a MainWindowHandle of 0 means the process has no top-level window on the desktop you are querying. So the process is real, it has a render surface that CDP can screenshot, but Windows is telling me there is no window to show a human. That is the whole mystery compressed into one integer.

The first thing I suspected was the launch flag. The launcher spawned Chrome with a detached creation flag, roughly subprocess.Popen(creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP). That is exactly the sort of thing that sounds like it would orphan a window, so I relaunched through Start-Process instead, which has none of that detachment baggage. Still no window. MainWindowHandle still 0.

The next suspect was off-screen coordinates. This is a real failure mode I have hit before. A window can open at a negative X coordinate on a monitor that is not physically there anymore, fully alive and fully invisible. So I enumerated every top-level window through the Win32 API, walking EnumWindows and pulling each title with GetWindowText. The Chrome window showed up in the window tree, but with WS_VISIBLE cleared. So it was not off-screen. It was present in the enumeration but flagged not-visible. That is a different problem than coordinates, and it pointed somewhere I had not been looking.

A window that exists, is owned by a live process, has no MainWindowHandle on my desktop, and is not visible is not a coordinate problem and not a launch-flag problem. It is a “which desktop are we even talking about” problem.

The real cause: Session 0 Isolation

The cockpit runs as an NSSM-managed Windows service. Services do not run in your interactive desktop session. They run in Session 0, the dedicated services session that Windows carved off in Vista specifically to keep services from sharing a window station with logged-in users. Anything a service spawns inherits Session 0 unless you do something explicit to cross back over. My cockpit spawned the Claude CLI, the CLI spawned the launcher, the launcher spawned Chrome, and every link in that chain stayed in Session 0.

Windows enforces Session 0 Isolation hard. A window created in Session 0 simply cannot draw on the interactive desktop. There is no user logged into Session 0 to look at it. The window genuinely exists, which is why EnumWindows found it and why CDP could screenshot the off-screen render surface, but it lives on a window station no human is attached to. That is the exact reconciliation of my two contradictory facts. Chrome is up, in Session 0. There is no Chrome, on my desktop, in my interactive session.

I confirmed it by listing both side by side. explorer.exe is the interactive desktop shell, so its session is my session; the service-spawned Chrome reported a different one:

Terminal window
Get-CimInstance Win32_Process -Filter "Name='chrome.exe' OR Name='explorer.exe'" |
Select-Object Name, ProcessId,
@{n='SessionId';e={ (Invoke-CimMethod $_ -MethodName GetOwnerSessionId).SessionId }} |
Sort-Object SessionId, Name

explorer.exe sat in my interactive session. The service-spawned processes sat in Session 0. Once you see those two numbers side by side, the ghost stops being a ghost. The window had never been on my desktop to begin with.

The fix: Task Scheduler as the bridge back

You cannot just tell a service-spawned process to draw on the interactive desktop. The old “Allow service to interact with desktop” checkbox is exactly the hole Session 0 Isolation was built to close, and it does not give you a real interactive window anyway. You need a legitimate way to launch a process into the user’s session from inside Session 0, and Windows already ships one: Task Scheduler.

A scheduled task whose /ru is the logged-in user, created with /it (interactive), runs in that user’s desktop session even when something in Session 0 triggers it. Those two flags are the whole trick. Drop them and the task runs as SYSTEM back in Session 0 and you are exactly where you started. So the launcher detects its own session and, if it is in Session 0, hands the actual Chrome launch off to a one-shot scheduled task instead of spawning Chrome directly:

Terminal window
schtasks /create /f /tn AgentChromeLaunch /sc once /st 00:00 ^
/tr "C:\path\to\launch-chrome.bat" ^
/ru "YOURMACHINE\yourusername" /it
schtasks /run /tn AgentChromeLaunch

I ended up pointing /tr at a small .bat rather than chrome.exe directly, because the nested quoting around the profile path kept breaking schtasks parsing. The batch file just starts Chrome with the port and profile flags. The detection is the same SessionId check I used to diagnose it. If the launcher finds it is running in Session 0, it routes through schtasks; otherwise it launches Chrome directly the way it always did. I baked that branch into the launcher so the agent cockpit never has to think about it again. After that, the launch landed Chrome in the interactive session with port 9222 still answering: same browser, same profile, same automation, now visible.