termbeam 0.1.0 → 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/README.md CHANGED
@@ -13,7 +13,7 @@ I built this because I kept needing to run quick commands on my dev machine whil
13
13
 
14
14
  [Full documentation](https://dorlugasigal.github.io/TermBeam/)
15
15
 
16
- https://github.com/user-attachments/assets/c91ca15d-0c84-400f-bbfa-3d58d1be07ee
16
+ https://github.com/user-attachments/assets/9dd4f3d7-f017-4314-9b3a-f6a5688e3671
17
17
 
18
18
  ## Quick Start
19
19
 
@@ -30,18 +30,22 @@ termbeam
30
30
 
31
31
  Scan the QR code printed in your terminal, or open the URL on any device.
32
32
 
33
- ### Password protection (recommended)
33
+ ### Secure by default
34
34
 
35
- ```bash
36
- termbeam --generate-password
35
+ TermBeam starts with a tunnel and auto-generated password out of the box — just run `termbeam` and scan the QR code.
37
36
 
38
- # or set your own
39
- termbeam --password mysecret
37
+ ```bash
38
+ termbeam # tunnel + auto-password (default)
39
+ termbeam --password mysecret # use a specific password
40
+ termbeam --no-tunnel # LAN-only (no tunnel)
41
+ termbeam --no-password # disable password protection
40
42
  ```
41
43
 
42
44
  ## Features
43
45
 
44
46
  - **Mobile-first UI** with on-screen touch bar (arrow keys, Tab, Enter, Ctrl shortcuts, Esc) and touch-optimized controls
47
+ - **Copy/paste support** — Copy button opens text overlay for finger-selectable terminal content; Paste button with clipboard API + fallback modal
48
+ - **Image paste** — paste images from clipboard, uploaded to server
45
49
  - **Tabbed multi-session terminal** — open, switch, and manage multiple sessions from a single tab bar with drag-to-reorder
46
50
  - **Split view** — view two sessions side-by-side (horizontal on desktop, vertical on mobile)
47
51
  - **Session colors** — assign a color to each session for quick identification
@@ -65,11 +69,14 @@ termbeam --password mysecret
65
69
  ## Remote Access
66
70
 
67
71
  ```bash
68
- # One-off tunnel (deleted on shutdown)
69
- termbeam --tunnel --generate-password
72
+ # Tunnel is on by default
73
+ termbeam
70
74
 
71
75
  # Persisted tunnel (stable URL you can bookmark, reused across restarts, 30-day expiry)
72
- termbeam --persisted-tunnel --generate-password
76
+ termbeam --persisted-tunnel
77
+
78
+ # LAN-only (no tunnel)
79
+ termbeam --no-tunnel
73
80
  ```
74
81
 
75
82
  Requires the [Dev Tunnels CLI](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started):
@@ -88,20 +95,22 @@ termbeam --port 8080 # custom port (default: 3456)
88
95
  termbeam --host 127.0.0.1 # restrict to localhost (default: 0.0.0.0)
89
96
  ```
90
97
 
91
- | Flag | Description | Default |
92
- | --------------------- | ---------------------------------------- | ----------- |
93
- | `--password <pw>` | Set access password (also accepts `--password=<pw>`) | None |
94
- | `--generate-password` | Auto-generate a secure password | — |
95
- | `--tunnel` | Create an ephemeral devtunnel URL | Off |
96
- | `--persisted-tunnel` | Create a reusable devtunnel URL | Off |
97
- | `--port <port>` | Server port | `3456` |
98
- | `--host <addr>` | Bind address | `0.0.0.0` |
98
+ | Flag | Description | Default |
99
+ | --------------------- | ---------------------------------------- | ---------------- |
100
+ | `--password <pw>` | Set access password (also accepts `--password=<pw>`) | Auto-generated |
101
+ | `--no-password` | Disable password | — |
102
+ | `--generate-password` | Auto-generate a secure password | On |
103
+ | `--tunnel` | Create an ephemeral devtunnel URL | On |
104
+ | `--no-tunnel` | Disable tunnel (LAN-only) | |
105
+ | `--persisted-tunnel` | Create a reusable devtunnel URL | Off |
106
+ | `--port <port>` | Server port | `3456` |
107
+ | `--host <addr>` | Bind address | `0.0.0.0` |
99
108
 
100
109
  Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD`, `SHELL` (Unix fallback), `COMSPEC` (Windows fallback). See [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/).
101
110
 
102
111
  ## Security
103
112
 
104
- TermBeam binds to all interfaces (`0.0.0.0`) by default, so it's accessible on your local network out of the box. **Always set a password** when running on a shared network, or pass `--host 127.0.0.1` to restrict access to your machine only.
113
+ TermBeam auto-generates a password and creates a tunnel by default, so your terminal is protected out of the box. Be aware that the tunnel exposes your terminal to the internet — use `--no-tunnel` for LAN-only access, or `--host 127.0.0.1` to restrict to your machine only.
105
114
 
106
115
  Auth uses secure httpOnly cookies with 24-hour expiry, login is rate-limited to 5 attempts per minute, and security headers (X-Frame-Options, X-Content-Type-Options, etc.) are set on all responses. API clients that can't use cookies can authenticate with an `Authorization: Bearer <password>` header. See the [Security Guide](https://dorlugasigal.github.io/TermBeam/security/) for more.
107
116
 
@@ -115,4 +124,4 @@ Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
115
124
 
116
125
  ## Acknowledgments
117
126
 
118
- Special thanks to [@tamirdresher](https://github.com/tamirdresher) for the [blog post](https://www.tamirdresher.com/blog/2026/02/26/squad-remote-control) that inspired the solution idea for this project, and for his [cli-tunnel](https://github.com/tamirdresher/cli-tunnel) implementation.
127
+ Special thanks to [@tamirdresher](https://github.com/tamirdresher) for the [blog post](https://www.tamirdresher.com/blog/2026/02/26/squad-remote-control) that inspired the solution idea for this project, and for his [cli-tunnel](https://github.com/tamirdresher/cli-tunnel) implementation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Beam your terminal to any device — mobile-optimized web terminal with multi-session support",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -647,7 +647,7 @@
647
647
  </div>
648
648
  <label for="sess-cwd">Working Directory</label>
649
649
  <div class="cwd-picker">
650
- <input type="text" id="sess-cwd" placeholder="/Users/dorlugasigal" />
650
+ <input type="text" id="sess-cwd" placeholder="Uses server default" />
651
651
  <button type="button" class="cwd-browse-btn" id="browse-btn" title="Browse folders">
652
652
  <svg
653
653
  width="18"
@@ -838,6 +838,10 @@
838
838
  try {
839
839
  const res = await fetch('/api/shells');
840
840
  const data = await res.json();
841
+ if (data.cwd) {
842
+ document.getElementById('sess-cwd').placeholder = data.cwd;
843
+ hubServerCwd = data.cwd;
844
+ }
841
845
  shellSelect.innerHTML = '';
842
846
  for (const s of data.shells) {
843
847
  const opt = document.createElement('option');
@@ -952,9 +956,10 @@
952
956
  const browserBreadcrumb = document.getElementById('browser-breadcrumb');
953
957
  const browserPath = document.getElementById('browser-path');
954
958
  let currentBrowsePath = '/';
959
+ let hubServerCwd = '/';
955
960
 
956
961
  document.getElementById('browse-btn').addEventListener('click', () => {
957
- const initial = cwdInput.value.trim() || '/';
962
+ const initial = cwdInput.value.trim() || hubServerCwd;
958
963
  navigateTo(initial);
959
964
  browserOverlay.classList.add('visible');
960
965
  });
@@ -980,11 +985,17 @@
980
985
  try {
981
986
  const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
982
987
  const data = await res.json();
983
- if (!data.dirs.length) {
984
- browserList.innerHTML = '<div class="browser-empty">No subfolders</div>';
985
- return;
988
+ let items = '';
989
+ // Add parent (..) entry unless at root
990
+ const parent = dir.replace(/[/\\][^/\\]+$/, '') || (dir.includes('\\') ? dir.match(/^[A-Za-z]:\\/)?.[0] : '/');
991
+ if (parent && parent !== dir) {
992
+ items += `<div class="folder-item" data-path="${esc(parent)}">
993
+ <span class="folder-icon">📁</span>
994
+ <span class="folder-name">..</span>
995
+ <span class="folder-arrow">›</span>
996
+ </div>`;
986
997
  }
987
- browserList.innerHTML = data.dirs
998
+ items += data.dirs
988
999
  .map((d) => {
989
1000
  const name = d.split(/[/\\]/).pop();
990
1001
  return `<div class="folder-item" data-path="${esc(d)}">
@@ -995,6 +1006,7 @@
995
1006
  })
