Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/config/buffer-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,31 @@
* - Terminal buffer: 2MB max × 20 = 40MB worst case
* - Text output: 1MB max × 20 = 20MB worst case
* - Messages: ~1KB each × 1000 × 20 = 20MB worst case
* - Total buffer overhead: ~80MB (acceptable for long-running server)
* - Total buffer overhead: ~80MB (acceptable for a long-running server)
*
* @module config/buffer-limits
*/

import { DEFAULT_TERMINAL_BUFFER_MAX_BYTES, DEFAULT_TERMINAL_BUFFER_TRIM_BYTES } from './terminal-history.js';

// ============================================================================
// Terminal Buffer Limits
// ============================================================================

/**
* Maximum terminal buffer size in characters.
* Contains raw terminal output with ANSI escape sequences.
* Reduced from 5MB to 2MB for better render performance.
* Sourced from terminal-history config (env/settings overridable).
* Override: CODEMAN_MAX_TERMINAL_BUFFER (bytes)
*/
export const MAX_TERMINAL_BUFFER_SIZE = parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '') || 2 * 1024 * 1024;
export const MAX_TERMINAL_BUFFER_SIZE = DEFAULT_TERMINAL_BUFFER_MAX_BYTES;

/**
* Size to trim terminal buffer to when max is exceeded.
* Keeps the most recent portion to preserve context.
* Override: CODEMAN_TRIM_TERMINAL_TO (bytes)
*/
export const TRIM_TERMINAL_TO = parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '') || 1.5 * 1024 * 1024;
export const TRIM_TERMINAL_TO = DEFAULT_TERMINAL_BUFFER_TRIM_BYTES;

// ============================================================================
// Text Output Buffer Limits
Expand Down
65 changes: 65 additions & 0 deletions src/config/terminal-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Defaults, bounds, and resolution for terminal history retention.
*
* Centralizes the terminal scrollback, tmux history-limit, and server PTY buffer
* byte caps that were previously scattered as hardcoded literals. Each value is
* overridable (env var or the settings object) and clamped to a sane range via
* resolveTerminalHistoryConfig(). Defaults intentionally match the prior
* hardcoded values, so introducing this module is behavior-neutral.
*/

export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 50_000;
export const DEFAULT_TMUX_HISTORY_LIMIT = 50_000;
export const DEFAULT_TERMINAL_BUFFER_MAX_BYTES =
parseInt(process.env.CODEMAN_MAX_TERMINAL_BUFFER || '', 10) || 2 * 1024 * 1024;
export const DEFAULT_TERMINAL_BUFFER_TRIM_BYTES =
parseInt(process.env.CODEMAN_TRIM_TERMINAL_TO || '', 10) || 1.5 * 1024 * 1024;

export const MIN_TERMINAL_SCROLLBACK_LINES = 1_000;
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
export const MIN_TERMINAL_BUFFER_BYTES = 1024 * 1024;
export const MAX_TERMINAL_BUFFER_BYTES = 128 * 1024 * 1024;

export interface TerminalHistoryConfig {
terminalScrollbackLines: number;
tmuxHistoryLimit: number;
terminalBufferMaxBytes: number;
terminalBufferTrimBytes: number;
}

function boundedInt(value: unknown, fallback: number, min: number, max: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.max(min, Math.min(max, Math.trunc(value)));
}

export function resolveTerminalHistoryConfig(settings: Record<string, unknown> = {}): TerminalHistoryConfig {
const terminalBufferMaxBytes = boundedInt(
settings.terminalBufferMaxBytes,
DEFAULT_TERMINAL_BUFFER_MAX_BYTES,
MIN_TERMINAL_BUFFER_BYTES,
MAX_TERMINAL_BUFFER_BYTES
);
const terminalBufferTrimBytes = boundedInt(
settings.terminalBufferTrimBytes,
Math.min(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES, terminalBufferMaxBytes),
MIN_TERMINAL_BUFFER_BYTES,
terminalBufferMaxBytes
);

return {
terminalScrollbackLines: boundedInt(
settings.terminalScrollbackLines,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
MIN_TERMINAL_SCROLLBACK_LINES,
MAX_TERMINAL_SCROLLBACK_LINES
),
tmuxHistoryLimit: boundedInt(
settings.tmuxHistoryLimit,
DEFAULT_TMUX_HISTORY_LIMIT,
MIN_TERMINAL_SCROLLBACK_LINES,
MAX_TERMINAL_SCROLLBACK_LINES
),
terminalBufferMaxBytes,
terminalBufferTrimBytes,
};
}
7 changes: 7 additions & 0 deletions src/mux-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export interface CreateSessionOptions {
envOverrides?: Record<string, string>;
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
effort?: EffortLevel;
/** tmux history-limit (scrollback lines) to set for this session. */
historyLimit?: number;
}

