unbrowse 2.12.2 → 2.12.4

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 (60) hide show
  1. package/README.md +86 -5
  2. package/SKILL.md +754 -0
  3. package/bin/unbrowse-update-hint.mjs +22 -0
  4. package/bin/unbrowse-wrapper.mjs +84 -16
  5. package/bin/unbrowse.js +0 -1
  6. package/dist/cli.js +1899 -19159
  7. package/dist/mcp.js +1796 -0
  8. package/package.json +6 -3
  9. package/runtime-src/agent-outcome.ts +166 -0
  10. package/runtime-src/analytics-session.ts +28 -6
  11. package/runtime-src/api/browse-session.ts +520 -51
  12. package/runtime-src/api/browse-submit-prereqs.ts +48 -0
  13. package/runtime-src/api/browse-submit.ts +746 -17
  14. package/runtime-src/api/routes.ts +950 -427
  15. package/runtime-src/auth/index.ts +160 -7
  16. package/runtime-src/browser/index.ts +17 -9
  17. package/runtime-src/build-info.generated.ts +4 -0
  18. package/runtime-src/capture/index.ts +30 -22
  19. package/runtime-src/cli.ts +412 -83
  20. package/runtime-src/client/index.ts +97 -24
  21. package/runtime-src/execution/index.ts +351 -60
  22. package/runtime-src/indexer/index.ts +208 -247
  23. package/runtime-src/kuri/client.ts +774 -267
  24. package/runtime-src/mcp.ts +1522 -0
  25. package/runtime-src/orchestrator/first-pass-action.ts +69 -28
  26. package/runtime-src/orchestrator/index.ts +603 -133
  27. package/runtime-src/orchestrator/passive-publish.ts +33 -3
  28. package/runtime-src/payments/wallet.ts +76 -11
  29. package/runtime-src/publish/sanitize.ts +197 -0
  30. package/runtime-src/publish-admission.ts +279 -0
  31. package/runtime-src/reverse-engineer/description-prompt.ts +83 -2
  32. package/runtime-src/reverse-engineer/index.ts +29 -10
  33. package/runtime-src/routing-telemetry.ts +395 -0
  34. package/runtime-src/runtime/browser-auth.ts +12 -0
  35. package/runtime-src/runtime/local-server.ts +107 -24
  36. package/runtime-src/runtime/setup.ts +11 -7
  37. package/runtime-src/runtime/update-hints.ts +351 -0
  38. package/runtime-src/server.ts +5 -0
  39. package/runtime-src/settings.ts +221 -0
  40. package/runtime-src/site-policy.ts +54 -0
  41. package/runtime-src/stale-cleanup-runner.ts +144 -0
  42. package/runtime-src/stale-cleanup.ts +133 -0
  43. package/runtime-src/telemetry-attribution.ts +120 -0
  44. package/runtime-src/types/skill.ts +439 -0
  45. package/runtime-src/verification/auth-gate.ts +8 -0
  46. package/runtime-src/verification/candidates.ts +27 -0
  47. package/runtime-src/verification/index.ts +21 -15
  48. package/runtime-src/version.ts +73 -13
  49. package/runtime-src/workflow/artifact.ts +161 -0
  50. package/runtime-src/workflow/compile.ts +808 -0
  51. package/runtime-src/workflow/publish.ts +205 -0
  52. package/runtime-src/workflow/runtime.ts +213 -0
  53. package/scripts/postinstall.mjs +43 -19
  54. package/scripts/release-assets.mjs +24 -0
  55. package/scripts/verify-release-assets.mjs +39 -0
  56. package/vendor/kuri/darwin-arm64/kuri +0 -0
  57. package/vendor/kuri/darwin-x64/kuri +0 -0
  58. package/vendor/kuri/linux-arm64/kuri +0 -0
  59. package/vendor/kuri/linux-x64/kuri +0 -0
  60. package/vendor/kuri/manifest.json +24 -0
@@ -1,10 +1,14 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { getKuriErrorMessage } from "../kuri/client.js";
2
3
 
3
4
  export interface BrowseSession {
5
+ sessionId: string;
4
6
  tabId: string;
5
7
  url: string;
6
8
  harActive: boolean;
7
9
  domain: string;
10
+ brokerPort?: number;
11
+ client?: BrowseSessionClient;
8
12
  }
