claude-remote 0.4.4 → 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 +228 -196
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.4.4",
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;
@@ -194,10 +194,10 @@ let turnState = {
194
194
  version: 0,
195
195
  updatedAt: Date.now(),
196
196
  };
197
- let ttyInputForwarderAttached = false;
198
- let ttyInputHandler = null;
199
- let ttyResizeHandler = null;
200
- let activeLinuxClipboardProc = null;
197
+ let ttyInputForwarderAttached = false;
198
+ let ttyInputHandler = null;
199
+ let ttyResizeHandler = null;
200
+ let activeLinuxClipboardProc = null;
201
201
 
202
202
  // --- Permission approval state ---
203
203
  let approvalSeq = 0;
@@ -210,27 +210,27 @@ const PARTIAL_AUTO_ALLOW = new Set(['Read', 'Glob', 'Grep', 'Write', 'Edit']);
210
210
  // --- Logging → file only (never pollute the terminal) ---
211
211
  const LOG_FILE = path.join(os.homedir(), '.claude', 'bridge.log');
212
212
  fs.writeFileSync(LOG_FILE, `--- Bridge started ${new Date().toISOString()} ---\n`);
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
- }
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
+ }
234
234
 
235
235
  function wsLabel(ws) {
236
236
  const clientId = ws && ws._clientInstanceId ? ` client=${ws._clientInstanceId}` : '';
@@ -263,6 +263,7 @@ function getTurnStatePayload() {
263
263
  sessionId: turnState.sessionId,
264
264
  version: turnState.version,
265
265
  updatedAt: turnState.updatedAt,
266
+ reason: turnState.reason || '',
266
267
  };
267
268
  }
268
269
 
@@ -284,6 +285,7 @@ function setTurnState(phase, { sessionId = currentSessionId, reason = '', force
284
285
  sessionId: normalizedSessionId,
285
286
  version: ++turnStateVersion,
286
287
  updatedAt: Date.now(),
288
+ reason,
287
289
  };
288
290
 
289
291
  log(`Turn state -> phase=${turnState.phase} session=${turnState.sessionId ?? 'null'} version=${turnState.version}${reason ? ` reason=${reason}` : ''}`);
@@ -291,19 +293,41 @@ function setTurnState(phase, { sessionId = currentSessionId, reason = '', force
291
293
  return true;
292
294
  }
293
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
+
294
312
  function attachTtyForwarders() {
295
- if (!isTTY || ttyInputForwarderAttached) return;
296
-
297
- ttyInputHandler = (chunk) => {
298
- if (DEBUG_TTY_INPUT) {
299
- try {
300
- log(`TTY input ${formatTtyInputChunk(chunk)}`);
301
- } catch (err) {
302
- log(`TTY input log error: ${err.message}`);
303
- }
304
- }
305
- if (claudeProc) claudeProc.write(chunk);
306
- };
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
+ };
307
331
  ttyResizeHandler = () => {
308
332
  if (claudeProc) claudeProc.resize(process.stdout.columns, process.stdout.rows);
309
333
  };
@@ -657,80 +681,80 @@ function cleanupClientUploads(ws) {
657
681
  }
658
682
  }
659
683
 
660
- function createTempImageFile(buffer, mediaType, uploadId) {
661
- const isLinux = process.platform !== 'win32' && process.platform !== 'darwin';
662
- const tmpDir = isLinux
663
- ? IMAGE_UPLOAD_DIR
664
- : (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());
665
689
  const type = String(mediaType || 'image/png').toLowerCase();
666
690
  const ext = type.includes('jpeg') || type.includes('jpg') ? '.jpg' : '.png';
667
691
  fs.mkdirSync(tmpDir, { recursive: true });
668
692
  const tmpFile = path.join(tmpDir, `bridge_upload_${uploadId}_${Date.now()}${ext}`);
669
- fs.writeFileSync(tmpFile, buffer);
670
- return tmpFile;
671
- }
672
-
673
- function getLinuxClipboardTool() {
674
- if (process.platform === 'win32' || process.platform === 'darwin') return null;
675
- try {
676
- execSync('command -v xclip >/dev/null 2>&1', {
677
- stdio: 'ignore',
678
- shell: '/bin/sh',
679
- timeout: 2000,
680
- });
681
- return 'xclip';
682
- } catch {}
683
- try {
684
- execSync('command -v wl-copy >/dev/null 2>&1', {
685
- stdio: 'ignore',
686
- shell: '/bin/sh',
687
- timeout: 2000,
688
- });
689
- return 'wl-copy';
690
- } catch {}
691
- return null;
692
- }
693
-
694
- function assertLinuxClipboardAvailable() {
695
- const tool = getLinuxClipboardTool();
696
- if (tool) return tool;
697
- throw new Error('Linux image paste requires xclip or wl-copy on the server. Install one and try again.');
698
- }
699
-
700
- function startLinuxClipboardImage(tmpFile, mediaType) {
701
- const type = String(mediaType || 'image/png').toLowerCase();
702
- const tool = assertLinuxClipboardAvailable();
703
- const imageBuffer = fs.readFileSync(tmpFile);
704
- clearActiveLinuxClipboardProc('replace');
705
- const args = tool === 'xclip'
706
- ? ['-selection', 'clipboard', '-t', type, '-i']
707
- : ['--type', type];
708
- const child = spawn(tool, args, {
709
- detached: true,
710
- stdio: ['pipe', 'ignore', 'pipe'],
711
- });
712
- activeLinuxClipboardProc = { child, tool };
713
- let stderr = '';
714
- child.on('error', (err) => {
715
- log(`Linux clipboard process error (${tool}): ${err.message}`);
716
- });
717
- child.stderr.on('data', (chunk) => {
718
- stderr += chunk.toString('utf8');
719
- if (stderr.length > 2000) stderr = stderr.slice(-2000);
720
- });
721
- child.on('exit', (code, signal) => {
722
- if (activeLinuxClipboardProc && activeLinuxClipboardProc.child === child) activeLinuxClipboardProc = null;
723
- const extra = stderr.trim() ? ` stderr=${JSON.stringify(stderr.trim())}` : '';
724
- log(`Linux clipboard process exited (${tool}) code=${code ?? 'null'} signal=${signal ?? 'null'}${extra}`);
725
- });
726
- child.stdin.on('error', (err) => {
727
- if (err.code !== 'EPIPE') log(`Linux clipboard stdin error (${tool}): ${err.message}`);
728
- });
729
- child.stdin.end(imageBuffer);
730
- child.unref();
731
- log(`Linux clipboard process started (${tool}) pid=${child.pid ?? 'null'} type=${type} bytes=${imageBuffer.length}`);
732
- return tool;
733
- }
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
+ }
734
758
 
