Read PR description, comments, commits, and changed files.
UnderstandChecked for linked issues and analyzed them.
UnderstandVerified function usage and callers.
UnderstandValidated changelog entry and attribution.
VerifyYou are given one or more GitHub PR URLs: https://github.com/badlogic/pi-mono/pull/795/changes
For each PR URL, do the following in order:
packages/*/CHANGELOG.md files. Report whether an entry exists. If missing, state that a changelog entry is required before merge and that you will add it if the user decides to merge. Follow the changelog format rules in AGENTS.md. Verify:
### Breaking Changes, ### Added, ### Fixed, etc.)Fixed foo ([#123](https://github.com/badlogic/pi-mono/pull/123) by [@user](https://github.com/user))### Breaking Changes, not just ### FixedOutput format per PR: PR: Changelog:
If no issues are found, say so under Bad and Ugly.
The user wants me to review a GitHub PR. Let me start by reading the PR page to get all the details.
>gh pr view 795 --repo badlogic/pi-mono --json title,body,comments,commits,files,author,labels,state,reviews{"author":{"id":"MDQ6VXNlcjIyMDY1ODM=","is_bot":false,"login":"dannote","name":"Danila Poyarkov"},"body":"**Problem**\n\nPhoton module fails to load with \"require is not defined\" error when running in ESM context.\n\n**Solution**\n\nReplace synchronous `require()` with async `import()` in photon.ts.\n\n**Changes**\n\n- `packages/coding-agent/src/utils/photon.ts`: Replace `getPhoton()` with async `loadPhoton()`\n- `packages/coding-agent/src/utils/image-convert.ts`: Use `await loadPhoton()`\n- `packages/coding-agent/src/utils/image-resize.ts`: Use `await loadPhoton()`","comments":[],"commits":[{"authoredDate":"2026-01-17T00:06:27Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjIyMDY1ODM=","login":"dannote","name":"Danila Poyarkov"}],"committedDate":"2026-01-17T00:06:27Z","messageBody":"","messageHeadline":"fix(coding-agent): use dynamic import for photon module in ESM context","oid":"e4e26a27c5c810c44413e2b0b03abb83a96eeed3"}],"files":[{"path":"packages/coding-agent/CHANGELOG.md","additions":1,"deletions":0},{"path":"packages/coding-agent/src/utils/image-convert.ts","additions":10,"deletions":12},{"path":"packages/coding-agent/src/utils/image-resize.ts","additions":2,"deletions":2},{"path":"packages/coding-agent/src/utils/photon.ts","additions":18,"deletions":32}],"labels":[],"reviews":[],"state":"OPEN","title":"fix(coding-agent): use dynamic import for photon module in ESM context"}>gh pr diff 795 --repo badlogic/pi-monodiff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index a40543c35..961be9e57 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -15,6 +15,7 @@
### Fixed
+- Fixed photon module failing to load in ESM context with "require is not defined" error
- Fixed crash during auto-compaction when summarization fails (e.g., quota exceeded). Now displays error message instead of crashing ([#792](https://github.com/badlogic/pi-mono/issues/792))
- Fixed `--no-extensions` flag not preventing extension discovery ([#776](https://github.com/badlogic/pi-mono/issues/776))
- Fixed extension messages rendering twice on startup when `pi.sendMessage({ display: true })` is called during `session_start` ([#765](https://github.com/badlogic/pi-mono/pull/765) by [@dannote](https://github.com/dannote))
diff --git a/packages/coding-agent/src/utils/image-convert.ts b/packages/coding-agent/src/utils/image-convert.ts
index a1e5a4745..9f1e8ec18 100644
--- a/packages/coding-agent/src/utils/image-convert.ts
+++ b/packages/coding-agent/src/utils/image-convert.ts
@@ -1,26 +1,23 @@
-import { getPhoton } from "./photon.js";
+import { loadPhoton } from "./photon.js";
/**
* Convert image to PNG format for terminal display.
* Kitty graphics protocol requires PNG format (f=100).
*/
-export async function convertToPng(
- base64Data: string,
- mimeType: string,
-): Promise<{ data: string; mimeType: string } | null> {
+export async function convertToPng(base64Data: string, mimeType: string): Promise<{ data: string; mimeType: string }> {
// Already PNG, no conversion needed
if (mimeType === "image/png") {
return { data: base64Data, mimeType };
}
- const photon = getPhoton();
+ const photon = await loadPhoton();
if (!photon) {
- // Photon not available, can't convert
- return null;
+ throw new Error("Photon module not available");
}
try {
- const image = photon.PhotonImage.new_from_byteslice(new Uint8Array(Buffer.from(base64Data, "base64")));
+ const bytes = new Uint8Array(Buffer.from(base64Data, "base64"));
+ const image = photon.PhotonImage.new_from_byteslice(bytes);
try {
const pngBuffer = image.get_bytes();
return {
@@ -30,8 +27,9 @@ export async function convertToPng(
} finally {
image.free();
}
- } catch {
- // Conversion failed
- return null;
+ } catch (e) {
+ // Conversion failed - return error details
+ const msg = e instanceof Error ? e.message : String(e);
+ throw new Error(`Photon conversion failed: ${msg}`);
}
}
diff --git a/packages/coding-agent/src/utils/image-resize.ts b/packages/coding-agent/src/utils/image-resize.ts
index dbaf0b4c5..91c3f7968 100644
--- a/packages/coding-agent/src/utils/image-resize.ts
+++ b/packages/coding-agent/src/utils/image-resize.ts
@@ -1,5 +1,5 @@
import type { ImageContent } from "@mariozechner/pi-ai";
-import { getPhoton } from "./photon.js";
+import { loadPhoton } from "./photon.js";
export interface ImageResizeOptions {
maxWidth?: number; // Default: 2000
@@ -53,7 +53,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
const opts = { ...DEFAULT_OPTIONS, ...options };
const inputBuffer = Buffer.from(img.data, "base64");
- const photon = getPhoton();
+ const photon = await loadPhoton();
if (!photon) {
// Photon not available, return original image
return {
diff --git a/packages/coding-agent/src/utils/photon.ts b/packages/coding-agent/src/utils/photon.ts
index 0666460bc..d7a2f07fc 100644
--- a/packages/coding-agent/src/utils/photon.ts
+++ b/packages/coding-agent/src/utils/photon.ts
@@ -8,8 +8,8 @@
* The challenge: photon-node's CJS entry uses fs.readFileSync(__dirname + '/photon_rs_bg.wasm')
* which bakes the build machine's absolute path into Bun compiled binaries.
*
- * Solution: Lazy-load photon and gracefully handle failures. Image processing functions
- * already have fallbacks that return original images when photon isn't available.
+ * Solution: Lazy-load photon via dynamic import and gracefully handle failures.
+ * Image processing functions have fallbacks that return original images when photon isn't available.
*/
// Re-export types from the main package
@@ -17,43 +17,29 @@ export type { PhotonImage as PhotonImageType } from "@silvia-odwyer/photon-node"
// Lazy-loaded photon module
let photonModule: typeof import("@silvia-odwyer/photon-node") | null = null;
-let loadAttempted = false;
-let loadError: Error | null = null;
+let loadPromise: Promise<typeof import("@silvia-odwyer/photon-node") | null> | null = null;
/**
- * Get the photon module, loading it lazily on first access.
- * Returns null if loading fails (e.g., in broken Bun binary).
+ * Load the photon module asynchronously.
+ * Returns cached module on subsequent calls.
*/
-export function getPhoton(): typeof import("@silvia-odwyer/photon-node") | null {
- if (loadAttempted) {
+export async function loadPhoton(): Promise<typeof import("@silvia-odwyer/photon-node") | null> {
+ if (photonModule) {
return photonModule;
}
- loadAttempted = true;
-
- try {
- // Dynamic require to defer loading until actually needed
- // This also allows the error to be caught gracefully
- photonModule = require("@silvia-odwyer/photon-node");
- } catch (e) {
- loadError = e as Error;
- photonModule = null;
+ if (loadPromise) {
+ return loadPromise;
}
- return photonModule;
-}
-
-/**
- * Check if photon is available and working.
- */
-export function isPhotonAvailable(): boolean {
- return getPhoton() !== null;
-}
+ loadPromise = (async () => {
+ try {
+ photonModule = await import("@silvia-odwyer/photon-node");
+ } catch {
+ photonModule = null;
+ }
+ return photonModule;
+ })();
-/**
- * Get the error that occurred during photon loading, if any.
- */
-export function getPhotonLoadError(): Error | null {
- getPhoton(); // Ensure load was attempted
- return loadError;
+ return loadPromise;
}Now let me read the current files on main to compare against the diff:
Let me read the current source files on main to compare:
Now let me check if there are any callers of the removed functions isPhotonAvailable() and getPhotonLoadError():
Let me check for any usages of the removed functions and verify the callers of convertToPng:
>grep -r "isPhotonAvailable\|getPhotonLoadError\|getPhoton\|loadPhoton" packages/coding-agent/src --include="*.ts" | head -50packages/coding-agent/src/utils/photon.ts:export function getPhoton(): typeof import("@silvia-odwyer/photon-node") | null {
packages/coding-agent/src/utils/photon.ts:export function isPhotonAvailable(): boolean {
packages/coding-agent/src/utils/photon.ts: return getPhoton() !== null;
packages/coding-agent/src/utils/photon.ts:export function getPhotonLoadError(): Error | null {
packages/coding-agent/src/utils/photon.ts: getPhoton(); // Ensure load was attempted
packages/coding-agent/src/utils/image-convert.ts:import { getPhoton } from "./photon.js";
packages/coding-agent/src/utils/image-convert.ts: const photon = getPhoton();
packages/coding-agent/src/utils/image-resize.ts:import { getPhoton } from "./photon.js";
packages/coding-agent/src/utils/image-resize.ts: const photon = getPhoton();>grep -r "convertToPng" packages/coding-agent/src --include="*.ts" -lpackages/coding-agent/src/modes/interactive/components/tool-execution.ts
packages/coding-agent/src/utils/image-convert.tsNow let me look at the tool-execution.ts file to see how convertToPng is used there:
Let me check how convertToPng is called in the codebase:
>grep -B5 -A10 "convertToPng" packages/coding-agent/src/modes/interactive/components/tool-execution.tsimport stripAnsi from "strip-ansi";
import type { ToolDefinition } from "../../../core/extensions/types.js";
import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff.js";
import { allTools } from "../../../core/tools/index.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
import { convertToPng } from "../../../utils/image-convert.js";
import { sanitizeBinaryOutput } from "../../../utils/shell.js";
import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js";
import { renderDiff } from "./diff.js";
import { keyHint } from "./keybinding-hints.js";
import { truncateToVisualLines } from "./visual-truncate.js";
// Preview line limit for bash when not expanded
const BASH_PREVIEW_LINES = 5;
/**
--
if (img.mimeType === "image/png") continue;
if (this.convertedImages.has(i)) continue;
// Convert async
const index = i;
convertToPng(img.data, img.mimeType).then((converted) => {
if (converted) {
this.convertedImages.set(index, converted);
this.updateDisplay();
this.ui.requestRender();
}
});
}
}
setExpanded(expanded: boolean): void {Now I see a problem. The original convertToPng returns { data, mimeType } | null and the caller handles the null case by just not updating. But the PR changes convertToPng to throw instead of returning null.
Let me look at the full context of how convertToPng is used in tool-execution.ts:
I see. Let me check the full usage of convertToPng:
>grep -B10 -A20 "convertToPng" packages/coding-agent/src/modes/interactive/components/tool-execution.ts | tail -50import { renderDiff } from "./diff.js";
import { keyHint } from "./keybinding-hints.js";
import { truncateToVisualLines } from "./visual-truncate.js";
// Preview line limit for bash when not expanded
const BASH_PREVIEW_LINES = 5;
/**
* Convert absolute path to tilde notation if it's in home directory
*/
function shortenPath(path: string): string {
const home = os.homedir();
if (path.startsWith(home)) {
return `~${path.slice(home.length)}`;
}
return path;
}
--
for (let i = 0; i < imageBlocks.length; i++) {
const img = imageBlocks[i];
if (!img.data || !img.mimeType) continue;
// Skip if already PNG or already converted
if (img.mimeType === "image/png") continue;
if (this.convertedImages.has(i)) continue;
// Convert async
const index = i;
convertToPng(img.data, img.mimeType).then((converted) => {
if (converted) {
this.convertedImages.set(index, converted);
this.updateDisplay();
this.ui.requestRender();
}
});
}
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
setShowImages(show: boolean): void {
this.showImages = show;
this.updateDisplay();
}
override invalidate(): void {This is a breaking change! The PR changes:
convertToPng signature from returning Promise<{ data, mimeType } | null> to Promise<{ data, mimeType }> (never null, throws instead).then((converted) => { if (converted) { ... } }) and doesn't catch errors.If photon is unavailable or conversion fails, the current code just ignores it (returns null). The PR changes this to throw an error, which would cause an unhandled promise rejection in the calling code since there's no .catch() handler.
This is a breaking change that would cause uncaught promise rejection in the tool-execution.ts component!
Let me also check if there are any linked issues referenced:
Let me check if there are any linked issues and also verify that the PR modifies the caller:
>gh issue list --repo badlogic/pi-mono --search "photon ESM require" --json number,title,state,body[]>gh issue list --repo badlogic/pi-mono --search "require is not defined" --json number,title,state,body[]Let me check the documentation to see if it needs updates:
>grep -r "photon\|getPhoton\|isPhoton" packages/coding-agent/README.md packages/coding-agent/docs/ 2>/dev/null || echo "No matches found"No matches foundNow let me do a final check on the PR. The key issue I've identified is that the PR changes convertToPng to throw on errors, but the caller in tool-execution.ts doesn't handle errors. However, looking at the diff again, the PR doesn't modify tool-execution.ts at all!
This means the caller still does:
convertToPng(img.data, img.mimeType).then((converted) => { if (converted) { ... }});But convertToPng now throws instead of returning null, causing an unhandled promise rejection.
Let me also note:
isPhotonAvailable() and getPhotonLoadError() - I should check if these are used anywhere elseconvertToPng changed from Promise<{ data, mimeType } | null> to Promise<{ data, mimeType }> (no longer nullable)Let me verify nothing else uses these removed functions:
>grep -r "isPhotonAvailable\|getPhotonLoadError" packages/coding-agent/src --include="*.ts" | grep -v "photon.ts"(no output)
Command exited with code 1Good - the removed functions aren't used elsewhere. Let me check the exports:
>grep -r "isPhotonAvailable\|getPhotonLoadError\|getPhoton\|loadPhoton" packages/coding-agent/src/index.ts packages/coding-agent/src/exports.ts 2>/dev/null || echo "No matches"No matchesGood, those functions are internal only, not exported from the package's public API.
Now let me summarize my findings:
Good:
require is not defined) is correctly addressed by switching from require() to import()loadPromise is correct to prevent multiple concurrent loadsBad:
convertToPng return type changed from Promise<{ data, mimeType } | null> to Promise<{ data, mimeType }> (throws on error). The caller in tool-execution.ts calls .then((converted) => { if (converted) { ... } }) without a .catch(), causing unhandled promise rejection when photon fails.tool-execution.ts to handle the new throwing behaviorUgly:
Let me verify my understanding of the return type change in the diff:
Looking at the diff for image-convert.ts:
Promise<{ data: string; mimeType: string } | null>Promise<{ data: string; mimeType: string }> (no null)Before, it returned null when:
After, it throws an error in both cases.
This is definitely a bug in the PR since it breaks the caller.
Let me check the CHANGELOG entry format as well:
Let me verify the changelog entry format and check for attribution:
>gh pr diff 795 --repo badlogic/pi-mono | grep "CHANGELOG" -A5diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index a40543c35..961be9e57 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -15,6 +15,7 @@
### Fixed
+- Fixed photon module failing to load in ESM context with "require is not defined" errorThe changelog entry is missing the PR attribution! Per AGENTS.md:
External contributions:
Fixed foo ([#456](https://github.com/badlogic/pi-mono/pull/456) by [@username](https://github.com/username))
It should be:
- Fixed photon module failing to load in ESM context with "require is not defined" error ([#795](https://github.com/badlogic/pi-mono/pull/795) by [@dannote](https://github.com/dannote))
Now I have a complete picture of this PR:
require() with async import()convertToPng breaks the caller in tool-execution.ts (unhandled promise rejection)isPhotonAvailable, getPhotonLoadError) - though they're not used externallyLet me provide my full review.
PR: https://github.com/badlogic/pi-mono/pull/795
Changelog:
### Fixed) but missing PR attribution for external contributionFixed photon module failing to load in ESM context with "require is not defined" error ([#795](https://github.com/badlogic/pi-mono/pull/795) by [@dannote](https://github.com/dannote))Good:
require() with async import() solves the ESM context issueloadPromise is a clean pattern that prevents race conditions on concurrent loadsBad:
convertToPng return type changed from Promise<{ data, mimeType } | null> to Promise<{ data, mimeType }> (throws instead of returning null). The caller in tool-execution.ts (line ~290) uses .then((converted) => { if (converted) { ... } }) without a .catch() handler, causing unhandled promise rejection when photon is unavailable or conversion fails.tool-execution.ts to handle the new throwing behaviorisPhotonAvailable(), getPhotonLoadError()) without deprecation, though they're internal-only and unusedUgly:
Questions or Assumptions:
convertToPng (null → throw) intentional? If so, tool-execution.ts needs a corresponding update with .catch() handler.Change summary:
photon.ts: Replace sync getPhoton() with async loadPhoton(), remove isPhotonAvailable() and getPhotonLoadError()image-convert.ts: Use await loadPhoton(), change from returning null to throwing on errorsimage-resize.ts: Use await loadPhoton(), preserves null-return behavior (returns original image on photon unavailable)Tests:
convertToPng(img.data, img.mimeType).then((converted) => { if (converted) { ... }});