Skip to content

[STG-2408] feat(cli): default local managed sessions to headed when run interactively#2281

Open
shrey150 wants to merge 2 commits into
mainfrom
shrey/headed-default-when-interactive
Open

[STG-2408] feat(cli): default local managed sessions to headed when run interactively#2281
shrey150 wants to merge 2 commits into
mainfrom
shrey/headed-default-when-interactive

Conversation

@shrey150

@shrey150 shrey150 commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

What & why

Local managed-browser sessions default to headless today — even for a human running browse open at 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 --headed nor --headless is passed, default to headed only when run interactively with a display — otherwise headless. Explicit flags always win.

shouldDefaultHeaded():
  if (isAgentContext()) return false;        // agent-driven → headless
  if (!process.stdout.isTTY) return false;   // piped / non-interactive / CI → headless
  if (process.env.CI) return false;          // CI → headless
  if (!hasDisplay()) return false;           // no display server → headless
  return true;
// hasDisplay(): darwin/win32 → true; linux/other → DISPLAY || WAYLAND_DISPLAY

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 cli PostHog project (8.17M cli.command_invoked events) found no property capturing session mode at all, so this default would ship blind. This PR closes that gap: cli.command_completed now carries

  • session_mode — the resolved driver kind (managed-local | remote | cdp | auto-connect)
  • headless — for managed-local, the resolved window mode (true/false; null otherwise)

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 existing run-telemetry accumulator that already feeds command_completed. Combined with the existing agent and platform props, we can now see the headed/headless split by human-vs-agent and OS, and — via command_completed.success — whether headed sessions fail to launch more often (the one false positive noted below).

Design notes / rationale

  • Why agents stay headless even with a display: the dominant agent traffic is local IDE agents (codex, claude-code) running on a human's own desktop, where a headed window would steal focus on every command. Sandbox agents that want headed (e.g. a more realistic fingerprint) can opt in with --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_DISPLAY is 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.
  • The default is conservative by construction: every gate must pass before going headed, so it fails safe to headless on any doubt — a missed window is a mild letdown; a headed launch that crashes on a server is a real break.

Implementation notes

  • Only the default branch of resolveHeadless changed (mode.ts); explicit --headed/--headless handling and the both-flags error are untouched, and resolveHeadless stays synchronous.
  • isAgentContext() (in lib/agent.ts) is a sync helper mirroring the env markers the async detectAgent keys on (CLAUDECODE, CURSOR_*, CODEX_*, GEMINI_CLI, HERMES_SESSION_PLATFORM, OPENCLAW_SHELL, …), omitting only detectAgent's async Devin filesystem probe — same signal source, no blocking.
  • Telemetry: 2 fields added to RunTelemetryState; recorded at resolveTargetForCommand; emitted in commandCompletedProperties. Best-effort — telemetry never affects CLI behavior.

E2E Test Matrix

Command / flow Observed output Confidence / sufficiency
Real headless session with live telemetry capture: built CLI open https://example.com --local --headless → local capture server cli.command_completed payload: { session_mode: "managed-local", headless: true, success: true, leaf_command: "open", agent: "claude-code_…" }; page loaded ("Example Domain") Proves the new props ride along on a real managed-local session end-to-end (real headless Chrome, real PostHog-shaped POST), and that they appear only on command_completed, not command_invoked.
pnpm test:cli 291 passed (290 baseline + 1 new recording test). New unit test asserts resolveTargetForCommand records session_mode/headless for managed-local headless, managed-local headed (headless=false), and remote (headless unset). Covers the recording logic across all modes, incl. headed=false — which the headless smoke can't exercise in a non-TTY shell.
cli-telemetry.test.ts (e2e HTTP capture) non-driver status command → session_mode: null, headless: null on the captured command_completed Confirms defaults for commands that never resolve a driver target.
pnpm lint (eslint + tsc --noEmit) clean Types + lint.
pnpm build (turbo, core built first) success Compiles against built core.

Follow-ups (not in this PR)

  • Once data lands: if repeat/power users start passing --headless, consider headed-on-first-run, headless after (first-run is derivable via first_time_for_user).
  • Revisit whether specific sandbox agent markers (hermes/openclaw) should be allowed headed when they have a display — decide from real session_mode × agent × platform data rather than guessing now.

Closes STG-2408.

🤖 Generated with Claude Code

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-bot

changeset-bot Bot commented Jun 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ac16609

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When 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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Loading

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread packages/cli/src/lib/driver/mode.ts
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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we not using detect agent from Vercel directly here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant