Files
Codeman/src/tmux-manager.ts
T

3449 lines
132 KiB
TypeScript
Raw Normal View History

2026-02-12 10:29:57 +01:00
/**
* @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
*
2026-02-26 16:44:34 +01:00
* tmux sessions are named `codeman-{sessionId}` and stored in ~/.codeman/mux-sessions.json.
2026-02-12 10:29:57 +01:00
*
2026-02-18 12:51:35 +01:00
* Key features:
* - `send-keys 'text' Enter` sends literal text in a single command
* - `list-sessions -F` provides structured queries
2026-02-12 10:29:57 +01:00
* - `display-message -p '#{pane_pid}'` for reliable PID discovery
2026-02-18 12:51:35 +01:00
* - Single server architecture
2026-02-12 10:29:57 +01:00
*
* @module tmux-manager
*/
import { EventEmitter } from 'node:events';
import { collectDescendants } from './proc-tree.js';
import { execSync, exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execAsync = promisify(exec);
2026-02-12 20:14:01 +01:00
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
import { writeFile, rename } from 'node:fs/promises';
import { dirname } from 'node:path';
import { homedir } from 'node:os';
import { dataPath, DEFAULT_TMUX_SOCKET, CODEMAN_INSTANCE } from './config/instance.js';
import {
ProcessStats,
PersistedRespawnConfig,
getErrorMessage,
DEFAULT_NICE_CONFIG,
type PaneInfo,
type ClaudeMode,
type SessionMode,
type OpenCodeConfig,
type CodexConfig,
type EffortLevel,
2026-05-16 20:10:49 -04:00
type GeminiConfig,
2026-08-04 12:59:40 +02:00
type AntigravityConfig,
2026-06-11 18:43:39 +00:00
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
} from './types.js';
import { buildEffortCliArgs, buildNameCliArgs } from './session-cli-builder.js';
import {
buildSshConnectionArgs,
defaultRemoteCommandForMode,
remoteLoginShellCommand,
remoteSshTarget,
} from './remote-hosts.js';
import {
buildDockerBaseArgs,
buildDockerCreateArgs,
containerApiUrl,
CONTAINER_HOME,
defaultDockerCommandForMode,
hostGatewayAlias,
resolveDockerClaudeArtifacts,
resolveDockerCredentialArtifacts,
type DockerCreateContext,
type DockerMount,
type DockerSeedCopy,
} from './docker-hosts.js';
2026-05-16 20:10:49 -04:00
import {
wrapWithNice,
SAFE_PATH_PATTERN,
findClaudeDir,
getClaudeCliVersion,
2026-05-16 20:10:49 -04:00
resolveOpenCodeDir,
resolveCodexDir,
resolveGeminiDir,
2026-08-04 12:59:40 +02:00
resolveAntigravityDir,
2026-08-04 23:09:00 +02:00
resolveLocalShell,
loginShellArgs,
2026-05-16 20:10:49 -04:00
} from './utils/index.js';
import type {
TerminalMultiplexer,
MuxSession,
MuxSessionWithStats,
CreateSessionOptions,
RespawnPaneOptions,
PaneCaptureOptions,
} from './mux-interface.js';
import {
decideReconnect,
advanceBackoff,
freshReconnectState,
resetReconnectState,
type RemoteReconnectState,
} from './remote-reconnect.js';
2026-02-12 10:29:57 +01:00
// ============================================================================
// Timing Constants
// ============================================================================
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
/** How long a cached process snapshot stays usable. */
const PROC_SNAPSHOT_TTL_MS = 2000;
/**
* How long the kill path waits for a fresh snapshot before giving up on it.
* Shorter than EXEC_TIMEOUT_MS on purpose: killSession has two further strategies
* (process group, tmux kill-session) and must reach them even when `ps` is wedged.
*/
const PROC_SNAPSHOT_WAIT_MS = 1500;
import { DEFAULT_TMUX_HISTORY_LIMIT, DEFAULT_TERMINAL_BUFFER_MAX_BYTES } from './config/terminal-history.js';
/**
* Extra stdout headroom for the full-history `capture-pane` child process on
* top of the consumer's byte cap: raw scrollback carries per-line SGR/ANSI
* overhead that the route pipeline strips before applying its cap, so the
* capture must be allowed to exceed the final payload size.
*/
const FULL_HISTORY_CAPTURE_SLACK_BYTES = 8 * 1024 * 1024;
2026-02-12 10:29:57 +01:00
/** Delay after tmux session creation — enough for detached tmux to be queryable */
const TMUX_CREATION_WAIT_MS = 100;
2026-02-12 10:29:57 +01:00
2026-02-24 13:23:00 +01:00
/** 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;
2026-02-12 10:29:57 +01:00
/** 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;
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000;
2026-06-08 11:18:14 -04:00
/** 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;
2026-02-12 10:29:57 +01:00
/**
* 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
2026-02-26 16:44:34 +01:00
* - Read/write ~/.codeman/mux-sessions.json
2026-02-12 10:29:57 +01:00
*/
const IS_TEST_MODE = !!process.env.VITEST;
2026-02-12 10:29:57 +01:00
/** Path to persisted mux session metadata */
const MUX_SESSIONS_FILE = dataPath('mux-sessions.json');
2026-02-12 10:29:57 +01:00
/**
* COD-108 kill-switch: `remoteAutoReconnect` app setting (default ON). Read at
* call time (like headroom routing) so a settings change takes effect without a
* restart. Absent/non-boolean ⇒ true (feature on).
*/
function isRemoteAutoReconnectEnabled(): boolean {
try {
const s = JSON.parse(readFileSync(dataPath('settings.json'), 'utf8')) as Record<string, unknown>;
return typeof s.remoteAutoReconnect === 'boolean' ? s.remoteAutoReconnect : true;
} catch {
return true;
}
}
2026-02-12 10:29:57 +01:00
/** Regex to validate tmux session names (only allow safe characters) */
2026-02-26 16:44:34 +01:00
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-]+$/;
2026-02-12 10:29:57 +01:00
2026-02-12 13:44:07 +01:00
/** 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;
}
/**
* Normalize scrollback line endings to `\r\n` so a fresh xterm replays each line
* at column 0 (COD-138).
*
* `capture-pane -p -e -S -` (full-history capture) joins scrollback rows with a
* BARE `\n`. The browser xterm is created with the default `convertEol: false`
* (correct for the live PTY stream, which already carries real `\r\n`), so a bare
* `\n` drops a row without returning the cursor to column 0. Replaying that raw
* buffer on a full page reload makes every line start one column further right —
* the diagonal "staircase". The visible/tab-switch path avoids this by repainting
* each row with an absolute cursor CSI (`formatPaneSnapshot`); the full-history
* path returns raw scrollback, so it must be CRLF-normalized here.
*
* `\r?\n → \r\n` is idempotent on already-CRLF input and leaves a lone `\r` (an
* intentional in-line column reset / overwrite) untouched.
*/
export function normalizeScrollbackEol(buffer: string): string {
return buffer.replace(/\r?\n/g, '\r\n');
}
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]/;
2026-02-12 10:29:57 +01:00
/**
* Validates that a session name contains only safe characters.
* Prevents command injection via malformed session IDs.
*/
function isValidMuxName(name: string): boolean {
2026-02-26 16:44:34 +01:00
return SAFE_MUX_NAME_PATTERN.test(name) || LEGACY_MUX_NAME_PATTERN.test(name);
2026-02-12 10:29:57 +01:00
}
function isValidTerminalDimension(value: number): boolean {
return Number.isSafeInteger(value) && value > 0 && value <= 1000;
}
2026-02-12 10:29:57 +01:00
/**
* 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)) {
2026-02-12 10:29:57 +01:00
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 'auto':
return ' --permission-mode auto';
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?.animations !== undefined) {
parts.push('--config', `tui.animations=${config.animations ? 'true' : 'false'}`);
}
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(' ');
}
2026-05-16 20:10:49 -04:00
/**
* 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(' ');
}
2026-08-04 12:59:40 +02:00
/**
* Build the Antigravity CLI (agy) command with appropriate flags.
*
* Unlike gemini's yolo default, `--dangerously-skip-permissions` is only added
* when the config explicitly asks for it (the frontend sends it for parity with
* Codeman's Claude default; the multi-user clamp strips it for non-granted owners,
* and an ABSENT config stays at agy's own prompting default — safe like Codex).
*/
function buildAntigravityCommand(config?: AntigravityConfig): string {
const parts = ['agy'];
if (config?.dangerouslySkipPermissions) {
parts.push('--dangerously-skip-permissions');
}
if (config?.model) {
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
if (config?.resumeConversationId) {
const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeConversationId) ? config.resumeConversationId : undefined;
if (safeId) parts.push('--conversation', 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}'` : '';
}
/**
* Build the ` --name "<session name>"` shell fragment, or '' when it must be
* omitted. Version-gated FAIL-CLOSED in buildNameCliArgs (an older/unknown CLI
* aborts startup on an unknown flag, which would kill every claude spawn), and
* the value is allowlist-sanitized there, so it contains none of the characters
* that are special inside this double-quoted interpolation. The peer name is a
* soft default (in-session /rename still wins), which is why this rides the
* spawn command rather than any persisted config.
*/
function buildClaudeNameFlag(sessionName: string | undefined, cliVersion: string | null): string {
const [flag, value] = buildNameCliArgs(sessionName, cliVersion);
return flag && value ? ` ${flag} "${value}"` : '';
}
export function buildSpawnCommand(options: {
mode: SessionMode;
sessionId: string;
model?: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
2026-05-16 20:10:49 -04:00
geminiConfig?: GeminiConfig;
2026-08-04 12:59:40 +02:00
antigravityConfig?: AntigravityConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
sessionName?: string;
/**
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
* (getClaudeCliVersion; null under vitest). Tests inject a value here; the
* docker/remote paths never see this builder's output, which is what keeps the
2026-08-09 13:23:52 +02:00
* gate measuring the RIGHT binary, the local one.
*/
claudeCliVersion?: string | null;
}): 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);
const nameFlag = buildClaudeNameFlag(
options.sessionName,
options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion()
);
// 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}${nameFlag}`;
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`;
return `${resumeCmd} || ${fallbackCmd}`;
}
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`;
}
if (options.mode === 'opencode') {
return buildOpenCodeCommand(options.openCodeConfig);
}
if (options.mode === 'codex') {
return buildCodexCommand(options.codexConfig);
}
2026-05-16 20:10:49 -04:00
if (options.mode === 'gemini') {
return buildGeminiCommand(options.geminiConfig);
}
2026-08-04 12:59:40 +02:00
if (options.mode === 'antigravity') {
return buildAntigravityCommand(options.antigravityConfig);
}
2026-08-04 23:09:00 +02:00
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
// so a `$SHELL` here is expanded by the SERVER process's shell against the
// SERVER process's env — empty in containers and system systemd units, leaving
// the pane command ending in a dangling `&&` ("syntax error: unexpected end of
// file", pane dead on arrival). Resolve it in Node and quote the result.
// #209: launch it as a LOGIN shell, which is what tmux itself does for a pane
// with no `default-command`, so a Codeman shell tab matches a hand-started tmux
// one. That is what picks up /etc/profile and /etc/profile.d/* — a systemd
// --user service never sourced them, so its PATH is what every pane inherited.
// The flags come from loginShellArgs() rather than being hardcoded: they are
// appended to a path that ultimately comes from the passwd entry, and a shell
// that rejects an unknown flag exits on the spot, which is #208 all over again.
const shell = resolveLocalShell();
return `${shellescape(shell)}${loginShellArgs(shell)}`;
}
/**
* Dedicated socket for Codeman-launched REMOTE tmux servers, distinct from the
* canonical local `-L codeman` socket. A remote host that runs its OWN Codeman
* would otherwise share the `-L codeman` socket AND the `codeman-<hex>` discovery
* name, so its `reconcileSessions()` would ADOPT our session (attach a PTY,
* resize, respawn-pane it locally) — the cross-machine form of the "2nd instance
* attaches live sessions" hazard. A private socket keeps our remote sessions off
* that instance's radar entirely.
*/
const REMOTE_TMUX_SOCKET = 'codeman-remote';
/**
* Deterministic, reattach-stable remote tmux session name for a Codeman session.
*
* Derived from the same stable field the LOCAL muxName uses (the first 8 chars of
* the sessionId), so reconnecting (which re-issues the exact same
* `ssh … new-session -A`) lands back in the SAME remote session. Must NOT be
* random/time-based — it has to be stable across reconnects.
*
* The `codeman-ssh-` prefix is deliberately chosen to FAIL a remote Codeman's
* `SAFE_MUX_NAME_PATTERN` (`^codeman-[a-f0-9-]+$`) — the `s`/`h` letters mean a
* remote instance's discovery never treats this as one of its own sessions (belt
* to the dedicated-socket suspenders above).
*/
export function remoteTmuxSessionName(sessionId: string): string {
return `codeman-ssh-${sessionId.slice(0, 8)}`;
}
/**
* COD-104 — build the SSH command that launches (or reattaches) a remote
* session INSIDE a tmux server on the remote host, so the remote agent survives
* an SSH drop.
*
* Emits:
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
* 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path> "cd <path> && exec <cli>" \
* \; set -t codeman-ssh-<id> status off \; set -t codeman-ssh-<id> mouse off \
* \; set -t codeman-ssh-<id> prefix C-q \; set -s escape-time 0'
*
* COD-107 — the connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
* arbitrary `-o`) come from the shared `buildSshConnectionArgs(remote)`, so the
* prereq tmux probe and this launch connect with identical options.
*
* - `new-session -A -s codeman-ssh-<id>` = attach-if-exists-else-create
* (idempotent), so reconnect re-runs the same command and reattaches the
* still-running agent.
* - `-L codeman-remote` = a DEDICATED socket, NOT the canonical `-L codeman` a
* remote Codeman would use, so our session never collides with / gets adopted by
* an instance running on the remote host.
* - The `set` options are scoped per-session (`set -t <name>` / server-level
* `set -s`), never `-g`, so they never mutate other sessions' prefix/mouse.
* - The whole tmux invocation is a SINGLE ssh argument (the remote login shell
* runs it), so it is shell-quoted as one unit; the `cd && exec` command is in
* turn a single tmux argument (tmux runs it via `/bin/sh -c`), so the path is
* shell-quoted inside it too. This keeps escaping correct through every layer
* even when the remote path contains spaces.
*/
export function buildRemoteLaunchCommand(options: {
mode: SessionMode;
remote: SessionRemote;
sessionId: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
}): string {
const { mode, remote, sessionId, claudeMode, allowedTools } = options;
// §6.3: honor the session's EFFECTIVE claude permission mode on remote instead of
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
// ignored claudeMode). A per-host `commands.claude` override stays authoritative
// (admin's explicit choice). Wrapped in `$SHELL -i -l -c` for the same reason as
// `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH entry that
// only an interactive login shell resolves (see that function's comment).
const override = remote.commands?.[mode];
const modeCommand = override
? override
: mode === 'claude'
? remoteLoginShellCommand(`claude${buildClaudePermissionFlags(claudeMode, allowedTools)}`)
: defaultRemoteCommandForMode(mode);
const remoteName = remoteTmuxSessionName(sessionId);
// Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
// tmux, so the path needs shell-quoting here. `exec` replaces the shell with
// the CLI so the pane PID is the agent itself.
const paneCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
// The tmux command line, with `\;` separating commands so the config `set`s
// apply on the SAME connection (and are idempotent on reattach). Options are
// scoped per-session (`set -t <name>` / server `set -s`), NEVER `-g`, so a
// shared remote tmux server's other sessions keep their own prefix/mouse.
const tmuxInvocation = [
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`,
`set -t ${remoteName} status off`,
`set -t ${remoteName} mouse off`,
`set -t ${remoteName} prefix C-q`,
'set -s escape-time 0',
// COD-106 — shared/collaborative sessions: tmux defaults to sizing a window
// to the SMALLEST attached client, so two Codemans at different viewports
// would fight (clamp to the smaller). `window-size latest` sizes to the
// most-recently-active client instead, so concurrent clients coexist.
// Per-session scoped (`set -t <name>`, matching #145's hardening) so a shared
// remote tmux server's other sessions keep their own sizing behavior.
`set -t ${remoteName} window-size latest`,
// #210: keep a CRASHED pane so the failure is still on screen. Without this,
// tmux destroys the pane -> window -> session (and, being the only session,
// the whole remote server) the instant the pane command exits, which tears the
// local `ssh -t` attach down with it; reconnect's `-A` then builds a fresh
// session and the cycle can repeat as a flap loop with no evidence surviving.
// That is how the exit-127 PATH bug fixed above stayed invisible.
//
// `failed`, NOT `on`: `on` keeps the pane on a CLEAN exit too, so typing
// `exit` in a remote shell leaves a dead pane behind, the session outlives it,
// and the next launch's `-A` reattaches to that corpse ("Pane is dead (status
// 0)") instead of starting a shell — verified against a real tmux. `failed`
// keeps the pane only on a non-zero exit, which is exactly the diagnostic case.
//
// LAST in the chain on purpose: tmux aborts the remaining commands of a `\;`
// sequence once one errors (also verified), and `failed` needs tmux >= 3.2 on
// the REMOTE host. Trailing, a rejection costs only this option; leading, it
// would silently drop status/mouse/prefix/escape-time/window-size with it.
`set -t ${remoteName} remain-on-exit failed`,
].join(' \\; ');
// ssh runs its trailing args through the remote login shell, so the entire
// tmux invocation is passed as one shell-quoted argument.
//
// COD-107 — connection options (port, identity, SOCKS ProxyCommand, jump host,
// arbitrary -o) come from the shared `buildSshConnectionArgs` so the launch and
// the tmux-prereq probe connect IDENTICALLY. `-t` is inserted right after
// `ssh -o BatchMode=yes` (preserving the historical token order), then the rest
// of the connection args, then the target and the quoted tmux invocation.
const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote);
const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)];
return sshParts.join(' ');
2026-06-11 18:43:39 +00:00
}
/**
* Build the SSH command that kills the durable remote tmux session created by
* `buildRemoteLaunchCommand`. Because that session lives on a private socket
* (`-L codeman-remote`) under a stable name, killing the LOCAL ssh wrapper alone
* would orphan the remote agent forever (invisible to Codeman, still burning plan
* quota). This is fired best-effort on session kill; the shared connection args
* carry the default `-o ConnectTimeout=10` so an unreachable host fails fast.
*/
export function buildRemoteKillCommand(options: { remote: SessionRemote; sessionId: string }): string {
const { remote, sessionId } = options;
const remoteName = remoteTmuxSessionName(sessionId);
const killCmd = `tmux -L ${REMOTE_TMUX_SOCKET} kill-session -t ${shellescape(remoteName)}`;
const [ssh, ...connectionArgs] = buildSshConnectionArgs(remote);
return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].join(' ');
}
// ========== Docker cases (COD-Docker) ==========
//
// The docker analog of the remote-SSH launch above. Instead of a local tmux pane
// running `ssh -t host 'tmux new-session …'`, it runs `docker exec -it <container>
// sh -lc 'tmux new-session …'` into a DURABLE in-container tmux server. The
// container is per-CASE, so many sessions `docker exec` into the same one. See
// docs/docker-cases-plan.md.
/**
* DEDICATED in-container tmux socket. A Codeman running INSIDE the container uses
* `-L codeman`; ours is `-L codeman-docker` with a `codeman-dkr-*` session name
* that deliberately FAILS SAFE_MUX_NAME_PATTERN, so an in-container Codeman never
* adopts/resizes/respawns our session (same defence as the remote socket).
*/
const DOCKER_TMUX_SOCKET = 'codeman-docker';
/**
* Deterministic, reattach-stable in-container tmux session name. Derived from the
* same stable field the local muxName uses (first 8 chars of the sessionId), so a
* reconnect re-issues the exact same `new-session -A` and lands back in the SAME
* in-container session. The `dkr` letters make it fail SAFE_MUX_NAME_PATTERN.
*/
export function dockerTmuxSessionName(sessionId: string): string {
return `codeman-dkr-${sessionId.slice(0, 8)}`;
}
/** Resume ids are UUID-ish; reject anything with shell metacharacters (defensive). */
const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/;
/**
* Append the CLI-specific resume flag to a pane command (codex/gemini/antigravity). Only fires
2026-07-20 02:36:02 +02:00
* when the in-container tmux is RE-CREATED (`new-session -A` makes the flag inert
* on a live reattach), i.e. exactly when the previous live agent was lost and we
* want to resume the conversation from the bind-mounted transcript. Claude mode
* uses claudeDockerPaneCommand instead.
*/
function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string {
if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand;
switch (mode) {
case 'gemini':
return `${modeCommand} --resume ${resumeId}`;
case 'codex':
return `${modeCommand} resume ${resumeId}`;
2026-08-04 12:59:40 +02:00
case 'antigravity':
return `${modeCommand} --conversation ${resumeId}`;
default:
return modeCommand; // shell / opencode: no resume
}
}
2026-07-20 02:36:02 +02:00
/**
* Claude-mode pane command with a DETERMINISTIC conversation id (the docker analog
* of buildSpawnCommand's --resume/--session-id logic). A fresh launch passes
* `--session-id <sessionId>`, so the in-container conversation id is knowable
* host-side (resume-id capture + subagent/workflow correlation) WITHOUT relying on
* hook reachability. When the in-container tmux was re-created after a container
* stop/reboot, the same command re-runs against the surviving transcript:
* `--session-id` exits 1 ("already in use") and the `||` fallback RESUMES that
* conversation (verified CLI behavior). An explicit resumeId gets the local
* builder's shape — resume first, session-id fallback — so a stale id never
* dead-panes. The leading `exec ` is stripped: an exec'd first branch could never
* fall back.
*/
function claudeDockerPaneCommand(modeCommand: string, sessionId: string, resumeId?: string): string {
if (!RESUME_ID_SAFE.test(sessionId)) return modeCommand; // defensive — ids are server-minted uuids
const cmd = modeCommand.replace(/^exec\s+/, '');
const rid = resumeId && RESUME_ID_SAFE.test(resumeId) ? resumeId : undefined;
if (rid && rid !== sessionId) {
return `${cmd} --resume ${rid} || ${cmd} --session-id ${sessionId}`;
}
const cid = rid ?? sessionId;
return `${cmd} --session-id ${cid} || ${cmd} --resume ${cid}`;
}
/** Fully-resolved inputs for buildDockerLaunchCommand (pure). */
export interface DockerLaunchOptions {
mode: SessionMode;
docker: SessionDocker;
sessionId: string;
resumeSessionId?: string;
createContext: DockerCreateContext;
/** exec-time inline env (non-secret): TERM, COLORTERM, CODEMAN_SESSION_ID, CODEMAN_MUX */
execEnv: Record<string, string>;
/** exec-time NAME-ONLY env forwarded from Codeman's process env (codex/gemini keys) */
execEnvNames: string[];
/**
* Files to copy from read-only seed mounts into the container's writable HOME once
* before launch (guarded so reconnects never clobber). Isolates Claude state: the
* merged `~/.claude.json`, plus `~/.claude/.credentials.json` + `settings.json`,
* are writable copies (not host mounts), so the container never re-auths and never
* writes its runtime state back into the host `~/.claude`.
*/
seedCopies?: DockerSeedCopy[];
}
/**
* Build the ONE `bash -c` launch string for a docker session: image-check ->
* ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
* in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
* four layers: outer `bash -c "…"` (JSON.stringify at respawn-pane) -> the joined
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
*/
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
const { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies } = opts;
const base = buildDockerBaseArgs(docker).join(' ');
const createArgs = buildDockerCreateArgs(createContext).join(' ');
const name = shellescape(docker.containerName);
const workdir = shellescape(docker.containerWorkdir);
const image = shellescape(docker.image);
const dkrName = dockerTmuxSessionName(sessionId);
const sid = sessionId.slice(0, 8);
let modeCommand = docker.commands?.[mode as DockerCommandMode] || defaultDockerCommandForMode(mode);
2026-07-20 02:36:02 +02:00
if (mode === 'claude') {
modeCommand = claudeDockerPaneCommand(modeCommand, sessionId, resumeSessionId);
} else if (resumeSessionId) {
modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
}
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
// pane PID the agent itself.
const paneCommand = `cd ${workdir} && ${modeCommand}`;
// `setenv -g` primes the session id so reattaches / newly-created panes inherit
// it. `new-session -A` = attach-or-create (idempotent + resume-aware). Options
// are scoped per-session (`set -t`) or server (`set -s`), never `-g`, so a shared
// in-container tmux server's other sessions keep their own prefix/mouse.
const tmuxInvocation = [
`tmux -L ${DOCKER_TMUX_SOCKET} setenv -g CODEMAN_SESSION_ID ${shellescape(sid)}`,
'setenv -g CODEMAN_MUX 1',
`new-session -A -s ${dkrName} -c ${workdir} ${shellescape(paneCommand)}`,
`set -t ${dkrName} status off`,
`set -t ${dkrName} mouse off`,
`set -t ${dkrName} prefix C-q`,
'set -s escape-time 0',
].join(' \\; ');
const execEnvFlags: string[] = [];
for (const [k, v] of Object.entries(execEnv)) execEnvFlags.push('--env', shellescape(`${k}=${v}`));
// NAME-ONLY forwards: docker reads the VALUE from Codeman's own process env, so
// the secret never appears in argv (no `ps` leak) and is not committed.
for (const n of execEnvNames) execEnvFlags.push('--env', n);
for (const extra of docker.extraExecArgs ?? []) execEnvFlags.push(shellescape(extra));
const imageMissingMsg = shellescape(
`Codeman: base image ${docker.image} not present (it is normally auto-built on first use)`
);
const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`);
const imageCheck = `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`;
// create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain.
const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`;
const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
// Seed writable credential config from read-only host mounts ONCE per container
// (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for
// whole-dir credential seeds). mkdir -p the parent so a file seed works even when
// no sibling share-mount pre-created the dir. Paths are fixed CONTAINER_HOME
// constants (no shell metachars), so the whole inner command is shell-quoted once.
const seedSteps = (seedCopies ?? []).map((s) => {
const cp = s.recursive ? 'cp -a' : 'cp';
const parent = s.to.slice(0, s.to.lastIndexOf('/'));
return `mkdir -p ${parent} 2>/dev/null; [ -e ${s.to} ] || ${cp} ${s.from} ${s.to} 2>/dev/null || true`;
});
const innerCmd = seedSteps.length ? `${seedSteps.join(' ; ')} ; ${tmuxInvocation}` : tmuxInvocation;
const execCmd = `exec ${base} exec -it --workdir ${workdir} ${execEnvFlags.join(' ')} ${name} sh -lc ${shellescape(innerCmd)}`;
return [imageCheck, ensure, start, execCmd].join(' ; ');
}
/**
* Kill ONLY this session's in-container tmux session. The container is shared by
* the case's other sessions, so this NEVER `docker stop`s it — stopping/removing
* the container is an explicit teardown (buildDockerStopCommand) or case-delete
* (buildDockerRemoveCommand). Fired best-effort on session kill.
*/
export function buildDockerKillCommand(options: { docker: SessionDocker; sessionId: string }): string {
const { docker, sessionId } = options;
const base = buildDockerBaseArgs(docker).join(' ');
const dkrName = dockerTmuxSessionName(sessionId);
return `${base} exec ${shellescape(docker.containerName)} tmux -L ${DOCKER_TMUX_SOCKET} kill-session -t ${shellescape(dkrName)}`;
}
/** Explicit container stop (frees RAM/CPU; conversation resumes on next launch via --resume). */
export function buildDockerStopCommand(docker: SessionDocker): string {
return `${buildDockerBaseArgs(docker).join(' ')} stop -t 10 ${shellescape(docker.containerName)}`;
}
/** Explicit container removal (case-delete). Destroys in-image state; bind mounts survive. */
export function buildDockerRemoveCommand(docker: SessionDocker): string {
return `${buildDockerBaseArgs(docker).join(' ')} rm -f ${shellescape(docker.containerName)}`;
}
/**
* Resolve the environment-dependent bits of a docker launch (host uid, existing
* credential mounts, derived api url, hook-secret mount, Desktop detection) into
* the pure buildDockerLaunchCommand inputs. IO; only ever called from the real
* launch path (createSession/respawnPane no-op under VITEST).
*/
export function resolveDockerLaunchOptions(
mode: SessionMode,
docker: SessionDocker,
sessionId: string,
resumeSessionId?: string
): DockerLaunchOptions {
const home = homedir();
const isDesktop = process.platform === 'darwin'; // Docker Desktop translates uids + native host.docker.internal
const uid = typeof process.getuid === 'function' ? process.getuid() : 1000;
const userArgs: string[] =
docker.engine === 'podman'
? ['--userns=keep-id'] // rootless podman: map host uid to the image `agent` uid
: isDesktop
? [] // Desktop: run as the image's baked uid (a mac uid wouldn't own /home/agent)
: ['--user', `${uid}:0`]; // Linux: host uid + GID 0 (OpenShift arbitrary-uid writable HOME)
const gatewayAlias = hostGatewayAlias(docker.engine);
const credentialMounts: DockerMount[] = [];
const extraMounts: DockerMount[] = [];
// Isolated credential state (Claude + codex/gemini/gcloud/opencode): each store
// shares ONLY what a host feature / --resume needs (Claude projects/, codex
// sessions/+history) and seeds everything else (tokens, settings, configs) as
// writable copies, so the container is authed WITHOUT re-auth and WITHOUT writing
// its runtime state back into the host dirs. Only when credentials are mounted.
let seedCopies: DockerSeedCopy[] = [];
if (docker.mountCredentials) {
const claudeArtifacts = resolveDockerClaudeArtifacts(home, docker.containerName, docker.containerWorkdir);
const credArtifacts = resolveDockerCredentialArtifacts(home);
extraMounts.push(...claudeArtifacts.mounts, ...credArtifacts.mounts);
seedCopies = [...claudeArtifacts.seedCopies, ...credArtifacts.seedCopies];
}
const envCreate: Record<string, string> = {
HOME: CONTAINER_HOME,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
// Force a UTF-8 locale (the base image defaults to POSIX/C). Without this, tmux
// runs in non-UTF-8 mode and renders Claude's Unicode box-drawing (─│┌┐) as raw
// VT100 ACS glyphs (`qqqq…`). `C.UTF-8` is built into glibc (no locale-gen).
LANG: 'C.UTF-8',
LC_ALL: 'C.UTF-8',
// Give claude a temp dir it will own inside HOME. Its default `/tmp/claude-<uid>`
// is refused when that path pre-exists root-owned — which happens when the
// workspace bind-mount path traverses it (e.g. a workspace under /tmp/claude-<uid>).
// A nonexistent HOME subpath is created+owned by the running uid, so this is robust
// to any workspace location. Non-secret path, safe to be committed on export.
CLAUDE_CODE_TMPDIR: `${CONTAINER_HOME}/.cache/codeman-claude-tmp`,
};
if (docker.hooksEnabled) {
// Derive a container-reachable API url (scheme + port preserved; host swapped
// for the engine gateway alias). Prod is HTTPS on 3000.
envCreate.CODEMAN_API_URL = containerApiUrl(process.env.CODEMAN_API_URL, docker.engine);
const hookSecretPath = dataPath('hook-secret');
if (existsSync(hookSecretPath)) {
const dst = `${CONTAINER_HOME}/.codeman/hook-secret`;
extraMounts.push({ src: hookSecretPath, dst, readonly: true });
envCreate.CODEMAN_HOOK_SECRET_FILE = dst; // a path is non-secret; the bytes ride the bind mount
}
}
const createContext: DockerCreateContext = {
docker,
sessionId,
instance: CODEMAN_INSTANCE,
userArgs,
credentialMounts,
extraMounts,
envCreate,
addHostGateway: !isDesktop,
gatewayAlias,
};
const execEnv: Record<string, string> = {
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
// UTF-8 at exec time too, so the tmux CLIENT this exec launches is UTF-8 and
// renders box-drawing correctly even when reattaching to a container created
// before this fix (client_utf8 is per-client, resolved from the exec's locale).
LANG: 'C.UTF-8',
LC_ALL: 'C.UTF-8',
CODEMAN_SESSION_ID: sessionId.slice(0, 8),
CODEMAN_MUX: '1',
};
// NAME-ONLY exec env forwarded from Codeman's process env (the docker client
// inherits it), so API-key CLIs get their key without it appearing in argv.
const execEnvNames =
mode === 'codex'
? ['OPENAI_API_KEY', 'CODEX_API_KEY']
: mode === 'gemini'
? ['GEMINI_API_KEY', 'GOOGLE_API_KEY']
: [];
return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies };
}
/**
* COD-105 — build the SSH command that ATTACHES to an EXISTING `codeman-*` tmux
* session on the remote host (one this Codeman didn't create — discovered via
* `listRemoteCodemanSessions`). Sibling of `buildRemoteLaunchCommand`.
*
* Emits:
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
* 'tmux -L codeman attach -t <session>'
*
* - `attach` (NOT `new-session -A`) so we only join an existing session; the
* remote session keeps running independent of us, which is exactly why the
* resulting Codeman session is NON-OWNED (see `SessionRemote.owned`): closing
* the local tab must detach, never `kill-session` the remote.
* - The remote session name is shell-escaped so a value with metachars stays a
* single token inside the quoted tmux invocation.
* - COD-107 — connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
* arbitrary `-o`) come from the shared `buildSshConnectionArgs`, so attach
* connects identically to launch / discovery / the prereq probe. `-t` sits
* right after `ssh -o BatchMode=yes` (a PTY is required for interactive tmux).
*/
export function buildRemoteAttachCommand(remote: SessionRemote, remoteSessionName: string): string {
const tmuxInvocation = `tmux -L codeman attach -t ${shellescape(remoteSessionName)}`;
const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote);
const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)];
return sshParts.join(' ');
}
/**
* COD-105 — choose the right remote ssh command for a session's ownership:
* - NON-owned (`remote.owned === false`): ATTACH to a discovered remote tmux
* session by its EXISTING name (`remote.remoteSessionName`, falling back to
* this session's deterministic name). We only join — never create.
* - owned (default): LAUNCH/attach-or-create via `buildRemoteLaunchCommand`
* (COD-104), which we then own and may explicitly kill.
*/
function buildRemoteSessionCommand(options: {
mode: SessionMode;
remote: SessionRemote;
sessionId: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
}): string {
const { remote, sessionId } = options;
if (remote.owned === false) {
const target = remote.remoteSessionName || remoteTmuxSessionName(sessionId);
return buildRemoteAttachCommand(remote, target);
}
return buildRemoteLaunchCommand(options);
}
/**
* 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 */
}
}
}
}
2026-05-16 20:10:49 -04:00
/**
* 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 {
2026-05-16 20:10:49 -04:00
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}'`, {
2026-05-16 20:10:49 -04:00
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 */
}
}
}
2026-02-12 10:29:57 +01:00
/**
* Manages tmux sessions that wrap Claude CLI or shell processes.
*
2026-02-18 12:51:35 +01:00
* Implements the TerminalMultiplexer interface.
2026-02-12 10:29:57 +01:00
*
* @example
* ```typescript
* const manager = new TmuxManager();
*
* // Create a tmux session for Claude
* const session = await manager.createSession({ sessionId, workingDir: '/project', mode: 'claude' });
2026-02-12 10:29:57 +01:00
*
* // 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();
2026-02-12 10:29:57 +01:00
private statsInterval: NodeJS.Timeout | null = null;
2026-02-18 10:23:10 +01:00
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();
2026-02-12 10:29:57 +01:00
// ── COD-108 remote-reconnect watcher state ────────────────────────────────
/** Periodic watcher that re-establishes dropped remote sessions. */
private remoteReconnectInterval: NodeJS.Timeout | null = null;
/** Per-session backoff/attempt bookkeeping (sessionId → state). */
private reconnectState: Map<string, RemoteReconnectState> = new Map();
/**
* Sessions excluded from auto-reconnect because they are being intentionally
* torn down (killed/detached/stopping). A guarded session is NEVER revived.
*/
private reconnectGuard: Set<string> = new Set();
2026-02-12 11:35:10 +01:00
private trueColorConfigured = false;
2026-02-12 10:29:57 +01:00
constructor() {
super();
this.setMaxListeners(50);
if (!IS_TEST_MODE) {
this.loadSessions();
}
2026-02-12 10:29:57 +01:00
}
/** 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)
2026-02-12 10:29:57 +01:00
private loadSessions(): void {
if (IS_TEST_MODE) return;
2026-02-12 10:29:57 +01:00
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
2026-02-12 10:29:57 +01:00
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++;
}
2026-02-12 10:29:57 +01:00
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();
2026-02-12 10:29:57 +01:00
}
}
}
} catch (err) {
console.error('[TmuxManager] Failed to load sessions:', err);
}
}
/**
* Save sessions to disk asynchronously. (NEVER writes in test mode)
2026-02-12 20:14:01 +01:00
* Uses atomic temp+rename to prevent corruption on crash.
2026-02-12 10:29:57 +01:00
*/
private saveSessions(): void {
if (IS_TEST_MODE) return;
2026-02-12 10:29:57 +01:00
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);
2026-02-12 20:14:01 +01:00
const tempPath = MUX_SESSIONS_FILE + '.tmp';
writeFile(tempPath, json, 'utf-8')
.then(() => rename(tempPath, MUX_SESSIONS_FILE))
.catch((err) => {
2026-02-12 10:29:57 +01:00
console.error('[TmuxManager] Failed to save sessions:', err);
2026-02-12 20:14:01 +01:00
});
2026-02-12 10:29:57 +01:00
} 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',
2026-08-04 12:59:40 +02:00
mode === 'codex' || mode === 'gemini' || mode === 'antigravity'
? 'export COLORTERM=truecolor'
: 'unset COLORTERM',
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' ? ['unset NO_COLOR'] : []),
// Stamp each Codex pane with a unique originator so the response-viewer
// can locate THIS pane's rollout exactly — codex writes the value into
// session_meta.originator of every rollout it creates. Without it,
// rollouts are matched by cwd+mtime and two panes in the same directory
// bleed into each other.
...(mode === 'codex' ? [`export CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_${sessionId}`] : []),
'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
2026-08-09 01:18:03 +02:00
// Only exported when the server has stamped the real URL (scheme+host+port,
// set in WebServer.start()). A hardcoded fallback here exported the wrong
// scheme on HTTPS installs; leaving the variable unset makes in-session
// guards fail closed instead of curling a URL that was never right.
...(process.env.CODEMAN_API_URL ? [`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL}`] : []),
// 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 };
}
2026-05-16 20:10:49 -04:00
if (mode === 'gemini') {
const dir = resolveGeminiDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
2026-08-04 12:59:40 +02:00
if (mode === 'antigravity') {
const dir = resolveAntigravityDir();
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);
}
2026-05-16 20:10:49 -04:00
/**
* Configure Gemini-specific environment on a tmux session.
*/
private _configureGemini(muxName: string): void {
setGeminiEnvVars(this.tmux(), muxName);
2026-05-16 20:10:49 -04:00
}
2026-02-12 10:29:57 +01:00
/**
* Creates a new tmux session wrapping Claude CLI or a shell.
* In test mode: creates an in-memory session only (no real tmux session).
2026-02-12 10:29:57 +01:00
*/
async createSession(options: CreateSessionOptions): Promise<MuxSession> {
const {
sessionId,
workingDir,
mode,
name,
niceConfig,
model,
claudeMode,
allowedTools,
openCodeConfig,
codexConfig,
2026-05-16 20:10:49 -04:00
geminiConfig,
2026-08-04 12:59:40 +02:00
antigravityConfig,
resumeSessionId,
envOverrides,
effort,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
docker,
owner,
} = options;
2026-02-26 16:44:34 +01:00
const muxName = `codeman-${sessionId.slice(0, 8)}`;
2026-02-12 10:29:57 +01:00
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,
remote,
docker,
owner,
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');
2026-02-24 13:30:17 +01:00
}
2026-05-16 20:10:49 -04:00
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');
}
2026-08-04 12:59:40 +02:00
if (mode === 'antigravity' && !cliDir) {
throw new Error(
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
);
}
2026-02-12 10:29:57 +01:00
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
2026-02-12 10:29:57 +01:00
const baseCmd = buildSpawnCommand({
mode,
sessionId,
model,
claudeMode,
allowedTools,
openCodeConfig,
codexConfig,
2026-05-16 20:10:49 -04:00
geminiConfig,
2026-08-04 12:59:40 +02:00
antigravityConfig,
resumeSessionId,
effort,
sessionName: name,
});
2026-02-12 10:29:57 +01:00
const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config);
try {
// Build the full command to run inside tmux
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
const fullCmd = docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
: localFullCmd;
2026-02-12 10:29:57 +01:00
2026-02-24 13:34:30 +01:00
// 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.
2026-06-08 11:18:14 -04:00
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);
2026-02-12 10:29:57 +01:00
2026-02-24 13:34:30 +01:00
// Set remain-on-exit now that the server is running — must be before respawn-pane
2026-02-24 13:30:17 +01:00
try {
execSync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, {
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
} catch {
/* Non-critical */
}
2026-02-24 13:30:17 +01:00
// 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);
}
2026-05-16 20:10:49 -04:00
// 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);
2026-06-08 11:18:14 -04:00
// 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 = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
2026-06-08 11:18:14 -04:00
execSync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
{
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
}
);
2026-02-24 13:30:17 +01:00
2026-02-24 13:23:00 +01:00
// Wait for tmux session to be queryable
await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
2026-02-12 10:29:57 +01:00
// Non-critical tmux config — run in parallel to avoid blocking event loop.
2026-02-18 10:23:10 +01:00
// 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>[] = [
2026-02-26 16:44:34 +01:00
// 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 */
}),
2026-02-24 13:25:26 +01:00
// 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 */
}),
2026-05-11 22:41:11 +02:00
// Raise tmux scrollback from its 2000-line default so re-attach preserves
// more context. Intentionally exceeds the xterm-side DEFAULT_SCROLLBACK (50k
// in constants.js), which stays lower to protect browser/mobile memory.
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit ${historyLimit}`, {
timeout: EXEC_TIMEOUT_MS,
})
2026-05-11 22:41:11 +02:00
.then(() => {})
.catch(() => {
/* Non-critical — falls back to tmux default */
}),
];
2026-02-12 10:29:57 +01:00
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
2026-02-12 11:35:10 +01:00
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 */
})
);
2026-02-12 11:35:10 +01:00
}
// 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);
2026-02-24 13:23:00 +01:00
// 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));
2026-02-24 13:23:00 +01:00
pid = this.getPanePid(muxName);
}
2026-02-12 10:29:57 +01:00
if (!pid) {
throw new Error('Failed to get tmux pane PID');
}
const session: MuxSession = {
sessionId,
muxName,
pid,
createdAt: Date.now(),
workingDir,
remote,
docker,
owner,
2026-02-12 10:29:57 +01:00
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;
2026-02-12 10:29:57 +01:00
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();
2026-02-12 10:29:57 +01:00
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,
2026-05-16 20:10:49 -04:00
geminiConfig,
2026-08-04 12:59:40 +02:00
antigravityConfig,
resumeSessionId,
envOverrides,
effort,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
docker,
name,
} = 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,
2026-05-16 20:10:49 -04:00
geminiConfig,
2026-08-04 12:59:40 +02:00
antigravityConfig,
resumeSessionId,
effort,
sessionName: name,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config);
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
const fullCmd = docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools })
: localFullCmd;
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);
}
2026-05-16 20:10:49 -04:00
// 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);
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
2026-06-08 11:18:14 -04:00
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;
}
}
2026-02-12 10:29:57 +01:00
private sessionExists(muxName: string): boolean {
if (IS_TEST_MODE) return false;
if (!isValidMuxName(muxName)) return false;
2026-02-12 10:29:57 +01:00
try {
execSync(`${this.tmux()} has-session -t "${muxName}" 2>/dev/null`, {
2026-02-12 10:29:57 +01:00
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
return true;
} catch {
return false;
}
}
/** One `ps` snapshot of the whole process table, cached briefly. */
private static procSnapshot: { at: number; byParent: Map<number, number[]> } | null = null;
/** Single-flight guard so a hung `ps` cannot pile up parallel refreshes. */
private static procRefresh: { started: number; promise: Promise<Map<number, number[]>> } | null = null;
/**
* Fork ONE `ps` asynchronously and cache the parent -> children map.
*
* Async on purpose: a synchronous fork here would block the event loop on every
* stats tick, and under the procfs pathology this module exists to survive,
* `execSync`'s timeout cannot return at all (spawnSync waits for the unkillable
* child) — freezing the whole server where a hung async poll only costs staleness.
*/
private static refreshProcSnapshot(): Promise<Map<number, number[]>> {
const inFlight = TmuxManager.procRefresh;
// Reuse an in-flight refresh — unless it is old enough to be presumed stuck.
if (inFlight && Date.now() - inFlight.started < EXEC_TIMEOUT_MS * 2) return inFlight.promise;
const started = Date.now();
const promise = new Promise<Map<number, number[]>>((resolve) => {
execFile('ps', ['-eo', 'pid=,ppid='], { timeout: EXEC_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (err, out) => {
if (TmuxManager.procRefresh?.started === started) TmuxManager.procRefresh = null;
if (err) {
// ANY error, not just an empty one: a timed-out or truncated `ps` yields
// partial output, and caching that as fresh would make whole subtrees
// invisible — including to the kill path. Stale beats wrong.
console.error('[TmuxManager] process snapshot failed:', err);
resolve(TmuxManager.procSnapshot?.byParent ?? new Map());
return;
2026-02-12 10:29:57 +01:00
}
const byParent = new Map<number, number[]>();
for (const line of String(out).split('\n')) {
const parts = line.trim().split(/\s+/);
if (parts.length < 2) continue;
const pid = parseInt(parts[0], 10);
const ppid = parseInt(parts[1], 10);
if (Number.isNaN(pid) || Number.isNaN(ppid)) continue;
const list = byParent.get(ppid);
if (list) list.push(pid);
else byParent.set(ppid, [pid]);
}
TmuxManager.procSnapshot = { at: Date.now(), byParent };
resolve(byParent);
});
});
TmuxManager.procRefresh = { started, promise };
return promise;
}
/**
* Best snapshot WITHOUT forking: returns the cache, kicking off a background
* refresh when it has gone stale, and never blocks. Stats and window-title
* consumers tolerate data one interval old; nothing that KILLS may use this.
*/
private childrenByParent(): Map<number, number[]> {
const cached = TmuxManager.procSnapshot;
if (!cached || Date.now() - cached.at >= PROC_SNAPSHOT_TTL_MS) {
void TmuxManager.refreshProcSnapshot();
2026-02-12 10:29:57 +01:00
}
return cached?.byParent ?? new Map();
}
/**
* Descendants from a snapshot that is not the cached one — the kill path's variant.
*
* killSession re-scans for survivors between SIGTERM and SIGKILL, and the wait in
* between (200ms) sits far inside the cache TTL (2000ms): reading the cache there
* returns the pre-SIGTERM state verbatim, so children spawned since are invisible
* and SIGKILL aims at stale PIDs, guarded only by kill(pid, 0) — which cannot
* detect PID reuse.
*
* It forces a refresh rather than guaranteeing recency: an already-running refresh
* is reused, so the snapshot can predate this call by up to one `ps` runtime. A
* strict postdate guarantee would mean chaining a second `ps` behind every
* in-flight one, which is the fork storm this code exists to avoid.
*
* Bounded by design: waiting forever would freeze killSession before it reaches
* its process-group and tmux fallbacks.
*/
private async getChildPidsFresh(pid: number): Promise<number[]> {
let byParent: ReadonlyMap<number, readonly number[]>;
try {
byParent = await Promise.race([
TmuxManager.refreshProcSnapshot(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('proc snapshot timeout')), PROC_SNAPSHOT_WAIT_MS)
),
]);
} catch {
console.warn('[TmuxManager] process snapshot did not return in time; using the cached one');
byParent = TmuxManager.procSnapshot?.byParent ?? new Map<number, number[]>();
}
return collectDescendants(pid, byParent, {
onTruncated: (root, cap, reason) =>
console.warn(`[TmuxManager] descendant walk for ${root} hit the ${cap}-${reason} cap; truncating`),
});
2026-02-12 10:29:57 +01:00
}
// 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;
2026-02-12 10:29:57 +01:00
if (aliveCount === 0) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, checkInterval));
2026-02-12 10:29:57 +01:00
}
const stillAlive = pids.filter((pid) => this.isProcessAlive(pid));
2026-02-12 10:29:57 +01:00
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.
2026-02-18 12:51:35 +01:00
* Uses a 4-strategy approach (children → process group → tmux kill → SIGKILL).
* In test mode: removes from memory only (no real kill).
2026-02-12 10:29:57 +01:00
*/
async killSession(sessionId: string): Promise<boolean> {
const session = this.sessions.get(sessionId);
if (!session) {
return false;
}
// COD-108: an intentional kill/detach must NEVER be auto-revived by the
// remote-reconnect watcher. Guard BEFORE any teardown so a tick that fires
// mid-kill (especially the non-owned DETACH early-return below, where the
// dead local pane would otherwise look reconnectable) sees the guard.
this.guardRemoteReconnect(sessionId);
// TEST MODE: Remove from memory only — NEVER touch real tmux sessions
if (IS_TEST_MODE) {
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
this.emit('sessionKilled', { sessionId });
return true;
}
2026-02-12 11:35:10 +01:00
// SAFETY: Never kill the tmux session we're running inside of
2026-02-26 16:44:34 +01:00
const currentMuxName = process.env.CODEMAN_MUX_NAME;
2026-02-12 11:35:10 +01:00
if (currentMuxName && session.muxName === currentMuxName) {
console.error(`[TmuxManager] BLOCKED: Refusing to kill own tmux session: ${session.muxName}`);
return false;
}
// COD-105 — DETACH-NOT-KILL for NON-owned remote sessions.
//
// When this session was created by ATTACHING a remote tmux session another
// Codeman owns (`remote.owned === false`), closing the tab must NOT propagate
// a remote `tmux kill-session` — that would nuke work the remote's own
// Codeman (or another instance) still relies on. We tear down ONLY the LOCAL
// pane that holds the ssh client: killing the local ssh sends SIGHUP to its
// remote `tmux attach`, which DETACHES (the durable remote session survives).
//
// This early return is the structural guarantee: no code below this point
// (now or in future for owned sessions) can ever issue a remote kill-session
// for a non-owned session. The only `kill-session` we run is on OUR LOCAL
// socket (`this.tmux()` = `tmux -L codeman` on THIS host), which kills the
// local pane — it does NOT reach the REMOTE socket.
if (session.remote && session.remote.owned === false) {
console.log(`[TmuxManager] DETACH (non-owned remote): tearing down local pane only for ${session.muxName}`);
if (isValidMuxName(session.muxName)) {
try {
// Local socket only — detaches the remote session by killing the local ssh pane.
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
// Local pane may already be gone.
}
}
this.lastPaneCount.delete(session.muxName);
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
this.saveSessions();
this.emit('sessionKilled', { sessionId });
return true;
}
2026-02-12 10:29:57 +01:00
// 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 = await this.getChildPidsFresh(currentPid);
2026-02-12 10:29:57 +01:00
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));
2026-02-12 10:29:57 +01:00
childPids = await this.getChildPidsFresh(currentPid);
2026-02-12 10:29:57 +01:00
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));
2026-02-12 10:29:57 +01:00
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
}
2026-02-12 10:29:57 +01:00
}
// Strategy 3b: Remote sessions run a DURABLE tmux server on the remote host
// (survives ssh drops), so killing only the local ssh wrapper above would
// orphan the remote agent forever. Fire a best-effort `ssh … tmux kill-session`
// — fire-and-forget so it NEVER blocks or throws the local kill (bounded by the
// shared ConnectTimeout on an unreachable host).
if (session.remote) {
try {
const remoteKillCmd = buildRemoteKillCommand({ remote: session.remote, sessionId });
exec(remoteKillCmd, { timeout: EXEC_TIMEOUT_MS }, () => {});
} catch {
// Best-effort — a failure here must not affect the local kill result.
}
}
// Strategy 3c: Docker sessions run a DURABLE in-container tmux session. Kill
// ONLY this session's in-container tmux session (best-effort). The container is
// PER-CASE and shared by the case's other sessions, so we deliberately do NOT
// `docker stop` it here — stopping/removing is an explicit teardown/case-delete.
if (session.docker && !IS_TEST_MODE) {
try {
exec(buildDockerKillCommand({ docker: session.docker, sessionId }), { timeout: EXEC_TIMEOUT_MS }, () => {});
} catch {
// Best-effort — never affects the local kill result.
}
}
2026-02-12 10:29:57 +01:00
// 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}`);
}
2026-02-18 10:23:10 +01:00
this.lastPaneCount.delete(session.muxName);
2026-02-12 10:29:57 +01:00
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
2026-02-12 10:29:57 +01:00
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: [],
};
}
2026-02-12 10:29:57 +01:00
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.
2026-02-12 10:29:57 +01:00
for (const [sessionId, session] of this.sessions) {
const pid = active.get(session.muxName);
if (pid !== undefined) {
2026-02-12 10:29:57 +01:00
alive.push(sessionId);
if (pid !== session.pid) session.pid = pid;
2026-02-12 10:29:57 +01:00
} else {
dead.push(sessionId);
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
2026-02-12 10:29:57 +01:00
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);
}
2026-02-12 10:29:57 +01:00
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;
2026-02-12 10:29:57 +01:00
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})`);
2026-02-12 10:29:57 +01:00
}
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() };
2026-02-12 10:29:57 +01:00
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();
2026-02-12 10:29:57 +01:00
const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
2026-02-12 10:29:57 +01:00
// From the shared snapshot: this runs per session on every stats tick, and a
// pgrep per session was a fork per session per interval.
2026-02-12 10:29:57 +01:00
let childCount = 0;
try {
childCount = (this.childrenByParent().get(session.pid) ?? []).length;
2026-02-12 10:29:57 +01:00
} catch {
// No children or snapshot unavailable
2026-02-12 10:29:57 +01:00
}
return {
memoryMB: Math.round((rss / 1024) * 10) / 10,
2026-02-12 10:29:57 +01:00
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() },
}));
}
2026-02-12 10:29:57 +01:00
const sessions = Array.from(this.sessions.values());
if (sessions.length === 0) {
return [];
}
const sessionPids = sessions.map((s) => s.pid);
2026-02-12 10:29:57 +01:00
const statsMap = new Map<number, ProcessStats>();
try {
// Step 1: Get descendant PIDs
const descendantMap = new Map<number, number[]>();
// Derived from the ONE snapshot instead of a shell loop that forks a pgrep
// per session — the shape that turned into a fork storm under load.
const byParent = this.childrenByParent();
const childLines = sessionPids.map((p) => `${p}:${(byParent.get(p) ?? []).join(',')}`).join('\n');
2026-02-12 10:29:57 +01:00
for (const line of childLines.split('\n')) {
2026-02-12 10:29:57 +01:00
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);
2026-02-12 10:29:57 +01:00
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();
2026-02-12 10:29:57 +01:00
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,
2026-02-12 10:29:57 +01:00
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));
2026-02-12 10:29:57 +01:00
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) => ({
2026-02-12 10:29:57 +01:00
...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;
}
}
2026-02-18 10:23:10 +01:00
/**
* Start periodic mouse mode sync for all tracked sessions.
* Polls pane counts every 5s and toggles mouse on/off as needed.
2026-02-19 08:49:36 +01:00
* 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.
2026-02-18 10:23:10 +01:00
*/
startMouseModeSync(intervalMs: number = 5000): void {
if (this.mouseSyncInterval) {
clearInterval(this.mouseSyncInterval);
}
this.mouseSyncInterval = setInterval(async () => {
2026-02-18 10:23:10 +01:00
if (IS_TEST_MODE) return;
2026-02-19 08:49:36 +01:00
2026-02-18 10:23:10 +01:00
for (const session of this.sessions.values()) {
const panes = await this.listPanes(session.muxName);
2026-02-18 10:23:10 +01:00
const count = panes.length;
2026-02-19 08:49:36 +01:00
if (count === 0) continue;
2026-02-18 10:23:10 +01:00
2026-02-19 08:49:36 +01:00
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)) {
2026-02-19 08:49:36 +01:00
this.lastPaneCount.set(session.muxName, count);
}
// If enableMouseMode fails, DON'T update lastPaneCount — retry next poll
} else {
if (await this.disableMouseMode(session.muxName)) {
2026-02-19 08:49:36 +01:00
this.lastPaneCount.set(session.muxName, count);
2026-02-18 10:23:10 +01:00
}
}
}
}, intervalMs);
}
stopMouseModeSync(): void {
if (this.mouseSyncInterval) {
clearInterval(this.mouseSyncInterval);
this.mouseSyncInterval = null;
}
this.lastPaneCount.clear();
}
// ── COD-108 remote-session auto-reconnect watcher ─────────────────────────
/**
* Start the remote-reconnect watcher (COD-108). Each tick, for every tracked
* session with `session.remote` whose local pane is DEAD, not intentionally
* guarded, and within its backoff budget, emit `remoteSessionDropped` so the
* session owner reattaches (re-running the idempotent remote command rejoins
* the durable remote tmux session). After the attempt cap, emit
* `remoteReconnectExhausted` once and go quiet.
*
* No-op tick body under `IS_TEST_MODE` (mirrors `startMouseModeSync`): tests
* drive the logic deterministically via {@link runRemoteReconnectTick}.
*/
startRemoteReconnectWatcher(intervalMs: number = DEFAULT_REMOTE_RECONNECT_INTERVAL_MS): void {
if (this.remoteReconnectInterval) {
clearInterval(this.remoteReconnectInterval);
}
this.remoteReconnectInterval = setInterval(() => {
if (IS_TEST_MODE) return;
try {
this.runRemoteReconnectTick(Date.now(), isRemoteAutoReconnectEnabled());
} catch (err) {
console.error('[TmuxManager] Remote reconnect watcher error:', err);
}
}, intervalMs);
}
stopRemoteReconnectWatcher(): void {
if (this.remoteReconnectInterval) {
clearInterval(this.remoteReconnectInterval);
this.remoteReconnectInterval = null;
}
}
/**
* Run ONE watcher tick. Extracted (and given an injected `now`/`enabled`) so
* the reconnect logic is deterministically testable even though the live
* `setInterval` body no-ops under test mode. For each remote session it
* applies the pure {@link decideReconnect} decision and translates the result
* into events + backoff/state transitions. Public for tests + the watcher.
*/
runRemoteReconnectTick(now: number, enabled: boolean): void {
for (const session of this.sessions.values()) {
if (!session.remote) continue;
const sessionId = session.sessionId;
const state = this.reconnectState.get(sessionId);
const action = decideReconnect({
session: {
sessionId,
isRemote: true,
paneDead: this.isPaneDead(session.muxName),
},
state,
guarded: this.reconnectGuard.has(sessionId),
enabled,
now,
});
if (action.kind === 'emit') {
const base = state ?? freshReconnectState();
// Mark in-flight + advance backoff BEFORE emitting so a re-entrant tick
// (or a synchronous listener) can never stack a second reconnect.
this.reconnectState.set(sessionId, { ...advanceBackoff(base, now), inFlight: true });
this.emit('remoteSessionDropped', { sessionId, attempt: action.attempt });
} else if (action.kind === 'exhaust') {
const base = state ?? freshReconnectState();
if (!base.exhaustedEmitted) {
this.reconnectState.set(sessionId, { ...base, exhausted: true, exhaustedEmitted: true });
this.emit('remoteReconnectExhausted', { sessionId });
}
}
// 'skip' → nothing to do.
}
}
/**
* Tell the watcher a reattach attempt for `sessionId` finished. On success,
* reset the backoff so the session is healthy again; on failure, just clear
* the in-flight flag so the next due tick can retry under the existing
* backoff schedule. Called by the session owner after `respawnPane`.
*/
noteRemoteReconnect(sessionId: string, success: boolean): void {
if (success) {
this.reconnectState.set(sessionId, resetReconnectState());
return;
}
const state = this.reconnectState.get(sessionId);
if (state) this.reconnectState.set(sessionId, { ...state, inFlight: false });
}
/**
* Exclude a session from auto-reconnect (intentional teardown). Adds it to the
* guard set and drops any backoff state so a closed/killed tab — especially a
* non-owned remote DETACH — is never auto-revived. Idempotent.
*/
guardRemoteReconnect(sessionId: string): void {
this.reconnectGuard.add(sessionId);
this.reconnectState.delete(sessionId);
}
/** Clear all per-session reconnect + guard state (e.g. when a session is removed). */
clearRemoteReconnectState(sessionId: string): void {
this.reconnectState.delete(sessionId);
this.reconnectGuard.delete(sessionId);
}
2026-02-12 10:29:57 +01:00
destroy(): void {
this.stopStatsCollection();
2026-02-18 10:23:10 +01:00
this.stopMouseModeSync();
this.stopRemoteReconnectWatcher();
this.reconnectState.clear();
this.reconnectGuard.clear();
2026-02-12 10:29:57 +01:00
}
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);
}
2026-02-12 10:29:57 +01:00
/**
* Send input directly to a tmux session using `send-keys`.
*
2026-02-18 12:51:35 +01:00
* Uses tmux send-keys for reliable input delivery:
2026-02-12 10:29:57 +01:00
* - `-l` flag sends literal text (no key interpretation)
2026-02-20 04:11:49 +01:00
* - `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
2026-02-12 10:29:57 +01:00
*/
async sendInput(sessionId: string, input: string): Promise<boolean> {
2026-02-12 10:29:57 +01:00
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(', ')}`
);
2026-02-12 10:29:57 +01:00
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')}`
);
2026-02-12 10:29:57 +01:00
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) {
2026-02-20 04:11:49 +01:00
// 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,
});
2026-02-12 10:29:57 +01:00
} else if (textPart) {
// Text only, no Enter
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
2026-02-12 10:29:57 +01:00
} else if (hasCarriageReturn) {
// Enter only
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
2026-02-12 10:29:57 +01:00
}
return true;
} catch (err) {
console.error('[TmuxManager] Failed to send input:', err);
return false;
}
}
2026-02-12 13:44:07 +01:00
// ========== 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.
2026-02-18 10:23:10 +01:00
* When mouse mode is on, tmux intercepts mouse events (slow selection, no browser copy).
2026-02-12 13:44:07 +01:00
*/
async enableMouseMode(muxName: string): Promise<boolean> {
if (IS_TEST_MODE) return true;
2026-02-12 13:44:07 +01:00
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,
});
2026-02-19 08:49:36 +01:00
console.log(`[TmuxManager] Mouse mode ON for ${muxName}`);
2026-02-12 13:44:07 +01:00
return true;
2026-02-19 08:49:36 +01:00
} catch (err) {
console.error(`[TmuxManager] Failed to enable mouse mode for ${muxName}:`, err);
2026-02-12 13:44:07 +01:00
return false;
}
}
2026-02-18 10:23:10 +01:00
/**
* Disable mouse mode for an existing tmux session.
* Restores native xterm.js text selection and browser clipboard copy.
*/
async disableMouseMode(muxName: string): Promise<boolean> {
2026-02-18 10:23:10 +01:00
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,
});
2026-02-19 08:49:36 +01:00
console.log(`[TmuxManager] Mouse mode OFF for ${muxName}`);
2026-02-18 10:23:10 +01:00
return true;
2026-02-19 08:49:36 +01:00
} catch (err) {
console.error(`[TmuxManager] Failed to disable mouse mode for ${muxName}:`, err);
2026-02-18 10:23:10 +01:00
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> {
2026-02-18 10:23:10 +01:00
if (IS_TEST_MODE) return true;
const panes = await this.listPanes(muxName);
2026-02-18 10:23:10 +01:00
if (panes.length > 1) {
return this.enableMouseMode(muxName);
} else {
return this.disableMouseMode(muxName);
}
}
2026-02-12 13:44:07 +01:00
/**
* List all panes in a tmux session.
* Returns structured info for each pane.
*/
async listPanes(muxName: string): Promise<PaneInfo[]> {
if (IS_TEST_MODE) return [];
2026-02-12 13:44:07 +01:00
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();
2026-02-12 13:44:07 +01:00
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));
2026-02-12 13:44:07 +01:00
} 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;
2026-02-12 13:44:07 +01:00
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;
}
2026-02-26 16:44:34 +01:00
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
const tmux = this.tmux();
2026-02-12 13:44:07 +01:00
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,
});
2026-02-12 13:44:07 +01:00
} else if (textPart) {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
2026-02-12 13:44:07 +01:00
} else if (hasCarriageReturn) {
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
2026-02-12 13:44:07 +01:00
}
return true;
} catch (err) {
console.error('[TmuxManager] Failed to send input to pane:', err);
return false;
}
}
/**
* Capture a pane's text and SGR styles.
*
* Two modes:
* - Visible (default): `capture-pane -p -e` grabs only the on-screen frame,
* then `formatPaneSnapshot` repaints each row at its absolute position so
* the browser xterm reproduces the live frame. Used for fast tab switches.
* - Full history (`opts.fullHistory`): `capture-pane -p -e -J -S -<N>` grabs
* the tmux scrollback (COD-47, bounded to the configured history limit),
* returned as linear scrollback text with SGR codes preserved (NOT
* repositioned — a multi-screen history can't be painted into a single
* visible frame, so the snapshot repaint is skipped). `-J` re-joins lines
* hard-wrapped at the pane width so they reflow in the browser xterm.
* Used for full page reloads so the user gets back their scroll history.
* Caveat: lines tmux has already evicted past its history-limit are gone.
2026-02-12 13:44:07 +01:00
*/
/**
* Plain visible-frame text for the working/idle probe (see `session.ts`).
*
* One `capture-pane` and nothing else: no `-e` styles, no `display-message`
* cursor query, no repaint reconstruction: this feeds a regex, not a
* terminal. Returns null in tests (no tmux) so callers fall back to their
* stream heuristics rather than reading an empty screen as "not working".
*/
capturePaneText(muxName: string, paneTarget?: string): string | null {
if (IS_TEST_MODE) return null;
const target = resolveTmuxPaneTarget(muxName, paneTarget);
if (!target) return null;
try {
return execSync(`${this.tmux()} capture-pane -p -t ${shellescape(target)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} catch {
// A dead/renamed pane is an ordinary outcome here, not an error worth logging
// on a timer; the caller treats null as "no evidence either way".
return null;
}
}
capturePaneBuffer(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null {
if (IS_TEST_MODE) return '';
const target = resolveTmuxPaneTarget(muxName, paneTarget);
if (!target) {
console.error('[TmuxManager] Invalid pane target in capturePaneBuffer:', { muxName, paneTarget });
2026-02-12 13:44:07 +01:00
return null;
}
const fullHistory = opts?.fullHistory === true;
2026-02-12 13:44:07 +01:00
try {
// `-S -<N>` starts the capture N lines above the visible frame (tmux
// clamps to the top of history), so tmux never serializes more scrollback
// than the configured history limit retains.
const requestedLines = opts?.historyLimitLines;
const historyLines =
typeof requestedLines === 'number' && Number.isFinite(requestedLines) && requestedLines > 0
? Math.trunc(requestedLines)
: DEFAULT_TMUX_HISTORY_LIMIT;
const captureFlags = fullHistory ? `capture-pane -p -e -J -S -${historyLines}` : 'capture-pane -p -e';
// execSync's default maxBuffer (1MB) kills multi-MB scrollback dumps
// (ENOBUFS) and would silently degrade full-history capture to the byte
// buffer for exactly the long sessions it exists for — size it from the
// consumer's byte cap plus ANSI-overhead slack instead.
const execOpts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number } = {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
};
if (fullHistory) {
execOpts.maxBuffer =
(opts?.maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES) + FULL_HISTORY_CAPTURE_SLACK_BYTES;
}
const buffer = execSync(`${this.tmux()} ${captureFlags} -t ${shellescape(target)}`, execOpts).replace(
/\n+$/g,
''
);
// Full-history spans many screens — return it as raw linear scrollback
// rather than repainting rows at single-screen absolute positions. tmux
// joins scrollback rows with a bare `\n`; normalize to `\r\n` so a fresh
// xterm (convertEol:false) starts each replayed line at column 0 instead
// of staircasing diagonally (COD-138).
if (fullHistory) {
return normalizeScrollbackEol(buffer);
}
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);
}
// Cursor query failed or geometry was invalid, so we skip the absolute-
// positioned snapshot repaint and fall back to the raw capture. Normalize
// its bare `\n` line endings to `\r\n` so the replay doesn't staircase
// diagonally in a fresh xterm (COD-138, same reason as the fullHistory path).
return normalizeScrollbackEol(buffer);
2026-02-12 13:44:07 +01:00
} catch (err) {
// ENOBUFS carries the truncated multi-MB stdout on the error object —
// log a concise line instead of dumping it into the journal.
if ((err as NodeJS.ErrnoException)?.code === 'ENOBUFS') {
console.error('[TmuxManager] Pane capture exceeded maxBuffer (ENOBUFS); falling back to byte history');
} else {
console.error('[TmuxManager] Failed to capture pane buffer:', err);
}
2026-02-12 13:44:07 +01:00
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, opts?: PaneCaptureOptions): 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, opts) : null;
} catch (err) {
console.error('[TmuxManager] Failed to resolve active pane for capture:', err);
return null;
}
}
2026-02-12 13:44:07 +01:00
/**
* 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;
2026-02-12 13:44:07 +01:00
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}`;
2026-02-12 13:44:07 +01:00
try {
execSync(`${this.tmux()} pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
2026-02-12 13:44:07 +01:00
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;
2026-02-12 13:44:07 +01:00
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}`;
2026-02-12 13:44:07 +01:00
try {
execSync(`${this.tmux()} pipe-pane -t ${shellescape(target)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
2026-02-12 13:44:07 +01:00
return true;
} catch (err) {
console.error('[TmuxManager] Failed to stop pipe-pane:', err);
return false;
}
}
2026-02-12 10:29:57 +01:00
getAttachCommand(): string {
return 'tmux';
}
getAttachArgs(muxName: string): string[] {
return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
2026-02-12 10:29:57 +01:00
}
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;
}
2026-02-12 10:29:57 +01:00
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, "'\\''") + "'";
}