Skip to content

Commit b6d2093

Browse files
committed
Add richer sah run UX
1 parent cf134e3 commit b6d2093

10 files changed

Lines changed: 1090 additions & 65 deletions

File tree

cmd/sah/main.go

Lines changed: 91 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,9 @@ Commands:
7171
7272
Examples:
7373
sah auth login
74-
sah run --agent codex
75-
sah daemon install --agent claude --interval 30m
74+
sah run --rotate-installed
75+
sah run --agents codex,gemini,claude --models codex=gpt-5.4-mini,gemini=gemini3-flash,claude=sonnet
76+
sah daemon install --agents codex,claude --interval 30m
7677
sah me
7778
`)
7879
}
@@ -169,7 +170,10 @@ func runCmd(args []string) error {
169170
fs := flag.NewFlagSet("run", flag.ContinueOnError)
170171
fs.SetOutput(os.Stderr)
171172
agent := fs.String("agent", "", "Agent CLI to use: codex, gemini, claude")
173+
agents := fs.String("agents", "", "Comma-separated round-robin agent order, e.g. codex,gemini,claude")
174+
rotateInstalled := fs.Bool("rotate-installed", false, "Rotate through every supported agent CLI installed on this Mac")
172175
model := fs.String("model", "", "Optional model override passed to the agent CLI")
176+
models := fs.String("models", "", "Per-agent model overrides, e.g. codex=gpt-5.4-mini,gemini=gemini3-flash,claude=sonnet")
173177
interval := fs.String("interval", "", "Polling interval")
174178
timeout := fs.String("timeout", "", "Per-assignment agent timeout")
175179
taskType := fs.String("task-type", "", "Optional task type filter")
@@ -179,6 +183,12 @@ func runCmd(args []string) error {
179183
if err := fs.Parse(args); err != nil {
180184
return err
181185
}
186+
if err := validateAgentFlags(*agent, *agents, *rotateInstalled); err != nil {
187+
return err
188+
}
189+
if !*daemonMode {
190+
sah.PrintRunBanner(os.Stdout)
191+
}
182192

183193
ctx, cancel := signalContext()
184194
defer cancel()
@@ -205,6 +215,16 @@ func runCmd(args []string) error {
205215
return err
206216
}
207217
}
218+
agentPool := sah.ParseAgentList(*agents)
219+
if len(agentPool) > 0 {
220+
if _, err := sah.ResolveAgentPool(config, sah.WorkerOptions{Agents: agentPool}); err != nil {
221+
return err
222+
}
223+
}
224+
agentModels, err := sah.ParseAgentModels(*models)
225+
if err != nil {
226+
return err
227+
}
208228

209229
pollInterval, err := sah.ParsePollInterval(pickString(*interval, config.PollInterval))
210230
if err != nil {
@@ -215,16 +235,29 @@ func runCmd(args []string) error {
215235
return err
216236
}
217237

218-
return sah.RunWorker(ctx, config, sah.WorkerOptions{
219-
Agent: pickString(*agent, config.DefaultAgent),
220-
Model: pickString(*model, config.AgentModel),
221-
Interval: pollInterval,
222-
Timeout: agentTimeout,
223-
TaskType: strings.TrimSpace(*taskType),
224-
Once: *once,
225-
Output: os.Stdout,
226-
ErrorOutput: os.Stderr,
227-
})
238+
options := sah.WorkerOptions{
239+
Agent: pickString(*agent, config.DefaultAgent),
240+
Agents: agentPool,
241+
RotateInstalled: *rotateInstalled,
242+
Model: pickString(*model, config.AgentModel),
243+
Models: sah.MergeAgentModels(config.AgentModels, agentModels),
244+
Interval: pollInterval,
245+
Timeout: agentTimeout,
246+
TaskType: strings.TrimSpace(*taskType),
247+
Once: *once,
248+
Output: os.Stdout,
249+
ErrorOutput: os.Stderr,
250+
}
251+
252+
picker, err := sah.NewAgentPicker(config, options)
253+
if err != nil {
254+
return err
255+
}
256+
if !*daemonMode {
257+
sah.PrintRunPlan(os.Stdout, config, options, picker.Pool())
258+
}
259+
260+
return sah.RunWorker(ctx, config, options)
228261
}
229262

230263
func daemonCmd(args []string) error {
@@ -237,13 +270,19 @@ func daemonCmd(args []string) error {
237270
fs := flag.NewFlagSet("daemon install", flag.ContinueOnError)
238271
fs.SetOutput(os.Stderr)
239272
agent := fs.String("agent", "", "Default agent CLI for the daemon")
273+
agents := fs.String("agents", "", "Comma-separated round-robin agent order for the daemon")
274+
rotateInstalled := fs.Bool("rotate-installed", false, "Rotate through every installed supported agent CLI")
240275
model := fs.String("model", "", "Default model override")
276+
models := fs.String("models", "", "Per-agent model overrides, e.g. codex=gpt-5.4-mini,gemini=gemini3-flash,claude=sonnet")
241277
interval := fs.String("interval", "", "Default polling interval")
242278
timeout := fs.String("timeout", "", "Default per-assignment timeout")
243279
baseURL := fs.String("base-url", "", "SCIENCE@home base URL")
244280
if err := fs.Parse(args[1:]); err != nil {
245281
return err
246282
}
283+
if err := validateAgentFlags(*agent, *agents, *rotateInstalled); err != nil {
284+
return err
285+
}
247286

248287
ctx, cancel := signalContext()
249288
defer cancel()
@@ -260,10 +299,31 @@ func daemonCmd(args []string) error {
260299
return err
261300
}
262301
config.DefaultAgent = *agent
302+
config.AgentPool = nil
303+
config.RotateInstalled = false
304+
}
305+
if pool := sah.ParseAgentList(*agents); len(pool) > 0 {
306+
if _, err := sah.ResolveAgentPool(config, sah.WorkerOptions{Agents: pool}); err != nil {
307+
return err
308+
}
309+
config.AgentPool = pool
310+
config.RotateInstalled = false
311+
}
312+
if *rotateInstalled {
313+
if _, err := sah.ResolveAgentPool(config, sah.WorkerOptions{RotateInstalled: true}); err != nil {
314+
return err
315+
}
316+
config.AgentPool = nil
317+
config.RotateInstalled = true
263318
}
264319
if strings.TrimSpace(*model) != "" {
265320
config.AgentModel = *model
266321
}
322+
if parsedModels, err := sah.ParseAgentModels(*models); err != nil {
323+
return err
324+
} else if parsedModels != nil {
325+
config.AgentModels = parsedModels
326+
}
267327
if strings.TrimSpace(*interval) != "" {
268328
if _, err := sah.ParsePollInterval(*interval); err != nil {
269329
return err
@@ -342,7 +402,13 @@ func daemonCmd(args []string) error {
342402
return err
343403
}
344404
fmt.Printf("Base URL: %s\n", config.BaseURL)
345-
fmt.Printf("Default agent: %s\n", config.DefaultAgent)
405+
fmt.Printf("Agents: %s\n", sah.DescribeAgentMode(config, sah.WorkerOptions{}))
406+
if model := strings.TrimSpace(config.AgentModel); model != "" {
407+
fmt.Printf("Model: %s\n", model)
408+
}
409+
if models := sah.FormatAgentModels(config.AgentModels); models != "" {
410+
fmt.Printf("Per-agent models: %s\n", models)
411+
}
346412
fmt.Printf("Interval: %s\n", config.PollInterval)
347413
if loaded {
348414
fmt.Println("Launchd: loaded")
@@ -492,13 +558,13 @@ func agentsCmd(args []string) error {
492558
}
493559

494560
writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
495-
fmt.Fprintln(writer, "NAME\tINSTALLED\tPATH\tDESCRIPTION")
561+
fmt.Fprintln(writer, "NAME\tINSTALLED\tPATH\tDESCRIPTION\tMODEL FLAG")
496562
for _, status := range sah.InstalledAgents() {
497563
installed := "no"
498564
if status.Installed {
499565
installed = "yes"
500566
}
501-
fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", status.Name, installed, status.Path, status.Description)
567+
fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t--model / --models %s=<name>\n", status.Name, installed, status.Path, status.Description, status.Name)
502568
}
503569
return writer.Flush()
504570
}
@@ -587,6 +653,16 @@ func pickString(primary string, fallback string) string {
587653
return fallback
588654
}
589655

656+
func validateAgentFlags(agent string, agents string, rotateInstalled bool) error {
657+
if rotateInstalled && (strings.TrimSpace(agent) != "" || strings.TrimSpace(agents) != "") {
658+
return fmt.Errorf("--rotate-installed cannot be combined with --agent or --agents")
659+
}
660+
if strings.TrimSpace(agent) != "" && strings.TrimSpace(agents) != "" {
661+
return fmt.Errorf("--agent cannot be combined with --agents")
662+
}
663+
return nil
664+
}
665+
590666
func signalContext() (context.Context, context.CancelFunc) {
591667
return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
592668
}

internal/sah/agent_selection.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package sah
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strings"
7+
)
8+
9+
type AgentPicker struct {
10+
pool []AgentSpec
11+
index int
12+
}
13+
14+
func NewAgentPicker(config Config, options WorkerOptions) (*AgentPicker, error) {
15+
pool, err := ResolveAgentPool(config, options)
16+
if err != nil {
17+
return nil, err
18+
}
19+
return &AgentPicker{pool: pool}, nil
20+
}
21+
22+
func (picker *AgentPicker) Pool() []AgentSpec {
23+
cloned := make([]AgentSpec, len(picker.pool))
24+
copy(cloned, picker.pool)
25+
return cloned
26+
}
27+
28+
func (picker *AgentPicker) Next() AgentSpec {
29+
agent := picker.pool[picker.index%len(picker.pool)]
30+
picker.index++
31+
return agent
32+
}
33+
34+
func ResolveAgentPool(config Config, options WorkerOptions) ([]AgentSpec, error) {
35+
switch {
36+
case options.RotateInstalled:
37+
return resolveInstalledAgentPool()
38+
case len(options.Agents) > 0:
39+
return resolveNamedAgentPool(options.Agents)
40+
case config.RotateInstalled:
41+
return resolveInstalledAgentPool()
42+
case len(config.AgentPool) > 0:
43+
return resolveNamedAgentPool(config.AgentPool)
44+
default:
45+
name := strings.TrimSpace(options.Agent)
46+
if name == "" {
47+
name = config.DefaultAgent
48+
}
49+
agent, err := ResolveAgent(name)
50+
if err != nil {
51+
return nil, err
52+
}
53+
return []AgentSpec{agent}, nil
54+
}
55+
}
56+
57+
func resolveInstalledAgentPool() ([]AgentSpec, error) {
58+
pool := make([]AgentSpec, 0, len(SupportedAgents))
59+
for _, status := range InstalledAgents() {
60+
if status.Installed {
61+
pool = append(pool, status.AgentSpec)
62+
}
63+
}
64+
if len(pool) == 0 {
65+
return nil, fmt.Errorf("no supported agent CLI found in PATH")
66+
}
67+
return pool, nil
68+
}
69+
70+
func resolveNamedAgentPool(names []string) ([]AgentSpec, error) {
71+
pool := make([]AgentSpec, 0, len(names))
72+
seen := map[string]struct{}{}
73+
for _, entry := range names {
74+
name := normalizeAgentName(entry)
75+
if name == "" {
76+
continue
77+
}
78+
if _, ok := seen[name]; ok {
79+
continue
80+
}
81+
agent, err := ResolveAgent(name)
82+
if err != nil {
83+
return nil, err
84+
}
85+
seen[name] = struct{}{}
86+
pool = append(pool, agent)
87+
}
88+
if len(pool) == 0 {
89+
return nil, fmt.Errorf("agent pool is empty")
90+
}
91+
return pool, nil
92+
}
93+
94+
func ParseAgentList(raw string) []string {
95+
if strings.TrimSpace(raw) == "" {
96+
return nil
97+
}
98+
99+
return normalizeAgentPool(strings.Split(raw, ","))
100+
}
101+
102+
func ParseAgentModels(raw string) (map[string]string, error) {
103+
if strings.TrimSpace(raw) == "" {
104+
return nil, nil
105+
}
106+
107+
models := map[string]string{}
108+
for _, entry := range strings.Split(raw, ",") {
109+
pair := strings.SplitN(strings.TrimSpace(entry), "=", 2)
110+
if len(pair) != 2 {
111+
return nil, fmt.Errorf("invalid model override %q; expected agent=model", entry)
112+
}
113+
114+
name := normalizeAgentName(pair[0])
115+
model := strings.TrimSpace(pair[1])
116+
if name == "" || model == "" {
117+
return nil, fmt.Errorf("invalid model override %q; expected agent=model", entry)
118+
}
119+
models[name] = model
120+
}
121+
return normalizeAgentModels(models), nil
122+
}
123+
124+
func MergeAgentModels(base map[string]string, overrides map[string]string) map[string]string {
125+
if len(base) == 0 && len(overrides) == 0 {
126+
return nil
127+
}
128+
129+
merged := map[string]string{}
130+
for name, model := range normalizeAgentModels(base) {
131+
merged[name] = model
132+
}
133+
for name, model := range normalizeAgentModels(overrides) {
134+
merged[name] = model
135+
}
136+
if len(merged) == 0 {
137+
return nil
138+
}
139+
return merged
140+
}
141+
142+
func ModelForAgent(agentName string, fallback string, overrides map[string]string) string {
143+
if model, ok := normalizeAgentModels(overrides)[normalizeAgentName(agentName)]; ok {
144+
return model
145+
}
146+
return strings.TrimSpace(fallback)
147+
}
148+
149+
func DescribeAgentMode(config Config, options WorkerOptions) string {
150+
if options.RotateInstalled {
151+
return "all installed agents"
152+
}
153+
if len(options.Agents) > 0 {
154+
return strings.Join(options.Agents, ", ")
155+
}
156+
if config.RotateInstalled {
157+
return "all installed agents"
158+
}
159+
if len(config.AgentPool) > 0 {
160+
return strings.Join(config.AgentPool, ", ")
161+
}
162+
if strings.TrimSpace(options.Agent) != "" {
163+
return normalizeAgentName(options.Agent)
164+
}
165+
return config.DefaultAgent
166+
}
167+
168+
func FormatAgentModels(models map[string]string) string {
169+
normalized := normalizeAgentModels(models)
170+
if len(normalized) == 0 {
171+
return ""
172+
}
173+
174+
names := make([]string, 0, len(normalized))
175+
for name := range normalized {
176+
names = append(names, name)
177+
}
178+
sort.Strings(names)
179+
180+
parts := make([]string, 0, len(names))
181+
for _, name := range names {
182+
parts = append(parts, fmt.Sprintf("%s=%s", name, normalized[name]))
183+
}
184+
return strings.Join(parts, ", ")
185+
}

0 commit comments

Comments
 (0)