From 74f4a48ca7e34feafcadaafb0242e7b23b80a3c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Tue, 31 Mar 2026 18:24:17 -0500 Subject: [PATCH] feat(baoyu-post-to-x): add X session persistence and Chrome lock recovery --- skills/baoyu-post-to-x/scripts/bun.lock | 1 + skills/baoyu-post-to-x/scripts/x-browser.ts | 43 +++-- skills/baoyu-post-to-x/scripts/x-quote.ts | 46 ++++-- .../baoyu-post-to-x/scripts/x-utils.test.ts | 54 +++++++ skills/baoyu-post-to-x/scripts/x-utils.ts | 150 +++++++++++++++++- skills/baoyu-post-to-x/scripts/x-video.ts | 44 +++-- 6 files changed, 295 insertions(+), 43 deletions(-) create mode 100644 skills/baoyu-post-to-x/scripts/x-utils.test.ts diff --git a/skills/baoyu-post-to-x/scripts/bun.lock b/skills/baoyu-post-to-x/scripts/bun.lock index 6f50e45..cafb9b0 100644 --- a/skills/baoyu-post-to-x/scripts/bun.lock +++ b/skills/baoyu-post-to-x/scripts/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "baoyu-post-to-x-scripts", diff --git a/skills/baoyu-post-to-x/scripts/x-browser.ts b/skills/baoyu-post-to-x/scripts/x-browser.ts index 0c1b390..dbde01f 100644 --- a/skills/baoyu-post-to-x/scripts/x-browser.ts +++ b/skills/baoyu-post-to-x/scripts/x-browser.ts @@ -7,10 +7,12 @@ import { copyImageToClipboard, findExistingChromeDebugPort, getDefaultProfileDir, + gracefulKillChrome, launchChrome, openPageSession, pasteFromClipboard, sleep, + waitForXSessionPersistence, waitForChromeDebugPort, } from './x-utils.js'; @@ -44,6 +46,8 @@ export async function postToX(options: XBrowserOptions): Promise { else console.log(`[x-browser] Launching Chrome (profile: ${profileDir})`); let cdp: CdpConnection | null = null; + let sessionId: string | null = null; + let loggedInDuringRun = false; try { const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true }); @@ -56,9 +60,11 @@ export async function postToX(options: XBrowserOptions): Promise { matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'), enablePage: true, enableRuntime: true, + enableNetwork: true, }); - const { sessionId } = page; - await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId }); + const activeSessionId = page.sessionId; + sessionId = activeSessionId; + await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId: activeSessionId }); console.log('[x-browser] Waiting for X editor...'); await sleep(3000); @@ -69,7 +75,7 @@ export async function postToX(options: XBrowserOptions): Promise { const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', { expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`, returnByValue: true, - }, { sessionId }); + }, { sessionId: activeSessionId }); if (result.result.value) return true; await sleep(1000); } @@ -82,6 +88,7 @@ export async function postToX(options: XBrowserOptions): Promise { console.log('[x-browser] Waiting for login...'); const loggedIn = await waitForEditor(); if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.'); + loggedInDuringRun = true; } if (text) { @@ -94,7 +101,7 @@ export async function postToX(options: XBrowserOptions): Promise { document.execCommand('insertText', false, ${JSON.stringify(text)}); } `, - }, { sessionId }); + }, { sessionId: activeSessionId }); await sleep(500); } @@ -115,7 +122,7 @@ export async function postToX(options: XBrowserOptions): Promise { const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', { expression: `document.querySelectorAll('img[src^="blob:"]').length`, returnByValue: true, - }, { sessionId }); + }, { sessionId: activeSessionId }); // Wait for clipboard to be ready await sleep(500); @@ -123,7 +130,7 @@ export async function postToX(options: XBrowserOptions): Promise { // Focus the editor await cdp.send('Runtime.evaluate', { expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`, - }, { sessionId }); + }, { sessionId: activeSessionId }); await sleep(200); // Use paste script (handles platform differences, activates Chrome) @@ -140,14 +147,14 @@ export async function postToX(options: XBrowserOptions): Promise { code: 'KeyV', modifiers, windowsVirtualKeyCode: 86, - }, { sessionId }); + }, { sessionId: activeSessionId }); await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86, - }, { sessionId }); + }, { sessionId: activeSessionId }); } console.log('[x-browser] Verifying image upload...'); @@ -158,7 +165,7 @@ export async function postToX(options: XBrowserOptions): Promise { const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', { expression: `document.querySelectorAll('img[src^="blob:"]').length`, returnByValue: true, - }, { sessionId }); + }, { sessionId: activeSessionId }); if (r.result.value >= expectedImgCount) { imgUploadOk = true; break; @@ -177,18 +184,32 @@ export async function postToX(options: XBrowserOptions): Promise { console.log('[x-browser] Submitting post...'); await cdp.send('Runtime.evaluate', { expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`, - }, { sessionId }); + }, { sessionId: activeSessionId }); await sleep(2000); console.log('[x-browser] Post submitted!'); } else { console.log('[x-browser] Post composed. Please review and click the publish button in the browser.'); } } finally { + let leaveChromeOpen = !submit; + if (chrome && submit && loggedInDuringRun && cdp && sessionId) { + console.log('[x-browser] Waiting for X session cookies to persist...'); + const sessionReady = await waitForXSessionPersistence({ cdp, sessionId }); + if (!sessionReady) { + console.warn('[x-browser] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.'); + leaveChromeOpen = true; + } + } + if (cdp) { cdp.close(); } if (chrome) { - chrome.unref(); + if (leaveChromeOpen) { + chrome.unref(); + } else { + await gracefulKillChrome(chrome, port); + } } } } diff --git a/skills/baoyu-post-to-x/scripts/x-quote.ts b/skills/baoyu-post-to-x/scripts/x-quote.ts index 87014b5..2f3b69f 100644 --- a/skills/baoyu-post-to-x/scripts/x-quote.ts +++ b/skills/baoyu-post-to-x/scripts/x-quote.ts @@ -5,10 +5,11 @@ import { CdpConnection, findExistingChromeDebugPort, getDefaultProfileDir, - killChrome, + gracefulKillChrome, launchChrome, openPageSession, sleep, + waitForXSessionPersistence, waitForChromeDebugPort, } from './x-utils.js'; @@ -49,7 +50,9 @@ export async function quotePost(options: QuoteOptions): Promise { else console.log(`[x-quote] Launching Chrome (profile: ${profileDir})`); let cdp: CdpConnection | null = null; + let sessionId: string | null = null; let targetId: string | null = null; + let loggedInDuringRun = false; try { const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true }); @@ -62,8 +65,10 @@ export async function quotePost(options: QuoteOptions): Promise { matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'), enablePage: true, enableRuntime: true, + enableNetwork: true, }); - const { sessionId } = page; + const activeSessionId = page.sessionId; + sessionId = activeSessionId; targetId = page.targetId; console.log('[x-quote] Waiting for tweet to load...'); @@ -76,7 +81,7 @@ export async function quotePost(options: QuoteOptions): Promise { const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', { expression: `!!document.querySelector('[data-testid="retweet"]')`, returnByValue: true, - }, { sessionId }); + }, { sessionId: activeSessionId }); if (result.result.value) return true; await sleep(1000); } @@ -89,13 +94,14 @@ export async function quotePost(options: QuoteOptions): Promise { console.log('[x-quote] Waiting for login...'); const loggedIn = await waitForRetweetButton(); if (!loggedIn) throw new Error('Timed out waiting for tweet. Please log in first or check the tweet URL.'); + loggedInDuringRun = true; } // Click the retweet button console.log('[x-quote] Clicking retweet button...'); await cdp.send('Runtime.evaluate', { expression: `document.querySelector('[data-testid="retweet"]')?.click()`, - }, { sessionId }); + }, { sessionId: activeSessionId }); await sleep(1000); // Wait for and click the "Quote" option in the menu @@ -106,7 +112,7 @@ export async function quotePost(options: QuoteOptions): Promise { const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', { expression: `!!document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')`, returnByValue: true, - }, { sessionId }); + }, { sessionId: activeSessionId }); if (result.result.value) return true; await sleep(200); } @@ -121,7 +127,7 @@ export async function quotePost(options: QuoteOptions): Promise { // Click the quote option (second menu item) await cdp.send('Runtime.evaluate', { expression: `document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')?.click()`, - }, { sessionId }); + }, { sessionId: activeSessionId }); await sleep(2000); // Wait for the quote compose dialog @@ -132,7 +138,7 @@ export async function quotePost(options: QuoteOptions): Promise { const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', { expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`, returnByValue: true, - }, { sessionId }); + }, { sessionId: activeSessionId }); if (result.result.value) return true; await sleep(200); } @@ -150,12 +156,12 @@ export async function quotePost(options: QuoteOptions): Promise { // Use CDP Input.insertText for more reliable text insertion await cdp.send('Runtime.evaluate', { expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`, - }, { sessionId }); + }, { sessionId: activeSessionId }); await sleep(200); await cdp.send('Input.insertText', { text: comment, - }, { sessionId }); + }, { sessionId: activeSessionId }); await sleep(500); } @@ -163,7 +169,7 @@ export async function quotePost(options: QuoteOptions): Promise { console.log('[x-quote] Submitting quote post...'); await cdp.send('Runtime.evaluate', { expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`, - }, { sessionId }); + }, { sessionId: activeSessionId }); await sleep(2000); console.log('[x-quote] Quote post submitted!'); } else { @@ -172,15 +178,29 @@ export async function quotePost(options: QuoteOptions): Promise { await sleep(30_000); } } finally { + let leaveChromeOpen = false; + if (chrome && loggedInDuringRun && cdp && sessionId) { + console.log('[x-quote] Waiting for X session cookies to persist...'); + const sessionReady = await waitForXSessionPersistence({ cdp, sessionId }); + if (!sessionReady) { + console.warn('[x-quote] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.'); + leaveChromeOpen = true; + } + } + if (cdp) { if (reusing && targetId) { try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {} - } else if (!reusing) { - try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {} } cdp.close(); } - if (chrome) killChrome(chrome); + if (chrome) { + if (leaveChromeOpen) { + chrome.unref(); + } else { + await gracefulKillChrome(chrome, port); + } + } } } diff --git a/skills/baoyu-post-to-x/scripts/x-utils.test.ts b/skills/baoyu-post-to-x/scripts/x-utils.test.ts new file mode 100644 index 0000000..b4c2ef1 --- /dev/null +++ b/skills/baoyu-post-to-x/scripts/x-utils.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildXSessionCookieMap, + hasChromeLockArtifacts, + hasRequiredXSessionCookies, + shouldRetryChromeLaunch, +} from "./x-utils.ts"; + +test("hasChromeLockArtifacts detects Chrome singleton artifacts", () => { + assert.equal(hasChromeLockArtifacts(["SingletonSocket"]), true); + assert.equal(hasChromeLockArtifacts(["chrome.pid"]), true); + assert.equal(hasChromeLockArtifacts(["Cookies", "Preferences"]), false); +}); + +test("shouldRetryChromeLaunch only retries when no live owner exists", () => { + assert.equal( + shouldRetryChromeLaunch({ lockArtifactsPresent: true, hasLiveOwner: false }), + true, + ); + assert.equal( + shouldRetryChromeLaunch({ lockArtifactsPresent: true, hasLiveOwner: true }), + false, + ); + assert.equal( + shouldRetryChromeLaunch({ lockArtifactsPresent: false, hasLiveOwner: false }), + false, + ); +}); + +test("buildXSessionCookieMap keeps only non-empty cookies", () => { + assert.deepEqual( + buildXSessionCookieMap([ + { name: "auth_token", value: "auth" }, + { name: "ct0", value: "csrf" }, + { name: "twid", value: "u=123" }, + { name: "ct0", value: "" }, + { name: "", value: "ignored" }, + { name: "gt", value: undefined }, + ]), + { + auth_token: "auth", + ct0: "csrf", + twid: "u=123", + }, + ); +}); + +test("hasRequiredXSessionCookies requires auth_token and ct0", () => { + assert.equal(hasRequiredXSessionCookies({ auth_token: "auth" }), false); + assert.equal(hasRequiredXSessionCookies({ ct0: "csrf" }), false); + assert.equal(hasRequiredXSessionCookies({ auth_token: "auth", ct0: "csrf" }), true); +}); diff --git a/skills/baoyu-post-to-x/scripts/x-utils.ts b/skills/baoyu-post-to-x/scripts/x-utils.ts index 2ef5670..1175faf 100644 --- a/skills/baoyu-post-to-x/scripts/x-utils.ts +++ b/skills/baoyu-post-to-x/scripts/x-utils.ts @@ -1,4 +1,5 @@ import { execSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -8,6 +9,7 @@ import { findChromeExecutable as findChromeExecutableBase, findExistingChromeDebugPort as findExistingChromeDebugPortBase, getFreePort as getFreePortBase, + gracefulKillChrome, killChrome, launchChrome as launchChromeBase, openPageSession, @@ -17,9 +19,21 @@ import { type PlatformCandidates, } from 'baoyu-chrome-cdp'; -export { CdpConnection, killChrome, openPageSession, sleep, waitForChromeDebugPort }; +export { CdpConnection, gracefulKillChrome, killChrome, openPageSession, sleep, waitForChromeDebugPort }; export type { PlatformCandidates } from 'baoyu-chrome-cdp'; +const X_SESSION_URLS = ['https://x.com/', 'https://twitter.com/'] as const; +const REQUIRED_X_SESSION_COOKIES = ['auth_token', 'ct0'] as const; + +interface CookieLike { + name?: string; + value?: string | null; +} + +interface NetworkGetCookiesResult { + cookies?: CookieLike[]; +} + export const CHROME_CANDIDATES_BASIC: PlatformCandidates = { darwin: [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', @@ -94,15 +108,105 @@ export async function findExistingChromeDebugPort(profileDir: string): Promise entries.includes(name)); +} + +export function shouldRetryChromeLaunch(options: { + lockArtifactsPresent: boolean; + hasLiveOwner: boolean; +}): boolean { + return options.lockArtifactsPresent && !options.hasLiveOwner; +} + +export function buildXSessionCookieMap(cookies: readonly CookieLike[]): Record { + const cookieMap: Record = {}; + for (const cookie of cookies) { + const name = cookie.name?.trim(); + const value = cookie.value?.trim(); + if (!name || !value) { + continue; + } + cookieMap[name] = value; + } + return cookieMap; +} + +export function hasRequiredXSessionCookies(cookieMap: Record): boolean { + return REQUIRED_X_SESSION_COOKIES.every((name) => Boolean(cookieMap[name])); +} + +export async function readXSessionCookieMap( + cdp: CdpConnection, + sessionId?: string, +): Promise> { + const { cookies } = await cdp.send( + 'Network.getCookies', + { urls: [...X_SESSION_URLS] }, + { + sessionId, + timeoutMs: 5_000, + }, + ); + return buildXSessionCookieMap(cookies ?? []); +} + +export async function waitForXSessionPersistence(options: { + cdp: CdpConnection; + sessionId?: string; + timeoutMs?: number; + pollIntervalMs?: number; +}): Promise { + const timeoutMs = options.timeoutMs ?? 15_000; + const pollIntervalMs = options.pollIntervalMs ?? 1_000; + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + const cookieMap = await readXSessionCookieMap(options.cdp, options.sessionId).catch(() => ({})); + if (hasRequiredXSessionCookies(cookieMap)) { + return true; + } + await sleep(pollIntervalMs); + } + + return false; +} + +function cleanStaleLockFiles(profileDir: string): void { + for (const name of CHROME_LOCK_FILES) { + try { fs.unlinkSync(path.join(profileDir, name)); } catch {} + } +} + +function hasLiveChromeOwner(profileDir: string): boolean { + if (process.platform === 'win32') return false; + try { + const result = spawnSync('ps', ['aux'], { + encoding: 'utf8', + timeout: 5000, + }); + if (result.status !== 0 || !result.stdout) return false; + return result.stdout.split('\n').some((line) => line.includes(`--user-data-dir=${profileDir}`)); + } catch { + return false; + } +} + +async function listProfileDirEntries(profileDir: string): Promise { + try { + return await fs.promises.readdir(profileDir); + } catch { + return []; + } +} + +async function launchChromeOnce( url: string, profileDir: string, - candidates: PlatformCandidates, - chromePathOverride?: string, + chromePath: string, ): Promise<{ chrome: Awaited>; port: number }> { - const chromePath = chromePathOverride?.trim() || findChromeExecutable(candidates); - if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.'); - const port = await getFreePort(); const chrome = await launchChromeBase({ chromePath, @@ -112,7 +216,37 @@ export async function launchChrome( extraArgs: ['--start-maximized'], }); - return { chrome, port }; + try { + await waitForChromeDebugPort(port, 30_000, { includeLastError: true }); + return { chrome, port }; + } catch (error) { + killChrome(chrome); + throw error; + } +} + +export async function launchChrome( + url: string, + profileDir: string, + candidates: PlatformCandidates, + chromePathOverride?: string, +): Promise<{ chrome: Awaited>; port: number }> { + const chromePath = chromePathOverride?.trim() || findChromeExecutable(candidates); + if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.'); + + try { + return await launchChromeOnce(url, profileDir, chromePath); + } catch (error) { + const entries = await listProfileDirEntries(profileDir); + const shouldRetry = shouldRetryChromeLaunch({ + lockArtifactsPresent: hasChromeLockArtifacts(entries), + hasLiveOwner: hasLiveChromeOwner(profileDir), + }); + if (!shouldRetry) throw error; + + cleanStaleLockFiles(profileDir); + return await launchChromeOnce(url, profileDir, chromePath); + } } export function getScriptDir(): string { diff --git a/skills/baoyu-post-to-x/scripts/x-video.ts b/skills/baoyu-post-to-x/scripts/x-video.ts index 43ccc55..0d91755 100644 --- a/skills/baoyu-post-to-x/scripts/x-video.ts +++ b/skills/baoyu-post-to-x/scripts/x-video.ts @@ -7,10 +7,11 @@ import { CdpConnection, findExistingChromeDebugPort, getDefaultProfileDir, - killChrome, + gracefulKillChrome, launchChrome, openPageSession, sleep, + waitForXSessionPersistence, waitForChromeDebugPort, } from './x-utils.js'; @@ -49,7 +50,9 @@ export async function postVideoToX(options: XVideoOptions): Promise { else console.log(`[x-video] Launching Chrome (profile: ${profileDir})`); let cdp: CdpConnection | null = null; + let sessionId: string | null = null; let targetId: string | null = null; + let loggedInDuringRun = false; try { const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true }); @@ -63,10 +66,12 @@ export async function postVideoToX(options: XVideoOptions): Promise { enablePage: true, enableRuntime: true, enableDom: true, + enableNetwork: true, }); - const { sessionId } = page; + const activeSessionId = page.sessionId; + sessionId = activeSessionId; targetId = page.targetId; - await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId }); + await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId: activeSessionId }); console.log('[x-video] Waiting for X editor...'); await sleep(3000); @@ -77,7 +82,7 @@ export async function postVideoToX(options: XVideoOptions): Promise { const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', { expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`, returnByValue: true, - }, { sessionId }); + }, { sessionId: activeSessionId }); if (result.result.value) return true; await sleep(1000); } @@ -90,16 +95,17 @@ export async function postVideoToX(options: XVideoOptions): Promise { console.log('[x-video] Waiting for login...'); const loggedIn = await waitForEditor(); if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.'); + loggedInDuringRun = true; } // Upload video FIRST (before typing text to avoid text being cleared) console.log('[x-video] Uploading video...'); - const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId }); + const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId: activeSessionId }); const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', { nodeId: root.nodeId, selector: 'input[type="file"][accept*="video"], input[data-testid="fileInput"], input[type="file"]', - }, { sessionId }); + }, { sessionId: activeSessionId }); if (!nodeId || nodeId === 0) { throw new Error('Could not find file input for video upload.'); @@ -108,7 +114,7 @@ export async function postVideoToX(options: XVideoOptions): Promise { await cdp.send('DOM.setFileInputFiles', { nodeId, files: [absVideoPath], - }, { sessionId }); + }, { sessionId: activeSessionId }); console.log('[x-video] Video file set, uploading in background...'); // Wait a moment for upload to start, then type text while video processes @@ -125,7 +131,7 @@ export async function postVideoToX(options: XVideoOptions): Promise { document.execCommand('insertText', false, ${JSON.stringify(text)}); } `, - }, { sessionId }); + }, { sessionId: activeSessionId }); await sleep(500); } @@ -143,7 +149,7 @@ export async function postVideoToX(options: XVideoOptions): Promise { return { hasMedia, buttonEnabled }; })()`, returnByValue: true, - }, { sessionId }); + }, { sessionId: activeSessionId }); const { hasMedia, buttonEnabled } = result.result.value; if (hasMedia && buttonEnabled) { @@ -171,7 +177,7 @@ export async function postVideoToX(options: XVideoOptions): Promise { console.log('[x-video] Submitting post...'); await cdp.send('Runtime.evaluate', { expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`, - }, { sessionId }); + }, { sessionId: activeSessionId }); await sleep(5000); console.log('[x-video] Post submitted!'); } else { @@ -179,13 +185,29 @@ export async function postVideoToX(options: XVideoOptions): Promise { console.log('[x-video] Browser stays open for review.'); } } finally { + let leaveChromeOpen = !submit; + if (chrome && submit && loggedInDuringRun && cdp && sessionId) { + console.log('[x-video] Waiting for X session cookies to persist...'); + const sessionReady = await waitForXSessionPersistence({ cdp, sessionId }); + if (!sessionReady) { + console.warn('[x-video] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.'); + leaveChromeOpen = true; + } + } + if (cdp) { if (reusing && submit && targetId) { try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {} } cdp.close(); } - if (chrome && submit) killChrome(chrome); + if (chrome && submit) { + if (leaveChromeOpen) { + chrome.unref(); + } else { + await gracefulKillChrome(chrome, port); + } + } } }