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