Files
Codeman/src/tmux-manager.ts
T
Aamer AkhterandSaqeb Akhter 70378315da feat(codex): add Codex (OpenAI CLI) run-mode foundation
Add Codex as a first-class session mode alongside Claude/Shell/OpenCode.

- codex-cli-resolver: locate the `codex` binary and augment PATH (mirrors the
  OpenCode resolver)
- SessionMode 'codex' + CodexConfig (model, resumeSessionId, dangerouslyBypass,
  renderMode); persisted in SessionState and threaded through CreateSession/
  RespawnPane options
- schema validation: CodexConfigSchema, CODEX_ env-var prefix allowlist, mode
  enums on create/quick-start, codexDangerouslyBypassApprovals setting
- tmux launch: buildCodexCommand, setenv for OPENAI_API_KEY/CODEX_* (keeps
  secrets out of ps), truecolor COLORTERM, codex PATH resolution
- session + routes: availability check (clear install hint), config passthrough,
  tmux-required guard; Codex skips Claude-only parsers (Ralph/respawn/token)
- run-mode UI: "Run CX" selector option + dedicated Codex CLI settings tab with
  the bypass-approvals toggle; GET /api/codex/status

Scope: foundation only. Codex terminal redraw handling and xterm snapshot/replay
are intentionally excluded and tracked separately.

Verification: tsc --noEmit, eslint, prettier --check, check:frontend-syntax all
clean; full test:ci suite green (2712 passed, 0 failed); server boot smoke OK.

Co-Authored-By: Saqeb Akhter <saqeb.akhter@gmail.com>
2026-06-10 09:34:52 -04:00

2267 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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,
} from './types.js';
import { buildEffortCliArgs } from './session-cli-builder.js';
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir, resolveCodexDir } 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';
/** 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);
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]/;
/**
* 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 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;
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);
}
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 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' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
...(mode === 'codex' ? ['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'}`,
];
// 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 };
}
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);
}
/**
* 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,
resumeSessionId,
envOverrides,
effort,
} = 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');
}
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
const baseCmd = buildSpawnCommand({
mode,
sessionId,
model,
claudeMode,
allowedTools,
openCodeConfig,
codexConfig,
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);
}
// 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 50000`, { 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,
resumeSessionId,
envOverrides,
effort,
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
const muxName = session.muxName;
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
// 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,
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);
}
// 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();
}
}
/**
* 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, "'\\''") + "'";
}