996
1007
  .join('');
997
1008
 
1009
+ browserList.innerHTML = items || '<div class="browser-empty">No subfolders</div>';
998
1010
  browserList.querySelectorAll('.folder-item').forEach((el) => {
999
1011
  el.addEventListener('click', () => navigateTo(el.dataset.path));
1000
1012
  });
@@ -1005,13 +1017,15 @@
1005
1017
  }
1006
1018
 
1007
1019
  function renderBreadcrumb(dir) {
1008
- const parts = dir.split('/').filter(Boolean);
1009
- let html = `<button class="crumb" data-path="/">/</button>`;
1010
- let accumulated = '';
1020
+ const sep = dir.includes('\\') ? '\\' : '/';
1021
+ const parts = dir.split(/[/\\]/).filter(Boolean);
1022
+ const isWindows = /^[A-Za-z]:/.test(dir);
1023
+ let html = isWindows ? '' : `<button class="crumb" data-path="/">/</button>`;
1024
+ let accumulated = isWindows ? '' : '';
1011
1025
  parts.forEach((part, i) => {
1012
- accumulated += '/' + part;
1026
+ accumulated += (i === 0 && isWindows ? '' : sep) + part;
1013
1027
  const isCurrent = i === parts.length - 1;
1014
- html += `<span class="crumb-sep">›</span>`;
1028
+ if (i > 0 || isWindows) html += `<span class="crumb-sep">›</span>`;
1015
1029
  html += `<button class="crumb${isCurrent ? ' current' : ''}" data-path="${esc(accumulated)}">${esc(part)}</button>`;
1016
1030
  });
1017
1031
  browserBreadcrumb.innerHTML = html;
@@ -1031,6 +1045,7 @@
1031
1045
  .catch(() => {});
1032
1046
 
1033
1047
  loadSessions();
1048
+ loadShells();
1034
1049
  setInterval(loadSessions, 3000);
1035
1050
 
1036
1051
  // Share button
package/public/sw.js CHANGED
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = 'termbeam-v2';
1
+ const CACHE_NAME = 'termbeam-v5';
2
2
  const SHELL_URLS = ['/', '/terminal'];
3
3
 
4
4
  self.addEventListener('install', (event) => {
@@ -56,7 +56,21 @@ self.addEventListener('fetch', (event) => {
56
56
  return;
57
57
  }
58
58
 
59
- // Cache-first for static assets
59
+ // Network-first for HTML pages (always get latest code)
60
+ if (event.request.mode === 'navigate' || event.request.headers.get('accept')?.includes('text/html')) {
61
+ event.respondWith(
62
+ fetch(event.request).then((response) => {
63
+ if (response.ok) {
64
+ const clone = response.clone();
65
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
66
+ }
67
+ return response;
68
+ }).catch(() => caches.match(event.request))
69
+ );
70
+ return;
71
+ }
72
+
73
+ // Cache-first for static assets (JS, CSS, images)
60
74
  event.respondWith(
61
75
  caches.match(event.request).then((cached) => {
62
76
  if (cached) return cached;
@@ -309,7 +309,8 @@
309
309
  #paste-overlay {
310
310
  display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
311
311
  background: var(--overlay-bg); z-index: 150;
312
- flex-direction: column; align-items: center; justify-content: center; gap: 12px;
312
+ flex-direction: column; align-items: center; justify-content: flex-start;
313
+ padding-top: calc(80px + env(safe-area-inset-top, 0px)); gap: 12px;
313
314
  }
314
315
  #paste-overlay.visible { display: flex; }
315
316
  #paste-overlay label { font-size: 15px; color: #fff; font-weight: 600; }
@@ -319,6 +320,38 @@
319
320
  padding: 10px; font-size: 14px; font-family: 'NerdFont', 'JetBrains Mono', monospace; resize: vertical;
320
321
  }
321
322
 
323
+ #select-overlay {
324
+ display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
325
+ background: var(--bg); z-index: 160;
326
+ flex-direction: column;
327
+ }
328
+ #select-overlay.visible { display: flex; }
329
+ .select-overlay-header {
330
+ display: flex; align-items: center; justify-content: space-between;
331
+ padding: 12px 12px; border-bottom: 1px solid var(--border);
332
+ font-size: 15px; font-weight: 600; color: var(--text);
333
+ padding-top: calc(12px + env(safe-area-inset-top, 0px));
334
+ min-height: 48px; box-sizing: border-box;
335
+ }
336
+ .select-overlay-header button {
337
+ padding: 6px 12px; border: none; border-radius: 8px;
338
+ font-size: 13px; font-weight: 600; cursor: pointer;
339
+ white-space: nowrap; flex-shrink: 0;
340
+ transition: background 0.15s, transform 0.1s;
341
+ }
342
+ .select-overlay-header button:active { transform: scale(0.95); }
343
+ #select-copy { background: var(--accent); color: #fff; }
344
+ #select-close { background: var(--border); color: var(--text); }
345
+ #select-content {
346
+ flex: 1; overflow: auto; padding: 12px 16px;
347
+ font-family: 'NerdFont', 'JetBrains Mono', monospace;
348
+ font-size: 13px; line-height: 1.4; color: var(--text);
349
+ white-space: pre; word-break: normal;
350
+ -webkit-user-select: text; user-select: text;
351
+ margin: 0;
352
+ padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
353
+ }
354
+
322
355
  #reconnect-overlay {
323
356
  display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
324
357
  background: var(--overlay-bg); z-index: 100;
@@ -714,6 +747,7 @@
714
747
  <button class="key-btn" data-key="&#x1b;[H" title="Home">Home</button>
715
748
  <button class="key-btn" data-key="&#x1b;[F" title="End">End</button>
716
749
  <div class="key-sep"></div>
750
+ <button class="key-btn" id="select-btn" title="Copy text">Copy</button>
717
751
  <button class="key-btn" id="paste-btn" title="Paste from clipboard">Paste</button>
718
752
  <div class="key-sep"></div>
719
753
  <button class="key-btn wide" data-key="&#x09;" title="Autocomplete">Tab</button>
@@ -740,6 +774,18 @@
740
774
  </div>
741
775
  </div>
742
776
 
777
+ <div id="select-overlay">
778
+ <div class="select-overlay-header">
779
+ <span id="select-title">Copy Text</span>
780
+ <div style="display:flex;gap:8px">
781
+ <button id="select-copy">Copy</button>
782
+ <button id="select-close">Done</button>
783
+ </div>
784
+ </div>
785
+ <button id="select-load-more" style="display:none;width:100%;padding:8px;background:var(--surface);color:var(--accent);border:1px solid var(--border);border-radius:6px;font-size:13px;cursor:pointer;">▲ Load more</button>
786
+ <pre id="select-content"></pre>
787
+ </div>
788
+
743
789
  <!-- New Session Modal -->
744
790
  <div class="modal-overlay" id="new-session-modal">
745
791
  <div class="modal">
@@ -804,8 +850,20 @@
804
850
  const managed = new Map(); // sessionId -> ManagedSession
805
851
  let activeId = null;
806
852
  let splitMode = false;
853
+
807
854
  let splitSecondId = null;
808
855
 
856
+ // Clipboard copy fallback for non-secure contexts (HTTP over LAN)
857
+ function copyFallback(text) {
858
+ const ta = document.createElement('textarea');
859
+ ta.value = text;
860
+ ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
861
+ document.body.appendChild(ta);
862
+ ta.select();
863
+ try { document.execCommand('copy'); showToast('Copied!'); } catch {}
864
+ document.body.removeChild(ta);
865
+ }
866
+
809
867
  // ===== DOM Refs =====
810
868
  const statusDot = document.getElementById('status-dot');
811
869
  const statusText = document.getElementById('status-text');
@@ -937,7 +995,10 @@
937
995
  renderTabs();
938
996
  setupKeyBar();
939
997
  setupPaste();
998
+ setupImagePaste();
999
+ setupSelectMode();
940
1000
  setupNewSessionModal();
1001
+ loadShellsForModal();
941
1002
  startPolling();
942
1003
 
943
1004
  // Zoom
@@ -1120,8 +1181,14 @@
1120
1181
  // Copy on selection
1121
1182
  term.onSelectionChange(() => {
1122
1183
  const sel = term.getSelection();
1123
- if (sel && navigator.clipboard && navigator.clipboard.writeText) {
1124
- navigator.clipboard.writeText(sel).then(() => showToast('Copied!')).catch(() => {});
1184
+ if (!sel) return;
1185
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1186
+ navigator.clipboard.writeText(sel).then(() => showToast('Copied!')).catch(() => {
1187
+ // Fallback for non-secure contexts (HTTP over LAN)
1188
+ copyFallback(sel);
1189
+ });
1190
+ } else {
1191
+ copyFallback(sel);
1125
1192
  }
1126
1193
  });
1127
1194
 
@@ -1203,6 +1270,11 @@
1203
1270
  reconnectOverlay.classList.add('visible');
1204
1271
  }
1205
1272
  } else if (msg.type === 'error') {
1273
+ if (msg.message === 'Session not found') {
1274
+ ms.exited = true;
1275
+ if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
1276
+ renderTabs();
1277
+ }
1206
1278
  if (ms.id === activeId) {
1207
1279
  statusText.textContent = msg.message;
1208
1280
  reconnectOverlay.querySelector('.msg').textContent = msg.message;
@@ -1655,18 +1727,25 @@
1655
1727
  }, 400);
1656
1728
  }
