|
| 1 | +import Foundation |
| 2 | + |
| 3 | +extension SessionTransplant { |
| 4 | + static func restorePi( |
| 5 | + _ artifact: Artifact, forNodeID nodeID: UUID, projectPath: String |
| 6 | + ) -> String? { |
| 7 | + guard let session = artifact.files["session.jsonl"] else { return nil } |
| 8 | + let freshID = UUID().uuidString.lowercased() |
| 9 | + guard |
| 10 | + let rewritten = rewritingPiSession( |
| 11 | + session, replacing: artifact.sessionID, with: freshID, workingDirectory: projectPath) |
| 12 | + else { return nil } |
| 13 | + let directory = |
| 14 | + piSessionsRoot |
| 15 | + .appendingPathComponent(piSessionSlug(forWorkingDirectory: projectPath)) |
| 16 | + guard write(rewritten, to: directory.appendingPathComponent(piSessionFileName(id: freshID))) |
| 17 | + else { return nil } |
| 18 | + SessionIDStore.save(freshID, forNodeID: nodeID) |
| 19 | + return freshID |
| 20 | + } |
| 21 | + |
| 22 | + // MARK: - Backend layouts |
| 23 | + |
| 24 | + static var claudeProjectsRoot: URL { |
| 25 | + URL(fileURLWithPath: NSHomeDirectory()) |
| 26 | + .appendingPathComponent(".claude", isDirectory: true) |
| 27 | + .appendingPathComponent("projects", isDirectory: true) |
| 28 | + } |
| 29 | + |
| 30 | + /// Where imported Codex rollouts land: a dated directory like the ones `codex` |
| 31 | + /// itself writes, under today's date at import time. |
| 32 | + static var codexImportDirectory: URL { |
| 33 | + let parts = Calendar(identifier: .gregorian) |
| 34 | + .dateComponents([.year, .month, .day], from: Date()) |
| 35 | + return CodexSessionLog.sessionsDirectory |
| 36 | + .appendingPathComponent(String(parts.year ?? 1970), isDirectory: true) |
| 37 | + .appendingPathComponent(String(format: "%02d", parts.month ?? 1), isDirectory: true) |
| 38 | + .appendingPathComponent(String(format: "%02d", parts.day ?? 1), isDirectory: true) |
| 39 | + } |
| 40 | + |
| 41 | + /// Claude Code's directory name for a working directory: the *resolved* path with |
| 42 | + /// every non-alphanumeric character replaced by `-`. Resolution matters — a session |
| 43 | + /// started in `/tmp/x` is recorded under `-private-tmp-x` — and it has to be POSIX |
| 44 | + /// `realpath`, because Foundation's `resolvingSymlinksInPath()` deliberately leaves |
| 45 | + /// `/private` prefixes unresolved and produced the wrong directory for exactly |
| 46 | + /// those paths. |
| 47 | + static func claudeProjectSlug(forWorkingDirectory path: String) -> String { |
| 48 | + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) |
| 49 | + let resolved = path.withCString { realpath($0, &buffer).map { String(cString: $0) } } ?? path |
| 50 | + return String(resolved.map { $0.isLetter || $0.isNumber ? $0 : "-" }) |
| 51 | + } |
| 52 | + |
| 53 | + static func findClaudeTranscript(sessionID: String) -> URL? { |
| 54 | + // Found by id across every project directory rather than by reconstructing which |
| 55 | + // directory the session ran in — a loop bound to a worktree recorded its |
| 56 | + // transcript under the worktree's slug, not the project's, and the id is unique |
| 57 | + // either way. |
| 58 | + let fileManager = FileManager.default |
| 59 | + guard |
| 60 | + let projectDirs = try? fileManager.contentsOfDirectory( |
| 61 | + at: claudeProjectsRoot, includingPropertiesForKeys: nil) |
| 62 | + else { return nil } |
| 63 | + for directory in projectDirs { |
| 64 | + let candidate = directory.appendingPathComponent("\(sessionID).jsonl") |
| 65 | + if fileManager.fileExists(atPath: candidate.path) { return candidate } |
| 66 | + } |
| 67 | + return nil |
| 68 | + } |
| 69 | + |
| 70 | + static var piSessionsRoot: URL { |
| 71 | + URL(fileURLWithPath: NSHomeDirectory()) |
| 72 | + .appendingPathComponent(".pi", isDirectory: true) |
| 73 | + .appendingPathComponent("agent", isDirectory: true) |
| 74 | + .appendingPathComponent("sessions", isDirectory: true) |
| 75 | + } |
| 76 | + |
| 77 | + /// pi's directory name for a working directory: `--<path>--`, the leading separator |
| 78 | + /// dropped and every `/`, `\` and `:` replaced by `-`. pi applies it to `process.cwd()`, |
| 79 | + /// which is already resolved, hence `realpath` as for Claude's slug. |
| 80 | + static func piSessionSlug(forWorkingDirectory path: String) -> String { |
| 81 | + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) |
| 82 | + let resolved = path.withCString { realpath($0, &buffer).map { String(cString: $0) } } ?? path |
| 83 | + let trimmed = resolved.hasPrefix("/") ? String(resolved.dropFirst()) : resolved |
| 84 | + return "--" + String(trimmed.map { "/\\:".contains($0) ? "-" : $0 }) + "--" |
| 85 | + } |
| 86 | + |
| 87 | + /// `<timestamp>_<id>.jsonl`, the timestamp in pi's own shape: ISO 8601 with `:` and `.` |
| 88 | + /// replaced by `-`. |
| 89 | + static func piSessionFileName(id: String, at date: Date = Date()) -> String { |
| 90 | + let formatter = ISO8601DateFormatter() |
| 91 | + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] |
| 92 | + let stamp = formatter.string(from: date) |
| 93 | + .replacingOccurrences(of: ":", with: "-") |
| 94 | + .replacingOccurrences(of: ".", with: "-") |
| 95 | + return "\(stamp)_\(id).jsonl" |
| 96 | + } |
| 97 | + |
| 98 | + /// The session with every occurrence of its id replaced and the header line's `id` and |
| 99 | + /// `cwd` set to the new identity and working directory. Nil when the first line is not a |
| 100 | + /// pi session header — a file pi itself would refuse to list. |
| 101 | + static func rewritingPiSession( |
| 102 | + _ data: Data, replacing oldID: String, with freshID: String, workingDirectory: String |
| 103 | + ) -> Data? { |
| 104 | + let body = rewriting(data, replacing: oldID, with: freshID) |
| 105 | + let newline = body.firstIndex(of: UInt8(ascii: "\n")) ?? body.endIndex |
| 106 | + guard |
| 107 | + var header = (try? JSONSerialization.jsonObject(with: Data(body[..<newline]))) |
| 108 | + as? [String: Any], |
| 109 | + header["type"] as? String == "session" |
| 110 | + else { return nil } |
| 111 | + header["id"] = freshID |
| 112 | + header["cwd"] = workingDirectory |
| 113 | + guard |
| 114 | + let line = try? JSONSerialization.data( |
| 115 | + withJSONObject: header, options: [.sortedKeys, .withoutEscapingSlashes]) |
| 116 | + else { return nil } |
| 117 | + return line + body[newline...] |
| 118 | + } |
| 119 | + |
| 120 | + /// Found by id across every slug directory, like `findClaudeTranscript`: a |
| 121 | + /// worktree-bound loop recorded its session under the worktree's slug. |
| 122 | + static func findPiSession(sessionID: String) -> URL? { |
| 123 | + let fileManager = FileManager.default |
| 124 | + guard |
| 125 | + let slugDirs = try? fileManager.contentsOfDirectory( |
| 126 | + at: piSessionsRoot, includingPropertiesForKeys: nil) |
| 127 | + else { return nil } |
| 128 | + let suffix = "_\(sessionID).jsonl" |
| 129 | + for directory in slugDirs { |
| 130 | + guard let names = try? fileManager.contentsOfDirectory(atPath: directory.path) else { |
| 131 | + continue |
| 132 | + } |
| 133 | + if let name = names.first(where: { $0.hasSuffix(suffix) }) { |
| 134 | + return directory.appendingPathComponent(name) |
| 135 | + } |
| 136 | + } |
| 137 | + return nil |
| 138 | + } |
| 139 | + |
| 140 | + /// The `<uuid>` inside a `rollout-<timestamp>-<uuid>.jsonl` filename. |
| 141 | + static func rolloutUUID(in filename: String) -> String? { |
| 142 | + let stem = filename.hasSuffix(".jsonl") ? String(filename.dropLast(6)) : filename |
| 143 | + let tail = stem.split(separator: "-").suffix(5).joined(separator: "-") |
| 144 | + return UUID(uuidString: tail) != nil ? tail : nil |
| 145 | + } |
| 146 | + |
| 147 | + // MARK: - File plumbing |
| 148 | + |
| 149 | + static func filesUnder(_ root: URL) -> [String: Data] { |
| 150 | + var files: [String: Data] = [:] |
| 151 | + let fileManager = FileManager.default |
| 152 | + guard |
| 153 | + let enumerator = fileManager.enumerator( |
| 154 | + at: root, includingPropertiesForKeys: [.isRegularFileKey]) |
| 155 | + else { return files } |
| 156 | + // Resolved on both sides before the prefix strip, or a symlinked component |
| 157 | + // (`/var` → `/private/var`) turns every relative key into an absolute path. |
| 158 | + let rootPrefix = root.resolvingSymlinksInPath().path + "/" |
| 159 | + for case let url as URL in enumerator { |
| 160 | + guard (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true |
| 161 | + else { continue } |
| 162 | + let resolved = url.resolvingSymlinksInPath().path |
| 163 | + guard resolved.hasPrefix(rootPrefix) else { continue } |
| 164 | + if let data = try? Data(contentsOf: url) { |
| 165 | + files[String(resolved.dropFirst(rootPrefix.count))] = data |
| 166 | + } |
| 167 | + } |
| 168 | + return files |
| 169 | + } |
| 170 | + |
| 171 | + /// Text files get the old identity swapped for the new; anything that doesn't |
| 172 | + /// decode as UTF-8 passes through untouched rather than being corrupted by a |
| 173 | + /// byte-level splice. |
| 174 | + static func rewriting(_ data: Data, replacing old: String, with new: String) -> Data { |
| 175 | + guard let text = String(data: data, encoding: .utf8) else { return data } |
| 176 | + return Data(text.replacingOccurrences(of: old, with: new).utf8) |
| 177 | + } |
| 178 | + |
| 179 | + static func write(_ data: Data, to url: URL) -> Bool { |
| 180 | + do { |
| 181 | + try FileManager.default.createDirectory( |
| 182 | + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) |
| 183 | + try data.write(to: url, options: .atomic) |
| 184 | + return true |
| 185 | + } catch { |
| 186 | + return false |
| 187 | + } |
| 188 | + } |
| 189 | +} |
0 commit comments