Hmbown/CodeWhale · error · ExecError

AT-SPI action failed

Error message

AT-SPI action failed: ${(r.stderr || r.stdout).slice(0, 250)}

What it means

Thrown by atspiResolve when the embedded python3 AT-SPI script either fails to produce parseable JSON on its last stdout line or crashes. The message carries up to 250 chars of stderr (falling back to stdout) prefixed with 'AT-SPI action failed'. The wrapper runs `python3 -c <script> <name> <path-json>` with a 30s timeout, then tryJson()es the final line.

Solutions

  1. Read the 250-char payload: a Python traceback means missing modules or no AT-SPI bus; {"ok":false,"code":...} means the lookup/action itself failed.
  2. Install accessibility bindings: python3-pyatspi and at-spi2-related GIR packages for your distro.
  3. Confirm `python3 -c 'import pyatspi'` works in the same environment/user session.
  4. Ensure the desktop session exposes the accessibility bus (AT_SPI_BUS / accessibility enabled in the session settings).
  5. Re-list apps/windows and retry — the target's path may be stale after UI changes.

Example fix

// before (Debian/Ubuntu, missing bindings)
# python3 exists but pyatspi doesn't -> AT-SPI action failed
// after
sudo apt install python3-pyatspi gir1.2-atspi-2.0
Defensive patterns

Strategy: try-catch

Validate before calling

import { execFileSync } from 'child_process';
function atspiReady() {
  try {
    execFileSync('python3', ['-c', 'import pyatspi'], { stdio: 'ignore', timeout: 10_000 });
    return true;
  } catch { return false; }
}

Try / catch

try {
  const out = await atspiResolve(name, target, extraArg);
} catch (e) {
  if (e instanceof ExecError && e.message.startsWith('AT-SPI action failed')) {
    if (/ModuleNotFoundError|ImportError/.test(e.message)) installAtspiBindings();
    else if (/Traceback|No display|bus/i.test(e.message)) ensureDesktopSession();
    else await refreshTargetsAndRetry(); // stale path — re-list and retry once
  } else throw e;
}

Prevention

When it happens

Trigger: Any AT-SPI-backed action (list_apps, tree resolution, invoke action) where: python3 is missing/broken, pyatspi/gi is not installed, the target application or path no longer exists (script prints its exception JSON or tracebacks to stderr), or stdout's last line is not the expected JSON object.

Common situations: Minimal Linux installs without python3-pyatspi / gir1.2-atspi-2.0; running outside a desktop session so no AT-SPI bus is available; app closed between listing and acting so the resolved path is stale; accessibility not enabled in the session.

Understand the failure class

Background: "not installed", "pip install", "required for": how missing-dependency errors surface across open-source libraries — this error's family across 34 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/3b73acb31af8b870. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/plugins/computer-use/src/backends/linux.mjs:303

        break
    if len(p) > 12: continue
    try:
        for i in range(n.childCount):
            c = n.getChildAtIndex(i)
            if c: stack.append((c, p + [i]))
    except Exception: pass
if found is None:
    print(json.dumps({"ok": False, "code": "element_stale"}))
    sys.exit(0)
try:
${pythonBody}
except Exception as e:
    print(json.dumps({"ok": False, "code": str(e)}))`;
    const argv = ["-c", script, name, JSON.stringify(target.path ?? [])];
    if (extraArg != null) argv.push(String(extraArg));
    const r = await run("python3", argv, { timeoutMs: 30_000 });
    const out = tryJson((r.stdout.trim().split("\n").pop() ?? ""), null);
    if (!out) throw new ExecError(`AT-SPI action failed: ${(r.stderr || r.stdout).slice(0, 250)}`, r);
    return out;
  }

  // ---------- input helpers ----------
  function clickButton(button, clicks) {
    if (session === "x11") {
      const args = ["click"];
      if (clicks > 1) args.push("--repeat", String(clicks), "--delay", "80");
      args.push(String(button));
      return xdotool(args);
    }
    // ydotool click mask: down|up|count nibble (0xC0 = left click once, +1 per extra click;
    // 0x04 bit selects right button, 0x02 middle).
    const count = Math.max(1, Math.min(3, clicks));
    const code = button === 3 ? 0xc0 + count + 0x04 : button === 2 ? 0xc0 + count + 0x02 : 0xc0 + count - 1;
    return ydotool(["click", "0x" + code.toString(16)]);
  }

View on GitHub (pinned to 73e0f67d83)