claude-remote 0.2.2 → 0.2.4

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 +340 -32
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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
@@ -103,8 +103,9 @@ const { cwd: _parsedCwd, claudeArgs: CLAUDE_EXTRA_ARGS, blocked: _blockedArgs }
103
103
 
104
104
  // --- Config ---
105
105
  const PORT = parseInt(process.env.PORT || '3100', 10);
106
- const CWD = _parsedCwd;
106
+ let CWD = _parsedCwd;
107
107
  const CLAUDE_HOME = path.join(os.homedir(), '.claude');
108
+ const CLAUDE_STATE_FILE = path.join(os.homedir(), '.claude.json');
108
109
  const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
109
110
 
110
111
  // --- State ---
@@ -128,6 +129,16 @@ let tailCatchingUp = false; // true while reading historical transcript content
128
129
  const isTTY = process.stdin.isTTY && process.stdout.isTTY;
129
130
  const LEGACY_REPLAY_DELAY_MS = 1500;
130
131
  const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
132
+ let turnStateVersion = 0;
133
+ let turnState = {
134
+ phase: 'idle',
135
+ sessionId: null,
136
+ version: 0,
137
+ updatedAt: Date.now(),
138
+ };
139
+ let ttyInputForwarderAttached = false;
140
+ let ttyInputHandler = null;
141
+ let ttyResizeHandler = null;
131
142
 
132
143
  // --- Permission approval state ---
133
144
  let approvalSeq = 0;
