handmux 0.18.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.
@@ -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
+ }
@@ -45,10 +45,10 @@ export async function listSessions() {
45
45
 
46
46
  export async function listWindows(sessionId) {
47
47
  const out = await runTmux(['list-windows', '-t', sessionId, '-F', tmuxFormat([
48
- 'window_id', 'window_name', 'window_active', 'window_panes', 'window_width', 'window_height',
48
+ 'window_id', 'window_name', 'window_active', 'window_panes', 'window_width', 'window_height', 'pane_id',
49
49
  ])]);
50
- return parseTmuxRows(out, 6, 'window').map(([id, name, active, panes, width, height]) => {
51
- return { id, name, active: active === '1', panes: Number(panes), width: Number(width), height: Number(height) };
50
+ return parseTmuxRows(out, 7, 'window').map(([id, name, active, panes, width, height, activePaneId]) => {
51
+ return { id, name, active: active === '1', panes: Number(panes), width: Number(width), height: Number(height), activePaneId };
52
52
  });
53
53
  }
54
54
 
@@ -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) {
@@ -164,6 +181,11 @@ export async function sendText(paneId, text) {
164
181
  await runTmux(['send-keys', '-t', paneId, '-l', '--', text]);
165
182
  }
166
183
 
184
+ export async function sendHexInput(paneId, hex) {
185
+ const bytes = hex.match(/../g) || [];
186
+ await runTmux(['send-keys', '-t', paneId, '-H', ...bytes]);
187
+ }
188
+
167
189
  export async function sendEnter(paneId) {
168
190
  await runTmux(['send-keys', '-t', paneId, 'Enter']);
169
191
  }
@@ -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'));