Files
gstack/extension/background.js
Garry Tan ed1e4be2f6 feat: gstack browser sidebar = interactive Claude Code REPL with live tab awareness (v1.14.0.0) (#1216)
* build: vendor xterm@5 for the Terminal sidebar tab

Adds xterm@5 + xterm-addon-fit as devDependencies and a `vendor:xterm`
build step that copies the assets into `extension/lib/` at build time.
The vendored files are .gitignored so the npm version stays the source
of truth. xterm@5 is eval-free, so no MV3 CSP changes needed.

No runtime callers yet — this just stages the assets.

* feat(server): add pty-session-cookie module for the Terminal tab

Mirrors `sse-session-cookie.ts` exactly. Mints short-lived 30-min HttpOnly
cookies for authenticating the Terminal-tab WebSocket upgrade against
the terminal-agent. Same TTL, same opportunistic-pruning shape, same
"scoped tokens never valid as root" invariant. Two registries instead of
one because the cookie names are different (`gstack_sse` vs `gstack_pty`)
and the token spaces must not overlap.

No callers yet — wired up in the next commit.

* feat(server): add terminal-agent.ts (PTY for the Terminal sidebar tab)

Translates phoenix gbrowser's Go PTY (cmd/gbd/terminal.go) into a Bun
non-compiled process. Lives separately from `sidebar-agent.ts` so a
WS-framing or PTY-cleanup bug can't take down the chat path (codex
outside-voice review caught the coupling risk).

Architecture:
- Bun.serve on 127.0.0.1:0 (never tunneled).
- POST /internal/grant accepts cookie tokens from the parent server over
  loopback, authenticated with a per-boot internal token.
- GET /ws upgrades require BOTH (a) Origin: chrome-extension://<id> and
  (b) the gstack_pty cookie minted by /pty-session. Either gate alone is
  insufficient (CSWSH defense + auth defense).
- Lazy spawn: claude PTY is not started until the WS receives its first
  data frame. Idle sidebar opens cost nothing.
- Bun PTY API: `terminal: { rows, cols, data(t, chunk) }` — verified at
  impl time on Bun 1.3.10. proc.terminal.write() for input,
  proc.terminal.resize() for resize, proc.kill() + 3s SIGKILL fallback
  on close.
- process.on('uncaughtException'|'unhandledRejection') handlers so a
  framing bug logs but doesn't kill the listener loop.

Test-only `BROWSE_TERMINAL_BINARY` env override lets the integration
tests spawn /bin/bash instead of requiring claude on every CI runner.

Not yet spawned by anything — wired in the next commit.

* feat(server): wire /pty-session route + spawn terminal-agent

Server-side glue connecting the Terminal sidebar tab to the new
terminal-agent process.

server.ts:
- New POST /pty-session route. Validates AUTH_TOKEN, mints a gstack_pty
  HttpOnly cookie via pty-session-cookie.ts, posts the cookie value to
  the agent's loopback /internal/grant. Returns the terminalPort + Set-Cookie
  to the extension.
