detached b11e6ed

swarm: introduce /swarm dashboard, /btw-style transcript view, and per-session scope

A /swarm subsystem for long-running parallel subagents. Each agent runs
in its own subprocess against a fresh git worktree (branch swarm/<id>)
with its own persistent session file and unix-socket inbox; the parent
zot stays in the main session and pokes / observes them via the
dashboard.

Highlights:

- New internal/swarm package: Agent, Spawn/Resume/Kill/Remove, event log
  (events.jsonl), inbox protocol (listen/dial), worktree manager, exec
  runner that spawns "zot --swarm-agent ...".
- New internal/agent/swarm_agent.go: daemon-mode child entry point.
  Reuses the standard agent loop but persists turns to the supervisor-
  chosen session.json and streams events as JSONL on stdout. Mirror to
  events.jsonl is dormant while the supervisor's stdout pipe is alive so
  events do not get double-written.
- Resume reattaches in place: reuses the same worktree, session, branch
  and inbox path; carries forward the prior transcript replayed from
  events.jsonl. Resume no longer re-fires the original Task as a fresh
  user turn -- that was producing "agent busy; send cancel first" races.
- core.NewSessionAtPath plus an openOrCreateSession fallback so the
  child actually persists its session.json at the supervisor-chosen path
  on first spawn instead of running with sess==nil.
- Dashboard in internal/agent/modes/swarm_dialog.go + swarm_slash.go:
  list / new / kill / remove / resume / logs / send subcommands plus an
  interactive picker. Transcript view is /btw-style: an always-on
  inline editor at the bottom, streaming auto-follow, inline busy
  spinner with the agent's current activity such as "thinking" or
  "tool: edit". /model inside the spawn editor pops the global model
  picker.
- Per-session scope: each spawn is stamped with the host session's id
  and only shows in that session's /swarm dashboard. Pre-upgrade agents
  -- empty session_id -- remain visible everywhere as a safety net. The
  active scope is re-applied whenever loadSession swaps sessions.
- Resolve falls back to the provider's default model when the persisted
  cfg.Model is no longer in the catalogue, warns on stderr, and rewrites
  config.json so the next launch is silent.
- ReadEventLog folds back-to-back same-type identical-payload events
  within 250ms so events.jsonl files polluted by the old supervisor +
  mirror double-write read back cleanly.
- DrawLog gains an idle no-op fast path: identical buffer plus identical
  cursor = emit nothing, so the terminal's cursor blink keeps ticking in
  dialogs whose underlying agent is idle.

Slash UX:

- New /swarm command with subcommands; the suggester picks it up.
- README.md documents the full dashboard, CLI, and persistence story,
  and explicitly notes that /session export does NOT bundle subagents
  -- their worktree and unix-socket inbox cannot round-trip through a
  .zotsession.

Tests cover: SpawnReq + Resume lifecycle, session-id scoping + persistence,
default-child-args spawn vs resume contract, NewSessionAtPath at a fixed
path, model fallback when the configured model is gone, swarm dialog
behaviour -- auto-open editor, /model in spawn editor, transcript grows
without internal scroll, busy spinner, multi-message send -- event-log
dedup, swarm emitter dormant-until-orphan, and the DrawLog idle no-op +
change-breaks-fast-path invariants.
commit
b11e6ed4e44a9e0a011000ce8fffc89807fa30fb
author
patriceckhart · · 2026-05-16 09:53 UTC
parent
7970412
signature
unsigned
35 files · +8356 -13
M README.md +45 -0
@@ -187,6 +187,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands:
187 | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. |187 | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. |
188 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |188 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
189 | `/btw` | Side chat with full context that doesn't add to the main thread. |189 | `/btw` | Side chat with full context that doesn't add to the main thread. |
190+| `/swarm` | Spawn, monitor, and chat with background subagents. Each gets its own git worktree and runs in parallel with your main session. |
190 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |191 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
191 | `/compact` | Summarize the transcript into one message to free up context. |192 | `/compact` | Summarize the transcript into one message to free up context. |
192 | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. |193 | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. |
@@ -208,6 +209,8 @@ Shows previous sessions for the current working directory, newest first, with ti
208 Four ops on the current session. `/session` alone opens a picker; each is also runnable directly.209 Four ops on the current session. `/session` alone opens a picker; each is also runnable directly.
209 210
210 - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout.211 - **`/session export [path]`**. Writes the running transcript to a portable `.zotsession` file. Default destination is `~/Downloads/<timestamp>-<session-id>-<prompt-slug>.zotsession`. Pass a path to override; a directory is fine (a dated name is built inside), a bare name gets `.zotsession` appended. The meta's cwd is stripped on the way out so the recipient doesn't see your filesystem layout.
212+
213+ **What's included.** Only the main chat thread of the running session — messages, tool calls, tool results, compactions, and usage. **`/swarm` subagents are NOT included.** Their transcripts, worktrees (which are real git checkouts under `$ZOT_HOME/swarm/worktrees/<id>` on branch `swarm/<id>`), unix-socket inboxes, and per-agent session files are all machine-local; a `.zotsession` is just a chat transcript and has no way to bundle a git worktree or revive a unix socket on another box. If you want to share a subagent's work, use normal git tooling on its branch (`git push`, `git format-patch`, etc.); if you want the conversation, copy it out of the dashboard manually.
211 - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions`, `/jump`, and the tree. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically).214 - **`/session import <path>`**. Copies a `.zotsession` file into `$ZOT_HOME/sessions/<cwd-hash>/` with a fresh id and the current cwd, then switches the running agent onto it. Imported sessions are first-class: they show up in `/sessions`, `/jump`, and the tree. Drag-drop paths in the editor are accepted (zot strips the surrounding quotes automatically).
212 - **`/session fork`**. Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies every message up to and including that turn into a new session, records `parent` + `fork_point` in the new meta, and switches onto the branch. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path.215 - **`/session fork`**. Opens a turn picker (same shape as `/jump`). Pick any past user message; zot copies every message up to and including that turn into a new session, records `parent` + `fork_point` in the new meta, and switches onto the branch. The parent session stays on disk. Use it to try a different question without polluting the original transcript, or to rewind after the agent went down the wrong path.
213 - **`/session tree`**. Shows every session in the current cwd arranged by parent/child relationships, depth-first with indent per level. The current session is tagged `[current]`. Pick any entry to switch into it. Parentless sessions are roots; branches created via `/session fork` nest under whichever session they were forked from. Orphaned children (whose parent file was deleted) still show as roots so they stay discoverable.216 - **`/session tree`**. Shows every session in the current cwd arranged by parent/child relationships, depth-first with indent per level. The current session is tagged `[current]`. Pick any entry to switch into it. Parentless sessions are roots; branches created via `/session fork` nest under whichever session they were forked from. Orphaned children (whose parent file was deleted) still show as roots so they stay discoverable.
@@ -231,6 +234,48 @@ Each question fires a one-off model call against `system + main transcript + sid
231 234
232 Inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes the overlay if idle), `ctrl+c` closes immediately. Side-chat exchanges never touch the transcript and aren't persisted to the session file.235 Inside the overlay: `enter` sends, `esc` cancels an in-flight call (or closes the overlay if idle), `ctrl+c` closes immediately. Side-chat exchanges never touch the transcript and aren't persisted to the session file.
233 236
237+### `/swarm`
238+
239+Long-running parallel subagents. Each one gets its own git worktree off your current repo (branch `swarm/<id>`), its own persistent session file, and its own background subprocess driving the model. You stay in the main session and check in on them whenever you want — the dashboard is a `/btw`-style chat per agent.
240+
241+Why: “go write the test harness for module X” / “investigate this stack trace” / “draft the migration” are perfect side tasks. Spawn an agent, keep working in your main session, come back to its results later — or chat with it interactively while it works.
242+
243+```
244+/swarm # open the dashboard
245+/swarm new <task> # spawn an agent on a fresh worktree
246+/swarm new --model gpt-5 <task> # pin the new agent to a specific model
247+/swarm logs <id> # jump straight into one agent's transcript
248+/swarm send <id> <text> # send a follow-up without opening the dashboard
249+/swarm resume # pick a stopped agent to bring back
250+/swarm resume <id> # bring a specific agent back
251+/swarm kill <id> # stop a running agent (worktree stays)
252+/swarm remove <id> # delete the worktree + session for an agent
253+/swarm list # alias for opening the dashboard
254+```
255+
256+**Dashboard (`/swarm` with no arg)** — a list of every agent for the current session with status, age, and current activity. Keys:
257+
258+- `↑` / `↓` move the cursor between rows.
259+- `enter` opens the highlighted agent's transcript view.
260+- `n` spawns a new agent. Type the task, `enter` to confirm; the new agent inherits the model your main session is currently on (see `/model` and the in-editor `/model` command below).
261+- `p` opens a one-off prompt editor for the selected row (alternative to entering the transcript).
262+- `R` resumes a stopped agent in place.
263+- `k` kills the selected running agent (its worktree and session stay so you can resume later).
264+- `r` removes the selected agent entirely (worktree + session + meta gone).
265+- `esc` closes the dashboard.
266+
267+**Inside an agent's transcript** — a chat overlay just like `/btw`. The agent's conversation flows above an always-on inline composer; type and hit `enter` to send a follow-up. The view auto-follows streaming output and shows an inline spinner with the agent's current activity (`thinking`, `tool: edit_file`, etc.) while it's busy. `esc` returns to the dashboard.
268+
269+**Switching the spawn model from inside the editor** — while composing a task in the `n`-prompt, type `/model` on its own line and `enter`. The same model picker the global `/model` uses pops up; pick a model, the picker closes, and the editor reopens with your typed task intact and the new model pinned for the spawn.
270+
271+**Session scoping** — each agent is stamped with the session that spawned it and only shows up in that session's `/swarm` dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents you spawned in another session keep running and reappear when you switch back. Pre-upgrade agents (no session stamp) are visible from every session as a safety net.
272+
273+**Persistence across zot restarts** — every spawn writes a `meta.json` next to its event log and session file under `$ZOT_HOME/swarm/agents/<id>/`. On the next `zot` launch they show up in the dashboard as **detached**; press `R` (or `/swarm resume <id>`) to bring one back. Resumed agents reattach to the same worktree, session, branch, and inbox socket, so the conversation continues from where it left off.
274+
275+**Where their work lives** — worktrees go under `$ZOT_HOME/swarm/worktrees/<id>` on the branch `swarm/<id>`. Use the normal git tooling to inspect, merge, or rebase (`git worktree list`, `git log swarm/<id>`, etc.). `/swarm remove` deletes both the worktree and the swarm bookkeeping.
276+
277+**`/session export` does NOT bundle subagents.** A `.zotsession` is just the main chat transcript; swarm worktrees and per-agent state are machine-local (a real git worktree on disk and a unix-socket inbox, neither of which round-trips through a JSONL file). To share a subagent's actual work, push its `swarm/<id>` branch with the normal git tooling. To share what it said, copy it out of the transcript view manually.
278+
234 ### `/skills`279 ### `/skills`
235 280
236 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.281 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.
M internal/agent/args.go +19 -0
@@ -17,6 +17,13 @@ const (
17 ModePrint Mode = "print"17 ModePrint Mode = "print"
18 ModeJSON Mode = "json"18 ModeJSON Mode = "json"
19 ModeRPC Mode = "rpc"19 ModeRPC Mode = "rpc"
20+ // ModeSwarmAgent is the long-lived, headless daemon mode used by
21+ // swarm-spawned agents. The binary opens a unix-socket inbox at
22+ // the path provided by --swarm-agent, reads supervisor messages
23+ // off it ("user ...", "cancel", "shutdown"), runs each user turn
24+ // against a persistent session, and streams JSONL events on
25+ // stdout. See internal/swarm/inbox.go for the wire protocol.
26+ ModeSwarmAgent Mode = "swarm-agent"
20 )27 )
21 28
22 // Args holds parsed command-line options.29 // Args holds parsed command-line options.
@@ -85,6 +92,11 @@ type Args struct {
85 Version bool92 Version bool
86 93
87 Prompt string // concatenated positional args94 Prompt string // concatenated positional args
95+
96+ // SwarmAgent is the inbox-socket path when this process is a
97+ // swarm-spawned agent. Empty in every other mode. Set by
98+ // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent.
99+ SwarmAgent string
88 }100 }
89 101
90 // ParseArgs parses the process arguments (excluding argv[0]).102 // ParseArgs parses the process arguments (excluding argv[0]).
@@ -202,6 +214,13 @@ func ParseArgs(in []string) (Args, error) {
202 return a, err214 return a, err
203 }215 }
204 a.CWD = v216 a.CWD = v
217+ case "--swarm-agent":
218+ v, err := want(&i, arg)
219+ if err != nil {
220+ return a, err
221+ }
222+ a.SwarmAgent = v
223+ a.Mode = ModeSwarmAgent
205 case "--tools":224 case "--tools":
206 v, err := want(&i, arg)225 v, err := want(&i, arg)
207 if err != nil {226 if err != nil {
M internal/agent/build.go +63 -3
@@ -118,6 +118,31 @@ func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []To
118 return out118 return out
119 }119 }
120 120
121+// defaultModelForProvider returns the model id zot prefers when the
122+// caller didn't pick one. Mirrors the per-provider switch used at
123+// multiple points in Resolve; centralised so the unknown-model
124+// recovery path and the no-model-configured path can't drift.
125+//
126+// Returns the empty string for "ollama", which has no built-in
127+// default — the caller is expected to special-case ollama and
128+// error or use whatever the user passed.
129+func defaultModelForProvider(prov string) string {
130+ switch prov {
131+ case "openai":
132+ return "gpt-5"
133+ case "kimi":
134+ return "kimi-for-coding"
135+ case "deepseek":
136+ return "deepseek-v4-pro"
137+ case "google":
138+ return "gemini-2.5-pro"
139+ case "ollama":
140+ return ""
141+ default:
142+ return provider.DefaultModel.ID
143+ }
144+}
145+
121 // Resolve merges args, config, and env into a Resolved set.146 // Resolve merges args, config, and env into a Resolved set.
122 //147 //
123 // Unlike the earlier version, Resolve NEVER returns an error for148 // Unlike the earlier version, Resolve NEVER returns an error for
@@ -221,10 +246,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
221 }246 }
222 }247 }
223 resolvedModel, err := provider.FindModel(provName, model)248 resolvedModel, err := provider.FindModel(provName, model)
224- if err != nil && provName != "ollama" {
225- return Resolved{}, err
226- }
227 if err != nil && provName == "ollama" {249 if err != nil && provName == "ollama" {
250+ // ollama is intentionally open-catalogue: any model id the
251+ // local server understands is valid, even if not in the
252+ // baked-in catalog.
228 resolvedModel = provider.Model{253 resolvedModel = provider.Model{
229 Provider: "ollama",254 Provider: "ollama",
230 ID: model,255 ID: model,
@@ -234,6 +259,41 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
234 BaseURL: args.BaseURL,259 BaseURL: args.BaseURL,
235 Source: "ollama",260 Source: "ollama",
236 }261 }
262+ err = nil
263+ }
264+ if err != nil {
265+ // The model the user (or persisted config) asked for is no
266+ // longer in the active catalogue — they probably removed it
267+ // from their models.json or upgraded zot and the id changed.
268+ // Refusing to launch is the wrong move: it strands the user
269+ // with no way to even open the TUI and pick a new model.
270+ // Fall back to the provider's default, warn on stderr, and,
271+ // when the stale id came from the persisted config (not an
272+ // explicit --model flag), repair the config so the warning
273+ // doesn't repeat on every launch.
274+ fallback := defaultModelForProvider(provName)
275+ fm, ferr := provider.FindModel(provName, fallback)
276+ if ferr != nil {
277+ // Even the provider default is gone (catastrophic
278+ // catalogue trim). Last resort: any model on this
279+ // provider, then the global DefaultModel.
280+ if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
281+ fm = candidates[0]
282+ ferr = nil
283+ } else {
284+ fm = provider.DefaultModel
285+ ferr = nil
286+ }
287+ }
288+ fmt.Fprintf(os.Stderr,
289+ "zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
290+ model, fm.ID)
291+ if args.Model == "" && cfg.Model == model {
292+ cfg.Model = fm.ID
293+ _ = SaveConfig(cfg)
294+ }
295+ resolvedModel = fm
296+ model = fm.ID
237 }297 }
238 298
239 // If the model defines a base URL (e.g. local ollama) and the299 // If the model defines a base URL (e.g. local ollama) and the
M internal/agent/build_test.go +63 -0
@@ -45,3 +45,66 @@ func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
45 t.Fatalf("expected no context, got %q", got)45 t.Fatalf("expected no context, got %q", got)
46 }46 }
47 }47 }
48+
49+// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
50+// startup failure caught by the user's screenshot: the persisted
51+// config.json points at a model id that's no longer in the active
52+// catalogue (because they edited models.json or zot's bundled
53+// catalogue changed). Resolve must NOT error — strands the user
54+// with no way to fix it from the TUI — and should repair the config
55+// so the next launch is silent.
56+func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
57+ t.Setenv("ZOT_HOME", t.TempDir())
58+ t.Setenv("OPENAI_API_KEY", "test-key")
59+ // Persist a stale model id.
60+ stale := "gpt-5.5-pro-not-real"
61+ if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
62+ t.Fatal(err)
63+ }
64+
65+ r, err := Resolve(Args{}, false)
66+ if err != nil {
67+ t.Fatalf("Resolve refused to launch with stale model: %v", err)
68+ }
69+ if r.Model == stale {
70+ t.Fatalf("Resolve kept stale model %q", r.Model)
71+ }
72+ if r.Provider != "openai" {
73+ t.Errorf("provider drifted: got %q; want openai", r.Provider)
74+ }
75+
76+ // Config on disk should now hold the fallback so subsequent
77+ // launches don't repeat the warning.
78+ cfg, _ := LoadConfig()
79+ if cfg.Model == stale {
80+ t.Errorf("config.json still pins the stale model %q", cfg.Model)
81+ }
82+ if cfg.Model == "" {
83+ t.Errorf("config.json was emptied; expected the fallback model id")
84+ }
85+}
86+
87+// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
88+// repair-on-disk happens ONLY when the stale id came from the
89+// persisted config. If the user passed --model X explicitly and X is
90+// unknown, we still fall back, but we don't touch their config.
91+func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
92+ t.Setenv("ZOT_HOME", t.TempDir())
93+ t.Setenv("OPENAI_API_KEY", "test-key")
94+ good := "gpt-5"
95+ if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
96+ t.Fatal(err)
97+ }
98+
99+ r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
100+ if err != nil {
101+ t.Fatalf("Resolve errored on unknown --model: %v", err)
102+ }
103+ if r.Model == "gpt-totally-fake" {
104+ t.Errorf("Resolve kept the bogus --model value")
105+ }
106+ cfg, _ := LoadConfig()
107+ if cfg.Model != good {
108+ t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
109+ }
110+}
M internal/agent/cli.go +54 -0
@@ -4,10 +4,12 @@ import (
4 "bufio"4 "bufio"
5 "context"5 "context"
6 "encoding/json"6 "encoding/json"
7+ "errors"
7 "fmt"8 "fmt"
8 "io"9 "io"
9 "os"10 "os"
10 "os/signal"11 "os/signal"
12+ "path/filepath"
11 "strings"13 "strings"
12 "sync"14 "sync"
13 "syscall"15 "syscall"
@@ -20,6 +22,7 @@ import (
20 "github.com/patriceckhart/zot/internal/extproto"22 "github.com/patriceckhart/zot/internal/extproto"
21 "github.com/patriceckhart/zot/internal/provider"23 "github.com/patriceckhart/zot/internal/provider"
22 "github.com/patriceckhart/zot/internal/skills"24 "github.com/patriceckhart/zot/internal/skills"
25+ "github.com/patriceckhart/zot/internal/swarm"
23 "github.com/patriceckhart/zot/internal/tui"26 "github.com/patriceckhart/zot/internal/tui"
24 )27 )
25 28
@@ -218,6 +221,8 @@ func Run(rawArgs []string, version string) error {
218 return runJSONMode(ctx, args, version)221 return runJSONMode(ctx, args, version)
219 case ModeRPC:222 case ModeRPC:
220 return runRPCMode(ctx, args, version)223 return runRPCMode(ctx, args, version)
224+ case ModeSwarmAgent:
225+ return runSwarmAgentMode(ctx, args, version)
221 default:226 default:
222 return runInteractive(ctx, args, version)227 return runInteractive(ctx, args, version)
223 }228 }
@@ -534,6 +539,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
534 539
535 var sess *core.Session540 var sess *core.Session
536 var sessBaselineMsgs int // messages already on disk when current session opened541 var sessBaselineMsgs int // messages already on disk when current session opened
542+ // swarmMgr is constructed below, but loadSession (defined before
543+ // the construction site) needs to re-scope it whenever the user
544+ // swaps sessions. Forward-declare here so the closure can
545+ // reference it; the assignment happens at the construction line.
546+ var swarmMgr *swarm.Swarm
537 // persistMu guards sess + sessBaselineMsgs against concurrent access547 // persistMu guards sess + sessBaselineMsgs against concurrent access
538 // from the agent loop's per-message persistence hook (runs on the548 // from the agent loop's per-message persistence hook (runs on the
539 // agent goroutine) and the TUI's session swap / flush callbacks549 // agent goroutine) and the TUI's session swap / flush callbacks
@@ -654,6 +664,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
654 // the hydrated tail.664 // the hydrated tail.
655 sessBaselineMsgs = fullMsgCount665 sessBaselineMsgs = fullMsgCount
656 persistMu.Unlock()666 persistMu.Unlock()
667+ // Re-scope the swarm dashboard to the new session so /swarm
668+ // only shows agents this session spawned. swarmMgr may be nil
669+ // here if we haven't reached the construction site yet (it
670+ // shouldn't be, since the interactive loop is what triggers
671+ // loadSession, but the nil check is cheap insurance).
672+ if swarmMgr != nil && newSess != nil {
673+ swarmMgr.SetActiveSession(newSess.ID)
674+ }
657 return nil675 return nil
658 }676 }
659 677
@@ -708,6 +726,29 @@ func runInteractive(ctx context.Context, args Args, version string) error {
708 }()726 }()
709 727
710 initialCfg, _ := LoadConfig()728 initialCfg, _ := LoadConfig()
729+
730+ // Build the swarm supervisor. Root lives under ZotHome/swarm so
731+ // worktrees survive across zot sessions and the user can hunt
732+ // them down with `git worktree list` if anything misbehaves.
733+ swarmMgr = swarm.New(swarm.Config{
734+ Root: filepath.Join(ZotHome(), "swarm"),
735+ RepoRoot: r.CWD,
736+ })
737+ // Pull any previously-spawned agents off disk so the user can see,
738+ // resume, or remove them from the dashboard. Failures here aren't
739+ // fatal — the supervisor still works for new agents.
740+ _, _ = swarmMgr.Reload()
741+ // Scope the dashboard to the active host session so /swarm only
742+ // shows agents this session spawned (and any pre-upgrade unscoped
743+ // agents — see SnapshotAll docs). Updated again whenever the
744+ // user swaps sessions via loadSession below.
745+ if sess != nil {
746+ swarmMgr.SetActiveSession(sess.ID)
747+ }
748+ // Best-effort shutdown on interactive exit: stop all running
749+ // agents so they don't outlive their parent zot.
750+ defer swarmMgr.StopAll()
751+
711 iv = modes.NewInteractive(modes.InteractiveConfig{752 iv = modes.NewInteractive(modes.InteractiveConfig{
712 Terminal: term,753 Terminal: term,
713 Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),754 Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
@@ -773,6 +814,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
773 sessBaselineMsgs = len(currentAg.Messages())814 sessBaselineMsgs = len(currentAg.Messages())
774 },815 },
775 Extensions: extMgr,816 Extensions: extMgr,
817+ Swarm: swarmMgr,
776 ChangelogChan: changelogCh,818 ChangelogChan: changelogCh,
777 OnChangelogDismiss: func() {819 OnChangelogDismiss: func() {
778 // For dev builds (0.0.0) store the actual release version820 // For dev builds (0.0.0) store the actual release version
@@ -894,6 +936,18 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
894 switch {936 switch {
895 case args.Session != "":937 case args.Session != "":
896 s, msgs, err = core.OpenSession(args.Session)938 s, msgs, err = core.OpenSession(args.Session)
939+ // The swarm-agent child passes a fixed --session path that
940+ // may not exist yet on first Spawn. Treat ENOENT as "create
941+ // a fresh session AT THIS PATH" so the conversation actually
942+ // gets persisted; without this fallback the swarm child runs
943+ // with sess==nil and every Resume re-starts with no memory
944+ // of the prior turns. Other openers (--continue / --resume /
945+ // the picker) never see ENOENT here because they only choose
946+ // paths that already exist on disk.
947+ if err != nil && errors.Is(err, os.ErrNotExist) {
948+ s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)
949+ msgs = nil
950+ }
897 case args.Continue:951 case args.Continue:
898 latest := core.LatestSession(ZotHome(), args.CWD)952 latest := core.LatestSession(ZotHome(), args.CWD)
899 if latest != "" {953 if latest != "" {
M internal/agent/modes/file_suggest.go +10 -2
@@ -274,8 +274,13 @@ func (s *fileSuggester) Render(input string, th tui.Theme, width int) []string {
274 return out274 return out
275 }275 }
276 276
277-// fileChipRE matches [file:N:name] and [dir:N:name/] chips.277+// fileChipRE matches @-picker chips inserted by modes:
278-var fileChipRE = regexp.MustCompile(`\[(file|dir):(\d+):[^\]]+\]`)278+// [file:relative/path] and [dir:relative/path/]. The lower-level
279+// tui.Editor has its own drag/drop chips ([file:N:basename]) which
280+// Editor.SubmitValue expands before callers reach this helper; if one
281+// leaks through, leave it untouched rather than guessing at a path.
282+var fileChipRE = regexp.MustCompile(`\[(file|dir):([^\]]+)\]`)
283+var editorFileChipRE = regexp.MustCompile(`^\d+:`)
279 284
280 // expandFileChips replaces [file:name] and [dir:name/] chips with285 // expandFileChips replaces [file:name] and [dir:name/] chips with
281 // the full path relative to cwd.286 // the full path relative to cwd.
@@ -286,6 +291,9 @@ func expandFileChips(text, cwd string) string {
286 return match291 return match
287 }292 }
288 name := groups[2]293 name := groups[2]
294+ if editorFileChipRE.MatchString(name) {
295+ return match
296+ }
289 // Strip trailing slash from dir names.297 // Strip trailing slash from dir names.
290 name = strings.TrimRight(name, "/")298 name = strings.TrimRight(name, "/")
291 return filepath.Join(cwd, name)299 return filepath.Join(cwd, name)
A internal/agent/modes/file_suggest_test.go +26 -0
@@ -0,0 +1,26 @@
1+package modes
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+)
7+
8+func TestExpandFileChipsAtPickerShape(t *testing.T) {
9+ cwd := filepath.Join(string(filepath.Separator), "repo")
10+ in := "read [file:README.md] and [dir:docs/]"
11+ want := "read " + filepath.Join(cwd, "README.md") + " and " + filepath.Join(cwd, "docs")
12+ if got := expandFileChips(in, cwd); got != want {
13+ t.Fatalf("expandFileChips() = %q, want %q", got, want)
14+ }
15+}
16+
17+func TestExpandFileChipsLeavesEditorPlaceholderShapeAlone(t *testing.T) {
18+ cwd := filepath.Join(string(filepath.Separator), "repo")
19+ // tui.Editor.SubmitValue should expand [file:N:name] using its
20+ // private path map before modes see the text. If such a token leaks
21+ // through, modes must not guess that "1:foo.txt" is a relative path.
22+ in := "read [file:1:foo.txt]"
23+ if got := expandFileChips(in, cwd); got != in {
24+ t.Fatalf("editor placeholder was changed: %q", got)
25+ }
26+}
M internal/agent/modes/interactive.go +69 -7
@@ -17,6 +17,7 @@ import (
17 "github.com/patriceckhart/zot/internal/extproto"17 "github.com/patriceckhart/zot/internal/extproto"
18 "github.com/patriceckhart/zot/internal/provider"18 "github.com/patriceckhart/zot/internal/provider"
19 "github.com/patriceckhart/zot/internal/skills"19 "github.com/patriceckhart/zot/internal/skills"
20+ "github.com/patriceckhart/zot/internal/swarm"
20 "github.com/patriceckhart/zot/internal/tui"21 "github.com/patriceckhart/zot/internal/tui"
21 )22 )
22 23
@@ -131,6 +132,13 @@ type InteractiveConfig struct {
131 // AFTER the built-in catalog so a built-in name always wins.132 // AFTER the built-in catalog so a built-in name always wins.
132 Extensions *extensions.Manager133 Extensions *extensions.Manager
133 134
135+ // Swarm, if non-nil, enables the /swarm slash command and the
136+ // dashboard dialog. The cli constructs the Swarm once per
137+ // interactive run and tears it down on exit. nil disables the
138+ // feature entirely (used by embedders / tests that don't want
139+ // subprocesses).
140+ Swarm *swarm.Swarm
141+
134 // SkillSnapshot, if non-nil, returns the current list of142 // SkillSnapshot, if non-nil, returns the current list of
135 // discovered SKILL.md files. Re-invoked each time /skills opens143 // discovered SKILL.md files. Re-invoked each time /skills opens
136 // so the picker reflects edits made during the session.144 // so the picker reflects edits made during the session.
@@ -280,6 +288,7 @@ type Interactive struct {
280 modelDialog *modelDialog288 modelDialog *modelDialog
281 rescueDialog *rescueDialog289 rescueDialog *rescueDialog
282 sessionDialog *sessionDialog290 sessionDialog *sessionDialog
291+ swarmDialog *swarmDialog
283 jumpDialog *jumpDialog292 jumpDialog *jumpDialog
284 btwDialog *btwDialog293 btwDialog *btwDialog
285 skillsDialog *skillsDialog294 skillsDialog *skillsDialog
@@ -367,6 +376,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
367 modelDialog: newModelDialog(),376 modelDialog: newModelDialog(),
368 rescueDialog: newRescueDialog(),377 rescueDialog: newRescueDialog(),
369 sessionDialog: newSessionDialog(),378 sessionDialog: newSessionDialog(),
379+ swarmDialog: newSwarmDialog(),
370 jumpDialog: newJumpDialog(),380 jumpDialog: newJumpDialog(),
371 btwDialog: newBtwDialog(),381 btwDialog: newBtwDialog(),
372 skillsDialog: newSkillsDialog(),382 skillsDialog: newSkillsDialog(),
@@ -616,7 +626,16 @@ func (i *Interactive) Run(ctx context.Context) error {
616 // the tick and firing it cancels the terminal's cursor626 // the tick and firing it cancels the terminal's cursor
617 // blink inside dialogs that host their own editor (btw),627 // blink inside dialogs that host their own editor (btw),
618 // because each frame re-emits hide-cursor + show-cursor.628 // because each frame re-emits hide-cursor + show-cursor.
619- if i.busy || i.btwDialog.Loading() {629+ //
630+ // The swarm dashboard is also animated: its rows reflect
631+ // background subprocesses whose activity / age change
632+ // without any user input. Without the tick redraw the
633+ // dashboard freezes on the snapshot taken when the user
634+ // last pressed a key. We exclude the dashboard when one
635+ // of its inline editors (spawn task or prompt composer)
636+ // is active so the cursor blink in those editors works
637+ // the same way it does inside btw.
638+ if i.busy || i.btwDialog.Loading() || i.swarmDialog.NeedsTickRefresh() {
620 requestRedraw()639 requestRedraw()
621 }640 }
622 }641 }
@@ -877,6 +896,8 @@ func (i *Interactive) redraw() {
877 }896 }
878 i.sessionDialog.MaxRows = avail897 i.sessionDialog.MaxRows = avail
879 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)898 dialog = i.sessionDialog.Render(i.cfg.Theme, cols)
899+ case i.swarmDialog.Active():
900+ dialog = i.swarmDialog.Render(i.cfg.Theme, cols)
880 case i.jumpDialog.Active():901 case i.jumpDialog.Active():
881 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)902 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
882 case i.btwDialog.Active():903 case i.btwDialog.Active():
@@ -1024,12 +1045,22 @@ func (i *Interactive) redraw() {
1024 bottom = append(bottom, "")1045 bottom = append(bottom, "")
1025 }1046 }
1026 bottom = append(bottom, dialog...)1047 bottom = append(bottom, dialog...)
1027- bottom = append(bottom, suggest...)1048+ // The swarm dashboard owns the bottom of the screen while it's
1028- bottom = append(bottom, queue...)1049+ // active: it has its own inline editors for spawn (`n`) and
1029- bottom = append(bottom, "")1050+ // prompt (`p`), so the main input would be a confusing second
1030- bottom = append(bottom, statusLines...)1051+ // caret. The suggest popup, sliding-in queue, status block, and
1031- bottom = append(bottom, "")1052+ // main editor are all hidden underneath it. Keystrokes still
1032- bottom = append(bottom, edLines...)1053+ // reach handleKey — it routes them to swarmDialog.HandleKey
1054+ // before the editor ever sees them — so the only effect of this
1055+ // branch is visual.
1056+ if !i.swarmDialog.Active() {
1057+ bottom = append(bottom, suggest...)
1058+ bottom = append(bottom, queue...)
1059+ bottom = append(bottom, "")
1060+ bottom = append(bottom, statusLines...)
1061+ bottom = append(bottom, "")
1062+ bottom = append(bottom, edLines...)
1063+ }
1033 1064
1034 _, rows := i.cfg.Terminal.Size()1065 _, rows := i.cfg.Terminal.Size()
1035 chatRows := rows - len(bottom)1066 chatRows := rows - len(bottom)
@@ -1167,6 +1198,19 @@ func (i *Interactive) redraw() {
1167 cursorCol = c1198 cursorCol = c
1168 }1199 }
1169 }1200 }
1201+ if i.swarmDialog.Active() {
1202+ if r, c := i.swarmDialog.CursorPos(cols); r >= 0 {
1203+ cursorRow = dialogLead + r
1204+ cursorCol = c
1205+ } else {
1206+ // Dashboard list / transcript view has no caret. Without
1207+ // this branch the default cursorRow points at the
1208+ // (hidden) main editor row, so the terminal would draw
1209+ // a stray block somewhere in the chat region.
1210+ cursorRow = -1
1211+ cursorCol = 0
1212+ }
1213+ }
1170 if i.extPanel.Active() {1214 if i.extPanel.Active() {
1171 cursorRow = -11215 cursorRow = -1
1172 cursorCol = 01216 cursorCol = 0
@@ -1477,6 +1521,22 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1477 }1521 }
1478 return false1522 return false
1479 }1523 }
1524+ if i.swarmDialog.Active() {
1525+ if k.Kind == tui.KeyCtrlC {
1526+ i.swarmDialog.Close()
1527+ i.invalidate()
1528+ return false
1529+ }
1530+ _, msg, errMsg := i.swarmDialog.HandleKey(k)
1531+ if msg != "" || errMsg != "" {
1532+ i.mu.Lock()
1533+ i.statusOK = msg
1534+ i.statusErr = errMsg
1535+ i.mu.Unlock()
1536+ }
1537+ i.invalidate()
1538+ return false
1539+ }
1480 if i.logoutDialog.Active() {1540 if i.logoutDialog.Active() {
1481 if k.Kind == tui.KeyCtrlC {1541 if k.Kind == tui.KeyCtrlC {
1482 i.logoutDialog.Close()1542 i.logoutDialog.Close()
@@ -2395,6 +2455,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
2395 break2455 break
2396 }2456 }
2397 i.openSessionOpsDialog()2457 i.openSessionOpsDialog()
2458+ case "/swarm":
2459+ i.runSwarm(ctx, parts[1:])
2398 default:2460 default:
2399 // Last-resort fallback: try the extension manager. Built-in2461 // Last-resort fallback: try the extension manager. Built-in
2400 // cases above always win; this branch only fires for slash2462 // cases above always win; this branch only fires for slash
M internal/agent/modes/slash_suggest.go +1 -0
@@ -45,6 +45,7 @@ var slashCatalog = []slashCommand{
45 {Name: "/jail", Desc: "confine tools to the current directory"},45 {Name: "/jail", Desc: "confine tools to the current directory"},
46 {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"},46 {Name: "/unjail", Desc: "allow tools to touch paths outside this directory"},
47 {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"},47 {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"},
48+ {Name: "/swarm", Desc: "supervise background agents working in their own worktrees"},
48 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},49 {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"},
49 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},50 {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"},
50 {Name: "/clear", Desc: "clear the chat transcript"},51 {Name: "/clear", Desc: "clear the chat transcript"},
M internal/agent/modes/slash_suggest_test.go +7 -0
@@ -21,6 +21,13 @@ func TestSlashSuggesterHidesUnjailUntilJailed(t *testing.T) {
21 }21 }
22 }22 }
23 23
24+func TestSlashSuggesterHasSwarm(t *testing.T) {
25+ s := newSlashSuggester()
26+ if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") {
27+ t.Fatalf("/swarm missing from suggestions, got %v", got)
28+ }
29+}
30+
24 func commandNames(cmds []slashCommand) []string {31 func commandNames(cmds []slashCommand) []string {
25 out := make([]string, 0, len(cmds))32 out := make([]string, 0, len(cmds))
26 for _, c := range cmds {33 for _, c := range cmds {
A internal/agent/modes/swarm_dialog.go +1382 -0
@@ -0,0 +1,1382 @@
1+package modes
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strings"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/internal/swarm"
10+ "github.com/patriceckhart/zot/internal/tui"
11+)
12+
13+// swarmDialog is the dashboard shown by /swarm (no argument) and
14+// /swarm list. It's a read-only window onto a *swarm.Swarm; the
15+// caller passes a snapshotter so the dialog never holds the swarm
16+// pointer or its lock.
17+//
18+// Keys (list view):
19+//
20+// n spawn a new agent (inline task prompt)
21+// p send a follow-up prompt to the selected agent
22+// R resume (restart) a detached / finished agent
23+// ↑/↓ move cursor
24+// enter show transcript tail for the selected agent
25+// k kill (Stop) the selected running agent
26+// r remove a terminated agent (deletes worktree)
27+// esc / q close
28+//
29+// Keys (transcript view):
30+//
31+// p send a follow-up prompt to this agent (inline editor)
32+// ↑/↓/PgUp/PgDn/Home/End scroll
33+// esc / q return to the list
34+type swarmDialog struct {
35+ active bool
36+ snapshot func() []swarm.AgentSnapshot
37+ stop func(id string) error
38+ remove func(id string) error
39+ // spawn accepts an optional model + provider override (empty
40+ // strings mean "let the child resolve its own default"). The cli
41+ // adapter forwards these to swarm.Swarm.SpawnReq.
42+ spawn func(task, model, provider string) error
43+ // send delivers a follow-up user turn to a running agent's inbox.
44+ // Wired by Open(); when nil the inline 'p' shortcut is disabled.
45+ send func(id, text string) error
46+ // resume restarts a detached or terminated agent on its existing
47+ // worktree/session. Wired by Open(); when nil the inline 'R'
48+ // shortcut is disabled.
49+ resume func(id string) error
50+
51+ rows []swarm.AgentSnapshot
52+ cursor int
53+ viewing bool // when true, show full transcript of the selected row
54+
55+ // transcriptSpin animates the busy hint shown above the inline
56+ // editor while the agent is mid-turn (status running, activity
57+ // anything other than idle). Same shape as the /btw spinner and
58+ // the main chat's busy line, so the user gets a consistent
59+ // "something's happening" signal. Lazily created on first
60+ // busy frame; reset by Open / Close.
61+ transcriptSpin *spinner
62+
63+ // spawning toggles the inline "enter task" editor. While true the
64+ // dialog captures keys into a real tui.Editor so swarm-new has the
65+ // same editing, paste, drag-drop file chips, and visual style as
66+ // the default input and /btw input.
67+ spawning bool
68+ newTaskEd *tui.Editor
69+ fileSuggest *fileSuggester
70+ cwd string
71+
72+ // pendingModel / pendingProvider are the model + provider every
73+ // freshly-spawned agent will be pinned to. They're seeded from
74+ // the host's currently-active model (via SetCurrentModel) at the
75+ // moment the user opens the dashboard, so swarm agents inherit
76+ // whatever the user last picked via /model. Empty means "don't
77+ // pin a model; let the child resolve its own default".
78+ pendingModel string
79+ pendingProvider string
80+
81+ // pickingModel + modelPicker drive the in-spawn-editor /model
82+ // command: while typing a task into the spawn editor, the user
83+ // can submit just "/model" to pop the same picker /model uses
84+ // globally, choose a different model for this spawn, and resume
85+ // the editor with the new pending model captured. Empty when
86+ // the picker isn't on screen.
87+ pickingModel bool
88+ modelPicker *modelDialog
89+ modelPickerLoggedIn []string
90+
91+ // spawnDraft remembers the editor buffer when we suspend it to
92+ // show the model picker, so the user doesn't lose half-typed
93+ // task text just because they wanted to switch models. Restored
94+ // when the picker closes (whether by select or Esc).
95+ spawnDraft string
96+
97+ // prompting toggles the inline "send prompt to selected agent"
98+ // editor. Distinct from `spawning` so we don't accidentally
99+ // route a follow-up into Swarm.Spawn. The editor and its
100+ // file-suggest live in their own fields so opening the prompt
101+ // editor while a spawn editor is half-typed doesn't clobber it
102+ // (the dialog enforces one-at-a-time, but the separate state
103+ // keeps the lifecycle obvious).
104+ prompting bool
105+ promptEd *tui.Editor
106+ promptSuggest *fileSuggester
107+ // promptTargetID is the agent id captured the moment the user
108+ // pressed 'p'. We pin it here so the prompt routes to the same
109+ // agent even if the user's cursor moves (or rows reorder) while
110+ // they're typing.
111+ promptTargetID string
112+}
113+
114+func newSwarmDialog() *swarmDialog { return &swarmDialog{} }
115+
116+// SetCurrentModel pins the model + provider every fresh spawn will
117+// inherit. The host wires this to the same Model / Provider the rest
118+// of zot is currently using, so agents started from the dashboard
119+// run on whatever the user last picked via /model. Pass empty
120+// strings to clear the override (the child then resolves its own
121+// default the same way a bare `zot` invocation does).
122+func (d *swarmDialog) SetCurrentModel(model, providerID string) {
123+ d.pendingModel = model
124+ d.pendingProvider = providerID
125+}
126+
127+// SetLoggedInProviders supplies the list of providers the user has
128+// active credentials for. Forwarded to the embedded modelDialog when
129+// the user invokes /model inside the spawn editor so the picker can
130+// filter out unauthenticated providers, matching the global /model
131+// behaviour.
132+func (d *swarmDialog) SetLoggedInProviders(provs []string) {
133+ d.modelPickerLoggedIn = provs
134+}
135+
136+// Open binds the dialog to a swarm via the provided callbacks. The
137+// dialog refreshes the snapshot every Render so the dashboard reads
138+// live state without needing a goroutine. spawn / send are optional;
139+// when nil the corresponding inline shortcut ('n' / 'p') is disabled.
140+func (d *swarmDialog) Open(
141+ snapshot func() []swarm.AgentSnapshot,
142+ stop func(id string) error,
143+ remove func(id string) error,
144+ spawn func(task, model, provider string) error,
145+ send func(id, text string) error,
146+ resume func(id string) error,
147+ cwd string,
148+) {
149+ d.snapshot = snapshot
150+ d.stop = stop
151+ d.remove = remove
152+ d.spawn = spawn
153+ d.send = send
154+ d.resume = resume
155+ d.active = true
156+ d.cursor = 0
157+ d.viewing = false
158+ d.spawning = false
159+ d.newTaskEd = nil
160+ d.fileSuggest = nil
161+ d.prompting = false
162+ d.promptEd = nil
163+ d.promptSuggest = nil
164+ d.promptTargetID = ""
165+ // pendingModel / pendingProvider are NOT cleared here: the host
166+ // sets them via SetCurrentModel just before opening the dialog
167+ // so the spawn flow inherits whatever /model selected. Resetting
168+ // here would undo that.
169+ d.pickingModel = false
170+ d.modelPicker = nil
171+ d.spawnDraft = ""
172+ d.transcriptSpin = nil
173+ d.cwd = cwd
174+ d.refresh()
175+}
176+
177+// CursorPos returns the row/col for the terminal cursor while an
178+// inline editor (spawn or prompt) is active so the caret blinks in
179+// the right spot. Returns -1, -1 when no input is being captured.
180+func (d *swarmDialog) CursorPos(width int) (row, col int) {
181+ if !d.Active() {
182+ return -1, -1
183+ }
184+ // Spawn editor (list view): rendered directly under the frame
185+ // header, optionally preceded by the model banner row (when a
186+ // model was picked) and the @ file-suggest popup, with one
187+ // breathing-room blank row inserted just above the editor. The
188+ // constants below must mirror the spawn-editor render path.
189+ if d.spawning && d.newTaskEd != nil {
190+ modelBannerRows := 0
191+ if d.pendingModel != "" {
192+ modelBannerRows = 1
193+ }
194+ popupRows := 0
195+ if d.fileSuggest != nil && d.fileSuggest.Active(d.newTaskEd.Value()) {
196+ popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width))
197+ }
198+ _, eRow, eCol := d.newTaskEd.Render(width - 2)
199+ const editorBlankRow = 1
200+ return 1 + modelBannerRows + popupRows + editorBlankRow + eRow, eCol + 2
201+ }
202+ // Prompt editor cursor placement. Two flavours:
203+ // 1. List-view modal (`p` from the dashboard list): renderPromptEditor
204+ // draws frame header + "send to <id>:" hint + popup + editor.
205+ // 2. Transcript view (always-on editor, /btw-style): the editor
206+ // lives at the bottom of renderTranscript's output, after the
207+ // metadata block, the "── transcript ──" rule, the transcript
208+ // body itself, optional "↑/↓ N more" rows and the @-popup.
209+ //
210+ // Both flavours funnel through here. The math must mirror the
211+ // corresponding render path exactly.
212+ if d.prompting && d.promptEd != nil {
213+ popupRows := 0
214+ if d.promptSuggest != nil && d.promptSuggest.Active(d.promptEd.Value()) {
215+ popupRows = len(d.promptSuggest.Render(d.promptEd.Value(), tui.Theme{}, width))
216+ }
217+ _, eRow, eCol := d.promptEd.Render(width - 2)
218+
219+ if d.viewing {
220+ return d.transcriptEditorCursorRow(width, popupRows, eRow), eCol + 2
221+ }
222+
223+ // List-view modal layout: frame header + "send to <id>:" hint
224+ // + popup + blank + editor.
225+ const headerRows = 1
226+ const sendToHintRow = 1
227+ const editorBlankRow = 1
228+ return headerRows + sendToHintRow + popupRows + editorBlankRow + eRow, eCol + 2
229+ }
230+ return -1, -1
231+}
232+
233+// transcriptEditorCursorRow computes the visual row the caret
234+// belongs on when the always-on editor sits at the bottom of the
235+// transcript view. Mirrors renderTranscript's row layout so the
236+// terminal cursor stays glued to the editor as the body grows.
237+func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffset int) int {
238+ a := d.selected()
239+ if a == nil {
240+ return -1
241+ }
242+ row := 1 // frame header
243+ row += 4 // task / branch / dir / status
244+ if a.Model != "" {
245+ row++
246+ }
247+ if a.Err != "" {
248+ row++
249+ }
250+ row += 3 // blank + "── transcript ──" + trailing blank
251+
252+ // Replay transcript body sizing (no-transcript-yet path vs.
253+ // real body path) so the editor row count matches what render
254+ // actually emitted.
255+ raw := a.Lines
256+ if len(raw) == 0 && a.Tail != "" {
257+ raw = strings.Split(a.Tail, "\n")
258+ }
259+ if len(raw) == 0 {
260+ row++ // "(no transcript yet)" line
261+ } else {
262+ // Full body, no scroll window — the host's outer scrollback
263+ // owns overflow now. Cursor row math just counts every
264+ // rendered line.
265+ body := renderSwarmTranscriptBlocks(raw, tui.Theme{}, width)
266+ row += len(body)
267+ }
268+
269+ row += popupRows // @-file-suggest popup, if active
270+ if agentIsBusy(a) {
271+ row += 2 // blank + spinner row inserted above the editor
272+ }
273+ row++ // blank above editor (appendTranscriptEditor)
274+ return row + editorRowOffset
275+}
276+
277+func (d *swarmDialog) Active() bool { return d != nil && d.active }
278+func (d *swarmDialog) Close() { d.active = false }
279+
280+// NeedsTickRefresh reports whether the host's animation tick should
281+// repaint the screen while this dialog is open. True when the
282+// dashboard's list/transcript view is on (rows / activity / age and
283+// the transcript itself are background-driven and need a periodic
284+// redraw — essential for streaming agent replies to actually appear
285+// on screen). False only while a modal editor (spawn, list-view
286+// prompt, model picker) has the dialog to itself; those need the
287+// terminal's cursor blink, which a full-frame redraw cancels.
288+//
289+// Note: the transcript view's always-on editor does NOT suppress
290+// tick refresh. The cursor blink still works because the tick rate
291+// is well below the terminal's blink rate, and we need the redraws
292+// for streaming output. /btw makes the same trade-off.
293+func (d *swarmDialog) NeedsTickRefresh() bool {
294+ if d == nil || !d.active {
295+ return false
296+ }
297+ if d.spawning || d.pickingModel {
298+ return false
299+ }
300+ // The list-view `p` modal (prompting without viewing) suppresses
301+ // tick like the other modal editors. The transcript-view
302+ // always-on editor (viewing && prompting) does not — streaming
303+ // agent output needs periodic redraws to render.
304+ if d.prompting && !d.viewing {
305+ return false
306+ }
307+ return true
308+}
309+
310+// OpenViewing opens the dashboard and jumps straight to the
311+// transcript view for the agent with id agentID. Used by /swarm logs
312+// <id>. If no such agent is found the dashboard opens normally so
313+// the user can see why (and pick from the list).
314+func (d *swarmDialog) OpenViewing(
315+ agentID string,
316+ snapshot func() []swarm.AgentSnapshot,
317+ stop func(id string) error,
318+ remove func(id string) error,
319+ spawn func(task, model, provider string) error,
320+ send func(id, text string) error,
321+ resume func(id string) error,
322+ cwd string,
323+) bool {
324+ d.Open(snapshot, stop, remove, spawn, send, resume, cwd)
325+ rows := d.snapshot()
326+ for i, r := range rows {
327+ if r.ID == agentID || strings.HasPrefix(r.ID, agentID) {
328+ d.cursor = i
329+ d.viewing = true
330+ // Auto-open the inline editor for running agents so
331+ // /swarm logs <id> drops the user straight into a
332+ // /btw-style send-ready prompt.
333+ if r.Status == swarm.StatusRunning && d.send != nil {
334+ d.openPromptEditor(tui.Theme{}, r.ID)
335+ }
336+ return true
337+ }
338+ }
339+ return false
340+}
341+
342+// OpenForResume opens the dashboard and parks the cursor on the first
343+// resumable agent (any non-running status), so the user can press R
344+// to resume without typing an id. Returns the count of resumable
345+// agents found — callers surface that as "3 resumable, press R" or,
346+// when zero, "no agents available to resume".
347+//
348+// The dialog still shows the full list (live + detached + terminated)
349+// so the user has context: pressing ↑/↓ they can still walk through
350+// running agents, and the R key is a no-op there (Resume rejects
351+// non-terminal agents with a clear error).
352+func (d *swarmDialog) OpenForResume(
353+ snapshot func() []swarm.AgentSnapshot,
354+ stop func(id string) error,
355+ remove func(id string) error,
356+ spawn func(task, model, provider string) error,
357+ send func(id, text string) error,
358+ resume func(id string) error,
359+ cwd string,
360+) (resumable int) {
361+ d.Open(snapshot, stop, remove, spawn, send, resume, cwd)
362+ // d.rows was populated by Open's refresh(); walk it for the
363+ // first resumable row and count the total in one pass.
364+ for i, r := range d.rows {
365+ if isResumable(r.Status) {
366+ if resumable == 0 {
367+ d.cursor = i
368+ }
369+ resumable++
370+ }
371+ }
372+ return resumable
373+}
374+
375+// isResumable reports whether a Status is eligible for Resume. Mirror
376+// of the precondition inside Swarm.Resume; centralised so the dialog
377+// and the slash handler agree on what "resumable" means.
378+func isResumable(s swarm.Status) bool {
379+ switch s {
380+ case swarm.StatusDetached,
381+ swarm.StatusDone,
382+ swarm.StatusFailed,
383+ swarm.StatusKilled:
384+ return true
385+ }
386+ return false
387+}
388+
389+// promptDisabledHint returns the status-bar message shown when the
390+// user presses 'p' on an agent that can't receive input. We branch on
391+// status so the hint points at the right next step: resume for the
392+// states Swarm.Resume accepts; nothing useful otherwise.
393+func promptDisabledHint(s swarm.Status) string {
394+ if isResumable(s) {
395+ return "prompt: agent is " + string(s) + "; press R to resume first"
396+ }
397+ return "prompt: agent is " + string(s) + " and can't receive input"
398+}
399+
400+// killDisabledHint mirrors promptDisabledHint for the 'k' shortcut.
401+// Kill only makes sense on running / pending agents; on detached and
402+// terminal ones it's a no-op and the user usually wants 'r' (remove)
403+// to clean up the worktree instead.
404+func killDisabledHint(s swarm.Status) string {
405+ return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)"
406+}
407+
408+// friendlySendErr turns a raw send error into a status-bar message
409+// the user can act on. The most common failure mode — dialling an
410+// agent whose previous subprocess exited — surfaces as
411+// swarm.ErrNotReady; we rewrite it to mention resume instead of
412+// leaking the unix-socket path. Other errors fall through verbatim
413+// because they're rare and almost always indicate a real bug.
414+func friendlySendErr(id string, err error) string {
415+ if errors.Is(err, swarm.ErrNotReady) {
416+ return "send: agent " + id + " isn't accepting input (press R to resume)"
417+ }
418+ return "send: " + err.Error()
419+}
420+
421+func (d *swarmDialog) refresh() {
422+ if d.snapshot == nil {
423+ d.rows = nil
424+ return
425+ }
426+ d.rows = d.snapshot()
427+ if d.cursor >= len(d.rows) {
428+ d.cursor = len(d.rows) - 1
429+ }
430+ if d.cursor < 0 {
431+ d.cursor = 0
432+ }
433+}
434+
435+// HandleKey advances state. Returns (closed, statusMsg, statusErr).
436+// statusMsg / statusErr surface in the host's status bar so the user
437+// gets feedback on kill/remove without writing to the chat.
438+func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
439+ if !d.Active() {
440+ return false, "", ""
441+ }
442+ d.refresh()
443+
444+ // Inline editors own key handling. They run before the other
445+ // modes so the user's typed letters aren't interpreted as 'k'
446+ // (kill), 'p' (prompt), etc.
447+ //
448+ // Transcript view is a special case: the editor is always on,
449+ // but we still need transcript-level keys (PageUp/PageDown/
450+ // Home/End scroll, Esc close) to take precedence over the
451+ // editor. handleViewingKey owns that routing and delegates
452+ // editor input to handlePromptKey itself.
453+ if d.viewing {
454+ return d.handleViewingKey(k)
455+ }
456+ if d.prompting {
457+ return d.handlePromptKey(k)
458+ }
459+ if d.pickingModel {
460+ return d.handleModelPickerKey(k)
461+ }
462+ if d.spawning {
463+ return d.handleSpawnKey(k)
464+ }
465+
466+ switch k.Kind {
467+ case tui.KeyEsc:
468+ d.Close()
469+ return true, "", ""
470+ case tui.KeyUp:
471+ if d.cursor > 0 {
472+ d.cursor--
473+ }
474+ case tui.KeyDown:
475+ if d.cursor < len(d.rows)-1 {
476+ d.cursor++
477+ }
478+ case tui.KeyEnter:
479+ if len(d.rows) > 0 {
480+ d.viewing = true
481+ // Auto-open the inline editor (à la /btw) so the user
482+ // can start typing immediately. Skipped for non-running
483+ // agents; their inbox isn't listening, so an editor
484+ // would just frustrate them with bounced sends.
485+ if a := d.selected(); a != nil && a.Status == swarm.StatusRunning && d.send != nil {
486+ d.openPromptEditor(tui.Theme{}, a.ID)
487+ }
488+ }
489+ case tui.KeyRune:
490+ switch k.Rune {
491+ case 'q':
492+ d.Close()
493+ return true, "", ""
494+ case 'n':
495+ if d.spawn != nil {
496+ // Open the task editor directly. The model the new
497+ // agent will run against was already captured via
498+ // SetCurrentModel from the host's /model selection;
499+ // the user changes models the normal way (/model)
500+ // before opening /swarm.
501+ d.openSpawnEditor(tui.Theme{})
502+ }
503+ case 'p':
504+ if a := d.selected(); a != nil && d.send != nil {
505+ // Only running agents have a live inbox listener;
506+ // dialling the unix socket on a detached / done /
507+ // failed / killed agent fails with a long ECONNREFUSED
508+ // or ENOENT trail that's not actionable. Tell the user
509+ // what to do instead.
510+ if a.Status != swarm.StatusRunning {
511+ return false, "", promptDisabledHint(a.Status)
512+ }
513+ d.openPromptEditor(tui.Theme{}, a.ID)
514+ }
515+ case 'R':
516+ // Capital R for resume; lowercase r is already taken by
517+ // remove. Using a different keystroke avoids a confirm
518+ // modal for an action that's already destructive (resume
519+ // is reversible, so we don't need the shift-as-guard
520+ // dance lowercase r has historically warranted).
521+ if a := d.selected(); a != nil && d.resume != nil {
522+ if err := d.resume(a.ID); err != nil {
523+ return false, "", "resume: " + err.Error()
524+ }
525+ return false, "resumed " + a.ID, ""
526+ }
527+ case 'k':
528+ if a := d.selected(); a != nil && d.stop != nil {
529+ // Only running / pending agents have something to
530+ // cancel. Detached and already-terminal agents would
531+ // be silent no-ops at the Swarm layer; surface a hint
532+ // so the user knows kill did nothing and what to do
533+ // instead (remove via 'r').
534+ if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending {
535+ return false, "", killDisabledHint(a.Status)
536+ }
537+ if err := d.stop(a.ID); err != nil {
538+ return false, "", "stop: " + err.Error()
539+ }
540+ return false, "stopped " + a.ID, ""
541+ }
542+ case 'r':
543+ if a := d.selected(); a != nil && d.remove != nil {
544+ if err := d.remove(a.ID); err != nil {
545+ return false, "", "remove: " + err.Error()
546+ }
547+ return false, "removed " + a.ID, ""
548+ }
549+ }
550+ }
551+ return false, "", ""
552+}
553+
554+// handleViewingKey routes one keystroke while the transcript view
555+// is on screen. The transcript view runs /btw-style: an inline
556+// editor is always pinned at the bottom, the transcript flows in
557+// full above it, and the host's outer scrollback handles overflow.
558+// There is no internal scroll, so this handler is small:
559+//
560+// - Esc closes the transcript view (back to the dashboard list).
561+// If the user was in the middle of an @-file-pick we let the
562+// editor handler eat the Esc first so it just cancels the popup.
563+//
564+// Every other key is forwarded to handlePromptKey, which owns the
565+// editor itself (typed runes, Enter to submit, Backspace, file
566+// suggest, arrow keys for caret movement, etc.). Submits stay in
567+// the view and clear the editor for the next message instead of
568+// closing it — see handlePromptKey for the always-on behaviour.
569+func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg string) {
570+ a := d.selected()
571+
572+ // Lazy-open the editor on first key into the transcript view, so
573+ // re-entering an agent after switching away picks up cleanly.
574+ // Skipped for non-running agents (their inbox isn't listening;
575+ // sending would just bounce).
576+ if a != nil && a.Status == swarm.StatusRunning && d.send != nil && !d.prompting {
577+ d.openPromptEditor(tui.Theme{}, a.ID)
578+ }
579+
580+ // Esc closes the transcript view, regardless of whether the
581+ // editor is open. Handled here (not in the editor handler) so
582+ // the user gets a consistent "esc backs out one level" feel.
583+ // The file-suggest popup, if active, gets to eat the Esc first
584+ // inside handlePromptKey — we route there and check whether the
585+ // editor consumed it.
586+ if k.Kind == tui.KeyEsc {
587+ if d.prompting && d.promptSuggest != nil && d.promptEd != nil && d.promptSuggest.Active(d.promptEd.Value()) {
588+ // Let the editor's @-suggest popup handle the Esc to
589+ // dismiss itself instead of collapsing the whole view.
590+ return d.handlePromptKey(k)
591+ }
592+ d.viewing = false
593+ d.closePromptEditor()
594+ return false, "", ""
595+ }
596+
597+ // Everything else — typed runes, Enter, Backspace, arrow keys
598+ // for caret movement, @-file picker — is editor input. If the
599+ // agent isn't running there's no live editor; surface a hint.
600+ if a == nil || a.Status != swarm.StatusRunning || d.send == nil {
601+ if k.Kind == tui.KeyRune && a != nil {
602+ return false, "", promptDisabledHint(a.Status)
603+ }
604+ return false, "", ""
605+ }
606+ return d.handlePromptKey(k)
607+}
608+
609+// openSpawnEditor enters the inline new-agent prompt. It deliberately
610+// uses tui.Editor (not a one-line string) so swarm-new supports the same
611+// editing model as the normal input: multiline paste collapse, drag-drop
612+// file/dir chips, cursor movement, and the same accent-bar prompt style.
613+func (d *swarmDialog) openSpawnEditor(th tui.Theme) {
614+ d.spawning = true
615+ d.newTaskEd = tui.NewEditor(th.AccentBar(th.Accent))
616+ d.fileSuggest = newFileSuggester()
617+ d.fileSuggest.SetCWD(d.cwd)
618+ if d.spawnDraft != "" {
619+ d.newTaskEd.SetValue(d.spawnDraft)
620+ }
621+}
622+
623+// openModelPicker suspends the spawn editor and shows the same
624+// model dialog the global /model uses. Triggered when the user
625+// submits "/model" from inside the spawn editor; on select we
626+// capture the new pendingModel/Provider and reopen the spawn
627+// editor with the previously-typed task buffer restored.
628+func (d *swarmDialog) openModelPicker() {
629+ d.spawning = false
630+ d.newTaskEd = nil
631+ d.fileSuggest = nil
632+ d.pickingModel = true
633+ d.modelPicker = newModelDialog()
634+ d.modelPicker.Open(d.pendingModel, d.modelPickerLoggedIn)
635+}
636+
637+// handleModelPickerKey forwards one keystroke to the embedded model
638+// picker and, on select or close, reopens the spawn editor so the
639+// user can continue composing the task. A selection captures the new
640+// pendingModel + pendingProvider; Esc leaves them unchanged (the
641+// user gets to keep whatever was inherited before they invoked
642+// /model).
643+func (d *swarmDialog) handleModelPickerKey(k tui.Key) (closed bool, msg, errMsg string) {
644+ if d.modelPicker == nil {
645+ d.openModelPicker()
646+ }
647+ act := d.modelPicker.HandleKey(k)
648+ switch {
649+ case act.Select:
650+ d.pendingModel = act.Model
651+ d.pendingProvider = act.Provider
652+ d.pickingModel = false
653+ d.modelPicker = nil
654+ d.openSpawnEditor(tui.Theme{})
655+ return false, "model: " + act.Model, ""
656+ case act.Close:
657+ d.pickingModel = false
658+ d.modelPicker = nil
659+ d.openSpawnEditor(tui.Theme{})
660+ return false, "", ""
661+ }
662+ return false, "", ""
663+}
664+
665+// containsSlashModelLine reports whether any line in buf has trimmed
666+// content of "/model" (with optional trailing args). Used to decide
667+// whether to treat a spawn-editor submit as a model-picker command
668+// vs. as a task description.
669+func containsSlashModelLine(buf string) bool {
670+ for _, ln := range strings.Split(buf, "\n") {
671+ t := strings.TrimSpace(ln)
672+ if t == "/model" || strings.HasPrefix(t, "/model ") {
673+ return true
674+ }
675+ }
676+ return false
677+}
678+
679+// stripSlashModelLine removes the line containing "/model" (with
680+// optional trailing args) from the spawn-editor buffer so the
681+// remainder can be restored after the picker closes. The /model line
682+// is always exactly one full line in practice — the user typed
683+// /model<Enter> — but we tolerate inline placement at the start of
684+// a line just in case.
685+func stripSlashModelLine(buf string) string {
686+ lines := strings.Split(buf, "\n")
687+ out := lines[:0]
688+ for _, ln := range lines {
689+ t := strings.TrimSpace(ln)
690+ if t == "/model" || strings.HasPrefix(t, "/model ") {
691+ continue
692+ }
693+ out = append(out, ln)
694+ }
695+ return strings.TrimRight(strings.Join(out, "\n"), "\n")
696+}
697+
698+// openPromptEditor enters the inline "send a follow-up prompt" editor
699+// for the agent with id targetID. Mirrors openSpawnEditor so the
700+// editing experience (paste, @-picker, drop chips) is identical.
701+func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) {
702+ d.prompting = true
703+ d.promptEd = tui.NewEditor(th.AccentBar(th.Accent))
704+ d.promptSuggest = newFileSuggester()
705+ d.promptSuggest.SetCWD(d.cwd)
706+ d.promptTargetID = targetID
707+}
708+
709+// handlePromptKey owns the keystrokes while the inline prompt editor
710+// is open. It mirrors handleSpawnKey but, on submit, invokes d.send
711+// with the captured target id instead of d.spawn. Esc cancels and
712+// returns the user to whichever view (list or transcript) was active
713+// when 'p' was pressed.
714+func (d *swarmDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg string) {
715+ if d.promptEd == nil {
716+ d.openPromptEditor(tui.Theme{}, d.promptTargetID)
717+ }
718+ ed := d.promptEd
719+ fs := d.promptSuggest
720+ if fs != nil {
721+ fs.SetCWD(d.cwd)
722+ }
723+
724+ // File suggestions: same keys and chip shape as the default input.
725+ if fs != nil && fs.Active(ed.Value()) {
726+ switch k.Kind {
727+ case tui.KeyUp:
728+ fs.Up()
729+ return false, "", ""
730+ case tui.KeyDown:
731+ fs.Down()
732+ return false, "", ""
733+ case tui.KeyRight:
734+ fs.Right()
735+ return false, "", ""
736+ case tui.KeyLeft:
737+ fs.Left()
738+ return false, "", ""
739+ case tui.KeyEnter:
740+ if entry, ok := fs.SelectedEntry(ed.Value()); ok {
741+ chip := "[file:" + entry.rel + "]"
742+ if entry.isDir {
743+ chip = "[dir:" + entry.rel + "/]"
744+ }
745+ val := ed.Value()
746+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
747+ val = val[:idx]
748+ }
749+ ed.SetValue(val + chip + " ")
750+ fs.Reset()
751+ }
752+ return false, "", ""
753+ case tui.KeyEsc:
754+ val := ed.Value()
755+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
756+ ed.SetValue(val[:idx])
757+ }
758+ fs.Reset()
759+ return false, "", ""
760+ }
761+ }
762+
763+ // Esc cancels the prompt editor when the file picker isn't active.
764+ if k.Kind == tui.KeyEsc {
765+ d.closePromptEditor()
766+ return false, "", ""
767+ }
768+
769+ if submitted := ed.HandleKey(k); submitted {
770+ text := strings.TrimRight(ed.SubmitValue(), "\n")
771+ text = expandFileChips(text, d.cwd)
772+ text = strings.TrimSpace(text)
773+ targetID := d.promptTargetID
774+
775+ // Empty submit: nothing to send. If we're in transcript
776+ // view (always-on editor), just clear and stay; otherwise
777+ // (list-view-p flow) close the modal.
778+ if text == "" {
779+ if d.viewing {
780+ ed.Clear()
781+ return false, "", ""
782+ }
783+ d.closePromptEditor()
784+ return false, "", ""
785+ }
786+ if d.send == nil {
787+ if !d.viewing {
788+ d.closePromptEditor()
789+ }
790+ return false, "", "send not wired"
791+ }
792+ if targetID == "" {
793+ if !d.viewing {
794+ d.closePromptEditor()
795+ }
796+ return false, "", "send: no target agent"
797+ }
798+ if err := d.send(targetID, text); err != nil {
799+ if !d.viewing {
800+ d.closePromptEditor()
801+ }
802+ return false, "", friendlySendErr(targetID, err)
803+ }
804+
805+ // In transcript view (always-on editor) keep the editor
806+ // mounted and clear the buffer for the next message; in the
807+ // list-view modal flow, close it so the user returns to the
808+ // list. Either way, snap the transcript to the tail so the
809+ // user sees their just-sent message and the agent's reply.
810+ if d.viewing {
811+ ed.Clear()
812+ } else {
813+ d.closePromptEditor()
814+ }
815+ return false, "sent to " + targetID, ""
816+ }
817+ return false, "", ""
818+}
819+
820+// closePromptEditor resets all prompt-editor state. Called on Esc and
821+// after submit so a subsequent 'p' starts from a clean slate.
822+func (d *swarmDialog) closePromptEditor() {
823+ d.prompting = false
824+ d.promptEd = nil
825+ d.promptSuggest = nil
826+ d.promptTargetID = ""
827+}
828+
829+// handleSpawnKey owns the keystrokes while the inline new-agent editor
830+// is open. It mirrors the normal input's @ file-picker path and submit
831+// expansion, then asks the host to spawn.
832+func (d *swarmDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string) {
833+ if d.newTaskEd == nil {
834+ d.openSpawnEditor(tui.Theme{})
835+ }
836+ ed := d.newTaskEd
837+ fs := d.fileSuggest
838+ if fs != nil {
839+ fs.SetCWD(d.cwd)
840+ }
841+
842+ // File suggestions: same keys and chip shape as the default input.
843+ if fs != nil && fs.Active(ed.Value()) {
844+ switch k.Kind {
845+ case tui.KeyUp:
846+ fs.Up()
847+ return false, "", ""
848+ case tui.KeyDown:
849+ fs.Down()
850+ return false, "", ""
851+ case tui.KeyRight:
852+ fs.Right()
853+ return false, "", ""
854+ case tui.KeyLeft:
855+ fs.Left()
856+ return false, "", ""
857+ case tui.KeyEnter:
858+ if entry, ok := fs.SelectedEntry(ed.Value()); ok {
859+ chip := "[file:" + entry.rel + "]"
860+ if entry.isDir {
861+ chip = "[dir:" + entry.rel + "/]"
862+ }
863+ val := ed.Value()
864+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
865+ val = val[:idx]
866+ }
867+ ed.SetValue(val + chip + " ")
868+ fs.Reset()
869+ }
870+ return false, "", ""
871+ case tui.KeyEsc:
872+ val := ed.Value()
873+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
874+ ed.SetValue(val[:idx])
875+ }
876+ fs.Reset()
877+ return false, "", ""
878+ }
879+ }
880+
881+ // Esc cancels the spawn prompt when the file picker isn't active.
882+ if k.Kind == tui.KeyEsc {
883+ d.spawning = false
884+ d.newTaskEd = nil
885+ d.fileSuggest = nil
886+ return false, "", ""
887+ }
888+
889+ if submitted := ed.HandleKey(k); submitted {
890+ raw := strings.TrimRight(ed.SubmitValue(), "\n")
891+
892+ // In-editor /model command: when the buffer contains a line
893+ // whose trimmed content is "/model" (anywhere — typically on
894+ // its own line, but a trailing /model after a task works
895+ // too), pop the same picker the global /model uses, drop the
896+ // /model line, and preserve the rest of the buffer so the
897+ // user can keep composing once they've picked.
898+ if containsSlashModelLine(raw) {
899+ d.spawnDraft = stripSlashModelLine(raw)
900+ d.openModelPicker()
901+ return false, "", ""
902+ }
903+
904+ task := expandFileChips(raw, d.cwd)
905+ task = strings.TrimSpace(task)
906+ model := d.pendingModel
907+ provider := d.pendingProvider
908+ d.spawning = false
909+ d.newTaskEd = nil
910+ d.fileSuggest = nil
911+ d.spawnDraft = ""
912+ d.pendingModel = ""
913+ d.pendingProvider = ""
914+ if task == "" {
915+ return false, "", ""
916+ }
917+ if d.spawn == nil {
918+ return false, "", "spawn not wired"
919+ }
920+ if err := d.spawn(task, model, provider); err != nil {
921+ return false, "", "spawn: " + err.Error()
922+ }
923+ if model != "" {
924+ return false, "spawned (model " + model + ")", ""
925+ }
926+ return false, "spawned", ""
927+ }
928+ return false, "", ""
929+}
930+
931+func (d *swarmDialog) selected() *swarm.AgentSnapshot {
932+ if d.cursor < 0 || d.cursor >= len(d.rows) {
933+ return nil
934+ }
935+ r := d.rows[d.cursor]
936+ return &r
937+}
938+
939+// Render returns the dialog lines. Re-reads the snapshot each call so
940+// the dashboard updates as agents make progress.
941+func (d *swarmDialog) Render(th tui.Theme, width int) []string {
942+ if !d.Active() {
943+ return nil
944+ }
945+ d.refresh()
946+
947+ if d.viewing {
948+ return d.renderTranscript(th, width)
949+ }
950+
951+ out := []string{frameHeader(th, "swarm (n new · p prompt · R resume · ↑/↓ move · enter view · k kill · r remove · esc close)", width)}
952+ if d.prompting {
953+ return d.renderPromptEditor(th, width, out)
954+ }
955+ if d.pickingModel {
956+ // modelDialog renders its own frame; replace ours so the
957+ // picker takes the dashboard cleanly. Trailing hint reminds
958+ // the user Esc returns to the spawn editor.
959+ if d.modelPicker == nil {
960+ d.openModelPicker()
961+ }
962+ out = d.modelPicker.Render(th, width)
963+ out = append(out, " "+th.FG256(th.Muted, "select model for next spawn · esc to cancel"))
964+ return out
965+ }
966+ if d.spawning {
967+ if d.newTaskEd == nil {
968+ d.openSpawnEditor(th)
969+ }
970+ if d.newTaskEd != nil {
971+ d.newTaskEd.Prompt = th.AccentBar(th.Accent)
972+ }
973+ // One-line banner reminding the user which model the
974+ // agent-to-be will run against. Skipped when no model was
975+ // picked so the layout matches the historical look.
976+ if d.pendingModel != "" {
977+ label := "model: " + d.pendingModel
978+ if d.pendingProvider != "" {
979+ label += " (" + d.pendingProvider + ")"
980+ }
981+ out = append(out, " "+th.FG256(th.Accent, label))
982+ }
983+ if d.fileSuggest != nil {
984+ d.fileSuggest.SetCWD(d.cwd)
985+ if popup := d.fileSuggest.Render(d.newTaskEd.Value(), th, width); len(popup) > 0 {
986+ out = append(out, popup...)
987+ }
988+ }
989+ // Breathing-room blank row above the editor so the input
990+ // doesn't sit flush against the frame header.
991+ out = append(out, "")
992+ if d.newTaskEd != nil {
993+ edLines, _, _ := d.newTaskEd.Render(width - 2)
994+ for _, l := range edLines {
995+ out = append(out, " "+l)
996+ }
997+ }
998+ // Matching blank row below before the hint, mirroring the
999+ // main input's editor breathing room.
1000+ out = append(out, "")
1001+ out = append(out, " "+th.FG256(th.Muted, "enter spawn · /model pick model · @ file/dir picker · paste/drop paths become [file:] / [dir:] chips · esc cancel"))
1002+ out = append(out, frameRule(th, width))
1003+ return out
1004+ }
1005+ if len(d.rows) == 0 {
1006+ out = append(out, " "+th.FG256(th.Muted, "no agents — press n to spawn one (or /swarm new <task>)"))
1007+ out = append(out, frameRule(th, width))
1008+ return out
1009+ }
1010+
1011+ // Column header for readability.
1012+ header := fmt.Sprintf(" %-9s %-26s %-8s %s", "STATUS", "ID", "AGE", "ACTIVITY")
1013+ out = append(out, th.FG256(th.Muted, header))
1014+
1015+ for i, r := range d.rows {
1016+ row := formatSwarmRow(r, width-2)
1017+ if i == d.cursor {
1018+ out = append(out, th.PadHighlight(" "+row, width))
1019+ } else {
1020+ out = append(out, " "+th.FG256(th.FG, row))
1021+ }
1022+ }
1023+ out = append(out, frameRule(th, width))
1024+ return out
1025+}
1026+
1027+func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string {
1028+ a := d.selected()
1029+ if a == nil {
1030+ d.viewing = false
1031+ return d.Render(th, width)
1032+ }
1033+ header := []string{
1034+ frameHeader(th, "swarm: "+a.ID+" (type to send · esc back)", width),
1035+ " " + th.FG256(th.Muted, "task: "+a.Task),
1036+ " " + th.FG256(th.Muted, "branch: "+a.Branch),
1037+ " " + th.FG256(th.Muted, "dir: "+a.Dir),
1038+ " " + th.FG256(th.Muted, fmt.Sprintf("status: %s · %s", a.Status, a.Activity)),
1039+ }
1040+ if a.Model != "" {
1041+ modelLine := "model: " + a.Model
1042+ if a.Provider != "" {
1043+ modelLine += " (" + a.Provider + ")"
1044+ }
1045+ header = append(header, " "+th.FG256(th.Muted, modelLine))
1046+ }
1047+ if a.Err != "" {
1048+ header = append(header, " "+th.FG256(th.Muted, "error: "+a.Err))
1049+ }
1050+ header = append(header,
1051+ "",
1052+ " "+th.FG256(th.Muted, "── transcript ──"),
1053+ "",
1054+ )
1055+
1056+ // Body: prefer the full Lines slice when present, fall back to
1057+ // the Tail string for snapshots that only filled the summary.
1058+ raw := a.Lines
1059+ if len(raw) == 0 && a.Tail != "" {
1060+ raw = strings.Split(a.Tail, "\n")
1061+ }
1062+ if len(raw) == 0 {
1063+ header = append(header, " "+th.FG256(th.Muted, "(no transcript yet)"))
1064+ header = d.appendTranscriptEditor(header, th, width, a)
1065+ header = append(header, frameRule(th, width))
1066+ return header
1067+ }
1068+
1069+ // Re-render the flat log into proper chat blocks: user-bubble
1070+ // rows for echoes, markdown-rendered prose for assistant turns,
1071+ // tinted prefixes for stderr / errors. The result is a single
1072+ // pre-styled, pre-indented []string of display rows ready to
1073+ // hand to the host.
1074+ //
1075+ // We deliberately do NOT clip into a fixed-height viewport with
1076+ // internal scroll — the transcript flows in full, /btw-style,
1077+ // and the host's outer scrollback handles overflow naturally.
1078+ // That gives the user the chat UX they expect (transcript grows
1079+ // with the window, arrow keys belong to the editor, no "↑ N
1080+ // more above" cliff).
1081+ body := renderSwarmTranscriptBlocks(raw, th, width)
1082+
1083+ out := header
1084+ out = append(out, body...)
1085+
1086+ // /btw-style inline editor pinned to the bottom of the transcript.
1087+ // Rendered after the body so the editor sits flush above the
1088+ // frame rule. Only shown for running agents (others have no live
1089+ // inbox listener — we surface a hint instead).
1090+ out = d.appendTranscriptEditor(out, th, width, a)
1091+
1092+ out = append(out, frameRule(th, width))
1093+ return out
1094+}
1095+
1096+// appendTranscriptEditor adds the always-on inline composer to the
1097+// transcript view, mimicking the /btw side-chat layout: a breathing
1098+// row, the editor itself, another breathing row, and a single muted
1099+// hint. For non-running agents we render a short "can't send while
1100+// X" note instead so the user knows why typing is disabled.
1101+func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width int, a *swarm.AgentSnapshot) []string {
1102+ if a == nil {
1103+ return out
1104+ }
1105+ if a.Status != swarm.StatusRunning || d.send == nil {
1106+ out = append(out, "")
1107+ out = append(out, " "+th.FG256(th.Muted, promptDisabledHint(a.Status)))
1108+ return out
1109+ }
1110+ // Lazy-init the editor here too so the very first render of the
1111+ // transcript view (before the user has pressed any key) already
1112+ // shows the composer. Mirrors openPromptEditor's setup but uses
1113+ // the live theme so the accent bar matches.
1114+ if d.promptEd == nil {
1115+ d.openPromptEditor(th, a.ID)
1116+ }
1117+ if d.promptEd != nil {
1118+ d.promptEd.Prompt = th.AccentBar(th.Accent)
1119+ }
1120+ if d.promptSuggest != nil {
1121+ d.promptSuggest.SetCWD(d.cwd)
1122+ if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 {
1123+ out = append(out, popup...)
1124+ }
1125+ }
1126+
1127+ // Busy-spinner row, /btw-style. Shown whenever the agent's
1128+ // current activity isn't "idle" — so "thinking", "tool: foo",
1129+ // "starting", etc. all animate. The spinner is stopped (and the
1130+ // row hidden) once the agent reports idle, mirroring the main
1131+ // chat's busy line.
1132+ if agentIsBusy(a) {
1133+ if d.transcriptSpin == nil {
1134+ d.transcriptSpin = newSpinner()
1135+ d.transcriptSpin.Start()
1136+ }
1137+ out = append(out, "")
1138+ prefix := fmt.Sprintf("%s %s · %s",
1139+ th.FG256(th.Assistant, d.transcriptSpin.Frame()),
1140+ th.FG256(th.Assistant, a.Activity),
1141+ th.FG256(th.Muted, d.transcriptSpin.Elapsed().String()),
1142+ )
1143+ out = append(out, " "+prefix)
1144+ } else {
1145+ // Reset so the next busy phase starts fresh (rotating
1146+ // funny-line cycles back to the start, elapsed restarts).
1147+ d.transcriptSpin = nil
1148+ }
1149+
1150+ out = append(out, "")
1151+ edLines, _, _ := d.promptEd.Render(width - 2)
1152+ for _, l := range edLines {
1153+ out = append(out, " "+l)
1154+ }
1155+ out = append(out, "")
1156+ out = append(out, " "+th.FG256(th.Muted, "enter send · @ file/dir picker · esc back"))
1157+ return out
1158+}
1159+
1160+// agentIsBusy reports whether the agent is currently mid-turn and
1161+// should render a spinner. Conservative: only known idle markers
1162+// suppress the spinner so transient / unfamiliar activity strings
1163+// still animate. Detached / done / failed / killed never animate —
1164+// they aren't actively producing output.
1165+func agentIsBusy(a *swarm.AgentSnapshot) bool {
1166+ if a == nil {
1167+ return false
1168+ }
1169+ if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending {
1170+ return false
1171+ }
1172+ switch strings.TrimSpace(a.Activity) {
1173+ case "", "idle":
1174+ return false
1175+ }
1176+ return true
1177+}
1178+
1179+// renderSwarmTranscriptBlocks converts the agent's flat transcript
1180+// log into a slice of display rows styled like the main chat: user
1181+// echoes in bubble rows, assistant prose through tui.RenderMarkdown,
1182+// stderr / error chatter tinted. Each output row is fully indented
1183+// and ready for the caller to slice into a scroll window.
1184+//
1185+// The flat log mixes role markers and raw text, so we group adjacent
1186+// lines of the same role into one logical "block" and render the
1187+// whole block together — essential for markdown, which needs the
1188+// full message body in one piece to detect fenced code spans,
1189+// bullet lists, and so on.
1190+func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int) []string {
1191+ type blockKind int
1192+ const (
1193+ kindAssistant blockKind = iota
1194+ kindUser
1195+ kindStderr
1196+ kindError
1197+ )
1198+ type block struct {
1199+ kind blockKind
1200+ body []string // raw lines, role prefix already stripped
1201+ }
1202+
1203+ // classify maps one raw line to its role and the stripped body.
1204+ classify := func(s string) (blockKind, string) {
1205+ switch {
1206+ case strings.HasPrefix(s, "user: "):
1207+ return kindUser, strings.TrimPrefix(s, "user: ")
1208+ case strings.HasPrefix(s, "stderr: "):
1209+ return kindStderr, strings.TrimPrefix(s, "stderr: ")
1210+ case strings.HasPrefix(s, "error: "):
1211+ return kindError, strings.TrimPrefix(s, "error: ")
1212+ default:
1213+ return kindAssistant, s
1214+ }
1215+ }
1216+
1217+ // Coalesce consecutive same-kind lines into blocks. Empty lines
1218+ // keep the kind of the previous block so blank lines inside an
1219+ // assistant message stay attached for markdown rendering (an
1220+ // empty line between paragraphs is significant in markdown).
1221+ var blocks []block
1222+ for _, ln := range lines {
1223+ if ln == "" {
1224+ // Empty lines stay attached to the previous block so
1225+ // markdown sees paragraph breaks inside an assistant
1226+ // turn. Standalone empties before any block are dropped
1227+ // (nothing meaningful to attach them to).
1228+ if len(blocks) > 0 {
1229+ blocks[len(blocks)-1].body = append(blocks[len(blocks)-1].body, "")
1230+ }
1231+ continue
1232+ }
1233+ k, stripped := classify(ln)
1234+ if len(blocks) == 0 || blocks[len(blocks)-1].kind != k {
1235+ blocks = append(blocks, block{kind: k})
1236+ }
1237+ blocks[len(blocks)-1].body = append(blocks[len(blocks)-1].body, stripped)
1238+ }
1239+
1240+ // Render each block. Width budget mirrors the btw dialog: the
1241+ // transcript text sits inside two leading frame cells, so the
1242+ // inner width is width-4 for markdown and width-2 for the
1243+ // user-bubble (the bubble already pads itself).
1244+ var out []string
1245+ innerMD := width - 4
1246+ if innerMD < 20 {
1247+ innerMD = 20
1248+ }
1249+ bubbleWidth := width - 2
1250+ if bubbleWidth < 10 {
1251+ bubbleWidth = 10
1252+ }
1253+
1254+ for i, b := range blocks {
1255+ if i > 0 {
1256+ out = append(out, "") // breathing room between turns
1257+ }
1258+ switch b.kind {
1259+ case kindUser:
1260+ text := strings.Join(b.body, "\n")
1261+ out = append(out, btwUserBubbleRows(th, text, bubbleWidth)...)
1262+ case kindAssistant:
1263+ text := strings.Join(b.body, "\n")
1264+ md := tui.RenderMarkdown(strings.TrimLeft(text, "\n"), th, innerMD)
1265+ for _, line := range strings.Split(md, "\n") {
1266+ if len(line) > 0 && line[0] == tui.FlushLeftSentinel {
1267+ line = line[1:]
1268+ }
1269+ out = append(out, " "+line)
1270+ }
1271+ case kindStderr:
1272+ for _, line := range b.body {
1273+ out = append(out, " "+th.FG256(th.Muted, "stderr "+line))
1274+ }
1275+ case kindError:
1276+ for _, line := range b.body {
1277+ out = append(out, " "+th.FG256(th.Error, "✖ "+line))
1278+ }
1279+ }
1280+ }
1281+ return out
1282+}
1283+
1284+// renderPromptEditor draws the inline "send a follow-up prompt"
1285+// composer. Reuses the editor + file-suggest popup styling of the
1286+// spawn editor so the two flows are visually consistent. `out` is
1287+// the pre-built header (frame header for list view, or the agent
1288+// metadata block for transcript view); we append onto it and close
1289+// with the standard frame rule.
1290+func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string) []string {
1291+ if d.promptEd == nil {
1292+ d.openPromptEditor(th, d.promptTargetID)
1293+ }
1294+ if d.promptEd != nil {
1295+ d.promptEd.Prompt = th.AccentBar(th.Accent)
1296+ }
1297+ target := d.promptTargetID
1298+ if target == "" {
1299+ target = "<unknown>"
1300+ }
1301+ out = append(out, " "+th.FG256(th.Muted, "send to "+target+":"))
1302+ if d.promptSuggest != nil {
1303+ d.promptSuggest.SetCWD(d.cwd)
1304+ if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 {
1305+ out = append(out, popup...)
1306+ }
1307+ }
1308+ // Breathing-room blank row above the editor.
1309+ out = append(out, "")
1310+ if d.promptEd != nil {
1311+ edLines, _, _ := d.promptEd.Render(width - 2)
1312+ for _, l := range edLines {
1313+ out = append(out, " "+l)
1314+ }
1315+ }
1316+ // Matching blank row below before the hint.
1317+ out = append(out, "")
1318+ out = append(out, " "+th.FG256(th.Muted, "enter send · @ file/dir picker · esc cancel"))
1319+ out = append(out, frameRule(th, width))
1320+ return out
1321+}
1322+
1323+// formatSwarmRow is the one-line summary shown per agent.
1324+func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {
1325+ status := statusLabel(r.Status)
1326+ age := formatAge(r.Started)
1327+ left := fmt.Sprintf("%-9s %-26s %-8s ", status, truncateLineSafe(r.ID, 26), age)
1328+ room := maxWidth - len([]rune(left))
1329+ if room < 10 {
1330+ room = 10
1331+ }
1332+ act := strings.ReplaceAll(r.Activity, "\n", " ")
1333+ if act == "" {
1334+ act = r.Task
1335+ }
1336+ if len([]rune(act)) > room {
1337+ act = string([]rune(act)[:room-1]) + "…"
1338+ }
1339+ row := left + act
1340+ rowRunes := []rune(row)
1341+ if len(rowRunes) > maxWidth {
1342+ row = string(rowRunes[:maxWidth-1]) + "…"
1343+ }
1344+ return row
1345+}
1346+
1347+// statusLabel returns a short, padded status badge. Kept stable across
1348+// runs (no colours embedded) so tests can match it deterministically.
1349+func statusLabel(s swarm.Status) string {
1350+ switch s {
1351+ case swarm.StatusPending:
1352+ return "● pend"
1353+ case swarm.StatusRunning:
1354+ return "● run"
1355+ case swarm.StatusDone:
1356+ return "✓ done"
1357+ case swarm.StatusFailed:
1358+ return "✗ fail"
1359+ case swarm.StatusKilled:
1360+ return "■ kill"
1361+ case swarm.StatusDetached:
1362+ return "○ detach"
1363+ }
1364+ return string(s)
1365+}
1366+
1367+func formatAge(t time.Time) string {
1368+ if t.IsZero() {
1369+ return "-"
1370+ }
1371+ d := time.Since(t)
1372+ switch {
1373+ case d < time.Minute:
1374+ return fmt.Sprintf("%ds", int(d.Seconds()))
1375+ case d < time.Hour:
1376+ return fmt.Sprintf("%dm", int(d.Minutes()))
1377+ case d < 24*time.Hour:
1378+ return fmt.Sprintf("%dh", int(d.Hours()))
1379+ default:
1380+ return fmt.Sprintf("%dd", int(d.Hours()/24))
1381+ }
1382+}
A internal/agent/modes/swarm_dialog_test.go +1279 -0
@@ -0,0 +1,1279 @@
1+package modes
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/patriceckhart/zot/internal/provider"
13+ "github.com/patriceckhart/zot/internal/swarm"
14+ "github.com/patriceckhart/zot/internal/tui"
15+)
16+
17+// staticSnapshots returns a deterministic snapshot slice for the
18+// dialog to render.
19+func staticSnapshots(rows ...swarm.AgentSnapshot) func() []swarm.AgentSnapshot {
20+ return func() []swarm.AgentSnapshot {
21+ out := make([]swarm.AgentSnapshot, len(rows))
22+ copy(out, rows)
23+ return out
24+ }
25+}
26+
27+func TestSwarmDialogEmptyState(t *testing.T) {
28+ d := newSwarmDialog()
29+ d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
30+ lines := d.Render(tui.Theme{}, 80)
31+ joined := strings.Join(lines, "\n")
32+ if !strings.Contains(joined, "no agents") {
33+ t.Fatalf("empty state missing hint:\n%s", joined)
34+ }
35+ if !strings.Contains(joined, "press n") {
36+ t.Fatalf("empty state should advertise n shortcut:\n%s", joined)
37+ }
38+}
39+
40+func TestSwarmDialogRendersRows(t *testing.T) {
41+ now := time.Now()
42+ rows := []swarm.AgentSnapshot{
43+ {ID: "alpha-1", Task: "fix login", Status: swarm.StatusRunning, Activity: "editing file", Started: now},
44+ {ID: "beta-2", Task: "write tests", Status: swarm.StatusDone, Activity: "done", Started: now},
45+ }
46+ d := newSwarmDialog()
47+ d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
48+ out := strings.Join(d.Render(tui.Theme{}, 100), "\n")
49+ for _, want := range []string{"alpha-1", "beta-2", "editing file", "done", "STATUS", "ACTIVITY"} {
50+ if !strings.Contains(out, want) {
51+ t.Fatalf("render missing %q:\n%s", want, out)
52+ }
53+ }
54+}
55+
56+func TestSwarmDialogStopAndRemoveCallbacks(t *testing.T) {
57+ now := time.Now()
58+ rows := []swarm.AgentSnapshot{
59+ {ID: "alpha-1", Status: swarm.StatusRunning, Started: now},
60+ }
61+ stops := 0
62+ removes := 0
63+ d := newSwarmDialog()
64+ d.Open(
65+ staticSnapshots(rows...),
66+ func(id string) error { stops++; return nil },
67+ func(id string) error { removes++; return nil },
68+ nil,
69+ nil,
70+ nil,
71+ "",
72+ )
73+ // Render once so cursor + rows are populated.
74+ _ = d.Render(tui.Theme{}, 80)
75+
76+ _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'k'})
77+ if stops != 1 {
78+ t.Fatalf("stop calls = %d", stops)
79+ }
80+ if !strings.Contains(msg, "alpha-1") || errMsg != "" {
81+ t.Fatalf("stop status (%q, %q)", msg, errMsg)
82+ }
83+
84+ _, msg, errMsg = d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'r'})
85+ if removes != 1 {
86+ t.Fatalf("remove calls = %d", removes)
87+ }
88+ if !strings.Contains(msg, "alpha-1") || errMsg != "" {
89+ t.Fatalf("remove status (%q, %q)", msg, errMsg)
90+ }
91+}
92+
93+func TestSwarmDialogEnterShowsTranscript(t *testing.T) {
94+ now := time.Now()
95+ rows := []swarm.AgentSnapshot{
96+ {ID: "alpha-1", Task: "do stuff", Status: swarm.StatusDone, Tail: "line a\nline b", Started: now},
97+ }
98+ d := newSwarmDialog()
99+ d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
100+ _ = d.Render(tui.Theme{}, 80)
101+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
102+ out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
103+ for _, want := range []string{"task:", "branch:", "line a", "line b"} {
104+ if !strings.Contains(out, want) {
105+ t.Fatalf("transcript view missing %q:\n%s", want, out)
106+ }
107+ }
108+ // Esc should bring us back to the list view.
109+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
110+ out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
111+ if !strings.Contains(out, "STATUS") {
112+ t.Fatalf("did not return to list view:\n%s", out)
113+ }
114+}
115+
116+func TestSwarmDialogEscClosesList(t *testing.T) {
117+ d := newSwarmDialog()
118+ d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
119+ closed, _, _ := d.HandleKey(tui.Key{Kind: tui.KeyEsc})
120+ if !closed {
121+ t.Fatal("esc did not close")
122+ }
123+ if d.Active() {
124+ t.Fatal("still active after esc")
125+ }
126+}
127+
128+// TestSwarmDialogSpawnFlow drives the inline n-prompt end-to-end:
129+// press n, type a task, press enter, observe the spawn callback
130+// fires with the right text and the prompt closes.
131+func TestSwarmDialogSpawnFlow(t *testing.T) {
132+ var spawned string
133+ d := newSwarmDialog()
134+ d.Open(
135+ staticSnapshots(),
136+ nil, nil,
137+ func(task, _, _ string) error { spawned = task; return nil },
138+ nil,
139+ nil,
140+ "",
141+ )
142+ _ = d.Render(tui.Theme{}, 80)
143+
144+ // Press n to open the spawn editor directly. The model is
145+ // taken from whatever the host set via SetCurrentModel; this
146+ // test doesn't pin one.
147+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
148+ if !d.spawning {
149+ t.Fatal("n did not enter spawning mode")
150+ }
151+ out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
152+ if !strings.Contains(out, "enter spawn") || !strings.Contains(out, "@ file/dir picker") {
153+ t.Fatalf("spawn editor hint not rendered:\n%s", out)
154+ }
155+
156+ // Type "fix x".
157+ for _, r := range "fix x" {
158+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
159+ }
160+ // Submit.
161+ _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
162+ if errMsg != "" {
163+ t.Fatalf("unexpected err: %s", errMsg)
164+ }
165+ if msg != "spawned" {
166+ t.Fatalf("status msg = %q", msg)
167+ }
168+ if spawned != "fix x" {
169+ t.Fatalf("spawn callback got %q", spawned)
170+ }
171+ if d.spawning {
172+ t.Fatal("prompt did not close after enter")
173+ }
174+}
175+
176+func TestSwarmDialogSpawnFilePickerFlow(t *testing.T) {
177+ cwd := t.TempDir()
178+ if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil {
179+ t.Fatal(err)
180+ }
181+ var spawned string
182+ d := newSwarmDialog()
183+ d.Open(staticSnapshots(), nil, nil, func(task, _, _ string) error { spawned = task; return nil }, nil, nil, cwd)
184+ _ = d.Render(tui.Theme{}, 80)
185+
186+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
187+ for _, r := range "summarize @READ" {
188+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
189+ }
190+ out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
191+ if !strings.Contains(out, "README.md") {
192+ t.Fatalf("@ file picker did not render match:\n%s", out)
193+ }
194+
195+ // Enter while the file picker is active selects the highlighted file,
196+ // inserting the compact [file:] chip instead of submitting yet.
197+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
198+ if d.newTaskEd == nil || !strings.Contains(d.newTaskEd.Value(), "[file:README.md]") {
199+ t.Fatalf("file picker did not insert chip; editor=%v", d.newTaskEd)
200+ }
201+
202+ // A second Enter submits. The chip should expand to the absolute
203+ // path before the task reaches Swarm.Spawn.
204+ _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
205+ if errMsg != "" {
206+ t.Fatalf("unexpected err: %s", errMsg)
207+ }
208+ if msg != "spawned" {
209+ t.Fatalf("status msg = %q", msg)
210+ }
211+ wantPath := filepath.Join(cwd, "README.md")
212+ if !strings.Contains(spawned, wantPath) {
213+ t.Fatalf("spawned task %q missing expanded path %q", spawned, wantPath)
214+ }
215+}
216+
217+func TestSwarmDialogSpawnEscCancels(t *testing.T) {
218+ called := false
219+ d := newSwarmDialog()
220+ d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { called = true; return nil }, nil, nil, "")
221+ _ = d.Render(tui.Theme{}, 80)
222+
223+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
224+ for _, r := range "abc" {
225+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
226+ }
227+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
228+ if called {
229+ t.Fatal("spawn callback ran despite esc")
230+ }
231+ if d.spawning {
232+ t.Fatal("prompt still open after esc")
233+ }
234+ if d.Active() == false {
235+ t.Fatal("esc on spawn prompt should not close the whole dialog")
236+ }
237+}
238+
239+func TestSwarmDialogNoNWithoutSpawnCallback(t *testing.T) {
240+ d := newSwarmDialog()
241+ d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") // spawn callback nil
242+ _ = d.Render(tui.Theme{}, 80)
243+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
244+ if d.spawning {
245+ t.Fatal("n opened spawn prompt without a spawn callback wired")
246+ }
247+}
248+
249+// TestSwarmDialogFullTranscriptRendersEverything pins the /btw-style
250+// rendering: the dialog NEVER clips the transcript into a fixed
251+// window with internal scroll. Every line goes into the rendered
252+// output and the host's outer scrollback owns overflow. This is
253+// what makes the transcript grow with the window and with the
254+// content, instead of cutting off at row 16 with a "↑ N more above"
255+// indicator.
256+func TestSwarmDialogFullTranscriptRendersEverything(t *testing.T) {
257+ lines := make([]string, 60)
258+ for i := range lines {
259+ lines[i] = fmt.Sprintf("line %d", i)
260+ }
261+ rows := []swarm.AgentSnapshot{{
262+ ID: "alpha-1", Task: "x", Status: swarm.StatusDone,
263+ Lines: lines, Started: time.Now(),
264+ }}
265+ d := newSwarmDialog()
266+ d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
267+ _ = d.Render(tui.Theme{}, 80)
268+ d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open viewer
269+
270+ out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
271+ // First line must be present (no internal clipping from the top).
272+ if !strings.Contains(out, "line 0") {
273+ t.Errorf("first transcript line missing — the view appears to be clipping:\n%s", out)
274+ }
275+ // Last line must be present (no internal clipping from the bottom).
276+ if !strings.Contains(out, fmt.Sprintf("line %d", len(lines)-1)) {
277+ t.Errorf("last transcript line missing — the view appears to be clipping:\n%s", out)
278+ }
279+ // The old "↑ N more above" / "↓ N more below" cliff markers
280+ // must NOT appear; those belonged to the old viewport model.
281+ if strings.Contains(out, "more above") || strings.Contains(out, "more below") {
282+ t.Errorf("transcript still uses internal scroll markers:\n%s", out)
283+ }
284+
285+ // Esc returns to the list view.
286+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
287+ if d.viewing {
288+ t.Fatal("esc did not exit viewer")
289+ }
290+}
291+
292+// TestSwarmDialogOpenViewingJumpsToAgent regression-tests /swarm logs
293+// <id>: OpenViewing should select the row whose id matches the
294+// (possibly truncated) argument and enter the transcript view
295+// directly without showing the list first.
296+func TestSwarmDialogOpenViewingJumpsToAgent(t *testing.T) {
297+ rows := []swarm.AgentSnapshot{
298+ {ID: "alpha-1", Lines: []string{"hi from alpha"}, Started: time.Now()},
299+ {ID: "beta-2", Lines: []string{"hi from beta"}, Started: time.Now()},
300+ }
301+ d := newSwarmDialog()
302+ ok := d.OpenViewing("beta", staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
303+ if !ok {
304+ t.Fatal("prefix match did not resolve")
305+ }
306+ if !d.viewing || d.cursor != 1 {
307+ t.Fatalf("viewing=%v cursor=%d", d.viewing, d.cursor)
308+ }
309+ out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
310+ if !strings.Contains(out, "hi from beta") {
311+ t.Fatalf("wrong agent shown:\n%s", out)
312+ }
313+}
314+
315+func TestSwarmDialogOpenViewingUnknownIDReturnsFalse(t *testing.T) {
316+ d := newSwarmDialog()
317+ ok := d.OpenViewing("nope", staticSnapshots(), nil, nil, nil, nil, nil, "")
318+ if ok {
319+ t.Fatal("expected miss for unknown id")
320+ }
321+}
322+
323+// TestSwarmDialogPromptFlowFromList drives the inline p-prompt:
324+// press p on the selected agent, type a message, press enter,
325+// and assert the send callback fired with the right id+text.
326+func TestSwarmDialogPromptFlowFromList(t *testing.T) {
327+ rows := []swarm.AgentSnapshot{
328+ {ID: "alpha-1", Status: swarm.StatusRunning, Started: time.Now()},
329+ }
330+ var gotID, gotText string
331+ sends := 0
332+ d := newSwarmDialog()
333+ d.Open(
334+ staticSnapshots(rows...),
335+ nil, nil, nil,
336+ func(id, text string) error { sends++; gotID = id; gotText = text; return nil },
337+ nil,
338+ "",
339+ )
340+ _ = d.Render(tui.Theme{}, 80)
341+
342+ // Press 'p' to open the inline prompt for alpha-1.
343+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
344+ if !d.prompting {
345+ t.Fatal("p did not enter prompting mode")
346+ }
347+ if d.promptTargetID != "alpha-1" {
348+ t.Fatalf("prompt target = %q; want alpha-1", d.promptTargetID)
349+ }
350+ out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
351+ if !strings.Contains(out, "send to alpha-1:") || !strings.Contains(out, "enter send") {
352+ t.Fatalf("prompt editor hint not rendered:\n%s", out)
353+ }
354+
355+ // Type "do the thing" and submit.
356+ for _, r := range "do the thing" {
357+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
358+ }
359+ _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
360+ if errMsg != "" {
361+ t.Fatalf("unexpected err: %s", errMsg)
362+ }
363+ if msg != "sent to alpha-1" {
364+ t.Fatalf("status msg = %q", msg)
365+ }
366+ if sends != 1 || gotID != "alpha-1" || gotText != "do the thing" {
367+ t.Fatalf("send callback got id=%q text=%q (calls=%d)", gotID, gotText, sends)
368+ }
369+ if d.prompting {
370+ t.Fatal("prompt did not close after enter")
371+ }
372+}
373+
374+// TestSwarmDialogPromptFromTranscriptView pins the always-on editor
375+// behaviour for the transcript view: entering an agent immediately
376+// auto-opens the inline composer (no `p` needed), typing flows
377+// straight into it, Enter sends, and the metadata stays visible.
378+// The transcript view is /btw-style now — you never leave the
379+// view to send a follow-up.
380+func TestSwarmDialogPromptFromTranscriptView(t *testing.T) {
381+ rows := []swarm.AgentSnapshot{
382+ {ID: "beta-2", Task: "do thing", Status: swarm.StatusRunning, Started: time.Now(), Tail: "hi from beta"},
383+ }
384+ var gotID, gotText string
385+ d := newSwarmDialog()
386+ d.Open(
387+ staticSnapshots(rows...),
388+ nil, nil, nil,
389+ func(id, text string) error { gotID = id; gotText = text; return nil },
390+ nil,
391+ "",
392+ )
393+ // Enter the transcript view; for a running agent the editor
394+ // must auto-open right away.
395+ _ = d.Render(tui.Theme{}, 80)
396+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
397+ if !d.viewing {
398+ t.Fatal("enter did not open transcript view")
399+ }
400+ if !d.prompting {
401+ t.Fatal("transcript view did not auto-open inline editor for running agent")
402+ }
403+ out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
404+ if !strings.Contains(out, "task: do thing") {
405+ t.Fatalf("transcript metadata not visible:\n%s", out)
406+ }
407+ if !strings.Contains(out, "swarm: beta-2") {
408+ t.Fatalf("frame header missing agent id:\n%s", out)
409+ }
410+
411+ // Typing flows straight into the editor — no `p` modal step.
412+ for _, r := range "keep going" {
413+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
414+ }
415+ _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
416+ if msg != "sent to beta-2" || gotID != "beta-2" || gotText != "keep going" {
417+ t.Fatalf("send didn't fire correctly: msg=%q id=%q text=%q", msg, gotID, gotText)
418+ }
419+
420+ // Editor must stay mounted after submit (/btw-style), with an
421+ // empty buffer ready for the next message.
422+ if !d.prompting {
423+ t.Error("editor closed after submit; /btw-style transcript view should keep it mounted")
424+ }
425+ if !d.viewing {
426+ t.Error("transcript view collapsed after submit; should stay open")
427+ }
428+ if d.promptEd != nil && d.promptEd.Value() != "" {
429+ t.Errorf("editor buffer not cleared after submit: %q", d.promptEd.Value())
430+ }
431+}
432+
433+// TestSwarmDialogPromptEscCancels makes sure Esc inside the prompt
434+// editor aborts without invoking the send callback.
435+func TestSwarmDialogPromptEscCancels(t *testing.T) {
436+ rows := []swarm.AgentSnapshot{{ID: "gamma-3", Status: swarm.StatusRunning, Started: time.Now()}}
437+ sends := 0
438+ d := newSwarmDialog()
439+ d.Open(
440+ staticSnapshots(rows...),
441+ nil, nil, nil,
442+ func(id, text string) error { sends++; return nil },
443+ nil,
444+ "",
445+ )
446+ _ = d.Render(tui.Theme{}, 80)
447+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
448+ for _, r := range "oops" {
449+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
450+ }
451+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
452+ if d.prompting {
453+ t.Fatal("esc did not close prompt editor")
454+ }
455+ if sends != 0 {
456+ t.Fatalf("send fired despite esc: calls=%d", sends)
457+ }
458+}
459+
460+// TestSwarmDialogPWithoutSendCallback verifies that pressing 'p'
461+// when no send callback is wired is a silent no-op (mirrors the
462+// existing 'n' / no-spawn-callback contract).
463+func TestSwarmDialogPWithoutSendCallback(t *testing.T) {
464+ rows := []swarm.AgentSnapshot{{ID: "delta-4", Status: swarm.StatusRunning, Started: time.Now()}}
465+ d := newSwarmDialog()
466+ d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
467+ _ = d.Render(tui.Theme{}, 80)
468+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
469+ if d.prompting {
470+ t.Fatal("p opened prompt editor without a send callback")
471+ }
472+}
473+
474+// TestOpenForResumeParksCursorOnFirstResumable verifies the picker
475+// entry point: with a mix of running and detached agents, the
476+// dialog opens with the cursor on the first detached row (skipping
477+// any leading running ones) so the user can press R immediately.
478+func TestOpenForResumeParksCursorOnFirstResumable(t *testing.T) {
479+ rows := []swarm.AgentSnapshot{
480+ {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()},
481+ {ID: "live-2", Status: swarm.StatusRunning, Started: time.Now()},
482+ {ID: "detached-1", Status: swarm.StatusDetached, Started: time.Now()},
483+ {ID: "detached-2", Status: swarm.StatusKilled, Started: time.Now()},
484+ }
485+ d := newSwarmDialog()
486+ count := d.OpenForResume(
487+ staticSnapshots(rows...),
488+ nil, nil, nil, nil,
489+ func(id string) error { return nil },
490+ "",
491+ )
492+ if count != 2 {
493+ t.Fatalf("resumable count = %d; want 2", count)
494+ }
495+ if d.cursor != 2 {
496+ t.Fatalf("cursor = %d; want 2 (first resumable row)", d.cursor)
497+ }
498+ if sel := d.selected(); sel == nil || sel.ID != "detached-1" {
499+ t.Fatalf("selected = %+v; want detached-1", sel)
500+ }
501+}
502+
503+// TestOpenForResumeWithNoResumableLeavesDialogActive ensures the
504+// picker still opens the dashboard when nothing is resumable, so the
505+// user sees what's there instead of getting silently nothing. The
506+// slash handler is responsible for surfacing the "no resumable
507+// agents" status string — the dialog itself just opens.
508+func TestOpenForResumeWithNoResumableLeavesDialogActive(t *testing.T) {
509+ rows := []swarm.AgentSnapshot{
510+ {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()},
511+ }
512+ d := newSwarmDialog()
513+ count := d.OpenForResume(
514+ staticSnapshots(rows...),
515+ nil, nil, nil, nil,
516+ func(id string) error { return nil },
517+ "",
518+ )
519+ if count != 0 {
520+ t.Fatalf("resumable count = %d; want 0", count)
521+ }
522+ if !d.Active() {
523+ t.Fatal("dialog should remain active so the user sees the empty state")
524+ }
525+ if d.cursor != 0 {
526+ t.Fatalf("cursor = %d; want 0 (no parking)", d.cursor)
527+ }
528+}
529+
530+// TestOpenForResumeREnterTriggersResume drives the full picker UX:
531+// /swarm resume → dialog opens parked on a resumable row → user
532+// presses R → the resume callback fires for that exact id.
533+func TestOpenForResumeREnterTriggersResume(t *testing.T) {
534+ rows := []swarm.AgentSnapshot{
535+ {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()},
536+ {ID: "detached-1", Status: swarm.StatusDetached, Started: time.Now()},
537+ }
538+ var resumed string
539+ d := newSwarmDialog()
540+ d.OpenForResume(
541+ staticSnapshots(rows...),
542+ nil, nil, nil, nil,
543+ func(id string) error { resumed = id; return nil },
544+ "",
545+ )
546+ _ = d.Render(tui.Theme{}, 80)
547+ _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'R'})
548+ if errMsg != "" {
549+ t.Fatalf("unexpected err: %s", errMsg)
550+ }
551+ if resumed != "detached-1" {
552+ t.Fatalf("resume callback got %q; want detached-1", resumed)
553+ }
554+ if msg != "resumed detached-1" {
555+ t.Fatalf("status msg = %q", msg)
556+ }
557+}
558+
559+// TestNeedsTickRefresh enumerates the four dialog states so the host's
560+// animation tick wakes the renderer for live updates but stays out of
561+// the way of the inline editors (which need the cursor blink).
562+func TestNeedsTickRefresh(t *testing.T) {
563+ // Closed.
564+ d := newSwarmDialog()
565+ if d.NeedsTickRefresh() {
566+ t.Fatal("closed dialog should not request tick refresh")
567+ }
568+
569+ // List view (open, no editors): tick should refresh so rows update.
570+ d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
571+ if !d.NeedsTickRefresh() {
572+ t.Fatal("open list view should request tick refresh")
573+ }
574+
575+ // Transcript view: still background-driven; tick should refresh.
576+ rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}}
577+ d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
578+ _ = d.Render(tui.Theme{}, 80)
579+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
580+ if !d.viewing {
581+ t.Fatal("setup: enter did not open transcript view")
582+ }
583+ if !d.NeedsTickRefresh() {
584+ t.Fatal("transcript view should request tick refresh")
585+ }
586+
587+ // Spawn editor open: tick should NOT refresh (cursor blink).
588+ d = newSwarmDialog()
589+ d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "")
590+ _ = d.Render(tui.Theme{}, 80)
591+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
592+ if !d.spawning {
593+ t.Fatal("setup: n did not enter spawning mode")
594+ }
595+ if d.NeedsTickRefresh() {
596+ t.Fatal("spawn editor should suppress tick refresh")
597+ }
598+
599+ // Prompt editor open: same suppression.
600+ d = newSwarmDialog()
601+ rows = []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}}
602+ d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "")
603+ _ = d.Render(tui.Theme{}, 80)
604+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
605+ if !d.prompting {
606+ t.Fatal("setup: p did not enter prompting mode")
607+ }
608+ if d.NeedsTickRefresh() {
609+ t.Fatal("prompt editor should suppress tick refresh")
610+ }
611+}
612+
613+// TestCursorPosPromptEditor verifies the dialog reports a real
614+// caret row for the prompt editor in both list-view and
615+// transcript-view contexts. Without this, the terminal cursor
616+// stayed at the (now-hidden) main editor row when the user
617+// pressed 'p', so the visible caret looked wrong.
618+func TestCursorPosPromptEditor(t *testing.T) {
619+ rows := []swarm.AgentSnapshot{
620+ {ID: "a", Task: "t", Status: swarm.StatusRunning, Started: time.Now()},
621+ }
622+
623+ // From the list view.
624+ d := newSwarmDialog()
625+ d.Open(staticSnapshots(rows...), nil, nil, nil,
626+ func(string, string) error { return nil }, nil, "")
627+ _ = d.Render(tui.Theme{}, 80)
628+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
629+ if !d.prompting {
630+ t.Fatal("setup: p did not enter prompting from list view")
631+ }
632+ row, _ := d.CursorPos(80)
633+ if row < 1 {
634+ t.Fatalf("list-view prompt CursorPos row = %d; want >= 1", row)
635+ }
636+
637+ // From the transcript view (deeper offset due to metadata block).
638+ d = newSwarmDialog()
639+ d.Open(staticSnapshots(rows...), nil, nil, nil,
640+ func(string, string) error { return nil }, nil, "")
641+ _ = d.Render(tui.Theme{}, 80)
642+ d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open transcript
643+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
644+ if !d.prompting || !d.viewing {
645+ t.Fatalf("setup: prompting=%v viewing=%v", d.prompting, d.viewing)
646+ }
647+ rowT, _ := d.CursorPos(80)
648+ if rowT <= row {
649+ t.Fatalf("transcript-view prompt row = %d; should be deeper than list row %d", rowT, row)
650+ }
651+}
652+
653+// TestSwarmDialogPOnNonRunningAgentShowsHint covers the regression
654+// caught by the user's screenshot: pressing 'p' on a detached agent
655+// dialled a dead unix socket and printed the raw path as an error.
656+// We now block the prompt editor for any non-running status and
657+// surface a clear status-bar hint instead.
658+func TestSwarmDialogPOnNonRunningAgentShowsHint(t *testing.T) {
659+ for _, st := range []swarm.Status{
660+ swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,
661+ } {
662+ t.Run(string(st), func(t *testing.T) {
663+ rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}}
664+ sends := 0
665+ d := newSwarmDialog()
666+ d.Open(staticSnapshots(rows...), nil, nil, nil,
667+ func(string, string) error { sends++; return nil }, nil, "")
668+ _ = d.Render(tui.Theme{}, 80)
669+
670+ _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
671+ if d.prompting {
672+ t.Fatal("prompt editor opened on non-running agent")
673+ }
674+ if sends != 0 {
675+ t.Fatal("send callback fired without an open editor")
676+ }
677+ if msg != "" {
678+ t.Errorf("unexpected ok msg: %q", msg)
679+ }
680+ if !strings.Contains(errMsg, string(st)) {
681+ t.Errorf("hint = %q; should mention status %q", errMsg, st)
682+ }
683+ if !strings.Contains(errMsg, "resume") && st != swarm.StatusPending && st != swarm.StatusRunning {
684+ t.Errorf("hint = %q; should mention resume for resumable state", errMsg)
685+ }
686+ })
687+ }
688+}
689+
690+// TestSwarmDialogPOnRunningAgentOpensEditor is the positive
691+// complement of the previous test: running agents still open the
692+// editor normally.
693+func TestSwarmDialogPOnRunningAgentOpensEditor(t *testing.T) {
694+ rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}}
695+ d := newSwarmDialog()
696+ d.Open(staticSnapshots(rows...), nil, nil, nil,
697+ func(string, string) error { return nil }, nil, "")
698+ _ = d.Render(tui.Theme{}, 80)
699+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
700+ if !d.prompting {
701+ t.Fatal("prompt editor did not open on running agent")
702+ }
703+}
704+
705+// TestFriendlySendErrRewritesNotReady asserts ErrNotReady becomes the
706+// user-friendly "isn't accepting input" hint, and unknown errors
707+// fall through verbatim.
708+func TestFriendlySendErrRewritesNotReady(t *testing.T) {
709+ hint := friendlySendErr("alpha-1", swarm.ErrNotReady)
710+ if !strings.Contains(hint, "alpha-1") || !strings.Contains(hint, "resume") {
711+ t.Errorf("friendlySendErr(ErrNotReady) = %q", hint)
712+ }
713+ if strings.Contains(hint, "swarm:") {
714+ t.Errorf("friendlySendErr leaked raw error text: %q", hint)
715+ }
716+
717+ other := errors.New("some other failure")
718+ got := friendlySendErr("alpha-1", other)
719+ if got != "send: some other failure" {
720+ t.Errorf("friendlySendErr passthrough = %q", got)
721+ }
722+}
723+
724+// TestRenderSwarmTranscriptBlocks_GroupsByRole asserts the flat
725+// transcript log is regrouped into user / assistant / stderr /
726+// error blocks before rendering. Each output row should be styled
727+// for its role rather than printed verbatim.
728+func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) {
729+ lines := []string{
730+ "user: hello",
731+ "hi there",
732+ "second assistant line",
733+ "stderr: warning thing",
734+ "error: boom",
735+ }
736+ out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)
737+ joined := strings.Join(out, "\n")
738+
739+ // The user echo should appear as a bubble row, which contains
740+ // the "▌ " left-bar glyph.
741+ if !strings.Contains(joined, "▌ ") {
742+ t.Errorf("user echo did not render as a bubble row:\n%s", joined)
743+ }
744+ // The user prefix should be stripped from the rendered output.
745+ if strings.Contains(joined, "user: hello") {
746+ t.Errorf("user prefix leaked into rendered output")
747+ }
748+ // Assistant content survives.
749+ if !strings.Contains(joined, "hi there") {
750+ t.Errorf("assistant text missing from rendered output")
751+ }
752+ // Stderr line is prefixed with stderr marker (we keep "stderr"
753+ // word in the rendered output for clarity).
754+ if !strings.Contains(joined, "stderr") || !strings.Contains(joined, "warning thing") {
755+ t.Errorf("stderr block missing:\n%s", joined)
756+ }
757+ // Error line gets the ✖ marker.
758+ if !strings.Contains(joined, "✖ boom") {
759+ t.Errorf("error block missing ✖ marker:\n%s", joined)
760+ }
761+}
762+
763+// TestRenderSwarmTranscriptBlocks_MarkdownStyling proves the
764+// assistant text goes through tui.RenderMarkdown: a literal **bold**
765+// pair should become an ANSI bold sequence and bullet "-" should
766+// become "•".
767+func TestRenderSwarmTranscriptBlocks_MarkdownStyling(t *testing.T) {
768+ lines := []string{
769+ "Here is a list:",
770+ "- **bold** item",
771+ "- plain item",
772+ }
773+ out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)
774+ joined := strings.Join(out, "\n")
775+
776+ // ANSI bold on/off. Markdown rendering uses CSI 1 / 22.
777+ if !strings.Contains(joined, "\x1b[1m") || !strings.Contains(joined, "\x1b[22m") {
778+ t.Errorf("expected ANSI bold sequences:\n%q", joined)
779+ }
780+ // Bullet glyph upgrade.
781+ if !strings.Contains(joined, "• ") {
782+ t.Errorf("expected • bullet:\n%s", joined)
783+ }
784+ // Literal "**bold**" markdown source must NOT leak through.
785+ if strings.Contains(joined, "**bold**") {
786+ t.Errorf("unrendered markdown leaked through:\n%s", joined)
787+ }
788+}
789+
790+// TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines
791+// guards the contract that consecutive assistant lines form one
792+// markdown block. If we render each line in isolation, fenced code
793+// (and other multi-line constructs) would never close.
794+func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) {
795+ lines := []string{
796+ "Some prose.",
797+ "```go",
798+ "package main",
799+ "",
800+ "func main() {}",
801+ "```",
802+ "Done.",
803+ }
804+ out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)
805+ joined := strings.Join(out, "\n")
806+
807+ // The literal fence markers should be consumed by the markdown
808+ // renderer; if we rendered each line on its own the ``` would
809+ // leak as text.
810+ if strings.Contains(joined, "```") {
811+ t.Errorf("fence markers leaked — lines weren't grouped:\n%s", joined)
812+ }
813+ // The code content should still be in the output — strip ANSI
814+ // before checking because syntax highlighting wraps each token.
815+ plain := stripANSIBytes(joined)
816+ if !strings.Contains(plain, "package main") || !strings.Contains(plain, "func main") {
817+ t.Errorf("code block content missing:\nansi=%q\nplain=%q", joined, plain)
818+ }
819+}
820+
821+// TestSwarmDialogKOnNonRunningAgentShowsHint mirrors the prompt-
822+// disabled test: pressing 'k' on an already-terminal or detached
823+// agent now surfaces a hint instead of routing to Swarm.Stop, which
824+// is a no-op for those states (and used to segfault for detached
825+// agents before the cancel-nil guard).
826+func TestSwarmDialogKOnNonRunningAgentShowsHint(t *testing.T) {
827+ for _, st := range []swarm.Status{
828+ swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,
829+ } {
830+ t.Run(string(st), func(t *testing.T) {
831+ rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}}
832+ stops := 0
833+ d := newSwarmDialog()
834+ d.Open(staticSnapshots(rows...),
835+ func(string) error { stops++; return nil },
836+ nil, nil, nil, nil, "")
837+ _ = d.Render(tui.Theme{}, 80)
838+ _, _, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'k'})
839+ if stops != 0 {
840+ t.Fatalf("stop callback fired on %s agent", st)
841+ }
842+ if !strings.Contains(errMsg, string(st)) || !strings.Contains(errMsg, "remove") {
843+ t.Errorf("hint = %q; should mention %q and suggest remove", errMsg, st)
844+ }
845+ })
846+ }
847+}
848+
849+// TestSwarmDialogSpawnFlow_CurrentModelInherited proves the spawn
850+// callback receives whatever model the host pinned via
851+// SetCurrentModel before opening the dialog. This is how /model is
852+// surfaced to swarm agents: the user picks via /model first, then
853+// /swarm n, and the new agent inherits.
854+func TestSwarmDialogSpawnFlow_CurrentModelInherited(t *testing.T) {
855+ var gotTask, gotModel, gotProv string
856+ d := newSwarmDialog()
857+ d.SetCurrentModel("claude-sonnet-4-5", "anthropic")
858+ d.Open(staticSnapshots(), nil, nil,
859+ func(task, model, prov string) error {
860+ gotTask, gotModel, gotProv = task, model, prov
861+ return nil
862+ },
863+ nil, nil, "")
864+ _ = d.Render(tui.Theme{}, 80)
865+
866+ // `n` goes straight to the spawn editor; no picker step.
867+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
868+ if !d.spawning {
869+ t.Fatal("n did not enter spawning mode")
870+ }
871+
872+ // The spawn editor advertises the inherited model so the user
873+ // knows what they're about to spawn against.
874+ out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
875+ if !strings.Contains(out, "model: claude-sonnet-4-5") {
876+ t.Errorf("spawn editor missing model banner:\n%s", out)
877+ }
878+
879+ for _, r := range "do thing" {
880+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
881+ }
882+ _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
883+ if gotTask != "do thing" || gotModel != "claude-sonnet-4-5" || gotProv != "anthropic" {
884+ t.Fatalf("spawn args = (%q,%q,%q); want (\"do thing\",\"claude-sonnet-4-5\",\"anthropic\")",
885+ gotTask, gotModel, gotProv)
886+ }
887+ if !strings.Contains(msg, "claude-sonnet-4-5") {
888+ t.Errorf("status msg = %q; should mention inherited model", msg)
889+ }
890+}
891+
892+// TestSwarmDialogSpawnEditor_SlashModelOpensPicker proves the in-
893+// editor /model command pops the same model dialog the global
894+// /model uses, that picking a model captures it as the pending
895+// override, and that the spawn editor reopens with any previously-
896+// typed task text intact so the user doesn't lose work.
897+func TestSwarmDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) {
898+ var gotTask, gotModel, gotProv string
899+ d := newSwarmDialog()
900+ d.SetCurrentModel("old-model", "old-prov")
901+ d.Open(staticSnapshots(), nil, nil,
902+ func(task, model, prov string) error {
903+ gotTask, gotModel, gotProv = task, model, prov
904+ return nil
905+ },
906+ nil, nil, "")
907+ _ = d.Render(tui.Theme{}, 80)
908+
909+ // Open spawn editor and type some task text plus a /model line.
910+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
911+ if !d.spawning {
912+ t.Fatal("n did not enter spawning mode")
913+ }
914+ for _, r := range "refactor X" {
915+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
916+ }
917+ // Newline so the editor has a two-line buffer when we add /model.
918+ d.newTaskEd.SetValue("refactor X\n/model")
919+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
920+
921+ if !d.pickingModel {
922+ t.Fatalf("/model submit did not open the picker (spawning=%v picking=%v)", d.spawning, d.pickingModel)
923+ }
924+ if d.spawning {
925+ t.Fatal("spawn editor still marked open while picker is up")
926+ }
927+ if d.spawnDraft != "refactor X" {
928+ t.Fatalf("spawnDraft = %q; want %q (the /model line should be stripped, the task kept)", d.spawnDraft, "refactor X")
929+ }
930+
931+ // Picker render must take over the dialog frame.
932+ out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
933+ if !strings.Contains(out, "select model for next spawn") {
934+ t.Errorf("picker render hint missing:\n%s", out)
935+ }
936+
937+ // Inject a fake selection into the embedded modelDialog and confirm.
938+ d.modelPicker.all = []provider.Model{{Provider: "acme", ID: "fake-1"}}
939+ d.modelPicker.view = d.modelPicker.all
940+ d.modelPicker.cursor = 0
941+ _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
942+
943+ if d.pickingModel {
944+ t.Fatal("picker did not close on select")
945+ }
946+ if !d.spawning {
947+ t.Fatal("spawn editor did not reopen after picker select")
948+ }
949+ if d.pendingModel != "fake-1" || d.pendingProvider != "acme" {
950+ t.Errorf("pending = (%q,%q); want (fake-1, acme)", d.pendingModel, d.pendingProvider)
951+ }
952+ if !strings.Contains(msg, "fake-1") {
953+ t.Errorf("status msg = %q; should mention picked model", msg)
954+ }
955+ if got := d.newTaskEd.Value(); got != "refactor X" {
956+ t.Errorf("reopened editor buffer = %q; want preserved task %q", got, "refactor X")
957+ }
958+
959+ // Now submit the task and confirm the spawn callback gets the
960+ // new model.
961+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
962+ if gotTask != "refactor X" || gotModel != "fake-1" || gotProv != "acme" {
963+ t.Errorf("spawn args = (%q,%q,%q); want (refactor X, fake-1, acme)", gotTask, gotModel, gotProv)
964+ }
965+}
966+
967+// TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft confirms
968+// pressing Esc inside the picker (no selection) reopens the spawn
969+// editor with the previously-typed text intact AND leaves the
970+// pending model untouched, so the user can bail out cleanly.
971+func TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft(t *testing.T) {
972+ d := newSwarmDialog()
973+ d.SetCurrentModel("keep-me", "keep-prov")
974+ d.Open(staticSnapshots(), nil, nil,
975+ func(string, string, string) error { return nil },
976+ nil, nil, "")
977+ _ = d.Render(tui.Theme{}, 80)
978+
979+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
980+ d.newTaskEd.SetValue("task here\n/model")
981+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
982+ if !d.pickingModel {
983+ t.Fatal("/model did not open the picker")
984+ }
985+
986+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
987+ if d.pickingModel {
988+ t.Fatal("esc did not close the picker")
989+ }
990+ if !d.spawning {
991+ t.Fatal("spawn editor did not reopen after picker cancel")
992+ }
993+ if d.pendingModel != "keep-me" || d.pendingProvider != "keep-prov" {
994+ t.Errorf("pending changed on Esc: (%q,%q); want (keep-me, keep-prov)", d.pendingModel, d.pendingProvider)
995+ }
996+ if got := d.newTaskEd.Value(); got != "task here" {
997+ t.Errorf("reopened editor buffer = %q; want %q", got, "task here")
998+ }
999+}
1000+
1001+// TestStripSlashModelLine pins the helper's behaviour: drop any
1002+// line whose trimmed content is "/model" or starts with "/model ",
1003+// preserve everything else verbatim.
1004+func TestStripSlashModelLine(t *testing.T) {
1005+ cases := []struct{ in, want string }{
1006+ {"/model", ""},
1007+ {" /model ", ""},
1008+ {"foo\n/model", "foo"},
1009+ {"/model\nfoo", "foo"},
1010+ {"foo\n/model\nbar", "foo\nbar"},
1011+ {"/model gpt-5", ""},
1012+ {"a /model in prose", "a /model in prose"}, // not first non-blank token → keep
1013+ {"", ""},
1014+ {"plain task", "plain task"},
1015+ }
1016+ for _, c := range cases {
1017+ if got := stripSlashModelLine(c.in); got != c.want {
1018+ t.Errorf("stripSlashModelLine(%q) = %q; want %q", c.in, got, c.want)
1019+ }
1020+ }
1021+}
1022+
1023+// TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault confirms that
1024+// when the host doesn't pin a model (SetCurrentModel never called or
1025+// called with empty strings), the spawn callback receives empty
1026+// model/provider and the child subprocess resolves its own default.
1027+func TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) {
1028+ var gotModel, gotProv string
1029+ d := newSwarmDialog()
1030+ d.Open(staticSnapshots(), nil, nil,
1031+ func(_, model, prov string) error {
1032+ gotModel, gotProv = model, prov
1033+ return nil
1034+ },
1035+ nil, nil, "")
1036+ _ = d.Render(tui.Theme{}, 80)
1037+
1038+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
1039+ for _, r := range "do thing" {
1040+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1041+ }
1042+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1043+ if gotModel != "" || gotProv != "" {
1044+ t.Fatalf("spawn args = (model=%q, prov=%q); want both empty", gotModel, gotProv)
1045+ }
1046+}
1047+
1048+// TestSwarmTranscriptGrowsWithNewMessages confirms the /btw-style
1049+// "the transcript grows with the content" property: when the agent
1050+// reply lands in the snapshot, the next render includes it in full,
1051+// alongside everything that was already there. Replaces the old
1052+// followTail/scroll tests that pinned a viewport mechanic we no
1053+// longer have.
1054+func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) {
1055+ cur := []swarm.AgentSnapshot{{
1056+ ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1057+ Lines: []string{"old line 1", "old line 2"}, Started: time.Now(),
1058+ }}
1059+ snap := func() []swarm.AgentSnapshot { return cur }
1060+
1061+ d := newSwarmDialog()
1062+ d.Open(snap, nil, nil, nil,
1063+ func(string, string) error { return nil },
1064+ nil, "")
1065+ _ = d.Render(tui.Theme{}, 80)
1066+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1067+
1068+ out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1069+ if !strings.Contains(out, "old line 1") || !strings.Contains(out, "old line 2") {
1070+ t.Fatalf("initial render missing existing lines:\n%s", out)
1071+ }
1072+
1073+ // Agent appends a fresh reply: the very next render must show
1074+ // both the new content AND keep the old content (no clipping).
1075+ cur = []swarm.AgentSnapshot{{
1076+ ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1077+ Lines: []string{"old line 1", "old line 2", "new reply A", "new reply B"},
1078+ Started: cur[0].Started,
1079+ }}
1080+ out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
1081+ for _, want := range []string{"old line 1", "old line 2", "new reply A", "new reply B"} {
1082+ if !strings.Contains(out, want) {
1083+ t.Errorf("line %q missing after growth:\n%s", want, out)
1084+ }
1085+ }
1086+}
1087+
1088+// TestSwarmTranscriptBtwStyleMultipleSends pins the /btw-style flow
1089+// for the transcript view: after one Enter-submit the editor stays
1090+// mounted and clears, and a second message can be composed and sent
1091+// without any modal toggle. Regresses the previous design where
1092+// every send closed the prompt and forced the user to press `p`
1093+// again.
1094+func TestSwarmTranscriptBtwStyleMultipleSends(t *testing.T) {
1095+ rows := []swarm.AgentSnapshot{{
1096+ ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(),
1097+ Lines: []string{"warm-up"},
1098+ }}
1099+ var sent []string
1100+ d := newSwarmDialog()
1101+ d.Open(staticSnapshots(rows...), nil, nil, nil,
1102+ func(_, text string) error { sent = append(sent, text); return nil },
1103+ nil, "")
1104+ _ = d.Render(tui.Theme{}, 80)
1105+ d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open transcript + auto-editor
1106+
1107+ // First message.
1108+ for _, r := range "first" {
1109+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1110+ }
1111+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1112+
1113+ // Second message, no `p` toggle in between.
1114+ for _, r := range "second" {
1115+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1116+ }
1117+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1118+
1119+ if len(sent) != 2 || sent[0] != "first" || sent[1] != "second" {
1120+ t.Fatalf("sent = %v; want [first second]", sent)
1121+ }
1122+ if !d.viewing || !d.prompting {
1123+ t.Errorf("transcript view/editor torn down between sends (viewing=%v prompting=%v)", d.viewing, d.prompting)
1124+ }
1125+}
1126+
1127+// TestSwarmTranscriptEscClosesView pins the Esc semantics for the
1128+// /btw-style transcript view: a single Esc closes the whole view
1129+// (back to the dashboard list), regardless of whether the editor
1130+// is currently composing. Without this routing the editor's own
1131+// Esc handler would just clear the modal and leave the user stuck
1132+// inside an empty transcript view.
1133+func TestSwarmTranscriptEscClosesView(t *testing.T) {
1134+ rows := []swarm.AgentSnapshot{{
1135+ ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(),
1136+ Lines: []string{"hi"},
1137+ }}
1138+ d := newSwarmDialog()
1139+ d.Open(staticSnapshots(rows...), nil, nil, nil,
1140+ func(string, string) error { return nil },
1141+ nil, "")
1142+ _ = d.Render(tui.Theme{}, 80)
1143+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1144+ if !d.viewing || !d.prompting {
1145+ t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting)
1146+ }
1147+
1148+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
1149+ if d.viewing {
1150+ t.Error("Esc did not close the transcript view")
1151+ }
1152+ if d.prompting {
1153+ t.Error("Esc did not tear down the inline editor")
1154+ }
1155+}
1156+
1157+// TestSwarmTranscriptNonRunningAgentNoEditor pins the safety net:
1158+// transcripts for done / failed / killed agents must NOT auto-open
1159+// an editor (the agent's inbox isn't listening, so a send would
1160+// just bounce). The view shows the transcript plus a hint about
1161+// resuming instead.
1162+func TestSwarmTranscriptNonRunningAgentNoEditor(t *testing.T) {
1163+ rows := []swarm.AgentSnapshot{{
1164+ ID: "agent-1", Task: "x", Status: swarm.StatusDone, Started: time.Now(),
1165+ Lines: []string{"all done"},
1166+ }}
1167+ d := newSwarmDialog()
1168+ d.Open(staticSnapshots(rows...), nil, nil, nil,
1169+ func(string, string) error { return nil },
1170+ nil, "")
1171+ _ = d.Render(tui.Theme{}, 80)
1172+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1173+ if d.prompting {
1174+ t.Fatal("editor auto-opened for a done agent; should only happen for running ones")
1175+ }
1176+ out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1177+ if !strings.Contains(out, "prompt: agent is done") {
1178+ t.Errorf("hint about non-running status missing:\n%s", out)
1179+ }
1180+}
1181+
1182+// TestSwarmTranscriptTickRefreshWhileEditing pins the streaming
1183+// fix: with the always-on editor in transcript view, the host's
1184+// animation tick MUST still call us so streaming agent output
1185+// renders. Without this, sending a message and watching the reply
1186+// land silently failed because the prompting=true branch was
1187+// suppressing redraws (like the modal editors do).
1188+func TestSwarmTranscriptTickRefreshWhileEditing(t *testing.T) {
1189+ rows := []swarm.AgentSnapshot{{
1190+ ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1191+ Activity: "thinking", Started: time.Now(),
1192+ Lines: []string{"hello"},
1193+ }}
1194+ d := newSwarmDialog()
1195+ d.Open(staticSnapshots(rows...), nil, nil, nil,
1196+ func(string, string) error { return nil },
1197+ nil, "")
1198+ _ = d.Render(tui.Theme{}, 80)
1199+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1200+ if !d.viewing || !d.prompting {
1201+ t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting)
1202+ }
1203+
1204+ if !d.NeedsTickRefresh() {
1205+ t.Fatal("transcript view with always-on editor refused tick refresh; streaming output would freeze")
1206+ }
1207+}
1208+
1209+// TestSwarmTranscriptTickRefreshModalSuppressed pins the inverse:
1210+// the list-view `p` modal (prompting without viewing) still
1211+// suppresses tick like the other modal editors, so the cursor
1212+// blink works cleanly while composing a one-off prompt.
1213+func TestSwarmTranscriptTickRefreshModalSuppressed(t *testing.T) {
1214+ rows := []swarm.AgentSnapshot{{
1215+ ID: "agent-1", Status: swarm.StatusRunning, Started: time.Now(),
1216+ }}
1217+ d := newSwarmDialog()
1218+ d.Open(staticSnapshots(rows...), nil, nil, nil,
1219+ func(string, string) error { return nil },
1220+ nil, "")
1221+ _ = d.Render(tui.Theme{}, 80)
1222+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
1223+ if d.viewing || !d.prompting {
1224+ t.Fatalf("setup: viewing=%v prompting=%v; want list-view modal", d.viewing, d.prompting)
1225+ }
1226+
1227+ if d.NeedsTickRefresh() {
1228+ t.Error("list-view prompt modal did not suppress tick refresh; cursor blink may misbehave")
1229+ }
1230+}
1231+
1232+// TestSwarmTranscriptBusySpinnerRenders proves the inline spinner
1233+// appears above the always-on editor while the agent is busy and
1234+// disappears when it goes idle. This is the /btw-style "something
1235+// is happening" feedback the user was missing.
1236+func TestSwarmTranscriptBusySpinnerRenders(t *testing.T) {
1237+ cur := []swarm.AgentSnapshot{{
1238+ ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1239+ Activity: "thinking", Started: time.Now(),
1240+ Lines: []string{"hello"},
1241+ }}
1242+ snap := func() []swarm.AgentSnapshot { return cur }
1243+ d := newSwarmDialog()
1244+ d.Open(snap, nil, nil, nil,
1245+ func(string, string) error { return nil },
1246+ nil, "")
1247+ _ = d.Render(tui.Theme{}, 80)
1248+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1249+
1250+ out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1251+ if !strings.Contains(out, "thinking") {
1252+ t.Fatalf("busy spinner did not surface activity \"thinking\" near the editor:\n%s", out)
1253+ }
1254+ if d.transcriptSpin == nil {
1255+ t.Error("spinner not initialised while agent is busy")
1256+ }
1257+
1258+ // Flip the activity to idle: spinner should vanish.
1259+ cur[0].Activity = "idle"
1260+ out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
1261+ if d.transcriptSpin != nil {
1262+ t.Error("spinner stayed live after agent reported idle")
1263+ }
1264+ // The bare word "thinking" must not appear anymore.
1265+ if strings.Contains(out, "· thinking ·") {
1266+ t.Errorf("busy line still rendered when agent is idle:\n%s", out)
1267+ }
1268+}
1269+
1270+func TestStatusLabelCoversAll(t *testing.T) {
1271+ for _, s := range []swarm.Status{
1272+ swarm.StatusPending, swarm.StatusRunning,
1273+ swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,
1274+ } {
1275+ if statusLabel(s) == "" {
1276+ t.Fatalf("no label for %s", s)
1277+ }
1278+ }
1279+}
A internal/agent/modes/swarm_slash.go +309 -0
@@ -0,0 +1,309 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/patriceckhart/zot/internal/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.
26+func (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.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
73+ if i.cfg.LoggedInProviders != nil {
74+ i.swarmDialog.SetLoggedInProviders(i.cfg.LoggedInProviders())
75+ }
76+
77+ switch sub {
78+ case "", "list", "ls", "ps":
79+ i.swarmDialog.Open(
80+ i.cfg.Swarm.SnapshotAll,
81+ i.cfg.Swarm.Stop,
82+ i.cfg.Swarm.Remove,
83+ spawnAdapter,
84+ sendAdapter,
85+ resumeAdapter,
86+ i.cfg.CWD,
87+ )
88+ case "new", "spawn":
89+ if rest == "" {
90+ i.swarmStatus("", "/swarm new <task>: missing task")
91+ return
92+ }
93+ // Permit `--model X --provider Y` flags before the task so
94+ // scripts can pin a model without going through the dialog.
95+ // Anything that isn't a recognised flag terminates parsing
96+ // and the rest becomes the task; this keeps `/swarm new
97+ // --model foo do a thing` and `/swarm new do --model thing`
98+ // (where --model is part of the task) unambiguous — only
99+ // leading flags are consumed.
100+ model, provider, task := parseSpawnFlags(rest)
101+ if task == "" {
102+ i.swarmStatus("", "/swarm new: missing task (after any --model/--provider flags)")
103+ return
104+ }
105+ a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
106+ Task: task, Model: model, Provider: provider,
107+ })
108+ if err != nil {
109+ i.swarmStatus("", "spawn: "+err.Error())
110+ return
111+ }
112+ if model != "" {
113+ i.swarmStatus("spawned "+a.ID+" on "+a.Branch+" (model "+model+")", "")
114+ } else {
115+ i.swarmStatus("spawned "+a.ID+" on "+a.Branch, "")
116+ }
117+ case "kill", "stop":
118+ if rest == "" {
119+ i.swarmStatus("", "/swarm kill <id>: missing id")
120+ return
121+ }
122+ if err := i.cfg.Swarm.Stop(rest); err != nil {
123+ i.swarmStatus("", "kill: "+err.Error())
124+ return
125+ }
126+ i.swarmStatus("stopped "+rest, "")
127+ case "remove", "rm":
128+ if rest == "" {
129+ i.swarmStatus("", "/swarm remove <id>: missing id")
130+ return
131+ }
132+ if err := i.cfg.Swarm.Remove(rest); err != nil {
133+ i.swarmStatus("", "remove: "+err.Error())
134+ return
135+ }
136+ i.swarmStatus("removed "+rest, "")
137+ case "logs", "log", "view":
138+ if rest == "" {
139+ i.swarmStatus("", "/swarm logs <id>: missing id")
140+ return
141+ }
142+ ok := i.swarmDialog.OpenViewing(
143+ rest,
144+ i.cfg.Swarm.SnapshotAll,
145+ i.cfg.Swarm.Stop,
146+ i.cfg.Swarm.Remove,
147+ spawnAdapter,
148+ sendAdapter,
149+ resumeAdapter,
150+ i.cfg.CWD,
151+ )
152+ if !ok {
153+ i.swarmStatus("", "/swarm logs: no agent matching "+rest)
154+ }
155+ case "resume", "reattach", "reopen":
156+ if rest == "" {
157+ // No id given: open the dashboard with the cursor
158+ // pre-positioned on the first resumable agent, and
159+ // tell the user how many there are so they know what
160+ // to expect. Pressing R confirms; ↑/↓ to pick a
161+ // different row first.
162+ count := i.swarmDialog.OpenForResume(
163+ i.cfg.Swarm.SnapshotAll,
164+ i.cfg.Swarm.Stop,
165+ i.cfg.Swarm.Remove,
166+ spawnAdapter,
167+ sendAdapter,
168+ resumeAdapter,
169+ i.cfg.CWD,
170+ )
171+ switch count {
172+ case 0:
173+ i.swarmStatus("", "/swarm resume: no resumable agents (none detached or terminated)")
174+ case 1:
175+ i.swarmStatus("1 resumable agent · press R to resume", "")
176+ default:
177+ i.swarmStatus(fmt.Sprintf("%d resumable agents · ↑/↓ to pick · R to resume", count), "")
178+ }
179+ return
180+ }
181+ a, err := i.cfg.Swarm.Resume(ctx, rest)
182+ if err != nil {
183+ i.swarmStatus("", "resume: "+err.Error())
184+ return
185+ }
186+ i.swarmStatus("resumed "+a.ID, "")
187+ case "send", "prompt", "msg":
188+ // /swarm send <id> <text...> is the non-interactive
189+ // counterpart of pressing 'p' in the dashboard. We split the
190+ // joined `rest` ourselves rather than reusing the dispatcher's
191+ // already-fielded args[] because the text may contain spaces
192+ // the user expects to be preserved verbatim.
193+ id, text := splitIDAndRest(rest)
194+ if id == "" {
195+ i.swarmStatus("", "/swarm send <id> <text>: missing id")
196+ return
197+ }
198+ if text == "" {
199+ i.swarmStatus("", "/swarm send <id> <text>: missing text")
200+ return
201+ }
202+ if err := i.cfg.Swarm.SendUserTurn(id, text); err != nil {
203+ i.swarmStatus("", friendlySendErr(id, err))
204+ return
205+ }
206+ i.swarmStatus("sent to "+id, "")
207+ case "attach":
208+ // PTY-reparenting is a significant chunk of work I haven't
209+ // landed yet (see the design sketch). Recognise the name so
210+ // /swarm attach doesn't fall through to the generic "unknown
211+ // subcommand" path — that error message is misleading because
212+ // it makes attach sound like a typo instead of a planned
213+ // feature. Point the user at /swarm logs in the meantime.
214+ i.swarmStatus("", "/swarm attach: not implemented yet (needs PTY reparenting). Use /swarm logs "+firstWord(rest)+" to watch its transcript.")
215+ default:
216+ i.swarmStatus("", "/swarm: unknown subcommand "+sub+" (try list / new / kill / remove / logs / send / resume)")
217+ }
218+}
219+
220+// parseSpawnFlags consumes any leading `--model X` / `--provider Y`
221+// flags from s and returns them along with the remaining task body.
222+// We deliberately only honour LEADING flags so a task like "check
223+// --model lookup" doesn't accidentally swallow part of its prose as
224+// the model name.
225+//
226+// Recognised forms:
227+//
228+// --model X two-token form
229+// --model=X single-token form
230+// --provider X two-token form
231+// --provider=X single-token form
232+func parseSpawnFlags(s string) (model, provider, task string) {
233+ fields := strings.Fields(s)
234+ i := 0
235+ for i < len(fields) {
236+ f := fields[i]
237+ switch {
238+ case f == "--model":
239+ // Consume the flag even when no value follows so a
240+ // dangling "--model" doesn't leak into the task. The
241+ // caller surfaces "missing task" instead.
242+ if i+1 < len(fields) {
243+ model = fields[i+1]
244+ i += 2
245+ } else {
246+ i++
247+ }
248+ continue
249+ case strings.HasPrefix(f, "--model="):
250+ model = strings.TrimPrefix(f, "--model=")
251+ i++
252+ continue
253+ case f == "--provider":
254+ if i+1 < len(fields) {
255+ provider = fields[i+1]
256+ i += 2
257+ } else {
258+ i++
259+ }
260+ continue
261+ case strings.HasPrefix(f, "--provider="):
262+ provider = strings.TrimPrefix(f, "--provider=")
263+ i++
264+ continue
265+ }
266+ break
267+ }
268+ task = strings.TrimSpace(strings.Join(fields[i:], " "))
269+ return
270+}
271+
272+// splitIDAndRest splits "<id> <text...>" into (id, text). The text
273+// half preserves all whitespace after the first token so the agent
274+// receives the user's prompt verbatim (modulo a single trim of the
275+// boundary space). Returns ("", "") when s is empty so the caller
276+// can surface a missing-id error.
277+func splitIDAndRest(s string) (id, text string) {
278+ s = strings.TrimLeft(s, " \t")
279+ if s == "" {
280+ return "", ""
281+ }
282+ cut := strings.IndexAny(s, " \t")
283+ if cut < 0 {
284+ return s, ""
285+ }
286+ return s[:cut], strings.TrimLeft(s[cut+1:], " \t")
287+}
288+
289+// firstWord returns the first whitespace-separated token of s, or
290+// "<id>" when s is empty. Used to keep the "/swarm attach" hint
291+// readable even when the user typed no argument.
292+func firstWord(s string) string {
293+ s = strings.TrimSpace(s)
294+ if s == "" {
295+ return "<id>"
296+ }
297+ if i := strings.IndexAny(s, " \t"); i >= 0 {
298+ return s[:i]
299+ }
300+ return s
301+}
302+
303+func (i *Interactive) swarmStatus(ok, errMsg string) {
304+ i.mu.Lock()
305+ i.statusOK = ok
306+ i.statusErr = errMsg
307+ i.mu.Unlock()
308+ i.invalidate()
309+}
A internal/agent/modes/swarm_slash_test.go +228 -0
@@ -0,0 +1,228 @@
1+package modes
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/internal/swarm"
10+)
11+
12+// newInteractiveForSwarmTest builds the minimal Interactive scaffolding
13+// runSwarm needs. It does NOT call NewInteractive (which would pull in
14+// the whole TUI); the runSwarm method only touches cfg.Swarm, the
15+// status mutex, and the swarm dialog, so we hand-build those.
16+func newInteractiveForSwarmTest(t *testing.T) (*Interactive, *swarm.Swarm) {
17+ t.Helper()
18+ root := t.TempDir()
19+ f := swarm.New(swarm.Config{
20+ Root: root,
21+ RepoRoot: root,
22+ Worktree: swarm.MemWorktree(filepath.Join(root, "wt")),
23+ NewRunner: func(a *swarm.Agent) swarm.Runner {
24+ return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error {
25+ <-ctx.Done()
26+ return ctx.Err()
27+ })
28+ },
29+ })
30+ iv := &Interactive{
31+ swarmDialog: newSwarmDialog(),
32+ dirty: make(chan struct{}, 1),
33+ }
34+ iv.cfg.Swarm = f
35+ return iv, f
36+}
37+
38+// TestRunSwarmBareDoesNotPanic regression-tests the slice-out-of-range
39+// panic that hit when /swarm was typed with no subcommand: runSwarm
40+// did args[1:] without checking len(args), which panics as [1:0].
41+func TestRunSwarmBareDoesNotPanic(t *testing.T) {
42+ iv, _ := newInteractiveForSwarmTest(t)
43+ defer iv.cfg.Swarm.StopAll()
44+
45+ // Bare /swarm: parts[1:] from the dispatcher is an empty slice.
46+ iv.runSwarm(context.Background(), nil)
47+
48+ if !iv.swarmDialog.Active() {
49+ t.Fatal("bare /swarm should open the dashboard")
50+ }
51+}
52+
53+func TestRunSwarmSubcommandsDoNotPanic(t *testing.T) {
54+ iv, _ := newInteractiveForSwarmTest(t)
55+ defer iv.cfg.Swarm.StopAll()
56+
57+ // Each row is the slice that the dispatcher hands to runSwarm —
58+ // i.e. parts[1:] where parts was strings.Fields of the slash
59+ // command. Mixing zero-arg and arg'd forms exercises both
60+ // branches of the reslice guard.
61+ cases := [][]string{
62+ {"list"},
63+ {"new"},
64+ {"new", "fix", "the", "thing"},
65+ {"kill"},
66+ {"kill", "no-such-id"},
67+ {"remove"},
68+ {"remove", "no-such-id"},
69+ {"send"},
70+ {"send", "no-such-id"},
71+ {"send", "no-such-id", "hello", "world"},
72+ {"bogus"},
73+ }
74+ for _, args := range cases {
75+ func() {
76+ defer func() {
77+ if r := recover(); r != nil {
78+ t.Fatalf("runSwarm(%v) panicked: %v", args, r)
79+ }
80+ }()
81+ iv.runSwarm(context.Background(), args)
82+ }()
83+ }
84+}
85+
86+func TestRunSwarmNewSpawnsAgent(t *testing.T) {
87+ iv, f := newInteractiveForSwarmTest(t)
88+ defer f.StopAll()
89+
90+ iv.runSwarm(context.Background(), []string{"new", "do", "stuff"})
91+ agents := f.List()
92+ if len(agents) != 1 {
93+ t.Fatalf("want 1 agent; got %d", len(agents))
94+ }
95+ if agents[0].Task != "do stuff" {
96+ t.Fatalf("task = %q; want %q", agents[0].Task, "do stuff")
97+ }
98+}
99+
100+// TestRunSwarmSendDeliversToAgentInbox spins up a real agent with a
101+// fake Runner whose only job is to forward inbox lines to a channel,
102+// then asserts the /swarm send <id> <text...> path routes through
103+// Swarm.SendUserTurn and lands at the agent verbatim.
104+func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) {
105+ root := t.TempDir()
106+ recv := make(chan string, 4)
107+ f := swarm.New(swarm.Config{
108+ Root: root,
109+ RepoRoot: root,
110+ Worktree: swarm.MemWorktree(filepath.Join(root, "wt")),
111+ NewRunner: func(a *swarm.Agent) swarm.Runner {
112+ return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error {
113+ // Stand up a real Listener on the agent's inbox path so
114+ // SendUserTurn (which dials a unix socket) actually has
115+ // something to talk to. The runner-test stubs do the
116+ // same; this is the minimum to exercise the wire.
117+ ln, err := swarm.Listen(a.InboxPath)
118+ if err != nil {
119+ return err
120+ }
121+ defer ln.Close()
122+ for {
123+ select {
124+ case <-ctx.Done():
125+ return ctx.Err()
126+ case line, ok := <-ln.Lines():
127+ if !ok {
128+ return nil
129+ }
130+ recv <- line
131+ }
132+ }
133+ })
134+ },
135+ })
136+ defer f.StopAll()
137+ iv := &Interactive{swarmDialog: newSwarmDialog(), dirty: make(chan struct{}, 1)}
138+ iv.cfg.Swarm = f
139+
140+ a, err := f.Spawn(context.Background(), "do thing")
141+ if err != nil {
142+ t.Fatalf("spawn: %v", err)
143+ }
144+
145+ // Run /swarm send <id> <text...>. The dispatcher would have
146+ // already strings.Fields-ed the input; mirror that here.
147+ iv.runSwarm(context.Background(), []string{"send", a.ID, "please", "continue"})
148+
149+ select {
150+ case msg := <-recv:
151+ want := "user please continue"
152+ if msg != want {
153+ t.Fatalf("agent received %q; want %q", msg, want)
154+ }
155+ case <-time.After(2 * time.Second):
156+ t.Fatal("timed out waiting for agent to receive the prompt")
157+ }
158+
159+ if iv.statusErr != "" {
160+ t.Fatalf("status err set: %q", iv.statusErr)
161+ }
162+ if iv.statusOK == "" || iv.statusOK[:7] != "sent to" {
163+ t.Fatalf("status ok = %q; want \"sent to ...\"", iv.statusOK)
164+ }
165+}
166+
167+func TestParseSpawnFlags(t *testing.T) {
168+ cases := []struct {
169+ in string
170+ wantModel, wantProv string
171+ wantTask string
172+ }{
173+ {"do x", "", "", "do x"},
174+ {"--model claude do x", "claude", "", "do x"},
175+ {"--model=claude do x", "claude", "", "do x"},
176+ {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "do x"},
177+ {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "do x"},
178+ // Only LEADING flags are consumed.
179+ {"do --model x", "", "", "do --model x"},
180+ // Missing value: --model with no follow-up token leaves model empty
181+ // and the next field starts the task.
182+ {"--model", "", "", ""},
183+ }
184+ for _, c := range cases {
185+ m, p, task := parseSpawnFlags(c.in)
186+ if m != c.wantModel || p != c.wantProv || task != c.wantTask {
187+ t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q); want (%q,%q,%q)",
188+ c.in, m, p, task, c.wantModel, c.wantProv, c.wantTask)
189+ }
190+ }
191+}
192+
193+func TestSplitIDAndRest(t *testing.T) {
194+ cases := []struct {
195+ in string
196+ wantID string
197+ wantText string
198+ }{
199+ {"", "", ""},
200+ {" ", "", ""},
201+ {"alpha", "alpha", ""},
202+ {"alpha hello world", "alpha", "hello world"},
203+ {" alpha hello world ", "alpha", "hello world "},
204+ {"alpha\thi", "alpha", "hi"},
205+ }
206+ for _, c := range cases {
207+ gotID, gotText := splitIDAndRest(c.in)
208+ if gotID != c.wantID || gotText != c.wantText {
209+ t.Errorf("splitIDAndRest(%q) = (%q,%q); want (%q,%q)", c.in, gotID, gotText, c.wantID, c.wantText)
210+ }
211+ }
212+}
213+
214+func TestRunSwarmWithoutSwarmIsNoop(t *testing.T) {
215+ iv := &Interactive{
216+ swarmDialog: newSwarmDialog(),
217+ dirty: make(chan struct{}, 1),
218+ }
219+ // cfg.Swarm stays nil. The command should set a status err and
220+ // otherwise be inert.
221+ iv.runSwarm(context.Background(), nil)
222+ if iv.swarmDialog.Active() {
223+ t.Fatal("dialog opened despite no swarm")
224+ }
225+ if iv.statusErr == "" {
226+ t.Fatal("expected a status error when swarm is nil")
227+ }
228+}
A internal/agent/swarm_agent.go +262 -0
@@ -0,0 +1,262 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "strings"
9+ "sync"
10+ "time"
11+
12+ "github.com/patriceckhart/zot/internal/agent/modes"
13+ "github.com/patriceckhart/zot/internal/core"
14+ "github.com/patriceckhart/zot/internal/swarm"
15+ "github.com/patriceckhart/zot/internal/provider"
16+)
17+
18+// runSwarmAgentMode is the daemon-mode entry point used by every
19+// swarm-spawned zot subprocess. It's intentionally close in shape to
20+// runJSONMode but with two key differences:
21+//
22+// - Lifetime: the process stays alive across many user turns. The
23+// initial positional task (if any) is the first turn; subsequent
24+// turns arrive through the inbox unix socket at args.SwarmAgent.
25+//
26+// - Output: every emitted JSON line is also mirrored verbatim into
27+// events.jsonl (see ZOT_SWARM_EVENT_LOG) so a separate zot
28+// process can /swarm open this agent and replay its full history
29+// even after the parent that spawned us is long gone.
30+//
31+// The runner in internal/swarm/runner.go is the only caller in
32+// production; tests use the stubchild binary under
33+// internal/swarm/testdata/cmd/stubchild instead of the real model
34+// loop.
35+func runSwarmAgentMode(ctx context.Context, args Args, version string) error {
36+ if args.SwarmAgent == "" {
37+ return fmt.Errorf("--swarm-agent requires a socket path")
38+ }
39+
40+ r, err := Resolve(args, true)
41+ if err != nil {
42+ return err
43+ }
44+ extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
45+ defer stopExt()
46+
47+ ag := r.NewAgent()
48+ wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
49+ sess, _ := openOrCreateSession(args, r, ag, version)
50+ defer sess.Close()
51+
52+ // Open the inbox listener BEFORE emitting agent_ready so the
53+ // supervisor can dial through on the very first send. The
54+ // swarm supervisor's Inbox.SendInput retries dialing for a
55+ // short window, but emitting ready first and then listening
56+ // would still race in tight loops.
57+ ln, err := swarm.Listen(args.SwarmAgent)
58+ if err != nil {
59+ return fmt.Errorf("swarm-agent listen: %w", err)
60+ }
61+ defer ln.Close()
62+
63+ // Event log is owned by the supervisor's runner via stdout, but
64+ // the daemon also writes a redundant copy here when the runner's
65+ // pipe is closed (e.g. parent zot exited but the agent is still
66+ // running headless). The env var is set by the runner; if it's
67+ // empty we silently skip the second mirror.
68+ var logMirror *swarm.EventLog
69+ if path := os.Getenv("ZOT_SWARM_EVENT_LOG"); path != "" {
70+ logMirror, _ = swarm.OpenEventLog(path)
71+ }
72+ if logMirror != nil {
73+ defer logMirror.Close()
74+ }
75+
76+ em := newSwarmEmitter(os.Stdout, logMirror)
77+
78+ em.emit("agent_ready", map[string]any{
79+ "version": version,
80+ "cwd": r.CWD,
81+ "model": r.Model,
82+ })
83+
84+ // turnCtx is the per-turn context. We keep a per-process
85+ // cancel so the "cancel" inbox message can interrupt an
86+ // in-flight turn without tearing down the whole daemon.
87+ var (
88+ mu sync.Mutex
89+ turnCtx context.Context = ctx
90+ cancelFn context.CancelFunc
91+ busyTurn bool
92+ turnNo int
93+ shutdown = make(chan struct{})
94+ )
95+
96+ runOne := func(prompt string) {
97+ mu.Lock()
98+ if busyTurn {
99+ // Drop concurrent turns rather than queuing. The
100+ // supervisor protocol assumes one outstanding turn per
101+ // agent; if a user really wants to interrupt and start
102+ // another, they should send "cancel" first.
103+ mu.Unlock()
104+ em.emit("error", map[string]any{"message": "agent busy; send 'cancel' first"})
105+ return
106+ }
107+ busyTurn = true
108+ turnNo++
109+ step := turnNo
110+ turnCtx, cancelFn = context.WithCancel(ctx)
111+ c := turnCtx
112+ mu.Unlock()
113+
114+ em.emit("turn_start", map[string]any{"step": step})
115+
116+ sink := func(ev core.AgentEvent) {
117+ em.emit(ev.Type(), modes.EventToJSON(ev))
118+ }
119+
120+ start := len(ag.Messages())
121+ err := ag.Prompt(c, prompt, nil, sink)
122+ WriteNewTranscript(ag, sess, start)
123+
124+ em.emit("turn_end", map[string]any{
125+ "step": step,
126+ "error": errString(err),
127+ })
128+
129+ mu.Lock()
130+ busyTurn = false
131+ cancelFn = nil
132+ mu.Unlock()
133+ }
134+
135+ // Initial task: run before processing the inbox so the agent
136+ // "starts working" the moment it boots, matching what users
137+ // expect from `/swarm new <task>`.
138+ if args.Prompt != "" {
139+ go runOne(args.Prompt)
140+ }
141+
142+ // Inbox loop: one supervisor message at a time. We don't spawn
143+ // a goroutine per turn because runOne already serialises them
144+ // via the busyTurn flag; doing the dispatch on the main
145+ // goroutine keeps the daemon's lifecycle easy to follow.
146+ for {
147+ select {
148+ case <-ctx.Done():
149+ em.emit("agent_stopped", map[string]any{"reason": "cancelled"})
150+ return ctx.Err()
151+ case <-shutdown:
152+ em.emit("agent_stopped", map[string]any{"reason": "shutdown"})
153+ return nil
154+ case msg, ok := <-ln.Lines():
155+ if !ok {
156+ em.emit("agent_stopped", map[string]any{"reason": "inbox-closed"})
157+ return nil
158+ }
159+ switch {
160+ case msg == "shutdown":
161+ close(shutdown)
162+ case msg == "cancel":
163+ mu.Lock()
164+ if cancelFn != nil {
165+ cancelFn()
166+ }
167+ mu.Unlock()
168+ case strings.HasPrefix(msg, "user "):
169+ prompt := strings.TrimPrefix(msg, "user ")
170+ go runOne(prompt)
171+ default:
172+ em.emit("error", map[string]any{
173+ "message": "unknown supervisor message: " + truncateForLog(msg, 200),
174+ })
175+ }
176+ }
177+ }
178+}
179+
180+// swarmEmitter serialises events to stdout and (optionally) to a
181+// durable log file. Concurrent goroutines call emit so we have to
182+// hold a mutex around the encoder.
183+type swarmEmitter struct {
184+ mu sync.Mutex
185+ w *os.File
186+ mirror *swarm.EventLog
187+
188+ // orphan flips true the first time a stdout write fails (broken
189+ // pipe — the supervisor died). Until then the mirror stays
190+ // dormant: the supervisor is the canonical writer to events.jsonl
191+ // (it parses our stdout and Append()s each event itself). Writing
192+ // from both sides used to land every event in the log twice,
193+ // which showed up as a fully-duplicated transcript the next time
194+ // the agent was reloaded — the exact "why is everything doubled"
195+ // bug. Once orphaned the mirror takes over so the events still
196+ // land on disk for the next reload.
197+ orphan bool
198+}
199+
200+func newSwarmEmitter(w *os.File, mirror *swarm.EventLog) *swarmEmitter {
201+ return &swarmEmitter{w: w, mirror: mirror}
202+}
203+
204+func (e *swarmEmitter) emit(typ string, data map[string]any) {
205+ if data == nil {
206+ data = map[string]any{}
207+ }
208+ data["type"] = typ
209+ data["time"] = time.Now().Format(time.RFC3339Nano)
210+
211+ e.mu.Lock()
212+ defer e.mu.Unlock()
213+
214+ if !e.orphan {
215+ // Encode to a buffer first so we can detect the stdout write
216+ // error — json.Encoder swallows partial writes silently, but
217+ // a direct Write on *os.File returns broken-pipe immediately.
218+ line, err := json.Marshal(data)
219+ if err == nil {
220+ line = append(line, '\n')
221+ if _, werr := e.w.Write(line); werr != nil {
222+ // Supervisor's stdout pipe is gone (parent zot exited
223+ // but we kept running). Switch to mirror-only mode so
224+ // subsequent events still get persisted; also retro-
225+ // actively log this very event to the mirror so it
226+ // doesn't get lost in the handoff.
227+ e.orphan = true
228+ }
229+ }
230+ }
231+
232+ if e.orphan && e.mirror != nil {
233+ // Drop "type" + "time" out of data into Event fields so the
234+ // mirror file matches what the supervisor would have written.
235+ flat := map[string]any{}
236+ for k, v := range data {
237+ if k == "type" || k == "time" {
238+ continue
239+ }
240+ flat[k] = v
241+ }
242+ _ = e.mirror.Append(swarm.NewEvent(typ, flat))
243+ }
244+}
245+
246+func errString(err error) string {
247+ if err == nil {
248+ return ""
249+ }
250+ return err.Error()
251+}
252+
253+func truncateForLog(s string, n int) string {
254+ if len(s) <= n {
255+ return s
256+ }
257+ return s[:n-1] + "…"
258+}
259+
260+// _ keeps the provider import used; provider types may surface
261+// through ag.OnEvent / modes.EventToJSON in future iterations.
262+var _ provider.Content = provider.TextBlock{}
A internal/agent/swarm_agent_test.go +119 -0
@@ -0,0 +1,119 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/patriceckhart/zot/internal/swarm"
12+)
13+
14+// TestSwarmEmitterMirrorDormantUntilStdoutBreaks regresses the
15+// "everything is doubled after reopening a swarm agent" bug.
16+//
17+// Symptom: events.jsonl held two copies of every event because the
18+// child mirrored each event to disk AND the supervisor parsed the
19+// child's stdout and appended each event to disk too. On next zot
20+// launch the replay produced two transcript lines per real one.
21+//
22+// Fix invariant: while stdout writes succeed (i.e. the supervisor is
23+// alive on the other end of the pipe), the child's mirror writes
24+// NOTHING. Only when a stdout write fails (broken pipe → orphan)
25+// does the mirror take over so events still get persisted.
26+func TestSwarmEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) {
27+ // Real *os.File for the emitter's stdout-equivalent so the
28+ // emitter's write() path (which expects *os.File) actually runs.
29+ stdoutPath := filepath.Join(t.TempDir(), "stdout.fifo")
30+ stdoutFile, err := os.Create(stdoutPath)
31+ if err != nil {
32+ t.Fatalf("create stdout file: %v", err)
33+ }
34+
35+ // Mirror writes go to a separate events.jsonl that we can read
36+ // at the end to assert how many events the mirror emitted.
37+ mirrorPath := filepath.Join(t.TempDir(), "events.jsonl")
38+ mirror, err := swarm.OpenEventLog(mirrorPath)
39+ if err != nil {
40+ t.Fatalf("open mirror: %v", err)
41+ }
42+
43+ em := newSwarmEmitter(stdoutFile, mirror)
44+
45+ // Healthy stdout: emit three events. Mirror must stay empty.
46+ em.emit("turn_start", map[string]any{"step": 1})
47+ em.emit("assistant_message", map[string]any{"text": "hi"})
48+ em.emit("turn_end", map[string]any{"step": 1})
49+
50+ got, err := swarm.ReadEventLog(mirrorPath)
51+ if err != nil {
52+ t.Fatalf("read mirror: %v", err)
53+ }
54+ if len(got) != 0 {
55+ t.Fatalf("mirror wrote %d events while supervisor was alive; want 0 (every event would otherwise double on the next reload)\n%+v",
56+ len(got), got)
57+ }
58+
59+ // Simulate supervisor death: close stdout so the next Write
60+ // returns EBADF / broken pipe. The emitter must flip into
61+ // orphan mode and start writing through the mirror.
62+ if err := stdoutFile.Close(); err != nil {
63+ t.Fatalf("close stdout: %v", err)
64+ }
65+
66+ em.emit("assistant_message", map[string]any{"text": "after orphan"})
67+ em.emit("turn_end", map[string]any{"step": 2})
68+
69+ got, err = swarm.ReadEventLog(mirrorPath)
70+ if err != nil {
71+ t.Fatalf("read mirror post-orphan: %v", err)
72+ }
73+ if len(got) < 2 {
74+ t.Fatalf("mirror failed to take over after stdout died: got %d events", len(got))
75+ }
76+ if got[len(got)-1].Type != "turn_end" {
77+ t.Errorf("last mirrored event type = %q; want turn_end", got[len(got)-1].Type)
78+ }
79+}
80+
81+// TestSwarmEmitterStdoutShapeMatchesSupervisorParser pins the
82+// wire-format contract: each emitted event lands on stdout as one
83+// JSON object per line with type+time at top level alongside the
84+// data fields. The supervisor's runner parses this exact shape.
85+func TestSwarmEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
86+ // Pipe so we can read what the emitter wrote.
87+ r, w, err := os.Pipe()
88+ if err != nil {
89+ t.Fatalf("pipe: %v", err)
90+ }
91+ defer r.Close()
92+
93+ em := newSwarmEmitter(w, nil)
94+ em.emit("turn_start", map[string]any{"step": 1})
95+ _ = w.Close()
96+
97+ body, err := io.ReadAll(r)
98+ if err != nil {
99+ t.Fatalf("read pipe: %v", err)
100+ }
101+ // One trailing newline => one event line.
102+ lines := bytes.Split(bytes.TrimRight(body, "\n"), []byte("\n"))
103+ if len(lines) != 1 {
104+ t.Fatalf("expected 1 event line, got %d: %q", len(lines), body)
105+ }
106+ var flat map[string]any
107+ if err := json.Unmarshal(lines[0], &flat); err != nil {
108+ t.Fatalf("not valid json: %v\n%s", err, lines[0])
109+ }
110+ if flat["type"] != "turn_start" {
111+ t.Errorf("type field missing or wrong: %v", flat["type"])
112+ }
113+ if _, ok := flat["time"].(string); !ok {
114+ t.Errorf("time field missing: %v", flat["time"])
115+ }
116+ if flat["step"] != float64(1) {
117+ t.Errorf("step field missing or wrong: %v", flat["step"])
118+ }
119+}
M internal/core/core_test.go +48 -0
@@ -46,6 +46,54 @@ func TestSessionRoundTrip(t *testing.T) {
46 }46 }
47 }47 }
48 48
49+// TestNewSessionAtPathCreatesAtExplicitPath proves the swarm child's
50+// session-persistence fallback works: NewSessionAtPath creates the
51+// file (and parent dirs) at the exact path the caller chose,
52+// independent of SessionsDir(root, cwd). Without this helper, the
53+// swarm child's --session <path> with a non-existent path would
54+// fall through to NewSession and write the session under a
55+// different (autogenerated) name, leaving the supervisor-chosen
56+// path empty.
57+func TestNewSessionAtPathCreatesAtExplicitPath(t *testing.T) {
58+ dir := t.TempDir()
59+ want := dir + "/nested/sub/session.json"
60+
61+ s, err := NewSessionAtPath(want, "/tmp/proj", "anthropic", "claude", "test")
62+ if err != nil {
63+ t.Fatalf("NewSessionAtPath: %v", err)
64+ }
65+ if s.Path != want {
66+ t.Errorf("Path = %q; want %q", s.Path, want)
67+ }
68+ if err := s.AppendMessage(provider.Message{
69+ Role: provider.RoleUser,
70+ Content: []provider.Content{provider.TextBlock{Text: "hi"}},
71+ Time: time.Now().UTC(),
72+ }); err != nil {
73+ t.Fatal(err)
74+ }
75+ if err := s.Close(); err != nil {
76+ t.Fatal(err)
77+ }
78+
79+ // Reopen at the same path and the message must still be there.
80+ reopened, msgs, err := OpenSession(want)
81+ if err != nil {
82+ t.Fatalf("OpenSession at fixed path: %v", err)
83+ }
84+ t.Cleanup(func() { _ = reopened.Close() })
85+ if len(msgs) != 1 {
86+ t.Fatalf("reopen got %d msgs; want 1", len(msgs))
87+ }
88+
89+ // A second call to NewSessionAtPath at the same path must fail
90+ // (O_EXCL): callers should use OpenSession to reattach to an
91+ // existing file.
92+ if _, err := NewSessionAtPath(want, "/tmp/proj", "anthropic", "claude", "test"); err == nil {
93+ t.Error("NewSessionAtPath on existing path returned nil; want error")
94+ }
95+}
96+
49 func TestCostAdd(t *testing.T) {97 func TestCostAdd(t *testing.T) {
50 var c CostTracker98 var c CostTracker
51 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, CostUSD: 0.01})99 c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, CostUSD: 0.01})
M internal/core/session.go +22 -1
@@ -85,7 +85,8 @@ func SessionsDir(root, cwd string) string {
85 return filepath.Join(root, "sessions", short)85 return filepath.Join(root, "sessions", short)
86 }86 }
87 87
88-// NewSession creates and opens a new session file.88+// NewSession creates and opens a new session file under
89+// SessionsDir(root, cwd) with an autogenerated, time-stamped name.
89 func NewSession(root, cwd, providerName, model, version string) (*Session, error) {90 func NewSession(root, cwd, providerName, model, version string) (*Session, error) {
90 dir := SessionsDir(root, cwd)91 dir := SessionsDir(root, cwd)
91 if err := os.MkdirAll(dir, 0o755); err != nil {92 if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -94,6 +95,26 @@ func NewSession(root, cwd, providerName, model, version string) (*Session, error
94 id := uuid.NewString()95 id := uuid.NewString()
95 name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), id[:8])96 name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), id[:8])
96 p := filepath.Join(dir, name)97 p := filepath.Join(dir, name)
98+ return newSessionAt(p, cwd, providerName, model, version)
99+}
100+
101+// NewSessionAtPath creates a session at an explicit file path. Used
102+// by callers (notably the swarm-agent child) that need the session
103+// file to live at a path chosen by their parent rather than under
104+// SessionsDir. Returns an error if the file already exists — use
105+// OpenSession for that case.
106+func NewSessionAtPath(path, cwd, providerName, model, version string) (*Session, error) {
107+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
108+ return nil, err
109+ }
110+ return newSessionAt(path, cwd, providerName, model, version)
111+}
112+
113+// newSessionAt is the shared implementation. Both NewSession and
114+// NewSessionAtPath funnel through here so the meta-line layout,
115+// freshFile bookkeeping, and id format stay identical.
116+func newSessionAt(p, cwd, providerName, model, version string) (*Session, error) {
117+ id := uuid.NewString()
97 f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)118 f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
98 if err != nil {119 if err != nil {
99 return nil, err120 return nil, err
A internal/swarm/agent.go +188 -0
@@ -0,0 +1,188 @@
1+package swarm
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+ "time"
9+)
10+
11+// Agent is one supervised task. Public fields are immutable after
12+// Spawn; mutable state (status, activity, transcript) lives behind
13+// the embedded mutex.
14+type Agent struct {
15+ ID string
16+ Task string
17+ Branch string
18+ Dir string
19+ Started time.Time
20+
21+ // Model and Provider, when non-empty, override the child
22+ // subprocess's model resolution. Empty means "inherit whatever
23+ // the child resolves on its own from config / env / flags" —
24+ // the historical behaviour. Persisted in meta.json so Resume
25+ // keeps using the same model across zot restarts.
26+ Model string
27+ Provider string
28+
29+ // SessionID, when non-empty, scopes the agent to a particular
30+ // host zot session: the dashboard only surfaces agents whose
31+ // SessionID matches the active session. Empty means "unscoped"
32+ // (legacy meta files from before the field existed, or agents
33+ // spawned without a session context such as in tests). Set at
34+ // Spawn time from Swarm.activeSession and persisted in
35+ // meta.json so the scope survives restarts.
36+ SessionID string
37+
38+ // Resuming is true when this Agent struct was built by Resume
39+ // rather than Spawn. The runner consults it to decide whether
40+ // to pass the original Task as a positional argv to the child:
41+ // on first Spawn we want the child to run the task immediately;
42+ // on Resume the task was already run last time and replaying it
43+ // would produce a second turn that collides ("agent busy; send
44+ // 'cancel' first") with whatever the user types next. Not
45+ // persisted — every Resume sets it explicitly.
46+ Resuming bool
47+
48+ // InboxPath is the unix socket the child agent listens on for
49+ // follow-up prompts and control messages. The supervisor opens
50+ // an Inbox at this path; the child opens a Listener.
51+ InboxPath string
52+
53+ // EventLogPath is the durable JSONL event log for this agent.
54+ // The runner appends every well-formed event from the child
55+ // (plus lifecycle events of its own) here. /swarm open in any
56+ // zot process reads from this file to replay the full history.
57+ EventLogPath string
58+
59+ // SessionPath is the child's persistent session file. Surfaced
60+ // so the dashboard / /swarm open can resume the agent later.
61+ SessionPath string
62+
63+ // inbox is the supervisor-side socket handle. Populated by
64+ // Swarm.Spawn so SendInput can route messages without each
65+ // caller redialing.
66+ inbox *Inbox
67+
68+ mu sync.Mutex
69+ status Status
70+ activity string
71+ transcript []string
72+ finished time.Time
73+ lastErr error
74+
75+ ctx context.Context
76+ cancel context.CancelFunc
77+ runner Runner
78+
79+ // done closes when the run goroutine finalises the agent's
80+ // status (done / failed / killed). Wait blocks on this so
81+ // callers don't have to poll.
82+ done chan struct{}
83+}
84+
85+// Inbox exposes the supervisor-side socket handle. Returns nil for
86+// agents without inboxes (e.g. tests using a custom Runner that
87+// doesn't speak the daemon protocol).
88+func (a *Agent) Inbox() *Inbox { return a.inbox }
89+
90+// Status returns the current high-level status. Cheap; safe from any goroutine.
91+func (a *Agent) Status() Status {
92+ a.mu.Lock()
93+ defer a.mu.Unlock()
94+ return a.status
95+}
96+
97+// Activity returns the current one-line activity string.
98+func (a *Agent) Activity() string {
99+ a.mu.Lock()
100+ defer a.mu.Unlock()
101+ return a.activity
102+}
103+
104+// Transcript returns a copy of the running transcript.
105+func (a *Agent) Transcript() []string {
106+ a.mu.Lock()
107+ defer a.mu.Unlock()
108+ out := make([]string, len(a.transcript))
109+ copy(out, a.transcript)
110+ return out
111+}
112+
113+// Err returns the runner's terminal error, if any.
114+func (a *Agent) Err() error {
115+ a.mu.Lock()
116+ defer a.mu.Unlock()
117+ return a.lastErr
118+}
119+
120+// Wait blocks until the agent reaches a terminal state. Used by tests
121+// and by /swarm wait <id>.
122+func (a *Agent) Wait() { <-a.done }
123+
124+func (a *Agent) setStatus(s Status) {
125+ a.mu.Lock()
126+ a.status = s
127+ a.mu.Unlock()
128+}
129+
130+func (a *Agent) setActivity(msg string) {
131+ a.mu.Lock()
132+ a.activity = strings.TrimSpace(msg)
133+ a.mu.Unlock()
134+}
135+
136+func (a *Agent) appendTranscript(chunk string) {
137+ chunk = strings.TrimRight(chunk, "\n")
138+ if chunk == "" {
139+ return
140+ }
141+ a.mu.Lock()
142+ for _, line := range strings.Split(chunk, "\n") {
143+ a.transcript = append(a.transcript, line)
144+ }
145+ // Bound the transcript so dashboards don't hold gigabytes for
146+ // long-running agents. 2000 lines is plenty for inspection;
147+ // the durable record lives in the agent's session file.
148+ const cap = 2000
149+ if len(a.transcript) > cap {
150+ a.transcript = a.transcript[len(a.transcript)-cap:]
151+ }
152+ a.mu.Unlock()
153+}
154+
155+// newAgentID returns a short, mostly-collision-free identifier of the
156+// form "<slug>-<nano>". The slug is derived from the task text so
157+// dashboards stay readable; the nano suffix guarantees uniqueness
158+// even when two agents are spawned in the same millisecond.
159+func newAgentID(task string, now time.Time) string {
160+ slug := taskSlug(task)
161+ return fmt.Sprintf("%s-%d", slug, now.UnixNano()%1_000_000)
162+}
163+
164+func taskSlug(task string) string {
165+ task = strings.ToLower(task)
166+ var b strings.Builder
167+ dash := false
168+ for _, r := range task {
169+ switch {
170+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
171+ b.WriteRune(r)
172+ dash = false
173+ default:
174+ if !dash && b.Len() > 0 {
175+ b.WriteByte('-')
176+ dash = true
177+ }
178+ }
179+ if b.Len() >= 24 {
180+ break
181+ }
182+ }
183+ out := strings.Trim(b.String(), "-")
184+ if out == "" {
185+ out = "agent"
186+ }
187+ return out
188+}
A internal/swarm/event.go +305 -0
@@ -0,0 +1,305 @@
1+package swarm
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "sync"
12+ "time"
13+)
14+
15+// Event is one structured datum in an agent's durable event log.
16+//
17+// The shape is intentionally a thin map+timestamp wrapper rather
18+// than a strict struct so the swarm package doesn't have to track
19+// every field added to core.AgentEvent. The child writes
20+// `modes.EventToJSON(ev)` plus a few swarm-internal types
21+// (lifecycle, user_input echo, error). The supervisor doesn't
22+// interpret most fields; it just keeps them for replay and lifts
23+// a few well-known ones into AgentSnapshot (Activity, Status,
24+// Tail) for the dashboard.
25+//
26+// The on-disk file (events.jsonl) is append-only, one event per
27+// line, newline-terminated JSON. Reading is forward-only; readers
28+// stat the file size and read from their last offset on every
29+// poll.
30+type Event struct {
31+ Time time.Time `json:"time"`
32+ Type string `json:"type"`
33+ Data map[string]any `json:"-"`
34+ Raw map[string]any `json:"-"` // includes type+time+data for replay
35+}
36+
37+// MarshalJSON flattens Data into the top-level object so consumers
38+// see {type, time, ...fields} rather than {type, time, data:{...}}.
39+func (e Event) MarshalJSON() ([]byte, error) {
40+ out := make(map[string]any, len(e.Data)+2)
41+ for k, v := range e.Data {
42+ out[k] = v
43+ }
44+ out["type"] = e.Type
45+ out["time"] = e.Time
46+ return json.Marshal(out)
47+}
48+
49+// UnmarshalJSON accepts a flat object with at least type+time.
50+// All other fields land in both Data and Raw.
51+func (e *Event) UnmarshalJSON(b []byte) error {
52+ m := map[string]any{}
53+ if err := json.Unmarshal(b, &m); err != nil {
54+ return err
55+ }
56+ e.Raw = m
57+ e.Data = map[string]any{}
58+ for k, v := range m {
59+ if k == "type" || k == "time" {
60+ continue
61+ }
62+ e.Data[k] = v
63+ }
64+ if t, ok := m["type"].(string); ok {
65+ e.Type = t
66+ } else {
67+ return errors.New("swarm event: missing type")
68+ }
69+ if ts, ok := m["time"].(string); ok {
70+ parsed, err := time.Parse(time.RFC3339Nano, ts)
71+ if err == nil {
72+ e.Time = parsed
73+ }
74+ }
75+ return nil
76+}
77+
78+// EventLog is an append-only writer for events.jsonl. Safe for
79+// concurrent writers (the child only has one writer, but tests
80+// occasionally fan out).
81+type EventLog struct {
82+ path string
83+ mu sync.Mutex
84+ f *os.File
85+}
86+
87+// OpenEventLog opens (or creates) the events.jsonl file at path.
88+// Parent directories are created as needed.
89+func OpenEventLog(path string) (*EventLog, error) {
90+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
91+ return nil, fmt.Errorf("event log dir: %w", err)
92+ }
93+ f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
94+ if err != nil {
95+ return nil, fmt.Errorf("event log open: %w", err)
96+ }
97+ return &EventLog{path: path, f: f}, nil
98+}
99+
100+// Path returns the absolute log path.
101+func (l *EventLog) Path() string { return l.path }
102+
103+// Append writes one event. The encoding is `<json>\n`. Concurrent
104+// callers are serialised; small enough events never need partial
105+// writes since unix guarantees atomicity for writes ≤ PIPE_BUF on
106+// regular files, and we hold a per-process mutex on top.
107+func (l *EventLog) Append(ev Event) error {
108+ l.mu.Lock()
109+ defer l.mu.Unlock()
110+ if ev.Time.IsZero() {
111+ ev.Time = time.Now()
112+ }
113+ b, err := json.Marshal(ev)
114+ if err != nil {
115+ return err
116+ }
117+ b = append(b, '\n')
118+ _, err = l.f.Write(b)
119+ return err
120+}
121+
122+// Close flushes and closes the underlying file.
123+func (l *EventLog) Close() error {
124+ l.mu.Lock()
125+ defer l.mu.Unlock()
126+ if l.f == nil {
127+ return nil
128+ }
129+ err := l.f.Close()
130+ l.f = nil
131+ return err
132+}
133+
134+// NewEvent is a convenience constructor that fills the timestamp.
135+func NewEvent(typ string, data map[string]any) Event {
136+ if data == nil {
137+ data = map[string]any{}
138+ }
139+ return Event{Time: time.Now(), Type: typ, Data: data}
140+}
141+
142+// ReadEventLog parses every event currently in the file. Used by
143+// the dashboard on first open and by tests; live tailing uses
144+// FollowEventLog below.
145+func ReadEventLog(path string) ([]Event, error) {
146+ f, err := os.Open(path)
147+ if err != nil {
148+ if os.IsNotExist(err) {
149+ return nil, nil
150+ }
151+ return nil, err
152+ }
153+ defer f.Close()
154+ return readEvents(f)
155+}
156+
157+func readEvents(r io.Reader) ([]Event, error) {
158+ br := bufio.NewReader(r)
159+ var out []Event
160+ for {
161+ line, err := br.ReadBytes('\n')
162+ if len(line) > 0 {
163+ var ev Event
164+ if jerr := json.Unmarshal(line, &ev); jerr == nil {
165+ if !isLikelyDoubleWrite(ev, out) {
166+ out = append(out, ev)
167+ }
168+ }
169+ // Malformed lines are skipped silently; the dashboard
170+ // renders only well-formed events and the child is the
171+ // only writer.
172+ }
173+ if err != nil {
174+ if errors.Is(err, io.EOF) {
175+ return out, nil
176+ }
177+ return out, err
178+ }
179+ }
180+}
181+
182+// isLikelyDoubleWrite reports whether ev is a back-to-back duplicate
183+// of the most recent event in `tail` — same type and identical Data
184+// payload, with timestamps within a small window (the supervisor and
185+// the child's mirror used to BOTH write each event to disk, landing
186+// the same content twice in quick succession in events.jsonl). The
187+// historical behaviour was fixed at write time (the child's mirror
188+// is now dormant unless the supervisor is gone), but on-disk files
189+// from before the fix are still polluted, so we dedupe defensively
190+// at read time too.
191+//
192+// We deliberately bound by time (250ms) so two genuinely identical
193+// adjacent events that happen seconds apart — e.g. an agent that
194+// runs the same tool twice in a row — still both render.
195+func isLikelyDoubleWrite(ev Event, tail []Event) bool {
196+ if len(tail) == 0 {
197+ return false
198+ }
199+ prev := tail[len(tail)-1]
200+ if prev.Type != ev.Type {
201+ return false
202+ }
203+ if !ev.Time.IsZero() && !prev.Time.IsZero() {
204+ dt := ev.Time.Sub(prev.Time)
205+ if dt < 0 {
206+ dt = -dt
207+ }
208+ if dt > 250*time.Millisecond {
209+ return false
210+ }
211+ }
212+ return sameEventData(prev.Data, ev.Data)
213+}
214+
215+// sameEventData deep-compares two event payloads. Cheap because the
216+// payloads are small map[string]any trees built from JSON, and only
217+// called for adjacent same-type pairs.
218+func sameEventData(a, b map[string]any) bool {
219+ if len(a) != len(b) {
220+ return false
221+ }
222+ ab, _ := json.Marshal(a)
223+ bb, _ := json.Marshal(b)
224+ return string(ab) == string(bb)
225+}
226+
227+// EventFollower polls an events.jsonl file and emits new events as
228+// they're appended. The supervisor uses one per agent so dashboard
229+// snapshots stay current without reparsing the whole file on every
230+// frame. Close stops the goroutine; the channel returned by Events
231+// is closed when Close is called or the file is removed.
232+type EventFollower struct {
233+ path string
234+ interval time.Duration
235+ out chan Event
236+ done chan struct{}
237+ once sync.Once
238+}
239+
240+// FollowEventLog starts polling path every interval. interval ≤ 0
241+// defaults to 50ms which is invisible to the user and cheap on
242+// disk (one stat + one short read per agent per tick).
243+func FollowEventLog(path string, interval time.Duration) *EventFollower {
244+ if interval <= 0 {
245+ interval = 50 * time.Millisecond
246+ }
247+ f := &EventFollower{
248+ path: path,
249+ interval: interval,
250+ out: make(chan Event, 256),
251+ done: make(chan struct{}),
252+ }
253+ go f.loop()
254+ return f
255+}
256+
257+// Events returns the receive end of the event stream.
258+func (f *EventFollower) Events() <-chan Event { return f.out }
259+
260+// Close stops polling and closes the events channel.
261+func (f *EventFollower) Close() {
262+ f.once.Do(func() {
263+ close(f.done)
264+ })
265+}
266+
267+func (f *EventFollower) loop() {
268+ defer close(f.out)
269+ var offset int64
270+ tick := time.NewTicker(f.interval)
271+ defer tick.Stop()
272+ for {
273+ select {
274+ case <-f.done:
275+ return
276+ case <-tick.C:
277+ }
278+ fi, err := os.Stat(f.path)
279+ if err != nil {
280+ continue
281+ }
282+ if fi.Size() <= offset {
283+ continue
284+ }
285+ fh, err := os.Open(f.path)
286+ if err != nil {
287+ continue
288+ }
289+ if _, err := fh.Seek(offset, io.SeekStart); err != nil {
290+ _ = fh.Close()
291+ continue
292+ }
293+ evs, _ := readEvents(fh)
294+ newOffset, _ := fh.Seek(0, io.SeekCurrent)
295+ _ = fh.Close()
296+ offset = newOffset
297+ for _, ev := range evs {
298+ select {
299+ case f.out <- ev:
300+ case <-f.done:
301+ return
302+ }
303+ }
304+ }
305+}
A internal/swarm/event_test.go +227 -0
@@ -0,0 +1,227 @@
1+package swarm
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+ "time"
7+)
8+
9+// TestEventLogAppendAndRead is the simplest possible invariant:
10+// what we write goes back through ReadEventLog exactly, including
11+// the flat field shape and the timestamp.
12+func TestEventLogAppendAndRead(t *testing.T) {
13+ path := filepath.Join(t.TempDir(), "events.jsonl")
14+ log, err := OpenEventLog(path)
15+ if err != nil {
16+ t.Fatalf("open: %v", err)
17+ }
18+ want := []Event{
19+ NewEvent("turn_start", map[string]any{"step": 0}),
20+ NewEvent("assistant_message", map[string]any{
21+ "content": []any{map[string]any{"type": "text", "text": "hi"}},
22+ }),
23+ NewEvent("turn_end", map[string]any{"stop": "end"}),
24+ }
25+ for _, ev := range want {
26+ if err := log.Append(ev); err != nil {
27+ t.Fatalf("append: %v", err)
28+ }
29+ }
30+ _ = log.Close()
31+
32+ got, err := ReadEventLog(path)
33+ if err != nil {
34+ t.Fatalf("read: %v", err)
35+ }
36+ if len(got) != len(want) {
37+ t.Fatalf("got %d events, want %d", len(got), len(want))
38+ }
39+ for i := range want {
40+ if got[i].Type != want[i].Type {
41+ t.Errorf("event %d type = %q; want %q", i, got[i].Type, want[i].Type)
42+ }
43+ }
44+}
45+
46+// TestReadEventLogIgnoresGarbage documents that one malformed line
47+// doesn't break the whole replay. The runner's responsibility is to
48+// only write well-formed events, but a partial crash could leave
49+// a half-written line; we don't want that to prevent the dashboard
50+// from showing the rest.
51+func TestReadEventLogIgnoresGarbage(t *testing.T) {
52+ path := filepath.Join(t.TempDir(), "events.jsonl")
53+ log, err := OpenEventLog(path)
54+ if err != nil {
55+ t.Fatalf("open: %v", err)
56+ }
57+ if err := log.Append(NewEvent("first", nil)); err != nil {
58+ t.Fatalf("append: %v", err)
59+ }
60+ // Sneak a garbage line in by writing directly.
61+ if _, err := log.f.WriteString("not json at all\n"); err != nil {
62+ t.Fatalf("raw write: %v", err)
63+ }
64+ if err := log.Append(NewEvent("second", nil)); err != nil {
65+ t.Fatalf("append: %v", err)
66+ }
67+ _ = log.Close()
68+
69+ got, err := ReadEventLog(path)
70+ if err != nil {
71+ t.Fatalf("read: %v", err)
72+ }
73+ if len(got) != 2 || got[0].Type != "first" || got[1].Type != "second" {
74+ t.Fatalf("garbage tripped parser: %+v", got)
75+ }
76+}
77+
78+// TestReadEventLogDeduplicatesDoubleWrites pins the read-time fix
79+// for already-polluted events.jsonl files. The historical bug: the
80+// supervisor parsed child stdout and called log.Append(ev), AND the
81+// child's mirror appended the same event. Two writes per event
82+// landed in the file in quick succession with near-identical
83+// timestamps, and the next zot launch replayed everything twice —
84+// the user saw every transcript line duplicated.
85+//
86+// The write-time fix makes the child's mirror dormant when the
87+// supervisor is alive, but old files are still polluted. ReadEventLog
88+// now folds back-to-back same-type identical-payload events whose
89+// timestamps are within ~250ms of each other.
90+func TestReadEventLogDeduplicatesDoubleWrites(t *testing.T) {
91+ path := filepath.Join(t.TempDir(), "events.jsonl")
92+ log, err := OpenEventLog(path)
93+ if err != nil {
94+ t.Fatalf("open: %v", err)
95+ }
96+
97+ // Two writes per logical event, mimicking the supervisor +
98+ // mirror double-write pattern. Timestamps differ by a few ms
99+ // like they would in the wild.
100+ now := time.Now()
101+ writePair := func(typ string, data map[string]any) {
102+ e1 := NewEvent(typ, data)
103+ e1.Time = now
104+ if err := log.Append(e1); err != nil {
105+ t.Fatalf("append: %v", err)
106+ }
107+ e2 := NewEvent(typ, data)
108+ e2.Time = now.Add(2 * time.Millisecond)
109+ if err := log.Append(e2); err != nil {
110+ t.Fatalf("append: %v", err)
111+ }
112+ now = now.Add(500 * time.Millisecond) // next event well outside the dedup window
113+ }
114+
115+ writePair("assistant_message", map[string]any{
116+ "content": []any{map[string]any{"type": "text", "text": "hi"}},
117+ })
118+ writePair("turn_end", map[string]any{"step": float64(1)})
119+ _ = log.Close()
120+
121+ got, err := ReadEventLog(path)
122+ if err != nil {
123+ t.Fatalf("read: %v", err)
124+ }
125+ if len(got) != 2 {
126+ t.Fatalf("expected double-writes to collapse to 2 events; got %d", len(got))
127+ }
128+ if got[0].Type != "assistant_message" || got[1].Type != "turn_end" {
129+ t.Errorf("unexpected types after dedup: %q, %q", got[0].Type, got[1].Type)
130+ }
131+}
132+
133+// TestReadEventLogKeepsLegitimateAdjacentEvents pins the inverse:
134+// two events of the same type with identical payloads but separated
135+// by more than the dedup window (250ms) must both survive. The
136+// agent legitimately can emit identical adjacent events (think: a
137+// retry running the same tool seconds apart).
138+func TestReadEventLogKeepsLegitimateAdjacentEvents(t *testing.T) {
139+ path := filepath.Join(t.TempDir(), "events.jsonl")
140+ log, err := OpenEventLog(path)
141+ if err != nil {
142+ t.Fatalf("open: %v", err)
143+ }
144+ now := time.Now()
145+ for i := 0; i < 3; i++ {
146+ e := NewEvent("tool_call", map[string]any{"name": "read"})
147+ e.Time = now.Add(time.Duration(i) * time.Second)
148+ if err := log.Append(e); err != nil {
149+ t.Fatalf("append: %v", err)
150+ }
151+ }
152+ _ = log.Close()
153+
154+ got, err := ReadEventLog(path)
155+ if err != nil {
156+ t.Fatalf("read: %v", err)
157+ }
158+ if len(got) != 3 {
159+ t.Errorf("dedup collapsed legitimately separated events; got %d, want 3", len(got))
160+ }
161+}
162+
163+// TestFollowerEmitsNewEvents follows a live file and ensures events
164+// appended after the follower starts make it to the channel.
165+func TestFollowerEmitsNewEvents(t *testing.T) {
166+ path := filepath.Join(t.TempDir(), "events.jsonl")
167+ // Pre-seed one event so the file exists. The follower's
168+ // contract is "emit events appended AFTER it starts", so this
169+ // first event is not expected on the channel.
170+ log, err := OpenEventLog(path)
171+ if err != nil {
172+ t.Fatalf("open: %v", err)
173+ }
174+ if err := log.Append(NewEvent("preexisting", nil)); err != nil {
175+ t.Fatalf("append: %v", err)
176+ }
177+
178+ fol := FollowEventLog(path, 10*time.Millisecond)
179+ defer fol.Close()
180+
181+ // Give the follower one tick to read the pre-existing event so
182+ // its offset advances past the first line. After that, any new
183+ // append should show up.
184+ time.Sleep(40 * time.Millisecond)
185+ drain(fol.Events())
186+
187+ for i := 0; i < 3; i++ {
188+ if err := log.Append(NewEvent("live", map[string]any{"i": i})); err != nil {
189+ t.Fatalf("append %d: %v", i, err)
190+ }
191+ }
192+
193+ got := waitFor(t, fol.Events(), 3, time.Second)
194+ if len(got) != 3 {
195+ t.Fatalf("got %d live events, want 3", len(got))
196+ }
197+ for i, ev := range got {
198+ if ev.Type != "live" {
199+ t.Errorf("event %d type = %q; want live", i, ev.Type)
200+ }
201+ }
202+}
203+
204+func drain(ch <-chan Event) {
205+ for {
206+ select {
207+ case <-ch:
208+ default:
209+ return
210+ }
211+ }
212+}
213+
214+func waitFor(t *testing.T, ch <-chan Event, n int, timeout time.Duration) []Event {
215+ t.Helper()
216+ var got []Event
217+ deadline := time.After(timeout)
218+ for len(got) < n {
219+ select {
220+ case ev := <-ch:
221+ got = append(got, ev)
222+ case <-deadline:
223+ return got
224+ }
225+ }
226+ return got
227+}
A internal/swarm/inbox.go +270 -0
@@ -0,0 +1,270 @@
1+package swarm
2+
3+import (
4+ "bufio"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "net"
9+ "os"
10+ "path/filepath"
11+ "sync"
12+ "syscall"
13+ "time"
14+)
15+
16+// Inbox is the supervisor-side handle on a per-agent unix socket
17+// that the parent zot uses to send follow-up prompts (and other
18+// control lines) to a running swarm agent.
19+//
20+// Protocol on the wire: one UTF-8 message per line, newline-
21+// terminated. Three message kinds:
22+//
23+// user <text>... — append <text> as the next user turn
24+// cancel — cancel the agent's in-flight turn
25+// shutdown — graceful exit; child will write a final
26+// EvTurnEnd-equivalent JSON event and quit
27+//
28+// The child listens on the same socket via Listener (below) and
29+// translates the messages into core.Agent.Prompt calls. The
30+// parent never reads from the socket — it's a one-way channel
31+// driven by the supervisor.
32+//
33+// Why a socket and not a FIFO: FIFOs are POSIX-only and have
34+// awkward blocking-open semantics. A unix socket gives us:
35+//
36+// - the ability to fail fast if no child is listening yet,
37+// so SendInput returns a useful error to the TUI;
38+// - clean shutdown semantics when the child closes the
39+// listener;
40+// - works under tests with a temp dir; and
41+// - portable enough; Windows support is a follow-up that can
42+// swap in a named pipe behind this same interface.
43+type Inbox struct {
44+ path string
45+
46+ mu sync.Mutex
47+ conn net.Conn // lazily dialed; one persistent connection per agent
48+}
49+
50+// NewInbox returns a handle that will dial the socket at path on
51+// the first SendInput call. The socket file is expected to be
52+// created by the child (Listener.Open below). This split lets the
53+// parent build the Inbox before the child has booted; sends that
54+// happen before the child is ready get a clear "not yet" error
55+// the caller can retry or surface.
56+func NewInbox(path string) *Inbox { return &Inbox{path: path} }
57+
58+// Path returns the absolute socket path. Used by the runner to
59+// pass the path to the child as a flag.
60+func (b *Inbox) Path() string { return b.path }
61+
62+// SendInput writes msg to the inbox as one line. msg must already
63+// include the leading kind ("user ", "cancel", "shutdown") — the
64+// caller chooses the shape so we don't proliferate methods. The
65+// socket is reused across calls; transient dial failures yield
66+// ErrNotReady so the TUI can retry or report "agent not listening
67+// yet" rather than crashing.
68+func (b *Inbox) SendInput(msg string) error {
69+ b.mu.Lock()
70+ defer b.mu.Unlock()
71+ if b.conn == nil {
72+ c, err := dialUnix(b.path, 200*time.Millisecond)
73+ if err != nil {
74+ return err
75+ }
76+ b.conn = c
77+ }
78+ if _, err := io.WriteString(b.conn, msg+"\n"); err != nil {
79+ // Drop the connection so the next call redials. The
80+ // previous error is more informative than the redial's
81+ // would be, so surface this one.
82+ _ = b.conn.Close()
83+ b.conn = nil
84+ return err
85+ }
86+ return nil
87+}
88+
89+// Close drops any persistent connection. Safe to call repeatedly.
90+// Does not unlink the socket file — that's the child's job
91+// (Listener.Close).
92+func (b *Inbox) Close() error {
93+ b.mu.Lock()
94+ defer b.mu.Unlock()
95+ if b.conn != nil {
96+ err := b.conn.Close()
97+ b.conn = nil
98+ return err
99+ }
100+ return nil
101+}
102+
103+// ErrNotReady is returned by SendInput when the child hasn't yet
104+// opened its listener. Callers can retry after a short backoff;
105+// the TUI surfaces it as "agent <id> not listening yet".
106+var ErrNotReady = errors.New("swarm: agent inbox not ready")
107+
108+// dialUnix wraps net.DialTimeout with a small retry window so the
109+// happy path "child just started, supervisor sends right away"
110+// works without forcing the caller to poll.
111+//
112+// Errors collapse onto ErrNotReady whenever the failure means "no
113+// one is listening": either the socket file doesn't exist (child
114+// hasn't booted) or it does exist but the dial was refused (the
115+// previous child exited and left a stale inode). Both are
116+// recoverable by Swarm.Resume, and both should surface as the same
117+// user-facing hint rather than as a path-leaking error string.
118+func dialUnix(path string, timeout time.Duration) (net.Conn, error) {
119+ deadline := time.Now().Add(timeout)
120+ var lastErr error
121+ for {
122+ c, err := net.DialTimeout("unix", path, 50*time.Millisecond)
123+ if err == nil {
124+ return c, nil
125+ }
126+ lastErr = err
127+ if time.Now().After(deadline) {
128+ break
129+ }
130+ time.Sleep(20 * time.Millisecond)
131+ }
132+ if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
133+ return nil, ErrNotReady
134+ }
135+ if isNoListenerErr(lastErr) {
136+ return nil, ErrNotReady
137+ }
138+ return nil, fmt.Errorf("swarm: dial %s: %w", path, lastErr)
139+}
140+
141+// isNoListenerErr reports whether err means "the socket file exists
142+// but no process is listening on it" — i.e. ECONNREFUSED on unix
143+// domain sockets. We pattern-match on errno via errors.Is rather
144+// than the error string so platform variants (linux vs darwin) all
145+// fold to the same case.
146+func isNoListenerErr(err error) bool {
147+ return errors.Is(err, syscall.ECONNREFUSED)
148+}
149+
150+// Listener is the child-side end of the inbox. The swarm-agent
151+// daemon mode constructs one with Listen, then iterates Lines
152+// to receive supervisor messages. Designed so a single goroutine
153+// in the child can drive prompting without juggling raw net code.
154+type Listener struct {
155+ path string
156+ ln net.Listener
157+ // active is the most recent accepted connection. The protocol
158+ // only expects one supervisor (the parent zot), so newer
159+ // connections preempt older ones — the previous parent
160+ // presumably crashed.
161+ mu sync.Mutex
162+ active net.Conn
163+ out chan string
164+ done chan struct{}
165+}
166+
167+// Listen creates the socket at path and starts accepting. The
168+// caller must call Close to remove the socket file on shutdown.
169+// Returns the listener even if a stale socket exists from a
170+// previous run: we unlink first, mirroring how most unix daemons
171+// behave.
172+func Listen(path string) (*Listener, error) {
173+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
174+ return nil, fmt.Errorf("inbox dir: %w", err)
175+ }
176+ // Best-effort cleanup of a stale socket. If the parent
177+ // process is still alive and using it, Listen will fail
178+ // below and the caller surfaces that as a real conflict.
179+ _ = os.Remove(path)
180+ ln, err := net.Listen("unix", path)
181+ if err != nil {
182+ return nil, fmt.Errorf("inbox listen: %w", err)
183+ }
184+ l := &Listener{
185+ path: path,
186+ ln: ln,
187+ out: make(chan string, 16),
188+ done: make(chan struct{}),
189+ }
190+ go l.acceptLoop()
191+ return l, nil
192+}
193+
194+// Lines returns a channel of newline-stripped messages received
195+// from the supervisor. The channel closes when Close is called.
196+func (l *Listener) Lines() <-chan string { return l.out }
197+
198+// Close stops accepting, drops the active connection, removes
199+// the socket file, and closes Lines. Idempotent.
200+func (l *Listener) Close() error {
201+ select {
202+ case <-l.done:
203+ return nil
204+ default:
205+ close(l.done)
206+ }
207+ l.mu.Lock()
208+ if l.active != nil {
209+ _ = l.active.Close()
210+ }
211+ l.mu.Unlock()
212+ _ = l.ln.Close()
213+ _ = os.Remove(l.path)
214+ return nil
215+}
216+
217+func (l *Listener) acceptLoop() {
218+ defer close(l.out)
219+ for {
220+ c, err := l.ln.Accept()
221+ if err != nil {
222+ select {
223+ case <-l.done:
224+ return
225+ default:
226+ // Accept can fail transiently (signal); back off briefly.
227+ time.Sleep(20 * time.Millisecond)
228+ continue
229+ }
230+ }
231+ l.mu.Lock()
232+ if l.active != nil {
233+ _ = l.active.Close()
234+ }
235+ l.active = c
236+ l.mu.Unlock()
237+ go l.readLoop(c)
238+ }
239+}
240+
241+func (l *Listener) readLoop(c net.Conn) {
242+ br := bufio.NewReader(c)
243+ for {
244+ line, err := br.ReadString('\n')
245+ if line != "" {
246+ select {
247+ case l.out <- trimRightNL(line):
248+ case <-l.done:
249+ _ = c.Close()
250+ return
251+ }
252+ }
253+ if err != nil {
254+ _ = c.Close()
255+ l.mu.Lock()
256+ if l.active == c {
257+ l.active = nil
258+ }
259+ l.mu.Unlock()
260+ return
261+ }
262+ }
263+}
264+
265+func trimRightNL(s string) string {
266+ for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
267+ s = s[:len(s)-1]
268+ }
269+ return s
270+}
A internal/swarm/inbox_test.go +232 -0
@@ -0,0 +1,232 @@
1+package swarm
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sync"
9+ "syscall"
10+ "testing"
11+ "time"
12+)
13+
14+// shortSocketDir returns a tempdir short enough that we can append
15+// "in.sock" and still fit under the unix-socket path cap. The Go
16+// runner's t.TempDir() lives under a long /var/folders/... path on
17+// macOS which blows the 104-byte limit by itself, so the inbox tests
18+// can't use it.
19+func shortSocketDir(t *testing.T) string {
20+ t.Helper()
21+ dir, err := os.MkdirTemp("/tmp", "zot-in-")
22+ if err != nil {
23+ t.Fatalf("mkdir: %v", err)
24+ }
25+ t.Cleanup(func() { os.RemoveAll(dir) })
26+ return dir
27+}
28+
29+// TestInboxRoundTrip is the happy path: child listens, supervisor
30+// dials and sends three lines, child receives them in order, and
31+// closing both sides is clean. Locks in the line-framed protocol.
32+func TestInboxRoundTrip(t *testing.T) {
33+ path := filepath.Join(shortSocketDir(t), "in.sock")
34+ ln, err := Listen(path)
35+ if err != nil {
36+ t.Fatalf("Listen: %v", err)
37+ }
38+ defer ln.Close()
39+
40+ got := collectFor(t, ln.Lines(), 3, time.Second)
41+ inbox := NewInbox(path)
42+ defer inbox.Close()
43+
44+ want := []string{"user hello", "user world", "cancel"}
45+ for _, msg := range want {
46+ if err := inbox.SendInput(msg); err != nil {
47+ t.Fatalf("SendInput(%q): %v", msg, err)
48+ }
49+ }
50+
51+ gotLines := <-got
52+ if len(gotLines) != len(want) {
53+ t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want), gotLines)
54+ }
55+ for i, w := range want {
56+ if gotLines[i] != w {
57+ t.Errorf("line %d = %q; want %q", i, gotLines[i], w)
58+ }
59+ }
60+}
61+
62+// TestInboxNotReady regression-tests the explicit "child hasn't
63+// opened the socket yet" case. The TUI needs an error it can show
64+// as "agent not listening yet" rather than a generic dial failure.
65+func TestInboxNotReady(t *testing.T) {
66+ path := filepath.Join(shortSocketDir(t), "in.sock")
67+ inbox := NewInbox(path)
68+ defer inbox.Close()
69+ err := inbox.SendInput("user hi")
70+ if err == nil {
71+ t.Fatal("SendInput on missing socket: want error")
72+ }
73+ if !errors.Is(err, ErrNotReady) {
74+ t.Fatalf("SendInput err = %v; want ErrNotReady", err)
75+ }
76+}
77+
78+// TestIsNoListenerErrMatchesECONNREFUSED documents the contract
79+// that the user's screenshot regression depends on: any dial error
80+// whose root cause is "the kernel refused the connect because nobody
81+// is listening" must fold into ErrNotReady at the SendInput layer,
82+// so the TUI shows "agent not accepting input (press R to resume)"
83+// instead of leaking the unix-socket path. We pattern-match via
84+// errors.Is on syscall.ECONNREFUSED rather than string-matching the
85+// error text so platform variants all hit the same branch.
86+func TestIsNoListenerErrMatchesECONNREFUSED(t *testing.T) {
87+ if !isNoListenerErr(syscall.ECONNREFUSED) {
88+ t.Error("isNoListenerErr(ECONNREFUSED) = false; want true")
89+ }
90+ // Wrapped via fmt.Errorf — common form of the dial error.
91+ if !isNoListenerErr(fmt.Errorf("dial unix /tmp/x: %w", syscall.ECONNREFUSED)) {
92+ t.Error("isNoListenerErr(wrapped) = false; want true")
93+ }
94+ if isNoListenerErr(syscall.ECONNRESET) {
95+ t.Error("isNoListenerErr(ECONNRESET) = true; want false (unrelated errno)")
96+ }
97+ if isNoListenerErr(nil) {
98+ t.Error("isNoListenerErr(nil) = true; want false")
99+ }
100+}
101+
102+// TestInboxNewSupervisorPreemptsOld documents that only one
103+// supervisor connection at a time is honoured. The swarm design
104+// assumes a single parent zot owns each agent; if a second
105+// parent dials, the listener boots the first one. The first
106+// supervisor's already-delivered messages still land in the
107+// channel — what we're guarding against is silent dual ownership,
108+// not message loss.
109+func TestInboxNewSupervisorPreemptsOld(t *testing.T) {
110+ path := filepath.Join(shortSocketDir(t), "in.sock")
111+ ln, err := Listen(path)
112+ if err != nil {
113+ t.Fatalf("Listen: %v", err)
114+ }
115+ defer ln.Close()
116+
117+ first := NewInbox(path)
118+ if err := first.SendInput("user one"); err != nil {
119+ t.Fatalf("first send: %v", err)
120+ }
121+ // Wait for the listener to absorb "user one" before dialing the
122+ // second supervisor; otherwise we race against the connection
123+ // preemption and "user one" can be dropped (the first conn is
124+ // closed by Accept before its read loop processes the byte).
125+ waitN(t, ln.Lines(), 1, time.Second)
126+
127+ second := NewInbox(path)
128+ defer second.Close()
129+ if err := second.SendInput("user two"); err != nil {
130+ t.Fatalf("second send: %v", err)
131+ }
132+
133+ secondLines := waitN(t, ln.Lines(), 1, time.Second)
134+ if len(secondLines) != 1 || secondLines[0] != "user two" {
135+ t.Fatalf("second supervisor not received: %v", secondLines)
136+ }
137+
138+ // The original supervisor's connection was preempted by the
139+ // listener accepting the second dial. The next SendInput on the
140+ // first handle sees a broken pipe and drops the cached connection;
141+ // the call after that redials successfully. Both behaviours
142+ // matter: the broken-pipe error must surface (so a real
143+ // supervisor knows it lost ownership) AND the next call must
144+ // recover so a flaky reconnect doesn't permanently wedge the
145+ // inbox.
146+ if err := first.SendInput("user three"); err == nil {
147+ t.Fatal("expected broken-pipe error on stale connection")
148+ }
149+ if err := first.SendInput("user four"); err != nil {
150+ t.Fatalf("first redial after eviction: %v", err)
151+ }
152+}
153+
154+func waitN(t *testing.T, ch <-chan string, n int, timeout time.Duration) []string {
155+ t.Helper()
156+ var got []string
157+ deadline := time.After(timeout)
158+ for len(got) < n {
159+ select {
160+ case msg := <-ch:
161+ got = append(got, msg)
162+ case <-deadline:
163+ return got
164+ }
165+ }
166+ return got
167+}
168+
169+// collectFor consumes up to n items from ch within timeout and
170+// pushes the slice onto the returned channel. Used by the tests
171+// above so they don't race against the listener's accept goroutine.
172+func collectFor(t *testing.T, ch <-chan string, n int, timeout time.Duration) <-chan []string {
173+ t.Helper()
174+ out := make(chan []string, 1)
175+ go func() {
176+ var got []string
177+ deadline := time.After(timeout)
178+ for len(got) < n {
179+ select {
180+ case msg, ok := <-ch:
181+ if !ok {
182+ out <- got
183+ return
184+ }
185+ got = append(got, msg)
186+ case <-deadline:
187+ out <- got
188+ return
189+ }
190+ }
191+ out <- got
192+ }()
193+ return out
194+}
195+
196+// TestInboxConcurrentSends serialises the SendInput call so two
197+// goroutines writing at once still produce two whole lines, never
198+// a tearing of bytes between them.
199+func TestInboxConcurrentSends(t *testing.T) {
200+ path := filepath.Join(shortSocketDir(t), "in.sock")
201+ ln, err := Listen(path)
202+ if err != nil {
203+ t.Fatalf("Listen: %v", err)
204+ }
205+ defer ln.Close()
206+ inbox := NewInbox(path)
207+ defer inbox.Close()
208+
209+ var wg sync.WaitGroup
210+ const n = 50
211+ wg.Add(n)
212+ for i := 0; i < n; i++ {
213+ go func(i int) {
214+ defer wg.Done()
215+ if err := inbox.SendInput("user msg"); err != nil {
216+ t.Errorf("send %d: %v", i, err)
217+ }
218+ }(i)
219+ }
220+ wg.Wait()
221+
222+ collected := collectFor(t, ln.Lines(), n, 2*time.Second)
223+ got := <-collected
224+ if len(got) != n {
225+ t.Fatalf("got %d lines, want %d", len(got), n)
226+ }
227+ for i, g := range got {
228+ if g != "user msg" {
229+ t.Fatalf("line %d garbled: %q", i, g)
230+ }
231+ }
232+}
A internal/swarm/persist.go +394 -0
@@ -0,0 +1,394 @@
1+package swarm
2+
3+// On-disk persistence for swarm agents.
4+//
5+// Every Spawn writes a meta.json next to the agent's events.jsonl and
6+// session.json. The file captures the immutable identity bits (id,
7+// task, branch, dir) plus the paths the runner needs to resume the
8+// agent later. On a fresh zot launch, Swarm.Reload() walks
9+// <root>/agents/*/meta.json and re-registers every agent it finds in
10+// StatusDetached so the user can see, view, resume, or remove them
11+// from the dashboard.
12+//
13+// We don't try to keep meta.json in sync with mutable state (status,
14+// activity, transcript). Those live in the events log (durable) and
15+// in-memory Agent fields (rebuilt by Reload from the log tail).
16+// Keeping meta.json immutable means we never have to worry about
17+// concurrent writers stomping on each other and the file matters
18+// only on the spawn/reload boundary.
19+
20+import (
21+ "context"
22+ "encoding/json"
23+ "errors"
24+ "fmt"
25+ "os"
26+ "path/filepath"
27+ "sort"
28+ "strings"
29+ "time"
30+)
31+
32+// agentMeta is the durable identity record for one agent. Only fields
33+// the supervisor needs to rebuild an Agent after a restart live here.
34+// Adding a field is backwards-compatible (older meta.json files just
35+// leave it zero); removing or renaming one is not.
36+type agentMeta struct {
37+ ID string `json:"id"`
38+ Task string `json:"task"`
39+ Branch string `json:"branch"`
40+ Dir string `json:"dir"`
41+ Started time.Time `json:"started"`
42+ Model string `json:"model,omitempty"`
43+ Provider string `json:"provider,omitempty"`
44+ InboxPath string `json:"inbox_path"`
45+ EventLogPath string `json:"event_log_path"`
46+ SessionPath string `json:"session_path"`
47+
48+ // SessionID, when non-empty, scopes the agent to a particular
49+ // host zot session: the dashboard only shows agents whose
50+ // SessionID matches the active session. Older meta.json files
51+ // (and agents spawned outside of any session, e.g. by tests or
52+ // scripted callers that didn't call SetActiveSession) have an
53+ // empty SessionID and are visible from every session as a
54+ // backward-compat fallback. Added in 2026 — a fresh field on a
55+ // json struct is backwards-compatible by design.
56+ SessionID string `json:"session_id,omitempty"`
57+}
58+
59+func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.json") }
60+
61+// writeAgentMeta serialises a's identity into stateDir/meta.json. The
62+// write is atomic (tmp + rename) so a crash mid-write can't leave a
63+// half-parsable file that fails Reload.
64+func writeAgentMeta(stateDir string, a *Agent) error {
65+ m := agentMeta{
66+ ID: a.ID,
67+ Task: a.Task,
68+ Branch: a.Branch,
69+ Dir: a.Dir,
70+ Started: a.Started,
71+ Model: a.Model,
72+ Provider: a.Provider,
73+ InboxPath: a.InboxPath,
74+ EventLogPath: a.EventLogPath,
75+ SessionPath: a.SessionPath,
76+ SessionID: a.SessionID,
77+ }
78+ b, err := json.MarshalIndent(m, "", " ")
79+ if err != nil {
80+ return fmt.Errorf("swarm meta marshal: %w", err)
81+ }
82+ if err := os.MkdirAll(stateDir, 0o755); err != nil {
83+ return fmt.Errorf("swarm meta dir: %w", err)
84+ }
85+ final := metaPath(stateDir)
86+ tmp := final + ".tmp"
87+ if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil {
88+ return fmt.Errorf("swarm meta write: %w", err)
89+ }
90+ if err := os.Rename(tmp, final); err != nil {
91+ _ = os.Remove(tmp)
92+ return fmt.Errorf("swarm meta rename: %w", err)
93+ }
94+ return nil
95+}
96+
97+// readAgentMeta loads one meta.json. Returns os.ErrNotExist (wrapped)
98+// when the file is missing so callers can distinguish "no such agent"
99+// from "corrupt metadata".
100+func readAgentMeta(stateDir string) (agentMeta, error) {
101+ var m agentMeta
102+ b, err := os.ReadFile(metaPath(stateDir))
103+ if err != nil {
104+ return m, err
105+ }
106+ if err := json.Unmarshal(b, &m); err != nil {
107+ return m, fmt.Errorf("swarm meta parse %s: %w", stateDir, err)
108+ }
109+ if m.ID == "" {
110+ return m, fmt.Errorf("swarm meta %s: missing id", stateDir)
111+ }
112+ return m, nil
113+}
114+
115+// Reload scans <root>/agents/*/meta.json and re-registers every
116+// previously-spawned agent as a StatusDetached entry. Agents already
117+// present in memory are left alone (Reload is idempotent and safe to
118+// call after Spawn, though in practice the cli invokes it exactly
119+// once just after New()).
120+//
121+// The reloaded agents have no live Runner; the user can:
122+// - view their transcript (the dashboard reads from EventLogPath),
123+// - resume them via Swarm.Resume (starts a fresh subprocess on the
124+// same worktree / session / inbox path),
125+// - remove them (worktree + meta + events log gone).
126+//
127+// Reload returns the number of agents loaded plus any per-directory
128+// error encountered. Malformed entries are skipped rather than
129+// failing the whole reload — one bad meta.json shouldn't hide the
130+// rest of the swarm.
131+func (f *Swarm) Reload() (loaded int, errs []error) {
132+ agentsDir := filepath.Join(f.cfg.Root, "agents")
133+ entries, err := os.ReadDir(agentsDir)
134+ if err != nil {
135+ if errors.Is(err, os.ErrNotExist) {
136+ return 0, nil
137+ }
138+ return 0, []error{fmt.Errorf("swarm reload: %w", err)}
139+ }
140+
141+ // Sort by directory name so the load order is stable across runs.
142+ // agentStateDir uses the id verbatim so name-sort == id-sort,
143+ // which mirrors the creation order well enough for the dashboard
144+ // (we also sort by Started in SnapshotAll, but having a stable
145+ // f.order helps tests).
146+ names := make([]string, 0, len(entries))
147+ for _, e := range entries {
148+ if e.IsDir() {
149+ names = append(names, e.Name())
150+ }
151+ }
152+ sort.Strings(names)
153+
154+ for _, name := range names {
155+ stateDir := filepath.Join(agentsDir, name)
156+ m, err := readAgentMeta(stateDir)
157+ if err != nil {
158+ if errors.Is(err, os.ErrNotExist) {
159+ // Bare directory with no meta.json — probably a
160+ // leftover from a Spawn that failed before
161+ // writeAgentMeta. Ignore silently.
162+ continue
163+ }
164+ errs = append(errs, err)
165+ continue
166+ }
167+
168+ f.mu.Lock()
169+ if _, exists := f.agents[m.ID]; exists {
170+ f.mu.Unlock()
171+ continue
172+ }
173+ a := f.buildDetachedAgent(m)
174+ f.agents[m.ID] = a
175+ f.order = append(f.order, m.ID)
176+ f.mu.Unlock()
177+ loaded++
178+ }
179+ return loaded, errs
180+}
181+
182+// buildDetachedAgent constructs an Agent from a meta.json with no
183+// running Runner. The agent's transcript is populated from the tail
184+// of its event log so the dashboard immediately shows recent output;
185+// activity is inferred from the last lifecycle event.
186+//
187+// The returned Agent has a closed `done` channel because Wait should
188+// return instantly: there is nothing to wait for.
189+func (f *Swarm) buildDetachedAgent(m agentMeta) *Agent {
190+ a := &Agent{
191+ ID: m.ID,
192+ Task: m.Task,
193+ Branch: m.Branch,
194+ Dir: m.Dir,
195+ Started: m.Started,
196+ Model: m.Model,
197+ Provider: m.Provider,
198+ InboxPath: m.InboxPath,
199+ EventLogPath: m.EventLogPath,
200+ SessionPath: m.SessionPath,
201+ SessionID: m.SessionID,
202+ inbox: NewInbox(m.InboxPath),
203+ status: StatusDetached,
204+ activity: "detached",
205+ done: make(chan struct{}),
206+ }
207+ // Wait() must not block for detached agents; close the channel
208+ // immediately. Callers Resuming the agent will replace done with
209+ // a fresh channel before starting the new runner.
210+ close(a.done)
211+
212+ // Recover transcript + activity hints from the event log. Best
213+ // effort: a missing or unreadable log just leaves the agent
214+ // detached with an empty transcript.
215+ if a.EventLogPath != "" {
216+ if evs, err := ReadEventLog(a.EventLogPath); err == nil {
217+ replayEventsIntoAgent(a, evs)
218+ }
219+ }
220+ return a
221+}
222+
223+// replayEventsIntoAgent re-derives an agent's transcript and last
224+// known status hint from its event log. Mirrors applyEventToSink in
225+// runner.go but writes directly to the Agent fields (no Sink because
226+// the agent isn't being driven by a runner yet).
227+//
228+// Status precedence: explicit lifecycle events (agent_stopped) win
229+// over inferred ones (assistant_message → idle). If the log contains
230+// no terminator we keep status=StatusDetached so the user can resume.
231+func replayEventsIntoAgent(a *Agent, evs []Event) {
232+ terminal := false
233+ for _, ev := range evs {
234+ switch ev.Type {
235+ case "assistant_message":
236+ if c, ok := ev.Data["content"].([]any); ok {
237+ for _, blk := range c {
238+ m, _ := blk.(map[string]any)
239+ if t, _ := m["type"].(string); t == "text" {
240+ if txt, _ := m["text"].(string); txt != "" {
241+ a.appendTranscript(txt)
242+ }
243+ }
244+ }
245+ }
246+ case "user_message":
247+ if c, ok := ev.Data["content"].([]any); ok {
248+ for _, blk := range c {
249+ m, _ := blk.(map[string]any)
250+ if t, _ := m["type"].(string); t == "text" {
251+ if txt, _ := m["text"].(string); txt != "" {
252+ a.appendTranscript("user: " + txt)
253+ }
254+ }
255+ }
256+ }
257+ case "stdout":
258+ if txt, _ := ev.Data["text"].(string); txt != "" {
259+ a.appendTranscript(txt)
260+ }
261+ case "stderr":
262+ if txt, _ := ev.Data["text"].(string); txt != "" {
263+ a.appendTranscript("stderr: " + txt)
264+ }
265+ case "error":
266+ if msg, _ := ev.Data["message"].(string); msg != "" {
267+ a.appendTranscript("error: " + msg)
268+ }
269+ case "agent_stopped":
270+ terminal = true
271+ reason, _ := ev.Data["reason"].(string)
272+ a.mu.Lock()
273+ switch reason {
274+ case "cancelled":
275+ a.status = StatusKilled
276+ a.activity = "cancelled (offline)"
277+ case "shutdown":
278+ a.status = StatusDone
279+ a.activity = "shutdown (offline)"
280+ case "exit":
281+ if code, ok := ev.Data["code"].(float64); ok && code != 0 {
282+ a.status = StatusFailed
283+ a.activity = fmt.Sprintf("exit %d (offline)", int(code))
284+ } else {
285+ a.status = StatusDone
286+ a.activity = "done (offline)"
287+ }
288+ default:
289+ a.status = StatusDone
290+ a.activity = "stopped (offline)"
291+ }
292+ a.mu.Unlock()
293+ }
294+ }
295+ if !terminal {
296+ // Non-terminal log means the previous parent died mid-run.
297+ // Leave status=StatusDetached but record a hint so the
298+ // dashboard shows something useful.
299+ a.mu.Lock()
300+ if a.activity == "detached" && len(a.transcript) > 0 {
301+ a.activity = "detached (resume to continue)"
302+ }
303+ a.mu.Unlock()
304+ }
305+}
306+
307+// Resume re-attaches a Runner to a previously-spawned agent. The
308+// existing worktree, session file, branch, and inbox path are kept;
309+// only the in-memory Agent and its runner are replaced. Use this to
310+// continue a swarm session across zot restarts:
311+//
312+// swarmMgr.Reload()
313+// a, err := swarmMgr.Resume(ctx, "alpha-12345")
314+// swarmMgr.SendUserTurn(a.ID, "where were we?")
315+//
316+// The agent must be in a non-running state (Detached, Done, Failed,
317+// Killed). Resuming a still-running agent returns an error so two
318+// runners don't race for the same session.
319+func (f *Swarm) Resume(ctx context.Context, id string) (*Agent, error) {
320+ existing := f.Get(id)
321+ if existing == nil {
322+ return nil, fmt.Errorf("swarm: no such agent %q", id)
323+ }
324+ existing.mu.Lock()
325+ st := existing.status
326+ existing.mu.Unlock()
327+ if st == StatusRunning || st == StatusPending {
328+ return nil, fmt.Errorf("swarm: agent %s is still %s; stop it first", existing.ID, st)
329+ }
330+
331+ // Rebuild from the meta record so we don't carry stale runner
332+ // state from a previous incarnation. We re-read meta.json rather
333+ // than reusing the live struct's fields so callers that mutated
334+ // (e.g. tests that hand-built an Agent) don't accidentally route
335+ // the new runner at the wrong paths.
336+ m := agentMeta{
337+ ID: existing.ID, Task: existing.Task, Branch: existing.Branch,
338+ Dir: existing.Dir, Started: existing.Started,
339+ Model: existing.Model, Provider: existing.Provider,
340+ InboxPath: existing.InboxPath, EventLogPath: existing.EventLogPath,
341+ SessionPath: existing.SessionPath,
342+ }
343+
344+ a := &Agent{
345+ ID: m.ID,
346+ Task: m.Task,
347+ Branch: m.Branch,
348+ Dir: m.Dir,
349+ Started: m.Started,
350+ Model: m.Model,
351+ Provider: m.Provider,
352+ InboxPath: m.InboxPath,
353+ EventLogPath: m.EventLogPath,
354+ SessionPath: m.SessionPath,
355+ Resuming: true,
356+ inbox: NewInbox(m.InboxPath),
357+ status: StatusPending,
358+ activity: "resuming",
359+ done: make(chan struct{}),
360+ }
361+ // Carry the previous transcript forward so the dashboard doesn't
362+ // flash empty between resume and the first new event.
363+ prev := existing.Transcript()
364+ if len(prev) > 0 {
365+ a.appendTranscript(strings.Join(prev, "\n"))
366+ }
367+ a.ctx, a.cancel = context.WithCancel(ctx)
368+ a.runner = f.cfg.NewRunner(a)
369+
370+ f.mu.Lock()
371+ f.agents[a.ID] = a
372+ // Keep the agent's slot in f.order; replacing in-place avoids
373+ // reshuffling the dashboard's row ordering on resume.
374+ found := false
375+ for _, k := range f.order {
376+ if k == a.ID {
377+ found = true
378+ break
379+ }
380+ }
381+ if !found {
382+ f.order = append(f.order, a.ID)
383+ }
384+ f.mu.Unlock()
385+
386+ // Refresh the meta.json so any new path defaults (e.g. socket
387+ // path moved into /tmp because the root got renamed) get
388+ // persisted. Best-effort; resume still works if the disk is
389+ // read-only because everything the runner needs is in-memory.
390+ _ = writeAgentMeta(f.agentStateDir(a.ID), a)
391+
392+ go f.run(a)
393+ return a, nil
394+}
A internal/swarm/persist_test.go +838 -0
@@ -0,0 +1,838 @@
1+package swarm
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+)
14+
15+// TestSpawnWritesMetaJSON asserts the durability contract: every
16+// successful Spawn leaves a meta.json on disk with the agent's
17+// identity bits. Without this, Reload on the next zot launch can't
18+// find the agent and the user loses access to the worktree.
19+func TestSpawnWritesMetaJSON(t *testing.T) {
20+ root := t.TempDir()
21+ f := New(Config{
22+ Root: root,
23+ RepoRoot: root,
24+ Worktree: MemWorktree(filepath.Join(root, "wt")),
25+ NewRunner: func(a *Agent) Runner {
26+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
27+ <-ctx.Done()
28+ return ctx.Err()
29+ })
30+ },
31+ })
32+ defer f.StopAll()
33+
34+ a, err := f.Spawn(context.Background(), "investigate widget")
35+ if err != nil {
36+ t.Fatalf("spawn: %v", err)
37+ }
38+
39+ // Read meta.json straight off disk.
40+ metaBytes, err := os.ReadFile(filepath.Join(root, "agents", a.ID, "meta.json"))
41+ if err != nil {
42+ t.Fatalf("read meta.json: %v", err)
43+ }
44+ var got agentMeta
45+ if err := json.Unmarshal(metaBytes, &got); err != nil {
46+ t.Fatalf("parse meta.json: %v", err)
47+ }
48+ if got.ID != a.ID {
49+ t.Errorf("meta.ID = %q; want %q", got.ID, a.ID)
50+ }
51+ if got.Task != "investigate widget" {
52+ t.Errorf("meta.Task = %q", got.Task)
53+ }
54+ if got.Branch != a.Branch || got.Dir != a.Dir {
55+ t.Errorf("meta paths drifted: %+v vs agent %+v", got, a)
56+ }
57+ if got.InboxPath == "" || got.EventLogPath == "" || got.SessionPath == "" {
58+ t.Errorf("meta paths empty: %+v", got)
59+ }
60+ if got.Started.IsZero() {
61+ t.Error("meta.Started is zero")
62+ }
63+}
64+
65+// TestReloadRebuildsDetachedAgents simulates a zot restart by spawning
66+// in one Swarm, throwing it away, then opening a fresh Swarm against
67+// the same root and calling Reload. The user-visible state — id,
68+// task, branch, dir — must come back, and status must be Detached so
69+// the dashboard can offer resume.
70+func TestReloadRebuildsDetachedAgents(t *testing.T) {
71+ root := t.TempDir()
72+
73+ // First incarnation: spawn two agents, then drop the supervisor.
74+ first := New(Config{
75+ Root: root,
76+ RepoRoot: root,
77+ Worktree: MemWorktree(filepath.Join(root, "wt")),
78+ NewRunner: func(a *Agent) Runner {
79+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
80+ <-ctx.Done()
81+ return ctx.Err()
82+ })
83+ },
84+ })
85+ a1, err := first.Spawn(context.Background(), "alpha task")
86+ if err != nil {
87+ t.Fatalf("spawn a1: %v", err)
88+ }
89+ a2, err := first.Spawn(context.Background(), "beta task")
90+ if err != nil {
91+ t.Fatalf("spawn a2: %v", err)
92+ }
93+ first.StopAll()
94+ // Wait briefly for the runner goroutines to settle so their
95+ // stop event reaches the log before we move on. Reload itself
96+ // doesn't need this; it makes the assertions below deterministic.
97+ a1.Wait()
98+ a2.Wait()
99+
100+ // Second incarnation against the same root.
101+ second := New(Config{
102+ Root: root,
103+ RepoRoot: root,
104+ Worktree: MemWorktree(filepath.Join(root, "wt")),
105+ })
106+ loaded, errs := second.Reload()
107+ if len(errs) > 0 {
108+ t.Fatalf("reload errs: %v", errs)
109+ }
110+ if loaded != 2 {
111+ t.Fatalf("loaded = %d; want 2", loaded)
112+ }
113+ snap := second.SnapshotAll()
114+ if len(snap) != 2 {
115+ t.Fatalf("snapshot len = %d; want 2", len(snap))
116+ }
117+ ids := map[string]AgentSnapshot{}
118+ for _, s := range snap {
119+ ids[s.ID] = s
120+ }
121+ if _, ok := ids[a1.ID]; !ok {
122+ t.Errorf("reloaded set missing %s", a1.ID)
123+ }
124+ if _, ok := ids[a2.ID]; !ok {
125+ t.Errorf("reloaded set missing %s", a2.ID)
126+ }
127+ for _, s := range snap {
128+ if s.Status != StatusDetached {
129+ t.Errorf("agent %s status = %q; want detached", s.ID, s.Status)
130+ }
131+ if s.Task == "" {
132+ t.Errorf("agent %s lost its task", s.ID)
133+ }
134+ }
135+}
136+
137+// TestReloadIsIdempotent calls Reload twice in a row and asserts the
138+// second call neither duplicates rows nor errors.
139+func TestReloadIsIdempotent(t *testing.T) {
140+ root := t.TempDir()
141+ first := New(Config{
142+ Root: root, RepoRoot: root,
143+ Worktree: MemWorktree(filepath.Join(root, "wt")),
144+ NewRunner: func(a *Agent) Runner {
145+ return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
146+ },
147+ })
148+ if _, err := first.Spawn(context.Background(), "only one"); err != nil {
149+ t.Fatal(err)
150+ }
151+ first.StopAll()
152+
153+ second := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})
154+ loaded1, _ := second.Reload()
155+ loaded2, errs := second.Reload()
156+ if len(errs) > 0 {
157+ t.Fatalf("errs on second reload: %v", errs)
158+ }
159+ if loaded1 != 1 || loaded2 != 0 {
160+ t.Fatalf("loaded counts = (%d, %d); want (1, 0)", loaded1, loaded2)
161+ }
162+ if got := len(second.SnapshotAll()); got != 1 {
163+ t.Fatalf("snapshot len = %d; want 1", got)
164+ }
165+}
166+
167+// TestReloadReplaysTranscriptFromEventLog drops a curated events.jsonl
168+// next to a meta.json and checks that the transcript surfaces in the
169+// reloaded agent. This is the user-facing payoff of Reload: opening
170+// /swarm logs <id> after a restart shows what the agent said before.
171+func TestReloadReplaysTranscriptFromEventLog(t *testing.T) {
172+ root := t.TempDir()
173+ id := "alpha-9"
174+ stateDir := filepath.Join(root, "agents", id)
175+ if err := os.MkdirAll(stateDir, 0o755); err != nil {
176+ t.Fatal(err)
177+ }
178+ // meta.json
179+ m := agentMeta{
180+ ID: id, Task: "do thing", Branch: "swarm/alpha-9",
181+ Dir: filepath.Join(root, "wt", id), Started: time.Now().Add(-time.Hour),
182+ InboxPath: filepath.Join(stateDir, "in.sock"),
183+ EventLogPath: filepath.Join(stateDir, "events.jsonl"),
184+ SessionPath: filepath.Join(stateDir, "session.json"),
185+ }
186+ mb, _ := json.MarshalIndent(m, "", " ")
187+ if err := os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644); err != nil {
188+ t.Fatal(err)
189+ }
190+ // Event log with one assistant turn and a graceful stop.
191+ log, err := OpenEventLog(m.EventLogPath)
192+ if err != nil {
193+ t.Fatal(err)
194+ }
195+ _ = log.Append(NewEvent("assistant_message", map[string]any{
196+ "content": []any{map[string]any{"type": "text", "text": "hello from the past"}},
197+ }))
198+ _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "shutdown"}))
199+ _ = log.Close()
200+
201+ f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})
202+ loaded, errs := f.Reload()
203+ if len(errs) > 0 || loaded != 1 {
204+ t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
205+ }
206+ a := f.Get(id)
207+ if a == nil {
208+ t.Fatal("reloaded agent not found")
209+ }
210+ // Status should reflect the lifecycle terminator we wrote.
211+ if a.Status() != StatusDone {
212+ t.Errorf("status = %q; want done (offline)", a.Status())
213+ }
214+ got := a.Transcript()
215+ found := false
216+ for _, line := range got {
217+ if line == "hello from the past" {
218+ found = true
219+ }
220+ }
221+ if !found {
222+ t.Errorf("transcript missing replayed line: %v", got)
223+ }
224+}
225+
226+// TestReloadSkipsBareDirsAndCorruptMeta ensures one bad meta.json
227+// doesn't blow up the whole reload. Directories with no meta.json
228+// at all are silently ignored (Spawn that failed mid-way leaves
229+// these); corrupt meta files are reported as errors but don't stop
230+// the rest of the load.
231+func TestReloadSkipsBareDirsAndCorruptMeta(t *testing.T) {
232+ root := t.TempDir()
233+ agentsDir := filepath.Join(root, "agents")
234+ // Bare directory, no meta.json.
235+ if err := os.MkdirAll(filepath.Join(agentsDir, "leftover"), 0o755); err != nil {
236+ t.Fatal(err)
237+ }
238+ // Directory with garbage meta.json.
239+ if err := os.MkdirAll(filepath.Join(agentsDir, "corrupt"), 0o755); err != nil {
240+ t.Fatal(err)
241+ }
242+ if err := os.WriteFile(filepath.Join(agentsDir, "corrupt", "meta.json"), []byte("not json"), 0o644); err != nil {
243+ t.Fatal(err)
244+ }
245+ // One good agent.
246+ good := "good-1"
247+ stateDir := filepath.Join(agentsDir, good)
248+ _ = os.MkdirAll(stateDir, 0o755)
249+ m := agentMeta{ID: good, Task: "x", Branch: "swarm/" + good, Dir: "/tmp/x", Started: time.Now()}
250+ mb, _ := json.MarshalIndent(m, "", " ")
251+ _ = os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644)
252+
253+ f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})
254+ loaded, errs := f.Reload()
255+ if loaded != 1 {
256+ t.Errorf("loaded = %d; want 1", loaded)
257+ }
258+ if len(errs) != 1 {
259+ t.Errorf("errs len = %d; want 1 (the corrupt entry): %v", len(errs), errs)
260+ }
261+}
262+
263+// TestResumeRestartsRunnerOnSameSession is the headline test. We:
264+//
265+// 1. Spawn an agent with a counting runner (records each Run call).
266+// 2. Stop it.
267+// 3. Resume it.
268+// 4. Assert Run was called twice — once for spawn, once for resume —
269+// against the same SessionPath / InboxPath / Dir.
270+func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
271+ root := t.TempDir()
272+ var (
273+ mu sync.Mutex
274+ runs []*Agent
275+ runsDone int32
276+ )
277+ f := New(Config{
278+ Root: root, RepoRoot: root,
279+ Worktree: MemWorktree(filepath.Join(root, "wt")),
280+ NewRunner: func(a *Agent) Runner {
281+ return RunnerFunc(func(ctx context.Context, sink Sink) error {
282+ mu.Lock()
283+ runs = append(runs, a)
284+ mu.Unlock()
285+ sink.Activity("ran")
286+ atomic.AddInt32(&runsDone, 1)
287+ <-ctx.Done()
288+ return ctx.Err()
289+ })
290+ },
291+ })
292+ defer f.StopAll()
293+
294+ a, err := f.Spawn(context.Background(), "do thing")
295+ if err != nil {
296+ t.Fatalf("spawn: %v", err)
297+ }
298+ originalSession := a.SessionPath
299+ originalDir := a.Dir
300+ originalInbox := a.InboxPath
301+
302+ // Wait for the runner to record its presence, then stop the agent.
303+ deadline := time.Now().Add(time.Second)
304+ for atomic.LoadInt32(&runsDone) < 1 && time.Now().Before(deadline) {
305+ time.Sleep(2 * time.Millisecond)
306+ }
307+ if err := f.Stop(a.ID); err != nil {
308+ t.Fatalf("stop: %v", err)
309+ }
310+ a.Wait()
311+ if a.Status() != StatusKilled {
312+ t.Fatalf("post-stop status = %q; want killed", a.Status())
313+ }
314+
315+ // Now resume.
316+ a2, err := f.Resume(context.Background(), a.ID)
317+ if err != nil {
318+ t.Fatalf("resume: %v", err)
319+ }
320+ if a2.ID != a.ID {
321+ t.Errorf("resume returned different id: %s vs %s", a2.ID, a.ID)
322+ }
323+ if a2.SessionPath != originalSession {
324+ t.Errorf("resume changed session path: %s vs %s", a2.SessionPath, originalSession)
325+ }
326+ if a2.Dir != originalDir {
327+ t.Errorf("resume changed dir: %s vs %s", a2.Dir, originalDir)
328+ }
329+ if a2.InboxPath != originalInbox {
330+ t.Errorf("resume changed inbox path: %s vs %s", a2.InboxPath, originalInbox)
331+ }
332+ // Two runner invocations: spawn + resume.
333+ deadline = time.Now().Add(time.Second)
334+ for atomic.LoadInt32(&runsDone) < 2 && time.Now().Before(deadline) {
335+ time.Sleep(2 * time.Millisecond)
336+ }
337+ mu.Lock()
338+ defer mu.Unlock()
339+ if len(runs) != 2 {
340+ t.Fatalf("runner invocations = %d; want 2", len(runs))
341+ }
342+ if runs[1].ID != a.ID {
343+ t.Errorf("second run had wrong id: %s", runs[1].ID)
344+ }
345+ if got := len(f.SnapshotAll()); got != 1 {
346+ t.Errorf("snapshot len = %d; want 1 (in-place replace)", got)
347+ }
348+}
349+
350+// TestResumeSetsResumingFlag pins the contract execRunner relies on:
351+// after Resume, the new Agent has Resuming == true; after a fresh
352+// Spawn it's false. The runner branches on this to decide whether
353+// to pass the original Task as a positional argv to the child, so
354+// getting it wrong silently re-runs the initial task on every
355+// resume and surfaces "agent busy; send 'cancel' first" between
356+// assistant messages.
357+func TestResumeSetsResumingFlag(t *testing.T) {
358+ root := t.TempDir()
359+ f := New(Config{
360+ Root: root, RepoRoot: root,
361+ Worktree: MemWorktree(filepath.Join(root, "wt")),
362+ NewRunner: func(a *Agent) Runner {
363+ return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
364+ },
365+ })
366+ defer f.StopAll()
367+
368+ a, err := f.Spawn(context.Background(), "x")
369+ if err != nil {
370+ t.Fatal(err)
371+ }
372+ if a.Resuming {
373+ t.Fatal("fresh Spawn produced Resuming==true; want false")
374+ }
375+ if err := f.Stop(a.ID); err != nil {
376+ t.Fatal(err)
377+ }
378+ a.Wait()
379+
380+ a2, err := f.Resume(context.Background(), a.ID)
381+ if err != nil {
382+ t.Fatalf("resume: %v", err)
383+ }
384+ if !a2.Resuming {
385+ t.Error("Resume produced Resuming==false; want true so the runner skips the duplicate initial-task argv")
386+ }
387+}
388+
389+// TestResumeRejectsRunningAgent prevents the user from double-running
390+// an agent: two runners on the same session.json would race.
391+func TestResumeRejectsRunningAgent(t *testing.T) {
392+ root := t.TempDir()
393+ f := New(Config{
394+ Root: root, RepoRoot: root,
395+ Worktree: MemWorktree(filepath.Join(root, "wt")),
396+ NewRunner: func(a *Agent) Runner {
397+ return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
398+ },
399+ })
400+ defer f.StopAll()
401+ a, err := f.Spawn(context.Background(), "x")
402+ if err != nil {
403+ t.Fatal(err)
404+ }
405+ // Wait for the runner to actually start.
406+ deadline := time.Now().Add(time.Second)
407+ for a.Status() != StatusRunning && time.Now().Before(deadline) {
408+ time.Sleep(2 * time.Millisecond)
409+ }
410+ if _, err := f.Resume(context.Background(), a.ID); err == nil {
411+ t.Fatal("resume on running agent did not error")
412+ }
413+}
414+
415+// TestResumeAfterReload exercises the full lifecycle the user cares
416+// about: spawn in process A, exit A, start process B, Reload, Resume.
417+// The resumed agent must keep its id and start producing new output
418+// against the same on-disk state.
419+//
420+// Note on transcript persistence: in production the runner is
421+// execRunner, which writes every event to events.jsonl, and Reload's
422+// replayEventsIntoAgent rebuilds the transcript from there. The fake
423+// RunnerFunc in this test calls sink.Transcript directly (which only
424+// touches in-memory state), so the "first run" line is *expected* to
425+// be lost across the restart — the in-memory Agent went away. We
426+// have TestReloadReplaysTranscriptFromEventLog covering the
427+// log-replay path with a curated events.jsonl.
428+func TestResumeAfterReload(t *testing.T) {
429+ root := t.TempDir()
430+ // Process A
431+ a := New(Config{
432+ Root: root, RepoRoot: root,
433+ Worktree: MemWorktree(filepath.Join(root, "wt")),
434+ NewRunner: func(ag *Agent) Runner {
435+ return RunnerFunc(func(ctx context.Context, sink Sink) error {
436+ sink.Transcript("first run for " + ag.ID)
437+ <-ctx.Done()
438+ return ctx.Err()
439+ })
440+ },
441+ })
442+ ag, err := a.Spawn(context.Background(), "x")
443+ if err != nil {
444+ t.Fatal(err)
445+ }
446+ // Let the runner record its transcript line before we tear it down.
447+ deadline := time.Now().Add(time.Second)
448+ for len(ag.Transcript()) == 0 && time.Now().Before(deadline) {
449+ time.Sleep(2 * time.Millisecond)
450+ }
451+ id := ag.ID
452+ a.StopAll()
453+ ag.Wait()
454+
455+ // Process B: a new Swarm against the same root.
456+ resumed := make(chan struct{}, 1)
457+ b := New(Config{
458+ Root: root, RepoRoot: root,
459+ Worktree: MemWorktree(filepath.Join(root, "wt")),
460+ NewRunner: func(ag *Agent) Runner {
461+ return RunnerFunc(func(ctx context.Context, sink Sink) error {
462+ sink.Transcript("second run for " + ag.ID)
463+ select {
464+ case resumed <- struct{}{}:
465+ default:
466+ }
467+ <-ctx.Done()
468+ return ctx.Err()
469+ })
470+ },
471+ })
472+ if loaded, errs := b.Reload(); loaded != 1 || len(errs) > 0 {
473+ t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
474+ }
475+ a2, err := b.Resume(context.Background(), id)
476+ if err != nil {
477+ t.Fatalf("resume: %v", err)
478+ }
479+ defer b.StopAll()
480+
481+ select {
482+ case <-resumed:
483+ case <-time.After(time.Second):
484+ t.Fatal("resume runner did not start")
485+ }
486+ if a2.ID != id {
487+ t.Errorf("resume id = %s; want %s", a2.ID, id)
488+ }
489+
490+ // Wait for the new transcript line to land.
491+ deadline = time.Now().Add(time.Second)
492+ for time.Now().Before(deadline) {
493+ for _, l := range a2.Transcript() {
494+ if l == "second run for "+id {
495+ return
496+ }
497+ }
498+ time.Sleep(5 * time.Millisecond)
499+ }
500+ t.Fatalf("resumed transcript missing fresh line: %v", a2.Transcript())
501+}
502+
503+// TestSpawnReqPersistsModel confirms that the per-agent model
504+// override is captured at Spawn time, surfaced via Snapshot, and
505+// written to meta.json so a later Reload + Resume reuses it.
506+func TestSpawnReqPersistsModel(t *testing.T) {
507+ root := t.TempDir()
508+ f := New(Config{
509+ Root: root, RepoRoot: root,
510+ Worktree: MemWorktree(filepath.Join(root, "wt")),
511+ NewRunner: func(a *Agent) Runner {
512+ return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
513+ },
514+ })
515+ a, err := f.SpawnReq(context.Background(), SpawnRequest{
516+ Task: "x", Model: "claude-sonnet-4-5", Provider: "anthropic",
517+ })
518+ if err != nil {
519+ t.Fatalf("spawn: %v", err)
520+ }
521+ if a.Model != "claude-sonnet-4-5" || a.Provider != "anthropic" {
522+ t.Fatalf("agent fields = (%q,%q); want (claude-sonnet-4-5, anthropic)", a.Model, a.Provider)
523+ }
524+ snap := a.Snapshot()
525+ if snap.Model != "claude-sonnet-4-5" || snap.Provider != "anthropic" {
526+ t.Fatalf("snapshot = (%q,%q); model fields not surfaced", snap.Model, snap.Provider)
527+ }
528+
529+ // Stop so we can read meta.json without racing the run loop.
530+ if err := f.Stop(a.ID); err != nil {
531+ t.Fatal(err)
532+ }
533+ a.Wait()
534+
535+ metaBytes, err := os.ReadFile(filepath.Join(root, "agents", a.ID, "meta.json"))
536+ if err != nil {
537+ t.Fatalf("read meta.json: %v", err)
538+ }
539+ var got agentMeta
540+ if err := json.Unmarshal(metaBytes, &got); err != nil {
541+ t.Fatal(err)
542+ }
543+ if got.Model != "claude-sonnet-4-5" || got.Provider != "anthropic" {
544+ t.Errorf("meta = (%q,%q); want model + provider persisted", got.Model, got.Provider)
545+ }
546+
547+ // Reload in a fresh Swarm and confirm the detached agent still
548+ // carries the model/provider so Resume can route the child
549+ // subprocess back to the same model.
550+ g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})
551+ if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 {
552+ t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
553+ }
554+ re := g.Get(a.ID)
555+ if re == nil {
556+ t.Fatal("reloaded agent missing")
557+ }
558+ if re.Model != "claude-sonnet-4-5" || re.Provider != "anthropic" {
559+ t.Errorf("reloaded fields = (%q,%q); want preserved", re.Model, re.Provider)
560+ }
561+}
562+
563+// TestSwarmAgentArgsIncludesModelFlags pins the argv contract that
564+// connects the supervisor's per-agent model override to the child's
565+// --model / --provider flag set. Adding a new path to argv without
566+// updating this assertion is the failure mode this catches.
567+func TestSwarmAgentArgsIncludesModelFlags(t *testing.T) {
568+ args := swarmAgentArgs(swarmAgentArgsOpts{
569+ Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock",
570+ Task: "do x", Model: "gpt-5", Provider: "openai",
571+ })
572+ want := []string{"--model", "gpt-5", "--provider", "openai"}
573+ for i := 0; i+1 < len(want); i += 2 {
574+ flag, value := want[i], want[i+1]
575+ found := false
576+ for j := 0; j+1 < len(args); j++ {
577+ if args[j] == flag && args[j+1] == value {
578+ found = true
579+ break
580+ }
581+ }
582+ if !found {
583+ t.Errorf("argv missing %s %s: %v", flag, value, args)
584+ }
585+ }
586+ // And the task still ends up positional last.
587+ if args[len(args)-1] != "do x" {
588+ t.Errorf("task should be last positional; argv=%v", args)
589+ }
590+}
591+
592+// TestStopOnDetachedAgentIsNoopAndDoesNotPanic regression-tests the
593+// segfault that hit when the user pressed 'k' on a reloaded
594+// (detached) agent: Swarm.Stop unconditionally called a.cancel(),
595+// but buildDetachedAgent never assigns a cancel func because there's
596+// no in-process runner to cancel. The fix is to short-circuit Stop
597+// for StatusDetached and — belt-and-braces — nil-check a.cancel.
598+func TestStopOnDetachedAgentIsNoopAndDoesNotPanic(t *testing.T) {
599+ root := t.TempDir()
600+ // Build a detached agent the same way Reload does: drop a
601+ // meta.json on disk, then ask a fresh Swarm to pick it up.
602+ id := "alpha-1"
603+ stateDir := filepath.Join(root, "agents", id)
604+ if err := os.MkdirAll(stateDir, 0o755); err != nil {
605+ t.Fatal(err)
606+ }
607+ m := agentMeta{
608+ ID: id, Task: "t", Branch: "swarm/" + id,
609+ Dir: filepath.Join(root, "wt", id),
610+ Started: time.Now().Add(-time.Hour),
611+ InboxPath: filepath.Join(stateDir, "in.sock"),
612+ EventLogPath: filepath.Join(stateDir, "events.jsonl"),
613+ SessionPath: filepath.Join(stateDir, "session.json"),
614+ }
615+ mb, _ := json.MarshalIndent(m, "", " ")
616+ if err := os.WriteFile(filepath.Join(stateDir, "meta.json"), mb, 0o644); err != nil {
617+ t.Fatal(err)
618+ }
619+
620+ f := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})
621+ if loaded, errs := f.Reload(); loaded != 1 || len(errs) > 0 {
622+ t.Fatalf("reload loaded=%d errs=%v", loaded, errs)
623+ }
624+ a := f.Get(id)
625+ if a == nil {
626+ t.Fatal("detached agent missing from swarm")
627+ }
628+ if a.Status() != StatusDetached {
629+ t.Fatalf("setup: agent status = %q; want detached", a.Status())
630+ }
631+
632+ // The real assertion: Stop returns cleanly without panicking.
633+ defer func() {
634+ if r := recover(); r != nil {
635+ t.Fatalf("Stop on detached agent panicked: %v", r)
636+ }
637+ }()
638+ if err := f.Stop(id); err != nil {
639+ t.Fatalf("Stop on detached agent: %v", err)
640+ }
641+ // Status must remain Detached (Stop is a no-op).
642+ if got := a.Status(); got != StatusDetached {
643+ t.Errorf("after Stop status = %q; want detached (no-op)", got)
644+ }
645+
646+ // StopAll, which the cli defers on exit, must also be safe.
647+ f.StopAll()
648+}
649+
650+// TestRemoveAlsoCleansStateDir ensures Remove deletes the on-disk
651+// state directory in addition to the worktree, so a removed agent
652+// doesn't reappear on the next Reload.
653+func TestRemoveAlsoCleansStateDir(t *testing.T) {
654+ root := t.TempDir()
655+ f := New(Config{
656+ Root: root, RepoRoot: root,
657+ Worktree: MemWorktree(filepath.Join(root, "wt")),
658+ NewRunner: func(a *Agent) Runner {
659+ return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
660+ },
661+ })
662+ a, err := f.Spawn(context.Background(), "x")
663+ if err != nil {
664+ t.Fatal(err)
665+ }
666+ stateDir := filepath.Join(root, "agents", a.ID)
667+ if _, err := os.Stat(filepath.Join(stateDir, "meta.json")); err != nil {
668+ t.Fatalf("meta.json missing pre-remove: %v", err)
669+ }
670+ if err := f.Stop(a.ID); err != nil {
671+ t.Fatal(err)
672+ }
673+ a.Wait()
674+ if err := f.Remove(a.ID); err != nil {
675+ t.Fatal(err)
676+ }
677+ if _, err := os.Stat(stateDir); !errors.Is(err, os.ErrNotExist) {
678+ t.Fatalf("state dir should be gone after remove; got %v", err)
679+ }
680+
681+ // A fresh Swarm + Reload should find nothing.
682+ g := New(Config{Root: root, RepoRoot: root, Worktree: MemWorktree(filepath.Join(root, "wt"))})
683+ if loaded, _ := g.Reload(); loaded != 0 {
684+ t.Fatalf("reload after remove loaded=%d; want 0", loaded)
685+ }
686+}
687+
688+// TestActiveSessionScopesSnapshotAll proves the session filter the
689+// user asked for: a single Swarm with agents spawned under two
690+// different active sessions only surfaces the agents matching the
691+// currently-active session via SnapshotAll. Switching the active
692+// session re-narrows the view without touching agent state.
693+func TestActiveSessionScopesSnapshotAll(t *testing.T) {
694+ root := t.TempDir()
695+ f := New(Config{
696+ Root: root, RepoRoot: root,
697+ Worktree: MemWorktree(filepath.Join(root, "wt")),
698+ NewRunner: func(a *Agent) Runner {
699+ return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
700+ },
701+ })
702+
703+ // Spawn one agent under session A and one under session B.
704+ f.SetActiveSession("sess-A")
705+ aA, err := f.Spawn(context.Background(), "task A")
706+ if err != nil {
707+ t.Fatal(err)
708+ }
709+ f.SetActiveSession("sess-B")
710+ aB, err := f.Spawn(context.Background(), "task B")
711+ if err != nil {
712+ t.Fatal(err)
713+ }
714+
715+ // Both agents must carry the session id they were spawned under,
716+ // regardless of any later SetActiveSession.
717+ if aA.SessionID != "sess-A" {
718+ t.Errorf("aA.SessionID = %q; want sess-A", aA.SessionID)
719+ }
720+ if aB.SessionID != "sess-B" {
721+ t.Errorf("aB.SessionID = %q; want sess-B", aB.SessionID)
722+ }
723+
724+ // Active session B → only aB visible.
725+ only := snapshotIDs(f.SnapshotAll())
726+ if len(only) != 1 || only[0] != aB.ID {
727+ t.Errorf("scoped to sess-B, snapshot ids = %v; want [%s]", only, aB.ID)
728+ }
729+
730+ // Switch back to A → only aA visible.
731+ f.SetActiveSession("sess-A")
732+ only = snapshotIDs(f.SnapshotAll())
733+ if len(only) != 1 || only[0] != aA.ID {
734+ t.Errorf("scoped to sess-A, snapshot ids = %v; want [%s]", only, aA.ID)
735+ }
736+
737+ // Clear the scope → both visible.
738+ f.SetActiveSession("")
739+ all := snapshotIDs(f.SnapshotAll())
740+ if len(all) != 2 {
741+ t.Errorf("unscoped snapshot ids = %v; want both agents", all)
742+ }
743+
744+ // Cleanup.
745+ _ = f.Stop(aA.ID)
746+ _ = f.Stop(aB.ID)
747+ aA.Wait()
748+ aB.Wait()
749+}
750+
751+// TestSessionIDPersistsAcrossReload confirms the SessionID field
752+// survives a Swarm restart: an agent spawned under session A in one
753+// Swarm instance is still SessionID="sess-A" after a fresh New +
754+// Reload reads it back from meta.json. Without persistence, the
755+// scope filter would forget which session owned each agent after
756+// a zot restart and the dashboard would leak everything again.
757+func TestSessionIDPersistsAcrossReload(t *testing.T) {
758+ root := t.TempDir()
759+ mkSwarm := func() *Swarm {
760+ return New(Config{
761+ Root: root, RepoRoot: root,
762+ Worktree: MemWorktree(filepath.Join(root, "wt")),
763+ NewRunner: func(a *Agent) Runner {
764+ return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
765+ },
766+ })
767+ }
768+
769+ f := mkSwarm()
770+ f.SetActiveSession("sess-keep")
771+ a, err := f.Spawn(context.Background(), "persist me")
772+ if err != nil {
773+ t.Fatal(err)
774+ }
775+ _ = f.Stop(a.ID)
776+ a.Wait()
777+
778+ // Fresh Swarm, same root, reload from disk.
779+ g := mkSwarm()
780+ if loaded, errs := g.Reload(); loaded != 1 || len(errs) > 0 {
781+ t.Fatalf("reload loaded=%d errs=%v; want 1 / no errs", loaded, errs)
782+ }
783+ g.SetActiveSession("sess-keep")
784+ got := snapshotIDs(g.SnapshotAll())
785+ if len(got) != 1 || got[0] != a.ID {
786+ t.Errorf("after reload + scope to sess-keep, ids = %v; want [%s]", got, a.ID)
787+ }
788+
789+ // Scope to a different session: agent must be hidden.
790+ g.SetActiveSession("sess-other")
791+ if got := snapshotIDs(g.SnapshotAll()); len(got) != 0 {
792+ t.Errorf("scoped to other session, ids = %v; want []", got)
793+ }
794+}
795+
796+// TestEmptySessionIDIsVisibleFromAnyScope pins the backward-compat
797+// rule: agents whose meta.json predates the SessionID field (or who
798+// were spawned without an active session, e.g. via a test rig or
799+// scripted caller) carry SessionID == "" and remain visible from
800+// every scope. Otherwise the schema bump would orphan every
801+// pre-existing agent the moment a user upgraded zot.
802+func TestEmptySessionIDIsVisibleFromAnyScope(t *testing.T) {
803+ root := t.TempDir()
804+ f := New(Config{
805+ Root: root, RepoRoot: root,
806+ Worktree: MemWorktree(filepath.Join(root, "wt")),
807+ NewRunner: func(a *Agent) Runner {
808+ return RunnerFunc(func(ctx context.Context, _ Sink) error { <-ctx.Done(); return ctx.Err() })
809+ },
810+ })
811+ // No SetActiveSession call → agent spawned with empty SessionID.
812+ a, err := f.Spawn(context.Background(), "legacy")
813+ if err != nil {
814+ t.Fatal(err)
815+ }
816+ if a.SessionID != "" {
817+ t.Fatalf("unscoped spawn produced SessionID %q; want empty", a.SessionID)
818+ }
819+
820+ for _, scope := range []string{"", "any-session", "some-other"} {
821+ f.SetActiveSession(scope)
822+ got := snapshotIDs(f.SnapshotAll())
823+ if len(got) != 1 || got[0] != a.ID {
824+ t.Errorf("scope=%q: ids = %v; want legacy agent visible", scope, got)
825+ }
826+ }
827+
828+ _ = f.Stop(a.ID)
829+ a.Wait()
830+}
831+
832+func snapshotIDs(ss []AgentSnapshot) []string {
833+ out := make([]string, len(ss))
834+ for i, s := range ss {
835+ out[i] = s.ID
836+ }
837+ return out
838+}
A internal/swarm/runner.go +339 -0
@@ -0,0 +1,339 @@
1+package swarm
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "fmt"
8+ "io"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strings"
13+ "time"
14+)
15+
16+// execRunner spawns `zot --swarm-agent <inbox> --session <path>` in
17+// the agent's worktree and consumes its JSONL event stream on stdout.
18+//
19+// Why a long-lived daemon and not `zot --print`: the supervisor and
20+// the user expect agents to keep accepting follow-up prompts. A
21+// one-shot subprocess can't do that; this design gives each swarm
22+// agent a persistent session file plus an inbox socket the parent
23+// writes to, mirroring Claude Code's "Agents view" model.
24+//
25+// Events flow:
26+//
27+// child stdout --> decoder --> EventLog (events.jsonl)
28+// --> Sink (Activity/Transcript)
29+//
30+// The on-disk log is the durable record. The Sink updates are an
31+// in-memory mirror so the dashboard doesn't have to tail the file
32+// for the parent's own agents. /swarm open in a separate zot would
33+// read the log directly.
34+type execRunner struct {
35+ agent *Agent
36+
37+ // Command overrides the default `zot --swarm-agent ...`
38+ // invocation. Tests set this to a fake binary (or `go run`
39+ // against a tiny stub program) so the supervisor logic can be
40+ // tested without a real child. Production code leaves it nil.
41+ Command []string
42+
43+ // SessionPath is the agent's session file. When empty the
44+ // runner derives it as <Dir>/.zot/session.json so each agent
45+ // owns its own session inside its worktree.
46+ SessionPath string
47+}
48+
49+// swarmAgentArgsOpts captures every dynamic input to swarmAgentArgs
50+// so future per-agent overrides (e.g. tools, reasoning) can be added
51+// without churning the signature. The fields map 1:1 onto child CLI
52+// flags; empty values omit the flag entirely and let the child
53+// resolve a default the same way a normal `zot` invocation does.
54+type swarmAgentArgsOpts struct {
55+ Exe string
56+ Dir string
57+ SessionPath string
58+ InboxPath string
59+ Task string
60+ Model string
61+ Provider string
62+}
63+
64+// defaultChildArgs builds the argv execRunner uses when its Command
65+// override is empty. Centralised so the spawn-vs-resume decision
66+// (whether to pass the original Task as a positional) lives in one
67+// place that tests can hit directly without going through Run's
68+// side effects.
69+//
70+// On Spawn (Resuming==false) we pass the task so the child's first
71+// turn runs immediately. On Resume (Resuming==true) we omit it: the
72+// child reopens the existing session file, loads the prior
73+// conversation, and just waits on the inbox for the next prompt.
74+// Re-firing the task on every resume produces a duplicate turn that
75+// collides with whatever the user types next, surfacing the
76+// "agent busy; send 'cancel' first" error between two assistant
77+// messages — which is exactly the bug this helper fixes.
78+func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []string {
79+ task := a.Task
80+ if a.Resuming {
81+ task = ""
82+ }
83+ return swarmAgentArgs(swarmAgentArgsOpts{
84+ Exe: exe,
85+ Dir: a.Dir,
86+ SessionPath: sessionPath,
87+ InboxPath: inboxPath,
88+ Task: task,
89+ Model: a.Model,
90+ Provider: a.Provider,
91+ })
92+}
93+
94+// swarmAgentArgs builds the argv used when execRunner.Command is
95+// empty. Pulled out so tests can lock in the flag set without
96+// actually spawning a subprocess.
97+func swarmAgentArgs(opts swarmAgentArgsOpts) []string {
98+ args := []string{
99+ opts.Exe,
100+ "--swarm-agent", opts.InboxPath,
101+ "--session", opts.SessionPath,
102+ "--cwd", opts.Dir,
103+ }
104+ if opts.Model != "" {
105+ args = append(args, "--model", opts.Model)
106+ }
107+ if opts.Provider != "" {
108+ args = append(args, "--provider", opts.Provider)
109+ }
110+ if opts.Task != "" {
111+ // First task is positional so the child treats it as the
112+ // initial user turn; subsequent turns arrive via the inbox.
113+ args = append(args, opts.Task)
114+ }
115+ return args
116+}
117+
118+func (r *execRunner) Run(ctx context.Context, sink Sink) error {
119+ sessionPath := r.SessionPath
120+ if sessionPath == "" {
121+ sessionPath = filepath.Join(r.agent.Dir, ".zot", "session.json")
122+ }
123+ if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
124+ return fmt.Errorf("session dir: %w", err)
125+ }
126+
127+ inboxPath := r.agent.InboxPath
128+ logPath := r.agent.EventLogPath
129+ if logPath == "" {
130+ return fmt.Errorf("swarm: agent missing event log path")
131+ }
132+ log, err := OpenEventLog(logPath)
133+ if err != nil {
134+ return err
135+ }
136+ defer log.Close()
137+
138+ args := r.Command
139+ if len(args) == 0 {
140+ exe, err := os.Executable()
141+ if err != nil {
142+ return fmt.Errorf("locate self: %w", err)
143+ }
144+ args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath)
145+ }
146+
147+ cmd := exec.CommandContext(ctx, args[0], args[1:]...)
148+ cmd.Dir = r.agent.Dir
149+ cmd.Env = append(os.Environ(),
150+ "ZOT_SWARM_AGENT_ID="+r.agent.ID,
151+ "ZOT_SWARM_EVENT_LOG="+logPath,
152+ )
153+
154+ stdout, err := cmd.StdoutPipe()
155+ if err != nil {
156+ return err
157+ }
158+ stderr, err := cmd.StderrPipe()
159+ if err != nil {
160+ return err
161+ }
162+
163+ // "spawning" is briefly shown until the first event arrives;
164+ // the child's "spawned" lifecycle event then overwrites it.
165+ sink.Activity("starting")
166+ if err := cmd.Start(); err != nil {
167+ return err
168+ }
169+
170+ // stdout: parsed as JSONL. Every well-formed event is appended
171+ // to the durable log AND forwarded to the in-memory sink so the
172+ // dashboard updates without having to tail the file. Malformed
173+ // lines are surfaced as plain transcript so they don't vanish.
174+ done := make(chan struct{}, 2)
175+ go func() {
176+ defer func() { done <- struct{}{} }()
177+ dec := bufio.NewReader(stdout)
178+ for {
179+ line, err := dec.ReadBytes('\n')
180+ if len(line) > 0 {
181+ trimmed := strings.TrimRight(string(line), "\r\n")
182+ if trimmed == "" {
183+ goto next
184+ }
185+ if ev, ok := parseEventLine(trimmed); ok {
186+ _ = log.Append(ev)
187+ applyEventToSink(ev, sink)
188+ } else {
189+ // Non-JSON output. Keep it as transcript so an
190+ // accidental fmt.Println in the child still
191+ // shows up in the dashboard, and log a
192+ // lifecycle event so the durable record stays
193+ // in sync.
194+ sink.Transcript(trimmed)
195+ _ = log.Append(NewEvent("stdout", map[string]any{"text": trimmed}))
196+ }
197+ }
198+ next:
199+ if err != nil {
200+ return
201+ }
202+ }
203+ }()
204+
205+ // stderr: lifecycle/error chatter from the child. Every line
206+ // is mirrored as a stderr event in the durable log AND surfaced
207+ // in the transcript so users can diagnose a failing agent
208+ // without leaving the dashboard.
209+ go func() {
210+ defer func() { done <- struct{}{} }()
211+ br := bufio.NewReader(stderr)
212+ for {
213+ line, err := br.ReadString('\n')
214+ if line != "" {
215+ txt := strings.TrimRight(line, "\r\n")
216+ sink.Transcript("stderr: " + txt)
217+ _ = log.Append(NewEvent("stderr", map[string]any{"text": txt}))
218+ }
219+ if err != nil {
220+ return
221+ }
222+ }
223+ }()
224+
225+ <-done
226+ <-done
227+
228+ err = cmd.Wait()
229+ exit := 0
230+ if ee, ok := err.(*exec.ExitError); ok {
231+ exit = ee.ExitCode()
232+ }
233+ if err != nil && ctx.Err() != nil {
234+ _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"}))
235+ return ctx.Err()
236+ }
237+ if err != nil {
238+ _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()}))
239+ return err
240+ }
241+ _ = log.Append(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": 0}))
242+ sink.Activity("done")
243+ return nil
244+}
245+
246+// parseEventLine attempts to decode one JSONL line as an Event.
247+// Returns ok=false for non-JSON or JSON without a "type" field.
248+func parseEventLine(line string) (Event, bool) {
249+ if len(line) == 0 || line[0] != '{' {
250+ return Event{}, false
251+ }
252+ var ev Event
253+ if err := json.Unmarshal([]byte(line), &ev); err != nil {
254+ return Event{}, false
255+ }
256+ if ev.Type == "" {
257+ return Event{}, false
258+ }
259+ if ev.Time.IsZero() {
260+ ev.Time = time.Now()
261+ }
262+ return ev, true
263+}
264+
265+// applyEventToSink translates an Event into Sink updates. Only a
266+// few event types are interpreted; the rest still land in the
267+// durable log via the caller.
268+func applyEventToSink(ev Event, sink Sink) {
269+ switch ev.Type {
270+ case "assistant_message":
271+ if c, ok := ev.Data["content"].([]any); ok {
272+ for _, blk := range c {
273+ m, _ := blk.(map[string]any)
274+ if t, _ := m["type"].(string); t == "text" {
275+ if txt, _ := m["text"].(string); txt != "" {
276+ sink.Transcript(txt)
277+ }
278+ }
279+ }
280+ }
281+ sink.Activity("idle")
282+ case "user_message":
283+ if c, ok := ev.Data["content"].([]any); ok {
284+ for _, blk := range c {
285+ m, _ := blk.(map[string]any)
286+ if t, _ := m["type"].(string); t == "text" {
287+ if txt, _ := m["text"].(string); txt != "" {
288+ sink.Transcript("user: " + txt)
289+ }
290+ }
291+ }
292+ }
293+ case "turn_start":
294+ sink.Activity("thinking")
295+ case "tool_call":
296+ if name, _ := ev.Data["name"].(string); name != "" {
297+ sink.Activity("tool: " + truncate(name, 60))
298+ }
299+ case "tool_result":
300+ sink.Activity("idle")
301+ case "turn_end":
302+ sink.Activity("idle")
303+ case "agent_ready":
304+ sink.Activity("idle")
305+ case "agent_stopped":
306+ // terminal status is decided by Swarm.run from the runner's
307+ // return value, not from this event. Don't overwrite the
308+ // activity here.
309+ case "error":
310+ if msg, _ := ev.Data["message"].(string); msg != "" {
311+ sink.Transcript("error: " + msg)
312+ }
313+ }
314+}
315+
316+// RunnerFunc adapts a plain function into a Runner. Useful for tests
317+// and for callers who don't need their own type.
318+type RunnerFunc func(ctx context.Context, sink Sink) error
319+
320+func (f RunnerFunc) Run(ctx context.Context, sink Sink) error { return f(ctx, sink) }
321+
322+// streamLines kept around for any caller still using it directly.
323+//
324+// Deprecated: the runner now parses JSONL from stdout via
325+// parseEventLine; this helper is unused inside the package but
326+// remains exported via internal use by tests in the runner_test
327+// suite that pre-date the daemon switch.
328+func streamLines(r io.Reader, fn func(string)) {
329+ br := bufio.NewReader(r)
330+ for {
331+ line, err := br.ReadString('\n')
332+ if line != "" {
333+ fn(strings.TrimRight(line, "\r\n"))
334+ }
335+ if err != nil {
336+ return
337+ }
338+ }
339+}
A internal/swarm/runner_e2e_test.go +194 -0
@@ -0,0 +1,194 @@
1+package swarm
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "runtime"
10+ "strings"
11+ "testing"
12+ "time"
13+)
14+
15+// TestRunnerEndToEndWithStubChild is the integration test for the
16+// new daemon-mode runner. It compiles the stubchild binary in
17+// testdata/cmd/stubchild, points an execRunner at it, and drives
18+// a Swarm through one Spawn + one SendUserTurn + Stop cycle.
19+//
20+// What this proves:
21+//
22+// - The default argv shape (swarmAgentArgs) is one the child can
23+// actually parse — locks the shape against silent breakage.
24+// - The stdout JSONL parser ingests events and writes them to the
25+// durable log.
26+// - applyEventToSink turns events into Activity / Transcript
27+// updates the dashboard reads.
28+// - The supervisor inbox dials the child's socket and a follow-up
29+// line round-trips back as a user_message event.
30+// - Stop closes the inbox AND cancels the child's context so the
31+// stub exits cleanly.
32+//
33+// The test is skipped on platforms without unix sockets (Windows).
34+func TestRunnerEndToEndWithStubChild(t *testing.T) {
35+ if runtime.GOOS == "windows" {
36+ t.Skip("unix sockets not supported")
37+ }
38+ if testing.Short() {
39+ t.Skip("skip end-to-end runner test in -short mode")
40+ }
41+
42+ exe := buildStubChild(t)
43+
44+ root := t.TempDir()
45+ repo := t.TempDir()
46+ f := New(Config{
47+ Root: root,
48+ RepoRoot: repo,
49+ Worktree: MemWorktree(filepath.Join(root, "wt")),
50+ NewRunner: func(a *Agent) Runner {
51+ return &execRunner{
52+ agent: a,
53+ Command: swarmAgentArgs(swarmAgentArgsOpts{
54+ Exe: exe,
55+ Dir: a.Dir,
56+ SessionPath: a.SessionPath,
57+ InboxPath: a.InboxPath,
58+ Task: a.Task,
59+ Model: a.Model,
60+ Provider: a.Provider,
61+ }),
62+ }
63+ },
64+ })
65+ defer f.StopAll()
66+
67+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
68+ defer cancel()
69+
70+ a, err := f.Spawn(ctx, "first task")
71+ if err != nil {
72+ t.Fatalf("Spawn: %v", err)
73+ }
74+
75+ // Wait until the durable log has at least one assistant_message
76+ // from the initial task. That confirms stdout→log→follower.
77+ waitFor := func(want string) {
78+ t.Helper()
79+ deadline := time.Now().Add(5 * time.Second)
80+ for time.Now().Before(deadline) {
81+ evs, _ := ReadEventLog(a.EventLogPath)
82+ for _, ev := range evs {
83+ if strings.Contains(eventText(ev), want) {
84+ return
85+ }
86+ }
87+ time.Sleep(20 * time.Millisecond)
88+ }
89+ evs, _ := ReadEventLog(a.EventLogPath)
90+ t.Fatalf("timed out waiting for %q in event log; got %d events:\n%s\n%s",
91+ want, len(evs), formatEvents(evs), dumpEventsVerbose(evs))
92+ }
93+ waitFor("echo: first task")
94+
95+ // Send a follow-up over the inbox. The stub echoes the text into
96+ // an assistant_message we can poll for in the log.
97+ if err := retrySend(f, a.ID, "user follow up", time.Second); err != nil {
98+ t.Fatalf("SendInput: %v", err)
99+ }
100+ waitFor("echo: follow up")
101+
102+ // Shut the agent down gracefully via the inbox.
103+ if err := f.SendInput(a.ID, "shutdown"); err != nil && !errors.Is(err, ErrNotReady) {
104+ t.Fatalf("shutdown send: %v", err)
105+ }
106+ a.Wait()
107+ if got := a.Status(); got != StatusDone && got != StatusKilled {
108+ t.Fatalf("final status = %s; want done/killed", got)
109+ }
110+}
111+
112+// retrySend exists because the inbox dial races against the child
113+// opening the socket. Production callers handle ErrNotReady with a
114+// status message; tests retry within a small window.
115+func retrySend(f *Swarm, id, msg string, timeout time.Duration) error {
116+ deadline := time.Now().Add(timeout)
117+ var lastErr error
118+ for time.Now().Before(deadline) {
119+ err := f.SendInput(id, msg)
120+ if err == nil {
121+ return nil
122+ }
123+ lastErr = err
124+ if !errors.Is(err, ErrNotReady) {
125+ return err
126+ }
127+ time.Sleep(30 * time.Millisecond)
128+ }
129+ return lastErr
130+}
131+
132+func eventText(ev Event) string {
133+ if ev.Type != "assistant_message" && ev.Type != "user_message" {
134+ return ""
135+ }
136+ content, _ := ev.Data["content"].([]any)
137+ var sb strings.Builder
138+ for _, c := range content {
139+ m, _ := c.(map[string]any)
140+ if t, _ := m["type"].(string); t == "text" {
141+ if txt, _ := m["text"].(string); txt != "" {
142+ sb.WriteString(txt)
143+ sb.WriteByte('\n')
144+ }
145+ }
146+ }
147+ return sb.String()
148+}
149+
150+func dumpEventsVerbose(evs []Event) string {
151+ var sb strings.Builder
152+ for _, ev := range evs {
153+ sb.WriteString(ev.Type)
154+ sb.WriteString("\t")
155+ for k, v := range ev.Data {
156+ sb.WriteString(k)
157+ sb.WriteString("=")
158+ switch vv := v.(type) {
159+ case string:
160+ sb.WriteString(vv)
161+ default:
162+ sb.WriteString("<...>")
163+ }
164+ sb.WriteString(" ")
165+ }
166+ sb.WriteString("\n")
167+ }
168+ return sb.String()
169+}
170+
171+func formatEvents(evs []Event) string {
172+ var sb strings.Builder
173+ for _, ev := range evs {
174+ sb.WriteString(ev.Type)
175+ sb.WriteString(" ")
176+ sb.WriteString(ev.Time.Format(time.RFC3339Nano))
177+ sb.WriteString("\n")
178+ }
179+ return sb.String()
180+}
181+
182+func buildStubChild(t *testing.T) string {
183+ t.Helper()
184+ out := filepath.Join(t.TempDir(), "stubchild")
185+ cmd := exec.Command("go", "build", "-o", out, "./testdata/cmd/stubchild")
186+ // Pass through the test runner's env so `go build` can find
187+ // HOME, PATH, GOCACHE, etc. CGO is disabled to keep the build
188+ // hermetic across machines.
189+ cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
190+ if b, err := cmd.CombinedOutput(); err != nil {
191+ t.Fatalf("build stubchild: %v\n%s", err, b)
192+ }
193+ return out
194+}
A internal/swarm/runner_test.go +140 -0
@@ -0,0 +1,140 @@
1+package swarm
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// TestSwarmAgentArgs locks in the exact flag set the subprocess
9+// runner uses to start a swarm agent in daemon mode. Past
10+// regressions in this area:
11+//
12+// - "--no-sess" instead of "--no-session" (old print-mode runner):
13+// every spawned agent died with "unknown flag" before it could
14+// talk to the model.
15+//
16+// - Forgetting --cwd: the child resolved tools against the parent
17+// zot's working directory, defeating the whole point of the
18+// worktree isolation.
19+//
20+// - Forgetting --session: a daemon-mode agent without a session
21+// file would lose context between follow-up turns, making
22+// "send another message" mostly useless.
23+//
24+// The test asserts the load-bearing pieces are present in plausible
25+// positions. If a flag is renamed, update both the runner and this
26+// test so we notice immediately.
27+func TestSwarmAgentArgs(t *testing.T) {
28+ args := swarmAgentArgs(swarmAgentArgsOpts{
29+ Exe: "/path/to/zot",
30+ Dir: "/tmp/worktree",
31+ SessionPath: "/tmp/state/session.json",
32+ InboxPath: "/tmp/state/in.sock",
33+ Task: "do the thing",
34+ })
35+ if len(args) < 7 {
36+ t.Fatalf("argv unexpectedly short: %v", args)
37+ }
38+ if args[0] != "/path/to/zot" {
39+ t.Fatalf("argv[0] = %q; want the binary path", args[0])
40+ }
41+ // The task must come last so anything that looks flag-like in
42+ // the task body doesn't get interpreted as a flag.
43+ if args[len(args)-1] != "do the thing" {
44+ t.Fatalf("task should be last positional; got %v", args)
45+ }
46+
47+ mustHave := map[string]string{
48+ "--swarm-agent": "/tmp/state/in.sock",
49+ "--session": "/tmp/state/session.json",
50+ "--cwd": "/tmp/worktree",
51+ }
52+ for flag, value := range mustHave {
53+ i := indexOf(args, flag)
54+ if i < 0 {
55+ t.Errorf("argv missing %q: %v", flag, args)
56+ continue
57+ }
58+ if i+1 >= len(args) || args[i+1] != value {
59+ t.Errorf("argv %q value = %q; want %q", flag, safeAt(args, i+1), value)
60+ }
61+ }
62+
63+ // Reject prior bad flags explicitly so a future revert is caught.
64+ joined := strings.Join(args, " ")
65+ for _, bad := range []string{"--print", "--no-sess ", "--no-session"} {
66+ if strings.Contains(joined, bad) {
67+ t.Fatalf("argv contains stale/wrong flag %q: %s", bad, joined)
68+ }
69+ }
70+}
71+
72+// TestSwarmAgentArgsEmptyTaskOmitsPositional makes sure that when the
73+// agent is being adopted (no fresh task) we don't pass an empty
74+// positional which the arg parser would treat as a real prompt.
75+func TestSwarmAgentArgsEmptyTaskOmitsPositional(t *testing.T) {
76+ args := swarmAgentArgs(swarmAgentArgsOpts{
77+ Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock",
78+ })
79+ for _, a := range args {
80+ if a == "" {
81+ t.Fatalf("argv contains an empty positional: %v", args)
82+ }
83+ }
84+ // last arg should be a real flag value, not a stray positional
85+ if a := args[len(args)-1]; strings.HasPrefix(a, "--") {
86+ t.Fatalf("argv ends on a flag with no value: %v", args)
87+ }
88+}
89+
90+// TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a
91+// fresh (non-resuming) Agent produces argv that ends with the
92+// original task as a positional, so the child runs it as the
93+// initial user turn.
94+func TestDefaultChildArgsSpawnIncludesTask(t *testing.T) {
95+ a := &Agent{Dir: "/wt", Task: "do thing"}
96+ args := defaultChildArgs("/zot", a, "/s.json", "/in.sock")
97+ if got := args[len(args)-1]; got != "do thing" {
98+ t.Fatalf("spawn argv last = %q; want %q\n%v", got, "do thing", args)
99+ }
100+}
101+
102+// TestDefaultChildArgsResumeOmitsTask is the regression for the
103+// "agent busy; send 'cancel' first" error: when an Agent is being
104+// resumed (Resuming==true), the child argv MUST NOT include the
105+// original Task as a positional. Otherwise the child fires the task
106+// as a fresh user turn on every resume, racing with whatever the
107+// user types next via the inbox.
108+func TestDefaultChildArgsResumeOmitsTask(t *testing.T) {
109+ a := &Agent{Dir: "/wt", Task: "do thing", Resuming: true}
110+ args := defaultChildArgs("/zot", a, "/s.json", "/in.sock")
111+ for _, v := range args {
112+ if v == "do thing" {
113+ t.Fatalf("resume argv contains the task; it would re-fire as a duplicate turn\n%v", args)
114+ }
115+ }
116+ // And no trailing positional at all: the last arg should be a
117+ // flag value, not a stray empty string.
118+ if got := args[len(args)-1]; got == "" {
119+ t.Fatalf("resume argv ends with an empty positional: %v", args)
120+ }
121+ if strings.HasPrefix(args[len(args)-1], "--") {
122+ t.Fatalf("resume argv ends on a bare flag (no value): %v", args)
123+ }
124+}
125+
126+func indexOf(xs []string, want string) int {
127+ for i, x := range xs {
128+ if x == want {
129+ return i
130+ }
131+ }
132+ return -1
133+}
134+
135+func safeAt(xs []string, i int) string {
136+ if i < 0 || i >= len(xs) {
137+ return ""
138+ }
139+ return xs[i]
140+}
A internal/swarm/socketpath.go +69 -0
@@ -0,0 +1,69 @@
1+package swarm
2+
3+import (
4+ "crypto/sha1"
5+ "encoding/hex"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "runtime"
10+)
11+
12+// maxUnixSocketPath is the conservative platform-portable path limit
13+// for unix sockets. macOS allows 104, linux 108 (including the NUL
14+// terminator). We pick 100 so the path itself plus a small filename
15+// tail stays under both caps with a safety margin.
16+const maxUnixSocketPath = 100
17+
18+// inboxSocketPath returns a per-agent unix-socket path that's short
19+// enough to actually work (see maxUnixSocketPath) and unique per
20+// swarm root so two zot instances on the same machine don't collide.
21+//
22+// Strategy:
23+//
24+// 1. Try <root>/agents/<id>/in.sock. This is the obvious place and
25+// puts everything next to the durable state; on most setups it
26+// fits.
27+// 2. If that's too long, fall back to <tmp>/zot-swarm-<roothash>/<id>.sock.
28+// We hash root rather than embedding it so the tmp directory name
29+// stays short. SHA-1's first 8 hex chars is plenty: collisions
30+// only matter within a single user's tmp dir and we already
31+// create a dedicated subdir.
32+// 3. If even /tmp is somehow too long (chroots, containers), give
33+// up with a clear error so the caller surfaces it instead of
34+// leaving the user wondering why follow-ups don't work.
35+func inboxSocketPath(root, agentID string) (string, error) {
36+ primary := filepath.Join(root, "agents", agentID, "in.sock")
37+ if len(primary) <= maxUnixSocketPath {
38+ return primary, nil
39+ }
40+ tmp := os.TempDir()
41+ dir := filepath.Join(tmp, "zot-swarm-"+rootTag(root))
42+ if err := os.MkdirAll(dir, 0o700); err != nil {
43+ return "", fmt.Errorf("socket tmp dir: %w", err)
44+ }
45+ candidate := filepath.Join(dir, agentID+".sock")
46+ if len(candidate) <= maxUnixSocketPath {
47+ return candidate, nil
48+ }
49+ // Last-resort: use just the short hash of the id so even very long
50+ // task slugs fit. We surface the original id in the meta.json /
51+ // events log; the socket path is purely transport.
52+ short := shortHash(agentID)
53+ candidate = filepath.Join(dir, short+".sock")
54+ if len(candidate) <= maxUnixSocketPath {
55+ return candidate, nil
56+ }
57+ return "", fmt.Errorf("unix socket path too long even after shortening (%s, %d > %d, GOOS=%s)",
58+ candidate, len(candidate), maxUnixSocketPath, runtime.GOOS)
59+}
60+
61+// rootTag returns a stable 8-hex-char tag for the swarm root. Used
62+// in the tmp-dir name so two parallel zot instances with different
63+// roots don't share sockets.
64+func rootTag(root string) string { return shortHash(root) }
65+
66+func shortHash(s string) string {
67+ sum := sha1.Sum([]byte(s))
68+ return hex.EncodeToString(sum[:4])
69+}
A internal/swarm/swarm.go +527 -0
@@ -0,0 +1,527 @@
1+// Package swarm implements zot's multi-agent supervisor.
2+//
3+// A Swarm manages a set of headless zot subprocesses ("agents")
4+// working in their own git worktrees. The interactive TUI exposes
5+// the supervisor through the /swarm slash command and a dashboard
6+// dialog; non-TUI code can drive it directly through this package.
7+//
8+// Each Agent has:
9+// - a unique id (short slug + nanoseconds)
10+// - a branch name on a fresh git worktree under <root>/swarm/<id>
11+// - a Runner (the thing that actually executes the task)
12+// - a Status string + Activity string that the dashboard reads
13+//
14+// The Runner abstraction means tests can swap a fake in instead of
15+// really spawning a subprocess; the production Runner shells out to
16+// `zot --print` so we reuse zot's own model resolution and tooling
17+// without re-implementing the agent loop.
18+package swarm
19+
20+import (
21+ "context"
22+ "errors"
23+ "fmt"
24+ "os"
25+ "path/filepath"
26+ "sort"
27+ "strings"
28+ "sync"
29+ "time"
30+)
31+
32+// Status is the high-level lifecycle state of an Agent.
33+type Status string
34+
35+const (
36+ StatusPending Status = "pending" // created, not started yet
37+ StatusRunning Status = "running" // Runner.Run is in flight
38+ StatusDone Status = "done" // Runner.Run returned nil
39+ StatusFailed Status = "failed" // Runner.Run returned an error
40+ StatusKilled Status = "killed" // Stop() called before completion
41+ StatusDetached Status = "detached" // reloaded from disk; no live runner
42+)
43+
44+// Config configures a Swarm.
45+type Config struct {
46+ // Root is the directory under which worktrees + state files live.
47+ // Typically <ZotHome>/swarm, but tests pass a tempdir.
48+ Root string
49+
50+ // RepoRoot is the path to the user's git repo (CWD of the parent
51+ // zot). New worktrees branch off this repo.
52+ RepoRoot string
53+
54+ // Worktree creates the per-agent working directory. If nil, the
55+ // default git-worktree implementation is used.
56+ Worktree WorktreeManager
57+
58+ // NewRunner produces the Runner for an Agent. If nil, the default
59+ // `zot --print` exec runner is used. Tests inject a fake here.
60+ NewRunner func(a *Agent) Runner
61+
62+ // Now is a clock seam for tests; defaults to time.Now.
63+ Now func() time.Time
64+}
65+
66+// WorktreeManager creates and removes per-agent working directories.
67+type WorktreeManager interface {
68+ Create(id, branch, base string) (dir string, err error)
69+ Remove(id, dir string) error
70+}
71+
72+// Runner executes one agent task. Run blocks until the task finishes,
73+// is cancelled via ctx, or hits an unrecoverable error.
74+//
75+// Run should report progress by writing short human-readable strings
76+// to the activity channel and final transcript text to transcript.
77+// Both channels are non-blocking sinks owned by the Swarm; if the
78+// dashboard isn't reading, sends are dropped.
79+type Runner interface {
80+ Run(ctx context.Context, sink Sink) error
81+}
82+
83+// Sink is how a Runner reports activity and transcript back to the
84+// supervisor. All methods are safe to call from any goroutine and
85+// never block.
86+type Sink interface {
87+ // Activity sets the one-line "what is this agent doing right now"
88+ // string shown in the dashboard.
89+ Activity(msg string)
90+ // Transcript appends a chunk of agent output (typically a final
91+ // assistant message) to the agent's running transcript.
92+ Transcript(chunk string)
93+}
94+
95+// Swarm supervises a set of Agents.
96+type Swarm struct {
97+ cfg Config
98+
99+ mu sync.Mutex
100+ agents map[string]*Agent
101+ order []string // creation order for stable listing
102+
103+ // activeSession is the host session id the dashboard is
104+ // currently scoped to. When non-empty, SnapshotAll filters out
105+ // agents whose SessionID doesn't match (legacy / unscoped
106+ // agents with SessionID == "" are always shown). Spawn stamps
107+ // new agents with this value so they appear only in the session
108+ // that created them. When empty (the default), the historical
109+ // "show everything" behaviour is preserved — important for
110+ // tests and any scripted use of the Swarm that doesn't bother
111+ // with sessions.
112+ activeSession string
113+}
114+
115+// New constructs a Swarm from cfg. Missing config fields are filled
116+// with defaults.
117+func New(cfg Config) *Swarm {
118+ if cfg.Now == nil {
119+ cfg.Now = time.Now
120+ }
121+ if cfg.Worktree == nil {
122+ cfg.Worktree = &gitWorktree{root: filepath.Join(cfg.Root, "worktrees"), repo: cfg.RepoRoot}
123+ }
124+ if cfg.NewRunner == nil {
125+ cfg.NewRunner = func(a *Agent) Runner { return &execRunner{agent: a} }
126+ }
127+ return &Swarm{
128+ cfg: cfg,
129+ agents: map[string]*Agent{},
130+ }
131+}
132+
133+// SetActiveSession scopes the dashboard view (and Spawn stamping)
134+// to a particular host zot session id. Pass empty to clear the
135+// scope and revert to "show every agent" (the original behaviour).
136+//
137+// Existing in-memory agents keep their SessionID; only the filter
138+// applied at snapshot time changes. So swapping the active session
139+// with /sessions instantly re-narrows the dashboard without
140+// touching any agent state.
141+func (f *Swarm) SetActiveSession(id string) {
142+ f.mu.Lock()
143+ defer f.mu.Unlock()
144+ f.activeSession = id
145+}
146+
147+// ActiveSession returns the current scope, mostly for tests and
148+// diagnostics. Empty means "no scope; show everything".
149+func (f *Swarm) ActiveSession() string {
150+ f.mu.Lock()
151+ defer f.mu.Unlock()
152+ return f.activeSession
153+}
154+
155+// agentStateDir is the per-agent state directory laid out as:
156+//
157+// <root>/agents/<id>/
158+// events.jsonl durable event log (runner-owned)
159+// in.sock unix socket inbox (child-owned)
160+// session.json persistent agent session (child-owned)
161+// meta.json static metadata (id, task, branch, dir)
162+func (f *Swarm) agentStateDir(id string) string {
163+ return filepath.Join(f.cfg.Root, "agents", id)
164+}
165+
166+// SpawnRequest configures a Spawn. Only Task is required; the rest
167+// are optional. Model + Provider, when set, get baked into the
168+// child argv as --model / --provider so the agent runs against the
169+// chosen model regardless of the parent's current selection.
170+type SpawnRequest struct {
171+ Task string
172+ Model string // optional override; child resolves default if empty
173+ Provider string // optional override; usually paired with Model
174+}
175+
176+// Spawn creates a new Agent for the given task, sets up its worktree,
177+// allocates the on-disk state directory (events log, inbox socket
178+// path, session file path), and starts the Runner on a background
179+// goroutine. The returned Agent is already in StatusRunning (or
180+// StatusFailed if worktree setup failed before the goroutine
181+// started). This is the historical signature; callers that want to
182+// override the child's model use SpawnReq instead.
183+func (f *Swarm) Spawn(ctx context.Context, task string) (*Agent, error) {
184+ return f.SpawnReq(ctx, SpawnRequest{Task: task})
185+}
186+
187+// SpawnReq is the full-fat variant of Spawn that accepts a
188+// SpawnRequest. Existing callers can keep using Spawn; new code that
189+// wants to pin the child's model uses this.
190+func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) {
191+ task := strings.TrimSpace(req.Task)
192+ if task == "" {
193+ return nil, errors.New("swarm: empty task")
194+ }
195+ id := newAgentID(task, f.cfg.Now())
196+ branch := "swarm/" + id
197+ dir, err := f.cfg.Worktree.Create(id, branch, f.cfg.RepoRoot)
198+ if err != nil {
199+ return nil, fmt.Errorf("worktree create: %w", err)
200+ }
201+
202+ stateDir := f.agentStateDir(id)
203+ if err := os.MkdirAll(stateDir, 0o755); err != nil {
204+ return nil, fmt.Errorf("swarm state dir: %w", err)
205+ }
206+ logPath := filepath.Join(stateDir, "events.jsonl")
207+ sessionPath := filepath.Join(stateDir, "session.json")
208+ // Unix sockets have a hard 104-byte path limit on darwin and 108
209+ // on linux. Long ZOT_HOME paths plus an agent slug blow that cap
210+ // quickly. Pick the shortest path that still keeps sockets
211+ // per-swarm-root so two zot instances on the same machine don't
212+ // collide. inboxSocketPath falls back from $TMPDIR to /tmp if
213+ // neither is short enough.
214+ inboxPath, err := inboxSocketPath(f.cfg.Root, id)
215+ if err != nil {
216+ return nil, fmt.Errorf("swarm inbox path: %w", err)
217+ }
218+
219+ // Snapshot activeSession under the lock; we use it twice (struct
220+ // init below + persistence). Reading it without the lock would
221+ // race a concurrent SetActiveSession call.
222+ f.mu.Lock()
223+ sessionID := f.activeSession
224+ f.mu.Unlock()
225+
226+ a := &Agent{
227+ ID: id,
228+ Task: task,
229+ Branch: branch,
230+ Dir: dir,
231+ Started: f.cfg.Now(),
232+ Model: strings.TrimSpace(req.Model),
233+ Provider: strings.TrimSpace(req.Provider),
234+ SessionID: sessionID,
235+ InboxPath: inboxPath,
236+ EventLogPath: logPath,
237+ SessionPath: sessionPath,
238+ inbox: NewInbox(inboxPath),
239+ status: StatusPending,
240+ activity: "queued",
241+ done: make(chan struct{}),
242+ }
243+ a.ctx, a.cancel = context.WithCancel(ctx)
244+ a.runner = f.cfg.NewRunner(a)
245+
246+ f.mu.Lock()
247+ f.agents[id] = a
248+ f.order = append(f.order, id)
249+ f.mu.Unlock()
250+
251+ // Persist the agent's identity so a later `zot` invocation can
252+ // reload it from disk via Swarm.Reload. Best-effort: if the disk
253+ // is read-only we still let the runner start, the user just won't
254+ // see this agent on the next launch.
255+ _ = writeAgentMeta(stateDir, a)
256+
257+ go f.run(a)
258+ return a, nil
259+}
260+
261+// SendInput delivers msg to the agent's inbox. msg should already
262+// include the leading kind ("user " + text, "cancel", "shutdown").
263+// Returns an error wrapping ErrNotReady if the child hasn't opened
264+// its listener yet; the caller can retry or surface the wait.
265+func (f *Swarm) SendInput(id, msg string) error {
266+ a := f.Get(id)
267+ if a == nil {
268+ return fmt.Errorf("no such agent %q", id)
269+ }
270+ if a.inbox == nil {
271+ return fmt.Errorf("agent %s has no inbox", a.ID)
272+ }
273+ return a.inbox.SendInput(msg)
274+}
275+
276+// SendUserTurn is sugar for the common "send the next user turn"
277+// case. It quotes nothing and forwards verbatim; callers are
278+// expected to have already trimmed and expanded the text.
279+func (f *Swarm) SendUserTurn(id, text string) error {
280+ return f.SendInput(id, "user "+text)
281+}
282+
283+func (f *Swarm) run(a *Agent) {
284+ a.setStatus(StatusRunning)
285+ a.setActivity("starting")
286+ err := a.runner.Run(a.ctx, agentSink{a: a})
287+ a.mu.Lock()
288+ a.finished = f.cfg.Now()
289+ switch {
290+ case a.status == StatusKilled:
291+ // Already finalised by Stop.
292+ case errors.Is(err, context.Canceled):
293+ a.status = StatusKilled
294+ a.activity = "cancelled"
295+ case err != nil:
296+ a.status = StatusFailed
297+ a.activity = "error: " + truncate(err.Error(), 120)
298+ a.lastErr = err
299+ default:
300+ a.status = StatusDone
301+ a.activity = "done"
302+ }
303+ a.mu.Unlock()
304+ close(a.done)
305+}
306+
307+// List returns a snapshot of every agent in creation order. The
308+// returned slice is a copy; callers may iterate without holding the
309+// swarm lock. Agent fields are read under their own mutex during
310+// formatting in Snapshot.
311+func (f *Swarm) List() []*Agent {
312+ f.mu.Lock()
313+ defer f.mu.Unlock()
314+ out := make([]*Agent, 0, len(f.order))
315+ for _, id := range f.order {
316+ out = append(out, f.agents[id])
317+ }
318+ return out
319+}
320+
321+// Get returns the agent with the given (possibly truncated) id, or
322+// nil. Matching is prefix-based so the user can type the first few
323+// characters of a long id.
324+func (f *Swarm) Get(id string) *Agent {
325+ f.mu.Lock()
326+ defer f.mu.Unlock()
327+ if a, ok := f.agents[id]; ok {
328+ return a
329+ }
330+ // Prefix match.
331+ var hits []*Agent
332+ for k, a := range f.agents {
333+ if strings.HasPrefix(k, id) {
334+ hits = append(hits, a)
335+ }
336+ }
337+ if len(hits) == 1 {
338+ return hits[0]
339+ }
340+ return nil
341+}
342+
343+// Stop cancels the agent's context. The Runner should observe the
344+// cancellation and return promptly; the goroutine then finalises the
345+// agent in StatusKilled. Also closes the supervisor-side inbox handle
346+// so any pending SendInput retries fail fast instead of dialing a
347+// socket about to be unlinked.
348+//
349+// Stop is a no-op for any agent that's not in a live runnable state
350+// — Done / Failed / Killed (already finalised) and Detached (no
351+// in-process runner; reloaded from disk). Calling Stop on a detached
352+// agent must not crash: buildDetachedAgent doesn't allocate a
353+// context/cancel pair because there's nothing to cancel.
354+func (f *Swarm) Stop(id string) error {
355+ a := f.Get(id)
356+ if a == nil {
357+ return fmt.Errorf("no such agent %q", id)
358+ }
359+ a.mu.Lock()
360+ switch a.status {
361+ case StatusDone, StatusFailed, StatusKilled, StatusDetached:
362+ a.mu.Unlock()
363+ return nil
364+ }
365+ a.status = StatusKilled
366+ a.activity = "stopped"
367+ a.mu.Unlock()
368+ // Belt-and-braces guard against the cancel-less case (detached
369+ // agents skip this branch above, but a future code path that
370+ // builds an Agent without a runner shouldn't crash the
371+ // supervisor).
372+ if a.cancel != nil {
373+ a.cancel()
374+ }
375+ if a.inbox != nil {
376+ _ = a.inbox.Close()
377+ }
378+ return nil
379+}
380+
381+// StopAll cancels every running agent. Used on shutdown.
382+func (f *Swarm) StopAll() {
383+ for _, a := range f.List() {
384+ _ = f.Stop(a.ID)
385+ }
386+}
387+
388+// Remove tears down the worktree for a terminated agent. It is an
389+// error to remove an agent that's still running; call Stop first and
390+// wait for the status to settle. Detached agents (reloaded from
391+// disk) remove cleanly because they have no live runner racing for
392+// the same files.
393+func (f *Swarm) Remove(id string) error {
394+ a := f.Get(id)
395+ if a == nil {
396+ return fmt.Errorf("no such agent %q", id)
397+ }
398+ a.mu.Lock()
399+ st := a.status
400+ dir := a.Dir
401+ a.mu.Unlock()
402+ if st == StatusRunning || st == StatusPending {
403+ return fmt.Errorf("agent %s still %s", a.ID, st)
404+ }
405+ if err := f.cfg.Worktree.Remove(a.ID, dir); err != nil {
406+ return err
407+ }
408+ // Best-effort cleanup of the per-agent state directory (meta.json,
409+ // events.jsonl, session.json, in.sock if it's local). Worktree
410+ // removal already succeeded; failing here would leave the user
411+ // with no recourse, so swallow the error.
412+ _ = os.RemoveAll(f.agentStateDir(a.ID))
413+ f.mu.Lock()
414+ delete(f.agents, a.ID)
415+ for i, k := range f.order {
416+ if k == a.ID {
417+ f.order = append(f.order[:i], f.order[i+1:]...)
418+ break
419+ }
420+ }
421+ f.mu.Unlock()
422+ return nil
423+}
424+
425+// Snapshot returns a read-only view of one agent. Safe for the TUI
426+// goroutine to call repeatedly; never blocks on the Runner.
427+type AgentSnapshot struct {
428+ ID string
429+ Task string
430+ Branch string
431+ Dir string
432+ Status Status
433+ Activity string
434+ Started time.Time
435+ Finished time.Time
436+ Err string
437+ Tail string // last few transcript lines, joined with "\n"
438+ Lines []string // full transcript (already capped by Agent.appendTranscript)
439+
440+ // Model and Provider expose the per-agent overrides set at
441+ // Spawn time (empty when the agent inherits the child's default
442+ // resolution). The dashboard surfaces these so the user can
443+ // confirm which model an agent is running against.
444+ Model string
445+ Provider string
446+
447+ // Paths to the agent's durable state. Surface them in the
448+ // snapshot so the dashboard / /swarm open can read events.jsonl
449+ // or resume the session without going back through the Agent.
450+ InboxPath string
451+ EventLogPath string
452+ SessionPath string
453+}
454+
455+// Snapshot copies the live agent state into a value the caller can
456+// inspect at leisure.
457+func (a *Agent) Snapshot() AgentSnapshot {
458+ a.mu.Lock()
459+ defer a.mu.Unlock()
460+ tail := strings.Join(lastN(a.transcript, 6), "\n")
461+ lines := make([]string, len(a.transcript))
462+ copy(lines, a.transcript)
463+ errStr := ""
464+ if a.lastErr != nil {
465+ errStr = a.lastErr.Error()
466+ }
467+ return AgentSnapshot{
468+ ID: a.ID, Task: a.Task, Branch: a.Branch, Dir: a.Dir,
469+ Status: a.status, Activity: a.activity,
470+ Started: a.Started, Finished: a.finished,
471+ Err: errStr, Tail: tail, Lines: lines,
472+ Model: a.Model,
473+ Provider: a.Provider,
474+ InboxPath: a.InboxPath,
475+ EventLogPath: a.EventLogPath,
476+ SessionPath: a.SessionPath,
477+ }
478+}
479+
480+// SnapshotAll returns snapshots of every agent in creation order,
481+// scoped to the active session when one is set.
482+//
483+// Scoping rules:
484+// - activeSession == "": no filter, every agent is returned
485+// (historical behaviour; used by tests and scripted callers).
486+// - activeSession != "": include only agents whose SessionID
487+// matches activeSession OR is empty. The empty-id pass-through
488+// keeps pre-upgrade agents (their meta.json was written before
489+// session_id existed) visible from any session so the user
490+// doesn't lose access after the schema bump.
491+func (f *Swarm) SnapshotAll() []AgentSnapshot {
492+ agents := f.List()
493+ f.mu.Lock()
494+ active := f.activeSession
495+ f.mu.Unlock()
496+
497+ out := make([]AgentSnapshot, 0, len(agents))
498+ for _, a := range agents {
499+ if active != "" && a.SessionID != "" && a.SessionID != active {
500+ continue
501+ }
502+ out = append(out, a.Snapshot())
503+ }
504+ // Sort by start time for a stable, deterministic listing.
505+ sort.SliceStable(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) })
506+ return out
507+}
508+
509+// agentSink is the Sink the Swarm hands to each Runner.
510+type agentSink struct{ a *Agent }
511+
512+func (s agentSink) Activity(msg string) { s.a.setActivity(msg) }
513+func (s agentSink) Transcript(chunk string) { s.a.appendTranscript(chunk) }
514+
515+func truncate(s string, n int) string {
516+ if len(s) <= n {
517+ return s
518+ }
519+ return s[:n-1] + "…"
520+}
521+
522+func lastN(lines []string, n int) []string {
523+ if len(lines) <= n {
524+ return lines
525+ }
526+ return lines[len(lines)-n:]
527+}
A internal/swarm/swarm_test.go +239 -0
@@ -0,0 +1,239 @@
1+package swarm
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "strings"
8+ "sync"
9+ "testing"
10+ "time"
11+)
12+
13+// newTestSwarm builds a Swarm rooted in t.TempDir with the in-memory
14+// worktree and a Runner factory the test controls. Returns the swarm
15+// plus a slice of runners keyed by spawn order so tests can assert
16+// they were actually invoked.
17+func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm {
18+ t.Helper()
19+ root := t.TempDir()
20+ return New(Config{
21+ Root: root,
22+ RepoRoot: root,
23+ Worktree: MemWorktree(filepath.Join(root, "worktrees")),
24+ NewRunner: mk,
25+ })
26+}
27+
28+func TestSpawnRunsAndCompletes(t *testing.T) {
29+ ran := make(chan string, 1)
30+ f := newTestSwarm(t, func(a *Agent) Runner {
31+ return RunnerFunc(func(ctx context.Context, sink Sink) error {
32+ ran <- a.Task
33+ sink.Activity("hello")
34+ sink.Transcript("line one")
35+ sink.Transcript("line two")
36+ return nil
37+ })
38+ })
39+ a, err := f.Spawn(context.Background(), "do a thing")
40+ if err != nil {
41+ t.Fatal(err)
42+ }
43+ select {
44+ case got := <-ran:
45+ if got != "do a thing" {
46+ t.Fatalf("runner got task %q; want %q", got, "do a thing")
47+ }
48+ case <-time.After(time.Second):
49+ t.Fatal("runner did not start")
50+ }
51+ a.Wait()
52+ if a.Status() != StatusDone {
53+ t.Fatalf("status %s; want done", a.Status())
54+ }
55+ if got := a.Transcript(); len(got) != 2 || got[0] != "line one" || got[1] != "line two" {
56+ t.Fatalf("transcript = %q", got)
57+ }
58+ if !strings.Contains(a.ID, "do-a-thing") {
59+ t.Fatalf("id %q missing slug", a.ID)
60+ }
61+ if a.Branch != "swarm/"+a.ID {
62+ t.Fatalf("branch = %q", a.Branch)
63+ }
64+}
65+
66+func TestSpawnEmptyTaskFails(t *testing.T) {
67+ f := newTestSwarm(t, func(a *Agent) Runner {
68+ return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
69+ })
70+ if _, err := f.Spawn(context.Background(), " "); err == nil {
71+ t.Fatal("expected error on empty task")
72+ }
73+}
74+
75+func TestRunnerErrorMarksFailed(t *testing.T) {
76+ wantErr := errors.New("boom")
77+ f := newTestSwarm(t, func(a *Agent) Runner {
78+ return RunnerFunc(func(ctx context.Context, sink Sink) error { return wantErr })
79+ })
80+ a, _ := f.Spawn(context.Background(), "explode")
81+ a.Wait()
82+ if a.Status() != StatusFailed {
83+ t.Fatalf("status %s; want failed", a.Status())
84+ }
85+ if !errors.Is(a.Err(), wantErr) {
86+ t.Fatalf("err = %v", a.Err())
87+ }
88+ if !strings.Contains(a.Activity(), "boom") {
89+ t.Fatalf("activity = %q; want it to mention the error", a.Activity())
90+ }
91+}
92+
93+func TestStopCancelsRunningAgent(t *testing.T) {
94+ started := make(chan struct{})
95+ f := newTestSwarm(t, func(a *Agent) Runner {
96+ return RunnerFunc(func(ctx context.Context, sink Sink) error {
97+ close(started)
98+ <-ctx.Done()
99+ return ctx.Err()
100+ })
101+ })
102+ a, _ := f.Spawn(context.Background(), "long")
103+ <-started
104+ if err := f.Stop(a.ID); err != nil {
105+ t.Fatal(err)
106+ }
107+ a.Wait()
108+ if a.Status() != StatusKilled {
109+ t.Fatalf("status = %s; want killed", a.Status())
110+ }
111+}
112+
113+func TestStopAfterDoneIsNoop(t *testing.T) {
114+ f := newTestSwarm(t, func(a *Agent) Runner {
115+ return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
116+ })
117+ a, _ := f.Spawn(context.Background(), "quick")
118+ a.Wait()
119+ if err := f.Stop(a.ID); err != nil {
120+ t.Fatalf("stop after done: %v", err)
121+ }
122+ if a.Status() != StatusDone {
123+ t.Fatalf("status flipped to %s", a.Status())
124+ }
125+}
126+
127+func TestGetPrefixMatch(t *testing.T) {
128+ f := newTestSwarm(t, func(a *Agent) Runner {
129+ return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
130+ })
131+ a, _ := f.Spawn(context.Background(), "alpha task")
132+ a.Wait()
133+ // Full id works.
134+ if got := f.Get(a.ID); got != a {
135+ t.Fatal("get by full id failed")
136+ }
137+ // Slug prefix works as long as it's unique.
138+ if got := f.Get("alpha"); got != a {
139+ t.Fatal("get by prefix failed")
140+ }
141+ // Bogus id returns nil.
142+ if got := f.Get("zzz-nope"); got != nil {
143+ t.Fatalf("expected nil; got %#v", got)
144+ }
145+}
146+
147+func TestRemoveRequiresTerminalState(t *testing.T) {
148+ hold := make(chan struct{})
149+ f := newTestSwarm(t, func(a *Agent) Runner {
150+ return RunnerFunc(func(ctx context.Context, sink Sink) error {
151+ <-hold
152+ return nil
153+ })
154+ })
155+ a, _ := f.Spawn(context.Background(), "still going")
156+ // Wait for run goroutine to flip to running.
157+ for i := 0; i < 100 && a.Status() != StatusRunning; i++ {
158+ time.Sleep(time.Millisecond)
159+ }
160+ if err := f.Remove(a.ID); err == nil {
161+ t.Fatal("remove of running agent should fail")
162+ }
163+ close(hold)
164+ a.Wait()
165+ if err := f.Remove(a.ID); err != nil {
166+ t.Fatalf("remove after done: %v", err)
167+ }
168+ if got := f.Get(a.ID); got != nil {
169+ t.Fatal("agent still present after remove")
170+ }
171+}
172+
173+func TestSnapshotIsStableAcrossAccess(t *testing.T) {
174+ var wg sync.WaitGroup
175+ wg.Add(1)
176+ f := newTestSwarm(t, func(a *Agent) Runner {
177+ return RunnerFunc(func(ctx context.Context, sink Sink) error {
178+ defer wg.Done()
179+ for i := 0; i < 200; i++ {
180+ sink.Transcript("chunk")
181+ sink.Activity("step")
182+ }
183+ return nil
184+ })
185+ })
186+ a, _ := f.Spawn(context.Background(), "race")
187+ // Hammer Snapshot while the runner is writing; the -race detector
188+ // is the real assertion here.
189+ stop := make(chan struct{})
190+ go func() {
191+ for {
192+ select {
193+ case <-stop:
194+ return
195+ default:
196+ _ = a.Snapshot()
197+ }
198+ }
199+ }()
200+ wg.Wait()
201+ a.Wait()
202+ close(stop)
203+ if a.Status() != StatusDone {
204+ t.Fatalf("status = %s", a.Status())
205+ }
206+}
207+
208+func TestTaskSlug(t *testing.T) {
209+ cases := map[string]string{
210+ "fix the login form": "fix-the-login-form",
211+ " weird --- spaces!! ": "weird-spaces",
212+ "": "agent",
213+ "a-very-long-task-name-that-overflows": "a-very-long-task-name-th",
214+ }
215+ for in, want := range cases {
216+ if got := taskSlug(in); got != want {
217+ t.Errorf("taskSlug(%q) = %q; want %q", in, got, want)
218+ }
219+ }
220+}
221+
222+func TestSnapshotAllSorted(t *testing.T) {
223+ f := newTestSwarm(t, func(a *Agent) Runner {
224+ return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
225+ })
226+ a1, _ := f.Spawn(context.Background(), "first")
227+ // Force second spawn into a later nanosecond bucket.
228+ time.Sleep(2 * time.Millisecond)
229+ a2, _ := f.Spawn(context.Background(), "second")
230+ a1.Wait()
231+ a2.Wait()
232+ snaps := f.SnapshotAll()
233+ if len(snaps) != 2 {
234+ t.Fatalf("want 2 snapshots; got %d", len(snaps))
235+ }
236+ if !snaps[0].Started.Before(snaps[1].Started) && !snaps[0].Started.Equal(snaps[1].Started) {
237+ t.Fatal("snapshots not in spawn order")
238+ }
239+}
A internal/swarm/testdata/cmd/stubchild/main.go +145 -0
@@ -0,0 +1,145 @@
1+// stubchild is a minimal swarm-agent stand-in used by the runner
2+// end-to-end test. It speaks the daemon protocol the real zot
3+// binary will implement next:
4+//
5+// - parses --swarm-agent <path>, --session <path>, --cwd <path>,
6+// and an optional positional task,
7+// - opens a unix-socket listener at the inbox path so the
8+// supervisor's Inbox can dial through,
9+// - emits well-formed JSONL events on stdout that the supervisor
10+// mirrors into the durable event log,
11+// - reads one line per supervisor message and echoes it back as
12+// a "user_message" event followed by a fake "assistant_message"
13+// so the runner sees the dialogue happen.
14+//
15+// The runner test compiles this binary into a tempdir, points
16+// swarm's execRunner at it via Command, and asserts the events
17+// flow through correctly without needing the full zot model
18+// machinery.
19+package main
20+
21+import (
22+ "bufio"
23+ "encoding/json"
24+ "flag"
25+ "fmt"
26+ "net"
27+ "os"
28+ "sync"
29+ "time"
30+)
31+
32+func main() {
33+ var (
34+ inboxPath string
35+ sessionPath string
36+ cwd string
37+ )
38+ flag.StringVar(&inboxPath, "swarm-agent", "", "inbox socket path")
39+ flag.StringVar(&sessionPath, "session", "", "session file path")
40+ flag.StringVar(&cwd, "cwd", "", "working directory")
41+ flag.Parse()
42+
43+ if inboxPath == "" {
44+ fmt.Fprintln(os.Stderr, "stubchild: --swarm-agent required")
45+ os.Exit(2)
46+ }
47+
48+ emit := newEmitter()
49+
50+ // Open the inbox listener BEFORE doing any work so the supervisor
51+ // can dial through as soon as it sees the agent_ready event. If we
52+ // processed the initial task first, the parent's SendInput call
53+ // would race the stub's net.Listen and trip ErrNotReady.
54+ ln, err := net.Listen("unix", inboxPath)
55+ if err != nil {
56+ emit("error", map[string]any{"message": err.Error()})
57+ os.Exit(1)
58+ }
59+ defer os.Remove(inboxPath)
60+
61+ emit("agent_ready", map[string]any{
62+ "inbox": inboxPath,
63+ "session": sessionPath,
64+ "cwd": cwd,
65+ })
66+
67+ // Initial task lives in the positional. Process it as the first
68+ // user turn so the supervisor's "initial task" path is exercised.
69+ if task := flag.Arg(0); task != "" {
70+ runTurn(emit, task, 0)
71+ }
72+
73+ turn := 1
74+ for {
75+ c, err := ln.Accept()
76+ if err != nil {
77+ return
78+ }
79+ br := bufio.NewReader(c)
80+ for {
81+ line, err := br.ReadString('\n')
82+ if line != "" {
83+ msg := trimNL(line)
84+ switch {
85+ case msg == "shutdown":
86+ emit("agent_stopped", map[string]any{"reason": "shutdown"})
87+ _ = c.Close()
88+ return
89+ case msg == "cancel":
90+ emit("turn_end", map[string]any{"stop": "cancelled"})
91+ case len(msg) > 5 && msg[:5] == "user ":
92+ runTurn(emit, msg[5:], turn)
93+ turn++
94+ }
95+ }
96+ if err != nil {
97+ _ = c.Close()
98+ break
99+ }
100+ }
101+ }
102+}
103+
104+// runTurn fakes one model round-trip: turn_start, an echoed
105+// user_message, an assistant_message that echoes back, and a
106+// turn_end. Enough event variety that applyEventToSink has
107+// something to interpret.
108+func runTurn(emit emitter, text string, step int) {
109+ emit("turn_start", map[string]any{"step": step})
110+ emit("user_message", map[string]any{
111+ "content": []any{
112+ map[string]any{"type": "text", "text": text},
113+ },
114+ })
115+ emit("assistant_message", map[string]any{
116+ "content": []any{
117+ map[string]any{"type": "text", "text": "echo: " + text},
118+ },
119+ })
120+ emit("turn_end", map[string]any{"stop": "end"})
121+}
122+
123+type emitter = func(string, map[string]any)
124+
125+func newEmitter() emitter {
126+ var mu sync.Mutex
127+ enc := json.NewEncoder(os.Stdout)
128+ return func(typ string, data map[string]any) {
129+ mu.Lock()
130+ defer mu.Unlock()
131+ if data == nil {
132+ data = map[string]any{}
133+ }
134+ data["type"] = typ
135+ data["time"] = time.Now().Format(time.RFC3339Nano)
136+ _ = enc.Encode(data)
137+ }
138+}
139+
140+func trimNL(s string) string {
141+ for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
142+ s = s[:len(s)-1]
143+ }
144+ return s
145+}
A internal/swarm/worktree.go +110 -0
@@ -0,0 +1,110 @@
1+package swarm
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+)
11+
12+// gitWorktree creates per-agent working directories via `git worktree
13+// add`. If the repo path isn't a git repository it falls back to a
14+// plain mkdir under root/<id>, which is still useful for tests and
15+// for running agents in non-git directories.
16+type gitWorktree struct {
17+ root string // <swarm-root>/worktrees
18+ repo string // user CWD; git operations resolve from here
19+}
20+
21+// Create makes a new worktree on branch off of HEAD. The branch is
22+// created if it doesn't already exist. Returns the absolute path.
23+func (g *gitWorktree) Create(id, branch, base string) (string, error) {
24+ dir := filepath.Join(g.root, id)
25+ if err := os.MkdirAll(g.root, 0o755); err != nil {
26+ return "", err
27+ }
28+ if !isGitRepo(g.repo) {
29+ // Non-git fallback: just a fresh directory. The agent works
30+ // here; nothing is staged for merge.
31+ if err := os.MkdirAll(dir, 0o755); err != nil {
32+ return "", err
33+ }
34+ return dir, nil
35+ }
36+ cmd := exec.Command("git", "worktree", "add", "-b", branch, dir)
37+ cmd.Dir = g.repo
38+ out, err := cmd.CombinedOutput()
39+ if err != nil {
40+ // If the branch already exists (e.g. a leftover from a
41+ // previous run), retry without -b so the user doesn't need
42+ // to clean up by hand.
43+ if strings.Contains(string(out), "already exists") {
44+ cmd = exec.Command("git", "worktree", "add", dir, branch)
45+ cmd.Dir = g.repo
46+ out, err = cmd.CombinedOutput()
47+ }
48+ }
49+ if err != nil {
50+ return "", fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), err)
51+ }
52+ return dir, nil
53+}
54+
55+// Remove deletes the worktree. Uses `git worktree remove --force` so
56+// dirty trees don't block cleanup; the user is the one running this
57+// command explicitly.
58+func (g *gitWorktree) Remove(id, dir string) error {
59+ if dir == "" {
60+ return errors.New("empty worktree dir")
61+ }
62+ if !isGitRepo(g.repo) {
63+ return os.RemoveAll(dir)
64+ }
65+ cmd := exec.Command("git", "worktree", "remove", "--force", dir)
66+ cmd.Dir = g.repo
67+ out, err := cmd.CombinedOutput()
68+ if err != nil {
69+ // `git worktree remove` refuses to operate on an unknown path;
70+ // in that case fall back to a plain rmdir so the user can
71+ // always clean up.
72+ if strings.Contains(string(out), "not a working tree") {
73+ return os.RemoveAll(dir)
74+ }
75+ return fmt.Errorf("git worktree remove: %s: %w", strings.TrimSpace(string(out)), err)
76+ }
77+ return nil
78+}
79+
80+func isGitRepo(dir string) bool {
81+ if dir == "" {
82+ return false
83+ }
84+ cmd := exec.Command("git", "-C", dir, "rev-parse", "--git-dir")
85+ return cmd.Run() == nil
86+}
87+
88+// memWorktree is a WorktreeManager used by tests. It does no git
89+// operations; it just makes a fresh subdirectory under root.
90+type memWorktree struct{ root string }
91+
92+// MemWorktree returns a WorktreeManager that creates plain
93+// subdirectories. Exposed for tests and for callers running zot
94+// outside a git repository.
95+func MemWorktree(root string) WorktreeManager { return &memWorktree{root: root} }
96+
97+func (m *memWorktree) Create(id, branch, base string) (string, error) {
98+ dir := filepath.Join(m.root, id)
99+ if err := os.MkdirAll(dir, 0o755); err != nil {
100+ return "", err
101+ }
102+ return dir, nil
103+}
104+
105+func (m *memWorktree) Remove(id, dir string) error {
106+ if dir == "" {
107+ return errors.New("empty worktree dir")
108+ }
109+ return os.RemoveAll(dir)
110+}
M internal/tui/render.go +30 -0
@@ -382,6 +382,20 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
382 cursorTargetRow = len(chatFrame) + cursorBottomRow382 cursorTargetRow = len(chatFrame) + cursorBottomRow
383 }383 }
384 384
385+ // Idle no-op fast path. When the buffer AND the cursor position
386+ // haven't changed since the last DrawLog, emit nothing. The
387+ // alternative — always writing SeqHideCursor + cursor-position +
388+ // SeqShowCursor — resets the terminal's cursor blink timer on
389+ // every tick. At our 120ms animation cadence that means the
390+ // caret in an idle dialog editor (e.g. an open swarm transcript
391+ // for an agent that's currently idle) appears as a solid block
392+ // that never blinks, because we keep "showing" it before the
393+ // terminal can blink it off. Bailing out here lets the OS run
394+ // its blink cycle.
395+ if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
396+ return
397+ }
398+
385 var w strings.Builder399 var w strings.Builder
386 w.WriteString(SeqSynchronizedOn)400 w.WriteString(SeqSynchronizedOn)
387 w.WriteString(SeqHideCursor)401 w.WriteString(SeqHideCursor)
@@ -612,6 +626,22 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
612 r.cursorCol = cursorCol626 r.cursorCol = cursorCol
613 }627 }
614 628
629+// sameLines reports whether two []string have the exact same
630+// length and per-row contents. Used by DrawLog's idle no-op fast
631+// path; cheap enough at our frame rates and far simpler than
632+// hashing every byte.
633+func sameLines(a, b []string) bool {
634+ if len(a) != len(b) {
635+ return false
636+ }
637+ for i := range a {
638+ if a[i] != b[i] {
639+ return false
640+ }
641+ }
642+ return true
643+}
644+
615 func writeBlock(w *strings.Builder, lines []string) {645 func writeBlock(w *strings.Builder, lines []string) {
616 for i, line := range lines {646 for i, line := range lines {
617 w.WriteString("\x1b[0m")647 w.WriteString("\x1b[0m")
A internal/tui/render_test.go +103 -0
@@ -0,0 +1,103 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+)
8+
9+// TestDrawLogIdleNoOpEmitsNothing pins the cursor-blink fix: when
10+// DrawLog is called with the exact same buffer and cursor position
11+// as the previous call, it must emit ZERO bytes.
12+//
13+// The bug this regresses: at the 120ms animation tick the renderer
14+// used to always emit SeqHideCursor + cursor-position +
15+// SeqShowCursor, which resets the terminal's blink timer. Faster
16+// than the OS blink interval, so an idle dialog editor (e.g. a
17+// re-opened swarm transcript whose agent isn't producing output)
18+// rendered the caret as a solid non-blinking block.
19+//
20+// With the no-op fast path the renderer leaves the screen alone
21+// on idle frames, letting the terminal run its own blink cycle.
22+func TestDrawLogIdleNoOpEmitsNothing(t *testing.T) {
23+ var buf bytes.Buffer
24+ r := NewRenderer(&buf)
25+ r.Resize(80, 24)
26+
27+ chat := []string{"hello", "world"}
28+ bottom := []string{"▌ "}
29+ // First draw populates the renderer's cached buffer.
30+ r.DrawLog(chat, bottom, 0, 2)
31+ first := buf.Len()
32+ if first == 0 {
33+ t.Fatal("first DrawLog wrote nothing; setup is broken")
34+ }
35+ buf.Reset()
36+
37+ // Identical second draw: same content, same cursor placement.
38+ r.DrawLog(chat, bottom, 0, 2)
39+ if buf.Len() != 0 {
40+ t.Fatalf("idle re-draw emitted %d bytes; expected 0 so terminal blink keeps ticking\n%q",
41+ buf.Len(), buf.String())
42+ }
43+}
44+
45+// TestDrawLogContentChangeBreaksFastPath proves the no-op fast path
46+// only fires when nothing changed. A buffer mutation must still
47+// produce output, otherwise streaming agent replies would freeze on
48+// screen.
49+func TestDrawLogContentChangeBreaksFastPath(t *testing.T) {
50+ var buf bytes.Buffer
51+ r := NewRenderer(&buf)
52+ r.Resize(80, 24)
53+
54+ r.DrawLog([]string{"hello"}, []string{"▌ "}, 0, 2)
55+ buf.Reset()
56+
57+ // New chat row lands.
58+ r.DrawLog([]string{"hello", "world"}, []string{"▌ "}, 0, 2)
59+ if buf.Len() == 0 {
60+ t.Fatal("content change suppressed by fast path; streaming output would freeze")
61+ }
62+}
63+
64+// TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change
65+// (no buffer change) still produces output. Without this, typing in
66+// the editor would visually move the caret but the terminal would
67+// keep drawing it at the old column.
68+func TestDrawLogCursorMoveBreaksFastPath(t *testing.T) {
69+ var buf bytes.Buffer
70+ r := NewRenderer(&buf)
71+ r.Resize(80, 24)
72+
73+ r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2)
74+ buf.Reset()
75+
76+ // Same buffer, different cursor column.
77+ r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 3)
78+ if buf.Len() == 0 {
79+ t.Fatal("cursor-only change suppressed by fast path; caret would lag behind typing")
80+ }
81+ // And the emitted bytes must at least reposition the cursor.
82+ if !strings.Contains(buf.String(), "\x1b[") {
83+ t.Errorf("cursor move emission missing CSI escapes: %q", buf.String())
84+ }
85+}
86+
87+// TestDrawLogResizeForcesFullRedraw confirms a resize invalidates
88+// the cache so the next DrawLog with identical inputs still emits.
89+// Resize sets logInit=false; without that, a resize followed by an
90+// identical buffer would falsely no-op and leave a stale frame.
91+func TestDrawLogResizeForcesFullRedraw(t *testing.T) {
92+ var buf bytes.Buffer
93+ r := NewRenderer(&buf)
94+ r.Resize(80, 24)
95+ r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2)
96+ buf.Reset()
97+
98+ r.Resize(100, 30)
99+ r.DrawLog([]string{"hi"}, []string{"▌ "}, 0, 2)
100+ if buf.Len() == 0 {
101+ t.Fatal("post-resize redraw skipped; the new frame would never reach the terminal")
102+ }
103+}

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