opengstack 0.13.9 → 0.14.0

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.
Files changed (152) hide show
  1. package/{skills/land-and-deploy/SKILL.md → commands/autoplan.md} +0 -16
  2. package/{skills/benchmark/SKILL.md → commands/benchmark.md} +0 -17
  3. package/{skills/browse/SKILL.md → commands/browse.md} +0 -17
  4. package/{skills/ship/SKILL.md → commands/canary.md} +0 -18
  5. package/{skills/careful/SKILL.md → commands/careful.md} +0 -20
  6. package/{skills/canary/SKILL.md → commands/codex.md} +0 -17
  7. package/{skills/connect-chrome/SKILL.md → commands/connect-chrome.md} +0 -15
  8. package/commands/cso.md +72 -0
  9. package/commands/design-consultation.md +72 -0
  10. package/commands/design-review.md +72 -0
  11. package/commands/design-shotgun.md +72 -0
  12. package/commands/document-release.md +72 -0
  13. package/{skills/freeze/SKILL.md → commands/freeze.md} +0 -26
  14. package/{skills/gstack-upgrade/SKILL.md → commands/gstack-upgrade.md} +0 -14
  15. package/{skills/guard/SKILL.md → commands/guard.md} +0 -31
  16. package/commands/investigate.md +72 -0
  17. package/commands/land-and-deploy.md +72 -0
  18. package/commands/office-hours.md +72 -0
  19. package/commands/plan-ceo-review.md +72 -0
  20. package/commands/plan-design-review.md +72 -0
  21. package/commands/plan-eng-review.md +72 -0
  22. package/commands/qa-only.md +72 -0
  23. package/commands/qa.md +72 -0
  24. package/commands/retro.md +72 -0
  25. package/commands/review.md +72 -0
  26. package/{skills/setup-browser-cookies/SKILL.md → commands/setup-browser-cookies.md} +0 -14
  27. package/commands/setup-deploy.md +72 -0
  28. package/commands/ship.md +72 -0
  29. package/{skills/unfreeze/SKILL.md → commands/unfreeze.md} +0 -12
  30. package/package.json +4 -4
  31. package/scripts/install-commands.js +45 -0
  32. package/scripts/install-skills.js +4 -7
  33. package/skills/autoplan/SKILL.md +0 -96
  34. package/skills/autoplan/SKILL.md.tmpl +0 -694
  35. package/skills/benchmark/SKILL.md.tmpl +0 -222
  36. package/skills/browse/SKILL.md.tmpl +0 -131
  37. package/skills/browse/bin/find-browse +0 -21
  38. package/skills/browse/bin/remote-slug +0 -14
  39. package/skills/browse/scripts/build-node-server.sh +0 -48
  40. package/skills/browse/src/activity.ts +0 -208
  41. package/skills/browse/src/browser-manager.ts +0 -959
  42. package/skills/browse/src/buffers.ts +0 -137
  43. package/skills/browse/src/bun-polyfill.cjs +0 -109
  44. package/skills/browse/src/cli.ts +0 -678
  45. package/skills/browse/src/commands.ts +0 -128
  46. package/skills/browse/src/config.ts +0 -150
  47. package/skills/browse/src/cookie-import-browser.ts +0 -625
  48. package/skills/browse/src/cookie-picker-routes.ts +0 -230
  49. package/skills/browse/src/cookie-picker-ui.ts +0 -688
  50. package/skills/browse/src/find-browse.ts +0 -61
  51. package/skills/browse/src/meta-commands.ts +0 -550
  52. package/skills/browse/src/platform.ts +0 -17
  53. package/skills/browse/src/read-commands.ts +0 -358
  54. package/skills/browse/src/server.ts +0 -1192
  55. package/skills/browse/src/sidebar-agent.ts +0 -280
  56. package/skills/browse/src/sidebar-utils.ts +0 -21
  57. package/skills/browse/src/snapshot.ts +0 -407
  58. package/skills/browse/src/url-validation.ts +0 -95
  59. package/skills/browse/src/write-commands.ts +0 -364
  60. package/skills/browse/test/activity.test.ts +0 -120
  61. package/skills/browse/test/adversarial-security.test.ts +0 -32
  62. package/skills/browse/test/browser-manager-unit.test.ts +0 -17
  63. package/skills/browse/test/bun-polyfill.test.ts +0 -72
  64. package/skills/browse/test/commands.test.ts +0 -2075
  65. package/skills/browse/test/compare-board.test.ts +0 -342
  66. package/skills/browse/test/config.test.ts +0 -316
  67. package/skills/browse/test/cookie-import-browser.test.ts +0 -519
  68. package/skills/browse/test/cookie-picker-routes.test.ts +0 -260
  69. package/skills/browse/test/file-drop.test.ts +0 -271
  70. package/skills/browse/test/find-browse.test.ts +0 -50
  71. package/skills/browse/test/findport.test.ts +0 -191
  72. package/skills/browse/test/fixtures/basic.html +0 -33
  73. package/skills/browse/test/fixtures/cursor-interactive.html +0 -22
  74. package/skills/browse/test/fixtures/dialog.html +0 -15
  75. package/skills/browse/test/fixtures/empty.html +0 -2
  76. package/skills/browse/test/fixtures/forms.html +0 -55
  77. package/skills/browse/test/fixtures/iframe.html +0 -30
  78. package/skills/browse/test/fixtures/network-idle.html +0 -30
  79. package/skills/browse/test/fixtures/qa-eval-checkout.html +0 -108
  80. package/skills/browse/test/fixtures/qa-eval-spa.html +0 -98
  81. package/skills/browse/test/fixtures/qa-eval.html +0 -51
  82. package/skills/browse/test/fixtures/responsive.html +0 -49
  83. package/skills/browse/test/fixtures/snapshot.html +0 -55
  84. package/skills/browse/test/fixtures/spa.html +0 -24
  85. package/skills/browse/test/fixtures/states.html +0 -17
  86. package/skills/browse/test/fixtures/upload.html +0 -25
  87. package/skills/browse/test/gstack-config.test.ts +0 -138
  88. package/skills/browse/test/gstack-update-check.test.ts +0 -514
  89. package/skills/browse/test/handoff.test.ts +0 -235
  90. package/skills/browse/test/path-validation.test.ts +0 -91
  91. package/skills/browse/test/platform.test.ts +0 -37
  92. package/skills/browse/test/server-auth.test.ts +0 -65
  93. package/skills/browse/test/sidebar-agent-roundtrip.test.ts +0 -226
  94. package/skills/browse/test/sidebar-agent.test.ts +0 -199
  95. package/skills/browse/test/sidebar-integration.test.ts +0 -320
  96. package/skills/browse/test/sidebar-unit.test.ts +0 -96
  97. package/skills/browse/test/snapshot.test.ts +0 -467
  98. package/skills/browse/test/state-ttl.test.ts +0 -35
  99. package/skills/browse/test/test-server.ts +0 -57
  100. package/skills/browse/test/url-validation.test.ts +0 -72
  101. package/skills/browse/test/watch.test.ts +0 -129
  102. package/skills/canary/SKILL.md.tmpl +0 -212
  103. package/skills/careful/SKILL.md.tmpl +0 -56
  104. package/skills/careful/bin/check-careful.sh +0 -112
  105. package/skills/codex/SKILL.md +0 -90
  106. package/skills/codex/SKILL.md.tmpl +0 -417
  107. package/skills/connect-chrome/SKILL.md.tmpl +0 -195
  108. package/skills/cso/ACKNOWLEDGEMENTS.md +0 -14
  109. package/skills/cso/SKILL.md +0 -93
  110. package/skills/cso/SKILL.md.tmpl +0 -606
  111. package/skills/design-consultation/SKILL.md +0 -94
  112. package/skills/design-consultation/SKILL.md.tmpl +0 -415
  113. package/skills/design-review/SKILL.md +0 -94
  114. package/skills/design-review/SKILL.md.tmpl +0 -290
  115. package/skills/design-shotgun/SKILL.md +0 -91
  116. package/skills/design-shotgun/SKILL.md.tmpl +0 -285
  117. package/skills/document-release/SKILL.md +0 -91
  118. package/skills/document-release/SKILL.md.tmpl +0 -359
  119. package/skills/freeze/SKILL.md.tmpl +0 -77
  120. package/skills/freeze/bin/check-freeze.sh +0 -79
  121. package/skills/gstack-upgrade/SKILL.md.tmpl +0 -222
  122. package/skills/guard/SKILL.md.tmpl +0 -77
  123. package/skills/investigate/SKILL.md +0 -105
  124. package/skills/investigate/SKILL.md.tmpl +0 -194
  125. package/skills/land-and-deploy/SKILL.md.tmpl +0 -881
  126. package/skills/office-hours/SKILL.md +0 -96
  127. package/skills/office-hours/SKILL.md.tmpl +0 -645
  128. package/skills/plan-ceo-review/SKILL.md +0 -94
  129. package/skills/plan-ceo-review/SKILL.md.tmpl +0 -811
  130. package/skills/plan-design-review/SKILL.md +0 -92
  131. package/skills/plan-design-review/SKILL.md.tmpl +0 -446
  132. package/skills/plan-eng-review/SKILL.md +0 -93
  133. package/skills/plan-eng-review/SKILL.md.tmpl +0 -303
  134. package/skills/qa/SKILL.md +0 -95
  135. package/skills/qa/SKILL.md.tmpl +0 -316
  136. package/skills/qa/references/issue-taxonomy.md +0 -85
  137. package/skills/qa/templates/qa-report-template.md +0 -126
  138. package/skills/qa-only/SKILL.md +0 -89
  139. package/skills/qa-only/SKILL.md.tmpl +0 -101
  140. package/skills/retro/SKILL.md +0 -89
  141. package/skills/retro/SKILL.md.tmpl +0 -820
  142. package/skills/review/SKILL.md +0 -92
  143. package/skills/review/SKILL.md.tmpl +0 -281
  144. package/skills/review/TODOS-format.md +0 -62
  145. package/skills/review/checklist.md +0 -220
  146. package/skills/review/design-checklist.md +0 -132
  147. package/skills/review/greptile-triage.md +0 -220
  148. package/skills/setup-browser-cookies/SKILL.md.tmpl +0 -81
  149. package/skills/setup-deploy/SKILL.md +0 -92
  150. package/skills/setup-deploy/SKILL.md.tmpl +0 -215
  151. package/skills/ship/SKILL.md.tmpl +0 -636
  152. package/skills/unfreeze/SKILL.md.tmpl +0 -36
@@ -1,959 +0,0 @@
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
- }