@@ -98,6 +98,26 @@ enum MacNodeClaudeSessionCatalog {
9898 var generation : UInt64 = 0
9999 }
100100
101+ private struct CLIRecordInspection {
102+ var aiTitle : String ?
103+ var record : SessionRecord ?
104+ var sidechain = false
105+ var shouldStop = false
106+ }
107+
108+ private struct CLIRecordDiscoveryContext {
109+ var projectsURL : URL
110+ var resolvedProjectsURL : URL
111+ var rootPath : String
112+ }
113+
114+ private struct CLIRecordFileScan {
115+ var fileBytes : Int
116+ var record : SessionRecord ?
117+ var sidechain : Bool
118+ var cacheable : Bool
119+ }
120+
101121 private final class CatalogDiscoveryCache : @unchecked Sendable {
102122 private let lock = NSLock ( )
103123 private var entries : [ String : CatalogDiscoveryCacheEntry ] = [ : ]
@@ -439,7 +459,10 @@ extension MacNodeClaudeSessionCatalog {
439459 var scannedBytes = 0
440460 var truncated = false
441461 var seenPaths = Set < String > ( )
442- let rootPath = projectsURL. path
462+ let context = CLIRecordDiscoveryContext (
463+ projectsURL: projectsURL,
464+ resolvedProjectsURL: resolvedProjectsURL,
465+ rootPath: projectsURL. path)
443466 scan: for projectURL in self . childDirectories ( projectsURL) {
444467 try Task . checkCancellation ( )
445468 let files = ( try ? FileManager . default. contentsOfDirectory (
@@ -453,157 +476,204 @@ extension MacNodeClaudeSessionCatalog {
453476 break scan
454477 }
455478 discoveredFiles += 1
456- let sessionId = candidate. deletingPathExtension ( ) . lastPathComponent
457- guard !sessionId. isEmpty,
458- records [ sessionId] == nil ,
459- !sidechainIds. contains ( sessionId) ,
460- let fileURL = self . safeSessionFile (
461- root: projectsURL,
462- resolvedRoot: resolvedProjectsURL,
463- candidate: candidate,
464- sessionId: sessionId)
465- else { continue }
466- let identity = self . catalogFileIdentity ( fileURL)
467- let cachePath = fileURL. path
468- seenPaths. insert ( cachePath)
469- // Cache identity does not encode ACLs. Preserve the old open-on-every-list
470- // authorization behavior before returning cached prompt metadata.
471- guard FileManager . default. isReadableFile ( atPath: cachePath) else { continue }
472- if let identity,
473- let cached = self . catalogDiscoveryCache. lookup (
474- path: cachePath,
475- rootPath: rootPath,
476- identity: identity,
477- sessionId: sessionId) ,
478- scannedBytes + cached. scannedBytes <= self . maxCatalogMetadataScanBytes
479- {
480- if cached. sidechain {
481- sidechainIds. insert ( sessionId)
482- }
483- if let record = cached. record {
484- records [ sessionId] = record
485- }
486- // Preserve the cold-scan byte frontier so repeated pagination stays stable.
487- scannedBytes += cached. scannedBytes
488- if scannedBytes >= self . maxCatalogMetadataScanBytes {
489- truncated = true
490- break scan
491- }
492- continue
493- }
494- guard let handle = try ? FileHandle ( forReadingFrom: fileURL) else { continue }
495- var aiTitle : String ?
496- let updatedAt = identity. map {
497- Int64 ( $0. modificationDate. timeIntervalSince1970 * 1000 )
498- } ?? ( try ? fileURL. resourceValues (
499- forKeys: [ . contentModificationDateKey] ) . contentModificationDate)
500- . map { Int64 ( $0. timeIntervalSince1970 * 1000 ) }
501- var stopFile = false
502- var discoveredRecord : SessionRecord ?
503- var discoveredSidechain = false
504- func inspectLine( _ line: Data ) {
505- guard let row = try ? JSONSerialization . jsonObject ( with: line) as? [ String : Any ] ,
506- self . string ( row [ " sessionId " ] , maxLength: self . maxSessionIdLength) == sessionId
507- else { return }
508- if row [ " type " ] as? String == " ai-title " {
509- aiTitle = self . string ( row [ " aiTitle " ] , maxLength: 500 ) ?? aiTitle
510- return
511- }
512- if let entrypoint = row [ " entrypoint " ] as? String , entrypoint != " sdk-cli " {
513- stopFile = true
514- return
515- }
516- if row [ " entrypoint " ] as? String == " sdk-cli " ,
517- ( row [ " isSidechain " ] as? Bool ) == true
518- {
519- discoveredSidechain = true
520- stopFile = true
521- return
522- }
523- guard row [ " entrypoint " ] as? String == " sdk-cli " ,
524- row [ " type " ] as? String == " user " ,
525- let message = row [ " message " ] as? [ String : Any ] ,
526- message [ " role " ] as? String == " user " ,
527- let content = message [ " content " ]
528- else { return }
529- var fragments : [ String ] = [ ]
530- self . collectText ( content, into: & fragments)
531- discoveredRecord = SessionRecord (
532- threadId: sessionId,
533- name: aiTitle ?? fragments. first. flatMap { self . string ( $0, maxLength: 500 ) } ,
534- cwd: self . string ( row [ " cwd " ] ) ,
535- createdAt: self . timestampMs ( row [ " timestamp " ] ) ,
536- updatedAt: updatedAt,
537- source: " claude-cli " ,
538- gitBranch: self . string ( row [ " gitBranch " ] , maxLength: 500 ) ,
539- fileURL: fileURL)
540- stopFile = true
541- }
542- var pending = Data ( )
543- var fileBytes = 0
544- var reachedEnd = false
545- while !stopFile,
546- fileBytes < self . metadataPrefixBytes,
547- scannedBytes < self . maxCatalogMetadataScanBytes
548- {
549- try Task . checkCancellation ( )
550- let size = min (
551- self . metadataReadChunkBytes,
552- self . metadataPrefixBytes - fileBytes,
553- self . maxCatalogMetadataScanBytes - scannedBytes)
554- guard size > 0 else { break }
555- guard let chunk = try ? handle. read ( upToCount: size) else {
556- pending. removeAll ( )
557- break
558- }
559- if chunk. isEmpty {
560- reachedEnd = true
561- break
562- }
563- fileBytes += chunk. count
564- scannedBytes += chunk. count
565- pending. append ( chunk)
566- while !stopFile, let newline = pending. firstIndex ( of: 0x0A ) {
567- inspectLine ( Data ( pending [ ..< newline] ) )
568- pending. removeSubrange ( ... newline)
569- }
570- }
571- if !stopFile, reachedEnd, !pending. isEmpty {
572- inspectLine ( pending)
573- }
574- try ? handle. close ( )
575- if discoveredSidechain {
576- sidechainIds. insert ( sessionId)
577- }
578- if let discoveredRecord {
579- records [ sessionId] = discoveredRecord
580- }
581- let budgetConstrained = scannedBytes >= self . maxCatalogMetadataScanBytes
582- if let identity,
583- !budgetConstrained,
584- stopFile || reachedEnd || fileBytes >= self . metadataPrefixBytes
479+ if try self . discoverCLIRecord (
480+ candidate: candidate,
481+ context: context,
482+ scannedBytes: & scannedBytes,
483+ seenPaths: & seenPaths,
484+ records: & records,
485+ sidechainIds: & sidechainIds)
585486 {
586- self . catalogDiscoveryCache. store (
587- CatalogDiscoveryCacheEntry (
588- rootPath: rootPath,
589- identity: identity,
590- sessionId: sessionId,
591- scannedBytes: fileBytes,
592- record: discoveredRecord,
593- sidechain: discoveredSidechain) ,
594- path: cachePath)
595- }
596- if scannedBytes >= self . maxCatalogMetadataScanBytes {
597487 truncated = true
598488 break scan
599489 }
600490 }
601491 }
602492 if !truncated {
603- self . catalogDiscoveryCache. removeUnseen ( rootPath: rootPath, seenPaths: seenPaths)
493+ self . catalogDiscoveryCache. removeUnseen ( rootPath: context . rootPath, seenPaths: seenPaths)
604494 }
605495 }
606496
497+ private static func discoverCLIRecord(
498+ candidate: URL ,
499+ context: CLIRecordDiscoveryContext ,
500+ scannedBytes: inout Int ,
501+ seenPaths: inout Set < String > ,
502+ records: inout [ String : SessionRecord ] ,
503+ sidechainIds: inout Set < String > ) throws -> Bool
504+ {
505+ let sessionId = candidate. deletingPathExtension ( ) . lastPathComponent
506+ guard !sessionId. isEmpty,
507+ records [ sessionId] == nil ,
508+ !sidechainIds. contains ( sessionId) ,
509+ let fileURL = self . safeSessionFile (
510+ root: context. projectsURL,
511+ resolvedRoot: context. resolvedProjectsURL,
512+ candidate: candidate,
513+ sessionId: sessionId)
514+ else { return false }
515+ let identity = self . catalogFileIdentity ( fileURL)
516+ let cachePath = fileURL. path
517+ seenPaths. insert ( cachePath)
518+ // Cache identity does not encode ACLs. Preserve open-on-every-list authorization.
519+ guard FileManager . default. isReadableFile ( atPath: cachePath) else { return false }
520+ if let identity,
521+ let cached = self . catalogDiscoveryCache. lookup (
522+ path: cachePath,
523+ rootPath: context. rootPath,
524+ identity: identity,
525+ sessionId: sessionId) ,
526+ scannedBytes + cached. scannedBytes <= self . maxCatalogMetadataScanBytes
527+ {
528+ if cached. sidechain {
529+ sidechainIds. insert ( sessionId)
530+ }
531+ if let record = cached. record {
532+ records [ sessionId] = record
533+ }
534+ // Preserve the cold-scan byte frontier so repeated pagination stays stable.
535+ scannedBytes += cached. scannedBytes
536+ return scannedBytes >= self . maxCatalogMetadataScanBytes
537+ }
538+ guard let handle = try ? FileHandle ( forReadingFrom: fileURL) else { return false }
539+ let updatedAt = identity. map {
540+ Int64 ( $0. modificationDate. timeIntervalSince1970 * 1000 )
541+ } ?? ( try ? fileURL. resourceValues (
542+ forKeys: [ . contentModificationDateKey] ) . contentModificationDate)
543+ . map { Int64 ( $0. timeIntervalSince1970 * 1000 ) }
544+ let scan = try self . scanCLIRecordFile (
545+ handle: handle,
546+ fileURL: fileURL,
547+ sessionId: sessionId,
548+ updatedAt: updatedAt,
549+ byteLimit: self . maxCatalogMetadataScanBytes - scannedBytes)
550+ try ? handle. close ( )
551+ scannedBytes += scan. fileBytes
552+ if scan. sidechain {
553+ sidechainIds. insert ( sessionId)
554+ }
555+ if let record = scan. record {
556+ records [ sessionId] = record
557+ }
558+ let budgetConstrained = scannedBytes >= self . maxCatalogMetadataScanBytes
559+ if let identity, !budgetConstrained, scan. cacheable {
560+ self . catalogDiscoveryCache. store (
561+ CatalogDiscoveryCacheEntry (
562+ rootPath: context. rootPath,
563+ identity: identity,
564+ sessionId: sessionId,
565+ scannedBytes: scan. fileBytes,
566+ record: scan. record,
567+ sidechain: scan. sidechain) ,
568+ path: cachePath)
569+ }
570+ return budgetConstrained
571+ }
572+
573+ private static func scanCLIRecordFile(
574+ handle: FileHandle ,
575+ fileURL: URL ,
576+ sessionId: String ,
577+ updatedAt: Int64 ? ,
578+ byteLimit: Int ) throws -> CLIRecordFileScan
579+ {
580+ var inspection = CLIRecordInspection ( )
581+ var pending = Data ( )
582+ var fileBytes = 0
583+ var reachedEnd = false
584+ var readFailed = false
585+ while !inspection. shouldStop,
586+ fileBytes < self . metadataPrefixBytes,
587+ fileBytes < byteLimit
588+ {
589+ try Task . checkCancellation ( )
590+ let size = min (
591+ self . metadataReadChunkBytes,
592+ self . metadataPrefixBytes - fileBytes,
593+ byteLimit - fileBytes)
594+ guard size > 0 else { break }
595+ guard let chunk = try ? handle. read ( upToCount: size) else {
596+ pending. removeAll ( )
597+ readFailed = true
598+ break
599+ }
600+ if chunk. isEmpty {
601+ reachedEnd = true
602+ break
603+ }
604+ fileBytes += chunk. count
605+ pending. append ( chunk)
606+ while !inspection. shouldStop, let newline = pending. firstIndex ( of: 0x0A ) {
607+ self . inspectCLIRecordLine (
608+ Data ( pending [ ..< newline] ) ,
609+ sessionId: sessionId,
610+ fileURL: fileURL,
611+ updatedAt: updatedAt,
612+ inspection: & inspection)
613+ pending. removeSubrange ( ... newline)
614+ }
615+ }
616+ if !inspection. shouldStop, reachedEnd, !pending. isEmpty {
617+ self . inspectCLIRecordLine (
618+ pending,
619+ sessionId: sessionId,
620+ fileURL: fileURL,
621+ updatedAt: updatedAt,
622+ inspection: & inspection)
623+ }
624+ return CLIRecordFileScan (
625+ fileBytes: fileBytes,
626+ record: inspection. record,
627+ sidechain: inspection. sidechain,
628+ cacheable: !readFailed &&
629+ ( inspection. shouldStop || reachedEnd || fileBytes >= self . metadataPrefixBytes) )
630+ }
631+
632+ private static func inspectCLIRecordLine(
633+ _ line: Data ,
634+ sessionId: String ,
635+ fileURL: URL ,
636+ updatedAt: Int64 ? ,
637+ inspection: inout CLIRecordInspection )
638+ {
639+ guard let row = try ? JSONSerialization . jsonObject ( with: line) as? [ String : Any ] ,
640+ self . string ( row [ " sessionId " ] , maxLength: self . maxSessionIdLength) == sessionId
641+ else { return }
642+ if row [ " type " ] as? String == " ai-title " {
643+ inspection. aiTitle = self . string ( row [ " aiTitle " ] , maxLength: 500 ) ?? inspection. aiTitle
644+ return
645+ }
646+ if let entrypoint = row [ " entrypoint " ] as? String , entrypoint != " sdk-cli " {
647+ inspection. shouldStop = true
648+ return
649+ }
650+ if row [ " entrypoint " ] as? String == " sdk-cli " ,
651+ ( row [ " isSidechain " ] as? Bool ) == true
652+ {
653+ inspection. sidechain = true
654+ inspection. shouldStop = true
655+ return
656+ }
657+ guard row [ " entrypoint " ] as? String == " sdk-cli " ,
658+ row [ " type " ] as? String == " user " ,
659+ let message = row [ " message " ] as? [ String : Any ] ,
660+ message [ " role " ] as? String == " user " ,
661+ let content = message [ " content " ]
662+ else { return }
663+ var fragments : [ String ] = [ ]
664+ self . collectText ( content, into: & fragments)
665+ inspection. record = SessionRecord (
666+ threadId: sessionId,
667+ name: inspection. aiTitle ?? fragments. first. flatMap { self . string ( $0, maxLength: 500 ) } ,
668+ cwd: self . string ( row [ " cwd " ] ) ,
669+ createdAt: self . timestampMs ( row [ " timestamp " ] ) ,
670+ updatedAt: updatedAt,
671+ source: " claude-cli " ,
672+ gitBranch: self . string ( row [ " gitBranch " ] , maxLength: 500 ) ,
673+ fileURL: fileURL)
674+ inspection. shouldStop = true
675+ }
676+
607677 private static func sessions( homeURL: URL ) throws -> [ SessionRecord ] {
608678 try Task . checkCancellation ( )
609679 let projectsURL = self . projectsURL ( homeURL: homeURL)
0 commit comments