From 95471b860da170abc924fc33b9570394a8d6cddb Mon Sep 17 00:00:00 2001 From: scgopi Date: Sat, 12 Sep 2026 15:55:37 -0700 Subject: [PATCH] feat: transplant pi sessions through node export/import pi keeps one JSONL per session under ~/.pi/agent/sessions//, named _.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 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) Claude-Session: https://claude.ai/code/session_01KBuQWNV3dpDAtb6SnGN15P --- .../Sessions/SessionTransplant+Layouts.swift | 189 ++++++++++++++++++ .../Sources/Sessions/SessionTransplant.swift | 158 +++++---------- .../Tests/PiSessionTransplantTests.swift | 121 +++++++++++ .../Tests/RemoteSessionExportTests.swift | 29 ++- .../Tests/RemoteSessionTransplantTests.swift | 15 ++ 5 files changed, 405 insertions(+), 107 deletions(-) create mode 100644 GraphcodeKit/Sources/Sessions/SessionTransplant+Layouts.swift create mode 100644 graphcode/Tests/PiSessionTransplantTests.swift diff --git a/GraphcodeKit/Sources/Sessions/SessionTransplant+Layouts.swift b/GraphcodeKit/Sources/Sessions/SessionTransplant+Layouts.swift new file mode 100644 index 00000000..054beaab --- /dev/null +++ b/GraphcodeKit/Sources/Sessions/SessionTransplant+Layouts.swift @@ -0,0 +1,189 @@ +import Foundation + +extension SessionTransplant { + static func restorePi( + _ artifact: Artifact, forNodeID nodeID: UUID, projectPath: String + ) -> String? { + guard let session = artifact.files["session.jsonl"] else { return nil } + let freshID = UUID().uuidString.lowercased() + guard + let rewritten = rewritingPiSession( + session, replacing: artifact.sessionID, with: freshID, workingDirectory: projectPath) + else { return nil } + let directory = + piSessionsRoot + .appendingPathComponent(piSessionSlug(forWorkingDirectory: projectPath)) + guard write(rewritten, to: directory.appendingPathComponent(piSessionFileName(id: freshID))) + else { return nil } + SessionIDStore.save(freshID, forNodeID: nodeID) + return freshID + } + + // MARK: - Backend layouts + + static var claudeProjectsRoot: URL { + URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent("projects", isDirectory: true) + } + + /// Where imported Codex rollouts land: a dated directory like the ones `codex` + /// itself writes, under today's date at import time. + static var codexImportDirectory: URL { + let parts = Calendar(identifier: .gregorian) + .dateComponents([.year, .month, .day], from: Date()) + return CodexSessionLog.sessionsDirectory + .appendingPathComponent(String(parts.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", parts.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", parts.day ?? 1), isDirectory: true) + } + + /// Claude Code's directory name for a working directory: the *resolved* path with + /// every non-alphanumeric character replaced by `-`. Resolution matters — a session + /// started in `/tmp/x` is recorded under `-private-tmp-x` — and it has to be POSIX + /// `realpath`, because Foundation's `resolvingSymlinksInPath()` deliberately leaves + /// `/private` prefixes unresolved and produced the wrong directory for exactly + /// those paths. + static func claudeProjectSlug(forWorkingDirectory path: String) -> String { + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) + let resolved = path.withCString { realpath($0, &buffer).map { String(cString: $0) } } ?? path + return String(resolved.map { $0.isLetter || $0.isNumber ? $0 : "-" }) + } + + static func findClaudeTranscript(sessionID: String) -> URL? { + // Found by id across every project directory rather than by reconstructing which + // directory the session ran in — a loop bound to a worktree recorded its + // transcript under the worktree's slug, not the project's, and the id is unique + // either way. + let fileManager = FileManager.default + guard + let projectDirs = try? fileManager.contentsOfDirectory( + at: claudeProjectsRoot, includingPropertiesForKeys: nil) + else { return nil } + for directory in projectDirs { + let candidate = directory.appendingPathComponent("\(sessionID).jsonl") + if fileManager.fileExists(atPath: candidate.path) { return candidate } + } + return nil + } + + static var piSessionsRoot: URL { + URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + } + + /// pi's directory name for a working directory: `----`, the leading separator + /// dropped and every `/`, `\` and `:` replaced by `-`. pi applies it to `process.cwd()`, + /// which is already resolved, hence `realpath` as for Claude's slug. + static func piSessionSlug(forWorkingDirectory path: String) -> String { + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) + let resolved = path.withCString { realpath($0, &buffer).map { String(cString: $0) } } ?? path + let trimmed = resolved.hasPrefix("/") ? String(resolved.dropFirst()) : resolved + return "--" + String(trimmed.map { "/\\:".contains($0) ? "-" : $0 }) + "--" + } + + /// `_.jsonl`, the timestamp in pi's own shape: ISO 8601 with `:` and `.` + /// replaced by `-`. + static func piSessionFileName(id: String, at date: Date = Date()) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let stamp = formatter.string(from: date) + .replacingOccurrences(of: ":", with: "-") + .replacingOccurrences(of: ".", with: "-") + return "\(stamp)_\(id).jsonl" + } + + /// The session with every occurrence of its id replaced and the header line's `id` and + /// `cwd` set to the new identity and working directory. Nil when the first line is not a + /// pi session header — a file pi itself would refuse to list. + static func rewritingPiSession( + _ data: Data, replacing oldID: String, with freshID: String, workingDirectory: String + ) -> Data? { + let body = rewriting(data, replacing: oldID, with: freshID) + let newline = body.firstIndex(of: UInt8(ascii: "\n")) ?? body.endIndex + guard + var header = (try? JSONSerialization.jsonObject(with: Data(body[.. URL? { + let fileManager = FileManager.default + guard + let slugDirs = try? fileManager.contentsOfDirectory( + at: piSessionsRoot, includingPropertiesForKeys: nil) + else { return nil } + let suffix = "_\(sessionID).jsonl" + for directory in slugDirs { + guard let names = try? fileManager.contentsOfDirectory(atPath: directory.path) else { + continue + } + if let name = names.first(where: { $0.hasSuffix(suffix) }) { + return directory.appendingPathComponent(name) + } + } + return nil + } + + /// The `` inside a `rollout--.jsonl` filename. + static func rolloutUUID(in filename: String) -> String? { + let stem = filename.hasSuffix(".jsonl") ? String(filename.dropLast(6)) : filename + let tail = stem.split(separator: "-").suffix(5).joined(separator: "-") + return UUID(uuidString: tail) != nil ? tail : nil + } + + // MARK: - File plumbing + + static func filesUnder(_ root: URL) -> [String: Data] { + var files: [String: Data] = [:] + let fileManager = FileManager.default + guard + let enumerator = fileManager.enumerator( + at: root, includingPropertiesForKeys: [.isRegularFileKey]) + else { return files } + // Resolved on both sides before the prefix strip, or a symlinked component + // (`/var` → `/private/var`) turns every relative key into an absolute path. + let rootPrefix = root.resolvingSymlinksInPath().path + "/" + for case let url as URL in enumerator { + guard (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true + else { continue } + let resolved = url.resolvingSymlinksInPath().path + guard resolved.hasPrefix(rootPrefix) else { continue } + if let data = try? Data(contentsOf: url) { + files[String(resolved.dropFirst(rootPrefix.count))] = data + } + } + return files + } + + /// Text files get the old identity swapped for the new; anything that doesn't + /// decode as UTF-8 passes through untouched rather than being corrupted by a + /// byte-level splice. + static func rewriting(_ data: Data, replacing old: String, with new: String) -> Data { + guard let text = String(data: data, encoding: .utf8) else { return data } + return Data(text.replacingOccurrences(of: old, with: new).utf8) + } + + static func write(_ data: Data, to url: URL) -> Bool { + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + return true + } catch { + return false + } + } +} diff --git a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift index cf1e1baa..832391cf 100644 --- a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift +++ b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift @@ -18,6 +18,11 @@ import Foundation /// along as carried history — readable in the bundle, installed under /// `~/.codex/sessions` for `codex`'s own pickers — and the imported loop's session /// starts fresh, exactly as every Codex relaunch does. +/// - **pi** transplants fully. Each session is one JSONL file under +/// `~/.pi/agent/sessions//` whose header line names its id and cwd; the copy +/// is installed under the target's slug with both rewritten, and `--session ` +/// resumes it. A session found only under another project's slug would stop at pi's +/// interactive "fork into current directory?" prompt, so the slug is not optional. public enum SessionTransplant { /// What one node's session contributes to an export bundle: the backend's own /// on-disk state, as relative-path → content, plus the id it was recorded under. @@ -84,9 +89,14 @@ public enum SessionTransplant { files: ["rollout.jsonl": rollout]) case .pi: - // pi keeps a JSONL file per session that could travel; nothing restores it under a - // fresh identity yet, so an exported pi loop starts fresh. - return nil + guard let sessionID = SessionIDStore.load(forNodeID: node.id), + let url = findPiSession(sessionID: sessionID), + let session = try? Data(contentsOf: url) + else { return nil } + return Artifact( + backend: .pi, sessionID: sessionID, + sourceWorkingDirectory: workingDirectory, + files: ["session.jsonl": session]) case .openCode: // OpenCode's conversations live in one SQLite database shared by every session on @@ -217,6 +227,8 @@ public enum SessionTransplant { /// session graphcode launched it as — the walk `remoteIDBankFragment` does. /// - Codex: the newest rollout whose header opened in the loop's working directory, /// the match `CodexSessionLog.remoteSummaryInvocation` makes. + /// - pi: the banked id — the file its extension writes — then the `*_.jsonl` file + /// across every slug directory, for the same worktree reason as Claude. /// - OpenCode: nothing, for the reason the local export carries nothing. /// /// The archive's first path component is the session's identity — `.jsonl`, @@ -251,7 +263,11 @@ public enum SessionTransplant { + "if head -c 65536 \"$f\" 2>/dev/null | grep -q \"\\\"cwd\\\":\\\"$W\\\"\"; " + "then F=\"$f\"; break; fi; done; " + "[ -n \"$F\" ] || exit 0; exec tar -cf - -C \"$(dirname \"$F\")\" \"$(basename \"$F\")\"" - case .openCode, .pi: + case .pi: + return "S=$(cat \(idFile) 2>/dev/null); [ -n \"$S\" ] || exit 0; " + + "F=$(ls -t \"$HOME\"/.pi/agent/sessions/*/*_\"$S\".jsonl 2>/dev/null | head -1); " + + "[ -n \"$F\" ] || exit 0; exec tar -cf - -C \"$(dirname \"$F\")\" \"$(basename \"$F\")\"" + case .openCode: return nil } } @@ -298,7 +314,17 @@ public enum SessionTransplant { return Artifact( backend: .codex, sessionID: only.name, sourceWorkingDirectory: workingDirectory, files: ["rollout.jsonl": only.data]) - case .openCode, .pi: + case .pi: + guard let only = singleFile(in: files), + let separator = only.name.lastIndex(of: "_") + else { return nil } + let sessionID = String( + only.name[only.name.index(after: separator)...].dropLast(".jsonl".count)) + guard !sessionID.isEmpty else { return nil } + return Artifact( + backend: .pi, sessionID: sessionID, + sourceWorkingDirectory: workingDirectory, files: ["session.jsonl": only.data]) + case .openCode: return nil } } @@ -339,7 +365,8 @@ public enum SessionTransplant { case .claudeCode: return restoreClaude(artifact, forNodeID: nodeID, projectPath: projectPath) case .copilotCLI: return restoreCopilot(artifact, forNodeID: nodeID) case .codex: return restoreCodex(artifact, projectPath: projectPath) - case .openCode, .pi: return nil + case .pi: return restorePi(artifact, forNodeID: nodeID, projectPath: projectPath) + case .openCode: return nil } } @@ -413,7 +440,17 @@ public enum SessionTransplant { for (relativePath, data) in artifact.files { staged[relativePath] = rewriting(data, replacing: artifact.sessionID, with: freshID) } - case .codex, .openCode, .pi: + case .pi: + // The header's cwd is the host's unresolved project path: pi opens the session there, + // which is the same directory, while the slug needs the resolved form and is + // computed on the host. + guard let session = artifact.files["session.jsonl"], + let rewritten = rewritingPiSession( + session, replacing: artifact.sessionID, with: freshID, + workingDirectory: location.remotePath) + else { return nil } + staged[piSessionFileName(id: freshID)] = rewritten + case .codex, .openCode: return nil } guard await deliver(files: staged, remoteScript: script, at: location) else { return nil } @@ -445,7 +482,14 @@ public enum SessionTransplant { return "set -e; dir=\"$HOME/.copilot/session-state/\(freshID)\"; " + "mkdir -p \"$dir\" \"$HOME/.graphcode/sessions\"; " + "tar -xf - -C \"$dir\"; \(bank)" - case .codex, .openCode, .pi: + case .pi: + let repo = RemoteProjectLocation.shellQuoted(location.remotePath) + return "set -e; p=$(cd \(repo) && pwd -P); " + + "slug=\"--$(printf %s \"${p#/}\" | tr '/:' '--')--\"; " + + "dir=\"$HOME/.pi/agent/sessions/$slug\"; " + + "mkdir -p \"$dir\" \"$HOME/.graphcode/sessions\"; " + + "tar -xf - -C \"$dir\"; \(bank)" + case .codex, .openCode: return nil } } @@ -499,102 +543,4 @@ public enum SessionTransplant { _ = write(rewritten, to: codexImportDirectory.appendingPathComponent(freshName)) return nil } - - // MARK: - Backend layouts - - static var claudeProjectsRoot: URL { - URL(fileURLWithPath: NSHomeDirectory()) - .appendingPathComponent(".claude", isDirectory: true) - .appendingPathComponent("projects", isDirectory: true) - } - - /// Where imported Codex rollouts land: a dated directory like the ones `codex` - /// itself writes, under today's date at import time. - static var codexImportDirectory: URL { - let parts = Calendar(identifier: .gregorian) - .dateComponents([.year, .month, .day], from: Date()) - return CodexSessionLog.sessionsDirectory - .appendingPathComponent(String(parts.year ?? 1970), isDirectory: true) - .appendingPathComponent(String(format: "%02d", parts.month ?? 1), isDirectory: true) - .appendingPathComponent(String(format: "%02d", parts.day ?? 1), isDirectory: true) - } - - /// Claude Code's directory name for a working directory: the *resolved* path with - /// every non-alphanumeric character replaced by `-`. Resolution matters — a session - /// started in `/tmp/x` is recorded under `-private-tmp-x` — and it has to be POSIX - /// `realpath`, because Foundation's `resolvingSymlinksInPath()` deliberately leaves - /// `/private` prefixes unresolved and produced the wrong directory for exactly - /// those paths. - static func claudeProjectSlug(forWorkingDirectory path: String) -> String { - var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) - let resolved = path.withCString { realpath($0, &buffer).map { String(cString: $0) } } ?? path - return String(resolved.map { $0.isLetter || $0.isNumber ? $0 : "-" }) - } - - private static func findClaudeTranscript(sessionID: String) -> URL? { - // Found by id across every project directory rather than by reconstructing which - // directory the session ran in — a loop bound to a worktree recorded its - // transcript under the worktree's slug, not the project's, and the id is unique - // either way. - let fileManager = FileManager.default - guard - let projectDirs = try? fileManager.contentsOfDirectory( - at: claudeProjectsRoot, includingPropertiesForKeys: nil) - else { return nil } - for directory in projectDirs { - let candidate = directory.appendingPathComponent("\(sessionID).jsonl") - if fileManager.fileExists(atPath: candidate.path) { return candidate } - } - return nil - } - - /// The `` inside a `rollout--.jsonl` filename. - private static func rolloutUUID(in filename: String) -> String? { - let stem = filename.hasSuffix(".jsonl") ? String(filename.dropLast(6)) : filename - let tail = stem.split(separator: "-").suffix(5).joined(separator: "-") - return UUID(uuidString: tail) != nil ? tail : nil - } - - // MARK: - File plumbing - - private static func filesUnder(_ root: URL) -> [String: Data] { - var files: [String: Data] = [:] - let fileManager = FileManager.default - guard - let enumerator = fileManager.enumerator( - at: root, includingPropertiesForKeys: [.isRegularFileKey]) - else { return files } - // Resolved on both sides before the prefix strip, or a symlinked component - // (`/var` → `/private/var`) turns every relative key into an absolute path. - let rootPrefix = root.resolvingSymlinksInPath().path + "/" - for case let url as URL in enumerator { - guard (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true - else { continue } - let resolved = url.resolvingSymlinksInPath().path - guard resolved.hasPrefix(rootPrefix) else { continue } - if let data = try? Data(contentsOf: url) { - files[String(resolved.dropFirst(rootPrefix.count))] = data - } - } - return files - } - - /// Text files get the old identity swapped for the new; anything that doesn't - /// decode as UTF-8 passes through untouched rather than being corrupted by a - /// byte-level splice. - private static func rewriting(_ data: Data, replacing old: String, with new: String) -> Data { - guard let text = String(data: data, encoding: .utf8) else { return data } - return Data(text.replacingOccurrences(of: old, with: new).utf8) - } - - private static func write(_ data: Data, to url: URL) -> Bool { - do { - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - try data.write(to: url, options: .atomic) - return true - } catch { - return false - } - } } diff --git a/graphcode/Tests/PiSessionTransplantTests.swift b/graphcode/Tests/PiSessionTransplantTests.swift new file mode 100644 index 00000000..6f1bdfa5 --- /dev/null +++ b/graphcode/Tests/PiSessionTransplantTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing + +@testable import GraphcodeKit + +/// pi resumes `--session ` only when the file sits under the working directory's own +/// slug and its header names that id; found under another slug it stops at an interactive +/// "fork into current directory?" prompt, and the header's cwd is where pi reopens it. +@Suite +struct PiSessionTransplantTests { + private let session = Data( + """ + {"type":"session","version":3,"id":"old-id","timestamp":"2026-09-12T22:30:30.343Z","cwd":"/Users/someone/src"} + {"type":"model_change","id":"856c8497","parentId":null} + {"type":"message","id":"a1","parentId":"856c8497","note":"resumed old-id"} + + """.utf8) + + @Test + func headerTakesTheFreshIDAndTheTargetWorkingDirectory() throws { + let rewritten = try #require( + SessionTransplant.rewritingPiSession( + session, replacing: "old-id", with: "fresh-id", workingDirectory: "/srv/widget")) + let lines = try #require(String(data: rewritten, encoding: .utf8)) + .split(separator: "\n", omittingEmptySubsequences: false) + + let header = try #require( + try JSONSerialization.jsonObject(with: Data(lines[0].utf8)) as? [String: Any]) + #expect(header["type"] as? String == "session") + #expect(header["id"] as? String == "fresh-id") + #expect(header["cwd"] as? String == "/srv/widget") + #expect(header["version"] as? Int == 3) + #expect(lines[0].contains("\"cwd\":\"/srv/widget\"")) + #expect(lines[1] == #"{"type":"model_change","id":"856c8497","parentId":null}"#) + #expect(lines[2].contains("resumed fresh-id")) + #expect(lines.count == 4) + } + + @Test + func aFileThatDoesNotOpenWithASessionHeaderIsRefused() { + #expect( + SessionTransplant.rewritingPiSession( + Data("{\"type\":\"message\"}\n".utf8), replacing: "a", with: "b", workingDirectory: "/") + == nil) + #expect( + SessionTransplant.rewritingPiSession( + Data("not json".utf8), replacing: "a", with: "b", workingDirectory: "/") == nil) + } + + @Test + func slugMatchesPisOwnEncodingOfTheResolvedPath() { + #expect( + SessionTransplant.piSessionSlug(forWorkingDirectory: "/Volumes/SCG/wd/graphcode") + == "--Volumes-SCG-wd-graphcode--") + #expect( + SessionTransplant.piSessionSlug(forWorkingDirectory: "/no-such/dir.d/x:y") + == "--no-such-dir.d-x-y--") + #expect(SessionTransplant.piSessionSlug(forWorkingDirectory: "/tmp") == "--private-tmp--") + } + + @Test + func fileNameCarriesPisTimestampShapeAndTheID() { + let name = SessionTransplant.piSessionFileName( + id: "fresh-id", at: Date(timeIntervalSince1970: 1_789_338_630.343)) + #expect(name.hasSuffix("_fresh-id.jsonl")) + let shape = #"^\d{4}-\d\d-\d\dT\d\d-\d\d-\d\d-\d{3}Z_"# + #expect(name.range(of: shape, options: .regularExpression) != nil) + } + + @Test + func remoteInstallLandsWhereTheSwiftSlugSaysAndBanksTheID() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("pi-transplant-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appendingPathComponent("home", isDirectory: true) + let repo = root.appendingPathComponent("repo:x", isDirectory: true) + let staging = root.appendingPathComponent("staging", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) + let fileName = SessionTransplant.piSessionFileName(id: "fresh-id") + try session.write(to: staging.appendingPathComponent(fileName)) + + let nodeID = UUID() + let location = RemoteProjectLocation(user: "dev", host: "box", remotePath: repo.path) + let artifact = SessionTransplant.Artifact( + backend: .pi, sessionID: "old-id", sourceWorkingDirectory: nil, + files: ["session.jsonl": session]) + let script = try #require( + SessionTransplant.remoteInstallScript( + for: artifact, freshID: "fresh-id", nodeID: nodeID, at: location)) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = [ + "-c", + "tar -C \(RemoteProjectLocation.shellQuoted(staging.path)) -cf - . | /bin/sh -c " + + RemoteProjectLocation.shellQuoted(script), + ] + process.environment = ["HOME": home.path, "PATH": "/usr/bin:/bin"] + let status: Int32 = try await withCheckedThrowingContinuation { continuation in + process.terminationHandler = { continuation.resume(returning: $0.terminationStatus) } + do { + try process.run() + } catch { + process.terminationHandler = nil + continuation.resume(throwing: error) + } + } + #expect(status == 0) + + let installed = home.appendingPathComponent(".pi/agent/sessions") + .appendingPathComponent(SessionTransplant.piSessionSlug(forWorkingDirectory: repo.path)) + .appendingPathComponent(fileName) + #expect(FileManager.default.fileExists(atPath: installed.path)) + let banked = try String( + contentsOf: home.appendingPathComponent(".graphcode/sessions/\(nodeID.uuidString).id"), + encoding: .utf8) + #expect(banked == "fresh-id") + } +} diff --git a/graphcode/Tests/RemoteSessionExportTests.swift b/graphcode/Tests/RemoteSessionExportTests.swift index 6bde17f1..085f7f1e 100644 --- a/graphcode/Tests/RemoteSessionExportTests.swift +++ b/graphcode/Tests/RemoteSessionExportTests.swift @@ -48,7 +48,7 @@ struct RemoteSessionExportTests { @Test func fetchScriptsStreamNothingAndExitCleanlyWhenNothingIsBanked() throws { - for backend in [CLISessionBackendKind.claudeCode, .copilotCLI, .codex] { + for backend in [CLISessionBackendKind.claudeCode, .copilotCLI, .codex, .pi] { let script = try script(node(backend)) #expect(script.contains("|| exit 0"), "\(backend)") // The archive is the whole of stdout: nothing may print before `tar` does. @@ -89,6 +89,18 @@ struct RemoteSessionExportTests { project.hasSuffix("exec tar -cf - -C \"$(dirname \"$F\")\" \"$(basename \"$F\")\"")) } + @Test + func piFetchReadsTheBankedIDThenTheSessionFileByID() throws { + let node = node(.pi) + let script = try script(node) + + #expect( + script.contains("S=$(cat \(PresenceHooks.remoteSessionIDExpression(forNodeID: node.id))")) + #expect(script.contains("\"$HOME\"/.pi/agent/sessions/*/*_\"$S\".jsonl")) + #expect( + script.hasSuffix("exec tar -cf - -C \"$(dirname \"$F\")\" \"$(basename \"$F\")\"")) + } + @Test func openCodeHasNoRemoteFetch() { #expect(SessionTransplant.remoteExportScript(forNode: node(.openCode), at: location) == nil) @@ -213,6 +225,21 @@ struct RemoteSessionExportTests { #expect(artifact.files == ["rollout.jsonl": Data("r".utf8)]) } + @Test + func piArchiveBecomesTheSessionArtifactKeyedByTheIDInItsName() throws { + let name = "2026-09-12T22-30-30-343Z_01a097be-5cc6-7580-b4e2-43834b154219.jsonl" + let artifact = try #require( + SessionTransplant.artifact( + fromFetched: [name: Data("s".utf8)], backend: .pi, workingDirectory: "/srv/widget")) + + #expect(artifact.backend == .pi) + #expect(artifact.sessionID == "01a097be-5cc6-7580-b4e2-43834b154219") + #expect(artifact.files == ["session.jsonl": Data("s".utf8)]) + #expect( + SessionTransplant.artifact( + fromFetched: ["noid.jsonl": Data()], backend: .pi, workingDirectory: "/") == nil) + } + @Test func anythingButOneWholeSessionIsRefused() { let empty: [String: Data] = [:] diff --git a/graphcode/Tests/RemoteSessionTransplantTests.swift b/graphcode/Tests/RemoteSessionTransplantTests.swift index 8805918c..aa7adb22 100644 --- a/graphcode/Tests/RemoteSessionTransplantTests.swift +++ b/graphcode/Tests/RemoteSessionTransplantTests.swift @@ -65,6 +65,21 @@ struct RemoteSessionTransplantTests { #expect(script.contains(".graphcode/sessions/\(nodeID.uuidString).id")) } + @Test + func piInstallLandsInTheHostsOwnSlugDirectory() throws { + let script = try #require( + SessionTransplant.remoteInstallScript( + for: artifact(.pi, files: ["session.jsonl": Data("{}".utf8)]), + freshID: "fresh-id", nodeID: nodeID, at: location)) + + #expect(script.hasPrefix("set -e;")) + #expect(script.contains("cd '/workspaces/widget' && pwd -P")) + #expect(script.contains("$HOME/.pi/agent/sessions/$slug")) + let bank = try #require(script.range(of: ".graphcode/sessions/\(nodeID.uuidString).id")) + let untar = try #require(script.range(of: "tar -xf -")) + #expect(untar.lowerBound < bank.lowerBound) + } + @Test func backendsThatCannotResumeGetNoRemoteInstall() { #expect(