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.
- package/package.json +1 -1
- package/server.js +276 -20
package/package.json
CHANGED
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
// ============================================================
|