claude-remote 0.2.2 → 0.2.3

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 +276 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
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
@@ -103,8 +103,9 @@ const { cwd: _parsedCwd, claudeArgs: CLAUDE_EXTRA_ARGS, blocked: _blockedArgs }
103
103
 
104
104
  // --- Config ---
105
105
  const PORT = parseInt(process.env.PORT || '3100', 10);
106
- const CWD = _parsedCwd;
106
+ let CWD = _parsedCwd;
107
107
  const CLAUDE_HOME = path.join(os.homedir(), '.claude');
108
+ const CLAUDE_STATE_FILE = path.join(os.homedir(), '.claude.json');
108
109
  const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
109
110
 
110
111
  // --- State ---
@@ -128,6 +129,9 @@ let tailCatchingUp = false; // true while reading historical transcript content
128
129
  const isTTY = process.stdin.isTTY && process.stdout.isTTY;
129
130
  const LEGACY_REPLAY_DELAY_MS = 1500;
130
131
  const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
132
+ let ttyInputForwarderAttached = false;
133
+ let ttyInputHandler = null;
134
+ let ttyResizeHandler = null;
131
135
 
132
136
  // --- Permission approval state ---
133
137
  let approvalSeq = 0;
@@ -163,6 +167,23 @@ function sendWs(ws, msg, context = '') {
163
167
  return true;
164
168
  }
165
169
 
