claude-remote 0.4.7 → 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 -241
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.4.7",
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,145 +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}`);
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;
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;
723
698
  }
724
699
 
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.');
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('/');
733
706
  }
734
707
 
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');
708
+ function buildLinuxImagePrompt(text, tmpFile) {
709
+ const trimmedText = String(text || '').trim();
710
+ const atPath = `@${toClaudeAtPath(tmpFile)}`;
711
+ return trimmedText ? `${trimmedText} ${atPath}` : atPath;
823
712
  }
824
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
+
825
1043
  setInterval(() => {
826
1044
  const now = Date.now();
827
1045
  for (const [uploadId, upload] of pendingImageUploads) {
@@ -1064,26 +1282,17 @@ wss.on('connection', (ws, req) => {
1064
1282
  }
1065
1283
  break;
1066
1284
  }
1067
- case 'image_upload_init': {
1068
- const uploadId = String(msg.uploadId || '');
1069
- if (!uploadId) {
1070
- sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
1071
- break;
1072
- }
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
- }
1285
+ case 'image_upload_init': {
1286
+ const uploadId = String(msg.uploadId || '');
1287
+ if (!uploadId) {
1288
+ sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
1289
+ break;
1082
1290
  }
1083
- pendingImageUploads.set(uploadId, {
1084
- id: uploadId,
1085
- owner: ws,
1086
- mediaType: msg.mediaType || 'image/png',
1291
+ cleanupImageUpload(uploadId);
1292
+ pendingImageUploads.set(uploadId, {
1293
+ id: uploadId,
1294
+ owner: ws,
1295
+ mediaType: msg.mediaType || 'image/png',
1087
1296
  name: msg.name || 'image',
1088
1297
  totalBytes: Number.isFinite(msg.totalBytes) ? msg.totalBytes : 0,
1089
1298
  totalChunks: Number.isFinite(msg.totalChunks) ? msg.totalChunks : 0,
@@ -1181,13 +1390,13 @@ wss.on('connection', (ws, req) => {
1181
1390
  sendUploadStatus(ws, uploadId, 'error', { message: 'Upload not ready' });
1182
1391
  break;
1183
1392
  }
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),
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),
1191
1400
  });
1192
1401
  upload.submitted = true;
1193
1402
  upload.updatedAt = Date.now();
@@ -1396,11 +1605,20 @@ function extractSlashCommand(content) {
1396
1605
  return inlineMatch ? inlineMatch[1].trim().toLowerCase() : '';
1397
1606
  }
1398
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
+
1399
1616
  function isNonAiUserEvent(event, content) {
1400
1617
  if (!event || typeof event !== 'object') return false;
1401
1618
  if (event.isMeta === true) return true;
1402
1619
  if (event.isCompactSummary === true) return true;
1403
1620
  if (event.isVisibleInTranscriptOnly === true) return true;
1621
+ if (isUserInterruptEvent(content)) return true;
1404
1622
 
1405
1623
  const text = flattenUserContent(content).trim();
1406
1624
  if (!text) return false;
@@ -1554,12 +1772,16 @@ function startTailing() {
1554
1772
  if (event.type === 'user' || (event.message && event.message.role === 'user')) {
1555
1773
  const content = event.message && event.message.content;
1556
1774
  const slashCommand = extractSlashCommand(content);
1775
+ const isInterruptedUserEvent = isUserInterruptEvent(content);
1557
1776
  const isPassiveUserEvent = isNonAiUserEvent(event, content);
1558
1777
  const ignoreInitialClear = (
1559
1778
  slashCommand === '/clear' &&
1560
1779
  pendingInitialClearTranscript &&
1561
1780
  pendingInitialClearTranscript.sessionId === currentSessionId
1562
1781
  );
1782
+ if (!tailCatchingUp && isInterruptedUserEvent) {
1783
+ setTurnState('idle', { sessionId: currentSessionId, reason: 'transcript_user_interrupt' });
1784
+ }
1563
1785
  // Only live, AI-producing user messages can move the turn state
1564
1786
  // into running. Historical replay and slash commands are ignored.
1565
1787
  if (!tailCatchingUp && !slashCommand && !isPassiveUserEvent) {
@@ -1815,81 +2037,91 @@ function restartClaude(newCwd) {
1815
2037
  // ============================================================
1816
2038
  // 5. Image Upload → Clipboard Injection
1817
2039
  // ============================================================
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
-
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
+
1822
2044
  const isWin = process.platform === 'win32';
1823
2045
  const isMac = process.platform === 'darwin';
1824
2046
  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
2047
  try {
1829
2048
  const stat = fs.statSync(tmpFile);
1830
2049
  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) {
1837
- execSync(`osascript -e 'set the clipboard to (read POSIX file "${tmpFile}" as 芦class PNGf禄)'`, { timeout: 10000 });
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(() => {
2050
+ if (isLinux) {
2051
+ const linuxPrompt = buildLinuxImagePrompt(text, tmpFile);
2052
+ await new Promise((resolve, reject) => {
1847
2053
  if (!claudeProc) {
1848
- reject(new Error('Claude stopped before image paste'));
2054
+ reject(new Error('Claude stopped before Linux image submit'));
1849
2055
  return;
1850
2056
  }
1851
- if (isWin) claudeProc.write('\x1bv');
1852
- else claudeProc.write('\x16');
1853
- log('Sent image paste keypress to PTY');
1854
-
2057
+ claudeProc.write(linuxPrompt);
1855
2058
  setTimeout(() => {
1856
2059
  if (!claudeProc) {
1857
- reject(new Error('Claude stopped before image prompt'));
2060
+ reject(new Error('Claude stopped before Linux image submit'));
1858
2061
  return;
1859
2062
  }
1860
- const trimmedText = (text || '').trim();
1861
- if (trimmedText) claudeProc.write(trimmedText);
1862
-
2063
+ claudeProc.write('\r');
2064
+ log(`Sent Linux image prompt via @ref: "${linuxPrompt.substring(0, 120)}"`);
1863
2065
  setTimeout(() => {
1864
- if (!claudeProc) {
1865
- reject(new Error('Claude stopped before image submit'));
1866
- return;
2066
+ if (onCleanup) onCleanup();
2067
+ else {
2068
+ try { fs.unlinkSync(tmpFile); } catch {}
1867
2069
  }
1868
- claudeProc.write('\r');
1869
- 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
+ }
1870
2076
 
1871
- if (isLinux) {
1872
- 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;
1873
2108
  }
2109
+ claudeProc.write('\r');
2110
+ log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
1874
2111
 
1875
2112
  setTimeout(() => {
1876
- if (isLinux) setLinuxImagePasteInFlight(false, 'cleanup');
1877
2113
  if (onCleanup) onCleanup();
1878
2114
  else {
1879
2115
  try { fs.unlinkSync(tmpFile); } catch {}
1880
2116
  }
1881
- }, 5000);
1882
- resolve();
1883
- }, 150);
1884
- }, 1000);
1885
- }, pasteDelayMs);
1886
- });
2117
+ }, 5000);
2118
+ resolve();
2119
+ }, 150);
2120
+ }, 1000);
2121
+ }, pasteDelayMs);
2122
+ });
1887
2123
  } catch (err) {
1888
2124
  log(`Image upload error: ${err.message}`);
1889
- if (isLinux) {
1890
- clearActiveLinuxClipboardProc('error');
1891
- setLinuxImagePasteInFlight(false, 'error');
1892
- }
1893
2125
  if (onCleanup) onCleanup();
1894
2126
  else {
1895
2127
  try { fs.unlinkSync(tmpFile); } catch {}
@@ -1907,30 +2139,26 @@ function handleImageUpload(msg) {
1907
2139
  log('Image upload ignored: no base64 data');
1908
2140
  return;
1909
2141
  }
1910
- let tmpFile = null;
1911
-
2142
+ let tmpFile = null;
2143
+
1912
2144
  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
2145
  const buf = Buffer.from(msg.base64, 'base64');
1918
2146
  tmpFile = createTempImageFile(buf, msg.mediaType, `legacy_${Date.now()}`);
1919
2147
  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
- }
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
+ }
1934
2162
 
1935
2163
  // ============================================================
1936
2164
  // 6. Hook Auto-Setup