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 text := result.Summary
93 if result.Handoff != "" {
94 text = result.Handoff
95 }
96 if summary := truncateStatus(sanitizeSessionTreeText(text), 240); summary != "" {
97 status += ": " + summary
98 }
99 return status
100}
101
102func (i *Interactive) openResidentChildSession(childID string) {
103 session := newResidentChildSession(i.cfg.ResidentManager, childID, i.cfg.Theme)
104 i.mu.Lock()
105 previous := i.residentChildSession
106 if i.residentSubagentsDialog != nil {
107 i.residentSubagentsDialog.Close()
108 }
109 i.residentChildSession = session
110 i.mu.Unlock()
111 if previous != nil {
112 previous.Close()
113 }
114 i.setResidentChildMouseReporting(true)
115 i.invalidate()
116 if session.BeginLoad() {
117 go func() {
118 err := session.LoadAll(200, i.invalidate)
119 if session.FinishLoad(err) {
120 i.reloadResidentChildSession(session)
121 }
122 i.invalidate()
123 }()
124 }
125}
126
127func (i *Interactive) spawnResidentSubagent(rest string) {
128 if i.cfg.SpawnResident == nil {
129 i.subagentsStatus("", "spawn: resident construction is unavailable")
130 return
131 }
132 model, providerID, reasoning, profileName, task := parseSpawnFlags(rest)
133 if task == "" {
134 i.subagentsStatus("", "/subagents new <task>: missing task")
135 return
136 }
137 var profile *subagents.Profile
138 var fastModeOverride *bool
139 if profileName != "" {
140 if i.cfg.ResolveSubagent == nil {
141 i.subagentsStatus("", "spawn: named subagent profiles are unavailable")
142 return
143 }
144 resolved, err := i.cfg.ResolveSubagent(profileName)
145 if err != nil || resolved == nil {
146 if err != nil {
147 i.subagentsStatus("", "spawn: "+err.Error())
148 } else {
149 i.subagentsStatus("", "spawn: unknown subagent profile "+profileName)
150 }
151 return
152 }
153 profile = resolved
154 fastModeOverride = profile.FastMode
155 if model == "" {
156 providerID, model = profile.ModelSelection()
157 }
158 if reasoning == "" {
159 reasoning = profile.Thinking
160 }
161 }
162 if model == "" {
163 model = i.cfg.Model
164 }
165 if providerID == "" {
166 providerID = i.cfg.Provider
167 }
168 if reasoning == "" {
169 reasoning = i.cfg.Reasoning
170 }
171 if reasoning != "" {
172 var err error
173 reasoning, err = normalizeSpawnReasoning(reasoning)
174 if err != nil {
175 i.subagentsStatus("", "spawn: "+err.Error())
176 return
177 }
178 }
179 childID, err := i.cfg.SpawnResident(context.Background(), tools.ResidentSpawnRequest{Task: task, Profile: profile, Model: model, Provider: providerID, Reasoning: reasoning, FastMode: fastModeOverride})
180 if err != nil {
181 i.subagentsStatus("", "spawn: "+err.Error())
182 return
183 }
184 i.subagentsStatus("spawned "+childID, "")
185}
186
187// normalizeSpawnReasoning validates and canonicalizes the reasoning levels
188// accepted by ParseArgs before a child process is launched.
189func normalizeSpawnReasoning(value string) (string, error) {
190 raw := strings.ToLower(strings.TrimSpace(value))
191 switch raw {
192 case "", "off":
193 return raw, nil
194 case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max":
195 return provider.NormalizeReasoning(raw), nil
196 default:
197 return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
198 }
199}
200
201// parseSpawnFlags consumes leading profile/model/provider/reasoning flags
202// using both separate-value and equals forms. Flags after the task remain
203// part of the task text.
204func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) {
205 fields := strings.Fields(s)
206 i := 0
207 for i < len(fields) {
208 f := fields[i]
209 switch {
210 case f == "--agent":
211 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
212 subagent = fields[i+1]
213 i += 2
214 } else {
215 i++
216 }
217 continue
218 case strings.HasPrefix(f, "--agent="):
219 subagent = strings.TrimPrefix(f, "--agent=")
220 i++
221 continue
222 case f == "--model":
223 // Consume the flag even when no value follows so a
224 // dangling "--model" doesn't leak into the task. The
225 // caller surfaces "missing task" instead.
226 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
227 model = fields[i+1]
228 i += 2
229 } else {
230 i++
231 }
232 continue
233 case strings.HasPrefix(f, "--model="):
234 model = strings.TrimPrefix(f, "--model=")
235 i++
236 continue
237 case f == "--provider":
238 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
239 provider = fields[i+1]
240 i += 2
241 } else {
242 i++
243 }
244 continue
245 case strings.HasPrefix(f, "--provider="):
246 provider = strings.TrimPrefix(f, "--provider=")
247 i++
248 continue
249 case f == "--reasoning" || f == "--thinking":
250 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
251 reasoning = fields[i+1]
252 i += 2
253 } else {
254 i++
255 }
256 continue
257 case strings.HasPrefix(f, "--reasoning="):
258 reasoning = strings.TrimPrefix(f, "--reasoning=")
259 i++
260 continue
261 case strings.HasPrefix(f, "--thinking="):
262 reasoning = strings.TrimPrefix(f, "--thinking=")
263 i++
264 continue
265 }
266 break
267 }
268 task = strings.TrimSpace(strings.Join(fields[i:], " "))
269 return
270}
271
272// splitIDAndRest splits "<id> <text...>" into (id, text). The text
273// half preserves all whitespace after the first token so the agent
274// receives the user's prompt verbatim (modulo a single trim of the
275// boundary space). Returns ("", "") when s is empty so the caller
276// can surface a missing-id error.
277func splitIDAndRest(s string) (id, text string) {
278 s = strings.TrimLeft(s, " \t")
279 if s == "" {
280 return "", ""
281 }
282 cut := strings.IndexAny(s, " \t")
283 if cut < 0 {
284 return s, ""
285 }
286 return s[:cut], strings.TrimLeft(s[cut+1:], " \t")
287}
288
289func truncateStatus(value string, max int) string {
290 value = strings.ReplaceAll(strings.TrimSpace(value), "\n", " ")
291 if len([]rune(value)) <= max {
292 return value
293 }
294 if max <= 3 {
295 return string([]rune(value)[:max])
296 }
297 return string([]rune(value)[:max-3]) + "..."
298}
299
300func (i *Interactive) subagentsStatus(ok, errMsg string) {
301 i.mu.Lock()
302 i.statusOK = ok
303 i.statusErr = errMsg
304 i.mu.Unlock()
305 i.invalidate()
306}