/** Options for respawning a dead pane. */
Expand All @@ -92,6 +94,8 @@ export interface RespawnPaneOptions {
envOverrides?: Record<string, string>;
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
effort?: EffortLevel;
/** tmux history-limit (scrollback lines) to set for this session after respawn. */
historyLimit?: number;
}

/**
Expand Down Expand Up @@ -170,6 +174,9 @@ export interface TerminalMultiplexer extends EventEmitter {
/** Update Ralph enabled state for a session */
updateRalphEnabled(sessionId: string, enabled: boolean): void;

/** Apply a tmux history-limit to all tracked sessions. */
setHistoryLimit(limit: number): Promise<void>;

// ========== Discovery ==========

/**
Expand Down
11 changes: 11 additions & 0 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
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 {
buildInteractiveArgs,
Expand Down Expand Up @@ -381,6 +382,9 @@ export class Session extends EventEmitter {
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
private _effort: EffortLevel | undefined;

// tmux history-limit (scrollback lines) applied to this session's pane.
private readonly _tmuxHistoryLimit: number;

// Session color for visual differentiation
private _color: import('./types.js').SessionColor = 'default';

Expand Down Expand Up @@ -448,6 +452,8 @@ export class Session extends EventEmitter {
envOverrides?: Record<string, string>;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
/** tmux history-limit (scrollback lines) for this session's pane. */
tmuxHistoryLimit?: number;
/** Restored per-session attachment history. May include server-private external paths. */
attachmentHistory?: SessionAttachmentHistoryItem[];
}
Expand Down Expand Up @@ -521,6 +527,7 @@ export class Session extends EventEmitter {
if (config.effort && isEffortLevel(config.effort)) {
this._effort = config.effort;
}
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
this.restoreAttachmentHistory(config.attachmentHistory);
}
Expand Down Expand Up @@ -1274,6 +1281,7 @@ export class Session extends EventEmitter {
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
historyLimit: this._tmuxHistoryLimit,
},
createSessionOptions: {
sessionId: this.id,
Expand All @@ -1290,6 +1298,7 @@ export class Session extends EventEmitter {
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
historyLimit: this._tmuxHistoryLimit,
},
spawnErrLabel: 'mux attachment',
});
Expand Down Expand Up @@ -1627,6 +1636,7 @@ export class Session extends EventEmitter {
mode: 'shell',
niceConfig: this._niceConfig,
envOverrides: this._envOverrides,
historyLimit: this._tmuxHistoryLimit,
},
createSessionOptions: {
sessionId: this.id,
Expand All @@ -1635,6 +1645,7 @@ export class Session extends EventEmitter {
name: this._name,
niceConfig: this._niceConfig,
envOverrides: this._envOverrides,
historyLimit: this._tmuxHistoryLimit,
},
spawnErrLabel: 'shell mux attachment',
});
Expand Down
37 changes: 36 additions & 1 deletion src/tmux-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import type {
// ============================================================================

import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';

/** Delay after tmux session creation — enough for detached tmux to be queryable */
const TMUX_CREATION_WAIT_MS = 100;
Expand Down Expand Up @@ -1057,6 +1058,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
resumeSessionId,
envOverrides,
effort,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
} = options;
const muxName = `codeman-${sessionId.slice(0, 8)}`;

