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/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();