hop2 0.9.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.
- package/AGENTS.md +33 -0
- package/MULTI-AGENT-CHECKLIST.md +30 -0
- package/README-MCP.md +213 -0
- package/README.md +117 -17
- package/hay/CHANGELOG.md +6 -0
- package/hay/apps/cli/src/index.ts +285 -48
- package/hay/apps/server/src/index.ts +4 -3
- package/hay/apps/server/src/lib.ts +4 -3
- package/hay/apps/server/src/pty.ts +20 -7
- package/hay/apps/server/src/rooms.ts +93 -12
- package/hay/apps/server/test/rooms.test.ts +82 -3
- package/hay/apps/web/src/App.tsx +253 -40
- package/hay/apps/web/src/components/MobileKeyboard.tsx +43 -2
- package/hay/apps/web/src/styles.css +9 -2
- package/hay/package-lock.json +4 -4
- package/hay/packages/shared/src/protocol.ts +4 -0
- package/hay-web/assets/{index-CTTzApRp.css → index-B7rkCY1_.css} +1 -1
- package/hay-web/assets/index-kr4B-B06.js +60 -0
- package/hay-web/assets/{index-FDfdolCs.js.map → index-kr4B-B06.js.map} +1 -1
- package/hay-web/index.html +2 -2
- package/hop +4896 -585
- package/hop.bak2 +8071 -0
- package/lib/terminal-access.js +17 -0
- package/mcp/hop-mcp.js +4530 -0
- package/mcp/hop-mcp.js.bak +4492 -0
- package/mcp/hop-mcp.js.bak2 +4530 -0
- package/package.json +6 -3
- package/scripts/hay-host.js +189 -0
- package/sessions.html +314 -26
- package/setup-mcp.js +443 -0
- package/tests/hop-mcp.integration.test.cjs +3014 -0
- package/tests/terminal-access.unit.test.cjs +21 -0
- package/tests/terminal-api.integration.test.cjs +298 -0
- package/hay-web/assets/index-FDfdolCs.js +0 -60
|
@@ -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
|
-
|
|
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
|
-
|
|
329
|
+
return "";
|
|
330
|
+
}
|
|
331
|
+
if (remoteMouseModes.size > 0) {
|
|
332
|
+
return sequence;
|
|
264
333
|
}
|
|
265
334
|
return "";
|
|
266
335
|
});
|
|
267
|
-
return { cleaned
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
|
499
|
-
const
|
|
500
|
-
|
|
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
|
|
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
|
-
|
|
884
|
-
|
|
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),
|
|
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 (
|
|
985
|
-
//
|
|
986
|
-
const
|
|
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 (
|
|
989
|
-
viewY = Math.max(0,
|
|
990
|
-
} else if (
|
|
991
|
-
viewY =
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
5
|
+
export type PtyFactoryOptions = {
|
|
6
6
|
cols: number;
|
|
7
7
|
rows: number;
|
|
8
|
-
cwd
|
|
9
|
-
|
|
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
|
|
53
|
+
cwd,
|
|
41
54
|
env
|
|
42
55
|
});
|
|
43
56
|
} catch (error) {
|