Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 196 additions & 77 deletions Sources/CodeIsland/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2281,6 +2281,7 @@ final class AppState {
("cursor", "\(home)/.cursor/projects"),
("copilot", "\(home)/.copilot/session-state"),
("opencode", "\(home)/.local/share/opencode"),
("kimi", "\(home)/.kimi-code/sessions"),
("kimi", "\(home)/.kimi/sessions"),
]
let fm = FileManager.default
Expand Down Expand Up @@ -3239,12 +3240,14 @@ final class AppState {
private nonisolated static func findKimiPids(candidatePids: [pid_t]? = nil) -> [pid_t] {
findPids(
matchingPathSubstrings: [
"/.kimi-code/bin/kimi",
"/.local/bin/kimi",
"/.local/share/uv/tools/kimi-cli/",
],
argSubstrings: [
"/kimi-cli/",
"kimi_cli",
"/.kimi-code/",
],
candidatePids: candidatePids
)
Expand Down Expand Up @@ -3278,62 +3281,146 @@ final class AppState {

let home = FileManager.default.homeDirectoryForCurrentUser.path
let fm = FileManager.default
let sessionsBase = "\(home)/.kimi/sessions"
guard fm.fileExists(atPath: sessionsBase) else { return [] }
// Legacy kimi-cli hashes cwd under sessions/; kimi-code may only need
// session_index.jsonl, so do not bail when these dirs are absent.
let sessionsBases = ["\(home)/.kimi-code/sessions", "\(home)/.kimi/sessions"]
.filter { fm.fileExists(atPath: $0) }

var results: [DiscoveredSession] = []
var seenSessionIds: Set<String> = []

for pid in kimiPids {
guard let cwd = getCwd(for: pid), !cwd.isEmpty, !isSubagentWorktree(cwd) else { continue }
let processStart = getProcessStartTime(pid)
let workdirHash = md5Hash(of: cwd)
let workdirPath = "\(sessionsBase)/\(workdirHash)"
guard fm.fileExists(atPath: workdirPath),
let sessionDirs = try? fm.contentsOfDirectory(atPath: workdirPath) else { continue }

var bestPath: String?
var bestDate = Date.distantPast
var bestSessionId: String?

for sessionId in sessionDirs {
let wirePath = "\(workdirPath)/\(sessionId)/wire.jsonl"
guard fm.fileExists(atPath: wirePath),
let attrs = try? fm.attributesOfItem(atPath: wirePath),
let modified = attrs[.modificationDate] as? Date,
modified > bestDate else { continue }
if let start = processStart, modified < start.addingTimeInterval(-10) {
continue
}
bestPath = wirePath
bestDate = modified
bestSessionId = sessionId
// Prefer kimi-code session_index.jsonl (workDir → sessionDir mapping).
if let indexed = discoverKimiCodeSessionFromIndex(
home: home,
cwd: cwd,
pid: pid,
processStart: processStart,
fm: fm
), !seenSessionIds.contains(indexed.sessionId) {
seenSessionIds.insert(indexed.sessionId)
results.append(indexed)
continue
}

guard let path = bestPath, let sessionId = bestSessionId else { continue }
let freshnessLimit: TimeInterval = processStart != nil ? -300 : -30
if bestDate.timeIntervalSinceNow < freshnessLimit { continue }
// Legacy kimi-cli: ~/.kimi/sessions/<md5(cwd)>/<sessionId>/wire.jsonl
let workdirHash = md5Hash(of: cwd)
for sessionsBase in sessionsBases {
let workdirPath = "\(sessionsBase)/\(workdirHash)"
guard fm.fileExists(atPath: workdirPath),
let sessionDirs = try? fm.contentsOfDirectory(atPath: workdirPath) else { continue }

var bestPath: String?
var bestDate = Date.distantPast
var bestSessionId: String?

for sessionId in sessionDirs {
let wirePath = "\(workdirPath)/\(sessionId)/wire.jsonl"
guard fm.fileExists(atPath: wirePath),
let attrs = try? fm.attributesOfItem(atPath: wirePath),
let modified = attrs[.modificationDate] as? Date,
modified > bestDate else { continue }
if let start = processStart, modified < start.addingTimeInterval(-10) {
continue
}
bestPath = wirePath
bestDate = modified
bestSessionId = sessionId
}

let (_, messages) = readRecentFromKimiTranscript(path: path)
guard !seenSessionIds.contains(sessionId) else { continue }
seenSessionIds.insert(sessionId)
guard let path = bestPath, let sessionId = bestSessionId else { continue }
let freshnessLimit: TimeInterval = processStart != nil ? -300 : -30
if bestDate.timeIntervalSinceNow < freshnessLimit { continue }

results.append(DiscoveredSession(
sessionId: sessionId,
cwd: cwd,
tty: nil,
model: nil,
pid: pid,
modifiedAt: bestDate,
recentMessages: messages,
source: "kimi"
))
let (_, messages) = readRecentFromKimiTranscript(path: path)
guard !seenSessionIds.contains(sessionId) else { continue }
seenSessionIds.insert(sessionId)

results.append(DiscoveredSession(
sessionId: sessionId,
cwd: cwd,
tty: nil,
model: nil,
pid: pid,
modifiedAt: bestDate,
recentMessages: messages,
source: "kimi"
))
break
}
}

return results
}

private nonisolated static func readRecentFromKimiTranscript(path: String) -> (String?, [ChatMessage]) {
/// kimi-code tracks sessions in `~/.kimi-code/session_index.jsonl` with
/// `{ sessionId, sessionDir, workDir }` — workdir folders are no longer md5(cwd).
private nonisolated static func discoverKimiCodeSessionFromIndex(
home: String,
cwd: String,
pid: pid_t,
processStart: Date?,
fm: FileManager
) -> DiscoveredSession? {
let indexPath = "\(home)/.kimi-code/session_index.jsonl"
guard fm.fileExists(atPath: indexPath),
let data = fm.contents(atPath: indexPath),
let text = String(data: data, encoding: .utf8) else { return nil }

var best: (sessionId: String, sessionDir: String, modified: Date)?
for line in text.components(separatedBy: "\n") where !line.isEmpty {
guard let lineData = line.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any],
let workDir = json["workDir"] as? String,
workDir == cwd,
let sessionId = json["sessionId"] as? String,
let sessionDir = json["sessionDir"] as? String
else { continue }

let statePath = "\(sessionDir)/state.json"
let stampPath = fm.fileExists(atPath: statePath) ? statePath : sessionDir
guard let attrs = try? fm.attributesOfItem(atPath: stampPath),
let modified = attrs[.modificationDate] as? Date else { continue }
if let start = processStart, modified < start.addingTimeInterval(-10) {
continue
}
if best == nil || modified > best!.modified {
best = (sessionId, sessionDir, modified)
}
}

guard let match = best else { return nil }
let freshnessLimit: TimeInterval = processStart != nil ? -300 : -30
if match.modified.timeIntervalSinceNow < freshnessLimit { return nil }

// kimi-code keeps the transcript under agents/main/wire.jsonl.
let wirePath = "\(match.sessionDir)/agents/main/wire.jsonl"
let messages: [ChatMessage]
if fm.fileExists(atPath: wirePath) {
messages = readRecentFromKimiTranscript(path: wirePath).1
} else {
messages = []
}

return DiscoveredSession(
sessionId: match.sessionId,
cwd: cwd,
tty: nil,
model: nil,
pid: pid,
modifiedAt: match.modified,
recentMessages: messages,
source: "kimi"
)
}

/// Parse recent chat turns from a Kimi wire.jsonl transcript.
/// Supports legacy kimi-cli (`message.type = TurnBegin|ContentPart|TurnEnd`)
/// and kimi-code (`turn.prompt` / `context.append_message` / `content.part`).
internal nonisolated static func readRecentFromKimiTranscript(path: String) -> (String?, [ChatMessage]) {
guard let handle = FileHandle(forReadingAtPath: path) else { return (nil, []) }
defer { handle.closeFile() }

Expand All @@ -3347,59 +3434,91 @@ final class AppState {
var previousUserText: String?
var previousAssistantText: String = ""

func flushTurn() {
if let userText = previousUserText, !userText.isEmpty {
messages.append(ChatMessage(isUser: true, text: userText))
if !previousAssistantText.isEmpty {
messages.append(ChatMessage(isUser: false, text: previousAssistantText))
}
}
previousUserText = nil
previousAssistantText = ""
}

func textParts(from value: Any?) -> String {
let parts: [[String: Any]]
if let typed = value as? [[String: Any]] {
parts = typed
} else if let anyParts = value as? [Any] {
parts = anyParts.compactMap { $0 as? [String: Any] }
} else {
return ""
}
return parts.compactMap { part -> String? in
if let type = part["type"] as? String, type != "text" { return nil }
return part["text"] as? String
}.joined()
}

for line in text.components(separatedBy: "\n") where !line.isEmpty {
guard let lineData = line.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any],
let message = json["message"] as? [String: Any],
let type = message["type"] as? String
let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any]
else { continue }

switch type {
case "TurnBegin":
if let userText = previousUserText, !userText.isEmpty {
messages.append(ChatMessage(isUser: true, text: userText))
if !previousAssistantText.isEmpty {
messages.append(ChatMessage(isUser: false, text: previousAssistantText))
// Legacy kimi-cli envelope: { "message": { "type": "TurnBegin"|... } }
if let message = json["message"] as? [String: Any],
let type = message["type"] as? String {
switch type {
case "TurnBegin":
flushTurn()
if let payload = message["payload"] as? [String: Any] {
previousUserText = textParts(from: payload["user_input"])
}
case "ContentPart":
if let payload = message["payload"] as? [String: Any],
payload["type"] as? String == "text",
let textContent = payload["text"] as? String {
previousAssistantText += textContent
}
case "TurnEnd":
flushTurn()
default:
break
}
previousUserText = nil
previousAssistantText = ""
if let payload = message["payload"] as? [String: Any],
let userInput = payload["user_input"] as? [[String: Any]] {
let texts = userInput.compactMap { part -> String? in
guard part["type"] as? String == "text" else { return nil }
return part["text"] as? String
continue
}

// kimi-code wire protocol (v1.4+).
switch json["type"] as? String {
case "turn.prompt":
flushTurn()
previousUserText = textParts(from: json["input"])
case "context.append_message":
if let message = json["message"] as? [String: Any],
message["role"] as? String == "user" {
let userText = textParts(from: message["content"])
if !userText.isEmpty {
// Prefer turn.prompt when both exist for the same turn;
// only start a turn here if we don't already have one open.
if previousUserText == nil {
previousUserText = userText
}
}
previousUserText = texts.joined()
}
case "ContentPart":
if let payload = message["payload"] as? [String: Any],
payload["type"] as? String == "text",
let textContent = payload["text"] as? String {
case "context.append_loop_event":
if let event = json["event"] as? [String: Any],
event["type"] as? String == "content.part",
let part = event["part"] as? [String: Any],
part["type"] as? String == "text",
let textContent = part["text"] as? String {
previousAssistantText += textContent
}
case "TurnEnd":
if let userText = previousUserText, !userText.isEmpty {
messages.append(ChatMessage(isUser: true, text: userText))
if !previousAssistantText.isEmpty {
messages.append(ChatMessage(isUser: false, text: previousAssistantText))
}
}
previousUserText = nil
previousAssistantText = ""
default:
break
}
}

// flush final turn
if let userText = previousUserText, !userText.isEmpty {
messages.append(ChatMessage(isUser: true, text: userText))
if !previousAssistantText.isEmpty {
messages.append(ChatMessage(isUser: false, text: previousAssistantText))
}
}

flushTurn()
return (nil, Array(messages.suffix(3)))
}

Expand Down
Loading