@@ -1593,7 +1593,7 @@ async function scanProjectDirs(
15931593
15941594 type FileInfo = { dirName : string ; fp : NonNullable < Awaited < ReturnType < typeof fingerprintFile > > > ; source ?: SessionSourceMetadata }
15951595 const unchangedFiles : Array < { filePath : string ; dirName : string ; source ?: SessionSourceMetadata ; cached : CachedFile } > = [ ]
1596- const changedFiles : Array < { filePath : string ; info : FileInfo } > = [ ]
1596+ const changedFiles : Array < { filePath : string ; info : FileInfo ; append ?: { cached : CachedFile ; readFromOffset : number } } > = [ ]
15971597
15981598 const discoverProgress = createScanProgress ( 'scanning claude project dirs' , dirs . length )
15991599 let dirsDone = 0
@@ -1607,6 +1607,12 @@ async function scanProjectDirs(
16071607 const action = reconcileFile ( fp , section . files [ filePath ] )
16081608 if ( action . action === 'unchanged' ) {
16091609 unchangedFiles . push ( { filePath, dirName, source, cached : section . files [ filePath ] ! } )
1610+ } else if ( action . action === 'appended' ) {
1611+ changedFiles . push ( {
1612+ filePath,
1613+ info : { dirName, fp, source } ,
1614+ append : { cached : section . files [ filePath ] ! , readFromOffset : action . readFromOffset } ,
1615+ } )
16101616 } else {
16111617 changedFiles . push ( { filePath, info : { dirName, fp, source } } )
16121618 }
@@ -1629,10 +1635,78 @@ async function scanProjectDirs(
16291635 const progressTotal = changedFiles . length
16301636 let filesDone = 0
16311637 emitScanProgress ( { kind : 'tick' , provider : 'claude' , done : 0 , total : progressTotal } )
1632- for ( const { filePath, info } of changedFiles ) {
1638+ for ( const { filePath, info, append } of changedFiles ) {
16331639 delete section . files [ filePath ]
16341640
16351641 try {
1642+ if ( append ) {
1643+ // Append-only growth: parse ONLY the bytes past the cached resume offset
1644+ // and merge with the cached turns, rather than re-reading the file from 0.
1645+ // On a studio machine where live agents constantly append to session
1646+ // JSONL, this is the dominant warm-run cost. The merged result is
1647+ // byte-for-byte identical to a full re-parse (see mergeBoundaryCalls).
1648+ const tracker = { lastCompleteLineOffset : append . readFromOffset }
1649+ const newEntries = await parseClaudeEntries ( filePath , tracker , append . readFromOffset )
1650+ const cached = append . cached
1651+
1652+ const newTurns = newEntries
1653+ ? groupIntoTurns ( dedupeStreamingMessageIds ( newEntries ) , seenMsgIds ) . map ( parsedTurnToCachedTurn )
1654+ : [ ]
1655+
1656+ const mergedTurns : CachedTurn [ ] = cached . turns . map ( t => ( { ...t , calls : [ ...t . calls ] } ) )
1657+ if ( newTurns . length > 0 ) {
1658+ let startIdx = 0
1659+ // A first new turn with no leading user message is a continuation of
1660+ // the last cached turn — merge its calls in (a full re-parse would put
1661+ // them in that same turn), then append the remaining new turns.
1662+ if ( ! newTurns [ 0 ] ! . userMessage . trim ( ) && mergedTurns . length > 0 ) {
1663+ const last = mergedTurns [ mergedTurns . length - 1 ] !
1664+ last . calls = mergeBoundaryCalls ( last . calls , newTurns [ 0 ] ! . calls )
1665+ startIdx = 1
1666+ }
1667+ for ( let i = startIdx ; i < newTurns . length ; i ++ ) mergedTurns . push ( newTurns [ i ] ! )
1668+ }
1669+
1670+ // The cached region's dedup keys were not added to seenMsgIds (only
1671+ // unchanged files pre-seed it), so add them now — a full re-parse would
1672+ // have, and later files dedup cross-file against them.
1673+ for ( const t of cached . turns ) for ( const c of t . calls ) seenMsgIds . add ( c . deduplicationKey )
1674+
1675+ // First-cwd wins, and the first cwd lives in the cached region whenever
1676+ // one was resolved there; only re-derive if the cached region had none.
1677+ let canonicalCwd = cached . canonicalCwd
1678+ let canonicalProjectName = cached . canonicalProjectName
1679+ if ( canonicalCwd === undefined && newEntries ) {
1680+ const cwd = extractCanonicalCwd ( newEntries )
1681+ const canonical = ( cwd && ! isCoworkSession ( cwd , filePath ) ) ? await resolveCanonicalProjectPath ( cwd ) : undefined
1682+ canonicalCwd = canonical ?. path
1683+ canonicalProjectName = canonical ?. isWorktree ? projectNameFromPath ( canonical . path , info . dirName ) : undefined
1684+ }
1685+
1686+ // Inventory is a sorted set union; cached (older entries) ∪ new = full.
1687+ const mcpInventory = newEntries
1688+ ? Array . from ( new Set ( [ ...cached . mcpInventory , ...extractMcpInventory ( newEntries ) ] ) ) . sort ( )
1689+ : cached . mcpInventory
1690+
1691+ section . files [ filePath ] = {
1692+ fingerprint : info . fp ,
1693+ lastCompleteLineOffset : tracker . lastCompleteLineOffset ,
1694+ canonicalCwd,
1695+ canonicalProjectName,
1696+ mcpInventory,
1697+ turns : mergedTurns ,
1698+ agentType : cached . agentType ,
1699+ }
1700+ ; ( diskCache as { _dirty ?: boolean } ) . _dirty = true
1701+ filesDone ++
1702+ await parseProgress . tick ( filesDone )
1703+ if ( filesDone % 50 === 0 || filesDone === progressTotal ) {
1704+ emitScanProgress ( { kind : 'tick' , provider : 'claude' , done : filesDone , total : progressTotal } )
1705+ }
1706+ if ( onFileParsed ) await onFileParsed ( )
1707+ continue
1708+ }
1709+
16361710 const tracker = { lastCompleteLineOffset : 0 }
16371711 const entries = await parseClaudeEntries ( filePath , tracker )
16381712 if ( ! entries ) { filesDone ++ ; await parseProgress . tick ( filesDone ) ; continue }
@@ -1968,15 +2042,52 @@ function cachedTurnToClassified(turn: CachedTurn): ClassifiedTurn {
19682042
19692043// ── Cache-Aware Parsing Helpers ────────────────────────────────────────
19702044
2045+ // Merge the calls of the last cached turn with the calls parsed from the
2046+ // appended region when the appended region continues that turn (its first new
2047+ // content had no leading user message). This mirrors `dedupeStreamingMessageIds`
2048+ // at the call level: a Claude message re-emitted across the append boundary
2049+ // (same `msg.id`, or the trailing not-yet-newline-terminated line re-read from
2050+ // the resume offset) collapses to its LAST occurrence, keeping the FIRST
2051+ // occurrence's timestamp — byte-for-byte what a full re-parse of the combined
2052+ // stream produces. Synthetic `claude:<ts>` keys (id-less entries) are never
2053+ // collapsed, matching `getMessageId` returning null for them.
2054+ function mergeBoundaryCalls ( cachedCalls : CachedCall [ ] , newCalls : CachedCall [ ] ) : CachedCall [ ] {
2055+ const combined = [ ...cachedCalls , ...newCalls ]
2056+ const firstIdx = new Map < string , number > ( )
2057+ const lastIdx = new Map < string , number > ( )
2058+ for ( let i = 0 ; i < combined . length ; i ++ ) {
2059+ const key = combined [ i ] ! . deduplicationKey
2060+ if ( key . startsWith ( 'claude:' ) ) continue
2061+ if ( ! firstIdx . has ( key ) ) firstIdx . set ( key , i )
2062+ lastIdx . set ( key , i )
2063+ }
2064+ if ( lastIdx . size === 0 ) return combined
2065+ const result : CachedCall [ ] = [ ]
2066+ for ( let i = 0 ; i < combined . length ; i ++ ) {
2067+ const call = combined [ i ] !
2068+ const key = call . deduplicationKey
2069+ if ( key . startsWith ( 'claude:' ) ) { result . push ( call ) ; continue }
2070+ if ( lastIdx . get ( key ) !== i ) continue
2071+ if ( firstIdx . get ( key ) !== i ) {
2072+ result . push ( { ...call , timestamp : combined [ firstIdx . get ( key ) ! ] ! . timestamp } )
2073+ continue
2074+ }
2075+ result . push ( call )
2076+ }
2077+ return result
2078+ }
2079+
19712080async function parseClaudeEntries (
19722081 filePath : string ,
19732082 tracker : { lastCompleteLineOffset : number } ,
2083+ startByteOffset ?: number ,
19742084) : Promise < JournalEntry [ ] | null > {
19752085 const entries : JournalEntry [ ] = [ ]
19762086 let hasLines = false
19772087 for await ( const line of readSessionLines ( filePath , undefined , {
19782088 largeLineAsBuffer : true ,
19792089 byteOffsetTracker : tracker ,
2090+ ...( startByteOffset !== undefined ? { startByteOffset } : { } ) ,
19802091 } ) ) {
19812092 hasLines = true
19822093 const entry = parseJsonlLine ( line )
0 commit comments