Skip to content
Merged
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
42 changes: 30 additions & 12 deletions Sources/AgentSyncCore/ClaudeAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,18 @@ public struct ClaudeAdapter: Sendable {
return nil
}

let fallbackSessionID = url.deletingPathExtension().lastPathComponent
var sessionID = fallbackSessionID
let sessionID = claudeSessionID(url: url, objects: objects)
var cwd = ""
var title: String?
var model: String?
var firstTimestamp: Date?
var lastTimestamp: Date?
var events: [CanonicalEvent] = []

for (index, object) in objects.enumerated() {
if let value = object.string("sessionId") {
sessionID = value
}
if cwd.isEmpty, let value = object.string("cwd") {
if let value = object.string("cwd"),
cwd.isEmpty || object.string("sessionId") == sessionID {
cwd = value
}
// ai-title lines recur as Claude re-titles the session; latest wins.
if let aiTitle = object.string("aiTitle") {
title = aiTitle
}

let timestamp = DateCoding.parse(object.string("timestamp")) ?? firstTimestamp ?? Date(timeIntervalSince1970: 0)
if firstTimestamp == nil, object.string("timestamp") != nil {
Expand Down Expand Up @@ -90,7 +82,7 @@ public struct ClaudeAdapter: Sendable {

let createdAt = firstTimestamp ?? events.first?.timestamp ?? Date()
let updatedAt = lastTimestamp ?? events.last?.timestamp ?? createdAt
let resolvedTitle = cleanTitle(title)
let resolvedTitle = claudeSessionTitle(objects: objects, sessionID: sessionID)
?? cleanTitle(events.first(where: { $0.role == .user && !looksLikeInjectedContext($0.text) })?.text)
?? "Claude session \(sessionID)"

Expand Down Expand Up @@ -255,6 +247,32 @@ public struct ClaudeAdapter: Sendable {
}
}

/// Forks can begin with copied records that still carry the parent's ID.
/// Native UUID filenames identify the session being read, regardless of history.
func claudeSessionID(url: URL, objects: [[String: JSONValue]]) -> String {
let filename = url.deletingPathExtension().lastPathComponent
if UUID(uuidString: filename) != nil {
return filename
}
return objects.reversed().compactMap { $0.string("sessionId") }.first ?? filename
}

/// Explicit names win over automatic names. Prefer this session's metadata;
/// copied parent metadata is a fallback for forks that have not been named yet.
func claudeSessionTitle(objects: [[String: JSONValue]], sessionID: String) -> String? {
func title(in records: [[String: JSONValue]]) -> String? {
for key in ["customTitle", "aiTitle"] {
if let value = records.reversed().compactMap({ cleanTitle($0.string(key)) }).first {
return value
}
}
return nil
}
return title(in: objects.filter { $0.string("sessionId") == sessionID })
?? title(in: objects.filter { $0.string("sessionId") == nil })
?? title(in: objects)
}

private func renderClaudeQuestions(_ input: [String: JSONValue]) -> String? {
guard let questions = input.array("questions") else {
return nil
Expand Down
53 changes: 53 additions & 0 deletions Sources/AgentSyncCore/ClaudeTitleCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Foundation

/// Keeps repeated picker refreshes from streaming unchanged transcripts.
/// A cached entry also represents a successful scan that found no title.
final class ClaudeTitleCache: @unchecked Sendable {
private struct Stamp: Equatable {
let modifiedAt: Date
let size: Int
let sessionID: String
}

private struct Entry {
let stamp: Stamp
let title: String?
}

private let lock = NSLock()
private var entries: [String: Entry] = [:]
private let capacity: Int

init(capacity: Int = 512) {
self.capacity = max(1, capacity)
}

func title(for url: URL, sessionID: String, load: () throws -> String?) throws -> String? {
func stamp() throws -> Stamp? {
// Use a fresh URL so resource-value caching cannot hide a change.
let values = try URL(fileURLWithPath: url.path).resourceValues(
forKeys: [.contentModificationDateKey, .fileSizeKey]
)
guard let modifiedAt = values.contentModificationDate, let size = values.fileSize else {
return nil
}
return Stamp(modifiedAt: modifiedAt, size: size, sessionID: sessionID)
}

let before = try stamp()
if let before, let cached = lock.withLock({ entries[url.path] }), cached.stamp == before {
return cached.title
}
let title = try load()
// Never cache a failed read or a result from a file changing mid-scan.
if let before, before == (try stamp()) {
lock.withLock {
if entries[url.path] == nil, entries.count >= capacity {
entries.removeAll(keepingCapacity: true)
}
entries[url.path] = Entry(stamp: before, title: title)
}
}
return title
}
}
101 changes: 90 additions & 11 deletions Sources/AgentSyncCore/RecentSessionScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ public struct SessionPreview: Identifiable, Equatable, Sendable {
}

public enum RecentSessionScanner {
private static let claudeTitleCache = ClaudeTitleCache()

/// Newest-first previews across all providers.
public static func scan(
claudeHome: URL,
Expand Down Expand Up @@ -331,36 +333,36 @@ public enum RecentSessionScanner {
prefixObjects: [[String: JSONValue]],
tailObjects: [[String: JSONValue]]
) -> SessionPreview {
let fallbackID = url.deletingPathExtension().lastPathComponent
var sessionID = fallbackID
let edgeObjects = prefixObjects + tailObjects
let sessionID = claudeSessionID(url: url, objects: edgeObjects)
var cwd = PathEncoding.decodeClaudeProjectName(url.deletingLastPathComponent().lastPathComponent)
var title: String?
var snippet: String?
var models: [String] = []

for object in prefixObjects {
sessionID = object.string("sessionId") ?? sessionID
cwd = object.string("cwd") ?? cwd
if let message = object.object("message") {
if let model = message.string("model"), !models.contains(model) {
models.append(model)
}
if snippet == nil,
object.string("type") == "user",
let text = previewText(from: message["content"]),
let text = claudePromptPreview(from: message["content"]),
!looksLikeInjectedContext(text) {
snippet = text
}
}
}
// ai-title lines recur through the file as Claude re-titles; the last
// one in the tail is current.
for object in (prefixObjects + tailObjects) {
if let aiTitle = object.string("aiTitle") {
title = aiTitle
// Fork prefixes may still describe the parent's working directory.
for object in edgeObjects where object.string("sessionId") == sessionID {
if let value = object.string("cwd") {
cwd = value
}
}
let sourceTitle = cleanTitle(title)
let sourceTitle = (try? claudeTitleCache.title(for: url, sessionID: sessionID) {
try readClaudeTitle(from: url, sessionID: sessionID)
})
?? claudeSessionTitle(objects: edgeObjects, sessionID: sessionID)

return SessionPreview(
provider: .claude,
Expand All @@ -376,6 +378,83 @@ public enum RecentSessionScanner {
)
}

/// Names may be buried between the preview edges. Stream the file without
/// decoding message bodies or retaining the transcript. Title records are
/// small; oversized lines are discarded until the next newline.
private static func readClaudeTitle(from url: URL, sessionID: String) throws -> String? {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
let chunkSize = 64 * 1024
let titleKeys = ["customTitle", "aiTitle"]
let markers = titleKeys.map { Data("\"\($0)\"".utf8) }
var line = Data()
var oversized = false
// At most six records: latest custom/automatic title in each scope
// (this session, unscoped, or copied parent history).
var latest: [String: (Int, [String: JSONValue])] = [:]
var recordIndex = 0

func consumeLine() {
defer { line.removeAll(keepingCapacity: true); oversized = false }
guard !oversized, markers.contains(where: { line.range(of: $0) != nil }),
let raw = try? JSONSerialization.jsonObject(with: line),
let value = try? JSONValue.foundation(raw),
let object = value.objectValue else { return }
let id = object.string("sessionId")
let scope = id == sessionID ? "own" : (id == nil ? "unscoped" : "parent")
for key in titleKeys where cleanTitle(object.string(key)) != nil {
latest["\(scope):\(key)"] = (recordIndex, object)
}
recordIndex += 1
}

while let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty {
var start = chunk.startIndex
while start < chunk.endIndex {
let newline = chunk[start...].firstIndex(of: 0x0a)
let end = newline ?? chunk.endIndex
if !oversized {
if line.count + (end - start) <= chunkSize {
line.append(contentsOf: chunk[start..<end])
} else {
line.removeAll(keepingCapacity: true)
oversized = true
}
}
if let newline {
consumeLine()
start = newline + 1
} else {
break
}
}
}
if !line.isEmpty { consumeLine() }
let records = latest.values.sorted { $0.0 < $1.0 }.map { $0.1 }
return claudeSessionTitle(objects: records, sessionID: sessionID)
}

private static func claudePromptPreview(from value: JSONValue?) -> String? {
// Filter individual content blocks before joining. Claude Desktop puts
// worktree reminders in a separate block before the human's request.
if case .array(let values) = value {
let text = values.compactMap { item -> String? in
let text: String?
if let object = item.objectValue {
guard object.string("type") == "text" else { return nil }
text = object.string("text")
} else {
text = item.stringValue
}
guard let text, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!looksLikeInjectedContext(text) else { return nil }
return text
}.joined(separator: " ")
return text.isEmpty ? nil : text
}
return previewText(from: value)
}

private static func codexPreview(
url: URL,
modifiedAt: Date,
Expand Down
Loading
Loading