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/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-CIlbBw_U.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-erQcVC-q.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-erQcVC-q.css"></noscript>
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
+ }
@@ -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}; (add client_max_body_size 60m; proxy_read_timeout 90s;)',
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.',
@@ -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}; (加上 client_max_body_size 60m; proxy_read_timeout 90s;)',
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 网关。',
@@ -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 raw = await commands.capturePane(req.query.pane, altScreen ? 0 : lines);
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({ ansi, width, height, hash, cur, alt: altScreen, mouseAware });
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
- // WebSocket/HMR for dynamic previews: route raw Upgrade by Host to the right loopback port.
155
- server.on('upgrade', preview.onUpgrade);
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 = () => { shutdown().catch(() => {}); };
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
+ }
@@ -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) {
@@ -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 — a shaded/full-width padding row
12
- // (e.g. Claude Code's grey message bar) carries \x1b and is NEVER trimmed here; that stays for the
13
- // client's trimTrailingShadow (and such boxes are never the very bottom of the pane anyway).
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'));