diff --git a/CODEBASE_DOCUMENTATION.md b/CODEBASE_DOCUMENTATION.md index a29c5bff..33990120 100644 --- a/CODEBASE_DOCUMENTATION.md +++ b/CODEBASE_DOCUMENTATION.md @@ -85,6 +85,12 @@ server/utils/processUtils.js - Shared spawn/env hardening helpers └─ Cross-platform behavior: non-Windows platforms pass through unchanged so Linux/macOS launch behavior stays stable server/utils/nodePtyCompat.js - Runtime compatibility shim for the bundled `node-pty` Windows ConPTY loader └─ Windows PTY guard: wraps stale ConPTY calls in memory (`startProcess`, `connect`, `resize`, `clear`, `kill`) via `loadNativeModule` when available or direct `conpty.node` patching when package internals differ, so packaged installs survive read-only app-resource layouts and mixed node-pty variants +server/utils/tmuxSessionBackend.js - tmux-backed session persistence (terminals survive app-server restarts) +├─ Model: the orchestrator's pty is only a tmux CLIENT; the real shell/agent runs in a pane under the tmux server on a dedicated per-instance socket (`agent-workspace-`), so nodemon reloads/updates/crashes detach instead of killing sessions, and `new-session -A` re-adopts them on the next createSession() +├─ Env hygiene: scrubs `CLAUDECODE`/`CLAUDE_CODE_ENTRYPOINT`/`TMUX` at the tmux-server choke point so nested-session guards never trip; socket options make panes behave like plain terminals (status off, prefix None, mouse off, window-size latest) +├─ Lifecycle: explicit close/terminate/workspace-teardown kills the tmux session (never leaks detached panes); server shutdown detaches only; tree-kills + process limits target the PANE pid, not the client +├─ Fallback: Windows/tmux-less installs fail closed to direct node-pty spawning (`ORCHESTRATOR_SESSION_PERSISTENCE=0` or `config.sessions.persistence.enabled=false` to disable); survives server restarts only — reboots still rely on transcript resume +└─ Observability: `GET /api/sessions/persistence` reports managed vs orphaned tmux sessions; adopted sessions backfill their buffer from `capture-pane` so the log endpoint/scrollback preload shows pre-restart history server/utils/pathUtils.js - Shared slash-normalization + data-directory compatibility helpers for repo/worktree labels └─ Legacy migration: renames `~/.orchestrator` when possible, otherwise merges richer legacy state into `~/.agent-workspace` with conflict backups before falling back to the old directory server/tokenCounter.js - Token usage tracking (if applicable) diff --git a/client/app.js b/client/app.js index b80f76fc..621348ff 100644 --- a/client/app.js +++ b/client/app.js @@ -34,6 +34,15 @@ class ClaudeOrchestrator { constructor() { this.sessions = new Map(); this.activeView = []; + // Per-worktree button config lookups log "using defaults" for every + // worktree without a custom .orchestrator-config.json — normal, and noisy + // with many worktrees. Opt in via localStorage 'debug-worktree-config'. + this.debugWorktreeConfig = false; + try { + this.debugWorktreeConfig = window?.localStorage?.getItem('debug-worktree-config') === 'true'; + } catch { + // ignore + } this.visibleTerminals = new Set(); // Track which terminals are visible // Second-layer filter applied after per-worktree visibility toggles: // 'all' | 'claude' | 'server' @@ -4348,15 +4357,15 @@ class ClaudeOrchestrator { } if (!repositoryType) { - console.log(`No repositoryType found for session ${sessionId}, using defaults`); + if (this.debugWorktreeConfig) console.debug(`No repositoryType found for session ${sessionId}, using defaults`); return this.getDefaultButtons(terminalType, sessionId); } // Get worktree-specific cascaded config (pre-fetched) const cascadedConfig = this.worktreeConfigs.get(sessionId); - console.log(`Looking up worktree config for ${sessionId} (type: ${repositoryType}):`, cascadedConfig); + if (this.debugWorktreeConfig) console.debug(`Looking up worktree config for ${sessionId} (type: ${repositoryType}):`, cascadedConfig); if (!cascadedConfig || !cascadedConfig.buttons) { - console.log(`No worktree config or buttons found for ${sessionId}, using defaults`); + if (this.debugWorktreeConfig) console.debug(`No worktree config or buttons found for ${sessionId}, using defaults`); return this.getDefaultButtons(terminalType, sessionId); } diff --git a/client/terminal.js b/client/terminal.js index 02448615..8dc163e4 100644 --- a/client/terminal.js +++ b/client/terminal.js @@ -846,11 +846,30 @@ class TerminalManager { return; } - this.warnFit( + // 0x0 means the element simply isn't measurable right now — an + // off-screen terminal in the scrollable grid, or one whose fonts/ + // layout haven't settled. That's expected with many worktrees and + // resolves on the next show/resize, so keep it at debug level. + // A small-but-nonzero proposal is a real fit problem worth warning. + const unmeasurable = proposedCols === 0 && proposedRows === 0; + const logFit = unmeasurable ? this.debugFit.bind(this) : this.warnFit.bind(this); + logFit( sessionId, 'proposed-still-too-small', `Terminal ${sessionId} proposed fit still too small after 5 retries (${proposedCols}x${proposedRows}; min ${minStableCols}x${minStableRows}); skipping fit` ); + // Off-screen terminals still deserve a delayed retry so they fit + // correctly once scrolled into view without a resize event. + if (unmeasurable) { + if (!this.delayedFitTimers) this.delayedFitTimers = new Map(); + if (!this.delayedFitTimers.has(sessionId)) { + const t = setTimeout(() => { + this.delayedFitTimers.delete(sessionId); + this.fitTerminal(sessionId, 0); + }, 1200); + this.delayedFitTimers.set(sessionId, t); + } + } this.fitTimers.delete(sessionId); return; } diff --git a/config.json b/config.json index 4f2693b7..c33ed818 100644 --- a/config.json +++ b/config.json @@ -12,7 +12,11 @@ "claudeTimeoutMs": 0, "serverTimeoutMs": 43200000, "maxBufferSize": 1000000, - "maxProcessesPerSession": 50 + "maxProcessesPerSession": 50, + "persistence": { + "enabled": true, + "socketName": "" + } }, "logging": { "level": "info" diff --git a/server/commanderService.js b/server/commanderService.js index bdd86108..5bc40fa0 100644 --- a/server/commanderService.js +++ b/server/commanderService.js @@ -537,7 +537,13 @@ class CommanderService { return false; } - // Use pty.write directly since sendInput may not exist + // Route through the single input choke point so this shares the same + // handling as browser keystrokes: device-report stripping under tmux, + // PowerShell CRLF normalization, and activity/status bookkeeping. Falls + // back to a direct write only if writeToSession is somehow unavailable. + if (typeof this.sessionManager.writeToSession === 'function') { + return this.sessionManager.writeToSession(sessionId, input); + } if (session.pty) { session.pty.write(input); return true; diff --git a/server/index.js b/server/index.js index 347b7a62..aeb7c381 100644 --- a/server/index.js +++ b/server/index.js @@ -2776,6 +2776,17 @@ app.get('/api/audit/export', requirePolicyAction('audit_export'), proOnly, async } }); +// Session-persistence observability: whether terminals run inside tmux (and +// survive server restarts), which live tmux sessions are managed vs orphaned. +app.get('/api/sessions/persistence', (req, res) => { + try { + return res.json({ ok: true, ...sessionManager.getPersistenceStatus() }); + } catch (error) { + logger.error('Failed to resolve session persistence status', { error: error.message, stack: error.stack }); + return res.status(500).json({ ok: false, error: 'Failed to resolve session persistence status' }); + } +}); + app.get('/api/sessions/:sessionId/log', (req, res) => { try { const sessionId = String(req.params.sessionId || '').trim(); diff --git a/server/sessionManager.js b/server/sessionManager.js index cafd2c12..484b3fd1 100644 --- a/server/sessionManager.js +++ b/server/sessionManager.js @@ -17,6 +17,7 @@ const { } = require('./utils/shellCommand'); const { augmentProcessEnv, buildPowerShellArgs } = require('./utils/processUtils'); const { loadNodePty } = require('./utils/nodePtyCompat'); +const { TmuxSessionBackend, stripDeviceReports } = require('./utils/tmuxSessionBackend'); const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', @@ -116,6 +117,28 @@ class SessionManager extends EventEmitter { this.conversationSnapshotTtlMs = parseInt(process.env.CONVERSATION_SNAPSHOT_TTL_MS || '5000'); this.conversationSnapshotCache = { timestamp: 0, files: null }; + // Session persistence: terminals live inside tmux sessions on a dedicated + // per-instance socket, so they survive app-server restarts and are + // re-adopted on the next createSession() for the same id (issue #1025). + // ORCHESTRATOR_SESSION_PERSISTENCE=0 disables; unavailable tmux (Windows, + // minimal installs) falls back to direct node-pty spawning automatically. + const persistenceConfig = this.config.sessions?.persistence || {}; + const persistenceEnvOverride = String(process.env.ORCHESTRATOR_SESSION_PERSISTENCE || '').trim(); + const persistenceWanted = persistenceEnvOverride + ? persistenceEnvOverride !== '0' + : persistenceConfig.enabled !== false; + const instancePort = process.env.ORCHESTRATOR_PORT || this.config.server?.port || 'default'; + this.sessionPersistence = new TmuxSessionBackend({ + socketName: persistenceConfig.socketName || `agent-workspace-${instancePort}`, + logger + }); + this.sessionPersistenceEnabled = persistenceWanted && this.sessionPersistence.isAvailable(); + logger.info('Session persistence', { + enabled: this.sessionPersistenceEnabled, + wanted: persistenceWanted, + socket: this.sessionPersistence.socketName + }); + // Worktrees will be built when workspace is set this.worktrees = []; } @@ -824,17 +847,47 @@ class SessionManager extends EventEmitter { const effectiveEnv = augmentProcessEnv(env); - const ptyProcess = pty.spawn( - config.command, - config.args, - this.buildPtyOptions(config, effectiveEnv) - ); + // Persistence path: spawn a tmux CLIENT instead of the shell directly. + // The pane (real shell/agent) lives under the tmux server and survives + // app-server restarts; `new-session -A` re-attaches to a surviving + // session, which is how sessions are adopted after a restart. + let spawnCommand = config.command; + let spawnArgs = config.args; + let persistence = null; + if (this.sessionPersistenceEnabled) { + // A leaked TMUX var would make the client refuse to start ("nested"). + delete effectiveEnv.TMUX; + delete effectiveEnv.TMUX_PANE; + this.sessionPersistence.ensureConfigured(); + const adopted = this.sessionPersistence.hasSession(sessionId); + const spec = this.sessionPersistence.buildSpawnCommand({ + sessionId, + command: config.command, + args: config.args, + cwd: config.cwd + }); + spawnCommand = spec.command; + spawnArgs = spec.args; + persistence = { backend: 'tmux', name: spec.name, adopted }; + if (adopted) { + logger.info('Adopting surviving persistent session', { sessionId }); + } + } + + const ptyOptions = this.buildPtyOptions(config, effectiveEnv); + if (persistence) { + // The outer client terminal must advertise 256-color support or tmux + // degrades every pane's rendering. + ptyOptions.name = 'xterm-256color'; + } + const ptyProcess = pty.spawn(spawnCommand, spawnArgs, ptyOptions); const initialCwd = config.cwd || process.cwd(); - + const session = { id: sessionId, pty: ptyProcess, + persistence, type: config.type, worktreeId: config.worktreeId, repositoryName: config.repositoryName, // For mixed-repo workspaces @@ -857,7 +910,21 @@ class SessionManager extends EventEmitter { autoStarted: false, // Track if auto-start has been triggered claudeLaunchState: null }; - + + // Adopted sessions re-attach mid-flight, so the fresh client only sees a + // screen redraw. Backfill the buffer from tmux scrollback so the log + // endpoint / client history preload can show what happened before the + // restart. Mark it delivered: history is served via the log endpoint, + // not re-streamed as live output. + if (persistence?.adopted) { + const history = this.sessionPersistence.capturePane(sessionId, 2000); + if (history) { + session.buffer = history.endsWith('\n') ? history : `${history}\n`; + session.deliveredBufferLength = session.buffer.length; + } + } + + // Set up inactivity timer (respect per-type timeout; 0 disables) const effectiveTimeout = this.getSessionTimeout(session); if (effectiveTimeout > 0) { @@ -1778,8 +1845,13 @@ class SessionManager extends EventEmitter { try { let payload = data; - // PowerShell terminals need CRLF to reliably execute commands written programmatically. if (typeof payload === 'string') { + // Under tmux, drop echoed device-attribute reports so they can't land + // on the active pane as "1;2c0;276;0c" junk (see stripDeviceReports). + if (this.sessionPersistenceEnabled && session.persistence) { + payload = stripDeviceReports(payload); + } + // PowerShell terminals need CRLF to reliably execute commands written programmatically. const shellKind = this.getShellKindForSession(sessionId); if (shellKind === 'powershell') { payload = payload.replace(/\r?\n/g, '\r\n'); @@ -2472,10 +2544,68 @@ class SessionManager extends EventEmitter { return true; } + // Pid owning the session's REAL process tree. For persistent sessions the + // pane process is a child of the tmux server, not of the client pty the + // orchestrator holds — tree-kills and child counting must target the pane. + getSessionProcessPid(session) { + if (session?.persistence?.backend === 'tmux' && this.sessionPersistenceEnabled) { + const panePid = this.sessionPersistence.panePid(session.id); + if (panePid) return panePid; + } + const pid = Number(session?.pty?.pid); + return Number.isFinite(pid) && pid > 0 ? pid : null; + } + + // Explicit destruction of a persistent session. Killing only the client pty + // would DETACH the pane, leaking it as an orphaned tmux session — surviving + // is only for server restarts, never for user-initiated close/terminate. + destroyPersistentSession(session) { + if (session?.persistence?.backend !== 'tmux') return; + try { + this.sessionPersistence.killSession(session.id); + } catch (error) { + logger.warn('Failed to kill persistent tmux session', { sessionId: session.id, error: error.message }); + } + } + + // Observability for the persistence layer: what tmux knows vs what the + // orchestrator manages. "Orphaned" sessions survived a restart but were not + // re-adopted (e.g. their worktree was removed from the workspace meanwhile); + // they can be inspected via `tmux -L attach -t ` or killed. + getPersistenceStatus() { + const status = { + enabled: !!this.sessionPersistenceEnabled, + backend: 'tmux', + socketName: this.sessionPersistence?.socketName || null, + managed: [], + orphaned: [] + }; + if (!status.enabled) return status; + + const known = new Map(); + const collect = (map) => { + for (const session of map.values()) { + if (session?.persistence?.name) known.set(session.persistence.name, session.id); + } + }; + collect(this.sessions); + for (const map of this.workspaceSessionMaps.values()) collect(map); + + const live = this.sessionPersistence.listSessionNames(); + for (const name of live) { + if (known.has(name)) { + status.managed.push({ name, sessionId: known.get(name) }); + } else { + status.orphaned.push({ name }); + } + } + return status; + } + checkProcessLimit(session) { if (!session.pty || !session.pty.pid) return; - const pid = Number(session.pty.pid); + const pid = this.getSessionProcessPid(session); if (!Number.isFinite(pid) || pid <= 0) return; const { spawn } = require('child_process'); @@ -2600,7 +2730,11 @@ class SessionManager extends EventEmitter { session.pendingStatusTimer = null; } - const ptyPid = Number(session?.pty?.pid); + // Resolve the real process-tree pid BEFORE tearing anything down: for + // persistent sessions it comes from a tmux query that fails once the + // session is killed. + const processPid = this.getSessionProcessPid(session); + this.destroyPersistentSession(session); // Kill the PTY process if it exists if (session.pty) { @@ -2616,7 +2750,7 @@ class SessionManager extends EventEmitter { // Best-effort process tree cleanup to avoid orphaned agent subprocesses // after terminals are closed/removed. - this.bestEffortKillProcessTree(ptyPid, { sessionId: sid }); + this.bestEffortKillProcessTree(processPid, { sessionId: sid }); // Remove from sessions map sessionMap.delete(sid); @@ -3209,6 +3343,9 @@ class SessionManager extends EventEmitter { // Kill all PTY processes for (const [sessionId, session] of this.sessions) { try { + // Destructive path (workspace teardown): persistent panes must die + // with their sessions, not linger detached on the tmux socket. + this.destroyPersistentSession(session); if (session.pty) { session.pty.kill(); logger.debug(`Killed session: ${sessionId}`); diff --git a/server/utils/tmuxSessionBackend.js b/server/utils/tmuxSessionBackend.js new file mode 100644 index 00000000..88884373 --- /dev/null +++ b/server/utils/tmuxSessionBackend.js @@ -0,0 +1,217 @@ +'use strict'; + +const { execFileSync } = require('child_process'); + +// tmux-backed session persistence (issue #1025). +// +// Terminals run inside per-session tmux sessions on a dedicated socket, so the +// PTY the orchestrator owns is only a tmux CLIENT. When the app server restarts +// (nodemon reload, version update, crash) the panes keep running under the tmux +// server, and the next createSession() for the same id re-attaches via +// `new-session -A` — live agents survive the restart. +// +// Boundaries, on purpose: +// - Survives app-server restarts only; a reboot/`wsl --shutdown` still ends the +// tmux server (transcript resume is the recovery path for that). +// - Windows and tmux-less installs fail closed via isAvailable(); the session +// manager falls back to direct node-pty spawning (previous behavior). +// - Each orchestrator instance uses its own socket (name includes the server +// port) so dev/prod instances can never collide on session names. + +const SESSION_NAME_UNSAFE = /[^A-Za-z0-9_-]/g; + +// Device-attribute REPORT sequences: DA1 `ESC [ ? … c` and DA2 `ESC [ > … c`. +// These are terminal auto-RESPONSES, never something a user types. They only +// appear on the input stream as an echo of the browser terminal answering a +// probe. Under tmux the outer-terminal (xterm.js) probe response can arrive on +// the client's stdin after tmux's read window has closed — tmux then routes the +// stray bytes to the active pane, where they surface as "1;2c0;276;0c" junk at +// the shell prompt. Stripping them from inbound input is safe: tmux answers +// inner-app DA queries itself, and capability detection is pinned via +// terminal-features below, so nothing legitimate depends on this echo. +// Cursor-position (…R) and DSR (…n) reports are deliberately NOT stripped — +// some apps legitimately read those back as input. +const DEVICE_REPORT_RE = /\x1b\[[?>][0-9;]*c/g; + +const stripDeviceReports = (input) => { + if (typeof input !== 'string' || input.indexOf('\x1b[') === -1) return input; + return input.replace(DEVICE_REPORT_RE, ''); +}; + +// Quote a value for the shell-command string tmux hands to `$SHELL -c`. +const shellQuote = (value) => { + const s = String(value ?? ''); + if (s === '') return "''"; + if (/^[A-Za-z0-9_\/.:=,+@%-]+$/.test(s)) return s; + return `'${s.replace(/'/g, `'\\''`)}'`; +}; + +class TmuxSessionBackend { + constructor({ socketName, logger = console, execImpl = execFileSync, platform = process.platform, baseEnv = process.env } = {}) { + if (!socketName) throw new Error('TmuxSessionBackend requires a socketName'); + this.socketName = socketName; + this.logger = logger; + this.exec = execImpl; + this.platform = platform; + this.baseEnv = baseEnv; + this._available = null; + this._configured = false; + } + + // Environment for every tmux invocation. The tmux SERVER inherits the env of + // the command that first starts it, and every pane inherits from the server — + // this is the one choke point where nested-session markers must be scrubbed. + // Without it, an orchestrator launched from inside a Claude session would + // leak CLAUDECODE into every terminal and trip the CLI's nesting guard; a + // leaked TMUX var would make the client refuse to start at all. + buildTmuxEnv() { + const env = { ...this.baseEnv }; + delete env.CLAUDECODE; + delete env.CLAUDE_CODE_ENTRYPOINT; + delete env.TMUX; + delete env.TMUX_PANE; + return env; + } + + run(args, opts = {}) { + return this.exec('tmux', ['-L', this.socketName, ...args], { + env: this.buildTmuxEnv(), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + ...opts + }); + } + + isAvailable() { + if (this.platform === 'win32') return false; + if (this._available !== null) return this._available; + try { + this.exec('tmux', ['-V'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 3000 }); + this._available = true; + } catch (error) { + this._available = false; + this.logger.info?.('tmux not available — session persistence disabled', { error: error.message }); + } + return this._available; + } + + // One-time per-socket server config. The options make embedded panes behave + // like plain terminals: no status bar, no prefix key, no tmux-level mouse + // handling (a pane's own mouse-mode requests still pass through), modest + // scrollback (xterm.js keeps its own client-side), and windows sized to the + // most recently attached client. + ensureConfigured() { + if (this._configured) return true; + try { + this.run(['start-server']); + } catch (error) { + this.logger.error?.('Failed to start tmux server for session persistence', { socket: this.socketName, error: error.message }); + return false; + } + const options = [ + ['set', '-g', 'status', 'off'], + ['set', '-g', 'prefix', 'None'], + ['set', '-g', 'mouse', 'off'], + ['set', '-g', 'history-limit', '20000'], + ['set', '-g', 'default-terminal', 'xterm-256color'], + // Pin the outer terminal's capabilities so tmux never has to depend on a + // (slow, round-tripped over the browser socket) probe response to detect + // truecolor/clipboard — which is what races and leaks DA reports. + ['set', '-ga', 'terminal-features', 'xterm-256color:RGB:clipboard'], + ['set', '-g', 'escape-time', '25'], + ['set', '-g', 'window-size', 'latest'], + ['set', '-g', 'allow-rename', 'off'], + ['set', '-g', 'set-titles', 'off'], + // Belt-and-braces on top of buildTmuxEnv: never hand these to panes. + ['set-environment', '-g', '-r', 'CLAUDECODE'], + ['set-environment', '-g', '-r', 'CLAUDE_CODE_ENTRYPOINT'] + ]; + for (const args of options) { + try { + this.run(args); + } catch { + // e.g. set-environment -r on a variable that was never set — harmless + } + } + this._configured = true; + return true; + } + + sessionName(sessionId) { + return String(sessionId || '').replace(SESSION_NAME_UNSAFE, '_') || 'session'; + } + + // "=" forces an exact-name match; without it tmux prefix-matches targets, + // which would make "work1" resolve to "work1-claude". + target(sessionId) { + return `=${this.sessionName(sessionId)}`; + } + + hasSession(sessionId) { + try { + this.run(['has-session', '-t', this.target(sessionId)]); + return true; + } catch { + return false; + } + } + + listSessionNames() { + try { + const out = this.run(['list-sessions', '-F', '#{session_name}']); + return String(out || '').split('\n').map((s) => s.trim()).filter(Boolean); + } catch { + return []; // no server running / no sessions + } + } + + // Argv for node-pty. `new-session -A` attaches when the session already + // exists (server restart) and creates it otherwise; on attach the trailing + // shell-command is ignored — exactly the adoption semantic we want. + buildSpawnCommand({ sessionId, command, args = [], cwd }) { + const name = this.sessionName(sessionId); + const shellCommand = [command, ...args].map(shellQuote).join(' '); + const tmuxArgs = ['-L', this.socketName, 'new-session', '-A', '-s', name]; + if (cwd) tmuxArgs.push('-c', cwd); + tmuxArgs.push(shellCommand); + return { command: 'tmux', args: tmuxArgs, name }; + } + + killSession(sessionId) { + try { + this.run(['kill-session', '-t', this.target(sessionId)]); + return true; + } catch { + return false; + } + } + + // Pid of the process actually running inside the session's pane (a child of + // the tmux server, NOT of the client pty the orchestrator holds). + panePid(sessionId) { + try { + const out = this.run(['list-panes', '-t', this.target(sessionId), '-F', '#{pane_pid}']); + const pid = parseInt(String(out || '').trim().split('\n')[0], 10); + return Number.isFinite(pid) && pid > 0 ? pid : null; + } catch { + return null; + } + } + + // Scrollback (with escape sequences, joined wrapped lines) for buffer + // backfill when adopting a surviving session after a server restart. + capturePane(sessionId, lines = 2000) { + try { + return String(this.run([ + 'capture-pane', '-p', '-e', '-J', + '-t', this.target(sessionId), + '-S', `-${Math.max(1, Math.floor(lines))}` + ]) || ''); + } catch { + return ''; + } + } +} + +module.exports = { TmuxSessionBackend, shellQuote, stripDeviceReports }; diff --git a/tests/unit/sessionManager.persistence.test.js b/tests/unit/sessionManager.persistence.test.js new file mode 100644 index 00000000..0543d059 --- /dev/null +++ b/tests/unit/sessionManager.persistence.test.js @@ -0,0 +1,140 @@ +jest.mock('../../server/sessionRecoveryService', () => ({ + clearSession: jest.fn(), + updateSession: jest.fn(), + updateAgent: jest.fn(), + updateCwd: jest.fn(), + updateConversation: jest.fn(), + updateServer: jest.fn(), + getSession: jest.fn(), + getAllSessions: jest.fn(), + init: jest.fn(), + loadWorkspaceState: jest.fn(), + getRecoveryInfo: jest.fn(), + clearWorkspace: jest.fn(), + markAgentInactive: jest.fn() +})); + +const { SessionManager } = require('../../server/sessionManager'); + +const makeManager = () => { + const io = { emit: jest.fn() }; + const sm = new SessionManager(io, null); + sm.sessionPersistenceEnabled = true; + sm.sessionPersistence = { + socketName: 'test-sock', + panePid: jest.fn(() => 4242), + killSession: jest.fn(() => true), + listSessionNames: jest.fn(() => []), + capturePane: jest.fn(() => ''), + hasSession: jest.fn(() => false) + }; + return { sm, io }; +}; + +describe('SessionManager session persistence', () => { + test('getSessionProcessPid prefers the tmux pane pid for persistent sessions', () => { + const { sm } = makeManager(); + const session = { id: 's1', persistence: { backend: 'tmux', name: 's1' }, pty: { pid: 111 } }; + expect(sm.getSessionProcessPid(session)).toBe(4242); + expect(sm.sessionPersistence.panePid).toHaveBeenCalledWith('s1'); + }); + + test('getSessionProcessPid falls back to the pty pid for direct sessions', () => { + const { sm } = makeManager(); + expect(sm.getSessionProcessPid({ id: 's2', pty: { pid: 222 } })).toBe(222); + expect(sm.sessionPersistence.panePid).not.toHaveBeenCalled(); + }); + + test('getSessionProcessPid falls back to the pty pid when the pane query fails', () => { + const { sm } = makeManager(); + sm.sessionPersistence.panePid.mockReturnValue(null); + const session = { id: 's3', persistence: { backend: 'tmux', name: 's3' }, pty: { pid: 333 } }; + expect(sm.getSessionProcessPid(session)).toBe(333); + }); + + test('destroyPersistentSession kills the tmux session only for persistent sessions', () => { + const { sm } = makeManager(); + sm.destroyPersistentSession({ id: 'plain', pty: {} }); + expect(sm.sessionPersistence.killSession).not.toHaveBeenCalled(); + + sm.destroyPersistentSession({ id: 'persisted', persistence: { backend: 'tmux', name: 'persisted' } }); + expect(sm.sessionPersistence.killSession).toHaveBeenCalledWith('persisted'); + }); + + test('terminateSession destroys the backing tmux session and tree-kills the pane pid', () => { + const { sm } = makeManager(); + const ptyKill = jest.fn(); + sm.sessions.set('work1-claude', { + id: 'work1-claude', + type: 'claude', + workspace: 'ws1', + persistence: { backend: 'tmux', name: 'work1-claude' }, + pty: { pid: 555, kill: ptyKill } + }); + const treeKill = jest.spyOn(sm, 'bestEffortKillProcessTree').mockImplementation(() => {}); + + sm.terminateSession('work1-claude'); + + expect(sm.sessionPersistence.killSession).toHaveBeenCalledWith('work1-claude'); + expect(ptyKill).toHaveBeenCalled(); + expect(treeKill).toHaveBeenCalledWith(4242, { sessionId: 'work1-claude' }); + treeKill.mockRestore(); + }); + + test('getPersistenceStatus separates managed sessions from orphans', () => { + const { sm } = makeManager(); + sm.sessions.set('a-claude', { id: 'a-claude', persistence: { backend: 'tmux', name: 'a-claude' }, pty: {} }); + const stashed = new Map(); + stashed.set('b-server', { id: 'b-server', persistence: { backend: 'tmux', name: 'b-server' }, pty: {} }); + sm.workspaceSessionMaps.set('ws2', stashed); + sm.sessionPersistence.listSessionNames.mockReturnValue(['a-claude', 'b-server', 'stray-work9-claude']); + + const status = sm.getPersistenceStatus(); + expect(status.enabled).toBe(true); + expect(status.managed).toEqual(expect.arrayContaining([ + { name: 'a-claude', sessionId: 'a-claude' }, + { name: 'b-server', sessionId: 'b-server' } + ])); + expect(status.orphaned).toEqual([{ name: 'stray-work9-claude' }]); + }); + + test('writeToSession strips echoed device-attribute reports for tmux sessions', () => { + const { sm } = makeManager(); + const writes = []; + sm.sessions.set('work1-claude', { + id: 'work1-claude', + type: 'claude', + persistence: { backend: 'tmux', name: 'work1-claude' }, + pty: { write: (d) => writes.push(d) } + }); + + sm.writeToSession('work1-claude', '\x1b[?1;2c\x1b[>0;276;0cls\r'); + expect(writes).toEqual(['ls\r']); // DA reports removed, real keystroke kept + }); + + test('writeToSession leaves input untouched for non-persistent (direct pty) sessions', () => { + const { sm } = makeManager(); + sm.sessionPersistenceEnabled = false; + const writes = []; + sm.sessions.set('work2-claude', { + id: 'work2-claude', + type: 'claude', + pty: { write: (d) => writes.push(d) } + }); + + sm.writeToSession('work2-claude', '\x1b[?1;2cls\r'); + expect(writes).toEqual(['\x1b[?1;2cls\r']); // no stripping without tmux + }); + + test('getPersistenceStatus reports disabled cleanly', () => { + const { sm } = makeManager(); + sm.sessionPersistenceEnabled = false; + expect(sm.getPersistenceStatus()).toEqual({ + enabled: false, + backend: 'tmux', + socketName: 'test-sock', + managed: [], + orphaned: [] + }); + }); +}); diff --git a/tests/unit/tmuxSessionBackend.test.js b/tests/unit/tmuxSessionBackend.test.js new file mode 100644 index 00000000..96505a78 --- /dev/null +++ b/tests/unit/tmuxSessionBackend.test.js @@ -0,0 +1,172 @@ +const { TmuxSessionBackend, shellQuote, stripDeviceReports } = require('../../server/utils/tmuxSessionBackend'); + +const makeBackend = (overrides = {}) => { + const calls = []; + const execImpl = overrides.execImpl || jest.fn((cmd, args) => { + calls.push([cmd, ...args]); + return ''; + }); + const backend = new TmuxSessionBackend({ + socketName: 'test-sock', + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, + execImpl, + platform: overrides.platform || 'linux', + baseEnv: overrides.baseEnv || { PATH: '/usr/bin', HOME: '/home/u' } + }); + return { backend, execImpl, calls }; +}; + +describe('shellQuote', () => { + test('passes simple tokens through and quotes the rest', () => { + expect(shellQuote('bash')).toBe('bash'); + expect(shellQuote('/usr/bin/env')).toBe('/usr/bin/env'); + expect(shellQuote('')).toBe("''"); + expect(shellQuote('cd "x" && exec bash')).toBe(`'cd "x" && exec bash'`); + expect(shellQuote("it's")).toBe(`'it'\\''s'`); + }); +}); + +describe('stripDeviceReports', () => { + const ESC = '\x1b'; + test('removes DA1 and DA2 report sequences (the leaked prompt junk)', () => { + expect(stripDeviceReports(`${ESC}[?1;2c`)).toBe(''); + expect(stripDeviceReports(`${ESC}[>0;276;0c`)).toBe(''); + expect(stripDeviceReports(`${ESC}[?1;2c${ESC}[>0;276;0c`)).toBe(''); + // interleaved with a real keystroke that happened to follow + expect(stripDeviceReports(`${ESC}[?1;2cls`)).toBe('ls'); + }); + + test('leaves ordinary input and other escape sequences untouched', () => { + expect(stripDeviceReports('ls -la\r')).toBe('ls -la\r'); + expect(stripDeviceReports(`${ESC}[A`)).toBe(`${ESC}[A`); // arrow up + expect(stripDeviceReports(`${ESC}[200~pasted${ESC}[201~`)).toBe(`${ESC}[200~pasted${ESC}[201~`); + // cursor-position and DSR reports are intentionally preserved (apps use them) + expect(stripDeviceReports(`${ESC}[24;80R`)).toBe(`${ESC}[24;80R`); + expect(stripDeviceReports(`${ESC}[0n`)).toBe(`${ESC}[0n`); + }); + + test('is a no-op for non-strings and escape-free input', () => { + expect(stripDeviceReports('hello')).toBe('hello'); + expect(stripDeviceReports(undefined)).toBe(undefined); + expect(stripDeviceReports(null)).toBe(null); + }); +}); + +describe('TmuxSessionBackend', () => { + test('is unavailable on win32 without probing', () => { + const { backend, execImpl } = makeBackend({ platform: 'win32' }); + expect(backend.isAvailable()).toBe(false); + expect(execImpl).not.toHaveBeenCalled(); + }); + + test('caches the availability probe', () => { + const { backend, execImpl } = makeBackend(); + expect(backend.isAvailable()).toBe(true); + expect(backend.isAvailable()).toBe(true); + expect(execImpl).toHaveBeenCalledTimes(1); + expect(execImpl.mock.calls[0][1]).toEqual(['-V']); + }); + + test('fails closed when tmux is missing', () => { + const execImpl = jest.fn(() => { throw new Error('ENOENT'); }); + const { backend } = makeBackend({ execImpl }); + expect(backend.isAvailable()).toBe(false); + }); + + test('scrubs nested-session markers from every tmux invocation', () => { + const execImpl = jest.fn(() => ''); + const { backend } = makeBackend({ + execImpl, + baseEnv: { PATH: '/usr/bin', CLAUDECODE: '1', CLAUDE_CODE_ENTRYPOINT: 'cli', TMUX: '/tmp/x,1,0', TMUX_PANE: '%1' } + }); + backend.run(['list-sessions']); + const env = execImpl.mock.calls[0][2].env; + expect(env.PATH).toBe('/usr/bin'); + expect(env.CLAUDECODE).toBeUndefined(); + expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined(); + expect(env.TMUX).toBeUndefined(); + expect(env.TMUX_PANE).toBeUndefined(); + }); + + test('buildSpawnCommand produces an attach-or-create client argv with quoted shell command', () => { + const { backend } = makeBackend(); + const spec = backend.buildSpawnCommand({ + sessionId: 'zoo-game-work1-claude', + command: 'bash', + args: ['-c', 'cd "/tmp/w t" && exec bash'], + cwd: '/tmp/w t' + }); + expect(spec.command).toBe('tmux'); + expect(spec.name).toBe('zoo-game-work1-claude'); + expect(spec.args).toEqual([ + '-L', 'test-sock', + 'new-session', '-A', '-s', 'zoo-game-work1-claude', + '-c', '/tmp/w t', + `bash -c 'cd "/tmp/w t" && exec bash'` + ]); + }); + + test('sanitizes session names that would break tmux targets', () => { + const { backend } = makeBackend(); + expect(backend.sessionName('repo.name:work1-claude')).toBe('repo_name_work1-claude'); + expect(backend.target('repo.name')).toBe('=repo_name'); + }); + + test('ensureConfigured starts the server, applies options, and tolerates option failures', () => { + const seen = []; + const execImpl = jest.fn((cmd, args) => { + seen.push(args.slice(2)); // drop -L + if (args.includes('set-environment')) throw new Error('unknown variable'); + return ''; + }); + const { backend } = makeBackend({ execImpl }); + expect(backend.ensureConfigured()).toBe(true); + expect(seen[0]).toEqual(['start-server']); + expect(seen).toEqual(expect.arrayContaining([ + ['set', '-g', 'status', 'off'], + ['set', '-g', 'prefix', 'None'], + ['set', '-g', 'mouse', 'off'], + ['set', '-g', 'window-size', 'latest'], + ['set', '-ga', 'terminal-features', 'xterm-256color:RGB:clipboard'] + ])); + // second call is a no-op + const callsBefore = execImpl.mock.calls.length; + expect(backend.ensureConfigured()).toBe(true); + expect(execImpl.mock.calls.length).toBe(callsBefore); + }); + + test('hasSession / killSession / panePid / capturePane use exact-match targets and fail soft', () => { + const responses = { + 'has-session': () => '', + 'kill-session': () => '', + 'list-panes': () => '12345\n', + 'capture-pane': () => 'line1\nline2\n', + 'list-sessions': () => 'a-claude\nb-server\n' + }; + const execImpl = jest.fn((cmd, args) => { + const sub = args[2]; + if (!responses[sub]) throw new Error(`unexpected ${sub}`); + return responses[sub](); + }); + const { backend } = makeBackend({ execImpl }); + + expect(backend.hasSession('a-claude')).toBe(true); + expect(execImpl.mock.calls[0][1]).toEqual(['-L', 'test-sock', 'has-session', '-t', '=a-claude']); + + expect(backend.killSession('a-claude')).toBe(true); + expect(backend.panePid('a-claude')).toBe(12345); + expect(backend.capturePane('a-claude', 500)).toBe('line1\nline2\n'); + expect(execImpl.mock.calls.at(-1)[1]).toEqual( + ['-L', 'test-sock', 'capture-pane', '-p', '-e', '-J', '-t', '=a-claude', '-S', '-500'] + ); + expect(backend.listSessionNames()).toEqual(['a-claude', 'b-server']); + + // failures degrade to safe defaults instead of throwing + const failing = makeBackend({ execImpl: jest.fn(() => { throw new Error('no server'); }) }).backend; + expect(failing.hasSession('x')).toBe(false); + expect(failing.killSession('x')).toBe(false); + expect(failing.panePid('x')).toBeNull(); + expect(failing.capturePane('x')).toBe(''); + expect(failing.listSessionNames()).toEqual([]); + }); +});