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
11 changes: 10 additions & 1 deletion GraphcodeKit/Sources/Domain/BackendCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ extension CLISessionBackendKind {
}
}

public var supportsVersionPreference: Bool { self == .copilotCLI }

public func versionArguments(_ settings: GraphcodeSettings) -> [String] {
guard supportsVersionPreference, let version = settings.normalizedCopilotPreferredVersion else {
return []
}
return ["--prefer-version", version]
}

/// The model each tier maps to for this backend.
///
/// Deliberately per-backend rather than one shared alias list: Claude Code takes short
Expand Down Expand Up @@ -92,7 +101,7 @@ extension CLISessionBackendKind {
sessionsDirectory: String? = nil
) -> [String] {
let model =
modelArguments(for: tier) + permissionArguments(settings)
versionArguments(settings) + modelArguments(for: tier) + permissionArguments(settings)
+ presenceArguments(
hooksFile: hooksFile, sessionName: sessionName, zmxPath: zmxPath,
sessionsDirectory: sessionsDirectory)
Expand Down
19 changes: 19 additions & 0 deletions GraphcodeKit/Sources/Domain/GraphcodeSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,21 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
}
public var claudePermissionMode: ClaudePermissionMode
public var copilotPermissions: CopilotPermissions
/// Empty leaves Copilot's version selection unchanged. Applies to future launches,
/// including resumes, not to sessions already running.
public var copilotPreferredVersion: String

public var normalizedCopilotPreferredVersion: String? {
let version = copilotPreferredVersion.trimmingCharacters(in: .whitespacesAndNewlines)
return version.isEmpty ? nil : version
}

/// Installation remains an explicit action on the machine that runs Copilot.
public var copilotInstallCommand: String? {
guard let version = normalizedCopilotPreferredVersion else { return nil }
return "npm install -g " + PresenceHooks.singleQuoted("@github/copilot@\(version)")
}

