on main help sign in
Merge remote-tracking branch 'origin/main' into fast-ui
f012b46Christian Rocha
go.mod+1−1
@@ -7,7 +7,7 @@ require (
7 charm.land/bubbletea/v2 v2.0.6
7 charm.land/bubbletea/v2 v2.0.6
8 charm.land/catwalk v0.40.0
8 charm.land/catwalk v0.40.0
9 charm.land/fang/v2 v2.0.1
9 charm.land/fang/v2 v2.0.1
10− charm.land/fantasy v0.23.2
10+ charm.land/fantasy v0.24.0
11 charm.land/glamour/v2 v2.0.0
11 charm.land/glamour/v2 v2.0.0
12 charm.land/lipgloss/v2 v2.0.3
12 charm.land/lipgloss/v2 v2.0.3
13 charm.land/log/v2 v2.0.0
13 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=
6charm.land/catwalk v0.40.0/go.mod h1:LmMFJdRqF5F7qKa+xqD9SBq7tph7L98GU3ZFa1TxftA=
6charm.land/catwalk v0.40.0/go.mod h1:LmMFJdRqF5F7qKa+xqD9SBq7tph7L98GU3ZFa1TxftA=
7charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
7charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
8charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
8charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
9−charm.land/fantasy v0.23.2 h1:9gUknrENwv0lV379Hs7m/GK2c/9m+gWXyfbox1tVHrg=
9+charm.land/fantasy v0.24.0 h1:/q35T2TvrqTYqMnFOBHEqp8IWoKMxh05Y7r5wsF/6F4=
10−charm.land/fantasy v0.23.2/go.mod h1:8QrWUzIcKwZQP+aAnC9vLu3iID6hu9/Jt+rPMiieBkc=
10+charm.land/fantasy v0.24.0/go.mod h1:8QrWUzIcKwZQP+aAnC9vLu3iID6hu9/Jt+rPMiieBkc=
11charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
11charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
12charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
12charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
13charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
13charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
internal/agent/coordinator.go+32−9
@@ -62,6 +62,15 @@ var (
62 errSmallModelNotFound = errors.New("small model not found in provider config")
62 errSmallModelNotFound = errors.New("small model not found in provider config")
63)
63)
64
64
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+
65type Coordinator interface {
74type Coordinator interface {
66 // INFO: (kujtim) this is not used yet we will use this when we have multiple agents
75 // INFO: (kujtim) this is not used yet we will use this when we have multiple agents
67 // SetMainAgent(string)
76 // SetMainAgent(string)
@@ -268,7 +277,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
268 switch providerCfg.Type {
277 switch providerCfg.Type {
269 case openai.Name, azure.Name:
278 case openai.Name, azure.Name:
270 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
279 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
271− if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" {
280+ if !hasReasoningEffort && model.ModelCfg.ReasoningEffort != "" && model.CatwalkCfg.CanReason {
272 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
281 mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
273 }
282 }
274 if openai.IsResponsesModel(model.CatwalkCfg.ID) {
283 if openai.IsResponsesModel(model.CatwalkCfg.ID) {
@@ -292,7 +301,7 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
292 _, hasThink = mergedOptions["thinking"]
301 _, hasThink = mergedOptions["thinking"]
293 )
302 )
294 switch {
303 switch {
295− case !hasEffort && model.ModelCfg.ReasoningEffort != "":
304+ case !hasEffort && model.ModelCfg.ReasoningEffort != "" && model.CatwalkCfg.CanReason:
296 mergedOptions["effort"] = model.ModelCfg.ReasoningEffort
305 mergedOptions["effort"] = model.ModelCfg.ReasoningEffort
297 case !hasThink && model.ModelCfg.Think:
306 case !hasThink && model.ModelCfg.Think:
298 mergedOptions["thinking"] = map[string]any{"budget_tokens": 2000}
307 mergedOptions["thinking"] = map[string]any{"budget_tokens": 2000}
@@ -346,13 +355,18 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
346 options[google.Name] = parsed
355 options[google.Name] = parsed
347 }
356 }
348 case openaicompat.Name, hyper.Name:
357 case openaicompat.Name, hyper.Name:
358+ extraBody := make(map[string]any)
359+
349 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
360 _, hasReasoningEffort := mergedOptions["reasoning_effort"]
350− if !hasReasoningEffort && 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:
351− mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
366+ mergedOptions["reasoning_effort"] = model.ModelCfg.ReasoningEffort
367+ }
352 }
368 }
353
369
354− extraBody := make(map[string]any)
355−
356 // "reasoning effort" is a standard OpenAI field, but "thinking" is not.
370 // "reasoning effort" is a standard OpenAI field, but "thinking" is not.
357 // Setting it in the right way for each provider.
371 // Setting it in the right way for each provider.
358 // TODO: Abstract this in Fantasy somehow?
372 // TODO: Abstract this in Fantasy somehow?
@@ -361,8 +375,12 @@ func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.
361 case hyper.Name:
375 case hyper.Name:
362 extraBody["thinking"] = model.ModelCfg.Think
376 extraBody["thinking"] = model.ModelCfg.Think
363 case string(catwalk.InferenceProviderIoNet):
377 case string(catwalk.InferenceProviderIoNet):
364− extraBody["chat_template_kwargs"] = map[string]any{
378+ if _, ok := extraBody["reasoning"]; !ok && model.CatwalkCfg.CanReason {
365− "thinking": model.ModelCfg.Think,
379+ if model.ModelCfg.Think {
380+ extraBody["reasoning"] = map[string]string{"effort": "medium"}
381+ } else {
382+ extraBody["reasoning"] = map[string]string{"effort": "none"}
383+ }
366 }
384 }
367 case string(catwalk.InferenceProviderZAI), string(catwalk.InferenceProviderDeepSeek):
385 case string(catwalk.InferenceProviderZAI), string(catwalk.InferenceProviderDeepSeek):
368 if model.ModelCfg.Think {
386 if model.ModelCfg.Think {
@@ -722,7 +740,12 @@ func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers
722 // Set HTTP client based on provider and debug mode.
740 // Set HTTP client based on provider and debug mode.
723 var httpClient *http.Client
741 var httpClient *http.Client
724 if providerID == string(catwalk.InferenceProviderCopilot) {
742 if providerID == string(catwalk.InferenceProviderCopilot) {
743+ opts = append(opts,
725− opts = append(opts, openaicompat.WithUseResponsesAPI())
744+ openaicompat.WithUseResponsesAPI(),
745+ openaicompat.WithResponsesAPIFunc(func(modelID string) bool {
746+ return copilotResponsesModels[modelID]
747+ }),
748+ )
726 httpClient = copilot.NewClient(isSubAgent, c.cfg.Config().Options.Debug)
749 httpClient = copilot.NewClient(isSubAgent, c.cfg.Config().Options.Debug)
727 } else if c.cfg.Config().Options.Debug {
750 } else if c.cfg.Config().Options.Debug {
728 httpClient = log.NewHTTPClient()
751 httpClient = log.NewHTTPClient()
internal/agent/coordinator_test.go+1−1
@@ -399,7 +399,7 @@ func TestGetProviderOptionsReasoningEffort(t *testing.T) {
399 for _, tc := range tests {
399 for _, tc := range tests {
400 t.Run(tc.name, func(t *testing.T) {
400 t.Run(tc.name, func(t *testing.T) {
401 model := Model{
401 model := Model{
402− CatwalkCfg: catwalk.Model{ID: "claude-opus-4-7"},
402+ CatwalkCfg: catwalk.Model{ID: "claude-opus-4-7", CanReason: true},
403 ModelCfg: config.SelectedModel{
403 ModelCfg: config.SelectedModel{
404 Provider: "test",
404 Provider: "test",
405 ReasoningEffort: "max",
405 ReasoningEffort: "max",
internal/backend/backend.go+1
@@ -174,6 +174,7 @@ func (b *Backend) VersionInfo() proto.VersionInfo {
174 return proto.VersionInfo{
174 return proto.VersionInfo{
175 Version: version.Version,
175 Version: version.Version,
176 Commit: version.Commit,
176 Commit: version.Commit,
177+ BuildID: version.BuildID,
177 GoVersion: runtime.Version(),
178 GoVersion: runtime.Version(),
178 Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
179 Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
179 }
180 }
internal/cmd/root.go+7−4
@@ -614,12 +614,15 @@ func restartIfStale(cmd *cobra.Command, hostURL *url.URL) (restarted bool, err e
614 if err != nil {
614 if err != nil {
615 return false, err
615 return false, err
616 }
616 }
617− if vi.Version == version.Version {
617+ if vi.Version == version.Version && vi.BuildID == version.BuildID {
618 return false, nil
618 return false, nil
619 }
619 }
620+ slog.Info(
620− slog.Info("Server version mismatch, restarting",
621+ "Server version mismatch, restarting",
621− "server", vi.Version,
622+ "server_version", vi.Version,
622− "client", version.Version,
623+ "client_version", version.Version,
624+ "server_build_id", vi.BuildID,
625+ "client_build_id", version.BuildID,
623 )
626 )
624 _ = c.ShutdownServer(cmd.Context())
627 _ = c.ShutdownServer(cmd.Context())
625 // Give the old process a moment to release the socket.
628 // 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 {
158 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
158 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
159 return fmt.Errorf("failed to create config directory %q: %w", path, err)
159 return fmt.Errorf("failed to create config directory %q: %w", path, err)
160 }
160 }
161− if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
161+ if err := atomicWriteFile(path, []byte(newValue), 0o600); err != nil {
162 return fmt.Errorf("failed to write config file: %w", err)
162 return fmt.Errorf("failed to write config file: %w", err)
163 }
163 }
164
164
@@ -193,7 +193,7 @@ func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
193 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
193 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
194 return fmt.Errorf("failed to create config directory %q: %w", path, err)
194 return fmt.Errorf("failed to create config directory %q: %w", path, err)
195 }
195 }
196− if err := os.WriteFile(path, []byte(newValue), 0o600); err != nil {
196+ if err := atomicWriteFile(path, []byte(newValue), 0o600); err != nil {
197 return fmt.Errorf("failed to write config file: %w", err)
197 return fmt.Errorf("failed to write config file: %w", err)
198 }
198 }
199
199
@@ -302,7 +302,10 @@ func (s *ConfigStore) SetProviderAPIKey(scope Scope, providerID string, apiKey a
302// RefreshOAuthToken refreshes the OAuth token for the given provider.
302// RefreshOAuthToken refreshes the OAuth token for the given provider.
303// Before making an external refresh request, it checks the config file on
303// Before making an external refresh request, it checks the config file on
304// disk to see if another Crush session has already refreshed the token. If
304// 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.
306func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
309func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, providerID string) error {
307 providerConfig, exists := s.config.Providers.Get(providerID)
310 providerConfig, exists := s.config.Providers.Get(providerID)
308 if !exists {
311 if !exists {
@@ -318,15 +321,9 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
318 newToken, err := s.loadTokenFromDisk(scope, providerID)
321 newToken, err := s.loadTokenFromDisk(scope, providerID)
319 if err != nil {
322 if err != nil {
320 slog.Warn("Failed to read token from config file, proceeding with refresh", "provider", providerID, "error", err)
323 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 {
322 slog.Info("Using token refreshed by another session", "provider", providerID)
325 slog.Info("Using token refreshed by another session", "provider", providerID)
323− providerConfig.OAuthToken = newToken
326+ return s.applyToken(providerConfig, newToken, providerID)
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
330 }
327 }
331
328
332 var refreshedToken *oauth.Token
329 var refreshedToken *oauth.Token
@@ -340,6 +337,16 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
340 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
337 return fmt.Errorf("OAuth refresh not supported for provider %s", providerID)
341 }
338 }
342 if refreshErr != nil {
339 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+ }
343 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
350 return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr)
344 }
351 }
345
352
@@ -364,6 +371,17 @@ func (s *ConfigStore) RefreshOAuthToken(ctx context.Context, scope Scope, provid
364 return nil
371 return nil
365}
372}
366
373
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+
367// loadTokenFromDisk reads the OAuth token for the given provider from the
385// loadTokenFromDisk reads the OAuth token for the given provider from the
368// config file on disk. Returns nil if the token is not found or matches the
386// config file on disk. Returns nil if the token is not found or matches the
369// current in-memory token.
387// current in-memory token.
internal/proto/version.go+1
@@ -4,6 +4,7 @@ package proto
4type VersionInfo struct {
4type VersionInfo struct {
5 Version string `json:"version"`
5 Version string `json:"version"`
6 Commit string `json:"commit"`
6 Commit string `json:"commit"`
7+ BuildID string `json:"build_id"`
7 GoVersion string `json:"go_version"`
8 GoVersion string `json:"go_version"`
8 Platform string `json:"platform"`
9 Platform string `json:"platform"`
9}
10}
internal/version/version.go+34−6
@@ -1,12 +1,21 @@
1package version
1package version
2
2
3+import (
4+ "os"
3−import "runtime/debug"
5+ "runtime/debug"
6+ "strconv"
7+)
4
8
5// Build-time parameters set via -ldflags.
9// Build-time parameters set via -ldflags.
6
10
7var (
11var (
8 Version = "devel"
12 Version = "devel"
9 Commit = "unknown"
13 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 = ""
10)
19)
11
20
12// A user may install crush using `go install github.com/charmbracelet/crush@latest`.
21// A user may install crush using `go install github.com/charmbracelet/crush@latest`.
@@ -15,11 +24,30 @@ var (
15// is only set for `go install` and not for `go build`).
24// is only set for `go install` and not for `go build`).
16func init() {
25func init() {
17 info, ok := debug.ReadBuildInfo()
26 info, ok := debug.ReadBuildInfo()
18− if !ok {
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 {
19− return
46+ return "unknown"
20 }
47 }
21− mainVersion := info.Main.Version
48+ fi, err := os.Stat(exe)
22− if mainVersion != "" && mainVersion != "(devel)" {
49+ if err != nil {
23− Version = mainVersion
50+ return "unknown"
24 }
51 }
52+ return strconv.FormatInt(fi.ModTime().UnixNano(), 36)
25}
53}