fix(tmux): isolate sessions on a dedicated socket + raise pane nofile limit (fixes new-session crash after tmux upgrade) (#96)

* fix: isolate codeman tmux sessions

* fix(tmux): unify all sessions onto a single dedicated socket

Remove the per-session `tmuxSocket` field that recorded which tmux server
each session lived on (default vs the `codeman` socket). That field was a
persisted cache of physical reality and could drift — causing live sessions
to be wrongly marked dead ("tab shows no session found") and spawning
duplicate "Restored:" tabs.

All Codeman sessions now live on one process-wide socket (`tmux -L codeman`,
overridable via CODEMAN_TMUX_SOCKET), exposed via TmuxManager.muxSocket on
the TerminalMultiplexer interface. reconcileSessions() collapses from a
multi-socket scan (locate / re-pin / cross-socket dedup) to a single
`list-panes` query. loadSessions() strips the obsolete field from on-disk
records so it stops being written back.

Also fix two sibling bare-`tmux` call sites the unification would otherwise
leave broken (same #80 regression class — bare tmux hits the user's default
server and never finds a session on the codeman socket):
- session.ts queryTmuxWindowSize(): add `-L <socket>` (was silently falling
  back to 120x40 on re-attach, losing scrollback)
- session-routes.ts send-key (Shift+Enter / Ctrl+Enter newline): route
  through ctx.mux.muxSocket

SSH chooser scripts (tmux-manager.sh, tmux-chooser.sh) route every tmux call
through `tmux -L $CODEMAN_TMUX_SOCKET`, matching the TS default.

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
This commit is contained in:
Tenggan Zhang
2026-05-25 23:28:58 +02:00
committed by GitHub
co-authored by Teigen
parent 08de6667ab
commit 1ff315a1e6
10 changed files with 287 additions and 93 deletions
+10 -3
View File
@@ -32,6 +32,13 @@ set -e
CODEMAN_STATE="$HOME/.codeman/state.json" CODEMAN_STATE="$HOME/.codeman/state.json"
CODEMAN_SESSIONS="$HOME/.codeman/mux-sessions.json" CODEMAN_SESSIONS="$HOME/.codeman/mux-sessions.json"
# Dedicated tmux socket all Codeman sessions live on. MUST match
# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts —
# otherwise list-sessions would enumerate the user's default tmux server
# (missing the real Codeman sessions, surfacing unrelated ones).
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
# iPhone 17 Pro portrait width (conservative) # iPhone 17 Pro portrait width (conservative)
MAX_WIDTH=44 MAX_WIDTH=44
@@ -286,7 +293,7 @@ parse_sessions() {
# Get PID from tmux # Get PID from tmux
local pid local pid
pid=$(tmux display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0") pid=$("${TMUX_CMD[@]}" display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0")
SESSION_PIDS+=("$pid") SESSION_PIDS+=("$pid")
MUX_NAMES+=("$session_name") MUX_NAMES+=("$session_name")
@@ -314,7 +321,7 @@ parse_sessions() {
fi fi
i=$((i + 1)) i=$((i + 1))
done < <(tmux list-sessions 2>/dev/null || true) done < <("${TMUX_CMD[@]}" list-sessions 2>/dev/null || true)
} }
# ============================================================================ # ============================================================================
@@ -462,7 +469,7 @@ attach_session() {
echo -e "${D}(Ctrl+B D to detach)${R}" echo -e "${D}(Ctrl+B D to detach)${R}"
sleep 0.3 sleep 0.3
tmux attach-session -t "$mux_name" "${TMUX_CMD[@]}" attach-session -t "$mux_name"
return 0 return 0
} }
+17 -8
View File
@@ -20,6 +20,13 @@ REVERSE='\033[7m'
# Use the same path as codeman (src/tmux-manager.ts) # Use the same path as codeman (src/tmux-manager.ts)
SESSIONS_FILE="${HOME}/.codeman/mux-sessions.json" SESSIONS_FILE="${HOME}/.codeman/mux-sessions.json"
# Dedicated tmux socket all Codeman sessions live on. MUST match
# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts —
# otherwise this script would talk to the user's default tmux server and never
# see (or could mis-target) Codeman's sessions.
CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}"
TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET")
# Cached data # Cached data
CACHED_JSON="" CACHED_JSON=""
@@ -92,7 +99,7 @@ declare -A ALIVE_CACHE
check_alive() { check_alive() {
local mux_name=$1 local mux_name=$1
if [[ -z "${ALIVE_CACHE[$mux_name]+x}" ]]; then if [[ -z "${ALIVE_CACHE[$mux_name]+x}" ]]; then
if tmux has-session -t "$mux_name" 2>/dev/null; then if "${TMUX_CMD[@]}" has-session -t "$mux_name" 2>/dev/null; then
ALIVE_CACHE[$mux_name]=1 ALIVE_CACHE[$mux_name]=1
else else
ALIVE_CACHE[$mux_name]=0 ALIVE_CACHE[$mux_name]=0
@@ -111,8 +118,10 @@ kill_session() {
local mux_name=$(get_session_field $idx "muxName") local mux_name=$(get_session_field $idx "muxName")
local pid=$(get_session_field $idx "pid") local pid=$(get_session_field $idx "pid")
# SAFETY: Never kill own tmux session # SAFETY: Never kill own tmux session. Queried on the Codeman socket; if run
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "") # from a session on a different socket this returns empty (no match), which
# is fine — you can't be "inside" a Codeman-socket session you didn't attach to.
local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "")
if [[ -n "$current_session" && "$mux_name" == "$current_session" ]]; then if [[ -n "$current_session" && "$mux_name" == "$current_session" ]]; then
echo -e "${RED}BLOCKED: Cannot kill own tmux session: $mux_name${NC}" echo -e "${RED}BLOCKED: Cannot kill own tmux session: $mux_name${NC}"
return 1 return 1
@@ -120,7 +129,7 @@ kill_session() {
pkill -TERM -P $pid 2>/dev/null pkill -TERM -P $pid 2>/dev/null
kill -TERM -$pid 2>/dev/null kill -TERM -$pid 2>/dev/null
tmux kill-session -t "$mux_name" 2>/dev/null "${TMUX_CMD[@]}" kill-session -t "$mux_name" 2>/dev/null
kill -KILL $pid 2>/dev/null kill -KILL $pid 2>/dev/null
# Remove from JSON # Remove from JSON
@@ -345,7 +354,7 @@ interactive_menu() {
clear clear
echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}" echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}"
sleep 0.3 sleep 0.3
tmux attach-session -t "$mux_name" "${TMUX_CMD[@]}" attach-session -t "$mux_name"
tput civis tput civis
need_full_redraw=1 need_full_redraw=1
force_refresh force_refresh
@@ -474,7 +483,7 @@ main() {
[[ -z "${2:-}" ]] && { echo "Usage: $0 attach <N>"; exit 1; } [[ -z "${2:-}" ]] && { echo "Usage: $0 attach <N>"; exit 1; }
force_refresh force_refresh
local mux_name=$(get_session_field $(($2-1)) "muxName") local mux_name=$(get_session_field $(($2-1)) "muxName")
check_alive "$mux_name" && tmux attach-session -t "$mux_name" || echo "Session dead or not found" check_alive "$mux_name" && "${TMUX_CMD[@]}" attach-session -t "$mux_name" || echo "Session dead or not found"
;; ;;
kill) kill)
[[ -z "${2:-}" ]] && { echo "Usage: $0 kill <N|N,M|N-M>"; exit 1; } [[ -z "${2:-}" ]] && { echo "Usage: $0 kill <N|N,M|N-M>"; exit 1; }
@@ -492,8 +501,8 @@ main() {
;; ;;
kill-all) kill-all)
force_refresh force_refresh
# SAFETY: Never kill own tmux session # SAFETY: Never kill own tmux session (queried on the Codeman socket)
local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "") local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "")
local killed=0 local killed=0
for ((i=CACHED_COUNT-1; i>=0; i--)); do for ((i=CACHED_COUNT-1; i>=0; i--)); do
local mux_name=$(get_session_field $i "muxName") local mux_name=$(get_session_field $i "muxName")
+3
View File
@@ -98,6 +98,9 @@ export interface TerminalMultiplexer extends EventEmitter {
/** Which backend this instance uses */ /** Which backend this instance uses */
readonly backend: 'tmux'; readonly backend: 'tmux';
/** The dedicated tmux socket name all sessions live on (e.g. "codeman"). */
readonly muxSocket: string;
// ========== Lifecycle ========== // ========== Lifecycle ==========
/** /**
+13 -6
View File
@@ -133,15 +133,22 @@ const TMUX_DISPLAY_TIMEOUT_MS = 2000;
* (tmux dead, muxName unknown, malformed output) — caller never has to * (tmux dead, muxName unknown, malformed output) — caller never has to
* differentiate "tmux unreachable" from "size 120x40". * differentiate "tmux unreachable" from "size 120x40".
* *
* `socket` MUST be the same dedicated socket the session lives on (`mux.muxSocket`);
* querying the default server would never find the session and silently fall back.
*
* Argv form (execFileSync, not execSync) keeps `muxName` out of any shell so * Argv form (execFileSync, not execSync) keeps `muxName` out of any shell so
* a hostile session name can't inject options. * a hostile session name can't inject options.
*/ */
export function queryTmuxWindowSize(muxName: string): { cols: number; rows: number } { export function queryTmuxWindowSize(muxName: string, socket: string): { cols: number; rows: number } {
try { try {
const sizeStr = execFileSync('tmux', ['display', '-t', muxName, '-p', '#{window_width} #{window_height}'], { const sizeStr = execFileSync(
timeout: TMUX_DISPLAY_TIMEOUT_MS, 'tmux',
encoding: 'utf8', ['-L', socket, 'display', '-t', muxName, '-p', '#{window_width} #{window_height}'],
}).trim(); {
timeout: TMUX_DISPLAY_TIMEOUT_MS,
encoding: 'utf8',
}
).trim();
const [w, h] = sizeStr.split(' ').map(Number); const [w, h] = sizeStr.split(' ').map(Number);
if (w > 0 && h > 0) { if (w > 0 && h > 0) {
return { cols: w, rows: h }; return { cols: w, rows: h };
@@ -978,7 +985,7 @@ export class Session extends EventEmitter {
// Attach to the mux session via PTY // Attach to the mux session via PTY
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default) // Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default)
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName); const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
try { try {
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), { this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
name: 'xterm-256color', name: 'xterm-256color',
+143 -47
View File
@@ -71,6 +71,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** Default stats collection interval (2 seconds) */ /** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000; const DEFAULT_STATS_INTERVAL_MS = 2000;
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
/** /**
* SAFETY: Test mode detection. * SAFETY: Test mode detection.
* When running under vitest (VITEST env var is set automatically), * When running under vitest (VITEST env var is set automatically),
@@ -98,6 +101,12 @@ const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */ /** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/; const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
/** Dedicated tmux socket for new Codeman-owned sessions. */
const DEFAULT_CODEMAN_TMUX_SOCKET = 'codeman';
/** 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. * Separator used in `tmux list-panes -F` output between session name and pid.
* *
@@ -114,6 +123,19 @@ const PANE_LIST_SEP = '|';
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */ /** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`; 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}'` * 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. * into a Map of session-name → pane pid. Exported for unit testing.
@@ -161,6 +183,31 @@ function isValidPath(path: string): boolean {
return SAFE_PATH_PATTERN.test(path); 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. * Build Claude CLI permission flags for the tmux command string.
* Validates allowedTools to prevent command injection. * Validates allowedTools to prevent command injection.
@@ -248,7 +295,7 @@ function buildSpawnCommand(options: {
* Set sensitive environment variables on a tmux session via setenv. * Set sensitive environment variables on a tmux session via setenv.
* These are inherited by panes but not visible in ps output or tmux history. * These are inherited by panes but not visible in ps output or tmux history.
*/ */
function setOpenCodeEnvVars(muxName: string): void { function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void {
const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY']; const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
for (const key of sensitiveVars) { for (const key of sensitiveVars) {
const val = process.env[key]; const val = process.env[key];
@@ -256,7 +303,7 @@ function setOpenCodeEnvVars(muxName: string): void {
// Shell-escape: wrap in single quotes, escape any inner single quotes // Shell-escape: wrap in single quotes, escape any inner single quotes
const escaped = val.replace(/'/g, "'\\''"); const escaped = val.replace(/'/g, "'\\''");
try { try {
execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, { execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8', encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
@@ -272,7 +319,7 @@ function setOpenCodeEnvVars(muxName: string): void {
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv. * Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON. * Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
*/ */
function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): void { function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: OpenCodeConfig): void {
if (!config) return; if (!config) return;
let jsonContent: string | undefined; let jsonContent: string | undefined;
@@ -303,7 +350,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
if (jsonContent) { if (jsonContent) {
const escaped = jsonContent.replace(/'/g, "'\\''"); const escaped = jsonContent.replace(/'/g, "'\\''");
try { try {
execSync(`tmux setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, { execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
encoding: 'utf8', encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
@@ -336,6 +383,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi
export class TmuxManager extends EventEmitter implements TerminalMultiplexer { export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
readonly backend = 'tmux' as const; readonly backend = 'tmux' as const;
private sessions: Map<string, MuxSession> = new Map(); private sessions: Map<string, MuxSession> = new Map();
private readonly tmuxSocket = resolveConfiguredTmuxSocket();
private statsInterval: NodeJS.Timeout | null = null; private statsInterval: NodeJS.Timeout | null = null;
private mouseSyncInterval: NodeJS.Timeout | null = null; private mouseSyncInterval: NodeJS.Timeout | null = null;
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */ /** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
@@ -351,6 +399,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
} }
/** 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) // Load saved sessions from disk (NEVER called in test mode)
private loadSessions(): void { private loadSessions(): void {
if (IS_TEST_MODE) return; if (IS_TEST_MODE) return;
@@ -360,8 +417,40 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8'); const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8');
const data = JSON.parse(content); const data = JSON.parse(content);
if (Array.isArray(data)) { if (Array.isArray(data)) {
// Dedup by muxName: one live tmux session must map to exactly one
// tracked entry. A per-session socket-tag mismatch could historically
// let the same session be tracked twice — once under its real UUID and
// once under a "restored-<id>" placeholder — surfacing as duplicate tabs.
// Single-socket unification removed that failure mode; this pass stays
// to clean any stale duplicates already on disk. Keep the real (UUID)
// entry and drop placeholder twins.
let dropped = 0;
const keptByMuxName = new Map<string, string>(); // muxName -> kept sessionId
for (const session of data) { for (const session of data) {
// Strip the obsolete per-session tmuxSocket tag (now a process-wide
// constant). Left in place it would be written back by saveSessions()
// and linger on disk as a zombie field forever.
delete (session as { tmuxSocket?: unknown }).tmuxSocket;
const muxName: string | undefined = session.muxName;
const priorId = muxName ? keptByMuxName.get(muxName) : undefined;
if (priorId) {
const incomingIsPlaceholder = String(session.sessionId).startsWith('restored-');
const priorIsPlaceholder = priorId.startsWith('restored-');
// Drop the incoming unless it's the real twin of a placeholder we kept.
if (incomingIsPlaceholder || !priorIsPlaceholder) {
dropped++;
continue;
}
this.sessions.delete(priorId);
dropped++;
}
this.sessions.set(session.sessionId, session); 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();
} }
} }
} }
@@ -438,7 +527,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
continue; continue;
} }
try { try {
execSync(`tmux setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, { execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
}); });
@@ -471,8 +560,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* (not visible in ps output or tmux history, inherited by panes). * (not visible in ps output or tmux history, inherited by panes).
*/ */
private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void { private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void {
setOpenCodeEnvVars(muxName); const tmuxCmd = this.tmux();
setOpenCodeConfigContent(muxName, openCodeConfig); setOpenCodeEnvVars(tmuxCmd, muxName);
setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
} }
/** /**
@@ -545,7 +635,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try { try {
// Build the full command to run inside tmux // Build the full command to run inside tmux
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`; const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
// Create tmux session in three steps to handle cold-start (no server running) // 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: // and avoid the race where the command exits before remain-on-exit is set:
@@ -556,7 +646,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// (Production uses systemd which has a clean env, but dev/test may be nested.) // (Production uses systemd which has a clean env, but dev/test may be nested.)
const cleanEnv = { ...process.env }; const cleanEnv = { ...process.env };
delete cleanEnv.TMUX; delete cleanEnv.TMUX;
execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}"`, { execSync(`${this.tmux()} new-session -ds "${muxName}" -c "${workingDir}"`, {
cwd: workingDir, cwd: workingDir,
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore', stdio: 'ignore',
@@ -565,7 +655,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Set remain-on-exit now that the server is running — must be before respawn-pane // Set remain-on-exit now that the server is running — must be before respawn-pane
try { try {
execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, { execSync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore', stdio: 'ignore',
}); });
@@ -584,7 +674,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.applyEnvOverrides(muxName, envOverrides); this.applyEnvOverrides(muxName, envOverrides);
// Replace the shell with the actual command (no echo in terminal) // Replace the shell with the actual command (no echo in terminal)
execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { execSync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore', stdio: 'ignore',
}); });
@@ -598,20 +688,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// It gets enabled dynamically when panes are split (agent teams). // It gets enabled dynamically when panes are split (agent teams).
const configPromises: Promise<void>[] = [ const configPromises: Promise<void>[] = [
// Disable tmux status bar — Codeman's web UI provides session info // Disable tmux status bar — Codeman's web UI provides session info
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS }) execAsync(`${this.tmux()} set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {}) .then(() => {})
.catch(() => { .catch(() => {
/* Non-critical — session still works with status bar */ /* Non-critical — session still works with status bar */
}), }),
// Override global remain-on-exit with session-level setting // Override global remain-on-exit with session-level setting
execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS }) execAsync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {}) .then(() => {})
.catch(() => { .catch(() => {
/* Already set globally as fallback */ /* Already set globally as fallback */
}), }),
// Raise tmux scrollback from its 2000-line default so re-attach preserves // Raise tmux scrollback from its 2000-line default so re-attach preserves
// more context. Matches the xterm-side default in constants.js. // more context. Matches the xterm-side default in constants.js.
execAsync(`tmux set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS }) execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
.then(() => {}) .then(() => {})
.catch(() => { .catch(() => {
/* Non-critical — falls back to tmux default */ /* Non-critical — falls back to tmux default */
@@ -621,7 +711,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Enable 24-bit true color passthrough — server-wide, set once per lifetime // Enable 24-bit true color passthrough — server-wide, set once per lifetime
if (!this.trueColorConfigured) { if (!this.trueColorConfigured) {
configPromises.push( configPromises.push(
execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS }) execAsync(`${this.tmux()} set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
.then(() => { .then(() => {
this.trueColorConfigured = true; this.trueColorConfigured = true;
}) })
@@ -678,7 +768,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
try { try {
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, { const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}).trim(); }).trim();
@@ -704,7 +794,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (IS_TEST_MODE) return false; if (IS_TEST_MODE) return false;
if (!isValidMuxName(muxName)) return false; if (!isValidMuxName(muxName)) return false;
try { try {
const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, { const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_dead}'`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}).trim(); }).trim();
@@ -754,7 +844,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}); });
const config = niceConfig || DEFAULT_NICE_CONFIG; const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config); const cmd = wrapWithNice(baseCmd, config);
const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`; const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
try { try {
// For OpenCode: set sensitive env vars via tmux setenv before respawn // For OpenCode: set sensitive env vars via tmux setenv before respawn
@@ -765,7 +855,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Re-apply user env overrides before respawn so the new shell inherits them. // Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides); this.applyEnvOverrides(muxName, envOverrides);
await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { await execAsync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
// Wait for the respawned process to start // Wait for the respawned process to start
@@ -783,7 +873,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (IS_TEST_MODE) return false; if (IS_TEST_MODE) return false;
try { try {
execSync(`tmux has-session -t "${muxName}" 2>/dev/null`, { execSync(`${this.tmux()} has-session -t "${muxName}" 2>/dev/null`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -923,7 +1013,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Strategy 3: Kill tmux session by name // Strategy 3: Kill tmux session by name
try { try {
execSync(`tmux kill-session -t "${session.muxName}" 2>/dev/null`, { execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} catch { } catch {
@@ -988,26 +1078,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dead: string[] = []; const dead: string[] = [];
const discovered: string[] = []; const discovered: string[] = [];
// Batch: single tmux call to get all session names + pane PIDs (replaces N per-session subprocess calls) // Single batched query against the one socket Codeman owns. With a single
let activeSessions = new Map<string, number>(); // 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 { try {
const output = execSync(`tmux list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, { const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}).trim(); }).trim();
activeSessions = parsePaneList(output); active = parsePaneList(output);
} catch (err) { } catch (err) {
console.error('[TmuxManager] Failed to list tmux panes:', err); console.error('[TmuxManager] Failed to list tmux panes:', err);
active = new Map();
} }
// Check known sessions against the batch result (O(1) map lookup instead of subprocess per session) // Check tracked sessions against the live pane list.
for (const [sessionId, session] of this.sessions) { for (const [sessionId, session] of this.sessions) {
const pid = activeSessions.get(session.muxName); const pid = active.get(session.muxName);
if (pid !== undefined) { if (pid !== undefined) {
alive.push(sessionId); alive.push(sessionId);
if (pid !== session.pid) { if (pid !== session.pid) session.pid = pid;
session.pid = pid;
}
} else { } else {
dead.push(sessionId); dead.push(sessionId);
this.sessions.delete(sessionId); this.sessions.delete(sessionId);
@@ -1015,13 +1107,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
} }
// Discover unknown codeman/claudeman sessions from the same batch result // 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>(); const knownMuxNames = new Set<string>();
for (const session of this.sessions.values()) { for (const session of this.sessions.values()) {
knownMuxNames.add(session.muxName); knownMuxNames.add(session.muxName);
} }
for (const [sessionName, pid] of activeSessions) { for (const [sessionName, pid] of active) {
if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue; if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue;
if (knownMuxNames.has(sessionName)) continue; if (knownMuxNames.has(sessionName)) continue;
@@ -1038,6 +1132,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
name: `Restored: ${sessionName}`, name: `Restored: ${sessionName}`,
}; };
this.sessions.set(sessionId, session); this.sessions.set(sessionId, session);
knownMuxNames.add(sessionName);
discovered.push(sessionId); discovered.push(sessionId);
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`); console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
} }
@@ -1345,21 +1440,21 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Ink (Claude CLI's terminal framework) needs them split — sending both in a // 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 // single tmux invocation (via \;) causes Ink to interpret Enter as a newline
// character in the input buffer rather than as form submission. // character in the input buffer rather than as form submission.
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, { await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, { await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} else if (textPart) { } else if (textPart) {
// Text only, no Enter // Text only, no Enter
await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, { await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} else if (hasCarriageReturn) { } else if (hasCarriageReturn) {
// Enter only // Enter only
await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, { await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} }
@@ -1386,7 +1481,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
try { try {
execSync(`tmux set-option -t "${muxName}" mouse on`, { execSync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1410,7 +1505,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
try { try {
execSync(`tmux set-option -t "${muxName}" mouse off`, { execSync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1450,7 +1545,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try { try {
const output = execSync( const output = execSync(
`tmux list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`, `${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim(); ).trim();
@@ -1489,27 +1584,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1") // Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
const tmux = this.tmux();
try { try {
const hasCarriageReturn = input.includes('\r'); const hasCarriageReturn = input.includes('\r');
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd(); const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
if (textPart && hasCarriageReturn) { if (textPart && hasCarriageReturn) {
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, { execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, { execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} else if (textPart) { } else if (textPart) {
execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, { execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} else if (hasCarriageReturn) { } else if (hasCarriageReturn) {
execSync(`tmux send-keys -t ${shellescape(target)} Enter`, { execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1540,7 +1636,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try { try {
return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, { return execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)} -S -5000`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1572,7 +1668,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try { try {
execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, { execSync(`${this.tmux()} pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1600,7 +1696,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
try { try {
execSync(`tmux pipe-pane -t ${shellescape(target)}`, { execSync(`${this.tmux()} pipe-pane -t ${shellescape(target)}`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
@@ -1616,7 +1712,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} }
getAttachArgs(muxName: string): string[] { getAttachArgs(muxName: string): string[] {
return ['attach-session', '-t', muxName]; return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
} }
isAvailable(): boolean { isAvailable(): boolean {
+11 -4
View File
@@ -652,11 +652,18 @@ export function registerSessionRoutes(
} }
try { try {
// Route through the dedicated Codeman socket — bare `tmux` would target the
// user's default server and never find this session (same #80 regression class).
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
execFile('tmux', ['send-keys', '-H', '-t', muxName, ...hex], { timeout: 5000 }, (err) => { execFile(
if (err) reject(err); 'tmux',
else resolve(); ['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex],
}); { timeout: 5000 },
(err) => {
if (err) reject(err);
else resolve();
}
);
}); });
} catch (err) { } catch (err) {
console.error('[Server] send-key failed:', err); console.error('[Server] send-key failed:', err);
+1
View File
@@ -90,6 +90,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
// -- InfraPort -- // -- InfraPort --
mux: { mux: {
muxSocket: 'codeman',
createSession: vi.fn(), createSession: vi.fn(),
killSession: vi.fn(), killSession: vi.fn(),
listSessions: vi.fn(() => []), listSessions: vi.fn(() => []),
+46
View File
@@ -7,6 +7,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>();
return { ...actual, execFile };
});
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
describe('session-routes', () => { describe('session-routes', () => {
@@ -20,6 +28,44 @@ describe('session-routes', () => {
await harness.app.close(); await harness.app.close();
}); });
// ========== POST /api/sessions/:id/send-key ==========
describe('POST /api/sessions/:id/send-key', () => {
it('routes tmux send-keys through the dedicated Codeman socket (-L)', async () => {
// Regression guard: bare `tmux` would hit the user's default server and never
// find a session that lives only on the Codeman socket (#80 regression class).
execFile.mockReset();
execFile.mockImplementation((_bin: string, _argv: string[], _opts: unknown, cb: (e: Error | null) => void) =>
cb(null)
);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key: 'S-Enter' },
});
expect(res.statusCode).toBe(200);
expect(execFile).toHaveBeenCalledTimes(1);
const [bin, argv] = execFile.mock.calls[0];
expect(bin).toBe('tmux');
expect((argv as string[]).slice(0, 2)).toEqual(['-L', 'codeman']);
expect(argv).toContain('send-keys');
expect(argv).toContain('-H');
});
it('rejects keys outside the hex allowlist without invoking tmux', async () => {
execFile.mockReset();
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key: 'rm -rf' },
});
expect(JSON.parse(res.body).success).toBe(false);
expect(execFile).not.toHaveBeenCalled();
});
});
// ========== GET /api/sessions ========== // ========== GET /api/sessions ==========
describe('GET /api/sessions', () => { describe('GET /api/sessions', () => {
+17 -2
View File
@@ -77,9 +77,24 @@ describe('TmuxManager (unit)', () => {
}); });
describe('getAttachArgs', () => { describe('getAttachArgs', () => {
it('should return attach-session args', () => { it('should attach every session through the dedicated Codeman socket', () => {
const args = manager.getAttachArgs('codeman-abc12345'); const args = manager.getAttachArgs('codeman-abc12345');
expect(args).toEqual(['attach-session', '-t', 'codeman-abc12345']); expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-abc12345']);
});
it('should attach registered sessions on the same dedicated socket (no per-session socket)', () => {
manager.registerSession({
sessionId: 'some-session',
muxName: 'codeman-abc12345',
pid: 12345,
createdAt: Date.now(),
workingDir: '/tmp',
mode: 'claude',
attached: false,
});
const args = manager.getAttachArgs('codeman-abc12345');
expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-abc12345']);
}); });
}); });
+26 -23
View File
@@ -8,7 +8,8 @@
* the next frame — visible flicker and one lost repaint of scrollback. * the next frame — visible flicker and one lost repaint of scrollback.
* *
* The fix queries tmux for the actual window geometry first via * The fix queries tmux for the actual window geometry first via
* `tmux display -t <name> -p '#{window_width} #{window_height}'`. We cover: * `tmux -L <socket> display -t <name> -p '#{window_width} #{window_height}'`
* (the `-L <socket>` targets the isolated Codeman socket). We cover:
* - Happy path: tmux reports valid geometry → those numbers are used. * - Happy path: tmux reports valid geometry → those numbers are used.
* - Browser-resize-between-attaches: tmux reports a non-default size * - Browser-resize-between-attaches: tmux reports a non-default size
* (because a prior client resized it) → the helper picks that up. * (because a prior client resized it) → the helper picks that up.
@@ -49,7 +50,7 @@ beforeEach(() => {
describe('queryTmuxWindowSize — happy path', () => { describe('queryTmuxWindowSize — happy path', () => {
it('returns the geometry tmux reports', () => { it('returns the geometry tmux reports', () => {
execFileSync.mockReturnValue('200 50\n'); execFileSync.mockReturnValue('200 50\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 200, rows: 50 }); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 200, rows: 50 });
}); });
it('picks up a non-default size left behind by a prior client (browser-resize-between-attaches)', () => { it('picks up a non-default size left behind by a prior client (browser-resize-between-attaches)', () => {
@@ -57,12 +58,12 @@ describe('queryTmuxWindowSize — happy path', () => {
// tmux keeps the last-attached geometry. Client B re-attaches and should spawn // tmux keeps the last-attached geometry. Client B re-attaches and should spawn
// its PTY at 220x60, not 120x40 — that's the whole point of #80. // its PTY at 220x60, not 120x40 — that's the whole point of #80.
execFileSync.mockReturnValue('220 60'); execFileSync.mockReturnValue('220 60');
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 220, rows: 60 }); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 220, rows: 60 });
}); });
it('tolerates trailing whitespace and newlines in tmux output', () => { it('tolerates trailing whitespace and newlines in tmux output', () => {
execFileSync.mockReturnValue(' 180 45 \n\n'); execFileSync.mockReturnValue(' 180 45 \n\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 180, rows: 45 }); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 180, rows: 45 });
}); });
}); });
@@ -75,7 +76,7 @@ describe('queryTmuxWindowSize — fallback paths', () => {
err.status = 1; err.status = 1;
throw err; throw err;
}); });
expect(queryTmuxWindowSize('bogus')).toEqual(DEFAULT); expect(queryTmuxWindowSize('bogus', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when tmux dies between query and parse (ETIMEDOUT / ENOENT)', () => { it('falls back when tmux dies between query and parse (ETIMEDOUT / ENOENT)', () => {
@@ -85,63 +86,65 @@ describe('queryTmuxWindowSize — fallback paths', () => {
err.code = 'ETIMEDOUT'; err.code = 'ETIMEDOUT';
throw err; throw err;
}); });
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when tmux returns empty output', () => { it('falls back when tmux returns empty output', () => {
execFileSync.mockReturnValue(''); execFileSync.mockReturnValue('');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when tmux returns whitespace-only output', () => { it('falls back when tmux returns whitespace-only output', () => {
execFileSync.mockReturnValue(' \n'); execFileSync.mockReturnValue(' \n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when tmux returns non-numeric output', () => { it('falls back when tmux returns non-numeric output', () => {
execFileSync.mockReturnValue('not a size\n'); execFileSync.mockReturnValue('not a size\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when only one dimension is present', () => { it('falls back when only one dimension is present', () => {
execFileSync.mockReturnValue('200\n'); execFileSync.mockReturnValue('200\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when a dimension is zero (degenerate geometry)', () => { it('falls back when a dimension is zero (degenerate geometry)', () => {
// tmux reporting `0` would crash node-pty downstream — must not propagate. // tmux reporting `0` would crash node-pty downstream — must not propagate.
execFileSync.mockReturnValue('0 40\n'); execFileSync.mockReturnValue('0 40\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
execFileSync.mockReturnValue('120 0\n'); execFileSync.mockReturnValue('120 0\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when a dimension is negative', () => { it('falls back when a dimension is negative', () => {
execFileSync.mockReturnValue('-200 -50\n'); execFileSync.mockReturnValue('-200 -50\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
it('falls back when tmux returns NaN-producing tokens', () => { it('falls back when tmux returns NaN-producing tokens', () => {
execFileSync.mockReturnValue('abc def\n'); execFileSync.mockReturnValue('abc def\n');
expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT);
}); });
}); });
describe('queryTmuxWindowSize — call shape', () => { describe('queryTmuxWindowSize — call shape', () => {
it('invokes tmux with display -t <name> -p ... via argv (not a shell)', () => { it('invokes tmux on the dedicated socket via argv (not a shell)', () => {
execFileSync.mockReturnValue('120 40\n'); execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('codeman-abc'); queryTmuxWindowSize('codeman-abc', 'codeman');
expect(execFileSync).toHaveBeenCalledTimes(1); expect(execFileSync).toHaveBeenCalledTimes(1);
const [bin, argv, opts] = execFileSync.mock.calls[0]; const [bin, argv, opts] = execFileSync.mock.calls[0];
expect(bin).toBe('tmux'); expect(bin).toBe('tmux');
expect(argv).toEqual(['display', '-t', 'codeman-abc', '-p', '#{window_width} #{window_height}']); // `-L <socket>` MUST lead: querying the default server would never find a
// session that lives on the isolated Codeman socket (the #80 regression).
expect(argv).toEqual(['-L', 'codeman', 'display', '-t', 'codeman-abc', '-p', '#{window_width} #{window_height}']);
// execFileSync — not execSync — so muxName is never substituted into a shell string. // execFileSync — not execSync — so muxName is never substituted into a shell string.
expect(opts).toMatchObject({ encoding: 'utf8' }); expect(opts).toMatchObject({ encoding: 'utf8' });
}); });
it('uses a bounded timeout so a hung tmux server cannot block startup forever', () => { it('uses a bounded timeout so a hung tmux server cannot block startup forever', () => {
execFileSync.mockReturnValue('120 40\n'); execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('codeman-abc'); queryTmuxWindowSize('codeman-abc', 'codeman');
const [, , opts] = execFileSync.mock.calls[0]; const [, , opts] = execFileSync.mock.calls[0];
// Whatever the exact constant, the contract is: ≤5s so the user-visible // Whatever the exact constant, the contract is: ≤5s so the user-visible
// attach path can't hang on a stuck tmux server. // attach path can't hang on a stuck tmux server.
@@ -152,12 +155,12 @@ describe('queryTmuxWindowSize — call shape', () => {
it('passes a muxName that looks like a tmux flag as an argv element (no option injection)', () => { it('passes a muxName that looks like a tmux flag as an argv element (no option injection)', () => {
execFileSync.mockReturnValue('120 40\n'); execFileSync.mockReturnValue('120 40\n');
queryTmuxWindowSize('-x 1 -y 1; rm -rf'); queryTmuxWindowSize('-x 1 -y 1; rm -rf', 'codeman');
const [, argv] = execFileSync.mock.calls[0]; const [, argv] = execFileSync.mock.calls[0];
// The whole "name" lives in a single argv slot, so tmux interprets it as a // The whole "name" lives in a single argv slot (index 4, after `-L codeman
// target session name, not as additional flags. The `-t` flag preceding it // display -t`), so tmux interprets it as a target session name, not as
// pins it as the target argument. // additional flags. The `-t` flag preceding it pins it as the target.
expect(argv?.[2]).toBe('-x 1 -y 1; rm -rf'); expect(argv?.[4]).toBe('-x 1 -y 1; rm -rf');
expect((argv as string[]).indexOf('-x')).toBe(-1); expect((argv as string[]).indexOf('-x')).toBe(-1);
}); });
}); });