handoff
This commit is contained in:
@@ -1,25 +1,15 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { IPty } from 'node-pty';
|
||||
import { getSessionInfo } from '../db.js';
|
||||
import { sanitizeId, tmuxSessionName, tmuxWindowName, ensureWindow } from '../pty/manager.js';
|
||||
import {
|
||||
sanitizeId,
|
||||
tmuxSessionName,
|
||||
ensureSession,
|
||||
capturePane,
|
||||
} from '../pty/manager.js';
|
||||
import { attachPty } from '../pty/pty.js';
|
||||
import { getUser } from '../auth.js';
|
||||
|
||||
// Registry of currently-attached PTYs keyed by paneId. Used by the resize REST
|
||||
// route to find the active node-pty handle so it can call pty.resize(cols, rows).
|
||||
const active = new Map<string, IPty>();
|
||||
|
||||
export function resizePane(paneId: string, cols: number, rows: number): boolean {
|
||||
const handle = active.get(paneId);
|
||||
if (!handle) return false;
|
||||
try {
|
||||
handle.resize(cols, rows);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
|
||||
app.get<{
|
||||
Params: { sid: string; pid: string };
|
||||
@@ -44,24 +34,33 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionName = tmuxSessionName(sid);
|
||||
const windowName = tmuxWindowName(pid);
|
||||
const sessionName = tmuxSessionName(pid);
|
||||
const cols = parseInt(req.query.cols ?? '', 10) || 80;
|
||||
const rows = parseInt(req.query.rows ?? '', 10) || 24;
|
||||
|
||||
// Idempotent — /start typically created the session already, but cover
|
||||
// the race where the client opens the WS before /start's response lands
|
||||
// (or skips /start entirely). With per-pane tmux sessions there's no
|
||||
// cross-pane interference, so creating-on-attach is safe.
|
||||
try {
|
||||
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log);
|
||||
await ensureSession(
|
||||
tmuxConfPath,
|
||||
sessionName,
|
||||
session.project_path,
|
||||
req.log,
|
||||
cols,
|
||||
rows,
|
||||
);
|
||||
} catch (err) {
|
||||
req.log.error({ err }, 'ensureWindow failed in WS handler');
|
||||
req.log.error({ err }, 'ensureSession failed in WS handler');
|
||||
socket.close(1011, 'tmux_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const cols = parseInt(req.query.cols ?? '', 10) || 80;
|
||||
const rows = parseInt(req.query.rows ?? '', 10) || 24;
|
||||
|
||||
let handle: IPty;
|
||||
try {
|
||||
handle = attachPty({
|
||||
sessionName,
|
||||
windowName,
|
||||
projectRoot: session.project_path,
|
||||
cols,
|
||||
rows,
|
||||
@@ -73,9 +72,31 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
|
||||
return;
|
||||
}
|
||||
|
||||
active.set(pid, handle);
|
||||
// Frame contract (boolab pattern):
|
||||
// server → client text: JSON control — `init` on connect, `exit` on PTY death
|
||||
// server → client binary: raw PTY bytes (first frame after init = capture-pane replay)
|
||||
// client → server binary: user keystrokes
|
||||
// client → server text: JSON control — `{type:"resize", cols, rows}`
|
||||
//
|
||||
// The init frame lets the client term.clear() before paint so a remount
|
||||
// doesn't show stale buffer content. The capture-pane replay then
|
||||
// paints the current tmux pane state into the fresh xterm.
|
||||
try {
|
||||
socket.send(JSON.stringify({ type: 'init', cols, rows, tmux_session: sessionName }));
|
||||
} catch (err) {
|
||||
req.log.warn({ err }, 'init frame send failed');
|
||||
}
|
||||
|
||||
const onData = (data: string) => {
|
||||
try {
|
||||
const capture = await capturePane(tmuxConfPath, sessionName);
|
||||
if (capture.length > 0) {
|
||||
socket.send(Buffer.from(capture, 'utf8'), { binary: true });
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.warn({ err }, 'capture-pane failed');
|
||||
}
|
||||
|
||||
const onData = (data: string): void => {
|
||||
if (socket.readyState !== socket.OPEN) return;
|
||||
try {
|
||||
socket.send(Buffer.from(data, 'utf8'), { binary: true });
|
||||
@@ -85,13 +106,32 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
|
||||
};
|
||||
handle.onData(onData);
|
||||
|
||||
socket.on('message', (data: Buffer | string) => {
|
||||
try {
|
||||
if (typeof data === 'string') {
|
||||
handle.write(data);
|
||||
} else {
|
||||
handle.write(data.toString('utf8'));
|
||||
socket.on('message', (rawData: Buffer | string, isBinary?: boolean) => {
|
||||
// ws v8 emits Buffer + isBinary boolean; older versions emit string
|
||||
// for text frames. Either way: text path tries JSON parse for the
|
||||
// resize control; binary path writes to the PTY.
|
||||
const isTextFrame = typeof rawData === 'string' || isBinary === false;
|
||||
if (isTextFrame) {
|
||||
const text = typeof rawData === 'string' ? rawData : rawData.toString('utf8');
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { type?: string; cols?: number; rows?: number };
|
||||
if (parsed.type === 'resize') {
|
||||
const newCols = Math.max(1, Math.min(2000, Math.floor(Number(parsed.cols) || 80)));
|
||||
const newRows = Math.max(1, Math.min(2000, Math.floor(Number(parsed.rows) || 24)));
|
||||
req.log.info({ pid, cols: newCols, rows: newRows }, 'resize');
|
||||
try {
|
||||
handle.resize(newCols, newRows);
|
||||
} catch {
|
||||
/* ignore — invalid winsize bubble */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* malformed text frame — drop silently */
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handle.write((rawData as Buffer).toString('utf8'));
|
||||
} catch (err) {
|
||||
req.log.warn({ err }, 'pty write failed');
|
||||
}
|
||||
@@ -110,13 +150,13 @@ export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (active.get(pid) === handle) active.delete(pid);
|
||||
});
|
||||
|
||||
// WS close kills the local PTY (the tmux client). The tmux server and
|
||||
// window persist so a refresh resumes with full scrollback.
|
||||
// WS close kills the tmux client (the local PTY) but the tmux server +
|
||||
// session persist — so a refresh resumes with full scrollback. Permanent
|
||||
// teardown happens via the /kill route called from the frontend when the
|
||||
// user closes the pane.
|
||||
socket.on('close', () => {
|
||||
if (active.get(pid) === handle) active.delete(pid);
|
||||
try {
|
||||
handle.kill();
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user