source dump of claude code
at main 770 lines 24 kB view raw
1import * as fs from 'fs' 2import { 3 mkdir as mkdirPromise, 4 open, 5 readdir as readdirPromise, 6 readFile as readFilePromise, 7 rename as renamePromise, 8 rmdir as rmdirPromise, 9 rm as rmPromise, 10 stat as statPromise, 11 unlink as unlinkPromise, 12} from 'fs/promises' 13import { homedir } from 'os' 14import * as nodePath from 'path' 15import { getErrnoCode } from './errors.js' 16import { slowLogging } from './slowOperations.js' 17 18/** 19 * Simplified filesystem operations interface based on Node.js fs module. 20 * Provides a subset of commonly used sync operations with type safety. 21 * Allows abstraction for alternative implementations (e.g., mock, virtual). 22 */ 23export type FsOperations = { 24 // File access and information operations 25 /** Gets the current working directory */ 26 cwd(): string 27 /** Checks if a file or directory exists */ 28 existsSync(path: string): boolean 29 /** Gets file stats asynchronously */ 30 stat(path: string): Promise<fs.Stats> 31 /** Lists directory contents with file type information asynchronously */ 32 readdir(path: string): Promise<fs.Dirent[]> 33 /** Deletes file asynchronously */ 34 unlink(path: string): Promise<void> 35 /** Removes an empty directory asynchronously */ 36 rmdir(path: string): Promise<void> 37 /** Removes files and directories asynchronously (with recursive option) */ 38 rm( 39 path: string, 40 options?: { recursive?: boolean; force?: boolean }, 41 ): Promise<void> 42 /** Creates directory recursively asynchronously. */ 43 mkdir(path: string, options?: { mode?: number }): Promise<void> 44 /** Reads file content as string asynchronously */ 45 readFile(path: string, options: { encoding: BufferEncoding }): Promise<string> 46 /** Renames/moves file asynchronously */ 47 rename(oldPath: string, newPath: string): Promise<void> 48 /** Gets file stats */ 49 statSync(path: string): fs.Stats 50 /** Gets file stats without following symlinks */ 51 lstatSync(path: string): fs.Stats 52 53 // File content operations 54 /** Reads file content as string with specified encoding */ 55 readFileSync( 56 path: string, 57 options: { 58 encoding: BufferEncoding 59 }, 60 ): string 61 /** Reads raw file bytes as Buffer */ 62 readFileBytesSync(path: string): Buffer 63 /** Reads specified number of bytes from file start */ 64 readSync( 65 path: string, 66 options: { 67 length: number 68 }, 69 ): { 70 buffer: Buffer 71 bytesRead: number 72 } 73 /** Appends string to file */ 74 appendFileSync(path: string, data: string, options?: { mode?: number }): void 75 /** Copies file from source to destination */ 76 copyFileSync(src: string, dest: string): void 77 /** Deletes file */ 78 unlinkSync(path: string): void 79 /** Renames/moves file */ 80 renameSync(oldPath: string, newPath: string): void 81 /** Creates hard link */ 82 linkSync(target: string, path: string): void 83 /** Creates symbolic link */ 84 symlinkSync( 85 target: string, 86 path: string, 87 type?: 'dir' | 'file' | 'junction', 88 ): void 89 /** Reads symbolic link */ 90 readlinkSync(path: string): string 91 /** Resolves symbolic links and returns the canonical pathname */ 92 realpathSync(path: string): string 93 94 // Directory operations 95 /** Creates directory recursively. Mode defaults to 0o777 & ~umask if not specified. */ 96 mkdirSync( 97 path: string, 98 options?: { 99 mode?: number 100 }, 101 ): void 102 /** Lists directory contents with file type information */ 103 readdirSync(path: string): fs.Dirent[] 104 /** Lists directory contents as strings */ 105 readdirStringSync(path: string): string[] 106 /** Checks if the directory is empty */ 107 isDirEmptySync(path: string): boolean 108 /** Removes an empty directory */ 109 rmdirSync(path: string): void 110 /** Removes files and directories (with recursive option) */ 111 rmSync( 112 path: string, 113 options?: { 114 recursive?: boolean 115 force?: boolean 116 }, 117 ): void 118 /** Create a writable stream for writing data to a file. */ 119 createWriteStream(path: string): fs.WriteStream 120 /** Reads raw file bytes as Buffer asynchronously. 121 * When maxBytes is set, only reads up to that many bytes. */ 122 readFileBytes(path: string, maxBytes?: number): Promise<Buffer> 123} 124 125/** 126 * Safely resolves a file path, handling symlinks and errors gracefully. 127 * 128 * Error handling strategy: 129 * - If the file doesn't exist, returns the original path (allows for file creation) 130 * - If symlink resolution fails (broken symlink, permission denied, circular links), 131 * returns the original path and marks it as not a symlink 132 * - This ensures operations can continue with the original path rather than failing 133 * 134 * @param fs The filesystem implementation to use 135 * @param filePath The path to resolve 136 * @returns Object containing the resolved path and whether it was a symlink 137 */ 138export function safeResolvePath( 139 fs: FsOperations, 140 filePath: string, 141): { resolvedPath: string; isSymlink: boolean; isCanonical: boolean } { 142 // Block UNC paths before any filesystem access to prevent network 143 // requests (DNS/SMB) during validation on Windows 144 if (filePath.startsWith('//') || filePath.startsWith('\\\\')) { 145 return { resolvedPath: filePath, isSymlink: false, isCanonical: false } 146 } 147 148 try { 149 // Check for special file types (FIFOs, sockets, devices) before calling realpathSync. 150 // realpathSync can block on FIFOs waiting for a writer, causing hangs. 151 // If the file doesn't exist, lstatSync throws ENOENT which the catch 152 // below handles by returning the original path (allows file creation). 153 const stats = fs.lstatSync(filePath) 154 if ( 155 stats.isFIFO() || 156 stats.isSocket() || 157 stats.isCharacterDevice() || 158 stats.isBlockDevice() 159 ) { 160 return { resolvedPath: filePath, isSymlink: false, isCanonical: false } 161 } 162 163 const resolvedPath = fs.realpathSync(filePath) 164 return { 165 resolvedPath, 166 isSymlink: resolvedPath !== filePath, 167 // realpathSync returned: resolvedPath is canonical (all symlinks in 168 // all path components resolved). Callers can skip further symlink 169 // resolution on this path. 170 isCanonical: true, 171 } 172 } catch (_error) { 173 // If lstat/realpath fails for any reason (ENOENT, broken symlink, 174 // EACCES, ELOOP, etc.), return the original path to allow operations 175 // to proceed 176 return { resolvedPath: filePath, isSymlink: false, isCanonical: false } 177 } 178} 179 180/** 181 * Check if a file path is a duplicate and should be skipped. 182 * Resolves symlinks to detect duplicates pointing to the same file. 183 * If not a duplicate, adds the resolved path to loadedPaths. 184 * 185 * @returns true if the file should be skipped (is duplicate) 186 */ 187export function isDuplicatePath( 188 fs: FsOperations, 189 filePath: string, 190 loadedPaths: Set<string>, 191): boolean { 192 const { resolvedPath } = safeResolvePath(fs, filePath) 193 if (loadedPaths.has(resolvedPath)) { 194 return true 195 } 196 loadedPaths.add(resolvedPath) 197 return false 198} 199 200/** 201 * Resolve the deepest existing ancestor of a path via realpathSync, walking 202 * up until it succeeds. Detects dangling symlinks (link entry exists, target 203 * doesn't) via lstat and resolves them via readlink. 204 * 205 * Use when the input path may not exist (new file writes) and you need to 206 * know where the write would ACTUALLY land after the OS follows symlinks. 207 * 208 * Returns the resolved absolute path with non-existent tail segments 209 * rejoined, or undefined if no symlink was found in any existing ancestor 210 * (the path's existing ancestors all resolve to themselves). 211 * 212 * Handles: live parent symlinks, dangling file symlinks, dangling parent 213 * symlinks. Same core algorithm as teamMemPaths.ts:realpathDeepestExisting. 214 */ 215export function resolveDeepestExistingAncestorSync( 216 fs: FsOperations, 217 absolutePath: string, 218): string | undefined { 219 let dir = absolutePath 220 const segments: string[] = [] 221 // Walk up using lstat (cheap, O(1)) to find the first existing component. 222 // lstat does not follow symlinks, so dangling symlinks are detected here. 223 // Only call realpathSync (expensive, O(depth)) once at the end. 224 while (dir !== nodePath.dirname(dir)) { 225 let st: fs.Stats 226 try { 227 st = fs.lstatSync(dir) 228 } catch { 229 // lstat failed: truly non-existent. Walk up. 230 segments.unshift(nodePath.basename(dir)) 231 dir = nodePath.dirname(dir) 232 continue 233 } 234 if (st.isSymbolicLink()) { 235 // Found a symlink (live or dangling). Try realpath first (resolves 236 // chained symlinks); fall back to readlink for dangling symlinks. 237 try { 238 const resolved = fs.realpathSync(dir) 239 return segments.length === 0 240 ? resolved 241 : nodePath.join(resolved, ...segments) 242 } catch { 243 // Dangling: realpath failed but lstat saw the link entry. 244 const target = fs.readlinkSync(dir) 245 const absTarget = nodePath.isAbsolute(target) 246 ? target 247 : nodePath.resolve(nodePath.dirname(dir), target) 248 return segments.length === 0 249 ? absTarget 250 : nodePath.join(absTarget, ...segments) 251 } 252 } 253 // Existing non-symlink component. One realpath call resolves any 254 // symlinks in its ancestors. If none, return undefined (no symlink). 255 try { 256 const resolved = fs.realpathSync(dir) 257 if (resolved !== dir) { 258 return segments.length === 0 259 ? resolved 260 : nodePath.join(resolved, ...segments) 261 } 262 } catch { 263 // realpath can still fail (e.g. EACCES in ancestors). Return 264 // undefined — we can't resolve, and the logical path is already 265 // in pathSet for the caller. 266 } 267 return undefined 268 } 269 return undefined 270} 271 272/** 273 * Gets all paths that should be checked for permissions. 274 * This includes the original path, all intermediate symlink targets in the chain, 275 * and the final resolved path. 276 * 277 * For example, if test.txt -> /etc/passwd -> /private/etc/passwd: 278 * - test.txt (original path) 279 * - /etc/passwd (intermediate symlink target) 280 * - /private/etc/passwd (final resolved path) 281 * 282 * This is important for security: a deny rule for /etc/passwd should block 283 * access even if the file is actually at /private/etc/passwd (as on macOS). 284 * 285 * @param path - The path to check (will be converted to absolute) 286 * @returns An array of absolute paths to check permissions for 287 */ 288export function getPathsForPermissionCheck(inputPath: string): string[] { 289 // Expand tilde notation defensively - tools should do this in getPath(), 290 // but we normalize here as defense in depth for permission checking 291 let path = inputPath 292 if (path === '~') { 293 path = homedir().normalize('NFC') 294 } else if (path.startsWith('~/')) { 295 path = nodePath.join(homedir().normalize('NFC'), path.slice(2)) 296 } 297 298 const pathSet = new Set<string>() 299 const fsImpl = getFsImplementation() 300 301 // Always check the original path 302 pathSet.add(path) 303 304 // Block UNC paths before any filesystem access to prevent network 305 // requests (DNS/SMB) during validation on Windows 306 if (path.startsWith('//') || path.startsWith('\\\\')) { 307 return Array.from(pathSet) 308 } 309 310 // Follow the symlink chain, collecting ALL intermediate targets 311 // This handles cases like: test.txt -> /etc/passwd -> /private/etc/passwd 312 // We want to check all three paths, not just test.txt and /private/etc/passwd 313 try { 314 let currentPath = path 315 const visited = new Set<string>() 316 const maxDepth = 40 // Prevent runaway loops, matches typical SYMLOOP_MAX 317 318 for (let depth = 0; depth < maxDepth; depth++) { 319 // Prevent infinite loops from circular symlinks 320 if (visited.has(currentPath)) { 321 break 322 } 323 visited.add(currentPath) 324 325 if (!fsImpl.existsSync(currentPath)) { 326 // Path doesn't exist (new file case). existsSync follows symlinks, 327 // so this is also reached for DANGLING symlinks (link entry exists, 328 // target doesn't). Resolve symlinks in the path and its ancestors 329 // so permission checks see the real destination. Without this, 330 // `./data -> /etc/cron.d/` (live parent symlink) or 331 // `./evil.txt -> ~/.ssh/authorized_keys2` (dangling file symlink) 332 // would allow writes that escape the working directory. 333 if (currentPath === path) { 334 const resolved = resolveDeepestExistingAncestorSync(fsImpl, path) 335 if (resolved !== undefined) { 336 pathSet.add(resolved) 337 } 338 } 339 break 340 } 341 342 const stats = fsImpl.lstatSync(currentPath) 343 344 // Skip special file types that can cause issues 345 if ( 346 stats.isFIFO() || 347 stats.isSocket() || 348 stats.isCharacterDevice() || 349 stats.isBlockDevice() 350 ) { 351 break 352 } 353 354 if (!stats.isSymbolicLink()) { 355 break 356 } 357 358 // Get the immediate symlink target 359 const target = fsImpl.readlinkSync(currentPath) 360 361 // If target is relative, resolve it relative to the symlink's directory 362 const absoluteTarget = nodePath.isAbsolute(target) 363 ? target 364 : nodePath.resolve(nodePath.dirname(currentPath), target) 365 366 // Add this intermediate target to the set 367 pathSet.add(absoluteTarget) 368 currentPath = absoluteTarget 369 } 370 } catch { 371 // If anything fails during chain traversal, continue with what we have 372 } 373 374 // Also add the final resolved path using realpathSync for completeness 375 // This handles any remaining symlinks in directory components 376 const { resolvedPath, isSymlink } = safeResolvePath(fsImpl, path) 377 if (isSymlink && resolvedPath !== path) { 378 pathSet.add(resolvedPath) 379 } 380 381 return Array.from(pathSet) 382} 383 384export const NodeFsOperations: FsOperations = { 385 cwd() { 386 return process.cwd() 387 }, 388 389 existsSync(fsPath) { 390 using _ = slowLogging`fs.existsSync(${fsPath})` 391 return fs.existsSync(fsPath) 392 }, 393 394 async stat(fsPath) { 395 return statPromise(fsPath) 396 }, 397 398 async readdir(fsPath) { 399 return readdirPromise(fsPath, { withFileTypes: true }) 400 }, 401 402 async unlink(fsPath) { 403 return unlinkPromise(fsPath) 404 }, 405 406 async rmdir(fsPath) { 407 return rmdirPromise(fsPath) 408 }, 409 410 async rm(fsPath, options) { 411 return rmPromise(fsPath, options) 412 }, 413 414 async mkdir(dirPath, options) { 415 try { 416 await mkdirPromise(dirPath, { recursive: true, ...options }) 417 } catch (e) { 418 // Bun/Windows: recursive:true throws EEXIST on directories with the 419 // FILE_ATTRIBUTE_READONLY bit set (Group Policy, OneDrive, desktop.ini). 420 // Bun's directoryExistsAt misclassifies DIRECTORY+READONLY as not-a-dir 421 // (bun-internal src/sys.zig existsAtType). The dir exists; ignore. 422 // https://github.com/anthropics/claude-code/issues/30924 423 if (getErrnoCode(e) !== 'EEXIST') throw e 424 } 425 }, 426 427 async readFile(fsPath, options) { 428 return readFilePromise(fsPath, { encoding: options.encoding }) 429 }, 430 431 async rename(oldPath, newPath) { 432 return renamePromise(oldPath, newPath) 433 }, 434 435 statSync(fsPath) { 436 using _ = slowLogging`fs.statSync(${fsPath})` 437 return fs.statSync(fsPath) 438 }, 439 440 lstatSync(fsPath) { 441 using _ = slowLogging`fs.lstatSync(${fsPath})` 442 return fs.lstatSync(fsPath) 443 }, 444 445 readFileSync(fsPath, options) { 446 using _ = slowLogging`fs.readFileSync(${fsPath})` 447 return fs.readFileSync(fsPath, { encoding: options.encoding }) 448 }, 449 450 readFileBytesSync(fsPath) { 451 using _ = slowLogging`fs.readFileBytesSync(${fsPath})` 452 return fs.readFileSync(fsPath) 453 }, 454 455 readSync(fsPath, options) { 456 using _ = slowLogging`fs.readSync(${fsPath}, ${options.length} bytes)` 457 let fd: number | undefined = undefined 458 try { 459 fd = fs.openSync(fsPath, 'r') 460 const buffer = Buffer.alloc(options.length) 461 const bytesRead = fs.readSync(fd, buffer, 0, options.length, 0) 462 return { buffer, bytesRead } 463 } finally { 464 if (fd) fs.closeSync(fd) 465 } 466 }, 467 468 appendFileSync(path, data, options) { 469 using _ = slowLogging`fs.appendFileSync(${path}, ${data.length} chars)` 470 // For new files with explicit mode, use 'ax' (atomic create-with-mode) to avoid 471 // TOCTOU race between existence check and open. Fall back to normal append if exists. 472 if (options?.mode !== undefined) { 473 try { 474 const fd = fs.openSync(path, 'ax', options.mode) 475 try { 476 fs.appendFileSync(fd, data) 477 } finally { 478 fs.closeSync(fd) 479 } 480 return 481 } catch (e) { 482 if (getErrnoCode(e) !== 'EEXIST') throw e 483 // File exists — fall through to normal append 484 } 485 } 486 fs.appendFileSync(path, data) 487 }, 488 489 copyFileSync(src, dest) { 490 using _ = slowLogging`fs.copyFileSync(${src}${dest})` 491 fs.copyFileSync(src, dest) 492 }, 493 494 unlinkSync(path: string) { 495 using _ = slowLogging`fs.unlinkSync(${path})` 496 fs.unlinkSync(path) 497 }, 498 499 renameSync(oldPath: string, newPath: string) { 500 using _ = slowLogging`fs.renameSync(${oldPath}${newPath})` 501 fs.renameSync(oldPath, newPath) 502 }, 503 504 linkSync(target: string, path: string) { 505 using _ = slowLogging`fs.linkSync(${target}${path})` 506 fs.linkSync(target, path) 507 }, 508 509 symlinkSync( 510 target: string, 511 path: string, 512 type?: 'dir' | 'file' | 'junction', 513 ) { 514 using _ = slowLogging`fs.symlinkSync(${target}${path})` 515 fs.symlinkSync(target, path, type) 516 }, 517 518 readlinkSync(path: string) { 519 using _ = slowLogging`fs.readlinkSync(${path})` 520 return fs.readlinkSync(path) 521 }, 522 523 realpathSync(path: string) { 524 using _ = slowLogging`fs.realpathSync(${path})` 525 return fs.realpathSync(path).normalize('NFC') 526 }, 527 528 mkdirSync(dirPath, options) { 529 using _ = slowLogging`fs.mkdirSync(${dirPath})` 530 const mkdirOptions: { recursive: boolean; mode?: number } = { 531 recursive: true, 532 } 533 if (options?.mode !== undefined) { 534 mkdirOptions.mode = options.mode 535 } 536 try { 537 fs.mkdirSync(dirPath, mkdirOptions) 538 } catch (e) { 539 // Bun/Windows: recursive:true throws EEXIST on directories with the 540 // FILE_ATTRIBUTE_READONLY bit set (Group Policy, OneDrive, desktop.ini). 541 // Bun's directoryExistsAt misclassifies DIRECTORY+READONLY as not-a-dir 542 // (bun-internal src/sys.zig existsAtType). The dir exists; ignore. 543 // https://github.com/anthropics/claude-code/issues/30924 544 if (getErrnoCode(e) !== 'EEXIST') throw e 545 } 546 }, 547 548 readdirSync(dirPath) { 549 using _ = slowLogging`fs.readdirSync(${dirPath})` 550 return fs.readdirSync(dirPath, { withFileTypes: true }) 551 }, 552 553 readdirStringSync(dirPath) { 554 using _ = slowLogging`fs.readdirStringSync(${dirPath})` 555 return fs.readdirSync(dirPath) 556 }, 557 558 isDirEmptySync(dirPath) { 559 using _ = slowLogging`fs.isDirEmptySync(${dirPath})` 560 const files = this.readdirSync(dirPath) 561 return files.length === 0 562 }, 563 564 rmdirSync(dirPath) { 565 using _ = slowLogging`fs.rmdirSync(${dirPath})` 566 fs.rmdirSync(dirPath) 567 }, 568 569 rmSync(path, options) { 570 using _ = slowLogging`fs.rmSync(${path})` 571 fs.rmSync(path, options) 572 }, 573 574 createWriteStream(path: string) { 575 return fs.createWriteStream(path) 576 }, 577 578 async readFileBytes(fsPath: string, maxBytes?: number) { 579 if (maxBytes === undefined) { 580 return readFilePromise(fsPath) 581 } 582 const handle = await open(fsPath, 'r') 583 try { 584 const { size } = await handle.stat() 585 const readSize = Math.min(size, maxBytes) 586 const buffer = Buffer.allocUnsafe(readSize) 587 let offset = 0 588 while (offset < readSize) { 589 const { bytesRead } = await handle.read( 590 buffer, 591 offset, 592 readSize - offset, 593 offset, 594 ) 595 if (bytesRead === 0) break 596 offset += bytesRead 597 } 598 return offset < readSize ? buffer.subarray(0, offset) : buffer 599 } finally { 600 await handle.close() 601 } 602 }, 603} 604 605// The currently active filesystem implementation 606let activeFs: FsOperations = NodeFsOperations 607 608/** 609 * Overrides the filesystem implementation. Note: This function does not 610 * automatically update cwd. 611 * @param implementation The filesystem implementation to use 612 */ 613export function setFsImplementation(implementation: FsOperations): void { 614 activeFs = implementation 615} 616 617/** 618 * Gets the currently active filesystem implementation 619 * @returns The currently active filesystem implementation 620 */ 621export function getFsImplementation(): FsOperations { 622 return activeFs 623} 624 625/** 626 * Resets the filesystem implementation to the default Node.js implementation. 627 * Note: This function does not automatically update cwd. 628 */ 629export function setOriginalFsImplementation(): void { 630 activeFs = NodeFsOperations 631} 632 633export type ReadFileRangeResult = { 634 content: string 635 bytesRead: number 636 bytesTotal: number 637} 638 639/** 640 * Read up to `maxBytes` from a file starting at `offset`. 641 * Returns a flat string from Buffer — no sliced string references to a 642 * larger parent. Returns null if the file is smaller than the offset. 643 */ 644export async function readFileRange( 645 path: string, 646 offset: number, 647 maxBytes: number, 648): Promise<ReadFileRangeResult | null> { 649 await using fh = await open(path, 'r') 650 const size = (await fh.stat()).size 651 if (size <= offset) { 652 return null 653 } 654 const bytesToRead = Math.min(size - offset, maxBytes) 655 const buffer = Buffer.allocUnsafe(bytesToRead) 656 657 let totalRead = 0 658 while (totalRead < bytesToRead) { 659 const { bytesRead } = await fh.read( 660 buffer, 661 totalRead, 662 bytesToRead - totalRead, 663 offset + totalRead, 664 ) 665 if (bytesRead === 0) { 666 break 667 } 668 totalRead += bytesRead 669 } 670 671 return { 672 content: buffer.toString('utf8', 0, totalRead), 673 bytesRead: totalRead, 674 bytesTotal: size, 675 } 676} 677 678/** 679 * Read the last `maxBytes` of a file. 680 * Returns the whole file if it's smaller than maxBytes. 681 */ 682export async function tailFile( 683 path: string, 684 maxBytes: number, 685): Promise<ReadFileRangeResult> { 686 await using fh = await open(path, 'r') 687 const size = (await fh.stat()).size 688 if (size === 0) { 689 return { content: '', bytesRead: 0, bytesTotal: 0 } 690 } 691 const offset = Math.max(0, size - maxBytes) 692 const bytesToRead = size - offset 693 const buffer = Buffer.allocUnsafe(bytesToRead) 694 695 let totalRead = 0 696 while (totalRead < bytesToRead) { 697 const { bytesRead } = await fh.read( 698 buffer, 699 totalRead, 700 bytesToRead - totalRead, 701 offset + totalRead, 702 ) 703 if (bytesRead === 0) { 704 break 705 } 706 totalRead += bytesRead 707 } 708 709 return { 710 content: buffer.toString('utf8', 0, totalRead), 711 bytesRead: totalRead, 712 bytesTotal: size, 713 } 714} 715 716/** 717 * Async generator that yields lines from a file in reverse order. 718 * Reads the file backwards in chunks to avoid loading the entire file into memory. 719 * @param path - The path to the file to read 720 * @returns An async generator that yields lines in reverse order 721 */ 722export async function* readLinesReverse( 723 path: string, 724): AsyncGenerator<string, void, undefined> { 725 const CHUNK_SIZE = 1024 * 4 726 const fileHandle = await open(path, 'r') 727 try { 728 const stats = await fileHandle.stat() 729 let position = stats.size 730 // Carry raw bytes (not a decoded string) across chunk boundaries so that 731 // multi-byte UTF-8 sequences split by the 4KB boundary are not corrupted. 732 // Decoding per-chunk would turn a split sequence into U+FFFD on both sides, 733 // which for history.jsonl means JSON.parse throws and the entry is dropped. 734 let remainder = Buffer.alloc(0) 735 const buffer = Buffer.alloc(CHUNK_SIZE) 736 737 while (position > 0) { 738 const currentChunkSize = Math.min(CHUNK_SIZE, position) 739 position -= currentChunkSize 740 741 await fileHandle.read(buffer, 0, currentChunkSize, position) 742 const combined = Buffer.concat([ 743 buffer.subarray(0, currentChunkSize), 744 remainder, 745 ]) 746 747 const firstNewline = combined.indexOf(0x0a) 748 if (firstNewline === -1) { 749 remainder = combined 750 continue 751 } 752 753 remainder = Buffer.from(combined.subarray(0, firstNewline)) 754 const lines = combined.toString('utf8', firstNewline + 1).split('\n') 755 756 for (let i = lines.length - 1; i >= 0; i--) { 757 const line = lines[i]! 758 if (line) { 759 yield line 760 } 761 } 762 } 763 764 if (remainder.length > 0) { 765 yield remainder.toString('utf8') 766 } 767 } finally { 768 await fileHandle.close() 769 } 770}