browser-tools / bt -- the agent manual

This is the whole of `bt`. Reading it is enough to drive a browser with this
tool; nothing else needs to be read first. `bt guide` prints it.

The install also puts down `browser-tools-profiler`, a separate command for
CPU profiling. It is the last section here.

`bt --version` prints the installed version. Check it first when behaviour
does not match this manual: an older copy earlier on PATH answers the same
verbs and answers them differently.

The tool tracks named browser instances in a registry. Each instance is one
running browser process, named after the directory it was launched from.
Liveness is engine-aware: Chrome is process identity plus CDP port
attribution, Camoufox is process identity plus a user-data-dir hold. Never PID
existence alone.


WHAT THIS IS FOR

  Driving a real browser: one with logins, extensions, and whatever state a
  site keeps. Reach for it when the browser is the point.

  It fits:

  - Work behind a login. `launch --profile NAME` keeps the session, so you
    log in by hand once and every later run is already signed in.
  - Pages that resist automation. Camoufox is an anti-detect engine, and
    `detect` says whether a challenge page is what you are looking at.
  - Reading what a page renders rather than what its HTML says. `snapshot`
    is the accessibility tree, so it sees what a screen reader sees, after
    the JavaScript has run.
  - Watching a page work: console messages, network traffic, CDP events,
    screenshots, screencasts.
  - Measuring one: `trace` and `insights` for load and Web Vitals, `heap`
    for what the page retains, `browser-tools-profiler` for JavaScript CPU.
  - A Chrome the person started for debugging on a profile of its own,
    through --endpoint or --chrome-profile. Not the browser they already
    have open: Chrome refuses remote debugging on its default data
    directory, so the everyday browser cannot be reached. See EXTERNAL
    BROWSERS.

  It is not an HTTP client. Fetching a URL and parsing the body is faster
  with curl. Use this when the page has to actually run.

  Two shapes of work, and they cost very differently. Each invocation opens
  its own connection and session, which costs about 70 to 85 ms before any
  browser work. One verb at a time is fine. A long chain of them pays that
  every step, and some things cannot be split across two invocations at all:
  see WHAT DOES NOT CARRY BETWEEN INVOCATIONS.


NAMING THE INSTANCE

  Every verb that drives a browser takes the instance ahead of the verb:

    bt web-01 snapshot
    bt web-01 frames select checkout
    bt web-01 Page.navigate '{"url": "https://..."}'

  A bare leading token is an instance name when the registry knows it, and the
  verb otherwise, so `bt frames select checkout` needs no escaping. INSTANCE
  may be omitted when exactly one instance is registered; with several, every
  verb names the candidates rather than guessing (exit 1). Naming the instance
  twice is a usage error (exit 2).

  `launch` derives the name from the current directory and always adds a
  two-digit suffix. The first available name is <directory>-01. A second
  unnamed launch from the same directory succeeds as <directory>-02 while the
  first remains registered, then -03, and so on, using the first suffix free
  in the registry. `launch` prints the name it chose in its JSON result. There
  is no option to choose a name, so read that result before sending later
  commands to the instance.


LIFECYCLE VERBS

  launch [--engine chrome|camoufox] [--profile NAME] [--channel NAME]
         [--headless] [--port PORT] [--fingerprint FILE] [--no-window-border]
         [-- BROWSER_ARGS]
      Launch a browser and register it. Prints the new instance as JSON.
      Chrome's debugging port is allocated from 9422 upward, never 9222:
      that is the DevTools default port, so whatever answers there on a
      working machine is most likely someone's own browser. --port pins an
      exact port instead. A Camoufox instance records a registry port it
      does not use.
      --engine camoufox starts an anti-detect Camoufox instance. launch takes
      no positional argument: the name is assigned by the registry, and a bare
      token before -- is a usage error (exit 2), because it would otherwise be
      handed to the browser along with every flag after it. See NAMING THE
      INSTANCE for suffixing and how to capture the assigned name.

  status [INSTANCE]
      Every registered instance with liveness, engine, profile and page
      targets, as JSON. With INSTANCE, only that one. It reports the registry,
      so a browser driven with --endpoint does not appear. If status returns
      [] while the browser you were using is still running, treat this as a
      registry/process mismatch. Before another launch, verify that browser's
      process identity, user-data-dir, and debugging port. To drive its
      verified loopback endpoint, use --endpoint URL; see EXTERNAL BROWSERS.
      This does not restore registry management. If identity cannot be
      established, stop and report the mismatch.

  stop [INSTANCE] [--target SPEC]
      Stop a browser and retire its registry entry, or close one tab with
      --target. A profile-bound instance keeps its user-data-dir; an unbound
      one has its session dir reaped. Closing a tab is Chrome only (exit 1 on
      Camoufox), and needs a live instance (exit 1).

  cleanup
      Remove stale registry entries and orphaned session directories. Live
      instances are never touched, and no path under a profile root is ever
      deleted -- profiles go away through `profile delete`, never on age. An
      unparseable registry deletes nothing and is quarantined instead.

  profile list
      Every named profile with its name, its path, and the live instance
      holding it (null when free). Needs no running instance; an empty root
      lists nothing. A profile still in the old /tmp root is marked
      "legacy": true.

  profile delete NAME
      Remove one profile directory, and its login with it. A name outside the
      permitted character set, or one whose resolved path leaves the profile
      root, is a usage error (exit 2) and deletes nothing. A profile a live
      instance holds is refused (exit 1) naming the holder.

  profile migrate [--dry-run] [--back]
      Move profiles still in the old /tmp root into the durable one.
      --dry-run reports what would move and moves nothing. --back reverses it,
      and is the rollback for the root move: once the profiles have moved on
      disk, reverting the code does not move them back. A live holder is
      refused, and a name present on both sides is refused rather than merged.

  guide
      Print this manual. Plain text, not JSON.

  window-border [on|off]
      Show, or persistently set, whether marked windows draw the colored
      border and corner badge over the page (default on). They cover the
      page's outer edge and top-left corner; 'off' removes them from every
      running browser within a second and keeps them off for later launches.
      The tab-title prefix stays either way. --no-window-border on launch
      turns off all marking for that one launch.


