claude-remote 0.4.5 → 0.4.7

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 +253 -161
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
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
@@ -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 = {
@@ -194,10 +195,11 @@ let turnState = {
194
195
  version: 0,
195
196
  updatedAt: Date.now(),
196
197
  };
197
- let ttyInputForwarderAttached = false;
198
- let ttyInputHandler = null;
199
- let ttyResizeHandler = null;
200
- let activeLinuxClipboardProc = null;
198
+ let ttyInputForwarderAttached = false;
199
+ let ttyInputHandler = null;
200
+ let ttyResizeHandler = null;
201
+ let activeLinuxClipboardProc = null;
202
+ let linuxImagePasteInFlight = false;
201
203
 
202
204
  // --- Permission approval state ---
203
205
  let approvalSeq = 0;
@@ -220,10 +222,10 @@ function formatTtyInputChunk(chunk) {
220
222
  return `len=${buf.length} hex=${buf.toString('hex')} base64=${buf.toString('base64')} utf8=${JSON.stringify(buf.toString('utf8'))}`;
221
223
  }
222
224
 
223
- function clearActiveLinuxClipboardProc(reason = '') {
224
- if (!activeLinuxClipboardProc) return;
225
- const { child, tool } = activeLinuxClipboardProc;
226
- activeLinuxClipboardProc = null;
225
+ function clearActiveLinuxClipboardProc(reason = '') {
226
+ if (!activeLinuxClipboardProc) return;
227
+ const { child, tool } = activeLinuxClipboardProc;
228
+ activeLinuxClipboardProc = null;
227
229
  try {
228
230
  child.kill('SIGTERM');
229
231
  log(`Linux clipboard process terminated (${tool}) reason=${reason || 'cleanup'}`);
@@ -694,67 +696,131 @@ function createTempImageFile(buffer, mediaType, uploadId) {
694
696
  return tmpFile;
695
697
  }
696
698
 
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
- }
699
+ function isLinuxClipboardToolInstalled(tool) {
700
+ try {
701
+ execSync(`command -v ${tool} >/dev/null 2>&1`, {
702
+ stdio: 'ignore',
703
+ shell: '/bin/sh',
704
+ timeout: 2000,
705
+ });
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;
723
+ }
724
+
725
+ function assertLinuxClipboardAvailable() {
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.');
733
+ }
734
+
735
+ function spawnLinuxClipboardTool(tool, imageBuffer, type) {
736
+ return new Promise((resolve, reject) => {
737
+ const args = tool === 'xclip'
738
+ ? ['-quiet', '-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
+ if (child.exitCode == null && child.signalCode == null) {
753
+ try { child.kill('SIGTERM'); } catch {}
754
+ }
755
+ reject(new Error(message));
756
+ };
757
+
758
+ const settleSuccess = (trackProcess = true) => {
759
+ if (settled) return;
760
+ settled = true;
761
+ if (readyTimer) clearTimeout(readyTimer);
762
+ if (trackProcess && child.exitCode == null && child.signalCode == null) {
763
+ activeLinuxClipboardProc = { child, tool };
764
+ child.unref();
765
+ }
766
+ resolve(tool);
767
+ };
768
+
769
+ child.on('error', (err) => {
770
+ log(`Linux clipboard process error (${tool}): ${err.message}`);
771
+ settleFailure(`Linux clipboard tool ${tool} failed: ${err.message}`);
772
+ });
773
+ child.stderr.on('data', (chunk) => {
774
+ stderr += chunk.toString('utf8');
775
+ if (stderr.length > 2000) stderr = stderr.slice(-2000);
776
+ });
777
+ child.on('exit', (code, signal) => {
778
+ if (activeLinuxClipboardProc && activeLinuxClipboardProc.child === child) activeLinuxClipboardProc = null;
779
+ const extra = stderr.trim() ? ` stderr=${JSON.stringify(stderr.trim())}` : '';
780
+ log(`Linux clipboard process exited (${tool}) code=${code ?? 'null'} signal=${signal ?? 'null'}${extra}`);
781
+ if (!settled) {
782
+ if (tool === 'xclip' && code === 0 && !signal && !stderr.trim()) {
783
+ log('Linux clipboard xclip exited cleanly without stderr; treating clipboard arm as successful');
784
+ settleSuccess(false);
785
+ return;
786
+ }
787
+ const detail = stderr.trim() || `exit code ${code ?? 'null'} signal ${signal ?? 'null'}`;
788
+ settleFailure(`Linux clipboard tool ${tool} exited before paste: ${detail}`);
789
+ }
790
+ });
791
+ child.stdin.on('error', (err) => {
792
+ if (err.code === 'EPIPE') {
793
+ settleFailure(`Linux clipboard tool ${tool} closed its input early`);
794
+ return;
795
+ }
796
+ log(`Linux clipboard stdin error (${tool}): ${err.message}`);
797
+ settleFailure(`Linux clipboard tool ${tool} stdin failed: ${err.message}`);
798
+ });
799
+
800
+ child.stdin.end(imageBuffer);
801
+ log(`Linux clipboard process started (${tool}) pid=${child.pid ?? 'null'} type=${type} bytes=${imageBuffer.length}`);
802
+ readyTimer = setTimeout(() => settleSuccess(), LINUX_CLIPBOARD_READY_GRACE_MS);
803
+ });
804
+ }
805
+
806
+ async function startLinuxClipboardImage(tmpFile, mediaType) {
807
+ const type = String(mediaType || 'image/png').toLowerCase();
808
+ const imageBuffer = fs.readFileSync(tmpFile);
809
+ const availableTools = assertLinuxClipboardAvailable();
810
+ clearActiveLinuxClipboardProc('replace');
811
+
812
+ let lastErr = null;
813
+ for (const tool of availableTools) {
814
+ try {
815
+ return await spawnLinuxClipboardTool(tool, imageBuffer, type);
816
+ } catch (err) {
817
+ lastErr = err;
818
+ log(`Linux clipboard arm failed (${tool}): ${err.message}`);
819
+ }
820
+ }
821
+
822
+ throw lastErr || new Error('Linux clipboard could not be initialized');
823
+ }
758
824
 
759
825
  setInterval(() => {
760
826
  const now = Date.now();
@@ -1004,16 +1070,16 @@ wss.on('connection', (ws, req) => {
1004
1070
  sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
1005
1071
  break;
1006
1072
  }
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
- }
1073
+ cleanupImageUpload(uploadId);
1074
+ if (process.platform !== 'win32' && process.platform !== 'darwin') {
1075
+ try {
1076
+ const tools = assertLinuxClipboardAvailable();
1077
+ log(`Linux clipboard preflight OK: ${tools.join(', ')}`);
1078
+ } catch (err) {
1079
+ sendUploadStatus(ws, uploadId, 'error', { message: err.message });
1080
+ break;
1081
+ }
1082
+ }
1017
1083
  pendingImageUploads.set(uploadId, {
1018
1084
  id: uploadId,
1019
1085
  owner: ws,
@@ -1115,13 +1181,13 @@ wss.on('connection', (ws, req) => {
1115
1181
  sendUploadStatus(ws, uploadId, 'error', { message: 'Upload not ready' });
1116
1182
  break;
1117
1183
  }
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),
1184
+ try {
1185
+ await handlePreparedImageUpload({
1186
+ tmpFile: upload.tmpFile,
1187
+ mediaType: upload.mediaType,
1188
+ text: msg.text || '',
1189
+ logLabel: upload.name || uploadId,
1190
+ onCleanup: () => cleanupImageUpload(uploadId),
1125
1191
  });
1126
1192
  upload.submitted = true;
1127
1193
  upload.updatedAt = Date.now();
@@ -1749,62 +1815,85 @@ function restartClaude(newCwd) {
1749
1815
  // ============================================================
1750
1816
  // 5. Image Upload → Clipboard Injection
1751
1817
  // ============================================================
1752
- function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', onCleanup = null }) {
1753
- if (!claudeProc) throw new Error('Claude not running');
1754
- if (!tmpFile || !fs.existsSync(tmpFile)) throw new Error('Prepared image file missing');
1755
-
1756
- const isWin = process.platform === 'win32';
1757
- const isMac = process.platform === 'darwin';
1758
- try {
1759
- const stat = fs.statSync(tmpFile);
1760
- log(`Image ready: ${logLabel || path.basename(tmpFile)} (${stat.size} bytes)`);
1761
-
1762
- if (isWin) {
1763
- 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()`;
1764
- execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
1765
- } else if (isMac) {
1818
+ async function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', onCleanup = null }) {
1819
+ if (!claudeProc) throw new Error('Claude not running');
1820
+ if (!tmpFile || !fs.existsSync(tmpFile)) throw new Error('Prepared image file missing');
1821
+
1822
+ const isWin = process.platform === 'win32';
1823
+ const isMac = process.platform === 'darwin';
1824
+ const isLinux = !isWin && !isMac;
1825
+ if (isLinux && linuxImagePasteInFlight) {
1826
+ throw new Error('Another Linux image paste is still in progress. Please wait a moment and try again.');
1827
+ }
1828
+ try {
1829
+ const stat = fs.statSync(tmpFile);
1830
+ log(`Image ready: ${logLabel || path.basename(tmpFile)} (${stat.size} bytes)`);
1831
+ if (isLinux) setLinuxImagePasteInFlight(true, 'prepare_upload');
1832
+
1833
+ if (isWin) {
1834
+ 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()`;
1835
+ execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
1836
+ } else if (isMac) {
1766
1837
  execSync(`osascript -e 'set the clipboard to (read POSIX file "${tmpFile}" as 芦class PNGf禄)'`, { timeout: 10000 });
1767
- } else {
1768
- const tool = startLinuxClipboardImage(tmpFile, mediaType);
1769
- log(`Linux clipboard armed with ${tool}`);
1770
- }
1771
- log('Clipboard set with image');
1772
-
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);
1785
-
1786
- setTimeout(() => {
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();
1796
- else {
1797
- try { fs.unlinkSync(tmpFile); } catch {}
1798
- }
1799
- }, 5000);
1800
- }, 150);
1801
- }, 1000 + pasteDelayMs);
1802
- } catch (err) {
1803
- log(`Image upload error: ${err.message}`);
1804
- if (onCleanup) onCleanup();
1805
- else {
1806
- try { fs.unlinkSync(tmpFile); } catch {}
1807
- }
1838
+ } else {
1839
+ const tool = await startLinuxClipboardImage(tmpFile, mediaType);
1840
+ log(`Linux clipboard armed with ${tool}`);
1841
+ }
1842
+ log('Clipboard set with image');
1843
+
1844
+ const pasteDelayMs = isWin || isMac ? 0 : 150;
1845
+ await new Promise((resolve, reject) => {
1846
+ setTimeout(() => {
1847
+ if (!claudeProc) {
1848
+ reject(new Error('Claude stopped before image paste'));
1849
+ return;
1850
+ }
1851
+ if (isWin) claudeProc.write('\x1bv');
1852
+ else claudeProc.write('\x16');
1853
+ log('Sent image paste keypress to PTY');
1854
+
1855
+ setTimeout(() => {
1856
+ if (!claudeProc) {
1857
+ reject(new Error('Claude stopped before image prompt'));
1858
+ return;
1859
+ }
1860
+ const trimmedText = (text || '').trim();
1861
+ if (trimmedText) claudeProc.write(trimmedText);
1862
+
1863
+ setTimeout(() => {
1864
+ if (!claudeProc) {
1865
+ reject(new Error('Claude stopped before image submit'));
1866
+ return;
1867
+ }
1868
+ claudeProc.write('\r');
1869
+ log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
1870
+
1871
+ if (isLinux) {
1872
+ setTimeout(() => clearActiveLinuxClipboardProc('post-paste'), 1000);
1873
+ }
1874
+
1875
+ setTimeout(() => {
1876
+ if (isLinux) setLinuxImagePasteInFlight(false, 'cleanup');
1877
+ if (onCleanup) onCleanup();
1878
+ else {
1879
+ try { fs.unlinkSync(tmpFile); } catch {}
1880
+ }
1881
+ }, 5000);
1882
+ resolve();
1883
+ }, 150);
1884
+ }, 1000);
1885
+ }, pasteDelayMs);
1886
+ });
1887
+ } catch (err) {
1888
+ log(`Image upload error: ${err.message}`);
1889
+ if (isLinux) {
1890
+ clearActiveLinuxClipboardProc('error');
1891
+ setLinuxImagePasteInFlight(false, 'error');
1892
+ }
1893
+ if (onCleanup) onCleanup();
1894
+ else {
1895
+ try { fs.unlinkSync(tmpFile); } catch {}
1896
+ }
1808
1897
  throw err;
1809
1898
  }
1810
1899
  }
@@ -1820,25 +1909,28 @@ function handleImageUpload(msg) {
1820
1909
  }
1821
1910
  let tmpFile = null;
1822
1911
 
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) {
1838
- log(`Image upload error: ${err.message}`);
1839
- try { fs.unlinkSync(tmpFile); } catch {}
1840
- }
1841
- }
1912
+ try {
1913
+ if (process.platform !== 'win32' && process.platform !== 'darwin') {
1914
+ const tools = assertLinuxClipboardAvailable();
1915
+ log(`Linux clipboard preflight OK (legacy upload): ${tools.join(', ')}`);
1916
+ }
1917
+ const buf = Buffer.from(msg.base64, 'base64');
1918
+ tmpFile = createTempImageFile(buf, msg.mediaType, `legacy_${Date.now()}`);
1919
+ log(`Image saved: ${tmpFile} (${buf.length} bytes)`);
1920
+ handlePreparedImageUpload({
1921
+ tmpFile,
1922
+ mediaType: msg.mediaType,
1923
+ text: msg.text || '',
1924
+ }).then(() => {
1925
+ setTurnState('running', { reason: 'legacy_image_upload' });
1926
+ }).catch((err) => {
1927
+ log(`Image upload error: ${err.message}`);
1928
+ });
1929
+ } catch (err) {
1930
+ log(`Image upload error: ${err.message}`);
1931
+ try { fs.unlinkSync(tmpFile); } catch {}
1932
+ }
1933
+ }
1842
1934
 
1843
1935
  // ============================================================
1844
1936
  // 6. Hook Auto-Setup