All skills
Skillintermediate

Sidebar Message Flow

How the GStack Browser sidebar actually works. Read this before touching sidepanel.js, background.js, content.js, server.ts sidebar endpoints, or sidebar-agent.ts.

Claude Code Knowledge Pack7/10/2026

Overview

Sidebar Message Flow

How the GStack Browser sidebar actually works. Read this before touching sidepanel.js, background.js, content.js, server.ts sidebar endpoints, or sidebar-agent.ts.

Components

┌─────────────────┐     ┌──────────────┐     ┌─────────────┐     ┌────────────────┐
│  sidepanel.js   │────▶│ background.js│────▶│  server.ts   │────▶│sidebar-agent.ts│
│  (Chrome panel) │     │ (svc worker) │     │  (Bun HTTP)  │     │  (Bun process) │
└─────────────────┘     └──────────────┘     └─────────────┘     └────────────────┘
        ▲                                           │                      │
        │           polls /sidebar-chat             │    polls queue file   │
        └───────────────────────────────────────────┘                      │
                                                    ◀──────────────────────┘
                                                    POST /sidebar-agent/event

Startup Timeline

T+0ms     CLI runs `$B connect`
            ├── Server starts on port 34567
            ├── Writes state to .gstack/browse.json (pid, port, token)
            ├── Launches headed Chromium with extension
            └── Clears sidebar-agent-queue.jsonl

T+500ms   sidebar-agent.ts spawned by CLI
            ├── Reads auth token from .gstack/browse.json
            ├── Creates queue file if missing
            ├── Sets lastLine = current line count
            └── Starts polling every 200ms

T+1-3s    Extension loads in Chromium
            ├── background.js: health poll every 1s (fast startup)
            │     └── GET /health → gets auth token
            ├── content.js: injects on welcome page
            │     └── Does NOT fire gstack-extension-ready (waits for sidebar)
            └── Side panel: may auto-open via chrome.sidePanel.open()

T+2-10s   Side panel connects
            ├── tryConnect() → asks background for port/token
            ├── Fallback: direct GET /health for token
            ├── updateConnection(url, token)
            │     ├── Starts chat polling (1s interval)
            │     ├── Starts tab polling (2s interval)
            │     ├── Connects SSE activity stream
            │     └── Sends { type: 'sidebarOpened' } to background
            └── background relays to content script → hides welcome arrow

T+10s+    Ready for messages

Message Flow: User Types → Claude Responds

1. User types "go to hn" in sidebar, hits Enter

2. sidepanel.js sendMessage()
   ├── Renders user bubble immediately (optimistic)
   ├── Renders thinking dots immediately
   ├── Switches to fast poll (300ms)
   └── chrome.runtime.sendMessage({ type: 'sidebar-command', message, tabId })

3. background.js
   ├── Gets active Chrome tab URL
   └── POST /sidebar-command { message, activeTabUrl }
       with Authorization: Bearer ${authToken}

4. server.ts /sidebar-command handler
   ├── validateAuth(req)
   ├── syncActiveTabByUrl(extensionUrl) — syncs Playwright tab to Chrome tab
   ├── pickSidebarModel(message) — 'sonnet' for actions, 'opus' for analysis
   ├── Adds user message to chat buffer
   ├── Builds system prompt + args
   └── Appends JSON to ~/.gstack/sidebar-agent-queue.jsonl

5. sidebar-agent.ts poll() (within 200ms)
   ├── Reads new line from queue file
   ├── Parses JSON entry
   ├── Checks processingTabs — skips if tab already has agent running
   └── askClaude(entry) — fire and forget

6. sidebar-agent.ts askClaude()
   ├── spawn('claude', ['-p', prompt, '--model', model, ...])
   ├── Streams stdout line-by-line (stream-json format)
   ├── For each event: POST /sidebar-agent/event { type, tool, text, tabId }
   └── On close: POST /sidebar-agent/event { type: 'agent_done' }

7. server.ts processAgentEvent()
   ├── Adds entry to chat buffer (in-memory + disk)
   ├── On agent_done: sets tab status to 'idle'
   └── On agent_done: processes next queued message for that tab

