Hmbown/CodeWhale · critical · ExecError

screencapture exited

Error message

screencapture exited ${r.code}: ${r.stderr.trim().slice(0, 300)}

What it means

The darwin backend invokes the macOS `screencapture` CLI (20s timeout). If it exits with a non-zero status, the backend throws an ExecError embedding the exit code and up to 300 chars of stderr. This is a wrapper over OS-level capture failure — permission, session, or resource issues — not a validation error.

Solutions

  1. Grant Screen Recording permission to the host app/terminal in System Settings > Privacy & Security > Screen Recording, then restart it.
  2. Run inside a logged-in GUI session (not SSH/headless) where WindowServer is available.
  3. Read the embedded stderr in the error message for the specific screencapture failure reason.
  4. Unlock the screen and ensure a display is attached before retrying.
Defensive patterns

Strategy: try-catch

Try / catch

try { await screenshot() } catch (e) {
  if (String(e.message).startsWith("screencapture exited")) {
    // log stderr from the message; usually TCC screen-recording permission
    console.error("Screen Recording permission or GUI session missing:", e.message);
  } else throw e;
}

Prevention

When it happens

Trigger: Running without Screen Recording TCC permission (System Settings > Privacy & Security > Screen Recording); running in an SSH/headless session without a WindowServer; screencapture failing on an invalid -R rect or display; the screen-locked/secure-input state blocking capture.

Common situations: First run of the app after install where the permission prompt was dismissed; CI runners without a GUI login; kiosk/VNC environments where the window server is unavailable.

Related errors


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

Appendix: source

Thrown at crates/tui/plugins/computer-use/src/backends/darwin.mjs:548

    // mis-target every later coordinate. A window capture ignores `display`.
    let displays = null;
    if (!window) {
      displays = await displayInfo();
      if (disp != null && disp !== "all" && !displays.some((x) => x.index === disp)) {
        throw new ExecError(`no display ${disp}; have [${displays.map((x) => x.index).join(", ")}] — screenshot takes the display index from list_displays, not its id`);
      }
    }
    if (window) args.push("-o", "-l", String(window.window_id));
    else if (disp && disp !== "all") args.push("-D", String(disp));
    if (region) {
      if (!region.every((n) => Number.isFinite(n) && n >= 0) || region.length !== 4) {
        throw new ExecError("region must be [x, y, w, h] in screen points");
      }
      args.push("-R", region.join(","));
    }
    args.push(file);
    const r = await runL("screencapture", args, { timeoutMs: 20_000 });
    if (r.code !== 0) throw new ExecError(`screencapture exited ${r.code}: ${r.stderr.trim().slice(0, 300)}`, r);
    await fitRasterToBudget(file);
    const stat = fs.statSync(file);
    displays ??= await displayInfo();
    const d = displays.find((x) => x.index === (disp === "all" ? 1 : disp)) ?? displays[0];
    const scale = d?.scale ?? 1;
    state.lastRaster = {
      file,
      ...(window ? { app_ref, window_index: window_id ?? 0 } : {}),
      bytes: stat.size,
      display: disp ?? 1,
      // Region and window rasters describe that rect, not the whole display.
      // The PNG header is the pixel ground truth; scale is derived from
      // pixels/points below so Retina and mixed-DPI stay exact.
      points: window?.points ?? (region ? { x: region[0], y: region[1], w: region[2], h: region[3] } : d?.points ?? null),
      pixels: imagePixels(file),
      scale: d?.scale ?? 1,
      capturedAt: new Date().toISOString(),
    };

View on GitHub (pinned to 73e0f67d83)