Skip to content

Architecture

Hermes Agent edited this page Oct 1, 2026 · 2 revisions

Architecture

English | 中文 | 日本語 | 한국어 | Español | Português | Русский

browsa is a Chrome MV3 extension with a side-panel chat UI. The extension source has no build step — Chrome loads every JS file directly; the only build (build/build.mjs) bundles vendor libraries with esbuild.

Journey of one message

page content scripts (MAIN-world interceptors / ISOLATED-world selection toolbar)
        │  chrome.runtime.sendMessage / port
        ▼
background.js (service worker, single message router)
        │  handle() switch — case bodies live in lib/handlers/*
        ▼
sidepanel.js (the UI orchestrator)
        ▲
        ├── browsa-nav      long-lived port: NAVIGATED / XHS pushes / SELECTION_ACTION
        ├── browsa-chat     fresh per turn: main chat stream (chunk protocol)
        └── browsa-subchat  fresh per send: detail-thread stream
  • background.js is the only chrome.runtime.onMessage router. One handle() switch remains the single exported dispatcher tests import; the biggest case bodies live in lib/handlers/ (chat / subchat / session / attach family / mermaid-repair / provider-resolver / approval-relay / agent-stream-session / prompt-assembly / site-cache-store …).
  • Stream state (streamPorts / streamState / chatControllers / pending-approval Maps …) lives once in lib/state.js, which background.js re-exports — tests importing from ../background.js get the same Map instances.

Port lifecycle: persistent vs per-turn (deliberately different)

  • browsa-nav is long-lived: connected once at panel init, reconnected after SW restarts via the connectNavPort() pattern (1s backoff; a new port object must have all its onMessage listeners re-attached). It means "whichever tab the panel is watching" — re-registering under a new tabId via NAV_FOLLOW is exactly its semantics.
  • browsa-chat / browsa-subchat open fresh per turn / per send: one turn = one port. A disconnect caused by the SW sleeping needs no self-healing — the next send opens a new one. Deliberate: a turn's lifecycle never leaks into the next.

Response envelope (a historical bug family — check which layer you're reading)

  • Most handlers: success { ok: true, data } / thrown error { ok: false, error, code, hint }.
  • A few (APPROVAL_RESPOND / CLARIFY_RESPOND …) catch relay failures internally and return an inner { ok, ... } inside data.
  • Read the actual case before assuming. Mis-reading the envelope is a whole family of real bugs found in the 2026-08 full-repo sweep (e.g. LOAD_SESSION returns -1 on miss — 0 is a legal empty session; the inner res.data.ok is the real success flag).

Background streams across session switches (2026-09-24)

Switching conversations no longer cancels a running turn. The routing rule in one line: while the panel is watching the stream, the turn writes to LIVE history (bg === false); once backgrounded it writes to the ORIGIN session's snapshot. persistTurnEntry is the single writer; REASSIGN_STREAM_SESSION (on switch-away auto-save) and STREAM_PEEK (re-attach on switch-back) are the only two messages that change bg. An aborted turn with streamed text is salvaged as { interrupted: true } into the origin (Esc / idle timeout / network drop); explicit history destruction uses salvage: false.

MV3 service-worker gotchas (read before writing SW-side code)

  • The SW sleeps after ~30s idle. Module-level Maps reset on every restart — never store durable state there.
  • setTimeout inside the SW is unreliable after message handling returns — use chrome.alarms or chrome.storage.session.
  • On-demand listener registration is house style: the three chrome.webNavigation listeners register only while a navPort exists, tabs.onRemoved registers on demand, the stream-GC alarm exists only while streamState is non-empty — otherwise every navigation/tab-close cold-starts the SW (644KB module parse) to no-op.
  • chrome.storage.session survives SW restarts within a browser session (site-cache restore, pending SELECTION_ACTION …).

Tab switching

chrome.tabs.onActivated must not touch the DOM — Chrome keeps the side-panel document alive across tab switches. Update currentTabId and the page-meta text, send NAV_FOLLOW. Stream aborts address the stream's OWN tab (streamTabIdOf()), never the panel's current tab.

Module map

Path Responsibility
background.js SW; handle() dispatch + inline small cases (ATTACH_PAGE …)
lib/handlers/* Big case bodies: chat / subchat / session / attach-* / approval-relay / provider-resolver / stream-dispatch / agent-stream-session / site-cache-store / attach-store / attach-modes
lib/state.js Stream-state Maps + pushChunk protocol + terminal tombstones
lib/llm-client.js Wire-protocol layer: four streams over one openSseStream() skeleton
lib/message-builder.js Per-provider request shapes + ageStaleAttachments
lib/agent-turn.js / lib/image-budget.js Shared agent-turn layer (text/images budget/backfill)
lib/prompt-assembly.js CAPABILITY_HINTS_ENTRIES — the single render-contract text source
lib/storage.js chrome.storage.local wrapper; global history + split session keys
lib/sidepanel/* 26 UI-side modules (render pipeline, sessions drawer, detail thread, timeline …)
lib/content-scripts/* MAIN-world site interceptors + ISOLATED selection toolbar; per-site knowledge in SITES.md
lib/page-extractor.js … Attach/extraction layer (reader/dom/full/auto cascade + site fast-paths + PDF/Office/ASR handoffs)

Source of truth: AGENTS.md "Message flow", "Port lifecycle", "MV3 service worker gotchas", "Background streams across session switches" sections; CONTEXT.md glossary. Synced 2026-10-01.

Clone this wiki locally