mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
Merge master (PR #112 terminal pane-buffer rework) into v1-readiness-hardening
Conflict in src/web/public/app.js selectSession: combined #112's _clearTerminalLoadState cleanup on stale select with #113's {success,data} envelope unwrap of the terminal fetch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+384
-6
@@ -164,6 +164,280 @@ export function parsePaneList(output: string): Map<string, number> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a target pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`.
|
||||
* Prefers the active pane and falls back to the first valid pane.
|
||||
*/
|
||||
export function resolveTmuxPaneTarget(muxName: string, paneTarget?: string): string | null {
|
||||
if (!isValidMuxName(muxName)) {
|
||||
return null;
|
||||
}
|
||||
if (paneTarget === undefined || paneTarget === 'active') {
|
||||
return muxName;
|
||||
}
|
||||
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
|
||||
return null;
|
||||
}
|
||||
return `${muxName}.${paneTarget}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the active pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`
|
||||
* output (lines like `%0:1`). Returns the pane id whose active flag is 1.
|
||||
*/
|
||||
export function resolveActivePaneTarget(output: string): string | null {
|
||||
for (const line of output.split('\n')) {
|
||||
const sep = line.indexOf(':');
|
||||
if (sep === -1) continue;
|
||||
const paneId = line.slice(0, sep).trim();
|
||||
const active = line.slice(sep + 1).trim();
|
||||
if (paneId && active === '1') return paneId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type GraphemeSegmenter = {
|
||||
segment(input: string): Iterable<{ segment: string }>;
|
||||
};
|
||||
|
||||
const GRAPHEME_SEGMENTER: GraphemeSegmenter | null = (() => {
|
||||
try {
|
||||
const Segmenter = (
|
||||
Intl as typeof Intl & {
|
||||
Segmenter?: new (locale?: string, options?: { granularity: 'grapheme' }) => GraphemeSegmenter;
|
||||
}
|
||||
).Segmenter;
|
||||
return Segmenter ? new Segmenter(undefined, { granularity: 'grapheme' }) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
function findEscapeEnd(text: string, start: number): number {
|
||||
const type = text[start + 1];
|
||||
if (type === '[') {
|
||||
for (let i = start + 2; i < text.length; i++) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code >= 0x40 && code <= 0x7e) return i;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
if (type === ']') {
|
||||
for (let i = start + 2; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 0x07) return i;
|
||||
if (text[i] === '\x1b' && text[i + 1] === '\\') return i + 1;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
if (type === 'P' || type === '^' || type === '_' || type === 'X') {
|
||||
for (let i = start + 2; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 0x07) return i;
|
||||
if (text[i] === '\x1b' && text[i + 1] === '\\') return i + 1;
|
||||
}
|
||||
return text.length - 1;
|
||||
}
|
||||
|
||||
return Math.min(start + 1, text.length - 1);
|
||||
}
|
||||
|
||||
function sanitizePaneLineStyles(line: string): string {
|
||||
let result = '';
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] !== '\x1b') {
|
||||
result += line[i];
|
||||
continue;
|
||||
}
|
||||
|
||||
const end = findEscapeEnd(line, i);
|
||||
const sequence = line.slice(i, end + 1);
|
||||
if (isSgrSequence(sequence)) {
|
||||
result += sequence;
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isSgrSequence(sequence: string): boolean {
|
||||
return (
|
||||
sequence.length >= 3 &&
|
||||
sequence.charCodeAt(0) === 27 &&
|
||||
sequence[1] === '[' &&
|
||||
sequence.endsWith('m') &&
|
||||
/^[0-9;:]*$/.test(sequence.slice(2, -1))
|
||||
);
|
||||
}
|
||||
|
||||
function isZeroWidthCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
codePoint === 0x00ad ||
|
||||
codePoint === 0x034f ||
|
||||
codePoint === 0x061c ||
|
||||
codePoint === 0x115f ||
|
||||
codePoint === 0x1160 ||
|
||||
codePoint === 0x17b4 ||
|
||||
codePoint === 0x17b5 ||
|
||||
codePoint === 0x180e ||
|
||||
codePoint === 0x200b ||
|
||||
codePoint === 0x200c ||
|
||||
codePoint === 0x200d ||
|
||||
codePoint === 0x2060 ||
|
||||
codePoint === 0xfeff ||
|
||||
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
||||
(codePoint >= 0x0483 && codePoint <= 0x0489) ||
|
||||
(codePoint >= 0x0591 && codePoint <= 0x05bd) ||
|
||||
codePoint === 0x05bf ||
|
||||
(codePoint >= 0x05c1 && codePoint <= 0x05c2) ||
|
||||
(codePoint >= 0x05c4 && codePoint <= 0x05c5) ||
|
||||
codePoint === 0x05c7 ||
|
||||
(codePoint >= 0x0610 && codePoint <= 0x061a) ||
|
||||
(codePoint >= 0x064b && codePoint <= 0x065f) ||
|
||||
codePoint === 0x0670 ||
|
||||
(codePoint >= 0x06d6 && codePoint <= 0x06dc) ||
|
||||
(codePoint >= 0x06df && codePoint <= 0x06e4) ||
|
||||
(codePoint >= 0x06e7 && codePoint <= 0x06e8) ||
|
||||
(codePoint >= 0x06ea && codePoint <= 0x06ed) ||
|
||||
codePoint === 0x0711 ||
|
||||
(codePoint >= 0x0730 && codePoint <= 0x074a) ||
|
||||
(codePoint >= 0x07a6 && codePoint <= 0x07b0) ||
|
||||
(codePoint >= 0x07eb && codePoint <= 0x07f3) ||
|
||||
(codePoint >= 0x0816 && codePoint <= 0x0819) ||
|
||||
(codePoint >= 0x081b && codePoint <= 0x0823) ||
|
||||
(codePoint >= 0x0825 && codePoint <= 0x0827) ||
|
||||
(codePoint >= 0x0829 && codePoint <= 0x082d) ||
|
||||
(codePoint >= 0x0859 && codePoint <= 0x085b) ||
|
||||
(codePoint >= 0x08d3 && codePoint <= 0x08e1) ||
|
||||
(codePoint >= 0x08e3 && codePoint <= 0x0902) ||
|
||||
(codePoint >= 0x093a && codePoint <= 0x093c) ||
|
||||
codePoint === 0x094d ||
|
||||
(codePoint >= 0x0951 && codePoint <= 0x0957) ||
|
||||
(codePoint >= 0x0962 && codePoint <= 0x0963) ||
|
||||
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
|
||||
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
|
||||
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
|
||||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
||||
(codePoint >= 0xfe20 && codePoint <= 0xfe2f) ||
|
||||
(codePoint >= 0xe0100 && codePoint <= 0xe01ef)
|
||||
);
|
||||
}
|
||||
|
||||
function isWideCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
codePoint >= 0x1100 &&
|
||||
(codePoint <= 0x115f ||
|
||||
codePoint === 0x2329 ||
|
||||
codePoint === 0x232a ||
|
||||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
||||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
||||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
||||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
||||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
||||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
||||
(codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
|
||||
(codePoint >= 0x20000 && codePoint <= 0x3fffd))
|
||||
);
|
||||
}
|
||||
|
||||
function nextGrapheme(text: string, start: number): { value: string; nextIndex: number } {
|
||||
if (GRAPHEME_SEGMENTER) {
|
||||
const iterator = GRAPHEME_SEGMENTER.segment(text.slice(start))[Symbol.iterator]();
|
||||
const next = iterator.next();
|
||||
if (!next.done && next.value.segment) {
|
||||
return { value: next.value.segment, nextIndex: start + next.value.segment.length };
|
||||
}
|
||||
}
|
||||
|
||||
const first = text.codePointAt(start);
|
||||
if (first === undefined) return { value: '', nextIndex: start + 1 };
|
||||
let value = String.fromCodePoint(first);
|
||||
let nextIndex = start + value.length;
|
||||
while (nextIndex < text.length) {
|
||||
const codePoint = text.codePointAt(nextIndex);
|
||||
if (codePoint === undefined || !isZeroWidthCodePoint(codePoint)) break;
|
||||
const mark = String.fromCodePoint(codePoint);
|
||||
value += mark;
|
||||
nextIndex += mark.length;
|
||||
}
|
||||
return { value, nextIndex };
|
||||
}
|
||||
|
||||
function terminalCellWidth(grapheme: string): number {
|
||||
let hasVisible = false;
|
||||
let hasWide = false;
|
||||
for (let i = 0; i < grapheme.length; i++) {
|
||||
const codePoint = grapheme.codePointAt(i);
|
||||
if (codePoint === undefined) continue;
|
||||
if (codePoint > 0xffff) i++;
|
||||
if (isZeroWidthCodePoint(codePoint) || codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) {
|
||||
continue;
|
||||
}
|
||||
hasVisible = true;
|
||||
if (isWideCodePoint(codePoint)) hasWide = true;
|
||||
}
|
||||
if (!hasVisible) return 0;
|
||||
return hasWide ? 2 : 1;
|
||||
}
|
||||
|
||||
function truncatePaneLineByVisibleColumns(line: string, maxColumns: number): string {
|
||||
let result = '';
|
||||
let visibleColumns = 0;
|
||||
let sawSgr = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] === '\x1b') {
|
||||
const end = findEscapeEnd(line, i);
|
||||
const sequence = line.slice(i, end + 1);
|
||||
if (isSgrSequence(sequence)) {
|
||||
result += sequence;
|
||||
sawSgr = true;
|
||||
}
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
const grapheme = nextGrapheme(line, i);
|
||||
const width = terminalCellWidth(grapheme.value);
|
||||
if (width === 0) {
|
||||
result += grapheme.value;
|
||||
} else if (visibleColumns + width <= maxColumns) {
|
||||
result += grapheme.value;
|
||||
visibleColumns += width;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
i = grapheme.nextIndex - 1;
|
||||
if (visibleColumns >= maxColumns) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (sawSgr) {
|
||||
result += '\x1b[0m';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatPaneSnapshot(
|
||||
lines: string[],
|
||||
geometry: { cols: number; rows: number; cursorX: number; cursorY: number }
|
||||
): string {
|
||||
const cols = Math.max(1, geometry.cols);
|
||||
const paintCols = Math.max(1, cols - 1);
|
||||
const rows = Math.max(1, geometry.rows);
|
||||
const parts: string[] = [];
|
||||
for (let row = 0; row < Math.min(lines.length, rows); row++) {
|
||||
const safeLine = truncatePaneLineByVisibleColumns(sanitizePaneLineStyles(lines[row]), paintCols);
|
||||
parts.push(`\x1b[${row + 1};1H${safeLine}`);
|
||||
}
|
||||
const cursorX = Math.max(0, Math.min(cols - 1, geometry.cursorX));
|
||||
const cursorY = Math.max(0, Math.min(rows - 1, geometry.cursorY));
|
||||
parts.push(`\x1b[${cursorY + 1};${cursorX + 1}H`);
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/** Characters unsafe in paths — shell metacharacters, quotes, and control chars */
|
||||
const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/;
|
||||
|
||||
@@ -175,6 +449,10 @@ function isValidMuxName(name: string): boolean {
|
||||
return SAFE_MUX_NAME_PATTERN.test(name) || LEGACY_MUX_NAME_PATTERN.test(name);
|
||||
}
|
||||
|
||||
function isValidTerminalDimension(value: number): boolean {
|
||||
return Number.isSafeInteger(value) && value > 0 && value <= 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a path contains only safe characters.
|
||||
* Prevents command injection via malformed paths.
|
||||
@@ -682,14 +960,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
// (Production uses systemd which has a clean env, but dev/test may be nested.)
|
||||
const cleanEnv = { ...process.env };
|
||||
delete cleanEnv.TMUX;
|
||||
// Start the tmux server from a stable local cwd so FUSE/rclone workspace
|
||||
// blips do not poison tmux's long-lived getcwd state.
|
||||
// Create the session on the dedicated socket (${this.tmux()} = `tmux -L <socket>`),
|
||||
// launched in TMUX_LAUNCH_CWD (/tmp) rather than the real workingDir: a FUSE/rclone
|
||||
// mount that isn't ready yet makes `getcwd` fail and breaks the spawn (see #110). The
|
||||
// pane cd's into workingDir below via respawn-pane.
|
||||
execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, {
|
||||
cwd: TMUX_LAUNCH_CWD,
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
env: cleanEnv,
|
||||
});
|
||||
this.resizeWindow(muxName, 120, 40);
|
||||
|
||||
// Set remain-on-exit now that the server is running — must be before respawn-pane
|
||||
try {
|
||||
@@ -1689,8 +1970,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the current buffer of a specific pane.
|
||||
* Returns the pane content with ANSI escape codes preserved.
|
||||
* Capture the current visible text and SGR styles of a specific pane.
|
||||
*
|
||||
* `capture-pane -e` is sanitized by `formatPaneSnapshot`: SGR color/style
|
||||
* codes are preserved, while cursor/erase/scroll-region controls are stripped
|
||||
* before rows are repainted at absolute positions in browser xterm.
|
||||
*/
|
||||
capturePaneBuffer(muxName: string, paneTarget: string): string | null {
|
||||
if (IS_TEST_MODE) return '';
|
||||
@@ -1706,16 +1990,67 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||||
|
||||
try {
|
||||
return execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
|
||||
const buffer = execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
}).replace(/\n+$/g, '');
|
||||
try {
|
||||
const cursor = execSync(
|
||||
`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}
|
||||
).trim();
|
||||
const [cursorX, cursorY, cols, rows] = cursor.split(/\s+/).map((value) => parseInt(value, 10));
|
||||
if (
|
||||
Number.isFinite(cursorX) &&
|
||||
Number.isFinite(cursorY) &&
|
||||
Number.isFinite(cols) &&
|
||||
Number.isFinite(rows) &&
|
||||
cursorX >= 0 &&
|
||||
cursorY >= 0 &&
|
||||
cols > 0 &&
|
||||
rows > 0
|
||||
) {
|
||||
return formatPaneSnapshot(buffer.split('\n'), { cols, rows, cursorX, cursorY });
|
||||
}
|
||||
} catch (cursorErr) {
|
||||
console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
|
||||
}
|
||||
return buffer;
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to capture pane buffer:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the active pane for a tmux session.
|
||||
*
|
||||
* Pane ids are not stable across respawns or restores, so callers should not
|
||||
* assume the first pane remains `%0`.
|
||||
*/
|
||||
captureActivePaneBuffer(muxName: string): string | null {
|
||||
if (IS_TEST_MODE) return '';
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in captureActivePaneBuffer:', muxName);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execSync(`${this.tmux()} list-panes -t ${shellescape(muxName)} -F '#{pane_id}:#{pane_active}'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
const target = resolveActivePaneTarget(output);
|
||||
return target ? this.capturePaneBuffer(muxName, target) : null;
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to resolve active pane for capture:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start piping pane output to a file using tmux pipe-pane.
|
||||
* Only pipes output direction (-O) to avoid echoing input.
|
||||
@@ -1785,6 +2120,49 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
|
||||
}
|
||||
|
||||
setManualWindowSize(muxName: string): boolean {
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in setManualWindowSize:', muxName);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`${this.tmux()} set-window-option -t ${shellescape(muxName)} window-size manual`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[TmuxManager] Failed to set manual window size:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
resizeWindow(muxName: string, cols: number, rows: number): boolean {
|
||||
if (!isValidMuxName(muxName)) {
|
||||
console.error('[TmuxManager] Invalid session name in resizeWindow:', muxName);
|
||||
return false;
|
||||
}
|
||||
if (!isValidTerminalDimension(cols) || !isValidTerminalDimension(rows)) {
|
||||
console.error('[TmuxManager] Invalid resize dimensions:', { cols, rows });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fire-and-forget: this runs on the interactive resize path (WS {t:'z'} and
|
||||
// HTTP /resize), so use a non-blocking exec — a slow/hung tmux must not stall
|
||||
// the Fastify event loop while other sessions' input/SSE are served. The sole
|
||||
// caller (Session.resize) ignores the result, and under `window-size manual`
|
||||
// the subsequent ptyProcess.resize is subordinate to this authoritative size.
|
||||
exec(
|
||||
`${this.tmux()} resize-window -t ${shellescape(muxName)} -x ${cols} -y ${rows}`,
|
||||
{ timeout: EXEC_TIMEOUT_MS },
|
||||
(err) => {
|
||||
if (err) console.error('[TmuxManager] Failed to resize tmux window:', err);
|
||||
}
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return TmuxManager.isTmuxAvailable();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user