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