170
+ function attachTtyForwarders() {
171
+ if (!isTTY || ttyInputForwarderAttached) return;
172
+
173
+ ttyInputHandler = (chunk) => {
174
+ if (claudeProc) claudeProc.write(chunk);
175
+ };
176
+ ttyResizeHandler = () => {
177
+ if (claudeProc) claudeProc.resize(process.stdout.columns, process.stdout.rows);
178
+ };
179
+
180
+ process.stdin.setRawMode(true);
181
+ process.stdin.resume();
182
+ process.stdin.on('data', ttyInputHandler);
183
+ process.stdout.on('resize', ttyResizeHandler);
184
+ ttyInputForwarderAttached = true;
185
+ }
186
+
166
187
  function normalizeFsPath(value) {
167
188
  const resolved = path.resolve(String(value || ''));
168
189
  return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
@@ -553,7 +574,7 @@ wss.on('connection', (ws, req) => {
553
574
  sendReplay(ws, null);
554
575
  }, LEGACY_REPLAY_DELAY_MS);
555
576
 
556
- ws.on('message', (raw) => {
577
+ ws.on('message', async (raw) => {
557
578
  let msg;
558
579
  try { msg = JSON.parse(raw); } catch { return; }
559
580
 
@@ -798,6 +819,55 @@ wss.on('connection', (ws, req) => {
798
819
  handleImageUpload(msg);
799
820
  break;
800
821
  }
822
+ case 'list_sessions': {
823
+ try {
824
+ const sessions = scanSessions(CWD, 20);
825
+ sendWs(ws, { type: 'sessions', sessions });
826
+ } catch (err) {
827
+ log(`scanSessions error: ${err.message}`);
828
+ sendWs(ws, { type: 'sessions', sessions: [], error: err.message });
829
+ }
830
+ break;
831
+ }
832
+ case 'list_dirs': {
833
+ try {
834
+ const browser = listDirectories(msg.cwd || CWD);
835
+ sendWs(ws, { type: 'dir_list', ...browser });
836
+ } catch (err) {
837
+ log(`listDirectories error: ${err.message}`);
838
+ sendWs(ws, {
839
+ type: 'dir_list',
840
+ cwd: path.resolve(String(msg.cwd || CWD || '')),
841
+ parent: null,
842
+ roots: getDirectoryRoots(),
843
+ entries: [],
844
+ error: err.message,
845
+ });
846
+ }
847
+ break;
848
+ }
849
+ case 'switch_session': {
850
+ if (claudeProc && msg.sessionId) {
851
+ log(`Switch session → /resume ${msg.sessionId}`);
852
+ markExpectingSwitch();
853
+ claudeProc.write(`/resume ${msg.sessionId}`);
854
+ setTimeout(() => {
855
+ if (claudeProc) claudeProc.write('\r');
856
+ }, 150);
857
+ }
858
+ break;
859
+ }
860
+ case 'change_cwd': {
861
+ if (msg.cwd) {
862
+ try {
863
+ const targetCwd = assertDirectoryPath(msg.cwd);
864
+ restartClaude(targetCwd);
865
+ } catch (err) {
866
+ sendWs(ws, { type: 'cwd_change_error', cwd: String(msg.cwd), error: err.message });
867
+ }
868
+ }
869
+ break;
870
+ }
801
871
  }
802
872
  });
803
873
 
@@ -828,7 +898,7 @@ function spawnClaude() {
828
898
  const cols = isTTY ? process.stdout.columns : 120;
829
899
  const rows = isTTY ? process.stdout.rows : 40;
830
900
 
831
- claudeProc = pty.spawn(shell, args, {
901
+ const proc = claudeProc = pty.spawn(shell, args, {
832
902
  name: 'xterm-256color',
833
903
  cols,
834
904
  rows,
@@ -837,32 +907,30 @@ function spawnClaude() {
837
907
  });
838
908
 
839
909
  log(`Claude spawned (pid ${claudeProc.pid}) — ${cols}x${rows} cmd="${claudeCmd}"`);
840
- broadcast({ type: 'status', status: 'running', pid: claudeProc.pid });
910
+ broadcast({
911
+ type: 'status',
912
+ status: 'running',
913
+ pid: proc.pid,
914
+ cwd: CWD,
915
+ sessionId: currentSessionId,
916
+ lastSeq: latestEventSeq(),
917
+ });
841
918
 
842
919
  // === PTY output → local terminal + WebSocket + mode detection ===
843
- claudeProc.onData((data) => {
920
+ proc.onData((data) => {
844
921
  if (isTTY) process.stdout.write(data); // show in the terminal you ran the bridge from
845
922
  broadcast({ type: 'pty_output', data }); // push to WebUI
846
923
  });
847
924
 
848
925
  // === Local terminal input → PTY ===
849
- if (isTTY) {
850
- process.stdin.setRawMode(true);
851
- process.stdin.resume();
852
- process.stdin.on('data', (chunk) => {
853
- if (claudeProc) claudeProc.write(chunk);
854
- });
855
-
856
- // Resize PTY when local terminal resizes
857
- process.stdout.on('resize', () => {
858
- if (claudeProc) {
859
- claudeProc.resize(process.stdout.columns, process.stdout.rows);
860
- }
861
- });
862
- }
926
+ attachTtyForwarders();
863
927
 
864
928
  // === PTY exit → cleanup ===
865
- claudeProc.onExit(({ exitCode, signal }) => {
929
+ proc.onExit(({ exitCode, signal }) => {
930
+ if (claudeProc !== proc) {
931
+ log(`Ignoring stale Claude exit (pid ${proc.pid}, code=${exitCode}, signal=${signal})`);
932
+ return;
933
+ }
866
934
  log(`Claude exited (code=${exitCode}, signal=${signal})`);
867
935
  broadcast({ type: 'pty_exit', exitCode, signal });
868
936
  claudeProc = null;
@@ -949,6 +1017,21 @@ function isNonAiUserEvent(event, content) {
949
1017
  return /<local-command-(?:stdout|stderr|caveat)>/i.test(text);
950
1018
  }
951
1019
 
1020
+ function extractSessionPrompt(event) {
1021
+ if (!event || event.type !== 'user') return '';
1022
+
1023
+ const message = event.message;
1024
+ const content = typeof message === 'string'
1025
+ ? message
1026
+ : (message && typeof message === 'object' ? message.content : '');
1027
+ const text = flattenUserContent(content).trim();
1028
+ if (!text) return '';
1029
+ if (isNonAiUserEvent(event, content)) return '';
1030
+ if (extractSlashCommand(content)) return '';
1031
+
1032
+ return text.replace(/\s+/g, ' ').trim().substring(0, 120);
1033
+ }
1034
+
952
1035
  function attachTranscript(target, startOffset = 0) {
953
1036
  transcriptPath = target.full;
954
1037
  currentSessionId = path.basename(transcriptPath, '.jsonl');
@@ -1154,6 +1237,111 @@ function enrichEditStartLines(event) {
1154
1237
  }
1155
1238
  }
1156
1239
 
1240
+ // ============================================================
1241
+ // Session Scanner — list historical sessions from JSONL files
1242
+ // ============================================================
1243
+ function scanSessions(cwd, limit = 20) {
1244
+ const dir = path.join(PROJECTS_DIR, getProjectSlug(cwd));
1245
+ let files;
1246
+ try {
1247
+ files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
1248
+ } catch {
1249
+ return [];
1250
+ }
1251
+
1252
+ // Stat each file, sort by mtime desc
1253
+ const entries = [];
1254
+ for (const f of files) {
1255
+ const full = path.join(dir, f);
1256
+ try {
1257
+ const stat = fs.statSync(full);
1258
+ entries.push({ file: f, full, mtime: stat.mtimeMs, size: stat.size });
1259
+ } catch { /* skip */ }
1260
+ }
1261
+ entries.sort((a, b) => b.mtime - a.mtime);
1262
+ const top = entries.slice(0, limit);
1263
+
1264
+ const sessions = [];
1265
+ for (const entry of top) {
1266
+ const sessionId = path.basename(entry.file, '.jsonl');
1267
+ const info = {
1268
+ sessionId,
1269
+ summary: '',
1270
+ firstPrompt: '',
1271
+ lastModified: Math.round(entry.mtime),
1272
+ fileSize: entry.size,
1273
+ cwd: cwd,
1274
+ };
1275
+
1276
+ // Read first ~64KB to extract the first real user prompt and model.
1277
+ try {
1278
+ const fd = fs.openSync(entry.full, 'r');
1279
+ const buf = Buffer.alloc(Math.min(entry.size, 64 * 1024));
1280
+ fs.readSync(fd, buf, 0, buf.length, 0);
1281
+ fs.closeSync(fd);
1282
+ const lines = buf.toString('utf8').split('\n').filter(Boolean);
1283
+ for (const line of lines) {
1284
+ try {
1285
+ const evt = JSON.parse(line);
1286
+ if (!info.firstPrompt) {
1287
+ info.firstPrompt = extractSessionPrompt(evt);
1288
+ }
1289
+ if (!info.model && evt.model) {
1290
+ info.model = evt.model;
1291
+ }
1292
+ } catch { /* skip malformed line */ }
1293
+ }
1294
+ } catch { /* skip unreadable file */ }
1295
+
1296
+ info.summary = info.firstPrompt || 'Untitled';
1297
+ sessions.push(info);
1298
+ }
1299
+ return sessions;
1300
+ }
1301
+
1302
+ function getDirectoryRoots() {
1303
+ if (process.platform === 'win32') {
1304
+ const roots = [];
1305
+ for (let code = 65; code <= 90; code++) {
1306
+ const drive = String.fromCharCode(code) + ':\\';
1307
+ try {
1308
+ if (fs.existsSync(drive)) roots.push(drive);
1309
+ } catch {}
1310
+ }
1311
+ return roots;
1312
+ }
1313
+ return ['/'];
1314
+ }
1315
+
1316
+ function assertDirectoryPath(target) {
1317
+ const resolved = path.resolve(String(target || ''));
1318
+ let stat;
1319
+ try {
1320
+ stat = fs.statSync(resolved);
1321
+ } catch {
1322
+ throw new Error('Directory not found');
1323
+ }
1324
+ if (!stat.isDirectory()) throw new Error('Path is not a directory');
1325
+ return resolved;
1326
+ }
1327
+
1328
+ function listDirectories(target) {
1329
+ const cwd = assertDirectoryPath(target);
1330
+ const roots = getDirectoryRoots();
1331
+ const parentDir = path.dirname(cwd);
1332
+ const parent = normalizeFsPath(parentDir) === normalizeFsPath(cwd) ? null : parentDir;
1333
+
1334
+ const entries = fs.readdirSync(cwd, { withFileTypes: true })
1335
+ .filter(entry => entry.isDirectory())
1336
+ .map(entry => ({
1337
+ name: entry.name,
1338
+ path: path.join(cwd, entry.name),
1339
+ }))
1340
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
1341
+
1342
+ return { cwd, parent, roots, entries };
1343
+ }
1344
+
1157
1345
  function stopTailing() {
1158
1346
  if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
1159
1347
  if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
@@ -1165,6 +1353,74 @@ function stopTailing() {
1165
1353
  tailRemainder = Buffer.alloc(0);
1166
1354
  }
1167
1355
 
1356
+ function trustProjectCwd(cwd) {
1357
+ const resolved = path.resolve(String(cwd || ''));
1358
+ if (!resolved) return;
1359
+
1360
+ let state = {};
1361
+ try {
1362
+ state = JSON.parse(fs.readFileSync(CLAUDE_STATE_FILE, 'utf8'));
1363
+ } catch {}
1364
+
1365
+ state.projects = state.projects && typeof state.projects === 'object' ? state.projects : {};
1366
+ const keyVariants = process.platform === 'win32'
1367
+ ? [resolved, resolved.replace(/\\/g, '/')]
1368
+ : [resolved];
1369
+
1370
+ for (const projectKey of keyVariants) {
1371
+ const existing = state.projects[projectKey];
1372
+ state.projects[projectKey] = {
1373
+ ...(existing && typeof existing === 'object' ? existing : {}),
1374
+ hasTrustDialogAccepted: true,
1375
+ projectOnboardingSeenCount: Number.isInteger(existing?.projectOnboardingSeenCount)
1376
+ ? existing.projectOnboardingSeenCount
1377
+ : 0,
1378
+ };
1379
+ }
1380
+
1381
+ fs.writeFileSync(CLAUDE_STATE_FILE, JSON.stringify(state, null, 2));
1382
+ log(`Trusted Claude project cwd: ${resolved}`);
1383
+ }
1384
+
1385
+ function restartClaude(newCwd) {
1386
+ log(`Restarting Claude with new CWD: ${newCwd}`);
1387
+ CWD = newCwd;
1388
+ try {
1389
+ trustProjectCwd(CWD);
1390
+ } catch (err) {
1391
+ log(`Failed to trust Claude project cwd "${CWD}": ${err.message}`);
1392
+ }
1393
+
1394
+ // Stop transcript tailing (also clears switch/expect state)
1395
+ stopTailing();
1396
+
1397
+ // Reset session state
1398
+ currentSessionId = null;
1399
+ transcriptPath = null;
1400
+ transcriptOffset = 0;
1401
+ eventBuffer = [];
1402
+ eventSeq = 0;
1403
+ tailCatchingUp = false;
1404
+
1405
+ // Mark the current PTY as stale before killing it so its exit handler
1406
+ // does not shut down the whole bridge during a restart.
1407
+ const procToRestart = claudeProc;
1408
+ claudeProc = null;
1409
+ if (procToRestart) {
1410
+ procToRestart.kill();
1411
+ }
1412
+
1413
+ // Re-setup hooks (CWD changed → project slug may change)
1414
+ setupHooks();
1415
+
1416
+ // Broadcast cwd change immediately so clients drop the previous session
1417
+ // before the replacement Claude process attaches a new transcript.
1418
+ broadcast({ type: 'cwd_changed', cwd: CWD, sessionId: null, lastSeq: 0 });
1419
+
1420
+ // Respawn Claude in new directory
1421
+ spawnClaude();
1422
+ }
1423
+
1168
1424
  // ============================================================
1169
1425
  // 5. Image Upload → Clipboard Injection
1170
1426
  // ============================================================