CURATED VERBS

  Each takes [INSTANCE] as above, and --endpoint URL to drive an external
  browser. Each prints one JSON document on stdout.

  When no page selector is given, the handler-routed verbs in this section
  pick the first page target in the normative target-ID sort. A run uses the
  same default for its shared page. This differs from raw protocol passthrough,
  which refuses an omitted selector when several pages exist and lists them.
  Standalone screenshot and the event/list verbs also use the strict selector.

  DIALOG POLICY
      click, fill, eval, press, type, hover, wait-text, navigate, network-get
      and run answer every JavaScript dialog while they run. network-get only
      drives the page with --reload, and carries the policy for that.
      --dialog defaults to dismiss even when omitted: the default actively
      declines dialogs. --dialog accept confirms them; --dialog accept:TEXT
      also supplies prompt text. Bare accept keeps a prompt's default text;
      accept: supplies an empty string.
      Each answered dialog appears in the invocation's document under dialogs,
      in order, with type, message, answer (accept or dismiss), and result.
      result is a boolean for confirm/beforeunload, text or null for prompt,
      and null for alert. There is no count limit or escalation. If no dialog
      fires, the dialogs key is absent. Other standalone verbs do not subscribe.

      Worked both ways, against a page whose button calls confirm():

        bt eval 'confirm("Delete this?")' --dialog accept
          {"value": true, "type": "boolean", ..., "dialogs": [{"type":
           "confirm", "message": "Delete this?", "answer": "accept",
           "result": true}]}

        bt eval 'confirm("Delete this?")'
          {"value": false, "type": "boolean", ..., "dialogs": [{"type":
           "confirm", "message": "Delete this?", "answer": "dismiss",
           "result": false}]}

      The second one passed no --dialog. The dialogs entry is how you see
      that the default answered rather than that no dialog fired.

  navigate <URL> [--target SPEC | --url SUB] [--dialog dismiss|accept[:TEXT]]
      Navigate the selected page with Page.navigate. The active default dismiss
      declines beforeunload prompts; --dialog accept permits leaving the page.
      Returns the Page.navigate result, plus dialogs when any were answered.
      A browser navigation error is exit 1. This command does not wait for load.
      Inside a Step List use Page.navigate; the run supplies its dialog policy.

  snapshot [--target SPEC]
      The accessibility tree, one UID per node. This is how you find something
      to click or fill.

  click --uid UID [--target SPEC] [--dialog dismiss|accept[:TEXT]]
  fill --uid UID --text T [--target SPEC] [--dialog dismiss|accept[:TEXT]]
      Act on the node a snapshot named. Missing --uid or --text is a usage
      error (exit 2). A UID that names a node with no DOM node behind it, or
      a node that is not an element, is exit 1.
      Both actively dismiss dialogs by default, even without --dialog. fill
      carries the policy because setting a value fires the field's own input
      handler, and that handler can raise a dialog as directly as a click
      handler does.

  wait-idle [--timeout-ms MS] [--idle-ms MS]
  wait-stable [--timeout-ms MS] [--stable-ms MS]
      Wait for network idle (default 5000ms deadline, 500ms quiet window), or
      for the DOM to stop changing (5000ms, 300ms).

  eval '<js>' [--await] [--target SPEC | --url SUB] [--dialog dismiss|accept[:TEXT]]
      Evaluate JavaScript by value. Statement bodies run inside an IIFE and
      return their last expression's value. --await waits for a Promise;
      it also accepts a non-Promise. The result has value, type, url and
      targetId. A JavaScript exception is exit 1, with its description on
      stderr and nothing on stdout. Raw Runtime.evaluate instead exits 0 and
      returns Chrome's exceptionDetails; it is not equivalent to eval.
      --dialog defaults to dismiss and actively answers dialogs during eval.

      --await awaits the value the expression returns. It does not make the
      body an async function, so a top-level await is a SyntaxError even with
      --await. Wrap it in an async arrow and await the call instead.
      Examples:
        bt eval 'document.title'
        bt eval 'const n = 20; n * 2'
        bt eval 'Promise.resolve({ready: true})' --await
        bt eval '(async () => (await fetch("/api")).status)()' --await

  press <key> [--modifiers NAME[,NAME...]] [--target SPEC | --url SUB] [--dialog dismiss|accept[:TEXT]]
      Dispatch keyDown then keyUp, including native default actions. <key>
      is one printable character, Enter, Tab, Escape, Backspace, Delete,
      ArrowLeft, ArrowRight, ArrowUp, ArrowDown, Home, End, PageUp, PageDown,
      or F1 through F12. Modifiers are Alt, Control, Meta, Shift. Unknown
      names are exit 2 with the valid list. Example: bt press Enter
      --dialog defaults to dismiss and actively answers dialogs from key handlers.
      Result: {"key":"Enter","modifiers":[],"dispatched":["keyDown","keyUp"]}

  hover --uid UID [--target SPEC | --url SUB] [--dialog dismiss|accept[:TEXT]]
      Move the native pointer to the UID's box centre, setting CSS :hover.
      Synthetic mouseover from JavaScript does not set that CSS state.
      --dialog defaults to dismiss and actively answers dialogs from hover handlers.
      Example: bt hover --uid 2F0B815AF686-32
      Result: {"uid":"2F0B815AF686-32","x":412.5,"y":208.0}

  type (<text> | --file FILE) [--target SPEC | --url SUB] [--dialog dismiss|accept[:TEXT]]
      Insert text at the caret with one Input.insertText; keep existing text.
      Exactly one source is required. --file reads UTF-8, and --file - reads
      stdin. It fires beforeinput and input, but no key events. Use press
      when a caller needs key events. Examples:
        bt type 'hello'
        bt type --file ./message.txt
        cat ./message.txt | bt type --file -
      Result: {"chars":123,"source":"file","path":"/absolute/message.txt"}
      Positional text uses source "text" and has no path. Stdin's path is "-".
      --dialog defaults to dismiss and actively answers dialogs from input handlers.

  wait-text <substring> [--timeout-ms MS] [--target SPEC | --url SUB] [--dialog dismiss|accept[:TEXT]]
      Wait for rendered body text to contain the substring. Default 5000ms.
      Checks present text and installs a MutationObserver in one JS task,
      so text inserted by script during setup is not lost. Timeout is exit
      1 with stderr only. Example: bt wait-text 'Order placed' --timeout-ms 8000
      Result: {"found":true,"substring":"Order placed","waitedMs":812}
      Choose wait-idle for "has network activity stopped?", wait-stable for
      "has the DOM stopped changing?", and wait-text for "is this text here?".
      --dialog defaults to dismiss and actively answers dialogs while waiting.

  network-get (--url SUB | --request-id ID) [--response-file PATH] [--target SPEC]
              [--duration SECONDS] [--reload]
      --url selects a response URL, not the page. Exactly one selector is
      required. A run enables Network before step 1 and retains responses
      until the run ends. Use network-get --url after the step that triggers
      a request; it reads that traffic without reloading or losing page state.
      A Step List cannot substitute an earlier result, so URL matching is the
      usual step form. --request-id works only for an id in this session.

      Standalone, there is no run history. Subscribe and wait for new traffic
      for at most --duration seconds (default 2, matching network-list).
      Use --reload to trigger page-load requests after subscribing. A selected
      frame is navigated to its current URL instead. Reload is standalone-only.
      Without --reload, the caller or page must trigger traffic during the window.
      In a run, --duration bounds waiting for a missing or unfinished response;
      the whole-run deadline can end that wait sooner. Zero checks only what
      is already buffered. Duration must be finite and non-negative.

      Return as soon as the last matching response observed so far has finished.
      Do not wait out the window to gather more matches. matched counts the
      matching responses seen at lookup, including earlier run traffic.
      An absent, failed or incomplete response is exit 1. Chrome can evict a
      body even while metadata remains; fetching it then reports the CDP error.
      IDs from another invocation cannot retrieve bodies.
      Example: bt network-get --url /api/orders --reload --response-file ./orders.json
      Result fields: requestId, url, status, mimeType, bytes, matched,
      base64Encoded, and either body, responseFile or bodyOmitted.
      --response-file writes decoded bytes and reports an absolute responseFile.
      Without it, UTF-8 text of at most 1 MiB is inline as body. Larger or
      base64-encoded bodies have bodyOmitted naming --response-file as the
      remedy; the body is never truncated. network-list still reports metadata
      from its own window. In a run, that traffic also enters the run buffer.

      All six verbs are Step List steps and share the run's deadline and
      frame selection. A step cannot name an instance, --endpoint, --target
      or a page-selecting --url. network-get's response-selecting --url is allowed.

  detect [--wait SECONDS | --no-wait]
      Run interstitial detection against the current page. --wait bounds the
      retry for a self-clearing challenge; --no-wait reports the current state
      at once. Only JS-solvable challenges auto-retry. Vendor presence is
      reported alongside the blocked answer, never in place of it: a cookie
      proving a site uses a bot-protection vendor is on every page of that
      site and does not by itself mean the page is blocking you.

  console-list [--target SPEC | --url SUBSTRING] [--duration SECONDS]
  network-list [--target SPEC | --url SUBSTRING] [--duration SECONDS]
      Collect console messages, or network requests and responses, over a
      short attach window (default 2 seconds). A standalone console-list also
      returns console messages from before it attached: Chrome replays its
      buffered messages when Runtime is enabled. --duration is how long it
      keeps listening after that replay; it does not limit the result to new
      messages. Inside a run, Runtime is already enabled, so console-list gets
      no replay there. network-list correlates requests and responses by
      requestId, keeps rows in first-observation order, and keeps request-only
      and response-only rows with the missing fields null. Network does not
      replay earlier traffic. Both verbs subscribe before enabling their
      domain, so an event emitted during enable is in the result.

  frames list | frames select PATTERN | frames reset
      Inspect and select page frames. PATTERN is a frame URL substring. Any
      other sub-action is a usage error (exit 2).

      SAME-PROCESS FRAMES ONLY, unless you pass --frames all. A cross-origin
      iframe runs in its own renderer process under Chrome's site isolation,
      with its own CDP target. By default it is absent from `frames list`,
      `frames select` cannot select it, and `snapshot` shows the Iframe node
      with nothing under it. `--target` does not reach it either: only page
      targets are attachable. Logins, payment forms and consent banners are
      usually cross-origin iframes, so this is the common case rather than
      the exotic one. `frames select` says so when it can tell that is what
      happened.

  --frames page | --frames all
      Which frames `frames list` and `frames select` can see. Default `page`,
      which is the behaviour above. `all` attaches to each cross-origin
      iframe as well and splices its frames into the one tree.

      What it gives you:

          frames list      lists cross-origin iframes in tree position,
                           marked [out-of-process]
          frames select    selects one, at any nesting depth
          storage get      reads the selected frame's own localStorage and
                           sessionStorage, from its own renderer
          snapshot         reads each frame's tree on the session that owns
                           it and prints one tree, so nodes inside a
                           cross-origin iframe get uids
          click, fill      reach those nodes, dispatching into the frame's
                           own renderer

      A uid names the document it was minted in, so nodes in different
      frames of one snapshot have different uid prefixes. That is how a
      later `click` knows which process to send to. Pass --frames all to
      the click too, or it will not know.

      `frames list --frames all` marks a frame in another process
      [out-of-process]. A frame past a bound is listed [unreachable] with its
      URL rather than dropped, so a frame you cannot reach is still a row you
      can see. The bounds are 32 frame sessions and 10 levels of nesting.

      [unreachable] rows come after the tree, at a fixed indent. They are not
      in tree position, because the tool never attached to the frame and so
      does not know where it sits. Read them as a list of what was left out,
      not as children of the frame above them.

      Accepted by `frames list`, `frames select`, `frames reset`, `storage
      get`, `snapshot`, `click`, `fill`, and `run`, where it covers every
      step of the run. The last four accept it so that a run can carry one
      flag throughout; it does not make them see into the frame.

      Off by default because it is opt-in for this release, not because it
      costs much: measured over 9 runs each on a page with twenty sibling
      cross-origin iframes, `frames list` took 109.2 ms by default and
      119.7 ms with --frames all. On a page with no cross-origin iframe the
      flag costs one CDP round trip, about 8 ms.

      `screenshot`, `screencast`, `wait-idle`, `detect`, `console-list` and
      `network-list` do not take --frames at all; passing it is a usage error
      (exit 2). None of them is frame-scoped, so there is nothing for the
      flag to mean there.

  storage get [--key K]
      The selected frame's storage. --key is a frame URL pattern to select
      before reading. It is NOT a cookie name or a local-storage key. Without
      a selected frame and without --key, exit 1.

  screenshot [--path FILE] [--target SPEC | --url SUBSTRING]
      A full-page PNG. Without --path, the base64 data URI. A near-uniform
      capture is retried once before it is returned.

  screencast --dir DIR [--duration SECONDS] [--format FMT] [--max-frames N]
      Capture a screencast and write its frames plus a frames.json manifest to
      DIR, in one invocation. Capture ends at whichever comes first: the
      duration (default 5 seconds) or the frame cap (default 600). --dir is
      required (exit 2). There is no separate start and stop, and naming one
      is a usage error: the frame buffer belongs to the process that captured
      it, so a stop in a second process could never reach the first one's
      frames.


