1package modes
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "github.com/bnema/zut/packages/agent/subagents"
9 "github.com/bnema/zut/packages/agent/tools"
10 "github.com/bnema/zut/packages/provider"
11)
12
13func (i *Interactive) runSubagents(ctx context.Context, args []string) {
14 if i.cfg.ResidentManager == nil {
15 i.mu.Lock()
16 i.statusErr = i.autoSubagentsUnavailableHint()
17 i.statusOK = ""
18 i.mu.Unlock()
19 return
20 }
21 i.runResidentSubagents(ctx, args)
22}
23
24// runResidentSubagents is the production slash-command path. The list/editor
25// migration is deliberately kept separate from the legacy process dashboard;
26// details open a structured journal-backed child session immediately.
27func (i *Interactive) runResidentSubagents(ctx context.Context, args []string) {
28 sub := ""
29 rest := ""
30 if len(args) > 0 {
31 sub = strings.ToLower(args[0])
32 if len(args) > 1 {
33 rest = strings.TrimSpace(strings.Join(args[1:], " "))
34 }
35 }
36 switch sub {
37 case "", "list", "ls", "ps":
38 i.mu.Lock()
39 if i.residentSubagentsDialog == nil {
40 i.residentSubagentsDialog = newResidentSubagentsDialog()
41 }
42 i.residentSubagentsDialog.Open(i.cfg.ResidentManager)
43 i.mu.Unlock()
44 i.invalidate()
45 case "new", "spawn":
46 i.spawnResidentSubagent(rest)
47 case "logs", "log", "view":
48 if rest == "" {
49 i.subagentsStatus("", "/subagents logs <id>: missing id")
50 return
51 }
52 i.openResidentChildSession(rest)
53 case "result", "inspect":
54 if rest == "" {
55 i.subagentsStatus("", "/subagents result <id>: missing id")
56 return
57 }
58 result, err := i.cfg.ResidentManager.Result(rest)
59 if err != nil {
60 i.subagentsStatus("", "result: "+err.Error())
61 return
62 }
63 i.subagentsStatus(residentResultStatus(rest, result), "")
64 case "kill", "stop":
65 if rest == "" {
66 i.subagentsStatus("", "/subagents kill <id>: missing id")
67 return
68 }
69 if err := i.cfg.ResidentManager.Stop(ctx, rest); err != nil {
70 i.subagentsStatus("", "kill: "+err.Error())
71 return
72 }
73 i.subagentsStatus("stopped "+rest, "")
74 case "send", "prompt", "msg", "resume":
75 id, prompt := splitIDAndRest(rest)
76 if id == "" || prompt == "" {
77 i.subagentsStatus("", "/subagents "+sub+" <id> <prompt>: missing id or prompt")
78 return
79 }
80 if err := i.cfg.ResidentManager.Resume(ctx, id, prompt); err != nil {
81 i.subagentsStatus("", sub+": "+err.Error())
82 return
83 }
84 i.subagentsStatus("accepted follow-up for "+id, "")
85 default:
86 i.subagentsStatus("", "/subagents: resident child sessions support list / new / logs / result / kill / send / resume")
87 }
88}
89
90func residentResultStatus(id string, result subagents.ResidentResult) string {
91 status := fmt.Sprintf("%s result %s", residentDisplayState(result.State), id)
92 if summary := truncateStatus(result.Summary, 240); summary != "" {
93 status += ": " + summary
94 }
95 return status
96}
97
98func (i *Interactive) openResidentChildSession(childID string) {
99 session := newResidentChildSession(i.cfg.ResidentManager, childID, i.cfg.Theme)
100 i.mu.Lock()
101 if i.residentSubagentsDialog != nil {
102 i.residentSubagentsDialog.Close()
103 }
104 i.residentChildSession = session
105 i.mu.Unlock()
106 i.invalidate()
107 if session.BeginLoad() {
108 go func() {
109 err := session.LoadRecent(200)
110 if session.FinishLoad(err) {
111 i.reloadResidentChildSession(session)
112 }
113 i.invalidate()
114 }()
115 }
116}
117
118func (i *Interactive) spawnResidentSubagent(rest string) {
119 if i.cfg.SpawnResident == nil {
120 i.subagentsStatus("", "spawn: resident construction is unavailable")
121 return
122 }
123 model, providerID, reasoning, profileName, task := parseSpawnFlags(rest)
124 if task == "" {
125 i.subagentsStatus("", "/subagents new <task>: missing task")
126 return
127 }
128 var profile *subagents.Profile
129 var fastModeOverride *bool
130 if profileName != "" {
131 if i.cfg.ResolveSubagent == nil {
132 i.subagentsStatus("", "spawn: named subagent profiles are unavailable")
133 return
134 }
135 resolved, err := i.cfg.ResolveSubagent(profileName)
136 if err != nil || resolved == nil {
137 if err != nil {
138 i.subagentsStatus("", "spawn: "+err.Error())
139 } else {
140 i.subagentsStatus("", "spawn: unknown subagent profile "+profileName)
141 }
142 return
143 }
144 profile = resolved
145 fastModeOverride = profile.FastMode
146 if model == "" {
147 providerID, model = profile.ModelSelection()
148 }
149 if reasoning == "" {
150 reasoning = profile.Thinking
151 }
152 }
153 if model == "" {
154 model = i.cfg.Model
155 }
156 if providerID == "" {
157 providerID = i.cfg.Provider
158 }
159 if reasoning == "" {
160 reasoning = i.cfg.Reasoning
161 }
162 if reasoning != "" {
163 var err error
164 reasoning, err = normalizeSpawnReasoning(reasoning)
165 if err != nil {
166 i.subagentsStatus("", "spawn: "+err.Error())
167 return
168 }
169 }
170 childID, err := i.cfg.SpawnResident(context.Background(), tools.ResidentSpawnRequest{Task: task, Profile: profile, Model: model, Provider: providerID, Reasoning: reasoning, FastMode: fastModeOverride})
171 if err != nil {
172 i.subagentsStatus("", "spawn: "+err.Error())
173 return
174 }
175 i.subagentsStatus("spawned "+childID, "")
176}
177
178// normalizeSpawnReasoning validates and canonicalizes the reasoning levels
179// accepted by ParseArgs before a child process is launched.
180func normalizeSpawnReasoning(value string) (string, error) {
181 raw := strings.ToLower(strings.TrimSpace(value))
182 switch raw {
183 case "", "off":
184 return raw, nil
185 case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max":
186 return provider.NormalizeReasoning(raw), nil
187 default:
188 return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
189 }
190}
191
192// parseSpawnFlags consumes leading profile/model/provider/reasoning flags
193// using both separate-value and equals forms. Flags after the task remain
194// part of the task text.
195func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) {
196 fields := strings.Fields(s)
197 i := 0
198 for i < len(fields) {
199 f := fields[i]
200 switch {
201 case f == "--agent":
202 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
203 subagent = fields[i+1]
204 i += 2
205 } else {
206 i++
207 }
208 continue
209 case strings.HasPrefix(f, "--agent="):
210 subagent = strings.TrimPrefix(f, "--agent=")
211 i++
212 continue
213 case f == "--model":
214 // Consume the flag even when no value follows so a
215 // dangling "--model" doesn't leak into the task. The
216 // caller surfaces "missing task" instead.
217 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
218 model = fields[i+1]
219 i += 2
220 } else {
221 i++
222 }
223 continue
224 case strings.HasPrefix(f, "--model="):
225 model = strings.TrimPrefix(f, "--model=")
226 i++
227 continue
228 case f == "--provider":
229 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
230 provider = fields[i+1]
231 i += 2
232 } else {
233 i++
234 }
235 continue
236 case strings.HasPrefix(f, "--provider="):
237 provider = strings.TrimPrefix(f, "--provider=")
238 i++
239 continue
240 case f == "--reasoning" || f == "--thinking":
241 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
242 reasoning = fields[i+1]
243 i += 2
244 } else {
245 i++
246 }
247 continue
248 case strings.HasPrefix(f, "--reasoning="):
249 reasoning = strings.TrimPrefix(f, "--reasoning=")
250 i++
251 continue
252 case strings.HasPrefix(f, "--thinking="):
253 reasoning = strings.TrimPrefix(f, "--thinking=")
254 i++
255 continue
256 }
257 break
258 }
259 task = strings.TrimSpace(strings.Join(fields[i:], " "))
260 return
261}
262
263// splitIDAndRest splits "<id> <text...>" into (id, text). The text
264// half preserves all whitespace after the first token so the agent
265// receives the user's prompt verbatim (modulo a single trim of the
266// boundary space). Returns ("", "") when s is empty so the caller
267// can surface a missing-id error.
268func splitIDAndRest(s string) (id, text string) {
269 s = strings.TrimLeft(s, " \t")
270 if s == "" {
271 return "", ""
272 }
273 cut := strings.IndexAny(s, " \t")
274 if cut < 0 {
275 return s, ""
276 }
277 return s[:cut], strings.TrimLeft(s[cut+1:], " \t")
278}
279
280func truncateStatus(value string, max int) string {
281 value = strings.ReplaceAll(strings.TrimSpace(value), "\n", " ")
282 if len([]rune(value)) <= max {
283 return value
284 }
285 if max <= 3 {
286 return string([]rune(value)[:max])
287 }
288 return string([]rune(value)[:max-3]) + "..."
289}
290
291func (i *Interactive) subagentsStatus(ok, errMsg string) {
292 i.mu.Lock()
293 i.statusOK = ok
294 i.statusErr = errMsg
295 i.mu.Unlock()
296 i.invalidate()
297}