ghostterm 1.2.2 → 1.3.0
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 +37 -0
- package/README.md +3 -1
- package/bin/ghostterm.js +137 -25
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.0 (2026-03-17)
|
|
4
|
+
|
|
5
|
+
### Stability (35 issues fixed)
|
|
6
|
+
- **Graceful shutdown** — SIGTERM/SIGINT properly kills all PTY sessions, closes relay, cleans PID file
|
|
7
|
+
- **Heartbeat timeout** — detects dead relay within 40s, forces reconnect
|
|
8
|
+
- **Buffer backpressure** — output buffer truncation now tracks `bufferStart` for correct delta sync
|
|
9
|
+
- **Message queue** — queues up to 50 messages while disconnected, flushes on reconnect
|
|
10
|
+
- **Spawn error handling** — node-pty spawn failure no longer crashes companion
|
|
11
|
+
- **Create-session lock** — prevents duplicate session creation from rapid requests
|
|
12
|
+
- **Create-session cooldown** — 1 second minimum between creates
|
|
13
|
+
- **Input size limit** — rejects input payloads over 64KB
|
|
14
|
+
- **Standby timer dedup** — prevents multiple standby preparation timers
|
|
15
|
+
- **Login server cleanup** — closes HTTP server immediately on auth error
|
|
16
|
+
- **VBS launcher cleanup** — deletes temporary launcher.vbs after use
|
|
17
|
+
- **Send error handling** — ws.send wrapped in try-catch
|
|
18
|
+
- **Worker stdio fix** — Windows worker inherits stdin for node-pty console compatibility
|
|
19
|
+
|
|
20
|
+
### Relay Improvements (deployed separately)
|
|
21
|
+
- Graceful shutdown, email validation, dead pair code reuse, dual companion protection
|
|
22
|
+
- Sessions list caching for instant mobile reconnect
|
|
23
|
+
- Message forwarding validation, health endpoint hardened
|
|
24
|
+
|
|
25
|
+
## 1.2.3 (2026-03-17)
|
|
26
|
+
|
|
27
|
+
### Features (Frontend)
|
|
28
|
+
- **Long-press acceleration** — Backspace, arrow keys, Space, scroll buttons repeat with increasing speed (150ms → 30ms)
|
|
29
|
+
- **Tap feedback** — all buttons show scale + color animation on press
|
|
30
|
+
- **Paste button always visible** — shows "no file" when empty, glows on upload
|
|
31
|
+
- **iOS file picker fix** — transparent file input overlay (bypasses iOS trusted gesture restriction)
|
|
32
|
+
- **Service Worker removed** — fixes stale cache preventing updates
|
|
33
|
+
- **Landing page SEO** — Open Graph tags, Twitter cards, PWA meta
|
|
34
|
+
|
|
35
|
+
### Bug Fixes
|
|
36
|
+
- **Cloudflare deploy** — must use `--branch=main` for production (was deploying to Preview)
|
|
37
|
+
- **D-pad escape sequences** — `data-repeat` attribute now properly converts `\x1b` to ESC character
|
|
38
|
+
- **Removed debug console.log** — no longer leaks sessionId to browser console
|
|
39
|
+
|
|
3
40
|
## 1.2.2 (2026-03-17)
|
|
4
41
|
|
|
5
42
|
### Bug Fixes
|
package/README.md
CHANGED
|
@@ -91,16 +91,18 @@ Open **[ghostterm.pages.dev](https://ghostterm.pages.dev)** in any mobile browse
|
|
|
91
91
|
- **Quick keys** — one-tap `y`/`n` for Claude Code approvals
|
|
92
92
|
- **12345 numpad** — tap to pop up number selection for CLI surveys/prompts (raw keypress, no Enter)
|
|
93
93
|
- **D-pad** — arrow keys, Enter, Tab, Shift+Tab, Space
|
|
94
|
+
- **Long-press acceleration** — hold Backspace, arrow keys, Space, or scroll buttons to repeat with increasing speed
|
|
94
95
|
- **`claude` button** — quick-launch menu: new session, resume, continue, dangerous mode
|
|
95
96
|
- **Ctrl+C (Stop)** — interrupt running processes
|
|
96
97
|
- **Text input** — full keyboard input with Send button
|
|
98
|
+
- **Tap feedback** — all buttons show visual press animation
|
|
97
99
|
- **Copy mode** — select and copy terminal text
|
|
98
100
|
|
|
99
101
|
### File Transfer
|
|
100
102
|
|
|
101
103
|
- **Screen** — capture your terminal screen and send it to your PC as a file
|
|
102
104
|
- **File upload** — upload files directly from your phone to your desktop
|
|
103
|
-
- **Paste button** — after
|
|
105
|
+
- **Paste button** — always visible; glows for 5 seconds after upload with shortened filename; tap to paste the file path into the terminal
|
|
104
106
|
|
|
105
107
|
### Visual
|
|
106
108
|
|
package/bin/ghostterm.js
CHANGED
|
@@ -134,6 +134,7 @@ function onSignIn(response) {
|
|
|
134
134
|
resolve(id_token);
|
|
135
135
|
}, 500);
|
|
136
136
|
} catch (e) {
|
|
137
|
+
loginServer.close(); // H-6: close server on error
|
|
137
138
|
reject(e);
|
|
138
139
|
}
|
|
139
140
|
});
|
|
@@ -171,19 +172,28 @@ async function getToken() {
|
|
|
171
172
|
const sessions = new Map();
|
|
172
173
|
let nextSessionId = 1;
|
|
173
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
|
|
174
177
|
|
|
175
178
|
function createSession() {
|
|
176
179
|
const id = nextSessionId++;
|
|
177
180
|
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
}
|
|
185
195
|
|
|
186
|
-
const session = { id, term, outputBuffer: '', bufferSeq: 0, exited: false, pendingData: '', flushTimer: null };
|
|
196
|
+
const session = { id, term, outputBuffer: '', bufferSeq: 0, bufferStart: 0, exited: false, pendingData: '', flushTimer: null };
|
|
187
197
|
|
|
188
198
|
console.log(` Terminal ${id} spawned (PID: ${term.pid})`);
|
|
189
199
|
|
|
@@ -199,7 +209,9 @@ function createSession() {
|
|
|
199
209
|
session.outputBuffer += data;
|
|
200
210
|
session.bufferSeq += data.length;
|
|
201
211
|
if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
|
|
212
|
+
const before = session.outputBuffer.length;
|
|
202
213
|
session.outputBuffer = session.outputBuffer.slice(-OUTPUT_BUFFER_MAX);
|
|
214
|
+
session.bufferStart += (before - session.outputBuffer.length); // H-1: track truncation
|
|
203
215
|
}
|
|
204
216
|
session.pendingData += data;
|
|
205
217
|
// Adaptive: small output (keystroke echo) → flush immediately
|
|
@@ -230,8 +242,10 @@ function getSessionList() {
|
|
|
230
242
|
|
|
231
243
|
// ==================== Standby Session (pre-spawn for instant open) ====================
|
|
232
244
|
let standbySession = null;
|
|
245
|
+
let standbyTimerScheduled = false; // H-2: prevent multiple standby timers
|
|
233
246
|
|
|
234
247
|
function prepareStandby() {
|
|
248
|
+
standbyTimerScheduled = false; // H-2: clear flag when actually running
|
|
235
249
|
// Only prepare if under max and no standby exists
|
|
236
250
|
if (standbySession || sessions.size >= MAX_SESSIONS) return;
|
|
237
251
|
standbySession = createSession();
|
|
@@ -250,9 +264,13 @@ function useStandbyOrCreate() {
|
|
|
250
264
|
} else {
|
|
251
265
|
standbySession = null;
|
|
252
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);
|
|
253
273
|
}
|
|
254
|
-
// Prepare next standby after a short delay
|
|
255
|
-
setTimeout(() => prepareStandby(), 1000);
|
|
256
274
|
return s;
|
|
257
275
|
}
|
|
258
276
|
|
|
@@ -261,28 +279,51 @@ let relayWs = null;
|
|
|
261
279
|
let pairCode = null;
|
|
262
280
|
let reconnectTimer = null;
|
|
263
281
|
let googleToken = null;
|
|
282
|
+
let _creatingSession = false;
|
|
283
|
+
const pendingMessages = []; // M-1: queue messages while disconnected
|
|
284
|
+
const PENDING_MSG_MAX = 50;
|
|
264
285
|
|
|
265
286
|
function connectToRelay() {
|
|
266
287
|
relayWs = new WebSocket(RELAY_URL);
|
|
267
288
|
|
|
289
|
+
let lastPingTime = Date.now();
|
|
290
|
+
let heartbeatCheck = null;
|
|
291
|
+
|
|
268
292
|
relayWs.on('open', () => {
|
|
269
293
|
console.log(' Connected to relay');
|
|
270
294
|
if (reconnectTimer) {
|
|
271
295
|
clearTimeout(reconnectTimer);
|
|
272
296
|
reconnectTimer = null;
|
|
273
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);
|
|
274
307
|
if (googleToken) {
|
|
275
308
|
sendToRelay({ type: 'auth', token: googleToken });
|
|
276
309
|
}
|
|
310
|
+
flushPendingMessages(); // M-1: flush queued messages on reconnect
|
|
277
311
|
});
|
|
278
312
|
|
|
279
313
|
relayWs.on('message', (data) => {
|
|
280
314
|
let msg;
|
|
281
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
|
+
}
|
|
282
322
|
handleRelayMessage(msg);
|
|
283
323
|
});
|
|
284
324
|
|
|
285
325
|
relayWs.on('close', () => {
|
|
326
|
+
if (heartbeatCheck) { clearInterval(heartbeatCheck); heartbeatCheck = null; }
|
|
286
327
|
console.log(' Disconnected, reconnecting in 3s...');
|
|
287
328
|
relayWs = null;
|
|
288
329
|
reconnectTimer = setTimeout(connectToRelay, 3000);
|
|
@@ -295,7 +336,30 @@ function connectToRelay() {
|
|
|
295
336
|
|
|
296
337
|
function sendToRelay(msg) {
|
|
297
338
|
if (relayWs?.readyState === WebSocket.OPEN) {
|
|
298
|
-
|
|
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
|
+
}
|
|
299
363
|
}
|
|
300
364
|
}
|
|
301
365
|
|
|
@@ -337,17 +401,17 @@ function handleRelayMessage(msg) {
|
|
|
337
401
|
break;
|
|
338
402
|
|
|
339
403
|
case 'mobile_connected':
|
|
340
|
-
console.log(' Mobile connected!');
|
|
341
|
-
if
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
} else {
|
|
347
|
-
sendToRelay({ type: 'sessions', list: getSessionList() });
|
|
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) {
|
|
348
410
|
const first = sessions.values().next().value;
|
|
349
411
|
if (first) sendToRelay({ type: 'attached', id: first.id });
|
|
350
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
|
|
351
415
|
break;
|
|
352
416
|
|
|
353
417
|
case 'mobile_disconnected':
|
|
@@ -356,6 +420,8 @@ function handleRelayMessage(msg) {
|
|
|
356
420
|
|
|
357
421
|
case 'input':
|
|
358
422
|
if (msg.sessionId) {
|
|
423
|
+
// L-1: reject oversized input
|
|
424
|
+
if (typeof msg.data === 'string' && msg.data.length > MAX_INPUT_PAYLOAD) break;
|
|
359
425
|
const session = sessions.get(msg.sessionId);
|
|
360
426
|
if (session && !session.exited) session.term.write(msg.data);
|
|
361
427
|
}
|
|
@@ -365,16 +431,27 @@ function handleRelayMessage(msg) {
|
|
|
365
431
|
if (msg.sessionId) {
|
|
366
432
|
const session = sessions.get(msg.sessionId);
|
|
367
433
|
if (session && !session.exited) {
|
|
368
|
-
try { session.term.resize(Math.max(msg.cols, 10), Math.max(msg.rows, 4)); } catch {}
|
|
434
|
+
try { session.term.resize(Math.min(Math.max(msg.cols, 10), 300), Math.min(Math.max(msg.rows, 4), 100)); } catch {}
|
|
369
435
|
}
|
|
370
436
|
}
|
|
371
437
|
break;
|
|
372
438
|
|
|
373
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
|
+
}
|
|
374
446
|
if (sessions.size < MAX_SESSIONS) {
|
|
447
|
+
_creatingSession = true;
|
|
448
|
+
lastCreateSessionTime = Date.now(); // M-3: track creation time
|
|
375
449
|
const session = useStandbyOrCreate();
|
|
376
|
-
|
|
377
|
-
|
|
450
|
+
_creatingSession = false;
|
|
451
|
+
if (session) {
|
|
452
|
+
sendToRelay({ type: 'sessions', list: getSessionList() });
|
|
453
|
+
sendToRelay({ type: 'attached', id: session.id });
|
|
454
|
+
}
|
|
378
455
|
} else {
|
|
379
456
|
sendToRelay({ type: 'error_msg', message: `Max ${MAX_SESSIONS} sessions` });
|
|
380
457
|
}
|
|
@@ -395,7 +472,7 @@ function handleRelayMessage(msg) {
|
|
|
395
472
|
case 'sync': {
|
|
396
473
|
const syncSession = sessions.get(msg.id);
|
|
397
474
|
if (!syncSession || syncSession.exited) return;
|
|
398
|
-
const bufStart = syncSession.
|
|
475
|
+
const bufStart = syncSession.bufferStart; // H-1: use tracked bufferStart
|
|
399
476
|
if (msg.clientSeq >= syncSession.bufferSeq) {
|
|
400
477
|
sendToRelay({ type: 'delta', data: '', seq: syncSession.bufferSeq, id: msg.id });
|
|
401
478
|
} else if (msg.clientSeq >= bufStart) {
|
|
@@ -519,6 +596,7 @@ function handleSubcommand() {
|
|
|
519
596
|
`ws.Run "${cmd.replace(/"/g, '""')}", 0, False\n`
|
|
520
597
|
);
|
|
521
598
|
require('child_process').execSync(`cscript //nologo "${vbsFile}"`, { stdio: 'ignore' });
|
|
599
|
+
try { fs.unlinkSync(vbsFile); } catch {} // L-3: clean up VBS launcher
|
|
522
600
|
// Wait for supervisor to write PID file
|
|
523
601
|
const waitUntil = Date.now() + 10000;
|
|
524
602
|
while (Date.now() < waitUntil) {
|
|
@@ -556,10 +634,16 @@ function handleSubcommand() {
|
|
|
556
634
|
|
|
557
635
|
function spawnWorker() {
|
|
558
636
|
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
559
|
-
const
|
|
637
|
+
const spawnOpts = {
|
|
560
638
|
stdio: ['ignore', logFd, logFd],
|
|
561
639
|
env: { ...process.env },
|
|
562
|
-
}
|
|
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);
|
|
563
647
|
worker.on('exit', (code) => {
|
|
564
648
|
if (code === 0) {
|
|
565
649
|
// Clean exit (e.g. from stop command), don't restart
|
|
@@ -580,6 +664,34 @@ function handleSubcommand() {
|
|
|
580
664
|
return 'daemon';
|
|
581
665
|
}
|
|
582
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
|
+
|
|
583
695
|
// ==================== Main ====================
|
|
584
696
|
async function main() {
|
|
585
697
|
const mode = handleSubcommand();
|