@dinko_abdic/claude-code-remote 0.1.1

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,256 @@
1
+ const { execSync } = require('child_process');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const logger = require('./logger');
6
+
7
+ const CACHE_TTL_MS = 5000;
8
+ let cachedResult = null;
9
+ let cachedAt = 0;
10
+
11
+ /**
12
+ * Normalize a string the same way Claude Code encodes project paths:
13
+ * every non-alphanumeric char becomes '-'.
14
+ */
15
+ function normalizeForComparison(str) {
16
+ return str.replace(/[^a-zA-Z0-9]/g, '-');
17
+ }
18
+
19
+ /**
20
+ * Decode an encoded project directory name back to a real filesystem path.
21
+ * E.g. "E--Dinko-Abdi--My-Apps-remote-claude" → "E:\Dinko Abdić\My Apps\remote-claude"
22
+ *
23
+ * Strategy: extract drive letter, then greedily walk the filesystem matching
24
+ * normalized directory names against segments of the encoded string.
25
+ */
26
+ function decodeProjectDir(encoded) {
27
+ try {
28
+ // Extract drive letter: first char should be a letter, followed by '-'
29
+ const driveMatch = encoded.match(/^([A-Za-z])-(.+)$/);
30
+ if (!driveMatch) return null;
31
+
32
+ const driveLetter = driveMatch[1].toUpperCase();
33
+ let remaining = driveMatch[2];
34
+ let currentPath = driveLetter + ':\\';
35
+
36
+ if (!fs.existsSync(currentPath)) return null;
37
+
38
+ // Greedily walk filesystem: at each level, try to match the longest
39
+ // directory name that matches the start of `remaining`
40
+ while (remaining.length > 0) {
41
+ // Strip leading '-' separators (the path separator gets encoded as '-')
42
+ if (remaining.startsWith('-')) {
43
+ remaining = remaining.slice(1);
44
+ if (remaining.length === 0) break;
45
+ }
46
+
47
+ let entries;
48
+ try {
49
+ entries = fs.readdirSync(currentPath, { withFileTypes: true });
50
+ } catch {
51
+ return null; // can't read directory
52
+ }
53
+
54
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
55
+
56
+ // Try longest match first (greedy)
57
+ let matched = false;
58
+ // Sort by normalized length descending for greedy matching
59
+ const candidates = dirs
60
+ .map(name => ({ name, normalized: normalizeForComparison(name) }))
61
+ .sort((a, b) => b.normalized.length - a.normalized.length);
62
+
63
+ for (const { name, normalized } of candidates) {
64
+ if (remaining.startsWith(normalized)) {
65
+ // Check that the match ends at the string boundary or at a '-' separator
66
+ const afterMatch = remaining.slice(normalized.length);
67
+ if (afterMatch.length === 0 || afterMatch.startsWith('-')) {
68
+ currentPath = path.join(currentPath, name);
69
+ remaining = afterMatch;
70
+ matched = true;
71
+ break;
72
+ }
73
+ }
74
+ }
75
+
76
+ if (!matched) return null; // no directory matched
77
+ }
78
+
79
+ // Verify the decoded path exists
80
+ if (fs.existsSync(currentPath)) {
81
+ return currentPath;
82
+ }
83
+ return null;
84
+ } catch (err) {
85
+ logger.debug?.(`Failed to decode project dir "${encoded}": ${err.message}`);
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Scan ~/.claude/projects/ for project directories, sorted by most recent activity.
92
+ * No time cutoff — we match the N most recent projects to N detected processes.
93
+ * Returns Map<encodedDirName, { cwd, projectName, lastModified }>
94
+ */
95
+ function scanRecentProjects() {
96
+ const claudeDir = path.join(os.homedir(), '.claude', 'projects');
97
+ const all = [];
98
+
99
+ if (!fs.existsSync(claudeDir)) return new Map();
100
+
101
+ try {
102
+ const projectDirs = fs.readdirSync(claudeDir, { withFileTypes: true });
103
+
104
+ for (const dir of projectDirs) {
105
+ if (!dir.isDirectory()) continue;
106
+
107
+ const projectPath = path.join(claudeDir, dir.name);
108
+ let latestMtime = 0;
109
+
110
+ try {
111
+ const files = fs.readdirSync(projectPath);
112
+ for (const file of files) {
113
+ if (!file.endsWith('.jsonl')) continue;
114
+ try {
115
+ const stat = fs.statSync(path.join(projectPath, file));
116
+ if (stat.mtimeMs > latestMtime) {
117
+ latestMtime = stat.mtimeMs;
118
+ }
119
+ } catch {
120
+ // skip unreadable files
121
+ }
122
+ }
123
+ } catch {
124
+ continue;
125
+ }
126
+
127
+ if (latestMtime > 0) {
128
+ all.push({
129
+ dirName: dir.name,
130
+ cwd: decodeProjectDir(dir.name),
131
+ projectName: dir.name,
132
+ lastModified: latestMtime,
133
+ });
134
+ }
135
+ }
136
+ } catch (err) {
137
+ logger.warn?.(`Failed to scan Claude projects: ${err.message}`);
138
+ }
139
+
140
+ // Sort most recent first — callers pick the top N to match detected processes
141
+ all.sort((a, b) => b.lastModified - a.lastModified);
142
+
143
+ const results = new Map();
144
+ for (const entry of all) {
145
+ results.set(entry.dirName, entry);
146
+ }
147
+ return results;
148
+ }
149
+
150
+ // Persistent PID → project mapping (survives across scans so assignments stay stable)
151
+ const pidProjectMap = new Map(); // pid → { cwd, projectName }
152
+
153
+ /**
154
+ * Scan for externally-running claude.exe processes (not managed by the daemon).
155
+ * Uses a stable PID→project mapping: a project is assigned to a PID once when
156
+ * it first appears and only freed when that PID disappears.
157
+ * @param {number[]} daemonPtyPids - PIDs of daemon-managed PTY processes to exclude
158
+ * @returns {Array<{pid: number, cwd: string|null, projectName: string}>}
159
+ */
160
+ function scanExternalClaudeSessions(daemonPtyPids = []) {
161
+ // Return cache if still fresh
162
+ if (cachedResult && Date.now() - cachedAt < CACHE_TTL_MS) {
163
+ return cachedResult;
164
+ }
165
+
166
+ const results = [];
167
+ const daemonPidSet = new Set(daemonPtyPids);
168
+
169
+ try {
170
+ // Find claude.exe processes via wmic
171
+ const raw = execSync(
172
+ 'wmic process where "name=\'claude.exe\'" get ProcessId,ExecutablePath,ParentProcessId /FORMAT:CSV',
173
+ { encoding: 'utf-8', timeout: 5000, windowsHide: true }
174
+ );
175
+
176
+ const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
177
+ // CSV format: Node,ExecutablePath,ParentProcessId,ProcessId
178
+ // Skip header
179
+ const claudeProcesses = [];
180
+ for (let i = 1; i < lines.length; i++) {
181
+ const parts = lines[i].split(',');
182
+ if (parts.length < 4) continue;
183
+
184
+ const exePath = parts[1];
185
+ const parentPid = parseInt(parts[2], 10);
186
+ const pid = parseInt(parts[3], 10);
187
+
188
+ // Only include ~/.local/bin/claude.exe (not Claude Desktop/Electron)
189
+ if (!exePath || !exePath.includes('.local') || !exePath.includes('bin')) continue;
190
+
191
+ // Exclude daemon-managed processes
192
+ if (daemonPidSet.has(parentPid)) continue;
193
+
194
+ claudeProcesses.push({ pid, parentPid, exePath });
195
+ }
196
+
197
+ // Prune stale PIDs from the mapping (processes that no longer exist)
198
+ const currentPids = new Set(claudeProcesses.map(p => p.pid));
199
+ for (const pid of pidProjectMap.keys()) {
200
+ if (!currentPids.has(pid)) pidProjectMap.delete(pid);
201
+ }
202
+
203
+ if (claudeProcesses.length === 0) {
204
+ cachedResult = [];
205
+ cachedAt = Date.now();
206
+ return results;
207
+ }
208
+
209
+ // Find PIDs that don't have a project assigned yet
210
+ const unmappedProcesses = claudeProcesses.filter(p => !pidProjectMap.has(p.pid));
211
+
212
+ if (unmappedProcesses.length > 0) {
213
+ // Scan projects and find ones not already assigned to existing PIDs
214
+ const recentProjects = scanRecentProjects();
215
+ const projectList = [...recentProjects.values()]
216
+ .sort((a, b) => b.lastModified - a.lastModified);
217
+
218
+ const assignedProjects = new Set(
219
+ [...pidProjectMap.values()].map(v => v.projectName)
220
+ );
221
+ const availableProjects = projectList.filter(
222
+ p => !assignedProjects.has(p.projectName)
223
+ );
224
+
225
+ // Assign the most recent available project to each new PID
226
+ for (let i = 0; i < unmappedProcesses.length; i++) {
227
+ const project = availableProjects[i] || null;
228
+ pidProjectMap.set(unmappedProcesses[i].pid, {
229
+ cwd: project?.cwd || null,
230
+ projectName: project?.projectName || 'Unknown project',
231
+ });
232
+ }
233
+ }
234
+
235
+ // Build results from the stable mapping
236
+ for (const proc of claudeProcesses) {
237
+ const mapping = pidProjectMap.get(proc.pid);
238
+ results.push({
239
+ pid: proc.pid,
240
+ cwd: mapping?.cwd || null,
241
+ projectName: mapping?.projectName || 'Unknown project',
242
+ });
243
+ }
244
+ } catch (err) {
245
+ // wmic may fail if no claude.exe is running — that's fine
246
+ if (!err.message?.includes('No Instance')) {
247
+ logger.warn?.(`Process scan failed: ${err.message}`);
248
+ }
249
+ }
250
+
251
+ cachedResult = results;
252
+ cachedAt = Date.now();
253
+ return results;
254
+ }
255
+
256
+ module.exports = { scanExternalClaudeSessions, decodeProjectDir };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * WebSocket message protocol for Claude Code Remote.
3
+ * All messages are JSON text frames.
4
+ */
5
+
6
+ const MessageType = {
7
+ // Terminal
8
+ TERMINAL_INPUT: 'terminal.input',
9
+ TERMINAL_OUTPUT: 'terminal.output',
10
+ TERMINAL_RESIZE: 'terminal.resize',
11
+
12
+ // Session
13
+ SESSION_CREATED: 'session.created',
14
+ SESSION_ENDED: 'session.ended',
15
+ SESSION_IDLE: 'session.idle',
16
+
17
+ // Error
18
+ ERROR: 'error',
19
+ };
20
+
21
+ function validate(msg) {
22
+ if (!msg || typeof msg !== 'object') return 'Message must be a JSON object';
23
+ if (!msg.type) return 'Missing "type" field';
24
+
25
+ switch (msg.type) {
26
+ case MessageType.TERMINAL_INPUT:
27
+ if (typeof msg.data !== 'string') return 'terminal.input requires string "data"';
28
+ if (!msg.sessionId) return 'terminal.input requires "sessionId"';
29
+ break;
30
+
31
+ case MessageType.TERMINAL_RESIZE:
32
+ if (!Number.isInteger(msg.cols) || msg.cols < 1) return 'terminal.resize requires positive integer "cols"';
33
+ if (!Number.isInteger(msg.rows) || msg.rows < 1) return 'terminal.resize requires positive integer "rows"';
34
+ if (!msg.sessionId) return 'terminal.resize requires "sessionId"';
35
+ break;
36
+
37
+ default:
38
+ return `Unknown message type: ${msg.type}`;
39
+ }
40
+
41
+ return null; // valid
42
+ }
43
+
44
+ function makeSessionCreated(sessionId, cols, rows, metadata) {
45
+ return JSON.stringify({
46
+ type: MessageType.SESSION_CREATED,
47
+ sessionId,
48
+ cols,
49
+ rows,
50
+ cwd: metadata?.cwd || null,
51
+ name: metadata?.name || null,
52
+ createdAt: metadata?.createdAt || null,
53
+ });
54
+ }
55
+
56
+ function makeSessionEnded(sessionId, reason) {
57
+ return JSON.stringify({
58
+ type: MessageType.SESSION_ENDED,
59
+ sessionId,
60
+ reason,
61
+ });
62
+ }
63
+
64
+ function makeTerminalOutput(sessionId, data) {
65
+ return JSON.stringify({
66
+ type: MessageType.TERMINAL_OUTPUT,
67
+ sessionId,
68
+ data,
69
+ });
70
+ }
71
+
72
+ function makeSessionIdle(sessionId) {
73
+ return JSON.stringify({
74
+ type: MessageType.SESSION_IDLE,
75
+ sessionId,
76
+ });
77
+ }
78
+
79
+ function makeError(message) {
80
+ return JSON.stringify({
81
+ type: MessageType.ERROR,
82
+ message,
83
+ });
84
+ }
85
+
86
+ module.exports = { MessageType, validate, makeSessionCreated, makeSessionEnded, makeSessionIdle, makeTerminalOutput, makeError };
package/src/sandbox.js ADDED
@@ -0,0 +1,37 @@
1
+ const path = require('path');
2
+ const logger = require('./logger');
3
+
4
+ const IS_WINDOWS = process.platform === 'win32';
5
+
6
+ /**
7
+ * Validate that a requested path is within the sandbox root.
8
+ * Returns the resolved path if valid, or null if rejected.
9
+ */
10
+ function validatePath(requestedPath, sandboxRoot) {
11
+ if (!sandboxRoot) {
12
+ // No sandbox configured — allow any path
13
+ return path.resolve(requestedPath);
14
+ }
15
+
16
+ const resolved = path.resolve(requestedPath);
17
+ const root = path.resolve(sandboxRoot);
18
+
19
+ // Reject UNC paths on Windows
20
+ if (IS_WINDOWS && resolved.startsWith('\\\\')) {
21
+ logger.warn(`Rejected UNC path: ${resolved}`);
22
+ return null;
23
+ }
24
+
25
+ // Case-insensitive comparison on Windows
26
+ const normalizedResolved = IS_WINDOWS ? resolved.toLowerCase() : resolved;
27
+ const normalizedRoot = IS_WINDOWS ? root.toLowerCase() : root;
28
+
29
+ if (!normalizedResolved.startsWith(normalizedRoot + path.sep) && normalizedResolved !== normalizedRoot) {
30
+ logger.warn(`Path outside sandbox: ${resolved} (root: ${root})`);
31
+ return null;
32
+ }
33
+
34
+ return resolved;
35
+ }
36
+
37
+ module.exports = { validatePath };
@@ -0,0 +1,90 @@
1
+ const { execSync } = require('child_process');
2
+ const path = require('path');
3
+ const logger = require('./logger');
4
+
5
+ let cached = null;
6
+ let cachedAt = 0;
7
+ const CACHE_TTL = 30_000;
8
+
9
+ // Windows installs Tailscale here but doesn't always add it to PATH
10
+ const TAILSCALE_PATHS = [
11
+ 'tailscale',
12
+ path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Tailscale', 'tailscale.exe'),
13
+ ];
14
+
15
+ function tryExec(cmd) {
16
+ return execSync(cmd, {
17
+ timeout: 5000,
18
+ stdio: ['ignore', 'pipe', 'ignore'],
19
+ windowsHide: true,
20
+ });
21
+ }
22
+
23
+ function getTailscaleStatus() {
24
+ const now = Date.now();
25
+ if (cached && now - cachedAt < CACHE_TTL) return cached;
26
+
27
+ const result = { installed: false, running: false, ip: null, error: null };
28
+
29
+ // Try tailscale CLI (bare name first, then full Windows path)
30
+ for (const bin of TAILSCALE_PATHS) {
31
+ try {
32
+ const raw = tryExec(`"${bin}" status --json`);
33
+ const status = JSON.parse(raw.toString());
34
+ result.installed = true;
35
+
36
+ // TailscaleIPs at top level and on Self — check both
37
+ const selfIPs = status.Self?.TailscaleIPs || status.TailscaleIPs || [];
38
+ const ipv4 = selfIPs.find((ip) => /^100\./.test(ip));
39
+ if (ipv4) {
40
+ result.ip = ipv4;
41
+ // BackendState can say "Stopped" even when the GUI shows Connected,
42
+ // so treat having an IP as running
43
+ result.running = true;
44
+ } else {
45
+ result.error = 'No Tailscale IPv4 address assigned';
46
+ }
47
+
48
+ cached = result;
49
+ cachedAt = now;
50
+ return result;
51
+ } catch {
52
+ // try next path
53
+ }
54
+ }
55
+
56
+ // Fallback: parse ipconfig for Tailscale adapter
57
+ try {
58
+ const raw = tryExec('ipconfig');
59
+ const output = raw.toString();
60
+ const sections = output.split(/\r?\n\r?\n/);
61
+
62
+ for (const section of sections) {
63
+ if (!/tailscale/i.test(section)) continue;
64
+ result.installed = true;
65
+
66
+ const match = section.match(/IPv4[^:]*:\s*([\d.]+)/);
67
+ if (match && match[1].startsWith('100.')) {
68
+ result.running = true;
69
+ result.ip = match[1];
70
+ break;
71
+ }
72
+ }
73
+
74
+ if (!result.ip && result.installed) {
75
+ result.error = 'Tailscale adapter found but no 100.x IPv4 address';
76
+ }
77
+ } catch (err) {
78
+ result.error = `Detection failed: ${err.message}`;
79
+ }
80
+
81
+ if (!result.installed) {
82
+ result.error = 'Tailscale not found';
83
+ }
84
+
85
+ cached = result;
86
+ cachedAt = now;
87
+ return result;
88
+ }
89
+
90
+ module.exports = { getTailscaleStatus };