import { appendFile, rename } from 'fs/promises' import { basename, dirname, join } from 'path' import { getOriginalCwd, getSessionId } from '../bootstrap/state.js' import { createBufferedWriter } from './bufferedWriter.js' import { registerCleanup } from './cleanupRegistry.js' import { logForDebugging } from './debug.js' import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' import { getFsImplementation } from './fsOperations.js' import { sanitizePath } from './path.js' import { jsonStringify } from './slowOperations.js' // Mutable recording state — filePath is updated when session ID changes (e.g., --resume) const recordingState: { filePath: string | null; timestamp: number } = { filePath: null, timestamp: 0, } /** * Get the asciicast recording file path. * For ants with CLAUDE_CODE_TERMINAL_RECORDING=1: returns a path. * Otherwise: returns null. * The path is computed once and cached in recordingState. */ export function getRecordFilePath(): string | null { if (recordingState.filePath !== null) { return recordingState.filePath } if (process.env.USER_TYPE !== 'ant') { return null } if (!isEnvTruthy(process.env.CLAUDE_CODE_TERMINAL_RECORDING)) { return null } // Record alongside the transcript. // Each launch gets its own file so --continue produces multiple recordings. const projectsDir = join(getClaudeConfigHomeDir(), 'projects') const projectDir = join(projectsDir, sanitizePath(getOriginalCwd())) recordingState.timestamp = Date.now() recordingState.filePath = join( projectDir, `${getSessionId()}-${recordingState.timestamp}.cast`, ) return recordingState.filePath } export function _resetRecordingStateForTesting(): void { recordingState.filePath = null recordingState.timestamp = 0 } /** * Find all .cast files for the current session. * Returns paths sorted by filename (chronological by timestamp suffix). */ export function getSessionRecordingPaths(): string[] { const sessionId = getSessionId() const projectsDir = join(getClaudeConfigHomeDir(), 'projects') const projectDir = join(projectsDir, sanitizePath(getOriginalCwd())) try { // eslint-disable-next-line custom-rules/no-sync-fs -- called during /share before upload, not in hot path const entries = getFsImplementation().readdirSync(projectDir) const names = ( typeof entries[0] === 'string' ? entries : (entries as { name: string }[]).map(e => e.name) ) as string[] const files = names .filter(f => f.startsWith(sessionId) && f.endsWith('.cast')) .sort() return files.map(f => join(projectDir, f)) } catch { return [] } } /** * Rename the recording file to match the current session ID. * Called after --resume/--continue changes the session ID via switchSession(). * The recorder was installed with the initial (random) session ID; this renames * the file so getSessionRecordingPaths() can find it by the resumed session ID. */ export async function renameRecordingForSession(): Promise { const oldPath = recordingState.filePath if (!oldPath || recordingState.timestamp === 0) { return } const projectsDir = join(getClaudeConfigHomeDir(), 'projects') const projectDir = join(projectsDir, sanitizePath(getOriginalCwd())) const newPath = join( projectDir, `${getSessionId()}-${recordingState.timestamp}.cast`, ) if (oldPath === newPath) { return } // Flush pending writes before renaming await recorder?.flush() const oldName = basename(oldPath) const newName = basename(newPath) try { await rename(oldPath, newPath) recordingState.filePath = newPath logForDebugging(`[asciicast] Renamed recording: ${oldName} → ${newName}`) } catch { logForDebugging( `[asciicast] Failed to rename recording from ${oldName} to ${newName}`, ) } } type AsciicastRecorder = { flush(): Promise dispose(): Promise } let recorder: AsciicastRecorder | null = null function getTerminalSize(): { cols: number; rows: number } { // Direct access to stdout dimensions — not in a React component // eslint-disable-next-line custom-rules/prefer-use-terminal-size const cols = process.stdout.columns || 80 // eslint-disable-next-line custom-rules/prefer-use-terminal-size const rows = process.stdout.rows || 24 return { cols, rows } } /** * Flush pending recording data to disk. * Call before reading the .cast file (e.g., during /share). */ export async function flushAsciicastRecorder(): Promise { await recorder?.flush() } /** * Install the asciicast recorder. * Wraps process.stdout.write to capture all terminal output with timestamps. * Must be called before Ink mounts. */ export function installAsciicastRecorder(): void { const filePath = getRecordFilePath() if (!filePath) { return } const { cols, rows } = getTerminalSize() const startTime = performance.now() // Write the asciicast v2 header const header = jsonStringify({ version: 2, width: cols, height: rows, timestamp: Math.floor(Date.now() / 1000), env: { SHELL: process.env.SHELL || '', TERM: process.env.TERM || '', }, }) try { // eslint-disable-next-line custom-rules/no-sync-fs -- one-time init before Ink mounts getFsImplementation().mkdirSync(dirname(filePath)) } catch { // Directory may already exist } // eslint-disable-next-line custom-rules/no-sync-fs -- one-time init before Ink mounts getFsImplementation().appendFileSync(filePath, header + '\n', { mode: 0o600 }) let pendingWrite: Promise = Promise.resolve() const writer = createBufferedWriter({ writeFn(content: string) { // Use recordingState.filePath (mutable) so writes follow renames from --resume const currentPath = recordingState.filePath if (!currentPath) { return } pendingWrite = pendingWrite .then(() => appendFile(currentPath, content)) .catch(() => { // Silently ignore write errors — don't break the session }) }, flushIntervalMs: 500, maxBufferSize: 50, maxBufferBytes: 10 * 1024 * 1024, // 10MB }) // Wrap process.stdout.write to capture output const originalWrite = process.stdout.write.bind( process.stdout, ) as typeof process.stdout.write process.stdout.write = function ( chunk: string | Uint8Array, encodingOrCb?: BufferEncoding | ((err?: Error) => void), cb?: (err?: Error) => void, ): boolean { // Record the output event const elapsed = (performance.now() - startTime) / 1000 const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8') writer.write(jsonStringify([elapsed, 'o', text]) + '\n') // Pass through to the real stdout if (typeof encodingOrCb === 'function') { return originalWrite(chunk, encodingOrCb) } return originalWrite(chunk, encodingOrCb, cb) } as typeof process.stdout.write // Handle terminal resize events function onResize(): void { const elapsed = (performance.now() - startTime) / 1000 const { cols: newCols, rows: newRows } = getTerminalSize() writer.write(jsonStringify([elapsed, 'r', `${newCols}x${newRows}`]) + '\n') } process.stdout.on('resize', onResize) recorder = { async flush(): Promise { writer.flush() await pendingWrite }, async dispose(): Promise { writer.dispose() await pendingWrite process.stdout.removeListener('resize', onResize) process.stdout.write = originalWrite }, } registerCleanup(async () => { await recorder?.dispose() recorder = null }) logForDebugging(`[asciicast] Recording to ${filePath}`) }