Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 154 additions & 22 deletions src/tmux-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,100 @@ const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
*/
const PANE_LIST_SEP = '|';

/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneDiscoveryList}. */
const PANE_LIST_FORMAT = ['#{session_name}', '#{pane_pid}', '#{@codeman-mode}', '#{pane_current_path}'].join(
PANE_LIST_SEP
);

const SESSION_MODES = new Set<SessionMode>(['claude', 'shell', 'opencode', 'codex', 'gemini']);

export interface PaneDiscovery {
pid: number;
workingDir: string | undefined;
mode?: SessionMode;
}

/**
* Parse live pane metadata. Current output includes a durable session-mode
* option before the path. Legacy pid-only and path-bearing output remains
* supported so panes created before the option was introduced can be migrated.
*/
export function parsePaneDiscoveryList(output: string): Map<string, PaneDiscovery> {
const result = new Map<string, PaneDiscovery>();
for (const line of output.split('\n')) {
if (!line) continue;
const firstSep = line.indexOf(PANE_LIST_SEP);
if (firstSep === -1) continue;
const secondSep = line.indexOf(PANE_LIST_SEP, firstSep + 1);
const thirdSep = secondSep === -1 ? -1 : line.indexOf(PANE_LIST_SEP, secondSep + 1);
const name = line.slice(0, firstSep);
const pidText = secondSep === -1 ? line.slice(firstSep + 1) : line.slice(firstSep + 1, secondSep);
const pid = parseInt(pidText, 10);
const modeText = thirdSep === -1 ? undefined : line.slice(secondSep + 1, thirdSep);
const mode = modeText && SESSION_MODES.has(modeText as SessionMode) ? (modeText as SessionMode) : undefined;
const workingDir =
secondSep === -1
? undefined
: thirdSep === -1
? line.slice(secondSep + 1) || undefined
: line.slice(thirdSep + 1) || undefined;
if (name && !Number.isNaN(pid)) {
result.set(name, { pid, workingDir, ...(mode ? { mode } : {}) });
}
}
return result;
}

/**
* Infer the provider from a legacy pane's live process command. This is only a
* migration fallback for sessions created before `@codeman-mode`; new panes
* restore from explicit tmux metadata instead.
*/
export function inferSessionModeFromProcessCommand(command: string): SessionMode | undefined {
const tokens = String(command)
.replace(/\0/g, ' ')
.trim()
.split(/\s+/)
.map(
(token) =>
token
.replace(/^["']|["']$/g, '')
.split('/')
.pop()
?.toLowerCase() || ''
);

if (tokens.includes('codex')) return 'codex';
if (tokens.includes('opencode')) return 'opencode';
if (tokens.includes('gemini')) return 'gemini';
if (tokens.includes('claude')) return 'claude';

const executable = tokens[0];
if (['sh', 'bash', 'zsh', 'fish', 'pwsh', 'pwsh.exe', 'powershell.exe'].includes(executable)) {
return 'shell';
}
return undefined;
}

function inferSessionModeFromPaneProcess(pid: number): SessionMode | undefined {
try {
const command = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
const inferred = inferSessionModeFromProcessCommand(command);
if (inferred) return inferred;
} catch {
// procfs is Linux-only; macOS and other hosts use ps below.
}

try {
const command = execSync(`ps -p ${pid} -o command=`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
});
return inferSessionModeFromProcessCommand(command);
} catch {
return undefined;
}
}

/**
* 构建 pane 启动前的 nofile 修复命令。
Expand All @@ -212,15 +304,8 @@ export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT):
*/
export function parsePaneList(output: string): Map<string, number> {
const result = new Map<string, number>();
for (const line of output.split('\n')) {
if (!line) continue;
const sep = line.indexOf(PANE_LIST_SEP);
if (sep === -1) continue;
const name = line.slice(0, sep);
const pid = parseInt(line.slice(sep + 1), 10);
if (name && !Number.isNaN(pid)) {
result.set(name, pid);
}
for (const [name, pane] of parsePaneDiscoveryList(output)) {
result.set(name, pane.pid);
}
return result;
}
Expand Down Expand Up @@ -553,6 +638,13 @@ function isValidPath(path: string): boolean {
return SAFE_PATH_PATTERN.test(path);
}

function resolveDiscoveredWorkingDir(workingDir: string | undefined): string {
if (workingDir?.startsWith('/') && isValidPath(workingDir)) {
return workingDir;
}
return process.cwd();
}

// ===========================================================================
// Single-socket architecture: ALL Codeman sessions live on one dedicated tmux
// socket (`tmux -L codeman`), isolated from the user's default tmux server.
Expand Down Expand Up @@ -1396,6 +1488,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return tmuxCommand(this.tmuxSocket);
}

private persistSessionMode(muxName: string, mode: SessionMode): boolean {
if (!isValidMuxName(muxName)) return false;
try {
execSync(`${this.tmux()} set-option -t "${muxName}" @codeman-mode "${mode}"`, {
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
});
return true;
} catch {
return false;
}
}

// Load saved sessions from disk (NEVER called in test mode)
private loadSessions(): void {
if (IS_TEST_MODE) return;
Expand Down Expand Up @@ -1725,6 +1830,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} catch {
/* Non-critical */
}
this.persistSessionMode(muxName, mode);

// For OpenCode: set sensitive env vars and config via tmux setenv
// (not visible in ps output or tmux history, inherited by panes)
Expand Down Expand Up @@ -1922,6 +2028,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}).catch(() => {
/* Non-critical — keeps existing tmux history-limit */
});
this.persistSessionMode(muxName, mode);
}

