2026-02-12 10:29:57 +01:00
/**
* @fileoverview tmux session manager for persistent Claude sessions.
*
* This module provides the TmuxManager class which creates and manages
* tmux sessions that wrap Claude CLI processes. tmux provides:
*
* - **Persistence**: Sessions survive server restarts and disconnects
* - **Ghost recovery**: Orphaned sessions are discovered and reattached on startup
* - **Resource tracking**: Memory, CPU, and child process stats per session
2026-10-14 21:23:58 +02:00
* - **Reliable input**: a submitted prompt goes in as one bracketed paste, then a separate Enter
2026-02-12 10:29:57 +01:00
* - **Teammate support**: Immutable pane IDs enable targeting individual teammates
*
2026-02-26 16:44:34 +01:00
* tmux sessions are named `codeman-{sessionId}` and stored in ~/.codeman/mux-sessions.json.
2026-02-12 10:29:57 +01:00
*
2026-02-18 12:51:35 +01:00
* Key features:
2026-10-14 21:23:58 +02:00
* - `paste-buffer -p` + a separate `send-keys Enter` submits a prompt (see sendInput)
2026-02-18 12:51:35 +01:00
* - `list-sessions -F` provides structured queries
2026-02-12 10:29:57 +01:00
* - `display-message -p '#{pane_pid}'` for reliable PID discovery
2026-02-18 12:51:35 +01:00
* - Single server architecture
2026-02-12 10:29:57 +01:00
*
* @module tmux-manager
*/
import { EventEmitter } from 'node:events' ;
2026-08-07 01:34:47 +02:00
import { collectDescendants } from './proc-tree.js' ;
2026-09-29 18:01:47 -07:00
import {
foreignViewSessionName ,
buildForeignDockerViewKillCommand ,
buildForeignAttachCommand ,
buildForeignDockerAttachCommand ,
buildForeignRemoteAttachCommand ,
} from './foreign-tmux.js' ;
2026-08-07 01:34:47 +02:00
import { execSync , exec , execFile } from 'node:child_process' ;
2026-02-12 18:26:16 +01:00
import { promisify } from 'node:util' ;
const execAsync = promisify ( exec );
2026-10-21 08:02:05 +02:00
import { existsSync , readFileSync , mkdirSync , realpathSync } from 'node:fs' ;
2026-02-12 20:14:01 +01:00
import { writeFile , rename } from 'node:fs/promises' ;
2026-06-06 03:53:54 +02:00
import { dirname } from 'node:path' ;
2026-07-19 15:09:48 +02:00
import { homedir } from 'node:os' ;
2026-08-16 19:29:49 +02:00
import {
dataPath ,
DEFAULT_TMUX_SOCKET ,
CODEMAN_INSTANCE ,
SAFE_TMUX_SOCKET_PATTERN ,
resolveTmuxSocketName ,
} from './config/instance.js' ;
2026-02-27 13:16:23 +01:00
import {
ProcessStats ,
PersistedRespawnConfig ,
getErrorMessage ,
DEFAULT_NICE_CONFIG ,
type PaneInfo ,
type ClaudeMode ,
type SessionMode ,
type OpenCodeConfig ,
2026-06-10 09:31:58 -04:00
type CodexConfig ,
2026-06-07 10:33:11 +08:00
type EffortLevel ,
2026-05-16 20:10:49 -04:00
type GeminiConfig ,
2026-08-04 12:59:40 +02:00
type AntigravityConfig ,
2026-08-13 13:54:47 +02:00
type PiConfig ,
2026-08-23 08:39:03 +02:00
type GrokConfig ,
2026-10-10 14:06:04 +08:00
type CopilotConfig ,
2026-08-24 03:37:56 +02:00
type DeepSeekConfig ,
2026-08-18 23:17:58 -05:00
type OmpConfig ,
2026-06-11 18:43:39 +00:00
type SessionRemote ,
2026-07-19 15:09:48 +02:00
type SessionDocker ,
2026-09-29 18:01:47 -07:00
type SessionAdopt ,
2026-07-19 15:09:48 +02:00
type DockerCommandMode ,
2026-09-21 10:00:54 +02:00
type PaneExit ,
2026-10-11 12:36:59 -04:00
type StatusLineExporter ,
2026-02-27 13:16:23 +01:00
} from './types.js' ;
2026-09-02 08:26:45 +08:00
import { getCli } from './config/cli-registry/registry.js' ;
import { missingCliMessage , resolveCliBinDir } from './utils/cli-resolver.js' ;
import {
buildSpawnCommandFromRegistry ,
configSetenvValues ,
2026-10-18 13:09:06 -04:00
launchGatePasses ,
2026-09-02 08:26:45 +08:00
legacyConfigForMode ,
} from './session-cli-registry-bridge.js' ;
2026-10-18 13:09:06 -04:00
import { keepWarmOn } from './prompt-cache-keepwarm.js' ;
2026-09-02 08:26:45 +08:00
import type { CliEntry } from './config/cli-registry/types.js' ;
2026-09-07 20:37:29 -05:00
import {
resolveStatusLineCliCommand ,
readPlanUsageTelemetryEnabled ,
2026-10-18 13:09:06 -04:00
readKeepWarmSettings ,
2026-09-07 20:37:29 -05:00
findEffectiveUserStatusLineCommand ,
} from './hooks-config.js' ;
2026-08-05 01:40:37 +02:00
import {
buildSshConnectionArgs ,
defaultRemoteCommandForMode ,
remoteLoginShellCommand ,
remoteSshTarget ,
2026-08-29 15:21:33 -05:00
remoteTmuxSessionAlive ,
2026-10-23 17:27:18 +04:00
withRemoteTmuxPath ,
2026-08-05 01:40:37 +02:00
} from './remote-hosts.js' ;
2026-07-19 15:09:48 +02:00
import {
buildDockerBaseArgs ,
buildDockerCreateArgs ,
containerApiUrl ,
CONTAINER_HOME ,
defaultDockerCommandForMode ,
hostGatewayAlias ,
2026-07-20 01:28:53 +02:00
resolveDockerClaudeArtifacts ,
resolveDockerCredentialArtifacts ,
2026-08-27 19:38:38 +08:00
resolveDockerDaemonMountSource ,
2026-07-19 15:09:48 +02:00
type DockerCreateContext ,
type DockerMount ,
2026-07-20 01:28:53 +02:00
type DockerSeedCopy ,
2026-07-19 15:09:48 +02:00
} from './docker-hosts.js' ;
2026-10-10 04:20:46 +02:00
import {
wrapWithNice ,
SAFE_PATH_PATTERN ,
resolveLocalShell ,
loginShellArgs ,
waitForProcessesExit ,
} from './utils/index.js' ;
2026-02-27 13:16:23 +01:00
import type {
TerminalMultiplexer ,
MuxSession ,
MuxSessionWithStats ,
CreateSessionOptions ,
RespawnPaneOptions ,
2026-07-12 18:33:18 +02:00
PaneCaptureOptions ,
2026-02-27 13:16:23 +01:00
} from './mux-interface.js' ;
2026-06-13 14:26:06 -04:00
import {
decideReconnect ,
advanceBackoff ,
freshReconnectState ,
resetReconnectState ,
type RemoteReconnectState ,
} from './remote-reconnect.js' ;
2026-02-12 10:29:57 +01:00
// ============================================================================
// Timing Constants
// ============================================================================
2026-02-28 17:39:44 +01:00
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js' ;
2026-08-24 03:37:56 +02:00
import { ensureDeepSeekStatusShim } from './deepseek-status-shim.js' ;
2026-10-23 07:41:46 +00:00
import { NESTED_TMUX_PREFIX , nestedPrefixTableResetCommands , restrictTmuxPrefixTable } from './tmux-prefix-table.js' ;
2026-08-07 01:34:47 +02:00
/** How long a cached process snapshot stays usable. */
const PROC_SNAPSHOT_TTL_MS = 2000 ;
/**
* How long the kill path waits for a fresh snapshot before giving up on it.
* Shorter than EXEC_TIMEOUT_MS on purpose: killSession has two further strategies
* (process group, tmux kill-session) and must reach them even when `ps` is wedged.
*/
const PROC_SNAPSHOT_WAIT_MS = 1500 ;
2026-07-12 18:33:18 +02:00
import { DEFAULT_TMUX_HISTORY_LIMIT , DEFAULT_TERMINAL_BUFFER_MAX_BYTES } from './config/terminal-history.js' ;
/**
* Extra stdout headroom for the full-history `capture-pane` child process on
* top of the consumer's byte cap: raw scrollback carries per-line SGR/ANSI
* overhead that the route pipeline strips before applying its cap, so the
* capture must be allowed to exceed the final payload size.
*/
const FULL_HISTORY_CAPTURE_SLACK_BYTES = 8 * 1024 * 1024 ;
2026-02-12 10:29:57 +01:00
2026-02-20 04:30:02 +01:00
/** Delay after tmux session creation — enough for detached tmux to be queryable */
const TMUX_CREATION_WAIT_MS = 100 ;
2026-02-12 10:29:57 +01:00
2026-02-24 13:23:00 +01:00
/** Max retries for getPanePid — tmux server cold-start (e.g. macOS) may need extra time */
const GET_PID_MAX_RETRIES = 5 ;
const GET_PID_RETRY_MS = 200 ;
2026-10-10 04:20:46 +02:00
/**
* How long killSession gives a pane's children to exit on SIGTERM before it
* re-scans and SIGKILLs. A deadline, not a sleep (see utils/process-exit-wait.ts).
*/
2026-02-12 10:29:57 +01:00
const TMUX_KILL_WAIT_MS = 200 ;
2026-10-10 04:20:46 +02:00
/** How long the pane's process group gets to exit on SIGTERM before SIGKILL. Also a deadline. */
2026-02-12 10:29:57 +01:00
const GRACEFUL_SHUTDOWN_WAIT_MS = 100 ;
2026-10-10 04:20:46 +02:00
/** How long killSession waits for every process it signalled to be gone before it warns. */
const KILL_VERIFY_TIMEOUT_MS = 2000 ;
2026-02-12 10:29:57 +01:00
/** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000 ;
2026-09-22 09:19:55 +02:00
/**
* How often the pane-exit watcher re-reads every pane on the socket. The
* watcher owns this cadence: it does NOT ride `startStatsCollection()`, whose
* lifetime a browser panel controls (see {@link TmuxManager.startPaneExitWatcher}).
2026-09-22 15:24:09 +02:00
* Matched to the stats cadence above because both cost one batched tmux read.
* ⚠ It does NOT bound a read: EXEC_TIMEOUT_MS is 5000 ms, so a slow read can
* outlive two ticks, which is exactly why `paneExitReadInFlight` exists.
2026-09-22 09:19:55 +02:00
*/
2026-09-21 10:00:54 +02:00
const DEFAULT_PANE_EXIT_INTERVAL_MS = 2000 ;
2026-06-13 14:26:06 -04:00
/** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000 ;
2026-06-08 11:18:14 -04:00
/** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */
const TMUX_LAUNCH_CWD = '/tmp' ;
2026-10-21 08:02:05 +02:00
/**
* TMUX_LAUNCH_CWD in every spelling a pane can report it. tmux reads a pane's
* directory from its process, which comes back resolved (`/private/tmp` on
* macOS, where `/tmp` is a symlink), so the literal and the resolved spelling
* both count. Computed once.
*/
const TMUX_LAUNCH_CWD_SPELLINGS : ReadonlySet < string > = (() => {
const spellings = new Set ([ TMUX_LAUNCH_CWD ]);
try {
spellings . add ( realpathSync ( TMUX_LAUNCH_CWD ));
} catch {
// Unresolvable launch directory: the literal spelling is all there is.
}
return spellings ;
})();
2026-05-26 05:28:58 +08:00
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646 ;
2026-02-12 10:29:57 +01:00
/**
2026-02-12 18:26:16 +01:00
* SAFETY: Test mode detection.
* When running under vitest (VITEST env var is set automatically),
* ALL tmux shell commands are disabled. TmuxManager becomes a pure
* in-memory mock that cannot interact with real tmux sessions.
*
* This makes it PHYSICALLY IMPOSSIBLE for any test to:
* - Kill a tmux session
* - Create a tmux session
* - Send input to a tmux session
* - Discover/reconcile real tmux sessions
2026-02-26 16:44:34 +01:00
* - Read/write ~/.codeman/mux-sessions.json
2026-02-12 10:29:57 +01:00
*/
2026-02-12 18:26:16 +01:00
const IS_TEST_MODE = !! process . env . VITEST ;
2026-02-12 10:29:57 +01:00
/** Path to persisted mux session metadata */
2026-06-06 03:53:54 +02:00
const MUX_SESSIONS_FILE = dataPath ( 'mux-sessions.json' );
2026-02-12 10:29:57 +01:00
2026-06-13 14:26:06 -04:00
/**
* COD-108 kill-switch: `remoteAutoReconnect` app setting (default ON). Read at
* call time (like headroom routing) so a settings change takes effect without a
* restart. Absent/non-boolean ⇒ true (feature on).
*/
function isRemoteAutoReconnectEnabled () : boolean {
try {
const s = JSON . parse ( readFileSync ( dataPath ( 'settings.json' ), 'utf8' )) as Record < string , unknown >;
return typeof s . remoteAutoReconnect === 'boolean' ? s.remoteAutoReconnect : true ;
} catch {
return true ;
}
}
2026-02-12 10:29:57 +01:00
/** Regex to validate tmux session names (only allow safe characters) */
2026-02-26 16:44:34 +01:00
const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/ ;
/** Legacy pattern for pre-rename sessions (claudeman- prefix) */
const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/ ;
2026-02-12 10:29:57 +01:00
2026-02-12 13:44:07 +01:00
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/ ;
2026-06-06 03:53:54 +02:00
/** Dedicated tmux socket for new Codeman-owned sessions (instance-scoped:
* `codeman` for prod, `codeman-beta` on the beta branch). */
const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET ;
2026-05-26 05:28:58 +08:00
2026-04-28 08:11:11 +08:00
/**
* Separator used in `tmux list-panes -F` output between session name and pid.
*
* Must NOT be a backslash-escape (e.g. `\t`, `\n`): under non-tty execution
* contexts (launchd on macOS, systemd without TTYPath) tmux can emit such
* escapes as the literal two characters `\` + letter rather than the control
* byte, breaking the parser and causing every tracked session to be classified
* as dead — which wipes state.json on restart. '|' is passed through verbatim
* in every environment and is rejected by tmux's own session-name validation,
* so it cannot appear inside `#{session_name}` and cause a false split.
*/
const PANE_LIST_SEP = '|' ;
2026-09-21 10:00:54 +02:00
/**
* Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneRows}.
*
* The three `pane_dead*` fields carry the agent-exit signal of Ark0N/Codeman#446.
* Appending them is backward compatible in both directions. A tmux that does not
* know a variable substitutes the empty string rather than failing, which is how
* tmux 3.2a answers `#{pane_dead_signal}` (added in 3.4), and the parser reads a
* short row as "pid known, deadness unknown" rather than discarding it.
2026-10-21 03:48:00 +02:00
*
* `#{pane_current_path}` comes last on purpose: a directory name may contain the
* separator, and only the final field can absorb it without ambiguity.
2026-09-21 10:00:54 +02:00
*/
const PANE_LIST_FORMAT =
`#{session_name} ${ PANE_LIST_SEP } #{pane_pid}` +
2026-10-21 03:48:00 +02:00
` ${ PANE_LIST_SEP } #{pane_dead} ${ PANE_LIST_SEP } #{pane_dead_status} ${ PANE_LIST_SEP } #{pane_dead_signal}` +
` ${ PANE_LIST_SEP } #{pane_current_path}` ;
2026-04-28 08:11:11 +08:00
2026-05-26 05:28:58 +08:00
/**
* 构建 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` ;
}
2026-09-21 10:00:54 +02:00
/** One pane of one tmux session, as {@link parsePaneRows} reads it off the wire. */
export interface PaneRow {
/** tmux session this pane belongs to. Repeats once per pane of a split session. */
sessionName : string ;
/** `#{pane_pid}` — the process tmux started in the pane. */
pid : number ;
/** `#{pane_dead}` — true for 1, false for 0, undefined when tmux said nothing. */
dead? : boolean ;
/** `#{pane_dead_status}` — the exit code, absent when tmux reported none. */
exitStatus? : number ;
/** `#{pane_dead_signal}` — the killing signal, absent before tmux 3.4 and when unsignalled. */
exitSignal? : number ;
2026-10-21 03:48:00 +02:00
/** `#{pane_current_path}` — the pane's current directory, absent when tmux reported none. */
currentPath? : string ;
2026-09-21 10:00:54 +02:00
}
2026-04-28 08:11:11 +08:00
/**
2026-09-21 10:00:54 +02:00
* One pane-exit reading, with the pane pid that produced it.
*
* The pid never leaves this module. It is what distinguishes "the same dead
* pane, seen again" from "a second command in the same pane that also exited
* with the same status", so the `at` stamp can hold across the first and must
* not across the second. {@link PaneExit} itself stays free of it: the pid on
* the session record is the attach client's, and a second pid there would
* invite exactly the confusion Ark0N/Codeman#446 is about.
*/
export interface PaneExitObservation {
/** `#{pane_pid}` of the pane this reading came from. */
panePid : number ;
/** What to publish on the session record. */
exit : PaneExit ;
}
2026-09-24 22:18:07 +02:00
/**
* A {@link PaneExitObservation} as the manager stores it, with a count of the
* authoritative reads that have seen this same exit. The count is what lets
* the exited-agent sweep act only on a death that more than one read agreed on
* (`CLEAN_EXIT_CONFIRMING_READS` in `pane-exit-sweep.ts`). A failed or skipped
* read never reaches {@link TmuxManager.applyPaneExits}, so it neither raises
* the count nor resets it.
*/
interface TrackedPaneExit extends PaneExitObservation {
/** Authoritative reads that saw this exit, counting the first. */
reads : number ;
}
2026-09-21 10:00:54 +02:00
/** Read one optional numeric field; a blank or non-numeric value is "not reported". */
function paneField ( fields : string [], index : number ) : number | undefined {
const raw = fields [ index ];
if ( raw === undefined || raw === '' ) return undefined ;
const value = parseInt ( raw , 10 );
return Number . isNaN ( value ) ? undefined : value ;
}
/**
* Parse the output of `tmux list-panes -a -F` under {@link PANE_LIST_FORMAT}
* into one row per pane, in tmux's own order. Exported for unit testing.
2026-04-28 08:11:11 +08:00
*
* - Skips empty lines and lines without the separator.
* - Skips entries with a non-numeric pid or empty name.
2026-09-21 10:00:54 +02:00
* - Leaves every field after the pid undefined when it is blank or absent, so a
* row from an older tmux still yields its pid.
2026-04-28 08:11:11 +08:00
*/
2026-09-21 10:00:54 +02:00
export function parsePaneRows ( output : string ) : PaneRow [] {
const rows : PaneRow [] = [];
2026-04-28 08:11:11 +08:00
for ( const line of output . split ( '\n' )) {
if ( ! line ) continue ;
2026-09-21 10:00:54 +02:00
if ( ! line . includes ( PANE_LIST_SEP )) continue ;
const fields = line . split ( PANE_LIST_SEP );
const sessionName = fields [ 0 ];
const pid = parseInt ( fields [ 1 ] ?? '' , 10 );
if ( ! sessionName || Number . isNaN ( pid )) continue ;
const deadFlag = fields [ 2 ];
rows . push ({
sessionName ,
pid ,
dead : deadFlag === '1' ? true : deadFlag === '0' ? false : undefined ,
exitStatus : paneField ( fields , 3 ),
exitSignal : paneField ( fields , 4 ),
2026-10-21 03:48:00 +02:00
// Everything after the fifth separator, so a path containing `|` stays whole.
currentPath : fields.slice ( 5 ). join ( PANE_LIST_SEP ) || undefined ,
2026-09-21 10:00:54 +02:00
});
2026-04-28 08:11:11 +08:00
}
2026-09-21 10:00:54 +02:00
return rows ;
}
/**
* Decide, from every pane tmux listed, which tmux sessions have an exited agent.
* Exported for unit testing. Returns one entry per session with a known answer;
* a session absent from the map is UNKNOWN, which must never render as alive.
*
* Two rules make a positive answer trustworthy:
*
* A session answers only when tmux listed EXACTLY ONE pane for it. Codeman
* creates one pane per session and `isPaneDead()` reads one pane, so a session
* the user has split by hand has no single "the agent" to report on, and
* guessing which of its panes speaks for the session could report a live
* session as exited.
*
* A pane answers only when `#{pane_dead}` said 1 or 0. An empty field is a tmux
* that did not answer, not a live pane.
*
* `status` and `signal` stay absent when tmux did not report them. Measured on
* tmux 3.2a, a SIGKILLed pane reports neither, so folding an absent status into
* 0 would turn an unexplained death into a clean exit.
*/
export function derivePaneExits ( rows : PaneRow [], now : number ) : Map < string , PaneExitObservation > {
const panesPerSession = new Map < string , number >();
for ( const row of rows ) {
panesPerSession . set ( row . sessionName , ( panesPerSession . get ( row . sessionName ) ?? 0 ) + 1 );
}
const exits = new Map < string , PaneExitObservation >();
for ( const row of rows ) {
if ( panesPerSession . get ( row . sessionName ) !== 1 ) continue ;
if ( row . dead !== true ) continue ;
exits . set ( row . sessionName , {
panePid : row.pid ,
exit : {
...( row . exitStatus !== undefined ? { status : row.exitStatus } : {}),
...( row . exitSignal !== undefined ? { signal : row.exitSignal } : {}),
at : now ,
},
});
}
return exits ;
2026-04-28 08:11:11 +08:00
}
2026-10-22 20:59:28 -04:00
/**
* Decide, from every pane tmux listed, which directory each tmux session's pane
* is in now (`#{pane_current_path}`). Exported for unit testing.
*
* This is what a relative path printed in the terminal is relative TO: a
* session's `workingDir` is fixed at create time and never follows a `cd`.
*
* - Only sessions with EXACTLY ONE pane answer, for the reason
* {@link derivePaneExits} gives: a hand-split session has no single "the"
* pane to speak for it.
2026-10-28 19:28:53 -04:00
* - Only absolute paths count. A blank field is a tmux that could not read the
* process cwd: usually a pane whose program exited under `remain-on-exit`
* (tmux 3.4 then reports it blank on every read), otherwise a read that
* failed that tick. Either way the directory is unknown, not gone, so a
* single-pane session with a blank field keeps its entry from `previous`.
2026-10-28 15:28:59 -04:00
* A session split into several panes, or one the read no longer lists, gets
* no entry: those are authoritative absences, not unanswered questions.
2026-10-22 20:59:28 -04:00
*
* The launch directory is NOT filtered here (unlike the rediscovery path): a
2026-11-04 16:59:16 +01:00
* remote or docker pane that never leaves it is refused by `Session.setPaneCwd()`,
2026-10-22 20:59:28 -04:00
* and a local pane reporting it really is there.
*/
2026-10-28 15:28:59 -04:00
export function derivePaneCurrentPaths (
rows : PaneRow [],
previous : ReadonlyMap < string , string > = new Map ()
) : Map < string , string > {
2026-10-22 20:59:28 -04:00
const panesPerSession = new Map < string , number >();
for ( const row of rows ) {
panesPerSession . set ( row . sessionName , ( panesPerSession . get ( row . sessionName ) ?? 0 ) + 1 );
}
const paths = new Map < string , string >();
for ( const row of rows ) {
if ( panesPerSession . get ( row . sessionName ) !== 1 ) continue ;
2026-10-28 15:28:59 -04:00
const path = row . currentPath ?? previous . get ( row . sessionName );
2026-10-22 20:59:28 -04:00
if ( ! path || ! path . startsWith ( '/' )) continue ;
paths . set ( row . sessionName , path );
}
return paths ;
}
2026-09-22 09:19:55 +02:00
/**
* Could any of these tmux sessions ever produce a pane-exit answer? Exported
* for unit testing.
*
* Mirrors `Session.paneExitApplies`, which is where the rule is enforced. A
* remote session's local pane holds the ssh client, a docker case's holds a
2026-09-29 18:01:47 -07:00
* `docker exec` into the container's own tmux, an ADOPTED session's holds a
* `tmux attach` to a session a human started rather than the agent itself, and
* a record rebuilt from the socket carries no provenance at all, so the session
* end forces all four to UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
2026-09-22 09:19:55 +02:00
* learn, and `refreshPaneExits()` skips its tmux read rather than paying for
* the answer.
*
* ⚠ This gates the READ, never the watcher. The watcher is always-on by
* design (see {@link TmuxManager.startPaneExitWatcher}), so it keeps ticking
* with nothing to observe and picks the read straight back up as soon as one
* local session exists.
*/
export function hasObservablePaneSession ( sessions : Iterable < MuxSession >) : boolean {
for ( const session of sessions ) {
if ( session . remote ) continue ;
if ( session . docker ) continue ;
2026-09-29 18:01:47 -07:00
if ( session . adopt ) continue ;
2026-09-22 09:19:55 +02:00
if ( session . discovered === true ) continue ;
return true ;
}
return false ;
}
2026-11-06 07:44:22 +01:00
/**
* Which adopted wrappers have a dead pane with a tmux client still attached, from rows of
* `#{pane_dead} #{session_attached} #{session_name}`. Exported for unit testing.
*/
export function endedAdoptedAttaches ( rows : string , adopted : ReadonlySet < string >) : string [] {
const ended : string [] = [];
for ( const row of rows . split ( '\n' )) {
const [ dead , attached , muxName ] = row . trim (). split ( ' ' );
if ( dead === '1' && Number ( attached ) > 0 && adopted . has ( muxName )) ended . push ( muxName );
}
return ended ;
}
2026-06-09 10:38:35 -04:00
/**
* Resolve a target pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`.
* Prefers the active pane and falls back to the first valid pane.
*/
export function resolveTmuxPaneTarget ( muxName : string , paneTarget? : string ) : string | null {
if ( ! isValidMuxName ( muxName )) {
return null ;
}
if ( paneTarget === undefined || paneTarget === 'active' ) {
return muxName ;
}
if ( ! SAFE_PANE_TARGET_PATTERN . test ( paneTarget )) {
return null ;
}
return ` ${ muxName } . ${ paneTarget } ` ;
}
2026-09-25 16:58:00 +02:00
let submitBufferSeq = 0 ;
/** The tmux commands of one bracketed paste, see buildPasteTextCommands(). */
export interface PasteTextCommands {
/** Fills a buffer of its own with the text. */
set : string ;
/** Pastes that buffer into the target, bracketed, and deletes it (`-d`). */
paste : string ;
/** Deletes the buffer when `paste` failed; `-d` only runs on a successful paste. */
discard : string ;
}
2026-10-14 21:23:58 +02:00
/**
* Escape a final `;` for tmux. tmux splits commands on an argument that ENDS in
2026-10-24 13:57:37 +02:00
* `;` (`set-buffer -- 'a;'` stores `a`, `send-keys -l -- 'a;'` types `a`, a lone `;`
2026-10-14 21:23:58 +02:00
* is no argument at all); a final `\;` is its escape for a literal `;`. A `;`
2026-10-24 13:57:37 +02:00
* anywhere else in the argument is literal already. Every `send-keys -l` passes
* `--` before the text, like `set-buffer`, or tmux parses a text that starts with
* `-` as its own options. Exported for unit testing.
2026-10-14 21:23:58 +02:00
*/
export function escapeTmuxTrailingSemicolon ( text : string ) : string {
return text . endsWith ( ';' ) ? ` ${ text . slice ( 0 , - 1 ) } \\;` : text ;
}
/**
* Whether a text+Enter submit types `text` as keys instead of pasting it: true for
* a single character. Claude Code's and Codex's selection dialogs ignore a bracketed
* paste, so a pasted `3` + Enter confirms the highlighted option instead of option 3;
* a typed `3` selects it. One character cannot form the burst Codex reads as a paste,
* so typing it is safe there. Exported for unit testing.
*/
export function submitsAsKeys ( text : string ) : boolean {
return [... text ]. length === 1 ;
}
2026-09-25 16:58:00 +02:00
/**
* tmux commands that type `text` into `target` as ONE bracketed paste, ahead of a
* separately sent `Enter` that submits it. Exported for unit testing.
*
* Why a paste and not `send-keys -l`: Codex detects a paste that arrives WITHOUT
* bracketed-paste markers heuristically, from characters arriving in a fast burst,
* and folds an Enter that lands inside that burst window into the paste as a
* newline. `send-keys -l` types exactly such an unmarked burst, so a text+Enter
* pair sat in Codex's composer unsubmitted: always for long text (Codex shows it as
* `[Pasted Content N chars]`, which SubmitVerifier cannot match against the prompt,
* so nothing re-presses Enter), intermittently for short text at the 50 ms gap.
* `paste-buffer -p` wraps the text in the markers (only when the pane asked for
* them, so an app without bracketed paste sees the same bytes as before) and the
* Enter after it is unambiguous.
*
* Each call gets its own named buffer so concurrent sends cannot paste each other's
* text. tmux keeps a named buffer until something deletes it (`buffer-limit` trims
* only automatic ones), hence `discard` for a paste that fails.
*
2026-10-14 21:23:58 +02:00
* A single character is typed instead (submitsAsKeys), so a dialog answer selects.
* A final `;` is escaped (escapeTmuxTrailingSemicolon).
2026-09-25 16:58:00 +02:00
*/
export function buildPasteTextCommands ( tmux : string , target : string , text : string ) : PasteTextCommands {
const buffer = `codeman-submit- ${ process . pid } - ${ ++ submitBufferSeq } ` ;
return {
2026-10-14 21:23:58 +02:00
set : ` ${ tmux } set-buffer -b ${ buffer } -- ${ shellescape ( escapeTmuxTrailingSemicolon ( text )) } ` ,
2026-09-25 16:58:00 +02:00
paste : ` ${ tmux } paste-buffer -p -d -b ${ buffer } -t ${ shellescape ( target ) } ` ,
discard : ` ${ tmux } delete-buffer -b ${ buffer } ` ,
};
}
/**
* Run a bracketed paste. A failed paste (the pane or the server is gone) deletes
* the buffer before the paste's own error propagates; a failing delete is ignored.
*/
export async function pasteText ( run : ( cmd : string ) => Promise < unknown >, cmds : PasteTextCommands ) : Promise < void > {
await run ( cmds . set );
try {
await run ( cmds . paste );
} catch ( err ) {
await run ( cmds . discard ). catch (() => undefined );
throw err ;
}
}
2026-06-09 10:38:35 -04:00
/**
* Pick the active pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`
* output (lines like `%0:1`). Returns the pane id whose active flag is 1.
*/
export function resolveActivePaneTarget ( output : string ) : string | null {
for ( const line of output . split ( '\n' )) {
const sep = line . indexOf ( ':' );
if ( sep === - 1 ) continue ;
const paneId = line . slice ( 0 , sep ). trim ();
const active = line . slice ( sep + 1 ). trim ();
if ( paneId && active === '1' ) return paneId ;
}
return null ;
}
type GraphemeSegmenter = {
segment ( input : string ) : Iterable < { segment : string } > ;
};
const GRAPHEME_SEGMENTER : GraphemeSegmenter | null = (() => {
try {
const Segmenter = (
Intl as typeof Intl & {
Segmenter? : new ( locale? : string , options ?: { granularity : 'grapheme' }) => GraphemeSegmenter ;
}
). Segmenter ;
return Segmenter ? new Segmenter ( undefined , { granularity : 'grapheme' }) : null ;
} catch {
return null ;
}
})();
function findEscapeEnd ( text : string , start : number ) : number {
const type = text [ start + 1 ];
if ( type === '[' ) {
for ( let i = start + 2 ; i < text . length ; i ++ ) {
const code = text . charCodeAt ( i );
if ( code >= 0x40 && code <= 0x7e ) return i ;
}
return text . length - 1 ;
}
if ( type === ']' ) {
for ( let i = start + 2 ; i < text . length ; i ++ ) {
if ( text . charCodeAt ( i ) === 0x07 ) return i ;
if ( text [ i ] === '\x1b' && text [ i + 1 ] === '\\' ) return i + 1 ;
}
return text . length - 1 ;
}
if ( type === 'P' || type === '^' || type === '_' || type === 'X' ) {
for ( let i = start + 2 ; i < text . length ; i ++ ) {
if ( text . charCodeAt ( i ) === 0x07 ) return i ;
if ( text [ i ] === '\x1b' && text [ i + 1 ] === '\\' ) return i + 1 ;
}
return text . length - 1 ;
}
return Math . min ( start + 1 , text . length - 1 );
}
function sanitizePaneLineStyles ( line : string ) : string {
let result = '' ;
for ( let i = 0 ; i < line . length ; i ++ ) {
if ( line [ i ] !== '\x1b' ) {
result += line [ i ];
continue ;
}
const end = findEscapeEnd ( line , i );
const sequence = line . slice ( i , end + 1 );
if ( isSgrSequence ( sequence )) {
result += sequence ;
}
i = end ;
}
return result ;
}
function isSgrSequence ( sequence : string ) : boolean {
return (
sequence . length >= 3 &&
sequence . charCodeAt ( 0 ) === 27 &&
sequence [ 1 ] === '[' &&
sequence . endsWith ( 'm' ) &&
/^[0-9;:]*$/ . test ( sequence . slice ( 2 , - 1 ))
);
}
function isZeroWidthCodePoint ( codePoint : number ) : boolean {
return (
codePoint === 0x00ad ||
codePoint === 0x034f ||
codePoint === 0x061c ||
codePoint === 0x115f ||
codePoint === 0x1160 ||
codePoint === 0x17b4 ||
codePoint === 0x17b5 ||
codePoint === 0x180e ||
codePoint === 0x200b ||
codePoint === 0x200c ||
codePoint === 0x200d ||
codePoint === 0x2060 ||
codePoint === 0xfeff ||
( codePoint >= 0x0300 && codePoint <= 0x036f ) ||
( codePoint >= 0x0483 && codePoint <= 0x0489 ) ||
( codePoint >= 0x0591 && codePoint <= 0x05bd ) ||
codePoint === 0x05bf ||
( codePoint >= 0x05c1 && codePoint <= 0x05c2 ) ||
( codePoint >= 0x05c4 && codePoint <= 0x05c5 ) ||
codePoint === 0x05c7 ||
( codePoint >= 0x0610 && codePoint <= 0x061a ) ||
( codePoint >= 0x064b && codePoint <= 0x065f ) ||
codePoint === 0x0670 ||
( codePoint >= 0x06d6 && codePoint <= 0x06dc ) ||
( codePoint >= 0x06df && codePoint <= 0x06e4 ) ||
( codePoint >= 0x06e7 && codePoint <= 0x06e8 ) ||
( codePoint >= 0x06ea && codePoint <= 0x06ed ) ||
codePoint === 0x0711 ||
( codePoint >= 0x0730 && codePoint <= 0x074a ) ||
( codePoint >= 0x07a6 && codePoint <= 0x07b0 ) ||
( codePoint >= 0x07eb && codePoint <= 0x07f3 ) ||
( codePoint >= 0x0816 && codePoint <= 0x0819 ) ||
( codePoint >= 0x081b && codePoint <= 0x0823 ) ||
( codePoint >= 0x0825 && codePoint <= 0x0827 ) ||
( codePoint >= 0x0829 && codePoint <= 0x082d ) ||
( codePoint >= 0x0859 && codePoint <= 0x085b ) ||
( codePoint >= 0x08d3 && codePoint <= 0x08e1 ) ||
( codePoint >= 0x08e3 && codePoint <= 0x0902 ) ||
( codePoint >= 0x093a && codePoint <= 0x093c ) ||
codePoint === 0x094d ||
( codePoint >= 0x0951 && codePoint <= 0x0957 ) ||
( codePoint >= 0x0962 && codePoint <= 0x0963 ) ||
( codePoint >= 0x1ab0 && codePoint <= 0x1aff ) ||
( codePoint >= 0x1dc0 && codePoint <= 0x1dff ) ||
( codePoint >= 0x20d0 && codePoint <= 0x20ff ) ||
( codePoint >= 0xfe00 && codePoint <= 0xfe0f ) ||
( codePoint >= 0xfe20 && codePoint <= 0xfe2f ) ||
( codePoint >= 0xe0100 && codePoint <= 0xe01ef )
);
}
function isWideCodePoint ( codePoint : number ) : boolean {
return (
codePoint >= 0x1100 &&
( codePoint <= 0x115f ||
codePoint === 0x2329 ||
codePoint === 0x232a ||
( codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f ) ||
( codePoint >= 0xac00 && codePoint <= 0xd7a3 ) ||
( codePoint >= 0xf900 && codePoint <= 0xfaff ) ||
( codePoint >= 0xfe10 && codePoint <= 0xfe19 ) ||
( codePoint >= 0xfe30 && codePoint <= 0xfe6f ) ||
( codePoint >= 0xff00 && codePoint <= 0xff60 ) ||
( codePoint >= 0xffe0 && codePoint <= 0xffe6 ) ||
( codePoint >= 0x1f300 && codePoint <= 0x1faff ) ||
( codePoint >= 0x20000 && codePoint <= 0x3fffd ))
);
}
function nextGrapheme ( text : string , start : number ) : { value : string ; nextIndex : number } {
if ( GRAPHEME_SEGMENTER ) {
const iterator = GRAPHEME_SEGMENTER . segment ( text . slice ( start ))[ Symbol . iterator ]();
const next = iterator . next ();
if ( ! next . done && next . value . segment ) {
return { value : next.value.segment , nextIndex : start + next . value . segment . length };
}
}
const first = text . codePointAt ( start );
if ( first === undefined ) return { value : '' , nextIndex : start + 1 };
let value = String . fromCodePoint ( first );
let nextIndex = start + value . length ;
while ( nextIndex < text . length ) {
const codePoint = text . codePointAt ( nextIndex );
if ( codePoint === undefined || ! isZeroWidthCodePoint ( codePoint )) break ;
const mark = String . fromCodePoint ( codePoint );
value += mark ;
nextIndex += mark . length ;
}
return { value , nextIndex };
}
function terminalCellWidth ( grapheme : string ) : number {
let hasVisible = false ;
let hasWide = false ;
for ( let i = 0 ; i < grapheme . length ; i ++ ) {
const codePoint = grapheme . codePointAt ( i );
if ( codePoint === undefined ) continue ;
if ( codePoint > 0xffff ) i ++ ;
if ( isZeroWidthCodePoint ( codePoint ) || codePoint < 0x20 || ( codePoint >= 0x7f && codePoint < 0xa0 )) {
continue ;
}
hasVisible = true ;
if ( isWideCodePoint ( codePoint )) hasWide = true ;
}
if ( ! hasVisible ) return 0 ;
return hasWide ? 2 : 1 ;
}
function truncatePaneLineByVisibleColumns ( line : string , maxColumns : number ) : string {
let result = '' ;
let visibleColumns = 0 ;
let sawSgr = false ;
for ( let i = 0 ; i < line . length ; i ++ ) {
if ( line [ i ] === '\x1b' ) {
const end = findEscapeEnd ( line , i );
const sequence = line . slice ( i , end + 1 );
if ( isSgrSequence ( sequence )) {
result += sequence ;
sawSgr = true ;
}
i = end ;
continue ;
}
const grapheme = nextGrapheme ( line , i );
const width = terminalCellWidth ( grapheme . value );
if ( width === 0 ) {
result += grapheme . value ;
} else if ( visibleColumns + width <= maxColumns ) {
result += grapheme . value ;
visibleColumns += width ;
} else {
break ;
}
i = grapheme . nextIndex - 1 ;
if ( visibleColumns >= maxColumns ) {
continue ;
}
}
if ( sawSgr ) {
result += '\x1b[0m' ;
}
return result ;
}
2026-06-20 09:43:05 -04:00
/**
* Normalize scrollback line endings to `\r\n` so a fresh xterm replays each line
* at column 0 (COD-138).
*
* `capture-pane -p -e -S -` (full-history capture) joins scrollback rows with a
* BARE `\n`. The browser xterm is created with the default `convertEol: false`
* (correct for the live PTY stream, which already carries real `\r\n`), so a bare
* `\n` drops a row without returning the cursor to column 0. Replaying that raw
* buffer on a full page reload makes every line start one column further right —
* the diagonal "staircase". The visible/tab-switch path avoids this by repainting
* each row with an absolute cursor CSI (`formatPaneSnapshot`); the full-history
* path returns raw scrollback, so it must be CRLF-normalized here.
*
* `\r?\n → \r\n` is idempotent on already-CRLF input and leaves a lone `\r` (an
* intentional in-line column reset / overwrite) untouched.
*/
export function normalizeScrollbackEol ( buffer : string ) : string {
return buffer . replace ( /\r?\n/g , '\r\n' );
}
2026-10-10 23:32:04 -05:00
/** `capture-pane -p` text as rows: tmux ends it with one `\n`, and '' has none. */
function captureRows ( text : string ) : string [] {
return text === '' ? [] : text . replace ( /\n$/ , '' ). split ( '\n' );
}
/**
* Least overlap that places a frame in a screen: rows compared, and how many
* of them hold text. A placement too LOW repeats the rows between the true one
* and it, so a short overlap (one `$` or `❯ ` row, a rule line) is not trusted:
* past this, the plain join, which can only lose rows, is the safer answer.
*/
const FRAME_MATCH_MIN_ROWS = 3 ;
const FRAME_MATCH_MIN_CONTENT_ROWS = 2 ;
/**
* A captured row as plain text: escape sequences (SGR, OSC 8 links) and
* trailing blanks dropped. Two tmuxes render the same cell content, but need
* not encode its attributes the same way.
*/
function capturedRowText ( row : string ) : string {
// eslint-disable-next-line no-control-regex
return row . replace ( /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]/g , '' ). trimEnd ();
}
/**
* Where a later frame of the same pane begins among the rows of an earlier
* screen capture, or -1 when that cannot be told.
*
* The frame's top rows must equal the screen's rows from that index on, as
* text. Two rows are left out of the comparison: the screen's trailing blank
* rows (output after the screen read fills them in the frame) and its last
* content row (output in flight may have finished it since). Every compared
* row must match, the overlap must reach FRAME_MATCH_MIN_ROWS rows with
* FRAME_MATCH_MIN_CONTENT_ROWS of them non-blank, and the SMALLEST index wins,
* so a block that merely repeats further down is only chosen when nothing
* above it matches. A row that changed between the two reads (a spinner, a
* clock) fails the true placement; the minimum keeps a lower one from being
* taken on a row or two that happen to be equal.
2026-10-11 18:54:20 -05:00
*
* An agent TUI mid-turn (Claude Code, codex, opencode) is not placed: it keeps
* a live region (spinner, composer, footer) pinned to the bottom and inserts new
* rows above it, so the frame is not a shifted copy of the screen and the
* comparison down to the screen's last content row fails. The caller then falls
2026-10-13 18:04:36 +02:00
* back to the plain join. A plain shell places, unless the frame starts on the
* second half of a wrapped line: -J holds that line as one screen row, so it
* matches nothing and the plain join applies too.
2026-10-10 23:32:04 -05:00
*/
export function findFrameInScreen ( screenRows : string [], frameRows : string []) : number {
const screen = screenRows . map ( capturedRowText );
const frame = frameRows . map ( capturedRowText );
let lastContent = screen . length - 1 ;
while ( lastContent >= 0 && screen [ lastContent ] === '' ) lastContent -- ;
for ( let start = 0 ; start < lastContent ; start ++ ) {
const overlap = Math . min ( lastContent - start , frame . length );
if ( overlap < FRAME_MATCH_MIN_ROWS ) break ; // only shrinks from here on
let matches = true ;
let contentRows = 0 ;
for ( let k = 0 ; k < overlap && matches ; k ++ ) {
if ( screen [ start + k ] !== frame [ k ]) matches = false ;
else if ( frame [ k ] !== '' ) contentRows ++ ;
}
if ( matches && contentRows >= FRAME_MATCH_MIN_CONTENT_ROWS ) return start ;
}
return - 1 ;
}
2026-10-09 23:25:45 -05:00
/**
* Put scrollback rows read elsewhere (`PaneCaptureOptions.scrollbackOverride`)
* above a visible-frame capture, as one capture in tmux's own shape: rows joined
* by a bare `\n`. Undefined or empty scrollback leaves the frame as it is.
2026-10-10 23:32:04 -05:00
*
* `screen` is that other pane's screen, read right after its scrollback. The
* frame is captured later, so rows can have scrolled out of it in between, and
* a frame showing part of a taller screen starts below that screen's top.
* Either way the screen rows above the frame's first row are in neither the
* scrollback nor the frame, so they go between the two. When the frame cannot
* be placed in the screen ({@link findFrameInScreen}), the join is the plain
* scrollback-over-frame one.
2026-10-09 23:25:45 -05:00
*/
2026-10-10 23:32:04 -05:00
export function joinScrollbackAndFrame ( scrollback : string | undefined , frame : string , screen? : string ) : string {
let above = scrollback ? captureRows ( scrollback ) : [];
if ( screen ) {
const screenRows = captureRows ( screen );
const frameStart = findFrameInScreen ( screenRows , captureRows ( frame ));
2026-10-13 18:04:29 +02:00
if ( frameStart > 0 ) {
// The remote screen is a capture of its own too, encoded from the default
// state: the history's last row must not leave its attributes active.
if ( above . length > 0 ) above [ above . length - 1 ] += '\x1b[0m' ;
above = above . concat ( screenRows . slice ( 0 , frameStart ));
}
2026-10-10 23:32:04 -05:00
}
2026-10-13 18:04:05 +02:00
// Separate captures: tmux encodes the frame relative to the DEFAULT state, so
// the rows above it must not leave their attributes active.
return above . length > 0 ? ` ${ above . join ( '\n' ) } \ x1b[0m \ n ${ frame } ` : frame ;
2026-10-09 23:25:45 -05:00
}
2026-09-09 15:13:15 +02:00
/** Pane geometry and caret position, as `display-message` reports them. */
interface PaneCursorGeometry {
cols : number ;
rows : number ;
cursorX : number ;
cursorY : number ;
2026-10-10 05:56:37 +02:00
/**
* `#{history_size}`: rows tmux holds ABOVE the visible frame, i.e. what a
* full-history capture can add. Absent when the query did not return it.
* Optional and validated on its own, so a bad value never voids the caret.
*/
historyLines? : number ;
2026-10-10 23:29:19 -05:00
/** Whether the pane is in the alternate screen (`#{alternate_on}`), when tmux reported it. */
alternateOn? : boolean ;
2026-09-09 15:13:15 +02:00
}
/**
* Read the pane's cursor and size, or null when tmux cannot say.
*
2026-10-09 19:18:05 -05:00
* The four geometry fields are validated together: a caller that gets a value
* back can place a caret with it, and one that gets null must not try. The
2026-10-10 23:29:19 -05:00
* optional fifth and sixth fields, the history depth and the alternate-screen
* flag, are only informational, so a bad one is dropped rather than
* discarding a good caret.
2026-09-09 15:13:15 +02:00
*/
export function queryPaneCursor ( run : () => string ) : PaneCursorGeometry | null {
let raw : string ;
try {
raw = run (). trim ();
} catch ( cursorErr ) {
console . error ( '[TmuxManager] Failed to query pane cursor after capture:' , cursorErr );
return null ;
}
2026-10-13 17:59:07 +02:00
const [ cursorX , cursorY , cols , rows , historyLines , alternateOn ] = raw
. split ( /\s+/ )
. map (( value ) => parseInt ( value , 10 ));
2026-09-09 15:13:15 +02:00
if (
! Number . isFinite ( cursorX ) ||
! Number . isFinite ( cursorY ) ||
! Number . isFinite ( cols ) ||
! Number . isFinite ( rows ) ||
cursorX < 0 ||
cursorY < 0 ||
cols <= 0 ||
rows <= 0
) {
return null ;
}
2026-10-10 05:56:37 +02:00
const geometry : PaneCursorGeometry = { cols , rows , cursorX , cursorY };
if ( Number . isFinite ( historyLines ) && historyLines >= 0 ) geometry . historyLines = historyLines ;
2026-10-10 23:29:19 -05:00
if ( alternateOn === 0 || alternateOn === 1 ) geometry . alternateOn = alternateOn === 1 ;
2026-10-10 05:56:37 +02:00
return geometry ;
2026-09-09 15:13:15 +02:00
}
/** SGR attributes, which is all `capture-pane -e` emits. */
// eslint-disable-next-line no-control-regex
const CAPTURE_STYLE_SEQUENCE = /\x1b\[[0-9;:]*m/g ;
/** Whether a capture holds anything a reader would see, styles discounted. */
export function hasVisibleContent ( capture : string ) : boolean {
return /\S/ . test ( capture . replace ( CAPTURE_STYLE_SEQUENCE , '' ));
}
/**
* Put the caret back where the pane has it, counting UP from the bottom of what
* was just replayed.
*
* Relative rather than absolute (`CUP`) on purpose. `\x1b[<row>;<col>H` numbers
* rows from the top of the browser's screen, so it only lands correctly while
* the browser's row count equals the pane's — and it need not, because
* `resizeWindow` fires its tmux resize without waiting, so a capture can be
* taken before a requested resize has been applied. Counting up from the last
* replayed row is anchored to the content instead, which is the thing both ends
* genuinely share.
*/
export function formatCursorRestore ( geometry : PaneCursorGeometry ) : string {
const up = Math . max ( 0 , geometry . rows - 1 - geometry . cursorY );
const right = Math . max ( 0 , geometry . cursorX );
// `\r` first so the column is known: the replay leaves the caret wherever the
// last row's text ended.
return ` ${ up > 0 ? ` \ x1b[ ${ up } A` : '' } \ r ${ right > 0 ? ` \ x1b[ ${ right } C` : '' } ` ;
}
2026-10-25 09:22:35 -04:00
/**
* Split the output of `capture-pane -p \; display-message -p '<cursor format>'`
* into the captured rows and the cursor line, which tmux prints last. Null unless
* that last line has the cursor query's shape (four leading integers), so a query
* that printed nothing can never have the frame's own last row taken for it.
*/
export function splitFrameAndCursor ( output : string ) : { frame : string ; cursor : string } | null {
const body = output . endsWith ( '\n' ) ? output . slice ( 0 , - 1 ) : output ;
const split = body . lastIndexOf ( '\n' );
const cursor = body . slice ( split + 1 );
if ( ! /^\d+ \d+ \d+ \d+(?: |$)/ . test ( cursor )) return null ;
return { frame : body.slice ( 0 , split + 1 ), cursor };
}
2026-06-09 10:38:35 -04:00
export function formatPaneSnapshot (
lines : string [],
geometry : { cols : number ; rows : number ; cursorX : number ; cursorY : number }
) : string {
const cols = Math . max ( 1 , geometry . cols );
2026-06-10 19:56:20 -04:00
// Paint the full pane width. Earlier this dropped the rightmost column
// (cols - 1) out of caution about last-column autowrap, but every painted
// row is immediately followed by an absolute cursor-position CSI (the next
// row's `\x1b[r;1H`, or the final cursor move), which cancels xterm's
// pending-wrap state before any further glyph — so the last column is safe.
const paintCols = cols ;
2026-06-09 10:38:35 -04:00
const rows = Math . max ( 1 , geometry . rows );
const parts : string [] = [];
for ( let row = 0 ; row < Math . min ( lines . length , rows ); row ++ ) {
const safeLine = truncatePaneLineByVisibleColumns ( sanitizePaneLineStyles ( lines [ row ]), paintCols );
parts . push ( ` \ x1b[ ${ row + 1 } ;1H ${ safeLine } ` );
}
const cursorX = Math . max ( 0 , Math . min ( cols - 1 , geometry . cursorX ));
const cursorY = Math . max ( 0 , Math . min ( rows - 1 , geometry . cursorY ));
parts . push ( ` \ x1b[ ${ cursorY + 1 } ; ${ cursorX + 1 } H` );
return parts . join ( '' );
}
2026-03-25 23:21:38 +01:00
/** Characters unsafe in paths — shell metacharacters, quotes, and control chars */
const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/ ;
2026-02-12 10:29:57 +01:00
/**
* Validates that a session name contains only safe characters.
* Prevents command injection via malformed session IDs.
*/
function isValidMuxName ( name : string ) : boolean {
2026-02-26 16:44:34 +01:00
return SAFE_MUX_NAME_PATTERN . test ( name ) || LEGACY_MUX_NAME_PATTERN . test ( name );
2026-02-12 10:29:57 +01:00
}
2026-06-09 10:38:35 -04:00
function isValidTerminalDimension ( value : number ) : boolean {
return Number . isSafeInteger ( value ) && value > 0 && value <= 1000 ;
}
2026-02-12 10:29:57 +01:00
/**
* Validates that a path contains only safe characters.
* Prevents command injection via malformed paths.
*/
function isValidPath ( path : string ) : boolean {
2026-03-25 23:21:38 +01:00
if ( UNSAFE_PATH_CHARS . test ( path )) {
2026-02-12 10:29:57 +01:00
return false ;
}
if ( path . includes ( '..' )) {
return false ;
}
return SAFE_PATH_PATTERN . test ( path );
}
2026-05-26 05:28:58 +08:00
// ===========================================================================
// 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 ) } ` );
}
2026-08-16 19:29:49 +02:00
return resolveTmuxSocketName ();
2026-05-26 05:28:58 +08:00
}
/** Build the `tmux -L <socket>` command prefix. Socket name is shell-escaped. */
function tmuxCommand ( socket : string ) : string {
return `tmux -L ${ shellescape ( socket ) } ` ;
}
2026-02-24 15:52:55 +01:00
/**
* Build Claude CLI permission flags for the tmux command string.
* Validates allowedTools to prevent command injection.
*/
function buildClaudePermissionFlags ( claudeMode? : ClaudeMode , allowedTools? : string ) : string {
const mode = claudeMode || 'dangerously-skip-permissions' ;
switch ( mode ) {
case 'dangerously-skip-permissions' :
return ' --dangerously-skip-permissions' ;
2026-07-20 02:48:14 +02:00
case 'auto' :
return ' --permission-mode auto' ;
2026-02-24 15:52:55 +01:00
case 'allowedTools' :
if ( allowedTools ) {
// Sanitize: allow tool names with patterns like Bash(git:*), space/comma-separated
// Block shell metacharacters: ; & | $ ` \ { } < > ' " newlines
const hasDangerousChars = /[;&|$`\\{}<>'"[\]\n\r]/ . test ( allowedTools );
if ( ! hasDangerousChars ) {
return ` --allowedTools " ${ allowedTools } "` ;
}
}
// Fall back to normal mode if tools are invalid or missing
return '' ;
case 'normal' :
return '' ;
}
}
2026-02-26 10:07:43 +01:00
/**
2026-09-02 08:26:45 +08:00
* Build the codex CLI command.
2026-06-10 09:31:58 -04:00
*
2026-09-02 08:26:45 +08:00
* Kept as a named wrapper purely because callers (and `test/tmux-manager.test.ts`) reach for
* it directly; the command itself is registry data now, like every other CLI's. The `??`
* fallback covers a registry in which codex has been disabled or removed — this function
* promises a string, so it degrades to the bare binary rather than throwing.
2026-06-10 09:31:58 -04:00
*/
export function buildCodexCommand ( config? : CodexConfig ) : string {
2026-09-02 08:26:45 +08:00
const entry = getCli ( 'codex' );
if ( ! entry ) return 'codex' ;
return buildSpawnCommandFromRegistry ( entry , { mode : 'codex' , sessionId : '' , codexConfig : config }) ?? 'codex' ;
2026-08-09 13:23:24 +02:00
}
2026-10-18 13:09:06 -04:00
/**
* Whether a launch carries `idleCompaction: false` in its one `--settings` JSON: keep-warm on
* at launch (`keepWarmOn`), the statusLine exporter injected (without its `prompt_cache` reports
* keep-warm cannot run, and the opt-out alone would only cost the user Claude Code's own idle
* compaction), and the CLI's `idleCompactionSetting` gate passed, fail-closed like `--name`:
* an unknown version means no key. Pure; `claudeCliVersion` as in buildSpawnCommand.
*/
export function decideIdleCompactionOptOut ( o : {
mode : SessionMode ;
statusLine : StatusLineExporter | undefined ;
keepWarm : boolean | undefined ;
keepWarmDefault : boolean ;
spawnedByAgent : boolean | undefined ;
claudeCliVersion? : string | null ;
}) : boolean {
return (
o . statusLine === 'injected' &&
keepWarmOn ( o . keepWarm , o . keepWarmDefault , !! o . spawnedByAgent ) &&
launchGatePasses ( o . mode , 'idleCompactionSetting' , o . claudeCliVersion )
);
}
2026-07-20 02:48:14 +02:00
export function buildSpawnCommand ( options : {
2026-02-26 10:07:43 +01:00
mode : SessionMode ;
sessionId : string ;
model? : string ;
claudeMode? : ClaudeMode ;
allowedTools? : string ;
openCodeConfig? : OpenCodeConfig ;
2026-06-10 09:31:58 -04:00
codexConfig? : CodexConfig ;
2026-05-16 20:10:49 -04:00
geminiConfig? : GeminiConfig ;
2026-08-04 12:59:40 +02:00
antigravityConfig? : AntigravityConfig ;
2026-08-13 13:54:47 +02:00
piConfig? : PiConfig ;
2026-08-23 08:39:03 +02:00
grokConfig? : GrokConfig ;
2026-10-10 14:06:04 +08:00
copilotConfig? : CopilotConfig ;
2026-08-24 03:37:56 +02:00
deepSeekConfig? : DeepSeekConfig ;
2026-08-18 23:17:58 -05:00
ompConfig? : OmpConfig ;
2026-03-10 02:22:53 +08:00
resumeSessionId? : string ;
2026-06-07 10:33:11 +08:00
effort? : EffortLevel ;
2026-10-04 19:05:43 +02:00
/** Claude advisor model, merged into the launch's one `--settings` JSON (see buildAdvisorSettings). */
advisorModel? : string ;
2026-08-30 17:31:25 -05:00
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
statusLineCommand? : string ;
2026-10-18 13:09:06 -04:00
/** Add `idleCompaction: false` to the launch's one `--settings` JSON (see decideIdleCompactionOptOut). */
idleCompactionOff? : boolean ;
2026-10-18 13:58:09 +08:00
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). By default only a user-chosen name; `pinCliSessionName` pins every tab name: see `Session.cliPinnedName`. */
2026-08-09 13:23:24 +02:00
sessionName? : string ;
/**
2026-11-01 06:21:38 +01:00
* CLI version for the launch template's version gates (claude's `--name`, codex's
* `--no-daemon`). Omitted = probe the mode's own local CLI (resolveSessionCliVersion;
* null under vitest). The name predates the second gated CLI. Tests inject a value here; the
2026-08-09 13:23:24 +02:00
* docker/remote paths never see this builder's output, which is what keeps the
2026-08-09 13:23:52 +02:00
* gate measuring the RIGHT binary, the local one.
2026-08-09 13:23:24 +02:00
*/
claudeCliVersion? : string | null ;
2026-02-26 10:07:43 +01:00
}) : string {
2026-09-02 08:26:45 +08:00
// Every CLI's command shape is registry DATA, rendered by the argv engine — see
// config/cli-registry/argv.ts for why config can never contain shell text. A `shell`-kind
// entry (or an unregistered mode) renders `undefined` and falls through to the local
// login-shell resolution below, which cannot be templated because it varies per user.
const entry = getCli ( options . mode );
if ( entry ) {
const rendered = buildSpawnCommandFromRegistry ( entry , options );
if ( rendered !== undefined ) return rendered ;
2026-08-24 03:37:56 +02:00
}
2026-08-04 23:09:00 +02:00
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
// so a `$SHELL` here is expanded by the SERVER process's shell against the
// SERVER process's env — empty in containers and system systemd units, leaving
// the pane command ending in a dangling `&&` ("syntax error: unexpected end of
// file", pane dead on arrival). Resolve it in Node and quote the result.
2026-08-05 01:40:37 +02:00
// #209: launch it as a LOGIN shell, which is what tmux itself does for a pane
// with no `default-command`, so a Codeman shell tab matches a hand-started tmux
// one. That is what picks up /etc/profile and /etc/profile.d/* — a systemd
// --user service never sourced them, so its PATH is what every pane inherited.
// The flags come from loginShellArgs() rather than being hardcoded: they are
// appended to a path that ultimately comes from the passwd entry, and a shell
// that rejects an unknown flag exits on the spot, which is #208 all over again.
const shell = resolveLocalShell ();
return ` ${ shellescape ( shell ) }${ loginShellArgs ( shell ) } ` ;
2026-02-26 10:07:43 +01:00
}
2026-07-12 19:49:39 +02:00
/**
* Dedicated socket for Codeman-launched REMOTE tmux servers, distinct from the
* canonical local `-L codeman` socket. A remote host that runs its OWN Codeman
* would otherwise share the `-L codeman` socket AND the `codeman-<hex>` discovery
* name, so its `reconcileSessions()` would ADOPT our session (attach a PTY,
* resize, respawn-pane it locally) — the cross-machine form of the "2nd instance
* attaches live sessions" hazard. A private socket keeps our remote sessions off
* that instance's radar entirely.
*/
const REMOTE_TMUX_SOCKET = 'codeman-remote' ;
2026-06-13 10:21:56 -04:00
/**
* Deterministic, reattach-stable remote tmux session name for a Codeman session.
*
2026-07-12 19:49:39 +02:00
* Derived from the same stable field the LOCAL muxName uses (the first 8 chars of
* the sessionId), so reconnecting (which re-issues the exact same
* `ssh … new-session -A`) lands back in the SAME remote session. Must NOT be
* random/time-based — it has to be stable across reconnects.
*
* The `codeman-ssh-` prefix is deliberately chosen to FAIL a remote Codeman's
* `SAFE_MUX_NAME_PATTERN` (`^codeman-[a-f0-9-]+$`) — the `s`/`h` letters mean a
* remote instance's discovery never treats this as one of its own sessions (belt
* to the dedicated-socket suspenders above).
2026-06-13 10:21:56 -04:00
*/
export function remoteTmuxSessionName ( sessionId : string ) : string {
2026-07-12 19:49:39 +02:00
return `codeman-ssh- ${ sessionId . slice ( 0 , 8 ) } ` ;
2026-06-13 10:21:56 -04:00
}
2026-10-09 23:25:45 -05:00
/**
* The remote tmux socket and session a remote Codeman session's pane is attached
* to, matching the command that attached it: an owned session is the one
* `buildRemoteLaunchCommand` created on the private socket, a non-owned one is the
* discovered session `buildRemoteAttachCommand` joined on `-L codeman`.
*/
export function remoteTmuxLocation ( remote : SessionRemote , sessionId : string ) : { socket : string ; sessionName : string } {
if ( remote . owned === false ) {
return { socket : 'codeman' , sessionName : remote.remoteSessionName || remoteTmuxSessionName ( sessionId ) };
}
return { socket : REMOTE_TMUX_SOCKET , sessionName : remoteTmuxSessionName ( sessionId ) };
}
2026-06-13 10:21:56 -04:00
/**
* COD-104 — build the SSH command that launches (or reattaches) a remote
* session INSIDE a tmux server on the remote host, so the remote agent survives
* an SSH drop.
*
* Emits:
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
2026-11-06 02:39:42 +01:00
* 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path> /bin/sh -c <pane-command> \
2026-07-12 19:49:39 +02:00
* \; set -t codeman-ssh-<id> status off \; set -t codeman-ssh-<id> mouse off \
2026-10-23 07:41:46 +00:00
* \; set -t codeman-ssh-<id> prefix C-q \; set -s escape-time 0 \; \
2026-10-23 09:08:48 +00:00
* bind-key -T prefix C-q send-prefix \; unbind-key -a -T prefix \; \
2026-10-24 14:10:07 +02:00
* bind-key -T prefix C-q send-prefix \; set -t codeman-ssh-<id> remain-on-exit failed'
2026-06-13 10:21:56 -04:00
*
2026-11-06 02:39:42 +01:00
* `<pane-command>` is `cd <path> && exec <cli>`, quoted as one token. A per-host `commands.*`
* override is that one token alone, with no `/bin/sh -c` in front of it.
*
2026-06-13 10:21:56 -04:00
* COD-107 — the connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
* arbitrary `-o`) come from the shared `buildSshConnectionArgs(remote)`, so the
* prereq tmux probe and this launch connect with identical options.
*
2026-07-12 19:49:39 +02:00
* - `new-session -A -s codeman-ssh-<id>` = attach-if-exists-else-create
* (idempotent), so reconnect re-runs the same command and reattaches the
* still-running agent.
* - `-L codeman-remote` = a DEDICATED socket, NOT the canonical `-L codeman` a
* remote Codeman would use, so our session never collides with / gets adopted by
* an instance running on the remote host.
* - The `set` options are scoped per-session (`set -t <name>` / server-level
* `set -s`), never `-g`, so they never mutate other sessions' prefix/mouse.
2026-06-13 10:21:56 -04:00
* - The whole tmux invocation is a SINGLE ssh argument (the remote login shell
2026-11-06 00:59:46 +01:00
* runs it), so it is shell-quoted as one unit. The `cd && exec` command is the
* pane command: Codeman's own text goes to `/bin/sh -c` as separate tmux
* arguments, and a per-host `commands.*` override stays one argument, which
* tmux hands to the remote default-shell (see the comment at `paneCommand`).
* Each is shell-quoted, so escaping stays correct through every layer even
* when the remote path contains spaces.
2026-06-13 10:21:56 -04:00
*/
export function buildRemoteLaunchCommand ( options : {
mode : SessionMode ;
remote : SessionRemote ;
sessionId : string ;
2026-07-20 12:33:12 +02:00
claudeMode? : ClaudeMode ;
allowedTools? : string ;
2026-08-29 15:21:33 -05:00
/** OMP only — resume/continue overrides for the remote omp relaunch (dead-pane respawn). */
ompConfig? : OmpConfig ;
resumeSessionId? : string ;
2026-06-13 10:21:56 -04:00
}) : string {
2026-08-29 15:21:33 -05:00
const { mode , remote , sessionId , claudeMode , allowedTools , ompConfig , resumeSessionId } = options ;
2026-07-20 12:33:12 +02:00
// §6.3: honor the session's EFFECTIVE claude permission mode on remote instead of
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
// ignored claudeMode). A per-host `commands.claude` override stays authoritative
2026-11-06 06:05:53 +01:00
// (admin's explicit choice). Wrapped in the login shell (`remoteLoginShellCommand`) for
// the same reason as `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH
// entry that only an interactive login shell resolves (see that function's comment).
2026-07-20 12:33:12 +02:00
const override = remote . commands ? .[ mode ];
2026-08-29 12:57:17 -05:00
let modeCommand : string ;
if ( override ) {
modeCommand = override ;
} else if ( mode === 'claude' ) {
// Deterministic conversation pinning for SSH-remote claude (mirrors the
2026-09-07 22:11:54 -05:00
// docker-claude shape in claudeDockerPaneCommand, INCLUDING the distinct
// resumeId branch it declares — this used to only mirror the same-id
// fallback shape, silently dropping an explicit resumeSessionId that
// differs from sessionId, e.g. a resume-from-history launch): the FIRST
// run creates the conversation under --session-id <sessionId>; a respawn
// / reattach re-runs the same idempotent command, --session-id exits
// non-zero ("already in use"), and the `||` fallback RESUMES that same
2026-08-29 12:57:17 -05:00
// conversation. Without a pinned id, every reattach relaunched a bare
// `claude` and started a NEW conversation (found live 2026-08-29: remote
// claude ctrl-d / ctrl-c relaunched a fresh session). A per-host
// `commands.claude` override stays authoritative (admin's explicit
// choice) and skips this entirely.
const permFlags = buildClaudePermissionFlags ( claudeMode , allowedTools );
2026-09-07 22:11:54 -05:00
const cmd = `claude ${ permFlags } ` ;
// Defense in depth, mirroring claudeDockerPaneCommand's own belt-and-braces check:
// sessionId is server-minted and always safe in practice, but this command is built
// as a single shellescaped string and then executed as shell code on the remote
// host, so an unsafe value here is validated rather than trusted.
if ( ! RESUME_ID_SAFE . test ( sessionId )) {
modeCommand = remoteLoginShellCommand ( cmd );
} else {
const rid = resumeSessionId && RESUME_ID_SAFE . test ( resumeSessionId ) ? resumeSessionId : undefined ;
modeCommand = remoteLoginShellCommand (
rid && rid !== sessionId
? ` ${ cmd } --resume ${ rid } || ${ cmd } --session-id ${ sessionId } `
: ` ${ cmd } --session-id ${ sessionId } || ${ cmd } --resume ${ sessionId } `
);
}
2026-08-29 15:21:33 -05:00
} else if ( mode === 'omp' ) {
// Remote OMP respawn must RESUME the same conversation instead of
// relaunching fresh (found live 2026-08-29: remote ctrl-c/ctrl-d relaunched
// a brand-new omp session). The pinned id, when known, is passed as an
// explicit --resume; otherwise fall back to omp's own "most recent"
// --continue so a dead-pane respawn still lands back in the conversation.
// Rendered through the CLI registry (buildSpawnCommandFromRegistry), the
// SAME mode-agnostic path local/docker spawns use — not appendResumeFlag(),
// which would hand the id to the login shell as $0 after the quoted `-c
// 'omp'`, and not a raw buildOmpCommand() call, which the registry refactor
// (#347) deleted. Gives every registry CLI with a resume form this
// behaviour for free, and the flags can't drift from the local builder.
const ompEntry = getCli ( 'omp' );
const ompCmd = ompEntry
? ( buildSpawnCommandFromRegistry ( ompEntry , {
mode : 'omp' ,
sessionId ,
ompConfig : {
... ompConfig ,
resumeSessionId : resumeSessionId || ompConfig ? . resumeSessionId ,
},
}) ?? 'omp' )
: 'omp' ;
modeCommand = remoteLoginShellCommand ( ompCmd );
2026-08-29 12:57:17 -05:00
} else {
modeCommand = defaultRemoteCommandForMode ( mode );
}
2026-06-13 10:21:56 -04:00
const remoteName = remoteTmuxSessionName ( sessionId );
2026-11-06 00:59:46 +01:00
// Innermost: the command tmux runs in the new pane. `exec` replaces the shell
// with the CLI so the pane PID is the agent itself.
//
// tmux runs a ONE-argument command through the remote default-shell, which is
// the user's login shell, and bypasses it for two or more arguments (both
// measured on tmux 3.4). Codeman's own text is POSIX (`"${SHELL:-/bin/sh}"` is
// a parse error in fish, status 127, a dead pane on every new session), so it
// goes to `/bin/sh -c` as separate arguments and no login shell has to parse it.
// An override is the user's own text for THEIR shell: it stays one argument,
// because `source` and `[[` work in bash and zsh and die in a dash `/bin/sh`.
2026-06-13 10:21:56 -04:00
const paneCommand = `cd ${ shellescape ( remote . remotePath ) } && ${ modeCommand } ` ;
2026-11-06 00:59:46 +01:00
const paneArgv = ` ${ override ? '' : '/bin/sh -c ' }${ shellescape ( paneCommand ) } ` ;
2026-06-13 10:21:56 -04:00
// The tmux command line, with `\;` separating commands so the config `set`s
2026-07-12 19:49:39 +02:00
// apply on the SAME connection (and are idempotent on reattach). Options are
// scoped per-session (`set -t <name>` / server `set -s`), NEVER `-g`, so a
// shared remote tmux server's other sessions keep their own prefix/mouse.
2026-06-13 10:21:56 -04:00
const tmuxInvocation = [
2026-11-06 00:59:46 +01:00
`tmux -L ${ REMOTE_TMUX_SOCKET } new-session -A -s ${ remoteName } -c ${ shellescape ( remote . remotePath ) } ${ paneArgv } ` ,
2026-07-12 19:49:39 +02:00
`set -t ${ remoteName } status off` ,
`set -t ${ remoteName } mouse off` ,
2026-10-23 07:41:46 +00:00
`set -t ${ remoteName } prefix ${ NESTED_TMUX_PREFIX } ` ,
2026-07-12 19:49:39 +02:00
'set -s escape-time 0' ,
2026-06-13 15:17:21 -04:00
// COD-106 — shared/collaborative sessions: tmux defaults to sizing a window
// to the SMALLEST attached client, so two Codemans at different viewports
// would fight (clamp to the smaller). `window-size latest` sizes to the
// most-recently-active client instead, so concurrent clients coexist.
// Per-session scoped (`set -t <name>`, matching #145's hardening) so a shared
// remote tmux server's other sessions keep their own sizing behavior.
`set -t ${ remoteName } window-size latest` ,
2026-10-23 07:41:46 +00:00
// The prefix TABLE is server-global (unlike `prefix C-q`, which is per
// session). This socket is dedicated to Codeman's owned sessions, so the
// same reduction as the local socket applies: Ctrl+Q then )/(/s/w/L must
2026-10-23 09:08:48 +00:00
// not switch-client / choose-tree onto a sibling. Bind, then unbind-all,
// then bind again: a user config that left the prefix table empty makes
// `unbind-key -a -T prefix` fail and abort the rest of this chain.
// BEFORE remain-on-exit, which stays last because an old remote tmux
// rejects `failed` and aborts the rest of the chain.
2026-10-23 07:41:46 +00:00
... nestedPrefixTableResetCommands (),
2026-08-05 01:40:37 +02:00
// #210: keep a CRASHED pane so the failure is still on screen. Without this,
// tmux destroys the pane -> window -> session (and, being the only session,
// the whole remote server) the instant the pane command exits, which tears the
// local `ssh -t` attach down with it; reconnect's `-A` then builds a fresh
// session and the cycle can repeat as a flap loop with no evidence surviving.
// That is how the exit-127 PATH bug fixed above stayed invisible.
//
// `failed`, NOT `on`: `on` keeps the pane on a CLEAN exit too, so typing
// `exit` in a remote shell leaves a dead pane behind, the session outlives it,
// and the next launch's `-A` reattaches to that corpse ("Pane is dead (status
// 0)") instead of starting a shell — verified against a real tmux. `failed`
// keeps the pane only on a non-zero exit, which is exactly the diagnostic case.
//
// LAST in the chain on purpose: tmux aborts the remaining commands of a `\;`
// sequence once one errors (also verified), and `failed` needs tmux >= 3.2 on
// the REMOTE host. Trailing, a rejection costs only this option; leading, it
2026-10-23 07:41:46 +00:00
// would silently drop status/mouse/prefix/escape-time/window-size and the
// prefix-table reduction with it.
2026-08-05 01:40:37 +02:00
`set -t ${ remoteName } remain-on-exit failed` ,
2026-06-13 10:21:56 -04:00
]. join ( ' \\; ' );
// ssh runs its trailing args through the remote login shell, so the entire
// tmux invocation is passed as one shell-quoted argument.
//
// COD-107 — connection options (port, identity, SOCKS ProxyCommand, jump host,
// arbitrary -o) come from the shared `buildSshConnectionArgs` so the launch and
// the tmux-prereq probe connect IDENTICALLY. `-t` is inserted right after
// `ssh -o BatchMode=yes` (preserving the historical token order), then the rest
// of the connection args, then the target and the quoted tmux invocation.
const [ ssh , batchMode , ... connectionArgs ] = buildSshConnectionArgs ( remote );
2026-10-23 17:27:18 +04:00
const sshParts = [
ssh ,
batchMode ,
'-t' ,
... connectionArgs ,
remoteSshTarget ( remote ),
shellescape ( withRemoteTmuxPath ( tmuxInvocation )),
];
2026-06-13 10:21:56 -04:00
return sshParts . join ( ' ' );
2026-06-11 18:43:39 +00:00
}
2026-07-12 19:49:39 +02:00
/**
* Build the SSH command that kills the durable remote tmux session created by
* `buildRemoteLaunchCommand`. Because that session lives on a private socket
* (`-L codeman-remote`) under a stable name, killing the LOCAL ssh wrapper alone
* would orphan the remote agent forever (invisible to Codeman, still burning plan
* quota). This is fired best-effort on session kill; the shared connection args
* carry the default `-o ConnectTimeout=10` so an unreachable host fails fast.
*/
export function buildRemoteKillCommand ( options : { remote : SessionRemote ; sessionId : string }) : string {
const { remote , sessionId } = options ;
const remoteName = remoteTmuxSessionName ( sessionId );
const killCmd = `tmux -L ${ REMOTE_TMUX_SOCKET } kill-session -t ${ shellescape ( remoteName ) } ` ;
const [ ssh , ... connectionArgs ] = buildSshConnectionArgs ( remote );
2026-10-23 17:27:18 +04:00
return [ ssh , ... connectionArgs , remoteSshTarget ( remote ), shellescape ( withRemoteTmuxPath ( killCmd ))]. join ( ' ' );
2026-07-12 19:49:39 +02:00
}
2026-07-19 15:09:48 +02:00
// ========== Docker cases (COD-Docker) ==========
//
// The docker analog of the remote-SSH launch above. Instead of a local tmux pane
// running `ssh -t host 'tmux new-session …'`, it runs `docker exec -it <container>
// sh -lc 'tmux new-session …'` into a DURABLE in-container tmux server. The
// container is per-CASE, so many sessions `docker exec` into the same one. See
// docs/docker-cases-plan.md.
/**
* DEDICATED in-container tmux socket. A Codeman running INSIDE the container uses
* `-L codeman`; ours is `-L codeman-docker` with a `codeman-dkr-*` session name
* that deliberately FAILS SAFE_MUX_NAME_PATTERN, so an in-container Codeman never
* adopts/resizes/respawns our session (same defence as the remote socket).
*/
const DOCKER_TMUX_SOCKET = 'codeman-docker' ;
/**
* Deterministic, reattach-stable in-container tmux session name. Derived from the
* same stable field the local muxName uses (first 8 chars of the sessionId), so a
* reconnect re-issues the exact same `new-session -A` and lands back in the SAME
* in-container session. The `dkr` letters make it fail SAFE_MUX_NAME_PATTERN.
*/
export function dockerTmuxSessionName ( sessionId : string ) : string {
return `codeman-dkr- ${ sessionId . slice ( 0 , 8 ) } ` ;
}
/** Resume ids are UUID-ish; reject anything with shell metacharacters (defensive). */
const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/ ;
/**
2026-08-06 07:21:57 +02:00
* Append the CLI-specific resume flag to a pane command (codex/gemini/antigravity). Only fires
2026-07-20 02:36:02 +02:00
* when the in-container tmux is RE-CREATED (`new-session -A` makes the flag inert
* on a live reattach), i.e. exactly when the previous live agent was lost and we
* want to resume the conversation from the bind-mounted transcript. Claude mode
* uses claudeDockerPaneCommand instead.
2026-07-19 15:09:48 +02:00
*/
function appendResumeFlag ( modeCommand : string , mode : SessionMode , resumeId : string ) : string {
if ( ! RESUME_ID_SAFE . test ( resumeId )) return modeCommand ;
2026-09-02 08:26:45 +08:00
// The append-only sibling of the full launch spec: this bolts a resume onto an ALREADY
// built command, for the docker "the in-container tmux was re-created" path. An entry with
// no `resumeAppend` has no resume form to append (shell, opencode — opencode's docker
// resume rides its own config object instead).
const append = getCli ( mode ) ? . launch . resumeAppend ;
if ( ! append ) return modeCommand ;
return append . style === 'flag'
? ` ${ modeCommand } ${ append . flag } ${ resumeId } `
: ` ${ modeCommand } ${ append . token } ${ resumeId } ` ;
2026-07-19 15:09:48 +02:00
}
2026-07-20 02:36:02 +02:00
/**
* Claude-mode pane command with a DETERMINISTIC conversation id (the docker analog
* of buildSpawnCommand's --resume/--session-id logic). A fresh launch passes
* `--session-id <sessionId>`, so the in-container conversation id is knowable
* host-side (resume-id capture + subagent/workflow correlation) WITHOUT relying on
* hook reachability. When the in-container tmux was re-created after a container
* stop/reboot, the same command re-runs against the surviving transcript:
* `--session-id` exits 1 ("already in use") and the `||` fallback RESUMES that
* conversation (verified CLI behavior). An explicit resumeId gets the local
* builder's shape — resume first, session-id fallback — so a stale id never
* dead-panes. The leading `exec ` is stripped: an exec'd first branch could never
* fall back.
*/
function claudeDockerPaneCommand ( modeCommand : string , sessionId : string , resumeId? : string ) : string {
if ( ! RESUME_ID_SAFE . test ( sessionId )) return modeCommand ; // defensive — ids are server-minted uuids
const cmd = modeCommand . replace ( /^exec\s+/ , '' );
const rid = resumeId && RESUME_ID_SAFE . test ( resumeId ) ? resumeId : undefined ;
if ( rid && rid !== sessionId ) {
return ` ${ cmd } --resume ${ rid } || ${ cmd } --session-id ${ sessionId } ` ;
}
const cid = rid ?? sessionId ;
return ` ${ cmd } --session-id ${ cid } || ${ cmd } --resume ${ cid } ` ;
}
2026-07-19 15:09:48 +02:00
/** Fully-resolved inputs for buildDockerLaunchCommand (pure). */
export interface DockerLaunchOptions {
mode : SessionMode ;
docker : SessionDocker ;
sessionId : string ;
resumeSessionId? : string ;
createContext : DockerCreateContext ;
/** exec-time inline env (non-secret): TERM, COLORTERM, CODEMAN_SESSION_ID, CODEMAN_MUX */
execEnv : Record < string , string >;
/** exec-time NAME-ONLY env forwarded from Codeman's process env (codex/gemini keys) */
execEnvNames : string [];
2026-07-20 01:28:53 +02:00
/**
* Files to copy from read-only seed mounts into the container's writable HOME once
* before launch (guarded so reconnects never clobber). Isolates Claude state: the
* merged `~/.claude.json`, plus `~/.claude/.credentials.json` + `settings.json`,
* are writable copies (not host mounts), so the container never re-auths and never
* writes its runtime state back into the host `~/.claude`.
*/
seedCopies? : DockerSeedCopy [];
2026-07-19 15:09:48 +02:00
}
/**
* Build the ONE `bash -c` launch string for a docker session: image-check ->
* ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
* in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
2026-11-06 00:59:46 +01:00
* four layers: outer `bash -c '…'` (`bashCArg()` at respawn-pane) -> the joined
2026-07-19 15:09:48 +02:00
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
*/
export function buildDockerLaunchCommand ( opts : DockerLaunchOptions ) : string {
2026-07-20 01:28:53 +02:00
const { mode , docker , sessionId , resumeSessionId , createContext , execEnv , execEnvNames , seedCopies } = opts ;
2026-07-19 15:09:48 +02:00
const base = buildDockerBaseArgs ( docker ). join ( ' ' );
2026-08-29 21:02:19 -07:00
// ADOPTED container (docker.owned === false): the user built it and runs it, so
// this chain may only LOOK and then exec. No image check (the image is theirs),
// no create, and above all no `start` — starting a container we do not own is
// exactly the lifecycle mutation adoption promises never to perform. A missing
// or stopped container fails closed with an actionable message instead.
const adopted = docker . owned === false ;
// Built lazily: an adopted case has no meaningful create-config, so computing
// create args for it would demand a context the adopt path never assembles.
const createArgs = adopted ? '' : buildDockerCreateArgs ( createContext ). join ( ' ' );
2026-07-19 15:09:48 +02:00
const name = shellescape ( docker . containerName );
const workdir = shellescape ( docker . containerWorkdir );
const image = shellescape ( docker . image );
const dkrName = dockerTmuxSessionName ( sessionId );
const sid = sessionId . slice ( 0 , 8 );
2026-08-29 23:31:28 -07:00
let modeCommand =
docker . commands ? .[ mode as DockerCommandMode ] || defaultDockerCommandForMode ( mode , !! docker . runsAsRoot );
2026-07-20 02:36:02 +02:00
if ( mode === 'claude' ) {
modeCommand = claudeDockerPaneCommand ( modeCommand , sessionId , resumeSessionId );
} else if ( resumeSessionId ) {
modeCommand = appendResumeFlag ( modeCommand , mode , resumeSessionId );
}
2026-11-06 00:59:46 +01:00
// Run by tmux through the container's default-shell, so the path is shell-quoted
// here. `exec` makes the pane PID the agent itself.
2026-07-19 15:09:48 +02:00
const paneCommand = `cd ${ workdir } && ${ modeCommand } ` ;
// `setenv -g` primes the session id so reattaches / newly-created panes inherit
// it. `new-session -A` = attach-or-create (idempotent + resume-aware). Options
// are scoped per-session (`set -t`) or server (`set -s`), never `-g`, so a shared
// in-container tmux server's other sessions keep their own prefix/mouse.
const tmuxInvocation = [
`tmux -L ${ DOCKER_TMUX_SOCKET } setenv -g CODEMAN_SESSION_ID ${ shellescape ( sid ) } ` ,
'setenv -g CODEMAN_MUX 1' ,
`new-session -A -s ${ dkrName } -c ${ workdir } ${ shellescape ( paneCommand ) } ` ,
`set -t ${ dkrName } status off` ,
`set -t ${ dkrName } mouse off` ,
2026-10-23 07:41:46 +00:00
`set -t ${ dkrName } prefix ${ NESTED_TMUX_PREFIX } ` ,
2026-07-19 15:09:48 +02:00
'set -s escape-time 0' ,
2026-10-23 07:41:46 +00:00
// Same reduction as the owned-remote chain: the in-container server is
// Codeman's (`-L codeman-docker`), and its stock prefix table would move
// the inner client to a sibling session on Ctrl+Q then )/(/s/w/L.
... nestedPrefixTableResetCommands (),
2026-07-19 15:09:48 +02:00
]. join ( ' \\; ' );
const execEnvFlags : string [] = [];
for ( const [ k , v ] of Object . entries ( execEnv )) execEnvFlags . push ( '--env' , shellescape ( ` ${ k } = ${ v } ` ));
// NAME-ONLY forwards: docker reads the VALUE from Codeman's own process env, so
// the secret never appears in argv (no `ps` leak) and is not committed.
for ( const n of execEnvNames ) execEnvFlags . push ( '--env' , n );
for ( const extra of docker . extraExecArgs ?? []) execEnvFlags . push ( shellescape ( extra ));
const imageMissingMsg = shellescape (
2026-07-20 01:28:53 +02:00
`Codeman: base image ${ docker . image } not present (it is normally auto-built on first use)`
2026-07-19 15:09:48 +02:00
);
const startFailMsg = shellescape ( `Codeman: container ${ docker . containerName } failed to start (docker daemon down?)` );
2026-08-29 21:02:19 -07:00
const notFoundMsg = shellescape (
2026-08-29 23:31:28 -07:00
`Codeman: container ${ docker . containerName } not found. Adopted containers are never created by Codeman - start it yourself, then reopen this session.`
2026-08-29 21:02:19 -07:00
);
const notRunningMsg = shellescape (
2026-08-29 23:31:28 -07:00
`Codeman: container ${ docker . containerName } is not running. Codeman never starts a container it does not own - start it yourself, then reopen this session.`
2026-08-29 21:02:19 -07:00
);
const imageCheck = adopted
? ''
: ` ${ base } image inspect ${ image } >/dev/null 2>&1 || { echo ${ imageMissingMsg } ; exit 1; }` ;
2026-08-27 19:38:38 +08:00
// create-if-missing (idempotent): reconnect / boot recovery re-runs this exact
// chain. A daemon without swap accounting warns whenever --memory is present,
// even when --memory-swap is omitted. In compatibility mode, retain the memory
// cap and filter ONLY that exact warning; all other stdout/stderr and the real
// create exit status are preserved so mount/config failures remain visible.
// A session-unique file avoids shell variables and command substitution, both
// of which would be expanded too early by the nested bash/tmux launch layers.
const createOutputPath = shellescape ( `/tmp/codeman-create- ${ sessionId } .log` );
const filteredCreateOutput = `sed '/^WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted\\. Memory limited without swap\\.$/d' ${ createOutputPath } ` ;
const removeCreateOutput = `rm -f ${ createOutputPath } ` ;
const createCommand = createContext . disableSwapLimit
? `{ if ${ base } ${ createArgs } > ${ createOutputPath } 2>&1; ` +
`then ${ filteredCreateOutput } ; ${ removeCreateOutput } ; ` +
`elif ${ base } inspect ${ name } >/dev/null 2>&1; then ${ removeCreateOutput } ; ` +
`else ${ filteredCreateOutput } >&2; ${ removeCreateOutput } ; false; fi; }`
: ` ${ base } ${ createArgs } ` ;
2026-08-29 21:02:19 -07:00
const ensure = adopted
? ` ${ base } inspect ${ name } >/dev/null 2>&1 || { echo ${ notFoundMsg } ; exit 1; }`
2026-09-05 16:21:51 +02:00
: ` ${ base } inspect ${ name } >/dev/null 2>&1 || ${ createCommand } ` ;
2026-11-06 00:59:46 +01:00
// A `grep -qx` pipeline, in the single-quoted form every other line in this builder
// uses. This chain reaches `bash -c` verbatim now (`bashCArg()`), but it went through
// a double-quoted layer for a long time, where a `"` closed that string early and tmux
// failed with a bare `execvp(3) failed`, so keep it free of double quotes and `$(…)`.
2026-08-29 21:02:19 -07:00
const start = adopted
2026-08-29 23:31:28 -07:00
? ` ${ base } inspect -f ${ shellescape ( '{{.State.Running}}' ) } ${ name } 2>/dev/null | grep -qx true || { echo ${ notRunningMsg } ; exit 1; }`
2026-08-29 21:02:19 -07:00
: ` ${ base } start ${ name } >/dev/null 2>&1 || { echo ${ startFailMsg } ; exit 1; }` ;
2026-07-20 01:28:53 +02:00
// Seed writable credential config from read-only host mounts ONCE per container
// (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for
// whole-dir credential seeds). mkdir -p the parent so a file seed works even when
// no sibling share-mount pre-created the dir. Paths are fixed CONTAINER_HOME
// constants (no shell metachars), so the whole inner command is shell-quoted once.
2026-08-29 21:02:19 -07:00
// An ADOPTED container gets NO seed copies: those read from create-time
// read-only mounts that do not exist here, and writing host credentials into a
// container the user owns is a mutation adoption does not permit. Its CLIs must
// already be authenticated inside it.
const seedSteps = ( adopted ? [] : ( seedCopies ?? [])). map (( s ) => {
2026-07-20 01:28:53 +02:00
const cp = s . recursive ? 'cp -a' : 'cp' ;
const parent = s . to . slice ( 0 , s . to . lastIndexOf ( '/' ));
return `mkdir -p ${ parent } 2>/dev/null; [ -e ${ s . to } ] || ${ cp } ${ s . from } ${ s . to } 2>/dev/null || true` ;
});
const innerCmd = seedSteps . length ? ` ${ seedSteps . join ( ' ; ' ) } ; ${ tmuxInvocation } ` : tmuxInvocation ;
const execCmd = `exec ${ base } exec -it --workdir ${ workdir } ${ execEnvFlags . join ( ' ' ) } ${ name } sh -lc ${ shellescape ( innerCmd ) } ` ;
2026-07-19 15:09:48 +02:00
2026-08-29 21:02:19 -07:00
return [ imageCheck , ensure , start , execCmd ]. filter ( Boolean ). join ( ' ; ' );
2026-07-19 15:09:48 +02:00
}
/**
* Kill ONLY this session's in-container tmux session. The container is shared by
* the case's other sessions, so this NEVER `docker stop`s it — stopping/removing
* the container is an explicit teardown (buildDockerStopCommand) or case-delete
* (buildDockerRemoveCommand). Fired best-effort on session kill.
*/
export function buildDockerKillCommand ( options : { docker : SessionDocker ; sessionId : string }) : string {
const { docker , sessionId } = options ;
const base = buildDockerBaseArgs ( docker ). join ( ' ' );
const dkrName = dockerTmuxSessionName ( sessionId );
return ` ${ base } exec ${ shellescape ( docker . containerName ) } tmux -L ${ DOCKER_TMUX_SOCKET } kill-session -t ${ shellescape ( dkrName ) } ` ;
}
2026-08-29 21:02:19 -07:00
/**
* Guard for the two builders that mutate CONTAINER lifecycle. They are pure
* string builders, so refusing here means an adopted container cannot even have
* a stop/remove command constructed for it — there is no shape of caller bug
* that turns into a `docker stop`/`rm` on something we do not own.
*/
function assertOwnedContainer ( docker : SessionDocker , action : string ) : void {
if ( docker . owned === false ) {
throw new Error (
`Refusing to ${ action } adopted container " ${ docker . containerName } ": Codeman does not own its lifecycle.`
);
}
}
2026-07-19 15:09:48 +02:00
/** Explicit container stop (frees RAM/CPU; conversation resumes on next launch via --resume). */
export function buildDockerStopCommand ( docker : SessionDocker ) : string {
2026-08-29 21:02:19 -07:00
assertOwnedContainer ( docker , 'stop' );
2026-07-19 15:09:48 +02:00
return ` ${ buildDockerBaseArgs ( docker ). join ( ' ' ) } stop -t 10 ${ shellescape ( docker . containerName ) } ` ;
}
/** Explicit container removal (case-delete). Destroys in-image state; bind mounts survive. */
export function buildDockerRemoveCommand ( docker : SessionDocker ) : string {
2026-08-29 21:02:19 -07:00
assertOwnedContainer ( docker , 'remove' );
2026-07-19 15:09:48 +02:00
return ` ${ buildDockerBaseArgs ( docker ). join ( ' ' ) } rm -f ${ shellescape ( docker . containerName ) } ` ;
}
/**
* Resolve the environment-dependent bits of a docker launch (host uid, existing
* credential mounts, derived api url, hook-secret mount, Desktop detection) into
* the pure buildDockerLaunchCommand inputs. IO; only ever called from the real
* launch path (createSession/respawnPane no-op under VITEST).
*/
export function resolveDockerLaunchOptions (
mode : SessionMode ,
docker : SessionDocker ,
sessionId : string ,
resumeSessionId? : string
) : DockerLaunchOptions {
const home = homedir ();
const isDesktop = process . platform === 'darwin' ; // Docker Desktop translates uids + native host.docker.internal
const uid = typeof process . getuid === 'function' ? process . getuid () : 1000 ;
const userArgs : string [] =
docker . engine === 'podman'
? [ '--userns=keep-id' ] // rootless podman: map host uid to the image `agent` uid
: isDesktop
? [] // Desktop: run as the image's baked uid (a mac uid wouldn't own /home/agent)
: [ '--user' , ` ${ uid } :0` ]; // Linux: host uid + GID 0 (OpenShift arbitrary-uid writable HOME)
const gatewayAlias = hostGatewayAlias ( docker . engine );
2026-07-20 01:28:53 +02:00
const credentialMounts : DockerMount [] = [];
2026-07-19 15:09:48 +02:00
const extraMounts : DockerMount [] = [];
2026-07-20 01:28:53 +02:00
// Isolated credential state (Claude + codex/gemini/gcloud/opencode): each store
// shares ONLY what a host feature / --resume needs (Claude projects/, codex
// sessions/+history) and seeds everything else (tokens, settings, configs) as
// writable copies, so the container is authed WITHOUT re-auth and WITHOUT writing
// its runtime state back into the host dirs. Only when credentials are mounted.
let seedCopies : DockerSeedCopy [] = [];
if ( docker . mountCredentials ) {
const claudeArtifacts = resolveDockerClaudeArtifacts ( home , docker . containerName , docker . containerWorkdir );
const credArtifacts = resolveDockerCredentialArtifacts ( home );
extraMounts . push (... claudeArtifacts . mounts , ... credArtifacts . mounts );
seedCopies = [... claudeArtifacts . seedCopies , ... credArtifacts . seedCopies ];
}
2026-07-19 15:09:48 +02:00
const envCreate : Record < string , string > = {
HOME : CONTAINER_HOME ,
TERM : 'xterm-256color' ,
COLORTERM : 'truecolor' ,
2026-07-20 01:28:53 +02:00
// Force a UTF-8 locale (the base image defaults to POSIX/C). Without this, tmux
// runs in non-UTF-8 mode and renders Claude's Unicode box-drawing (─│┌┐) as raw
// VT100 ACS glyphs (`qqqq…`). `C.UTF-8` is built into glibc (no locale-gen).
LANG : 'C.UTF-8' ,
LC_ALL : 'C.UTF-8' ,
2026-07-19 18:19:50 +02:00
// Give claude a temp dir it will own inside HOME. Its default `/tmp/claude-<uid>`
// is refused when that path pre-exists root-owned — which happens when the
// workspace bind-mount path traverses it (e.g. a workspace under /tmp/claude-<uid>).
// A nonexistent HOME subpath is created+owned by the running uid, so this is robust
// to any workspace location. Non-secret path, safe to be committed on export.
CLAUDE_CODE_TMPDIR : ` ${ CONTAINER_HOME } /.cache/codeman-claude-tmp` ,
2026-07-19 15:09:48 +02:00
};
if ( docker . hooksEnabled ) {
// Derive a container-reachable API url (scheme + port preserved; host swapped
// for the engine gateway alias). Prod is HTTPS on 3000.
envCreate . CODEMAN_API_URL = containerApiUrl ( process . env . CODEMAN_API_URL , docker . engine );
const hookSecretPath = dataPath ( 'hook-secret' );
if ( existsSync ( hookSecretPath )) {
const dst = ` ${ CONTAINER_HOME } /.codeman/hook-secret` ;
extraMounts . push ({ src : hookSecretPath , dst , readonly : true });
envCreate . CODEMAN_HOOK_SECRET_FILE = dst ; // a path is non-secret; the bytes ride the bind mount
}
}
const createContext : DockerCreateContext = {
docker ,
sessionId ,
instance : CODEMAN_INSTANCE ,
userArgs ,
2026-08-27 19:38:38 +08:00
credentialMounts : credentialMounts.map (( mount ) => ({
... mount ,
src : resolveDockerDaemonMountSource ( mount . src , home , process . env . CODEMAN_DOCKER_HOST_HOME ),
})),
extraMounts : extraMounts.map (( mount ) => ({
... mount ,
src : resolveDockerDaemonMountSource ( mount . src , home , process . env . CODEMAN_DOCKER_HOST_HOME ),
})),
2026-07-19 15:09:48 +02:00
envCreate ,
addHostGateway : ! isDesktop ,
gatewayAlias ,
2026-08-27 19:38:38 +08:00
disableSwapLimit : process.env.CODEMAN_DOCKER_DISABLE_SWAP_LIMIT === '1' ,
2026-07-19 15:09:48 +02:00
};
const execEnv : Record < string , string > = {
TERM : 'xterm-256color' ,
COLORTERM : 'truecolor' ,
2026-07-20 01:28:53 +02:00
// UTF-8 at exec time too, so the tmux CLIENT this exec launches is UTF-8 and
// renders box-drawing correctly even when reattaching to a container created
// before this fix (client_utf8 is per-client, resolved from the exec's locale).
LANG : 'C.UTF-8' ,
LC_ALL : 'C.UTF-8' ,
2026-07-19 15:09:48 +02:00
CODEMAN_SESSION_ID : sessionId.slice ( 0 , 8 ),
CODEMAN_MUX : '1' ,
};
// NAME-ONLY exec env forwarded from Codeman's process env (the docker client
// inherits it), so API-key CLIs get their key without it appearing in argv.
2026-09-02 08:26:45 +08:00
const execEnvNames = getCli ( mode ) ? . env . dockerExecEnvNames ?? [];
2026-07-19 15:09:48 +02:00
2026-07-20 01:28:53 +02:00
return { mode , docker , sessionId , resumeSessionId , createContext , execEnv , execEnvNames , seedCopies };
2026-07-19 15:09:48 +02:00
}
2026-06-13 11:04:18 -04:00
/**
* COD-105 — build the SSH command that ATTACHES to an EXISTING `codeman-*` tmux
* session on the remote host (one this Codeman didn't create — discovered via
* `listRemoteCodemanSessions`). Sibling of `buildRemoteLaunchCommand`.
*
* Emits:
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
* 'tmux -L codeman attach -t <session>'
*
* - `attach` (NOT `new-session -A`) so we only join an existing session; the
* remote session keeps running independent of us, which is exactly why the
* resulting Codeman session is NON-OWNED (see `SessionRemote.owned`): closing
* the local tab must detach, never `kill-session` the remote.
* - The remote session name is shell-escaped so a value with metachars stays a
* single token inside the quoted tmux invocation.
* - COD-107 — connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
* arbitrary `-o`) come from the shared `buildSshConnectionArgs`, so attach
* connects identically to launch / discovery / the prereq probe. `-t` sits
* right after `ssh -o BatchMode=yes` (a PTY is required for interactive tmux).
*/
export function buildRemoteAttachCommand ( remote : SessionRemote , remoteSessionName : string ) : string {
const tmuxInvocation = `tmux -L codeman attach -t ${ shellescape ( remoteSessionName ) } ` ;
const [ ssh , batchMode , ... connectionArgs ] = buildSshConnectionArgs ( remote );
2026-10-23 17:27:18 +04:00
const sshParts = [
ssh ,
batchMode ,
'-t' ,
... connectionArgs ,
remoteSshTarget ( remote ),
shellescape ( withRemoteTmuxPath ( tmuxInvocation )),
];
2026-06-13 11:04:18 -04:00
return sshParts . join ( ' ' );
}
/**
* COD-105 — choose the right remote ssh command for a session's ownership:
* - NON-owned (`remote.owned === false`): ATTACH to a discovered remote tmux
* session by its EXISTING name (`remote.remoteSessionName`, falling back to
* this session's deterministic name). We only join — never create.
* - owned (default): LAUNCH/attach-or-create via `buildRemoteLaunchCommand`
* (COD-104), which we then own and may explicitly kill.
*/
2026-07-20 14:27:02 +02:00
function buildRemoteSessionCommand ( options : {
mode : SessionMode ;
remote : SessionRemote ;
sessionId : string ;
claudeMode? : ClaudeMode ;
allowedTools? : string ;
2026-08-29 15:21:33 -05:00
/** OMP only — resume/continue overrides for a remote omp relaunch. */
ompConfig? : OmpConfig ;
resumeSessionId? : string ;
2026-07-20 14:27:02 +02:00
}) : string {
const { remote , sessionId } = options ;
2026-06-13 11:04:18 -04:00
if ( remote . owned === false ) {
const target = remote . remoteSessionName || remoteTmuxSessionName ( sessionId );
return buildRemoteAttachCommand ( remote , target );
}
2026-07-20 14:27:02 +02:00
return buildRemoteLaunchCommand ( options );
2026-06-13 11:04:18 -04:00
}
2026-09-29 18:01:47 -07:00
/**
2026-11-06 08:20:39 +01:00
* ADOPTION: the pane command for a wrapper session around a tmux session a
2026-09-29 18:01:47 -07:00
* human started. Sibling of `buildRemoteSessionCommand` and
* `buildDockerLaunchCommand`, and the same shape of decision: the wrapper is an
* ordinary `codeman-<8hex>` session on OUR socket, and only what runs INSIDE its
* pane differs per location.
*
* That indirection is what makes adoption safe by construction rather than by
* discipline: `killSession` only ever kills the wrapper on our own socket, so
* there is no shape of caller bug that reaches the foreign server with a
* `kill-session`. It is the same reasoning that keeps a non-owned REMOTE session
* safe (see the detach-not-kill early return in `killSession`).
*/
2026-11-06 03:06:47 +01:00
export function buildAdoptSessionCommand ( adopt : SessionAdopt , sessionId : string ) : string {
2026-09-29 18:01:47 -07:00
const target = {
socketPath : adopt.socketPath ,
targetSession : adopt.targetSession ,
// Derived rather than required: the view name is a function of the Codeman
// session id, so a record that predates it (or one whose creator filled it in
// after construction) still produces the right name instead of an empty `-s`.
viewSession : adopt.viewSession || foreignViewSessionName ( sessionId ),
};
if ( adopt . location === 'docker' && adopt . docker ) {
return buildForeignDockerAttachCommand ({
... target ,
dockerBase : buildDockerBaseArgs ( adopt . docker ). join ( ' ' ),
containerName : adopt.docker.containerName ,
});
}
if ( adopt . location === 'remote' && adopt . remote ) {
return buildForeignRemoteAttachCommand ({
... target ,
sshArgs : buildSshConnectionArgs ( adopt . remote ),
sshTarget : remoteSshTarget ( adopt . remote ),
});
}
return buildForeignAttachCommand ( target );
}
2026-02-26 10:07:43 +01:00
/**
2026-09-02 08:26:45 +08:00
* Push one environment variable into a tmux session with `setenv`.
*
* ⚠️ `setenv` rather than the spawn command line is the whole point: a value set this way is
* inherited by panes but never appears in `ps` output or tmux history, so an API key cannot
* be read by every other process on the box. Nothing that carries a secret may move to the
* command line.
*
* A failure is deliberately swallowed — a key the CLI does not need is not an error, and a
* CLI that does need it will say so far more usefully than a spawn failure here would.
2026-02-26 10:07:43 +01:00
*/
2026-09-02 08:26:45 +08:00
function setTmuxEnvVar ( tmuxCmd : string , muxName : string , key : string , value : string ) : void {
// Shell-escape: wrap in single quotes, escape any inner single quotes.
const escaped = value . replace ( /'/g , "'\\''" );
try {
execSync ( ` ${ tmuxCmd } setenv -t ' ${ muxName } ' ${ key } ' ${ escaped } '` , {
encoding : 'utf8' ,
timeout : EXEC_TIMEOUT_MS ,
stdio : [ 'pipe' , 'pipe' , 'pipe' ],
});
} catch {
/* Non-critical — key may not be needed */
2026-02-26 10:07:43 +01:00
}
}
2026-06-10 09:31:58 -04:00
/**
2026-09-02 08:26:45 +08:00
* Forward this CLI's declared sensitive env vars from the SERVER's own environment into the
* tmux session. Names come from `env.tmuxSetenvKeys`; values are never in config.
*
* Was three near-identical per-CLI functions whose only difference was the key list.
2026-06-10 09:31:58 -04:00
*/
2026-09-02 08:26:45 +08:00
function setCliSensitiveEnvVars ( tmuxCmd : string , muxName : string , keys : readonly string []) : void {
for ( const key of keys ) {
2026-06-10 09:31:58 -04:00
const val = process . env [ key ];
2026-09-02 08:26:45 +08:00
if ( val ) setTmuxEnvVar ( tmuxCmd , muxName , key , val );
2026-06-10 09:31:58 -04:00
}
}
2026-05-16 20:10:49 -04:00
/**
2026-09-02 08:26:45 +08:00
* Implementations of the named profiles a CLI may select via `env.setenvProfile` — the escape
* hatch for setup that genuinely needs to RUN CODE rather than name a list of env keys.
*
* Keyed by PROFILE NAME, never by CLI id: a second launcher-style CLI adds an entry here and
* names it from its registry entry, and nothing else in this file learns about it. The names
* themselves are declared (and schema-validated at load) in `config/cli-registry/profiles.ts`.
*
* Returns the env vars to set; the caller does the actual `tmux setenv` calls.
2026-05-16 20:10:49 -04:00
*/
2026-09-02 08:26:45 +08:00
const SETENV_PROFILES : Record <
string ,
( sessionId : string , entry : CliEntry , rawConfig? : Record < string , unknown >) => Record < string , string >
> = {
/**
* DeepSeek's Herdr-compatible status bridge.
*
* Pointing `HERDR_BIN_PATH` at our own generated shim is what upgrades this mode from
* output-stabilization guessing to DEFINITIVE idle/working/blocked events (see
* deepseek-status-shim.ts). The pane id IS the Codeman session id, which is how the shim
* attributes a report without trusting anything the agent could influence.
*
* Needs a profile rather than key names because it writes an executable to disk and then
* exports that file's path — neither a name list nor a config value could express it.
*/
'deepseek-status-bridge' : ( sessionId , entry , rawConfig ) => {
// Opt-OUT, not opt-in: an absent flag means the bridge is armed, so a caller who says
// nothing gets the better signals. Only an explicit `false` disarms it, which is exactly
// what `hooksAvailableForMode()` reads to decide whether `stop` can ever fire.
const field = entry . launch . legacyConfigAliases ? . statusReporting ?? 'statusReporting' ;
if ( rawConfig ? .[ field ] === false ) return {};
const shim = ensureDeepSeekStatusShim ();
if ( ! shim ) return {};
const vars : Record < string , string > = { HERDR_ENV : '1' , HERDR_BIN_PATH : shim , HERDR_PANE_ID : sessionId };
return vars ;
},
};
2026-05-16 20:10:49 -04:00
2026-02-26 10:07:43 +01:00
/**
2026-09-02 08:26:45 +08:00
* Set a CLI's JSON config-content env var on a tmux session via setenv.
*
* The var NAME comes from `env.configContentVar` rather than being hardcoded, so this is not
* an opencode special case — but opencode is its only user today. `setenv` (rather than the
* command line) is what keeps user-supplied JSON away from shell metacharacter parsing.
2026-02-26 10:07:43 +01:00
*/
2026-09-02 08:26:45 +08:00
function setCliConfigContent ( tmuxCmd : string , muxName : string , varName : string , config? : OpenCodeConfig ) : void {
2026-02-26 10:07:43 +01:00
if ( ! config ) return ;
let jsonContent : string | undefined ;
if ( config . autoAllowTools ) {
const permConfig : Record < string , unknown > = { permission : { '*' : 'allow' } };
if ( config . configContent ) {
try {
const existing = JSON . parse ( config . configContent ) as Record < string , unknown >;
Object . assign ( permConfig , existing );
permConfig . permission = { '*' : 'allow' };
2026-02-27 13:16:23 +01:00
} catch {
/* invalid JSON, use default permConfig */
}
2026-02-26 10:07:43 +01:00
}
jsonContent = JSON . stringify ( permConfig );
} else if ( config . configContent ) {
// Validate JSON to prevent garbage config
try {
JSON . parse ( config . configContent );
jsonContent = config . configContent ;
} catch {
console . error ( '[TmuxManager] Invalid JSON in openCodeConfig.configContent, skipping' );
return ;
}
}
2026-09-02 08:26:45 +08:00
if ( jsonContent ) setTmuxEnvVar ( tmuxCmd , muxName , varName , jsonContent );
2026-02-26 10:07:43 +01:00
}
2026-02-12 10:29:57 +01:00
/**
* Manages tmux sessions that wrap Claude CLI or shell processes.
*
2026-02-18 12:51:35 +01:00
* Implements the TerminalMultiplexer interface.
2026-02-12 10:29:57 +01:00
*
* @example
* ```typescript
* const manager = new TmuxManager();
*
* // Create a tmux session for Claude
2026-02-26 15:58:26 +01:00
* const session = await manager.createSession({ sessionId, workingDir: '/project', mode: 'claude' });
2026-02-12 10:29:57 +01:00
*
* // Send input (single command, no delay!)
* manager.sendInput(sessionId, '/clear\r');
*
* // Kill when done
* await manager.killSession(sessionId);
* ```
*/
export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
readonly backend = 'tmux' as const ;
private sessions : Map < string , MuxSession > = new Map ();
2026-05-26 05:28:58 +08:00
private readonly tmuxSocket = resolveConfiguredTmuxSocket ();
2026-02-12 10:29:57 +01:00
private statsInterval : NodeJS.Timeout | null = null ;
2026-02-18 10:23:10 +01:00
private mouseSyncInterval : NodeJS.Timeout | null = null ;
/** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
private lastPaneCount : Map < string , number > = new Map ();
2026-09-21 10:00:54 +02:00
/**
* muxName → the exited agent the pane-exit watcher last observed
* (Ark0N/Codeman#446). Absence is the UNKNOWN arm of the tri-state, so an
* entry goes the moment tmux stops reporting the pane dead, and the map is
* empty until the first read runs. The manager reports what tmux says and
* nothing more: the scoping that hides this for remote and docker sessions
* lives on `Session`, because the remote-reconnect watcher above needs the
* raw pane reading.
*/
2026-09-24 22:18:07 +02:00
private paneExits : Map < string , TrackedPaneExit > = new Map ();
2026-10-22 20:59:28 -04:00
/**
* muxName -> `#{pane_current_path}` from the last authoritative pane read
* ({@link derivePaneCurrentPaths}). Replaced wholesale by each non-empty read,
* so it is bounded by the panes on the socket. Like {@link paneExits} it holds
2026-11-04 16:59:16 +01:00
* the raw reading; `Session.setPaneCwd()` decides which sessions may use it.
2026-10-22 20:59:28 -04:00
*/
private paneCurrentPaths : Map < string , string > = new Map ();
2026-09-21 10:00:54 +02:00
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */
private paneExitInterval : NodeJS.Timeout | null = null ;
/**
* True while a pane read is in flight. `EXEC_TIMEOUT_MS` is 5000 ms against a
* poll interval of 2000 ms, so without this a slow read overlaps the next two
* and the older one can resolve last and win.
*/
private paneExitReadInFlight = false ;
/**
* Bumped by every deliberate {@link clearPaneExit}. A read that started before
* a clear carries the older generation and is discarded rather than writing
* the death back over the pane that has just replaced it.
*/
private paneExitGeneration = 0 ;
2026-02-12 10:29:57 +01:00
2026-06-13 14:26:06 -04:00
// ── COD-108 remote-reconnect watcher state ────────────────────────────────
/** Periodic watcher that re-establishes dropped remote sessions. */
private remoteReconnectInterval : NodeJS.Timeout | null = null ;
/** Per-session backoff/attempt bookkeeping (sessionId → state). */
private reconnectState : Map < string , RemoteReconnectState > = new Map ();
/**
* Sessions excluded from auto-reconnect because they are being intentionally
* torn down (killed/detached/stopping). A guarded session is NEVER revived.
*/
private reconnectGuard : Set < string > = new Set ();
2026-08-29 15:21:33 -05:00
/**
* Cached result of the remote tmux `has-session` probe (sessionId → alive).
* `true` = the durable remote tmux session exists (transport drop → reconnect
* is safe); `false` = remote session gone (agent exited cleanly → do NOT
* reconnect); `undefined` = not yet probed / probe failed. Only sessions
* whose pane is otherwise dead+eligible get probed, so a clean exit tears
* down the remote tmux and the probe reports false — killing the auto-revive
* (found live 2026-08-29: remote omp/opencode ctrl-c/ctrl-d auto-respawned
* fresh agents because the watcher couldn't tell a clean exit from a
* transport drop).
*/
private remoteAliveCache : Map < string , boolean | undefined > = new Map ();
2026-09-04 13:47:12 +02:00
/**
* Sessions with a `has-session` probe currently in flight. The probe is a
* fire-and-forget ssh round-trip with a 15s timeout against a 5s tick, so
* without this an unreachable host would accumulate three overlapping ssh
* processes per dead session.
*/
private remoteAliveInFlight : Set < string > = new Set ();
2026-06-13 14:26:06 -04:00
2026-02-12 11:35:10 +01:00
private trueColorConfigured = false ;
2026-08-21 08:19:14 -04:00
/** tmux 3.7+ can resize pane history after creation; older releases cannot. */
private liveHistoryResizeSupported : boolean | null = null ;
2026-02-12 11:35:10 +01:00
2026-02-12 10:29:57 +01:00
constructor () {
super ();
2026-02-15 05:33:34 +01:00
this . setMaxListeners ( 50 );
2026-02-12 18:26:16 +01:00
if ( ! IS_TEST_MODE ) {
this . loadSessions ();
}
2026-02-12 10:29:57 +01:00
}
2026-05-26 05:28:58 +08:00
/** The dedicated tmux socket all Codeman sessions live on (see {@link TerminalMultiplexer.muxSocket}). */
get muxSocket () : string {
return this . tmuxSocket ;
}
private tmux () : string {
return tmuxCommand ( this . tmuxSocket );
}
2026-08-21 08:19:14 -04:00
private supportsLiveHistoryResize () : boolean {
if ( this . liveHistoryResizeSupported !== null ) return this . liveHistoryResizeSupported ;
try {
const output = execSync ( ` ${ this . tmux () } -V` , {
encoding : 'utf8' ,
timeout : EXEC_TIMEOUT_MS ,
stdio : [ 'ignore' , 'pipe' , 'ignore' ],
});
const match = output . match ( /(?:^|\D)(\d+)\.(\d+)/ );
const major = match ? Number ( match [ 1 ]) : 0 ;
const minor = match ? Number ( match [ 2 ]) : 0 ;
this . liveHistoryResizeSupported = major > 3 || ( major === 3 && minor >= 7 );
} catch {
// Unknown versions take the legacy path required by tmux <3.7.
this . liveHistoryResizeSupported = false ;
}
return this . liveHistoryResizeSupported ;
}
2026-02-12 18:26:16 +01:00
// Load saved sessions from disk (NEVER called in test mode)
2026-02-12 10:29:57 +01:00
private loadSessions () : void {
2026-02-12 18:26:16 +01:00
if ( IS_TEST_MODE ) return ;
2026-02-12 10:29:57 +01:00
try {
if ( existsSync ( MUX_SESSIONS_FILE )) {
const content = readFileSync ( MUX_SESSIONS_FILE , 'utf-8' );
const data = JSON . parse ( content );
if ( Array . isArray ( data )) {
2026-05-26 05:28:58 +08:00
// Dedup by muxName: one live tmux session must map to exactly one
// tracked entry. A per-session socket-tag mismatch could historically
// let the same session be tracked twice — once under its real UUID and
// once under a "restored-<id>" placeholder — surfacing as duplicate tabs.
// Single-socket unification removed that failure mode; this pass stays
// to clean any stale duplicates already on disk. Keep the real (UUID)
// entry and drop placeholder twins.
let dropped = 0 ;
const keptByMuxName = new Map < string , string >(); // muxName -> kept sessionId
2026-02-12 10:29:57 +01:00
for ( const session of data ) {
2026-05-26 05:28:58 +08:00
// Strip the obsolete per-session tmuxSocket tag (now a process-wide
// constant). Left in place it would be written back by saveSessions()
// and linger on disk as a zombie field forever.
delete ( session as { tmuxSocket? : unknown }). tmuxSocket ;
const muxName : string | undefined = session . muxName ;
const priorId = muxName ? keptByMuxName . get ( muxName ) : undefined ;
if ( priorId ) {
const incomingIsPlaceholder = String ( session . sessionId ). startsWith ( 'restored-' );
const priorIsPlaceholder = priorId . startsWith ( 'restored-' );
// Drop the incoming unless it's the real twin of a placeholder we kept.
if ( incomingIsPlaceholder || ! priorIsPlaceholder ) {
dropped ++ ;
continue ;
}
this . sessions . delete ( priorId );
dropped ++ ;
}
2026-02-12 10:29:57 +01:00
this . sessions . set ( session . sessionId , session );
2026-05-26 05:28:58 +08:00
if ( muxName ) keptByMuxName . set ( muxName , session . sessionId );
}
// Persist the cleaned list so the stale duplicates don't reload.
if ( dropped > 0 ) {
console . log ( `[TmuxManager] Dropped ${ dropped } duplicate mux session record(s) on load` );
this . saveSessions ();
2026-02-12 10:29:57 +01:00
}
}
}
} catch ( err ) {
console . error ( '[TmuxManager] Failed to load sessions:' , err );
}
}
/**
2026-02-12 18:26:16 +01:00
* Save sessions to disk asynchronously. (NEVER writes in test mode)
2026-02-12 20:14:01 +01:00
* Uses atomic temp+rename to prevent corruption on crash.
2026-02-12 10:29:57 +01:00
*/
private saveSessions () : void {
2026-02-12 18:26:16 +01:00
if ( IS_TEST_MODE ) return ;
2026-02-12 10:29:57 +01:00
try {
const dir = dirname ( MUX_SESSIONS_FILE );
if ( ! existsSync ( dir )) {
mkdirSync ( dir , { recursive : true });
}
const data = Array . from ( this . sessions . values ());
const json = JSON . stringify ( data , null , 2 );
2026-02-12 20:14:01 +01:00
const tempPath = MUX_SESSIONS_FILE + '.tmp' ;
writeFile ( tempPath , json , 'utf-8' )
. then (() => rename ( tempPath , MUX_SESSIONS_FILE ))
. catch (( err ) => {
2026-02-12 10:29:57 +01:00
console . error ( '[TmuxManager] Failed to save sessions:' , err );
2026-02-12 20:14:01 +01:00
});
2026-02-12 10:29:57 +01:00
} catch ( err ) {
console . error ( '[TmuxManager] Failed to save sessions:' , err );
}
}
2026-03-25 23:32:28 +01:00
/**
* Build the array of environment export commands shared by createSession() and respawnPane().
* Includes locale, mux markers, session identity, and API URL.
2026-04-22 00:01:35 +08:00
*
* User-supplied envOverrides are NOT inlined here — they go through applyEnvOverrides()
* via `tmux setenv` so secret values (e.g., OPENCODE_API_KEY) never appear in the bash
* command line (visible in `ps`). This also sidesteps shell-metachar injection via keys.
2026-03-25 23:32:28 +01:00
*/
private buildEnvExports ( sessionId : string , muxName : string , mode : SessionMode ) : string [] {
2026-09-02 08:26:45 +08:00
const entry = getCli ( mode );
// Per-CLI colour/identity vars, straight from the entry. `unset` before `export` is
// arbitrary: these are independent bash statements joined by ` && `, so nothing here
// depends on another's value and the order carries no semantics.
const cliEnv : string [] = [];
for ( const name of entry ? . env . unset ?? []) cliEnv . push ( `unset ${ name } ` );
for ( const item of entry ? . env . exports ?? []) {
// Values are either literals validated against the shell-token pattern at load, or an
// engine value produced here — never free text from config.
const value =
typeof item . value === 'string'
? item.value
: item.value.engine === 'codemanPrefixedSessionId'
? `codeman_ ${ sessionId } `
: item . value . engine === 'sessionId'
? sessionId
: item.value.engine === 'muxName'
? muxName
: undefined ;
// A CLI stamping a per-pane originator (codex) is what lets the response viewer find
// THIS pane's rollout exactly; without it, rollouts are matched by cwd+mtime and two
// panes in the same directory bleed into each other.
if ( value !== undefined ) cliEnv . push ( `export ${ item . name } = ${ value } ` );
}
return [
2026-03-25 23:32:28 +01:00
'export LANG=en_US.UTF-8' ,
'export LC_ALL=en_US.UTF-8' ,
2026-09-02 08:26:45 +08:00
... cliEnv ,
2026-03-25 23:32:28 +01:00
'export CODEMAN_MUX=1' ,
`export CODEMAN_SESSION_ID= ${ sessionId } ` ,
`export CODEMAN_MUX_NAME= ${ muxName } ` ,
2026-08-09 01:18:03 +02:00
// Only exported when the server has stamped the real URL (scheme+host+port,
// set in WebServer.start()). A hardcoded fallback here exported the wrong
// scheme on HTTPS installs; leaving the variable unset makes in-session
// guards fail closed instead of curling a URL that was never right.
...( process . env . CODEMAN_API_URL ? [ `export CODEMAN_API_URL= ${ process . env . CODEMAN_API_URL } ` ] : []),
2026-06-10 22:31:09 +02:00
// Path only (not the secret value): hook curl commands cat the file at
// execution time, so the COD-54 hook secret stays off the command line.
`export CODEMAN_HOOK_SECRET_FILE=" ${ dataPath ( 'hook-secret' ) } "` ,
2026-03-25 23:32:28 +01:00
];
}
2026-04-22 00:01:35 +08:00
/**
* Apply user-supplied env overrides to a tmux session via `tmux setenv`.
* Values stay off the bash command line (not visible in `ps`), and are inherited
* by new panes — including `respawn-pane`. Persists at tmux-session level, so
* Codeman server restarts don't lose the setting as long as the tmux session lives.
*
* Key validation is strict (`/^[A-Z_][A-Z0-9_]*$/`) as defense-in-depth against
* shell-metachar injection even if upstream schema check is bypassed.
*/
2026-09-14 23:45:42 +02:00
private applyEnvOverrides ( muxName : string , envOverrides? : Record < string , string >, unsetKeys? : string []) : void {
const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/ ;
2026-06-07 10:33:11 +08:00
// Legacy cleanup: pre-0.7.2 set CLAUDE_CODE_EFFORT_LEVEL via setenv, which persists
// on the tmux session and hard-locks /effort switching in every respawned pane.
// Effort now flows as a `--settings` soft default (see buildEffortSettingsFlag),
// so unconditionally unset the stale var before applying current overrides.
2026-09-14 23:45:42 +02:00
//
// The caller's own unsets ride the same path, and run BEFORE the overrides are
// (re)applied: a key that is both unset and present in `envOverrides` ends up set,
// so a stale unset can never clobber a live value. Removing a key from the map is
// not enough on its own — `setenv` persists at the tmux-session level and is
// inherited by `respawn-pane`, measured: `setenv FOO bar` survived two successive
// `respawn-pane -k`. Clearing a custom-model selection is what needs this.
for ( const key of [ 'CLAUDE_CODE_EFFORT_LEVEL' , ...( unsetKeys ?? [])]) {
if ( ! VALID_KEY . test ( key )) {
console . warn ( `[TmuxManager] Skipping invalid env unset key: ${ JSON . stringify ( key ) } ` );
continue ;
}
try {
execSync ( ` ${ this . tmux () } setenv -t ${ shellescape ( muxName ) } -u ${ key } ` , {
timeout : EXEC_TIMEOUT_MS ,
stdio : [ 'pipe' , 'pipe' , 'pipe' ],
});
} catch {
/* Non-critical — var may not exist */
}
2026-06-07 10:33:11 +08:00
}
2026-04-22 00:01:35 +08:00
if ( ! envOverrides ) return ;
for ( const [ key , value ] of Object . entries ( envOverrides )) {
if ( ! value ) continue ; // Skip empty — nothing to set
if ( ! VALID_KEY . test ( key )) {
console . warn ( `[TmuxManager] Skipping invalid env override key: ${ JSON . stringify ( key ) } ` );
continue ;
}
try {
2026-05-26 05:28:58 +08:00
execSync ( ` ${ this . tmux () } setenv -t ${ shellescape ( muxName ) } ${ key } ${ shellescape ( value ) } ` , {
2026-04-22 00:01:35 +08:00
timeout : EXEC_TIMEOUT_MS ,
stdio : [ 'pipe' , 'pipe' , 'pipe' ],
});
} catch ( err ) {
console . warn ( `[TmuxManager] Failed to set env override ${ key } :` , err );
}
}
}
2026-03-25 23:32:28 +01:00
/**
* Resolve the CLI binary directory and return the PATH export prefix string.
* Returns '' if no override is needed (shell mode) or the binary dir is not found.
* In createSession(), a missing binary dir throws — the caller handles that separately.
*/
private buildPathExport ( mode : SessionMode ) : { pathExport : string ; dir : string | null } {
2026-09-02 08:26:45 +08:00
// Prepending the resolved bin dir is what makes a CLI installed somewhere the server's
// own PATH does not cover (nvm, Homebrew, ~/.local/bin under a systemd unit) reachable
// from inside the pane. `shell` and any unregistered mode resolve to null and get
// nothing prepended.
const dir = resolveCliBinDir ( mode );
return { pathExport : dir ? `export PATH=" ${ dir } :$PATH" && ` : '' , dir };
2026-03-25 23:32:28 +01:00
}
/**
2026-09-02 08:26:45 +08:00
* Configure this CLI's environment on a tmux session, entirely from registry data.
*
* Four independent pieces, all via `tmux setenv` so they are inherited by the pane without
* ever appearing in `ps`:
*
* 1. `env.tmuxSetenvKeys` — sensitive vars forwarded from the SERVER's own environment
* (API keys, CLI home dirs). Names only ever live in config; values never do.
* 2. `env.configSetenv` — vars whose value comes from the caller's config rather than the
* server env. DeepSeek's `DSH_PERMISSION_MODE` is the case this exists for: its
* permission switch is an env var, not a flag. Routing it through a declared launch
* param is what lets the ordinary multi-user clamp reach it.
* 3. `env.configContentVar` — a JSON config blob (opencode).
* 4. `env.setenvProfile` — genuinely code-shaped setup. DeepSeek's status bridge writes an
* executable shim to disk and exports its path plus this session's pane id, which is
* what upgrades that mode from output-stabilization guessing to definitive hook events.
*
* Called UNCONDITIONALLY for every mode: an entry with no keys, no config var and no
* profile does nothing here, which is a better shape than four `if (mode === ...)` guards
* that each had to be remembered at two separate call sites.
2026-03-25 23:32:28 +01:00
*/
2026-09-02 08:26:45 +08:00
private _configureCliEnv (
muxName : string ,
sessionId : string ,
mode : SessionMode ,
rawConfig? : Record < string , unknown >
) : void {
const entry = getCli ( mode );
if ( ! entry ) return ;
2026-05-26 05:28:58 +08:00
const tmuxCmd = this . tmux ();
2026-03-25 23:32:28 +01:00
2026-09-02 08:26:45 +08:00
setCliSensitiveEnvVars ( tmuxCmd , muxName , entry . env . tmuxSetenvKeys );
2026-06-10 09:31:58 -04:00
2026-09-02 08:26:45 +08:00
for ( const [ key , value ] of Object . entries ( configSetenvValues ( entry , rawConfig ))) {
setTmuxEnvVar ( tmuxCmd , muxName , key , value );
2026-08-24 03:37:56 +02:00
}
2026-09-02 08:26:45 +08:00
if ( entry . env . configContentVar ) {
setCliConfigContent ( tmuxCmd , muxName , entry . env . configContentVar , rawConfig as OpenCodeConfig | undefined );
2026-08-24 03:37:56 +02:00
}
2026-09-02 08:26:45 +08:00
const profileName = entry . env . setenvProfile ;
if ( profileName ) {
const profile = SETENV_PROFILES [ profileName ];
// A name the schema accepted but this build does not implement: skip rather than
// throw. Losing a status bridge degrades signal quality; failing here would refuse
// the session outright.
if ( profile ) {
for ( const [ key , value ] of Object . entries ( profile ( sessionId , entry , rawConfig ))) {
setTmuxEnvVar ( tmuxCmd , muxName , key , value );
}
2026-08-24 03:37:56 +02:00
}
}
}
2026-10-10 12:22:00 -04:00
/**
* The statusLine exporter for one claude launch, and why it is missing when it is.
* Registry-gated (capabilities.statusLineTelemetry, claude only today), local spawns
* only (remote/docker have their own separate command builders). Resolving still runs
* with collection off, because it also self-heals: it strips any legacy disk-written
* exporter from an older Codeman build the first time a session starts in that
2026-10-11 12:36:59 -04:00
* workspace again. `state` lands on the mux record so the Status row can
2026-10-10 12:22:00 -04:00
* say why no cache report will come, instead of "not reported yet" forever.
*/
private async _resolveStatusLine (
mode : SessionMode ,
workingDir : string ,
remoteOrDocker : boolean
2026-10-11 12:36:59 -04:00
) : Promise < { command? : string ; state? : StatusLineExporter } > {
2026-10-10 12:22:00 -04:00
if ( ! getCli ( mode ) ? . capabilities . statusLineTelemetry ) return {};
if ( remoteOrDocker ) return { state : 'remote' };
const enabled = await readPlanUsageTelemetryEnabled ();
const command = await resolveStatusLineCliCommand ( workingDir , enabled );
return { command , state : command ? 'injected' : enabled ? 'workspace-statusline' : 'collection-off' };
}
2026-10-18 13:09:06 -04:00
/**
* Whether this launch carries `idleCompaction: false` (decideIdleCompactionOptOut), with the
* keep-warm app default read fresh like the plan-usage switch above. Logged when keep-warm is
* on but the version gate keeps the key off, so a session that may still idle-compact says why.
*/
private async _resolveIdleCompactionOptOut (
mode : SessionMode ,
statusLine : StatusLineExporter | undefined ,
keepWarm : boolean | undefined ,
spawnedByAgent : boolean | undefined
) : Promise < boolean > {
if ( statusLine !== 'injected' ) return false ; // no settings read for a launch that can never carry it
const { keepWarmDefault } = await readKeepWarmSettings ();
const off = decideIdleCompactionOptOut ({ mode , statusLine , keepWarm , keepWarmDefault , spawnedByAgent });
if ( ! off && keepWarmOn ( keepWarm , keepWarmDefault , !! spawnedByAgent )) {
console . log (
`[TmuxManager] Keep-warm is on but Claude Code's version is unknown or predates the idleCompaction setting; launching without the opt-out`
);
}
return off ;
}
2026-08-30 18:56:32 -05:00
/**
* Export the user's own REAL statusLine command (found by
* findEffectiveUserStatusLineCommand) via tmux setenv, so the shared
* exporter script (statusLineExporterScriptContent in hooks-config.ts) can
* wrap it. Via setenv rather than embedding it in the spawn command line:
* tmux stores a setenv value verbatim and never re-parses it as shell
* syntax, so once safely escaped for THIS one command, the command's own
* `$`/quotes survive untouched into the claude process's environment — the
* same reasoning that made the exporter script itself necessary (see
* ensureStatusLineExporterScript's doc comment). Only this ONE line needs
* shellescape(); the stored value itself is opaque to tmux from then on.
2026-09-14 15:55:17 +02:00
*
* With NO user command the variable is UNSET rather than left alone: a tmux
* setenv survives respawn-pane, so a user who deleted their own statusline
* would otherwise keep getting the stale one wrapped (and lose Codeman's
* footer print-through) until the tmux session was recreated. Same shape as
* the CLAUDE_CODE_EFFORT_LEVEL cleanup in applyEnvOverrides.
2026-08-30 18:56:32 -05:00
*/
private _configureStatusLineUserCommand ( muxName : string , command : string | undefined ) : void {
2026-09-14 15:55:17 +02:00
const setOrUnset = command
? `CODEMAN_USER_STATUSLINE_CMD ${ shellescape ( command ) } `
: '-u CODEMAN_USER_STATUSLINE_CMD' ;
2026-08-30 18:56:32 -05:00
try {
2026-09-14 15:55:17 +02:00
execSync ( ` ${ this . tmux () } setenv -t ${ shellescape ( muxName ) } ${ setOrUnset } ` , {
2026-08-30 18:56:32 -05:00
timeout : EXEC_TIMEOUT_MS ,
stdio : 'ignore' ,
});
} catch {
2026-09-14 15:55:17 +02:00
// Non-critical: the exporter prints its own footer, or nothing.
2026-08-30 18:56:32 -05:00
}
}
2026-02-12 10:29:57 +01:00
/**
* Creates a new tmux session wrapping Claude CLI or a shell.
2026-02-12 18:26:16 +01:00
* In test mode: creates an in-memory session only (no real tmux session).
2026-02-12 10:29:57 +01:00
*/
2026-02-26 10:07:43 +01:00
async createSession ( options : CreateSessionOptions ) : Promise < MuxSession > {
2026-03-10 02:22:53 +08:00
const {
sessionId ,
workingDir ,
mode ,
name ,
2026-09-24 07:48:33 +08:00
cliName ,
2026-03-10 02:22:53 +08:00
niceConfig ,
model ,
claudeMode ,
allowedTools ,
openCodeConfig ,
2026-06-10 09:31:58 -04:00
codexConfig ,
2026-05-16 20:10:49 -04:00
geminiConfig ,
2026-08-04 12:59:40 +02:00
antigravityConfig ,
2026-08-13 13:54:47 +02:00
piConfig ,
2026-08-23 08:39:03 +02:00
grokConfig ,
2026-10-10 14:06:04 +08:00
copilotConfig ,
2026-08-24 03:37:56 +02:00
deepSeekConfig ,
2026-08-18 23:17:58 -05:00
ompConfig ,
2026-03-10 02:22:53 +08:00
resumeSessionId ,
2026-04-22 00:01:35 +08:00
envOverrides ,
2026-06-07 10:33:11 +08:00
effort ,
2026-10-04 19:05:43 +02:00
advisorModel ,
2026-10-18 13:09:06 -04:00
keepWarm ,
spawnedByAgent ,
2026-06-29 17:49:39 -04:00
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT ,
2026-06-13 10:21:56 -04:00
remote ,
2026-07-19 15:09:48 +02:00
docker ,
2026-09-29 18:01:47 -07:00
adopt ,
2026-07-20 04:02:46 +02:00
owner ,
2026-03-10 02:22:53 +08:00
} = options ;
2026-02-26 16:44:34 +01:00
const muxName = `codeman- ${ sessionId . slice ( 0 , 8 ) } ` ;
2026-02-12 10:29:57 +01:00
if ( ! isValidMuxName ( muxName )) {
throw new Error ( 'Invalid session name: contains unsafe characters' );
}
if ( ! isValidPath ( workingDir )) {
throw new Error ( 'Invalid working directory path: contains unsafe characters' );
}
2026-02-12 18:26:16 +01:00
// TEST MODE: Create in-memory session only — no real tmux session
if ( IS_TEST_MODE ) {
const session : MuxSession = {
sessionId ,
muxName ,
pid : 99999 ,
createdAt : Date.now (),
workingDir ,
2026-06-13 10:21:56 -04:00
remote ,
2026-07-19 15:09:48 +02:00
docker ,
2026-09-29 18:01:47 -07:00
adopt ,
2026-07-20 04:02:46 +02:00
owner ,
2026-02-12 18:26:16 +01:00
mode ,
attached : false ,
name ,
};
this . sessions . set ( sessionId , session );
this . emit ( 'sessionCreated' , session );
return session ;
}
2026-08-21 02:33:01 +02:00
// Resolve CLI binary directory based on mode. The not-found messages come
// from the resolvers (formatCliNotFoundMessage) so the error names WHERE it
// looked — server PATH, login shell, checked directories — instead of just
// asserting the CLI is missing (the classic systemd/launchd PATH trap).
2026-03-25 23:32:28 +01:00
const { pathExport , dir : cliDir } = this . buildPathExport ( mode );
2026-09-02 08:26:45 +08:00
// Refuse the spawn rather than launching a pane that dies on `command not found`.
// `missingCliMessage()` returns null for a mode with no binary to find (`shell`), and
// carries bounded PATH/login-shell/search-dir diagnostics so the error says where we
// actually looked.
2026-09-05 16:21:51 +02:00
//
// ⚠️ Skipped entirely for a DOCKER session: the CLI runs INSIDE the container, so the
// host does not need it at all. Demanding it here threw for a host without the binary,
// the catch fell back to a direct PTY, and that PTY tried to exec the CLI on the HOST —
// surfacing as a bare `execvp(3) failed: No such file or directory` with nothing
// pointing at the real cause. The container's own CLIs are verified by the adoption
// preflight / image gate before launch instead.
2026-09-29 18:01:47 -07:00
//
// ⚠️ Skipped for an ADOPTED session for the mirror-image reason: its CLI was started by
// a human in a process we did not spawn, and our pane only runs `tmux attach`. Requiring
// the binary here would reject adopting a claude that lives in a container or on an ssh
// host, and (worse) reject a LOCAL adoption whenever the agent is outside the server
// process's PATH, which is the usual systemd/launchd situation.
const cliRunsInContainer = !! docker || !! adopt ;
2026-09-05 16:21:51 +02:00
if ( ! cliRunsInContainer && ! cliDir ) {
2026-09-02 08:26:45 +08:00
const message = missingCliMessage ( mode );
if ( message ) throw new Error ( message );
2026-08-23 08:39:03 +02:00
}
2026-02-12 10:29:57 +01:00
2026-03-25 23:32:28 +01:00
const envExportsStr = this . buildEnvExports ( sessionId , muxName , mode ). join ( ' && ' );
2026-02-12 10:29:57 +01:00
2026-10-10 12:22:00 -04:00
const { command : statusLineCommand , state : statusLine } = await this . _resolveStatusLine (
mode ,
workingDir ,
2026-11-06 03:06:47 +01:00
// An adopted wrapper launches no agent, so nothing can carry the exporter.
!! ( remote || docker || adopt )
2026-10-10 12:22:00 -04:00
);
2026-10-18 13:09:06 -04:00
const idleCompactionOff = await this . _resolveIdleCompactionOptOut ( mode , statusLine , keepWarm , spawnedByAgent );
2026-08-30 18:56:32 -05:00
// The user's own REAL statusLine, if any (walked via Claude Code's own
// settings precedence) — exported below so the shared exporter script
// can wrap it. Only worth discovering when we're actually injecting.
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand ( workingDir ) : undefined ;
2026-08-30 17:31:25 -05:00
2026-02-27 13:16:23 +01:00
const baseCmd = buildSpawnCommand ({
mode ,
sessionId ,
model ,
claudeMode ,
allowedTools ,
openCodeConfig ,
2026-06-10 09:31:58 -04:00
codexConfig ,
2026-05-16 20:10:49 -04:00
geminiConfig ,
2026-08-04 12:59:40 +02:00
antigravityConfig ,
2026-08-13 13:54:47 +02:00
piConfig ,
2026-08-23 08:39:03 +02:00
grokConfig ,
2026-10-10 14:06:04 +08:00
copilotConfig ,
2026-08-24 03:37:56 +02:00
deepSeekConfig ,
2026-08-18 23:17:58 -05:00
ompConfig ,
2026-03-10 02:22:53 +08:00
resumeSessionId ,
2026-06-07 10:33:11 +08:00
effort ,
2026-10-04 19:05:43 +02:00
advisorModel ,
2026-08-30 17:31:25 -05:00
statusLineCommand ,
2026-10-18 13:09:06 -04:00
idleCompactionOff ,
2026-09-24 07:48:33 +08:00
sessionName : cliName ,
2026-02-27 13:16:23 +01:00
});
2026-02-12 10:29:57 +01:00
const config = niceConfig || DEFAULT_NICE_CONFIG ;
const cmd = wrapWithNice ( baseCmd , config );
try {
// Build the full command to run inside tmux
2026-06-13 10:21:56 -04:00
const localFullCmd = ` ${ buildNofileLimitCommand () } && ${ pathExport }${ envExportsStr } && ${ cmd } ` ;
2026-09-29 18:01:47 -07:00
const fullCmd = adopt
? buildAdoptSessionCommand ( adopt , sessionId )
: docker
? buildDockerLaunchCommand ( resolveDockerLaunchOptions ( mode , docker , sessionId , resumeSessionId ))
: remote
? buildRemoteSessionCommand ({
mode ,
remote ,
sessionId ,
claudeMode ,
allowedTools ,
ompConfig ,
resumeSessionId ,
})
: localFullCmd ;
2026-02-12 10:29:57 +01:00
2026-02-24 13:34:30 +01:00
// Create tmux session in three steps to handle cold-start (no server running)
// and avoid the race where the command exits before remain-on-exit is set:
// 2. Set remain-on-exit (server now exists, session won't vanish on exit)
// 3. Replace shell with actual command via respawn-pane (no terminal echo)
2026-02-26 15:41:37 +01:00
// Unset $TMUX so nested sessions work when the dev server itself runs inside tmux.
// (Production uses systemd which has a clean env, but dev/test may be nested.)
const cleanEnv = { ... process . env };
delete cleanEnv . TMUX ;
2026-06-09 10:38:35 -04:00
// Create the session on the dedicated socket (${this.tmux()} = `tmux -L <socket>`),
// launched in TMUX_LAUNCH_CWD (/tmp) rather than the real workingDir: a FUSE/rclone
// mount that isn't ready yet makes `getcwd` fail and breaks the spawn (see #110). The
// pane cd's into workingDir below via respawn-pane.
2026-08-21 08:19:14 -04:00
// tmux <3.7 allocates history only at pane creation, so its global default
// must be set immediately BEFORE new-session. tmux 3.7+ can resize a pane
// after creation; target only the new session there because changing the
// global option can resize (and when lowered, trim) unrelated live panes.
const safeHistoryLimit =
Number . isSafeInteger ( historyLimit ) && historyLimit > 0 ? Math . trunc ( historyLimit ) : DEFAULT_TMUX_HISTORY_LIMIT ;
const createSessionCommand = this . supportsLiveHistoryResize ()
? ` ${ this . tmux () } new-session -ds " ${ muxName } " -c ${ TMUX_LAUNCH_CWD } \\; set-option -t " ${ muxName } " history-limit ${ safeHistoryLimit } `
: ` ${ this . tmux () } set-option -g history-limit ${ safeHistoryLimit } \\; new-session -ds " ${ muxName } " -c ${ TMUX_LAUNCH_CWD } \\; set-option -t " ${ muxName } " history-limit ${ safeHistoryLimit } ` ;
execSync ( createSessionCommand , {
2026-06-08 11:18:14 -04:00
cwd : TMUX_LAUNCH_CWD ,
2026-02-27 13:16:23 +01:00
timeout : EXEC_TIMEOUT_MS ,
stdio : 'ignore' ,
env : cleanEnv ,
});
2026-06-09 10:38:35 -04:00
this . resizeWindow ( muxName , 120 , 40 );
2026-02-12 10:29:57 +01:00
2026-02-24 13:34:30 +01:00
// Set remain-on-exit now that the server is running — must be before respawn-pane
2026-02-24 13:30:17 +01:00
try {
2026-05-26 05:28:58 +08:00
execSync ( ` ${ this . tmux () } set-option -t " ${ muxName } " remain-on-exit on` , {
2026-02-27 13:16:23 +01:00
timeout : EXEC_TIMEOUT_MS ,
stdio : 'ignore' ,
});
} catch {
/* Non-critical */
}
2026-02-24 13:30:17 +01:00
2026-10-22 19:23:09 +02:00
// Browser keystrokes reach the attach client, so tmux's stock prefix table would
// let Ctrl+B ( or s move a tab onto another session. Before the agent starts.
restrictTmuxPrefixTable ( this . tmux ());
2026-09-02 08:26:45 +08:00
// Per-CLI env: API keys, config blobs, config-sourced vars, status bridges. All of
// it is registry data, so this is one unconditional call rather than a per-mode ladder.
this . _configureCliEnv (
muxName ,
sessionId ,
mode ,
legacyConfigForMode ( mode , options as unknown as Record < string , unknown >)
);
2026-08-30 18:56:32 -05:00
this . _configureStatusLineUserCommand ( muxName , userStatusLineCommand );
2026-02-26 10:07:43 +01:00
2026-04-22 00:01:35 +08:00
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
// so secret values stay off the bash command line. Must run before respawn-pane.
this . applyEnvOverrides ( muxName , envOverrides );
2026-06-08 11:18:14 -04:00
// Replace the shell with the actual command (no echo in terminal). Keep
// pane launch in /tmp, then cd inside bash against the current mount table.
2026-11-06 03:29:35 +01:00
// ⚠️ An adopted pane is never `cd`'d: it only attaches, and its `workingDir`
// is the neutral wrapper folder, not where the foreign pane is.
2026-09-29 18:01:47 -07:00
const launchCmd = remote || docker || adopt ? fullCmd : `cd ${ JSON . stringify ( workingDir ) } && ${ fullCmd } ` ;
2026-06-08 11:18:14 -04:00
execSync (
2026-11-06 05:36:20 +01:00
` ${ this . tmux () } respawn-pane -k -c ${ TMUX_LAUNCH_CWD } -t " ${ muxName } " bash -c ${ bashCArg ( launchCmd , !! ( remote || docker || adopt )) } ` ,
2026-06-08 11:18:14 -04:00
{
timeout : EXEC_TIMEOUT_MS ,
stdio : 'ignore' ,
}
);
2026-02-24 13:30:17 +01:00
2026-02-24 13:23:00 +01:00
// Wait for tmux session to be queryable
2026-02-27 13:16:23 +01:00
await new Promise (( resolve ) => setTimeout ( resolve , TMUX_CREATION_WAIT_MS ));
2026-02-12 10:29:57 +01:00
2026-02-12 18:26:16 +01:00
// Non-critical tmux config — run in parallel to avoid blocking event loop.
2026-02-18 10:23:10 +01:00
// These configure UX niceties (no status bar, true color).
// Mouse mode is OFF by default so xterm.js handles text selection natively.
// It gets enabled dynamically when panes are split (agent teams).
2026-02-12 18:26:16 +01:00
const configPromises : Promise < void >[] = [
2026-02-26 16:44:34 +01:00
// Disable tmux status bar — Codeman's web UI provides session info
2026-05-26 05:28:58 +08:00
execAsync ( ` ${ this . tmux () } set-option -t " ${ muxName } " status off` , { timeout : EXEC_TIMEOUT_MS })
2026-02-27 13:16:23 +01:00
. then (() => {})
. catch (() => {
/* Non-critical — session still works with status bar */
}),
2026-02-24 13:25:26 +01:00
// Override global remain-on-exit with session-level setting
2026-05-26 05:28:58 +08:00
execAsync ( ` ${ this . tmux () } set-option -t " ${ muxName } " remain-on-exit on` , { timeout : EXEC_TIMEOUT_MS })
2026-02-27 13:16:23 +01:00
. then (() => {})
. catch (() => {
/* Already set globally as fallback */
}),
2026-02-12 18:26:16 +01:00
];
2026-02-12 10:29:57 +01:00
2026-02-12 18:26:16 +01:00
// Enable 24-bit true color passthrough — server-wide, set once per lifetime
2026-02-12 11:35:10 +01:00
if ( ! this . trueColorConfigured ) {
2026-02-12 18:26:16 +01:00
configPromises . push (
2026-05-26 05:28:58 +08:00
execAsync ( ` ${ this . tmux () } set-option -sa terminal-overrides ",*:Tc"` , { timeout : EXEC_TIMEOUT_MS })
2026-02-27 13:16:23 +01:00
. then (() => {
this . trueColorConfigured = true ;
})
. catch (() => {
/* Non-critical — colors limited to 256 */
})
2026-02-12 18:26:16 +01:00
);
2026-02-12 11:35:10 +01:00
}
2026-02-20 04:30:02 +01:00
// Fire-and-forget — these are non-critical UX niceties that don't need
// to complete before the session is usable. Errors are already swallowed.
void Promise . all ( configPromises );
2026-02-12 18:26:16 +01:00
2026-02-24 13:23:00 +01:00
// Get the PID of the pane process (retry for tmux server cold-start)
let pid = this . getPanePid ( muxName );
for ( let i = 0 ; ! pid && i < GET_PID_MAX_RETRIES ; i ++ ) {
2026-02-27 13:16:23 +01:00
await new Promise (( resolve ) => setTimeout ( resolve , GET_PID_RETRY_MS ));
2026-02-24 13:23:00 +01:00
pid = this . getPanePid ( muxName );
}
2026-02-12 10:29:57 +01:00
if ( ! pid ) {
throw new Error ( 'Failed to get tmux pane PID' );
}
const session : MuxSession = {
sessionId ,
muxName ,
pid ,
createdAt : Date.now (),
workingDir ,
2026-06-13 10:21:56 -04:00
remote ,
2026-07-19 15:09:48 +02:00
docker ,
2026-09-29 18:01:47 -07:00
adopt ,
2026-07-20 04:02:46 +02:00
owner ,
2026-02-12 10:29:57 +01:00
mode ,
attached : false ,
name ,
2026-10-10 12:22:00 -04:00
statusLine ,
2026-10-18 13:09:06 -04:00
idleCompactionOff ,
2026-02-12 10:29:57 +01:00
};
this . sessions . set ( sessionId , session );
this . saveSessions ();
this . emit ( 'sessionCreated' , session );
return session ;
} catch ( err ) {
throw new Error ( `Failed to create tmux session: ${ getErrorMessage ( err ) } ` );
}
}
/**
* Get the PID of the process running in the tmux pane.
*/
private getPanePid ( muxName : string ) : number | null {
2026-02-12 18:26:16 +01:00
if ( IS_TEST_MODE ) return 99999 ;
2026-02-12 10:29:57 +01:00
if ( ! isValidMuxName ( muxName )) {
console . error ( '[TmuxManager] Invalid session name in getPanePid:' , muxName );
return null ;
}
try {
2026-05-26 05:28:58 +08:00
const output = execSync ( ` ${ this . tmux () } display-message -t " ${ muxName } " -p '#{pane_pid}'` , {
2026-02-27 13:16:23 +01:00
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
}). trim ();
2026-02-12 10:29:57 +01:00
const pid = parseInt ( output , 10 );
return Number . isNaN ( pid ) ? null : pid ;
} catch {
return null ;
}
}
2026-10-10 04:20:46 +02:00
/**
* {@link getPanePid} without blocking the event loop, for the kill path: a close
* must not stall every other session's I/O while tmux answers.
*/
private async getPanePidAsync ( muxName : string ) : Promise < number | null > {
if ( IS_TEST_MODE ) return 99999 ;
if ( ! isValidMuxName ( muxName )) {
console . error ( '[TmuxManager] Invalid session name in getPanePidAsync:' , muxName );
return null ;
}
try {
const { stdout } = await execAsync ( ` ${ this . tmux () } display-message -t " ${ muxName } " -p '#{pane_pid}'` , {
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
});
const pid = parseInt ( stdout . trim (), 10 );
return Number . isNaN ( pid ) ? null : pid ;
} catch {
return null ;
}
}
2026-02-12 10:29:57 +01:00
/**
* Check if a tmux session exists.
*/
2026-02-19 21:38:01 +01:00
muxSessionExists ( muxName : string ) : boolean {
return this . sessionExists ( muxName );
}
2026-02-19 22:37:45 +01:00
/**
* Check if the pane in a tmux session is dead (command exited but remain-on-exit keeps it).
* Returns true if the session exists but the pane's command has exited.
*/
isPaneDead ( muxName : string ) : boolean {
if ( IS_TEST_MODE ) return false ;
if ( ! isValidMuxName ( muxName )) return false ;
try {
2026-05-26 05:28:58 +08:00
const output = execSync ( ` ${ this . tmux () } display-message -t " ${ muxName } " -p '#{pane_dead}'` , {
2026-02-27 13:16:23 +01:00
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
}). trim ();
2026-02-19 22:37:45 +01:00
return output === '1' ;
} catch {
return false ;
}
}
/**
* Respawn a dead pane in an existing tmux session.
* Uses `tmux respawn-pane -k` to restart the command in the same pane,
* preserving the session and its scrollback buffer.
*/
2026-02-26 10:07:43 +01:00
async respawnPane ( options : RespawnPaneOptions ) : Promise < number | null > {
2026-03-10 14:03:19 +08:00
const {
sessionId ,
workingDir ,
mode ,
niceConfig ,
model ,
claudeMode ,
allowedTools ,
openCodeConfig ,
2026-06-10 09:31:58 -04:00
codexConfig ,
2026-05-16 20:10:49 -04:00
geminiConfig ,
2026-08-04 12:59:40 +02:00
antigravityConfig ,
2026-08-13 13:54:47 +02:00
piConfig ,
2026-08-23 08:39:03 +02:00
grokConfig ,
2026-10-10 14:06:04 +08:00
copilotConfig ,
2026-08-24 03:37:56 +02:00
deepSeekConfig ,
2026-08-26 21:36:32 -05:00
ompConfig ,
2026-03-10 14:03:19 +08:00
resumeSessionId ,
2026-04-22 00:01:35 +08:00
envOverrides ,
2026-09-14 23:45:42 +02:00
unsetEnvKeys ,
2026-06-07 10:33:11 +08:00
effort ,
2026-10-04 19:05:43 +02:00
advisorModel ,
2026-10-18 13:09:06 -04:00
keepWarm ,
spawnedByAgent ,
2026-06-13 10:21:56 -04:00
remote ,
2026-07-19 15:09:48 +02:00
docker ,
2026-09-24 07:48:33 +08:00
cliName ,
2026-03-10 14:03:19 +08:00
} = options ;
2026-02-19 22:37:45 +01:00
const session = this . sessions . get ( sessionId );
if ( ! session ) return null ;
2026-09-29 18:01:47 -07:00
// Adoption metadata comes from the tracked session, not the caller: a respawn
// of an adopted wrapper RE-ATTACHES to the same foreign target. It must never
// be able to become "launch a fresh agent here" because a caller forgot to
// pass the field through.
const adopt = options . adopt ?? session . adopt ;
2026-02-19 22:37:45 +01:00
const muxName = session . muxName ;
if ( ! isValidMuxName ( muxName ) || ! isValidPath ( workingDir )) return null ;
2026-02-26 10:07:43 +01:00
// Resolve CLI binary directory based on mode
2026-03-25 23:32:28 +01:00
const { pathExport } = this . buildPathExport ( mode );
2026-02-26 10:07:43 +01:00
2026-03-25 23:32:28 +01:00
const envExportsStr = this . buildEnvExports ( sessionId , muxName , mode ). join ( ' && ' );
2026-02-19 22:37:45 +01:00
2026-10-10 12:22:00 -04:00
const { command : statusLineCommand , state : statusLine } = await this . _resolveStatusLine (
mode ,
workingDir ,
2026-11-06 03:06:47 +01:00
// An adopted wrapper launches no agent, so nothing can carry the exporter.
!! ( remote || docker || adopt )
2026-10-10 12:22:00 -04:00
);
2026-10-18 13:09:06 -04:00
const idleCompactionOff = await this . _resolveIdleCompactionOptOut ( mode , statusLine , keepWarm , spawnedByAgent );
2026-08-30 18:56:32 -05:00
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand ( workingDir ) : undefined ;
2026-08-30 17:31:25 -05:00
2026-02-27 13:16:23 +01:00
const baseCmd = buildSpawnCommand ({
mode ,
sessionId ,
model ,
claudeMode ,
allowedTools ,
openCodeConfig ,
2026-06-10 09:31:58 -04:00
codexConfig ,
2026-05-16 20:10:49 -04:00
geminiConfig ,
2026-08-04 12:59:40 +02:00
antigravityConfig ,
2026-08-13 13:54:47 +02:00
piConfig ,
2026-08-23 08:39:03 +02:00
grokConfig ,
2026-10-10 14:06:04 +08:00
copilotConfig ,
2026-08-24 03:37:56 +02:00
deepSeekConfig ,
2026-08-26 21:36:32 -05:00
ompConfig ,
2026-03-10 14:03:19 +08:00
resumeSessionId ,
2026-06-07 10:33:11 +08:00
effort ,
2026-10-04 19:05:43 +02:00
advisorModel ,
2026-08-30 17:31:25 -05:00
statusLineCommand ,
2026-10-18 13:09:06 -04:00
idleCompactionOff ,
2026-09-24 07:48:33 +08:00
sessionName : cliName ,
2026-02-27 13:16:23 +01:00
});
2026-02-19 22:37:45 +01:00
const config = niceConfig || DEFAULT_NICE_CONFIG ;
const cmd = wrapWithNice ( baseCmd , config );
2026-06-13 10:21:56 -04:00
const localFullCmd = ` ${ buildNofileLimitCommand () } && ${ pathExport }${ envExportsStr } && ${ cmd } ` ;
2026-09-29 18:01:47 -07:00
const fullCmd = adopt
? buildAdoptSessionCommand ( adopt , sessionId )
: docker
? buildDockerLaunchCommand ( resolveDockerLaunchOptions ( mode , docker , sessionId , resumeSessionId ))
: remote
? buildRemoteSessionCommand ({ mode , remote , sessionId , claudeMode , allowedTools , ompConfig , resumeSessionId })
: localFullCmd ;
2026-02-19 22:37:45 +01:00
try {
2026-09-02 08:26:45 +08:00
// Same per-CLI env setup as createSession, re-applied so the respawned pane inherits it.
this . _configureCliEnv (
muxName ,
sessionId ,
mode ,
legacyConfigForMode ( mode , options as unknown as Record < string , unknown >)
);
2026-08-30 18:56:32 -05:00
this . _configureStatusLineUserCommand ( muxName , userStatusLineCommand );
2026-02-26 10:07:43 +01:00
2026-09-14 23:45:42 +02:00
// Re-apply user env overrides before respawn so the new shell inherits them,
// dropping the ones the caller retired first (see applyEnvOverrides).
this . applyEnvOverrides ( muxName , envOverrides , unsetEnvKeys );
2026-04-22 00:01:35 +08:00
2026-06-13 10:21:56 -04:00
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
2026-11-06 03:29:35 +01:00
// ⚠️ An adopted pane is never `cd`'d: it only attaches, and its `workingDir`
// is the neutral wrapper folder, not where the foreign pane is.
2026-09-29 18:01:47 -07:00
const launchCmd = remote || docker || adopt ? fullCmd : `cd ${ JSON . stringify ( workingDir ) } && ${ fullCmd } ` ;
2026-06-08 11:18:14 -04:00
await execAsync (
2026-11-06 05:36:20 +01:00
` ${ this . tmux () } respawn-pane -k -c ${ TMUX_LAUNCH_CWD } -t " ${ muxName } " bash -c ${ bashCArg ( launchCmd , !! ( remote || docker || adopt )) } ` ,
2026-06-08 11:18:14 -04:00
{
timeout : EXEC_TIMEOUT_MS ,
}
);
2026-02-19 22:37:45 +01:00
// Wait for the respawned process to start
2026-02-27 13:16:23 +01:00
await new Promise (( resolve ) => setTimeout ( resolve , TMUX_CREATION_WAIT_MS ));
2026-09-21 10:00:54 +02:00
// The pane now runs a fresh command, so whatever the last read observed of
// the old one is history. Clearing it here rather than waiting for the next
// poll also invalidates any read already in flight, which would otherwise
// write the old death back over the pane that just replaced it.
this . clearPaneExit ( muxName );
2026-02-19 22:37:45 +01:00
const pid = this . getPanePid ( muxName );
if ( pid ) session . pid = pid ;
2026-10-18 13:09:06 -04:00
if ( session . statusLine !== statusLine || session . idleCompactionOff !== idleCompactionOff ) {
2026-10-10 12:22:00 -04:00
session . statusLine = statusLine ;
2026-10-18 13:09:06 -04:00
session . idleCompactionOff = idleCompactionOff ;
2026-10-10 12:22:00 -04:00
this . saveSessions ();
}
2026-02-19 22:37:45 +01:00
return pid ;
} catch ( err ) {
console . error ( '[TmuxManager] Failed to respawn pane:' , err );
return null ;
}
}
2026-02-12 10:29:57 +01:00
private sessionExists ( muxName : string ) : boolean {
2026-02-12 18:26:16 +01:00
if ( IS_TEST_MODE ) return false ;
2026-06-09 20:02:17 +02:00
if ( ! isValidMuxName ( muxName )) return false ;
2026-02-12 18:26:16 +01:00
2026-02-12 10:29:57 +01:00
try {
2026-05-26 05:28:58 +08:00
execSync ( ` ${ this . tmux () } has-session -t " ${ muxName } " 2>/dev/null` , {
2026-02-12 10:29:57 +01:00
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
});
return true ;
} catch {
return false ;
}
}
2026-08-07 01:34:47 +02:00
/** One `ps` snapshot of the whole process table, cached briefly. */
private static procSnapshot : { at : number ; byParent : Map < number , number [] > } | null = null ;
/** Single-flight guard so a hung `ps` cannot pile up parallel refreshes. */
private static procRefresh : { started : number ; promise : Promise < Map < number , number [] >> } | null = null ;
/**
* Fork ONE `ps` asynchronously and cache the parent -> children map.
*
* Async on purpose: a synchronous fork here would block the event loop on every
* stats tick, and under the procfs pathology this module exists to survive,
* `execSync`'s timeout cannot return at all (spawnSync waits for the unkillable
* child) — freezing the whole server where a hung async poll only costs staleness.
*/
private static refreshProcSnapshot () : Promise < Map < number , number [] >> {
const inFlight = TmuxManager . procRefresh ;
// Reuse an in-flight refresh — unless it is old enough to be presumed stuck.
if ( inFlight && Date . now () - inFlight . started < EXEC_TIMEOUT_MS * 2 ) return inFlight . promise ;
const started = Date . now ();
const promise = new Promise < Map < number , number [] >>(( resolve ) => {
execFile ( 'ps' , [ '-eo' , 'pid=,ppid=' ], { timeout : EXEC_TIMEOUT_MS , maxBuffer : 8 * 1024 * 1024 }, ( err , out ) => {
if ( TmuxManager . procRefresh ? . started === started ) TmuxManager . procRefresh = null ;
if ( err ) {
// ANY error, not just an empty one: a timed-out or truncated `ps` yields
// partial output, and caching that as fresh would make whole subtrees
// invisible — including to the kill path. Stale beats wrong.
console . error ( '[TmuxManager] process snapshot failed:' , err );
resolve ( TmuxManager . procSnapshot ? . byParent ?? new Map ());
return ;
2026-02-12 10:29:57 +01:00
}
2026-08-07 01:34:47 +02:00
const byParent = new Map < number , number [] >();
for ( const line of String ( out ). split ( '\n' )) {
const parts = line . trim (). split ( /\s+/ );
if ( parts . length < 2 ) continue ;
const pid = parseInt ( parts [ 0 ], 10 );
const ppid = parseInt ( parts [ 1 ], 10 );
if ( Number . isNaN ( pid ) || Number . isNaN ( ppid )) continue ;
const list = byParent . get ( ppid );
if ( list ) list . push ( pid );
else byParent . set ( ppid , [ pid ]);
}
TmuxManager . procSnapshot = { at : Date.now (), byParent };
resolve ( byParent );
});
});
TmuxManager . procRefresh = { started , promise };
return promise ;
}
/**
* Best snapshot WITHOUT forking: returns the cache, kicking off a background
* refresh when it has gone stale, and never blocks. Stats and window-title
* consumers tolerate data one interval old; nothing that KILLS may use this.
*/
private childrenByParent () : Map < number , number [] > {
const cached = TmuxManager . procSnapshot ;
if ( ! cached || Date . now () - cached . at >= PROC_SNAPSHOT_TTL_MS ) {
void TmuxManager . refreshProcSnapshot ();
2026-02-12 10:29:57 +01:00
}
2026-08-07 01:34:47 +02:00
return cached ? . byParent ?? new Map ();
}
/**
* Descendants from a snapshot that is not the cached one — the kill path's variant.
*
* killSession re-scans for survivors between SIGTERM and SIGKILL, and the wait in
* between (200ms) sits far inside the cache TTL (2000ms): reading the cache there
* returns the pre-SIGTERM state verbatim, so children spawned since are invisible
* and SIGKILL aims at stale PIDs, guarded only by kill(pid, 0) — which cannot
* detect PID reuse.
*
* It forces a refresh rather than guaranteeing recency: an already-running refresh
* is reused, so the snapshot can predate this call by up to one `ps` runtime. A
* strict postdate guarantee would mean chaining a second `ps` behind every
* in-flight one, which is the fork storm this code exists to avoid.
*
* Bounded by design: waiting forever would freeze killSession before it reaches
* its process-group and tmux fallbacks.
*/
private async getChildPidsFresh ( pid : number ) : Promise < number [] > {
let byParent : ReadonlyMap < number , readonly number [] >;
try {
byParent = await Promise . race ([
TmuxManager . refreshProcSnapshot (),
new Promise < never >(( _ , reject ) =>
setTimeout (() => reject ( new Error ( 'proc snapshot timeout' )), PROC_SNAPSHOT_WAIT_MS )
),
]);
} catch {
console . warn ( '[TmuxManager] process snapshot did not return in time; using the cached one' );
byParent = TmuxManager . procSnapshot ? . byParent ?? new Map < number , number [] >();
}
return collectDescendants ( pid , byParent , {
onTruncated : ( root , cap , reason ) =>
console . warn ( `[TmuxManager] descendant walk for ${ root } hit the ${ cap } - ${ reason } cap; truncating` ),
});
2026-02-12 10:29:57 +01:00
}
2026-10-10 04:20:46 +02:00
// Check if a process is still alive. Signal decisions only: an unreaped zombie
// counts here, so its process group still gets the SIGKILL below. The WAITS use
// waitForProcessesExit(), which counts a zombie as exited.
2026-02-12 10:29:57 +01:00
private isProcessAlive ( pid : number ) : boolean {
try {
process . kill ( pid , 0 );
return true ;
} catch {
return false ;
}
}
2026-10-10 04:20:46 +02:00
// Verify all PIDs are dead, returning as soon as they are
2026-02-12 10:29:57 +01:00
private async verifyProcessesDead ( pids : number [], maxWaitMs : number = 1000 ) : Promise < boolean > {
2026-10-10 04:20:46 +02:00
const stillAlive = await waitForProcessesExit ( pids , { timeoutMs : maxWaitMs });
2026-02-12 10:29:57 +01:00
if ( stillAlive . length > 0 ) {
console . warn ( `[TmuxManager] ${ stillAlive . length } processes still alive after kill: ${ stillAlive . join ( ', ' ) } ` );
}
return stillAlive . length === 0 ;
}
/**
* Kill a tmux session and all its child processes.
2026-02-18 12:51:35 +01:00
* Uses a 4-strategy approach (children → process group → tmux kill → SIGKILL).
2026-02-12 18:26:16 +01:00
* In test mode: removes from memory only (no real kill).
2026-02-12 10:29:57 +01:00
*/
async killSession ( sessionId : string ) : Promise < boolean > {
const session = this . sessions . get ( sessionId );
if ( ! session ) {
return false ;
}
2026-06-13 14:26:06 -04:00
// COD-108: an intentional kill/detach must NEVER be auto-revived by the
// remote-reconnect watcher. Guard BEFORE any teardown so a tick that fires
// mid-kill (especially the non-owned DETACH early-return below, where the
// dead local pane would otherwise look reconnectable) sees the guard.
this . guardRemoteReconnect ( sessionId );
2026-02-12 18:26:16 +01:00
// TEST MODE: Remove from memory only — NEVER touch real tmux sessions
if ( IS_TEST_MODE ) {
this . sessions . delete ( sessionId );
2026-06-13 14:26:06 -04:00
this . clearRemoteReconnectState ( sessionId );
2026-02-12 18:26:16 +01:00
this . emit ( 'sessionKilled' , { sessionId });
return true ;
}
2026-02-12 11:35:10 +01:00
// SAFETY: Never kill the tmux session we're running inside of
2026-02-26 16:44:34 +01:00
const currentMuxName = process . env . CODEMAN_MUX_NAME ;
2026-02-12 11:35:10 +01:00
if ( currentMuxName && session . muxName === currentMuxName ) {
console . error ( `[TmuxManager] BLOCKED: Refusing to kill own tmux session: ${ session . muxName } ` );
return false ;
}
2026-06-13 11:04:18 -04:00
// COD-105 — DETACH-NOT-KILL for NON-owned remote sessions.
//
// When this session was created by ATTACHING a remote tmux session another
// Codeman owns (`remote.owned === false`), closing the tab must NOT propagate
// a remote `tmux kill-session` — that would nuke work the remote's own
// Codeman (or another instance) still relies on. We tear down ONLY the LOCAL
// pane that holds the ssh client: killing the local ssh sends SIGHUP to its
// remote `tmux attach`, which DETACHES (the durable remote session survives).
//
// This early return is the structural guarantee: no code below this point
// (now or in future for owned sessions) can ever issue a remote kill-session
// for a non-owned session. The only `kill-session` we run is on OUR LOCAL
// socket (`this.tmux()` = `tmux -L codeman` on THIS host), which kills the
// local pane — it does NOT reach the REMOTE socket.
2026-11-06 08:20:39 +01:00
// ADOPTION: DETACH-NOT-KILL, by construction rather than by discipline.
2026-09-29 18:01:47 -07:00
//
// An adopted session's wrapper is an ordinary session on OUR socket whose
// pane runs `tmux attach` (or `docker exec …`/`ssh …` then attach) against a
// FOREIGN server. Killing the wrapper kills our attach client, which is a
// DETACH on the foreign side; the human's session keeps running, and our
// grouped view session self-destroys via `destroy-unattached on`.
//
2026-11-06 08:20:39 +01:00
// The guard below is a formality more than a fence: `this.tmux()` only ever
2026-09-29 18:01:47 -07:00
// addresses our own socket, so no code path here CAN reach the foreign
// server. It is written out anyway so the intent survives future edits, the
// same way the non-owned-remote early return does.
if ( session . adopt ) {
console . log ( `[TmuxManager] DETACH (adopted): tearing down wrapper only for ${ session . muxName } ` );
// A `docker exec` survives its client, so the in-container view would stay
// attached forever and leak. Best-effort, and it can only reach OUR view.
// See buildForeignDockerViewKillCommand for why local/remote need nothing.
2026-11-06 03:06:47 +01:00
// Fire and forget like the owned docker branch below: a daemon that stopped
// answering must not hold the event loop for the whole timeout.
2026-09-29 18:01:47 -07:00
if ( session . adopt . location === 'docker' && session . adopt . docker ) {
try {
2026-11-06 03:06:47 +01:00
exec (
buildForeignDockerViewKillCommand ({
2026-09-29 18:01:47 -07:00
dockerBase : buildDockerBaseArgs ( session . adopt . docker ). join ( ' ' ),
containerName : session.adopt.docker.containerName ,
socketPath : session.adopt.socketPath ,
viewSession : session.adopt.viewSession || foreignViewSessionName ( sessionId ),
2026-11-06 03:06:47 +01:00
}),
{ timeout : EXEC_TIMEOUT_MS },
() => {}
2026-09-29 18:01:47 -07:00
);
} catch {
2026-11-06 03:06:47 +01:00
// Best-effort: the container may be gone, or the view already collected.
2026-09-29 18:01:47 -07:00
}
}
if ( isValidMuxName ( session . muxName )) {
try {
2026-11-06 03:06:47 +01:00
await execAsync ( ` ${ this . tmux () } kill-session -t " ${ session . muxName } " 2>/dev/null` , {
2026-09-29 18:01:47 -07:00
timeout : EXEC_TIMEOUT_MS ,
});
} catch {
// Wrapper may already be gone.
}
}
this . lastPaneCount . delete ( session . muxName );
2026-11-06 03:06:47 +01:00
this . clearPaneExit ( session . muxName );
2026-09-29 18:01:47 -07:00
this . sessions . delete ( sessionId );
2026-11-06 03:06:47 +01:00
this . clearRemoteReconnectState ( sessionId );
2026-09-29 18:01:47 -07:00
this . saveSessions ();
this . emit ( 'sessionKilled' , { sessionId });
return true ;
}
2026-06-13 11:04:18 -04:00
if ( session . remote && session . remote . owned === false ) {
console . log ( `[TmuxManager] DETACH (non-owned remote): tearing down local pane only for ${ session . muxName } ` );
if ( isValidMuxName ( session . muxName )) {
try {
// Local socket only — detaches the remote session by killing the local ssh pane.
2026-10-10 04:20:46 +02:00
await execAsync ( ` ${ this . tmux () } kill-session -t " ${ session . muxName } " 2>/dev/null` , {
2026-06-13 11:04:18 -04:00
timeout : EXEC_TIMEOUT_MS ,
});
} catch {
// Local pane may already be gone.
}
}
this . lastPaneCount . delete ( session . muxName );
2026-09-21 10:00:54 +02:00
this . clearPaneExit ( session . muxName );
2026-06-13 11:04:18 -04:00
this . sessions . delete ( sessionId );
2026-06-13 14:26:06 -04:00
this . clearRemoteReconnectState ( sessionId );
2026-06-13 11:04:18 -04:00
this . saveSessions ();
this . emit ( 'sessionKilled' , { sessionId });
return true ;
}
2026-02-12 10:29:57 +01:00
// Get current PID (may have changed)
2026-10-10 04:20:46 +02:00
const currentPid = ( await this . getPanePidAsync ( session . muxName )) || session . pid ;
2026-02-12 10:29:57 +01:00
console . log ( `[TmuxManager] Killing session ${ session . muxName } (PID ${ currentPid } )` );
const allPids : number [] = [ currentPid ];
// Strategy 1: Kill all child processes recursively
2026-08-07 01:34:47 +02:00
let childPids = await this . getChildPidsFresh ( currentPid );
2026-02-12 10:29:57 +01:00
if ( childPids . length > 0 ) {
console . log ( `[TmuxManager] Found ${ childPids . length } child processes to kill` );
allPids . push (... childPids );
for ( const childPid of [... childPids ]. reverse ()) {
if ( this . isProcessAlive ( childPid )) {
try {
process . kill ( childPid , 'SIGTERM' );
} catch {
// Process may already be dead
}
}
}
2026-10-10 04:20:46 +02:00
// Most children are gone within a few ms; the re-scan below still runs, to
// catch anything spawned since the first one.
await waitForProcessesExit ( childPids , { timeoutMs : TMUX_KILL_WAIT_MS });
2026-02-12 10:29:57 +01:00
2026-08-07 01:34:47 +02:00
childPids = await this . getChildPidsFresh ( currentPid );
2026-02-12 10:29:57 +01:00
for ( const childPid of childPids ) {
if ( this . isProcessAlive ( childPid )) {
try {
process . kill ( childPid , 'SIGKILL' );
} catch {
// Process already terminated
}
}
}
}
// Strategy 2: Kill the entire process group
if ( this . isProcessAlive ( currentPid )) {
try {
process . kill ( - currentPid , 'SIGTERM' );
2026-10-10 04:20:46 +02:00
await waitForProcessesExit ([ currentPid ], { timeoutMs : GRACEFUL_SHUTDOWN_WAIT_MS });
2026-02-12 10:29:57 +01:00
if ( this . isProcessAlive ( currentPid )) {
process . kill ( - currentPid , 'SIGKILL' );
}
} catch {
// Process group may not exist or already terminated
}
}
2026-10-10 04:20:46 +02:00
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell).
// Async: tmux takes tens of ms to tear a session down, and execSync held the
// whole server for that long on every close.
2026-06-09 20:02:17 +02:00
if ( isValidMuxName ( session . muxName )) {
try {
2026-10-10 04:20:46 +02:00
await execAsync ( ` ${ this . tmux () } kill-session -t " ${ session . muxName } " 2>/dev/null` , {
2026-06-09 20:02:17 +02:00
timeout : EXEC_TIMEOUT_MS ,
});
} catch {
// Session may already be dead
}
2026-02-12 10:29:57 +01:00
}
2026-07-12 19:49:39 +02:00
// Strategy 3b: Remote sessions run a DURABLE tmux server on the remote host
// (survives ssh drops), so killing only the local ssh wrapper above would
// orphan the remote agent forever. Fire a best-effort `ssh … tmux kill-session`
// — fire-and-forget so it NEVER blocks or throws the local kill (bounded by the
// shared ConnectTimeout on an unreachable host).
if ( session . remote ) {
try {
const remoteKillCmd = buildRemoteKillCommand ({ remote : session.remote , sessionId });
exec ( remoteKillCmd , { timeout : EXEC_TIMEOUT_MS }, () => {});
} catch {
// Best-effort — a failure here must not affect the local kill result.
}
}
2026-07-19 15:09:48 +02:00
// Strategy 3c: Docker sessions run a DURABLE in-container tmux session. Kill
// ONLY this session's in-container tmux session (best-effort). The container is
// PER-CASE and shared by the case's other sessions, so we deliberately do NOT
// `docker stop` it here — stopping/removing is an explicit teardown/case-delete.
if ( session . docker && ! IS_TEST_MODE ) {
try {
exec ( buildDockerKillCommand ({ docker : session.docker , sessionId }), { timeout : EXEC_TIMEOUT_MS }, () => {});
} catch {
// Best-effort — never affects the local kill result.
2026-07-12 19:49:39 +02:00
}
}
2026-02-12 10:29:57 +01:00
// Strategy 4: Direct kill by PID as final fallback
if ( this . isProcessAlive ( currentPid )) {
try {
process . kill ( currentPid , 'SIGKILL' );
} catch {
// Already dead
}
}
// Verify all processes are dead
2026-10-10 04:20:46 +02:00
const allDead = await this . verifyProcessesDead ( allPids , KILL_VERIFY_TIMEOUT_MS );
2026-02-12 10:29:57 +01:00
if ( ! allDead ) {
console . error ( `[TmuxManager] Warning: Some processes may still be alive for session ${ session . muxName } ` );
}
2026-02-18 10:23:10 +01:00
this . lastPaneCount . delete ( session . muxName );
2026-09-21 10:00:54 +02:00
this . clearPaneExit ( session . muxName );
2026-02-12 10:29:57 +01:00
this . sessions . delete ( sessionId );
2026-06-13 14:26:06 -04:00
this . clearRemoteReconnectState ( sessionId );
2026-02-12 10:29:57 +01:00
this . saveSessions ();
this . emit ( 'sessionKilled' , { sessionId });
return true ;
}
getSessions () : MuxSession [] {
return Array . from ( this . sessions . values ());
}
getSession ( sessionId : string ) : MuxSession | undefined {
return this . sessions . get ( sessionId );
}
updateSessionName ( sessionId : string , name : string ) : boolean {
const session = this . sessions . get ( sessionId );
if ( ! session ) {
return false ;
}
session . name = name ;
this . saveSessions ();
return true ;
}
/**
* Reconcile tracked sessions with actual running tmux sessions.
*/
async reconcileSessions () : Promise < { alive : string []; dead : string []; discovered : string [] } > {
2026-02-12 18:26:16 +01:00
// TEST MODE: Return all registered sessions as alive, never discover real ones
if ( IS_TEST_MODE ) {
return {
alive : Array.from ( this . sessions . keys ()),
dead : [],
discovered : [],
};
}
2026-02-12 10:29:57 +01:00
const alive : string [] = [];
const dead : string [] = [];
const discovered : string [] = [];
2026-05-26 05:28:58 +08:00
// Single batched query against the one socket Codeman owns. With a single
// socket a session's location is a constant, so there is no per-session
// socket tag to reconcile and no cross-socket ambiguity that could mark a
// live session dead (the root cause of vanished/duplicate tabs).
let active : Map < string , number >;
2026-10-21 03:48:00 +02:00
const paneDirs = new Map < string , string >();
2026-03-14 19:20:44 +01:00
try {
2026-05-26 05:28:58 +08:00
const output = execSync ( ` ${ this . tmux () } list-panes -a -F ' ${ PANE_LIST_FORMAT } ' 2>/dev/null || true` , {
2026-03-14 19:20:44 +01:00
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
2026-10-21 08:02:05 +02:00
});
// Not trimmed: the directory is the last field, so trimming would cut a
// trailing space off the last row's path. parsePaneRows skips empty lines.
2026-09-21 10:00:54 +02:00
const rows = parsePaneRows ( output );
active = new Map ( rows . map (( row ) => [ row . sessionName , row . pid ]));
2026-10-21 03:48:00 +02:00
// A discovered session's directory, from its first pane with a usable path.
// The path is admitted only if it passes the same check createSession and
// respawnPane apply, since it was set by whoever owns the pane, not by us.
2026-10-21 08:02:05 +02:00
// The launch directory counts as no answer: every pane starts there, and a
// remote or docker pane never cds out of it, so for such a session it is
// not the workspace — and a rediscovered record cannot tell it is one.
2026-10-21 03:48:00 +02:00
for ( const row of rows ) {
2026-10-21 08:02:05 +02:00
if (
row . currentPath &&
! TMUX_LAUNCH_CWD_SPELLINGS . has ( row . currentPath ) &&
! paneDirs . has ( row . sessionName ) &&
isValidPath ( row . currentPath )
) {
2026-10-21 03:48:00 +02:00
paneDirs . set ( row . sessionName , row . currentPath );
}
}
2026-09-21 10:00:54 +02:00
// The same read answers both questions, so recovery starts with a pane-exit
// reading rather than waiting for the first stats tick — which may never
// come, since the collector only starts when boot found a live session.
if ( rows . length > 0 ) {
this . applyPaneExits ( derivePaneExits ( rows , Date . now ()));
}
2026-03-14 19:20:44 +01:00
} catch ( err ) {
console . error ( '[TmuxManager] Failed to list tmux panes:' , err );
2026-05-26 05:28:58 +08:00
active = new Map ();
2026-03-14 19:20:44 +01:00
}
2026-10-22 19:23:09 +02:00
// Sessions that survived an upgrade may sit on a server whose prefix table was never
// reduced (createSession does it for new ones), so cover them at boot too. Only with
// panes listed: on an empty socket `list-keys` spins up a throwaway server instead.
if ( active . size > 0 ) restrictTmuxPrefixTable ( this . tmux ());
2026-05-26 05:28:58 +08:00
// Check tracked sessions against the live pane list.
2026-02-12 10:29:57 +01:00
for ( const [ sessionId , session ] of this . sessions ) {
2026-05-26 05:28:58 +08:00
const pid = active . get ( session . muxName );
2026-03-14 19:20:44 +01:00
if ( pid !== undefined ) {
2026-02-12 10:29:57 +01:00
alive . push ( sessionId );
2026-05-26 05:28:58 +08:00
if ( pid !== session . pid ) session . pid = pid ;
2026-02-12 10:29:57 +01:00
} else {
dead . push ( sessionId );
this . sessions . delete ( sessionId );
2026-09-21 10:00:54 +02:00
this . clearPaneExit ( session . muxName );
2026-06-13 14:26:06 -04:00
this . clearRemoteReconnectState ( sessionId );
2026-02-12 10:29:57 +01:00
this . emit ( 'sessionDied' , { sessionId });
}
}
2026-05-26 05:28:58 +08:00
// 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.
2026-03-14 19:20:44 +01:00
const knownMuxNames = new Set < string >();
for ( const session of this . sessions . values ()) {
knownMuxNames . add ( session . muxName );
}
2026-02-12 10:29:57 +01:00
2026-05-26 05:28:58 +08:00
for ( const [ sessionName , pid ] of active ) {
2026-03-14 19:20:44 +01:00
if ( ! sessionName . startsWith ( 'codeman-' ) && ! sessionName . startsWith ( 'claudeman-' )) continue ;
2026-06-09 20:02:17 +02:00
// Only admit names that pass the safe-name pattern. A foreign process on the
// shared `tmux -L codeman` socket could create a `codeman-…` session whose name
// contains shell metacharacters; rejecting it here keeps it out of this.sessions
// and away from the name-interpolating tmux call sites (M1).
if ( ! isValidMuxName ( sessionName )) {
console . warn ( `[TmuxManager] Skipping discovered tmux session with unsafe name: ${ sessionName } ` );
continue ;
}
2026-03-14 19:20:44 +01:00
if ( knownMuxNames . has ( sessionName )) continue ;
2026-02-12 10:29:57 +01:00
2026-03-14 19:20:44 +01:00
const fragment = sessionName . replace ( /^(?:codeman|claudeman)-/ , '' );
const sessionId = `restored- ${ fragment } ` ;
const session : MuxSession = {
sessionId ,
muxName : sessionName ,
pid ,
createdAt : Date.now (),
2026-10-21 03:48:00 +02:00
// Where the pane actually works, when tmux said; the server's cwd is only a fallback.
workingDir : paneDirs.get ( sessionName ) ?? process . cwd (),
2026-03-14 19:20:44 +01:00
mode : 'claude' ,
attached : false ,
name : `Restored: ${ sessionName } ` ,
2026-10-21 08:02:05 +02:00
// Every field above except the name, the pid and, when tmux reported a
// usable one, the directory is a guess: this record
2026-09-21 10:00:54 +02:00
// was rebuilt from the socket because Codeman's own bookkeeping did not
// have it. The synthetic id also cannot find the session's state.json
// entry, so a remote or docker session rediscovered this way arrives
// looking local. Consumers that would be wrong about such a session
// read this flag and fail closed — see `Session.paneExitApplies`.
discovered : true ,
2026-03-14 19:20:44 +01:00
};
this . sessions . set ( sessionId , session );
2026-05-26 05:28:58 +08:00
knownMuxNames . add ( sessionName );
2026-03-14 19:20:44 +01:00
discovered . push ( sessionId );
console . log ( `[TmuxManager] Discovered unknown tmux session: ${ sessionName } (PID ${ pid } )` );
2026-02-12 10:29:57 +01:00
}
if ( dead . length > 0 || discovered . length > 0 ) {
this . saveSessions ();
}
return { alive , dead , discovered };
}
async getProcessStats ( sessionId : string ) : Promise < ProcessStats | null > {
2026-02-12 18:26:16 +01:00
if ( IS_TEST_MODE ) return { memoryMB : 0 , cpuPercent : 0 , childCount : 0 , updatedAt : Date.now () };
2026-02-12 10:29:57 +01:00
const session = this . sessions . get ( sessionId );
if ( ! session ) {
return null ;
}
try {
2026-06-02 01:32:24 +08:00
const psOutput = (
await execAsync ( `ps -o rss=,pcpu= -p ${ session . pid } 2>/dev/null || echo "0 0"` , {
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
})
). stdout . trim ();
2026-02-12 10:29:57 +01:00
2026-02-27 13:16:23 +01:00
const [ rss , cpu ] = psOutput . split ( /\s+/ ). map (( x ) => parseFloat ( x ) || 0 );
2026-02-12 10:29:57 +01:00
2026-08-07 01:34:47 +02:00
// From the shared snapshot: this runs per session on every stats tick, and a
// pgrep per session was a fork per session per interval.
2026-02-12 10:29:57 +01:00
let childCount = 0 ;
try {
2026-08-07 01:34:47 +02:00
childCount = ( this . childrenByParent (). get ( session . pid ) ?? []). length ;
2026-02-12 10:29:57 +01:00
} catch {
2026-08-07 01:34:47 +02:00
// No children or snapshot unavailable
2026-02-12 10:29:57 +01:00
}
return {
2026-02-27 13:16:23 +01:00
memoryMB : Math.round (( rss / 1024 ) * 10 ) / 10 ,
2026-02-12 10:29:57 +01:00
cpuPercent : Math.round ( cpu * 10 ) / 10 ,
childCount ,
updatedAt : Date.now (),
};
} catch {
return null ;
}
}
async getSessionsWithStats () : Promise < MuxSessionWithStats [] > {
2026-02-12 18:26:16 +01:00
if ( IS_TEST_MODE ) {
2026-02-27 13:16:23 +01:00
return Array . from ( this . sessions . values ()). map (( s ) => ({
2026-02-12 18:26:16 +01:00
... s ,
stats : { memoryMB : 0 , cpuPercent : 0 , childCount : 0 , updatedAt : Date.now () },
}));
}
2026-02-12 10:29:57 +01:00
const sessions = Array . from ( this . sessions . values ());
if ( sessions . length === 0 ) {
return [];
}
2026-02-27 13:16:23 +01:00
const sessionPids = sessions . map (( s ) => s . pid );
2026-02-12 10:29:57 +01:00
const statsMap = new Map < number , ProcessStats >();
try {
// Step 1: Get descendant PIDs
const descendantMap = new Map < number , number [] >();
2026-08-07 01:34:47 +02:00
// Derived from the ONE snapshot instead of a shell loop that forks a pgrep
// per session — the shape that turned into a fork storm under load.
const byParent = this . childrenByParent ();
const childLines = sessionPids . map (( p ) => ` ${ p } : ${ ( byParent . get ( p ) ?? []). join ( ',' ) } ` ). join ( '\n' );
2026-02-12 10:29:57 +01:00
2026-08-07 01:34:47 +02:00
for ( const line of childLines . split ( '\n' )) {
2026-02-12 10:29:57 +01:00
const [ pidStr , childrenStr ] = line . split ( ':' );
const sessionPid = parseInt ( pidStr , 10 );
if ( ! Number . isNaN ( sessionPid )) {
const children = ( childrenStr || '' )
. split ( ',' )
2026-02-27 13:16:23 +01:00
. map (( s ) => parseInt ( s . trim (), 10 ))
. filter (( n ) => ! Number . isNaN ( n ) && n > 0 );
2026-02-12 10:29:57 +01:00
descendantMap . set ( sessionPid , children );
}
}
// Step 2: Collect all PIDs
const allPids = new Set < number >( sessionPids );
for ( const children of descendantMap . values ()) {
for ( const child of children ) {
allPids . add ( child );
}
}
// Step 3: Single ps call
const pidArray = Array . from ( allPids );
if ( pidArray . length > 0 ) {
2026-06-02 01:32:24 +08:00
const psOutput = (
await execAsync ( `ps -o pid=,rss=,pcpu= -p ${ pidArray . join ( ',' ) } 2>/dev/null || true` , {
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
})
). stdout . trim ();
2026-02-12 10:29:57 +01:00
const processStats = new Map < number , { rss : number ; cpu : number }>();
for ( const line of psOutput . split ( '\n' )) {
const parts = line . trim (). split ( /\s+/ );
if ( parts . length >= 3 ) {
const pid = parseInt ( parts [ 0 ], 10 );
const rss = parseFloat ( parts [ 1 ]) || 0 ;
const cpu = parseFloat ( parts [ 2 ]) || 0 ;
if ( ! Number . isNaN ( pid )) {
processStats . set ( pid , { rss , cpu });
}
}
}
// Step 4: Aggregate stats
for ( const sessionPid of sessionPids ) {
const children = descendantMap . get ( sessionPid ) || [];
const sessionStats = processStats . get ( sessionPid ) || { rss : 0 , cpu : 0 };
let totalRss = sessionStats . rss ;
let totalCpu = sessionStats . cpu ;
for ( const childPid of children ) {
const childStats = processStats . get ( childPid );
if ( childStats ) {
totalRss += childStats . rss ;
totalCpu += childStats . cpu ;
}
}
statsMap . set ( sessionPid , {
2026-02-27 13:16:23 +01:00
memoryMB : Math.round (( totalRss / 1024 ) * 10 ) / 10 ,
2026-02-12 10:29:57 +01:00
cpuPercent : Math.round ( totalCpu * 10 ) / 10 ,
childCount : children.length ,
updatedAt : Date.now (),
});
}
}
} catch {
// Fall back to individual queries
2026-02-27 13:16:23 +01:00
const statsPromises = sessions . map (( session ) => this . getProcessStats ( session . sessionId ));
2026-02-12 10:29:57 +01:00
const results = await Promise . allSettled ( statsPromises );
return sessions . map (( session , i ) => ({
... session ,
stats : results [ i ]. status === 'fulfilled' ? ( results [ i ]. value ?? undefined ) : undefined ,
}));
}
2026-02-27 13:16:23 +01:00
return sessions . map (( session ) => ({
2026-02-12 10:29:57 +01:00
... session ,
stats : statsMap.get ( session . pid ) || undefined ,
}));
}
2026-10-22 20:59:28 -04:00
/**
* The directory the session's pane was in at the last authoritative pane
* read, or `undefined` when unknown. Costs nothing: it reads the map the
* pane-exit watcher's batched `list-panes -a` fills, so no tmux call is made
2026-10-28 15:28:59 -04:00
* per click. A failed or empty read keeps the previous answers, and so does
* a blank `#{pane_current_path}` on a single-pane session. After a successful
* read, `undefined` means that read listed no single directory for the
* session: it was split into several panes or is no longer on the socket.
2026-10-22 20:59:28 -04:00
*/
getPaneCurrentPath ( muxName : string ) : string | undefined {
return this . paneCurrentPaths . get ( muxName );
}
2026-09-21 10:00:54 +02:00
/**
* What the last pane read saw of this tmux session's agent. `undefined` is
* the UNKNOWN answer and must never be rendered as "alive": it covers a pane
* that is running, a tmux session that no longer exists, a probe that failed,
* and every poll that has not run yet. See {@link PaneExit}.
*/
getPaneExit ( muxName : string ) : PaneExit | undefined {
return this . paneExits . get ( muxName ) ? . exit ;
}
2026-09-24 22:18:07 +02:00
/**
* How many authoritative pane reads have agreed on the exit that
* {@link getPaneExit} reports, or 0 when it reports none. A new observation
* starts at 1, and every later read that sees the same pane with the same
* status and signal adds one.
*/
getPaneExitReadCount ( muxName : string ) : number {
return this . paneExits . get ( muxName ) ? . reads ?? 0 ;
}
2026-09-21 10:00:54 +02:00
/**
* Re-read every pane on the socket and refresh {@link paneExits}. ONE batched
* `tmux list-panes -a` answers for every session at once, which is why this
* polls rather than probing per session.
*
* A failed or empty probe leaves the previous answers ALONE rather than
2026-09-22 15:24:09 +02:00
* clearing them, because the two cannot be told apart: the command ends in
* `|| true`, so a tmux that errored and a socket with genuinely no panes both
* arrive as empty output. Treating that as "tmux did not answer" is the
* conservative reading — clearing on it would turn a transient failure into a
* silent retraction of a death Codeman had already observed, and the cost of
* being wrong the other way is one stale entry for a socket that no longer
* has the pane. A NON-empty read is different: `list-panes -a` lists
2026-09-21 10:00:54 +02:00
* every pane on the socket, so it is authoritative and {@link applyPaneExits}
* prunes against it.
*
* Two guards keep a slow read from undoing a fast one. A read already in
* flight suppresses the next poll, and a read that started before a
* {@link clearPaneExit} is discarded when it lands.
2026-09-22 09:19:55 +02:00
*
* A third guard skips the read entirely while no session on this manager
* could produce an answer ({@link hasObservablePaneSession}). Skipping
* retracts nothing, for the same reason a failed read does not: the map
* still holds what the last real read saw, and every path that puts a new
* command in a pane calls {@link clearPaneExit} itself.
2026-09-21 10:00:54 +02:00
*/
async refreshPaneExits ( now : number = Date . now ()) : Promise < void > {
2026-09-22 15:24:09 +02:00
// Nothing on this socket could answer, so do not read tmux to find that
2026-09-22 09:19:55 +02:00
// out. See `hasObservablePaneSession`: the watcher above still ticks.
if ( ! hasObservablePaneSession ( this . sessions . values ())) return ;
2026-09-21 10:00:54 +02:00
if ( this . paneExitReadInFlight ) return ;
const generation = this . paneExitGeneration ;
this . paneExitReadInFlight = true ;
let rows : PaneRow [];
try {
2026-09-22 15:24:09 +02:00
rows = await this . readPaneRows ();
2026-09-21 10:00:54 +02:00
} finally {
this . paneExitReadInFlight = false ;
}
if ( rows . length === 0 ) return ;
2026-10-22 20:59:28 -04:00
// The directory reading rides the same read. It is applied before the
// generation check below: a respawn invalidates an exit observation, but the
2026-10-28 15:28:59 -04:00
// pane's directory at read time is still the best answer there is. A blank
// field on a single-pane session carries the previous answer forward.
this . paneCurrentPaths = derivePaneCurrentPaths ( rows , this . paneCurrentPaths );
2026-09-21 10:00:54 +02:00
// A pane was respawned or killed while this read was out, so what it saw is
// already history. Dropping it is what stops a freshly respawned pane from
// being republished as exited.
if ( generation !== this . paneExitGeneration ) return ;
this . applyPaneExits ( derivePaneExits ( rows , now ));
}
2026-09-22 15:24:09 +02:00
/**
* Read every pane on the socket. The ONLY part of the pane-exit watcher that
* touches tmux, which is what lets a test subclass drive the guards in
* {@link refreshPaneExits} — the in-flight suppression, the generation
* check, the empty-read retraction rule and the read gate — against rows it
* chooses. Split out for the reason `runRemoteReconnectTick` is: a guard no
* test can reach is a guard that can be deleted without anything failing.
*
* A failed read answers with NO rows, which the caller treats as "tmux did
* not answer" and which therefore retracts nothing.
*/
protected async readPaneRows () : Promise < PaneRow [] > {
// The test-mode gate lives HERE rather than at the top of the tick, so that
// what tests cannot do is spawn a process, not exercise the bookkeeping.
if ( IS_TEST_MODE ) return [];
try {
// execAsync, not execSync: this runs on a 2000 ms timer, and a synchronous
// exec freezes the port while the process stays alive (see the
// event-loop-monitor note in CLAUDE.md). The three `isPaneDead()` callers
// stay synchronous because each is answering one request right then.
const { stdout } = await execAsync ( ` ${ this . tmux () } list-panes -a -F ' ${ PANE_LIST_FORMAT } ' 2>/dev/null || true` , {
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
});
return parsePaneRows ( stdout . trim ());
} catch ( err ) {
console . error ( '[TmuxManager] Failed to read pane exit state:' , err );
return [];
}
}
2026-09-21 10:00:54 +02:00
/**
* Fold one authoritative observation into {@link paneExits}. Split out from
* the tmux call so the merge rules are unit-testable.
*
* `observed` comes from a read of EVERY pane on the socket, so a session
* missing from it has no exit to report and its entry goes. That is what
* keeps the map from growing without bound as tmux sessions come and go
* outside `killSession()`. Only the caller may decide a read is authoritative:
* a failed or empty one never reaches here.
*
* An entry keeps the `at` of the FIRST read that saw that exit, so the stamp
* says when the agent was found gone rather than when the last poll ran. A
* changed status, a changed signal, or a different pane pid all start a new
* observation — the pid is what catches a second command in the same pane
* that happened to exit the same way.
2026-09-24 22:18:07 +02:00
*
* The same rule decides the read count: a repeat of the stored exit adds one,
* and anything that starts a new observation starts the count again at 1.
2026-09-21 10:00:54 +02:00
*/
applyPaneExits ( observed : Map < string , PaneExitObservation >) : void {
for ( const muxName of [... this . paneExits . keys ()]) {
if ( ! observed . has ( muxName )) this . paneExits . delete ( muxName );
}
for ( const [ muxName , next ] of observed ) {
const prev = this . paneExits . get ( muxName );
const sameExit =
prev !== undefined &&
prev . panePid === next . panePid &&
prev . exit . status === next . exit . status &&
prev . exit . signal === next . exit . signal ;
2026-09-24 22:18:07 +02:00
this . paneExits . set ( muxName , sameExit ? { ... prev , reads : prev.reads + 1 } : { ... next , reads : 1 });
2026-09-21 10:00:54 +02:00
}
}
/**
* Forget a session's exit observation, e.g. once its pane has been respawned.
* Also invalidates any read already in flight, so the answer this retracts
* cannot be written back a moment later.
*/
clearPaneExit ( muxName : string ) : void {
this . paneExits . delete ( muxName );
this . paneExitGeneration ++ ;
}
/**
* Poll for exited agents, on the manager's own interval.
*
* Deliberately NOT part of `startStatsCollection()`. That collector is armed
* when the browser opens the Monitor panel and DISARMED when it closes it
* (`panels-ui.js`), and it is skipped at boot entirely when no session was
* recovered — so riding it would leave a session created on a freshly booted
* server reporting nothing at all, and would let one browser turn exit
* detection off for every other. Started unconditionally, like the mouse-mode
* sync and the remote-reconnect watcher below.
*
* The `paneExitsUpdated` event is internal to the server; nothing here adds an
* SSE event, and the field reaches the browser on `session:updated`.
*/
startPaneExitWatcher ( intervalMs : number = DEFAULT_PANE_EXIT_INTERVAL_MS ) : void {
if ( this . paneExitInterval ) {
clearInterval ( this . paneExitInterval );
}
this . paneExitInterval = setInterval (() => {
2026-09-22 15:24:09 +02:00
// No IS_TEST_MODE guard: `readPaneRows()` is the only thing that would
// spawn a process and it refuses under test, so a test can drive this
// whole loop with fake timers instead of being locked out of it.
2026-09-21 10:00:54 +02:00
void this . refreshPaneExits ()
. then (() => this . emit ( 'paneExitsUpdated' ))
. catch (( err ) => console . error ( '[TmuxManager] Pane exit watcher error:' , err ));
}, intervalMs );
}
stopPaneExitWatcher () : void {
if ( this . paneExitInterval ) {
clearInterval ( this . paneExitInterval );
this . paneExitInterval = null ;
}
}
2026-02-12 10:29:57 +01:00
startStatsCollection ( intervalMs : number = DEFAULT_STATS_INTERVAL_MS ) : void {
if ( this . statsInterval ) {
clearInterval ( this . statsInterval );
}
this . statsInterval = setInterval ( async () => {
try {
const sessionsWithStats = await this . getSessionsWithStats ();
this . emit ( 'statsUpdated' , sessionsWithStats );
} catch ( err ) {
console . error ( '[TmuxManager] Stats collection error:' , err );
}
}, intervalMs );
}
stopStatsCollection () : void {
if ( this . statsInterval ) {
clearInterval ( this . statsInterval );
this . statsInterval = null ;
}
}
2026-02-18 10:23:10 +01:00
/**
* Start periodic mouse mode sync for all tracked sessions.
* Polls pane counts every 5s and toggles mouse on/off as needed.
2026-02-19 08:49:36 +01:00
* Polls every 5s. On pane count change, toggles mouse on (>1 pane) or off (1 pane).
* If enableMouseMode/disableMouseMode fails, lastPaneCount is NOT updated so it retries next poll.
2026-02-18 10:23:10 +01:00
*/
startMouseModeSync ( intervalMs : number = 5000 ) : void {
if ( this . mouseSyncInterval ) {
clearInterval ( this . mouseSyncInterval );
}
2026-06-02 01:32:24 +08:00
this . mouseSyncInterval = setInterval ( async () => {
2026-02-18 10:23:10 +01:00
if ( IS_TEST_MODE ) return ;
2026-02-19 08:49:36 +01:00
2026-02-18 10:23:10 +01:00
for ( const session of this . sessions . values ()) {
2026-06-02 01:32:24 +08:00
const panes = await this . listPanes ( session . muxName );
2026-02-18 10:23:10 +01:00
const count = panes . length ;
2026-02-19 08:49:36 +01:00
if ( count === 0 ) continue ;
2026-02-18 10:23:10 +01:00
2026-02-19 08:49:36 +01:00
const prev = this . lastPaneCount . get ( session . muxName );
if ( prev === count ) continue ;
// Pane count changed — toggle mouse mode
if ( count > 1 ) {
2026-06-02 01:32:24 +08:00
if ( await this . enableMouseMode ( session . muxName )) {
2026-02-19 08:49:36 +01:00
this . lastPaneCount . set ( session . muxName , count );
}
// If enableMouseMode fails, DON'T update lastPaneCount — retry next poll
} else {
2026-06-02 01:32:24 +08:00
if ( await this . disableMouseMode ( session . muxName )) {
2026-02-19 08:49:36 +01:00
this . lastPaneCount . set ( session . muxName , count );
2026-02-18 10:23:10 +01:00
}
}
}
}, intervalMs );
}
stopMouseModeSync () : void {
if ( this . mouseSyncInterval ) {
clearInterval ( this . mouseSyncInterval );
this . mouseSyncInterval = null ;
}
this . lastPaneCount . clear ();
}
2026-06-13 14:26:06 -04:00
// ── COD-108 remote-session auto-reconnect watcher ─────────────────────────
/**
* Start the remote-reconnect watcher (COD-108). Each tick, for every tracked
* session with `session.remote` whose local pane is DEAD, not intentionally
* guarded, and within its backoff budget, emit `remoteSessionDropped` so the
* session owner reattaches (re-running the idempotent remote command rejoins
* the durable remote tmux session). After the attempt cap, emit
* `remoteReconnectExhausted` once and go quiet.
*
* No-op tick body under `IS_TEST_MODE` (mirrors `startMouseModeSync`): tests
* drive the logic deterministically via {@link runRemoteReconnectTick}.
*/
startRemoteReconnectWatcher ( intervalMs : number = DEFAULT_REMOTE_RECONNECT_INTERVAL_MS ) : void {
if ( this . remoteReconnectInterval ) {
clearInterval ( this . remoteReconnectInterval );
}
this . remoteReconnectInterval = setInterval (() => {
if ( IS_TEST_MODE ) return ;
try {
this . runRemoteReconnectTick ( Date . now (), isRemoteAutoReconnectEnabled ());
} catch ( err ) {
console . error ( '[TmuxManager] Remote reconnect watcher error:' , err );
}
2026-11-06 07:44:22 +01:00
void this . detachEndedAdoptedAttaches (). catch (( err ) => {
console . error ( '[TmuxManager] Adopted attach watcher error:' , err );
});
2026-06-13 14:26:06 -04:00
}, intervalMs );
}
2026-11-06 07:44:22 +01:00
/**
* Let go of a wrapper whose pane died (#510). That pane holds the attach to someone
* else's tmux session, so it dies when that session ends, the transport drops or the
* view gets detached, and `paneExit` rightly reports none of them. But `remain-on-exit`
* keeps Codeman's own tmux client on the dead pane, so the tab kept its pid and `busy`,
* input went nowhere and both attach routes refused it.
*
* Detaching that client is an ordinary PTY exit with status 0: the pid goes null, the tab
* stays open, and selecting it starts the wrapper again, which joins the target if it is
* back and otherwise prints that it is gone. Only Codeman's own socket is touched.
*/
async detachEndedAdoptedAttaches () : Promise < void > {
const adopted = new Set < string >();
for ( const session of this . sessions . values ()) {
if ( session . adopt ) adopted . add ( session . muxName );
}
if ( adopted . size === 0 ) return ;
for ( const muxName of endedAdoptedAttaches ( await this . readAttachRows (), adopted )) {
await this . detachClients ( muxName );
}
}
/** Rows for {@link endedAdoptedAttaches}. Empty when tmux did not answer. */
protected async readAttachRows () : Promise < string > {
if ( IS_TEST_MODE ) return '' ;
const format = '#{pane_dead} #{session_attached} #{session_name}' ;
const { stdout } = await execAsync ( ` ${ this . tmux () } list-panes -a -F ' ${ format } ' 2>/dev/null || true` , {
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
});
return stdout ;
}
/** Detach every tmux client from one of Codeman's own sessions. */
protected async detachClients ( muxName : string ) : Promise < void > {
if ( IS_TEST_MODE || ! isValidMuxName ( muxName )) return ;
await execAsync ( ` ${ this . tmux () } detach-client -s " ${ muxName } "` , { timeout : EXEC_TIMEOUT_MS });
}
2026-06-13 14:26:06 -04:00
stopRemoteReconnectWatcher () : void {
if ( this . remoteReconnectInterval ) {
clearInterval ( this . remoteReconnectInterval );
this . remoteReconnectInterval = null ;
}
}
/**
* Run ONE watcher tick. Extracted (and given an injected `now`/`enabled`) so
* the reconnect logic is deterministically testable even though the live
* `setInterval` body no-ops under test mode. For each remote session it
* applies the pure {@link decideReconnect} decision and translates the result
* into events + backoff/state transitions. Public for tests + the watcher.
*/
2026-08-29 15:21:33 -05:00
/**
* Refresh the cached remote-tmux liveness for a session whose pane is dead.
* Fire-and-forget (async, not awaited by the sync tick): the probe is a slow
* ssh round-trip, so it must not block the 5s watcher interval. On success it
* writes the cached result; the NEXT tick then makes the revive decision with
* fresh data. A clean exit makes the remote tmux session vanish, so the probe
* resolves false and the watcher stops reviving it (2026-08-29).
*/
private async refreshRemoteAlive ( session : MuxSession ) : Promise < void > {
if ( ! session . remote ) return ;
2026-09-04 13:47:12 +02:00
if ( this . remoteAliveInFlight . has ( session . sessionId )) return ;
this . remoteAliveInFlight . add ( session . sessionId );
2026-08-29 15:21:33 -05:00
const remoteName = session . remote . remoteSessionName || remoteTmuxSessionName ( session . sessionId );
try {
const alive = await remoteTmuxSessionAlive ( session . remote , remoteName );
this . remoteAliveCache . set ( session . sessionId , alive );
} catch {
this . remoteAliveCache . set ( session . sessionId , undefined );
2026-09-04 13:47:12 +02:00
} finally {
this . remoteAliveInFlight . delete ( session . sessionId );
2026-08-29 15:21:33 -05:00
}
}
2026-06-13 14:26:06 -04:00
runRemoteReconnectTick ( now : number , enabled : boolean ) : void {
for ( const session of this . sessions . values ()) {
if ( ! session . remote ) continue ;
const sessionId = session . sessionId ;
const state = this . reconnectState . get ( sessionId );
2026-08-29 15:21:33 -05:00
// Only probe when the pane is actually dead — otherwise the ssh round-trip
// would run every 5s for every healthy remote session. The cache is
// refreshed lazily so a clean exit (remote tmux gone) flips it to false
// on the next tick and stops the auto-revive.
const paneDead = this . isPaneDead ( session . muxName );
2026-09-04 13:47:12 +02:00
if ( ! paneDead ) {
// A live pane makes whatever the probe last said STALE, so forget it:
// after a successful reattach (or a manual restart) the next dead pane
// must be probed afresh. A cached `true` from the transport drop would
// otherwise revive a later CLEAN exit, the exact bug this cache exists
// to prevent, and a cached `false` from a clean exit would leave a
// manually restarted session with auto-reconnect permanently off.
this . remoteAliveCache . delete ( sessionId );
} else if ( this . remoteAliveCache . get ( sessionId ) === undefined ) {
2026-08-29 15:21:33 -05:00
void this . refreshRemoteAlive ( session );
}
2026-06-13 14:26:06 -04:00
const action = decideReconnect ({
session : {
sessionId ,
isRemote : true ,
2026-08-29 15:21:33 -05:00
paneDead ,
remoteAlive : this.remoteAliveCache.get ( sessionId ),
2026-06-13 14:26:06 -04:00
},
state ,
guarded : this.reconnectGuard.has ( sessionId ),
enabled ,
now ,
});
if ( action . kind === 'emit' ) {
const base = state ?? freshReconnectState ();
// Mark in-flight + advance backoff BEFORE emitting so a re-entrant tick
// (or a synchronous listener) can never stack a second reconnect.
this . reconnectState . set ( sessionId , { ... advanceBackoff ( base , now ), inFlight : true });
this . emit ( 'remoteSessionDropped' , { sessionId , attempt : action.attempt });
} else if ( action . kind === 'exhaust' ) {
const base = state ?? freshReconnectState ();
if ( ! base . exhaustedEmitted ) {
this . reconnectState . set ( sessionId , { ... base , exhausted : true , exhaustedEmitted : true });
this . emit ( 'remoteReconnectExhausted' , { sessionId });
}
}
// 'skip' → nothing to do.
}
}
/**
* Tell the watcher a reattach attempt for `sessionId` finished. On success,
* reset the backoff so the session is healthy again; on failure, just clear
* the in-flight flag so the next due tick can retry under the existing
* backoff schedule. Called by the session owner after `respawnPane`.
*/
noteRemoteReconnect ( sessionId : string , success : boolean ) : void {
if ( success ) {
this . reconnectState . set ( sessionId , resetReconnectState ());
return ;
}
const state = this . reconnectState . get ( sessionId );
if ( state ) this . reconnectState . set ( sessionId , { ... state , inFlight : false });
}
/**
* Exclude a session from auto-reconnect (intentional teardown). Adds it to the
* guard set and drops any backoff state so a closed/killed tab — especially a
* non-owned remote DETACH — is never auto-revived. Idempotent.
*/
guardRemoteReconnect ( sessionId : string ) : void {
this . reconnectGuard . add ( sessionId );
this . reconnectState . delete ( sessionId );
2026-08-29 15:21:33 -05:00
this . remoteAliveCache . delete ( sessionId );
2026-09-04 13:47:12 +02:00
this . remoteAliveInFlight . delete ( sessionId );
2026-06-13 14:26:06 -04:00
}
/** Clear all per-session reconnect + guard state (e.g. when a session is removed). */
clearRemoteReconnectState ( sessionId : string ) : void {
this . reconnectState . delete ( sessionId );
this . reconnectGuard . delete ( sessionId );
2026-08-29 15:21:33 -05:00
this . remoteAliveCache . delete ( sessionId );
2026-09-04 13:47:12 +02:00
this . remoteAliveInFlight . delete ( sessionId );
2026-06-13 14:26:06 -04:00
}
2026-02-12 10:29:57 +01:00
destroy () : void {
this . stopStatsCollection ();
2026-09-21 10:00:54 +02:00
this . stopPaneExitWatcher ();
this . paneExits . clear ();
2026-02-18 10:23:10 +01:00
this . stopMouseModeSync ();
2026-06-13 14:26:06 -04:00
this . stopRemoteReconnectWatcher ();
this . reconnectState . clear ();
this . reconnectGuard . clear ();
2026-08-29 15:21:33 -05:00
this . remoteAliveCache . clear ();
2026-09-04 13:47:12 +02:00
this . remoteAliveInFlight . clear ();
2026-02-12 10:29:57 +01:00
}
registerSession ( session : MuxSession ) : void {
this . sessions . set ( session . sessionId , session );
this . saveSessions ();
}
setAttached ( sessionId : string , attached : boolean ) : void {
const session = this . sessions . get ( sessionId );
if ( session ) {
session . attached = attached ;
this . saveSessions ();
}
}
updateRespawnConfig ( sessionId : string , config : PersistedRespawnConfig | undefined ) : void {
const session = this . sessions . get ( sessionId );
if ( session ) {
session . respawnConfig = config ;
this . saveSessions ();
}
}
clearRespawnConfig ( sessionId : string ) : void {
const session = this . sessions . get ( sessionId );
if ( session && session . respawnConfig ) {
delete session . respawnConfig ;
this . saveSessions ();
}
}
updateRalphEnabled ( sessionId : string , enabled : boolean ) : void {
const session = this . sessions . get ( sessionId );
if ( session ) {
session . ralphEnabled = enabled ;
this . saveSessions ();
}
}
2026-06-29 17:49:39 -04:00
/**
2026-08-21 08:19:14 -04:00
* Apply a tmux history limit. tmux 3.7+ safely targets tracked live sessions;
* older releases can only change the global default for future panes. Invalid
* limits fall back to the default.
2026-06-29 17:49:39 -04:00
*/
async setHistoryLimit ( limit : number ) : Promise < void > {
const safeLimit = Number . isSafeInteger ( limit ) && limit > 0 ? Math . trunc ( limit ) : DEFAULT_TMUX_HISTORY_LIMIT ;
if ( IS_TEST_MODE ) {
return ;
}
2026-08-21 08:19:14 -04:00
if ( this . supportsLiveHistoryResize ()) {
const updates = Array . from ( this . sessions . values ()). map (( session ) =>
execAsync ( ` ${ this . tmux () } set-option -t ${ shellescape ( session . muxName ) } history-limit ${ safeLimit } ` , {
timeout : EXEC_TIMEOUT_MS ,
})
);
await Promise . allSettled ( updates );
return ;
}
await execAsync ( ` ${ this . tmux () } set-option -g history-limit ${ safeLimit } ` , {
timeout : EXEC_TIMEOUT_MS ,
}). catch (() => {
// No tmux server yet is fine: legacy createSession sets the same default
// immediately before it creates the first pane.
});
2026-06-29 17:49:39 -04:00
}
2026-02-12 10:29:57 +01:00
/**
2026-09-25 16:58:00 +02:00
* Send input directly to a tmux session.
2026-02-12 10:29:57 +01:00
*
2026-09-25 16:58:00 +02:00
* - Text followed by `\r` goes in as ONE bracketed paste (buildPasteTextCommands),
2026-10-14 21:23:58 +02:00
* so Codex cannot fold the Enter into a paste burst; a single character is typed
* with `send-keys -l` instead, so a dialog answer selects (submitsAsKeys)
2026-10-21 04:53:57 +02:00
* - Single-line text without `\r` is typed literally with `send-keys -l` (no key
* interpretation)
2026-10-21 03:51:31 +02:00
* - A `\n` inside the text is kept (`\r\n` loses only its `\r`), and multi-line text
* always goes in as ONE bracketed paste, with or without a following Enter: inside
2026-10-21 04:53:57 +02:00
* the paste markers a CLI that enabled bracketed paste receives the line breaks as
* pasted text, where typed as keys each one could submit a line on its own
2026-02-20 04:11:49 +01:00
* - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
* - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
2026-02-12 10:29:57 +01:00
*/
2026-10-23 14:15:14 -04:00
async sendInput ( sessionId : string , input : string , options ?: { beforeDispatch ?: () => boolean }) : Promise < boolean > {
if ( options ? . beforeDispatch && input !== '\r' ) return false ;
2026-02-12 10:29:57 +01:00
const session = this . sessions . get ( sessionId );
if ( ! session ) {
2026-02-27 13:16:23 +01:00
console . error (
`[TmuxManager] sendInput failed: no session found for ${ sessionId } . Known: ${ Array . from ( this . sessions . keys ()). join ( ', ' ) } `
);
2026-02-12 10:29:57 +01:00
return false ;
}
2026-10-21 03:51:31 +02:00
// TEST MODE: No-op — don't send input to real tmux sessions. The gate lives in
// inputRunner() so a test subclass can record what WOULD reach tmux.
const run = this . inputRunner ();
if ( ! run ) {
2026-10-23 14:15:14 -04:00
return options ? . beforeDispatch ? .() ?? true ;
2026-02-12 18:26:16 +01:00
}
2026-02-27 13:16:23 +01:00
console . log (
`[TmuxManager] sendInput to ${ session . muxName } , input length: ${ input . length } , hasCarriageReturn: ${ input . includes ( '\r' ) } `
);
2026-02-12 10:29:57 +01:00
if ( ! isValidMuxName ( session . muxName )) {
console . error ( '[TmuxManager] Invalid session name in sendInput:' , session . muxName );
return false ;
}
try {
const hasCarriageReturn = input . includes ( '\r' );
2026-10-21 23:31:06 +02:00
// Leading and trailing blank lines are dropped, so neither `"\nfix it\r"` nor
// `" \nfix it\r"` pastes an empty first line (nor `"fix it\n\r"` an empty last
// one). The leading match ends at a line break, so the first real line keeps its
// indentation.
const textPart = input
. replace ( /\r/g , '' )
. replace ( /^\s*\n/ , '' )
. trimEnd ();
2026-02-12 10:29:57 +01:00
if ( textPart && hasCarriageReturn ) {
2026-02-20 04:11:49 +01:00
// Send text first, then Enter as a SEPARATE tmux command after a short delay.
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
2026-09-25 16:58:00 +02:00
// character in the input buffer rather than as form submission. The text goes
// in as a bracketed paste so Codex cannot fold the Enter into it either
2026-10-14 21:23:58 +02:00
// (see buildPasteTextCommands). A single character is typed, because a
// selection dialog ignores a pasted `3` and Enter then confirms the
// highlighted option (see submitsAsKeys).
if ( submitsAsKeys ( textPart )) {
2026-10-21 03:51:31 +02:00
await run (
2026-10-24 13:54:44 +02:00
` ${ this . tmux () } send-keys -t " ${ session . muxName } " -l -- ${ shellescape ( escapeTmuxTrailingSemicolon ( textPart )) } `
2026-10-14 21:23:58 +02:00
);
} else {
2026-10-21 03:51:31 +02:00
await pasteText ( run , buildPasteTextCommands ( this . tmux (), session . muxName , textPart ));
2026-10-14 21:23:58 +02:00
}
2026-02-27 13:16:23 +01:00
await new Promise (( resolve ) => setTimeout ( resolve , 50 ));
2026-10-21 03:51:31 +02:00
await run ( ` ${ this . tmux () } send-keys -t " ${ session . muxName } " Enter` );
} else if ( textPart . includes ( '\n' )) {
// Multi-line text, no Enter: pasted, because typed as keys every `\n` would
2026-10-21 08:03:54 +02:00
// reach the pane as a key the CLI may read as submit. A pane that has not
// enabled bracketed paste (POSIX sh, macOS /bin/bash 3.2) still gets every line
// break as a CR, so there every line but the last runs; before line breaks were
// kept, the joined text was typed and nothing ran.
2026-10-21 03:51:31 +02:00
await pasteText ( run , buildPasteTextCommands ( this . tmux (), session . muxName , textPart ));
2026-02-12 10:29:57 +01:00
} else if ( textPart ) {
// Text only, no Enter
2026-10-21 03:51:31 +02:00
await run (
2026-10-24 13:54:44 +02:00
` ${ this . tmux () } send-keys -t " ${ session . muxName } " -l -- ${ shellescape ( escapeTmuxTrailingSemicolon ( textPart )) } `
2026-10-14 21:23:58 +02:00
);
2026-02-12 10:29:57 +01:00
} else if ( hasCarriageReturn ) {
2026-10-23 14:15:14 -04:00
// Enter only: no await between the guard and dispatch.
if ( options ? . beforeDispatch && ! options . beforeDispatch ()) return false ;
2026-10-21 03:51:31 +02:00
await run ( ` ${ this . tmux () } send-keys -t " ${ session . muxName } " Enter` );
2026-02-12 10:29:57 +01:00
}
return true ;
} catch ( err ) {
console . error ( '[TmuxManager] Failed to send input:' , err );
return false ;
}
}
2026-10-21 03:51:31 +02:00
/**
* How {@link sendInput} reaches tmux, or null where it must not (vitest). Split
* out like {@link readPaneRows}: with the test-mode gate at the top of sendInput,
* no test could see which commands a submit actually sends.
*/
protected inputRunner () : (( cmd : string ) => Promise < unknown >) | null {
if ( IS_TEST_MODE ) return null ;
return ( cmd ) => execAsync ( cmd , { timeout : EXEC_TIMEOUT_MS });
}
2026-02-12 13:44:07 +01:00
// ========== Pane Methods (for Agent Team teammate panes) ==========
/**
* Enable mouse mode for an existing tmux session.
* Allows clicking to select panes in agent team split-pane layouts.
2026-02-18 10:23:10 +01:00
* When mouse mode is on, tmux intercepts mouse events (slow selection, no browser copy).
2026-02-12 13:44:07 +01:00
*/
2026-06-02 01:32:24 +08:00
async enableMouseMode ( muxName : string ) : Promise < boolean > {
2026-02-12 18:26:16 +01:00
if ( IS_TEST_MODE ) return true ;
2026-02-12 13:44:07 +01:00
if ( ! isValidMuxName ( muxName )) {
console . error ( '[TmuxManager] Invalid session name in enableMouseMode:' , muxName );
return false ;
}
try {
2026-06-02 01:32:24 +08:00
await execAsync ( ` ${ this . tmux () } set-option -t " ${ muxName } " mouse on` , {
2026-02-27 13:16:23 +01:00
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
});
2026-02-19 08:49:36 +01:00
console . log ( `[TmuxManager] Mouse mode ON for ${ muxName } ` );
2026-02-12 13:44:07 +01:00
return true ;
2026-02-19 08:49:36 +01:00
} catch ( err ) {
console . error ( `[TmuxManager] Failed to enable mouse mode for ${ muxName } :` , err );
2026-02-12 13:44:07 +01:00
return false ;
}
}
2026-02-18 10:23:10 +01:00
/**
* Disable mouse mode for an existing tmux session.
* Restores native xterm.js text selection and browser clipboard copy.
*/
2026-06-02 01:32:24 +08:00
async disableMouseMode ( muxName : string ) : Promise < boolean > {
2026-02-18 10:23:10 +01:00
if ( IS_TEST_MODE ) return true ;
if ( ! isValidMuxName ( muxName )) {
console . error ( '[TmuxManager] Invalid session name in disableMouseMode:' , muxName );
return false ;
}
try {
2026-06-02 01:32:24 +08:00
await execAsync ( ` ${ this . tmux () } set-option -t " ${ muxName } " mouse off` , {
2026-02-27 13:16:23 +01:00
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
});
2026-02-19 08:49:36 +01:00
console . log ( `[TmuxManager] Mouse mode OFF for ${ muxName } ` );
2026-02-18 10:23:10 +01:00
return true ;
2026-02-19 08:49:36 +01:00
} catch ( err ) {
console . error ( `[TmuxManager] Failed to disable mouse mode for ${ muxName } :` , err );
2026-02-18 10:23:10 +01:00
return false ;
}
}
/**
* Sync mouse mode based on pane count: enable if split (>1 pane), disable if single.
* Called by TeamWatcher when teammates spawn/despawn panes.
* Uses `tmux list-panes` for bulletproof detection — counts actual panes, not config.
*/
2026-06-02 01:32:24 +08:00
async syncMouseMode ( muxName : string ) : Promise < boolean > {
2026-02-18 10:23:10 +01:00
if ( IS_TEST_MODE ) return true ;
2026-06-02 01:32:24 +08:00
const panes = await this . listPanes ( muxName );
2026-02-18 10:23:10 +01:00
if ( panes . length > 1 ) {
return this . enableMouseMode ( muxName );
} else {
return this . disableMouseMode ( muxName );
}
}
2026-02-12 13:44:07 +01:00
/**
* List all panes in a tmux session.
* Returns structured info for each pane.
*/
2026-06-02 01:32:24 +08:00
async listPanes ( muxName : string ) : Promise < PaneInfo [] > {
2026-02-12 18:26:16 +01:00
if ( IS_TEST_MODE ) return [];
2026-02-12 13:44:07 +01:00
if ( ! isValidMuxName ( muxName )) {
console . error ( '[TmuxManager] Invalid session name in listPanes:' , muxName );
return [];
}
try {
2026-06-02 01:32:24 +08:00
const output = (
await execAsync (
` ${ this . tmux () } list-panes -t " ${ muxName } " -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'` ,
{ encoding : 'utf-8' , timeout : EXEC_TIMEOUT_MS }
)
). stdout . trim ();
2026-02-12 13:44:07 +01:00
2026-02-27 13:16:23 +01:00
return output
. split ( '\n' )
. map (( line ) => {
const [ paneId , indexStr , pidStr , widthStr , heightStr ] = line . split ( ':' );
return {
paneId ,
paneIndex : parseInt ( indexStr , 10 ),
panePid : parseInt ( pidStr , 10 ),
width : parseInt ( widthStr , 10 ),
height : parseInt ( heightStr , 10 ),
};
})
. filter (( p ) => ! Number . isNaN ( p . paneIndex ));
2026-02-12 13:44:07 +01:00
} catch {
return [];
}
}
/**
* Send input to a specific pane within a tmux session.
2026-10-21 05:06:43 +02:00
* Types the text literally with `send-keys -l`, line breaks stripped, then Enter
* when the input carried a `\r`. Unlike sendInput(), it neither pastes nor keeps
* the line breaks of multi-line text.
2026-02-12 13:44:07 +01:00
*/
sendInputToPane ( muxName : string , paneTarget : string , input : string ) : boolean {
2026-02-12 18:26:16 +01:00
if ( IS_TEST_MODE ) return true ;
2026-02-12 13:44:07 +01:00
if ( ! isValidMuxName ( muxName )) {
console . error ( '[TmuxManager] Invalid session name in sendInputToPane:' , muxName );
return false ;
}
if ( ! SAFE_PANE_TARGET_PATTERN . test ( paneTarget )) {
console . error ( '[TmuxManager] Invalid pane target:' , paneTarget );
return false ;
}
2026-02-26 16:44:34 +01:00
// Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
2026-02-27 13:16:23 +01:00
const target = paneTarget . startsWith ( '%' ) ? ` ${ muxName } . ${ paneTarget } ` : ` ${ muxName } .% ${ paneTarget } ` ;
2026-05-26 05:28:58 +08:00
const tmux = this . tmux ();
2026-02-12 13:44:07 +01:00
try {
const hasCarriageReturn = input . includes ( '\r' );
const textPart = input . replace ( /\r/g , '' ). replace ( /\n/g , '' ). trimEnd ();
if ( textPart && hasCarriageReturn ) {
2026-10-14 21:23:58 +02:00
execSync (
2026-10-22 13:26:04 -04:00
` ${ tmux } send-keys -t ${ shellescape ( target ) } -l -- ${ shellescape ( escapeTmuxTrailingSemicolon ( textPart )) } ` ,
2026-10-14 21:23:58 +02:00
{
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
}
);
2026-05-26 05:28:58 +08:00
execSync ( ` ${ tmux } send-keys -t ${ shellescape ( target ) } Enter` , {
2026-02-27 13:16:23 +01:00
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
});
2026-02-12 13:44:07 +01:00
} else if ( textPart ) {
2026-10-14 21:23:58 +02:00
execSync (
2026-10-22 13:26:04 -04:00
` ${ tmux } send-keys -t ${ shellescape ( target ) } -l -- ${ shellescape ( escapeTmuxTrailingSemicolon ( textPart )) } ` ,
2026-10-14 21:23:58 +02:00
{
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
}
);
2026-02-12 13:44:07 +01:00
} else if ( hasCarriageReturn ) {
2026-05-26 05:28:58 +08:00
execSync ( ` ${ tmux } send-keys -t ${ shellescape ( target ) } Enter` , {
2026-02-27 13:16:23 +01:00
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
});
2026-02-12 13:44:07 +01:00
}
return true ;
} catch ( err ) {
console . error ( '[TmuxManager] Failed to send input to pane:' , err );
return false ;
}
}
/**
2026-06-19 12:00:35 -04:00
* Capture a pane's text and SGR styles.
2026-06-09 10:38:35 -04:00
*
2026-06-19 12:00:35 -04:00
* Two modes:
* - Visible (default): `capture-pane -p -e` grabs only the on-screen frame,
* then `formatPaneSnapshot` repaints each row at its absolute position so
* the browser xterm reproduces the live frame. Used for fast tab switches.
2026-07-12 18:33:18 +02:00
* - Full history (`opts.fullHistory`): `capture-pane -p -e -J -S -<N>` grabs
* the tmux scrollback (COD-47, bounded to the configured history limit),
2026-09-09 15:13:15 +02:00
* returned as linear scrollback text with SGR codes preserved. Rows are not
* repainted at absolute positions — a multi-screen history can't be painted
* into a single visible frame — but the capture DOES end with a cursor move
* putting the caret back where the pane has it, counted up from the last
* replayed row. `-J` re-joins lines hard-wrapped at the pane width so they
* reflow in the browser xterm. Used for full page reloads so the user gets
* back their scroll history. Returns '' for a pane holding nothing visible,
* so the caller keeps whatever history it already had.
2026-06-19 12:00:35 -04:00
* Caveat: lines tmux has already evicted past its history-limit are gone.
2026-02-12 13:44:07 +01:00
*/
2026-08-09 15:31:02 +02:00
/**
* Plain visible-frame text for the working/idle probe (see `session.ts`).
*
2026-10-19 19:58:14 -04:00
* One `capture-pane` and nothing else: no `display-message` cursor query, no
* repaint reconstruction: this feeds a regex, not a terminal. `styles` adds
* `-e` for a reader that must tell faint text from typed text (the keep-warm
* composer check); lines stay `\n`-separated either way. Returns null in tests
* (no tmux) so callers fall back to their stream heuristics rather than
* reading an empty screen as "not working".
2026-08-09 15:31:02 +02:00
*/
2026-10-19 19:58:14 -04:00
capturePaneText ( muxName : string , paneTarget? : string , opts ?: { styles? : boolean }) : string | null {
2026-08-09 15:31:02 +02:00
if ( IS_TEST_MODE ) return null ;
const target = resolveTmuxPaneTarget ( muxName , paneTarget );
if ( ! target ) return null ;
try {
2026-10-19 19:58:14 -04:00
return execSync ( ` ${ this . tmux () } capture-pane -p ${ opts ? . styles ? ' -e' : '' } -t ${ shellescape ( target ) } ` , {
2026-08-09 15:31:02 +02:00
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
});
} catch {
// A dead/renamed pane is an ordinary outcome here, not an error worth logging
// on a timer; the caller treats null as "no evidence either way".
return null ;
}
}
2026-07-12 18:33:18 +02:00
capturePaneBuffer ( muxName : string , paneTarget? : string , opts? : PaneCaptureOptions ) : string | null {
2026-10-26 09:04:52 -04:00
const exec = this . captureRunner ();
if ( ! exec ) return '' ;
2026-06-20 09:43:05 -04:00
const target = resolveTmuxPaneTarget ( muxName , paneTarget );
if ( ! target ) {
console . error ( '[TmuxManager] Invalid pane target in capturePaneBuffer:' , { muxName , paneTarget });
2026-02-12 13:44:07 +01:00
return null ;
}
2026-06-19 12:00:35 -04:00
const fullHistory = opts ? . fullHistory === true ;
2026-02-12 13:44:07 +01:00
try {
2026-07-12 18:33:18 +02:00
// `-S -<N>` starts the capture N lines above the visible frame (tmux
// clamps to the top of history), so tmux never serializes more scrollback
// than the configured history limit retains.
const requestedLines = opts ? . historyLimitLines ;
const historyLines =
typeof requestedLines === 'number' && Number . isFinite ( requestedLines ) && requestedLines > 0
? Math . trunc ( requestedLines )
: DEFAULT_TMUX_HISTORY_LIMIT ;
2026-10-09 23:25:45 -05:00
// With a scrollback override this pane's own history is not read at all:
// `-S 0` starts at the top of the visible frame.
const scrollbackOverride = fullHistory ? opts?.scrollbackOverride : undefined ;
const captureFlags = ! fullHistory
? 'capture-pane -p -e'
: scrollbackOverride !== undefined
? 'capture-pane -p -e -J -S 0'
: `capture-pane -p -e -J -S - ${ historyLines } ` ;
2026-07-12 18:33:18 +02:00
// execSync's default maxBuffer (1MB) kills multi-MB scrollback dumps
// (ENOBUFS) and would silently degrade full-history capture to the byte
// buffer for exactly the long sessions it exists for — size it from the
// consumer's byte cap plus ANSI-overhead slack instead.
const execOpts : { encoding : 'utf-8' ; timeout : number ; maxBuffer? : number } = {
2026-02-27 13:16:23 +01:00
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
2026-07-12 18:33:18 +02:00
};
if ( fullHistory ) {
execOpts . maxBuffer =
( opts ? . maxCaptureBytes ?? DEFAULT_TERMINAL_BUFFER_MAX_BYTES ) + FULL_HISTORY_CAPTURE_SLACK_BYTES ;
}
2026-10-25 09:22:35 -04:00
const cursorQuery = `display-message -p -t ${ shellescape ( target ) } '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'` ;
const captureCommand = ` ${ this . tmux () } ${ captureFlags } -t ${ shellescape ( target ) } ` ;
// The visible frame and its caret come from ONE tmux invocation
// (the capture, `\;`, then the cursor query): tmux runs a command list back to back
// in its command queue, with no pane output parsed between the two, so the
// caret belongs to the frame. Two separate calls raced the CLI's repaint: a
// CLI that redraws its composer a few ms after input arrives (codex) could be
// captured before the redraw and its caret queried after it, and guarded
2026-10-26 09:04:52 -04:00
// stage then refused a frame the keys had already reached. Only output that
// splitFrameAndCursor() cannot split (a last line that is not the query's)
// falls back to the two separate calls, which is what this always did. A call
// that THREW (a hung tmux timing out) goes to the catch below like any other
// capture failure: retrying it would block the event loop for a second timeout
// on every `GET .../terminal` load.
const combined = fullHistory
? null
: splitFrameAndCursor (
exec ( ` ${ captureCommand } \\; ${ cursorQuery } ` , { encoding : 'utf-8' , timeout : EXEC_TIMEOUT_MS })
);
2026-10-09 23:25:45 -05:00
const rawCapture = joinScrollbackAndFrame (
scrollbackOverride ,
2026-10-26 09:04:52 -04:00
combined ? . frame ?? exec ( captureCommand , execOpts ),
2026-10-10 23:32:04 -05:00
scrollbackOverride !== undefined ? opts?.scrollbackOverrideScreen : undefined
2026-10-09 23:25:45 -05:00
);
2026-09-09 15:13:15 +02:00
// Query the cursor BEFORE deciding anything else. On the full-history path
// it settles both how the capture is trimmed and whether a cursor move is
// appended, and those two have to agree: trailing blank rows are only safe
// to keep when a move follows to put the caret back above them.
const geometry = queryPaneCursor (() =>
2026-10-25 09:22:35 -04:00
combined
? combined.cursor
2026-10-26 09:04:52 -04:00
: exec ( ` ${ this . tmux () } ${ cursorQuery } ` , { encoding : 'utf-8' , timeout : EXEC_TIMEOUT_MS })
2026-09-09 15:13:15 +02:00
);
2026-09-18 16:45:37 +02:00
// Report the size the pane was really drawing at. The visible-frame path
// below addresses every row absolutely, so a consumer whose terminal is
// shorter than this piles the overflow rows onto its last line and loses
// the rows it overwrote. The full-history path instead ends in a RELATIVE
// cursor move, which costs it nothing when the two sizes disagree, so the
// geometry is reported there for diagnosis rather than for repair. Only
// the caller can see both sizes, so hand it this one.
2026-09-15 12:42:55 +02:00
if ( opts && geometry ) opts . capturedGeometry = { cols : geometry.cols , rows : geometry.rows };
2026-10-10 05:56:37 +02:00
// Same query, no extra tmux call: how much scrollback a full-history pull
// could return. A pane in the alternate screen (fullscreen claude) holds
2026-10-13 17:59:07 +02:00
// none, and the partial-history notice must not promise it. A caller that
// bounded the capture with `historyLimitLines` also reads it to tell
// whether the bound cut anything.
2026-10-10 05:56:37 +02:00
if ( opts && geometry ? . historyLines !== undefined ) opts . capturedHistoryLines = geometry . historyLines ;
2026-10-10 23:29:19 -05:00
// A pane in the alternate screen keeps no history (a nested tmux client
// holds it there), so its capture is one screen however deep it reads.
if ( opts && geometry ? . alternateOn !== undefined ) opts . capturedAlternateOn = geometry . alternateOn ;
2026-09-09 15:13:15 +02:00
if ( fullHistory ) {
// Without geometry there is no cursor move, so fall back to the old trim.
// Keeping the blank rows here would park the caret at the bottom of the
// pane with nothing to correct it — worse than not trying at all.
2026-10-14 18:07:32 +00:00
if ( ! geometry ) {
const trimmed = rawCapture . replace ( /\n+$/g , '' );
return trimmed ? ` ${ normalizeScrollbackEol ( trimmed ) } \ x1b[0m` : '' ;
}
2026-09-09 15:13:15 +02:00
// Take the line terminator off and nothing else. The trailing blank rows
// that remain are the real bottom of the screen, and the cursor move
// below counts up from it. tmux joins rows with a bare `\n`; normalize to
// `\r\n` so a fresh xterm (convertEol:false) starts each replayed line at
// column 0 instead of staircasing diagonally (COD-138).
const trimmed = rawCapture . replace ( /\n$/ , '' );
// An all-blank pane has to keep reading as "nothing to replay". The caller
// treats an empty string as "capture unavailable" and keeps the byte
// history; blank rows plus a cursor move are not empty, so without this a
// blank pane REPLACES that history with a blank screen — the downgrade
// `_replayWouldShrinkBuffer` exists to refuse, arriving from the server
// side where that guard cannot see it.
if ( ! hasVisibleContent ( trimmed )) return '' ;
2026-10-14 18:07:32 +00:00
// capture-pane -e can leave the last row's SGR active (e.g. a nested
// tmux status bar). Hand live output the same default style as a visible
// snapshot, resetting before the cursor move so its position is kept.
return ` ${ normalizeScrollbackEol ( trimmed ) } \ x1b[0m ${ formatCursorRestore ( geometry ) } ` ;
2026-06-09 10:38:35 -04:00
}
2026-09-09 15:13:15 +02:00
const buffer = rawCapture . replace ( /\n+$/g , '' );
if ( geometry ) return formatPaneSnapshot ( buffer . split ( '\n' ), geometry );
2026-06-20 09:43:05 -04:00
// Cursor query failed or geometry was invalid, so we skip the absolute-
// positioned snapshot repaint and fall back to the raw capture. Normalize
// its bare `\n` line endings to `\r\n` so the replay doesn't staircase
// diagonally in a fresh xterm (COD-138, same reason as the fullHistory path).
2026-10-14 18:07:32 +00:00
return buffer ? ` ${ normalizeScrollbackEol ( buffer ) } \ x1b[0m` : '' ;
2026-02-12 13:44:07 +01:00
} catch ( err ) {
2026-07-12 18:33:18 +02:00
// ENOBUFS carries the truncated multi-MB stdout on the error object —
// log a concise line instead of dumping it into the journal.
if (( err as NodeJS . ErrnoException ) ? . code === 'ENOBUFS' ) {
console . error ( '[TmuxManager] Pane capture exceeded maxBuffer (ENOBUFS); falling back to byte history' );
} else {
console . error ( '[TmuxManager] Failed to capture pane buffer:' , err );
}
2026-02-12 13:44:07 +01:00
return null ;
}
}
2026-10-26 09:04:52 -04:00
/**
* How {@link capturePaneBuffer} runs tmux, or null where it must not (vitest).
* Split out like {@link inputRunner} so a test subclass can count and script the
* calls a capture makes.
*/
protected captureRunner () :
| (( cmd : string , opts : { encoding : 'utf-8' ; timeout : number ; maxBuffer? : number }) => string )
| null {
if ( IS_TEST_MODE ) return null ;
return ( cmd , opts ) => execSync ( cmd , opts );
2026-10-25 09:22:35 -04:00
}
2026-06-09 10:38:35 -04:00
/**
* Capture the active pane for a tmux session.
*
* Pane ids are not stable across respawns or restores, so callers should not
* assume the first pane remains `%0`.
*/
2026-07-12 18:33:18 +02:00
captureActivePaneBuffer ( muxName : string , opts? : PaneCaptureOptions ) : string | null {
2026-06-09 10:38:35 -04:00
if ( IS_TEST_MODE ) return '' ;
if ( ! isValidMuxName ( muxName )) {
console . error ( '[TmuxManager] Invalid session name in captureActivePaneBuffer:' , muxName );
return null ;
}
try {
const output = execSync ( ` ${ this . tmux () } list-panes -t ${ shellescape ( muxName ) } -F '#{pane_id}:#{pane_active}'` , {
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
}). trim ();
const target = resolveActivePaneTarget ( output );
2026-06-19 12:00:35 -04:00
return target ? this . capturePaneBuffer ( muxName , target , opts ) : null ;
2026-06-09 10:38:35 -04:00
} catch ( err ) {
console . error ( '[TmuxManager] Failed to resolve active pane for capture:' , err );
return null ;
}
}
2026-02-12 13:44:07 +01:00
/**
* Start piping pane output to a file using tmux pipe-pane.
* Only pipes output direction (-O) to avoid echoing input.
*/
startPipePane ( muxName : string , paneTarget : string , outputFile : string ) : boolean {
2026-02-12 18:26:16 +01:00
if ( IS_TEST_MODE ) return true ;
2026-02-12 13:44:07 +01:00
if ( ! isValidMuxName ( muxName )) {
console . error ( '[TmuxManager] Invalid session name in startPipePane:' , muxName );
return false ;
}
if ( ! SAFE_PANE_TARGET_PATTERN . test ( paneTarget )) {
console . error ( '[TmuxManager] Invalid pane target:' , paneTarget );
return false ;
}
if ( ! isValidPath ( outputFile )) {
console . error ( '[TmuxManager] Invalid output file path:' , outputFile );
return false ;
}
2026-02-27 13:16:23 +01:00
const target = paneTarget . startsWith ( '%' ) ? ` ${ muxName } . ${ paneTarget } ` : ` ${ muxName } .% ${ paneTarget } ` ;
2026-02-12 13:44:07 +01:00
try {
2026-05-26 05:28:58 +08:00
execSync ( ` ${ this . tmux () } pipe-pane -O -t ${ shellescape ( target ) } ${ shellescape ( 'cat >> ' + outputFile ) } ` , {
2026-02-27 13:16:23 +01:00
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
});
2026-02-12 13:44:07 +01:00
return true ;
} catch ( err ) {
console . error ( '[TmuxManager] Failed to start pipe-pane:' , err );
return false ;
}
}
/**
* Stop piping pane output (calling pipe-pane with no command stops piping).
*/
stopPipePane ( muxName : string , paneTarget : string ) : boolean {
2026-02-12 18:26:16 +01:00
if ( IS_TEST_MODE ) return true ;
2026-02-12 13:44:07 +01:00
if ( ! isValidMuxName ( muxName )) {
console . error ( '[TmuxManager] Invalid session name in stopPipePane:' , muxName );
return false ;
}
if ( ! SAFE_PANE_TARGET_PATTERN . test ( paneTarget )) {
console . error ( '[TmuxManager] Invalid pane target:' , paneTarget );
return false ;
}
2026-02-27 13:16:23 +01:00
const target = paneTarget . startsWith ( '%' ) ? ` ${ muxName } . ${ paneTarget } ` : ` ${ muxName } .% ${ paneTarget } ` ;
2026-02-12 13:44:07 +01:00
try {
2026-05-26 05:28:58 +08:00
execSync ( ` ${ this . tmux () } pipe-pane -t ${ shellescape ( target ) } ` , {
2026-02-27 13:16:23 +01:00
encoding : 'utf-8' ,
timeout : EXEC_TIMEOUT_MS ,
});
2026-02-12 13:44:07 +01:00
return true ;
} catch ( err ) {
console . error ( '[TmuxManager] Failed to stop pipe-pane:' , err );
return false ;
}
}
2026-02-12 10:29:57 +01:00
getAttachCommand () : string {
return 'tmux' ;
}
getAttachArgs ( muxName : string ) : string [] {
2026-05-26 05:28:58 +08:00
return [ '-L' , this . tmuxSocket , 'attach-session' , '-t' , muxName ];
2026-02-12 10:29:57 +01:00
}
2026-06-09 10:38:35 -04:00
setManualWindowSize ( muxName : string ) : boolean {
if ( ! isValidMuxName ( muxName )) {
console . error ( '[TmuxManager] Invalid session name in setManualWindowSize:' , muxName );
return false ;
}
try {
execSync ( ` ${ this . tmux () } set-window-option -t ${ shellescape ( muxName ) } window-size manual` , {
timeout : EXEC_TIMEOUT_MS ,
stdio : 'ignore' ,
});
return true ;
} catch ( err ) {
console . error ( '[TmuxManager] Failed to set manual window size:' , err );
return false ;
}
}
resizeWindow ( muxName : string , cols : number , rows : number ) : boolean {
if ( ! isValidMuxName ( muxName )) {
console . error ( '[TmuxManager] Invalid session name in resizeWindow:' , muxName );
return false ;
}
if ( ! isValidTerminalDimension ( cols ) || ! isValidTerminalDimension ( rows )) {
console . error ( '[TmuxManager] Invalid resize dimensions:' , { cols , rows });
return false ;
}
2026-06-10 02:07:26 +02:00
// Fire-and-forget: this runs on the interactive resize path (WS {t:'z'} and
// HTTP /resize), so use a non-blocking exec — a slow/hung tmux must not stall
// the Fastify event loop while other sessions' input/SSE are served. The sole
// caller (Session.resize) ignores the result, and under `window-size manual`
// the subsequent ptyProcess.resize is subordinate to this authoritative size.
exec (
` ${ this . tmux () } resize-window -t ${ shellescape ( muxName ) } -x ${ cols } -y ${ rows } ` ,
{ timeout : EXEC_TIMEOUT_MS },
( err ) => {
if ( err ) console . error ( '[TmuxManager] Failed to resize tmux window:' , err );
}
);
return true ;
2026-06-09 10:38:35 -04:00
}
2026-02-12 10:29:57 +01:00
isAvailable () : boolean {
return TmuxManager . isTmuxAvailable ();
}
/**
* Check if tmux is available on the system.
*/
static isTmuxAvailable () : boolean {
try {
execSync ( 'which tmux' , { encoding : 'utf-8' , timeout : EXEC_TIMEOUT_MS });
return true ;
} catch {
return false ;
}
}
}
2026-11-03 15:20:22 +01:00
/**
* The `bash -c` argument of a pane launch (`respawn-pane … bash -c <arg>`, run through the
2026-11-06 05:36:20 +01:00
* local sh). A remote, docker or adopted command is already quoted for the shell at the far
* end and must reach bash verbatim, so it goes in single quotes. Double quotes
* (JSON.stringify) let the local sh expand every `$` first: the PATH bootstrap of
* `withRemoteTmuxPath` (`$HOME/.local/bin:…:$PATH`) arrived on the remote host as this
* host's home and PATH, so a remote user with another home found no tmux and every new
* remote pane exited 127. `${SHELL:-/bin/sh}` in the remote launch was the local shell for
* the same reason, and an adopted ssh attach carries the same bootstrap, so a tmux in the
* remote `~/.local/bin` was found by discovery and then missed by the attach. Single quotes
* also keep a real newline, which JSON escaping left as the two characters `\n`.
* An adopted attach goes verbatim at all three locations, the local one included: every
* value in it is already quoted by `foreign-tmux.ts`, and nothing in it is for this shell.
* An agent launched on this host keeps its double-quoted form.
2026-11-06 00:59:46 +01:00
*
* ⚠️ So nothing in a verbatim launch is expanded on this host any more, the docker client's
* own arguments included: a bare `$VAR` in `extraCreateArgs`/`extraExecArgs` reaches docker
* as those literal characters.
2026-11-03 15:20:22 +01:00
*/
2026-11-06 05:36:20 +01:00
export function bashCArg ( launchCmd : string , verbatim : boolean ) : string {
2026-11-03 15:20:22 +01:00
return verbatim ? shellescape ( launchCmd ) : JSON . stringify ( launchCmd );
}
2026-02-12 10:29:57 +01:00
/**
* Shell-escape a string for use as a single argument.
* Wraps in single quotes, escaping any embedded single quotes.
*/
function shellescape ( str : string ) : string {
// Replace single quotes with '\'' (end quote, escaped quote, restart quote)
return "'" + str . replace ( /'/g , "'\\''" ) + "'" ;
}