hop2 0.8.0 → 0.9.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.
@@ -165,15 +165,26 @@ if (!config.room) {
165
165
  process.exit(1);
166
166
  }
167
167
 
168
+ let sessionLabel = config.room;
169
+
168
170
  const DETACH_EXIT_CODE = 10;
169
171
  const KILL_EXIT_CODE = 11;
170
172
  const TYPING_IDLE_MS = 1200;
171
173
  const NOTICE_MS = 3500;
174
+ const HOP_DAEMON_POLL_MS = 5000;
172
175
  const PAN_STEP = 2;
173
176
  const PAN_FAST_STEP = 10;
174
177
  let cursorVisible = true;
175
178
  const SCROLL_STEP = 3;
179
+ const REMOTE_MOUSE_MODE_PARAMS = new Set([9, 1000, 1001, 1002, 1003, 1005, 1006, 1015, 1016]);
180
+ const REMOTE_ALT_SCREEN_PARAMS = new Set([47, 1047, 1049]);
181
+ const REMOTE_APPLICATION_CURSOR_MODE_PARAM = 1;
182
+ const PRIVATE_MODE_TAIL_CHARS = 64;
176
183
  let pendingInput = "";
184
+ let remoteMouseModes = new Set<number>();
185
+ let remoteAlternateScreen = false;
186
+ let remoteApplicationCursor = false;
187
+ let pendingPrivateMode = "";
177
188
  type RgbColor = { r: number; g: number; b: number };
178
189
  let defaultFg: RgbColor | null = null;
179
190
  let defaultBg: RgbColor | null = null;
@@ -189,8 +200,10 @@ let reconnectAttempt = 0;
189
200
  let reconnectTimer: NodeJS.Timeout | null = null;
190
201
  let typingTimeout: NodeJS.Timeout | null = null;
191
202
  let noticeTimer: NodeJS.Timeout | null = null;
203
+ let daemonPollTimer: NodeJS.Timeout | null = null;
192
204
  let exitCode = 0;
193
205
  let exitMessage: string | null = null;
206
+ let shortcutsEnabledAt = 0;
194
207
 
195
208
  let collabMode = true;
196
209
  let controllerId: string | null = null;
@@ -201,6 +214,7 @@ let syncSize = true;
201
214
  let mouseCapture = true;
202
215
  let viewX = 0;
203
216
  let viewY = 0;
217
+ let userPannedAway = false;
204
218
  let showHints = true;
205
219
  let scrollOff = DEFAULT_SCROLL_OFF;
206
220
 
@@ -213,6 +227,46 @@ let renderScheduled = false;
213
227
  let lastRenderCols = 0;
214
228
  let lastRenderRows = 0;
215
229
 
