diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts
index 5512888a..c250a5f7 100644
--- a/browse/src/browser-manager.ts
+++ b/browse/src/browser-manager.ts
@@ -166,25 +166,32 @@ export class BrowserManager {
launchArgs.push(`--load-extension=${extensionPath}`);
}
- // Launch real Chrome via Playwright's channel protocol
- // This uses the system Chrome binary, headed, with real window
- this.browser = await chromium.launch({
- channel: 'chrome',
+ // Launch headed Chromium via Playwright's persistent context.
+ // Extensions REQUIRE launchPersistentContext (not launch + newContext).
+ // Real Chrome (executablePath/channel) silently blocks --load-extension,
+ // so we use Playwright's bundled Chromium which reliably loads extensions.
+ const fs = require('fs');
+ const path = require('path');
+ const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
+ fs.mkdirSync(userDataDir, { recursive: true });
+
+ this.context = await chromium.launchPersistentContext(userDataDir, {
headless: false,
args: launchArgs,
+ viewport: null, // Use browser's default viewport (real window size)
+ // Playwright adds flags that block extension loading
+ ignoreDefaultArgs: [
+ '--disable-extensions',
+ '--disable-component-extensions-with-background-pages',
+ ],
});
+ this.browser = this.context.browser();
this.connectionMode = 'cdp';
this.intentionalDisconnect = false;
- // Create a context (channel:chrome doesn't have pre-existing contexts)
- const contextOptions: BrowserContextOptions = {
- viewport: null, // Use Chrome's default viewport (real window size)
- };
- this.context = await this.browser.newContext(contextOptions);
-
// Inject visual indicator — subtle top-edge gradient + floating pill
// so the user always knows which Chrome window gstack controls
- await this.context.addInitScript(() => {
+ const indicatorScript = () => {
const injectIndicator = () => {
if (document.getElementById('gstack-ctrl')) return;
@@ -193,7 +200,7 @@ export class BrowserManager {
topLine.id = 'gstack-ctrl';
topLine.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; height: 2px;
- background: linear-gradient(90deg, #4ade80, #22d3ee, #4ade80);
+ background: linear-gradient(90deg, #F59E0B, #FBBF24, #F59E0B);
background-size: 200% 100%;
animation: gstack-shimmer 3s linear infinite;
pointer-events: none; z-index: 2147483647;
@@ -208,17 +215,17 @@ export class BrowserManager {
z-index: 2147483647; pointer-events: none;
display: flex; align-items: center; gap: 5px;
padding: 4px 10px;
- background: rgba(0, 0, 0, 0.7);
+ background: rgba(12, 12, 12, 0.7);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
- border: 1px solid rgba(74, 222, 128, 0.25);
- border-radius: 100px;
+ border: 1px solid rgba(245, 158, 11, 0.25);
+ border-radius: 9999px;
font: 500 10px -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.03em;
transition: opacity 0.5s ease;
opacity: 1;
`;
- pill.innerHTML = 'gstack';
+ pill.innerHTML = 'gstack';
// Keyframe for shimmer animation
const style = document.createElement('style');
@@ -244,17 +251,31 @@ export class BrowserManager {
} else {
injectIndicator();
}
- });
+ };
+ await this.context.addInitScript(indicatorScript);
- // Create first tab
- await this.newTab();
+ // Persistent context opens a default page — adopt it instead of creating a new one
+ const existingPages = this.context.pages();
+ if (existingPages.length > 0) {
+ const page = existingPages[0];
+ const id = this.nextTabId++;
+ this.pages.set(id, page);
+ this.activeTabId = id;
+ this.wirePageEvents(page);
+ // Inject indicator on restored page (addInitScript only fires on new navigations)
+ try { await page.evaluate(indicatorScript); } catch {}
+ } else {
+ await this.newTab();
+ }
// Browser disconnect handler
- this.browser.on('disconnected', () => {
- if (this.intentionalDisconnect) return;
- console.error('[browse] Real browser disconnected.');
- process.exit(1);
- });
+ if (this.browser) {
+ this.browser.on('disconnected', () => {
+ if (this.intentionalDisconnect) return;
+ console.error('[browse] Real browser disconnected.');
+ process.exit(1);
+ });
+ }
// CDP-specific defaults
this.dialogAutoAccept = false; // Don't dismiss user's real dialogs
@@ -293,13 +314,13 @@ export class BrowserManager {
}
async close() {
- if (this.browser) {
+ if (this.browser || (this.connectionMode === 'cdp' && this.context)) {
if (this.connectionMode === 'cdp') {
- // CDP/channel:chrome mode: close the browser we launched
+ // CDP/persistent context mode: close the context (which closes the browser)
this.intentionalDisconnect = true;
- this.browser.removeAllListeners('disconnected');
+ if (this.browser) this.browser.removeAllListeners('disconnected');
await Promise.race([
- this.browser.close(),
+ this.context ? this.context.close() : Promise.resolve(),
new Promise(resolve => setTimeout(resolve, 5000)),
]).catch(() => {});
} else {
diff --git a/browse/src/cli.ts b/browse/src/cli.ts
index b715a6c7..065450a0 100644
--- a/browse/src/cli.ts
+++ b/browse/src/cli.ts
@@ -365,9 +365,11 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
console.log('Launching real Chrome browser...');
try {
// Start server with CDP flag — server.ts will use channel:chrome
+ // Use a well-known port so the Chrome extension auto-connects
const newState = await startServer({
BROWSE_CDP_URL: 'channel:chrome',
BROWSE_CDP_PORT: '0',
+ BROWSE_PORT: '34567',
});
// Print connected status