on main help sign in
Merge remote-tracking branch 'origin/main' into fast-ui
commitf012b46976e6aeb2b84e2cfa6cf2304b80b3d81b authorChristian Rocha <christian@rocha.is> dateThu 14 May 2026, 21:21 (5 months ago) parent55205400fc5cfe
11 files changed+189−34
go.mod+1−1 go.sum+2−2 internal/agent/coordinator.go+32−9 internal/agent/coordinator_test.go+1−1 internal/backend/backend.go+1 internal/cmd/root.go+7−4 internal/config/atomicwrite.go+38 internal/config/atomicwrite_test.go+43 internal/config/store.go+29−11 internal/proto/version.go+1 internal/version/version.go+34−6
go.mod+1−1
@@ -7,7 +7,7 @@ require (
77 charm.land/bubbletea/v2 v2.0.6
88 charm.land/catwalk v0.40.0
99 charm.land/fang/v2 v2.0.1
10− charm.land/fantasy v0.23.2
10+ charm.land/fantasy v0.24.0
1111 charm.land/glamour/v2 v2.0.0
1212 charm.land/lipgloss/v2 v2.0.3
1313 charm.land/log/v2 v2.0.0
go.sum+2−2
@@ -6,8 +6,8 @@ charm.land/catwalk v0.40.0 h1:Ldqr2YzTqk2QdXAFFa5W/EiFoxNGMQc7KXekze5Z+Cw=
66charm.land/catwalk v0.40.0/go.mod h1:LmMFJdRqF5F7qKa+xqD9SBq7tph7L98GU3ZFa1TxftA=
77charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
88charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
9−charm.land/fantasy v0.23.2 h1:9gUknrENwv0lV379Hs7m/GK2c/9m+gWXyfbox1tVHrg=
10−charm.land/fantasy v0.23.2/go.mod h1:8QrWUzIcKwZQP+aAnC9vLu3iID6hu9/Jt+rPMiieBkc=
9+charm.land/fantasy v0.24.0 h1:/q35T2TvrqTYqMnFOBHEqp8IWoKMxh05Y7r5wsF/6F4=
10+charm.land/fantasy v0.24.0/go.mod h1:8QrWUzIcKwZQP+aAnC9vLu3iID6hu9/Jt+rPMiieBkc=
1111charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
1212charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
1313charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
internal/agent/coordinator.go+32−9
@@ -62,6 +62,15 @@ var (
6262 errSmallModelNotFound = errors.New("small model not found in provider config")
6363)
6464
65+// Copilot models that use the Responses API instead of Chat Completions.
66+var copilotResponsesModels = map[string]bool{
67+ "gpt-5.2": true,
68+ "gpt-5.2-codex": true,
69+ "gpt-5.3-codex": true,
70+ "gpt-5.4-mini": true,
71+ "gpt-5-mini": true,
72+}
73+
6574type Coordinator interface {
6675 // INFO: (kujtim) this is not used yet we will use this when we have multiple agents
6776 // SetMainAgent(string)
@@ -268,7 +277,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
268277 switch providerCfg.Type {
269278 case openai.Name, azure.Name:
270279 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
271− if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
280+ if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" && model.CatwalkCfg.CanReason {
272281 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
273282 }
274283 if openai.IsResponsesModel(model.CatwalkCfg.ID) {
@@ -292,7 +301,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
292301 _, hasThink = mergedOptions["thinking"]
293302 )
294303 switch {
295− case !hasEffort && model.ModelCfg.ReasoningEffort != "":
304+ case !hasEffort && model.ModelCfg.ReasoningEffort != "" && model.CatwalkCfg.CanReason:
296305 mergedOptions["effort"] = model.ModelCfg.ReasoningEffort
297306 case !hasThink && model.ModelCfg.Think:
298307 mergedOptions["thinking"] = map[string]any{"budget_tokens": 2000}
@@ -346,13 +355,18 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
346355 options[google.Name] = parsed
347356 }
348357 case openaicompat.Name, hyper.Name:
358+ extraBody := make(map[string]any)
359+
349360 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
350− if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
351− mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
361+ if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" && model.CatwalkCfg.CanReason {
362+ switch providerCfg.ID {
363+ case string(catwalk.InferenceProviderIoNet):
364+ extraBody["reasoning"] = map[string]string{"effort": model.ModelCfg.ReasoningEffort}
365+ default:
366+ mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
367+ }
352368 }
353369
354− extraBody := make(map[string]any)
355−
356370 // "reasoning effort" is a standard OpenAI field, but "thinking" is not.
357371 // Setting it in the right way for each provider.
358372 // TODO: Abstract this in Fantasy somehow?
@@ -361,8 +375,12 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
361375 case hyper.Name:
362376 extraBody["thinking"] = model.ModelCfg.Think
363377 case string(catwalk.InferenceProviderIoNet):
364− extraBody["chat_template_kwargs"] = map[string]any{
365− "thinking": model.ModelCfg.Think,
378+ if _, ok := extraBody["reasoning"]; !ok && model.CatwalkCfg.CanReason {
379+ if model.ModelCfg.Think {
380+ extraBody["reasoning"] = map[string]string{"effort": "medium"}
381+ } else {
382+ extraBody["reasoning"] = map[string]string{"effort": "none"}
383+ }
366384 }
367385 case string(catwalk.InferenceProviderZAI), string(catwalk.InferenceProviderDeepSeek):
368386 if model.ModelCfg.Think {
@@ -722,7 +740,12 @@ func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers
722740 // Set HTTP client based on provider and debug mode.
723741 var httpClient *http.Client
724742 if providerID == string(catwalk.InferenceProviderCopilot) {
725− opts = append(opts, openaicompat.WithUseResponsesAPI())
743+ opts = append(opts,
744+ openaicompat.WithUseResponsesAPI(),
745+ openaicompat.WithResponsesAPIFunc(func(modelID string) bool {
746+ return copilotResponsesModels[modelID]
747+ }),
748+ )
726749 httpClient = copilot.NewClient(isSubAgent, c.cfg.Config().Options.Debug)
727750 } else if c.cfg.Config().Options.Debug {
728751 httpClient = log.NewHTTPClient()
internal/agent/coordinator_test.go+1−1
@@ -399,7 +399,7 @@ func TestGetProviderOptionsReasoningEffort(t *testing.T) {
399399 for _, tc := range tests {
400400 t.Run(tc.name, func(t *testing.T) {
401401 model := Model{
402− CatwalkCfg: catwalk.Model{ID: "claude-opus-4-7"},
402+ CatwalkCfg: catwalk.Model{ID: "claude-opus-4-7", CanReason: true},
403403 ModelCfg: config.SelectedModel{
404404 Provider: "test",
405405 ReasoningEffort: "max",
internal/backend/backend.go+1
@@ -174,6 +174,7 @@ func (b *Backend) VersionInfo() proto.VersionInfo {
174174 return proto.VersionInfo{
175175 Version: version.Version,
176176 Commit: version.Commit,
177+ BuildID: version.BuildID,
177178 GoVersion: runtime.Version(),
178179 Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
179180 }
internal/cmd/root.go+7−4
@@ -614,12 +614,15 @@ func restartIfStale(cmd *cobra.Command, hostURL *url.URL) (restarted bool, err e
614614 if err != nil {
615615 return false, err
616616 }
617− if vi.Version == version.Version {
617+ if vi.Version == version.Version && vi.BuildID == version.BuildID {
618618 return false, nil
619619 }
620− slog.Info("Server version mismatch, restarting",
621− "server", vi.Version,
622− "client", version.Version,
620+ slog.Info(
621+ "Server version mismatch, restarting",
622+ "server_version", vi.Version,
623+ "client_version", version.Version,
624+ "server_build_id", vi.BuildID,
625+ "client_build_id", version.BuildID,
623626 )
624627 _ = c.ShutdownServer(cmd.Context())
625628 // Give the old process a moment to release the socket.
internal/config/atomicwrite.gonew file+38
@@ -0,0 +1,38 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+)
7+
8+// atomicWriteFile writes data to a file atomically by writing to a unique
9+// temporary file in the same directory and renaming it into place. This
10+// prevents concurrent readers from observing a partially-written file.
11+func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
12+ path = filepath.Clean(path)
13+ dir := filepath.Dir(path)
14+ f, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
15+ if err != nil {
16+ return err
17+ }
18+ tmp := f.Name()
19+ if _, err := f.Write(data); err != nil {
20+ f.Close()
21+ os.Remove(tmp)
22+ return err
23+ }
24+ if err := f.Chmod(perm); err != nil {
25+ f.Close()
26+ os.Remove(tmp)
27+ return err
28+ }
29+ if err := f.Close(); err != nil {
30+ os.Remove(tmp)
31+ return err
32+ }
33+ if err := os.Rename(tmp, path); err != nil {
34+ os.Remove(tmp)
35+ return err
36+ }
37+ return nil
38+}
internal/config/atomicwrite_test.gonew file+43
@@ -0,0 +1,43 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "runtime"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestAtomicWriteFile(t *testing.T) {
13+ t.Parallel()
14+ dir := t.TempDir()
15+ path := filepath.Join(dir, "test.json")
16+
17+ require.NoError(t, atomicWriteFile(path, []byte(`{"key":"value"}`), 0o600))
18+
19+ data, err := os.ReadFile(path)
20+ require.NoError(t, err)
21+ require.Equal(t, `{"key":"value"}`, string(data))
22+
23+ // No temp files should linger.
24+ entries, err := os.ReadDir(dir)
25+ require.NoError(t, err)
26+ require.Len(t, entries, 1)
27+ require.Equal(t, "test.json", entries[0].Name())
28+}
29+
30+func TestAtomicWriteFile_PermissionsApplied(t *testing.T) {
31+ if runtime.GOOS == "windows" {
32+ t.Skip("Windows does not support Unix file permissions")
33+ }
34+ t.Parallel()
35+ dir := t.TempDir()
36+ path := filepath.Join(dir, "test.json")
37+
38+ require.NoError(t, atomicWriteFile(path, []byte(`{}`), 0o600))
39+
40+ info, err := os.Stat(path)
41+ require.NoError(t, err)
42+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
43+}
internal/config/store.go+29−11
@@ -158,7 +158,7 @@ func (s *ConfigStore) SetConfigFields(scope Scope, kv map[string]any) error {
158158 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
159159 return fmt.Errorf("failed to create config directory %q: %w", path, err)
160160 }
161− if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
161+ if err := atomicWriteFile(path, []byte(newValue), 0o600); err != nil {
162162 return fmt.Errorf("failed to write config file: %w", err)
163163 }
164164
@@ -193,7 +193,7 @@ func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
193193 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
194194 return fmt.Errorf("failed to create config directory %q: %w", path, err)
195195 }
196− if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
196+ if err := atomicWriteFile(path, []byte(newValue), 0o600); err != nil {
197197 return fmt.Errorf("failed to write config file: %w", err)
198198 }
199199
@@ -302,7 +302,10 @@ func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey a
302302// RefreshOAuthToken refreshes the OAuth token for the given provider.
303303// Before making an external refresh request, it checks the config file on
304304// disk to see if another Crush session has already refreshed the token. If
305−// a newer token is found, it is used instead of refreshing.
305+// a newer, unexpired token is found, it is used instead of refreshing. If
306+// the exchange fails (e.g. because another session already rotated the
307+// refresh token), the disk is re-checked to recover the other session's
308+// token.
306309func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
307310 providerConfig, exists := s.config.Providers.Get(providerID)
308311 if !exists {
@@ -318,15 +321,9 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
318321 newToken, err := s.loadTokenFromDisk(scope, providerID)
319322 if err != nil {
320323 slog.Warn("Failed to read token from config file, proceeding with refresh", "provider", providerID, "error", err)
321− } else if newToken != nil && newToken.AccessToken != providerConfig.OAuthToken.AccessToken {
324+ } else if newToken != nil && !newToken.IsExpired() && newToken.AccessToken != providerConfig.OAuthToken.AccessToken {
322325 slog.Info("Using token refreshed by another session", "provider", providerID)
323− providerConfig.OAuthToken = newToken
324− providerConfig.APIKey = newToken.AccessToken
325− if providerID == string(catwalk.InferenceProviderCopilot) {
326− providerConfig.SetupGitHubCopilot()
327− }
328− s.config.Providers.Set(providerID, providerConfig)
329− return nil
326+ return s.applyToken(providerConfig, newToken, providerID)
330327 }
331328
332329 var refreshedToken *oauth.Token
@@ -340,6 +337,16 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
340337 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
341338 }
342339 if refreshErr != nil {
340+ // The exchange may have failed because another session already
341+ // rotated the refresh token. Re-read the config file and use the
342+ // other session's token if available.
343+ if diskToken, diskErr := s.loadTokenFromDisk(scope, providerID); diskErr == nil &&
344+ diskToken != nil &&
345+ !diskToken.IsExpired() &&
346+ diskToken.AccessToken != providerConfig.OAuthToken.AccessToken {
347+ slog.Info("Using token refreshed by another session after exchange failure", "provider", providerID)
348+ return s.applyToken(providerConfig, diskToken, providerID)
349+ }
343350 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
344351 }
345352
@@ -364,6 +371,17 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
364371 return nil
365372}
366373
374+// applyToken updates the in-memory provider config with the given token.
375+func (s *ConfigStore) applyToken(providerConfig ProviderConfig, token *oauth.Token, providerID string) error {
376+ providerConfig.OAuthToken = token
377+ providerConfig.APIKey = token.AccessToken
378+ if providerID == string(catwalk.InferenceProviderCopilot) {
379+ providerConfig.SetupGitHubCopilot()
380+ }
381+ s.config.Providers.Set(providerID, providerConfig)
382+ return nil
383+}
384+
367385// loadTokenFromDisk reads the OAuth token for the given provider from the
368386// config file on disk. Returns nil if the token is not found or matches the
369387// current in-memory token.
internal/proto/version.go+1
@@ -4,6 +4,7 @@ package proto
44type VersionInfo struct {
55 Version string `json:"version"`
66 Commit string `json:"commit"`
7+ BuildID string `json:"build_id"`
78 GoVersion string `json:"go_version"`
89 Platform string `json:"platform"`
910}
internal/version/version.go+34−6
@@ -1,12 +1,21 @@
11package version
22
3−import "runtime/debug"
3+import (
4+ "os"
5+ "runtime/debug"
6+ "strconv"
7+)
48
59// Build-time parameters set via -ldflags.
610
711var (
812 Version = "devel"
913 Commit = "unknown"
14+ // BuildID is a unique identifier for this build. For release builds it
15+ // equals Commit; for development builds (go run / go build without
16+ // ldflags) it is derived from the executable's modification time, which
17+ // changes on every recompilation.
18+ BuildID = ""
1019)
1120
1221// A user may install crush using `go install github.com/charmbracelet/crush@latest`.
@@ -15,11 +24,30 @@ var (
1524// is only set for `go install` and not for `go build`).
1625func init() {
1726 info, ok := debug.ReadBuildInfo()
18− if !ok {
19− return
27+ if ok {
28+ mainVersion := info.Main.Version
29+ if mainVersion != "" && mainVersion != "(devel)" {
30+ Version = mainVersion
31+ }
32+ }
33+
34+ // Derive BuildID when not set via ldflags.
35+ if BuildID == "" {
36+ BuildID = deriveBuildID()
37+ }
38+}
39+
40+// deriveBuildID uses the running executable's modification time as a unique
41+// build fingerprint. This changes on every recompilation (including `go run`),
42+// making it reliable for detecting stale servers during development.
43+func deriveBuildID() string {
44+ exe, err := os.Executable()
45+ if err != nil {
46+ return "unknown"
2047 }
21− mainVersion := info.Main.Version
22− if mainVersion != "" && mainVersion != "(devel)" {
23− Version = mainVersion
48+ fi, err := os.Stat(exe)
49+ if err != nil {
50+ return "unknown"
2451 }
52+ return strconv.FormatInt(fi.ModTime().UnixNano(), 36)
2553}