claude-remote 0.4.4 → 0.4.6

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 +287 -172
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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;
@@ -185,7 +185,8 @@ let tailRemainder = Buffer.alloc(0);
185
185
  let tailCatchingUp = false; // true while reading historical transcript content
186
186
  const isTTY = process.stdin.isTTY && process.stdout.isTTY;
187
187
  const LEGACY_REPLAY_DELAY_MS = 1500;
188
- const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
188
+ const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
189
+ const LINUX_CLIPBOARD_READY_GRACE_MS = 400;
189
190
  const IMAGE_UPLOAD_DIR = path.join(CLAUDE_HOME, 'remote-uploads');
190
191
  let turnStateVersion = 0;
191
192
  let turnState = {
@@ -198,6 +199,7 @@ let ttyInputForwarderAttached = false;
198
199
  let ttyInputHandler = null;
199
200
  let ttyResizeHandler = null;
200
201
  let activeLinuxClipboardProc = null;
202
+ let linuxImagePasteInFlight = false;
201
203
 
202
204
  // --- Permission approval state ---
203
205
  let approvalSeq = 0;
@@ -210,27 +212,27 @@ const PARTIAL_AUTO_ALLOW = new Set(['Read', 'Glob', 'Grep', 'Write', 'Edit']);
210
212
  // --- Logging → file only (never pollute the terminal) ---
211
213
  const LOG_FILE = path.join(os.homedir(), '.claude', 'bridge.log');
212
214
  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
-
215
+ function log(msg) {
216
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
217
+ fs.appendFileSync(LOG_FILE, line);
218
+ }
219
+
220
+ function formatTtyInputChunk(chunk) {
221
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
222
+ return `len=${buf.length} hex=${buf.toString('hex')} base64=${buf.toString('base64')} utf8=${JSON.stringify(buf.toString('utf8'))}`;
223
+ }
224
+
223
225
  function clearActiveLinuxClipboardProc(reason = '') {
224
226
  if (!activeLinuxClipboardProc) return;
225
227
  const { child, tool } = activeLinuxClipboardProc;
226
228
  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
- }
229
+ try {
230
+ child.kill('SIGTERM');
231
+ log(`Linux clipboard process terminated (${tool}) reason=${reason || 'cleanup'}`);
232
+ } catch (err) {
233
+ log(`Linux clipboard process terminate error (${tool}): ${err.message}`);
234
+ }
235
+ }
234
236
 
235
237
  function wsLabel(ws) {
236
238
  const clientId = ws && ws._clientInstanceId ? ` client=${ws._clientInstanceId}` : '';
@@ -263,6 +265,7 @@ function getTurnStatePayload() {
263
265
  sessionId: turnState.sessionId,
264
266
  version: turnState.version,
265
267
  updatedAt: turnState.updatedAt,
268
+ reason: turnState.reason || '',
266
269
  };
267
270
  }
268
271
 
@@ -284,6 +287,7 @@ function setTurnState(phase, { sessionId = currentSessionId, reason = '', force
284
287
  sessionId: normalizedSessionId,
285
288
  version: ++turnStateVersion,
286
289
  updatedAt: Date.now(),
290
+ reason,
287
291
  };
288
292
 
289
293
  log(`Turn state -> phase=${turnState.phase} session=${turnState.sessionId ?? 'null'} version=${turnState.version}${reason ? ` reason=${reason}` : ''}`);
@@ -291,19 +295,41 @@ function setTurnState(phase, { sessionId = currentSessionId, reason = '', force
291
295
  return true;
292
296
  }
293
297
 
298
+ function emitInterrupt(source) {
299
+ const interruptEvent = {
300
+ type: 'interrupt',
301
+ source,
302
+ timestamp: Date.now(),
303
+ uuid: crypto.randomUUID(),
304
+ };
305
+ const record = { seq: ++eventSeq, event: interruptEvent };
306
+ eventBuffer.push(record);
307
+ if (eventBuffer.length > EVENT_BUFFER_MAX) {
308
+ eventBuffer = eventBuffer.slice(-Math.round(EVENT_BUFFER_MAX * 0.8));
309
+ }
310
+ broadcast({ type: 'log_event', seq: record.seq, event: interruptEvent });
311
+ setTurnState('idle', { reason: `${source}_interrupt` });
312
+ }
313
+
294
314
  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
- };
315
+ if (!isTTY || ttyInputForwarderAttached) return;
316
+
317
+ ttyInputHandler = (chunk) => {
318
+ if (DEBUG_TTY_INPUT) {
319
+ try {
320
+ log(`TTY input ${formatTtyInputChunk(chunk)}`);
321
+ } catch (err) {
322
+ log(`TTY input log error: ${err.message}`);
323
+ }
324
+ }
325
+ if (claudeProc) claudeProc.write(chunk);
326
+ // Detect Ctrl+C (0x03) from local terminal and sync state
327
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
328
+ if (buf.includes(0x03) && turnState.phase === 'running') {
329
+ log('Terminal Ctrl+C detected — injecting interrupt event');
330
+ emitInterrupt('terminal');
331
+ }
332
+ };
307
333
  ttyResizeHandler = () => {
308
334
  if (claudeProc) claudeProc.resize(process.stdout.columns, process.stdout.rows);
309
335
  };
@@ -657,79 +683,134 @@ function cleanupClientUploads(ws) {
657
683
  }
658
684
  }
