Skip to content

Commit 95471b8

Browse files
scgopiclaude
andcommitted
feat: transplant pi sessions through node export/import
pi keeps one JSONL per session under ~/.pi/agent/sessions/<cwd slug>/, named <timestamp>_<id>.jsonl, whose header line names the id and cwd. Export carries that file (locally by the banked id, remotely through the same tar-over-ssh fetch Claude uses); restore rewrites the header's id to a fresh UUID and its cwd to the target, installs it under the target's slug, and banks the fresh id so --session <id> resumes it. The slug must be the target's own: pi only offers an interactive fork prompt for a session it finds under another project. The layout and file-plumbing helpers move to SessionTransplant+Layouts so the enum body stays under swiftlint's type_body_length limit, which the base branch already exceeded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBuQWNV3dpDAtb6SnGN15P
1 parent cfe871f commit 95471b8

5 files changed

Lines changed: 405 additions & 107 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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

Comments
 (0)