9
13
 
10
14
  export interface BrowseTabRef {
@@ -19,6 +23,25 @@ export interface BrowseSessionClient {
19
23
  closeTab(tabId: string): Promise<void>;
20
24
  discoverTabs(): Promise<BrowseTabRef[]>;
21
25
  getCurrentUrl(tabId: string): Promise<string>;
26
+ getPort?(): number;
27
+ }
28
+
29
+ export type BrowseSessionErrorCode =
30
+ | "no_active_session"
31
+ | "session_id_required"
32
+ | "session_not_found"
33
+ | "session_expired";
34
+
35
+ export class BrowseSessionError extends Error {
36
+ code: BrowseSessionErrorCode;
37
+ statusCode: number;
38
+
39
+ constructor(code: BrowseSessionErrorCode, message?: string) {
40
+ super(message ?? code);
41
+ this.name = "BrowseSessionError";
42
+ this.code = code;
43
+ this.statusCode = browseSessionErrorStatus(code);
44
+ }
22
45
  }
23
46
 
24
47
  const RECOVERABLE_BROWSE_FAILURES = [
@@ -30,8 +53,28 @@ const RECOVERABLE_BROWSE_FAILURES = [
30
53
  "execution context was destroyed",
31
54
  "cannot find context with specified id",
32
55
  "no such target",
56
+ "socket connection was closed unexpectedly",
57
+ "econnreset",
33
58
  ];
34
59
 
60
+ const LIVE_CHECK_RETRIES = 8;
61
+ const LIVE_CHECK_RETRY_DELAY_MS = 250;
62
+
63
+ const sessionQueues = new Map<string, Promise<void>>();
64
+
65
+ function browseSessionErrorStatus(code: BrowseSessionErrorCode): number {
66
+ switch (code) {
67
+ case "session_not_found":
68
+ return 404;
69
+ case "no_active_session":
70
+ return 404;
71
+ case "session_id_required":
72
+ return 409;
73
+ case "session_expired":
74
+ return 409;
75
+ }
76
+ }
77
+
35
78
  export function extractBrowseFailureMessage(value: unknown): string | null {
36
79
  return typeof value === "string" ? value : getKuriErrorMessage(value);
37
80
  }
@@ -43,19 +86,8 @@ export function isRecoverableBrowseFailure(value: unknown): boolean {
43
86
  return RECOVERABLE_BROWSE_FAILURES.some((needle) => normalized.includes(needle));
44
87
  }
45
88
 
46
- async function createBrowseSession(
47
- sessions: Map<string, BrowseSession>,
48
- client: BrowseSessionClient,
49
- injectInterceptor: (tabId: string) => Promise<unknown>,
50
- ): Promise<BrowseSession> {
51
- await client.start().catch(() => {});
52
- const tabId = await client.newTab();
53
- if (!tabId) throw new Error("Failed to create browser tab");
54
- await client.harStart(tabId).catch(() => {});
55
- await injectInterceptor(tabId);
56
- const session: BrowseSession = { tabId, url: "about:blank", harActive: true, domain: "" };
57
- sessions.set("default", session);
58
- return session;
89
+ function normalizeSessionId(sessionId?: string): string {
90
+ return sessionId?.trim() || randomUUID();
59
91
  }
60
92
 
61
93
  function extractDomain(url: string | undefined): string {
@@ -67,49 +99,255 @@ function extractDomain(url: string | undefined): string {
67
99
  }
68
100
  }
69
101
 
102
+ function normalizeBrowseUrl(url: string | undefined): URL | null {
103
+ if (!url) return null;
104
+ try {
105
+ return new URL(url);
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function normalizeBrowsePathname(pathname: string): string {
112
+ if (!pathname) return "/";
113
+ return pathname.endsWith("/") && pathname !== "/" ? pathname.slice(0, -1) : pathname;
114
+ }
115
+
116
+ function matchesPreferredBrowseTab(tabUrl: string | undefined, preferredUrl: string | undefined): boolean {
117
+ const candidate = normalizeBrowseUrl(tabUrl);
118
+ const preferred = normalizeBrowseUrl(preferredUrl);
119
+ if (!candidate || !preferred) return false;
120
+ if (candidate.hostname.replace(/^www\./, "") !== preferred.hostname.replace(/^www\./, "")) return false;
121
+ return normalizeBrowsePathname(candidate.pathname) === normalizeBrowsePathname(preferred.pathname);
122
+ }
123
+
124
+ function matchesPreferredBrowseDomain(tabUrl: string | undefined, preferredUrl: string | undefined): boolean {
125
+ const candidate = normalizeBrowseUrl(tabUrl);
126
+ const preferred = normalizeBrowseUrl(preferredUrl);
127
+ if (!candidate || !preferred) return false;
128
+ return candidate.hostname.replace(/^www\./, "") === preferred.hostname.replace(/^www\./, "");
129
+ }
130
+
131
+ function isPlaceholderBrowseUrl(url: string | undefined): boolean {
132
+ if (!url) return true;
133
+ const normalized = url.trim().toLowerCase();
134
+ return normalized === "about:blank"
135
+ || normalized.startsWith("chrome://newtab")
136
+ || normalized.startsWith("chrome://new-tab-page")
137
+ || normalized.startsWith("edge://newtab");
138
+ }
139
+
140
+ function hasMeaningfulBrowseUrl(url: string | undefined): boolean {
141
+ return hasKnownBrowseUrl(url) && !isPlaceholderBrowseUrl(url);
142
+ }
143
+
144
+ function pickLiveBrowseTab(
145
+ tabs: BrowseTabRef[],
146
+ sessionTabId: string,
147
+ preferredUrl: string | undefined,
148
+ fallbackUrl: string | undefined,
149
+ ): BrowseTabRef | undefined {
150
+ const exact = tabs.find((tab) => tab.id === sessionTabId);
151
+ if (exact && !isPlaceholderBrowseUrl(exact.url)) return exact;
152
+
153
+ const preferredReal = tabs.find((tab) => {
154
+ if (isPlaceholderBrowseUrl(tab.url)) return false;
155
+ return matchesPreferredBrowseTab(tab.url, preferredUrl)
156
+ || matchesPreferredBrowseTab(tab.url, fallbackUrl);
157
+ });
158
+ if (preferredReal) return preferredReal;
159
+
160
+ const sameDomainReal = tabs.filter((tab) => {
161
+ if (isPlaceholderBrowseUrl(tab.url)) return false;
162
+ return matchesPreferredBrowseDomain(tab.url, preferredUrl)
163
+ || matchesPreferredBrowseDomain(tab.url, fallbackUrl);
164
+ });
165
+ if (exact && isPlaceholderBrowseUrl(exact.url) && sameDomainReal.length === 1) {
166
+ return sameDomainReal[0];
167
+ }
168
+
169
+ if (exact) return exact;
170
+
171
+ return tabs.find((tab) => matchesPreferredBrowseTab(tab.url, preferredUrl)
172
+ || matchesPreferredBrowseTab(tab.url, fallbackUrl));
173
+ }
174
+
175
+ async function closeStalePlaceholderBrowseTab(
176
+ client: BrowseSessionClient,
177
+ staleTab: BrowseTabRef | undefined,
178
+ activeTabId: string,
179
+ ): Promise<void> {
180
+ if (!staleTab?.id || staleTab.id === activeTabId) return;
181
+ if (!isPlaceholderBrowseUrl(staleTab.url)) return;
182
+ await client.closeTab(staleTab.id).catch(() => {});
183
+ }
184
+
185
+ function cleanupSessionQueue(sessionId: string): void {
186
+ sessionQueues.delete(sessionId);
187
+ }
188
+
189
+ function sleep(ms: number): Promise<void> {
190
+ return new Promise((resolve) => setTimeout(resolve, ms));
191
+ }
192
+
193
+ function hasKnownBrowseUrl(value: string | undefined): boolean {
194
+ return typeof value === "string" && value.length > 0;
195
+ }
196
+
197
+ function resolveSessionClient(session: BrowseSession | undefined, fallback: BrowseSessionClient): BrowseSessionClient {
198
+ const fallbackPort = fallback.getPort?.();
199
+ if (session?.brokerPort !== undefined && fallbackPort === session.brokerPort) return fallback;
200
+ return session?.client ?? fallback;
201
+ }
202
+
203
+ export function createRegisteredBrowseSession(
204
+ sessions: Map<string, BrowseSession>,
205
+ input: {
206
+ sessionId?: string;
207
+ tabId: string;
208
+ url: string;
209
+ domain: string;
210
+ harActive?: boolean;
211
+ brokerPort?: number;
212
+ client?: BrowseSessionClient;
213
+ },
214
+ ): BrowseSession {
215
+ const existing = [...sessions.values()].find((session) => session.tabId === input.tabId);
216
+ if (existing) {
217
+ existing.url = input.url;
218
+ existing.domain = input.domain;
219
+ existing.harActive = input.harActive ?? existing.harActive;
220
+ existing.brokerPort = input.brokerPort ?? existing.brokerPort;
221
+ existing.client = input.client ?? existing.client;
222
+ return existing;
223
+ }
224
+
225
+ const sessionId = normalizeSessionId(input.sessionId);
226
+ const session: BrowseSession = {
227
+ sessionId,
228
+ tabId: input.tabId,
229
+ url: input.url,
230
+ domain: input.domain,
231
+ harActive: input.harActive ?? true,
232
+ brokerPort: input.brokerPort,
233
+ client: input.client,
234
+ };
235
+ sessions.set(sessionId, session);
236
+ return session;
237
+ }
238
+
239
+ export function removeBrowseSession(
240
+ sessions: Map<string, BrowseSession>,
241
+ sessionId: string,
242
+ ): BrowseSession | undefined {
243
+ const existing = sessions.get(sessionId);
244
+ sessions.delete(sessionId);
245
+ cleanupSessionQueue(sessionId);
246
+ return existing;
247
+ }
248
+
249
+ function ownedTabIds(
250
+ sessions: Map<string, BrowseSession>,
251
+ excludeSessionId?: string,
252
+ ): Set<string> {
253
+ return new Set(
254
+ [...sessions.values()]
255
+ .filter((session) => session.sessionId !== excludeSessionId)
256
+ .map((session) => session.tabId),
257
+ );
258
+ }
259
+
260
+ async function createBrowseSession(
261
+ sessions: Map<string, BrowseSession>,
262
+ client: BrowseSessionClient,
263
+ injectInterceptor: (tabId: string) => Promise<unknown>,
264
+ sessionId?: string,
265
+ ): Promise<BrowseSession> {
266
+ await client.start();
267
+ const tabId = await client.newTab();
268
+ if (!tabId) throw new Error("Failed to create browser tab");
269
+ await client.harStart(tabId).catch(() => {});
270
+ await injectInterceptor(tabId);
271
+ return createRegisteredBrowseSession(sessions, {
272
+ sessionId,
273
+ tabId,
274
+ url: "about:blank",
275
+ domain: "",
276
+ harActive: true,
277
+ brokerPort: client.getPort?.(),
278
+ client,
279
+ });
280
+ }
281
+
70
282
  async function adoptExistingBrowseTab(
71
283
  sessions: Map<string, BrowseSession>,
72
284
  client: BrowseSessionClient,
73
285
  injectInterceptor: (tabId: string) => Promise<unknown>,
74
- preferredDomain?: string,
286
+ preferredUrl?: string,
287
+ sessionId?: string,
75
288
  ): Promise<BrowseSession | null> {
76
289
  try {
290
+ await client.start();
77
291
  const tabs = await client.discoverTabs();
78
- const normalizedPreferred = preferredDomain?.replace(/^www\./, "") ?? "";
79
- const candidate =
80
- tabs.find((tab) => {
81
- const domain = extractDomain(tab.url);
82
- return !!domain && !!normalizedPreferred && domain === normalizedPreferred;
83
- }) ??
84
- tabs.find((tab) => /^(about:blank|chrome:\/\/newtab\/?)$/i.test(tab.url ?? "")) ??
85
- (!normalizedPreferred ? tabs.find((tab) => /^https?:\/\//.test(tab.url ?? "")) : undefined);
292
+ if (!preferredUrl) return null;
293
+ const reservedTabs = ownedTabIds(sessions, sessionId);
294
+ const candidate = tabs.find((tab) => {
295
+ if (!tab.id || reservedTabs.has(tab.id)) return false;
296
+ return matchesPreferredBrowseTab(tab.url, preferredUrl);
297
+ });
86
298
 
87
299
  if (!candidate?.id) return null;
88
300
  await client.harStart(candidate.id).catch(() => {});
89
301
  await injectInterceptor(candidate.id);
90
- const session: BrowseSession = {
302
+ return createRegisteredBrowseSession(sessions, {
303
+ sessionId,
91
304
  tabId: candidate.id,
92
305
  url: candidate.url ?? "about:blank",
93
- harActive: true,
94
306
  domain: extractDomain(candidate.url),
95
- };
96
- sessions.set("default", session);
97
- return session;
307
+ harActive: true,
308
+ brokerPort: client.getPort?.(),
309
+ client,
310
+ });
98
311
  } catch {
99
312
  return null;
100
313
  }
101
314
  }
102
315
 
316
+ export async function rebindBrowseSessionToMatchingTab(
317
+ sessions: Map<string, BrowseSession>,
318
+ client: BrowseSessionClient,
319
+ injectInterceptor: (tabId: string) => Promise<unknown>,
320
+ sessionId: string,
321
+ preferredUrl?: string,
322
+ ): Promise<BrowseSession | null> {
323
+ const existing = sessions.get(sessionId);
324
+ if (!existing) return null;
325
+ const rebound = await adoptExistingBrowseTab(
326
+ sessions,
327
+ resolveSessionClient(existing, client),
328
+ injectInterceptor,
329
+ preferredUrl ?? existing.url,
330
+ sessionId,
331
+ );
332
+ if (!rebound) return null;
333
+ existing.tabId = rebound.tabId;
334
+ existing.url = rebound.url;
335
+ existing.domain = rebound.domain;
336
+ existing.harActive = rebound.harActive;
337
+ existing.brokerPort = rebound.brokerPort;
338
+ existing.client = rebound.client;
339
+ sessions.set(sessionId, existing);
340
+ return existing;
341
+ }
342
+
103
343
  async function dropBrowseSession(
104
344
  sessions: Map<string, BrowseSession>,
105
345
  client: BrowseSessionClient,
106
346
  session: BrowseSession | undefined,
107
347
  ): Promise<void> {
108
348
  if (!session) return;
109
- await client.closeTab(session.tabId).catch(() => {});
110
- if (sessions.get("default")?.tabId === session.tabId) {
111
- sessions.delete("default");
112
- }
349
+ await resolveSessionClient(session, client).closeTab(session.tabId).catch(() => {});
350
+ removeBrowseSession(sessions, session.sessionId);
113
351
  }
114
352
 
115
353
  export async function isBrowseSessionLive(
@@ -117,28 +355,116 @@ export async function isBrowseSessionLive(
117
355
  client: BrowseSessionClient,
118
356
  ): Promise<boolean> {
119
357
  if (!session.tabId) return false;
358
+ const sessionClient = resolveSessionClient(session, client);
359
+ let tabSeen = false;
360
+ let lastKnownUrl = session.url;
120
361
 
121
362
  try {
122
- const tabs = await client.discoverTabs();
123
- if (!tabs.some((tab) => tab.id === session.tabId)) return false;
124
- const currentUrl = await client.getCurrentUrl(session.tabId);
125
- return typeof currentUrl === "string" && currentUrl.length > 0;
126
- } catch {
127
- return false;
363
+ await sessionClient.start();
364
+ } catch (error) {
365
+ if (isRecoverableBrowseFailure(error)) return false;
366
+ throw error;
367
+ }
368
+
369
+ for (let attempt = 0; attempt < LIVE_CHECK_RETRIES; attempt += 1) {
370
+ try {
371
+ const tabs = await sessionClient.discoverTabs();
372
+ const exactTab = tabs.find((tab) => tab.id === session.tabId);
373
+ const liveTab = pickLiveBrowseTab(tabs, session.tabId, session.url, lastKnownUrl);
374
+ if (!liveTab) {
375
+ if (attempt < LIVE_CHECK_RETRIES - 1) {
376
+ await sleep(LIVE_CHECK_RETRY_DELAY_MS);
377
+ continue;
378
+ }
379
+ return false;
380
+ }
381
+ if (liveTab.id !== session.tabId) {
382
+ session.tabId = liveTab.id;
383
+ session.url = liveTab.url ?? session.url;
384
+ session.domain = extractDomain(session.url);
385
+ session.brokerPort = sessionClient.getPort?.() ?? session.brokerPort;
386
+ session.client = sessionClient;
387
+ await closeStalePlaceholderBrowseTab(sessionClient, exactTab, liveTab.id);
388
+ }
389
+ tabSeen = true;
390
+ if (hasMeaningfulBrowseUrl(liveTab.url)) lastKnownUrl = liveTab.url!;
391
+
392
+ try {
393
+ const currentUrl = await sessionClient.getCurrentUrl(session.tabId);
394
+ if (hasMeaningfulBrowseUrl(currentUrl)) {
395
+ session.url = currentUrl;
396
+ session.domain = extractDomain(currentUrl);
397
+ session.brokerPort = sessionClient.getPort?.() ?? session.brokerPort;
398
+ session.client = sessionClient;
399
+ return true;
400
+ }
401
+ if (
402
+ liveTab.id === session.tabId
403
+ && isPlaceholderBrowseUrl(currentUrl)
404
+ && isPlaceholderBrowseUrl(liveTab.url)
405
+ && isPlaceholderBrowseUrl(session.url)
406
+ ) {
407
+ session.url = currentUrl || liveTab.url || session.url;
408
+ session.domain = extractDomain(session.url);
409
+ session.brokerPort = sessionClient.getPort?.() ?? session.brokerPort;
410
+ session.client = sessionClient;
411
+ return true;
412
+ }
413
+ } catch (error) {
414
+ if (!isRecoverableBrowseFailure(error)) return false;
415
+ }
416
+
417
+ if (hasMeaningfulBrowseUrl(lastKnownUrl)) {
418
+ session.url = lastKnownUrl;
419
+ session.domain = extractDomain(lastKnownUrl);
420
+ session.brokerPort = sessionClient.getPort?.() ?? session.brokerPort;
421
+ session.client = sessionClient;
422
+ return true;
423
+ }
424
+ } catch (error) {
425
+ if (!isRecoverableBrowseFailure(error)) return false;
426
+ }
427
+
428
+ if (attempt < LIVE_CHECK_RETRIES - 1) await sleep(LIVE_CHECK_RETRY_DELAY_MS);
128
429
  }
430
+
431
+ return tabSeen && hasMeaningfulBrowseUrl(lastKnownUrl);
129
432
  }
130
433
 
131
- export async function resetBrowseSession(
434
+ async function listLiveBrowseSessions(
132
435
  sessions: Map<string, BrowseSession>,
133
436
  client: BrowseSessionClient,
134
- injectInterceptor: (tabId: string) => Promise<unknown>,
437
+ ): Promise<BrowseSession[]> {
438
+ const live: BrowseSession[] = [];
439
+ const stale: string[] = [];
440
+
441
+ for (const session of sessions.values()) {
442
+ if (await isBrowseSessionLive(session, client)) {
443
+ live.push(session);
444
+ } else {
445
+ stale.push(session.sessionId);
446
+ }
447
+ }
448
+
449
+ for (const sessionId of stale) removeBrowseSession(sessions, sessionId);
450
+ return live;
451
+ }
452
+
453
+ export async function resolveRequestedBrowseSession(
454
+ sessions: Map<string, BrowseSession>,
455
+ client: BrowseSessionClient,
456
+ requestedSessionId?: string,
135
457
  ): Promise<BrowseSession> {
136
- const existing = sessions.get("default");
137
- const preferredDomain = existing?.domain || extractDomain(existing?.url);
138
- await dropBrowseSession(sessions, client, existing);
139
- const adopted = await adoptExistingBrowseTab(sessions, client, injectInterceptor, preferredDomain);
140
- if (adopted) return adopted;
141
- return createBrowseSession(sessions, client, injectInterceptor);
458
+ if (requestedSessionId) {
459
+ const session = sessions.get(requestedSessionId);
460
+ if (!session) throw new BrowseSessionError("session_not_found");
461
+ return session;
462
+ }
463
+
464
+ const live = await listLiveBrowseSessions(sessions, client);
465
+ if (live.length === 0) throw new BrowseSessionError("no_active_session");
466
+ if (live.length > 1) throw new BrowseSessionError("session_id_required");
467
+ return live[0];
142
468
  }
143
469
 
144
470
  export async function getOrCreateBrowseSession(
@@ -146,13 +472,67 @@ export async function getOrCreateBrowseSession(
146
472
  client: BrowseSessionClient,
147
473
  injectInterceptor: (tabId: string) => Promise<unknown>,
148
474
  ): Promise<BrowseSession> {
149
- const existing = sessions.get("default");
150
- if (existing && await isBrowseSessionLive(existing, client)) return existing;
151
- const preferredDomain = existing?.domain || extractDomain(existing?.url);
475
+ await client.start();
476
+ const existingSessions = [...sessions.values()];
477
+ if (existingSessions.length === 1) {
478
+ const existing = existingSessions[0];
479
+ if (await isBrowseSessionLive(existing, client)) return existing;
480
+ const preferredUrl = existing.url;
481
+ const targetSessionId = existing.sessionId;
482
+ const existingClient = resolveSessionClient(existing, client);
483
+ await dropBrowseSession(sessions, existingClient, existing);
484
+ const adopted = await adoptExistingBrowseTab(sessions, existingClient, injectInterceptor, preferredUrl, targetSessionId);
485
+ if (adopted) return adopted;
486
+ return createBrowseSession(sessions, existingClient, injectInterceptor, targetSessionId);
487
+ }
488
+
489
+ const live = await listLiveBrowseSessions(sessions, client);
490
+ if (live.length === 1) return live[0];
491
+ if (live.length > 1) throw new BrowseSessionError("session_id_required");
492
+
493
+ const existing = existingSessions[0];
494
+ const targetSessionId = existing?.sessionId;
495
+ const preferredUrl = existing?.url;
152
496
  if (existing) await dropBrowseSession(sessions, client, existing);
153
- const adopted = await adoptExistingBrowseTab(sessions, client, injectInterceptor, preferredDomain);
497
+ const adopted = await adoptExistingBrowseTab(sessions, client, injectInterceptor, preferredUrl, targetSessionId);
154
498
  if (adopted) return adopted;
155
- return createBrowseSession(sessions, client, injectInterceptor);
499
+ return createBrowseSession(sessions, client, injectInterceptor, targetSessionId);
500
+ }
501
+
502
+ export async function resetBrowseSession(
503
+ sessions: Map<string, BrowseSession>,
504
+ client: BrowseSessionClient,
505
+ injectInterceptor: (tabId: string) => Promise<unknown>,
506
+ sessionId?: string,
507
+ ): Promise<BrowseSession> {
508
+ await client.start();
509
+ const existing = sessionId
510
+ ? sessions.get(sessionId)
511
+ : [...sessions.values()][0];
512
+ const targetSessionId = sessionId ?? existing?.sessionId;
513
+ const preferredUrl = existing?.url;
514
+ const existingClient = resolveSessionClient(existing, client);
515
+ await dropBrowseSession(sessions, existingClient, existing);
516
+ const adopted = await adoptExistingBrowseTab(sessions, existingClient, injectInterceptor, preferredUrl, targetSessionId);
517
+ if (adopted) return adopted;
518
+ return createBrowseSession(sessions, existingClient, injectInterceptor, targetSessionId);
519
+ }
520
+
521
+ async function withSessionQueue<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
522
+ const prev = sessionQueues.get(sessionId) ?? Promise.resolve();
523
+ let release!: () => void;
524
+ const waitTurn = new Promise<void>((resolve) => {
525
+ release = resolve;
526
+ });
527
+ const gate = prev.catch(() => {}).then(() => waitTurn);
528
+ sessionQueues.set(sessionId, gate);
529
+ await prev.catch(() => {});
530
+ try {
531
+ return await fn();
532
+ } finally {
533
+ release();
534
+ if (sessionQueues.get(sessionId) === gate) cleanupSessionQueue(sessionId);
535
+ }
156
536
  }
157
537
 
158
538
  export async function withRecoveredBrowseSession<T>(
@@ -173,7 +553,96 @@ export async function withRecoveredBrowseSession<T>(
173
553
  if (!isRecoverableBrowseFailure(error)) throw error;
174
554
  }
175
555
 
176
- session = await resetBrowseSession(sessions, client, injectInterceptor);
556
+ session = await resetBrowseSession(sessions, client, injectInterceptor, session.sessionId);
177
557
  const result = await run(session);
178
558
  return { session, result, recovered: true };
179
559
  }
560
+
561
+ export async function withSerializedRecoveredBrowseSession<T>(
562
+ sessions: Map<string, BrowseSession>,
563
+ client: BrowseSessionClient,
564
+ injectInterceptor: (tabId: string) => Promise<unknown>,
565
+ requestedSessionId: string | undefined,
566
+ run: (session: BrowseSession) => Promise<T>,
567
+ shouldReset?: (result: T) => boolean,
568
+ ): Promise<{ session: BrowseSession; result: T; recovered: boolean }> {
569
+ const resolved = await resolveRequestedBrowseSession(sessions, client, requestedSessionId);
570
+ return withSessionQueue(resolved.sessionId, async () => {
571
+ let session = sessions.get(resolved.sessionId);
572
+ if (!session) throw new BrowseSessionError("session_expired");
573
+ const sessionClient = resolveSessionClient(session, client);
574
+
575
+ try {
576
+ const result = await run(session);
577
+ if (!shouldReset || !shouldReset(result)) {
578
+ return { session, result, recovered: false };
579
+ }
580
+ } catch (error) {
581
+ if (!isRecoverableBrowseFailure(error)) throw error;
582
+ }
583
+
584
+ session = await resetBrowseSession(sessions, sessionClient, injectInterceptor, resolved.sessionId);
585
+ const result = await run(session);
586
+ return { session, result, recovered: true };
587
+ });
588
+ }
589
+
590
+ export async function withSerializedStrictBrowseSession<T>(
591
+ sessions: Map<string, BrowseSession>,
592
+ client: BrowseSessionClient,
593
+ requestedSessionId: string | undefined,
594
+ run: (session: BrowseSession) => Promise<T>,
595
+ shouldExpire?: (result: T) => boolean,
596
+ ): Promise<{ session: BrowseSession; result: T; recovered: false }> {
597
+ const resolved = await resolveRequestedBrowseSession(sessions, client, requestedSessionId);
598
+ return withSessionQueue(resolved.sessionId, async () => {
599
+ const session = sessions.get(resolved.sessionId);
600
+ if (!session) throw new BrowseSessionError("session_expired");
601
+
602
+ const live = await isBrowseSessionLive(session, client);
603
+ if (!live) {
604
+ removeBrowseSession(sessions, resolved.sessionId);
605
+ throw new BrowseSessionError("session_expired");
606
+ }
607
+
608
+ try {
609
+ const result = await run(session);
610
+ if (shouldExpire?.(result)) {
611
+ const stillLive = await isBrowseSessionLive(session, client);
612
+ if (!stillLive) {
613
+ removeBrowseSession(sessions, resolved.sessionId);
614
+ throw new BrowseSessionError("session_expired");
615
+ }
616
+ }
617
+ return { session, result, recovered: false };
618
+ } catch (error) {
619
+ if (error instanceof BrowseSessionError) throw error;
620
+ if (isRecoverableBrowseFailure(error)) {
621
+ const stillLive = await isBrowseSessionLive(session, client);
622
+ if (!stillLive) {
623
+ removeBrowseSession(sessions, resolved.sessionId);
624
+ throw new BrowseSessionError("session_expired");
625
+ }
626
+ }
627
+ throw error;
628
+ }
629
+ });
630
+ }
631
+
632
+ export async function getOrCreateNavigateBrowseSession(
633
+ sessions: Map<string, BrowseSession>,
634
+ client: BrowseSessionClient,
635
+ injectInterceptor: (tabId: string) => Promise<unknown>,
636
+ requestedSessionId?: string,
637
+ ): Promise<BrowseSession> {
638
+ if (requestedSessionId) {
639
+ const session = sessions.get(requestedSessionId);
640
+ if (!session) throw new BrowseSessionError("session_not_found");
641
+ return session;
642
+ }
643
+
644
+ const live = await listLiveBrowseSessions(sessions, client);
645
+ if (live.length === 0) return createBrowseSession(sessions, client, injectInterceptor);
646
+ if (live.length > 1) throw new BrowseSessionError("session_id_required");
647
+ return live[0];
648
+ }