From 5afc7c474cfcf5960c61c47cf0c13eee6ca6d574 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 19:42:06 -0700 Subject: [PATCH 1/3] perf(browse): cap implicit networkidle waits and bound watch-mode memory click/fill/select and end-of-chain waited up to 2000ms for networkidle, taxing every interaction on polling/analytics pages. Capped to a shared NETWORK_SETTLE_MS (500ms); idle pages still resolve instantly (~4x faster per action on never-idle pages). watch mode retained every 5s snapshot (~125 MB/hr) though only the last is ever shown; now keeps last + count, with a reentrancy guard against overlapping snapshot runs. Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/src/browser-manager.ts | 21 +++++++++++++------- browse/src/commands.ts | 7 +++++++ browse/src/meta-commands.ts | 12 ++++++------ browse/src/server.ts | 7 +++++++ browse/src/write-commands.ts | 16 +++++++++------ browse/test/watch.test.ts | 37 +++++++++++++++++++++-------------- 6 files changed, 66 insertions(+), 34 deletions(-) diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 5cdfbb8ece..4b51a49cc4 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -230,7 +230,10 @@ export class BrowserManager { // ─── Watch Mode ───────────────────────────────────────── private watching = false; public watchInterval: ReturnType | null = null; - private watchSnapshots: string[] = []; + // watch stop only ever shows the most recent snapshot, so we keep just that + // plus a count instead of retaining every 5s snapshot (~126 MB/hr of dead strings). + private lastWatchSnapshot: string | null = null; + private watchSnapshotCount = 0; private watchStartTime: number = 0; // ─── Headed State ──────────────────────────────────────── @@ -299,25 +302,29 @@ export class BrowserManager { startWatch(): void { this.watching = true; - this.watchSnapshots = []; + this.lastWatchSnapshot = null; + this.watchSnapshotCount = 0; this.watchStartTime = Date.now(); } - stopWatch(): { snapshots: string[]; duration: number } { + stopWatch(): { count: number; last: string | null; duration: number } { this.watching = false; if (this.watchInterval) { clearInterval(this.watchInterval); this.watchInterval = null; } - const snapshots = this.watchSnapshots; + const count = this.watchSnapshotCount; + const last = this.lastWatchSnapshot; const duration = Date.now() - this.watchStartTime; - this.watchSnapshots = []; + this.lastWatchSnapshot = null; + this.watchSnapshotCount = 0; this.watchStartTime = 0; - return { snapshots, duration }; + return { count, last, duration }; } addWatchSnapshot(snapshot: string): void { - this.watchSnapshots.push(snapshot); + this.lastWatchSnapshot = snapshot; + this.watchSnapshotCount++; } /** diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 73bc9ab1bf..0e8383e6f8 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -10,6 +10,13 @@ * Zero side effects. Safe to import from build scripts and tests. */ +// Implicit post-action settle window (click/fill/select and end-of-chain). +// networkidle resolves instantly on quiet pages, so this only caps the wait on +// pages holding a connection open (polling, analytics, websockets) — where the +// old 2000ms made every interaction feel slow. Explicit `wait --networkidle` +// stays available for a full wait. +export const NETWORK_SETTLE_MS = 500; + export const READ_COMMANDS = new Set([ 'text', 'html', 'links', 'forms', 'accessibility', 'js', 'eval', 'css', 'attrs', diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index 4cd2964922..8bc3b4c1a5 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -5,7 +5,7 @@ import type { BrowserManager } from './browser-manager'; import { handleSnapshot } from './snapshot'; import { getCleanText } from './read-commands'; -import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand } from './commands'; +import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, NETWORK_SETTLE_MS } from './commands'; import { handleDomainSkillCommand } from './domain-skill-commands'; import { handleSkillCommand } from './browser-skill-commands'; import { validateNavigationUrl } from './url-validation'; @@ -706,9 +706,9 @@ export async function handleMetaCommand( } } - // Wait for network to settle after write commands before returning + // Settle window after the last write in a chain (see NETWORK_SETTLE_MS). if (lastWasWrite) { - await bm.getPage().waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {}); + await bm.getPage().waitForLoadState('networkidle', { timeout: NETWORK_SETTLE_MS }).catch(() => {}); } return results.join('\n\n'); @@ -842,11 +842,11 @@ export async function handleMetaCommand( if (!bm.isWatching()) return 'Not currently watching.'; const result = bm.stopWatch(); const durationSec = Math.round(result.duration / 1000); - const lastSnapshot = result.snapshots.length > 0 - ? wrapUntrustedContent(result.snapshots[result.snapshots.length - 1], bm.getCurrentUrl()) + const lastSnapshot = result.last + ? wrapUntrustedContent(result.last, bm.getCurrentUrl()) : '(none)'; return [ - `WATCH STOPPED (${durationSec}s, ${result.snapshots.length} snapshots)`, + `WATCH STOPPED (${durationSec}s, ${result.count} snapshots)`, '', 'Last snapshot:', lastSnapshot, diff --git a/browse/src/server.ts b/browse/src/server.ts index ca7ef19eb6..7b49156a16 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -1154,16 +1154,23 @@ async function handleCommandInternalImpl( }); // Start periodic snapshot interval when watch mode begins if (command === 'watch' && args[0] !== 'stop' && browserManager.isWatching()) { + // A full interactive snapshot can take >1s on large pages; guard against + // overlapping runs piling up when a snapshot outlasts the 5s interval. + let snapshotInFlight = false; const watchInterval = setInterval(async () => { if (!browserManager.isWatching()) { clearInterval(watchInterval); return; } + if (snapshotInFlight) return; + snapshotInFlight = true; try { const snapshot = await handleSnapshot(['-i'], browserManager.getActiveSession()); browserManager.addWatchSnapshot(snapshot); } catch { // Page may be navigating — skip this snapshot + } finally { + snapshotInFlight = false; } }, 5000); browserManager.watchInterval = watchInterval; diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 626ba8794b..f2cb341550 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -19,6 +19,7 @@ import { TEMP_DIR, isPathWithin } from './platform'; import { SAFE_DIRECTORIES } from './path-security'; import { modifyStyle, undoModification, resetModifications, getModificationHistory } from './cdp-inspector'; import { withCdpSession } from './cdp-bridge'; +import { NETWORK_SETTLE_MS } from './commands'; /** * Aggressive page cleanup selectors and heuristics. @@ -374,8 +375,11 @@ export async function handleWriteCommand( } throw err; } - // Wait for network to settle (catches XHR/fetch triggered by clicks) - await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {}); + // Settle window for XHR/fetch triggered by the click. Idle pages resolve + // instantly; only busy/polling pages ever wait. Capped low so analytics- + // heavy apps that never reach networkidle don't tax every action 2s. + // Explicit `wait --networkidle` stays available when a full wait is needed. + await page.waitForLoadState('networkidle', { timeout: NETWORK_SETTLE_MS }).catch(() => {}); return `Clicked ${selector} → now at ${page.url()}`; } @@ -389,8 +393,8 @@ export async function handleWriteCommand( } else { await target.locator(resolved.selector).fill(value, { timeout: 5000 }); } - // Wait for network to settle (form validation XHRs) - await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {}); + // Settle window for form-validation XHRs (see NETWORK_SETTLE_MS note above). + await page.waitForLoadState('networkidle', { timeout: NETWORK_SETTLE_MS }).catch(() => {}); return `Filled ${selector}`; } @@ -404,8 +408,8 @@ export async function handleWriteCommand( } else { await target.locator(resolved.selector).selectOption(value, { timeout: 5000 }); } - // Wait for network to settle (dropdown-triggered requests) - await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {}); + // Settle window for dropdown-triggered requests (see NETWORK_SETTLE_MS note above). + await page.waitForLoadState('networkidle', { timeout: NETWORK_SETTLE_MS }).catch(() => {}); return `Selected "${value}" in ${selector}`; } diff --git a/browse/test/watch.test.ts b/browse/test/watch.test.ts index 7e03ced704..38144e5bb0 100644 --- a/browse/test/watch.test.ts +++ b/browse/test/watch.test.ts @@ -4,6 +4,9 @@ * Pure unit tests — no browser needed. Just instantiate BrowserManager * and test the watch state methods (startWatch, stopWatch, addWatchSnapshot, * isWatching). + * + * watch stop only ever displayed the latest snapshot, so the manager keeps + * just the last snapshot + a count rather than retaining every 5s capture. */ import { describe, test, expect } from 'bun:test'; @@ -21,7 +24,7 @@ describe('watch mode — state machine', () => { expect(bm.isWatching()).toBe(true); }); - test('stopWatch clears isWatching and returns snapshots', () => { + test('stopWatch clears isWatching and returns count + last snapshot', () => { const bm = new BrowserManager(); bm.startWatch(); bm.addWatchSnapshot('snapshot-1'); @@ -29,8 +32,8 @@ describe('watch mode — state machine', () => { const result = bm.stopWatch(); expect(bm.isWatching()).toBe(false); - expect(result.snapshots).toEqual(['snapshot-1', 'snapshot-2']); - expect(result.snapshots.length).toBe(2); + expect(result.count).toBe(2); + expect(result.last).toBe('snapshot-2'); }); test('stopWatch returns correct duration (approximately)', async () => { @@ -47,7 +50,7 @@ describe('watch mode — state machine', () => { expect(result.duration).toBeLessThan(5000); }); - test('addWatchSnapshot stores snapshots', () => { + test('addWatchSnapshot only retains the latest snapshot + count', () => { const bm = new BrowserManager(); bm.startWatch(); @@ -56,25 +59,25 @@ describe('watch mode — state machine', () => { bm.addWatchSnapshot('page C content'); const result = bm.stopWatch(); - expect(result.snapshots.length).toBe(3); - expect(result.snapshots[0]).toBe('page A content'); - expect(result.snapshots[1]).toBe('page B content'); - expect(result.snapshots[2]).toBe('page C content'); + expect(result.count).toBe(3); + expect(result.last).toBe('page C content'); }); - test('stopWatch resets snapshots for next cycle', () => { + test('stopWatch resets state for next cycle', () => { const bm = new BrowserManager(); // First cycle bm.startWatch(); bm.addWatchSnapshot('first-cycle-snapshot'); const result1 = bm.stopWatch(); - expect(result1.snapshots.length).toBe(1); + expect(result1.count).toBe(1); + expect(result1.last).toBe('first-cycle-snapshot'); // Second cycle — should start fresh bm.startWatch(); const result2 = bm.stopWatch(); - expect(result2.snapshots.length).toBe(0); + expect(result2.count).toBe(0); + expect(result2.last).toBeNull(); }); test('multiple start/stop cycles work correctly', () => { @@ -86,7 +89,8 @@ describe('watch mode — state machine', () => { bm.addWatchSnapshot('snap-1'); const r1 = bm.stopWatch(); expect(bm.isWatching()).toBe(false); - expect(r1.snapshots).toEqual(['snap-1']); + expect(r1.count).toBe(1); + expect(r1.last).toBe('snap-1'); // Cycle 2 bm.startWatch(); @@ -95,14 +99,16 @@ describe('watch mode — state machine', () => { bm.addWatchSnapshot('snap-2b'); const r2 = bm.stopWatch(); expect(bm.isWatching()).toBe(false); - expect(r2.snapshots).toEqual(['snap-2a', 'snap-2b']); + expect(r2.count).toBe(2); + expect(r2.last).toBe('snap-2b'); // Cycle 3 — no snapshots added bm.startWatch(); expect(bm.isWatching()).toBe(true); const r3 = bm.stopWatch(); expect(bm.isWatching()).toBe(false); - expect(r3.snapshots).toEqual([]); + expect(r3.count).toBe(0); + expect(r3.last).toBeNull(); }); test('stopWatch clears watchInterval if set', () => { @@ -122,7 +128,8 @@ describe('watch mode — state machine', () => { // Calling stopWatch without startWatch should not throw const result = bm.stopWatch(); - expect(result.snapshots).toEqual([]); + expect(result.count).toBe(0); + expect(result.last).toBeNull(); expect(result.duration).toBeLessThanOrEqual(Date.now()); // duration = now - 0 expect(bm.isWatching()).toBe(false); }); From da5ba9c0f34a9f99fb01e70e2c95f690ae47321f Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 19:42:06 -0700 Subject: [PATCH 2/3] chore(browse): remove the dead find-browse binary from the distribution find-browse (a ~61 MB compiled Bun binary, ~122 MB uncompressed) had no runtime caller; the CLI resolves the browse binary by explicit path. Removed the source, its test, and the shim, and unwired it from the build, the runtime install manifest, the install smoke check, two CI workflows, the e2e test helpers, and the architecture doc. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/make-pdf-gate.yml | 2 +- .github/workflows/windows-setup-e2e.yml | 1 - BROWSER.md | 1 - browse/bin/find-browse | 21 ----- browse/src/find-browse.ts | 110 ----------------------- browse/test/find-browse.test.ts | 61 ------------- runtime/install.js | 1 - scripts/build.sh | 3 +- scripts/gstack2/runtime-install-smoke.sh | 1 - test/helpers/e2e-helpers.ts | 8 +- test/skill-e2e.test.ts | 8 +- 11 files changed, 4 insertions(+), 213 deletions(-) delete mode 100755 browse/bin/find-browse delete mode 100644 browse/src/find-browse.ts delete mode 100644 browse/test/find-browse.test.ts diff --git a/.github/workflows/make-pdf-gate.yml b/.github/workflows/make-pdf-gate.yml index 69c4d7eb14..2abf3d10f0 100644 --- a/.github/workflows/make-pdf-gate.yml +++ b/.github/workflows/make-pdf-gate.yml @@ -74,7 +74,7 @@ jobs: - name: ad-hoc codesign (Apple Silicon) if: matrix.os == 'macos-latest' run: | - for bin in browse/dist/browse browse/dist/find-browse design/dist/design make-pdf/dist/pdf; do + for bin in browse/dist/browse design/dist/design make-pdf/dist/pdf; do codesign --remove-signature "$bin" 2>/dev/null || true codesign -s - -f "$bin" || true done diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml index 310566ce37..edf8f28631 100644 --- a/.github/workflows/windows-setup-e2e.yml +++ b/.github/workflows/windows-setup-e2e.yml @@ -23,7 +23,6 @@ on: - 'setup' - 'runtime/**' - 'browse/src/cli.ts' - - 'browse/src/find-browse.ts' - 'bin/gstack-paths' - '.github/workflows/windows-setup-e2e.yml' workflow_dispatch: diff --git a/BROWSER.md b/BROWSER.md index affa0447d1..072fa8581c 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -1258,7 +1258,6 @@ browse/ │ ├── error-handling.ts # safeUnlink / safeKill / isProcessAlive │ ├── platform.ts # OS detection (macOS, Linux, Windows) │ ├── telemetry.ts # Anonymous opt-in usage telemetry -│ ├── find-browse.ts # Locate running daemon or bootstrap │ └── config.ts # Config resolution (env / files) ├── test/ # Integration tests + HTML fixtures └── dist/ diff --git a/browse/bin/find-browse b/browse/bin/find-browse deleted file mode 100755 index 8f441b499c..0000000000 --- a/browse/bin/find-browse +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -# Shim: delegates to compiled find-browse binary, falls back to basic discovery. -# The compiled binary handles git root detection for workspace-local installs. -DIR="$(cd "$(dirname "$0")/.." && pwd)/dist" -if test -x "$DIR/find-browse"; then - exec "$DIR/find-browse" "$@" -fi -# Fallback: basic discovery with priority chain -ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -for MARKER in .codex .agents .claude; do - if [ -n "$ROOT" ] && test -x "$ROOT/$MARKER/skills/gstack/browse/dist/browse"; then - echo "$ROOT/$MARKER/skills/gstack/browse/dist/browse" - exit 0 - fi - if test -x "$HOME/$MARKER/skills/gstack/browse/dist/browse"; then - echo "$HOME/$MARKER/skills/gstack/browse/dist/browse" - exit 0 - fi -done -echo "ERROR: browse binary not found. Run: cd && ./setup" >&2 -exit 1 diff --git a/browse/src/find-browse.ts b/browse/src/find-browse.ts deleted file mode 100644 index ab9f6a54d2..0000000000 --- a/browse/src/find-browse.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * find-browse — locate the gstack browse binary. - * - * Compiled to browse/dist/find-browse (standalone binary, no bun runtime needed). - * Outputs the absolute path to the browse binary on stdout, or exits 1 if not found. - */ - -import { accessSync, constants } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; - -// ─── Binary Discovery ─────────────────────────────────────────── - -function getGitRoot(): string | null { - try { - const proc = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { - stdout: 'pipe', - stderr: 'pipe', - }); - if (proc.exitCode !== 0) return null; - return proc.stdout.toString().trim(); - } catch { - return null; - } -} - -// Probe a path for executability. accessSync(X_OK) checks the executable -// bit on Linux/macOS and degrades to an existence check on Windows (no -// true execute bit). Mirrors make-pdf/src/browseClient.ts:159 / -// make-pdf/src/pdftotext.ts:117. -function isExecutable(p: string): boolean { - try { - accessSync(p, constants.X_OK); - return true; - } catch { - return false; - } -} - -// Resolve a bare binary path to the actual file on disk. On Windows, `bun -// build --compile` appends `.exe` to the output filename, so `browse` on -// disk is actually `browse.exe`. After a bare-path probe, try the Windows -// extensions. Linux/macOS behavior is unchanged. Mirrors the helper in -// make-pdf/src/browseClient.ts:89 and make-pdf/src/pdftotext.ts:52. -function findExecutable(base: string): string | null { - if (isExecutable(base)) return base; - if (process.platform === 'win32') { - for (const ext of ['.exe', '.cmd', '.bat']) { - const withExt = base + ext; - if (isExecutable(withExt)) return withExt; - } - } - return null; -} - -export function locateBinary(): string | null { - const root = getGitRoot(); - const home = homedir(); - const markers = ['.codex', '.agents', '.claude']; - - // Workspace-local takes priority (for development) - if (root) { - for (const m of markers) { - const local = join(root, m, 'skills', 'gstack', 'browse', 'dist', 'browse'); - const found = findExecutable(local); - if (found) return found; - } - - // Source-checkout fallback (no installed skill layout — the binary - // lives directly at /browse/dist/browse[.exe]). Hit by: - // - gstack repo dev workflow before `./setup` runs - // - the windows-setup-e2e.yml CI workflow which builds binaries - // in place but never installs them under a marker dir - // - make-pdf consumers running from a sibling source checkout - const sourceCheckout = join(root, 'browse', 'dist', 'browse'); - const sourceFound = findExecutable(sourceCheckout); - if (sourceFound) return sourceFound; - } - - // Global fallback - for (const m of markers) { - const global = join(home, m, 'skills', 'gstack', 'browse', 'dist', 'browse'); - const found = findExecutable(global); - if (found) return found; - } - - return null; -} - -// ─── Main ─────────────────────────────────────────────────────── - -function main() { - const bin = locateBinary(); - if (!bin) { - process.stderr.write('ERROR: browse binary not found. Run: cd && ./setup\n'); - process.exit(1); - } - - console.log(bin); -} - -// Only run main() when this module is the entry point. Without this guard, -// any test that imports `locateBinary` from this file would have main() fire -// at module-load time, calling process.exit(1) when no compiled binary -// exists — killing the test process before any test runs. Surfaced on the -// windows-free-tests CI lane where the runner has no compiled browse -// binary (intentional — that lane only builds server-node.mjs). -if (import.meta.main) { - main(); -} diff --git a/browse/test/find-browse.test.ts b/browse/test/find-browse.test.ts deleted file mode 100644 index 333e09acd8..0000000000 --- a/browse/test/find-browse.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Tests for find-browse binary locator. - */ - -import { describe, test, expect } from 'bun:test'; -import { locateBinary } from '../src/find-browse'; -import { existsSync } from 'fs'; - -describe('locateBinary', () => { - test('returns null when no binary exists at known paths', () => { - // This test depends on the test environment — if a real binary exists at - // ~/.claude/skills/gstack/browse/dist/browse, it will find it. - // We mainly test that the function doesn't throw. - const result = locateBinary(); - expect(result === null || typeof result === 'string').toBe(true); - }); - - test('returns string path when binary exists', () => { - const result = locateBinary(); - if (result !== null) { - expect(existsSync(result)).toBe(true); - } - }); - - test('priority chain checks .codex, .agents, .claude markers', () => { - // Verify the source code implements the correct priority order. - // We read the function source to confirm the markers array order. - const src = require('fs').readFileSync(require('path').join(__dirname, '../src/find-browse.ts'), 'utf-8'); - // The markers array should list .codex first, then .agents, then .claude - const markersMatch = src.match(/const markers = \[([^\]]+)\]/); - expect(markersMatch).not.toBeNull(); - const markers = markersMatch![1]; - const codexIdx = markers.indexOf('.codex'); - const agentsIdx = markers.indexOf('.agents'); - const claudeIdx = markers.indexOf('.claude'); - // All three must be present - expect(codexIdx).toBeGreaterThanOrEqual(0); - expect(agentsIdx).toBeGreaterThanOrEqual(0); - expect(claudeIdx).toBeGreaterThanOrEqual(0); - // .codex before .agents before .claude - expect(codexIdx).toBeLessThan(agentsIdx); - expect(agentsIdx).toBeLessThan(claudeIdx); - }); - - test('function signature accepts no arguments', () => { - // locateBinary should be callable with no arguments - expect(typeof locateBinary).toBe('function'); - expect(locateBinary.length).toBe(0); - }); - - test('source-checkout fallback resolves /browse/dist/browse[.exe]', () => { - // The windows-setup-e2e.yml workflow builds binaries directly under - // browse/dist/ (no .claude/skills/gstack/ install layout). find-browse - // must resolve those — otherwise every fresh build that hasn't run - // ./setup yet looks broken. Static pin so a future refactor that - // drops the source-checkout branch trips this test. - const src = require('fs').readFileSync(require('path').join(__dirname, '../src/find-browse.ts'), 'utf-8'); - expect(src).toContain('Source-checkout fallback'); - expect(src).toContain("join(root, 'browse', 'dist', 'browse')"); - }); -}); diff --git a/runtime/install.js b/runtime/install.js index 9046a2a6f0..00b067a237 100644 --- a/runtime/install.js +++ b/runtime/install.js @@ -264,7 +264,6 @@ export const DEFAULT_RUNTIME_BUNDLE = Object.freeze([ ...RUNTIME_HELPER_INTERNALS.map((target) => entry(target, undefined, true)), ...RUNTIME_HELPER_DEPENDENCIES.map((target) => entry(target)), entry(platformBinary("browse/dist/browse"), "core", true), - entry(platformBinary("browse/dist/find-browse"), "core", true), entry("browse/dist/server-node.mjs", "core"), entry("browse/dist/bun-polyfill.cjs", "core"), entry("browse/dist/.version", "core"), diff --git a/scripts/build.sh b/scripts/build.sh index 0678a052c0..a5bbb78293 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -34,12 +34,11 @@ esac "$BUN_CMD" run vendor:xterm "$BUN_CMD" build --compile browse/src/cli.ts --outfile browse/dist/browse -"$BUN_CMD" build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse "$BUN_CMD" build --compile design/src/cli.ts --outfile design/dist/design "$BUN_CMD" build --compile make-pdf/src/cli.ts --outfile make-pdf/dist/pdf bash browse/scripts/build-node-server.sh bash scripts/write-version-files.sh browse/dist/.version design/dist/.version make-pdf/dist/.version -chmod +x browse/dist/browse browse/dist/find-browse design/dist/design make-pdf/dist/pdf +chmod +x browse/dist/browse design/dist/design make-pdf/dist/pdf if [ "$RUNTIME_ONLY" -eq 0 ]; then "$BUN_CMD" run gen:gstack2 "$BUN_CMD" run gen:skill-docs --host all diff --git a/scripts/gstack2/runtime-install-smoke.sh b/scripts/gstack2/runtime-install-smoke.sh index e987da746b..fbbd9a62dd 100755 --- a/scripts/gstack2/runtime-install-smoke.sh +++ b/scripts/gstack2/runtime-install-smoke.sh @@ -28,7 +28,6 @@ cp -R "$SOURCE/." "$REPO/" rm -rf "$REPO/node_modules" rm -f \ "$REPO/browse/dist/browse" "$REPO/browse/dist/browse.exe" \ - "$REPO/browse/dist/find-browse" "$REPO/browse/dist/find-browse.exe" \ "$REPO/design/dist/design" "$REPO/design/dist/design.exe" \ "$REPO/make-pdf/dist/pdf" "$REPO/make-pdf/dist/pdf.exe" diff --git a/test/helpers/e2e-helpers.ts b/test/helpers/e2e-helpers.ts index 32510f13ac..2ef07ebd18 100644 --- a/test/helpers/e2e-helpers.ts +++ b/test/helpers/e2e-helpers.ts @@ -103,7 +103,7 @@ export function copyDirSync(src: string, dest: string) { } /** - * Set up browse shims (binary symlink, find-browse, remote-slug) in a tmpDir. + * Set up browse shims (binary symlink, remote-slug) in a tmpDir. */ export function setupBrowseShims(dir: string) { // Symlink browse binary @@ -113,14 +113,8 @@ export function setupBrowseShims(dir: string) { fs.symlinkSync(browseBin, path.join(binDir, 'browse')); } - // find-browse shim const findBrowseDir = path.join(dir, 'browse', 'bin'); fs.mkdirSync(findBrowseDir, { recursive: true }); - fs.writeFileSync( - path.join(findBrowseDir, 'find-browse'), - `#!/bin/bash\necho "${browseBin}"\n`, - { mode: 0o755 }, - ); // remote-slug shim (returns test-project) fs.writeFileSync( diff --git a/test/skill-e2e.test.ts b/test/skill-e2e.test.ts index 312a609cca..a167dd2aea 100644 --- a/test/skill-e2e.test.ts +++ b/test/skill-e2e.test.ts @@ -107,7 +107,7 @@ function copyDirSync(src: string, dest: string) { } /** - * Set up browse shims (binary symlink, find-browse, remote-slug) in a tmpDir. + * Set up browse shims (binary symlink, remote-slug) in a tmpDir. */ function setupBrowseShims(dir: string) { // Symlink browse binary @@ -117,14 +117,8 @@ function setupBrowseShims(dir: string) { fs.symlinkSync(browseBin, path.join(binDir, 'browse')); } - // find-browse shim const findBrowseDir = path.join(dir, 'browse', 'bin'); fs.mkdirSync(findBrowseDir, { recursive: true }); - fs.writeFileSync( - path.join(findBrowseDir, 'find-browse'), - `#!/bin/bash\necho "${browseBin}"\n`, - { mode: 0o755 }, - ); // remote-slug shim (returns test-project) fs.writeFileSync( From d3ed3013f4952e2d83c5aa628ab426d0442f43c8 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 19:42:06 -0700 Subject: [PATCH 3/3] feat(browse): add Aside as a host-native browser provider Adds the Aside AI browser to the GStack 2 browser-provider contract (kind: dedicated-ai-browser), so browser QA can route to the user's real logged-in browser via the aside CLI (structured snapshot, console logs via page.console.logs(), and network via page.cdp). Regenerated the six BROWSER-PROVIDERS.md and updated the provider-contract test. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/gstack2/browser-provider-contract.ts | 19 ++++++++++++++++++- skills/debug/references/BROWSER-PROVIDERS.md | 18 ++++++++++++++++++ skills/design/references/BROWSER-PROVIDERS.md | 18 ++++++++++++++++++ skills/plan/references/BROWSER-PROVIDERS.md | 18 ++++++++++++++++++ skills/qa/references/BROWSER-PROVIDERS.md | 18 ++++++++++++++++++ skills/review/references/BROWSER-PROVIDERS.md | 18 ++++++++++++++++++ skills/ship/references/BROWSER-PROVIDERS.md | 18 ++++++++++++++++++ test/gstack2-browser-provider-setup.test.ts | 6 +++++- 8 files changed, 131 insertions(+), 2 deletions(-) diff --git a/scripts/gstack2/browser-provider-contract.ts b/scripts/gstack2/browser-provider-contract.ts index dca3e93959..a28f32bf59 100644 --- a/scripts/gstack2/browser-provider-contract.ts +++ b/scripts/gstack2/browser-provider-contract.ts @@ -4,11 +4,12 @@ export type BrowserProviderKind = | 'native-agent' | 'native-plugin' | 'native-mcp' + | 'dedicated-ai-browser' | 'extension-only' | 'no-native-automation'; export interface BrowserProviderContract { - id: 'claude' | 'codex' | 'gemini' | 'cursor' | 'github-copilot' | 'openclaw' | 'kimi' | 'pi'; + id: 'claude' | 'codex' | 'gemini' | 'cursor' | 'github-copilot' | 'openclaw' | 'kimi' | 'pi' | 'aside'; label: string; kind: BrowserProviderKind; setup: readonly string[]; @@ -145,6 +146,22 @@ export const BROWSER_PROVIDER_CONTRACTS: readonly BrowserProviderContract[] = [ ], unavailable: 'Offer GStack local browser, evidence-limited continuation, or deferral; never recommend or install a third-party Pi browser package automatically.', }, + { + id: 'aside', + label: 'Aside AI browser', + kind: 'dedicated-ai-browser', + setup: [ + 'Explain that Aside is a standalone AI browser driven from the terminal by the `aside` CLI, running the user\'s real browser with their logged-in accounts and cookies. `aside repl` is a persistent Playwright-compatible JavaScript surface for deterministic control and evidence; `aside exec` delegates a whole task to Aside\'s own agent; `aside mcp` exposes the same REPL over MCP.', + 'Confirm the `aside` CLI is callable in this session (for example `aside --version`) and that the user has an authenticated Aside account. Never sign in, attach an account, or drive the user\'s logged-in sessions without their explicit request; use neutral public pages and localhost for QA.', + 'Prefer `aside repl` for QA: it returns structured evidence (accessibility snapshot, screenshots, console logs, network) rather than a prose summary. Reserve `aside exec` for autonomous multi-step tasks the user explicitly delegates.', + ], + readiness: [ + 'The `aside` CLI is callable and reports a version, and a `aside repl` session can `openTab` and return a structured `snapshot`.', + 'Console evidence is available via `await page.console.logs()` (level, message, timestamp), and network evidence via `page.cdp` (attach the page target, `Network.enable`, then `requestWillBeSent`/`responseReceived` give URL, method, and status).', + 'The common local readiness journey completes end to end through `aside repl`: navigate, read, interact, capture console and network, and release the tab.', + ], + unavailable: 'Offer GStack local browser or defer; never treat the presence of the `aside` binary as readiness, and never drive the user\'s logged-in accounts without an explicit request.', + }, ] as const; export function renderBrowserProviderContract(): string { diff --git a/skills/debug/references/BROWSER-PROVIDERS.md b/skills/debug/references/BROWSER-PROVIDERS.md index 9f0e998c29..d05d967609 100644 --- a/skills/debug/references/BROWSER-PROVIDERS.md +++ b/skills/debug/references/BROWSER-PROVIDERS.md @@ -183,6 +183,24 @@ Readiness evidence: If unavailable: Offer GStack local browser, evidence-limited continuation, or deferral; never recommend or install a third-party Pi browser package automatically. +## Aside AI browser + +Classification: `dedicated-ai-browser` + +Setup: + +1. Explain that Aside is a standalone AI browser driven from the terminal by the `aside` CLI, running the user's real browser with their logged-in accounts and cookies. `aside repl` is a persistent Playwright-compatible JavaScript surface for deterministic control and evidence; `aside exec` delegates a whole task to Aside's own agent; `aside mcp` exposes the same REPL over MCP. +2. Confirm the `aside` CLI is callable in this session (for example `aside --version`) and that the user has an authenticated Aside account. Never sign in, attach an account, or drive the user's logged-in sessions without their explicit request; use neutral public pages and localhost for QA. +3. Prefer `aside repl` for QA: it returns structured evidence (accessibility snapshot, screenshots, console logs, network) rather than a prose summary. Reserve `aside exec` for autonomous multi-step tasks the user explicitly delegates. + +Readiness evidence: + +- The `aside` CLI is callable and reports a version, and a `aside repl` session can `openTab` and return a structured `snapshot`. +- Console evidence is available via `await page.console.logs()` (level, message, timestamp), and network evidence via `page.cdp` (attach the page target, `Network.enable`, then `requestWillBeSent`/`responseReceived` give URL, method, and status). +- The common local readiness journey completes end to end through `aside repl`: navigate, read, interact, capture console and network, and release the tab. + +If unavailable: Offer GStack local browser or defer; never treat the presence of the `aside` binary as readiness, and never drive the user's logged-in accounts without an explicit request. + ## GStack local browser fallback GStack's existing local Chromium/Playwright implementation remains the only bundled browser backend. Follow `RUNTIME.md` for preview and separate install consent, run `gstack doctor`, then run the same readiness journey with the `browse` launcher. Do not run `./setup`, add a cloud browser, remote provider, alternate local backend, or personal-profile attachment as a fallback. diff --git a/skills/design/references/BROWSER-PROVIDERS.md b/skills/design/references/BROWSER-PROVIDERS.md index 9f0e998c29..d05d967609 100644 --- a/skills/design/references/BROWSER-PROVIDERS.md +++ b/skills/design/references/BROWSER-PROVIDERS.md @@ -183,6 +183,24 @@ Readiness evidence: If unavailable: Offer GStack local browser, evidence-limited continuation, or deferral; never recommend or install a third-party Pi browser package automatically. +## Aside AI browser + +Classification: `dedicated-ai-browser` + +Setup: + +1. Explain that Aside is a standalone AI browser driven from the terminal by the `aside` CLI, running the user's real browser with their logged-in accounts and cookies. `aside repl` is a persistent Playwright-compatible JavaScript surface for deterministic control and evidence; `aside exec` delegates a whole task to Aside's own agent; `aside mcp` exposes the same REPL over MCP. +2. Confirm the `aside` CLI is callable in this session (for example `aside --version`) and that the user has an authenticated Aside account. Never sign in, attach an account, or drive the user's logged-in sessions without their explicit request; use neutral public pages and localhost for QA. +3. Prefer `aside repl` for QA: it returns structured evidence (accessibility snapshot, screenshots, console logs, network) rather than a prose summary. Reserve `aside exec` for autonomous multi-step tasks the user explicitly delegates. + +Readiness evidence: + +- The `aside` CLI is callable and reports a version, and a `aside repl` session can `openTab` and return a structured `snapshot`. +- Console evidence is available via `await page.console.logs()` (level, message, timestamp), and network evidence via `page.cdp` (attach the page target, `Network.enable`, then `requestWillBeSent`/`responseReceived` give URL, method, and status). +- The common local readiness journey completes end to end through `aside repl`: navigate, read, interact, capture console and network, and release the tab. + +If unavailable: Offer GStack local browser or defer; never treat the presence of the `aside` binary as readiness, and never drive the user's logged-in accounts without an explicit request. + ## GStack local browser fallback GStack's existing local Chromium/Playwright implementation remains the only bundled browser backend. Follow `RUNTIME.md` for preview and separate install consent, run `gstack doctor`, then run the same readiness journey with the `browse` launcher. Do not run `./setup`, add a cloud browser, remote provider, alternate local backend, or personal-profile attachment as a fallback. diff --git a/skills/plan/references/BROWSER-PROVIDERS.md b/skills/plan/references/BROWSER-PROVIDERS.md index 9f0e998c29..d05d967609 100644 --- a/skills/plan/references/BROWSER-PROVIDERS.md +++ b/skills/plan/references/BROWSER-PROVIDERS.md @@ -183,6 +183,24 @@ Readiness evidence: If unavailable: Offer GStack local browser, evidence-limited continuation, or deferral; never recommend or install a third-party Pi browser package automatically. +## Aside AI browser + +Classification: `dedicated-ai-browser` + +Setup: + +1. Explain that Aside is a standalone AI browser driven from the terminal by the `aside` CLI, running the user's real browser with their logged-in accounts and cookies. `aside repl` is a persistent Playwright-compatible JavaScript surface for deterministic control and evidence; `aside exec` delegates a whole task to Aside's own agent; `aside mcp` exposes the same REPL over MCP. +2. Confirm the `aside` CLI is callable in this session (for example `aside --version`) and that the user has an authenticated Aside account. Never sign in, attach an account, or drive the user's logged-in sessions without their explicit request; use neutral public pages and localhost for QA. +3. Prefer `aside repl` for QA: it returns structured evidence (accessibility snapshot, screenshots, console logs, network) rather than a prose summary. Reserve `aside exec` for autonomous multi-step tasks the user explicitly delegates. + +Readiness evidence: + +- The `aside` CLI is callable and reports a version, and a `aside repl` session can `openTab` and return a structured `snapshot`. +- Console evidence is available via `await page.console.logs()` (level, message, timestamp), and network evidence via `page.cdp` (attach the page target, `Network.enable`, then `requestWillBeSent`/`responseReceived` give URL, method, and status). +- The common local readiness journey completes end to end through `aside repl`: navigate, read, interact, capture console and network, and release the tab. + +If unavailable: Offer GStack local browser or defer; never treat the presence of the `aside` binary as readiness, and never drive the user's logged-in accounts without an explicit request. + ## GStack local browser fallback GStack's existing local Chromium/Playwright implementation remains the only bundled browser backend. Follow `RUNTIME.md` for preview and separate install consent, run `gstack doctor`, then run the same readiness journey with the `browse` launcher. Do not run `./setup`, add a cloud browser, remote provider, alternate local backend, or personal-profile attachment as a fallback. diff --git a/skills/qa/references/BROWSER-PROVIDERS.md b/skills/qa/references/BROWSER-PROVIDERS.md index 9f0e998c29..d05d967609 100644 --- a/skills/qa/references/BROWSER-PROVIDERS.md +++ b/skills/qa/references/BROWSER-PROVIDERS.md @@ -183,6 +183,24 @@ Readiness evidence: If unavailable: Offer GStack local browser, evidence-limited continuation, or deferral; never recommend or install a third-party Pi browser package automatically. +## Aside AI browser + +Classification: `dedicated-ai-browser` + +Setup: + +1. Explain that Aside is a standalone AI browser driven from the terminal by the `aside` CLI, running the user's real browser with their logged-in accounts and cookies. `aside repl` is a persistent Playwright-compatible JavaScript surface for deterministic control and evidence; `aside exec` delegates a whole task to Aside's own agent; `aside mcp` exposes the same REPL over MCP. +2. Confirm the `aside` CLI is callable in this session (for example `aside --version`) and that the user has an authenticated Aside account. Never sign in, attach an account, or drive the user's logged-in sessions without their explicit request; use neutral public pages and localhost for QA. +3. Prefer `aside repl` for QA: it returns structured evidence (accessibility snapshot, screenshots, console logs, network) rather than a prose summary. Reserve `aside exec` for autonomous multi-step tasks the user explicitly delegates. + +Readiness evidence: + +- The `aside` CLI is callable and reports a version, and a `aside repl` session can `openTab` and return a structured `snapshot`. +- Console evidence is available via `await page.console.logs()` (level, message, timestamp), and network evidence via `page.cdp` (attach the page target, `Network.enable`, then `requestWillBeSent`/`responseReceived` give URL, method, and status). +- The common local readiness journey completes end to end through `aside repl`: navigate, read, interact, capture console and network, and release the tab. + +If unavailable: Offer GStack local browser or defer; never treat the presence of the `aside` binary as readiness, and never drive the user's logged-in accounts without an explicit request. + ## GStack local browser fallback GStack's existing local Chromium/Playwright implementation remains the only bundled browser backend. Follow `RUNTIME.md` for preview and separate install consent, run `gstack doctor`, then run the same readiness journey with the `browse` launcher. Do not run `./setup`, add a cloud browser, remote provider, alternate local backend, or personal-profile attachment as a fallback. diff --git a/skills/review/references/BROWSER-PROVIDERS.md b/skills/review/references/BROWSER-PROVIDERS.md index 9f0e998c29..d05d967609 100644 --- a/skills/review/references/BROWSER-PROVIDERS.md +++ b/skills/review/references/BROWSER-PROVIDERS.md @@ -183,6 +183,24 @@ Readiness evidence: If unavailable: Offer GStack local browser, evidence-limited continuation, or deferral; never recommend or install a third-party Pi browser package automatically. +## Aside AI browser + +Classification: `dedicated-ai-browser` + +Setup: + +1. Explain that Aside is a standalone AI browser driven from the terminal by the `aside` CLI, running the user's real browser with their logged-in accounts and cookies. `aside repl` is a persistent Playwright-compatible JavaScript surface for deterministic control and evidence; `aside exec` delegates a whole task to Aside's own agent; `aside mcp` exposes the same REPL over MCP. +2. Confirm the `aside` CLI is callable in this session (for example `aside --version`) and that the user has an authenticated Aside account. Never sign in, attach an account, or drive the user's logged-in sessions without their explicit request; use neutral public pages and localhost for QA. +3. Prefer `aside repl` for QA: it returns structured evidence (accessibility snapshot, screenshots, console logs, network) rather than a prose summary. Reserve `aside exec` for autonomous multi-step tasks the user explicitly delegates. + +Readiness evidence: + +- The `aside` CLI is callable and reports a version, and a `aside repl` session can `openTab` and return a structured `snapshot`. +- Console evidence is available via `await page.console.logs()` (level, message, timestamp), and network evidence via `page.cdp` (attach the page target, `Network.enable`, then `requestWillBeSent`/`responseReceived` give URL, method, and status). +- The common local readiness journey completes end to end through `aside repl`: navigate, read, interact, capture console and network, and release the tab. + +If unavailable: Offer GStack local browser or defer; never treat the presence of the `aside` binary as readiness, and never drive the user's logged-in accounts without an explicit request. + ## GStack local browser fallback GStack's existing local Chromium/Playwright implementation remains the only bundled browser backend. Follow `RUNTIME.md` for preview and separate install consent, run `gstack doctor`, then run the same readiness journey with the `browse` launcher. Do not run `./setup`, add a cloud browser, remote provider, alternate local backend, or personal-profile attachment as a fallback. diff --git a/skills/ship/references/BROWSER-PROVIDERS.md b/skills/ship/references/BROWSER-PROVIDERS.md index 9f0e998c29..d05d967609 100644 --- a/skills/ship/references/BROWSER-PROVIDERS.md +++ b/skills/ship/references/BROWSER-PROVIDERS.md @@ -183,6 +183,24 @@ Readiness evidence: If unavailable: Offer GStack local browser, evidence-limited continuation, or deferral; never recommend or install a third-party Pi browser package automatically. +## Aside AI browser + +Classification: `dedicated-ai-browser` + +Setup: + +1. Explain that Aside is a standalone AI browser driven from the terminal by the `aside` CLI, running the user's real browser with their logged-in accounts and cookies. `aside repl` is a persistent Playwright-compatible JavaScript surface for deterministic control and evidence; `aside exec` delegates a whole task to Aside's own agent; `aside mcp` exposes the same REPL over MCP. +2. Confirm the `aside` CLI is callable in this session (for example `aside --version`) and that the user has an authenticated Aside account. Never sign in, attach an account, or drive the user's logged-in sessions without their explicit request; use neutral public pages and localhost for QA. +3. Prefer `aside repl` for QA: it returns structured evidence (accessibility snapshot, screenshots, console logs, network) rather than a prose summary. Reserve `aside exec` for autonomous multi-step tasks the user explicitly delegates. + +Readiness evidence: + +- The `aside` CLI is callable and reports a version, and a `aside repl` session can `openTab` and return a structured `snapshot`. +- Console evidence is available via `await page.console.logs()` (level, message, timestamp), and network evidence via `page.cdp` (attach the page target, `Network.enable`, then `requestWillBeSent`/`responseReceived` give URL, method, and status). +- The common local readiness journey completes end to end through `aside repl`: navigate, read, interact, capture console and network, and release the tab. + +If unavailable: Offer GStack local browser or defer; never treat the presence of the `aside` binary as readiness, and never drive the user's logged-in accounts without an explicit request. + ## GStack local browser fallback GStack's existing local Chromium/Playwright implementation remains the only bundled browser backend. Follow `RUNTIME.md` for preview and separate install consent, run `gstack doctor`, then run the same readiness journey with the `browse` launcher. Do not run `./setup`, add a cloud browser, remote provider, alternate local backend, or personal-profile attachment as a fallback. diff --git a/test/gstack2-browser-provider-setup.test.ts b/test/gstack2-browser-provider-setup.test.ts index e1109b776c..4cd59dd805 100644 --- a/test/gstack2-browser-provider-setup.test.ts +++ b/test/gstack2-browser-provider-setup.test.ts @@ -16,7 +16,7 @@ afterEach(async () => { }); describe("browser provider setup contract", () => { - test("defines every verified host plus Gemini without inventing Kimi or Pi core automation", () => { + test("defines every verified host plus Gemini and the Aside AI browser without inventing Kimi or Pi core automation", () => { expect(BROWSER_PROVIDER_CONTRACTS.map((provider) => provider.id)).toEqual([ "claude", "codex", @@ -26,6 +26,7 @@ describe("browser provider setup contract", () => { "openclaw", "kimi", "pi", + "aside", ]); expect(BROWSER_PROVIDER_CONTRACTS.map((provider) => provider.kind)).toEqual([ "native-extension", @@ -36,6 +37,7 @@ describe("browser provider setup contract", () => { "native-plugin", "no-native-automation", "extension-only", + "dedicated-ai-browser", ]); const rendered = renderBrowserProviderContract(); @@ -48,6 +50,8 @@ describe("browser provider setup contract", () => { expect(rendered).toContain("OpenClaw includes a browser plugin"); expect(rendered).toContain("Do not describe `kimi web` as browser automation"); expect(rendered).toContain("Pi intentionally has no core interactive browser tool"); + expect(rendered).toContain("Aside is a standalone AI browser driven from the terminal by the `aside` CLI"); + expect(rendered).toContain("await page.console.logs()"); expect(rendered).toContain("never silently add a browser MCP or alternate backend"); expect(rendered).toContain("GStack's existing local Chromium/Playwright implementation remains the only bundled browser backend"); expect(rendered).toContain("Do not run `./setup`");