detached 6320769

feat: /settings dialog with auto-swarm toggle

Re-enable the /settings slash command and add an auto-swarm setting
that lets the agent spawn parallel background sub-agents on its own.

- Config: persist auto_swarm_enabled in config.json.
- SettingsStore: SetAutoSwarm; AutoSwarmEnabled() reads live flag.
- /settings: two items (inline images, auto-swarm); any toggle now
  triggers a full repaint (Ctrl+L equivalent) so changes land
  immediately.
- New built-in tool swarm_spawn: forks a background sub-agent against
  the host cwd via Swarm.SpawnReq and returns the agent id immediately
  so the main turn keeps running in parallel. Schema accepts task,
  optional model, optional provider.
- Tool is only registered when auto-swarm is on (startup checks the
  flag; live toggle adds/removes it on the running agent). Model never
  sees a tool it can't use.
- System prompt gains an AutoSwarmSystemAddendum when auto-swarm is on,
  telling the model to use swarm_spawn proactively for parallelizable
  sub-tasks. Applied at Resolve time and live-swapped on toggle so the
  model adapts mid-session without a restart.
- cli.go: construct swarmMgr earlier so the auto-swarm tool can capture
  it before agent build; injectSwarmSpawn helper threads through every
  registry build path (initial, buildAgent, buildAgentFor,
  buildAgentForRescue, /reload-ext).