RUNNING MANY STEPS IN ONE INVOCATION

  run FILE [--timeout SECONDS] [--dialog dismiss|accept[:TEXT]]
  run -    [--timeout SECONDS] [--dialog dismiss|accept[:TEXT]]
      Run an ordered list of steps against one browser, in one invocation, over
      one CDP connection. FILE is the list; `-` reads it from stdin. Takes
      [INSTANCE] and --endpoint URL as above, and --target SPEC or --url
      SUBSTRING to pick the page the whole run drives.
      --dialog defaults to dismiss and actively answers dialogs in every step,
      including raw CDP steps. The run owns the policy; a step cannot override
      it. Answered dialogs are recorded once, at the top of the Run Document.

      One step per line, each line a verb phrase exactly as you would type it
      after the instance name. Blank lines and lines starting with # are
      ignored; a # anywhere else is an ordinary character, so a URL fragment
      or a --text value carrying one needs no quoting for that reason. Lines
      are split the way a shell splits them, so quoting works as it does at
      the prompt.

          # select the checkout frame once, then read it twice
          frames select checkout
          storage get
          Page.getNavigationHistory '{}'
          snapshot

      This is not a scripting language. There are no variables, no conditions,
      no loops, and no way for one step to use another step's output. A run does
      what the same commands would do one after another; it cannot decide
      anything. Where you need a decision, read the output and run again.

      A STEP IS A VERB, NOT AN INVOCATION. Steps may be: snapshot, click, fill,
      eval, press, hover, type, wait-text, network-get,
      wait-idle, wait-stable, wait, detect, console-list, network-list, frames,
      storage get, screenshot, screencast, heap, and any raw Domain.method.
      A step may NOT name an instance, --endpoint, --target, --dialog or a
      page-selecting --url: those belong to the run, which resolves them once.
      attach is not a step (it runs until stdin ends, so nothing could follow
      it), and neither are launch, status, stop, cleanup, profile,
      window-border, guide, help, navigate, trace, insights or run itself. Any of these
      in a step list is a usage error (exit 2).

      FRAME SELECTION LASTS. `frames select` in one step governs the steps after
      it, so `storage get` with no --key works inside a run. A selection
      remembers the pattern you gave it, so after a navigation it re-points at
      whatever frame now matches. If nothing matches any more, the selection is
      cleared and the next frame-scoped step fails rather than reading the wrong
      frame. The selected frame going away clears it too, but the pattern is
      kept either way, so a later navigation that brings a matching frame back
      selects it again. Only `frames reset` and a new `frames select` change the
      pattern. `storage get --key` does not: the key names the frame for that
      one read, and the selection is put back afterwards.

      AFTER A NAVIGATION, WAIT BEFORE YOU READ. Page.navigate returns when the
      navigation commits, and the frame tree updates from the event that
      follows it. A step placed straight after a navigate can run before that
      event arrives and see the frames of the page you just left. Put a
      `wait-idle` step in between.

      Use `wait-idle` and not `wait --event Page.loadEventFired` here. `wait`
      reports events that arrive after it subscribes, and it subscribes when
      its own step starts. A load that fired in the gap after the navigate step
      is already gone, and the wait then times out on a page that has finished
      loading. `wait-idle` reads the page's current state instead of waiting
      for an edge, so it cannot miss one. It answers a narrower question than
      `load` - the new document has stopped fetching - which is the question
      this paragraph is about.

      DOMAIN OWNERSHIP. Domains a step turns on are turned off when that
      step ends, except Page, Runtime and Network. The run needs Page and
      Runtime to track frames, and Network to retain responses across steps.
      A step does not get a first enable on these domains.
      `wait --event Runtime.executionContextCreated` after any step that
      touched Runtime reports nothing, where the same command on its own
      reports existing contexts; console-list has the same limitation.
      Network does not replay prior responses. wait and network-list see only
      events in their own windows; network-get also sees the run's buffer.
      Every run pays for network events, retained metadata and Chrome's body
      storage, even when no step asks for a response body. Chrome's retention
      limits still apply; large traffic volumes can evict bodies.

      A raw enable you write yourself is yours to undo. `Log.enable {}` stays
      on until a later step turns it off. A curated step leaves a domain you
      enabled on, even when the normal cleanup rule would disable it.
      The run-owned domains cannot be disabled by a step: Page.disable,
      Runtime.disable and Network.disable are usage errors (exit 2).
      Run those commands on their own, outside a run.

      UIDS ARE UNCHANGED. A UID is still valid until the page navigates, no
      longer and no shorter. One process does not extend it. Because no step can
      read another step's output, every UID in a step list is one you put there
      from a snapshot you already read.

      IT STOPS AT THE FIRST FAILURE, and nothing rolls back. A step that has run
      has already reached the browser. Steps after the failure do not run.

      --timeout SECONDS bounds the whole run; without it there is no whole-run
      deadline, because every step already bounds itself. Use it when a step
      opts out of its own bound, as `wait --timeout 0` does.

      OUTPUT. One JSON document, on success and on failure alike: a `run` object
      with the step count, how many completed, and the status; and a `steps`
      array with one entry per step attempted, each carrying the JSON that step
      would have printed alone. Steps never reached are absent.

      READ IT IN THIS ORDER: the exit code, then `run.status`, then the steps.
      A failed run still contains successful step entries, and each of those
      carries exactly what that step prints on its own. If you parse the steps
      without checking the exit code first, a run that died at step 7 reads
      like a run that finished.

      Exit 0   every step succeeded.
      Exit 1   a step failed, or the run timed out. The document is still
               printed, so you can see what already ran. This is the one verb
               that prints on stdout when it exits 1.
      Exit 2   the step list was malformed, or a step named an instance,
               --endpoint, --target or --url. The whole list is checked before
               the first step runs, so nothing ran.