659
685
 
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());
686
+ function createTempImageFile(buffer, mediaType, uploadId) {
687
+ const isLinux = process.platform !== 'win32' && process.platform !== 'darwin';
688
+ const tmpDir = isLinux
689
+ ? IMAGE_UPLOAD_DIR
690
+ : (process.env.CLAUDE_CODE_TMPDIR || os.tmpdir());
665
691
  const type = String(mediaType || 'image/png').toLowerCase();
666
692
  const ext = type.includes('jpeg') || type.includes('jpg') ? '.jpg' : '.png';
667
693
  fs.mkdirSync(tmpDir, { recursive: true });
668
694
  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 {}
695
+ fs.writeFileSync(tmpFile, buffer);
696
+ return tmpFile;
697
+ }
698
+
699
+ function isLinuxClipboardToolInstalled(tool) {
683
700
  try {
684
- execSync('command -v wl-copy >/dev/null 2>&1', {
701
+ execSync(`command -v ${tool} >/dev/null 2>&1`, {
685
702
  stdio: 'ignore',
686
703
  shell: '/bin/sh',
687
704
  timeout: 2000,
688
705
  });
689
- return 'wl-copy';
690
- } catch {}
691
- return null;
706
+ return true;
707
+ } catch {
708
+ return false;
709
+ }
710
+ }
711
+
712
+ function setLinuxImagePasteInFlight(active, reason = '') {
713
+ linuxImagePasteInFlight = !!active;
714
+ if (reason) log(`Linux image paste lock=${linuxImagePasteInFlight ? 'on' : 'off'} reason=${reason}`);
715
+ }
716
+
717
+ function getLinuxClipboardToolCandidates() {
718
+ if (process.platform === 'win32' || process.platform === 'darwin') return [];
719
+ const preferred = [];
720
+ if (process.env.WAYLAND_DISPLAY) preferred.push('wl-copy');
721
+ if (process.env.DISPLAY) preferred.push('xclip');
722
+ return preferred;
692
723
  }
693
724
 
694
725
  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.');
726
+ const candidates = getLinuxClipboardToolCandidates();
727
+ const available = candidates.filter(isLinuxClipboardToolInstalled);
728
+ if (available.length > 0) return available;
729
+ if (!process.env.WAYLAND_DISPLAY && !process.env.DISPLAY) {
730
+ throw new Error('Linux image paste requires an active graphical session (WAYLAND_DISPLAY or DISPLAY).');
731
+ }
732
+ throw new Error('Linux image paste requires wl-copy or xclip on the server. Install a matching clipboard tool and try again.');
698
733
  }
699
734
 