230
+ const hopHomeDir = process.env.HOP_HOME || path.join(os.homedir(), ".hop2");
231
+ const hopTunnelStateFile = path.join(hopHomeDir, ".tunnel-state");
232
+ const explicitShareEnv = process.env.HAY_SHARE_URL || process.env.HOP_PUBLIC_URL || "";
233
+
234
+ const normalizeUrlOrigin = (raw: string) => {
235
+ try {
236
+ const url = new URL(raw);
237
+ return url.origin;
238
+ } catch {
239
+ return raw;
240
+ }
241
+ };
242
+
243
+ const inferServerOrigin = () => {
244
+ try {
245
+ const wsUrl = new URL(config.server);
246
+ const protocol = wsUrl.protocol === "wss:" ? "https:" : "http:";
247
+ return normalizeUrlOrigin(`${protocol}//${wsUrl.host}`);
248
+ } catch {
249
+ return "";
250
+ }
251
+ };
252
+
253
+ const explicitShareUrl = explicitShareEnv ? normalizeUrlOrigin(explicitShareEnv) : "";
254
+ const defaultShareUrl = explicitShareUrl || inferServerOrigin();
255
+
256
+ const shouldTrackHopDaemon = (() => {
257
+ if (process.env.HAY_TRACK_HOP_DAEMON === "1") return true;
258
+ if (process.env.HAY_TRACK_HOP_DAEMON === "0") return false;
259
+ try {
260
+ const wsUrl = new URL(config.server);
261
+ return wsUrl.hostname === "127.0.0.1" || wsUrl.hostname === "localhost" || wsUrl.hostname === "::1";
262
+ } catch {
263
+ return false;
264
+ }
265
+ })();
266
+
267
+ let hopDaemonRunning = false;
268
+ let hopDaemonShareUrl = "";
269
+
216
270
  const getLocalMetrics = () => {
217
271
  const cols = process.stdout.columns || 80;
218
272
  const rows = process.stdout.rows || 24;
@@ -250,21 +304,36 @@ terminal.onData((data) => {
250
304
 
251
305
  const filterFocusSequences = (data: string) => data.replace(/\x1b\[I/g, "").replace(/\x1b\[O/g, "");
252
306
 
307
+ const getDirectionalKey = (direction: number) => {
308
+ if (remoteApplicationCursor) {
309
+ return direction > 0 ? "\x1bOB" : "\x1bOA";
310
+ }
311
+ return direction > 0 ? "\x1b[B" : "\x1b[A";
312
+ };
313
+
253
314
  const stripMouseSequences = (data: string) => {
254
- let didScroll = false;
255
- const cleaned = data.replace(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g, (_, buttonText, _x, _y, kind) => {
315
+ const cleaned = data.replace(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g, (sequence, buttonText, _x, _y, kind) => {
256
316
  const button = Number(buttonText);
257
317
  const isWheel = (button & 64) === 64;
258
318
  if (isWheel && kind === "M") {
259
319
  const direction = (button & 1) === 1 ? 1 : -1;
320
+ const isTuiLikeMode = remoteAlternateScreen || remoteMouseModes.size > 0;
321
+ if (isTuiLikeMode) {
322
+ return getDirectionalKey(direction).repeat(SCROLL_STEP);
323
+ }
324
+ // In normal shell mode, wheel should pan local viewport scrollback.
260
325
  viewY += direction * SCROLL_STEP;
261
326
  clampView();
327
+ userPannedAway = viewY < getMaxViewY();
262
328
  scheduleRender();
263
- didScroll = true;
329
+ return "";
330
+ }
331
+ if (remoteMouseModes.size > 0) {
332
+ return sequence;
264
333
  }
265
334
  return "";
266
335
  });
267
- return { cleaned, didScroll };
336
+ return { cleaned };
268
337
  };
269
338
 
270
339
  const parseOscColor = (value: string): RgbColor | null => {
@@ -389,17 +458,65 @@ const setCursorVisible = (visible: boolean) => {
389
458
  scheduleRender();
390
459
  };
391
460
 
392
- terminal.parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => {
393
- if (params.includes(25)) {
394
- setCursorVisible(false);
461
+ const applyPrivateModes = (params: readonly number[], enabled: boolean) => {
462
+ for (const param of params) {
463
+ if (param === REMOTE_APPLICATION_CURSOR_MODE_PARAM) {
464
+ remoteApplicationCursor = enabled;
465
+ continue;
466
+ }
467
+ if (param === 25) {
468
+ setCursorVisible(enabled);
469
+ continue;
470
+ }
471
+ if (REMOTE_MOUSE_MODE_PARAMS.has(param)) {
472
+ if (enabled) {
473
+ remoteMouseModes.add(param);
474
+ } else {
475
+ remoteMouseModes.delete(param);
476
+ }
477
+ continue;
478
+ }
479
+ if (REMOTE_ALT_SCREEN_PARAMS.has(param)) {
480
+ remoteAlternateScreen = enabled;
481
+ }
395
482
  }
483
+ };
484
+
485
+ const trackPrivateModes = (chunk: string) => {
486
+ if (!chunk) return;
487
+ const combined = pendingPrivateMode + chunk;
488
+ const modeRegex = /\x1b\[\?([0-9;]*)([hl])/g;
489
+ let match: RegExpExecArray | null;
490
+ let lastCompleteIndex = 0;
491
+ while ((match = modeRegex.exec(combined)) !== null) {
492
+ const params = match[1]
493
+ .split(";")
494
+ .filter(Boolean)
495
+ .map((param) => Number.parseInt(param, 10))
496
+ .filter((param) => Number.isFinite(param));
497
+ applyPrivateModes(params, match[2] === "h");
498
+ lastCompleteIndex = modeRegex.lastIndex;
499
+ }
500
+
501
+ const trailingStart = combined.lastIndexOf("\x1b[?");
502
+ if (trailingStart !== -1 && trailingStart >= lastCompleteIndex) {
503
+ const trailing = combined.slice(trailingStart);
504
+ if (!/[hl]/.test(trailing)) {
505
+ pendingPrivateMode = trailing.slice(-PRIVATE_MODE_TAIL_CHARS);
506
+ return;
507
+ }
508
+ }
509
+
510
+ pendingPrivateMode = combined.slice(-PRIVATE_MODE_TAIL_CHARS);
511
+ };
512
+
513
+ terminal.parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => {
514
+ applyPrivateModes(params, false);
396
515
  return false;
397
516
  });
398
517
 
399
518
  terminal.parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => {
400
- if (params.includes(25)) {
401
- setCursorVisible(true);
402
- }
519
+ applyPrivateModes(params, true);
403
520
  return false;
404
521
  });
405
522
 
@@ -478,31 +595,78 @@ const getUrl = () => {
478
595
  )}&cols=${localMetrics.viewportCols}&rows=${localMetrics.viewportRows}`;
479
596
  };
480
597
 
481
- const buildShareUrl = () => {
482
- const explicit = process.env.HAY_SHARE_URL || process.env.HOP_PUBLIC_URL || "";
483
-
484
- const normalize = (raw: string) => {
485
- try {
486
- const url = new URL(raw);
487
- return url.origin;
488
- } catch {
489
- return raw;
490
- }
491
- };
492
-
493
- if (explicit) {
494
- return normalize(explicit);
598
+ const isPidAlive = (pid: number) => {
599
+ if (!Number.isInteger(pid) || pid <= 0) return false;
600
+ try {
601
+ process.kill(pid, 0);
602
+ return true;
603
+ } catch {
604
+ return false;
495
605
  }
606
+ };
496
607
 
608
+ const readHopDaemonRuntime = () => {
609
+ if (!shouldTrackHopDaemon) {
610
+ return { running: false, shareUrl: "" };
611
+ }
612
+ if (!fs.existsSync(hopTunnelStateFile)) {
613
+ return { running: false, shareUrl: "" };
614
+ }
497
615
  try {
498
- const wsUrl = new URL(config.server);
499
- const protocol = wsUrl.protocol === "wss:" ? "https:" : "http:";
500
- return normalize(`${protocol}//${wsUrl.host}`);
616
+ const parsed = JSON.parse(fs.readFileSync(hopTunnelStateFile, "utf8"));
617
+ const pid = Number(parsed?.pid);
618
+ if (!isPidAlive(pid)) {
619
+ return { running: false, shareUrl: "" };
620
+ }
621
+ const shareUrlRaw = typeof parsed?.url === "string" ? parsed.url.trim() : "";
622
+ return {
623
+ running: true,
624
+ shareUrl: shareUrlRaw ? normalizeUrlOrigin(shareUrlRaw) : ""
625
+ };
501
626
  } catch {
502
- return "";
627
+ return { running: false, shareUrl: "" };
503
628
  }
504
629
  };
505
630
 
631
+ const refreshHopDaemonRuntime = () => {
632
+ if (!shouldTrackHopDaemon) return;
633
+ const next = readHopDaemonRuntime();
634
+ if (next.running !== hopDaemonRunning || next.shareUrl !== hopDaemonShareUrl) {
635
+ hopDaemonRunning = next.running;
636
+ hopDaemonShareUrl = next.shareUrl;
637
+ scheduleRender();
638
+ }
639
+ };
640
+
641
+ const startDaemonPolling = () => {
642
+ if (!shouldTrackHopDaemon || daemonPollTimer) return;
643
+ refreshHopDaemonRuntime();
644
+ daemonPollTimer = setInterval(() => {
645
+ refreshHopDaemonRuntime();
646
+ }, HOP_DAEMON_POLL_MS);
647
+ daemonPollTimer.unref?.();
648
+ };
649
+
650
+ const stopDaemonPolling = () => {
651
+ if (!daemonPollTimer) return;
652
+ clearInterval(daemonPollTimer);
653
+ daemonPollTimer = null;
654
+ };
655
+
656
+ const buildShareUrl = () => {
657
+ if (shouldTrackHopDaemon && hopDaemonShareUrl) {
658
+ return hopDaemonShareUrl;
659
+ }
660
+ return defaultShareUrl;
661
+ };
662
+
663
+ const buildHopDaemonLabel = () => {
664
+ if (!shouldTrackHopDaemon) return "";
665
+ if (!hopDaemonRunning) return "hop offline";
666
+ if (!hopDaemonShareUrl) return "hop starting";
667
+ return "hop online";
668
+ };
669
+
506
670
  const setStatus = (nextStatus: typeof status) => {
507
671
  status = nextStatus;
508
672
  scheduleRender();
@@ -614,6 +778,42 @@ const padOrTrim = (text: string, cols: number) => {
614
778
  return text;
615
779
  };
616
780
 
781
+ type BarSegment = {
782
+ text: string;
783
+ secondary?: boolean;
784
+ dropPriority?: number;
785
+ };
786
+
787
+ const joinBarSegments = (segments: BarSegment[]) => segments.map((segment) => segment.text).join(" | ");
788
+
789
+ const fitBarSegments = (left: string, segments: BarSegment[], cols: number) => {
790
+ const active = segments.filter((segment) => segment.text);
791
+ while (active.length > 0) {
792
+ const right = joinBarSegments(active);
793
+ const rightLen = visibleLength(right);
794
+ const availableLeft = right ? Math.max(0, cols - rightLen - 1) : cols;
795
+ if (rightLen < cols && visibleLength(left) <= availableLeft) {
796
+ break;
797
+ }
798
+
799
+ let optionalIndex = -1;
800
+ let optionalPriority = Number.POSITIVE_INFINITY;
801
+ for (let i = 0; i < active.length; i += 1) {
802
+ const priority = active[i].dropPriority;
803
+ if (priority === undefined) continue;
804
+ if (priority < optionalPriority) {
805
+ optionalPriority = priority;
806
+ optionalIndex = i;
807
+ }
808
+ }
809
+ if (optionalIndex === -1) {
810
+ break;
811
+ }
812
+ active.splice(optionalIndex, 1);
813
+ }
814
+ return active;
815
+ };
816
+
617
817
  const composeBar = (left: string, right: string, cols: number) => {
618
818
  if (!right) {
619
819
  return padOrTrim(left, cols);
@@ -874,17 +1074,34 @@ const render = () => {
874
1074
  const presenceNames = presence.filter((client) => client.id !== clientId).map((client) => client.name);
875
1075
  const statusLabel = status === "connected" ? "" : status;
876
1076
  const shareUrl = buildShareUrl();
1077
+ const hopDaemonLabel = buildHopDaemonLabel();
877
1078
 
878
- const statusOrUrl = statusLabel || shareUrl;
879
- const bottomLeft = `● ${config.room}`;
1079
+ const bottomLeft = `● ${sessionLabel}`;
880
1080
  const autofitLabel = syncSize ? "autofit on" : "autofit off";
881
1081
  const peerLabel = `peers ${presenceNames.length}`;
1082
+ const rightSegments = fitBarSegments(
1083
+ bottomLeft,
1084
+ [
1085
+ statusLabel ? { text: statusLabel } : null,
1086
+ hopDaemonLabel ? { text: hopDaemonLabel, secondary: true, dropPriority: 1 } : null,
1087
+ !statusLabel && shareUrl ? { text: shareUrl, secondary: true, dropPriority: 0 } : null,
1088
+ { text: autofitLabel, secondary: true, dropPriority: 2 },
1089
+ { text: peerLabel, secondary: true, dropPriority: 3 }
1090
+ ].filter((segment): segment is BarSegment => !!segment),
1091
+ localMetrics.cols
1092
+ );
1093
+ const rightPriority = rightSegments
1094
+ .filter((segment) => segment.secondary)
1095
+ .map((segment) => segment.text);
882
1096
  const rightPrimary = decorateBottom(
883
- [statusOrUrl, autofitLabel, peerLabel].filter(Boolean).join(" | "),
884
- statusLabel ? [peerLabel] : [shareUrl, peerLabel]
1097
+ joinBarSegments(rightSegments),
1098
+ rightPriority
885
1099
  );
1100
+ const topPriority = rightSegments
1101
+ .filter((segment) => segment.text === hopDaemonLabel || segment.text === shareUrl)
1102
+ .map((segment) => segment.text);
886
1103
  const bottomPrimary = renderBar(
887
- decorateTop(composeBar(bottomLeft, rightPrimary, localMetrics.cols), statusLabel ? [] : [shareUrl], [config.room]),
1104
+ decorateTop(composeBar(bottomLeft, rightPrimary, localMetrics.cols), topPriority, [sessionLabel]),
888
1105
  "bottom"
889
1106
  );
890
1107
 
@@ -943,6 +1160,11 @@ const connect = () => {
943
1160
 
944
1161
  ws.on("open", () => {
945
1162
  connected = true;
1163
+ remoteMouseModes.clear();
1164
+ remoteAlternateScreen = false;
1165
+ remoteApplicationCursor = false;
1166
+ pendingPrivateMode = "";
1167
+ shortcutsEnabledAt = Date.now() + 400;
946
1168
  reconnectAttempt = 0;
947
1169
  setStatus("connected");
948
1170
  initUi();
@@ -973,22 +1195,20 @@ const connect = () => {
973
1195
  break;
974
1196
  case "output":
975
1197
  {
1198
+ trackPrivateModes(message.data);
976
1199
  trackOscColors(message.data);
977
1200
  const filtered = filterFocusSequences(message.data);
978
1201
  if (!filtered) return;
979
- // Follow output if cursor was visible (standard terminal behavior)
980
- const buffer = terminal.buffer.active;
981
- const cursorRowBefore = buffer.baseY + buffer.cursorY;
982
- const cursorWasVisible = cursorRowBefore >= viewY && cursorRowBefore < viewY + localMetrics.viewportRows;
983
1202
  terminal.write(filtered, () => {
984
- if (cursorWasVisible) {
985
- // Only scroll if cursor moved outside viewport (with scrollOff margin)
986
- const cursorRowAfter = buffer.baseY + buffer.cursorY;
1203
+ if (!userPannedAway) {
1204
+ // Follow cursor: keep it visible with scrollOff margin
1205
+ const buffer = terminal.buffer.active;
1206
+ const cursorRow = buffer.baseY + buffer.cursorY;
987
1207
  const margin = Math.min(scrollOff, Math.floor(localMetrics.viewportRows / 2));
988
- if (cursorRowAfter < viewY + margin) {
989
- viewY = Math.max(0, cursorRowAfter - margin);
990
- } else if (cursorRowAfter >= viewY + localMetrics.viewportRows - margin) {
991
- viewY = cursorRowAfter - localMetrics.viewportRows + margin + 1;
1208
+ if (cursorRow < viewY + margin) {
1209
+ viewY = Math.max(0, cursorRow - margin);
1210
+ } else if (cursorRow >= viewY + localMetrics.viewportRows - margin) {
1211
+ viewY = cursorRow - localMetrics.viewportRows + margin + 1;
992
1212
  }
993
1213
  clampView();
994
1214
  }
@@ -998,12 +1218,17 @@ const connect = () => {
998
1218
  break;
999
1219
  case "snapshot":
1000
1220
  {
1221
+ trackPrivateModes(message.data);
1222
+ if (typeof message.alternateScreen === "boolean") {
1223
+ remoteAlternateScreen = message.alternateScreen;
1224
+ }
1001
1225
  trackOscColors(message.data);
1002
1226
  terminal.clear();
1227
+ userPannedAway = false;
1003
1228
  const filtered = filterFocusSequences(message.data);
1004
1229
  if (!filtered) return;
1005
1230
  terminal.write(filtered, () => {
1006
- // On snapshot, only scroll if cursor is outside viewport (with scrollOff margin)
1231
+ // Always follow cursor on snapshot load
1007
1232
  const buffer = terminal.buffer.active;
1008
1233
  const cursorRow = buffer.baseY + buffer.cursorY;
1009
1234
  const margin = Math.min(scrollOff, Math.floor(localMetrics.viewportRows / 2));
@@ -1038,6 +1263,11 @@ const connect = () => {
1038
1263
  cleanupAndExit();
1039
1264
  }
1040
1265
  break;
1266
+ case "session_renamed":
1267
+ sessionLabel = message.displayName;
1268
+ pushNotice(`Session renamed to ${message.displayName}`);
1269
+ scheduleRender();
1270
+ break;
1041
1271
  case "error":
1042
1272
  pushNotice(message.message);
1043
1273
  break;
@@ -1067,6 +1297,7 @@ const cleanupAndExit = () => {
1067
1297
  if (typingTimeout) {
1068
1298
  clearTimeout(typingTimeout);
1069
1299
  }
1300
+ stopDaemonPolling();
1070
1301
  if (reconnectTimer) {
1071
1302
  clearTimeout(reconnectTimer);
1072
1303
  }
@@ -1095,9 +1326,10 @@ const handleLocalShortcut = (input: string) => {
1095
1326
 
1096
1327
  const shouldExitImmediately =
1097
1328
  !ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING;
1329
+ const ctrlShortcutsReady = Date.now() >= shortcutsEnabledAt;
1098
1330
 
1099
1331
  // Ctrl+Q to kill session
1100
- if (input === "\x11") {
1332
+ if (ctrlShortcutsReady && input === "\x11") {
1101
1333
  shouldReconnect = false;
1102
1334
  exitCode = KILL_EXIT_CODE;
1103
1335
  if (reconnectTimer) {
@@ -1113,7 +1345,7 @@ const handleLocalShortcut = (input: string) => {
1113
1345
  }
1114
1346
 
1115
1347
  // Ctrl+G to detach
1116
- if (input === "\x07") {
1348
+ if (ctrlShortcutsReady && input === "\x07") {
1117
1349
  shouldReconnect = false;
1118
1350
  exitCode = DETACH_EXIT_CODE;
1119
1351
  if (reconnectTimer) {
@@ -1128,7 +1360,7 @@ const handleLocalShortcut = (input: string) => {
1128
1360
  }
1129
1361
 
1130
1362
  // Ctrl+T toggles hint bar
1131
- if (input === "\x14") {
1363
+ if (ctrlShortcutsReady && input === "\x14") {
1132
1364
  showHints = !showHints;
1133
1365
  saveConfig({ ...configFile, showHints });
1134
1366
  pushNotice(`Hints ${showHints ? "on" : "off"}`);
@@ -1157,6 +1389,7 @@ const handleLocalShortcut = (input: string) => {
1157
1389
  const step = altKey("K") ? PAN_FAST_STEP : PAN_STEP;
1158
1390
  viewY -= step;
1159
1391
  clampView();
1392
+ userPannedAway = viewY < getMaxViewY();
1160
1393
  scheduleRender();
1161
1394
  return true;
1162
1395
  }
@@ -1164,6 +1397,7 @@ const handleLocalShortcut = (input: string) => {
1164
1397
  const step = altKey("J") ? PAN_FAST_STEP : PAN_STEP;
1165
1398
  viewY += step;
1166
1399
  clampView();
1400
+ userPannedAway = viewY < getMaxViewY();
1167
1401
  scheduleRender();
1168
1402
  return true;
1169
1403
  }
@@ -1213,6 +1447,7 @@ process.stdin.on("data", (data) => {
1213
1447
  applySyncSize();
1214
1448
  }
1215
1449
 
1450
+ userPannedAway = false;
1216
1451
  sendMessage({ type: "input", data: sanitized });
1217
1452
  handleTyping();
1218
1453
  });
@@ -1244,7 +1479,9 @@ process.on("SIGTERM", () => {
1244
1479
  });
1245
1480
 
1246
1481
  process.on("exit", () => {
1482
+ stopDaemonPolling();
1247
1483
  restoreUi();
1248
1484
  });
1249
1485
 
1486
+ startDaemonPolling();
1250
1487
  connect();
@@ -14,7 +14,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
14
  const PORT = Number(process.env.PORT ?? 4001);
15
15
  const SERVE_WEB = process.env.SERVE_WEB === "true";
16
16
  const WEB_DIST_PATH = process.env.WEB_DIST_PATH;
17
- const CWD = process.env.CWD; // Working directory for PTY sessions
17
+ const CWD = process.env.CWD || process.cwd(); // Working directory for PTY sessions
18
18
 
19
19
  const app = express();
20
20
  app.get("/health", (_req, res) => {
@@ -35,7 +35,7 @@ if (SERVE_WEB && WEB_DIST_PATH) {
35
35
  const server = http.createServer(app);
36
36
  const wss = new WebSocketServer({ noServer: true });
37
37
 
38
- const rooms = new RoomManager(createPty, CWD);
38
+ const rooms = new RoomManager(createPty);
39
39
 
40
40
  const toSocketAdapter = (ws: WebSocket) => {
41
41
  return {
@@ -81,10 +81,11 @@ wss.on("connection", (ws, req) => {
81
81
  const clientId = randomUUID();
82
82
  const colorIndex = Math.floor(Math.random() * 1000);
83
83
 
84
+ const cwd = url.searchParams.get("cwd") || CWD;
84
85
  const room = rooms.getRoom(roomId, {
85
86
  cols: Number.isFinite(cols) ? cols : 80,
86
87
  rows: Number.isFinite(rows) ? rows : 24
87
- });
88
+ }, cwd);
88
89
 
89
90
  room.attachClient(
90
91
  {
@@ -79,10 +79,10 @@ export type TermshareServerOptions = {
79
79
  * ```
80
80
  */
81
81
  export function attachTermshare(options: TermshareServerOptions) {
82
- const { server, path = "/ws", ptyFactory = createPty, cwd, onConnect, onDisconnect } = options;
82
+ const { server, path = "/ws", ptyFactory = createPty, cwd = process.cwd(), onConnect, onDisconnect } = options;
83
83
 
84
84
  const wss = new WebSocketServer({ noServer: true });
85
- const rooms = new RoomManager(ptyFactory, cwd);
85
+ const rooms = new RoomManager(ptyFactory);
86
86
 
87
87
  const toSocketAdapter = (ws: WebSocket): SocketAdapter => ({
88
88
  send: (data: string) => {
@@ -125,10 +125,11 @@ export function attachTermshare(options: TermshareServerOptions) {
125
125
  const clientId = randomUUID();
126
126
  const colorIndex = Math.floor(Math.random() * 1000);
127
127
 
128
+ const roomCwd = url.searchParams.get("cwd") || cwd;
128
129
  const room = rooms.getRoom(roomId, {
129
130
  cols: Number.isFinite(cols) ? cols : 80,
130
131
  rows: Number.isFinite(rows) ? rows : 24
131
- });
132
+ }, roomCwd);
132
133
 
133
134
  room.attachClient(
134
135
  {
@@ -2,13 +2,20 @@ import os from "node:os";
2
2
  import { EventEmitter } from "node:events";
3
3
  import pty, { IPty } from "node-pty-prebuilt-multiarch";
4
4
 
5
- export type PtyFactory = (options: {
5
+ export type PtyFactoryOptions = {
6
6
  cols: number;
7
7
  rows: number;
8
- cwd?: string;
9
- }) => IPty;
8
+ cwd: string;
9
+ env?: Record<string, string>;
10
+ shell?: string;
11
+ };
12
+
13
+ export type PtyFactory = (options: PtyFactoryOptions) => IPty;
10
14
 
11
- const resolveShell = () => {
15
+ const resolveShell = (shellOverride?: string) => {
16
+ if (shellOverride) {
17
+ return shellOverride;
18
+ }
12
19
  if (process.env.SHELL) {
13
20
  return process.env.SHELL;
14
21
  }
@@ -18,7 +25,7 @@ const resolveShell = () => {
18
25
  return "/bin/bash";
19
26
  };
20
27
 
21
- export const createPty: PtyFactory = ({ cols, rows, cwd }) => {
28
+ export const createPty: PtyFactory = ({ cols, rows, cwd, env: envOverrides, shell }) => {
22
29
  const mode = process.env.PTY_MODE ?? "native";
23
30
  if (mode === "mock") {
24
31
  return createMockPty();
@@ -30,14 +37,20 @@ export const createPty: PtyFactory = ({ cols, rows, cwd }) => {
30
37
  env[key] = value;
31
38
  }
32
39
  }
40
+ if (envOverrides && typeof envOverrides === "object") {
41
+ for (const [key, value] of Object.entries(envOverrides)) {
42
+ if (!key || typeof value !== "string") continue;
43
+ env[key] = value;
44
+ }
45
+ }
33
46
  env.TERM = "xterm-256color";
34
47
  if (!env.COLORTERM) {
35
48
  env.COLORTERM = "truecolor";
36
49
  }
37
- return pty.spawn(resolveShell(), [], {
50
+ return pty.spawn(resolveShell(shell), [], {
38
51
  cols,
39
52
  rows,
40
- cwd: cwd ?? process.cwd(),
53
+ cwd,
41
54
  env
42
55
  });
43
56
  } catch (error) {