EVENT VERBS

  attach [INSTANCE] +Domain.event [+Domain.event ...]
         [--target SPEC | --url SUBSTRING] [--endpoint URL]
      Stream subscribed CDP events as JSON Lines, one event per line, until
      stdin reaches EOF or the process is signalled. It prints a
      {"status": "ready"} line first. While it runs, a line of `+Domain.event`
      on stdin adds a subscription and `-Domain.event` removes one. Two
      attached observers never see each other's subscriptions. At least one
      +Domain.event is required (exit 2), and a token that is not
      Domain.event-shaped is a usage error (exit 2).

  wait [INSTANCE] --event Domain.event [--match SUBSTRING]
       [--timeout SECONDS] [--target SPEC | --url SUBSTRING] [--endpoint URL]
      Block until one matching event fires, then print it as JSON. It
      subscribes before it begins examining events, so an event that fires in
      between is buffered, not lost. --match is a substring test against the
      event's whole JSON serialization, not against its parameters alone.
      --timeout defaults to 30 seconds; --timeout 0 means no deadline. On the
      deadline: a diagnostic on stderr, exit 1, and nothing on stdout.


RAW PROTOCOL

  [INSTANCE] Domain.method '{...json params...}'
             [--target SPEC | --url SUBSTRING] [--endpoint URL]
      Send any CDP method the installed browser supports straight to it and
      print the JSON result. No curated verb needs to exist for the method.
      Parameters must parse to a JSON object; anything else is a usage error
      (exit 2). Methods that raise the window are refused; see NEVER TAKE THE
      SCREEN. With several pages, omitting --target or --url is exit 1 and the
      diagnostic lists every page. Raw passthrough does not pick the first
      page; handler-routed curated verbs and run do.

  help [INSTANCE] [Domain.method] [--endpoint URL]
      With a running instance, print the live CDP protocol schema read from
      that browser: every domain, one domain's commands and events, or one
      method's full signature. With zero or several live instances, or an
      unreachable one, it prints static usage instead. Plain text, not JSON.


NO CURATED VERB? SEND THE PROTOCOL

  The curated verbs cover the common path. Everything else the browser can
  do is one CDP call away and needs no new verb. The passthrough prints the
  method's result exactly as the browser returned it, with nothing wrapped
  around it.

  Each recipe here was run against Chrome 153 before it was written down.

  UPLOAD A FILE. Snapshot the page, find the file input, and pass the number
  after the dash in its UID as backendNodeId:

    bt snapshot
      [uid=2F0B815AF686-32] button "Choose File" = 'No file chosen'
    bt DOM.setFileInputFiles '{"files": ["/abs/path"], "backendNodeId": 32}'

  A DOM nodeId from DOM.querySelector will NOT work across invocations; see
  the next section. The backendNodeId inside a UID will.

  TYPE KEYSTROKES. Use type for prose at the caret and press for native keys
  and their default actions, such as submitting a form:

    bt type 'hello'
    bt press Enter

  SET THE VIEWPORT, or emulate a phone:

    bt Emulation.setDeviceMetricsOverride '{"width": 390, "height": 844,
       "deviceScaleFactor": 3, "mobile": true}'

  The override outlives the invocation that set it. A later
  Emulation.clearDeviceMetricsOverride does NOT undo it, because only the
  session that set an override can clear it. Set the size you want back.

  READ AND SET COOKIES:

    bt Network.getCookies '{"urls": ["https://example.com/"]}'
    bt Network.setCookie '{"name": "k", "value": "v",
       "domain": "example.com", "path": "/"}'

  SAVE THE PAGE AS PDF. The result carries base64 in `data`:

    bt Page.printToPDF '{}'

  WORK WITH SEVERAL PAGES:

    bt Target.getTargets '{}' --target 1
    bt Target.createTarget '{"url": "https://...", "newWindow": true}'
    bt Target.closeTarget '{"targetId": "<id>"}' --target 1

  Target.* is browser-level, but it is sent over a page session, so once a
  second page exists these calls need --target to say which page to send
  over. `status` lists the pages and their ids.

  DRAG AND DROP is Input.dispatchDragEvent, one call per dragEnter, dragOver
  and drop. `bt help Input.dispatchDragEvent` prints its signature.

  For anything not listed, `bt help Domain.method` reads the signature live
  from the browser you are driving, so the parameters are the ones that
  browser accepts rather than the ones some other version documented.