700
- function startLinuxClipboardImage(tmpFile, mediaType) {
735
+ function spawnLinuxClipboardTool(tool, imageBuffer, type) {
736
+ return new Promise((resolve, reject) => {
737
+ const args = tool === 'xclip'
738
+ ? ['-selection', 'clipboard', '-t', type, '-i']
739
+ : ['--type', type];
740
+ const child = spawn(tool, args, {
741
+ detached: true,
742
+ stdio: ['pipe', 'ignore', 'pipe'],
743
+ });
744
+ let settled = false;
745
+ let stderr = '';
746
+ let readyTimer = null;
747
+
748
+ const settleFailure = (message) => {
749
+ if (settled) return;
750
+ settled = true;
751
+ if (readyTimer) clearTimeout(readyTimer);
752
+ try { child.kill('SIGTERM'); } catch {}
753
+ reject(new Error(message));
754
+ };
755
+
756
+ const settleSuccess = () => {
757
+ if (settled) return;
758
+ settled = true;
759
+ if (readyTimer) clearTimeout(readyTimer);
760
+ activeLinuxClipboardProc = { child, tool };
761
+ child.unref();
762
+ resolve(tool);
763
+ };
764
+
765
+ child.on('error', (err) => {
766
+ log(`Linux clipboard process error (${tool}): ${err.message}`);
767
+ settleFailure(`Linux clipboard tool ${tool} failed: ${err.message}`);
768
+ });
769
+ child.stderr.on('data', (chunk) => {
770
+ stderr += chunk.toString('utf8');
771
+ if (stderr.length > 2000) stderr = stderr.slice(-2000);
772
+ });
773
+ child.on('exit', (code, signal) => {
774
+ if (activeLinuxClipboardProc && activeLinuxClipboardProc.child === child) activeLinuxClipboardProc = null;
775
+ const extra = stderr.trim() ? ` stderr=${JSON.stringify(stderr.trim())}` : '';
776
+ log(`Linux clipboard process exited (${tool}) code=${code ?? 'null'} signal=${signal ?? 'null'}${extra}`);
777
+ if (!settled) {
778
+ const detail = stderr.trim() || `exit code ${code ?? 'null'} signal ${signal ?? 'null'}`;
779
+ settleFailure(`Linux clipboard tool ${tool} exited before paste: ${detail}`);
780
+ }
781
+ });
782
+ child.stdin.on('error', (err) => {
783
+ if (err.code === 'EPIPE') {
784
+ settleFailure(`Linux clipboard tool ${tool} closed its input early`);
785
+ return;
786
+ }
787
+ log(`Linux clipboard stdin error (${tool}): ${err.message}`);
788
+ settleFailure(`Linux clipboard tool ${tool} stdin failed: ${err.message}`);
789
+ });
790
+
791
+ child.stdin.end(imageBuffer);
792
+ log(`Linux clipboard process started (${tool}) pid=${child.pid ?? 'null'} type=${type} bytes=${imageBuffer.length}`);
793
+ readyTimer = setTimeout(() => settleSuccess(), LINUX_CLIPBOARD_READY_GRACE_MS);
794
+ });
795
+ }
796
+
797
+ async function startLinuxClipboardImage(tmpFile, mediaType) {
701
798
  const type = String(mediaType || 'image/png').toLowerCase();
702
- const tool = assertLinuxClipboardAvailable();
703
799
  const imageBuffer = fs.readFileSync(tmpFile);
800
+ const availableTools = assertLinuxClipboardAvailable();
704
801
  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;
802
+
803
+ let lastErr = null;
804
+ for (const tool of availableTools) {
805
+ try {
806
+ return await spawnLinuxClipboardTool(tool, imageBuffer, type);
807
+ } catch (err) {
808
+ lastErr = err;
809
+ log(`Linux clipboard arm failed (${tool}): ${err.message}`);
810
+ }
811
+ }
812
+
813
+ throw lastErr || new Error('Linux clipboard could not be initialized');
733
814
  }
734
815
 
735
816
  setInterval(() => {
@@ -899,6 +980,14 @@ wss.on('connection', (ws, req) => {
899
980
  // Raw terminal keystrokes from xterm.js in WebUI
900
981
  if (claudeProc) claudeProc.write(msg.data);
901
982
  break;
983
+ case 'interrupt': {
984
+ // User pressed stop button in app — send Ctrl+C to PTY
985
+ if (!claudeProc || turnState.phase !== 'running') break;
986
+ log(`Interrupt from ${wsLabel(ws)} — sending Ctrl+C to PTY`);
987
+ claudeProc.write('\x03');
988
+ emitInterrupt('app');
989
+ break;
990
+ }
902
991
  case 'expect_clear':
903
992
  // Plan mode option 1 triggers /clear inside Claude Code;
904
993
  // client notifies us so we can detect the session switch.
@@ -966,25 +1055,25 @@ wss.on('connection', (ws, req) => {
966
1055
  }
967
1056
  break;
968
1057
  }
969
- case 'image_upload_init': {
970
- const uploadId = String(msg.uploadId || '');
971
- if (!uploadId) {
972
- sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
973
- break;
974
- }
1058
+ case 'image_upload_init': {
1059
+ const uploadId = String(msg.uploadId || '');
1060
+ if (!uploadId) {
1061
+ sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
1062
+ break;
1063
+ }
975
1064
  cleanupImageUpload(uploadId);
976
1065
  if (process.platform !== 'win32' && process.platform !== 'darwin') {
977
1066
  try {
978
- const tool = assertLinuxClipboardAvailable();
979
- log(`Linux clipboard preflight OK: ${tool}`);
1067
+ const tools = assertLinuxClipboardAvailable();
1068
+ log(`Linux clipboard preflight OK: ${tools.join(', ')}`);
980
1069
  } catch (err) {
981
1070
  sendUploadStatus(ws, uploadId, 'error', { message: err.message });
982
1071
  break;
983
1072
  }
984
1073
  }
985
- pendingImageUploads.set(uploadId, {
986
- id: uploadId,
987
- owner: ws,
1074
+ pendingImageUploads.set(uploadId, {
1075
+ id: uploadId,
1076
+ owner: ws,
988
1077
  mediaType: msg.mediaType || 'image/png',
989
1078
  name: msg.name || 'image',
990
1079
  totalBytes: Number.isFinite(msg.totalBytes) ? msg.totalBytes : 0,
@@ -1082,15 +1171,15 @@ wss.on('connection', (ws, req) => {
1082
1171
  if (!upload || !upload.tmpFile) {
1083
1172
  sendUploadStatus(ws, uploadId, 'error', { message: 'Upload not ready' });
1084
1173
  break;
1085
- }
1174
+ }
1086
1175
  try {
1087
- handlePreparedImageUpload({
1176
+ await handlePreparedImageUpload({
1088
1177
  tmpFile: upload.tmpFile,
1089
1178
  mediaType: upload.mediaType,
1090
1179
  text: msg.text || '',
1091
1180
  logLabel: upload.name || uploadId,
1092
1181
  onCleanup: () => cleanupImageUpload(uploadId),
1093
- });
1182
+ });
1094
1183
  upload.submitted = true;
1095
1184
  upload.updatedAt = Date.now();
1096
1185
  setTurnState('running', { reason: 'image_submit' });
@@ -1717,81 +1806,104 @@ function restartClaude(newCwd) {
1717
1806
  // ============================================================
1718
1807
  // 5. Image Upload → Clipboard Injection
1719
1808
  // ============================================================
1720
- function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', onCleanup = null }) {
1721
- if (!claudeProc) throw new Error('Claude not running');
1722
- if (!tmpFile || !fs.existsSync(tmpFile)) throw new Error('Prepared image file missing');
1723
-
1724
- const isWin = process.platform === 'win32';
1725
- const isMac = process.platform === 'darwin';
1809
+ async function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', onCleanup = null }) {
1810
+ if (!claudeProc) throw new Error('Claude not running');
1811
+ if (!tmpFile || !fs.existsSync(tmpFile)) throw new Error('Prepared image file missing');
1812
+
1813
+ const isWin = process.platform === 'win32';
1814
+ const isMac = process.platform === 'darwin';
1815
+ const isLinux = !isWin && !isMac;
1816
+ if (isLinux && linuxImagePasteInFlight) {
1817
+ throw new Error('Another Linux image paste is still in progress. Please wait a moment and try again.');
1818
+ }
1726
1819
  try {
1727
- const stat = fs.statSync(tmpFile);
1728
- log(`Image ready: ${logLabel || path.basename(tmpFile)} (${stat.size} bytes)`);
1729
-
1730
- if (isWin) {
1731
- const psCmd = `Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $img = [System.Drawing.Image]::FromFile('${tmpFile.replace(/'/g, "''")}'); [System.Windows.Forms.Clipboard]::SetImage($img); $img.Dispose()`;
1732
- execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
1733
- } else if (isMac) {
1820
+ const stat = fs.statSync(tmpFile);
1821
+ log(`Image ready: ${logLabel || path.basename(tmpFile)} (${stat.size} bytes)`);
1822
+ if (isLinux) setLinuxImagePasteInFlight(true, 'prepare_upload');
1823
+
1824
+ if (isWin) {
1825
+ const psCmd = `Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $img = [System.Drawing.Image]::FromFile('${tmpFile.replace(/'/g, "''")}'); [System.Windows.Forms.Clipboard]::SetImage($img); $img.Dispose()`;
1826
+ execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
1827
+ } else if (isMac) {
1734
1828
  execSync(`osascript -e 'set the clipboard to (read POSIX file "${tmpFile}" as 芦class PNGf禄)'`, { timeout: 10000 });
1735
1829
  } else {
1736
- const tool = startLinuxClipboardImage(tmpFile, mediaType);
1830
+ const tool = await startLinuxClipboardImage(tmpFile, mediaType);
1737
1831
  log(`Linux clipboard armed with ${tool}`);
1738
1832
  }
1739
- log('Clipboard set with image');
1740
-
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);
1833
+ log('Clipboard set with image');
1748
1834
 
1749
- setTimeout(() => {
1750
- if (!claudeProc) return;
1751
- const trimmedText = (text || '').trim();
1752
- if (trimmedText) claudeProc.write(trimmedText);
1753
-
1754
- 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);
1835
+ const pasteDelayMs = isWin || isMac ? 0 : 150;
1836
+ await new Promise((resolve, reject) => {
1837
+ setTimeout(() => {
1838
+ if (!claudeProc) {
1839
+ reject(new Error('Claude stopped before image paste'));
1840
+ return;
1760
1841
  }
1842
+ if (isWin) claudeProc.write('\x1bv');
1843
+ else claudeProc.write('\x16');
1844
+ log('Sent image paste keypress to PTY');
1761
1845
 
1762
1846
  setTimeout(() => {
1763
- if (onCleanup) onCleanup();
1764
- else {
1765
- try { fs.unlinkSync(tmpFile); } catch {}
1766
- }
1767
- }, 5000);
1768
- }, 150);
1769
- }, 1000 + pasteDelayMs);
1770
- } catch (err) {
1771
- log(`Image upload error: ${err.message}`);
1772
- if (onCleanup) onCleanup();
1773
- else {
1774
- try { fs.unlinkSync(tmpFile); } catch {}
1775
- }
1847
+ if (!claudeProc) {
1848
+ reject(new Error('Claude stopped before image prompt'));
1849
+ return;
1850
+ }
1851
+ const trimmedText = (text || '').trim();
1852
+ if (trimmedText) claudeProc.write(trimmedText);
1853
+
1854
+ setTimeout(() => {
1855
+ if (!claudeProc) {
1856
+ reject(new Error('Claude stopped before image submit'));
1857
+ return;
1858
+ }
1859
+ claudeProc.write('\r');
1860
+ log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
1861
+
1862
+ if (isLinux) {
1863
+ setTimeout(() => clearActiveLinuxClipboardProc('post-paste'), 1000);
1864
+ }
1865
+
1866
+ setTimeout(() => {
1867
+ if (isLinux) setLinuxImagePasteInFlight(false, 'cleanup');
1868
+ if (onCleanup) onCleanup();
1869
+ else {
1870
+ try { fs.unlinkSync(tmpFile); } catch {}
1871
+ }
1872
+ }, 5000);
1873
+ resolve();
1874
+ }, 150);
1875
+ }, 1000);
1876
+ }, pasteDelayMs);
1877
+ });
1878
+ } catch (err) {
1879
+ log(`Image upload error: ${err.message}`);
1880
+ if (isLinux) {
1881
+ clearActiveLinuxClipboardProc('error');
1882
+ setLinuxImagePasteInFlight(false, 'error');
1883
+ }
1884
+ if (onCleanup) onCleanup();
1885
+ else {
1886
+ try { fs.unlinkSync(tmpFile); } catch {}
1887
+ }
1776
1888
  throw err;
1777
1889
  }
1778
1890
  }
1779
1891
 
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
-
1892
+ function handleImageUpload(msg) {
1893
+ if (!claudeProc) {
1894
+ log('Image upload ignored: Claude not running');
1895
+ return;
1896
+ }
1897
+ if (!msg.base64) {
1898
+ log('Image upload ignored: no base64 data');
1899
+ return;
1900
+ }
1901
+ let tmpFile = null;
1902
+
1791
1903
  try {
1792
1904
  if (process.platform !== 'win32' && process.platform !== 'darwin') {
1793
- const tool = assertLinuxClipboardAvailable();
1794
- log(`Linux clipboard preflight OK (legacy upload): ${tool}`);
1905
+ const tools = assertLinuxClipboardAvailable();
1906
+ log(`Linux clipboard preflight OK (legacy upload): ${tools.join(', ')}`);
1795
1907
  }
1796
1908
  const buf = Buffer.from(msg.base64, 'base64');
1797
1909
  tmpFile = createTempImageFile(buf, msg.mediaType, `legacy_${Date.now()}`);
@@ -1800,13 +1912,16 @@ function handleImageUpload(msg) {
1800
1912
  tmpFile,
1801
1913
  mediaType: msg.mediaType,
1802
1914
  text: msg.text || '',
1915
+ }).then(() => {
1916
+ setTurnState('running', { reason: 'legacy_image_upload' });
1917
+ }).catch((err) => {
1918
+ log(`Image upload error: ${err.message}`);
1803
1919
  });
1804
- setTurnState('running', { reason: 'legacy_image_upload' });
1805
1920
  } catch (err) {
1806
- log(`Image upload error: ${err.message}`);
1807
- try { fs.unlinkSync(tmpFile); } catch {}
1808
- }
1809
- }
1921
+ log(`Image upload error: ${err.message}`);
1922
+ try { fs.unlinkSync(tmpFile); } catch {}
1923
+ }
1924
+ }
1810
1925
 
1811
1926
  // ============================================================
1812
1927
  // 6. Hook Auto-Setup