source dump of claude code
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 239 lines 7.8 kB view raw
1import { appendFile, rename } from 'fs/promises' 2import { basename, dirname, join } from 'path' 3import { getOriginalCwd, getSessionId } from '../bootstrap/state.js' 4import { createBufferedWriter } from './bufferedWriter.js' 5import { registerCleanup } from './cleanupRegistry.js' 6import { logForDebugging } from './debug.js' 7import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' 8import { getFsImplementation } from './fsOperations.js' 9import { sanitizePath } from './path.js' 10import { jsonStringify } from './slowOperations.js' 11 12// Mutable recording state — filePath is updated when session ID changes (e.g., --resume) 13const recordingState: { filePath: string | null; timestamp: number } = { 14 filePath: null, 15 timestamp: 0, 16} 17 18/** 19 * Get the asciicast recording file path. 20 * For ants with CLAUDE_CODE_TERMINAL_RECORDING=1: returns a path. 21 * Otherwise: returns null. 22 * The path is computed once and cached in recordingState. 23 */ 24export function getRecordFilePath(): string | null { 25 if (recordingState.filePath !== null) { 26 return recordingState.filePath 27 } 28 if (process.env.USER_TYPE !== 'ant') { 29 return null 30 } 31 if (!isEnvTruthy(process.env.CLAUDE_CODE_TERMINAL_RECORDING)) { 32 return null 33 } 34 // Record alongside the transcript. 35 // Each launch gets its own file so --continue produces multiple recordings. 36 const projectsDir = join(getClaudeConfigHomeDir(), 'projects') 37 const projectDir = join(projectsDir, sanitizePath(getOriginalCwd())) 38 recordingState.timestamp = Date.now() 39 recordingState.filePath = join( 40 projectDir, 41 `${getSessionId()}-${recordingState.timestamp}.cast`, 42 ) 43 return recordingState.filePath 44} 45 46export function _resetRecordingStateForTesting(): void { 47 recordingState.filePath = null 48 recordingState.timestamp = 0 49} 50 51/** 52 * Find all .cast files for the current session. 53 * Returns paths sorted by filename (chronological by timestamp suffix). 54 */ 55export function getSessionRecordingPaths(): string[] { 56 const sessionId = getSessionId() 57 const projectsDir = join(getClaudeConfigHomeDir(), 'projects') 58 const projectDir = join(projectsDir, sanitizePath(getOriginalCwd())) 59 try { 60 // eslint-disable-next-line custom-rules/no-sync-fs -- called during /share before upload, not in hot path 61 const entries = getFsImplementation().readdirSync(projectDir) 62 const names = ( 63 typeof entries[0] === 'string' 64 ? entries 65 : (entries as { name: string }[]).map(e => e.name) 66 ) as string[] 67 const files = names 68 .filter(f => f.startsWith(sessionId) && f.endsWith('.cast')) 69 .sort() 70 return files.map(f => join(projectDir, f)) 71 } catch { 72 return [] 73 } 74} 75 76/** 77 * Rename the recording file to match the current session ID. 78 * Called after --resume/--continue changes the session ID via switchSession(). 79 * The recorder was installed with the initial (random) session ID; this renames 80 * the file so getSessionRecordingPaths() can find it by the resumed session ID. 81 */ 82export async function renameRecordingForSession(): Promise<void> { 83 const oldPath = recordingState.filePath 84 if (!oldPath || recordingState.timestamp === 0) { 85 return 86 } 87 const projectsDir = join(getClaudeConfigHomeDir(), 'projects') 88 const projectDir = join(projectsDir, sanitizePath(getOriginalCwd())) 89 const newPath = join( 90 projectDir, 91 `${getSessionId()}-${recordingState.timestamp}.cast`, 92 ) 93 if (oldPath === newPath) { 94 return 95 } 96 // Flush pending writes before renaming 97 await recorder?.flush() 98 const oldName = basename(oldPath) 99 const newName = basename(newPath) 100 try { 101 await rename(oldPath, newPath) 102 recordingState.filePath = newPath 103 logForDebugging(`[asciicast] Renamed recording: ${oldName}${newName}`) 104 } catch { 105 logForDebugging( 106 `[asciicast] Failed to rename recording from ${oldName} to ${newName}`, 107 ) 108 } 109} 110 111type AsciicastRecorder = { 112 flush(): Promise<void> 113 dispose(): Promise<void> 114} 115 116let recorder: AsciicastRecorder | null = null 117 118function getTerminalSize(): { cols: number; rows: number } { 119 // Direct access to stdout dimensions — not in a React component 120 // eslint-disable-next-line custom-rules/prefer-use-terminal-size 121 const cols = process.stdout.columns || 80 122 // eslint-disable-next-line custom-rules/prefer-use-terminal-size 123 const rows = process.stdout.rows || 24 124 return { cols, rows } 125} 126 127/** 128 * Flush pending recording data to disk. 129 * Call before reading the .cast file (e.g., during /share). 130 */ 131export async function flushAsciicastRecorder(): Promise<void> { 132 await recorder?.flush() 133} 134 135/** 136 * Install the asciicast recorder. 137 * Wraps process.stdout.write to capture all terminal output with timestamps. 138 * Must be called before Ink mounts. 139 */ 140export function installAsciicastRecorder(): void { 141 const filePath = getRecordFilePath() 142 if (!filePath) { 143 return 144 } 145 146 const { cols, rows } = getTerminalSize() 147 const startTime = performance.now() 148 149 // Write the asciicast v2 header 150 const header = jsonStringify({ 151 version: 2, 152 width: cols, 153 height: rows, 154 timestamp: Math.floor(Date.now() / 1000), 155 env: { 156 SHELL: process.env.SHELL || '', 157 TERM: process.env.TERM || '', 158 }, 159 }) 160 161 try { 162 // eslint-disable-next-line custom-rules/no-sync-fs -- one-time init before Ink mounts 163 getFsImplementation().mkdirSync(dirname(filePath)) 164 } catch { 165 // Directory may already exist 166 } 167 // eslint-disable-next-line custom-rules/no-sync-fs -- one-time init before Ink mounts 168 getFsImplementation().appendFileSync(filePath, header + '\n', { mode: 0o600 }) 169 170 let pendingWrite: Promise<void> = Promise.resolve() 171 172 const writer = createBufferedWriter({ 173 writeFn(content: string) { 174 // Use recordingState.filePath (mutable) so writes follow renames from --resume 175 const currentPath = recordingState.filePath 176 if (!currentPath) { 177 return 178 } 179 pendingWrite = pendingWrite 180 .then(() => appendFile(currentPath, content)) 181 .catch(() => { 182 // Silently ignore write errors — don't break the session 183 }) 184 }, 185 flushIntervalMs: 500, 186 maxBufferSize: 50, 187 maxBufferBytes: 10 * 1024 * 1024, // 10MB 188 }) 189 190 // Wrap process.stdout.write to capture output 191 const originalWrite = process.stdout.write.bind( 192 process.stdout, 193 ) as typeof process.stdout.write 194 process.stdout.write = function ( 195 chunk: string | Uint8Array, 196 encodingOrCb?: BufferEncoding | ((err?: Error) => void), 197 cb?: (err?: Error) => void, 198 ): boolean { 199 // Record the output event 200 const elapsed = (performance.now() - startTime) / 1000 201 const text = 202 typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8') 203 writer.write(jsonStringify([elapsed, 'o', text]) + '\n') 204 205 // Pass through to the real stdout 206 if (typeof encodingOrCb === 'function') { 207 return originalWrite(chunk, encodingOrCb) 208 } 209 return originalWrite(chunk, encodingOrCb, cb) 210 } as typeof process.stdout.write 211 212 // Handle terminal resize events 213 function onResize(): void { 214 const elapsed = (performance.now() - startTime) / 1000 215 const { cols: newCols, rows: newRows } = getTerminalSize() 216 writer.write(jsonStringify([elapsed, 'r', `${newCols}x${newRows}`]) + '\n') 217 } 218 process.stdout.on('resize', onResize) 219 220 recorder = { 221 async flush(): Promise<void> { 222 writer.flush() 223 await pendingWrite 224 }, 225 async dispose(): Promise<void> { 226 writer.dispose() 227 await pendingWrite 228 process.stdout.removeListener('resize', onResize) 229 process.stdout.write = originalWrite 230 }, 231 } 232 233 registerCleanup(async () => { 234 await recorder?.dispose() 235 recorder = null 236 }) 237 238 logForDebugging(`[asciicast] Recording to ${filePath}`) 239}