jackwener/OpenCLI · error · Error

Tab ${tabId} no longer exists

Error message

Tab ${tabId} no longer exists

What it means

In ensureAttached, if chrome.tabs.get(tabId) rejects for any reason other than this function's own 'Cannot debug tab' error, the library concludes the tab is gone, deletes the cached attach entry, and throws 'Tab ${tabId} no longer exists'. It guards subsequent CDP commands from operating on a dead target.

Source

Thrown at extension/src/cdp.ts:125

function isDebuggableUrl(url?: string): boolean {
  if (!url) return true;  // empty/undefined = tab still loading, allow it
  return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:');
}

export async function ensureAttached(tabId: number, aggressiveRetry: boolean = false): Promise<void> {
  // Verify the tab URL is debuggable before attempting attach
  try {
    const tab = await chrome.tabs.get(tabId);
    if (!isDebuggableUrl(tab.url)) {
      // Invalidate cache if previously attached
      attached.delete(tabId);
      throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? 'unknown'}`);
    }
  } catch (e) {
    // Re-throw our own error, catch only chrome.tabs.get failures
    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)) {
    // Verify the debugger is still actually attached by sending a harmless command
    try {
      await sendDebuggerCommand({ tabId }, 'Runtime.evaluate', {
        expression: '1', returnByValue: true,
      }, CDP_PROBE_TIMEOUT_MS);
      return; // Still attached and working
    } catch {
      // Stale cache entry — need to re-attach
      attached.delete(tabId);
    }
  }

  // Retry attach up to 3 times — other extensions (1Password, Playwright MCP Bridge)
  // can temporarily interfere with chrome.debugger. A short delay usually resolves it.
  // Normal commands: 2 retries, 500ms delay (fast fail for non-browser use)

View on GitHub (pinned to 49907e53dc)

Solutions

  1. Re-resolve a live tab before retrying (query chrome.tabs.query for a fresh tabId)
  2. Retry after re-running the command that opens/targets the page
  3. Validate the tabId exists right before each CDP call
  4. Persist no tabIds across page/session boundaries; re-enumerate instead

Example fix

// before
const tabId = 42; // captured earlier
cdp.evaluate(tabId, expr);
// after
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) throw new Error('no active tab');
cdp.evaluate(tab.id, expr);
Defensive patterns

Strategy: retry

Validate before calling

let exists = false;
try { await chrome.tabs.get(tabId); exists = true; } catch {}
if (!exists) tabId = (await pickFreshTab()).id;

Type guard

async function resolveLiveTab(tabId: number): Promise<chrome.tabs.Tab> {
  try { return await chrome.tabs.get(tabId); }
  catch { const t = await chrome.tabs.query({ active: true, currentWindow: true });
          if (!t[0]?.id) throw new Error('no live tab'); return t[0]; }
}

Try / catch

try {
  await cdp.screenshot(tabId);
} catch (e) {
  if ((e as Error).message === `Tab ${tabId} no longer exists`) {
    tabId = await pickFreshTab();
    return retry();
  }
  throw e;
}

Prevention

When it happens

Trigger: chrome.tabs.get(tabId) fails inside ensureAttached — the tab was closed, its window closed, or an invalid tabId was passed — during any CDP operation (evaluate, screenshot, insertText, etc.).

Common situations: Script caches a tabId and reuses it after the user closed the tab; page navigations that destroy the tab (e.g. window.close()); passing a stale id from a previous session.

Related errors


AI-assisted analysis of jackwener/OpenCLI@49907e53dc (2026-08-29). Data as JSON: /api/errors/62ab1682a4162b89. Report an issue: GitHub.