diff --git a/.changeset/cli-headed-default-when-interactive.md b/.changeset/cli-headed-default-when-interactive.md new file mode 100644 index 000000000..904da29b4 --- /dev/null +++ b/.changeset/cli-headed-default-when-interactive.md @@ -0,0 +1,7 @@ +--- +"browse": patch +--- + +Local managed sessions now default to headed when run interactively with a display; headless for agents/CI/no-display/non-TTY. Pass `--headed`/`--headless` to override. + +Telemetry now records the resolved `session_mode` and `headless` choice on `cli.command_completed`, so headed-vs-headless usage is observable. diff --git a/packages/cli/src/lib/agent.ts b/packages/cli/src/lib/agent.ts index 6c4e0633d..a61803cd0 100644 --- a/packages/cli/src/lib/agent.ts +++ b/packages/cli/src/lib/agent.ts @@ -15,3 +15,42 @@ export async function detectAgent(): Promise { return null; } } + +/** + * Synchronous best-effort check for whether the CLI is being driven by a coding + * agent (Claude Code, Cursor, Codex, Gemini, etc.) rather than a human at a + * terminal. + * + * This mirrors the env-marker checks used by {@link detectAgent} and + * `@vercel/detect-agent`'s `determineAgent`, which are themselves almost + * entirely synchronous env reads. We deliberately omit the one async branch in + * `determineAgent` (a filesystem probe for Devin) so callers that must stay + * synchronous — e.g. headed/headless mode resolution — can use this without + * becoming async. The result is used only as a heuristic to bias the default + * window mode toward headless for agents; the authoritative async + * {@link detectAgent} still drives telemetry. + */ +export function isAgentContext(): boolean { + const env = process.env; + return Boolean( + env.HERMES_SESSION_PLATFORM || + env.OPENCLAW_SHELL || + env.AI_AGENT || + env.CURSOR_TRACE_ID || + env.CURSOR_AGENT || + env.CURSOR_EXTENSION_HOST_ROLE === "agent-exec" || + env.GEMINI_CLI || + env.CODEX_SANDBOX || + env.CODEX_CI || + env.CODEX_THREAD_ID || + env.ANTIGRAVITY_AGENT || + env.AUGMENT_AGENT || + env.OPENCODE_CLIENT || + env.CLAUDECODE || + env.CLAUDE_CODE || + env.REPL_ID || + env.COPILOT_MODEL || + env.COPILOT_ALLOW_ALL || + env.COPILOT_GITHUB_TOKEN, + ); +} diff --git a/packages/cli/src/lib/driver/command-cli.ts b/packages/cli/src/lib/driver/command-cli.ts index 709d6a9e9..4cb4630c7 100644 --- a/packages/cli/src/lib/driver/command-cli.ts +++ b/packages/cli/src/lib/driver/command-cli.ts @@ -23,6 +23,7 @@ import { } from "./mode.js"; import type { ConnectionTarget } from "./types.js"; import { outputJson } from "../output.js"; +import { setRunTelemetryCompletion } from "../run-telemetry.js"; import { runDriverCommandWithTarget } from "./runtime.js"; export const driverCommandFlags = { @@ -79,6 +80,19 @@ export async function resolveTargetForCommand( session: string, flags: DriverFlags, ) { + const target = await selectTargetForCommand(session, flags); + // Record the effective session mode (and, for managed-local, the resolved + // headed/headless choice) so it rides along on cli.command_completed. This is + // the only place every driver command resolves a target, and it captures the + // reused-session case too — i.e. the mode the command actually ran in. + setRunTelemetryCompletion({ + sessionMode: target.kind, + ...(target.kind === "managed-local" ? { headless: target.headless } : {}), + }); + return target; +} + +async function selectTargetForCommand(session: string, flags: DriverFlags) { const hasExplicitTarget = hasExplicitDriverTarget(flags); if (!hasExplicitTarget || hasModeOnlyFlag(flags)) { const status = await getDriverStatus(session); diff --git a/packages/cli/src/lib/driver/flags.ts b/packages/cli/src/lib/driver/flags.ts index 77509aadf..bba056b84 100644 --- a/packages/cli/src/lib/driver/flags.ts +++ b/packages/cli/src/lib/driver/flags.ts @@ -8,11 +8,13 @@ export const sessionFlag = Flags.string({ }); export const headedFlag = Flags.boolean({ - description: "Show a visible browser window for managed local sessions.", + description: + "Show a visible browser window for managed local sessions. Managed local sessions default to headed when run interactively with a display; headless otherwise. Use --headless/--headed to force.", }); export const headlessFlag = Flags.boolean({ - description: "Run managed local sessions in headless mode.", + description: + "Run managed local sessions in headless mode. Managed local sessions default to headed when run interactively with a display; headless otherwise. Use --headless/--headed to force.", }); export const localFlag = Flags.boolean({ diff --git a/packages/cli/src/lib/driver/mode.ts b/packages/cli/src/lib/driver/mode.ts index 8396e5ed7..084f8f6f7 100644 --- a/packages/cli/src/lib/driver/mode.ts +++ b/packages/cli/src/lib/driver/mode.ts @@ -1,5 +1,6 @@ import { isDeepStrictEqual } from "node:util"; +import { isAgentContext } from "../agent.js"; import { fail } from "../errors.js"; import { getRemote } from "./remote-binding.js"; import { resolveWsTarget } from "./resolve-ws.js"; @@ -23,7 +24,7 @@ interface ResolvedChromeArgs { ignoreDefaultArgs?: boolean | string[]; } -function resolveHeadless( +export function resolveHeadless( flags: Pick, ): boolean { if (flags.headed && flags.headless) { @@ -32,9 +33,44 @@ function resolveHeadless( if (flags.headed) return false; if (flags.headless) return true; + // no explicit flag → default headed for an interactive human with a display, else headless + return !shouldDefaultHeaded(); +} + +/** + * Decide whether a managed local session should default to a visible (headed) + * window when neither --headed nor --headless was passed. + * + * The "wow moment" of `browse open` is a human watching a real browser drive + * itself, so an interactive human at a terminal with a display gets a headed + * window. Agents, CI, piped/non-interactive shells, and machines without a + * display server stay headless so automation never spawns a stray window or + * breaks where no display exists. + */ +export function shouldDefaultHeaded(): boolean { + 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; } +/** + * Best-effort check for whether a GUI can be shown. On Linux we read the X11 / + * Wayland display vars, which is the canonical signal. macOS/Windows have no + * such env var, so we assume a display is present — this is right for the + * overwhelming common case (a dev on a laptop) but can be a false positive for + * a human SSH'd into a headless macOS/Windows box with no console session, + * where headed Chrome may fail to launch. That case is rare and recoverable + * with an explicit `--headless`; if it shows up in telemetry we can revisit. + */ +export function hasDisplay(): boolean { + if (process.platform === "darwin" || process.platform === "win32") + return true; + // linux/other: require an X11 or Wayland display server + return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); +} + export function hasChromeArgFlags(flags: DriverModeFlags): boolean { return chromeArgFlagsInUse(flags).length > 0; } diff --git a/packages/cli/src/lib/run-telemetry.ts b/packages/cli/src/lib/run-telemetry.ts index 9b4d705ac..c63e1f945 100644 --- a/packages/cli/src/lib/run-telemetry.ts +++ b/packages/cli/src/lib/run-telemetry.ts @@ -3,6 +3,10 @@ export interface RunTelemetryState { httpStatus?: number; requestHadHttpResponse?: boolean; skillId?: string; + /** Resolved driver session kind, e.g. "managed-local" | "remote" | "cdp". */ + sessionMode?: string; + /** For managed-local sessions, whether the resolved window mode was headless. */ + headless?: boolean; } let currentRunTelemetry: RunTelemetryState = {}; diff --git a/packages/cli/src/lib/telemetry.ts b/packages/cli/src/lib/telemetry.ts index 7288ba205..9273d9698 100644 --- a/packages/cli/src/lib/telemetry.ts +++ b/packages/cli/src/lib/telemetry.ts @@ -283,6 +283,8 @@ function commandCompletedProperties( runTelemetry.requestHadHttpResponse ?? null, skill_id: runTelemetry.skillId ?? null, + session_mode: runTelemetry.sessionMode ?? null, + headless: runTelemetry.headless ?? null, }; } diff --git a/packages/cli/tests/cli-telemetry.test.ts b/packages/cli/tests/cli-telemetry.test.ts index 1287fd1d5..c0695ee3e 100644 --- a/packages/cli/tests/cli-telemetry.test.ts +++ b/packages/cli/tests/cli-telemetry.test.ts @@ -65,6 +65,9 @@ describe("CLI telemetry", () => { expect(completedPayload.properties.http_status).toBe(null); expect(completedPayload.properties.request_had_http_response).toBe(null); expect(completedPayload.properties.skill_id).toBe(null); + // status is not a driver command, so no session mode is resolved. + expect(completedPayload.properties.session_mode).toBe(null); + expect(completedPayload.properties.headless).toBe(null); } finally { await telemetryServer.close(); } diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index b028c6171..03fdf5035 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -109,6 +109,49 @@ describe("driver commands", () => { } }); + it("records the resolved session mode and headless choice in run telemetry", async () => { + vi.resetModules(); + // No existing daemon → every command resolves a fresh target, and explicit + // --headed/--headless make the resolution display-independent. + const getDriverStatus = vi.fn().mockResolvedValue(undefined); + vi.doMock("../src/lib/driver/daemon/client.js", () => ({ + getDriverStatus, + })); + + try { + const { resolveTargetForCommand } = await import( + "../src/lib/driver/command-cli.js" + ); + const { getRunTelemetry, resetRunTelemetry } = await import( + "../src/lib/run-telemetry.js" + ); + + resetRunTelemetry(); + await resolveTargetForCommand("tele", { headless: true, local: true }); + expect(getRunTelemetry()).toMatchObject({ + sessionMode: "managed-local", + headless: true, + }); + + resetRunTelemetry(); + await resolveTargetForCommand("tele", { headed: true, local: true }); + expect(getRunTelemetry()).toMatchObject({ + sessionMode: "managed-local", + headless: false, + }); + + resetRunTelemetry(); + await resolveTargetForCommand("tele", { remote: true }); + const remoteTelemetry = getRunTelemetry(); + expect(remoteTelemetry.sessionMode).toBe("remote"); + // headless only applies to managed-local; remote leaves it unset. + expect(remoteTelemetry.headless).toBeUndefined(); + } finally { + vi.doUnmock("../src/lib/driver/daemon/client.js"); + vi.resetModules(); + } + }); + it("routes CDP targets through the daemon so session state persists", async () => { vi.resetModules(); const ensureDriverDaemon = vi.fn().mockResolvedValue(undefined); diff --git a/packages/cli/tests/driver-mode-headed-default.test.ts b/packages/cli/tests/driver-mode-headed-default.test.ts new file mode 100644 index 000000000..da4a072e2 --- /dev/null +++ b/packages/cli/tests/driver-mode-headed-default.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + hasDisplay, + resolveHeadless, + shouldDefaultHeaded, +} from "../src/lib/driver/mode.js"; + +/** + * Tests for the environment-aware headed/headless default introduced for managed + * local sessions: a human at an interactive TTY with a display gets a HEADED + * window, while agents / CI / piped / no-display contexts stay HEADLESS. Explicit + * --headed / --headless always win. + */ + +// Env vars that, if set in the ambient shell, would flip isAgentContext() to true +// and make the "interactive human" branch impossible to observe. We clear them +// for the relevant cases and restore afterwards. +const AGENT_ENV_KEYS = [ + "HERMES_SESSION_PLATFORM", + "OPENCLAW_SHELL", + "AI_AGENT", + "CURSOR_TRACE_ID", + "CURSOR_AGENT", + "CURSOR_EXTENSION_HOST_ROLE", + "GEMINI_CLI", + "CODEX_SANDBOX", + "CODEX_CI", + "CODEX_THREAD_ID", + "ANTIGRAVITY_AGENT", + "AUGMENT_AGENT", + "OPENCODE_CLIENT", + "CLAUDECODE", + "CLAUDE_CODE", + "REPL_ID", + "COPILOT_MODEL", + "COPILOT_ALLOW_ALL", + "COPILOT_GITHUB_TOKEN", +] as const; + +const DISPLAY_ENV_KEYS = ["DISPLAY", "WAYLAND_DISPLAY"] as const; + +const originalPlatform = process.platform; +const originalIsTTY = process.stdout.isTTY; +let savedEnv: Record = {}; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +function setTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdout, "isTTY", { + value, + configurable: true, + }); +} + +function clearKeys(keys: readonly string[]): void { + for (const key of keys) { + delete process.env[key]; + } +} + +beforeEach(() => { + // Snapshot every env key we may mutate so each test starts from a clean, + // deterministic baseline regardless of the outer shell (agent, CI, etc.). + savedEnv = {}; + for (const key of [...AGENT_ENV_KEYS, ...DISPLAY_ENV_KEYS, "CI"]) { + savedEnv[key] = process.env[key]; + } + clearKeys(AGENT_ENV_KEYS); + clearKeys(DISPLAY_ENV_KEYS); + delete process.env.CI; +}); + +afterEach(() => { + setPlatform(originalPlatform); + setTTY(originalIsTTY); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +describe("hasDisplay", () => { + it("returns true on darwin regardless of DISPLAY", () => { + setPlatform("darwin"); + expect(hasDisplay()).toBe(true); + }); + + it("returns true on win32 regardless of DISPLAY", () => { + setPlatform("win32"); + expect(hasDisplay()).toBe(true); + }); + + it("returns false on linux without a display server", () => { + setPlatform("linux"); + expect(hasDisplay()).toBe(false); + }); + + it("returns true on linux with X11 DISPLAY", () => { + setPlatform("linux"); + process.env.DISPLAY = ":0"; + expect(hasDisplay()).toBe(true); + }); + + it("returns true on linux with WAYLAND_DISPLAY", () => { + setPlatform("linux"); + process.env.WAYLAND_DISPLAY = "wayland-0"; + expect(hasDisplay()).toBe(true); + }); +}); + +describe("shouldDefaultHeaded", () => { + it("is true for an interactive human: TTY + display + no CI + no agent (darwin)", () => { + setPlatform("darwin"); + setTTY(true); + expect(shouldDefaultHeaded()).toBe(true); + }); + + it("is true for an interactive human on linux with a display", () => { + setPlatform("linux"); + setTTY(true); + process.env.DISPLAY = ":0"; + expect(shouldDefaultHeaded()).toBe(true); + }); + + it("is false when stdout is not a TTY (piped / non-interactive)", () => { + setPlatform("darwin"); + setTTY(undefined); + expect(shouldDefaultHeaded()).toBe(false); + }); + + it("is false when CI is set even with a TTY and display", () => { + setPlatform("darwin"); + setTTY(true); + process.env.CI = "true"; + expect(shouldDefaultHeaded()).toBe(false); + }); + + it("is false on linux without a display even with a TTY", () => { + setPlatform("linux"); + setTTY(true); + expect(shouldDefaultHeaded()).toBe(false); + }); + + it("is false when an agent marker is set even with a TTY and display", () => { + setPlatform("darwin"); + setTTY(true); + process.env.CLAUDECODE = "1"; + expect(shouldDefaultHeaded()).toBe(false); + }); + + it("treats a falsy CI value (empty string) as not CI", () => { + setPlatform("darwin"); + setTTY(true); + process.env.CI = ""; + expect(shouldDefaultHeaded()).toBe(true); + }); +}); + +describe("resolveHeadless default branch", () => { + it("defaults to HEADED for an interactive human (no flags)", () => { + setPlatform("darwin"); + setTTY(true); + expect(resolveHeadless({})).toBe(false); + }); + + it("defaults to HEADLESS when not a TTY (no flags)", () => { + setPlatform("darwin"); + setTTY(undefined); + expect(resolveHeadless({})).toBe(true); + }); + + it("defaults to HEADLESS in CI (no flags)", () => { + setPlatform("darwin"); + setTTY(true); + process.env.CI = "1"; + expect(resolveHeadless({})).toBe(true); + }); + + it("defaults to HEADLESS for an agent (no flags)", () => { + setPlatform("darwin"); + setTTY(true); + process.env.CURSOR_TRACE_ID = "abc"; + expect(resolveHeadless({})).toBe(true); + }); + + it("defaults to HEADLESS on linux without a display (no flags)", () => { + setPlatform("linux"); + setTTY(true); + expect(resolveHeadless({})).toBe(true); + }); +}); + +describe("resolveHeadless explicit flags still override", () => { + it("--headed forces headed even in a headless-default context (non-TTY)", () => { + setPlatform("darwin"); + setTTY(undefined); + expect(resolveHeadless({ headed: true })).toBe(false); + }); + + it("--headless forces headless even in a headed-default context (interactive)", () => { + setPlatform("darwin"); + setTTY(true); + expect(resolveHeadless({ headless: true })).toBe(true); + }); + + it("rejects passing both --headed and --headless", () => { + expect(() => resolveHeadless({ headed: true, headless: true })).toThrow( + "Pass either --headed or --headless", + ); + }); +});