Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/cli-headed-default-when-interactive.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions packages/cli/src/lib/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,42 @@ export async function detectAgent(): Promise<string | null> {
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 {

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.

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,
);
}
14 changes: 14 additions & 0 deletions packages/cli/src/lib/driver/command-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/lib/driver/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
38 changes: 37 additions & 1 deletion packages/cli/src/lib/driver/mode.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -23,7 +24,7 @@ interface ResolvedChromeArgs {
ignoreDefaultArgs?: boolean | string[];
}

function resolveHeadless(
export function resolveHeadless(
flags: Pick<DriverModeFlags, "headed" | "headless">,
): boolean {
if (flags.headed && flags.headless) {
Expand All @@ -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")
Comment thread
shrey150 marked this conversation as resolved.
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;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/lib/run-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/lib/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,8 @@ function commandCompletedProperties(
runTelemetry.requestHadHttpResponse ??
null,
skill_id: runTelemetry.skillId ?? null,
session_mode: runTelemetry.sessionMode ?? null,
headless: runTelemetry.headless ?? null,
};
}

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/tests/cli-telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/tests/driver-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading