pi-image-tools 1.0.11 → 1.1.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
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.1.0] - 2026-05-03
4
+
5
+ ### Added
6
+ - Added centralized image MIME, image-size, PowerShell, and error utilities used by clipboard, preview, attachment, and recent-image flows.
7
+ - Added size-limit enforcement for image attachments, recent-cache writes, recent-image loads, and Sixel preview conversion through `PI_IMAGE_TOOLS_MAX_IMAGE_BYTES`.
8
+
9
+ ### Changed
10
+ - Reworked recent-image caching to use an extension-owned cache directory with safe pruning that preserves user files.
11
+ - Reworked Sixel preview rendering to use an existing PowerShell `Sixel` module only, normalize converter output into complete terminal sequences, and fall back to native previews with actionable warnings.
12
+ - Reworked terminal image width resolution to honor Pi project/global `terminal.imageWidthCells` settings with a documented default fallback.
13
+
14
+ ### Fixed
15
+ - Prevented extension debug logging from writing terminal output; debug events now remain file-based and disabled by default.
16
+ - Preserved Sixel, Kitty, and iTerm inline image protocol rows during preview width fitting.
17
+
3
18
  ## [1.0.11] - 2026-04-25
4
19
 
5
20
  ### Changed
package/README.md CHANGED
@@ -114,6 +114,7 @@ Pi's built-in image paste shortcut is not overridden by default. To keep the pre
114
114
  |----------|-------------|---------|
115
115
  | `PI_IMAGE_TOOLS_RECENT_DIRS` | Semicolon-separated directories to search for recent images | Platform defaults listed below |
116
116
  | `PI_IMAGE_TOOLS_RECENT_CACHE_DIR` | Custom cache directory for clipboard-pasted images | OS temp dir + `pi-image-tools/recent-cache` |
117
+ | `PI_IMAGE_TOOLS_MAX_IMAGE_BYTES` | Maximum accepted image payload size before attachment, recent-cache writes, and preview conversion | `20971520` (20 MB) |
117
118
 
118
119
  Example:
119
120
 
@@ -254,8 +255,10 @@ When you queue one or more images, the extension renders an inline preview insid
254
255
 
255
256
  Preview behavior:
256
257
  - up to **3 images** are previewed per message
257
- - Sixel rendering is attempted on Windows when available
258
+ - Sixel rendering is attempted on Windows when the PowerShell `Sixel` module is already installed
259
+ - no PowerShell modules are installed automatically at runtime
258
260
  - native TUI image rendering is used as the fallback
261
+ - image payloads over `PI_IMAGE_TOOLS_MAX_IMAGE_BYTES` are rejected before attachment, recent-cache writes, or Sixel conversion
259
262
  - inline width fitting now preserves Sixel, Kitty, and iTerm image protocol rows instead of truncating them like plain text
260
263
 
261
264
  ### Clipboard readers
@@ -285,11 +288,16 @@ pi-image-tools/
285
288
  │ ├── clipboard.ts # Cross-platform clipboard image reading
286
289
  │ ├── config.ts # Runtime config loading and validation
287
290
  │ ├── debug-logger.ts # File-based debug logging
291
+ │ ├── errors.ts # Shared error normalization
292
+ │ ├── image-mime.ts # Shared image MIME and extension mapping
293
+ │ ├── image-size.ts # Shared image byte-size limits
288
294
  │ ├── recent-images.ts # Recent image discovery and cache management
289
295
  │ ├── image-preview.ts # Preview building and Sixel/native rendering
290
296
  │ ├── inline-user-preview.ts # Inline preview patching for user messages
291
297
  │ ├── keybindings.ts # Keyboard shortcut registration
292
- │ ├── temp-file.ts # Temporary file management and cleanup
298
+ │ ├── powershell.ts # Shared PowerShell command runner
299
+ │ ├── sixel-protocol.ts # Sixel protocol normalization and render lines
300
+ │ ├── terminal-image-width.ts # Terminal image width settings resolution
293
301
  │ └── types.ts # Shared TypeScript types
294
302
  └── config/
295
303
  └── config.example.json # Starter runtime config
package/package.json CHANGED
@@ -1,66 +1,66 @@
1
- {
2
- "name": "pi-image-tools",
3
- "version": "1.0.11",
4
- "description": "Image attachment and rendering extension for Pi TUI",
5
- "type": "module",
6
- "main": "./index.ts",
7
- "exports": {
8
- ".": "./index.ts"
9
- },
10
- "files": [
11
- "index.ts",
12
- "src",
13
- "config/config.example.json",
14
- "README.md",
15
- "CHANGELOG.md",
16
- "LICENSE"
17
- ],
18
- "scripts": {
19
- "build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
20
- "lint": "npm run build",
21
- "test": "node --test",
22
- "check": "npm run lint && npm run test"
23
- },
24
- "keywords": [
25
- "pi-package",
26
- "pi",
27
- "pi-extension",
28
- "pi-coding-agent",
29
- "pi-tui",
30
- "coding-agent",
31
- "image",
32
- "image-attachment",
33
- "image-preview",
34
- "clipboard",
35
- "preview",
36
- "windows"
37
- ],
38
- "author": "MasuRii",
39
- "license": "MIT",
40
- "repository": {
41
- "type": "git",
42
- "url": "git+https://github.com/MasuRii/pi-image-tools.git"
43
- },
44
- "homepage": "https://github.com/MasuRii/pi-image-tools#readme",
45
- "bugs": {
46
- "url": "https://github.com/MasuRii/pi-image-tools/issues"
47
- },
48
- "engines": {
49
- "node": ">=20"
50
- },
51
- "publishConfig": {
52
- "access": "public"
53
- },
54
- "pi": {
55
- "extensions": [
56
- "./index.ts"
57
- ]
58
- },
59
- "peerDependencies": {
60
- "@mariozechner/pi-coding-agent": "^0.70.2",
61
- "@mariozechner/pi-tui": "^0.70.2"
62
- },
63
- "optionalDependencies": {
64
- "@mariozechner/clipboard": "^0.3.3"
65
- }
66
- }
1
+ {
2
+ "name": "pi-image-tools",
3
+ "version": "1.1.0",
4
+ "description": "Image attachment and rendering extension for Pi TUI",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "src",
13
+ "config/config.example.json",
14
+ "README.md",
15
+ "CHANGELOG.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
20
+ "lint": "npm run build",
21
+ "test": "node --test",
22
+ "check": "npm run lint && npm run test"
23
+ },
24
+ "keywords": [
25
+ "pi-package",
26
+ "pi",
27
+ "pi-extension",
28
+ "pi-coding-agent",
29
+ "pi-tui",
30
+ "coding-agent",
31
+ "image",
32
+ "image-attachment",
33
+ "image-preview",
34
+ "clipboard",
35
+ "preview",
36
+ "windows"
37
+ ],
38
+ "author": "MasuRii",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/MasuRii/pi-image-tools.git"
43
+ },
44
+ "homepage": "https://github.com/MasuRii/pi-image-tools#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/MasuRii/pi-image-tools/issues"
47
+ },
48
+ "engines": {
49
+ "node": ">=20"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "pi": {
55
+ "extensions": [
56
+ "./index.ts"
57
+ ]
58
+ },
59
+ "peerDependencies": {
60
+ "@mariozechner/pi-coding-agent": "^0.72.0",
61
+ "@mariozechner/pi-tui": "^0.72.0"
62
+ },
63
+ "optionalDependencies": {
64
+ "@mariozechner/clipboard": "^0.3.5"
65
+ }
66
+ }
package/src/clipboard.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { createRequire } from "node:module";
3
3
 
