@oh-my-pi/pi-coding-agent 11.4.0 → 11.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [11.5.0] - 2026-02-06
6
+
7
+ ### Added
8
+
9
+ - Added terminal breadcrumb tracking to remember the last session per terminal, enabling `--continue` to work correctly with concurrent sessions in different terminals
10
+
11
+ ### Changed
12
+
13
+ - Changed screenshot format to always use PNG instead of supporting JPEG with quality parameter
14
+ - Changed default extract_readable format from text to markdown
15
+ - Changed screenshot storage to use temporary directory with Snowflake IDs instead of artifacts directory
16
+ - Changed ResizedImage interface to return buffer as Uint8Array with lazy-loaded base64 data getter for improved memory efficiency
17
+
18
+ ### Removed
19
+
20
+ - Removed JPEG quality parameter from screenshot options
21
+ - Removed format selection for screenshots (now PNG only)
22
+ - Removed ability to save screenshots to custom paths or artifacts directory
23
+
24
+ ## [11.4.1] - 2026-02-06
25
+ ### Fixed
26
+
27
+ - Fixed tab character display in error messages and bash tool output by properly replacing tabs with spaces
28
+
5
29
  ## [11.4.0] - 2026-02-06
6
30
 
7
31
  ### Added
@@ -3971,4 +3995,4 @@ Initial public release.
3971
3995
  - Git branch display in footer
3972
3996
  - Message queueing during streaming responses
3973
3997
  - OAuth integration for Gmail and Google Calendar access
3974
- - HTML export with syntax highlighting and collapsible sections
3998
+ - HTML export with syntax highlighting and collapsible sections
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "11.4.0",
3
+ "version": "11.5.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -90,12 +90,12 @@
90
90
  "@mozilla/readability": "0.6.0",
91
91
  "@oclif/core": "^4.8.0",
92
92
  "@oclif/plugin-autocomplete": "^3.2.40",
93
- "@oh-my-pi/omp-stats": "11.4.0",
94
- "@oh-my-pi/pi-agent-core": "11.4.0",
95
- "@oh-my-pi/pi-ai": "11.4.0",
96
- "@oh-my-pi/pi-natives": "11.4.0",
97
- "@oh-my-pi/pi-tui": "11.4.0",
98
- "@oh-my-pi/pi-utils": "11.4.0",
93
+ "@oh-my-pi/omp-stats": "11.5.0",
94
+ "@oh-my-pi/pi-agent-core": "11.5.0",
95
+ "@oh-my-pi/pi-ai": "11.5.0",
96
+ "@oh-my-pi/pi-natives": "11.5.0",
97
+ "@oh-my-pi/pi-tui": "11.5.0",
98
+ "@oh-my-pi/pi-utils": "11.5.0",
99
99
  "@sinclair/typebox": "^0.34.48",
100
100
  "ajv": "^8.17.1",
101
101
  "chalk": "^5.6.2",
