/** * @fileoverview Core PTY session wrapper for Claude CLI interactions. * * 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 * * Optionally wraps in a tmux session for persistence across disconnects. * Tracks tokens, costs, background tasks, and auto-compact/clear. * * 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:promptSubmitted, session:exit * * @module session */ import { EventEmitter } from 'node:events'; import { execSync, execFileSync } from 'node:child_process'; import { v4 as uuidv4 } from 'uuid'; import * as pty from 'node-pty'; import { SessionState, SessionStatus, SessionConfig, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG, getErrorMessage, isEffortLevel, type ClaudeMode, type SessionMode, type OpenCodeConfig, type CodexConfig, type EffortLevel, type CustomModelBookkeeping, type CustomModelSelection, type GeminiConfig, type AntigravityConfig, type PiConfig, type GrokConfig, type DeepSeekConfig, type OmpConfig, type SessionRemote, type SessionDocker, type SessionNameSource, type SessionWriteOptions, type PaneExit, } from './types.js'; import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js'; import { claudeTranscriptExists } from './utils/claude-transcript.js'; import { matchesPattern } from './config/cli-registry/patterns.js'; import { probeDockerCliVersion } from './docker-hosts.js'; import { probeRemoteCliVersion } from './remote-hosts.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; import { BashToolParser } from './bash-tool-parser.js'; import { isTrustDialogScreen, trustDialogNextKey, TRUST_KEY_CONFIRM, TRUST_DIALOG_WINDOW_MS, TRUST_DIALOG_RETRY_MS, TRUST_DIALOG_MAX_ATTEMPTS, TRUST_DIALOG_SCAN_BYTES, } from './session-trust-dialog.js'; import { trackActivityStreak, isSustainedActivity, isPaneQuiet, watchingLabel, WATCHING_TAIL_LINES, IDLE_RECHECK_MS, PANE_PROBE_MIN_INTERVAL_MS, PANE_PROBE_RECHECK_MS, type ActivityStreak, } from './session-activity.js'; import { BufferAccumulator, ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, CLAUDE_WORKING_LINE_PATTERN, MAX_SESSION_TOKENS, execPattern, getClaudeCliVersion, getClaudeBinaryPath, spawnPtyWithHelperRepair, resolveLocalShell, } from './utils/index.js'; 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'; import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { getCli } from './config/cli-registry/registry.js'; import { SubmitVerifier } from './session-submit-verifier.js'; import { compileVersionRegex } from './config/cli-registry/patterns.js'; import { resolveSessionCliVersion } from './utils/cli-resolver.js'; import { buildInteractiveArgs, buildPromptArgs, buildClaudeEnv, buildMuxAttachEnv, buildShellEnv, } from './session-cli-builder.js'; import { SessionAutoOps } from './session-auto-ops.js'; import { detectUsageLimitPause } from './usage-limit-patterns.js'; import { SessionTaskCache } from './session-task-cache.js'; import { InteractivePtyExitBreaker } from './session-pty-exit-breaker.js'; import { isGeneratedSessionName, SubmittedPromptTracker } from './session-auto-name.js'; import { parseTerminalAttachmentRequests } from './attachment-magic.js'; import { sanitizeAttachmentHistory, upsertAttachmentHistory as upsertAttachmentHistoryList, } from './session-attachment-history.js'; import type { SessionAttachmentHistoryItem } from './types/session.js'; export type { BackgroundTask } from './task-tracker.js'; export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js'; export type ResizeViewportType = 'mobile' | 'tablet' | 'desktop'; /** Line buffer flush interval (100ms) - forces processing of partial lines */ const LINE_BUFFER_FLUSH_INTERVAL = 100; // ============================================================================ // Timing Constants // ============================================================================ /** Delay after mux session creation before sending commands (300ms) */ const MUX_STARTUP_DELAY_MS = 300; /** Delay before declaring session idle after last output (2 seconds) */ const IDLE_DETECTION_DELAY_MS = 2000; // How long after construction a RECOVERED session's wire activity stamp keeps // its restored previous-run value. Recovery attaches every pane at boot and the // attach repaint arrives as ordinary PTY output; without this window that // repaint would overwrite every restored stamp within the same second, which is // exactly the restart flattening the restore exists to prevent. Real actions // (input, task assignment, respawn) always stamp through it. const WIRE_ACTIVITY_SETTLE_MS = 15_000; // Note: Auto-compact/clear timing constants moved to session-auto-ops.ts /** Graceful shutdown delay when stopping session (100ms) */ const GRACEFUL_SHUTDOWN_DELAY_MS = 100; // Conversations kept in a pane's chain. A pane that /clears repeatedly would // otherwise grow state.json without bound; 32 covers any real session's history // and the oldest entries are the ones whose transcripts Claude Code has pruned. const MAX_CLAUDE_SESSION_CHAIN = 32; // Filter out terminal focus escape sequences (focus in/out reports) // ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences // eslint-disable-next-line no-control-regex const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g; // 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; // Pre-compiled patterns for hot paths (avoid regex compilation per call) /** Pattern to strip leading ANSI escapes and whitespace from terminal buffer */ // eslint-disable-next-line no-control-regex const LEADING_ANSI_WHITESPACE_PATTERN = /^(\x1b\[\??[\d;]*[A-Za-z]|[\s\r\n])+/; /** Pattern to match Ctrl+L (form feed) characters */ // eslint-disable-next-line no-control-regex const CTRL_L_PATTERN = /\x0c/g; /** Pattern to split by newlines (CR or LF) */ const NEWLINE_SPLIT_PATTERN = /\r?\n/; /** * True for external-CLI run modes (non-Claude) that use their own TUI and output format: * no Claude transcript, no hooks, no Claude-format token/BashTool parsing. * * ⚠️ Reads its OWN capability flag rather than being derived from `hooks` or `kind`, and * that independence is load-bearing. `shell` has no hooks but is NOT external, so a * predicate derived from hooks would sweep it in here; `deepseek` HAS hooks but IS * external. Deriving one of these three predicates from another has already shipped a bug * (see CliCapabilities' own doc comment), which is why they are three separate fields. * * An UNREGISTERED mode is treated as external — the conservative answer, since it disables * Claude-specific parsing rather than pointing it at output that was never Claude's. */ export function isExternalCliMode(mode: SessionMode): boolean { return getCli(mode)?.capabilities.external ?? true; } /** Display name for a run mode. Falls back to the raw id for an unregistered one. */ function getModeLabel(mode: SessionMode): string { return getCli(mode)?.label ?? mode; } /** * Does this CLI's launch spec gate anything on its own version? * * Only such a CLI needs its version probed at session start — probing one with no gates * would spawn a `--version` subprocess whose answer nothing reads. Today that is claude * (the `--name` flag, gated at 2.1.224), which is why the probe used to be written as * `mode === 'claude'`. */ function cliNeedsVersionProbe(mode: SessionMode): boolean { return Object.keys(getCli(mode)?.capabilities.gates ?? {}).length > 0; } /** * Does this CLI ask for `COLORTERM=truecolor`? * * Read off the SAME `env.exports` list that `buildEnvExports()` emits into the tmux * session, so the attach client and the pane cannot disagree about colour depth. These * used to be two hand-maintained lists of mode names in two files that had to be edited * together, with a comment in each asking the next person to remember. */ function cliExportsTruecolor(mode: SessionMode): boolean { return (getCli(mode)?.env.exports ?? []).some((entry) => entry.name === 'COLORTERM' && entry.value === 'truecolor'); } /** * 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). * * 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), `opencode` (renders its own * TUI that may rely on it), `pi` (below) and `grok` (a fullscreen alt-screen TUI * with mouse support, i.e. the opencode case, not the Ink case). Keep parity * with the replay-side strip in session-routes.ts. * * ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode * falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen * toggles too whenever the session is tmux-backed, and pi/opencode ALWAYS are * (both refuse the direct-PTY fallback). What exclusion actually buys is the rest * of the full strip: `\x1b[3J` and the mouse-tracking DECSETs survive. That is the * real reason pi is out: its default TUI renders into the MAIN screen with * terminal-owned scrollback and is mouse-aware, so it is a `3J`/mouse consumer in * a way an Ink TUI repainting in place is not. Consequence to know before * debugging it: pi's runtime-switchable fullscreen TUI (`/settings`, 0.84.0+) * still gets its `?1049h` stripped and paints into the main buffer, exactly like * vim inside a tmux `shell` session. */ export function isAltScreenStripMode(mode: SessionMode): boolean { return getCli(mode)?.capabilities.altScreen === 'strip-full'; } /** * Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J` * and the mouse-tracking DECSETs alone. Applies to every mode `isAltScreenStripMode` * excludes, but ONLY when the session is tmux-backed (`useMux`). * * The bug (issue #205): the tmux CLIENT emits `smcup` (`\x1b[?1049h`) as its first * bytes on attach, before any program has run. Unstripped, xterm.js parks in the * alternate buffer for the whole session, where `baseY` is pinned at 0 (no * scrollback to reach, so touch scrolling is a no-op) and xterm's own wheel handler * translates the wheel into `\x1bOA`/`\x1bOB` cursor keys — which readline receives * as shell history navigation. Both reported symptoms, one sequence. * * Why this is safe under tmux, despite the old "shell must keep the alt screen for * vim/less/htop" reasoning: tmux is a full terminal emulator and NEVER forwards a * pane's alt-screen toggles to its client, it repaints instead. Captured from a real * attach, `\x1b[?1049h` appears exactly once (at attach) and vim/less/htop sessions * inside the pane emit zero. So the only thing stripped here is tmux's own smcup. * * Why it is gated on `useMux`: `startShell()`/`startInteractive()` fall back to a * DIRECT PTY when mux creation fails. There the inner program's `\x1b[?1049h` really * does reach xterm, and stripping it would break vim/less/htop for real. * * Why it is narrower than the full strip: with tmux `mouse off`, a mouse-aware * program in the pane (htop, vim with `set mouse=a`) still gets its DECSETs passed * through to the client, so stripping those would break its mouse support. And * `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback". */ export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean { return useMux && !isAltScreenStripMode(mode); } // Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv) /** 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; const IS_TEST_MODE = !!process.env.VITEST; /** * Echo transport for the test-mode PTY attach. Raw mode disables the tty line * discipline, so each input byte flows back exactly once and immediately; without * it, tty echo doubles every line and canonical buffering holds bytes until Enter. */ const TEST_PTY_SCRIPT = 'if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.pipe(process.stdout);'; /** Delay before the in-container Claude CLI version probe (lets the container start). */ const DOCKER_CLI_VERSION_PROBE_DELAY_MS = 3000; /** Delay before the over-ssh Claude CLI version probe (keeps session start off the ssh round-trip). */ const REMOTE_CLI_VERSION_PROBE_DELAY_MS = 3000; /** * 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". * * `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. * * Argv form (execFileSync, not execSync) keeps `muxName` out of any shell so * a hostile session name can't inject options. */ export function queryTmuxWindowSize(muxName: string, socket: string): { cols: number; rows: number } { try { const sizeStr = execFileSync( 'tmux', ['-L', socket, 'display', '-t', muxName, '-p', '#{window_width} #{window_height}'], { timeout: TMUX_DISPLAY_TIMEOUT_MS, encoding: 'utf8', } ).trim(); 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 }; } export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote, docker?: SessionDocker): string { // Remote and docker sessions run the CLI elsewhere (ssh / docker exec); the LOCAL // wrapper pane never needs the workspace as its cwd, so launch it in /tmp. return remote || docker ? '/tmp' : workingDir; } /** * Represents a JSON message from Claude CLI's stream-json output format. * Messages are newline-delimited JSON objects parsed from PTY output. */ export interface ClaudeMessage { /** Message type indicating the role or purpose */ type: 'system' | 'assistant' | 'user' | 'result'; /** Optional subtype for further classification */ subtype?: string; /** Claude's internal session identifier */ session_id?: string; /** Message content with optional token usage */ message?: { content: Array<{ type: string; text?: string }>; usage?: { input_tokens: number; output_tokens: number; }; }; /** Final result text (on result messages) */ result?: string; /** Whether this message represents an error */ is_error?: boolean; /** Total cost in USD (on result messages) */ total_cost_usd?: number; /** Total duration in milliseconds (on result messages) */ duration_ms?: number; } /** * Event signatures emitted by the Session class. * Subscribe using `session.on('eventName', handler)`. */ /** * 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', * mux: muxManager, * useMux: true * }); * await session.startInteractive(); * * // Listen for events * session.on('terminal', (data) => console.log(data)); * session.on('message', (msg) => console.log('Claude:', msg)); * * // Send input * session.write('Hello Claude!\r'); * * // Stop when done * await session.stop(); * ``` * * @fires Session#terminal - Raw terminal output * @fires Session#message - Parsed Claude JSON message * @fires Session#completion - One-shot prompt completed * @fires Session#exit - Process exited * @fires Session#autoClear - Token threshold reached, clearing context * @fires Session#autoCompact - Token threshold reached, compacting context */ export class Session extends EventEmitter { readonly id: string; readonly workingDir: string; readonly createdAt: number; readonly mode: SessionMode; // Task description cache (extracted to SessionTaskCache) private _taskCache = new SessionTaskCache(); private _name: string; private _nameSource: SessionNameSource; /** * Reconstructs the composer draft from USER keystrokes so the first real * prompt can name the tab. Fed only when a write says `fromUser`, and never * for a CLI whose Enter runs a command rather than submitting a prompt * (`startMode: 'shell'`), so a shell tab is not renamed after every `ls`. */ private readonly _submittedPromptTracker = new SubmittedPromptTracker(); private readonly _acceptsPrompts: boolean; private ptyProcess: pty.IPty | null = null; private _pid: number | null = null; private _status: SessionStatus = 'idle'; private _currentTaskId: string | null = null; // 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; // 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); private _errorBuffer: string = ''; private _lastActivityAt: number; // Display twin of _lastActivityAt, reported by toState()/the getter. It can // lag behind on recovery: the restored previous-run stamp survives the attach // repaint (see _markActivity), so a restart does not flatten the home // screens' quiet ordering. Idle detection never reads it. private _wireActivityAt: number; private _wireActivitySettleUntil: number; private _claudeSessionId: string | null = null; // Set only when the id came from the CLI's own UserPromptSubmit/Stop hook // payload, keyed on this pane's $CODEMAN_SESSION_ID. That binding is a fact, // not a correlation: it never consults cwd, so a sibling pane on the same // folder cannot steal it. Runtime-only — a restart must re-earn it from the // next hook rather than trust a persisted claim. private _claudeSessionIdIsFirstHand = false; // Conversations this pane has been on, oldest first, current last. Grows only // through a first-hand adoption, so it can never splice in a foreign // conversation. Persisted, because `/clear` is otherwise unrecoverable: the // predecessor id exists nowhere else once the pane moves on. private _claudeSessionChain: string[] = []; private _totalCost: number = 0; private _messages: ClaudeMessage[] = []; private _lineBuffer: string = ''; private _lineBufferFlushTimer: NodeJS.Timeout | null = null; // 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 = ''; /** * Mouse-tracking DECSET modes the CLI currently has ON, as observed while * STRIPPING them out of the stream below. Kept as a set rather than a boolean * because a TUI may enable 1002 and later disable 1000 (a mode it never * enabled); tracking is on while any of them is. */ private _cliMouseModes = new Set(); private _cliMouseTracking = false; private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null; private rejectPromise: ((reason: Error) => void) | null = null; private _promptResolved: boolean = false; // Guard against race conditions in runPrompt private _isWorking: boolean = false; private _lastPromptTime: number = 0; private activityTimeout: NodeJS.Timeout | null = null; private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection) private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read) /** * Background work the pane's own footer reports, e.g. `1 monitor`; null for none. * * Cached BESIDE `_lastPaneProbeWorking` and refreshed only by a capture that really * happened, so it goes stale exactly as that verdict does. The probe returns its * cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS`, and a * label derived from a capture nobody took would be a guess wearing a fact's clothes. * * ⚠️ It then FREEZES once `_confirmIdle()` concludes: `activityTimeout` is null from * there, and nothing looks at the pane again until it produces output. That is * correct rather than merely tolerable, because work ending repaints the pane either * way — a monitor firing wakes the agent, and codex drops its background-terminal row * on its own. Do not add a timer to keep this fresh; it would spend a `capture-pane` * per idle session per tick to learn nothing. * * A server restart is not a hole in that either, though it looks like one: this field * is live state and starts empty. Reconciliation re-attaches the pane, the attach * repaint carries the composer glyph, and the idle confirmation that arms on it probes * and re-reads the label with no input from anyone — measured 2026-09-23 on a restarted * instance, back within ~20 s for a session whose background terminal was still * running. A session that comes back with no label has no chip on its screen. */ private _watching: string | null = null; /** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */ private _workingLineRe: RegExp | undefined = undefined; /** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */ private _watchingLineRe: RegExp | null | undefined = undefined; /** Resolved with the pattern above: how many rows at the foot of the screen to search. */ private _watchingWindow = WATCHING_TAIL_LINES; private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up) private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read private _trustDialogTimer: NodeJS.Timeout | null = null; // Re-read after a keystroke (see below) /** Re-sends Enter while a programmatic prompt still sits in the composer (session-submit-verifier.ts). */ private _submitVerifier: SubmitVerifier | null = null; private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan) private _taskTracker: TaskTracker; // Token tracking for auto-clear private _totalInputTokens: number = 0; private _totalOutputTokens: number = 0; // Auto-compact/auto-clear automation (extracted to SessionAutoOps) private _autoOps!: SessionAutoOps; // Image watcher setting (per-session toggle) private _imageWatcherEnabled: boolean = false; // Pin state (COD-139) — pinned sessions float to the top of the session // manager list, ordered by pinnedAt descending (most-recently-pinned first). private _pinned: boolean = false; private _pinnedAt: number | null = null; // Flicker filter setting (per-session toggle, applied on frontend) private _flickerFilterEnabled: boolean = false; // 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 // 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; // Multiplexer session support (tmux) private _mux: TerminalMultiplexer | null = null; private _muxSession: MuxSession | null = null; private _useMux: boolean = false; /** * The agent in this session's local tmux pane has exited (Ark0N/Codeman#446). * `null` is the UNKNOWN arm of the tri-state and is what {@link setPaneExit} * stores for every session shape the field does not apply to. See * {@link PaneExit} for the shapes and for why an unknown answer must never be * rendered as "alive". */ private _paneExit: PaneExit | null = null; /** * This session was rebuilt from the tmux socket rather than from Codeman's * own records, so its `remote`/`docker` metadata is missing rather than known * to be absent. See {@link MuxSession.discovered}. */ private _discoveredMuxSession = false; // Flag to prevent new timers after session is stopped private _isStopped: boolean = false; // Ralph tracking (Ralph Wiggum loops and todo lists inside Claude Code) private _ralphTracker: RalphTracker; // Agent tree tracking private _parentAgentId: string | null = null; private _childAgentIds: string[] = []; // Bounded dedup set for terminal attachment magic-links already requested. private _attachmentMagicSeen = new Set(); private _attachmentHistory: SessionAttachmentHistoryItem[] = []; // Nice prioritying configuration private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG }; // Claude model override (e.g., 'opus', 'sonnet', 'haiku') private _model: string | undefined; // Claude CLI startup permission mode private _claudeMode: ClaudeMode = 'dangerously-skip-permissions'; private _allowedTools: string | undefined; // OpenCode configuration (only for mode === 'opencode') private _openCodeConfig: OpenCodeConfig | undefined; // Codex configuration (only for mode === 'codex') private _codexConfig: CodexConfig | undefined; // Gemini configuration (only for mode === 'gemini') private _geminiConfig: GeminiConfig | undefined; // Antigravity configuration (only for mode === 'antigravity') private _antigravityConfig: AntigravityConfig | undefined; // Pi configuration (only for mode === 'pi') private _piConfig: PiConfig | undefined; // Grok configuration (only for mode === 'grok') private _grokConfig: GrokConfig | undefined; // DeepSeek Harness configuration (only for mode === 'deepseek') private _deepSeekConfig: DeepSeekConfig | undefined; // OMP configuration (only for mode === 'omp') private _ompConfig: OmpConfig | undefined; private _resumeSessionId: string | undefined; // 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. private _envOverrides: Record | undefined; // 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; // Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md). `envKeys`, // `configDir` and `launchModel` are internal bookkeeping ONLY (never surfaced via // toState()/the customModel getter): they are what setCustomModel() needs to undo a // previous injection (remove exactly the env keys it added, delete a previous isolated // config dir) without guessing what it once wrote. Persisted disk-only (`__customModel`). private _customModel: CustomModelBookkeeping | undefined; // Env keys a retired custom-model selection injected that the NEXT respawn must // `tmux setenv -u`. Deleting a key from `_envOverrides` alone does nothing to the // tmux session, which keeps every `setenv` and hands it to `respawn-pane`, so the // relaunched CLI would come back still pointed at the old endpoint (measured, see // TmuxManager.applyEnvOverrides). Drained after a successful respawn. private _pendingEnvUnsets = new Set(); // tmux history-limit (scrollback lines) allocated when this session's pane is created. private readonly _tmuxHistoryLimit: number; // Remote execution metadata, present when this session runs over SSH through local tmux. private readonly _remote?: SessionRemote; // Docker execution metadata, present when this session runs inside a container via // local tmux + `docker exec`. The container is per-CASE (shared by sibling sessions). private readonly _docker?: SessionDocker; // Owning username in multi-user mode (undefined in single-user). Stamped at create // from req.authUser and round-tripped through recovery like _remote/_docker. private _owner?: string; // The session that spawned this one (tab lineage lines). Resolved by the create // route before it reaches here, so this is always either an id that existed at // create time or undefined. Decoration only — see SessionState.parentSessionId. private readonly _parentSessionId?: string; // Session color for visual differentiation private _color: import('./types.js').SessionColor = 'default'; // Store handler references for cleanup (prevents memory leaks) private _taskTrackerHandlers: { taskCreated: (task: BackgroundTask) => void; taskUpdated: (task: BackgroundTask) => void; taskCompleted: (task: BackgroundTask) => void; taskFailed: (task: BackgroundTask, error: string) => void; } | null = null; private _ralphHandlers: { loopUpdate: (state: RalphTrackerState) => void; todoUpdate: (todos: RalphTodoItem[]) => void; completionDetected: (phrase: string) => void; statusBlockDetected: (block: import('./types.js').RalphStatusBlock) => void; circuitBreakerUpdate: (status: import('./types.js').CircuitBreakerStatus) => void; exitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void; } | null = null; // Bash tool tracking (file paths for live log viewing) private _bashToolParser: BashToolParser; private _bashToolHandlers: { toolStart: (tool: ActiveBashTool) => void; toolEnd: (tool: ActiveBashTool) => void; toolsUpdate: (tools: ActiveBashTool[]) => void; } | null = null; // Task descriptions parsed from terminal output — delegated to SessionTaskCache // 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 constructor( config: Partial & { workingDir: string; mode?: SessionMode; name?: string; /** * Who owns the name (see `SessionNameSource`). Omitted, it is inferred * from the name: Codeman's own `w-` placeholders (or no name) * stay eligible for auto-naming, anything else counts as the user's. */ nameSource?: SessionNameSource; /** 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; /** Codex configuration (only for mode === 'codex') */ codexConfig?: CodexConfig; /** Gemini configuration (only for mode === 'gemini') */ geminiConfig?: GeminiConfig; /** Antigravity configuration (only for mode === 'antigravity') */ antigravityConfig?: AntigravityConfig; /** Pi configuration (only for mode === 'pi') */ piConfig?: PiConfig; /** Grok configuration (only for mode === 'grok') */ grokConfig?: GrokConfig; /** DeepSeek Harness configuration (only for mode === 'deepseek') */ deepSeekConfig?: DeepSeekConfig; /** OMP configuration (only for mode === 'omp') */ ompConfig?: OmpConfig; /** Resume a previous Claude conversation (used after server reboot) */ resumeSessionId?: string; /** Extra env vars exported to the CLI at spawn time (no disk persistence) */ envOverrides?: Record; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort?: EffortLevel; /** tmux history-limit (scrollback lines) allocated when this session's pane is created. */ tmuxHistoryLimit?: number; /** Restored per-session attachment history. May include server-private external paths. */ attachmentHistory?: SessionAttachmentHistoryItem[]; /** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */ lastSubmitAt?: number; /** Restored conversation chain, oldest first (see `claudeSessionChain`). */ claudeSessionChain?: string[]; /** Restored agent-exit observation for this session's pane (see `paneExit`). */ paneExit?: PaneExit; /** This session was rebuilt from the tmux socket, so its metadata is a guess. */ discoveredMuxSession?: boolean; /** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */ lastActivityAt?: number; /** Remote execution metadata for sessions launched through SSH inside local tmux. */ remote?: SessionRemote; /** Docker execution metadata for sessions launched inside a container via local tmux. */ docker?: SessionDocker; /** Owning username (multi-user mode); undefined in single-user. */ owner?: string; /** Session that spawned this one — tab lineage decoration, resolved by the caller. */ parentSessionId?: string; } ) { super(); this.setMaxListeners(25); // 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); }); this.id = config.id || uuidv4(); this.workingDir = config.workingDir; this.createdAt = config.createdAt || Date.now(); this.mode = config.mode || 'claude'; this._name = config.name || ''; this._nameSource = config.nameSource ?? (!this._name || isGeneratedSessionName(this._name) ? 'placeholder' : 'manual'); this._acceptsPrompts = getCli(this.mode)?.capabilities.startMode !== 'shell'; this._resumeSessionId = config.resumeSessionId; // NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a // days-old tmux session, and seeding last-activity from it would report a // freshly re-attached pane as having been silent for days, which the idle // confirmation reads as "already quiet". For a genuinely new session the // two are the same instant. this._lastActivityAt = Date.now(); // The WIRE copy of the stamp is allowed to be older: recovery threads the // previous run's value so a restart does not flatten the home screens' // most-recently-quiet ordering (every stamp otherwise resets to boot time, // and the attach repaint re-bumps the rest within the same second). The // settle window in _markActivity() carries the restored value through that // repaint; the private stamp above stays boot-anchored because the idle // confirmation reads it as "how long has the pane been quiet". this._wireActivityAt = config.lastActivityAt || Date.now(); this._wireActivitySettleUntil = config.lastActivityAt ? Date.now() + WIRE_ACTIVITY_SETTLE_MS : 0; // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one. // For omp and codex, `claudeSessionId` doubles as the generic "external // transcript id" alias key mergeUnifiedSessions() folds a history row into // its owning session by: each mints its OWN thread id, unrelated to this // Codeman id, so without this the conversation's Past-Sessions row (keyed by // that thread id) would never merge with its own live/persisted row (keyed // by this id) — it would just show up a second time. For codex a duplicate // is worse than cosmetic: the stale row still resumes, so clicking it starts // a SECOND `codex resume` on a thread already open in another pane. // // This covers a RESUMED codex session, which knows its thread id up front. A // fresh one learns its id only once codex writes the rollout, so it is folded // from the other side — see the originator stamping in `gatherUnifiedInputs()`. this._claudeSessionId = config.resumeSessionId || config.ompConfig?.resumeSessionId || config.codexConfig?.resumeSessionId || this.id; // Restored from state.json on boot recovery. start() resets _claudeSessionId // to the launch id even when re-attaching to a mux session whose CLI has // moved on (a `/clear` before the restart), so this anchor is what lets the // response viewer re-derive the live conversation without waiting for the // user to type again. this._lastSubmitAt = config.lastSubmitAt ?? 0; // Restored chain: its tail is the conversation the CLI was actually on when // the server stopped, which outranks the launch id seeded just above. The // FIRST-HAND flag is deliberately NOT restored — a persisted claim is not a // fact, so the pane re-earns the guess-free path from its next hook. this._claudeSessionChain = Array.isArray(config.claudeSessionChain) ? [...config.claudeSessionChain] : []; const restoredConversation = this._claudeSessionChain[this._claudeSessionChain.length - 1]; if (restoredConversation) this._claudeSessionId = restoredConversation; this._mux = config.mux || null; this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable()); this._muxSession = config.muxSession || null; // Apply Nice priority configuration if provided if (config.niceConfig) { this._niceConfig = { ...config.niceConfig }; } // Apply model override if provided if (config.model) { this._model = config.model; } // Apply Claude CLI permission mode if (config.claudeMode) { this._claudeMode = config.claudeMode; } if (config.allowedTools) { this._allowedTools = config.allowedTools; } // Apply OpenCode configuration if (config.openCodeConfig) { this._openCodeConfig = config.openCodeConfig; } // Apply Codex configuration if (config.codexConfig) { this._codexConfig = config.codexConfig; } // Apply Gemini configuration if (config.geminiConfig) { this._geminiConfig = config.geminiConfig; } // Apply Antigravity configuration if (config.antigravityConfig) { this._antigravityConfig = config.antigravityConfig; } // Apply Pi configuration if (config.piConfig) { this._piConfig = config.piConfig; } // Apply OMP configuration if (config.ompConfig) { this._ompConfig = config.ompConfig; } // Apply DeepSeek Harness configuration if (config.deepSeekConfig) { this._deepSeekConfig = config.deepSeekConfig; } // Apply Grok configuration if (config.grokConfig) { this._grokConfig = config.grokConfig; } // 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. if (config.envOverrides && Object.keys(config.envOverrides).length > 0) { 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; } this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT; this._remote = config.remote; this._docker = config.docker; this._owner = config.owner; this._discoveredMuxSession = config.discoveredMuxSession === true; // Restored so a record that says the agent exited survives a server restart // rather than being blanked by the first persist after boot. It runs here // because the scoping reads `_remote`, `_docker` and the mux fields, all of // which are set by now. It is a claim about a pane this process has not // looked at yet, so every path that starts or re-attaches a pane drops it // (see `_setupOrAttachMuxSession`) and the pane-exit watcher's own tick // replaces it with a first-hand reading. NOT the stats collector, which a // browser panel arms and disarms — see `startPaneExitWatcher`. this.setPaneExit(config.paneExit); // Never self-parent: a session pointing at itself would draw a zero-length // lineage arc under its own tab. Only reachable via the recovery path, where // both the id and the saved parent come from disk. this._parentSessionId = config.parentSessionId === this.id ? undefined : config.parentSessionId; if (config.attachmentHistory && config.attachmentHistory.length > 0) { this.restoreAttachmentHistory(config.attachmentHistory); } // Initialize task tracker and forward events (store handlers for cleanup) this._taskTracker = new TaskTracker(); this._taskTrackerHandlers = { taskCreated: (task) => this.emit('taskCreated', task), taskUpdated: (task) => this.emit('taskUpdated', task), taskCompleted: (task) => this.emit('taskCompleted', task), taskFailed: (task, error) => this.emit('taskFailed', task, error), }; this._taskTracker.on('taskCreated', this._taskTrackerHandlers.taskCreated); this._taskTracker.on('taskUpdated', this._taskTrackerHandlers.taskUpdated); this._taskTracker.on('taskCompleted', this._taskTrackerHandlers.taskCompleted); this._taskTracker.on('taskFailed', this._taskTrackerHandlers.taskFailed); // Initialize Ralph tracker and forward events (store handlers for cleanup) this._ralphTracker = new RalphTracker(); this._ralphHandlers = { loopUpdate: (state) => this.emit('ralphLoopUpdate', state), todoUpdate: (todos) => this.emit('ralphTodoUpdate', todos), completionDetected: (phrase) => this.emit('ralphCompletionDetected', phrase), statusBlockDetected: (block) => this.emit('ralphStatusBlockDetected', block), circuitBreakerUpdate: (status) => this.emit('ralphCircuitBreakerUpdate', status), exitGateMet: (data) => this.emit('ralphExitGateMet', data), }; this._ralphTracker.on('loopUpdate', this._ralphHandlers.loopUpdate); this._ralphTracker.on('todoUpdate', this._ralphHandlers.todoUpdate); this._ralphTracker.on('completionDetected', this._ralphHandlers.completionDetected); this._ralphTracker.on('statusBlockDetected', this._ralphHandlers.statusBlockDetected); this._ralphTracker.on('circuitBreakerUpdate', this._ralphHandlers.circuitBreakerUpdate); this._ralphTracker.on('exitGateMet', this._ralphHandlers.exitGateMet); // Initialize Bash tool parser and forward events (store handlers for cleanup) this._bashToolParser = new BashToolParser({ sessionId: this.id, workingDir: this.workingDir }); 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); // 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); }); 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)); } get status(): SessionStatus { return this._status; } get currentTaskId(): string | null { return this._currentTaskId; } get pid(): number | null { return this._pid; } get terminalBuffer(): string { return this._terminalBuffer.value; } get terminalBufferLength(): number { return this._terminalBuffer.length; } get textOutput(): string { return this._textOutput.value; } get errorBuffer(): string { return this._errorBuffer; } get lastActivityAt(): number { return this._wireActivityAt; } /** * Stamp activity NOW. The private stamp (idle detection's "how long has the * pane been quiet") always moves; the wire stamp holds its restored value * through the post-recovery attach-repaint window unless the activity is a * real action (input, task assignment, respawn), which always writes through. */ private _markActivity(realAction = false): void { this._lastActivityAt = Date.now(); if (realAction || Date.now() >= this._wireActivitySettleUntil) { this._wireActivityAt = this._lastActivityAt; this._wireActivitySettleUntil = 0; } } get claudeSessionId(): string | null { return this._claudeSessionId; } /** * True when `claudeSessionId` came from the CLI's own hook payload rather than * from the launch config or a history correlation. The response viewer uses it * to skip guessing entirely — see resolveActiveClaudeSessionIdFromHistory(). */ get claudeSessionIdIsFirstHand(): boolean { return this._claudeSessionIdIsFirstHand; } /** Conversations this pane has been on, oldest first, current last. */ get claudeSessionChain(): readonly string[] { return this._claudeSessionChain; } /** Docker execution metadata when this session runs inside a container, else undefined. */ get docker(): SessionDocker | undefined { return this._docker; } /** Remote-SSH metadata when this session runs on a remote host, else undefined. */ get remote(): SessionRemote | undefined { return this._remote; } /** * `deepSeekConfig.statusReporting` verbatim: `undefined` when the caller sent * none (i.e. ON), `false` when the user disarmed the status bridge for this * session. * * Exposed because whether a dsh session can deliver `stop`/`blocked` is a * per-SESSION fact, not a per-mode one, and `hooksAvailableForMode()` is pure * and holds no `Session` reference by design. Undefined for every other mode, * where the flag is meaningless. */ get deepSeekStatusReporting(): boolean | undefined { return this._deepSeekConfig?.statusReporting; } /** * This session's `DSH_HOME` override, if it set one. * * Deliberately ONE key rather than an `envOverrides` getter: the map can hold * provider credentials (`DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, …) and is * kept off the public `SessionState` for exactly that reason. The transcript * reader needs the profile tree's location and nothing else, so that is all * this exposes. */ get deepSeekHomeOverride(): string | undefined { const value = this._envOverrides?.DSH_HOME; return value && value.trim() ? value.trim() : undefined; } /** Owning username in multi-user mode, else undefined. */ get owner(): string | undefined { return this._owner; } /** The session that spawned this one (tab lineage decoration), else undefined. */ get parentSessionId(): string | undefined { return this._parentSessionId; } /** Set the owning username (used by recovery to restore ownership). */ set owner(username: string | undefined) { this._owner = username; } // 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. // // `firstHand` marks an id that came from the CLI process itself — a hook // payload whose delivery was keyed on this pane's $CODEMAN_SESSION_ID. Only // those extend the chain: a history-correlated guess must never be able to // write a foreign conversation into this pane's permanent record. adoptClaudeSessionId(newId: string, options: { firstHand?: boolean } = {}): void { if (!newId) return; if (options.firstHand) { this._claudeSessionIdIsFirstHand = true; this._recordClaudeSessionInChain(newId); } if (newId === this._claudeSessionId) return; this._claudeSessionId = newId; } /** * Append to the conversation chain, oldest first. A repeat of the current tail * is a no-op (every prompt in a conversation reports the same id), and an id * already in the chain moves to the tail rather than duplicating, which is * what a `/resume` back to an earlier conversation does. */ private _recordClaudeSessionInChain(id: string): void { if (this._claudeSessionChain[this._claudeSessionChain.length - 1] === id) return; const existing = this._claudeSessionChain.indexOf(id); if (existing !== -1) this._claudeSessionChain.splice(existing, 1); this._claudeSessionChain.push(id); // A pane that /clears in a loop must not grow this without bound. if (this._claudeSessionChain.length > MAX_CLAUDE_SESSION_CHAIN) { this._claudeSessionChain.splice(0, this._claudeSessionChain.length - MAX_CLAUDE_SESSION_CHAIN); } } /** The tmux session name, if the session is running inside a mux */ get muxName(): string | null { return this._muxSession?.muxName ?? null; } /** * True when a tmux pane's death would mean THIS session's agent has exited. * * Four shapes fail the test, and each would otherwise publish a death that is * not the agent's. A direct-PTY session owns no pane at all. A remote SSH * session's local pane holds the ssh client, whose death means a transport * drop OR an exit, which is the ambiguity PR #355 was about. A docker case's * local pane holds a `docker exec` into the container's own tmux. * * The fourth is a session rebuilt from the socket. Absent `remote`/`docker` * normally means "this is local", but on a discovered record it only means * "Codeman never found the metadata": the synthetic `restored-` id * matches no `state.json` entry, so a remote session rediscovered after * `mux-sessions.json` was lost arrives looking local, and its next transport * drop would be published as an agent exit. Unproven locality fails closed. */ private get paneExitApplies(): boolean { if (this._discoveredMuxSession) return false; return this._useMux && this._muxSession !== null && !this._remote && !this._docker; } /** What Codeman last observed of this pane's agent, or undefined for UNKNOWN. */ get paneExit(): PaneExit | undefined { return this._paneExit ?? undefined; } /** * Forget this pane's exit, on both this record and the mux layer's cache. * Every path that starts or relaunches a command in the pane calls it, and * the mux half also invalidates a pane read already in flight. * * It does not persist or broadcast by itself; the caller owns both. ⚠ That * caller MUST persist, and the pane-exit watcher is not a fallback for it: * the watcher's next tick reads UNKNOWN, finds this field already cleared, * reports no change and therefore writes nothing, so a caller that only * broadcasts leaves `state.json` saying the agent exited for as long as the * session stays quiet. `/interactive` and `/shell` did exactly that until * Ark0N/Codeman#446 review; both now persist on their success path. */ private clearPaneExitForNewPane(): void { this.setPaneExit(undefined); if (this._muxSession) this._mux?.clearPaneExit?.(this._muxSession.muxName); } /** * Record what the mux layer observed of this pane's agent, and say whether * that changed the answer. The caller persists and broadcasts on a true. * * A session the field does not apply to is forced to UNKNOWN here rather than * at the reporting end, so the rule lives in one place and the mux layer stays * free to report the raw pane reading its own remote-reconnect watcher needs. */ setPaneExit(next: PaneExit | undefined): boolean { const resolved = this.paneExitApplies ? (next ?? null) : null; const prev = this._paneExit; if (prev === resolved) return false; if ( prev !== null && resolved !== null && prev.status === resolved.status && prev.signal === resolved.signal && prev.at === resolved.at ) { return false; } this._paneExit = resolved; return true; } /** * True when this session's PTY is a tmux client rather than the program itself. * Read by the replay-side alt-screen strip, which must apply the same * `useMux` gate as the live strip (isMuxAltScreenOnlyStripMode). */ get usesMux(): boolean { return this._useMux; } get totalCost(): number { return this._totalCost; } get messages(): ClaudeMessage[] { return this._messages; } get isWorking(): boolean { return this._isWorking; } /** * What the pane says is still running in the background, e.g. `1 monitor`, or null when * nothing is. A session with a label here has ended its turn without wanting anything * from the user, so a surface that would otherwise file it under "needs you" can say * what it is waiting for instead. */ get watching(): string | null { return this._watching; } /** * 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). * * 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; // Single call: get direct children with their command names const output = execSync(`ps -o pid=,comm= --ppid ${panePid} 2>/dev/null`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }).trim(); if (!output) return []; const activeProcesses: { pid: number; command: string }[] = []; 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 }); } return activeProcesses; } catch { // ps returns exit code 1 when no matches — normal (no children) return []; } } get lastPromptTime(): number { return this._lastPromptTime; } get taskTracker(): TaskTracker { return this._taskTracker; } get runningTaskCount(): number { return this._taskTracker.getRunningCount(); } get taskTree(): BackgroundTask[] { return this._taskTracker.getTaskTree(); } get taskStats(): { total: number; running: number; completed: number; failed: number } { return this._taskTracker.getStats(); } // Ralph tracking getters get ralphTracker(): RalphTracker { return this._ralphTracker; } get ralphLoopState(): RalphTrackerState { return this._ralphTracker.loopState; } get ralphTodos(): RalphTodoItem[] { return this._ralphTracker.todos; } get ralphTodoStats(): { total: number; pending: number; inProgress: number; completed: number } { return this._ralphTracker.getTodoStats(); } // Bash tool tracking getters get bashToolParser(): BashToolParser { return this._bashToolParser; } get activeTools(): ActiveBashTool[] { return this._bashToolParser.activeTools; } get parentAgentId(): string | null { return this._parentAgentId; } set parentAgentId(value: string | null) { this._parentAgentId = value; } get childAgentIds(): string[] { return [...this._childAgentIds]; } addChildAgentId(agentId: string): void { if (!this._childAgentIds.includes(agentId)) { this._childAgentIds.push(agentId); } } removeChildAgentId(agentId: string): void { const idx = this._childAgentIds.indexOf(agentId); if (idx >= 0) this._childAgentIds.splice(idx, 1); } // Nice priority config getters and setters get niceConfig(): NiceConfig { return { ...this._niceConfig }; } /** Claude CLI startup permission mode */ get claudeMode(): ClaudeMode { return this._claudeMode; } /** Allowed tools list (for 'allowedTools' mode) */ get allowedTools(): string | undefined { return this._allowedTools; } /** Codex CLI configuration for this session. */ get codexConfig(): CodexConfig | undefined { return this._codexConfig; } // Note: _buildPermissionArgs removed — now using buildInteractiveArgs from session-cli-builder.ts /** * Set CPU priority configuration. * Note: This only affects new sessions; existing running processes won't be changed. */ setNice(config: Partial): void { if (config.enabled !== undefined) { this._niceConfig.enabled = config.enabled; } if (config.niceValue !== undefined) { // Clamp to valid range this._niceConfig.niceValue = Math.max(-20, Math.min(19, config.niceValue)); } } // 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; } } // Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md) — public-safe // subset only (never envKeys/configDir/launchModel, the bookkeeping for setCustomModel). get customModel(): CustomModelSelection | undefined { if (!this._customModel) return undefined; const { endpointId, modelId, label } = this._customModel; return { endpointId, modelId, label }; } /** * The full selection incl. bookkeeping, for state.json ONLY (`__customModel`, the * same disk-only convention as `getEnvOverridesForPersist()`). Carries no env values, * so nothing secret lands on disk; recovery re-derives them from the endpoint store. * Without this a Codeman restart left the pane on the custom endpoint (tmux keeps * its `setenv`s) while `customModel` came back undefined, so the state was wrong and * clearing had nothing to unset. Must NOT be included in any API-bound serializer. */ getCustomModelForPersist(): CustomModelBookkeeping | undefined { return this._customModel ? { ...this._customModel, envKeys: [...this._customModel.envKeys] } : undefined; } /** * Update this session's custom-model selection and merge the endpoint's injected env * vars into `_envOverrides` — first UNDOING whatever the previous selection injected * (removing exactly those env keys), so switching endpoints, or clearing back to the * harness's native cloud default, never leaves a stale key behind. Synchronous and * side-effect-free beyond mutating state, matching `setNice`/`setColor` above — this * class does no file IO, so it reports the PREVIOUS `configDir` (if any) for the * caller to clean up on disk (custom-model-injection.ts's configDir kind). * * Keys the previous selection injected that the new one does not re-set are queued * for `tmux setenv -u` on the next respawn (`_pendingEnvUnsets`, threaded through * `_buildRespawnPaneOptions().unsetEnvKeys`): the tmux session inherits every * `setenv` into `respawn-pane`, so dropping them from the map alone would relaunch * the CLI still pointed at the old endpoint — and for the `configDir` kinds, at a * `HOME`/`CODEX_HOME`/`GROK_HOME` the caller has just deleted. */ setCustomModel( next: CustomModelBookkeeping | undefined, envOverrides?: Record ): { removedEnvKeys: string[]; previousConfigDir: string | undefined } { const previousConfigDir = this._customModel?.configDir; const removedEnvKeys: string[] = []; if (this._customModel) { for (const key of this._customModel.envKeys) { if (this._envOverrides) delete this._envOverrides[key]; removedEnvKeys.push(key); this._pendingEnvUnsets.add(key); } } this._customModel = next ? { ...next, envKeys: [...next.envKeys] } : undefined; if (envOverrides && Object.keys(envOverrides).length > 0) { this._envOverrides = { ...(this._envOverrides ?? {}), ...envOverrides }; // A key the new selection sets again does not need an unset (applyEnvOverrides // would set it right back anyway); keep the list to what actually goes away. for (const key of Object.keys(envOverrides)) this._pendingEnvUnsets.delete(key); } return { removedEnvKeys, previousConfigDir }; } // Token tracking getters and setters get totalTokens(): number { return this._totalInputTokens + this._totalOutputTokens; } get inputTokens(): number { return this._totalInputTokens; } get outputTokens(): number { return this._totalOutputTokens; } /** * Restore token and cost values from saved state. * Called when recovering sessions after server restart. */ restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void { // Sanity check: reject absurdly large individual values if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) { console.warn( `[Session ${this.id}] Rejected absurd restored tokens: input=${inputTokens}, output=${outputTokens}` ); return; } // Check token sum doesn't overflow MAX_SESSION_TOKENS if (inputTokens + outputTokens > MAX_SESSION_TOKENS) { console.warn( `[Session ${this.id}] Rejected token sum overflow: input=${inputTokens} + output=${outputTokens} = ${inputTokens + outputTokens} > ${MAX_SESSION_TOKENS}` ); return; } // Reject negative values if (inputTokens < 0 || outputTokens < 0 || totalCost < 0) { console.warn( `[Session ${this.id}] Rejected negative restored tokens: input=${inputTokens}, output=${outputTokens}, cost=${totalCost}` ); return; } this._totalInputTokens = inputTokens; this._totalOutputTokens = outputTokens; this._totalCost = totalCost; } get autoClearThreshold(): number { return this._autoOps.autoClearThreshold; } get autoClearEnabled(): boolean { return this._autoOps.autoClearEnabled; } get name(): string { return this._name; } /** An explicit rename: the name is the user's from here on and auto-naming never touches it. */ set name(value: string) { this._name = value; this._nameSource = 'manual'; } /** * Names the tab after its first prompt. Only a placeholder is eligible, and * the session stops being one whether or not the string changed: "first * prompt" means the first, not "every prompt until a rename". Returns * whether the name changed, so the caller knows whether to persist and * broadcast. */ applyAutoName(value: string): boolean { if (this._nameSource !== 'placeholder') return false; const name = value.trim(); if (!name) return false; this._nameSource = 'auto'; if (this._name === name) return false; this._name = name; return true; } get nameSource(): SessionNameSource { return this._nameSource; } setAutoClear(enabled: boolean, threshold?: number): void { this._autoOps.setAutoClear(enabled, threshold); } get autoCompactThreshold(): number { return this._autoOps.autoCompactThreshold; } get autoCompactEnabled(): boolean { return this._autoOps.autoCompactEnabled; } get autoCompactPrompt(): string { return this._autoOps.autoCompactPrompt; } setAutoCompact(enabled: boolean, threshold?: number, prompt?: string): void { this._autoOps.setAutoCompact(enabled, threshold, prompt); } 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); } get imageWatcherEnabled(): boolean { return this._imageWatcherEnabled; } set imageWatcherEnabled(enabled: boolean) { this._imageWatcherEnabled = enabled; } /** Whether this session is pinned to the top of the session manager (COD-139). */ get pinned(): boolean { return this._pinned; } /** When the session was pinned (epoch ms), or null when unpinned. */ get pinnedAt(): number | null { return this._pinnedAt; } /** * Set pin state (COD-139). Pinning stamps pinnedAt with now so the pinned * group orders most-recently-pinned first; unpinning clears it. Idempotent: * re-pinning an already-pinned session refreshes its pinnedAt. */ setPinned(pinned: boolean): void { this._pinned = pinned; this._pinnedAt = pinned ? Date.now() : null; } /** * Restore a pin from a persisted record, keeping the moment it was pinned. * * `setPinned()` stamps `pinnedAt` with now, which is right for a user pinning a * session and wrong for a restore: the session-manager orders its pinned group * by that stamp, so a restored session would jump to the front of a list it had * been sitting further down. */ restorePin(pinned: boolean, pinnedAt?: number): void { this._pinned = pinned; this._pinnedAt = pinned ? (pinnedAt ?? Date.now()) : null; } get flickerFilterEnabled(): boolean { return this._flickerFilterEnabled; } set flickerFilterEnabled(enabled: boolean) { this._flickerFilterEnabled = enabled; } isIdle(): boolean { return this._status === 'idle'; } isBusy(): boolean { return this._status === 'busy'; } isRunning(): boolean { return this._status === 'idle' || this._status === 'busy'; } 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()) { // 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; this.upsertAttachmentHistory(item); } } getAttachmentHistoryForPersist(): SessionAttachmentHistoryItem[] | undefined { return this._attachmentHistory.length > 0 ? this._attachmentHistory.map((item) => ({ ...item })) : undefined; } toState(): SessionState { return { id: this.id, pid: this.pid, status: this._status, workingDir: this.workingDir, remote: this._remote, docker: this._docker, owner: this._owner, parentSessionId: this._parentSessionId, currentTaskId: this._currentTaskId, createdAt: this.createdAt, // The wire twin, not the private stamp: it survives the post-recovery // attach repaint, so the home screens' quiet ordering survives a restart. lastActivityAt: this._wireActivityAt, name: this._name, nameSource: this._nameSource, mode: this.mode, autoClearEnabled: this._autoOps.autoClearEnabled, autoClearThreshold: this._autoOps.autoClearThreshold, autoCompactEnabled: this._autoOps.autoCompactEnabled, autoCompactThreshold: this._autoOps.autoCompactThreshold, autoCompactPrompt: this._autoOps.autoCompactPrompt, autoResumeEnabled: this._autoOps.autoResumeEnabled, autoResumeAt: this._autoOps.autoResumeAt ?? undefined, imageWatcherEnabled: this._imageWatcherEnabled, pinned: this._pinned || undefined, pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined, totalCost: this._totalCost, inputTokens: this._totalInputTokens, outputTokens: this._totalOutputTokens, ralphEnabled: this._ralphTracker.enabled, ralphAutoEnableDisabled: this._ralphTracker.autoEnableDisabled || undefined, ralphCompletionPhrase: this._ralphTracker.loopState.completionPhrase || undefined, parentAgentId: this._parentAgentId || undefined, childAgentIds: this._childAgentIds.length > 0 ? this._childAgentIds : undefined, niceEnabled: this._niceConfig.enabled, niceValue: this._niceConfig.niceValue, color: this._color, flickerFilterEnabled: this._flickerFilterEnabled, cliMouseTracking: this._cliMouseTracking || undefined, cliVersion: this._cliVersion || undefined, cliModel: this._cliModel || undefined, cliAccountType: this._cliAccountType || undefined, cliLatestVersion: this._cliLatestVersion || undefined, openCodeConfig: this._openCodeConfig, codexConfig: this._codexConfig, geminiConfig: this._geminiConfig, antigravityConfig: this._antigravityConfig, piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, effort: this._effort, customModel: this.customModel, // 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, // Ark0N/Codeman#446 — the agent in this pane has exited, published here so // it rides the existing `session:updated` broadcast and lands in state.json // through the same persist. `status` and `pid` above stay untouched by it. paneExit: this._paneExit ?? undefined, attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined, lastSubmitAt: this._lastSubmitAt || undefined, // Only a chain the CLI's own hooks vouched for is persisted, and only when // the pane actually moved conversation. Its LAST entry is the live one, so // it is also what restores `claudeSessionId` across a restart — `start()` // resets that field to the launch id at three separate points, which is // why a recovered pane otherwise shows its pre-/clear transcript forever. claudeSessionChain: this._claudeSessionChain.length > 0 ? [...this._claudeSessionChain] : undefined, // 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. }; } /** * Returns a subset of env overrides safe for disk persistence (state.json). * Only non-sensitive `CLAUDE_CODE_*` keys plus CLAUDE_CONFIG_DIR (a path, not * a secret — and losing it across a restart would silently move a session back * to the default Claude account, #255) 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 | undefined { if (!this._envOverrides) return undefined; const safe: Record = {}; for (const [key, value] of Object.entries(this._envOverrides)) { if (key.startsWith('CLAUDE_CODE_') || key === 'CLAUDE_CONFIG_DIR') safe[key] = value; } return Object.keys(safe).length > 0 ? safe : undefined; } toDetailedState() { 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() { return { ...this.toState(), name: this._name, mode: this.mode, claudeSessionId: this._claudeSessionId, totalCost: this._totalCost, messageCount: this._messages.length, isWorking: this._isWorking, watching: this._watching, lastPromptTime: this._lastPromptTime, // Buffer statistics for monitoring long-running sessions bufferStats: { terminalBufferSize: this._terminalBuffer.length, textOutputSize: this._textOutput.length, messageCount: this._messages.length, maxTerminalBuffer: MAX_TERMINAL_BUFFER_SIZE, maxTextOutput: MAX_TEXT_OUTPUT_SIZE, maxMessages: MAX_MESSAGES, }, // Background task tracking (light tree strips large output strings) taskStats: this._taskTracker.getStats(), taskTree: this._taskTracker.getTaskTreeLight(), // Token tracking tokens: { input: this._totalInputTokens, output: this._totalOutputTokens, total: this._totalInputTokens + this._totalOutputTokens, }, autoClear: { enabled: this._autoOps.autoClearEnabled, threshold: this._autoOps.autoClearThreshold, }, // CPU priority configuration nice: { enabled: this._niceConfig.enabled, niceValue: this._niceConfig.niceValue, }, // Ralph tracking state ralphLoop: this._ralphTracker.loopState, ralphTodos: this._ralphTracker.todos, ralphTodoStats: this._ralphTracker.getTodoStats(), }; } /** * Starts an interactive Claude CLI session with full terminal support. * * This spawns Claude CLI 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. * * @throws {Error} If a process is already running in this session * * @example * ```typescript * const session = new Session({ workingDir: '/project', useMux: true }); * await session.startInteractive(); * session.on('terminal', (data) => process.stdout.write(data)); * session.write('help me with this code\r'); * ``` */ 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); // Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`). // `options.respawnPaneOptions` was built eagerly before this dead-pane // check ran, so it still carries the pre-pin ompConfig; rebuild it. this._pinOmpRespawnId(); const newPid = await mux.respawnPane(await this._buildRespawnPaneOptionsWithResumePin()); if (!newPid) { console.error('[Session] Failed to respawn pane, will create new session'); needsNewSession = true; } else { this._pendingEnvUnsets.clear(); // Wait a moment for the respawned process to fully start await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS)); } } // Whatever the last reading said about the OLD command in this pane is now // history: the branch above either respawned the pane or found it alive, and // the branch below creates a new one. The paths that reach here are boot // recovery and an explicit start, NOT a click on an exited tab — the browser // re-attaches only on a null pid, and the premise of Ark0N/Codeman#446 is // that an exited pane keeps its pid. `restartCli()` clears separately. this.clearPaneExitForNewPane(); // 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. When this is the FALLBACK after a failed // respawn, the eagerly-built create options still carry the unpinned // launch seed, so a session whose transcript exists would meet the same // `--session-id ... already in use` refusal the respawn just lost to — // the recovery of last resort failing for the very reason it was needed. // A genuinely new session has no transcript under any of its candidate // ids, so nothing is pinned and its command shape is unchanged. // // `_resumeSessionId` is written alongside, not just the create options: // this branch leaves `isRestored` false, so the block that sets // `_claudeSessionId` below reads that field and would otherwise settle on // `this.id` while the CLI resumes the chain tail. The response viewer, // Read My Mind and the unified-list alias map all read `_claudeSessionId` // until the next first-hand hook, so the two have to name the same // conversation. if (needsNewSession) { const pinned = (await this._buildRespawnPaneOptionsWithResumePin()).resumeSessionId; if (pinned) { options.createSessionOptions.resumeSessionId = pinned; this._resumeSessionId = pinned; } } 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 } // Integration tests need a live input/output transport without attaching to // the host's tmux server or agent CLI. Production still uses the real mux. if (!IS_TEST_MODE) { // 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. const { cols: ptyCols, rows: ptyRows } = IS_TEST_MODE ? { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS } : queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket); const attachCommand = IS_TEST_MODE ? process.execPath : mux.getAttachCommand(); const attachArgs = IS_TEST_MODE ? ['-e', TEST_PTY_SCRIPT] : mux.getAttachArgs(this._muxSession!.muxName); try { this.ptyProcess = spawnPtyWithHelperRepair(() => pty.spawn(attachCommand, attachArgs, { name: 'xterm-256color', cols: ptyCols, rows: ptyRows, cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker), // COD-75: a CLI that declares `export COLORTERM=truecolor` gets it on the ATTACH // client too. Both sides read the same registry entry, which is what stops the // attach client and the tmux session from disagreeing — they used to be two // hand-maintained lists of mode names that had to be edited in lockstep. env: buildMuxAttachEnv(cliExportsTruecolor(this.mode)), }) ); } 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 }; } /** * COD-108 — re-establish a dropped REMOTE session. Triggered by the * `TmuxManager` remote-reconnect watcher (via `remoteSessionDropped`): the * watcher detects a dead remote pane, the session owner reassembles the SAME * `RespawnPaneOptions` used for Claude-idle respawns and calls * `respawnPane()` directly. For a remote session that re-runs * `buildRemoteSessionCommand` (owned → `new-session -A`, non-owned → * `attach`), which idempotently REATTACHES the still-running durable remote * tmux session — scrollback + agent intact (proven COD-104/105). * * Deliberately does NOT route through the Claude-idle respawn-controller — * this is a transport re-establish, not a `/clear`/`/compact` cycle. * * @returns true if the pane was respawned (reattach issued), false otherwise. */ async reattachRemote(): Promise { if (!this._remote) return false; // not a remote session if (!this._useMux || !this._mux || !this._muxSession) return false; const mux = this._mux; // If tmux lost the whole session (not just a dead pane), there is nothing to // respawn into — a genuine death, leave it for normal recovery/reconcile. if (!mux.muxSessionExists(this._muxSession.muxName)) { console.log('[Session] reattachRemote: mux session gone, skipping:', this._muxSession.muxName); return false; } // Confirmed the mux session (and thus the pane) exists but this reattach // is about to respawn it — safe to resolve/pin now. this._pinOmpRespawnId(); const newPid = await mux.respawnPane(this._buildRespawnPaneOptions()); if (!newPid) { console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName); return false; } // No-op for the record (a remote session's field is always UNKNOWN), but the // mux layer's cache is keyed by muxName and this pane now runs a new client. this.clearPaneExitForNewPane(); console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid); return true; } /** * Kill and relaunch this session's CLI process IN PLACE — same pane, same tmux * session, fresh env/args from current state. Custom Model Endpoint Profiles * (docs/custom-model-endpoints-plan.md) is the first caller: after `setCustomModel()` merges new * env vars into `_envOverrides`, the running CLI process still has the OLD env * (inherited at its own process start, not live-reloaded), so switching a * session's model/endpoint requires this restart to actually take effect. * * A GENERALIZED {@link reattachRemote} with the `!this._remote` guard dropped — * `_buildRespawnPaneOptions()` already passes `remote: this._remote` through * unconditionally, so `mux.respawnPane()` builds the right command either way * (a local session gets `respawn-pane -k` + the real launch line, which is the * kill-and-relaunch this method exists for; a remote session gets the existing * reattach-to-durable-tmux behavior). Deliberately does NOT check `isBusy()` — * that's the caller's job (mirrors `/interactive`'s guard), since a raw restart * primitive shouldn't itself decide when it's safe to use. * * @returns true if the pane was respawned, false otherwise (no mux session, or * the mux session is gone — see {@link reattachRemote} for that reasoning). */ async restartCli(): Promise { if (!this._useMux || !this._mux || !this._muxSession) return false; const mux = this._mux; if (!mux.muxSessionExists(this._muxSession.muxName)) { console.log('[Session] restartCli: mux session gone, skipping:', this._muxSession.muxName); return false; } this._pinOmpRespawnId(); const newPid = await mux.respawnPane(await this._buildRespawnPaneOptionsWithResumePin()); if (!newPid) { console.error('[Session] restartCli: respawnPane failed for', this._muxSession.muxName); return false; } this._pendingEnvUnsets.clear(); // A relaunch in the same pane, so any exit observed of the previous command // is history. Without this the caller's persist-and-broadcast writes the old // exit straight back onto a session that is running again. this.clearPaneExitForNewPane(); console.log('[Session] restartCli: restarted CLI for', this._muxSession.muxName, 'pid', newPid); return true; } /** * Assemble the {@link RespawnPaneOptions} for this session. Single source of * truth shared by interactive start, shell start (via their inline copies), * {@link reattachRemote}, and {@link restartCli} so no respawn path can drift from * the spawn path. */ private _buildRespawnPaneOptions(): import('./mux-interface.js').RespawnPaneOptions { const options: import('./mux-interface.js').RespawnPaneOptions = { sessionId: this.id, workingDir: this.workingDir, mode: this.mode, name: this._name, niceConfig: this._niceConfig, model: this._model, claudeMode: this._claudeMode, allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig, codexConfig: this._codexConfig, geminiConfig: this._geminiConfig, antigravityConfig: this._antigravityConfig, piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, // OMP resolution/pinning does NOT happen here. This object is built // EAGERLY — including on every boot-recovery reattach, before anyone // knows whether the pane is actually dead — so resolving here mutated // `_ompConfig`/`_claudeSessionId` even for a pane that was simply being // reattached to, not respawned; with two omp tabs in the same case dir // that mis-pinned the ALIVE session onto whichever file happened to be // newest on disk (reported live in the Ark0N/Codeman#353 review). The // real pin now happens in `_pinOmpRespawnId()`, called by callers ONLY // once they've confirmed an actual respawn is about to happen. ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined, effort: this._effort, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, owner: this._owner, }; return this._withCustomModelLaunchModel(options); } /** * Respawn options for a pane whose command is being REPLACED, with the * conversation pinned so the relaunch resumes rather than collides. * * A CLI that launches with `--session-id ` refuses an id that is already * in use (claude: `Error: Session ID ... is already in use.`), and every * session whose agent has been prompted owns a transcript under that id. So * relaunching such a pane with the bare launch line fails, the pane dies * again immediately, and the user's conversation is stranded. A launch that * declares a `fallback` chain renders `resume || new` once a resume id is * set, which is the shape that survives both cases. * * Three candidates are tried in priority order — the conversation chain's * tail, the launch seed, then the session's own id — and the first one a * transcript backs is pinned. Four conditions gate that walk, each protecting * against a way of resuming the WRONG conversation or of making a working * relaunch fail. * * ⚠️ **A remote or docker session is never pinned.** Unlike `restartCli()`, * whose route refuses both, the dead-pane respawn is reached by every session * shape. Their pane commands (`buildRemoteLaunchCommand`, * `claudeDockerPaneCommand`) already render a SELF-HEALING * `--session-id || --resume `, and both flip to resume-first the * moment the resume id differs from the session id. The conversation lives on * the far side, so a local id pinned onto it resolves to nothing there, the * resume fails, and the `--session-id` fallback then collides with the * transcript the far side really does hold — both branches fail and the pane * dies. `_pinOmpRespawnId()` refuses remote for the same reason. * * ⚠️ **The candidates come from the conversation CHAIN, never from * `_claudeSessionId`.** That field holds either a first-hand id from the * CLI's own hook payload or a history correlation, which is a guess keyed on * the working directory. `_recordClaudeSessionInChain()` refuses a guess * precisely so it cannot "write a foreign conversation into this pane's * permanent record", and launching from one would do worse than the display * bug that rule exists to prevent: the relaunched CLI would open and WRITE to * a conversation that was never this pane's. The chain's tail is the live * conversation and is hook-vouched, so it leads the walk, ahead of the launch * seed, which is written once at construction and never moves off a `/clear`. * * ⚠️ **Every candidate must be backed by a transcript, the session's own id * included, and a candidate that has none is passed over rather than ending * the walk.** A pin that differs from the session id leaves * `--session-id ` in the fallback branch, so a resume that finds * nothing collides there and the pane dies exactly as it did before this * pinning existed. Pinning `this.id` renders the self-healing * `--resume || --session-id `, which is correct whether or not a * transcript exists, but a pane that has none pays for the shape twice: * claude prints "No conversation found" into the scrollback of a session that * is brand new, and `wrapWithNice()` prefixes only the FIRST branch of the * rendered `a || b`, so the branch that actually runs loses its priority for * the life of the session. Falling off the end of the walk therefore pins * nothing, which is the right answer: with no transcript anywhere there is * nothing for the bare `--session-id ` to collide with. * * The create route pre-validates a resume id for the same reason, though it * additionally requires the transcript be substantial — here mere existence * is the question, because a one-line transcript still makes `--session-id` * collide. * * The registry shape is the last gate, not the CLI's name: an entry whose * resume id is minted by the CLI itself (codex/pi/omp/grok) declares no * `fallback` chain and reads its resume field from its own `Config`. * * `reattachRemote()` deliberately does NOT call this. It re-runs the remote * session command, which attaches to the durable remote tmux with the agent * still inside it and renders no local `--session-id` to collide. */ private async _buildRespawnPaneOptionsWithResumePin(): Promise { const options = this._buildRespawnPaneOptions(); if (this._remote || this._docker) return options; const entry = getCli(this.mode); if (entry?.launch.chain !== 'fallback') return options; const resumeIdPattern = entry.launch.params?.resumeId; const configDir = this._claudeConfigDir(); const chainTail = this._claudeSessionChain[this._claudeSessionChain.length - 1]; const candidates = [chainTail, options.resumeSessionId, this.id].filter((v): v is string => !!v); for (const candidate of candidates) { // A session Codeman DISCOVERED on the socket rather than created carries a // synthetic `restored-` id, which fails claude's `uuid` token // pattern. The renderer would silently drop the resume flag and emit the // unpinned command, so say so here rather than letting the caller believe // the pane was pinned. if (resumeIdPattern?.type === 'token' && !matchesPattern(resumeIdPattern.pattern, candidate)) { console.log(`[Session] Not pinning resume id ${candidate} for relaunch: the CLI cannot accept that id shape`); continue; } if (!(await claudeTranscriptExists(candidate, configDir))) continue; options.resumeSessionId = candidate; return options; } // Nothing on disk to collide with, so the bare `--session-id ` the // unpinned options already carry is the correct command. return options; } /** The session's Claude config dir when it has been relocated (#255), else undefined. */ private _claudeConfigDir(): string | undefined { return this._envOverrides?.CLAUDE_CONFIG_DIR; } /** * Force the custom-model selection's `launchModel` (pi/omp `custom/`, grok's * `[model.]` block name) onto the CLI's `model` launch param. Where that param * lives is registry DATA — the entry's `legacyConfigField` (`piConfig`, `grokConfig`, * ...) or the top-level `model` for an entry that declares none — so this stays a * generic reader rather than a branch per CLI. Applied on the OPTIONS only: the stored * `Config` keeps whatever model the user chose at create, which is exactly what a * later clear must fall back to. */ private _withCustomModelLaunchModel( options: import('./mux-interface.js').RespawnPaneOptions ): import('./mux-interface.js').RespawnPaneOptions { const launchModel = this._customModel?.launchModel; if (!launchModel) return options; const entry = getCli(this.mode); if (!entry) return options; const field = entry.launch.legacyConfigField; if (!field) return { ...options, model: launchModel }; const bag = options as unknown as Record; const existing = (bag[field] ?? {}) as Record; return { ...options, [field]: { ...existing, model: launchModel } }; } /** * OMP-only: resolve and PIN the exact conversation to continue when * respawning a dead pane, so every later respawn reuses the same id * instead of re-resolving (and re-risking picking up a DIFFERENT * conversation that happened to touch this directory more recently). See * the comment at the call site in {@link _buildRespawnPaneOptions} for why * "newest file on disk" is safe here specifically. Non-omp modes and a * session that already carries an explicit id pass through untouched. */ private _pinOmpRespawnId(): void { // The omp-jsonl transcript reader is what this pin exists to feed, so ask for the // reader rather than for the CLI's name. if (getCli(this.mode)?.capabilities.transcript !== 'omp-jsonl') return; if (this._ompConfig?.resumeSessionId) return; // Callers MUST call this only immediately before an ACTUAL respawn (a // confirmed-dead pane, or a genuine remote reattach) — never while merely // building options that might not lead to a respawn. A fresh "Run OMP" // click has no _muxSession yet and must never inherit whatever omp // conversation happens to be newest on disk for this working directory // (reported live 2026-08-27, fixed in 13a19f79); this guard keeps that // fix intact now that resolution has moved out of the eager options build. if (!this._muxSession) return; // `resolveAndClaimOmpSessionId` scans THIS HOST's `~/.omp/agent/sessions/`, which is // meaningless for a remote session — the conversation and its session file live on the // remote host, under the REMOTE user's home. Worse than a no-op: `this.workingDir` for a // remote session is the remote path (e.g. `/home/user/dotfiles`), so if the local machine // happens to have its own omp history under a directory that mangles to the same name, // this would silently claim and pin a COMPLETELY UNRELATED local session's id onto a // remote respawn. Skip straight to the CLI's own `--continue` fallback, which the remote // pane command already renders (see buildRemoteLaunchCommand's omp branch) — safe there // because each remote respawn talks to exactly one remote pane's own omp history. if (this._remote) { this._ompConfig = { ...this._ompConfig, continueSession: true }; return; } const resolvedId = resolveAndClaimOmpSessionId(this.workingDir); if (resolvedId) { this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId }; // Alias omp's own session uuid to this Codeman id — see the // constructor's claudeSessionId comment for why this field is the // (generically-named) mechanism that folds a Past-Sessions row back // into its live/persisted session instead of duplicating it. this._claudeSessionId = resolvedId; return; } // Nothing unclaimed on disk (the dying process never got far enough to // write a session file, or a sibling already claimed the only candidate) // — fall back to the CLI's own "most recent" heuristic. console.warn( `[Session] OMP: no session file found under ${this.workingDir} to pin --resume on respawn; falling back to ambiguous --continue` ); this._ompConfig = { ...this._ompConfig, continueSession: true }; } /** * Remember whether the CLI currently wants to be told about mouse clicks. * * The strip in {@link _handleTerminalOutput} is the ONLY place these sequences * exist. After it, neither the browser nor xterm can ever learn that the CLI * asked for mouse tracking, so `terminal.modes.mouseTrackingMode` is * permanently 'none' for a stripped mode. The browser hand-encodes SGR reports * to compensate (`_sendSyntheticSgrTap` in terminal-ui.js), and with no state * to consult it had to do that on EVERY click, delivering mouse reports to a * CLI that never asked for them. Publishing this through `toState()` is what * lets the browser report a click only when the CLI is listening. * * Only the TRACKING modes count. 1005/1006 select an encoding and 1007 is * alt-scroll; a CLI that picks SGR encoding without turning a tracking mode on * is not asking about clicks, and counting those would put the stray reports * straight back. * * This must stay in lockstep with the strip regex that calls it: a sequence * removed from the stream but not recorded here is one the browser can neither * see nor be told about. */ private _recordStrippedMouseMode(seq: string): void { // eslint-disable-next-line no-control-regex const match = /\x1b\[\?(\d+)([hl])$/.exec(seq); if (!match) return; const mode = Number(match[1]); if (mode !== 1000 && mode !== 1001 && mode !== 1002 && mode !== 1003) return; if (match[2] === 'h') this._cliMouseModes.add(mode); else this._cliMouseModes.delete(mode); this._syncCliMouseTracking(); } /** Emit only on a real transition: a TUI re-emitting its enable on every repaint costs nothing. */ private _syncCliMouseTracking(): void { const active = this._cliMouseModes.size > 0; if (active === this._cliMouseTracking) return; this._cliMouseTracking = active; this.emit('mouseTrackingChanged', active); } private _handleTerminalOutput(data: string): void { // 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: // - \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, // xterm.js forwards wheel events to the CLI instead of scrolling the // 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 // 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/Gemini (isAltScreenStripMode). // // Every OTHER mode (shell/opencode/antigravity) gets the NARROW strip when it // is tmux-backed: alt-screen toggles only, because the sequence that breaks // scrollback there is tmux's own client-side smcup at attach, not anything the // program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode). // 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working. const fullStrip = isAltScreenStripMode(this.mode); const altOnlyStrip = !fullStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux); if (fullStrip || altOnlyStrip) { // 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. data = this._altScreenSeqCarry + data; this._altScreenSeqCarry = ''; // eslint-disable-next-line no-control-regex const splitTail = data.match(/\x1b(?:\[\??[0-9]{0,4})?$/); if (splitTail) { this._altScreenSeqCarry = splitTail[0]; data = data.slice(0, -splitTail[0].length); if (!data) return; } // eslint-disable-next-line no-control-regex data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, ''); if (fullStrip) { data = data // 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, (seq) => { this._recordStrippedMouseMode(seq); return ''; }); } } // Scan terminal output for attachment requests. `codeman://attach?...` is an // 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. // Codex is the only CLI that announces generated artifacts in its pane output, and it // is also the only one whose transcript is a rollout file — one implies the other. const attachmentRequests = parseTerminalAttachmentRequests(data, { codexArtifacts: getCli(this.mode)?.capabilities.transcript === 'codex-rollout', }); for (const request of attachmentRequests) { const seenKey = `${request.source}:${request.path}`; if (this._attachmentMagicSeen.has(seenKey)) continue; this._attachmentMagicSeen.add(seenKey); if (this._attachmentMagicSeen.size > 200) { const oldest = this._attachmentMagicSeen.values().next().value; if (oldest) this._attachmentMagicSeen.delete(oldest); } this.emit('attachmentRequested', { sessionId: this.id, path: request.path, source: request.source, timestamp: Date.now(), }); } // BufferAccumulator handles auto-trimming when max size exceeded this._terminalBuffer.append(data); this._markActivity(); this.emit('terminal', data); this.emit('output', data); } async startInteractive(): Promise { if (this.ptyProcess) { throw new Error('Session already has a running process'); } // Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here // rather than at PTY spawn so a slow mux attach still counts as startup. this._interactiveStartedAt = Date.now(); this._trustDialogAttempts = 0; this._lastTrustDialogScanAt = 0; if (this._trustDialogTimer) { clearTimeout(this._trustDialogTimer); this._trustDialogTimer = null; } // 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.' ); } this._resetBuffers(); const modeLabel = getModeLabel(this.mode); console.log( `[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '') ); // 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; they get // their own over-ssh probe below. Cached process-wide, best-effort. if (cliNeedsVersionProbe(this.mode) && !this._remote && !this._docker && !this._cliVersion) { const probedVersion = resolveSessionCliVersion(this.mode); if (probedVersion) { this._cliVersion = probedVersion; this.emit('cliInfoUpdated', { version: this._cliVersion, model: this._cliModel, accountType: this._cliAccountType, latestVersion: this._cliLatestVersion, }); } } // Docker sessions run claude INSIDE the container, so the local probe above // reports the HOST claude (wrong version, and leaving cliVersion undefined // silently disables wheel-forwarding, #154). Probe the IN-CONTAINER version // instead — deferred so the container is up after the mux attach below. if (cliNeedsVersionProbe(this.mode) && this._docker && !this._cliVersion) { const dockerMeta = this._docker; setTimeout(() => { if (this._isStopped || this._cliVersion) return; void probeDockerCliVersion(dockerMeta, this.mode) .then((version) => { if (!version || this._isStopped || this._cliVersion) return; this._cliVersion = version; this.emit('cliInfoUpdated', { version: this._cliVersion, model: this._cliModel, accountType: this._cliAccountType, latestVersion: this._cliLatestVersion, }); }) .catch(() => { /* best-effort */ }); }, DOCKER_CLI_VERSION_PROBE_DELAY_MS); } // Remote sessions run claude on ANOTHER HOST, so neither the local nor the // docker probe applies, and the banner-scrape fallback they were left with // is the unreliable path #154 was filed for, so remote Claude cases silently // never got wheel-forwarding (noted in the #205 analysis). Probe over ssh, // deferred so session start never waits on the ssh round-trip. if (cliNeedsVersionProbe(this.mode) && this._remote && !this._cliVersion) { const remoteMeta = this._remote; setTimeout(() => { if (this._isStopped || this._cliVersion) return; void probeRemoteCliVersion(remoteMeta, this.mode) .then((version) => { if (!version || this._isStopped || this._cliVersion) return; this._cliVersion = version; this.emit('cliInfoUpdated', { version: this._cliVersion, model: this._cliModel, accountType: this._cliAccountType, latestVersion: this._cliLatestVersion, }); }) .catch(() => { /* best-effort */ }); }, REMOTE_CLI_VERSION_PROBE_DELAY_MS); } // ⚠️ Hoisted, because the "third reset point" below runs unconditionally // AFTER the mux branch and would otherwise stomp the restored conversation // straight back to the launch id. let restoredConversation: string | undefined; // If mux wrapping is enabled, create or attach to a mux session if (this._useMux && this._mux) { try { const { isRestored } = await this._setupOrAttachMuxSession({ // Single source of truth shared with reattachRemote() (COD-108). respawnPaneOptions: this._buildRespawnPaneOptions(), createSessionOptions: { sessionId: this.id, workingDir: this.workingDir, mode: this.mode, name: this._name, niceConfig: this._niceConfig, model: this._model, claudeMode: this._claudeMode, allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig, codexConfig: this._codexConfig, geminiConfig: this._geminiConfig, antigravityConfig: this._antigravityConfig, piConfig: this._piConfig, grokConfig: this._grokConfig, deepSeekConfig: this._deepSeekConfig, ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, owner: this._owner, }, spawnErrLabel: 'mux attachment', }); // Set claudeSessionId — when resuming, the Claude conversation ID is the // resumed one. `_pinOmpRespawnId()` (called just above, inside // `_setupOrAttachMuxSession()`'s dead-pane branch) may have JUST aliased // this to omp's own session uuid — that already-resolved id must win // over the generic `this.id` fallback, or this line clobbers it back // to the Codeman id // on every single respawn. codex needs the same fallback for the same // reason: its thread id lives in `_codexConfig`, so without it every // respawn drops a resumed codex session's alias and its Past-Sessions // row springs back as a duplicate that still resumes. // ⚠️ A RESTORED mux session is the one case where the launch id is a // lie: the CLI never stopped, so a `/clear` before the Codeman restart // already moved it to a conversation `this.id` knows nothing about. The // persisted chain's tail is that conversation, reported first-hand by // the CLI's own hook, so it outranks every fallback here. A NEW pane has // an empty chain and falls through to the resume/alias fallbacks. restoredConversation = isRestored ? this._claudeSessionChain[this._claudeSessionChain.length - 1] : undefined; this._claudeSessionId = restoredConversation || this._resumeSessionId || this._ompConfig?.resumeSessionId || this._codexConfig?.resumeSessionId || this.id; // For NEW mux sessions: wait for readiness then clean buffer // For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch if (!isRestored) { if (isExternalCliMode(this.mode)) { // External CLIs use custom TUIs — no ❯ prompt to detect. // 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. // Emit needsRefresh so the client fetches the full buffer once the TUI has rendered. this._promptCheckTimeout = setTimeout(() => { this._promptCheckTimeout = null; if (this._isStopped) return; this._status = 'idle'; this.emit('needsRefresh'); }, 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 this._terminalBuffer.set(bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '')); // Signal client to refresh this.emit('clearTerminal'); } }, 50); // Timeout after 5 seconds if prompt not found this._promptCheckTimeout = setTimeout(() => { if (this._promptCheckInterval) { clearInterval(this._promptCheckInterval); this._promptCheckInterval = null; } this._promptCheckTimeout = null; }, 5000); } } } catch (err) { console.error('[Session] Failed to create mux session, falling back to direct PTY:', err); this._useMux = false; this._muxSession = null; } } // Fallback to direct PTY if mux is not used if (!this.ptyProcess) { // Every external CLI requires tmux and has NO direct-PTY fallback, because its // secrets are injected with socket-scoped `tmux setenv` and so must never touch a // spawn command line. DeepSeek additionally needs it for the HERDR_* status-bridge // triple, without which the mode silently loses its definitive idle/blocked signals. // // Refusing is the only safe answer: falling back to a direct PTY would start the CLI // unauthenticated (or, worse, tempt a future change into passing the key as an // argument, where every process on the box can read it). if (getCli(this.mode)?.capabilities.requiresMux) { throw new Error(`${getModeLabel(this.mode)} sessions require tmux. Direct PTY fallback is not supported.`); } try { // Pass --session-id to use the SAME ID as the Codeman session // This ensures subagents can be directly matched to the correct tab. // No plan-usage statusLine exporter on this path: the ephemeral // `--settings` injection (resolveStatusLineCliCommand, hooks-config.ts) // is wired into the tmux spawn builders only, so a direct-PTY session // has no Claude telemetry in the header chip. Documented in // architecture-invariants (Plan-usage chip); tmux is the supported path. const args = buildInteractiveArgs( this.id, this._claudeMode, this._model, this._allowedTools, this._effort, this._name, getClaudeCliVersion() ); this.ptyProcess = spawnPtyWithHelperRepair(() => pty.spawn(getClaudeBinaryPath(), args, { name: 'xterm-256color', cols: 120, rows: 40, cwd: this.workingDir, // Merge envOverrides after buildClaudeEnv so user settings shadow defaults. env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) }, }) ); } 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}`); } } // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one. // Mirrors the mux branch above and must not clobber it: this line runs // unconditionally after both the mux and direct-PTY paths, so it also needs // the ompConfig and codexConfig fallbacks or it stomps the mux branch's // correctly-resolved OMP/codex alias back to this.id on every mux/plain- // reattach boot recovery (the "third reset point" — see DECISIONS.md). // For the same reason it needs `restoredConversation`: on a RESTORED mux // attach the CLI never stopped and may have `/clear`ed before the restart, // so the launch id is a lie and the chain's tail is the live conversation. // It is empty on every other path, so those paths keep the alias chain. this._claudeSessionId = restoredConversation || this._resumeSessionId || this._ompConfig?.resumeSessionId || this._codexConfig?.resumeSessionId || this.id; this._pid = this.ptyProcess.pid; console.log('[Session] Interactive PTY spawned with PID:', this._pid); this.ptyProcess.onData((rawData: string) => { // Filter out focus escape sequences and Ctrl+L (form feed) const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L if (!data) return; // Skip if only filtered sequences this._handleTerminalOutput(data); // === Auto-accept workspace trust dialog === this._maybeAcceptTrustDialog(); // === Idle/working detection runs on every chunk (latency-sensitive) === this._detectInteractiveActivity(data); // === 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); } } }); this.ptyProcess.onExit(({ exitCode }) => { console.log('[Session] Interactive PTY exited with code:', exitCode); // 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()); this.ptyProcess = null; this._pid = null; this._status = 'idle'; this._awaitingIdleConfirmation = false; this._activityStreak = null; // 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; } // Clear expensive processing timer and flush any pending data if (this._expensiveProcessTimer) { clearTimeout(this._expensiveProcessTimer); this._expensiveProcessTimer = null; } this._pendingCleanData = ''; // If using mux, mark the session as detached but don't kill it if (this._muxSession && this._mux) { this._mux.setAttached(this.id, false); } // 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 }); } this.emit('exit', exitCode); }); } /** * 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; } /** * Answer Claude's workspace-trust dialog, which blocks a fresh case until * someone presses Enter. Codeman sessions run permission-skipping or * classifier-guarded modes, so the answer is always "yes, I trust this folder". * * Reads the RENDERED SCREEN rather than the chunk that just arrived. tmux * repaints a row with cursor-forward escapes in place of spaces, so the wire * carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder` and the old * `data.includes('trust this folder')` could never match: the auto-accept had * been dead for every session that hit the dialog. The screen is also what * makes a retry safe, since the terminal buffer is append-only and keeps the * dialog in its tail long after it has been answered. * * ⚠️ **The keystroke is read off the screen, never assumed.** Claude Code * 2.1.252 dropped the option numbers, put "No, exit" first, and highlights IT * by default, so the bare `\r` this used to send now answers *exit*: a fresh * case died (`Pane is dead (status 1)`) about six seconds after spawning. * `trustDialogNextKey()` returns one step at a time — an arrow while the * cursor is on the wrong option, Enter only once the screen shows it on the * trust option — and this method re-reads the pane between the two, so a * dropped arrow costs a repaint instead of the session. * * Three guards keep an Enter press off a live session: a startup-only window, * a two-marker match (isTrustDialogScreen), and an attempt cap. */ private _maybeAcceptTrustDialog(): void { if (this._trustDialogAccepted) return; const now = Date.now(); if (now - this._interactiveStartedAt > TRUST_DIALOG_WINDOW_MS) { this._trustDialogAccepted = true; // window closed; anything matching now is not the dialog return; } if (now - this._lastTrustDialogScanAt < TRUST_DIALOG_RETRY_MS) return; this._lastTrustDialogScanAt = now; // Prefer the pane; fall back to the buffer tail on a direct-PTY session, // where there is no screen to read. const screen = (this._mux && this._muxSession ? this._mux.capturePaneText?.(this._muxSession.muxName) : null) ?? this._terminalBuffer.value.slice(-TRUST_DIALOG_SCAN_BYTES); if (!isTrustDialogScreen(screen)) return; // Null means the frame does not say which option is highlighted. Waiting for // the next repaint is the safe move; pressing Enter blind is the bug. const key = trustDialogNextKey(screen); if (key === null) return; this._trustDialogAttempts++; if (this._trustDialogAttempts > TRUST_DIALOG_MAX_ATTEMPTS) { this._trustDialogAccepted = true; // leave it to the user rather than keep typing console.warn(`[Session] Workspace trust dialog did not clear after retries: ${this.id}`); return; } const step = key === TRUST_KEY_CONFIRM ? 'confirming' : 'moving to the trust option'; console.log( `[Session] Auto-accepting workspace trust dialog for: ${this.id} (attempt ${this._trustDialogAttempts}, ${step})` ); this.writeViaMux(key); // ⚠️ Schedule the next read; do NOT wait for more PTY output. This scan only // ever ran from `onData`, which was enough while one Enter answered the // dialog. It is not enough now: the arrow that moves the cursor is the LAST // output the pane produces, so a dialog left sitting on the trust option // never gets its Enter and the worker stays parked on it forever (measured // on a live 2.1.252 spawn: cursor moved at 6 s, then nothing). The timer is // one-shot and self-rearming through this same path, and every exit route // goes through _clearAllTimers(). // The +100ms puts the re-entry OUTSIDE the scan throttle above; firing at // exactly the throttle boundary would let the scan return early and break // the chain with the dialog still on screen. if (this._trustDialogTimer) clearTimeout(this._trustDialogTimer); this._trustDialogTimer = setTimeout(() => { this._trustDialogTimer = null; this._maybeAcceptTrustDialog(); }, TRUST_DIALOG_RETRY_MS + 100); } /** * Per-chunk working/idle detection for an interactive pane. Split out of the * PTY `onData` handler so it can be unit tested without spawning one. * * @param data raw PTY chunk, ANSI included */ private _detectInteractiveActivity(data: string): void { const workDetect = getCli(this.mode)?.capabilities.workDetect; // The composer row carries this glyph when the CLI is waiting for input. It only // ARMS the check and is NOT evidence the turn ended: a CLI redraws its composer // about once a second all the way through a turn, which is exactly how a working // session used to flip to idle two seconds in. _confirmIdle() waits for the pane to // actually go quiet before believing it. A CLI that declares no glyph keeps Claude's, // which is the glyph every such session has been armed by until now. if (data.includes(workDetect?.promptGlyph ?? '❯')) { // Only start a new timeout if we're not already awaiting idle confirmation. // This prevents status bar redraws (which include the prompt) from resetting it. if (!this._awaitingIdleConfirmation) { if (this.activityTimeout) clearTimeout(this.activityTimeout); this._awaitingIdleConfirmation = true; this.activityTimeout = setTimeout(() => this._confirmIdle(), IDLE_DETECTION_DELAY_MS); } } // Detect when Claude starts working (thinking, writing, etc). // Fast path: spinner characters on raw data (Unicode, never inside ANSI sequences). if (SPINNER_PATTERN.test(data)) this._markWorking(); // Activity fallback: current Claude Code animates `✻ Actualizing…` instead of a // braille spinner, so the fast path above misses entire turns, and matching the // new status line does not rescue it either (tmux repaints partially, so the // complete line reaches the PTY only every few tens of seconds). An unbroken run // of repaints is the signal that survives. See session-activity.ts for the // measurement. This needs a pane Codeman can read: without a glyph to arm the idle // confirmation, a session latches busy forever. A CLI that declares work detection // supplies its own glyph, and the non-external modes keep the run they always had. if (workDetect || !isExternalCliMode(this.mode)) { this._activityStreak = trackActivityStreak(this._activityStreak, Date.now()); // A streak is the TRIGGER to look, not the verdict: typing into the composer // also produces a steady stream of repaints. The screen settles it, and only // an explicit "no working line" vetoes; a probe that cannot read the pane // (null) leaves the streak in charge. if (!this._isWorking && isSustainedActivity(this._activityStreak) && this._probePaneWorking() !== false) { this._markWorking(); } } } /** * Ask the pane what it is rendering right now. * * The PTY stream cannot answer this on its own: measured on a live worker, * Claude repaints roughly once a second for most of a turn but can then sit * completely silent for tens of seconds inside a single tool call, while the * `✻ Elucidating… (39s · ↓ 2.0k tokens)` line stays on screen the whole time. * Silence therefore proves nothing, and the rendered frame is the only cheap * source that is right in both directions. * * Costs one `capture-pane`, floored at PANE_PROBE_MIN_INTERVAL_MS per session * and only ever called at a transition, never on the output hot path. * * @returns true/false when the screen could be read, null when it could not * (no mux, capture failed, tests). Callers must treat null as "no evidence" * and fall back to their stream heuristics. */ private _probePaneWorking(): boolean | null { if (!this._mux || !this._muxSession) return null; const now = Date.now(); if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking; this._lastPaneProbeAt = now; const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null; this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text); this._readWatching(text); return this._lastPaneProbeWorking; } /** * Read the background-work chip off the same capture the working probe just took. * * The two questions are different. A turn that is running is work the user is waiting * for; a monitor, a backgrounded shell or a cloud session the agent started is work * the AGENT is waiting for, and it is the reason a pane can sit at its composer with * nothing to say and still not want anything from the user. `_confirmIdle` takes this * capture at exactly the moment the turn ends, which is the moment the answer starts * mattering. * * A capture that could not be read leaves the last answer standing, the way the * working probe treats its own null: no evidence is not evidence of none. */ private _readWatching(paneText: string | null): void { const pattern = this._watchingLinePattern(); // Called only from the probe, and only with what a capture returned: `null` is // "the screen could not be read", which is not evidence that nothing is running. if (!pattern || paneText === null) return; const label = watchingLabel(paneText, pattern, this._watchingWindow); if (label === this._watching) return; this._watching = label; // ⚠️ This CHANGES while the session's status does not, so it needs an event of its // own. The label is usually set on the idle transition, which broadcasts anyway, but // it CLEARS when the work ends — and for a CLI whose background work ends without // taking a turn (measured on codex: a background terminal finishing repaints the row // away and nothing else happens) the session is idle before and after. Without this, // the server knew the badge was gone and every open page went on drawing it until // some unrelated event arrived. this.emit('watchingChanged'); } /** * The regex matching this CLI's background-work chip, or null for a CLI whose registry * entry declares none. Compiled once per session, like the working-line pattern, and * null rather than a fallback: no other CLI has been measured drawing such a chip, and * guessing one would badge sessions on the strength of an unread screen. */ private _watchingLinePattern(): RegExp | null { if (this._watchingLineRe === undefined) { // The pattern and the window it runs over are one decision, so they are resolved // together: how far up the screen a CLI's row can sit is as much a property of its // layout as the row itself. Claude writes on the last row and keeps the default, // Codex pins one above its composer and declares more. const detect = getCli(this.mode)?.capabilities.workDetect; this._watchingLineRe = detect?.watchingLine ? compileVersionRegex(detect.watchingLine) : null; this._watchingWindow = detect?.watchingLines ?? WATCHING_TAIL_LINES; } return this._watchingLineRe; } /** * The regex matching this CLI's "a turn is running" status line. * * Compiled once per session and cached: `_probePaneWorking` runs it against a whole * pane capture on a timer, and the throttled text detector runs it against every * accumulated chunk. A CLI that declares no pattern falls back to Claude's, which is * the pattern every session used before the registry carried one. */ private _workingLinePattern(): RegExp { if (this._workingLineRe === undefined) { const src = getCli(this.mode)?.capabilities.workDetect?.workingLine; // Same guard the schema applies, not a second opinion: `compileVersionRegex()` is // what keeps a nested quantifier out of this pattern, and this one runs on the PTY // hot path. It returns null rather than throwing, and Claude's pattern is the // fallback every session used before the registry carried one. this._workingLineRe = (src ? compileVersionRegex(src) : null) ?? CLAUDE_WORKING_LINE_PATTERN; } return this._workingLineRe; } /** * Mark the pane as working. Idempotent: `working` is emitted on the transition * only, so the per-chunk detectors can all call it freely. * * Deliberately does NOT cancel a pending idle confirmation. That confirmation * is what eventually notices the turn ended, and it already refuses to fire * while the pane is noisy, and cancelling it here would leave a session that * finished during a lull with nothing armed to ever call it idle. */ private _markWorking(): void { if (this._isWorking) return; this._isWorking = true; this._status = 'busy'; this.emit('working'); this._autoOps.notifyWorking(); } /** * Decide whether the armed idle confirmation is real. * * A ❯ sighting alone means nothing (Claude redraws the composer through the * whole turn), so the pane must ALSO have gone quiet. While output is still * flowing the check re-arms instead of concluding. That loop is a timestamp * compare every IDLE_RECHECK_MS and ends the moment the pane falls silent. */ private _confirmIdle(): void { if (this._isStopped) { this._awaitingIdleConfirmation = false; return; } if (!isPaneQuiet(this._lastActivityAt, Date.now())) { this.activityTimeout = setTimeout(() => this._confirmIdle(), IDLE_RECHECK_MS); return; // stays _awaitingIdleConfirmation, so ❯ redraws do not pile up timers } // Quiet is necessary but NOT sufficient: a turn can go silent mid-tool-call. // Ask the screen before concluding, and keep asking on a slow cadence. if (this._probePaneWorking() === true) { this._markWorking(); this.activityTimeout = setTimeout(() => this._confirmIdle(), PANE_PROBE_RECHECK_MS); return; } this._awaitingIdleConfirmation = false; this.activityTimeout = null; // 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(); if (wasWorking) this._maybeCaptureOmpSessionId(); this.emit('idle'); } } /** * A brand-new omp session (never yet respawned, so * {@link _pinOmpRespawnId} has never run) has no captured * omp-native session id: `_claudeSessionId` still defaults to this * session's OWN Codeman id from the constructor. Until something aliases * it, the omp history scan's row for this exact conversation (keyed by * omp's own uuid) merges with nothing and shows up a second time. The * first turn going idle is the first moment omp has definitely written * its session file, so resolve and alias it here — best-effort, and only * once (skips once `_claudeSessionId` differs from `this.id`, whether from * this capture or a resume/respawn that already resolved one). */ private _maybeCaptureOmpSessionId(): void { if (getCli(this.mode)?.capabilities.transcript !== 'omp-jsonl' || this._claudeSessionId !== this.id) return; // Same host-local-filesystem trap as `_pinOmpRespawnId`: the omp session file for a // remote session lives on the remote host, not here, so scanning locally risks aliasing // this session onto an unrelated local omp conversation that happens to mangle to the // same directory name. Never resolvable from here — skip. if (this._remote) return; try { const resolvedId = resolveAndClaimOmpSessionId(this.workingDir); if (resolvedId) { this._claudeSessionId = resolvedId; this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId }; } } catch { // Best-effort: a failed capture just means the next respawn tries again. } } /** * 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 { // 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; }; // Work detection by status line, ahead of the external-CLI gate below. The pattern // comes from the CLI's own registry entry, so this is the one parser here that is not // Claude-specific — and it sat under that gate, which is why an external CLI reported // itself idle through an entire turn. Guarded on the descriptor so a CLI without one // still skips the ANSI strip the gate used to save it. if (!this._isWorking && getCli(this.mode)?.capabilities.workDetect) { if (this._workingLinePattern().test(getCleanData())) this._markWorking(); } // 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; // Forward to Ralph tracker to detect Ralph loops and todos // (opencode sessions already returned early at line 1209) 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()); } // Usage-limit pause detection (auto-resume on usage limit) if (this._autoOps.autoResumeEnabled) { this._autoOps.processCleanData(getCleanData()); } // 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()); } // Legacy gerunds, Claude-only. The status-line pattern above already ran for every // CLI that declares one, so this adds only the older wording. Current Claude // randomizes the word ("Actualizing…", "Finagling…"), so these catch a fraction of // turns; the pattern above and the activity streak carry the rest. if (!this._isWorking) { const cleanData = getCleanData(); if ( cleanData.includes('Thinking') || cleanData.includes('Writing') || cleanData.includes('Reading') || cleanData.includes('Running') ) { this._markWorking(); } } } /** * Starts a plain shell session (bash/zsh) without Claude CLI. * * Useful for debugging, testing, or when you just need a terminal. * Uses the user's default shell from $SHELL or falls back to /bin/bash. * * @throws {Error} If a process is already running in this session * * @example * ```typescript * const session = new Session({ workingDir: '/project', mode: 'shell' }); * await session.startShell(); * session.write('ls -la\r'); * ``` */ async startShell(): Promise { if (this.ptyProcess) { throw new Error('Session already has a running process'); } this._resetBuffers(); // Use user's default shell, falling back to a shell that actually exists. // Shared with the tmux pane command so both paths launch the same binary. const shell = resolveLocalShell(); console.log( '[Session] Starting shell session with:', shell + (this._useMux ? ` (with ${this._mux!.backend})` : '') ); // If mux wrapping is enabled, create or attach to a mux session if (this._useMux && this._mux) { try { const { isRestored } = await this._setupOrAttachMuxSession({ respawnPaneOptions: { sessionId: this.id, workingDir: this.workingDir, mode: 'shell', niceConfig: this._niceConfig, envOverrides: this._envOverrides, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, owner: this._owner, }, createSessionOptions: { sessionId: this.id, workingDir: this.workingDir, mode: 'shell', name: this._name, niceConfig: this._niceConfig, envOverrides: this._envOverrides, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, owner: this._owner, }, spawnErrLabel: 'shell mux attachment', }); // For NEW sessions: clear by sending 'clear' command to the shell // For RESTORED sessions: don't clear - we want to see the existing output if (!isRestored) { setTimeout(() => { if (this.ptyProcess) { this._terminalBuffer.clear(); this.ptyProcess.write('clear\n'); } }, 100); } } catch (err) { console.error('[Session] Failed to create mux session, falling back to direct PTY:', err); this._useMux = false; this._muxSession = null; } } // Fallback to direct PTY if mux is not used if (!this.ptyProcess) { try { this.ptyProcess = spawnPtyWithHelperRepair(() => pty.spawn(shell, [], { name: 'xterm-256color', cols: 120, rows: 40, cwd: this.workingDir, env: buildShellEnv(this.id), }) ); } 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}`); } } this._pid = this.ptyProcess.pid; console.log('[Session] Shell PTY spawned with PID:', this._pid); this.ptyProcess.onData((rawData: string) => { // Filter out focus escape sequences const data = rawData.replace(FOCUS_ESCAPE_FILTER, ''); if (!data) return; // Skip if only focus sequences this._handleTerminalOutput(data); }); this.ptyProcess.onExit(({ exitCode }) => { console.log('[Session] Shell PTY exited with code:', exitCode); this.ptyProcess = null; this._pid = null; this._status = 'idle'; // Clear timers to prevent memory leaks if (this._shellIdleTimer) { clearTimeout(this._shellIdleTimer); this._shellIdleTimer = null; } if (this.activityTimeout) { clearTimeout(this.activityTimeout); this.activityTimeout = null; } // If using mux, mark the session as detached but don't kill it if (this._muxSession && this._mux) { this._mux.setAttached(this.id, false); } this.emit('exit', exitCode); }); // Mark as idle after a short delay (shell is ready) this._shellIdleTimer = setTimeout(() => { this._shellIdleTimer = null; this._status = 'idle'; this._isWorking = false; this.emit('idle'); }, 500); } /** * Runs a one-shot prompt and returns the result. * * This spawns Claude CLI with `--output-format stream-json` to get * structured JSON output. The promise resolves when Claude completes * the response. * * @param prompt - The prompt text to send to Claude * @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) * @returns Promise resolving to the result text and total cost in USD * @throws {Error} If a process is already running in this session * * @example * ```typescript * const session = new Session({ workingDir: '/project' }); * const { result, cost } = await session.runPrompt('Explain this code', { model: 'opus' }); * console.log(`Response: ${result}`); * console.log(`Cost: $${cost.toFixed(4)}`); * ``` */ async runPrompt( prompt: string, options?: { model?: string; onProgress?: (info: { tokens?: number; status?: string }) => void } ): Promise<{ result: string; cost: number }> { return new Promise((resolve, reject) => { if (this.ptyProcess) { reject(new Error('Session already has a running process')); return; } this._resetBuffers(); this._promptResolved = false; // Reset race condition guard this.resolvePromise = resolve; this.rejectPromise = reject; try { // Spawn claude in a real PTY const model = options?.model; console.log( '[Session] Spawning PTY for claude with prompt:', prompt.substring(0, 50), model ? `(model: ${model})` : '' ); const args = buildPromptArgs(prompt, model, this._claudeMode, this._allowedTools); try { this.ptyProcess = spawnPtyWithHelperRepair(() => pty.spawn(getClaudeBinaryPath(), args, { name: 'xterm-256color', cols: 120, rows: 40, cwd: this.workingDir, // Merge envOverrides after buildClaudeEnv so user settings shadow defaults. env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) }, }) ); } catch (spawnErr) { console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr); this.emit( 'error', `Failed to spawn Claude: ${spawnErr instanceof Error ? spawnErr.message : String(spawnErr)}` ); throw spawnErr; } this._pid = this.ptyProcess.pid; console.log('[Session] PTY spawned with PID:', this._pid); // Handle terminal data this.ptyProcess.onData((rawData: string) => { // Filter out focus escape sequences const data = rawData.replace(FOCUS_ESCAPE_FILTER, ''); if (!data) return; // Skip if only focus sequences this._handleTerminalOutput(data); // Also try to parse JSON lines for structured data this.processOutput(data); }); // Handle exit this.ptyProcess.onExit(({ exitCode }) => { console.log('[Session] PTY exited with code:', exitCode); this.ptyProcess = null; this._pid = null; // Guard against race conditions: only process once per runPrompt call if (this._promptResolved) { this.emit('exit', exitCode); return; } this._promptResolved = true; // Capture callbacks atomically before processing const resolve = this.resolvePromise; const reject = this.rejectPromise; this.resolvePromise = null; this.rejectPromise = null; // Find result from parsed messages or use text output const resultMsg = this._messages.find((m) => m.type === 'result'); if (resultMsg && !resultMsg.is_error) { this._status = 'idle'; const cost = resultMsg.total_cost_usd || 0; this._totalCost += cost; // 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); if (resolve) { resolve({ result, cost }); } } else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) { this._status = 'error'; if (reject) { reject(new Error(this._errorBuffer || this._textOutput.value || 'Process exited with error')); } } else { this._status = 'idle'; if (resolve) { resolve({ result: this._textOutput.value || this._terminalBuffer.value, cost: this._totalCost, }); } } this.emit('exit', exitCode); }); } catch (err) { this._status = 'error'; reject(err); // Null callbacks to prevent memory leak (onExit won't run if spawn failed) this.resolvePromise = null; this.rejectPromise = null; } }); } private _resetBuffers(): void { this._status = 'busy'; this._terminalBuffer.clear(); this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; this._lineBuffer = ''; this._altScreenSeqCarry = ''; // A restarted pane starts with no mouse mode: the new program has not asked // for one yet, and carrying the old CLI's state over would report clicks // into a program that never enabled tracking. this._cliMouseModes.clear(); this._syncCliMouseTracking(); this._markActivity(true); } private _clearAllTimers(): void { // Stop re-sending Enter for a prompt this session will never take now this._submitVerifier?.cancel(); this._submitVerifier = null; // Clear the workspace-trust follow-up read if (this._trustDialogTimer) { clearTimeout(this._trustDialogTimer); this._trustDialogTimer = null; } // 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)); } // 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. const msgSessionId = ((msg as unknown as Record).sessionId as string | undefined) ?? msg.session_id; if (msgSessionId && msgSessionId !== this._claudeSessionId) { 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'); } } private processOutput(data: string): void { // Early return if session is stopped to prevent any processing or timer creation if (this._isStopped) return; // Try to extract JSON from output (Claude may output JSON in stream mode) this._lineBuffer += data; // Prevent unbounded line buffer growth for very long lines if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) { // Force flush the oversized buffer as text output this._textOutput.append(this._lineBuffer + '\n'); this._lineBuffer = ''; } // Start flush timer if not running (handles partial lines after 100ms) if (!this._lineBufferFlushTimer && this._lineBuffer.length > 0 && !this._isStopped) { this._lineBufferFlushTimer = setTimeout(() => { this._lineBufferFlushTimer = null; if (this._lineBuffer.length > 0 && !this._isStopped) { // Flush partial line as text output this._textOutput.append(this._lineBuffer); this._lineBuffer = ''; } }, LINE_BUFFER_FLUSH_INTERVAL); } const lines = this._lineBuffer.split('\n'); this._lineBuffer = lines.pop() || ''; // Clear flush timer if buffer is now empty if (this._lineBuffer.length === 0 && this._lineBufferFlushTimer) { clearTimeout(this._lineBufferFlushTimer); this._lineBufferFlushTimer = null; } for (const line of lines) { const trimmed = line.trim(); // Remove ANSI escape codes for JSON parsing (use pre-compiled pattern) const cleanLine = trimmed.replace(ANSI_ESCAPE_PATTERN_FULL, ''); if (cleanLine.startsWith('{') && cleanLine.endsWith('}')) { this._handleJsonMessage(cleanLine, line); } else if (trimmed) { this._textOutput.append(line + '\n'); } // Parse task descriptions from terminal output (e.g., "Explore(Description)") // This captures the short description from Claude Code's Task tool output // Use direct method since cleanLine is already ANSI-stripped (line 1460) this.parseTaskDescriptionsDirect(cleanLine); } // Note: BufferAccumulator auto-trims when max size exceeded } /** * 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 */ private parseTaskDescriptionsFromTerminalData(cleanData: string): void { // Quick pre-check: skip if no parentheses present if (!cleanData.includes('(') || !cleanData.includes(')')) return; // Split by newlines and process each line (data already ANSI-stripped) const lines = cleanData.split(NEWLINE_SPLIT_PATTERN); for (const line of lines) { this.parseTaskDescriptionsDirect(line); } } /** * Parse task descriptions from a pre-cleaned line (no ANSI codes). * Used by both processOutput() and parseTaskDescriptionsFromTerminalData(). */ private parseTaskDescriptionsDirect(cleanLine: string): void { // Quick pre-check: skip expensive regex if no common tool patterns present if (!cleanLine.includes('(') || !cleanLine.includes(')')) return; execPattern(TASK_TOOL_PATTERN, cleanLine, (match) => { const description = match[2].trim(); if (description && description.length > 0) { this._taskCache.add(Date.now(), description); } }); } /** * Get recent task descriptions parsed from terminal output. * Returns descriptions sorted by timestamp (most recent first). */ getRecentTaskDescriptions(): Array<{ timestamp: number; description: string }> { return this._taskCache.getAll(); } /** * 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 { return this._taskCache.findNear(subagentStartTime, maxAgeMs); } // Parse token count from Claude's status line in interactive mode // Matches patterns like "123.4k tokens", "5234 tokens", "1.2M tokens" // // 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 private parseTokensFromStatusLine(cleanData: string): void { // Quick pre-check: skip expensive regex if "token" not present (performance optimization) if (!cleanData.includes('token')) return; // Match patterns: "123.4k tokens", "5234 tokens", "1.2M tokens" // The status line typically shows total tokens like "1.2k tokens" near the prompt // Note: ANSI codes are already stripped by caller for performance const tokenMatch = cleanData.match(TOKEN_PATTERN); if (tokenMatch) { let tokenCount = parseFloat(tokenMatch[1]); const suffix = tokenMatch[2]?.toLowerCase(); // Convert k/M suffix to actual number if (suffix === 'k') { tokenCount *= 1000; } else if (suffix === 'm') { // 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) { console.warn( `[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)` ); return; } tokenCount *= 1000000; } // Safety: Absolute maximum tokens per session if (tokenCount > MAX_SESSION_TOKENS) { console.warn(`[Session ${this.id}] Rejected token count exceeding max: ${tokenCount} > ${MAX_SESSION_TOKENS}`); return; } // 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) { 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) { console.warn( `[Session ${this.id}] Rejected suspicious token jump: ${currentTotal} -> ${tokenCount} (delta: ${delta})` ); return; } // 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); // Check if we should auto-compact or auto-clear this._autoOps.checkAutoCompact(); this._autoOps.checkAutoClear(); } } } // Parse Claude Code CLI info from terminal startup output // Extracts version, model, and account type for display in Codeman UI // Note: Expects cleanData with ANSI codes already stripped by caller private parseClaudeCodeInfo(cleanData: string): void { // Only parse once per session (during startup) if (this._cliInfoParsed) return; // Quick pre-checks if ( !cleanData.includes('Claude') && !cleanData.includes('current:') && !cleanData.includes('Opus') && !cleanData.includes('Sonnet') ) { 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, }); } } // Note: checkAutoCompact/checkAutoClear moved to SessionAutoOps (this._autoOps) /** * 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. * * @example * ```typescript * session.write('hello world'); // Text only, no Enter * session.write('\r'); // Enter key * session.write('ls -la\r'); // Command with Enter * ``` * * @param data - The input data to send (text, escape sequences, etc.) * @returns true if the data reached a PTY. A session whose PTY is gone still * discards the data, but it used to do so with no signal at all — which is how * input could disappear while the caller believed it had been delivered. */ write(data: string, options: SessionWriteOptions = {}): boolean { const submittedPrompt = this._trackSubmit(data, options); if (!this.ptyProcess) return false; this.ptyProcess.write(data); this._emitSubmittedPrompt(submittedPrompt); return true; } // ── Conversation tracking ───────────────────────────────────────────── // When this pane last submitted a message (Enter). The response-viewer // correlates this against the CLI's own history.jsonl entry timestamps to // find the conversation the pane is ACTUALLY on — the only signal that // survives /clear, /resume, /new and /fork typed inside the TUI itself, // none of which announce themselves on the PTY's stdout. private _lastSubmitAt = 0; /** Wall-clock ms of this pane's last Enter; 0 if it has never submitted. */ get lastSubmitAt(): number { return this._lastSubmitAt; } /** * Stamps the pane's last Enter for EVERY write, and feeds the auto-name * tracker only for user-originated input on a prompt-taking CLI. Ralph * kick-starts, respawn `/clear`s, cron launches, approval answers and the * trust-dialog keys all arrive without `fromUser` and so can never name a tab. */ private _trackSubmit(data: string, options: SessionWriteOptions): string[] { const submitted = options.fromUser && this._acceptsPrompts ? this._submittedPromptTracker.feed(data) : []; if (data.includes('\r') || data.includes('\n')) { this._lastSubmitAt = Date.now(); } return submitted; } /** * Feeds user input that reaches the pane AROUND the write paths: the * send-key route injects Shift+Enter's line feed through `tmux send-keys -H` * directly, and without this the two lines of a prompt joined with no * separator. Reports submissions like a write would (a line feed never is one). */ trackUserInput(data: string): void { if (!this._acceptsPrompts) return; this._emitSubmittedPrompt(this._submittedPromptTracker.feed(data)); } private _emitSubmittedPrompt(prompts: string[]): void { for (const prompt of prompts) { this.emit('promptSubmitted', prompt); } } /** * A prompt was submitted, reported by the CLI's own UserPromptSubmit hook. * `_trackSubmit` only sees input that flows through Codeman's write path, so * a pane the user drives by attaching to tmux directly never stamped this and * `lastSubmitAt` stayed 0 for its whole life. */ markPromptSubmitted(): void { this._lastSubmitAt = Date.now(); } /** * 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(); 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. */ /** * The highest input seq recorded for `clientId`, or 0 when this session has * never seen it. * * Reported back on a REJECTED (duplicate) frame so the client can lift its own * counter above this watermark. Without that number a client whose persisted * counter fell behind ours has no way to find its way out: every fresh * keystroke it sends lands at or below the watermark, is dropped as a * duplicate, and is ACKed anyway — so the UI looks healthy while nothing is * delivered, and a reload restores the same stale counter from localStorage. */ lastInputSeq(clientId: string): number { return this._appliedInputSeq.get(clientId) ?? 0; } 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; } /** * Undo the bookkeeping of {@link shouldApplyInput} for a delivery that failed. * * Without this, the reliable-delivery layer guarantees exactly-once delivery of * something that may never have been delivered: the seq is recorded as applied * BEFORE the write is attempted, so a client retry — the very mechanism the seq * exists for — is rejected as a duplicate and the input is lost for good. * * Only rolls back if `seq` is still the newest recorded one; a later input has * already superseded it and must not be re-opened. */ forgetInputSeq(clientId: string, seq: number): void { if (this._appliedInputSeq.get(clientId) === seq) { this._appliedInputSeq.set(clientId, seq - 1); } } /** * Sends input via the terminal multiplexer's direct input mechanism. * * More reliable than direct PTY write for programmatic input, especially * with Claude CLI which uses Ink (React for terminals). * Uses tmux `send-keys -l` to inject text + Enter. * * @param data - Input data with optional `\r` for Enter * @returns true if input was sent, false if no mux session or PTY * * @example * ```typescript * session.writeViaMux('/clear\r'); // Send /clear command * session.writeViaMux('/init\r'); // Send /init command * ``` */ async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise { const submittedPrompt = this._trackSubmit(data, options); if (this._mux && this._muxSession) { const sent = await this._mux.sendInput(this.id, data); if (sent) { this._emitSubmittedPrompt(submittedPrompt); this._verifySubmitted(data); } return sent; } // Fallback to PTY write if (this.ptyProcess) { this.ptyProcess.write(data); this._emitSubmittedPrompt(submittedPrompt); return true; } return false; } /** * Arm the composer check for a write that carried Enter (session-submit-verifier.ts): * Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints, * so the pair `sendInput` just sent can leave the text stranded. Only a mux session * can read its pane, only text can be stranded, and the glyph is the CLI's own. */ private _verifySubmitted(data: string): void { if (!data.includes('\r') || !this._mux?.capturePaneText || !this._muxSession) return; const text = data.replace(/[\r\n]/g, '').trimEnd(); if (!text) return; this._submitVerifier ??= new SubmitVerifier({ capture: () => this._isStopped || !this._mux || !this._muxSession ? null : this._mux.capturePaneText?.(this._muxSession.muxName), sendEnter: () => this._mux?.sendInput(this.id, '\r'), // ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file. // Only claude and codex declare a promptGlyph; the other eight modes would fall back // to claude's `❯`, which is ALSO starship's default shell prompt (and pure's, and // spaceship's, and p10k lean's). On a shell session the line `❯ npm run build` sits // on screen for as long as the command runs, promptStillInComposer() reads that as // "still unsubmitted", and the verifier presses Enter into the running program's // stdin on its 2s..60s schedule. Mostly a stray blank line; not harmless against a // y/N prompt, `read -p`, an installer or a pager, where it takes the default. // promptStillInComposer() returns undefined for an empty glyph, so this makes the // verifier inert for every CLI that does not declare one, which is what the Claude // Code 2.1.277 defect it exists for actually calls for. glyph: () => getCli(this.mode)?.capabilities.workDetect?.promptGlyph ?? '', log: (m) => console.log(`[Session ${this.id.slice(0, 8)}] ${m}`), }); this._submitVerifier.arm(text); } /** Current PTY dimensions — used to skip no-op resizes that trigger Ink redraws */ private _ptyCols = 120; private _ptyRows = 40; /** * 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. * * 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. */ private _desktopSizeClaims = new Set(); /** * 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; /** Register a live desktop sizing claim (see _desktopSizeClaims). */ claimDesktopSizing(token: symbol): void { this._desktopSizeClaims.add(token); this._lastDesktopActivityAt = Date.now(); } /** Release a desktop sizing claim when its connection goes away. */ releaseDesktopSizing(token: symbol): void { this._desktopSizeClaims.delete(token); } /** * 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) { this._mobileSizeOverride = false; this.resize(this._lastDesktopDims.cols, this._lastDesktopDims.rows, { viewportType: 'desktop' }); } } /** * Resizes the PTY terminal dimensions. * Skips the resize if dimensions haven't changed to avoid triggering * unnecessary Ink full-screen redraws (visible flicker on tab switch). * * 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. * * @param cols - Number of columns (width in characters) * @param rows - Number of rows (height in lines) */ resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType; force?: boolean } = {}): void { const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet'; if (options.viewportType === 'desktop') { this._lastDesktopDims = { cols, rows }; this._lastDesktopActivityAt = Date.now(); this._mobileSizeOverride = false; } if (isSmallViewport && this._desktopSizeClaims.size > 0) { if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) { return; } this._mobileSizeOverride = true; } const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows; if (this.ptyProcess && (dimsChanged || options.force)) { this._ptyCols = cols; this._ptyRows = rows; if (!IS_TEST_MODE && this._mux && this._muxSession) { this._mux.resizeWindow?.(this._muxSession.muxName, cols, rows); } this.ptyProcess.resize(cols, rows); } } // Legacy method for compatibility with session-manager async start(): Promise { this._status = 'idle'; } // Legacy method for sending input - wraps runPrompt async sendInput(input: string): Promise { this._status = 'busy'; this._markActivity(true); this.runPrompt(input).catch((err) => { const errorMsg = getErrorMessage(err); // 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._markActivity(true); this.emit('taskError', taskId, errorMsg); } else { this._status = 'idle'; } this.emit('error', errorMsg); }); } /** * Remove event listeners from TaskTracker and RalphTracker. * Prevents memory leaks by ensuring handlers don't persist after session stop. */ private cleanupTrackerListeners(): void { // Remove TaskTracker handlers if (this._taskTrackerHandlers) { this._taskTracker.off('taskCreated', this._taskTrackerHandlers.taskCreated); this._taskTracker.off('taskUpdated', this._taskTrackerHandlers.taskUpdated); this._taskTracker.off('taskCompleted', this._taskTrackerHandlers.taskCompleted); this._taskTracker.off('taskFailed', this._taskTrackerHandlers.taskFailed); this._taskTrackerHandlers = null; } // Remove RalphTracker handlers if (this._ralphHandlers) { this._ralphTracker.off('loopUpdate', this._ralphHandlers.loopUpdate); this._ralphTracker.off('todoUpdate', this._ralphHandlers.todoUpdate); this._ralphTracker.off('completionDetected', this._ralphHandlers.completionDetected); this._ralphTracker.off('statusBlockDetected', this._ralphHandlers.statusBlockDetected); this._ralphTracker.off('circuitBreakerUpdate', this._ralphHandlers.circuitBreakerUpdate); this._ralphTracker.off('exitGateMet', this._ralphHandlers.exitGateMet); this._ralphHandlers = null; } // Remove BashToolParser handlers if (this._bashToolHandlers) { this._bashToolParser.off('toolStart', this._bashToolHandlers.toolStart); this._bashToolParser.off('toolEnd', this._bashToolHandlers.toolEnd); this._bashToolParser.off('toolsUpdate', this._bashToolHandlers.toolsUpdate); this._bashToolHandlers = null; } // Destroy all trackers to release memory and stop timers this._bashToolParser.destroy(); this._taskTracker.destroy(); this._ralphTracker.destroy(); } /** * Stops the session and cleans up resources. * * This kills the PTY process and optionally the associated tmux session. * All buffers are cleared and the session is marked as stopped. * * @param killMux - Whether to also kill the mux session (default: true) * * @example * ```typescript * // Stop and kill everything * await session.stop(); * * // Stop but keep mux session running for later reattachment * await session.stop(false); * ``` */ async stop(killMux: boolean = true): Promise { // Set stopped flag first to prevent new timers from being created this._isStopped = true; // A pane that is gone is watching nothing. Nothing probes a stopped session, so // without this the last chip it drew would ride along on its row forever. this._watching = null; this._clearAllTimers(); // 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(); // Immediately cleanup Promise callbacks to prevent orphaned references // during the rest of stop() processing (e.g., if mux kill times out) if (this.rejectPromise && !this._promptResolved) { this._promptResolved = true; this.rejectPromise(new Error('Session stopped')); } this.resolvePromise = null; this.rejectPromise = null; // Remove event listeners from trackers to prevent memory leaks this.cleanupTrackerListeners(); if (this.ptyProcess) { if (killMux) { // Full kill: SIGTERM → wait → SIGKILL the PTY and its children const pid = this.ptyProcess.pid; // 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); } // Give it a moment to terminate gracefully await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS)); // 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); } // 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'); } this.ptyProcess = null; } this._pid = null; this._status = killMux ? 'stopped' : 'idle'; this._currentTaskId = null; // Clear task description cache and agent tree to prevent memory leak this._taskCache.clear(); this._childAgentIds = []; // Kill the associated mux session if requested if (killMux && this._mux) { // Try to kill mux session even if _muxSession is not set (e.g., restored sessions) try { const killed = await this._mux.killSession(this.id); if (killed) { console.log('[Session] Killed mux session for:', this.id); } } catch (err) { console.error('[Session] Failed to kill mux session:', err); } this._muxSession = null; } else if (this._muxSession && !killMux) { console.log('[Session] Keeping mux session alive:', this._muxSession.muxName); this._muxSession = null; // Detach but don't kill } } assignTask(taskId: string): void { this._currentTaskId = taskId; this._status = 'busy'; this._terminalBuffer.clear(); this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; this._markActivity(true); } clearTask(): void { this._currentTaskId = null; this._status = 'idle'; this._markActivity(true); } getOutput(): string { return this._textOutput.value; } getError(): string { return this._errorBuffer; } getTerminalBuffer(): string { return this._terminalBuffer.value; } clearBuffers(): void { this._terminalBuffer.clear(); this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; this._taskTracker.clear(); this._ralphTracker.clear(); this._taskCache.clear(); } }