can1357/oh-my-pi · error · ToolError

Element id ${id} is stale. Run tab.observe() again.

Error message

Element id ${id} is stale. Run tab.observe() again.

What it means

After finding the cached handle, the worker verifies el.isConnected inside the page. A disconnected element means the DOM node was removed/replaced since observe() (navigation, re-render, hydration), so the cache is cleared and this ToolError is thrown. The same message is also thrown if checking isConnected itself fails (e.g. the execution context was destroyed by a navigation).

Source

Thrown at packages/coding-agent/src/tools/browser/tab-worker.ts:2128

	): Promise<HTTPResponse> {
		const page = this.#requirePage();
		const predicate: (response: HTTPResponse) => boolean | Promise<boolean> =
			typeof pattern === "function"
				? pattern
				: pattern instanceof RegExp
					? response => pattern.test(response.url())
					: response => response.url().includes(pattern);
		return (await untilAborted(signal, () => page.waitForResponse(predicate, { timeout, signal }))) as HTTPResponse;
	}

	async #resolveCachedHandle(id: number): Promise<ElementHandle> {
		const handle = this.#elementCache.get(id);
		if (!handle) throw new ToolError(`Unknown element id ${id}. Run tab.observe() to refresh the element list.`);
		try {
			const isConnected = (await handle.evaluate(el => el.isConnected)) as boolean;
			if (!isConnected) {
				this.#clearElementCache();
				throw new ToolError(`Element id ${id} is stale. Run tab.observe() again.`);
			}
		} catch (err) {
			if (err instanceof ToolError) throw err;
			this.#clearElementCache();
			throw new ToolError(`Element id ${id} is stale. Run tab.observe() again.`);
		}
		return handle;
	}

	async #resolveAriaRef(id: string): Promise<ElementHandle> {
		const ref = parseAriaRefSelector(id) ?? id.trim();
		const handle = await resolveAriaRefHandle(this.#requirePage(), ref);
		if (!handle) {
			throw new ToolError(
				`Unknown ARIA ref ${JSON.stringify(ref)}. Run tab.ariaSnapshot() to refresh refs (they renumber each snapshot).`,
			);
		}
		return handle;

View on GitHub (pinned to 9690622007)

Solutions

  1. Run tab.observe() again immediately before the action to rebuild ids, then retry with the fresh id.
  2. Minimize the delay between observe and action; perform actions right after observing.
  3. If navigations are expected, wait for load after navigation and re-observe before interacting.
  4. For flaky re-rendering pages, target stable selectors/aria-refs rather than cached ids.

Example fix

// before
const id = (await tab.observe()).elements[3].id;
await clickApplyButton(); // triggers re-render
await tab.click(id); // stale
// after
const id = (await tab.observe()).elements[3].id;
await clickApplyButton();
await tab.waitForLoad?.();
const fresh = (await tab.observe()).elements.find(e => e.name === "the element");
await tab.click(fresh.id);
Defensive patterns

Strategy: retry

Validate before calling

const connected = await tab.evaluate((el) => el.isConnected, id);
if (!connected) await tab.observe();

Try / catch

try {
  await tab.click(id);
} catch (err) {
  if (err.message.startsWith("Element id") && err.message.includes("stale")) {
    const fresh = await tab.observe();
    const el = fresh.elements.find(e => e.name === expectedName);
    if (el) return tab.click(el.id);
  }
  throw err;
}

Prevention

When it happens

Trigger: Acting on an element id after the page navigated or the framework re-rendered and detached the node; evaluation context destroyed mid-check (SPA client-side navigation); element removed by an async update between observe and action.

Common situations: React/Vue re-render replacing list items; SPA route change wiping old nodes; login/session redirect between observe and click; long delay between observe and action letting the page update itself.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/f6c9451b6cf66e25. Report an issue: GitHub.