8. sidepanel.js pollChat() (every 300ms during fast poll)
   ├── GET /sidebar-chat?after=${chatLineCount}&tabId=${tabId}
   ├── Renders new entries (text, tool_use, agent_done)
   └── On agent idle: removes thinking dots, stops fast poll

Arrow Hint Hide Flow (4-step signal chain)

The welcome page shows a right-pointing arrow until the sidebar opens.

1. sidepanel.js updateConnection()
   └── chrome.runtime.sendMessage({ type: 'sidebarOpened' })

2. background.js
   └── chrome.tabs.sendMessage(activeTabId, { type: 'sidebarOpened' })

3. content.js onMessage handler
   └── document.dispatchEvent(new CustomEvent('gstack-extension-ready'))

4. welcome.html script
   └── addEventListener('gstack-extension-ready', () => arrow.classList.add('hidden'))

The arrow does NOT hide when the extension loads. Only when the sidebar connects.

Auth Token Flow

Server starts → AUTH_TOKEN = crypto.randomUUID()
    │
    ├── GET /health (no auth) → returns { token: AUTH_TOKEN }
    │
    ├── background.js checkHealth() → authToken = data.token
    │     └── Refreshes on EVERY health poll (fixes stale token on restart)
    │
    ├── sidepanel.js tryConnect() → serverToken from background or /health
    │     └── Used for chat polling: Authorization: Bearer ${serverToken}
    │
    └── sidebar-agent.ts refreshToken() → reads from .gstack/browse.json
          └── Used for event relay: Authorization: Bearer ${authToken}

If the server restarts, all three components get fresh tokens within 10s (background health poll interval).

Model Routing

pickSidebarModel(message) in server.ts classifies messages:

PatternModelWhy
"click @e24", "go to hn", "screenshot"sonnetDeterministic tool calls, no thinking needed
"what does this page say?", "summarize"opusNeeds comprehension
"find bugs", "check for broken links"opusAnalysis task
"navigate to X and fill the form"sonnetAction-oriented, no analysis words

Analysis words (what, why, how, summarize, describe, analyze, read X and Y) always override action verbs and force opus.

Known Failure Modes

FailureSymptomRoot CauseFix
Stale auth token"Unauthorized" in inputServer restarted, background had old tokenbackground.js refreshes token on every health poll
Tab ID mismatchMessage sent, no response visibleServer assigned tabId 1, sidebar polling tabId 0switchChatTab preserves optimistic UI during switch
Sidebar agent not runningMessages queue foreverAgent process failed to spawn or crashedCheck `ps aux
Agent stale tokenAgent runs but no events appear in sidebarsidebar-agent has old token from .gstack/browse.jsonAgent re-reads token before each event POST
Queue file missingspawnClaude failsRace between server start and agent startBoth sides create file if missing
Optimistic UI blown awayUser bubble + dots vanishswitchChatTab replaced DOM with welcome screenPreserved DOM when lastOptimisticMsg is set

Per-Tab Concurrency

Each browser tab can run its own agent simultaneously:

  • Server: tabAgents: Map<number, TabAgentState> with per-tab queue (max 5)
  • sidebar-agent: processingTabs: Set<number> prevents duplicate spawns
  • Two messages on same tab: queued sequentially, processed in order
  • Two messages on different tabs: run concurrently

File Locations

ComponentFileRuns in
Sidebar UIextension/sidepanel.jsChrome side panel
Service workerextension/background.jsChrome background
Content scriptextension/content.jsPage context
Welcome pagebrowse/src/welcome.htmlPage context
HTTP serverbrowse/src/server.tsBun (compiled binary)
Agent processbrowse/src/sidebar-agent.tsBun (non-compiled, can spawn)
CLI entrybrowse/src/cli.tsBun (compiled binary)
Queue file~/.gstack/sidebar-agent-queue.jsonlFilesystem
State file.gstack/browse.jsonFilesystem
Chat log~/.gstack/sessions/<id>/chat.jsonlFilesystem