opengstack 0.13.7 → 0.13.8
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.
- package/bin/opengstack.js +35 -90
- package/package.json +2 -3
- package/scripts/install-skills.js +29 -58
- package/skills/browse/bin/find-browse +21 -0
- package/skills/browse/bin/remote-slug +14 -0
- package/skills/browse/scripts/build-node-server.sh +48 -0
- package/skills/browse/src/activity.ts +208 -0
- package/skills/browse/src/browser-manager.ts +959 -0
- package/skills/browse/src/buffers.ts +137 -0
- package/skills/browse/src/bun-polyfill.cjs +109 -0
- package/skills/browse/src/cli.ts +678 -0
- package/skills/browse/src/commands.ts +128 -0
- package/skills/browse/src/config.ts +150 -0
- package/skills/browse/src/cookie-import-browser.ts +625 -0
- package/skills/browse/src/cookie-picker-routes.ts +230 -0
- package/skills/browse/src/cookie-picker-ui.ts +688 -0
- package/skills/browse/src/find-browse.ts +61 -0
- package/skills/browse/src/meta-commands.ts +550 -0
- package/skills/browse/src/platform.ts +17 -0
- package/skills/browse/src/read-commands.ts +358 -0
- package/skills/browse/src/server.ts +1192 -0
- package/skills/browse/src/sidebar-agent.ts +280 -0
- package/skills/browse/src/sidebar-utils.ts +21 -0
- package/skills/browse/src/snapshot.ts +407 -0
- package/skills/browse/src/url-validation.ts +95 -0
- package/skills/browse/src/write-commands.ts +364 -0
- package/skills/browse/test/activity.test.ts +120 -0
- package/skills/browse/test/adversarial-security.test.ts +32 -0
- package/skills/browse/test/browser-manager-unit.test.ts +17 -0
- package/skills/browse/test/bun-polyfill.test.ts +72 -0
- package/skills/browse/test/commands.test.ts +2075 -0
- package/skills/browse/test/compare-board.test.ts +342 -0
- package/skills/browse/test/config.test.ts +316 -0
- package/skills/browse/test/cookie-import-browser.test.ts +519 -0
- package/skills/browse/test/cookie-picker-routes.test.ts +260 -0
- package/skills/browse/test/file-drop.test.ts +271 -0
- package/skills/browse/test/find-browse.test.ts +50 -0
- package/skills/browse/test/findport.test.ts +191 -0
- package/skills/browse/test/fixtures/basic.html +33 -0
- package/skills/browse/test/fixtures/cursor-interactive.html +22 -0
- package/skills/browse/test/fixtures/dialog.html +15 -0
- package/skills/browse/test/fixtures/empty.html +2 -0
- package/skills/browse/test/fixtures/forms.html +55 -0
- package/skills/browse/test/fixtures/iframe.html +30 -0
- package/skills/browse/test/fixtures/network-idle.html +30 -0
- package/skills/browse/test/fixtures/qa-eval-checkout.html +108 -0
- package/skills/browse/test/fixtures/qa-eval-spa.html +98 -0
- package/skills/browse/test/fixtures/qa-eval.html +51 -0
- package/skills/browse/test/fixtures/responsive.html +49 -0
- package/skills/browse/test/fixtures/snapshot.html +55 -0
- package/skills/browse/test/fixtures/spa.html +24 -0
- package/skills/browse/test/fixtures/states.html +17 -0
- package/skills/browse/test/fixtures/upload.html +25 -0
- package/skills/browse/test/gstack-config.test.ts +138 -0
- package/skills/browse/test/gstack-update-check.test.ts +514 -0
- package/skills/browse/test/handoff.test.ts +235 -0
- package/skills/browse/test/path-validation.test.ts +91 -0
- package/skills/browse/test/platform.test.ts +37 -0
- package/skills/browse/test/server-auth.test.ts +65 -0
- package/skills/browse/test/sidebar-agent-roundtrip.test.ts +226 -0
- package/skills/browse/test/sidebar-agent.test.ts +199 -0
- package/skills/browse/test/sidebar-integration.test.ts +320 -0
- package/skills/browse/test/sidebar-unit.test.ts +96 -0
- package/skills/browse/test/snapshot.test.ts +467 -0
- package/skills/browse/test/state-ttl.test.ts +35 -0
- package/skills/browse/test/test-server.ts +57 -0
- package/skills/browse/test/url-validation.test.ts +72 -0
- package/skills/browse/test/watch.test.ts +129 -0
- package/skills/careful/bin/check-careful.sh +112 -0
- package/skills/cso/ACKNOWLEDGEMENTS.md +14 -0
- package/skills/freeze/bin/check-freeze.sh +79 -0
- package/skills/qa/references/issue-taxonomy.md +85 -0
- package/skills/qa/templates/qa-report-template.md +126 -0
- package/skills/review/TODOS-format.md +62 -0
- package/skills/review/checklist.md +220 -0
- package/skills/review/design-checklist.md +132 -0
- package/skills/review/greptile-triage.md +220 -0
- /package/{autoplan → skills/autoplan}/SKILL.md +0 -0
- /package/{autoplan → skills/autoplan}/SKILL.md.tmpl +0 -0
- /package/{benchmark → skills/benchmark}/SKILL.md +0 -0
- /package/{benchmark → skills/benchmark}/SKILL.md.tmpl +0 -0
- /package/{browse → skills/browse}/SKILL.md +0 -0
- /package/{browse → skills/browse}/SKILL.md.tmpl +0 -0
- /package/{canary → skills/canary}/SKILL.md +0 -0
- /package/{canary → skills/canary}/SKILL.md.tmpl +0 -0
- /package/{careful → skills/careful}/SKILL.md +0 -0
- /package/{careful → skills/careful}/SKILL.md.tmpl +0 -0
- /package/{codex → skills/codex}/SKILL.md +0 -0
- /package/{codex → skills/codex}/SKILL.md.tmpl +0 -0
- /package/{connect-chrome → skills/connect-chrome}/SKILL.md +0 -0
- /package/{connect-chrome → skills/connect-chrome}/SKILL.md.tmpl +0 -0
- /package/{cso → skills/cso}/SKILL.md +0 -0
- /package/{cso → skills/cso}/SKILL.md.tmpl +0 -0
- /package/{design-consultation → skills/design-consultation}/SKILL.md +0 -0
- /package/{design-consultation → skills/design-consultation}/SKILL.md.tmpl +0 -0
- /package/{design-review → skills/design-review}/SKILL.md +0 -0
- /package/{design-review → skills/design-review}/SKILL.md.tmpl +0 -0
- /package/{design-shotgun → skills/design-shotgun}/SKILL.md +0 -0
- /package/{design-shotgun → skills/design-shotgun}/SKILL.md.tmpl +0 -0
- /package/{document-release → skills/document-release}/SKILL.md +0 -0
- /package/{document-release → skills/document-release}/SKILL.md.tmpl +0 -0
- /package/{freeze → skills/freeze}/SKILL.md +0 -0
- /package/{freeze → skills/freeze}/SKILL.md.tmpl +0 -0
- /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md +0 -0
- /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md.tmpl +0 -0
- /package/{guard → skills/guard}/SKILL.md +0 -0
- /package/{guard → skills/guard}/SKILL.md.tmpl +0 -0
- /package/{investigate → skills/investigate}/SKILL.md +0 -0
- /package/{investigate → skills/investigate}/SKILL.md.tmpl +0 -0
- /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md +0 -0
- /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md.tmpl +0 -0
- /package/{office-hours → skills/office-hours}/SKILL.md +0 -0
- /package/{office-hours → skills/office-hours}/SKILL.md.tmpl +0 -0
- /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md +0 -0
- /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md.tmpl +0 -0
- /package/{plan-design-review → skills/plan-design-review}/SKILL.md +0 -0
- /package/{plan-design-review → skills/plan-design-review}/SKILL.md.tmpl +0 -0
- /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md +0 -0
- /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md.tmpl +0 -0
- /package/{qa → skills/qa}/SKILL.md +0 -0
- /package/{qa → skills/qa}/SKILL.md.tmpl +0 -0
- /package/{qa-only → skills/qa-only}/SKILL.md +0 -0
- /package/{qa-only → skills/qa-only}/SKILL.md.tmpl +0 -0
- /package/{retro → skills/retro}/SKILL.md +0 -0
- /package/{retro → skills/retro}/SKILL.md.tmpl +0 -0
- /package/{review → skills/review}/SKILL.md +0 -0
- /package/{review → skills/review}/SKILL.md.tmpl +0 -0
- /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md +0 -0
- /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md.tmpl +0 -0
- /package/{setup-deploy → skills/setup-deploy}/SKILL.md +0 -0
- /package/{setup-deploy → skills/setup-deploy}/SKILL.md.tmpl +0 -0
- /package/{ship → skills/ship}/SKILL.md +0 -0
- /package/{ship → skills/ship}/SKILL.md.tmpl +0 -0
- /package/{unfreeze → skills/unfreeze}/SKILL.md +0 -0
- /package/{unfreeze → skills/unfreeze}/SKILL.md.tmpl +0 -0
|
@@ -0,0 +1,959 @@
|
|
|
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
|
+
// ─── Watch Mode ─────────────────────────────────────────
|
|
65
|
+
private watching = false;
|
|
66
|
+
public watchInterval: ReturnType<typeof setInterval> | null = null;
|
|
67
|
+
private watchSnapshots: string[] = [];
|
|
68
|
+
private watchStartTime: number = 0;
|
|
69
|
+
|
|
70
|
+
// ─── Headed State ────────────────────────────────────────
|
|
71
|
+
private connectionMode: 'launched' | 'headed' = 'launched';
|
|
72
|
+
private intentionalDisconnect = false;
|
|
73
|
+
|
|
74
|
+
getConnectionMode(): 'launched' | 'headed' { return this.connectionMode; }
|
|
75
|
+
|
|
76
|
+
// ─── Watch Mode Methods ─────────────────────────────────
|
|
77
|
+
isWatching(): boolean { return this.watching; }
|
|
78
|
+
|
|
79
|
+
startWatch(): void {
|
|
80
|
+
this.watching = true;
|
|
81
|
+
this.watchSnapshots = [];
|
|
82
|
+
this.watchStartTime = Date.now();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
stopWatch(): { snapshots: string[]; duration: number } {
|
|
86
|
+
this.watching = false;
|
|
87
|
+
if (this.watchInterval) {
|
|
88
|
+
clearInterval(this.watchInterval);
|
|
89
|
+
this.watchInterval = null;
|
|
90
|
+
}
|
|
91
|
+
const snapshots = this.watchSnapshots;
|
|
92
|
+
const duration = Date.now() - this.watchStartTime;
|
|
93
|
+
this.watchSnapshots = [];
|
|
94
|
+
this.watchStartTime = 0;
|
|
95
|
+
return { snapshots, duration };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
addWatchSnapshot(snapshot: string): void {
|
|
99
|
+
this.watchSnapshots.push(snapshot);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Find the gstack Chrome extension directory.
|
|
104
|
+
* Checks: repo root /extension, global install, dev install.
|
|
105
|
+
*/
|
|
106
|
+
private findExtensionPath(): string | null {
|
|
107
|
+
const fs = require('fs');
|
|
108
|
+
const path = require('path');
|
|
109
|
+
const candidates = [
|
|
110
|
+
// Relative to this source file (dev mode: browse/src/ -> ../../extension)
|
|
111
|
+
path.resolve(__dirname, '..', '..', 'extension'),
|
|
112
|
+
// Global gstack install
|
|
113
|
+
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'extension'),
|
|
114
|
+
// Git repo root (detected via BROWSE_STATE_FILE location)
|
|
115
|
+
(() => {
|
|
116
|
+
const stateFile = process.env.BROWSE_STATE_FILE || '';
|
|
117
|
+
if (stateFile) {
|
|
118
|
+
const repoRoot = path.resolve(path.dirname(stateFile), '..');
|
|
119
|
+
return path.join(repoRoot, '.claude', 'skills', 'gstack', 'extension');
|
|
120
|
+
}
|
|
121
|
+
return '';
|
|
122
|
+
})(),
|
|
123
|
+
].filter(Boolean);
|
|
124
|
+
|
|
125
|
+
for (const candidate of candidates) {
|
|
126
|
+
try {
|
|
127
|
+
if (fs.existsSync(path.join(candidate, 'manifest.json'))) {
|
|
128
|
+
return candidate;
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get the ref map for external consumers (e.g., /refs endpoint).
|
|
137
|
+
*/
|
|
138
|
+
getRefMap(): Array<{ ref: string; role: string; name: string }> {
|
|
139
|
+
const refs: Array<{ ref: string; role: string; name: string }> = [];
|
|
140
|
+
for (const [ref, entry] of this.refMap) {
|
|
141
|
+
refs.push({ ref, role: entry.role, name: entry.name });
|
|
142
|
+
}
|
|
143
|
+
return refs;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async launch() {
|
|
147
|
+
// ─── Extension Support ────────────────────────────────────
|
|
148
|
+
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
|
|
149
|
+
// Extensions only work in headed mode, so we use an off-screen window.
|
|
150
|
+
const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
|
|
151
|
+
const launchArgs: string[] = [];
|
|
152
|
+
let useHeadless = true;
|
|
153
|
+
|
|
154
|
+
// Docker/CI: Chromium sandbox requires unprivileged user namespaces which
|
|
155
|
+
// are typically disabled in containers. Detect container environment and
|
|
156
|
+
// add --no-sandbox automatically.
|
|
157
|
+
if (process.env.CI || process.env.CONTAINER) {
|
|
158
|
+
launchArgs.push('--no-sandbox');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (extensionsDir) {
|
|
162
|
+
launchArgs.push(
|
|
163
|
+
`--disable-extensions-except=${extensionsDir}`,
|
|
164
|
+
`--load-extension=${extensionsDir}`,
|
|
165
|
+
'--window-position=-9999,-9999',
|
|
166
|
+
'--window-size=1,1',
|
|
167
|
+
);
|
|
168
|
+
useHeadless = false; // extensions require headed mode; off-screen window simulates headless
|
|
169
|
+
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this.browser = await chromium.launch({
|
|
173
|
+
headless: useHeadless,
|
|
174
|
+
// On Windows, Chromium's sandbox fails when the server is spawned through
|
|
175
|
+
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
|
|
176
|
+
// browsing user-specified URLs has marginal sandbox benefit.
|
|
177
|
+
chromiumSandbox: process.platform !== 'win32',
|
|
178
|
+
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Chromium crash → exit with clear message
|
|
182
|
+
this.browser.on('disconnected', () => {
|
|
183
|
+
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
|
|
184
|
+
console.error('[browse] Console/network logs flushed to .gstack/browse-*.log');
|
|
185
|
+
process.exit(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const contextOptions: BrowserContextOptions = {
|
|
189
|
+
viewport: { width: 1280, height: 720 },
|
|
190
|
+
};
|
|
191
|
+
if (this.customUserAgent) {
|
|
192
|
+
contextOptions.userAgent = this.customUserAgent;
|
|
193
|
+
}
|
|
194
|
+
this.context = await this.browser.newContext(contextOptions);
|
|
195
|
+
|
|
196
|
+
if (Object.keys(this.extraHeaders).length > 0) {
|
|
197
|
+
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Create first tab
|
|
201
|
+
await this.newTab();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Headed Mode ─────────────────────────────────────────────
|
|
205
|
+
/**
|
|
206
|
+
* Launch Playwright's bundled Chromium in headed mode with the gstack
|
|
207
|
+
* Chrome extension auto-loaded. Uses launchPersistentContext() which
|
|
208
|
+
* is required for extension loading (launch() + newContext() can't
|
|
209
|
+
* load extensions).
|
|
210
|
+
*
|
|
211
|
+
* The browser launches headed with a visible window — the user sees
|
|
212
|
+
* every action Claude takes in real time.
|
|
213
|
+
*/
|
|
214
|
+
async launchHeaded(authToken?: string): Promise<void> {
|
|
215
|
+
// Clear old state before repopulating
|
|
216
|
+
this.pages.clear();
|
|
217
|
+
this.refMap.clear();
|
|
218
|
+
this.nextTabId = 1;
|
|
219
|
+
|
|
220
|
+
// Find the gstack extension directory for auto-loading
|
|
221
|
+
const extensionPath = this.findExtensionPath();
|
|
222
|
+
const launchArgs = ['--hide-crash-restore-bubble'];
|
|
223
|
+
if (extensionPath) {
|
|
224
|
+
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
|
|
225
|
+
launchArgs.push(`--load-extension=${extensionPath}`);
|
|
226
|
+
// Write auth token for extension bootstrap (read via chrome.runtime.getURL)
|
|
227
|
+
if (authToken) {
|
|
228
|
+
const fs = require('fs');
|
|
229
|
+
const path = require('path');
|
|
230
|
+
const authFile = path.join(extensionPath, '.auth.json');
|
|
231
|
+
try {
|
|
232
|
+
fs.writeFileSync(authFile, JSON.stringify({ token: authToken }), { mode: 0o600 });
|
|
233
|
+
} catch (err: any) {
|
|
234
|
+
console.warn(`[browse] Could not write .auth.json: ${err.message}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Launch headed Chromium via Playwright's persistent context.
|
|
240
|
+
// Extensions REQUIRE launchPersistentContext (not launch + newContext).
|
|
241
|
+
// Real Chrome (executablePath/channel) silently blocks --load-extension,
|
|
242
|
+
// so we use Playwright's bundled Chromium which reliably loads extensions.
|
|
243
|
+
const fs = require('fs');
|
|
244
|
+
const path = require('path');
|
|
245
|
+
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
|
246
|
+
fs.mkdirSync(userDataDir, { recursive: true });
|
|
247
|
+
|
|
248
|
+
this.context = await chromium.launchPersistentContext(userDataDir, {
|
|
249
|
+
headless: false,
|
|
250
|
+
args: launchArgs,
|
|
251
|
+
viewport: null, // Use browser's default viewport (real window size)
|
|
252
|
+
// Playwright adds flags that block extension loading
|
|
253
|
+
ignoreDefaultArgs: [
|
|
254
|
+
'--disable-extensions',
|
|
255
|
+
'--disable-component-extensions-with-background-pages',
|
|
256
|
+
],
|
|
257
|
+
});
|
|
258
|
+
this.browser = this.context.browser();
|
|
259
|
+
this.connectionMode = 'headed';
|
|
260
|
+
this.intentionalDisconnect = false;
|
|
261
|
+
|
|
262
|
+
// Inject visual indicator — subtle top-edge amber gradient
|
|
263
|
+
// Extension's content script handles the floating pill
|
|
264
|
+
const indicatorScript = () => {
|
|
265
|
+
const injectIndicator = () => {
|
|
266
|
+
if (document.getElementById('gstack-ctrl')) return;
|
|
267
|
+
|
|
268
|
+
const topLine = document.createElement('div');
|
|
269
|
+
topLine.id = 'gstack-ctrl';
|
|
270
|
+
topLine.style.cssText = `
|
|
271
|
+
position: fixed; top: 0; left: 0; right: 0; height: 2px;
|
|
272
|
+
background: linear-gradient(90deg, #F59E0B, #FBBF24, #F59E0B);
|
|
273
|
+
background-size: 200% 100%;
|
|
274
|
+
animation: gstack-shimmer 3s linear infinite;
|
|
275
|
+
pointer-events: none; z-index: 2147483647;
|
|
276
|
+
opacity: 0.8;
|
|
277
|
+
`;
|
|
278
|
+
|
|
279
|
+
const style = document.createElement('style');
|
|
280
|
+
style.textContent = `
|
|
281
|
+
@keyframes gstack-shimmer {
|
|
282
|
+
0% { background-position: 200% 0; }
|
|
283
|
+
100% { background-position: -200% 0; }
|
|
284
|
+
}
|
|
285
|
+
@media (prefers-reduced-motion: reduce) {
|
|
286
|
+
#gstack-ctrl { animation: none !important; }
|
|
287
|
+
}
|
|
288
|
+
`;
|
|
289
|
+
|
|
290
|
+
document.documentElement.appendChild(style);
|
|
291
|
+
document.documentElement.appendChild(topLine);
|
|
292
|
+
};
|
|
293
|
+
if (document.readyState === 'loading') {
|
|
294
|
+
document.addEventListener('DOMContentLoaded', injectIndicator);
|
|
295
|
+
} else {
|
|
296
|
+
injectIndicator();
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
await this.context.addInitScript(indicatorScript);
|
|
300
|
+
|
|
301
|
+
// Persistent context opens a default page — adopt it instead of creating a new one
|
|
302
|
+
const existingPages = this.context.pages();
|
|
303
|
+
if (existingPages.length > 0) {
|
|
304
|
+
const page = existingPages[0];
|
|
305
|
+
const id = this.nextTabId++;
|
|
306
|
+
this.pages.set(id, page);
|
|
307
|
+
this.activeTabId = id;
|
|
308
|
+
this.wirePageEvents(page);
|
|
309
|
+
// Inject indicator on restored page (addInitScript only fires on new navigations)
|
|
310
|
+
try { await page.evaluate(indicatorScript); } catch {}
|
|
311
|
+
} else {
|
|
312
|
+
await this.newTab();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Browser disconnect handler — exit code 2 distinguishes from crashes (1)
|
|
316
|
+
if (this.browser) {
|
|
317
|
+
this.browser.on('disconnected', () => {
|
|
318
|
+
if (this.intentionalDisconnect) return;
|
|
319
|
+
console.error('[browse] Real browser disconnected (user closed or crashed).');
|
|
320
|
+
console.error('[browse] Run `$B connect` to reconnect.');
|
|
321
|
+
process.exit(2);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Headed mode defaults
|
|
326
|
+
this.dialogAutoAccept = false; // Don't dismiss user's real dialogs
|
|
327
|
+
this.isHeaded = true;
|
|
328
|
+
this.consecutiveFailures = 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async close() {
|
|
332
|
+
if (this.browser || (this.connectionMode === 'headed' && this.context)) {
|
|
333
|
+
if (this.connectionMode === 'headed') {
|
|
334
|
+
// Headed/persistent context mode: close the context (which closes the browser)
|
|
335
|
+
this.intentionalDisconnect = true;
|
|
336
|
+
if (this.browser) this.browser.removeAllListeners('disconnected');
|
|
337
|
+
await Promise.race([
|
|
338
|
+
this.context ? this.context.close() : Promise.resolve(),
|
|
339
|
+
new Promise(resolve => setTimeout(resolve, 5000)),
|
|
340
|
+
]).catch(() => {});
|
|
341
|
+
} else {
|
|
342
|
+
// Launched mode: close the browser we spawned
|
|
343
|
+
this.browser.removeAllListeners('disconnected');
|
|
344
|
+
await Promise.race([
|
|
345
|
+
this.browser.close(),
|
|
346
|
+
new Promise(resolve => setTimeout(resolve, 5000)),
|
|
347
|
+
]).catch(() => {});
|
|
348
|
+
}
|
|
349
|
+
this.browser = null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Health check — verifies Chromium is connected AND responsive */
|
|
354
|
+
async isHealthy(): Promise<boolean> {
|
|
355
|
+
if (!this.browser || !this.browser.isConnected()) return false;
|
|
356
|
+
try {
|
|
357
|
+
const page = this.pages.get(this.activeTabId);
|
|
358
|
+
if (!page) return true; // connected but no pages — still healthy
|
|
359
|
+
await Promise.race([
|
|
360
|
+
page.evaluate('1'),
|
|
361
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000)),
|
|
362
|
+
]);
|
|
363
|
+
return true;
|
|
364
|
+
} catch {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ─── Tab Management ────────────────────────────────────────
|
|
370
|
+
async newTab(url?: string): Promise<number> {
|
|
371
|
+
if (!this.context) throw new Error('Browser not launched');
|
|
372
|
+
|
|
373
|
+
// Validate URL before allocating page to avoid zombie tabs on rejection
|
|
374
|
+
if (url) {
|
|
375
|
+
await validateNavigationUrl(url);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const page = await this.context.newPage();
|
|
379
|
+
const id = this.nextTabId++;
|
|
380
|
+
this.pages.set(id, page);
|
|
381
|
+
this.activeTabId = id;
|
|
382
|
+
|
|
383
|
+
// Wire up console/network/dialog capture
|
|
384
|
+
this.wirePageEvents(page);
|
|
385
|
+
|
|
386
|
+
if (url) {
|
|
387
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return id;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async closeTab(id?: number): Promise<void> {
|
|
394
|
+
const tabId = id ?? this.activeTabId;
|
|
395
|
+
const page = this.pages.get(tabId);
|
|
396
|
+
if (!page) throw new Error(`Tab ${tabId} not found`);
|
|
397
|
+
|
|
398
|
+
await page.close();
|
|
399
|
+
this.pages.delete(tabId);
|
|
400
|
+
|
|
401
|
+
// Switch to another tab if we closed the active one
|
|
402
|
+
if (tabId === this.activeTabId) {
|
|
403
|
+
const remaining = [...this.pages.keys()];
|
|
404
|
+
if (remaining.length > 0) {
|
|
405
|
+
this.activeTabId = remaining[remaining.length - 1];
|
|
406
|
+
} else {
|
|
407
|
+
// No tabs left — create a new blank one
|
|
408
|
+
await this.newTab();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
switchTab(id: number): void {
|
|
414
|
+
if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`);
|
|
415
|
+
this.activeTabId = id;
|
|
416
|
+
this.activeFrame = null; // Frame context is per-tab
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
getTabCount(): number {
|
|
420
|
+
return this.pages.size;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async getTabListWithTitles(): Promise<Array<{ id: number; url: string; title: string; active: boolean }>> {
|
|
424
|
+
const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = [];
|
|
425
|
+
for (const [id, page] of this.pages) {
|
|
426
|
+
tabs.push({
|
|
427
|
+
id,
|
|
428
|
+
url: page.url(),
|
|
429
|
+
title: await page.title().catch(() => ''),
|
|
430
|
+
active: id === this.activeTabId,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return tabs;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ─── Page Access ───────────────────────────────────────────
|
|
437
|
+
getPage(): Page {
|
|
438
|
+
const page = this.pages.get(this.activeTabId);
|
|
439
|
+
if (!page) throw new Error('No active page. Use "browse goto <url>" first.');
|
|
440
|
+
return page;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
getCurrentUrl(): string {
|
|
444
|
+
try {
|
|
445
|
+
return this.getPage().url();
|
|
446
|
+
} catch {
|
|
447
|
+
return 'about:blank';
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ─── Ref Map ──────────────────────────────────────────────
|
|
452
|
+
setRefMap(refs: Map<string, RefEntry>) {
|
|
453
|
+
this.refMap = refs;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
clearRefs() {
|
|
457
|
+
this.refMap.clear();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Resolve a selector that may be a @ref (e.g., "@e3", "@c1") or a CSS selector.
|
|
462
|
+
* Returns { locator } for refs or { selector } for CSS selectors.
|
|
463
|
+
*/
|
|
464
|
+
async resolveRef(selector: string): Promise<{ locator: Locator } | { selector: string }> {
|
|
465
|
+
if (selector.startsWith('@e') || selector.startsWith('@c')) {
|
|
466
|
+
const ref = selector.slice(1); // "e3" or "c1"
|
|
467
|
+
const entry = this.refMap.get(ref);
|
|
468
|
+
if (!entry) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
`Ref ${selector} not found. Run 'snapshot' to get fresh refs.`
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
const count = await entry.locator.count();
|
|
474
|
+
if (count === 0) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
`Ref ${selector} (${entry.role} "${entry.name}") is stale — element no longer exists. ` +
|
|
477
|
+
`Run 'snapshot' for fresh refs.`
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
return { locator: entry.locator };
|
|
481
|
+
}
|
|
482
|
+
return { selector };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Get the ARIA role for a ref selector, or null for CSS selectors / unknown refs. */
|
|
486
|
+
getRefRole(selector: string): string | null {
|
|
487
|
+
if (selector.startsWith('@e') || selector.startsWith('@c')) {
|
|
488
|
+
const entry = this.refMap.get(selector.slice(1));
|
|
489
|
+
return entry?.role ?? null;
|
|
490
|
+
}
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
getRefCount(): number {
|
|
495
|
+
return this.refMap.size;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ─── Snapshot Diffing ─────────────────────────────────────
|
|
499
|
+
setLastSnapshot(text: string | null) {
|
|
500
|
+
this.lastSnapshot = text;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
getLastSnapshot(): string | null {
|
|
504
|
+
return this.lastSnapshot;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ─── Dialog Control ───────────────────────────────────────
|
|
508
|
+
setDialogAutoAccept(accept: boolean) {
|
|
509
|
+
this.dialogAutoAccept = accept;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
getDialogAutoAccept(): boolean {
|
|
513
|
+
return this.dialogAutoAccept;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
setDialogPromptText(text: string | null) {
|
|
517
|
+
this.dialogPromptText = text;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
getDialogPromptText(): string | null {
|
|
521
|
+
return this.dialogPromptText;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ─── Viewport ──────────────────────────────────────────────
|
|
525
|
+
async setViewport(width: number, height: number) {
|
|
526
|
+
await this.getPage().setViewportSize({ width, height });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ─── Extra Headers ─────────────────────────────────────────
|
|
530
|
+
async setExtraHeader(name: string, value: string) {
|
|
531
|
+
this.extraHeaders[name] = value;
|
|
532
|
+
if (this.context) {
|
|
533
|
+
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ─── User Agent ────────────────────────────────────────────
|
|
538
|
+
setUserAgent(ua: string) {
|
|
539
|
+
this.customUserAgent = ua;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
getUserAgent(): string | null {
|
|
543
|
+
return this.customUserAgent;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ─── Lifecycle helpers ───────────────────────────────
|
|
547
|
+
/**
|
|
548
|
+
* Close all open pages and clear the pages map.
|
|
549
|
+
* Used by state load to replace the current session.
|
|
550
|
+
*/
|
|
551
|
+
async closeAllPages(): Promise<void> {
|
|
552
|
+
for (const page of this.pages.values()) {
|
|
553
|
+
await page.close().catch(() => {});
|
|
554
|
+
}
|
|
555
|
+
this.pages.clear();
|
|
556
|
+
this.clearRefs();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ─── Frame context ─────────────────────────────────
|
|
560
|
+
private activeFrame: import('playwright').Frame | null = null;
|
|
561
|
+
|
|
562
|
+
setFrame(frame: import('playwright').Frame | null): void {
|
|
563
|
+
this.activeFrame = frame;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
getFrame(): import('playwright').Frame | null {
|
|
567
|
+
return this.activeFrame;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Returns the active frame if set, otherwise the current page.
|
|
572
|
+
* Use this for operations that work on both Page and Frame (locator, evaluate, etc.).
|
|
573
|
+
*/
|
|
574
|
+
getActiveFrameOrPage(): import('playwright').Page | import('playwright').Frame {
|
|
575
|
+
// Auto-recover from detached frames (iframe removed/navigated)
|
|
576
|
+
if (this.activeFrame?.isDetached()) {
|
|
577
|
+
this.activeFrame = null;
|
|
578
|
+
}
|
|
579
|
+
return this.activeFrame ?? this.getPage();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ─── State Save/Restore (shared by recreateContext + handoff) ─
|
|
583
|
+
/**
|
|
584
|
+
* Capture browser state: cookies, localStorage, sessionStorage, URLs, active tab.
|
|
585
|
+
* Skips pages that fail storage reads (e.g., already closed).
|
|
586
|
+
*/
|
|
587
|
+
async saveState(): Promise<BrowserState> {
|
|
588
|
+
if (!this.context) throw new Error('Browser not launched');
|
|
589
|
+
|
|
590
|
+
const cookies = await this.context.cookies();
|
|
591
|
+
const pages: BrowserState['pages'] = [];
|
|
592
|
+
|
|
593
|
+
for (const [id, page] of this.pages) {
|
|
594
|
+
const url = page.url();
|
|
595
|
+
let storage = null;
|
|
596
|
+
try {
|
|
597
|
+
storage = await page.evaluate(() => ({
|
|
598
|
+
localStorage: { ...localStorage },
|
|
599
|
+
sessionStorage: { ...sessionStorage },
|
|
600
|
+
}));
|
|
601
|
+
} catch {}
|
|
602
|
+
pages.push({
|
|
603
|
+
url: url === 'about:blank' ? '' : url,
|
|
604
|
+
isActive: id === this.activeTabId,
|
|
605
|
+
storage,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return { cookies, pages };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Restore browser state into the current context: cookies, pages, storage.
|
|
614
|
+
* Navigates to saved URLs, restores storage, wires page events.
|
|
615
|
+
* Failures on individual pages are swallowed — partial restore is better than none.
|
|
616
|
+
*/
|
|
617
|
+
async restoreState(state: BrowserState): Promise<void> {
|
|
618
|
+
if (!this.context) throw new Error('Browser not launched');
|
|
619
|
+
|
|
620
|
+
// Restore cookies
|
|
621
|
+
if (state.cookies.length > 0) {
|
|
622
|
+
await this.context.addCookies(state.cookies);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Re-create pages
|
|
626
|
+
let activeId: number | null = null;
|
|
627
|
+
for (const saved of state.pages) {
|
|
628
|
+
const page = await this.context.newPage();
|
|
629
|
+
const id = this.nextTabId++;
|
|
630
|
+
this.pages.set(id, page);
|
|
631
|
+
this.wirePageEvents(page);
|
|
632
|
+
|
|
633
|
+
if (saved.url) {
|
|
634
|
+
await page.goto(saved.url, { waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (saved.storage) {
|
|
638
|
+
try {
|
|
639
|
+
await page.evaluate((s: { localStorage: Record<string, string>; sessionStorage: Record<string, string> }) => {
|
|
640
|
+
if (s.localStorage) {
|
|
641
|
+
for (const [k, v] of Object.entries(s.localStorage)) {
|
|
642
|
+
localStorage.setItem(k, v);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (s.sessionStorage) {
|
|
646
|
+
for (const [k, v] of Object.entries(s.sessionStorage)) {
|
|
647
|
+
sessionStorage.setItem(k, v);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}, saved.storage);
|
|
651
|
+
} catch {}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (saved.isActive) activeId = id;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// If no pages were saved, create a blank one
|
|
658
|
+
if (this.pages.size === 0) {
|
|
659
|
+
await this.newTab();
|
|
660
|
+
} else {
|
|
661
|
+
this.activeTabId = activeId ?? [...this.pages.keys()][0];
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Clear refs — pages are new, locators are stale
|
|
665
|
+
this.clearRefs();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Recreate the browser context to apply user agent changes.
|
|
670
|
+
* Saves and restores cookies, localStorage, sessionStorage, and open pages.
|
|
671
|
+
* Falls back to a clean slate on any failure.
|
|
672
|
+
*/
|
|
673
|
+
async recreateContext(): Promise<string | null> {
|
|
674
|
+
if (this.connectionMode === 'headed') {
|
|
675
|
+
throw new Error('Cannot recreate context in headed mode. Use disconnect first.');
|
|
676
|
+
}
|
|
677
|
+
if (!this.browser || !this.context) {
|
|
678
|
+
throw new Error('Browser not launched');
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
// 1. Save state
|
|
683
|
+
const state = await this.saveState();
|
|
684
|
+
|
|
685
|
+
// 2. Close old pages and context
|
|
686
|
+
for (const page of this.pages.values()) {
|
|
687
|
+
await page.close().catch(() => {});
|
|
688
|
+
}
|
|
689
|
+
this.pages.clear();
|
|
690
|
+
await this.context.close().catch(() => {});
|
|
691
|
+
|
|
692
|
+
// 3. Create new context with updated settings
|
|
693
|
+
const contextOptions: BrowserContextOptions = {
|
|
694
|
+
viewport: { width: 1280, height: 720 },
|
|
695
|
+
};
|
|
696
|
+
if (this.customUserAgent) {
|
|
697
|
+
contextOptions.userAgent = this.customUserAgent;
|
|
698
|
+
}
|
|
699
|
+
this.context = await this.browser.newContext(contextOptions);
|
|
700
|
+
|
|
701
|
+
if (Object.keys(this.extraHeaders).length > 0) {
|
|
702
|
+
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// 4. Restore state
|
|
706
|
+
await this.restoreState(state);
|
|
707
|
+
|
|
708
|
+
return null; // success
|
|
709
|
+
} catch (err: unknown) {
|
|
710
|
+
// Fallback: create a clean context + blank tab
|
|
711
|
+
try {
|
|
712
|
+
this.pages.clear();
|
|
713
|
+
if (this.context) await this.context.close().catch(() => {});
|
|
714
|
+
|
|
715
|
+
const contextOptions: BrowserContextOptions = {
|
|
716
|
+
viewport: { width: 1280, height: 720 },
|
|
717
|
+
};
|
|
718
|
+
if (this.customUserAgent) {
|
|
719
|
+
contextOptions.userAgent = this.customUserAgent;
|
|
720
|
+
}
|
|
721
|
+
this.context = await this.browser!.newContext(contextOptions);
|
|
722
|
+
await this.newTab();
|
|
723
|
+
this.clearRefs();
|
|
724
|
+
} catch {
|
|
725
|
+
// If even the fallback fails, we're in trouble — but browser is still alive
|
|
726
|
+
}
|
|
727
|
+
return `Context recreation failed: ${err instanceof Error ? err.message : String(err)}. Browser reset to blank tab.`;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ─── Handoff: Headless → Headed ─────────────────────────────
|
|
732
|
+
/**
|
|
733
|
+
* Hand off browser control to the user by relaunching in headed mode.
|
|
734
|
+
*
|
|
735
|
+
* Flow (launch-first-close-second for safe rollback):
|
|
736
|
+
* 1. Save state from current headless browser
|
|
737
|
+
* 2. Launch NEW headed browser
|
|
738
|
+
* 3. Restore state into new browser
|
|
739
|
+
* 4. Close OLD headless browser
|
|
740
|
+
* If step 2 fails → return error, headless browser untouched
|
|
741
|
+
*/
|
|
742
|
+
async handoff(message: string): Promise<string> {
|
|
743
|
+
if (this.connectionMode === 'headed' || this.isHeaded) {
|
|
744
|
+
return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`;
|
|
745
|
+
}
|
|
746
|
+
if (!this.browser || !this.context) {
|
|
747
|
+
throw new Error('Browser not launched');
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// 1. Save state from current browser
|
|
751
|
+
const state = await this.saveState();
|
|
752
|
+
const currentUrl = this.getCurrentUrl();
|
|
753
|
+
|
|
754
|
+
// 2. Launch new headed browser with extension (same as launchHeaded)
|
|
755
|
+
// Uses launchPersistentContext so the extension auto-loads.
|
|
756
|
+
let newContext: BrowserContext;
|
|
757
|
+
try {
|
|
758
|
+
const fs = require('fs');
|
|
759
|
+
const path = require('path');
|
|
760
|
+
const extensionPath = this.findExtensionPath();
|
|
761
|
+
const launchArgs = ['--hide-crash-restore-bubble'];
|
|
762
|
+
if (extensionPath) {
|
|
763
|
+
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
|
|
764
|
+
launchArgs.push(`--load-extension=${extensionPath}`);
|
|
765
|
+
// Write auth token for extension bootstrap during handoff
|
|
766
|
+
if (this.serverPort) {
|
|
767
|
+
try {
|
|
768
|
+
const { resolveConfig } = require('./config');
|
|
769
|
+
const config = resolveConfig();
|
|
770
|
+
const stateFile = path.join(config.stateDir, 'browse.json');
|
|
771
|
+
if (fs.existsSync(stateFile)) {
|
|
772
|
+
const stateData = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
|
773
|
+
if (stateData.token) {
|
|
774
|
+
fs.writeFileSync(path.join(extensionPath, '.auth.json'), JSON.stringify({ token: stateData.token }), { mode: 0o600 });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
} catch {}
|
|
778
|
+
}
|
|
779
|
+
console.log(`[browse] Handoff: loading extension from ${extensionPath}`);
|
|
780
|
+
} else {
|
|
781
|
+
console.log('[browse] Handoff: extension not found — headed mode without side panel');
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
|
785
|
+
fs.mkdirSync(userDataDir, { recursive: true });
|
|
786
|
+
|
|
787
|
+
newContext = await chromium.launchPersistentContext(userDataDir, {
|
|
788
|
+
headless: false,
|
|
789
|
+
args: launchArgs,
|
|
790
|
+
viewport: null,
|
|
791
|
+
ignoreDefaultArgs: [
|
|
792
|
+
'--disable-extensions',
|
|
793
|
+
'--disable-component-extensions-with-background-pages',
|
|
794
|
+
],
|
|
795
|
+
timeout: 15000,
|
|
796
|
+
});
|
|
797
|
+
} catch (err: unknown) {
|
|
798
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
799
|
+
return `ERROR: Cannot open headed browser — ${msg}. Headless browser still running.`;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// 3. Restore state into new headed browser
|
|
803
|
+
try {
|
|
804
|
+
// Swap to new browser/context before restoreState (it uses this.context)
|
|
805
|
+
const oldBrowser = this.browser;
|
|
806
|
+
|
|
807
|
+
this.context = newContext;
|
|
808
|
+
this.browser = newContext.browser();
|
|
809
|
+
this.pages.clear();
|
|
810
|
+
this.connectionMode = 'headed';
|
|
811
|
+
|
|
812
|
+
if (Object.keys(this.extraHeaders).length > 0) {
|
|
813
|
+
await newContext.setExtraHTTPHeaders(this.extraHeaders);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Register crash handler on new browser
|
|
817
|
+
if (this.browser) {
|
|
818
|
+
this.browser.on('disconnected', () => {
|
|
819
|
+
if (this.intentionalDisconnect) return;
|
|
820
|
+
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
|
|
821
|
+
process.exit(1);
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
await this.restoreState(state);
|
|
826
|
+
this.isHeaded = true;
|
|
827
|
+
this.dialogAutoAccept = false; // User controls dialogs in headed mode
|
|
828
|
+
|
|
829
|
+
// 4. Close old headless browser (fire-and-forget)
|
|
830
|
+
oldBrowser.removeAllListeners('disconnected');
|
|
831
|
+
oldBrowser.close().catch(() => {});
|
|
832
|
+
|
|
833
|
+
return [
|
|
834
|
+
`HANDOFF: Browser opened at ${currentUrl}`,
|
|
835
|
+
`MESSAGE: ${message}`,
|
|
836
|
+
`STATUS: Waiting for user. Run 'resume' when done.`,
|
|
837
|
+
].join('\n');
|
|
838
|
+
} catch (err: unknown) {
|
|
839
|
+
// Restore failed — close the new context, keep old state
|
|
840
|
+
await newContext.close().catch(() => {});
|
|
841
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
842
|
+
return `ERROR: Handoff failed during state restore — ${msg}. Headless browser still running.`;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Resume AI control after user handoff.
|
|
848
|
+
* Clears stale refs and resets failure counter.
|
|
849
|
+
* The meta-command handler calls handleSnapshot() after this.
|
|
850
|
+
*/
|
|
851
|
+
resume(): void {
|
|
852
|
+
this.clearRefs();
|
|
853
|
+
this.resetFailures();
|
|
854
|
+
this.activeFrame = null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
getIsHeaded(): boolean {
|
|
858
|
+
return this.isHeaded;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ─── Auto-handoff Hint (consecutive failure tracking) ───────
|
|
862
|
+
incrementFailures(): void {
|
|
863
|
+
this.consecutiveFailures++;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
resetFailures(): void {
|
|
867
|
+
this.consecutiveFailures = 0;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
getFailureHint(): string | null {
|
|
871
|
+
if (this.consecutiveFailures >= 3 && !this.isHeaded) {
|
|
872
|
+
return `HINT: ${this.consecutiveFailures} consecutive failures. Consider using 'handoff' to let the user help.`;
|
|
873
|
+
}
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// ─── Console/Network/Dialog/Ref Wiring ────────────────────
|
|
878
|
+
private wirePageEvents(page: Page) {
|
|
879
|
+
// Clear ref map on navigation — refs point to stale elements after page change
|
|
880
|
+
// (lastSnapshot is NOT cleared — it's a text baseline for diffing)
|
|
881
|
+
page.on('framenavigated', (frame) => {
|
|
882
|
+
if (frame === page.mainFrame()) {
|
|
883
|
+
this.clearRefs();
|
|
884
|
+
this.activeFrame = null; // Navigation invalidates frame context
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
// ─── Dialog auto-handling (prevents browser lockup) ─────
|
|
889
|
+
page.on('dialog', async (dialog) => {
|
|
890
|
+
const entry: DialogEntry = {
|
|
891
|
+
timestamp: Date.now(),
|
|
892
|
+
type: dialog.type(),
|
|
893
|
+
message: dialog.message(),
|
|
894
|
+
defaultValue: dialog.defaultValue() || undefined,
|
|
895
|
+
action: this.dialogAutoAccept ? 'accepted' : 'dismissed',
|
|
896
|
+
response: this.dialogAutoAccept ? (this.dialogPromptText ?? undefined) : undefined,
|
|
897
|
+
};
|
|
898
|
+
addDialogEntry(entry);
|
|
899
|
+
|
|
900
|
+
try {
|
|
901
|
+
if (this.dialogAutoAccept) {
|
|
902
|
+
await dialog.accept(this.dialogPromptText ?? undefined);
|
|
903
|
+
} else {
|
|
904
|
+
await dialog.dismiss();
|
|
905
|
+
}
|
|
906
|
+
} catch {
|
|
907
|
+
// Dialog may have been dismissed by navigation — ignore
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
page.on('console', (msg) => {
|
|
912
|
+
addConsoleEntry({
|
|
913
|
+
timestamp: Date.now(),
|
|
914
|
+
level: msg.type(),
|
|
915
|
+
text: msg.text(),
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
page.on('request', (req) => {
|
|
920
|
+
addNetworkEntry({
|
|
921
|
+
timestamp: Date.now(),
|
|
922
|
+
method: req.method(),
|
|
923
|
+
url: req.url(),
|
|
924
|
+
});
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
page.on('response', (res) => {
|
|
928
|
+
// Find matching request entry and update it (backward scan)
|
|
929
|
+
const url = res.url();
|
|
930
|
+
const status = res.status();
|
|
931
|
+
for (let i = networkBuffer.length - 1; i >= 0; i--) {
|
|
932
|
+
const entry = networkBuffer.get(i);
|
|
933
|
+
if (entry && entry.url === url && !entry.status) {
|
|
934
|
+
networkBuffer.set(i, { ...entry, status, duration: Date.now() - entry.timestamp });
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
// Capture response sizes via response finished
|
|
941
|
+
page.on('requestfinished', async (req) => {
|
|
942
|
+
try {
|
|
943
|
+
const res = await req.response();
|
|
944
|
+
if (res) {
|
|
945
|
+
const url = req.url();
|
|
946
|
+
const body = await res.body().catch(() => null);
|
|
947
|
+
const size = body ? body.length : 0;
|
|
948
|
+
for (let i = networkBuffer.length - 1; i >= 0; i--) {
|
|
949
|
+
const entry = networkBuffer.get(i);
|
|
950
|
+
if (entry && entry.url === url && !entry.size) {
|
|
951
|
+
networkBuffer.set(i, { ...entry, size });
|
|
952
|
+
break;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
} catch {}
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
}
|