Skip to content

Commit 48f2cdc

Browse files
authored
fix: detect Kimi Code CLI under ~/.kimi-code (#274)
* fix: detect Kimi Code CLI under ~/.kimi-code Prefer the official kimi-code home over legacy ~/.kimi so Settings hooks detection, install/uninstall, and session discovery work with current Kimi Code CLI installs. * fix: harden kimi-code session discovery after path migration Allow index-based discovery without requiring sessions/ dirs, and load recent messages from agents/main/wire.jsonl for kimi-code sessions. * fix: show Kimi Code CLI chat text from hooks and wire.jsonl Parse content-part array prompts on UserPromptSubmit, and read the kimi-code wire protocol (turn.prompt / content.part) while keeping legacy kimi-cli TurnBegin transcripts working.
1 parent ed6f1bb commit 48f2cdc

6 files changed

Lines changed: 475 additions & 129 deletions

File tree

Sources/CodeIsland/AppState.swift

Lines changed: 196 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)