// Resolve CLI binary directory based on mode
Expand Down Expand Up @@ -2263,29 +2370,52 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const alive: string[] = [];
const dead: string[] = [];
const discovered: string[] = [];
let metadataChanged = false;

// 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>;
let active: Map<string, PaneDiscovery>;
try {
const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
active = parsePaneList(output);
active = parsePaneDiscoveryList(output);
} catch (err) {
console.error('[TmuxManager] Failed to list tmux panes:', err);
active = new Map();
}

// Check tracked sessions against the live pane list.
for (const [sessionId, session] of this.sessions) {
const pid = active.get(session.muxName);
if (pid !== undefined) {
const pane = active.get(session.muxName);
if (pane !== undefined) {
alive.push(sessionId);
if (pid !== session.pid) session.pid = pid;
if (pane.pid !== session.pid) session.pid = pane.pid;
const recoveredMode =
pane.mode ??
(sessionId.startsWith('restored-') ? inferSessionModeFromPaneProcess(pane.pid) : undefined) ??
session.mode;
if (recoveredMode && recoveredMode !== session.mode) {
session.mode = recoveredMode;
metadataChanged = true;
}
if (recoveredMode && !pane.mode) {
this.persistSessionMode(session.muxName, recoveredMode);
}
const recoveredWorkingDir = resolveDiscoveredWorkingDir(pane.workingDir);
const shouldRecoverWorkingDir = sessionId.startsWith('restored-') || session.workingDir === process.cwd();
if (
!session.remote &&
!session.docker &&
shouldRecoverWorkingDir &&
recoveredWorkingDir !== session.workingDir
) {
session.workingDir = recoveredWorkingDir;
metadataChanged = true;
}
} else {
dead.push(sessionId);
this.sessions.delete(sessionId);
Expand All @@ -2302,7 +2432,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
knownMuxNames.add(session.muxName);
}

for (const [sessionName, pid] of active) {
for (const [sessionName, pane] of active) {
if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue;
// Only admit names that pass the safe-name pattern. A foreign process on the
// shared `tmux -L codeman` socket could create a `codeman-…` session whose name
Expand All @@ -2316,23 +2446,25 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {

const fragment = sessionName.replace(/^(?:codeman|claudeman)-/, '');
const sessionId = `restored-${fragment}`;
const recoveredMode = pane.mode ?? inferSessionModeFromPaneProcess(pane.pid) ?? 'claude';
const session: MuxSession = {
sessionId,
muxName: sessionName,
pid,
pid: pane.pid,
createdAt: Date.now(),
workingDir: process.cwd(),
mode: 'claude',
workingDir: resolveDiscoveredWorkingDir(pane.workingDir),
mode: recoveredMode,
attached: false,
name: `Restored: ${sessionName}`,
};
this.sessions.set(sessionId, session);
if (!pane.mode) this.persistSessionMode(sessionName, recoveredMode);
knownMuxNames.add(sessionName);
discovered.push(sessionId);
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pane.pid})`);
}

if (dead.length > 0 || discovered.length > 0) {
if (dead.length > 0 || discovered.length > 0 || metadataChanged) {
this.saveSessions();
}

Expand Down
Loading