/ extension / dist / background.js
background.js
   1  //#region src/protocol.ts
   2  /** Default daemon port */
   3  var DAEMON_PORT = 19825;
   4  var DAEMON_HOST = "localhost";
   5  var DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
   6  /** Lightweight health-check endpoint — probed before each WebSocket attempt. */
   7  var DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
   8  /** Base reconnect delay for extension WebSocket (ms) */
   9  var WS_RECONNECT_BASE_DELAY = 2e3;
  10  /** Max reconnect delay (ms) — kept short since daemon is long-lived */
  11  var WS_RECONNECT_MAX_DELAY = 5e3;
  12  //#endregion
  13  //#region src/cdp.ts
  14  /**
  15  * CDP execution via chrome.debugger API.
  16  *
  17  * chrome.debugger only needs the "debugger" permission — no host_permissions.
  18  * It can attach to any http/https tab. Avoid chrome:// and chrome-extension://
  19  * tabs (resolveTabId in background.ts filters them).
  20  */
  21  var attached = /* @__PURE__ */ new Set();
  22  var networkCaptures = /* @__PURE__ */ new Map();
  23  /** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
  24  function isDebuggableUrl$1(url) {
  25  	if (!url) return true;
  26  	return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:");
  27  }
  28  async function ensureAttached(tabId, aggressiveRetry = false) {
  29  	try {
  30  		const tab = await chrome.tabs.get(tabId);
  31  		if (!isDebuggableUrl$1(tab.url)) {
  32  			attached.delete(tabId);
  33  			throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`);
  34  		}
  35  	} catch (e) {
  36  		if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e;
  37  		attached.delete(tabId);
  38  		throw new Error(`Tab ${tabId} no longer exists`);
  39  	}
  40  	if (attached.has(tabId)) try {
  41  		await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
  42  			expression: "1",
  43  			returnByValue: true
  44  		});
  45  		return;
  46  	} catch {
  47  		attached.delete(tabId);
  48  	}
  49  	const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2;
  50  	const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500;
  51  	let lastError = "";
  52  	for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) try {
  53  		try {
  54  			await chrome.debugger.detach({ tabId });
  55  		} catch {}
  56  		await chrome.debugger.attach({ tabId }, "1.3");
  57  		lastError = "";
  58  		break;
  59  	} catch (e) {
  60  		lastError = e instanceof Error ? e.message : String(e);
  61  		if (attempt < MAX_ATTACH_RETRIES) {
  62  			console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`);
  63  			await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
  64  			try {
  65  				const tab = await chrome.tabs.get(tabId);
  66  				if (!isDebuggableUrl$1(tab.url)) {
  67  					lastError = `Tab URL changed to ${tab.url} during retry`;
  68  					break;
  69  				}
  70  			} catch {
  71  				lastError = `Tab ${tabId} no longer exists`;
  72  			}
  73  		}
  74  	}
  75  	if (lastError) {
  76  		let finalUrl = "unknown";
  77  		let finalWindowId = "unknown";
  78  		try {
  79  			const tab = await chrome.tabs.get(tabId);
  80  			finalUrl = tab.url ?? "undefined";
  81  			finalWindowId = String(tab.windowId);
  82  		} catch {}
  83  		console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`);
  84  		const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : "";
  85  		throw new Error(`attach failed: ${lastError}${hint}`);
  86  	}
  87  	attached.add(tabId);
  88  	try {
  89  		await chrome.debugger.sendCommand({ tabId }, "Runtime.enable");
  90  	} catch {}
  91  }
  92  async function evaluate(tabId, expression, aggressiveRetry = false) {
  93  	const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2;
  94  	for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) try {
  95  		await ensureAttached(tabId, aggressiveRetry);
  96  		const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
  97  			expression,
  98  			returnByValue: true,
  99  			awaitPromise: true
 100  		});
 101  		if (result.exceptionDetails) {
 102  			const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error";
 103  			throw new Error(errMsg);
 104  		}
 105  		return result.result?.value;
 106  	} catch (e) {
 107  		const msg = e instanceof Error ? e.message : String(e);
 108  		const isNavigateError = msg.includes("Inspected target navigated") || msg.includes("Target closed");
 109  		if ((isNavigateError || msg.includes("attach failed") || msg.includes("Debugger is not attached") || msg.includes("chrome-extension://")) && attempt < MAX_EVAL_RETRIES) {
 110  			attached.delete(tabId);
 111  			const retryMs = isNavigateError ? 200 : 500;
 112  			await new Promise((resolve) => setTimeout(resolve, retryMs));
 113  			continue;
 114  		}
 115  		throw e;
 116  	}
 117  	throw new Error("evaluate: max retries exhausted");
 118  }
 119  var evaluateAsync = evaluate;
 120  /**
 121  * Capture a screenshot via CDP Page.captureScreenshot.
 122  * Returns base64-encoded image data.
 123  */
 124  async function screenshot(tabId, options = {}) {
 125  	await ensureAttached(tabId);
 126  	const format = options.format ?? "png";
 127  	if (options.fullPage) {
 128  		const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics");
 129  		const size = metrics.cssContentSize || metrics.contentSize;
 130  		if (size) await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", {
 131  			mobile: false,
 132  			width: Math.ceil(size.width),
 133  			height: Math.ceil(size.height),
 134  			deviceScaleFactor: 1
 135  		});
 136  	}
 137  	try {
 138  		const params = { format };
 139  		if (format === "jpeg" && options.quality !== void 0) params.quality = Math.max(0, Math.min(100, options.quality));
 140  		return (await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params)).data;
 141  	} finally {
 142  		if (options.fullPage) await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {});
 143  	}
 144  }
 145  /**
 146  * Set local file paths on a file input element via CDP DOM.setFileInputFiles.
 147  * This bypasses the need to send large base64 payloads through the message channel —
 148  * Chrome reads the files directly from the local filesystem.
 149  *
 150  * @param tabId - Target tab ID
 151  * @param files - Array of absolute local file paths
 152  * @param selector - CSS selector to find the file input (optional, defaults to first file input)
 153  */
 154  async function setFileInputFiles(tabId, files, selector) {
 155  	await ensureAttached(tabId);
 156  	await chrome.debugger.sendCommand({ tabId }, "DOM.enable");
 157  	const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument");
 158  	const query = selector || "input[type=\"file\"]";
 159  	const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", {
 160  		nodeId: doc.root.nodeId,
 161  		selector: query
 162  	});
 163  	if (!result.nodeId) throw new Error(`No element found matching selector: ${query}`);
 164  	await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", {
 165  		files,
 166  		nodeId: result.nodeId
 167  	});
 168  }
 169  async function insertText(tabId, text) {
 170  	await ensureAttached(tabId);
 171  	await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text });
 172  }
 173  function normalizeCapturePatterns(pattern) {
 174  	return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean);
 175  }
 176  function shouldCaptureUrl(url, patterns) {
 177  	if (!url) return false;
 178  	if (!patterns.length) return true;
 179  	return patterns.some((pattern) => url.includes(pattern));
 180  }
 181  function normalizeHeaders(headers) {
 182  	if (!headers || typeof headers !== "object") return {};
 183  	const out = {};
 184  	for (const [key, value] of Object.entries(headers)) out[String(key)] = String(value);
 185  	return out;
 186  }
 187  function getOrCreateNetworkCaptureEntry(tabId, requestId, fallback) {
 188  	const state = networkCaptures.get(tabId);
 189  	if (!state) return null;
 190  	const existingIndex = state.requestToIndex.get(requestId);
 191  	if (existingIndex !== void 0) return state.entries[existingIndex] || null;
 192  	const url = fallback?.url || "";
 193  	if (!shouldCaptureUrl(url, state.patterns)) return null;
 194  	const entry = {
 195  		kind: "cdp",
 196  		url,
 197  		method: fallback?.method || "GET",
 198  		requestHeaders: fallback?.requestHeaders || {},
 199  		timestamp: Date.now()
 200  	};
 201  	state.entries.push(entry);
 202  	state.requestToIndex.set(requestId, state.entries.length - 1);
 203  	return entry;
 204  }
 205  async function startNetworkCapture(tabId, pattern) {
 206  	await ensureAttached(tabId);
 207  	await chrome.debugger.sendCommand({ tabId }, "Network.enable");
 208  	networkCaptures.set(tabId, {
 209  		patterns: normalizeCapturePatterns(pattern),
 210  		entries: [],
 211  		requestToIndex: /* @__PURE__ */ new Map()
 212  	});
 213  }
 214  async function readNetworkCapture(tabId) {
 215  	const state = networkCaptures.get(tabId);
 216  	if (!state) return [];
 217  	const entries = state.entries.slice();
 218  	state.entries = [];
 219  	state.requestToIndex.clear();
 220  	return entries;
 221  }
 222  function hasActiveNetworkCapture(tabId) {
 223  	return networkCaptures.has(tabId);
 224  }
 225  async function detach(tabId) {
 226  	if (!attached.has(tabId)) return;
 227  	attached.delete(tabId);
 228  	networkCaptures.delete(tabId);
 229  	try {
 230  		await chrome.debugger.detach({ tabId });
 231  	} catch {}
 232  }
 233  function registerListeners() {
 234  	chrome.tabs.onRemoved.addListener((tabId) => {
 235  		attached.delete(tabId);
 236  		networkCaptures.delete(tabId);
 237  	});
 238  	chrome.debugger.onDetach.addListener((source) => {
 239  		if (source.tabId) {
 240  			attached.delete(source.tabId);
 241  			networkCaptures.delete(source.tabId);
 242  		}
 243  	});
 244  	chrome.tabs.onUpdated.addListener(async (tabId, info) => {
 245  		if (info.url && !isDebuggableUrl$1(info.url)) await detach(tabId);
 246  	});
 247  	chrome.debugger.onEvent.addListener(async (source, method, params) => {
 248  		const tabId = source.tabId;
 249  		if (!tabId) return;
 250  		const state = networkCaptures.get(tabId);
 251  		if (!state) return;
 252  		if (method === "Network.requestWillBeSent") {
 253  			const requestId = String(params?.requestId || "");
 254  			const request = params?.request;
 255  			const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, {
 256  				url: request?.url,
 257  				method: request?.method,
 258  				requestHeaders: normalizeHeaders(request?.headers)
 259  			});
 260  			if (!entry) return;
 261  			entry.requestBodyKind = request?.hasPostData ? "string" : "empty";
 262  			entry.requestBodyPreview = String(request?.postData || "").slice(0, 4e3);
 263  			try {
 264  				const postData = await chrome.debugger.sendCommand({ tabId }, "Network.getRequestPostData", { requestId });
 265  				if (postData?.postData) {
 266  					entry.requestBodyKind = "string";
 267  					entry.requestBodyPreview = postData.postData.slice(0, 4e3);
 268  				}
 269  			} catch {}
 270  			return;
 271  		}
 272  		if (method === "Network.responseReceived") {
 273  			const requestId = String(params?.requestId || "");
 274  			const response = params?.response;
 275  			const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { url: response?.url });
 276  			if (!entry) return;
 277  			entry.responseStatus = response?.status;
 278  			entry.responseContentType = response?.mimeType || "";
 279  			entry.responseHeaders = normalizeHeaders(response?.headers);
 280  			return;
 281  		}
 282  		if (method === "Network.loadingFinished") {
 283  			const requestId = String(params?.requestId || "");
 284  			const stateEntryIndex = state.requestToIndex.get(requestId);
 285  			if (stateEntryIndex === void 0) return;
 286  			const entry = state.entries[stateEntryIndex];
 287  			if (!entry) return;
 288  			try {
 289  				const body = await chrome.debugger.sendCommand({ tabId }, "Network.getResponseBody", { requestId });
 290  				if (typeof body?.body === "string") entry.responsePreview = body.base64Encoded ? `base64:${body.body.slice(0, 4e3)}` : body.body.slice(0, 4e3);
 291  			} catch {}
 292  		}
 293  	});
 294  }
 295  //#endregion
 296  //#region src/identity.ts
 297  /**
 298  * Page identity mapping — targetId ↔ tabId.
 299  *
 300  * targetId is the cross-layer page identity (CDP target UUID).
 301  * tabId is an internal Chrome Tabs API routing detail — never exposed outside the extension.
 302  *
 303  * Lifecycle:
 304  *   - Cache populated lazily via chrome.debugger.getTargets()
 305  *   - Evicted on tab close (chrome.tabs.onRemoved)
 306  *   - Miss triggers full refresh; refresh miss → hard error (no guessing)
 307  */
 308  var targetToTab = /* @__PURE__ */ new Map();
 309  var tabToTarget = /* @__PURE__ */ new Map();
 310  /**
 311  * Resolve targetId for a given tabId.
 312  * Returns cached value if available; on miss, refreshes from chrome.debugger.getTargets().
 313  * Throws if no targetId can be found (page may have been destroyed).
 314  */
 315  async function resolveTargetId(tabId) {
 316  	const cached = tabToTarget.get(tabId);
 317  	if (cached) return cached;
 318  	await refreshMappings();
 319  	const result = tabToTarget.get(tabId);
 320  	if (!result) throw new Error(`No targetId for tab ${tabId} — page may have been closed`);
 321  	return result;
 322  }
 323  /**
 324  * Resolve tabId for a given targetId.
 325  * Returns cached value if available; on miss, refreshes from chrome.debugger.getTargets().
 326  * Throws if no tabId can be found — never falls back to guessing.
 327  */
 328  async function resolveTabId$1(targetId) {
 329  	const cached = targetToTab.get(targetId);
 330  	if (cached !== void 0) return cached;
 331  	await refreshMappings();
 332  	const result = targetToTab.get(targetId);
 333  	if (result === void 0) throw new Error(`Page not found: ${targetId} — stale page identity`);
 334  	return result;
 335  }
 336  /**
 337  * Remove mappings for a closed tab.
 338  * Called from chrome.tabs.onRemoved listener.
 339  */
 340  function evictTab(tabId) {
 341  	const targetId = tabToTarget.get(tabId);
 342  	if (targetId) targetToTab.delete(targetId);
 343  	tabToTarget.delete(tabId);
 344  }
 345  /**
 346  * Full refresh of targetId ↔ tabId mappings from chrome.debugger.getTargets().
 347  */
 348  async function refreshMappings() {
 349  	const targets = await chrome.debugger.getTargets();
 350  	targetToTab.clear();
 351  	tabToTarget.clear();
 352  	for (const t of targets) if (t.type === "page" && t.tabId !== void 0) {
 353  		targetToTab.set(t.id, t.tabId);
 354  		tabToTarget.set(t.tabId, t.id);
 355  	}
 356  }
 357  //#endregion
 358  //#region src/background.ts
 359  var ws = null;
 360  var reconnectTimer = null;
 361  var reconnectAttempts = 0;
 362  var _origLog = console.log.bind(console);
 363  var _origWarn = console.warn.bind(console);
 364  var _origError = console.error.bind(console);
 365  function forwardLog(level, args) {
 366  	if (!ws || ws.readyState !== WebSocket.OPEN) return;
 367  	try {
 368  		const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
 369  		ws.send(JSON.stringify({
 370  			type: "log",
 371  			level,
 372  			msg,
 373  			ts: Date.now()
 374  		}));
 375  	} catch {}
 376  }
 377  console.log = (...args) => {
 378  	_origLog(...args);
 379  	forwardLog("info", args);
 380  };
 381  console.warn = (...args) => {
 382  	_origWarn(...args);
 383  	forwardLog("warn", args);
 384  };
 385  console.error = (...args) => {
 386  	_origError(...args);
 387  	forwardLog("error", args);
 388  };
 389  /**
 390  * Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket
 391  * connection.  fetch() failures are silently catchable; new WebSocket() is not
 392  * — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any
 393  * JS handler can intercept it.  By keeping the probe inside connect() every
 394  * call site remains unchanged and the guard can never be accidentally skipped.
 395  */
 396  async function connect() {
 397  	if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
 398  	try {
 399  		if (!(await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) })).ok) return;
 400  	} catch {
 401  		return;
 402  	}
 403  	try {
 404  		ws = new WebSocket(DAEMON_WS_URL);
 405  	} catch {
 406  		scheduleReconnect();
 407  		return;
 408  	}
 409  	ws.onopen = () => {
 410  		console.log("[opencli] Connected to daemon");
 411  		reconnectAttempts = 0;
 412  		if (reconnectTimer) {
 413  			clearTimeout(reconnectTimer);
 414  			reconnectTimer = null;
 415  		}
 416  		ws?.send(JSON.stringify({
 417  			type: "hello",
 418  			version: chrome.runtime.getManifest().version,
 419  			compatRange: ">=1.7.0"
 420  		}));
 421  	};
 422  	ws.onmessage = async (event) => {
 423  		try {
 424  			const result = await handleCommand(JSON.parse(event.data));
 425  			ws?.send(JSON.stringify(result));
 426  		} catch (err) {
 427  			console.error("[opencli] Message handling error:", err);
 428  		}
 429  	};
 430  	ws.onclose = () => {
 431  		console.log("[opencli] Disconnected from daemon");
 432  		ws = null;
 433  		scheduleReconnect();
 434  	};
 435  	ws.onerror = () => {
 436  		ws?.close();
 437  	};
 438  }
 439  /**
 440  * After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects.
 441  * The keepalive alarm (~24s) will still call connect() periodically, but at a
 442  * much lower frequency — reducing console noise when the daemon is not running.
 443  */
 444  var MAX_EAGER_ATTEMPTS = 6;
 445  function scheduleReconnect() {
 446  	if (reconnectTimer) return;
 447  	reconnectAttempts++;
 448  	if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return;
 449  	const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
 450  	reconnectTimer = setTimeout(() => {
 451  		reconnectTimer = null;
 452  		connect();
 453  	}, delay);
 454  }
 455  var automationSessions = /* @__PURE__ */ new Map();
 456  var IDLE_TIMEOUT_DEFAULT = 3e4;
 457  var IDLE_TIMEOUT_INTERACTIVE = 6e5;
 458  /** Per-workspace custom timeout overrides set via command.idleTimeout */
 459  var workspaceTimeoutOverrides = /* @__PURE__ */ new Map();
 460  function getIdleTimeout(workspace) {
 461  	const override = workspaceTimeoutOverrides.get(workspace);
 462  	if (override !== void 0) return override;
 463  	if (workspace.startsWith("browser:") || workspace.startsWith("operate:")) return IDLE_TIMEOUT_INTERACTIVE;
 464  	return IDLE_TIMEOUT_DEFAULT;
 465  }
 466  var windowFocused = false;
 467  function getWorkspaceKey(workspace) {
 468  	return workspace?.trim() || "default";
 469  }
 470  function resetWindowIdleTimer(workspace) {
 471  	const session = automationSessions.get(workspace);
 472  	if (!session) return;
 473  	if (session.idleTimer) clearTimeout(session.idleTimer);
 474  	const timeout = getIdleTimeout(workspace);
 475  	session.idleDeadlineAt = Date.now() + timeout;
 476  	session.idleTimer = setTimeout(async () => {
 477  		const current = automationSessions.get(workspace);
 478  		if (!current) return;
 479  		if (!current.owned) {
 480  			console.log(`[opencli] Borrowed workspace ${workspace} detached from window ${current.windowId} (idle timeout)`);
 481  			workspaceTimeoutOverrides.delete(workspace);
 482  			automationSessions.delete(workspace);
 483  			return;
 484  		}
 485  		try {
 486  			await chrome.windows.remove(current.windowId);
 487  			console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout, ${timeout / 1e3}s)`);
 488  		} catch {}
 489  		workspaceTimeoutOverrides.delete(workspace);
 490  		automationSessions.delete(workspace);
 491  	}, timeout);
 492  }
 493  /** Get or create the dedicated automation window.
 494  *  @param initialUrl — if provided (http/https), used as the initial page instead of about:blank.
 495  *    This avoids an extra blank-page→target-domain navigation on first command.
 496  */
 497  async function getAutomationWindow(workspace, initialUrl) {
 498  	const existing = automationSessions.get(workspace);
 499  	if (existing) try {
 500  		await chrome.windows.get(existing.windowId);
 501  		return existing.windowId;
 502  	} catch {
 503  		automationSessions.delete(workspace);
 504  	}
 505  	const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE;
 506  	const win = await chrome.windows.create({
 507  		url: startUrl,
 508  		focused: windowFocused,
 509  		width: 1280,
 510  		height: 900,
 511  		type: "normal"
 512  	});
 513  	const session = {
 514  		windowId: win.id,
 515  		idleTimer: null,
 516  		idleDeadlineAt: Date.now() + getIdleTimeout(workspace),
 517  		owned: true,
 518  		preferredTabId: null
 519  	};
 520  	automationSessions.set(workspace, session);
 521  	console.log(`[opencli] Created automation window ${session.windowId} (${workspace}, start=${startUrl})`);
 522  	resetWindowIdleTimer(workspace);
 523  	const tabs = await chrome.tabs.query({ windowId: win.id });
 524  	if (tabs[0]?.id) await new Promise((resolve) => {
 525  		const timeout = setTimeout(resolve, 500);
 526  		const listener = (tabId, info) => {
 527  			if (tabId === tabs[0].id && info.status === "complete") {
 528  				chrome.tabs.onUpdated.removeListener(listener);
 529  				clearTimeout(timeout);
 530  				resolve();
 531  			}
 532  		};
 533  		if (tabs[0].status === "complete") {
 534  			clearTimeout(timeout);
 535  			resolve();
 536  		} else chrome.tabs.onUpdated.addListener(listener);
 537  	});
 538  	return session.windowId;
 539  }
 540  chrome.windows.onRemoved.addListener(async (windowId) => {
 541  	for (const [workspace, session] of automationSessions.entries()) if (session.windowId === windowId) {
 542  		console.log(`[opencli] Automation window closed (${workspace})`);
 543  		if (session.idleTimer) clearTimeout(session.idleTimer);
 544  		automationSessions.delete(workspace);
 545  		workspaceTimeoutOverrides.delete(workspace);
 546  	}
 547  });
 548  chrome.tabs.onRemoved.addListener((tabId) => {
 549  	evictTab(tabId);
 550  });
 551  var initialized = false;
 552  function initialize() {
 553  	if (initialized) return;
 554  	initialized = true;
 555  	chrome.alarms.create("keepalive", { periodInMinutes: .4 });
 556  	registerListeners();
 557  	connect();
 558  	console.log("[opencli] OpenCLI extension initialized");
 559  }
 560  chrome.runtime.onInstalled.addListener(() => {
 561  	initialize();
 562  });
 563  chrome.runtime.onStartup.addListener(() => {
 564  	initialize();
 565  });
 566  chrome.alarms.onAlarm.addListener((alarm) => {
 567  	if (alarm.name === "keepalive") connect();
 568  });
 569  chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
 570  	if (msg?.type === "getStatus") sendResponse({
 571  		connected: ws?.readyState === WebSocket.OPEN,
 572  		reconnecting: reconnectTimer !== null
 573  	});
 574  	return false;
 575  });
 576  async function handleCommand(cmd) {
 577  	const workspace = getWorkspaceKey(cmd.workspace);
 578  	windowFocused = cmd.windowFocused === true;
 579  	if (cmd.idleTimeout != null && cmd.idleTimeout > 0) workspaceTimeoutOverrides.set(workspace, cmd.idleTimeout * 1e3);
 580  	resetWindowIdleTimer(workspace);
 581  	try {
 582  		switch (cmd.action) {
 583  			case "exec": return await handleExec(cmd, workspace);
 584  			case "navigate": return await handleNavigate(cmd, workspace);
 585  			case "tabs": return await handleTabs(cmd, workspace);
 586  			case "cookies": return await handleCookies(cmd);
 587  			case "screenshot": return await handleScreenshot(cmd, workspace);
 588  			case "close-window": return await handleCloseWindow(cmd, workspace);
 589  			case "cdp": return await handleCdp(cmd, workspace);
 590  			case "sessions": return await handleSessions(cmd);
 591  			case "set-file-input": return await handleSetFileInput(cmd, workspace);
 592  			case "insert-text": return await handleInsertText(cmd, workspace);
 593  			case "bind-current": return await handleBindCurrent(cmd, workspace);
 594  			case "network-capture-start": return await handleNetworkCaptureStart(cmd, workspace);
 595  			case "network-capture-read": return await handleNetworkCaptureRead(cmd, workspace);
 596  			default: return {
 597  				id: cmd.id,
 598  				ok: false,
 599  				error: `Unknown action: ${cmd.action}`
 600  			};
 601  		}
 602  	} catch (err) {
 603  		return {
 604  			id: cmd.id,
 605  			ok: false,
 606  			error: err instanceof Error ? err.message : String(err)
 607  		};
 608  	}
 609  }
 610  /** Internal blank page used when no user URL is provided. */
 611  var BLANK_PAGE = "about:blank";
 612  /** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
 613  function isDebuggableUrl(url) {
 614  	if (!url) return true;
 615  	return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:");
 616  }
 617  /** Check if a URL is safe for user-facing navigation (http/https only). */
 618  function isSafeNavigationUrl(url) {
 619  	return url.startsWith("http://") || url.startsWith("https://");
 620  }
 621  /** Minimal URL normalization for same-page comparison: root slash + default port only. */
 622  function normalizeUrlForComparison(url) {
 623  	if (!url) return "";
 624  	try {
 625  		const parsed = new URL(url);
 626  		if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") parsed.port = "";
 627  		const pathname = parsed.pathname === "/" ? "" : parsed.pathname;
 628  		return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`;
 629  	} catch {
 630  		return url;
 631  	}
 632  }
 633  function isTargetUrl(currentUrl, targetUrl) {
 634  	return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
 635  }
 636  function matchesDomain(url, domain) {
 637  	if (!url) return false;
 638  	try {
 639  		const parsed = new URL(url);
 640  		return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
 641  	} catch {
 642  		return false;
 643  	}
 644  }
 645  function matchesBindCriteria(tab, cmd) {
 646  	if (!tab.id || !isDebuggableUrl(tab.url)) return false;
 647  	if (cmd.matchDomain && !matchesDomain(tab.url, cmd.matchDomain)) return false;
 648  	if (cmd.matchPathPrefix) try {
 649  		if (!new URL(tab.url).pathname.startsWith(cmd.matchPathPrefix)) return false;
 650  	} catch {
 651  		return false;
 652  	}
 653  	return true;
 654  }
 655  function setWorkspaceSession(workspace, session) {
 656  	const existing = automationSessions.get(workspace);
 657  	if (existing?.idleTimer) clearTimeout(existing.idleTimer);
 658  	automationSessions.set(workspace, {
 659  		...session,
 660  		idleTimer: null,
 661  		idleDeadlineAt: Date.now() + getIdleTimeout(workspace)
 662  	});
 663  }
 664  /**
 665  * Resolve tabId from command's page (targetId).
 666  * Returns undefined if no page identity is provided.
 667  */
 668  async function resolveCommandTabId(cmd) {
 669  	if (cmd.page) return resolveTabId$1(cmd.page);
 670  }
 671  /**
 672  * Resolve target tab in the automation window, returning both the tabId and
 673  * the Tab object (when available) so callers can skip a redundant chrome.tabs.get().
 674  */
 675  async function resolveTab(tabId, workspace, initialUrl) {
 676  	if (tabId !== void 0) try {
 677  		const tab = await chrome.tabs.get(tabId);
 678  		const session = automationSessions.get(workspace);
 679  		const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false;
 680  		if (isDebuggableUrl(tab.url) && matchesSession) return {
 681  			tabId,
 682  			tab
 683  		};
 684  		if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) {
 685  			console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`);
 686  			try {
 687  				await chrome.tabs.move(tabId, {
 688  					windowId: session.windowId,
 689  					index: -1
 690  				});
 691  				const moved = await chrome.tabs.get(tabId);
 692  				if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) return {
 693  					tabId,
 694  					tab: moved
 695  				};
 696  			} catch (moveErr) {
 697  				console.warn(`[opencli] Failed to move tab back: ${moveErr}`);
 698  			}
 699  		} else if (!isDebuggableUrl(tab.url)) console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
 700  	} catch {
 701  		console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
 702  	}
 703  	const existingSession = automationSessions.get(workspace);
 704  	if (existingSession?.preferredTabId !== null) try {
 705  		const preferredTab = await chrome.tabs.get(existingSession.preferredTabId);
 706  		if (isDebuggableUrl(preferredTab.url)) return {
 707  			tabId: preferredTab.id,
 708  			tab: preferredTab
 709  		};
 710  	} catch {
 711  		automationSessions.delete(workspace);
 712  	}
 713  	const windowId = await getAutomationWindow(workspace, initialUrl);
 714  	const tabs = await chrome.tabs.query({ windowId });
 715  	const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url));
 716  	if (debuggableTab?.id) return {
 717  		tabId: debuggableTab.id,
 718  		tab: debuggableTab
 719  	};
 720  	const reuseTab = tabs.find((t) => t.id);
 721  	if (reuseTab?.id) {
 722  		await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE });
 723  		await new Promise((resolve) => setTimeout(resolve, 300));
 724  		try {
 725  			const updated = await chrome.tabs.get(reuseTab.id);
 726  			if (isDebuggableUrl(updated.url)) return {
 727  				tabId: reuseTab.id,
 728  				tab: updated
 729  			};
 730  			console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
 731  		} catch {}
 732  	}
 733  	const newTab = await chrome.tabs.create({
 734  		windowId,
 735  		url: BLANK_PAGE,
 736  		active: true
 737  	});
 738  	if (!newTab.id) throw new Error("Failed to create tab in automation window");
 739  	return {
 740  		tabId: newTab.id,
 741  		tab: newTab
 742  	};
 743  }
 744  /** Build a page-scoped success result with targetId resolved from tabId */
 745  async function pageScopedResult(id, tabId, data) {
 746  	return {
 747  		id,
 748  		ok: true,
 749  		data,
 750  		page: await resolveTargetId(tabId)
 751  	};
 752  }
 753  /** Convenience wrapper returning just the tabId (used by most handlers) */
 754  async function resolveTabId(tabId, workspace, initialUrl) {
 755  	return (await resolveTab(tabId, workspace, initialUrl)).tabId;
 756  }
 757  async function listAutomationTabs(workspace) {
 758  	const session = automationSessions.get(workspace);
 759  	if (!session) return [];
 760  	if (session.preferredTabId !== null) try {
 761  		return [await chrome.tabs.get(session.preferredTabId)];
 762  	} catch {
 763  		automationSessions.delete(workspace);
 764  		return [];
 765  	}
 766  	try {
 767  		return await chrome.tabs.query({ windowId: session.windowId });
 768  	} catch {
 769  		automationSessions.delete(workspace);
 770  		return [];
 771  	}
 772  }
 773  async function listAutomationWebTabs(workspace) {
 774  	return (await listAutomationTabs(workspace)).filter((tab) => isDebuggableUrl(tab.url));
 775  }
 776  async function handleExec(cmd, workspace) {
 777  	if (!cmd.code) return {
 778  		id: cmd.id,
 779  		ok: false,
 780  		error: "Missing code"
 781  	};
 782  	const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace);
 783  	try {
 784  		const aggressive = workspace.startsWith("browser:") || workspace.startsWith("operate:");
 785  		const data = await evaluateAsync(tabId, cmd.code, aggressive);
 786  		return pageScopedResult(cmd.id, tabId, data);
 787  	} catch (err) {
 788  		return {
 789  			id: cmd.id,
 790  			ok: false,
 791  			error: err instanceof Error ? err.message : String(err)
 792  		};
 793  	}
 794  }
 795  async function handleNavigate(cmd, workspace) {
 796  	if (!cmd.url) return {
 797  		id: cmd.id,
 798  		ok: false,
 799  		error: "Missing url"
 800  	};
 801  	if (!isSafeNavigationUrl(cmd.url)) return {
 802  		id: cmd.id,
 803  		ok: false,
 804  		error: "Blocked URL scheme -- only http:// and https:// are allowed"
 805  	};
 806  	const resolved = await resolveTab(await resolveCommandTabId(cmd), workspace, cmd.url);
 807  	const tabId = resolved.tabId;
 808  	const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId);
 809  	const beforeNormalized = normalizeUrlForComparison(beforeTab.url);
 810  	const targetUrl = cmd.url;
 811  	if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) return pageScopedResult(cmd.id, tabId, {
 812  		title: beforeTab.title,
 813  		url: beforeTab.url,
 814  		timedOut: false
 815  	});
 816  	if (!hasActiveNetworkCapture(tabId)) await detach(tabId);
 817  	await chrome.tabs.update(tabId, { url: targetUrl });
 818  	let timedOut = false;
 819  	await new Promise((resolve) => {
 820  		let settled = false;
 821  		let checkTimer = null;
 822  		let timeoutTimer = null;
 823  		const finish = () => {
 824  			if (settled) return;
 825  			settled = true;
 826  			chrome.tabs.onUpdated.removeListener(listener);
 827  			if (checkTimer) clearTimeout(checkTimer);
 828  			if (timeoutTimer) clearTimeout(timeoutTimer);
 829  			resolve();
 830  		};
 831  		const isNavigationDone = (url) => {
 832  			return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized;
 833  		};
 834  		const listener = (id, info, tab) => {
 835  			if (id !== tabId) return;
 836  			if (info.status === "complete" && isNavigationDone(tab.url ?? info.url)) finish();
 837  		};
 838  		chrome.tabs.onUpdated.addListener(listener);
 839  		checkTimer = setTimeout(async () => {
 840  			try {
 841  				const currentTab = await chrome.tabs.get(tabId);
 842  				if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) finish();
 843  			} catch {}
 844  		}, 100);
 845  		timeoutTimer = setTimeout(() => {
 846  			timedOut = true;
 847  			console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
 848  			finish();
 849  		}, 15e3);
 850  	});
 851  	let tab = await chrome.tabs.get(tabId);
 852  	const session = automationSessions.get(workspace);
 853  	if (session && tab.windowId !== session.windowId) {
 854  		console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${session.windowId}`);
 855  		try {
 856  			await chrome.tabs.move(tabId, {
 857  				windowId: session.windowId,
 858  				index: -1
 859  			});
 860  			tab = await chrome.tabs.get(tabId);
 861  		} catch (moveErr) {
 862  			console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`);
 863  		}
 864  	}
 865  	return pageScopedResult(cmd.id, tabId, {
 866  		title: tab.title,
 867  		url: tab.url,
 868  		timedOut
 869  	});
 870  }
 871  async function handleTabs(cmd, workspace) {
 872  	switch (cmd.op) {
 873  		case "list": {
 874  			const tabs = await listAutomationWebTabs(workspace);
 875  			const data = await Promise.all(tabs.map(async (t, i) => {
 876  				let page;
 877  				try {
 878  					page = t.id ? await resolveTargetId(t.id) : void 0;
 879  				} catch {}
 880  				return {
 881  					index: i,
 882  					page,
 883  					url: t.url,
 884  					title: t.title,
 885  					active: t.active
 886  				};
 887  			}));
 888  			return {
 889  				id: cmd.id,
 890  				ok: true,
 891  				data
 892  			};
 893  		}
 894  		case "new": {
 895  			if (cmd.url && !isSafeNavigationUrl(cmd.url)) return {
 896  				id: cmd.id,
 897  				ok: false,
 898  				error: "Blocked URL scheme -- only http:// and https:// are allowed"
 899  			};
 900  			const windowId = await getAutomationWindow(workspace);
 901  			const tab = await chrome.tabs.create({
 902  				windowId,
 903  				url: cmd.url ?? BLANK_PAGE,
 904  				active: true
 905  			});
 906  			if (!tab.id) return {
 907  				id: cmd.id,
 908  				ok: false,
 909  				error: "Failed to create tab"
 910  			};
 911  			return pageScopedResult(cmd.id, tab.id, { url: tab.url });
 912  		}
 913  		case "close": {
 914  			if (cmd.index !== void 0) {
 915  				const target = (await listAutomationWebTabs(workspace))[cmd.index];
 916  				if (!target?.id) return {
 917  					id: cmd.id,
 918  					ok: false,
 919  					error: `Tab index ${cmd.index} not found`
 920  				};
 921  				const closedPage = await resolveTargetId(target.id).catch(() => void 0);
 922  				await chrome.tabs.remove(target.id);
 923  				await detach(target.id);
 924  				return {
 925  					id: cmd.id,
 926  					ok: true,
 927  					data: { closed: closedPage }
 928  				};
 929  			}
 930  			const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace);
 931  			const closedPage = await resolveTargetId(tabId).catch(() => void 0);
 932  			await chrome.tabs.remove(tabId);
 933  			await detach(tabId);
 934  			return {
 935  				id: cmd.id,
 936  				ok: true,
 937  				data: { closed: closedPage }
 938  			};
 939  		}
 940  		case "select": {
 941  			if (cmd.index === void 0 && cmd.page === void 0) return {
 942  				id: cmd.id,
 943  				ok: false,
 944  				error: "Missing index or page"
 945  			};
 946  			const cmdTabId = await resolveCommandTabId(cmd);
 947  			if (cmdTabId !== void 0) {
 948  				const session = automationSessions.get(workspace);
 949  				let tab;
 950  				try {
 951  					tab = await chrome.tabs.get(cmdTabId);
 952  				} catch {
 953  					return {
 954  						id: cmd.id,
 955  						ok: false,
 956  						error: `Page no longer exists`
 957  					};
 958  				}
 959  				if (!session || tab.windowId !== session.windowId) return {
 960  					id: cmd.id,
 961  					ok: false,
 962  					error: `Page is not in the automation window`
 963  				};
 964  				await chrome.tabs.update(cmdTabId, { active: true });
 965  				return pageScopedResult(cmd.id, cmdTabId, { selected: true });
 966  			}
 967  			const target = (await listAutomationWebTabs(workspace))[cmd.index];
 968  			if (!target?.id) return {
 969  				id: cmd.id,
 970  				ok: false,
 971  				error: `Tab index ${cmd.index} not found`
 972  			};
 973  			await chrome.tabs.update(target.id, { active: true });
 974  			return pageScopedResult(cmd.id, target.id, { selected: true });
 975  		}
 976  		default: return {
 977  			id: cmd.id,
 978  			ok: false,
 979  			error: `Unknown tabs op: ${cmd.op}`
 980  		};
 981  	}
 982  }
 983  async function handleCookies(cmd) {
 984  	if (!cmd.domain && !cmd.url) return {
 985  		id: cmd.id,
 986  		ok: false,
 987  		error: "Cookie scope required: provide domain or url to avoid dumping all cookies"
 988  	};
 989  	const details = {};
 990  	if (cmd.domain) details.domain = cmd.domain;
 991  	if (cmd.url) details.url = cmd.url;
 992  	const data = (await chrome.cookies.getAll(details)).map((c) => ({
 993  		name: c.name,
 994  		value: c.value,
 995  		domain: c.domain,
 996  		path: c.path,
 997  		secure: c.secure,
 998  		httpOnly: c.httpOnly,
 999  		expirationDate: c.expirationDate
1000  	}));
1001  	return {
1002  		id: cmd.id,
1003  		ok: true,
1004  		data
1005  	};
1006  }
1007  async function handleScreenshot(cmd, workspace) {
1008  	const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace);
1009  	try {
1010  		const data = await screenshot(tabId, {
1011  			format: cmd.format,
1012  			quality: cmd.quality,
1013  			fullPage: cmd.fullPage
1014  		});
1015  		return pageScopedResult(cmd.id, tabId, data);
1016  	} catch (err) {
1017  		return {
1018  			id: cmd.id,
1019  			ok: false,
1020  			error: err instanceof Error ? err.message : String(err)
1021  		};
1022  	}
1023  }
1024  /** CDP methods permitted via the 'cdp' passthrough action. */
1025  var CDP_ALLOWLIST = new Set([
1026  	"Accessibility.getFullAXTree",
1027  	"DOM.getDocument",
1028  	"DOM.getBoxModel",
1029  	"DOM.getContentQuads",
1030  	"DOM.querySelectorAll",
1031  	"DOM.scrollIntoViewIfNeeded",
1032  	"DOMSnapshot.captureSnapshot",
1033  	"Input.dispatchMouseEvent",
1034  	"Input.dispatchKeyEvent",
1035  	"Input.insertText",
1036  	"Page.getLayoutMetrics",
1037  	"Page.captureScreenshot",
1038  	"Runtime.enable",
1039  	"Emulation.setDeviceMetricsOverride",
1040  	"Emulation.clearDeviceMetricsOverride"
1041  ]);
1042  async function handleCdp(cmd, workspace) {
1043  	if (!cmd.cdpMethod) return {
1044  		id: cmd.id,
1045  		ok: false,
1046  		error: "Missing cdpMethod"
1047  	};
1048  	if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) return {
1049  		id: cmd.id,
1050  		ok: false,
1051  		error: `CDP method not permitted: ${cmd.cdpMethod}`
1052  	};
1053  	const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace);
1054  	try {
1055  		await ensureAttached(tabId, workspace.startsWith("browser:") || workspace.startsWith("operate:"));
1056  		const data = await chrome.debugger.sendCommand({ tabId }, cmd.cdpMethod, cmd.cdpParams ?? {});
1057  		return pageScopedResult(cmd.id, tabId, data);
1058  	} catch (err) {
1059  		return {
1060  			id: cmd.id,
1061  			ok: false,
1062  			error: err instanceof Error ? err.message : String(err)
1063  		};
1064  	}
1065  }
1066  async function handleCloseWindow(cmd, workspace) {
1067  	const session = automationSessions.get(workspace);
1068  	if (session) {
1069  		if (session.owned) try {
1070  			await chrome.windows.remove(session.windowId);
1071  		} catch {}
1072  		if (session.idleTimer) clearTimeout(session.idleTimer);
1073  		workspaceTimeoutOverrides.delete(workspace);
1074  		automationSessions.delete(workspace);
1075  	}
1076  	return {
1077  		id: cmd.id,
1078  		ok: true,
1079  		data: { closed: true }
1080  	};
1081  }
1082  async function handleSetFileInput(cmd, workspace) {
1083  	if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) return {
1084  		id: cmd.id,
1085  		ok: false,
1086  		error: "Missing or empty files array"
1087  	};
1088  	const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace);
1089  	try {
1090  		await setFileInputFiles(tabId, cmd.files, cmd.selector);
1091  		return pageScopedResult(cmd.id, tabId, { count: cmd.files.length });
1092  	} catch (err) {
1093  		return {
1094  			id: cmd.id,
1095  			ok: false,
1096  			error: err instanceof Error ? err.message : String(err)
1097  		};
1098  	}
1099  }
1100  async function handleInsertText(cmd, workspace) {
1101  	if (typeof cmd.text !== "string") return {
1102  		id: cmd.id,
1103  		ok: false,
1104  		error: "Missing text payload"
1105  	};
1106  	const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace);
1107  	try {
1108  		await insertText(tabId, cmd.text);
1109  		return pageScopedResult(cmd.id, tabId, { inserted: true });
1110  	} catch (err) {
1111  		return {
1112  			id: cmd.id,
1113  			ok: false,
1114  			error: err instanceof Error ? err.message : String(err)
1115  		};
1116  	}
1117  }
1118  async function handleNetworkCaptureStart(cmd, workspace) {
1119  	const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace);
1120  	try {
1121  		await startNetworkCapture(tabId, cmd.pattern);
1122  		return pageScopedResult(cmd.id, tabId, { started: true });
1123  	} catch (err) {
1124  		return {
1125  			id: cmd.id,
1126  			ok: false,
1127  			error: err instanceof Error ? err.message : String(err)
1128  		};
1129  	}
1130  }
1131  async function handleNetworkCaptureRead(cmd, workspace) {
1132  	const tabId = await resolveTabId(await resolveCommandTabId(cmd), workspace);
1133  	try {
1134  		const data = await readNetworkCapture(tabId);
1135  		return pageScopedResult(cmd.id, tabId, data);
1136  	} catch (err) {
1137  		return {
1138  			id: cmd.id,
1139  			ok: false,
1140  			error: err instanceof Error ? err.message : String(err)
1141  		};
1142  	}
1143  }
1144  async function handleSessions(cmd) {
1145  	const now = Date.now();
1146  	const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
1147  		workspace,
1148  		windowId: session.windowId,
1149  		tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length,
1150  		idleMsRemaining: Math.max(0, session.idleDeadlineAt - now)
1151  	})));
1152  	return {
1153  		id: cmd.id,
1154  		ok: true,
1155  		data
1156  	};
1157  }
1158  async function handleBindCurrent(cmd, workspace) {
1159  	const activeTabs = await chrome.tabs.query({
1160  		active: true,
1161  		lastFocusedWindow: true
1162  	});
1163  	const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true });
1164  	const allTabs = await chrome.tabs.query({});
1165  	const boundTab = activeTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? fallbackTabs.find((tab) => matchesBindCriteria(tab, cmd)) ?? allTabs.find((tab) => matchesBindCriteria(tab, cmd));
1166  	if (!boundTab?.id) return {
1167  		id: cmd.id,
1168  		ok: false,
1169  		error: cmd.matchDomain || cmd.matchPathPrefix ? `No visible tab matching ${cmd.matchDomain ?? "domain"}${cmd.matchPathPrefix ? ` ${cmd.matchPathPrefix}` : ""}` : "No active debuggable tab found"
1170  	};
1171  	setWorkspaceSession(workspace, {
1172  		windowId: boundTab.windowId,
1173  		owned: false,
1174  		preferredTabId: boundTab.id
1175  	});
1176  	resetWindowIdleTimer(workspace);
1177  	console.log(`[opencli] Workspace ${workspace} explicitly bound to tab ${boundTab.id} (${boundTab.url})`);
1178  	return pageScopedResult(cmd.id, boundTab.id, {
1179  		url: boundTab.url,
1180  		title: boundTab.title,
1181  		workspace
1182  	});
1183  }
1184  //#endregion