claude-remote 0.5.1 → 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,494 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+ const { WebSocketServer, WebSocket } = require('ws');
6
+ const {
7
+ state,
8
+ WS_CLOSE_AUTH_FAILED,
9
+ WS_CLOSE_AUTH_TIMEOUT,
10
+ WS_CLOSE_REASON_AUTH_FAILED,
11
+ WS_CLOSE_REASON_AUTH_TIMEOUT,
12
+ AUTH_HELLO_TIMEOUT_MS,
13
+ LEGACY_REPLAY_DELAY_MS,
14
+ isTTY,
15
+ } = require('./state');
16
+ const {
17
+ log,
18
+ broadcast,
19
+ wsLabel,
20
+ sendWs,
21
+ sendTurnState,
22
+ setTurnState,
23
+ recomputeEffectiveApprovalMode,
24
+ setClientApprovalMode,
25
+ setTurnApprovalFloorMode,
26
+ latestEventSeq,
27
+ emitInterrupt,
28
+ } = require('./logger');
29
+ const {
30
+ extractSlashCommand,
31
+ markExpectingSwitch,
32
+ scanSessions,
33
+ listDirectories,
34
+ getDirectoryRoots,
35
+ assertDirectoryPath,
36
+ } = require('./transcript');
37
+ const {
38
+ cleanupImageUpload,
39
+ cleanupClientUploads,
40
+ sendUploadStatus,
41
+ handlePreparedImageUpload,
42
+ handleImageUpload,
43
+ createTempImageFile,
44
+ } = require('./image-upload');
45
+ const { restartClaude } = require('./pty-manager');
46
+
47
+ function sendReplay(ws, lastSeq = null) {
48
+ const normalizedLastSeq = Number.isInteger(lastSeq) && lastSeq >= 0 ? lastSeq : null;
49
+ const replayFrom = normalizedLastSeq == null ? 0 : normalizedLastSeq;
50
+ const records = replayFrom > 0
51
+ ? state.eventBuffer.filter(record => record.seq > replayFrom)
52
+ : state.eventBuffer;
53
+
54
+ log(`Replay start -> ${wsLabel(ws)} from=${replayFrom} count=${records.length} currentSession=${state.currentSessionId ?? 'null'}`);
55
+
56
+ for (const record of records) {
57
+ ws.send(JSON.stringify({
58
+ type: 'log_event',
59
+ seq: record.seq,
60
+ event: record.event,
61
+ }));
62
+ }
63
+
64
+ sendWs(ws, {
65
+ type: 'replay_done',
66
+ sessionId: state.currentSessionId,
67
+ lastSeq: latestEventSeq(),
68
+ resumed: normalizedLastSeq != null,
69
+ }, 'sendReplay');
70
+ sendTurnState(ws, 'sendReplay');
71
+ }
72
+
73
+ function sendInitialMessages(ws) {
74
+ sendWs(ws, {
75
+ type: 'status',
76
+ status: state.claudeProc ? 'running' : 'starting',
77
+ hasTranscript: !!state.transcriptPath,
78
+ cwd: state.CWD,
79
+ sessionId: state.currentSessionId,
80
+ lastSeq: latestEventSeq(),
81
+ }, 'initial');
82
+
83
+ if (state.currentSessionId) {
84
+ sendWs(ws, {
85
+ type: 'transcript_ready',
86
+ transcript: state.transcriptPath,
87
+ sessionId: state.currentSessionId,
88
+ lastSeq: latestEventSeq(),
89
+ }, 'initial');
90
+ }
91
+ }
92
+
93
+ function sendAuthOk(ws) {
94
+ sendWs(ws, {
95
+ type: 'auth_ok',
96
+ authRequired: !state.AUTH_DISABLED,
97
+ }, 'auth_ok');
98
+ }
99
+
100
+ function setupWebSocketServer(server) {
101
+ const wss = new WebSocketServer({ server });
102
+ state.wss = wss;
103
+
104
+ wss.on('connection', (ws, req) => {
105
+ ws._bridgeId = ++state.nextWsId;
106
+ ws._clientInstanceId = '';
107
+ ws._authenticated = state.AUTH_DISABLED;
108
+ ws._approvalMode = 'default';
109
+ ws._authTimer = null;
110
+ log(`WS connected: ${wsLabel(ws)} remote=${req.socket.remoteAddress || '?'} ua=${JSON.stringify(req.headers['user-agent'] || '')} authRequired=${!state.AUTH_DISABLED}`);
111
+
112
+ if (state.AUTH_DISABLED) {
113
+ sendAuthOk(ws);
114
+ sendInitialMessages(ws);
115
+ } else {
116
+ ws._authTimer = setTimeout(() => {
117
+ if (ws.readyState !== WebSocket.OPEN || ws._authenticated) return;
118
+ log(`Auth timeout for ${wsLabel(ws)}`);
119
+ ws.close(WS_CLOSE_AUTH_TIMEOUT, WS_CLOSE_REASON_AUTH_TIMEOUT);
120
+ }, AUTH_HELLO_TIMEOUT_MS);
121
+ }
122
+
123
+ ws._resumeHandled = false;
124
+ ws._legacyReplayTimer = null;
125
+ if (state.AUTH_DISABLED) {
126
+ ws._legacyReplayTimer = setTimeout(() => {
127
+ if (ws.readyState !== WebSocket.OPEN || ws._resumeHandled) return;
128
+ ws._resumeHandled = true;
129
+ sendReplay(ws, null);
130
+ }, LEGACY_REPLAY_DELAY_MS);
131
+ }
132
+
133
+ ws.on('message', async (raw) => {
134
+ let msg;
135
+ try { msg = JSON.parse(raw); } catch { return; }
136
+
137
+ // --- Authentication gate ---
138
+ if (!ws._authenticated) {
139
+ if (msg.type !== 'hello') return;
140
+ ws._clientInstanceId = String(msg.clientInstanceId || ws._clientInstanceId || '');
141
+ log(`WS hello from ${wsLabel(ws)} page=${JSON.stringify(msg.page || '')} ua=${JSON.stringify(msg.userAgent || '')}`);
142
+
143
+ const clientToken = String(msg.token || '');
144
+ if (!state.AUTH_TOKEN || !clientToken) {
145
+ log(`Auth failed for ${wsLabel(ws)}: missing token`);
146
+ ws.close(WS_CLOSE_AUTH_FAILED, WS_CLOSE_REASON_AUTH_FAILED);
147
+ return;
148
+ }
149
+ const a = Buffer.from(state.AUTH_TOKEN, 'utf8');
150
+ const b = Buffer.from(clientToken, 'utf8');
151
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
152
+ log(`Auth failed for ${wsLabel(ws)}: invalid token`);
153
+ ws.close(WS_CLOSE_AUTH_FAILED, WS_CLOSE_REASON_AUTH_FAILED);
154
+ return;
155
+ }
156
+ ws._authenticated = true;
157
+ if (ws._authTimer) {
158
+ clearTimeout(ws._authTimer);
159
+ ws._authTimer = null;
160
+ }
161
+ log(`Auth OK for ${wsLabel(ws)}`);
162
+
163
+ sendAuthOk(ws);
164
+ sendInitialMessages(ws);
165
+ ws._legacyReplayTimer = setTimeout(() => {
166
+ if (ws.readyState !== WebSocket.OPEN || ws._resumeHandled) return;
167
+ ws._resumeHandled = true;
168
+ sendReplay(ws, null);
169
+ }, LEGACY_REPLAY_DELAY_MS);
170
+ return;
171
+ }
172
+
173
+ switch (msg.type) {
174
+ case 'hello':
175
+ ws._clientInstanceId = String(msg.clientInstanceId || ws._clientInstanceId || '');
176
+ log(`WS hello from ${wsLabel(ws)} page=${JSON.stringify(msg.page || '')} ua=${JSON.stringify(msg.userAgent || '')}`);
177
+ break;
178
+ case 'debug_log':
179
+ if (msg.clientInstanceId) ws._clientInstanceId = String(msg.clientInstanceId);
180
+ log(`ClientDebug ${wsLabel(ws)} event=${msg.event || 'unknown'} detail=${JSON.stringify(msg.detail || {})}`);
181
+ break;
182
+ case 'resume': {
183
+ ws._resumeHandled = true;
184
+ if (ws._legacyReplayTimer) {
185
+ clearTimeout(ws._legacyReplayTimer);
186
+ ws._legacyReplayTimer = null;
187
+ }
188
+
189
+ if (!state.currentSessionId) {
190
+ ws.send(JSON.stringify({
191
+ type: 'replay_done',
192
+ sessionId: null,
193
+ lastSeq: 0,
194
+ resumed: false,
195
+ }));
196
+ sendTurnState(ws, 'resume-empty');
197
+ break;
198
+ }
199
+
200
+ const clientServerLastSeq = Number.isInteger(msg.serverLastSeq) && msg.serverLastSeq >= 0
201
+ ? msg.serverLastSeq
202
+ : null;
203
+ const canResume = (
204
+ msg.sessionId &&
205
+ msg.sessionId === state.currentSessionId &&
206
+ Number.isInteger(msg.lastSeq) &&
207
+ msg.lastSeq >= 0 &&
208
+ msg.lastSeq <= latestEventSeq() &&
209
+ (clientServerLastSeq == null || msg.lastSeq <= clientServerLastSeq)
210
+ );
211
+
212
+ log(`Resume request from ${wsLabel(ws)} session=${msg.sessionId ?? 'null'} lastSeq=${msg.lastSeq} serverLastSeq=${clientServerLastSeq ?? 'null'} canResume=${canResume}`);
213
+
214
+ sendReplay(ws, canResume ? msg.lastSeq : null);
215
+ break;
216
+ }
217
+ case 'foreground_probe': {
218
+ const probeId = typeof msg.probeId === 'string' ? msg.probeId : '';
219
+ sendWs(ws, {
220
+ type: 'foreground_probe_ack',
221
+ probeId,
222
+ sessionId: state.currentSessionId,
223
+ lastSeq: latestEventSeq(),
224
+ cwd: state.CWD,
225
+ }, 'foreground_probe');
226
+ log(`Foreground probe ack -> ${wsLabel(ws)} probeId=${probeId || 'none'} session=${state.currentSessionId ?? 'null'} lastSeq=${latestEventSeq()}`);
227
+ break;
228
+ }
229
+ case 'input':
230
+ if (state.claudeProc) state.claudeProc.write(msg.data);
231
+ break;
232
+ case 'interrupt': {
233
+ if (!state.claudeProc || state.turnState.phase !== 'running') break;
234
+ log(`Interrupt from ${wsLabel(ws)} — sending Ctrl+C to PTY`);
235
+ state.claudeProc.write('\x03');
236
+ emitInterrupt('app');
237
+ break;
238
+ }
239
+ case 'expect_clear':
240
+ markExpectingSwitch();
241
+ break;
242
+ case 'chat':
243
+ if (state.claudeProc) {
244
+ const text = msg.text;
245
+ log(`Chat input → PTY: "${text.substring(0, 80)}"`);
246
+ const slashCommand = extractSlashCommand(text);
247
+ if (slashCommand === '/clear') {
248
+ markExpectingSwitch();
249
+ }
250
+ if (!slashCommand) {
251
+ if (state.turnState.phase !== 'running') {
252
+ setTurnApprovalFloorMode(ws._approvalMode || 'default', `chat by ${wsLabel(ws)}`);
253
+ }
254
+ setTurnState('running', { reason: 'chat' });
255
+ }
256
+ state.claudeProc.write(text);
257
+ setTimeout(() => {
258
+ if (state.claudeProc) state.claudeProc.write('\r');
259
+ }, 150);
260
+ }
261
+ break;
262
+ case 'resize':
263
+ if (state.claudeProc && msg.cols && msg.rows && !isTTY) {
264
+ state.claudeProc.resize(msg.cols, msg.rows);
265
+ }
266
+ break;
267
+ case 'permission_response': {
268
+ const approval = state.pendingApprovals.get(msg.id);
269
+ if (approval) {
270
+ clearTimeout(approval.timer);
271
+ state.pendingApprovals.delete(msg.id);
272
+ approval.res.writeHead(200, { 'Content-Type': 'application/json' });
273
+ approval.res.end(JSON.stringify({
274
+ decision: msg.decision,
275
+ reason: msg.reason || '',
276
+ }));
277
+ log(`Permission #${msg.id}: ${msg.decision}`);
278
+ broadcast({
279
+ type: 'permission_resolved',
280
+ id: msg.id,
281
+ decision: msg.decision,
282
+ });
283
+ }
284
+ break;
285
+ }
286
+ case 'set_approval_mode': {
287
+ setClientApprovalMode(ws, msg.mode);
288
+ break;
289
+ }
290
+ case 'image_upload_init': {
291
+ const uploadId = String(msg.uploadId || '');
292
+ if (!uploadId) {
293
+ sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
294
+ break;
295
+ }
296
+ cleanupImageUpload(uploadId);
297
+ state.pendingImageUploads.set(uploadId, {
298
+ id: uploadId,
299
+ owner: ws,
300
+ mediaType: msg.mediaType || 'image/png',
301
+ name: msg.name || 'image',
302
+ totalBytes: Number.isFinite(msg.totalBytes) ? msg.totalBytes : 0,
303
+ totalChunks: Number.isFinite(msg.totalChunks) ? msg.totalChunks : 0,
304
+ nextChunkIndex: 0,
305
+ receivedBytes: 0,
306
+ chunks: [],
307
+ tmpFile: null,
308
+ updatedAt: Date.now(),
309
+ });
310
+ sendUploadStatus(ws, uploadId, 'ready_for_chunks', { receivedBytes: 0, totalBytes: msg.totalBytes || 0 });
311
+ break;
312
+ }
313
+ case 'image_upload_chunk': {
314
+ const uploadId = String(msg.uploadId || '');
315
+ const upload = state.pendingImageUploads.get(uploadId);
316
+ if (!upload) {
317
+ sendUploadStatus(ws, uploadId, 'error', { message: 'Upload session not found' });
318
+ break;
319
+ }
320
+ if (upload.owner !== ws) {
321
+ sendUploadStatus(ws, uploadId, 'error', { message: 'Upload owner mismatch' });
322
+ break;
323
+ }
324
+ if (msg.index !== upload.nextChunkIndex) {
325
+ sendUploadStatus(ws, uploadId, 'error', {
326
+ message: `Unexpected chunk index ${msg.index}, expected ${upload.nextChunkIndex}`,
327
+ });
328
+ break;
329
+ }
330
+ if (!msg.base64) {
331
+ sendUploadStatus(ws, uploadId, 'error', { message: 'Missing chunk payload' });
332
+ break;
333
+ }
334
+
335
+ try {
336
+ const chunk = Buffer.from(msg.base64, 'base64');
337
+ upload.chunks.push(chunk);
338
+ upload.receivedBytes += chunk.length;
339
+ upload.nextChunkIndex += 1;
340
+ upload.updatedAt = Date.now();
341
+ sendUploadStatus(ws, uploadId, 'uploading', {
342
+ chunkIndex: msg.index,
343
+ receivedBytes: upload.receivedBytes,
344
+ totalBytes: upload.totalBytes,
345
+ });
346
+ } catch (err) {
347
+ sendUploadStatus(ws, uploadId, 'error', { message: err.message });
348
+ }
349
+ break;
350
+ }
351
+ case 'image_upload_complete': {
352
+ const uploadId = String(msg.uploadId || '');
353
+ const upload = state.pendingImageUploads.get(uploadId);
354
+ if (!upload) {
355
+ sendUploadStatus(ws, uploadId, 'error', { message: 'Upload session not found' });
356
+ break;
357
+ }
358
+ if (upload.owner !== ws) {
359
+ sendUploadStatus(ws, uploadId, 'error', { message: 'Upload owner mismatch' });
360
+ break;
361
+ }
362
+ if (upload.nextChunkIndex !== upload.totalChunks) {
363
+ sendUploadStatus(ws, uploadId, 'error', {
364
+ message: `Upload incomplete (${upload.nextChunkIndex}/${upload.totalChunks})`,
365
+ });
366
+ break;
367
+ }
368
+
369
+ try {
370
+ const buffer = Buffer.concat(upload.chunks);
371
+ upload.tmpFile = createTempImageFile(buffer, upload.mediaType, uploadId);
372
+ upload.chunks = [];
373
+ upload.updatedAt = Date.now();
374
+ log(`Image pre-upload complete: ${upload.tmpFile} (${buffer.length} bytes)`);
375
+ sendUploadStatus(ws, uploadId, 'uploaded', {
376
+ receivedBytes: upload.receivedBytes,
377
+ totalBytes: upload.totalBytes,
378
+ });
379
+ } catch (err) {
380
+ sendUploadStatus(ws, uploadId, 'error', { message: err.message });
381
+ cleanupImageUpload(uploadId);
382
+ }
383
+ break;
384
+ }
385
+ case 'image_upload_abort': {
386
+ const uploadId = String(msg.uploadId || '');
387
+ if (uploadId) cleanupImageUpload(uploadId);
388
+ sendUploadStatus(ws, uploadId, 'aborted');
389
+ break;
390
+ }
391
+ case 'image_submit': {
392
+ const uploadId = String(msg.uploadId || '');
393
+ const upload = state.pendingImageUploads.get(uploadId);
394
+ if (!upload || !upload.tmpFile) {
395
+ sendUploadStatus(ws, uploadId, 'error', { message: 'Upload not ready' });
396
+ break;
397
+ }
398
+ try {
399
+ await handlePreparedImageUpload({
400
+ tmpFile: upload.tmpFile,
401
+ mediaType: upload.mediaType,
402
+ text: msg.text || '',
403
+ logLabel: upload.name || uploadId,
404
+ onCleanup: () => cleanupImageUpload(uploadId),
405
+ });
406
+ upload.submitted = true;
407
+ upload.updatedAt = Date.now();
408
+ if (state.turnState.phase !== 'running') {
409
+ setTurnApprovalFloorMode(ws._approvalMode || 'default', `image_submit by ${wsLabel(ws)}`);
410
+ }
411
+ setTurnState('running', { reason: 'image_submit' });
412
+ sendUploadStatus(ws, uploadId, 'submitted');
413
+ } catch (err) {
414
+ sendUploadStatus(ws, uploadId, 'error', { message: err.message });
415
+ cleanupImageUpload(uploadId);
416
+ }
417
+ break;
418
+ }
419
+ case 'image_upload': {
420
+ if (state.turnState.phase !== 'running') {
421
+ setTurnApprovalFloorMode(ws._approvalMode || 'default', `legacy image_upload by ${wsLabel(ws)}`);
422
+ }
423
+ handleImageUpload(msg);
424
+ break;
425
+ }
426
+ case 'list_sessions': {
427
+ try {
428
+ const sessions = scanSessions(state.CWD, 20);
429
+ sendWs(ws, { type: 'sessions', sessions });
430
+ } catch (err) {
431
+ log(`scanSessions error: ${err.message}`);
432
+ sendWs(ws, { type: 'sessions', sessions: [], error: err.message });
433
+ }
434
+ break;
435
+ }
436
+ case 'list_dirs': {
437
+ try {
438
+ const browser = listDirectories(msg.cwd || state.CWD);
439
+ sendWs(ws, { type: 'dir_list', ...browser });
440
+ } catch (err) {
441
+ log(`listDirectories error: ${err.message}`);
442
+ sendWs(ws, {
443
+ type: 'dir_list',
444
+ cwd: path.resolve(String(msg.cwd || state.CWD || '')),
445
+ parent: null,
446
+ roots: getDirectoryRoots(),
447
+ entries: [],
448
+ error: err.message,
449
+ });
450
+ }
451
+ break;
452
+ }
453
+ case 'switch_session': {
454
+ if (state.claudeProc && msg.sessionId) {
455
+ log(`Switch session → /resume ${msg.sessionId}`);
456
+ markExpectingSwitch();
457
+ state.claudeProc.write(`/resume ${msg.sessionId}`);
458
+ setTimeout(() => {
459
+ if (state.claudeProc) state.claudeProc.write('\r');
460
+ }, 150);
461
+ }
462
+ break;
463
+ }
464
+ case 'change_cwd': {
465
+ if (msg.cwd) {
466
+ try {
467
+ const targetCwd = assertDirectoryPath(msg.cwd);
468
+ restartClaude(targetCwd);
469
+ } catch (err) {
470
+ sendWs(ws, { type: 'cwd_change_error', cwd: String(msg.cwd), error: err.message });
471
+ }
472
+ }
473
+ break;
474
+ }
475
+ }
476
+ });
477
+
478
+ ws.on('close', () => {
479
+ if (ws._authTimer) {
480
+ clearTimeout(ws._authTimer);
481
+ ws._authTimer = null;
482
+ }
483
+ if (ws._legacyReplayTimer) {
484
+ clearTimeout(ws._legacyReplayTimer);
485
+ ws._legacyReplayTimer = null;
486
+ }
487
+ log(`WS closed: ${wsLabel(ws)}`);
488
+ cleanupClientUploads(ws);
489
+ recomputeEffectiveApprovalMode(`client disconnected ${wsLabel(ws)}`);
490
+ });
491
+ });
492
+ }
493
+
494
+ module.exports = { setupWebSocketServer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Remote control bridge for Claude Code REPL - drive from phone/WebUI",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "server.js",
11
+ "lib/",
11
12
  "hooks/",
12
13
  "bin/"
13
14
  ],