Skip to content

Commit b4fd5dd

Browse files
authored
Merge pull request #358 from ZekeAranyLucas/copilot-version-pin
Add configurable Copilot runtime version preference
2 parents 2ef1dac + 048b224 commit b4fd5dd

11 files changed

Lines changed: 377 additions & 11 deletions

File tree

GraphcodeKit/Sources/Domain/BackendCommand.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ extension CLISessionBackendKind {
2121
}
2222
}
2323

24+
public var supportsVersionPreference: Bool { self == .copilotCLI }
25+
26+
public func versionArguments(_ settings: GraphcodeSettings) -> [String] {
27+
guard supportsVersionPreference, let version = settings.normalizedCopilotPreferredVersion else {
28+
return []
29+
}
30+
return ["--prefer-version", version]
31+
}
32+
2433
/// The model each tier maps to for this backend.
2534
///
2635
/// Deliberately per-backend rather than one shared alias list: Claude Code takes short
@@ -92,7 +101,7 @@ extension CLISessionBackendKind {
92101
sessionsDirectory: String? = nil
93102
) -> [String] {
94103
let model =
95-
modelArguments(for: tier) + permissionArguments(settings)
104+
versionArguments(settings) + modelArguments(for: tier) + permissionArguments(settings)
96105
+ presenceArguments(
97106
hooksFile: hooksFile, sessionName: sessionName, zmxPath: zmxPath,
98107
sessionsDirectory: sessionsDirectory)

GraphcodeKit/Sources/Domain/GraphcodeSettings.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,21 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
289289
}
290290
public var claudePermissionMode: ClaudePermissionMode
291291
public var copilotPermissions: CopilotPermissions
292+
/// Empty leaves Copilot's version selection unchanged. Applies to future launches,
293+
/// including resumes, not to sessions already running.
294+
public var copilotPreferredVersion: String
295+
296+
public var normalizedCopilotPreferredVersion: String? {
297+
let version = copilotPreferredVersion.trimmingCharacters(in: .whitespacesAndNewlines)
298+
return version.isEmpty ? nil : version
299+
}
300+
301+
/// Installation remains an explicit action on the machine that runs Copilot.
302+
public var copilotInstallCommand: String? {
303+
guard let version = normalizedCopilotPreferredVersion else { return nil }
304+
return "npm install -g " + PresenceHooks.singleQuoted("@github/copilot@\(version)")
305+
}
306+
292307
/// Whether a session is told it's part of a graph and how to add loops to it
293308
/// (`SessionBriefing`). Off means loops behave exactly as they did before briefings
294309
/// existed — they do the work they were given and never create anything.
@@ -441,6 +456,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
441456
piProjectTrust: PiProjectTrust = .approve,
442457
claudePermissionMode: ClaudePermissionMode = .auto,
443458
copilotPermissions: CopilotPermissions = .allowEverything,
459+
copilotPreferredVersion: String = "",
444460
briefsSessionsAboutTheGraph: Bool = true,
445461
autoSelectsModel: Bool = false,
446462
showsActivityStrip: Bool = false,
@@ -460,6 +476,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
460476
self.piProjectTrust = piProjectTrust
461477
self.claudePermissionMode = claudePermissionMode
462478
self.copilotPermissions = copilotPermissions
479+
self.copilotPreferredVersion = copilotPreferredVersion
463480
self.briefsSessionsAboutTheGraph = briefsSessionsAboutTheGraph
464481
self.autoSelectsModel = autoSelectsModel
465482
self.showsActivityStrip = showsActivityStrip
@@ -494,6 +511,8 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
494511
copilotPermissions =
495512
try container.decodeIfPresent(CopilotPermissions.self, forKey: .copilotPermissions)
496513
?? .allowEverything
514+
copilotPreferredVersion =
515+
try container.decodeIfPresent(String.self, forKey: .copilotPreferredVersion) ?? ""
497516
briefsSessionsAboutTheGraph =
498517
try container.decodeIfPresent(Bool.self, forKey: .briefsSessionsAboutTheGraph) ?? true
499518
endsResolvedSessionsAfterMinutes =

GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ public enum SummaryModelWriter {
7373
/// The prompt is an argv element, never a shell string: it carries the agent's own
7474
/// sentence, which is arbitrary text from a model.
7575
public static func invocation(
76-
forBackend backend: CLISessionBackendKind, prompt: String, tier: ModelTier = .fast
76+
forBackend backend: CLISessionBackendKind, prompt: String, tier: ModelTier = .fast,
77+
settings: GraphcodeSettings = GraphcodeSettingsStore.load()
7778
) -> [String] {
7879
let model = backend.modelArguments(for: tier)
7980
switch backend {
@@ -82,7 +83,7 @@ public enum SummaryModelWriter {
8283
case .copilotCLI:
8384
// No `--allow-all`: this asks for a sentence, and a summariser that can run tools is
8485
// a summariser that can change the repository it is describing.
85-
return ["copilot", "-p", prompt] + model
86+
return ["copilot"] + backend.versionArguments(settings) + ["-p", prompt] + model
8687
case .codex:
8788
return ["codex", "exec", prompt] + model
8889
case .openCode:
@@ -117,9 +118,10 @@ public enum SummaryModelWriter {
117118

118119
/// The beat, rewritten — or the beat unchanged, which is every failure path.
119120
public static func rewrite(
120-
_ beat: SummaryBeat, backend: CLISessionBackendKind, workingDirectory: String?
121+
_ beat: SummaryBeat, backend: CLISessionBackendKind, workingDirectory: String?,
122+
settings: GraphcodeSettings = GraphcodeSettingsStore.load()
121123
) async -> SummaryBeat {
122-
let invocation = invocation(forBackend: backend, prompt: prompt(beat: beat))
124+
let invocation = invocation(forBackend: backend, prompt: prompt(beat: beat), settings: settings)
123125
// Through the launcher's login shell, not `Process`'s own launch. `Process` resolves
124126
// `executableURL` as a path and never searches `PATH`, so the bare `claude` above named
125127
// a file in the working directory: every rewrite on every backend threw at launch, and
@@ -173,7 +175,8 @@ public enum SummaryModelWriter {
173175
let rewritten = await rewrite(
174176
newest, backend: node.backend,
175177
workingDirectory: ZmxSessionLauncher.workingDirectory(
176-
forNode: node, projectPath: projectPath))
178+
forNode: node, projectPath: projectPath),
179+
settings: settings)
177180
return reading.replacingNewestBeat(with: rewritten)
178181
}
179182
}

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,38 @@ Releases are Developer ID signed and notarized.
8080
State lives in `~/.graphcode/` — graphs, recents, layouts, the daemon socket and logs, and the installed
8181
binaries. **Nothing is ever written inside a project folder you open.**
8282

83+
### Use a known-good Copilot version
84+
85+
If a Copilot CLI update is broken, first install a known-good published version on each
86+
machine where Copilot runs, including remote hosts, and verify the reported version.
87+
For example:
88+
89+
```sh
90+
npm install -g @github/copilot@v1.0.84-5 &&
91+
copilot --prefer-version 1.0.84-5 --version
92+
```
93+
94+
Only after installation succeeds and the reported version matches, set
95+
**Settings > Preferred versions > Copilot CLI** to **Specific version** and enter that version.
96+
The section follows **Permissions** and has one row per backend, all starting at **Default**.
97+
Claude Code, Codex, OpenCode, and Pi are locked to **Default** until version overrides are
98+
supported for them. This does not change which backend new loops or Quick Chats use.
99+
The Copilot field accepts any published version; `1.0.84-5` is just an example. GraphCode passes
100+
`--prefer-version <version>` to new and resumed Copilot sessions (app and daemon, local and
101+
SSH), and to Copilot title and summary requests. Running sessions are not interrupted.
102+
103+
Settings provides a copyable install command for the chosen version; GraphCode does not run
104+
it automatically or verify installation before activating the preference. The preference
105+
takes effect immediately for subsequent launches. Keep `copilot` on the login shell's `PATH`.
106+
Without the app, set
107+
`"copilotPreferredVersion": "1.0.84-5"` in `~/.graphcode/settings.json` (or the workspace's
108+
`GRAPHCODE_SUPPORT_DIR/settings.json`), preserving the other keys. The setting is read on
109+
each launch, so no daemon restart is needed.
110+
111+
Choose **Default**, clear the field, or remove the JSON key to stop passing `--prefer-version`.
112+
**Default** follows the CLI's own version selection; it does not install the latest release. If you also
113+
downgraded the global npm installation, run `npm install -g @github/copilot@latest` to update it.
114+
83115
## Workspaces
84116

85117
**File ▸ Workspace ▸ New Workspace…** opens a second GraphCode with projects, loops and terminal

graphcode/Sources/Clients/TitleSuggestionClient.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,19 @@ extension TitleSuggestionClient: DependencyKey {
7979
/// `-i` as well as `-l` for the reason every other launch site gives (see
8080
/// `GhosttyTerminalView.agentCommand`): the agent's `PATH` usually comes from
8181
/// `~/.zshrc`, which zsh reads only when interactive.
82-
static func invocation(for backend: CLISessionBackendKind) -> [String]? {
82+
static func invocation(
83+
for backend: CLISessionBackendKind,
84+
settings: GraphcodeSettings = GraphcodeSettingsStore.load()
85+
) -> [String]? {
8386
let command: String
8487
switch backend {
8588
case .claudeCode: command = "exec claude -p \"$\(promptVariable)\""
86-
case .copilotCLI: command = "exec copilot -p \"$\(promptVariable)\""
89+
case .copilotCLI:
90+
let prefix =
91+
(["exec", "copilot"]
92+
+ backend.versionArguments(settings).map(PresenceHooks.singleQuoted))
93+
.joined(separator: " ")
94+
command = "\(prefix) -p \"$\(promptVariable)\""
8795
// This is a separate headless process, so it does not inherit the permission flags
8896
// from the loop session. Without Codex's unattended flag it can stop at an approval
8997
// prompt and the new loop remains named "NewNode" forever.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import GraphcodeKit
2+
import SwiftUI
3+
4+
struct PreferredVersionsSettingsSection: View {
5+
@Binding var settings: GraphcodeSettings
6+
@State private var isEnteringCopilotVersion = false
7+
8+
var body: some View {
9+
Section {
10+
ForEach(CLISessionBackendKind.offerableAsDefault, id: \.self) { backend in
11+
Picker(
12+
backend.displayName,
13+
selection: backend.supportsVersionPreference ? copilotSelection : .constant(false)
14+
) {
15+
Text("Default").tag(false)
16+
if backend.supportsVersionPreference {
17+
Text("Specific version").tag(true)
18+
}
19+
}
20+
.disabled(!backend.supportsVersionPreference)
21+
22+
if backend == .copilotCLI, copilotSelection.wrappedValue {
23+
copilotVersionEditor
24+
}
25+
}
26+
} header: {
27+
Text("Preferred versions")
28+
} footer: {
29+
Text(
30+
"Default leaves version selection to each CLI; it does not install the latest release. "
31+
+ "Only Copilot currently supports an override. These settings do not change "
32+
+ "which backend new loops or chats use."
33+
)
34+
.font(.caption2)
35+
.foregroundStyle(.secondary)
36+
}
37+
}
38+
39+
private var copilotSelection: Binding<Bool> {
40+
Binding(
41+
get: { isEnteringCopilotVersion || settings.normalizedCopilotPreferredVersion != nil },
42+
set: { usesSpecificVersion in
43+
isEnteringCopilotVersion = usesSpecificVersion
44+
if !usesSpecificVersion { settings.copilotPreferredVersion = "" }
45+
})
46+
}
47+
48+
private var copilotVersionEditor: some View {
49+
Group {
50+
TextField("Copilot version", text: $settings.copilotPreferredVersion, prompt: Text("Version"))
51+
Text(
52+
"Install and verify the chosen version on each machine before setting it here. "
53+
+ "Changes apply immediately to new and resumed sessions and to title and summary "
54+
+ "requests. Running sessions are unchanged. Installation is not automatic."
55+
)
56+
.font(.caption2)
57+
.foregroundStyle(.secondary)
58+
.fixedSize(horizontal: false, vertical: true)
59+
if let command = settings.copilotInstallCommand {
60+
HStack {
61+
Text(command)
62+
.font(.system(.caption, design: .monospaced))
63+
.textSelection(.enabled)
64+
.fixedSize(horizontal: false, vertical: true)
65+
Spacer()
66+
Button("Copy") {
67+
NSPasteboard.general.clearContents()
68+
NSPasteboard.general.setString(command, forType: .string)
69+
}
70+
.help("Copy the npm install command")
71+
}
72+
}
73+
}
74+
}
75+
}

graphcode/Sources/Features/Settings/SettingsView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ struct SettingsView: View {
107107
.foregroundStyle(.secondary)
108108
}
109109

110+
PreferredVersionsSettingsSection(settings: $model.settings)
111+
110112
Section {
111113
Toggle("Pick a model for each loop", isOn: $model.settings.autoSelectsModel)
112114
} header: {

graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
214214
return Self.interactiveLoginShell(parts)
215215
}
216216

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

0 commit comments

Comments
 (0)