WHAT DOES NOT CARRY BETWEEN INVOCATIONS

  Each invocation opens its own session, works, and detaches. Page state
  stays behind: the page you navigated is still loaded, cookies are still
  set, and a device metrics override is still in force. Session state does
  not. The five emulation overrides below revert silently when the invocation
  detaches, so a standalone set exits 0 but has no effect on the next command.
  Put each override and the work that depends on it in one Step Run.

  Colour scheme:

    Emulation.setEmulatedMedia '{"features":[{"name":"prefers-color-scheme","value":"dark"}]}'
    screenshot --path page-dark.png

  Network conditions:

    Network.emulateNetworkConditions '{"offline":true,"latency":0,"downloadThroughput":0,"uploadThroughput":0}'
    Runtime.evaluate '{"expression":"navigator.onLine","returnByValue":true}'

  CPU throttling:

    Emulation.setCPUThrottlingRate '{"rate":4}'
    Runtime.evaluate '{"expression":"(()=>{const end=performance.now()+500;let n=0;while(performance.now()<end)n++;return n})()","returnByValue":true}'

  Geolocation:

    Browser.grantPermissions '{"origin":"https://example.com","permissions":["geolocation"]}'
    Emulation.setGeolocationOverride '{"latitude":48.8566,"longitude":2.3522,"accuracy":10}'
    Runtime.evaluate '{"expression":"new Promise(r=>navigator.geolocation.getCurrentPosition(p=>r([p.coords.latitude,p.coords.longitude])))","awaitPromise":true,"returnByValue":true}'

  User agent:

    Emulation.setUserAgentOverride '{"userAgent":"my-test-agent/1.0"}'
    Runtime.evaluate '{"expression":"navigator.userAgent","returnByValue":true}'

  Each block is a Step List fragment for `bt run FILE` or `bt run -`; it is
  not a sequence of standalone commands. The nearby device-metrics recipe is
  the exception: only Emulation.setDeviceMetricsOverride survives detachment,
  so that worked example does not generalise to any of these five overrides.

  Other session state fails loudly rather than quietly doing nothing:

  A DOM nodeId. DOM.getDocument and DOM.querySelector mint node ids owned by
  the session that asked. One reused in the next invocation fails with
  "Could not find node with given id" (exit 1). Use a snapshot UID's
  backendNodeId, which is stable for the life of the document.

  A domain you enabled. `Profiler.enable` in one invocation is gone by the
  next, so a following `Profiler.start` fails with "Profiler is not enabled"
  (exit 1). Any enable-then-collect pair split across two commands fails the
  same way. This is why console-list and network-list take a --duration and
  do their own enable inside one invocation, rather than offering a start
  and a stop, and why CPU profiling is its own command rather than a verb
  here. See CPU PROFILING at the end.

  A JavaScript dialog, and there is no way around this one. A dialog opened
  by one invocation is invisible to the next: Page.handleJavaScriptDialog
  fails with "No dialog is showing" (exit 1), even while the command that
  opened it is still running. The unhandled dialog then blocks the page and
  later commands against it hang. Navigating away can clear it; when that
  hangs too, `stop` the instance and `launch` again. The driving verbs avoid
  reaching that state by answering dialogs inside the invocation with their
  active --dialog policy (default dismiss).

  Page.addScriptToEvaluateOnNewDocument does not rescue this. Registering
  window.alert=()=>{} works when the same invocation also navigates, and
  does nothing when a later invocation navigates, because the registration
  belongs to the session that made it. Measured both ways. Use a carrying verb
  or run so its dialog policy can answer within the same invocation.

  A selected frame. `frames select` binds to the process that ran it, so a
  later `storage get` does not see it. That is what `storage get --key` is
  for, and --key is the only way to read a frame's storage from a separate
  invocation.


SELECTING A PAGE

  --target SPEC means one thing everywhere: a 1-based index into the page
  targets sorted by target ID, or a target ID prefix. A value is read as an
  index only when every character is a digit. The sort is normative, so
  --target 1 names the same page in every verb.

  --url SUBSTRING selects by URL substring instead. --target and --url are
  mutually exclusive; giving both is a usage error (exit 2).

  With several pages and neither selector, handler-routed curated verbs and
  run use --target 1. Raw Domain.method passthrough refuses instead (exit 1)
  and lists the pages. Standalone screenshot, attach, wait, console-list and
  network-list use that strict behaviour too. Choose a selector whenever the
  first page is not deliberately the page you want.


THE UID RULE

  A snapshot gives each node a UID of the form <docToken>-<backendNodeId>.
  One rule covers its whole lifetime:

    A UID is valid until the page navigates. After a navigation, take a new
    snapshot. Nothing else invalidates it.

  So a fresh snapshot invalidates nothing: taking one between two fills is
  unnecessary, and the second fill's UID from the first snapshot still
  resolves. Clicking, filling, scrolling and DOM changes do not invalidate a
  UID either. A UID minted against a previous document fails with exit 1 and
  says to take a new snapshot.

  A node the accessibility tree reports with no DOM node behind it gets a UID
  of the form <docToken>-x<n>. Those are readable in the snapshot but cannot
  be clicked or filled.


NEVER TAKE THE SCREEN

  A person is working on this machine. A browser window that comes to the
  front takes their keyboard focus and moves their window manager to it, and
  the next call takes it again. Three refusals keep windows in the background,
  and each names a remedy that works:

  - Target.activateTarget and Page.bringToFront are refused (exit 2).
  - Target.createTarget with background:false is refused (exit 2);
    Target.createTarget always opens in the background.
  - Input sent to a background tab -- a tab that is not the selected tab of
    its window -- fails (exit 1). Chrome drops it without an error, which is
    what makes an agent reach for activateTarget.

  Input and screenshots reach the selected tab of a window even when the
  window is behind other windows or on another workspace. Leave a launched
  window where the window manager put it; do not move it to a fixed workspace
  away from its related content. To work in a second
  page, open it in its own window and target it:

    bt Target.createTarget '{"url": "https://...", "newWindow": true}'
    bt Input.dispatchMouseEvent '{...}' --target <targetId>

  Or navigate the tab you already have with Page.navigate. launch opens its
  window in the background too.


