claude-remote 0.4.3 → 0.4.5

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/server.js +220 -168
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Remote control bridge for Claude Code REPL - drive from phone/WebUI",
5
5
  "main": "server.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -5,7 +5,7 @@ const os = require('os');
5
5
  const pty = require('node-pty');
6
6
  const { WebSocketServer, WebSocket } = require('ws');
7
7
  const crypto = require('crypto');
8
- const { execSync, spawn } = require('child_process');
8
+ const { execSync, spawn } = require('child_process');
9
9
 
10
10
  // --- CLI argument parsing ---
11
11
  // Separate bridge args (CWD positional) from claude passthrough flags.
@@ -158,12 +158,12 @@ let CWD = _parsedCwd;
158
158
  const CLAUDE_HOME = path.join(os.homedir(), '.claude');
159
159
  const CLAUDE_STATE_FILE = path.join(os.homedir(), '.claude.json');
160
160
  const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
161
- const AUTH_HELLO_TIMEOUT_MS = 5000;
162
- const WS_CLOSE_AUTH_FAILED = 4001;
163
- const WS_CLOSE_AUTH_TIMEOUT = 4002;
164
- const WS_CLOSE_REASON_AUTH_FAILED = 'auth_failed';
165
- const WS_CLOSE_REASON_AUTH_TIMEOUT = 'auth_timeout';
166
- const DEBUG_TTY_INPUT = process.env.CLAUDE_REMOTE_DEBUG_TTY_INPUT === '1';
161
+ const AUTH_HELLO_TIMEOUT_MS = 5000;
162
+ const WS_CLOSE_AUTH_FAILED = 4001;
163
+ const WS_CLOSE_AUTH_TIMEOUT = 4002;
164
+ const WS_CLOSE_REASON_AUTH_FAILED = 'auth_failed';
165
+ const WS_CLOSE_REASON_AUTH_TIMEOUT = 'auth_timeout';
166
+ const DEBUG_TTY_INPUT = process.env.CLAUDE_REMOTE_DEBUG_TTY_INPUT === '1';
167
167
 
168
168
  // --- State ---
169
169
  let claudeProc = null;
