handmux 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/README.zh-CN.md +2 -1
- package/package.json +5 -4
- package/public/assets/index-DCZuq0nO.css +32 -0
- package/public/assets/index-DkaDRYCU.js +335 -0
- package/public/index.html +2 -2
- package/src/captureBackground.js +93 -0
- package/src/cli/i18n/en.js +1 -1
- package/src/cli/i18n/zh.js +1 -1
- package/src/routes/terminal.js +20 -2
- package/src/server.js +11 -3
- package/src/terminalStream.js +388 -0
- package/src/tmux/commands.js +17 -0
- package/src/trimCapture.js +3 -3
- package/public/assets/index-CIlbBw_U.js +0 -330
- package/public/assets/index-erQcVC-q.css +0 -32
package/public/index.html
CHANGED
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
@keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
|
|
49
49
|
@media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
|
|
50
50
|
</style>
|
|
51
|
-
<script type="module" crossorigin src="/assets/index-
|
|
52
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
51
|
+
<script type="module" crossorigin src="/assets/index-DkaDRYCU.js"></script>
|
|
52
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DCZuq0nO.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-DCZuq0nO.css"></noscript>
|
|
53
53
|
</head>
|
|
54
54
|
<body>
|
|
55
55
|
<div id="boot-splash" aria-hidden="true">
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const CSI = /\x1b\[([0-9;]*)m/g;
|
|
2
|
+
const ANSI = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g;
|
|
3
|
+
|
|
4
|
+
function backgroundAfter(line, initial) {
|
|
5
|
+
let background = initial;
|
|
6
|
+
const sgr = new RegExp(CSI.source, 'g');
|
|
7
|
+
let match = sgr.exec(line);
|
|
8
|
+
while (match) {
|
|
9
|
+
const params = match[1] === '' ? [0] : match[1].split(';').map(Number);
|
|
10
|
+
for (let i = 0; i < params.length; i += 1) {
|
|
11
|
+
const value = params[i];
|
|
12
|
+
if (value === 0 || value === 49) background = false;
|
|
13
|
+
else if ((value >= 40 && value <= 47) || (value >= 100 && value <= 107)) background = true;
|
|
14
|
+
else if (value === 38 || value === 48) {
|
|
15
|
+
const isBackground = value === 48;
|
|
16
|
+
if (params[i + 1] === 5) {
|
|
17
|
+
i += 2;
|
|
18
|
+
if (isBackground) background = true;
|
|
19
|
+
} else if (params[i + 1] === 2) {
|
|
20
|
+
i += 4;
|
|
21
|
+
if (isBackground) background = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
match = sgr.exec(line);
|
|
26
|
+
}
|
|
27
|
+
return background;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function paintsBackground(line) {
|
|
31
|
+
let background = false;
|
|
32
|
+
let offset = 0;
|
|
33
|
+
const sgr = new RegExp(CSI.source, 'g');
|
|
34
|
+
let match = sgr.exec(line);
|
|
35
|
+
while (match) {
|
|
36
|
+
if (background && match.index > offset) return true;
|
|
37
|
+
background = backgroundAfter(match[0], background);
|
|
38
|
+
offset = sgr.lastIndex;
|
|
39
|
+
match = sgr.exec(line);
|
|
40
|
+
}
|
|
41
|
+
return background && offset < line.length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isBlank = (line) => line.replace(ANSI, '').trim() === '';
|
|
45
|
+
|
|
46
|
+
// `capture-pane -e -N` compresses SGR state across newlines. A default blank row after a shaded
|
|
47
|
+
// row (Claude) and a genuinely shaded padding row (Codex) therefore look identical in the combined
|
|
48
|
+
// capture. Capturing just that row makes tmux emit the row's real starting attributes. Only those
|
|
49
|
+
// ambiguous rows need the extra read; ordinary rows keep the single fast combined capture.
|
|
50
|
+
export function ambiguousBackgroundRows(lines) {
|
|
51
|
+
const indexes = [];
|
|
52
|
+
let background = false;
|
|
53
|
+
let inBlankRun = false;
|
|
54
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
55
|
+
const line = lines[index];
|
|
56
|
+
const ambiguous = background && isBlank(line);
|
|
57
|
+
// One row resolves the whole trailing run: default closes it before the first LF; shaded is the
|
|
58
|
+
// one real padding row the client preserves and seals before clearing any additional blanks.
|
|
59
|
+
if (ambiguous && !inBlankRun) indexes.push(index);
|
|
60
|
+
inBlankRun = ambiguous;
|
|
61
|
+
background = backgroundAfter(line, background);
|
|
62
|
+
}
|
|
63
|
+
return indexes;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function restoreBlankRowBackgrounds(lines, historyLines, readRow) {
|
|
67
|
+
const restored = [...lines];
|
|
68
|
+
const indexes = ambiguousBackgroundRows(lines);
|
|
69
|
+
await Promise.all(indexes.map(async (index) => {
|
|
70
|
+
const row = index - historyLines;
|
|
71
|
+
const exact = await readRow(row);
|
|
72
|
+
// A separately-captured default row has no background SGR. Explicitly close the combined
|
|
73
|
+
// stream's inherited background before replaying it. A real padding row starts with its own
|
|
74
|
+
// background SGR and is preserved verbatim.
|
|
75
|
+
restored[index] = paintsBackground(exact) ? exact : `\x1b[49m${exact}`;
|
|
76
|
+
}));
|
|
77
|
+
return restored;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function restoreCaptureBackgrounds(capture, paneHeight, readRow) {
|
|
81
|
+
const trailingNewline = capture.endsWith('\n');
|
|
82
|
+
const body = trailingNewline ? capture.slice(0, -1) : capture;
|
|
83
|
+
const lines = body.split('\n');
|
|
84
|
+
const historyLines = Math.max(0, lines.length - paneHeight);
|
|
85
|
+
const restored = await restoreBlankRowBackgrounds(lines, historyLines, async (row) => {
|
|
86
|
+
const exact = await readRow(row);
|
|
87
|
+
return exact.endsWith('\n') ? exact.slice(0, -1) : exact;
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
ansi: restored.join('\n') + (trailingNewline ? '\n' : ''),
|
|
91
|
+
historyLines,
|
|
92
|
+
};
|
|
93
|
+
}
|
package/src/cli/i18n/en.js
CHANGED
|
@@ -285,7 +285,7 @@ export default {
|
|
|
285
285
|
'setup.sshReady': '✓ passwordless SSH already set up',
|
|
286
286
|
'setup.sshSetup': "→ setting up passwordless SSH to {host} (you'll enter the password once) …",
|
|
287
287
|
'setup.sshHelp1': 'Server side (one-time): point a reverse proxy at the forwarded loopback port.',
|
|
288
|
-
'setup.sshHelpNginx': ' nginx: proxy_pass http://127.0.0.1:{port};
|
|
288
|
+
'setup.sshHelpNginx': ' nginx: proxy_pass http://127.0.0.1:{port}; also set proxy_http_version 1.1, the Upgrade/Connection WebSocket headers, client_max_body_size 60m, and proxy_read_timeout 90s;',
|
|
289
289
|
'setup.sshHelpCaddy': ' caddy: {url} { reverse_proxy 127.0.0.1:{port} }',
|
|
290
290
|
'setup.previewAbout': 'Open a development service running on this machine by port from your phone. Static directory preview still works without this setting.',
|
|
291
291
|
'setup.previewRoute': 'Enter only the preview domain (for example, preview.example.com). handmux uses <project-name>.preview.example.com; route *.preview.example.com to the handmux gateway.',
|
package/src/cli/i18n/zh.js
CHANGED
|
@@ -284,7 +284,7 @@ export default {
|
|
|
284
284
|
'setup.sshReady': '✓ 免密 SSH 已配置好',
|
|
285
285
|
'setup.sshSetup': '→ 正在配置到 {host} 的免密 SSH(你需要输入一次密码)…',
|
|
286
286
|
'setup.sshHelp1': '服务端(一次性):把反向代理指向被转发的 loopback 端口。',
|
|
287
|
-
'setup.sshHelpNginx': ' nginx: proxy_pass http://127.0.0.1:{port};
|
|
287
|
+
'setup.sshHelpNginx': ' nginx: proxy_pass http://127.0.0.1:{port}; 并设置 proxy_http_version 1.1、Upgrade/Connection WebSocket 头、client_max_body_size 60m、proxy_read_timeout 90s;',
|
|
288
288
|
'setup.sshHelpCaddy': ' caddy: {url} { reverse_proxy 127.0.0.1:{port} }',
|
|
289
289
|
'setup.previewAbout': '用于在手机上按端口打开本机正在运行的开发服务;不配置时,静态目录预览仍可使用。',
|
|
290
290
|
'setup.previewRoute': '只填预览域名(如 preview.example.com)。handmux 会使用 <项目名>.preview.example.com;请将 *.preview.example.com 路由到 handmux 网关。',
|
package/src/routes/terminal.js
CHANGED
|
@@ -6,6 +6,7 @@ import { gzipSync } from 'node:zlib';
|
|
|
6
6
|
import { isPaneId, isWindowId } from '../tmux/commands.js';
|
|
7
7
|
import { capTrailingBlankRows } from '../trimCapture.js';
|
|
8
8
|
import { isAllowedKey } from '../keyNames.js';
|
|
9
|
+
import { restoreCaptureBackgrounds } from '../captureBackground.js';
|
|
9
10
|
|
|
10
11
|
export { isAllowedKey } from '../keyNames.js';
|
|
11
12
|
|
|
@@ -46,13 +47,28 @@ export function terminalRoutes({ commands }) {
|
|
|
46
47
|
// pane as exactly its visible screen (lines=0) and skip the blank-trim. A normal pane still pulls
|
|
47
48
|
// `lines` of scrollback and caps the empty grid below the cursor (fresh shell = "prompt + a wall of
|
|
48
49
|
// blank rows") so the phone's bottom-anchored render shows content, not blank. See trimCapture.js.
|
|
49
|
-
const
|
|
50
|
+
const captured = await commands.capturePane(req.query.pane, altScreen ? 0 : lines);
|
|
51
|
+
const restored = typeof commands.capturePaneRow === 'function'
|
|
52
|
+
? await restoreCaptureBackgrounds(
|
|
53
|
+
captured,
|
|
54
|
+
height,
|
|
55
|
+
(row) => commands.capturePaneRow(req.query.pane, row),
|
|
56
|
+
)
|
|
57
|
+
: {
|
|
58
|
+
ansi: captured,
|
|
59
|
+
historyLines: Math.max(
|
|
60
|
+
0,
|
|
61
|
+
(captured.endsWith('\n') ? captured.slice(0, -1) : captured).split('\n').length - height,
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
const raw = restored.ansi;
|
|
50
65
|
const ansi = altScreen ? raw : capTrailingBlankRows(raw);
|
|
51
66
|
// The cursor's row counted from the BOTTOM of the (trimmed) capture. The live screen is the
|
|
52
67
|
// capture's last `height` rows, so the cursor sits `height-1-cursorY` rows above the bottom —
|
|
53
68
|
// less however many trailing blank rows capTrailingBlankRows dropped (all of them below the
|
|
54
69
|
// cursor). The client re-places xterm's cursor this many rows up from the seed's last row.
|
|
55
70
|
const rowsOf = (s) => (s.endsWith('\n') ? s.slice(0, -1) : s).split('\n').length;
|
|
71
|
+
const historyLines = altScreen ? 0 : restored.historyLines;
|
|
56
72
|
const cur = {
|
|
57
73
|
row: Math.max(0, (height - 1 - cursorY) - (rowsOf(raw) - rowsOf(ansi))),
|
|
58
74
|
col: cursorX, vis: cursorVisible,
|
|
@@ -67,7 +83,9 @@ export function terminalRoutes({ commands }) {
|
|
|
67
83
|
.update(`${width}x${height}\n${cur.col},${cursorY},${cur.vis ? 1 : 0}\n${altScreen ? 1 : 0}${mouseAware ? 'm' : ''}\n${ansi}`)
|
|
68
84
|
.digest('hex').slice(0, 16);
|
|
69
85
|
if (req.query.since === hash) return res.status(204).end();
|
|
70
|
-
const json = JSON.stringify({
|
|
86
|
+
const json = JSON.stringify({
|
|
87
|
+
ansi, width, height, historyLines, hash, cur, alt: altScreen, mouseAware,
|
|
88
|
+
});
|
|
71
89
|
res.set('Content-Type', 'application/json');
|
|
72
90
|
res.set('Vary', 'Accept-Encoding'); // both 200 branches vary on encoding (correct for any caching proxy)
|
|
73
91
|
// Capture text is mostly SGR codes + spaces — gzip crushes it ~10x. (204s are empty, never gzipped.)
|
package/src/server.js
CHANGED
|
@@ -24,6 +24,7 @@ import { createEnvironmentProvider } from './workspace/environment.js';
|
|
|
24
24
|
import { createWorkspaceLock } from './workspace/lock.js';
|
|
25
25
|
import { createGracefulShutdown, createWorkspaceBackground } from './workspace/checkpointer.js';
|
|
26
26
|
import { createWorkspaceRuntime } from './workspace/runtime.js';
|
|
27
|
+
import { createTerminalStream } from './terminalStream.js';
|
|
27
28
|
|
|
28
29
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
29
30
|
|
|
@@ -151,10 +152,17 @@ app.get('*', (req, res, next) => {
|
|
|
151
152
|
const server = app.listen(cfg.port, cfg.host, () => {
|
|
152
153
|
console.log(`[handmux] listening on http://${cfg.host}:${cfg.port} (serving ${staticDir})`);
|
|
153
154
|
});
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
const terminalStream = createTerminalStream({ token, commands });
|
|
156
|
+
// The terminal stream owns one exact path. Every other Upgrade keeps using the existing
|
|
157
|
+
// dynamic-preview router unchanged.
|
|
158
|
+
server.on('upgrade', (req, socket, head) => {
|
|
159
|
+
if (!terminalStream.onUpgrade(req, socket, head)) preview.onUpgrade(req, socket, head);
|
|
160
|
+
});
|
|
156
161
|
|
|
157
162
|
const shutdown = createGracefulShutdown({ events, workspace, server });
|
|
158
|
-
const handleSignal = () => {
|
|
163
|
+
const handleSignal = () => {
|
|
164
|
+
terminalStream.close();
|
|
165
|
+
shutdown().catch(() => {});
|
|
166
|
+
};
|
|
159
167
|
process.on('SIGINT', handleSignal);
|
|
160
168
|
process.on('SIGTERM', handleSignal);
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import { tokenEquals } from './auth.js';
|
|
4
|
+
import { isPaneId } from './tmux/commands.js';
|
|
5
|
+
import { restoreCaptureBackgrounds } from './captureBackground.js';
|
|
6
|
+
|
|
7
|
+
const MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
8
|
+
const MAX_CLIENT_MESSAGE_BYTES = 16 * 1024;
|
|
9
|
+
const START_TIMEOUT_MS = 5000;
|
|
10
|
+
const SUBSCRIBE_TIMEOUT_MS = 5000;
|
|
11
|
+
const HEARTBEAT_MS = 30000;
|
|
12
|
+
const INITIAL_HISTORY_LINES = 100;
|
|
13
|
+
const PANE_INFO = '"#{pane_width}\\t#{pane_height}\\t#{cursor_x}\\t#{cursor_y}\\t#{cursor_flag}\\t#{alternate_on}\\t#{mouse_any_flag}\\t#{mouse_sgr_flag}"';
|
|
14
|
+
|
|
15
|
+
export function echoTerminalProbe(ws, message) {
|
|
16
|
+
if (message?.type !== 'probe' || !Number.isSafeInteger(message.id) || message.id < 0) return false;
|
|
17
|
+
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'probe', id: message.id }));
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function startSubscribeDeadline(ws, timeoutMs = SUBSCRIBE_TIMEOUT_MS) {
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
if (ws.readyState < 2) ws.close(4001, 'authentication timeout');
|
|
24
|
+
}, timeoutMs);
|
|
25
|
+
timer.unref?.();
|
|
26
|
+
return () => clearTimeout(timer);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parsePaneInfo(infoLines) {
|
|
30
|
+
const values = Buffer.concat(infoLines).toString('utf8').split('\t').map(Number);
|
|
31
|
+
if (!values.every(Number.isFinite) || values.length !== 8 || values[0] < 1 || values[1] < 1) {
|
|
32
|
+
throw new Error('invalid tmux pane info');
|
|
33
|
+
}
|
|
34
|
+
return values;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function decodeControlData(data) {
|
|
38
|
+
const bytes = [];
|
|
39
|
+
for (let i = 0; i < data.length;) {
|
|
40
|
+
if (data[i] === 0x5c && data[i + 1] === 0x5c) {
|
|
41
|
+
bytes.push(0x5c);
|
|
42
|
+
i += 2;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (data[i] === 0x5c && i + 3 < data.length) {
|
|
46
|
+
const a = data[i + 1];
|
|
47
|
+
const b = data[i + 2];
|
|
48
|
+
const c = data[i + 3];
|
|
49
|
+
if (a >= 0x30 && a <= 0x37 && b >= 0x30 && b <= 0x37 && c >= 0x30 && c <= 0x37) {
|
|
50
|
+
bytes.push(((a - 0x30) << 6) | ((b - 0x30) << 3) | (c - 0x30));
|
|
51
|
+
i += 4;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
bytes.push(data[i]);
|
|
56
|
+
i += 1;
|
|
57
|
+
}
|
|
58
|
+
return Buffer.from(bytes);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class PaneControlStream {
|
|
62
|
+
constructor({ ws, pane, session, spawnControl = spawn }) {
|
|
63
|
+
this.ws = ws;
|
|
64
|
+
this.pane = pane;
|
|
65
|
+
this.buffer = Buffer.alloc(0);
|
|
66
|
+
this.waiters = [];
|
|
67
|
+
this.response = null;
|
|
68
|
+
this.phase = 'attach';
|
|
69
|
+
this.wantLive = true;
|
|
70
|
+
this.pendingOutput = [];
|
|
71
|
+
this.pendingOutputBytes = 0;
|
|
72
|
+
this.resyncing = null;
|
|
73
|
+
this.attached = new Promise((resolve, reject) => {
|
|
74
|
+
this.resolveAttached = resolve;
|
|
75
|
+
this.rejectAttached = reject;
|
|
76
|
+
});
|
|
77
|
+
this.startTimer = setTimeout(
|
|
78
|
+
() => this.rejectAttached(new Error('tmux control mode attach timed out')),
|
|
79
|
+
START_TIMEOUT_MS,
|
|
80
|
+
);
|
|
81
|
+
this.child = spawnControl('tmux', ['-C', 'attach-session', '-t', session], {
|
|
82
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
83
|
+
});
|
|
84
|
+
this.child.stdout.on('data', (chunk) => this.onChunk(chunk));
|
|
85
|
+
this.child.stderr.on('data', (chunk) => { this.lastError = chunk.toString('utf8'); });
|
|
86
|
+
this.child.on('error', (error) => this.fail(error));
|
|
87
|
+
this.child.on('exit', (code) => {
|
|
88
|
+
if (this.phase !== 'closed') this.fail(new Error(this.lastError || `tmux control mode exited (${code})`));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
onChunk(chunk) {
|
|
93
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
94
|
+
for (;;) {
|
|
95
|
+
const newline = this.buffer.indexOf(0x0a);
|
|
96
|
+
if (newline < 0) break;
|
|
97
|
+
const line = this.buffer.subarray(0, newline);
|
|
98
|
+
this.buffer = this.buffer.subarray(newline + 1);
|
|
99
|
+
this.onLine(line.at(-1) === 0x0d ? line.subarray(0, -1) : line);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onLine(line) {
|
|
104
|
+
if (line.subarray(0, 8).toString('ascii') === '%output ') {
|
|
105
|
+
const split = line.indexOf(0x20, 8);
|
|
106
|
+
if (split < 0 || line.subarray(8, split).toString('ascii') !== this.pane) return;
|
|
107
|
+
const output = decodeControlData(line.subarray(split + 1));
|
|
108
|
+
if (this.phase === 'buffer') {
|
|
109
|
+
if (this.pendingOutputBytes + output.length > MAX_BUFFERED_BYTES) {
|
|
110
|
+
this.wantLive = false;
|
|
111
|
+
this.phase = 'paused';
|
|
112
|
+
this.pendingOutput = [];
|
|
113
|
+
this.pendingOutputBytes = 0;
|
|
114
|
+
if (this.ws.readyState < 2) this.ws.close(1013, 'stream fell behind');
|
|
115
|
+
} else {
|
|
116
|
+
this.pendingOutput.push(output);
|
|
117
|
+
this.pendingOutputBytes += output.length;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else if (this.phase === 'live') this.sendOutput(output);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (line.subarray(0, 7).toString('ascii') === '%begin ') {
|
|
124
|
+
this.response = { lines: [], waiter: this.waiters.shift() ?? null };
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const end = line.subarray(0, 5).toString('ascii') === '%end ';
|
|
128
|
+
const error = line.subarray(0, 7).toString('ascii') === '%error ';
|
|
129
|
+
if (end || error) {
|
|
130
|
+
const response = this.response;
|
|
131
|
+
this.response = null;
|
|
132
|
+
if (response?.waiter) {
|
|
133
|
+
if (error) {
|
|
134
|
+
response.waiter.reject(new Error(Buffer.concat(response.lines).toString('utf8')));
|
|
135
|
+
} else {
|
|
136
|
+
try {
|
|
137
|
+
// Run the boundary callback synchronously, before onChunk can consume any notification
|
|
138
|
+
// following this %end in the same stdout chunk. Resync uses this to publish ready before
|
|
139
|
+
// later %output, so an older cursor snapshot can never overwrite newer streamed movement.
|
|
140
|
+
response.waiter.onEnd?.(response.lines);
|
|
141
|
+
response.waiter.resolve(response.lines);
|
|
142
|
+
} catch (callbackError) {
|
|
143
|
+
response.waiter.reject(callbackError);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (this.response) {
|
|
150
|
+
this.response.lines.push(Buffer.from(line));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (line.subarray(0, 17).toString('ascii') === '%session-changed ') {
|
|
154
|
+
clearTimeout(this.startTimer);
|
|
155
|
+
this.resolveAttached();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
request(command, onEnd) {
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
if (this.phase === 'closed') {
|
|
162
|
+
reject(new Error('tmux control stream closed'));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.waiters.push({ resolve, reject, onEnd });
|
|
166
|
+
this.child.stdin.write(`${command}\n`);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
sendOutput(output) {
|
|
171
|
+
if (this.ws.readyState !== 1) return;
|
|
172
|
+
if (this.ws.bufferedAmount > MAX_BUFFERED_BYTES) {
|
|
173
|
+
this.ws.close(1013, 'stream fell behind');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
this.ws.send(output, { binary: true });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
sendJson(message) {
|
|
180
|
+
if (this.ws.readyState !== 1) return false;
|
|
181
|
+
if (this.ws.bufferedAmount > MAX_BUFFERED_BYTES) {
|
|
182
|
+
this.ws.close(1013, 'stream fell behind');
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
this.ws.send(JSON.stringify(message));
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async start() {
|
|
190
|
+
await this.attached;
|
|
191
|
+
await this.resync();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
pause() {
|
|
195
|
+
if (this.phase !== 'closed') {
|
|
196
|
+
this.wantLive = false;
|
|
197
|
+
this.phase = 'paused';
|
|
198
|
+
this.pendingOutput = [];
|
|
199
|
+
this.pendingOutputBytes = 0;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
resync() {
|
|
204
|
+
this.wantLive = true;
|
|
205
|
+
if (this.resyncing) return this.resyncing;
|
|
206
|
+
this.resyncing = this.runResync().finally(() => { this.resyncing = null; });
|
|
207
|
+
return this.resyncing;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async runResync() {
|
|
211
|
+
await this.attached;
|
|
212
|
+
this.phase = 'capture';
|
|
213
|
+
this.pendingOutput = [];
|
|
214
|
+
this.pendingOutputBytes = 0;
|
|
215
|
+
const captureLines = await this.request(
|
|
216
|
+
`capture-pane -p -e -N -S -${INITIAL_HISTORY_LINES} -t ${this.pane}`,
|
|
217
|
+
() => { this.phase = this.wantLive ? 'buffer' : 'paused'; },
|
|
218
|
+
);
|
|
219
|
+
const preliminaryInfo = await this.request(
|
|
220
|
+
`display-message -p -t ${this.pane} ${PANE_INFO}`,
|
|
221
|
+
);
|
|
222
|
+
const [, height, , , , alternateOn] = parsePaneInfo(preliminaryInfo);
|
|
223
|
+
if (!this.wantLive || this.phase === 'closed') {
|
|
224
|
+
this.pendingOutput = [];
|
|
225
|
+
this.pendingOutputBytes = 0;
|
|
226
|
+
if (this.phase !== 'closed') this.phase = 'paused';
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const sourceLines = alternateOn === 1 ? captureLines.slice(-height) : captureLines;
|
|
230
|
+
const captured = Buffer.concat(sourceLines.flatMap((line) => [line, Buffer.from('\n')]))
|
|
231
|
+
.toString('utf8');
|
|
232
|
+
const restored = await restoreCaptureBackgrounds(captured, height, async (row) => {
|
|
233
|
+
const exact = await this.request(
|
|
234
|
+
`capture-pane -p -e -N -S ${row} -E ${row} -t ${this.pane}`,
|
|
235
|
+
);
|
|
236
|
+
return `${Buffer.concat(exact).toString('utf8')}\n`;
|
|
237
|
+
});
|
|
238
|
+
const restoredLines = restored.ansi.endsWith('\n')
|
|
239
|
+
? restored.ansi.slice(0, -1).split('\n').map((line) => Buffer.from(line))
|
|
240
|
+
: restored.ansi.split('\n').map((line) => Buffer.from(line));
|
|
241
|
+
// Re-read size/cursor after the targeted row checks. Output produced while those checks ran is
|
|
242
|
+
// buffered; the synchronous onEnd handoff below publishes seed → buffered output → fresh cursor
|
|
243
|
+
// before any later %output from the same control chunk can overtake it.
|
|
244
|
+
await this.request(
|
|
245
|
+
`display-message -p -t ${this.pane} ${PANE_INFO}`,
|
|
246
|
+
(infoLines) => this.finishResync(restoredLines, infoLines),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
finishResync(captureLines, infoLines) {
|
|
251
|
+
const [width, height, cursorX, cursorY, cursorFlag, alternateOn, mouseAny, mouseSgr] =
|
|
252
|
+
parsePaneInfo(infoLines);
|
|
253
|
+
if (!this.wantLive || this.phase === 'closed') {
|
|
254
|
+
this.pendingOutput = [];
|
|
255
|
+
this.pendingOutputBytes = 0;
|
|
256
|
+
if (this.phase !== 'closed') this.phase = 'paused';
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// Alternate-screen apps have no history of their own. tmux's -S capture may prepend the main
|
|
260
|
+
// screen's scrollback, so keep only the alternate screen's real grid there.
|
|
261
|
+
const visibleCapture = alternateOn === 1 ? captureLines.slice(-height) : captureLines;
|
|
262
|
+
const historyLines = Math.max(0, visibleCapture.length - height);
|
|
263
|
+
const ansi = Buffer.concat(visibleCapture.flatMap((line) => [line, Buffer.from('\n')])).toString('utf8');
|
|
264
|
+
if (!this.sendJson({
|
|
265
|
+
type: 'seed',
|
|
266
|
+
ansi,
|
|
267
|
+
width,
|
|
268
|
+
height,
|
|
269
|
+
historyLines,
|
|
270
|
+
alt: alternateOn === 1,
|
|
271
|
+
mouseAware: mouseAny === 1,
|
|
272
|
+
mouseSgr: mouseSgr === 1,
|
|
273
|
+
})) return;
|
|
274
|
+
for (const output of this.pendingOutput) this.sendOutput(output);
|
|
275
|
+
if (!this.sendJson({
|
|
276
|
+
type: 'ready',
|
|
277
|
+
cur: { row: height - 1 - cursorY, col: cursorX, vis: cursorFlag === 1 },
|
|
278
|
+
})) return;
|
|
279
|
+
this.pendingOutput = [];
|
|
280
|
+
this.pendingOutputBytes = 0;
|
|
281
|
+
this.phase = this.wantLive ? 'live' : 'paused';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
fail(error) {
|
|
285
|
+
clearTimeout(this.startTimer);
|
|
286
|
+
this.rejectAttached(error);
|
|
287
|
+
this.response?.waiter?.reject(error);
|
|
288
|
+
this.response = null;
|
|
289
|
+
for (const waiter of this.waiters.splice(0)) waiter.reject(error);
|
|
290
|
+
if (this.ws.readyState < 2) this.ws.close(1011, 'tmux stream failed');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
close() {
|
|
294
|
+
clearTimeout(this.startTimer);
|
|
295
|
+
this.phase = 'closed';
|
|
296
|
+
this.wantLive = false;
|
|
297
|
+
const error = new Error('tmux control stream closed');
|
|
298
|
+
this.response?.waiter?.reject(error);
|
|
299
|
+
this.response = null;
|
|
300
|
+
for (const waiter of this.waiters.splice(0)) waiter.reject(error);
|
|
301
|
+
this.pendingOutput = [];
|
|
302
|
+
this.pendingOutputBytes = 0;
|
|
303
|
+
try { this.child.kill(); } catch { /* already gone */ }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function createTerminalStream({ token, commands, spawnControl } = {}) {
|
|
308
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_CLIENT_MESSAGE_BYTES });
|
|
309
|
+
const streams = new Set();
|
|
310
|
+
const heartbeat = setInterval(() => {
|
|
311
|
+
for (const ws of wss.clients) {
|
|
312
|
+
if (ws.readyState !== 1) continue;
|
|
313
|
+
if (ws.isAlive === false) {
|
|
314
|
+
ws.terminate();
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
ws.isAlive = false;
|
|
318
|
+
ws.ping();
|
|
319
|
+
}
|
|
320
|
+
}, HEARTBEAT_MS);
|
|
321
|
+
heartbeat.unref?.();
|
|
322
|
+
|
|
323
|
+
wss.on('connection', (ws) => {
|
|
324
|
+
ws.isAlive = true;
|
|
325
|
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
326
|
+
const cancelSubscribeDeadline = startSubscribeDeadline(ws);
|
|
327
|
+
let authenticating = false;
|
|
328
|
+
let stream = null;
|
|
329
|
+
ws.on('message', async (raw, binary) => {
|
|
330
|
+
if (binary) return;
|
|
331
|
+
let message;
|
|
332
|
+
try { message = JSON.parse(raw.toString()); } catch { ws.close(1003, 'bad message'); return; }
|
|
333
|
+
if (stream) {
|
|
334
|
+
if (echoTerminalProbe(ws, message)) return;
|
|
335
|
+
if (message.type === 'pause') stream.pause();
|
|
336
|
+
else if (message.type === 'resync') {
|
|
337
|
+
try { await stream.resync(); } catch {
|
|
338
|
+
if (ws.readyState < 2) ws.close(1011, 'stream resync failed');
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (authenticating) return;
|
|
344
|
+
if (message.type !== 'subscribe'
|
|
345
|
+
|| !tokenEquals(message.token ?? '', token)
|
|
346
|
+
|| !isPaneId(message.pane)) {
|
|
347
|
+
ws.close(4001, 'unauthorized');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
authenticating = true;
|
|
351
|
+
cancelSubscribeDeadline();
|
|
352
|
+
try {
|
|
353
|
+
const session = await commands.paneSession(message.pane);
|
|
354
|
+
if (ws.readyState !== 1) return;
|
|
355
|
+
stream = new PaneControlStream({ ws, pane: message.pane, session, spawnControl });
|
|
356
|
+
streams.add(stream);
|
|
357
|
+
await stream.start();
|
|
358
|
+
} catch {
|
|
359
|
+
if (ws.readyState < 2) ws.close(1011, 'stream setup failed');
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
ws.on('close', () => {
|
|
363
|
+
cancelSubscribeDeadline();
|
|
364
|
+
if (stream) {
|
|
365
|
+
stream.close();
|
|
366
|
+
streams.delete(stream);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const onUpgrade = (req, socket, head) => {
|
|
372
|
+
let pathname;
|
|
373
|
+
try { pathname = new URL(req.url, 'http://handmux.local').pathname; } catch { return false; }
|
|
374
|
+
if (pathname !== '/api/terminal-stream') return false;
|
|
375
|
+
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
|
|
376
|
+
return true;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const close = () => {
|
|
380
|
+
clearInterval(heartbeat);
|
|
381
|
+
for (const stream of streams) stream.close();
|
|
382
|
+
streams.clear();
|
|
383
|
+
for (const ws of wss.clients) ws.close(1001, 'server shutting down');
|
|
384
|
+
wss.close();
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
return { onUpgrade, close };
|
|
388
|
+
}
|
package/src/tmux/commands.js
CHANGED
|
@@ -94,6 +94,16 @@ export async function capturePane(paneId, linesBack) {
|
|
|
94
94
|
return runTmux(['capture-pane', '-p', '-e', '-N', '-S', String(-Math.abs(linesBack)), '-t', paneId]);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// A single row capture re-emits that row's real starting attributes. The combined capture above
|
|
98
|
+
// intentionally compresses SGR state across newlines, which makes an inherited background ambiguous.
|
|
99
|
+
export async function capturePaneRow(paneId, row) {
|
|
100
|
+
return runTmux([
|
|
101
|
+
'capture-pane', '-p', '-e', '-N',
|
|
102
|
+
'-S', String(row), '-E', String(row),
|
|
103
|
+
'-t', paneId,
|
|
104
|
+
]);
|
|
105
|
+
}
|
|
106
|
+
|
|
97
107
|
// Plain visible-screen capture — NO SGR escapes (unlike capturePane's `-e`), so the text parses cleanly.
|
|
98
108
|
// Used to scrape a pending prompt/menu off the screen (see pendingPrompt.js).
|
|
99
109
|
export async function capturePlain(paneId) {
|
|
@@ -153,6 +163,13 @@ export async function paneLocation(paneId) {
|
|
|
153
163
|
return { session, window: windowId, windowName };
|
|
154
164
|
}
|
|
155
165
|
|
|
166
|
+
export async function paneSession(paneId) {
|
|
167
|
+
const out = await runTmux(['display-message', '-p', '-t', paneId, '#{session_id}']);
|
|
168
|
+
const id = out.trim();
|
|
169
|
+
if (!isSessionId(id)) throw new Error('pane session not found');
|
|
170
|
+
return id;
|
|
171
|
+
}
|
|
172
|
+
|
|
156
173
|
// Exit tmux copy/scroll mode if the pane is currently in it. Called before any user input so
|
|
157
174
|
// text and keys reach the shell instead of being swallowed by tmux's mode key-bindings.
|
|
158
175
|
export async function exitCopyModeIfActive(paneId) {
|
package/src/trimCapture.js
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
// bottom. We apply this BEFORE hashing/sending, so the change-hash, the transferred body, and the
|
|
9
9
|
// client's render + at-bottom logic all key off the same trimmed capture.
|
|
10
10
|
//
|
|
11
|
-
// A row counts as blank only if it has no glyph AND no SGR escape
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// A row counts as blank only if it has no glyph AND no SGR escape. captureBackground normalizes
|
|
12
|
+
// ambiguous blank rows before this step, so genuinely shaded padding carries its explicit SGR while
|
|
13
|
+
// a default-background blank may be trimmed normally.
|
|
14
14
|
export const MAX_TRAILING_BLANK = 3;
|
|
15
15
|
|
|
16
16
|
const isBlank = (row) => row === '' || (/^[ \t]*$/.test(row) && !row.includes('\x1b'));
|