@runchr/gstack-antigravity 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of @runchr/gstack-antigravity might be problematic. Click here for more details.
- package/.agents/skills/gstack/.agents/skills/gstack/SKILL.md +651 -0
- package/.agents/skills/gstack/.agents/skills/gstack-autoplan/SKILL.md +678 -0
- package/.agents/skills/gstack/.agents/skills/gstack-benchmark/SKILL.md +482 -0
- package/.agents/skills/gstack/.agents/skills/gstack-browse/SKILL.md +511 -0
- package/.agents/skills/gstack/.agents/skills/gstack-canary/SKILL.md +486 -0
- package/.agents/skills/gstack/.agents/skills/gstack-careful/SKILL.md +50 -0
- package/.agents/skills/gstack/.agents/skills/gstack-cso/SKILL.md +607 -0
- package/.agents/skills/gstack/.agents/skills/gstack-design-consultation/SKILL.md +615 -0
- package/.agents/skills/gstack/.agents/skills/gstack-design-review/SKILL.md +988 -0
- package/.agents/skills/gstack/.agents/skills/gstack-document-release/SKILL.md +604 -0
- package/.agents/skills/gstack/.agents/skills/gstack-freeze/SKILL.md +67 -0
- package/.agents/skills/gstack/.agents/skills/gstack-guard/SKILL.md +62 -0
- package/.agents/skills/gstack/.agents/skills/gstack-investigate/SKILL.md +415 -0
- package/.agents/skills/gstack/.agents/skills/gstack-land-and-deploy/SKILL.md +873 -0
- package/.agents/skills/gstack/.agents/skills/gstack-office-hours/SKILL.md +986 -0
- package/.agents/skills/gstack/.agents/skills/gstack-plan-ceo-review/SKILL.md +1268 -0
- package/.agents/skills/gstack/.agents/skills/gstack-plan-design-review/SKILL.md +668 -0
- package/.agents/skills/gstack/.agents/skills/gstack-plan-eng-review/SKILL.md +826 -0
- package/.agents/skills/gstack/.agents/skills/gstack-qa/SKILL.md +1006 -0
- package/.agents/skills/gstack/.agents/skills/gstack-qa-only/SKILL.md +626 -0
- package/.agents/skills/gstack/.agents/skills/gstack-retro/SKILL.md +1065 -0
- package/.agents/skills/gstack/.agents/skills/gstack-review/SKILL.md +704 -0
- package/.agents/skills/gstack/.agents/skills/gstack-setup-browser-cookies/SKILL.md +325 -0
- package/.agents/skills/gstack/.agents/skills/gstack-setup-deploy/SKILL.md +450 -0
- package/.agents/skills/gstack/.agents/skills/gstack-ship/SKILL.md +1312 -0
- package/.agents/skills/gstack/.agents/skills/gstack-unfreeze/SKILL.md +36 -0
- package/.agents/skills/gstack/.agents/skills/gstack-upgrade/SKILL.md +220 -0
- package/.agents/skills/gstack/.env.example +5 -0
- package/.agents/skills/gstack/.github/workflows/skill-docs.yml +17 -0
- package/.agents/skills/gstack/AGENTS.md +49 -0
- package/.agents/skills/gstack/ARCHITECTURE.md +359 -0
- package/.agents/skills/gstack/BROWSER.md +271 -0
- package/.agents/skills/gstack/CHANGELOG.md +800 -0
- package/.agents/skills/gstack/CLAUDE.md +284 -0
- package/.agents/skills/gstack/CONTRIBUTING.md +370 -0
- package/.agents/skills/gstack/ETHOS.md +129 -0
- package/.agents/skills/gstack/LICENSE +21 -0
- package/.agents/skills/gstack/README.md +228 -0
- package/.agents/skills/gstack/SKILL.md +657 -0
- package/.agents/skills/gstack/SKILL.md.tmpl +281 -0
- package/.agents/skills/gstack/TODOS.md +564 -0
- package/.agents/skills/gstack/VERSION +1 -0
- package/.agents/skills/gstack/autoplan/SKILL.md +689 -0
- package/.agents/skills/gstack/autoplan/SKILL.md.tmpl +416 -0
- package/.agents/skills/gstack/benchmark/SKILL.md +489 -0
- package/.agents/skills/gstack/benchmark/SKILL.md.tmpl +233 -0
- package/.agents/skills/gstack/bin/dev-setup +68 -0
- package/.agents/skills/gstack/bin/dev-teardown +56 -0
- package/.agents/skills/gstack/bin/gstack-analytics +191 -0
- package/.agents/skills/gstack/bin/gstack-community-dashboard +113 -0
- package/.agents/skills/gstack/bin/gstack-config +38 -0
- package/.agents/skills/gstack/bin/gstack-diff-scope +71 -0
- package/.agents/skills/gstack/bin/gstack-global-discover.ts +591 -0
- package/.agents/skills/gstack/bin/gstack-repo-mode +93 -0
- package/.agents/skills/gstack/bin/gstack-review-log +9 -0
- package/.agents/skills/gstack/bin/gstack-review-read +12 -0
- package/.agents/skills/gstack/bin/gstack-slug +15 -0
- package/.agents/skills/gstack/bin/gstack-telemetry-log +158 -0
- package/.agents/skills/gstack/bin/gstack-telemetry-sync +127 -0
- package/.agents/skills/gstack/bin/gstack-update-check +196 -0
- package/.agents/skills/gstack/browse/SKILL.md +517 -0
- package/.agents/skills/gstack/browse/SKILL.md.tmpl +141 -0
- package/.agents/skills/gstack/browse/bin/find-browse +21 -0
- package/.agents/skills/gstack/browse/bin/remote-slug +14 -0
- package/.agents/skills/gstack/browse/scripts/build-node-server.sh +48 -0
- package/.agents/skills/gstack/browse/src/browser-manager.ts +634 -0
- package/.agents/skills/gstack/browse/src/buffers.ts +137 -0
- package/.agents/skills/gstack/browse/src/bun-polyfill.cjs +109 -0
- package/.agents/skills/gstack/browse/src/cli.ts +420 -0
- package/.agents/skills/gstack/browse/src/commands.ts +111 -0
- package/.agents/skills/gstack/browse/src/config.ts +150 -0
- package/.agents/skills/gstack/browse/src/cookie-import-browser.ts +417 -0
- package/.agents/skills/gstack/browse/src/cookie-picker-routes.ts +207 -0
- package/.agents/skills/gstack/browse/src/cookie-picker-ui.ts +541 -0
- package/.agents/skills/gstack/browse/src/find-browse.ts +61 -0
- package/.agents/skills/gstack/browse/src/meta-commands.ts +269 -0
- package/.agents/skills/gstack/browse/src/platform.ts +17 -0
- package/.agents/skills/gstack/browse/src/read-commands.ts +335 -0
- package/.agents/skills/gstack/browse/src/server.ts +369 -0
- package/.agents/skills/gstack/browse/src/snapshot.ts +398 -0
- package/.agents/skills/gstack/browse/src/url-validation.ts +91 -0
- package/.agents/skills/gstack/browse/src/write-commands.ts +352 -0
- package/.agents/skills/gstack/browse/test/bun-polyfill.test.ts +72 -0
- package/.agents/skills/gstack/browse/test/commands.test.ts +1836 -0
- package/.agents/skills/gstack/browse/test/config.test.ts +250 -0
- package/.agents/skills/gstack/browse/test/cookie-import-browser.test.ts +397 -0
- package/.agents/skills/gstack/browse/test/cookie-picker-routes.test.ts +205 -0
- package/.agents/skills/gstack/browse/test/find-browse.test.ts +50 -0
- package/.agents/skills/gstack/browse/test/fixtures/basic.html +33 -0
- package/.agents/skills/gstack/browse/test/fixtures/cursor-interactive.html +22 -0
- package/.agents/skills/gstack/browse/test/fixtures/dialog.html +15 -0
- package/.agents/skills/gstack/browse/test/fixtures/empty.html +2 -0
- package/.agents/skills/gstack/browse/test/fixtures/forms.html +55 -0
- package/.agents/skills/gstack/browse/test/fixtures/qa-eval-checkout.html +108 -0
- package/.agents/skills/gstack/browse/test/fixtures/qa-eval-spa.html +98 -0
- package/.agents/skills/gstack/browse/test/fixtures/qa-eval.html +51 -0
- package/.agents/skills/gstack/browse/test/fixtures/responsive.html +49 -0
- package/.agents/skills/gstack/browse/test/fixtures/snapshot.html +55 -0
- package/.agents/skills/gstack/browse/test/fixtures/spa.html +24 -0
- package/.agents/skills/gstack/browse/test/fixtures/states.html +17 -0
- package/.agents/skills/gstack/browse/test/fixtures/upload.html +25 -0
- package/.agents/skills/gstack/browse/test/gstack-config.test.ts +125 -0
- package/.agents/skills/gstack/browse/test/gstack-update-check.test.ts +467 -0
- package/.agents/skills/gstack/browse/test/handoff.test.ts +235 -0
- package/.agents/skills/gstack/browse/test/path-validation.test.ts +63 -0
- package/.agents/skills/gstack/browse/test/platform.test.ts +37 -0
- package/.agents/skills/gstack/browse/test/snapshot.test.ts +467 -0
- package/.agents/skills/gstack/browse/test/test-server.ts +57 -0
- package/.agents/skills/gstack/browse/test/url-validation.test.ts +72 -0
- package/.agents/skills/gstack/canary/SKILL.md +493 -0
- package/.agents/skills/gstack/canary/SKILL.md.tmpl +220 -0
- package/.agents/skills/gstack/careful/SKILL.md +59 -0
- package/.agents/skills/gstack/careful/SKILL.md.tmpl +57 -0
- package/.agents/skills/gstack/careful/bin/check-careful.sh +112 -0
- package/.agents/skills/gstack/codex/SKILL.md +677 -0
- package/.agents/skills/gstack/codex/SKILL.md.tmpl +356 -0
- package/.agents/skills/gstack/conductor.json +6 -0
- package/.agents/skills/gstack/cso/SKILL.md +615 -0
- package/.agents/skills/gstack/cso/SKILL.md.tmpl +376 -0
- package/.agents/skills/gstack/design-consultation/SKILL.md +625 -0
- package/.agents/skills/gstack/design-consultation/SKILL.md.tmpl +369 -0
- package/.agents/skills/gstack/design-review/SKILL.md +998 -0
- package/.agents/skills/gstack/design-review/SKILL.md.tmpl +262 -0
- package/.agents/skills/gstack/docs/images/github-2013.png +0 -0
- package/.agents/skills/gstack/docs/images/github-2026.png +0 -0
- package/.agents/skills/gstack/docs/skills.md +877 -0
- package/.agents/skills/gstack/document-release/SKILL.md +613 -0
- package/.agents/skills/gstack/document-release/SKILL.md.tmpl +357 -0
- package/.agents/skills/gstack/freeze/SKILL.md +82 -0
- package/.agents/skills/gstack/freeze/SKILL.md.tmpl +80 -0
- package/.agents/skills/gstack/freeze/bin/check-freeze.sh +68 -0
- package/.agents/skills/gstack/gstack-upgrade/SKILL.md +226 -0
- package/.agents/skills/gstack/gstack-upgrade/SKILL.md.tmpl +224 -0
- package/.agents/skills/gstack/guard/SKILL.md +82 -0
- package/.agents/skills/gstack/guard/SKILL.md.tmpl +80 -0
- package/.agents/skills/gstack/investigate/SKILL.md +435 -0
- package/.agents/skills/gstack/investigate/SKILL.md.tmpl +196 -0
- package/.agents/skills/gstack/land-and-deploy/SKILL.md +880 -0
- package/.agents/skills/gstack/land-and-deploy/SKILL.md.tmpl +575 -0
- package/.agents/skills/gstack/office-hours/SKILL.md +996 -0
- package/.agents/skills/gstack/office-hours/SKILL.md.tmpl +624 -0
- package/.agents/skills/gstack/package.json +55 -0
- package/.agents/skills/gstack/plan-ceo-review/SKILL.md +1277 -0
- package/.agents/skills/gstack/plan-ceo-review/SKILL.md.tmpl +838 -0
- package/.agents/skills/gstack/plan-design-review/SKILL.md +676 -0
- package/.agents/skills/gstack/plan-design-review/SKILL.md.tmpl +314 -0
- package/.agents/skills/gstack/plan-eng-review/SKILL.md +836 -0
- package/.agents/skills/gstack/plan-eng-review/SKILL.md.tmpl +279 -0
- package/.agents/skills/gstack/qa/SKILL.md +1016 -0
- package/.agents/skills/gstack/qa/SKILL.md.tmpl +316 -0
- package/.agents/skills/gstack/qa/references/issue-taxonomy.md +85 -0
- package/.agents/skills/gstack/qa/templates/qa-report-template.md +126 -0
- package/.agents/skills/gstack/qa-only/SKILL.md +633 -0
- package/.agents/skills/gstack/qa-only/SKILL.md.tmpl +101 -0
- package/.agents/skills/gstack/retro/SKILL.md +1072 -0
- package/.agents/skills/gstack/retro/SKILL.md.tmpl +833 -0
- package/.agents/skills/gstack/review/SKILL.md +849 -0
- package/.agents/skills/gstack/review/SKILL.md.tmpl +259 -0
- package/.agents/skills/gstack/review/TODOS-format.md +62 -0
- package/.agents/skills/gstack/review/checklist.md +190 -0
- package/.agents/skills/gstack/review/design-checklist.md +132 -0
- package/.agents/skills/gstack/review/greptile-triage.md +220 -0
- package/.agents/skills/gstack/scripts/analytics.ts +190 -0
- package/.agents/skills/gstack/scripts/dev-skill.ts +82 -0
- package/.agents/skills/gstack/scripts/eval-compare.ts +96 -0
- package/.agents/skills/gstack/scripts/eval-list.ts +116 -0
- package/.agents/skills/gstack/scripts/eval-select.ts +86 -0
- package/.agents/skills/gstack/scripts/eval-summary.ts +187 -0
- package/.agents/skills/gstack/scripts/eval-watch.ts +172 -0
- package/.agents/skills/gstack/scripts/gen-skill-docs.ts +2414 -0
- package/.agents/skills/gstack/scripts/skill-check.ts +167 -0
- package/.agents/skills/gstack/setup +269 -0
- package/.agents/skills/gstack/setup-browser-cookies/SKILL.md +330 -0
- package/.agents/skills/gstack/setup-browser-cookies/SKILL.md.tmpl +74 -0
- package/.agents/skills/gstack/setup-deploy/SKILL.md +459 -0
- package/.agents/skills/gstack/setup-deploy/SKILL.md.tmpl +220 -0
- package/.agents/skills/gstack/ship/SKILL.md +1457 -0
- package/.agents/skills/gstack/ship/SKILL.md.tmpl +528 -0
- package/.agents/skills/gstack/supabase/config.sh +10 -0
- package/.agents/skills/gstack/supabase/functions/community-pulse/index.ts +59 -0
- package/.agents/skills/gstack/supabase/functions/telemetry-ingest/index.ts +135 -0
- package/.agents/skills/gstack/supabase/functions/update-check/index.ts +37 -0
- package/.agents/skills/gstack/supabase/migrations/001_telemetry.sql +89 -0
- package/.agents/skills/gstack/test/analytics.test.ts +277 -0
- package/.agents/skills/gstack/test/codex-e2e.test.ts +197 -0
- package/.agents/skills/gstack/test/fixtures/coverage-audit-fixture.ts +76 -0
- package/.agents/skills/gstack/test/fixtures/eval-baselines.json +7 -0
- package/.agents/skills/gstack/test/fixtures/qa-eval-checkout-ground-truth.json +43 -0
- package/.agents/skills/gstack/test/fixtures/qa-eval-ground-truth.json +43 -0
- package/.agents/skills/gstack/test/fixtures/qa-eval-spa-ground-truth.json +43 -0
- package/.agents/skills/gstack/test/fixtures/review-eval-design-slop.css +86 -0
- package/.agents/skills/gstack/test/fixtures/review-eval-design-slop.html +41 -0
- package/.agents/skills/gstack/test/fixtures/review-eval-enum-diff.rb +30 -0
- package/.agents/skills/gstack/test/fixtures/review-eval-enum.rb +27 -0
- package/.agents/skills/gstack/test/fixtures/review-eval-vuln.rb +14 -0
- package/.agents/skills/gstack/test/gemini-e2e.test.ts +173 -0
- package/.agents/skills/gstack/test/gen-skill-docs.test.ts +1049 -0
- package/.agents/skills/gstack/test/global-discover.test.ts +187 -0
- package/.agents/skills/gstack/test/helpers/codex-session-runner.ts +282 -0
- package/.agents/skills/gstack/test/helpers/e2e-helpers.ts +239 -0
- package/.agents/skills/gstack/test/helpers/eval-store.test.ts +548 -0
- package/.agents/skills/gstack/test/helpers/eval-store.ts +689 -0
- package/.agents/skills/gstack/test/helpers/gemini-session-runner.test.ts +104 -0
- package/.agents/skills/gstack/test/helpers/gemini-session-runner.ts +201 -0
- package/.agents/skills/gstack/test/helpers/llm-judge.ts +130 -0
- package/.agents/skills/gstack/test/helpers/observability.test.ts +283 -0
- package/.agents/skills/gstack/test/helpers/session-runner.test.ts +96 -0
- package/.agents/skills/gstack/test/helpers/session-runner.ts +357 -0
- package/.agents/skills/gstack/test/helpers/skill-parser.ts +206 -0
- package/.agents/skills/gstack/test/helpers/touchfiles.ts +260 -0
- package/.agents/skills/gstack/test/hook-scripts.test.ts +373 -0
- package/.agents/skills/gstack/test/skill-e2e-browse.test.ts +293 -0
- package/.agents/skills/gstack/test/skill-e2e-deploy.test.ts +279 -0
- package/.agents/skills/gstack/test/skill-e2e-design.test.ts +614 -0
- package/.agents/skills/gstack/test/skill-e2e-plan.test.ts +538 -0
- package/.agents/skills/gstack/test/skill-e2e-qa-bugs.test.ts +194 -0
- package/.agents/skills/gstack/test/skill-e2e-qa-workflow.test.ts +412 -0
- package/.agents/skills/gstack/test/skill-e2e-review.test.ts +535 -0
- package/.agents/skills/gstack/test/skill-e2e-workflow.test.ts +586 -0
- package/.agents/skills/gstack/test/skill-e2e.test.ts +3325 -0
- package/.agents/skills/gstack/test/skill-llm-eval.test.ts +787 -0
- package/.agents/skills/gstack/test/skill-parser.test.ts +179 -0
- package/.agents/skills/gstack/test/skill-routing-e2e.test.ts +605 -0
- package/.agents/skills/gstack/test/skill-validation.test.ts +1520 -0
- package/.agents/skills/gstack/test/telemetry.test.ts +278 -0
- package/.agents/skills/gstack/test/touchfiles.test.ts +262 -0
- package/.agents/skills/gstack/unfreeze/SKILL.md +40 -0
- package/.agents/skills/gstack/unfreeze/SKILL.md.tmpl +38 -0
- package/package.json +2 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Output the remote slug (owner-repo) for the current git repo.
|
|
3
|
+
# Used by SKILL.md files to derive project-specific paths in ~/.gstack/projects/.
|
|
4
|
+
set -e
|
|
5
|
+
URL=$(git remote get-url origin 2>/dev/null || true)
|
|
6
|
+
if [ -n "$URL" ]; then
|
|
7
|
+
# Strip trailing .git if present, then extract owner/repo
|
|
8
|
+
URL="${URL%.git}"
|
|
9
|
+
# Handle both SSH (git@host:owner/repo) and HTTPS (https://host/owner/repo)
|
|
10
|
+
OWNER_REPO=$(echo "$URL" | sed -E 's#.*[:/]([^/]+)/([^/]+)$#\1-\2#')
|
|
11
|
+
echo "$OWNER_REPO"
|
|
12
|
+
else
|
|
13
|
+
basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
14
|
+
fi
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Build a Node.js-compatible server bundle for Windows.
|
|
3
|
+
#
|
|
4
|
+
# On Windows, Bun can't launch or connect to Playwright's Chromium
|
|
5
|
+
# (oven-sh/bun#4253, #9911). This script produces a server bundle
|
|
6
|
+
# that runs under Node.js with Bun API polyfills.
|
|
7
|
+
|
|
8
|
+
set -e
|
|
9
|
+
|
|
10
|
+
GSTACK_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
11
|
+
SRC_DIR="$GSTACK_DIR/browse/src"
|
|
12
|
+
DIST_DIR="$GSTACK_DIR/browse/dist"
|
|
13
|
+
|
|
14
|
+
echo "Building Node-compatible server bundle..."
|
|
15
|
+
|
|
16
|
+
# Step 1: Transpile server.ts to a single .mjs bundle (externalize runtime deps)
|
|
17
|
+
bun build "$SRC_DIR/server.ts" \
|
|
18
|
+
--target=node \
|
|
19
|
+
--outfile "$DIST_DIR/server-node.mjs" \
|
|
20
|
+
--external playwright \
|
|
21
|
+
--external playwright-core \
|
|
22
|
+
--external diff \
|
|
23
|
+
--external "bun:sqlite"
|
|
24
|
+
|
|
25
|
+
# Step 2: Post-process
|
|
26
|
+
# Replace import.meta.dir with a resolvable reference
|
|
27
|
+
perl -pi -e 's/import\.meta\.dir/__browseNodeSrcDir/g' "$DIST_DIR/server-node.mjs"
|
|
28
|
+
# Stub out bun:sqlite (macOS-only cookie import, not needed on Windows)
|
|
29
|
+
perl -pi -e 's|import { Database } from "bun:sqlite";|const Database = null; // bun:sqlite stubbed on Node|g' "$DIST_DIR/server-node.mjs"
|
|
30
|
+
|
|
31
|
+
# Step 3: Create the final file with polyfill header injected after the first line
|
|
32
|
+
{
|
|
33
|
+
head -1 "$DIST_DIR/server-node.mjs"
|
|
34
|
+
echo '// ── Windows Node.js compatibility (auto-generated) ──'
|
|
35
|
+
echo 'import { fileURLToPath as _ftp } from "node:url";'
|
|
36
|
+
echo 'import { dirname as _dn } from "node:path";'
|
|
37
|
+
echo 'const __browseNodeSrcDir = _dn(_dn(_ftp(import.meta.url))) + "/src";'
|
|
38
|
+
echo '{ const _r = createRequire(import.meta.url); _r("./bun-polyfill.cjs"); }'
|
|
39
|
+
echo '// ── end compatibility ──'
|
|
40
|
+
tail -n +2 "$DIST_DIR/server-node.mjs"
|
|
41
|
+
} > "$DIST_DIR/server-node.tmp.mjs"
|
|
42
|
+
|
|
43
|
+
mv "$DIST_DIR/server-node.tmp.mjs" "$DIST_DIR/server-node.mjs"
|
|
44
|
+
|
|
45
|
+
# Step 4: Copy polyfill to dist/
|
|
46
|
+
cp "$SRC_DIR/bun-polyfill.cjs" "$DIST_DIR/bun-polyfill.cjs"
|
|
47
|
+
|
|
48
|
+
echo "Node server bundle ready: $DIST_DIR/server-node.mjs"
|
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser lifecycle manager
|
|
3
|
+
*
|
|
4
|
+
* Chromium crash handling:
|
|
5
|
+
* browser.on('disconnected') → log error → process.exit(1)
|
|
6
|
+
* CLI detects dead server → auto-restarts on next command
|
|
7
|
+
* We do NOT try to self-heal — don't hide failure.
|
|
8
|
+
*
|
|
9
|
+
* Dialog handling:
|
|
10
|
+
* page.on('dialog') → auto-accept by default → store in dialog buffer
|
|
11
|
+
* Prevents browser lockup from alert/confirm/prompt
|
|
12
|
+
*
|
|
13
|
+
* Context recreation (useragent):
|
|
14
|
+
* recreateContext() saves cookies/storage/URLs, creates new context,
|
|
15
|
+
* restores state. Falls back to clean slate on any failure.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
|
|
19
|
+
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
|
20
|
+
import { validateNavigationUrl } from './url-validation';
|
|
21
|
+
|
|
22
|
+
export interface RefEntry {
|
|
23
|
+
locator: Locator;
|
|
24
|
+
role: string;
|
|
25
|
+
name: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BrowserState {
|
|
29
|
+
cookies: Cookie[];
|
|
30
|
+
pages: Array<{
|
|
31
|
+
url: string;
|
|
32
|
+
isActive: boolean;
|
|
33
|
+
storage: { localStorage: Record<string, string>; sessionStorage: Record<string, string> } | null;
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class BrowserManager {
|
|
38
|
+
private browser: Browser | null = null;
|
|
39
|
+
private context: BrowserContext | null = null;
|
|
40
|
+
private pages: Map<number, Page> = new Map();
|
|
41
|
+
private activeTabId: number = 0;
|
|
42
|
+
private nextTabId: number = 1;
|
|
43
|
+
private extraHeaders: Record<string, string> = {};
|
|
44
|
+
private customUserAgent: string | null = null;
|
|
45
|
+
|
|
46
|
+
/** Server port — set after server starts, used by cookie-import-browser command */
|
|
47
|
+
public serverPort: number = 0;
|
|
48
|
+
|
|
49
|
+
// ─── Ref Map (snapshot → @e1, @e2, @c1, @c2, ...) ────────
|
|
50
|
+
private refMap: Map<string, RefEntry> = new Map();
|
|
51
|
+
|
|
52
|
+
// ─── Snapshot Diffing ─────────────────────────────────────
|
|
53
|
+
// NOT cleared on navigation — it's a text baseline for diffing
|
|
54
|
+
private lastSnapshot: string | null = null;
|
|
55
|
+
|
|
56
|
+
// ─── Dialog Handling ──────────────────────────────────────
|
|
57
|
+
private dialogAutoAccept: boolean = true;
|
|
58
|
+
private dialogPromptText: string | null = null;
|
|
59
|
+
|
|
60
|
+
// ─── Handoff State ─────────────────────────────────────────
|
|
61
|
+
private isHeaded: boolean = false;
|
|
62
|
+
private consecutiveFailures: number = 0;
|
|
63
|
+
|
|
64
|
+
async launch() {
|
|
65
|
+
this.browser = await chromium.launch({ headless: true });
|
|
66
|
+
|
|
67
|
+
// Chromium crash → exit with clear message
|
|
68
|
+
this.browser.on('disconnected', () => {
|
|
69
|
+
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
|
|
70
|
+
console.error('[browse] Console/network logs flushed to .gstack/browse-*.log');
|
|
71
|
+
process.exit(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const contextOptions: BrowserContextOptions = {
|
|
75
|
+
viewport: { width: 1280, height: 720 },
|
|
76
|
+
};
|
|
77
|
+
if (this.customUserAgent) {
|
|
78
|
+
contextOptions.userAgent = this.customUserAgent;
|
|
79
|
+
}
|
|
80
|
+
this.context = await this.browser.newContext(contextOptions);
|
|
81
|
+
|
|
82
|
+
if (Object.keys(this.extraHeaders).length > 0) {
|
|
83
|
+
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Create first tab
|
|
87
|
+
await this.newTab();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async close() {
|
|
91
|
+
if (this.browser) {
|
|
92
|
+
// Remove disconnect handler to avoid exit during intentional close
|
|
93
|
+
this.browser.removeAllListeners('disconnected');
|
|
94
|
+
// Timeout: headed browser.close() can hang on macOS
|
|
95
|
+
await Promise.race([
|
|
96
|
+
this.browser.close(),
|
|
97
|
+
new Promise(resolve => setTimeout(resolve, 5000)),
|
|
98
|
+
]).catch(() => {});
|
|
99
|
+
this.browser = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Health check — verifies Chromium is connected AND responsive */
|
|
104
|
+
async isHealthy(): Promise<boolean> {
|
|
105
|
+
if (!this.browser || !this.browser.isConnected()) return false;
|
|
106
|
+
try {
|
|
107
|
+
const page = this.pages.get(this.activeTabId);
|
|
108
|
+
if (!page) return true; // connected but no pages — still healthy
|
|
109
|
+
await Promise.race([
|
|
110
|
+
page.evaluate('1'),
|
|
111
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000)),
|
|
112
|
+
]);
|
|
113
|
+
return true;
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Tab Management ────────────────────────────────────────
|
|
120
|
+
async newTab(url?: string): Promise<number> {
|
|
121
|
+
if (!this.context) throw new Error('Browser not launched');
|
|
122
|
+
|
|
123
|
+
// Validate URL before allocating page to avoid zombie tabs on rejection
|
|
124
|
+
if (url) {
|
|
125
|
+
await validateNavigationUrl(url);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const page = await this.context.newPage();
|
|
129
|
+
const id = this.nextTabId++;
|
|
130
|
+
this.pages.set(id, page);
|
|
131
|
+
this.activeTabId = id;
|
|
132
|
+
|
|
133
|
+
// Wire up console/network/dialog capture
|
|
134
|
+
this.wirePageEvents(page);
|
|
135
|
+
|
|
136
|
+
if (url) {
|
|
137
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return id;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async closeTab(id?: number): Promise<void> {
|
|
144
|
+
const tabId = id ?? this.activeTabId;
|
|
145
|
+
const page = this.pages.get(tabId);
|
|
146
|
+
if (!page) throw new Error(`Tab ${tabId} not found`);
|
|
147
|
+
|
|
148
|
+
await page.close();
|
|
149
|
+
this.pages.delete(tabId);
|
|
150
|
+
|
|
151
|
+
// Switch to another tab if we closed the active one
|
|
152
|
+
if (tabId === this.activeTabId) {
|
|
153
|
+
const remaining = [...this.pages.keys()];
|
|
154
|
+
if (remaining.length > 0) {
|
|
155
|
+
this.activeTabId = remaining[remaining.length - 1];
|
|
156
|
+
} else {
|
|
157
|
+
// No tabs left — create a new blank one
|
|
158
|
+
await this.newTab();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
switchTab(id: number): void {
|
|
164
|
+
if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`);
|
|
165
|
+
this.activeTabId = id;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
getTabCount(): number {
|
|
169
|
+
return this.pages.size;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean }>> {
|
|
173
|
+
const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = [];
|
|
174
|
+
for (const [id, page] of this.pages) {
|
|
175
|
+
tabs.push({
|
|
176
|
+
id,
|
|
177
|
+
url: page.url(),
|
|
178
|
+
title: await page.title().catch(() => ''),
|
|
179
|
+
active: id === this.activeTabId,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return tabs;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Page Access ───────────────────────────────────────────
|
|
186
|
+
getPage(): Page {
|
|
187
|
+
const page = this.pages.get(this.activeTabId);
|
|
188
|
+
if (!page) throw new Error('No active page. Use "browse goto <url>" first.');
|
|
189
|
+
return page;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getCurrentUrl(): string {
|
|
193
|
+
try {
|
|
194
|
+
return this.getPage().url();
|
|
195
|
+
} catch {
|
|
196
|
+
return 'about:blank';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Ref Map ──────────────────────────────────────────────
|
|
201
|
+
setRefMap(refs: Map<string, RefEntry>) {
|
|
202
|
+
this.refMap = refs;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
clearRefs() {
|
|
206
|
+
this.refMap.clear();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolve a selector that may be a @ref (e.g., "@e3", "@c1") or a CSS selector.
|
|
211
|
+
* Returns { locator } for refs or { selector } for CSS selectors.
|
|
212
|
+
*/
|
|
213
|
+
async resolveRef(selector: string): Promise<{ locator: Locator } | { selector: string }> {
|
|
214
|
+
if (selector.startsWith('@e') || selector.startsWith('@c')) {
|
|
215
|
+
const ref = selector.slice(1); // "e3" or "c1"
|
|
216
|
+
const entry = this.refMap.get(ref);
|
|
217
|
+
if (!entry) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`Ref ${selector} not found. Run 'snapshot' to get fresh refs.`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const count = await entry.locator.count();
|
|
223
|
+
if (count === 0) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`Ref ${selector} (${entry.role} "${entry.name}") is stale — element no longer exists. ` +
|
|
226
|
+
`Run 'snapshot' for fresh refs.`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return { locator: entry.locator };
|
|
230
|
+
}
|
|
231
|
+
return { selector };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Get the ARIA role for a ref selector, or null for CSS selectors / unknown refs. */
|
|
235
|
+
getRefRole(selector: string): string | null {
|
|
236
|
+
if (selector.startsWith('@e') || selector.startsWith('@c')) {
|
|
237
|
+
const entry = this.refMap.get(selector.slice(1));
|
|
238
|
+
return entry?.role ?? null;
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
getRefCount(): number {
|
|
244
|
+
return this.refMap.size;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Snapshot Diffing ─────────────────────────────────────
|
|
248
|
+
setLastSnapshot(text: string | null) {
|
|
249
|
+
this.lastSnapshot = text;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
getLastSnapshot(): string | null {
|
|
253
|
+
return this.lastSnapshot;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── Dialog Control ───────────────────────────────────────
|
|
257
|
+
setDialogAutoAccept(accept: boolean) {
|
|
258
|
+
this.dialogAutoAccept = accept;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
getDialogAutoAccept(): boolean {
|
|
262
|
+
return this.dialogAutoAccept;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
setDialogPromptText(text: string | null) {
|
|
266
|
+
this.dialogPromptText = text;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
getDialogPromptText(): string | null {
|
|
270
|
+
return this.dialogPromptText;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── Viewport ──────────────────────────────────────────────
|
|
274
|
+
async setViewport(width: number, height: number) {
|
|
275
|
+
await this.getPage().setViewportSize({ width, height });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─── Extra Headers ─────────────────────────────────────────
|
|
279
|
+
async setExtraHeader(name: string, value: string) {
|
|
280
|
+
this.extraHeaders[name] = value;
|
|
281
|
+
if (this.context) {
|
|
282
|
+
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── User Agent ────────────────────────────────────────────
|
|
287
|
+
setUserAgent(ua: string) {
|
|
288
|
+
this.customUserAgent = ua;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
getUserAgent(): string | null {
|
|
292
|
+
return this.customUserAgent;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ─── State Save/Restore (shared by recreateContext + handoff) ─
|
|
296
|
+
/**
|
|
297
|
+
* Capture browser state: cookies, localStorage, sessionStorage, URLs, active tab.
|
|
298
|
+
* Skips pages that fail storage reads (e.g., already closed).
|
|
299
|
+
*/
|
|
300
|
+
async saveState(): Promise<BrowserState> {
|
|
301
|
+
if (!this.context) throw new Error('Browser not launched');
|
|
302
|
+
|
|
303
|
+
const cookies = await this.context.cookies();
|
|
304
|
+
const pages: BrowserState['pages'] = [];
|
|
305
|
+
|
|
306
|
+
for (const [id, page] of this.pages) {
|
|
307
|
+
const url = page.url();
|
|
308
|
+
let storage = null;
|
|
309
|
+
try {
|
|
310
|
+
storage = await page.evaluate(() => ({
|
|
311
|
+
localStorage: { ...localStorage },
|
|
312
|
+
sessionStorage: { ...sessionStorage },
|
|
313
|
+
}));
|
|
314
|
+
} catch {}
|
|
315
|
+
pages.push({
|
|
316
|
+
url: url === 'about:blank' ? '' : url,
|
|
317
|
+
isActive: id === this.activeTabId,
|
|
318
|
+
storage,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { cookies, pages };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Restore browser state into the current context: cookies, pages, storage.
|
|
327
|
+
* Navigates to saved URLs, restores storage, wires page events.
|
|
328
|
+
* Failures on individual pages are swallowed — partial restore is better than none.
|
|
329
|
+
*/
|
|
330
|
+
async restoreState(state: BrowserState): Promise<void> {
|
|
331
|
+
if (!this.context) throw new Error('Browser not launched');
|
|
332
|
+
|
|
333
|
+
// Restore cookies
|
|
334
|
+
if (state.cookies.length > 0) {
|
|
335
|
+
await this.context.addCookies(state.cookies);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Re-create pages
|
|
339
|
+
let activeId: number | null = null;
|
|
340
|
+
for (const saved of state.pages) {
|
|
341
|
+
const page = await this.context.newPage();
|
|
342
|
+
const id = this.nextTabId++;
|
|
343
|
+
this.pages.set(id, page);
|
|
344
|
+
this.wirePageEvents(page);
|
|
345
|
+
|
|
346
|
+
if (saved.url) {
|
|
347
|
+
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (saved.storage) {
|
|
351
|
+
try {
|
|
352
|
+
await page.evaluate((s: { localStorage: Record<string, string>; sessionStorage: Record<string, string> }) => {
|
|
353
|
+
if (s.localStorage) {
|
|
354
|
+
for (const [k, v] of Object.entries(s.localStorage)) {
|
|
355
|
+
localStorage.setItem(k, v);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (s.sessionStorage) {
|
|
359
|
+
for (const [k, v] of Object.entries(s.sessionStorage)) {
|
|
360
|
+
sessionStorage.setItem(k, v);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}, saved.storage);
|
|
364
|
+
} catch {}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (saved.isActive) activeId = id;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// If no pages were saved, create a blank one
|
|
371
|
+
if (this.pages.size === 0) {
|
|
372
|
+
await this.newTab();
|
|
373
|
+
} else {
|
|
374
|
+
this.activeTabId = activeId ?? [...this.pages.keys()][0];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Clear refs — pages are new, locators are stale
|
|
378
|
+
this.clearRefs();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Recreate the browser context to apply user agent changes.
|
|
383
|
+
* Saves and restores cookies, localStorage, sessionStorage, and open pages.
|
|
384
|
+
* Falls back to a clean slate on any failure.
|
|
385
|
+
*/
|
|
386
|
+
async recreateContext(): Promise<string | null> {
|
|
387
|
+
if (!this.browser || !this.context) {
|
|
388
|
+
throw new Error('Browser not launched');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
// 1. Save state
|
|
393
|
+
const state = await this.saveState();
|
|
394
|
+
|
|
395
|
+
// 2. Close old pages and context
|
|
396
|
+
for (const page of this.pages.values()) {
|
|
397
|
+
await page.close().catch(() => {});
|
|
398
|
+
}
|
|
399
|
+
this.pages.clear();
|
|
400
|
+
await this.context.close().catch(() => {});
|
|
401
|
+
|
|
402
|
+
// 3. Create new context with updated settings
|
|
403
|
+
const contextOptions: BrowserContextOptions = {
|
|
404
|
+
viewport: { width: 1280, height: 720 },
|
|
405
|
+
};
|
|
406
|
+
if (this.customUserAgent) {
|
|
407
|
+
contextOptions.userAgent = this.customUserAgent;
|
|
408
|
+
}
|
|
409
|
+
this.context = await this.browser.newContext(contextOptions);
|
|
410
|
+
|
|
411
|
+
if (Object.keys(this.extraHeaders).length > 0) {
|
|
412
|
+
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// 4. Restore state
|
|
416
|
+
await this.restoreState(state);
|
|
417
|
+
|
|
418
|
+
return null; // success
|
|
419
|
+
} catch (err: unknown) {
|
|
420
|
+
// Fallback: create a clean context + blank tab
|
|
421
|
+
try {
|
|
422
|
+
this.pages.clear();
|
|
423
|
+
if (this.context) await this.context.close().catch(() => {});
|
|
424
|
+
|
|
425
|
+
const contextOptions: BrowserContextOptions = {
|
|
426
|
+
viewport: { width: 1280, height: 720 },
|
|
427
|
+
};
|
|
428
|
+
if (this.customUserAgent) {
|
|
429
|
+
contextOptions.userAgent = this.customUserAgent;
|
|
430
|
+
}
|
|
431
|
+
this.context = await this.browser!.newContext(contextOptions);
|
|
432
|
+
await this.newTab();
|
|
433
|
+
this.clearRefs();
|
|
434
|
+
} catch {
|
|
435
|
+
// If even the fallback fails, we're in trouble — but browser is still alive
|
|
436
|
+
}
|
|
437
|
+
return `Context recreation failed: ${err instanceof Error ? err.message : String(err)}. Browser reset to blank tab.`;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ─── Handoff: Headless → Headed ─────────────────────────────
|
|
442
|
+
/**
|
|
443
|
+
* Hand off browser control to the user by relaunching in headed mode.
|
|
444
|
+
*
|
|
445
|
+
* Flow (launch-first-close-second for safe rollback):
|
|
446
|
+
* 1. Save state from current headless browser
|
|
447
|
+
* 2. Launch NEW headed browser
|
|
448
|
+
* 3. Restore state into new browser
|
|
449
|
+
* 4. Close OLD headless browser
|
|
450
|
+
* If step 2 fails → return error, headless browser untouched
|
|
451
|
+
*/
|
|
452
|
+
async handoff(message: string): Promise<string> {
|
|
453
|
+
if (this.isHeaded) {
|
|
454
|
+
return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`;
|
|
455
|
+
}
|
|
456
|
+
if (!this.browser || !this.context) {
|
|
457
|
+
throw new Error('Browser not launched');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 1. Save state from current browser
|
|
461
|
+
const state = await this.saveState();
|
|
462
|
+
const currentUrl = this.getCurrentUrl();
|
|
463
|
+
|
|
464
|
+
// 2. Launch new headed browser (try-catch — if this fails, headless stays running)
|
|
465
|
+
let newBrowser: Browser;
|
|
466
|
+
try {
|
|
467
|
+
newBrowser = await chromium.launch({ headless: false, timeout: 15000 });
|
|
468
|
+
} catch (err: unknown) {
|
|
469
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
470
|
+
return `ERROR: Cannot open headed browser — ${msg}. Headless browser still running.`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// 3. Create context and restore state into new headed browser
|
|
474
|
+
try {
|
|
475
|
+
const contextOptions: BrowserContextOptions = {
|
|
476
|
+
viewport: { width: 1280, height: 720 },
|
|
477
|
+
};
|
|
478
|
+
if (this.customUserAgent) {
|
|
479
|
+
contextOptions.userAgent = this.customUserAgent;
|
|
480
|
+
}
|
|
481
|
+
const newContext = await newBrowser.newContext(contextOptions);
|
|
482
|
+
|
|
483
|
+
if (Object.keys(this.extraHeaders).length > 0) {
|
|
484
|
+
await newContext.setExtraHTTPHeaders(this.extraHeaders);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Swap to new browser/context before restoreState (it uses this.context)
|
|
488
|
+
const oldBrowser = this.browser;
|
|
489
|
+
const oldContext = this.context;
|
|
490
|
+
|
|
491
|
+
this.browser = newBrowser;
|
|
492
|
+
this.context = newContext;
|
|
493
|
+
this.pages.clear();
|
|
494
|
+
|
|
495
|
+
// Register crash handler on new browser
|
|
496
|
+
this.browser.on('disconnected', () => {
|
|
497
|
+
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
|
|
498
|
+
console.error('[browse] Console/network logs flushed to .gstack/browse-*.log');
|
|
499
|
+
process.exit(1);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
await this.restoreState(state);
|
|
503
|
+
this.isHeaded = true;
|
|
504
|
+
|
|
505
|
+
// 4. Close old headless browser (fire-and-forget — close() can hang
|
|
506
|
+
// when another Playwright instance is active, so we don't await it)
|
|
507
|
+
oldBrowser.removeAllListeners('disconnected');
|
|
508
|
+
oldBrowser.close().catch(() => {});
|
|
509
|
+
|
|
510
|
+
return [
|
|
511
|
+
`HANDOFF: Browser opened at ${currentUrl}`,
|
|
512
|
+
`MESSAGE: ${message}`,
|
|
513
|
+
`STATUS: Waiting for user. Run 'resume' when done.`,
|
|
514
|
+
].join('\n');
|
|
515
|
+
} catch (err: unknown) {
|
|
516
|
+
// Restore failed — close the new browser, keep old one
|
|
517
|
+
await newBrowser.close().catch(() => {});
|
|
518
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
519
|
+
return `ERROR: Handoff failed during state restore — ${msg}. Headless browser still running.`;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Resume AI control after user handoff.
|
|
525
|
+
* Clears stale refs and resets failure counter.
|
|
526
|
+
* The meta-command handler calls handleSnapshot() after this.
|
|
527
|
+
*/
|
|
528
|
+
resume(): void {
|
|
529
|
+
this.clearRefs();
|
|
530
|
+
this.resetFailures();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
getIsHeaded(): boolean {
|
|
534
|
+
return this.isHeaded;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ─── Auto-handoff Hint (consecutive failure tracking) ───────
|
|
538
|
+
incrementFailures(): void {
|
|
539
|
+
this.consecutiveFailures++;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
resetFailures(): void {
|
|
543
|
+
this.consecutiveFailures = 0;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
getFailureHint(): string | null {
|
|
547
|
+
if (this.consecutiveFailures >= 3 && !this.isHeaded) {
|
|
548
|
+
return `HINT: ${this.consecutiveFailures} consecutive failures. Consider using 'handoff' to let the user help.`;
|
|
549
|
+
}
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ─── Console/Network/Dialog/Ref Wiring ────────────────────
|
|
554
|
+
private wirePageEvents(page: Page) {
|
|
555
|
+
// Clear ref map on navigation — refs point to stale elements after page change
|
|
556
|
+
// (lastSnapshot is NOT cleared — it's a text baseline for diffing)
|
|
557
|
+
page.on('framenavigated', (frame) => {
|
|
558
|
+
if (frame === page.mainFrame()) {
|
|
559
|
+
this.clearRefs();
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// ─── Dialog auto-handling (prevents browser lockup) ─────
|
|
564
|
+
page.on('dialog', async (dialog) => {
|
|
565
|
+
const entry: DialogEntry = {
|
|
566
|
+
timestamp: Date.now(),
|
|
567
|
+
type: dialog.type(),
|
|
568
|
+
message: dialog.message(),
|
|
569
|
+
defaultValue: dialog.defaultValue() || undefined,
|
|
570
|
+
action: this.dialogAutoAccept ? 'accepted' : 'dismissed',
|
|
571
|
+
response: this.dialogAutoAccept ? (this.dialogPromptText ?? undefined) : undefined,
|
|
572
|
+
};
|
|
573
|
+
addDialogEntry(entry);
|
|
574
|
+
|
|
575
|
+
try {
|
|
576
|
+
if (this.dialogAutoAccept) {
|
|
577
|
+
await dialog.accept(this.dialogPromptText ?? undefined);
|
|
578
|
+
} else {
|
|
579
|
+
await dialog.dismiss();
|
|
580
|
+
}
|
|
581
|
+
} catch {
|
|
582
|
+
// Dialog may have been dismissed by navigation — ignore
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
page.on('console', (msg) => {
|
|
587
|
+
addConsoleEntry({
|
|
588
|
+
timestamp: Date.now(),
|
|
589
|
+
level: msg.type(),
|
|
590
|
+
text: msg.text(),
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
page.on('request', (req) => {
|
|
595
|
+
addNetworkEntry({
|
|
596
|
+
timestamp: Date.now(),
|
|
597
|
+
method: req.method(),
|
|
598
|
+
url: req.url(),
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
page.on('response', (res) => {
|
|
603
|
+
// Find matching request entry and update it (backward scan)
|
|
604
|
+
const url = res.url();
|
|
605
|
+
const status = res.status();
|
|
606
|
+
for (let i = networkBuffer.length - 1; i >= 0; i--) {
|
|
607
|
+
const entry = networkBuffer.get(i);
|
|
608
|
+
if (entry && entry.url === url && !entry.status) {
|
|
609
|
+
networkBuffer.set(i, { ...entry, status, duration: Date.now() - entry.timestamp });
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// Capture response sizes via response finished
|
|
616
|
+
page.on('requestfinished', async (req) => {
|
|
617
|
+
try {
|
|
618
|
+
const res = await req.response();
|
|
619
|
+
if (res) {
|
|
620
|
+
const url = req.url();
|
|
621
|
+
const body = await res.body().catch(() => null);
|
|
622
|
+
const size = body ? body.length : 0;
|
|
623
|
+
for (let i = networkBuffer.length - 1; i >= 0; i--) {
|
|
624
|
+
const entry = networkBuffer.get(i);
|
|
625
|
+
if (entry && entry.url === url && !entry.size) {
|
|
626
|
+
networkBuffer.set(i, { ...entry, size });
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
} catch {}
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|