claude-remote 0.5.0 → 0.5.2

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.
@@ -0,0 +1,559 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { execSync, spawn } = require('child_process');
7
+ const {
8
+ state,
9
+ IMAGE_UPLOAD_TTL_MS,
10
+ LINUX_CLIPBOARD_READY_GRACE_MS,
11
+ LINUX_AT_PROMPT_SUBMIT_DELAY_MS,
12
+ LINUX_AT_IMAGE_CLEANUP_DELAY_MS,
13
+ } = require('./state');
14
+ const { log, setTurnState } = require('./logger');
15
+
16
+ // ============================================================
17
+ // Temp File Management
18
+ // ============================================================
19
+ function createTempImageFile(buffer, mediaType, uploadId) {
20
+ const isLinux = process.platform !== 'win32' && process.platform !== 'darwin';
21
+ const tmpDir = isLinux
22
+ ? path.join(state.CWD, 'tmp')
23
+ : (process.env.CLAUDE_CODE_TMPDIR || os.tmpdir());
24
+ const type = String(mediaType || 'image/png').toLowerCase();
25
+ const ext = type.includes('jpeg') || type.includes('jpg') ? '.jpg' : '.png';
26
+ fs.mkdirSync(tmpDir, { recursive: true });
27
+ const tmpFile = path.join(tmpDir, `bridge_upload_${uploadId}_${Date.now()}${ext}`);
28
+ fs.writeFileSync(tmpFile, buffer);
29
+ return tmpFile;
30
+ }
31
+
32
+ function cleanupImageUpload(uploadId) {
33
+ const upload = state.pendingImageUploads.get(uploadId);
34
+ if (!upload) return;
35
+ if (upload.tmpFile) {
36
+ try { fs.unlinkSync(upload.tmpFile); } catch {}
37
+ }
38
+ state.pendingImageUploads.delete(uploadId);
39
+ }
40
+
41
+ function cleanupClientUploads(ws) {
42
+ for (const [uploadId, upload] of state.pendingImageUploads) {
43
+ if (upload.owner === ws && !upload.submitted) cleanupImageUpload(uploadId);
44
+ }
45
+ }
46
+
47
+ function sendUploadStatus(ws, uploadId, status, extra = {}) {
48
+ if (!ws || ws.readyState !== 1 /* WebSocket.OPEN */) return;
49
+ ws.send(JSON.stringify({
50
+ type: 'image_upload_status',
51
+ uploadId,
52
+ status,
53
+ ...extra,
54
+ }));
55
+ }
56
+
57
+ // ============================================================
58
+ // Linux Clipboard Utilities
59
+ // ============================================================
60
+ function toClaudeAtPath(filePath) {
61
+ const normalized = path.normalize(String(filePath || ''));
62
+ const rel = path.relative(state.CWD, normalized);
63
+ const inProject = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
64
+ const target = inProject ? rel : normalized;
65
+ return target.split(path.sep).join('/');
66
+ }
67
+
68
+ function buildLinuxImagePrompt(text, tmpFile) {
69
+ const trimmedText = String(text || '').trim();
70
+ const atPath = `@${toClaudeAtPath(tmpFile)}`;
71
+ return trimmedText ? `${trimmedText} ${atPath}` : atPath;
72
+ }
73
+
74
+ function isLinuxClipboardToolInstalled(tool) {
75
+ try {
76
+ execSync(`command -v ${tool} >/dev/null 2>&1`, {
77
+ stdio: 'ignore',
78
+ shell: '/bin/sh',
79
+ timeout: 2000,
80
+ });
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ function setLinuxImagePasteInFlight(active, reason = '') {
88
+ state.linuxImagePasteInFlight = !!active;
89
+ if (reason) log(`Linux image paste lock=${state.linuxImagePasteInFlight ? 'on' : 'off'} reason=${reason}`);
90
+ }
91
+
92
+ function normalizeLinuxEnvVar(value) {
93
+ const text = String(value || '').trim();
94
+ return text || null;
95
+ }
96
+
97
+ function parseLinuxProcStatusUid(statusText) {
98
+ const match = String(statusText || '').match(/^Uid:\s+(\d+)/m);
99
+ return match ? Number(match[1]) : null;
100
+ }
101
+
102
+ function readLinuxProcGuiEnv(pid) {
103
+ try {
104
+ const statusPath = `/proc/${pid}/status`;
105
+ const environPath = `/proc/${pid}/environ`;
106
+ const statusText = fs.readFileSync(statusPath, 'utf8');
107
+ const currentUid = typeof process.getuid === 'function' ? process.getuid() : null;
108
+ if (currentUid != null) {
109
+ const procUid = parseLinuxProcStatusUid(statusText);
110
+ if (procUid == null || procUid !== currentUid) return null;
111
+ }
112
+ const envRaw = fs.readFileSync(environPath, 'utf8');
113
+ if (!envRaw) return null;
114
+ let waylandDisplay = null;
115
+ let display = null;
116
+ let runtimeDir = null;
117
+ let xAuthority = null;
118
+
119
+ for (const entry of envRaw.split('\0')) {
120
+ if (!entry) continue;
121
+ if (entry.startsWith('WAYLAND_DISPLAY=')) waylandDisplay = normalizeLinuxEnvVar(entry.slice('WAYLAND_DISPLAY='.length));
122
+ else if (entry.startsWith('DISPLAY=')) display = normalizeLinuxEnvVar(entry.slice('DISPLAY='.length));
123
+ else if (entry.startsWith('XDG_RUNTIME_DIR=')) runtimeDir = normalizeLinuxEnvVar(entry.slice('XDG_RUNTIME_DIR='.length));
124
+ else if (entry.startsWith('XAUTHORITY=')) xAuthority = normalizeLinuxEnvVar(entry.slice('XAUTHORITY='.length));
125
+ }
126
+
127
+ if (!waylandDisplay && !display) return null;
128
+ return { waylandDisplay, display, runtimeDir, xAuthority };
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ function discoverLinuxGuiEnvFromProc() {
135
+ if (process.platform === 'win32' || process.platform === 'darwin') return null;
136
+ let entries = [];
137
+ try {
138
+ entries = fs.readdirSync('/proc', { withFileTypes: true });
139
+ } catch {
140
+ return null;
141
+ }
142
+ for (const entry of entries) {
143
+ if (!entry.isDirectory()) continue;
144
+ if (!/^\d+$/.test(entry.name)) continue;
145
+ if (Number(entry.name) === process.pid) continue;
146
+ const discovered = readLinuxProcGuiEnv(entry.name);
147
+ if (discovered) return discovered;
148
+ }
149
+ return null;
150
+ }
151
+
152
+ function discoverLinuxGuiEnvFromSocket() {
153
+ if (process.platform === 'win32' || process.platform === 'darwin') return null;
154
+ const discovered = {
155
+ waylandDisplay: null,
156
+ display: null,
157
+ runtimeDir: null,
158
+ xAuthority: null,
159
+ };
160
+
161
+ const currentUid = typeof process.getuid === 'function' ? process.getuid() : null;
162
+ const runtimeDir = currentUid != null ? `/run/user/${currentUid}` : null;
163
+ if (runtimeDir && fs.existsSync(runtimeDir)) {
164
+ discovered.runtimeDir = runtimeDir;
165
+ try {
166
+ const entries = fs.readdirSync(runtimeDir);
167
+ const waylandSockets = entries.filter(name => /^wayland-\d+$/.test(name)).sort();
168
+ if (waylandSockets.length > 0) discovered.waylandDisplay = waylandSockets[0];
169
+ } catch {}
170
+ }
171
+
172
+ try {
173
+ const xEntries = fs.readdirSync('/tmp/.X11-unix');
174
+ const displaySockets = xEntries
175
+ .map(name => {
176
+ const match = /^X(\d+)$/.exec(name);
177
+ return match ? Number(match[1]) : null;
178
+ })
179
+ .filter(num => Number.isInteger(num))
180
+ .sort((a, b) => a - b);
181
+ if (displaySockets.length > 0) discovered.display = `:${displaySockets[0]}`;
182
+ } catch {}
183
+
184
+ if (!discovered.waylandDisplay && !discovered.display) return null;
185
+ return discovered;
186
+ }
187
+
188
+ function getLinuxClipboardEnv() {
189
+ if (process.platform === 'win32' || process.platform === 'darwin') {
190
+ return { env: process.env, source: 'not_linux' };
191
+ }
192
+
193
+ const overlay = {
194
+ WAYLAND_DISPLAY: normalizeLinuxEnvVar(process.env.CLAUDE_REMOTE_WAYLAND_DISPLAY) || normalizeLinuxEnvVar(process.env.WAYLAND_DISPLAY),
195
+ DISPLAY: normalizeLinuxEnvVar(process.env.CLAUDE_REMOTE_DISPLAY) || normalizeLinuxEnvVar(process.env.DISPLAY),
196
+ XDG_RUNTIME_DIR: normalizeLinuxEnvVar(process.env.CLAUDE_REMOTE_XDG_RUNTIME_DIR) || normalizeLinuxEnvVar(process.env.XDG_RUNTIME_DIR),
197
+ XAUTHORITY: normalizeLinuxEnvVar(process.env.CLAUDE_REMOTE_XAUTHORITY) || normalizeLinuxEnvVar(process.env.XAUTHORITY),
198
+ };
199
+
200
+ let source = 'process_env';
201
+ const needsSocketDiscovery =
202
+ (!overlay.WAYLAND_DISPLAY && !overlay.DISPLAY) ||
203
+ (!!overlay.WAYLAND_DISPLAY && !overlay.XDG_RUNTIME_DIR);
204
+ if (needsSocketDiscovery) {
205
+ const before = {
206
+ waylandDisplay: overlay.WAYLAND_DISPLAY,
207
+ display: overlay.DISPLAY,
208
+ runtimeDir: overlay.XDG_RUNTIME_DIR,
209
+ xAuthority: overlay.XAUTHORITY,
210
+ };
211
+ const fromSocket = discoverLinuxGuiEnvFromSocket();
212
+ if (fromSocket) {
213
+ if (!overlay.WAYLAND_DISPLAY && fromSocket.waylandDisplay) overlay.WAYLAND_DISPLAY = fromSocket.waylandDisplay;
214
+ if (!overlay.DISPLAY && fromSocket.display) overlay.DISPLAY = fromSocket.display;
215
+ if (!overlay.XDG_RUNTIME_DIR && fromSocket.runtimeDir) overlay.XDG_RUNTIME_DIR = fromSocket.runtimeDir;
216
+ if (!overlay.XAUTHORITY && fromSocket.xAuthority) overlay.XAUTHORITY = fromSocket.xAuthority;
217
+ const changed =
218
+ before.waylandDisplay !== overlay.WAYLAND_DISPLAY ||
219
+ before.display !== overlay.DISPLAY ||
220
+ before.runtimeDir !== overlay.XDG_RUNTIME_DIR ||
221
+ before.xAuthority !== overlay.XAUTHORITY;
222
+ if (changed) source = 'socket_discovery';
223
+ }
224
+ }
225
+
226
+ const needsProcDiscovery =
227
+ (!overlay.WAYLAND_DISPLAY && !overlay.DISPLAY) ||
228
+ (!!overlay.DISPLAY && !overlay.XAUTHORITY) ||
229
+ (!!overlay.WAYLAND_DISPLAY && !overlay.XDG_RUNTIME_DIR);
230
+ if (needsProcDiscovery) {
231
+ const before = {
232
+ waylandDisplay: overlay.WAYLAND_DISPLAY,
233
+ display: overlay.DISPLAY,
234
+ runtimeDir: overlay.XDG_RUNTIME_DIR,
235
+ xAuthority: overlay.XAUTHORITY,
236
+ };
237
+ const fromProc = discoverLinuxGuiEnvFromProc();
238
+ if (fromProc) {
239
+ if (!overlay.WAYLAND_DISPLAY && fromProc.waylandDisplay) overlay.WAYLAND_DISPLAY = fromProc.waylandDisplay;
240
+ if (!overlay.DISPLAY && fromProc.display) overlay.DISPLAY = fromProc.display;
241
+ if (!overlay.XDG_RUNTIME_DIR && fromProc.runtimeDir) overlay.XDG_RUNTIME_DIR = fromProc.runtimeDir;
242
+ if (!overlay.XAUTHORITY && fromProc.xAuthority) overlay.XAUTHORITY = fromProc.xAuthority;
243
+ const changed =
244
+ before.waylandDisplay !== overlay.WAYLAND_DISPLAY ||
245
+ before.display !== overlay.DISPLAY ||
246
+ before.runtimeDir !== overlay.XDG_RUNTIME_DIR ||
247
+ before.xAuthority !== overlay.XAUTHORITY;
248
+ if (changed) {
249
+ source = source === 'socket_discovery' ? 'socket+proc_discovery' : 'proc_discovery';
250
+ }
251
+ }
252
+ }
253
+
254
+ const env = { ...process.env };
255
+ if (overlay.WAYLAND_DISPLAY) env.WAYLAND_DISPLAY = overlay.WAYLAND_DISPLAY;
256
+ if (overlay.DISPLAY) env.DISPLAY = overlay.DISPLAY;
257
+ if (overlay.XDG_RUNTIME_DIR) env.XDG_RUNTIME_DIR = overlay.XDG_RUNTIME_DIR;
258
+ if (overlay.XAUTHORITY) env.XAUTHORITY = overlay.XAUTHORITY;
259
+
260
+ return {
261
+ env,
262
+ source,
263
+ waylandDisplay: overlay.WAYLAND_DISPLAY || null,
264
+ display: overlay.DISPLAY || null,
265
+ runtimeDir: overlay.XDG_RUNTIME_DIR || null,
266
+ xAuthority: overlay.XAUTHORITY || null,
267
+ };
268
+ }
269
+
270
+ function getLinuxClipboardToolCandidates(clipboardEnv = process.env) {
271
+ if (process.platform === 'win32' || process.platform === 'darwin') return [];
272
+ const preferred = [];
273
+ if (clipboardEnv.WAYLAND_DISPLAY) preferred.push('wl-copy');
274
+ if (clipboardEnv.DISPLAY) preferred.push('xclip');
275
+ return preferred;
276
+ }
277
+
278
+ function assertLinuxClipboardAvailable() {
279
+ const gui = getLinuxClipboardEnv();
280
+ const candidates = getLinuxClipboardToolCandidates(gui.env);
281
+ const available = candidates.filter(isLinuxClipboardToolInstalled);
282
+ if (available.length > 0) {
283
+ return {
284
+ tools: available,
285
+ env: gui.env,
286
+ source: gui.source,
287
+ waylandDisplay: gui.waylandDisplay,
288
+ display: gui.display,
289
+ runtimeDir: gui.runtimeDir,
290
+ xAuthority: gui.xAuthority,
291
+ };
292
+ }
293
+ if (!gui.waylandDisplay && !gui.display) {
294
+ 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.');
295
+ }
296
+ throw new Error('Linux image paste requires wl-copy or xclip on the server. Install a matching clipboard tool and try again.');
297
+ }
298
+
299
+ function clearActiveLinuxClipboardProc(reason = '') {
300
+ if (!state.activeLinuxClipboardProc) return;
301
+ const { child, tool } = state.activeLinuxClipboardProc;
302
+ state.activeLinuxClipboardProc = null;
303
+ try {
304
+ child.kill('SIGTERM');
305
+ log(`Linux clipboard process terminated (${tool}) reason=${reason || 'cleanup'}`);
306
+ } catch (err) {
307
+ log(`Linux clipboard process terminate error (${tool}): ${err.message}`);
308
+ }
309
+ }
310
+
311
+ function formatLinuxClipboardEnvLog(info) {
312
+ if (!info) return '';
313
+ const parts = [];
314
+ if (info.waylandDisplay) parts.push(`WAYLAND_DISPLAY=${info.waylandDisplay}`);
315
+ if (info.display) parts.push(`DISPLAY=${info.display}`);
316
+ if (info.runtimeDir) parts.push(`XDG_RUNTIME_DIR=${info.runtimeDir}`);
317
+ if (info.xAuthority) parts.push(`XAUTHORITY=${info.xAuthority}`);
318
+ return parts.length ? ` env[${parts.join(', ')}]` : '';
319
+ }
320
+
321
+ function spawnLinuxClipboardTool(tool, imageBuffer, type, clipboardEnv) {
322
+ return new Promise((resolve, reject) => {
323
+ const args = tool === 'xclip'
324
+ ? ['-quiet', '-selection', 'clipboard', '-t', type, '-i']
325
+ : ['--type', type];
326
+ const child = spawn(tool, args, {
327
+ detached: true,
328
+ stdio: ['pipe', 'ignore', 'pipe'],
329
+ env: clipboardEnv || process.env,
330
+ });
331
+ let settled = false;
332
+ let stderr = '';
333
+ let readyTimer = null;
334
+
335
+ const settleFailure = (message) => {
336
+ if (settled) return;
337
+ settled = true;
338
+ if (readyTimer) clearTimeout(readyTimer);
339
+ if (child.exitCode == null && child.signalCode == null) {
340
+ try { child.kill('SIGTERM'); } catch {}
341
+ }
342
+ reject(new Error(message));
343
+ };
344
+
345
+ const settleSuccess = (trackProcess = true) => {
346
+ if (settled) return;
347
+ settled = true;
348
+ if (readyTimer) clearTimeout(readyTimer);
349
+ if (trackProcess && child.exitCode == null && child.signalCode == null) {
350
+ state.activeLinuxClipboardProc = { child, tool };
351
+ child.unref();
352
+ }
353
+ resolve(tool);
354
+ };
355
+
356
+ child.on('error', (err) => {
357
+ log(`Linux clipboard process error (${tool}): ${err.message}`);
358
+ settleFailure(`Linux clipboard tool ${tool} failed: ${err.message}`);
359
+ });
360
+ child.stderr.on('data', (chunk) => {
361
+ stderr += chunk.toString('utf8');
362
+ if (stderr.length > 2000) stderr = stderr.slice(-2000);
363
+ });
364
+ child.on('exit', (code, signal) => {
365
+ if (state.activeLinuxClipboardProc && state.activeLinuxClipboardProc.child === child) state.activeLinuxClipboardProc = null;
366
+ const extra = stderr.trim() ? ` stderr=${JSON.stringify(stderr.trim())}` : '';
367
+ log(`Linux clipboard process exited (${tool}) code=${code ?? 'null'} signal=${signal ?? 'null'}${extra}`);
368
+ if (!settled) {
369
+ if (tool === 'xclip' && code === 0 && !signal && !stderr.trim()) {
370
+ log('Linux clipboard xclip exited cleanly without stderr; treating clipboard arm as successful');
371
+ settleSuccess(false);
372
+ return;
373
+ }
374
+ const detail = stderr.trim() || `exit code ${code ?? 'null'} signal ${signal ?? 'null'}`;
375
+ settleFailure(`Linux clipboard tool ${tool} exited before paste: ${detail}`);
376
+ }
377
+ });
378
+ child.stdin.on('error', (err) => {
379
+ if (err.code === 'EPIPE') {
380
+ settleFailure(`Linux clipboard tool ${tool} closed its input early`);
381
+ return;
382
+ }
383
+ log(`Linux clipboard stdin error (${tool}): ${err.message}`);
384
+ settleFailure(`Linux clipboard tool ${tool} stdin failed: ${err.message}`);
385
+ });
386
+
387
+ child.stdin.end(imageBuffer);
388
+ log(`Linux clipboard process started (${tool}) pid=${child.pid ?? 'null'} type=${type} bytes=${imageBuffer.length}`);
389
+ readyTimer = setTimeout(() => settleSuccess(), LINUX_CLIPBOARD_READY_GRACE_MS);
390
+ });
391
+ }
392
+
393
+ async function startLinuxClipboardImage(tmpFile, mediaType, clipboardInfo = null) {
394
+ const type = String(mediaType || 'image/png').toLowerCase();
395
+ const imageBuffer = fs.readFileSync(tmpFile);
396
+ const resolved = clipboardInfo || assertLinuxClipboardAvailable();
397
+ const availableTools = resolved.tools;
398
+ clearActiveLinuxClipboardProc('replace');
399
+
400
+ let lastErr = null;
401
+ for (const tool of availableTools) {
402
+ try {
403
+ return await spawnLinuxClipboardTool(tool, imageBuffer, type, resolved.env);
404
+ } catch (err) {
405
+ lastErr = err;
406
+ log(`Linux clipboard arm failed (${tool}): ${err.message}`);
407
+ }
408
+ }
409
+
410
+ throw lastErr || new Error('Linux clipboard could not be initialized');
411
+ }
412
+
413
+ // ============================================================
414
+ // Image Upload Handlers
415
+ // ============================================================
416
+ async function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', onCleanup = null }) {
417
+ if (!state.claudeProc) throw new Error('Claude not running');
418
+ if (!tmpFile || !fs.existsSync(tmpFile)) throw new Error('Prepared image file missing');
419
+
420
+ const isWin = process.platform === 'win32';
421
+ const isMac = process.platform === 'darwin';
422
+ const isLinux = !isWin && !isMac;
423
+ try {
424
+ const stat = fs.statSync(tmpFile);
425
+ log(`Image ready: ${logLabel || path.basename(tmpFile)} (${stat.size} bytes)`);
426
+ if (isLinux) {
427
+ const linuxPrompt = buildLinuxImagePrompt(text, tmpFile);
428
+ await new Promise((resolve, reject) => {
429
+ if (!state.claudeProc) {
430
+ reject(new Error('Claude stopped before Linux image submit'));
431
+ return;
432
+ }
433
+ state.claudeProc.write(linuxPrompt);
434
+ setTimeout(() => {
435
+ if (!state.claudeProc) {
436
+ reject(new Error('Claude stopped before Linux image submit'));
437
+ return;
438
+ }
439
+ state.claudeProc.write('\r');
440
+ log(`Sent Linux image prompt via @ref: "${linuxPrompt.substring(0, 120)}"`);
441
+ setTimeout(() => {
442
+ if (onCleanup) onCleanup();
443
+ else {
444
+ try { fs.unlinkSync(tmpFile); } catch {}
445
+ }
446
+ }, LINUX_AT_IMAGE_CLEANUP_DELAY_MS);
447
+ resolve();
448
+ }, LINUX_AT_PROMPT_SUBMIT_DELAY_MS);
449
+ });
450
+ return;
451
+ }
452
+
453
+ if (isWin) {
454
+ 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()`;
455
+ execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
456
+ } else if (isMac) {
457
+ execSync(`osascript -e 'set the clipboard to (read POSIX file "${tmpFile}" as \u00ABclass PNGf\u00BB)'`, { timeout: 10000 });
458
+ }
459
+ log('Clipboard set with image');
460
+
461
+ const pasteDelayMs = isWin || isMac ? 0 : 150;
462
+ await new Promise((resolve, reject) => {
463
+ setTimeout(() => {
464
+ if (!state.claudeProc) {
465
+ reject(new Error('Claude stopped before image paste'));
466
+ return;
467
+ }
468
+ if (isWin) state.claudeProc.write('\x1bv');
469
+ else state.claudeProc.write('\x16');
470
+ log('Sent image paste keypress to PTY');
471
+
472
+ setTimeout(() => {
473
+ if (!state.claudeProc) {
474
+ reject(new Error('Claude stopped before image prompt'));
475
+ return;
476
+ }
477
+ const trimmedText = (text || '').trim();
478
+ if (trimmedText) state.claudeProc.write(trimmedText);
479
+
480
+ setTimeout(() => {
481
+ if (!state.claudeProc) {
482
+ reject(new Error('Claude stopped before image submit'));
483
+ return;
484
+ }
485
+ state.claudeProc.write('\r');
486
+ log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
487
+
488
+ setTimeout(() => {
489
+ if (onCleanup) onCleanup();
490
+ else {
491
+ try { fs.unlinkSync(tmpFile); } catch {}
492
+ }
493
+ }, 5000);
494
+ resolve();
495
+ }, 150);
496
+ }, 1000);
497
+ }, pasteDelayMs);
498
+ });
499
+ } catch (err) {
500
+ log(`Image upload error: ${err.message}`);
501
+ if (onCleanup) onCleanup();
502
+ else {
503
+ try { fs.unlinkSync(tmpFile); } catch {}
504
+ }
505
+ throw err;
506
+ }
507
+ }
508
+
509
+ function handleImageUpload(msg) {
510
+ if (!state.claudeProc) {
511
+ log('Image upload ignored: Claude not running');
512
+ return;
513
+ }
514
+ if (!msg.base64) {
515
+ log('Image upload ignored: no base64 data');
516
+ return;
517
+ }
518
+ let tmpFile = null;
519
+
520
+ try {
521
+ const buf = Buffer.from(msg.base64, 'base64');
522
+ tmpFile = createTempImageFile(buf, msg.mediaType, `legacy_${Date.now()}`);
523
+ log(`Image saved: ${tmpFile} (${buf.length} bytes)`);
524
+ handlePreparedImageUpload({
525
+ tmpFile,
526
+ mediaType: msg.mediaType,
527
+ text: msg.text || '',
528
+ }).then(() => {
529
+ setTurnState('running', { reason: 'legacy_image_upload' });
530
+ }).catch((err) => {
531
+ log(`Image upload error: ${err.message}`);
532
+ });
533
+ } catch (err) {
534
+ log(`Image upload error: ${err.message}`);
535
+ try { fs.unlinkSync(tmpFile); } catch {}
536
+ }
537
+ }
538
+
539
+ function startUploadCleanup() {
540
+ setInterval(() => {
541
+ const now = Date.now();
542
+ for (const [uploadId, upload] of state.pendingImageUploads) {
543
+ if ((upload.updatedAt || 0) < (now - IMAGE_UPLOAD_TTL_MS)) {
544
+ cleanupImageUpload(uploadId);
545
+ }
546
+ }
547
+ }, 60 * 1000).unref();
548
+ }
549
+
550
+ module.exports = {
551
+ createTempImageFile,
552
+ cleanupImageUpload,
553
+ cleanupClientUploads,
554
+ sendUploadStatus,
555
+ handlePreparedImageUpload,
556
+ handleImageUpload,
557
+ startUploadCleanup,
558
+ clearActiveLinuxClipboardProc,
559
+ };