import { getAgentDir } from '@earendil-works/pi-coding-agent'; import { readFileSync } from 'node:fs'; import { isAbsolute, join } from 'node:path'; import { parse, type ParseError, printParseErrorCode } from 'jsonc-parser'; import type { TmuxAction, TmuxBashConfig } from './types.js'; const ALL_ACTIONS: readonly TmuxAction[] = [ 'list', 'peek', 'kill', 'poll', 'unpoll', 'list-polls', 'await', 'unawait', 'attach', 'send-input', 'send-key', 'cleanup-preview', 'cleanup', ]; const DEFAULT_ACTIONS: readonly TmuxAction[] = ALL_ACTIONS.filter( (action) => action !== 'send-input' && action !== 'send-key', ); export const DEFAULT_TMUX_BASH_CONFIG: Readonly = { bashToolName: 'bash', tmuxToolName: 'tmux', defaultTimeoutSeconds: 120, maxTimeoutSeconds: 86_400, defaultTimeoutAction: 'background', foregroundUpdateIntervalMs: 1_000, tmuxBinary: 'tmux', tmuxSessionScope: 'workspace', globalTmuxSessionName: 'pi-tmux-bash', gitRootTmuxSessionNameTemplate: 'pi-{gitHash}', cwdTmuxSessionNameTemplate: 'pi-cwd-{scopeHash}', tmuxWindowScope: 'pi-session', tmuxWindowNameTemplate: '{name}-{runId}', maxTmuxWindowNameLength: 64, autoCloseWindowsOnCompletion: true, defaultPollIntervalSeconds: 30, minimumModelPollIntervalSeconds: 15, pollDelivery: 'display', maxOutputBytes: 50 * 1024, maxSpoolBytes: 10 * 1024 * 1024, foregroundContextLines: 2_000, completionContextLines: 20, pollContextLines: 80, peekContextLines: 200, completedCompactDisplayLines: 5, completedExpandedDisplayLines: 20, completionDeliveryMaxAttempts: 5, completionDeliveryRetryBaseMs: 250, outputDir: '', preserveOutputFiles: false, environmentDenylist: ['TMUX', 'TMUX_PANE', 'PWD', 'OLDPWD', 'SHLVL', '_'], defaultWaitForBackgroundCompletion: false, defaultWaitAfterForegroundTimeout: true, enabledTmuxActions: [...DEFAULT_ACTIONS], systemPrompt: true, statusbarEnabled: true, adoptionPolicy: 'off', adoptionScanTimeoutMs: 5_000, adoptPolling: true, durableOutputDir: join(getAgentDir(), 'tmux-bash'), interactiveInputEnabled: false, maxInputBytes: 16 * 1024, routeUserBash: false, defaultCompletionDelivery: 'model', maxConcurrentRuns: 16, maxArtifactBytesPerRun: 10 * 1024 * 1024, maxArtifactBytesTotal: 1024 * 1024 * 1024, maxCompletedRuns: 100, completedArtifactRetentionSeconds: 24 * 60 * 60, resourceScanIntervalSeconds: 60, quotaPolicy: 'reject-new', nonGitScope: 'error', }; export interface LoadTmuxBashConfigOptions { path?: string; env?: NodeJS.ProcessEnv; } export function loadTmuxBashConfig(options: LoadTmuxBashConfigOptions = {}): TmuxBashConfig { const env = options.env ?? process.env; const configPath = options.path ?? env.PI_TMUX_BASH_CONFIG ?? join(getAgentDir(), 'tmux-bash.jsonc'); let input: unknown = {}; try { const errors: ParseError[] = []; input = parse(readFileSync(configPath, 'utf8'), errors, { allowTrailingComma: true, disallowComments: false, }); if (errors.length > 0) { const first = errors[0]; throw new Error(`${printParseErrorCode(first?.error ?? 0)} at byte ${first?.offset ?? 0}`); } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw new Error(`Invalid tmux-bash config ${configPath}: ${errorMessage(error)}`); } } if (!isRecord(input)) { throw new Error(`Invalid tmux-bash config ${configPath}: expected a JSON object.`); } return validateTmuxBashConfig(normalizeConfigInput(input)); } export function validateTmuxBashConfig(input: Record): TmuxBashConfig { const config = normalizeConfigInput(input) as unknown as TmuxBashConfig; requireToolName(config.bashToolName, 'bashToolName'); requireToolName(config.tmuxToolName, 'tmuxToolName'); requireInteger(config.defaultTimeoutSeconds, 'defaultTimeoutSeconds', 1); requireInteger(config.maxTimeoutSeconds, 'maxTimeoutSeconds', config.defaultTimeoutSeconds); requireEnum(config.defaultTimeoutAction, 'defaultTimeoutAction', ['background', 'kill']); requireInteger(config.foregroundUpdateIntervalMs, 'foregroundUpdateIntervalMs', 50); requireBinary(config.tmuxBinary); requireEnum(config.tmuxSessionScope, 'tmuxSessionScope', ['global', 'workspace']); requireTemplate(config.globalTmuxSessionName, 'globalTmuxSessionName'); requireTemplate(config.gitRootTmuxSessionNameTemplate, 'gitRootTmuxSessionNameTemplate'); requireTemplate(config.cwdTmuxSessionNameTemplate, 'cwdTmuxSessionNameTemplate'); requireEnum(config.tmuxWindowScope, 'tmuxWindowScope', ['pi-session', 'workspace', 'all']); requireTemplate(config.tmuxWindowNameTemplate, 'tmuxWindowNameTemplate'); requireInteger(config.maxTmuxWindowNameLength, 'maxTmuxWindowNameLength', 8, 200); requireBoolean(config.autoCloseWindowsOnCompletion, 'autoCloseWindowsOnCompletion'); requireInteger(config.defaultPollIntervalSeconds, 'defaultPollIntervalSeconds', 1); requireInteger(config.minimumModelPollIntervalSeconds, 'minimumModelPollIntervalSeconds', 1); requireEnum(config.pollDelivery, 'pollDelivery', ['model', 'display']); requireInteger(config.maxOutputBytes, 'maxOutputBytes', 1_024, 10 * 1024 * 1024); requireInteger(config.maxSpoolBytes, 'maxSpoolBytes', 1_024, 1024 * 1024 * 1024); for (const key of [ 'foregroundContextLines', 'completionContextLines', 'pollContextLines', 'peekContextLines', ] as const) { requireInteger(config[key], key, 1, 10_000); } requireInteger(config.completedCompactDisplayLines, 'completedCompactDisplayLines', 3, 10_000); requireInteger(config.completedExpandedDisplayLines, 'completedExpandedDisplayLines', 3, 10_000); requireInteger(config.completionDeliveryMaxAttempts, 'completionDeliveryMaxAttempts', 1, 20); requireInteger(config.completionDeliveryRetryBaseMs, 'completionDeliveryRetryBaseMs', 10, 60_000); requireAbsoluteOrEmptyPath(config.outputDir, 'outputDir'); requireBoolean(config.preserveOutputFiles, 'preserveOutputFiles'); if (!Array.isArray(config.environmentDenylist) || !config.environmentDenylist.every(isEnvName)) { throw new Error( 'tmux-bash config environmentDenylist must contain environment variable names.', ); } requireBoolean(config.defaultWaitForBackgroundCompletion, 'defaultWaitForBackgroundCompletion'); requireBoolean(config.defaultWaitAfterForegroundTimeout, 'defaultWaitAfterForegroundTimeout'); if ( !Array.isArray(config.enabledTmuxActions) || config.enabledTmuxActions.length === 0 || !config.enabledTmuxActions.every((action) => ALL_ACTIONS.includes(action)) ) { throw new Error(`tmux-bash config enabledTmuxActions must use: ${ALL_ACTIONS.join(', ')}.`); } requireBoolean(config.systemPrompt, 'systemPrompt'); requireBoolean(config.statusbarEnabled, 'statusbarEnabled'); requireEnum(config.adoptionPolicy, 'adoptionPolicy', ['off', 'same-pi-session']); requireInteger(config.adoptionScanTimeoutMs, 'adoptionScanTimeoutMs', 100, 60_000); requireBoolean(config.adoptPolling, 'adoptPolling'); requireAbsolutePath(config.durableOutputDir, 'durableOutputDir'); requireBoolean(config.interactiveInputEnabled, 'interactiveInputEnabled'); requireInteger(config.maxInputBytes, 'maxInputBytes', 1, 1024 * 1024); requireBoolean(config.routeUserBash, 'routeUserBash'); requireEnum(config.defaultCompletionDelivery, 'defaultCompletionDelivery', [ 'model', 'display', 'next-turn', ]); requireInteger(config.maxConcurrentRuns, 'maxConcurrentRuns', 1, 10_000); requireInteger( config.maxArtifactBytesPerRun, 'maxArtifactBytesPerRun', 1_024, 1024 * 1024 * 1024, ); requireInteger( config.maxArtifactBytesTotal, 'maxArtifactBytesTotal', config.maxArtifactBytesPerRun, ); requireInteger(config.maxCompletedRuns, 'maxCompletedRuns', 1, 1_000_000); requireInteger(config.completedArtifactRetentionSeconds, 'completedArtifactRetentionSeconds', 0); requireInteger(config.resourceScanIntervalSeconds, 'resourceScanIntervalSeconds', 1, 86_400); requireEnum(config.quotaPolicy, 'quotaPolicy', ['reject-new', 'cleanup-completed']); requireEnum(config.nonGitScope, 'nonGitScope', ['error', 'cwd']); const enabledTmuxActions = [...new Set(config.enabledTmuxActions)].filter( (action) => config.interactiveInputEnabled || (action !== 'send-input' && action !== 'send-key'), ); return { ...config, maxSpoolBytes: config.maxArtifactBytesPerRun, environmentDenylist: [...new Set(config.environmentDenylist)], enabledTmuxActions, }; } export function clampTimeout(config: TmuxBashConfig, timeout?: number): number { if (timeout === undefined || !Number.isFinite(timeout)) return config.defaultTimeoutSeconds; return Math.min(config.maxTimeoutSeconds, Math.max(1, Math.floor(timeout))); } export function clampPollInterval(config: TmuxBashConfig, interval?: number): number { const requested = interval ?? config.defaultPollIntervalSeconds; const minimum = config.pollDelivery === 'model' ? config.minimumModelPollIntervalSeconds : 1; return Math.min(config.maxTimeoutSeconds, Math.max(minimum, Math.floor(requested))); } function normalizeConfigInput(input: Record): Record { const merged: Record = { ...DEFAULT_TMUX_BASH_CONFIG, ...input }; if (merged.tmuxSessionScope === 'git-root') merged.tmuxSessionScope = 'workspace'; if (merged.tmuxWindowScope === 'git-root') merged.tmuxWindowScope = 'workspace'; if (input.maxArtifactBytesPerRun === undefined && input.maxSpoolBytes !== undefined) { merged.maxArtifactBytesPerRun = input.maxSpoolBytes; } return merged; } function requireToolName(value: unknown, key: string): asserts value is string { if (typeof value !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value)) { throw new Error(`tmux-bash config ${key} must be a valid tool name.`); } } function requireTemplate(value: unknown, key: string): asserts value is string { if (typeof value !== 'string' || !value.trim() || value.includes('\0')) { throw new Error(`tmux-bash config ${key} must be a non-empty string without NUL bytes.`); } } function requireBinary(value: unknown): asserts value is string { requireTemplate(value, 'tmuxBinary'); if (value.includes('/') && !isAbsolute(value)) { throw new Error('tmux-bash config tmuxBinary must be a command name or absolute path.'); } } function requireAbsoluteOrEmptyPath(value: unknown, key: string): asserts value is string { if (typeof value !== 'string' || value.includes('\0') || (value && !isAbsolute(value))) { throw new Error(`tmux-bash config ${key} must be empty or an absolute path without NUL bytes.`); } } function requireAbsolutePath(value: unknown, key: string): asserts value is string { requireAbsoluteOrEmptyPath(value, key); if (!value) throw new Error(`tmux-bash config ${key} must be an absolute path.`); } function requireInteger( value: unknown, key: string, minimum: number, maximum = Number.MAX_SAFE_INTEGER, ): asserts value is number { if (!Number.isInteger(value) || (value as number) < minimum || (value as number) > maximum) { throw new Error(`tmux-bash config ${key} must be an integer from ${minimum} to ${maximum}.`); } } function requireBoolean(value: unknown, key: string): asserts value is boolean { if (typeof value !== 'boolean') throw new Error(`tmux-bash config ${key} must be boolean.`); } function requireEnum( value: unknown, key: string, allowed: readonly T[], ): asserts value is T { if (typeof value !== 'string' || !allowed.includes(value as T)) { throw new Error(`tmux-bash config ${key} must be one of: ${allowed.join(', ')}.`); } } function isEnvName(value: unknown): value is string { return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); }