Expand Down Expand Up @@ -1201,7 +1203,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}),
// Raise tmux scrollback from its 2000-line default so re-attach preserves
// more context. Matches the xterm-side default in constants.js.
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit ${historyLimit}`, {
timeout: EXEC_TIMEOUT_MS,
})
.then(() => {})
.catch(() => {
/* Non-critical — falls back to tmux default */
Expand Down Expand Up @@ -1324,13 +1328,24 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
resumeSessionId,
envOverrides,
effort,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
const muxName = session.muxName;

if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;

// Re-apply the configured tmux history-limit after respawn (kept in sync
// with the live setting via setHistoryLimit()).
if (!IS_TEST_MODE) {
await execAsync(`${this.tmux()} set-option -t ${shellescape(muxName)} history-limit ${historyLimit}`, {
timeout: EXEC_TIMEOUT_MS,
}).catch(() => {
/* Non-critical — keeps existing tmux history-limit */
});
}

// Resolve CLI binary directory based on mode
const { pathExport } = this.buildPathExport(mode);

Expand Down Expand Up @@ -1935,6 +1950,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}

/**
* Apply a tmux history-limit to all tracked sessions (e.g. when the user
* changes the terminal-history setting). Invalid limits fall back to the
* default. Best-effort per session.
*/
async setHistoryLimit(limit: number): Promise<void> {
const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.trunc(limit) : DEFAULT_TMUX_HISTORY_LIMIT;

if (IS_TEST_MODE) {
return;
}

const updates = Array.from(this.sessions.values()).map((session) =>
execAsync(`${this.tmux()} set-option -t ${shellescape(session.muxName)} history-limit ${safeLimit}`, {
timeout: EXEC_TIMEOUT_MS,
})
);
await Promise.allSettled(updates);
}

/**
* Send input directly to a tmux session using `send-keys`.
*
Expand Down
2 changes: 2 additions & 0 deletions src/web/ports/config-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import type { ClaudeMode, NiceConfig } from '../../types.js';
import type { StateStore } from '../../state-store.js';
import type { TerminalHistoryConfig } from '../../config/terminal-history.js';

export interface ConfigPort {
readonly store: StateStore;
Expand All @@ -15,6 +16,7 @@ export interface ConfigPort {
getGlobalNiceConfig(): Promise<NiceConfig | undefined>;
getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record<string, string> } | null>;
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
getDefaultClaudeMdPath(): Promise<string | undefined>;
getLightState(): unknown;
getLightSessionsState(): unknown[];
Expand Down
4 changes: 4 additions & 0 deletions src/web/routes/session-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ export function registerSessionRoutes(
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir,
mode,
Expand All @@ -422,6 +423,7 @@ export function registerSessionRoutes(
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
effort: body.effort,
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
});

ctx.addSession(session);
Expand Down Expand Up @@ -1362,6 +1364,7 @@ export function registerSessionRoutes(
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir: casePath,
mux: ctx.mux,
Expand All @@ -1376,6 +1379,7 @@ export function registerSessionRoutes(
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
envOverrides,
effort,
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
});

// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
Expand Down
6 changes: 6 additions & 0 deletions src/web/routes/system-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '..
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js';

// Maximum screenshot upload size (10MB)
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
Expand Down Expand Up @@ -624,6 +625,11 @@ export function registerSystemRoutes(
const merged = { ...existing, ...settingsToStore };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));

// Apply a changed tmux history-limit to all live sessions immediately.
if (settings.tmuxHistoryLimit !== undefined) {
await ctx.mux.setHistoryLimit(resolveTerminalHistoryConfig(merged).tmuxHistoryLimit);
}

// Handle subagent tracking toggle dynamically
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');

Expand Down
31 changes: 30 additions & 1 deletion src/web/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@

import { z } from 'zod';
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
import {
MAX_TERMINAL_BUFFER_BYTES,
MAX_TERMINAL_SCROLLBACK_LINES,
MIN_TERMINAL_BUFFER_BYTES,
MIN_TERMINAL_SCROLLBACK_LINES,
} from '../config/terminal-history.js';

// ========== Path Validation ==========

Expand Down Expand Up @@ -412,6 +418,16 @@ export const SettingsUpdateSchema = z
allowedTools: z.string().max(2000).optional(),
// Codex CLI settings
codexDangerouslyBypassApprovals: z.boolean().optional(),
// Terminal history and retention
terminalScrollbackLines: z
.number()
.int()
.min(MIN_TERMINAL_SCROLLBACK_LINES)
.max(MAX_TERMINAL_SCROLLBACK_LINES)
.optional(),
tmuxHistoryLimit: z.number().int().min(MIN_TERMINAL_SCROLLBACK_LINES).max(MAX_TERMINAL_SCROLLBACK_LINES).optional(),
terminalBufferMaxBytes: z.number().int().min(MIN_TERMINAL_BUFFER_BYTES).max(MAX_TERMINAL_BUFFER_BYTES).optional(),
terminalBufferTrimBytes: z.number().int().min(MIN_TERMINAL_BUFFER_BYTES).max(MAX_TERMINAL_BUFFER_BYTES).optional(),
// CPU priority
nice: z
.object({
Expand Down Expand Up @@ -480,7 +496,20 @@ export const SettingsUpdateSchema = z
.max(20)
.optional(),
})
.strict();
.strict()
.superRefine((settings, ctx) => {
if (
settings.terminalBufferMaxBytes !== undefined &&
settings.terminalBufferTrimBytes !== undefined &&
settings.terminalBufferTrimBytes > settings.terminalBufferMaxBytes
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['terminalBufferTrimBytes'],
message: 'terminalBufferTrimBytes must be less than or equal to terminalBufferMaxBytes',
});
}
});

/**
* Schema for POST /api/sessions/:id/input with length limit
Expand Down
Loading