detached 80c0ac9

feat: auto-swarm summary, system-prompt nudge, README/docs

- Sub-agents are long-lived daemons that keep running on the inbox
  after the initial task, so agent.Wait() never unblocks for them.
  Replaced the Wait-based watcher with a per-turn OnTurnEnd callback:
  Agent.SetOnTurnEnd installs it under the agent mutex, the runner's
  stdout decoder fires it on every turn_end event from the child.
- trackSwarmAgent now subscribes via SetOnTurnEnd. First turn_end per
  tracked sub-agent marks it done; when every entry is done, zot
  flushes one [auto-swarm update] turn via SubmitOrQueue summarising
  each agent's status / task / transcript tail (and the turn error if
  any) so the main agent can recap the collective outcome in chat.
- System addendum extended to tell the model to expect that update
  message and treat it as observed state, not a new user request.
- README: /settings row in the slash-commands table, new /settings
  subsection covering both toggles, auto-swarm paragraph appended to
  the /swarm subsection, /settings listed as a read-only mid-turn
  command.
commit
80c0ac97a5cc1464ac13bc97c64b31e4ced08265
author
patriceckhart · · 2026-05-25 16:47 UTC
parent
6320769
signature
unsigned
refs
tag: v0.1.97
7 files · +186 -6
M README.md +11 -1
@@ -202,6 +202,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
202202 | `/unjail` | Allow tools to touch paths outside again. |
203203 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
204204 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
205+| `/settings` | Toggle persistent settings (inline images, auto-swarm) with `enter`/`space`. Saved to `$ZOT_HOME/config.json`; takes effect immediately. |
205206 | `/clear` | Clear the chat transcript. |
206207 | `/exit` | Exit zot. |
207208
@@ -285,6 +286,15 @@ Background subagents that run alongside your main session. Each one is a separat
285286
286287 **`/session export` does NOT bundle subagents.** A `.zotsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
287288
289+**Auto-swarm.** With `/settings` -> auto-swarm on, the main agent gets a built-in `swarm_spawn` tool and a system-prompt nudge to use it. It can then fork sub-agents on its own when a request naturally splits into independent parallel work ("implement A and B", "investigate three files"). Each spawn returns the sub-agent id immediately and the main turn keeps going. When every sub-agent the agent spawned in that batch finishes its initial task, zot injects one `[auto-swarm update]` message back into the main chat recapping each agent's status, task, and transcript tail; the main agent then writes a short follow-up summary referencing the agents by id. Off by default; toggle from `/settings`.
290+
291+### `/settings`
292+
293+Opens a dialog with every persistent toggle. `up`/`down` to navigate, `enter` or `space` to flip the selected row, `esc` to close. Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current toggles:
294+
295+- **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
296+- **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
297+
288298 ### `/skills`
289299
290300 Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately.
@@ -521,7 +531,7 @@ You can keep typing while the agent is working. Pressing `enter` during a turn q
521531
522532 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.
523533
524-Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
534+Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
525535
526536
527537 ## Keys (interactive mode)
@@ -202,6 +202,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
202 | `/unjail` | Allow tools to touch paths outside again. |202 | `/unjail` | Allow tools to touch paths outside again. |
203 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |203 | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). |
204 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |204 | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. |
205+| `/settings` | Toggle persistent settings (inline images, auto-swarm) with `enter`/`space`. Saved to `$ZOT_HOME/config.json`; takes effect immediately. |
205 | `/clear` | Clear the chat transcript. |206 | `/clear` | Clear the chat transcript. |
206 | `/exit` | Exit zot. |207 | `/exit` | Exit zot. |
207 208
@@ -285,6 +286,15 @@ Background subagents that run alongside your main session. Each one is a separat
285 286
286 **`/session export` does NOT bundle subagents.** A `.zotsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.287 **`/session export` does NOT bundle subagents.** A `.zotsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
287 288
289+**Auto-swarm.** With `/settings` -> auto-swarm on, the main agent gets a built-in `swarm_spawn` tool and a system-prompt nudge to use it. It can then fork sub-agents on its own when a request naturally splits into independent parallel work ("implement A and B", "investigate three files"). Each spawn returns the sub-agent id immediately and the main turn keeps going. When every sub-agent the agent spawned in that batch finishes its initial task, zot injects one `[auto-swarm update]` message back into the main chat recapping each agent's status, task, and transcript tail; the main agent then writes a short follow-up summary referencing the agents by id. Off by default; toggle from `/settings`.
290+
291+### `/settings`
292+
293+Opens a dialog with every persistent toggle. `up`/`down` to navigate, `enter` or `space` to flip the selected row, `esc` to close. Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current toggles:
294+
295+- **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
296+- **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
297+
288 ### `/skills`298 ### `/skills`
289 299
290 Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately.300 Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each row shows the skill name, source, and description. `enter` opens the body inline (scrollable with `up`/`down`/`pgup`/`pgdn`); `esc` goes back. Re-runs discovery each time it opens, so edits to a SKILL.md during a session are reflected immediately.
@@ -521,7 +531,7 @@ You can keep typing while the agent is working. Pressing `enter` during a turn q
521 531
522 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.532 To recover the most recently queued message back into the editor (to tweak it before it runs), press `Option+↑`. In VS Code's integrated terminal that chord doesn't survive xterm.js's macOS key handling — use `Option+Shift+↑` there. zot's hint line under the sliding-in queue adapts automatically based on `$TERM_PROGRAM`.
523 533
524-Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.534+Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jump`, `/btw`, `/sessions`, `/skills`, `/settings`, `/jail`, `/unjail`, `/exit`) take effect immediately. Destructive ones (`/clear`, `/compact`, `/login`, `/logout`, `/model`, `/reload-ext`) cancel the active turn first and then run.
525 535
526 536
527 ## Keys (interactive mode)537 ## Keys (interactive mode)
M internal/agent/cli.go +14 -2
@@ -441,6 +441,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
441441 // shows them as detached and the user can resume / remove them.
442442 _, _ = swarmMgr.Reload()
443443
444+ // onSpawnedSwarm is the OnSpawned callback the swarm_spawn tool
445+ // fires after every successful spawn. It hands the agent off to
446+ // the running Interactive so the watcher can flush a summary back
447+ // into chat when all sub-agents finish. Reads `iv` lazily because
448+ // the Interactive is constructed after the agent.
449+ onSpawnedSwarm := func(a *swarm.Agent, task string) {
450+ if iv != nil {
451+ iv.TrackSwarmAgent(a, task)
452+ }
453+ }
454+
444455 // Inject the swarm_spawn auto-swarm tool only when /settings ->
445456 // auto-swarm is currently enabled. Registering it unconditionally
446457 // leaves the model trying to call it (and getting a polite error)
@@ -455,8 +466,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
455466 return reg
456467 }
457468 reg["swarm_spawn"] = &tools.SwarmSpawnTool{
458- Swarm: swarmMgr,
459- Enabled: AutoSwarmEnabled,
469+ Swarm: swarmMgr,
470+ Enabled: AutoSwarmEnabled,
471+ OnSpawned: onSpawnedSwarm,
460472 }
461473 return reg
462474 }
@@ -441,6 +441,17 @@ func runInteractive(ctx context.Context, args Args, version string) error {
441 // shows them as detached and the user can resume / remove them.441 // shows them as detached and the user can resume / remove them.
442 _, _ = swarmMgr.Reload()442 _, _ = swarmMgr.Reload()
443 443
444+ // onSpawnedSwarm is the OnSpawned callback the swarm_spawn tool
445+ // fires after every successful spawn. It hands the agent off to
446+ // the running Interactive so the watcher can flush a summary back
447+ // into chat when all sub-agents finish. Reads `iv` lazily because
448+ // the Interactive is constructed after the agent.
449+ onSpawnedSwarm := func(a *swarm.Agent, task string) {
450+ if iv != nil {
451+ iv.TrackSwarmAgent(a, task)
452+ }
453+ }
454+
444 // Inject the swarm_spawn auto-swarm tool only when /settings ->455 // Inject the swarm_spawn auto-swarm tool only when /settings ->
445 // auto-swarm is currently enabled. Registering it unconditionally456 // auto-swarm is currently enabled. Registering it unconditionally
446 // leaves the model trying to call it (and getting a polite error)457 // leaves the model trying to call it (and getting a polite error)
@@ -455,8 +466,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
455 return reg466 return reg
456 }467 }
457 reg["swarm_spawn"] = &tools.SwarmSpawnTool{468 reg["swarm_spawn"] = &tools.SwarmSpawnTool{
458- Swarm: swarmMgr,469+ Swarm: swarmMgr,
459- Enabled: AutoSwarmEnabled,470+ Enabled: AutoSwarmEnabled,
471+ OnSpawned: onSpawnedSwarm,
460 }472 }
461 return reg473 return reg
462 }474 }
M internal/agent/modes/interactive.go +116 -2
@@ -325,6 +325,15 @@ type Interactive struct {
325325 sessionTreeDialog *sessionTreeDialog
326326 extPanel *extPanelDialog
327327
328+ // swarmWatch tracks auto-swarm sub-agents the main agent spawned
329+ // via swarm_spawn. Each entry holds the agent + the task text;
330+ // a per-entry goroutine waits on the agent's terminal state. When
331+ // every tracked entry has finished, the watcher flushes a single
332+ // summary turn into the main chat (queued if the main agent is
333+ // busy, run immediately if idle).
334+ swarmWatchMu sync.Mutex
335+ swarmWatch []*swarmWatchEntry
336+
328337 // pendingFork is true when the user ran /session fork: the next
329338 // jump-picker selection should branch off that message instead
330339 // of scrolling. Flag resets after the action fires or the dialog
@@ -4418,6 +4427,110 @@ func (a telegramSenderAdapter) Active() bool {
44184427 return a.bridge != nil && a.bridge.Active()
44194428 }
44204429
4430+// swarmWatchEntry is one tracked auto-swarm sub-agent. Filled in at
4431+// spawn time and finalised in trackSwarmAgent's waiter goroutine.
4432+type swarmWatchEntry struct {
4433+ agent *swarm.Agent
4434+ task string
4435+ done bool
4436+ err string
4437+}
4438+
4439+// TrackSwarmAgent is the exported entry point used by the cli to
4440+// hand a freshly-spawned auto-swarm agent off to the watcher.
4441+func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) {
4442+ i.trackSwarmAgent(a, task)
4443+}
4444+
4445+// trackSwarmAgent records a freshly-spawned auto-swarm agent and
4446+// subscribes to its turn_end events. Sub-agents are long-lived
4447+// daemons that keep running on the inbox after the initial task,
4448+// so we can't wait on agent.Wait() — it never returns until the
4449+// whole daemon dies. Instead we mark each entry done on its first
4450+// turn_end (the initial task finishing), and when every tracked
4451+// entry has reported in, flush a single summary into the main chat.
4452+//
4453+// Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto-
4454+// swarm is enabled, so this is a no-op when the feature is off.
4455+func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) {
4456+ if i == nil || a == nil {
4457+ return
4458+ }
4459+ entry := &swarmWatchEntry{agent: a, task: task}
4460+ i.swarmWatchMu.Lock()
4461+ i.swarmWatch = append(i.swarmWatch, entry)
4462+ i.swarmWatchMu.Unlock()
4463+
4464+ a.SetOnTurnEnd(func(step int, errMsg string) {
4465+ i.swarmWatchMu.Lock()
4466+ if entry.done {
4467+ i.swarmWatchMu.Unlock()
4468+ return
4469+ }
4470+ entry.done = true
4471+ entry.err = errMsg
4472+ allDone := true
4473+ for _, e := range i.swarmWatch {
4474+ if !e.done {
4475+ allDone = false
4476+ break
4477+ }
4478+ }
4479+ var batch []*swarmWatchEntry
4480+ if allDone {
4481+ batch = i.swarmWatch
4482+ i.swarmWatch = nil
4483+ }
4484+ i.swarmWatchMu.Unlock()
4485+ if len(batch) == 0 {
4486+ return
4487+ }
4488+ i.flushSwarmSummary(batch)
4489+ })
4490+}
4491+
4492+// flushSwarmSummary composes a synthetic user turn describing every
4493+// sub-agent's outcome and injects it via SubmitOrQueue so the main
4494+// agent picks it up at the next safe boundary. The summary is
4495+// phrased as a system update ("Auto-swarm finished: ...") so the
4496+// model treats it as observed state, not as a fresh user request.
4497+func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) {
4498+ if len(batch) == 0 {
4499+ return
4500+ }
4501+ var sb strings.Builder
4502+ fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch))
4503+ for idx, e := range batch {
4504+ snap := e.agent.Snapshot()
4505+ status := string(snap.Status)
4506+ task := snap.Task
4507+ if task == "" {
4508+ task = e.task
4509+ }
4510+ fmt.Fprintf(&sb, "%d. agent %s \u2014 status: %s\n", idx+1, snap.ID, status)
4511+ fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
4512+ if snap.Err != "" {
4513+ fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))
4514+ } else if e.err != "" {
4515+ fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))
4516+ }
4517+ if tail := strings.TrimSpace(snap.Tail); tail != "" {
4518+ fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600))
4519+ }
4520+ sb.WriteString("\n")
4521+ }
4522+ sb.WriteString("Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks.")
4523+ i.SubmitOrQueue(sb.String(), nil)
4524+}
4525+
4526+func truncateForSummary(s string, n int) string {
4527+ s = strings.TrimSpace(s)
4528+ if len(s) <= n {
4529+ return s
4530+ }
4531+ return s[:n-3] + "..."
4532+}
4533+
44214534 // applyAutoSwarmSystemPrompt appends (active=true) or strips
44224535 // (active=false) the auto-swarm system-prompt block on the running
44234536 // agent so the model proactively considers swarm_spawn when the user
@@ -4463,8 +4576,9 @@ func (i *Interactive) applyAutoSwarmTool(active bool) {
44634576 }
44644577 if active && i.cfg.Swarm != nil {
44654578 next["swarm_spawn"] = &tools.SwarmSpawnTool{
4466- Swarm: i.cfg.Swarm,
4467- Enabled: func() bool { return true },
4579+ Swarm: i.cfg.Swarm,
4580+ Enabled: func() bool { return true },
4581+ OnSpawned: i.trackSwarmAgent,
44684582 }
44694583 }
44704584 i.agent.SetTools(next)
@@ -325,6 +325,15 @@ type Interactive struct {
325 sessionTreeDialog *sessionTreeDialog325 sessionTreeDialog *sessionTreeDialog
326 extPanel *extPanelDialog326 extPanel *extPanelDialog
327 327
328+ // swarmWatch tracks auto-swarm sub-agents the main agent spawned
329+ // via swarm_spawn. Each entry holds the agent + the task text;
330+ // a per-entry goroutine waits on the agent's terminal state. When
331+ // every tracked entry has finished, the watcher flushes a single
332+ // summary turn into the main chat (queued if the main agent is
333+ // busy, run immediately if idle).
334+ swarmWatchMu sync.Mutex
335+ swarmWatch []*swarmWatchEntry
336+
328 // pendingFork is true when the user ran /session fork: the next337 // pendingFork is true when the user ran /session fork: the next
329 // jump-picker selection should branch off that message instead338 // jump-picker selection should branch off that message instead
330 // of scrolling. Flag resets after the action fires or the dialog339 // of scrolling. Flag resets after the action fires or the dialog
@@ -4418,6 +4427,110 @@ func (a telegramSenderAdapter) Active() bool {
4418 return a.bridge != nil && a.bridge.Active()4427 return a.bridge != nil && a.bridge.Active()
4419 }4428 }
4420 4429
4430+// swarmWatchEntry is one tracked auto-swarm sub-agent. Filled in at
4431+// spawn time and finalised in trackSwarmAgent's waiter goroutine.
4432+type swarmWatchEntry struct {
4433+ agent *swarm.Agent
4434+ task string
4435+ done bool
4436+ err string
4437+}
4438+
4439+// TrackSwarmAgent is the exported entry point used by the cli to
4440+// hand a freshly-spawned auto-swarm agent off to the watcher.
4441+func (i *Interactive) TrackSwarmAgent(a *swarm.Agent, task string) {
4442+ i.trackSwarmAgent(a, task)
4443+}
4444+
4445+// trackSwarmAgent records a freshly-spawned auto-swarm agent and
4446+// subscribes to its turn_end events. Sub-agents are long-lived
4447+// daemons that keep running on the inbox after the initial task,
4448+// so we can't wait on agent.Wait() — it never returns until the
4449+// whole daemon dies. Instead we mark each entry done on its first
4450+// turn_end (the initial task finishing), and when every tracked
4451+// entry has reported in, flush a single summary into the main chat.
4452+//
4453+// Wired in from cli.go via SwarmSpawnTool.OnSpawned only when auto-
4454+// swarm is enabled, so this is a no-op when the feature is off.
4455+func (i *Interactive) trackSwarmAgent(a *swarm.Agent, task string) {
4456+ if i == nil || a == nil {
4457+ return
4458+ }
4459+ entry := &swarmWatchEntry{agent: a, task: task}
4460+ i.swarmWatchMu.Lock()
4461+ i.swarmWatch = append(i.swarmWatch, entry)
4462+ i.swarmWatchMu.Unlock()
4463+
4464+ a.SetOnTurnEnd(func(step int, errMsg string) {
4465+ i.swarmWatchMu.Lock()
4466+ if entry.done {
4467+ i.swarmWatchMu.Unlock()
4468+ return
4469+ }
4470+ entry.done = true
4471+ entry.err = errMsg
4472+ allDone := true
4473+ for _, e := range i.swarmWatch {
4474+ if !e.done {
4475+ allDone = false
4476+ break
4477+ }
4478+ }
4479+ var batch []*swarmWatchEntry
4480+ if allDone {
4481+ batch = i.swarmWatch
4482+ i.swarmWatch = nil
4483+ }
4484+ i.swarmWatchMu.Unlock()
4485+ if len(batch) == 0 {
4486+ return
4487+ }
4488+ i.flushSwarmSummary(batch)
4489+ })
4490+}
4491+
4492+// flushSwarmSummary composes a synthetic user turn describing every
4493+// sub-agent's outcome and injects it via SubmitOrQueue so the main
4494+// agent picks it up at the next safe boundary. The summary is
4495+// phrased as a system update ("Auto-swarm finished: ...") so the
4496+// model treats it as observed state, not as a fresh user request.
4497+func (i *Interactive) flushSwarmSummary(batch []*swarmWatchEntry) {
4498+ if len(batch) == 0 {
4499+ return
4500+ }
4501+ var sb strings.Builder
4502+ fmt.Fprintf(&sb, "[auto-swarm update] %d sub-agent(s) finished:\n\n", len(batch))
4503+ for idx, e := range batch {
4504+ snap := e.agent.Snapshot()
4505+ status := string(snap.Status)
4506+ task := snap.Task
4507+ if task == "" {
4508+ task = e.task
4509+ }
4510+ fmt.Fprintf(&sb, "%d. agent %s \u2014 status: %s\n", idx+1, snap.ID, status)
4511+ fmt.Fprintf(&sb, " task: %s\n", truncateForSummary(task, 240))
4512+ if snap.Err != "" {
4513+ fmt.Fprintf(&sb, " error: %s\n", truncateForSummary(snap.Err, 240))
4514+ } else if e.err != "" {
4515+ fmt.Fprintf(&sb, " turn error: %s\n", truncateForSummary(e.err, 240))
4516+ }
4517+ if tail := strings.TrimSpace(snap.Tail); tail != "" {
4518+ fmt.Fprintf(&sb, " tail: %s\n", truncateForSummary(tail, 600))
4519+ }
4520+ sb.WriteString("\n")
4521+ }
4522+ sb.WriteString("Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks.")
4523+ i.SubmitOrQueue(sb.String(), nil)
4524+}
4525+
4526+func truncateForSummary(s string, n int) string {
4527+ s = strings.TrimSpace(s)
4528+ if len(s) <= n {
4529+ return s
4530+ }
4531+ return s[:n-3] + "..."
4532+}
4533+
4421 // applyAutoSwarmSystemPrompt appends (active=true) or strips4534 // applyAutoSwarmSystemPrompt appends (active=true) or strips
4422 // (active=false) the auto-swarm system-prompt block on the running4535 // (active=false) the auto-swarm system-prompt block on the running
4423 // agent so the model proactively considers swarm_spawn when the user4536 // agent so the model proactively considers swarm_spawn when the user
@@ -4463,8 +4576,9 @@ func (i *Interactive) applyAutoSwarmTool(active bool) {
4463 }4576 }
4464 if active && i.cfg.Swarm != nil {4577 if active && i.cfg.Swarm != nil {
4465 next["swarm_spawn"] = &tools.SwarmSpawnTool{4578 next["swarm_spawn"] = &tools.SwarmSpawnTool{
4466- Swarm: i.cfg.Swarm,4579+ Swarm: i.cfg.Swarm,
4467- Enabled: func() bool { return true },4580+ Enabled: func() bool { return true },
4581+ OnSpawned: i.trackSwarmAgent,
4468 }4582 }
4469 }4583 }
4470 i.agent.SetTools(next)4584 i.agent.SetTools(next)
M internal/agent/settings_store.go +3 -1
@@ -38,4 +38,6 @@ const AutoSwarmSystemAddendum = `Auto-swarm is enabled. You have a swarm_spawn t
3838
3939 Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing.
4040
41-Do NOT use swarm_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself.`
41+Do NOT use swarm_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself.
42+
43+When every sub-agent you spawned reaches a terminal state, the host injects a single [auto-swarm update] message recapping each agent's status, task, and transcript tail. Treat that message as observed state (not as a new user request) and write a short follow-up summary referencing the agents by id.`
@@ -38,4 +38,6 @@ const AutoSwarmSystemAddendum = `Auto-swarm is enabled. You have a swarm_spawn t
38 38
39 Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing.39 Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing.
40 40
41-Do NOT use swarm_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself.`41+Do NOT use swarm_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself.
42+
43+When every sub-agent you spawned reaches a terminal state, the host injects a single [auto-swarm update] message recapping each agent's status, task, and transcript tail. Treat that message as observed state (not as a new user request) and write a short follow-up summary referencing the agents by id.`
M internal/agent/tools/swarm_spawn.go +9 -0
@@ -30,6 +30,12 @@ type SwarmSpawnTool struct {
3030 // /settings without rebuilding the agent. When nil, the tool
3131 // is treated as disabled.
3232 Enabled func() bool
33+
34+ // OnSpawned, if set, is called after every successful spawn with
35+ // the new agent + the task it was started with. Used by the
36+ // interactive host to track agents and surface a summary back
37+ // in the main chat once they all finish.
38+ OnSpawned func(agent *swarm.Agent, task string)
3339 }
3440
3541 type swarmSpawnArgs struct {
@@ -87,6 +93,9 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
8793 if err != nil {
8894 return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err)
8995 }
96+ if t.OnSpawned != nil {
97+ t.OnSpawned(agent, task)
98+ }
9099
91100 var sb strings.Builder
92101 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
@@ -30,6 +30,12 @@ type SwarmSpawnTool struct {
30 // /settings without rebuilding the agent. When nil, the tool30 // /settings without rebuilding the agent. When nil, the tool
31 // is treated as disabled.31 // is treated as disabled.
32 Enabled func() bool32 Enabled func() bool
33+
34+ // OnSpawned, if set, is called after every successful spawn with
35+ // the new agent + the task it was started with. Used by the
36+ // interactive host to track agents and surface a summary back
37+ // in the main chat once they all finish.
38+ OnSpawned func(agent *swarm.Agent, task string)
33 }39 }
34 40
35 type swarmSpawnArgs struct {41 type swarmSpawnArgs struct {
@@ -87,6 +93,9 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
87 if err != nil {93 if err != nil {
88 return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err)94 return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err)
89 }95 }
96+ if t.OnSpawned != nil {
97+ t.OnSpawned(agent, task)
98+ }
90 99
91 var sb strings.Builder100 var sb strings.Builder
92 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)101 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
M internal/swarm/agent.go +18 -0
@@ -71,6 +71,14 @@ type Agent struct {
7171 finished time.Time
7272 lastErr error
7373
74+ // OnTurnEnd, if set, fires once per turn_end event the runner
75+ // observes from the child daemon. Used by auto-swarm watchers
76+ // to detect that a sub-agent's first (or n-th) task has
77+ // finished without waiting for the long-lived daemon itself to
78+ // exit — sub-agents keep running on the inbox even after the
79+ // initial task completes, so Wait() never unblocks for them.
80+ OnTurnEnd func(step int, errMsg string)
81+
7482 ctx context.Context
7583 cancel context.CancelFunc
7684 runner Runner
@@ -120,6 +128,16 @@ func (a *Agent) Err() error {
120128 // and by /swarm wait <id>.
121129 func (a *Agent) Wait() { <-a.done }
122130
131+// SetOnTurnEnd installs (or clears, with nil) the per-turn callback
132+// fired from the runner when the child daemon emits a turn_end
133+// event. Safe to call from any goroutine: the runner reads the
134+// callback under the same mutex.
135+func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
136+ a.mu.Lock()
137+ a.OnTurnEnd = fn
138+ a.mu.Unlock()
139+}
140+
123141 func (a *Agent) setStatus(s Status) {
124142 a.mu.Lock()
125143 a.status = s
@@ -71,6 +71,14 @@ type Agent struct {
71 finished time.Time71 finished time.Time
72 lastErr error72 lastErr error
73 73
74+ // OnTurnEnd, if set, fires once per turn_end event the runner
75+ // observes from the child daemon. Used by auto-swarm watchers
76+ // to detect that a sub-agent's first (or n-th) task has
77+ // finished without waiting for the long-lived daemon itself to
78+ // exit — sub-agents keep running on the inbox even after the
79+ // initial task completes, so Wait() never unblocks for them.
80+ OnTurnEnd func(step int, errMsg string)
81+
74 ctx context.Context82 ctx context.Context
75 cancel context.CancelFunc83 cancel context.CancelFunc
76 runner Runner84 runner Runner
@@ -120,6 +128,16 @@ func (a *Agent) Err() error {
120 // and by /swarm wait <id>.128 // and by /swarm wait <id>.
121 func (a *Agent) Wait() { <-a.done }129 func (a *Agent) Wait() { <-a.done }
122 130
131+// SetOnTurnEnd installs (or clears, with nil) the per-turn callback
132+// fired from the runner when the child daemon emits a turn_end
133+// event. Safe to call from any goroutine: the runner reads the
134+// callback under the same mutex.
135+func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
136+ a.mu.Lock()
137+ a.OnTurnEnd = fn
138+ a.mu.Unlock()
139+}
140+
123 func (a *Agent) setStatus(s Status) {141 func (a *Agent) setStatus(s Status) {
124 a.mu.Lock()142 a.mu.Lock()
125 a.status = s143 a.status = s
M internal/swarm/runner.go +15 -0
@@ -209,6 +209,21 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
209209 if ev, ok := parseEventLine(trimmed); ok {
210210 _ = log.Append(ev)
211211 applyEventToSink(ev, sink)
212+ // Fan turn_end up to any subscriber on the
213+ // supervised Agent. Daemons stay alive across
214+ // many turns, so Wait()-style hooks would
215+ // never fire; per-turn callbacks let auto-
216+ // swarm summarise as each task completes.
217+ if ev.Type == "turn_end" && r.agent != nil {
218+ r.agent.mu.Lock()
219+ fn := r.agent.OnTurnEnd
220+ r.agent.mu.Unlock()
221+ if fn != nil {
222+ step, _ := ev.Data["step"].(float64)
223+ errMsg, _ := ev.Data["error"].(string)
224+ go fn(int(step), errMsg)
225+ }
226+ }
212227 } else {
213228 // Non-JSON output. Keep it as transcript so an
214229 // accidental fmt.Println in the child still
@@ -209,6 +209,21 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
209 if ev, ok := parseEventLine(trimmed); ok {209 if ev, ok := parseEventLine(trimmed); ok {
210 _ = log.Append(ev)210 _ = log.Append(ev)
211 applyEventToSink(ev, sink)211 applyEventToSink(ev, sink)
212+ // Fan turn_end up to any subscriber on the
213+ // supervised Agent. Daemons stay alive across
214+ // many turns, so Wait()-style hooks would
215+ // never fire; per-turn callbacks let auto-
216+ // swarm summarise as each task completes.
217+ if ev.Type == "turn_end" && r.agent != nil {
218+ r.agent.mu.Lock()
219+ fn := r.agent.OnTurnEnd
220+ r.agent.mu.Unlock()
221+ if fn != nil {
222+ step, _ := ev.Data["step"].(float64)
223+ errMsg, _ := ev.Data["error"].(string)
224+ go fn(int(step), errMsg)
225+ }
226+ }
212 } else {227 } else {
213 // Non-JSON output. Keep it as transcript so an228 // Non-JSON output. Keep it as transcript so an
214 // accidental fmt.Println in the child still229 // accidental fmt.Println in the child still

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close