mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09:42 +02:00
Introduce src/config/terminal-history.ts: one place for terminal scrollback, tmux history-limit, and PTY buffer byte caps, each overridable via env var or the settings object and bounds-clamped via resolveTerminalHistoryConfig(). Defaults match the prior hardcoded values, so this is behavior-neutral. Wires the resolver through buffer-limits, tmux-manager (incl. a setHistoryLimit so a settings change applies live), session, server, system-routes, session-routes, schemas, and the config port. Adds 4 optional settings keys (terminalScrollback Lines, tmuxHistoryLimit, terminalBufferMaxBytes, terminalBufferTrimBytes) with bounds + a trim<=max cross-check.
2412 lines
82 KiB
TypeScript
2412 lines
82 KiB
TypeScript
/**
|
||
* @fileoverview tmux session manager for persistent Claude sessions.
|
||
*
|
||
* This module provides the TmuxManager class which creates and manages
|
||
* tmux sessions that wrap Claude CLI processes. tmux provides:
|
||
*
|
||
* - **Persistence**: Sessions survive server restarts and disconnects
|
||
* - **Ghost recovery**: Orphaned sessions are discovered and reattached on startup
|
||
* - **Resource tracking**: Memory, CPU, and child process stats per session
|
||
* - **Reliable input**: `send-keys -l` sends literal text in a single command
|
||
* - **Teammate support**: Immutable pane IDs enable targeting individual teammates
|
||
*
|
||
* tmux sessions are named `codeman-{sessionId}` and stored in ~/.codeman/mux-sessions.json.
|
||
*
|
||
* Key features:
|
||
* - `send-keys 'text' Enter` sends literal text in a single command
|
||
* - `list-sessions -F` provides structured queries
|
||
* - `display-message -p '#{pane_pid}'` for reliable PID discovery
|
||
* - Single server architecture
|
||
*
|
||
* @module tmux-manager
|
||
*/
|
||
|
||
import { EventEmitter } from 'node:events';
|
||
import { execSync, exec } from 'node:child_process';
|
||
import { promisify } from 'node:util';
|
||
|
||
const execAsync = promisify(exec);
|
||
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
|
||
import { writeFile, rename } from 'node:fs/promises';
|
||
import { dirname } from 'node:path';
|
||
import { dataPath, DEFAULT_TMUX_SOCKET } from './config/instance.js';
|
||
import {
|
||
ProcessStats,
|
||
PersistedRespawnConfig,
|
||
getErrorMessage,
|
||
DEFAULT_NICE_CONFIG,
|
||
type PaneInfo,
|
||
type ClaudeMode,
|
||
type SessionMode,
|
||
type OpenCodeConfig,
|
||
type CodexConfig,
|
||
type EffortLevel,
|
||
type GeminiConfig,
|
||
} from './types.js';
|
||
import { buildEffortCliArgs } from './session-cli-builder.js';
|
||
import {
|
||
wrapWithNice,
|
||
SAFE_PATH_PATTERN,
|
||
findClaudeDir,
|
||
resolveOpenCodeDir,
|
||
resolveCodexDir,
|
||
resolveGeminiDir,
|
||
} from './utils/index.js';
|
||
import type {
|
||
TerminalMultiplexer,
|
||
MuxSession,
|
||
MuxSessionWithStats,
|
||
CreateSessionOptions,
|
||
RespawnPaneOptions,
|
||
} from './mux-interface.js';
|
||
|
||
// ============================================================================
|
||
// Timing Constants
|
||
// ============================================================================
|
||
|
||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
||
|
||
/** Delay after tmux session creation — enough for detached tmux to be queryable */
|
||
const TMUX_CREATION_WAIT_MS = 100;
|
||
|
||
/** Max retries for getPanePid — tmux server cold-start (e.g. macOS) may need extra time */
|
||
const GET_PID_MAX_RETRIES = 5;
|
||
const GET_PID_RETRY_MS = 200;
|
||
|
||
/** Delay after tmux kill command (200ms) */
|
||
const TMUX_KILL_WAIT_MS = 200;
|
||
|
||
/** Delay for graceful shutdown (100ms) */
|
||
const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
|
||
|
||
/** Default stats collection interval (2 seconds) */
|
||
const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||
|
||
/** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */
|
||
const TMUX_LAUNCH_CWD = '/tmp';
|
||
|
||
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
|
||
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
|
||
|
||
/**
|
||
* SAFETY: Test mode detection.
|
||
* When running under vitest (VITEST env var is set automatically),
|
||
* ALL tmux shell commands are disabled. TmuxManager becomes a pure
|
||
* in-memory mock that cannot interact with real tmux sessions.
|
||
*
|
||
* This makes it PHYSICALLY IMPOSSIBLE for any test to:
|
||
* - Kill a tmux session
|
||
* - Create a tmux session
|
||
* - Send input to a tmux session
|
||
* - Discover/reconcile real tmux sessions
|
||
* - Read/write ~/.codeman/mux-sessions.json
|
||
*/
|
||
const IS_TEST_MODE = !!process.env.VITEST;
|
||
|
||
/** Path to persisted mux session metadata */
|
||
const MUX_SESSIONS_FILE = dataPath('mux-sessions.json');
|
||
|
||
/** Regex to validate tmux session names (only allow safe characters) */
|
||
const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/;
|
||
|
||
/** Legacy pattern for pre-rename sessions (claudeman- prefix) */
|
||
const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
|
||
|
||
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
|
||
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
|
||
|
||
/** Dedicated tmux socket for new Codeman-owned sessions (instance-scoped:
|
||
* `codeman` for prod, `codeman-beta` on the beta branch). */
|
||
const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET;
|
||
|
||
/** Regex to validate tmux socket names passed to `tmux -L`. */
|
||
const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||
|
||
/**
|
||
* Separator used in `tmux list-panes -F` output between session name and pid.
|
||
*
|
||
* Must NOT be a backslash-escape (e.g. `\t`, `\n`): under non-tty execution
|
||
* contexts (launchd on macOS, systemd without TTYPath) tmux can emit such
|
||
* escapes as the literal two characters `\` + letter rather than the control
|
||
* byte, breaking the parser and causing every tracked session to be classified
|
||
* as dead — which wipes state.json on restart. '|' is passed through verbatim
|
||
* in every environment and is rejected by tmux's own session-name validation,
|
||
* so it cannot appear inside `#{session_name}` and cause a false split.
|
||
*/
|
||
const PANE_LIST_SEP = '|';
|
||
|
||
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
|
||
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
|
||
|
||
/**
|
||
* 构建 pane 启动前的 nofile 修复命令。
|
||
*
|
||
* macOS launchd/tmux 组合有时会让 pane 继承 256 的 soft nofile;
|
||
* 新版 Claude Code 会在这种环境下直接退出。这里避免使用 $变量
|
||
* 或命令替换,因为 fullCmd 目前经由双引号 bash -c 传递,外层
|
||
* shell 会提前展开它们。
|
||
*/
|
||
export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT): string {
|
||
const safeLimit = Number.isSafeInteger(targetLimit) && targetLimit > 0 ? targetLimit : CLAUDE_CODE_NOFILE_LIMIT;
|
||
return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
|
||
}
|
||
|
||
/**
|
||
* Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
|
||
* into a Map of session-name → pane pid. Exported for unit testing.
|
||
*
|
||
* - Skips empty lines and lines without the separator.
|
||
* - Skips entries with a non-numeric pid or empty name.
|
||
*/
|
||
export function parsePaneList(output: string): Map<string, number> {
|
||
const result = new Map<string, number>();
|
||
for (const line of output.split('\n')) {
|
||
if (!line) continue;
|
||
const sep = line.indexOf(PANE_LIST_SEP);
|
||
if (sep === -1) continue;
|
||
const name = line.slice(0, sep);
|
||
const pid = parseInt(line.slice(sep + 1), 10);
|
||
if (name && !Number.isNaN(pid)) {
|
||
result.set(name, pid);
|
||
}
|
||
}
|
||
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);
|
||
// Paint the full pane width. Earlier this dropped the rightmost column
|
||
// (cols - 1) out of caution about last-column autowrap, but every painted
|
||
// row is immediately followed by an absolute cursor-position CSI (the next
|
||
// row's `\x1b[r;1H`, or the final cursor move), which cancels xterm's
|
||
// pending-wrap state before any further glyph — so the last column is safe.
|
||
const paintCols = cols;
|
||
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]/;
|
||
|
||
/**
|
||
* Validates that a session name contains only safe characters.
|
||
* Prevents command injection via malformed session IDs.
|
||
*/
|
||
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.
|
||
*/
|
||
function isValidPath(path: string): boolean {
|
||
if (UNSAFE_PATH_CHARS.test(path)) {
|
||
return false;
|
||
}
|
||
if (path.includes('..')) {
|
||
return false;
|
||
}
|
||
return SAFE_PATH_PATTERN.test(path);
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Single-socket architecture: ALL Codeman sessions live on one dedicated tmux
|
||
// socket (`tmux -L codeman`), isolated from the user's default tmux server.
|
||
// The socket name is a process-wide constant (env-overridable for test/multi-
|
||
// instance isolation) — it is never stored per-session, so it cannot drift.
|
||
// ===========================================================================
|
||
|
||
/**
|
||
* Resolve the process-wide Codeman tmux socket name. Always returns a valid
|
||
* name: `CODEMAN_TMUX_SOCKET` env override if safe, else the built-in default.
|
||
*/
|
||
function resolveConfiguredTmuxSocket(): string {
|
||
const raw = process.env.CODEMAN_TMUX_SOCKET ?? DEFAULT_CODEMAN_TMUX_SOCKET;
|
||
if (!SAFE_TMUX_SOCKET_PATTERN.test(raw)) {
|
||
console.warn(`[TmuxManager] Ignoring invalid CODEMAN_TMUX_SOCKET: ${JSON.stringify(raw)}`);
|
||
return DEFAULT_CODEMAN_TMUX_SOCKET;
|
||
}
|
||
return raw;
|
||
}
|
||
|
||
/** Build the `tmux -L <socket>` command prefix. Socket name is shell-escaped. */
|
||
function tmuxCommand(socket: string): string {
|
||
return `tmux -L ${shellescape(socket)}`;
|
||
}
|
||
|
||
/**
|
||
* Build Claude CLI permission flags for the tmux command string.
|
||
* Validates allowedTools to prevent command injection.
|
||
*/
|
||
function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: string): string {
|
||
const mode = claudeMode || 'dangerously-skip-permissions';
|
||
switch (mode) {
|
||
case 'dangerously-skip-permissions':
|
||
return ' --dangerously-skip-permissions';
|
||
case 'allowedTools':
|
||
if (allowedTools) {
|
||
// Sanitize: allow tool names with patterns like Bash(git:*), space/comma-separated
|
||
// Block shell metacharacters: ; & | $ ` \ { } < > ' " newlines
|
||
const hasDangerousChars = /[;&|$`\\{}<>'"[\]\n\r]/.test(allowedTools);
|
||
if (!hasDangerousChars) {
|
||
return ` --allowedTools "${allowedTools}"`;
|
||
}
|
||
}
|
||
// Fall back to normal mode if tools are invalid or missing
|
||
return '';
|
||
case 'normal':
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build the opencode CLI command with appropriate flags.
|
||
*/
|
||
function buildOpenCodeCommand(config?: OpenCodeConfig): string {
|
||
const parts = ['opencode'];
|
||
|
||
// Model selection — allow provider/model format (alphanumeric, dots, hyphens, slashes)
|
||
if (config?.model) {
|
||
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
||
if (safeModel) parts.push('--model', safeModel);
|
||
}
|
||
|
||
// Continue existing session
|
||
if (config?.continueSession) {
|
||
const safeId = /^[a-zA-Z0-9_-]+$/.test(config.continueSession) ? config.continueSession : undefined;
|
||
if (safeId) parts.push('--session', safeId);
|
||
if (safeId && config.forkSession) parts.push('--fork');
|
||
}
|
||
|
||
return parts.join(' ');
|
||
}
|
||
|
||
/**
|
||
* Build the codex CLI command with appropriate flags.
|
||
*
|
||
* Codeman launches Codex's native TUI and handles replay/scrollback by
|
||
* stripping destructive terminal sequences before xterm.js sees them.
|
||
*/
|
||
export function buildCodexCommand(config?: CodexConfig): string {
|
||
const parts = ['codex'];
|
||
|
||
if (config?.dangerouslyBypassApprovals) {
|
||
parts.push('--dangerously-bypass-approvals-and-sandbox');
|
||
}
|
||
|
||
if (config?.model) {
|
||
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
||
if (safeModel) parts.push('--model', safeModel);
|
||
}
|
||
|
||
if (config?.resumeSessionId) {
|
||
const safeId = /^[a-zA-Z0-9_-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
|
||
if (safeId) parts.push('resume', safeId);
|
||
}
|
||
|
||
return parts.join(' ');
|
||
}
|
||
|
||
/**
|
||
* Build the Gemini CLI command with appropriate flags.
|
||
*
|
||
* `--skip-trust` avoids a first-run workspace trust prompt inside Codeman.
|
||
* Approval mode defaults to `yolo` for parity with Codeman's Claude default
|
||
* of `--dangerously-skip-permissions`; users can override it later through
|
||
* Gemini config once Codeman exposes richer Gemini settings.
|
||
*/
|
||
function buildGeminiCommand(config?: GeminiConfig): string {
|
||
const parts = ['gemini', '--skip-trust'];
|
||
|
||
const approvalMode = config?.approvalMode || 'yolo';
|
||
if (['default', 'auto_edit', 'yolo', 'plan'].includes(approvalMode)) {
|
||
parts.push('--approval-mode', approvalMode);
|
||
}
|
||
|
||
if (config?.model) {
|
||
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
||
if (safeModel) parts.push('--model', safeModel);
|
||
}
|
||
|
||
if (config?.resumeSession) {
|
||
const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeSession) ? config.resumeSession : undefined;
|
||
if (safeId) parts.push('--resume', safeId);
|
||
}
|
||
|
||
return parts.join(' ');
|
||
}
|
||
|
||
/**
|
||
* Build the spawn command for any session mode.
|
||
* Shared by createSession() and respawnPane() to avoid duplication.
|
||
*/
|
||
/**
|
||
* Build the shell fragment carrying the effort level as a SOFT default
|
||
* (see buildEffortCliArgs — `--effort <level>` for regular levels incl. max,
|
||
* `--settings '{"ultracode":true}'` for ultracode; deliberately not the
|
||
* CLAUDE_CODE_EFFORT_LEVEL env var, which hard-locks /effort switching).
|
||
*
|
||
* Injection-safe: effort is validated against the EFFORT_LEVELS allowlist inside
|
||
* buildEffortCliArgs, so the single-quoted values contain no user-controlled characters.
|
||
*/
|
||
function buildEffortSettingsFlag(effort?: EffortLevel): string {
|
||
const [flag, value] = buildEffortCliArgs(effort);
|
||
return flag && value ? ` ${flag} '${value}'` : '';
|
||
}
|
||
|
||
function buildSpawnCommand(options: {
|
||
mode: SessionMode;
|
||
sessionId: string;
|
||
model?: string;
|
||
claudeMode?: ClaudeMode;
|
||
allowedTools?: string;
|
||
openCodeConfig?: OpenCodeConfig;
|
||
codexConfig?: CodexConfig;
|
||
geminiConfig?: GeminiConfig;
|
||
resumeSessionId?: string;
|
||
effort?: EffortLevel;
|
||
}): string {
|
||
if (options.mode === 'claude') {
|
||
// Validate model to prevent command injection
|
||
const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
|
||
const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
|
||
const effortFlag = buildEffortSettingsFlag(options.effort);
|
||
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
|
||
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
|
||
// fall back to a new session with --session-id so the pane doesn't die.
|
||
const safeResumeId =
|
||
options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined;
|
||
const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools);
|
||
if (safeResumeId) {
|
||
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}`;
|
||
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
|
||
return `${resumeCmd} || ${fallbackCmd}`;
|
||
}
|
||
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
|
||
}
|
||
if (options.mode === 'opencode') {
|
||
return buildOpenCodeCommand(options.openCodeConfig);
|
||
}
|
||
if (options.mode === 'codex') {
|
||
return buildCodexCommand(options.codexConfig);
|
||
}
|
||
if (options.mode === 'gemini') {
|
||
return buildGeminiCommand(options.geminiConfig);
|
||
}
|
||
return '$SHELL';
|
||
}
|
||
|
||
/**
|
||
* Set sensitive environment variables on a tmux session via setenv.
|
||
* These are inherited by panes but not visible in ps output or tmux history.
|
||
*/
|
||
function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void {
|
||
const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
|
||
for (const key of sensitiveVars) {
|
||
const val = process.env[key];
|
||
if (val) {
|
||
// Shell-escape: wrap in single quotes, escape any inner single quotes
|
||
const escaped = val.replace(/'/g, "'\\''");
|
||
try {
|
||
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
|
||
encoding: 'utf8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
});
|
||
} catch {
|
||
/* Non-critical — key may not be needed */
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set sensitive environment variables for Codex on a tmux session via setenv.
|
||
* Codex (OpenAI CLI) needs OPENAI_API_KEY; we also forward CODEX_* keys.
|
||
*/
|
||
function setCodexEnvVars(tmuxCmd: string, muxName: string): void {
|
||
const sensitiveVars = ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_HOME'];
|
||
for (const key of sensitiveVars) {
|
||
const val = process.env[key];
|
||
if (val) {
|
||
const escaped = val.replace(/'/g, "'\\''");
|
||
try {
|
||
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
|
||
encoding: 'utf8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
});
|
||
} catch {
|
||
/* Non-critical — key may not be needed */
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set sensitive environment variables for Gemini on a tmux session via setenv.
|
||
* Gemini Pro/Ultra users usually authenticate via cached Google login; these
|
||
* variables cover API-key and Vertex AI paths without putting secrets in ps.
|
||
*/
|
||
function setGeminiEnvVars(tmuxCmd: string, muxName: string): void {
|
||
const sensitiveVars = [
|
||
'GEMINI_API_KEY',
|
||
'GEMINI_MODEL',
|
||
'GOOGLE_API_KEY',
|
||
'GOOGLE_CLOUD_PROJECT',
|
||
'GOOGLE_CLOUD_LOCATION',
|
||
'GOOGLE_APPLICATION_CREDENTIALS',
|
||
'GOOGLE_GENAI_USE_VERTEXAI',
|
||
];
|
||
for (const key of sensitiveVars) {
|
||
const val = process.env[key];
|
||
if (val) {
|
||
const escaped = val.replace(/'/g, "'\\''");
|
||
try {
|
||
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
|
||
encoding: 'utf8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
});
|
||
} catch {
|
||
/* Non-critical — key may not be needed */
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
|
||
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
|
||
*/
|
||
function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: OpenCodeConfig): void {
|
||
if (!config) return;
|
||
|
||
let jsonContent: string | undefined;
|
||
|
||
if (config.autoAllowTools) {
|
||
const permConfig: Record<string, unknown> = { permission: { '*': 'allow' } };
|
||
if (config.configContent) {
|
||
try {
|
||
const existing = JSON.parse(config.configContent) as Record<string, unknown>;
|
||
Object.assign(permConfig, existing);
|
||
permConfig.permission = { '*': 'allow' };
|
||
} catch {
|
||
/* invalid JSON, use default permConfig */
|
||
}
|
||
}
|
||
jsonContent = JSON.stringify(permConfig);
|
||
} else if (config.configContent) {
|
||
// Validate JSON to prevent garbage config
|
||
try {
|
||
JSON.parse(config.configContent);
|
||
jsonContent = config.configContent;
|
||
} catch {
|
||
console.error('[TmuxManager] Invalid JSON in openCodeConfig.configContent, skipping');
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (jsonContent) {
|
||
const escaped = jsonContent.replace(/'/g, "'\\''");
|
||
try {
|
||
execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
|
||
encoding: 'utf8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
});
|
||
} catch {
|
||
/* Non-critical */
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Manages tmux sessions that wrap Claude CLI or shell processes.
|
||
*
|
||
* Implements the TerminalMultiplexer interface.
|
||
*
|
||
* @example
|
||
* ```typescript
|
||
* const manager = new TmuxManager();
|
||
*
|
||
* // Create a tmux session for Claude
|
||
* const session = await manager.createSession({ sessionId, workingDir: '/project', mode: 'claude' });
|
||
*
|
||
* // Send input (single command, no delay!)
|
||
* manager.sendInput(sessionId, '/clear\r');
|
||
*
|
||
* // Kill when done
|
||
* await manager.killSession(sessionId);
|
||
* ```
|
||
*/
|
||
export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||
readonly backend = 'tmux' as const;
|
||
private sessions: Map<string, MuxSession> = new Map();
|
||
private readonly tmuxSocket = resolveConfiguredTmuxSocket();
|
||
private statsInterval: NodeJS.Timeout | null = null;
|
||
private mouseSyncInterval: NodeJS.Timeout | null = null;
|
||
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
|
||
private lastPaneCount: Map<string, number> = new Map();
|
||
|
||
private trueColorConfigured = false;
|
||
|
||
constructor() {
|
||
super();
|
||
this.setMaxListeners(50);
|
||
if (!IS_TEST_MODE) {
|
||
this.loadSessions();
|
||
}
|
||
}
|
||
|
||
/** The dedicated tmux socket all Codeman sessions live on (see {@link TerminalMultiplexer.muxSocket}). */
|
||
get muxSocket(): string {
|
||
return this.tmuxSocket;
|
||
}
|
||
|
||
private tmux(): string {
|
||
return tmuxCommand(this.tmuxSocket);
|
||
}
|
||
|
||
// Load saved sessions from disk (NEVER called in test mode)
|
||
private loadSessions(): void {
|
||
if (IS_TEST_MODE) return;
|
||
|
||
try {
|
||
if (existsSync(MUX_SESSIONS_FILE)) {
|
||
const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8');
|
||
const data = JSON.parse(content);
|
||
if (Array.isArray(data)) {
|
||
// Dedup by muxName: one live tmux session must map to exactly one
|
||
// tracked entry. A per-session socket-tag mismatch could historically
|
||
// let the same session be tracked twice — once under its real UUID and
|
||
// once under a "restored-<id>" placeholder — surfacing as duplicate tabs.
|
||
// Single-socket unification removed that failure mode; this pass stays
|
||
// to clean any stale duplicates already on disk. Keep the real (UUID)
|
||
// entry and drop placeholder twins.
|
||
let dropped = 0;
|
||
const keptByMuxName = new Map<string, string>(); // muxName -> kept sessionId
|
||
for (const session of data) {
|
||
// Strip the obsolete per-session tmuxSocket tag (now a process-wide
|
||
// constant). Left in place it would be written back by saveSessions()
|
||
// and linger on disk as a zombie field forever.
|
||
delete (session as { tmuxSocket?: unknown }).tmuxSocket;
|
||
const muxName: string | undefined = session.muxName;
|
||
const priorId = muxName ? keptByMuxName.get(muxName) : undefined;
|
||
if (priorId) {
|
||
const incomingIsPlaceholder = String(session.sessionId).startsWith('restored-');
|
||
const priorIsPlaceholder = priorId.startsWith('restored-');
|
||
// Drop the incoming unless it's the real twin of a placeholder we kept.
|
||
if (incomingIsPlaceholder || !priorIsPlaceholder) {
|
||
dropped++;
|
||
continue;
|
||
}
|
||
this.sessions.delete(priorId);
|
||
dropped++;
|
||
}
|
||
this.sessions.set(session.sessionId, session);
|
||
if (muxName) keptByMuxName.set(muxName, session.sessionId);
|
||
}
|
||
// Persist the cleaned list so the stale duplicates don't reload.
|
||
if (dropped > 0) {
|
||
console.log(`[TmuxManager] Dropped ${dropped} duplicate mux session record(s) on load`);
|
||
this.saveSessions();
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[TmuxManager] Failed to load sessions:', err);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save sessions to disk asynchronously. (NEVER writes in test mode)
|
||
* Uses atomic temp+rename to prevent corruption on crash.
|
||
*/
|
||
private saveSessions(): void {
|
||
if (IS_TEST_MODE) return;
|
||
|
||
try {
|
||
const dir = dirname(MUX_SESSIONS_FILE);
|
||
if (!existsSync(dir)) {
|
||
mkdirSync(dir, { recursive: true });
|
||
}
|
||
const data = Array.from(this.sessions.values());
|
||
const json = JSON.stringify(data, null, 2);
|
||
|
||
const tempPath = MUX_SESSIONS_FILE + '.tmp';
|
||
writeFile(tempPath, json, 'utf-8')
|
||
.then(() => rename(tempPath, MUX_SESSIONS_FILE))
|
||
.catch((err) => {
|
||
console.error('[TmuxManager] Failed to save sessions:', err);
|
||
});
|
||
} catch (err) {
|
||
console.error('[TmuxManager] Failed to save sessions:', err);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build the array of environment export commands shared by createSession() and respawnPane().
|
||
* Includes locale, mux markers, session identity, and API URL.
|
||
*
|
||
* User-supplied envOverrides are NOT inlined here — they go through applyEnvOverrides()
|
||
* via `tmux setenv` so secret values (e.g., OPENCODE_API_KEY) never appear in the bash
|
||
* command line (visible in `ps`). This also sidesteps shell-metachar injection via keys.
|
||
*/
|
||
private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] {
|
||
const exports = [
|
||
'export LANG=en_US.UTF-8',
|
||
'export LC_ALL=en_US.UTF-8',
|
||
mode === 'codex' || mode === 'gemini' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
|
||
...(mode === 'codex' || mode === 'gemini' ? ['unset NO_COLOR'] : []),
|
||
'export CODEMAN_MUX=1',
|
||
`export CODEMAN_SESSION_ID=${sessionId}`,
|
||
`export CODEMAN_MUX_NAME=${muxName}`,
|
||
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
|
||
// Path only (not the secret value): hook curl commands cat the file at
|
||
// execution time, so the COD-54 hook secret stays off the command line.
|
||
`export CODEMAN_HOOK_SECRET_FILE="${dataPath('hook-secret')}"`,
|
||
];
|
||
// Only unset CLAUDECODE for Claude sessions
|
||
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
|
||
return exports;
|
||
}
|
||
|
||
/**
|
||
* Apply user-supplied env overrides to a tmux session via `tmux setenv`.
|
||
* Values stay off the bash command line (not visible in `ps`), and are inherited
|
||
* by new panes — including `respawn-pane`. Persists at tmux-session level, so
|
||
* Codeman server restarts don't lose the setting as long as the tmux session lives.
|
||
*
|
||
* Key validation is strict (`/^[A-Z_][A-Z0-9_]*$/`) as defense-in-depth against
|
||
* shell-metachar injection even if upstream schema check is bypassed.
|
||
*/
|
||
private applyEnvOverrides(muxName: string, envOverrides?: Record<string, string>): void {
|
||
// Legacy cleanup: pre-0.7.2 set CLAUDE_CODE_EFFORT_LEVEL via setenv, which persists
|
||
// on the tmux session and hard-locks /effort switching in every respawned pane.
|
||
// Effort now flows as a `--settings` soft default (see buildEffortSettingsFlag),
|
||
// so unconditionally unset the stale var before applying current overrides.
|
||
try {
|
||
execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} -u CLAUDE_CODE_EFFORT_LEVEL`, {
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
});
|
||
} catch {
|
||
/* Non-critical — var may not exist */
|
||
}
|
||
if (!envOverrides) return;
|
||
const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/;
|
||
for (const [key, value] of Object.entries(envOverrides)) {
|
||
if (!value) continue; // Skip empty — nothing to set
|
||
if (!VALID_KEY.test(key)) {
|
||
console.warn(`[TmuxManager] Skipping invalid env override key: ${JSON.stringify(key)}`);
|
||
continue;
|
||
}
|
||
try {
|
||
execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
});
|
||
} catch (err) {
|
||
console.warn(`[TmuxManager] Failed to set env override ${key}:`, err);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve the CLI binary directory and return the PATH export prefix string.
|
||
* Returns '' if no override is needed (shell mode) or the binary dir is not found.
|
||
* In createSession(), a missing binary dir throws — the caller handles that separately.
|
||
*/
|
||
private buildPathExport(mode: SessionMode): { pathExport: string; dir: string | null } {
|
||
if (mode === 'claude') {
|
||
const dir = findClaudeDir();
|
||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||
}
|
||
if (mode === 'opencode') {
|
||
const dir = resolveOpenCodeDir();
|
||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||
}
|
||
if (mode === 'codex') {
|
||
const dir = resolveCodexDir();
|
||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||
}
|
||
if (mode === 'gemini') {
|
||
const dir = resolveGeminiDir();
|
||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||
}
|
||
return { pathExport: '', dir: null };
|
||
}
|
||
|
||
/**
|
||
* Configure OpenCode-specific environment on a tmux session.
|
||
* Sets sensitive API keys and config content via tmux setenv
|
||
* (not visible in ps output or tmux history, inherited by panes).
|
||
*/
|
||
private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void {
|
||
const tmuxCmd = this.tmux();
|
||
setOpenCodeEnvVars(tmuxCmd, muxName);
|
||
setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
|
||
}
|
||
|
||
/**
|
||
* Configure Codex-specific environment on a tmux session.
|
||
* Sets OPENAI_API_KEY (and related keys) via tmux setenv so secrets don't
|
||
* appear in the bash command line.
|
||
*/
|
||
private _configureCodex(muxName: string): void {
|
||
setCodexEnvVars(this.tmux(), muxName);
|
||
}
|
||
|
||
/**
|
||
* Configure Gemini-specific environment on a tmux session.
|
||
*/
|
||
private _configureGemini(muxName: string): void {
|
||
setGeminiEnvVars(this.tmux(), muxName);
|
||
}
|
||
|
||
/**
|
||
* Creates a new tmux session wrapping Claude CLI or a shell.
|
||
* In test mode: creates an in-memory session only (no real tmux session).
|
||
*/
|
||
async createSession(options: CreateSessionOptions): Promise<MuxSession> {
|
||
const {
|
||
sessionId,
|
||
workingDir,
|
||
mode,
|
||
name,
|
||
niceConfig,
|
||
model,
|
||
claudeMode,
|
||
allowedTools,
|
||
openCodeConfig,
|
||
codexConfig,
|
||
geminiConfig,
|
||
resumeSessionId,
|
||
envOverrides,
|
||
effort,
|
||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||
} = options;
|
||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||
|
||
if (!isValidMuxName(muxName)) {
|
||
throw new Error('Invalid session name: contains unsafe characters');
|
||
}
|
||
if (!isValidPath(workingDir)) {
|
||
throw new Error('Invalid working directory path: contains unsafe characters');
|
||
}
|
||
|
||
// TEST MODE: Create in-memory session only — no real tmux session
|
||
if (IS_TEST_MODE) {
|
||
const session: MuxSession = {
|
||
sessionId,
|
||
muxName,
|
||
pid: 99999,
|
||
createdAt: Date.now(),
|
||
workingDir,
|
||
mode,
|
||
attached: false,
|
||
name,
|
||
};
|
||
this.sessions.set(sessionId, session);
|
||
this.emit('sessionCreated', session);
|
||
return session;
|
||
}
|
||
|
||
// Resolve CLI binary directory based on mode
|
||
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
|
||
if (mode === 'claude' && !cliDir) {
|
||
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
|
||
}
|
||
if (mode === 'opencode' && !cliDir) {
|
||
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
||
}
|
||
if (mode === 'codex' && !cliDir) {
|
||
throw new Error('Codex CLI not found. Install with: npm install -g @openai/codex');
|
||
}
|
||
if (mode === 'gemini' && !cliDir) {
|
||
throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli');
|
||
}
|
||
|
||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||
|
||
const baseCmd = buildSpawnCommand({
|
||
mode,
|
||
sessionId,
|
||
model,
|
||
claudeMode,
|
||
allowedTools,
|
||
openCodeConfig,
|
||
codexConfig,
|
||
geminiConfig,
|
||
resumeSessionId,
|
||
effort,
|
||
});
|
||
|
||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||
const cmd = wrapWithNice(baseCmd, config);
|
||
|
||
try {
|
||
// Build the full command to run inside tmux
|
||
const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
||
|
||
// Create tmux session in three steps to handle cold-start (no server running)
|
||
// and avoid the race where the command exits before remain-on-exit is set:
|
||
// 1. Create session with default shell (starts tmux server, stays alive)
|
||
// 2. Set remain-on-exit (server now exists, session won't vanish on exit)
|
||
// 3. Replace shell with actual command via respawn-pane (no terminal echo)
|
||
// Unset $TMUX so nested sessions work when the dev server itself runs inside tmux.
|
||
// (Production uses systemd which has a clean env, but dev/test may be nested.)
|
||
const cleanEnv = { ...process.env };
|
||
delete cleanEnv.TMUX;
|
||
// 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 {
|
||
execSync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, {
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
stdio: 'ignore',
|
||
});
|
||
} catch {
|
||
/* Non-critical */
|
||
}
|
||
|
||
// For OpenCode: set sensitive env vars and config via tmux setenv
|
||
// (not visible in ps output or tmux history, inherited by panes)
|
||
if (mode === 'opencode') {
|
||
this._configureOpenCode(muxName, openCodeConfig);
|
||
} else if (mode === 'codex') {
|
||
this._configureCodex(muxName);
|
||
}
|
||
// For Gemini: set Gemini/Google auth env vars via tmux setenv
|
||
if (mode === 'gemini') {
|
||
this._configureGemini(muxName);
|
||
}
|
||
|
||
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
|
||
// so secret values stay off the bash command line. Must run before respawn-pane.
|
||
this.applyEnvOverrides(muxName, envOverrides);
|
||
|
||
// Replace the shell with the actual command (no echo in terminal). Keep
|
||
// pane launch in /tmp, then cd inside bash against the current mount table.
|
||
const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||
execSync(
|
||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||
{
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
stdio: 'ignore',
|
||
}
|
||
);
|
||
|
||
// Wait for tmux session to be queryable
|
||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||
|
||
// Non-critical tmux config — run in parallel to avoid blocking event loop.
|
||
// These configure UX niceties (no status bar, true color).
|
||
// Mouse mode is OFF by default so xterm.js handles text selection natively.
|
||
// It gets enabled dynamically when panes are split (agent teams).
|
||
const configPromises: Promise<void>[] = [
|
||
// Disable tmux status bar — Codeman's web UI provides session info
|
||
execAsync(`${this.tmux()} set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
|
||
.then(() => {})
|
||
.catch(() => {
|
||
/* Non-critical — session still works with status bar */
|
||
}),
|
||
// Override global remain-on-exit with session-level setting
|
||
execAsync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
|
||
.then(() => {})
|
||
.catch(() => {
|
||
/* Already set globally as fallback */
|
||
}),
|
||
// Raise tmux scrollback from its 2000-line default so re-attach preserves
|
||
// more context. Matches the xterm-side default in constants.js.
|
||
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit ${historyLimit}`, {
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
})
|
||
.then(() => {})
|
||
.catch(() => {
|
||
/* Non-critical — falls back to tmux default */
|
||
}),
|
||
];
|
||
|
||
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
|
||
if (!this.trueColorConfigured) {
|
||
configPromises.push(
|
||
execAsync(`${this.tmux()} set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
|
||
.then(() => {
|
||
this.trueColorConfigured = true;
|
||
})
|
||
.catch(() => {
|
||
/* Non-critical — colors limited to 256 */
|
||
})
|
||
);
|
||
}
|
||
|
||
// Fire-and-forget — these are non-critical UX niceties that don't need
|
||
// to complete before the session is usable. Errors are already swallowed.
|
||
void Promise.all(configPromises);
|
||
|
||
// Get the PID of the pane process (retry for tmux server cold-start)
|
||
let pid = this.getPanePid(muxName);
|
||
for (let i = 0; !pid && i < GET_PID_MAX_RETRIES; i++) {
|
||
await new Promise((resolve) => setTimeout(resolve, GET_PID_RETRY_MS));
|
||
pid = this.getPanePid(muxName);
|
||
}
|
||
if (!pid) {
|
||
throw new Error('Failed to get tmux pane PID');
|
||
}
|
||
|
||
const session: MuxSession = {
|
||
sessionId,
|
||
muxName,
|
||
pid,
|
||
createdAt: Date.now(),
|
||
workingDir,
|
||
mode,
|
||
attached: false,
|
||
name,
|
||
};
|
||
|
||
this.sessions.set(sessionId, session);
|
||
this.saveSessions();
|
||
this.emit('sessionCreated', session);
|
||
|
||
return session;
|
||
} catch (err) {
|
||
throw new Error(`Failed to create tmux session: ${getErrorMessage(err)}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get the PID of the process running in the tmux pane.
|
||
*/
|
||
private getPanePid(muxName: string): number | null {
|
||
if (IS_TEST_MODE) return 99999;
|
||
|
||
if (!isValidMuxName(muxName)) {
|
||
console.error('[TmuxManager] Invalid session name in getPanePid:', muxName);
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
}).trim();
|
||
const pid = parseInt(output, 10);
|
||
return Number.isNaN(pid) ? null : pid;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if a tmux session exists.
|
||
*/
|
||
muxSessionExists(muxName: string): boolean {
|
||
return this.sessionExists(muxName);
|
||
}
|
||
|
||
/**
|
||
* Check if the pane in a tmux session is dead (command exited but remain-on-exit keeps it).
|
||
* Returns true if the session exists but the pane's command has exited.
|
||
*/
|
||
isPaneDead(muxName: string): boolean {
|
||
if (IS_TEST_MODE) return false;
|
||
if (!isValidMuxName(muxName)) return false;
|
||
try {
|
||
const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_dead}'`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
}).trim();
|
||
return output === '1';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Respawn a dead pane in an existing tmux session.
|
||
* Uses `tmux respawn-pane -k` to restart the command in the same pane,
|
||
* preserving the session and its scrollback buffer.
|
||
*/
|
||
async respawnPane(options: RespawnPaneOptions): Promise<number | null> {
|
||
const {
|
||
sessionId,
|
||
workingDir,
|
||
mode,
|
||
niceConfig,
|
||
model,
|
||
claudeMode,
|
||
allowedTools,
|
||
openCodeConfig,
|
||
codexConfig,
|
||
geminiConfig,
|
||
resumeSessionId,
|
||
envOverrides,
|
||
effort,
|
||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||
} = options;
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) return null;
|
||
const muxName = session.muxName;
|
||
|
||
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
|
||
|
||
// Re-apply the configured tmux history-limit after respawn (kept in sync
|
||
// with the live setting via setHistoryLimit()).
|
||
if (!IS_TEST_MODE) {
|
||
await execAsync(`${this.tmux()} set-option -t ${shellescape(muxName)} history-limit ${historyLimit}`, {
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
}).catch(() => {
|
||
/* Non-critical — keeps existing tmux history-limit */
|
||
});
|
||
}
|
||
|
||
// Resolve CLI binary directory based on mode
|
||
const { pathExport } = this.buildPathExport(mode);
|
||
|
||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||
|
||
const baseCmd = buildSpawnCommand({
|
||
mode,
|
||
sessionId,
|
||
model,
|
||
claudeMode,
|
||
allowedTools,
|
||
openCodeConfig,
|
||
codexConfig,
|
||
geminiConfig,
|
||
resumeSessionId,
|
||
effort,
|
||
});
|
||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||
const cmd = wrapWithNice(baseCmd, config);
|
||
const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
||
|
||
try {
|
||
// For OpenCode: set sensitive env vars via tmux setenv before respawn
|
||
if (mode === 'opencode') {
|
||
this._configureOpenCode(muxName, openCodeConfig);
|
||
} else if (mode === 'codex') {
|
||
this._configureCodex(muxName);
|
||
}
|
||
// For Gemini: set Gemini/Google auth env vars via tmux setenv before respawn
|
||
if (mode === 'gemini') {
|
||
this._configureGemini(muxName);
|
||
}
|
||
|
||
// Re-apply user env overrides before respawn so the new shell inherits them.
|
||
this.applyEnvOverrides(muxName, envOverrides);
|
||
|
||
const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||
await execAsync(
|
||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
||
{
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
}
|
||
);
|
||
// Wait for the respawned process to start
|
||
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
|
||
const pid = this.getPanePid(muxName);
|
||
if (pid) session.pid = pid;
|
||
return pid;
|
||
} catch (err) {
|
||
console.error('[TmuxManager] Failed to respawn pane:', err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private sessionExists(muxName: string): boolean {
|
||
if (IS_TEST_MODE) return false;
|
||
if (!isValidMuxName(muxName)) return false;
|
||
|
||
try {
|
||
execSync(`${this.tmux()} has-session -t "${muxName}" 2>/dev/null`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
});
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Get all child process PIDs recursively
|
||
private getChildPids(pid: number): number[] {
|
||
const pids: number[] = [];
|
||
try {
|
||
const output = execSync(`pgrep -P ${pid}`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
}).trim();
|
||
if (output) {
|
||
for (const childPid of output
|
||
.split('\n')
|
||
.map((p) => parseInt(p, 10))
|
||
.filter((p) => !Number.isNaN(p))) {
|
||
pids.push(childPid);
|
||
pids.push(...this.getChildPids(childPid));
|
||
}
|
||
}
|
||
} catch {
|
||
// No children or command failed
|
||
}
|
||
return pids;
|
||
}
|
||
|
||
// Check if a process is still alive
|
||
private isProcessAlive(pid: number): boolean {
|
||
try {
|
||
process.kill(pid, 0);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Verify all PIDs are dead, with retry
|
||
private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise<boolean> {
|
||
const startTime = Date.now();
|
||
const checkInterval = 100;
|
||
|
||
while (Date.now() - startTime < maxWaitMs) {
|
||
const aliveCount = pids.filter((pid) => this.isProcessAlive(pid)).length;
|
||
if (aliveCount === 0) {
|
||
return true;
|
||
}
|
||
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
||
}
|
||
|
||
const stillAlive = pids.filter((pid) => this.isProcessAlive(pid));
|
||
if (stillAlive.length > 0) {
|
||
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
|
||
}
|
||
return stillAlive.length === 0;
|
||
}
|
||
|
||
/**
|
||
* Kill a tmux session and all its child processes.
|
||
* Uses a 4-strategy approach (children → process group → tmux kill → SIGKILL).
|
||
* In test mode: removes from memory only (no real kill).
|
||
*/
|
||
async killSession(sessionId: string): Promise<boolean> {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) {
|
||
return false;
|
||
}
|
||
|
||
// TEST MODE: Remove from memory only — NEVER touch real tmux sessions
|
||
if (IS_TEST_MODE) {
|
||
this.sessions.delete(sessionId);
|
||
this.emit('sessionKilled', { sessionId });
|
||
return true;
|
||
}
|
||
|
||
// SAFETY: Never kill the tmux session we're running inside of
|
||
const currentMuxName = process.env.CODEMAN_MUX_NAME;
|
||
if (currentMuxName && session.muxName === currentMuxName) {
|
||
console.error(`[TmuxManager] BLOCKED: Refusing to kill own tmux session: ${session.muxName}`);
|
||
return false;
|
||
}
|
||
|
||
// Get current PID (may have changed)
|
||
const currentPid = this.getPanePid(session.muxName) || session.pid;
|
||
|
||
console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`);
|
||
|
||
const allPids: number[] = [currentPid];
|
||
|
||
// Strategy 1: Kill all child processes recursively
|
||
let childPids = this.getChildPids(currentPid);
|
||
if (childPids.length > 0) {
|
||
console.log(`[TmuxManager] Found ${childPids.length} child processes to kill`);
|
||
allPids.push(...childPids);
|
||
|
||
for (const childPid of [...childPids].reverse()) {
|
||
if (this.isProcessAlive(childPid)) {
|
||
try {
|
||
process.kill(childPid, 'SIGTERM');
|
||
} catch {
|
||
// Process may already be dead
|
||
}
|
||
}
|
||
}
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
|
||
|
||
childPids = this.getChildPids(currentPid);
|
||
for (const childPid of childPids) {
|
||
if (this.isProcessAlive(childPid)) {
|
||
try {
|
||
process.kill(childPid, 'SIGKILL');
|
||
} catch {
|
||
// Process already terminated
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Strategy 2: Kill the entire process group
|
||
if (this.isProcessAlive(currentPid)) {
|
||
try {
|
||
process.kill(-currentPid, 'SIGTERM');
|
||
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
|
||
if (this.isProcessAlive(currentPid)) {
|
||
process.kill(-currentPid, 'SIGKILL');
|
||
}
|
||
} catch {
|
||
// Process group may not exist or already terminated
|
||
}
|
||
}
|
||
|
||
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell)
|
||
if (isValidMuxName(session.muxName)) {
|
||
try {
|
||
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
});
|
||
} catch {
|
||
// Session may already be dead
|
||
}
|
||
}
|
||
|
||
// Strategy 4: Direct kill by PID as final fallback
|
||
if (this.isProcessAlive(currentPid)) {
|
||
try {
|
||
process.kill(currentPid, 'SIGKILL');
|
||
} catch {
|
||
// Already dead
|
||
}
|
||
}
|
||
|
||
// Verify all processes are dead
|
||
const allDead = await this.verifyProcessesDead(allPids, 2000);
|
||
if (!allDead) {
|
||
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
|
||
}
|
||
|
||
this.lastPaneCount.delete(session.muxName);
|
||
this.sessions.delete(sessionId);
|
||
this.saveSessions();
|
||
this.emit('sessionKilled', { sessionId });
|
||
|
||
return true;
|
||
}
|
||
|
||
getSessions(): MuxSession[] {
|
||
return Array.from(this.sessions.values());
|
||
}
|
||
|
||
getSession(sessionId: string): MuxSession | undefined {
|
||
return this.sessions.get(sessionId);
|
||
}
|
||
|
||
updateSessionName(sessionId: string, name: string): boolean {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) {
|
||
return false;
|
||
}
|
||
session.name = name;
|
||
this.saveSessions();
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Reconcile tracked sessions with actual running tmux sessions.
|
||
*/
|
||
async reconcileSessions(): Promise<{ alive: string[]; dead: string[]; discovered: string[] }> {
|
||
// TEST MODE: Return all registered sessions as alive, never discover real ones
|
||
if (IS_TEST_MODE) {
|
||
return {
|
||
alive: Array.from(this.sessions.keys()),
|
||
dead: [],
|
||
discovered: [],
|
||
};
|
||
}
|
||
|
||
const alive: string[] = [];
|
||
const dead: string[] = [];
|
||
const discovered: string[] = [];
|
||
|
||
// Single batched query against the one socket Codeman owns. With a single
|
||
// socket a session's location is a constant, so there is no per-session
|
||
// socket tag to reconcile and no cross-socket ambiguity that could mark a
|
||
// live session dead (the root cause of vanished/duplicate tabs).
|
||
let active: Map<string, number>;
|
||
try {
|
||
const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
}).trim();
|
||
active = parsePaneList(output);
|
||
} catch (err) {
|
||
console.error('[TmuxManager] Failed to list tmux panes:', err);
|
||
active = new Map();
|
||
}
|
||
|
||
// Check tracked sessions against the live pane list.
|
||
for (const [sessionId, session] of this.sessions) {
|
||
const pid = active.get(session.muxName);
|
||
if (pid !== undefined) {
|
||
alive.push(sessionId);
|
||
if (pid !== session.pid) session.pid = pid;
|
||
} else {
|
||
dead.push(sessionId);
|
||
this.sessions.delete(sessionId);
|
||
this.emit('sessionDied', { sessionId });
|
||
}
|
||
}
|
||
|
||
// Discover untracked codeman/claudeman sessions on our socket. Dedup by
|
||
// muxName (globally unique) so a name we already track never spawns a
|
||
// second "Restored:" entry.
|
||
const knownMuxNames = new Set<string>();
|
||
for (const session of this.sessions.values()) {
|
||
knownMuxNames.add(session.muxName);
|
||
}
|
||
|
||
for (const [sessionName, pid] of active) {
|
||
if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue;
|
||
// Only admit names that pass the safe-name pattern. A foreign process on the
|
||
// shared `tmux -L codeman` socket could create a `codeman-…` session whose name
|
||
// contains shell metacharacters; rejecting it here keeps it out of this.sessions
|
||
// and away from the name-interpolating tmux call sites (M1).
|
||
if (!isValidMuxName(sessionName)) {
|
||
console.warn(`[TmuxManager] Skipping discovered tmux session with unsafe name: ${sessionName}`);
|
||
continue;
|
||
}
|
||
if (knownMuxNames.has(sessionName)) continue;
|
||
|
||
const fragment = sessionName.replace(/^(?:codeman|claudeman)-/, '');
|
||
const sessionId = `restored-${fragment}`;
|
||
const session: MuxSession = {
|
||
sessionId,
|
||
muxName: sessionName,
|
||
pid,
|
||
createdAt: Date.now(),
|
||
workingDir: process.cwd(),
|
||
mode: 'claude',
|
||
attached: false,
|
||
name: `Restored: ${sessionName}`,
|
||
};
|
||
this.sessions.set(sessionId, session);
|
||
knownMuxNames.add(sessionName);
|
||
discovered.push(sessionId);
|
||
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
|
||
}
|
||
|
||
if (dead.length > 0 || discovered.length > 0) {
|
||
this.saveSessions();
|
||
}
|
||
|
||
return { alive, dead, discovered };
|
||
}
|
||
|
||
async getProcessStats(sessionId: string): Promise<ProcessStats | null> {
|
||
if (IS_TEST_MODE) return { memoryMB: 0, cpuPercent: 0, childCount: 0, updatedAt: Date.now() };
|
||
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const psOutput = (
|
||
await execAsync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
})
|
||
).stdout.trim();
|
||
|
||
const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
|
||
|
||
let childCount = 0;
|
||
try {
|
||
const childOutput = (
|
||
await execAsync(`pgrep -P ${session.pid} | wc -l`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
})
|
||
).stdout.trim();
|
||
childCount = parseInt(childOutput, 10) || 0;
|
||
} catch {
|
||
// No children or command failed
|
||
}
|
||
|
||
return {
|
||
memoryMB: Math.round((rss / 1024) * 10) / 10,
|
||
cpuPercent: Math.round(cpu * 10) / 10,
|
||
childCount,
|
||
updatedAt: Date.now(),
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async getSessionsWithStats(): Promise<MuxSessionWithStats[]> {
|
||
if (IS_TEST_MODE) {
|
||
return Array.from(this.sessions.values()).map((s) => ({
|
||
...s,
|
||
stats: { memoryMB: 0, cpuPercent: 0, childCount: 0, updatedAt: Date.now() },
|
||
}));
|
||
}
|
||
|
||
const sessions = Array.from(this.sessions.values());
|
||
if (sessions.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const sessionPids = sessions.map((s) => s.pid);
|
||
const statsMap = new Map<number, ProcessStats>();
|
||
|
||
try {
|
||
// Step 1: Get descendant PIDs
|
||
const descendantMap = new Map<number, number[]>();
|
||
|
||
const pgrepOutput = (
|
||
await execAsync(
|
||
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
|
||
{
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
}
|
||
)
|
||
).stdout.trim();
|
||
|
||
for (const line of pgrepOutput.split('\n')) {
|
||
const [pidStr, childrenStr] = line.split(':');
|
||
const sessionPid = parseInt(pidStr, 10);
|
||
if (!Number.isNaN(sessionPid)) {
|
||
const children = (childrenStr || '')
|
||
.split(',')
|
||
.map((s) => parseInt(s.trim(), 10))
|
||
.filter((n) => !Number.isNaN(n) && n > 0);
|
||
descendantMap.set(sessionPid, children);
|
||
}
|
||
}
|
||
|
||
// Step 2: Collect all PIDs
|
||
const allPids = new Set<number>(sessionPids);
|
||
for (const children of descendantMap.values()) {
|
||
for (const child of children) {
|
||
allPids.add(child);
|
||
}
|
||
}
|
||
|
||
// Step 3: Single ps call
|
||
const pidArray = Array.from(allPids);
|
||
if (pidArray.length > 0) {
|
||
const psOutput = (
|
||
await execAsync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
})
|
||
).stdout.trim();
|
||
|
||
const processStats = new Map<number, { rss: number; cpu: number }>();
|
||
for (const line of psOutput.split('\n')) {
|
||
const parts = line.trim().split(/\s+/);
|
||
if (parts.length >= 3) {
|
||
const pid = parseInt(parts[0], 10);
|
||
const rss = parseFloat(parts[1]) || 0;
|
||
const cpu = parseFloat(parts[2]) || 0;
|
||
if (!Number.isNaN(pid)) {
|
||
processStats.set(pid, { rss, cpu });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Step 4: Aggregate stats
|
||
for (const sessionPid of sessionPids) {
|
||
const children = descendantMap.get(sessionPid) || [];
|
||
const sessionStats = processStats.get(sessionPid) || { rss: 0, cpu: 0 };
|
||
|
||
let totalRss = sessionStats.rss;
|
||
let totalCpu = sessionStats.cpu;
|
||
|
||
for (const childPid of children) {
|
||
const childStats = processStats.get(childPid);
|
||
if (childStats) {
|
||
totalRss += childStats.rss;
|
||
totalCpu += childStats.cpu;
|
||
}
|
||
}
|
||
|
||
statsMap.set(sessionPid, {
|
||
memoryMB: Math.round((totalRss / 1024) * 10) / 10,
|
||
cpuPercent: Math.round(totalCpu * 10) / 10,
|
||
childCount: children.length,
|
||
updatedAt: Date.now(),
|
||
});
|
||
}
|
||
}
|
||
} catch {
|
||
// Fall back to individual queries
|
||
const statsPromises = sessions.map((session) => this.getProcessStats(session.sessionId));
|
||
const results = await Promise.allSettled(statsPromises);
|
||
return sessions.map((session, i) => ({
|
||
...session,
|
||
stats: results[i].status === 'fulfilled' ? (results[i].value ?? undefined) : undefined,
|
||
}));
|
||
}
|
||
|
||
return sessions.map((session) => ({
|
||
...session,
|
||
stats: statsMap.get(session.pid) || undefined,
|
||
}));
|
||
}
|
||
|
||
startStatsCollection(intervalMs: number = DEFAULT_STATS_INTERVAL_MS): void {
|
||
if (this.statsInterval) {
|
||
clearInterval(this.statsInterval);
|
||
}
|
||
|
||
this.statsInterval = setInterval(async () => {
|
||
try {
|
||
const sessionsWithStats = await this.getSessionsWithStats();
|
||
this.emit('statsUpdated', sessionsWithStats);
|
||
} catch (err) {
|
||
console.error('[TmuxManager] Stats collection error:', err);
|
||
}
|
||
}, intervalMs);
|
||
}
|
||
|
||
stopStatsCollection(): void {
|
||
if (this.statsInterval) {
|
||
clearInterval(this.statsInterval);
|
||
this.statsInterval = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start periodic mouse mode sync for all tracked sessions.
|
||
* Polls pane counts every 5s and toggles mouse on/off as needed.
|
||
* Polls every 5s. On pane count change, toggles mouse on (>1 pane) or off (1 pane).
|
||
* If enableMouseMode/disableMouseMode fails, lastPaneCount is NOT updated so it retries next poll.
|
||
*/
|
||
startMouseModeSync(intervalMs: number = 5000): void {
|
||
if (this.mouseSyncInterval) {
|
||
clearInterval(this.mouseSyncInterval);
|
||
}
|
||
|
||
this.mouseSyncInterval = setInterval(async () => {
|
||
if (IS_TEST_MODE) return;
|
||
|
||
for (const session of this.sessions.values()) {
|
||
const panes = await this.listPanes(session.muxName);
|
||
const count = panes.length;
|
||
if (count === 0) continue;
|
||
|
||
const prev = this.lastPaneCount.get(session.muxName);
|
||
if (prev === count) continue;
|
||
|
||
// Pane count changed — toggle mouse mode
|
||
if (count > 1) {
|
||
if (await this.enableMouseMode(session.muxName)) {
|
||
this.lastPaneCount.set(session.muxName, count);
|
||
}
|
||
// If enableMouseMode fails, DON'T update lastPaneCount — retry next poll
|
||
} else {
|
||
if (await this.disableMouseMode(session.muxName)) {
|
||
this.lastPaneCount.set(session.muxName, count);
|
||
}
|
||
}
|
||
}
|
||
}, intervalMs);
|
||
}
|
||
|
||
stopMouseModeSync(): void {
|
||
if (this.mouseSyncInterval) {
|
||
clearInterval(this.mouseSyncInterval);
|
||
this.mouseSyncInterval = null;
|
||
}
|
||
this.lastPaneCount.clear();
|
||
}
|
||
|
||
destroy(): void {
|
||
this.stopStatsCollection();
|
||
this.stopMouseModeSync();
|
||
}
|
||
|
||
registerSession(session: MuxSession): void {
|
||
this.sessions.set(session.sessionId, session);
|
||
this.saveSessions();
|
||
}
|
||
|
||
setAttached(sessionId: string, attached: boolean): void {
|
||
const session = this.sessions.get(sessionId);
|
||
if (session) {
|
||
session.attached = attached;
|
||
this.saveSessions();
|
||
}
|
||
}
|
||
|
||
updateRespawnConfig(sessionId: string, config: PersistedRespawnConfig | undefined): void {
|
||
const session = this.sessions.get(sessionId);
|
||
if (session) {
|
||
session.respawnConfig = config;
|
||
this.saveSessions();
|
||
}
|
||
}
|
||
|
||
clearRespawnConfig(sessionId: string): void {
|
||
const session = this.sessions.get(sessionId);
|
||
if (session && session.respawnConfig) {
|
||
delete session.respawnConfig;
|
||
this.saveSessions();
|
||
}
|
||
}
|
||
|
||
updateRalphEnabled(sessionId: string, enabled: boolean): void {
|
||
const session = this.sessions.get(sessionId);
|
||
if (session) {
|
||
session.ralphEnabled = enabled;
|
||
this.saveSessions();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Apply a tmux history-limit to all tracked sessions (e.g. when the user
|
||
* changes the terminal-history setting). Invalid limits fall back to the
|
||
* default. Best-effort per session.
|
||
*/
|
||
async setHistoryLimit(limit: number): Promise<void> {
|
||
const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.trunc(limit) : DEFAULT_TMUX_HISTORY_LIMIT;
|
||
|
||
if (IS_TEST_MODE) {
|
||
return;
|
||
}
|
||
|
||
const updates = Array.from(this.sessions.values()).map((session) =>
|
||
execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, {
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
})
|
||
);
|
||
await Promise.allSettled(updates);
|
||
}
|
||
|
||
/**
|
||
* Send input directly to a tmux session using `send-keys`.
|
||
*
|
||
* Uses tmux send-keys for reliable input delivery:
|
||
* - `-l` flag sends literal text (no key interpretation)
|
||
* - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
|
||
* - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
|
||
*/
|
||
async sendInput(sessionId: string, input: string): Promise<boolean> {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) {
|
||
console.error(
|
||
`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`
|
||
);
|
||
return false;
|
||
}
|
||
|
||
// TEST MODE: No-op — don't send input to real tmux sessions
|
||
if (IS_TEST_MODE) {
|
||
return true;
|
||
}
|
||
|
||
console.log(
|
||
`[TmuxManager] sendInput to ${session.muxName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`
|
||
);
|
||
|
||
if (!isValidMuxName(session.muxName)) {
|
||
console.error('[TmuxManager] Invalid session name in sendInput:', session.muxName);
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
const hasCarriageReturn = input.includes('\r');
|
||
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
|
||
|
||
if (textPart && hasCarriageReturn) {
|
||
// Send text first, then Enter as a SEPARATE tmux command after a short delay.
|
||
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
|
||
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
|
||
// character in the input buffer rather than as form submission.
|
||
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
});
|
||
} else if (textPart) {
|
||
// Text only, no Enter
|
||
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
});
|
||
} else if (hasCarriageReturn) {
|
||
// Enter only
|
||
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
});
|
||
}
|
||
|
||
return true;
|
||
} catch (err) {
|
||
console.error('[TmuxManager] Failed to send input:', err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ========== Pane Methods (for Agent Team teammate panes) ==========
|
||
|
||
/**
|
||
* Enable mouse mode for an existing tmux session.
|
||
* Allows clicking to select panes in agent team split-pane layouts.
|
||
* When mouse mode is on, tmux intercepts mouse events (slow selection, no browser copy).
|
||
*/
|
||
async enableMouseMode(muxName: string): Promise<boolean> {
|
||
if (IS_TEST_MODE) return true;
|
||
if (!isValidMuxName(muxName)) {
|
||
console.error('[TmuxManager] Invalid session name in enableMouseMode:', muxName);
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
});
|
||
console.log(`[TmuxManager] Mouse mode ON for ${muxName}`);
|
||
return true;
|
||
} catch (err) {
|
||
console.error(`[TmuxManager] Failed to enable mouse mode for ${muxName}:`, err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Disable mouse mode for an existing tmux session.
|
||
* Restores native xterm.js text selection and browser clipboard copy.
|
||
*/
|
||
async disableMouseMode(muxName: string): Promise<boolean> {
|
||
if (IS_TEST_MODE) return true;
|
||
if (!isValidMuxName(muxName)) {
|
||
console.error('[TmuxManager] Invalid session name in disableMouseMode:', muxName);
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
});
|
||
console.log(`[TmuxManager] Mouse mode OFF for ${muxName}`);
|
||
return true;
|
||
} catch (err) {
|
||
console.error(`[TmuxManager] Failed to disable mouse mode for ${muxName}:`, err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sync mouse mode based on pane count: enable if split (>1 pane), disable if single.
|
||
* Called by TeamWatcher when teammates spawn/despawn panes.
|
||
* Uses `tmux list-panes` for bulletproof detection — counts actual panes, not config.
|
||
*/
|
||
async syncMouseMode(muxName: string): Promise<boolean> {
|
||
if (IS_TEST_MODE) return true;
|
||
const panes = await this.listPanes(muxName);
|
||
if (panes.length > 1) {
|
||
return this.enableMouseMode(muxName);
|
||
} else {
|
||
return this.disableMouseMode(muxName);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* List all panes in a tmux session.
|
||
* Returns structured info for each pane.
|
||
*/
|
||
async listPanes(muxName: string): Promise<PaneInfo[]> {
|
||
if (IS_TEST_MODE) return [];
|
||
if (!isValidMuxName(muxName)) {
|
||
console.error('[TmuxManager] Invalid session name in listPanes:', muxName);
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
const output = (
|
||
await execAsync(
|
||
`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
|
||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||
)
|
||
).stdout.trim();
|
||
|
||
return output
|
||
.split('\n')
|
||
.map((line) => {
|
||
const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':');
|
||
return {
|
||
paneId,
|
||
paneIndex: parseInt(indexStr, 10),
|
||
panePid: parseInt(pidStr, 10),
|
||
width: parseInt(widthStr, 10),
|
||
height: parseInt(heightStr, 10),
|
||
};
|
||
})
|
||
.filter((p) => !Number.isNaN(p.paneIndex));
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send input to a specific pane within a tmux session.
|
||
* Uses the same literal text approach as sendInput() but targets a specific pane.
|
||
*/
|
||
sendInputToPane(muxName: string, paneTarget: string, input: string): boolean {
|
||
if (IS_TEST_MODE) return true;
|
||
if (!isValidMuxName(muxName)) {
|
||
console.error('[TmuxManager] Invalid session name in sendInputToPane:', muxName);
|
||
return false;
|
||
}
|
||
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
|
||
console.error('[TmuxManager] Invalid pane target:', paneTarget);
|
||
return false;
|
||
}
|
||
|
||
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
|
||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||
const tmux = this.tmux();
|
||
|
||
try {
|
||
const hasCarriageReturn = input.includes('\r');
|
||
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
|
||
|
||
if (textPart && hasCarriageReturn) {
|
||
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
});
|
||
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
});
|
||
} else if (textPart) {
|
||
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
});
|
||
} else if (hasCarriageReturn) {
|
||
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
});
|
||
}
|
||
|
||
return true;
|
||
} catch (err) {
|
||
console.error('[TmuxManager] Failed to send input to pane:', err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 '';
|
||
if (!isValidMuxName(muxName)) {
|
||
console.error('[TmuxManager] Invalid session name in capturePaneBuffer:', muxName);
|
||
return null;
|
||
}
|
||
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
|
||
console.error('[TmuxManager] Invalid pane target:', paneTarget);
|
||
return null;
|
||
}
|
||
|
||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||
|
||
try {
|
||
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.
|
||
*/
|
||
startPipePane(muxName: string, paneTarget: string, outputFile: string): boolean {
|
||
if (IS_TEST_MODE) return true;
|
||
if (!isValidMuxName(muxName)) {
|
||
console.error('[TmuxManager] Invalid session name in startPipePane:', muxName);
|
||
return false;
|
||
}
|
||
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
|
||
console.error('[TmuxManager] Invalid pane target:', paneTarget);
|
||
return false;
|
||
}
|
||
if (!isValidPath(outputFile)) {
|
||
console.error('[TmuxManager] Invalid output file path:', outputFile);
|
||
return false;
|
||
}
|
||
|
||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||
|
||
try {
|
||
execSync(`${this.tmux()} pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
});
|
||
return true;
|
||
} catch (err) {
|
||
console.error('[TmuxManager] Failed to start pipe-pane:', err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stop piping pane output (calling pipe-pane with no command stops piping).
|
||
*/
|
||
stopPipePane(muxName: string, paneTarget: string): boolean {
|
||
if (IS_TEST_MODE) return true;
|
||
if (!isValidMuxName(muxName)) {
|
||
console.error('[TmuxManager] Invalid session name in stopPipePane:', muxName);
|
||
return false;
|
||
}
|
||
if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
|
||
console.error('[TmuxManager] Invalid pane target:', paneTarget);
|
||
return false;
|
||
}
|
||
|
||
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
|
||
|
||
try {
|
||
execSync(`${this.tmux()} pipe-pane -t ${shellescape(target)}`, {
|
||
encoding: 'utf-8',
|
||
timeout: EXEC_TIMEOUT_MS,
|
||
});
|
||
return true;
|
||
} catch (err) {
|
||
console.error('[TmuxManager] Failed to stop pipe-pane:', err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
getAttachCommand(): string {
|
||
return 'tmux';
|
||
}
|
||
|
||
getAttachArgs(muxName: string): string[] {
|
||
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();
|
||
}
|
||
|
||
/**
|
||
* Check if tmux is available on the system.
|
||
*/
|
||
static isTmuxAvailable(): boolean {
|
||
try {
|
||
execSync('which tmux', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS });
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Shell-escape a string for use as a single argument.
|
||
* Wraps in single quotes, escaping any embedded single quotes.
|
||
*/
|
||
function shellescape(str: string): string {
|
||
// Replace single quotes with '\'' (end quote, escaped quote, restart quote)
|
||
return "'" + str.replace(/'/g, "'\\''") + "'";
|
||
}
|