mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
+10
-3
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(() => []),
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user