PROFILES AND LOGIN

  A profile is a persistent identity: a browser user-data-dir that keeps
  cookies and logins across restarts. A fingerprint profile is a different
  thing entirely -- a file of launch flags passed with --fingerprint, which
  shapes how the browser presents itself and stores no login at all.

  To establish a login, do it by hand once:

    bt launch --profile shopify-admin          # headed, so you can see it
    bt Page.navigate '{"url": "https://admin.shopify.com"}'
    # log in yourself in that window: username, password, 2FA, whatever it asks
    bt stop

  From then on, the same flag reuses the same identity:

    bt launch --profile shopify-admin
    bt snapshot                                # already signed in

  The profile directory is <profile root>/shopify-admin, and it survives
  `stop`, reboots, headed and headless switches, viewport changes, and the
  directory you invoke from. Without --profile a launch gets a fresh throwaway
  directory and starts logged out. Camoufox keeps a login only with --profile.

  PROFILE EXCLUSIVITY. A profile is held by at most one live instance.
  Launching into a profile another instance holds fails (exit 1) naming the
  holder, rather than opening a second browser on the same directory:

    Profile 'shopify-admin' is already held by live instance 'web-01'.
    Stop it first, or launch a different profile.

  The remedy is `bt stop web-01`, and then the launch succeeds.

  THE PROFILE ROOT is durable storage, resolved in this order, with an empty
  value falling through to the next:

    1. $BROWSER_TOOLS_PROFILES_DIR
    2. $XDG_DATA_HOME/browser-tools/profiles
    3. ~/.local/share/browser-tools/profiles

  It used to be /tmp/browser-tools-profiles, where the operating system
  deleted every signed-in session at boot, silently. A profile still there is
  listed with "legacy": true, `profile migrate` moves them all across, and
  `launch --profile NAME` brings that one forward by itself. The registry
  stays in /tmp: a cleared registry after a reboot is self-consistent, because
  no browser survives one.


EXTERNAL BROWSERS

  --endpoint URL drives a browser this tool did not launch: one the person
  starts for debugging on a non-default profile, and logs into by hand. It
  goes on the browser-driving verbs, per invocation.

    bt snapshot --endpoint http://127.0.0.1:9787
    bt Page.navigate '{"url": "https://..."}' --endpoint http://127.0.0.1:9787

  Start the browser yourself with --remote-debugging-port=PORT, log in by hand
  in that browser, then pass that port. There is no instance name, so every
  invocation carries the flag.

  NOT 9222. That is the DevTools default port, so on a machine someone is
  working on, whatever answers there is most likely their own browser, with
  their tabs and their cookies. Dialling a port you did not start reaches
  into whatever is behind it, and LOOPBACK ONLY below says what an endpoint
  grants. Start yours on a port of its own, or on --remote-debugging-port=0
  and let bt read the number, as below.

  Raw WebSocket attachment, and discovery from a debugging profile
  (verified end to end on this machine; see APPROVAL below):

    bt snapshot --endpoint ws://127.0.0.1:PORT/devtools/browser/ID
    bt snapshot --chrome-profile DIR

  --chrome-profile takes the USER DATA DIRECTORY Chrome was started on, the
  one passed to Chrome's own --user-data-dir. It reads that directory's
  DevToolsActivePort file. It is mutually exclusive with --endpoint. A missing
  or malformed file fails immediately with exit 2 and names the path.

  CHROME REFUSES REMOTE DEBUGGING ON A DEFAULT DATA DIRECTORY. Measured on
  Chrome 153:

    DevTools remote debugging requires a non-default data directory. Specify
    this using --user-data-dir.

  No port file is written and no endpoint starts, so the everyday browser
  cannot be attached to at all. A DIR that is any channel's platform default
  is refused with that text and the flow below. A channel name (stable, beta,
  dev, canary) is still accepted and resolves to that channel's directory:

    macOS: ~/Library/Application Support/Google/
      Chrome, Chrome Beta, Chrome Dev, Chrome Canary
    Windows: %LOCALAPPDATA%/Google/
      Chrome, Chrome Beta, Chrome Dev, Chrome SxS, each followed by /User Data
    Linux: ~/.config/google-chrome, google-chrome-beta,
      google-chrome-unstable, google-chrome-canary
      CHROME_CONFIG_HOME, then XDG_CONFIG_HOME, can override ~/.config.
      CHROME_USER_DATA_DIR overrides the entire Linux discovery directory.

  On macOS and Windows those are always the platform default, so a channel
  name is always refused there. Only the Linux overrides can move a channel
  somewhere Chrome will debug.

  The flow that works. Start Chrome yourself, on a directory of its own:

    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
      --user-data-dir="$HOME/.local/share/bt-debug-profile" \
      --remote-debugging-port=0

  then:

    bt snapshot --chrome-profile ~/.local/share/bt-debug-profile

  --remote-debugging-port=0 asks Chrome for a free port. Ask for a specific
  one and it does not fall back: a free preferred port binds IPv4; with IPv4
  occupied Chrome binds the SAME number on IPv6; with both occupied Chrome
  keeps running with no endpoint and writes no port file.

  WHICH FAMILY IS DIALLED IS VERIFIED, NOT RACED. The port file's second line
  is the browser WebSocket path, and the GUID in it is the browser's whole
  authentication: anything holding it has unauthenticated control of the
  browser. So bt asks each loopback family for /json/version, which carries
  no secret, and compares the webSocketDebuggerUrl in the reply against that
  line. Only a peer that already knows the GUID is the Chrome that wrote the
  file, and only that one is dialled. One family, one WebSocket. If neither
  family proves itself, bt refuses and says so: the port
  file is stale, or another process took the port. A Chrome 144+ approval
  service answers 404 for every /json path by design and cannot be
  discovered; pass its address with --endpoint ws://HOST:PORT/devtools/...,
  which names the family explicitly.

  NO APPROVAL CLICK ON THE WORKING SHAPE. Measured on Chrome 153 with the
  person at the keyboard: four connections against a Chrome started as
  above, zero permission prompts and no automation banner. The
  per-connection Allow flow belongs to the chrome://inspect
  remote-debugging toggle, and Chrome 153 refuses that service on the
  default data directory, so the everyday browser cannot be attached to at
  all. If a Chrome elsewhere does ask, the 30-second handshake bound below
  applies and an unattended agent cannot supply the click.

  EVERY WAIT HAS A BOUND. bt waits at most 30 seconds for the WebSocket
  handshake and the Browser.getVersion that verifies the peer, at most 30
  seconds for each of the two commands that follow -- listing targets, then
  attaching a page session, so the pair can take up to about a minute --
  at most 120 seconds for any single CDP command a verb sends, and at most
  5 seconds for the detach on the way out. A verb that waits on EVENTS --
  wait, attach, console-list --duration -- keeps its own deadline; these
  bound one command's reply, not the verb. A peer that completes the
  handshake and then stops answering ends the invocation with exit 1 and a
  diagnostic; it never hangs. The bounds are tested with non-answering
  peers. No Allow prompt appeared in the four connections measured on this
  machine, but that is an observation, not a guarantee; a connection that
  goes quiet keeps all three causes named below.

  WHAT A TIMEOUT PROVES, AND WHAT IT DOES NOT. A connection that is accepted
  and then goes quiet has at least three causes: Chrome waiting for Allow, a
  browser whose UI thread is busy or hung, or a process that is not a browser
  holding the port. Two plain TCP listeners produce it with no Chrome
  anywhere. The diagnostic names all three and lists every process holding the
  port with the user data directory each one holds; it does not assert one.

  Not enabled: start debugging on a separate profile. Not permitted: an
  administrator must change RemoteDebuggingAllowed
  (devtools.remote_debugging.allowed). Discovery checks explicit managed
  policy on desktop platforms, including the per-user macOS domain at
  /Library/Managed Preferences/<user>/com.google.Chrome.plist, and reads both
  the boolean and the integer spelling of a zero value. An absent file alone
  cannot distinguish a disabled preference, a policy not visible to bt, or a
  failed listener; check chrome://policy in that case. HTTP 403 means the
  connection was not permitted, but does not by itself prove an administrator
  blocked it.

  THE PEER DOES NOT CHOOSE THE ADDRESS. --endpoint http://HOST:PORT reads the
  browser WebSocket URL from /json/version, and that answer is re-checked
  against the same loopback rule the flag itself runs. A listener that answers
  with ws://192.0.2.1:41234/... is refused, not dialled. Every DevTools HTTP
  read goes to the host that was validated, never to "localhost", which
  resolves to either family and sometimes to neither.

  help over an external endpoint reads /json/protocol from that same address
  and prints the live schema. An endpoint that does not serve it gets static
  usage instead, which does not tell you to launch a browser: launching one
  would not be the browser you are attached to.

  THE BROWSER IS NOT REGISTERED. Nothing is written to the registry, so
  `status` does not list it, and `stop` and `cleanup` neither see it nor touch
  it. That absence is deliberate: those verbs act on registry entries, and an
  external browser's user-data-dir is the person's real profile directory.

  Three refusals, each exit 2, each naming a remedy that works:

  - LOOPBACK ONLY. 127.0.0.1 and ::1 are accepted and nothing else, not even
    localhost. A CDP endpoint is unauthenticated full control of a logged-in
    browser, cookies included, so a remote one is a takeover channel. Reach a
    browser on another machine by forwarding it, and the endpoint is loopback
    again:

      ssh -L 9787:127.0.0.1:9787 <host>

  - Browser.close and Browser.crash are refused: they would end every window
    and tab the person had open. Everything else passes. To close one tab,
    send Target.closeTarget with that tab's id in the params, and --target
    SPEC to pick the page the call is sent over when several are open:

      bt Target.closeTarget '{"targetId": "<id>"}' --target 1 --endpoint URL
  - --endpoint with --profile is refused: a profile is a launch-time identity
    and --endpoint launches nothing. Use `bt launch --profile NAME` instead.

  launch, status, stop, cleanup, guide and profile reject --endpoint (exit 2).
  Everything under NEVER TAKE THE SCREEN applies unchanged. A browser that
  dies mid-session shows up as a connection error on the next invocation,
  which names the port, any process holding it, and the user-data-dir that
  process holds.


