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/provider"
10 "github.com/bnema/zut/packages/tui"
11)
12
13// runSubagents dispatches /subagents subcommands. Layout:
14//
15// /subagents -> open the dashboard
16// /subagents list -> open the dashboard
17// /subagents new [--agent A] [--model M] [--provider P] [--reasoning L] <task...>
18// -> spawn an agent with optional profile/model overrides
19// /subagents kill <id> -> stop a running agent
20// /subagents cancel <id> -> cancel the active turn
21// /subagents remove <id> -> tear down a terminated agent
22// /subagents logs <id> -> open the scrollable transcript view
23// /subagents result|inspect <id> -> show the completed result
24// /subagents wait <id> -> wait for the agent to finish
25// /subagents send <id> <text...> -> send a follow-up user turn to <id>
26// /subagents resume [id] -> resume an agent (omit id to pick from a list)
27// /subagents restart-task|restart <id> -> restart the original task
28// /subagents attach <id> -> (planned) drop into the agent's TUI
29//
30// When cfg.Supervisor is nil the command tells the user the feature is
31// disabled instead of pretending to work.
32func (i *Interactive) runSubagents(ctx context.Context, args []string) {
33 if i.cfg.Supervisor == nil {
34 i.mu.Lock()
35 i.statusErr = "subagent is disabled in this build"
36 i.statusOK = ""
37 i.mu.Unlock()
38 return
39 }
40
41 sub := ""
42 rest := ""
43 if len(args) > 0 {
44 sub = strings.ToLower(args[0])
45 // Guard the args[1:] reslice: when only the subcommand was
46 // typed (e.g. bare "/subagents new"), args has length 1 and the
47 // naive args[1:] is fine, but when args is empty (bare
48 // "/subagents") the reslice is [1:0] and panics. The len>0 branch
49 // here keeps both cases safe.
50 if len(args) > 1 {
51 rest = strings.TrimSpace(strings.Join(args[1:], " "))
52 }
53 }
54
55 // spawnAdapter, sendAdapter, resumeAdapter wrap the Supervisor methods
56 // in the signatures the dialog expects. Defined once here so the
57 // three Open()-shaped entry points (list, logs/view-jump, resume)
58 // feed the dialog identical callbacks.
59 spawnAdapter := func(task, model, provider string) error {
60 _, err := i.cfg.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
61 Task: task, Model: model, Provider: provider, Reasoning: i.cfg.Reasoning,
62 })
63 return err
64 }
65 sendAdapter := func(id, text string) error {
66 return i.cfg.Supervisor.SendUserTurn(id, text)
67 }
68 resumeAdapter := func(id string) error {
69 _, err := i.cfg.Supervisor.ResumeSession(ctx, id)
70 return err
71 }
72
73 // Pin every fresh spawn to whatever the host's /model selection
74 // is right now. This is captured at /subagents time — if the user
75 // wants a different model for the next subagent agent, they pick it
76 // via /model first (globally), or, while inside the spawn
77 // editor, by typing /model on its own line to pop the picker.
78 i.subagentsDialog.SetCompactMode(i.compactModeEnabled())
79 i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
80 i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
81 if i.cfg.LoggedInProviders != nil {
82 i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
83 }
84
85 switch sub {
86 case "", "list", "ls", "ps":
87 i.subagentsDialog.Open(
88 i.cfg.Supervisor.SnapshotAll,
89 i.cfg.Supervisor.Stop,
90 i.cfg.Supervisor.Remove,
91 spawnAdapter,
92 sendAdapter,
93 resumeAdapter,
94 i.cfg.CWD,
95 )
96 case "new", "spawn":
97 if rest == "" {
98 i.subagentsStatus("", "/subagents new <task>: missing task")
99 return
100 }
101 // Permit profile/model/provider/reasoning flags before the task
102 // so scripts can pin a child without going through the dialog.
103 // Anything that isn't a recognised flag terminates parsing and
104 // the rest becomes the task; only leading flags are consumed.
105 model, provider, reasoning, subagent, task := parseSpawnFlags(rest)
106 var profileTools []string
107 var fastModeOverride *bool
108 if task == "" {
109 i.subagentsStatus("", "/subagents new: missing task (after any spawn flags)")
110 return
111 }
112 if subagent != "" {
113 if i.cfg.ResolveSubagent == nil {
114 i.subagentsStatus("", "spawn: named subagent profiles are unavailable")
115 return
116 }
117 profile, err := i.cfg.ResolveSubagent(subagent)
118 if err != nil {
119 i.subagentsStatus("", "spawn: "+err.Error())
120 return
121 }
122 if profile == nil {
123 i.subagentsStatus("", "spawn: unknown subagent profile "+subagent)
124 return
125 }
126 subagent = profile.Name
127 profileTools = append([]string(nil), profile.Tools...)
128 fastModeOverride = profile.FastMode
129 profileProvider, profileModel := profile.ModelSelection()
130 if model == "" {
131 model = profileModel
132 }
133 if provider == "" {
134 provider = profileProvider
135 }
136 if reasoning == "" {
137 reasoning = profile.Thinking
138 }
139 }
140 if model == "" {
141 model = i.cfg.Model
142 }
143 if provider == "" {
144 provider = i.cfg.Provider
145 }
146 if reasoning == "" {
147 reasoning = i.cfg.Reasoning
148 }
149 if reasoning != "" {
150 normalizedReasoning, err := normalizeSpawnReasoning(reasoning)
151 if err != nil {
152 i.subagentsStatus("", "spawn: "+err.Error())
153 return
154 }
155 reasoning = normalizedReasoning
156 }
157 a, err := i.cfg.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
158 Task: task, Model: model, Provider: provider, Reasoning: reasoning,
159 FastMode: fastModeOverride, Subagent: subagent,
160 Tools: profileTools,
161 })
162 if err != nil {
163 i.subagentsStatus("", "spawn: "+err.Error())
164 return
165 }
166 status := "spawned " + a.ID
167 if subagent != "" {
168 status += " (agent " + subagent + ")"
169 } else if model != "" {
170 status += " (model " + model + ")"
171 }
172 i.subagentsStatus(status, "")
173 case "kill", "stop":
174 if rest == "" {
175 i.subagentsStatus("", "/subagents kill <id>: missing id")
176 return
177 }
178 if err := i.cfg.Supervisor.Stop(rest); err != nil {
179 i.subagentsStatus("", "kill: "+err.Error())
180 return
181 }
182 i.subagentsStatus("stopped "+rest, "")
183 case "cancel":
184 if rest == "" {
185 i.subagentsStatus("", "/subagents cancel <id>: missing id")
186 return
187 }
188 if err := i.cfg.Supervisor.CancelTurn(rest); err != nil {
189 i.subagentsStatus("", "cancel: "+err.Error())
190 return
191 }
192 i.subagentsStatus("canceling turn "+rest, "")
193 case "remove", "rm":
194 if rest == "" {
195 i.subagentsStatus("", "/subagents remove <id>: missing id")
196 return
197 }
198 if err := i.cfg.Supervisor.Remove(rest); err != nil {
199 i.subagentsStatus("", "remove: "+err.Error())
200 return
201 }
202 i.subagentsStatus("removed "+rest, "")
203 case "logs", "log", "view":
204 if rest == "" {
205 i.subagentsStatus("", "/subagents logs <id>: missing id")
206 return
207 }
208 ok := i.subagentsDialog.OpenViewing(
209 rest,
210 i.cfg.Supervisor.SnapshotAll,
211 i.cfg.Supervisor.Stop,
212 i.cfg.Supervisor.Remove,
213 spawnAdapter,
214 sendAdapter,
215 resumeAdapter,
216 i.cfg.CWD,
217 )
218 if !ok {
219 i.subagentsStatus("", "/subagents logs: no agent matching "+rest)
220 }
221 case "result", "inspect":
222 if rest == "" {
223 i.subagentsStatus("", "/subagents result <id>: missing id")
224 return
225 }
226 result, err := i.cfg.Supervisor.ReadResult(rest)
227 if err != nil {
228 i.subagentsStatus("", "result: "+err.Error())
229 return
230 }
231 status := fmt.Sprintf("%s result %s", result.Status, i.cfg.Supervisor.ResultReference(rest))
232 if result.Summary != "" {
233 status += ": " + truncateStatus(result.Summary, 160)
234 }
235 i.subagentsStatus(status, "")
236 case "wait":
237 if rest == "" {
238 i.subagentsStatus("", "/subagents wait <id>: missing id")
239 return
240 }
241 a := i.cfg.Supervisor.Get(rest)
242 if a == nil {
243 i.subagentsStatus("", "wait: no such agent "+rest)
244 return
245 }
246 waitWatcherDone := i.subagentsWaitWatcherDone
247 go func() {
248 defer func() {
249 if waitWatcherDone != nil {
250 waitWatcherDone()
251 }
252 }()
253 if err := a.WaitContext(ctx); err != nil {
254 return
255 }
256 // If cancellation became observable at the same time as
257 // completion, do not report a completion that the caller
258 // no longer owns.
259 if ctx.Err() == nil {
260 i.subagentsStatus("completed "+a.ID, "")
261 }
262 }()
263 i.subagentsStatus("waiting for "+a.ID, "")
264 case "resume-session", "resume", "reattach", "reopen":
265 if rest == "" {
266 count := i.subagentsDialog.OpenForResume(
267 i.cfg.Supervisor.SnapshotAll,
268 i.cfg.Supervisor.Stop,
269 i.cfg.Supervisor.Remove,
270 spawnAdapter,
271 sendAdapter,
272 resumeAdapter,
273 i.cfg.CWD,
274 )
275 switch count {
276 case 0:
277 i.subagentsStatus("", "/subagents resume-session: no resumable agents")
278 case 1:
279 i.subagentsStatus("1 resumable agent, press R to resume", "")
280 default:
281 i.subagentsStatus(fmt.Sprintf("%d resumable agents, ↑/↓ to pick, R to resume", count), "")
282 }
283 return
284 }
285 a, err := i.cfg.Supervisor.ResumeSession(ctx, rest)
286 if err != nil {
287 i.subagentsStatus("", "resume-session: "+err.Error())
288 return
289 }
290 i.subagentsStatus("resumed "+a.ID, "")
291 case "restart-task", "restart":
292 if rest == "" {
293 i.subagentsStatus("", "/subagents restart-task <id>: missing id")
294 return
295 }
296 a, err := i.cfg.Supervisor.RestartTask(ctx, rest)
297 if err != nil {
298 i.subagentsStatus("", "restart-task: "+err.Error())
299 return
300 }
301 i.subagentsStatus("restarted task "+a.ID, "")
302 case "send", "prompt", "msg":
303 // /subagents send <id> <text...> is the non-interactive
304 // counterpart of pressing 'p' in the dashboard. We split the
305 // joined `rest` ourselves rather than reusing the dispatcher's
306 // already-fielded args[] because the text may contain spaces
307 // the user expects to be preserved verbatim.
308 id, text := splitIDAndRest(rest)
309 if id == "" {
310 i.subagentsStatus("", "/subagents send <id> <text>: missing id")
311 return
312 }
313 if text == "" {
314 i.subagentsStatus("", "/subagents send <id> <text>: missing text")
315 return
316 }
317 if err := i.cfg.Supervisor.SendUserTurn(id, text); err != nil {
318 i.subagentsStatus("", friendlySendErr(id, err))
319 return
320 }
321 i.subagentsStatus("sent to "+id, "")
322 case "attach":
323 // PTY-reparenting is a significant chunk of work I haven't
324 // landed yet (see the design sketch). Recognise the name so
325 // /subagents attach doesn't fall through to the generic "unknown
326 // subcommand" path — that error message is misleading because
327 // it makes attach sound like a typo instead of a planned
328 // feature. Point the user at /subagents logs in the meantime.
329 i.subagentsStatus("", "/subagents attach: not implemented yet (needs PTY reparenting). Use /subagents logs "+firstWord(rest)+" to watch its transcript.")
330 default:
331 i.subagentsStatus("", "/subagents: unknown subcommand "+sub+" (try list / new / kill / cancel / remove / logs / send / result / wait / resume-session / restart-task)")
332 }
333}
334
335// normalizeSpawnReasoning validates and canonicalizes the reasoning levels
336// accepted by ParseArgs before a child process is launched.
337func normalizeSpawnReasoning(value string) (string, error) {
338 raw := strings.ToLower(strings.TrimSpace(value))
339 switch raw {
340 case "", "off":
341 return raw, nil
342 case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max":
343 return provider.NormalizeReasoning(raw), nil
344 default:
345 return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
346 }
347}
348
349// parseSpawnFlags consumes leading profile/model/provider/reasoning flags
350// using both separate-value and equals forms. Flags after the task remain
351// part of the task text.
352func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) {
353 fields := strings.Fields(s)
354 i := 0
355 for i < len(fields) {
356 f := fields[i]
357 switch {
358 case f == "--agent":
359 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
360 subagent = fields[i+1]
361 i += 2
362 } else {
363 i++
364 }
365 continue
366 case strings.HasPrefix(f, "--agent="):
367 subagent = strings.TrimPrefix(f, "--agent=")
368 i++
369 continue
370 case f == "--model":
371 // Consume the flag even when no value follows so a
372 // dangling "--model" doesn't leak into the task. The
373 // caller surfaces "missing task" instead.
374 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
375 model = fields[i+1]
376 i += 2
377 } else {
378 i++
379 }
380 continue
381 case strings.HasPrefix(f, "--model="):
382 model = strings.TrimPrefix(f, "--model=")
383 i++
384 continue
385 case f == "--provider":
386 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
387 provider = fields[i+1]
388 i += 2
389 } else {
390 i++
391 }
392 continue
393 case strings.HasPrefix(f, "--provider="):
394 provider = strings.TrimPrefix(f, "--provider=")
395 i++
396 continue
397 case f == "--reasoning" || f == "--thinking":
398 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
399 reasoning = fields[i+1]
400 i += 2
401 } else {
402 i++
403 }
404 continue
405 case strings.HasPrefix(f, "--reasoning="):
406 reasoning = strings.TrimPrefix(f, "--reasoning=")
407 i++
408 continue
409 case strings.HasPrefix(f, "--thinking="):
410 reasoning = strings.TrimPrefix(f, "--thinking=")
411 i++
412 continue
413 }
414 break
415 }
416 task = strings.TrimSpace(strings.Join(fields[i:], " "))
417 return
418}
419
420// splitIDAndRest splits "<id> <text...>" into (id, text). The text
421// half preserves all whitespace after the first token so the agent
422// receives the user's prompt verbatim (modulo a single trim of the
423// boundary space). Returns ("", "") when s is empty so the caller
424// can surface a missing-id error.
425func splitIDAndRest(s string) (id, text string) {
426 s = strings.TrimLeft(s, " \t")
427 if s == "" {
428 return "", ""
429 }
430 cut := strings.IndexAny(s, " \t")
431 if cut < 0 {
432 return s, ""
433 }
434 return s[:cut], strings.TrimLeft(s[cut+1:], " \t")
435}
436
437// firstWord returns the first whitespace-separated token of s, or
438// "<id>" when s is empty. Used to keep the "/subagents attach" hint
439// readable even when the user typed no argument.
440func firstWord(s string) string {
441 s = strings.TrimSpace(s)
442 if s == "" {
443 return "<id>"
444 }
445 if i := strings.IndexAny(s, " \t"); i >= 0 {
446 return s[:i]
447 }
448 return s
449}
450
451func truncateStatus(value string, max int) string {
452 value = strings.ReplaceAll(strings.TrimSpace(value), "\n", " ")
453 if len([]rune(value)) <= max {
454 return value
455 }
456 if max <= 3 {
457 return string([]rune(value)[:max])
458 }
459 return string([]rune(value)[:max-3]) + "..."
460}
461
462func (i *Interactive) subagentsStatus(ok, errMsg string) {
463 i.mu.Lock()
464 i.statusOK = ok
465 i.statusErr = errMsg
466 i.mu.Unlock()
467 i.invalidate()
468}