Files
Codeman/src/session.ts
T
arkonandClaude Opus 4.5 9aa5973970 fix: rock-solid idle detection in respawn controller
- Add 300-char rolling window to catch working patterns split across PTY chunks
- Check completion message BEFORE working patterns (priority fix)
- Clear rolling window on completion message (transition point)
- Increase working pattern absence threshold from 3s to 8s
- Add Session.isWorking safety check before confirming idle
- Add 20+ more working patterns (Compiling, Building, Processing, etc.)
- Make AI idle checker prompt more conservative (err toward WORKING)
- Update documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 00:55:29 +01:00

1722 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Core PTY session wrapper for Claude CLI interactions.
*
* This module provides the Session class which manages a PTY (pseudo-terminal)
* process running the Claude CLI. It supports three operation modes:
*
* 1. **One-shot mode** (`runPrompt`): Execute a single prompt and get JSON response
* 2. **Interactive mode** (`startInteractive`): Start an interactive Claude session
* 3. **Shell mode**: Run a plain bash shell for debugging/testing
*
* The session can optionally run inside a GNU Screen session for persistence
* across disconnects. It tracks tokens, costs, background tasks, and supports
* auto-clear/auto-compact functionality when token limits are approached.
*
* @module session
*/
import { EventEmitter } from 'node:events';
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
import * as pty from 'node-pty';
import { SessionState, SessionStatus, SessionConfig, ScreenSession, RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
import { RalphTracker } from './ralph-tracker.js';
import { BashToolParser } from './bash-tool-parser.js';
import { ScreenManager } from './screen-manager.js';
export type { BackgroundTask } from './task-tracker.js';
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
export { withTimeout };
// ============================================================================
// Buffer Size Constants
// ============================================================================
/** Maximum terminal buffer size in characters (2MB) - reduced from 5MB for better render performance */
const MAX_TERMINAL_BUFFER_SIZE = 2 * 1024 * 1024;
/** When trimming terminal buffer, keep the most recent portion (1.5MB) */
const TERMINAL_BUFFER_TRIM_SIZE = 1.5 * 1024 * 1024;
/** Maximum text output buffer size (1MB) - ANSI-stripped text */
const MAX_TEXT_OUTPUT_SIZE = 1 * 1024 * 1024;
/** When trimming text output, keep the most recent portion (768KB) */
const TEXT_OUTPUT_TRIM_SIZE = 768 * 1024;
/** Maximum number of Claude JSON messages to keep in memory */
const MAX_MESSAGES = 1000;
/** Maximum line buffer size (64KB) - prevents unbounded growth for long lines */
const MAX_LINE_BUFFER_SIZE = 64 * 1024;
/** Line buffer flush interval (100ms) - forces processing of partial lines */
const LINE_BUFFER_FLUSH_INTERVAL = 100;
// Filter out terminal focus escape sequences (focus in/out reports)
// ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences
const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g;
// Pre-compiled regex patterns for performance (avoid re-compilation on each call)
// Comprehensive ANSI escape pattern:
// - SGR (colors/styles): ESC [ params m
// - CSI sequences (cursor, scroll, etc.): ESC [ params letter
// - OSC sequences (title, etc.): ESC ] ... BEL or ESC ] ... ST
// - Single-char escapes: ESC = or ESC >
const ANSI_ESCAPE_PATTERN = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g;
const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/;
// ============================================================================
// Claude CLI PATH Resolution
// ============================================================================
/** Common directories where the Claude CLI binary may be installed */
const CLAUDE_SEARCH_DIRS = [
`${process.env.HOME}/.local/bin`,
`${process.env.HOME}/.claude/local`,
'/usr/local/bin',
`${process.env.HOME}/.npm-global/bin`,
`${process.env.HOME}/bin`,
];
/** Cached PATH string with claude's directory prepended */
let _augmentedPath: string | null = null;
/**
* Returns a PATH string that includes the directory containing `claude`.
*
* Finds the claude binary (via `which` or common install locations), then
* prepends its directory to the current PATH if not already present.
* Result is cached for subsequent calls.
*/
export function getAugmentedPath(): string {
if (_augmentedPath) return _augmentedPath;
const currentPath = process.env.PATH || '';
let claudeDir: string | null = null;
// Try `which` first (respects current PATH)
try {
const result = execSync('which claude', { encoding: 'utf-8', timeout: 5000 }).trim();
if (result && existsSync(result)) {
claudeDir = dirname(result);
}
} catch {
// not in PATH, check common locations
}
// Fallback: check common installation directories
if (!claudeDir) {
for (const dir of CLAUDE_SEARCH_DIRS) {
if (existsSync(`${dir}/claude`)) {
claudeDir = dir;
break;
}
}
}
if (claudeDir && !currentPath.split(':').includes(claudeDir)) {
_augmentedPath = `${claudeDir}:${currentPath}`;
console.log('[Session] Augmented PATH with claude directory:', claudeDir);
} else {
_augmentedPath = currentPath;
}
return _augmentedPath;
}
// ============================================================================
// Buffer Accumulator (reduces GC pressure from string concatenation)
// ============================================================================
/**
* High-performance buffer accumulator using array-based collection.
*
* Reduces GC pressure by avoiding repeated string concatenation (`+=`).
* Instead, chunks are pushed to an array and joined only when needed.
* Automatically trims when size limits are exceeded.
*/
class BufferAccumulator {
private chunks: string[] = [];
private totalLength: number = 0;
private readonly maxSize: number;
private readonly trimSize: number;
constructor(maxSize: number, trimSize: number) {
this.maxSize = maxSize;
this.trimSize = trimSize;
}
/** Append data to the buffer */
append(data: string): void {
if (!data) return;
this.chunks.push(data);
this.totalLength += data.length;
// Trim if exceeded max size
if (this.totalLength > this.maxSize) {
this.trim();
}
}
/** Get the full buffer content (joins all chunks) */
get value(): string {
if (this.chunks.length === 0) return '';
if (this.chunks.length === 1) return this.chunks[0];
// Consolidate chunks on access
const result = this.chunks.join('');
this.chunks = [result];
return result;
}
/** Get current buffer length without joining */
get length(): number {
return this.totalLength;
}
/** Clear the buffer */
clear(): void {
this.chunks = [];
this.totalLength = 0;
}
/** Set buffer to a specific value */
set(value: string): void {
this.chunks = value ? [value] : [];
this.totalLength = value?.length || 0;
}
/** Trim buffer to keep only the most recent data */
private trim(): void {
const full = this.chunks.join('');
const trimmed = full.slice(-this.trimSize);
this.chunks = [trimmed];
this.totalLength = trimmed.length;
}
}
/**
* Wraps a promise with a timeout to prevent indefinite hangs.
* If the promise doesn't resolve within the timeout, rejects with TimeoutError.
*
* @param promise - The promise to wrap
* @param timeoutMs - Timeout in milliseconds
* @param operation - Description of the operation for error messages
* @returns Promise that resolves/rejects with the original result or timeout error
*/
function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
operation: string
): Promise<T> {
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${operation} timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutId);
});
}
/**
* Represents a JSON message from Claude CLI's stream-json output format.
* Messages are newline-delimited JSON objects parsed from PTY output.
*/
export interface ClaudeMessage {
/** Message type indicating the role or purpose */
type: 'system' | 'assistant' | 'user' | 'result';
/** Optional subtype for further classification */
subtype?: string;
/** Claude's internal session identifier */
session_id?: string;
/** Message content with optional token usage */
message?: {
content: Array<{ type: string; text?: string }>;
usage?: {
input_tokens: number;
output_tokens: number;
};
};
/** Final result text (on result messages) */
result?: string;
/** Whether this message represents an error */
is_error?: boolean;
/** Total cost in USD (on result messages) */
total_cost_usd?: number;
/** Total duration in milliseconds (on result messages) */
duration_ms?: number;
}
/**
* Event signatures emitted by the Session class.
* Subscribe using `session.on('eventName', handler)`.
*/
export interface SessionEvents {
/** Processed text output (ANSI stripped) */
output: (data: string) => void;
/** Parsed JSON message from Claude CLI */
message: (msg: ClaudeMessage) => void;
/** Error output from the session */
error: (data: string) => void;
/** Session process exited */
exit: (code: number | null) => void;
/** One-shot prompt completed with result and cost */
completion: (result: string, cost: number) => void;
/** Raw terminal data (includes ANSI codes) */
terminal: (data: string) => void;
/** Signal to clear terminal display (after screen attach) */
clearTerminal: () => void;
/** New background task started */
taskCreated: (task: BackgroundTask) => void;
/** Background task status changed */
taskUpdated: (task: BackgroundTask) => void;
/** Background task finished successfully */
taskCompleted: (task: BackgroundTask) => void;
/** Background task failed with error */
taskFailed: (task: BackgroundTask, error: string) => void;
/** Auto-clear triggered due to token threshold */
autoClear: (data: { tokens: number; threshold: number }) => void;
/** Auto-compact triggered due to token threshold */
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
/** Ralph loop state changed */
ralphLoopUpdate: (state: RalphTrackerState) => void;
/** Ralph todo list updated */
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
/** Ralph completion phrase detected */
ralphCompletionDetected: (phrase: string) => void;
/** Bash tool with file paths started */
bashToolStart: (tool: ActiveBashTool) => void;
/** Bash tool completed */
bashToolEnd: (tool: ActiveBashTool) => void;
/** Active Bash tools list updated */
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
}
/**
* Session operation mode.
* - `'claude'`: Runs Claude CLI for AI interactions (default)
* - `'shell'`: Runs a plain bash shell for debugging/testing
*/
export type SessionMode = 'claude' | 'shell';
/**
* Core session class that wraps a PTY process running Claude CLI or a shell.
*
* @example
* ```typescript
* // Create and start an interactive Claude session
* const session = new Session({
* workingDir: '/path/to/project',
* screenManager: screenManager,
* useScreen: true
* });
* await session.startInteractive();
*
* // Listen for events
* session.on('terminal', (data) => console.log(data));
* session.on('message', (msg) => console.log('Claude:', msg));
*
* // Send input
* session.write('Hello Claude!\r');
*
* // Stop when done
* await session.stop();
* ```
*
* @fires Session#terminal - Raw terminal output
* @fires Session#message - Parsed Claude JSON message
* @fires Session#completion - One-shot prompt completed
* @fires Session#exit - Process exited
* @fires Session#autoClear - Token threshold reached, clearing context
* @fires Session#autoCompact - Token threshold reached, compacting context
*/
export class Session extends EventEmitter {
readonly id: string;
readonly workingDir: string;
readonly createdAt: number;
readonly mode: SessionMode;
private _name: string;
private ptyProcess: pty.IPty | null = null;
private _pid: number | null = null;
private _status: SessionStatus = 'idle';
private _currentTaskId: string | null = null;
// Use BufferAccumulator for hot-path buffers to reduce GC pressure
private _terminalBuffer = new BufferAccumulator(MAX_TERMINAL_BUFFER_SIZE, TERMINAL_BUFFER_TRIM_SIZE);
private _outputBuffer: string = '';
private _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE);
private _errorBuffer: string = '';
private _lastActivityAt: number;
private _claudeSessionId: string | null = null;
private _totalCost: number = 0;
private _messages: ClaudeMessage[] = [];
private _lineBuffer: string = '';
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
private rejectPromise: ((reason: Error) => void) | null = null;
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
private _isWorking: boolean = false;
private _lastPromptTime: number = 0;
private activityTimeout: NodeJS.Timeout | null = null;
private _taskTracker: TaskTracker;
// Token tracking for auto-clear
private _totalInputTokens: number = 0;
private _totalOutputTokens: number = 0;
private _autoClearThreshold: number = 140000; // Default 140k tokens
private _autoClearEnabled: boolean = false;
private _isClearing: boolean = false; // Prevent recursive clearing
// Auto-compact settings
private _autoCompactThreshold: number = 110000; // Default 110k tokens (lower than clear)
private _autoCompactEnabled: boolean = false;
private _autoCompactPrompt: string = ''; // Optional prompt for compact
private _isCompacting: boolean = false; // Prevent recursive compacting
// Timer tracking for cleanup (prevents memory leaks)
private _autoCompactTimer: NodeJS.Timeout | null = null;
private _autoClearTimer: NodeJS.Timeout | null = null;
private _promptCheckInterval: NodeJS.Timeout | null = null;
private _promptCheckTimeout: NodeJS.Timeout | null = null;
private _shellIdleTimer: NodeJS.Timeout | null = null;
// Screen session support
private _screenManager: ScreenManager | null = null;
private _screenSession: ScreenSession | null = null;
private _useScreen: boolean = false;
// Flag to prevent new timers after session is stopped
private _isStopped: boolean = false;
// Ralph tracking (Ralph Wiggum loops and todo lists inside Claude Code)
private _ralphTracker: RalphTracker;
// Agent tree tracking
private _parentAgentId: string | null = null;
private _childAgentIds: string[] = [];
// Store handler references for cleanup (prevents memory leaks)
private _taskTrackerHandlers: {
taskCreated: (task: BackgroundTask) => void;
taskUpdated: (task: BackgroundTask) => void;
taskCompleted: (task: BackgroundTask) => void;
taskFailed: (task: BackgroundTask, error: string) => void;
} | null = null;
private _ralphHandlers: {
loopUpdate: (state: RalphTrackerState) => void;
todoUpdate: (todos: RalphTodoItem[]) => void;
completionDetected: (phrase: string) => void;
} | null = null;
// Bash tool tracking (file paths for live log viewing)
private _bashToolParser: BashToolParser;
private _bashToolHandlers: {
toolStart: (tool: ActiveBashTool) => void;
toolEnd: (tool: ActiveBashTool) => void;
toolsUpdate: (tools: ActiveBashTool[]) => void;
} | null = null;
constructor(config: Partial<SessionConfig> & {
workingDir: string;
mode?: SessionMode;
name?: string;
screenManager?: ScreenManager;
useScreen?: boolean;
screenSession?: ScreenSession; // For restored sessions - pass the existing screen
}) {
super();
this.id = config.id || uuidv4();
this.workingDir = config.workingDir;
this.createdAt = config.createdAt || Date.now();
this.mode = config.mode || 'claude';
this._name = config.name || '';
this._lastActivityAt = this.createdAt;
this._screenManager = config.screenManager || null;
this._useScreen = config.useScreen ?? (this._screenManager !== null && ScreenManager.isScreenAvailable());
this._screenSession = config.screenSession || null; // Use existing screen if provided
// Initialize task tracker and forward events (store handlers for cleanup)
this._taskTracker = new TaskTracker();
this._taskTrackerHandlers = {
taskCreated: (task) => this.emit('taskCreated', task),
taskUpdated: (task) => this.emit('taskUpdated', task),
taskCompleted: (task) => this.emit('taskCompleted', task),
taskFailed: (task, error) => this.emit('taskFailed', task, error),
};
this._taskTracker.on('taskCreated', this._taskTrackerHandlers.taskCreated);
this._taskTracker.on('taskUpdated', this._taskTrackerHandlers.taskUpdated);
this._taskTracker.on('taskCompleted', this._taskTrackerHandlers.taskCompleted);
this._taskTracker.on('taskFailed', this._taskTrackerHandlers.taskFailed);
// Initialize Ralph tracker and forward events (store handlers for cleanup)
this._ralphTracker = new RalphTracker();
this._ralphHandlers = {
loopUpdate: (state) => this.emit('ralphLoopUpdate', state),
todoUpdate: (todos) => this.emit('ralphTodoUpdate', todos),
completionDetected: (phrase) => this.emit('ralphCompletionDetected', phrase),
};
this._ralphTracker.on('loopUpdate', this._ralphHandlers.loopUpdate);
this._ralphTracker.on('todoUpdate', this._ralphHandlers.todoUpdate);
this._ralphTracker.on('completionDetected', this._ralphHandlers.completionDetected);
// Initialize Bash tool parser and forward events (store handlers for cleanup)
this._bashToolParser = new BashToolParser({ sessionId: this.id });
this._bashToolHandlers = {
toolStart: (tool) => this.emit('bashToolStart', tool),
toolEnd: (tool) => this.emit('bashToolEnd', tool),
toolsUpdate: (tools) => this.emit('bashToolsUpdate', tools),
};
this._bashToolParser.on('toolStart', this._bashToolHandlers.toolStart);
this._bashToolParser.on('toolEnd', this._bashToolHandlers.toolEnd);
this._bashToolParser.on('toolsUpdate', this._bashToolHandlers.toolsUpdate);
}
get status(): SessionStatus {
return this._status;
}
get currentTaskId(): string | null {
return this._currentTaskId;
}
get pid(): number | null {
return this._pid;
}
get terminalBuffer(): string {
return this._terminalBuffer.value;
}
get outputBuffer(): string {
return this._outputBuffer;
}
get textOutput(): string {
return this._textOutput.value;
}
get errorBuffer(): string {
return this._errorBuffer;
}
get lastActivityAt(): number {
return this._lastActivityAt;
}
get claudeSessionId(): string | null {
return this._claudeSessionId;
}
get totalCost(): number {
return this._totalCost;
}
get messages(): ClaudeMessage[] {
return this._messages;
}
get isWorking(): boolean {
return this._isWorking;
}
get lastPromptTime(): number {
return this._lastPromptTime;
}
get taskTracker(): TaskTracker {
return this._taskTracker;
}
get runningTaskCount(): number {
return this._taskTracker.getRunningCount();
}
get taskTree(): BackgroundTask[] {
return this._taskTracker.getTaskTree();
}
get taskStats(): { total: number; running: number; completed: number; failed: number } {
return this._taskTracker.getStats();
}
// Ralph tracking getters
get ralphTracker(): RalphTracker {
return this._ralphTracker;
}
get ralphLoopState(): RalphTrackerState {
return this._ralphTracker.loopState;
}
get ralphTodos(): RalphTodoItem[] {
return this._ralphTracker.todos;
}
get ralphTodoStats(): { total: number; pending: number; inProgress: number; completed: number } {
return this._ralphTracker.getTodoStats();
}
// Bash tool tracking getters
get bashToolParser(): BashToolParser {
return this._bashToolParser;
}
get activeTools(): ActiveBashTool[] {
return this._bashToolParser.activeTools;
}
get parentAgentId(): string | null {
return this._parentAgentId;
}
set parentAgentId(value: string | null) {
this._parentAgentId = value;
}
get childAgentIds(): string[] {
return [...this._childAgentIds];
}
addChildAgentId(agentId: string): void {
if (!this._childAgentIds.includes(agentId)) {
this._childAgentIds.push(agentId);
}
}
removeChildAgentId(agentId: string): void {
const idx = this._childAgentIds.indexOf(agentId);
if (idx >= 0) this._childAgentIds.splice(idx, 1);
}
// Token tracking getters and setters
get totalTokens(): number {
return this._totalInputTokens + this._totalOutputTokens;
}
get inputTokens(): number {
return this._totalInputTokens;
}
get outputTokens(): number {
return this._totalOutputTokens;
}
/**
* Restore token and cost values from saved state.
* Called when recovering sessions after server restart.
*/
restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void {
this._totalInputTokens = inputTokens;
this._totalOutputTokens = outputTokens;
this._totalCost = totalCost;
}
get autoClearThreshold(): number {
return this._autoClearThreshold;
}
get autoClearEnabled(): boolean {
return this._autoClearEnabled;
}
get name(): string {
return this._name;
}
set name(value: string) {
this._name = value;
}
setAutoClear(enabled: boolean, threshold?: number): void {
this._autoClearEnabled = enabled;
if (threshold !== undefined) {
this._autoClearThreshold = threshold;
}
}
get autoCompactThreshold(): number {
return this._autoCompactThreshold;
}
get autoCompactEnabled(): boolean {
return this._autoCompactEnabled;
}
get autoCompactPrompt(): string {
return this._autoCompactPrompt;
}
setAutoCompact(enabled: boolean, threshold?: number, prompt?: string): void {
this._autoCompactEnabled = enabled;
if (threshold !== undefined) {
this._autoCompactThreshold = threshold;
}
if (prompt !== undefined) {
this._autoCompactPrompt = prompt;
}
}
isIdle(): boolean {
return this._status === 'idle';
}
isBusy(): boolean {
return this._status === 'busy';
}
isRunning(): boolean {
return this._status === 'idle' || this._status === 'busy';
}
toState(): SessionState {
return {
id: this.id,
pid: this.pid,
status: this._status,
workingDir: this.workingDir,
currentTaskId: this._currentTaskId,
createdAt: this.createdAt,
lastActivityAt: this._lastActivityAt,
name: this._name,
mode: this.mode,
autoClearEnabled: this._autoClearEnabled,
autoClearThreshold: this._autoClearThreshold,
autoCompactEnabled: this._autoCompactEnabled,
autoCompactThreshold: this._autoCompactThreshold,
autoCompactPrompt: this._autoCompactPrompt,
totalCost: this._totalCost,
inputTokens: this._totalInputTokens,
outputTokens: this._totalOutputTokens,
ralphEnabled: this._ralphTracker.enabled,
ralphCompletionPhrase: this._ralphTracker.loopState.completionPhrase || undefined,
parentAgentId: this._parentAgentId || undefined,
childAgentIds: this._childAgentIds.length > 0 ? this._childAgentIds : undefined,
};
}
toDetailedState() {
return {
...this.toState(),
name: this._name,
mode: this.mode,
claudeSessionId: this._claudeSessionId,
totalCost: this._totalCost,
textOutput: this._textOutput.value,
terminalBuffer: this._terminalBuffer.value,
messageCount: this._messages.length,
isWorking: this._isWorking,
lastPromptTime: this._lastPromptTime,
// Buffer statistics for monitoring long-running sessions
bufferStats: {
terminalBufferSize: this._terminalBuffer.length,
textOutputSize: this._textOutput.length,
messageCount: this._messages.length,
maxTerminalBuffer: MAX_TERMINAL_BUFFER_SIZE,
maxTextOutput: MAX_TEXT_OUTPUT_SIZE,
maxMessages: MAX_MESSAGES,
},
// Background task tracking
taskStats: this._taskTracker.getStats(),
taskTree: this._taskTracker.getTaskTree(),
// Token tracking
tokens: {
input: this._totalInputTokens,
output: this._totalOutputTokens,
total: this._totalInputTokens + this._totalOutputTokens,
},
autoClear: {
enabled: this._autoClearEnabled,
threshold: this._autoClearThreshold,
},
// Ralph tracking state
ralphLoop: this._ralphTracker.loopState,
ralphTodos: this._ralphTracker.todos,
ralphTodoStats: this._ralphTracker.getTodoStats(),
};
}
/**
* Starts an interactive Claude CLI session with full terminal support.
*
* This spawns Claude CLI with `--dangerously-skip-permissions` flag in
* interactive mode. If screen wrapping is enabled, the session runs inside
* a GNU Screen session for persistence across disconnects.
*
* @throws {Error} If a process is already running in this session
*
* @example
* ```typescript
* const session = new Session({ workingDir: '/project', useScreen: true });
* await session.startInteractive();
* session.on('terminal', (data) => process.stdout.write(data));
* session.write('help me with this code\r');
* ```
*/
async startInteractive(): Promise<void> {
if (this.ptyProcess) {
throw new Error('Session already has a running process');
}
this._status = 'busy';
this._terminalBuffer.clear();
this._outputBuffer = '';
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
console.log('[Session] Starting interactive Claude session' + (this._useScreen ? ' (with screen)' : ''));
// If screen wrapping is enabled, create or attach to a screen session
if (this._useScreen && this._screenManager) {
try {
// Check if we already have a screen session (restored session)
const isRestoredSession = this._screenSession !== null;
if (isRestoredSession) {
console.log('[Session] Attaching to existing screen session:', this._screenSession!.screenName);
} else {
// Create a new screen session
this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'claude', this._name);
console.log('[Session] Created screen session:', this._screenSession.screenName);
// Wait a moment for screen to fully start
await new Promise(resolve => setTimeout(resolve, 300));
}
// Attach to the screen session via PTY
this.ptyProcess = pty.spawn('screen', [
'-x', this._screenSession!.screenName
], {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: { ...process.env, TERM: 'xterm-256color' },
});
// For NEW screens: wait for prompt to appear then clean buffer
// For RESTORED screens: don't do anything - client will fetch buffer on tab switch
if (!isRestoredSession) {
this._promptCheckInterval = setInterval(() => {
// Wait for the prompt character (❯) which means Claude is fully initialized
const bufferValue = this._terminalBuffer.value;
if (bufferValue.includes('❯') || bufferValue.includes('\u276f')) {
if (this._promptCheckInterval) {
clearInterval(this._promptCheckInterval);
this._promptCheckInterval = null;
}
if (this._promptCheckTimeout) {
clearTimeout(this._promptCheckTimeout);
this._promptCheckTimeout = null;
}
// Clean the buffer - remove screen init junk before actual content
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
this._terminalBuffer.set(
bufferValue.replace(/^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/, '')
);
// Signal client to refresh
this.emit('clearTerminal');
}
}, 50);
// Timeout after 5 seconds if prompt not found
this._promptCheckTimeout = setTimeout(() => {
if (this._promptCheckInterval) {
clearInterval(this._promptCheckInterval);
this._promptCheckInterval = null;
}
this._promptCheckTimeout = null;
}, 5000);
}
} catch (err) {
console.error('[Session] Failed to create screen session, falling back to direct PTY:', err);
this._useScreen = false;
this._screenSession = null;
}
}
// Fallback to direct PTY if screen is not used
if (!this.ptyProcess) {
this.ptyProcess = pty.spawn('claude', [
'--dangerously-skip-permissions'
], {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: {
...process.env,
PATH: getAugmentedPath(),
TERM: 'xterm-256color',
// Inform Claude it's running within Claudeman (helps prevent self-termination)
CLAUDEMAN_SCREEN: '1',
CLAUDEMAN_SESSION_ID: this.id,
CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000',
},
});
}
this._pid = this.ptyProcess.pid;
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
this.ptyProcess.onData((rawData: string) => {
// Filter out focus escape sequences and Ctrl+L (form feed)
const data = rawData
.replace(FOCUS_ESCAPE_FILTER, '')
.replace(/\x0c/g, ''); // Remove Ctrl+L
if (!data) return; // Skip if only filtered sequences
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
// Forward to Ralph tracker to detect Ralph loops and todos
this._ralphTracker.processTerminalData(data);
// Forward to Bash tool parser to detect file-viewing commands
this._bashToolParser.processTerminalData(data);
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
this.parseTokensFromStatusLine(data);
// Detect if Claude is working or at prompt
// The prompt line contains "❯" when waiting for input
if (data.includes('❯') || data.includes('\u276f')) {
// Reset activity timeout - if no activity for 2 seconds after prompt, Claude is idle
if (this.activityTimeout) clearTimeout(this.activityTimeout);
this.activityTimeout = setTimeout(() => {
if (this._isWorking) {
this._isWorking = false;
this._lastPromptTime = Date.now();
this.emit('idle');
}
}, 2000);
}
// Detect when Claude starts working (thinking, writing, etc)
if (data.includes('Thinking') || data.includes('Writing') || data.includes('Reading') ||
data.includes('Running') || data.includes('⠋') || data.includes('⠙') ||
data.includes('⠹') || data.includes('⠸') || data.includes('⠼') ||
data.includes('⠴') || data.includes('⠦') || data.includes('⠧')) {
if (!this._isWorking) {
this._isWorking = true;
this.emit('working');
}
// Reset timeout since Claude is active
if (this.activityTimeout) clearTimeout(this.activityTimeout);
}
});
this.ptyProcess.onExit(({ exitCode }) => {
console.log('[Session] Interactive PTY exited with code:', exitCode);
this.ptyProcess = null;
this._pid = null;
this._status = 'idle';
// Clear all timers to prevent memory leaks
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
this.activityTimeout = null;
}
if (this._promptCheckInterval) {
clearInterval(this._promptCheckInterval);
this._promptCheckInterval = null;
}
if (this._promptCheckTimeout) {
clearTimeout(this._promptCheckTimeout);
this._promptCheckTimeout = null;
}
// If using screen, mark the screen as detached but don't kill it
if (this._screenSession && this._screenManager) {
this._screenManager.setAttached(this.id, false);
}
this.emit('exit', exitCode);
});
}
/**
* Starts a plain shell session (bash/zsh) without Claude CLI.
*
* Useful for debugging, testing, or when you just need a terminal.
* Uses the user's default shell from $SHELL or falls back to /bin/bash.
*
* @throws {Error} If a process is already running in this session
*
* @example
* ```typescript
* const session = new Session({ workingDir: '/project', mode: 'shell' });
* await session.startShell();
* session.write('ls -la\r');
* ```
*/
async startShell(): Promise<void> {
if (this.ptyProcess) {
throw new Error('Session already has a running process');
}
this._status = 'busy';
this._terminalBuffer.clear();
this._outputBuffer = '';
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
// Use user's default shell or bash
const shell = process.env.SHELL || '/bin/bash';
console.log('[Session] Starting shell session with:', shell + (this._useScreen ? ' (with screen)' : ''));
// If screen wrapping is enabled, create or attach to a screen session
if (this._useScreen && this._screenManager) {
try {
// Check if we already have a screen session (restored session)
const isRestoredSession = this._screenSession !== null;
if (isRestoredSession) {
console.log('[Session] Attaching to existing screen session:', this._screenSession!.screenName);
} else {
// Create a new screen session
this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'shell', this._name);
console.log('[Session] Created screen session:', this._screenSession.screenName);
// Wait a moment for screen to fully start
await new Promise(resolve => setTimeout(resolve, 300));
}
// Attach to the screen session via PTY
this.ptyProcess = pty.spawn('screen', [
'-x', this._screenSession!.screenName
], {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: { ...process.env, TERM: 'xterm-256color' },
});
// For NEW screens: clear by sending 'clear' command to the shell
// For RESTORED screens: don't clear - we want to see the existing output
if (!isRestoredSession) {
setTimeout(() => {
if (this.ptyProcess) {
this._terminalBuffer.clear();
this.ptyProcess.write('clear\n');
}
}, 100);
}
} catch (err) {
console.error('[Session] Failed to create screen session, falling back to direct PTY:', err);
this._useScreen = false;
this._screenSession = null;
}
}
// Fallback to direct PTY if screen is not used
if (!this.ptyProcess) {
this.ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: {
...process.env,
TERM: 'xterm-256color',
CLAUDEMAN_SCREEN: '1',
CLAUDEMAN_SESSION_ID: this.id,
CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000',
},
});
}
this._pid = this.ptyProcess.pid;
console.log('[Session] Shell PTY spawned with PID:', this._pid);
this.ptyProcess.onData((rawData: string) => {
// Filter out focus escape sequences
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
if (!data) return; // Skip if only focus sequences
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
});
this.ptyProcess.onExit(({ exitCode }) => {
console.log('[Session] Shell PTY exited with code:', exitCode);
this.ptyProcess = null;
this._pid = null;
this._status = 'idle';
// Clear timers to prevent memory leaks
if (this._shellIdleTimer) {
clearTimeout(this._shellIdleTimer);
this._shellIdleTimer = null;
}
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
this.activityTimeout = null;
}
// If using screen, mark the screen as detached but don't kill it
if (this._screenSession && this._screenManager) {
this._screenManager.setAttached(this.id, false);
}
this.emit('exit', exitCode);
});
// Mark as idle after a short delay (shell is ready)
this._shellIdleTimer = setTimeout(() => {
this._shellIdleTimer = null;
this._status = 'idle';
this._isWorking = false;
this.emit('idle');
}, 500);
}
/**
* Runs a one-shot prompt and returns the result.
*
* This spawns Claude CLI with `--output-format stream-json` to get
* structured JSON output. The promise resolves when Claude completes
* the response.
*
* @param prompt - The prompt text to send to Claude
* @returns Promise resolving to the result text and total cost in USD
* @throws {Error} If a process is already running in this session
*
* @example
* ```typescript
* const session = new Session({ workingDir: '/project' });
* const { result, cost } = await session.runPrompt('Explain this code');
* console.log(`Response: ${result}`);
* console.log(`Cost: $${cost.toFixed(4)}`);
* ```
*/
async runPrompt(prompt: string): Promise<{ result: string; cost: number }> {
return new Promise((resolve, reject) => {
if (this.ptyProcess) {
reject(new Error('Session already has a running process'));
return;
}
this._status = 'busy';
this._terminalBuffer.clear();
this._outputBuffer = '';
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lineBuffer = '';
this._lastActivityAt = Date.now();
this._promptResolved = false; // Reset race condition guard
this.resolvePromise = resolve;
this.rejectPromise = reject;
try {
// Spawn claude in a real PTY
console.log('[Session] Spawning PTY for claude with prompt:', prompt.substring(0, 50));
this.ptyProcess = pty.spawn('claude', [
'-p',
'--dangerously-skip-permissions',
'--output-format', 'stream-json',
prompt
], {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: {
...process.env,
PATH: getAugmentedPath(),
TERM: 'xterm-256color',
// Inform Claude it's running within Claudeman
CLAUDEMAN_SCREEN: '1',
CLAUDEMAN_SESSION_ID: this.id,
CLAUDEMAN_API_URL: process.env.CLAUDEMAN_API_URL || 'http://localhost:3000',
},
});
this._pid = this.ptyProcess.pid;
console.log('[Session] PTY spawned with PID:', this._pid);
// Handle terminal data
this.ptyProcess.onData((rawData: string) => {
// Filter out focus escape sequences
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '');
if (!data) return; // Skip if only focus sequences
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this.emit('terminal', data);
this.emit('output', data);
// Also try to parse JSON lines for structured data
this.processOutput(data);
});
// Handle exit
this.ptyProcess.onExit(({ exitCode }) => {
console.log('[Session] PTY exited with code:', exitCode);
this.ptyProcess = null;
this._pid = null;
// Guard against race conditions: only process once per runPrompt call
if (this._promptResolved) {
this.emit('exit', exitCode);
return;
}
this._promptResolved = true;
// Capture callbacks atomically before processing
const resolve = this.resolvePromise;
const reject = this.rejectPromise;
this.resolvePromise = null;
this.rejectPromise = null;
// Find result from parsed messages or use text output
const resultMsg = this._messages.find(m => m.type === 'result');
if (resultMsg && !resultMsg.is_error) {
this._status = 'idle';
const cost = resultMsg.total_cost_usd || 0;
this._totalCost += cost;
this.emit('completion', resultMsg.result || '', cost);
if (resolve) {
resolve({ result: resultMsg.result || '', cost });
}
} else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) {
this._status = 'error';
if (reject) {
reject(new Error(this._errorBuffer || this._textOutput.value || 'Process exited with error'));
}
} else {
this._status = 'idle';
if (resolve) {
resolve({ result: this._textOutput.value || this._terminalBuffer.value, cost: this._totalCost });
}
}
this.emit('exit', exitCode);
});
} catch (err) {
this._status = 'error';
reject(err);
}
});
}
private processOutput(data: string): void {
// Try to extract JSON from output (Claude may output JSON in stream mode)
this._lineBuffer += data;
// Prevent unbounded line buffer growth for very long lines
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
// Force flush the oversized buffer as text output
this._textOutput.append(this._lineBuffer + '\n');
this._lineBuffer = '';
}
// Start flush timer if not running (handles partial lines after 100ms)
if (!this._lineBufferFlushTimer && this._lineBuffer.length > 0 && !this._isStopped) {
this._lineBufferFlushTimer = setTimeout(() => {
this._lineBufferFlushTimer = null;
if (this._lineBuffer.length > 0 && !this._isStopped) {
// Flush partial line as text output
this._textOutput.append(this._lineBuffer);
this._lineBuffer = '';
}
}, LINE_BUFFER_FLUSH_INTERVAL);
}
const lines = this._lineBuffer.split('\n');
this._lineBuffer = lines.pop() || '';
// Clear flush timer if buffer is now empty
if (this._lineBuffer.length === 0 && this._lineBufferFlushTimer) {
clearTimeout(this._lineBufferFlushTimer);
this._lineBufferFlushTimer = null;
}
for (const line of lines) {
const trimmed = line.trim();
// Remove ANSI escape codes for JSON parsing (use pre-compiled pattern)
const cleanLine = trimmed.replace(ANSI_ESCAPE_PATTERN, '');
if (cleanLine.startsWith('{') && cleanLine.endsWith('}')) {
try {
const msg = JSON.parse(cleanLine) as ClaudeMessage;
this._messages.push(msg);
this.emit('message', msg);
// Trim messages array for long-running sessions
if (this._messages.length > MAX_MESSAGES) {
this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8));
}
if (msg.type === 'system' && msg.session_id) {
this._claudeSessionId = msg.session_id;
}
// Process message for task tracking
this._taskTracker.processMessage(msg);
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
this._textOutput.append(block.text);
}
}
// Track tokens from usage
if (msg.message.usage) {
this._totalInputTokens += msg.message.usage.input_tokens || 0;
this._totalOutputTokens += msg.message.usage.output_tokens || 0;
// Check if we should auto-compact or auto-clear
this.checkAutoCompact();
this.checkAutoClear();
}
}
if (msg.type === 'result' && msg.total_cost_usd) {
this._totalCost = msg.total_cost_usd;
}
} catch {
// Not JSON, just regular output
this._textOutput.append(line + '\n');
}
} else if (trimmed) {
this._textOutput.append(line + '\n');
}
}
// Note: BufferAccumulator auto-trims when max size exceeded
}
// Parse token count from Claude's status line in interactive mode
// Matches patterns like "123.4k tokens", "5234 tokens", "1.2M tokens"
private parseTokensFromStatusLine(data: string): void {
// Quick pre-check: skip expensive regex if "token" not present (performance optimization)
if (!data.includes('token')) return;
// Remove ANSI escape codes for cleaner parsing (use pre-compiled pattern)
const cleanData = data.replace(ANSI_ESCAPE_PATTERN, '');
// Match patterns: "123.4k tokens", "5234 tokens", "1.2M tokens"
// The status line typically shows total tokens like "1.2k tokens" near the prompt
const tokenMatch = cleanData.match(TOKEN_PATTERN);
if (tokenMatch) {
let tokenCount = parseFloat(tokenMatch[1]);
const suffix = tokenMatch[2]?.toLowerCase();
// Convert k/M suffix to actual number
if (suffix === 'k') {
tokenCount *= 1000;
} else if (suffix === 'm') {
tokenCount *= 1000000;
}
// Only update if the new count is higher (tokens only increase within a session)
// We use total tokens as an estimate - Claude shows combined input+output
const currentTotal = this._totalInputTokens + this._totalOutputTokens;
if (tokenCount > currentTotal) {
// Estimate: split roughly 60% input, 40% output (common ratio)
// This is an approximation since interactive mode doesn't give us the breakdown
const delta = tokenCount - currentTotal;
this._totalInputTokens += Math.round(delta * 0.6);
this._totalOutputTokens += Math.round(delta * 0.4);
// Check if we should auto-compact or auto-clear
this.checkAutoCompact();
this.checkAutoClear();
}
}
}
// Check if we should auto-compact based on token threshold
private checkAutoCompact(): void {
if (!this._autoCompactEnabled || this._isCompacting || this._isClearing || this._isStopped) return;
const totalTokens = this._totalInputTokens + this._totalOutputTokens;
if (totalTokens >= this._autoCompactThreshold) {
this._isCompacting = true;
console.log(`[Session] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`);
// Wait for Claude to be idle before compacting
const checkAndCompact = () => {
// Check if session is still valid (not stopped)
if (!this._isCompacting || this._isStopped) return;
if (!this._isWorking) {
// Send /compact command with optional prompt
const compactCmd = this._autoCompactPrompt
? `/compact ${this._autoCompactPrompt}\r`
: '/compact\r';
this.writeViaScreen(compactCmd);
this.emit('autoCompact', {
tokens: totalTokens,
threshold: this._autoCompactThreshold,
prompt: this._autoCompactPrompt || undefined
});
// Wait a moment then re-enable (longer than clear since compact takes time)
if (!this._isStopped) {
this._autoCompactTimer = setTimeout(() => {
this._autoCompactTimer = null;
this._isCompacting = false;
}, 10000);
}
} else {
// Check again in 2 seconds
if (!this._isStopped) {
this._autoCompactTimer = setTimeout(checkAndCompact, 2000);
}
}
};
// Start checking after a short delay
if (!this._isStopped) {
this._autoCompactTimer = setTimeout(checkAndCompact, 1000);
}
}
}
// Check if we should auto-clear based on token threshold
private checkAutoClear(): void {
if (!this._autoClearEnabled || this._isClearing || this._isCompacting || this._isStopped) return;
const totalTokens = this._totalInputTokens + this._totalOutputTokens;
if (totalTokens >= this._autoClearThreshold) {
this._isClearing = true;
console.log(`[Session] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`);
// Wait for Claude to be idle before clearing
const checkAndClear = () => {
// Check if session is still valid (not stopped)
if (!this._isClearing || this._isStopped) return;
if (!this._isWorking) {
// Send /clear command
this.writeViaScreen('/clear\r');
// Reset token counts
this._totalInputTokens = 0;
this._totalOutputTokens = 0;
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
// Wait a moment then re-enable
if (!this._isStopped) {
this._autoClearTimer = setTimeout(() => {
this._autoClearTimer = null;
this._isClearing = false;
}, 5000);
}
} else {
// Check again in 2 seconds
if (!this._isStopped) {
this._autoClearTimer = setTimeout(checkAndClear, 2000);
}
}
};
// Start checking after a short delay
if (!this._isStopped) {
this._autoClearTimer = setTimeout(checkAndClear, 1000);
}
}
}
/**
* Sends input directly to the PTY process.
*
* For interactive sessions, this is how you send user input to Claude.
* Remember to include `\r` (carriage return) to simulate pressing Enter.
*
* @param data - The input data to send (text, escape sequences, etc.)
*
* @example
* ```typescript
* session.write('hello world'); // Text only, no Enter
* session.write('\r'); // Enter key
* session.write('ls -la\r'); // Command with Enter
* ```
*/
write(data: string): void {
if (this.ptyProcess) {
this.ptyProcess.write(data);
}
}
/**
* Sends input via GNU Screen's `screen -X stuff` command.
*
* More reliable than direct PTY write for programmatic input, especially
* with Claude CLI which uses Ink (React for terminals). Text and Enter
* are sent as separate commands internally.
*
* @param data - Input data with optional `\r` for Enter
* @returns true if input was sent, false if no screen session or PTY
*
* @example
* ```typescript
* session.writeViaScreen('/clear\r'); // Send /clear command
* session.writeViaScreen('/init\r'); // Send /init command
* ```
*/
writeViaScreen(data: string): boolean {
if (this._screenManager && this._screenSession) {
return this._screenManager.sendInput(this.id, data);
}
// Fallback to PTY write
if (this.ptyProcess) {
this.ptyProcess.write(data);
return true;
}
return false;
}
/**
* Resizes the PTY terminal dimensions.
*
* Call this when the frontend terminal is resized to keep PTY in sync.
*
* @param cols - Number of columns (width in characters)
* @param rows - Number of rows (height in lines)
*/
resize(cols: number, rows: number): void {
if (this.ptyProcess) {
this.ptyProcess.resize(cols, rows);
}
}
// Legacy method for compatibility with session-manager
async start(): Promise<void> {
this._status = 'idle';
}
// Legacy method for sending input - wraps runPrompt
async sendInput(input: string): Promise<void> {
this._status = 'busy';
this._lastActivityAt = Date.now();
this.runPrompt(input).catch(err => {
this.emit('error', err.message);
});
}
/**
* Remove event listeners from TaskTracker and RalphTracker.
* Prevents memory leaks by ensuring handlers don't persist after session stop.
*/
private cleanupTrackerListeners(): void {
// Remove TaskTracker handlers
if (this._taskTrackerHandlers) {
this._taskTracker.off('taskCreated', this._taskTrackerHandlers.taskCreated);
this._taskTracker.off('taskUpdated', this._taskTrackerHandlers.taskUpdated);
this._taskTracker.off('taskCompleted', this._taskTrackerHandlers.taskCompleted);
this._taskTracker.off('taskFailed', this._taskTrackerHandlers.taskFailed);
this._taskTrackerHandlers = null;
}
// Remove RalphTracker handlers
if (this._ralphHandlers) {
this._ralphTracker.off('loopUpdate', this._ralphHandlers.loopUpdate);
this._ralphTracker.off('todoUpdate', this._ralphHandlers.todoUpdate);
this._ralphTracker.off('completionDetected', this._ralphHandlers.completionDetected);
this._ralphHandlers = null;
}
// Remove BashToolParser handlers
if (this._bashToolHandlers) {
this._bashToolParser.off('toolStart', this._bashToolHandlers.toolStart);
this._bashToolParser.off('toolEnd', this._bashToolHandlers.toolEnd);
this._bashToolParser.off('toolsUpdate', this._bashToolHandlers.toolsUpdate);
this._bashToolHandlers = null;
}
this._bashToolParser.destroy();
}
/**
* Stops the session and cleans up resources.
*
* This kills the PTY process and optionally the associated GNU Screen
* session. All buffers are cleared and the session is marked as stopped.
*
* @param killScreen - Whether to also kill the screen session (default: true)
*
* @example
* ```typescript
* // Stop and kill everything
* await session.stop();
*
* // Stop but keep screen running for later reattachment
* await session.stop(false);
* ```
*/
async stop(killScreen: boolean = true): Promise<void> {
// Set stopped flag first to prevent new timers from being created
this._isStopped = true;
// Clear activity timeout to prevent memory leak
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
this.activityTimeout = null;
}
// Clear line buffer flush timer
if (this._lineBufferFlushTimer) {
clearTimeout(this._lineBufferFlushTimer);
this._lineBufferFlushTimer = null;
}
// Clear auto-compact/auto-clear timers to prevent memory leaks
if (this._autoCompactTimer) {
clearTimeout(this._autoCompactTimer);
this._autoCompactTimer = null;
}
this._isCompacting = false;
if (this._autoClearTimer) {
clearTimeout(this._autoClearTimer);
this._autoClearTimer = null;
}
this._isClearing = false;
// Clear prompt check timers
if (this._promptCheckInterval) {
clearInterval(this._promptCheckInterval);
this._promptCheckInterval = null;
}
if (this._promptCheckTimeout) {
clearTimeout(this._promptCheckTimeout);
this._promptCheckTimeout = null;
}
// Clear shell idle timer
if (this._shellIdleTimer) {
clearTimeout(this._shellIdleTimer);
this._shellIdleTimer = null;
}
// Immediately cleanup Promise callbacks to prevent orphaned references
// during the rest of stop() processing (e.g., if screen kill times out)
if (this.rejectPromise) {
this.rejectPromise(new Error('Session stopped'));
}
this.resolvePromise = null;
this.rejectPromise = null;
// Remove event listeners from trackers to prevent memory leaks
this.cleanupTrackerListeners();
if (this.ptyProcess) {
const pid = this.ptyProcess.pid;
// First try graceful SIGTERM
try {
this.ptyProcess.kill();
} catch {
// Process may already be dead
}
// Give it a moment to terminate gracefully
await new Promise(resolve => setTimeout(resolve, 100));
// Force kill with SIGKILL if still alive
try {
if (pid) {
process.kill(pid, 'SIGKILL');
}
} catch {
// Process already terminated
}
// Also try to kill any child processes in the process group
try {
if (pid) {
process.kill(-pid, 'SIGKILL');
}
} catch {
// Process group may not exist or already terminated
}
this.ptyProcess = null;
}
this._pid = null;
this._status = 'stopped';
this._currentTaskId = null;
// Kill the associated screen session if requested
if (killScreen && this._screenManager) {
// Try to kill screen even if _screenSession is not set (e.g., restored sessions)
try {
const killed = await this._screenManager.killScreen(this.id);
if (killed) {
console.log('[Session] Killed screen session for:', this.id);
}
} catch (err) {
console.error('[Session] Failed to kill screen session:', err);
}
this._screenSession = null;
} else if (this._screenSession && !killScreen) {
console.log('[Session] Keeping screen session alive:', this._screenSession.screenName);
this._screenSession = null; // Detach but don't kill
}
}
assignTask(taskId: string): void {
this._currentTaskId = taskId;
this._status = 'busy';
this._terminalBuffer.clear();
this._outputBuffer = '';
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lastActivityAt = Date.now();
}
clearTask(): void {
this._currentTaskId = null;
this._status = 'idle';
this._lastActivityAt = Date.now();
}
getOutput(): string {
return this._textOutput.value;
}
getError(): string {
return this._errorBuffer;
}
getTerminalBuffer(): string {
return this._terminalBuffer.value;
}
clearBuffers(): void {
this._terminalBuffer.clear();
this._outputBuffer = '';
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._taskTracker.clear();
this._ralphTracker.clear();
}
}