/ utils / asciicast.ts
asciicast.ts
  1  import { appendFile, rename } from 'fs/promises'
  2  import { basename, dirname, join } from 'path'
  3  import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'
  4  import { createBufferedWriter } from './bufferedWriter.js'
  5  import { registerCleanup } from './cleanupRegistry.js'
  6  import { logForDebugging } from './debug.js'
  7  import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
  8  import { getFsImplementation } from './fsOperations.js'
  9  import { sanitizePath } from './path.js'
 10  import { jsonStringify } from './slowOperations.js'
 11  
 12  // Mutable recording state — filePath is updated when session ID changes (e.g., --resume)
 13  const 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   */
 24  export 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  
 46  export 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   */
 55  export 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   */
 82  export 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  
111  type AsciicastRecorder = {
112    flush(): Promise<void>
113    dispose(): Promise<void>
114  }
115  
116  let recorder: AsciicastRecorder | null = null
117  
118  function 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   */
131  export 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   */
140  export 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  }