2026-01-18 04:50:46 +01:00
|
|
|
|
import { EventEmitter } from 'node:events';
|
|
|
|
|
|
import { v4 as uuidv4 } from 'uuid';
|
2026-01-18 21:36:03 +01:00
|
|
|
|
import * as pty from 'node-pty';
|
2026-01-19 03:40:57 +01:00
|
|
|
|
import { execSync } from 'node:child_process';
|
2026-01-19 13:21:01 +01:00
|
|
|
|
import { SessionState, SessionStatus, SessionConfig, ScreenSession, InnerLoopState, InnerTodoItem } from './types.js';
|
2026-01-19 00:38:11 +01:00
|
|
|
|
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
2026-01-19 13:21:01 +01:00
|
|
|
|
import { InnerLoopTracker } from './inner-loop-tracker.js';
|
2026-01-19 03:40:57 +01:00
|
|
|
|
import { ScreenManager } from './screen-manager.js';
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
|
|
|
|
|
export type { BackgroundTask } from './task-tracker.js';
|
2026-01-19 13:21:01 +01:00
|
|
|
|
export type { InnerLoopState, InnerTodoItem } from './types.js';
|
2026-01-18 04:50:46 +01:00
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// Maximum terminal buffer size in characters (default 5MB of text)
|
|
|
|
|
|
const MAX_TERMINAL_BUFFER_SIZE = 5 * 1024 * 1024;
|
|
|
|
|
|
// When trimming, keep the most recent portion (4MB)
|
|
|
|
|
|
const TERMINAL_BUFFER_TRIM_SIZE = 4 * 1024 * 1024;
|
|
|
|
|
|
// Maximum text output buffer size (2MB)
|
|
|
|
|
|
const MAX_TEXT_OUTPUT_SIZE = 2 * 1024 * 1024;
|
|
|
|
|
|
const TEXT_OUTPUT_TRIM_SIZE = 1.5 * 1024 * 1024;
|
|
|
|
|
|
// Maximum number of Claude messages to keep in memory
|
|
|
|
|
|
const MAX_MESSAGES = 1000;
|
2026-01-19 06:51:25 +01:00
|
|
|
|
// 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;
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
2026-01-19 02:04:30 +01:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-01-19 11:48:02 +01:00
|
|
|
|
// Pre-compiled regex patterns for performance (avoid re-compilation on each call)
|
|
|
|
|
|
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g;
|
|
|
|
|
|
const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/;
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
export interface ClaudeMessage {
|
|
|
|
|
|
type: 'system' | 'assistant' | 'user' | 'result';
|
|
|
|
|
|
subtype?: string;
|
|
|
|
|
|
session_id?: string;
|
|
|
|
|
|
message?: {
|
|
|
|
|
|
content: Array<{ type: string; text?: string }>;
|
|
|
|
|
|
usage?: {
|
|
|
|
|
|
input_tokens: number;
|
|
|
|
|
|
output_tokens: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
result?: string;
|
|
|
|
|
|
is_error?: boolean;
|
|
|
|
|
|
total_cost_usd?: number;
|
|
|
|
|
|
duration_ms?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 04:50:46 +01:00
|
|
|
|
export interface SessionEvents {
|
|
|
|
|
|
output: (data: string) => void;
|
2026-01-18 05:14:34 +01:00
|
|
|
|
message: (msg: ClaudeMessage) => void;
|
2026-01-18 04:50:46 +01:00
|
|
|
|
error: (data: string) => void;
|
|
|
|
|
|
exit: (code: number | null) => void;
|
2026-01-18 05:14:34 +01:00
|
|
|
|
completion: (result: string, cost: number) => void;
|
2026-01-18 21:36:03 +01:00
|
|
|
|
terminal: (data: string) => void; // Raw terminal data
|
2026-01-19 07:33:33 +01:00
|
|
|
|
clearTerminal: () => void; // Signal client to clear terminal (after screen attach)
|
2026-01-19 00:38:11 +01:00
|
|
|
|
// Background task events
|
|
|
|
|
|
taskCreated: (task: BackgroundTask) => void;
|
|
|
|
|
|
taskUpdated: (task: BackgroundTask) => void;
|
|
|
|
|
|
taskCompleted: (task: BackgroundTask) => void;
|
|
|
|
|
|
taskFailed: (task: BackgroundTask, error: string) => void;
|
|
|
|
|
|
// Auto-clear event
|
|
|
|
|
|
autoClear: (data: { tokens: number; threshold: number }) => void;
|
2026-01-19 13:08:53 +01:00
|
|
|
|
// Auto-compact event
|
|
|
|
|
|
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
|
2026-01-19 13:21:01 +01:00
|
|
|
|
// Inner loop tracking events (Claude Code running inside this session)
|
|
|
|
|
|
innerLoopUpdate: (state: InnerLoopState) => void;
|
|
|
|
|
|
innerTodoUpdate: (todos: InnerTodoItem[]) => void;
|
|
|
|
|
|
innerCompletionDetected: (phrase: string) => void;
|
2026-01-18 04:50:46 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 01:26:38 +01:00
|
|
|
|
export type SessionMode = 'claude' | 'shell';
|
|
|
|
|
|
|
2026-01-18 04:50:46 +01:00
|
|
|
|
export class Session extends EventEmitter {
|
|
|
|
|
|
readonly id: string;
|
|
|
|
|
|
readonly workingDir: string;
|
|
|
|
|
|
readonly createdAt: number;
|
2026-01-19 01:26:38 +01:00
|
|
|
|
readonly mode: SessionMode;
|
2026-01-18 04:50:46 +01:00
|
|
|
|
|
2026-01-19 01:26:38 +01:00
|
|
|
|
private _name: string;
|
2026-01-18 21:36:03 +01:00
|
|
|
|
private ptyProcess: pty.IPty | null = null;
|
|
|
|
|
|
private _pid: number | null = null;
|
2026-01-18 04:50:46 +01:00
|
|
|
|
private _status: SessionStatus = 'idle';
|
|
|
|
|
|
private _currentTaskId: string | null = null;
|
2026-01-18 21:36:03 +01:00
|
|
|
|
private _terminalBuffer: string = ''; // Raw terminal output
|
2026-01-18 04:50:46 +01:00
|
|
|
|
private _outputBuffer: string = '';
|
2026-01-18 05:14:34 +01:00
|
|
|
|
private _textOutput: string = '';
|
2026-01-18 04:50:46 +01:00
|
|
|
|
private _errorBuffer: string = '';
|
|
|
|
|
|
private _lastActivityAt: number;
|
2026-01-18 05:14:34 +01:00
|
|
|
|
private _claudeSessionId: string | null = null;
|
|
|
|
|
|
private _totalCost: number = 0;
|
|
|
|
|
|
private _messages: ClaudeMessage[] = [];
|
|
|
|
|
|
private _lineBuffer: string = '';
|
2026-01-19 06:51:25 +01:00
|
|
|
|
private _lineBufferFlushTimer: NodeJS.Timeout | null = null;
|
2026-01-18 21:36:03 +01:00
|
|
|
|
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
|
|
|
|
|
private rejectPromise: ((reason: Error) => void) | null = null;
|
|
|
|
|
|
private _isWorking: boolean = false;
|
|
|
|
|
|
private _lastPromptTime: number = 0;
|
|
|
|
|
|
private activityTimeout: NodeJS.Timeout | null = null;
|
2026-01-19 00:38:11 +01:00
|
|
|
|
private _taskTracker: TaskTracker;
|
|
|
|
|
|
|
|
|
|
|
|
// Token tracking for auto-clear
|
|
|
|
|
|
private _totalInputTokens: number = 0;
|
|
|
|
|
|
private _totalOutputTokens: number = 0;
|
2026-01-19 13:08:53 +01:00
|
|
|
|
private _autoClearThreshold: number = 140000; // Default 140k tokens
|
2026-01-19 00:38:11 +01:00
|
|
|
|
private _autoClearEnabled: boolean = false;
|
|
|
|
|
|
private _isClearing: boolean = false; // Prevent recursive clearing
|
2026-01-18 04:50:46 +01:00
|
|
|
|
|
2026-01-19 13:08:53 +01:00
|
|
|
|
// 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
|
|
|
|
|
|
|
2026-01-19 03:40:57 +01:00
|
|
|
|
// Screen session support
|
|
|
|
|
|
private _screenManager: ScreenManager | null = null;
|
|
|
|
|
|
private _screenSession: ScreenSession | null = null;
|
|
|
|
|
|
private _useScreen: boolean = false;
|
|
|
|
|
|
|
2026-01-19 13:21:01 +01:00
|
|
|
|
// Inner loop tracking (Ralph Wiggum loops and todo lists inside Claude Code)
|
|
|
|
|
|
private _innerLoopTracker: InnerLoopTracker;
|
|
|
|
|
|
|
2026-01-19 03:40:57 +01:00
|
|
|
|
constructor(config: Partial<SessionConfig> & {
|
|
|
|
|
|
workingDir: string;
|
|
|
|
|
|
mode?: SessionMode;
|
|
|
|
|
|
name?: string;
|
|
|
|
|
|
screenManager?: ScreenManager;
|
|
|
|
|
|
useScreen?: boolean;
|
|
|
|
|
|
}) {
|
2026-01-18 04:50:46 +01:00
|
|
|
|
super();
|
|
|
|
|
|
this.id = config.id || uuidv4();
|
|
|
|
|
|
this.workingDir = config.workingDir;
|
|
|
|
|
|
this.createdAt = config.createdAt || Date.now();
|
2026-01-19 01:26:38 +01:00
|
|
|
|
this.mode = config.mode || 'claude';
|
|
|
|
|
|
this._name = config.name || '';
|
2026-01-18 04:50:46 +01:00
|
|
|
|
this._lastActivityAt = this.createdAt;
|
2026-01-19 03:40:57 +01:00
|
|
|
|
this._screenManager = config.screenManager || null;
|
|
|
|
|
|
this._useScreen = config.useScreen ?? (this._screenManager !== null && ScreenManager.isScreenAvailable());
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
|
|
|
|
|
// Initialize task tracker and forward events
|
|
|
|
|
|
this._taskTracker = new TaskTracker();
|
|
|
|
|
|
this._taskTracker.on('taskCreated', (task) => this.emit('taskCreated', task));
|
|
|
|
|
|
this._taskTracker.on('taskUpdated', (task) => this.emit('taskUpdated', task));
|
|
|
|
|
|
this._taskTracker.on('taskCompleted', (task) => this.emit('taskCompleted', task));
|
|
|
|
|
|
this._taskTracker.on('taskFailed', (task, error) => this.emit('taskFailed', task, error));
|
2026-01-19 13:21:01 +01:00
|
|
|
|
|
|
|
|
|
|
// Initialize inner loop tracker and forward events
|
|
|
|
|
|
this._innerLoopTracker = new InnerLoopTracker();
|
|
|
|
|
|
this._innerLoopTracker.on('loopUpdate', (state) => this.emit('innerLoopUpdate', state));
|
|
|
|
|
|
this._innerLoopTracker.on('todoUpdate', (todos) => this.emit('innerTodoUpdate', todos));
|
|
|
|
|
|
this._innerLoopTracker.on('completionDetected', (phrase) => this.emit('innerCompletionDetected', phrase));
|
2026-01-18 04:50:46 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get status(): SessionStatus {
|
|
|
|
|
|
return this._status;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get currentTaskId(): string | null {
|
|
|
|
|
|
return this._currentTaskId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get pid(): number | null {
|
2026-01-18 21:36:03 +01:00
|
|
|
|
return this._pid;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get terminalBuffer(): string {
|
|
|
|
|
|
return this._terminalBuffer;
|
2026-01-18 04:50:46 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get outputBuffer(): string {
|
|
|
|
|
|
return this._outputBuffer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
get textOutput(): string {
|
|
|
|
|
|
return this._textOutput;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 04:50:46 +01:00
|
|
|
|
get errorBuffer(): string {
|
|
|
|
|
|
return this._errorBuffer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get lastActivityAt(): number {
|
|
|
|
|
|
return this._lastActivityAt;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
get claudeSessionId(): string | null {
|
|
|
|
|
|
return this._claudeSessionId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get totalCost(): number {
|
|
|
|
|
|
return this._totalCost;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get messages(): ClaudeMessage[] {
|
|
|
|
|
|
return this._messages;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
get isWorking(): boolean {
|
|
|
|
|
|
return this._isWorking;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get lastPromptTime(): number {
|
|
|
|
|
|
return this._lastPromptTime;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:38:11 +01:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 13:21:01 +01:00
|
|
|
|
// Inner loop tracking getters
|
|
|
|
|
|
get innerLoopTracker(): InnerLoopTracker {
|
|
|
|
|
|
return this._innerLoopTracker;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get innerLoopState(): InnerLoopState {
|
|
|
|
|
|
return this._innerLoopTracker.loopState;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get innerTodos(): InnerTodoItem[] {
|
|
|
|
|
|
return this._innerLoopTracker.todos;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get innerTodoStats(): { total: number; pending: number; inProgress: number; completed: number } {
|
|
|
|
|
|
return this._innerLoopTracker.getTodoStats();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:38:11 +01:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get autoClearThreshold(): number {
|
|
|
|
|
|
return this._autoClearThreshold;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get autoClearEnabled(): boolean {
|
|
|
|
|
|
return this._autoClearEnabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 01:26:38 +01:00
|
|
|
|
get name(): string {
|
|
|
|
|
|
return this._name;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
set name(value: string) {
|
|
|
|
|
|
this._name = value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:38:11 +01:00
|
|
|
|
setAutoClear(enabled: boolean, threshold?: number): void {
|
|
|
|
|
|
this._autoClearEnabled = enabled;
|
|
|
|
|
|
if (threshold !== undefined) {
|
|
|
|
|
|
this._autoClearThreshold = threshold;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 13:08:53 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 04:50:46 +01:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
toDetailedState() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...this.toState(),
|
2026-01-19 01:26:38 +01:00
|
|
|
|
name: this._name,
|
|
|
|
|
|
mode: this.mode,
|
2026-01-18 05:14:34 +01:00
|
|
|
|
claudeSessionId: this._claudeSessionId,
|
|
|
|
|
|
totalCost: this._totalCost,
|
|
|
|
|
|
textOutput: this._textOutput,
|
2026-01-18 21:36:03 +01:00
|
|
|
|
terminalBuffer: this._terminalBuffer,
|
2026-01-18 05:14:34 +01:00
|
|
|
|
messageCount: this._messages.length,
|
2026-01-18 21:36:03 +01:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
2026-01-19 00:38:11 +01:00
|
|
|
|
// 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,
|
|
|
|
|
|
},
|
2026-01-19 13:21:01 +01:00
|
|
|
|
// Inner loop tracking state
|
|
|
|
|
|
innerLoop: this._innerLoopTracker.loopState,
|
|
|
|
|
|
innerTodos: this._innerLoopTracker.todos,
|
|
|
|
|
|
innerTodoStats: this._innerLoopTracker.getTodoStats(),
|
2026-01-18 05:14:34 +01:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// Start an interactive Claude Code session (full terminal)
|
|
|
|
|
|
async startInteractive(): Promise<void> {
|
|
|
|
|
|
if (this.ptyProcess) {
|
|
|
|
|
|
throw new Error('Session already has a running process');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this._status = 'busy';
|
|
|
|
|
|
this._terminalBuffer = '';
|
|
|
|
|
|
this._outputBuffer = '';
|
|
|
|
|
|
this._textOutput = '';
|
|
|
|
|
|
this._errorBuffer = '';
|
|
|
|
|
|
this._messages = [];
|
|
|
|
|
|
this._lineBuffer = '';
|
|
|
|
|
|
this._lastActivityAt = Date.now();
|
|
|
|
|
|
|
2026-01-19 03:40:57 +01:00
|
|
|
|
console.log('[Session] Starting interactive Claude session' + (this._useScreen ? ' (with screen)' : ''));
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
2026-01-19 03:40:57 +01:00
|
|
|
|
// If screen wrapping is enabled, create a screen session first
|
|
|
|
|
|
if (this._useScreen && this._screenManager) {
|
|
|
|
|
|
try {
|
2026-01-19 04:22:41 +01:00
|
|
|
|
this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'claude', this._name);
|
2026-01-19 03:40:57 +01:00
|
|
|
|
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' },
|
|
|
|
|
|
});
|
2026-01-19 07:33:33 +01:00
|
|
|
|
|
|
|
|
|
|
// Screen creates blank space when initializing. After attaching, wait for
|
|
|
|
|
|
// the initial burst then clear the buffer and tell clients to clear their terminal.
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this._terminalBuffer = '';
|
|
|
|
|
|
this.emit('clearTerminal');
|
|
|
|
|
|
}, 100);
|
2026-01-19 03:40:57 +01:00
|
|
|
|
} 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, TERM: 'xterm-256color' },
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
|
|
|
|
|
this._pid = this.ptyProcess.pid;
|
|
|
|
|
|
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
|
|
|
|
|
|
|
2026-01-19 02:04:30 +01:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
this._terminalBuffer += data;
|
|
|
|
|
|
this._lastActivityAt = Date.now();
|
|
|
|
|
|
|
|
|
|
|
|
// Trim buffer if it exceeds max size to prevent memory issues
|
|
|
|
|
|
if (this._terminalBuffer.length > MAX_TERMINAL_BUFFER_SIZE) {
|
|
|
|
|
|
this._terminalBuffer = this._terminalBuffer.slice(-TERMINAL_BUFFER_TRIM_SIZE);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.emit('terminal', data);
|
|
|
|
|
|
this.emit('output', data);
|
|
|
|
|
|
|
2026-01-19 13:21:01 +01:00
|
|
|
|
// Forward to inner loop tracker to detect Ralph loops and todos
|
|
|
|
|
|
this._innerLoopTracker.processTerminalData(data);
|
|
|
|
|
|
|
2026-01-19 04:42:13 +01:00
|
|
|
|
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
|
|
|
|
|
|
this.parseTokensFromStatusLine(data);
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// 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';
|
2026-01-19 03:40:57 +01:00
|
|
|
|
// If using screen, mark the screen as detached but don't kill it
|
|
|
|
|
|
if (this._screenSession && this._screenManager) {
|
|
|
|
|
|
this._screenManager.setAttached(this.id, false);
|
|
|
|
|
|
}
|
2026-01-18 21:36:03 +01:00
|
|
|
|
this.emit('exit', exitCode);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 01:26:38 +01:00
|
|
|
|
// Start a plain shell session (bash/zsh without Claude)
|
|
|
|
|
|
async startShell(): Promise<void> {
|
|
|
|
|
|
if (this.ptyProcess) {
|
|
|
|
|
|
throw new Error('Session already has a running process');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this._status = 'busy';
|
|
|
|
|
|
this._terminalBuffer = '';
|
|
|
|
|
|
this._outputBuffer = '';
|
|
|
|
|
|
this._textOutput = '';
|
|
|
|
|
|
this._errorBuffer = '';
|
|
|
|
|
|
this._messages = [];
|
|
|
|
|
|
this._lineBuffer = '';
|
|
|
|
|
|
this._lastActivityAt = Date.now();
|
|
|
|
|
|
|
|
|
|
|
|
// Use user's default shell or bash
|
|
|
|
|
|
const shell = process.env.SHELL || '/bin/bash';
|
2026-01-19 03:40:57 +01:00
|
|
|
|
console.log('[Session] Starting shell session with:', shell + (this._useScreen ? ' (with screen)' : ''));
|
2026-01-19 01:26:38 +01:00
|
|
|
|
|
2026-01-19 03:40:57 +01:00
|
|
|
|
// If screen wrapping is enabled, create a screen session first
|
|
|
|
|
|
if (this._useScreen && this._screenManager) {
|
|
|
|
|
|
try {
|
2026-01-19 04:22:41 +01:00
|
|
|
|
this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'shell', this._name);
|
2026-01-19 03:40:57 +01:00
|
|
|
|
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' },
|
|
|
|
|
|
});
|
2026-01-19 07:33:33 +01:00
|
|
|
|
|
|
|
|
|
|
// Screen creates blank space when initializing. After attaching, wait for
|
|
|
|
|
|
// the initial burst then clear by sending 'clear' command to the shell.
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (this.ptyProcess) {
|
|
|
|
|
|
this._terminalBuffer = '';
|
|
|
|
|
|
this.ptyProcess.write('clear\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 100);
|
2026-01-19 03:40:57 +01:00
|
|
|
|
} 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' },
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-01-19 01:26:38 +01:00
|
|
|
|
|
|
|
|
|
|
this._pid = this.ptyProcess.pid;
|
|
|
|
|
|
console.log('[Session] Shell PTY spawned with PID:', this._pid);
|
|
|
|
|
|
|
2026-01-19 02:04:30 +01:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-01-19 01:26:38 +01:00
|
|
|
|
this._terminalBuffer += data;
|
|
|
|
|
|
this._lastActivityAt = Date.now();
|
|
|
|
|
|
|
|
|
|
|
|
// Trim buffer if it exceeds max size
|
|
|
|
|
|
if (this._terminalBuffer.length > MAX_TERMINAL_BUFFER_SIZE) {
|
|
|
|
|
|
this._terminalBuffer = this._terminalBuffer.slice(-TERMINAL_BUFFER_TRIM_SIZE);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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';
|
2026-01-19 03:40:57 +01:00
|
|
|
|
// If using screen, mark the screen as detached but don't kill it
|
|
|
|
|
|
if (this._screenSession && this._screenManager) {
|
|
|
|
|
|
this._screenManager.setAttached(this.id, false);
|
|
|
|
|
|
}
|
2026-01-19 01:26:38 +01:00
|
|
|
|
this.emit('exit', exitCode);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Mark as idle after a short delay (shell is ready)
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this._status = 'idle';
|
|
|
|
|
|
this._isWorking = false;
|
|
|
|
|
|
this.emit('idle');
|
|
|
|
|
|
}, 500);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
async runPrompt(prompt: string): Promise<{ result: string; cost: number }> {
|
2026-01-18 04:50:46 +01:00
|
|
|
|
return new Promise((resolve, reject) => {
|
2026-01-18 21:36:03 +01:00
|
|
|
|
if (this.ptyProcess) {
|
2026-01-18 05:14:34 +01:00
|
|
|
|
reject(new Error('Session already has a running process'));
|
2026-01-18 04:50:46 +01:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this._status = 'busy';
|
2026-01-18 21:36:03 +01:00
|
|
|
|
this._terminalBuffer = '';
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this._outputBuffer = '';
|
|
|
|
|
|
this._textOutput = '';
|
|
|
|
|
|
this._errorBuffer = '';
|
|
|
|
|
|
this._messages = [];
|
|
|
|
|
|
this._lineBuffer = '';
|
|
|
|
|
|
this._lastActivityAt = Date.now();
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
this.resolvePromise = resolve;
|
|
|
|
|
|
this.rejectPromise = reject;
|
|
|
|
|
|
|
2026-01-18 04:50:46 +01:00
|
|
|
|
try {
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// Spawn claude in a real PTY
|
|
|
|
|
|
console.log('[Session] Spawning PTY for claude with prompt:', prompt.substring(0, 50));
|
|
|
|
|
|
|
|
|
|
|
|
this.ptyProcess = pty.spawn('claude', [
|
2026-01-18 05:14:34 +01:00
|
|
|
|
'-p',
|
2026-01-18 21:36:03 +01:00
|
|
|
|
'--dangerously-skip-permissions',
|
2026-01-19 04:42:13 +01:00
|
|
|
|
'--output-format', 'stream-json',
|
2026-01-18 05:14:34 +01:00
|
|
|
|
prompt
|
|
|
|
|
|
], {
|
2026-01-18 21:36:03 +01:00
|
|
|
|
name: 'xterm-256color',
|
|
|
|
|
|
cols: 120,
|
|
|
|
|
|
rows: 40,
|
2026-01-18 04:50:46 +01:00
|
|
|
|
cwd: this.workingDir,
|
2026-01-18 21:36:03 +01:00
|
|
|
|
env: { ...process.env, TERM: 'xterm-256color' },
|
2026-01-18 04:50:46 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
this._pid = this.ptyProcess.pid;
|
|
|
|
|
|
console.log('[Session] PTY spawned with PID:', this._pid);
|
|
|
|
|
|
|
|
|
|
|
|
// Handle terminal data
|
2026-01-19 02:04:30 +01:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
this._terminalBuffer += data;
|
2026-01-18 04:50:46 +01:00
|
|
|
|
this._lastActivityAt = Date.now();
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
|
|
|
|
|
// Trim buffer if it exceeds max size to prevent memory issues
|
|
|
|
|
|
if (this._terminalBuffer.length > MAX_TERMINAL_BUFFER_SIZE) {
|
|
|
|
|
|
this._terminalBuffer = this._terminalBuffer.slice(-TERMINAL_BUFFER_TRIM_SIZE);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.emit('terminal', data);
|
|
|
|
|
|
this.emit('output', data);
|
|
|
|
|
|
|
|
|
|
|
|
// Also try to parse JSON lines for structured data
|
|
|
|
|
|
this.processOutput(data);
|
2026-01-18 04:50:46 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// Handle exit
|
|
|
|
|
|
this.ptyProcess.onExit(({ exitCode }) => {
|
|
|
|
|
|
console.log('[Session] PTY exited with code:', exitCode);
|
|
|
|
|
|
this.ptyProcess = null;
|
|
|
|
|
|
this._pid = null;
|
2026-01-18 04:50:46 +01:00
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// Find result from parsed messages or use text output
|
2026-01-18 05:14:34 +01:00
|
|
|
|
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);
|
2026-01-18 21:36:03 +01:00
|
|
|
|
if (this.resolvePromise) {
|
|
|
|
|
|
this.resolvePromise({ result: resultMsg.result || '', cost });
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) {
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this._status = 'error';
|
2026-01-18 21:36:03 +01:00
|
|
|
|
if (this.rejectPromise) {
|
|
|
|
|
|
this.rejectPromise(new Error(this._errorBuffer || this._textOutput || 'Process exited with error'));
|
|
|
|
|
|
}
|
2026-01-18 05:14:34 +01:00
|
|
|
|
} else {
|
|
|
|
|
|
this._status = 'idle';
|
2026-01-18 21:36:03 +01:00
|
|
|
|
if (this.resolvePromise) {
|
|
|
|
|
|
this.resolvePromise({ result: this._textOutput || this._terminalBuffer, cost: this._totalCost });
|
|
|
|
|
|
}
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
this.resolvePromise = null;
|
|
|
|
|
|
this.rejectPromise = null;
|
|
|
|
|
|
this.emit('exit', exitCode);
|
2026-01-18 04:50:46 +01:00
|
|
|
|
});
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
2026-01-18 04:50:46 +01:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
this._status = 'error';
|
|
|
|
|
|
reject(err);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
private processOutput(data: string): void {
|
|
|
|
|
|
// Try to extract JSON from output (Claude may output JSON in stream mode)
|
|
|
|
|
|
this._lineBuffer += data;
|
2026-01-19 06:51:25 +01:00
|
|
|
|
|
|
|
|
|
|
// 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 += this._lineBuffer + '\n';
|
|
|
|
|
|
this._lineBuffer = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Start flush timer if not running (handles partial lines after 100ms)
|
|
|
|
|
|
if (!this._lineBufferFlushTimer && this._lineBuffer.length > 0) {
|
|
|
|
|
|
this._lineBufferFlushTimer = setTimeout(() => {
|
|
|
|
|
|
this._lineBufferFlushTimer = null;
|
|
|
|
|
|
if (this._lineBuffer.length > 0) {
|
|
|
|
|
|
// Flush partial line as text output
|
|
|
|
|
|
this._textOutput += this._lineBuffer;
|
|
|
|
|
|
this._lineBuffer = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}, LINE_BUFFER_FLUSH_INTERVAL);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
const lines = this._lineBuffer.split('\n');
|
|
|
|
|
|
this._lineBuffer = lines.pop() || '';
|
|
|
|
|
|
|
2026-01-19 06:51:25 +01:00
|
|
|
|
// Clear flush timer if buffer is now empty
|
|
|
|
|
|
if (this._lineBuffer.length === 0 && this._lineBufferFlushTimer) {
|
|
|
|
|
|
clearTimeout(this._lineBufferFlushTimer);
|
|
|
|
|
|
this._lineBufferFlushTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
for (const line of lines) {
|
2026-01-18 21:36:03 +01:00
|
|
|
|
const trimmed = line.trim();
|
2026-01-19 11:48:02 +01:00
|
|
|
|
// Remove ANSI escape codes for JSON parsing (use pre-compiled pattern)
|
|
|
|
|
|
const cleanLine = trimmed.replace(ANSI_ESCAPE_PATTERN, '');
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
|
|
|
|
|
if (cleanLine.startsWith('{') && cleanLine.endsWith('}')) {
|
2026-01-18 05:14:34 +01:00
|
|
|
|
try {
|
2026-01-18 21:36:03 +01:00
|
|
|
|
const msg = JSON.parse(cleanLine) as ClaudeMessage;
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this._messages.push(msg);
|
|
|
|
|
|
this.emit('message', msg);
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// Trim messages array for long-running sessions
|
|
|
|
|
|
if (this._messages.length > MAX_MESSAGES) {
|
|
|
|
|
|
this._messages = this._messages.slice(-Math.floor(MAX_MESSAGES * 0.8));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
if (msg.type === 'system' && msg.session_id) {
|
|
|
|
|
|
this._claudeSessionId = msg.session_id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:38:11 +01:00
|
|
|
|
// Process message for task tracking
|
|
|
|
|
|
this._taskTracker.processMessage(msg);
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
if (msg.type === 'assistant' && msg.message?.content) {
|
|
|
|
|
|
for (const block of msg.message.content) {
|
|
|
|
|
|
if (block.type === 'text' && block.text) {
|
|
|
|
|
|
this._textOutput += block.text;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-19 00:38:11 +01:00
|
|
|
|
// Track tokens from usage
|
|
|
|
|
|
if (msg.message.usage) {
|
|
|
|
|
|
this._totalInputTokens += msg.message.usage.input_tokens || 0;
|
|
|
|
|
|
this._totalOutputTokens += msg.message.usage.output_tokens || 0;
|
|
|
|
|
|
|
2026-01-19 13:08:53 +01:00
|
|
|
|
// Check if we should auto-compact or auto-clear
|
|
|
|
|
|
this.checkAutoCompact();
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this.checkAutoClear();
|
|
|
|
|
|
}
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
if (msg.type === 'result' && msg.total_cost_usd) {
|
|
|
|
|
|
this._totalCost = msg.total_cost_usd;
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// Not JSON, just regular output
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this._textOutput += line + '\n';
|
|
|
|
|
|
}
|
2026-01-18 21:36:03 +01:00
|
|
|
|
} else if (trimmed) {
|
|
|
|
|
|
this._textOutput += line + '\n';
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
|
|
|
|
|
// Trim text output buffer for long-running sessions
|
|
|
|
|
|
if (this._textOutput.length > MAX_TEXT_OUTPUT_SIZE) {
|
|
|
|
|
|
this._textOutput = this._textOutput.slice(-TEXT_OUTPUT_TRIM_SIZE);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 04:42:13 +01:00
|
|
|
|
// 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 {
|
2026-01-19 11:48:02 +01:00
|
|
|
|
// Remove ANSI escape codes for cleaner parsing (use pre-compiled pattern)
|
|
|
|
|
|
const cleanData = data.replace(ANSI_ESCAPE_PATTERN, '');
|
2026-01-19 04:42:13 +01:00
|
|
|
|
|
|
|
|
|
|
// Match patterns: "123.4k tokens", "5234 tokens", "1.2M tokens"
|
|
|
|
|
|
// The status line typically shows total tokens like "1.2k tokens" near the prompt
|
2026-01-19 11:48:02 +01:00
|
|
|
|
const tokenMatch = cleanData.match(TOKEN_PATTERN);
|
2026-01-19 04:42:13 +01:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-01-19 13:08:53 +01:00
|
|
|
|
// Check if we should auto-compact or auto-clear
|
|
|
|
|
|
this.checkAutoCompact();
|
2026-01-19 04:42:13 +01:00
|
|
|
|
this.checkAutoClear();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 13:08:53 +01:00
|
|
|
|
// Check if we should auto-compact based on token threshold
|
|
|
|
|
|
private checkAutoCompact(): void {
|
|
|
|
|
|
if (!this._autoCompactEnabled || this._isCompacting || this._isClearing) 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 = () => {
|
|
|
|
|
|
if (!this._isWorking) {
|
|
|
|
|
|
// Send /compact command with optional prompt
|
|
|
|
|
|
const compactCmd = this._autoCompactPrompt
|
|
|
|
|
|
? `/compact ${this._autoCompactPrompt}\n`
|
|
|
|
|
|
: '/compact\n';
|
|
|
|
|
|
this.write(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)
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this._isCompacting = false;
|
|
|
|
|
|
}, 10000);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Check again in 2 seconds
|
|
|
|
|
|
setTimeout(checkAndCompact, 2000);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Start checking after a short delay
|
|
|
|
|
|
setTimeout(checkAndCompact, 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:38:11 +01:00
|
|
|
|
// Check if we should auto-clear based on token threshold
|
|
|
|
|
|
private checkAutoClear(): void {
|
2026-01-19 13:08:53 +01:00
|
|
|
|
if (!this._autoClearEnabled || this._isClearing || this._isCompacting) return;
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
|
|
|
|
|
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 = () => {
|
|
|
|
|
|
if (!this._isWorking) {
|
|
|
|
|
|
// Send /clear command
|
|
|
|
|
|
this.write('/clear\n');
|
|
|
|
|
|
// Reset token counts
|
|
|
|
|
|
this._totalInputTokens = 0;
|
|
|
|
|
|
this._totalOutputTokens = 0;
|
|
|
|
|
|
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
|
|
|
|
|
|
|
|
|
|
|
|
// Wait a moment then re-enable
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this._isClearing = false;
|
|
|
|
|
|
}, 5000);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Check again in 2 seconds
|
|
|
|
|
|
setTimeout(checkAndClear, 2000);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Start checking after a short delay
|
|
|
|
|
|
setTimeout(checkAndClear, 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// Send input to the PTY (for interactive sessions)
|
|
|
|
|
|
write(data: string): void {
|
|
|
|
|
|
if (this.ptyProcess) {
|
|
|
|
|
|
this.ptyProcess.write(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 15:35:41 +01:00
|
|
|
|
// Send input via screen -X stuff (for programmatic input like respawn controller)
|
|
|
|
|
|
// This bypasses PTY and sends directly to screen, more reliable for Enter key
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// Resize the PTY
|
|
|
|
|
|
resize(cols: number, rows: number): void {
|
|
|
|
|
|
if (this.ptyProcess) {
|
|
|
|
|
|
this.ptyProcess.resize(cols, rows);
|
|
|
|
|
|
}
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 04:47:48 +01:00
|
|
|
|
async stop(killScreen: boolean = true): Promise<void> {
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// Clear activity timeout to prevent memory leak
|
|
|
|
|
|
if (this.activityTimeout) {
|
|
|
|
|
|
clearTimeout(this.activityTimeout);
|
|
|
|
|
|
this.activityTimeout = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 06:51:25 +01:00
|
|
|
|
// Clear line buffer flush timer
|
|
|
|
|
|
if (this._lineBufferFlushTimer) {
|
|
|
|
|
|
clearTimeout(this._lineBufferFlushTimer);
|
|
|
|
|
|
this._lineBufferFlushTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
if (this.ptyProcess) {
|
|
|
|
|
|
const pid = this.ptyProcess.pid;
|
|
|
|
|
|
|
|
|
|
|
|
// First try graceful SIGTERM
|
|
|
|
|
|
try {
|
|
|
|
|
|
this.ptyProcess.kill();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Process may already be dead
|
2026-01-18 04:50:46 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// Give it a moment to terminate gracefully
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
2026-01-18 04:50:46 +01:00
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// Force kill with SIGKILL if still alive
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (pid) {
|
|
|
|
|
|
process.kill(pid, 'SIGKILL');
|
2026-01-18 04:50:46 +01:00
|
|
|
|
}
|
2026-01-18 21:36:03 +01:00
|
|
|
|
} 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;
|
|
|
|
|
|
|
2026-01-19 04:47:48 +01:00
|
|
|
|
// Kill the associated screen session if requested
|
2026-01-19 11:48:02 +01:00
|
|
|
|
if (killScreen && this._screenManager) {
|
|
|
|
|
|
// Try to kill screen even if _screenSession is not set (e.g., restored sessions)
|
2026-01-19 04:22:41 +01:00
|
|
|
|
try {
|
2026-01-19 11:48:02 +01:00
|
|
|
|
const killed = await this._screenManager.killScreen(this.id);
|
|
|
|
|
|
if (killed) {
|
|
|
|
|
|
console.log('[Session] Killed screen session for:', this.id);
|
|
|
|
|
|
}
|
2026-01-19 04:22:41 +01:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('[Session] Failed to kill screen session:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
this._screenSession = null;
|
2026-01-19 04:47:48 +01:00
|
|
|
|
} else if (this._screenSession && !killScreen) {
|
|
|
|
|
|
console.log('[Session] Keeping screen session alive:', this._screenSession.screenName);
|
|
|
|
|
|
this._screenSession = null; // Detach but don't kill
|
2026-01-19 04:22:41 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
if (this.rejectPromise) {
|
|
|
|
|
|
this.rejectPromise(new Error('Session stopped'));
|
|
|
|
|
|
this.resolvePromise = null;
|
|
|
|
|
|
this.rejectPromise = null;
|
|
|
|
|
|
}
|
2026-01-18 04:50:46 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
assignTask(taskId: string): void {
|
|
|
|
|
|
this._currentTaskId = taskId;
|
|
|
|
|
|
this._status = 'busy';
|
2026-01-18 21:36:03 +01:00
|
|
|
|
this._terminalBuffer = '';
|
2026-01-18 04:50:46 +01:00
|
|
|
|
this._outputBuffer = '';
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this._textOutput = '';
|
2026-01-18 04:50:46 +01:00
|
|
|
|
this._errorBuffer = '';
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this._messages = [];
|
2026-01-18 04:50:46 +01:00
|
|
|
|
this._lastActivityAt = Date.now();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
clearTask(): void {
|
|
|
|
|
|
this._currentTaskId = null;
|
|
|
|
|
|
this._status = 'idle';
|
|
|
|
|
|
this._lastActivityAt = Date.now();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getOutput(): string {
|
2026-01-18 05:14:34 +01:00
|
|
|
|
return this._textOutput;
|
2026-01-18 04:50:46 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getError(): string {
|
|
|
|
|
|
return this._errorBuffer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
getTerminalBuffer(): string {
|
|
|
|
|
|
return this._terminalBuffer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 04:50:46 +01:00
|
|
|
|
clearBuffers(): void {
|
2026-01-18 21:36:03 +01:00
|
|
|
|
this._terminalBuffer = '';
|
2026-01-18 04:50:46 +01:00
|
|
|
|
this._outputBuffer = '';
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this._textOutput = '';
|
2026-01-18 04:50:46 +01:00
|
|
|
|
this._errorBuffer = '';
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this._messages = [];
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this._taskTracker.clear();
|
2026-01-19 13:21:01 +01:00
|
|
|
|
this._innerLoopTracker.clear();
|
2026-01-18 04:50:46 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|