ghostterm 1.3.0 → 2.0.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/CHANGELOG.md +20 -101
- package/README.md +25 -267
- package/bin/ghostterm-p2p.js +312 -0
- package/lib/auth.js +152 -0
- package/lib/pty-manager.js +336 -0
- package/lib/webrtc-peer.js +193 -0
- package/package.json +23 -19
- package/bin/ghostterm.js +0 -726
package/bin/ghostterm.js
DELETED
|
@@ -1,726 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const WebSocket = require('ws');
|
|
4
|
-
const http = require('http');
|
|
5
|
-
const os = require('os');
|
|
6
|
-
const path = require('path');
|
|
7
|
-
const fs = require('fs');
|
|
8
|
-
const { exec } = require('child_process');
|
|
9
|
-
|
|
10
|
-
let pty;
|
|
11
|
-
try {
|
|
12
|
-
pty = require('node-pty');
|
|
13
|
-
} catch (e) {
|
|
14
|
-
console.error('');
|
|
15
|
-
console.error(' node-pty is required but failed to load.');
|
|
16
|
-
console.error(' On Windows, you may need: npm install --global windows-build-tools');
|
|
17
|
-
console.error(' On macOS/Linux, you may need: xcode-select --install or apt install build-essential');
|
|
18
|
-
console.error('');
|
|
19
|
-
console.error(' Error:', e.message);
|
|
20
|
-
process.exit(1);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const RELAY_URL = process.env.GHOSTTERM_RELAY || 'wss://ghostterm-relay.fly.dev?role=companion';
|
|
24
|
-
const GOOGLE_CLIENT_ID = '967114885808-9lrt6j05ip65t88sm1rq0foetcu52kdv.apps.googleusercontent.com';
|
|
25
|
-
const MAX_SESSIONS = 4;
|
|
26
|
-
const UPLOAD_DIR = path.join(os.homedir(), 'Desktop', 'claude-uploads');
|
|
27
|
-
const CRED_DIR = path.join(os.homedir(), '.ghostterm');
|
|
28
|
-
const CRED_FILE = path.join(CRED_DIR, 'credentials.json');
|
|
29
|
-
|
|
30
|
-
if (!fs.existsSync(UPLOAD_DIR)) {
|
|
31
|
-
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// ==================== Google Auth ====================
|
|
35
|
-
function loadToken() {
|
|
36
|
-
try {
|
|
37
|
-
if (fs.existsSync(CRED_FILE)) {
|
|
38
|
-
const cred = JSON.parse(fs.readFileSync(CRED_FILE, 'utf8'));
|
|
39
|
-
if (cred.id_token) {
|
|
40
|
-
// Long token format: base64payload.signature (no dots in payload)
|
|
41
|
-
const parts = cred.id_token.split('.');
|
|
42
|
-
if (parts.length === 2) {
|
|
43
|
-
// Long token — check our own expiry
|
|
44
|
-
try {
|
|
45
|
-
const data = JSON.parse(Buffer.from(parts[0], 'base64').toString());
|
|
46
|
-
if (data.exp > Date.now()) return cred.id_token;
|
|
47
|
-
console.log(' Long token expired, need to re-login');
|
|
48
|
-
} catch {}
|
|
49
|
-
} else if (parts.length === 3) {
|
|
50
|
-
// Google JWT — check Google expiry
|
|
51
|
-
try {
|
|
52
|
-
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
|
53
|
-
if (payload.exp * 1000 > Date.now()) return cred.id_token;
|
|
54
|
-
console.log(' Google token expired, need to re-login');
|
|
55
|
-
} catch {}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
} catch {}
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function saveToken(idToken) {
|
|
64
|
-
if (!fs.existsSync(CRED_DIR)) {
|
|
65
|
-
fs.mkdirSync(CRED_DIR, { recursive: true });
|
|
66
|
-
}
|
|
67
|
-
fs.writeFileSync(CRED_FILE, JSON.stringify({ id_token: idToken }, null, 2));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function openBrowser(url) {
|
|
71
|
-
const cmd = os.platform() === 'win32' ? `start "" "${url}"`
|
|
72
|
-
: os.platform() === 'darwin' ? `open "${url}"`
|
|
73
|
-
: `xdg-open "${url}"`;
|
|
74
|
-
exec(cmd);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function startLoginFlow() {
|
|
78
|
-
return new Promise((resolve, reject) => {
|
|
79
|
-
const loginPage = `<!DOCTYPE html>
|
|
80
|
-
<html><head>
|
|
81
|
-
<meta charset="UTF-8">
|
|
82
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
83
|
-
<title>GhostTerm - Sign In</title>
|
|
84
|
-
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
|
85
|
-
<style>
|
|
86
|
-
body { font-family: -apple-system, system-ui, sans-serif; background: #0f0f1a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
|
87
|
-
.container { text-align: center; }
|
|
88
|
-
h1 { color: #8b5cf6; margin-bottom: 8px; }
|
|
89
|
-
p { color: #94a3b8; margin-bottom: 24px; }
|
|
90
|
-
#status { margin-top: 20px; color: #10b981; display: none; font-size: 18px; }
|
|
91
|
-
</style>
|
|
92
|
-
</head><body>
|
|
93
|
-
<div class="container">
|
|
94
|
-
<h1>GhostTerm</h1>
|
|
95
|
-
<p>Sign in with Google to link your companion</p>
|
|
96
|
-
<div id="g_id_onload"
|
|
97
|
-
data-client_id="${GOOGLE_CLIENT_ID}"
|
|
98
|
-
data-callback="onSignIn"
|
|
99
|
-
data-auto_select="true">
|
|
100
|
-
</div>
|
|
101
|
-
<div class="g_id_signin" data-type="standard" data-size="large" data-theme="filled_black" data-text="signin_with" data-width="300"></div>
|
|
102
|
-
<div id="status">Login successful! You can close this tab.</div>
|
|
103
|
-
</div>
|
|
104
|
-
<script>
|
|
105
|
-
function onSignIn(response) {
|
|
106
|
-
fetch('/callback', {
|
|
107
|
-
method: 'POST',
|
|
108
|
-
headers: { 'Content-Type': 'application/json' },
|
|
109
|
-
body: JSON.stringify({ id_token: response.credential })
|
|
110
|
-
}).then(() => {
|
|
111
|
-
document.getElementById('status').style.display = 'block';
|
|
112
|
-
document.querySelector('.g_id_signin').style.display = 'none';
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
</script>
|
|
116
|
-
</body></html>`;
|
|
117
|
-
|
|
118
|
-
const loginServer = http.createServer((req, res) => {
|
|
119
|
-
if (req.method === 'GET' && req.url === '/') {
|
|
120
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
121
|
-
res.end(loginPage);
|
|
122
|
-
} else if (req.method === 'POST' && req.url === '/callback') {
|
|
123
|
-
let body = '';
|
|
124
|
-
req.on('data', chunk => body += chunk);
|
|
125
|
-
req.on('end', () => {
|
|
126
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
127
|
-
res.end('{"ok":true}');
|
|
128
|
-
try {
|
|
129
|
-
const { id_token } = JSON.parse(body);
|
|
130
|
-
saveToken(id_token);
|
|
131
|
-
console.log(' Login successful!');
|
|
132
|
-
setTimeout(() => {
|
|
133
|
-
loginServer.close();
|
|
134
|
-
resolve(id_token);
|
|
135
|
-
}, 500);
|
|
136
|
-
} catch (e) {
|
|
137
|
-
loginServer.close(); // H-6: close server on error
|
|
138
|
-
reject(e);
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
} else {
|
|
142
|
-
res.writeHead(404);
|
|
143
|
-
res.end();
|
|
144
|
-
}
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
loginServer.listen(3000, () => {
|
|
148
|
-
console.log('');
|
|
149
|
-
console.log(' Opening browser for Google login...');
|
|
150
|
-
console.log(' If browser does not open, go to: http://localhost:3000');
|
|
151
|
-
console.log('');
|
|
152
|
-
openBrowser('http://localhost:3000');
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
setTimeout(() => {
|
|
156
|
-
loginServer.close();
|
|
157
|
-
reject(new Error('Login timeout (2 min)'));
|
|
158
|
-
}, 120000);
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async function getToken() {
|
|
163
|
-
let token = loadToken();
|
|
164
|
-
if (token) {
|
|
165
|
-
console.log(' Using cached Google login');
|
|
166
|
-
return token;
|
|
167
|
-
}
|
|
168
|
-
return await startLoginFlow();
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ==================== Terminal Sessions ====================
|
|
172
|
-
const sessions = new Map();
|
|
173
|
-
let nextSessionId = 1;
|
|
174
|
-
const OUTPUT_BUFFER_MAX = 500 * 1024;
|
|
175
|
-
const MAX_INPUT_PAYLOAD = 64 * 1024; // L-1: 64KB max input
|
|
176
|
-
let lastCreateSessionTime = 0; // M-3: create-session cooldown
|
|
177
|
-
|
|
178
|
-
function createSession() {
|
|
179
|
-
const id = nextSessionId++;
|
|
180
|
-
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
|
181
|
-
let term;
|
|
182
|
-
try {
|
|
183
|
-
term = pty.spawn(shell, [], {
|
|
184
|
-
name: 'xterm-256color',
|
|
185
|
-
cols: 80,
|
|
186
|
-
rows: 24,
|
|
187
|
-
cwd: path.join(os.homedir(), 'Desktop'),
|
|
188
|
-
env: (() => { const e = { ...process.env, TERM: 'xterm-256color' }; delete e.CLAUDECODE; delete e.CLAUDE_CODE; return e; })(),
|
|
189
|
-
});
|
|
190
|
-
} catch (err) {
|
|
191
|
-
console.error(` Failed to spawn terminal ${id}: ${err.message}`);
|
|
192
|
-
sendToRelay({ type: 'error_msg', message: 'Failed to create terminal: ' + err.message });
|
|
193
|
-
return null;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const session = { id, term, outputBuffer: '', bufferSeq: 0, bufferStart: 0, exited: false, pendingData: '', flushTimer: null };
|
|
197
|
-
|
|
198
|
-
console.log(` Terminal ${id} spawned (PID: ${term.pid})`);
|
|
199
|
-
|
|
200
|
-
function flushOutput() {
|
|
201
|
-
if (session.pendingData) {
|
|
202
|
-
sendToRelay({ type: 'output', data: session.pendingData, seq: session.bufferSeq, id });
|
|
203
|
-
session.pendingData = '';
|
|
204
|
-
}
|
|
205
|
-
session.flushTimer = null;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
term.onData((data) => {
|
|
209
|
-
session.outputBuffer += data;
|
|
210
|
-
session.bufferSeq += data.length;
|
|
211
|
-
if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
|
|
212
|
-
const before = session.outputBuffer.length;
|
|
213
|
-
session.outputBuffer = session.outputBuffer.slice(-OUTPUT_BUFFER_MAX);
|
|
214
|
-
session.bufferStart += (before - session.outputBuffer.length); // H-1: track truncation
|
|
215
|
-
}
|
|
216
|
-
session.pendingData += data;
|
|
217
|
-
// Adaptive: small output (keystroke echo) → flush immediately
|
|
218
|
-
// Large output (bulk) → batch for 8ms
|
|
219
|
-
if (session.pendingData.length < 64) {
|
|
220
|
-
if (session.flushTimer) { clearTimeout(session.flushTimer); session.flushTimer = null; }
|
|
221
|
-
flushOutput();
|
|
222
|
-
} else if (!session.flushTimer) {
|
|
223
|
-
session.flushTimer = setTimeout(flushOutput, 8);
|
|
224
|
-
}
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
term.onExit(({ exitCode }) => {
|
|
228
|
-
console.log(` Terminal ${id} exited (code: ${exitCode})`);
|
|
229
|
-
session.exited = true;
|
|
230
|
-
sessions.delete(id);
|
|
231
|
-
sendToRelay({ type: 'exit', code: exitCode, id });
|
|
232
|
-
sendToRelay({ type: 'sessions', list: getSessionList() });
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
sessions.set(id, session);
|
|
236
|
-
return session;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function getSessionList() {
|
|
240
|
-
return Array.from(sessions.values()).map(s => ({ id: s.id, exited: s.exited }));
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ==================== Standby Session (pre-spawn for instant open) ====================
|
|
244
|
-
let standbySession = null;
|
|
245
|
-
let standbyTimerScheduled = false; // H-2: prevent multiple standby timers
|
|
246
|
-
|
|
247
|
-
function prepareStandby() {
|
|
248
|
-
standbyTimerScheduled = false; // H-2: clear flag when actually running
|
|
249
|
-
// Only prepare if under max and no standby exists
|
|
250
|
-
if (standbySession || sessions.size >= MAX_SESSIONS) return;
|
|
251
|
-
standbySession = createSession();
|
|
252
|
-
// Remove from active sessions — it's hidden until needed
|
|
253
|
-
sessions.delete(standbySession.id);
|
|
254
|
-
console.log(` Standby terminal ${standbySession.id} ready`);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function useStandbyOrCreate() {
|
|
258
|
-
let s;
|
|
259
|
-
if (standbySession && !standbySession.exited) {
|
|
260
|
-
s = standbySession;
|
|
261
|
-
sessions.set(s.id, s);
|
|
262
|
-
standbySession = null;
|
|
263
|
-
console.log(` Using standby terminal ${s.id}`);
|
|
264
|
-
} else {
|
|
265
|
-
standbySession = null;
|
|
266
|
-
s = createSession();
|
|
267
|
-
if (!s) return null; // spawn failed
|
|
268
|
-
}
|
|
269
|
-
// H-2: Prepare next standby after a short delay (only one timer)
|
|
270
|
-
if (!standbyTimerScheduled) {
|
|
271
|
-
standbyTimerScheduled = true;
|
|
272
|
-
setTimeout(() => prepareStandby(), 1000);
|
|
273
|
-
}
|
|
274
|
-
return s;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// ==================== Relay Connection ====================
|
|
278
|
-
let relayWs = null;
|
|
279
|
-
let pairCode = null;
|
|
280
|
-
let reconnectTimer = null;
|
|
281
|
-
let googleToken = null;
|
|
282
|
-
let _creatingSession = false;
|
|
283
|
-
const pendingMessages = []; // M-1: queue messages while disconnected
|
|
284
|
-
const PENDING_MSG_MAX = 50;
|
|
285
|
-
|
|
286
|
-
function connectToRelay() {
|
|
287
|
-
relayWs = new WebSocket(RELAY_URL);
|
|
288
|
-
|
|
289
|
-
let lastPingTime = Date.now();
|
|
290
|
-
let heartbeatCheck = null;
|
|
291
|
-
|
|
292
|
-
relayWs.on('open', () => {
|
|
293
|
-
console.log(' Connected to relay');
|
|
294
|
-
if (reconnectTimer) {
|
|
295
|
-
clearTimeout(reconnectTimer);
|
|
296
|
-
reconnectTimer = null;
|
|
297
|
-
}
|
|
298
|
-
lastPingTime = Date.now();
|
|
299
|
-
// Check for heartbeat timeout every 10s
|
|
300
|
-
heartbeatCheck = setInterval(() => {
|
|
301
|
-
if (Date.now() - lastPingTime > 40000) { // 40s no ping → dead
|
|
302
|
-
console.log(' No heartbeat from relay, forcing reconnect...');
|
|
303
|
-
if (heartbeatCheck) { clearInterval(heartbeatCheck); heartbeatCheck = null; }
|
|
304
|
-
relayWs?.close();
|
|
305
|
-
}
|
|
306
|
-
}, 10000);
|
|
307
|
-
if (googleToken) {
|
|
308
|
-
sendToRelay({ type: 'auth', token: googleToken });
|
|
309
|
-
}
|
|
310
|
-
flushPendingMessages(); // M-1: flush queued messages on reconnect
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
relayWs.on('message', (data) => {
|
|
314
|
-
let msg;
|
|
315
|
-
try { msg = JSON.parse(data); } catch { return; }
|
|
316
|
-
// Reply to relay heartbeat ping
|
|
317
|
-
if (msg.type === 'ping') {
|
|
318
|
-
lastPingTime = Date.now();
|
|
319
|
-
sendToRelay({ type: 'pong' });
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
handleRelayMessage(msg);
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
relayWs.on('close', () => {
|
|
326
|
-
if (heartbeatCheck) { clearInterval(heartbeatCheck); heartbeatCheck = null; }
|
|
327
|
-
console.log(' Disconnected, reconnecting in 3s...');
|
|
328
|
-
relayWs = null;
|
|
329
|
-
reconnectTimer = setTimeout(connectToRelay, 3000);
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
relayWs.on('error', (err) => {
|
|
333
|
-
console.error(' Relay error:', err.message);
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function sendToRelay(msg) {
|
|
338
|
-
if (relayWs?.readyState === WebSocket.OPEN) {
|
|
339
|
-
try {
|
|
340
|
-
relayWs.send(JSON.stringify(msg));
|
|
341
|
-
} catch (err) {
|
|
342
|
-
console.error(` Send failed: ${err.message}`);
|
|
343
|
-
}
|
|
344
|
-
} else {
|
|
345
|
-
// M-1: queue important messages (not output/pong) while disconnected
|
|
346
|
-
if (msg.type !== 'output' && msg.type !== 'pong' && msg.type !== 'delta') {
|
|
347
|
-
if (pendingMessages.length < PENDING_MSG_MAX) {
|
|
348
|
-
pendingMessages.push(msg);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function flushPendingMessages() {
|
|
355
|
-
while (pendingMessages.length > 0 && relayWs?.readyState === WebSocket.OPEN) {
|
|
356
|
-
const msg = pendingMessages.shift();
|
|
357
|
-
try {
|
|
358
|
-
relayWs.send(JSON.stringify(msg));
|
|
359
|
-
} catch (err) {
|
|
360
|
-
console.error(` Flush failed: ${err.message}`);
|
|
361
|
-
break;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// ==================== Handle Messages ====================
|
|
367
|
-
function handleRelayMessage(msg) {
|
|
368
|
-
switch (msg.type) {
|
|
369
|
-
case 'pair_code':
|
|
370
|
-
pairCode = msg.code;
|
|
371
|
-
console.log('');
|
|
372
|
-
console.log(' ┌────────────────────────────────────┐');
|
|
373
|
-
console.log(` │ Pairing Code: ${pairCode} │`);
|
|
374
|
-
console.log(' │ Open ghostterm.pages.dev on phone │');
|
|
375
|
-
console.log(' └────────────────────────────────────┘');
|
|
376
|
-
console.log('');
|
|
377
|
-
break;
|
|
378
|
-
|
|
379
|
-
case 'auth_ok':
|
|
380
|
-
console.log(` Authenticated as: ${msg.email}`);
|
|
381
|
-
console.log(' Phone will auto-connect with same Google account');
|
|
382
|
-
// Save long token if provided (valid 30 days, no more Google expiry issues)
|
|
383
|
-
if (msg.longToken) {
|
|
384
|
-
saveToken(msg.longToken);
|
|
385
|
-
googleToken = msg.longToken;
|
|
386
|
-
console.log(' Long-lived token saved (30 days)');
|
|
387
|
-
}
|
|
388
|
-
prepareStandby();
|
|
389
|
-
break;
|
|
390
|
-
|
|
391
|
-
case 'auth_error':
|
|
392
|
-
console.log(` Auth failed: ${msg.message}`);
|
|
393
|
-
console.log(' Using pairing code instead');
|
|
394
|
-
googleToken = null;
|
|
395
|
-
try { fs.unlinkSync(CRED_FILE); } catch {}
|
|
396
|
-
break;
|
|
397
|
-
|
|
398
|
-
case 'code_expired':
|
|
399
|
-
console.log(' Code expired, reconnecting...');
|
|
400
|
-
relayWs?.close();
|
|
401
|
-
break;
|
|
402
|
-
|
|
403
|
-
case 'mobile_connected':
|
|
404
|
-
console.log(' Mobile connected! (sessions: ' + sessions.size + ')');
|
|
405
|
-
// Always send current session list — let mobile decide if it needs more
|
|
406
|
-
// Only auto-create first session if mobile has never paired before (sessions.size === 0)
|
|
407
|
-
// Mobile will send 'create-session' if it needs one
|
|
408
|
-
sendToRelay({ type: 'sessions', list: getSessionList() });
|
|
409
|
-
if (sessions.size > 0) {
|
|
410
|
-
const first = sessions.values().next().value;
|
|
411
|
-
if (first) sendToRelay({ type: 'attached', id: first.id });
|
|
412
|
-
}
|
|
413
|
-
// If no sessions exist, don't auto-create — mobile's relay_paired handler
|
|
414
|
-
// will send 'create-session' after 2 seconds if currentSessionId is null
|
|
415
|
-
break;
|
|
416
|
-
|
|
417
|
-
case 'mobile_disconnected':
|
|
418
|
-
console.log(' Mobile disconnected');
|
|
419
|
-
break;
|
|
420
|
-
|
|
421
|
-
case 'input':
|
|
422
|
-
if (msg.sessionId) {
|
|
423
|
-
// L-1: reject oversized input
|
|
424
|
-
if (typeof msg.data === 'string' && msg.data.length > MAX_INPUT_PAYLOAD) break;
|
|
425
|
-
const session = sessions.get(msg.sessionId);
|
|
426
|
-
if (session && !session.exited) session.term.write(msg.data);
|
|
427
|
-
}
|
|
428
|
-
break;
|
|
429
|
-
|
|
430
|
-
case 'resize':
|
|
431
|
-
if (msg.sessionId) {
|
|
432
|
-
const session = sessions.get(msg.sessionId);
|
|
433
|
-
if (session && !session.exited) {
|
|
434
|
-
try { session.term.resize(Math.min(Math.max(msg.cols, 10), 300), Math.min(Math.max(msg.rows, 4), 100)); } catch {}
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
break;
|
|
438
|
-
|
|
439
|
-
case 'create-session':
|
|
440
|
-
if (_creatingSession) break; // prevent duplicate creation
|
|
441
|
-
// M-3: rate limit — at least 1s between creates
|
|
442
|
-
if (Date.now() - lastCreateSessionTime < 1000) {
|
|
443
|
-
sendToRelay({ type: 'error_msg', message: 'Please wait before creating another session' });
|
|
444
|
-
break;
|
|
445
|
-
}
|
|
446
|
-
if (sessions.size < MAX_SESSIONS) {
|
|
447
|
-
_creatingSession = true;
|
|
448
|
-
lastCreateSessionTime = Date.now(); // M-3: track creation time
|
|
449
|
-
const session = useStandbyOrCreate();
|
|
450
|
-
_creatingSession = false;
|
|
451
|
-
if (session) {
|
|
452
|
-
sendToRelay({ type: 'sessions', list: getSessionList() });
|
|
453
|
-
sendToRelay({ type: 'attached', id: session.id });
|
|
454
|
-
}
|
|
455
|
-
} else {
|
|
456
|
-
sendToRelay({ type: 'error_msg', message: `Max ${MAX_SESSIONS} sessions` });
|
|
457
|
-
}
|
|
458
|
-
break;
|
|
459
|
-
|
|
460
|
-
case 'close-session': {
|
|
461
|
-
const s = sessions.get(msg.id);
|
|
462
|
-
if (s && !s.exited) s.term.kill();
|
|
463
|
-
break;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
case 'attach': {
|
|
467
|
-
const session = sessions.get(msg.id);
|
|
468
|
-
if (session && !session.exited) sendToRelay({ type: 'attached', id: msg.id });
|
|
469
|
-
break;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
case 'sync': {
|
|
473
|
-
const syncSession = sessions.get(msg.id);
|
|
474
|
-
if (!syncSession || syncSession.exited) return;
|
|
475
|
-
const bufStart = syncSession.bufferStart; // H-1: use tracked bufferStart
|
|
476
|
-
if (msg.clientSeq >= syncSession.bufferSeq) {
|
|
477
|
-
sendToRelay({ type: 'delta', data: '', seq: syncSession.bufferSeq, id: msg.id });
|
|
478
|
-
} else if (msg.clientSeq >= bufStart) {
|
|
479
|
-
const offset = msg.clientSeq - bufStart;
|
|
480
|
-
sendToRelay({ type: 'delta', data: syncSession.outputBuffer.slice(offset), seq: syncSession.bufferSeq, id: msg.id });
|
|
481
|
-
} else {
|
|
482
|
-
sendToRelay({ type: 'history', data: syncSession.outputBuffer, seq: syncSession.bufferSeq, id: msg.id });
|
|
483
|
-
}
|
|
484
|
-
break;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
case 'upload':
|
|
488
|
-
try {
|
|
489
|
-
const UPLOAD_MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
|
490
|
-
const UPLOAD_EXT_WHITELIST = ['.png', '.jpg', '.jpeg', '.pdf', '.txt', '.md', '.json', '.csv', '.py', '.js', '.ts', '.html', '.css'];
|
|
491
|
-
const ext = (msg.ext || '.png').toLowerCase();
|
|
492
|
-
if (!UPLOAD_EXT_WHITELIST.includes(ext)) {
|
|
493
|
-
sendToRelay({ type: 'upload_error', message: `File type not allowed: ${ext}` });
|
|
494
|
-
break;
|
|
495
|
-
}
|
|
496
|
-
const buf = Buffer.from(msg.data, 'base64');
|
|
497
|
-
if (buf.length > UPLOAD_MAX_SIZE) {
|
|
498
|
-
sendToRelay({ type: 'upload_error', message: `File too large: ${(buf.length / 1024 / 1024).toFixed(1)}MB (max 5MB)` });
|
|
499
|
-
break;
|
|
500
|
-
}
|
|
501
|
-
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}${ext}`;
|
|
502
|
-
const filePath = path.join(UPLOAD_DIR, filename);
|
|
503
|
-
fs.writeFileSync(filePath, buf);
|
|
504
|
-
sendToRelay({ type: 'upload_result', path: filePath, filename, size: buf.length });
|
|
505
|
-
console.log(` File saved: ${filePath}`);
|
|
506
|
-
} catch (err) {
|
|
507
|
-
sendToRelay({ type: 'upload_error', message: err.message });
|
|
508
|
-
}
|
|
509
|
-
break;
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// ==================== Background Mode ====================
|
|
514
|
-
const PID_FILE = path.join(CRED_DIR, 'ghostterm.pid');
|
|
515
|
-
const LOG_FILE = path.join(CRED_DIR, 'ghostterm.log');
|
|
516
|
-
|
|
517
|
-
function isRunning(pid) {
|
|
518
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
function handleSubcommand() {
|
|
522
|
-
const arg = process.argv[2];
|
|
523
|
-
|
|
524
|
-
if (arg === 'stop') {
|
|
525
|
-
if (fs.existsSync(PID_FILE)) {
|
|
526
|
-
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8'));
|
|
527
|
-
if (isRunning(pid)) {
|
|
528
|
-
process.kill(pid);
|
|
529
|
-
fs.unlinkSync(PID_FILE);
|
|
530
|
-
console.log(` GhostTerm stopped (PID: ${pid})`);
|
|
531
|
-
} else {
|
|
532
|
-
fs.unlinkSync(PID_FILE);
|
|
533
|
-
console.log(' GhostTerm was not running');
|
|
534
|
-
}
|
|
535
|
-
} else {
|
|
536
|
-
console.log(' GhostTerm is not running');
|
|
537
|
-
}
|
|
538
|
-
process.exit(0);
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
if (arg === 'status') {
|
|
542
|
-
if (fs.existsSync(PID_FILE)) {
|
|
543
|
-
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8'));
|
|
544
|
-
if (isRunning(pid)) {
|
|
545
|
-
console.log(` GhostTerm is running (PID: ${pid})`);
|
|
546
|
-
} else {
|
|
547
|
-
fs.unlinkSync(PID_FILE);
|
|
548
|
-
console.log(' GhostTerm is not running');
|
|
549
|
-
}
|
|
550
|
-
} else {
|
|
551
|
-
console.log(' GhostTerm is not running');
|
|
552
|
-
}
|
|
553
|
-
process.exit(0);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
if (arg === 'logs') {
|
|
557
|
-
if (fs.existsSync(LOG_FILE)) {
|
|
558
|
-
const lines = fs.readFileSync(LOG_FILE, 'utf8').split('\n').slice(-50);
|
|
559
|
-
console.log(lines.join('\n'));
|
|
560
|
-
} else {
|
|
561
|
-
console.log(' No logs found');
|
|
562
|
-
}
|
|
563
|
-
process.exit(0);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
// No subcommand = start in background (unless already daemonized via --daemon or --supervisor)
|
|
567
|
-
if (arg !== '--daemon' && arg !== '--supervisor') {
|
|
568
|
-
// Check if already running
|
|
569
|
-
if (fs.existsSync(PID_FILE)) {
|
|
570
|
-
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8'));
|
|
571
|
-
if (isRunning(pid)) {
|
|
572
|
-
console.log(` GhostTerm is already running (PID: ${pid})`);
|
|
573
|
-
console.log(' Stop: npx ghostterm stop');
|
|
574
|
-
process.exit(0);
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// First-time login needs foreground (browser opens)
|
|
579
|
-
const needsLogin = !fs.existsSync(CRED_FILE);
|
|
580
|
-
if (needsLogin) {
|
|
581
|
-
// Run in foreground for first login, then restart as daemon
|
|
582
|
-
return 'foreground-first-login';
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// Spawn self as supervisor in background
|
|
586
|
-
const { spawn, execSync } = require('child_process');
|
|
587
|
-
const out = fs.openSync(LOG_FILE, 'a');
|
|
588
|
-
|
|
589
|
-
if (os.platform() === 'win32') {
|
|
590
|
-
// Windows: use VBS to launch completely hidden (no taskbar, no window)
|
|
591
|
-
// node-pty needs a console; VBS ws.Run with 0 = hidden window
|
|
592
|
-
const vbsFile = path.join(CRED_DIR, 'launcher.vbs');
|
|
593
|
-
const cmd = `"${process.execPath}" "${__filename}" --supervisor`;
|
|
594
|
-
fs.writeFileSync(vbsFile,
|
|
595
|
-
`Set ws = CreateObject("WScript.Shell")\n` +
|
|
596
|
-
`ws.Run "${cmd.replace(/"/g, '""')}", 0, False\n`
|
|
597
|
-
);
|
|
598
|
-
require('child_process').execSync(`cscript //nologo "${vbsFile}"`, { stdio: 'ignore' });
|
|
599
|
-
try { fs.unlinkSync(vbsFile); } catch {} // L-3: clean up VBS launcher
|
|
600
|
-
// Wait for supervisor to write PID file
|
|
601
|
-
const waitUntil = Date.now() + 10000;
|
|
602
|
-
while (Date.now() < waitUntil) {
|
|
603
|
-
if (fs.existsSync(PID_FILE)) {
|
|
604
|
-
const pid = fs.readFileSync(PID_FILE, 'utf8').trim();
|
|
605
|
-
if (pid && isRunning(parseInt(pid))) break;
|
|
606
|
-
}
|
|
607
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
|
|
608
|
-
}
|
|
609
|
-
} else {
|
|
610
|
-
// macOS/Linux: detached works fine
|
|
611
|
-
const child = spawn(process.execPath, [__filename, '--supervisor'], {
|
|
612
|
-
detached: true,
|
|
613
|
-
stdio: ['ignore', out, out],
|
|
614
|
-
env: { ...process.env },
|
|
615
|
-
});
|
|
616
|
-
child.unref();
|
|
617
|
-
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
618
|
-
}
|
|
619
|
-
const pid = fs.existsSync(PID_FILE) ? fs.readFileSync(PID_FILE, 'utf8').trim() : '?';
|
|
620
|
-
console.log('');
|
|
621
|
-
console.log(` ✅ GhostTerm running in background (PID: ${pid})`);
|
|
622
|
-
console.log(' 📱 Open: ghostterm.pages.dev');
|
|
623
|
-
console.log(' 🛑 Stop: npx ghostterm stop');
|
|
624
|
-
console.log(' 📋 Logs: npx ghostterm logs');
|
|
625
|
-
console.log('');
|
|
626
|
-
process.exit(0);
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// --supervisor: watchdog that restarts worker on crash
|
|
630
|
-
if (arg === '--supervisor') {
|
|
631
|
-
fs.writeFileSync(PID_FILE, String(process.pid));
|
|
632
|
-
const { spawn } = require('child_process');
|
|
633
|
-
let restartCount = 0;
|
|
634
|
-
|
|
635
|
-
function spawnWorker() {
|
|
636
|
-
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
637
|
-
const spawnOpts = {
|
|
638
|
-
stdio: ['ignore', logFd, logFd],
|
|
639
|
-
env: { ...process.env },
|
|
640
|
-
};
|
|
641
|
-
// On Windows, worker needs a console for node-pty (AttachConsole)
|
|
642
|
-
// Inherit stdin (console) but redirect stdout/stderr to log file
|
|
643
|
-
if (os.platform() === 'win32') {
|
|
644
|
-
spawnOpts.stdio = ['inherit', logFd, logFd];
|
|
645
|
-
}
|
|
646
|
-
const worker = spawn(process.execPath, [__filename, '--daemon'], spawnOpts);
|
|
647
|
-
worker.on('exit', (code) => {
|
|
648
|
-
if (code === 0) {
|
|
649
|
-
// Clean exit (e.g. from stop command), don't restart
|
|
650
|
-
process.exit(0);
|
|
651
|
-
}
|
|
652
|
-
restartCount++;
|
|
653
|
-
const delay = Math.min(restartCount * 3000, 30000); // 3s, 6s, 9s... max 30s
|
|
654
|
-
console.log(` [supervisor] Worker exited (code ${code}), restarting in ${delay / 1000}s... (restart #${restartCount})`);
|
|
655
|
-
setTimeout(spawnWorker, delay);
|
|
656
|
-
});
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
spawnWorker();
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
// --daemon: write PID file and continue to main()
|
|
664
|
-
return 'daemon';
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// ==================== Graceful Shutdown (L-6) ====================
|
|
668
|
-
function gracefulShutdown(signal) {
|
|
669
|
-
console.log(` Received ${signal}, shutting down...`);
|
|
670
|
-
// Kill all terminal sessions
|
|
671
|
-
for (const [id, session] of sessions) {
|
|
672
|
-
if (!session.exited) {
|
|
673
|
-
try { session.term.kill(); } catch {}
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
sessions.clear();
|
|
677
|
-
// Kill standby session
|
|
678
|
-
if (standbySession && !standbySession.exited) {
|
|
679
|
-
try { standbySession.term.kill(); } catch {}
|
|
680
|
-
standbySession = null;
|
|
681
|
-
}
|
|
682
|
-
// Close relay connection
|
|
683
|
-
if (relayWs) {
|
|
684
|
-
try { relayWs.close(); } catch {}
|
|
685
|
-
relayWs = null;
|
|
686
|
-
}
|
|
687
|
-
// Clean PID file
|
|
688
|
-
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
689
|
-
process.exit(0);
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
693
|
-
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
694
|
-
|
|
695
|
-
// ==================== Main ====================
|
|
696
|
-
async function main() {
|
|
697
|
-
const mode = handleSubcommand();
|
|
698
|
-
|
|
699
|
-
console.log('');
|
|
700
|
-
console.log(' ╔═══════════════════════════════════╗');
|
|
701
|
-
console.log(' ║ GhostTerm Companion ║');
|
|
702
|
-
console.log(' ║ Mobile terminal for Claude Code ║');
|
|
703
|
-
console.log(' ╚═══════════════════════════════════╝');
|
|
704
|
-
console.log('');
|
|
705
|
-
|
|
706
|
-
try {
|
|
707
|
-
googleToken = await getToken();
|
|
708
|
-
} catch (err) {
|
|
709
|
-
console.log(` Google login skipped: ${err.message}`);
|
|
710
|
-
console.log(' Will use pairing code instead');
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// First login done in foreground — now restart in background
|
|
714
|
-
if (mode === 'foreground-first-login' && googleToken) {
|
|
715
|
-
console.log('');
|
|
716
|
-
console.log(' Login successful! Starting in background...');
|
|
717
|
-
// Re-run ourselves which will take the normal background start path
|
|
718
|
-
const { execSync } = require('child_process');
|
|
719
|
-
execSync(`"${process.execPath}" "${__filename}"`, { stdio: 'inherit' });
|
|
720
|
-
process.exit(0);
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
connectToRelay();
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
main();
|