735
759
  setInterval(() => {
736
760
  const now = Date.now();
@@ -899,6 +923,14 @@ wss.on('connection', (ws, req) => {
899
923
  // Raw terminal keystrokes from xterm.js in WebUI
900
924
  if (claudeProc) claudeProc.write(msg.data);
901
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
+ }
902
934
  case 'expect_clear':
903
935
  // Plan mode option 1 triggers /clear inside Claude Code;
904
936
  // client notifies us so we can detect the session switch.
@@ -966,25 +998,25 @@ wss.on('connection', (ws, req) => {
966
998
  }
967
999
  break;
968
1000
  }
969
- case 'image_upload_init': {
970
- const uploadId = String(msg.uploadId || '');
971
- if (!uploadId) {
972
- sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
973
- break;
974
- }
975
- cleanupImageUpload(uploadId);
976
- if (process.platform !== 'win32' && process.platform !== 'darwin') {
977
- try {
978
- const tool = assertLinuxClipboardAvailable();
979
- log(`Linux clipboard preflight OK: ${tool}`);
980
- } catch (err) {
981
- sendUploadStatus(ws, uploadId, 'error', { message: err.message });
982
- break;
983
- }
984
- }
985
- pendingImageUploads.set(uploadId, {
986
- id: uploadId,
987
- 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,
988
1020
  mediaType: msg.mediaType || 'image/png',
989
1021
  name: msg.name || 'image',
990
1022
  totalBytes: Number.isFinite(msg.totalBytes) ? msg.totalBytes : 0,
@@ -1082,15 +1114,15 @@ wss.on('connection', (ws, req) => {
1082
1114
  if (!upload || !upload.tmpFile) {
1083
1115
  sendUploadStatus(ws, uploadId, 'error', { message: 'Upload not ready' });
1084
1116
  break;
1085
- }
1086
- try {
1087
- handlePreparedImageUpload({
1088
- tmpFile: upload.tmpFile,
1089
- mediaType: upload.mediaType,
1090
- text: msg.text || '',
1091
- logLabel: upload.name || uploadId,
1092
- onCleanup: () => cleanupImageUpload(uploadId),
1093
- });
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
+ });
1094
1126
  upload.submitted = true;
1095
1127
  upload.updatedAt = Date.now();
1096
1128
  setTurnState('running', { reason: 'image_submit' });
@@ -1723,7 +1755,7 @@ function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', on
1723
1755
 
1724
1756
  const isWin = process.platform === 'win32';
1725
1757
  const isMac = process.platform === 'darwin';
1726
- try {
1758
+ try {
1727
1759
  const stat = fs.statSync(tmpFile);
1728
1760
  log(`Image ready: ${logLabel || path.basename(tmpFile)} (${stat.size} bytes)`);
1729
1761
 
@@ -1732,41 +1764,41 @@ function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', on
1732
1764
  execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
1733
1765
  } else if (isMac) {
1734
1766
  execSync(`osascript -e 'set the clipboard to (read POSIX file "${tmpFile}" as 芦class PNGf禄)'`, { timeout: 10000 });
1735
- } else {
1736
- const tool = startLinuxClipboardImage(tmpFile, mediaType);
1737
- log(`Linux clipboard armed with ${tool}`);
1738
- }
1767
+ } else {
1768
+ const tool = startLinuxClipboardImage(tmpFile, mediaType);
1769
+ log(`Linux clipboard armed with ${tool}`);
1770
+ }
1739
1771
  log('Clipboard set with image');
1740
1772
 
1741
- const pasteDelayMs = isWin || isMac ? 0 : 150;
1742
- setTimeout(() => {
1743
- if (!claudeProc) return;
1744
- if (isWin) claudeProc.write('\x1bv');
1745
- else claudeProc.write('\x16');
1746
- log('Sent image paste keypress to PTY');
1747
- }, pasteDelayMs);
1748
-
1749
- setTimeout(() => {
1750
- if (!claudeProc) return;
1751
- const trimmedText = (text || '').trim();
1752
- 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);
1753
1785
 
1754
1786
  setTimeout(() => {
1755
- if (claudeProc) claudeProc.write('\r');
1756
- log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
1757
-
1758
- if (!isWin && !isMac) {
1759
- setTimeout(() => clearActiveLinuxClipboardProc('post-paste'), 1000);
1760
- }
1761
-
1762
- setTimeout(() => {
1763
- if (onCleanup) onCleanup();
1787
+ if (claudeProc) claudeProc.write('\r');
1788
+ log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
1789
+
1790
+ if (!isWin && !isMac) {
1791
+ setTimeout(() => clearActiveLinuxClipboardProc('post-paste'), 1000);
1792
+ }
1793
+
1794
+ setTimeout(() => {
1795
+ if (onCleanup) onCleanup();
1764
1796
  else {
1765
1797
  try { fs.unlinkSync(tmpFile); } catch {}
1766
1798
  }
1767
1799
  }, 5000);
1768
- }, 150);
1769
- }, 1000 + pasteDelayMs);
1800
+ }, 150);
1801
+ }, 1000 + pasteDelayMs);
1770
1802
  } catch (err) {
1771
1803
  log(`Image upload error: ${err.message}`);
1772
1804
  if (onCleanup) onCleanup();
@@ -1777,32 +1809,32 @@ function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', on
1777
1809
  }
1778
1810
  }
1779
1811
 
1780
- function handleImageUpload(msg) {
1781
- if (!claudeProc) {
1782
- log('Image upload ignored: Claude not running');
1783
- return;
1784
- }
1785
- if (!msg.base64) {
1786
- log('Image upload ignored: no base64 data');
1787
- return;
1788
- }
1789
- let tmpFile = null;
1790
-
1791
- try {
1792
- if (process.platform !== 'win32' && process.platform !== 'darwin') {
1793
- const tool = assertLinuxClipboardAvailable();
1794
- log(`Linux clipboard preflight OK (legacy upload): ${tool}`);
1795
- }
1796
- const buf = Buffer.from(msg.base64, 'base64');
1797
- tmpFile = createTempImageFile(buf, msg.mediaType, `legacy_${Date.now()}`);
1798
- log(`Image saved: ${tmpFile} (${buf.length} bytes)`);
1799
- handlePreparedImageUpload({
1800
- tmpFile,
1801
- mediaType: msg.mediaType,
1802
- text: msg.text || '',
1803
- });
1804
- setTurnState('running', { reason: 'legacy_image_upload' });
1805
- } 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) {
1806
1838
  log(`Image upload error: ${err.message}`);
1807
1839
  try { fs.unlinkSync(tmpFile); } catch {}
1808
1840
  }