From 333803e35f5436599def1bb7ddea4c1363075efc Mon Sep 17 00:00:00 2001 From: aappddeevv Date: Sun, 22 Feb 2026 07:33:19 -0500 Subject: [PATCH 1/3] defaultAgent/agentStart/.sidecar-agent-start functionality --- PRIVACY.md | 3 +- internal/config/config.go | 9 +- internal/config/loader.go | 85 ++++++- internal/config/loader_test.go | 130 ++++++++++ internal/config/saver.go | 18 +- internal/config/saver_test.go | 49 ++++ internal/plugins/workspace/agent.go | 139 +++++++++-- internal/plugins/workspace/agent_test.go | 229 +++++++++++++++++- internal/plugins/workspace/plugin.go | 116 +++++---- .../plugins/workspace/plugin_defaults_test.go | 84 +++++++ internal/plugins/workspace/setup.go | 1 + internal/plugins/workspace/shell.go | 14 +- internal/plugins/workspace/worktree.go | 1 + website/docs/workspaces-plugin.md | 34 ++- 14 files changed, 825 insertions(+), 87 deletions(-) create mode 100644 internal/plugins/workspace/plugin_defaults_test.go diff --git a/PRIVACY.md b/PRIVACY.md index d87ad3fe..3a1d7795 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -54,7 +54,7 @@ In workspace directories, sidecar may create: - `.sidecar/config.json` — per-project configuration (prompts, theme overrides) - `.sidecar/shells.json` — shell display names and metadata -- `.sidecar-task`, `.sidecar-agent`, `.sidecar-pr`, `.sidecar-base` — workspace state files +- `.sidecar-task`, `.sidecar-agent`, `.sidecar-agent-start`, `.sidecar-pr`, `.sidecar-base` — workspace state files - `.sidecar-start.sh` — temporary agent launcher script - `.sidecar-rename-tmp` — temporary file for rename operations - `.td-root` — links worktrees to a shared td database root @@ -81,6 +81,7 @@ Sidecar reads: - `HOME` — base path for all config and data directories - `EDITOR`, `VISUAL` — to open files in your editor - `SIDECAR_PPROF` — profiling server port (development only) +- `SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE`, `SIDECAR_DEFAULT_AGENT_TYPE` — override workspace default agent type at startup - `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, `XDG_STATE_HOME` — standard directories for locating agent data on Linux - `AMP_DATA_HOME` — Amp-specific data directory - `APPDATA`, `LOCALAPPDATA` — Windows data directories diff --git a/internal/config/config.go b/internal/config/config.go index 09f1ebc3..4e930950 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -67,6 +67,13 @@ type WorkspacePluginConfig struct { // DirPrefix prefixes workspace directory names with the repo name (e.g., 'myrepo-feature-auth') // This helps associate conversations with the repo after workspace deletion. Default: true. DirPrefix bool `json:"dirPrefix"` + // DefaultAgentType sets the default agent family selected when creating a workspace. + // Uses workspace.AgentType values (e.g. "claude", "codex", "opencode"). + DefaultAgentType string `json:"defaultAgentType,omitempty"` + // AgentStart maps agent family (AgentType string) to default startup command. + // Example: {"claude":"claude", "opencode":"opencode --profile fast"}. + // Per-workspace .sidecar-agent-start still takes precedence when present. + AgentStart map[string]string `json:"agentStart,omitempty"` // TmuxCaptureMaxBytes caps tmux pane capture size for the preview pane. Default: 2MB. TmuxCaptureMaxBytes int `json:"tmuxCaptureMaxBytes"` // InteractiveExitKey is the keybinding to exit interactive mode. Default: "ctrl+\". @@ -138,7 +145,7 @@ func Default() *Config { Overrides: make(map[string]string), }, UI: UIConfig{ - ShowClock: true, + ShowClock: true, Theme: ThemeConfig{ Name: "default", Overrides: make(map[string]interface{}), diff --git a/internal/config/loader.go b/internal/config/loader.go index 86e95875..8aa33b63 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "encoding/json" "log/slog" "os" @@ -59,16 +60,19 @@ type rawPluginsConfig struct { GitStatus rawGitStatusConfig `json:"git-status"` TDMonitor rawTDMonitorConfig `json:"td-monitor"` Conversations rawConversationsConfig `json:"conversations"` - Workspace rawWorkspaceConfig `json:"workspace"` + Workspace rawWorkspaceConfig `json:"workspace"` } type rawWorkspaceConfig struct { - DirPrefix *bool `json:"dirPrefix"` - TmuxCaptureMaxBytes *int `json:"tmuxCaptureMaxBytes"` - InteractiveExitKey string `json:"interactiveExitKey"` - InteractiveAttachKey string `json:"interactiveAttachKey"` - InteractiveCopyKey string `json:"interactiveCopyKey"` - InteractivePasteKey string `json:"interactivePasteKey"` + DirPrefix *bool `json:"dirPrefix"` + DefaultAgentType string `json:"defaultAgentType"` + LegacyDefaultAgent string `json:"defaultAgent"` // Backward compatibility + AgentStart json.RawMessage `json:"agentStart"` + TmuxCaptureMaxBytes *int `json:"tmuxCaptureMaxBytes"` + InteractiveExitKey string `json:"interactiveExitKey"` + InteractiveAttachKey string `json:"interactiveAttachKey"` + InteractiveCopyKey string `json:"interactiveCopyKey"` + InteractivePasteKey string `json:"interactivePasteKey"` } type rawGitStatusConfig struct { @@ -87,6 +91,11 @@ type rawConversationsConfig struct { ClaudeDataDir string `json:"claudeDataDir"` } +const ( + envWorkspaceDefaultAgentType = "SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE" + envDefaultAgentType = "SIDECAR_DEFAULT_AGENT_TYPE" +) + // Load loads configuration from the default location. func Load() (*Config, error) { return LoadFrom("") @@ -100,6 +109,7 @@ func LoadFrom(path string) (*Config, error) { if path == "" { home, err := os.UserHomeDir() if err != nil { + applyEnvOverrides(cfg) return cfg, nil // Return defaults on error } path = filepath.Join(home, configDir, configFile) @@ -108,6 +118,10 @@ func LoadFrom(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { + applyEnvOverrides(cfg) + if err := cfg.Validate(); err != nil { + return nil, err + } return cfg, nil // Return defaults if no config file } return nil, err @@ -120,6 +134,7 @@ func LoadFrom(path string) (*Config, error) { // Merge raw config into defaults mergeConfig(cfg, &raw) + applyEnvOverrides(cfg) // Expand paths cfg.Plugins.Conversations.ClaudeDataDir = ExpandPath(cfg.Plugins.Conversations.ClaudeDataDir) @@ -194,6 +209,15 @@ func mergeConfig(cfg *Config, raw *rawConfig) { if raw.Plugins.Workspace.TmuxCaptureMaxBytes != nil { cfg.Plugins.Workspace.TmuxCaptureMaxBytes = *raw.Plugins.Workspace.TmuxCaptureMaxBytes } + if raw.Plugins.Workspace.DefaultAgentType != "" { + cfg.Plugins.Workspace.DefaultAgentType = raw.Plugins.Workspace.DefaultAgentType + } + if cfg.Plugins.Workspace.DefaultAgentType == "" && raw.Plugins.Workspace.LegacyDefaultAgent != "" { + cfg.Plugins.Workspace.DefaultAgentType = raw.Plugins.Workspace.LegacyDefaultAgent + } + if agentStart, ok := parseAgentStartOverrides(raw.Plugins.Workspace.AgentStart); ok { + cfg.Plugins.Workspace.AgentStart = agentStart + } if raw.Plugins.Workspace.InteractiveExitKey != "" { cfg.Plugins.Workspace.InteractiveExitKey = raw.Plugins.Workspace.InteractiveExitKey } @@ -251,6 +275,53 @@ func mergeConfig(cfg *Config, raw *rawConfig) { } } +func applyEnvOverrides(cfg *Config) { + if cfg == nil { + return + } + + if v, ok := os.LookupEnv(envWorkspaceDefaultAgentType); ok { + cfg.Plugins.Workspace.DefaultAgentType = strings.TrimSpace(v) + return + } + if v, ok := os.LookupEnv(envDefaultAgentType); ok { + cfg.Plugins.Workspace.DefaultAgentType = strings.TrimSpace(v) + } +} + +func parseAgentStartOverrides(raw json.RawMessage) (map[string]string, bool) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return nil, false + } + + var byType map[string]string + if err := json.Unmarshal(raw, &byType); err == nil { + out := make(map[string]string, len(byType)) + for k, v := range byType { + key := strings.TrimSpace(k) + val := strings.TrimSpace(v) + if key == "" || val == "" { + continue + } + out[key] = val + } + return out, true + } + + // Backward compatibility: previous schema accepted a single string. + var single string + if err := json.Unmarshal(raw, &single); err == nil { + single = strings.TrimSpace(single) + if single == "" { + return map[string]string{}, true + } + return map[string]string{"*": single}, true + } + + return nil, false +} + // ExpandPath expands ~ to home directory. func ExpandPath(path string) string { if strings.HasPrefix(path, "~/") { diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index f7dc4352..16e162de 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -184,3 +184,133 @@ func TestLoadFrom_EmptyProjectsList(t *testing.T) { t.Errorf("got %d projects, want 0", len(cfg.Projects.List)) } } + +func TestLoadFrom_WorkspaceAgentSettings(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + content := []byte(`{ + "plugins": { + "workspace": { + "defaultAgentType": "opencode", + "agentStart": { + "opencode": "opencode --profile fast" + } + } + } + }`) + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadFrom(path) + if err != nil { + t.Fatalf("LoadFrom failed: %v", err) + } + + if cfg.Plugins.Workspace.DefaultAgentType != "opencode" { + t.Errorf("DefaultAgentType = %q, want %q", cfg.Plugins.Workspace.DefaultAgentType, "opencode") + } + if got := cfg.Plugins.Workspace.AgentStart["opencode"]; got != "opencode --profile fast" { + t.Errorf("AgentStart[opencode] = %q, want %q", got, "opencode --profile fast") + } +} + +func TestLoadFrom_WorkspaceDefaultAgentTypeEnvOverride(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + content := []byte(`{ + "plugins": { + "workspace": { + "defaultAgentType": "opencode" + } + } + }`) + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatal(err) + } + + t.Setenv("SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE", "codex") + + cfg, err := LoadFrom(path) + if err != nil { + t.Fatalf("LoadFrom failed: %v", err) + } + if cfg.Plugins.Workspace.DefaultAgentType != "codex" { + t.Errorf("DefaultAgentType = %q, want %q", cfg.Plugins.Workspace.DefaultAgentType, "codex") + } +} + +func TestLoadFrom_WorkspaceDefaultAgentTypeEnvOverride_NoConfigFile(t *testing.T) { + t.Setenv("SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE", "gemini") + + cfg, err := LoadFrom("/definitely/missing/config.json") + if err != nil { + t.Fatalf("LoadFrom failed: %v", err) + } + if cfg.Plugins.Workspace.DefaultAgentType != "gemini" { + t.Errorf("DefaultAgentType = %q, want %q", cfg.Plugins.Workspace.DefaultAgentType, "gemini") + } +} + +func TestLoadFrom_WorkspaceDefaultAgentTypeEnvOverride_LegacyAlias(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + content := []byte(`{"plugins":{"workspace":{"defaultAgentType":"opencode"}}}`) + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatal(err) + } + + t.Setenv("SIDECAR_DEFAULT_AGENT_TYPE", "cursor") + + cfg, err := LoadFrom(path) + if err != nil { + t.Fatalf("LoadFrom failed: %v", err) + } + if cfg.Plugins.Workspace.DefaultAgentType != "cursor" { + t.Errorf("DefaultAgentType = %q, want %q", cfg.Plugins.Workspace.DefaultAgentType, "cursor") + } +} + +func TestLoadFrom_WorkspaceDefaultAgentTypeEnvOverride_PrefersPrimaryVar(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + content := []byte(`{"plugins":{"workspace":{"defaultAgentType":"opencode"}}}`) + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatal(err) + } + + t.Setenv("SIDECAR_DEFAULT_AGENT_TYPE", "cursor") + t.Setenv("SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE", "codex") + + cfg, err := LoadFrom(path) + if err != nil { + t.Fatalf("LoadFrom failed: %v", err) + } + if cfg.Plugins.Workspace.DefaultAgentType != "codex" { + t.Errorf("DefaultAgentType = %q, want %q", cfg.Plugins.Workspace.DefaultAgentType, "codex") + } +} + +func TestLoadFrom_WorkspaceAgentStartLegacyStringBackwardCompat(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + content := []byte(`{ + "plugins": { + "workspace": { + "agentStart": "custom-agent --legacy" + } + } + }`) + if err := os.WriteFile(path, content, 0644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadFrom(path) + if err != nil { + t.Fatalf("LoadFrom failed: %v", err) + } + if got := cfg.Plugins.Workspace.AgentStart["*"]; got != "custom-agent --legacy" { + t.Errorf("AgentStart[*] = %q, want %q", got, "custom-agent --legacy") + } +} diff --git a/internal/config/saver.go b/internal/config/saver.go index b81384c7..a5177db0 100644 --- a/internal/config/saver.go +++ b/internal/config/saver.go @@ -27,7 +27,7 @@ type savePluginsConfig struct { GitStatus saveGitStatusConfig `json:"git-status,omitempty"` TDMonitor saveTDMonitorConfig `json:"td-monitor,omitempty"` Conversations saveConversationsConfig `json:"conversations,omitempty"` - Workspace saveWorkspaceConfig `json:"workspace,omitempty"` + Workspace saveWorkspaceConfig `json:"workspace,omitempty"` } type saveGitStatusConfig struct { @@ -47,12 +47,14 @@ type saveConversationsConfig struct { } type saveWorkspaceConfig struct { - DirPrefix *bool `json:"dirPrefix,omitempty"` - TmuxCaptureMaxBytes *int `json:"tmuxCaptureMaxBytes,omitempty"` - InteractiveExitKey string `json:"interactiveExitKey,omitempty"` - InteractiveAttachKey string `json:"interactiveAttachKey,omitempty"` - InteractiveCopyKey string `json:"interactiveCopyKey,omitempty"` - InteractivePasteKey string `json:"interactivePasteKey,omitempty"` + DirPrefix *bool `json:"dirPrefix,omitempty"` + DefaultAgentType string `json:"defaultAgentType,omitempty"` + AgentStart map[string]string `json:"agentStart,omitempty"` + TmuxCaptureMaxBytes *int `json:"tmuxCaptureMaxBytes,omitempty"` + InteractiveExitKey string `json:"interactiveExitKey,omitempty"` + InteractiveAttachKey string `json:"interactiveAttachKey,omitempty"` + InteractiveCopyKey string `json:"interactiveCopyKey,omitempty"` + InteractivePasteKey string `json:"interactivePasteKey,omitempty"` } // toSaveConfig converts Config to the JSON-serializable format. @@ -79,6 +81,8 @@ func toSaveConfig(cfg *Config) saveConfig { }, Workspace: saveWorkspaceConfig{ DirPrefix: &cfg.Plugins.Workspace.DirPrefix, + DefaultAgentType: cfg.Plugins.Workspace.DefaultAgentType, + AgentStart: cfg.Plugins.Workspace.AgentStart, TmuxCaptureMaxBytes: &cfg.Plugins.Workspace.TmuxCaptureMaxBytes, InteractiveExitKey: cfg.Plugins.Workspace.InteractiveExitKey, InteractiveAttachKey: cfg.Plugins.Workspace.InteractiveAttachKey, diff --git a/internal/config/saver_test.go b/internal/config/saver_test.go index 26fd3406..46c9e1da 100644 --- a/internal/config/saver_test.go +++ b/internal/config/saver_test.go @@ -98,3 +98,52 @@ func TestSave_WorksWithNoExistingFile(t *testing.T) { t.Error("missing 'projects' key") } } + +func TestSave_WritesWorkspaceAgentSettings(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + SetTestConfigPath(path) + defer ResetTestConfigPath() + + cfg := Default() + cfg.Plugins.Workspace.DefaultAgentType = "codex" + cfg.Plugins.Workspace.AgentStart = map[string]string{ + "codex": "codex --dangerously-bypass-approvals-and-sandbox", + } + + if err := Save(cfg); err != nil { + t.Fatalf("Save failed: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + var plugins map[string]json.RawMessage + if err := json.Unmarshal(raw["plugins"], &plugins); err != nil { + t.Fatalf("unmarshal plugins: %v", err) + } + + var workspace map[string]interface{} + if err := json.Unmarshal(plugins["workspace"], &workspace); err != nil { + t.Fatalf("unmarshal workspace: %v", err) + } + + if got := workspace["defaultAgentType"]; got != "codex" { + t.Errorf("defaultAgentType = %v, want %q", got, "codex") + } + agentStart, ok := workspace["agentStart"].(map[string]interface{}) + if !ok { + t.Fatalf("agentStart type = %T, want object", workspace["agentStart"]) + } + if got := agentStart["codex"]; got != "codex --dangerously-bypass-approvals-and-sandbox" { + t.Errorf("agentStart.codex = %v, want %q", got, "codex --dangerously-bypass-approvals-and-sandbox") + } +} diff --git a/internal/plugins/workspace/agent.go b/internal/plugins/workspace/agent.go index b393ddcc..4038fb13 100644 --- a/internal/plugins/workspace/agent.go +++ b/internal/plugins/workspace/agent.go @@ -1,6 +1,7 @@ package workspace import ( + "bytes" "context" "encoding/json" "fmt" @@ -8,16 +9,20 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strconv" "strings" "sync" "time" + "unicode" "unicode/utf8" tea "github.com/charmbracelet/bubbletea" "github.com/marcus/sidecar/internal/features" ) +var openCodeRunPrefixRe = regexp.MustCompile(`^(\S+)\s+run(\s+.*)?$`) + // paneCacheEntry holds cached capture output with timestamp type paneCacheEntry struct { output string @@ -235,9 +240,9 @@ const ( // Runaway detection thresholds (td-018f25) // Detect sessions producing continuous output and throttle them to reduce CPU usage. - runawayPollCount = 20 // Number of polls to track - runawayTimeWindow = 3 * time.Second // If 20 polls happen within this window = runaway - runawayResetCount = 3 // Consecutive unchanged polls to reset throttle + runawayPollCount = 20 // Number of polls to track + runawayTimeWindow = 3 * time.Second // If 20 polls happen within this window = runaway + runawayResetCount = 3 // Consecutive unchanged polls to reset throttle ) // AgentStartedMsg signals an agent has been started in a worktree. @@ -257,27 +262,27 @@ func (m AgentStartedMsg) GetEpoch() uint64 { return m.Epoch } // ApproveResultMsg signals the result of an approve action. type ApproveResultMsg struct { WorkspaceName string - Err error + Err error } // RejectResultMsg signals the result of a reject action. type RejectResultMsg struct { WorkspaceName string - Err error + Err error } // SendTextResultMsg signals the result of sending text to an agent. type SendTextResultMsg struct { WorkspaceName string - Text string - Err error + Text string + Err error } // pollAgentMsg triggers output polling for a worktree's agent. // Includes generation for timer leak prevention (td-83dc22). type pollAgentMsg struct { WorkspaceName string - Generation int // Generation at time of scheduling; ignore if stale + Generation int // Generation at time of scheduling; ignore if stale } // reconnectedAgentsMsg delivers reconnected agents from startup. @@ -422,10 +427,112 @@ func getAgentCommand(agentType AgentType) string { return "claude" // Default to claude } +// readAgentStartOverride reads a .sidecar-agent-start command override from a worktree path. +func readAgentStartOverride(worktreePath string) string { + if worktreePath == "" { + return "" + } + overridePath := filepath.Join(worktreePath, sidecarAgentStartFile) + raw, err := os.ReadFile(overridePath) + if err != nil { + return "" + } + + // Normalize editor/file encoding quirks so an invalid override never breaks startup. + raw = bytes.TrimSpace(raw) + raw = bytes.TrimPrefix(raw, []byte{0xEF, 0xBB, 0xBF}) // UTF-8 BOM + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.IndexByte(raw, 0) >= 0 || !utf8.Valid(raw) { + return "" + } + return sanitizeAgentStartCommand(string(raw)) +} + +func sanitizeAgentStartCommand(raw string) string { + cmd := strings.TrimSpace(raw) + if cmd == "" || strings.ContainsAny(cmd, "\r\n") { + return "" + } + + var cleaned strings.Builder + cleaned.Grow(len(cmd)) + for _, r := range cmd { + if r == '\uFFFD' || r == '\uFEFF' { + continue + } + if unicode.Is(unicode.Cf, r) || unicode.IsControl(r) { + continue + } + cleaned.WriteRune(r) + } + + result := strings.TrimSpace(cleaned.String()) + if result == "" || strings.ContainsAny(result, "\r\n") { + return "" + } + return result +} + +func resolveConfigAgentStart(agentStart map[string]string, agentType AgentType) string { + if len(agentStart) == 0 { + return "" + } + + lookupOrder := []string{string(agentType), "*", "default"} + for _, key := range lookupOrder { + cmd := sanitizeAgentStartCommand(agentStart[key]) + if cmd != "" { + return cmd + } + } + return "" +} + +// normalizeOpenCodeBaseCommand ensures overrides represent the opencode command portion, +// not the "opencode run" invocation. The launcher appends "run" itself. +func normalizeOpenCodeBaseCommand(cmd string) string { + if cmd == "" { + return "" + } + m := openCodeRunPrefixRe.FindStringSubmatch(cmd) + if len(m) == 0 { + return cmd + } + suffix := "" + if len(m) > 2 { + suffix = m[2] + } + return strings.TrimSpace(m[1] + suffix) +} + +// resolveAgentBaseCommand returns the command used to launch the selected agent family. +// Precedence: worktree .sidecar-agent-start > config.plugins.workspace.agentStart > AgentCommands map. +func (p *Plugin) resolveAgentBaseCommand(worktreePath string, agentType AgentType) string { + if overrideCmd := readAgentStartOverride(worktreePath); overrideCmd != "" { + if agentType == AgentOpenCode { + overrideCmd = normalizeOpenCodeBaseCommand(overrideCmd) + } + return overrideCmd + } + if p != nil && p.ctx != nil && p.ctx.Config != nil { + if configCmd := resolveConfigAgentStart(p.ctx.Config.Plugins.Workspace.AgentStart, agentType); configCmd != "" { + if agentType == AgentOpenCode { + configCmd = normalizeOpenCodeBaseCommand(configCmd) + } + return configCmd + } + } + return getAgentCommand(agentType) +} + // buildAgentCommand builds the agent command with optional skip permissions and task context. // If there's task context, it writes a launcher script to avoid shell escaping issues. func (p *Plugin) buildAgentCommand(agentType AgentType, wt *Worktree, skipPerms bool, prompt *Prompt) string { - baseCmd := getAgentCommand(agentType) + worktreePath := "" + if wt != nil { + worktreePath = wt.Path + } + baseCmd := p.resolveAgentBaseCommand(worktreePath, agentType) // Apply skip permissions flag if requested if skipPerms { @@ -743,7 +850,7 @@ func (p *Plugin) scheduleInteractivePoll(worktreeName string, delay time.Duratio // AgentPollUnchangedMsg signals content unchanged, schedule next poll. type AgentPollUnchangedMsg struct { - WorkspaceName string + WorkspaceName string CurrentStatus WorktreeStatus // Status including session file re-check WaitingFor string // Prompt text if waiting // Cursor position captured atomically (even when content unchanged) @@ -887,7 +994,7 @@ func (p *Plugin) handlePollAgent(worktreeName string) tea.Cmd { if !outputChanged { return AgentPollUnchangedMsg{ - WorkspaceName: worktreeName, + WorkspaceName: worktreeName, CurrentStatus: status, WaitingFor: waitingFor, CursorRow: cursorRow, @@ -900,7 +1007,7 @@ func (p *Plugin) handlePollAgent(worktreeName string) tea.Cmd { } return AgentOutputMsg{ - WorkspaceName: worktreeName, + WorkspaceName: worktreeName, Output: output, Status: status, WaitingFor: waitingFor, @@ -1250,7 +1357,7 @@ func (p *Plugin) Approve(wt *Worktree) tea.Cmd { return ApproveResultMsg{ WorkspaceName: wt.Name, - Err: err, + Err: err, } } } @@ -1267,7 +1374,7 @@ func (p *Plugin) Reject(wt *Worktree) tea.Cmd { return RejectResultMsg{ WorkspaceName: wt.Name, - Err: err, + Err: err, } } } @@ -1307,8 +1414,8 @@ func (p *Plugin) SendText(wt *Worktree, text string) tea.Cmd { return SendTextResultMsg{ WorkspaceName: wt.Name, - Text: text, - Err: err, + Text: text, + Err: err, } } } diff --git a/internal/plugins/workspace/agent_test.go b/internal/plugins/workspace/agent_test.go index f2ebebf0..12b6d407 100644 --- a/internal/plugins/workspace/agent_test.go +++ b/internal/plugins/workspace/agent_test.go @@ -2,9 +2,13 @@ package workspace import ( "os" + "path/filepath" "strings" "testing" "time" + + "github.com/marcus/sidecar/internal/config" + "github.com/marcus/sidecar/internal/plugin" ) func TestSanitizeName(t *testing.T) { @@ -151,6 +155,218 @@ func TestGetAgentCommand(t *testing.T) { } } +func TestResolveAgentBaseCommand(t *testing.T) { + tmpDir := t.TempDir() + + tests := []struct { + name string + agentType AgentType + hasFile bool + content string + expected string + }{ + { + name: "missing override file falls back to defaults", + agentType: AgentOpenCode, + hasFile: false, + content: "", + expected: "opencode", + }, + { + name: "non-empty override file is used", + agentType: AgentOpenCode, + hasFile: true, + content: "custom-opencode --mode fast\n", + expected: "custom-opencode --mode fast", + }, + { + name: "empty override file falls back to defaults", + agentType: AgentCodex, + hasFile: true, + content: "\n \n", + expected: "codex", + }, + { + name: "unknown type falls back to claude", + agentType: AgentCustom, + hasFile: false, + content: "", + expected: "claude", + }, + } + + overridePath := tmpDir + "/" + sidecarAgentStartFile + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _ = os.Remove(overridePath) + if tt.hasFile { + if err := os.WriteFile(overridePath, []byte(tt.content), 0644); err != nil { + t.Fatalf("failed to write override file: %v", err) + } + } + + p := &Plugin{} + got := p.resolveAgentBaseCommand(tmpDir, tt.agentType) + if got != tt.expected { + t.Errorf("resolveAgentBaseCommand(%q) = %q, want %q", tt.agentType, got, tt.expected) + } + }) + } +} + +func TestResolveAgentBaseCommand_ConfigFallback(t *testing.T) { + tmpDir := t.TempDir() + overridePath := tmpDir + "/" + sidecarAgentStartFile + + cfg := config.Default() + cfg.Plugins.Workspace.AgentStart = map[string]string{ + string(AgentCodex): "configured-agent --from config", + } + p := &Plugin{ + ctx: &plugin.Context{ + WorkDir: tmpDir, + Config: cfg, + }, + } + + got := p.resolveAgentBaseCommand(tmpDir, AgentCodex) + if got != "configured-agent --from config" { + t.Fatalf("resolveAgentBaseCommand config fallback = %q, want %q", got, "configured-agent --from config") + } + + // Worktree file should override config agentStart when present. + if err := os.WriteFile(overridePath, []byte("file-agent --preferred"), 0644); err != nil { + t.Fatalf("failed to write override file: %v", err) + } + got = p.resolveAgentBaseCommand(tmpDir, AgentCodex) + if got != "file-agent --preferred" { + t.Errorf("resolveAgentBaseCommand file override = %q, want %q", got, "file-agent --preferred") + } +} + +func TestResolveAgentBaseCommand_InvalidOverrideEncodingFallsBack(t *testing.T) { + tmpDir := t.TempDir() + overridePath := filepath.Join(tmpDir, sidecarAgentStartFile) + + // UTF-16LE-like bytes with NULs should be rejected to avoid invalid character errors. + if err := os.WriteFile(overridePath, []byte{0xff, 0xfe, 'o', 0x00, 'p', 0x00}, 0644); err != nil { + t.Fatalf("failed to write override file: %v", err) + } + + p := &Plugin{} + got := p.resolveAgentBaseCommand(tmpDir, AgentOpenCode) + if got != "opencode" { + t.Errorf("resolveAgentBaseCommand invalid bytes = %q, want %q", got, "opencode") + } +} + +func TestResolveAgentBaseCommand_UTF8BOMStripped(t *testing.T) { + tmpDir := t.TempDir() + overridePath := filepath.Join(tmpDir, sidecarAgentStartFile) + content := append([]byte{0xEF, 0xBB, 0xBF}, []byte("custom-agent --ok\n")...) + + if err := os.WriteFile(overridePath, content, 0644); err != nil { + t.Fatalf("failed to write override file: %v", err) + } + + p := &Plugin{} + got := p.resolveAgentBaseCommand(tmpDir, AgentOpenCode) + if got != "custom-agent --ok" { + t.Errorf("resolveAgentBaseCommand BOM content = %q, want %q", got, "custom-agent --ok") + } +} + +func TestResolveAgentBaseCommand_ReplacementCharFallsBack(t *testing.T) { + tmpDir := t.TempDir() + overridePath := filepath.Join(tmpDir, sidecarAgentStartFile) + if err := os.WriteFile(overridePath, []byte("opencode\ufffd"), 0644); err != nil { + t.Fatalf("failed to write override file: %v", err) + } + + p := &Plugin{} + got := p.resolveAgentBaseCommand(tmpDir, AgentOpenCode) + if got != "opencode" { + t.Errorf("resolveAgentBaseCommand replacement char = %q, want %q", got, "opencode") + } +} + +func TestResolveAgentBaseCommand_MultilineOverrideIgnored(t *testing.T) { + tmpDir := t.TempDir() + overridePath := filepath.Join(tmpDir, sidecarAgentStartFile) + if err := os.WriteFile(overridePath, []byte("custom-opencode\n--bad"), 0644); err != nil { + t.Fatalf("failed to write override file: %v", err) + } + + p := &Plugin{} + got := p.resolveAgentBaseCommand(tmpDir, AgentOpenCode) + if got != "opencode" { + t.Errorf("resolveAgentBaseCommand multiline override = %q, want %q", got, "opencode") + } +} + +func TestResolveAgentBaseCommand_StripsHiddenChars(t *testing.T) { + tmpDir := t.TempDir() + overridePath := filepath.Join(tmpDir, sidecarAgentStartFile) + if err := os.WriteFile(overridePath, []byte("custom\u200b-agent --ok"), 0644); err != nil { + t.Fatalf("failed to write override file: %v", err) + } + + p := &Plugin{} + got := p.resolveAgentBaseCommand(tmpDir, AgentOpenCode) + if got != "custom-agent --ok" { + t.Errorf("resolveAgentBaseCommand hidden chars = %q, want %q", got, "custom-agent --ok") + } +} + +func TestResolveAgentBaseCommand_OpenCodeRunSubcommandStripped(t *testing.T) { + tmpDir := t.TempDir() + overridePath := filepath.Join(tmpDir, sidecarAgentStartFile) + if err := os.WriteFile(overridePath, []byte("opencode run --profile fast"), 0644); err != nil { + t.Fatalf("failed to write override file: %v", err) + } + + p := &Plugin{} + got := p.resolveAgentBaseCommand(tmpDir, AgentOpenCode) + if got != "opencode --profile fast" { + t.Errorf("resolveAgentBaseCommand opencode run strip = %q, want %q", got, "opencode --profile fast") + } +} + +func TestResolveAgentBaseCommand_ConfigOpenCodeRunSubcommandStripped(t *testing.T) { + tmpDir := t.TempDir() + cfg := config.Default() + cfg.Plugins.Workspace.AgentStart = map[string]string{ + string(AgentOpenCode): "opencode run --profile fast", + } + p := &Plugin{ + ctx: &plugin.Context{ + WorkDir: tmpDir, + Config: cfg, + }, + } + + got := p.resolveAgentBaseCommand(tmpDir, AgentOpenCode) + if got != "opencode --profile fast" { + t.Errorf("resolveAgentBaseCommand config opencode run strip = %q, want %q", got, "opencode --profile fast") + } +} + +func TestBuildAgentCommandUsesSidecarAgentStart(t *testing.T) { + tmpDir := t.TempDir() + overridePath := tmpDir + "/" + sidecarAgentStartFile + if err := os.WriteFile(overridePath, []byte("custom-codex"), 0644); err != nil { + t.Fatalf("failed to write override file: %v", err) + } + + p := &Plugin{} + wt := &Worktree{Path: tmpDir} + got := p.buildAgentCommand(AgentCodex, wt, true, nil) + want := "custom-codex --dangerously-bypass-approvals-and-sandbox" + if got != want { + t.Errorf("buildAgentCommand with override = %q, want %q", got, want) + } +} + func TestDetectStatus(t *testing.T) { tests := []struct { name string @@ -308,7 +524,6 @@ func TestExtractPrompt(t *testing.T) { } } - func TestDetectStatusPriorityOrder(t *testing.T) { // Waiting should take priority over error when both patterns present output := "Error occurred\nRetry? [y/n]" @@ -468,12 +683,12 @@ func TestShouldShowSkipPermissions(t *testing.T) { func TestBuildAgentCommand(t *testing.T) { tests := []struct { - name string - agentType AgentType - skipPerms bool - taskID string - wantFlag string // Expected skip-perms flag in output - wantPrompt bool // Whether prompt should be included + name string + agentType AgentType + skipPerms bool + taskID string + wantFlag string // Expected skip-perms flag in output + wantPrompt bool // Whether prompt should be included }{ // Claude tests { diff --git a/internal/plugins/workspace/plugin.go b/internal/plugins/workspace/plugin.go index 2c1a563c..931b9f86 100644 --- a/internal/plugins/workspace/plugin.go +++ b/internal/plugins/workspace/plugin.go @@ -13,9 +13,9 @@ import ( "github.com/marcus/sidecar/internal/modal" "github.com/marcus/sidecar/internal/mouse" "github.com/marcus/sidecar/internal/plugin" - "github.com/marcus/sidecar/internal/ui" "github.com/marcus/sidecar/internal/plugins/gitstatus" "github.com/marcus/sidecar/internal/state" + "github.com/marcus/sidecar/internal/ui" ) const ( @@ -34,11 +34,11 @@ const ( flashDuration = 1500 * time.Millisecond // Hit region IDs - regionSidebar = "sidebar" - regionPreviewPane = "preview-pane" - regionPaneDivider = "pane-divider" - regionWorktreeItem = "workspace-item" - regionPreviewTab = "preview-tab" + regionSidebar = "sidebar" + regionPreviewPane = "preview-pane" + regionPaneDivider = "pane-divider" + regionWorktreeItem = "workspace-item" + regionPreviewTab = "preview-tab" // Agent choice modal IDs (modal library) agentChoiceListID = "agent-choice-list" agentChoiceConfirmID = "agent-choice-confirm" @@ -90,9 +90,9 @@ const ( typeSelectorInputID = "type-selector-name-input" typeSelectorConfirmID = "type-selector-confirm" typeSelectorCancelID = "type-selector-cancel" - typeSelectorAgentListID = "type-selector-agent-list" // td-a902fe - typeSelectorSkipPermsID = "type-selector-skip-perms" // td-a902fe - typeSelectorAgentItemPfx = "ts-agent-" // td-a902fe: prefix for agent items + typeSelectorAgentListID = "type-selector-agent-list" // td-a902fe + typeSelectorSkipPermsID = "type-selector-skip-perms" // td-a902fe + typeSelectorAgentItemPfx = "ts-agent-" // td-a902fe: prefix for agent items // Shell delete confirmation modal regions ) @@ -113,20 +113,20 @@ type Plugin struct { managedSessions map[string]bool // View state - viewMode ViewMode - activePane FocusPane - previewTab PreviewTab - selectedIdx int - scrollOffset int // Sidebar list scroll offset - visibleCount int // Number of visible list items + viewMode ViewMode + activePane FocusPane + previewTab PreviewTab + selectedIdx int + scrollOffset int // Sidebar list scroll offset + visibleCount int // Number of visible list items previewOffset int - autoScrollOutput bool // Auto-scroll output to follow agent (paused when user scrolls up) - scrollBaseLineCount int // Snapshot of lineCount when scroll started (td-f7c8be: prevents bounce on poll) - sidebarWidth int // Persisted sidebar width - sidebarVisible bool // Whether sidebar is visible (toggled with \) - flashPreviewTime time.Time // When preview flash was triggered - toastMessage string // Temporary toast message to display - toastTime time.Time // When toast was triggered + autoScrollOutput bool // Auto-scroll output to follow agent (paused when user scrolls up) + scrollBaseLineCount int // Snapshot of lineCount when scroll started (td-f7c8be: prevents bounce on poll) + sidebarWidth int // Persisted sidebar width + sidebarVisible bool // Whether sidebar is visible (toggled with \) + flashPreviewTime time.Time // When preview flash was triggered + toastMessage string // Temporary toast message to display + toastTime time.Time // When toast was triggered // Interactive selection state (preview pane) selection ui.SelectionState @@ -228,21 +228,21 @@ type Plugin struct { // Merge workflow state mergeState *MergeWorkflowState - mergeModal *modal.Modal // Modal instance for merge workflow - mergeModalWidth int // Cached width for rebuild detection + mergeModal *modal.Modal // Modal instance for merge workflow + mergeModalWidth int // Cached width for rebuild detection mergeModalStep MergeWorkflowStep // Cached step for rebuild detection // Commit-before-merge state - mergeCommitState *MergeCommitState - mergeCommitMessageInput textinput.Model - commitForMergeModal *modal.Modal // Modal instance - commitForMergeModalWidth int // Cached width for rebuild detection + mergeCommitState *MergeCommitState + mergeCommitMessageInput textinput.Model + commitForMergeModal *modal.Modal // Modal instance + commitForMergeModalWidth int // Cached width for rebuild detection // Agent choice modal state (attach vs restart) - agentChoiceWorktree *Worktree - agentChoiceIdx int // 0=attach, 1=restart - agentChoiceModal *modal.Modal // Modal instance - agentChoiceModalWidth int // Cached width for rebuild detection + agentChoiceWorktree *Worktree + agentChoiceIdx int // 0=attach, 1=restart + agentChoiceModal *modal.Modal // Modal instance + agentChoiceModalWidth int // Cached width for rebuild detection // Delete confirmation modal state deleteConfirmWorktree *Worktree // Worktree pending deletion @@ -290,10 +290,10 @@ type Plugin struct { shellSelected bool // True when any shell is selected (vs a worktree) // Type selector modal state (shell vs worktree) - typeSelectorIdx int // 0=Shell, 1=Worktree - typeSelectorNameInput textinput.Model // Optional shell name input - typeSelectorModal *modal.Modal // Modal instance - typeSelectorModalWidth int // Cached width for rebuild detection + typeSelectorIdx int // 0=Shell, 1=Worktree + typeSelectorNameInput textinput.Model // Optional shell name input + typeSelectorModal *modal.Modal // Modal instance + typeSelectorModalWidth int // Cached width for rebuild detection // Type selector modal - shell agent selection (td-2bb232) typeSelectorAgentIdx int // Selected index in agent list (0 = None) @@ -301,7 +301,7 @@ type Plugin struct { typeSelectorSkipPerms bool // Whether skip permissions is checked typeSelectorFocusField int // Focus: 0=name, 1=agent, 2=skipPerms, 3=buttons -// Resume conversation state (td-aa4136) + // Resume conversation state (td-aa4136) pendingResumeCmd string // Resume command to inject after shell creation pendingResumeWorktree string // Worktree name to enter interactive mode after agent starts @@ -707,13 +707,47 @@ func (p *Plugin) removeWorktreeByName(name string) { } } +func isCreateDefaultAgent(agentType AgentType) bool { + if agentType == "" { + return false + } + for _, at := range AgentTypeOrder { + if at == agentType { + return true + } + } + return false +} + +// getDefaultCreateAgentType returns the default agent for create-worktree modal. +// .sidecar-agent in the current workspace is treated equivalently to config defaultAgentType. +func (p *Plugin) getDefaultCreateAgentType() AgentType { + if p != nil && p.ctx != nil { + agentPath := filepath.Join(p.ctx.WorkDir, sidecarAgentFile) + if content, err := os.ReadFile(agentPath); err == nil { + fileAgent := AgentType(strings.TrimSpace(string(content))) + if isCreateDefaultAgent(fileAgent) { + return fileAgent + } + } + + if p.ctx.Config != nil { + configAgent := AgentType(strings.TrimSpace(p.ctx.Config.Plugins.Workspace.DefaultAgentType)) + if isCreateDefaultAgent(configAgent) { + return configAgent + } + } + } + return AgentClaude +} + // clearCreateModal resets create modal state. func (p *Plugin) clearCreateModal() { p.createNameInput = textinput.Model{} p.createBaseBranchInput = textinput.Model{} p.createTaskID = "" p.createTaskTitle = "" - p.createAgentType = AgentClaude // Default to Claude + p.createAgentType = p.getDefaultCreateAgentType() p.createAgentIdx = p.agentTypeIndex(p.createAgentType) p.createSkipPermissions = false p.createFocus = 0 @@ -765,7 +799,7 @@ func (p *Plugin) initCreateModalBase() { // Reset all state p.createTaskID = "" p.createTaskTitle = "" - p.createAgentType = AgentClaude + p.createAgentType = p.getDefaultCreateAgentType() p.createAgentIdx = p.agentTypeIndex(p.createAgentType) p.createSkipPermissions = false p.createFocus = 0 @@ -909,7 +943,7 @@ func (p *Plugin) moveCursor(delta int) { p.previewOffset = 0 p.autoScrollOutput = true p.resetScrollBaseLineCount() // td-f7c8be: clear snapshot for new selection - p.taskLoading = false // Reset task loading state for new selection (td-3668584f) + p.taskLoading = false // Reset task loading state for new selection (td-3668584f) // Exit interactive mode when switching selection (td-fc758e88) p.exitInteractiveMode() // Persist selection to disk @@ -946,7 +980,7 @@ func (p *Plugin) cyclePreviewTab(delta int) tea.Cmd { prevTab := p.previewTab p.previewTab = PreviewTab((int(p.previewTab) + delta + 3) % 3) p.previewOffset = 0 - p.autoScrollOutput = true // Reset auto-scroll when switching tabs + p.autoScrollOutput = true // Reset auto-scroll when switching tabs p.resetScrollBaseLineCount() // td-f7c8be: clear snapshot when switching tabs if prevTab == PreviewTabOutput && p.previewTab != PreviewTabOutput { diff --git a/internal/plugins/workspace/plugin_defaults_test.go b/internal/plugins/workspace/plugin_defaults_test.go new file mode 100644 index 00000000..7df9e876 --- /dev/null +++ b/internal/plugins/workspace/plugin_defaults_test.go @@ -0,0 +1,84 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" + + "github.com/marcus/sidecar/internal/config" + "github.com/marcus/sidecar/internal/plugin" +) + +func TestGetDefaultCreateAgentType_FromConfig(t *testing.T) { + cfg := config.Default() + cfg.Plugins.Workspace.DefaultAgentType = string(AgentOpenCode) + + p := &Plugin{ + ctx: &plugin.Context{ + WorkDir: t.TempDir(), + Config: cfg, + }, + } + + if got := p.getDefaultCreateAgentType(); got != AgentOpenCode { + t.Errorf("getDefaultCreateAgentType() = %q, want %q", got, AgentOpenCode) + } +} + +func TestGetDefaultCreateAgentType_SidecarAgentPrecedence(t *testing.T) { + workDir := t.TempDir() + cfg := config.Default() + cfg.Plugins.Workspace.DefaultAgentType = string(AgentGemini) + + if err := os.WriteFile(filepath.Join(workDir, sidecarAgentFile), []byte(string(AgentCodex)+"\n"), 0644); err != nil { + t.Fatalf("write .sidecar-agent: %v", err) + } + + p := &Plugin{ + ctx: &plugin.Context{ + WorkDir: workDir, + Config: cfg, + }, + } + + if got := p.getDefaultCreateAgentType(); got != AgentCodex { + t.Errorf("getDefaultCreateAgentType() = %q, want %q", got, AgentCodex) + } +} + +func TestGetDefaultCreateAgentType_InvalidFallback(t *testing.T) { + workDir := t.TempDir() + cfg := config.Default() + cfg.Plugins.Workspace.DefaultAgentType = "not-an-agent" + + p := &Plugin{ + ctx: &plugin.Context{ + WorkDir: workDir, + Config: cfg, + }, + } + + if got := p.getDefaultCreateAgentType(); got != AgentClaude { + t.Errorf("getDefaultCreateAgentType() = %q, want %q", got, AgentClaude) + } +} + +func TestInitCreateModalBase_UsesConfiguredDefaultAgent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + workDir := t.TempDir() + cfg := config.Default() + cfg.Plugins.Workspace.DefaultAgentType = string(AgentPi) + + p := New() + p.ctx = &plugin.Context{ + WorkDir: workDir, + Config: cfg, + } + + p.initCreateModalBase() + if p.createAgentType != AgentPi { + t.Errorf("createAgentType = %q, want %q", p.createAgentType, AgentPi) + } +} diff --git a/internal/plugins/workspace/setup.go b/internal/plugins/workspace/setup.go index 90e4b8cd..de7b2dd8 100644 --- a/internal/plugins/workspace/setup.go +++ b/internal/plugins/workspace/setup.go @@ -18,6 +18,7 @@ const ( var sidecarGitignoreEntries = []string{ ".sidecar/", ".sidecar-agent", + ".sidecar-agent-start", ".sidecar-task", ".sidecar-pr", ".sidecar-start.sh", diff --git a/internal/plugins/workspace/shell.go b/internal/plugins/workspace/shell.go index 3ea4bf4a..4c9621c6 100644 --- a/internal/plugins/workspace/shell.go +++ b/internal/plugins/workspace/shell.go @@ -462,6 +462,7 @@ func (p *Plugin) restoreShellDisplayNames() { _ = state.SetWorkspaceState(p.ctx.ProjectRoot, wtState) } } + // nextShellIndex returns the next available shell index based on existing sessions. func (p *Plugin) nextShellIndex() int { projectName := filepath.Base(p.ctx.WorkDir) @@ -659,12 +660,17 @@ func (p *Plugin) recreateOrphanedShell(idx int) tea.Cmd { // td-21a2d8: Called after shell is created when an agent was selected. func (p *Plugin) startAgentInShell(tmuxName string, agentType AgentType, skipPerms bool) tea.Cmd { return func() tea.Msg { - // Get the base command for this agent type - baseCmd := AgentCommands[agentType] - if baseCmd == "" { + workDir := "" + if p.ctx != nil { + workDir = p.ctx.WorkDir + } + + // Get the base command for this agent family, allowing worktree-level override. + baseCmd := p.resolveAgentBaseCommand(workDir, agentType) + if strings.TrimSpace(baseCmd) == "" { return ShellAgentErrorMsg{ TmuxName: tmuxName, - Err: fmt.Errorf("unknown agent type: %s", agentType), + Err: fmt.Errorf("empty agent command for type: %s", agentType), } } diff --git a/internal/plugins/workspace/worktree.go b/internal/plugins/workspace/worktree.go index db282656..eb708f00 100644 --- a/internal/plugins/workspace/worktree.go +++ b/internal/plugins/workspace/worktree.go @@ -552,6 +552,7 @@ func (p *Plugin) setupTDRoot(worktreePath string) error { const sidecarTaskFile = ".sidecar-task" const sidecarAgentFile = ".sidecar-agent" +const sidecarAgentStartFile = ".sidecar-agent-start" const sidecarPRFile = ".sidecar-pr" const sidecarBaseFile = ".sidecar-base" diff --git a/website/docs/workspaces-plugin.md b/website/docs/workspaces-plugin.md index 3f8d5d9f..36c6171e 100644 --- a/website/docs/workspaces-plugin.md +++ b/website/docs/workspaces-plugin.md @@ -69,10 +69,10 @@ When done, press `m` to review the diff, create a GitHub PR, and clean up branch ## Configuration -Workspace behavior is configured via JSON files: +Workspace behavior is configured via: -**Global config:** `~/.config/sidecar/config.json` -**Project config:** `.sidecar/config.json` (overrides global) +- **Global config:** `~/.config/sidecar/config.json` (for `plugins.workspace.*` settings) +- **Project config:** `.sidecar/config.json` (currently used for prompt definitions) **Example config:** @@ -81,6 +81,12 @@ Workspace behavior is configured via JSON files: "plugins": { "workspace": { "dirPrefix": true, + "defaultAgentType": "claude", + "agentStart": { + "claude": "claude --dangerously-skip-permissions", + "opencode": "opencode --profile fast", + "*": "claude" + }, "setupScript": ".sidecar/setup-workspace.sh" } }, @@ -104,8 +110,29 @@ Workspace behavior is configured via JSON files: | Option | Type | Description | |--------|------|-------------| | `dirPrefix` | bool | Prefix workspace dir with repo name (e.g., `myrepo-feature-auth`) | +| `defaultAgentType` | string | Default agent family selected in create-workspace modal for new worktrees (AgentType value, e.g. `claude`, `codex`, `opencode`) | +| `agentStart` | object | Default startup command map keyed by AgentType (plus optional `*`/`default` fallback) | | `setupScript` | string | Path to script run after workspace creation (for env setup, symlinks, etc.) | +Environment override: set `SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE` (or `SIDECAR_DEFAULT_AGENT_TYPE`) before launching sidecar to override `defaultAgentType` for that process. + +### Per-worktree / per-branch agent command + +To override startup for one specific worktree/branch, create this file in that worktree root: + +- `.sidecar-agent-start` (exact filename) + +Its contents should be a single-line command prefix (hidden characters are stripped; empty/multiline values are ignored). + +**Command precedence** when starting an agent in a worktree: + +1. `.sidecar-agent-start` in that worktree +2. `plugins.workspace.agentStart[]` +3. `plugins.workspace.agentStart["*"]` (or `"default"`) +4. Built-in command for the selected agent type + +For OpenCode, provide the command prefix (e.g. `opencode --profile fast`), not `opencode run ...`; sidecar handles `run` when needed for prompt launch. + The setup script runs in the new workspace directory with `$SIDECAR_WORKTREE_NAME` and `$SIDECAR_BASE_BRANCH` environment variables. ## Overview @@ -575,6 +602,7 @@ The plugin remembers state across restarts and automatically reconnects to runni | Diff view mode | User config | | Active tab | User config | | Agent type | `.sidecar-agent` in workspace dir | +| Agent start command override | `.sidecar-agent-start` in workspace dir | | Task link | `.sidecar-task` in workspace dir | | PR URL | `.sidecar-pr` in workspace dir | From 3258e6d232399b5732d13fb0084695b81c810257 Mon Sep 17 00:00:00 2001 From: aappddeevv Date: Sun, 22 Feb 2026 08:08:48 -0500 Subject: [PATCH 2/3] Support staring a new subagent on an existing worktree with the 's' command --- internal/plugins/workspace/agent.go | 5 +- internal/plugins/workspace/keys.go | 20 +++---- internal/plugins/workspace/plugin.go | 31 +++++++--- .../plugins/workspace/plugin_defaults_test.go | 58 +++++++++++++++++++ internal/plugins/workspace/update.go | 5 +- website/docs/workspaces-plugin.md | 11 +++- 6 files changed, 100 insertions(+), 30 deletions(-) diff --git a/internal/plugins/workspace/agent.go b/internal/plugins/workspace/agent.go index 4038fb13..6236ee75 100644 --- a/internal/plugins/workspace/agent.go +++ b/internal/plugins/workspace/agent.go @@ -1536,10 +1536,7 @@ func (p *Plugin) reconnectAgents() tea.Cmd { // Create agent record paneID := getPaneID(session) - agentType := wt.ChosenAgentType - if agentType == "" || agentType == AgentNone { - agentType = AgentClaude // Fallback if no .sidecar-agent file - } + agentType := p.resolveWorktreeAgentType(wt) agent := &Agent{ Type: agentType, TmuxSession: session, diff --git a/internal/plugins/workspace/keys.go b/internal/plugins/workspace/keys.go index 5009cc38..6f85a0c9 100644 --- a/internal/plugins/workspace/keys.go +++ b/internal/plugins/workspace/keys.go @@ -493,7 +493,7 @@ func (p *Plugin) handleListKeys(msg tea.KeyMsg) tea.Cmd { if p.previewOffset > 0 { p.previewOffset-- if p.previewOffset == 0 { - p.autoScrollOutput = true // Resume auto-scroll when at bottom + p.autoScrollOutput = true // Resume auto-scroll when at bottom p.resetScrollBaseLineCount() // td-f7c8be: clear snapshot } } @@ -538,7 +538,7 @@ func (p *Plugin) handleListKeys(msg tea.KeyMsg) tea.Cmd { // Go to top (oldest content) - pause auto-scroll p.autoScrollOutput = false p.captureScrollBaseLineCount() // td-f7c8be: prevent bounce on poll - p.previewOffset = math.MaxInt // Will be clamped in render + p.previewOffset = math.MaxInt // Will be clamped in render case "G": if p.viewMode == ViewModeKanban { // Kanban mode: jump cursor to bottom of current column @@ -576,8 +576,8 @@ func (p *Plugin) handleListKeys(msg tea.KeyMsg) tea.Cmd { p.typeSelectorNameInput.Prompt = "" p.typeSelectorNameInput.Width = 30 p.typeSelectorNameInput.CharLimit = 50 - p.typeSelectorModal = nil // Force rebuild - p.typeSelectorModalWidth = 0 // Force rebuild + p.typeSelectorModal = nil // Force rebuild + p.typeSelectorModalWidth = 0 // Force rebuild return nil case "D": // Check if deleting a shell session @@ -634,10 +634,7 @@ func (p *Plugin) handleListKeys(msg tea.KeyMsg) tea.Cmd { wt := p.selectedWorktree() if wt != nil && wt.IsOrphaned && wt.Agent == nil { wt.IsOrphaned = false - agentType := wt.ChosenAgentType - if agentType == AgentNone || agentType == "" { - agentType = AgentClaude - } + agentType := p.resolveWorktreeAgentType(wt) return p.StartAgent(wt, agentType) } } @@ -669,10 +666,7 @@ func (p *Plugin) handleListKeys(msg tea.KeyMsg) tea.Cmd { // Clear flag immediately for UI feedback; also cleared in AgentStartedMsg // handler when agent actually starts (StartAgent is async) wt.IsOrphaned = false - agentType := wt.ChosenAgentType - if agentType == AgentNone || agentType == "" { - agentType = AgentClaude // Fallback - } + agentType := p.resolveWorktreeAgentType(wt) return p.StartAgent(wt, agentType) } // No agent, not orphaned: focus preview @@ -810,7 +804,7 @@ func (p *Plugin) handleListKeys(msg tea.KeyMsg) tea.Cmd { } if wt.Agent == nil { // No agent running - start new one - return p.StartAgent(wt, wt.ChosenAgentType) + return p.StartAgent(wt, p.resolveWorktreeAgentType(wt)) } // Agent exists - show choice modal (attach or restart) p.agentChoiceWorktree = wt diff --git a/internal/plugins/workspace/plugin.go b/internal/plugins/workspace/plugin.go index 931b9f86..f8e689e6 100644 --- a/internal/plugins/workspace/plugin.go +++ b/internal/plugins/workspace/plugin.go @@ -719,6 +719,28 @@ func isCreateDefaultAgent(agentType AgentType) bool { return false } +func (p *Plugin) getConfigDefaultAgentType() AgentType { + if p != nil && p.ctx != nil && p.ctx.Config != nil { + configAgent := AgentType(strings.TrimSpace(p.ctx.Config.Plugins.Workspace.DefaultAgentType)) + if isCreateDefaultAgent(configAgent) { + return configAgent + } + } + return AgentClaude +} + +// resolveWorktreeAgentType returns the agent type to use when starting an agent for a worktree. +// Hierarchy: .sidecar-agent file -> config defaultAgentType -> Claude fallback. +func (p *Plugin) resolveWorktreeAgentType(wt *Worktree) AgentType { + if wt != nil { + fileAgent := loadAgentType(wt.Path) + if isCreateDefaultAgent(fileAgent) { + return fileAgent + } + } + return p.getConfigDefaultAgentType() +} + // getDefaultCreateAgentType returns the default agent for create-worktree modal. // .sidecar-agent in the current workspace is treated equivalently to config defaultAgentType. func (p *Plugin) getDefaultCreateAgentType() AgentType { @@ -731,14 +753,9 @@ func (p *Plugin) getDefaultCreateAgentType() AgentType { } } - if p.ctx.Config != nil { - configAgent := AgentType(strings.TrimSpace(p.ctx.Config.Plugins.Workspace.DefaultAgentType)) - if isCreateDefaultAgent(configAgent) { - return configAgent - } - } + return p.getConfigDefaultAgentType() } - return AgentClaude + return p.getConfigDefaultAgentType() } // clearCreateModal resets create modal state. diff --git a/internal/plugins/workspace/plugin_defaults_test.go b/internal/plugins/workspace/plugin_defaults_test.go index 7df9e876..81c8f9d0 100644 --- a/internal/plugins/workspace/plugin_defaults_test.go +++ b/internal/plugins/workspace/plugin_defaults_test.go @@ -82,3 +82,61 @@ func TestInitCreateModalBase_UsesConfiguredDefaultAgent(t *testing.T) { t.Errorf("createAgentType = %q, want %q", p.createAgentType, AgentPi) } } + +func TestResolveWorktreeAgentType_UsesConfigWhenNoSidecarFile(t *testing.T) { + workDir := t.TempDir() + cfg := config.Default() + cfg.Plugins.Workspace.DefaultAgentType = string(AgentOpenCode) + + p := &Plugin{ + ctx: &plugin.Context{ + WorkDir: workDir, + Config: cfg, + }, + } + wt := &Worktree{Path: workDir} + + if got := p.resolveWorktreeAgentType(wt); got != AgentOpenCode { + t.Errorf("resolveWorktreeAgentType() = %q, want %q", got, AgentOpenCode) + } +} + +func TestResolveWorktreeAgentType_SidecarFilePrecedence(t *testing.T) { + workDir := t.TempDir() + cfg := config.Default() + cfg.Plugins.Workspace.DefaultAgentType = string(AgentGemini) + + if err := os.WriteFile(filepath.Join(workDir, sidecarAgentFile), []byte(string(AgentCodex)+"\n"), 0644); err != nil { + t.Fatalf("write .sidecar-agent: %v", err) + } + + p := &Plugin{ + ctx: &plugin.Context{ + WorkDir: workDir, + Config: cfg, + }, + } + wt := &Worktree{Path: workDir} + + if got := p.resolveWorktreeAgentType(wt); got != AgentCodex { + t.Errorf("resolveWorktreeAgentType() = %q, want %q", got, AgentCodex) + } +} + +func TestResolveWorktreeAgentType_ClaudeFallback(t *testing.T) { + workDir := t.TempDir() + cfg := config.Default() + cfg.Plugins.Workspace.DefaultAgentType = "not-an-agent" + + p := &Plugin{ + ctx: &plugin.Context{ + WorkDir: workDir, + Config: cfg, + }, + } + wt := &Worktree{Path: workDir} + + if got := p.resolveWorktreeAgentType(wt); got != AgentClaude { + t.Errorf("resolveWorktreeAgentType() = %q, want %q", got, AgentClaude) + } +} diff --git a/internal/plugins/workspace/update.go b/internal/plugins/workspace/update.go index 587002bf..d8c070d0 100644 --- a/internal/plugins/workspace/update.go +++ b/internal/plugins/workspace/update.go @@ -952,10 +952,7 @@ func (p *Plugin) Update(msg tea.Msg) (plugin.Plugin, tea.Cmd) { case restartAgentMsg: // Start new agent after stop completed if msg.worktree != nil { - agentType := msg.worktree.ChosenAgentType - if agentType == "" { - agentType = AgentClaude - } + agentType := p.resolveWorktreeAgentType(msg.worktree) return p, p.StartAgent(msg.worktree, agentType) } return p, nil diff --git a/website/docs/workspaces-plugin.md b/website/docs/workspaces-plugin.md index 36c6171e..b28ea274 100644 --- a/website/docs/workspaces-plugin.md +++ b/website/docs/workspaces-plugin.md @@ -129,7 +129,14 @@ Its contents should be a single-line command prefix (hidden characters are strip 1. `.sidecar-agent-start` in that worktree 2. `plugins.workspace.agentStart[]` 3. `plugins.workspace.agentStart["*"]` (or `"default"`) -4. Built-in command for the selected agent type +4. Built-in command for the selected agent type (`AgentCommands`) +5. `claude` (only if the selected type has no built-in command mapping) + +**Selected agent type precedence** (used for `s` start/restart/reconnect): + +1. `.sidecar-agent` in that worktree +2. `plugins.workspace.defaultAgentType` (or `SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE` / `SIDECAR_DEFAULT_AGENT_TYPE`) +3. `claude` For OpenCode, provide the command prefix (e.g. `opencode --profile fast`), not `opencode run ...`; sidecar handles `run` when needed for prompt launch. @@ -613,7 +620,7 @@ When sidecar starts, it: 2. Checks for existing tmux sessions named `sidecar-ws-*` 3. Reconnects to active sessions and resumes output streaming 4. Detects agent status (Active, Waiting, Done) by analyzing recent output -5. Restores agent type from `.sidecar-agent` file +5. Resolves agent type using `.sidecar-agent` -> `defaultAgentType` -> `claude` **Claude Code integration:** From 15565b121384ae0d496ae3c283d91d1e85029c46 Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Thu, 5 Mar 2026 09:22:42 -0800 Subject: [PATCH 3/3] Polish agent-spec PR: fix blank-env bug, rename, tests, comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original work by @aappddeevv (PR #198, 917 lines). Taking over the branch to address review items before merge. Changes: - Fix blank-env-var early return in applyEnvOverrides: when SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE is set but blank, previously short-circuited and silently dropped SIDECAR_DEFAULT_AGENT_TYPE. Now only short-circuits when the value is non-empty after TrimSpace. - Fix getDefaultCreateAgentType to call loadAgentType() instead of duplicating the os.ReadFile logic inline. - Rename isCreateDefaultAgent → isKnownAgentType for clarity; the function answers 'is this a recognized agent type?' which has nothing to do with create-modal specifics. - Fix misleading test name TestResolveAgentBaseCommand_ReplacementCharFallsBack → TestResolveAgentBaseCommand_ReplacementCharStripped. The function strips the replacement char and returns the cleaned command; it doesn't fall back to the default. - Add missing tests: * TestResolveConfigAgentStart_WildcardFallbackChain: covers agentType miss → '*' hit → 'default' hit chain through resolveConfigAgentStart * TestResolveAgentBaseCommand_ThreeLayerPrecedence: end-to-end test of all three override layers (file > config > default) * TestApplyEnvOverrides_*: four cases covering blank-env fix, precedence, single-var, and neither-var-set - Add code comment in startAgentInShell explaining why shell sessions use p.ctx.WorkDir (workspace root) instead of wt.Path (worktree dir) for .sidecar-agent-start lookups, and what that means for users. --- internal/config/loader.go | 5 +- internal/config/loader_test.go | 57 ++++++++++++++ internal/plugins/workspace/agent_test.go | 98 +++++++++++++++++++++++- internal/plugins/workspace/plugin.go | 17 ++-- internal/plugins/workspace/shell.go | 6 +- 5 files changed, 170 insertions(+), 13 deletions(-) diff --git a/internal/config/loader.go b/internal/config/loader.go index 8aa33b63..07916f74 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -280,7 +280,10 @@ func applyEnvOverrides(cfg *Config) { return } - if v, ok := os.LookupEnv(envWorkspaceDefaultAgentType); ok { + // SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE takes precedence over SIDECAR_DEFAULT_AGENT_TYPE, + // but only when it is set to a non-blank value. A blank value means "unset" so we fall + // through to the lower-priority env var rather than silently dropping it. + if v, ok := os.LookupEnv(envWorkspaceDefaultAgentType); ok && strings.TrimSpace(v) != "" { cfg.Plugins.Workspace.DefaultAgentType = strings.TrimSpace(v) return } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 16e162de..67d56129 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -314,3 +314,60 @@ func TestLoadFrom_WorkspaceAgentStartLegacyStringBackwardCompat(t *testing.T) { t.Errorf("AgentStart[*] = %q, want %q", got, "custom-agent --legacy") } } + +func TestApplyEnvOverrides_WorkspaceVarTakesPrecedence(t *testing.T) { + t.Setenv(envWorkspaceDefaultAgentType, "opencode") + t.Setenv(envDefaultAgentType, "gemini") + + cfg := Default() + applyEnvOverrides(cfg) + + if cfg.Plugins.Workspace.DefaultAgentType != "opencode" { + t.Errorf("DefaultAgentType = %q, want %q", cfg.Plugins.Workspace.DefaultAgentType, "opencode") + } +} + +func TestApplyEnvOverrides_FallsThruWhenWorkspaceVarBlank(t *testing.T) { + // When SIDECAR_WORKSPACE_DEFAULT_AGENT_TYPE is set but blank, we should + // NOT short-circuit — SIDECAR_DEFAULT_AGENT_TYPE must still be honoured. + t.Setenv(envWorkspaceDefaultAgentType, " ") + t.Setenv(envDefaultAgentType, "gemini") + + cfg := Default() + applyEnvOverrides(cfg) + + if cfg.Plugins.Workspace.DefaultAgentType != "gemini" { + t.Errorf("DefaultAgentType = %q, want %q (blank workspace var should fall through)", cfg.Plugins.Workspace.DefaultAgentType, "gemini") + } +} + +func TestApplyEnvOverrides_OnlyDefaultVar(t *testing.T) { + t.Setenv(envDefaultAgentType, "codex") + + cfg := Default() + applyEnvOverrides(cfg) + + if cfg.Plugins.Workspace.DefaultAgentType != "codex" { + t.Errorf("DefaultAgentType = %q, want %q", cfg.Plugins.Workspace.DefaultAgentType, "codex") + } +} + +func TestApplyEnvOverrides_NeitherVarSet(t *testing.T) { + cfg := Default() + cfg.Plugins.Workspace.DefaultAgentType = "original" + + // Ensure neither env var is set + t.Setenv(envWorkspaceDefaultAgentType, "") + if err := os.Unsetenv(envWorkspaceDefaultAgentType); err != nil { + t.Fatal(err) + } + if err := os.Unsetenv(envDefaultAgentType); err != nil { + t.Fatal(err) + } + + applyEnvOverrides(cfg) + + if cfg.Plugins.Workspace.DefaultAgentType != "original" { + t.Errorf("DefaultAgentType = %q, want %q (should be unchanged)", cfg.Plugins.Workspace.DefaultAgentType, "original") + } +} diff --git a/internal/plugins/workspace/agent_test.go b/internal/plugins/workspace/agent_test.go index 12b6d407..8d618c8f 100644 --- a/internal/plugins/workspace/agent_test.go +++ b/internal/plugins/workspace/agent_test.go @@ -276,7 +276,7 @@ func TestResolveAgentBaseCommand_UTF8BOMStripped(t *testing.T) { } } -func TestResolveAgentBaseCommand_ReplacementCharFallsBack(t *testing.T) { +func TestResolveAgentBaseCommand_ReplacementCharStripped(t *testing.T) { tmpDir := t.TempDir() overridePath := filepath.Join(tmpDir, sidecarAgentStartFile) if err := os.WriteFile(overridePath, []byte("opencode\ufffd"), 0644); err != nil { @@ -1186,3 +1186,99 @@ func TestExtractLastNLines(t *testing.T) { }) } } + +// TestResolveConfigAgentStart exercises the wildcard fallback chain. +// Precedence: exact agentType key → "*" wildcard → "default" key. +func TestResolveConfigAgentStart_WildcardFallbackChain(t *testing.T) { + tests := []struct { + name string + agentStart map[string]string + agentType AgentType + want string + }{ + { + name: "exact match wins", + agentStart: map[string]string{"claude": "my-claude", "*": "wildcard-agent", "default": "default-agent"}, + agentType: AgentClaude, + want: "my-claude", + }, + { + name: "miss on exact falls through to wildcard", + agentStart: map[string]string{"*": "wildcard-agent", "default": "default-agent"}, + agentType: AgentCodex, + want: "wildcard-agent", + }, + { + name: "miss on exact and wildcard falls through to default", + agentStart: map[string]string{"default": "default-agent"}, + agentType: AgentCodex, + want: "default-agent", + }, + { + name: "all miss returns empty", + agentStart: map[string]string{"gemini": "gemini-agent"}, + agentType: AgentCodex, + want: "", + }, + { + name: "nil map returns empty", + agentStart: nil, + agentType: AgentClaude, + want: "", + }, + { + name: "empty map returns empty", + agentStart: map[string]string{}, + agentType: AgentClaude, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveConfigAgentStart(tt.agentStart, tt.agentType) + if got != tt.want { + t.Errorf("resolveConfigAgentStart() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestResolveAgentBaseCommand_ThreeLayerPrecedence verifies the full precedence chain: +// .sidecar-agent-start file > config agentStart > AgentCommands default. +func TestResolveAgentBaseCommand_ThreeLayerPrecedence(t *testing.T) { + tmpDir := t.TempDir() + overridePath := tmpDir + "/" + sidecarAgentStartFile + + cfg := config.Default() + cfg.Plugins.Workspace.AgentStart = map[string]string{ + string(AgentClaude): "config-claude --fast", + } + p := &Plugin{ + ctx: &plugin.Context{ + WorkDir: tmpDir, + Config: cfg, + }, + } + + // Layer 3: no file, no config key → AgentCommands default + got := p.resolveAgentBaseCommand(tmpDir, AgentCodex) + if got != "codex" { + t.Errorf("layer3 (default) = %q, want %q", got, "codex") + } + + // Layer 2: config agentStart key present → use it + got = p.resolveAgentBaseCommand(tmpDir, AgentClaude) + if got != "config-claude --fast" { + t.Errorf("layer2 (config) = %q, want %q", got, "config-claude --fast") + } + + // Layer 1: .sidecar-agent-start file → always wins + if err := os.WriteFile(overridePath, []byte("file-claude --override"), 0644); err != nil { + t.Fatalf("write override file: %v", err) + } + got = p.resolveAgentBaseCommand(tmpDir, AgentClaude) + if got != "file-claude --override" { + t.Errorf("layer1 (file) = %q, want %q", got, "file-claude --override") + } +} diff --git a/internal/plugins/workspace/plugin.go b/internal/plugins/workspace/plugin.go index f8e689e6..bcb151d3 100644 --- a/internal/plugins/workspace/plugin.go +++ b/internal/plugins/workspace/plugin.go @@ -707,7 +707,8 @@ func (p *Plugin) removeWorktreeByName(name string) { } } -func isCreateDefaultAgent(agentType AgentType) bool { +// isKnownAgentType reports whether agentType is a recognized, non-empty agent type. +func isKnownAgentType(agentType AgentType) bool { if agentType == "" { return false } @@ -722,7 +723,7 @@ func isCreateDefaultAgent(agentType AgentType) bool { func (p *Plugin) getConfigDefaultAgentType() AgentType { if p != nil && p.ctx != nil && p.ctx.Config != nil { configAgent := AgentType(strings.TrimSpace(p.ctx.Config.Plugins.Workspace.DefaultAgentType)) - if isCreateDefaultAgent(configAgent) { + if isKnownAgentType(configAgent) { return configAgent } } @@ -734,7 +735,7 @@ func (p *Plugin) getConfigDefaultAgentType() AgentType { func (p *Plugin) resolveWorktreeAgentType(wt *Worktree) AgentType { if wt != nil { fileAgent := loadAgentType(wt.Path) - if isCreateDefaultAgent(fileAgent) { + if isKnownAgentType(fileAgent) { return fileAgent } } @@ -745,14 +746,10 @@ func (p *Plugin) resolveWorktreeAgentType(wt *Worktree) AgentType { // .sidecar-agent in the current workspace is treated equivalently to config defaultAgentType. func (p *Plugin) getDefaultCreateAgentType() AgentType { if p != nil && p.ctx != nil { - agentPath := filepath.Join(p.ctx.WorkDir, sidecarAgentFile) - if content, err := os.ReadFile(agentPath); err == nil { - fileAgent := AgentType(strings.TrimSpace(string(content))) - if isCreateDefaultAgent(fileAgent) { - return fileAgent - } + fileAgent := loadAgentType(p.ctx.WorkDir) + if isKnownAgentType(fileAgent) { + return fileAgent } - return p.getConfigDefaultAgentType() } return p.getConfigDefaultAgentType() diff --git a/internal/plugins/workspace/shell.go b/internal/plugins/workspace/shell.go index 4c9621c6..fa435a18 100644 --- a/internal/plugins/workspace/shell.go +++ b/internal/plugins/workspace/shell.go @@ -665,7 +665,11 @@ func (p *Plugin) startAgentInShell(tmuxName string, agentType AgentType, skipPer workDir = p.ctx.WorkDir } - // Get the base command for this agent family, allowing worktree-level override. + // Get the base command for this agent family, allowing workspace-level override. + // Note: shell sessions pass p.ctx.WorkDir (the main workspace directory) as the + // search path for .sidecar-agent-start, unlike worktree sessions which pass wt.Path + // (the worktree-specific directory). This means .sidecar-agent-start in a worktree + // does NOT affect shell session agent commands — only the workspace root file does. baseCmd := p.resolveAgentBaseCommand(workDir, agentType) if strings.TrimSpace(baseCmd) == "" { return ShellAgentErrorMsg{