@oh-my-pi/pi-coding-agent 5.0.1 → 5.1.1

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,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.1.1] - 2026-01-14
6
+
7
+ ### Fixed
8
+
9
+ - Fixed clipboard image paste getting stuck on Wayland when no image is present (was falling back to X11 and timing out)
10
+
11
+ ## [5.1.0] - 2026-01-14
12
+
13
+ ### Changed
14
+
15
+ - Updated light theme colors for WCAG AA compliance (4.5:1 contrast against white background)
16
+ - Changed dequeue hint text from "restore" to "edit all queued messages"
17
+
18
+ ### Fixed
19
+
20
+ - Fixed session selector staying open when current folder has no sessions (shows hint to press Tab)
21
+ - Fixed print mode JSON output to emit session header at start
22
+ - Fixed "database is locked" SQLite errors when running subagents by serializing settings to workers instead of opening the database
23
+ - Fixed `/new` command to create a new session file (previously reused the same file when `--session` was specified)
24
+ - Fixed session selector page up/down navigation
25
+
5
26
  ## [5.0.1] - 2026-01-12
6
27
  ### Changed
7
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "5.0.1",
3
+ "version": "5.1.1",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,12 +39,12 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-agent-core": "5.0.1",
43
- "@oh-my-pi/pi-ai": "5.0.1",
44
- "@oh-my-pi/pi-git-tool": "5.0.1",
45
- "@oh-my-pi/pi-tui": "5.0.1",
42
+ "@oh-my-pi/pi-agent-core": "5.1.1",
43
+ "@oh-my-pi/pi-ai": "5.1.1",
44
+ "@oh-my-pi/pi-git-tool": "5.1.1",
45
+ "@oh-my-pi/pi-tui": "5.1.1",
46
46
  "@openai/agents": "^0.3.7",
47
- "@silvia-odwyer/photon": "^0.3.3",
47
+ "@silvia-odwyer/photon-node": "^0.3.4",
48
48
  "@sinclair/typebox": "^0.34.46",
49
49
  "ajv": "^8.17.1",
50
50
  "chalk": "^5.5.0",
