qwenproxy-cli 1.0.17 → 1.0.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qwenproxy-cli",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
4
4
  "description": "High-performance OpenAI & Anthropic compatible API gateway for Qwen with multi-account rotation, interactive TUI, and resilient tool calling.",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
package/src/api/server.ts CHANGED
@@ -721,27 +721,32 @@ export async function startServer(options?: {
721
721
 
722
722
  // Warm reserve account in background for fast failover without delaying startup
723
723
  if (config.playwright.maxActiveContexts > 1 && remainingAccounts.length > 0) {
724
- const reserveAccount = remainingAccounts[0];
725
- accountsToValidate = remainingAccounts.slice(1);
726
- try {
727
- const ok = await prepareAccountRuntime(
728
- reserveAccount,
729
- getAccountCredentials,
730
- initPlaywrightForAccount,
731
- disableNativeTools,
732
- warmQwenChatPool,
733
- );
734
- if (ok) {
735
- ensureAccountInPriority(reserveAccount.id);
736
- console.log(
737
- `✅ [Server] Reserve account ready (2/${totalAccounts}): ${maskEmail(reserveAccount.email)}`,
724
+ let reserveCandidateIdx = 0;
725
+ for (; reserveCandidateIdx < remainingAccounts.length; reserveCandidateIdx++) {
726
+ const reserveAccount = remainingAccounts[reserveCandidateIdx];
727
+ try {
728
+ const ok = await prepareAccountRuntime(
729
+ reserveAccount,
730
+ getAccountCredentials,
731
+ initPlaywrightForAccount,
732
+ disableNativeTools,
733
+ warmQwenChatPool,
734
+ );
735
+ if (ok) {
736
+ ensureAccountInPriority(reserveAccount.id);
737
+ console.log(
738
+ `✅ [Server] Reserve account ready (2/${totalAccounts}): ${maskEmail(reserveAccount.email)}`,
739
+ );
740
+ reserveCandidateIdx++;
741
+ break;
742
+ }
743
+ } catch (err) {
744
+ console.warn(
745
+ `⚠️ [Server] Failed to warm reserve account ${maskEmail(reserveAccount.email)}: ${getErrorMessage(err)}`,
738
746
  );
739
747
  }
740
- } catch (err) {
741
- console.warn(
742
- `⚠️ [Server] Failed to warm reserve account ${maskEmail(reserveAccount.email)}: ${getErrorMessage(err)}`,
743
- );
744
748
  }
749
+ accountsToValidate = remainingAccounts.slice(reserveCandidateIdx);
745
750
  }
746
751
 
747
752
  let validated = 0;
package/src/index.ts CHANGED
@@ -14,41 +14,35 @@ if (fs.existsSync(envPath)) {
14
14
  dotenv.config({ quiet: true })
15
15
  }
16
16
  // Prevent benign asynchronous driver/browser teardown exceptions from crashing the server
17
- process.on('uncaughtException', (error: unknown) => {
18
- const msg = error instanceof Error ? error.message : String(error)
19
- if (
20
- msg.includes('Cannot find parent object') ||
21
- msg.includes('Target page, context or browser has been closed') ||
22
- msg.includes('Browser has been closed') ||
23
- msg.includes('Target closed') ||
24
- msg.includes('Target crashed') ||
25
- msg.includes('Page crashed') ||
26
- msg.includes('Assertion error') ||
27
- msg.includes('Connection closed')
28
- ) {
29
- console.warn(`⚠️ [Playwright] Handled benign driver teardown exception: ${msg}`)
30
- return
17
+ process.on('uncaughtException', async (error: unknown) => {
18
+ const { isPlaywrightAlreadyClosedError } = await import('./services/playwright.ts');
19
+ if (isPlaywrightAlreadyClosedError(error)) {
20
+ const msg =
21
+ error instanceof Error
22
+ ? error.message
23
+ : typeof error === 'object' && error !== null && 'message' in error
24
+ ? String((error as any).message)
25
+ : String(error);
26
+ console.warn(`⚠️ [Playwright] Handled benign driver teardown exception: ${msg}`);
27
+ return;
31
28
  }
32
- console.error('❌ [Process] Uncaught Exception:', error)
33
- })
29
+ console.error('❌ [Process] Uncaught Exception:', error);
30
+ });
34
31
 
35
- process.on('unhandledRejection', (reason: unknown) => {
36
- const msg = reason instanceof Error ? reason.message : String(reason)
37
- if (
38
- msg.includes('Cannot find parent object') ||
39
- msg.includes('Target page, context or browser has been closed') ||
40
- msg.includes('Browser has been closed') ||
41
- msg.includes('Target closed') ||
42
- msg.includes('Target crashed') ||
43
- msg.includes('Page crashed') ||
44
- msg.includes('Assertion error') ||
45
- msg.includes('Connection closed')
46
- ) {
47
- console.warn(`⚠️ [Playwright] Handled benign driver teardown rejection: ${msg}`)
48
- return
32
+ process.on('unhandledRejection', async (reason: unknown) => {
33
+ const { isPlaywrightAlreadyClosedError } = await import('./services/playwright.ts');
34
+ if (isPlaywrightAlreadyClosedError(reason)) {
35
+ const msg =
36
+ reason instanceof Error
37
+ ? reason.message
38
+ : typeof reason === 'object' && reason !== null && 'message' in reason
39
+ ? String((reason as any).message)
40
+ : String(reason);
41
+ console.warn(`⚠️ [Playwright] Handled benign driver teardown rejection: ${msg}`);
42
+ return;
49
43
  }
50
- console.error('❌ [Process] Unhandled Rejection:', reason)
51
- })
44
+ console.error('❌ [Process] Unhandled Rejection:', reason);
45
+ });
52
46
  import { startServer } from './api/server.js'
53
47
  const isTui = process.argv.includes('--tui') || process.env.QWEN_TUI === 'true'
54
48
 
@@ -279,9 +279,11 @@ export async function saveStorageState(
279
279
  `storageState timed out after ${timeoutMs}ms`,
280
280
  );
281
281
  } catch (error) {
282
- console.warn(
283
- `[Playwright] Failed to save storage state for ${accountId}: ${getErrorMessage(error)}`,
284
- );
282
+ if (!isPlaywrightAlreadyClosedError(error)) {
283
+ console.warn(
284
+ `[Playwright] Failed to save storage state for ${accountId}: ${getErrorMessage(error)}`,
285
+ );
286
+ }
285
287
  }
286
288
  }
287
289
 
@@ -302,6 +304,37 @@ async function hasValidAuthCookie(context: BrowserContext, timeoutMs = 3_000): P
302
304
  }
303
305
  }
304
306
 
307
+ /**
308
+ * Detect whether the page is authenticated.
309
+ * Probes the authoritative /api/v1/auths/ endpoint from the page context (verified via HAR forensics)
310
+ * and falls back to inspecting the presence of visible "Log in" / "Sign up" buttons.
311
+ */
312
+ export async function isPageLoggedIn(page: Page): Promise<boolean> {
313
+ if (!page) return false;
314
+ if (typeof page.isClosed === "function" && page.isClosed()) return false;
315
+ try {
316
+ const url = typeof page.url === "function" ? page.url() : "";
317
+ if (url.includes("/auth") || url.includes("/login")) return false;
318
+ if (typeof page.evaluate !== "function") return true;
319
+
320
+ return await page
321
+ .evaluate(async () => {
322
+ try {
323
+ const res = await fetch("/api/v1/auths/", { method: "GET" });
324
+ return res.status === 200;
325
+ } catch {
326
+ const btn = document.querySelector(
327
+ ".header-right-auth-button, button.header-right-auth-button, a[href*='/auth'], a[href*='/login']",
328
+ );
329
+ return !btn || (btn as HTMLElement).offsetWidth === 0;
330
+ }
331
+ })
332
+ .catch(() => false);
333
+ } catch {
334
+ return false;
335
+ }
336
+ }
337
+
305
338
  export async function getOrLaunchSharedBrowser(
306
339
  browserType: BrowserType = "chromium",
307
340
  headless = true,
@@ -492,7 +525,7 @@ function isAccountServingStream(accountId: string): boolean {
492
525
  return true;
493
526
  }
494
527
 
495
- function getStealthScript(profile: FingerprintProfile): string {
528
+ export function getStealthScript(profile: FingerprintProfile): string {
496
529
  const profileJson = JSON.stringify(profile).replace(/</g, "\\u003c");
497
530
  return `
498
531
  (function() {
@@ -740,6 +773,14 @@ function getStealthScript(profile: FingerprintProfile): string {
740
773
  function makeMime(desc, suffixes, type) {
741
774
  return { description: desc, suffixes: suffixes, type: type };
742
775
  }
776
+ function attachPlugin(mime, plugin) {
777
+ Object.defineProperty(mime, 'enabledPlugin', {
778
+ value: plugin,
779
+ enumerable: false,
780
+ configurable: true,
781
+ writable: true,
782
+ });
783
+ }
743
784
  const pdfMime = makeMime('Portable Document Format', 'pdf', 'application/pdf');
744
785
  const pdfxMime = makeMime('Portable Document Format', 'pdf', 'text/pdf');
745
786
  const pdfPlugin = {
@@ -750,8 +791,8 @@ function getStealthScript(profile: FingerprintProfile): string {
750
791
  0: pdfMime,
751
792
  1: pdfxMime,
752
793
  };
753
- pdfMime.enabledPlugin = pdfPlugin;
754
- pdfxMime.enabledPlugin = pdfPlugin;
794
+ attachPlugin(pdfMime, pdfPlugin);
795
+ attachPlugin(pdfxMime, pdfPlugin);
755
796
 
756
797
  const chromePdfMime = makeMime('Portable Document Format', 'pdf', 'application/pdf');
757
798
  const chromePdfMime2 = makeMime('Portable Document Format', 'pdf', 'text/pdf');
@@ -763,8 +804,8 @@ function getStealthScript(profile: FingerprintProfile): string {
763
804
  0: chromePdfMime,
764
805
  1: chromePdfMime2,
765
806
  };
766
- chromePdfMime.enabledPlugin = chromePdfPlugin;
767
- chromePdfMime2.enabledPlugin = chromePdfPlugin;
807
+ attachPlugin(chromePdfMime, chromePdfPlugin);
808
+ attachPlugin(chromePdfMime2, chromePdfPlugin);
768
809
 
769
810
  const nativePlugin = {
770
811
  name: 'Native Client',
@@ -774,9 +815,8 @@ function getStealthScript(profile: FingerprintProfile): string {
774
815
  0: makeMime('Native Client Executable', '', 'application/x-nacl'),
775
816
  1: makeMime('Portable Native Client Executable', '', 'application/x-pnacl'),
776
817
  };
777
- nativePlugin[0].enabledPlugin = nativePlugin;
778
- nativePlugin[1].enabledPlugin = nativePlugin;
779
-
818
+ attachPlugin(nativePlugin[0], nativePlugin);
819
+ attachPlugin(nativePlugin[1], nativePlugin);
780
820
  const pluginsList = [pdfPlugin, chromePdfPlugin, nativePlugin];
781
821
  const mimeList = [pdfMime, pdfxMime, chromePdfMime, chromePdfMime2, nativePlugin[0], nativePlugin[1]];
782
822
 
@@ -1358,17 +1398,24 @@ export async function initPlaywrightForAccount(
1358
1398
  waitUntil: "domcontentloaded",
1359
1399
  timeout: config.timeouts.navigation,
1360
1400
  });
1361
- const url = acctPage.url();
1362
- if (url.includes("auth") || url.includes("login")) {
1401
+ const loggedIn = await isPageLoggedIn(acctPage);
1402
+ if (!loggedIn) {
1363
1403
  if (account.email && account.password) {
1364
1404
  console.warn(
1365
1405
  `⚠️ [Playwright] Session expired for ${maskEmail(account.email)}, re-authenticating...`,
1366
1406
  );
1367
- await loginToQwen(account.id, account.email, account.password);
1407
+ const ok = await loginToQwen(account.id, account.email, account.password);
1408
+ if (!ok) {
1409
+ validationError = new Error(
1410
+ `Session expired for ${maskEmail(account.email)} and re-authentication failed`,
1411
+ );
1412
+ continue;
1413
+ }
1368
1414
  } else {
1369
- console.warn(
1370
- `[Playwright] Session expired for account ${account.id} but no credentials available.`,
1415
+ validationError = new Error(
1416
+ `Session expired for account ${account.id} but no credentials available for re-login (run 'qpx login')`,
1371
1417
  );
1418
+ break;
1372
1419
  }
1373
1420
  }
1374
1421
  validationError = null;
@@ -1389,8 +1436,6 @@ export async function initPlaywrightForAccount(
1389
1436
  );
1390
1437
  throw validationError;
1391
1438
  }
1392
-
1393
- // Capture headers by navigating and intercepting
1394
1439
  await captureQwenHeaders(account.id);
1395
1440
 
1396
1441
  // Header capture may leave the UI on a generated chat page. Return the
@@ -1411,7 +1456,7 @@ export async function initPlaywrightForAccount(
1411
1456
 
1412
1457
  touchAccountActivity(account.id);
1413
1458
  } catch (error) {
1414
- await closePlaywrightContextBestEffort(account.id, acctContext);
1459
+ await closePlaywrightContextBestEffort(account.id, acctContext, { skipStorageSave: true });
1415
1460
  cleanupPlaywrightAccountState(account.id);
1416
1461
  throw error;
1417
1462
  }
@@ -1521,23 +1566,20 @@ export async function validateAccountLogin(
1521
1566
  } finally {
1522
1567
  accountPages.delete(account.id);
1523
1568
  }
1524
- } else if (hasAuthCookie) {
1525
- // Validate session by navigating to chat page
1569
+ } else {
1570
+ // Validate session by navigating to chat page and checking login state
1526
1571
  try {
1527
1572
  await acctPage.goto(qwenUrl("/"), {
1528
1573
  waitUntil: "domcontentloaded",
1529
1574
  timeout: config.timeouts.navigation,
1530
1575
  });
1531
- const url = acctPage.url();
1532
- if (url.includes("auth") || url.includes("login")) {
1533
- loggedIn = false;
1534
- if (account.email && account.password) {
1535
- accountPages.set(account.id, acctPage);
1536
- try {
1537
- loggedIn = await loginToQwen(account.id, account.email, account.password);
1538
- } finally {
1539
- accountPages.delete(account.id);
1540
- }
1576
+ loggedIn = await isPageLoggedIn(acctPage);
1577
+ if (!loggedIn && account.email && account.password) {
1578
+ accountPages.set(account.id, acctPage);
1579
+ try {
1580
+ loggedIn = await loginToQwen(account.id, account.email, account.password);
1581
+ } finally {
1582
+ accountPages.delete(account.id);
1541
1583
  }
1542
1584
  }
1543
1585
  } catch {
@@ -1939,59 +1981,64 @@ export async function captureQwenHeaders(
1939
1981
  touchAccountActivity(accountId);
1940
1982
  const cache = getHeaderCache(accountId);
1941
1983
 
1942
- return new Promise<void>((resolve, reject) => {
1943
- let settled = false;
1944
- let routeRegistered = false;
1945
- let timeout: ReturnType<typeof setTimeout> | undefined;
1946
- let routeHandler: (route: any, request: any) => Promise<void>;
1947
- let sawIncompleteHeaders = false;
1948
- let headersCaptured = false;
1949
- let retriggerRequested = false;
1950
- let lastAttemptGraceTimedOut = false;
1951
- let graceTimeoutCount = 0;
1952
- let wakeTriggerLoop: (() => void) | undefined;
1953
- const deadline = Date.now() + timeoutMs;
1954
- const remainingBudgetMs = () => deadline - Date.now();
1955
-
1956
- const cleanupRoute = () => {
1957
- if (!routeRegistered) return;
1958
- void page
1959
- .unroute("**/api/v2/chat/completions*", routeHandler)
1960
- .catch(() => {});
1961
- };
1984
+ let cleanupRoute = async () => {};
1985
+ try {
1986
+ return await new Promise<void>((resolve, reject) => {
1987
+ let settled = false;
1988
+ let routeRegistered = false;
1989
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1990
+ let routeHandler: (route: any, request: any) => Promise<void>;
1991
+ let sawIncompleteHeaders = false;
1992
+ let headersCaptured = false;
1993
+ let retriggerRequested = false;
1994
+ let lastAttemptGraceTimedOut = false;
1995
+ let graceTimeoutCount = 0;
1996
+ let wakeTriggerLoop: (() => void) | undefined;
1997
+ const deadline = Date.now() + timeoutMs;
1998
+ const remainingBudgetMs = () => deadline - Date.now();
1999
+
2000
+ cleanupRoute = async () => {
2001
+ if (!routeRegistered) return;
2002
+ routeRegistered = false;
2003
+ if (!page.isClosed()) {
2004
+ try {
2005
+ await page.unroute("**/api/v2/chat/completions*", routeHandler);
2006
+ } catch {}
2007
+ }
2008
+ };
1962
2009
 
1963
- const wakeTrigger = () => {
1964
- const wake = wakeTriggerLoop;
1965
- wakeTriggerLoop = undefined;
1966
- wake?.();
1967
- };
2010
+ const wakeTrigger = () => {
2011
+ const wake = wakeTriggerLoop;
2012
+ wakeTriggerLoop = undefined;
2013
+ wake?.();
2014
+ };
1968
2015
 
1969
- const settle = (error?: Error) => {
1970
- if (settled) return;
1971
- settled = true;
1972
- if (timeout) clearTimeout(timeout);
1973
- cleanupRoute();
1974
- // A trigger loop parked between attempts has to be released, otherwise it
1975
- // stays pending forever behind an already-settled capture.
1976
- wakeTrigger();
1977
- // When a trigger grace period expired (page fired no completion request),
1978
- // log the OUTCOME so the operator can see whether the retry loop
1979
- // recovered or the account is being rotated into cooldown — the bare
1980
- // per-attempt warning leaves that dangling.
1981
- if (graceTimeoutCount > 0) {
1982
- if (headersCaptured) {
1983
- console.log(
1984
- `✅ [Playwright] Header capture recovered for ${accountId} after ${graceTimeoutCount} silent send(s)`,
1985
- );
1986
- } else {
1987
- console.warn(
1988
- `❌ [Playwright] Header capture failed for ${accountId} after ${graceTimeoutCount} silent send(s): ${error?.message ?? "no completion request"}`,
1989
- );
2016
+ const settle = (error?: Error) => {
2017
+ if (settled) return;
2018
+ settled = true;
2019
+ if (timeout) clearTimeout(timeout);
2020
+ void cleanupRoute();
2021
+ // A trigger loop parked between attempts has to be released, otherwise it
2022
+ // stays pending forever behind an already-settled capture.
2023
+ wakeTrigger();
2024
+ // When a trigger grace period expired (page fired no completion request),
2025
+ // log the OUTCOME so the operator can see whether the retry loop
2026
+ // recovered or the account is being rotated into cooldown — the bare
2027
+ // per-attempt warning leaves that dangling.
2028
+ if (graceTimeoutCount > 0) {
2029
+ if (headersCaptured) {
2030
+ console.log(
2031
+ `✅ [Playwright] Header capture recovered for ${accountId} after ${graceTimeoutCount} silent send(s)`,
2032
+ );
2033
+ } else {
2034
+ console.warn(
2035
+ `❌ [Playwright] Header capture failed for ${accountId} after ${graceTimeoutCount} silent send(s): ${error?.message ?? "no completion request"}`,
2036
+ );
2037
+ }
1990
2038
  }
1991
- }
1992
- if (error) reject(error);
1993
- else resolve();
1994
- };
2039
+ if (error) reject(error);
2040
+ else resolve();
2041
+ };
1995
2042
 
1996
2043
  const incompleteHeadersError = () =>
1997
2044
  new Error(
@@ -2152,8 +2199,8 @@ export async function captureQwenHeaders(
2152
2199
  // burn every trigger attempt on a textarea that does not exist. Re-login
2153
2200
  // immediately when credentials are available; otherwise fail fast with a
2154
2201
  // clear diagnosis instead of 3 pointless grace timeouts.
2155
- const currentUrl = page.url();
2156
- if (currentUrl.includes("/auth") || currentUrl.includes("/login")) {
2202
+ const loggedIn = await isPageLoggedIn(page);
2203
+ if (!loggedIn) {
2157
2204
  const { getAccountCredentials } = await import("../core/accounts.ts");
2158
2205
  const creds = getAccountCredentials(accountId);
2159
2206
  if (creds && creds.email && creds.password) {
@@ -2176,7 +2223,7 @@ export async function captureQwenHeaders(
2176
2223
  } else {
2177
2224
  settle(
2178
2225
  new Error(
2179
- `Header capture failed for ${accountId}: session expired and no credentials available for re-login`,
2226
+ `Header capture failed for ${accountId}: session expired and no credentials available for re-login (run 'qpx login')`,
2180
2227
  ),
2181
2228
  );
2182
2229
  return;
@@ -2320,11 +2367,12 @@ export async function captureQwenHeaders(
2320
2367
  : new Error(`Header capture route registration failed for ${accountId}`),
2321
2368
  );
2322
2369
  });
2323
- });
2370
+ });
2371
+ } finally {
2372
+ await cleanupRoute();
2373
+ }
2324
2374
  }
2325
-
2326
2375
  type CookieSnapshot = Awaited<ReturnType<BrowserContext["cookies"]>>;
2327
-
2328
2376
  /**
2329
2377
  * Fetch the account context cookies once. The snapshot feeds every validity
2330
2378
  * check and the cookie string build, avoiding repeated CDP round-trips.
@@ -3022,15 +3070,23 @@ function cleanupPlaywrightAccountState(accountId: string): void {
3022
3070
  async function closePlaywrightContextBestEffort(
3023
3071
  accountId: string,
3024
3072
  context: BrowserContext,
3073
+ options?: { skipStorageSave?: boolean },
3025
3074
  ): Promise<void> {
3026
- try {
3027
- if (await hasValidAuthCookie(context)) {
3028
- await saveStorageState(context, accountId);
3029
- }
3030
- } catch {}
3075
+ if (!options?.skipStorageSave) {
3076
+ try {
3077
+ if (await hasValidAuthCookie(context)) {
3078
+ await saveStorageState(context, accountId);
3079
+ }
3080
+ } catch {}
3081
+ }
3031
3082
 
3032
3083
  try {
3033
3084
  const pages = context.pages();
3085
+ for (const page of pages) {
3086
+ if (!page.isClosed()) {
3087
+ await (page as any).unrouteAll?.({ behavior: "ignoreErrors" }).catch(() => {});
3088
+ }
3089
+ }
3034
3090
  await Promise.all(
3035
3091
  pages.map((page) =>
3036
3092
  withTimeout(
@@ -3078,7 +3134,13 @@ async function closePlaywrightForAccountLocked(
3078
3134
  * that must not be logged as keep-alive failures.
3079
3135
  */
3080
3136
  export function isPlaywrightAlreadyClosedError(error: unknown): boolean {
3081
- const message = error instanceof Error ? error.message : String(error);
3137
+ if (!error) return false;
3138
+ const message =
3139
+ error instanceof Error
3140
+ ? error.message
3141
+ : typeof error === "object" && "message" in error
3142
+ ? String((error as any).message)
3143
+ : String(error);
3082
3144
  return (
3083
3145
  message.includes("Target page, context or browser has been closed") ||
3084
3146
  message.includes("Browser has been closed") ||
@@ -3087,7 +3149,11 @@ export function isPlaywrightAlreadyClosedError(error: unknown): boolean {
3087
3149
  message.includes("Page crashed") ||
3088
3150
  message.includes("Assertion error") ||
3089
3151
  message.includes("Cannot find parent object") ||
3090
- message.includes("Connection closed")
3152
+ message.includes("Connection closed") ||
3153
+ message.includes("session closed") ||
3154
+ message.includes("Session closed") ||
3155
+ message.includes("Network.setCacheDisabled") ||
3156
+ message.includes("Protocol error")
3091
3157
  );
3092
3158
  }
3093
3159
 
@@ -962,7 +962,7 @@ function repairCommonMalformedToolJson(content: string): string {
962
962
  // a bare-word value, so inserting the quote is safe (true/false/null and
963
963
  // numbers are excluded). The trailing `\"` escapes are preserved, so the
964
964
  // model's closing quote still terminates the string.
965
- /([,{]\s*"[a-zA-Z_][a-zA-Z0-9_]*"\s*:\s*)(?=(?!true|false|null)[A-Za-z_])/g,
965
+ /([,{]\s*"[a-zA-Z_][a-zA-Z0-9_]*"\s*:\s*)(?=(?!true|false|null|\d|\[|\{|")[^\s])/g,
966
966
  '$1"',
967
967
  );
968
968
  return repairMissingArrayClose(repaired);
package/src/tui/index.ts CHANGED
@@ -37,6 +37,10 @@ async function main() {
37
37
  const app = new TuiApp(initialTab);
38
38
 
39
39
  process.on("uncaughtException", async (err) => {
40
+ const { isPlaywrightAlreadyClosedError } = await import("../services/playwright.ts");
41
+ if (isPlaywrightAlreadyClosedError(err)) {
42
+ return;
43
+ }
40
44
  try {
41
45
  await app.stop();
42
46
  } catch {}
@@ -45,6 +49,10 @@ async function main() {
45
49
  });
46
50
 
47
51
  process.on("unhandledRejection", async (err) => {
52
+ const { isPlaywrightAlreadyClosedError } = await import("../services/playwright.ts");
53
+ if (isPlaywrightAlreadyClosedError(err)) {
54
+ return;
55
+ }
48
56
  try {
49
57
  await app.stop();
50
58
  } catch {}