@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.
- package/ecosystem.config.js +17 -0
- package/package.json +22 -0
- package/src/auth.js +43 -0
- package/src/config.js +58 -0
- package/src/dashboard.js +357 -0
- package/src/index.js +445 -0
- package/src/logger.js +9 -0
- package/src/pick-folder.ps1 +82 -0
- package/src/process-scanner.js +256 -0
- package/src/protocol.js +86 -0
- package/src/sandbox.js +37 -0
- package/src/tailscale.js +90 -0
- package/src/terminal-manager.js +258 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const pty = require('node-pty');
|
|
3
|
+
const { validatePath } = require('./sandbox');
|
|
4
|
+
const { makeTerminalOutput, makeSessionEnded, makeSessionIdle } = require('./protocol');
|
|
5
|
+
const logger = require('./logger');
|
|
6
|
+
|
|
7
|
+
const MAX_SCROLLBACK = 50 * 1024; // 50KB
|
|
8
|
+
const IDLE_TIMEOUT_MS = 3000; // 3s of no output = idle
|
|
9
|
+
const DEBUG_XTERM_BRIDGE = process.env.DEBUG_XTERM_BRIDGE === '1';
|
|
10
|
+
|
|
11
|
+
function debugBridge(...args) {
|
|
12
|
+
if (!DEBUG_XTERM_BRIDGE) return;
|
|
13
|
+
logger.info('[xterm-bridge]', ...args);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @type {Map<string, {pty: any, ws: any|null, destroyTimer: any|null, idleTimer: any|null, scrollback: string, scrollbackHandler: any, exitHandler: any, dataHandler: any|null, cwd: string, name: string, createdAt: string, deviceName: string|null}>} */
|
|
17
|
+
const sessions = new Map();
|
|
18
|
+
|
|
19
|
+
function createSession(id, cwd, cols, rows, shell, sandboxRoot, name, deviceName) {
|
|
20
|
+
const resolvedCwd = validatePath(cwd, sandboxRoot);
|
|
21
|
+
if (!resolvedCwd) {
|
|
22
|
+
throw new Error(`Path rejected by sandbox: ${cwd}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ptyProcess = pty.spawn(shell, [], {
|
|
26
|
+
name: 'xterm-256color',
|
|
27
|
+
cols,
|
|
28
|
+
rows,
|
|
29
|
+
cwd: resolvedCwd,
|
|
30
|
+
env: process.env,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const session = {
|
|
34
|
+
pty: ptyProcess,
|
|
35
|
+
ws: null,
|
|
36
|
+
destroyTimer: null,
|
|
37
|
+
idleTimer: null,
|
|
38
|
+
scrollback: '',
|
|
39
|
+
scrollbackHandler: null,
|
|
40
|
+
exitHandler: null,
|
|
41
|
+
dataHandler: null,
|
|
42
|
+
cwd: resolvedCwd,
|
|
43
|
+
name: name || path.basename(resolvedCwd) || resolvedCwd,
|
|
44
|
+
createdAt: new Date().toISOString(),
|
|
45
|
+
deviceName: deviceName || null,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
sessions.set(id, session);
|
|
49
|
+
|
|
50
|
+
// Permanent scrollback capture — runs regardless of WS state
|
|
51
|
+
session.scrollbackHandler = ptyProcess.onData((data) => {
|
|
52
|
+
session.scrollback += data;
|
|
53
|
+
if (session.scrollback.length > MAX_SCROLLBACK) {
|
|
54
|
+
session.scrollback = session.scrollback.slice(-MAX_SCROLLBACK);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Idle detection: reset timer on every output chunk
|
|
58
|
+
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
59
|
+
session.idleTimer = setTimeout(() => {
|
|
60
|
+
session.idleTimer = null;
|
|
61
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
62
|
+
session.ws.send(makeSessionIdle(id));
|
|
63
|
+
}
|
|
64
|
+
}, IDLE_TIMEOUT_MS);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Permanent exit handler
|
|
68
|
+
session.exitHandler = ptyProcess.onExit(({ exitCode }) => {
|
|
69
|
+
logger.info(`Session ${id} pty exited with code ${exitCode}`);
|
|
70
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
71
|
+
session.ws.send(makeSessionEnded(id, `Process exited with code ${exitCode}`));
|
|
72
|
+
session.ws.close(1000, 'pty exited');
|
|
73
|
+
}
|
|
74
|
+
// Clean up permanent handlers
|
|
75
|
+
if (session.scrollbackHandler) session.scrollbackHandler.dispose();
|
|
76
|
+
if (session.exitHandler) session.exitHandler.dispose();
|
|
77
|
+
if (session.dataHandler) session.dataHandler.dispose();
|
|
78
|
+
if (session.destroyTimer) clearTimeout(session.destroyTimer);
|
|
79
|
+
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
80
|
+
sessions.delete(id);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
logger.info(`Session ${id} created (shell=${shell}, cwd=${resolvedCwd}, ${cols}x${rows})`);
|
|
84
|
+
return id;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function attachWebSocket(id, ws) {
|
|
88
|
+
const session = sessions.get(id);
|
|
89
|
+
if (!session) throw new Error(`Session not found: ${id}`);
|
|
90
|
+
|
|
91
|
+
debugBridge('attach requested', {
|
|
92
|
+
sessionId: id,
|
|
93
|
+
hasScrollback: session.scrollback.length > 0,
|
|
94
|
+
scrollbackBytes: session.scrollback.length,
|
|
95
|
+
hadExistingWs: Boolean(session.ws),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Clear any pending destroy timer
|
|
99
|
+
if (session.destroyTimer) {
|
|
100
|
+
clearTimeout(session.destroyTimer);
|
|
101
|
+
session.destroyTimer = null;
|
|
102
|
+
logger.info(`Session ${id} reconnected, destroy timer cancelled`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Detach previous WS data forwarder if any
|
|
106
|
+
if (session.ws) {
|
|
107
|
+
detachWebSocket(id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
session.ws = ws;
|
|
111
|
+
|
|
112
|
+
// Replay scrollback buffer so client sees previous terminal content
|
|
113
|
+
if (session.scrollback) {
|
|
114
|
+
debugBridge('replaying scrollback', {
|
|
115
|
+
sessionId: id,
|
|
116
|
+
scrollbackBytes: session.scrollback.length,
|
|
117
|
+
});
|
|
118
|
+
ws.send(makeTerminalOutput(id, session.scrollback));
|
|
119
|
+
} else {
|
|
120
|
+
debugBridge('no scrollback to replay', { sessionId: id });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Wire pty output → WS (only the forwarding handler, not exit/scrollback)
|
|
124
|
+
session.dataHandler = session.pty.onData((data) => {
|
|
125
|
+
if (session.ws && session.ws.readyState === 1) { // WebSocket.OPEN
|
|
126
|
+
debugBridge('forwarding live pty output', {
|
|
127
|
+
sessionId: id,
|
|
128
|
+
chunkBytes: data.length,
|
|
129
|
+
});
|
|
130
|
+
session.ws.send(makeTerminalOutput(id, data));
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
logger.info(`WebSocket attached to session ${id}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function detachWebSocket(id) {
|
|
138
|
+
const session = sessions.get(id);
|
|
139
|
+
if (!session) return;
|
|
140
|
+
|
|
141
|
+
// Only dispose the WS forwarding handler, NOT scrollback or exit handlers
|
|
142
|
+
if (session.dataHandler) {
|
|
143
|
+
session.dataHandler.dispose();
|
|
144
|
+
session.dataHandler = null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
session.ws = null;
|
|
148
|
+
logger.info(`WebSocket detached from session ${id}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function setDeviceName(id, deviceName) {
|
|
152
|
+
const session = sessions.get(id);
|
|
153
|
+
if (session) session.deviceName = deviceName || null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function writeToSession(id, data) {
|
|
157
|
+
const session = sessions.get(id);
|
|
158
|
+
if (!session) throw new Error(`Session not found: ${id}`);
|
|
159
|
+
session.pty.write(data);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function resizeSession(id, cols, rows) {
|
|
163
|
+
const session = sessions.get(id);
|
|
164
|
+
if (!session) throw new Error(`Session not found: ${id}`);
|
|
165
|
+
session.pty.resize(cols, rows);
|
|
166
|
+
logger.info(`Session ${id} resized to ${cols}x${rows}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function destroySession(id) {
|
|
170
|
+
const session = sessions.get(id);
|
|
171
|
+
if (!session) return;
|
|
172
|
+
|
|
173
|
+
if (session.destroyTimer) {
|
|
174
|
+
clearTimeout(session.destroyTimer);
|
|
175
|
+
}
|
|
176
|
+
if (session.idleTimer) {
|
|
177
|
+
clearTimeout(session.idleTimer);
|
|
178
|
+
}
|
|
179
|
+
if (session.dataHandler) {
|
|
180
|
+
session.dataHandler.dispose();
|
|
181
|
+
}
|
|
182
|
+
if (session.scrollbackHandler) {
|
|
183
|
+
session.scrollbackHandler.dispose();
|
|
184
|
+
}
|
|
185
|
+
if (session.exitHandler) {
|
|
186
|
+
session.exitHandler.dispose();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
session.pty.kill();
|
|
191
|
+
} catch {
|
|
192
|
+
// already dead
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
sessions.delete(id);
|
|
196
|
+
logger.info(`Session ${id} destroyed`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function scheduleDestroy(id, delayMs = 5 * 60 * 1000) {
|
|
200
|
+
const session = sessions.get(id);
|
|
201
|
+
if (!session) return;
|
|
202
|
+
|
|
203
|
+
session.destroyTimer = setTimeout(() => {
|
|
204
|
+
logger.info(`Session ${id} abandoned, destroying after timeout`);
|
|
205
|
+
destroySession(id);
|
|
206
|
+
}, delayMs);
|
|
207
|
+
|
|
208
|
+
logger.info(`Session ${id} scheduled for destruction in ${delayMs / 1000}s`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function destroyAll() {
|
|
212
|
+
for (const id of sessions.keys()) {
|
|
213
|
+
destroySession(id);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function getSession(id) {
|
|
218
|
+
return sessions.get(id);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function getSessionCount() {
|
|
222
|
+
return sessions.size;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function getDaemonPtyPids() {
|
|
226
|
+
return [...sessions.values()].map(s => s.pty.pid).filter(Boolean);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getSessionList() {
|
|
230
|
+
const list = [];
|
|
231
|
+
for (const [id, session] of sessions) {
|
|
232
|
+
list.push({
|
|
233
|
+
id,
|
|
234
|
+
cwd: session.cwd,
|
|
235
|
+
name: session.name,
|
|
236
|
+
createdAt: session.createdAt,
|
|
237
|
+
hasClient: session.ws !== null && session.ws.readyState === 1,
|
|
238
|
+
deviceName: session.deviceName,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return list;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = {
|
|
245
|
+
createSession,
|
|
246
|
+
attachWebSocket,
|
|
247
|
+
detachWebSocket,
|
|
248
|
+
setDeviceName,
|
|
249
|
+
writeToSession,
|
|
250
|
+
resizeSession,
|
|
251
|
+
destroySession,
|
|
252
|
+
scheduleDestroy,
|
|
253
|
+
destroyAll,
|
|
254
|
+
getSession,
|
|
255
|
+
getSessionCount,
|
|
256
|
+
getSessionList,
|
|
257
|
+
getDaemonPtyPids,
|
|
258
|
+
};
|