From 3dd061705236cddde28b5e377ca30e3abf0de288 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sat, 12 Sep 2026 15:47:25 -0700 Subject: [PATCH 1/6] feat: pi as a fifth backend `pi [prompt]` opens the TUI already running the prompt, which is graphcode's session model. Presence, activity, usage and the resume id come from one extension loaded with `-e `, writing the same zmx labels Claude Code's hooks do, so the Claude readers serve pi unchanged. It reports idle on `agent_settled` rather than `agent_end`, because pi can retry or run a queued follow-up after a run ends. Resume is `--session `, which exits 1 when the conversation is gone, so the resume-dead fallback applies. pi has no tool approvals; its one unattended stall is the project-trust prompt, settled by `--approve` (new PiProjectTrust setting, default approve). It has no `/goal`, `/loop`, MCP or sub-agents, so goals ride as prose and time loops use the daemon's cadence. Spiked against pi 0.85.1: flags read off `pi --help`, events off a probe extension run against the real binary with a fake zmx. Not yet: the summary rail and export/import. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KMVJyUtec3hpH5hF44xAgh --- .../Sources/CLI/GraphcodeCommand.swift | 2 +- .../Sources/Domain/BackendCapabilities.swift | 25 +++ .../Sources/Domain/BackendCommand.swift | 27 ++- .../Domain/CLISessionBackendKind.swift | 3 +- .../Sources/Domain/GraphcodeSettings.swift | 38 +++++ .../Sources/Domain/SessionBriefing.swift | 2 +- .../Sources/Sessions/CLISessionBackend.swift | 10 +- .../Sessions/PiPresenceExtension.swift | 122 ++++++++++++++ .../Sources/Sessions/PresenceHooks.swift | 36 +++- .../Sources/Sessions/RemoteGraphAccess.swift | 4 +- .../Sources/Sessions/SessionTransplant.swift | 15 +- .../Sources/Sessions/SummaryModelWriter.swift | 2 + .../Sources/Sessions/ZmxSessionLauncher.swift | 31 ++-- README.md | 8 +- docs/index.html | 2 +- .../Clients/TitleSuggestionClient.swift | 1 + .../Features/Settings/SettingsView.swift | 10 ++ .../Features/Welcome/OnboardingPages.swift | 1 + .../Ghostty/GhosttyTerminalView+Remote.swift | 1 + .../Ghostty/GhosttyTerminalView.swift | 2 + graphcode/Tests/CodexPresenceTests.swift | 6 +- graphcode/Tests/DaemonHeartbeatTests.swift | 4 +- graphcode/Tests/GoalBasedLoopTests.swift | 10 +- graphcode/Tests/GraphcodeSettingsTests.swift | 4 +- graphcode/Tests/NodeDraftTests.swift | 12 +- graphcode/Tests/PiBackendTests.swift | 158 ++++++++++++++++++ graphcode/Tests/ProjectFeatureTests.swift | 2 +- 27 files changed, 488 insertions(+), 50 deletions(-) create mode 100644 GraphcodeKit/Sources/Sessions/PiPresenceExtension.swift create mode 100644 graphcode/Tests/PiBackendTests.swift diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index 8230b6fb..ab62cc12 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -166,7 +166,7 @@ public enum GraphcodeCommand: Equatable, Sendable { every interval instead of the prompt carrying /loop. Needs "Daemon heartbeat" enabled in the app's Settings; the prompt is then the bare task, no cadence in it - --backend claudeCode | copilotCLI | codex | openCode — default: run from inside a + --backend claudeCode | copilotCLI | codex | openCode | pi — default: run from inside a loop, the creating loop's backend; otherwise the default in Settings -> Sessions --model fast | standard | capable (default: by loop type) diff --git a/GraphcodeKit/Sources/Domain/BackendCapabilities.swift b/GraphcodeKit/Sources/Domain/BackendCapabilities.swift index 47f9b329..087ca7ae 100644 --- a/GraphcodeKit/Sources/Domain/BackendCapabilities.swift +++ b/GraphcodeKit/Sources/Domain/BackendCapabilities.swift @@ -73,6 +73,7 @@ extension CLISessionBackendKind { case .copilotCLI: return "Copilot CLI" case .codex: return "Codex" case .openCode: return "OpenCode" + case .pi: return "Pi" } } @@ -208,6 +209,30 @@ extension CLISessionBackendKind { supportsInSessionRecurrence: false, supportsDaemonRecurrence: true, goalDirective: "/goal") + + case .pi: + // Spiked against pi 0.85.1 — flags read off `pi --help`, events off a probe extension + // run against the real binary. + // + // `pi [messages...]` opens the TUI already running the prompt (`-p` is the headless + // shape), and `--session ` resumes that exact conversation, exiting 1 when it is + // gone. pi has no tool approvals at all; the one prompt an unattended loop can stall + // at is project trust, which `--approve` settles (`GraphcodeSettings.PiProjectTrust`). + // + // `supportsHooks` is true through an extension loaded with `-e` whose events bracket + // a run and name every tool call (`PiPresenceExtension`). pi ships without MCP, + // sub-agents, `/goal` and `/loop` by design, so goals ride as prose — `goalDirective` + // nil — and recurrence is the daemon's. + return BackendCapabilities( + supportsGoalMode: true, + supportsHooks: true, + supportsStructuredOutput: false, + supportsSubAgents: false, + supportsMCP: false, + supportsMidSessionInput: true, + supportsInSessionRecurrence: false, + supportsDaemonRecurrence: true, + goalDirective: nil) } } diff --git a/GraphcodeKit/Sources/Domain/BackendCommand.swift b/GraphcodeKit/Sources/Domain/BackendCommand.swift index ffe4ff25..f4cb27cf 100644 --- a/GraphcodeKit/Sources/Domain/BackendCommand.swift +++ b/GraphcodeKit/Sources/Domain/BackendCommand.swift @@ -17,6 +17,7 @@ extension CLISessionBackendKind { case .copilotCLI: return "copilot" case .codex: return "codex" case .openCode: return "opencode" + case .pi: return "pi" } } @@ -52,6 +53,10 @@ extension CLISessionBackendKind { // to know (`opencode auth list`), not something a tier can name. Passing nothing // lets whatever `opencode` is configured to use apply. return [] + case .pi: + // `--model` takes `provider/id`, and which providers are logged in is the user's + // (`pi --list-models`), so nothing is passed and pi's own default applies. + return [] } } @@ -147,6 +152,15 @@ extension CLISessionBackendKind { SessionPrompt.composed( preamble: SessionBriefing.pointer(toBriefingAt: briefingPath), prompt: prompt), ] + case .pi: + // Positional, like Claude Code's. The briefing rides as a pointer inside the prompt: + // pi's `read` has no path gate, so it needs no directory grant either. + guard let briefingPath else { return model + [prompt] } + return model + + [ + SessionPrompt.composed( + preamble: SessionBriefing.pointer(toBriefingAt: briefingPath), prompt: prompt) + ] } } @@ -155,7 +169,7 @@ extension CLISessionBackendKind { /// same answer `launchArguments` gives. public var promptFlag: String? { switch self { - case .claudeCode, .codex: return nil + case .claudeCode, .codex, .pi: return nil case .copilotCLI: return "--interactive" case .openCode: return "--prompt" } @@ -164,7 +178,7 @@ extension CLISessionBackendKind { /// Whether a backend that verifies paths needs `--add-dir` for the briefing's folder. public var briefingNeedsDirectoryGrant: Bool { switch self { - case .claudeCode, .openCode: return false + case .claudeCode, .openCode, .pi: return false case .copilotCLI, .codex: return true } } @@ -195,6 +209,7 @@ extension CLISessionBackendKind { case .copilotCLI: return settings.copilotPermissions.arguments case .codex: return settings.codexApprovals.arguments case .openCode: return settings.openCodePermissions.arguments + case .pi: return settings.piProjectTrust.arguments } } @@ -205,6 +220,7 @@ extension CLISessionBackendKind { /// support changes both paths together rather than one silently drifting. public var supportsResume: Bool { self == .claudeCode || self == .copilotCLI || self == .codex || self == .openCode + || self == .pi } /// The argv that picks `sessionID` back up. OpenCode's `--session` and Codex's @@ -213,7 +229,7 @@ extension CLISessionBackendKind { switch self { case .claudeCode, .copilotCLI: return ["--resume", sessionID] case .codex: return ["resume", sessionID] - case .openCode: return ["--session", sessionID] + case .openCode, .pi: return ["--session", sessionID] } } @@ -226,7 +242,7 @@ extension CLISessionBackendKind { switch self { case .openCode: return hooksFile.map { ["OPENCODE_CONFIG": $0.path] } ?? [:] - case .claudeCode, .copilotCLI, .codex: + case .claudeCode, .copilotCLI, .codex, .pi: return [:] } } @@ -276,6 +292,9 @@ extension CLISessionBackendKind { // Reports through a plugin, which rides in the environment rather than the argv — // see `presenceEnvironment`. return [] + case .pi: + // An extension, loaded by path alongside the user's own — see `PiPresenceExtension`. + return hooksFile.map { ["-e", $0.path] } ?? [] } } } diff --git a/GraphcodeKit/Sources/Domain/CLISessionBackendKind.swift b/GraphcodeKit/Sources/Domain/CLISessionBackendKind.swift index cd631f9c..b02a559b 100644 --- a/GraphcodeKit/Sources/Domain/CLISessionBackendKind.swift +++ b/GraphcodeKit/Sources/Domain/CLISessionBackendKind.swift @@ -1,5 +1,5 @@ /// Which CLI coding-agent backend a `LoopNode` runs inside — see -/// docs/04-cli-backends.md. All four are spiked and share the zmx-backed adapter +/// docs/04-cli-backends.md. All five are spiked and share the zmx-backed adapter /// (`CLISessionBackend.zmxBacked`); what differs per backend is how a session can be /// asked what it is doing, which is why `presence` and `activity` are the two operations /// that switch on this and the rest are not. @@ -12,4 +12,5 @@ public enum CLISessionBackendKind: String, Codable, CaseIterable, Sendable { case copilotCLI case codex case openCode + case pi } diff --git a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift index 8cab0dfc..978b39cc 100644 --- a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift +++ b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift @@ -248,6 +248,40 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { public var openCodePermissions: OpenCodePermissions + /// pi asks nothing per tool; the one dialog an unattended session can park at is whether + /// to trust a project's own `.pi` resources, asked at startup when the repository has any. + public enum PiProjectTrust: String, Codable, CaseIterable, Sendable { + case approve + case ask + + public var displayName: String { + switch self { + case .approve: return "Trust the project (recommended)" + case .ask: return "Ask every time" + } + } + + public var explanation: String { + switch self { + case .approve: + return "pi's --approve: the loop's project-local extensions, skills and settings " + + "load without the trust prompt — what an unattended loop needs to start." + case .ask: + return "pi's own default. A loop in a repository with .pi resources waits at the " + + "trust prompt." + } + } + + public var arguments: [String] { + switch self { + case .approve: return ["--approve"] + case .ask: return [] + } + } + } + + public var piProjectTrust: PiProjectTrust + public var defaultBackend: CLISessionBackendKind { didSet { if !defaultBackend.isSpiked { defaultBackend = oldValue.isSpiked ? oldValue : .claudeCode } @@ -395,6 +429,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { defaultBackend: CLISessionBackendKind = .claudeCode, codexApprovals: CodexApprovals = .yolo, openCodePermissions: OpenCodePermissions = .auto, + piProjectTrust: PiProjectTrust = .approve, claudePermissionMode: ClaudePermissionMode = .auto, copilotPermissions: CopilotPermissions = .allowEverything, briefsSessionsAboutTheGraph: Bool = true, @@ -411,6 +446,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { self.defaultBackend = defaultBackend.isSpiked ? defaultBackend : .claudeCode self.codexApprovals = codexApprovals self.openCodePermissions = openCodePermissions + self.piProjectTrust = piProjectTrust self.claudePermissionMode = claudePermissionMode self.copilotPermissions = copilotPermissions self.briefsSessionsAboutTheGraph = briefsSessionsAboutTheGraph @@ -439,6 +475,8 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { openCodePermissions = try container.decodeIfPresent(OpenCodePermissions.self, forKey: .openCodePermissions) ?? .auto + piProjectTrust = + try container.decodeIfPresent(PiProjectTrust.self, forKey: .piProjectTrust) ?? .approve claudePermissionMode = try container.decodeIfPresent(ClaudePermissionMode.self, forKey: .claudePermissionMode) ?? .auto diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift index 1ecf3774..a1b24400 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -144,7 +144,7 @@ public enum SessionBriefing { creator, so making a child needs nothing beyond the create itself. A child runs on the same coding agent you do. To put one on a different agent, add - `--backend claudeCode | copilotCLI | codex | openCode`. + `--backend claudeCode | copilotCLI | codex | openCode | pi`. ## Choosing the loop type diff --git a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift index 227e9df5..fdc5b88a 100644 --- a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift +++ b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift @@ -121,8 +121,8 @@ extension CLISessionBackend { return await CopilotSessionLog.presence(of: node, projectPath: projectPath) case .codex: return await ZmxSessionLauncher.codexPresence(of: node, projectPath: projectPath) - case .openCode: - // Its plugin writes the same labels Claude Code's hooks do, so the same reader + case .openCode, .pi: + // Its plugin (pi's extension) writes the same labels Claude Code's hooks do, so the same reader // serves both — see `OpenCodePresencePlugin`. return await ZmxSessionLauncher.presence(of: node, projectPath: projectPath) } @@ -142,7 +142,7 @@ extension CLISessionBackend { return await CopilotSessionLog.activity(of: node, projectPath: projectPath) case .codex: return await CodexSessionLog.activity(of: node, projectPath: projectPath) - case .openCode: + case .openCode, .pi: return await ZmxSessionLauncher.activity(of: node, projectPath: projectPath) } }, @@ -173,6 +173,10 @@ extension CLISessionBackend { // narrates a beat yet, and a nil reading leaves the card without a rail rather // than with one that guesses. reading = nil + case .pi: + // pi's transcript is a JSONL file per session, but no beat reader is written for + // its entry shape yet. + reading = nil } // The optional second pass, which is the only part of this that costs anything. // Off, `applied` returns what it was given untouched. diff --git a/GraphcodeKit/Sources/Sessions/PiPresenceExtension.swift b/GraphcodeKit/Sources/Sessions/PiPresenceExtension.swift new file mode 100644 index 00000000..5d8a13f2 --- /dev/null +++ b/GraphcodeKit/Sources/Sessions/PiPresenceExtension.swift @@ -0,0 +1,122 @@ +import Foundation + +/// The extension a pi session loads to report what it is doing — `OpenCodePresencePlugin`'s +/// counterpart for the fifth backend. +/// +/// pi has no hook flags, but its extension API covers every edge the graph reads: +/// `agent_start`/`agent_settled` bracket a run, `tool_call` names what the run is doing, +/// `ui_prompt_start`/`ui_prompt_end` mark a blocking question, and `session_start` hands +/// over the session id a reboot resumes from. All of it writes into the same session-owned +/// label store Claude Code's hooks write to, so `ZmxSessionLauncher.presence(of:)` and +/// `.activity(of:)` read a pi loop with no code of their own. +/// +/// **`agent_settled`, not `agent_end`.** pi may auto-retry, compact and retry, or run a +/// queued follow-up after a run ends; reporting idle there would open the delivery window +/// for staged messages while the agent is still going. +/// +/// **Usage is re-tallied from the session's entries** rather than accumulated per message, +/// so a resumed session reports what the conversation has spent, not what this process +/// has. Reasoning tokens are already inside `output`. +/// +/// Loaded with `-e `, which adds to the user's own extensions. Guarded on +/// `$ZMX_SESSION`, and every write is best-effort. Events verified against pi 0.85.1 with a +/// probe extension, not read off the docs. +enum PiPresenceExtension { + static func remoteSource(zmxPath: String) -> String { + source( + zmxPath: zmxPath, + sessionsDirectoryExpression: #"join(process.env.HOME ?? "", ".graphcode", "sessions")"#) + } + + static func source(zmxPath: String, sessionsDirectory: String) -> String { + source( + zmxPath: zmxPath, + sessionsDirectoryExpression: OpenCodePresencePlugin.jsString(sessionsDirectory)) + } + + private static func source(zmxPath: String, sessionsDirectoryExpression: String) -> String { + """ + // Written by graphcode. Reports what this session is doing, for its card in the graph. + import { spawnSync } from "node:child_process" + import { appendFileSync, mkdirSync, writeFileSync } from "node:fs" + import { join } from "node:path" + + const ZMX = \(OpenCodePresencePlugin.jsString(zmxPath)) + const SESSIONS = \(sessionsDirectoryExpression) + const PREFIX = \(OpenCodePresencePlugin.jsString(SurfaceRef.zmxSessionPrefix)) + + export default function (pi) { + const session = process.env.ZMX_SESSION + if (!session || !session.startsWith(PREFIX)) return + const nodeID = session.slice(PREFIX.length) + const set = (...labels) => { + try { spawnSync(ZMX, ["set", session, ...labels], { stdio: "ignore" }) } catch {} + } + const encode = (phrase) => + phrase.slice(0, 64).replace(/_/g, "_5F").replace(/[^A-Za-z0-9._-]+/g, " ").trim() + .replace(/ /g, "_20") + const leaf = (path) => String(path ?? "").split("/").filter(Boolean).pop() ?? "" + const phrase = (tool, args) => { + const a = args ?? {} + const file = a.path + switch (tool) { + case "edit": case "write": return file ? "editing " + leaf(file) : "editing files" + case "read": return file ? "reading " + leaf(file) : "reading" + case "bash": case "powershell": + return a.command ? "running " + a.command : "running a command" + case "grep": return a.pattern ? "searching for " + a.pattern : "searching" + case "find": return a.pattern ? "looking for " + a.pattern : "looking for files" + case "ls": return file ? "listing " + leaf(file) : "listing files" + default: return "using " + tool + } + } + + let banked = null + const bank = (ctx) => { + const manager = ctx.sessionManager + const id = manager?.getSessionId?.() + if (!id || id === banked || !manager?.getSessionFile?.()) return + banked = id + try { + mkdirSync(SESSIONS, { recursive: true }) + const stamp = Math.floor(Date.now() / 1000) + appendFileSync(join(SESSIONS, nodeID + ".history"), `${stamp} ${id} ${ctx.cwd}\\n`) + writeFileSync(join(SESSIONS, nodeID + ".id"), id) + } catch {} + } + const tally = (ctx) => { + let input = 0 + let output = 0 + try { + for (const entry of ctx.sessionManager?.getEntries?.() ?? []) { + const message = entry?.type === "message" ? entry.message : null + if (message?.role !== "assistant" || !message.usage) continue + const u = message.usage + input += (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0) + output += u.output ?? 0 + } + } catch {} + if (input + output > 0) set(`usage=input.${input}_output.${output}`) + } + + pi.on("session_start", async (_event, ctx) => { + bank(ctx) + set("presence=idle", "activity=") + tally(ctx) + }) + pi.on("agent_start", async () => { set("presence=busy") }) + pi.on("tool_call", async (event) => { + set("presence=busy", "activity=" + encode(phrase(event.toolName, event.input))) + }) + pi.on("ui_prompt_start", async () => { set("presence=awaitingInput") }) + pi.on("ui_prompt_end", async (_event, ctx) => { + set(ctx.isIdle?.() === false ? "presence=busy" : "presence=idle") + }) + pi.on("agent_settled", async (_event, ctx) => { + set("presence=idle", "activity=") + tally(ctx) + }) + } + """ + } +} diff --git a/GraphcodeKit/Sources/Sessions/PresenceHooks.swift b/GraphcodeKit/Sources/Sessions/PresenceHooks.swift index ae9832b6..8ce3fffb 100644 --- a/GraphcodeKit/Sources/Sessions/PresenceHooks.swift +++ b/GraphcodeKit/Sources/Sessions/PresenceHooks.swift @@ -34,6 +34,12 @@ public enum PresenceHooks { directory.appendingPathComponent("opencode-presence.js") } + /// pi's reporter, an extension handed to the session by path with `-e` — see + /// `PiPresenceExtension`. + public static var piExtensionFile: URL { + directory.appendingPathComponent("pi-presence.js") + } + /// The `PreToolUse` reporter, kept as a file next to the settings that name it: it is a /// dozen lines of `case` and `sed`, and `zmx` types a launch command into a tty capped /// at `MAX_CANON` — the same reason the settings themselves travel by path. @@ -74,8 +80,8 @@ public enum PresenceHooks { ("Stop", .idle), ("SessionEnd", .absent), ] - case .copilotCLI, .codex, .openCode: - // OpenCode reports through a plugin, not through a hooks table — see + case .copilotCLI, .codex, .openCode, .pi: + // OpenCode and pi report through a plugin, not through a hooks table — see // `OpenCodePresencePlugin`. return nil } @@ -346,6 +352,8 @@ public enum PresenceHooks { "\"$HOME/.graphcode/hooks/notification.sh\"" public static let remoteOpenCodeConfigPath = "$HOME/.graphcode/hooks/openCode.json" + public static let remotePiExtensionPath = "$HOME/.graphcode/hooks/pi-presence.js" + public static let remotePiExtensionExpression = "\"\(remotePiExtensionPath)\"" public static let remoteOpenCodePluginExpression = "\"$HOME/.graphcode/hooks/opencode-presence.js\"" @@ -414,8 +422,9 @@ public enum PresenceHooks { /// small write, and it means an upgraded graphcode's hooks apply to the next session /// rather than to the next machine that happens to have no file yet. public static func write(forBackend backend: CLISessionBackendKind) -> URL? { - guard ZmxLocator.isInstalled, - let json = json(forBackend: backend, zmxPath: ZmxLocator.binaryURL.path) + guard ZmxLocator.isInstalled else { return nil } + if backend == .pi { return writePiExtension() } + guard let json = json(forBackend: backend, zmxPath: ZmxLocator.binaryURL.path) else { return nil } let url = file(forBackend: backend) do { @@ -446,6 +455,20 @@ public enum PresenceHooks { } } + /// pi takes the extension itself by path, so there is no settings file: the written + /// extension is what `-e` names. + private static func writePiExtension() -> URL? { + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try PiPresenceExtension.source( + zmxPath: ZmxLocator.binaryURL.path, sessionsDirectory: SessionIDStore.directory.path + ).write(to: piExtensionFile, atomically: true, encoding: .utf8) + return piExtensionFile + } catch { + return nil + } + } + // MARK: - Remote sessions /// Where the hooks land on a remote host — a `$HOME` expression rather than a path, @@ -479,6 +502,11 @@ public enum PresenceHooks { + " \(singleQuoted(configSuffix)) > \"\(remoteOpenCodeConfigPath)\"; }" + " 2>/dev/null || true" } + if backend == .pi { + return "{ mkdir -p \"$HOME/.graphcode/hooks\"" + + " && printf '%s' \(singleQuoted(PiPresenceExtension.remoteSource(zmxPath: "zmx")))" + + " > \(remotePiExtensionExpression); } 2>/dev/null || true" + } guard backend == .claudeCode else { return nil } guard let json = json( diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index 7e7d6d73..5114e71a 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -200,7 +200,7 @@ public enum RemoteGraphAccess { --predicate optional stop condition for --type goal (exit 0 = met) --prompt required for --type time or turn; a time loop's cadence goes inside it (/loop 1h ...) - --backend claudeCode | copilotCLI | codex | openCode + --backend claudeCode | copilotCLI | codex | openCode | pi --model fast | standard | capable --metric performance measure; last stdout line must be a number --direction minimize | maximize (default: maximize) @@ -746,7 +746,7 @@ public enum RemoteGraphAccess { goal["metricCommand"] = flags["metric"] draft["goal"] = goal if flags.get("backend"): - if flags["backend"] not in ("claudeCode", "copilotCLI", "codex", "openCode"): + if flags["backend"] not in ("claudeCode", "copilotCLI", "codex", "openCode", "pi"): fail("invalid value for --backend: %s" % flags["backend"]) draft["backend"] = flags["backend"] if flags.get("model"): diff --git a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift index 63cc2d9b..cf1e1baa 100644 --- a/GraphcodeKit/Sources/Sessions/SessionTransplant.swift +++ b/GraphcodeKit/Sources/Sessions/SessionTransplant.swift @@ -83,6 +83,11 @@ public enum SessionTransplant { sourceWorkingDirectory: workingDirectory, 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 + case .openCode: // OpenCode's conversations live in one SQLite database shared by every session on // the machine, not in a file per session that can be lifted out. Its own @@ -246,7 +251,7 @@ 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: + case .openCode, .pi: return nil } } @@ -293,7 +298,7 @@ public enum SessionTransplant { return Artifact( backend: .codex, sessionID: only.name, sourceWorkingDirectory: workingDirectory, files: ["rollout.jsonl": only.data]) - case .openCode: + case .openCode, .pi: return nil } } @@ -334,7 +339,7 @@ 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: return nil + case .openCode, .pi: return nil } } @@ -408,7 +413,7 @@ public enum SessionTransplant { for (relativePath, data) in artifact.files { staged[relativePath] = rewriting(data, replacing: artifact.sessionID, with: freshID) } - case .codex, .openCode: + case .codex, .openCode, .pi: return nil } guard await deliver(files: staged, remoteScript: script, at: location) else { return nil } @@ -440,7 +445,7 @@ 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: + case .codex, .openCode, .pi: return nil } } diff --git a/GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift b/GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift index 7fc67f17..a96bd7e4 100644 --- a/GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift +++ b/GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift @@ -87,6 +87,8 @@ public enum SummaryModelWriter { return ["codex", "exec", prompt] + model case .openCode: return ["opencode", "run", prompt] + model + case .pi: + return ["pi", "-p", "--no-tools", "--no-session", prompt] + model } } diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index bf7e7885..57d91673 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -994,9 +994,8 @@ public enum ZmxSessionLauncher { let remoteEnvironmentPath = remote != nil && node.backend == .openCode ? PresenceHooks.remoteOpenCodeConfigPath : nil - let remoteHooksSuffix = - remote != nil && node.backend == .claudeCode - ? " --settings \"\(PresenceHooks.remotePathExpression)\"" : "" + let remoteHooksSuffix = Self.remoteHooksSuffix( + forBackend: node.backend, isRemote: remote != nil) let arguments = node.backend.launchArguments( prompt: promptWithMemory, tier: tier, briefingPath: briefingPath, settings: settings, @@ -1138,9 +1137,8 @@ public enum ZmxSessionLauncher { let remoteEnvironmentPath = remote != nil && node.backend == .openCode ? PresenceHooks.remoteOpenCodeConfigPath : nil - let remoteHooksSuffix = - remote != nil && node.backend == .claudeCode - ? " --settings \"\(PresenceHooks.remotePathExpression)\"" : "" + let remoteHooksSuffix = Self.remoteHooksSuffix( + forBackend: node.backend, isRemote: remote != nil) // Copilot's `--name` and `--resume` are mutually exclusive: one creates a new // session, the other restores an existing one. A resume session drops `--name` // (by passing nil for sessionName) and uses `--resume` alone; the session's @@ -1214,6 +1212,19 @@ public enum ZmxSessionLauncher { return backend.presenceEnvironment(hooksFile: hooksFile) } + /// What a remote session's launch appends so its reporter loads — a `$HOME` path only + /// the remote shell can expand, which is why it rides as script text, not an argument. + static func remoteHooksSuffix(forBackend backend: CLISessionBackendKind, isRemote: Bool) + -> String + { + guard isRemote else { return "" } + switch backend { + case .claudeCode: return " --settings \"\(PresenceHooks.remotePathExpression)\"" + case .pi: return " -e \(PresenceHooks.remotePiExtensionExpression)" + case .copilotCLI, .codex, .openCode: return "" + } + } + /// Whether the assembled command survives being typed into a terminal. Budgeted well /// under `MAX_CANON` because `zmx` shell-quotes every argument before typing it, which /// only ever makes the line longer than what's measured here. @@ -1310,7 +1321,7 @@ public enum ZmxSessionLauncher { switch node.backend { case .copilotCLI: return copilotTrustSeedScript(forRemotePath: location.remotePath) + "; " case .claudeCode: return claudeTrustSeedScript(forRemotePath: location.remotePath) + "; " - case .codex, .openCode: return "" + case .codex, .openCode, .pi: return "" } }() let hooksWrite = @@ -1335,7 +1346,7 @@ public enum ZmxSessionLauncher { switch node.backend { case .copilotCLI: return " && { " + CopilotSessionLog.remoteIDBankFragment(forNodeID: node.id) + "; }" - case .claudeCode, .codex, .openCode: + case .claudeCode, .codex, .openCode, .pi: return "" } }() @@ -1776,7 +1787,7 @@ public enum ZmxSessionLauncher { case .copilotCLI: let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName return CopilotSessionLog.directory(forSessionNamed: name)?.lastPathComponent - case .claudeCode, .codex, .openCode: + case .claudeCode, .codex, .openCode, .pi: return nil } }() @@ -1816,7 +1827,7 @@ public enum ZmxSessionLauncher { switch node.backend { case .copilotCLI: CopilotTrust.ensureTrusted(directory: directory) case .claudeCode: ClaudeCodeTrust.ensureTrusted(directory: directory) - case .codex, .openCode: break + case .codex, .openCode, .pi: break } } // Noted *before* the launch: the first pass below waits for a Copilot session diff --git a/README.md b/README.md index 007110ea..9b5ed765 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ You can run one coding-agent session in a terminal. GraphCode lets you run ten — connected, unattended, and still yours to attach to and correct mid-run. Each node is a unit of work inside a real CLI coding-agent -session: **Claude Code, GitHub Copilot CLI, Codex, or OpenCode**, chosen per loop. Each edge is a hand-off, +session: **Claude Code, GitHub Copilot CLI, Codex, OpenCode, or pi**, chosen per loop. Each edge is a hand-off, message, or spawn between them, and an edge never asks which agent is on either end — a Codex loop hands off to a Claude Code loop that messages a Copilot one. They are live terminals, not headless jobs. @@ -34,7 +34,7 @@ Two design choices explain most of the rest: - **GraphCode schedules nothing.** A time-based loop's recurrence lives *inside* its session, written into the prompt with the agent's own `/loop` skill; the daemon only keeps the session alive. That is what makes a running loop something you can attach to and correct, rather than a job that already finished somewhere. - Codex and OpenCode have no such skill, so a time-based loop on them needs the experimental **Daemon + Codex, OpenCode and pi have no such skill, so a time-based loop on them needs the experimental **Daemon heartbeat** switched on in Settings. - **Sessions outlive everything.** Each loop's terminal is a [`zmx`](https://zmx.sh) session, so it survives quitting the app and rebooting — the backend's session ID is persisted, so relaunching resumes the @@ -43,7 +43,7 @@ Two design choices explain most of the rest: ## Install Requires **macOS 15+ on Apple Silicon** (arm64), with at least one agent CLI on your `PATH` — `claude`, -`copilot`, `codex`, or `opencode`. GraphCode launches whichever one a loop names; it bundles none of them. +`copilot`, `codex`, `opencode`, or `pi`. GraphCode launches whichever one a loop names; it bundles none of them. ```sh brew install --cask scgopi/graphcode/graphcode @@ -59,7 +59,7 @@ Releases are Developer ID signed and notarized. 2. **Create a loop** — ⊕ on the canvas. Write the prompt, pick the agent it runs as (Claude Code unless you change **Settings ▸ New loops use**), and hit Create; the type chooser explains what each kind hands off, and a goal's done check has a **Test** button that runs it as the daemon will. From a shell, - `graphcode node create` takes the same choice as `--backend claudeCode | copilotCLI | codex | openCode`; + `graphcode node create` takes the same choice as `--backend claudeCode | copilotCLI | codex | openCode | pi`; a loop that creates children without naming one hands them its own. 3. **Open it** — click the node for that loop's terminal workspace: tabs, splits, ⌘K to jump to any loop, ⌘⇧R to walk the ones asking for you ([shortcuts](https://graphcode.app/shortcuts.html)). You attach to the live session. diff --git a/docs/index.html b/docs/index.html index c9234333..f9cc94db 100644 --- a/docs/index.html +++ b/docs/index.html @@ -407,7 +407,7 @@

Graph Engineering, simplified —
with GraphCode.
10+
parallel live sessions, one canvas
0
files written inside your project folder
-
4
backends: Claude Code, Copilot, Codex, OpenCode
+
5
backends: Claude Code, Copilot, Codex, OpenCode, Pi
reboots survived — sessions outlive the app
diff --git a/graphcode/Sources/Clients/TitleSuggestionClient.swift b/graphcode/Sources/Clients/TitleSuggestionClient.swift index 1fe95964..e9ceee89 100644 --- a/graphcode/Sources/Clients/TitleSuggestionClient.swift +++ b/graphcode/Sources/Clients/TitleSuggestionClient.swift @@ -91,6 +91,7 @@ extension TitleSuggestionClient: DependencyKey { command = "exec codex exec --dangerously-bypass-approvals-and-sandbox \"$\(promptVariable)\"" case .openCode: command = "exec opencode run \"$\(promptVariable)\"" + case .pi: command = "exec pi -p --no-tools --no-session \"$\(promptVariable)\"" } return ["/bin/zsh", "-i", "-l", "-c", command] } diff --git a/graphcode/Sources/Features/Settings/SettingsView.swift b/graphcode/Sources/Features/Settings/SettingsView.swift index abe5d396..724d2c8f 100644 --- a/graphcode/Sources/Features/Settings/SettingsView.swift +++ b/graphcode/Sources/Features/Settings/SettingsView.swift @@ -85,6 +85,16 @@ struct SettingsView: View { .font(.caption2) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + + Picker("Pi", selection: $model.settings.piProjectTrust) { + ForEach(GraphcodeSettings.PiProjectTrust.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + Text(model.settings.piProjectTrust.explanation) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } header: { Text("Permissions") } footer: { diff --git a/graphcode/Sources/Features/Welcome/OnboardingPages.swift b/graphcode/Sources/Features/Welcome/OnboardingPages.swift index 5dd8490a..7046d667 100644 --- a/graphcode/Sources/Features/Welcome/OnboardingPages.swift +++ b/graphcode/Sources/Features/Welcome/OnboardingPages.swift @@ -349,6 +349,7 @@ struct OnboardingBackendPage: View { case .copilotCLI: return "GitHub's agent CLI." case .codex: return "OpenAI's agent CLI." case .openCode: return "The open-source agent CLI — any model provider." + case .pi: return "The minimal, extensible agent CLI — any model provider." } } diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Remote.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Remote.swift index 3668469b..90b6ffdf 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Remote.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Remote.swift @@ -267,6 +267,7 @@ extension GhosttyTerminalView { switch backend { case .claudeCode: return PresenceHooks.remotePathExpression case .openCode: return PresenceHooks.remoteOpenCodeConfigPath + case .pi: return PresenceHooks.remotePiExtensionPath case .copilotCLI, .codex: return nil } } diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift index 3e496b10..60c995a4 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -242,6 +242,8 @@ struct GhosttyTerminalView: NSViewRepresentable { parts.append("--settings \"\(path)\"") } else if backend == .openCode { parts.insert(contentsOf: ["env", "OPENCODE_CONFIG=\"\(path)\""], at: 1) + } else if backend == .pi { + parts.append("-e \"\(path)\"") } } diff --git a/graphcode/Tests/CodexPresenceTests.swift b/graphcode/Tests/CodexPresenceTests.swift index 4f8cf10f..be2c7b02 100644 --- a/graphcode/Tests/CodexPresenceTests.swift +++ b/graphcode/Tests/CodexPresenceTests.swift @@ -91,14 +91,14 @@ struct CodexPresenceTests { @Test func eachBackendGetsOnlyItsOwnMechanism() { - // One function, three answers — the point being that the three CLIs genuinely differ - // here and the code should not pretend otherwise. + // One function, a different answer per CLI — the point being that they genuinely differ + // here and the code should not pretend otherwise. OpenCode's answer is the environment. let file = URL(fileURLWithPath: "/tmp/hooks.json") let all = CLISessionBackendKind.allCases.map { $0.presenceArguments(hooksFile: file, sessionName: "graphcode-A", zmxPath: zmx).first } - #expect(Set(all.compactMap { $0 }) == ["--settings", "--name", "-c"]) + #expect(Set(all.compactMap { $0 }) == ["--settings", "--name", "-c", "-e"]) } @Test diff --git a/graphcode/Tests/DaemonHeartbeatTests.swift b/graphcode/Tests/DaemonHeartbeatTests.swift index d08ed328..8b02a4c0 100644 --- a/graphcode/Tests/DaemonHeartbeatTests.swift +++ b/graphcode/Tests/DaemonHeartbeatTests.swift @@ -142,7 +142,7 @@ struct DaemonHeartbeatTests { @Test func codexAndOpenCodeUseDaemonCadenceWhenTheToggleIsOff() async { - for backend in [CLISessionBackendKind.codex, .openCode] { + for backend in [CLISessionBackendKind.codex, .openCode, .pi] { let node = LoopNode( title: "Watcher", loopType: .timeBased, triggerPrompt: "/loop 30m check reports", backend: backend, state: .running) @@ -166,7 +166,7 @@ struct DaemonHeartbeatTests { @Test func explicitCodexAndOpenCodeHeartbeatsIgnoreTheToggle() async { - for backend in [CLISessionBackendKind.codex, .openCode] { + for backend in [CLISessionBackendKind.codex, .openCode, .pi] { let draft = NodeDraft( title: "Watcher", loopType: .timeBased, triggerPrompt: "check reports", heartbeatIntervalSeconds: 300, backend: backend) diff --git a/graphcode/Tests/GoalBasedLoopTests.swift b/graphcode/Tests/GoalBasedLoopTests.swift index 8015edc8..232903a9 100644 --- a/graphcode/Tests/GoalBasedLoopTests.swift +++ b/graphcode/Tests/GoalBasedLoopTests.swift @@ -280,9 +280,9 @@ struct GoalBasedLoopTests { func aGoalOpensWithTheBackendsOwnStopCondition() throws { // Prose asks; the directive binds. `/goal ` installs a check the session // cannot finish past, which is the whole contract of the type — so it leads the - // prompt, exactly as `/loop` leads a time-based one. Every backend graphcode drives - // has the command, so every backend gets it. - for backend in CLISessionBackendKind.allCases { + // prompt, exactly as `/loop` leads a time-based one. Every backend that has the command + // gets it; pi has none, and a directive it cannot parse would be typed as prose. + for backend in CLISessionBackendKind.allCases where backend.capabilities.goalDirective != nil { let node = LoopNode( title: "Green build", loopType: .goalBased, goal: GoalSpec(summary: "CI passes", predicate: "make test"), backend: backend) @@ -291,6 +291,10 @@ struct GoalBasedLoopTests { #expect(prompt.contains("make test")) #expect(!prompt.contains("Work toward this goal")) } + let pi = LoopNode( + title: "Green build", loopType: .goalBased, + goal: GoalSpec(summary: "CI passes", predicate: "make test"), backend: .pi) + #expect(pi.sessionPrompt?.hasPrefix("Work toward this goal until it is met: CI passes") == true) } @Test diff --git a/graphcode/Tests/GraphcodeSettingsTests.swift b/graphcode/Tests/GraphcodeSettingsTests.swift index 6d32b10e..ebd8944c 100644 --- a/graphcode/Tests/GraphcodeSettingsTests.swift +++ b/graphcode/Tests/GraphcodeSettingsTests.swift @@ -106,7 +106,9 @@ struct GraphcodeSettingsTests { // Codex was withheld while it had no adapter — making it the default would have set // every new loop to something that never starts. It has one now (issue #1). #expect( - CLISessionBackendKind.offerableAsDefault == [.claudeCode, .copilotCLI, .codex, .openCode]) + CLISessionBackendKind.offerableAsDefault == [ + .claudeCode, .copilotCLI, .codex, .openCode, .pi, + ]) #expect(GraphcodeSettings(defaultBackend: .codex).defaultBackend == .codex) } diff --git a/graphcode/Tests/NodeDraftTests.swift b/graphcode/Tests/NodeDraftTests.swift index 75a53c6a..51ab4485 100644 --- a/graphcode/Tests/NodeDraftTests.swift +++ b/graphcode/Tests/NodeDraftTests.swift @@ -222,12 +222,16 @@ struct NodeDraftTests { // a turn-based loop is judged by a human, and a goal's predicate is polled by the // daemon from outside. #expect( - CLISessionBackendKind.hosting(.turnBased) == [.claudeCode, .copilotCLI, .codex, .openCode]) + CLISessionBackendKind.hosting(.turnBased) == [ + .claudeCode, .copilotCLI, .codex, .openCode, .pi, + ]) #expect( - CLISessionBackendKind.hosting(.goalBased) == [.claudeCode, .copilotCLI, .codex, .openCode]) + CLISessionBackendKind.hosting(.goalBased) == [ + .claudeCode, .copilotCLI, .codex, .openCode, .pi, + ]) #expect( CLISessionBackendKind.hosting(.timeBased) - == [.claudeCode, .copilotCLI, .codex, .openCode]) + == [.claudeCode, .copilotCLI, .codex, .openCode, .pi]) // A composite still needs sub-agent fan-out, which Claude Code and (since 1.0.80's // `/fleet`) Copilot have been shown to do. #expect(CLISessionBackendKind.hosting(.composite) == [.claudeCode, .copilotCLI]) @@ -303,7 +307,7 @@ struct NodeDraftTests { @Test func daemonOnlyBackendsRequireARealDaemonCadence() { - for backend in [CLISessionBackendKind.codex, .openCode] { + for backend in [CLISessionBackendKind.codex, .openCode, .pi] { for prompt in ["check reports", "/loop tomorrow check reports", "/schedule daily check"] { #expect( !NodeDraft( diff --git a/graphcode/Tests/PiBackendTests.swift b/graphcode/Tests/PiBackendTests.swift new file mode 100644 index 00000000..469ed824 --- /dev/null +++ b/graphcode/Tests/PiBackendTests.swift @@ -0,0 +1,158 @@ +import Foundation +import Testing + +@testable import GraphcodeKit + +/// pi as a real backend, spiked against 0.85.1. +/// +/// Flags read off the installed `pi --help`, extension events off a probe extension run +/// against the real binary. The shape that matters: `pi` takes its prompt positionally, +/// resumes with `--session `, loads its reporter with `-e `, and has no goal or +/// loop directive of its own. +@Suite +struct PiBackendTests { + private func node(_ loopType: LoopType = .goalBased) -> LoopNode { + LoopNode( + title: "Ship it", loopType: loopType, goal: GoalSpec(summary: "Tests pass"), backend: .pi) + } + + @Test + func theSessionRunsPiNotClaude() { + let arguments = ZmxSessionLauncher.arguments(forNode: node()) ?? [] + + #expect(arguments.contains(where: { $0.hasSuffix(#"pi "$@""#) })) + #expect(!arguments.contains(where: { $0.contains("claude ") })) + } + + @Test + func thePromptIsPositionalAndLast() { + let arguments = CLISessionBackendKind.pi.launchArguments( + prompt: "go", tier: .standard, settings: GraphcodeSettings()) + + #expect(arguments.last == "go") + #expect(CLISessionBackendKind.pi.promptFlag == nil) + } + + @Test + func aGoalRidesAsProseBecausePiHasNoGoalDirective() { + #expect(CLISessionBackendKind.pi.capabilities.goalDirective == nil) + let prompt = node().sessionPrompt ?? "" + #expect(prompt.hasPrefix("Work toward this goal until it is met: Tests pass")) + #expect(!prompt.contains("/goal")) + } + + @Test + func theUnattendedDefaultTrustsTheProject() { + // pi asks nothing per tool; its one startup dialog is project trust. + let arguments = CLISessionBackendKind.pi.launchArguments( + prompt: "go", tier: .standard, settings: GraphcodeSettings()) + #expect(arguments.contains("--approve")) + + let asking = CLISessionBackendKind.pi.launchArguments( + prompt: "go", tier: .standard, settings: GraphcodeSettings(piProjectTrust: .ask)) + #expect(!asking.contains("--approve")) + } + + @Test + func noTierNamesAModelBecauseTheProviderIsTheUsers() { + for tier in ModelTier.allCases { + #expect(CLISessionBackendKind.pi.modelArguments(for: tier).isEmpty) + } + } + + @Test + func theBriefingIsAPointerInThePromptAndNeedsNoDirectoryGrant() { + let arguments = CLISessionBackendKind.pi.launchArguments( + prompt: "go", tier: .standard, briefingPath: "/Users/x/.graphcode/briefings/b.md", + workspacePaths: ["/work"]) + + #expect(!arguments.contains("--add-dir")) + #expect(arguments.last?.contains("/Users/x/.graphcode/briefings/b.md") == true) + #expect(arguments.last?.hasSuffix(" go") == true) + #expect(!CLISessionBackendKind.pi.briefingNeedsDirectoryGrant) + } + + @Test + func theExtensionLoadsByPathOnTheArgv() { + let file = URL(fileURLWithPath: "/Users/x/.graphcode/hooks/pi-presence.js") + + #expect(CLISessionBackendKind.pi.presenceArguments(hooksFile: file) == ["-e", file.path]) + #expect(CLISessionBackendKind.pi.presenceArguments(hooksFile: nil).isEmpty) + #expect(CLISessionBackendKind.pi.presenceEnvironment(hooksFile: file).isEmpty) + } + + @Test + func resumeNamesTheExactSession() { + // `--continue` would pick the project's most recent session, which with several loops + // in one folder is somebody else's. + #expect(CLISessionBackendKind.pi.supportsResume) + #expect(CLISessionBackendKind.pi.resumeArguments(sessionID: "abc") == ["--session", "abc"]) + } + + @Test + func theExtensionReportsEveryEdgeTheGraphReads() { + let source = PiPresenceExtension.source( + zmxPath: "/Users/o'brien/bin/zmx", sessionsDirectory: "/Users/o'brien/.graphcode/sessions") + + #expect(source.contains(#"const ZMX = "/Users/o'brien/bin/zmx""#)) + #expect(source.contains("export default function (pi)")) + #expect(source.contains("process.env.ZMX_SESSION")) + for event in [ + "session_start", "agent_start", "agent_settled", "tool_call", "ui_prompt_start", + "ui_prompt_end", + ] { + #expect(source.contains("\"\(event)\""), "\(event) is not reported") + } + for label in ["presence=busy", "presence=idle", "presence=awaitingInput", "usage=input."] { + #expect(source.contains(label), "\(label) is never written") + } + // `agent_end` can be followed by a retry or a queued follow-up; idle there would lie. + #expect(!source.contains("agent_end")) + #expect(source.contains(".history")) + } + + @Test + func aRemoteLaunchWritesAndLoadsItsExtension() throws { + let remoteNode = LoopNode( + title: "Ship it", loopType: .goalBased, goal: GoalSpec(summary: "Tests pass"), + backend: .pi, state: .running) + let location = RemoteProjectLocation( + user: "dev", host: "build-box", remotePath: "/home/dev/widget") + let invocation = try #require( + ZmxSessionLauncher.remoteEnsureInvocation(forNode: remoteNode, at: location)) + let command = try #require(invocation.last) + + #expect(command.contains("pi-presence.js")) + #expect(command.contains("-e")) + #expect(command.contains("process.env.HOME")) + } + + @Test + func itHostsEveryLoopTypeButComposite() { + #expect(CLISessionBackendKind.pi.canHost(.goalBased)) + #expect(CLISessionBackendKind.pi.canHost(.turnBased)) + #expect(CLISessionBackendKind.pi.canHost(.sketch)) + #expect(CLISessionBackendKind.pi.canHost(.timeBased)) + #expect(!CLISessionBackendKind.pi.canHost(.composite)) + #expect(CLISessionBackendKind.offerableAsDefault.contains(.pi)) + } + + @Test + func settingsRoundTripAndDefaultToApprove() throws { + let decoded = try JSONDecoder().decode(GraphcodeSettings.self, from: Data("{}".utf8)) + #expect(decoded.piProjectTrust == .approve) + + var settings = GraphcodeSettings() + settings.piProjectTrust = .ask + let data = try JSONEncoder().encode(settings) + let back = try JSONDecoder().decode(GraphcodeSettings.self, from: data) + #expect(back.piProjectTrust == .ask) + } + + @Test + func theHeadlessInvocationCannotRunTools() { + #expect( + SummaryModelWriter.invocation(forBackend: .pi, prompt: "say hi") + == ["pi", "-p", "--no-tools", "--no-session", "say hi"]) + } +} diff --git a/graphcode/Tests/ProjectFeatureTests.swift b/graphcode/Tests/ProjectFeatureTests.swift index 2db59135..6862381a 100644 --- a/graphcode/Tests/ProjectFeatureTests.swift +++ b/graphcode/Tests/ProjectFeatureTests.swift @@ -260,7 +260,7 @@ struct ProjectFeatureTests { @Test @MainActor func codexAndOpenCodeDraftsAlwaysUseDaemonCadence() { - for backend in [CLISessionBackendKind.codex, .openCode] { + for backend in [CLISessionBackendKind.codex, .openCode, .pi] { var state = ProjectFeature.State(graph: LoopGraph(project: Self.testProject)) state.draftLoopType = .timeBased state.draftBackend = backend From cfe871f91b62a7fd9816e711f8fd196ca1490210 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sat, 12 Sep 2026 15:48:52 -0700 Subject: [PATCH 2/6] pi loops carry no summary rail by decision Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KMVJyUtec3hpH5hF44xAgh --- GraphcodeKit/Sources/Sessions/CLISessionBackend.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift index fdc5b88a..dfa4e168 100644 --- a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift +++ b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift @@ -174,8 +174,8 @@ extension CLISessionBackend { // than with one that guesses. reading = nil case .pi: - // pi's transcript is a JSONL file per session, but no beat reader is written for - // its entry shape yet. + // Deliberately none: pi loops carry no summary rail, and a nil reading leaves the + // card without one rather than with a rail that guesses. reading = nil } // The optional second pass, which is the only part of this that costs anything. From 95471b860da170abc924fc33b9570394a8d6cddb Mon Sep 17 00:00:00 2001 From: scgopi Date: Sat, 12 Sep 2026 15:55:37 -0700 Subject: [PATCH 3/6] 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( From b936c1f97f9d47ee2292bae03c8cde02452aea3c Mon Sep 17 00:00:00 2001 From: scgopi Date: Sat, 12 Sep 2026 16:02:46 -0700 Subject: [PATCH 4/6] 0.1.69-beta1 (build 270): version bump Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KMVJyUtec3hpH5hF44xAgh --- Project.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Project.swift b/Project.swift index 22455221..0a4237af 100644 --- a/Project.swift +++ b/Project.swift @@ -84,8 +84,8 @@ let project = Project( // (#33). Before that the suffix lived on the tag only, so betas 48/49 // of the 0.1.15 line read "0.1.15" and are told by the build number // apart. - "CFBundleShortVersionString": "0.1.68", - "CFBundleVersion": "269", + "CFBundleShortVersionString": "0.1.69-beta1", + "CFBundleVersion": "270", ]), resources: [ "graphcode/Resources/**" From 83131e3a3a3ae67d0e62942e854e238b6205d742 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sat, 12 Sep 2026 16:12:01 -0700 Subject: [PATCH 5/6] fix: pi extension banks a session id only once its file exists pi names a session at startup but writes its JSONL only with the first assistant message, and `pi --session ` exits 1 ("No session found matching") on an id with no file. Banking at session_start left a dead id for any session quit before its first reply, or a /new a reboot interrupted. Bank when the file exists, retrying at tool_call and agent_settled. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XDp19dXj5ZKDEAMt65uRES --- .../Sources/Sessions/PiPresenceExtension.swift | 17 +++++++++++++---- graphcode/Tests/PiBackendTests.swift | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/GraphcodeKit/Sources/Sessions/PiPresenceExtension.swift b/GraphcodeKit/Sources/Sessions/PiPresenceExtension.swift index 5d8a13f2..650c8a3f 100644 --- a/GraphcodeKit/Sources/Sessions/PiPresenceExtension.swift +++ b/GraphcodeKit/Sources/Sessions/PiPresenceExtension.swift @@ -5,11 +5,16 @@ import Foundation /// /// pi has no hook flags, but its extension API covers every edge the graph reads: /// `agent_start`/`agent_settled` bracket a run, `tool_call` names what the run is doing, -/// `ui_prompt_start`/`ui_prompt_end` mark a blocking question, and `session_start` hands +/// `ui_prompt_start`/`ui_prompt_end` mark a blocking question, and the session manager hands /// over the session id a reboot resumes from. All of it writes into the same session-owned /// label store Claude Code's hooks write to, so `ZmxSessionLauncher.presence(of:)` and /// `.activity(of:)` read a pi loop with no code of their own. /// +/// **The id is banked only once its file exists.** pi names a session at startup but writes +/// nothing until the first assistant message, and `--session ` exits 1 on an id with no +/// file — so an id banked at `session_start` from a session quit before its first reply, or +/// from a `/new` the reboot interrupted, would replace a resumable id with a dead one. +/// /// **`agent_settled`, not `agent_end`.** pi may auto-retry, compact and retry, or run a /// queued follow-up after a run ends; reporting idle there would open the delivery window /// for staged messages while the agent is still going. @@ -38,7 +43,7 @@ enum PiPresenceExtension { """ // Written by graphcode. Reports what this session is doing, for its card in the graph. import { spawnSync } from "node:child_process" - import { appendFileSync, mkdirSync, writeFileSync } from "node:fs" + import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs" import { join } from "node:path" const ZMX = \(OpenCodePresencePlugin.jsString(zmxPath)) @@ -75,7 +80,9 @@ enum PiPresenceExtension { const bank = (ctx) => { const manager = ctx.sessionManager const id = manager?.getSessionId?.() - if (!id || id === banked || !manager?.getSessionFile?.()) return + if (!id || id === banked) return + const file = manager?.getSessionFile?.() + if (!file || !existsSync(file)) return banked = id try { mkdirSync(SESSIONS, { recursive: true }) @@ -105,7 +112,8 @@ enum PiPresenceExtension { tally(ctx) }) pi.on("agent_start", async () => { set("presence=busy") }) - pi.on("tool_call", async (event) => { + pi.on("tool_call", async (event, ctx) => { + bank(ctx) set("presence=busy", "activity=" + encode(phrase(event.toolName, event.input))) }) pi.on("ui_prompt_start", async () => { set("presence=awaitingInput") }) @@ -113,6 +121,7 @@ enum PiPresenceExtension { set(ctx.isIdle?.() === false ? "presence=busy" : "presence=idle") }) pi.on("agent_settled", async (_event, ctx) => { + bank(ctx) set("presence=idle", "activity=") tally(ctx) }) diff --git a/graphcode/Tests/PiBackendTests.swift b/graphcode/Tests/PiBackendTests.swift index 469ed824..cdc9e8d5 100644 --- a/graphcode/Tests/PiBackendTests.swift +++ b/graphcode/Tests/PiBackendTests.swift @@ -111,6 +111,23 @@ struct PiBackendTests { #expect(source.contains(".history")) } + @Test + func anIDIsBankedOnlyOnceItsSessionFileExists() throws { + // pi writes nothing until the first assistant message, and `--session ` exits 1 on + // an id with no file: banked at startup, a quit-before-reply session left a dead id. + let source = PiPresenceExtension.source(zmxPath: "/bin/zmx", sessionsDirectory: "/s") + let bank = try #require(source.range(of: "const bank = (ctx) => {")) + let write = try #require(source.range(of: "writeFileSync(join(SESSIONS")) + let body = source[bank.upperBound.. Date: Sat, 12 Sep 2026 16:24:44 -0700 Subject: [PATCH 6/6] Keep dash and at prompts as pi messages; report pi's trust prompt Review findings F2 and F3. pi parses a positional argument starting with `-` as an option and one starting with `@` as a file, so such a goal never reached the agent; it now gets a leading space, on both the daemon and the app launch. `--` could not rescue `@` and would swallow the remote `-e` suffix. pi's project-trust prompt runs before session_start and fires no ui_prompt event, so the extension now reports awaitingInput from project_trust and leaves the decision to pi. Verified live against pi 0.85.1. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KMVJyUtec3hpH5hF44xAgh --- GraphcodeKit/Sources/Domain/BackendCommand.swift | 15 ++++++++++++--- .../Sources/Sessions/PiPresenceExtension.swift | 9 +++++++++ .../Ghostty/GhosttyTerminalView.swift | 3 ++- graphcode/Tests/PiBackendTests.swift | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/GraphcodeKit/Sources/Domain/BackendCommand.swift b/GraphcodeKit/Sources/Domain/BackendCommand.swift index f4cb27cf..3f749fbb 100644 --- a/GraphcodeKit/Sources/Domain/BackendCommand.swift +++ b/GraphcodeKit/Sources/Domain/BackendCommand.swift @@ -155,15 +155,24 @@ extension CLISessionBackendKind { case .pi: // Positional, like Claude Code's. The briefing rides as a pointer inside the prompt: // pi's `read` has no path gate, so it needs no directory grant either. - guard let briefingPath else { return model + [prompt] } + guard let briefingPath else { return model + [Self.piMessage(prompt)] } return model + [ - SessionPrompt.composed( - preamble: SessionBriefing.pointer(toBriefingAt: briefingPath), prompt: prompt) + Self.piMessage( + SessionPrompt.composed( + preamble: SessionBriefing.pointer(toBriefingAt: briefingPath), prompt: prompt)) ] } } + /// pi reads a positional argument that starts with `-` as an option and one that starts + /// with `@` as a file to attach (`cli/args.js`), so a goal opening with a bullet or a + /// mention would never reach the agent. A leading space keeps it a message. `--` is not + /// an option: it cannot rescue `@`, and the remote `-e` suffix follows the prompt. + public static func piMessage(_ prompt: String) -> String { + prompt.hasPrefix("-") || prompt.hasPrefix("@") ? " " + prompt : prompt + } + /// The flag a backend's opening prompt rides behind, or `nil` for one that takes it /// positionally. The app assembles a shell string rather than an argv and needs the /// same answer `launchArguments` gives. diff --git a/GraphcodeKit/Sources/Sessions/PiPresenceExtension.swift b/GraphcodeKit/Sources/Sessions/PiPresenceExtension.swift index 650c8a3f..14a063e0 100644 --- a/GraphcodeKit/Sources/Sessions/PiPresenceExtension.swift +++ b/GraphcodeKit/Sources/Sessions/PiPresenceExtension.swift @@ -15,6 +15,11 @@ import Foundation /// file — so an id banked at `session_start` from a session quit before its first reply, or /// from a `/new` the reboot interrupted, would replace a resumable id with a dead one. /// +/// **Project trust is reported from `project_trust`.** pi's trust prompt runs before +/// `session_start` and outside `ctx.ui`, so no `ui_prompt_start` marks it; the handler reports +/// awaiting input and leaves the decision to pi, and `session_start` reports idle once it is +/// answered. It never fires under `--approve`. +/// /// **`agent_settled`, not `agent_end`.** pi may auto-retry, compact and retry, or run a /// queued follow-up after a run ends; reporting idle there would open the delivery window /// for staged messages while the agent is still going. @@ -106,6 +111,10 @@ enum PiPresenceExtension { if (input + output > 0) set(`usage=input.${input}_output.${output}`) } + pi.on("project_trust", async (_event, ctx) => { + if (ctx.hasUI) set("presence=awaitingInput") + return { trusted: "undecided" } + }) pi.on("session_start", async (_event, ctx) => { bank(ctx) set("presence=idle", "activity=") diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift index 60c995a4..6e098a12 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -298,7 +298,8 @@ struct GhosttyTerminalView: NSViewRepresentable { prompt = SessionPrompt.composed( preamble: SessionBriefing.pointer(toBriefingAt: briefingPath), prompt: prompt) } - environment[Self.promptVariable] = prompt + environment[Self.promptVariable] = + backend == .pi ? CLISessionBackendKind.piMessage(prompt) : prompt return environment } diff --git a/graphcode/Tests/PiBackendTests.swift b/graphcode/Tests/PiBackendTests.swift index cdc9e8d5..3499cf78 100644 --- a/graphcode/Tests/PiBackendTests.swift +++ b/graphcode/Tests/PiBackendTests.swift @@ -33,6 +33,16 @@ struct PiBackendTests { #expect(CLISessionBackendKind.pi.promptFlag == nil) } + @Test + func aPromptOpeningWithADashOrAnAtStaysAMessage() { + for prompt in ["- fix the build", "@Release: ship it"] { + let arguments = CLISessionBackendKind.pi.launchArguments( + prompt: prompt, tier: .standard, settings: GraphcodeSettings()) + #expect(arguments.last == " " + prompt) + } + #expect(CLISessionBackendKind.piMessage("fix the build") == "fix the build") + } + @Test func aGoalRidesAsProseBecausePiHasNoGoalDirective() { #expect(CLISessionBackendKind.pi.capabilities.goalDirective == nil) @@ -106,6 +116,10 @@ struct PiBackendTests { for label in ["presence=busy", "presence=idle", "presence=awaitingInput", "usage=input."] { #expect(source.contains(label), "\(label) is never written") } + // The trust prompt is the one question no `ui_prompt_start` reports, and the decision + // must stay pi's. + #expect(source.contains(#"pi.on("project_trust""#)) + #expect(source.contains(#"return { trusted: "undecided" }"#)) // `agent_end` can be followed by a retry or a queued follow-up; idle there would lie. #expect(!source.contains("agent_end")) #expect(source.contains(".history"))