diff --git a/cli-manifest.json b/cli-manifest.json index 99ae2eb56..d6175ae6c 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -36689,14 +36689,39 @@ "type": "int", "default": 20, "required": false, - "help": "Maximum number of bookmarks to return (default 20)." + "help": "Maximum number of bookmarks to return (default 20). Ignored when --all is set." + }, + { + "name": "all", + "type": "bool", + "default": false, + "required": false, + "help": "Fetch all bookmark pages until exhausted. Prefer --output-file for large archives." + }, + { + "name": "resume-file", + "type": "string", + "required": false, + "help": "Resume file for long-running all-pages bookmark syncs." + }, + { + "name": "output-file", + "type": "string", + "required": false, + "help": "Write all-page results to a JSONL file instead of returning one large JSON array." + }, + { + "name": "max-pages", + "type": "int", + "required": false, + "help": "Optional pagination safety cap (default 100; raised automatically with --all)." }, { "name": "top-by-engagement", "type": "int", "default": 0, "required": false, - "help": "When set to N>0, re-rank the bookmarks by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API's native (saved-time) ordering." + "help": "When set to N>0, re-rank the bookmarks by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API's native (saved-time) ordering. Incompatible with --output-file." } ], "columns": [ @@ -37035,14 +37060,39 @@ "type": "int", "default": 20, "required": false, - "help": "Maximum number of liked tweets to return (default 20)." + "help": "Maximum number of liked tweets to return (default 20). Ignored when --all is set." + }, + { + "name": "all", + "type": "bool", + "default": false, + "required": false, + "help": "Fetch all liked-tweet pages until exhausted. Prefer --output-file for large archives." + }, + { + "name": "resume-file", + "type": "string", + "required": false, + "help": "Resume file for long-running all-pages likes syncs." + }, + { + "name": "output-file", + "type": "string", + "required": false, + "help": "Write all-page results to a JSONL file instead of returning one large JSON array." + }, + { + "name": "max-pages", + "type": "int", + "required": false, + "help": "Optional pagination safety cap (default 100; raised automatically with --all)." }, { "name": "top-by-engagement", "type": "int", "default": 0, "required": false, - "help": "When set to N>0, re-rank the liked tweets by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API's native (recency) ordering." + "help": "When set to N>0, re-rank the liked tweets by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API's native (recency) ordering. Incompatible with --output-file." } ], "columns": [ diff --git a/clis/twitter/bookmarks.js b/clis/twitter/bookmarks.js index 2cd0c046f..f3298ef23 100644 --- a/clis/twitter/bookmarks.js +++ b/clis/twitter/bookmarks.js @@ -1,9 +1,13 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { cli, Strategy } from '@jackwener/opencli/registry'; -import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; import { extractMedia, describeTwitterApiError } from './shared.js'; import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js'; const BOOKMARKS_QUERY_ID = 'Fy0QMy4q_aZCpkO0PnyLYw'; -const MAX_PAGINATION_PAGES = 100; +// Safety cap only. Full-archive runs can set a higher page budget via --max-pages. +const DEFAULT_MAX_PAGINATION_PAGES = 100; +const HARD_MAX_PAGINATION_PAGES = 100000; const FEATURES = { rweb_video_screen_enabled: false, profile_label_improvements_pcf_label_in_post_enabled: true, @@ -100,6 +104,88 @@ export function parseBookmarks(data, seen) { } return { tweets, nextCursor }; } +function readResumeFile(filePath) { + if (!filePath || !fs.existsSync(filePath)) + return null; + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + return { + cursor: parsed?.cursor || null, + count: Number(parsed?.count || 0), + tweets: Array.isArray(parsed?.tweets) ? parsed.tweets : [], + complete: Boolean(parsed?.complete), + source: parsed?.source || null, + outputFile: parsed?.outputFile || null, + updatedAt: parsed?.updatedAt || null, + }; + } + catch { + return null; + } +} +function ensureParentDir(filePath) { + if (!filePath) + return; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} +function removeFile(filePath) { + if (!filePath) + return; + try { + fs.rmSync(filePath, { force: true }); + } + catch { + } +} +function loadSeenIdsFromJsonl(filePath) { + const seen = new Set(); + if (!filePath || !fs.existsSync(filePath)) + return seen; + const text = fs.readFileSync(filePath, 'utf8'); + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) + continue; + try { + const row = JSON.parse(trimmed); + if (row?.id) + seen.add(String(row.id)); + } + catch { + } + } + return seen; +} +function appendJsonlRows(filePath, rows) { + if (!filePath || !Array.isArray(rows) || rows.length === 0) + return; + ensureParentDir(filePath); + // Escape LS/PS so JSONL stays one physical line even when tweet text contains them. + const text = rows + .map((row) => JSON.stringify(row).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')) + .join('\n') + '\n'; + fs.appendFileSync(filePath, text, 'utf8'); +} +function writeResumeFile(filePath, payload) { + if (!filePath) + return; + ensureParentDir(filePath); + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n'); +} +function removeResumeFile(filePath) { + removeFile(filePath); +} +function resolveMaxPages(kwargs, fetchAll) { + const raw = kwargs['max-pages']; + if (raw === undefined || raw === null || raw === '') { + return fetchAll ? HARD_MAX_PAGINATION_PAGES : DEFAULT_MAX_PAGINATION_PAGES; + } + const value = Number(raw); + if (!Number.isInteger(value) || value < 1 || value > HARD_MAX_PAGINATION_PAGES) { + throw new ArgumentError(`--max-pages must be an integer between 1 and ${HARD_MAX_PAGINATION_PAGES}`); + } + return value; +} cli({ site: 'twitter', name: 'bookmarks', @@ -109,12 +195,28 @@ cli({ strategy: Strategy.COOKIE, browser: true, args: [ - { name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' }, - { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the bookmarks by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API\'s native (saved-time) ordering.' }, + { name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20). Ignored when --all is set.' }, + { name: 'all', type: 'bool', default: false, help: 'Fetch all bookmark pages until exhausted. Prefer --output-file for large archives.' }, + { name: 'resume-file', type: 'string', help: 'Resume file for long-running all-pages bookmark syncs.' }, + { name: 'output-file', type: 'string', help: 'Write all-page results to a JSONL file instead of returning one large JSON array.' }, + { name: 'max-pages', type: 'int', help: `Optional pagination safety cap (default ${DEFAULT_MAX_PAGINATION_PAGES}; raised automatically with --all).` }, + { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the bookmarks by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API\'s native (saved-time) ordering. Incompatible with --output-file.' }, ], columns: ['id', 'author', 'text', 'likes', 'retweets', 'bookmarks', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters'], func: async (page, kwargs) => { - const limit = kwargs.limit || 20; + const fetchAll = Boolean(kwargs.all); + const limit = fetchAll ? Number.POSITIVE_INFINITY : (kwargs.limit || 20); + const resumeFile = kwargs['resume-file'] || ''; + const outputFile = kwargs['output-file'] || ''; + const useOutputFile = Boolean(fetchAll && outputFile); + const maxPages = resolveMaxPages(kwargs, fetchAll); + const topByEngagement = Number(kwargs['top-by-engagement'] || 0); + if (useOutputFile && topByEngagement > 0) { + throw new ArgumentError('--top-by-engagement cannot be combined with --output-file'); + } + if (outputFile && !fetchAll) { + throw new ArgumentError('--output-file requires --all'); + } const cookies = await page.getCookies({ url: 'https://x.com' }); const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null; if (!ct0) @@ -149,33 +251,85 @@ cli({ 'X-Twitter-Auth-Type': 'OAuth2Session', 'X-Twitter-Active-User': 'yes', }); - const allTweets = []; - const seen = new Set(); - let cursor = null; - // Runaway guard only; --limit and cursor exhaustion control normal pagination. - for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) { - const fetchCount = Math.min(100, limit - allTweets.length + 10); + let resumed = fetchAll ? readResumeFile(resumeFile) : null; + if (useOutputFile && resumed && !fs.existsSync(outputFile)) { + removeResumeFile(resumeFile); + resumed = null; + } + if (useOutputFile && !resumed) + removeFile(outputFile); + const allTweets = useOutputFile ? [] : (resumed?.tweets ? [...resumed.tweets] : []); + const seen = useOutputFile + ? loadSeenIdsFromJsonl(outputFile) + : new Set(allTweets.map((tweet) => tweet?.id).filter(Boolean)); + let outputCount = useOutputFile + ? Math.max(seen.size, Number(resumed?.count || 0)) + : 0; + let cursor = resumed?.cursor || null; + let pages = 0; + let exhausted = false; + // Runaway guard only; --limit/--all and cursor exhaustion control normal pagination. + while (pages < maxPages && (fetchAll || allTweets.length < limit)) { + pages += 1; + const currentCount = useOutputFile ? outputCount : allTweets.length; + const remaining = fetchAll ? 100 : (limit - currentCount + 10); + const fetchCount = Math.min(100, remaining); const apiUrl = buildBookmarksUrl(fetchCount, cursor).replace(BOOKMARKS_QUERY_ID, queryId); const data = await page.evaluate(`async () => { - const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' }); + const r = await fetch(${JSON.stringify(apiUrl)}, { headers: ${headers}, credentials: 'include' }); return r.ok ? await r.json() : { error: r.status }; }`); if (data?.error) { - if (allTweets.length === 0) + if ((useOutputFile ? outputCount : allTweets.length) === 0) throw new CommandExecutionError(describeTwitterApiError('Bookmarks', data.error)); break; } const { tweets, nextCursor } = parseBookmarks(data, seen); - allTweets.push(...tweets); - if (!nextCursor || nextCursor === cursor) + if (useOutputFile) { + appendJsonlRows(outputFile, tweets); + outputCount += tweets.length; + } + else { + allTweets.push(...tweets); + } + const pageComplete = !nextCursor || nextCursor === cursor; + writeResumeFile(resumeFile, { + cursor: pageComplete ? null : nextCursor, + count: useOutputFile ? outputCount : allTweets.length, + tweets: useOutputFile ? undefined : allTweets, + updatedAt: new Date().toISOString(), + complete: pageComplete, + source: 'bookmarks', + outputFile: useOutputFile ? outputFile : null, + }); + if (pageComplete) { + exhausted = true; break; + } cursor = nextCursor; } - const trimmed = allTweets.slice(0, limit); - return applyTopByEngagement(trimmed, kwargs['top-by-engagement']); + // Resume is only removed after the timeline is truly exhausted. Hitting + // --max-pages, partial API errors after some rows, or an interrupt must + // leave the resume file so the next run can continue. + if (exhausted) + removeResumeFile(resumeFile); + if (useOutputFile) { + return { + outputFile, + count: outputCount, + source: 'bookmarks', + complete: exhausted, + pages, + ...(exhausted ? {} : { cursor, resumeFile: resumeFile || null }), + }; + } + const trimmed = fetchAll ? allTweets : allTweets.slice(0, limit); + return applyTopByEngagement(trimmed, topByEngagement); }, }); export const __test__ = { parseBookmarks, extractBookmarkTweet, + appendJsonlRows, + readResumeFile, }; diff --git a/clis/twitter/bookmarks.test.js b/clis/twitter/bookmarks.test.js index 57c82ea5f..0d619eb8d 100644 --- a/clis/twitter/bookmarks.test.js +++ b/clis/twitter/bookmarks.test.js @@ -1,4 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; import { __test__ } from './bookmarks.js'; const { parseBookmarks, extractBookmarkTweet } = __test__; @@ -204,3 +206,140 @@ describe('twitter bookmarks parser', () => { expect(parseBookmarks({}, new Set())).toEqual({ tweets: [], nextCursor: null }); }); }); + +function bookmarksPayload(withBottomCursor = false) { + const entries = [{ + entryId: 'tweet-1', + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '1', + legacy: { + full_text: 'bookmarked post', + favorite_count: 3, + retweet_count: 1, + bookmark_count: 4, + created_at: 'now', + }, + core: { + user_results: { + result: { + legacy: { screen_name: 'alice', name: 'Alice' }, + }, + }, + }, + }, + }, + }, + }, + }]; + if (withBottomCursor) { + entries.push({ + entryId: 'cursor-bottom-1', + content: { + entryType: 'TimelineTimelineCursor', + cursorType: 'Bottom', + value: 'NEXT_CURSOR', + }, + }); + } + return { + data: { + bookmark_timeline_v2: { + timeline: { + instructions: [{ entries }], + }, + }, + }, + }; +} + +describe('twitter bookmarks command', () => { + it('keeps resume state and reports complete=false when --max-pages stops early', async () => { + const command = getRegistry().get('twitter/bookmarks'); + const resumeFile = `/tmp/opencli-bookmarks-resume-${process.pid}-${Date.now()}.json`; + const outputFile = `/tmp/opencli-bookmarks-out-${process.pid}-${Date.now()}.jsonl`; + const page = { + getCookies: vi.fn(async () => [{ name: 'ct0', value: 'token' }]), + evaluate: vi.fn(async (script) => { + const text = String(script); + if (text.includes('Bookmarks') && text.includes('queryId')) return null; + if (text.includes('/Bookmarks')) return bookmarksPayload(true); + throw new Error(`Unexpected evaluate: ${text.slice(0, 80)}`); + }), + }; + + try { + const result = await command.func(page, { + all: true, + 'max-pages': 1, + 'resume-file': resumeFile, + 'output-file': outputFile, + }); + + expect(result).toMatchObject({ + outputFile, + count: 1, + source: 'bookmarks', + complete: false, + pages: 1, + cursor: 'NEXT_CURSOR', + resumeFile, + }); + expect(fs.existsSync(resumeFile)).toBe(true); + const resume = __test__.readResumeFile(resumeFile); + expect(resume).toMatchObject({ + cursor: 'NEXT_CURSOR', + count: 1, + complete: false, + source: 'bookmarks', + outputFile, + }); + expect(fs.readFileSync(outputFile, 'utf8').trim().split('\n')).toHaveLength(1); + } + finally { + fs.rmSync(resumeFile, { force: true }); + fs.rmSync(outputFile, { force: true }); + } + }); + + it('removes resume file only after the bookmarks timeline is exhausted', async () => { + const command = getRegistry().get('twitter/bookmarks'); + const resumeFile = `/tmp/opencli-bookmarks-resume-done-${process.pid}-${Date.now()}.json`; + const outputFile = `/tmp/opencli-bookmarks-out-done-${process.pid}-${Date.now()}.jsonl`; + const page = { + getCookies: vi.fn(async () => [{ name: 'ct0', value: 'token' }]), + evaluate: vi.fn(async (script) => { + const text = String(script); + if (text.includes('Bookmarks') && text.includes('queryId')) return null; + if (text.includes('/Bookmarks')) return bookmarksPayload(false); + throw new Error(`Unexpected evaluate: ${text.slice(0, 80)}`); + }), + }; + + try { + const result = await command.func(page, { + all: true, + 'max-pages': 1, + 'resume-file': resumeFile, + 'output-file': outputFile, + }); + + expect(result).toMatchObject({ + outputFile, + count: 1, + source: 'bookmarks', + complete: true, + pages: 1, + }); + expect(result.cursor).toBeUndefined(); + expect(fs.existsSync(resumeFile)).toBe(false); + expect(fs.existsSync(outputFile)).toBe(true); + } + finally { + fs.rmSync(resumeFile, { force: true }); + fs.rmSync(outputFile, { force: true }); + } + }); +}); diff --git a/clis/twitter/likes.js b/clis/twitter/likes.js index e2b79df53..2ad14560b 100644 --- a/clis/twitter/likes.js +++ b/clis/twitter/likes.js @@ -1,10 +1,14 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; import { looksLikePrivateTwitterTimeline, normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, extractMedia, unwrapBrowserResult, describeTwitterApiError } from './shared.js'; import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js'; const LIKES_QUERY_ID = 'CDWHmpZeSdIJ3HGeRbNm0w'; const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ'; -const MAX_PAGINATION_PAGES = 100; +// Safety cap only. Full-archive runs can set a higher page budget via --max-pages. +const DEFAULT_MAX_PAGINATION_PAGES = 100; +const HARD_MAX_PAGINATION_PAGES = 100000; const FEATURES = { rweb_video_screen_enabled: false, profile_label_improvements_pcf_label_in_post_enabled: true, @@ -135,6 +139,89 @@ function parseLikes(data, seen) { } return { tweets, nextCursor }; } +function readResumeFile(filePath) { + if (!filePath || !fs.existsSync(filePath)) + return null; + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + return { + cursor: parsed?.cursor || null, + count: Number(parsed?.count || 0), + tweets: Array.isArray(parsed?.tweets) ? parsed.tweets : [], + username: parsed?.username || null, + complete: Boolean(parsed?.complete), + source: parsed?.source || null, + outputFile: parsed?.outputFile || null, + updatedAt: parsed?.updatedAt || null, + }; + } + catch { + return null; + } +} +function ensureParentDir(filePath) { + if (!filePath) + return; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} +function removeFile(filePath) { + if (!filePath) + return; + try { + fs.rmSync(filePath, { force: true }); + } + catch { + } +} +function loadSeenIdsFromJsonl(filePath) { + const seen = new Set(); + if (!filePath || !fs.existsSync(filePath)) + return seen; + const text = fs.readFileSync(filePath, 'utf8'); + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) + continue; + try { + const row = JSON.parse(trimmed); + if (row?.id) + seen.add(String(row.id)); + } + catch { + } + } + return seen; +} +function appendJsonlRows(filePath, rows) { + if (!filePath || !Array.isArray(rows) || rows.length === 0) + return; + ensureParentDir(filePath); + // Escape LS/PS so JSONL stays one physical line even when tweet text contains them. + const text = rows + .map((row) => JSON.stringify(row).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')) + .join('\n') + '\n'; + fs.appendFileSync(filePath, text, 'utf8'); +} +function writeResumeFile(filePath, payload) { + if (!filePath) + return; + ensureParentDir(filePath); + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + '\n'); +} +function removeResumeFile(filePath) { + removeFile(filePath); +} +function resolveMaxPages(kwargs, fetchAll) { + const raw = kwargs['max-pages']; + if (raw === undefined || raw === null || raw === '') { + return fetchAll ? HARD_MAX_PAGINATION_PAGES : DEFAULT_MAX_PAGINATION_PAGES; + } + const value = Number(raw); + if (!Number.isInteger(value) || value < 1 || value > HARD_MAX_PAGINATION_PAGES) { + throw new ArgumentError(`--max-pages must be an integer between 1 and ${HARD_MAX_PAGINATION_PAGES}`); + } + return value; +} cli({ site: 'twitter', name: 'likes', @@ -145,12 +232,28 @@ cli({ browser: true, args: [ { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' }, - { name: 'limit', type: 'int', default: 20, help: 'Maximum number of liked tweets to return (default 20).' }, - { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the liked tweets by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API\'s native (recency) ordering.' }, + { name: 'limit', type: 'int', default: 20, help: 'Maximum number of liked tweets to return (default 20). Ignored when --all is set.' }, + { name: 'all', type: 'bool', default: false, help: 'Fetch all liked-tweet pages until exhausted. Prefer --output-file for large archives.' }, + { name: 'resume-file', type: 'string', help: 'Resume file for long-running all-pages likes syncs.' }, + { name: 'output-file', type: 'string', help: 'Write all-page results to a JSONL file instead of returning one large JSON array.' }, + { name: 'max-pages', type: 'int', help: `Optional pagination safety cap (default ${DEFAULT_MAX_PAGINATION_PAGES}; raised automatically with --all).` }, + { name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the liked tweets by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API\'s native (recency) ordering. Incompatible with --output-file.' }, ], columns: ['id', 'author', 'name', 'text', 'likes', 'retweets', 'created_at', 'url', 'has_media', 'media_urls', 'media_posters'], func: async (page, kwargs) => { - const limit = kwargs.limit || 20; + const fetchAll = Boolean(kwargs.all); + const limit = fetchAll ? Number.POSITIVE_INFINITY : (kwargs.limit || 20); + const resumeFile = kwargs['resume-file'] || ''; + const outputFile = kwargs['output-file'] || ''; + const useOutputFile = Boolean(fetchAll && outputFile); + const maxPages = resolveMaxPages(kwargs, fetchAll); + const topByEngagement = Number(kwargs['top-by-engagement'] || 0); + if (useOutputFile && topByEngagement > 0) { + throw new ArgumentError('--top-by-engagement cannot be combined with --output-file'); + } + if (outputFile && !fetchAll) { + throw new ArgumentError('--output-file requires --all'); + } const rawUsername = String(kwargs.username ?? '').trim(); let username = normalizeTwitterScreenName(rawUsername); if (rawUsername && !username) { @@ -199,38 +302,91 @@ cli({ if (!userId) { throw new CommandExecutionError(`Could not find user @${username}`); } - const allTweets = []; - const seen = new Set(); - let cursor = null; + let resumed = fetchAll ? readResumeFile(resumeFile) : null; + if (useOutputFile && resumed && !fs.existsSync(outputFile)) { + removeResumeFile(resumeFile); + resumed = null; + } + if (useOutputFile && !resumed) + removeFile(outputFile); + const allTweets = useOutputFile ? [] : (resumed?.tweets ? [...resumed.tweets] : []); + const seen = useOutputFile + ? loadSeenIdsFromJsonl(outputFile) + : new Set(allTweets.map((tweet) => tweet?.id).filter(Boolean)); + let outputCount = useOutputFile + ? Math.max(seen.size, Number(resumed?.count || 0)) + : 0; + let cursor = resumed?.cursor || null; let lastRawResponse = null; - // Runaway guard only; --limit and cursor exhaustion control normal pagination. - for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) { - const fetchCount = Math.min(100, limit - allTweets.length + 10); + let pages = 0; + let exhausted = false; + // Runaway guard only; --limit/--all and cursor exhaustion control normal pagination. + while (pages < maxPages && (fetchAll || allTweets.length < limit)) { + pages += 1; + const currentCount = useOutputFile ? outputCount : allTweets.length; + const remaining = fetchAll ? 100 : (limit - currentCount + 10); + const fetchCount = Math.min(100, remaining); const apiUrl = buildLikesUrl(likesQueryId, userId, fetchCount, cursor); const data = unwrapBrowserResult(await page.evaluate(`async () => { - const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' }); + const r = await fetch(${JSON.stringify(apiUrl)}, { headers: ${headers}, credentials: 'include' }); return r.ok ? await r.json() : { error: r.status }; }`)); if (data?.error) { - if (allTweets.length === 0) + if ((useOutputFile ? outputCount : allTweets.length) === 0) throw new CommandExecutionError(describeTwitterApiError('Likes', data.error)); break; } lastRawResponse = data; const { tweets, nextCursor } = parseLikes(data, seen); - allTweets.push(...tweets); - if (!nextCursor || nextCursor === cursor) + if (useOutputFile) { + appendJsonlRows(outputFile, tweets); + outputCount += tweets.length; + } + else { + allTweets.push(...tweets); + } + const pageComplete = !nextCursor || nextCursor === cursor; + writeResumeFile(resumeFile, { + cursor: pageComplete ? null : nextCursor, + count: useOutputFile ? outputCount : allTweets.length, + tweets: useOutputFile ? undefined : allTweets, + updatedAt: new Date().toISOString(), + complete: pageComplete, + source: 'likes', + username, + outputFile: useOutputFile ? outputFile : null, + }); + if (pageComplete) { + exhausted = true; break; + } cursor = nextCursor; } - if (allTweets.length === 0) { + const finalCount = useOutputFile ? outputCount : allTweets.length; + if (finalCount === 0) { if (looksLikePrivateTwitterTimeline(lastRawResponse)) { throw new EmptyResultError('twitter likes', `No likes returned for @${username} (Likes are private by default on X; only the account owner can view their own likes)`); } throw new EmptyResultError('twitter likes', `No likes found for @${username}`); } - const trimmed = allTweets.slice(0, limit); - return applyTopByEngagement(trimmed, kwargs['top-by-engagement']); + // Resume is only removed after the timeline is truly exhausted. Hitting + // --max-pages, partial API errors after some rows, or an interrupt must + // leave the resume file so the next run can continue. + if (exhausted) + removeResumeFile(resumeFile); + if (useOutputFile) { + return { + outputFile, + count: outputCount, + source: 'likes', + username, + complete: exhausted, + pages, + ...(exhausted ? {} : { cursor, resumeFile: resumeFile || null }), + }; + } + const trimmed = fetchAll ? allTweets : allTweets.slice(0, limit); + return applyTopByEngagement(trimmed, topByEngagement); }, }); export const __test__ = { @@ -238,4 +394,6 @@ export const __test__ = { buildLikesUrl, buildUserByScreenNameUrl, parseLikes, + appendJsonlRows, + readResumeFile, }; diff --git a/clis/twitter/likes.test.js b/clis/twitter/likes.test.js index 81ab7af6c..a94bad144 100644 --- a/clis/twitter/likes.test.js +++ b/clis/twitter/likes.test.js @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import { describe, expect, it, vi } from 'vitest'; import { getRegistry } from '@jackwener/opencli/registry'; import { ArgumentError, AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors'; @@ -217,4 +218,124 @@ describe('twitter likes command', () => { expect(decodeURIComponent(String(likesCall[0]))).toContain('"userId":"42"'); expect(decodeURIComponent(String(likesCall[0]))).not.toContain('[object Object]'); }); + + it('keeps resume state and reports complete=false when --max-pages stops early', async () => { + const command = getRegistry().get('twitter/likes'); + const resumeFile = `/tmp/opencli-likes-resume-${process.pid}-${Date.now()}.json`; + const outputFile = `/tmp/opencli-likes-out-${process.pid}-${Date.now()}.jsonl`; + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + getCookies: vi.fn(async () => [{ name: 'ct0', value: 'token' }]), + evaluate: vi.fn(async (script) => { + const text = String(script); + if (text.includes('AppTabBar_Profile_Link')) { + return { session: 'site:twitter', data: '/viewer' }; + } + if (text.includes('operationName')) return null; + if (text.includes('/UserByScreenName')) { + return { session: 'site:twitter', data: '42' }; + } + if (text.includes('/Likes')) { + const payload = likesPayload(); + payload.data.user.result.timeline_v2.timeline.instructions[0].entries.push({ + entryId: 'cursor-bottom-1', + content: { + entryType: 'TimelineTimelineCursor', + cursorType: 'Bottom', + value: 'NEXT_CURSOR', + }, + }); + return { session: 'site:twitter', data: payload }; + } + throw new Error(`Unexpected evaluate: ${text.slice(0, 80)}`); + }), + }; + + try { + const result = await command.func(page, { + all: true, + 'max-pages': 1, + 'resume-file': resumeFile, + 'output-file': outputFile, + }); + + expect(result).toMatchObject({ + outputFile, + count: 1, + source: 'likes', + username: 'viewer', + complete: false, + pages: 1, + cursor: 'NEXT_CURSOR', + resumeFile, + }); + expect(fs.existsSync(resumeFile)).toBe(true); + expect(fs.existsSync(outputFile)).toBe(true); + const resume = __test__.readResumeFile(resumeFile); + expect(resume).toMatchObject({ + cursor: 'NEXT_CURSOR', + count: 1, + complete: false, + source: 'likes', + username: 'viewer', + outputFile, + }); + expect(fs.readFileSync(outputFile, 'utf8').trim().split('\n')).toHaveLength(1); + } + finally { + fs.rmSync(resumeFile, { force: true }); + fs.rmSync(outputFile, { force: true }); + } + }); + + it('removes resume file only after the likes timeline is exhausted', async () => { + const command = getRegistry().get('twitter/likes'); + const resumeFile = `/tmp/opencli-likes-resume-done-${process.pid}-${Date.now()}.json`; + const outputFile = `/tmp/opencli-likes-out-done-${process.pid}-${Date.now()}.jsonl`; + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + getCookies: vi.fn(async () => [{ name: 'ct0', value: 'token' }]), + evaluate: vi.fn(async (script) => { + const text = String(script); + if (text.includes('AppTabBar_Profile_Link')) { + return { session: 'site:twitter', data: '/viewer' }; + } + if (text.includes('operationName')) return null; + if (text.includes('/UserByScreenName')) { + return { session: 'site:twitter', data: '42' }; + } + if (text.includes('/Likes')) { + return { session: 'site:twitter', data: likesPayload() }; + } + throw new Error(`Unexpected evaluate: ${text.slice(0, 80)}`); + }), + }; + + try { + const result = await command.func(page, { + all: true, + 'max-pages': 1, + 'resume-file': resumeFile, + 'output-file': outputFile, + }); + + expect(result).toMatchObject({ + outputFile, + count: 1, + source: 'likes', + username: 'viewer', + complete: true, + pages: 1, + }); + expect(result.cursor).toBeUndefined(); + expect(fs.existsSync(resumeFile)).toBe(false); + expect(fs.existsSync(outputFile)).toBe(true); + } + finally { + fs.rmSync(resumeFile, { force: true }); + fs.rmSync(outputFile, { force: true }); + } + }); }); diff --git a/extension/dist/background.js b/extension/dist/background.js index c6306aca8..194ac9fe9 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,2454 +1,2554 @@ -const DAEMON_PORT = 19825; -const DAEMON_HOST = "localhost"; -const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; - -const attached = /* @__PURE__ */ new Set(); -const tabFrameContexts = /* @__PURE__ */ new Map(); -const frameTargets = /* @__PURE__ */ new Map(); -const frameTargetKeys = /* @__PURE__ */ new Map(); -let frameTargetCleanupRegistered = false; -const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024; -const CDP_REQUEST_BODY_CAPTURE_LIMIT = 1 * 1024 * 1024; -const networkCaptures = /* @__PURE__ */ new Map(); -const CDP_COMMAND_TIMEOUT_MS = 6e4; -const CDP_PROBE_TIMEOUT_MS = 2e3; +//#region src/protocol.ts +/** Default daemon port */ +var DAEMON_PORT = 19825; +var DAEMON_HOST = "localhost"; +var DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; +/** Lightweight health-check endpoint — probed before each WebSocket attempt. */ +var DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; +//#endregion +//#region src/cdp.ts +/** +* CDP execution via chrome.debugger API. +* +* chrome.debugger only needs the "debugger" permission — no host_permissions. +* It can attach to any http/https tab. Avoid chrome:// and chrome-extension:// +* tabs (resolveTabId in background.ts filters them). +*/ +var attached = /* @__PURE__ */ new Set(); +var tabFrameContexts = /* @__PURE__ */ new Map(); +var frameTargets = /* @__PURE__ */ new Map(); +var frameTargetKeys = /* @__PURE__ */ new Map(); +var frameTargetCleanupRegistered = false; +var CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024; +var CDP_REQUEST_BODY_CAPTURE_LIMIT = 1 * 1024 * 1024; +var networkCaptures = /* @__PURE__ */ new Map(); +/** +* Default deadline for a single chrome.debugger command. chrome.debugger has +* no timeout of its own: a page-blocking native dialog (alert/confirm/print/ +* beforeunload) makes Runtime.evaluate hang forever, wedging every later +* command on the tab. Long enough for legitimate in-page waits (default 30s +* plus headroom), short enough to fail before the daemon's 120s timer. +*/ +var CDP_COMMAND_TIMEOUT_MS = 6e4; +/** Health-check probe deadline — a blocked probe should fail fast. */ +var CDP_PROBE_TIMEOUT_MS = 2e3; +/** +* chrome.debugger.sendCommand with a deadline. The underlying command cannot +* be cancelled — this only unblocks the caller so the CLI gets an error +* instead of an infinite hang. +*/ async function sendDebuggerCommand(target, method, params, timeoutMs = CDP_COMMAND_TIMEOUT_MS) { - let timer; - const commandPromise = params === void 0 ? chrome.debugger.sendCommand(target, method) : chrome.debugger.sendCommand(target, method, params); - commandPromise.catch(() => { - }); - try { - return await Promise.race([ - commandPromise, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error( - `CDP command ${method} timed out after ${Math.round(timeoutMs / 1e3)}s — the page may be blocked by a native dialog (alert/confirm/print)` - )), timeoutMs); - }) - ]); - } finally { - if (timer !== void 0) clearTimeout(timer); - } -} + let timer; + const commandPromise = params === void 0 ? chrome.debugger.sendCommand(target, method) : chrome.debugger.sendCommand(target, method, params); + commandPromise.catch(() => {}); + try { + return await Promise.race([commandPromise, new Promise((_, reject) => { + timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`CDP command ${method} timed out after ${Math.round(timeoutMs / 1e3)}s — the page may be blocked by a native dialog (alert/confirm/print)`)), timeoutMs); + })]); + } finally { + if (timer !== void 0) clearTimeout(timer); + } +} +/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ function isDebuggableUrl$1(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); } async function ensureAttached(tabId, aggressiveRetry = false) { - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl$1(tab.url)) { - attached.delete(tabId); - throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); - } - } catch (e) { - if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; - attached.delete(tabId); - throw new Error(`Tab ${tabId} no longer exists`); - } - if (attached.has(tabId)) { - try { - await sendDebuggerCommand({ tabId }, "Runtime.evaluate", { - expression: "1", - returnByValue: true - }, CDP_PROBE_TIMEOUT_MS); - return; - } catch { - attached.delete(tabId); - } - } - const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; - const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; - let lastError = ""; - const preservedNetworkCapture = networkCaptures.get(tabId); - for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) { - try { - try { - await chrome.debugger.detach({ tabId }); - } catch { - } - await chrome.debugger.attach({ tabId }, "1.3"); - lastError = ""; - break; - } catch (e) { - lastError = e instanceof Error ? e.message : String(e); - if (attempt < MAX_ATTACH_RETRIES) { - console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl$1(tab.url)) { - lastError = `Tab URL changed to ${tab.url} during retry`; - break; - } - } catch { - lastError = `Tab ${tabId} no longer exists`; - } - } - } - } - if (lastError) { - let finalUrl = "unknown"; - let finalWindowId = "unknown"; - try { - const tab = await chrome.tabs.get(tabId); - finalUrl = tab.url ?? "undefined"; - finalWindowId = String(tab.windowId); - } catch { - } - console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`); - const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; - throw new Error(`attach failed: ${lastError}${hint}`); - } - attached.add(tabId); - try { - await sendDebuggerCommand({ tabId }, "Runtime.enable"); - } catch { - } - if (preservedNetworkCapture) { - try { - await sendDebuggerCommand({ tabId }, "Network.enable"); - networkCaptures.set(tabId, preservedNetworkCapture); - } catch { - } - } + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl$1(tab.url)) { + attached.delete(tabId); + throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); + } + } catch (e) { + if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; + attached.delete(tabId); + throw new Error(`Tab ${tabId} no longer exists`); + } + if (attached.has(tabId)) try { + await sendDebuggerCommand({ tabId }, "Runtime.evaluate", { + expression: "1", + returnByValue: true + }, CDP_PROBE_TIMEOUT_MS); + return; + } catch { + attached.delete(tabId); + } + const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; + const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; + let lastError = ""; + const preservedNetworkCapture = networkCaptures.get(tabId); + for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) try { + try { + await chrome.debugger.detach({ tabId }); + } catch {} + await chrome.debugger.attach({ tabId }, "1.3"); + lastError = ""; + break; + } catch (e) { + lastError = e instanceof Error ? e.message : String(e); + if (attempt < MAX_ATTACH_RETRIES) { + console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl$1(tab.url)) { + lastError = `Tab URL changed to ${tab.url} during retry`; + break; + } + } catch { + lastError = `Tab ${tabId} no longer exists`; + } + } + } + if (lastError) { + let finalUrl = "unknown"; + let finalWindowId = "unknown"; + try { + const tab = await chrome.tabs.get(tabId); + finalUrl = tab.url ?? "undefined"; + finalWindowId = String(tab.windowId); + } catch {} + console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`); + const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; + throw new Error(`attach failed: ${lastError}${hint}`); + } + attached.add(tabId); + try { + await sendDebuggerCommand({ tabId }, "Runtime.enable"); + } catch {} + if (preservedNetworkCapture) try { + await sendDebuggerCommand({ tabId }, "Network.enable"); + networkCaptures.set(tabId, preservedNetworkCapture); + } catch {} } async function evaluate(tabId, expression, aggressiveRetry = false, timeoutMs = CDP_COMMAND_TIMEOUT_MS) { - try { - await ensureAttached(tabId, aggressiveRetry); - const result = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", { - expression, - returnByValue: true, - awaitPromise: true - }, timeoutMs); - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); - } - return result.result?.value; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - if (msg.includes("Detached") || msg.includes("Debugger is not attached") || msg.includes("Target closed")) { - attached.delete(tabId); - } - throw e; - } -} -const evaluateAsync = evaluate; + try { + await ensureAttached(tabId, aggressiveRetry); + const result = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true + }, timeoutMs); + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result.result?.value; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("Detached") || msg.includes("Debugger is not attached") || msg.includes("Target closed")) attached.delete(tabId); + throw e; + } +} +var evaluateAsync = evaluate; +/** +* Capture a screenshot via CDP Page.captureScreenshot. +* Returns base64-encoded image data. +*/ async function screenshot(tabId, options = {}) { - await ensureAttached(tabId); - const format = options.format ?? "png"; - const fullPage = options.fullPage === true; - const overrideWidth = options.width && options.width > 0 ? Math.ceil(options.width) : void 0; - const overrideHeight = !fullPage && options.height && options.height > 0 ? Math.ceil(options.height) : void 0; - const needsOverride = fullPage || overrideWidth !== void 0 || overrideHeight !== void 0; - if (needsOverride) { - if (overrideWidth !== void 0 && fullPage) { - await sendDebuggerCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { - mobile: false, - width: overrideWidth, - height: 0, - deviceScaleFactor: 1 - }); - } - let finalWidth = overrideWidth ?? 0; - let finalHeight = overrideHeight ?? 0; - if (fullPage) { - const metrics = await sendDebuggerCommand({ tabId }, "Page.getLayoutMetrics"); - const size = metrics.cssContentSize || metrics.contentSize; - if (size) { - if (finalWidth === 0) finalWidth = Math.ceil(size.width); - finalHeight = Math.ceil(size.height); - } - } - await sendDebuggerCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { - mobile: false, - width: finalWidth, - height: finalHeight, - deviceScaleFactor: 1 - }); - } - try { - const params = { format }; - if (format === "jpeg" && options.quality !== void 0) { - params.quality = Math.max(0, Math.min(100, options.quality)); - } - const result = await sendDebuggerCommand({ tabId }, "Page.captureScreenshot", params); - return result.data; - } finally { - if (needsOverride) { - await sendDebuggerCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => { - }); - } - } -} + await ensureAttached(tabId); + const format = options.format ?? "png"; + const fullPage = options.fullPage === true; + const overrideWidth = options.width && options.width > 0 ? Math.ceil(options.width) : void 0; + const overrideHeight = !fullPage && options.height && options.height > 0 ? Math.ceil(options.height) : void 0; + const needsOverride = fullPage || overrideWidth !== void 0 || overrideHeight !== void 0; + if (needsOverride) { + if (overrideWidth !== void 0 && fullPage) await sendDebuggerCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + mobile: false, + width: overrideWidth, + height: 0, + deviceScaleFactor: 1 + }); + let finalWidth = overrideWidth ?? 0; + let finalHeight = overrideHeight ?? 0; + if (fullPage) { + const metrics = await sendDebuggerCommand({ tabId }, "Page.getLayoutMetrics"); + const size = metrics.cssContentSize || metrics.contentSize; + if (size) { + if (finalWidth === 0) finalWidth = Math.ceil(size.width); + finalHeight = Math.ceil(size.height); + } + } + await sendDebuggerCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + mobile: false, + width: finalWidth, + height: finalHeight, + deviceScaleFactor: 1 + }); + } + try { + const params = { format }; + if (format === "jpeg" && options.quality !== void 0) params.quality = Math.max(0, Math.min(100, options.quality)); + return (await sendDebuggerCommand({ tabId }, "Page.captureScreenshot", params)).data; + } finally { + if (needsOverride) await sendDebuggerCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {}); + } +} +/** +* Set local file paths on a file input element via CDP DOM.setFileInputFiles. +* This bypasses the need to send large base64 payloads through the message channel — +* Chrome reads the files directly from the local filesystem. +* +* @param tabId - Target tab ID +* @param files - Array of absolute local file paths +* @param selector - CSS selector to find the file input (optional, defaults to first file input) +*/ async function setFileInputFiles(tabId, files, selector) { - await ensureAttached(tabId); - await sendDebuggerCommand({ tabId }, "DOM.enable"); - await sendDebuggerCommand({ tabId }, "Page.enable"); - const query = selector || 'input[type="file"]'; - const found = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", { - expression: `!!document.querySelector(${JSON.stringify(query)})`, - returnByValue: true - }); - if (!found.result?.value) { - throw new Error(`No element found matching selector: ${query}`); - } - await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: true }); - try { - const backendNodeId = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error("Page.fileChooserOpened not received within 5s — the input may not have opened a file chooser")); - }, 5e3); - const listener = (source, method, params) => { - if (source.tabId !== tabId || method !== "Page.fileChooserOpened") return; - cleanup(); - const backend = params?.backendNodeId; - if (typeof backend === "number") resolve(backend); - else reject(new Error("Page.fileChooserOpened carried no backendNodeId")); - }; - const cleanup = () => { - clearTimeout(timer); - chrome.debugger.onEvent.removeListener(listener); - }; - chrome.debugger.onEvent.addListener(listener); - void sendDebuggerCommand({ tabId }, "Runtime.evaluate", { - expression: `document.querySelector(${JSON.stringify(query)}).click()` - }).catch((err) => { - cleanup(); - reject(err instanceof Error ? err : new Error(String(err))); - }); - }); - await sendDebuggerCommand({ tabId }, "DOM.setFileInputFiles", { - files, - backendNodeId - }); - } finally { - await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => { - }); - } + await ensureAttached(tabId); + await sendDebuggerCommand({ tabId }, "DOM.enable"); + await sendDebuggerCommand({ tabId }, "Page.enable"); + const query = selector || "input[type=\"file\"]"; + if (!(await sendDebuggerCommand({ tabId }, "Runtime.evaluate", { + expression: `!!document.querySelector(${JSON.stringify(query)})`, + returnByValue: true + })).result?.value) throw new Error(`No element found matching selector: ${query}`); + await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: true }); + try { + const backendNodeId = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(/* @__PURE__ */ new Error("Page.fileChooserOpened not received within 5s — the input may not have opened a file chooser")); + }, 5e3); + const listener = (source, method, params) => { + if (source.tabId !== tabId || method !== "Page.fileChooserOpened") return; + cleanup(); + const backend = params?.backendNodeId; + if (typeof backend === "number") resolve(backend); + else reject(/* @__PURE__ */ new Error("Page.fileChooserOpened carried no backendNodeId")); + }; + const cleanup = () => { + clearTimeout(timer); + chrome.debugger.onEvent.removeListener(listener); + }; + chrome.debugger.onEvent.addListener(listener); + sendDebuggerCommand({ tabId }, "Runtime.evaluate", { expression: `document.querySelector(${JSON.stringify(query)}).click()` }).catch((err) => { + cleanup(); + reject(err instanceof Error ? err : new Error(String(err))); + }); + }); + await sendDebuggerCommand({ tabId }, "DOM.setFileInputFiles", { + files, + backendNodeId + }); + } finally { + await sendDebuggerCommand({ tabId }, "Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => {}); + } } function matchesDownloadPattern(item, pattern) { - if (!pattern) return true; - const haystack = [ - item.filename, - item.url, - item.finalUrl, - item.mime - ].filter(Boolean).join("\n").toLowerCase(); - return haystack.includes(pattern.toLowerCase()); + if (!pattern) return true; + return [ + item.filename, + item.url, + item.finalUrl, + item.mime + ].filter(Boolean).join("\n").toLowerCase().includes(pattern.toLowerCase()); } function downloadResult(item, startedAt) { - return { - downloaded: item.state === "complete", - id: item.id, - filename: item.filename, - url: item.url, - finalUrl: item.finalUrl, - mime: item.mime, - totalBytes: item.totalBytes, - state: item.state, - danger: item.danger, - error: item.error, - elapsedMs: Date.now() - startedAt - }; + return { + downloaded: item.state === "complete", + id: item.id, + filename: item.filename, + url: item.url, + finalUrl: item.finalUrl, + mime: item.mime, + totalBytes: item.totalBytes, + state: item.state, + danger: item.danger, + error: item.error, + elapsedMs: Date.now() - startedAt + }; } async function waitForDownload(pattern = "", timeoutMs = 3e4) { - const startedAt = Date.now(); - const timeout = Math.max(1, timeoutMs); - return await new Promise((resolve) => { - let done = false; - const inProgressIds = /* @__PURE__ */ new Set(); - const finish = (result) => { - if (done) return; - done = true; - clearTimeout(timer); - chrome.downloads.onCreated.removeListener(onCreated); - chrome.downloads.onChanged.removeListener(onChanged); - resolve(result); - }; - const inspectById = async (id) => { - const items = await chrome.downloads.search({ id }); - const item = items[0]; - if (!item || !matchesDownloadPattern(item, pattern)) return; - inProgressIds.add(id); - if (item.state === "complete" || item.state === "interrupted") finish(downloadResult(item, startedAt)); - }; - const onCreated = (item) => { - if (!matchesDownloadPattern(item, pattern)) return; - inProgressIds.add(item.id); - if (item.state === "complete" || item.state === "interrupted") finish(downloadResult(item, startedAt)); - }; - const onChanged = (delta) => { - if (!delta.id) return; - if (!inProgressIds.has(delta.id) && !delta.filename && !delta.url) return; - if (delta.filename?.current || delta.url?.current) { - void inspectById(delta.id); - return; - } - if (delta.state?.current === "complete" || delta.state?.current === "interrupted") { - void inspectById(delta.id); - } - }; - const timer = setTimeout(() => { - finish({ - downloaded: false, - state: "interrupted", - error: `No download matched "${pattern || "*"}" within ${timeout}ms`, - elapsedMs: Date.now() - startedAt - }); - }, timeout); - chrome.downloads.onCreated.addListener(onCreated); - chrome.downloads.onChanged.addListener(onChanged); - void chrome.downloads.search({ - limit: 50, - orderBy: ["-startTime"], - startedAfter: new Date(startedAt - Math.max(timeout, 1e3)).toISOString() - }).then((recent) => { - if (done) return; - const completed = recent.find((item) => item.state === "complete" && matchesDownloadPattern(item, pattern)); - if (completed) { - finish(downloadResult(completed, startedAt)); - return; - } - for (const item of recent) { - if (item.state === "in_progress" && matchesDownloadPattern(item, pattern)) inProgressIds.add(item.id); - } - }).catch((err) => { - finish({ - downloaded: false, - state: "interrupted", - error: err instanceof Error ? err.message : String(err), - elapsedMs: Date.now() - startedAt - }); - }); - }); + const startedAt = Date.now(); + const timeout = Math.max(1, timeoutMs); + return await new Promise((resolve) => { + let done = false; + const inProgressIds = /* @__PURE__ */ new Set(); + const finish = (result) => { + if (done) return; + done = true; + clearTimeout(timer); + chrome.downloads.onCreated.removeListener(onCreated); + chrome.downloads.onChanged.removeListener(onChanged); + resolve(result); + }; + const inspectById = async (id) => { + const item = (await chrome.downloads.search({ id }))[0]; + if (!item || !matchesDownloadPattern(item, pattern)) return; + inProgressIds.add(id); + if (item.state === "complete" || item.state === "interrupted") finish(downloadResult(item, startedAt)); + }; + const onCreated = (item) => { + if (!matchesDownloadPattern(item, pattern)) return; + inProgressIds.add(item.id); + if (item.state === "complete" || item.state === "interrupted") finish(downloadResult(item, startedAt)); + }; + const onChanged = (delta) => { + if (!delta.id) return; + if (!inProgressIds.has(delta.id) && !delta.filename && !delta.url) return; + if (delta.filename?.current || delta.url?.current) { + inspectById(delta.id); + return; + } + if (delta.state?.current === "complete" || delta.state?.current === "interrupted") inspectById(delta.id); + }; + const timer = setTimeout(() => { + finish({ + downloaded: false, + state: "interrupted", + error: `No download matched "${pattern || "*"}" within ${timeout}ms`, + elapsedMs: Date.now() - startedAt + }); + }, timeout); + chrome.downloads.onCreated.addListener(onCreated); + chrome.downloads.onChanged.addListener(onChanged); + chrome.downloads.search({ + limit: 50, + orderBy: ["-startTime"], + startedAfter: new Date(startedAt - Math.max(timeout, 1e3)).toISOString() + }).then((recent) => { + if (done) return; + const completed = recent.find((item) => item.state === "complete" && matchesDownloadPattern(item, pattern)); + if (completed) { + finish(downloadResult(completed, startedAt)); + return; + } + for (const item of recent) if (item.state === "in_progress" && matchesDownloadPattern(item, pattern)) inProgressIds.add(item.id); + }).catch((err) => { + finish({ + downloaded: false, + state: "interrupted", + error: err instanceof Error ? err.message : String(err), + elapsedMs: Date.now() - startedAt + }); + }); + }); } function frameTargetKey(tabId, frameId) { - return `${tabId}:${frameId}`; + return `${tabId}:${frameId}`; } function registerFrameTargetCleanup() { - if (frameTargetCleanupRegistered) return; - frameTargetCleanupRegistered = true; - chrome.debugger.onEvent.addListener((_source, method, params) => { - if (method === "Target.detachedFromTarget") { - const targetId = String(params?.targetId || ""); - clearFrameTarget(targetId); - } - }); + if (frameTargetCleanupRegistered) return; + frameTargetCleanupRegistered = true; + chrome.debugger.onEvent.addListener((_source, method, params) => { + if (method === "Target.detachedFromTarget") clearFrameTarget(String(params?.targetId || "")); + }); } function clearFrameTarget(targetId) { - if (!targetId) return; - const key = frameTargetKeys.get(targetId); - if (key) frameTargets.delete(key); - frameTargetKeys.delete(targetId); + if (!targetId) return; + const key = frameTargetKeys.get(targetId); + if (key) frameTargets.delete(key); + frameTargetKeys.delete(targetId); } async function ensureFrameTarget(tabId, frameId, aggressiveRetry = false, targetUrl) { - registerFrameTargetCleanup(); - await ensureAttached(tabId, aggressiveRetry); - const key = frameTargetKey(tabId, frameId); - const existing = frameTargets.get(key); - if (existing) return existing; - await sendDebuggerCommand({ tabId }, "Target.setDiscoverTargets", { discover: true }).catch(() => { - }); - await sendDebuggerCommand({ tabId }, "Target.setAutoAttach", { - autoAttach: true, - waitForDebuggerOnStart: false, - flatten: true, - filter: [{ type: "iframe", exclude: false }] - }).catch(() => { - }); - const targetId = await resolveFrameTargetId(tabId, frameId, targetUrl); - try { - await chrome.debugger.attach({ targetId }, "1.3"); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (!message.includes("Another debugger is already attached")) throw err; - } - frameTargets.set(key, targetId); - frameTargetKeys.set(targetId, key); - return targetId; + registerFrameTargetCleanup(); + await ensureAttached(tabId, aggressiveRetry); + const key = frameTargetKey(tabId, frameId); + const existing = frameTargets.get(key); + if (existing) return existing; + await sendDebuggerCommand({ tabId }, "Target.setDiscoverTargets", { discover: true }).catch(() => {}); + await sendDebuggerCommand({ tabId }, "Target.setAutoAttach", { + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, + filter: [{ + type: "iframe", + exclude: false + }] + }).catch(() => {}); + const targetId = await resolveFrameTargetId(tabId, frameId, targetUrl); + try { + await chrome.debugger.attach({ targetId }, "1.3"); + } catch (err) { + if (!(err instanceof Error ? err.message : String(err)).includes("Another debugger is already attached")) throw err; + } + frameTargets.set(key, targetId); + frameTargetKeys.set(targetId, key); + return targetId; } async function resolveFrameTargetId(tabId, frameId, targetUrl) { - const result = await sendDebuggerCommand({ tabId }, "Target.getTargets").catch(() => null); - const targets = result?.targetInfos ?? []; - const frameTarget = targets.find((candidate) => { - const candidateId = candidate.targetId || candidate.id; - return candidate.type === "iframe" && (candidateId === frameId || !!targetUrl && candidate.url === targetUrl); - }); - const targetId = frameTarget?.targetId || frameTarget?.id; - if (targetId) return targetId; - const candidates = targets.filter((target) => target.type === "iframe").map((target) => `${target.targetId || target.id || "?"} ${target.url || ""}`).join("; "); - throw new Error(`No iframe target found for frame ${frameId}${targetUrl ? ` (${targetUrl})` : ""}. Candidates: ${candidates || "none"}`); + const targets = (await sendDebuggerCommand({ tabId }, "Target.getTargets").catch(() => null))?.targetInfos ?? []; + const frameTarget = targets.find((candidate) => { + const candidateId = candidate.targetId || candidate.id; + return candidate.type === "iframe" && (candidateId === frameId || !!targetUrl && candidate.url === targetUrl); + }); + const targetId = frameTarget?.targetId || frameTarget?.id; + if (targetId) return targetId; + const candidates = targets.filter((target) => target.type === "iframe").map((target) => `${target.targetId || target.id || "?"} ${target.url || ""}`).join("; "); + throw new Error(`No iframe target found for frame ${frameId}${targetUrl ? ` (${targetUrl})` : ""}. Candidates: ${candidates || "none"}`); } async function sendCommandInFrameTarget(tabId, frameId, method, params = {}, aggressiveRetry = false, timeoutMs = CDP_COMMAND_TIMEOUT_MS, targetUrl) { - const targetId = await ensureFrameTarget(tabId, frameId, aggressiveRetry, targetUrl); - const target = { targetId }; - return sendDebuggerCommand(target, method, params, timeoutMs); + return sendDebuggerCommand({ targetId: await ensureFrameTarget(tabId, frameId, aggressiveRetry, targetUrl) }, method, params, timeoutMs); } async function insertText(tabId, text) { - await ensureAttached(tabId); - await sendDebuggerCommand({ tabId }, "Input.insertText", { text }); + await ensureAttached(tabId); + await sendDebuggerCommand({ tabId }, "Input.insertText", { text }); } function registerFrameTracking() { - registerFrameTargetCleanup(); - chrome.debugger.onEvent.addListener((source, method, params) => { - const tabId = source.tabId; - if (!tabId) return; - if (method === "Runtime.executionContextCreated") { - const context = params.context; - if (!context?.auxData?.frameId || context.auxData.isDefault !== true) return; - const frameId = context.auxData.frameId; - if (!tabFrameContexts.has(tabId)) { - tabFrameContexts.set(tabId, /* @__PURE__ */ new Map()); - } - tabFrameContexts.get(tabId).set(frameId, context.id); - } - if (method === "Runtime.executionContextDestroyed") { - const ctxId = params.executionContextId; - const contexts = tabFrameContexts.get(tabId); - if (contexts) { - for (const [fid, cid] of contexts) { - if (cid === ctxId) { - contexts.delete(fid); - break; - } - } - } - } - if (method === "Runtime.executionContextsCleared") { - tabFrameContexts.delete(tabId); - } - }); - chrome.tabs.onRemoved.addListener((tabId) => { - tabFrameContexts.delete(tabId); - }); + registerFrameTargetCleanup(); + chrome.debugger.onEvent.addListener((source, method, params) => { + const tabId = source.tabId; + if (!tabId) return; + if (method === "Runtime.executionContextCreated") { + const context = params.context; + if (!context?.auxData?.frameId || context.auxData.isDefault !== true) return; + const frameId = context.auxData.frameId; + if (!tabFrameContexts.has(tabId)) tabFrameContexts.set(tabId, /* @__PURE__ */ new Map()); + tabFrameContexts.get(tabId).set(frameId, context.id); + } + if (method === "Runtime.executionContextDestroyed") { + const ctxId = params.executionContextId; + const contexts = tabFrameContexts.get(tabId); + if (contexts) { + for (const [fid, cid] of contexts) if (cid === ctxId) { + contexts.delete(fid); + break; + } + } + } + if (method === "Runtime.executionContextsCleared") tabFrameContexts.delete(tabId); + }); + chrome.tabs.onRemoved.addListener((tabId) => { + tabFrameContexts.delete(tabId); + }); } async function getFrameTree(tabId) { - await ensureAttached(tabId); - return sendDebuggerCommand({ tabId }, "Page.getFrameTree"); + await ensureAttached(tabId); + return sendDebuggerCommand({ tabId }, "Page.getFrameTree"); } async function evaluateInFrame(tabId, expression, frameId, aggressiveRetry = false, timeoutMs = CDP_COMMAND_TIMEOUT_MS) { - await ensureAttached(tabId, aggressiveRetry); - await sendDebuggerCommand({ tabId }, "Runtime.enable").catch(() => { - }); - const contexts = tabFrameContexts.get(tabId); - const contextId = contexts?.get(frameId); - if (contextId !== void 0) { - try { - const result2 = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", { - expression, - contextId, - returnByValue: true, - awaitPromise: true - }, timeoutMs); - if (result2.exceptionDetails) { - const errMsg = result2.exceptionDetails.exception?.description || result2.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); - } - return result2.result?.value; - } catch (err) { - const msg = String(err?.message || err); - if (!/Cannot find context|context with specified id|Execution context was destroyed/i.test(msg)) { - throw err; - } - contexts?.delete(frameId); - } - } - await sendCommandInFrameTarget(tabId, frameId, "Runtime.enable", {}, aggressiveRetry, timeoutMs).catch(() => void 0); - const result = await sendCommandInFrameTarget(tabId, frameId, "Runtime.evaluate", { - expression, - returnByValue: true, - awaitPromise: true - }, aggressiveRetry, timeoutMs); - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); - } - return result.result?.value; + await ensureAttached(tabId, aggressiveRetry); + await sendDebuggerCommand({ tabId }, "Runtime.enable").catch(() => {}); + const contexts = tabFrameContexts.get(tabId); + const contextId = contexts?.get(frameId); + if (contextId !== void 0) try { + const result = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", { + expression, + contextId, + returnByValue: true, + awaitPromise: true + }, timeoutMs); + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result.result?.value; + } catch (err) { + const msg = String(err?.message || err); + if (!/Cannot find context|context with specified id|Execution context was destroyed/i.test(msg)) throw err; + contexts?.delete(frameId); + } + await sendCommandInFrameTarget(tabId, frameId, "Runtime.enable", {}, aggressiveRetry, timeoutMs).catch(() => void 0); + const result = await sendCommandInFrameTarget(tabId, frameId, "Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true + }, aggressiveRetry, timeoutMs); + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result.result?.value; } function normalizeCapturePatterns(pattern) { - return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean); + return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean); } function shouldCaptureUrl(url, patterns) { - if (!url) return false; - if (!patterns.length) return true; - return patterns.some((pattern) => url.includes(pattern)); + if (!url) return false; + if (!patterns.length) return true; + return patterns.some((pattern) => url.includes(pattern)); } function normalizeHeaders(headers) { - if (!headers || typeof headers !== "object") return {}; - const out = {}; - for (const [key, value] of Object.entries(headers)) { - out[String(key)] = String(value); - } - return out; + if (!headers || typeof headers !== "object") return {}; + const out = {}; + for (const [key, value] of Object.entries(headers)) out[String(key)] = String(value); + return out; } function getOrCreateNetworkCaptureEntry(tabId, requestId, fallback) { - const state = networkCaptures.get(tabId); - if (!state) return null; - const existingIndex = state.requestToIndex.get(requestId); - if (existingIndex !== void 0) { - return state.entries[existingIndex] || null; - } - const url = fallback?.url || ""; - if (!shouldCaptureUrl(url, state.patterns)) return null; - const entry = { - kind: "cdp", - url, - method: fallback?.method || "GET", - requestHeaders: fallback?.requestHeaders || {}, - timestamp: Date.now() - }; - state.entries.push(entry); - state.requestToIndex.set(requestId, state.entries.length - 1); - return entry; + const state = networkCaptures.get(tabId); + if (!state) return null; + const existingIndex = state.requestToIndex.get(requestId); + if (existingIndex !== void 0) return state.entries[existingIndex] || null; + const url = fallback?.url || ""; + if (!shouldCaptureUrl(url, state.patterns)) return null; + const entry = { + kind: "cdp", + url, + method: fallback?.method || "GET", + requestHeaders: fallback?.requestHeaders || {}, + timestamp: Date.now() + }; + state.entries.push(entry); + state.requestToIndex.set(requestId, state.entries.length - 1); + return entry; } async function startNetworkCapture(tabId, pattern) { - await ensureAttached(tabId); - await sendDebuggerCommand({ tabId }, "Network.enable"); - networkCaptures.set(tabId, { - patterns: normalizeCapturePatterns(pattern), - entries: [], - requestToIndex: /* @__PURE__ */ new Map() - }); + await ensureAttached(tabId); + await sendDebuggerCommand({ tabId }, "Network.enable"); + networkCaptures.set(tabId, { + patterns: normalizeCapturePatterns(pattern), + entries: [], + requestToIndex: /* @__PURE__ */ new Map() + }); } async function readNetworkCapture(tabId) { - const state = networkCaptures.get(tabId); - if (!state) return []; - const entries = state.entries.slice(); - state.entries = []; - state.requestToIndex.clear(); - return entries; + const state = networkCaptures.get(tabId); + if (!state) return []; + const entries = state.entries.slice(); + state.entries = []; + state.requestToIndex.clear(); + return entries; } function hasActiveNetworkCapture(tabId) { - return networkCaptures.has(tabId); + return networkCaptures.has(tabId); } function clearFrameTargetsForTab(tabId) { - for (const [key, targetId] of [...frameTargets.entries()]) { - if (!key.startsWith(`${tabId}:`)) continue; - frameTargets.delete(key); - frameTargetKeys.delete(targetId); - chrome.debugger.detach({ targetId }).catch(() => { - }); - } + for (const [key, targetId] of [...frameTargets.entries()]) { + if (!key.startsWith(`${tabId}:`)) continue; + frameTargets.delete(key); + frameTargetKeys.delete(targetId); + chrome.debugger.detach({ targetId }).catch(() => {}); + } } async function detach(tabId) { - clearFrameTargetsForTab(tabId); - if (!attached.has(tabId)) return; - attached.delete(tabId); - networkCaptures.delete(tabId); - tabFrameContexts.delete(tabId); - try { - await chrome.debugger.detach({ tabId }); - } catch { - } + clearFrameTargetsForTab(tabId); + if (!attached.has(tabId)) return; + attached.delete(tabId); + networkCaptures.delete(tabId); + tabFrameContexts.delete(tabId); + try { + await chrome.debugger.detach({ tabId }); + } catch {} } function registerListeners() { - chrome.tabs.onRemoved.addListener((tabId) => { - attached.delete(tabId); - networkCaptures.delete(tabId); - tabFrameContexts.delete(tabId); - clearFrameTargetsForTab(tabId); - }); - chrome.debugger.onDetach.addListener((source) => { - if (source.tabId) { - attached.delete(source.tabId); - networkCaptures.delete(source.tabId); - tabFrameContexts.delete(source.tabId); - clearFrameTargetsForTab(source.tabId); - return; - } - if (source.targetId) clearFrameTarget(source.targetId); - }); - chrome.tabs.onUpdated.addListener(async (tabId, info) => { - if (info.url && !isDebuggableUrl$1(info.url)) { - await detach(tabId); - } - }); - chrome.debugger.onEvent.addListener(async (source, method, params) => { - const tabId = source.tabId; - if (!tabId) return; - const state = networkCaptures.get(tabId); - if (!state) return; - const eventParams = params; - if (method === "Network.requestWillBeSent") { - const requestId = String(eventParams?.requestId || ""); - const request = eventParams?.request; - const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { - url: request?.url, - method: request?.method, - requestHeaders: normalizeHeaders(request?.headers) - }); - if (!entry) return; - if (!eventParams?.redirectResponse) { - entry.requestBodyKind = request?.hasPostData ? "string" : "empty"; - { - const raw = String(request?.postData || ""); - const fullSize = raw.length; - const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; - entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; - entry.requestBodyFullSize = fullSize; - entry.requestBodyTruncated = truncated; - } - try { - const postData = await sendDebuggerCommand({ tabId }, "Network.getRequestPostData", { requestId }); - if (postData?.postData) { - const raw = postData.postData; - const fullSize = raw.length; - const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; - entry.requestBodyKind = "string"; - entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; - entry.requestBodyFullSize = fullSize; - entry.requestBodyTruncated = truncated; - } - } catch { - } - } - return; - } - if (method === "Network.responseReceived") { - const requestId = String(eventParams?.requestId || ""); - const response = eventParams?.response; - const stateEntryIndex = state.requestToIndex.get(requestId); - if (stateEntryIndex === void 0) return; - const entry = state.entries[stateEntryIndex]; - if (!entry) return; - entry.responseStatus = response?.status; - entry.responseContentType = response?.mimeType || ""; - entry.responseHeaders = normalizeHeaders(response?.headers); - return; - } - if (method === "Network.loadingFinished") { - const requestId = String(eventParams?.requestId || ""); - const stateEntryIndex = state.requestToIndex.get(requestId); - if (stateEntryIndex === void 0) return; - const entry = state.entries[stateEntryIndex]; - if (!entry) return; - try { - const body = await sendDebuggerCommand({ tabId }, "Network.getResponseBody", { requestId }); - if (typeof body?.body === "string") { - const fullSize = body.body.length; - const truncated = fullSize > CDP_RESPONSE_BODY_CAPTURE_LIMIT; - const stored = truncated ? body.body.slice(0, CDP_RESPONSE_BODY_CAPTURE_LIMIT) : body.body; - entry.responsePreview = body.base64Encoded ? `base64:${stored}` : stored; - entry.responseBodyFullSize = fullSize; - entry.responseBodyTruncated = truncated; - } - } catch { - } - } - }); -} - -const targetToTab = /* @__PURE__ */ new Map(); -const tabToTarget = /* @__PURE__ */ new Map(); + chrome.tabs.onRemoved.addListener((tabId) => { + attached.delete(tabId); + networkCaptures.delete(tabId); + tabFrameContexts.delete(tabId); + clearFrameTargetsForTab(tabId); + }); + chrome.debugger.onDetach.addListener((source) => { + if (source.tabId) { + attached.delete(source.tabId); + networkCaptures.delete(source.tabId); + tabFrameContexts.delete(source.tabId); + clearFrameTargetsForTab(source.tabId); + return; + } + if (source.targetId) clearFrameTarget(source.targetId); + }); + chrome.tabs.onUpdated.addListener(async (tabId, info) => { + if (info.url && !isDebuggableUrl$1(info.url)) await detach(tabId); + }); + chrome.debugger.onEvent.addListener(async (source, method, params) => { + const tabId = source.tabId; + if (!tabId) return; + const state = networkCaptures.get(tabId); + if (!state) return; + const eventParams = params; + if (method === "Network.requestWillBeSent") { + const requestId = String(eventParams?.requestId || ""); + const request = eventParams?.request; + const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { + url: request?.url, + method: request?.method, + requestHeaders: normalizeHeaders(request?.headers) + }); + if (!entry) return; + if (!eventParams?.redirectResponse) { + entry.requestBodyKind = request?.hasPostData ? "string" : "empty"; + { + const raw = String(request?.postData || ""); + const fullSize = raw.length; + const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; + entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; + entry.requestBodyFullSize = fullSize; + entry.requestBodyTruncated = truncated; + } + try { + const postData = await sendDebuggerCommand({ tabId }, "Network.getRequestPostData", { requestId }); + if (postData?.postData) { + const raw = postData.postData; + const fullSize = raw.length; + const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; + entry.requestBodyKind = "string"; + entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; + entry.requestBodyFullSize = fullSize; + entry.requestBodyTruncated = truncated; + } + } catch {} + } + return; + } + if (method === "Network.responseReceived") { + const requestId = String(eventParams?.requestId || ""); + const response = eventParams?.response; + const stateEntryIndex = state.requestToIndex.get(requestId); + if (stateEntryIndex === void 0) return; + const entry = state.entries[stateEntryIndex]; + if (!entry) return; + entry.responseStatus = response?.status; + entry.responseContentType = response?.mimeType || ""; + entry.responseHeaders = normalizeHeaders(response?.headers); + return; + } + if (method === "Network.loadingFinished") { + const requestId = String(eventParams?.requestId || ""); + const stateEntryIndex = state.requestToIndex.get(requestId); + if (stateEntryIndex === void 0) return; + const entry = state.entries[stateEntryIndex]; + if (!entry) return; + try { + const body = await sendDebuggerCommand({ tabId }, "Network.getResponseBody", { requestId }); + if (typeof body?.body === "string") { + const fullSize = body.body.length; + const truncated = fullSize > CDP_RESPONSE_BODY_CAPTURE_LIMIT; + const stored = truncated ? body.body.slice(0, CDP_RESPONSE_BODY_CAPTURE_LIMIT) : body.body; + entry.responsePreview = body.base64Encoded ? `base64:${stored}` : stored; + entry.responseBodyFullSize = fullSize; + entry.responseBodyTruncated = truncated; + } + } catch {} + } + }); +} +//#endregion +//#region src/identity.ts +/** +* Page identity mapping — targetId ↔ tabId. +* +* targetId is the cross-layer page identity (CDP target UUID). +* tabId is an internal Chrome Tabs API routing detail — never exposed outside the extension. +* +* Lifecycle: +* - Cache populated lazily via chrome.debugger.getTargets() +* - Evicted on tab close (chrome.tabs.onRemoved) +* - Miss triggers full refresh; refresh miss → hard error (no guessing) +*/ +var targetToTab = /* @__PURE__ */ new Map(); +var tabToTarget = /* @__PURE__ */ new Map(); +/** +* Resolve targetId for a given tabId. +* Returns cached value if available; on miss, refreshes from chrome.debugger.getTargets(). +* Throws if no targetId can be found (page may have been destroyed). +*/ async function resolveTargetId(tabId) { - const cached = tabToTarget.get(tabId); - if (cached) return cached; - await refreshMappings(); - const result = tabToTarget.get(tabId); - if (!result) throw new Error(`No targetId for tab ${tabId} — page may have been closed`); - return result; -} + const cached = tabToTarget.get(tabId); + if (cached) return cached; + await refreshMappings(); + const result = tabToTarget.get(tabId); + if (!result) throw new Error(`No targetId for tab ${tabId} — page may have been closed`); + return result; +} +/** +* Resolve tabId for a given targetId. +* Returns cached value if available; on miss, refreshes from chrome.debugger.getTargets(). +* Throws if no tabId can be found — never falls back to guessing. +*/ async function resolveTabId$1(targetId) { - const cached = targetToTab.get(targetId); - if (cached !== void 0) return cached; - await refreshMappings(); - const result = targetToTab.get(targetId); - if (result === void 0) throw new Error(`Page not found: ${targetId} — stale page identity`); - return result; -} + const cached = targetToTab.get(targetId); + if (cached !== void 0) return cached; + await refreshMappings(); + const result = targetToTab.get(targetId); + if (result === void 0) throw new Error(`Page not found: ${targetId} — stale page identity`); + return result; +} +/** +* Remove mappings for a closed tab. +* Called from chrome.tabs.onRemoved listener. +*/ function evictTab(tabId) { - const targetId = tabToTarget.get(tabId); - if (targetId) targetToTab.delete(targetId); - tabToTarget.delete(tabId); + const targetId = tabToTarget.get(tabId); + if (targetId) targetToTab.delete(targetId); + tabToTarget.delete(tabId); } +/** +* Full refresh of targetId ↔ tabId mappings from chrome.debugger.getTargets(). +*/ async function refreshMappings() { - const targets = await chrome.debugger.getTargets(); - targetToTab.clear(); - tabToTarget.clear(); - for (const t of targets) { - if (t.type === "page" && t.tabId !== void 0) { - targetToTab.set(t.id, t.tabId); - tabToTarget.set(t.tabId, t.id); - } - } -} - -const JOURNAL_KEY = "opencli_command_journal_v1"; -const JOURNAL_MAX_ENTRIES = 64; -const JOURNAL_RESULT_MAX_BYTES = 64 * 1024; -let cache = null; -let writeQueue = Promise.resolve(); -const inFlight = /* @__PURE__ */ new Map(); + const targets = await chrome.debugger.getTargets(); + targetToTab.clear(); + tabToTarget.clear(); + for (const t of targets) if (t.type === "page" && t.tabId !== void 0) { + targetToTab.set(t.id, t.tabId); + tabToTarget.set(t.tabId, t.id); + } +} +//#endregion +//#region src/journal.ts +var JOURNAL_KEY = "opencli_command_journal_v1"; +var JOURNAL_MAX_ENTRIES = 64; +/** Results larger than this are not recorded; a replayed id re-fails honestly. */ +var JOURNAL_RESULT_MAX_BYTES = 64 * 1024; +var cache = null; +var writeQueue = Promise.resolve(); +var inFlight = /* @__PURE__ */ new Map(); async function load() { - if (cache) return cache; - try { - const stored = await chrome.storage.session.get(JOURNAL_KEY); - cache = stored?.[JOURNAL_KEY] ?? {}; - } catch { - cache = {}; - } - return cache; + if (cache) return cache; + try { + cache = (await chrome.storage.session.get(JOURNAL_KEY))?.[JOURNAL_KEY] ?? {}; + } catch { + cache = {}; + } + return cache; } function persist() { - const snapshot = cache; - if (!snapshot) return; - writeQueue = writeQueue.then(async () => { - try { - await chrome.storage.session.set({ [JOURNAL_KEY]: snapshot }); - } catch { - } - }); + const snapshot = cache; + if (!snapshot) return; + writeQueue = writeQueue.then(async () => { + try { + await chrome.storage.session.set({ [JOURNAL_KEY]: snapshot }); + } catch {} + }); } function trim(journal) { - const ids = Object.keys(journal); - if (ids.length <= JOURNAL_MAX_ENTRIES) return; - ids.sort((a, b) => journal[a].ts - journal[b].ts); - for (const id of ids.slice(0, ids.length - JOURNAL_MAX_ENTRIES)) delete journal[id]; + const ids = Object.keys(journal); + if (ids.length <= JOURNAL_MAX_ENTRIES) return; + ids.sort((a, b) => journal[a].ts - journal[b].ts); + for (const id of ids.slice(0, ids.length - JOURNAL_MAX_ENTRIES)) delete journal[id]; } function resultByteLength(result) { - try { - return JSON.stringify(result).length; - } catch { - return Number.POSITIVE_INFINITY; - } -} -const UNKNOWN_OUTCOME_HINT = "Inspect the browser/session state before retrying. Do not blindly re-run write commands such as navigate, click, type, or eval."; + try { + return JSON.stringify(result).length; + } catch { + return Number.POSITIVE_INFINITY; + } +} +var UNKNOWN_OUTCOME_HINT = "Inspect the browser/session state before retrying. Do not blindly re-run write commands such as navigate, click, type, or eval."; +/** +* Execute a command exactly once per id. Duplicate deliveries of the same id +* (transport retries after a dropped connection) replay the recorded outcome. +*/ async function executeWithJournal(cmd, execute) { - const id = cmd.id; - if (!id) return execute(cmd); - const running = inFlight.get(id); - if (running) return running; - const run = (async () => { - const journal = await load(); - const entry = journal[id]; - if (entry?.status === "done") { - if (entry.result) return entry.result; - return { - id, - ok: false, - errorCode: "result_evicted", - error: "Command already executed, but its result was too large to record for replay.", - errorHint: UNKNOWN_OUTCOME_HINT - }; - } - if (entry?.status === "started") { - return { - id, - ok: false, - errorCode: "command_lost", - error: "Command was interrupted mid-execution (extension or browser restarted); it may or may not have applied.", - errorHint: UNKNOWN_OUTCOME_HINT - }; - } - journal[id] = { status: "started", ts: Date.now() }; - trim(journal); - persist(); - let result; - try { - result = await execute(cmd); - } catch (err) { - result = { id, ok: false, error: err instanceof Error ? err.message : String(err) }; - } - journal[id] = resultByteLength(result) <= JOURNAL_RESULT_MAX_BYTES ? { status: "done", ts: Date.now(), result } : { status: "done", ts: Date.now() }; - persist(); - return result; - })(); - inFlight.set(id, run); - try { - return await run; - } finally { - inFlight.delete(id); - } -} - -let ws = null; -let reconnectTimer = null; -let reconnectAttempts = 0; -const CONTEXT_ID_KEY = "opencli_context_id_v1"; -let currentContextId = "default"; -let contextIdPromise = null; -let connectInFlight = null; -let workerReady = Promise.resolve(); -let workerRecovered = true; + const id = cmd.id; + if (!id) return execute(cmd); + const running = inFlight.get(id); + if (running) return running; + const run = (async () => { + const journal = await load(); + const entry = journal[id]; + if (entry?.status === "done") { + if (entry.result) return entry.result; + return { + id, + ok: false, + errorCode: "result_evicted", + error: "Command already executed, but its result was too large to record for replay.", + errorHint: UNKNOWN_OUTCOME_HINT + }; + } + if (entry?.status === "started") return { + id, + ok: false, + errorCode: "command_lost", + error: "Command was interrupted mid-execution (extension or browser restarted); it may or may not have applied.", + errorHint: UNKNOWN_OUTCOME_HINT + }; + journal[id] = { + status: "started", + ts: Date.now() + }; + trim(journal); + persist(); + let result; + try { + result = await execute(cmd); + } catch (err) { + result = { + id, + ok: false, + error: err instanceof Error ? err.message : String(err) + }; + } + journal[id] = resultByteLength(result) <= JOURNAL_RESULT_MAX_BYTES ? { + status: "done", + ts: Date.now(), + result + } : { + status: "done", + ts: Date.now() + }; + persist(); + return result; + })(); + inFlight.set(id, run); + try { + return await run; + } finally { + inFlight.delete(id); + } +} +//#endregion +//#region src/background.ts +var ws = null; +var reconnectTimer = null; +var reconnectAttempts = 0; +var CONTEXT_ID_KEY = "opencli_context_id_v1"; +var currentContextId = "default"; +var contextIdPromise = null; +var connectInFlight = null; +var workerReady = Promise.resolve(); +var workerRecovered = true; async function getCurrentContextId() { - if (contextIdPromise) return contextIdPromise; - contextIdPromise = (async () => { - try { - const local = chrome.storage?.local; - if (!local) return currentContextId; - const raw = await local.get(CONTEXT_ID_KEY); - const existing = raw[CONTEXT_ID_KEY]; - if (typeof existing === "string" && existing.trim()) { - currentContextId = existing.trim(); - return currentContextId; - } - const generated = generateContextId(); - await local.set({ [CONTEXT_ID_KEY]: generated }); - currentContextId = generated; - return currentContextId; - } catch { - return currentContextId; - } - })(); - return contextIdPromise; + if (contextIdPromise) return contextIdPromise; + contextIdPromise = (async () => { + try { + const local = chrome.storage?.local; + if (!local) return currentContextId; + const existing = (await local.get(CONTEXT_ID_KEY))[CONTEXT_ID_KEY]; + if (typeof existing === "string" && existing.trim()) { + currentContextId = existing.trim(); + return currentContextId; + } + const generated = generateContextId(); + await local.set({ [CONTEXT_ID_KEY]: generated }); + currentContextId = generated; + return currentContextId; + } catch { + return currentContextId; + } + })(); + return contextIdPromise; } function generateContextId() { - const alphabet = "23456789abcdefghjkmnpqrstuvwxyz"; - const maxUnbiasedByte = Math.floor(256 / alphabet.length) * alphabet.length; - let id = ""; - while (id.length < 8) { - const bytes = new Uint8Array(8); - try { - crypto.getRandomValues(bytes); - } catch { - for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); - } - for (const byte of bytes) { - if (byte >= maxUnbiasedByte) continue; - id += alphabet[byte % alphabet.length]; - if (id.length === 8) break; - } - } - return id; -} -const _origLog = console.log.bind(console); -const _origWarn = console.warn.bind(console); -const _origError = console.error.bind(console); + const alphabet = "23456789abcdefghjkmnpqrstuvwxyz"; + const maxUnbiasedByte = Math.floor(256 / 31) * 31; + let id = ""; + while (id.length < 8) { + const bytes = new Uint8Array(8); + try { + crypto.getRandomValues(bytes); + } catch { + for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); + } + for (const byte of bytes) { + if (byte >= maxUnbiasedByte) continue; + id += alphabet[byte % 31]; + if (id.length === 8) break; + } + } + return id; +} +var _origLog = console.log.bind(console); +var _origWarn = console.warn.bind(console); +var _origError = console.error.bind(console); function forwardLog(level, args) { - try { - const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); - safeSend(ws, { type: "log", level, msg, ts: Date.now() }); - } catch { - } + try { + const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); + safeSend(ws, { + type: "log", + level, + msg, + ts: Date.now() + }); + } catch {} } function safeSend(socket, payload) { - if (!socket || socket.readyState !== WebSocket.OPEN) return false; - try { - socket.send(JSON.stringify(payload)); - return true; - } catch { - return false; - } + if (!socket || socket.readyState !== WebSocket.OPEN) return false; + try { + socket.send(JSON.stringify(payload)); + return true; + } catch { + return false; + } } console.log = (...args) => { - _origLog(...args); - forwardLog("info", args); + _origLog(...args); + forwardLog("info", args); }; console.warn = (...args) => { - _origWarn(...args); - forwardLog("warn", args); + _origWarn(...args); + forwardLog("warn", args); }; console.error = (...args) => { - _origError(...args); - forwardLog("error", args); + _origError(...args); + forwardLog("error", args); }; function isDaemonSocketActive(socket = ws) { - return socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING; -} + return socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING; +} +/** +* Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket +* connection. fetch() failures are silently catchable; new WebSocket() is not +* — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any +* JS handler can intercept it. By keeping the probe inside connect() every +* call site remains unchanged and the guard can never be accidentally skipped. +*/ function connect() { - if (isDaemonSocketActive()) return Promise.resolve(); - if (connectInFlight) return connectInFlight; - const attempt = workerRecovered ? connectAttempt() : workerReady.then(() => connectAttempt()); - connectInFlight = attempt.finally(() => { - connectInFlight = null; - }); - return connectInFlight; + if (isDaemonSocketActive()) return Promise.resolve(); + if (connectInFlight) return connectInFlight; + connectInFlight = (workerRecovered ? connectAttempt() : workerReady.then(() => connectAttempt())).finally(() => { + connectInFlight = null; + }); + return connectInFlight; } async function connectAttempt() { - if (isDaemonSocketActive()) return; - try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); - if (!res.ok) { - scheduleReconnect(); - return; - } - reconnectAttempts = 0; - } catch { - scheduleReconnect(); - return; - } - if (isDaemonSocketActive()) return; - let thisWs; - try { - const contextId = await getCurrentContextId(); - if (isDaemonSocketActive()) return; - thisWs = new WebSocket(DAEMON_WS_URL); - ws = thisWs; - currentContextId = contextId; - } catch { - scheduleReconnect(); - return; - } - thisWs.onopen = () => { - if (ws !== thisWs) return; - console.log("[opencli] Connected to daemon"); - reconnectAttempts = 0; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - safeSend(thisWs, { - type: "hello", - contextId: currentContextId, - version: chrome.runtime.getManifest().version, - compatRange: ">=1.7.0" - }); - startWsKeepalive(thisWs); - }; - thisWs.onmessage = async (event) => { - if (ws !== thisWs) return; - try { - const command = JSON.parse(event.data); - const result = await executeWithJournal(command, handleCommand); - const target = ws && ws.readyState === WebSocket.OPEN ? ws : thisWs; - safeSend(target, result); - } catch (err) { - console.error("[opencli] Message handling error:", err); - } - }; - thisWs.onclose = () => { - stopWsKeepalive(thisWs); - if (ws !== thisWs) return; - console.log("[opencli] Disconnected from daemon"); - ws = null; - scheduleReconnect(); - }; - thisWs.onerror = () => { - thisWs.close(); - }; -} -const WS_KEEPALIVE_INTERVAL_MS = 2e4; -let wsKeepaliveTimer = null; -let wsKeepaliveSocket = null; + if (isDaemonSocketActive()) return; + try { + if (!(await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) })).ok) { + scheduleReconnect(); + return; + } + reconnectAttempts = 0; + } catch { + scheduleReconnect(); + return; + } + if (isDaemonSocketActive()) return; + let thisWs; + try { + const contextId = await getCurrentContextId(); + if (isDaemonSocketActive()) return; + thisWs = new WebSocket(DAEMON_WS_URL); + ws = thisWs; + currentContextId = contextId; + } catch { + scheduleReconnect(); + return; + } + thisWs.onopen = () => { + if (ws !== thisWs) return; + console.log("[opencli] Connected to daemon"); + reconnectAttempts = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + safeSend(thisWs, { + type: "hello", + contextId: currentContextId, + version: chrome.runtime.getManifest().version, + compatRange: ">=1.7.0" + }); + startWsKeepalive(thisWs); + }; + thisWs.onmessage = async (event) => { + if (ws !== thisWs) return; + try { + const result = await executeWithJournal(JSON.parse(event.data), handleCommand); + safeSend(ws && ws.readyState === WebSocket.OPEN ? ws : thisWs, result); + } catch (err) { + console.error("[opencli] Message handling error:", err); + } + }; + thisWs.onclose = () => { + stopWsKeepalive(thisWs); + if (ws !== thisWs) return; + console.log("[opencli] Disconnected from daemon"); + ws = null; + scheduleReconnect(); + }; + thisWs.onerror = () => { + thisWs.close(); + }; +} +var WS_KEEPALIVE_INTERVAL_MS = 2e4; +var wsKeepaliveTimer = null; +var wsKeepaliveSocket = null; function startWsKeepalive(socket) { - if (wsKeepaliveTimer) clearInterval(wsKeepaliveTimer); - wsKeepaliveSocket = socket; - wsKeepaliveTimer = setInterval(() => { - if (socket !== ws || socket.readyState !== WebSocket.OPEN) { - stopWsKeepalive(socket); - return; - } - safeSend(socket, { type: "ping", ts: Date.now() }); - }, WS_KEEPALIVE_INTERVAL_MS); + if (wsKeepaliveTimer) clearInterval(wsKeepaliveTimer); + wsKeepaliveSocket = socket; + wsKeepaliveTimer = setInterval(() => { + if (socket !== ws || socket.readyState !== WebSocket.OPEN) { + stopWsKeepalive(socket); + return; + } + safeSend(socket, { + type: "ping", + ts: Date.now() + }); + }, WS_KEEPALIVE_INTERVAL_MS); } function stopWsKeepalive(socket) { - if (wsKeepaliveSocket !== socket) return; - if (wsKeepaliveTimer) clearInterval(wsKeepaliveTimer); - wsKeepaliveTimer = null; - wsKeepaliveSocket = null; -} -const RECONNECT_BASE_DELAY_MS = 1e3; -const RECONNECT_MAX_DELAY_MS = 15e3; + if (wsKeepaliveSocket !== socket) return; + if (wsKeepaliveTimer) clearInterval(wsKeepaliveTimer); + wsKeepaliveTimer = null; + wsKeepaliveSocket = null; +} +/** +* Reconnect cadence: plain exponential backoff with jitter, never giving up +* while Chrome keeps the service worker alive. 1s → 2s → 4s → … capped at 15s +* (+0-500ms jitter); attempts reset on a successful WS open. The durable wake +* path is chrome.alarms: production Chrome enforces a ~30s minimum alarm +* interval, so alarms wake the worker after idle eviction while setTimeout +* provides the faster path only when the worker remains alive. +*/ +var RECONNECT_BASE_DELAY_MS = 1e3; +var RECONNECT_MAX_DELAY_MS = 15e3; function nextReconnectDelayMs() { - const exp = Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** Math.min(reconnectAttempts, 6)); - return exp + Math.floor(Math.random() * 500); + return Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** Math.min(reconnectAttempts, 6)) + Math.floor(Math.random() * 500); } function scheduleReconnect() { - if (reconnectTimer) return; - const delay = nextReconnectDelayMs(); - reconnectAttempts++; - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - void connect(); - }, delay); -} -const automationSessions = /* @__PURE__ */ new Map(); -const IDLE_TIMEOUT_DEFAULT = 3e4; -const IDLE_TIMEOUT_INTERACTIVE = 6e5; -const IDLE_TIMEOUT_NONE = -1; -const REGISTRY_KEY = "opencli_target_lease_registry_v2"; -const LEASE_IDLE_ALARM_PREFIX = "opencli:lease-idle:"; -const CONTAINER_TAB_GROUP_TITLE = { - interactive: "OpenCLI Browser", - // Retained for registry/type compatibility. Adapter automation no longer - // creates or discovers a visible tab group. - automation: "OpenCLI Adapter" + if (reconnectTimer) return; + const delay = nextReconnectDelayMs(); + reconnectAttempts++; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); +} +var automationSessions = /* @__PURE__ */ new Map(); +var IDLE_TIMEOUT_DEFAULT = 3e4; +var IDLE_TIMEOUT_INTERACTIVE = 6e5; +var IDLE_TIMEOUT_NONE = -1; +var REGISTRY_KEY = "opencli_target_lease_registry_v2"; +var LEASE_IDLE_ALARM_PREFIX = "opencli:lease-idle:"; +var CONTAINER_TAB_GROUP_TITLE = { + interactive: "OpenCLI Browser", + automation: "OpenCLI Adapter" }; -const OWNED_TAB_GROUP_COLOR = "orange"; -let leaseMutationQueue = Promise.resolve(); -const ownedContainers = { - interactive: { windowId: null, groupId: null, promise: null, groupPromise: null }, - automation: { windowId: null, groupId: null, promise: null, groupPromise: null } +var OWNED_TAB_GROUP_COLOR = "orange"; +var leaseMutationQueue = Promise.resolve(); +var ownedContainers = { + interactive: { + windowId: null, + groupId: null, + promise: null, + groupPromise: null + }, + automation: { + windowId: null, + groupId: null, + promise: null, + groupPromise: null + } }; -const interactiveGroupLedger = /* @__PURE__ */ new Set(); -class CommandFailure extends Error { - constructor(code, message, hint) { - super(message); - this.code = code; - this.hint = hint; - this.name = "CommandFailure"; - } -} -const sessionOverrides = /* @__PURE__ */ new Map(); +var interactiveGroupLedger = /* @__PURE__ */ new Set(); +var CommandFailure = class extends Error { + constructor(code, message, hint) { + super(message); + this.code = code; + this.hint = hint; + this.name = "CommandFailure"; + } +}; +var sessionOverrides = /* @__PURE__ */ new Map(); function setSessionOverride(key, patch) { - sessionOverrides.set(key, { ...sessionOverrides.get(key), ...patch }); -} -const activeCommandCounts = /* @__PURE__ */ new Map(); -const LEASE_KEY_SEPARATOR = "\0"; + sessionOverrides.set(key, { + ...sessionOverrides.get(key), + ...patch + }); +} +/** Commands currently executing per lease — idle release is deferred while > 0. */ +var activeCommandCounts = /* @__PURE__ */ new Map(); +var LEASE_KEY_SEPARATOR = "\0"; function getLeaseKey(session, surface) { - return `${surface}${LEASE_KEY_SEPARATOR}${encodeURIComponent(session)}`; + return `${surface}${LEASE_KEY_SEPARATOR}${encodeURIComponent(session)}`; } function getSessionName(session) { - const raw = session?.trim(); - if (!raw) throw new CommandFailure( - "session_required", - "Browser session is required.", - "Pass a browser session name, e.g. opencli browser ." - ); - return raw; + const raw = session?.trim(); + if (!raw) throw new CommandFailure("session_required", "Browser session is required.", "Pass a browser session name, e.g. opencli browser ."); + return raw; } function getCommandSurface(cmd) { - return cmd.surface === "adapter" ? "adapter" : "browser"; + return cmd.surface === "adapter" ? "adapter" : "browser"; } function getSurfaceFromKey(key) { - return key.split(LEASE_KEY_SEPARATOR, 1)[0] === "adapter" ? "adapter" : "browser"; + return key.split(LEASE_KEY_SEPARATOR, 1)[0] === "adapter" ? "adapter" : "browser"; } function getSessionFromKey(key) { - const idx = key.indexOf(LEASE_KEY_SEPARATOR); - if (idx === -1) return key; - try { - return decodeURIComponent(key.slice(idx + 1)); - } catch { - return key.slice(idx + 1); - } + const idx = key.indexOf(LEASE_KEY_SEPARATOR); + if (idx === -1) return key; + try { + return decodeURIComponent(key.slice(idx + 1)); + } catch { + return key.slice(idx + 1); + } } function getIdleTimeout(key) { - const session = automationSessions.get(key); - if (session?.kind === "bound") return IDLE_TIMEOUT_NONE; - const overrides = sessionOverrides.get(key); - const adapterPersistent = getSurfaceFromKey(key) === "adapter" && (session?.lifecycle === "persistent" || overrides?.lifecycle === "persistent"); - if (adapterPersistent) return IDLE_TIMEOUT_NONE; - if (overrides?.idleTimeoutMs !== void 0) return overrides.idleTimeoutMs; - return getSurfaceFromKey(key) === "browser" ? IDLE_TIMEOUT_INTERACTIVE : IDLE_TIMEOUT_DEFAULT; + const session = automationSessions.get(key); + if (session?.kind === "bound") return IDLE_TIMEOUT_NONE; + const overrides = sessionOverrides.get(key); + if (getSurfaceFromKey(key) === "adapter" && (session?.lifecycle === "persistent" || overrides?.lifecycle === "persistent")) return IDLE_TIMEOUT_NONE; + if (overrides?.idleTimeoutMs !== void 0) return overrides.idleTimeoutMs; + return getSurfaceFromKey(key) === "browser" ? IDLE_TIMEOUT_INTERACTIVE : IDLE_TIMEOUT_DEFAULT; } function getLeaseLifecycle(key, kind) { - if (kind === "bound") return "pinned"; - const override = sessionOverrides.get(key)?.lifecycle; - if (override) return override; - return getSurfaceFromKey(key) === "browser" ? "persistent" : "ephemeral"; + if (kind === "bound") return "pinned"; + const override = sessionOverrides.get(key)?.lifecycle; + if (override) return override; + return getSurfaceFromKey(key) === "browser" ? "persistent" : "ephemeral"; } function getOwnedWindowRole(key) { - return getSurfaceFromKey(key) === "browser" ? "interactive" : "automation"; + return getSurfaceFromKey(key) === "browser" ? "interactive" : "automation"; } function getWindowRole(key, ownership) { - return ownership === "borrowed" ? "borrowed-user" : getOwnedWindowRole(key); + return ownership === "borrowed" ? "borrowed-user" : getOwnedWindowRole(key); } function getWindowMode(key) { - return sessionOverrides.get(key)?.windowMode ?? (getOwnedWindowRole(key) === "interactive" ? "foreground" : "background"); + return sessionOverrides.get(key)?.windowMode ?? (getOwnedWindowRole(key) === "interactive" ? "foreground" : "background"); } function makeAlarmName(leaseKey) { - return `${LEASE_IDLE_ALARM_PREFIX}${encodeURIComponent(leaseKey)}`; + return `${LEASE_IDLE_ALARM_PREFIX}${encodeURIComponent(leaseKey)}`; } function leaseKeyFromAlarmName(name) { - if (!name.startsWith(LEASE_IDLE_ALARM_PREFIX)) return null; - try { - return decodeURIComponent(name.slice(LEASE_IDLE_ALARM_PREFIX.length)); - } catch { - return null; - } + if (!name.startsWith(LEASE_IDLE_ALARM_PREFIX)) return null; + try { + return decodeURIComponent(name.slice(19)); + } catch { + return null; + } } function withLeaseMutation(fn) { - const run = leaseMutationQueue.then(fn, fn); - leaseMutationQueue = run.then(() => void 0, () => void 0); - return run; + const run = leaseMutationQueue.then(fn, fn); + leaseMutationQueue = run.then(() => void 0, () => void 0); + return run; } function makeSession(key, session) { - const ownership = session.owned ? "owned" : "borrowed"; - return { - ...session, - contextId: currentContextId, - ownership, - lifecycle: getLeaseLifecycle(key, session.kind), - windowRole: getWindowRole(key, ownership) - }; + const ownership = session.owned ? "owned" : "borrowed"; + return { + ...session, + contextId: currentContextId, + ownership, + lifecycle: getLeaseLifecycle(key, session.kind), + windowRole: getWindowRole(key, ownership) + }; } function emptyRegistry() { - return { - version: 2, - contextId: currentContextId, - ownedContainers: { - interactive: { - windowId: ownedContainers.interactive.windowId, - groupIds: [...interactiveGroupLedger] - }, - automation: { windowId: ownedContainers.automation.windowId } - }, - leases: {} - }; + return { + version: 2, + contextId: currentContextId, + ownedContainers: { + interactive: { + windowId: ownedContainers.interactive.windowId, + groupIds: [...interactiveGroupLedger] + }, + automation: { windowId: ownedContainers.automation.windowId } + }, + leases: {} + }; } async function readRegistry() { - try { - const session = chrome.storage?.session; - if (!session) return emptyRegistry(); - const raw = await session.get(REGISTRY_KEY); - const stored = raw[REGISTRY_KEY]; - if (!stored || stored.version !== 2 || typeof stored.leases !== "object") return emptyRegistry(); - const storedContainers = stored.ownedContainers && typeof stored.ownedContainers === "object" ? stored.ownedContainers : emptyRegistry().ownedContainers; - return { - version: 2, - contextId: currentContextId, - ownedContainers: { - interactive: { - windowId: typeof storedContainers.interactive?.windowId === "number" ? storedContainers.interactive.windowId : null, - groupIds: Array.isArray(storedContainers.interactive?.groupIds) ? storedContainers.interactive.groupIds.filter((id) => typeof id === "number") : [] - }, - automation: { - windowId: typeof storedContainers.automation?.windowId === "number" ? storedContainers.automation.windowId : null - } - }, - leases: stored.leases - }; - } catch { - return emptyRegistry(); - } + try { + const session = chrome.storage?.session; + if (!session) return emptyRegistry(); + const stored = (await session.get(REGISTRY_KEY))[REGISTRY_KEY]; + if (!stored || stored.version !== 2 || typeof stored.leases !== "object") return emptyRegistry(); + const storedContainers = stored.ownedContainers && typeof stored.ownedContainers === "object" ? stored.ownedContainers : emptyRegistry().ownedContainers; + return { + version: 2, + contextId: currentContextId, + ownedContainers: { + interactive: { + windowId: typeof storedContainers.interactive?.windowId === "number" ? storedContainers.interactive.windowId : null, + groupIds: Array.isArray(storedContainers.interactive?.groupIds) ? storedContainers.interactive.groupIds.filter((id) => typeof id === "number") : [] + }, + automation: { windowId: typeof storedContainers.automation?.windowId === "number" ? storedContainers.automation.windowId : null } + }, + leases: stored.leases + }; + } catch { + return emptyRegistry(); + } } async function writeRegistry(registry) { - try { - await chrome.storage?.session?.set({ [REGISTRY_KEY]: registry }); - } catch { - } + try { + await chrome.storage?.session?.set({ [REGISTRY_KEY]: registry }); + } catch {} } async function persistRuntimeState() { - const leases = {}; - for (const [leaseKey, session] of automationSessions.entries()) { - leases[leaseKey] = { - session: session.session, - surface: session.surface, - kind: session.kind, - windowId: session.windowId, - owned: session.owned, - preferredTabId: session.preferredTabId, - contextId: session.contextId, - ownership: session.ownership, - lifecycle: session.lifecycle, - windowRole: session.windowRole, - idleDeadlineAt: session.idleDeadlineAt, - updatedAt: Date.now() - }; - } - await writeRegistry({ - version: 2, - contextId: currentContextId, - ownedContainers: { - interactive: { - windowId: ownedContainers.interactive.windowId, - groupIds: [...interactiveGroupLedger] - }, - automation: { windowId: ownedContainers.automation.windowId } - }, - leases - }); + const leases = {}; + for (const [leaseKey, session] of automationSessions.entries()) leases[leaseKey] = { + session: session.session, + surface: session.surface, + kind: session.kind, + windowId: session.windowId, + owned: session.owned, + preferredTabId: session.preferredTabId, + contextId: session.contextId, + ownership: session.ownership, + lifecycle: session.lifecycle, + windowRole: session.windowRole, + idleDeadlineAt: session.idleDeadlineAt, + updatedAt: Date.now() + }; + await writeRegistry({ + version: 2, + contextId: currentContextId, + ownedContainers: { + interactive: { + windowId: ownedContainers.interactive.windowId, + groupIds: [...interactiveGroupLedger] + }, + automation: { windowId: ownedContainers.automation.windowId } + }, + leases + }); } function scheduleIdleAlarm(leaseKey, timeout) { - const alarmName = makeAlarmName(leaseKey); - try { - if (timeout > 0) { - chrome.alarms?.create?.(alarmName, { when: Date.now() + timeout }); - } else { - chrome.alarms?.clear?.(alarmName); - } - } catch { - } + const alarmName = makeAlarmName(leaseKey); + try { + if (timeout > 0) chrome.alarms?.create?.(alarmName, { when: Date.now() + timeout }); + else chrome.alarms?.clear?.(alarmName); + } catch {} } async function safeDetach(tabId) { - try { - const detach$1 = detach; - if (typeof detach$1 === "function") await detach$1(tabId); - } catch { - } + try { + const detach$1 = detach; + if (typeof detach$1 === "function") await detach$1(tabId); + } catch {} } async function removeLeaseSession(leaseKey) { - const existing = automationSessions.get(leaseKey); - if (existing?.idleTimer) clearTimeout(existing.idleTimer); - automationSessions.delete(leaseKey); - sessionOverrides.delete(leaseKey); - scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); - await persistRuntimeState(); + const existing = automationSessions.get(leaseKey); + if (existing?.idleTimer) clearTimeout(existing.idleTimer); + automationSessions.delete(leaseKey); + sessionOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); + await persistRuntimeState(); } function resetWindowIdleTimer(leaseKey, remainingMs) { - const session = automationSessions.get(leaseKey); - if (!session) return; - if (session.idleTimer) clearTimeout(session.idleTimer); - const timeout = getIdleTimeout(leaseKey); - if (timeout <= 0) { - scheduleIdleAlarm(leaseKey, timeout); - session.idleTimer = null; - session.idleDeadlineAt = 0; - void persistRuntimeState(); - return; - } - const interval = remainingMs === void 0 ? timeout : Math.max(0, Math.min(remainingMs, timeout)); - scheduleIdleAlarm(leaseKey, interval); - session.idleDeadlineAt = Date.now() + interval; - void persistRuntimeState(); - session.idleTimer = setTimeout(async () => { - if ((activeCommandCounts.get(leaseKey) ?? 0) > 0) { - return; - } - await releaseLease(leaseKey, "idle timeout"); - }, interval); + const session = automationSessions.get(leaseKey); + if (!session) return; + if (session.idleTimer) clearTimeout(session.idleTimer); + const timeout = getIdleTimeout(leaseKey); + if (timeout <= 0) { + scheduleIdleAlarm(leaseKey, timeout); + session.idleTimer = null; + session.idleDeadlineAt = 0; + persistRuntimeState(); + return; + } + const interval = remainingMs === void 0 ? timeout : Math.max(0, Math.min(remainingMs, timeout)); + scheduleIdleAlarm(leaseKey, interval); + session.idleDeadlineAt = Date.now() + interval; + persistRuntimeState(); + session.idleTimer = setTimeout(async () => { + if ((activeCommandCounts.get(leaseKey) ?? 0) > 0) return; + await releaseLease(leaseKey, "idle timeout"); + }, interval); } function getOwnedContainerGroupTitles(role) { - return role === "automation" ? [] : [CONTAINER_TAB_GROUP_TITLE.interactive]; + return role === "automation" ? [] : [CONTAINER_TAB_GROUP_TITLE.interactive]; } async function focusOwnedWindowIfRequested(windowId, mode) { - if (mode !== "foreground") return; - const updateWindow = chrome.windows.update; - if (typeof updateWindow === "function") await updateWindow(windowId, { focused: true }).catch(() => { - }); + if (mode !== "foreground") return; + const updateWindow = chrome.windows.update; + if (typeof updateWindow === "function") await updateWindow(windowId, { focused: true }).catch(() => {}); } async function toOwnedContainerGroupCandidate(group) { - try { - const chromeWindow = await chrome.windows.get(group.windowId); - const reusableTabId = await findReusableOwnedContainerTab(group.windowId, group.id); - return { - id: group.id, - windowId: group.windowId, - title: group.title, - focused: !!chromeWindow.focused, - hasReusableTab: reusableTabId !== void 0 - }; - } catch { - return null; - } + try { + const chromeWindow = await chrome.windows.get(group.windowId); + const reusableTabId = await findReusableOwnedContainerTab(group.windowId, group.id); + return { + id: group.id, + windowId: group.windowId, + title: group.title, + focused: !!chromeWindow.focused, + hasReusableTab: reusableTabId !== void 0 + }; + } catch { + return null; + } } function selectOwnedContainerGroupCandidate(candidates) { - if (candidates.length === 0) return null; - return [...candidates].sort((a, b) => { - if (a.focused !== b.focused) return a.focused ? -1 : 1; - if (a.hasReusableTab !== b.hasReusableTab) return a.hasReusableTab ? -1 : 1; - if (a.windowId !== b.windowId) return a.windowId - b.windowId; - return a.id - b.id; - })[0]; + if (candidates.length === 0) return null; + return [...candidates].sort((a, b) => { + if (a.focused !== b.focused) return a.focused ? -1 : 1; + if (a.hasReusableTab !== b.hasReusableTab) return a.hasReusableTab ? -1 : 1; + if (a.windowId !== b.windowId) return a.windowId - b.windowId; + return a.id - b.id; + })[0]; } async function collectOwnedGroupCandidates(role) { - if (role === "automation") return []; - const container = ownedContainers[role]; - const groupsById = /* @__PURE__ */ new Map(); - if (container.groupId !== null) { - try { - const group = await chrome.tabGroups.get(container.groupId); - groupsById.set(group.id, group); - } catch { - container.groupId = null; - } - } - let ledgerPruned = false; - for (const groupId of [...interactiveGroupLedger]) { - if (groupsById.has(groupId)) continue; - try { - const group = await chrome.tabGroups.get(groupId); - groupsById.set(group.id, group); - } catch { - interactiveGroupLedger.delete(groupId); - ledgerPruned = true; - } - } - if (ledgerPruned) await persistRuntimeState(); - for (const title of getOwnedContainerGroupTitles(role)) { - const groups = await chrome.tabGroups.query({ title }); - for (const group of groups) groupsById.set(group.id, group); - } - for (const [leaseKey, session] of automationSessions.entries()) { - if (!session.owned || getOwnedWindowRole(leaseKey) !== role || session.preferredTabId === null) continue; - try { - const tab = await chrome.tabs.get(session.preferredTabId); - const groupId = tab.groupId; - if (typeof groupId !== "number" || groupId === chrome.tabGroups.TAB_GROUP_ID_NONE) continue; - const group = await chrome.tabGroups.get(groupId); - groupsById.set(group.id, group); - } catch { - } - } - const ownedPreferredTabIds = /* @__PURE__ */ new Set(); - for (const [leaseKey, session] of automationSessions.entries()) { - if (!session.owned || getOwnedWindowRole(leaseKey) !== role || session.preferredTabId === null) continue; - ownedPreferredTabIds.add(session.preferredTabId); - } - if (ownedPreferredTabIds.size > 0) { - try { - const allGroups = await chrome.tabGroups.query({}); - for (const group of allGroups) { - if (group.title) continue; - if (groupsById.has(group.id)) continue; - const tabsInGroup = await chrome.tabs.query({ groupId: group.id }); - if (tabsInGroup.some((tab) => tab.id !== void 0 && ownedPreferredTabIds.has(tab.id))) { - groupsById.set(group.id, group); - } - } - } catch { - } - } - const candidates = await Promise.all([...groupsById.values()].map(toOwnedContainerGroupCandidate)); - return candidates.filter((candidate) => candidate !== null); + if (role === "automation") return []; + const container = ownedContainers[role]; + const groupsById = /* @__PURE__ */ new Map(); + if (container.groupId !== null) try { + const group = await chrome.tabGroups.get(container.groupId); + groupsById.set(group.id, group); + } catch { + container.groupId = null; + } + let ledgerPruned = false; + for (const groupId of [...interactiveGroupLedger]) { + if (groupsById.has(groupId)) continue; + try { + const group = await chrome.tabGroups.get(groupId); + groupsById.set(group.id, group); + } catch { + interactiveGroupLedger.delete(groupId); + ledgerPruned = true; + } + } + if (ledgerPruned) await persistRuntimeState(); + for (const title of getOwnedContainerGroupTitles(role)) { + const groups = await chrome.tabGroups.query({ title }); + for (const group of groups) groupsById.set(group.id, group); + } + for (const [leaseKey, session] of automationSessions.entries()) { + if (!session.owned || getOwnedWindowRole(leaseKey) !== role || session.preferredTabId === null) continue; + try { + const groupId = (await chrome.tabs.get(session.preferredTabId)).groupId; + if (typeof groupId !== "number" || groupId === chrome.tabGroups.TAB_GROUP_ID_NONE) continue; + const group = await chrome.tabGroups.get(groupId); + groupsById.set(group.id, group); + } catch {} + } + const ownedPreferredTabIds = /* @__PURE__ */ new Set(); + for (const [leaseKey, session] of automationSessions.entries()) { + if (!session.owned || getOwnedWindowRole(leaseKey) !== role || session.preferredTabId === null) continue; + ownedPreferredTabIds.add(session.preferredTabId); + } + if (ownedPreferredTabIds.size > 0) try { + const allGroups = await chrome.tabGroups.query({}); + for (const group of allGroups) { + if (group.title) continue; + if (groupsById.has(group.id)) continue; + if ((await chrome.tabs.query({ groupId: group.id })).some((tab) => tab.id !== void 0 && ownedPreferredTabIds.has(tab.id))) groupsById.set(group.id, group); + } + } catch {} + return (await Promise.all([...groupsById.values()].map(toOwnedContainerGroupCandidate))).filter((candidate) => candidate !== null); } function updateOwnedSessionWindowForTabs(role, tabIds, windowId) { - const moved = new Set(tabIds); - for (const [leaseKey, session] of automationSessions.entries()) { - if (!session.owned || getOwnedWindowRole(leaseKey) !== role) continue; - if (session.preferredTabId !== null && moved.has(session.preferredTabId)) { - session.windowId = windowId; - } - } + const moved = new Set(tabIds); + for (const [leaseKey, session] of automationSessions.entries()) { + if (!session.owned || getOwnedWindowRole(leaseKey) !== role) continue; + if (session.preferredTabId !== null && moved.has(session.preferredTabId)) session.windowId = windowId; + } } async function ensureTabsInWindow(tabIds, windowId) { - const movedIds = []; - for (const tabId of tabIds) { - try { - const tab = await chrome.tabs.get(tabId); - if (tab.windowId !== windowId) { - await chrome.tabs.move(tabId, { windowId, index: -1 }); - movedIds.push(tabId); - } - } catch { - } - } - return movedIds; + const movedIds = []; + for (const tabId of tabIds) try { + if ((await chrome.tabs.get(tabId)).windowId !== windowId) { + await chrome.tabs.move(tabId, { + windowId, + index: -1 + }); + movedIds.push(tabId); + } + } catch {} + return movedIds; } async function ensureCanonicalGroupTitle(role, group) { - const canonicalTitle = CONTAINER_TAB_GROUP_TITLE[role]; - if (group.title === canonicalTitle) return group; - const updated = await chrome.tabGroups.update(group.id, { - title: canonicalTitle, - color: OWNED_TAB_GROUP_COLOR - }); - return { id: updated.id, windowId: updated.windowId, title: updated.title }; + const canonicalTitle = CONTAINER_TAB_GROUP_TITLE[role]; + if (group.title === canonicalTitle) return group; + const updated = await chrome.tabGroups.update(group.id, { + title: canonicalTitle, + color: OWNED_TAB_GROUP_COLOR + }); + return { + id: updated.id, + windowId: updated.windowId, + title: updated.title + }; } async function convergeOwnedGroupDuplicates(role, canonical, candidates) { - for (const duplicate of candidates) { - if (duplicate.id === canonical.id) continue; - const tabs = await chrome.tabs.query({ groupId: duplicate.id }); - const tabIds = tabs.map((tab) => tab.id).filter((id) => id !== void 0); - if (tabIds.length === 0) continue; - await ensureTabsInWindow(tabIds, canonical.windowId); - await chrome.tabs.group({ groupId: canonical.id, tabIds }); - updateOwnedSessionWindowForTabs(role, tabIds, canonical.windowId); - } - return canonical; + for (const duplicate of candidates) { + if (duplicate.id === canonical.id) continue; + const tabIds = (await chrome.tabs.query({ groupId: duplicate.id })).map((tab) => tab.id).filter((id) => id !== void 0); + if (tabIds.length === 0) continue; + await ensureTabsInWindow(tabIds, canonical.windowId); + await chrome.tabs.group({ + groupId: canonical.id, + tabIds + }); + updateOwnedSessionWindowForTabs(role, tabIds, canonical.windowId); + } + return canonical; } async function attachTabsToOwnedGroup(role, group, ids) { - if (ids.length === 0) return group; - await ensureTabsInWindow(ids, group.windowId); - const tabs = await Promise.all(ids.map((id) => chrome.tabs.get(id).catch(() => null))); - const missing = tabs.filter((tab) => tab !== null && tab.id !== void 0 && tab.groupId !== group.id).map((tab) => tab.id); - if (missing.length > 0) await chrome.tabs.group({ groupId: group.id, tabIds: missing }); - updateOwnedSessionWindowForTabs(role, ids, group.windowId); - return group; + if (ids.length === 0) return group; + await ensureTabsInWindow(ids, group.windowId); + const missing = (await Promise.all(ids.map((id) => chrome.tabs.get(id).catch(() => null)))).filter((tab) => tab !== null && tab.id !== void 0 && tab.groupId !== group.id).map((tab) => tab.id); + if (missing.length > 0) await chrome.tabs.group({ + groupId: group.id, + tabIds: missing + }); + updateOwnedSessionWindowForTabs(role, ids, group.windowId); + return group; } async function createOwnedGroup(role, windowId, ids) { - if (ids.length === 0) throw new Error(`Cannot create ${role} tab group without tabs`); - await ensureTabsInWindow(ids, windowId); - const groupId = await chrome.tabs.group({ tabIds: ids, createProperties: { windowId } }); - ownedContainers[role].groupId = groupId; - ownedContainers[role].windowId = windowId; - if (role === "interactive") interactiveGroupLedger.add(groupId); - await persistRuntimeState(); - const group = await chrome.tabGroups.update(groupId, { - color: OWNED_TAB_GROUP_COLOR, - title: CONTAINER_TAB_GROUP_TITLE[role], - collapsed: false - }); - updateOwnedSessionWindowForTabs(role, ids, group.windowId); - return { id: group.id, windowId: group.windowId, title: group.title }; + if (ids.length === 0) throw new Error(`Cannot create ${role} tab group without tabs`); + await ensureTabsInWindow(ids, windowId); + const groupId = await chrome.tabs.group({ + tabIds: ids, + createProperties: { windowId } + }); + ownedContainers[role].groupId = groupId; + ownedContainers[role].windowId = windowId; + if (role === "interactive") interactiveGroupLedger.add(groupId); + await persistRuntimeState(); + const group = await chrome.tabGroups.update(groupId, { + color: OWNED_TAB_GROUP_COLOR, + title: CONTAINER_TAB_GROUP_TITLE[role], + collapsed: false + }); + updateOwnedSessionWindowForTabs(role, ids, group.windowId); + return { + id: group.id, + windowId: group.windowId, + title: group.title + }; } async function ensureOwnedContainerGroup(role, fallbackWindowId, tabIds) { - if (role === "automation") return null; - const ids = [...new Set(tabIds.filter((id) => id !== void 0))]; - const container = ownedContainers[role]; - const previousGroupPromise = container.groupPromise ?? Promise.resolve(null); - const nextGroupPromise = previousGroupPromise.catch(() => null).then(() => ensureOwnedContainerGroupUnlocked(role, fallbackWindowId, ids)); - const trackedGroupPromise = nextGroupPromise.finally(() => { - if (container.groupPromise === trackedGroupPromise) container.groupPromise = null; - }); - container.groupPromise = trackedGroupPromise; - return trackedGroupPromise; + if (role === "automation") return null; + const ids = [...new Set(tabIds.filter((id) => id !== void 0))]; + const container = ownedContainers[role]; + const trackedGroupPromise = (container.groupPromise ?? Promise.resolve(null)).catch(() => null).then(() => ensureOwnedContainerGroupUnlocked(role, fallbackWindowId, ids)).finally(() => { + if (container.groupPromise === trackedGroupPromise) container.groupPromise = null; + }); + container.groupPromise = trackedGroupPromise; + return trackedGroupPromise; } async function ensureOwnedContainerGroupUnlocked(role, fallbackWindowId, ids) { - try { - const candidates = await collectOwnedGroupCandidates(role); - const selected = selectOwnedContainerGroupCandidate(candidates); - let canonical = selected ? { id: selected.id, windowId: selected.windowId, title: selected.title } : null; - if (canonical) { - canonical = await convergeOwnedGroupDuplicates(role, canonical, candidates); - canonical = await ensureCanonicalGroupTitle(role, canonical); - canonical = await attachTabsToOwnedGroup(role, canonical, ids); - } else if (fallbackWindowId !== null && ids.length > 0) { - canonical = await createOwnedGroup(role, fallbackWindowId, ids); - } - if (canonical) { - ownedContainers[role].windowId = canonical.windowId; - ownedContainers[role].groupId = canonical.id; - if (!interactiveGroupLedger.has(canonical.id)) { - interactiveGroupLedger.add(canonical.id); - await persistRuntimeState(); - } - } else { - ownedContainers[role].groupId = null; - if (fallbackWindowId === null) ownedContainers[role].windowId = null; - } - return canonical; - } catch (err) { - console.warn(`[opencli] Failed to ensure ${role} tab group: ${err instanceof Error ? err.message : String(err)}`); - throw err; - } -} + try { + const candidates = await collectOwnedGroupCandidates(role); + const selected = selectOwnedContainerGroupCandidate(candidates); + let canonical = selected ? { + id: selected.id, + windowId: selected.windowId, + title: selected.title + } : null; + if (canonical) { + canonical = await convergeOwnedGroupDuplicates(role, canonical, candidates); + canonical = await ensureCanonicalGroupTitle(role, canonical); + canonical = await attachTabsToOwnedGroup(role, canonical, ids); + } else if (fallbackWindowId !== null && ids.length > 0) canonical = await createOwnedGroup(role, fallbackWindowId, ids); + if (canonical) { + ownedContainers[role].windowId = canonical.windowId; + ownedContainers[role].groupId = canonical.id; + if (!interactiveGroupLedger.has(canonical.id)) { + interactiveGroupLedger.add(canonical.id); + await persistRuntimeState(); + } + } else { + ownedContainers[role].groupId = null; + if (fallbackWindowId === null) ownedContainers[role].windowId = null; + } + return canonical; + } catch (err) { + console.warn(`[opencli] Failed to ensure ${role} tab group: ${err instanceof Error ? err.message : String(err)}`); + throw err; + } +} +/** +* Ensure the owned window for the requested role exists. +* +* First-principles model: +* - BrowserContext is the user's default Chrome profile. +* - Session identity maps to a TargetLease (usually a tab), not a window. +* - Browser commands and adapters use separate owned windows so foreground +* interactive work cannot drag background adapter automation into view. +*/ async function ensureOwnedContainerWindow(role, initialUrl, mode = "background") { - const container = ownedContainers[role]; - if (container.promise) return container.promise; - container.promise = ensureOwnedContainerWindowUnlocked(role, initialUrl, mode).finally(() => { - container.promise = null; - }); - return container.promise; + const container = ownedContainers[role]; + if (container.promise) return container.promise; + container.promise = ensureOwnedContainerWindowUnlocked(role, initialUrl, mode).finally(() => { + container.promise = null; + }); + return container.promise; } async function ensureOwnedContainerWindowUnlocked(role, initialUrl, mode = "background") { - const container = ownedContainers[role]; - if (container.windowId !== null) { - try { - await chrome.windows.get(container.windowId); - const group2 = await ensureOwnedContainerGroup(role, container.windowId, []); - if (group2) { - await focusOwnedWindowIfRequested(group2.windowId, mode); - const initialTabId3 = await findReusableOwnedContainerTab(group2.windowId, group2.id); - return { - windowId: group2.windowId, - initialTabId: initialTabId3 - }; - } - await focusOwnedWindowIfRequested(container.windowId, mode); - const initialTabId2 = await findReusableOwnedContainerTab(container.windowId, null); - const createdGroup = await ensureOwnedContainerGroup(role, container.windowId, [initialTabId2]); - if (createdGroup) { - return { - windowId: createdGroup.windowId, - initialTabId: initialTabId2 - }; - } - return { - windowId: container.windowId, - initialTabId: initialTabId2 - }; - } catch { - container.windowId = null; - container.groupId = null; - } - } - const existingGroup = await ensureOwnedContainerGroup(role, null, []); - if (existingGroup) { - await focusOwnedWindowIfRequested(existingGroup.windowId, mode); - const initialTabId2 = await findReusableOwnedContainerTab(existingGroup.windowId, existingGroup.id); - await persistRuntimeState(); - return { - windowId: existingGroup.windowId, - initialTabId: initialTabId2 - }; - } - const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; - const win = await chrome.windows.create({ - url: startUrl, - focused: mode === "foreground", - width: 1280, - height: 900, - type: "normal" - }); - container.windowId = win.id; - await persistRuntimeState(); - console.log(`[opencli] Created owned ${role} window ${container.windowId} (start=${startUrl})`); - const tabs = await chrome.tabs.query({ windowId: win.id }); - const initialTabId = tabs[0]?.id; - if (initialTabId) { - await new Promise((resolve) => { - const timeout = setTimeout(resolve, 500); - const listener = (tabId, info) => { - if (tabId === initialTabId && info.status === "complete") { - chrome.tabs.onUpdated.removeListener(listener); - clearTimeout(timeout); - resolve(); - } - }; - if (tabs[0].status === "complete") { - clearTimeout(timeout); - resolve(); - } else { - chrome.tabs.onUpdated.addListener(listener); - } - }); - } - const group = await ensureOwnedContainerGroup(role, container.windowId, [initialTabId]); - await persistRuntimeState(); - return { windowId: group?.windowId ?? container.windowId, initialTabId }; + const container = ownedContainers[role]; + if (container.windowId !== null) try { + await chrome.windows.get(container.windowId); + const group = await ensureOwnedContainerGroup(role, container.windowId, []); + if (group) { + await focusOwnedWindowIfRequested(group.windowId, mode); + const initialTabId = await findReusableOwnedContainerTab(group.windowId, group.id); + return { + windowId: group.windowId, + initialTabId + }; + } + await focusOwnedWindowIfRequested(container.windowId, mode); + const initialTabId = await findReusableOwnedContainerTab(container.windowId, null); + const createdGroup = await ensureOwnedContainerGroup(role, container.windowId, [initialTabId]); + if (createdGroup) return { + windowId: createdGroup.windowId, + initialTabId + }; + return { + windowId: container.windowId, + initialTabId + }; + } catch { + container.windowId = null; + container.groupId = null; + } + const existingGroup = await ensureOwnedContainerGroup(role, null, []); + if (existingGroup) { + await focusOwnedWindowIfRequested(existingGroup.windowId, mode); + const initialTabId = await findReusableOwnedContainerTab(existingGroup.windowId, existingGroup.id); + await persistRuntimeState(); + return { + windowId: existingGroup.windowId, + initialTabId + }; + } + const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; + const win = await chrome.windows.create({ + url: startUrl, + focused: mode === "foreground", + width: 1280, + height: 900, + type: "normal" + }); + container.windowId = win.id; + await persistRuntimeState(); + console.log(`[opencli] Created owned ${role} window ${container.windowId} (start=${startUrl})`); + const tabs = await chrome.tabs.query({ windowId: win.id }); + const initialTabId = tabs[0]?.id; + if (initialTabId) await new Promise((resolve) => { + const timeout = setTimeout(resolve, 500); + const listener = (tabId, info) => { + if (tabId === initialTabId && info.status === "complete") { + chrome.tabs.onUpdated.removeListener(listener); + clearTimeout(timeout); + resolve(); + } + }; + if (tabs[0].status === "complete") { + clearTimeout(timeout); + resolve(); + } else chrome.tabs.onUpdated.addListener(listener); + }); + const group = await ensureOwnedContainerGroup(role, container.windowId, [initialTabId]); + await persistRuntimeState(); + return { + windowId: group?.windowId ?? container.windowId, + initialTabId + }; } async function findReusableOwnedContainerTab(windowId, ownedGroupId) { - try { - const tabs = await chrome.tabs.query({ windowId }); - const reusable = tabs.find( - (tab) => tab.id !== void 0 && initialTabIsAvailable(tab.id) && isDebuggableUrl(tab.url) && (ownedGroupId === void 0 || ownedGroupId !== null && tab.groupId === ownedGroupId || !isSafeNavigationUrl(tab.url ?? "")) - ); - return reusable?.id; - } catch { - return void 0; - } + try { + return (await chrome.tabs.query({ windowId })).find((tab) => tab.id !== void 0 && initialTabIsAvailable(tab.id) && isDebuggableUrl(tab.url) && (ownedGroupId === void 0 || ownedGroupId !== null && tab.groupId === ownedGroupId || !isSafeNavigationUrl(tab.url ?? "")))?.id; + } catch { + return; + } } function initialTabIsAvailable(tabId) { - if (tabId === void 0) return false; - for (const session of automationSessions.values()) { - if (session.owned && session.preferredTabId === tabId) return false; - } - return true; + if (tabId === void 0) return false; + for (const session of automationSessions.values()) if (session.owned && session.preferredTabId === tabId) return false; + return true; } async function createOwnedTabLease(leaseKey, initialUrl) { - return withLeaseMutation(() => createOwnedTabLeaseUnlocked(leaseKey, initialUrl)); + return withLeaseMutation(() => createOwnedTabLeaseUnlocked(leaseKey, initialUrl)); } async function createOwnedTabLeaseUnlocked(leaseKey, initialUrl) { - const targetUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; - const role = getOwnedWindowRole(leaseKey); - const { windowId, initialTabId } = await ensureOwnedContainerWindow(role, targetUrl, getWindowMode(leaseKey)); - let tab; - if (initialTabIsAvailable(initialTabId)) { - tab = await chrome.tabs.get(initialTabId); - if (!isTargetUrl(tab.url, targetUrl)) { - tab = await chrome.tabs.update(initialTabId, { url: targetUrl }); - await new Promise((resolve) => setTimeout(resolve, 300)); - tab = await chrome.tabs.get(initialTabId); - } - } else { - tab = await chrome.tabs.create({ windowId, url: targetUrl, active: true }); - } - const tabId = tab.id; - if (!tabId) throw new Error("Failed to create tab lease in automation container"); - const group = await ensureOwnedContainerGroup(role, windowId, [tabId]); - const sessionWindowId = group?.windowId ?? tab.windowId; - if (tab.windowId !== sessionWindowId) tab = await chrome.tabs.get(tabId); - setLeaseSession(leaseKey, { - session: getSessionFromKey(leaseKey), - surface: getSurfaceFromKey(leaseKey), - kind: "owned", - windowId: sessionWindowId, - owned: true, - preferredTabId: tabId - }); - resetWindowIdleTimer(leaseKey); - return { tabId, tab }; -} + const targetUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; + const role = getOwnedWindowRole(leaseKey); + const { windowId, initialTabId } = await ensureOwnedContainerWindow(role, targetUrl, getWindowMode(leaseKey)); + let tab; + if (initialTabIsAvailable(initialTabId)) { + tab = await chrome.tabs.get(initialTabId); + if (!isTargetUrl(tab.url, targetUrl)) { + tab = await chrome.tabs.update(initialTabId, { url: targetUrl }); + await new Promise((resolve) => setTimeout(resolve, 300)); + tab = await chrome.tabs.get(initialTabId); + } + } else tab = await chrome.tabs.create({ + windowId, + url: targetUrl, + active: true + }); + const tabId = tab.id; + if (!tabId) throw new Error("Failed to create tab lease in automation container"); + const sessionWindowId = (await ensureOwnedContainerGroup(role, windowId, [tabId]))?.windowId ?? tab.windowId; + if (tab.windowId !== sessionWindowId) tab = await chrome.tabs.get(tabId); + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: "owned", + windowId: sessionWindowId, + owned: true, + preferredTabId: tabId + }); + resetWindowIdleTimer(leaseKey); + return { + tabId, + tab + }; +} +/** Get or create the dedicated automation container window. +* This compatibility helper returns the shared owned container. Leases +* lease tabs inside it instead of owning separate windows. +*/ async function getAutomationWindow(leaseKey, initialUrl) { - const existing = automationSessions.get(leaseKey); - if (existing) { - if (!existing.owned) { - throw new CommandFailure( - "bound_window_operation_blocked", - `Session "${existing.session}" is bound to a user tab and does not own an OpenCLI tab lease.`, - "Use page commands on the bound tab, or unbind the session first." - ); - } - try { - const tabId = existing.preferredTabId; - if (tabId !== null) { - const tab = await chrome.tabs.get(tabId); - if (isDebuggableUrl(tab.url)) return tab.windowId; - } - await chrome.windows.get(existing.windowId); - return existing.windowId; - } catch { - await removeLeaseSession(leaseKey); - } - } - const role = getOwnedWindowRole(leaseKey); - return (await ensureOwnedContainerWindow(role, initialUrl, getWindowMode(leaseKey))).windowId; + const existing = automationSessions.get(leaseKey); + if (existing) { + if (!existing.owned) throw new CommandFailure("bound_window_operation_blocked", `Session "${existing.session}" is bound to a user tab and does not own an OpenCLI tab lease.`, "Use page commands on the bound tab, or unbind the session first."); + try { + const tabId = existing.preferredTabId; + if (tabId !== null) { + const tab = await chrome.tabs.get(tabId); + if (isDebuggableUrl(tab.url)) return tab.windowId; + } + await chrome.windows.get(existing.windowId); + return existing.windowId; + } catch { + await removeLeaseSession(leaseKey); + } + } + return (await ensureOwnedContainerWindow(getOwnedWindowRole(leaseKey), initialUrl, getWindowMode(leaseKey))).windowId; } chrome.windows.onRemoved.addListener(async (windowId) => { - await workerReady; - for (const container of Object.values(ownedContainers)) { - if (container.windowId === windowId) { - container.windowId = null; - container.groupId = null; - } - } - for (const [leaseKey, session] of automationSessions.entries()) { - if (session.windowId === windowId) { - console.log(`[opencli] ${session.surface} container closed (session=${session.session})`); - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(leaseKey); - sessionOverrides.delete(leaseKey); - scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); - } - } - await persistRuntimeState(); + await workerReady; + for (const container of Object.values(ownedContainers)) if (container.windowId === windowId) { + container.windowId = null; + container.groupId = null; + } + for (const [leaseKey, session] of automationSessions.entries()) if (session.windowId === windowId) { + console.log(`[opencli] ${session.surface} container closed (session=${session.session})`); + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(leaseKey); + sessionOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); + } + await persistRuntimeState(); }); chrome.tabs.onRemoved.addListener(async (tabId) => { - await workerReady; - evictTab(tabId); - for (const [leaseKey, session] of automationSessions.entries()) { - if (session.preferredTabId === tabId) { - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(leaseKey); - sessionOverrides.delete(leaseKey); - scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); - console.log(`[opencli] Session ${session.session} detached from tab ${tabId} (tab closed)`); - } - } - await persistRuntimeState(); + await workerReady; + evictTab(tabId); + for (const [leaseKey, session] of automationSessions.entries()) if (session.preferredTabId === tabId) { + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(leaseKey); + sessionOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); + console.log(`[opencli] Session ${session.session} detached from tab ${tabId} (tab closed)`); + } + await persistRuntimeState(); }); -let initialized = false; +var initialized = false; function initialize() { - if (initialized) return; - initialized = true; - chrome.alarms.create("keepalive", { periodInMinutes: 0.5 }); - registerListeners(); - try { - const registerFrameTracking$1 = registerFrameTracking; - registerFrameTracking$1?.(); - } catch { - } - try { - void chrome.storage?.local?.remove?.(REGISTRY_KEY)?.catch?.(() => { - }); - } catch { - } - workerRecovered = false; - workerReady = (async () => { - await getCurrentContextId(); - await reconcileTargetLeaseRegistry(); - })().catch((err) => { - console.warn(`[opencli] Startup recovery failed: ${err instanceof Error ? err.message : String(err)}`); - }).finally(() => { - workerRecovered = true; - }); - void workerReady.then(() => connect()); - console.log("[opencli] OpenCLI extension initialized"); + if (initialized) return; + initialized = true; + chrome.alarms.create("keepalive", { periodInMinutes: .5 }); + registerListeners(); + try { + registerFrameTracking?.(); + } catch {} + try { + chrome.storage?.local?.remove?.(REGISTRY_KEY)?.catch?.(() => {}); + } catch {} + workerRecovered = false; + workerReady = (async () => { + await getCurrentContextId(); + await reconcileTargetLeaseRegistry(); + })().catch((err) => { + console.warn(`[opencli] Startup recovery failed: ${err instanceof Error ? err.message : String(err)}`); + }).finally(() => { + workerRecovered = true; + }); + workerReady.then(() => connect()); + console.log("[opencli] OpenCLI extension initialized"); } chrome.runtime.onInstalled.addListener(() => { - initialize(); + initialize(); }); chrome.runtime.onStartup.addListener(() => { - initialize(); + initialize(); }); initialize(); chrome.alarms.onAlarm.addListener(async (alarm) => { - await workerReady; - if (alarm.name === "keepalive") void connect(); - const leaseKey = leaseKeyFromAlarmName(alarm.name); - if (!leaseKey) return; - if ((activeCommandCounts.get(leaseKey) ?? 0) > 0) { - resetWindowIdleTimer(leaseKey); - return; - } - await releaseLease(leaseKey, "idle alarm"); + await workerReady; + if (alarm.name === "keepalive") connect(); + const leaseKey = leaseKeyFromAlarmName(alarm.name); + if (!leaseKey) return; + if ((activeCommandCounts.get(leaseKey) ?? 0) > 0) { + resetWindowIdleTimer(leaseKey); + return; + } + await releaseLease(leaseKey, "idle alarm"); }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg?.type === "getStatus") { - void (async () => { - const contextId = await getCurrentContextId(); - const connected = ws?.readyState === WebSocket.OPEN; - const extensionVersion = chrome.runtime.getManifest().version; - const daemonVersion = connected ? await fetchDaemonVersion() : null; - sendResponse({ - connected, - reconnecting: reconnectTimer !== null, - contextId, - extensionVersion, - daemonVersion - }); - })(); - return true; - } - return false; + if (msg?.type === "getStatus") { + (async () => { + const contextId = await getCurrentContextId(); + const connected = ws?.readyState === WebSocket.OPEN; + const extensionVersion = chrome.runtime.getManifest().version; + const daemonVersion = connected ? await fetchDaemonVersion() : null; + sendResponse({ + connected, + reconnecting: reconnectTimer !== null, + contextId, + extensionVersion, + daemonVersion + }); + })(); + return true; + } + return false; }); +/** +* Best-effort fetch of the daemon's reported version for the popup status panel. +* Resolves to null on any failure — the popup degrades to showing connection +* state without the version label. +*/ async function fetchDaemonVersion() { - try { - const res = await fetch(`http://${DAEMON_HOST}:${DAEMON_PORT}/status`, { - method: "GET", - headers: { "X-OpenCLI": "1" }, - signal: AbortSignal.timeout(1500) - }); - if (!res.ok) return null; - const body = await res.json(); - return typeof body.daemonVersion === "string" ? body.daemonVersion : null; - } catch { - return null; - } + try { + const res = await fetch(`http://${DAEMON_HOST}:${DAEMON_PORT}/status`, { + method: "GET", + headers: { "X-OpenCLI": "1" }, + signal: AbortSignal.timeout(1500) + }); + if (!res.ok) return null; + const body = await res.json(); + return typeof body.daemonVersion === "string" ? body.daemonVersion : null; + } catch { + return null; + } } async function handleCommand(cmd) { - const session = getSessionName(cmd.session); - const surface = getCommandSurface(cmd); - const leaseKey = getLeaseKey(session, surface); - if (cmd.windowMode === "foreground" || cmd.windowMode === "background") { - setSessionOverride(leaseKey, { windowMode: cmd.windowMode }); - } - if (surface === "adapter" && (cmd.siteSession === "persistent" || cmd.siteSession === "ephemeral")) { - setSessionOverride(leaseKey, { lifecycle: cmd.siteSession }); - } - if (cmd.idleTimeout != null && cmd.idleTimeout > 0) { - setSessionOverride(leaseKey, { idleTimeoutMs: cmd.idleTimeout * 1e3 }); - } - resetWindowIdleTimer(leaseKey); - activeCommandCounts.set(leaseKey, (activeCommandCounts.get(leaseKey) ?? 0) + 1); - try { - switch (cmd.action) { - case "exec": - return await handleExec(cmd, leaseKey); - case "navigate": - return await handleNavigate(cmd, leaseKey); - case "tabs": - return await handleTabs(cmd, leaseKey); - case "cookies": - return await handleCookies(cmd); - case "screenshot": - return await handleScreenshot(cmd, leaseKey); - case "close-window": - return await handleCloseWindow(cmd, leaseKey); - case "cdp": - return await handleCdp(cmd, leaseKey); - case "set-file-input": - return await handleSetFileInput(cmd, leaseKey); - case "insert-text": - return await handleInsertText(cmd, leaseKey); - case "bind": - return await handleBind(cmd, leaseKey); - case "network-capture-start": - return await handleNetworkCaptureStart(cmd, leaseKey); - case "network-capture-read": - return await handleNetworkCaptureRead(cmd, leaseKey); - case "wait-download": - return await handleWaitDownload(cmd); - case "frames": - return await handleFrames(cmd, leaseKey); - default: - return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; - } - } catch (err) { - return errorResult(cmd.id, err); - } finally { - const remaining = (activeCommandCounts.get(leaseKey) ?? 1) - 1; - if (remaining <= 0) activeCommandCounts.delete(leaseKey); - else activeCommandCounts.set(leaseKey, remaining); - resetWindowIdleTimer(leaseKey); - } -} -const BLANK_PAGE = "about:blank"; + const session = getSessionName(cmd.session); + const surface = getCommandSurface(cmd); + const leaseKey = getLeaseKey(session, surface); + if (cmd.windowMode === "foreground" || cmd.windowMode === "background") setSessionOverride(leaseKey, { windowMode: cmd.windowMode }); + if (surface === "adapter" && (cmd.siteSession === "persistent" || cmd.siteSession === "ephemeral")) setSessionOverride(leaseKey, { lifecycle: cmd.siteSession }); + if (cmd.idleTimeout != null && cmd.idleTimeout > 0) setSessionOverride(leaseKey, { idleTimeoutMs: cmd.idleTimeout * 1e3 }); + resetWindowIdleTimer(leaseKey); + activeCommandCounts.set(leaseKey, (activeCommandCounts.get(leaseKey) ?? 0) + 1); + try { + switch (cmd.action) { + case "exec": return await handleExec(cmd, leaseKey); + case "navigate": return await handleNavigate(cmd, leaseKey); + case "tabs": return await handleTabs(cmd, leaseKey); + case "cookies": return await handleCookies(cmd); + case "screenshot": return await handleScreenshot(cmd, leaseKey); + case "close-window": return await handleCloseWindow(cmd, leaseKey); + case "cdp": return await handleCdp(cmd, leaseKey); + case "set-file-input": return await handleSetFileInput(cmd, leaseKey); + case "insert-text": return await handleInsertText(cmd, leaseKey); + case "bind": return await handleBind(cmd, leaseKey); + case "network-capture-start": return await handleNetworkCaptureStart(cmd, leaseKey); + case "network-capture-read": return await handleNetworkCaptureRead(cmd, leaseKey); + case "wait-download": return await handleWaitDownload(cmd); + case "frames": return await handleFrames(cmd, leaseKey); + default: return { + id: cmd.id, + ok: false, + error: `Unknown action: ${cmd.action}` + }; + } + } catch (err) { + return errorResult(cmd.id, err); + } finally { + const remaining = (activeCommandCounts.get(leaseKey) ?? 1) - 1; + if (remaining <= 0) activeCommandCounts.delete(leaseKey); + else activeCommandCounts.set(leaseKey, remaining); + resetWindowIdleTimer(leaseKey); + } +} +/** Internal blank page used when no user URL is provided. */ +var BLANK_PAGE = "about:blank"; +/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ function isDebuggableUrl(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); } +/** Check if a URL is safe for user-facing navigation (http/https only). */ function isSafeNavigationUrl(url) { - return url.startsWith("http://") || url.startsWith("https://"); + return url.startsWith("http://") || url.startsWith("https://"); } +/** Minimal URL normalization for same-page comparison: root slash + default port only. */ function normalizeUrlForComparison(url) { - if (!url) return ""; - try { - const parsed = new URL(url); - if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") { - parsed.port = ""; - } - const pathname = parsed.pathname === "/" ? "" : parsed.pathname; - return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; - } catch { - return url; - } + if (!url) return ""; + try { + const parsed = new URL(url); + if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") parsed.port = ""; + const pathname = parsed.pathname === "/" ? "" : parsed.pathname; + return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; + } catch { + return url; + } } function isTargetUrl(currentUrl, targetUrl) { - return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); + return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); } function getUrlOrigin(url) { - if (!url) return null; - try { - return new URL(url).origin; - } catch { - return null; - } + if (!url) return null; + try { + return new URL(url).origin; + } catch { + return null; + } } function enumerateCrossOriginFrames(tree) { - const frames = []; - function collect(node, accessibleOrigin) { - for (const child of node.childFrames || []) { - const frame = child.frame; - const frameUrl = frame.url || frame.unreachableUrl || ""; - const frameOrigin = getUrlOrigin(frameUrl); - if (accessibleOrigin && frameOrigin && frameOrigin === accessibleOrigin) { - collect(child, frameOrigin); - continue; - } - frames.push({ - index: frames.length, - frameId: frame.id, - url: frameUrl, - name: frame.name || "" - }); - } - } - const rootFrame = tree?.frameTree?.frame; - const rootUrl = rootFrame?.url || rootFrame?.unreachableUrl || ""; - collect(tree.frameTree, getUrlOrigin(rootUrl)); - return frames; + const frames = []; + function collect(node, accessibleOrigin) { + for (const child of node.childFrames || []) { + const frame = child.frame; + const frameUrl = frame.url || frame.unreachableUrl || ""; + const frameOrigin = getUrlOrigin(frameUrl); + if (accessibleOrigin && frameOrigin && frameOrigin === accessibleOrigin) { + collect(child, frameOrigin); + continue; + } + frames.push({ + index: frames.length, + frameId: frame.id, + url: frameUrl, + name: frame.name || "" + }); + } + } + const rootFrame = tree?.frameTree?.frame; + const rootUrl = rootFrame?.url || rootFrame?.unreachableUrl || ""; + collect(tree.frameTree, getUrlOrigin(rootUrl)); + return frames; } function setLeaseSession(leaseKey, session) { - const existing = automationSessions.get(leaseKey); - if (existing?.idleTimer) clearTimeout(existing.idleTimer); - const timeout = getIdleTimeout(leaseKey); - automationSessions.set(leaseKey, { - ...makeSession(leaseKey, session), - idleTimer: null, - idleDeadlineAt: timeout <= 0 ? 0 : Date.now() + timeout - }); - void persistRuntimeState(); -} + const existing = automationSessions.get(leaseKey); + if (existing?.idleTimer) clearTimeout(existing.idleTimer); + const timeout = getIdleTimeout(leaseKey); + automationSessions.set(leaseKey, { + ...makeSession(leaseKey, session), + idleTimer: null, + idleDeadlineAt: timeout <= 0 ? 0 : Date.now() + timeout + }); + persistRuntimeState(); +} +/** +* Resolve tabId from command's page (targetId). +* Returns undefined if no page identity is provided. +*/ async function resolveCommandTabId(cmd) { - if (cmd.page) return resolveTabId$1(cmd.page); - return void 0; + if (cmd.page) return resolveTabId$1(cmd.page); } +/** +* Resolve target tab for the session lease, returning both the tabId and +* the Tab object (when available) so callers can skip a redundant chrome.tabs.get(). +*/ async function resolveTab(tabId, leaseKey, initialUrl) { - const existingSession = automationSessions.get(leaseKey); - if (tabId !== void 0) { - try { - const tab = await chrome.tabs.get(tabId); - const session = existingSession; - const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false; - if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab }; - if (session && !session.owned) { - throw new CommandFailure( - matchesSession ? "bound_tab_not_debuggable" : "bound_tab_mismatch", - matchesSession ? `Bound tab for session "${session.session}" is not debuggable (${tab.url ?? "unknown URL"}).` : `Target tab is not the tab bound to session "${session.session}".`, - 'Run "opencli browser bind" again on a debuggable http(s) tab.' - ); - } - if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) { - console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); - try { - await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 }); - const moved = await chrome.tabs.get(tabId); - if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) { - return { tabId, tab: moved }; - } - } catch (moveErr) { - console.warn(`[opencli] Failed to move tab back: ${moveErr}`); - } - } else if (!isDebuggableUrl(tab.url)) { - console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); - } - } catch (err) { - if (err instanceof CommandFailure) throw err; - if (existingSession && !existingSession.owned) { - automationSessions.delete(leaseKey); - throw new CommandFailure( - "bound_tab_gone", - `Bound tab for session "${existingSession.session}" no longer exists.`, - 'Run "opencli browser bind" again, then retry the command.' - ); - } - console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); - } - } - const existingPreferredTabId = existingSession?.preferredTabId ?? null; - if (existingSession && existingPreferredTabId !== null) { - const session = existingSession; - try { - const preferredTab = await chrome.tabs.get(existingPreferredTabId); - if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id, tab: preferredTab }; - if (!session.owned) { - throw new CommandFailure( - "bound_tab_not_debuggable", - `Bound tab for session "${session.session}" is not debuggable (${preferredTab.url ?? "unknown URL"}).`, - 'Switch the tab to an http(s) page or run "opencli browser bind" on another tab.' - ); - } - } catch (err) { - if (err instanceof CommandFailure) throw err; - await removeLeaseSession(leaseKey); - if (!session.owned) { - throw new CommandFailure( - "bound_tab_gone", - `Bound tab for session "${session.session}" no longer exists.`, - 'Run "opencli browser bind" again, then retry the command.' - ); - } - return createOwnedTabLease(leaseKey, initialUrl); - } - } - if (!existingSession || existingSession.owned && existingSession.preferredTabId === null) { - return createOwnedTabLease(leaseKey, initialUrl); - } - const windowId = await getAutomationWindow(leaseKey, initialUrl); - const role = getOwnedWindowRole(leaseKey); - const group = existingSession?.owned ? await ensureOwnedContainerGroup(role, windowId, []) : null; - const scopedWindowId = group?.windowId ?? windowId; - const reusableTabId = await findReusableOwnedContainerTab(scopedWindowId, existingSession?.owned ? group?.id ?? null : void 0); - if (reusableTabId !== void 0) return { tabId: reusableTabId, tab: await chrome.tabs.get(reusableTabId) }; - const tabs = await chrome.tabs.query({ windowId: scopedWindowId }); - const reuseTab = existingSession?.owned ? void 0 : tabs.find((t) => t.id); - if (reuseTab?.id) { - await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); - await new Promise((resolve) => setTimeout(resolve, 300)); - try { - const updated = await chrome.tabs.get(reuseTab.id); - if (isDebuggableUrl(updated.url)) return { tabId: reuseTab.id, tab: updated }; - console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); - } catch { - } - } - const newTab = await chrome.tabs.create({ windowId: scopedWindowId, url: BLANK_PAGE, active: true }); - if (!newTab.id) throw new Error("Failed to create tab in automation container"); - await ensureOwnedContainerGroup(role, scopedWindowId, [newTab.id]); - return { tabId: newTab.id, tab: await chrome.tabs.get(newTab.id) }; -} + const existingSession = automationSessions.get(leaseKey); + if (tabId !== void 0) try { + const tab = await chrome.tabs.get(tabId); + const session = existingSession; + const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false; + if (isDebuggableUrl(tab.url) && matchesSession) return { + tabId, + tab + }; + if (session && !session.owned) throw new CommandFailure(matchesSession ? "bound_tab_not_debuggable" : "bound_tab_mismatch", matchesSession ? `Bound tab for session "${session.session}" is not debuggable (${tab.url ?? "unknown URL"}).` : `Target tab is not the tab bound to session "${session.session}".`, "Run \"opencli browser bind\" again on a debuggable http(s) tab."); + if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) { + console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); + try { + await chrome.tabs.move(tabId, { + windowId: session.windowId, + index: -1 + }); + const moved = await chrome.tabs.get(tabId); + if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) return { + tabId, + tab: moved + }; + } catch (moveErr) { + console.warn(`[opencli] Failed to move tab back: ${moveErr}`); + } + } else if (!isDebuggableUrl(tab.url)) console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); + } catch (err) { + if (err instanceof CommandFailure) throw err; + if (existingSession && !existingSession.owned) { + automationSessions.delete(leaseKey); + throw new CommandFailure("bound_tab_gone", `Bound tab for session "${existingSession.session}" no longer exists.`, "Run \"opencli browser bind\" again, then retry the command."); + } + console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); + } + const existingPreferredTabId = existingSession?.preferredTabId ?? null; + if (existingSession && existingPreferredTabId !== null) { + const session = existingSession; + try { + const preferredTab = await chrome.tabs.get(existingPreferredTabId); + if (isDebuggableUrl(preferredTab.url)) return { + tabId: preferredTab.id, + tab: preferredTab + }; + if (!session.owned) throw new CommandFailure("bound_tab_not_debuggable", `Bound tab for session "${session.session}" is not debuggable (${preferredTab.url ?? "unknown URL"}).`, "Switch the tab to an http(s) page or run \"opencli browser bind\" on another tab."); + } catch (err) { + if (err instanceof CommandFailure) throw err; + await removeLeaseSession(leaseKey); + if (!session.owned) throw new CommandFailure("bound_tab_gone", `Bound tab for session "${session.session}" no longer exists.`, "Run \"opencli browser bind\" again, then retry the command."); + return createOwnedTabLease(leaseKey, initialUrl); + } + } + if (!existingSession || existingSession.owned && existingSession.preferredTabId === null) return createOwnedTabLease(leaseKey, initialUrl); + const windowId = await getAutomationWindow(leaseKey, initialUrl); + const role = getOwnedWindowRole(leaseKey); + const group = existingSession?.owned ? await ensureOwnedContainerGroup(role, windowId, []) : null; + const scopedWindowId = group?.windowId ?? windowId; + const reusableTabId = await findReusableOwnedContainerTab(scopedWindowId, existingSession?.owned ? group?.id ?? null : void 0); + if (reusableTabId !== void 0) return { + tabId: reusableTabId, + tab: await chrome.tabs.get(reusableTabId) + }; + const tabs = await chrome.tabs.query({ windowId: scopedWindowId }); + const reuseTab = existingSession?.owned ? void 0 : tabs.find((t) => t.id); + if (reuseTab?.id) { + await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); + await new Promise((resolve) => setTimeout(resolve, 300)); + try { + const updated = await chrome.tabs.get(reuseTab.id); + if (isDebuggableUrl(updated.url)) return { + tabId: reuseTab.id, + tab: updated + }; + console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); + } catch {} + } + const newTab = await chrome.tabs.create({ + windowId: scopedWindowId, + url: BLANK_PAGE, + active: true + }); + if (!newTab.id) throw new Error("Failed to create tab in automation container"); + await ensureOwnedContainerGroup(role, scopedWindowId, [newTab.id]); + return { + tabId: newTab.id, + tab: await chrome.tabs.get(newTab.id) + }; +} +/** Build a page-scoped success result with targetId resolved from tabId */ async function pageScopedResult(id, tabId, data) { - const page = await resolveTargetId(tabId); - return { id, ok: true, data, page }; -} + return { + id, + ok: true, + data, + page: await resolveTargetId(tabId) + }; +} +/** Convenience wrapper returning just the tabId (used by most handlers) */ async function resolveTabId(tabId, leaseKey, initialUrl) { - const resolved = await resolveTab(tabId, leaseKey, initialUrl); - return resolved.tabId; + return (await resolveTab(tabId, leaseKey, initialUrl)).tabId; } async function listAutomationTabs(leaseKey) { - const session = automationSessions.get(leaseKey); - if (!session) return []; - if (session.preferredTabId !== null) { - try { - return [await chrome.tabs.get(session.preferredTabId)]; - } catch { - automationSessions.delete(leaseKey); - return []; - } - } - try { - return await chrome.tabs.query({ windowId: session.windowId }); - } catch { - automationSessions.delete(leaseKey); - return []; - } + const session = automationSessions.get(leaseKey); + if (!session) return []; + if (session.preferredTabId !== null) try { + return [await chrome.tabs.get(session.preferredTabId)]; + } catch { + automationSessions.delete(leaseKey); + return []; + } + try { + return await chrome.tabs.query({ windowId: session.windowId }); + } catch { + automationSessions.delete(leaseKey); + return []; + } } async function listAutomationWebTabs(leaseKey) { - const tabs = await listAutomationTabs(leaseKey); - return tabs.filter((tab) => isDebuggableUrl(tab.url)); -} + return (await listAutomationTabs(leaseKey)).filter((tab) => isDebuggableUrl(tab.url)); +} +/** +* Derive the per-command CDP deadline from the command's absolute deadline +* (preferred — remaining budget absorbs service-worker wake and queueing +* latency) or the legacy duration field. Undercut by 5s so this (more +* specific) error reaches the CLI before the daemon's generic timer fires. +* Returns undefined when the command carries neither — callers fall back to +* the executor's default deadline. +*/ function commandCdpTimeoutMs(cmd) { - if (typeof cmd.deadlineAt === "number" && cmd.deadlineAt > 0) { - return Math.max(1e4, cmd.deadlineAt - Date.now() - 5e3); - } - if (typeof cmd.timeout === "number" && cmd.timeout > 0) { - return Math.max(1e4, cmd.timeout * 1e3 - 5e3); - } - return void 0; -} + if (typeof cmd.deadlineAt === "number" && cmd.deadlineAt > 0) return Math.max(1e4, cmd.deadlineAt - Date.now() - 5e3); + if (typeof cmd.timeout === "number" && cmd.timeout > 0) return Math.max(1e4, cmd.timeout * 1e3 - 5e3); +} +/** +* Map an executor error to a machine-readable code so the CLI can decide +* retry safety without regex-matching message text: +* - `attach_failed` / `tab_gone`: failed BEFORE any page code ran — a new +* logical attempt is safe; +* - `target_navigated`: the document changed under the command — the page +* layer decides whether to settle-retry; +* - `detached_mid_command` / `cdp_timeout`: died MID-execution — the outcome +* is unknown, a blind re-run could double-apply a write. +*/ function classifyExtensionError(message) { - if (/Inspected target navigated|Target closed/.test(message)) return "target_navigated"; - if (/Detached while handling command/.test(message)) return "detached_mid_command"; - if (/CDP command .* timed out/.test(message)) return "cdp_timeout"; - if (/attach failed|Debugger is not attached/.test(message)) return "attach_failed"; - if (/No tab with id|no longer exists|No window with id/.test(message)) return "tab_gone"; - return void 0; + if (/Inspected target navigated|Target closed/.test(message)) return "target_navigated"; + if (/Detached while handling command/.test(message)) return "detached_mid_command"; + if (/CDP command .* timed out/.test(message)) return "cdp_timeout"; + if (/attach failed|Debugger is not attached/.test(message)) return "attach_failed"; + if (/No tab with id|no longer exists|No window with id/.test(message)) return "tab_gone"; } function errorResult(id, err) { - const message = err instanceof Error ? err.message : String(err); - if (err instanceof CommandFailure) { - return { id, ok: false, error: message, errorCode: err.code, ...err.hint ? { errorHint: err.hint } : {} }; - } - const errorCode = classifyExtensionError(message); - return { id, ok: false, error: message, ...errorCode ? { errorCode } : {} }; + const message = err instanceof Error ? err.message : String(err); + if (err instanceof CommandFailure) return { + id, + ok: false, + error: message, + errorCode: err.code, + ...err.hint ? { errorHint: err.hint } : {} + }; + const errorCode = classifyExtensionError(message); + return { + id, + ok: false, + error: message, + ...errorCode ? { errorCode } : {} + }; } async function handleExec(cmd, leaseKey) { - if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; - const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, leaseKey); - try { - const aggressive = getSurfaceFromKey(leaseKey) === "browser"; - if (cmd.frameIndex != null) { - const tree = await getFrameTree(tabId); - const frames = enumerateCrossOriginFrames(tree); - if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) { - return { id: cmd.id, ok: false, error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` }; - } - const data2 = await evaluateInFrame(tabId, cmd.code, frames[cmd.frameIndex].frameId, aggressive, commandCdpTimeoutMs(cmd)); - return pageScopedResult(cmd.id, tabId, data2); - } - const data = await evaluateAsync(tabId, cmd.code, aggressive, commandCdpTimeoutMs(cmd)); - return pageScopedResult(cmd.id, tabId, data); - } catch (err) { - return errorResult(cmd.id, err); - } + if (!cmd.code) return { + id: cmd.id, + ok: false, + error: "Missing code" + }; + const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); + try { + const aggressive = getSurfaceFromKey(leaseKey) === "browser"; + if (cmd.frameIndex != null) { + const frames = enumerateCrossOriginFrames(await getFrameTree(tabId)); + if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) return { + id: cmd.id, + ok: false, + error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` + }; + const data = await evaluateInFrame(tabId, cmd.code, frames[cmd.frameIndex].frameId, aggressive, commandCdpTimeoutMs(cmd)); + return pageScopedResult(cmd.id, tabId, data); + } + const data = await evaluateAsync(tabId, cmd.code, aggressive, commandCdpTimeoutMs(cmd)); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return errorResult(cmd.id, err); + } } async function handleFrames(cmd, leaseKey) { - const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, leaseKey); - try { - const tree = await getFrameTree(tabId); - return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) }; - } catch (err) { - return errorResult(cmd.id, err); - } + const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); + try { + const tree = await getFrameTree(tabId); + return { + id: cmd.id, + ok: true, + data: enumerateCrossOriginFrames(tree) + }; + } catch (err) { + return errorResult(cmd.id, err); + } } async function handleNavigate(cmd, leaseKey) { - if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" }; - if (!isSafeNavigationUrl(cmd.url)) { - return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; - } - const cmdTabId = await resolveCommandTabId(cmd); - const resolved = await resolveTab(cmdTabId, leaseKey, cmd.url); - const tabId = resolved.tabId; - const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); - const beforeNormalized = normalizeUrlForComparison(beforeTab.url); - const targetUrl = cmd.url; - if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) { - return pageScopedResult(cmd.id, tabId, { title: beforeTab.title, url: beforeTab.url, timedOut: false }); - } - if (!hasActiveNetworkCapture(tabId)) { - await detach(tabId); - } - await chrome.tabs.update(tabId, { url: targetUrl }); - let timedOut = false; - await new Promise((resolve) => { - let settled = false; - let checkTimer = null; - let timeoutTimer = null; - const finish = () => { - if (settled) return; - settled = true; - chrome.tabs.onUpdated.removeListener(listener); - if (checkTimer) clearTimeout(checkTimer); - if (timeoutTimer) clearTimeout(timeoutTimer); - resolve(); - }; - const isNavigationDone = (url) => { - return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; - }; - const listener = (id, info, tab2) => { - if (id !== tabId) return; - if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) { - finish(); - } - }; - chrome.tabs.onUpdated.addListener(listener); - checkTimer = setTimeout(async () => { - try { - const currentTab = await chrome.tabs.get(tabId); - if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) { - finish(); - } - } catch { - } - }, 100); - timeoutTimer = setTimeout(() => { - timedOut = true; - console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); - finish(); - }, 15e3); - }); - let tab = await chrome.tabs.get(tabId); - const postNavigationSession = automationSessions.get(leaseKey); - if (postNavigationSession && tab.windowId !== postNavigationSession.windowId) { - console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${postNavigationSession.windowId}`); - try { - await chrome.tabs.move(tabId, { windowId: postNavigationSession.windowId, index: -1 }); - tab = await chrome.tabs.get(tabId); - } catch (moveErr) { - console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); - } - } - return pageScopedResult(cmd.id, tabId, { title: tab.title, url: tab.url, timedOut }); + if (!cmd.url) return { + id: cmd.id, + ok: false, + error: "Missing url" + }; + if (!isSafeNavigationUrl(cmd.url)) return { + id: cmd.id, + ok: false, + error: "Blocked URL scheme -- only http:// and https:// are allowed" + }; + const resolved = await resolveTab(await resolveCommandTabId(cmd), leaseKey, cmd.url); + const tabId = resolved.tabId; + const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); + const beforeNormalized = normalizeUrlForComparison(beforeTab.url); + const targetUrl = cmd.url; + if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) return pageScopedResult(cmd.id, tabId, { + title: beforeTab.title, + url: beforeTab.url, + timedOut: false + }); + if (!hasActiveNetworkCapture(tabId)) await detach(tabId); + await chrome.tabs.update(tabId, { url: targetUrl }); + let timedOut = false; + await new Promise((resolve) => { + let settled = false; + let checkTimer = null; + let timeoutTimer = null; + const finish = () => { + if (settled) return; + settled = true; + chrome.tabs.onUpdated.removeListener(listener); + if (checkTimer) clearTimeout(checkTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + resolve(); + }; + const isNavigationDone = (url) => { + return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; + }; + const listener = (id, info, tab) => { + if (id !== tabId) return; + if (info.status === "complete" && isNavigationDone(tab.url ?? info.url)) finish(); + }; + chrome.tabs.onUpdated.addListener(listener); + checkTimer = setTimeout(async () => { + try { + const currentTab = await chrome.tabs.get(tabId); + if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) finish(); + } catch {} + }, 100); + timeoutTimer = setTimeout(() => { + timedOut = true; + console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); + finish(); + }, 15e3); + }); + let tab = await chrome.tabs.get(tabId); + const postNavigationSession = automationSessions.get(leaseKey); + if (postNavigationSession && tab.windowId !== postNavigationSession.windowId) { + console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${postNavigationSession.windowId}`); + try { + await chrome.tabs.move(tabId, { + windowId: postNavigationSession.windowId, + index: -1 + }); + tab = await chrome.tabs.get(tabId); + } catch (moveErr) { + console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); + } + } + return pageScopedResult(cmd.id, tabId, { + title: tab.title, + url: tab.url, + timedOut + }); } async function handleTabs(cmd, leaseKey) { - const session = automationSessions.get(leaseKey); - if (session && !session.owned && cmd.op !== "list") { - return { - id: cmd.id, - ok: false, - errorCode: "bound_tab_mutation_blocked", - error: `Session "${session.session}" is bound to a user tab; tab new/select/close requires an owned OpenCLI session.`, - errorHint: "Unbind the session first, or use a different session for owned OpenCLI tabs." - }; - } - switch (cmd.op) { - case "list": { - const tabs = await listAutomationWebTabs(leaseKey); - const data = await Promise.all(tabs.map(async (t, i) => { - let page; - try { - page = t.id ? await resolveTargetId(t.id) : void 0; - } catch { - } - return { index: i, page, url: t.url, title: t.title, active: t.active }; - })); - return { id: cmd.id, ok: true, data }; - } - case "new": { - if (cmd.url && !isSafeNavigationUrl(cmd.url)) { - return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; - } - if (!automationSessions.has(leaseKey)) { - const created = await createOwnedTabLease(leaseKey, cmd.url); - return pageScopedResult(cmd.id, created.tabId, { url: created.tab?.url }); - } - const windowId = await getAutomationWindow(leaseKey); - let tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); - const tabId = tab.id; - if (!tabId) return { id: cmd.id, ok: false, error: "Failed to create tab" }; - const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), windowId, [tabId]); - const sessionWindowId = group?.windowId ?? tab.windowId; - if (tab.windowId !== sessionWindowId) tab = await chrome.tabs.get(tabId); - setLeaseSession(leaseKey, { - session: getSessionFromKey(leaseKey), - surface: getSurfaceFromKey(leaseKey), - kind: "owned", - windowId: sessionWindowId, - owned: true, - preferredTabId: tabId - }); - resetWindowIdleTimer(leaseKey); - return pageScopedResult(cmd.id, tabId, { url: tab.url }); - } - case "close": { - if (cmd.index !== void 0) { - const tabs = await listAutomationWebTabs(leaseKey); - const target = tabs[cmd.index]; - if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; - const closedPage2 = await resolveTargetId(target.id).catch(() => void 0); - const currentSession2 = automationSessions.get(leaseKey); - if (currentSession2?.preferredTabId === target.id) { - await releaseLease(leaseKey, "tab close"); - } else { - await safeDetach(target.id); - await chrome.tabs.remove(target.id); - } - return { id: cmd.id, ok: true, data: { closed: closedPage2 } }; - } - const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, leaseKey); - const closedPage = await resolveTargetId(tabId).catch(() => void 0); - const currentSession = automationSessions.get(leaseKey); - if (currentSession?.preferredTabId === tabId) { - await releaseLease(leaseKey, "tab close"); - } else { - await safeDetach(tabId); - await chrome.tabs.remove(tabId); - } - return { id: cmd.id, ok: true, data: { closed: closedPage } }; - } - case "select": { - if (cmd.index === void 0 && cmd.page === void 0) - return { id: cmd.id, ok: false, error: "Missing index or page" }; - const cmdTabId = await resolveCommandTabId(cmd); - if (cmdTabId !== void 0) { - const session2 = automationSessions.get(leaseKey); - let tab; - try { - tab = await chrome.tabs.get(cmdTabId); - } catch { - return { id: cmd.id, ok: false, error: `Page no longer exists` }; - } - if (!session2 || tab.windowId !== session2.windowId) { - return { id: cmd.id, ok: false, error: `Page is not in the automation container` }; - } - await chrome.tabs.update(cmdTabId, { active: true }); - return pageScopedResult(cmd.id, cmdTabId, { selected: true }); - } - const tabs = await listAutomationWebTabs(leaseKey); - const target = tabs[cmd.index]; - if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; - await chrome.tabs.update(target.id, { active: true }); - return pageScopedResult(cmd.id, target.id, { selected: true }); - } - default: - return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` }; - } + const session = automationSessions.get(leaseKey); + if (session && !session.owned && cmd.op !== "list" && cmd.op !== "current-window") return { + id: cmd.id, + ok: false, + errorCode: "bound_tab_mutation_blocked", + error: `Session "${session.session}" is bound to a user tab; tab new/select/close requires an owned OpenCLI session.`, + errorHint: "Unbind the session first, or use a different session for owned OpenCLI tabs." + }; + switch (cmd.op) { + case "list": { + const tabs = await listAutomationWebTabs(leaseKey); + const data = await Promise.all(tabs.map(async (t, i) => { + let page; + try { + page = t.id ? await resolveTargetId(t.id) : void 0; + } catch {} + return { + index: i, + page, + url: t.url, + title: t.title, + active: t.active + }; + })); + return { + id: cmd.id, + ok: true, + data + }; + } + case "current-window": { + const tabs = (await chrome.tabs.query({ lastFocusedWindow: true })).filter((t) => isDebuggableUrl(t.url)); + const data = await Promise.all(tabs.map(async (t, i) => { + let page; + try { + page = t.id ? await resolveTargetId(t.id) : void 0; + } catch {} + return { + index: i, + page, + tabId: t.id, + windowId: t.windowId, + url: t.url, + title: t.title, + active: t.active, + pinned: t.pinned + }; + })); + return { + id: cmd.id, + ok: true, + data + }; + } + case "new": { + if (cmd.url && !isSafeNavigationUrl(cmd.url)) return { + id: cmd.id, + ok: false, + error: "Blocked URL scheme -- only http:// and https:// are allowed" + }; + if (!automationSessions.has(leaseKey)) { + const created = await createOwnedTabLease(leaseKey, cmd.url); + return pageScopedResult(cmd.id, created.tabId, { url: created.tab?.url }); + } + const windowId = await getAutomationWindow(leaseKey); + let tab = await chrome.tabs.create({ + windowId, + url: cmd.url ?? BLANK_PAGE, + active: true + }); + const tabId = tab.id; + if (!tabId) return { + id: cmd.id, + ok: false, + error: "Failed to create tab" + }; + const sessionWindowId = (await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), windowId, [tabId]))?.windowId ?? tab.windowId; + if (tab.windowId !== sessionWindowId) tab = await chrome.tabs.get(tabId); + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: "owned", + windowId: sessionWindowId, + owned: true, + preferredTabId: tabId + }); + resetWindowIdleTimer(leaseKey); + return pageScopedResult(cmd.id, tabId, { url: tab.url }); + } + case "close": { + if (cmd.index !== void 0) { + const target = (await listAutomationWebTabs(leaseKey))[cmd.index]; + if (!target?.id) return { + id: cmd.id, + ok: false, + error: `Tab index ${cmd.index} not found` + }; + const closedPage = await resolveTargetId(target.id).catch(() => void 0); + if (automationSessions.get(leaseKey)?.preferredTabId === target.id) await releaseLease(leaseKey, "tab close"); + else { + await safeDetach(target.id); + await chrome.tabs.remove(target.id); + } + return { + id: cmd.id, + ok: true, + data: { closed: closedPage } + }; + } + const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); + const closedPage = await resolveTargetId(tabId).catch(() => void 0); + if (automationSessions.get(leaseKey)?.preferredTabId === tabId) await releaseLease(leaseKey, "tab close"); + else { + await safeDetach(tabId); + await chrome.tabs.remove(tabId); + } + return { + id: cmd.id, + ok: true, + data: { closed: closedPage } + }; + } + case "select": { + if (cmd.index === void 0 && cmd.page === void 0) return { + id: cmd.id, + ok: false, + error: "Missing index or page" + }; + const cmdTabId = await resolveCommandTabId(cmd); + if (cmdTabId !== void 0) { + const session = automationSessions.get(leaseKey); + let tab; + try { + tab = await chrome.tabs.get(cmdTabId); + } catch { + return { + id: cmd.id, + ok: false, + error: `Page no longer exists` + }; + } + if (!session || tab.windowId !== session.windowId) return { + id: cmd.id, + ok: false, + error: `Page is not in the automation container` + }; + await chrome.tabs.update(cmdTabId, { active: true }); + return pageScopedResult(cmd.id, cmdTabId, { selected: true }); + } + const target = (await listAutomationWebTabs(leaseKey))[cmd.index]; + if (!target?.id) return { + id: cmd.id, + ok: false, + error: `Tab index ${cmd.index} not found` + }; + await chrome.tabs.update(target.id, { active: true }); + return pageScopedResult(cmd.id, target.id, { selected: true }); + } + default: return { + id: cmd.id, + ok: false, + error: `Unknown tabs op: ${cmd.op}` + }; + } } async function handleCookies(cmd) { - if (!cmd.domain && !cmd.url) { - return { id: cmd.id, ok: false, error: "Cookie scope required: provide domain or url to avoid dumping all cookies" }; - } - const details = {}; - if (cmd.domain) details.domain = cmd.domain; - if (cmd.url) details.url = cmd.url; - const cookies = await chrome.cookies.getAll(details); - const data = cookies.map((c) => ({ - name: c.name, - value: c.value, - domain: c.domain, - path: c.path, - secure: c.secure, - httpOnly: c.httpOnly, - expirationDate: c.expirationDate - })); - return { id: cmd.id, ok: true, data }; + if (!cmd.domain && !cmd.url) return { + id: cmd.id, + ok: false, + error: "Cookie scope required: provide domain or url to avoid dumping all cookies" + }; + const details = {}; + if (cmd.domain) details.domain = cmd.domain; + if (cmd.url) details.url = cmd.url; + const data = (await chrome.cookies.getAll(details)).map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + secure: c.secure, + httpOnly: c.httpOnly, + expirationDate: c.expirationDate + })); + return { + id: cmd.id, + ok: true, + data + }; } async function handleScreenshot(cmd, leaseKey) { - const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, leaseKey); - try { - const data = await screenshot(tabId, { - format: cmd.format, - quality: cmd.quality, - fullPage: cmd.fullPage, - width: cmd.width, - height: cmd.height - }); - return pageScopedResult(cmd.id, tabId, data); - } catch (err) { - return errorResult(cmd.id, err); - } -} -const CDP_ALLOWLIST = /* @__PURE__ */ new Set([ - // Agent DOM context - "Accessibility.enable", - "Accessibility.getFullAXTree", - "DOM.enable", - "DOM.getDocument", - "DOM.getBoxModel", - "DOM.getContentQuads", - "DOM.focus", - "DOM.querySelector", - "DOM.querySelectorAll", - "DOM.scrollIntoViewIfNeeded", - "DOMSnapshot.captureSnapshot", - // Native input events - "Input.dispatchMouseEvent", - "Input.dispatchKeyEvent", - "Input.insertText", - // Page metrics & screenshots - "Page.getLayoutMetrics", - "Page.captureScreenshot", - "Page.getFrameTree", - "Page.handleJavaScriptDialog", - // Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action) - "Runtime.enable", - // Emulation (used by screenshot full-page) - "Emulation.setDeviceMetricsOverride", - "Emulation.clearDeviceMetricsOverride" + const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); + try { + const data = await screenshot(tabId, { + format: cmd.format, + quality: cmd.quality, + fullPage: cmd.fullPage, + width: cmd.width, + height: cmd.height + }); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return errorResult(cmd.id, err); + } +} +/** CDP methods permitted via the 'cdp' passthrough action. */ +var CDP_ALLOWLIST = new Set([ + "Accessibility.enable", + "Accessibility.getFullAXTree", + "DOM.enable", + "DOM.getDocument", + "DOM.getBoxModel", + "DOM.getContentQuads", + "DOM.focus", + "DOM.querySelector", + "DOM.querySelectorAll", + "DOM.scrollIntoViewIfNeeded", + "DOMSnapshot.captureSnapshot", + "Input.dispatchMouseEvent", + "Input.dispatchKeyEvent", + "Input.insertText", + "Page.getLayoutMetrics", + "Page.captureScreenshot", + "Page.getFrameTree", + "Page.handleJavaScriptDialog", + "Runtime.enable", + "Emulation.setDeviceMetricsOverride", + "Emulation.clearDeviceMetricsOverride" ]); async function handleCdp(cmd, leaseKey) { - if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: "Missing cdpMethod" }; - if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) { - return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` }; - } - const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, leaseKey); - try { - const aggressive = getSurfaceFromKey(leaseKey) === "browser"; - await ensureAttached(tabId, aggressive); - const params = cmd.cdpParams ?? {}; - const routeFrameId = typeof params.frameId === "string" && params.sessionId === "target" ? params.frameId : void 0; - const routeTargetUrl = typeof params.targetUrl === "string" ? params.targetUrl : void 0; - const data = routeFrameId ? await sendCommandInFrameTarget(tabId, routeFrameId, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, true), aggressive, commandCdpTimeoutMs(cmd) ?? 3e4, routeTargetUrl) : await sendDebuggerCommand( - { tabId }, - cmd.cdpMethod, - stripOpenCliFrameRoutingParams(params, false), - commandCdpTimeoutMs(cmd) - ); - return pageScopedResult(cmd.id, tabId, data); - } catch (err) { - return errorResult(cmd.id, err); - } + if (!cmd.cdpMethod) return { + id: cmd.id, + ok: false, + error: "Missing cdpMethod" + }; + if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) return { + id: cmd.id, + ok: false, + error: `CDP method not permitted: ${cmd.cdpMethod}` + }; + const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); + try { + const aggressive = getSurfaceFromKey(leaseKey) === "browser"; + await ensureAttached(tabId, aggressive); + const params = cmd.cdpParams ?? {}; + const routeFrameId = typeof params.frameId === "string" && params.sessionId === "target" ? params.frameId : void 0; + const routeTargetUrl = typeof params.targetUrl === "string" ? params.targetUrl : void 0; + const data = routeFrameId ? await sendCommandInFrameTarget(tabId, routeFrameId, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, true), aggressive, commandCdpTimeoutMs(cmd) ?? 3e4, routeTargetUrl) : await sendDebuggerCommand({ tabId }, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, false), commandCdpTimeoutMs(cmd)); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return errorResult(cmd.id, err); + } } function stripOpenCliFrameRoutingParams(params, stripFrameId) { - const { sessionId, frameId, targetUrl, ...rest } = params; - if (!stripFrameId && frameId !== void 0) return { ...rest, frameId }; - return rest; + const { sessionId, frameId, targetUrl, ...rest } = params; + if (!stripFrameId && frameId !== void 0) return { + ...rest, + frameId + }; + return rest; } async function handleCloseWindow(cmd, leaseKey) { - const sessionName = automationSessions.get(leaseKey)?.session ?? getSessionFromKey(leaseKey); - await releaseLease(leaseKey, "explicit close"); - return { id: cmd.id, ok: true, data: { closed: true, session: sessionName } }; + const sessionName = automationSessions.get(leaseKey)?.session ?? getSessionFromKey(leaseKey); + await releaseLease(leaseKey, "explicit close"); + return { + id: cmd.id, + ok: true, + data: { + closed: true, + session: sessionName + } + }; } async function handleSetFileInput(cmd, leaseKey) { - if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) { - return { id: cmd.id, ok: false, error: "Missing or empty files array" }; - } - const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, leaseKey); - try { - await setFileInputFiles(tabId, cmd.files, cmd.selector); - return pageScopedResult(cmd.id, tabId, { count: cmd.files.length }); - } catch (err) { - return errorResult(cmd.id, err); - } + if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) return { + id: cmd.id, + ok: false, + error: "Missing or empty files array" + }; + const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); + try { + await setFileInputFiles(tabId, cmd.files, cmd.selector); + return pageScopedResult(cmd.id, tabId, { count: cmd.files.length }); + } catch (err) { + return errorResult(cmd.id, err); + } } async function handleInsertText(cmd, leaseKey) { - if (typeof cmd.text !== "string") { - return { id: cmd.id, ok: false, error: "Missing text payload" }; - } - const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, leaseKey); - try { - await insertText(tabId, cmd.text); - return pageScopedResult(cmd.id, tabId, { inserted: true }); - } catch (err) { - return errorResult(cmd.id, err); - } + if (typeof cmd.text !== "string") return { + id: cmd.id, + ok: false, + error: "Missing text payload" + }; + const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); + try { + await insertText(tabId, cmd.text); + return pageScopedResult(cmd.id, tabId, { inserted: true }); + } catch (err) { + return errorResult(cmd.id, err); + } } async function handleNetworkCaptureStart(cmd, leaseKey) { - const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, leaseKey); - try { - await startNetworkCapture(tabId, cmd.pattern); - return pageScopedResult(cmd.id, tabId, { started: true }); - } catch (err) { - return errorResult(cmd.id, err); - } + const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); + try { + await startNetworkCapture(tabId, cmd.pattern); + return pageScopedResult(cmd.id, tabId, { started: true }); + } catch (err) { + return errorResult(cmd.id, err); + } } async function handleNetworkCaptureRead(cmd, leaseKey) { - const cmdTabId = await resolveCommandTabId(cmd); - const tabId = await resolveTabId(cmdTabId, leaseKey); - try { - const data = await readNetworkCapture(tabId); - return pageScopedResult(cmd.id, tabId, data); - } catch (err) { - return errorResult(cmd.id, err); - } + const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); + try { + const data = await readNetworkCapture(tabId); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return errorResult(cmd.id, err); + } } async function handleWaitDownload(cmd) { - try { - const data = await waitForDownload(cmd.pattern ?? "", cmd.timeoutMs ?? 3e4); - return { id: cmd.id, ok: true, data }; - } catch (err) { - return errorResult(cmd.id, err); - } + try { + const data = await waitForDownload(cmd.pattern ?? "", cmd.timeoutMs ?? 3e4); + return { + id: cmd.id, + ok: true, + data + }; + } catch (err) { + return errorResult(cmd.id, err); + } } async function releaseLease(leaseKey, reason = "released") { - const session = automationSessions.get(leaseKey); - if (!session) { - sessionOverrides.delete(leaseKey); - scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); - await persistRuntimeState(); - return; - } - if (session.idleTimer) clearTimeout(session.idleTimer); - scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); - if (session.owned) { - const tabId = session.preferredTabId; - if (tabId !== null) { - const hasOtherOwnedLease = [...automationSessions.entries()].some( - ([otherLease, otherSession]) => otherLease !== leaseKey && otherSession.owned && otherSession.windowId === session.windowId && otherSession.preferredTabId !== null - ); - await safeDetach(tabId); - evictTab(tabId); - if (hasOtherOwnedLease) { - await chrome.tabs.remove(tabId).catch(() => { - }); - console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); - } else { - try { - const tab = await chrome.tabs.update(tabId, { url: BLANK_PAGE, active: true }); - const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), session.windowId, [tab.id ?? tabId]); - if (group) session.windowId = group.windowId; - console.log(`[opencli] Released owned tab lease ${tabId} as reusable placeholder (session=${session.session}, surface=${session.surface}, ${reason})`); - } catch { - await chrome.tabs.remove(tabId).catch(() => { - }); - console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); - } - } - } else { - console.log(`[opencli] Released legacy owned window lease ${session.windowId} without closing container (session=${session.session}, surface=${session.surface}, ${reason})`); - } - } else if (session.preferredTabId !== null) { - await safeDetach(session.preferredTabId); - console.log(`[opencli] Detached borrowed tab lease ${session.preferredTabId} (session=${session.session}, surface=${session.surface}, ${reason})`); - } - automationSessions.delete(leaseKey); - sessionOverrides.delete(leaseKey); - await persistRuntimeState(); + const session = automationSessions.get(leaseKey); + if (!session) { + sessionOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); + await persistRuntimeState(); + return; + } + if (session.idleTimer) clearTimeout(session.idleTimer); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); + if (session.owned) { + const tabId = session.preferredTabId; + if (tabId !== null) { + const hasOtherOwnedLease = [...automationSessions.entries()].some(([otherLease, otherSession]) => otherLease !== leaseKey && otherSession.owned && otherSession.windowId === session.windowId && otherSession.preferredTabId !== null); + await safeDetach(tabId); + evictTab(tabId); + if (hasOtherOwnedLease) { + await chrome.tabs.remove(tabId).catch(() => {}); + console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); + } else try { + const tab = await chrome.tabs.update(tabId, { + url: BLANK_PAGE, + active: true + }); + const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), session.windowId, [tab.id ?? tabId]); + if (group) session.windowId = group.windowId; + console.log(`[opencli] Released owned tab lease ${tabId} as reusable placeholder (session=${session.session}, surface=${session.surface}, ${reason})`); + } catch { + await chrome.tabs.remove(tabId).catch(() => {}); + console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); + } + } else console.log(`[opencli] Released legacy owned window lease ${session.windowId} without closing container (session=${session.session}, surface=${session.surface}, ${reason})`); + } else if (session.preferredTabId !== null) { + await safeDetach(session.preferredTabId); + console.log(`[opencli] Detached borrowed tab lease ${session.preferredTabId} (session=${session.session}, surface=${session.surface}, ${reason})`); + } + automationSessions.delete(leaseKey); + sessionOverrides.delete(leaseKey); + await persistRuntimeState(); } async function reconcileTargetLeaseRegistry() { - const registry = await readRegistry(); - interactiveGroupLedger.clear(); - for (const id of registry.ownedContainers.interactive.groupIds) interactiveGroupLedger.add(id); - for (const role of Object.keys(ownedContainers)) { - ownedContainers[role].windowId = registry.ownedContainers[role]?.windowId ?? null; - const windowId = ownedContainers[role].windowId; - if (windowId !== null) { - try { - await chrome.windows.get(windowId); - } catch { - ownedContainers[role].windowId = null; - } - } - } - automationSessions.clear(); - for (const [leaseKey, stored] of Object.entries(registry.leases)) { - const tabId = stored.preferredTabId; - if (tabId === null) continue; - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl(tab.url)) continue; - if (stored.lifecycle === "ephemeral" || stored.lifecycle === "persistent" || stored.lifecycle === "pinned") { - setSessionOverride(leaseKey, { lifecycle: stored.lifecycle }); - } - const session = makeSession(leaseKey, { - session: typeof stored.session === "string" ? stored.session : getSessionFromKey(leaseKey), - surface: stored.surface === "adapter" ? "adapter" : getSurfaceFromKey(leaseKey), - kind: stored.kind === "bound" || stored.owned === false ? "bound" : "owned", - windowId: tab.windowId, - owned: stored.owned, - preferredTabId: tabId - }); - const timeout = getIdleTimeout(leaseKey); - automationSessions.set(leaseKey, { - ...session, - idleTimer: null, - idleDeadlineAt: stored.idleDeadlineAt - }); - if (session.owned) { - const role = getOwnedWindowRole(leaseKey); - if (ownedContainers[role].windowId === null) ownedContainers[role].windowId = tab.windowId; - const group = await ensureOwnedContainerGroup(role, tab.windowId, [tabId]); - if (group) { - const current = automationSessions.get(leaseKey); - if (current) current.windowId = group.windowId; - } - } - const remaining = stored.idleDeadlineAt > 0 ? stored.idleDeadlineAt - Date.now() : timeout; - if (timeout > 0) { - if (remaining <= 0) { - await releaseLease(leaseKey, "reconciled idle expiry"); - } else { - resetWindowIdleTimer(leaseKey, remaining); - } - } - } catch { - } - } - try { - await ensureOwnedContainerGroup("interactive", null, []); - } catch (err) { - console.warn(`[opencli] Startup interactive group convergence failed: ${err instanceof Error ? err.message : String(err)}`); - } - await persistRuntimeState(); + const registry = await readRegistry(); + interactiveGroupLedger.clear(); + for (const id of registry.ownedContainers.interactive.groupIds) interactiveGroupLedger.add(id); + for (const role of Object.keys(ownedContainers)) { + ownedContainers[role].windowId = registry.ownedContainers[role]?.windowId ?? null; + const windowId = ownedContainers[role].windowId; + if (windowId !== null) try { + await chrome.windows.get(windowId); + } catch { + ownedContainers[role].windowId = null; + } + } + automationSessions.clear(); + for (const [leaseKey, stored] of Object.entries(registry.leases)) { + const tabId = stored.preferredTabId; + if (tabId === null) continue; + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl(tab.url)) continue; + if (stored.lifecycle === "ephemeral" || stored.lifecycle === "persistent" || stored.lifecycle === "pinned") setSessionOverride(leaseKey, { lifecycle: stored.lifecycle }); + const session = makeSession(leaseKey, { + session: typeof stored.session === "string" ? stored.session : getSessionFromKey(leaseKey), + surface: stored.surface === "adapter" ? "adapter" : getSurfaceFromKey(leaseKey), + kind: stored.kind === "bound" || stored.owned === false ? "bound" : "owned", + windowId: tab.windowId, + owned: stored.owned, + preferredTabId: tabId + }); + const timeout = getIdleTimeout(leaseKey); + automationSessions.set(leaseKey, { + ...session, + idleTimer: null, + idleDeadlineAt: stored.idleDeadlineAt + }); + if (session.owned) { + const role = getOwnedWindowRole(leaseKey); + if (ownedContainers[role].windowId === null) ownedContainers[role].windowId = tab.windowId; + const group = await ensureOwnedContainerGroup(role, tab.windowId, [tabId]); + if (group) { + const current = automationSessions.get(leaseKey); + if (current) current.windowId = group.windowId; + } + } + const remaining = stored.idleDeadlineAt > 0 ? stored.idleDeadlineAt - Date.now() : timeout; + if (timeout > 0) if (remaining <= 0) await releaseLease(leaseKey, "reconciled idle expiry"); + else resetWindowIdleTimer(leaseKey, remaining); + } catch {} + } + try { + await ensureOwnedContainerGroup("interactive", null, []); + } catch (err) { + console.warn(`[opencli] Startup interactive group convergence failed: ${err instanceof Error ? err.message : String(err)}`); + } + await persistRuntimeState(); } async function handleBind(cmd, leaseKey) { - const existing = automationSessions.get(leaseKey); - if (existing?.owned) { - await releaseLease(leaseKey, "rebind"); - } - const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); - const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); - const boundTab = activeTabs.find((tab) => isDebuggableUrl(tab.url)) ?? fallbackTabs.find((tab) => isDebuggableUrl(tab.url)); - if (!boundTab?.id) { - return { - id: cmd.id, - ok: false, - errorCode: "bound_tab_not_found", - error: "No debuggable tab found in the current window", - errorHint: "Focus the target Chrome tab/window, then retry bind." - }; - } - const current = automationSessions.get(leaseKey); - if (current && !current.owned && current.preferredTabId !== null && current.preferredTabId !== boundTab.id) { - await detach(current.preferredTabId).catch(() => { - }); - } - setLeaseSession(leaseKey, { - session: getSessionFromKey(leaseKey), - surface: getSurfaceFromKey(leaseKey), - kind: "bound", - windowId: boundTab.windowId, - owned: false, - preferredTabId: boundTab.id - }); - resetWindowIdleTimer(leaseKey); - console.log(`[opencli] Session ${getSessionFromKey(leaseKey)} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); - return pageScopedResult(cmd.id, boundTab.id, { - url: boundTab.url, - title: boundTab.title, - session: getSessionFromKey(leaseKey) - }); -} + if (automationSessions.get(leaseKey)?.owned) await releaseLease(leaseKey, "rebind"); + const activeTabs = await chrome.tabs.query({ + active: true, + lastFocusedWindow: true + }); + const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); + const boundTab = activeTabs.find((tab) => isDebuggableUrl(tab.url)) ?? fallbackTabs.find((tab) => isDebuggableUrl(tab.url)); + if (!boundTab?.id) return { + id: cmd.id, + ok: false, + errorCode: "bound_tab_not_found", + error: "No debuggable tab found in the current window", + errorHint: "Focus the target Chrome tab/window, then retry bind." + }; + const current = automationSessions.get(leaseKey); + if (current && !current.owned && current.preferredTabId !== null && current.preferredTabId !== boundTab.id) await detach(current.preferredTabId).catch(() => {}); + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: "bound", + windowId: boundTab.windowId, + owned: false, + preferredTabId: boundTab.id + }); + resetWindowIdleTimer(leaseKey); + console.log(`[opencli] Session ${getSessionFromKey(leaseKey)} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); + return pageScopedResult(cmd.id, boundTab.id, { + url: boundTab.url, + title: boundTab.title, + session: getSessionFromKey(leaseKey) + }); +} +//#endregion diff --git a/extension/src/background.ts b/extension/src/background.ts index 4918894c2..ae8494cb1 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1799,7 +1799,7 @@ async function handleNavigate(cmd: Command, leaseKey: string): Promise { async function handleTabs(cmd: Command, leaseKey: string): Promise { const session = automationSessions.get(leaseKey); - if (session && !session.owned && cmd.op !== 'list') { + if (session && !session.owned && cmd.op !== 'list' && cmd.op !== 'current-window') { return { id: cmd.id, ok: false, @@ -1818,6 +1818,27 @@ async function handleTabs(cmd: Command, leaseKey: string): Promise { })); return { id: cmd.id, ok: true, data }; } + case 'current-window': { + // Diagnostic helper: inspect the user's currently focused real Chrome window + // (not the owned automation container). Useful when leftover tabs/windows are suspected. + const tabs = (await chrome.tabs.query({ lastFocusedWindow: true })) + .filter((t) => isDebuggableUrl(t.url)); + const data = await Promise.all(tabs.map(async (t, i) => { + let page: string | undefined; + try { page = t.id ? await identity.resolveTargetId(t.id) : undefined; } catch { /* skip */ } + return { + index: i, + page, + tabId: t.id, + windowId: t.windowId, + url: t.url, + title: t.title, + active: t.active, + pinned: t.pinned, + }; + })); + return { id: cmd.id, ok: true, data }; + } case 'new': { if (cmd.url && !isSafeNavigationUrl(cmd.url)) { return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' }; diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index e4e00e615..19c5cee71 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -39,8 +39,8 @@ export interface Command { siteSession?: 'ephemeral' | 'persistent'; /** URL to navigate to (navigate action) */ url?: string; - /** Sub-operation for tabs: list, new, close, select */ - op?: 'list' | 'new' | 'close' | 'select'; + /** Sub-operation for tabs: list, new, close, select, current-window */ + op?: 'list' | 'new' | 'close' | 'select' | 'current-window'; /** Tab index for tabs select/close */ index?: number; /** Cookie domain filter */ diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 9d7bffc0c..a2e12697a 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -500,3 +500,7 @@ export async function sendCommandFull( export async function bindTab(session: string, opts: { contextId?: string } = {}): Promise { return sendCommand('bind', { session, surface: 'browser', ...opts }); } + +export async function listCurrentWindowTabs(session: string, opts: { contextId?: string } = {}): Promise { + return sendCommand('tabs', { op: 'current-window', session, surface: 'browser', ...opts }); +} diff --git a/src/browser/page.ts b/src/browser/page.ts index 7994ff30b..a3b5e397b 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -192,9 +192,9 @@ export class Page extends BasePage { /** Release the current browser session lease in the extension */ async closeWindow(): Promise { try { + // Let close failures propagate so executeCommand can retry/log them. + // Local page state is still cleared in finally — the lease may already be gone. await sendCommand('close-window', { ...this._sessionOpts() }); - } catch { - // Window may already be closed or daemon may be down } finally { this._page = undefined; this._lastUrl = null; diff --git a/src/cli.test.ts b/src/cli.test.ts index 77774351c..49d3db8f5 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -597,10 +597,11 @@ describe('createProgram root help descriptions', () => { group: 'tab', command: 'opencli browser tab', usage: 'opencli browser tab [args] [options]', - command_count: 4, + command_count: 5, }); expect(data.commands.map((cmd: any) => cmd.name)).toEqual([ 'tab close', + 'tab current-window', 'tab list', 'tab new', 'tab select', diff --git a/src/cli.ts b/src/cli.ts index 9c0132891..bb5bedf63 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,7 +35,7 @@ import { analyzeSite, type PageSignals } from './browser/analyze.js'; import { registerAuthCommands } from './commands/auth.js'; import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js'; import { log } from './logger.js'; -import { bindTab, BrowserCommandError, sendCommand } from './browser/daemon-client.js'; +import { bindTab, BrowserCommandError, sendCommand, listCurrentWindowTabs } from './browser/daemon-client.js'; import { fetchDaemonStatus } from './browser/daemon-transport.js'; import { aliasForContextId, loadProfileConfig, profileRouteParams, renameProfile, resolveProfileSelection, setDefaultProfile, type ProfileSelection } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale } from './browser/daemon-version.js'; @@ -1103,6 +1103,13 @@ Examples: console.log(JSON.stringify(tabs, null, 2)); })); + browserTab.command('current-window') + .description('List tabs from the currently focused real Chrome window') + .action(browserSessionCommandAction(async ({ session, contextId }) => { + const data = await listCurrentWindowTabs(session, { ...(contextId && { contextId }) }); + console.log(JSON.stringify(data, null, 2)); + })); + browserTab.command('new') .argument('[url]', 'Optional URL to open in the new tab') .description('Create a new tab and print its target ID') diff --git a/src/execution.ts b/src/execution.ts index 585bc0344..72a389fac 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -51,6 +51,28 @@ function normalizeTraceMode(raw: unknown): TraceMode { throw new ArgumentError(`--trace must be one of: off, on, retain-on-failure. Received: "${String(raw)}"`); } +async function closeBrowserWindow(page: IPage, label: string): Promise { + if (!page.closeWindow) return; + + for (let attempt = 1; attempt <= 2; attempt += 1) { + try { + await page.closeWindow(); + if (process.env.OPENCLI_VERBOSE) { + log.debug(`[execution] closed browser window for ${label}`); + } + return; + } catch (err) { + if (attempt === 1) { + await new Promise((resolve) => setTimeout(resolve, 250)); + continue; + } + if (process.env.OPENCLI_VERBOSE) { + log.warn(`[execution] failed to close browser window for ${label}: ${getErrorMessage(err)}`); + } + } + } +} + export function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: CommandArgs): CommandArgs { const result: CommandArgs = { ...kwargs }; @@ -384,7 +406,7 @@ export async function executeCommand( // Adapter commands are one-shot — release the current tab lease immediately // instead of waiting for the 30s idle timeout. The automation container // window stays open for reuse. - if (!keepTab) await page.closeWindow?.().catch(() => {}); + if (!keepTab) await closeBrowserWindow(page, fullName(cmd)); return result; } catch (err) { if (!commandSettled) adapterStillRunning = true; @@ -408,7 +430,7 @@ export async function executeCommand( // Release the tab lease on failure too — without this, the lease lingers // until the extension's idle timer fires (unreliable on Windows where // MV3 service workers may be suspended before setTimeout triggers). - if (!keepTab) await page.closeWindow?.().catch(() => {}); + if (!keepTab) await closeBrowserWindow(page, fullName(cmd)); throw err; } }, { session, cdpEndpoint, ...profileRouting, windowMode, surface: 'adapter', siteSession });