@@ -265,13 +265,13 @@ export const editToolRenderer = {
265
265
 
266
266
  if (result.isError) {
267
267
  if (errorText) {
268
- text += `\n\n${uiTheme.fg("error", errorText)}`;
268
+ text += `\n\n${uiTheme.fg("error", replaceTabs(errorText))}`;
269
269
  }
270
270
  } else if (result.details?.diff) {
271
271
  text += renderDiffSection(result.details.diff, rawPath, expanded, uiTheme, ui, renderDiffFn);
272
272
  } else if (editDiffPreview) {
273
273
  if ("error" in editDiffPreview) {
274
- text += `\n\n${uiTheme.fg("error", editDiffPreview.error)}`;
274
+ text += `\n\n${uiTheme.fg("error", replaceTabs(editDiffPreview.error))}`;
275
275
  } else if (editDiffPreview.diff) {
276
276
  text += renderDiffSection(editDiffPreview.diff, rawPath, expanded, uiTheme, ui, renderDiffFn);
277
277
  }
@@ -16,7 +16,7 @@ Use this tool to navigate, click, type, scroll, drag, query DOM content, and cap
16
16
  - Use `action: "get_text"`, `"get_html"`, or `"get_attribute"` for DOM queries
17
17
  - For batch queries, pass `args: [{ selector, attribute? }]` to get an array of results (attribute required for `get_attribute`)
18
18
  - Use `action: "extract_readable"` to return reader-mode content (title/byline/excerpt/text or markdown)
19
- - Set `format` to `"text"` (default) or `"markdown"`
19
+ - Set `format` to `"markdown"` (default) or `"text"`
20
20
  - Use `action: "screenshot"` to capture images (optionally with `selector` to capture a single element)
21
21
  - Use `action: "close"` to release the browser when done
22
22
  </instruction>
@@ -4,6 +4,12 @@ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
4
4
 
5
5
  const BLOB_PREFIX = "blob:sha256:";
6
6
 
7
+ export interface BlobPutResult {
8
+ hash: string;
9
+ path: string;
10
+ get ref(): string;
11
+ }
12
+
7
13
  /**
8
14
  * Content-addressed blob store for externalizing large binary data (images) from session JSONL files.
9
15
  *
@@ -18,22 +24,19 @@ export class BlobStore {
18
24
  * Write binary data to the blob store.
19
25
  * @returns SHA-256 hex hash of the data
20
26
  */
21
- async put(data: Buffer): Promise<string> {
22
- const hasher = new Bun.CryptoHasher("sha256");
23
- hasher.update(data);
24
- const hash = hasher.digest("hex");
27
+ async put(data: Buffer): Promise<BlobPutResult> {
28
+ const hash = new Bun.CryptoHasher("sha256").update(data).digest("hex");
25
29
  const blobPath = path.join(this.dir, hash);
26
-
27
- // Content-addressed: skip write if blob already exists
28
- try {
29
- await fs.access(blobPath);
30
- return hash;
31
- } catch {
32
- // Does not exist, write it
33
- }
30
+ const result = {
31
+ hash,
32
+ path: blobPath,
33
+ get ref() {
34
+ return `${BLOB_PREFIX}${hash}`;
35
+ },
36
+ };
34
37
 
35
38
  await Bun.write(blobPath, data);
36
- return hash;
39
+ return result;
37
40
  }
38
41
 
39
42
  /** Read blob by hash, returns Buffer or null if not found. */
@@ -71,11 +74,6 @@ export function parseBlobRef(data: string): string | null {
71
74
  return data.slice(BLOB_PREFIX.length);
72
75
  }
73
76
 
74
- /** Create a blob reference string from a SHA-256 hash. */
75
- export function makeBlobRef(hash: string): string {
76
- return `${BLOB_PREFIX}${hash}`;
77
- }
78
-
79
77
  /**
80
78
  * Externalize an image's base64 data to the blob store, returning a blob reference.
81
79
  * If the data is already a blob reference, returns it unchanged.
@@ -83,8 +81,8 @@ export function makeBlobRef(hash: string): string {
83
81
  export async function externalizeImageData(blobStore: BlobStore, base64Data: string): Promise<string> {
84
82
  if (isBlobRef(base64Data)) return base64Data;
85
83
  const buffer = Buffer.from(base64Data, "base64");
86
- const hash = await blobStore.put(buffer);
87
- return makeBlobRef(hash);
84
+ const { ref } = await blobStore.put(buffer);
85
+ return ref;
88
86
  }
89
87
 
90
88
  /**
@@ -1,9 +1,10 @@
1
+ import * as fs from "node:fs";
1
2
  import * as path from "node:path";
2
3
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
4
  import type { ImageContent, Message, TextContent, Usage } from "@oh-my-pi/pi-ai";
4
5
  import { isEnoent, logger, parseJsonlLenient, Snowflake } from "@oh-my-pi/pi-utils";
5
6
  import { getBlobsDir, getAgentDir as getDefaultAgentDir } from "../config";
6
- import { BlobStore, externalizeImageData, isBlobRef, resolveImageData } from "./blob-store";
7
+ import { type BlobPutResult, BlobStore, externalizeImageData, isBlobRef, resolveImageData } from "./blob-store";
7
8
  import {
8
9
  type BashExecutionMessage,
9
10
  type CustomMessage,
@@ -222,6 +223,7 @@ export type ReadonlySessionManager = Pick<
222
223
  | "getEntries"
223
224
  | "getTree"
224
225
  | "getUsageStatistics"
226
+ | "putBlob"
225
227
  >;
226
228
 
227
229
  /** Generate a unique short ID (8 hex chars, collision-checked) */
@@ -468,6 +470,96 @@ function getDefaultSessionDir(cwd: string, storage: SessionStorage): string {
468
470
  return sessionDir;
469
471
  }
470
472
 
473
+ // =============================================================================
474
+ // Terminal breadcrumbs: maps terminal (TTY) -> last session file for --continue
475
+ // =============================================================================
476
+
477
+ const TERMINAL_SESSIONS_DIR = "terminal-sessions";
478
+
479
+ /**
480
+ * Get a stable identifier for the current terminal.
481
+ * Uses the TTY device path (e.g., /dev/pts/3), falling back to environment
482
+ * variables for terminal multiplexers or terminal emulators.
483
+ * Returns null if no terminal can be identified (e.g., piped input).
484
+ */
485
+ function getTerminalId(): string | null {
486
+ // TTY device path — most reliable, unique per terminal tab
487
+ if (process.stdin.isTTY) {
488
+ try {
489
+ // On Linux/macOS, /proc/self/fd/0 -> /dev/pts/N
490
+ const ttyPath = fs.readlinkSync("/proc/self/fd/0");
491
+ if (ttyPath.startsWith("/dev/")) {
492
+ return ttyPath.slice(5).replace(/\//g, "-"); // /dev/pts/3 -> pts-3
493
+ }
494
+ } catch {}
495
+ }
496
+
497
+ // Fallback to terminal-specific env vars
498
+ const kittyId = process.env.KITTY_WINDOW_ID;
499
+ if (kittyId) return `kitty-${kittyId}`;
500
+
501
+ const tmuxPane = process.env.TMUX_PANE;
502
+ if (tmuxPane) return `tmux-${tmuxPane}`;
503
+
504
+ const terminalSessionId = process.env.TERM_SESSION_ID; // macOS Terminal.app
505
+ if (terminalSessionId) return `apple-${terminalSessionId}`;
506
+
507
+ const wtSession = process.env.WT_SESSION; // Windows Terminal
508
+ if (wtSession) return `wt-${wtSession}`;
509
+
510
+ return null;
511
+ }
512
+
513
+ /**
514
+ * Write a breadcrumb linking the current terminal to a session file.
515
+ * The breadcrumb contains the cwd and session path so --continue can
516
+ * find "this terminal's last session" even when running concurrent instances.
517
+ */
518
+ function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
519
+ const terminalId = getTerminalId();
520
+ if (!terminalId) return;
521
+
522
+ try {
523
+ const breadcrumbDir = path.join(getDefaultAgentDir(), TERMINAL_SESSIONS_DIR);
524
+ const breadcrumbFile = path.join(breadcrumbDir, terminalId);
525
+ const content = `${cwd}\n${sessionFile}\n`;
526
+ // Bun.write auto-creates parent dirs
527
+ void Bun.write(breadcrumbFile, content);
528
+ } catch {
529
+ // Best-effort — don't break session creation if breadcrumb fails
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Read the terminal breadcrumb for the current terminal, scoped to a cwd.
535
+ * Returns the session file path if it exists and matches the cwd, null otherwise.
536
+ */
537
+ async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
538
+ const terminalId = getTerminalId();
539
+ if (!terminalId) return null;
540
+
541
+ try {
542
+ const breadcrumbFile = path.join(getDefaultAgentDir(), TERMINAL_SESSIONS_DIR, terminalId);
543
+ const content = await Bun.file(breadcrumbFile).text();
544
+ const lines = content.trim().split("\n");
545
+ if (lines.length < 2) return null;
546
+
547
+ const breadcrumbCwd = lines[0];
548
+ const sessionFile = lines[1];
549
+
550
+ // Only return if cwd matches (user might have cd'd)
551
+ if (path.resolve(breadcrumbCwd) !== path.resolve(cwd)) return null;
552
+
553
+ // Verify the session file still exists
554
+ const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
555
+ if (stat?.isFile()) return sessionFile;
556
+ } catch (err) {
557
+ if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
558
+ // Breadcrumb doesn't exist or is corrupt — fall through
559
+ }
560
+ return null;
561
+ }
562
+
471
563
  /** Exported for testing */
472
564
  export async function loadEntriesFromFile(
473
565
  filePath: string,
@@ -1013,6 +1105,11 @@ export class SessionManager {
1013
1105
  // Note: call _initSession() or _initSessionFile() after construction
1014
1106
  }
1015
1107
 
1108
+ /** Puts a binary blob into the blob store and returns the blob reference */
1109
+ async putBlob(data: Buffer): Promise<BlobPutResult> {
1110
+ return this.blobStore.put(data);
1111
+ }
1112
+
1016
1113
  /** Initialize with a specific session file (used by factory methods) */
1017
1114
  private async _initSessionFile(sessionFile: string): Promise<void> {
1018
1115
  await this.setSessionFile(sessionFile);
@@ -1029,6 +1126,7 @@ export class SessionManager {
1029
1126
  this.persistError = undefined;
1030
1127
  this.persistErrorReported = false;
1031
1128
  this.sessionFile = path.resolve(sessionFile);
1129
+ writeTerminalBreadcrumb(this.cwd, this.sessionFile);
1032
1130
  this.fileEntries = await loadEntriesFromFile(this.sessionFile, this.storage);
1033
1131
  if (this.fileEntries.length > 0) {
1034
1132
  const header = this.fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
@@ -1134,6 +1232,7 @@ export class SessionManager {
1134
1232
  if (this.persist) {
1135
1233
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
1136
1234
  this.sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
1235
+ writeTerminalBreadcrumb(this.cwd, this.sessionFile);
1137
1236
  }
1138
1237
  return this.sessionFile;
1139
1238
  }
@@ -1992,7 +2091,9 @@ export class SessionManager {
1992
2091
  storage: SessionStorage = new FileSessionStorage(),
1993
2092
  ): Promise<SessionManager> {
1994
2093
  const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
1995
- const mostRecent = await findMostRecentSession(dir, storage);
2094
+ // Prefer terminal-scoped breadcrumb (handles concurrent sessions correctly)
2095
+ const terminalSession = await readTerminalBreadcrumb(cwd);
2096
+ const mostRecent = terminalSession ?? (await findMostRecentSession(dir, storage));
1996
2097
  const manager = new SessionManager(cwd, dir, true, storage);
1997
2098
  if (mostRecent) {
1998
2099
  await manager._initSessionFile(mostRecent);
package/src/tools/bash.ts CHANGED
@@ -17,7 +17,7 @@ import { applyHeadTail, normalizeBashCommand } from "./bash-normalize";
17
17
  import type { OutputMeta } from "./output-meta";
18
18
  import { allocateOutputArtifact, createTailBuffer } from "./output-utils";
19
19
  import { resolveToCwd } from "./path-utils";
20
- import { formatBytes, wrapBrackets } from "./render-utils";
20
+ import { formatBytes, replaceTabs, wrapBrackets } from "./render-utils";
21
21
  import { ToolError } from "./tool-errors";
22
22
  import { toolResult } from "./tool-result";
23
23
  import { DEFAULT_MAX_BYTES } from "./truncate";
@@ -271,11 +271,13 @@ export const bashToolRenderer = {
271
271
  const hasOutput = displayOutput.trim().length > 0;
272
272
  if (hasOutput) {
273
273
  if (expanded) {
274
- outputLines.push(...displayOutput.split("\n").map(line => uiTheme.fg("toolOutput", line)));
274
+ outputLines.push(
275
+ ...displayOutput.split("\n").map(line => uiTheme.fg("toolOutput", replaceTabs(line))),
276
+ );
275
277
  } else {
276
278
  const styledOutput = displayOutput
277
279
  .split("\n")
278
- .map(line => uiTheme.fg("toolOutput", line))
280
+ .map(line => uiTheme.fg("toolOutput", replaceTabs(line)))
279
281
  .join("\n");
280
282
  const textContent = styledOutput;
281
283
  const result = truncateToVisualLines(textContent, previewLines, width);
@@ -1,8 +1,9 @@
1
+ import { tmpdir } from "node:os";
1
2
  import * as path from "node:path";
2
3
  import { Readability } from "@mozilla/readability";
3
4
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
5
  import { StringEnum } from "@oh-my-pi/pi-ai";
5
- import { logger, untilAborted } from "@oh-my-pi/pi-utils";
6
+ import { logger, Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
6
7
  import { type Static, Type } from "@sinclair/typebox";
7
8
  import { JSDOM, VirtualConsole } from "jsdom";
8
9
  import type { Browser, CDPSession, ElementHandle, KeyInput, Page, SerializedAXNode } from "puppeteer";
@@ -13,7 +14,6 @@ import type { ToolSession } from "../sdk";
13
14
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
14
15
  import { htmlToBasicMarkdown } from "../web/scrapers/types";
15
16
  import type { OutputMeta } from "./output-meta";
16
- import { resolveToCwd } from "./path-utils";
17
17
  import stealthTamperingScript from "./puppeteer/00_stealth_tampering.txt" with { type: "text" };
18
18
  import stealthActivityScript from "./puppeteer/01_stealth_activity.txt" with { type: "text" };
19
19
  import stealthHairlineScript from "./puppeteer/02_stealth_hairline.txt" with { type: "text" };
@@ -319,11 +319,10 @@ const browserSchema = Type.Object({
319
319
  ),
320
320
  full_page: Type.Optional(Type.Boolean({ description: "Capture full page screenshot (screenshot)" })),
321
321
  format: Type.Optional(
322
- StringEnum(["png", "jpeg", "text", "markdown"], {
323
- description: "Output format (screenshot: png/jpeg, extract_readable: text/markdown)",
322
+ StringEnum(["text", "markdown"], {
323
+ description: "Output format for extract_readable (text/markdown)",
324
324
  }),
325
325
  ),
326
- quality: Type.Optional(Type.Number({ description: "JPEG quality 0-100 (screenshot)" })),
327
326
  path: Type.Optional(Type.String({ description: "Optional path to save screenshot (relative to cwd)" })),
328
327
  viewport: Type.Optional(
329
328
  Type.Object({
@@ -407,11 +406,6 @@ function clampTimeout(timeoutSeconds?: number): number {
407
406
  return Math.min(Math.max(timeoutSeconds, 1), MAX_TIMEOUT_SECONDS);
408
407
  }
409
408
 
410
- function clampQuality(quality?: number): number | undefined {
411
- if (quality === undefined) return undefined;
412
- return Math.min(Math.max(quality, 0), 100);
413
- }
414
-
415
409
  function ensureParam<T>(value: T | undefined, name: string, action: string): T {
416
410
  if (value === undefined || value === null || value === "") {
417
411
  throw new ToolError(`Missing required parameter '${name}' for action '${action}'.`);
@@ -419,10 +413,6 @@ function ensureParam<T>(value: T | undefined, name: string, action: string): T {
419
413
  return value;
420
414
  }
421
415
 
422
- function resolveArtifactsDir(session: ToolSession): string | null {
423
- return session.getArtifactsDir?.() ?? session.getSessionFile()?.slice(0, -6) ?? null;
424
- }
425
-
426
416
  function formatEvaluateResult(value: unknown): string {
427
417
  if (typeof value === "string") return value;
428
418
  if (value === undefined) return "undefined";
@@ -895,7 +885,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
895
885
  params: BrowserParams,
896
886
  signal?: AbortSignal,
897
887
  _onUpdate?: AgentToolUpdateCallback<BrowserToolDetails>,
898
- _context?: AgentToolContext,
888
+ _ctx?: AgentToolContext,
899
889
  ): Promise<AgentToolResult<BrowserToolDetails>> {
900
890
  try {
901
891
  throwIfAborted(signal);
@@ -1237,10 +1227,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
1237
1227
  }
1238
1228
  case "extract_readable": {
1239
1229
  const page = await this.ensurePage(params);
1240
- const format = params.format ?? "text";
1241
- if (format !== "text" && format !== "markdown") {
1242
- throw new ToolError("extract_readable format must be text or markdown");
1243
- }
1230
+ const format = params.format ?? "markdown";
1244
1231
  const html = (await untilAborted(signal, () => page.content())) as string;
1245
1232
  const url = page.url();
1246
1233
  const virtualConsole = new VirtualConsole();
@@ -1277,13 +1264,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
1277
1264
  }
1278
1265
  case "screenshot": {
1279
1266
  const page = await this.ensurePage(params);
1280
- const format = params.format ?? "png";
1281
- if (format !== "png" && format !== "jpeg") {
1282
- throw new ToolError("Screenshot format must be png or jpeg");
1283
- }
1284
- const imageFormat = format;
1285
1267
  const fullPage = params.selector ? false : (params.full_page ?? false);
1286
- const quality = imageFormat === "jpeg" ? clampQuality(params.quality ?? 80) : undefined;
1287
1268
  let buffer: Buffer;
1288
1269
 
1289
1270
  if (params.selector) {
@@ -1292,47 +1273,34 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
1292
1273
  if (!handle) {
1293
1274
  throw new ToolError("Screenshot selector did not resolve to an element");
1294
1275
  }
1295
- buffer = (await untilAborted(signal, () =>
1296
- handle.screenshot({ type: imageFormat, quality }),
1297
- )) as Buffer;
1276
+ buffer = (await untilAborted(signal, () => handle.screenshot({ type: "png" }))) as Buffer;
1298
1277
  await handle.dispose();
1299
1278
  details.selector = params.selector;
1300
1279
  } else {
1301
- buffer = (await untilAborted(signal, () =>
1302
- page.screenshot({ type: imageFormat, quality, fullPage }),
1303
- )) as Buffer;
1280
+ buffer = (await untilAborted(signal, () => page.screenshot({ type: "png", fullPage }))) as Buffer;
1304
1281
  }
1305
1282
 
1306
- const mimeType = imageFormat === "png" ? "image/png" : "image/jpeg";
1307
- const base64 = buffer.toBase64();
1308
- let savedPath: string | undefined;
1309
- if (params.path) {
1310
- const resolved = resolveToCwd(params.path, this.session.cwd);
1311
- const ext = path.extname(resolved);
1312
- savedPath = ext ? resolved : `${resolved}.${imageFormat}`;
1313
- await Bun.write(savedPath, buffer);
1314
- } else {
1315
- const artifactsDir = resolveArtifactsDir(this.session);
1316
- if (artifactsDir) {
1317
- savedPath = path.join(artifactsDir, `puppeteer-${Date.now()}.${imageFormat}`);
1318
- await Bun.write(savedPath, buffer);
1319
- }
1320
- }
1321
- details.screenshotPath = savedPath;
1322
- details.mimeType = mimeType;
1323
- details.bytes = buffer.length;
1324
-
1325
1283
  // Compress for API content (same as pasted images)
1326
- const resized = await resizeImage({ type: "image", data: base64, mimeType });
1284
+ // NOTE: screenshots can be deceptively large (especially PNG) even at modest resolutions,
1285
+ // and tool results are immediately embedded in the next LLM request.
1286
+ // Use a tighter budget than the global per-image limit to avoid 413 request_too_large.
1287
+ const resized = await resizeImage(
1288
+ { type: "image", data: buffer.toBase64(), mimeType: "image/png" },
1289
+ { maxBytes: 0.75 * 1024 * 1024 },
1290
+ );
1327
1291
  const dimensionNote = formatDimensionNote(resized);
1328
-
1329
- const lines = ["Screenshot captured", `Format: ${format}`, `Bytes: ${buffer.length}`];
1330
- if (resized.wasResized) {
1331
- lines.push(`Compressed: ${resized.width}x${resized.height} (${resized.mimeType})`);
1332
- }
1333
- if (savedPath) {
1334
- lines.push(`Saved: ${savedPath}`);
1335
- }
1292
+ const tempFile = path.join(tmpdir(), `omp-sshots-${Snowflake.next()}.png`);
1293
+ await Bun.write(tempFile, resized.buffer);
1294
+ details.screenshotPath = tempFile;
1295
+ details.mimeType = resized.mimeType;
1296
+ details.bytes = resized.buffer.length;
1297
+
1298
+ // Show both raw bytes (saved to disk) and compressed bytes (sent to model).
1299
+ const lines = [
1300
+ "Screenshot captured",
1301
+ `Format: ${resized.mimeType} (${(resized.buffer.length / 1024).toFixed(2)} KB)`,
1302
+ `Dimensions: ${resized.width}x${resized.height}`,
1303
+ ];
1336
1304
  if (dimensionNote) {
1337
1305
  lines.push(dimensionNote);
1338
1306
  }
@@ -9,13 +9,14 @@ export interface ImageResizeOptions {
9
9
  }
10
10
 
11
11
  export interface ResizedImage {
12
- data: string; // base64
12
+ buffer: Uint8Array;
13
13
  mimeType: string;
14
14
  originalWidth: number;
15
15
  originalHeight: number;
16
16
  width: number;
17
17
  height: number;
18
18
  wasResized: boolean;
19
+ get data(): string;
19
20
  }
20
21
 
21
22
  // 4.5MB - provides headroom below Anthropic's 5MB limit
@@ -64,13 +65,16 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
64
65
  const originalSize = inputBuffer.length;
65
66
  if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
66
67
  return {
67
- data: img.data,
68
+ buffer: inputBuffer,
68
69
  mimeType: img.mimeType ?? `image/${format}`,
69
70
  originalWidth,
70
71
  originalHeight,
71
72
  width: originalWidth,
72
73
  height: originalHeight,
73
74
  wasResized: false,
75
+ get data() {
76
+ return img.data;
77
+ },
74
78
  };
75
79
  }
76
80
 
@@ -119,13 +123,16 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
119
123
 
120
124
  if (best.buffer.length <= opts.maxBytes) {
121
125
  return {
122
- data: best.buffer.toBase64(),
126
+ buffer: best.buffer,
123
127
  mimeType: best.mimeType,
124
128
  originalWidth,
125
129
  originalHeight,
126
130
  width: finalWidth,
127
131
  height: finalHeight,
128
132
  wasResized: true,
133
+ get data() {
134
+ return best.buffer.toBase64();
135
+ },
129
136
  };
130
137
  }
131
138
 
@@ -135,13 +142,16 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
135
142
 
136
143
  if (best.buffer.length <= opts.maxBytes) {
137
144
  return {
138
- data: best.buffer.toBase64(),
145
+ buffer: best.buffer,
139
146
  mimeType: best.mimeType,
140
147
  originalWidth,
141
148
  originalHeight,
142
149
  width: finalWidth,
143
150
  height: finalHeight,
144
151
  wasResized: true,
152
+ get data() {
153
+ return best.buffer.toBase64();
154
+ },
145
155
  };
146
156
  }
147
157
  }
@@ -160,13 +170,16 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
160
170
 
161
171
  if (best.buffer.length <= opts.maxBytes) {
162
172
  return {
163
- data: best.buffer.toBase64(),
173
+ buffer: best.buffer,
164
174
  mimeType: best.mimeType,
165
175
  originalWidth,
166
176
  originalHeight,
167
177
  width: finalWidth,
168
178
  height: finalHeight,
169
179
  wasResized: true,
180
+ get data() {
181
+ return best.buffer.toBase64();
182
+ },
170
183
  };
171
184
  }
172
185
  }
@@ -174,24 +187,30 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
174
187
 
175
188
  // Last resort: return smallest version we produced
176
189
  return {
177
- data: best.buffer.toBase64(),
190
+ buffer: best.buffer,
178
191
  mimeType: best.mimeType,
179
192
  originalWidth,
180
193
  originalHeight,
181
194
  width: finalWidth,
182
195
  height: finalHeight,
183
196
  wasResized: true,
197
+ get data() {
198
+ return best.buffer.toBase64();
199
+ },
184
200
  };
185
201
  } catch {
186
202
  // Failed to load image
187
203
  return {
188
- data: img.data,
204
+ buffer: inputBuffer,
189
205
  mimeType: img.mimeType,
190
206
  originalWidth: 0,
191
207
  originalHeight: 0,
192
208
  width: 0,
193
209
  height: 0,
194
210
  wasResized: false,
211
+ get data() {
212
+ return img.data;
213
+ },
195
214
  };
196
215
  }
197
216
  }