1657
1729
 
1730
+ let keyBarTouched = false;
1658
1731
  keyBar.addEventListener('mousedown', e => {
1732
+ if (keyBarTouched) { keyBarTouched = false; return; }
1659
1733
  const btn = e.target.closest('.key-btn');
1660
- if (btn) { e.preventDefault(); startRepeat(btn); }
1734
+ if (btn && btn.dataset.key) { e.preventDefault(); startRepeat(btn); }
1661
1735
  });
1662
1736
  keyBar.addEventListener('mouseup', stopRepeat);
1663
1737
  keyBar.addEventListener('mouseleave', stopRepeat);
1664
1738
 
1665
1739
  keyBar.addEventListener('touchstart', e => {
1740
+ keyBarTouched = true;
1666
1741
  const btn = e.target.closest('.key-btn');
1667
- if (btn) startRepeat(btn);
1668
- }, { passive: true });
1669
- keyBar.addEventListener('touchend', stopRepeat);
1742
+ if (btn && btn.dataset.key) { e.preventDefault(); startRepeat(btn); }
1743
+ }, { passive: false });
1744
+ keyBar.addEventListener('touchend', (e) => {
1745
+ const btn = e.target.closest('.key-btn');
1746
+ if (btn && btn.dataset.key) e.preventDefault();
1747
+ stopRepeat();
1748
+ });
1670
1749
  keyBar.addEventListener('touchcancel', stopRepeat);
