diff --git a/docs/src/content/docs/install.md b/docs/src/content/docs/install.md index 152989c..28365ac 100644 --- a/docs/src/content/docs/install.md +++ b/docs/src/content/docs/install.md @@ -95,6 +95,13 @@ set of reserved keys is also accepted for non-rule build-time toggles: target for browser-use agents. Enable for human-facing deployments where on-page access to options is useful. +- `runOnInactiveTabs` (boolean, default **off**) — keep the shared subtree + watcher observing while the tab is hidden. Off by default because a hidden tab + gets no observer callbacks, which avoids work the user can't see. Enable when + something else reads the page while it's in the background (chat copilots, + accessibility-tree agents, sidebar extensions) — without this, a page that + mutates while hidden could reach the consumer unredacted. + The file may be partial; rules not listed keep the committed default. Unknown keys (neither a registered rule id nor a reserved key) and non-boolean values fail the build with a message naming them. diff --git a/extension/build.ts b/extension/build.ts index a756c2c..b80358d 100644 --- a/extension/build.ts +++ b/extension/build.ts @@ -108,7 +108,8 @@ async function build(): Promise { if (defaultsPath) { const changed = Object.keys(overrides.rules).length + - (overrides.optionsButton === undefined ? 0 : 1); + (overrides.optionsButton === undefined ? 0 : 1) + + (overrides.runOnInactiveTabs === undefined ? 0 : 1); console.log( `Applying ${changed} build-time default override(s) from ${defaultsPath}.`, ); @@ -152,6 +153,11 @@ async function build(): Promise { ? "" : String(overrides.optionsButton), ), + "process.env.EXTENSION_RUN_ON_INACTIVE_TABS_DEFAULT": JSON.stringify( + overrides.runOnInactiveTabs === undefined + ? "" + : String(overrides.runOnInactiveTabs), + ), }, }); diff --git a/extension/scripts/__tests__/load-default-overrides.test.ts b/extension/scripts/__tests__/load-default-overrides.test.ts index 7193087..71ae920 100644 --- a/extension/scripts/__tests__/load-default-overrides.test.ts +++ b/extension/scripts/__tests__/load-default-overrides.test.ts @@ -80,6 +80,29 @@ describe("loadDefaultOverrides", () => { ).toThrow(/non-boolean values for: optionsButton/); }); + it("extracts the runOnInactiveTabs reserved key alongside rules", () => { + const file = writeFile( + "with-run-on-inactive.json", + JSON.stringify({ "pii-redact": true, runOnInactiveTabs: true }), + ); + expect( + loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), + ).toEqual({ + rules: { "pii-redact": true }, + runOnInactiveTabs: true, + }); + }); + + it("rejects a non-boolean runOnInactiveTabs value", () => { + const file = writeFile( + "bad-run-on-inactive.json", + JSON.stringify({ runOnInactiveTabs: "always" }), + ); + expect(() => + loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), + ).toThrow(/non-boolean values for: runOnInactiveTabs/); + }); + it("throws when the file does not exist", () => { expect(() => loadDefaultOverrides({ diff --git a/extension/scripts/load-default-overrides.ts b/extension/scripts/load-default-overrides.ts index 435166b..cbb5fa2 100644 --- a/extension/scripts/load-default-overrides.ts +++ b/extension/scripts/load-default-overrides.ts @@ -24,12 +24,13 @@ export interface LoadOverridesOptions { export interface DefaultOverrides { rules: Record; optionsButton?: boolean; + runOnInactiveTabs?: boolean; } // Reserved top-level keys are not rule ids; the loader maps each one to a // typed field on `DefaultOverrides`. Add new build-time toggles here as they // appear. -const RESERVED_KEYS = new Set(["optionsButton"]); +const RESERVED_KEYS = new Set(["optionsButton", "runOnInactiveTabs"]); export function loadDefaultOverrides( options: LoadOverridesOptions, @@ -78,6 +79,8 @@ export function loadDefaultOverrides( } if (key === "optionsButton") { result.optionsButton = value; + } else if (key === "runOnInactiveTabs") { + result.runOnInactiveTabs = value; } continue; } diff --git a/extension/src/lib/__tests__/subtree-watcher.test.ts b/extension/src/lib/__tests__/subtree-watcher.test.ts index 7cdc9b1..5e8f689 100644 --- a/extension/src/lib/__tests__/subtree-watcher.test.ts +++ b/extension/src/lib/__tests__/subtree-watcher.test.ts @@ -10,6 +10,7 @@ import { PLACEHOLDER_CLASS } from "../placeholder"; import { __resetRouteChangeForTesting } from "../route-change"; +import { runOnInactiveTabsStorage } from "../run-on-inactive-tabs"; import { __resetShadowRootsForTesting, installShadowRootHook, @@ -25,7 +26,15 @@ async function flushMutations(): Promise { await Promise.resolve(); } -beforeEach(() => { +// The watcher reads runOnInactiveTabsStorage asynchronously on start(). Tests +// that depend on the resolved value (or that flipped it) need to drain the +// chained microtasks before observing behavior. +async function flushStorageReads(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(async () => { document.body.innerHTML = ""; jest.useFakeTimers(); __resetRouteChangeForTesting(); @@ -36,6 +45,9 @@ beforeEach(() => { // behavior run regardless of whether content.ts has been imported. installShadowRootHook(); history.replaceState(null, "", "/initial"); + // The in-memory webext-storage mock persists across tests; reset to the + // committed default so each test starts from a known state. + await runOnInactiveTabsStorage.set(false); }); afterEach(() => { @@ -737,14 +749,6 @@ describe("createSubtreeWatcher", () => { // per-subscriber options and that router lifecycle tracks the last // subscriber. - function setHidden(hidden: boolean): void { - Object.defineProperty(document, "hidden", { - configurable: true, - value: hidden, - }); - document.dispatchEvent(new Event("visibilitychange")); - } - it("fans out a single mutation to every watcher on the same root", async () => { const a = jest.fn(); const b = jest.fn(); @@ -970,6 +974,14 @@ describe("createSubtreeWatcher", () => { }); it("flushes every subscriber's pending on visibilitychange to hidden", async () => { + function setHidden(hidden: boolean): void { + Object.defineProperty(document, "hidden", { + configurable: true, + value: hidden, + }); + document.dispatchEvent(new Event("visibilitychange")); + } + const a = jest.fn(); const b = jest.fn(); const watcherA = createSubtreeWatcher({ onSubtrees: a }); @@ -1578,4 +1590,164 @@ describe("createSubtreeWatcher", () => { watcher.stop(); }); }); + + describe("runOnInactiveTabs option", () => { + function setHidden(hidden: boolean): void { + Object.defineProperty(document, "hidden", { + configurable: true, + value: hidden, + }); + document.dispatchEvent(new Event("visibilitychange")); + } + + function fireRouteChange(toUrl: string): void { + history.replaceState(null, "", toUrl); + globalThis.dispatchEvent(new Event("popstate")); + } + + it("keeps delivering callbacks while hidden when enabled at start", async () => { + await runOnInactiveTabsStorage.set(true); + + const onSubtrees = jest.fn(); + const watcher = createSubtreeWatcher({ onSubtrees }); + watcher.start(document.body); + // Wait out the storage read so the watcher latches the stored value + // before we hide the tab. + await flushStorageReads(); + + setHidden(true); + + document.body.append(document.createElement("div")); + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(onSubtrees).toHaveBeenCalledTimes(1); + watcher.stop(); + }); + + it("reattaches when the option flips on while the tab is hidden", async () => { + const onSubtrees = jest.fn(); + const watcher = createSubtreeWatcher({ onSubtrees }); + watcher.start(document.body); + await flushStorageReads(); + + setHidden(true); + // Default-off: a mid-hidden mutation is ignored. + document.body.append(document.createElement("div")); + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + expect(onSubtrees).not.toHaveBeenCalled(); + + await runOnInactiveTabsStorage.set(true); + await flushMutations(); + + document.body.append(document.createElement("section")); + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(onSubtrees).toHaveBeenCalledTimes(1); + watcher.stop(); + }); + + it("detaches when the option flips off while the tab is hidden", async () => { + await runOnInactiveTabsStorage.set(true); + + const onSubtrees = jest.fn(); + const watcher = createSubtreeWatcher({ onSubtrees }); + watcher.start(document.body); + await flushStorageReads(); + + setHidden(true); + // Sanity: with the option on, a hidden mutation surfaces. + document.body.append(document.createElement("div")); + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + expect(onSubtrees).toHaveBeenCalledTimes(1); + + await runOnInactiveTabsStorage.set(false); + await flushMutations(); + + document.body.append(document.createElement("section")); + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + // No additional call: the watcher detached when the option flipped off. + expect(onSubtrees).toHaveBeenCalledTimes(1); + watcher.stop(); + }); + + it("does not change behavior while the tab is visible", async () => { + const onSubtrees = jest.fn(); + const watcher = createSubtreeWatcher({ onSubtrees }); + watcher.start(document.body); + await flushStorageReads(); + + // Visible the whole time; flipping the option must not double-attach + // or otherwise duplicate callbacks. + await runOnInactiveTabsStorage.set(true); + await flushMutations(); + await runOnInactiveTabsStorage.set(false); + await flushMutations(); + + document.body.append(document.createElement("div")); + await flushMutations(); + jest.advanceTimersByTime(THROTTLE_MS); + + expect(onSubtrees).toHaveBeenCalledTimes(1); + watcher.stop(); + }); + + it("sweeps document.body on route change while hidden when enabled", async () => { + await runOnInactiveTabsStorage.set(true); + + const onSubtrees = jest.fn(); + const watcher = createSubtreeWatcher({ onSubtrees }); + watcher.start(document.body); + await flushStorageReads(); + + setHidden(true); + fireRouteChange("/route-b"); + jest.advanceTimersToNextFrame(); + + expect(onSubtrees).toHaveBeenCalledTimes(1); + const [roots] = onSubtrees.mock.calls[0] as [Element[]]; + expect(roots).toEqual([document.body]); + watcher.stop(); + }); + + it("uses setTimeout (not rAF) for the route sweep while hidden", async () => { + // Chrome documents rAF as paused in background tabs (see + // developer.chrome.com/blog/background_tabs). Documented exemptions + // (audible audio, WebRTC) and devtools attachment can keep it firing, + // so the safety-net sweep can't rely on either outcome — fall back to + // setTimeout, which is clamped to ~1s in background tabs but does + // fire. Spy on both schedulers since Jest's fake timers don't + // simulate the visibility-based pause. + await runOnInactiveTabsStorage.set(true); + + const onSubtrees = jest.fn(); + const watcher = createSubtreeWatcher({ onSubtrees }); + watcher.start(document.body); + await flushStorageReads(); + + setHidden(true); + + const rafSpy = jest.spyOn(globalThis, "requestAnimationFrame"); + const setTimeoutSpy = jest.spyOn(globalThis, "setTimeout"); + + fireRouteChange("/route-b"); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 0); + + // Drain the setTimeout — advance by 0ms is sufficient since the + // handler was scheduled with delay=0. + jest.advanceTimersByTime(0); + expect(onSubtrees).toHaveBeenCalledTimes(1); + + rafSpy.mockRestore(); + setTimeoutSpy.mockRestore(); + watcher.stop(); + }); + }); }); diff --git a/extension/src/lib/run-on-inactive-tabs.ts b/extension/src/lib/run-on-inactive-tabs.ts new file mode 100644 index 0000000..d686a62 --- /dev/null +++ b/extension/src/lib/run-on-inactive-tabs.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Controls whether the shared subtree watcher keeps observing while the tab +// is hidden. Default off: a hidden tab gets no observer callbacks, which +// avoids work the user can't see. Operators flip it on when something else +// reads the page while it's in the background — a chat copilot, an +// accessibility-tree agent, or a sidebar extension can still consume the +// page's content after the user navigates away, and a page that mutates +// while hidden (lazy widgets finishing load, periodic refreshes, late +// prompt-injection payloads) would otherwise reach the consumer unredacted. + +import { createChromeStorageValue } from "./chrome-storage-value"; + +export const RUN_ON_INACTIVE_TABS_DEFAULT = false; + +// `process.env.EXTENSION_RUN_ON_INACTIVE_TABS_DEFAULT` is substituted by +// build.ts when the operator passes a defaults file with a +// `runOnInactiveTabs` field. Literal `"true"` / `"false"` forces a value; +// empty string falls back to the committed default above. +function resolveDefault(): boolean { + const raw = process.env.EXTENSION_RUN_ON_INACTIVE_TABS_DEFAULT; + if (raw === "true") { + return true; + } + if (raw === "false") { + return false; + } + return RUN_ON_INACTIVE_TABS_DEFAULT; +} + +export const runOnInactiveTabsStorage = createChromeStorageValue({ + key: "agent-browser-shield.run-on-inactive-tabs", + defaultValue: resolveDefault(), +}); diff --git a/extension/src/lib/subtree-watcher.ts b/extension/src/lib/subtree-watcher.ts index a14ab20..2717194 100644 --- a/extension/src/lib/subtree-watcher.ts +++ b/extension/src/lib/subtree-watcher.ts @@ -18,6 +18,10 @@ import throttle from "lodash/throttle"; import { PLACEHOLDER_CLASS } from "./placeholder"; import { subscribeRouteChange } from "./route-change"; +import { + RUN_ON_INACTIVE_TABS_DEFAULT, + runOnInactiveTabsStorage, +} from "./run-on-inactive-tabs"; import { discoverShadowRootsIn, subscribeShadowRootAttached, @@ -87,6 +91,11 @@ interface Router { visibilityListener: (() => void) | null; unsubscribeRouteChange: (() => void) | null; routeSweepHandle: number | null; + // Whether routeSweepHandle came from setTimeout vs requestAnimationFrame. + // Chromium pauses rAF entirely in hidden tabs, so handleRouteChange falls + // back to setTimeout when the route change arrives while hidden — and the + // cancellation path needs to call the matching teardown API. + routeSweepIsTimeout: boolean; // Per-router map of observed shadow roots. Each shadow root gets its // own MutationObserver because MO does not cross shadow boundaries — // an observer on document.body misses every mutation inside a shadow @@ -101,6 +110,17 @@ interface Router { // gets its own entry because meta-injection-strip observes it separately. const routersByTarget = new Map(); +// Cached value of the runOnInactiveTabs setting. Seeded with the build-time +// default so the sync startRouter() path has something to read before storage +// resolves. Updated by the async get() and by the subscribe() listener for +// cross-tab changes; reconciles every active router on change. +let runOnInactive = RUN_ON_INACTIVE_TABS_DEFAULT; +let unsubscribeRunOnInactive: (() => void) | null = null; + +function shouldPauseForHidden(): boolean { + return document.hidden && !runOnInactive; +} + function resolveTarget(root: ParentNode): Node | null { // rule-engine always passes document.body, but accept Document for // robustness and resolve to its body. `Document.body` is typed as @@ -233,7 +253,7 @@ function observerInit(router: Router): MutationObserverInit { } function refreshObservation(router: Router): void { - if (!router.observer || document.hidden) { + if (!router.observer || shouldPauseForHidden()) { return; } // observe() on an already-observed MO replaces the existing options @@ -285,7 +305,7 @@ function adoptShadowRoot(router: Router, shadowRoot: ShadowRoot): void { fanOut(router, mutations); }); router.shadowObservers.set(shadowRoot, observer); - if (!document.hidden) { + if (!shouldPauseForHidden()) { observer.observe(shadowRoot, observerInit(router)); } @@ -364,26 +384,43 @@ function isUnderRouterTarget(router: Router, node: Node): boolean { return router.target.contains(node); } +function detachRouterObserver(router: Router): void { + // Flush whatever's pending so we don't sit on a stale snapshot until + // observation resumes, then stop receiving mutations. Background tabs + // keep firing observer callbacks; disconnecting is the cheap way to + // opt out for the duration. Shadow observers need the same treatment — + // they have their own MO instances and would otherwise keep delivering + // mutations in the background. + for (const subscriber of router.subscribers) { + subscriber.throttledScan.flush(); + } + router.observer?.disconnect(); + for (const observer of router.shadowObservers.values()) { + observer.disconnect(); + } +} + function handleVisibilityChange(router: Router): void { - if (document.hidden) { - // Flush whatever's pending so we don't sit on a stale snapshot until - // the user returns, then stop receiving mutations. Background tabs - // keep firing observer callbacks; disconnecting is the cheap way to - // opt out for the duration. Shadow observers need the same - // treatment — they have their own MO instances and would otherwise - // keep delivering mutations in the background. - for (const subscriber of router.subscribers) { - subscriber.throttledScan.flush(); - } - router.observer?.disconnect(); - for (const observer of router.shadowObservers.values()) { - observer.disconnect(); - } + if (shouldPauseForHidden()) { + detachRouterObserver(router); } else if (router.observer) { refreshObservation(router); } } +function cancelRouteSweep(router: Router): void { + if (router.routeSweepHandle === null) { + return; + } + if (router.routeSweepIsTimeout) { + clearTimeout(router.routeSweepHandle); + } else { + cancelAnimationFrame(router.routeSweepHandle); + } + router.routeSweepHandle = null; + router.routeSweepIsTimeout = false; +} + function handleRouteChange(router: Router): void { if (!router.observer) { return; @@ -404,12 +441,11 @@ function handleRouteChange(router: Router): void { // makes rules that scan from their root argument do a full re-scan; // selector-hide-rule's onSubtrees already scans from document.body // regardless — both shapes end up doing the right thing. - if (router.routeSweepHandle !== null) { - cancelAnimationFrame(router.routeSweepHandle); - } - router.routeSweepHandle = requestAnimationFrame(() => { + cancelRouteSweep(router); + const sweep = (): void => { router.routeSweepHandle = null; - if (!router.observer || document.hidden) { + router.routeSweepIsTimeout = false; + if (!router.observer || shouldPauseForHidden()) { return; } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -419,18 +455,69 @@ function handleRouteChange(router: Router): void { for (const subscriber of router.subscribers) { subscriber.onSubtrees([document.body]); } - }); + }; + if (document.hidden) { + // Chrome documents rAF as paused for background tabs (developer.chrome + // .com/blog/background_tabs). Exemptions (audible audio, WebRTC, + // devtools attached) can keep it firing in practice, but the runOnInactive + // case is opt-in for hidden-tab coverage and should not depend on those + // exemptions being present. setTimeout is clamped to ~1s in background + // tabs but reliably fires, which is acceptable for the safety-net sweep. + // The visible-tab path keeps using rAF to land after the framework's + // commit. + router.routeSweepHandle = setTimeout(sweep, 0) as unknown as number; + router.routeSweepIsTimeout = true; + } else { + router.routeSweepHandle = requestAnimationFrame(sweep); + router.routeSweepIsTimeout = false; + } +} + +function handleRunOnInactiveChange(next: boolean): void { + if (next === runOnInactive) { + return; + } + runOnInactive = next; + // Only the hidden case can change behavior — visible tabs always observe. + // Reconcile every active router to whichever state the new setting implies. + if (!document.hidden) { + return; + } + for (const router of routersByTarget.values()) { + if (!router.observer) { + continue; + } + if (runOnInactive) { + refreshObservation(router); + } else { + detachRouterObserver(router); + } + } +} + +function ensureRunOnInactiveSubscription(): void { + if (unsubscribeRunOnInactive) { + return; + } + // Resolve the persisted setting and listen for cross-tab edits. The first + // sync window uses the build-time default, which matches what we'd observe + // in a typical fresh-load active tab anyway. + unsubscribeRunOnInactive = runOnInactiveTabsStorage.subscribe( + handleRunOnInactiveChange, + ); + void runOnInactiveTabsStorage.get().then(handleRunOnInactiveChange); } function startRouter(router: Router): void { if (router.observer) { return; } + ensureRunOnInactiveSubscription(); const observer = new MutationObserver((mutations) => { fanOut(router, mutations); }); router.observer = observer; - if (!document.hidden) { + if (!shouldPauseForHidden()) { observer.observe(router.target, observerInit(router)); } router.visibilityListener = () => { @@ -477,11 +564,12 @@ function stopRouter(router: Router): void { } router.unsubscribeRouteChange?.(); router.unsubscribeRouteChange = null; - if (router.routeSweepHandle !== null) { - cancelAnimationFrame(router.routeSweepHandle); - router.routeSweepHandle = null; - } + cancelRouteSweep(router); routersByTarget.delete(router.target); + if (routersByTarget.size === 0) { + unsubscribeRunOnInactive?.(); + unsubscribeRunOnInactive = null; + } } function getOrCreateRouter(target: Node): Router { @@ -495,6 +583,7 @@ function getOrCreateRouter(target: Node): Router { visibilityListener: null, unsubscribeRouteChange: null, routeSweepHandle: null, + routeSweepIsTimeout: false, shadowObservers: new Map(), unsubscribeShadowAttach: null, }; @@ -616,12 +705,13 @@ export function __resetSubtreeWatcherForTesting(): void { ); } router.unsubscribeRouteChange?.(); - if (router.routeSweepHandle !== null) { - cancelAnimationFrame(router.routeSweepHandle); - } + cancelRouteSweep(router); for (const subscriber of router.subscribers) { subscriber.throttledScan.cancel(); } } routersByTarget.clear(); + unsubscribeRunOnInactive?.(); + unsubscribeRunOnInactive = null; + runOnInactive = RUN_ON_INACTIVE_TABS_DEFAULT; } diff --git a/extension/src/options/Options.tsx b/extension/src/options/Options.tsx index bf80f23..3871a4a 100644 --- a/extension/src/options/Options.tsx +++ b/extension/src/options/Options.tsx @@ -9,6 +9,7 @@ import { optionsButtonStorage } from "../lib/options-button-toggle"; import type { PlaceholderDisplayMode } from "../lib/placeholder-display"; import { placeholderDisplayStorage } from "../lib/placeholder-display"; import { RuleList } from "../lib/RuleList"; +import { runOnInactiveTabsStorage } from "../lib/run-on-inactive-tabs"; import { ruleStatesStorage, setAllRuleStates } from "../lib/storage"; import { useChromeStorageValue } from "../lib/use-chrome-storage-value"; import { useTransientStatus } from "../lib/use-transient-status"; @@ -21,6 +22,7 @@ export function Options() { const displayMode = useChromeStorageValue(placeholderDisplayStorage); const storedApiKey = useChromeStorageValue(apiKeyStorage); const optionsButtonEnabled = useChromeStorageValue(optionsButtonStorage); + const runOnInactiveTabs = useChromeStorageValue(runOnInactiveTabsStorage); const [draft, setDraft] = useState(""); const [error, setError] = useState(null); @@ -42,7 +44,8 @@ export function Options() { !availability || displayMode === null || apiKeyDraft === null || - optionsButtonEnabled === null + optionsButtonEnabled === null || + runOnInactiveTabs === null ) { return
Loading…
; } @@ -86,6 +89,7 @@ export function Options() { Export configuration Placeholder display On-page options button + Inactive tabs OpenAI API key Rules Disclaimer @@ -189,6 +193,38 @@ export function Options() { +
+

+ When a tab isn't visible, the extension stops watching it for new + content — background tabs still fire DOM mutations, and ignoring them + saves work the user can't see. Turn this on if something else reads + the page while you're not looking at it: a chat copilot, an + accessibility-tree agent, or a sidebar extension can keep consuming a + page's content after you switch tabs, and a page that loads or + rewrites content while hidden would otherwise reach those consumers + unredacted. +

+ +
+

Used by the irrelevant-sections-redact rule.{" "} diff --git a/skills/agent-browser-shield-install/SKILL.md b/skills/agent-browser-shield-install/SKILL.md index 3594408..59ca6c3 100644 --- a/skills/agent-browser-shield-install/SKILL.md +++ b/skills/agent-browser-shield-install/SKILL.md @@ -199,6 +199,15 @@ JSON override file instead of using the hosted ZIP. and become a misleading "click me to make progress" target. Enable for human-facing deployments where on-page access to options is useful. + - `runOnInactiveTabs` (boolean, default **off**) — keep the shared subtree + watcher observing while the tab is hidden. Off by default because a hidden + tab gets no observer callbacks, which avoids work the user can't see. + Enable when something else reads the page while it's in the background — a + chat copilot, an accessibility-tree agent, or a sidebar extension that can + still consume the page's content after the user navigates away, and a page + that mutates while hidden (lazy widgets, periodic refreshes, late + prompt-injection payloads) would otherwise reach the consumer unredacted. + 2. Pass the path via CLI flag or env var to `bun run build`: ```sh