PERFORMANCE CAPTURES AND CPU PROFILING

  Four things live here. Pick by the question you are asking.

    Why is this page slow to load, and what were its Web Vitals?
        trace, then insights on the file it wrote.
    Which JavaScript function is burning the CPU?
        browser-tools-profiler, at the end of this section.
    What is this page still holding on to?
        heap, read in the DevTools Memory panel.
    What would a scoring tool say about this page load?
        the Lighthouse recipe, below.

  screencast, trace and heap are Bounded Captures. One invocation starts the
  capture, drives or waits, collects, writes its file and exits. Nothing
  outlives it, so there is no start verb and no stop verb for any of them.

  WHY THESE ARE VERBS AND NOT RECIPES. A CDP payload that arrives as events,
  or as a stream handle a later call has to redeem, cannot be assembled from
  a Step List. Raw passthrough returns one command's return value and
  collects no events, and no step reads another step's output. A trace
  arrives one of those two ways depending on its transfer mode, and a heap
  snapshot arrives as chunked events. So each of these is a curated verb or
  the capability is absent; there is no recipe to fall back on. The same rule
  puts CPU profiling in a command of its own: a profile is an
  enable-start-stop sequence, and one bt invocation does not outlive it.

  trace [INSTANCE] --out FILE [--duration SECONDS] [--steps FILE]
      [--categories LIST] [--timeout SECONDS] [--target SPEC] [--endpoint URL]
      Write unmodified Chrome trace JSON for the DevTools Performance panel.
      Supply --duration (positive decimal seconds), --steps, or both. There is
      no default window. With both, capture ends at whichever bound comes first.
      --steps uses the same Step List validation and runner as `run`; all
      validation precedes Tracing.start. --timeout bounds the whole step run,
      and --frames all reaches cross-origin iframes exactly as it does for
      `run`. --timeout 0 means no deadline, so it contradicts --duration and
      is refused (exit 2); pass one or the other.
      --categories is one comma-separated list replacing the DevTools default
      set. It cannot repeat. Use the default for complete performance analysis.
      Write it as --categories=LIST when the list starts with a dash, which
      the DevTools set does: `--categories -*,v8` is read as a missing value
      and exits 2, while `--categories=-*,v8` works.
      stdout contains trace.path, events, bytes, dataLossOccurred, durationMs,
      endedBy (duration, steps, timeout), categories, and a Run Document under
      run when --steps is present. Check dataLossOccurred before analysis.
      A failed or timed-out run still writes the trace and prints the document,
      but exits 1. Validation failure exits 2 and starts no capture.
      Use --steps to drive the interaction: a plain load window cannot reveal
      a slow click that never happened. trace itself cannot be a Step List step.
      Examples, a fixed window and a driven one:
        bt trace --out window.json --duration 3
        bt trace --steps load.steps --out load.json
      Result: {"trace": {"path": "/abs/window.json", "events": 538,
               "bytes": 264837, "dataLossOccurred": false,
               "durationMs": 3004.02, "endedBy": "duration",
               "categories": [...]}}
      With --steps the document also carries the Run Document under run, and
      endedBy is "steps".

  insights --setup
      Explicitly install the optional analysis runtime. Requires Node.js 22+
      and npm on PATH, network access to the npm registry, and write access to
      the installed package. Runs only npm ci --omit=dev --ignore-scripts
      against the committed lockfile inside browser_tools/_insights;
      node_modules stays there. --ignore-scripts closes the install-time
      script channel; the install is byte-identical with it.
      The base Python install still pulls only websockets, no Node or engine.
      There is no insights Python extra: an empty extra cannot install a marker.
      Before setup, analysis exits 2, naming this command and the npm registry.
      A failed setup prints the npm ci command to retry when network returns,
      as valid shell you can paste.
      Re-run setup after an upgrade changes the lockfile. No auto-install.
      A complete node_modules whose .installed marker is gone is re-stamped
      without reaching the registry, so offline after setup keeps working.
      An adapter file that ships in the wheel cannot be restored by npm: a
      missing one exits 1 and names the package reinstall instead.

  insights --trace FILE [--insight NAME] [--format json|text]
      [--ignore-engine-mismatch]
      Analyze a local trace offline, without a browser or an instance argument.
      JSON has engineRevision, browserVersion and navigations. Each navigation
      has a url and insights; each insight has key, state, title, savings and
      detail. detail is the pinned upstream DevTools formatter's English text.
      savings is the largest positive time saving as {metric, ms}, or null;
      CLS is unitless and is not mislabelled as milliseconds. Other savings
      remain in detail. Text format prints only detail, with no JSON wrapper.
      --insight selects that model per navigation. Unknown names exit 2 and
      list the valid names. Model errors exit 1 and retain available results;
      selecting one insight only fails for errors in the selected model.
      Text format names each navigation when a trace has more than one.
      --trace must name a regular file. An empty path, a directory or a FIFO
      exits 2 before Node starts: analysis reads the file to the end.
      A navigation that committed Chrome's chrome-error:// document exits 1.
      The requested page never loaded, so the insights describe the error page.

      Engine pin: @paulirish/trace_engine 0.0.65. Provisional validated versions:
      Chrome 151.0.7922.34 and 153.0.0.0 only, not intervening builds. The Open
      Question 4 fixture matrix does not exist. The first version has file
      navigation/interaction evidence; 153.0.0.0 has HTTP load evidence only.
      File URLs can lack DocumentLatency timing; use HTTP for complete results.
      browserVersion comes from trace metadata (product-version or user-agent),
      never from a browser running now. Missing metadata is null. Missing or
      unvalidated versions exit 1 with a stamped JSON diagnostic and the remedy.
      --ignore-engine-mismatch accepts that risk and preserves the stamp.
      JSON failures remain JSON even with --format text so the stamp survives.

      Zero Insight Sets is exit 1. The diagnostic names the cause the trace's
      own events show, and gives the remedy that matches that cause:
        No navigationStart and no document request: no navigation was
        captured. A trace on an already loaded page can have zero sets even
        when it contains a click. Start the navigation inside the trace;
        then drive the interaction.
        navigationStart with an empty documentLoaderURL: the navigation
        committed no document inside the trace window. Read the navigate
        step's own result, and end on wait-text or wait-stable, not --duration.
        A document request and no navigationStart: --categories dropped
        blink.user_timing, which carries navigationStart. Re-capture with the
        default categories, or keep blink.user_timing in the list you pass.
      For example, save this Step List as load.steps:
          Page.navigate '{"url":"http://127.0.0.1:8000/"}'
          wait-stable
      Capture and analyze it:
          bt trace --steps load.steps --out load.json
          bt insights --trace load.json --insight INPBreakdown
      Add the real interaction after navigation to measure its breakdown.
      A load with no interaction reports INPBreakdown pass; a captured 280ms
      click handler reports fail with input, processing and presentation times.
      insights cannot itself be a Step List step.

  heap [INSTANCE] --out FILE [--target SPEC] [--endpoint URL]
      Write a .heapsnapshot for the DevTools Memory panel. It shows what the
      page still retains. stdout reports path, nodes, edges, bytes and chunks.
      heap is valid inside a Step List, because the snapshot completes within
      its own step.
      Example: bt heap --out page.heapsnapshot
      Result: {"path": "/abs/page.heapsnapshot", "nodes": 26528,
               "edges": 99696, "bytes": 2234404, "chunks": 3}

  LIGHTHOUSE IS A RECIPE, NOT A VERB. Lighthouse is a Node CLI that audits a
  browser over its debugging port, so it audits a bt instance. There is no
  `bt lighthouse` and none is planned. bt installs nothing for this; npx
  fetches Lighthouse when the line below runs.

    bt launch --headless > instance.json
    NAME=$(python3 -c 'import json;print(json.load(open("instance.json"))["name"])')
    PORT=$(bt status "$NAME" |
      python3 -c 'import json,sys;print(json.load(sys.stdin)[0]["port"])')
    npx -y lighthouse@latest https://example.com --port="$PORT" \
      --output=json --output-path=./lighthouse.json
    bt stop "$NAME"

  Measured against Chrome 151: it runs as written, Lighthouse 13.5.0 exits 0,
  and ./lighthouse.json holds 161 audits and five category scores. The
  instance keeps its name, port and pid throughout and stops on the last line.

  READ THE PORT OUT OF THE INSTANCE, IN THE SAME SCRIPT. A --port with no
  browser on it does not fail. Lighthouse's launcher starts a Chrome of its
  own on that port, audits with it, kills it and exits 0, and the clean
  report you get came from a browser you never chose. Never type a port
  number here. Do not pass --quiet either: the log line "Found existing
  Chrome already running using port N, using that." is the proof it attached
  to your instance.

  LAUNCH IT HEADLESS. Lighthouse opens its tab through Puppeteer, which
  brings that tab to the front. NEVER TAKE THE SCREEN does not cover it:
  those refusals are bt's, and Lighthouse speaks CDP straight to the port. A
  headed run takes the person's screen for a few seconds mid-audit.

  WHAT IT CHANGES IN THE BROWSER. One extra tab exists while the audit runs,
  which shifts numeric --target indices, so use --url SUBSTRING for any bt
  call made during one. A default run clears the browser-wide HTTP cache, and
  the audited origin's service workers and cache storage. Pass
  --disable-storage-reset to turn that off. Cookies, localStorage and
  IndexedDB are untouched, so an audit inside a `launch --profile NAME`
  instance audits the signed-in page.

  WHAT IT DOES NOT REPORT. Lighthouse audits one navigation and drives no
  interaction, so its INP breakdown comes back notApplicable on every run of
  this recipe. Capture it with `trace --steps` instead, with the navigation
  in the same step list, and read that trace with `insights`; a trace holding
  the click alone yields no Insight Sets at all. It does not go the other way
  either: trace and insights produce no audit list and no category scores,
  because that scoring is Lighthouse's own and no bt verb reproduces it.
  Camoufox has no debugging port and cannot be audited at all.

  A non-HTML URL still writes a report, with runtimeError.code NOT_HTML and
  exit 1. Read runtimeError, not the exit code alone.

  `browser-tools-profiler` is installed next to `bt` and profiles the page's
  JavaScript. It is a separate command because a profile is an
  enable-start-stop sequence, and those cannot be split across `bt`
  invocations; see WHAT DOES NOT CARRY BETWEEN INVOCATIONS.

  It takes a debug port, not an instance name, so read the port out of
  `bt status` or the JSON `bt launch` printed. The default is 9222, which is
  whatever happens to be on that port rather than an instance you named, so
  pass --port.

    browser-tools-profiler --port PORT [--format text|json] timed
        [--duration SECONDS]
      Profile for a fixed window, 5 seconds by default, and print the
      functions by self time.

    browser-tools-profiler --port PORT [--format text|json] watch
        [--threshold PERCENT] [--timeout SECONDS] [--window SECONDS]
      Wait until CPU crosses the threshold (80 percent by default), then
      capture for --window seconds. Gives up after --timeout.

  --format json prints a JSON array of {name, url, line, selfTime,
  hitCount}. --format text is the default and is for a person.


OUTPUT AND EXIT CODES

  Machine-readable output goes to stdout; diagnostics go to stderr.

  Every verb prints one JSON document on stdout, with three exceptions:
  `guide` and `help` print plain text, and `attach` prints JSON Lines, one
  event per line.

  Exit 0   success.
  Exit 1   operational failure: the browser is gone, a CDP call failed, a
           deadline passed, a profile is held, a UID no longer resolves.
           Nothing is printed on stdout, except by `run`, which prints its
           run document so you can see which steps already ran.
  Exit 2   usage error: the invocation was malformed or refused before
           anything happened. Nothing was sent, written or deleted.
