@makerbi/remodex 1.3.8 → 1.3.9

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.
@@ -0,0 +1,406 @@
1
+ // FILE: workspace-checkpoints.js
2
+ // Purpose: Captures, diffs, and restores hidden Git checkpoints for turn-scoped undo.
3
+ // Layer: Bridge workspace support
4
+ // Exports: workspaceCheckpointCapture, workspaceCheckpointCopy, workspaceCheckpointDiff,
5
+ // workspaceCheckpointRestorePreview, workspaceCheckpointRestoreApply
6
+ // Depends on: child_process, fs, os, path, ./git-handler
7
+
8
+ const { execFile } = require("child_process");
9
+ const fs = require("fs");
10
+ const os = require("os");
11
+ const path = require("path");
12
+ const { promisify } = require("util");
13
+ const { gitStatus } = require("./git-handler");
14
+
15
+ const execFileAsync = promisify(execFile);
16
+ const GIT_TIMEOUT_MS = 30_000;
17
+ const CHECKPOINT_REFS_PREFIX = "refs/remodex/checkpoints";
18
+
19
+ async function workspaceCheckpointCapture(repoRoot, params) {
20
+ const checkpoint = resolveCheckpointDescriptor(params);
21
+ const commit = await captureGitCheckpoint(repoRoot, checkpoint.ref);
22
+ return {
23
+ repoRoot,
24
+ checkpointRef: checkpoint.ref,
25
+ checkpointKind: checkpoint.kind,
26
+ commit,
27
+ threadId: checkpoint.threadId,
28
+ turnId: checkpoint.turnId || undefined,
29
+ messageId: checkpoint.messageId || undefined,
30
+ };
31
+ }
32
+
33
+ async function workspaceCheckpointCopy(repoRoot, params) {
34
+ const sourceRef = resolveCheckpointRef(params, "source");
35
+ const target = resolveCheckpointDescriptor(params, "target");
36
+ const sourceCommit = await resolveCheckpointCommit(repoRoot, sourceRef);
37
+ if (!sourceCommit) {
38
+ return {
39
+ copied: false,
40
+ repoRoot,
41
+ sourceCheckpointRef: sourceRef,
42
+ checkpointRef: target.ref,
43
+ };
44
+ }
45
+
46
+ await git(repoRoot, "update-ref", target.ref, sourceCommit);
47
+ return {
48
+ copied: true,
49
+ repoRoot,
50
+ sourceCheckpointRef: sourceRef,
51
+ checkpointRef: target.ref,
52
+ checkpointKind: target.kind,
53
+ commit: sourceCommit,
54
+ threadId: target.threadId,
55
+ turnId: target.turnId || undefined,
56
+ messageId: target.messageId || undefined,
57
+ };
58
+ }
59
+
60
+ async function workspaceCheckpointDiff(repoRoot, params) {
61
+ const fromRef = resolveCheckpointRef(params, "from");
62
+ const toRef = resolveCheckpointRef(params, "to");
63
+ const [fromCommit, toCommit] = await Promise.all([
64
+ resolveCheckpointCommit(repoRoot, fromRef),
65
+ resolveCheckpointCommit(repoRoot, toRef),
66
+ ]);
67
+ if (!fromCommit || !toCommit) {
68
+ throw workspaceCheckpointError(
69
+ "checkpoint_missing",
70
+ "One of the requested workspace checkpoints is unavailable."
71
+ );
72
+ }
73
+
74
+ const diff = await git(repoRoot, "diff", "--patch", "--minimal", "--no-color", fromCommit, toCommit);
75
+ return {
76
+ repoRoot,
77
+ fromCheckpointRef: fromRef,
78
+ toCheckpointRef: toRef,
79
+ diff,
80
+ };
81
+ }
82
+
83
+ async function workspaceCheckpointRestorePreview(repoRoot, params) {
84
+ const checkpointRef = resolveCheckpointRef(params, "target");
85
+ const commit = await resolveCheckpointCommit(repoRoot, checkpointRef);
86
+ if (!commit) {
87
+ throw workspaceCheckpointError(
88
+ "checkpoint_missing",
89
+ "The requested workspace checkpoint is unavailable."
90
+ );
91
+ }
92
+
93
+ const [changedFiles, stagedFiles, untrackedFiles] = await Promise.all([
94
+ changedFilesAgainstCommit(repoRoot, commit),
95
+ stagedFilesInRepo(repoRoot),
96
+ untrackedFilesInRepo(repoRoot),
97
+ ]);
98
+
99
+ return {
100
+ canRestore: true,
101
+ repoRoot,
102
+ checkpointRef,
103
+ commit,
104
+ affectedFiles: uniqueSorted([...changedFiles, ...untrackedFiles]),
105
+ stagedFiles,
106
+ untrackedFiles,
107
+ };
108
+ }
109
+
110
+ async function workspaceCheckpointRestoreApply(repoRoot, params) {
111
+ if (params.confirmDestructiveRestore !== true) {
112
+ throw workspaceCheckpointError(
113
+ "restore_confirmation_required",
114
+ "Checkpoint restore requires explicit destructive-restore confirmation."
115
+ );
116
+ }
117
+
118
+ const preview = await workspaceCheckpointRestorePreview(repoRoot, params);
119
+ const expectedTargetCommit = firstNonEmptyString([params.expectedTargetCommit]);
120
+ if (expectedTargetCommit && preview.commit !== expectedTargetCommit) {
121
+ throw workspaceCheckpointError(
122
+ "checkpoint_changed",
123
+ "The workspace checkpoint changed after preview. Review the restore again before applying it."
124
+ );
125
+ }
126
+
127
+ const backup = backupDescriptorForRestore(params);
128
+ const backupCommit = await captureGitCheckpoint(repoRoot, backup.ref);
129
+
130
+ await git(repoRoot, "restore", "--source", preview.commit, "--worktree", "--staged", "--", ".");
131
+ await git(repoRoot, "clean", "-fd", "--", ".");
132
+ if (await hasHeadCommit(repoRoot)) {
133
+ await git(repoRoot, "reset", "--quiet", "--", ".");
134
+ }
135
+
136
+ const status = await gitStatus(repoRoot).catch(() => null);
137
+ return {
138
+ success: true,
139
+ repoRoot,
140
+ checkpointRef: preview.checkpointRef,
141
+ backupCheckpointRef: backup.ref,
142
+ backupCommit,
143
+ restoredFiles: preview.affectedFiles,
144
+ status,
145
+ };
146
+ }
147
+
148
+ function resolveCheckpointDescriptor(params, prefix = "") {
149
+ const checkpointRef = firstNonEmptyString([
150
+ params[`${prefix}CheckpointRef`],
151
+ prefix ? null : params.checkpointRef,
152
+ ]);
153
+ if (checkpointRef) {
154
+ return {
155
+ ref: validateCheckpointRef(checkpointRef),
156
+ kind: firstNonEmptyString([params[`${prefix}CheckpointKind`], params.checkpointKind]) || "custom",
157
+ threadId: firstNonEmptyString([params.threadId]) || "",
158
+ turnId: firstNonEmptyString([params.turnId]) || null,
159
+ messageId: firstNonEmptyString([params.messageId]) || null,
160
+ };
161
+ }
162
+
163
+ const threadId = requireIdentifier(params.threadId, "threadId");
164
+ const kind = firstNonEmptyString([
165
+ params[`${prefix}CheckpointKind`],
166
+ params[`${prefix}Kind`],
167
+ params.checkpointKind,
168
+ params.kind,
169
+ ]) || "turnEnd";
170
+ const turnId = firstNonEmptyString([params[`${prefix}TurnId`], params.turnId]);
171
+ const messageId = firstNonEmptyString([params[`${prefix}MessageId`], params.messageId]);
172
+ const ref = checkpointRefFor({ threadId, kind, turnId, messageId });
173
+
174
+ return {
175
+ ref,
176
+ kind,
177
+ threadId,
178
+ turnId: turnId || null,
179
+ messageId: messageId || null,
180
+ };
181
+ }
182
+
183
+ function resolveCheckpointRef(params, prefix) {
184
+ const directRef = firstNonEmptyString([
185
+ params[`${prefix}CheckpointRef`],
186
+ params[`${prefix}Ref`],
187
+ prefix === "target" ? params.checkpointRef : null,
188
+ ]);
189
+ if (directRef) {
190
+ return validateCheckpointRef(directRef);
191
+ }
192
+
193
+ return resolveCheckpointDescriptor(params, prefix).ref;
194
+ }
195
+
196
+ function checkpointRefFor({ threadId, kind, turnId, messageId }) {
197
+ const threadKey = encodeRefSegment(threadId);
198
+ switch (kind) {
199
+ case "messageStart":
200
+ return `${CHECKPOINT_REFS_PREFIX}/${threadKey}/message-start/${encodeRefSegment(
201
+ requireIdentifier(messageId, "messageId")
202
+ )}`;
203
+ case "turnStart":
204
+ return `${CHECKPOINT_REFS_PREFIX}/${threadKey}/turn-start/${encodeRefSegment(
205
+ requireIdentifier(turnId, "turnId")
206
+ )}`;
207
+ case "turnEnd":
208
+ case "turn":
209
+ return `${CHECKPOINT_REFS_PREFIX}/${threadKey}/turn/${encodeRefSegment(
210
+ requireIdentifier(turnId, "turnId")
211
+ )}`;
212
+ case "restoreBackup":
213
+ return `${CHECKPOINT_REFS_PREFIX}/${threadKey}/restore-backup/${Date.now()}-${Math.random()
214
+ .toString(16)
215
+ .slice(2)}`;
216
+ default:
217
+ throw workspaceCheckpointError(
218
+ "invalid_checkpoint_kind",
219
+ `Unsupported workspace checkpoint kind: ${kind}`
220
+ );
221
+ }
222
+ }
223
+
224
+ function backupDescriptorForRestore(params) {
225
+ const threadId = requireIdentifier(params.threadId, "threadId");
226
+ const ref = checkpointRefFor({ threadId, kind: "restoreBackup" });
227
+ return {
228
+ ref,
229
+ kind: "restoreBackup",
230
+ threadId,
231
+ };
232
+ }
233
+
234
+ async function captureGitCheckpoint(repoRoot, checkpointRef) {
235
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "remodex-checkpoint-"));
236
+ const tempIndexPath = path.join(tempDir, `index-${process.pid}-${Date.now()}`);
237
+ const env = {
238
+ ...process.env,
239
+ GIT_INDEX_FILE: tempIndexPath,
240
+ GIT_AUTHOR_NAME: "Remodex",
241
+ GIT_AUTHOR_EMAIL: "remodex@users.noreply.github.com",
242
+ GIT_COMMITTER_NAME: "Remodex",
243
+ GIT_COMMITTER_EMAIL: "remodex@users.noreply.github.com",
244
+ };
245
+
246
+ try {
247
+ if (await hasHeadCommit(repoRoot)) {
248
+ await git(repoRoot, "read-tree", "HEAD", { env });
249
+ }
250
+ await git(repoRoot, "add", "-A", "--", ".", { env });
251
+ const treeOid = (await git(repoRoot, "write-tree", { env })).trim();
252
+ if (!treeOid) {
253
+ throw workspaceCheckpointError(
254
+ "checkpoint_capture_failed",
255
+ "Git did not produce a checkpoint tree."
256
+ );
257
+ }
258
+
259
+ const message = `remodex checkpoint ref=${checkpointRef}`;
260
+ const commitOid = (await git(repoRoot, "commit-tree", treeOid, "-m", message, { env })).trim();
261
+ if (!commitOid) {
262
+ throw workspaceCheckpointError(
263
+ "checkpoint_capture_failed",
264
+ "Git did not produce a checkpoint commit."
265
+ );
266
+ }
267
+
268
+ await git(repoRoot, "update-ref", checkpointRef, commitOid);
269
+ return commitOid;
270
+ } finally {
271
+ await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
272
+ }
273
+ }
274
+
275
+ async function resolveCheckpointCommit(repoRoot, checkpointRef) {
276
+ const result = await gitResult(
277
+ repoRoot,
278
+ ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`],
279
+ { allowNonZeroExit: true }
280
+ );
281
+ if (result.code !== 0) {
282
+ return null;
283
+ }
284
+ const commit = result.stdout.trim();
285
+ return commit || null;
286
+ }
287
+
288
+ async function hasHeadCommit(repoRoot) {
289
+ const result = await gitResult(repoRoot, ["rev-parse", "--verify", "--quiet", "HEAD"], {
290
+ allowNonZeroExit: true,
291
+ });
292
+ return result.code === 0 && result.stdout.trim().length > 0;
293
+ }
294
+
295
+ async function changedFilesAgainstCommit(repoRoot, commit) {
296
+ const output = await git(repoRoot, "diff", "--name-only", commit, "--");
297
+ return output.split("\n").map((line) => line.trim()).filter(Boolean);
298
+ }
299
+
300
+ async function stagedFilesInRepo(repoRoot) {
301
+ const output = await git(repoRoot, "diff", "--name-only", "--cached");
302
+ return output.split("\n").map((line) => line.trim()).filter(Boolean);
303
+ }
304
+
305
+ async function untrackedFilesInRepo(repoRoot) {
306
+ const output = await git(repoRoot, "ls-files", "--others", "--exclude-standard");
307
+ return output.split("\n").map((line) => line.trim()).filter(Boolean);
308
+ }
309
+
310
+ function validateCheckpointRef(checkpointRef) {
311
+ const normalized = String(checkpointRef || "").trim();
312
+ if (!normalized.startsWith(`${CHECKPOINT_REFS_PREFIX}/`)) {
313
+ throw workspaceCheckpointError(
314
+ "invalid_checkpoint_ref",
315
+ "Workspace checkpoint refs must stay inside the Remodex checkpoint namespace."
316
+ );
317
+ }
318
+ if (normalized.includes("..") || normalized.includes(" ")) {
319
+ throw workspaceCheckpointError(
320
+ "invalid_checkpoint_ref",
321
+ "Workspace checkpoint ref contains invalid path segments."
322
+ );
323
+ }
324
+ return normalized;
325
+ }
326
+
327
+ function requireIdentifier(value, name) {
328
+ const normalized = typeof value === "string" ? value.trim() : "";
329
+ if (!normalized) {
330
+ throw workspaceCheckpointError(
331
+ "missing_checkpoint_identifier",
332
+ `Workspace checkpoint requires ${name}.`
333
+ );
334
+ }
335
+ return normalized;
336
+ }
337
+
338
+ function encodeRefSegment(value) {
339
+ return Buffer.from(requireIdentifier(value, "checkpoint segment"), "utf8")
340
+ .toString("base64")
341
+ .replaceAll("+", "-")
342
+ .replaceAll("/", "_")
343
+ .replaceAll("=", "");
344
+ }
345
+
346
+ function uniqueSorted(values) {
347
+ return [...new Set(values.filter(Boolean))].sort();
348
+ }
349
+
350
+ function firstNonEmptyString(candidates) {
351
+ for (const candidate of candidates) {
352
+ if (typeof candidate !== "string") {
353
+ continue;
354
+ }
355
+ const trimmed = candidate.trim();
356
+ if (trimmed) {
357
+ return trimmed;
358
+ }
359
+ }
360
+ return null;
361
+ }
362
+
363
+ async function git(repoRoot, ...args) {
364
+ let options = {};
365
+ if (args.length && typeof args[args.length - 1] === "object" && !Array.isArray(args[args.length - 1])) {
366
+ options = args.pop();
367
+ }
368
+ const result = await gitResult(repoRoot, args, options);
369
+ return result.stdout;
370
+ }
371
+
372
+ async function gitResult(repoRoot, args, options = {}) {
373
+ try {
374
+ const { stdout, stderr } = await execFileAsync("git", args, {
375
+ cwd: repoRoot,
376
+ timeout: GIT_TIMEOUT_MS,
377
+ env: options.env || process.env,
378
+ });
379
+ return { code: 0, stdout, stderr };
380
+ } catch (err) {
381
+ if (options.allowNonZeroExit) {
382
+ return {
383
+ code: typeof err.code === "number" ? err.code : 1,
384
+ stdout: err.stdout || "",
385
+ stderr: err.stderr || err.message || "",
386
+ };
387
+ }
388
+ const message = (err.stderr || err.message || "git command failed").trim();
389
+ throw workspaceCheckpointError("git_failed", message);
390
+ }
391
+ }
392
+
393
+ function workspaceCheckpointError(errorCode, userMessage) {
394
+ const err = new Error(userMessage);
395
+ err.errorCode = errorCode;
396
+ err.userMessage = userMessage;
397
+ return err;
398
+ }
399
+
400
+ module.exports = {
401
+ workspaceCheckpointCapture,
402
+ workspaceCheckpointCopy,
403
+ workspaceCheckpointDiff,
404
+ workspaceCheckpointRestorePreview,
405
+ workspaceCheckpointRestoreApply,
406
+ };
@@ -11,6 +11,13 @@ const path = require("path");
11
11
  const { promisify } = require("util");
12
12
  const { resolveCodexGeneratedImagesRoot } = require("./codex-home");
13
13
  const { gitStatus } = require("./git-handler");
14
+ const {
15
+ workspaceCheckpointCapture,
16
+ workspaceCheckpointCopy,
17
+ workspaceCheckpointDiff,
18
+ workspaceCheckpointRestoreApply,
19
+ workspaceCheckpointRestorePreview,
20
+ } = require("./workspace-checkpoints");
14
21
 
15
22
  const execFileAsync = promisify(execFile);
16
23
  const GIT_TIMEOUT_MS = 30_000;
@@ -18,6 +25,9 @@ const MAX_IMAGE_READ_BYTES = 8 * 1024 * 1024;
18
25
  const MAX_IMAGE_PREVIEW_READ_BYTES = 2 * 1024 * 1024;
19
26
  const MIN_IMAGE_PREVIEW_PIXEL_DIMENSION = 128;
20
27
  const MAX_IMAGE_PREVIEW_PIXEL_DIMENSION = 3_200;
28
+ const IMAGE_PREVIEW_RETRY_SCALE = 0.75;
29
+ const IMAGE_PREVIEW_TOOL_TIMEOUT_MS = 5_000;
30
+ const IMAGE_PREVIEW_TOTAL_TIMEOUT_MS = 15_000;
21
31
  const IMAGE_MIME_TYPES_BY_EXTENSION = new Map([
22
32
  [".jpg", "image/jpeg"],
23
33
  [".jpeg", "image/jpeg"],
@@ -76,6 +86,16 @@ async function handleWorkspaceMethod(method, params) {
76
86
  const repoRoot = await resolveRepoRoot(cwd);
77
87
 
78
88
  switch (method) {
89
+ case "workspace/checkpointCapture":
90
+ return withRepoMutationLock(repoRoot, () => workspaceCheckpointCapture(repoRoot, params));
91
+ case "workspace/checkpointCopy":
92
+ return withRepoMutationLock(repoRoot, () => workspaceCheckpointCopy(repoRoot, params));
93
+ case "workspace/checkpointDiff":
94
+ return workspaceCheckpointDiff(repoRoot, params);
95
+ case "workspace/checkpointRestorePreview":
96
+ return workspaceCheckpointRestorePreview(repoRoot, params);
97
+ case "workspace/checkpointRestoreApply":
98
+ return withRepoMutationLock(repoRoot, () => workspaceCheckpointRestoreApply(repoRoot, params));
79
99
  case "workspace/revertPatchPreview":
80
100
  return workspaceRevertPatchPreview(repoRoot, params);
81
101
  case "workspace/revertPatchApply":
@@ -85,7 +105,7 @@ async function handleWorkspaceMethod(method, params) {
85
105
  }
86
106
  }
87
107
 
88
- // Reads only recognized local image files, and only from the bound repo or Codex generated-images cache.
108
+ // Reads recognized local image files from the bound repo, Codex image cache, or host temp screenshot folders.
89
109
  async function workspaceReadImage(params) {
90
110
  const requestedPath = firstNonEmptyString([params.path, params.filePath, params.localPath]);
91
111
  if (!requestedPath) {
@@ -112,13 +132,16 @@ async function workspaceReadImage(params) {
112
132
  throw workspaceError("image_not_found", "The image file no longer exists on this Mac.");
113
133
  }
114
134
 
115
- const repoRoot = cwd ? await resolveRepoRoot(cwd).catch(() => null) : null;
116
- const realRepoRoot = repoRoot ? await realpathOrNull(repoRoot) : null;
135
+ const [realRepoRoot, realTempRoots] = await Promise.all([
136
+ cwd ? resolveRepoRoot(cwd).then(realpathOrNull).catch(() => null) : null,
137
+ realTemporaryImageRoots(),
138
+ ]);
117
139
  const isAllowed =
118
140
  (realRepoRoot && isPathInside(realImagePath, realRepoRoot))
119
- || (realGeneratedImagesRoot && isPathInside(realImagePath, realGeneratedImagesRoot));
141
+ || (realGeneratedImagesRoot && isPathInside(realImagePath, realGeneratedImagesRoot))
142
+ || realTempRoots.some((tempRoot) => isPathInside(realImagePath, tempRoot));
120
143
  if (!isAllowed) {
121
- throw workspaceError("image_path_not_allowed", "Only images in this workspace or Codex generated images can be previewed.");
144
+ throw workspaceError("image_path_not_allowed", "Only images in this workspace, Codex generated images, or temporary screenshot files can be previewed.");
122
145
  }
123
146
 
124
147
  const stat = await fs.promises.stat(realImagePath);
@@ -153,7 +176,7 @@ async function workspaceReadImage(params) {
153
176
  }
154
177
 
155
178
  const data = maxPixelDimension
156
- ? await readPreviewImageData(realImagePath, maxPixelDimension)
179
+ ? await readPreviewImageData(realImagePath, maxPixelDimension, stat.size)
157
180
  : await fs.promises.readFile(realImagePath);
158
181
  return {
159
182
  ...result,
@@ -173,31 +196,113 @@ function normalizedPreviewPixelDimension(params) {
173
196
  );
174
197
  }
175
198
 
176
- async function readPreviewImageData(imagePath, maxPixelDimension) {
177
- let previewData;
178
- try {
179
- previewData = await downsampleImageWithSips(imagePath, maxPixelDimension);
180
- } catch {
199
+ async function realTemporaryImageRoots() {
200
+ const candidates = [
201
+ os.tmpdir(),
202
+ process.env.TMPDIR,
203
+ ];
204
+
205
+ if (process.platform === "darwin") {
206
+ candidates.push("/tmp");
207
+ }
208
+
209
+ const roots = await Promise.all(
210
+ Array.from(new Set(candidates.filter(Boolean))).map((candidate) => realpathOrNull(candidate))
211
+ );
212
+ return Array.from(new Set(roots.filter(Boolean)));
213
+ }
214
+
215
+ async function readPreviewImageData(imagePath, maxPixelDimension, originalByteLength) {
216
+ if (!usesSipsImagePreview()) {
217
+ if (originalByteLength <= MAX_IMAGE_PREVIEW_READ_BYTES) {
218
+ return fs.promises.readFile(imagePath);
219
+ }
220
+
221
+ throw workspaceError(
222
+ "image_preview_unsupported_platform",
223
+ "This computer cannot resize image previews yet. Try a smaller image or open it on the computer."
224
+ );
225
+ }
226
+
227
+ let sawConversionFailure = false;
228
+ const previewDeadline = Date.now() + IMAGE_PREVIEW_TOTAL_TIMEOUT_MS;
229
+ for (const candidateDimension of previewPixelDimensionCandidates(maxPixelDimension)) {
230
+ const remainingTimeoutMs = previewDeadline - Date.now();
231
+ if (remainingTimeoutMs <= 0) {
232
+ throw workspaceError(
233
+ "image_preview_timed_out",
234
+ "This image preview took too long to resize. Try a smaller image or open it on the computer."
235
+ );
236
+ }
237
+
238
+ try {
239
+ const previewData = await downsampleImageWithSips(
240
+ imagePath,
241
+ candidateDimension,
242
+ Math.min(IMAGE_PREVIEW_TOOL_TIMEOUT_MS, remainingTimeoutMs)
243
+ );
244
+ if (previewData && previewData.length > 0 && previewData.length <= MAX_IMAGE_PREVIEW_READ_BYTES) {
245
+ return previewData;
246
+ }
247
+ } catch (err) {
248
+ if (isImagePreviewTimeoutError(err)) {
249
+ throw workspaceError(
250
+ "image_preview_timed_out",
251
+ "This image preview took too long to resize. Try a smaller image or open it on the computer."
252
+ );
253
+ }
254
+ sawConversionFailure = true;
255
+ }
256
+ }
257
+
258
+ if (sawConversionFailure) {
181
259
  throw workspaceError(
182
260
  "image_preview_failed",
183
261
  "This image could not be converted into a lightweight phone preview."
184
262
  );
185
263
  }
186
- if (!previewData || previewData.length === 0 || previewData.length > MAX_IMAGE_PREVIEW_READ_BYTES) {
187
- throw workspaceError(
188
- "image_preview_too_large",
189
- "This image preview is still too large to send to the phone."
264
+
265
+ throw workspaceError(
266
+ "image_preview_too_large",
267
+ "This image preview is still too large to send to the phone."
268
+ );
269
+ }
270
+
271
+ function previewPixelDimensionCandidates(maxPixelDimension) {
272
+ const dimensions = [];
273
+ let next = maxPixelDimension;
274
+ while (next >= MIN_IMAGE_PREVIEW_PIXEL_DIMENSION) {
275
+ dimensions.push(next);
276
+ if (next === MIN_IMAGE_PREVIEW_PIXEL_DIMENSION) {
277
+ break;
278
+ }
279
+ next = Math.max(
280
+ MIN_IMAGE_PREVIEW_PIXEL_DIMENSION,
281
+ Math.floor(next * IMAGE_PREVIEW_RETRY_SCALE)
190
282
  );
191
283
  }
192
- return previewData;
284
+
285
+ // Hard-to-compress previews can stay oversized after one resize; these checkpoints keep retry behavior predictable.
286
+ for (const checkpoint of [1024, 768, 512, 384, 256, MIN_IMAGE_PREVIEW_PIXEL_DIMENSION]) {
287
+ if (checkpoint <= maxPixelDimension) {
288
+ dimensions.push(checkpoint);
289
+ }
290
+ }
291
+
292
+ return Array.from(new Set(dimensions)).sort((a, b) => b - a);
293
+ }
294
+
295
+ function usesSipsImagePreview() {
296
+ const normalizedPlatform = String(process.platform || "").trim().toLowerCase();
297
+ return normalizedPlatform === "darwin" || normalizedPlatform === "macos" || normalizedPlatform === "mac";
193
298
  }
194
299
 
195
- async function downsampleImageWithSips(imagePath, maxPixelDimension) {
300
+ async function downsampleImageWithSips(imagePath, maxPixelDimension, timeoutMs = IMAGE_PREVIEW_TOOL_TIMEOUT_MS) {
196
301
  const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "remodex-image-preview-"));
197
302
  const outputPath = path.join(tempDir, `preview${path.extname(imagePath) || ".png"}`);
198
303
  try {
199
304
  await execFileAsync("sips", ["-Z", String(maxPixelDimension), imagePath, "--out", outputPath], {
200
- timeout: 15_000,
305
+ timeout: Math.max(1, Math.floor(timeoutMs)),
201
306
  maxBuffer: 1024 * 1024,
202
307
  });
203
308
  return await fs.promises.readFile(outputPath);
@@ -206,6 +311,12 @@ async function downsampleImageWithSips(imagePath, maxPixelDimension) {
206
311
  }
207
312
  }
208
313
 
314
+ function isImagePreviewTimeoutError(err) {
315
+ return err?.code === "ETIMEDOUT"
316
+ || (err?.killed === true && err?.signal === "SIGTERM")
317
+ || /timed out|timeout/i.test(String(err?.message || ""));
318
+ }
319
+
209
320
  function isUnchangedImageRead(params, stat, maxPixelDimension) {
210
321
  const cachedByteLength = Number(params.ifByteLength);
211
322
  const cachedMtimeMs = Number(params.ifMtimeMs);