claude-remote 0.4.6 → 0.5.0

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 +469 -232
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
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
@@ -184,22 +184,23 @@ let pendingInitialClearTranscript = null; // { sessionId }
184
184
  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
- const LEGACY_REPLAY_DELAY_MS = 1500;
187
+ const LEGACY_REPLAY_DELAY_MS = 1500;
188
188
  const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
189
189
  const LINUX_CLIPBOARD_READY_GRACE_MS = 400;
190
- const IMAGE_UPLOAD_DIR = path.join(CLAUDE_HOME, 'remote-uploads');
191
- let turnStateVersion = 0;
190
+ const LINUX_AT_PROMPT_SUBMIT_DELAY_MS = 450;
191
+ const LINUX_AT_IMAGE_CLEANUP_DELAY_MS = 10 * 60 * 1000;
192
+ let turnStateVersion = 0;
192
193
  let turnState = {
193
194
  phase: 'idle',
194
195
  sessionId: null,
195
196
  version: 0,
196
197
  updatedAt: Date.now(),
197
198
  };
198
- let ttyInputForwarderAttached = false;
199
- let ttyInputHandler = null;
200
- let ttyResizeHandler = null;
201
- let activeLinuxClipboardProc = null;
202
- let linuxImagePasteInFlight = false;
199
+ let ttyInputForwarderAttached = false;
200
+ let ttyInputHandler = null;
201
+ let ttyResizeHandler = null;
202
+ let activeLinuxClipboardProc = null;
203
+ let linuxImagePasteInFlight = false;
203
204
 
204
205
  // --- Permission approval state ---
205
206
  let approvalSeq = 0;
@@ -222,10 +223,10 @@ function formatTtyInputChunk(chunk) {
222
223
  return `len=${buf.length} hex=${buf.toString('hex')} base64=${buf.toString('base64')} utf8=${JSON.stringify(buf.toString('utf8'))}`;
223
224
  }
224
225
 
