1package modes
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "github.com/patriceckhart/zot/packages/agent/swarm"
9)
10
11// runSwarm dispatches /swarm subcommands. Layout:
12//
13// /swarm -> open the dashboard
14// /swarm list -> open the dashboard
15// /swarm new [--model M] [--provider P] <task...>
16// -> spawn an agent (optionally pinned to a model)
17// /swarm kill <id> -> stop a running agent
18// /swarm remove <id> -> tear down a terminated agent
19// /swarm logs <id> -> open the scrollable transcript view
20// /swarm send <id> <text...> -> send a follow-up user turn to <id>
21// /swarm resume [id] -> resume an agent (omit id to pick from a list)
22// /swarm attach <id> -> (planned) drop into the agent's TUI
23//
24// When cfg.Swarm is nil the command tells the user the feature is
25// disabled instead of pretending to work.
26func (i *Interactive) runSwarm(ctx context.Context, args []string) {
27 if i.cfg.Swarm == nil {
28 i.mu.Lock()
29 i.statusErr = "swarm is disabled in this build"
30 i.statusOK = ""
31 i.mu.Unlock()
32 return
33 }
34
35 sub := ""
36 rest := ""
37 if len(args) > 0 {
38 sub = strings.ToLower(args[0])
39 // Guard the args[1:] reslice: when only the subcommand was
40 // typed (e.g. bare "/swarm new"), args has length 1 and the
41 // naive args[1:] is fine, but when args is empty (bare
42 // "/swarm") the reslice is [1:0] and panics. The len>0 branch
43 // here keeps both cases safe.
44 if len(args) > 1 {
45 rest = strings.TrimSpace(strings.Join(args[1:], " "))
46 }
47 }
48
49 // spawnAdapter, sendAdapter, resumeAdapter wrap the Swarm methods
50 // in the signatures the dialog expects. Defined once here so the
51 // three Open()-shaped entry points (list, logs/view-jump, resume)
52 // feed the dialog identical callbacks.
53 spawnAdapter := func(task, model, provider string) error {
54 _, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
55 Task: task, Model: model, Provider: provider,
56 })
57 return err
58 }
59 sendAdapter := func(id, text string) error {
60 return i.cfg.Swarm.SendUserTurn(id, text)
61 }
62 resumeAdapter := func(id string) error {
63 _, err := i.cfg.Swarm.Resume(ctx, id)
64 return err
65 }
66
67 // Pin every fresh spawn to whatever the host's /model selection
68 // is right now. This is captured at /swarm time — if the user
69 // wants a different model for the next swarm agent, they pick it
70 // via /model first (globally), or, while inside the spawn
71 // editor, by typing /model on its own line to pop the picker.
72 i.swarmDialog.SetCompactMode(i.compactModeEnabled())
73 i.swarmDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
74 if i.cfg.LoggedInProviders != nil {
75 i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
76 }
77
78 switch sub {
79 case "", "list", "ls", "ps":
80 i.swarmDialog.Open(
81 i.cfg.Swarm.SnapshotAll,
82 i.cfg.Swarm.Stop,
83 i.cfg.Swarm.Remove,
84 spawnAdapter,
85 sendAdapter,
86 resumeAdapter,
87 i.cfg.CWD,
88 )
89 case "new", "spawn":
90 if rest == "" {
91 i.swarmStatus("", "/swarm new <task>: missing task")
92 return
93 }
94 // Permit `--model X --provider Y` flags before the task so
95 // scripts can pin a model without going through the dialog.
96 // Anything that isn't a recognised flag terminates parsing
97 // and the rest becomes the task; this keeps `/swarm new
98 // --model foo do a thing` and `/swarm new do --model thing`
99 // (where --model is part of the task) unambiguous — only
100 // leading flags are consumed.
101 model, provider, task := parseSpawnFlags(rest)
102 if task == "" {
103 i.swarmStatus("", "/swarm new: missing task (after any --model/--provider flags)")
104 return
105 }
106 a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
107 Task: task, Model: model, Provider: provider,
108 })
109 if err != nil {
110 i.swarmStatus("", "spawn: "+err.Error())
111 return
112 }
113 if model != "" {
114 i.swarmStatus("spawned "+a.ID+" (model "+model+")", "")
115 } else {
116 i.swarmStatus("spawned "+a.ID, "")
117 }
118 case "kill", "stop":
119 if rest == "" {
120 i.swarmStatus("", "/swarm kill <id>: missing id")
121 return
122 }
123 if err := i.cfg.Swarm.Stop(rest); err != nil {
124 i.swarmStatus("", "kill: "+err.Error())
125 return
126 }
127 i.swarmStatus("stopped "+rest, "")
128 case "remove", "rm":
129 if rest == "" {
130 i.swarmStatus("", "/swarm remove <id>: missing id")
131 return
132 }
133 if err := i.cfg.Swarm.Remove(rest); err != nil {
134 i.swarmStatus("", "remove: "+err.Error())
135 return
136 }
137 i.swarmStatus("removed "+rest, "")
138 case "logs", "log", "view":
139 if rest == "" {
140 i.swarmStatus("", "/swarm logs <id>: missing id")
141 return
142 }
143 ok := i.swarmDialog.OpenViewing(
144 rest,
145 i.cfg.Swarm.SnapshotAll,
146 i.cfg.Swarm.Stop,
147 i.cfg.Swarm.Remove,
148 spawnAdapter,
149 sendAdapter,
150 resumeAdapter,
151 i.cfg.CWD,
152 )
153 if !ok {
154 i.swarmStatus("", "/swarm logs: no agent matching "+rest)
155 }
156 case "resume", "reattach", "reopen":
157 if rest == "" {
158 // No id given: open the dashboard with the cursor
159 // pre-positioned on the first resumable agent, and
160 // tell the user how many there are so they know what
161 // to expect. Pressing R confirms; ↑/↓ to pick a
162 // different row first.
163 count := i.swarmDialog.OpenForResume(
164 i.cfg.Swarm.SnapshotAll,
165 i.cfg.Swarm.Stop,
166 i.cfg.Swarm.Remove,
167 spawnAdapter,
168 sendAdapter,
169 resumeAdapter,
170 i.cfg.CWD,
171 )
172 switch count {
173 case 0:
174 i.swarmStatus("", "/swarm resume: no resumable agents (none detached or terminated)")
175 case 1:
176 i.swarmStatus("1 resumable agent, press R to resume", "")
177 default:
178 i.swarmStatus(fmt.Sprintf("%d resumable agents, ↑/↓ to pick, R to resume", count), "")
179 }
180 return
181 }
182 a, err := i.cfg.Swarm.Resume(ctx, rest)
183 if err != nil {
184 i.swarmStatus("", "resume: "+err.Error())
185 return
186 }
187 i.swarmStatus("resumed "+a.ID, "")
188 case "send", "prompt", "msg":
189 // /swarm send <id> <text...> is the non-interactive
190 // counterpart of pressing 'p' in the dashboard. We split the
191 // joined `rest` ourselves rather than reusing the dispatcher's
192 // already-fielded args[] because the text may contain spaces
193 // the user expects to be preserved verbatim.
194 id, text := splitIDAndRest(rest)
195 if id == "" {
196 i.swarmStatus("", "/swarm send <id> <text>: missing id")
197 return
198 }
199 if text == "" {
200 i.swarmStatus("", "/swarm send <id> <text>: missing text")
201 return
202 }
203 if err := i.cfg.Swarm.SendUserTurn(id, text); err != nil {
204 i.swarmStatus("", friendlySendErr(id, err))
205 return
206 }
207 i.swarmStatus("sent to "+id, "")
208 case "attach":
209 // PTY-reparenting is a significant chunk of work I haven't
210 // landed yet (see the design sketch). Recognise the name so
211 // /swarm attach doesn't fall through to the generic "unknown
212 // subcommand" path — that error message is misleading because
213 // it makes attach sound like a typo instead of a planned
214 // feature. Point the user at /swarm logs in the meantime.
215 i.swarmStatus("", "/swarm attach: not implemented yet (needs PTY reparenting). Use /swarm logs "+firstWord(rest)+" to watch its transcript.")
216 default:
217 i.swarmStatus("", "/swarm: unknown subcommand "+sub+" (try list / new / kill / remove / logs / send / resume)")
218 }
219}
220
221// parseSpawnFlags consumes any leading `--model X` / `--provider Y`
222// flags from s and returns them along with the remaining task body.
223// We deliberately only honour LEADING flags so a task like "check
224// --model lookup" doesn't accidentally swallow part of its prose as
225// the model name.
226//
227// Recognised forms:
228//
229// --model X two-token form
230// --model=X single-token form
231// --provider X two-token form
232// --provider=X single-token form
233func parseSpawnFlags(s string) (model, provider, task string) {
234 fields := strings.Fields(s)
235 i := 0
236 for i < len(fields) {
237 f := fields[i]
238 switch {
239 case f == "--model":
240 // Consume the flag even when no value follows so a
241 // dangling "--model" doesn't leak into the task. The
242 // caller surfaces "missing task" instead.
243 if i+1 < len(fields) {
244 model = fields[i+1]
245 i += 2
246 } else {
247 i++
248 }
249 continue
250 case strings.HasPrefix(f, "--model="):
251 model = strings.TrimPrefix(f, "--model=")
252 i++
253 continue
254 case f == "--provider":
255 if i+1 < len(fields) {
256 provider = fields[i+1]
257 i += 2
258 } else {
259 i++
260 }
261 continue
262 case strings.HasPrefix(f, "--provider="):
263 provider = strings.TrimPrefix(f, "--provider=")
264 i++
265 continue
266 }
267 break
268 }
269 task = strings.TrimSpace(strings.Join(fields[i:], " "))
270 return
271}
272
273// splitIDAndRest splits "<id> <text...>" into (id, text). The text
274// half preserves all whitespace after the first token so the agent
275// receives the user's prompt verbatim (modulo a single trim of the
276// boundary space). Returns ("", "") when s is empty so the caller
277// can surface a missing-id error.
278func splitIDAndRest(s string) (id, text string) {
279 s = strings.TrimLeft(s, " \t")
280 if s == "" {
281 return "", ""
282 }
283 cut := strings.IndexAny(s, " \t")
284 if cut < 0 {
285 return s, ""
286 }
287 return s[:cut], strings.TrimLeft(s[cut+1:], " \t")
288}
289
290// firstWord returns the first whitespace-separated token of s, or
291// "<id>" when s is empty. Used to keep the "/swarm attach" hint
292// readable even when the user typed no argument.
293func firstWord(s string) string {
294 s = strings.TrimSpace(s)
295 if s == "" {
296 return "<id>"
297 }
298 if i := strings.IndexAny(s, " \t"); i >= 0 {
299 return s[:i]
300 }
301 return s
302}
303
304func (i *Interactive) swarmStatus(ok, errMsg string) {
305 i.mu.Lock()
306 i.statusOK = ok
307 i.statusErr = errMsg
308 i.mu.Unlock()
309 i.invalidate()
310}