asciicast.ts 7.6 KB

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