1671
1750
 
1672
1751
  keyBar.addEventListener('click', e => {
@@ -1679,6 +1758,7 @@
1679
1758
  function setupPaste() {
1680
1759
  const pasteOverlay = document.getElementById('paste-overlay');
1681
1760
  const pasteInput = document.getElementById('paste-input');
1761
+ const pasteBtn = document.getElementById('paste-btn');
1682
1762
 
1683
1763
  function openPasteModal() {
1684
1764
  pasteInput.value = '';
@@ -1686,19 +1766,65 @@
1686
1766
  pasteInput.focus();
1687
1767
  }
1688
1768
 
1689
- document.getElementById('paste-btn').addEventListener('mousedown', e => e.preventDefault());
1690
- document.getElementById('paste-btn').addEventListener('click', () => {
1769
+ async function handlePaste() {
1770
+ // Try clipboard API for text first (most common), then images, then fallback to modal
1691
1771
  if (navigator.clipboard && navigator.clipboard.readText) {
1692
- navigator.clipboard.readText().then(text => {
1693
- const ms = managed.get(activeId);
1694
- if (text && ms && ms.ws && ms.ws.readyState === 1) {
1695
- ms.ws.send(JSON.stringify({ type: 'input', data: text }));
1696
- showToast('Pasted!');
1772
+ try {
1773
+ const text = await navigator.clipboard.readText();
1774
+ if (text) {
1775
+ const ms = managed.get(activeId);
1776
+ if (ms && ms.ws && ms.ws.readyState === 1) {
1777
+ ms.ws.send(JSON.stringify({ type: 'input', data: text }));
1778
+ showToast('Pasted!');
1779
+ }
1780
+ return;
1697
1781
  }
1698
- }).catch(() => openPasteModal());
1699
- } else {
1700
- openPasteModal();
1782
+ } catch (err) {
1783
+ console.warn('clipboard.readText failed:', err.message);
1784
+ }
1785
+ }
1786
+ // Image paste: try clipboard.read() only if readText didn't work
1787
+ if (navigator.clipboard && navigator.clipboard.read) {
1788
+ try {
1789
+ const items = await navigator.clipboard.read();
1790
+ for (const item of items) {
1791
+ const imageType = item.types.find(t => t.startsWith('image/'));
1792
+ if (imageType) {
1793
+ const blob = await item.getType(imageType);
1794
+ const res = await fetch('/api/upload', {
1795
+ method: 'POST',
1796
+ headers: { 'Content-Type': imageType },
1797
+ body: blob,
1798
+ credentials: 'same-origin',
1799
+ });
1800
+ if (!res.ok) throw new Error('Upload failed');
1801
+ const data = await res.json();
1802
+ const ms = managed.get(activeId);
1803
+ if (ms && ms.ws && ms.ws.readyState === 1) {
1804
+ ms.ws.send(JSON.stringify({ type: 'input', data: data.path + ' ' }));
1805
+ showToast('Image pasted: ' + data.path.split(/[/\\]/).pop());
1806
+ }
1807
+ return;
1808
+ }
1809
+ }
1810
+ } catch (err) {
1811
+ console.warn('clipboard.read failed:', err.message);
1812
+ }
1701
1813
  }
1814
+ openPasteModal();
1815
+ }
1816
+
1817
+ // Use touchend directly for iOS Safari (click may not fire reliably)
1818
+ let pasteTouched = false;
1819
+ pasteBtn.addEventListener('touchend', (e) => {
1820
+ e.preventDefault();
1821
+ pasteTouched = true;
1822
+ handlePaste();
1823
+ });
1824
+ pasteBtn.addEventListener('mousedown', e => e.preventDefault());
1825
+ pasteBtn.addEventListener('click', () => {
1826
+ if (pasteTouched) { pasteTouched = false; return; }
1827
+ handlePaste();
1702
1828
  });
1703
1829
 
1704
1830
  document.getElementById('paste-send').addEventListener('click', () => {
@@ -1717,6 +1843,130 @@
1717
1843
  });
1718
1844
  }
1719
1845
 
1846
+ // ===== Select Text Overlay =====
1847
+ function setupSelectMode() {
1848
+ const selectBtn = document.getElementById('select-btn');
1849
+ const selectOverlay = document.getElementById('select-overlay');
1850
+ const selectContent = document.getElementById('select-content');
1851
+ const PAGE_SIZE = 200;
1852
+ let allLines = [];
1853
+ let loadedFrom = 0;
1854
+
1855
+ function renderLines(from, to) {
1856
+ return allLines.slice(from, to).join('\n');
1857
+ }
1858
+
1859
+ function openSelectOverlay() {
1860
+ const ms = managed.get(activeId);
1861
+ if (!ms) return;
1862
+ const buf = ms.term.buffer.active;
1863
+ allLines = [];
1864
+ for (let i = 0; i < buf.length; i++) {
1865
+ const line = buf.getLine(i);
1866
+ if (line) allLines.push(line.translateToString(true));
1867
+ }
1868
+ // Trim trailing empty lines
1869
+ while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') allLines.pop();
1870
+
1871
+ // Show last PAGE_SIZE lines
1872
+ loadedFrom = Math.max(0, allLines.length - PAGE_SIZE);
1873
+ selectContent.textContent = renderLines(loadedFrom, allLines.length);
1874
+
1875
+ // Show/hide "Load more" button
1876
+ const loadMoreBtn = document.getElementById('select-load-more');
1877
+ loadMoreBtn.style.display = loadedFrom > 0 ? 'block' : 'none';
1878
+ loadMoreBtn.textContent = `▲ Load more (${loadedFrom} lines above)`;
1879
+
1880
+ // Show line count in title
1881
+ const title = document.getElementById('select-title');
1882
+ const shown = allLines.length - loadedFrom;
1883
+ title.textContent = allLines.length <= PAGE_SIZE
1884
+ ? `Copy Text (${allLines.length} lines)`
1885
+ : `Copy Text (${shown}/${allLines.length} lines)`;
1886
+
1887
+ selectBtn.style.display = 'none';
1888
+ selectOverlay.classList.add('visible');
1889
+ selectContent.scrollTop = selectContent.scrollHeight;
1890
+ }
1891
+
1892
+ document.getElementById('select-load-more').addEventListener('click', () => {
1893
+ const prevHeight = selectContent.scrollHeight;
1894
+ const newFrom = Math.max(0, loadedFrom - PAGE_SIZE);
1895
+ const chunk = renderLines(newFrom, loadedFrom);
1896
+ selectContent.textContent = chunk + '\n' + selectContent.textContent;
1897
+ loadedFrom = newFrom;
1898
+ // Keep scroll position stable
1899
+ selectContent.scrollTop = selectContent.scrollHeight - prevHeight;
1900
+ const loadMoreBtn = document.getElementById('select-load-more');
1901
+ loadMoreBtn.style.display = loadedFrom > 0 ? 'block' : 'none';
1902
+ loadMoreBtn.textContent = loadedFrom > 0 ? `▲ Load more (${loadedFrom} lines above)` : '';
1903
+ // Update title
1904
+ const title = document.getElementById('select-title');
1905
+ const shown = allLines.length - loadedFrom;
1906
+ title.textContent = `Copy Text (${shown}/${allLines.length} lines)`;
1907
+ });
1908
+
1909
+ selectBtn.addEventListener('mousedown', e => e.preventDefault());
1910
+ selectBtn.addEventListener('click', openSelectOverlay);
1911
+
1912
+ document.getElementById('select-copy').addEventListener('click', () => {
1913
+ // Copy finger selection if any, otherwise copy all loaded text
1914
+ const sel = window.getSelection();
1915
+ const text = (sel && sel.toString()) ? sel.toString() : selectContent.textContent;
1916
+ if (!text) return;
1917
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1918
+ navigator.clipboard.writeText(text).then(() => showToast('Copied!')).catch(() => {
1919
+ copyFallback(text);
1920
+ });
1921
+ } else {
1922
+ copyFallback(text);
1923
+ }
1924
+ });
1925
+
1926
+ document.getElementById('select-close').addEventListener('click', () => {
1927
+ selectOverlay.classList.remove('visible');
1928
+ selectContent.textContent = '';
1929
+ allLines = [];
1930
+ selectBtn.style.display = '';
1931
+ const ms = managed.get(activeId);
1932
+ if (ms) ms.term.focus();
1933
+ });
1934
+ }
1935
+
1936
+ // ===== Image Paste =====
1937
+ function setupImagePaste() {
1938
+ document.addEventListener('paste', async (e) => {
1939
+ const items = e.clipboardData && e.clipboardData.items;
1940
+ if (!items) return;
1941
+
1942
+ for (const item of items) {
1943
+ if (item.type.startsWith('image/')) {
1944
+ e.preventDefault();
1945
+ const blob = item.getAsFile();
1946
+ if (!blob) return;
1947
+
1948
+ try {
1949
+ const res = await fetch('/api/upload', {
1950
+ method: 'POST',
1951
+ headers: { 'Content-Type': item.type },
1952
+ body: blob,
1953
+ });
1954
+ if (!res.ok) throw new Error('Upload failed');
1955
+ const data = await res.json();
1956
+ const ms = managed.get(activeId);
1957
+ if (ms && ms.ws && ms.ws.readyState === 1) {
1958
+ ms.ws.send(JSON.stringify({ type: 'input', data: data.path + ' ' }));
1959
+ showToast('Image pasted: ' + data.path.split(/[/\\]/).pop());
1960
+ }
1961
+ } catch (err) {
1962
+ showToast('Image paste failed');
1963
+ }
1964
+ return;
1965
+ }
1966
+ }
1967
+ });
1968
+ }
1969
+
1720
1970
  // ===== New Session Modal =====
1721
1971
  let shellsLoaded = false;
1722
1972
 
@@ -1755,9 +2005,10 @@
1755
2005
  const nsBrowserBreadcrumb = document.getElementById('ns-browser-breadcrumb');
1756
2006
  const nsBrowserPath = document.getElementById('ns-browser-path');
1757
2007
  let nsBrowsePath = '/';
2008
+ let serverCwd = '/';
1758
2009
 
1759
2010
  document.getElementById('ns-browse-btn').addEventListener('click', () => {
1760
- const initial = nsCwdInput.value.trim() || '/';
2011
+ const initial = nsCwdInput.value.trim() || serverCwd;
1761
2012
  nsBrowseNavigate(initial);
1762
2013
  nsBrowserOverlay.classList.add('visible');
1763
2014
  });
@@ -1782,11 +2033,17 @@
1782
2033
  try {
1783
2034
  const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
1784
2035
  const data = await res.json();
1785
- if (!data.dirs.length) {
1786
- nsBrowserList.innerHTML = '<div class="browser-empty">No subfolders</div>';
1787
- return;
2036
+ let items = '';
2037
+ // Add parent (..) entry unless at root
2038
+ const parent = dir.replace(/[/\\][^/\\]+$/, '') || (dir.includes('\\') ? dir.match(/^[A-Za-z]:\\/)?.[0] : '/');
2039
+ if (parent && parent !== dir) {
2040
+ items += `<div class="folder-item" data-path="${escAttr(parent)}">
2041
+ <span class="folder-icon">📁</span>
2042
+ <span class="folder-name">..</span>
2043
+ <span class="folder-arrow">›</span>
2044
+ </div>`;
1788
2045
  }
1789
- nsBrowserList.innerHTML = data.dirs.map(d => {
2046
+ items += data.dirs.map(d => {
1790
2047
  const name = d.split(/[/\\]/).pop();
1791
2048
  return `<div class="folder-item" data-path="${escAttr(d)}">
1792
2049
  <span class="folder-icon">📁</span>
@@ -1794,6 +2051,7 @@
1794
2051
  <span class="folder-arrow">›</span>
1795
2052
  </div>`;
1796
2053
  }).join('');
2054
+ nsBrowserList.innerHTML = items || '<div class="browser-empty">No subfolders</div>';
1797
2055
  nsBrowserList.querySelectorAll('.folder-item').forEach(el => {
1798
2056
  el.addEventListener('click', () => nsBrowseNavigate(el.dataset.path));
1799
2057
  });
@@ -1804,13 +2062,15 @@
1804
2062
  }
1805
2063
 
1806
2064
  function nsBrowseRenderBreadcrumb(dir) {
1807
- const parts = dir.split('/').filter(Boolean);
1808
- let html = `<button class="crumb" data-path="/">/</button>`;
1809
- let accumulated = '';
2065
+ const sep = dir.includes('\\') ? '\\' : '/';
2066
+ const parts = dir.split(/[/\\]/).filter(Boolean);
2067
+ const isWindows = /^[A-Za-z]:/.test(dir);
2068
+ let html = isWindows ? '' : `<button class="crumb" data-path="/">/</button>`;
2069
+ let accumulated = isWindows ? '' : '';
1810
2070
  parts.forEach((part, i) => {
1811
- accumulated += '/' + part;
2071
+ accumulated += (i === 0 && isWindows ? '' : sep) + part;
1812
2072
  const isCurrent = i === parts.length - 1;
1813
- html += `<span class="crumb-sep">›</span>`;
2073
+ if (i > 0 || isWindows) html += `<span class="crumb-sep">›</span>`;
1814
2074
  html += `<button class="crumb${isCurrent ? ' current' : ''}" data-path="${escAttr(accumulated)}">${esc(part)}</button>`;
1815
2075
  });
1816
2076
  nsBrowserBreadcrumb.innerHTML = html;
@@ -1826,6 +2086,10 @@
1826
2086
  const sel = document.getElementById('ns-shell');
1827
2087
  try {
1828
2088
  const data = await fetch('/api/shells').then(r => r.json());
2089
+ if (data.cwd) {
2090
+ serverCwd = data.cwd;
2091
+ document.getElementById('ns-cwd').placeholder = data.cwd;
2092
+ }
1829
2093
  sel.innerHTML = data.shells.map(s =>
1830
2094
  '<option value="' + escAttr(s.cmd) + '"' + (s.cmd === data.default ? ' selected' : '') + '>'
1831
2095
  + esc(s.name) + ' (' + esc(s.cmd) + ')</option>'
@@ -1913,6 +2177,7 @@
1913
2177
  statusText.textContent = '';
1914
2178
  statusDot.className = '';
1915
2179
  document.getElementById('stop-btn').style.display = 'none';
2180
+ reconnectOverlay.classList.remove('visible');
1916
2181
  }
1917
2182
  }
1918
2183
  }
package/src/auth.js CHANGED
@@ -107,6 +107,7 @@ function createAuth(password) {
107
107
  const attempts = authAttempts.get(ip) || [];
108
108
  const recent = attempts.filter((t) => now - t < window);
109
109
  if (recent.length >= maxAttempts) {
110
+ console.warn(`[termbeam] Auth: rate limit exceeded for ${ip}`);
110
111
  return res.status(429).json({ error: 'Too many attempts. Try again later.' });
111
112
  }
112
113
  recent.push(now);
package/src/cli.js CHANGED
@@ -11,19 +11,26 @@ Usage:
11
11
 
12
12
  Options:
13
13
  --password <pw> Set access password (or TERMBEAM_PASSWORD env var)
14
- --generate-password Auto-generate a secure password
15
- --tunnel Create a public devtunnel URL (ephemeral)
14
+ --generate-password Auto-generate a secure password (default: auto)
15
+ --no-password Disable password authentication
16
+ --tunnel Create a public devtunnel URL (default: on)
17
+ --no-tunnel Disable tunnel (LAN-only mode)
16
18
  --persisted-tunnel Create a reusable devtunnel URL (stable across restarts)
17
19
  --port <port> Set port (default: 3456, or PORT env var)
18
20
  --host <addr> Bind address (default: 0.0.0.0)
19
21
  -h, --help Show this help
20
22
  -v, --version Show version
21
23
 
24
+ Defaults:
25
+ By default, TermBeam enables tunnel + auto-generated password for secure
26
+ mobile access (clipboard, HTTPS). Use --no-tunnel for LAN-only mode.
27
+
22
28
  Examples:
23
- termbeam Start with default shell
24
- termbeam --password secret Start with password auth
25
- termbeam --generate-password Start with auto-generated password
26
- termbeam --tunnel --password pw Start with public tunnel
29
+ termbeam Start with tunnel + auto password
30
+ termbeam --no-tunnel LAN-only, no tunnel
31
+ termbeam --no-tunnel --no-password LAN-only, no auth (local use)
32
+ termbeam --password secret Start with specific password
33
+ termbeam --persisted-tunnel Stable tunnel URL across restarts
27
34
  termbeam /bin/bash Use bash instead of default shell
28
35
 
29
36
  Environment:
@@ -146,8 +153,10 @@ function parseArgs() {
146
153
  const defaultShell = getDefaultShell();
147
154
  const cwd = process.env.TERMBEAM_CWD || process.env.PTY_CWD || process.cwd();
148
155
  let password = process.env.TERMBEAM_PASSWORD || process.env.PTY_PASSWORD || null;
149
- let useTunnel = false;
156
+ let useTunnel = true;
157
+ let noTunnel = false;
150
158
  let persistedTunnel = false;
159
+ let explicitPassword = !!password;
151
160
 
152
161
  const args = process.argv.slice(2);
153
162
  const filteredArgs = [];
@@ -155,13 +164,17 @@ function parseArgs() {
155
164
  for (let i = 0; i < args.length; i++) {
156
165
  if (args[i] === '--password' && args[i + 1]) {
157
166
  password = args[++i];
167
+ explicitPassword = true;
158
168
  } else if (args[i] === '--tunnel') {
159
169
  useTunnel = true;
170
+ } else if (args[i] === '--no-tunnel') {
171
+ noTunnel = true;
160
172
  } else if (args[i] === '--persisted-tunnel') {
161
173
  useTunnel = true;
162
174
  persistedTunnel = true;
163
175
  } else if (args[i].startsWith('--password=')) {
164
176
  password = args[i].split('=')[1];
177
+ explicitPassword = true;
165
178
  } else if (args[i] === '--help' || args[i] === '-h') {
166
179
  printHelp();
167
180
  process.exit(0);
@@ -171,7 +184,10 @@ function parseArgs() {
171
184
  process.exit(0);
172
185
  } else if (args[i] === '--generate-password') {
173
186
  password = crypto.randomBytes(16).toString('base64url');
174
- console.log(`Generated password: ${password}`);
187
+ explicitPassword = true;
188
+ } else if (args[i] === '--no-password') {
189
+ password = null;
190
+ explicitPassword = true;
175
191
  } else if (args[i] === '--port' && args[i + 1]) {
176
192
  port = parseInt(args[++i], 10);
177
193
  } else if (args[i] === '--host' && args[i + 1]) {
@@ -181,6 +197,14 @@ function parseArgs() {
181
197
  }
182
198
  }
183
199
 
200
+ // Default: auto-generate password if none specified
201
+ if (!explicitPassword && !password) {
202
+ password = crypto.randomBytes(16).toString('base64url');
203
+ }
204
+
205
+ // --no-tunnel disables the default tunnel
206
+ if (noTunnel) useTunnel = false;
207
+
184
208
  const shell = filteredArgs[0] || defaultShell;
185
209
  const shellArgs = filteredArgs.slice(1);
186
210
 
package/src/routes.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const path = require('path');
2
2
  const os = require('os');
3
3
  const fs = require('fs');
4
+ const crypto = require('crypto');
4
5
  const express = require('express');
5
6
  const { detectShells } = require('./shells');
6
7
 
@@ -27,8 +28,10 @@ function setupRoutes(app, { auth, sessions, config }) {
27
28
  maxAge: 24 * 60 * 60 * 1000,
28
29
  secure: false,
29
30
  });
31
+ console.log(`[termbeam] Auth: login success from ${req.ip}`);
30
32
  res.json({ ok: true });
31
33
  } else {
34
+ console.warn(`[termbeam] Auth: login failed from ${req.ip}`);
32
35
  res.status(401).json({ error: 'wrong password' });
33
36
  }
34
37
  });
@@ -66,7 +69,7 @@ function setupRoutes(app, { auth, sessions, config }) {
66
69
  // Available shells
67
70
  app.get('/api/shells', auth.middleware, (_req, res) => {
68
71
  const shells = detectShells();
69
- res.json({ shells, default: config.defaultShell });
72
+ res.json({ shells, default: config.defaultShell, cwd: config.cwd });
70
73
  });
71
74
 
72
75
  app.delete('/api/sessions/:id', auth.middleware, (req, res) => {
@@ -89,11 +92,64 @@ function setupRoutes(app, { auth, sessions, config }) {
89
92
  }
90
93
  });
91
94
 
95
+ // Image upload
96
+ app.post('/api/upload', auth.middleware, (req, res) => {
97
+ const contentType = req.headers['content-type'] || '';
98
+ if (!contentType.startsWith('image/')) {
99
+ console.warn(`[termbeam] Upload rejected: invalid content-type "${contentType}"`);
100
+ return res.status(400).json({ error: 'Invalid content type' });
101
+ }
102
+
103
+ const chunks = [];
104
+ let size = 0;
105
+ let aborted = false;
106
+ const limit = 10 * 1024 * 1024;
107
+
108
+ req.on('data', (chunk) => {
109
+ if (aborted) return;
110
+ size += chunk.length;
111
+ if (size > limit) {
112
+ aborted = true;
113
+ console.warn(`[termbeam] Upload rejected: file too large (${size} bytes)`);
114
+ res.status(413).json({ error: 'File too large' });
115
+ req.resume(); // drain remaining data
116
+ return;
117
+ }
118
+ chunks.push(chunk);
119
+ });
120
+
121
+ req.on('end', () => {
122
+ if (aborted) return;
123
+ const buffer = Buffer.concat(chunks);
124
+ if (!buffer.length) {
125
+ return res.status(400).json({ error: 'No image data' });
126
+ }
127
+ const ext = {
128
+ 'image/png': '.png',
129
+ 'image/jpeg': '.jpg',
130
+ 'image/gif': '.gif',
131
+ 'image/webp': '.webp',
132
+ 'image/bmp': '.bmp',
133
+ }[contentType] || '.png';
134
+ const filename = `termbeam-${crypto.randomUUID()}${ext}`;
135
+ const filepath = path.join(os.tmpdir(), filename);
136
+ fs.writeFileSync(filepath, buffer);
137
+ console.log(`[termbeam] Upload: ${filename} (${buffer.length} bytes)`);
138
+ res.json({ path: filepath });
139
+ });
140
+
141
+ req.on('error', (err) => {
142
+ console.error(`[termbeam] Upload error: ${err.message}`);
143
+ res.status(500).json({ error: 'Upload failed' });
144
+ });
145
+ });
146
+
92
147
  // Directory listing for folder browser
93
148
  app.get('/api/dirs', auth.middleware, (req, res) => {
94
- const query = req.query.q || os.homedir();
95
- const dir = query.endsWith('/') ? query : path.dirname(query);
96
- const prefix = query.endsWith('/') ? '' : path.basename(query);
149
+ const query = req.query.q || (config.cwd + path.sep);
150
+ const endsWithSep = query.endsWith('/') || query.endsWith('\\');
151
+ const dir = endsWithSep ? query : path.dirname(query);
152
+ const prefix = endsWithSep ? '' : path.basename(query);
97
153
 
98
154
  try {
99
155
  const entries = fs.readdirSync(dir, { withFileTypes: true });
package/src/server.js CHANGED
@@ -26,13 +26,14 @@ app.use(cookieParser());
26
26
  app.use((_req, res, next) => {
27
27
  res.setHeader('X-Content-Type-Options', 'nosniff');
28
28
  res.setHeader('X-Frame-Options', 'DENY');
29
- res.setHeader('X-XSS-Protection', '1; mode=block');
30
29
  res.setHeader('Referrer-Policy', 'no-referrer');
30
+ res.setHeader('Cache-Control', 'no-store');
31
+ res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws: wss:; font-src 'self' https://cdn.jsdelivr.net");
31
32
  next();
32
33
  });
33
34
 
34
35
  const server = http.createServer(app);
35
- const wss = new WebSocketServer({ server, path: '/ws' });
36
+ const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 * 1024 * 1024 });
36
37
 
37
38
  setupRoutes(app, { auth, sessions, config });
38
39
  setupWebSocket(wss, { auth, sessions });
@@ -105,32 +106,37 @@ server.listen(config.port, config.host, async () => {
105
106
  console.log('');
106
107
  console.log(` Beam your terminal to any device 📡 v${config.version}`);
107
108
  console.log('');
108
- console.log(` Local: http://localhost:${config.port}`);
109
109
  const isLanReachable = config.host === '0.0.0.0' || config.host === '::' || config.host === ip;
110
- if (isLanReachable) {
111
- console.log(` LAN: ${localUrl}`);
112
- }
113
- console.log(` Shell: ${config.shell}`);
114
- console.log(` Session: ${defaultId}`);
115
110
  const gn = '\x1b[38;5;114m'; // green
116
- console.log(` Auth: ${config.password ? `${gn}🔒 password${rs}` : '🔓 none'}`);
111
+ const dm = '\x1b[2m'; // dim
117
112
 
118
113
  let publicUrl = null;
119
114
  if (config.useTunnel) {
120
115
  const tunnel = await startTunnel(config.port, { persisted: config.persistedTunnel });
121
116
  if (tunnel) {
122
117
  publicUrl = tunnel.url;
123
- console.log('');
124
- console.log(` 🌐 Public: ${publicUrl}`);
125
- console.log(` Tunnel: ${tunnel.mode} (expires in ${tunnel.expiry})`);
126
118
  } else {
127
- console.log('');
128
119
  console.log(' ⚠️ Tunnel failed to start. Using LAN only.');
129
120
  }
130
121
  }
131
122
 
123
+ console.log(` Shell: ${config.shell}`);
124
+ console.log(` Session: ${defaultId}`);
125
+ console.log(` Auth: ${config.password ? `${gn}🔒 password${rs}` : '🔓 none'}`);
126
+ console.log('');
127
+
128
+ if (publicUrl) {
129
+ console.log(` 🌐 Public: ${publicUrl}`);
130
+ }
131
+ console.log(` Local: http://localhost:${config.port}`);
132
+ if (isLanReachable) {
133
+ console.log(` LAN: ${localUrl}`);
134
+ }
135
+
132
136
  const qrUrl = publicUrl || (isLanReachable ? localUrl : `http://localhost:${config.port}`);
133
137
  console.log('');
138
+ console.log(` ${dm}📋 Clipboard requires HTTPS — use the Public or localhost URL${rs}`);
139
+ console.log('');
134
140
  try {
135
141
  const qr = await QRCode.toString(qrUrl, { type: 'terminal', small: true });
136
142
  console.log(qr);
package/src/sessions.js CHANGED
@@ -82,6 +82,7 @@ class SessionManager {
82
82
  delete(id) {
83
83
  const s = this.sessions.get(id);
84
84
  if (!s) return false;
85
+ console.log(`[termbeam] Session "${s.name}" deleted (id=${id})`);
85
86
  s.pty.kill();
86
87
  return true;
87
88
  }
package/src/tunnel.js CHANGED
@@ -173,7 +173,8 @@ async function startTunnel(port, options = {}) {
173
173
  hostProc.stderr.on('data', (data) => {
174
174
  output += data.toString();
175
175
  });
176
- hostProc.on('error', () => {
176
+ hostProc.on('error', (err) => {
177
+ console.error(`[termbeam] Tunnel process error: ${err.message}`);
177
178
  clearTimeout(timeout);
178
179
  resolve(null);
179
180
  });
package/src/websocket.js CHANGED
@@ -35,7 +35,9 @@ function setupWebSocket(wss, { auth, sessions }) {
35
35
  if (msg.password === auth.password || auth.validateToken(msg.token)) {
36
36
  authenticated = true;
37
37
  ws.send(JSON.stringify({ type: 'auth_ok' }));
38
+ console.log('[termbeam] WS: auth success');
38
39
  } else {
40
+ console.warn('[termbeam] WS: auth failed');
39
41
  ws.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }));
40
42
  ws.close();
41
43
  }
@@ -52,6 +54,7 @@ function setupWebSocket(wss, { auth, sessions }) {
52
54
  const session = sessions.get(msg.sessionId);
53
55
  if (!session) {
54
56
  ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
57
+ console.warn(`[termbeam] WS: attach failed — session ${msg.sessionId} not found`);
55
58
  return;
56
59
  }
57
60
  attached = session;
@@ -76,8 +79,8 @@ function setupWebSocket(wss, { auth, sessions }) {
76
79
  recalcPtySize(attached);
77
80
  }
78
81
  }
79
- } catch {
80
- if (attached) attached.pty.write(raw.toString());
82
+ } catch (err) {
83
+ console.warn('WS: dropped unparseable message:', err.message);
81
84
  }
82
85
  });
83
86