@@ -197,6 +197,7 @@ let turnState = {
197
197
  let ttyInputForwarderAttached = false;
198
198
  let ttyInputHandler = null;
199
199
  let ttyResizeHandler = null;
200
+ let activeLinuxClipboardProc = null;
200
201
 
201
202
  // --- Permission approval state ---
202
203
  let approvalSeq = 0;
@@ -209,15 +210,27 @@ const PARTIAL_AUTO_ALLOW = new Set(['Read', 'Glob', 'Grep', 'Write', 'Edit']);
209
210
  // --- Logging → file only (never pollute the terminal) ---
210
211
  const LOG_FILE = path.join(os.homedir(), '.claude', 'bridge.log');
211
212
  fs.writeFileSync(LOG_FILE, `--- Bridge started ${new Date().toISOString()} ---\n`);
212
- function log(msg) {
213
- const line = `[${new Date().toISOString()}] ${msg}\n`;
214
- fs.appendFileSync(LOG_FILE, line);
215
- }
216
-
217
- function formatTtyInputChunk(chunk) {
218
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
219
- return `len=${buf.length} hex=${buf.toString('hex')} base64=${buf.toString('base64')} utf8=${JSON.stringify(buf.toString('utf8'))}`;
220
- }
213
+ function log(msg) {
214
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
215
+ fs.appendFileSync(LOG_FILE, line);
216
+ }
217
+
218
+ function formatTtyInputChunk(chunk) {
219
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
220
+ return `len=${buf.length} hex=${buf.toString('hex')} base64=${buf.toString('base64')} utf8=${JSON.stringify(buf.toString('utf8'))}`;
221
+ }
222
+
223
+ function clearActiveLinuxClipboardProc(reason = '') {
224
+ if (!activeLinuxClipboardProc) return;
225
+ const { child, tool } = activeLinuxClipboardProc;
226
+ activeLinuxClipboardProc = null;
227
+ try {
228
+ child.kill('SIGTERM');
229
+ log(`Linux clipboard process terminated (${tool}) reason=${reason || 'cleanup'}`);
230
+ } catch (err) {
231
+ log(`Linux clipboard process terminate error (${tool}): ${err.message}`);
232
+ }
233
+ }
221
234
 
222
235
  function wsLabel(ws) {
223
236
  const clientId = ws && ws._clientInstanceId ? ` client=${ws._clientInstanceId}` : '';
@@ -250,6 +263,7 @@ function getTurnStatePayload() {
250
263
  sessionId: turnState.sessionId,
251
264
  version: turnState.version,
252
265
  updatedAt: turnState.updatedAt,
266
+ reason: turnState.reason || '',
253
267
  };
254
268
  }
255
269
 
@@ -271,6 +285,7 @@ function setTurnState(phase, { sessionId = currentSessionId, reason = '', force
271
285
  sessionId: normalizedSessionId,
272
286
  version: ++turnStateVersion,
273
287
  updatedAt: Date.now(),
288
+ reason,
274
289
  };
275
290
 
276
291
  log(`Turn state -> phase=${turnState.phase} session=${turnState.sessionId ?? 'null'} version=${turnState.version}${reason ? ` reason=${reason}` : ''}`);
@@ -278,19 +293,41 @@ function setTurnState(phase, { sessionId = currentSessionId, reason = '', force
278
293
  return true;
279
294
  }
280
295
 
296
+ function emitInterrupt(source) {
297
+ const interruptEvent = {
298
+ type: 'interrupt',
299
+ source,
300
+ timestamp: Date.now(),
301
+ uuid: crypto.randomUUID(),
302
+ };
303
+ const record = { seq: ++eventSeq, event: interruptEvent };
304
+ eventBuffer.push(record);
305
+ if (eventBuffer.length > EVENT_BUFFER_MAX) {
306
+ eventBuffer = eventBuffer.slice(-Math.round(EVENT_BUFFER_MAX * 0.8));
307
+ }
308
+ broadcast({ type: 'log_event', seq: record.seq, event: interruptEvent });
309
+ setTurnState('idle', { reason: `${source}_interrupt` });
310
+ }
311
+
281
312
  function attachTtyForwarders() {
282
- if (!isTTY || ttyInputForwarderAttached) return;
283
-
284
- ttyInputHandler = (chunk) => {
285
- if (DEBUG_TTY_INPUT) {
286
- try {
287
- log(`TTY input ${formatTtyInputChunk(chunk)}`);
288
- } catch (err) {
289
- log(`TTY input log error: ${err.message}`);
290
- }
291
- }
292
- if (claudeProc) claudeProc.write(chunk);
293
- };
313
+ if (!isTTY || ttyInputForwarderAttached) return;
314
+
315
+ ttyInputHandler = (chunk) => {
316
+ if (DEBUG_TTY_INPUT) {
317
+ try {
318
+ log(`TTY input ${formatTtyInputChunk(chunk)}`);
319
+ } catch (err) {
320
+ log(`TTY input log error: ${err.message}`);
321
+ }
322
+ }
323
+ if (claudeProc) claudeProc.write(chunk);
324
+ // Detect Ctrl+C (0x03) from local terminal and sync state
325
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
326
+ if (buf.includes(0x03) && turnState.phase === 'running') {
327
+ log('Terminal Ctrl+C detected — injecting interrupt event');
328
+ emitInterrupt('terminal');
329
+ }
330
+ };
294
331
  ttyResizeHandler = () => {
295
332
  if (claudeProc) claudeProc.resize(process.stdout.columns, process.stdout.rows);
296
333
  };
@@ -644,77 +681,80 @@ function cleanupClientUploads(ws) {
644
681
  }
645
682
  }
646
683
 
647
- function createTempImageFile(buffer, mediaType, uploadId) {
648
- const isLinux = process.platform !== 'win32' && process.platform !== 'darwin';
649
- const tmpDir = isLinux
650
- ? IMAGE_UPLOAD_DIR
651
- : (process.env.CLAUDE_CODE_TMPDIR || os.tmpdir());
684
+ function createTempImageFile(buffer, mediaType, uploadId) {
685
+ const isLinux = process.platform !== 'win32' && process.platform !== 'darwin';
686
+ const tmpDir = isLinux
687
+ ? IMAGE_UPLOAD_DIR
688
+ : (process.env.CLAUDE_CODE_TMPDIR || os.tmpdir());
652
689
  const type = String(mediaType || 'image/png').toLowerCase();
653
690
  const ext = type.includes('jpeg') || type.includes('jpg') ? '.jpg' : '.png';
654
691
  fs.mkdirSync(tmpDir, { recursive: true });
655
692
  const tmpFile = path.join(tmpDir, `bridge_upload_${uploadId}_${Date.now()}${ext}`);
656
- fs.writeFileSync(tmpFile, buffer);
657
- return tmpFile;
658
- }
659
-
660
- function getLinuxClipboardTool() {
661
- if (process.platform === 'win32' || process.platform === 'darwin') return null;
662
- try {
663
- execSync('command -v xclip >/dev/null 2>&1', {
664
- stdio: 'ignore',
665
- shell: '/bin/sh',
666
- timeout: 2000,
667
- });
668
- return 'xclip';
669
- } catch {}
670
- try {
671
- execSync('command -v wl-copy >/dev/null 2>&1', {
672
- stdio: 'ignore',
673
- shell: '/bin/sh',
674
- timeout: 2000,
675
- });
676
- return 'wl-copy';
677
- } catch {}
678
- return null;
679
- }
680
-
681
- function assertLinuxClipboardAvailable() {
682
- const tool = getLinuxClipboardTool();
683
- if (tool) return tool;
684
- throw new Error('Linux image paste requires xclip or wl-copy on the server. Install one and try again.');
685
- }
686
-
687
- function startLinuxClipboardImage(tmpFile, mediaType) {
688
- const type = String(mediaType || 'image/png').toLowerCase();
689
- const tool = assertLinuxClipboardAvailable();
690
- const imageBuffer = fs.readFileSync(tmpFile);
691
- const args = tool === 'xclip'
692
- ? ['-selection', 'clipboard', '-t', type, '-i', '-loops', '1']
693
- : ['--type', type, '--paste-once'];
694
- const child = spawn(tool, args, {
695
- detached: true,
696
- stdio: ['pipe', 'ignore', 'pipe'],
697
- });
698
- let stderr = '';
699
- child.on('error', (err) => {
700
- log(`Linux clipboard process error (${tool}): ${err.message}`);
701
- });
702
- child.stderr.on('data', (chunk) => {
703
- stderr += chunk.toString('utf8');
704
- if (stderr.length > 2000) stderr = stderr.slice(-2000);
705
- });
706
- child.on('exit', (code, signal) => {
707
- const extra = stderr.trim() ? ` stderr=${JSON.stringify(stderr.trim())}` : '';
708
- log(`Linux clipboard process exited (${tool}) code=${code ?? 'null'} signal=${signal ?? 'null'}${extra}`);
709
- });
710
- child.stdin.on('error', (err) => {
711
- if (err.code !== 'EPIPE') log(`Linux clipboard stdin error (${tool}): ${err.message}`);
712
- });
713
- child.stdin.end(imageBuffer);
714
- child.unref();
715
- log(`Linux clipboard process started (${tool}) pid=${child.pid ?? 'null'} type=${type} bytes=${imageBuffer.length}`);
716
- return tool;
717
- }
693
+ fs.writeFileSync(tmpFile, buffer);
694
+ return tmpFile;
695
+ }
696
+
697
+ function getLinuxClipboardTool() {
698
+ if (process.platform === 'win32' || process.platform === 'darwin') return null;
699
+ try {
700
+ execSync('command -v xclip >/dev/null 2>&1', {
701
+ stdio: 'ignore',
702
+ shell: '/bin/sh',
703
+ timeout: 2000,
704
+ });
705
+ return 'xclip';
706
+ } catch {}
707
+ try {
708
+ execSync('command -v wl-copy >/dev/null 2>&1', {
709
+ stdio: 'ignore',
710
+ shell: '/bin/sh',
711
+ timeout: 2000,
712
+ });
713
+ return 'wl-copy';
714
+ } catch {}
715
+ return null;
716
+ }
717
+
718
+ function assertLinuxClipboardAvailable() {
719
+ const tool = getLinuxClipboardTool();
720
+ if (tool) return tool;
721
+ throw new Error('Linux image paste requires xclip or wl-copy on the server. Install one and try again.');
722
+ }
723
+
724
+ function startLinuxClipboardImage(tmpFile, mediaType) {
725
+ const type = String(mediaType || 'image/png').toLowerCase();
726
+ const tool = assertLinuxClipboardAvailable();
727
+ const imageBuffer = fs.readFileSync(tmpFile);
728
+ clearActiveLinuxClipboardProc('replace');
729
+ const args = tool === 'xclip'
730
+ ? ['-selection', 'clipboard', '-t', type, '-i']
731
+ : ['--type', type];
732
+ const child = spawn(tool, args, {
733
+ detached: true,
734
+ stdio: ['pipe', 'ignore', 'pipe'],
735
+ });
736
+ activeLinuxClipboardProc = { child, tool };
737
+ let stderr = '';
738
+ child.on('error', (err) => {
739
+ log(`Linux clipboard process error (${tool}): ${err.message}`);
740
+ });
741
+ child.stderr.on('data', (chunk) => {
742
+ stderr += chunk.toString('utf8');
743
+ if (stderr.length > 2000) stderr = stderr.slice(-2000);
744
+ });
745
+ child.on('exit', (code, signal) => {
746
+ if (activeLinuxClipboardProc && activeLinuxClipboardProc.child === child) activeLinuxClipboardProc = null;
747
+ const extra = stderr.trim() ? ` stderr=${JSON.stringify(stderr.trim())}` : '';
748
+ log(`Linux clipboard process exited (${tool}) code=${code ?? 'null'} signal=${signal ?? 'null'}${extra}`);
749
+ });
750
+ child.stdin.on('error', (err) => {
751
+ if (err.code !== 'EPIPE') log(`Linux clipboard stdin error (${tool}): ${err.message}`);
752
+ });
753
+ child.stdin.end(imageBuffer);
754
+ child.unref();
755
+ log(`Linux clipboard process started (${tool}) pid=${child.pid ?? 'null'} type=${type} bytes=${imageBuffer.length}`);
756
+ return tool;
757
+ }
718
758
 
719
759
  setInterval(() => {
720
760
  const now = Date.now();
@@ -883,6 +923,14 @@ wss.on('connection', (ws, req) => {
883
923
  // Raw terminal keystrokes from xterm.js in WebUI
884
924
  if (claudeProc) claudeProc.write(msg.data);
885
925
  break;
926
+ case 'interrupt': {
927
+ // User pressed stop button in app — send Ctrl+C to PTY
928
+ if (!claudeProc || turnState.phase !== 'running') break;
929
+ log(`Interrupt from ${wsLabel(ws)} — sending Ctrl+C to PTY`);
930
+ claudeProc.write('\x03');
931
+ emitInterrupt('app');
932
+ break;
933
+ }
886
934
  case 'expect_clear':
887
935
  // Plan mode option 1 triggers /clear inside Claude Code;
888
936
  // client notifies us so we can detect the session switch.
@@ -950,25 +998,25 @@ wss.on('connection', (ws, req) => {
950
998
  }
951
999
  break;
952
1000
  }
953
- case 'image_upload_init': {
954
- const uploadId = String(msg.uploadId || '');
955
- if (!uploadId) {
956
- sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
957
- break;
958
- }
959
- cleanupImageUpload(uploadId);
960
- if (process.platform !== 'win32' && process.platform !== 'darwin') {
961
- try {
962
- const tool = assertLinuxClipboardAvailable();
963
- log(`Linux clipboard preflight OK: ${tool}`);
964
- } catch (err) {
965
- sendUploadStatus(ws, uploadId, 'error', { message: err.message });
966
- break;
967
- }
968
- }
969
- pendingImageUploads.set(uploadId, {
970
- id: uploadId,
971
- owner: ws,
1001
+ case 'image_upload_init': {
1002
+ const uploadId = String(msg.uploadId || '');
1003
+ if (!uploadId) {
1004
+ sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
1005
+ break;
1006
+ }
1007
+ cleanupImageUpload(uploadId);
1008
+ if (process.platform !== 'win32' && process.platform !== 'darwin') {
1009
+ try {
1010
+ const tool = assertLinuxClipboardAvailable();
1011
+ log(`Linux clipboard preflight OK: ${tool}`);
1012
+ } catch (err) {
1013
+ sendUploadStatus(ws, uploadId, 'error', { message: err.message });
1014
+ break;
1015
+ }
1016
+ }
1017
+ pendingImageUploads.set(uploadId, {
1018
+ id: uploadId,
1019
+ owner: ws,
972
1020
  mediaType: msg.mediaType || 'image/png',
973
1021
  name: msg.name || 'image',
974
1022
  totalBytes: Number.isFinite(msg.totalBytes) ? msg.totalBytes : 0,
@@ -1066,15 +1114,15 @@ wss.on('connection', (ws, req) => {
1066
1114
  if (!upload || !upload.tmpFile) {
1067
1115
  sendUploadStatus(ws, uploadId, 'error', { message: 'Upload not ready' });
1068
1116
  break;
1069
- }
1070
- try {
1071
- handlePreparedImageUpload({
1072
- tmpFile: upload.tmpFile,
1073
- mediaType: upload.mediaType,
1074
- text: msg.text || '',
1075
- logLabel: upload.name || uploadId,
1076
- onCleanup: () => cleanupImageUpload(uploadId),
1077
- });
1117
+ }
1118
+ try {
1119
+ handlePreparedImageUpload({
1120
+ tmpFile: upload.tmpFile,
1121
+ mediaType: upload.mediaType,
1122
+ text: msg.text || '',
1123
+ logLabel: upload.name || uploadId,
1124
+ onCleanup: () => cleanupImageUpload(uploadId),
1125
+ });
1078
1126
  upload.submitted = true;
1079
1127
  upload.updatedAt = Date.now();
1080
1128
  setTurnState('running', { reason: 'image_submit' });
@@ -1707,7 +1755,7 @@ function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', on
1707
1755
 
1708
1756
  const isWin = process.platform === 'win32';
1709
1757
  const isMac = process.platform === 'darwin';
1710
- try {
1758
+ try {
1711
1759
  const stat = fs.statSync(tmpFile);
1712
1760
  log(`Image ready: ${logLabel || path.basename(tmpFile)} (${stat.size} bytes)`);
1713
1761
 
@@ -1716,37 +1764,41 @@ function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', on
1716
1764
  execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
1717
1765
  } else if (isMac) {
1718
1766
  execSync(`osascript -e 'set the clipboard to (read POSIX file "${tmpFile}" as 芦class PNGf禄)'`, { timeout: 10000 });
1719
- } else {
1720
- const tool = startLinuxClipboardImage(tmpFile, mediaType);
1721
- log(`Linux clipboard armed with ${tool}`);
1722
- }
1767
+ } else {
1768
+ const tool = startLinuxClipboardImage(tmpFile, mediaType);
1769
+ log(`Linux clipboard armed with ${tool}`);
1770
+ }
1723
1771
  log('Clipboard set with image');
1724
1772
 
1725
- const pasteDelayMs = isWin || isMac ? 0 : 150;
1726
- setTimeout(() => {
1727
- if (!claudeProc) return;
1728
- if (isWin) claudeProc.write('\x1bv');
1729
- else claudeProc.write('\x16');
1730
- log('Sent image paste keypress to PTY');
1731
- }, pasteDelayMs);
1732
-
1733
- setTimeout(() => {
1734
- if (!claudeProc) return;
1735
- const trimmedText = (text || '').trim();
1736
- if (trimmedText) claudeProc.write(trimmedText);
1773
+ const pasteDelayMs = isWin || isMac ? 0 : 150;
1774
+ setTimeout(() => {
1775
+ if (!claudeProc) return;
1776
+ if (isWin) claudeProc.write('\x1bv');
1777
+ else claudeProc.write('\x16');
1778
+ log('Sent image paste keypress to PTY');
1779
+ }, pasteDelayMs);
1780
+
1781
+ setTimeout(() => {
1782
+ if (!claudeProc) return;
1783
+ const trimmedText = (text || '').trim();
1784
+ if (trimmedText) claudeProc.write(trimmedText);
1737
1785
 
1738
1786
  setTimeout(() => {
1739
1787
  if (claudeProc) claudeProc.write('\r');
1740
1788
  log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
1741
1789
 
1790
+ if (!isWin && !isMac) {
1791
+ setTimeout(() => clearActiveLinuxClipboardProc('post-paste'), 1000);
1792
+ }
1793
+
1742
1794
  setTimeout(() => {
1743
1795
  if (onCleanup) onCleanup();
1744
1796
  else {
1745
1797
  try { fs.unlinkSync(tmpFile); } catch {}
1746
1798
  }
1747
1799
  }, 5000);
1748
- }, 150);
1749
- }, 1000 + pasteDelayMs);
1800
+ }, 150);
1801
+ }, 1000 + pasteDelayMs);
1750
1802
  } catch (err) {
1751
1803
  log(`Image upload error: ${err.message}`);
1752
1804
  if (onCleanup) onCleanup();
@@ -1757,32 +1809,32 @@ function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', on
1757
1809
  }
1758
1810
  }
1759
1811
 
1760
- function handleImageUpload(msg) {
1761
- if (!claudeProc) {
1762
- log('Image upload ignored: Claude not running');
1763
- return;
1764
- }
1765
- if (!msg.base64) {
1766
- log('Image upload ignored: no base64 data');
1767
- return;
1768
- }
1769
- let tmpFile = null;
1770
-
1771
- try {
1772
- if (process.platform !== 'win32' && process.platform !== 'darwin') {
1773
- const tool = assertLinuxClipboardAvailable();
1774
- log(`Linux clipboard preflight OK (legacy upload): ${tool}`);
1775
- }
1776
- const buf = Buffer.from(msg.base64, 'base64');
1777
- tmpFile = createTempImageFile(buf, msg.mediaType, `legacy_${Date.now()}`);
1778
- log(`Image saved: ${tmpFile} (${buf.length} bytes)`);
1779
- handlePreparedImageUpload({
1780
- tmpFile,
1781
- mediaType: msg.mediaType,
1782
- text: msg.text || '',
1783
- });
1784
- setTurnState('running', { reason: 'legacy_image_upload' });
1785
- } catch (err) {
1812
+ function handleImageUpload(msg) {
1813
+ if (!claudeProc) {
1814
+ log('Image upload ignored: Claude not running');
1815
+ return;
1816
+ }
1817
+ if (!msg.base64) {
1818
+ log('Image upload ignored: no base64 data');
1819
+ return;
1820
+ }
1821
+ let tmpFile = null;
1822
+
1823
+ try {
1824
+ if (process.platform !== 'win32' && process.platform !== 'darwin') {
1825
+ const tool = assertLinuxClipboardAvailable();
1826
+ log(`Linux clipboard preflight OK (legacy upload): ${tool}`);
1827
+ }
1828
+ const buf = Buffer.from(msg.base64, 'base64');
1829
+ tmpFile = createTempImageFile(buf, msg.mediaType, `legacy_${Date.now()}`);
1830
+ log(`Image saved: ${tmpFile} (${buf.length} bytes)`);
1831
+ handlePreparedImageUpload({
1832
+ tmpFile,
1833
+ mediaType: msg.mediaType,
1834
+ text: msg.text || '',
1835
+ });
1836
+ setTurnState('running', { reason: 'legacy_image_upload' });
1837
+ } catch (err) {
1786
1838
  log(`Image upload error: ${err.message}`);
1787
1839
  try { fs.unlinkSync(tmpFile); } catch {}
1788
1840
  }