Hmbown/CodeWhale · error · ExecError

screenshot path must end in .png, .jpg or .jpeg

Error message

screenshot path must end in .png, .jpg or .jpeg

What it means

The screenshot API validates that the requested output path has a .png, .jpg or .jpeg extension (case-insensitive). The extension also selects the capture format via the underlying screencapture tool, so an unknown extension cannot be mapped to a format.

Solutions

  1. End the path in .jpg/.jpeg (preferred: much smaller for photographic screen content) or .png for lossless
  2. Convert the captured file to another format afterwards with a separate tool if another format is needed
  3. Omit outPath entirely to get an auto-generated timestamped .jpg path
  4. Fix filename templates that append unsupported suffixes

Example fix

// before
await screenshot({ outPath: "shot.bmp" });
// after
await screenshot({ outPath: "shot.jpg" });
Defensive patterns

Strategy: validation

Validate before calling

if (!/\.(png|jpe?g)$/i.test(outPath)) throw new Error(`unsupported screenshot format: ${outPath}; use .png, .jpg or .jpeg`);

Try / catch

try {
  await screenshot({ outPath });
} catch (e) {
  if (e instanceof ExecError && e.message.includes("must end in .png")) {
    await screenshot({ outPath: outPath.replace(/\.\w+$/, "") + ".jpg" });
  } else throw e;
}

Prevention

When it happens

Trigger: Calling screenshot with outPath like 'shot.bmp', 'shot.txt', 'shot' (no extension), 'shot.tiff', or a path with a trailing character after the extension.

Common situations: Caller picking an image format the backend does not support (bmp/webp); building filenames from templates that append suffixes; forgetting the extension entirely.

Understand the failure class

Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.

Related errors


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

Appendix: source

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

  // ---------- screenshots ----------
  function recordingsDir() {
    return process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings");
  }

  async function screenshot({ display, region, app_ref, window_id, path: outPath } = {}) {
    // Once an app is selected, ordinary observations follow it behind the
    // user's work. An explicit display/region remains a deliberate desktop capture.
    if (app_ref === undefined && display === undefined && region === undefined) app_ref = state.inputApp ?? undefined;
    const dir = recordingsDir();
    fs.mkdirSync(dir, { recursive: true });
    // JPEG, not PNG. A screen is photographic content — gradients, wallpaper,
    // antialiased text — and lossless compression of it is enormous: the same
    // 5760x3240 frame is 21.8MB as PNG and 2.1MB as JPEG, at full resolution
    // and with terminal text still crisp. PNG stays available by asking for a
    // `.png` path, which is what a pixel-exact comparison wants.
    const file = outPath || path.join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}.jpg`);
    if (!/\.(png|jpe?g)$/i.test(file)) throw new ExecError("screenshot path must end in .png, .jpg or .jpeg");
    const args = ["-x", "-t", /\.png$/i.test(file) ? "png" : "jpg"];
    const disp = display ?? state.activeDisplay;
    // An explicit app reference resolves first and alone: nothing may run
    // before it and redirect the capture to another target.
    const window = app_ref !== undefined ? await native("window_info", { app_ref, window_id }) : null;
    if (window && region) throw new ExecError("choose app_ref or region, not both");
    // On the display path, resolve displays before capturing so an unknown
    // index is a clean error instead of a raster silently labelled with another
    // display's geometry — list_displays reports `index` and `id` separately,
    // and a caller passing the id would otherwise get points and scale that
    // 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`);
      }
    }

View on GitHub (pinned to 73e0f67d83)