[STG-2408] feat(cli): default local managed sessions to headed when run interactively#2281
[STG-2408] feat(cli): default local managed sessions to headed when run interactively#2281shrey150 wants to merge 2 commits into
Conversation
Managed local sessions previously always defaulted to headless. Now, when neither --headed nor --headless is passed, the default is environment-aware: an interactive human at a TTY with a display gets a visible (headed) browser window — the "wow moment" of `browse open` — while agents, CI, piped / non-interactive shells, and machines without a display server stay headless. resolveHeadless stays synchronous. Agent detection reuses the same env markers that drive the `agent` telemetry property (mirrored from @vercel/detect-agent's determineAgent via a new sync isAgentContext()), omitting only that detector's async Devin filesystem probe so mode resolution never blocks. Explicit --headed / --headless always override; passing both still fails. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: ac16609 The changes in this PR will be included in the next version bump. This PR includes changesets to release 0 packagesWhen changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
1 issue found across 5 files
Confidence score: 3/5
- In
packages/cli/src/lib/driver/mode.ts,hasDisplay()always returning true on macOS/Windows can mis-detect headless environments, causing interactive no-GUI sessions to default to headed mode and fail local managed startup; fix the display check (or force headless fallback when no GUI is available) before merging.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/cli/src/lib/driver/mode.ts">
<violation number="1" location="packages/cli/src/lib/driver/mode.ts:59">
P2: Display detection is incorrect on macOS/Windows because hasDisplay() always returns true. This can default to headed in interactive no-GUI sessions and break local managed startup.</violation>
</file>
Architecture diagram
sequenceDiagram
participant CLI as browse CLI
participant Flags as Flag Parser
participant Resolve as resolveHeadless()
participant Default as NEW: shouldDefaultHeaded()
participant Agent as NEW: isAgentContext()
participant Disp as NEW: hasDisplay()
participant TTY as process.stdout.isTTY
participant Env as process.env (CI, DISPLAY, WAYLAND_DISPLAY)
participant Platform as process.platform
Note over CLI,Platform: Runtime default detection when neither --headed nor --headless given
CLI->>Flags: parse flags (--headed, --headless, ...)
Flags-->>CLI: {headed: undefined, headless: undefined}
CLI->>Resolve: resolveHeadless({})
alt explicit flag present
Note over Resolve: (unchanged path – not shown, both flags still error)
else no explicit flag → NEW default logic
Resolve->>Default: shouldDefaultHeaded()?
Default->>Agent: isAgentContext()?
Agent-->>Default: false (no agent env)
Default->>TTY: process.stdout.isTTY?
TTY-->>Default: true (interactive terminal)
Default->>Env: process.env.CI?
Env-->>Default: undefined/falsy (not CI)
Default->>Disp: hasDisplay()?
Disp->>Platform: process.platform?
Platform-->>Disp: "darwin" or "win32"
Note over Disp: darwin/win32 → true always
alt linux/other
Disp->>Env: DISPLAY or WAYLAND_DISPLAY?
Env-->>Disp: set or undefined
end
Disp-->>Default: true (display available)
Default-->>Resolve: true (should default headed)
Resolve-->>CLI: headless = false (headed)
end
Note over CLI,Platform: Key rejection branches (first matching condition wins)
alt Agent context
Agent-->>Default: true (env marker present)
Default-->>Resolve: false → headless
else Non‑TTY
TTY-->>Default: undefined/false
Default-->>Resolve: false → headless
else CI env
Env-->>Default: "true"/"1" (CI set)
Default-->>Resolve: false → headless
else No display
Disp-->>Default: false (linux without display)
Default-->>Resolve: false → headless
end
Note over CLI: Browser launched with resolved headless mode
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Capture the resolved driver session kind and (for managed-local) the headed/headless choice on cli.command_completed via the run-telemetry accumulator, recorded at resolveTargetForCommand — the single chokepoint every driver command goes through (captures reused sessions too). This makes headed-vs-headless usage observable, which it wasn't before. Also document the macOS/Windows hasDisplay() assumption and its one false-positive (a human SSH'd into a headless box). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| * window mode toward headless for agents; the authoritative async | ||
| * {@link detectAgent} still drives telemetry. | ||
| */ | ||
| export function isAgentContext(): boolean { |
There was a problem hiding this comment.
why are we not using detect agent from Vercel directly here?
There was a problem hiding this comment.
determineAgent() from @vercel/detect-agent is async — it does a filesystem probe (for Devin) and returns a Promise. We do use it directly, in the async detectAgent() right above (line 12), which is the authoritative path that drives telemetry.
isAgentContext() can't call it, though: it's invoked from resolveHeadless() → shouldDefaultHeaded(), which this PR deliberately keeps synchronous (sync call sites + ~20 tests assert sync behavior). You can't await determineAgent() from a sync function, so this is a sync reimplementation of the same env-marker checks, minus only that async Devin probe.
Tradeoff: the marker list is duplicated and can drift from @vercel/detect-agent over time. Mitigation: the async detectAgent() (which does call the Vercel pkg) stays authoritative for telemetry; isAgentContext() is only a best-effort bias toward headless, so a missed marker just means an agent occasionally gets the headed default on a real display — recoverable with --headless.
If you'd rather have one source of truth, the alternative is to make shouldDefaultHeaded()/resolveHeadless() async and call determineAgent() directly — but that ripples through the sync call sites and tests, which is why this PR kept the sync fork. Happy to do that refactor if you'd prefer it.
What & why
Local managed-browser sessions default to headless today — even for a human running
browse openat their terminal, so they see nothing. Watching a real browser drive itself is the activation/"aha" moment (and makes debugging obvious). But "always headed" would break the majority of usage: agents / CI / containers / headless Linux often have no display server, where headed Chrome can't launch — and headed steals OS focus (mitigated in #2258).So: detect the human, don't flip the global default.
The rule
When neither
--headednor--headlessis passed, default to headed only when run interactively with a display — otherwise headless. Explicit flags always win.A dev on a laptop gets a window; agents/CI/scripts stay headless and unbroken.
Observability (added this round)
We had no telemetry on headed vs headless — a 90-day audit of the
cliPostHog project (8.17Mcli.command_invokedevents) found no property capturing session mode at all, so this default would ship blind. This PR closes that gap:cli.command_completednow carriessession_mode— the resolved driver kind (managed-local|remote|cdp|auto-connect)headless— for managed-local, the resolved window mode (true/false;nullotherwise)Recorded once at
resolveTargetForCommand— the single chokepoint every driver command goes through (so it captures reused sessions too: the mode the command actually ran in) — via the existingrun-telemetryaccumulator that already feedscommand_completed. Combined with the existingagentandplatformprops, we can now see the headed/headless split by human-vs-agent and OS, and — viacommand_completed.success— whether headed sessions fail to launch more often (the one false positive noted below).Design notes / rationale
--headed. We deliberately don't auto-flip agents to headed based on display presence — an explicit flag is a guarantee; display-presence is a guess.hasDisplay()is the safe primitive, not a hack:DISPLAY/WAYLAND_DISPLAYis the canonical X11/Wayland signal, and there's no mature npm lib for it (everyone reads the env var directly). macOS/Windows have no such var, so we assume a display — correct for the common case (laptop dev), with one documented false positive: a human SSH'd into a headless mac/Windows box. Rare, recoverable with--headless, and now measurable.Implementation notes
resolveHeadlesschanged (mode.ts); explicit--headed/--headlesshandling and the both-flags error are untouched, andresolveHeadlessstays synchronous.isAgentContext()(inlib/agent.ts) is a sync helper mirroring the env markers the asyncdetectAgentkeys on (CLAUDECODE,CURSOR_*,CODEX_*,GEMINI_CLI,HERMES_SESSION_PLATFORM,OPENCLAW_SHELL, …), omitting onlydetectAgent's async Devin filesystem probe — same signal source, no blocking.RunTelemetryState; recorded atresolveTargetForCommand; emitted incommandCompletedProperties. Best-effort — telemetry never affects CLI behavior.E2E Test Matrix
open https://example.com --local --headless→ local capture servercli.command_completedpayload:{ session_mode: "managed-local", headless: true, success: true, leaf_command: "open", agent: "claude-code_…" }; page loaded ("Example Domain")command_completed, notcommand_invoked.pnpm test:cliresolveTargetForCommandrecordssession_mode/headlessfor managed-local headless, managed-local headed (headless=false), and remote (headless unset).cli-telemetry.test.ts(e2e HTTP capture)statuscommand →session_mode: null, headless: nullon the capturedcommand_completedpnpm lint(eslint +tsc --noEmit)pnpm build(turbo, core built first)Follow-ups (not in this PR)
--headless, consider headed-on-first-run, headless after (first-run is derivable viafirst_time_for_user).session_mode×agent×platformdata rather than guessing now.Closes STG-2408.
🤖 Generated with Claude Code