commit
6320769094fddc845ed9e73586d5e81f23c2cc74
author
patriceckhart · · 2026-05-25 16:29 UTC
parent
6684724
signature
unsigned
7 files · +342 -30
M internal/agent/build.go +3 -0
@@ -405,6 +405,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
405405 if skillAddendum != "" {
406406 append_ = append(append_, skillAddendum)
407407 }
408+ if AutoSwarmEnabled() {
409+ append_ = append(append_, AutoSwarmSystemAddendum)
410+ }
408411
409412 // Custom system prompt resolution order:
410413 // 1. --system-prompt flag (highest priority; ad-hoc per run)
@@ -405,6 +405,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
405 if skillAddendum != "" {405 if skillAddendum != "" {
406 append_ = append(append_, skillAddendum)406 append_ = append(append_, skillAddendum)
407 }407 }
408+ if AutoSwarmEnabled() {
409+ append_ = append(append_, AutoSwarmSystemAddendum)
410+ }
408 411
409 // Custom system prompt resolution order:412 // Custom system prompt resolution order:
410 // 1. --system-prompt flag (highest priority; ad-hoc per run)413 // 1. --system-prompt flag (highest priority; ad-hoc per run)
M internal/agent/cli.go +49 -17
@@ -17,6 +17,7 @@ import (
1717
1818 "github.com/patriceckhart/zot/internal/agent/extensions"
1919 "github.com/patriceckhart/zot/internal/agent/modes"
20+ "github.com/patriceckhart/zot/internal/agent/tools"
2021 "github.com/patriceckhart/zot/internal/auth"
2122 "github.com/patriceckhart/zot/internal/core"
2223 "github.com/patriceckhart/zot/internal/extproto"
@@ -422,6 +423,45 @@ func runInteractive(ctx context.Context, args Args, version string) error {
422423 extToolAdapter := &extToolAdapter{mgr: extMgr}
423424 r.MergeExtensionTools(extToolAdapter)
424425
426+ // Build the swarm supervisor BEFORE the agent so the auto-swarm
427+ // tool can reference it during tool-registry construction. State
428+ // lives under ZotHome/swarm so per-agent meta/events survive
429+ // restarts; the user can hunt orphaned agents down with
430+ // `git worktree list` if anything misbehaves.
431+ //
432+ // swarmMgr is also captured by loadSession / changeCWD closures
433+ // further down the function, which is why we keep the variable
434+ // in this outer scope rather than scoping it tighter.
435+ var swarmMgr *swarm.Swarm
436+ swarmMgr = swarm.New(swarm.Config{
437+ Root: filepath.Join(ZotHome(), "swarm"),
438+ RepoRoot: r.CWD,
439+ })
440+ // Pull any previously-spawned agents off disk so the dashboard
441+ // shows them as detached and the user can resume / remove them.
442+ _, _ = swarmMgr.Reload()
443+
444+ // Inject the swarm_spawn auto-swarm tool only when /settings ->
445+ // auto-swarm is currently enabled. Registering it unconditionally
446+ // leaves the model trying to call it (and getting a polite error)
447+ // even when the user has switched the feature off. The /settings
448+ // toggle live-mutates the running agent's registry separately so
449+ // flipping the flag mid-session takes effect on the next turn.
450+ injectSwarmSpawn := func(reg core.Registry) core.Registry {
451+ if reg == nil {
452+ return reg
453+ }
454+ if !AutoSwarmEnabled() {
455+ return reg
456+ }
457+ reg["swarm_spawn"] = &tools.SwarmSpawnTool{
458+ Swarm: swarmMgr,
459+ Enabled: AutoSwarmEnabled,
460+ }
461+ return reg
462+ }
463+ injectSwarmSpawn(r.ToolRegistry)
464+
425465 // Confirmation gate: when --no-yolo is on, the agent must ask
426466 // the user before every tool call. In interactive mode the TUI
427467 // provides the Confirmer; in print/json/rpc modes there's no
@@ -475,6 +515,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
475515 }
476516 resolved.UseSandbox(sharedSandbox)
477517 resolved.MergeExtensionTools(extToolAdapter)
518+ injectSwarmSpawn(resolved.ToolRegistry)
478519 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
479520 }
480521
@@ -493,6 +534,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
493534 }
494535 resolved.UseSandbox(sharedSandbox)
495536 resolved.MergeExtensionTools(extToolAdapter)
537+ injectSwarmSpawn(resolved.ToolRegistry)
496538 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
497539 }
498540
@@ -519,6 +561,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
519561 }
520562 resolved.UseSandbox(sharedSandbox)
521563 resolved.MergeExtensionTools(extToolAdapter)
564+ injectSwarmSpawn(resolved.ToolRegistry)
522565 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
523566 }
524567
@@ -544,6 +587,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
544587 }
545588 resolved.UseSandbox(sharedSandbox)
546589 resolved.MergeExtensionTools(extToolAdapter)
590+ injectSwarmSpawn(resolved.ToolRegistry)
547591 current.SetTools(resolved.ToolRegistry)
548592 })
549593
@@ -552,11 +596,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
552596
553597 var sess *core.Session
554598 var sessBaselineMsgs int // messages already on disk when current session opened
555- // swarmMgr is constructed below, but loadSession (defined before
556- // the construction site) needs to re-scope it whenever the user
557- // swaps sessions. Forward-declare here so the closure can
558- // reference it; the assignment happens at the construction line.
559- var swarmMgr *swarm.Swarm
560599 // persistMu guards sess + sessBaselineMsgs against concurrent access
561600 // from the agent loop's per-message persistence hook (runs on the
562601 // agent goroutine) and the TUI's session swap / flush callbacks
@@ -861,18 +900,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
861900
862901 initialCfg, _ := LoadConfig()
863902
864- // Build the swarm supervisor. Root lives under ZotHome/swarm so
865- // worktrees survive across zot sessions and the user can hunt
866- // them down with `git worktree list` if anything misbehaves.
867- swarmMgr = swarm.New(swarm.Config{
868- Root: filepath.Join(ZotHome(), "swarm"),
869- RepoRoot: r.CWD,
870- })
871- // Pull any previously-spawned agents off disk so the user can see,
872- // resume, or remove them from the dashboard. Failures here aren't
873- // fatal — the supervisor still works for new agents.
874- _, _ = swarmMgr.Reload()
875- // Scope the dashboard to the active host session so /swarm only
903+ // swarmMgr was constructed and reloaded earlier (before the agent
904+ // build, so the auto-swarm tool could capture it). Here we just
905+ // scope the dashboard to the active host session so /swarm only
876906 // shows agents this session spawned (and any pre-upgrade unscoped
877907 // agents — see SnapshotAll docs). Updated again whenever the
878908 // user swaps sessions via loadSession below.
@@ -887,6 +917,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
887917 Terminal: term,
888918 Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
889919 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
920+ AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
921+ AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
890922 SettingsStore: configSettingsStore{},
891923 Model: r.Model,
892924 Provider: r.Provider,
@@ -17,6 +17,7 @@ import (
17 17
18 "github.com/patriceckhart/zot/internal/agent/extensions"18 "github.com/patriceckhart/zot/internal/agent/extensions"
19 "github.com/patriceckhart/zot/internal/agent/modes"19 "github.com/patriceckhart/zot/internal/agent/modes"
20+ "github.com/patriceckhart/zot/internal/agent/tools"
20 "github.com/patriceckhart/zot/internal/auth"21 "github.com/patriceckhart/zot/internal/auth"
21 "github.com/patriceckhart/zot/internal/core"22 "github.com/patriceckhart/zot/internal/core"
22 "github.com/patriceckhart/zot/internal/extproto"23 "github.com/patriceckhart/zot/internal/extproto"
@@ -422,6 +423,45 @@ func runInteractive(ctx context.Context, args Args, version string) error {
422 extToolAdapter := &extToolAdapter{mgr: extMgr}423 extToolAdapter := &extToolAdapter{mgr: extMgr}
423 r.MergeExtensionTools(extToolAdapter)424 r.MergeExtensionTools(extToolAdapter)
424 425
426+ // Build the swarm supervisor BEFORE the agent so the auto-swarm
427+ // tool can reference it during tool-registry construction. State
428+ // lives under ZotHome/swarm so per-agent meta/events survive
429+ // restarts; the user can hunt orphaned agents down with
430+ // `git worktree list` if anything misbehaves.
431+ //
432+ // swarmMgr is also captured by loadSession / changeCWD closures
433+ // further down the function, which is why we keep the variable
434+ // in this outer scope rather than scoping it tighter.
435+ var swarmMgr *swarm.Swarm
436+ swarmMgr = swarm.New(swarm.Config{
437+ Root: filepath.Join(ZotHome(), "swarm"),
438+ RepoRoot: r.CWD,
439+ })
440+ // Pull any previously-spawned agents off disk so the dashboard
441+ // shows them as detached and the user can resume / remove them.
442+ _, _ = swarmMgr.Reload()
443+
444+ // Inject the swarm_spawn auto-swarm tool only when /settings ->
445+ // auto-swarm is currently enabled. Registering it unconditionally
446+ // leaves the model trying to call it (and getting a polite error)
447+ // even when the user has switched the feature off. The /settings
448+ // toggle live-mutates the running agent's registry separately so
449+ // flipping the flag mid-session takes effect on the next turn.
450+ injectSwarmSpawn := func(reg core.Registry) core.Registry {
451+ if reg == nil {
452+ return reg
453+ }
454+ if !AutoSwarmEnabled() {
455+ return reg
456+ }
457+ reg["swarm_spawn"] = &tools.SwarmSpawnTool{
458+ Swarm: swarmMgr,
459+ Enabled: AutoSwarmEnabled,
460+ }
461+ return reg
462+ }
463+ injectSwarmSpawn(r.ToolRegistry)
464+
425 // Confirmation gate: when --no-yolo is on, the agent must ask465 // Confirmation gate: when --no-yolo is on, the agent must ask
426 // the user before every tool call. In interactive mode the TUI466 // the user before every tool call. In interactive mode the TUI
427 // provides the Confirmer; in print/json/rpc modes there's no467 // provides the Confirmer; in print/json/rpc modes there's no
@@ -475,6 +515,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
475 }515 }
476 resolved.UseSandbox(sharedSandbox)516 resolved.UseSandbox(sharedSandbox)
477 resolved.MergeExtensionTools(extToolAdapter)517 resolved.MergeExtensionTools(extToolAdapter)
518+ injectSwarmSpawn(resolved.ToolRegistry)
478 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil519 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
479 }520 }
480 521
@@ -493,6 +534,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
493 }534 }
494 resolved.UseSandbox(sharedSandbox)535 resolved.UseSandbox(sharedSandbox)
495 resolved.MergeExtensionTools(extToolAdapter)536 resolved.MergeExtensionTools(extToolAdapter)
537+ injectSwarmSpawn(resolved.ToolRegistry)
496 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil538 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
497 }539 }
498 540
@@ -519,6 +561,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
519 }561 }
520 resolved.UseSandbox(sharedSandbox)562 resolved.UseSandbox(sharedSandbox)
521 resolved.MergeExtensionTools(extToolAdapter)563 resolved.MergeExtensionTools(extToolAdapter)
564+ injectSwarmSpawn(resolved.ToolRegistry)
522 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil565 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
523 }566 }
524 567
@@ -544,6 +587,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
544 }587 }
545 resolved.UseSandbox(sharedSandbox)588 resolved.UseSandbox(sharedSandbox)
546 resolved.MergeExtensionTools(extToolAdapter)589 resolved.MergeExtensionTools(extToolAdapter)
590+ injectSwarmSpawn(resolved.ToolRegistry)
547 current.SetTools(resolved.ToolRegistry)591 current.SetTools(resolved.ToolRegistry)
548 })592 })
549 593
@@ -552,11 +596,6 @@ func runInteractive(ctx context.Context, args Args, version string) error {
552 596
553 var sess *core.Session597 var sess *core.Session
554 var sessBaselineMsgs int // messages already on disk when current session opened598 var sessBaselineMsgs int // messages already on disk when current session opened
555- // swarmMgr is constructed below, but loadSession (defined before
556- // the construction site) needs to re-scope it whenever the user
557- // swaps sessions. Forward-declare here so the closure can
558- // reference it; the assignment happens at the construction line.
559- var swarmMgr *swarm.Swarm
560 // persistMu guards sess + sessBaselineMsgs against concurrent access599 // persistMu guards sess + sessBaselineMsgs against concurrent access
561 // from the agent loop's per-message persistence hook (runs on the600 // from the agent loop's per-message persistence hook (runs on the
562 // agent goroutine) and the TUI's session swap / flush callbacks601 // agent goroutine) and the TUI's session swap / flush callbacks
@@ -861,18 +900,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
861 900
862 initialCfg, _ := LoadConfig()901 initialCfg, _ := LoadConfig()
863 902
864- // Build the swarm supervisor. Root lives under ZotHome/swarm so903+ // swarmMgr was constructed and reloaded earlier (before the agent
865- // worktrees survive across zot sessions and the user can hunt904+ // build, so the auto-swarm tool could capture it). Here we just
866- // them down with `git worktree list` if anything misbehaves.905+ // scope the dashboard to the active host session so /swarm only
867- swarmMgr = swarm.New(swarm.Config{
868- Root: filepath.Join(ZotHome(), "swarm"),
869- RepoRoot: r.CWD,
870- })
871- // Pull any previously-spawned agents off disk so the user can see,
872- // resume, or remove them from the dashboard. Failures here aren't
873- // fatal — the supervisor still works for new agents.
874- _, _ = swarmMgr.Reload()
875- // Scope the dashboard to the active host session so /swarm only
876 // shows agents this session spawned (and any pre-upgrade unscoped906 // shows agents this session spawned (and any pre-upgrade unscoped
877 // agents — see SnapshotAll docs). Updated again whenever the907 // agents — see SnapshotAll docs). Updated again whenever the
878 // user swaps sessions via loadSession below.908 // user swaps sessions via loadSession below.
@@ -887,6 +917,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
887 Terminal: term,917 Terminal: term,
888 Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),918 Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
889 InlineImagesEnabled: initialCfg.InlineImagesEnabled,919 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
920+ AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
921+ AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
890 SettingsStore: configSettingsStore{},922 SettingsStore: configSettingsStore{},
891 Model: r.Model,923 Model: r.Model,
892 Provider: r.Provider,924 Provider: r.Provider,
M internal/agent/config.go +5 -0
@@ -27,6 +27,11 @@ type Config struct {
2727 // detected protocol when available.
2828 InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"`
2929
30+ // AutoSwarmEnabled lets the main agent spawn background sub-agents
31+ // for parallel sub-tasks via a built-in swarm_spawn tool. Off by
32+ // default; nil/missing means disabled. Toggle from /settings.
33+ AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
34+
3035 // LastChangelogShown is the version whose release-notes
3136 // dialog the user has already seen. When the running binary's
3237 // version differs, the next interactive run shows the
@@ -27,6 +27,11 @@ type Config struct {
27 // detected protocol when available.27 // detected protocol when available.
28 InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"`28 InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"`
29 29
30+ // AutoSwarmEnabled lets the main agent spawn background sub-agents
31+ // for parallel sub-tasks via a built-in swarm_spawn tool. Off by
32+ // default; nil/missing means disabled. Toggle from /settings.
33+ AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"`
34+
30 // LastChangelogShown is the version whose release-notes35 // LastChangelogShown is the version whose release-notes
31 // dialog the user has already seen. When the running binary's36 // dialog the user has already seen. When the running binary's
32 // version differs, the next interactive run shows the37 // version differs, the next interactive run shows the
M internal/agent/modes/interactive.go +130 -13
@@ -39,7 +39,18 @@ type InteractiveConfig struct {
3939 // auto-detect and render when supported; false disables; true uses
4040 // the detected protocol when available.
4141 InlineImagesEnabled *bool
42- SettingsStore SettingsStore
42+
43+ // AutoSwarmEnabled mirrors the persisted config flag at startup so
44+ // the /settings dialog can render the current state without
45+ // re-reading config.json on every open.
46+ AutoSwarmEnabled *bool
47+
48+ // AutoSwarmSystemAddendum is the system-prompt block that gets
49+ // appended/stripped when the user toggles auto-swarm at runtime.
50+ // Plumbed in from the cli so this package doesn't have to import
51+ // agent (cycle).
52+ AutoSwarmSystemAddendum string
53+ SettingsStore SettingsStore
4354
4455 // Agent is optional. If nil, zot opens without credentials; the
4556 // user must /login before they can prompt.
@@ -209,6 +220,7 @@ type chatCacheKey struct {
209220 // SettingsStore persists user-toggleable settings surfaced by /settings.
210221 type SettingsStore interface {
211222 SetInlineImages(enabled bool) error
223+ SetAutoSwarm(enabled bool) error
212224 }
213225
214226 type Interactive struct {
@@ -2470,17 +2482,49 @@ func (i *Interactive) openSettingsDialog() {
24702482 } else {
24712483 imgHint = "terminal supports " + imageProtocolName(detected)
24722484 }
2473- i.settingsDialog.Open([]settingsItem{{
2474- key: "inline_images_enabled",
2475- label: "render images when supported",
2476- desc: "draw screenshots inline instead of showing a text placeholder",
2477- value: imgEnabled,
2478- disabled: imgDisabled,
2479- hint: imgHint,
2480- }})
2485+
2486+ autoSwarm := false
2487+ if i.cfg.AutoSwarmEnabled != nil {
2488+ autoSwarm = *i.cfg.AutoSwarmEnabled
2489+ }
2490+ autoSwarmDisabled := i.cfg.Swarm == nil
2491+ autoSwarmHint := ""
2492+ if autoSwarmDisabled {
2493+ autoSwarm = false
2494+ autoSwarmHint = "swarm supervisor not available in this mode"
2495+ }
2496+
2497+ i.settingsDialog.Open([]settingsItem{
2498+ {
2499+ key: "inline_images_enabled",
2500+ label: "render images when supported",
2501+ desc: "draw screenshots inline instead of showing a text placeholder",
2502+ value: imgEnabled,
2503+ disabled: imgDisabled,
2504+ hint: imgHint,
2505+ },
2506+ {
2507+ key: "auto_swarm_enabled",
2508+ label: "auto-swarm",
2509+ desc: "let the agent spawn background sub-agents in parallel via the swarm_spawn tool",
2510+ value: autoSwarm,
2511+ disabled: autoSwarmDisabled,
2512+ hint: autoSwarmHint,
2513+ },
2514+ })
24812515 }
24822516
24832517 func (i *Interactive) applySettingToggle(key string, value bool) {
2518+ // Every setting toggle forces a full repaint at the end — same
2519+ // effect as the user pressing Ctrl+L — so any per-setting visual
2520+ // change (image rendering, status copy, future toggles) lands
2521+ // immediately instead of waiting for the next diff frame.
2522+ defer func() {
2523+ if i.rend != nil {
2524+ i.rend.Clear()
2525+ }
2526+ i.invalidate()
2527+ }()
24842528 switch key {
24852529 case "inline_images_enabled":
24862530 val := value
@@ -2499,6 +2543,30 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
24992543 i.statusOK = "inline image rendering " + onOff(value)
25002544 i.statusErr = ""
25012545 i.mu.Unlock()
2546+ case "auto_swarm_enabled":
2547+ val := value
2548+ i.cfg.AutoSwarmEnabled = &val
2549+ if i.cfg.SettingsStore != nil {
2550+ if err := i.cfg.SettingsStore.SetAutoSwarm(value); err != nil {
2551+ i.mu.Lock()
2552+ i.statusErr = "settings: " + err.Error()
2553+ i.mu.Unlock()
2554+ return
2555+ }
2556+ }
2557+ // Add/remove the swarm_spawn tool on the live agent so the
2558+ // model's tools[] list reflects the toggle on the next turn.
2559+ // Without this the tool stays advertised after a disable and
2560+ // the model keeps trying to call it.
2561+ i.applyAutoSwarmTool(value)
2562+ // Also swap the system-prompt addendum in/out so the model
2563+ // knows to use the tool proactively (or stops referencing it
2564+ // after a disable).
2565+ i.applyAutoSwarmSystemPrompt(value)
2566+ i.mu.Lock()
2567+ i.statusOK = "auto-swarm " + onOff(value)
2568+ i.statusErr = ""
2569+ i.mu.Unlock()
25022570 }
25032571 }
25042572
@@ -2790,10 +2858,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
27902858 i.modelDialog.Open(i.cfg.Model, loggedIn)
27912859 }
27922860 case "/settings":
2793- i.mu.Lock()
2794- i.statusErr = "/settings is temporarily disabled"
2795- i.statusOK = ""
2796- i.mu.Unlock()
2861+ i.openSettingsDialog()
27972862 case "/sessions":
27982863 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
27992864 case "/jump":
@@ -4353,6 +4418,58 @@ func (a telegramSenderAdapter) Active() bool {
43534418 return a.bridge != nil && a.bridge.Active()
43544419 }
43554420
4421+// applyAutoSwarmSystemPrompt appends (active=true) or strips
4422+// (active=false) the auto-swarm system-prompt block on the running
4423+// agent so the model proactively considers swarm_spawn when the user
4424+// flips the toggle. The block lives at the tail of agent.System so
4425+// stripping is a plain suffix-trim; idempotent in both directions.
4426+func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
4427+ if i.agent == nil {
4428+ return
4429+ }
4430+ addendum := i.cfg.AutoSwarmSystemAddendum
4431+ if addendum == "" {
4432+ return
4433+ }
4434+ sys := i.agent.System
4435+ has := strings.Contains(sys, addendum)
4436+ switch {
4437+ case active && !has:
4438+ if sys != "" && !strings.HasSuffix(sys, "\n\n") {
4439+ sys += "\n\n"
4440+ }
4441+ i.agent.System = sys + addendum
4442+ case !active && has:
4443+ i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n"
4444+ }
4445+}
4446+
4447+// applyAutoSwarmTool registers (active=true) or removes (active=false)
4448+// the swarm_spawn tool on the running agent so the model only sees it
4449+// when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'
4450+// snapshot+mutate pattern so extension tools and /reload-ext additions
4451+// survive a toggle.
4452+func (i *Interactive) applyAutoSwarmTool(active bool) {
4453+ if i.agent == nil {
4454+ return
4455+ }
4456+ current := i.agent.Tools
4457+ next := core.Registry{}
4458+ for name, t := range current {
4459+ if name == "swarm_spawn" {
4460+ continue
4461+ }
4462+ next[name] = t
4463+ }
4464+ if active && i.cfg.Swarm != nil {
4465+ next["swarm_spawn"] = &tools.SwarmSpawnTool{
4466+ Swarm: i.cfg.Swarm,
4467+ Enabled: func() bool { return true },
4468+ }
4469+ }
4470+ i.agent.SetTools(next)
4471+}
4472+
43564473 // applyTelegramTools registers (active=true) or removes (active=false)
43574474 // the telegram_send_image and telegram_send_file tools on the running
43584475 // agent so the model only sees them while the bridge is connected.
@@ -39,7 +39,18 @@ type InteractiveConfig struct {
39 // auto-detect and render when supported; false disables; true uses39 // auto-detect and render when supported; false disables; true uses
40 // the detected protocol when available.40 // the detected protocol when available.
41 InlineImagesEnabled *bool41 InlineImagesEnabled *bool
42- SettingsStore SettingsStore42+
43+ // AutoSwarmEnabled mirrors the persisted config flag at startup so
44+ // the /settings dialog can render the current state without
45+ // re-reading config.json on every open.
46+ AutoSwarmEnabled *bool
47+
48+ // AutoSwarmSystemAddendum is the system-prompt block that gets
49+ // appended/stripped when the user toggles auto-swarm at runtime.
50+ // Plumbed in from the cli so this package doesn't have to import
51+ // agent (cycle).
52+ AutoSwarmSystemAddendum string
53+ SettingsStore SettingsStore
43 54
44 // Agent is optional. If nil, zot opens without credentials; the55 // Agent is optional. If nil, zot opens without credentials; the
45 // user must /login before they can prompt.56 // user must /login before they can prompt.
@@ -209,6 +220,7 @@ type chatCacheKey struct {
209 // SettingsStore persists user-toggleable settings surfaced by /settings.220 // SettingsStore persists user-toggleable settings surfaced by /settings.
210 type SettingsStore interface {221 type SettingsStore interface {
211 SetInlineImages(enabled bool) error222 SetInlineImages(enabled bool) error
223+ SetAutoSwarm(enabled bool) error
212 }224 }
213 225
214 type Interactive struct {226 type Interactive struct {
@@ -2470,17 +2482,49 @@ func (i *Interactive) openSettingsDialog() {
2470 } else {2482 } else {
2471 imgHint = "terminal supports " + imageProtocolName(detected)2483 imgHint = "terminal supports " + imageProtocolName(detected)
2472 }2484 }
2473- i.settingsDialog.Open([]settingsItem{{2485+
2474- key: "inline_images_enabled",2486+ autoSwarm := false
2475- label: "render images when supported",2487+ if i.cfg.AutoSwarmEnabled != nil {
2476- desc: "draw screenshots inline instead of showing a text placeholder",2488+ autoSwarm = *i.cfg.AutoSwarmEnabled
2477- value: imgEnabled,2489+ }
2478- disabled: imgDisabled,2490+ autoSwarmDisabled := i.cfg.Swarm == nil
2479- hint: imgHint,2491+ autoSwarmHint := ""
2480- }})2492+ if autoSwarmDisabled {
2493+ autoSwarm = false
2494+ autoSwarmHint = "swarm supervisor not available in this mode"
2495+ }
2496+
2497+ i.settingsDialog.Open([]settingsItem{
2498+ {
2499+ key: "inline_images_enabled",
2500+ label: "render images when supported",
2501+ desc: "draw screenshots inline instead of showing a text placeholder",
2502+ value: imgEnabled,
2503+ disabled: imgDisabled,
2504+ hint: imgHint,
2505+ },
2506+ {
2507+ key: "auto_swarm_enabled",
2508+ label: "auto-swarm",
2509+ desc: "let the agent spawn background sub-agents in parallel via the swarm_spawn tool",
2510+ value: autoSwarm,
2511+ disabled: autoSwarmDisabled,
2512+ hint: autoSwarmHint,
2513+ },
2514+ })
2481 }2515 }
2482 2516
2483 func (i *Interactive) applySettingToggle(key string, value bool) {2517 func (i *Interactive) applySettingToggle(key string, value bool) {
2518+ // Every setting toggle forces a full repaint at the end — same
2519+ // effect as the user pressing Ctrl+L — so any per-setting visual
2520+ // change (image rendering, status copy, future toggles) lands
2521+ // immediately instead of waiting for the next diff frame.
2522+ defer func() {
2523+ if i.rend != nil {
2524+ i.rend.Clear()
2525+ }
2526+ i.invalidate()
2527+ }()
2484 switch key {2528 switch key {
2485 case "inline_images_enabled":2529 case "inline_images_enabled":
2486 val := value2530 val := value
@@ -2499,6 +2543,30 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
2499 i.statusOK = "inline image rendering " + onOff(value)2543 i.statusOK = "inline image rendering " + onOff(value)
2500 i.statusErr = ""2544 i.statusErr = ""
2501 i.mu.Unlock()2545 i.mu.Unlock()
2546+ case "auto_swarm_enabled":
2547+ val := value
2548+ i.cfg.AutoSwarmEnabled = &val
2549+ if i.cfg.SettingsStore != nil {
2550+ if err := i.cfg.SettingsStore.SetAutoSwarm(value); err != nil {
2551+ i.mu.Lock()
2552+ i.statusErr = "settings: " + err.Error()
2553+ i.mu.Unlock()
2554+ return
2555+ }
2556+ }
2557+ // Add/remove the swarm_spawn tool on the live agent so the
2558+ // model's tools[] list reflects the toggle on the next turn.
2559+ // Without this the tool stays advertised after a disable and
2560+ // the model keeps trying to call it.
2561+ i.applyAutoSwarmTool(value)
2562+ // Also swap the system-prompt addendum in/out so the model
2563+ // knows to use the tool proactively (or stops referencing it
2564+ // after a disable).
2565+ i.applyAutoSwarmSystemPrompt(value)
2566+ i.mu.Lock()
2567+ i.statusOK = "auto-swarm " + onOff(value)
2568+ i.statusErr = ""
2569+ i.mu.Unlock()
2502 }2570 }
2503 }2571 }
2504 2572
@@ -2790,10 +2858,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
2790 i.modelDialog.Open(i.cfg.Model, loggedIn)2858 i.modelDialog.Open(i.cfg.Model, loggedIn)
2791 }2859 }
2792 case "/settings":2860 case "/settings":
2793- i.mu.Lock()2861+ i.openSettingsDialog()
2794- i.statusErr = "/settings is temporarily disabled"
2795- i.statusOK = ""
2796- i.mu.Unlock()
2797 case "/sessions":2862 case "/sessions":
2798 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)2863 i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD)
2799 case "/jump":2864 case "/jump":
@@ -4353,6 +4418,58 @@ func (a telegramSenderAdapter) Active() bool {
4353 return a.bridge != nil && a.bridge.Active()4418 return a.bridge != nil && a.bridge.Active()
4354 }4419 }
4355 4420
4421+// applyAutoSwarmSystemPrompt appends (active=true) or strips
4422+// (active=false) the auto-swarm system-prompt block on the running
4423+// agent so the model proactively considers swarm_spawn when the user
4424+// flips the toggle. The block lives at the tail of agent.System so
4425+// stripping is a plain suffix-trim; idempotent in both directions.
4426+func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
4427+ if i.agent == nil {
4428+ return
4429+ }
4430+ addendum := i.cfg.AutoSwarmSystemAddendum
4431+ if addendum == "" {
4432+ return
4433+ }
4434+ sys := i.agent.System
4435+ has := strings.Contains(sys, addendum)
4436+ switch {
4437+ case active && !has:
4438+ if sys != "" && !strings.HasSuffix(sys, "\n\n") {
4439+ sys += "\n\n"
4440+ }
4441+ i.agent.System = sys + addendum
4442+ case !active && has:
4443+ i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n"
4444+ }
4445+}
4446+
4447+// applyAutoSwarmTool registers (active=true) or removes (active=false)
4448+// the swarm_spawn tool on the running agent so the model only sees it
4449+// when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'
4450+// snapshot+mutate pattern so extension tools and /reload-ext additions
4451+// survive a toggle.
4452+func (i *Interactive) applyAutoSwarmTool(active bool) {
4453+ if i.agent == nil {
4454+ return
4455+ }
4456+ current := i.agent.Tools
4457+ next := core.Registry{}
4458+ for name, t := range current {
4459+ if name == "swarm_spawn" {
4460+ continue
4461+ }
4462+ next[name] = t
4463+ }
4464+ if active && i.cfg.Swarm != nil {
4465+ next["swarm_spawn"] = &tools.SwarmSpawnTool{
4466+ Swarm: i.cfg.Swarm,
4467+ Enabled: func() bool { return true },
4468+ }
4469+ }
4470+ i.agent.SetTools(next)
4471+}
4472+
4356 // applyTelegramTools registers (active=true) or removes (active=false)4473 // applyTelegramTools registers (active=true) or removes (active=false)
4357 // the telegram_send_image and telegram_send_file tools on the running4474 // the telegram_send_image and telegram_send_file tools on the running
4358 // agent so the model only sees them while the bridge is connected.4475 // agent so the model only sees them while the bridge is connected.
M internal/agent/modes/slash_suggest.go +1 -0
@@ -50,6 +50,7 @@ var slashCatalog = []slashCommand{
5050 {Name: "/swarm", Desc: "supervise background agents that share this working directory"},
5151 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
5252 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},
53+ {Name: "/settings", Desc: "open settings"},
5354 {Name: "/clear", Desc: "clear the chat transcript"},
5455 {Name: "/exit", Desc: "exit zot"},
5556 }
@@ -50,6 +50,7 @@ var slashCatalog = []slashCommand{
50 {Name: "/swarm", Desc: "supervise background agents that share this working directory"},50 {Name: "/swarm", Desc: "supervise background agents that share this working directory"},
51 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},51 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
52 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},52 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},
53+ {Name: "/settings", Desc: "open settings"},
53 {Name: "/clear", Desc: "clear the chat transcript"},54 {Name: "/clear", Desc: "clear the chat transcript"},
54 {Name: "/exit", Desc: "exit zot"},55 {Name: "/exit", Desc: "exit zot"},
55 }56 }
M internal/agent/settings_store.go +29 -0
@@ -10,3 +10,32 @@ func (configSettingsStore) SetInlineImages(enabled bool) error {
1010 cfg.InlineImagesEnabled = &enabled
1111 return SaveConfig(cfg)
1212 }
13+
14+func (configSettingsStore) SetAutoSwarm(enabled bool) error {
15+ cfg, err := LoadConfig()
16+ if err != nil {
17+ return err
18+ }
19+ cfg.AutoSwarmEnabled = &enabled
20+ return SaveConfig(cfg)
21+}
22+
23+// AutoSwarmEnabled reads the current auto-swarm flag from config.
24+// Used by the swarm_spawn tool at call time to gate execution.
25+func AutoSwarmEnabled() bool {
26+ cfg, err := LoadConfig()
27+ if err != nil {
28+ return false
29+ }
30+ return cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled
31+}
32+
33+// AutoSwarmSystemAddendum is appended to the system prompt when
34+// auto-swarm is enabled, so the model knows it may delegate to
35+// background sub-agents without the user having to mention the tool
36+// by name. Kept short so it doesn't bloat the cached prompt prefix.
37+const AutoSwarmSystemAddendum = `Auto-swarm is enabled. You have a swarm_spawn tool that forks background sub-agents working in parallel in this same working directory.
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.
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.`
@@ -10,3 +10,32 @@ func (configSettingsStore) SetInlineImages(enabled bool) error {
10 cfg.InlineImagesEnabled = &enabled10 cfg.InlineImagesEnabled = &enabled
11 return SaveConfig(cfg)11 return SaveConfig(cfg)
12 }12 }
13+
14+func (configSettingsStore) SetAutoSwarm(enabled bool) error {
15+ cfg, err := LoadConfig()
16+ if err != nil {
17+ return err
18+ }
19+ cfg.AutoSwarmEnabled = &enabled
20+ return SaveConfig(cfg)
21+}
22+
23+// AutoSwarmEnabled reads the current auto-swarm flag from config.
24+// Used by the swarm_spawn tool at call time to gate execution.
25+func AutoSwarmEnabled() bool {
26+ cfg, err := LoadConfig()
27+ if err != nil {
28+ return false
29+ }
30+ return cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled
31+}
32+
33+// AutoSwarmSystemAddendum is appended to the system prompt when
34+// auto-swarm is enabled, so the model knows it may delegate to
35+// background sub-agents without the user having to mention the tool
36+// by name. Kept short so it doesn't bloat the cached prompt prefix.
37+const AutoSwarmSystemAddendum = `Auto-swarm is enabled. You have a swarm_spawn tool that forks background sub-agents working in parallel in this same working directory.
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.
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.`
A internal/agent/tools/swarm_spawn.go +125 -0
@@ -0,0 +1,125 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/patriceckhart/zot/internal/core"
10+ "github.com/patriceckhart/zot/internal/provider"
11+ "github.com/patriceckhart/zot/internal/swarm"
12+)
13+
14+// SwarmSpawnTool lets the main agent fork a background sub-agent
15+// against the host's cwd via swarm.Swarm.SpawnReq. The sub-agent runs
16+// in parallel: the tool returns the agent id immediately and the main
17+// turn continues uninterrupted. The user can monitor / chat with the
18+// spawned agent via /swarm.
19+//
20+// Gated by the auto_swarm_enabled config flag at call time so a user
21+// can flip it off mid-session and the next call refuses cleanly
22+// without re-registering the tool.
23+type SwarmSpawnTool struct {
24+ // Swarm is the supervisor used to spawn agents. Nil means
25+ // "auto-swarm not available in this mode" and the tool always
26+ // errors.
27+ Swarm *swarm.Swarm
28+
29+ // Enabled reads the live config flag. Lets users toggle from
30+ // /settings without rebuilding the agent. When nil, the tool
31+ // is treated as disabled.
32+ Enabled func() bool
33+}
34+
35+type swarmSpawnArgs struct {
36+ Task string `json:"task"`
37+ Model string `json:"model,omitempty"`
38+ Provider string `json:"provider,omitempty"`
39+}
40+
41+const swarmSpawnSchema = `{
42+ "type": "object",
43+ "properties": {
44+ "task": {
45+ "type": "string",
46+ "description": "The full task description for the sub-agent. Be specific: the sub-agent has the same tools (read/write/edit/bash) and shares this working directory, but starts with NO context from this conversation."
47+ },
48+ "model": {
49+ "type": "string",
50+ "description": "Optional model id to pin the sub-agent to (e.g. \"claude-sonnet-4-5\", \"gpt-5\"). Defaults to the host's current model."
51+ },
52+ "provider": {
53+ "type": "string",
54+ "description": "Optional provider id (e.g. \"anthropic\", \"openai\"). Usually paired with model."
55+ }
56+ },
57+ "required": ["task"]
58+}`
59+
60+func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" }
61+func (t *SwarmSpawnTool) Description() string {
62+ return "Spawn a background sub-agent to work on a parallel sub-task. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. Useful for splitting independent work (write tests while implementing a feature, refactor module A while drafting module B). The sub-agent shares this working directory and has the same tools."
63+}
64+func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) }
65+
66+func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
67+ if t.Swarm == nil {
68+ return toolErr("swarm_spawn: swarm supervisor not available in this mode"), nil
69+ }
70+ if t.Enabled == nil || !t.Enabled() {
71+ return toolErr("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks."), nil
72+ }
73+ var a swarmSpawnArgs
74+ if err := json.Unmarshal(raw, &a); err != nil {
75+ return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
76+ }
77+ task := strings.TrimSpace(a.Task)
78+ if task == "" {
79+ return toolErr("swarm_spawn: task is required"), nil
80+ }
81+
82+ agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
83+ Task: task,
84+ Model: strings.TrimSpace(a.Model),
85+ Provider: strings.TrimSpace(a.Provider),
86+ })
87+ if err != nil {
88+ return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err)
89+ }
90+
91+ var sb strings.Builder
92+ fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
93+ fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
94+ if a.Model != "" {
95+ fmt.Fprintf(&sb, "model: %s\n", a.Model)
96+ }
97+ if a.Provider != "" {
98+ fmt.Fprintf(&sb, "provider: %s\n", a.Provider)
99+ }
100+ sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ")
101+ sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
102+ return core.ToolResult{
103+ Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
104+ Details: map[string]any{
105+ "agent_id": agent.ID,
106+ "task": task,
107+ "model": a.Model,
108+ "provider": a.Provider,
109+ },
110+ }, nil
111+}
112+
113+func toolErr(msg string) core.ToolResult {
114+ return core.ToolResult{
115+ Content: []provider.Content{provider.TextBlock{Text: msg}},
116+ IsError: true,
117+ }
118+}
119+
120+func truncateTask(s string, n int) string {
121+ if len(s) <= n {
122+ return s
123+ }
124+ return s[:n-3] + "..."
125+}
@@ -0,0 +1,125 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/patriceckhart/zot/internal/core"
10+ "github.com/patriceckhart/zot/internal/provider"
11+ "github.com/patriceckhart/zot/internal/swarm"
12+)
13+
14+// SwarmSpawnTool lets the main agent fork a background sub-agent
15+// against the host's cwd via swarm.Swarm.SpawnReq. The sub-agent runs
16+// in parallel: the tool returns the agent id immediately and the main
17+// turn continues uninterrupted. The user can monitor / chat with the
18+// spawned agent via /swarm.
19+//
20+// Gated by the auto_swarm_enabled config flag at call time so a user
21+// can flip it off mid-session and the next call refuses cleanly
22+// without re-registering the tool.
23+type SwarmSpawnTool struct {
24+ // Swarm is the supervisor used to spawn agents. Nil means
25+ // "auto-swarm not available in this mode" and the tool always
26+ // errors.
27+ Swarm *swarm.Swarm
28+
29+ // Enabled reads the live config flag. Lets users toggle from
30+ // /settings without rebuilding the agent. When nil, the tool
31+ // is treated as disabled.
32+ Enabled func() bool
33+}
34+
35+type swarmSpawnArgs struct {
36+ Task string `json:"task"`
37+ Model string `json:"model,omitempty"`
38+ Provider string `json:"provider,omitempty"`
39+}
40+
41+const swarmSpawnSchema = `{
42+ "type": "object",
43+ "properties": {
44+ "task": {
45+ "type": "string",
46+ "description": "The full task description for the sub-agent. Be specific: the sub-agent has the same tools (read/write/edit/bash) and shares this working directory, but starts with NO context from this conversation."
47+ },
48+ "model": {
49+ "type": "string",
50+ "description": "Optional model id to pin the sub-agent to (e.g. \"claude-sonnet-4-5\", \"gpt-5\"). Defaults to the host's current model."
51+ },
52+ "provider": {
53+ "type": "string",
54+ "description": "Optional provider id (e.g. \"anthropic\", \"openai\"). Usually paired with model."
55+ }
56+ },
57+ "required": ["task"]
58+}`
59+
60+func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" }
61+func (t *SwarmSpawnTool) Description() string {
62+ return "Spawn a background sub-agent to work on a parallel sub-task. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. Useful for splitting independent work (write tests while implementing a feature, refactor module A while drafting module B). The sub-agent shares this working directory and has the same tools."
63+}
64+func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) }
65+
66+func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
67+ if t.Swarm == nil {
68+ return toolErr("swarm_spawn: swarm supervisor not available in this mode"), nil
69+ }
70+ if t.Enabled == nil || !t.Enabled() {
71+ return toolErr("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks."), nil
72+ }
73+ var a swarmSpawnArgs
74+ if err := json.Unmarshal(raw, &a); err != nil {
75+ return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
76+ }
77+ task := strings.TrimSpace(a.Task)
78+ if task == "" {
79+ return toolErr("swarm_spawn: task is required"), nil
80+ }
81+
82+ agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
83+ Task: task,
84+ Model: strings.TrimSpace(a.Model),
85+ Provider: strings.TrimSpace(a.Provider),
86+ })
87+ if err != nil {
88+ return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err)
89+ }
90+
91+ var sb strings.Builder
92+ fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
93+ fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
94+ if a.Model != "" {
95+ fmt.Fprintf(&sb, "model: %s\n", a.Model)
96+ }
97+ if a.Provider != "" {
98+ fmt.Fprintf(&sb, "provider: %s\n", a.Provider)
99+ }
100+ sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ")
101+ sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
102+ return core.ToolResult{
103+ Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
104+ Details: map[string]any{
105+ "agent_id": agent.ID,
106+ "task": task,
107+ "model": a.Model,
108+ "provider": a.Provider,
109+ },
110+ }, nil
111+}
112+
113+func toolErr(msg string) core.ToolResult {
114+ return core.ToolResult{
115+ Content: []provider.Content{provider.TextBlock{Text: msg}},
116+ IsError: true,
117+ }
118+}
119+
120+func truncateTask(s string, n int) string {
121+ if len(s) <= n {
122+ return s
123+ }
124+ return s[:n-3] + "..."
125+}

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