diff --git a/packages/server/browser.test.ts b/packages/server/browser.test.ts index be2a5ff65..739455674 100644 --- a/packages/server/browser.test.ts +++ b/packages/server/browser.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { shouldTryRemoteBrowserFallback } from "./browser"; +import { isNoOpBrowserSentinel, shouldTryRemoteBrowserFallback } from "./browser"; const savedEnv: Record = {}; const envKeys = ["PLANNOTATOR_BROWSER", "BROWSER"]; @@ -43,4 +43,35 @@ describe("shouldTryRemoteBrowserFallback", () => { process.env.PLANNOTATOR_BROWSER = "/usr/bin/browser"; expect(shouldTryRemoteBrowserFallback(true)).toBe(false); }); + + test("true for remote sessions when BROWSER is a no-op sentinel (e.g. agent view)", () => { + clearEnv(); + process.env.BROWSER = "true"; + expect(shouldTryRemoteBrowserFallback(true)).toBe(true); + }); + + test("true for remote sessions when PLANNOTATOR_BROWSER is a no-op sentinel", () => { + clearEnv(); + process.env.PLANNOTATOR_BROWSER = "none"; + expect(shouldTryRemoteBrowserFallback(true)).toBe(true); + }); +}); + +describe("isNoOpBrowserSentinel", () => { + test("returns false for undefined / empty", () => { + expect(isNoOpBrowserSentinel(undefined)).toBe(false); + expect(isNoOpBrowserSentinel("")).toBe(false); + }); + + test("recognises the documented no-op values, case- and whitespace-insensitive", () => { + for (const v of ["true", "false", "none", ":", "0", "1", "TRUE", " none "]) { + expect(isNoOpBrowserSentinel(v)).toBe(true); + } + }); + + test("does not flag real browser handlers", () => { + expect(isNoOpBrowserSentinel("/usr/bin/firefox")).toBe(false); + expect(isNoOpBrowserSentinel("Google Chrome")).toBe(false); + expect(isNoOpBrowserSentinel("open")).toBe(false); + }); }); diff --git a/packages/server/browser.ts b/packages/server/browser.ts index 68a85375c..913c5b846 100644 --- a/packages/server/browser.ts +++ b/packages/server/browser.ts @@ -9,6 +9,20 @@ import fs from "node:fs"; const IPC_REGISTRY = path.join(os.homedir(), ".plannotator", "vscode-ipc.json"); +/** + * Common "no-op" values for $BROWSER used by headless/background environments + * (e.g. Claude Code's agent view sets BROWSER=true) to signal "do not actually + * launch a browser". Treating these as if the variable were unset prevents + * silently shelling out to e.g. `true `, which exits 0 without opening + * anything and leaves the Plannotator server hanging on waitForDecision(). + */ +const NOOP_BROWSER_VALUES = new Set(["true", "false", "none", ":", "0", "1"]); + +export function isNoOpBrowserSentinel(value: string | undefined): boolean { + if (!value) return false; + return NOOP_BROWSER_VALUES.has(value.trim().toLowerCase()); +} + /** * Try opening URL via VS Code extension IPC registry. * Falls back when env vars (PLANNOTATOR_BROWSER) aren't available to the process. @@ -76,7 +90,15 @@ export async function isWSL(): Promise { * Fails silently if browser can't be opened */ export function shouldTryRemoteBrowserFallback(isRemote: boolean): boolean { - return isRemote && !process.env.PLANNOTATOR_BROWSER && !process.env.BROWSER; + if (!isRemote) return false; + const plannotatorBrowser = process.env.PLANNOTATOR_BROWSER; + const browser = process.env.BROWSER; + // Treat headless sentinels (e.g. BROWSER=true from Claude Code's agent view) + // as if no real browser handler were configured, so the IPC fallback still runs. + const hasRealHandler = + (plannotatorBrowser && !isNoOpBrowserSentinel(plannotatorBrowser)) || + (browser && !isNoOpBrowserSentinel(browser)); + return !hasRealHandler; } export async function openBrowser( @@ -84,7 +106,13 @@ export async function openBrowser( options?: { isRemote?: boolean } ): Promise { try { - const browser = process.env.PLANNOTATOR_BROWSER || process.env.BROWSER; + const rawPlannotatorBrowser = process.env.PLANNOTATOR_BROWSER; + const rawBrowser = process.env.BROWSER; + const plannotatorBrowser = isNoOpBrowserSentinel(rawPlannotatorBrowser) + ? undefined + : rawPlannotatorBrowser; + const envBrowser = isNoOpBrowserSentinel(rawBrowser) ? undefined : rawBrowser; + const browser = plannotatorBrowser || envBrowser; if (shouldTryRemoteBrowserFallback(options?.isRemote ?? false)) { const openedViaIpc = await tryVscodeIpc(url); if (openedViaIpc) { @@ -96,7 +124,6 @@ export async function openBrowser( const wsl = await isWSL(); if (browser) { - const plannotatorBrowser = process.env.PLANNOTATOR_BROWSER; if (plannotatorBrowser && platform === "darwin") { if (plannotatorBrowser.includes("/") && !plannotatorBrowser.endsWith(".app")) { await $`${plannotatorBrowser} ${url}`.quiet();