mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
COD-80 centralize terminal history/scrollback/buffer limits into config
Introduce src/config/terminal-history.ts: one place for terminal scrollback, tmux history-limit, and PTY buffer byte caps, each overridable via env var or the settings object and bounds-clamped via resolveTerminalHistoryConfig(). Defaults match the prior hardcoded values, so this is behavior-neutral. Wires the resolver through buffer-limits, tmux-manager (incl. a setHistoryLimit so a settings change applies live), session, server, system-routes, session-routes, schemas, and the config port. Adds 4 optional settings keys (terminalScrollback Lines, tmuxHistoryLimit, terminalBufferMaxBytes, terminalBufferTrimBytes) with bounds + a trim<=max cross-check.
This commit is contained in:
@@ -9,11 +9,13 @@
|
|||||||
* - Terminal buffer: 2MB max × 20 = 40MB worst case
|
* - Terminal buffer: 2MB max × 20 = 40MB worst case
|
||||||
* - Text output: 1MB max × 20 = 20MB worst case
|
* - Text output: 1MB max × 20 = 20MB worst case
|
||||||
* - Messages: ~1KB each × 1000 × 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
|
* @module config/buffer-limits
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_TERMINAL_BUFFER_MAX_BYTES, DEFAULT_TERMINAL_BUFFER_TRIM_BYTES } from './terminal-history.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Terminal Buffer Limits
|
// Terminal Buffer Limits
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -21,17 +23,17 @@
|
|||||||
/**
|
/**
|
||||||
* Maximum terminal buffer size in characters.
|
* Maximum terminal buffer size in characters.
|
||||||
* Contains raw terminal output with ANSI escape sequences.
|
* 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)
|
* 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.
|
* Size to trim terminal buffer to when max is exceeded.
|
||||||
* Keeps the most recent portion to preserve context.
|
* Keeps the most recent portion to preserve context.
|
||||||
* Override: CODEMAN_TRIM_TERMINAL_TO (bytes)
|
* 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
|
// Text Output Buffer Limits
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -72,6 +72,8 @@ export interface CreateSessionOptions {
|
|||||||
envOverrides?: Record<string, string>;
|
envOverrides?: Record<string, string>;
|
||||||
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
|
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
/** tmux history-limit (scrollback lines) to set for this session. */
|
||||||
|
historyLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options for respawning a dead pane. */
|
/** Options for respawning a dead pane. */
|
||||||
@@ -92,6 +94,8 @@ export interface RespawnPaneOptions {
|
|||||||
envOverrides?: Record<string, string>;
|
envOverrides?: Record<string, string>;
|
||||||
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
/** tmux history-limit (scrollback lines) to set for this session after respawn. */
|
||||||
|
historyLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,6 +174,9 @@ export interface TerminalMultiplexer extends EventEmitter {
|
|||||||
/** Update Ralph enabled state for a session */
|
/** Update Ralph enabled state for a session */
|
||||||
updateRalphEnabled(sessionId: string, enabled: boolean): void;
|
updateRalphEnabled(sessionId: string, enabled: boolean): void;
|
||||||
|
|
||||||
|
/** Apply a tmux history-limit to all tracked sessions. */
|
||||||
|
setHistoryLimit(limit: number): Promise<void>;
|
||||||
|
|
||||||
// ========== Discovery ==========
|
// ========== Discovery ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import {
|
|||||||
MAX_MESSAGES,
|
MAX_MESSAGES,
|
||||||
MAX_LINE_BUFFER_SIZE,
|
MAX_LINE_BUFFER_SIZE,
|
||||||
} from './config/buffer-limits.js';
|
} 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 { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||||
import {
|
import {
|
||||||
buildInteractiveArgs,
|
buildInteractiveArgs,
|
||||||
@@ -381,6 +382,9 @@ export class Session extends EventEmitter {
|
|||||||
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
|
||||||
private _effort: EffortLevel | undefined;
|
private _effort: EffortLevel | undefined;
|
||||||
|
|
||||||
|
// tmux history-limit (scrollback lines) applied to this session's pane.
|
||||||
|
private readonly _tmuxHistoryLimit: number;
|
||||||
|
|
||||||
// Session color for visual differentiation
|
// Session color for visual differentiation
|
||||||
private _color: import('./types.js').SessionColor = 'default';
|
private _color: import('./types.js').SessionColor = 'default';
|
||||||
|
|
||||||
@@ -448,6 +452,8 @@ export class Session extends EventEmitter {
|
|||||||
envOverrides?: Record<string, string>;
|
envOverrides?: Record<string, string>;
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
effort?: EffortLevel;
|
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. */
|
/** Restored per-session attachment history. May include server-private external paths. */
|
||||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||||
}
|
}
|
||||||
@@ -521,6 +527,7 @@ export class Session extends EventEmitter {
|
|||||||
if (config.effort && isEffortLevel(config.effort)) {
|
if (config.effort && isEffortLevel(config.effort)) {
|
||||||
this._effort = config.effort;
|
this._effort = config.effort;
|
||||||
}
|
}
|
||||||
|
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
|
||||||
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
||||||
this.restoreAttachmentHistory(config.attachmentHistory);
|
this.restoreAttachmentHistory(config.attachmentHistory);
|
||||||
}
|
}
|
||||||
@@ -1274,6 +1281,7 @@ export class Session extends EventEmitter {
|
|||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
},
|
},
|
||||||
createSessionOptions: {
|
createSessionOptions: {
|
||||||
sessionId: this.id,
|
sessionId: this.id,
|
||||||
@@ -1290,6 +1298,7 @@ export class Session extends EventEmitter {
|
|||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
},
|
},
|
||||||
spawnErrLabel: 'mux attachment',
|
spawnErrLabel: 'mux attachment',
|
||||||
});
|
});
|
||||||
@@ -1627,6 +1636,7 @@ export class Session extends EventEmitter {
|
|||||||
mode: 'shell',
|
mode: 'shell',
|
||||||
niceConfig: this._niceConfig,
|
niceConfig: this._niceConfig,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
},
|
},
|
||||||
createSessionOptions: {
|
createSessionOptions: {
|
||||||
sessionId: this.id,
|
sessionId: this.id,
|
||||||
@@ -1635,6 +1645,7 @@ export class Session extends EventEmitter {
|
|||||||
name: this._name,
|
name: this._name,
|
||||||
niceConfig: this._niceConfig,
|
niceConfig: this._niceConfig,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
},
|
},
|
||||||
spawnErrLabel: 'shell mux attachment',
|
spawnErrLabel: 'shell mux attachment',
|
||||||
});
|
});
|
||||||
|
|||||||
+36
-1
@@ -65,6 +65,7 @@ import type {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
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 */
|
/** Delay after tmux session creation — enough for detached tmux to be queryable */
|
||||||
const TMUX_CREATION_WAIT_MS = 100;
|
const TMUX_CREATION_WAIT_MS = 100;
|
||||||
@@ -1057,6 +1058,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
|
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||||
} = options;
|
} = options;
|
||||||
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
||||||
|
|
||||||
@@ -1201,7 +1203,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}),
|
}),
|
||||||
// Raise tmux scrollback from its 2000-line default so re-attach preserves
|
// Raise tmux scrollback from its 2000-line default so re-attach preserves
|
||||||
// more context. Matches the xterm-side default in constants.js.
|
// 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(() => {})
|
.then(() => {})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* Non-critical — falls back to tmux default */
|
/* Non-critical — falls back to tmux default */
|
||||||
@@ -1324,6 +1328,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
|
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||||
} = options;
|
} = options;
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
@@ -1331,6 +1336,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
|
|
||||||
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
|
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
|
// Resolve CLI binary directory based on mode
|
||||||
const { pathExport } = this.buildPathExport(mode);
|
const { pathExport } = this.buildPathExport(mode);
|
||||||
|
|
||||||
@@ -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`.
|
* Send input directly to a tmux session using `send-keys`.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import type { ClaudeMode, NiceConfig } from '../../types.js';
|
import type { ClaudeMode, NiceConfig } from '../../types.js';
|
||||||
import type { StateStore } from '../../state-store.js';
|
import type { StateStore } from '../../state-store.js';
|
||||||
|
import type { TerminalHistoryConfig } from '../../config/terminal-history.js';
|
||||||
|
|
||||||
export interface ConfigPort {
|
export interface ConfigPort {
|
||||||
readonly store: StateStore;
|
readonly store: StateStore;
|
||||||
@@ -15,6 +16,7 @@ export interface ConfigPort {
|
|||||||
getGlobalNiceConfig(): Promise<NiceConfig | undefined>;
|
getGlobalNiceConfig(): Promise<NiceConfig | undefined>;
|
||||||
getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record<string, string> } | null>;
|
getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record<string, string> } | null>;
|
||||||
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
|
getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>;
|
||||||
|
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
||||||
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
||||||
getLightState(): unknown;
|
getLightState(): unknown;
|
||||||
getLightSessionsState(): unknown[];
|
getLightSessionsState(): unknown[];
|
||||||
|
|||||||
@@ -406,6 +406,7 @@ export function registerSessionRoutes(
|
|||||||
? modelConfig?.defaultModel || undefined
|
? modelConfig?.defaultModel || undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||||
|
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir,
|
workingDir,
|
||||||
mode,
|
mode,
|
||||||
@@ -422,6 +423,7 @@ export function registerSessionRoutes(
|
|||||||
resumeSessionId: validatedResumeId,
|
resumeSessionId: validatedResumeId,
|
||||||
envOverrides: body.envOverrides,
|
envOverrides: body.envOverrides,
|
||||||
effort: body.effort,
|
effort: body.effort,
|
||||||
|
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.addSession(session);
|
ctx.addSession(session);
|
||||||
@@ -1362,6 +1364,7 @@ export function registerSessionRoutes(
|
|||||||
? qsModelConfig?.defaultModel || undefined
|
? qsModelConfig?.defaultModel || undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||||
|
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: casePath,
|
workingDir: casePath,
|
||||||
mux: ctx.mux,
|
mux: ctx.mux,
|
||||||
@@ -1376,6 +1379,7 @@ export function registerSessionRoutes(
|
|||||||
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
|
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
|
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '..
|
|||||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||||
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
||||||
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
|
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
|
||||||
|
import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js';
|
||||||
|
|
||||||
// Maximum screenshot upload size (10MB)
|
// Maximum screenshot upload size (10MB)
|
||||||
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
||||||
@@ -624,6 +625,11 @@ export function registerSystemRoutes(
|
|||||||
const merged = { ...existing, ...settingsToStore };
|
const merged = { ...existing, ...settingsToStore };
|
||||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
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
|
// Handle subagent tracking toggle dynamically
|
||||||
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
toggleService((settings.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||||
|
|
||||||
|
|||||||
+30
-1
@@ -9,6 +9,12 @@
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
|
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 ==========
|
// ========== Path Validation ==========
|
||||||
|
|
||||||
@@ -412,6 +418,16 @@ export const SettingsUpdateSchema = z
|
|||||||
allowedTools: z.string().max(2000).optional(),
|
allowedTools: z.string().max(2000).optional(),
|
||||||
// Codex CLI settings
|
// Codex CLI settings
|
||||||
codexDangerouslyBypassApprovals: z.boolean().optional(),
|
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
|
// CPU priority
|
||||||
nice: z
|
nice: z
|
||||||
.object({
|
.object({
|
||||||
@@ -480,7 +496,20 @@ export const SettingsUpdateSchema = z
|
|||||||
.max(20)
|
.max(20)
|
||||||
.optional(),
|
.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
|
* Schema for POST /api/sessions/:id/input with length limit
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ import {
|
|||||||
import type { EventLoopMonitorHandle } from '../utils/index.js';
|
import type { EventLoopMonitorHandle } from '../utils/index.js';
|
||||||
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
||||||
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
|
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
|
||||||
|
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
|
||||||
import { SseEvent } from './sse-events.js';
|
import { SseEvent } from './sse-events.js';
|
||||||
import { getLatestPlanUsage } from './plan-usage-latest.js';
|
import { getLatestPlanUsage } from './plan-usage-latest.js';
|
||||||
import type { ScheduledRun } from './ports/index.js';
|
import type { ScheduledRun } from './ports/index.js';
|
||||||
@@ -587,6 +588,7 @@ export class WebServer extends EventEmitter {
|
|||||||
getGlobalNiceConfig: this.getGlobalNiceConfig.bind(this),
|
getGlobalNiceConfig: this.getGlobalNiceConfig.bind(this),
|
||||||
getModelConfig: this.getModelConfig.bind(this),
|
getModelConfig: this.getModelConfig.bind(this),
|
||||||
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
|
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
|
||||||
|
getTerminalHistoryConfig: this.getTerminalHistoryConfig.bind(this),
|
||||||
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
|
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
|
||||||
getLightState: this.getLightState.bind(this),
|
getLightState: this.getLightState.bind(this),
|
||||||
getLightSessionsState: this.getLightSessionsState.bind(this),
|
getLightSessionsState: this.getLightSessionsState.bind(this),
|
||||||
@@ -1463,6 +1465,12 @@ export class WebServer extends EventEmitter {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the bounds-clamped terminal-history config from settings.json.
|
||||||
|
private async getTerminalHistoryConfig() {
|
||||||
|
const settings = await this.readSettings();
|
||||||
|
return resolveTerminalHistoryConfig(settings);
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to get model configuration from settings
|
// Helper to get model configuration from settings
|
||||||
private async getModelConfig(): Promise<{
|
private async getModelConfig(): Promise<{
|
||||||
defaultModel?: string;
|
defaultModel?: string;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { MockSession, createMockSession } from './mock-session.js';
|
import { MockSession, createMockSession } from './mock-session.js';
|
||||||
|
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a mock context that satisfies all port interfaces.
|
* Creates a mock context that satisfies all port interfaces.
|
||||||
@@ -76,6 +77,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
|||||||
getGlobalNiceConfig: vi.fn(async () => undefined),
|
getGlobalNiceConfig: vi.fn(async () => undefined),
|
||||||
getModelConfig: vi.fn(async () => null),
|
getModelConfig: vi.fn(async () => null),
|
||||||
getClaudeModeConfig: vi.fn(async () => ({})),
|
getClaudeModeConfig: vi.fn(async () => ({})),
|
||||||
|
getTerminalHistoryConfig: vi.fn(async () => resolveTerminalHistoryConfig({})),
|
||||||
getDefaultClaudeMdPath: vi.fn(async () => undefined),
|
getDefaultClaudeMdPath: vi.fn(async () => undefined),
|
||||||
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
|
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
|
||||||
getLightSessionsState: vi.fn(() => {
|
getLightSessionsState: vi.fn(() => {
|
||||||
@@ -99,6 +101,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
|||||||
getSession: vi.fn(() => null),
|
getSession: vi.fn(() => null),
|
||||||
clearRespawnConfig: vi.fn(),
|
clearRespawnConfig: vi.fn(),
|
||||||
updateRespawnConfig: vi.fn(),
|
updateRespawnConfig: vi.fn(),
|
||||||
|
setHistoryLimit: vi.fn(async () => {}),
|
||||||
},
|
},
|
||||||
runSummaryTrackers: new Map(),
|
runSummaryTrackers: new Map(),
|
||||||
activePlanOrchestrators: new Map(),
|
activePlanOrchestrators: new Map(),
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||||
|
import {
|
||||||
|
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
MAX_TERMINAL_BUFFER_BYTES,
|
||||||
|
MIN_TERMINAL_BUFFER_BYTES,
|
||||||
|
} from '../src/config/terminal-history.js';
|
||||||
|
|
||||||
|
describe('SettingsUpdateSchema — terminal history keys', () => {
|
||||||
|
it('accepts valid in-range terminal-history settings', () => {
|
||||||
|
const res = SettingsUpdateSchema.safeParse({
|
||||||
|
terminalScrollbackLines: 100_000,
|
||||||
|
tmuxHistoryLimit: 100_000,
|
||||||
|
terminalBufferMaxBytes: 32 * 1024 * 1024,
|
||||||
|
terminalBufferTrimBytes: 24 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a settings object that omits the terminal-history keys', () => {
|
||||||
|
const res = SettingsUpdateSchema.safeParse({ allowedTools: 'Bash' });
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects scrollback below the minimum', () => {
|
||||||
|
const res = SettingsUpdateSchema.safeParse({ terminalScrollbackLines: MIN_TERMINAL_SCROLLBACK_LINES - 1 });
|
||||||
|
expect(res.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects scrollback above the maximum', () => {
|
||||||
|
const res = SettingsUpdateSchema.safeParse({ tmuxHistoryLimit: MAX_TERMINAL_SCROLLBACK_LINES + 1 });
|
||||||
|
expect(res.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects buffer bytes outside the byte bounds', () => {
|
||||||
|
expect(SettingsUpdateSchema.safeParse({ terminalBufferMaxBytes: MIN_TERMINAL_BUFFER_BYTES - 1 }).success).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(SettingsUpdateSchema.safeParse({ terminalBufferMaxBytes: MAX_TERMINAL_BUFFER_BYTES + 1 }).success).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-integer values', () => {
|
||||||
|
expect(SettingsUpdateSchema.safeParse({ terminalScrollbackLines: 12_345.5 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects trim > max via the cross-field superRefine', () => {
|
||||||
|
const res = SettingsUpdateSchema.safeParse({
|
||||||
|
terminalBufferMaxBytes: 4 * 1024 * 1024,
|
||||||
|
terminalBufferTrimBytes: 8 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
expect(res.success).toBe(false);
|
||||||
|
if (!res.success) {
|
||||||
|
expect(res.error.issues.some((i) => i.path.includes('terminalBufferTrimBytes'))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows trim == max', () => {
|
||||||
|
const res = SettingsUpdateSchema.safeParse({
|
||||||
|
terminalBufferMaxBytes: 8 * 1024 * 1024,
|
||||||
|
terminalBufferTrimBytes: 8 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
expect(res.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
resolveTerminalHistoryConfig,
|
||||||
|
DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
DEFAULT_TMUX_HISTORY_LIMIT,
|
||||||
|
DEFAULT_TERMINAL_BUFFER_MAX_BYTES,
|
||||||
|
DEFAULT_TERMINAL_BUFFER_TRIM_BYTES,
|
||||||
|
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
MIN_TERMINAL_BUFFER_BYTES,
|
||||||
|
MAX_TERMINAL_BUFFER_BYTES,
|
||||||
|
} from '../src/config/terminal-history.js';
|
||||||
|
|
||||||
|
describe('resolveTerminalHistoryConfig', () => {
|
||||||
|
it('returns the documented defaults for empty settings', () => {
|
||||||
|
const cfg = resolveTerminalHistoryConfig({});
|
||||||
|
expect(cfg).toEqual({
|
||||||
|
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
tmuxHistoryLimit: DEFAULT_TMUX_HISTORY_LIMIT,
|
||||||
|
terminalBufferMaxBytes: DEFAULT_TERMINAL_BUFFER_MAX_BYTES,
|
||||||
|
terminalBufferTrimBytes: DEFAULT_TERMINAL_BUFFER_TRIM_BYTES,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults match the prior hardcoded values (behavior-neutral)', () => {
|
||||||
|
expect(DEFAULT_TMUX_HISTORY_LIMIT).toBe(50_000);
|
||||||
|
expect(DEFAULT_TERMINAL_SCROLLBACK_LINES).toBe(50_000);
|
||||||
|
expect(DEFAULT_TERMINAL_BUFFER_MAX_BYTES).toBe(2 * 1024 * 1024);
|
||||||
|
expect(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES).toBe(1.5 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes valid in-range values through unchanged', () => {
|
||||||
|
const cfg = resolveTerminalHistoryConfig({
|
||||||
|
terminalScrollbackLines: 50_000,
|
||||||
|
tmuxHistoryLimit: 75_000,
|
||||||
|
terminalBufferMaxBytes: 16 * 1024 * 1024,
|
||||||
|
terminalBufferTrimBytes: 8 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
expect(cfg).toEqual({
|
||||||
|
terminalScrollbackLines: 50_000,
|
||||||
|
tmuxHistoryLimit: 75_000,
|
||||||
|
terminalBufferMaxBytes: 16 * 1024 * 1024,
|
||||||
|
terminalBufferTrimBytes: 8 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps scrollback/history below MIN up to the floor', () => {
|
||||||
|
const cfg = resolveTerminalHistoryConfig({
|
||||||
|
terminalScrollbackLines: 1,
|
||||||
|
tmuxHistoryLimit: 0,
|
||||||
|
});
|
||||||
|
expect(cfg.terminalScrollbackLines).toBe(MIN_TERMINAL_SCROLLBACK_LINES);
|
||||||
|
expect(cfg.tmuxHistoryLimit).toBe(MIN_TERMINAL_SCROLLBACK_LINES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps scrollback/history above MAX down to the ceiling', () => {
|
||||||
|
const cfg = resolveTerminalHistoryConfig({
|
||||||
|
terminalScrollbackLines: 999_999_999,
|
||||||
|
tmuxHistoryLimit: 999_999_999,
|
||||||
|
});
|
||||||
|
expect(cfg.terminalScrollbackLines).toBe(MAX_TERMINAL_SCROLLBACK_LINES);
|
||||||
|
expect(cfg.tmuxHistoryLimit).toBe(MAX_TERMINAL_SCROLLBACK_LINES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the buffer byte caps to their MIN/MAX bounds', () => {
|
||||||
|
const tooSmall = resolveTerminalHistoryConfig({ terminalBufferMaxBytes: 1 });
|
||||||
|
expect(tooSmall.terminalBufferMaxBytes).toBe(MIN_TERMINAL_BUFFER_BYTES);
|
||||||
|
|
||||||
|
const tooLarge = resolveTerminalHistoryConfig({ terminalBufferMaxBytes: 1024 * 1024 * 1024 });
|
||||||
|
expect(tooLarge.terminalBufferMaxBytes).toBe(MAX_TERMINAL_BUFFER_BYTES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps trim <= max (trim is capped to the resolved max)', () => {
|
||||||
|
const cfg = resolveTerminalHistoryConfig({
|
||||||
|
terminalBufferMaxBytes: 4 * 1024 * 1024,
|
||||||
|
terminalBufferTrimBytes: 64 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
expect(cfg.terminalBufferTrimBytes).toBeLessThanOrEqual(cfg.terminalBufferMaxBytes);
|
||||||
|
expect(cfg.terminalBufferTrimBytes).toBe(4 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the default trim to a lowered max', () => {
|
||||||
|
// Default trim (1.5MB) exceeds a 1MB max → trim must fall to the max.
|
||||||
|
const cfg = resolveTerminalHistoryConfig({ terminalBufferMaxBytes: 1 * 1024 * 1024 });
|
||||||
|
expect(cfg.terminalBufferTrimBytes).toBe(1 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates fractional inputs to integers', () => {
|
||||||
|
const cfg = resolveTerminalHistoryConfig({ terminalScrollbackLines: 12_345.9 });
|
||||||
|
expect(cfg.terminalScrollbackLines).toBe(12_345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to defaults for non-number / non-finite inputs', () => {
|
||||||
|
const cfg = resolveTerminalHistoryConfig({
|
||||||
|
terminalScrollbackLines: 'lots' as unknown as number,
|
||||||
|
tmuxHistoryLimit: NaN,
|
||||||
|
terminalBufferMaxBytes: null as unknown as number,
|
||||||
|
terminalBufferTrimBytes: Infinity,
|
||||||
|
});
|
||||||
|
expect(cfg.terminalScrollbackLines).toBe(DEFAULT_TERMINAL_SCROLLBACK_LINES);
|
||||||
|
expect(cfg.tmuxHistoryLimit).toBe(DEFAULT_TMUX_HISTORY_LIMIT);
|
||||||
|
expect(cfg.terminalBufferMaxBytes).toBe(DEFAULT_TERMINAL_BUFFER_MAX_BYTES);
|
||||||
|
// trim default is min(DEFAULT_TRIM, resolvedMax) — here resolvedMax is the default max.
|
||||||
|
expect(cfg.terminalBufferTrimBytes).toBe(DEFAULT_TERMINAL_BUFFER_TRIM_BYTES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses an empty object when called with no argument', () => {
|
||||||
|
expect(resolveTerminalHistoryConfig().tmuxHistoryLimit).toBe(DEFAULT_TMUX_HISTORY_LIMIT);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user