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:
Get-Process chrome | Where-Object MainWindowHandle -ne 0Empty. 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:
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, Nameexplorer.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:
schtasks /create /f /tn AgentChromeLaunch /sc once /st 00:00 ^ /tr "C:\path\to\launch-chrome.bat" ^ /ru "YOURMACHINE\yourusername" /itschtasks /run /tn AgentChromeLaunchWe 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.
Related
- Clearing %TEMP% on a machine running parallel Claude sessions is friendly fire, another Windows-specific agent infrastructure collision
- Our agent scheduled a daily job against a repo that didn’t exist, agent acts confidently on a false assumption about what exists
- One-Flag Fix: —isolated Stops Parallel Playwright Clones From Deadlocking, parallel agent process isolation on the same machine
- Moving Claude Code Off Your C: Drive: The 12.4 GB VM Bundle and the UWP Permission Wall, Windows-specific agent runtime environment constraints
- claude —resume worked in a terminal and failed in a web app: the LocalSystem homedir trap, NSSM service session context causing silent agent failures
- The most valuable line in an agent’s ruleset: verify the fix in the browser before claiming it’s fixed, closing the visibility loop on agent browser actions