English | Português Brasileiro
- One-shot Chrome CDP automation for AI agents
- Lifecycle is always: BORN → EXECUTE → FINALIZE → DIE, in a single process with no daemon
- Full agent command list (71 names): see docs/HOW_TO_USE.md and
browser-automation-cli commands --json
| Layer | Path | Role |
|---|---|---|
| Binary thin | src/main.rs |
panic hook, run_from_args, exit code |
| Lib entry | src/lib.rs |
run / run_from_args, tracing_local guard hold, lifecycle |
| CLI surface | src/cli/ |
Clap derive (Parser / Subcommand); help = agent UX |
| Dispatch | src/commands/ |
PRD handlers (mod.rs match + meta + run) |
| Session | src/browser/ |
One-shot Chrome session, actions, residual ledger hooks |
| Native CDP | src/native/ |
chromiumoxide client, snapshot, heap, cookies, … |
| Contract I/O | src/output.rs, src/envelope.rs, src/json_util.rs |
stdout envelopes; BrokenPipe → 141 |
| Lifecycle | src/lifecycle/ |
cancel token, BORN/FINALIZE orchestration, SIGINT/SIGTERM |
| Residual disk/process | src/residual/ |
marker + Chromium tmp Singleton GC; ResidualDiskReport |
| Local tracing | src/tracing_local/ |
tracing dual sink (stderr + optional rotated JSON) |
| XDG config | src/xdg/, src/config.rs |
product settings: flags + XDG config only |
| i18n | src/i18n/, locales/*.ftl |
--lang + XDG lang → negotiate → OnceLock; human suggestions only |
| Platform | src/platform/ |
PATH which_bin, console UTF-8/VT, HostEnvironment, browser sandbox |
| Windows jobs | src/win_job.rs |
Job Object residual process kill (stubs on non-Windows) |
| Capability table | src/capability/ |
one declarative gate / capture / page-state table read by argv dispatch, run steps and preflight |
| Concurrency | src/concurrency/ |
bounded parallelism budget: Tokio semaphores for I/O, Rayon for CPU, no unbounded fan-out |
| Named constants | src/constants/ |
compile-time defaults (anti-hardcode): identity, viewport, HTTP/CDP budgets, Chrome layouts |
| Cache | src/cache/ |
HTTP / parse cache: L1 in-process map + SQLite under XDG; Redis only via config set cache_backend |
| Network policy | src/net/ |
SSRF modes, body caps, loopback addressing; reqwest system proxy env disabled via no_proxy() |
| Robots | src/robots/ |
robots.txt honoured by default; override needs both --ignore-robots and --i-accept-robots-risk |
| Root containment | src/fs_roots/ |
canonical allowed-root check for local reads and artifact writes; --allow-outside-roots escape |
| Doctor | src/doctor/ |
I/O-light sequential local probes assembled in stable report order (never claims CPU fan-out) |
| Local scrape | src/scrape_local/ |
HTTP / browser scrape engine, envelope shaping, metadata harvest, coverage disclosure |
| Local media | src/image_local/, src/video_local/, src/audio_local/ |
path-to-path probe / convert / resize / trim; optional ffprobe / ffmpeg via XDG ffmpeg_path |
| Local MITM | src/mitm_local/ |
local CA under XDG data, capture store under XDG state, HAR export, process-wide capture policy |
| Local workflow | src/workflow_local/ |
one-shot DAG manifest + SQLite journal; sequential topo order with fail-fast |
| Agent output ops | src/agent_ops/ |
eight universal operations applied over data before stdout |
- Residual-zero covers both live Chrome trees and disk hygiene after DIE
- Process residual — ledger-owned Chrome PID, with Unix SIGTERM → grace → SIGKILL and Windows Job Object kill-on-close
- Marker residual — CLI-owned temp profiles under
browser-automation-cli-chrome-* - Chromium tmp Singleton residual — owned
/tmp/org.chromium.Chromium.*and/tmp/.org.chromium.Chromium.*that are Singleton-only (or empty), same uid, with no live process holding the path - Never kill or wipe host Flatpak Chrome trees (for example
com.google.Chrome.*temp prefixes) - Cross-run GC is Singleton-shape + uid + age + no live holder only
- Marker prefix and Chromium tmp prefix constants (public, anti-hardcode)
- Discovery of invocation-window side-channels (pid/profile attribution)
- Cross-run stale GC:
scavenge_stale_singleton_orphanswith age floor 60s (STALE_MIN_AGE_SECS) - Live-process checks via a single
/proccmdline index (no O(N×P) rescans) - Machine report:
ResidualDiskReport/residual_disk_report()for doctor and agents
| Phase | Residual work |
|---|---|
BORN (Lifecycle::new) |
scavenge_stale_singleton_orphans — wipe cross-run Singleton-only orphans older than 60s |
FINALIZE (Lifecycle::finalize) |
Ledger residual kill/wipe; re-discover invocation-window side-channels; scavenge_owned_chromium_tmp_orphans; second scavenge_stale_singleton_orphans |
| Drop | Sync safety net calling the same idempotent finalize path |
- FINALIZE dual scavenge = invocation-window orphans plus stale Singleton GC so a one-shot cannot leave disk litter for the next process
- Check id:
residual_disk, path-light, with no Chrome launch for the report itself - Top-level doctor JSON field:
residual(ResidualDiskReport) - Fields: all ten, because a shorter list here used to disagree with the struct
scanned_roots— the roots this report actually walked, because a zero without them is unfalsifiablecli_marker_dirs— count ofbrowser-automation-cli-chrome-*under the scanned rootschromium_tmp_singleton_orphans— Singleton-only Chromium tmp that looks orphanedscavenge_safe_candidates— paths stale GC would wipe now (age ≥ 60s, owned, no live holder)live_cli_marker_processes— legacy per-process count, and agents MUST NOT require zerosibling_live_processes— concurrent invocations, informational and never failingorphan_marker_dirs— marker dir past the age floor whose owner pid is deadforeign_root_orphans— held marker PROFILES outside the scanned rootsghost_marker_processes— live CLI browser whose marker profile dir is goneprocess_table_unavailable— enumeration failed, so every wipe is refused- Status is
failonorphan_marker_dirsorghost_marker_processes - Status is
warnon marker dirs or Singleton orphans, andpassotherwise - A live sibling invocation is healthy and never fails the check
- Identity comes from the kernel-reported executable path, never from argv
- argv is written by the process itself, and
sysinfodocumentscmd[0]as untrustworthy for this - The predicate is split by consequence, because the same error costs opposite things
- Verdict and reaping are STRICT — an unknown executable is never treated as a browser
- Wipe protection is PERMISSIVE — anything that might hold a profile keeps it alive
- Known blind spot: sandbox wrappers report
bwrap(Flatpak) orsnapas the tree root - The strict counts under-report those roots, and under-reporting cannot fail a healthy host or signal an innocent process
- Local maintainer gates (local maintainer scripts only):
scripts/residual-check.sh,scripts/residual-stress.sh
- Precedence:
--lang→ XDGlang→ OS locale (sys-locale+fluent-langneg) → defaulten - MVP packs:
en+pt-BR(UiLocale/UiMessageexhaustive match + FTL parity) - Machine JSON
error.messageand tracing stay English (agent contract) - Optional packs: features
i18n-cjk/i18n-rtl/i18n-europe/i18n-full(scaffold) - Diagnostics: subcommand
locale(+--json) - Man page generation: subcommand
man, roff via clap_mangen, with no Chrome - Product settings (including language) use flags + XDG only
- Do not invent or promote product environment variables for durable config
mod.rs—dispatchmatch onCommands+ browser/session handlersmeta/—commands/schemainventory for agents (71 names viacommands --json, schema in an SRP dir)run/— multi-steprun/execscript engine (NDJSON steps)
dialog_map_key: pure helper that maps open JS dialogs by CDP session identity- The event
session_idwins, and browser-scopedNonefalls back to the active page session id - Page forwarders stamp
Page::session_id, soPage.javascriptDialogOpening/Closedfrom non-active tabs do not collide with the active tab map entry - Multi-tab isolation is via
Page::session_id/dialog_map_key dialog_settled: after accept/dismiss, the session waits up to XDGdialog_settle_msforjavascriptDialogClosed- It then returns a compact boolean, agent-first, with no invented post-settle wait by consumers (GAP-054)
dialog_settle_ms: XDG config key only (config set dialog_settle_ms), never a product env vartab_switchdomain enable budget: when switching tabs under a page-modal dialog, domain enable is best-effort underTAB_SWITCH_DOMAIN_ENABLE_BUDGET_MSso modal ownership does not hang the switch path
wait_timeout_ms: public key on run wait steps (GAP-053), and the parser honors it instead of discarding it silently- Scrape
format/formatsin run: without HTML monster when only text is requested (GAP-057) - Native select:
pick/select-optiondispatchinputthenchange, and reportvia: native_select(GAP-055) grabencode: png|jpeg|webp only, and AVIF was removed (breaking)- Inventory 71 includes
submit+storage+image+video+audio+record - The clap product surface is 69, because
pick/select-optionare inventory/run multi-step names
scores_from_lhr: pure function that extracts category scores from Lighthouse Result JSON (0–1 or null audits)- Unit fixtures:
scripts/fixtures/lighthouse/minimal_lhr.jsonand the real sanitizedchrome_captured_lhr.json - The e2e mock path stays SKIP, which is not a parser PASS claim (GAP-021 partial)
- GAP-022 residual: ~53 multi-version dups accepted (cheap prune exhausted)
- GAP-023/024: intentional PRD divergences
- Residual-zero disk law from 0.1.5 still current
- Product config: flags + XDG only (never product env vars)
src/browser_policy/publishes window mode, stealth and egress once during CLI dispatch- It exists because three global flags parsed and were read by nobody
--headedwas one of them: the session hard-codedheadless: true, so the flag changed only the help text- Window mode resolves
auto | headed | headlessby flag, then XDG config, then the compiled default - Publishing once into a process-global works because a one-shot process owns exactly one browser lifetime
- Every value is
Relaxedand written once before any browser launch runs - No product environment variable is read for it, because configuration belongs to
config setand the XDG file
src/native/stealth/patches automation markers before the first navigation- A headless Chrome driven over CDP announces itself in about ten places at once
- A bot check sums those markers into a score, so the module patches every marker it can rather than the loudest one
- Patches run through
Page.addScriptToEvaluateOnNewDocument, which executes before any page script on every document - Applying them after a navigation is worthless: the challenge script already read the values it came for
src/native/stealth/identity.rsresolves one identity that User-Agent, Client Hints and header order all derive from- A bot check cross-reads those surfaces, so there is deliberately no way to move the User-Agent without moving the Client Hints
- Headed under the default
autoprofile the browser keeps Chrome's own User-Agent and its nativenavigator.userAgentData, which already match the real engine, GPU andsec-ch-ua - A headless launch gets a User-Agent override so it does not announce
HeadlessChrome - The patch never emulates
navigator.userAgentDatain any mode; with an override,src/native/browser/identity_override.rssends a completeuserAgentMetadataover CDP, so the page object,getHighEntropyValuesand thesec-ch-ua-*headers all come from one source - An explicit
chrome-win/chrome-macprofile does override it, and the caller accepts a known transport mismatch - The HTTP engine has no browser to borrow an identity from, so it gets a synthesized one: the crate table without a seed, or the major the last seeded launch stored
user_agent_major_sourcein thescrapeenvelope names which of those answered:projected,host_binaryorhost_unprobedsrc/native/stealth/seed_cache.rspins one identity across one-shot processes, opt-in via--stealth-seedor XDGstealth_seed- Without it a 50-URL crawl presents 50 different machines from one address, which creates a signal instead of masking one
- It stays off by default because it writes state to disk, and that is the caller's decision
- The host Chrome major follows the same opt-in:
state_dir/stealth/host-major-<hash>.txtis read and written only with stealth on and a seed, and never under--no-stealth src/browser/session/launch/stealth.rsinstalls the layer on a freshly attached session- It is split from
state.rsby reason to change: domains needed vs. what the page sees before its own scripts run
src/native/interaction/kinematics/is pure pointer and keyboard geometry and timing, with no CDP in it- Every function maps numbers to numbers, so trajectory maths is unit-tested without a browser
- The interpolation loop used to live in
drag_html5alone, sopress,hover,scrollandtypedispatched input no hand could produce InputProfile::Directreproduces the pre-0.1.8 dispatch byte for byte, for speed or exact determinismInputProfile::Humanis the default and costs real time: a click grows from three CDP calls to roughlyinput_move_steps + 3
src/mitm_local/policy.rspublishes the capture policy once from CLI dispatch--mitm-max-body-bytes,--mitm-no-media-bodiesand--mitm-redact-secretsparsed and applied nothing before- Redaction did happen, but by accident of call site: every caller passed
trueliterally, so the flag could neither enable nor disable it - The values live in atomics because the hudsucker handler is cloned per request/response pair
- Threading them through the proxy constructor would put the policy in a dozen signatures that have no interest in it
mitm blockwas declared surface that nothing ever consumedRequestOrResponse::Responsewas never constructed anywhere in the crate, andblock_rules.jsonwas written and never read back- The command answered
{"ok": true}while the traffic the operator asked to refuse went through intact - v0.1.9 short-circuits a matching request with
204 No Contentbefore any DNS lookup or connection - The refusal is recorded in the capture, so a blocked request is distinguishable from one that never happened
- This is the THIRD occurrence of the same class in this module:
--hostswas accepted and discarded,--mitm-max-body-byteswas declared and ignored, and nowmitm block - The lesson this architecture has to record is not about MITM
- Declared surface that nothing ever consumes is a defect no gate in this project detects today
- That is a KNOWN limitation of the verifier set, written here because no gate covers it and none is promised
- Body buffering now has a real ceiling on the READ, not only on a declared length
body_is_bufferableadmitted every body with nocontent-length, which is thechunkedcase and is the norm- The readers then called
body.collect()with no envelope, so the remote peer chose how much memory this process allocated clipnever protected anything, because it runs after the body is already resident and trims what is RETAINED, not what is READ- Both directions now wrap the body in
Limitedwith the 8 MiB ceiling ofBUFFER_CEILING_BYTES - The declared consequence is that a
chunkedbody above 8 MiB now arrives empty
src/scrape_local/shape.rsgivesscrapeone envelope shape whatever--formatcarried- Single-format used to return twenty top-level keys and multi-format four, so asking for MORE data returned LESS
- The lost fields included
status_code,robots_policy,cache_hitandstealth, which callers branch on --fields markdownworked in the first case and returned emptydatawithok: trueand exit 0 in the second- The shape is the union rather than the smaller of the two, because arity must never carry semantics
formatsis always present, holding one entry when one format was asked for- Each format is ALSO promoted to the top level, so the single-format spelling existing callers parse keeps working
src/scrape_local/disclosure.rsstates in the envelope which anti-detection layers are actually live- An agent assuming full coverage reads a block as a transient failure and retries, which escalates the block
- TLS and HTTP/2 are not impersonated: a JA4 fingerprint needs BoringSSL, which is C, and this product is pure Rust by policy
tls_impersonationin the scrape envelope says so, because silence would read as a guarantee the transport cannot keep
src/agent_ops/applies eight universal operations overdatabefore stdout- One implementation covers all 71 commands, including the ones nobody wired locally
- Four of the global flags are
--fields,--filter-rows,--limit-rows,--sort-rows - The other four are
--dedupe-by,--count-only,--truncate-content,--max-output-bytes --select,--filter,--limitand--sortare not global flags- They exist only as per-command flags on
scrape,crawl,mapandsearch batch-scrapeand the media info verbs also declare that per-command set- The
-rowssuffix stops clap from handing one value to both projections - Order of operations: select, resolve rows, filter, sort, dedupe, limit
- Then string truncation, the count-only collapse and the byte ceiling
selectruns first because it also disambiguates data holding two arrays- The envelope gains the
agent_opsfield only when some flag actually ran - An untouched envelope keeps its exact previous shape for existing consumers
- A flag that ran and resolved cleanly also omits the field, so running one is necessary and not sufficient
- Members are all optional:
total,matched,truncated,omitted_rows,unresolved_paths unresolved_pathsis a list of{flag, path}entries- It names a requested dotted path that no row actually carries
- Without it a mistyped path returns exit 0 and looks like success
src/agent_ops/path.rshasproject()returning(Value, Vec<String>)- The second element is the set of paths that did not resolve
src/agent_ops/filter.rshas the purerows_with_key()probe- It runs before dedupe and sort, which would otherwise erase the evidence
- A row operation against data with no single list fails as
Usage
src/scrape_local/html_meta.rsbacks themetadatascrape formatcollect_metadata()reads the document already parsed bybuild_scrape_payload- The extra coverage costs one selector pass per field and no new dependency
- Harvested families: Open Graph, Dublin Core,
article:and Twitter card - Also harvested: canonical URL, favicon, declared charset and
html_lang - Simple
<meta>names cover keywords, author, language, robots, viewport, generator and theme-color - Qualified prefixes are keyed as
prefix_name, without the colon - Absent fields are omitted rather than emitted as null (CLEAN stdout)
- Favicon tries
icon, thenshortcut icon, thenapple-touch-icon html_langcomes from the<html lang>attribute, not thelanguagemetameta_property()uses a literal selector match for qualified prefixes- The shared helper adds an implicit
og:fallback to every lookup - That fallback would make
dc:titlesilently answer withog:title - Literal matching keeps the harvest from reporting fields the page never declared
- Discover live:
browser-automation-cli commands --json
assert attr back batch-scrape click-at commands completions config console cookie
crawl devtools3p dialog doctor drag emulate eval exec extension extract feed fill-form
find-paths forward goto grab heap hover image video audio keys lighthouse locale man map mitm monitor
net page parse perf pick press print-pdf qr record reload resize run schema scrape screencast
scroll search select-option sg-rewrite sg-scan sheet-write sitemap storage submit text type
upload version view wait webmcp workflow write
pickandselect-optionare multi-step inventory names used inrunscripts- The clap product subcommand count is 69
- Large handler surface remains in
mod.rsby design (single match table for agent parity) - Prefer extracting new command families into sibling modules rather than growing unrelated helpers
- Full list of names:
docs/HOW_TO_USE.mdandbrowser-automation-cli commands --json
- No public
macro_rules!/proc-macrocrate - CDP protocol stubs:
build.rs+include!(concat!(env!("OUT_DIR"), "/cdp_generated.rs")) - Event forwarders: generic functions (
spawn_cdp_event_forwarder), not macros
- Order: XDG
chrome_path→ product browsers cache →$PATHnames → known absolute layouts (Linux/usr//opt/snap/flatpak, macOS/Applications, Windows%ProgramFiles%/ LocalAppData including Edge/Beta/Canary/Brave) → home Puppeteer/Playwright caches - No product
CHROME_PATHenv (product law: flags + XDG only) - Snap/Flatpak paths warn via
tracingand doctorsandboxfield - Containers/root get Chrome
--no-sandbox+--disable-dev-shm-usage - Host probe:
doctor --json→host_environment, which names the sandbox and host markersHostEnvironment::detectfound
launch_self_spawnedinsrc/native/cdp/chrome/spawn.rsno longer opens a DevTools TCP port- The loopback port answered
/json/versionwithout authentication, so any local process could drive the browser while the command lived use_pipe_transportinsrc/native/cdp/pipe/mod.rsremoves--remote-debugging-portand--remote-debugging-addressand adds--remote-debugging-pipe- On POSIX,
install_child_endsinsrc/native/cdp/pipe/unix.rsplaces Chrome's pipe ends on descriptors 3 and 4 - Each message on the pipe is JSON text followed by a zero byte
chromiumoxidespeaks WebSocket only, soPipeBridge::startinsrc/native/cdp/pipe/bridge.rsserves one on loopback- The WebSocket path holds the 122 random bits of a v4 UUID
accept_oneanswers every other path with 403, and the bridge exposes no/json/versionrelaydrops the listener once one client finishes the handshake, so a second client never connects- Each handshake attempt has
CDP_PIPE_HANDSHAKE_TIMEOUT_MS(2000 ms) to finish CDP_PIPE_MAX_MESSAGE_BYTESinsrc/constants/cdp.rscaps each message at 256 MiB, on the pipe and on the WebSocketPipeBridge::wait_readywaits for Chrome's answer to aBrowser.getVersionprobe queued before the relay startsPipeBridge::shutdownwaits at mostCDP_PIPE_SHUTDOWN_GRACE_MS(2000 ms) for its pipe threads and leaves a still-blocked thread detached- The bound exists because a Chrome descendant that inherits the pipe keeps the reader from seeing end-of-file
write_to_chromeblocksSIGPIPEon its own thread only, so a dead Chrome yieldsEPIPEand the exit 141 contract holds everywhere else- The stdout and stderr drainers of Chrome and Lightpanda get the same bound through
LOG_DRAINER_JOIN_GRACE_MS(2000 ms) - The Windows half in
src/native/cdp/pipe/windows.rspasses two handles in--remote-debugging-io-pipes, and it was NOT compiled or tested - The Lightpanda engine and the legacy launch behind the XDG key
chrome_legacy_oxide_launchdo not use the pipe and did not change
launch_self_spawnedbuilds theChromeProcessowner ofsrc/native/cdp/chrome/process.rsinside the blocking spawn task, before the first readiness wait- A launch cancelled between the fork and the first readiness wait therefore kills Chrome on drop instead of leaving it without an owner
with_transporthands the bridge to that owner before the first wait on it, andshutdown_transportruns only after Chrome is reapedChromeProcess::killsignals Chrome's whole process group throughkill_group, not only its pid- The lifecycle ledger learns the group only after a successful launch, so a failed launch used to leave group members alive
ChromeProcess::wait_or_killgives leftover group membersCHROME_GROUP_STRAGGLER_GRACE_MS(500 ms) betweenSIGTERMandSIGKILLkill_off_the_runtimeinspawn.rsruns the teardown of a failed launch on a blocking thread, so--timeoutstill ends the command with exit 124 instead of 69- A descendant that calls
setsidleaves the group and stays outside the group kill
should_use_private_displayinsrc/native/cdp/xvfb/mod.rsasks for a private display only for a headed launch on Linux without--no-xvfb- It reads the
headlessvalue of the launch itself, so the extension launch path, which forces headed, also gets the display launch_self_spawnedstarts the display BEFORE building the argv, so the X11 pin follows the server that actually came up- A failed start is not a launch error: the launch warns and continues headed on the current display
record_display_outcomeinsrc/browser_policy/mod.rsthen makesdisplay_backendreportheadless,xvfborhostpin_x11_for_private_displayinsrc/native/cdp/chrome/args.rsadds--ozone-platform=x11only when the private display started- In that same case the spawn removes
WAYLAND_DISPLAYfrom Chrome's environment throughSpawnRequest::env_removeinsrc/native/cdp/spawn/guard.rs launch_with_oxideinsrc/native/cdp/oxide.rs, the legacy launch, never starts a private display, so it recordsprivate_displayas false and never receives the pinstart_private_displayinsrc/native/cdp/xvfb/spawn.rswalksfree_displaysfromXVFB_DISPLAY_SEARCH_START(99) overXVFB_DISPLAY_SEARCH_SPAN(32) numbers- Xvfb runs with
-nolisten tcpand writes its output to the null device throughdiscard_output poll_until_owneddeclares the display ready only when/tmp/.X{n}-locknames the pid of the Xvfb this launch spawned and the socket exists- A server that exits before that is retried on the next free number
display_free_frominsrc/native/cdp/xvfb/display.rsreuses a number whose lock names a dead pid, and never takes a lock with no readable pidXvfbGuardsendsSIGTERMon drop and waitsXVFB_TERM_GRACE_MS(2000 ms) beforeSIGKILL, so the server removes its own lock and socket- The guard removes lock and socket itself only when the lock still names its own pid
XAuthority::createinsrc/native/cdp/xvfb/auth.rswrites a 16-byteMIT-MAGIC-COOKIE-1into a mode 0600 file- The file lives in
dirs::runtime_dir(), with the temp directory as fallback, and is namedbrowser-automation-cli-xauth-<pid>-<uuid> - Xvfb receives the file through
-authand Chrome receives it throughXAUTHORITY - The file is removed when its guard drops, and
sweep_orphansremoves files whose creator pid is dead on the next launch ChromeProcessowns theXvfbGuard, so Chrome is reaped before its display is stopped- macOS, KDE and Sway were NOT validated live for the pipe or the X11 pin
- The absence of a window on the compositor was proven by argv, environment and sockets, not visually
- stdout = JSON envelopes only (agent-first)
- stderr = diagnostics / tracing
- zero remote telemetry
- residual zero after DIE: Chrome process + CLI markers + Chromium Singleton tmp (process and disk)
- never kill host Flatpak Chrome residual
- product settings: flags + XDG only (no product env catalogs)
- no remote release orchestration pipelines in-repo (local gates under
scripts/*-check.sh) - host-only Chrome CDP (no WASM automation target)
docs/COOKBOOK.md— agent recipesdocs/TESTING.md— how to run gatesdocs/CROSS_PLATFORM.md— OS matrix, browser paths, sandboxesdocs/HOW_TO_USE.md— full inventory of 71 commandsdocs/ARCHITECTURE.pt-BR.md— Portuguese mirrorgaps.md— audit notes on the Wayland defect fixed in v0.2.0PRIVACY.md— local-only data handling