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.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript)
81 i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
82 if i.cfg.LoggedInProviders != nil {
83 i.subagentsDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
84 }
85
86 switch sub {
87 case "", "list", "ls", "ps":
88 i.subagentsDialog.Open(
89 i.cfg.Supervisor.SnapshotAll,
90 i.cfg.Supervisor.Stop,
91 i.cfg.Supervisor.Remove,
92 spawnAdapter,
93 sendAdapter,
94 resumeAdapter,
95 i.cfg.CWD,
96 )
97 case "new", "spawn":
98 if rest == "" {
99 i.subagentsStatus("", "/subagents new <task>: missing task")
100 return
101 }
102 // Permit profile/model/provider/reasoning flags before the task
103 // so scripts can pin a child without going through the dialog.
104 // Anything that isn't a recognised flag terminates parsing and
105 // the rest becomes the task; only leading flags are consumed.
106 model, provider, reasoning, subagent, task := parseSpawnFlags(rest)
107 var profileTools []string
108 var fastModeOverride *bool
109 if task == "" {
110 i.subagentsStatus("", "/subagents new: missing task (after any spawn flags)")
111 return
112 }
113 if subagent != "" {
114 if i.cfg.ResolveSubagent == nil {
115 i.subagentsStatus("", "spawn: named subagent profiles are unavailable")
116 return
117 }
118 profile, err := i.cfg.ResolveSubagent(subagent)
119 if err != nil {
120 i.subagentsStatus("", "spawn: "+err.Error())
121 return
122 }
123 if profile == nil {
124 i.subagentsStatus("", "spawn: unknown subagent profile "+subagent)
125 return
126 }
127 subagent = profile.Name
128 profileTools = append([]string(nil), profile.Tools...)
129 fastModeOverride = profile.FastMode
130 profileProvider, profileModel := profile.ModelSelection()
131 if model == "" {
132 model = profileModel
133 }
134 if provider == "" {
135 provider = profileProvider
136 }
137 if reasoning == "" {
138 reasoning = profile.Thinking
139 }
140 }
141 if model == "" {
142 model = i.cfg.Model
143 }
144 if provider == "" {
145 provider = i.cfg.Provider
146 }
147 if reasoning == "" {
148 reasoning = i.cfg.Reasoning
149 }
150 if reasoning != "" {
151 normalizedReasoning, err := normalizeSpawnReasoning(reasoning)
152 if err != nil {
153 i.subagentsStatus("", "spawn: "+err.Error())
154 return
155 }
156 reasoning = normalizedReasoning
157 }
158 a, err := i.cfg.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
159 Task: task, Model: model, Provider: provider, Reasoning: reasoning,
160 FastMode: fastModeOverride, Subagent: subagent,
161 Tools: profileTools,
162 })
163 if err != nil {
164 i.subagentsStatus("", "spawn: "+err.Error())
165 return
166 }
167 status := "spawned " + a.ID
168 if subagent != "" {
169 status += " (agent " + subagent + ")"
170 } else if model != "" {
171 status += " (model " + model + ")"
172 }
173 i.subagentsStatus(status, "")
174 case "kill", "stop":
175 if rest == "" {
176 i.subagentsStatus("", "/subagents kill <id>: missing id")
177 return
178 }
179 if err := i.cfg.Supervisor.Stop(rest); err != nil {
180 i.subagentsStatus("", "kill: "+err.Error())
181 return
182 }
183 i.subagentsStatus("stopped "+rest, "")
184 case "cancel":
185 if rest == "" {
186 i.subagentsStatus("", "/subagents cancel <id>: missing id")
187 return
188 }
189 if err := i.cfg.Supervisor.CancelTurn(rest); err != nil {
190 i.subagentsStatus("", "cancel: "+err.Error())
191 return
192 }
193 i.subagentsStatus("canceling turn "+rest, "")
194 case "remove", "rm":
195 if rest == "" {
196 i.subagentsStatus("", "/subagents remove <id>: missing id")
197 return
198 }
199 if err := i.cfg.Supervisor.Remove(rest); err != nil {
200 i.subagentsStatus("", "remove: "+err.Error())
201 return
202 }
203 i.subagentsStatus("removed "+rest, "")
204 case "logs", "log", "view":
205 if rest == "" {
206 i.subagentsStatus("", "/subagents logs <id>: missing id")
207 return
208 }
209 if err := i.cfg.Supervisor.LoadTranscript(rest); err != nil {
210 i.subagentsStatus("", "logs: "+err.Error())
211 return
212 }
213 ok := i.subagentsDialog.OpenViewing(
214 rest,
215 i.cfg.Supervisor.SnapshotAll,
216 i.cfg.Supervisor.Stop,
217 i.cfg.Supervisor.Remove,
218 spawnAdapter,
219 sendAdapter,
220 resumeAdapter,
221 i.cfg.CWD,
222 )
223 if !ok {
224 i.subagentsStatus("", "/subagents logs: no agent matching "+rest)
225 }
226 case "result", "inspect":
227 if rest == "" {
228 i.subagentsStatus("", "/subagents result <id>: missing id")
229 return
230 }
231 result, err := i.cfg.Supervisor.ReadResult(rest)
232 if err != nil {
233 i.subagentsStatus("", "result: "+err.Error())
234 return
235 }
236 status := fmt.Sprintf("%s result %s", result.Status, i.cfg.Supervisor.ResultReference(rest))
237 if result.Summary != "" {
238 status += ": " + truncateStatus(result.Summary, 160)
239 }
240 i.subagentsStatus(status, "")
241 case "wait":
242 if rest == "" {
243 i.subagentsStatus("", "/subagents wait <id>: missing id")
244 return
245 }
246 a := i.cfg.Supervisor.Get(rest)
247 if a == nil {
248 i.subagentsStatus("", "wait: no such agent "+rest)
249 return
250 }
251 waitWatcherDone := i.subagentsWaitWatcherDone
252 // Publish the initial state before starting the watcher. A worker may
253 // already be done, and its completion must be the final status rather
254 // than being overwritten by a late "waiting" update.
255 i.subagentsStatus("waiting for "+a.ID, "")
256 go func() {
257 defer func() {
258 if waitWatcherDone != nil {
259 waitWatcherDone()
260 }
261 }()
262 if err := a.WaitContext(ctx); err != nil {
263 return
264 }
265 // If cancellation became observable at the same time as
266 // completion, do not report a completion that the caller
267 // no longer owns.
268 if ctx.Err() == nil {
269 i.subagentsStatus("completed "+a.ID, "")
270 }
271 }()
272 case "resume-session", "resume", "reattach", "reopen":
273 if rest == "" {
274 count := i.subagentsDialog.OpenForResume(
275 i.cfg.Supervisor.SnapshotAll,
276 i.cfg.Supervisor.Stop,
277 i.cfg.Supervisor.Remove,
278 spawnAdapter,
279 sendAdapter,
280 resumeAdapter,
281 i.cfg.CWD,
282 )
283 switch count {
284 case 0:
285 i.subagentsStatus("", "/subagents resume-session: no resumable agents")
286 case 1:
287 i.subagentsStatus("1 resumable agent, press R to resume", "")
288 default:
289 i.subagentsStatus(fmt.Sprintf("%d resumable agents, ↑/↓ to pick, R to resume", count), "")
290 }
291 return
292 }
293 a, err := i.cfg.Supervisor.ResumeSession(ctx, rest)
294 if err != nil {
295 i.subagentsStatus("", "resume-session: "+err.Error())
296 return
297 }
298 i.subagentsStatus("resumed "+a.ID, "")
299 case "restart-task", "restart":
300 if rest == "" {
301 i.subagentsStatus("", "/subagents restart-task <id>: missing id")
302 return
303 }
304 a, err := i.cfg.Supervisor.RestartTask(ctx, rest)
305 if err != nil {
306 i.subagentsStatus("", "restart-task: "+err.Error())
307 return
308 }
309 i.subagentsStatus("restarted task "+a.ID, "")
310 case "send", "prompt", "msg":
311 // /subagents send <id> <text...> is the non-interactive
312 // counterpart of pressing 'p' in the dashboard. We split the
313 // joined `rest` ourselves rather than reusing the dispatcher's
314 // already-fielded args[] because the text may contain spaces
315 // the user expects to be preserved verbatim.
316 id, text := splitIDAndRest(rest)
317 if id == "" {
318 i.subagentsStatus("", "/subagents send <id> <text>: missing id")
319 return
320 }
321 if text == "" {
322 i.subagentsStatus("", "/subagents send <id> <text>: missing text")
323 return
324 }
325 if err := i.cfg.Supervisor.SendUserTurn(id, text); err != nil {
326 i.subagentsStatus("", friendlySendErr(id, err))
327 return
328 }
329 i.subagentsStatus("sent to "+id, "")
330 case "attach":
331 // PTY-reparenting is a significant chunk of work I haven't
332 // landed yet (see the design sketch). Recognise the name so
333 // /subagents attach doesn't fall through to the generic "unknown
334 // subcommand" path — that error message is misleading because
335 // it makes attach sound like a typo instead of a planned
336 // feature. Point the user at /subagents logs in the meantime.
337 i.subagentsStatus("", "/subagents attach: not implemented yet (needs PTY reparenting). Use /subagents logs "+firstWord(rest)+" to watch its transcript.")
338 default:
339 i.subagentsStatus("", "/subagents: unknown subcommand "+sub+" (try list / new / kill / cancel / remove / logs / send / result / wait / resume-session / restart-task)")
340 }
341}
342
343// normalizeSpawnReasoning validates and canonicalizes the reasoning levels
344// accepted by ParseArgs before a child process is launched.
345func normalizeSpawnReasoning(value string) (string, error) {
346 raw := strings.ToLower(strings.TrimSpace(value))
347 switch raw {
348 case "", "off":
349 return raw, nil
350 case "minimal", "minimum", "low", "medium", "high", "xhigh", "maximum", "max":
351 return provider.NormalizeReasoning(raw), nil
352 default:
353 return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
354 }
355}
356
357// parseSpawnFlags consumes leading profile/model/provider/reasoning flags
358// using both separate-value and equals forms. Flags after the task remain
359// part of the task text.
360func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task string) {
361 fields := strings.Fields(s)
362 i := 0
363 for i < len(fields) {
364 f := fields[i]
365 switch {
366 case f == "--agent":
367 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
368 subagent = fields[i+1]
369 i += 2
370 } else {
371 i++
372 }
373 continue
374 case strings.HasPrefix(f, "--agent="):
375 subagent = strings.TrimPrefix(f, "--agent=")
376 i++
377 continue
378 case f == "--model":
379 // Consume the flag even when no value follows so a
380 // dangling "--model" doesn't leak into the task. The
381 // caller surfaces "missing task" instead.
382 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
383 model = fields[i+1]
384 i += 2
385 } else {
386 i++
387 }
388 continue
389 case strings.HasPrefix(f, "--model="):
390 model = strings.TrimPrefix(f, "--model=")
391 i++
392 continue
393 case f == "--provider":
394 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
395 provider = fields[i+1]
396 i += 2
397 } else {
398 i++
399 }
400 continue
401 case strings.HasPrefix(f, "--provider="):
402 provider = strings.TrimPrefix(f, "--provider=")
403 i++
404 continue
405 case f == "--reasoning" || f == "--thinking":
406 if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") {
407 reasoning = fields[i+1]
408 i += 2
409 } else {
410 i++
411 }
412 continue
413 case strings.HasPrefix(f, "--reasoning="):
414 reasoning = strings.TrimPrefix(f, "--reasoning=")
415 i++
416 continue
417 case strings.HasPrefix(f, "--thinking="):
418 reasoning = strings.TrimPrefix(f, "--thinking=")
419 i++
420 continue
421 }
422 break
423 }
424 task = strings.TrimSpace(strings.Join(fields[i:], " "))
425 return
426}
427
428// splitIDAndRest splits "<id> <text...>" into (id, text). The text
429// half preserves all whitespace after the first token so the agent
430// receives the user's prompt verbatim (modulo a single trim of the
431// boundary space). Returns ("", "") when s is empty so the caller
432// can surface a missing-id error.
433func splitIDAndRest(s string) (id, text string) {
434 s = strings.TrimLeft(s, " \t")
435 if s == "" {
436 return "", ""
437 }
438 cut := strings.IndexAny(s, " \t")
439 if cut < 0 {
440 return s, ""
441 }
442 return s[:cut], strings.TrimLeft(s[cut+1:], " \t")
443}
444
445// firstWord returns the first whitespace-separated token of s, or
446// "<id>" when s is empty. Used to keep the "/subagents attach" hint
447// readable even when the user typed no argument.
448func firstWord(s string) string {
449 s = strings.TrimSpace(s)
450 if s == "" {
451 return "<id>"
452 }
453 if i := strings.IndexAny(s, " \t"); i >= 0 {
454 return s[:i]
455 }
456 return s
457}
458
459func truncateStatus(value string, max int) string {
460 value = strings.ReplaceAll(strings.TrimSpace(value), "\n", " ")
461 if len([]rune(value)) <= max {
462 return value
463 }
464 if max <= 3 {
465 return string([]rune(value)[:max])
466 }
467 return string([]rune(value)[:max-3]) + "..."
468}
469
470func (i *Interactive) subagentsStatus(ok, errMsg string) {
471 i.mu.Lock()
472 i.statusOK = ok
473 i.statusErr = errMsg
474 i.mu.Unlock()
475 i.invalidate()
476}