Chrome was running. The debugger answered on port 9222, our automation could navigate pages and screenshot them, and every health check said the browser was alive. We 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, the services-only one no human logs into, and that is why nothing we tried for the first hour worked.

Two equally true statements contradicted each other: the browser is up, and there is no browser. The reconciliation is a piece of Windows architecture that has shipped since Vista.

The setup that produced the ghost

Our agent service runs Chrome with a dedicated profile so the automation has its own cookies and state, separate from a 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. We 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, and we did not connect the two until much later.

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

Two wrong answers before the right one

First we checked whether the window handle existed at all:

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

Empty. Every Chrome process had MainWindowHandle = 0. On Windows that means the process has no top-level window on the desktop you are querying. The process is real and has a render surface CDP can screenshot, but Windows says there is no window to show a human.

Our first suspect was the launch flag. The launcher spawned Chrome with a detached creation flag, roughly subprocess.Popen(creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP). That sounds like something that would orphan a window, so we relaunched through Start-Process, which has none of that detachment baggage. Still no window. MainWindowHandle still 0. That cost us time and moved nothing.

A window can open at a negative X coordinate on a monitor that is no longer physically there, fully alive and fully invisible. So we 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 and flagged not-visible, which is a different problem than coordinates.

A window that exists, belongs to a live process, has no MainWindowHandle on our desktop and is not visible is neither a coordinate problem nor a launch-flag problem. It is a question of which desktop we were even talking about.

The cause: Session 0 Isolation

The agent service 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 split off in Vista to keep services from sharing a window station with logged-in users. Anything a service spawns inherits Session 0 unless you explicitly cross back over. The service 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 this hard. A window created in Session 0 cannot draw on the interactive desktop, because no user is logged into Session 0 to look at it. The window exists, which is why EnumWindows found it and why CDP could screenshot the render surface, but it lives on a window station no human is attached to. That reconciles the two facts. Chrome is up, in Session 0. There is no Chrome on the interactive desktop.

We confirmed it by listing both side by side. explorer.exe is the interactive desktop shell, so its session is the interactive 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 the interactive session. The service-spawned processes sat in Session 0. With those two numbers side by side, the window had never been on our desktop to begin with.

The fix: Task Scheduler as the bridge back

You cannot tell a service-spawned process to draw on the interactive desktop. The old “Allow service to interact with desktop” checkbox is 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, exactly where we started. So the launcher detects its own session and, if it is in Session 0, hands the Chrome launch 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

We 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 we used to diagnose it. In Session 0, the launcher routes through schtasks; otherwise it launches Chrome directly the way it always did. We baked that branch into the launcher so the agent service 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.

What to check first

If a browser automation reports healthy but nobody can see its window, check which Windows session the process runs in before chasing launch flags or screen coordinates. Anything a Windows service starts stays in Session 0 unless it is launched through an interactive route, such as a scheduled task run as the logged-in user. In our case every health check reported the browser alive while its window sat in Session 0.