ghostterm 1.2.3 → 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 CHANGED
@@ -1,5 +1,27 @@
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
+
3
25
  ## 1.2.3 (2026-03-17)
4
26
 
5
27
  ### Features (Frontend)
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
- const term = pty.spawn(shell, [], {
179
- name: 'xterm-256color',
180
- cols: 80,
181
- rows: 24,
182
- cwd: path.join(os.homedir(), 'Desktop'),
183
- env: (() => { const e = { ...process.env, TERM: 'xterm-256color' }; delete e.CLAUDECODE; delete e.CLAUDE_CODE; return e; })(),
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
- relayWs.send(JSON.stringify(msg));
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 (sessions.size === 0) {
342
- // Use standby session if available, otherwise create new
343
- const s = useStandbyOrCreate();
344
- sendToRelay({ type: 'sessions', list: getSessionList() });
345
- sendToRelay({ type: 'attached', id: s.id });
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
- sendToRelay({ type: 'sessions', list: getSessionList() });
377
- sendToRelay({ type: 'attached', id: session.id });
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.bufferSeq - syncSession.outputBuffer.length;
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 worker = spawn(process.execPath, [__filename, '--daemon'], {
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ghostterm",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "Mobile terminal for Claude Code — control your PC from your phone",
5
5
  "bin": {
6
6
  "ghostterm": "bin/ghostterm.js"