225
- function clearActiveLinuxClipboardProc(reason = '') {
226
- if (!activeLinuxClipboardProc) return;
227
- const { child, tool } = activeLinuxClipboardProc;
228
- activeLinuxClipboardProc = null;
226
+ function clearActiveLinuxClipboardProc(reason = '') {
227
+ if (!activeLinuxClipboardProc) return;
228
+ const { child, tool } = activeLinuxClipboardProc;
229
+ activeLinuxClipboardProc = null;
229
230
  try {
230
231
  child.kill('SIGTERM');
231
232
  log(`Linux clipboard process terminated (${tool}) reason=${reason || 'cleanup'}`);
@@ -683,136 +684,362 @@ function cleanupClientUploads(ws) {
683
684
  }
684
685
  }
685
686
 
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());
691
- const type = String(mediaType || 'image/png').toLowerCase();
692
- const ext = type.includes('jpeg') || type.includes('jpg') ? '.jpg' : '.png';
693
- fs.mkdirSync(tmpDir, { recursive: true });
694
- const tmpFile = path.join(tmpDir, `bridge_upload_${uploadId}_${Date.now()}${ext}`);
695
- fs.writeFileSync(tmpFile, buffer);
696
- return tmpFile;
697
- }
698
-
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}`);
687
+ function createTempImageFile(buffer, mediaType, uploadId) {
688
+ const isLinux = process.platform !== 'win32' && process.platform !== 'darwin';
689
+ const tmpDir = isLinux
690
+ ? path.join(CWD, 'tmp')
691
+ : (process.env.CLAUDE_CODE_TMPDIR || os.tmpdir());
692
+ const type = String(mediaType || 'image/png').toLowerCase();
693
+ const ext = type.includes('jpeg') || type.includes('jpg') ? '.jpg' : '.png';
694
+ fs.mkdirSync(tmpDir, { recursive: true });
695
+ const tmpFile = path.join(tmpDir, `bridge_upload_${uploadId}_${Date.now()}${ext}`);
696
+ fs.writeFileSync(tmpFile, buffer);
697
+ return tmpFile;
715
698
  }
716
699
 
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;
700
+ function toClaudeAtPath(filePath) {
701
+ const normalized = path.normalize(String(filePath || ''));
702
+ const rel = path.relative(CWD, normalized);
703
+ const inProject = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
704
+ const target = inProject ? rel : normalized;
705
+ return target.split(path.sep).join('/');
723
706
  }
724
707
 
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
- ? ['-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) {
798
- const type = String(mediaType || 'image/png').toLowerCase();
799
- const imageBuffer = fs.readFileSync(tmpFile);
800
- const availableTools = assertLinuxClipboardAvailable();
801
- clearActiveLinuxClipboardProc('replace');
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');
708
+ function buildLinuxImagePrompt(text, tmpFile) {
709
+ const trimmedText = String(text || '').trim();
710
+ const atPath = `@${toClaudeAtPath(tmpFile)}`;
711
+ return trimmedText ? `${trimmedText} ${atPath}` : atPath;
814
712
  }
815
713
 
714
+ function isLinuxClipboardToolInstalled(tool) {
715
+ try {
716
+ execSync(`command -v ${tool} >/dev/null 2>&1`, {
717
+ stdio: 'ignore',
718
+ shell: '/bin/sh',
719
+ timeout: 2000,
720
+ });
721
+ return true;
722
+ } catch {
723
+ return false;
724
+ }
725
+ }
726
+
727
+ function setLinuxImagePasteInFlight(active, reason = '') {
728
+ linuxImagePasteInFlight = !!active;
729
+ if (reason) log(`Linux image paste lock=${linuxImagePasteInFlight ? 'on' : 'off'} reason=${reason}`);
730
+ }
731
+
732
+ function normalizeLinuxEnvVar(value) {
733
+ const text = String(value || '').trim();
734
+ return text || null;
735
+ }
736
+
737
+ function parseLinuxProcStatusUid(statusText) {
738
+ const match = String(statusText || '').match(/^Uid:\s+(\d+)/m);
739
+ return match ? Number(match[1]) : null;
740
+ }
741
+
742
+ function readLinuxProcGuiEnv(pid) {
743
+ try {
744
+ const statusPath = `/proc/${pid}/status`;
745
+ const environPath = `/proc/${pid}/environ`;
746
+ const statusText = fs.readFileSync(statusPath, 'utf8');
747
+ const currentUid = typeof process.getuid === 'function' ? process.getuid() : null;
748
+ if (currentUid != null) {
749
+ const procUid = parseLinuxProcStatusUid(statusText);
750
+ if (procUid == null || procUid !== currentUid) return null;
751
+ }
752
+
753
+ const envRaw = fs.readFileSync(environPath, 'utf8');
754
+ if (!envRaw) return null;
755
+ let waylandDisplay = null;
756
+ let display = null;
757
+ let runtimeDir = null;
758
+ let xAuthority = null;
759
+
760
+ for (const entry of envRaw.split('\0')) {
761
+ if (!entry) continue;
762
+ if (entry.startsWith('WAYLAND_DISPLAY=')) waylandDisplay = normalizeLinuxEnvVar(entry.slice('WAYLAND_DISPLAY='.length));
763
+ else if (entry.startsWith('DISPLAY=')) display = normalizeLinuxEnvVar(entry.slice('DISPLAY='.length));
764
+ else if (entry.startsWith('XDG_RUNTIME_DIR=')) runtimeDir = normalizeLinuxEnvVar(entry.slice('XDG_RUNTIME_DIR='.length));
765
+ else if (entry.startsWith('XAUTHORITY=')) xAuthority = normalizeLinuxEnvVar(entry.slice('XAUTHORITY='.length));
766
+ }
767
+
768
+ if (!waylandDisplay && !display) return null;
769
+ return { waylandDisplay, display, runtimeDir, xAuthority };
770
+ } catch {
771
+ return null;
772
+ }
773
+ }
774
+
775
+ function discoverLinuxGuiEnvFromProc() {
776
+ if (process.platform === 'win32' || process.platform === 'darwin') return null;
777
+ let entries = [];
778
+ try {
779
+ entries = fs.readdirSync('/proc', { withFileTypes: true });
780
+ } catch {
781
+ return null;
782
+ }
783
+
784
+ for (const entry of entries) {
785
+ if (!entry.isDirectory()) continue;
786
+ if (!/^\d+$/.test(entry.name)) continue;
787
+ if (Number(entry.name) === process.pid) continue;
788
+ const discovered = readLinuxProcGuiEnv(entry.name);
789
+ if (discovered) return discovered;
790
+ }
791
+ return null;
792
+ }
793
+
794
+ function discoverLinuxGuiEnvFromSocket() {
795
+ if (process.platform === 'win32' || process.platform === 'darwin') return null;
796
+ const discovered = {
797
+ waylandDisplay: null,
798
+ display: null,
799
+ runtimeDir: null,
800
+ xAuthority: null,
801
+ };
802
+
803
+ const currentUid = typeof process.getuid === 'function' ? process.getuid() : null;
804
+ const runtimeDir = currentUid != null ? `/run/user/${currentUid}` : null;
805
+ if (runtimeDir && fs.existsSync(runtimeDir)) {
806
+ discovered.runtimeDir = runtimeDir;
807
+ try {
808
+ const entries = fs.readdirSync(runtimeDir);
809
+ const waylandSockets = entries.filter(name => /^wayland-\d+$/.test(name)).sort();
810
+ if (waylandSockets.length > 0) discovered.waylandDisplay = waylandSockets[0];
811
+ } catch {}
812
+ }
813
+
814
+ try {
815
+ const xEntries = fs.readdirSync('/tmp/.X11-unix');
816
+ const displaySockets = xEntries
817
+ .map(name => {
818
+ const match = /^X(\d+)$/.exec(name);
819
+ return match ? Number(match[1]) : null;
820
+ })
821
+ .filter(num => Number.isInteger(num))
822
+ .sort((a, b) => a - b);
823
+ if (displaySockets.length > 0) discovered.display = `:${displaySockets[0]}`;
824
+ } catch {}
825
+
826
+ if (!discovered.waylandDisplay && !discovered.display) return null;
827
+ return discovered;
828
+ }
829
+
830
+ function getLinuxClipboardEnv() {
831
+ if (process.platform === 'win32' || process.platform === 'darwin') {
832
+ return { env: process.env, source: 'not_linux' };
833
+ }
834
+
835
+ const overlay = {
836
+ WAYLAND_DISPLAY: normalizeLinuxEnvVar(process.env.CLAUDE_REMOTE_WAYLAND_DISPLAY) || normalizeLinuxEnvVar(process.env.WAYLAND_DISPLAY),
837
+ DISPLAY: normalizeLinuxEnvVar(process.env.CLAUDE_REMOTE_DISPLAY) || normalizeLinuxEnvVar(process.env.DISPLAY),
838
+ XDG_RUNTIME_DIR: normalizeLinuxEnvVar(process.env.CLAUDE_REMOTE_XDG_RUNTIME_DIR) || normalizeLinuxEnvVar(process.env.XDG_RUNTIME_DIR),
839
+ XAUTHORITY: normalizeLinuxEnvVar(process.env.CLAUDE_REMOTE_XAUTHORITY) || normalizeLinuxEnvVar(process.env.XAUTHORITY),
840
+ };
841
+
842
+ let source = 'process_env';
843
+ const needsSocketDiscovery =
844
+ (!overlay.WAYLAND_DISPLAY && !overlay.DISPLAY) ||
845
+ (!!overlay.WAYLAND_DISPLAY && !overlay.XDG_RUNTIME_DIR);
846
+ if (needsSocketDiscovery) {
847
+ const before = {
848
+ waylandDisplay: overlay.WAYLAND_DISPLAY,
849
+ display: overlay.DISPLAY,
850
+ runtimeDir: overlay.XDG_RUNTIME_DIR,
851
+ xAuthority: overlay.XAUTHORITY,
852
+ };
853
+ const fromSocket = discoverLinuxGuiEnvFromSocket();
854
+ if (fromSocket) {
855
+ if (!overlay.WAYLAND_DISPLAY && fromSocket.waylandDisplay) overlay.WAYLAND_DISPLAY = fromSocket.waylandDisplay;
856
+ if (!overlay.DISPLAY && fromSocket.display) overlay.DISPLAY = fromSocket.display;
857
+ if (!overlay.XDG_RUNTIME_DIR && fromSocket.runtimeDir) overlay.XDG_RUNTIME_DIR = fromSocket.runtimeDir;
858
+ if (!overlay.XAUTHORITY && fromSocket.xAuthority) overlay.XAUTHORITY = fromSocket.xAuthority;
859
+ const changed =
860
+ before.waylandDisplay !== overlay.WAYLAND_DISPLAY ||
861
+ before.display !== overlay.DISPLAY ||
862
+ before.runtimeDir !== overlay.XDG_RUNTIME_DIR ||
863
+ before.xAuthority !== overlay.XAUTHORITY;
864
+ if (changed) source = 'socket_discovery';
865
+ }
866
+ }
867
+
868
+ const needsProcDiscovery =
869
+ (!overlay.WAYLAND_DISPLAY && !overlay.DISPLAY) ||
870
+ (!!overlay.DISPLAY && !overlay.XAUTHORITY) ||
871
+ (!!overlay.WAYLAND_DISPLAY && !overlay.XDG_RUNTIME_DIR);
872
+ if (needsProcDiscovery) {
873
+ const before = {
874
+ waylandDisplay: overlay.WAYLAND_DISPLAY,
875
+ display: overlay.DISPLAY,
876
+ runtimeDir: overlay.XDG_RUNTIME_DIR,
877
+ xAuthority: overlay.XAUTHORITY,
878
+ };
879
+ const fromProc = discoverLinuxGuiEnvFromProc();
880
+ if (fromProc) {
881
+ if (!overlay.WAYLAND_DISPLAY && fromProc.waylandDisplay) overlay.WAYLAND_DISPLAY = fromProc.waylandDisplay;
882
+ if (!overlay.DISPLAY && fromProc.display) overlay.DISPLAY = fromProc.display;
883
+ if (!overlay.XDG_RUNTIME_DIR && fromProc.runtimeDir) overlay.XDG_RUNTIME_DIR = fromProc.runtimeDir;
884
+ if (!overlay.XAUTHORITY && fromProc.xAuthority) overlay.XAUTHORITY = fromProc.xAuthority;
885
+ const changed =
886
+ before.waylandDisplay !== overlay.WAYLAND_DISPLAY ||
887
+ before.display !== overlay.DISPLAY ||
888
+ before.runtimeDir !== overlay.XDG_RUNTIME_DIR ||
889
+ before.xAuthority !== overlay.XAUTHORITY;
890
+ if (changed) {
891
+ source = source === 'socket_discovery' ? 'socket+proc_discovery' : 'proc_discovery';
892
+ }
893
+ }
894
+ }
895
+
896
+ const env = { ...process.env };
897
+ if (overlay.WAYLAND_DISPLAY) env.WAYLAND_DISPLAY = overlay.WAYLAND_DISPLAY;
898
+ if (overlay.DISPLAY) env.DISPLAY = overlay.DISPLAY;
899
+ if (overlay.XDG_RUNTIME_DIR) env.XDG_RUNTIME_DIR = overlay.XDG_RUNTIME_DIR;
900
+ if (overlay.XAUTHORITY) env.XAUTHORITY = overlay.XAUTHORITY;
901
+
902
+ return {
903
+ env,
904
+ source,
905
+ waylandDisplay: overlay.WAYLAND_DISPLAY || null,
906
+ display: overlay.DISPLAY || null,
907
+ runtimeDir: overlay.XDG_RUNTIME_DIR || null,
908
+ xAuthority: overlay.XAUTHORITY || null,
909
+ };
910
+ }
911
+
912
+ function getLinuxClipboardToolCandidates(clipboardEnv = process.env) {
913
+ if (process.platform === 'win32' || process.platform === 'darwin') return [];
914
+ const preferred = [];
915
+ if (clipboardEnv.WAYLAND_DISPLAY) preferred.push('wl-copy');
916
+ if (clipboardEnv.DISPLAY) preferred.push('xclip');
917
+ return preferred;
918
+ }
919
+
920
+ function assertLinuxClipboardAvailable() {
921
+ const gui = getLinuxClipboardEnv();
922
+ const candidates = getLinuxClipboardToolCandidates(gui.env);
923
+ const available = candidates.filter(isLinuxClipboardToolInstalled);
924
+ if (available.length > 0) {
925
+ return {
926
+ tools: available,
927
+ env: gui.env,
928
+ source: gui.source,
929
+ waylandDisplay: gui.waylandDisplay,
930
+ display: gui.display,
931
+ runtimeDir: gui.runtimeDir,
932
+ xAuthority: gui.xAuthority,
933
+ };
934
+ }
935
+ if (!gui.waylandDisplay && !gui.display) {
936
+ throw new Error('Linux image paste requires a graphical session. Could not detect WAYLAND_DISPLAY or DISPLAY (common in pm2/systemd). Set CLAUDE_REMOTE_DISPLAY or CLAUDE_REMOTE_WAYLAND_DISPLAY and retry.');
937
+ }
938
+ throw new Error('Linux image paste requires wl-copy or xclip on the server. Install a matching clipboard tool and try again.');
939
+ }
940
+
941
+ function formatLinuxClipboardEnvLog(info) {
942
+ if (!info) return '';
943
+ const parts = [];
944
+ if (info.waylandDisplay) parts.push(`WAYLAND_DISPLAY=${info.waylandDisplay}`);
945
+ if (info.display) parts.push(`DISPLAY=${info.display}`);
946
+ if (info.runtimeDir) parts.push(`XDG_RUNTIME_DIR=${info.runtimeDir}`);
947
+ if (info.xAuthority) parts.push(`XAUTHORITY=${info.xAuthority}`);
948
+ return parts.length ? ` env[${parts.join(', ')}]` : '';
949
+ }
950
+
951
+ function spawnLinuxClipboardTool(tool, imageBuffer, type, clipboardEnv) {
952
+ return new Promise((resolve, reject) => {
953
+ const args = tool === 'xclip'
954
+ ? ['-quiet', '-selection', 'clipboard', '-t', type, '-i']
955
+ : ['--type', type];
956
+ const child = spawn(tool, args, {
957
+ detached: true,
958
+ stdio: ['pipe', 'ignore', 'pipe'],
959
+ env: clipboardEnv || process.env,
960
+ });
961
+ let settled = false;
962
+ let stderr = '';
963
+ let readyTimer = null;
964
+
965
+ const settleFailure = (message) => {
966
+ if (settled) return;
967
+ settled = true;
968
+ if (readyTimer) clearTimeout(readyTimer);
969
+ if (child.exitCode == null && child.signalCode == null) {
970
+ try { child.kill('SIGTERM'); } catch {}
971
+ }
972
+ reject(new Error(message));
973
+ };
974
+
975
+ const settleSuccess = (trackProcess = true) => {
976
+ if (settled) return;
977
+ settled = true;
978
+ if (readyTimer) clearTimeout(readyTimer);
979
+ if (trackProcess && child.exitCode == null && child.signalCode == null) {
980
+ activeLinuxClipboardProc = { child, tool };
981
+ child.unref();
982
+ }
983
+ resolve(tool);
984
+ };
985
+
986
+ child.on('error', (err) => {
987
+ log(`Linux clipboard process error (${tool}): ${err.message}`);
988
+ settleFailure(`Linux clipboard tool ${tool} failed: ${err.message}`);
989
+ });
990
+ child.stderr.on('data', (chunk) => {
991
+ stderr += chunk.toString('utf8');
992
+ if (stderr.length > 2000) stderr = stderr.slice(-2000);
993
+ });
994
+ child.on('exit', (code, signal) => {
995
+ if (activeLinuxClipboardProc && activeLinuxClipboardProc.child === child) activeLinuxClipboardProc = null;
996
+ const extra = stderr.trim() ? ` stderr=${JSON.stringify(stderr.trim())}` : '';
997
+ log(`Linux clipboard process exited (${tool}) code=${code ?? 'null'} signal=${signal ?? 'null'}${extra}`);
998
+ if (!settled) {
999
+ if (tool === 'xclip' && code === 0 && !signal && !stderr.trim()) {
1000
+ log('Linux clipboard xclip exited cleanly without stderr; treating clipboard arm as successful');
1001
+ settleSuccess(false);
1002
+ return;
1003
+ }
1004
+ const detail = stderr.trim() || `exit code ${code ?? 'null'} signal ${signal ?? 'null'}`;
1005
+ settleFailure(`Linux clipboard tool ${tool} exited before paste: ${detail}`);
1006
+ }
1007
+ });
1008
+ child.stdin.on('error', (err) => {
1009
+ if (err.code === 'EPIPE') {
1010
+ settleFailure(`Linux clipboard tool ${tool} closed its input early`);
1011
+ return;
1012
+ }
1013
+ log(`Linux clipboard stdin error (${tool}): ${err.message}`);
1014
+ settleFailure(`Linux clipboard tool ${tool} stdin failed: ${err.message}`);
1015
+ });
1016
+
1017
+ child.stdin.end(imageBuffer);
1018
+ log(`Linux clipboard process started (${tool}) pid=${child.pid ?? 'null'} type=${type} bytes=${imageBuffer.length}`);
1019
+ readyTimer = setTimeout(() => settleSuccess(), LINUX_CLIPBOARD_READY_GRACE_MS);
1020
+ });
1021
+ }
1022
+
1023
+ async function startLinuxClipboardImage(tmpFile, mediaType, clipboardInfo = null) {
1024
+ const type = String(mediaType || 'image/png').toLowerCase();
1025
+ const imageBuffer = fs.readFileSync(tmpFile);
1026
+ const resolved = clipboardInfo || assertLinuxClipboardAvailable();
1027
+ const availableTools = resolved.tools;
1028
+ clearActiveLinuxClipboardProc('replace');
1029
+
1030
+ let lastErr = null;
1031
+ for (const tool of availableTools) {
1032
+ try {
1033
+ return await spawnLinuxClipboardTool(tool, imageBuffer, type, resolved.env);
1034
+ } catch (err) {
1035
+ lastErr = err;
1036
+ log(`Linux clipboard arm failed (${tool}): ${err.message}`);
1037
+ }
1038
+ }
1039
+
1040
+ throw lastErr || new Error('Linux clipboard could not be initialized');
1041
+ }
1042
+
816
1043
  setInterval(() => {
817
1044
  const now = Date.now();
818
1045
  for (const [uploadId, upload] of pendingImageUploads) {
@@ -1055,26 +1282,17 @@ wss.on('connection', (ws, req) => {
1055
1282
  }
1056
1283
  break;
1057
1284
  }
1058
- case 'image_upload_init': {
1059
- const uploadId = String(msg.uploadId || '');
1060
- if (!uploadId) {
1061
- sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
1062
- break;
1063
- }
1064
- cleanupImageUpload(uploadId);
1065
- if (process.platform !== 'win32' && process.platform !== 'darwin') {
1066
- try {
1067
- const tools = assertLinuxClipboardAvailable();
1068
- log(`Linux clipboard preflight OK: ${tools.join(', ')}`);
1069
- } catch (err) {
1070
- sendUploadStatus(ws, uploadId, 'error', { message: err.message });
1071
- break;
1072
- }
1285
+ case 'image_upload_init': {
1286
+ const uploadId = String(msg.uploadId || '');
1287
+ if (!uploadId) {
1288
+ sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
1289
+ break;
1073
1290
  }
1074
- pendingImageUploads.set(uploadId, {
1075
- id: uploadId,
1076
- owner: ws,
1077
- mediaType: msg.mediaType || 'image/png',
1291
+ cleanupImageUpload(uploadId);
1292
+ pendingImageUploads.set(uploadId, {
1293
+ id: uploadId,
1294
+ owner: ws,
1295
+ mediaType: msg.mediaType || 'image/png',
1078
1296
  name: msg.name || 'image',
1079
1297
  totalBytes: Number.isFinite(msg.totalBytes) ? msg.totalBytes : 0,
1080
1298
  totalChunks: Number.isFinite(msg.totalChunks) ? msg.totalChunks : 0,
@@ -1172,13 +1390,13 @@ wss.on('connection', (ws, req) => {
1172
1390
  sendUploadStatus(ws, uploadId, 'error', { message: 'Upload not ready' });
1173
1391
  break;
1174
1392
  }
1175
- try {
1176
- await handlePreparedImageUpload({
1177
- tmpFile: upload.tmpFile,
1178
- mediaType: upload.mediaType,
1179
- text: msg.text || '',
1180
- logLabel: upload.name || uploadId,
1181
- onCleanup: () => cleanupImageUpload(uploadId),
1393
+ try {
1394
+ await handlePreparedImageUpload({
1395
+ tmpFile: upload.tmpFile,
1396
+ mediaType: upload.mediaType,
1397
+ text: msg.text || '',
1398
+ logLabel: upload.name || uploadId,
1399
+ onCleanup: () => cleanupImageUpload(uploadId),
1182
1400
  });
1183
1401
  upload.submitted = true;
1184
1402
  upload.updatedAt = Date.now();
@@ -1387,11 +1605,20 @@ function extractSlashCommand(content) {
1387
1605
  return inlineMatch ? inlineMatch[1].trim().toLowerCase() : '';
1388
1606
  }
1389
1607
 
1608
+ function isUserInterruptEvent(content) {
1609
+ const text = flattenUserContent(content)
1610
+ .replace(/\x1B\[[0-9;]*m/g, '')
1611
+ .trim();
1612
+ if (!text) return false;
1613
+ return /(?:^|\n)\[Request interrupted by user(?: for tool use)?\](?:\r?\n|$)/i.test(text);
1614
+ }
1615
+
1390
1616
  function isNonAiUserEvent(event, content) {
1391
1617
  if (!event || typeof event !== 'object') return false;
1392
1618
  if (event.isMeta === true) return true;
1393
1619
  if (event.isCompactSummary === true) return true;
1394
1620
  if (event.isVisibleInTranscriptOnly === true) return true;
1621
+ if (isUserInterruptEvent(content)) return true;
1395
1622
 
1396
1623
  const text = flattenUserContent(content).trim();
1397
1624
  if (!text) return false;
@@ -1545,12 +1772,16 @@ function startTailing() {
1545
1772
  if (event.type === 'user' || (event.message && event.message.role === 'user')) {
1546
1773
  const content = event.message && event.message.content;
1547
1774
  const slashCommand = extractSlashCommand(content);
1775
+ const isInterruptedUserEvent = isUserInterruptEvent(content);
1548
1776
  const isPassiveUserEvent = isNonAiUserEvent(event, content);
1549
1777
  const ignoreInitialClear = (
1550
1778
  slashCommand === '/clear' &&
1551
1779
  pendingInitialClearTranscript &&
1552
1780
  pendingInitialClearTranscript.sessionId === currentSessionId
1553
1781
  );
1782
+ if (!tailCatchingUp && isInterruptedUserEvent) {
1783
+ setTurnState('idle', { sessionId: currentSessionId, reason: 'transcript_user_interrupt' });
1784
+ }
1554
1785
  // Only live, AI-producing user messages can move the turn state
1555
1786
  // into running. Historical replay and slash commands are ignored.
1556
1787
  if (!tailCatchingUp && !slashCommand && !isPassiveUserEvent) {
@@ -1806,81 +2037,91 @@ function restartClaude(newCwd) {
1806
2037
  // ============================================================
1807
2038
  // 5. Image Upload → Clipboard Injection
1808
2039
  // ============================================================
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
-
2040
+ async function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', onCleanup = null }) {
2041
+ if (!claudeProc) throw new Error('Claude not running');
2042
+ if (!tmpFile || !fs.existsSync(tmpFile)) throw new Error('Prepared image file missing');
2043
+
1813
2044
  const isWin = process.platform === 'win32';
1814
2045
  const isMac = process.platform === 'darwin';
1815
2046
  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
- }
1819
2047
  try {
1820
2048
  const stat = fs.statSync(tmpFile);
1821
2049
  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) {
1828
- execSync(`osascript -e 'set the clipboard to (read POSIX file "${tmpFile}" as 芦class PNGf禄)'`, { timeout: 10000 });
1829
- } else {
1830
- const tool = await startLinuxClipboardImage(tmpFile, mediaType);
1831
- log(`Linux clipboard armed with ${tool}`);
1832
- }
1833
- log('Clipboard set with image');
1834
-
1835
- const pasteDelayMs = isWin || isMac ? 0 : 150;
1836
- await new Promise((resolve, reject) => {
1837
- setTimeout(() => {
2050
+ if (isLinux) {
2051
+ const linuxPrompt = buildLinuxImagePrompt(text, tmpFile);
2052
+ await new Promise((resolve, reject) => {
1838
2053
  if (!claudeProc) {
1839
- reject(new Error('Claude stopped before image paste'));
2054
+ reject(new Error('Claude stopped before Linux image submit'));
1840
2055
  return;
1841
2056
  }
1842
- if (isWin) claudeProc.write('\x1bv');
1843
- else claudeProc.write('\x16');
1844
- log('Sent image paste keypress to PTY');
1845
-
2057
+ claudeProc.write(linuxPrompt);
1846
2058
  setTimeout(() => {
1847
2059
  if (!claudeProc) {
1848
- reject(new Error('Claude stopped before image prompt'));
2060
+ reject(new Error('Claude stopped before Linux image submit'));
1849
2061
  return;
1850
2062
  }
1851
- const trimmedText = (text || '').trim();
1852
- if (trimmedText) claudeProc.write(trimmedText);
1853
-
2063
+ claudeProc.write('\r');
2064
+ log(`Sent Linux image prompt via @ref: "${linuxPrompt.substring(0, 120)}"`);
1854
2065
  setTimeout(() => {
1855
- if (!claudeProc) {
1856
- reject(new Error('Claude stopped before image submit'));
1857
- return;
2066
+ if (onCleanup) onCleanup();
2067
+ else {
2068
+ try { fs.unlinkSync(tmpFile); } catch {}
1858
2069
  }
1859
- claudeProc.write('\r');
1860
- log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
2070
+ }, LINUX_AT_IMAGE_CLEANUP_DELAY_MS);
2071
+ resolve();
2072
+ }, LINUX_AT_PROMPT_SUBMIT_DELAY_MS);
2073
+ });
2074
+ return;
2075
+ }
1861
2076
 
1862
- if (isLinux) {
1863
- setTimeout(() => clearActiveLinuxClipboardProc('post-paste'), 1000);
2077
+ if (isWin) {
2078
+ 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()`;
2079
+ execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
2080
+ } else if (isMac) {
2081
+ execSync(`osascript -e 'set the clipboard to (read POSIX file "${tmpFile}" as 芦class PNGf禄)'`, { timeout: 10000 });
2082
+ }
2083
+ log('Clipboard set with image');
2084
+
2085
+ const pasteDelayMs = isWin || isMac ? 0 : 150;
2086
+ await new Promise((resolve, reject) => {
2087
+ setTimeout(() => {
2088
+ if (!claudeProc) {
2089
+ reject(new Error('Claude stopped before image paste'));
2090
+ return;
2091
+ }
2092
+ if (isWin) claudeProc.write('\x1bv');
2093
+ else claudeProc.write('\x16');
2094
+ log('Sent image paste keypress to PTY');
2095
+
2096
+ setTimeout(() => {
2097
+ if (!claudeProc) {
2098
+ reject(new Error('Claude stopped before image prompt'));
2099
+ return;
2100
+ }
2101
+ const trimmedText = (text || '').trim();
2102
+ if (trimmedText) claudeProc.write(trimmedText);
2103
+
2104
+ setTimeout(() => {
2105
+ if (!claudeProc) {
2106
+ reject(new Error('Claude stopped before image submit'));
2107
+ return;
1864
2108
  }
2109
+ claudeProc.write('\r');
2110
+ log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
1865
2111
 
1866
2112
  setTimeout(() => {
1867
- if (isLinux) setLinuxImagePasteInFlight(false, 'cleanup');
1868
2113
  if (onCleanup) onCleanup();
1869
2114
  else {
1870
2115
  try { fs.unlinkSync(tmpFile); } catch {}
1871
2116
  }
1872
- }, 5000);
1873
- resolve();
1874
- }, 150);
1875
- }, 1000);
1876
- }, pasteDelayMs);
1877
- });
2117
+ }, 5000);
2118
+ resolve();
2119
+ }, 150);
2120
+ }, 1000);
2121
+ }, pasteDelayMs);
2122
+ });
1878
2123
  } catch (err) {
1879
2124
  log(`Image upload error: ${err.message}`);
1880
- if (isLinux) {
1881
- clearActiveLinuxClipboardProc('error');
1882
- setLinuxImagePasteInFlight(false, 'error');
1883
- }
1884
2125
  if (onCleanup) onCleanup();
1885
2126
  else {
1886
2127
  try { fs.unlinkSync(tmpFile); } catch {}
@@ -1898,30 +2139,26 @@ function handleImageUpload(msg) {
1898
2139
  log('Image upload ignored: no base64 data');
1899
2140
  return;
1900
2141
  }
1901
- let tmpFile = null;
1902
-
2142
+ let tmpFile = null;
2143
+
1903
2144
  try {
1904
- if (process.platform !== 'win32' && process.platform !== 'darwin') {
1905
- const tools = assertLinuxClipboardAvailable();
1906
- log(`Linux clipboard preflight OK (legacy upload): ${tools.join(', ')}`);
1907
- }
1908
2145
  const buf = Buffer.from(msg.base64, 'base64');
1909
2146
  tmpFile = createTempImageFile(buf, msg.mediaType, `legacy_${Date.now()}`);
1910
2147
  log(`Image saved: ${tmpFile} (${buf.length} bytes)`);
1911
- handlePreparedImageUpload({
1912
- tmpFile,
1913
- mediaType: msg.mediaType,
1914
- text: msg.text || '',
1915
- }).then(() => {
1916
- setTurnState('running', { reason: 'legacy_image_upload' });
1917
- }).catch((err) => {
1918
- log(`Image upload error: ${err.message}`);
1919
- });
1920
- } catch (err) {
1921
- log(`Image upload error: ${err.message}`);
1922
- try { fs.unlinkSync(tmpFile); } catch {}
1923
- }
1924
- }
2148
+ handlePreparedImageUpload({
2149
+ tmpFile,
2150
+ mediaType: msg.mediaType,
2151
+ text: msg.text || '',
2152
+ }).then(() => {
2153
+ setTurnState('running', { reason: 'legacy_image_upload' });
2154
+ }).catch((err) => {
2155
+ log(`Image upload error: ${err.message}`);
2156
+ });
2157
+ } catch (err) {
2158
+ log(`Image upload error: ${err.message}`);
2159
+ try { fs.unlinkSync(tmpFile); } catch {}
2160
+ }
2161
+ }
1925
2162
 
1926
2163
  // ============================================================
1927
2164
  // 6. Hook Auto-Setup