2026-01-21 04:25:53 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* @fileoverview Core PTY session wrapper for Claude CLI interactions.
|
|
|
|
|
|
*
|
2026-03-03 22:40:18 +01:00
|
|
|
|
* Manages a PTY (pseudo-terminal) process running Claude CLI or OpenCode CLI.
|
|
|
|
|
|
* Three operation modes:
|
|
|
|
|
|
* 1. **One-shot** (`runPrompt`): Single prompt → JSON response
|
|
|
|
|
|
* 2. **Interactive** (`startInteractive`): Persistent interactive session
|
|
|
|
|
|
* 3. **Shell** (`startShell`): Plain bash shell for debugging
|
2026-01-21 04:25:53 +01:00
|
|
|
|
*
|
2026-03-03 22:40:18 +01:00
|
|
|
|
* Optionally wraps in a tmux session for persistence across disconnects.
|
|
|
|
|
|
* Tracks tokens, costs, background tasks, and auto-compact/clear.
|
2026-01-21 04:25:53 +01:00
|
|
|
|
*
|
2026-03-03 22:40:18 +01:00
|
|
|
|
* Key exports:
|
|
|
|
|
|
* - `Session` class — main entity, extends EventEmitter
|
|
|
|
|
|
* - `ClaudeMessage` interface — parsed JSON messages from Claude output
|
|
|
|
|
|
* - `SessionEvents` interface — typed event map
|
|
|
|
|
|
*
|
|
|
|
|
|
* Key methods: `runPrompt()`, `startInteractive()`, `startShell()`,
|
|
|
|
|
|
* `writeViaMux()`, `toState()`, `stop()`, `resize()`, `isIdle()`,
|
|
|
|
|
|
* `setAutoCompact()`, `findTaskDescriptionNear()`, `getTerminalBuffer()`
|
|
|
|
|
|
*
|
|
|
|
|
|
* @dependencies session-cli-builder (args/env), session-auto-ops (auto-compact/clear),
|
|
|
|
|
|
* ralph-tracker (todo/completion parsing), bash-tool-parser (tool invocation tracking),
|
|
|
|
|
|
* task-tracker (background tasks), mux-interface (tmux abstraction)
|
|
|
|
|
|
* @consumedby session-manager, web/server, respawn-controller
|
|
|
|
|
|
* @emits session:terminal, session:idle, session:working, session:completion, session:exit
|
2026-01-21 04:25:53 +01:00
|
|
|
|
*
|
|
|
|
|
|
* @module session
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-01-18 04:50:46 +01:00
|
|
|
|
import { EventEmitter } from 'node:events';
|
2026-05-11 16:32:17 -04:00
|
|
|
|
import { execSync, execFileSync } from 'node:child_process';
|
2026-01-18 04:50:46 +01:00
|
|
|
|
import { v4 as uuidv4 } from 'uuid';
|
2026-01-18 21:36:03 +01:00
|
|
|
|
import * as pty from 'node-pty';
|
2026-02-27 13:16:23 +01:00
|
|
|
|
import {
|
|
|
|
|
|
SessionState,
|
|
|
|
|
|
SessionStatus,
|
|
|
|
|
|
SessionConfig,
|
|
|
|
|
|
RalphTrackerState,
|
|
|
|
|
|
RalphTodoItem,
|
|
|
|
|
|
ActiveBashTool,
|
|
|
|
|
|
NiceConfig,
|
|
|
|
|
|
DEFAULT_NICE_CONFIG,
|
2026-03-27 01:43:59 +01:00
|
|
|
|
getErrorMessage,
|
2026-06-07 10:33:11 +08:00
|
|
|
|
isEffortLevel,
|
2026-02-27 13:16:23 +01:00
|
|
|
|
type ClaudeMode,
|
|
|
|
|
|
type SessionMode,
|
|
|
|
|
|
type OpenCodeConfig,
|
2026-06-10 09:31:58 -04:00
|
|
|
|
type CodexConfig,
|
2026-06-07 10:33:11 +08:00
|
|
|
|
type EffortLevel,
|
2026-05-16 20:10:49 -04:00
|
|
|
|
type GeminiConfig,
|
2026-06-11 18:43:39 +00:00
|
|
|
|
type SessionRemote,
|
2026-02-27 13:16:23 +01:00
|
|
|
|
} from './types.js';
|
2026-02-12 10:29:57 +01:00
|
|
|
|
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
2026-01-19 00:38:11 +01:00
|
|
|
|
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
2026-01-21 22:36:45 +01:00
|
|
|
|
import { RalphTracker } from './ralph-tracker.js';
|
2026-01-26 00:55:29 +01:00
|
|
|
|
import { BashToolParser } from './bash-tool-parser.js';
|
2026-03-07 07:33:44 +01:00
|
|
|
|
import {
|
2026-03-12 15:50:40 +01:00
|
|
|
|
BufferAccumulator,
|
2026-03-07 07:33:44 +01:00
|
|
|
|
ANSI_ESCAPE_PATTERN_FULL,
|
|
|
|
|
|
TOKEN_PATTERN,
|
|
|
|
|
|
SPINNER_PATTERN,
|
|
|
|
|
|
MAX_SESSION_TOKENS,
|
|
|
|
|
|
execPattern,
|
2026-07-16 09:33:39 +02:00
|
|
|
|
getClaudeCliVersion,
|
2026-03-07 07:33:44 +01:00
|
|
|
|
} from './utils/index.js';
|
2026-01-28 04:38:43 +01:00
|
|
|
|
import {
|
|
|
|
|
|
MAX_TERMINAL_BUFFER_SIZE,
|
|
|
|
|
|
TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE,
|
|
|
|
|
|
MAX_TEXT_OUTPUT_SIZE,
|
|
|
|
|
|
TRIM_TEXT_TO as TEXT_OUTPUT_TRIM_SIZE,
|
|
|
|
|
|
MAX_MESSAGES,
|
|
|
|
|
|
MAX_LINE_BUFFER_SIZE,
|
|
|
|
|
|
} from './config/buffer-limits.js';
|
2026-06-29 17:49:39 -04:00
|
|
|
|
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
2026-03-28 04:34:53 +01:00
|
|
|
|
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
2026-03-01 04:04:35 +01:00
|
|
|
|
import {
|
|
|
|
|
|
buildInteractiveArgs,
|
|
|
|
|
|
buildPromptArgs,
|
|
|
|
|
|
buildClaudeEnv,
|
|
|
|
|
|
buildMuxAttachEnv,
|
|
|
|
|
|
buildShellEnv,
|
|
|
|
|
|
} from './session-cli-builder.js';
|
|
|
|
|
|
import { SessionAutoOps } from './session-auto-ops.js';
|
2026-06-10 20:41:34 +02:00
|
|
|
|
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
2026-03-01 04:04:35 +01:00
|
|
|
|
import { SessionTaskCache } from './session-task-cache.js';
|
2026-06-16 08:53:29 -04:00
|
|
|
|
import { InteractivePtyExitBreaker } from './session-pty-exit-breaker.js';
|
2026-07-01 09:57:47 +00:00
|
|
|
|
import { parseTerminalAttachmentRequests } from './attachment-magic.js';
|
2026-06-12 14:37:27 -04:00
|
|
|
|
import {
|
|
|
|
|
|
sanitizeAttachmentHistory,
|
|
|
|
|
|
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
|
|
|
|
|
} from './session-attachment-history.js';
|
|
|
|
|
|
import type { SessionAttachmentHistoryItem } from './types/session.js';
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
|
|
|
|
|
export type { BackgroundTask } from './task-tracker.js';
|
2026-01-26 00:55:29 +01:00
|
|
|
|
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
2026-01-18 04:50:46 +01:00
|
|
|
|
|
2026-06-09 10:26:09 -04:00
|
|
|
|
export type ResizeViewportType = 'mobile' | 'tablet' | 'desktop';
|
|
|
|
|
|
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/** Line buffer flush interval (100ms) - forces processing of partial lines */
|
2026-01-19 06:51:25 +01:00
|
|
|
|
const LINE_BUFFER_FLUSH_INTERVAL = 100;
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
2026-01-26 14:27:21 +01:00
|
|
|
|
// ============================================================================
|
|
|
|
|
|
// Timing Constants
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
2026-02-18 12:51:35 +01:00
|
|
|
|
/** Delay after mux session creation before sending commands (300ms) */
|
|
|
|
|
|
const MUX_STARTUP_DELAY_MS = 300;
|
2026-01-26 14:27:21 +01:00
|
|
|
|
|
|
|
|
|
|
/** Delay before declaring session idle after last output (2 seconds) */
|
|
|
|
|
|
const IDLE_DETECTION_DELAY_MS = 2000;
|
|
|
|
|
|
|
2026-03-01 04:04:35 +01:00
|
|
|
|
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
|
2026-01-26 14:27:21 +01:00
|
|
|
|
|
|
|
|
|
|
/** Graceful shutdown delay when stopping session (100ms) */
|
|
|
|
|
|
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
|
2026-01-21 05:34:45 +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
|
2026-02-27 13:16:23 +01:00
|
|
|
|
// eslint-disable-next-line no-control-regex
|
2026-01-19 02:04:30 +01:00
|
|
|
|
const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g;
|
|
|
|
|
|
|
2026-01-26 10:42:04 +01:00
|
|
|
|
// Pattern to match Task tool invocations in terminal output
|
|
|
|
|
|
// Matches: "Explore(Description)", "Task(Description)", "Bash(Description)", etc.
|
|
|
|
|
|
// The prefix characters vary (●, ·, ✶, etc.) so we don't require them
|
|
|
|
|
|
// We look for the tool name followed by (description)
|
|
|
|
|
|
const TASK_TOOL_PATTERN = /\b(Explore|Task|Bash|Plan|general-purpose)\(([^)]+)\)/g;
|
2026-01-19 11:48:02 +01:00
|
|
|
|
|
2026-01-30 11:30:51 +01:00
|
|
|
|
// Pre-compiled patterns for hot paths (avoid regex compilation per call)
|
|
|
|
|
|
/** Pattern to strip leading ANSI escapes and whitespace from terminal buffer */
|
2026-02-27 13:16:23 +01:00
|
|
|
|
// eslint-disable-next-line no-control-regex
|
2026-01-30 11:30:51 +01:00
|
|
|
|
const LEADING_ANSI_WHITESPACE_PATTERN = /^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/;
|
|
|
|
|
|
/** Pattern to match Ctrl+L (form feed) characters */
|
2026-02-27 13:16:23 +01:00
|
|
|
|
// eslint-disable-next-line no-control-regex
|
2026-01-30 11:30:51 +01:00
|
|
|
|
const CTRL_L_PATTERN = /\x0c/g;
|
|
|
|
|
|
/** Pattern to split by newlines (CR or LF) */
|
|
|
|
|
|
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
|
|
|
|
|
|
2026-06-10 09:31:58 -04:00
|
|
|
|
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
|
2026-06-10 16:15:55 +02:00
|
|
|
|
export function isExternalCliMode(mode: SessionMode): boolean {
|
2026-05-16 20:10:49 -04:00
|
|
|
|
return mode === 'opencode' || mode === 'codex' || mode === 'gemini';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getModeLabel(mode: SessionMode): string {
|
|
|
|
|
|
switch (mode) {
|
|
|
|
|
|
case 'opencode':
|
|
|
|
|
|
return 'OpenCode';
|
|
|
|
|
|
case 'codex':
|
|
|
|
|
|
return 'Codex';
|
|
|
|
|
|
case 'gemini':
|
|
|
|
|
|
return 'Gemini';
|
|
|
|
|
|
case 'shell':
|
|
|
|
|
|
return 'Shell';
|
|
|
|
|
|
case 'claude':
|
|
|
|
|
|
return 'Claude';
|
|
|
|
|
|
}
|
2026-06-10 09:31:58 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 18:08:45 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Modes whose TUI emits alt-screen / scrollback-erase / mouse-tracking sequences
|
|
|
|
|
|
* that we strip so the browser keeps everything in the main buffer with scrollback
|
|
|
|
|
|
* reachable (the strip runs on both the live stream and the buffer replay).
|
|
|
|
|
|
*
|
2026-06-25 00:26:06 +02:00
|
|
|
|
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
|
|
|
|
|
|
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
|
|
|
|
|
|
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
|
|
|
|
|
|
* vim/less/htop legitimately need the alt screen) and `opencode` (renders its own
|
|
|
|
|
|
* TUI that may rely on it). Keep parity with the replay-side strip in
|
|
|
|
|
|
* session-routes.ts.
|
2026-06-16 18:08:45 +02:00
|
|
|
|
*/
|
|
|
|
|
|
export function isAltScreenStripMode(mode: SessionMode): boolean {
|
2026-06-25 00:26:06 +02:00
|
|
|
|
return mode === 'codex' || mode === 'claude' || mode === 'gemini';
|
2026-06-16 18:08:45 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 04:04:35 +01:00
|
|
|
|
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
2026-01-24 17:43:31 +01:00
|
|
|
|
|
2026-05-12 10:23:44 +02:00
|
|
|
|
/** PTY fallback geometry when tmux can't be queried (matches pre-#80 hardcoded values). */
|
|
|
|
|
|
const DEFAULT_PTY_COLS = 120;
|
|
|
|
|
|
const DEFAULT_PTY_ROWS = 40;
|
|
|
|
|
|
const TMUX_DISPLAY_TIMEOUT_MS = 2000;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Ask tmux for the current window geometry of `muxName` so a re-attaching PTY
|
|
|
|
|
|
* client can spawn at the same size and avoid the resize-flicker / scrollback
|
|
|
|
|
|
* loss documented in #80. Returns `{ cols: 120, rows: 40 }` on any failure
|
|
|
|
|
|
* (tmux dead, muxName unknown, malformed output) — caller never has to
|
|
|
|
|
|
* differentiate "tmux unreachable" from "size 120x40".
|
|
|
|
|
|
*
|
2026-05-26 05:28:58 +08:00
|
|
|
|
* `socket` MUST be the same dedicated socket the session lives on (`mux.muxSocket`);
|
|
|
|
|
|
* querying the default server would never find the session and silently fall back.
|
|
|
|
|
|
*
|
2026-05-12 10:23:44 +02:00
|
|
|
|
* Argv form (execFileSync, not execSync) keeps `muxName` out of any shell so
|
|
|
|
|
|
* a hostile session name can't inject options.
|
|
|
|
|
|
*/
|
2026-05-26 05:28:58 +08:00
|
|
|
|
export function queryTmuxWindowSize(muxName: string, socket: string): { cols: number; rows: number } {
|
2026-05-12 10:23:44 +02:00
|
|
|
|
try {
|
2026-05-26 05:28:58 +08:00
|
|
|
|
const sizeStr = execFileSync(
|
|
|
|
|
|
'tmux',
|
|
|
|
|
|
['-L', socket, 'display', '-t', muxName, '-p', '#{window_width} #{window_height}'],
|
|
|
|
|
|
{
|
|
|
|
|
|
timeout: TMUX_DISPLAY_TIMEOUT_MS,
|
|
|
|
|
|
encoding: 'utf8',
|
|
|
|
|
|
}
|
|
|
|
|
|
).trim();
|
2026-05-12 10:23:44 +02:00
|
|
|
|
const [w, h] = sizeStr.split(' ').map(Number);
|
|
|
|
|
|
if (w > 0 && h > 0) {
|
|
|
|
|
|
return { cols: w, rows: h };
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* fall back below */
|
|
|
|
|
|
}
|
|
|
|
|
|
return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 18:43:39 +00:00
|
|
|
|
export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote): string {
|
|
|
|
|
|
return remote ? '/tmp' : workingDir;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Represents a JSON message from Claude CLI's stream-json output format.
|
|
|
|
|
|
* Messages are newline-delimited JSON objects parsed from PTY output.
|
|
|
|
|
|
*/
|
2026-01-18 05:14:34 +01:00
|
|
|
|
export interface ClaudeMessage {
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/** Message type indicating the role or purpose */
|
2026-01-18 05:14:34 +01:00
|
|
|
|
type: 'system' | 'assistant' | 'user' | 'result';
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/** Optional subtype for further classification */
|
2026-01-18 05:14:34 +01:00
|
|
|
|
subtype?: string;
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/** Claude's internal session identifier */
|
2026-01-18 05:14:34 +01:00
|
|
|
|
session_id?: string;
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/** Message content with optional token usage */
|
2026-01-18 05:14:34 +01:00
|
|
|
|
message?: {
|
|
|
|
|
|
content: Array<{ type: string; text?: string }>;
|
|
|
|
|
|
usage?: {
|
|
|
|
|
|
input_tokens: number;
|
|
|
|
|
|
output_tokens: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/** Final result text (on result messages) */
|
2026-01-18 05:14:34 +01:00
|
|
|
|
result?: string;
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/** Whether this message represents an error */
|
2026-01-18 05:14:34 +01:00
|
|
|
|
is_error?: boolean;
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/** Total cost in USD (on result messages) */
|
2026-01-18 05:14:34 +01:00
|
|
|
|
total_cost_usd?: number;
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/** Total duration in milliseconds (on result messages) */
|
2026-01-18 05:14:34 +01:00
|
|
|
|
duration_ms?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Event signatures emitted by the Session class.
|
|
|
|
|
|
* Subscribe using `session.on('eventName', handler)`.
|
|
|
|
|
|
*/
|
2026-01-19 01:26:38 +01:00
|
|
|
|
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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',
|
2026-02-18 12:51:35 +01:00
|
|
|
|
* mux: muxManager,
|
|
|
|
|
|
* useMux: true
|
2026-01-21 04:25:53 +01:00
|
|
|
|
* });
|
|
|
|
|
|
* 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
|
|
|
|
|
|
*/
|
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-03-01 04:04:35 +01:00
|
|
|
|
// Task description cache (extracted to SessionTaskCache)
|
|
|
|
|
|
private _taskCache = new SessionTaskCache();
|
2026-01-31 23:47:49 +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-06-16 08:53:29 -04:00
|
|
|
|
|
|
|
|
|
|
// COD-118: bound repeated non-zero interactive-PTY exits. Recorded in the
|
|
|
|
|
|
// interactive PTY onExit handler; when it trips, the session flips to 'error'
|
|
|
|
|
|
// and startInteractive() refuses to respawn until an explicit user restart
|
|
|
|
|
|
// calls resetRespawnBreaker(). Defense-in-depth over the COD-115 crash-loop.
|
|
|
|
|
|
private readonly _ptyExitBreaker = new InteractivePtyExitBreaker();
|
|
|
|
|
|
private _respawnBlocked = false;
|
2026-01-21 04:55:48 +01:00
|
|
|
|
// Use BufferAccumulator for hot-path buffers to reduce GC pressure
|
|
|
|
|
|
private _terminalBuffer = new BufferAccumulator(MAX_TERMINAL_BUFFER_SIZE, TERMINAL_BUFFER_TRIM_SIZE);
|
|
|
|
|
|
private _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE);
|
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-06-16 18:08:45 +02:00
|
|
|
|
// Alt-screen-strip modes (Codex/Claude): trailing partial CSI held back so
|
|
|
|
|
|
// sequences split across PTY chunks can't slip past the alt-screen/scrollback
|
|
|
|
|
|
// strip (see _handleTerminalOutput / isAltScreenStripMode)
|
|
|
|
|
|
private _altScreenSeqCarry: string = '';
|
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;
|
2026-02-27 13:16:23 +01:00
|
|
|
|
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
2026-01-18 21:36:03 +01:00
|
|
|
|
private _isWorking: boolean = false;
|
|
|
|
|
|
private _lastPromptTime: number = 0;
|
|
|
|
|
|
private activityTimeout: NodeJS.Timeout | null = null;
|
2026-01-28 05:34:25 +01:00
|
|
|
|
private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection
|
2026-04-01 08:51:37 +02:00
|
|
|
|
private _trustDialogAccepted: boolean = false; // Prevents repeated trust dialog auto-accept
|
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-18 04:50:46 +01:00
|
|
|
|
|
2026-03-01 04:04:35 +01:00
|
|
|
|
// Auto-compact/auto-clear automation (extracted to SessionAutoOps)
|
|
|
|
|
|
private _autoOps!: SessionAutoOps;
|
2026-01-19 13:08:53 +01:00
|
|
|
|
|
2026-01-28 07:18:45 +01:00
|
|
|
|
// Image watcher setting (per-session toggle)
|
2026-02-17 16:47:54 +01:00
|
|
|
|
private _imageWatcherEnabled: boolean = false;
|
2026-01-28 07:18:45 +01:00
|
|
|
|
|
2026-01-30 17:50:02 +01:00
|
|
|
|
// Flicker filter setting (per-session toggle, applied on frontend)
|
|
|
|
|
|
private _flickerFilterEnabled: boolean = false;
|
|
|
|
|
|
|
2026-01-31 04:44:46 +01:00
|
|
|
|
// Claude Code CLI info (parsed from terminal startup)
|
|
|
|
|
|
private _cliVersion: string = '';
|
|
|
|
|
|
private _cliModel: string = '';
|
|
|
|
|
|
private _cliAccountType: string = '';
|
|
|
|
|
|
private _cliLatestVersion: string = '';
|
|
|
|
|
|
private _cliInfoParsed: boolean = false; // Only parse once per session
|
|
|
|
|
|
|
2026-01-21 09:54:42 +01:00
|
|
|
|
// Timer tracking for cleanup (prevents memory leaks)
|
|
|
|
|
|
private _promptCheckInterval: NodeJS.Timeout | null = null;
|
|
|
|
|
|
private _promptCheckTimeout: NodeJS.Timeout | null = null;
|
|
|
|
|
|
private _shellIdleTimer: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
2026-02-18 12:51:35 +01:00
|
|
|
|
// Multiplexer session support (tmux)
|
2026-02-12 10:29:57 +01:00
|
|
|
|
private _mux: TerminalMultiplexer | null = null;
|
|
|
|
|
|
private _muxSession: MuxSession | null = null;
|
|
|
|
|
|
private _useMux: boolean = false;
|
2026-01-22 00:08:32 +01:00
|
|
|
|
// Flag to prevent new timers after session is stopped
|
|
|
|
|
|
private _isStopped: boolean = false;
|
2026-01-19 03:40:57 +01:00
|
|
|
|
|
2026-01-21 22:36:45 +01:00
|
|
|
|
// Ralph tracking (Ralph Wiggum loops and todo lists inside Claude Code)
|
|
|
|
|
|
private _ralphTracker: RalphTracker;
|
2026-01-19 13:21:01 +01:00
|
|
|
|
|
2026-01-23 10:20:37 +01:00
|
|
|
|
// Agent tree tracking
|
|
|
|
|
|
private _parentAgentId: string | null = null;
|
|
|
|
|
|
private _childAgentIds: string[] = [];
|
|
|
|
|
|
|
2026-06-10 20:42:17 -04:00
|
|
|
|
// Bounded dedup set for terminal attachment magic-links already requested.
|
|
|
|
|
|
private _attachmentMagicSeen = new Set<string>();
|
2026-06-12 14:37:27 -04:00
|
|
|
|
private _attachmentHistory: SessionAttachmentHistoryItem[] = [];
|
2026-06-10 20:42:17 -04:00
|
|
|
|
|
2026-01-27 11:43:20 +01:00
|
|
|
|
// Nice prioritying configuration
|
|
|
|
|
|
private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG };
|
2026-01-27 01:58:02 +01:00
|
|
|
|
|
2026-02-18 23:14:39 +01:00
|
|
|
|
// Claude model override (e.g., 'opus', 'sonnet', 'haiku')
|
|
|
|
|
|
private _model: string | undefined;
|
|
|
|
|
|
|
2026-02-24 15:52:55 +01:00
|
|
|
|
// Claude CLI startup permission mode
|
|
|
|
|
|
private _claudeMode: ClaudeMode = 'dangerously-skip-permissions';
|
|
|
|
|
|
private _allowedTools: string | undefined;
|
|
|
|
|
|
|
2026-02-26 10:07:43 +01:00
|
|
|
|
// OpenCode configuration (only for mode === 'opencode')
|
|
|
|
|
|
private _openCodeConfig: OpenCodeConfig | undefined;
|
2026-06-10 09:31:58 -04:00
|
|
|
|
// Codex configuration (only for mode === 'codex')
|
|
|
|
|
|
private _codexConfig: CodexConfig | undefined;
|
2026-05-16 20:10:49 -04:00
|
|
|
|
// Gemini configuration (only for mode === 'gemini')
|
|
|
|
|
|
private _geminiConfig: GeminiConfig | undefined;
|
2026-03-10 02:22:53 +08:00
|
|
|
|
private _resumeSessionId: string | undefined;
|
2026-02-26 10:07:43 +01:00
|
|
|
|
|
2026-06-07 10:33:11 +08:00
|
|
|
|
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
|
|
|
|
|
// at spawn, preserved across respawns via persisted state. Not written to .claude/settings.local.json.
|
2026-04-22 00:01:35 +08:00
|
|
|
|
private _envOverrides: Record<string, string> | undefined;
|
|
|
|
|
|
|
2026-06-07 10:33:11 +08:00
|
|
|
|
// Claude CLI effort level — injected as a `--settings` soft default at spawn so the
|
|
|
|
|
|
// user can still switch in-session via /effort (incl. ultracode). Never carried as
|
|
|
|
|
|
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
|
|
|
|
|
private _effort: EffortLevel | undefined;
|
|
|
|
|
|
|
2026-06-29 17:49:39 -04:00
|
|
|
|
// tmux history-limit (scrollback lines) applied to this session's pane.
|
|
|
|
|
|
private readonly _tmuxHistoryLimit: number;
|
|
|
|
|
|
|
2026-06-11 18:43:39 +00:00
|
|
|
|
// Remote execution metadata, present when this session runs over SSH through local tmux.
|
|
|
|
|
|
private readonly _remote?: SessionRemote;
|
|
|
|
|
|
|
2026-01-30 13:25:18 +01:00
|
|
|
|
// Session color for visual differentiation
|
|
|
|
|
|
private _color: import('./types.js').SessionColor = 'default';
|
|
|
|
|
|
|
2026-01-21 05:50:09 +01:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-01-21 22:36:45 +01:00
|
|
|
|
private _ralphHandlers: {
|
|
|
|
|
|
loopUpdate: (state: RalphTrackerState) => void;
|
|
|
|
|
|
todoUpdate: (todos: RalphTodoItem[]) => void;
|
2026-01-21 05:50:09 +01:00
|
|
|
|
completionDetected: (phrase: string) => void;
|
2026-01-27 00:29:38 +01:00
|
|
|
|
statusBlockDetected: (block: import('./types.js').RalphStatusBlock) => void;
|
|
|
|
|
|
circuitBreakerUpdate: (status: import('./types.js').CircuitBreakerStatus) => void;
|
|
|
|
|
|
exitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
|
2026-01-21 05:50:09 +01:00
|
|
|
|
} | null = null;
|
|
|
|
|
|
|
2026-01-26 00:55:29 +01:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-03-01 04:04:35 +01:00
|
|
|
|
// Task descriptions parsed from terminal output — delegated to SessionTaskCache
|
2026-01-26 10:42:04 +01:00
|
|
|
|
|
2026-02-20 14:33:06 +01:00
|
|
|
|
// Throttle expensive PTY processing (Ralph, bash parser, task descriptions)
|
|
|
|
|
|
// Accumulates clean data between processing windows to avoid running regex on every chunk
|
|
|
|
|
|
private _lastExpensiveProcessTime: number = 0;
|
|
|
|
|
|
private _pendingCleanData: string = '';
|
|
|
|
|
|
private _expensiveProcessTimer: NodeJS.Timeout | null = null;
|
|
|
|
|
|
private static readonly EXPENSIVE_PROCESS_INTERVAL_MS = 150; // Process at most every 150ms
|
|
|
|
|
|
|
2026-02-27 13:16:23 +01:00
|
|
|
|
constructor(
|
|
|
|
|
|
config: Partial<SessionConfig> & {
|
|
|
|
|
|
workingDir: string;
|
|
|
|
|
|
mode?: SessionMode;
|
|
|
|
|
|
name?: string;
|
|
|
|
|
|
/** Terminal multiplexer instance (tmux) */
|
|
|
|
|
|
mux?: TerminalMultiplexer;
|
|
|
|
|
|
/** Whether to use multiplexer wrapping */
|
|
|
|
|
|
useMux?: boolean;
|
|
|
|
|
|
/** Existing mux session for restored sessions */
|
|
|
|
|
|
muxSession?: MuxSession;
|
|
|
|
|
|
niceConfig?: NiceConfig; // Nice prioritying configuration
|
|
|
|
|
|
/** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */
|
|
|
|
|
|
model?: string;
|
|
|
|
|
|
/** Claude CLI startup permission mode */
|
|
|
|
|
|
claudeMode?: ClaudeMode;
|
|
|
|
|
|
/** Comma-separated allowed tools (for 'allowedTools' mode) */
|
|
|
|
|
|
allowedTools?: string;
|
|
|
|
|
|
/** OpenCode configuration (only for mode === 'opencode') */
|
|
|
|
|
|
openCodeConfig?: OpenCodeConfig;
|
2026-06-10 09:31:58 -04:00
|
|
|
|
/** Codex configuration (only for mode === 'codex') */
|
|
|
|
|
|
codexConfig?: CodexConfig;
|
2026-05-16 20:10:49 -04:00
|
|
|
|
/** Gemini configuration (only for mode === 'gemini') */
|
|
|
|
|
|
geminiConfig?: GeminiConfig;
|
2026-03-10 02:22:53 +08:00
|
|
|
|
/** Resume a previous Claude conversation (used after server reboot) */
|
|
|
|
|
|
resumeSessionId?: string;
|
2026-04-22 00:01:35 +08:00
|
|
|
|
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
|
|
|
|
|
envOverrides?: Record<string, string>;
|
2026-06-07 10:33:11 +08:00
|
|
|
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
|
|
|
|
|
effort?: EffortLevel;
|
2026-06-29 17:49:39 -04:00
|
|
|
|
/** tmux history-limit (scrollback lines) for this session's pane. */
|
|
|
|
|
|
tmuxHistoryLimit?: number;
|
2026-06-12 14:37:27 -04:00
|
|
|
|
/** Restored per-session attachment history. May include server-private external paths. */
|
|
|
|
|
|
attachmentHistory?: SessionAttachmentHistoryItem[];
|
2026-06-11 18:43:39 +00:00
|
|
|
|
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
|
|
|
|
|
|
remote?: SessionRemote;
|
2026-02-27 13:16:23 +01:00
|
|
|
|
}
|
|
|
|
|
|
) {
|
2026-01-18 04:50:46 +01:00
|
|
|
|
super();
|
2026-02-15 05:33:34 +01:00
|
|
|
|
this.setMaxListeners(25);
|
2026-02-12 14:41:27 +01:00
|
|
|
|
|
|
|
|
|
|
// Default error handler prevents unhandled 'error' events from crashing the process.
|
|
|
|
|
|
// Server attaches its own handler after construction — this is a safety net for the gap.
|
|
|
|
|
|
this.on('error', (err) => {
|
|
|
|
|
|
console.error(`[Session] Unhandled error event:`, err);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-18 04:50:46 +01:00
|
|
|
|
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-03-10 02:22:53 +08:00
|
|
|
|
this._resumeSessionId = config.resumeSessionId;
|
2026-01-18 04:50:46 +01:00
|
|
|
|
this._lastActivityAt = this.createdAt;
|
2026-03-10 02:22:53 +08:00
|
|
|
|
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
|
|
|
|
|
this._claudeSessionId = config.resumeSessionId || this.id;
|
2026-02-18 12:51:35 +01:00
|
|
|
|
this._mux = config.mux || null;
|
|
|
|
|
|
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
|
|
|
|
|
|
this._muxSession = config.muxSession || null;
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
2026-01-27 11:43:20 +01:00
|
|
|
|
// Apply Nice priority configuration if provided
|
|
|
|
|
|
if (config.niceConfig) {
|
|
|
|
|
|
this._niceConfig = { ...config.niceConfig };
|
2026-01-27 01:58:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 23:14:39 +01:00
|
|
|
|
// Apply model override if provided
|
|
|
|
|
|
if (config.model) {
|
|
|
|
|
|
this._model = config.model;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 15:52:55 +01:00
|
|
|
|
// Apply Claude CLI permission mode
|
|
|
|
|
|
if (config.claudeMode) {
|
|
|
|
|
|
this._claudeMode = config.claudeMode;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (config.allowedTools) {
|
|
|
|
|
|
this._allowedTools = config.allowedTools;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 10:07:43 +01:00
|
|
|
|
// Apply OpenCode configuration
|
|
|
|
|
|
if (config.openCodeConfig) {
|
|
|
|
|
|
this._openCodeConfig = config.openCodeConfig;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 09:31:58 -04:00
|
|
|
|
// Apply Codex configuration
|
|
|
|
|
|
if (config.codexConfig) {
|
|
|
|
|
|
this._codexConfig = config.codexConfig;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 20:10:49 -04:00
|
|
|
|
// Apply Gemini configuration
|
|
|
|
|
|
if (config.geminiConfig) {
|
|
|
|
|
|
this._geminiConfig = config.geminiConfig;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 10:33:11 +08:00
|
|
|
|
// Apply env overrides (exported at spawn, not persisted to disk).
|
|
|
|
|
|
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
|
|
|
|
|
|
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
|
|
|
|
|
|
// and never export it as an env var again. Explicit config.effort wins over legacy.
|
2026-04-22 00:01:35 +08:00
|
|
|
|
if (config.envOverrides && Object.keys(config.envOverrides).length > 0) {
|
2026-06-07 10:33:11 +08:00
|
|
|
|
const { CLAUDE_CODE_EFFORT_LEVEL: legacyEffort, ...restOverrides } = config.envOverrides;
|
|
|
|
|
|
this._envOverrides = Object.keys(restOverrides).length > 0 ? restOverrides : undefined;
|
|
|
|
|
|
if (legacyEffort && isEffortLevel(legacyEffort)) {
|
|
|
|
|
|
this._effort = legacyEffort;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (config.effort && isEffortLevel(config.effort)) {
|
|
|
|
|
|
this._effort = config.effort;
|
2026-04-22 00:01:35 +08:00
|
|
|
|
}
|
2026-06-29 17:49:39 -04:00
|
|
|
|
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
|
2026-06-11 18:43:39 +00:00
|
|
|
|
this._remote = config.remote;
|
2026-06-12 14:37:27 -04:00
|
|
|
|
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
|
|
|
|
|
this.restoreAttachmentHistory(config.attachmentHistory);
|
|
|
|
|
|
}
|
2026-04-22 00:01:35 +08:00
|
|
|
|
|
2026-01-21 05:50:09 +01:00
|
|
|
|
// Initialize task tracker and forward events (store handlers for cleanup)
|
2026-01-19 00:38:11 +01:00
|
|
|
|
this._taskTracker = new TaskTracker();
|
2026-01-21 05:50:09 +01:00
|
|
|
|
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);
|
2026-01-19 13:21:01 +01:00
|
|
|
|
|
2026-01-21 22:36:45 +01:00
|
|
|
|
// 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),
|
2026-01-27 00:29:38 +01:00
|
|
|
|
statusBlockDetected: (block) => this.emit('ralphStatusBlockDetected', block),
|
|
|
|
|
|
circuitBreakerUpdate: (status) => this.emit('ralphCircuitBreakerUpdate', status),
|
|
|
|
|
|
exitGateMet: (data) => this.emit('ralphExitGateMet', data),
|
2026-01-21 05:50:09 +01:00
|
|
|
|
};
|
2026-01-21 22:36:45 +01:00
|
|
|
|
this._ralphTracker.on('loopUpdate', this._ralphHandlers.loopUpdate);
|
|
|
|
|
|
this._ralphTracker.on('todoUpdate', this._ralphHandlers.todoUpdate);
|
|
|
|
|
|
this._ralphTracker.on('completionDetected', this._ralphHandlers.completionDetected);
|
2026-01-27 00:29:38 +01:00
|
|
|
|
this._ralphTracker.on('statusBlockDetected', this._ralphHandlers.statusBlockDetected);
|
|
|
|
|
|
this._ralphTracker.on('circuitBreakerUpdate', this._ralphHandlers.circuitBreakerUpdate);
|
|
|
|
|
|
this._ralphTracker.on('exitGateMet', this._ralphHandlers.exitGateMet);
|
2026-01-23 10:20:37 +01:00
|
|
|
|
|
2026-01-26 00:55:29 +01:00
|
|
|
|
// Initialize Bash tool parser and forward events (store handlers for cleanup)
|
2026-01-26 04:35:50 +01:00
|
|
|
|
this._bashToolParser = new BashToolParser({ sessionId: this.id, workingDir: this.workingDir });
|
2026-01-26 00:55:29 +01:00
|
|
|
|
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);
|
2026-03-01 04:04:35 +01:00
|
|
|
|
|
|
|
|
|
|
// Initialize auto-compact/auto-clear automation and forward events
|
|
|
|
|
|
this._autoOps = new SessionAutoOps({
|
|
|
|
|
|
writeCommand: (cmd) => this.writeViaMux(cmd),
|
|
|
|
|
|
isWorking: () => this._isWorking,
|
|
|
|
|
|
isStopped: () => this._isStopped,
|
|
|
|
|
|
getTotalTokens: () => this._totalInputTokens + this._totalOutputTokens,
|
|
|
|
|
|
getSessionId: () => this.id,
|
|
|
|
|
|
});
|
|
|
|
|
|
this._autoOps.on('autoCompact', (data) => this.emit('autoCompact', data));
|
|
|
|
|
|
this._autoOps.on('autoClear', (data) => {
|
|
|
|
|
|
// Reset token counts on clear
|
|
|
|
|
|
this._totalInputTokens = 0;
|
|
|
|
|
|
this._totalOutputTokens = 0;
|
|
|
|
|
|
this.emit('autoClear', data);
|
|
|
|
|
|
});
|
2026-06-10 20:41:34 +02:00
|
|
|
|
this._autoOps.on('limitPauseScheduled', (data) => this.emit('limitPauseScheduled', data));
|
|
|
|
|
|
this._autoOps.on('limitResume', (data) => this.emit('limitResume', data));
|
|
|
|
|
|
this._autoOps.on('limitResumeCancelled', (data) => this.emit('limitResumeCancelled', data));
|
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 {
|
2026-01-21 04:55:48 +01:00
|
|
|
|
return this._terminalBuffer.value;
|
2026-01-18 04:50:46 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 15:04:45 +01:00
|
|
|
|
get terminalBufferLength(): number {
|
|
|
|
|
|
return this._terminalBuffer.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
get textOutput(): string {
|
2026-01-21 04:55:48 +01:00
|
|
|
|
return this._textOutput.value;
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 09:03:11 +08:00
|
|
|
|
// Adopt a Claude conversation ID observed from an external source (e.g. hook
|
|
|
|
|
|
// payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so
|
|
|
|
|
|
// `_handleJsonMessage` never sees `session_id`; hooks are the only signal
|
|
|
|
|
|
// that conveys a post-/clear conversation switch.
|
|
|
|
|
|
adoptClaudeSessionId(newId: string): void {
|
|
|
|
|
|
if (!newId || newId === this._claudeSessionId) return;
|
|
|
|
|
|
this._claudeSessionId = newId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 07:20:09 +02:00
|
|
|
|
/** The tmux session name, if the session is running inside a mux */
|
|
|
|
|
|
get muxName(): string | null {
|
|
|
|
|
|
return this._muxSession?.muxName ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-28 04:34:53 +01:00
|
|
|
|
/**
|
2026-03-28 05:14:39 +01:00
|
|
|
|
* Check if the session's process tree has active child processes beyond Claude itself.
|
|
|
|
|
|
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The tmux pane PID is typically "claude" directly (bash exec'd into it). When Claude
|
|
|
|
|
|
* runs a bash tool, it spawns child processes: claude → bash → npm/node/python/etc.
|
|
|
|
|
|
* We check direct children of the pane PID, filtering out "claude" itself (for the rare
|
|
|
|
|
|
* case where bash wraps claude and didn't exec).
|
2026-03-28 04:34:53 +01:00
|
|
|
|
*
|
|
|
|
|
|
* Returns an array of {pid, command} for each child process, or empty array if none.
|
|
|
|
|
|
* Returns empty array if no mux session or on error (fail-open to avoid blocking respawn).
|
|
|
|
|
|
*/
|
|
|
|
|
|
getActiveChildProcesses(): { pid: number; command: string }[] {
|
|
|
|
|
|
if (!this._muxSession) return [];
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const panePid = this._muxSession.pid;
|
2026-03-28 05:14:39 +01:00
|
|
|
|
|
|
|
|
|
|
// Single call: get direct children with their command names
|
|
|
|
|
|
const output = execSync(`ps -o pid=,comm= --ppid ${panePid} 2>/dev/null`, {
|
2026-03-28 04:34:53 +01:00
|
|
|
|
encoding: 'utf-8',
|
|
|
|
|
|
timeout: EXEC_TIMEOUT_MS,
|
|
|
|
|
|
}).trim();
|
2026-03-28 05:14:39 +01:00
|
|
|
|
if (!output) return [];
|
2026-03-28 04:34:53 +01:00
|
|
|
|
|
|
|
|
|
|
const activeProcesses: { pid: number; command: string }[] = [];
|
2026-03-28 05:14:39 +01:00
|
|
|
|
for (const line of output.split('\n')) {
|
|
|
|
|
|
const match = line.trim().match(/^(\d+)\s+(.+)/);
|
|
|
|
|
|
if (!match) continue;
|
|
|
|
|
|
const pid = parseInt(match[1], 10);
|
|
|
|
|
|
const command = match[2].trim();
|
|
|
|
|
|
// Skip the claude process itself (pane_pid may be bash wrapping claude)
|
|
|
|
|
|
if (command === 'claude') continue;
|
|
|
|
|
|
activeProcesses.push({ pid, command });
|
2026-03-28 04:34:53 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return activeProcesses;
|
|
|
|
|
|
} catch {
|
2026-03-28 05:14:39 +01:00
|
|
|
|
// ps returns exit code 1 when no matches — normal (no children)
|
2026-03-28 04:34:53 +01:00
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
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-21 22:36:45 +01:00
|
|
|
|
// Ralph tracking getters
|
|
|
|
|
|
get ralphTracker(): RalphTracker {
|
|
|
|
|
|
return this._ralphTracker;
|
2026-01-19 13:21:01 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 22:36:45 +01:00
|
|
|
|
get ralphLoopState(): RalphTrackerState {
|
|
|
|
|
|
return this._ralphTracker.loopState;
|
2026-01-19 13:21:01 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 22:36:45 +01:00
|
|
|
|
get ralphTodos(): RalphTodoItem[] {
|
|
|
|
|
|
return this._ralphTracker.todos;
|
2026-01-19 13:21:01 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 22:36:45 +01:00
|
|
|
|
get ralphTodoStats(): { total: number; pending: number; inProgress: number; completed: number } {
|
|
|
|
|
|
return this._ralphTracker.getTodoStats();
|
2026-01-19 13:21:01 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-26 00:55:29 +01:00
|
|
|
|
// Bash tool tracking getters
|
|
|
|
|
|
get bashToolParser(): BashToolParser {
|
|
|
|
|
|
return this._bashToolParser;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get activeTools(): ActiveBashTool[] {
|
|
|
|
|
|
return this._bashToolParser.activeTools;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-23 10:20:37 +01:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-27 11:43:20 +01:00
|
|
|
|
// Nice priority config getters and setters
|
|
|
|
|
|
get niceConfig(): NiceConfig {
|
|
|
|
|
|
return { ...this._niceConfig };
|
2026-01-27 01:58:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 15:52:55 +01:00
|
|
|
|
/** Claude CLI startup permission mode */
|
|
|
|
|
|
get claudeMode(): ClaudeMode {
|
|
|
|
|
|
return this._claudeMode;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Allowed tools list (for 'allowedTools' mode) */
|
|
|
|
|
|
get allowedTools(): string | undefined {
|
|
|
|
|
|
return this._allowedTools;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 09:31:58 -04:00
|
|
|
|
/** Codex CLI configuration for this session. */
|
|
|
|
|
|
get codexConfig(): CodexConfig | undefined {
|
|
|
|
|
|
return this._codexConfig;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 04:04:35 +01:00
|
|
|
|
// Note: _buildPermissionArgs removed — now using buildInteractiveArgs from session-cli-builder.ts
|
2026-02-24 15:52:55 +01:00
|
|
|
|
|
2026-01-27 01:58:02 +01:00
|
|
|
|
/**
|
2026-01-27 11:43:20 +01:00
|
|
|
|
* Set CPU priority configuration.
|
|
|
|
|
|
* Note: This only affects new sessions; existing running processes won't be changed.
|
2026-01-27 01:58:02 +01:00
|
|
|
|
*/
|
2026-01-27 11:43:20 +01:00
|
|
|
|
setNice(config: Partial<NiceConfig>): void {
|
2026-01-27 01:58:02 +01:00
|
|
|
|
if (config.enabled !== undefined) {
|
2026-01-27 11:43:20 +01:00
|
|
|
|
this._niceConfig.enabled = config.enabled;
|
2026-01-27 01:58:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
if (config.niceValue !== undefined) {
|
|
|
|
|
|
// Clamp to valid range
|
2026-01-27 11:43:20 +01:00
|
|
|
|
this._niceConfig.niceValue = Math.max(-20, Math.min(19, config.niceValue));
|
2026-01-27 01:58:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-30 13:25:18 +01:00
|
|
|
|
// Session color for visual differentiation
|
|
|
|
|
|
get color(): import('./types.js').SessionColor {
|
|
|
|
|
|
return this._color;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setColor(color: import('./types.js').SessionColor): void {
|
|
|
|
|
|
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
|
|
|
|
|
if (validColors.includes(color)) {
|
|
|
|
|
|
this._color = color;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-25 07:26:49 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Restore token and cost values from saved state.
|
|
|
|
|
|
* Called when recovering sessions after server restart.
|
|
|
|
|
|
*/
|
|
|
|
|
|
restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void {
|
2026-01-31 23:47:49 +01:00
|
|
|
|
// Sanity check: reject absurdly large individual values
|
2026-01-26 12:22:05 +01:00
|
|
|
|
if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) {
|
2026-02-27 13:16:23 +01:00
|
|
|
|
console.warn(
|
|
|
|
|
|
`[Session ${this.id}] Rejected absurd restored tokens: input=${inputTokens}, output=${outputTokens}`
|
|
|
|
|
|
);
|
2026-01-26 12:22:05 +01:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-31 23:47:49 +01:00
|
|
|
|
// Check token sum doesn't overflow MAX_SESSION_TOKENS
|
|
|
|
|
|
if (inputTokens + outputTokens > MAX_SESSION_TOKENS) {
|
2026-02-27 13:16:23 +01:00
|
|
|
|
console.warn(
|
|
|
|
|
|
`[Session ${this.id}] Rejected token sum overflow: input=${inputTokens} + output=${outputTokens} = ${inputTokens + outputTokens} > ${MAX_SESSION_TOKENS}`
|
|
|
|
|
|
);
|
2026-01-31 23:47:49 +01:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-26 12:22:05 +01:00
|
|
|
|
// Reject negative values
|
|
|
|
|
|
if (inputTokens < 0 || outputTokens < 0 || totalCost < 0) {
|
2026-02-27 13:16:23 +01:00
|
|
|
|
console.warn(
|
|
|
|
|
|
`[Session ${this.id}] Rejected negative restored tokens: input=${inputTokens}, output=${outputTokens}, cost=${totalCost}`
|
|
|
|
|
|
);
|
2026-01-26 12:22:05 +01:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-25 07:26:49 +01:00
|
|
|
|
this._totalInputTokens = inputTokens;
|
|
|
|
|
|
this._totalOutputTokens = outputTokens;
|
|
|
|
|
|
this._totalCost = totalCost;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 00:38:11 +01:00
|
|
|
|
get autoClearThreshold(): number {
|
2026-03-01 04:04:35 +01:00
|
|
|
|
return this._autoOps.autoClearThreshold;
|
2026-01-19 00:38:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get autoClearEnabled(): boolean {
|
2026-03-01 04:04:35 +01:00
|
|
|
|
return this._autoOps.autoClearEnabled;
|
2026-01-19 00:38:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
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 {
|
2026-03-01 04:04:35 +01:00
|
|
|
|
this._autoOps.setAutoClear(enabled, threshold);
|
2026-01-19 00:38:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 13:08:53 +01:00
|
|
|
|
get autoCompactThreshold(): number {
|
2026-03-01 04:04:35 +01:00
|
|
|
|
return this._autoOps.autoCompactThreshold;
|
2026-01-19 13:08:53 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get autoCompactEnabled(): boolean {
|
2026-03-01 04:04:35 +01:00
|
|
|
|
return this._autoOps.autoCompactEnabled;
|
2026-01-19 13:08:53 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get autoCompactPrompt(): string {
|
2026-03-01 04:04:35 +01:00
|
|
|
|
return this._autoOps.autoCompactPrompt;
|
2026-01-19 13:08:53 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setAutoCompact(enabled: boolean, threshold?: number, prompt?: string): void {
|
2026-03-01 04:04:35 +01:00
|
|
|
|
this._autoOps.setAutoCompact(enabled, threshold, prompt);
|
2026-01-19 13:08:53 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 20:41:34 +02:00
|
|
|
|
get autoResumeEnabled(): boolean {
|
|
|
|
|
|
return this._autoOps.autoResumeEnabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** When the scheduled usage-limit auto-resume fires (epoch ms), or null. */
|
|
|
|
|
|
get autoResumeAt(): number | null {
|
|
|
|
|
|
return this._autoOps.autoResumeAt;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** True while the session is paused on a Claude usage limit (auto-resume armed). */
|
|
|
|
|
|
get isLimitPaused(): boolean {
|
|
|
|
|
|
return this._autoOps.isLimitPaused;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setAutoResume(enabled: boolean): void {
|
|
|
|
|
|
this._autoOps.setAutoResume(enabled);
|
|
|
|
|
|
// Users typically enable this WHILE a session already sits paused — the
|
|
|
|
|
|
// limit footer won't reprint on its own, so scan the recent buffer once.
|
|
|
|
|
|
// Only a future reset time counts: stale scrollback must not arm a resume.
|
|
|
|
|
|
if (enabled && !isExternalCliMode(this.mode)) {
|
|
|
|
|
|
const tail = this._terminalBuffer.value.slice(-8192).replace(ANSI_ESCAPE_PATTERN_FULL, '');
|
|
|
|
|
|
const detection = detectUsageLimitPause(tail);
|
|
|
|
|
|
if (detection && detection.resetAt > Date.now()) {
|
|
|
|
|
|
this._autoOps.processCleanData(tail);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Restore auto-resume state (and a pending schedule) after Codeman restart. */
|
|
|
|
|
|
restoreAutoResume(enabled: boolean, resumeAt?: number): void {
|
|
|
|
|
|
this._autoOps.restoreAutoResume(enabled, resumeAt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-28 07:18:45 +01:00
|
|
|
|
get imageWatcherEnabled(): boolean {
|
|
|
|
|
|
return this._imageWatcherEnabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
set imageWatcherEnabled(enabled: boolean) {
|
|
|
|
|
|
this._imageWatcherEnabled = enabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-30 17:50:02 +01:00
|
|
|
|
get flickerFilterEnabled(): boolean {
|
|
|
|
|
|
return this._flickerFilterEnabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
set flickerFilterEnabled(enabled: boolean) {
|
|
|
|
|
|
this._flickerFilterEnabled = enabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 14:37:27 -04:00
|
|
|
|
get attachmentHistory(): SessionAttachmentHistoryItem[] {
|
|
|
|
|
|
return sanitizeAttachmentHistory(this._attachmentHistory);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
upsertAttachmentHistory(item: SessionAttachmentHistoryItem): void {
|
|
|
|
|
|
this._attachmentHistory = upsertAttachmentHistoryList(this._attachmentHistory, item);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
restoreAttachmentHistory(history: SessionAttachmentHistoryItem[] | undefined): void {
|
|
|
|
|
|
this._attachmentHistory = [];
|
|
|
|
|
|
for (const item of [...(history ?? [])].reverse()) {
|
2026-06-14 09:30:01 +02:00
|
|
|
|
// Guard against malformed/legacy on-disk entries (null, non-object, or
|
|
|
|
|
|
// missing required fields). historyKey() dereferences source/fileName, so
|
|
|
|
|
|
// a bad item would otherwise throw inside the constructor and abort the
|
|
|
|
|
|
// entire mux-recovery loop.
|
|
|
|
|
|
if (!item || typeof item !== 'object' || !item.source || !item.fileName) continue;
|
2026-06-12 14:37:27 -04:00
|
|
|
|
this.upsertAttachmentHistory(item);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getAttachmentHistoryForPersist(): SessionAttachmentHistoryItem[] | undefined {
|
|
|
|
|
|
return this._attachmentHistory.length > 0 ? this._attachmentHistory.map((item) => ({ ...item })) : undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 04:50:46 +01:00
|
|
|
|
toState(): SessionState {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: this.id,
|
|
|
|
|
|
pid: this.pid,
|
|
|
|
|
|
status: this._status,
|
|
|
|
|
|
workingDir: this.workingDir,
|
2026-06-11 18:43:39 +00:00
|
|
|
|
remote: this._remote,
|
2026-01-18 04:50:46 +01:00
|
|
|
|
currentTaskId: this._currentTaskId,
|
|
|
|
|
|
createdAt: this.createdAt,
|
|
|
|
|
|
lastActivityAt: this._lastActivityAt,
|
2026-01-22 20:36:06 +01:00
|
|
|
|
name: this._name,
|
|
|
|
|
|
mode: this.mode,
|
2026-03-01 04:04:35 +01:00
|
|
|
|
autoClearEnabled: this._autoOps.autoClearEnabled,
|
|
|
|
|
|
autoClearThreshold: this._autoOps.autoClearThreshold,
|
|
|
|
|
|
autoCompactEnabled: this._autoOps.autoCompactEnabled,
|
|
|
|
|
|
autoCompactThreshold: this._autoOps.autoCompactThreshold,
|
|
|
|
|
|
autoCompactPrompt: this._autoOps.autoCompactPrompt,
|
2026-06-10 20:41:34 +02:00
|
|
|
|
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
|
|
|
|
|
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
2026-01-28 07:18:45 +01:00
|
|
|
|
imageWatcherEnabled: this._imageWatcherEnabled,
|
2026-01-22 20:36:06 +01:00
|
|
|
|
totalCost: this._totalCost,
|
|
|
|
|
|
inputTokens: this._totalInputTokens,
|
|
|
|
|
|
outputTokens: this._totalOutputTokens,
|
|
|
|
|
|
ralphEnabled: this._ralphTracker.enabled,
|
2026-01-30 17:20:53 +01:00
|
|
|
|
ralphAutoEnableDisabled: this._ralphTracker.autoEnableDisabled || undefined,
|
2026-01-22 20:36:06 +01:00
|
|
|
|
ralphCompletionPhrase: this._ralphTracker.loopState.completionPhrase || undefined,
|
2026-01-23 10:20:37 +01:00
|
|
|
|
parentAgentId: this._parentAgentId || undefined,
|
|
|
|
|
|
childAgentIds: this._childAgentIds.length > 0 ? this._childAgentIds : undefined,
|
2026-01-27 11:43:20 +01:00
|
|
|
|
niceEnabled: this._niceConfig.enabled,
|
|
|
|
|
|
niceValue: this._niceConfig.niceValue,
|
2026-01-30 13:25:18 +01:00
|
|
|
|
color: this._color,
|
2026-01-30 17:50:02 +01:00
|
|
|
|
flickerFilterEnabled: this._flickerFilterEnabled,
|
2026-01-31 04:44:46 +01:00
|
|
|
|
cliVersion: this._cliVersion || undefined,
|
|
|
|
|
|
cliModel: this._cliModel || undefined,
|
|
|
|
|
|
cliAccountType: this._cliAccountType || undefined,
|
|
|
|
|
|
cliLatestVersion: this._cliLatestVersion || undefined,
|
2026-02-26 10:07:43 +01:00
|
|
|
|
openCodeConfig: this._openCodeConfig,
|
2026-06-10 09:31:58 -04:00
|
|
|
|
codexConfig: this._codexConfig,
|
2026-05-16 20:10:49 -04:00
|
|
|
|
geminiConfig: this._geminiConfig,
|
2026-03-10 02:22:53 +08:00
|
|
|
|
resumeSessionId: this._resumeSessionId,
|
2026-06-07 10:33:11 +08:00
|
|
|
|
effort: this._effort,
|
2026-07-12 18:21:42 +02:00
|
|
|
|
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
|
|
|
|
|
// intent before restarting a crash-looped session. Deliberately NOT restored
|
|
|
|
|
|
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
|
|
|
|
|
// recovery can re-attach.
|
|
|
|
|
|
respawnBlocked: this._respawnBlocked || undefined,
|
2026-06-12 14:37:27 -04:00
|
|
|
|
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
2026-04-22 00:01:35 +08:00
|
|
|
|
// envOverrides intentionally NOT on the public SessionState type — they must not
|
|
|
|
|
|
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
|
|
|
|
|
|
// can carry secrets). For disk persistence, session-manager calls
|
|
|
|
|
|
// getEnvOverridesForPersist() and writes alongside state.
|
2026-01-18 04:50:46 +01:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 00:01:35 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Returns a subset of env overrides safe for disk persistence (state.json).
|
|
|
|
|
|
* Only non-sensitive `CLAUDE_CODE_*` keys are included. `OPENCODE_*` keys are
|
|
|
|
|
|
* filtered out because the schema permits them and they can carry secrets
|
|
|
|
|
|
* (e.g., OPENCODE_API_KEY); secrets must not land in `~/.codeman/state.json`.
|
|
|
|
|
|
* Must NOT be included in any API-bound serializer — see toState() comment.
|
|
|
|
|
|
*/
|
|
|
|
|
|
getEnvOverridesForPersist(): Record<string, string> | undefined {
|
|
|
|
|
|
if (!this._envOverrides) return undefined;
|
|
|
|
|
|
const safe: Record<string, string> = {};
|
|
|
|
|
|
for (const [key, value] of Object.entries(this._envOverrides)) {
|
|
|
|
|
|
if (key.startsWith('CLAUDE_CODE_')) safe[key] = value;
|
|
|
|
|
|
}
|
|
|
|
|
|
return Object.keys(safe).length > 0 ? safe : undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 05:14:34 +01:00
|
|
|
|
toDetailedState() {
|
2026-02-13 21:07:49 +01:00
|
|
|
|
return {
|
|
|
|
|
|
...this.toLightDetailedState(),
|
|
|
|
|
|
textOutput: this._textOutput.value,
|
|
|
|
|
|
terminalBuffer: this._terminalBuffer.value,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Lightweight detailed state that excludes heavy buffers (textOutput, terminalBuffer).
|
|
|
|
|
|
* Use for SSE session:updated broadcasts where buffers aren't needed.
|
|
|
|
|
|
* Full buffers are fetched on-demand via /api/sessions/:id/terminal.
|
|
|
|
|
|
*/
|
|
|
|
|
|
toLightDetailedState() {
|
2026-01-18 05:14:34 +01:00
|
|
|
|
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,
|
|
|
|
|
|
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-02-20 06:50:51 +01:00
|
|
|
|
// Background task tracking (light tree strips large output strings)
|
2026-01-19 00:38:11 +01:00
|
|
|
|
taskStats: this._taskTracker.getStats(),
|
2026-02-20 06:50:51 +01:00
|
|
|
|
taskTree: this._taskTracker.getTaskTreeLight(),
|
2026-01-19 00:38:11 +01:00
|
|
|
|
// Token tracking
|
|
|
|
|
|
tokens: {
|
|
|
|
|
|
input: this._totalInputTokens,
|
|
|
|
|
|
output: this._totalOutputTokens,
|
|
|
|
|
|
total: this._totalInputTokens + this._totalOutputTokens,
|
|
|
|
|
|
},
|
|
|
|
|
|
autoClear: {
|
2026-03-01 04:04:35 +01:00
|
|
|
|
enabled: this._autoOps.autoClearEnabled,
|
|
|
|
|
|
threshold: this._autoOps.autoClearThreshold,
|
2026-01-19 00:38:11 +01:00
|
|
|
|
},
|
2026-01-27 11:43:20 +01:00
|
|
|
|
// CPU priority configuration
|
|
|
|
|
|
nice: {
|
|
|
|
|
|
enabled: this._niceConfig.enabled,
|
|
|
|
|
|
niceValue: this._niceConfig.niceValue,
|
2026-01-27 01:58:02 +01:00
|
|
|
|
},
|
2026-01-21 22:36:45 +01:00
|
|
|
|
// Ralph tracking state
|
|
|
|
|
|
ralphLoop: this._ralphTracker.loopState,
|
|
|
|
|
|
ralphTodos: this._ralphTracker.todos,
|
|
|
|
|
|
ralphTodoStats: this._ralphTracker.getTodoStats(),
|
2026-01-18 05:14:34 +01:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Starts an interactive Claude CLI session with full terminal support.
|
|
|
|
|
|
*
|
2026-02-24 15:52:55 +01:00
|
|
|
|
* This spawns Claude CLI in interactive mode with the configured permission
|
|
|
|
|
|
* mode (default: `--dangerously-skip-permissions`). If mux wrapping is enabled,
|
|
|
|
|
|
* the session runs inside a tmux session for persistence across disconnects.
|
2026-01-21 04:25:53 +01:00
|
|
|
|
*
|
|
|
|
|
|
* @throws {Error} If a process is already running in this session
|
|
|
|
|
|
*
|
|
|
|
|
|
* @example
|
|
|
|
|
|
* ```typescript
|
2026-02-18 12:51:35 +01:00
|
|
|
|
* const session = new Session({ workingDir: '/project', useMux: true });
|
2026-01-21 04:25:53 +01:00
|
|
|
|
* await session.startInteractive();
|
|
|
|
|
|
* session.on('terminal', (data) => process.stdout.write(data));
|
|
|
|
|
|
* session.write('help me with this code\r');
|
|
|
|
|
|
* ```
|
|
|
|
|
|
*/
|
2026-03-26 22:50:25 +01:00
|
|
|
|
private async _setupOrAttachMuxSession(options: {
|
|
|
|
|
|
respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
|
|
|
|
|
|
createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
|
|
|
|
|
|
spawnErrLabel: string;
|
|
|
|
|
|
}): Promise<{ isRestored: boolean }> {
|
|
|
|
|
|
const mux = this._mux!;
|
|
|
|
|
|
|
|
|
|
|
|
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
|
|
|
|
|
|
if (this._muxSession && !mux.muxSessionExists(this._muxSession.muxName)) {
|
|
|
|
|
|
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
|
|
|
|
|
this._muxSession = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
|
|
|
|
|
|
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
|
|
|
|
|
|
let needsNewSession = false;
|
|
|
|
|
|
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
|
|
|
|
|
|
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
|
|
|
|
|
const newPid = await mux.respawnPane(options.respawnPaneOptions);
|
|
|
|
|
|
if (!newPid) {
|
|
|
|
|
|
console.error('[Session] Failed to respawn pane, will create new session');
|
|
|
|
|
|
needsNewSession = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Wait a moment for the respawned process to fully start
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if we already have a mux session (restored session)
|
|
|
|
|
|
const isRestored = this._muxSession !== null && !needsNewSession;
|
|
|
|
|
|
if (isRestored) {
|
|
|
|
|
|
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Create a new mux session
|
|
|
|
|
|
this._muxSession = await mux.createSession(options.createSessionOptions);
|
|
|
|
|
|
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
|
|
|
|
|
// No extra sleep — createSession() already waits for tmux readiness
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Attach to the mux session via PTY
|
2026-06-09 10:38:35 -04:00
|
|
|
|
// Prevent tmux from letting the newest browser attach dictate global window
|
|
|
|
|
|
// size; accepted Codeman resize events update it explicitly below.
|
|
|
|
|
|
mux.setManualWindowSize?.(this._muxSession!.muxName);
|
|
|
|
|
|
// Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default).
|
|
|
|
|
|
// MUST go through the dedicated socket (mux.muxSocket); a bare `tmux display` hits the
|
|
|
|
|
|
// default server, always fails for our socketed sessions, and silently falls back to 120x40.
|
2026-05-26 05:28:58 +08:00
|
|
|
|
const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket);
|
2026-03-26 22:50:25 +01:00
|
|
|
|
try {
|
|
|
|
|
|
this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), {
|
|
|
|
|
|
name: 'xterm-256color',
|
2026-05-11 16:32:17 -04:00
|
|
|
|
cols: ptyCols,
|
|
|
|
|
|
rows: ptyRows,
|
2026-06-11 18:43:39 +00:00
|
|
|
|
cwd: resolveMuxAttachCwd(this.workingDir, this._remote),
|
2026-07-12 18:21:42 +02:00
|
|
|
|
// COD-75: codex/gemini get COLORTERM=truecolor — mirrors buildEnvExports()
|
|
|
|
|
|
// in tmux-manager.ts so the attach client and the tmux session agree.
|
|
|
|
|
|
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini'),
|
2026-03-26 22:50:25 +01:00
|
|
|
|
});
|
|
|
|
|
|
} catch (spawnErr) {
|
|
|
|
|
|
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
|
|
|
|
|
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
|
|
|
|
|
throw spawnErr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { isRestored };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private _handleTerminalOutput(data: string): void {
|
2026-06-16 18:08:45 +02:00
|
|
|
|
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
|
|
|
|
|
|
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
|
|
|
|
|
|
// scrollback. Claude Code does this intermittently (e.g. full-screen pickers /
|
|
|
|
|
|
// dialogs), which is why terminal scroll-up "randomly" breaks for Claude
|
|
|
|
|
|
// sessions on mobile and desktop until the dialog closes:
|
2026-06-10 13:18:45 -04:00
|
|
|
|
// - \x1b[?1049h / \x1b[?47h / \x1b[?1047h: switch to the alt buffer (no
|
|
|
|
|
|
// scrollback) — \x1b[?...l switches back.
|
|
|
|
|
|
// - \x1b[3J: erase saved lines (scrollback). (\x1b[2J / \x1b[J — erase
|
|
|
|
|
|
// the visible viewport — are left intact; the TUI repaints those rows.)
|
|
|
|
|
|
// - \x1b[?1000h / 1002h / 1003h / 1005h / 1006h / 1007h: mouse-tracking
|
|
|
|
|
|
// modes (X10, button-event, any-event, UTF-8, SGR, alt-scroll). Once on,
|
2026-06-16 18:08:45 +02:00
|
|
|
|
// xterm.js forwards wheel events to the CLI instead of scrolling the
|
2026-06-10 13:18:45 -04:00
|
|
|
|
// viewport, so the conversation is in scrollback but unreachable.
|
|
|
|
|
|
// (Focus events at ?1004 are left alone — codeman uses them for
|
|
|
|
|
|
// active-tab detection.)
|
|
|
|
|
|
// Strip them at the source so neither the persisted buffer nor the live
|
|
|
|
|
|
// SSE/WS stream carries them, keeping everything in the main buffer with
|
2026-06-16 18:08:45 +02:00
|
|
|
|
// scrollback intact. These are controlled TUIs whose cursor-positioned
|
|
|
|
|
|
// redraws overwrite only the cells they target, so non-erased rows keep
|
|
|
|
|
|
// their content. Gated to Codex/Claude (isAltScreenStripMode) — shell must
|
|
|
|
|
|
// keep the alt screen for vim/less/htop.
|
|
|
|
|
|
if (isAltScreenStripMode(this.mode)) {
|
2026-06-10 22:47:02 +02:00
|
|
|
|
// Reassemble sequences split across PTY chunk boundaries first: a chunk
|
|
|
|
|
|
// ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the
|
|
|
|
|
|
// strip below and leave xterm stuck in the scrollback-less alt buffer
|
|
|
|
|
|
// until the next buffer replay. Hold back an incomplete digit-only CSI
|
|
|
|
|
|
// tail (≤7 chars — the longest strippable intro is '\x1b[?1049') and
|
|
|
|
|
|
// prepend it to the next chunk; complete sequences are never held.
|
2026-06-16 18:08:45 +02:00
|
|
|
|
data = this._altScreenSeqCarry + data;
|
|
|
|
|
|
this._altScreenSeqCarry = '';
|
2026-06-10 22:47:02 +02:00
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
|
const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/);
|
|
|
|
|
|
if (splitTail) {
|
2026-06-16 18:08:45 +02:00
|
|
|
|
this._altScreenSeqCarry = splitTail[0];
|
2026-06-10 22:47:02 +02:00
|
|
|
|
data = data.slice(0, -splitTail[0].length);
|
|
|
|
|
|
if (!data) return;
|
|
|
|
|
|
}
|
2026-06-10 13:18:45 -04:00
|
|
|
|
data = data
|
|
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
|
.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, '')
|
|
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
|
.replace(/\x1b\[3J/g, '')
|
|
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
|
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 09:57:47 +00:00
|
|
|
|
// Scan terminal output for attachment requests. `codeman://attach?...` is an
|
2026-07-12 17:50:21 +02:00
|
|
|
|
// explicit magic link (all modes); Codex generated images report
|
|
|
|
|
|
// `Saved to: file://...` — that scanner (and its relaxed trust policy) is
|
|
|
|
|
|
// only enabled for codex-mode sessions. The web server applies the trust
|
|
|
|
|
|
// boundary for each request source.
|
|
|
|
|
|
const attachmentRequests = parseTerminalAttachmentRequests(data, { codexArtifacts: this.mode === 'codex' });
|
2026-07-01 09:57:47 +00:00
|
|
|
|
for (const request of attachmentRequests) {
|
|
|
|
|
|
const seenKey = `${request.source}:${request.path}`;
|
|
|
|
|
|
if (this._attachmentMagicSeen.has(seenKey)) continue;
|
|
|
|
|
|
this._attachmentMagicSeen.add(seenKey);
|
2026-06-10 20:42:17 -04:00
|
|
|
|
if (this._attachmentMagicSeen.size > 200) {
|
|
|
|
|
|
const oldest = this._attachmentMagicSeen.values().next().value;
|
|
|
|
|
|
if (oldest) this._attachmentMagicSeen.delete(oldest);
|
|
|
|
|
|
}
|
2026-07-01 09:57:47 +00:00
|
|
|
|
this.emit('attachmentRequested', {
|
|
|
|
|
|
sessionId: this.id,
|
|
|
|
|
|
path: request.path,
|
|
|
|
|
|
source: request.source,
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
});
|
2026-06-10 20:42:17 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 22:50:25 +01:00
|
|
|
|
// BufferAccumulator handles auto-trimming when max size exceeded
|
|
|
|
|
|
this._terminalBuffer.append(data);
|
|
|
|
|
|
this._lastActivityAt = Date.now();
|
|
|
|
|
|
this.emit('terminal', data);
|
|
|
|
|
|
this.emit('output', data);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
async startInteractive(): Promise<void> {
|
|
|
|
|
|
if (this.ptyProcess) {
|
|
|
|
|
|
throw new Error('Session already has a running process');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 08:53:29 -04:00
|
|
|
|
// COD-118: if the PTY exit breaker has tripped (repeated non-zero exits in a
|
|
|
|
|
|
// short window), refuse to respawn. This is the uniform choke point that stops
|
|
|
|
|
|
// automatic recovery/reconnect callers from re-creating a crash-looping PTY.
|
|
|
|
|
|
// An explicit user restart clears it via resetRespawnBreaker().
|
|
|
|
|
|
if (this._respawnBlocked) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
'Respawn blocked: interactive PTY exited non-zero too many times in a short window (circuit breaker tripped). Restart the session to clear it.'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 23:21:38 +01:00
|
|
|
|
this._resetBuffers();
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
2026-05-16 20:10:49 -04:00
|
|
|
|
const modeLabel = getModeLabel(this.mode);
|
2026-02-27 13:16:23 +01:00
|
|
|
|
console.log(
|
|
|
|
|
|
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
|
|
|
|
|
);
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
2026-07-16 09:33:39 +02:00
|
|
|
|
// Seed the CLI version deterministically for LOCAL Claude sessions. The
|
|
|
|
|
|
// banner scrape in parseClaudeCodeInfo() is unreliable — newer Claude Code
|
|
|
|
|
|
// builds don't print "Claude Code vX.Y.Z" at startup and resumed sessions
|
|
|
|
|
|
// never show it — which left cliVersion undefined and silently disabled
|
|
|
|
|
|
// wheel-forwarding to Claude's own transcript (the only route to history in
|
|
|
|
|
|
// repaint/alt-screen mode; issue #154). Remote sessions run claude on
|
|
|
|
|
|
// another host, so a local probe wouldn't reflect their version — skip them
|
|
|
|
|
|
// and let the banner scrape handle those. Cached process-wide, best-effort.
|
|
|
|
|
|
if (this.mode === 'claude' && !this._remote && !this._cliVersion) {
|
|
|
|
|
|
const probedVersion = getClaudeCliVersion();
|
|
|
|
|
|
if (probedVersion) {
|
|
|
|
|
|
this._cliVersion = probedVersion;
|
|
|
|
|
|
this.emit('cliInfoUpdated', {
|
|
|
|
|
|
version: this._cliVersion,
|
|
|
|
|
|
model: this._cliModel,
|
|
|
|
|
|
accountType: this._cliAccountType,
|
|
|
|
|
|
latestVersion: this._cliLatestVersion,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 10:29:57 +01:00
|
|
|
|
// If mux wrapping is enabled, create or attach to a mux session
|
|
|
|
|
|
if (this._useMux && this._mux) {
|
2026-01-19 03:40:57 +01:00
|
|
|
|
try {
|
2026-03-26 22:50:25 +01:00
|
|
|
|
const { isRestored } = await this._setupOrAttachMuxSession({
|
|
|
|
|
|
respawnPaneOptions: {
|
2026-02-27 13:16:23 +01:00
|
|
|
|
sessionId: this.id,
|
|
|
|
|
|
workingDir: this.workingDir,
|
|
|
|
|
|
mode: this.mode,
|
|
|
|
|
|
niceConfig: this._niceConfig,
|
|
|
|
|
|
model: this._model,
|
|
|
|
|
|
claudeMode: this._claudeMode,
|
|
|
|
|
|
allowedTools: this._allowedTools,
|
|
|
|
|
|
openCodeConfig: this._openCodeConfig,
|
2026-06-10 09:31:58 -04:00
|
|
|
|
codexConfig: this._codexConfig,
|
2026-05-16 20:10:49 -04:00
|
|
|
|
geminiConfig: this._geminiConfig,
|
2026-03-10 14:03:19 +08:00
|
|
|
|
resumeSessionId: this._resumeSessionId,
|
2026-04-22 00:01:35 +08:00
|
|
|
|
envOverrides: this._envOverrides,
|
2026-06-07 10:33:11 +08:00
|
|
|
|
effort: this._effort,
|
2026-06-29 17:49:39 -04:00
|
|
|
|
historyLimit: this._tmuxHistoryLimit,
|
2026-06-11 18:43:39 +00:00
|
|
|
|
remote: this._remote,
|
2026-03-26 22:50:25 +01:00
|
|
|
|
},
|
|
|
|
|
|
createSessionOptions: {
|
2026-02-27 13:16:23 +01:00
|
|
|
|
sessionId: this.id,
|
|
|
|
|
|
workingDir: this.workingDir,
|
|
|
|
|
|
mode: this.mode,
|
|
|
|
|
|
name: this._name,
|
|
|
|
|
|
niceConfig: this._niceConfig,
|
|
|
|
|
|
model: this._model,
|
|
|
|
|
|
claudeMode: this._claudeMode,
|
|
|
|
|
|
allowedTools: this._allowedTools,
|
2026-02-26 10:07:43 +01:00
|
|
|
|
openCodeConfig: this._openCodeConfig,
|
2026-06-10 09:31:58 -04:00
|
|
|
|
codexConfig: this._codexConfig,
|
2026-05-16 20:10:49 -04:00
|
|
|
|
geminiConfig: this._geminiConfig,
|
2026-03-10 02:22:53 +08:00
|
|
|
|
resumeSessionId: this._resumeSessionId,
|
2026-04-22 00:01:35 +08:00
|
|
|
|
envOverrides: this._envOverrides,
|
2026-06-07 10:33:11 +08:00
|
|
|
|
effort: this._effort,
|
2026-06-29 17:49:39 -04:00
|
|
|
|
historyLimit: this._tmuxHistoryLimit,
|
2026-06-11 18:43:39 +00:00
|
|
|
|
remote: this._remote,
|
2026-03-26 22:50:25 +01:00
|
|
|
|
},
|
|
|
|
|
|
spawnErrLabel: 'mux attachment',
|
|
|
|
|
|
});
|
2026-01-19 03:40:57 +01:00
|
|
|
|
|
2026-03-26 22:50:25 +01:00
|
|
|
|
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
|
|
|
|
|
this._claudeSessionId = this._resumeSessionId || this.id;
|
2026-01-19 07:33:33 +01:00
|
|
|
|
|
2026-02-26 10:07:43 +01:00
|
|
|
|
// For NEW mux sessions: wait for readiness then clean buffer
|
2026-02-18 12:51:35 +01:00
|
|
|
|
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
|
2026-03-26 22:50:25 +01:00
|
|
|
|
if (!isRestored) {
|
2026-06-10 09:31:58 -04:00
|
|
|
|
if (isExternalCliMode(this.mode)) {
|
|
|
|
|
|
// External CLIs use custom TUIs — no ❯ prompt to detect.
|
2026-02-26 10:07:43 +01:00
|
|
|
|
// Wait for TUI to stabilize (output stops changing), then mark ready.
|
|
|
|
|
|
// Don't clear the buffer — the TUI's initial render IS the useful content.
|
2026-02-26 13:03:28 +01:00
|
|
|
|
// Emit needsRefresh so the client fetches the full buffer once the TUI has rendered.
|
2026-02-26 10:07:43 +01:00
|
|
|
|
this._promptCheckTimeout = setTimeout(() => {
|
|
|
|
|
|
this._promptCheckTimeout = null;
|
2026-02-26 15:58:26 +01:00
|
|
|
|
if (this._isStopped) return;
|
2026-02-26 10:07:43 +01:00
|
|
|
|
this._status = 'idle';
|
2026-02-26 13:03:28 +01:00
|
|
|
|
this.emit('needsRefresh');
|
2026-02-26 10:07:43 +01:00
|
|
|
|
}, 3000);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Claude mode: wait for ❯ prompt
|
|
|
|
|
|
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 mux 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
|
2026-02-27 13:16:23 +01:00
|
|
|
|
this._terminalBuffer.set(bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, ''));
|
2026-02-26 10:07:43 +01:00
|
|
|
|
// Signal client to refresh
|
|
|
|
|
|
this.emit('clearTerminal');
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 50);
|
|
|
|
|
|
// Timeout after 5 seconds if prompt not found
|
|
|
|
|
|
this._promptCheckTimeout = setTimeout(() => {
|
2026-01-21 09:54:42 +01:00
|
|
|
|
if (this._promptCheckInterval) {
|
|
|
|
|
|
clearInterval(this._promptCheckInterval);
|
|
|
|
|
|
this._promptCheckInterval = null;
|
|
|
|
|
|
}
|
2026-02-26 10:07:43 +01:00
|
|
|
|
this._promptCheckTimeout = null;
|
|
|
|
|
|
}, 5000);
|
|
|
|
|
|
}
|
2026-01-19 19:56:28 +01:00
|
|
|
|
}
|
2026-01-19 03:40:57 +01:00
|
|
|
|
} catch (err) {
|
2026-02-12 10:29:57 +01:00
|
|
|
|
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
|
|
|
|
|
|
this._useMux = false;
|
|
|
|
|
|
this._muxSession = null;
|
2026-01-19 03:40:57 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 10:29:57 +01:00
|
|
|
|
// Fallback to direct PTY if mux is not used
|
2026-01-19 03:40:57 +01:00
|
|
|
|
if (!this.ptyProcess) {
|
2026-02-26 15:58:26 +01:00
|
|
|
|
// OpenCode sessions require tmux for env var injection (API keys via setenv)
|
|
|
|
|
|
if (this.mode === 'opencode') {
|
|
|
|
|
|
throw new Error('OpenCode sessions require tmux. Direct PTY fallback is not supported.');
|
|
|
|
|
|
}
|
2026-06-10 09:31:58 -04:00
|
|
|
|
// Codex sessions require tmux for OPENAI_API_KEY injection via setenv
|
|
|
|
|
|
if (this.mode === 'codex') {
|
|
|
|
|
|
throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.');
|
|
|
|
|
|
}
|
2026-05-16 20:10:49 -04:00
|
|
|
|
// Gemini sessions require tmux for Gemini/Google auth env injection via setenv
|
|
|
|
|
|
if (this.mode === 'gemini') {
|
|
|
|
|
|
throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.');
|
|
|
|
|
|
}
|
2026-01-27 20:24:00 +01:00
|
|
|
|
try {
|
2026-02-26 16:44:34 +01:00
|
|
|
|
// Pass --session-id to use the SAME ID as the Codeman session
|
2026-01-29 14:56:11 +01:00
|
|
|
|
// This ensures subagents can be directly matched to the correct tab
|
2026-06-07 10:33:11 +08:00
|
|
|
|
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools, this._effort);
|
2026-02-18 23:14:39 +01:00
|
|
|
|
this.ptyProcess = pty.spawn('claude', args, {
|
2026-01-27 20:24:00 +01:00
|
|
|
|
name: 'xterm-256color',
|
|
|
|
|
|
cols: 120,
|
|
|
|
|
|
rows: 40,
|
|
|
|
|
|
cwd: this.workingDir,
|
2026-04-22 00:01:35 +08:00
|
|
|
|
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
|
|
|
|
|
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
2026-01-27 20:24:00 +01:00
|
|
|
|
});
|
|
|
|
|
|
} catch (spawnErr) {
|
|
|
|
|
|
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
|
|
|
|
|
this._status = 'stopped';
|
|
|
|
|
|
this.emit('error', `Failed to start Claude: ${spawnErr}`);
|
|
|
|
|
|
throw new Error(`Failed to spawn Claude process: ${spawnErr}`);
|
|
|
|
|
|
}
|
2026-01-19 03:40:57 +01:00
|
|
|
|
}
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
2026-03-11 19:36:58 +01:00
|
|
|
|
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
|
|
|
|
|
this._claudeSessionId = this._resumeSessionId || this.id;
|
2026-01-29 14:56:11 +01:00
|
|
|
|
|
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) => {
|
2026-01-20 20:56:25 +01:00
|
|
|
|
// Filter out focus escape sequences and Ctrl+L (form feed)
|
2026-02-27 13:16:23 +01:00
|
|
|
|
const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
|
2026-01-20 20:56:25 +01:00
|
|
|
|
if (!data) return; // Skip if only filtered sequences
|
2026-01-20 19:02:31 +01:00
|
|
|
|
|
2026-03-26 22:50:25 +01:00
|
|
|
|
this._handleTerminalOutput(data);
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
2026-04-01 08:51:37 +02:00
|
|
|
|
// === Auto-accept workspace trust dialog ===
|
|
|
|
|
|
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
|
|
|
|
|
|
// Codeman sessions always use --dangerously-skip-permissions, so auto-accept.
|
|
|
|
|
|
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
|
|
|
|
|
|
this._trustDialogAccepted = true;
|
|
|
|
|
|
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
|
|
|
|
|
|
// Send Enter to accept the default selection ("Yes, I trust this folder")
|
|
|
|
|
|
this.writeViaMux('\r');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 14:33:06 +01:00
|
|
|
|
// === Idle/working detection runs on every chunk (latency-sensitive) ===
|
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')) {
|
2026-01-28 05:34:25 +01:00
|
|
|
|
// Only start a new timeout if we're not already awaiting idle confirmation
|
|
|
|
|
|
// This prevents status bar redraws (which include ❯) from resetting the timer
|
|
|
|
|
|
if (!this._awaitingIdleConfirmation) {
|
|
|
|
|
|
if (this.activityTimeout) clearTimeout(this.activityTimeout);
|
|
|
|
|
|
this._awaitingIdleConfirmation = true;
|
|
|
|
|
|
this.activityTimeout = setTimeout(() => {
|
|
|
|
|
|
this._awaitingIdleConfirmation = false;
|
|
|
|
|
|
// Emit idle if either:
|
|
|
|
|
|
// 1. Claude was working and is now at prompt (normal case)
|
|
|
|
|
|
// 2. Session just started and is ready (status is 'busy' but _isWorking is false)
|
|
|
|
|
|
const wasWorking = this._isWorking;
|
|
|
|
|
|
const isInitialReady = this._status === 'busy' && !this._isWorking;
|
|
|
|
|
|
if (wasWorking || isInitialReady) {
|
|
|
|
|
|
this._isWorking = false;
|
|
|
|
|
|
this._status = 'idle';
|
|
|
|
|
|
this._lastPromptTime = Date.now();
|
|
|
|
|
|
this.emit('idle');
|
|
|
|
|
|
}
|
|
|
|
|
|
}, IDLE_DETECTION_DELAY_MS);
|
|
|
|
|
|
}
|
2026-01-18 21:36:03 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Detect when Claude starts working (thinking, writing, etc)
|
2026-02-13 05:07:51 +01:00
|
|
|
|
// Fast path: check spinner characters on raw data (Unicode, never in ANSI sequences)
|
2026-02-21 07:12:57 +01:00
|
|
|
|
const hasSpinner = SPINNER_PATTERN.test(data);
|
2026-02-20 14:33:06 +01:00
|
|
|
|
if (hasSpinner) {
|
2026-01-18 21:36:03 +01:00
|
|
|
|
if (!this._isWorking) {
|
|
|
|
|
|
this._isWorking = true;
|
2026-01-27 11:02:02 +01:00
|
|
|
|
this._status = 'busy';
|
2026-01-18 21:36:03 +01:00
|
|
|
|
this.emit('working');
|
2026-06-10 20:41:34 +02:00
|
|
|
|
this._autoOps.notifyWorking();
|
2026-01-18 21:36:03 +01:00
|
|
|
|
}
|
2026-01-28 05:34:25 +01:00
|
|
|
|
this._awaitingIdleConfirmation = false;
|
2026-01-18 21:36:03 +01:00
|
|
|
|
if (this.activityTimeout) clearTimeout(this.activityTimeout);
|
|
|
|
|
|
}
|
2026-02-20 14:33:06 +01:00
|
|
|
|
|
|
|
|
|
|
// === Expensive processing (ANSI strip, Ralph, bash parser) is throttled ===
|
|
|
|
|
|
// Instead of running regex-heavy parsers on every PTY chunk, we accumulate
|
|
|
|
|
|
// raw data and process at most every EXPENSIVE_PROCESS_INTERVAL_MS.
|
|
|
|
|
|
// This dramatically reduces CPU load with multiple busy sessions.
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const elapsed = now - this._lastExpensiveProcessTime;
|
|
|
|
|
|
if (elapsed >= Session.EXPENSIVE_PROCESS_INTERVAL_MS) {
|
|
|
|
|
|
// Process immediately — include any previously accumulated data
|
|
|
|
|
|
this._lastExpensiveProcessTime = now;
|
|
|
|
|
|
const accumulated = this._pendingCleanData ? this._pendingCleanData + data : data;
|
|
|
|
|
|
this._pendingCleanData = '';
|
|
|
|
|
|
if (this._expensiveProcessTimer) {
|
|
|
|
|
|
clearTimeout(this._expensiveProcessTimer);
|
|
|
|
|
|
this._expensiveProcessTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
this._processExpensiveParsers(accumulated);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Accumulate for deferred processing
|
|
|
|
|
|
this._pendingCleanData += data;
|
|
|
|
|
|
// Cap accumulated size to prevent unbounded growth
|
|
|
|
|
|
if (this._pendingCleanData.length > 64 * 1024) {
|
|
|
|
|
|
this._pendingCleanData = this._pendingCleanData.slice(-32 * 1024);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Schedule deferred processing if not already scheduled
|
|
|
|
|
|
if (!this._expensiveProcessTimer) {
|
|
|
|
|
|
this._expensiveProcessTimer = setTimeout(() => {
|
|
|
|
|
|
this._expensiveProcessTimer = null;
|
|
|
|
|
|
this._lastExpensiveProcessTime = Date.now();
|
|
|
|
|
|
const pending = this._pendingCleanData;
|
|
|
|
|
|
this._pendingCleanData = '';
|
|
|
|
|
|
if (pending) {
|
|
|
|
|
|
this._processExpensiveParsers(pending);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, Session.EXPENSIVE_PROCESS_INTERVAL_MS - elapsed);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-18 21:36:03 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this.ptyProcess.onExit(({ exitCode }) => {
|
|
|
|
|
|
console.log('[Session] Interactive PTY exited with code:', exitCode);
|
2026-06-16 08:53:29 -04:00
|
|
|
|
// COD-118: record the exit in the circuit breaker BEFORE status bookkeeping.
|
|
|
|
|
|
// A clean (0) exit resets the counter; rapid non-zero repeats trip it.
|
|
|
|
|
|
const breakerResult = this._ptyExitBreaker.recordExit(exitCode, Date.now());
|
2026-01-18 21:36:03 +01:00
|
|
|
|
this.ptyProcess = null;
|
|
|
|
|
|
this._pid = null;
|
|
|
|
|
|
this._status = 'idle';
|
2026-01-28 05:34:25 +01:00
|
|
|
|
this._awaitingIdleConfirmation = false;
|
2026-01-21 09:54:42 +01:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-02-20 14:33:06 +01:00
|
|
|
|
// Clear expensive processing timer and flush any pending data
|
|
|
|
|
|
if (this._expensiveProcessTimer) {
|
|
|
|
|
|
clearTimeout(this._expensiveProcessTimer);
|
|
|
|
|
|
this._expensiveProcessTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
this._pendingCleanData = '';
|
2026-02-12 10:29:57 +01:00
|
|
|
|
// If using mux, mark the session as detached but don't kill it
|
|
|
|
|
|
if (this._muxSession && this._mux) {
|
|
|
|
|
|
this._mux.setAttached(this.id, false);
|
2026-01-19 03:40:57 +01:00
|
|
|
|
}
|
2026-06-16 08:53:29 -04:00
|
|
|
|
// COD-118: if the breaker tripped, surface an error state and block the NEXT
|
|
|
|
|
|
// respawn so recovery/reconnect callers stop looping. Still emit 'exit' below
|
|
|
|
|
|
// for normal cleanup. Cleared by an explicit user restart (resetRespawnBreaker()).
|
|
|
|
|
|
if (breakerResult.tripped && !this._respawnBlocked) {
|
|
|
|
|
|
this._respawnBlocked = true;
|
|
|
|
|
|
this._status = 'error';
|
|
|
|
|
|
console.error(
|
|
|
|
|
|
`[Session] PTY exit circuit breaker tripped for ${this.id} (${breakerResult.count} non-zero exits within window); blocking respawn.`
|
|
|
|
|
|
);
|
|
|
|
|
|
this.emit('respawnBreakerTripped', { count: breakerResult.count });
|
|
|
|
|
|
}
|
2026-01-18 21:36:03 +01:00
|
|
|
|
this.emit('exit', exitCode);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 08:53:29 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* Clear the interactive-PTY exit circuit breaker (COD-118).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Called on an EXPLICIT, user-initiated (re)start so an intentional restart is
|
|
|
|
|
|
* never blocked by a prior crash-loop trip. Automatic recovery/reconnect paths
|
|
|
|
|
|
* must NOT call this — that's the whole point of the breaker.
|
|
|
|
|
|
*/
|
|
|
|
|
|
resetRespawnBreaker(): void {
|
|
|
|
|
|
this._ptyExitBreaker.reset();
|
|
|
|
|
|
this._respawnBlocked = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Whether the interactive-PTY exit circuit breaker is currently tripped (COD-118). */
|
|
|
|
|
|
get respawnBlocked(): boolean {
|
|
|
|
|
|
return this._respawnBlocked;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 14:33:06 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions).
|
|
|
|
|
|
* Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every
|
|
|
|
|
|
* PTY data chunk. Receives accumulated raw data to process in one batch.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private _processExpensiveParsers(rawData: string): void {
|
2026-06-10 09:31:58 -04:00
|
|
|
|
// Skip Claude-specific parsers for external CLI sessions (Ralph tracker,
|
|
|
|
|
|
// BashToolParser, token + CLI-info parsing all depend on Claude's output format).
|
|
|
|
|
|
if (isExternalCliMode(this.mode)) return;
|
2026-02-26 10:07:43 +01:00
|
|
|
|
|
2026-02-20 14:33:06 +01:00
|
|
|
|
// Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
|
|
|
|
|
|
let _cleanData: string | null = null;
|
|
|
|
|
|
const getCleanData = (): string => {
|
|
|
|
|
|
if (_cleanData === null) {
|
|
|
|
|
|
_cleanData = rawData.replace(ANSI_ESCAPE_PATTERN_FULL, '');
|
|
|
|
|
|
}
|
|
|
|
|
|
return _cleanData;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Forward to Ralph tracker to detect Ralph loops and todos
|
2026-02-26 15:06:36 +01:00
|
|
|
|
// (opencode sessions already returned early at line 1209)
|
2026-02-20 14:33:06 +01:00
|
|
|
|
if (this._ralphTracker.enabled || !this._ralphTracker.autoEnableDisabled) {
|
|
|
|
|
|
this._ralphTracker.processCleanData(getCleanData());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Forward to Bash tool parser to detect file-viewing commands
|
|
|
|
|
|
if (this._bashToolParser.enabled) {
|
|
|
|
|
|
this._bashToolParser.processCleanData(getCleanData());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 20:41:34 +02:00
|
|
|
|
// Usage-limit pause detection (auto-resume on usage limit)
|
|
|
|
|
|
if (this._autoOps.autoResumeEnabled) {
|
|
|
|
|
|
this._autoOps.processCleanData(getCleanData());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 14:33:06 +01:00
|
|
|
|
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
|
|
|
|
|
|
if (rawData.includes('token')) {
|
|
|
|
|
|
this.parseTokensFromStatusLine(getCleanData());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Parse Claude Code CLI info (version, model, account type) from startup
|
|
|
|
|
|
if (!this._cliInfoParsed) {
|
|
|
|
|
|
this.parseClaudeCodeInfo(getCleanData());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Parse task descriptions from terminal output (e.g., "Explore(Check files)")
|
|
|
|
|
|
if (rawData.includes('(') && rawData.includes(')')) {
|
|
|
|
|
|
this.parseTaskDescriptionsFromTerminalData(getCleanData());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Work keyword detection (text-based, needs clean data)
|
|
|
|
|
|
// Only check if spinner didn't already trigger working state
|
|
|
|
|
|
if (!this._isWorking) {
|
|
|
|
|
|
const cleanData = getCleanData();
|
2026-02-27 13:16:23 +01:00
|
|
|
|
if (
|
|
|
|
|
|
cleanData.includes('Thinking') ||
|
|
|
|
|
|
cleanData.includes('Writing') ||
|
|
|
|
|
|
cleanData.includes('Reading') ||
|
|
|
|
|
|
cleanData.includes('Running')
|
|
|
|
|
|
) {
|
2026-02-20 14:33:06 +01:00
|
|
|
|
this._isWorking = true;
|
|
|
|
|
|
this._status = 'busy';
|
|
|
|
|
|
this.emit('working');
|
2026-06-10 20:41:34 +02:00
|
|
|
|
this._autoOps.notifyWorking();
|
2026-02-20 14:33:06 +01:00
|
|
|
|
this._awaitingIdleConfirmation = false;
|
|
|
|
|
|
if (this.activityTimeout) clearTimeout(this.activityTimeout);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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');
|
|
|
|
|
|
* ```
|
|
|
|
|
|
*/
|
2026-01-19 01:26:38 +01:00
|
|
|
|
async startShell(): Promise<void> {
|
|
|
|
|
|
if (this.ptyProcess) {
|
|
|
|
|
|
throw new Error('Session already has a running process');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 23:21:38 +01:00
|
|
|
|
this._resetBuffers();
|
2026-01-19 01:26:38 +01:00
|
|
|
|
|
|
|
|
|
|
// Use user's default shell or bash
|
|
|
|
|
|
const shell = process.env.SHELL || '/bin/bash';
|
2026-02-27 13:16:23 +01:00
|
|
|
|
console.log(
|
|
|
|
|
|
'[Session] Starting shell session with:',
|
|
|
|
|
|
shell + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
|
|
|
|
|
);
|
2026-01-19 01:26:38 +01:00
|
|
|
|
|
2026-02-12 10:29:57 +01:00
|
|
|
|
// If mux wrapping is enabled, create or attach to a mux session
|
|
|
|
|
|
if (this._useMux && this._mux) {
|
2026-01-19 03:40:57 +01:00
|
|
|
|
try {
|
2026-03-26 22:50:25 +01:00
|
|
|
|
const { isRestored } = await this._setupOrAttachMuxSession({
|
|
|
|
|
|
respawnPaneOptions: {
|
2026-02-27 13:16:23 +01:00
|
|
|
|
sessionId: this.id,
|
|
|
|
|
|
workingDir: this.workingDir,
|
|
|
|
|
|
mode: 'shell',
|
|
|
|
|
|
niceConfig: this._niceConfig,
|
2026-04-22 00:01:35 +08:00
|
|
|
|
envOverrides: this._envOverrides,
|
2026-06-29 17:49:39 -04:00
|
|
|
|
historyLimit: this._tmuxHistoryLimit,
|
2026-06-11 18:43:39 +00:00
|
|
|
|
remote: this._remote,
|
2026-03-26 22:50:25 +01:00
|
|
|
|
},
|
|
|
|
|
|
createSessionOptions: {
|
2026-02-27 13:16:23 +01:00
|
|
|
|
sessionId: this.id,
|
|
|
|
|
|
workingDir: this.workingDir,
|
|
|
|
|
|
mode: 'shell',
|
|
|
|
|
|
name: this._name,
|
|
|
|
|
|
niceConfig: this._niceConfig,
|
2026-04-22 00:01:35 +08:00
|
|
|
|
envOverrides: this._envOverrides,
|
2026-06-29 17:49:39 -04:00
|
|
|
|
historyLimit: this._tmuxHistoryLimit,
|
2026-06-11 18:43:39 +00:00
|
|
|
|
remote: this._remote,
|
2026-03-26 22:50:25 +01:00
|
|
|
|
},
|
|
|
|
|
|
spawnErrLabel: 'shell mux attachment',
|
|
|
|
|
|
});
|
2026-01-19 07:33:33 +01:00
|
|
|
|
|
2026-02-12 10:29:57 +01:00
|
|
|
|
// For NEW sessions: clear by sending 'clear' command to the shell
|
|
|
|
|
|
// For RESTORED sessions: don't clear - we want to see the existing output
|
2026-03-26 22:50:25 +01:00
|
|
|
|
if (!isRestored) {
|
2026-01-19 19:56:28 +01:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (this.ptyProcess) {
|
2026-01-21 04:55:48 +01:00
|
|
|
|
this._terminalBuffer.clear();
|
2026-01-19 19:56:28 +01:00
|
|
|
|
this.ptyProcess.write('clear\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
}
|
2026-01-19 03:40:57 +01:00
|
|
|
|
} catch (err) {
|
2026-02-12 10:29:57 +01:00
|
|
|
|
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
|
|
|
|
|
|
this._useMux = false;
|
|
|
|
|
|
this._muxSession = null;
|
2026-01-19 03:40:57 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 10:29:57 +01:00
|
|
|
|
// Fallback to direct PTY if mux is not used
|
2026-01-19 03:40:57 +01:00
|
|
|
|
if (!this.ptyProcess) {
|
2026-01-27 20:24:00 +01:00
|
|
|
|
try {
|
|
|
|
|
|
this.ptyProcess = pty.spawn(shell, [], {
|
|
|
|
|
|
name: 'xterm-256color',
|
|
|
|
|
|
cols: 120,
|
|
|
|
|
|
rows: 40,
|
|
|
|
|
|
cwd: this.workingDir,
|
2026-03-01 04:04:35 +01:00
|
|
|
|
env: buildShellEnv(this.id),
|
2026-01-27 20:24:00 +01:00
|
|
|
|
});
|
|
|
|
|
|
} catch (spawnErr) {
|
|
|
|
|
|
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
|
|
|
|
|
|
this._status = 'stopped';
|
|
|
|
|
|
this.emit('error', `Failed to start shell: ${spawnErr}`);
|
|
|
|
|
|
throw new Error(`Failed to spawn shell process: ${spawnErr}`);
|
|
|
|
|
|
}
|
2026-01-19 03:40:57 +01:00
|
|
|
|
}
|
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-03-26 22:50:25 +01:00
|
|
|
|
this._handleTerminalOutput(data);
|
2026-01-19 01:26:38 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this.ptyProcess.onExit(({ exitCode }) => {
|
|
|
|
|
|
console.log('[Session] Shell PTY exited with code:', exitCode);
|
|
|
|
|
|
this.ptyProcess = null;
|
|
|
|
|
|
this._pid = null;
|
|
|
|
|
|
this._status = 'idle';
|
2026-01-21 09:54:42 +01:00
|
|
|
|
// Clear timers to prevent memory leaks
|
|
|
|
|
|
if (this._shellIdleTimer) {
|
|
|
|
|
|
clearTimeout(this._shellIdleTimer);
|
|
|
|
|
|
this._shellIdleTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (this.activityTimeout) {
|
|
|
|
|
|
clearTimeout(this.activityTimeout);
|
|
|
|
|
|
this.activityTimeout = null;
|
|
|
|
|
|
}
|
2026-02-12 10:29:57 +01:00
|
|
|
|
// If using mux, mark the session as detached but don't kill it
|
|
|
|
|
|
if (this._muxSession && this._mux) {
|
|
|
|
|
|
this._mux.setAttached(this.id, false);
|
2026-01-19 03:40:57 +01:00
|
|
|
|
}
|
2026-01-19 01:26:38 +01:00
|
|
|
|
this.emit('exit', exitCode);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Mark as idle after a short delay (shell is ready)
|
2026-01-21 09:54:42 +01:00
|
|
|
|
this._shellIdleTimer = setTimeout(() => {
|
|
|
|
|
|
this._shellIdleTimer = null;
|
2026-01-19 01:26:38 +01:00
|
|
|
|
this._status = 'idle';
|
|
|
|
|
|
this._isWorking = false;
|
|
|
|
|
|
this.emit('idle');
|
|
|
|
|
|
}, 500);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
2026-01-27 02:38:37 +01:00
|
|
|
|
* @param options - Optional configuration
|
|
|
|
|
|
* @param options.model - Model to use ('opus', 'sonnet', or full model name). Defaults to default model.
|
|
|
|
|
|
* @param options.onProgress - Callback for progress updates (token count, status)
|
2026-01-21 04:25:53 +01:00
|
|
|
|
* @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' });
|
2026-01-27 02:38:37 +01:00
|
|
|
|
* const { result, cost } = await session.runPrompt('Explain this code', { model: 'opus' });
|
2026-01-21 04:25:53 +01:00
|
|
|
|
* console.log(`Response: ${result}`);
|
|
|
|
|
|
* console.log(`Cost: $${cost.toFixed(4)}`);
|
|
|
|
|
|
* ```
|
|
|
|
|
|
*/
|
2026-01-27 02:38:37 +01:00
|
|
|
|
async runPrompt(
|
|
|
|
|
|
prompt: string,
|
|
|
|
|
|
options?: { model?: string; onProgress?: (info: { tokens?: number; status?: string }) => void }
|
|
|
|
|
|
): 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-03-25 23:21:38 +01:00
|
|
|
|
this._resetBuffers();
|
2026-02-27 13:16:23 +01:00
|
|
|
|
this._promptResolved = false; // Reset race condition guard
|
2026-01-18 05:14:34 +01:00
|
|
|
|
|
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
|
2026-01-27 02:38:37 +01:00
|
|
|
|
const model = options?.model;
|
2026-02-27 13:16:23 +01:00
|
|
|
|
console.log(
|
|
|
|
|
|
'[Session] Spawning PTY for claude with prompt:',
|
|
|
|
|
|
prompt.substring(0, 50),
|
|
|
|
|
|
model ? `(model: ${model})` : ''
|
|
|
|
|
|
);
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
2026-03-01 04:04:35 +01:00
|
|
|
|
const args = buildPromptArgs(prompt, model);
|
2026-01-27 02:38:37 +01:00
|
|
|
|
|
2026-01-27 20:24:00 +01:00
|
|
|
|
try {
|
|
|
|
|
|
this.ptyProcess = pty.spawn('claude', args, {
|
|
|
|
|
|
name: 'xterm-256color',
|
|
|
|
|
|
cols: 120,
|
|
|
|
|
|
rows: 40,
|
|
|
|
|
|
cwd: this.workingDir,
|
2026-04-22 00:01:35 +08:00
|
|
|
|
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
|
|
|
|
|
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
2026-01-27 20:24:00 +01:00
|
|
|
|
});
|
|
|
|
|
|
} catch (spawnErr) {
|
|
|
|
|
|
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
2026-02-27 13:16:23 +01:00
|
|
|
|
this.emit(
|
|
|
|
|
|
'error',
|
|
|
|
|
|
`Failed to spawn Claude: ${spawnErr instanceof Error ? spawnErr.message : String(spawnErr)}`
|
|
|
|
|
|
);
|
2026-01-27 20:24:00 +01:00
|
|
|
|
throw spawnErr;
|
|
|
|
|
|
}
|
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-03-26 22:50:25 +01:00
|
|
|
|
this._handleTerminalOutput(data);
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
|
|
|
|
|
// 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-21 05:47:17 +01:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// Find result from parsed messages or use text output
|
2026-02-27 13:16:23 +01:00
|
|
|
|
const resultMsg = this._messages.find((m) => m.type === 'result');
|
2026-01-18 05:14:34 +01:00
|
|
|
|
|
|
|
|
|
|
if (resultMsg && !resultMsg.is_error) {
|
|
|
|
|
|
this._status = 'idle';
|
|
|
|
|
|
const cost = resultMsg.total_cost_usd || 0;
|
|
|
|
|
|
this._totalCost += cost;
|
2026-03-25 23:53:57 +01:00
|
|
|
|
// Claude CLI stream-json may return empty result field — fall back to accumulated text output
|
|
|
|
|
|
const result = resultMsg.result || this._textOutput.value || '';
|
|
|
|
|
|
this.emit('completion', result, cost);
|
2026-01-21 05:47:17 +01:00
|
|
|
|
if (resolve) {
|
2026-03-25 23:53:57 +01:00
|
|
|
|
resolve({ result, cost });
|
2026-01-18 21:36:03 +01:00
|
|
|
|
}
|
|
|
|
|
|
} else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) {
|
2026-01-18 05:14:34 +01:00
|
|
|
|
this._status = 'error';
|
2026-01-21 05:47:17 +01:00
|
|
|
|
if (reject) {
|
|
|
|
|
|
reject(new Error(this._errorBuffer || this._textOutput.value || 'Process exited with error'));
|
2026-01-18 21:36:03 +01:00
|
|
|
|
}
|
2026-01-18 05:14:34 +01:00
|
|
|
|
} else {
|
|
|
|
|
|
this._status = 'idle';
|
2026-01-21 05:47:17 +01:00
|
|
|
|
if (resolve) {
|
2026-02-27 13:16:23 +01:00
|
|
|
|
resolve({
|
|
|
|
|
|
result: this._textOutput.value || this._terminalBuffer.value,
|
|
|
|
|
|
cost: this._totalCost,
|
|
|
|
|
|
});
|
2026-01-18 21:36:03 +01:00
|
|
|
|
}
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
this.emit('exit', exitCode);
|
2026-01-18 04:50:46 +01:00
|
|
|
|
});
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
this._status = 'error';
|
|
|
|
|
|
reject(err);
|
2026-01-30 13:25:18 +01:00
|
|
|
|
// Null callbacks to prevent memory leak (onExit won't run if spawn failed)
|
|
|
|
|
|
this.resolvePromise = null;
|
|
|
|
|
|
this.rejectPromise = null;
|
2026-01-18 04:50:46 +01:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 23:21:38 +01:00
|
|
|
|
private _resetBuffers(): void {
|
|
|
|
|
|
this._status = 'busy';
|
|
|
|
|
|
this._terminalBuffer.clear();
|
|
|
|
|
|
this._textOutput.clear();
|
|
|
|
|
|
this._errorBuffer = '';
|
|
|
|
|
|
this._messages = [];
|
|
|
|
|
|
this._lineBuffer = '';
|
2026-06-16 18:08:45 +02:00
|
|
|
|
this._altScreenSeqCarry = '';
|
2026-03-25 23:21:38 +01:00
|
|
|
|
this._lastActivityAt = Date.now();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private _clearAllTimers(): void {
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Destroy auto-compact/auto-clear automation (clears its timers)
|
|
|
|
|
|
this._autoOps.destroy();
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Clear expensive processing timer
|
|
|
|
|
|
if (this._expensiveProcessTimer) {
|
|
|
|
|
|
clearTimeout(this._expensiveProcessTimer);
|
|
|
|
|
|
this._expensiveProcessTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
this._pendingCleanData = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private _handleJsonMessage(cleanLine: string, rawLine: string): void {
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 08:58:42 +08:00
|
|
|
|
// Extract Claude session ID from messages (can be in any message type).
|
|
|
|
|
|
// Support both sessionId (camelCase) and session_id (snake_case).
|
|
|
|
|
|
// The constructor seeds _claudeSessionId with this.id as a placeholder;
|
|
|
|
|
|
// once Claude CLI emits its real session ID, adopt it so JSONL lookups
|
|
|
|
|
|
// (e.g. /api/sessions/:id/last-response) can find the transcript file.
|
2026-03-25 23:21:38 +01:00
|
|
|
|
const msgSessionId =
|
|
|
|
|
|
((msg as unknown as Record<string, unknown>).sessionId as string | undefined) ?? msg.session_id;
|
2026-04-28 08:58:42 +08:00
|
|
|
|
if (msgSessionId && msgSessionId !== this._claudeSessionId) {
|
2026-03-25 23:21:38 +01:00
|
|
|
|
this._claudeSessionId = msgSessionId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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 (with validation)
|
|
|
|
|
|
if (msg.message.usage) {
|
|
|
|
|
|
const inputDelta = msg.message.usage.input_tokens || 0;
|
|
|
|
|
|
const outputDelta = msg.message.usage.output_tokens || 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Sanity check: max 100k tokens per message (generous limit)
|
|
|
|
|
|
const MAX_TOKENS_PER_MESSAGE = 100_000;
|
|
|
|
|
|
if (inputDelta > 0 && inputDelta <= MAX_TOKENS_PER_MESSAGE) {
|
|
|
|
|
|
this._totalInputTokens += inputDelta;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (outputDelta > 0 && outputDelta <= MAX_TOKENS_PER_MESSAGE) {
|
|
|
|
|
|
this._totalOutputTokens += outputDelta;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if we should auto-compact or auto-clear
|
|
|
|
|
|
this._autoOps.checkAutoCompact();
|
|
|
|
|
|
this._autoOps.checkAutoClear();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (msg.type === 'result' && msg.total_cost_usd) {
|
|
|
|
|
|
this._totalCost = msg.total_cost_usd;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (parseErr) {
|
|
|
|
|
|
// Not JSON, just regular output - this is expected for non-JSON lines
|
|
|
|
|
|
console.debug(
|
|
|
|
|
|
'[Session] Line not JSON (expected for text output):',
|
|
|
|
|
|
parseErr instanceof Error ? parseErr.message : parseErr
|
|
|
|
|
|
);
|
|
|
|
|
|
this._textOutput.append(rawLine + '\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
private processOutput(data: string): void {
|
2026-01-26 13:58:34 +01:00
|
|
|
|
// Early return if session is stopped to prevent any processing or timer creation
|
|
|
|
|
|
if (this._isStopped) return;
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
// 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
|
2026-01-21 04:55:48 +01:00
|
|
|
|
this._textOutput.append(this._lineBuffer + '\n');
|
2026-01-19 06:51:25 +01:00
|
|
|
|
this._lineBuffer = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Start flush timer if not running (handles partial lines after 100ms)
|
2026-01-22 00:08:32 +01:00
|
|
|
|
if (!this._lineBufferFlushTimer && this._lineBuffer.length > 0 && !this._isStopped) {
|
2026-01-19 06:51:25 +01:00
|
|
|
|
this._lineBufferFlushTimer = setTimeout(() => {
|
|
|
|
|
|
this._lineBufferFlushTimer = null;
|
2026-01-22 00:08:32 +01:00
|
|
|
|
if (this._lineBuffer.length > 0 && !this._isStopped) {
|
2026-01-19 06:51:25 +01:00
|
|
|
|
// Flush partial line as text output
|
2026-01-21 04:55:48 +01:00
|
|
|
|
this._textOutput.append(this._lineBuffer);
|
2026-01-19 06:51:25 +01:00
|
|
|
|
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)
|
2026-01-29 10:55:49 +01:00
|
|
|
|
const cleanLine = trimmed.replace(ANSI_ESCAPE_PATTERN_FULL, '');
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
|
|
|
|
|
if (cleanLine.startsWith('{') && cleanLine.endsWith('}')) {
|
2026-03-25 23:21:38 +01:00
|
|
|
|
this._handleJsonMessage(cleanLine, line);
|
2026-01-18 21:36:03 +01:00
|
|
|
|
} else if (trimmed) {
|
2026-01-21 04:55:48 +01:00
|
|
|
|
this._textOutput.append(line + '\n');
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
2026-01-26 10:42:04 +01:00
|
|
|
|
|
|
|
|
|
|
// Parse task descriptions from terminal output (e.g., "Explore(Description)")
|
|
|
|
|
|
// This captures the short description from Claude Code's Task tool output
|
2026-02-12 18:26:16 +01:00
|
|
|
|
// Use direct method since cleanLine is already ANSI-stripped (line 1460)
|
|
|
|
|
|
this.parseTaskDescriptionsDirect(cleanLine);
|
2026-01-18 05:14:34 +01:00
|
|
|
|
}
|
2026-01-21 04:55:48 +01:00
|
|
|
|
// Note: BufferAccumulator auto-trims when max size exceeded
|
2026-01-18 21:36:03 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-26 10:42:04 +01:00
|
|
|
|
/**
|
2026-02-04 07:24:32 +01:00
|
|
|
|
* Parse task descriptions from terminal data (may contain multiple lines).
|
|
|
|
|
|
* Called from interactive mode's onData handler with ANSI-stripped data.
|
|
|
|
|
|
* @param cleanData - Terminal data with ANSI codes already stripped
|
2026-01-26 10:42:04 +01:00
|
|
|
|
*/
|
2026-02-04 07:24:32 +01:00
|
|
|
|
private parseTaskDescriptionsFromTerminalData(cleanData: string): void {
|
2026-01-26 10:42:04 +01:00
|
|
|
|
// Quick pre-check: skip if no parentheses present
|
2026-02-04 07:24:32 +01:00
|
|
|
|
if (!cleanData.includes('(') || !cleanData.includes(')')) return;
|
2026-01-26 10:42:04 +01:00
|
|
|
|
|
2026-02-04 07:24:32 +01:00
|
|
|
|
// Split by newlines and process each line (data already ANSI-stripped)
|
|
|
|
|
|
const lines = cleanData.split(NEWLINE_SPLIT_PATTERN);
|
2026-01-26 10:42:04 +01:00
|
|
|
|
for (const line of lines) {
|
2026-02-04 07:24:32 +01:00
|
|
|
|
this.parseTaskDescriptionsDirect(line);
|
2026-01-26 10:42:04 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 07:24:32 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Parse task descriptions from a pre-cleaned line (no ANSI codes).
|
2026-02-12 18:26:16 +01:00
|
|
|
|
* Used by both processOutput() and parseTaskDescriptionsFromTerminalData().
|
2026-02-04 07:24:32 +01:00
|
|
|
|
*/
|
|
|
|
|
|
private parseTaskDescriptionsDirect(cleanLine: string): void {
|
|
|
|
|
|
// Quick pre-check: skip expensive regex if no common tool patterns present
|
|
|
|
|
|
if (!cleanLine.includes('(') || !cleanLine.includes(')')) return;
|
2026-01-26 10:42:04 +01:00
|
|
|
|
|
2026-03-07 07:33:44 +01:00
|
|
|
|
execPattern(TASK_TOOL_PATTERN, cleanLine, (match) => {
|
2026-01-26 10:42:04 +01:00
|
|
|
|
const description = match[2].trim();
|
|
|
|
|
|
if (description && description.length > 0) {
|
2026-03-01 04:04:35 +01:00
|
|
|
|
this._taskCache.add(Date.now(), description);
|
2026-01-26 13:58:34 +01:00
|
|
|
|
}
|
2026-03-07 07:33:44 +01:00
|
|
|
|
});
|
2026-01-26 10:42:04 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get recent task descriptions parsed from terminal output.
|
|
|
|
|
|
* Returns descriptions sorted by timestamp (most recent first).
|
|
|
|
|
|
*/
|
|
|
|
|
|
getRecentTaskDescriptions(): Array<{ timestamp: number; description: string }> {
|
2026-03-01 04:04:35 +01:00
|
|
|
|
return this._taskCache.getAll();
|
2026-01-26 10:42:04 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Find a task description that was parsed close to a given timestamp.
|
|
|
|
|
|
* Used to correlate with SubagentWatcher discoveries.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param subagentStartTime - The timestamp when the subagent was discovered
|
|
|
|
|
|
* @param maxAgeMs - Maximum age difference to consider (default 10 seconds)
|
|
|
|
|
|
* @returns The matching description or undefined
|
|
|
|
|
|
*/
|
|
|
|
|
|
findTaskDescriptionNear(subagentStartTime: number, maxAgeMs: number = 10000): string | undefined {
|
2026-03-01 04:04:35 +01:00
|
|
|
|
return this._taskCache.findNear(subagentStartTime, maxAgeMs);
|
2026-01-26 10:42:04 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
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"
|
2026-01-26 12:22:05 +01:00
|
|
|
|
//
|
|
|
|
|
|
// SAFETY LIMITS:
|
|
|
|
|
|
// - Max tokens per session: 500k (Claude's context is ~200k)
|
|
|
|
|
|
// - Max delta per update: 100k (prevents sudden jumps from parsing errors)
|
|
|
|
|
|
// - Rejects "M" suffix values > 0.5 (500k) to prevent false matches
|
2026-02-04 07:24:32 +01:00
|
|
|
|
private parseTokensFromStatusLine(cleanData: string): void {
|
2026-01-21 10:05:39 +01:00
|
|
|
|
// Quick pre-check: skip expensive regex if "token" not present (performance optimization)
|
2026-02-04 07:24:32 +01:00
|
|
|
|
if (!cleanData.includes('token')) return;
|
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-02-04 07:24:32 +01:00
|
|
|
|
// Note: ANSI codes are already stripped by caller for performance
|
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') {
|
2026-01-26 12:22:05 +01:00
|
|
|
|
// Safety: Reject M values that would result in > 500k tokens
|
|
|
|
|
|
// Claude's context window is ~200k, so anything claiming millions is likely a false match
|
|
|
|
|
|
if (tokenCount > 0.5) {
|
2026-02-27 13:16:23 +01:00
|
|
|
|
console.warn(
|
|
|
|
|
|
`[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)`
|
|
|
|
|
|
);
|
2026-01-26 12:22:05 +01:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-19 04:42:13 +01:00
|
|
|
|
tokenCount *= 1000000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 10:55:49 +01:00
|
|
|
|
// Safety: Absolute maximum tokens per session
|
2026-01-26 12:22:05 +01:00
|
|
|
|
if (tokenCount > MAX_SESSION_TOKENS) {
|
|
|
|
|
|
console.warn(`[Session ${this.id}] Rejected token count exceeding max: ${tokenCount} > ${MAX_SESSION_TOKENS}`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 04:42:13 +01:00
|
|
|
|
// 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) {
|
2026-01-26 12:22:05 +01:00
|
|
|
|
const delta = tokenCount - currentTotal;
|
|
|
|
|
|
|
|
|
|
|
|
// Safety: Reject suspiciously large jumps (max 100k per update)
|
|
|
|
|
|
const MAX_DELTA_PER_UPDATE = 100_000;
|
|
|
|
|
|
if (delta > MAX_DELTA_PER_UPDATE) {
|
2026-02-27 13:16:23 +01:00
|
|
|
|
console.warn(
|
|
|
|
|
|
`[Session ${this.id}] Rejected suspicious token jump: ${currentTotal} -> ${tokenCount} (delta: ${delta})`
|
|
|
|
|
|
);
|
2026-01-26 12:22:05 +01:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 04:42:13 +01:00
|
|
|
|
// Estimate: split roughly 60% input, 40% output (common ratio)
|
|
|
|
|
|
// This is an approximation since interactive mode doesn't give us the breakdown
|
|
|
|
|
|
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
|
2026-03-01 04:04:35 +01:00
|
|
|
|
this._autoOps.checkAutoCompact();
|
|
|
|
|
|
this._autoOps.checkAutoClear();
|
2026-01-19 04:42:13 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 04:44:46 +01:00
|
|
|
|
// Parse Claude Code CLI info from terminal startup output
|
2026-02-26 16:44:34 +01:00
|
|
|
|
// Extracts version, model, and account type for display in Codeman UI
|
2026-02-04 07:24:32 +01:00
|
|
|
|
// Note: Expects cleanData with ANSI codes already stripped by caller
|
|
|
|
|
|
private parseClaudeCodeInfo(cleanData: string): void {
|
2026-01-31 04:44:46 +01:00
|
|
|
|
// Only parse once per session (during startup)
|
|
|
|
|
|
if (this._cliInfoParsed) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Quick pre-checks
|
2026-02-27 13:16:23 +01:00
|
|
|
|
if (
|
|
|
|
|
|
!cleanData.includes('Claude') &&
|
|
|
|
|
|
!cleanData.includes('current:') &&
|
|
|
|
|
|
!cleanData.includes('Opus') &&
|
|
|
|
|
|
!cleanData.includes('Sonnet')
|
|
|
|
|
|
) {
|
2026-01-31 04:44:46 +01:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let changed = false;
|
|
|
|
|
|
|
|
|
|
|
|
// Match "Claude Code v2.1.27" or "Claude Code vX.Y.Z"
|
|
|
|
|
|
if (!this._cliVersion) {
|
|
|
|
|
|
const versionMatch = cleanData.match(/Claude Code v(\d+\.\d+\.\d+)/);
|
|
|
|
|
|
if (versionMatch) {
|
|
|
|
|
|
this._cliVersion = versionMatch[1];
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Match model and account: "Opus 4.5 · Claude Max" or "Sonnet 4 · API"
|
|
|
|
|
|
// The · character separates model from account type
|
|
|
|
|
|
if (!this._cliModel || !this._cliAccountType) {
|
|
|
|
|
|
// Try various model patterns
|
|
|
|
|
|
const modelPatterns = [
|
|
|
|
|
|
/(Opus \d+(?:\.\d+)?)\s*[·•]\s*(.+?)(?:\s*$|\s+[~/])/,
|
|
|
|
|
|
/(Sonnet \d+(?:\.\d+)?)\s*[·•]\s*(.+?)(?:\s*$|\s+[~/])/,
|
|
|
|
|
|
/(Haiku \d+(?:\.\d+)?)\s*[·•]\s*(.+?)(?:\s*$|\s+[~/])/,
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (const pattern of modelPatterns) {
|
|
|
|
|
|
const match = cleanData.match(pattern);
|
|
|
|
|
|
if (match) {
|
|
|
|
|
|
if (!this._cliModel) {
|
|
|
|
|
|
this._cliModel = match[1].trim();
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!this._cliAccountType) {
|
|
|
|
|
|
this._cliAccountType = match[2].trim();
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Match version check: "current: 2.1.27" and "latest: 2.1.27"
|
|
|
|
|
|
if (!this._cliLatestVersion) {
|
|
|
|
|
|
const latestMatch = cleanData.match(/latest:\s*(\d+\.\d+\.\d+)/);
|
|
|
|
|
|
if (latestMatch) {
|
|
|
|
|
|
this._cliLatestVersion = latestMatch[1];
|
|
|
|
|
|
changed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Mark as parsed once we have the essential info
|
|
|
|
|
|
if (this._cliVersion && this._cliModel) {
|
|
|
|
|
|
this._cliInfoParsed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Emit update if anything changed
|
|
|
|
|
|
if (changed) {
|
|
|
|
|
|
this.emit('cliInfoUpdated', {
|
|
|
|
|
|
version: this._cliVersion,
|
|
|
|
|
|
model: this._cliModel,
|
|
|
|
|
|
accountType: this._cliAccountType,
|
|
|
|
|
|
latestVersion: this._cliLatestVersion,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-01 04:04:35 +01:00
|
|
|
|
// Note: checkAutoCompact/checkAutoClear moved to SessionAutoOps (this._autoOps)
|
2026-01-19 00:38:11 +01:00
|
|
|
|
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
|
|
|
|
|
* ```
|
|
|
|
|
|
*/
|
2026-01-18 21:36:03 +01:00
|
|
|
|
write(data: string): void {
|
2026-07-12 10:39:42 +08:00
|
|
|
|
this._trackCodexSubmit(data);
|
2026-01-18 21:36:03 +01:00
|
|
|
|
if (this.ptyProcess) {
|
|
|
|
|
|
this.ptyProcess.write(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-12 10:39:42 +08:00
|
|
|
|
// ── Codex thread tracking ─────────────────────────────────────────────
|
|
|
|
|
|
// When a codex pane last submitted a message (Enter). The response-viewer
|
|
|
|
|
|
// correlates this against ~/.codex/history.jsonl entry timestamps to find
|
|
|
|
|
|
// the thread the pane is ACTUALLY on — the only signal that survives
|
|
|
|
|
|
// /resume, /new and /fork typed inside the codex TUI itself.
|
|
|
|
|
|
private _codexLastSubmitAt = 0;
|
|
|
|
|
|
|
|
|
|
|
|
get codexLastSubmitAt(): number {
|
|
|
|
|
|
return this._codexLastSubmitAt;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private _trackCodexSubmit(data: string): void {
|
|
|
|
|
|
if (this.mode === 'codex' && (data.includes('\r') || data.includes('\n'))) {
|
|
|
|
|
|
this._codexLastSubmitAt = Date.now();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 16:44:18 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Per-client highest-applied input sequence, for exactly-once input delivery.
|
|
|
|
|
|
* Keyed by the web client's stable `clientId`. Bounded so many devices over a
|
|
|
|
|
|
* long-lived session can't grow it without limit (insertion order = MRU, so
|
|
|
|
|
|
* eviction drops the least-recently-active client).
|
|
|
|
|
|
*/
|
|
|
|
|
|
private _appliedInputSeq = new Map<string, number>();
|
|
|
|
|
|
private static readonly MAX_INPUT_DEDUP_CLIENTS = 256;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Decide whether an input frame should be applied to the PTY or skipped as a
|
|
|
|
|
|
* duplicate redelivery. Returns true exactly once per (clientId, seq): the
|
|
|
|
|
|
* first time a seq strictly greater than the client's last-applied is seen.
|
|
|
|
|
|
* A redelivery of an already-applied seq (the client never got our ACK and
|
|
|
|
|
|
* resent) returns false. Callers should ACK regardless — a duplicate is, from
|
|
|
|
|
|
* the client's view, "delivered" — and only `write()` the PTY when this is
|
|
|
|
|
|
* true. Relies on the client delivering one client's frames in seq order over
|
|
|
|
|
|
* a single ordered stream, so `seq <= last` ⇒ already applied.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Without this, the client's at-least-once redelivery (needed because a
|
|
|
|
|
|
* half-open socket silently drops frames with no error) would type a prompt
|
|
|
|
|
|
* twice whenever an ACK is lost after the write landed.
|
|
|
|
|
|
*/
|
|
|
|
|
|
shouldApplyInput(clientId: string, seq: number): boolean {
|
|
|
|
|
|
const last = this._appliedInputSeq.get(clientId);
|
|
|
|
|
|
if (last !== undefined && seq <= last) return false;
|
|
|
|
|
|
// Re-insert to move this client to the MRU end for fair eviction.
|
|
|
|
|
|
if (last !== undefined) this._appliedInputSeq.delete(clientId);
|
|
|
|
|
|
this._appliedInputSeq.set(clientId, seq);
|
|
|
|
|
|
if (this._appliedInputSeq.size > Session.MAX_INPUT_DEDUP_CLIENTS) {
|
|
|
|
|
|
const oldest = this._appliedInputSeq.keys().next().value;
|
|
|
|
|
|
if (oldest !== undefined) this._appliedInputSeq.delete(oldest);
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/**
|
2026-02-12 10:29:57 +01:00
|
|
|
|
* Sends input via the terminal multiplexer's direct input mechanism.
|
2026-01-21 04:25:53 +01:00
|
|
|
|
*
|
|
|
|
|
|
* More reliable than direct PTY write for programmatic input, especially
|
2026-02-12 10:29:57 +01:00
|
|
|
|
* with Claude CLI which uses Ink (React for terminals).
|
2026-02-18 12:51:35 +01:00
|
|
|
|
* Uses tmux `send-keys -l` to inject text + Enter.
|
2026-01-21 04:25:53 +01:00
|
|
|
|
*
|
|
|
|
|
|
* @param data - Input data with optional `\r` for Enter
|
2026-02-12 10:29:57 +01:00
|
|
|
|
* @returns true if input was sent, false if no mux session or PTY
|
2026-01-21 04:25:53 +01:00
|
|
|
|
*
|
|
|
|
|
|
* @example
|
|
|
|
|
|
* ```typescript
|
2026-02-18 12:51:35 +01:00
|
|
|
|
* session.writeViaMux('/clear\r'); // Send /clear command
|
|
|
|
|
|
* session.writeViaMux('/init\r'); // Send /init command
|
2026-01-21 04:25:53 +01:00
|
|
|
|
* ```
|
|
|
|
|
|
*/
|
2026-02-18 12:51:35 +01:00
|
|
|
|
async writeViaMux(data: string): Promise<boolean> {
|
2026-07-12 10:39:42 +08:00
|
|
|
|
this._trackCodexSubmit(data);
|
2026-02-12 10:29:57 +01:00
|
|
|
|
if (this._mux && this._muxSession) {
|
|
|
|
|
|
return this._mux.sendInput(this.id, data);
|
2026-01-19 15:35:41 +01:00
|
|
|
|
}
|
|
|
|
|
|
// Fallback to PTY write
|
|
|
|
|
|
if (this.ptyProcess) {
|
|
|
|
|
|
this.ptyProcess.write(data);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 15:41:43 +01:00
|
|
|
|
/** Current PTY dimensions — used to skip no-op resizes that trigger Ink redraws */
|
|
|
|
|
|
private _ptyCols = 120;
|
|
|
|
|
|
private _ptyRows = 40;
|
|
|
|
|
|
|
2026-06-10 04:07:26 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Live WebSocket connections that have announced a desktop viewport for this
|
|
|
|
|
|
* session. While at least one is registered, small-viewport (mobile/tablet)
|
|
|
|
|
|
* resizes are ignored so a phone glancing at the session can't reflow the
|
|
|
|
|
|
* PTY under an active desktop view. Claims are connection-scoped: ws-routes
|
|
|
|
|
|
* registers them on a desktop-typed resize and releases them on socket
|
|
|
|
|
|
* close, so a mobile-only session (no desktop connected) keeps full control
|
|
|
|
|
|
* of its own size — including narrowing below the spawn default.
|
2026-06-10 04:31:45 +02:00
|
|
|
|
*
|
|
|
|
|
|
* Deliberate tradeoff: claims are WS-only because only a socket has a
|
|
|
|
|
|
* liveness signal. A desktop degraded to the stateless HTTP resize fallback
|
|
|
|
|
|
* still applies its typed resizes but holds no claim, so a concurrent phone
|
|
|
|
|
|
* can reflow it. This is cooperative UX arbitration, not a security
|
|
|
|
|
|
* boundary — untyped (legacy/API) resizes bypass claims by design.
|
2026-06-10 04:07:26 +02:00
|
|
|
|
*/
|
|
|
|
|
|
private _desktopSizeClaims = new Set<symbol>();
|
|
|
|
|
|
|
2026-06-10 20:41:34 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* A desktop sizing claim only blocks small-viewport resizes while the
|
|
|
|
|
|
* desktop is RECENTLY ACTIVE (claim registration or typed input within this
|
|
|
|
|
|
* window). An abandoned-but-connected desktop tab (left open at home, screen
|
|
|
|
|
|
* locked) must not hold a phone's view hostage: without this, the phone
|
|
|
|
|
|
* renders a desktop-width stream in a narrow xterm — mid-word wraps, tmux
|
|
|
|
|
|
* dot-fill, and Ink overdraw soup (the 0.9.8–0.9.12 mobile regression).
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static readonly DESKTOP_CLAIM_IDLE_MS = 90_000;
|
|
|
|
|
|
|
|
|
|
|
|
/** Last evidence of a live desktop user (claim registered / typed input). */
|
|
|
|
|
|
private _lastDesktopActivityAt = 0;
|
|
|
|
|
|
|
|
|
|
|
|
/** Last desktop-typed dimensions, for re-asserting after a mobile override. */
|
|
|
|
|
|
private _lastDesktopDims: { cols: number; rows: number } | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
/** True while a small viewport reflowed the pane past an idle desktop claim. */
|
|
|
|
|
|
private _mobileSizeOverride = false;
|
|
|
|
|
|
|
2026-06-10 04:07:26 +02:00
|
|
|
|
/** Register a live desktop sizing claim (see _desktopSizeClaims). */
|
|
|
|
|
|
claimDesktopSizing(token: symbol): void {
|
|
|
|
|
|
this._desktopSizeClaims.add(token);
|
2026-06-10 20:41:34 +02:00
|
|
|
|
this._lastDesktopActivityAt = Date.now();
|
2026-06-10 04:07:26 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Release a desktop sizing claim when its connection goes away. */
|
|
|
|
|
|
releaseDesktopSizing(token: symbol): void {
|
|
|
|
|
|
this._desktopSizeClaims.delete(token);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 20:41:34 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Record desktop user activity (typed input over a claim-holding socket).
|
|
|
|
|
|
* If a phone reflowed the pane while the desktop was idle, the desktop
|
|
|
|
|
|
* layout is restored — "whoever is actively using the session wins".
|
|
|
|
|
|
*/
|
|
|
|
|
|
noteDesktopActivity(): void {
|
|
|
|
|
|
this._lastDesktopActivityAt = Date.now();
|
|
|
|
|
|
if (this._mobileSizeOverride && this._lastDesktopDims) {
|
2026-06-10 20:50:24 +02:00
|
|
|
|
this._mobileSizeOverride = false;
|
2026-06-10 20:41:34 +02:00
|
|
|
|
this.resize(this._lastDesktopDims.cols, this._lastDesktopDims.rows, { viewportType: 'desktop' });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Resizes the PTY terminal dimensions.
|
2026-02-20 15:41:43 +01:00
|
|
|
|
* Skips the resize if dimensions haven't changed to avoid triggering
|
|
|
|
|
|
* unnecessary Ink full-screen redraws (visible flicker on tab switch).
|
2026-01-21 04:25:53 +01:00
|
|
|
|
*
|
2026-06-10 20:41:34 +02:00
|
|
|
|
* Arbitration: while a desktop connection holds a sizing claim AND has been
|
|
|
|
|
|
* active within DESKTOP_CLAIM_IDLE_MS, resizes from small viewports
|
|
|
|
|
|
* (mobile/tablet) are ignored — shrink AND grow would both reflow the
|
|
|
|
|
|
* desktop view. Once the desktop goes idle, a phone may take the pane (the
|
|
|
|
|
|
* desktop re-asserts its size on its next typed input via
|
|
|
|
|
|
* noteDesktopActivity). Without a desktop connected, small viewports
|
|
|
|
|
|
* control the PTY size freely.
|
2026-06-10 04:07:26 +02:00
|
|
|
|
*
|
2026-01-21 04:25:53 +01:00
|
|
|
|
* @param cols - Number of columns (width in characters)
|
|
|
|
|
|
* @param rows - Number of rows (height in lines)
|
|
|
|
|
|
*/
|
2026-06-17 23:19:54 +08:00
|
|
|
|
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType; force?: boolean } = {}): void {
|
2026-06-09 10:26:09 -04:00
|
|
|
|
const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet';
|
2026-06-10 20:41:34 +02:00
|
|
|
|
if (options.viewportType === 'desktop') {
|
|
|
|
|
|
this._lastDesktopDims = { cols, rows };
|
|
|
|
|
|
this._lastDesktopActivityAt = Date.now();
|
|
|
|
|
|
this._mobileSizeOverride = false;
|
|
|
|
|
|
}
|
2026-06-10 04:07:26 +02:00
|
|
|
|
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
|
2026-06-10 20:41:34 +02:00
|
|
|
|
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this._mobileSizeOverride = true;
|
2026-06-09 10:26:09 -04:00
|
|
|
|
}
|
2026-06-17 23:19:54 +08:00
|
|
|
|
const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows;
|
|
|
|
|
|
if (this.ptyProcess && (dimsChanged || options.force)) {
|
2026-02-20 15:41:43 +01:00
|
|
|
|
this._ptyCols = cols;
|
|
|
|
|
|
this._ptyRows = rows;
|
2026-06-09 10:38:35 -04:00
|
|
|
|
if (this._mux && this._muxSession) {
|
|
|
|
|
|
this._mux.resizeWindow?.(this._muxSession.muxName, cols, rows);
|
|
|
|
|
|
}
|
2026-01-18 21:36:03 +01:00
|
|
|
|
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();
|
2026-02-27 13:16:23 +01:00
|
|
|
|
this.runPrompt(input).catch((err) => {
|
2026-03-27 01:43:59 +01:00
|
|
|
|
const errorMsg = getErrorMessage(err);
|
2026-02-28 01:35:37 +01:00
|
|
|
|
// Clean up task state so the task queue doesn't get stuck
|
|
|
|
|
|
if (this._currentTaskId) {
|
|
|
|
|
|
const taskId = this._currentTaskId;
|
|
|
|
|
|
this._currentTaskId = null;
|
|
|
|
|
|
this._status = 'idle';
|
|
|
|
|
|
this._lastActivityAt = Date.now();
|
|
|
|
|
|
this.emit('taskError', taskId, errorMsg);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this._status = 'idle';
|
|
|
|
|
|
}
|
2026-02-04 07:24:32 +01:00
|
|
|
|
this.emit('error', errorMsg);
|
2026-01-18 05:14:34 +01:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 05:50:09 +01:00
|
|
|
|
/**
|
2026-01-21 22:36:45 +01:00
|
|
|
|
* Remove event listeners from TaskTracker and RalphTracker.
|
2026-01-21 05:50:09 +01:00
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 22:36:45 +01:00
|
|
|
|
// 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);
|
2026-01-27 00:29:38 +01:00
|
|
|
|
this._ralphTracker.off('statusBlockDetected', this._ralphHandlers.statusBlockDetected);
|
|
|
|
|
|
this._ralphTracker.off('circuitBreakerUpdate', this._ralphHandlers.circuitBreakerUpdate);
|
|
|
|
|
|
this._ralphTracker.off('exitGateMet', this._ralphHandlers.exitGateMet);
|
2026-01-21 22:36:45 +01:00
|
|
|
|
this._ralphHandlers = null;
|
2026-01-21 05:50:09 +01:00
|
|
|
|
}
|
2026-01-23 10:20:37 +01:00
|
|
|
|
|
2026-01-26 00:55:29 +01:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-30 11:30:51 +01:00
|
|
|
|
// Destroy all trackers to release memory and stop timers
|
|
|
|
|
|
this._bashToolParser.destroy();
|
|
|
|
|
|
this._taskTracker.destroy();
|
|
|
|
|
|
this._ralphTracker.destroy();
|
2026-01-21 05:50:09 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-21 04:25:53 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Stops the session and cleans up resources.
|
|
|
|
|
|
*
|
2026-02-18 12:51:35 +01:00
|
|
|
|
* This kills the PTY process and optionally the associated tmux session.
|
|
|
|
|
|
* All buffers are cleared and the session is marked as stopped.
|
2026-01-21 04:25:53 +01:00
|
|
|
|
*
|
2026-02-18 12:51:35 +01:00
|
|
|
|
* @param killMux - Whether to also kill the mux session (default: true)
|
2026-01-21 04:25:53 +01:00
|
|
|
|
*
|
|
|
|
|
|
* @example
|
|
|
|
|
|
* ```typescript
|
|
|
|
|
|
* // Stop and kill everything
|
|
|
|
|
|
* await session.stop();
|
|
|
|
|
|
*
|
2026-02-18 12:51:35 +01:00
|
|
|
|
* // Stop but keep mux session running for later reattachment
|
2026-01-21 04:25:53 +01:00
|
|
|
|
* await session.stop(false);
|
|
|
|
|
|
* ```
|
|
|
|
|
|
*/
|
2026-02-18 12:51:35 +01:00
|
|
|
|
async stop(killMux: boolean = true): Promise<void> {
|
2026-01-22 00:08:32 +01:00
|
|
|
|
// Set stopped flag first to prevent new timers from being created
|
|
|
|
|
|
this._isStopped = true;
|
|
|
|
|
|
|
2026-03-25 23:21:38 +01:00
|
|
|
|
this._clearAllTimers();
|
2026-02-20 14:33:06 +01:00
|
|
|
|
|
2026-06-10 04:31:45 +02:00
|
|
|
|
// Drop desktop sizing claims defensively. Sockets normally release their
|
|
|
|
|
|
// own claim on close, but a hung client's close event can lag the session
|
|
|
|
|
|
// teardown by up to a ping cycle — don't let a stale claim suppress
|
|
|
|
|
|
// mobile resizes if this Session object sees any further use.
|
|
|
|
|
|
this._desktopSizeClaims.clear();
|
|
|
|
|
|
|
2026-01-21 05:34:45 +01:00
|
|
|
|
// Immediately cleanup Promise callbacks to prevent orphaned references
|
2026-02-18 12:51:35 +01:00
|
|
|
|
// during the rest of stop() processing (e.g., if mux kill times out)
|
2026-02-12 20:14:01 +01:00
|
|
|
|
if (this.rejectPromise && !this._promptResolved) {
|
|
|
|
|
|
this._promptResolved = true;
|
2026-01-21 05:34:45 +01:00
|
|
|
|
this.rejectPromise(new Error('Session stopped'));
|
|
|
|
|
|
}
|
|
|
|
|
|
this.resolvePromise = null;
|
|
|
|
|
|
this.rejectPromise = null;
|
|
|
|
|
|
|
2026-01-21 05:50:09 +01:00
|
|
|
|
// Remove event listeners from trackers to prevent memory leaks
|
|
|
|
|
|
this.cleanupTrackerListeners();
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
if (this.ptyProcess) {
|
2026-02-19 22:15:36 +01:00
|
|
|
|
if (killMux) {
|
|
|
|
|
|
// Full kill: SIGTERM → wait → SIGKILL the PTY and its children
|
|
|
|
|
|
const pid = this.ptyProcess.pid;
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
2026-02-19 22:15:36 +01:00
|
|
|
|
// First try graceful SIGTERM
|
|
|
|
|
|
try {
|
|
|
|
|
|
this.ptyProcess.kill();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err);
|
2026-01-18 04:50:46 +01:00
|
|
|
|
}
|
2026-01-18 21:36:03 +01:00
|
|
|
|
|
2026-02-19 22:15:36 +01:00
|
|
|
|
// Give it a moment to terminate gracefully
|
2026-02-27 13:16:23 +01:00
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
|
2026-02-19 22:15:36 +01:00
|
|
|
|
|
|
|
|
|
|
// Force kill with SIGKILL if still alive
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (pid) {
|
|
|
|
|
|
process.kill(pid, 'SIGKILL');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.warn('[Session] Failed to send SIGKILL to process (already terminated):', err);
|
2026-01-18 21:36:03 +01:00
|
|
|
|
}
|
2026-02-19 22:15:36 +01:00
|
|
|
|
|
|
|
|
|
|
// Also try to kill any child processes in the process group
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (pid) {
|
|
|
|
|
|
process.kill(-pid, 'SIGKILL');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.warn('[Session] Failed to send SIGKILL to process group (may not exist):', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Server shutdown: just detach — the process lives on inside tmux
|
|
|
|
|
|
console.log('[Session] Detaching from PTY (server shutdown) — mux session preserved');
|
2026-01-18 21:36:03 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.ptyProcess = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
this._pid = null;
|
2026-02-19 22:15:36 +01:00
|
|
|
|
this._status = killMux ? 'stopped' : 'idle';
|
2026-01-18 21:36:03 +01:00
|
|
|
|
this._currentTaskId = null;
|
|
|
|
|
|
|
2026-02-12 18:26:16 +01:00
|
|
|
|
// Clear task description cache and agent tree to prevent memory leak
|
2026-03-01 04:04:35 +01:00
|
|
|
|
this._taskCache.clear();
|
2026-02-12 18:26:16 +01:00
|
|
|
|
this._childAgentIds = [];
|
2026-01-30 13:25:18 +01:00
|
|
|
|
|
2026-02-12 10:29:57 +01:00
|
|
|
|
// Kill the associated mux session if requested
|
2026-02-18 12:51:35 +01:00
|
|
|
|
if (killMux && this._mux) {
|
2026-02-12 10:29:57 +01:00
|
|
|
|
// Try to kill mux session even if _muxSession is not set (e.g., restored sessions)
|
2026-01-19 04:22:41 +01:00
|
|
|
|
try {
|
2026-02-12 10:29:57 +01:00
|
|
|
|
const killed = await this._mux.killSession(this.id);
|
2026-01-19 11:48:02 +01:00
|
|
|
|
if (killed) {
|
2026-02-12 10:29:57 +01:00
|
|
|
|
console.log('[Session] Killed mux session for:', this.id);
|
2026-01-19 11:48:02 +01:00
|
|
|
|
}
|
2026-01-19 04:22:41 +01:00
|
|
|
|
} catch (err) {
|
2026-02-12 10:29:57 +01:00
|
|
|
|
console.error('[Session] Failed to kill mux session:', err);
|
2026-01-19 04:22:41 +01:00
|
|
|
|
}
|
2026-02-12 10:29:57 +01:00
|
|
|
|
this._muxSession = null;
|
2026-02-18 12:51:35 +01:00
|
|
|
|
} else if (this._muxSession && !killMux) {
|
2026-02-12 10:29:57 +01:00
|
|
|
|
console.log('[Session] Keeping mux session alive:', this._muxSession.muxName);
|
|
|
|
|
|
this._muxSession = null; // Detach but don't kill
|
2026-01-19 04:22:41 +01:00
|
|
|
|
}
|
2026-01-18 04:50:46 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
assignTask(taskId: string): void {
|
|
|
|
|
|
this._currentTaskId = taskId;
|
|
|
|
|
|
this._status = 'busy';
|
2026-01-21 05:10:57 +01:00
|
|
|
|
this._terminalBuffer.clear();
|
|
|
|
|
|
this._textOutput.clear();
|
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-21 04:55:48 +01:00
|
|
|
|
return this._textOutput.value;
|
2026-01-18 04:50:46 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getError(): string {
|
|
|
|
|
|
return this._errorBuffer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 21:36:03 +01:00
|
|
|
|
getTerminalBuffer(): string {
|
2026-01-21 04:55:48 +01:00
|
|
|
|
return this._terminalBuffer.value;
|
2026-01-18 21:36:03 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 04:50:46 +01:00
|
|
|
|
clearBuffers(): void {
|
2026-01-21 04:55:48 +01:00
|
|
|
|
this._terminalBuffer.clear();
|
|
|
|
|
|
this._textOutput.clear();
|
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-21 22:36:45 +01:00
|
|
|
|
this._ralphTracker.clear();
|
2026-03-01 04:04:35 +01:00
|
|
|
|
this._taskCache.clear();
|
2026-01-18 04:50:46 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|