4
+ import { isErrnoException } from "./errors.js";
5
+ import { normalizeMimeType, selectPreferredImageMimeType, SUPPORTED_IMAGE_MIME_TYPES } from "./image-mime.js";
6
+ import { runPowerShellCommand } from "./powershell.js";
4
7
  import type { ClipboardImage, ClipboardModule } from "./types.js";
5
8
 
6
9
  const require = createRequire(import.meta.url);
@@ -8,14 +11,6 @@ const require = createRequire(import.meta.url);
8
11
  const LIST_TYPES_TIMEOUT_MS = 1000;
9
12
  const READ_TIMEOUT_MS = 5000;
10
13
  const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
11
- const SUPPORTED_IMAGE_MIME_TYPES = [
12
- "image/png",
13
- "image/jpeg",
14
- "image/webp",
15
- "image/gif",
16
- "image/bmp",
17
- ] as const;
18
-
19
14
  let cachedClipboardModule: ClipboardModule | null | undefined;
20
15
 
21
16
  interface CommandResult {
@@ -29,10 +24,6 @@ interface ClipboardReadResult {
29
24
  image: ClipboardImage | null;
30
25
  }
31
26
 
32
- function isErrnoException(error: Error): error is NodeJS.ErrnoException {
33
- return "code" in error;
34
- }
35
-
36
27
  function hasGraphicalSession(platform: NodeJS.Platform, environment: NodeJS.ProcessEnv): boolean {
37
28
  return platform !== "linux" || Boolean(environment.DISPLAY || environment.WAYLAND_DISPLAY);
38
29
  }
@@ -41,27 +32,6 @@ function isWaylandSession(environment: NodeJS.ProcessEnv): boolean {
41
32
  return Boolean(environment.WAYLAND_DISPLAY) || environment.XDG_SESSION_TYPE === "wayland";
42
33
  }
43
34
 
44
- function normalizeMimeType(mimeType: string): string {
45
- return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
46
- }
47
-
48
- function selectPreferredImageMimeType(mimeTypes: readonly string[]): string | null {
49
- const normalized = mimeTypes
50
- .map((mimeType) => mimeType.trim())
51
- .filter((mimeType) => mimeType.length > 0)
52
- .map((mimeType) => ({ raw: mimeType, normalized: normalizeMimeType(mimeType) }));
53
-
54
- for (const preferredMimeType of SUPPORTED_IMAGE_MIME_TYPES) {
55
- const match = normalized.find((mimeType) => mimeType.normalized === preferredMimeType);
56
- if (match) {
57
- return match.raw;
58
- }
59
- }
60
-
61
- const firstImage = normalized.find((mimeType) => mimeType.normalized.startsWith("image/"));
62
- return firstImage?.raw ?? null;
63
- }
64
-
65
35
  function loadClipboardModule(
66
36
  platform: NodeJS.Platform = process.platform,
67
37
  environment: NodeJS.ProcessEnv = process.env,
@@ -141,10 +111,6 @@ function runCommand(
141
111
  };
142
112
  }
143
113
 
144
- function encodePowerShell(script: string): string {
145
- return Buffer.from(script, "utf16le").toString("base64");
146
- }
147
-
148
114
  function readClipboardImageViaPowerShell(): ClipboardReadResult {
149
115
  const script = `
150
116
  $ErrorActionPreference = 'Stop'
@@ -170,33 +136,18 @@ try {
170
136
  }
171
137
  `;
172
138
 
173
- const result = spawnSync(
174
- "powershell.exe",
175
- [
176
- "-NoProfile",
177
- "-NonInteractive",
178
- "-ExecutionPolicy",
179
- "Bypass",
180
- "-STA",
181
- "-EncodedCommand",
182
- encodePowerShell(script),
183
- ],
184
- {
185
- encoding: "utf8",
186
- timeout: READ_TIMEOUT_MS,
187
- maxBuffer: MAX_BUFFER_BYTES,
188
- windowsHide: true,
189
- },
190
- );
139
+ const result = runPowerShellCommand(script, {
140
+ encoded: true,
141
+ sta: true,
142
+ timeout: READ_TIMEOUT_MS,
143
+ maxBuffer: MAX_BUFFER_BYTES,
144
+ });
191
145
 
192
- if (result.error) {
193
- return {
194
- available: !isErrnoException(result.error) || result.error.code !== "ENOENT",
195
- image: null,
196
- };
146
+ if (result.missingCommand) {
147
+ return { available: false, image: null };
197
148
  }
198
149
 
199
- if (result.status !== 0) {
150
+ if (!result.ok) {
200
151
  return { available: true, image: null };
201
152
  }
202
153
 
@@ -2,20 +2,13 @@ import { appendFileSync, mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
4
  import { getExtensionRoot, type ImageToolsConfig } from "./config.js";
5
+ import { getErrorMessage } from "./errors.js";
5
6
 
6
7
  const DEBUG_DIRECTORY_NAME = "debug";
7
8
  const DEBUG_LOG_FILE_NAME = "debug.log";
8
9
 
9
10
  type DebugFields = Record<string, unknown>;
10
11
 
11
- function getErrorMessage(error: unknown): string {
12
- if (error instanceof Error && error.message.trim().length > 0) {
13
- return error.message;
14
- }
15
-
16
- return "Unknown error";
17
- }
18
-
19
12
  export class DebugLogger {
20
13
  private readonly logPath: string | undefined;
21
14
 
package/src/errors.ts ADDED
@@ -0,0 +1,11 @@
1
+ export function getErrorMessage(error: unknown): string {
2
+ if (error instanceof Error && error.message.trim().length > 0) {
3
+ return error.message;
4
+ }
5
+
6
+ return "Unknown error";
7
+ }
8
+
9
+ export function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
10
+ return error instanceof Error && "code" in error;
11
+ }
@@ -0,0 +1,60 @@
1
+ const PREFERRED_IMAGE_MIME_TYPES = [
2
+ "image/png",
3
+ "image/jpeg",
4
+ "image/webp",
5
+ "image/gif",
6
+ "image/bmp",
7
+ ] as const;
8
+
9
+ const MIME_TYPE_TO_EXTENSION = new Map<string, string>([
10
+ ["image/png", "png"],
11
+ ["image/jpeg", "jpg"],
12
+ ["image/webp", "webp"],
13
+ ["image/gif", "gif"],
14
+ ["image/bmp", "bmp"],
15
+ ["image/tiff", "tiff"],
16
+ ]);
17
+
18
+ const EXTENSION_TO_MIME_TYPE = new Map<string, string>([
19
+ [".png", "image/png"],
20
+ [".jpg", "image/jpeg"],
21
+ [".jpeg", "image/jpeg"],
22
+ [".webp", "image/webp"],
23
+ [".gif", "image/gif"],
24
+ [".bmp", "image/bmp"],
25
+ ]);
26
+
27
+ export const SUPPORTED_IMAGE_MIME_TYPES: readonly string[] = PREFERRED_IMAGE_MIME_TYPES;
28
+
29
+ export function normalizeMimeType(mimeType: string): string {
30
+ return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
31
+ }
32
+
33
+ export function selectPreferredImageMimeType(mimeTypes: readonly string[]): string | null {
34
+ const normalized = mimeTypes
35
+ .map((mimeType) => mimeType.trim())
36
+ .filter((mimeType) => mimeType.length > 0)
37
+ .map((mimeType) => ({ raw: mimeType, normalized: normalizeMimeType(mimeType) }));
38
+
39
+ for (const preferredMimeType of SUPPORTED_IMAGE_MIME_TYPES) {
40
+ const match = normalized.find((mimeType) => mimeType.normalized === preferredMimeType);
41
+ if (match) {
42
+ return match.raw;
43
+ }
44
+ }
45
+
46
+ const firstImage = normalized.find((mimeType) => mimeType.normalized.startsWith("image/"));
47
+ return firstImage?.raw ?? null;
48
+ }
49
+
50
+ export function mimeTypeToExtension(mimeType: string, fallbackExtension = "png"): string {
51
+ return MIME_TYPE_TO_EXTENSION.get(normalizeMimeType(mimeType)) ?? fallbackExtension;
52
+ }
53
+
54
+ export function extensionToMimeType(fileNameOrExtension: string): string | null {
55
+ const extension = fileNameOrExtension.startsWith(".")
56
+ ? fileNameOrExtension.toLowerCase()
57
+ : `.${fileNameOrExtension.toLowerCase()}`;
58
+
59
+ return EXTENSION_TO_MIME_TYPE.get(extension) ?? null;
60
+ }
@@ -1,4 +1,3 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
2
  import { tmpdir } from "node:os";
4
3
  import { join } from "node:path";
@@ -15,6 +14,12 @@ import {
15
14
  type Component,
16
15
  } from "@mariozechner/pi-tui";
17
16
 
17
+ import { isRecord } from "./config.js";
18
+ import type { DebugLogger } from "./debug-logger.js";
19
+ import { getErrorMessage } from "./errors.js";
20
+ import { getBase64DecodedByteLength, assertImageWithinByteLimit } from "./image-size.js";
21
+ import { mimeTypeToExtension } from "./image-mime.js";
22
+ import { runPowerShellCommand } from "./powershell.js";
18
23
  import { buildSixelRenderLines, ensureCompleteSixelSequence } from "./sixel-protocol.js";
19
24
  import {
20
25
  DEFAULT_TERMINAL_IMAGE_WIDTH_CELLS,
@@ -77,22 +82,6 @@ function normalizeText(value: unknown): string {
77
82
  return typeof value === "string" ? value.trim() : "";
78
83
  }
79
84
 
80
- function getErrorMessage(error: unknown): string {
81
- if (error instanceof Error && error.message.trim()) {
82
- return error.message;
83
- }
84
-
85
- return "Unknown error";
86
- }
87
-
88
- function toRecord(value: unknown): Record<string, unknown> {
89
- if (!value || typeof value !== "object" || Array.isArray(value)) {
90
- return {};
91
- }
92
-
93
- return value as Record<string, unknown>;
94
- }
95
-
96
85
  function normalizeEnvValue(value: string | undefined): string {
97
86
  return typeof value === "string" ? value.trim().toLowerCase() : "";
98
87
  }
@@ -118,63 +107,6 @@ function shouldAttemptSixelRendering(): boolean {
118
107
  return !getCapabilities().images;
119
108
  }
120
109
 
121
- function runPowerShellCommand(
122
- script: string,
123
- args: string[] = [],
124
- ): { ok: boolean; stdout: string; stderr: string; reason?: string } {
125
- if (process.platform !== "win32") {
126
- return {
127
- ok: false,
128
- stdout: "",
129
- stderr: "",
130
- reason: "PowerShell-based Sixel rendering is only available on Windows.",
131
- };
132
- }
133
-
134
- const result = spawnSync(
135
- "powershell.exe",
136
- [
137
- "-NoProfile",
138
- "-NonInteractive",
139
- "-ExecutionPolicy",
140
- "Bypass",
141
- "-Command",
142
- script,
143
- ...args,
144
- ],
145
- {
146
- encoding: "utf8",
147
- timeout: POWER_SHELL_TIMEOUT_MS,
148
- maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
149
- windowsHide: true,
150
- },
151
- );
152
-
153
- if (result.error) {
154
- return {
155
- ok: false,
156
- stdout: result.stdout ?? "",
157
- stderr: result.stderr ?? "",
158
- reason: getErrorMessage(result.error),
159
- };
160
- }
161
-
162
- if (result.status !== 0) {
163
- return {
164
- ok: false,
165
- stdout: result.stdout ?? "",
166
- stderr: result.stderr ?? "",
167
- reason: `PowerShell exited with code ${result.status}`,
168
- };
169
- }
170
-
171
- return {
172
- ok: true,
173
- stdout: result.stdout ?? "",
174
- stderr: result.stderr ?? "",
175
- };
176
- }
177
-
178
110
  const sixelAvailabilityState: SixelAvailability = {
179
111
  checked: false,
180
112
  available: false,
@@ -191,26 +123,16 @@ $ProgressPreference = 'SilentlyContinue'
191
123
 
192
124
  $module = Get-Module -ListAvailable -Name Sixel | Sort-Object Version -Descending | Select-Object -First 1
193
125
  if ($null -eq $module) {
194
- try {
195
- if (Get-Command Install-Module -ErrorAction SilentlyContinue) {
196
- Install-Module -Name Sixel -Scope CurrentUser -Force -AllowClobber -Repository PSGallery -ErrorAction Stop | Out-Null
197
- } elseif (Get-Command Install-PSResource -ErrorAction SilentlyContinue) {
198
- Install-PSResource -Name Sixel -Scope CurrentUser -TrustRepository -Reinstall -Force -ErrorAction Stop | Out-Null
199
- }
200
- } catch {
201
- }
202
-
203
- $module = Get-Module -ListAvailable -Name Sixel | Sort-Object Version -Descending | Select-Object -First 1
204
- }
205
-
206
- if ($null -eq $module) {
207
- Write-Error 'Sixel PowerShell module is unavailable.'
126
+ Write-Error 'Sixel PowerShell module is unavailable. Install the Sixel module manually to enable Sixel previews.'
208
127
  }
209
128
 
210
129
  Write-Output ('Sixel/' + $module.Version.ToString())
211
130
  `;
212
131
 
213
- const result = runPowerShellCommand(script);
132
+ const result = runPowerShellCommand(script, {
133
+ timeout: POWER_SHELL_TIMEOUT_MS,
134
+ maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
135
+ });
214
136
  sixelAvailabilityState.checked = true;
215
137
 
216
138
  if (!result.ok) {
@@ -219,7 +141,7 @@ Write-Output ('Sixel/' + $module.Version.ToString())
219
141
  sixelAvailabilityState.available = false;
220
142
  sixelAvailabilityState.version = undefined;
221
143
  sixelAvailabilityState.reason =
222
- stderr || stdout || result.reason || "Failed to detect/install the Sixel PowerShell module.";
144
+ stderr || stdout || result.reason || "Failed to detect the Sixel PowerShell module.";
223
145
  return sixelAvailabilityState;
224
146
  }
225
147
 
@@ -232,26 +154,6 @@ Write-Output ('Sixel/' + $module.Version.ToString())
232
154
  return sixelAvailabilityState;
233
155
  }
234
156
 
235
- function extensionForImageMimeType(mimeType: string): string {
236
- const normalized = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
237
- switch (normalized) {
238
- case "image/png":
239
- return "png";
240
- case "image/jpeg":
241
- return "jpg";
242
- case "image/webp":
243
- return "webp";
244
- case "image/gif":
245
- return "gif";
246
- case "image/bmp":
247
- return "bmp";
248
- case "image/tiff":
249
- return "tiff";
250
- default:
251
- return "png";
252
- }
253
- }
254
-
255
157
  function escapePowerShellSingleQuoted(value: string): string {
256
158
  return value.replace(/'/g, "''");
257
159
  }
@@ -260,9 +162,10 @@ function convertImageToSixelSequence(
260
162
  image: ImagePayload,
261
163
  ): { sequence?: string; error?: string } {
262
164
  const tempBaseDir = mkdtempSync(join(tmpdir(), "pi-image-tools-image-"));
263
- const imagePath = join(tempBaseDir, `preview.${extensionForImageMimeType(image.mimeType)}`);
165
+ const imagePath = join(tempBaseDir, `preview.${mimeTypeToExtension(image.mimeType)}`);
264
166
 
265
167
  try {
168
+ assertImageWithinByteLimit(getBase64DecodedByteLength(image.data), "Preview image");
266
169
  const bytes = Buffer.from(image.data, "base64");
267
170
  if (bytes.length === 0) {
268
171
  return { error: "Image conversion failed: clipboard payload was empty." };
@@ -292,7 +195,10 @@ if ([string]::IsNullOrWhiteSpace($rendered)) {
292
195
  Write-Output $rendered
293
196
  `;
294
197
 
295
- const result = runPowerShellCommand(script);
198
+ const result = runPowerShellCommand(script, {
199
+ timeout: POWER_SHELL_TIMEOUT_MS,
200
+ maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
201
+ });
296
202
  if (!result.ok) {
297
203
  const detail = normalizeText(result.stderr) || normalizeText(result.stdout) || result.reason;
298
204
  return {
@@ -328,15 +234,22 @@ function estimateImageRows(image: ImagePayload, maxWidthCells: number): number {
328
234
  }
329
235
 
330
236
  function parseImagePreviewDetails(value: unknown): ImagePreviewDetails | null {
331
- const record = toRecord(value);
332
- const itemsRaw = record.items;
237
+ if (!isRecord(value)) {
238
+ return null;
239
+ }
240
+
241
+ const itemsRaw = value.items;
333
242
  if (!Array.isArray(itemsRaw)) {
334
243
  return null;
335
244
  }
336
245
 
337
246
  const items: ImagePreviewItem[] = [];
338
247
  for (const raw of itemsRaw) {
339
- const itemRecord = toRecord(raw);
248
+ if (!isRecord(raw)) {
249
+ continue;
250
+ }
251
+
252
+ const itemRecord = raw;
340
253
  const protocol = itemRecord.protocol === "sixel" ? "sixel" : "native";
341
254
  const mimeType = typeof itemRecord.mimeType === "string" ? itemRecord.mimeType : "image/png";
342
255
  const rows =
@@ -432,7 +345,26 @@ export function buildPreviewItems(
432
345
  });
433
346
  }
434
347
 
435
- export function registerImagePreviewDisplay(pi: ExtensionAPI): void {
348
+ export interface RegisterImagePreviewDisplayOptions {
349
+ logger?: DebugLogger;
350
+ }
351
+
352
+ function logPreviewHandlerError(
353
+ logger: DebugLogger | undefined,
354
+ event: string,
355
+ error: unknown,
356
+ ): void {
357
+ try {
358
+ logger?.log(event, { error: getErrorMessage(error) });
359
+ } catch {
360
+ // Debug logging is best-effort inside Pi event handlers.
361
+ }
362
+ }
363
+
364
+ export function registerImagePreviewDisplay(
365
+ pi: ExtensionAPI,
366
+ options: RegisterImagePreviewDisplayOptions = {},
367
+ ): void {
436
368
  let warnedSixelSetup = false;
437
369
 
438
370
  pi.registerMessageRenderer<ImagePreviewDetails>(
@@ -481,17 +413,21 @@ export function registerImagePreviewDisplay(pi: ExtensionAPI): void {
481
413
  );
482
414
 
483
415
  pi.on("session_start", async (_event, ctx) => {
484
- if (!shouldAttemptSixelRendering()) {
485
- return;
486
- }
416
+ try {
417
+ if (!shouldAttemptSixelRendering()) {
418
+ return;
419
+ }
487
420
 
488
- const availability = ensureSixelModuleAvailable();
489
- if (!availability.available && !warnedSixelSetup && ctx.hasUI) {
490
- warnedSixelSetup = true;
491
- ctx.ui.notify(
492
- `Image preview fallback active: ${availability.reason || "Sixel module unavailable."}`,
493
- "warning",
494
- );
421
+ const availability = ensureSixelModuleAvailable();
422
+ if (!availability.available && !warnedSixelSetup && ctx.hasUI) {
423
+ warnedSixelSetup = true;
424
+ ctx.ui.notify(
425
+ `Image preview fallback active: ${availability.reason || "Sixel module unavailable."}`,
426
+ "warning",
427
+ );
428
+ }
429
+ } catch (error) {
430
+ logPreviewHandlerError(options.logger, "image-preview.session_start_failed", error);
495
431
  }
496
432
  });
497
433
  }
@@ -0,0 +1,63 @@
1
+ export const IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR = "PI_IMAGE_TOOLS_MAX_IMAGE_BYTES";
2
+ export const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
3
+
4
+ function parseMaxImageBytes(environment: NodeJS.ProcessEnv): number {
5
+ const rawValue = environment[IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR]?.trim();
6
+ if (!rawValue) {
7
+ return DEFAULT_MAX_IMAGE_BYTES;
8
+ }
9
+
10
+ const parsed = Number(rawValue);
11
+ if (!Number.isFinite(parsed) || parsed < 1) {
12
+ throw new Error(
13
+ `${IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR} must be a positive byte count when set.`,
14
+ );
15
+ }
16
+
17
+ return Math.floor(parsed);
18
+ }
19
+
20
+ export function getMaxImageBytes(environment: NodeJS.ProcessEnv = process.env): number {
21
+ return parseMaxImageBytes(environment);
22
+ }
23
+
24
+ export function formatByteLimit(bytes: number): string {
25
+ if (bytes < 1024) {
26
+ return `${bytes} B`;
27
+ }
28
+
29
+ const units = ["KB", "MB", "GB"] as const;
30
+ let value = bytes / 1024;
31
+ let unitIndex = 0;
32
+
33
+ while (value >= 1024 && unitIndex < units.length - 1) {
34
+ value /= 1024;
35
+ unitIndex += 1;
36
+ }
37
+
38
+ return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
39
+ }
40
+
41
+ export function assertImageWithinByteLimit(
42
+ sizeBytes: number,
43
+ label: string,
44
+ environment: NodeJS.ProcessEnv = process.env,
45
+ ): void {
46
+ const maxImageBytes = getMaxImageBytes(environment);
47
+ if (sizeBytes > maxImageBytes) {
48
+ throw new Error(
49
+ `${label} is too large (${formatByteLimit(sizeBytes)}). The pi-image-tools limit is ${formatByteLimit(maxImageBytes)}. Set ${IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR} to a larger byte count if needed.`,
50
+ );
51
+ }
52
+ }
53
+
54
+ export function getBase64DecodedByteLength(base64Data: string): number {
55
+ const normalized = base64Data.trim().replace(/^data:[^,]*,/, "").replace(/\s/g, "");
56
+ if (normalized.length === 0) {
57
+ return 0;
58
+ }
59
+
60
+ const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
61
+ return Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
62
+ }
63
+
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ import { readClipboardImage } from "./clipboard.js";
4
4
  import { registerPasteImageCommand } from "./commands.js";
5
5
  import { loadImageToolsConfig } from "./config.js";
6
6
  import { DebugLogger } from "./debug-logger.js";
7
+ import { getErrorMessage } from "./errors.js";
8
+ import { assertImageWithinByteLimit } from "./image-size.js";
7
9
  import {
8
10
  IMAGE_PREVIEW_CUSTOM_TYPE,
9
11
  buildPreviewItems,
@@ -27,15 +29,8 @@ const IMAGE_ATTACHMENT_INDICATOR = "[󰈟 Image Attached]";
27
29
 
28
30
  interface PendingImage extends ImagePayload {}
29
31
 
30
- function getErrorMessage(error: unknown): string {
31
- if (error instanceof Error && error.message.trim().length > 0) {
32
- return error.message;
33
- }
34
-
35
- return "Unknown error";
36
- }
37
-
38
32
  function imageToBase64(image: ClipboardImage): string {
33
+ assertImageWithinByteLimit(image.bytes.length, "Image attachment");
39
34
  return Buffer.from(image.bytes).toString("base64");
40
35
  }
41
36
 
@@ -155,8 +150,8 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
155
150
  pasteImageShortcutsConfigured: config.shortcuts.pasteImage !== undefined,
156
151
  });
157
152
 
158
- registerInlineUserImagePreview(pi);
159
- registerImagePreviewDisplay(pi);
153
+ registerInlineUserImagePreview(pi, { logger });
154
+ registerImagePreviewDisplay(pi, { logger });
160
155
 
161
156
  const pasteImageFromClipboard = async (ctx: PasteContext): Promise<void> => {
162
157
  if (!ctx.hasUI) {
@@ -5,6 +5,9 @@ import {
5
5
  } from "@mariozechner/pi-coding-agent";
6
6
  import { Image, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
7
7
 
8
+ import { isRecord } from "./config.js";
9
+ import type { DebugLogger } from "./debug-logger.js";
10
+ import { getErrorMessage } from "./errors.js";
8
11
  import { buildPreviewItems, type ImagePayload, type ImagePreviewItem } from "./image-preview.js";
9
12
  import { buildSixelRenderLines, isInlineImageProtocolLine } from "./sixel-protocol.js";
10
13
  import { setActiveTerminalImageSettingsCwd } from "./terminal-image-width.js";
@@ -108,19 +111,15 @@ function renderPreviewLines(items: readonly ImagePreviewItem[], width: number):
108
111
  }
109
112
 
110
113
  function toUserMessage(value: unknown): UserMessageLike {
111
- if (!value || typeof value !== "object" || Array.isArray(value)) {
112
- return {};
113
- }
114
-
115
- return value as UserMessageLike;
114
+ return isRecord(value) ? value : {};
116
115
  }
117
116
 
118
117
  function toImageContent(value: unknown): UserImageContent | null {
119
- if (!value || typeof value !== "object" || Array.isArray(value)) {
118
+ if (!isRecord(value)) {
120
119
  return null;
121
120
  }
122
121
 
123
- const record = value as Record<string, unknown>;
122
+ const record = value;
124
123
  if (record.type !== "image") {
125
124
  return null;
126
125
  }
@@ -237,7 +236,19 @@ function assignPreviewItemsToLatestUserMessage(
237
236
  }
238
237
  }
239
238
 
240
- function patchInteractiveMode(): void {
239
+ function logInlinePreviewError(
240
+ logger: DebugLogger | undefined,
241
+ event: string,
242
+ error: unknown,
243
+ ): void {
244
+ try {
245
+ logger?.log(event, { error: getErrorMessage(error) });
246
+ } catch {
247
+ // Debug logging is best-effort inside Pi event handlers.
248
+ }
249
+ }
250
+
251
+ function patchInteractiveMode(logger?: DebugLogger): void {
241
252
  const prototype = (InteractiveMode as unknown as { prototype: InteractiveModePrototype }).prototype;
242
253
  if (!prototype) {
243
254
  return;
@@ -281,7 +292,8 @@ function patchInteractiveMode(): void {
281
292
  if (imagePayloads.length > 0) {
282
293
  try {
283
294
  previewItems = buildPreviewItems(imagePayloads);
284
- } catch {
295
+ } catch (error) {
296
+ logInlinePreviewError(logger, "inline-user-preview.build_preview_failed", error);
285
297
  previewItems = [];
286
298
  }
287
299
  }
@@ -303,31 +315,53 @@ function patchInteractiveMode(): void {
303
315
  prototype.__piImageToolsPreviewPatched = true;
304
316
  }
305
317
 
306
- export function registerInlineUserImagePreview(pi: ExtensionAPI): void {
307
- const schedulePatch = (): void => {
308
- setTimeout(() => {
309
- patchInteractiveMode();
310
- patchUserMessageRender();
311
- }, 0);
318
+ export interface RegisterInlineUserImagePreviewOptions {
319
+ logger?: DebugLogger;
320
+ }
312
321
 
322
+ export function registerInlineUserImagePreview(
323
+ pi: ExtensionAPI,
324
+ options: RegisterInlineUserImagePreviewOptions = {},
325
+ ): void {
326
+ const runPatch = (delayMs: number): void => {
313
327
  setTimeout(() => {
314
- patchInteractiveMode();
315
- patchUserMessageRender();
316
- }, 25);
328
+ try {
329
+ patchInteractiveMode(options.logger);
330
+ patchUserMessageRender();
331
+ } catch (error) {
332
+ logInlinePreviewError(options.logger, "inline-user-preview.patch_failed", error);
333
+ }
334
+ }, delayMs);
335
+ };
336
+
337
+ const schedulePatch = (): void => {
338
+ runPatch(0);
339
+ runPatch(25);
340
+ };
341
+
342
+ const handleSessionEvent = (eventName: string, cwd: string | undefined): void => {
343
+ try {
344
+ setActiveTerminalImageSettingsCwd(cwd);
345
+ schedulePatch();
346
+ } catch (error) {
347
+ logInlinePreviewError(options.logger, `inline-user-preview.${eventName}_failed`, error);
348
+ }
317
349
  };
318
350
 
319
351
  pi.on("session_start", async (_event, ctx) => {
320
- setActiveTerminalImageSettingsCwd(ctx.cwd);
321
- schedulePatch();
352
+ handleSessionEvent("session_start", ctx.cwd);
322
353
  });
323
354
 
324
355
  pi.on("before_agent_start", async (_event, ctx) => {
325
- setActiveTerminalImageSettingsCwd(ctx.cwd);
326
- schedulePatch();
356
+ handleSessionEvent("before_agent_start", ctx.cwd);
327
357
  });
328
358
 
329
- pi.on("session_switch", async (_event, ctx) => {
330
- setActiveTerminalImageSettingsCwd(ctx.cwd);
331
- schedulePatch();
359
+ const onSessionSwitch = pi.on as unknown as (
360
+ event: "session_switch",
361
+ handler: (_event: unknown, ctx: { cwd?: string }) => Promise<void>,
362
+ ) => void;
363
+
364
+ onSessionSwitch("session_switch", async (_event, ctx) => {
365
+ handleSessionEvent("session_switch", ctx.cwd);
332
366
  });
333
367
  }
@@ -0,0 +1,71 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ import { getErrorMessage, isErrnoException } from "./errors.js";
4
+
5
+ export interface PowerShellCommandResult {
6
+ ok: boolean;
7
+ stdout: string;
8
+ stderr: string;
9
+ missingCommand: boolean;
10
+ reason?: string;
11
+ }
12
+
13
+ export interface RunPowerShellCommandOptions {
14
+ args?: string[];
15
+ encoded?: boolean;
16
+ maxBuffer: number;
17
+ sta?: boolean;
18
+ timeout: number;
19
+ }
20
+
21
+ function encodePowerShell(script: string): string {
22
+ return Buffer.from(script, "utf16le").toString("base64");
23
+ }
24
+
25
+ export function runPowerShellCommand(
26
+ script: string,
27
+ options: RunPowerShellCommandOptions,
28
+ ): PowerShellCommandResult {
29
+ if (process.platform !== "win32") {
30
+ return {
31
+ ok: false,
32
+ stdout: "",
33
+ stderr: "",
34
+ missingCommand: false,
35
+ reason: "PowerShell is only available through pi-image-tools on Windows.",
36
+ };
37
+ }
38
+
39
+ const commandArgs = [
40
+ "-NoProfile",
41
+ "-NonInteractive",
42
+ ...(options.sta ? ["-STA"] : []),
43
+ ...(options.encoded ? ["-EncodedCommand", encodePowerShell(script)] : ["-Command", script]),
44
+ ...(options.args ?? []),
45
+ ];
46
+
47
+ const result = spawnSync("powershell.exe", commandArgs, {
48
+ encoding: "utf8",
49
+ timeout: options.timeout,
50
+ maxBuffer: options.maxBuffer,
51
+ windowsHide: true,
52
+ });
53
+
54
+ if (result.error) {
55
+ return {
56
+ ok: false,
57
+ stdout: result.stdout ?? "",
58
+ stderr: result.stderr ?? "",
59
+ missingCommand: isErrnoException(result.error) && result.error.code === "ENOENT",
60
+ reason: getErrorMessage(result.error),
61
+ };
62
+ }
63
+
64
+ return {
65
+ ok: result.status === 0,
66
+ stdout: result.stdout ?? "",
67
+ stderr: result.stderr ?? "",
68
+ missingCommand: false,
69
+ reason: result.status === 0 ? undefined : `PowerShell exited with code ${result.status}`,
70
+ };
71
+ }
@@ -11,6 +11,8 @@ import {
11
11
  import { homedir, tmpdir } from "node:os";
12
12
  import { basename, extname, join, resolve } from "node:path";
13
13
 
14
+ import { extensionToMimeType, mimeTypeToExtension } from "./image-mime.js";
15
+ import { assertImageWithinByteLimit, formatByteLimit } from "./image-size.js";
14
16
  import type { ClipboardImage } from "./types.js";
15
17
 
16
18
  export const RECENT_IMAGE_ENV_VAR = "PI_IMAGE_TOOLS_RECENT_DIRS";
@@ -30,15 +32,6 @@ const SCREENSHOT_NAME_PATTERNS: readonly RegExp[] = [
30
32
  /^スクリーンショット/i,
31
33
  ];
32
34
 
33
- const EXTENSION_TO_MIME = new Map<string, string>([
34
- [".png", "image/png"],
35
- [".jpg", "image/jpeg"],
36
- [".jpeg", "image/jpeg"],
37
- [".webp", "image/webp"],
38
- [".gif", "image/gif"],
39
- [".bmp", "image/bmp"],
40
- ]);
41
-
42
35
  interface RecentImageSource {
43
36
  path: string;
44
37
  filterScreenshotNames: boolean;
@@ -193,27 +186,11 @@ function isLikelyScreenshotName(name: string): boolean {
193
186
  }
194
187
 
195
188
  function toMimeType(fileName: string): string | null {
196
- const extension = extname(fileName).toLowerCase();
197
- return EXTENSION_TO_MIME.get(extension) ?? null;
189
+ return extensionToMimeType(extname(fileName));
198
190
  }
199
191
 
200
- function extensionForMimeType(mimeType: string): string {
201
- const normalized = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
202
-
203
- switch (normalized) {
204
- case "image/png":
205
- return "png";
206
- case "image/jpeg":
207
- return "jpg";
208
- case "image/webp":
209
- return "webp";
210
- case "image/gif":
211
- return "gif";
212
- case "image/bmp":
213
- return "bmp";
214
- default:
215
- return "png";
216
- }
192
+ function isExtensionOwnedCacheFileName(name: string): boolean {
193
+ return /^pi-recent-\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i.test(name);
217
194
  }
218
195
 
219
196
  function listRecentImagesFromSource(source: RecentImageSource): RecentImageCandidate[] {
@@ -372,7 +349,7 @@ function pruneCacheDirectory(cacheDirectory: string, maxCacheFiles: number): voi
372
349
  return null;
373
350
  }
374
351
 
375
- if (!toMimeType(name)) {
352
+ if (!isExtensionOwnedCacheFileName(name) || !toMimeType(name)) {
376
353
  return null;
377
354
  }
378
355
 
@@ -406,8 +383,9 @@ export function persistImageToRecentCache(
406
383
  }
407
384
 
408
385
  const environment = options.environment ?? process.env;
386
+ assertImageWithinByteLimit(image.bytes.length, "Cached image", environment);
409
387
  const cacheDirectory = getRecentImageCacheDirectory(environment);
410
- const extension = extensionForMimeType(image.mimeType);
388
+ const extension = mimeTypeToExtension(image.mimeType);
411
389
 
412
390
  mkdirSync(cacheDirectory, { recursive: true });
413
391
 
@@ -451,23 +429,6 @@ function formatRelativeAge(modifiedAtMs: number, nowMs: number): string {
451
429
  return `${deltaYears}y ago`;
452
430
  }
453
431
 
454
- function formatSize(sizeBytes: number): string {
455
- if (sizeBytes < 1024) {
456
- return `${sizeBytes} B`;
457
- }
458
-
459
- const units = ["KB", "MB", "GB"] as const;
460
- let value = sizeBytes / 1024;
461
- let unitIndex = 0;
462
-
463
- while (value >= 1024 && unitIndex < units.length - 1) {
464
- value /= 1024;
465
- unitIndex += 1;
466
- }
467
-
468
- return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
469
- }
470
-
471
432
  function detectPathSeparator(pathValue: string): string {
472
433
  return pathValue.includes("\\") ? "\\" : "/";
473
434
  }
@@ -489,13 +450,17 @@ function abbreviatePath(pathValue: string, maxChars: number): string {
489
450
 
490
451
  export function formatRecentImageLabel(candidate: RecentImageCandidate, nowMs = Date.now()): string {
491
452
  const age = formatRelativeAge(candidate.modifiedAtMs, nowMs);
492
- const size = formatSize(candidate.sizeBytes);
453
+ const size = formatByteLimit(candidate.sizeBytes);
493
454
  const shortPath = abbreviatePath(candidate.path, 64);
494
455
 
495
456
  return `${candidate.name} • ${age} • ${size} • ${shortPath}`;
496
457
  }
497
458
 
498
- export function loadRecentImage(candidate: RecentImageCandidate): ClipboardImage {
459
+ export function loadRecentImage(
460
+ candidate: RecentImageCandidate,
461
+ environment: NodeJS.ProcessEnv = process.env,
462
+ ): ClipboardImage {
463
+ assertImageWithinByteLimit(candidate.sizeBytes, `Recent image ${candidate.name}`, environment);
499
464
  const raw = readFileSync(candidate.path);
500
465
  if (raw.length === 0) {
501
466
  throw new Error(`File is empty: ${candidate.path}`);
@@ -1,6 +1,8 @@
1
1
  import { SettingsManager, getAgentDir } from "@mariozechner/pi-coding-agent";
2
2
  import { resolve } from "node:path";
3
3
 
4
+ import { isRecord } from "./config.js";
5
+
4
6
  export const DEFAULT_TERMINAL_IMAGE_WIDTH_CELLS = 60;
5
7
 
6
8
  export interface TerminalImageWidthOptions {
@@ -42,7 +44,7 @@ function normalizeImageWidthCells(value: unknown): number {
42
44
  }
43
45
 
44
46
  function readRawImageWidthCells(settings: unknown): unknown {
45
- if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
47
+ if (!isRecord(settings)) {
46
48
  return undefined;
47
49
  }
48
50
 
package/src/temp-file.ts DELETED
@@ -1,82 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
-
6
- import type { ClipboardImage } from "./types.js";
7
-
8
- function extensionForImageMimeType(mimeType: string): string {
9
- const normalized = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
10
-
11
- switch (normalized) {
12
- case "image/png":
13
- return "png";
14
- case "image/jpeg":
15
- return "jpg";
16
- case "image/webp":
17
- return "webp";
18
- case "image/gif":
19
- return "gif";
20
- default:
21
- return "png";
22
- }
23
- }
24
-
25
- export class TempFileManager {
26
- private readonly baseDir: string;
27
- private readonly createdFiles = new Set<string>();
28
- private exitHookRegistered = false;
29
-
30
- constructor(baseDir: string = join(tmpdir(), "pi-images")) {
31
- this.baseDir = baseDir;
32
- }
33
-
34
- registerExitCleanup(): void {
35
- if (this.exitHookRegistered) {
36
- return;
37
- }
38
-
39
- process.once("exit", () => {
40
- this.cleanupSync();
41
- });
42
-
43
- this.exitHookRegistered = true;
44
- }
45
-
46
- saveImage(image: ClipboardImage): string {
47
- mkdirSync(this.baseDir, { recursive: true });
48
-
49
- const ext = extensionForImageMimeType(image.mimeType);
50
- const filePath = join(this.baseDir, `pi-image-${Date.now()}-${randomUUID()}.${ext}`);
51
-
52
- writeFileSync(filePath, Buffer.from(image.bytes));
53
- this.createdFiles.add(filePath);
54
-
55
- return filePath;
56
- }
57
-
58
- async cleanup(): Promise<void> {
59
- this.cleanupSync();
60
- }
61
-
62
- cleanupSync(): void {
63
- for (const filePath of this.createdFiles) {
64
- try {
65
- unlinkSync(filePath);
66
- } catch {
67
- // Best-effort cleanup only.
68
- }
69
- }
70
-
71
- this.createdFiles.clear();
72
-
73
- try {
74
- const entries = readdirSync(this.baseDir);
75
- if (entries.length === 0) {
76
- rmSync(this.baseDir, { recursive: true, force: true });
77
- }
78
- } catch {
79
- // Ignore directory cleanup errors.
80
- }
81
- }
82
- }