Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion GraphcodeKit/Sources/CLI/GraphcodeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> claudeCode | copilotCLI | codex | openCode — default: run from inside a
--backend <name> claudeCode | copilotCLI | codex | openCode | pi — default: run from inside a
loop, the creating loop's backend; otherwise the default
in Settings -> Sessions
--model <tier> fast | standard | capable (default: by loop type)
Expand Down
25 changes: 25 additions & 0 deletions GraphcodeKit/Sources/Domain/BackendCapabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ extension CLISessionBackendKind {
case .copilotCLI: return "Copilot CLI"
case .codex: return "Codex"
case .openCode: return "OpenCode"
case .pi: return "Pi"
}
}

Expand Down Expand Up @@ -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 <id>` 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)
}
}

Expand Down
36 changes: 32 additions & 4 deletions GraphcodeKit/Sources/Domain/BackendCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ extension CLISessionBackendKind {
case .copilotCLI: return "copilot"
case .codex: return "codex"
case .openCode: return "opencode"
case .pi: return "pi"
}
}

Expand Down Expand Up @@ -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 []
}
}

Expand Down Expand Up @@ -147,15 +152,33 @@ 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 + [Self.piMessage(prompt)] }
return model
+ [
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.
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"
}
Expand All @@ -164,7 +187,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
}
}
Expand Down Expand Up @@ -195,6 +218,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
}
}

Expand All @@ -205,6 +229,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
Expand All @@ -213,7 +238,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]
}
}

Expand All @@ -226,7 +251,7 @@ extension CLISessionBackendKind {
switch self {
case .openCode:
return hooksFile.map { ["OPENCODE_CONFIG": $0.path] } ?? [:]
case .claudeCode, .copilotCLI, .codex:
case .claudeCode, .copilotCLI, .codex, .pi:
return [:]
}
}
Expand Down Expand Up @@ -276,6 +301,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] } ?? []
}
}
}
3 changes: 2 additions & 1 deletion GraphcodeKit/Sources/Domain/CLISessionBackendKind.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -12,4 +12,5 @@ public enum CLISessionBackendKind: String, Codable, CaseIterable, Sendable {
case copilotCLI
case codex
case openCode
case pi
}
38 changes: 38 additions & 0 deletions GraphcodeKit/Sources/Domain/GraphcodeSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion GraphcodeKit/Sources/Domain/SessionBriefing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 7 additions & 3 deletions GraphcodeKit/Sources/Sessions/CLISessionBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
},
Expand Down Expand Up @@ -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:
// 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.
// Off, `applied` returns what it was given untouched.
Expand Down
Loading
Loading