feat: $B state save/load + $B frame — new browse commands

- state save/load: persist cookies + URLs to .gstack/browse-states/{name}.json
  File perms 0o600, name sanitized to [a-zA-Z0-9_-]. V1 skips localStorage
  (breaks on load-before-navigate). Load replaces session via closeAllPages().
- frame: switch command context to iframe via CSS selector, @ref, --name, or
  --url. 'frame main' returns to main frame. Execution target abstraction
  (getActiveFrameOrPage) across read-commands, snapshot, and write-commands.
- Frame context cleared on tab switch, navigation, resume, and handoff.
- Snapshot shows [Context: iframe src="..."] header when in frame.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-26 00:46:56 -06:00
parent e497d996c5
commit 5c6cbeaeff
4 changed files with 76 additions and 18 deletions
+35
View File
@@ -402,6 +402,7 @@ export class BrowserManager {
switchTab(id: number): void {
if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`);
this.activeTabId = id;
this.activeFrame = null; // Frame context is per-tab
}
getTabCount(): number {
@@ -531,6 +532,38 @@ export class BrowserManager {
return this.customUserAgent;
}
// ─── Lifecycle helpers ───────────────────────────────
/**
* Close all open pages and clear the pages map.
* Used by state load to replace the current session.
*/
async closeAllPages(): Promise<void> {
for (const page of this.pages.values()) {
await page.close().catch(() => {});
}
this.pages.clear();
this.clearRefs();
}
// ─── Frame context ─────────────────────────────────
private activeFrame: import('playwright').Frame | null = null;
setFrame(frame: import('playwright').Frame | null): void {
this.activeFrame = frame;
}
getFrame(): import('playwright').Frame | null {
return this.activeFrame;
}
/**
* Returns the active frame if set, otherwise the current page.
* Use this for operations that work on both Page and Frame (locator, evaluate, etc.).
*/
getActiveFrameOrPage(): import('playwright').Page | import('playwright').Frame {
return this.activeFrame ?? this.getPage();
}
// ─── State Save/Restore (shared by recreateContext + handoff) ─
/**
* Capture browser state: cookies, localStorage, sessionStorage, URLs, active tab.
@@ -789,6 +822,7 @@ export class BrowserManager {
resume(): void {
this.clearRefs();
this.resetFailures();
this.activeFrame = null;
}
getIsHeaded(): boolean {
@@ -818,6 +852,7 @@ export class BrowserManager {
page.on('framenavigated', (frame) => {
if (frame === page.mainFrame()) {
this.clearRefs();
this.activeFrame = null; // Navigation invalidates frame context
}
});
+6
View File
@@ -34,6 +34,8 @@ export const META_COMMANDS = new Set([
'connect', 'disconnect', 'focus',
'inbox',
'watch',
'state',
'frame',
]);
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
@@ -109,6 +111,10 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
'inbox': { category: 'Meta', description: 'List messages from sidebar scout inbox', usage: 'inbox [--clear]' },
// Watch
'watch': { category: 'Meta', description: 'Passive observation — periodic snapshots while user browses', usage: 'watch [stop]' },
// State
'state': { category: 'Server', description: 'Save/load browser state (cookies + URLs)', usage: 'state save|load <name>' },
// Frame
'frame': { category: 'Meta', description: 'Switch to iframe context (or main to return)', usage: 'frame <sel|@ref|--name n|--url pattern|main>' },
};
// Load-time validation: descriptions must cover exactly the command sets
+23 -15
View File
@@ -7,7 +7,7 @@
import type { BrowserManager } from './browser-manager';
import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
import type { Page } from 'playwright';
import type { Page, Frame } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
import { TEMP_DIR, isPathWithin } from './platform';
@@ -57,7 +57,7 @@ export function validateReadPath(filePath: string): void {
* Extract clean text from a page (strips script/style/noscript/svg).
* Exported for DRY reuse in meta-commands (diff).
*/
export async function getCleanText(page: Page): Promise<string> {
export async function getCleanText(page: Page | Frame): Promise<string> {
return await page.evaluate(() => {
const body = document.body;
if (!body) return '';
@@ -77,10 +77,12 @@ export async function handleReadCommand(
bm: BrowserManager
): Promise<string> {
const page = bm.getPage();
// Frame-aware target for content extraction
const target = bm.getActiveFrameOrPage();
switch (command) {
case 'text': {
return await getCleanText(page);
return await getCleanText(target);
}
case 'html': {
@@ -90,13 +92,19 @@ export async function handleReadCommand(
if ('locator' in resolved) {
return await resolved.locator.innerHTML({ timeout: 5000 });
}
return await page.innerHTML(resolved.selector);
return await target.locator(resolved.selector).innerHTML({ timeout: 5000 });
}
return await page.content();
// page.content() is page-only; use evaluate for frame compat
const doctype = await target.evaluate(() => {
const dt = document.doctype;
return dt ? `<!DOCTYPE ${dt.name}>` : '';
});
const html = await target.evaluate(() => document.documentElement.outerHTML);
return doctype ? `${doctype}\n${html}` : html;
}
case 'links': {
const links = await page.evaluate(() =>
const links = await target.evaluate(() =>
[...document.querySelectorAll('a[href]')].map(a => ({
text: a.textContent?.trim().slice(0, 120) || '',
href: (a as HTMLAnchorElement).href,
@@ -106,7 +114,7 @@ export async function handleReadCommand(
}
case 'forms': {
const forms = await page.evaluate(() => {
const forms = await target.evaluate(() => {
return [...document.querySelectorAll('form')].map((form, i) => {
const fields = [...form.querySelectorAll('input, select, textarea')].map(el => {
const input = el as HTMLInputElement;
@@ -136,7 +144,7 @@ export async function handleReadCommand(
}
case 'accessibility': {
const snapshot = await page.locator("body").ariaSnapshot();
const snapshot = await target.locator("body").ariaSnapshot();
return snapshot;
}
@@ -144,7 +152,7 @@ export async function handleReadCommand(
const expr = args[0];
if (!expr) throw new Error('Usage: browse js <expression>');
const wrapped = wrapForEvaluate(expr);
const result = await page.evaluate(wrapped);
const result = await target.evaluate(wrapped);
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
}
@@ -155,7 +163,7 @@ export async function handleReadCommand(
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
const code = fs.readFileSync(filePath, 'utf-8');
const wrapped = wrapForEvaluate(code);
const result = await page.evaluate(wrapped);
const result = await target.evaluate(wrapped);
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
}
@@ -170,7 +178,7 @@ export async function handleReadCommand(
);
return value;
}
const value = await page.evaluate(
const value = await target.evaluate(
([sel, prop]) => {
const el = document.querySelector(sel);
if (!el) return `Element not found: ${sel}`;
@@ -195,7 +203,7 @@ export async function handleReadCommand(
});
return JSON.stringify(attrs, null, 2);
}
const attrs = await page.evaluate((sel) => {
const attrs = await target.evaluate((sel: string) => {
const el = document.querySelector(sel);
if (!el) return `Element not found: ${sel}`;
const result: Record<string, string> = {};
@@ -253,7 +261,7 @@ export async function handleReadCommand(
if ('locator' in resolved) {
locator = resolved.locator;
} else {
locator = page.locator(resolved.selector);
locator = target.locator(resolved.selector);
}
switch (property) {
@@ -283,10 +291,10 @@ export async function handleReadCommand(
if (args[0] === 'set' && args[1]) {
const key = args[1];
const value = args[2] || '';
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
await target.evaluate(([k, v]: string[]) => localStorage.setItem(k, v), [key, value]);
return `Set localStorage["${key}"]`;
}
const storage = await page.evaluate(() => ({
const storage = await target.evaluate(() => ({
localStorage: { ...localStorage },
sessionStorage: { ...sessionStorage },
}));
+12 -3
View File
@@ -17,7 +17,7 @@
* Later: "click @e3" → look up Locator → locator.click()
*/
import type { Page, Locator } from 'playwright';
import type { Page, Frame, Locator } from 'playwright';
import type { BrowserManager, RefEntry } from './browser-manager';
import * as Diff from 'diff';
import { TEMP_DIR, isPathWithin } from './platform';
@@ -136,15 +136,18 @@ export async function handleSnapshot(
): Promise<string> {
const opts = parseSnapshotArgs(args);
const page = bm.getPage();
// Frame-aware target for accessibility tree
const target = bm.getActiveFrameOrPage();
const inFrame = bm.getFrame() !== null;
// Get accessibility tree via ariaSnapshot
let rootLocator: Locator;
if (opts.selector) {
rootLocator = page.locator(opts.selector);
rootLocator = target.locator(opts.selector);
const count = await rootLocator.count();
if (count === 0) throw new Error(`Selector not found: ${opts.selector}`);
} else {
rootLocator = page.locator('body');
rootLocator = target.locator('body');
}
const ariaText = await rootLocator.ariaSnapshot();
@@ -394,5 +397,11 @@ export async function handleSnapshot(
// Store for future diffs
bm.setLastSnapshot(snapshotText);
// Add frame context header when operating inside an iframe
if (inFrame) {
const frameUrl = bm.getFrame()?.url() ?? 'unknown';
output.unshift(`[Context: iframe src="${frameUrl}"]`);
}
return output.join('\n');
}