@@ -173,6 +173,7 @@ export class AgentStorage {
173
173
  this.db.exec(`
174
174
  PRAGMA journal_mode=WAL;
175
175
  PRAGMA synchronous=NORMAL;
176
+ PRAGMA busy_timeout=5000;
176
177
 
177
178
  CREATE TABLE IF NOT EXISTS auth_credentials (
178
179
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -42,6 +42,7 @@ export class HistoryStorage {
42
42
  this.db.exec(`
43
43
  PRAGMA journal_mode=WAL;
44
44
  PRAGMA synchronous=NORMAL;
45
+ PRAGMA busy_timeout=5000;
45
46
 
46
47
  CREATE TABLE IF NOT EXISTS history (
47
48
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -8,7 +8,6 @@ import {
8
8
  getGitHubCopilotBaseUrl,
9
9
  getModels,
10
10
  getProviders,
11
- type KnownProvider,
12
11
  type Model,
13
12
  normalizeDomain,
14
13
  } from "@oh-my-pi/pi-ai";
@@ -250,7 +249,7 @@ export class ModelRegistry {
250
249
  return getProviders()
251
250
  .filter((provider) => !replacedProviders.has(provider))
252
251
  .flatMap((provider) => {
253
- const models = getModels(provider as KnownProvider) as Model<Api>[];
252
+ const models = getModels(provider as any) as Model<Api>[];
254
253
  const override = overrides.get(provider);
255
254
  if (!override) return models;
256
255
 
@@ -11,6 +11,7 @@ import type { ModelRegistry } from "./model-registry";
11
11
 
12
12
  /** Default model IDs for each known provider */
13
13
  export const defaultModelPerProvider: Record<KnownProvider, string> = {
14
+ "amazon-bedrock": "us.anthropic.claude-sonnet-4-5-20250514-v1:0",
14
15
  anthropic: "claude-sonnet-4-5",
15
16
  openai: "gpt-5.1-codex",
16
17
  "openai-codex": "codex-max",
@@ -21,11 +22,13 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
21
22
  "github-copilot": "gpt-4o",
22
23
  cursor: "claude-sonnet-4-5",
23
24
  openrouter: "openai/gpt-5.1-codex",
25
+ "vercel-ai-gateway": "claude-sonnet-4-5",
24
26
  xai: "grok-4-fast-non-reasoning",
25
27
  groq: "openai/gpt-oss-120b",
26
28
  cerebras: "zai-glm-4.6",
27
29
  zai: "glm-4.6",
28
30
  mistral: "devstral-medium-latest",
31
+ minimax: "MiniMax-M2",
29
32
  opencode: "claude-opus-4-5",
30
33
  };
31
34
 
package/src/core/sdk.ts CHANGED
@@ -689,6 +689,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
689
689
  return activeModel ? formatModelString(activeModel) : undefined;
690
690
  },
691
691
  settings: settingsManager,
692
+ settingsManager,
692
693
  authStorage,
693
694
  modelRegistry,
694
695
  };
@@ -894,7 +894,9 @@ export class SessionManager {
894
894
  this._buildIndex();
895
895
  this.flushed = true;
896
896
  } else {
897
+ const explicitPath = this.sessionFile;
897
898
  this._newSessionSync();
899
+ this.sessionFile = explicitPath; // preserve explicit path from --session flag
898
900
  }
899
901
  }
900
902
 
@@ -921,12 +923,12 @@ export class SessionManager {
921
923
  };
922
924
  this.fileEntries = [header];
923
925
  this.byId.clear();
926
+ this.labelsById.clear();
924
927
  this.leafId = null;
925
928
  this.flushed = false;
926
929
  this.usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
927
930
 
928
- // Only generate filename if persisting and not already set (e.g., via --session flag)
929
- if (this.persist && !this.sessionFile) {
931
+ if (this.persist) {
930
932
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
931
933
  this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
932
934
  }
@@ -506,6 +506,14 @@ export class SettingsManager {
506
506
  return new SettingsManager(null, null, settings, false, {});
507
507
  }
508
508
 
509
+ /**
510
+ * Serialize settings for passing to subagent workers.
511
+ * Returns the merged settings (global + project + overrides).
512
+ */
513
+ serialize(): Settings {
514
+ return { ...this.settings };
515
+ }
516
+
509
517
  /**
510
518
  * Load settings from SQLite storage, applying any schema migrations.
511
519
  * @param storage - AgentStorage instance, or null for in-memory mode
@@ -116,6 +116,8 @@ export interface ToolSession {
116
116
  modelRegistry?: import("../model-registry").ModelRegistry;
117
117
  /** MCP manager for proxying MCP calls through parent */
118
118
  mcpManager?: import("../mcp/manager").MCPManager;
119
+ /** Settings manager for passing to subagents (avoids SQLite access in workers) */
120
+ settingsManager?: { serialize: () => import("../settings-manager").Settings };
119
121
  /** Settings manager (optional) */
120
122
  settings?: {
121
123
  getImageAutoResize(): boolean;
@@ -52,6 +52,7 @@ export interface ExecutorOptions {
52
52
  mcpManager?: MCPManager;
53
53
  authStorage?: AuthStorage;
54
54
  modelRegistry?: ModelRegistry;
55
+ settingsManager?: { serialize: () => import("../../settings-manager").Settings };
55
56
  }
56
57
 
57
58
  /**
@@ -600,6 +601,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
600
601
  enableLsp,
601
602
  serializedAuth: options.authStorage?.serialize(),
602
603
  serializedModels: options.modelRegistry?.serialize(),
604
+ serializedSettings: options.settingsManager?.serialize(),
603
605
  mcpTools: options.mcpManager ? extractMCPToolMetadata(options.mcpManager) : undefined,
604
606
  },
605
607
  };
@@ -364,6 +364,7 @@ export async function createTaskTool(
364
364
  },
365
365
  authStorage: session.authStorage,
366
366
  modelRegistry: session.modelRegistry,
367
+ settingsManager: session.settingsManager,
367
368
  mcpManager: session.mcpManager,
368
369
  });
369
370
  },
@@ -1,6 +1,7 @@
1
1
  import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { SerializedAuthStorage } from "../../auth-storage";
3
3
  import type { SerializedModelRegistry } from "../../model-registry";
4
+ import type { Settings } from "../../settings-manager";
4
5
 
5
6
  /**
6
7
  * MCP tool metadata passed from parent to worker for proxy tool creation.
@@ -51,6 +52,7 @@ export interface SubagentWorkerStartPayload {
51
52
  spawnsEnv?: string;
52
53
  serializedAuth?: SerializedAuthStorage;
53
54
  serializedModels?: SerializedModelRegistry;
55
+ serializedSettings?: Settings;
54
56
  mcpTools?: MCPToolMetadata[];
55
57
  }
56
58
 
@@ -23,6 +23,7 @@ import { ModelRegistry } from "../../model-registry";
23
23
  import { parseModelPattern, parseModelString } from "../../model-resolver";
24
24
  import { createAgentSession, discoverAuthStorage, discoverModels } from "../../sdk";
25
25
  import { SessionManager } from "../../session-manager";
26
+ import { SettingsManager } from "../../settings-manager";
26
27
  import { untilAborted } from "../../utils";
27
28
  import type {
28
29
  MCPToolCallResponse,
@@ -296,6 +297,10 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
296
297
  : SessionManager.inMemory(payload.cwd);
297
298
  checkAbort();
298
299
 
300
+ // Use serialized settings if provided, otherwise use empty in-memory settings
301
+ // This avoids opening the SQLite database in worker threads
302
+ const settingsManager = SettingsManager.inMemory(payload.serializedSettings ?? {});
303
+
299
304
  // Create agent session (equivalent to CLI's createAgentSession)
300
305
  // Note: hasUI: false disables interactive features
301
306
  const completionInstruction =
@@ -305,6 +310,7 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload):
305
310
  cwd: payload.cwd,
306
311
  authStorage,
307
312
  modelRegistry,
313
+ settingsManager,
308
314
  model,
309
315
  thinkingLevel,
310
316
  toolNames: payload.toolNames,
package/src/index.ts CHANGED
@@ -260,9 +260,11 @@ export {
260
260
  } from "./modes/interactive/components/index";
261
261
  // Theme utilities for custom tools
262
262
  export {
263
+ getLanguageFromPath,
263
264
  getMarkdownTheme,
264
265
  getSelectListTheme,
265
266
  getSettingsListTheme,
267
+ highlightCode,
266
268
  initTheme,
267
269
  Theme,
268
270
  type ThemeColor,
@@ -7,6 +7,8 @@ import {
7
7
  isCtrlC,
8
8
  isEnter,
9
9
  isEscape,
10
+ isPageDown,
11
+ isPageUp,
10
12
  Spacer,
11
13
  Text,
12
14
  truncateToWidth,
@@ -25,14 +27,16 @@ class SessionList implements Component {
25
27
  private filteredSessions: SessionInfo[] = [];
26
28
  private selectedIndex: number = 0;
27
29
  private searchInput: Input;
30
+ private showCwd = false;
28
31
  public onSelect?: (sessionPath: string) => void;
29
32
  public onCancel?: () => void;
30
33
  public onExit: () => void = () => {};
31
34
  private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)
32
35
 
33
- constructor(sessions: SessionInfo[]) {
36
+ constructor(sessions: SessionInfo[], showCwd = false) {
34
37
  this.allSessions = sessions;
35
38
  this.filteredSessions = sessions;
39
+ this.showCwd = showCwd;
36
40
  this.searchInput = new Input();
37
41
 
38
42
  // Handle Enter in search input - select current item
@@ -67,7 +71,13 @@ class SessionList implements Component {
67
71
  lines.push(""); // Blank line after search
68
72
 
69
73
  if (this.filteredSessions.length === 0) {
70
- lines.push(theme.fg("muted", " No sessions found"));
74
+ if (this.showCwd) {
75
+ // "All" scope - no sessions anywhere that match filter
76
+ lines.push(theme.fg("muted", " No sessions found"));
77
+ } else {
78
+ // "Current folder" scope - hint to try "all"
79
+ lines.push(theme.fg("muted", " No sessions in current folder. Press Tab to view all."));
80
+ }
71
81
  return lines;
72
82
  }
73
83
 
@@ -154,6 +164,14 @@ class SessionList implements Component {
154
164
  else if (isArrowDown(keyData)) {
155
165
  this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1);
156
166
  }
167
+ // Page up - jump up by maxVisible items
168
+ else if (isPageUp(keyData)) {
169
+ this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible);
170
+ }
171
+ // Page down - jump down by maxVisible items
172
+ else if (isPageDown(keyData)) {
173
+ this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + this.maxVisible);
174
+ }
157
175
  // Enter
158
176
  else if (isEnter(keyData)) {
159
177
  const selected = this.filteredSessions[this.selectedIndex];
@@ -211,11 +229,6 @@ export class SessionSelectorComponent extends Container {
211
229
  // Add bottom border
212
230
  this.addChild(new Spacer(1));
213
231
  this.addChild(new DynamicBorder());
214
-
215
- // Auto-cancel if no sessions
216
- if (sessions.length === 0) {
217
- setTimeout(() => onCancel(), 100);
218
- }
219
232
  }
220
233
 
221
234
  getSessionList(): SessionList {
@@ -2,13 +2,13 @@
2
2
  "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/theme-schema.json",
3
3
  "name": "light",
4
4
  "vars": {
5
- "teal": "#5f8787",
6
- "blue": "#5f87af",
7
- "green": "#87af87",
8
- "red": "#af5f5f",
9
- "yellow": "#d7af5f",
5
+ "teal": "#5a8080",
6
+ "blue": "#547da7",
7
+ "green": "#588458",
8
+ "red": "#aa5555",
9
+ "yellow": "#9a7326",
10
10
  "mediumGray": "#6c6c6c",
11
- "dimGray": "#8a8a8a",
11
+ "dimGray": "#767676",
12
12
  "lightGray": "#b0b0b0",
13
13
  "selectedBg": "#d0d0e0",
14
14
  "userMsgBg": "#e8e8e8",
@@ -68,9 +68,9 @@
68
68
  "syntaxPunctuation": "#000000",
69
69
 
70
70
  "thinkingOff": "lightGray",
71
- "thinkingMinimal": "#9e9e9e",
72
- "thinkingLow": "#5f87af",
73
- "thinkingMedium": "#5f8787",
71
+ "thinkingMinimal": "#767676",
72
+ "thinkingLow": "blue",
73
+ "thinkingMedium": "teal",
74
74
  "thinkingHigh": "#875f87",
75
75
  "thinkingXhigh": "#8b008b",
76
76
 
@@ -340,6 +340,8 @@ export class UiHelpers {
340
340
  const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
341
341
  this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
342
342
  }
343
+ const hintText = theme.fg("dim", `${theme.tree.hook} Alt+Up to edit`);
344
+ this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
343
345
  }
344
346
  }
345
347
 
@@ -29,6 +29,14 @@ export interface PrintModeOptions {
29
29
  */
30
30
  export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise<void> {
31
31
  const { mode, messages = [], initialMessage, initialImages } = options;
32
+
33
+ // Emit session header for JSON mode
34
+ if (mode === "json") {
35
+ const header = session.sessionManager.getHeader();
36
+ if (header) {
37
+ process.stdout.write(`${JSON.stringify(header)}\n`);
38
+ }
39
+ }
32
40
  // Set up extensions for print mode (no UI, no command context)
33
41
  const extensionRunner = session.extensionRunner;
34
42
  if (extensionRunner) {
@@ -151,19 +151,29 @@ export async function readImageFromClipboard(): Promise<ClipboardImage | null> {
151
151
  return null;
152
152
  }
153
153
 
154
+ type ClipboardReadResult =
155
+ | { status: "found"; image: ClipboardImage }
156
+ | { status: "empty" } // Tools ran successfully, no image in clipboard
157
+ | { status: "unavailable" }; // Tools not found or failed to run
158
+
154
159
  async function readImageLinux(timeout: number): Promise<ClipboardImage | null> {
155
160
  const wayland = isWaylandSession();
156
161
  if (wayland) {
157
- const image = await readImageWayland(timeout);
158
- if (image) return image;
162
+ const result = await readImageWayland(timeout);
163
+ if (result.status === "found") return result.image;
164
+ if (result.status === "empty") return null; // Don't fall back to X11 if Wayland worked
159
165
  }
160
166
 
161
- return await readImageX11(timeout);
167
+ const result = await readImageX11(timeout);
168
+ return result.status === "found" ? result.image : null;
162
169
  }
163
170
 
164
- async function readImageWayland(timeout: number): Promise<ClipboardImage | null> {
165
- const types = await spawnAndRead(["wl-paste", "--list-types"], timeout);
166
- if (!types) return null;
171
+ async function readImageWayland(timeout: number): Promise<ClipboardReadResult> {
172
+ const wlPastePath = Bun.which("wl-paste");
173
+ if (!wlPastePath) return { status: "unavailable" };
174
+
175
+ const types = await spawnAndRead([wlPastePath, "--list-types"], timeout);
176
+ if (!types) return { status: "unavailable" }; // Command failed
167
177
 
168
178
  const typeList = types
169
179
  .toString("utf-8")
@@ -172,43 +182,46 @@ async function readImageWayland(timeout: number): Promise<ClipboardImage | null>
172
182
  .filter(Boolean);
173
183
 
174
184
  const selectedType = selectPreferredImageMimeType(typeList);
175
- if (!selectedType) return null;
185
+ if (!selectedType) return { status: "empty" }; // No image types available
176
186
 
177
- const imageData = await spawnAndRead(["wl-paste", "--type", selectedType, "--no-newline"], timeout);
178
- if (!imageData || imageData.length === 0) return null;
187
+ const imageData = await spawnAndRead([wlPastePath, "--type", selectedType, "--no-newline"], timeout);
188
+ if (!imageData || imageData.length === 0) return { status: "empty" };
179
189
 
180
190
  return {
181
- data: imageData.toString("base64"),
182
- mimeType: baseMimeType(selectedType),
191
+ status: "found",
192
+ image: {
193
+ data: imageData.toString("base64"),
194
+ mimeType: baseMimeType(selectedType),
195
+ },
183
196
  };
184
197
  }
185
198
 
186
- async function readImageX11(timeout: number): Promise<ClipboardImage | null> {
187
- const targets = await spawnAndRead(["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"], timeout);
199
+ async function readImageX11(timeout: number): Promise<ClipboardReadResult> {
200
+ const xclipPath = Bun.which("xclip");
201
+ if (!xclipPath) return { status: "unavailable" };
188
202
 
189
- let candidateTypes: string[] = [];
190
- if (targets) {
191
- candidateTypes = targets
192
- .toString("utf-8")
193
- .split(/\r?\n/)
194
- .map((t) => t.trim())
195
- .filter(Boolean);
196
- }
203
+ const targets = await spawnAndRead([xclipPath, "-selection", "clipboard", "-t", "TARGETS", "-o"], timeout);
204
+ if (!targets) return { status: "unavailable" }; // xclip failed (no X server?)
197
205
 
198
- const preferred = candidateTypes.length > 0 ? selectPreferredImageMimeType(candidateTypes) : null;
199
- const tryTypes = preferred ? [preferred, ...PREFERRED_IMAGE_MIME_TYPES] : [...PREFERRED_IMAGE_MIME_TYPES];
206
+ const candidateTypes = targets
207
+ .toString("utf-8")
208
+ .split(/\r?\n/)
209
+ .map((t) => t.trim())
210
+ .filter(Boolean);
200
211
 
201
- for (const mimeType of tryTypes) {
202
- const imageData = await spawnAndRead(["xclip", "-selection", "clipboard", "-t", mimeType, "-o"], timeout);
203
- if (imageData && imageData.length > 0) {
204
- return {
205
- data: imageData.toString("base64"),
206
- mimeType: baseMimeType(mimeType),
207
- };
208
- }
209
- }
212
+ const selectedType = selectPreferredImageMimeType(candidateTypes);
213
+ if (!selectedType) return { status: "empty" }; // Clipboard has no image types
210
214
 
211
- return null;
215
+ const imageData = await spawnAndRead([xclipPath, "-selection", "clipboard", "-t", selectedType, "-o"], timeout);
216
+ if (!imageData || imageData.length === 0) return { status: "empty" };
217
+
218
+ return {
219
+ status: "found",
220
+ image: {
221
+ data: imageData.toString("base64"),
222
+ mimeType: baseMimeType(selectedType),
223
+ },
224
+ };
212
225
  }
213
226
 
214
227
  async function readImageMacOS(timeout: number): Promise<ClipboardImage | null> {
@@ -1,11 +1,8 @@
1
- import { logger } from "../core/logger";
2
- import { convertToPngWithImageMagick } from "./image-magick";
3
- import { getPhoton } from "./photon";
1
+ import photon from "@silvia-odwyer/photon-node";
4
2
 
5
3
  /**
6
4
  * Convert image to PNG format for terminal display.
7
5
  * Kitty graphics protocol requires PNG format (f=100).
8
- * Uses Photon (Rust/WASM) if available, falls back to ImageMagick.
9
6
  */
10
7
  export async function convertToPng(
11
8
  base64Data: string,
@@ -17,7 +14,6 @@ export async function convertToPng(
17
14
  }
18
15
 
19
16
  try {
20
- const photon = await getPhoton();
21
17
  const image = photon.PhotonImage.new_from_byteslice(new Uint8Array(Buffer.from(base64Data, "base64")));
22
18
  try {
23
19
  const pngBuffer = image.get_bytes();
@@ -28,13 +24,8 @@ export async function convertToPng(
28
24
  } finally {
29
25
  image.free();
30
26
  }
31
- } catch (error) {
32
- // Photon failed, try ImageMagick fallback
33
- logger.error("Failed to convert image to PNG with Photon", {
34
- error: error instanceof Error ? error.message : String(error),
35
- });
27
+ } catch {
28
+ // Conversion failed
29
+ return null;
36
30
  }
37
-
38
- // Fall back to ImageMagick
39
- return convertToPngWithImageMagick(base64Data, mimeType);
40
31
  }
@@ -1,7 +1,5 @@
1
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
- import { logger } from "../core/logger";
3
- import { getImageDimensionsWithImageMagick, resizeWithImageMagick } from "./image-magick";
4
- import { getPhoton } from "./photon";
2
+ import photon from "@silvia-odwyer/photon-node";
5
3
 
6
4
  export interface ImageResizeOptions {
7
5
  maxWidth?: number; // Default: 2000
@@ -30,49 +28,6 @@ const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
30
28
  jpegQuality: 80,
31
29
  };
32
30
 
33
- /**
34
- * Fallback resize using ImageMagick when Photon is unavailable.
35
- */
36
- async function resizeImageWithImageMagick(
37
- img: ImageContent,
38
- opts: Required<ImageResizeOptions>,
39
- ): Promise<ResizedImage> {
40
- const dims = await getImageDimensionsWithImageMagick(img.data);
41
- const originalWidth = dims?.width ?? 0;
42
- const originalHeight = dims?.height ?? 0;
43
-
44
- const result = await resizeWithImageMagick(
45
- img.data,
46
- img.mimeType,
47
- opts.maxWidth,
48
- opts.maxHeight,
49
- opts.maxBytes,
50
- opts.jpegQuality,
51
- );
52
-
53
- if (result) {
54
- return {
55
- data: result.data,
56
- mimeType: result.mimeType,
57
- originalWidth,
58
- originalHeight,
59
- width: result.width,
60
- height: result.height,
61
- wasResized: true,
62
- };
63
- }
64
-
65
- return {
66
- data: img.data,
67
- mimeType: img.mimeType,
68
- originalWidth,
69
- originalHeight,
70
- width: originalWidth,
71
- height: originalHeight,
72
- wasResized: false,
73
- };
74
- }
75
-
76
31
  /** Helper to pick the smaller of two buffers */
77
32
  function pickSmaller(
78
33
  a: { buffer: Uint8Array; mimeType: string },
@@ -85,7 +40,8 @@ function pickSmaller(
85
40
  * Resize an image to fit within the specified max dimensions and file size.
86
41
  * Returns the original image if it already fits within the limits.
87
42
  *
88
- * Uses Photon (Rust/WASM) for image processing. Falls back to ImageMagick if unavailable.
43
+ * Uses Photon (Rust/WASM) for image processing. If Photon is not available,
44
+ * returns the original image unchanged.
89
45
  *
90
46
  * Strategy for staying under maxBytes:
91
47
  * 1. First resize to maxWidth/maxHeight
@@ -97,73 +53,88 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
97
53
  const opts = { ...DEFAULT_OPTIONS, ...options };
98
54
  const inputBuffer = Buffer.from(img.data, "base64");
99
55
 
56
+ let image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;
100
57
  try {
101
- const photon = await getPhoton();
102
- const image = photon.PhotonImage.new_from_byteslice(new Uint8Array(inputBuffer));
58
+ image = photon.PhotonImage.new_from_byteslice(new Uint8Array(inputBuffer));
103
59
 
104
- try {
105
- const originalWidth = image.get_width();
106
- const originalHeight = image.get_height();
107
- const format = img.mimeType?.split("/")[1] ?? "png";
60
+ const originalWidth = image.get_width();
61
+ const originalHeight = image.get_height();
62
+ const format = img.mimeType?.split("/")[1] ?? "png";
108
63
 
109
- // Check if already within all limits (dimensions AND size)
110
- const originalSize = inputBuffer.length;
111
- if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
112
- return {
113
- data: img.data,
114
- mimeType: img.mimeType ?? `image/${format}`,
115
- originalWidth,
116
- originalHeight,
117
- width: originalWidth,
118
- height: originalHeight,
119
- wasResized: false,
120
- };
121
- }
64
+ // Check if already within all limits (dimensions AND size)
65
+ const originalSize = inputBuffer.length;
66
+ if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
67
+ return {
68
+ data: img.data,
69
+ mimeType: img.mimeType ?? `image/${format}`,
70
+ originalWidth,
71
+ originalHeight,
72
+ width: originalWidth,
73
+ height: originalHeight,
74
+ wasResized: false,
75
+ };
76
+ }
122
77
 
123
- // Calculate initial dimensions respecting max limits
124
- let targetWidth = originalWidth;
125
- let targetHeight = originalHeight;
78
+ // Calculate initial dimensions respecting max limits
79
+ let targetWidth = originalWidth;
80
+ let targetHeight = originalHeight;
126
81
 
127
- if (targetWidth > opts.maxWidth) {
128
- targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
129
- targetWidth = opts.maxWidth;
130
- }
131
- if (targetHeight > opts.maxHeight) {
132
- targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
133
- targetHeight = opts.maxHeight;
134
- }
82
+ if (targetWidth > opts.maxWidth) {
83
+ targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
84
+ targetWidth = opts.maxWidth;
85
+ }
86
+ if (targetHeight > opts.maxHeight) {
87
+ targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
88
+ targetHeight = opts.maxHeight;
89
+ }
135
90
 
136
- // Helper to resize and encode in both formats, returning the smaller one
137
- function tryBothFormats(
138
- width: number,
139
- height: number,
140
- jpegQuality: number,
141
- ): { buffer: Uint8Array; mimeType: string } {
142
- const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3);
91
+ // Helper to resize and encode in both formats, returning the smaller one
92
+ function tryBothFormats(
93
+ width: number,
94
+ height: number,
95
+ jpegQuality: number,
96
+ ): { buffer: Uint8Array; mimeType: string } {
97
+ const resized = photon.resize(image!, width, height, photon.SamplingFilter.Lanczos3);
98
+
99
+ try {
100
+ const pngBuffer = resized.get_bytes();
101
+ const jpegBuffer = resized.get_bytes_jpeg(jpegQuality);
102
+
103
+ return pickSmaller(
104
+ { buffer: pngBuffer, mimeType: "image/png" },
105
+ { buffer: jpegBuffer, mimeType: "image/jpeg" },
106
+ );
107
+ } finally {
108
+ resized.free();
109
+ }
110
+ }
143
111
 
144
- try {
145
- const pngBuffer = resized.get_bytes();
146
- const jpegBuffer = resized.get_bytes_jpeg(jpegQuality);
112
+ // Try to produce an image under maxBytes
113
+ const qualitySteps = [85, 70, 55, 40];
114
+ const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
147
115
 
148
- return pickSmaller(
149
- { buffer: pngBuffer, mimeType: "image/png" },
150
- { buffer: jpegBuffer, mimeType: "image/jpeg" },
151
- );
152
- } finally {
153
- resized.free();
154
- }
155
- }
116
+ let best: { buffer: Uint8Array; mimeType: string };
117
+ let finalWidth = targetWidth;
118
+ let finalHeight = targetHeight;
156
119
 
157
- // Try to produce an image under maxBytes
158
- const qualitySteps = [85, 70, 55, 40];
159
- const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
120
+ // First attempt: resize to target dimensions, try both formats
121
+ best = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
160
122
 
161
- let best: { buffer: Uint8Array; mimeType: string };
162
- let finalWidth = targetWidth;
163
- let finalHeight = targetHeight;
123
+ if (best.buffer.length <= opts.maxBytes) {
124
+ return {
125
+ data: Buffer.from(best.buffer).toString("base64"),
126
+ mimeType: best.mimeType,
127
+ originalWidth,
128
+ originalHeight,
129
+ width: finalWidth,
130
+ height: finalHeight,
131
+ wasResized: true,
132
+ };
133
+ }
164
134
 
165
- // First attempt: resize to target dimensions, try both formats
166
- best = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
135
+ // Still too large - try JPEG with decreasing quality
136
+ for (const quality of qualitySteps) {
137
+ best = tryBothFormats(targetWidth, targetHeight, quality);
167
138
 
168
139
  if (best.buffer.length <= opts.maxBytes) {
169
140
  return {
@@ -176,10 +147,19 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
176
147
  wasResized: true,
177
148
  };
178
149
  }
150
+ }
151
+
152
+ // Still too large - reduce dimensions progressively
153
+ for (const scale of scaleSteps) {
154
+ finalWidth = Math.round(targetWidth * scale);
155
+ finalHeight = Math.round(targetHeight * scale);
156
+
157
+ if (finalWidth < 100 || finalHeight < 100) {
158
+ break;
159
+ }
179
160
 
180
- // Still too large - try JPEG with decreasing quality
181
161
  for (const quality of qualitySteps) {
182
- best = tryBothFormats(targetWidth, targetHeight, quality);
162
+ best = tryBothFormats(finalWidth, finalHeight, quality);
183
163
 
184
164
  if (best.buffer.length <= opts.maxBytes) {
185
165
  return {
@@ -193,51 +173,33 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
193
173
  };
194
174
  }
195
175
  }
176
+ }
196
177
 
197
- // Still too large - reduce dimensions progressively
198
- for (const scale of scaleSteps) {
199
- finalWidth = Math.round(targetWidth * scale);
200
- finalHeight = Math.round(targetHeight * scale);
201
-
202
- if (finalWidth < 100 || finalHeight < 100) {
203
- break;
204
- }
205
-
206
- for (const quality of qualitySteps) {
207
- best = tryBothFormats(finalWidth, finalHeight, quality);
208
-
209
- if (best.buffer.length <= opts.maxBytes) {
210
- return {
211
- data: Buffer.from(best.buffer).toString("base64"),
212
- mimeType: best.mimeType,
213
- originalWidth,
214
- originalHeight,
215
- width: finalWidth,
216
- height: finalHeight,
217
- wasResized: true,
218
- };
219
- }
220
- }
221
- }
222
-
223
- // Last resort: return smallest version we produced
224
- return {
225
- data: Buffer.from(best.buffer).toString("base64"),
226
- mimeType: best.mimeType,
227
- originalWidth,
228
- originalHeight,
229
- width: finalWidth,
230
- height: finalHeight,
231
- wasResized: true,
232
- };
233
- } finally {
178
+ // Last resort: return smallest version we produced
179
+ return {
180
+ data: Buffer.from(best.buffer).toString("base64"),
181
+ mimeType: best.mimeType,
182
+ originalWidth,
183
+ originalHeight,
184
+ width: finalWidth,
185
+ height: finalHeight,
186
+ wasResized: true,
187
+ };
188
+ } catch {
189
+ // Failed to load image
190
+ return {
191
+ data: img.data,
192
+ mimeType: img.mimeType,
193
+ originalWidth: 0,
194
+ originalHeight: 0,
195
+ width: 0,
196
+ height: 0,
197
+ wasResized: false,
198
+ };
199
+ } finally {
200
+ if (image) {
234
201
  image.free();
235
202
  }
236
- } catch (error) {
237
- logger.error("Failed to resize image with Photon", {
238
- error: error instanceof Error ? error.message : String(error),
239
- });
240
- return resizeImageWithImageMagick(img, opts);
241
203
  }
242
204
  }
243
205
 
@@ -1,247 +0,0 @@
1
- let imagemagickCommand: string | null | undefined;
2
-
3
- /**
4
- * Detect available ImageMagick command.
5
- * Returns "magick" (IM7) or "convert" (IM6) or null if unavailable.
6
- */
7
- async function detectImageMagick(): Promise<string | null> {
8
- if (imagemagickCommand !== undefined) {
9
- return imagemagickCommand;
10
- }
11
-
12
- for (const cmd of ["magick", "convert"]) {
13
- try {
14
- const proc = Bun.spawn([cmd, "-version"], { stdout: "ignore", stderr: "ignore" });
15
- const code = await proc.exited;
16
- if (code === 0) {
17
- imagemagickCommand = cmd;
18
- return cmd;
19
- }
20
- } catch {}
21
- }
22
-
23
- imagemagickCommand = null;
24
- return null;
25
- }
26
-
27
- /**
28
- * Run ImageMagick command with buffer input/output.
29
- */
30
- async function runImageMagick(cmd: string, args: string[], input: Buffer): Promise<Buffer> {
31
- const proc = Bun.spawn([cmd, ...args], {
32
- stdin: new Blob([input]),
33
- stdout: "pipe",
34
- stderr: "pipe",
35
- });
36
-
37
- const [stdout, stderr, exitCode] = await Promise.all([
38
- new Response(proc.stdout).arrayBuffer(),
39
- new Response(proc.stderr).text(),
40
- proc.exited,
41
- ]);
42
-
43
- if (exitCode !== 0) {
44
- throw new Error(`ImageMagick exited with code ${exitCode}: ${stderr}`);
45
- }
46
-
47
- return Buffer.from(stdout);
48
- }
49
-
50
- /**
51
- * Convert image to PNG using ImageMagick.
52
- * Returns null if ImageMagick is unavailable or conversion fails.
53
- */
54
- export async function convertToPngWithImageMagick(
55
- base64Data: string,
56
- _mimeType: string,
57
- ): Promise<{ data: string; mimeType: string } | null> {
58
- const cmd = await detectImageMagick();
59
- if (!cmd) {
60
- return null;
61
- }
62
-
63
- try {
64
- const input = Buffer.from(base64Data, "base64");
65
- // "-" reads from stdin, "png:-" writes PNG to stdout
66
- const output = await runImageMagick(cmd, ["-", "png:-"], input);
67
- return {
68
- data: output.toString("base64"),
69
- mimeType: "image/png",
70
- };
71
- } catch {
72
- return null;
73
- }
74
- }
75
-
76
- export interface ImageMagickResizeResult {
77
- data: string; // base64
78
- mimeType: string;
79
- width: number;
80
- height: number;
81
- }
82
-
83
- /**
84
- * Get image dimensions using ImageMagick identify.
85
- */
86
- async function getImageDimensions(cmd: string, buffer: Buffer): Promise<{ width: number; height: number } | null> {
87
- try {
88
- // Use identify to get dimensions
89
- const identifyCmd = cmd === "magick" ? "magick" : "identify";
90
- const args = cmd === "magick" ? ["identify", "-format", "%w %h", "-"] : ["-format", "%w %h", "-"];
91
-
92
- const output = await runImageMagick(identifyCmd, args, buffer);
93
- const [w, h] = output.toString().trim().split(" ").map(Number);
94
- if (Number.isFinite(w) && Number.isFinite(h)) {
95
- return { width: w, height: h };
96
- }
97
- } catch {
98
- // Fall through
99
- }
100
- return null;
101
- }
102
-
103
- /**
104
- * Resize image using ImageMagick.
105
- * Returns null if ImageMagick is unavailable or operation fails.
106
- */
107
- export async function resizeWithImageMagick(
108
- base64Data: string,
109
- _mimeType: string,
110
- maxWidth: number,
111
- maxHeight: number,
112
- maxBytes: number,
113
- jpegQuality: number,
114
- ): Promise<ImageMagickResizeResult | null> {
115
- const cmd = await detectImageMagick();
116
- if (!cmd) {
117
- return null;
118
- }
119
-
120
- try {
121
- const input = Buffer.from(base64Data, "base64");
122
-
123
- // Get original dimensions
124
- const dims = await getImageDimensions(cmd, input);
125
- if (!dims) {
126
- return null;
127
- }
128
-
129
- // Check if already within limits
130
- if (dims.width <= maxWidth && dims.height <= maxHeight && input.length <= maxBytes) {
131
- return null; // Signal caller to use original
132
- }
133
-
134
- // Calculate target dimensions maintaining aspect ratio
135
- let targetWidth = dims.width;
136
- let targetHeight = dims.height;
137
-
138
- if (targetWidth > maxWidth) {
139
- targetHeight = Math.round((targetHeight * maxWidth) / targetWidth);
140
- targetWidth = maxWidth;
141
- }
142
- if (targetHeight > maxHeight) {
143
- targetWidth = Math.round((targetWidth * maxHeight) / targetHeight);
144
- targetHeight = maxHeight;
145
- }
146
-
147
- // Try PNG first, then JPEG with decreasing quality
148
- const attempts: Array<{ args: string[]; mimeType: string }> = [
149
- { args: ["-", "-resize", `${targetWidth}x${targetHeight}>`, "png:-"], mimeType: "image/png" },
150
- {
151
- args: ["-", "-resize", `${targetWidth}x${targetHeight}>`, "-quality", String(jpegQuality), "jpeg:-"],
152
- mimeType: "image/jpeg",
153
- },
154
- ];
155
-
156
- // Add lower quality JPEG attempts
157
- for (const q of [70, 55, 40]) {
158
- attempts.push({
159
- args: ["-", "-resize", `${targetWidth}x${targetHeight}>`, "-quality", String(q), "jpeg:-"],
160
- mimeType: "image/jpeg",
161
- });
162
- }
163
-
164
- let best: { buffer: Buffer; mimeType: string } | null = null;
165
-
166
- for (const attempt of attempts) {
167
- try {
168
- const output = await runImageMagick(cmd, attempt.args, input);
169
- if (output.length <= maxBytes) {
170
- return {
171
- data: output.toString("base64"),
172
- mimeType: attempt.mimeType,
173
- width: targetWidth,
174
- height: targetHeight,
175
- };
176
- }
177
- if (!best || output.length < best.buffer.length) {
178
- best = { buffer: output, mimeType: attempt.mimeType };
179
- }
180
- } catch {}
181
- }
182
-
183
- // Try progressively smaller dimensions
184
- const scaleSteps = [0.75, 0.5, 0.35, 0.25];
185
- for (const scale of scaleSteps) {
186
- const scaledWidth = Math.round(targetWidth * scale);
187
- const scaledHeight = Math.round(targetHeight * scale);
188
-
189
- if (scaledWidth < 100 || scaledHeight < 100) break;
190
-
191
- for (const q of [85, 70, 55, 40]) {
192
- try {
193
- const output = await runImageMagick(
194
- cmd,
195
- ["-", "-resize", `${scaledWidth}x${scaledHeight}>`, "-quality", String(q), "jpeg:-"],
196
- input,
197
- );
198
- if (output.length <= maxBytes) {
199
- return {
200
- data: output.toString("base64"),
201
- mimeType: "image/jpeg",
202
- width: scaledWidth,
203
- height: scaledHeight,
204
- };
205
- }
206
- if (!best || output.length < best.buffer.length) {
207
- best = { buffer: output, mimeType: "image/jpeg" };
208
- }
209
- } catch {}
210
- }
211
- }
212
-
213
- // Return best attempt even if over limit
214
- if (best) {
215
- return {
216
- data: best.buffer.toString("base64"),
217
- mimeType: best.mimeType,
218
- width: targetWidth,
219
- height: targetHeight,
220
- };
221
- }
222
-
223
- return null;
224
- } catch {
225
- return null;
226
- }
227
- }
228
-
229
- /**
230
- * Get image dimensions using ImageMagick.
231
- * Returns null if ImageMagick is unavailable.
232
- */
233
- export async function getImageDimensionsWithImageMagick(
234
- base64Data: string,
235
- ): Promise<{ width: number; height: number } | null> {
236
- const cmd = await detectImageMagick();
237
- if (!cmd) {
238
- return null;
239
- }
240
-
241
- try {
242
- const buffer = Buffer.from(base64Data, "base64");
243
- return await getImageDimensions(cmd, buffer);
244
- } catch {
245
- return null;
246
- }
247
- }
@@ -1,25 +0,0 @@
1
- import type { base64_to_image, PhotonImage, resize as photonResize, SamplingFilter } from "@silvia-odwyer/photon";
2
-
3
- let _photon: typeof import("@silvia-odwyer/photon") | undefined;
4
- let _initialized = false;
5
-
6
- /**
7
- * Get the initialized Photon module.
8
- * Lazily imports and initializes the WASM module on first use.
9
- */
10
- export async function getPhoton(): Promise<typeof import("@silvia-odwyer/photon")> {
11
- if (_photon && _initialized) return _photon;
12
-
13
- const photon = await import("@silvia-odwyer/photon");
14
-
15
- // Initialize the WASM module (default export is the init function)
16
- if (!_initialized) {
17
- await photon.default();
18
- _initialized = true;
19
- }
20
-
21
- _photon = photon;
22
- return _photon;
23
- }
24
-
25
- export type { PhotonImage, SamplingFilter, photonResize, base64_to_image };