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