/// Whether a session is told it's part of a graph and how to add loops to it
/// (`SessionBriefing`). Off means loops behave exactly as they did before briefings
/// existed — they do the work they were given and never create anything.
Expand Down Expand Up @@ -441,6 +456,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
piProjectTrust: PiProjectTrust = .approve,
claudePermissionMode: ClaudePermissionMode = .auto,
copilotPermissions: CopilotPermissions = .allowEverything,
copilotPreferredVersion: String = "",
briefsSessionsAboutTheGraph: Bool = true,
autoSelectsModel: Bool = false,
showsActivityStrip: Bool = false,
Expand All @@ -460,6 +476,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
self.piProjectTrust = piProjectTrust
self.claudePermissionMode = claudePermissionMode
self.copilotPermissions = copilotPermissions
self.copilotPreferredVersion = copilotPreferredVersion
self.briefsSessionsAboutTheGraph = briefsSessionsAboutTheGraph
self.autoSelectsModel = autoSelectsModel
self.showsActivityStrip = showsActivityStrip
Expand Down Expand Up @@ -494,6 +511,8 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
copilotPermissions =
try container.decodeIfPresent(CopilotPermissions.self, forKey: .copilotPermissions)
?? .allowEverything
copilotPreferredVersion =
try container.decodeIfPresent(String.self, forKey: .copilotPreferredVersion) ?? ""
briefsSessionsAboutTheGraph =
try container.decodeIfPresent(Bool.self, forKey: .briefsSessionsAboutTheGraph) ?? true
endsResolvedSessionsAfterMinutes =
Expand Down
13 changes: 8 additions & 5 deletions GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ public enum SummaryModelWriter {
/// The prompt is an argv element, never a shell string: it carries the agent's own
/// sentence, which is arbitrary text from a model.
public static func invocation(
forBackend backend: CLISessionBackendKind, prompt: String, tier: ModelTier = .fast
forBackend backend: CLISessionBackendKind, prompt: String, tier: ModelTier = .fast,
settings: GraphcodeSettings = GraphcodeSettingsStore.load()
) -> [String] {
let model = backend.modelArguments(for: tier)
switch backend {
Expand All @@ -82,7 +83,7 @@ public enum SummaryModelWriter {
case .copilotCLI:
// No `--allow-all`: this asks for a sentence, and a summariser that can run tools is
// a summariser that can change the repository it is describing.
return ["copilot", "-p", prompt] + model
return ["copilot"] + backend.versionArguments(settings) + ["-p", prompt] + model
case .codex:
return ["codex", "exec", prompt] + model
case .openCode:
Expand Down Expand Up @@ -117,9 +118,10 @@ public enum SummaryModelWriter {

/// The beat, rewritten — or the beat unchanged, which is every failure path.
public static func rewrite(
_ beat: SummaryBeat, backend: CLISessionBackendKind, workingDirectory: String?
_ beat: SummaryBeat, backend: CLISessionBackendKind, workingDirectory: String?,
settings: GraphcodeSettings = GraphcodeSettingsStore.load()
) async -> SummaryBeat {
let invocation = invocation(forBackend: backend, prompt: prompt(beat: beat))
let invocation = invocation(forBackend: backend, prompt: prompt(beat: beat), settings: settings)
// Through the launcher's login shell, not `Process`'s own launch. `Process` resolves
// `executableURL` as a path and never searches `PATH`, so the bare `claude` above named
// a file in the working directory: every rewrite on every backend threw at launch, and
Expand Down Expand Up @@ -173,7 +175,8 @@ public enum SummaryModelWriter {
let rewritten = await rewrite(
newest, backend: node.backend,
workingDirectory: ZmxSessionLauncher.workingDirectory(
forNode: node, projectPath: projectPath))
forNode: node, projectPath: projectPath),
settings: settings)
return reading.replacingNewestBeat(with: rewritten)
}
}
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,38 @@ Releases are Developer ID signed and notarized.
State lives in `~/.graphcode/` — graphs, recents, layouts, the daemon socket and logs, and the installed
binaries. **Nothing is ever written inside a project folder you open.**

### Use a known-good Copilot version

If a Copilot CLI update is broken, first install a known-good published version on each
machine where Copilot runs, including remote hosts, and verify the reported version.
For example:

```sh
npm install -g @github/copilot@v1.0.84-5 &&
copilot --prefer-version 1.0.84-5 --version
```

Only after installation succeeds and the reported version matches, set
**Settings > Preferred versions > Copilot CLI** to **Specific version** and enter that version.
The section follows **Permissions** and has one row per backend, all starting at **Default**.
Claude Code, Codex, OpenCode, and Pi are locked to **Default** until version overrides are
supported for them. This does not change which backend new loops or Quick Chats use.
The Copilot field accepts any published version; `1.0.84-5` is just an example. GraphCode passes
`--prefer-version <version>` to new and resumed Copilot sessions (app and daemon, local and
SSH), and to Copilot title and summary requests. Running sessions are not interrupted.

Settings provides a copyable install command for the chosen version; GraphCode does not run
it automatically or verify installation before activating the preference. The preference
takes effect immediately for subsequent launches. Keep `copilot` on the login shell's `PATH`.
Without the app, set
`"copilotPreferredVersion": "1.0.84-5"` in `~/.graphcode/settings.json` (or the workspace's
`GRAPHCODE_SUPPORT_DIR/settings.json`), preserving the other keys. The setting is read on
each launch, so no daemon restart is needed.

Choose **Default**, clear the field, or remove the JSON key to stop passing `--prefer-version`.
**Default** follows the CLI's own version selection; it does not install the latest release. If you also
downgraded the global npm installation, run `npm install -g @github/copilot@latest` to update it.

## Workspaces

**File ▸ Workspace ▸ New Workspace…** opens a second GraphCode with projects, loops and terminal
Expand Down
12 changes: 10 additions & 2 deletions graphcode/Sources/Clients/TitleSuggestionClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,19 @@ extension TitleSuggestionClient: DependencyKey {
/// `-i` as well as `-l` for the reason every other launch site gives (see
/// `GhosttyTerminalView.agentCommand`): the agent's `PATH` usually comes from
/// `~/.zshrc`, which zsh reads only when interactive.
static func invocation(for backend: CLISessionBackendKind) -> [String]? {
static func invocation(
for backend: CLISessionBackendKind,
settings: GraphcodeSettings = GraphcodeSettingsStore.load()
) -> [String]? {
let command: String
switch backend {
case .claudeCode: command = "exec claude -p \"$\(promptVariable)\""
case .copilotCLI: command = "exec copilot -p \"$\(promptVariable)\""
case .copilotCLI:
let prefix =
(["exec", "copilot"]
+ backend.versionArguments(settings).map(PresenceHooks.singleQuoted))
.joined(separator: " ")
command = "\(prefix) -p \"$\(promptVariable)\""
// This is a separate headless process, so it does not inherit the permission flags
// from the loop session. Without Codex's unattended flag it can stop at an approval
// prompt and the new loop remains named "NewNode" forever.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import GraphcodeKit
import SwiftUI

struct PreferredVersionsSettingsSection: View {
@Binding var settings: GraphcodeSettings
@State private var isEnteringCopilotVersion = false

var body: some View {
Section {
ForEach(CLISessionBackendKind.offerableAsDefault, id: \.self) { backend in
Picker(
backend.displayName,
selection: backend.supportsVersionPreference ? copilotSelection : .constant(false)
) {
Text("Default").tag(false)
if backend.supportsVersionPreference {
Text("Specific version").tag(true)
}
}
.disabled(!backend.supportsVersionPreference)

if backend == .copilotCLI, copilotSelection.wrappedValue {
copilotVersionEditor
}
}
} header: {
Text("Preferred versions")
} footer: {
Text(
"Default leaves version selection to each CLI; it does not install the latest release. "
+ "Only Copilot currently supports an override. These settings do not change "
+ "which backend new loops or chats use."
)
.font(.caption2)
.foregroundStyle(.secondary)
}
}

private var copilotSelection: Binding<Bool> {
Binding(
get: { isEnteringCopilotVersion || settings.normalizedCopilotPreferredVersion != nil },
set: { usesSpecificVersion in
isEnteringCopilotVersion = usesSpecificVersion
if !usesSpecificVersion { settings.copilotPreferredVersion = "" }
})
}

private var copilotVersionEditor: some View {
Group {
TextField("Copilot version", text: $settings.copilotPreferredVersion, prompt: Text("Version"))
Text(
"Install and verify the chosen version on each machine before setting it here. "
+ "Changes apply immediately to new and resumed sessions and to title and summary "
+ "requests. Running sessions are unchanged. Installation is not automatic."
)
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let command = settings.copilotInstallCommand {
HStack {
Text(command)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
Spacer()
Button("Copy") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(command, forType: .string)
}
.help("Copy the npm install command")
}
}
}
}
}
2 changes: 2 additions & 0 deletions graphcode/Sources/Features/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ struct SettingsView: View {
.foregroundStyle(.secondary)
}

PreferredVersionsSettingsSection(settings: $model.settings)

Section {
Toggle("Pick a model for each loop", isOn: $model.settings.autoSelectsModel)
} header: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
return Self.interactiveLoginShell(parts)
}

/// The words every launch of this surface's agent starts from — executable, model,
/// The words every launch of this surface's agent starts from — executable, version, model,
/// permissions — shared by the fresh launch above and the reboot resume
/// (`resumeCommand`), so a flag every session needs cannot land in one and not the
/// other.
Expand All @@ -225,6 +225,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
let model = backend.modelArguments(for: tier).joined(separator: " ")
let permissions = backend.permissionArguments(settings).joined(separator: " ")
var parts = ["exec", executable]
parts += backend.versionArguments(settings).map(PresenceHooks.singleQuoted)
if !model.isEmpty { parts.append(model) }
if !permissions.isEmpty { parts.append(permissions) }
return parts
Expand Down
Loading
Loading