@@ -153,16 +164,70 @@ function wsLabel(ws) {
153
164
  function sendWs(ws, msg, context = '') {
154
165
  if (!ws || ws.readyState !== WebSocket.OPEN) return false;
155
166
  ws.send(JSON.stringify(msg));
156
- if (msg.type === 'status' || msg.type === 'transcript_ready' || msg.type === 'replay_done') {
167
+ if (msg.type === 'status' || msg.type === 'transcript_ready' || msg.type === 'replay_done' || msg.type === 'turn_state') {
157
168
  const extra = [];
158
169
  if (msg.sessionId !== undefined) extra.push(`session=${msg.sessionId ?? 'null'}`);
159
170
  if (msg.lastSeq !== undefined) extra.push(`lastSeq=${msg.lastSeq}`);
160
171
  if (msg.resumed !== undefined) extra.push(`resumed=${msg.resumed}`);
172
+ if (msg.phase !== undefined) extra.push(`phase=${msg.phase}`);
173
+ if (msg.version !== undefined) extra.push(`version=${msg.version}`);
161
174
  log(`Send ${msg.type}${context ? ` (${context})` : ''} -> ${wsLabel(ws)}${extra.length ? ` ${extra.join(' ')}` : ''}`);
162
175
  }
163
176
  return true;
164
177
  }
165
178
 
179
+ function getTurnStatePayload() {
180
+ return {
181
+ type: 'turn_state',
182
+ phase: turnState.phase,
183
+ sessionId: turnState.sessionId,
184
+ version: turnState.version,
185
+ updatedAt: turnState.updatedAt,
186
+ };
187
+ }
188
+
189
+ function sendTurnState(ws, context = '') {
190
+ return sendWs(ws, getTurnStatePayload(), context);
191
+ }
192
+
193
+ function setTurnState(phase, { sessionId = currentSessionId, reason = '', force = false } = {}) {
194
+ const normalizedPhase = phase === 'running' ? 'running' : 'idle';
195
+ const normalizedSessionId = sessionId || null;
196
+ const changed = force ||
197
+ turnState.phase !== normalizedPhase ||
198
+ turnState.sessionId !== normalizedSessionId;
199
+
200
+ if (!changed) return false;
201
+
202
+ turnState = {
203
+ phase: normalizedPhase,
204
+ sessionId: normalizedSessionId,
205
+ version: ++turnStateVersion,
206
+ updatedAt: Date.now(),
207
+ };
208
+
209
+ log(`Turn state -> phase=${turnState.phase} session=${turnState.sessionId ?? 'null'} version=${turnState.version}${reason ? ` reason=${reason}` : ''}`);
210
+ broadcast(getTurnStatePayload());
211
+ return true;
212
+ }
213
+
214
+ function attachTtyForwarders() {
215
+ if (!isTTY || ttyInputForwarderAttached) return;
216
+
217
+ ttyInputHandler = (chunk) => {
218
+ if (claudeProc) claudeProc.write(chunk);
219
+ };
220
+ ttyResizeHandler = () => {
221
+ if (claudeProc) claudeProc.resize(process.stdout.columns, process.stdout.rows);
222
+ };
223
+
224
+ process.stdin.setRawMode(true);
225
+ process.stdin.resume();
226
+ process.stdin.on('data', ttyInputHandler);
227
+ process.stdout.on('resize', ttyResizeHandler);
228
+ ttyInputForwarderAttached = true;
229
+ }
230
+
166
231
  function normalizeFsPath(value) {
167
232
  const resolved = path.resolve(String(value || ''));
168
233
  return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
@@ -393,6 +458,7 @@ const server = http.createServer((req, res) => {
393
458
  if (reason === 'clear') {
394
459
  markExpectingSwitch();
395
460
  }
461
+ setTurnState('idle', { reason: `session-end:${reason}` });
396
462
  broadcast({ type: 'session_end', reason });
397
463
  res.writeHead(200, { 'Content-Type': 'application/json' });
398
464
  res.end('{}');
@@ -409,7 +475,7 @@ const server = http.createServer((req, res) => {
409
475
  try {
410
476
  maybeAttachHookSession(JSON.parse(body), 'stop');
411
477
  } catch {}
412
- broadcast({ type: 'turn_complete' });
478
+ setTurnState('idle', { reason: 'stop-hook' });
413
479
  res.writeHead(200, { 'Content-Type': 'application/json' });
414
480
  res.end('{}');
415
481
  });
@@ -444,7 +510,7 @@ function broadcast(msg) {
444
510
  recipients.push(wsLabel(ws));
445
511
  }
446
512
  }
447
- if (msg.type === 'working_started' || msg.type === 'turn_complete' || msg.type === 'status' || msg.type === 'transcript_ready') {
513
+ if (msg.type === 'status' || msg.type === 'transcript_ready' || msg.type === 'turn_state') {
448
514
  log(`Broadcast ${msg.type} -> ${recipients.length} client(s)${recipients.length ? ` [${recipients.join(', ')}]` : ''}`);
449
515
  }
450
516
  }
@@ -476,6 +542,7 @@ function sendReplay(ws, lastSeq = null) {
476
542
  lastSeq: latestEventSeq(),
477
543
  resumed: normalizedLastSeq != null,
478
544
  }, 'sendReplay');
545
+ sendTurnState(ws, 'sendReplay');
479
546
  }
480
547
 
481
548
  function sendUploadStatus(ws, uploadId, status, extra = {}) {
@@ -553,7 +620,7 @@ wss.on('connection', (ws, req) => {
553
620
  sendReplay(ws, null);
554
621
  }, LEGACY_REPLAY_DELAY_MS);
555
622
 
556
- ws.on('message', (raw) => {
623
+ ws.on('message', async (raw) => {
557
624
  let msg;
558
625
  try { msg = JSON.parse(raw); } catch { return; }
559
626
 
@@ -580,6 +647,7 @@ wss.on('connection', (ws, req) => {
580
647
  lastSeq: 0,
581
648
  resumed: false,
582
649
  }));
650
+ sendTurnState(ws, 'resume-empty');
583
651
  break;
584
652
  }
585
653
 
@@ -620,11 +688,11 @@ wss.on('connection', (ws, req) => {
620
688
  if (slashCommand === '/clear') {
621
689
  markExpectingSwitch();
622
690
  }
623
- // Slash commands (e.g. /clear, /help, /compact) are internal CLI
691
+ // Slash commands are internal CLI control flow.
624
692
  // commands, not AI turns — the stop hook will never fire, so don't
625
- // enter the waiting state.
693
+ // They should never mutate the live turn state into running.
626
694
  if (!slashCommand) {
627
- broadcast({ type: 'working_started' });
695
+ setTurnState('running', { reason: 'chat' });
628
696
  }
629
697
  claudeProc.write(text);
630
698
  setTimeout(() => {
@@ -787,6 +855,7 @@ wss.on('connection', (ws, req) => {
787
855
  logLabel: upload.name || uploadId,
788
856
  onCleanup: () => cleanupImageUpload(uploadId),
789
857
  });
858
+ setTurnState('running', { reason: 'image_submit' });
790
859
  sendUploadStatus(ws, uploadId, 'submitted');
791
860
  } catch (err) {
792
861
  sendUploadStatus(ws, uploadId, 'error', { message: err.message });
@@ -798,6 +867,55 @@ wss.on('connection', (ws, req) => {
798
867
  handleImageUpload(msg);
799
868
  break;
800
869
  }
870
+ case 'list_sessions': {
871
+ try {
872
+ const sessions = scanSessions(CWD, 20);
873
+ sendWs(ws, { type: 'sessions', sessions });
874
+ } catch (err) {
875
+ log(`scanSessions error: ${err.message}`);
876
+ sendWs(ws, { type: 'sessions', sessions: [], error: err.message });
877
+ }
878
+ break;
879
+ }
880
+ case 'list_dirs': {
881
+ try {
882
+ const browser = listDirectories(msg.cwd || CWD);
883
+ sendWs(ws, { type: 'dir_list', ...browser });
884
+ } catch (err) {
885
+ log(`listDirectories error: ${err.message}`);
886
+ sendWs(ws, {
887
+ type: 'dir_list',
888
+ cwd: path.resolve(String(msg.cwd || CWD || '')),
889
+ parent: null,
890
+ roots: getDirectoryRoots(),
891
+ entries: [],
892
+ error: err.message,
893
+ });
894
+ }
895
+ break;
896
+ }
897
+ case 'switch_session': {
898
+ if (claudeProc && msg.sessionId) {
899
+ log(`Switch session → /resume ${msg.sessionId}`);
900
+ markExpectingSwitch();
901
+ claudeProc.write(`/resume ${msg.sessionId}`);
902
+ setTimeout(() => {
903
+ if (claudeProc) claudeProc.write('\r');
904
+ }, 150);
905
+ }
906
+ break;
907
+ }
908
+ case 'change_cwd': {
909
+ if (msg.cwd) {
910
+ try {
911
+ const targetCwd = assertDirectoryPath(msg.cwd);
912
+ restartClaude(targetCwd);
913
+ } catch (err) {
914
+ sendWs(ws, { type: 'cwd_change_error', cwd: String(msg.cwd), error: err.message });
915
+ }
916
+ }
917
+ break;
918
+ }
801
919
  }
802
920
  });
803
921
 
@@ -828,7 +946,7 @@ function spawnClaude() {
828
946
  const cols = isTTY ? process.stdout.columns : 120;
829
947
  const rows = isTTY ? process.stdout.rows : 40;
830
948
 
831
- claudeProc = pty.spawn(shell, args, {
949
+ const proc = claudeProc = pty.spawn(shell, args, {
832
950
  name: 'xterm-256color',
833
951
  cols,
834
952
  rows,
@@ -837,33 +955,33 @@ function spawnClaude() {
837
955
  });
838
956
 
839
957
  log(`Claude spawned (pid ${claudeProc.pid}) — ${cols}x${rows} cmd="${claudeCmd}"`);
840
- broadcast({ type: 'status', status: 'running', pid: claudeProc.pid });
958
+ setTurnState('idle', { sessionId: currentSessionId, reason: 'claude_spawned' });
959
+ broadcast({
960
+ type: 'status',
961
+ status: 'running',
962
+ pid: proc.pid,
963
+ cwd: CWD,
964
+ sessionId: currentSessionId,
965
+ lastSeq: latestEventSeq(),
966
+ });
841
967
 
842
968
  // === PTY output → local terminal + WebSocket + mode detection ===
843
- claudeProc.onData((data) => {
969
+ proc.onData((data) => {
844
970
  if (isTTY) process.stdout.write(data); // show in the terminal you ran the bridge from
845
971
  broadcast({ type: 'pty_output', data }); // push to WebUI
846
972
  });
847
973
 
848
974
  // === Local terminal input → PTY ===
849
- if (isTTY) {
850
- process.stdin.setRawMode(true);
851
- process.stdin.resume();
852
- process.stdin.on('data', (chunk) => {
853
- if (claudeProc) claudeProc.write(chunk);
854
- });
855
-
856
- // Resize PTY when local terminal resizes
857
- process.stdout.on('resize', () => {
858
- if (claudeProc) {
859
- claudeProc.resize(process.stdout.columns, process.stdout.rows);
860
- }
861
- });
862
- }
975
+ attachTtyForwarders();
863
976
 
864
977
  // === PTY exit → cleanup ===
865
- claudeProc.onExit(({ exitCode, signal }) => {
978
+ proc.onExit(({ exitCode, signal }) => {
979
+ if (claudeProc !== proc) {
980
+ log(`Ignoring stale Claude exit (pid ${proc.pid}, code=${exitCode}, signal=${signal})`);
981
+ return;
982
+ }
866
983
  log(`Claude exited (code=${exitCode}, signal=${signal})`);
984
+ setTurnState('idle', { sessionId: currentSessionId, reason: 'pty_exit' });
867
985
  broadcast({ type: 'pty_exit', exitCode, signal });
868
986
  claudeProc = null;
869
987
 
@@ -949,9 +1067,25 @@ function isNonAiUserEvent(event, content) {
949
1067
  return /<local-command-(?:stdout|stderr|caveat)>/i.test(text);
950
1068
  }
951
1069
 
1070
+ function extractSessionPrompt(event) {
1071
+ if (!event || event.type !== 'user') return '';
1072
+
1073
+ const message = event.message;
1074
+ const content = typeof message === 'string'
1075
+ ? message
1076
+ : (message && typeof message === 'object' ? message.content : '');
1077
+ const text = flattenUserContent(content).trim();
1078
+ if (!text) return '';
1079
+ if (isNonAiUserEvent(event, content)) return '';
1080
+ if (extractSlashCommand(content)) return '';
1081
+
1082
+ return text.replace(/\s+/g, ' ').trim().substring(0, 120);
1083
+ }
1084
+
952
1085
  function attachTranscript(target, startOffset = 0) {
953
1086
  transcriptPath = target.full;
954
1087
  currentSessionId = path.basename(transcriptPath, '.jsonl');
1088
+ setTurnState('idle', { sessionId: currentSessionId, reason: 'transcript_attached' });
955
1089
  pendingInitialClearTranscript = target.ignoreInitialClearCommand
956
1090
  ? { sessionId: currentSessionId }
957
1091
  : null;
@@ -970,8 +1104,8 @@ function attachTranscript(target, startOffset = 0) {
970
1104
  }
971
1105
  if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
972
1106
 
973
- // If transcript file already has content, mark as catching up so we don't
974
- // broadcast working_started for historical user messages.
1107
+ // If transcript file already has content, mark as catching up so historical
1108
+ // transcript replay cannot mutate live turn state.
975
1109
  try {
976
1110
  const stat = fs.statSync(transcriptPath);
977
1111
  tailCatchingUp = stat.size > transcriptOffset;
@@ -1086,11 +1220,10 @@ function startTailing() {
1086
1220
  pendingInitialClearTranscript &&
1087
1221
  pendingInitialClearTranscript.sessionId === currentSessionId
1088
1222
  );
1089
- // Only broadcast working_started for live (new) user messages,
1090
- // not for historical events during catch-up, and not for slash
1091
- // commands (which are CLI commands, not AI turns).
1223
+ // Only live, AI-producing user messages can move the turn state
1224
+ // into running. Historical replay and slash commands are ignored.
1092
1225
  if (!tailCatchingUp && !slashCommand && !isPassiveUserEvent) {
1093
- broadcast({ type: 'working_started' });
1226
+ setTurnState('running', { sessionId: currentSessionId, reason: 'transcript_user_event' });
1094
1227
  }
1095
1228
  if (slashCommand === '/clear') {
1096
1229
  if (ignoreInitialClear) {
@@ -1154,6 +1287,111 @@ function enrichEditStartLines(event) {
1154
1287
  }
1155
1288
  }
1156
1289
 
1290
+ // ============================================================
1291
+ // Session Scanner — list historical sessions from JSONL files
1292
+ // ============================================================
1293
+ function scanSessions(cwd, limit = 20) {
1294
+ const dir = path.join(PROJECTS_DIR, getProjectSlug(cwd));
1295
+ let files;
1296
+ try {
1297
+ files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
1298
+ } catch {
1299
+ return [];
1300
+ }
1301
+
1302
+ // Stat each file, sort by mtime desc
1303
+ const entries = [];
1304
+ for (const f of files) {
1305
+ const full = path.join(dir, f);
1306
+ try {
1307
+ const stat = fs.statSync(full);
1308
+ entries.push({ file: f, full, mtime: stat.mtimeMs, size: stat.size });
1309
+ } catch { /* skip */ }
1310
+ }
1311
+ entries.sort((a, b) => b.mtime - a.mtime);
1312
+ const top = entries.slice(0, limit);
1313
+
1314
+ const sessions = [];
1315
+ for (const entry of top) {
1316
+ const sessionId = path.basename(entry.file, '.jsonl');
1317
+ const info = {
1318
+ sessionId,
1319
+ summary: '',
1320
+ firstPrompt: '',
1321
+ lastModified: Math.round(entry.mtime),
1322
+ fileSize: entry.size,
1323
+ cwd: cwd,
1324
+ };
1325
+
1326
+ // Read first ~64KB to extract the first real user prompt and model.
1327
+ try {
1328
+ const fd = fs.openSync(entry.full, 'r');
1329
+ const buf = Buffer.alloc(Math.min(entry.size, 64 * 1024));
1330
+ fs.readSync(fd, buf, 0, buf.length, 0);
1331
+ fs.closeSync(fd);
1332
+ const lines = buf.toString('utf8').split('\n').filter(Boolean);
1333
+ for (const line of lines) {
1334
+ try {
1335
+ const evt = JSON.parse(line);
1336
+ if (!info.firstPrompt) {
1337
+ info.firstPrompt = extractSessionPrompt(evt);
1338
+ }
1339
+ if (!info.model && evt.model) {
1340
+ info.model = evt.model;
1341
+ }
1342
+ } catch { /* skip malformed line */ }
1343
+ }
1344
+ } catch { /* skip unreadable file */ }
1345
+
1346
+ info.summary = info.firstPrompt || 'Untitled';
1347
+ sessions.push(info);
1348
+ }
1349
+ return sessions;
1350
+ }
1351
+
1352
+ function getDirectoryRoots() {
1353
+ if (process.platform === 'win32') {
1354
+ const roots = [];
1355
+ for (let code = 65; code <= 90; code++) {
1356
+ const drive = String.fromCharCode(code) + ':\\';
1357
+ try {
1358
+ if (fs.existsSync(drive)) roots.push(drive);
1359
+ } catch {}
1360
+ }
1361
+ return roots;
1362
+ }
1363
+ return ['/'];
1364
+ }
1365
+
1366
+ function assertDirectoryPath(target) {
1367
+ const resolved = path.resolve(String(target || ''));
1368
+ let stat;
1369
+ try {
1370
+ stat = fs.statSync(resolved);
1371
+ } catch {
1372
+ throw new Error('Directory not found');
1373
+ }
1374
+ if (!stat.isDirectory()) throw new Error('Path is not a directory');
1375
+ return resolved;
1376
+ }
1377
+
1378
+ function listDirectories(target) {
1379
+ const cwd = assertDirectoryPath(target);
1380
+ const roots = getDirectoryRoots();
1381
+ const parentDir = path.dirname(cwd);
1382
+ const parent = normalizeFsPath(parentDir) === normalizeFsPath(cwd) ? null : parentDir;
1383
+
1384
+ const entries = fs.readdirSync(cwd, { withFileTypes: true })
1385
+ .filter(entry => entry.isDirectory())
1386
+ .map(entry => ({
1387
+ name: entry.name,
1388
+ path: path.join(cwd, entry.name),
1389
+ }))
1390
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
1391
+
1392
+ return { cwd, parent, roots, entries };
1393
+ }
1394
+
1157
1395
  function stopTailing() {
1158
1396
  if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
1159
1397
  if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
@@ -1165,6 +1403,75 @@ function stopTailing() {
1165
1403
  tailRemainder = Buffer.alloc(0);
1166
1404
  }
1167
1405
 
1406
+ function trustProjectCwd(cwd) {
1407
+ const resolved = path.resolve(String(cwd || ''));
1408
+ if (!resolved) return;
1409
+
1410
+ let state = {};
1411
+ try {
1412
+ state = JSON.parse(fs.readFileSync(CLAUDE_STATE_FILE, 'utf8'));
1413
+ } catch {}
1414
+
1415
+ state.projects = state.projects && typeof state.projects === 'object' ? state.projects : {};
1416
+ const keyVariants = process.platform === 'win32'
1417
+ ? [resolved, resolved.replace(/\\/g, '/')]
1418
+ : [resolved];
1419
+
1420
+ for (const projectKey of keyVariants) {
1421
+ const existing = state.projects[projectKey];
1422
+ state.projects[projectKey] = {
1423
+ ...(existing && typeof existing === 'object' ? existing : {}),
1424
+ hasTrustDialogAccepted: true,
1425
+ projectOnboardingSeenCount: Number.isInteger(existing?.projectOnboardingSeenCount)
1426
+ ? existing.projectOnboardingSeenCount
1427
+ : 0,
1428
+ };
1429
+ }
1430
+
1431
+ fs.writeFileSync(CLAUDE_STATE_FILE, JSON.stringify(state, null, 2));
1432
+ log(`Trusted Claude project cwd: ${resolved}`);
1433
+ }
1434
+
1435
+ function restartClaude(newCwd) {
1436
+ log(`Restarting Claude with new CWD: ${newCwd}`);
1437
+ CWD = newCwd;
1438
+ try {
1439
+ trustProjectCwd(CWD);
1440
+ } catch (err) {
1441
+ log(`Failed to trust Claude project cwd "${CWD}": ${err.message}`);
1442
+ }
1443
+
1444
+ // Stop transcript tailing (also clears switch/expect state)
1445
+ stopTailing();
1446
+
1447
+ // Reset session state
1448
+ currentSessionId = null;
1449
+ transcriptPath = null;
1450
+ transcriptOffset = 0;
1451
+ eventBuffer = [];
1452
+ eventSeq = 0;
1453
+ tailCatchingUp = false;
1454
+ setTurnState('idle', { sessionId: null, reason: 'restart_claude' });
1455
+
1456
+ // Mark the current PTY as stale before killing it so its exit handler
1457
+ // does not shut down the whole bridge during a restart.
1458
+ const procToRestart = claudeProc;
1459
+ claudeProc = null;
1460
+ if (procToRestart) {
1461
+ procToRestart.kill();
1462
+ }
1463
+
1464
+ // Re-setup hooks (CWD changed → project slug may change)
1465
+ setupHooks();
1466
+
1467
+ // Broadcast cwd change immediately so clients drop the previous session
1468
+ // before the replacement Claude process attaches a new transcript.
1469
+ broadcast({ type: 'cwd_changed', cwd: CWD, sessionId: null, lastSeq: 0 });
1470
+
1471
+ // Respawn Claude in new directory
1472
+ spawnClaude();
1473
+ }
1474
+
1168
1475
  // ============================================================
1169
1476
  // 5. Image Upload → Clipboard Injection
1170
1477
  // ============================================================
@@ -1244,6 +1551,7 @@ function handleImageUpload(msg) {
1244
1551
  mediaType: msg.mediaType,
1245
1552
  text: msg.text || '',
1246
1553
  });
1554
+ setTurnState('running', { reason: 'legacy_image_upload' });
1247
1555
  } catch (err) {
1248
1556
  log(`Image upload error: ${err.message}`);
1249
1557
  try { fs.unlinkSync(tmpFile); } catch {}