@@ -2291,6 +2291,7 @@ final class AppState {
22912291 ( " cursor " , " \( home) /.cursor/projects " ) ,
22922292 ( " copilot " , " \( home) /.copilot/session-state " ) ,
22932293 ( " opencode " , " \( home) /.local/share/opencode " ) ,
2294+ ( " kimi " , " \( home) /.kimi-code/sessions " ) ,
22942295 ( " kimi " , " \( home) /.kimi/sessions " ) ,
22952296 ]
22962297 let fm = FileManager . default
@@ -3257,12 +3258,14 @@ final class AppState {
32573258 private nonisolated static func findKimiPids( candidatePids: [ pid_t ] ? = nil ) -> [ pid_t ] {
32583259 findPids (
32593260 matchingPathSubstrings: [
3261+ " /.kimi-code/bin/kimi " ,
32603262 " /.local/bin/kimi " ,
32613263 " /.local/share/uv/tools/kimi-cli/ " ,
32623264 ] ,
32633265 argSubstrings: [
32643266 " /kimi-cli/ " ,
32653267 " kimi_cli " ,
3268+ " /.kimi-code/ " ,
32663269 ] ,
32673270 candidatePids: candidatePids
32683271 )
@@ -3296,62 +3299,146 @@ final class AppState {
32963299
32973300 let home = FileManager . default. homeDirectoryForCurrentUser. path
32983301 let fm = FileManager . default
3299- let sessionsBase = " \( home) /.kimi/sessions "
3300- guard fm. fileExists ( atPath: sessionsBase) else { return [ ] }
3302+ // Legacy kimi-cli hashes cwd under sessions/; kimi-code may only need
3303+ // session_index.jsonl, so do not bail when these dirs are absent.
3304+ let sessionsBases = [ " \( home) /.kimi-code/sessions " , " \( home) /.kimi/sessions " ]
3305+ . filter { fm. fileExists ( atPath: $0) }
33013306
33023307 var results : [ DiscoveredSession ] = [ ]
33033308 var seenSessionIds : Set < String > = [ ]
33043309
33053310 for pid in kimiPids {
33063311 guard let cwd = getCwd ( for: pid) , !cwd. isEmpty, !isSubagentWorktree( cwd) else { continue }
33073312 let processStart = getProcessStartTime ( pid)
3308- let workdirHash = md5Hash ( of: cwd)
3309- let workdirPath = " \( sessionsBase) / \( workdirHash) "
3310- guard fm. fileExists ( atPath: workdirPath) ,
3311- let sessionDirs = try ? fm. contentsOfDirectory ( atPath: workdirPath) else { continue }
33123313
3313- var bestPath : String ?
3314- var bestDate = Date . distantPast
3315- var bestSessionId : String ?
3316-
3317- for sessionId in sessionDirs {
3318- let wirePath = " \( workdirPath) / \( sessionId) /wire.jsonl "
3319- guard fm. fileExists ( atPath: wirePath) ,
3320- let attrs = try ? fm. attributesOfItem ( atPath: wirePath) ,
3321- let modified = attrs [ . modificationDate] as? Date ,
3322- modified > bestDate else { continue }
3323- if let start = processStart, modified < start. addingTimeInterval ( - 10 ) {
3324- continue
3325- }
3326- bestPath = wirePath
3327- bestDate = modified
3328- bestSessionId = sessionId
3314+ // Prefer kimi-code session_index.jsonl (workDir → sessionDir mapping).
3315+ if let indexed = discoverKimiCodeSessionFromIndex (
3316+ home: home,
3317+ cwd: cwd,
3318+ pid: pid,
3319+ processStart: processStart,
3320+ fm: fm
3321+ ) , !seenSessionIds. contains ( indexed. sessionId) {
3322+ seenSessionIds. insert ( indexed. sessionId)
3323+ results. append ( indexed)
3324+ continue
33293325 }
33303326
3331- guard let path = bestPath, let sessionId = bestSessionId else { continue }
3332- let freshnessLimit : TimeInterval = processStart != nil ? - 300 : - 30
3333- if bestDate. timeIntervalSinceNow < freshnessLimit { continue }
3327+ // Legacy kimi-cli: ~/.kimi/sessions/<md5(cwd)>/<sessionId>/wire.jsonl
3328+ let workdirHash = md5Hash ( of: cwd)
3329+ for sessionsBase in sessionsBases {
3330+ let workdirPath = " \( sessionsBase) / \( workdirHash) "
3331+ guard fm. fileExists ( atPath: workdirPath) ,
3332+ let sessionDirs = try ? fm. contentsOfDirectory ( atPath: workdirPath) else { continue }
3333+
3334+ var bestPath : String ?
3335+ var bestDate = Date . distantPast
3336+ var bestSessionId : String ?
3337+
3338+ for sessionId in sessionDirs {
3339+ let wirePath = " \( workdirPath) / \( sessionId) /wire.jsonl "
3340+ guard fm. fileExists ( atPath: wirePath) ,
3341+ let attrs = try ? fm. attributesOfItem ( atPath: wirePath) ,
3342+ let modified = attrs [ . modificationDate] as? Date ,
3343+ modified > bestDate else { continue }
3344+ if let start = processStart, modified < start. addingTimeInterval ( - 10 ) {
3345+ continue
3346+ }
3347+ bestPath = wirePath
3348+ bestDate = modified
3349+ bestSessionId = sessionId
3350+ }
33343351
3335- let ( _ , messages ) = readRecentFromKimiTranscript ( path : path )
3336- guard !seenSessionIds . contains ( sessionId ) else { continue }
3337- seenSessionIds . insert ( sessionId )
3352+ guard let path = bestPath , let sessionId = bestSessionId else { continue }
3353+ let freshnessLimit : TimeInterval = processStart != nil ? - 300 : - 30
3354+ if bestDate . timeIntervalSinceNow < freshnessLimit { continue }
33383355
3339- results. append ( DiscoveredSession (
3340- sessionId: sessionId,
3341- cwd: cwd,
3342- tty: nil ,
3343- model: nil ,
3344- pid: pid,
3345- modifiedAt: bestDate,
3346- recentMessages: messages,
3347- source: " kimi "
3348- ) )
3356+ let ( _, messages) = readRecentFromKimiTranscript ( path: path)
3357+ guard !seenSessionIds. contains ( sessionId) else { continue }
3358+ seenSessionIds. insert ( sessionId)
3359+
3360+ results. append ( DiscoveredSession (
3361+ sessionId: sessionId,
3362+ cwd: cwd,
3363+ tty: nil ,
3364+ model: nil ,
3365+ pid: pid,
3366+ modifiedAt: bestDate,
3367+ recentMessages: messages,
3368+ source: " kimi "
3369+ ) )
3370+ break
3371+ }
33493372 }
33503373
33513374 return results
33523375 }
33533376
3354- private nonisolated static func readRecentFromKimiTranscript( path: String ) -> ( String ? , [ ChatMessage ] ) {
3377+ /// kimi-code tracks sessions in `~/.kimi-code/session_index.jsonl` with
3378+ /// `{ sessionId, sessionDir, workDir }` — workdir folders are no longer md5(cwd).
3379+ private nonisolated static func discoverKimiCodeSessionFromIndex(
3380+ home: String ,
3381+ cwd: String ,
3382+ pid: pid_t ,
3383+ processStart: Date ? ,
3384+ fm: FileManager
3385+ ) -> DiscoveredSession ? {
3386+ let indexPath = " \( home) /.kimi-code/session_index.jsonl "
3387+ guard fm. fileExists ( atPath: indexPath) ,
3388+ let data = fm. contents ( atPath: indexPath) ,
3389+ let text = String ( data: data, encoding: . utf8) else { return nil }
3390+
3391+ var best : ( sessionId: String , sessionDir: String , modified: Date ) ?
3392+ for line in text. components ( separatedBy: " \n " ) where !line. isEmpty {
3393+ guard let lineData = line. data ( using: . utf8) ,
3394+ let json = try ? JSONSerialization . jsonObject ( with: lineData) as? [ String : Any ] ,
3395+ let workDir = json [ " workDir " ] as? String ,
3396+ workDir == cwd,
3397+ let sessionId = json [ " sessionId " ] as? String ,
3398+ let sessionDir = json [ " sessionDir " ] as? String
3399+ else { continue }
3400+
3401+ let statePath = " \( sessionDir) /state.json "
3402+ let stampPath = fm. fileExists ( atPath: statePath) ? statePath : sessionDir
3403+ guard let attrs = try ? fm. attributesOfItem ( atPath: stampPath) ,
3404+ let modified = attrs [ . modificationDate] as? Date else { continue }
3405+ if let start = processStart, modified < start. addingTimeInterval ( - 10 ) {
3406+ continue
3407+ }
3408+ if best == nil || modified > best!. modified {
3409+ best = ( sessionId, sessionDir, modified)
3410+ }
3411+ }
3412+
3413+ guard let match = best else { return nil }
3414+ let freshnessLimit : TimeInterval = processStart != nil ? - 300 : - 30
3415+ if match. modified. timeIntervalSinceNow < freshnessLimit { return nil }
3416+
3417+ // kimi-code keeps the transcript under agents/main/wire.jsonl.
3418+ let wirePath = " \( match. sessionDir) /agents/main/wire.jsonl "
3419+ let messages : [ ChatMessage ]
3420+ if fm. fileExists ( atPath: wirePath) {
3421+ messages = readRecentFromKimiTranscript ( path: wirePath) . 1
3422+ } else {
3423+ messages = [ ]
3424+ }
3425+
3426+ return DiscoveredSession (
3427+ sessionId: match. sessionId,
3428+ cwd: cwd,
3429+ tty: nil ,
3430+ model: nil ,
3431+ pid: pid,
3432+ modifiedAt: match. modified,
3433+ recentMessages: messages,
3434+ source: " kimi "
3435+ )
3436+ }
3437+
3438+ /// Parse recent chat turns from a Kimi wire.jsonl transcript.
3439+ /// Supports legacy kimi-cli (`message.type = TurnBegin|ContentPart|TurnEnd`)
3440+ /// and kimi-code (`turn.prompt` / `context.append_message` / `content.part`).
3441+ internal nonisolated static func readRecentFromKimiTranscript( path: String ) -> ( String ? , [ ChatMessage ] ) {
33553442 guard let handle = FileHandle ( forReadingAtPath: path) else { return ( nil , [ ] ) }
33563443 defer { handle. closeFile ( ) }
33573444
@@ -3365,59 +3452,91 @@ final class AppState {
33653452 var previousUserText : String ?
33663453 var previousAssistantText : String = " "
33673454
3455+ func flushTurn( ) {
3456+ if let userText = previousUserText, !userText. isEmpty {
3457+ messages. append ( ChatMessage ( isUser: true , text: userText) )
3458+ if !previousAssistantText. isEmpty {
3459+ messages. append ( ChatMessage ( isUser: false , text: previousAssistantText) )
3460+ }
3461+ }
3462+ previousUserText = nil
3463+ previousAssistantText = " "
3464+ }
3465+
3466+ func textParts( from value: Any ? ) -> String {
3467+ let parts : [ [ String : Any ] ]
3468+ if let typed = value as? [ [ String : Any ] ] {
3469+ parts = typed
3470+ } else if let anyParts = value as? [ Any ] {
3471+ parts = anyParts. compactMap { $0 as? [ String : Any ] }
3472+ } else {
3473+ return " "
3474+ }
3475+ return parts. compactMap { part -> String ? in
3476+ if let type = part [ " type " ] as? String , type != " text " { return nil }
3477+ return part [ " text " ] as? String
3478+ } . joined ( )
3479+ }
3480+
33683481 for line in text. components ( separatedBy: " \n " ) where !line. isEmpty {
33693482 guard let lineData = line. data ( using: . utf8) ,
3370- let json = try ? JSONSerialization . jsonObject ( with: lineData) as? [ String : Any ] ,
3371- let message = json [ " message " ] as? [ String : Any ] ,
3372- let type = message [ " type " ] as? String
3483+ let json = try ? JSONSerialization . jsonObject ( with: lineData) as? [ String : Any ]
33733484 else { continue }
33743485
3375- switch type {
3376- case " TurnBegin " :
3377- if let userText = previousUserText, !userText. isEmpty {
3378- messages. append ( ChatMessage ( isUser: true , text: userText) )
3379- if !previousAssistantText. isEmpty {
3380- messages. append ( ChatMessage ( isUser: false , text: previousAssistantText) )
3486+ // Legacy kimi-cli envelope: { "message": { "type": "TurnBegin"|... } }
3487+ if let message = json [ " message " ] as? [ String : Any ] ,
3488+ let type = message [ " type " ] as? String {
3489+ switch type {
3490+ case " TurnBegin " :
3491+ flushTurn ( )
3492+ if let payload = message [ " payload " ] as? [ String : Any ] {
3493+ previousUserText = textParts ( from: payload [ " user_input " ] )
33813494 }
3495+ case " ContentPart " :
3496+ if let payload = message [ " payload " ] as? [ String : Any ] ,
3497+ payload [ " type " ] as? String == " text " ,
3498+ let textContent = payload [ " text " ] as? String {
3499+ previousAssistantText += textContent
3500+ }
3501+ case " TurnEnd " :
3502+ flushTurn ( )
3503+ default :
3504+ break
33823505 }
3383- previousUserText = nil
3384- previousAssistantText = " "
3385- if let payload = message [ " payload " ] as? [ String : Any ] ,
3386- let userInput = payload [ " user_input " ] as? [ [ String : Any ] ] {
3387- let texts = userInput. compactMap { part -> String ? in
3388- guard part [ " type " ] as? String == " text " else { return nil }
3389- return part [ " text " ] as? String
3506+ continue
3507+ }
3508+
3509+ // kimi-code wire protocol (v1.4+).
3510+ switch json [ " type " ] as? String {
3511+ case " turn.prompt " :
3512+ flushTurn ( )
3513+ previousUserText = textParts ( from: json [ " input " ] )
3514+ case " context.append_message " :
3515+ if let message = json [ " message " ] as? [ String : Any ] ,
3516+ message [ " role " ] as? String == " user " {
3517+ let userText = textParts ( from: message [ " content " ] )
3518+ if !userText. isEmpty {
3519+ // Prefer turn.prompt when both exist for the same turn;
3520+ // only start a turn here if we don't already have one open.
3521+ if previousUserText == nil {
3522+ previousUserText = userText
3523+ }
33903524 }
3391- previousUserText = texts. joined ( )
33923525 }
3393- case " ContentPart " :
3394- if let payload = message [ " payload " ] as? [ String : Any ] ,
3395- payload [ " type " ] as? String == " text " ,
3396- let textContent = payload [ " text " ] as? String {
3526+ case " context.append_loop_event " :
3527+ if let event = json [ " event " ] as? [ String : Any ] ,
3528+ event [ " type " ] as? String == " content.part " ,
3529+ let part = event [ " part " ] as? [ String : Any ] ,
3530+ part [ " type " ] as? String == " text " ,
3531+ let textContent = part [ " text " ] as? String {
33973532 previousAssistantText += textContent
33983533 }
3399- case " TurnEnd " :
3400- if let userText = previousUserText, !userText. isEmpty {
3401- messages. append ( ChatMessage ( isUser: true , text: userText) )
3402- if !previousAssistantText. isEmpty {
3403- messages. append ( ChatMessage ( isUser: false , text: previousAssistantText) )
3404- }
3405- }
3406- previousUserText = nil
3407- previousAssistantText = " "
34083534 default :
34093535 break
34103536 }
34113537 }
34123538
3413- // flush final turn
3414- if let userText = previousUserText, !userText. isEmpty {
3415- messages. append ( ChatMessage ( isUser: true , text: userText) )
3416- if !previousAssistantText. isEmpty {
3417- messages. append ( ChatMessage ( isUser: false , text: previousAssistantText) )
3418- }
3419- }
3420-
3539+ flushTurn ( )
34213540 return ( nil , Array ( messages. suffix ( 3 ) ) )
34223541 }
34233542
0 commit comments