- /health response gains `terminalPort` (just the port number — never a
  shell token). Tokens flow via the cookie path, never /health, because
  /health already surfaces AUTH_TOKEN to localhost callers in headed mode
  (that's a separate v1.1+ TODO).
- /pty-session and /terminal/* are deliberately NOT added to TUNNEL_PATHS,
  so the dual-listener tunnel surface 404s by default-deny.
- Shutdown path now also pkills terminal-agent and unlinks its state files
  (terminal-port + terminal-internal-token) so a reconnect doesn't try to
  hit a dead port.

cli.ts:
- After spawning sidebar-agent.ts, also spawn terminal-agent.ts. Same
  pattern: pkill old instances, Bun.spawn(['bun', 'run', script]) with
  BROWSE_STATE_FILE + BROWSE_SERVER_PORT env. Non-fatal if the spawn
  fails — chat still works without the terminal agent.

* feat(extension): Terminal as default sidebar tab

Adds a primary tab bar (Terminal | Chat) above the existing tab-content
panes. Terminal is the default-active tab; clicking Chat returns to the
existing claude -p one-shot flow which is preserved verbatim.

manifest.json: adds ws://127.0.0.1:*/ to host_permissions so MV3 doesn't
block the WebSocket upgrade.

sidepanel.html: new primary-tabs nav, new #tab-terminal pane with a
"Press any key to start Claude Code" bootstrap card, claude-not-found
install card, xterm mount point, and "session ended" restart UI. Loads
xterm.js + xterm-addon-fit + sidepanel-terminal.js. tab-chat is no
longer the .active default.

sidepanel.js: new activePrimaryPaneId() helper that reads which primary
tab is selected. Debug-close paths now route back to whichever primary
pane is active (was hardcoded to tab-chat). Primary-tab click handler
toggles .active classes and aria-selected. window.gstackServerPort and
window.gstackAuthToken exposed so sidepanel-terminal.js can build the
/pty-session POST and the WS URL.

sidepanel-terminal.js (new): xterm.js lifecycle. Lazy-spawn — first
keystroke fires POST /pty-session, then opens
ws://127.0.0.1:<terminalPort>/ws. Origin + cookie are set automatically
by the browser. Resize observer sends {type:"resize"} text frames.
ResizeObserver, tab-switch hooks, restart button, install-card retry.
On WS close shows "Session ended, click to restart" — no auto-reconnect
(codex outside-voice flagged that as session-burning).

sidepanel.css: primary-tabs bar + Terminal pane styling (full-height
xterm container, install card, ended state).

* test: terminal-agent + cookie module + sidebar default-tab regression

Three new test files:

terminal-agent.test.ts (16 tests): pty-session-cookie mint/validate/
revoke, Set-Cookie shape (HttpOnly + SameSite=Strict + Path=/, NO Secure
since 127.0.0.1 over HTTP), source-level guards that /pty-session and
/terminal/* are NOT in TUNNEL_PATHS, /health does NOT surface ptyToken
or gstack_pty, terminal-agent binds 127.0.0.1, /ws upgrade enforces
chrome-extension:// Origin AND gstack_pty cookie, lazy-spawn invariant
(spawnClaude is called from message handler, not upgrade), uncaughtException/
unhandledRejection handlers exist, SIGINT-then-SIGKILL cleanup.

terminal-agent-integration.test.ts (7 tests): spawns the agent as a real
subprocess in a tmp state dir. Verifies /internal/grant accepts/rejects
the loopback token, /ws gates (no Origin → 403, bad Origin → 403, no
cookie → 401), real WebSocket round-trip with /bin/bash via the
BROWSE_TERMINAL_BINARY override (write 'echo hello-pty-world\n', read it
back), and resize message acceptance.

sidebar-tabs.test.ts (13 tests): structural regression suite locking the
load-bearing invariants of the default-tab change — Terminal is .active,
Chat is not, xterm assets are loaded, debug-close path no longer hardcodes
tab-chat (uses activePrimaryPaneId), primary-tab click handler exists,
chat surface is not accidentally deleted, terminal JS does NOT auto-
reconnect on close, manifest declares ws:// + http:// localhost host
permissions, no unsafe-eval.

Plan called for Playwright + extension regression; the codebase doesn't
ship Playwright extension launcher infra, so we follow the existing
extension-test pattern (source-level structural assertions). Same
load-bearing intent — locks the invariants before they regress.

* docs: Terminal flow + threat model + v1.1 follow-ups

SIDEBAR_MESSAGE_FLOW.md: new "Terminal flow" section. Documents the WS
upgrade path (/pty-session cookie mint → /ws Origin + cookie gate →
lazy claude spawn), the dual-token model (AUTH_TOKEN for /pty-session,
gstack_pty cookie for /ws, INTERNAL_TOKEN for server↔agent loopback),
and the threat-model boundary — the Terminal tab bypasses the entire
prompt-injection security stack on purpose; user keystrokes are the
trust source. That trust assumption is load-bearing on three transport
guarantees: local-only listener, Origin gate, cookie auth. Drop any
one of those three and the tab becomes unsafe.

CLAUDE.md: extends the "Sidebar architecture" note to include
terminal-agent.ts in the read-this-first list. Adds a "Terminal tab is
its own process" note so a future contributor doesn't bolt PTY logic
onto sidebar-agent.ts.

TODOS.md: three new follow-ups under a new "Sidebar Terminal" section:
  - v1.1: PTY session survives sidebar reload (Issue 1C deferred).
  - v1.1+: audit /health AUTH_TOKEN distribution (codex finding #2 —
    a pre-existing soft leak that cc-pty-import sidesteps but doesn't
    fix).
  - v1.1+: apply terminal-agent's process.on exception handlers to
    sidebar-agent.ts (codex finding #4 — chat path has no fatal
    handlers).

* feat(extension): Terminal-only sidebar — auth fix, UX polish, chat rip

The chat queue path is gone. The Chrome side panel is now just an
interactive claude PTY in xterm.js. Activity / Refs / Inspector still
exist behind the `debug` toggle in the footer.

Three threads of change, all from dogfood iteration on top of
cc-pty-import:

1. fix(server): cross-port WS auth via Sec-WebSocket-Protocol
   - Browsers can't set Authorization on a WebSocket upgrade. We had
     been minting an HttpOnly gstack_pty cookie via /pty-session, but
     SameSite=Strict cookies don't survive the cross-port jump from
     server.ts:34567 to the agent's random port from a chrome-extension
     origin. The WS opened then immediately closed → "Session ended."
   - /pty-session now also returns ptySessionToken in the JSON body.
   - Extension calls `new WebSocket(url, [`gstack-pty.<token>`])`.
     Browser sends Sec-WebSocket-Protocol on the upgrade.
   - Agent reads the protocol header, validates against validTokens,
     and MUST echo the protocol back (Chromium closes the connection
     immediately if a server doesn't pick one of the offered protocols).
   - Cookie path is kept as a fallback for non-browser callers (curl,
     integration tests).
   - New integration test exercises the full protocol-auth round-trip
     via raw fetch+Upgrade so a future regression of this exact class
     fails in CI.

2. fix(extension): UX polish on the Terminal pane
   - Eager auto-connect when the sidebar opens — no "Press any key to
     start" friction every reload.
   - Always-visible ↻ Restart button in the terminal toolbar (not
     gated on the ENDED state) so the user can force a fresh claude
     mid-session.
   - MutationObserver on #tab-terminal's class attribute drives a
     fitAddon.fit() + term.refresh() when the pane becomes visible
     again — xterm doesn't auto-redraw after display:none → display:flex.

3. feat(extension): rip the chat tab + sidebar-agent.ts
   - Sidebar is Terminal-only. No more Terminal | Chat primary nav.
   - sidebar-agent.ts deleted. /sidebar-command, /sidebar-chat,
     /sidebar-agent/event, /sidebar-tabs* and friends all deleted.
   - The pickSidebarModel router (sonnet vs opus) is gone — the live
     PTY uses whatever model the user's `claude` CLI is configured with.
   - Quick-actions (🧹 Cleanup / 📸 Screenshot / 🍪 Cookies) survive
     in the Terminal toolbar. Cleanup now injects its prompt into the
     live PTY via window.gstackInjectToTerminal — no more
     /sidebar-command POST. The Inspector "Send to Code" action uses
     the same injection path.
   - clear-chat button removed from the footer.
   - sidepanel.js shed ~900 lines of chat polling, optimistic UI,
     stop-agent, etc.

Net diff: -3.4k lines across 16 files. CLAUDE.md, TODOS.md, and
docs/designs/SIDEBAR_MESSAGE_FLOW.md rewritten to match. The sidebar
regression test (browse/test/sidebar-tabs.test.ts) is rewritten as 27
structural assertions locking the new layout — Terminal sole pane,
no chat input, quick-actions in toolbar, eager-connect, MutationObserver
repaint, restart helper.

* feat: live tab awareness for the Terminal pane

claude in the PTY now has continuous tab-aware context. Three pieces:

1. Live state files. background.js listens to chrome.tabs.onActivated /
   onCreated / onRemoved / onUpdated (throttled to URL/title/status==
   complete so loading spinners don't spam) and pushes a snapshot. The
   sidepanel relays it as a custom event; sidepanel-terminal.js sends
   {type:"tabState"} text frames over the live PTY WebSocket.
   terminal-agent.ts writes:
     <stateDir>/tabs.json          all open tabs (id, url, title, active,
                                   pinned, audible, windowId)
     <stateDir>/active-tab.json    current active tab (skips chrome:// and
                                   chrome-extension:// internal pages)
   Atomic write via tmp + rename so claude never reads a half-written
   document. A fresh snapshot is pushed on WS open so the files exist by
   the time claude finishes booting.

2. New $B tab-each <command> [args...] meta-command. Fans out a single
   command across every open tab, returns
   {command, args, total, results: [{tabId, url, title, status, output}]}.
   Skips chrome:// pages; restores the originally active tab in a finally
   block (so a mid-batch error doesn't leave the user looking at a
   different tab); uses bringToFront: false so the OS window doesn't
   jump on every fanout. Scope-checks the inner command BEFORE the loop.

3. --append-system-prompt hint at spawn time. Claude is told about both
   the state files and the $B tab-each command up front, so it doesn't
   have to discover the surface by trial. Passed via the --append-system-
   prompt CLI flag, NOT as a leading PTY write — the hint stays out of
   the visible transcript.

Tests:
- browse/test/tab-each.test.ts (new) — registration + source-level
  invariants (scope check before loop, finally-restore, bringToFront:false,
  chrome:// skip) + behavior tests with a mock BrowserManager that verify
  iteration order, JSON shape, error handling, and active-tab restore.
- browse/test/terminal-agent.test.ts — three new assertions for
  tabState handler shape, atomic-write pattern, and the
  --append-system-prompt wiring at spawn.

Verified live: opened 5 tabs, ran $B tab-each url against the live
server, got per-tab JSON results back, original active tab restored
without OS focus stealing.

* chore: drop sidebar-agent test refs after chat rip

Five test files / describe blocks targeted the deleted chat path:
- browse/test/security-e2e-fullstack.test.ts (full-stack chat-pipeline E2E
  with mock claude — whole file gone)
- browse/test/security-review-fullstack.test.ts (review-flow E2E with real
  classifier — whole file gone)
- browse/test/security-review-sidepanel-e2e.test.ts (Playwright E2E for
  the security event banner that was ripped from sidepanel.html)
- browse/test/security-audit-r2.test.ts (5 describe blocks: agent queue
  permissions, isValidQueueEntry stateFile traversal, loadSession session-ID
  validation, switchChatTab DocumentFragment, pollChat reentrancy guard,
  /sidebar-tabs URL sanitization, sidebar-agent SIGTERM→SIGKILL escalation,
  AGENT_SRC top-level read converted to graceful fallback)
- browse/test/security-adversarial-fixes.test.ts (canary stream-chunk split
  detection on detectCanaryLeak; one tool-output test on sidebar-agent)
- test/skill-validation.test.ts (sidebar agent #584 describe block)

These all assumed sidebar-agent.ts existed and tested chat-queue plumbing,
chat-tab DOM round-trip, chat-polling reentrancy, or per-message classifier
canary detection. With the live PTY there is no chat queue, no chat tab,
no LLM stream to canary-scan, and no per-message subprocess. The Terminal
pane's invariants are covered by the new browse/test/sidebar-tabs.test.ts
(27 structural assertions), browse/test/terminal-agent.test.ts, and
browse/test/terminal-agent-integration.test.ts.

bun test → exit 0, 0 failures.

* chore: bump version and changelog (v1.14.0.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(extension): xterm fills the full Terminal panel height

The Terminal pane only rendered into the top portion of the panel — most
of the panel below the prompt was an empty black gap. Three layered
issues, all about xterm.js measuring dimensions during a layout state
that wasn't ready yet:

1. order-of-operations in connect(): ensureXterm() ran BEFORE
   setState(LIVE), so term.open() measured els.mount while it was still
   display:none. xterm caches a 0-size viewport synchronously inside
   open() and never auto-recovers when the container goes visible.
   Flipped: setState(LIVE) → ensureXterm.

2. first fit() ran synchronously before the browser had applied the
   .active class transition. Wrapped in requestAnimationFrame so layout
   has settled before fit() reads clientHeight.

3. CSS flex-overflow trap: .terminal-mount has flex:1 inside the
   flex-column #tab-terminal, but .tab-content's `overflow-y: auto` and
   the lack of `min-height: 0` on .terminal-mount meant the item
   couldn't shrink below content size. flex:1 then refused to expand
   into available space and xterm rendered into whatever its initial
   2x2 measurement happened to be.

Fixes:
- extension/sidepanel-terminal.js: reorder + RAF fit
- extension/sidepanel.css: .terminal-mount gets `flex: 1 1 0` +
  `min-height: 0` + `position: relative`. #tab-terminal overrides
  .tab-content's `overflow-y: auto` to `overflow: hidden` (xterm has
  its own viewport scroll; the parent shouldn't compete) and explicitly
  re-declares `display: flex; flex-direction: column` for #tab-terminal.active.

bun test browse/test/sidebar-tabs.test.ts → 27/27 pass.
Manually verified: side panel opens → Terminal fills full panel height,
xterm scrollback works, debug-tab toggle still repaints correctly.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:52:15 -07:00

604 lines
21 KiB
JavaScript

/**
* gstack browse — background service worker
*
* Polls /health every 10s to detect browse server.
* Fetches /refs on snapshot completion, relays to content script.
* Proxies commands from sidebar → browse server.
* Updates badge: amber (connected), gray (disconnected).
*/
const DEFAULT_PORT = 34567; // Well-known port used by `$B connect`
let serverPort = null;
let authToken = null;
let isConnected = false;
let healthInterval = null;
// ─── Port Discovery ────────────────────────────────────────────
async function loadPort() {
const data = await chrome.storage.local.get('port');
serverPort = data.port || DEFAULT_PORT;
return serverPort;
}
async function savePort(port) {
serverPort = port;
await chrome.storage.local.set({ port });
}
function getBaseUrl() {
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
}
// ─── Auth Token Bootstrap ─────────────────────────────────────
async function loadAuthToken() {
if (authToken) return;
// Get token from browse server /health endpoint (localhost-only, safe).
// Previously read from .auth.json in extension dir, but that breaks
// read-only .app bundles and codesigning.
const base = getBaseUrl();
if (!base) return;
try {
const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) });
if (resp.ok) {
const data = await resp.json();
if (data.token) authToken = data.token;
}
} catch (err) {
console.error('[gstack bg] Failed to load auth token:', err.message);
}
}
// ─── Health Polling ────────────────────────────────────────────
async function checkHealth() {
const base = getBaseUrl();
if (!base) {
setDisconnected();
return;
}
// Retry loading auth token if we don't have one yet
if (!authToken) await loadAuthToken();
try {
const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) });
if (!resp.ok) { setDisconnected(); return; }
const data = await resp.json();
if (data.status === 'healthy') {
// Always refresh auth token from /health — the server generates a new
// token on each restart, so the old one becomes stale.
if (data.token) authToken = data.token;
// Forward chatEnabled so sidepanel can show/hide chat tab
setConnected({ ...data, chatEnabled: !!data.chatEnabled });
} else {
setDisconnected();
}
} catch (err) {
console.error('[gstack bg] Health check failed:', err.message);
setDisconnected();
}
}
function setConnected(healthData) {
const wasDisconnected = !isConnected;
isConnected = true;
chrome.action.setBadgeBackgroundColor({ color: '#F59E0B' });
chrome.action.setBadgeText({ text: ' ' });
// Broadcast health to popup and side panel (token excluded — use getToken message instead)
chrome.runtime.sendMessage({ type: 'health', data: healthData }).catch((err) => {
console.debug('[gstack bg] No listener for health broadcast:', err.message);
});
// Notify content scripts on connection change
if (wasDisconnected) {
notifyContentScripts('connected');
}
}
function setDisconnected() {
const wasConnected = isConnected;
isConnected = false;
// Keep authToken — it persists across reconnections
chrome.action.setBadgeText({ text: '' });
chrome.runtime.sendMessage({ type: 'health', data: null }).catch((err) => {
console.debug('[gstack bg] No listener for disconnect broadcast:', err.message);
});
// Notify content scripts on disconnection
if (wasConnected) {
notifyContentScripts('disconnected');
}
}
async function notifyContentScripts(type) {
try {
const tabs = await chrome.tabs.query({});
for (const tab of tabs) {
if (tab.id) {
chrome.tabs.sendMessage(tab.id, { type }).catch(() => {
// Expected: tabs without content script
});
}
}
} catch (err) {
console.error('[gstack bg] Failed to query tabs for notification:', err.message);
}
}
// ─── Command Proxy ─────────────────────────────────────────────
async function executeCommand(command, args) {
const base = getBaseUrl();
if (!base || !authToken) {
return { error: 'Not connected to browse server' };
}
try {
const resp = await fetch(`${base}/command`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ command, args }),
signal: AbortSignal.timeout(30000),
});
const data = await resp.json();
return data;
} catch (err) {
return { error: err.message || 'Command failed' };
}
}
// ─── Refs Relay ─────────────────────────────────────────────────
async function fetchAndRelayRefs() {
const base = getBaseUrl();
if (!base || !isConnected) return;
try {
const headers = {};
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
const resp = await fetch(`${base}/refs`, { signal: AbortSignal.timeout(3000), headers });
if (!resp.ok) {
console.warn(`[gstack bg] Refs endpoint returned ${resp.status}`);
return;
}
const data = await resp.json();
// Send to all tabs' content scripts
const tabs = await chrome.tabs.query({});
for (const tab of tabs) {
if (tab.id) {
chrome.tabs.sendMessage(tab.id, { type: 'refs', data }).catch(() => {
// Expected: tabs without content script
});
}
}
} catch (err) {
console.error('[gstack bg] Failed to fetch/relay refs:', err.message);
}
}
// ─── Inspector ──────────────────────────────────────────────────
// Track inspector mode per tab — 'full' (inspector.js injected) or 'basic' (content.js fallback)
let inspectorMode = 'full';
async function injectInspector(tabId) {
// Try full inspector injection first
try {
await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
files: ['inspector.js'],
});
// CSS injection failure alone doesn't need fallback
try {
await chrome.scripting.insertCSS({
target: { tabId, allFrames: true },
files: ['inspector.css'],
});
} catch (err) {
console.debug('[gstack bg] Inspector CSS injection failed (non-fatal):', err.message);
}
// Send startPicker to the injected inspector.js
try {
await chrome.tabs.sendMessage(tabId, { type: 'startPicker' });
} catch (err) {
console.warn('[gstack bg] Failed to send startPicker:', err.message);
}
inspectorMode = 'full';
return { ok: true, mode: 'full' };
} catch (err) {
// Script injection failed (CSP, chrome:// page, etc.)
// Fall back to content.js basic picker (loaded by manifest on most pages)
try {
await chrome.tabs.sendMessage(tabId, { type: 'startBasicPicker' });
inspectorMode = 'basic';
return { ok: true, mode: 'basic' };
} catch (err2) {
console.error('[gstack bg] Inspector injection failed completely:', err.message, '| Basic fallback:', err2.message);
inspectorMode = 'full';
return { error: 'Cannot inspect this page' };
}
}
}
async function stopInspector(tabId) {
try {
await chrome.tabs.sendMessage(tabId, { type: 'stopPicker' });
} catch (err) {
console.debug('[gstack bg] Failed to stop picker on tab', tabId, ':', err.message);
}
return { ok: true };
}
async function postInspectorPick(selector, frameInfo, basicData, activeTabUrl) {
const base = getBaseUrl();
if (!base || !authToken) {
// No browse server — return basic data as fallback
return { mode: 'basic', selector, basicData, frameInfo };
}
try {
const resp = await fetch(`${base}/inspector/pick`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ selector, activeTabUrl, frameInfo }),
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) {
// Server error — fall back to basic mode
return { mode: 'basic', selector, basicData, frameInfo };
}
const data = await resp.json();
return { mode: 'cdp', ...data };
} catch (err) {
console.debug('[gstack bg] Inspector pick server unavailable, using basic mode:', err.message);
return { mode: 'basic', selector, basicData, frameInfo };
}
}
async function sendToContentScript(tabId, message) {
try {
const response = await chrome.tabs.sendMessage(tabId, message);
return response || { ok: true };
} catch {
return { error: 'Content script not available' };
}
}
// ─── Message Handling ──────────────────────────────────────────
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// Security: only accept messages from this extension's own scripts
if (sender.id !== chrome.runtime.id) {
console.warn('[gstack] Rejected message from unknown sender:', sender.id);
return;
}
const ALLOWED_TYPES = new Set([
'getPort', 'setPort', 'getServerUrl', 'getToken', 'fetchRefs',
'openSidePanel', 'sidebarOpened', 'command', 'sidebar-command',
'getTabState',
// Inspector message types
'startInspector', 'stopInspector', 'elementPicked', 'pickerCancelled',
'applyStyle', 'toggleClass', 'injectCSS', 'resetAll',
'inspectResult'
]);
if (!ALLOWED_TYPES.has(msg.type)) {
console.warn('[gstack] Rejected unknown message type:', msg.type);
return;
}
if (msg.type === 'getPort') {
sendResponse({ port: serverPort, connected: isConnected, token: authToken });
return true;
}
if (msg.type === 'getTabState') {
snapshotTabs().then(snap => sendResponse(snap || { active: null, tabs: [] }));
return true; // async sendResponse
}
if (msg.type === 'setPort') {
savePort(msg.port).then(() => {
checkHealth();
sendResponse({ ok: true });
});
return true;
}
if (msg.type === 'getServerUrl') {
sendResponse({ url: getBaseUrl() });
return true;
}
// Token delivered via targeted sendResponse, not broadcast — limits exposure.
// Only respond to extension pages (sidepanel/popup) — content scripts have
// sender.tab set, so reject those to prevent token access from injected contexts.
if (msg.type === 'getToken') {
if (sender.tab) {
console.warn('[gstack] Rejected getToken from content script context');
sendResponse({ token: null });
} else {
sendResponse({ token: authToken });
}
return true;
}
if (msg.type === 'fetchRefs') {
fetchAndRelayRefs().then(() => sendResponse({ ok: true }));
return true;
}
// Open side panel from content script pill click
if (msg.type === 'openSidePanel') {
if (chrome.sidePanel?.open && sender.tab) {
chrome.sidePanel.open({ tabId: sender.tab.id }).catch((err) => {
console.warn('[gstack bg] Failed to open side panel:', err.message);
});
}
return;
}
// Sidebar opened — tell active tab's content script so the welcome page
// can hide its arrow hint. Only fires when the sidebar actually connects.
if (msg.type === 'sidebarOpened') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tabId = tabs?.[0]?.id;
if (tabId) {
chrome.tabs.sendMessage(tabId, { type: 'sidebarOpened' }).catch(() => {
// Expected: tab may not have content script
});
}
});
return;
}
// Inspector: inject + start picker
if (msg.type === 'startInspector') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tabId = tabs?.[0]?.id;
if (!tabId) { sendResponse({ error: 'No active tab' }); return; }
injectInspector(tabId).then(result => sendResponse(result));
});
return true;
}
// Inspector: stop picker
if (msg.type === 'stopInspector') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tabId = tabs?.[0]?.id;
if (!tabId) { sendResponse({ error: 'No active tab' }); return; }
stopInspector(tabId).then(result => sendResponse(result));
});
return true;
}
// Inspector: element picked by content script
if (msg.type === 'elementPicked') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const activeTabUrl = tabs?.[0]?.url || null;
const frameInfo = msg.frameSrc ? { frameSrc: msg.frameSrc, frameName: msg.frameName } : null;
postInspectorPick(msg.selector, frameInfo, msg.basicData, activeTabUrl)
.then(result => {
// Forward enriched result to sidepanel
chrome.runtime.sendMessage({
type: 'inspectResult',
data: {
...result,
selector: msg.selector,
tagName: msg.tagName,
classes: msg.classes,
id: msg.id,
dimensions: msg.dimensions,
basicData: msg.basicData,
frameInfo,
},
}).catch((err) => {
console.warn('[gstack bg] Failed to forward inspectResult to sidepanel:', err.message);
});
sendResponse({ ok: true });
});
});
return true;
}
// Inspector: picker cancelled
if (msg.type === 'pickerCancelled') {
chrome.runtime.sendMessage({ type: 'pickerCancelled' }).catch((err) => {
console.debug('[gstack bg] No listener for pickerCancelled:', err.message);
});
return;
}
// Inspector: route alteration commands to content script
if (msg.type === 'applyStyle' || msg.type === 'toggleClass' || msg.type === 'injectCSS' || msg.type === 'resetAll') {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tabId = tabs?.[0]?.id;
if (!tabId) { sendResponse({ error: 'No active tab' }); return; }
sendToContentScript(tabId, msg).then(result => sendResponse(result));
});
return true;
}
// Sidebar → browse server command proxy
if (msg.type === 'command') {
executeCommand(msg.command, msg.args).then(result => sendResponse(result));
return true;
}
// Sidebar → Claude Code (file-based message queue)
if (msg.type === 'sidebar-command') {
const base = getBaseUrl();
if (!base || !authToken) {
sendResponse({ error: 'Not connected' });
return true;
}
// Capture the active tab's URL so the sidebar agent knows what page
// the user is actually looking at (Playwright's page.url() can be stale
// if the user navigated manually in headed mode).
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const activeTabUrl = tabs?.[0]?.url || null;
fetch(`${base}/sidebar-command`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ message: msg.message, activeTabUrl }),
})
.then(r => {
if (!r.ok) {
console.error(`[gstack bg] sidebar-command failed: ${r.status} ${r.statusText}`);
return r.json().catch(() => ({ error: `Server returned ${r.status}` }));
}
return r.json();
})
.then(data => sendResponse(data))
.catch(err => {
console.error('[gstack bg] sidebar-command error:', err.message);
sendResponse({ error: err.message });
});
});
return true;
}
});
// ─── Side Panel ─────────────────────────────────────────────────
// Click extension icon → open side panel directly (no popup)
if (chrome.sidePanel && chrome.sidePanel.setPanelBehavior) {
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch((err) => {
console.warn('[gstack bg] Failed to set panel behavior:', err.message);
});
}
// Auto-open side panel with retry. chrome.sidePanel.open() can fail silently
// if the window/tab isn't fully ready yet. Retry up to 5 times with backoff.
async function autoOpenSidePanel() {
if (!chrome.sidePanel?.open) return;
for (let attempt = 0; attempt < 5; attempt++) {
try {
const wins = await chrome.windows.getAll({ windowTypes: ['normal'] });
if (wins.length > 0) {
await chrome.sidePanel.open({ windowId: wins[0].id });
console.log(`[gstack] Side panel opened on attempt ${attempt + 1}`);
return; // success
}
} catch (e) {
// May throw if window isn't ready or user gesture required
console.log(`[gstack] Side panel open attempt ${attempt + 1} failed:`, e.message);
}
// Backoff: 500ms, 1000ms, 2000ms, 3000ms, 5000ms
await new Promise(r => setTimeout(r, [500, 1000, 2000, 3000, 5000][attempt]));
}
console.log('[gstack] Side panel auto-open failed after 5 attempts');
}
// Fire on install/update
chrome.runtime.onInstalled.addListener(() => {
autoOpenSidePanel();
});
// Fire on every service worker startup (covers persistent context reuse)
autoOpenSidePanel();
// ─── Tab Awareness ───────────────────────────────────────────────
// Push live tab state to the sidepanel so claude in the Terminal pane
// always has up-to-date tabs.json + active-tab.json on disk. The
// sidepanel relays these to terminal-agent.ts over the live WebSocket;
// terminal-agent writes the files for claude to read.
async function snapshotTabs() {
try {
const [active] = await chrome.tabs.query({ active: true, currentWindow: true });
const all = await chrome.tabs.query({});
const slim = all.map(t => ({
tabId: t.id,
url: t.url || '',
title: t.title || '',
active: !!t.active,
windowId: t.windowId,
pinned: !!t.pinned,
audible: !!t.audible,
}));
return {
active: active ? { tabId: active.id, url: active.url || '', title: active.title || '' } : null,
tabs: slim,
};
} catch {
return null;
}
}
async function pushTabState(reason) {
const snapshot = await snapshotTabs();
if (!snapshot) return;
chrome.runtime.sendMessage({
type: 'browserTabState',
reason,
...snapshot,
}).catch(() => {}); // expected: sidepanel may not be open
}
chrome.tabs.onActivated.addListener((activeInfo) => {
// Keep the legacy event for any consumer still listening to it (the chat
// path is gone but the message type is harmless), and also fire the new
// unified state push so claude's tabs.json reflects the new active tab.
chrome.tabs.get(activeInfo.tabId, (tab) => {
if (chrome.runtime.lastError || !tab) return;
chrome.runtime.sendMessage({
type: 'browserTabActivated',
tabId: activeInfo.tabId,
url: tab.url || '',
title: tab.title || '',
}).catch(() => {});
});
pushTabState('activated');
});
chrome.tabs.onCreated.addListener(() => pushTabState('created'));
chrome.tabs.onRemoved.addListener(() => pushTabState('removed'));
chrome.tabs.onUpdated.addListener((_id, changeInfo) => {
// Throttle: only re-push on URL or title changes, not on every loading
// tick. We don't want to spam claude with a state push every 50ms while
// a page loads.
if (changeInfo.url || changeInfo.title || changeInfo.status === 'complete') {
pushTabState('updated');
}
});
// ─── Startup ────────────────────────────────────────────────────
// Fast-retry health check on startup. The server may not be listening yet
// (Chromium launches before Bun.serve starts). Retry every 1s for the
// first 15 seconds, then switch to 10s polling.
loadAuthToken().then(() => {
loadPort().then(() => {
let startupAttempts = 0;
const startupCheck = setInterval(async () => {
startupAttempts++;
await checkHealth();
if (isConnected || startupAttempts >= 15) {
clearInterval(startupCheck);
// Switch to slow polling now that we're connected (or gave up)
if (!healthInterval) {
healthInterval = setInterval(checkHealth, 10000);
}
if (!isConnected) {
console.log('[gstack] Startup health checks failed after 15 attempts, falling back to 10s polling');
}
}
}, 1000);
});
});